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
+37
View File
@@ -24,6 +24,7 @@ import type {
OmpConfig,
SessionRemote,
SessionDocker,
PaneExit,
} from './types.js';
/**
@@ -56,6 +57,23 @@ export interface MuxSession {
respawnConfig?: PersistedRespawnConfig;
/** Whether Ralph / Todo tracking is enabled */
ralphEnabled?: boolean;
/**
* This record was rebuilt from the tmux socket rather than from Codeman's own
* bookkeeping, so everything on it but the name and the pid is a guess. Its
* synthetic `restored-<fragment>` id cannot find the session's `state.json`
* entry either, which means a remote or docker session rediscovered this way
* arrives with no `remote`/`docker` metadata and looks local. Anything that
* would be WRONG about such a session rather than merely vague must fail
* closed on this flag.
*
* ⚠ It is PERMANENT, not merely true for the boot that rediscovered the
* session: `saveSessions()` serializes the whole record to
* `mux-sessions.json` and `loadSessions()` restores it, so a genuinely local
* session rediscovered once stays opted out of everything keyed on this for
* the life of that record. That is the safe direction to fail, and it costs
* only the guess Codeman is declining to make.
*/
discovered?: boolean;
}
/**
@@ -180,6 +198,7 @@ export interface PaneCaptureOptions {
* - `sessionKilled` (data: { sessionId: string }) - Session terminated
* - `sessionDied` (data: { sessionId: string }) - Session died unexpectedly
* - `statsUpdated` (sessions: MuxSessionWithStats[]) - Stats refreshed
* - `paneExitsUpdated` () - A pane read finished; ask `getPaneExit()` per session
*/
export interface TerminalMultiplexer extends EventEmitter {
/** Which backend this instance uses */
@@ -294,6 +313,24 @@ export interface TerminalMultiplexer extends EventEmitter {
/** Check if the pane in a session is dead (command exited but remain-on-exit keeps it alive) */
isPaneDead(muxName: string): boolean;
/**
* What the last pane read saw of this session's agent, or `undefined` for
* UNKNOWN (Ark0N/Codeman#446). Unlike `isPaneDead()` this costs nothing: it
* reads a map the batched watcher fills, so it answers no fresher than that
* watcher's interval and the three synchronous `isPaneDead()` callers still
* need their own probe. See {@link PaneExit}.
*/
getPaneExit?(muxName: string): PaneExit | undefined;
/** Forget a session's exit observation, e.g. once its pane has been respawned. */
clearPaneExit?(muxName: string): void;
/** Start polling every pane on the socket for an exited agent. */
startPaneExitWatcher?(intervalMs?: number): void;
/** Stop the pane-exit watcher. */
stopPaneExitWatcher?(): void;
/** Respawn a dead pane with a fresh command. Returns the new PID or null on failure. */
respawnPane(options: RespawnPaneOptions): Promise<number | null>;
+117
View File
@@ -60,6 +60,7 @@ import {
type SessionDocker,
type SessionNameSource,
type SessionWriteOptions,
type PaneExit,
} from './types.js';
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
import { claudeTranscriptExists } from './utils/claude-transcript.js';
@@ -544,6 +545,20 @@ export class Session extends EventEmitter {
private _mux: TerminalMultiplexer | null = null;
private _muxSession: MuxSession | null = null;
private _useMux: boolean = false;
/**
* The agent in this session's local tmux pane has exited (Ark0N/Codeman#446).
* `null` is the UNKNOWN arm of the tri-state and is what {@link setPaneExit}
* stores for every session shape the field does not apply to. See
* {@link PaneExit} for the shapes and for why an unknown answer must never be
* rendered as "alive".
*/
private _paneExit: PaneExit | null = null;
/**
* This session was rebuilt from the tmux socket rather than from Codeman's
* own records, so its `remote`/`docker` metadata is missing rather than known
* to be absent. See {@link MuxSession.discovered}.
*/
private _discoveredMuxSession = false;
// Flag to prevent new timers after session is stopped
private _isStopped: boolean = false;
@@ -720,6 +735,10 @@ export class Session extends EventEmitter {
lastSubmitAt?: number;
/** Restored conversation chain, oldest first (see `claudeSessionChain`). */
claudeSessionChain?: string[];
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
paneExit?: PaneExit;
/** This session was rebuilt from the tmux socket, so its metadata is a guess. */
discoveredMuxSession?: boolean;
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
lastActivityAt?: number;
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
@@ -872,6 +891,16 @@ export class Session extends EventEmitter {
this._remote = config.remote;
this._docker = config.docker;
this._owner = config.owner;
this._discoveredMuxSession = config.discoveredMuxSession === true;
// Restored so a record that says the agent exited survives a server restart
// rather than being blanked by the first persist after boot. It runs here
// because the scoping reads `_remote`, `_docker` and the mux fields, all of
// which are set by now. It is a claim about a pane this process has not
// looked at yet, so every path that starts or re-attaches a pane drops it
// (see `_setupOrAttachMuxSession`) and the pane-exit watcher's own tick
// replaces it with a first-hand reading. NOT the stats collector, which a
// browser panel arms and disarms — see `startPaneExitWatcher`.
this.setPaneExit(config.paneExit);
// Never self-parent: a session pointing at itself would draw a zero-length
// lineage arc under its own tab. Only reachable via the recovery path, where
// both the id and the saved parent come from disk.
@@ -1099,6 +1128,75 @@ export class Session extends EventEmitter {
return this._muxSession?.muxName ?? null;
}
/**
* True when a tmux pane's death would mean THIS session's agent has exited.
*
* Four shapes fail the test, and each would otherwise publish a death that is
* not the agent's. A direct-PTY session owns no pane at all. 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 #355 was about. A docker case's
* local pane holds a `docker exec` into the container's own tmux.
*
* The fourth is a session rebuilt from the socket. Absent `remote`/`docker`
* normally means "this is local", but on a discovered record it only means
* "Codeman never found the metadata": the synthetic `restored-<fragment>` id
* matches no `state.json` entry, so a remote session rediscovered after
* `mux-sessions.json` was lost arrives looking local, and its next transport
* drop would be published as an agent exit. Unproven locality fails closed.
*/
private get paneExitApplies(): boolean {
if (this._discoveredMuxSession) return false;
return this._useMux && this._muxSession !== null && !this._remote && !this._docker;
}
/** What Codeman last observed of this pane's agent, or undefined for UNKNOWN. */
get paneExit(): PaneExit | undefined {
return this._paneExit ?? undefined;
}
/**
* Forget this pane's exit, on both this record and the mux layer's cache.
* Every path that starts or relaunches a command in the pane calls it, and
* the mux half also invalidates a pane read already in flight.
*
* It does not persist or broadcast by itself; the caller owns both. ⚠ That
* caller MUST persist, and the pane-exit watcher is not a fallback for it:
* the watcher's next tick reads UNKNOWN, finds this field already cleared,
* reports no change and therefore writes nothing, so a caller that only
* broadcasts leaves `state.json` saying the agent exited for as long as the
* session stays quiet. `/interactive` and `/shell` did exactly that until
* Ark0N/Codeman#446 review; both now persist on their success path.
*/
private clearPaneExitForNewPane(): void {
this.setPaneExit(undefined);
if (this._muxSession) this._mux?.clearPaneExit?.(this._muxSession.muxName);
}
/**
* Record what the mux layer observed of this pane's agent, and say whether
* that changed the answer. The caller persists and broadcasts on a true.
*
* A session the field does not apply to is forced to UNKNOWN here rather than
* at the reporting end, so the rule lives in one place and the mux layer stays
* free to report the raw pane reading its own remote-reconnect watcher needs.
*/
setPaneExit(next: PaneExit | undefined): boolean {
const resolved = this.paneExitApplies ? (next ?? null) : null;
const prev = this._paneExit;
if (prev === resolved) return false;
if (
prev !== null &&
resolved !== null &&
prev.status === resolved.status &&
prev.signal === resolved.signal &&
prev.at === resolved.at
) {
return false;
}
this._paneExit = resolved;
return true;
}
/**
* True when this session's PTY is a tmux client rather than the program itself.
* Read by the replay-side alt-screen strip, which must apply the same
@@ -1622,6 +1720,10 @@ export class Session extends EventEmitter {
// by the constructor: a Codeman restart starts with a fresh breaker so boot
// recovery can re-attach.
respawnBlocked: this._respawnBlocked || undefined,
// Ark0N/Codeman#446 — the agent in this pane has exited, published here so
// it rides the existing `session:updated` broadcast and lands in state.json
// through the same persist. `status` and `pid` above stay untouched by it.
paneExit: this._paneExit ?? undefined,
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
lastSubmitAt: this._lastSubmitAt || undefined,
// Only a chain the CLI's own hooks vouched for is persisted, and only when
@@ -1762,6 +1864,14 @@ export class Session extends EventEmitter {
}
}
// Whatever the last reading said about the OLD command in this pane is now
// history: the branch above either respawned the pane or found it alive, and
// the branch below creates a new one. The paths that reach here are boot
// recovery and an explicit start, NOT a click on an exited tab — the browser
// re-attaches only on a null pid, and the premise of Ark0N/Codeman#446 is
// that an exited pane keeps its pid. `restartCli()` clears separately.
this.clearPaneExitForNewPane();
// Check if we already have a mux session (restored session)
const isRestored = this._muxSession !== null && !needsNewSession;
if (isRestored) {
@@ -1867,6 +1977,9 @@ export class Session extends EventEmitter {
console.error('[Session] reattachRemote: respawnPane failed for', this._muxSession.muxName);
return false;
}
// No-op for the record (a remote session's field is always UNKNOWN), but the
// mux layer's cache is keyed by muxName and this pane now runs a new client.
this.clearPaneExitForNewPane();
console.log('[Session] reattachRemote: reattached remote session', this._muxSession.muxName, 'pid', newPid);
return true;
}
@@ -1907,6 +2020,10 @@ export class Session extends EventEmitter {
return false;
}
this._pendingEnvUnsets.clear();
// A relaunch in the same pane, so any exit observed of the previous command
// is history. Without this the caller's persist-and-broadcast writes the old
// exit straight back onto a session that is running again.
this.clearPaneExitForNewPane();
console.log('[Session] restartCli: restarted CLI for', this._muxSession.muxName, 'pid', newPid);
return true;
}
+365 -15
View File
@@ -58,6 +58,7 @@ import {
type SessionRemote,
type SessionDocker,
type DockerCommandMode,
type PaneExit,
} from './types.js';
import { getCli } from './config/cli-registry/registry.js';
import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js';
@@ -152,6 +153,16 @@ 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. 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.
* ⚠ It does NOT bound a read: EXEC_TIMEOUT_MS is 5000 ms, so a slow read can
* outlive two ticks, which is exactly why `paneExitReadInFlight` exists.
*/
const DEFAULT_PANE_EXIT_INTERVAL_MS = 2000;
/** Default remote-reconnect watcher poll interval (5 seconds) — COD-108 */
const DEFAULT_REMOTE_RECONNECT_INTERVAL_MS = 5000;
@@ -219,8 +230,18 @@ const DEFAULT_CODEMAN_TMUX_SOCKET = DEFAULT_TMUX_SOCKET;
*/
const PANE_LIST_SEP = '|';
/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */
const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`;
/**
* Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneRows}.
*
* The three `pane_dead*` fields carry the agent-exit signal of Ark0N/Codeman#446.
* Appending them is backward compatible in both directions. A tmux that does not
* know a variable substitutes the empty string rather than failing, which is how
* tmux 3.2a answers `#{pane_dead_signal}` (added in 3.4), and the parser reads a
* short row as "pid known, deadness unknown" rather than discarding it.
*/
const PANE_LIST_FORMAT =
`#{session_name}${PANE_LIST_SEP}#{pane_pid}` +
`${PANE_LIST_SEP}#{pane_dead}${PANE_LIST_SEP}#{pane_dead_status}${PANE_LIST_SEP}#{pane_dead_signal}`;
/**
* 构建 pane 启动前的 nofile 修复命令。
@@ -235,26 +256,141 @@ export function buildNofileLimitCommand(targetLimit = CLAUDE_CODE_NOFILE_LIMIT):
return `ulimit -Sn ${safeLimit} 2>/dev/null || ulimit -n ${safeLimit} 2>/dev/null || true`;
}
/** One pane of one tmux session, as {@link parsePaneRows} reads it off the wire. */
export interface PaneRow {
/** tmux session this pane belongs to. Repeats once per pane of a split session. */
sessionName: string;
/** `#{pane_pid}` — the process tmux started in the pane. */
pid: number;
/** `#{pane_dead}` — true for 1, false for 0, undefined when tmux said nothing. */
dead?: boolean;
/** `#{pane_dead_status}` — the exit code, absent when tmux reported none. */
exitStatus?: number;
/** `#{pane_dead_signal}` — the killing signal, absent before tmux 3.4 and when unsignalled. */
exitSignal?: number;
}
/**
* Parse the output of `tmux list-panes -a -F '#{session_name}|#{pane_pid}'`
* into a Map of session-name → pane pid. Exported for unit testing.
* One pane-exit reading, with the pane pid that produced it.
*
* The pid never leaves this module. It is what distinguishes "the same dead
* pane, seen again" from "a second command in the same pane that also exited
* with the same status", so the `at` stamp can hold across the first and must
* not across the second. {@link PaneExit} itself stays free of it: the pid on
* the session record is the attach client's, and a second pid there would
* invite exactly the confusion Ark0N/Codeman#446 is about.
*/
export interface PaneExitObservation {
/** `#{pane_pid}` of the pane this reading came from. */
panePid: number;
/** What to publish on the session record. */
exit: PaneExit;
}
/** Read one optional numeric field; a blank or non-numeric value is "not reported". */
function paneField(fields: string[], index: number): number | undefined {
const raw = fields[index];
if (raw === undefined || raw === '') return undefined;
const value = parseInt(raw, 10);
return Number.isNaN(value) ? undefined : value;
}
/**
* Parse the output of `tmux list-panes -a -F` under {@link PANE_LIST_FORMAT}
* into one row per pane, in tmux's own order. Exported for unit testing.
*
* - Skips empty lines and lines without the separator.
* - Skips entries with a non-numeric pid or empty name.
* - Leaves every field after the pid undefined when it is blank or absent, so a
* row from an older tmux still yields its pid.
*/
export function parsePaneList(output: string): Map<string, number> {
const result = new Map<string, number>();
export function parsePaneRows(output: string): PaneRow[] {
const rows: PaneRow[] = [];
for (const line of output.split('\n')) {
if (!line) continue;
const sep = line.indexOf(PANE_LIST_SEP);
if (sep === -1) continue;
const name = line.slice(0, sep);
const pid = parseInt(line.slice(sep + 1), 10);
if (name && !Number.isNaN(pid)) {
result.set(name, pid);
}
if (!line.includes(PANE_LIST_SEP)) continue;
const fields = line.split(PANE_LIST_SEP);
const sessionName = fields[0];
const pid = parseInt(fields[1] ?? '', 10);
if (!sessionName || Number.isNaN(pid)) continue;
const deadFlag = fields[2];
rows.push({
sessionName,
pid,
dead: deadFlag === '1' ? true : deadFlag === '0' ? false : undefined,
exitStatus: paneField(fields, 3),
exitSignal: paneField(fields, 4),
});
}
return result;
return rows;
}
/**
* Decide, from every pane tmux listed, which tmux sessions have an exited agent.
* Exported for unit testing. Returns one entry per session with a known answer;
* a session absent from the map is UNKNOWN, which must never render as alive.
*
* Two rules make a positive answer trustworthy:
*
* A session answers only when tmux listed EXACTLY ONE pane for it. Codeman
* creates one pane per session and `isPaneDead()` reads one pane, so a session
* the user has split by hand has no single "the agent" to report on, and
* guessing which of its panes speaks for the session could report a live
* session as exited.
*
* A pane answers only when `#{pane_dead}` said 1 or 0. An empty field is a tmux
* that did not answer, not a live pane.
*
* `status` and `signal` stay absent when tmux did not report them. Measured on
* tmux 3.2a, a SIGKILLed pane reports neither, so folding an absent status into
* 0 would turn an unexplained death into a clean exit.
*/
export function derivePaneExits(rows: PaneRow[], now: number): Map<string, PaneExitObservation> {
const panesPerSession = new Map<string, number>();
for (const row of rows) {
panesPerSession.set(row.sessionName, (panesPerSession.get(row.sessionName) ?? 0) + 1);
}
const exits = new Map<string, PaneExitObservation>();
for (const row of rows) {
if (panesPerSession.get(row.sessionName) !== 1) continue;
if (row.dead !== true) continue;
exits.set(row.sessionName, {
panePid: row.pid,
exit: {
...(row.exitStatus !== undefined ? { status: row.exitStatus } : {}),
...(row.exitSignal !== undefined ? { signal: row.exitSignal } : {}),
at: now,
},
});
}
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;
}
/**
@@ -1527,6 +1663,30 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
private mouseSyncInterval: NodeJS.Timeout | null = null;
/** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
private lastPaneCount: Map<string, number> = new Map();
/**
* muxName → the exited agent the pane-exit watcher last observed
* (Ark0N/Codeman#446). Absence is the UNKNOWN arm of the tri-state, so an
* entry goes the moment tmux stops reporting the pane dead, and the map is
* empty until the first read runs. The manager reports what tmux says and
* nothing more: the scoping that hides this for remote and docker sessions
* lives on `Session`, because the remote-reconnect watcher above needs the
* raw pane reading.
*/
private paneExits: Map<string, PaneExitObservation> = new Map();
/** The pane-exit watcher's own interval. Runs whether or not stats are on. */
private paneExitInterval: NodeJS.Timeout | null = null;
/**
* True while a pane read is in flight. `EXEC_TIMEOUT_MS` is 5000 ms against a
* poll interval of 2000 ms, so without this a slow read overlaps the next two
* and the older one can resolve last and win.
*/
private paneExitReadInFlight = false;
/**
* Bumped by every deliberate {@link clearPaneExit}. A read that started before
* a clear carries the older generation and is discarded rather than writing
* the death back over the pane that has just replaced it.
*/
private paneExitGeneration = 0;
// ── COD-108 remote-reconnect watcher state ────────────────────────────────
/** Periodic watcher that re-establishes dropped remote sessions. */
@@ -2297,6 +2457,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
);
// Wait for the respawned process to start
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
// The pane now runs a fresh command, so whatever the last read observed of
// the old one is history. Clearing it here rather than waiting for the next
// poll also invalidates any read already in flight, which would otherwise
// write the old death back over the pane that just replaced it.
this.clearPaneExit(muxName);
const pid = this.getPanePid(muxName);
if (pid) session.pid = pid;
return pid;
@@ -2508,6 +2673,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
this.lastPaneCount.delete(session.muxName);
this.clearPaneExit(session.muxName);
this.sessions.delete(sessionId);
this.clearRemoteReconnectState(sessionId);
this.saveSessions();
@@ -2618,6 +2784,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
this.lastPaneCount.delete(session.muxName);
this.clearPaneExit(session.muxName);
this.sessions.delete(sessionId);
this.clearRemoteReconnectState(sessionId);
this.saveSessions();
@@ -2671,7 +2838,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
active = parsePaneList(output);
const rows = parsePaneRows(output);
active = new Map(rows.map((row) => [row.sessionName, row.pid]));
// The same read answers both questions, so recovery starts with a pane-exit
// reading rather than waiting for the first stats tick — which may never
// come, since the collector only starts when boot found a live session.
if (rows.length > 0) {
this.applyPaneExits(derivePaneExits(rows, Date.now()));
}
} catch (err) {
console.error('[TmuxManager] Failed to list tmux panes:', err);
active = new Map();
@@ -2686,6 +2860,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} else {
dead.push(sessionId);
this.sessions.delete(sessionId);
this.clearPaneExit(session.muxName);
this.clearRemoteReconnectState(sessionId);
this.emit('sessionDied', { sessionId });
}
@@ -2722,6 +2897,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
mode: 'claude',
attached: false,
name: `Restored: ${sessionName}`,
// Every field above except the name and the pid is a guess: this record
// was rebuilt from the socket because Codeman's own bookkeeping did not
// have it. The synthetic id also cannot find the session's state.json
// entry, so a remote or docker session rediscovered this way arrives
// looking local. Consumers that would be wrong about such a session
// read this flag and fail closed — see `Session.paneExitApplies`.
discovered: true,
};
this.sessions.set(sessionId, session);
knownMuxNames.add(sessionName);
@@ -2882,6 +3064,172 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}));
}
/**
* What the last pane read saw of this tmux session's agent. `undefined` is
* the UNKNOWN answer and must never be rendered as "alive": it covers a pane
* that is running, a tmux session that no longer exists, a probe that failed,
* and every poll that has not run yet. See {@link PaneExit}.
*/
getPaneExit(muxName: string): PaneExit | undefined {
return this.paneExits.get(muxName)?.exit;
}
/**
* Re-read every pane on the socket and refresh {@link paneExits}. ONE batched
* `tmux list-panes -a` answers for every session at once, which is why this
* polls rather than probing per session.
*
* A failed or empty probe leaves the previous answers ALONE rather than
* clearing them, because the two cannot be told apart: the command ends in
* `|| true`, so a tmux that errored and a socket with genuinely no panes both
* arrive as empty output. Treating that as "tmux did not answer" is the
* conservative reading — clearing on it would turn a transient failure into a
* silent retraction of a death Codeman had already observed, and the cost of
* being wrong the other way is one stale entry for a socket that no longer
* has the pane. A NON-empty read is different: `list-panes -a` lists
* every pane on the socket, so it is authoritative and {@link applyPaneExits}
* prunes against it.
*
* 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> {
// Nothing on this socket could answer, so do not read 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;
this.paneExitReadInFlight = true;
let rows: PaneRow[];
try {
rows = await this.readPaneRows();
} finally {
this.paneExitReadInFlight = false;
}
if (rows.length === 0) return;
// A pane was respawned or killed while this read was out, so what it saw is
// already history. Dropping it is what stops a freshly respawned pane from
// being republished as exited.
if (generation !== this.paneExitGeneration) return;
this.applyPaneExits(derivePaneExits(rows, now));
}
/**
* Read every pane on the socket. The ONLY part of the pane-exit watcher that
* touches tmux, which is what lets a test subclass drive the guards in
* {@link refreshPaneExits} — the in-flight suppression, the generation
* check, the empty-read retraction rule and the read gate — against rows it
* chooses. Split out for the reason `runRemoteReconnectTick` is: a guard no
* test can reach is a guard that can be deleted without anything failing.
*
* A failed read answers with NO rows, which the caller treats as "tmux did
* not answer" and which therefore retracts nothing.
*/
protected async readPaneRows(): Promise<PaneRow[]> {
// The test-mode gate lives HERE rather than at the top of the tick, so that
// what tests cannot do is spawn a process, not exercise the bookkeeping.
if (IS_TEST_MODE) return [];
try {
// execAsync, not execSync: this runs on a 2000 ms timer, and a synchronous
// exec freezes the port while the process stays alive (see the
// event-loop-monitor note in CLAUDE.md). The three `isPaneDead()` callers
// stay synchronous because each is answering one request right then.
const { stdout } = await execAsync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
return parsePaneRows(stdout.trim());
} catch (err) {
console.error('[TmuxManager] Failed to read pane exit state:', err);
return [];
}
}
/**
* Fold one authoritative observation into {@link paneExits}. Split out from
* the tmux call so the merge rules are unit-testable.
*
* `observed` comes from a read of EVERY pane on the socket, so a session
* missing from it has no exit to report and its entry goes. That is what
* keeps the map from growing without bound as tmux sessions come and go
* outside `killSession()`. Only the caller may decide a read is authoritative:
* a failed or empty one never reaches here.
*
* An entry keeps the `at` of the FIRST read that saw that exit, so the stamp
* says when the agent was found gone rather than when the last poll ran. A
* changed status, a changed signal, or a different pane pid all start a new
* observation — the pid is what catches a second command in the same pane
* that happened to exit the same way.
*/
applyPaneExits(observed: Map<string, PaneExitObservation>): void {
for (const muxName of [...this.paneExits.keys()]) {
if (!observed.has(muxName)) this.paneExits.delete(muxName);
}
for (const [muxName, next] of observed) {
const prev = this.paneExits.get(muxName);
const sameExit =
prev !== undefined &&
prev.panePid === next.panePid &&
prev.exit.status === next.exit.status &&
prev.exit.signal === next.exit.signal;
this.paneExits.set(muxName, sameExit ? prev : next);
}
}
/**
* Forget a session's exit observation, e.g. once its pane has been respawned.
* Also invalidates any read already in flight, so the answer this retracts
* cannot be written back a moment later.
*/
clearPaneExit(muxName: string): void {
this.paneExits.delete(muxName);
this.paneExitGeneration++;
}
/**
* Poll for exited agents, on the manager's own interval.
*
* Deliberately NOT part of `startStatsCollection()`. That collector is armed
* when the browser opens the Monitor panel and DISARMED when it closes it
* (`panels-ui.js`), and it is skipped at boot entirely when no session was
* recovered — so riding it would leave a session created on a freshly booted
* server reporting nothing at all, and would let one browser turn exit
* detection off for every other. Started unconditionally, like the mouse-mode
* sync and the remote-reconnect watcher below.
*
* The `paneExitsUpdated` event is internal to the server; nothing here adds an
* SSE event, and the field reaches the browser on `session:updated`.
*/
startPaneExitWatcher(intervalMs: number = DEFAULT_PANE_EXIT_INTERVAL_MS): void {
if (this.paneExitInterval) {
clearInterval(this.paneExitInterval);
}
this.paneExitInterval = setInterval(() => {
// No IS_TEST_MODE guard: `readPaneRows()` is the only thing that would
// spawn a process and it refuses under test, so a test can drive this
// whole loop with fake timers instead of being locked out of it.
void this.refreshPaneExits()
.then(() => this.emit('paneExitsUpdated'))
.catch((err) => console.error('[TmuxManager] Pane exit watcher error:', err));
}, intervalMs);
}
stopPaneExitWatcher(): void {
if (this.paneExitInterval) {
clearInterval(this.paneExitInterval);
this.paneExitInterval = null;
}
}
startStatsCollection(intervalMs: number = DEFAULT_STATS_INTERVAL_MS): void {
if (this.statsInterval) {
clearInterval(this.statsInterval);
@@ -3101,6 +3449,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
destroy(): void {
this.stopStatsCollection();
this.stopPaneExitWatcher();
this.paneExits.clear();
this.stopMouseModeSync();
this.stopRemoteReconnectWatcher();
this.reconnectState.clear();
+63
View File
@@ -625,6 +625,54 @@ export interface CustomModelBookkeeping extends CustomModelSelection {
launchModel?: string;
}
/**
* The agent inside a LOCAL tmux pane has exited, and the pane survived it.
*
* Codeman creates every pane with `remain-on-exit on`, so `/exit` ends the CLI
* while tmux keeps the pane, the tmux session and the `tmux attach-session`
* process Codeman records as the session's pid. No PTY exit handler runs, so
* without this record the session reads as a live idle one (Ark0N/Codeman#446).
*
* The field is TRI-STATE, and the third state is the absence of the field:
* `undefined` means Codeman does not know, and it must never be rendered as
* "alive". It is absent for a direct-PTY session (no pane exists), for a remote
* SSH session (the local pane holds the ssh client, whose death means transport
* drop OR exit) and for a docker case (the local pane holds a `docker exec`
* into the container's own tmux).
*
* `status` and `signal` are independently optional because tmux may know that
* the pane died without reporting how. 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 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, 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;
/**
* Wall-clock ms when THIS server process first observed the pane dead. It is
* not when the agent exited, which nothing records, and a restart that finds
* the pane still dead respawns it rather than re-timing the old exit.
*/
at: number;
}
export interface SessionState {
/** Unique session identifier */
id: string;
@@ -780,6 +828,21 @@ export interface SessionState {
* (COD-118). Runtime-only: never restored on boot (fresh server = fresh breaker).
*/
respawnBlocked?: boolean;
/**
* The agent in this session's LOCAL tmux pane has exited (Ark0N/Codeman#446).
* See {@link PaneExit} for the tri-state rule and for which session shapes
* leave it absent. `status` and `pid` are deliberately untouched by it: the
* PTY-exit breaker owns `status: 'error'`, and a null `pid` is what makes the
* browser re-attach and launch a fresh CLI.
*
* Persisted so a reboot restore can tell a session whose agent exited from one
* that was merely idle when the power went. `reboot-restore.ts` reads the
* persisted record and never builds a `Session`, so the record is the only
* place that survives the reboot to carry it. Nothing reads it there YET:
* making the restore refuse such a session is a behavior change, and it
* belongs with the part of Ark0N/Codeman#446 that closes exited sessions.
*/
paneExit?: PaneExit;
}
/**
+61 -1
View File
@@ -325,6 +325,55 @@ function parseSessionPrefix(name) {
return null;
}
// ═══════════════════════════════════════════════════════════════
// Exited-agent tab label (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. The field is tri-state
// and its third state is absence, which means Codeman does not know — that
// renders as nothing here and must never read as alive.
//
// status and signal are each optional, because tmux can know the pane died
// without reporting how (a SIGKILLed pane on tmux 3.2a reports neither). So an
// absent status shows a bare "exited" rather than "exited (0)": a clean exit
// and an unexplained one must not look the same.
function paneExitLabel(paneExit) {
if (!paneExit || typeof paneExit !== 'object') return '';
if (typeof paneExit.signal === 'number' && paneExit.signal > 0) return `exited (signal ${paneExit.signal})`;
if (typeof paneExit.status === 'number') return `exited (${paneExit.status})`;
return 'exited';
}
// Add, update or remove one tab's exited-agent badge in place. Separate from
// the render loop so it can be exercised directly: this is the only path a
// session going live-to-exited ever takes, since that transition adds and
// removes no tab and so never reaches the full rebuild.
function applyPaneExitBadge(tab, paneExit) {
const label = paneExitLabel(paneExit);
const existing = tab.querySelector('.tab-exited-badge');
// Quiets the status dot too. That dot reports `status`, which stays `idle` or
// `busy` for an exited pane by design, so without this a green or pulsing dot
// sits next to a badge saying the agent is gone.
tab.classList.toggle('tab-agent-exited', !!label);
if (!label) {
existing?.remove();
return;
}
if (!existing) {
const badge = document.createElement('span');
badge.className = 'tab-exited-badge';
// Generated status text, like the status pills: it carries data-i18n-skip
// rather than a dictionary entry. Without it the translator would rewrite
// the badge and the next render pass would rewrite it back, because the
// comparison below is against the English string.
badge.setAttribute('data-i18n-skip', '');
badge.textContent = label;
tab.querySelector('.tab-name')?.insertAdjacentElement('afterend', badge);
return;
}
if (existing.textContent !== label) existing.textContent = label;
}
const DEFAULT_SHORTCUTS = [
{
id: 'show-shortcuts',
@@ -4968,6 +5017,11 @@ class CodemanApp {
statusEl.className = `tab-status ${status}`;
}
// The exited-agent badge (Ark0N/Codeman#446). A session going from live
// to exited changes no tab count, so the full rebuild below never runs
// for it and this is the only path that ever draws the badge.
applyPaneExitBadge(tab, session.paneExit);
// Rich sidebar meta ("created 3d ago · working 12m" + pill). The stamps
// themselves move on _tickSidebarRichTimes(); this is here for the parts
// a tick cannot see — the state flipping, and with it the pill, the row
@@ -5268,10 +5322,15 @@ class CodemanApp {
? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}"`
: '';
// '' whenever the server said nothing about this pane's agent, which covers
// a running pane and every session shape the field never applies to
// (direct-PTY, remote SSH, docker). See paneExitLabel().
const paneExitBadge = paneExitLabel(session.paneExit);
const inlineSessionActions = this.shouldInlineSessionActions();
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">&#x22EF;</button></span>`;
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
<span class="tab-status ${status}" aria-hidden="true"></span>
@@ -5279,6 +5338,7 @@ class CodemanApp {
<span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : mode === 'omp' ? '<span class="tab-mode omp" aria-hidden="true">om</span>' : ''}
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
${paneExitBadge ? `<span class="tab-exited-badge" data-i18n-skip>${escapeHtml(paneExitBadge)}</span>` : ''}
${inlineSessionActions ? tabActionsHtml : ''}
<span class="tab-detached-badge" aria-hidden="true">detached</span>
</span>
+16
View File
@@ -694,6 +694,22 @@ html.mobile-init .file-browser-panel {
display: none;
}
/* The exited-agent mute, a third time, for the phone (Ark0N/Codeman#446).
The rule above enlarges the working dot and gives it a green glow with
!important, and `status` deliberately stays `busy` for a pane whose agent
died mid-turn — so without this a phone renders a 9px grey dot still
wearing the green halo, beside a badge reading "exited". Measured; the
desktop rules cannot reach it, since they declare no box-shadow and lose
to !important anyway. !important here for the reason the glow needs it:
the skin block in styles.css outranks any plain class rule in this file.
⚠ The alert classes and the error state are excluded exactly as they are
on desktop — a colour that means "this needs you" outranks "it exited". */
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle) .tab-status:not(.error) {
background: var(--text-muted) !important;
box-shadow: none !important;
opacity: 0.5;
}
/* Truncate tab names more aggressively on mobile */
.session-tab .tab-name {
max-width: 50px;
+65
View File
@@ -2433,6 +2433,51 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
display: inline-flex;
}
/* The agent inside the pane has exited (Ark0N/Codeman#446). Unlike the badge
above this one carries text the server computed, so it is added and removed
by the renderer rather than toggled by a class. */
.session-tab .tab-exited-badge {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 1px 4px;
margin-left: 4px;
border-radius: 3px;
white-space: nowrap;
background: rgba(248, 113, 113, 0.16);
color: var(--danger);
}
/* An exited agent quiets the status dot, which otherwise reports `status` and
so stays green (or pulses, for a session the exit caught mid-turn) beside a
badge saying the agent is gone. `status` itself is deliberately untouched —
`error` is the PTY-exit breaker's value and makes the browser offer a restart
— so this is a rendering rule only, and it matches `.tab-status.ended`.
⚠ Three states are excluded BY HAND rather than by cascade, the same way the
rich-rail dot rules below do it: a dot turning red or yellow because a
session is blocked on a human outranks "the agent exited", and this selector
is specific enough (0,5,0) to have beaten those (0,3,0) rules otherwise. The
third is `.tab-status.error` (0,2,0), which is the PTY-exit breaker's state
and the one the browser answers with a "restart it?" confirm — the same
argument that protects the two alert classes, and it reaches the dot rather
than the tab, which is why the `:not()` sits on `.tab-status` here and on
`.session-tab` there. The spinner ring is a pseudo-element the skin blocks
cannot reach, so it needs hiding explicitly rather than by unsetting the
animation. */
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle) .tab-status:not(.error) {
background: var(--text-muted);
opacity: 0.5;
animation: none;
}
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle) .tab-status:not(.error)::after {
display: none;
}
/* ===== Tab action icons: active tab only =================================
All three per-tab icons live in a .tab-actions wrapper (in flow; it adds
no width of its own while the children keep the width:0 collapse above).
@@ -18477,6 +18522,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:not(.error) {
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
+9
View File
@@ -1583,6 +1583,12 @@ export function registerSessionRoutes(
name: session.name,
mode: session.mode,
});
// Persist, not just broadcast. Starting a command in the pane changes
// `pid` and retracts any `paneExit` (Ark0N/Codeman#446), and the pane-exit
// watcher cannot write that retraction to disk for us: its next tick finds
// the in-memory field already cleared, reports no change and persists
// nothing, so `state.json` would keep saying the agent had exited.
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.SessionInteractive, { id });
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
@@ -1612,6 +1618,9 @@ export function registerSessionRoutes(
name: session.name,
mode: 'shell',
});
// Persist for the same reason /interactive does: a started pane retracts
// `paneExit`, and the watcher's next tick cannot write that retraction.
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.SessionInteractive, { id, mode: 'shell' });
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
return {};
+53
View File
@@ -463,6 +463,11 @@ export class WebServer extends EventEmitter {
this.mux.on('statsUpdated', (sessions) => {
this.broadcast(SseEvent.MuxStatsUpdated, sessions);
});
// Ark0N/Codeman#446 — a pane read finished. Internal only: the field reaches
// the browser on `session:updated`, and no SSE event was added for it.
this.mux.on('paneExitsUpdated', () => {
this.applyPaneExits();
});
// COD-108 — remote-session auto-reconnect. The TmuxManager watcher detects a
// dead remote pane and emits `remoteSessionDropped`; the session owner (here)
@@ -2455,6 +2460,36 @@ export class WebServer extends EventEmitter {
this.sse.broadcastSessionStateDebounced(sessionId);
}
/**
* Fold the latest pane readings into the sessions they belong to
* (Ark0N/Codeman#446). A reading that changes a session's answer persists the
* record and pushes a `session:updated`, which is how the tab learns; a read
* that repeats what the last one said costs nothing.
*
* The answer is pulled per session from the mux rather than taken off a
* broadcast payload. The mux reports the RAW pane reading, which for a remote
* or docker session is the death of an ssh client or a `docker exec` rather
* than of the agent, so it must not travel to a browser at all;
* `Session.setPaneExit()` is where that scoping is applied.
*
* Nothing here touches `status` or `pid`. `status: 'error'` belongs to the
* PTY-exit breaker and makes the browser offer a restart, and a null `pid` is
* what makes the browser re-attach and launch a fresh CLI.
*/
private applyPaneExits(): void {
const getPaneExit = this.mux.getPaneExit?.bind(this.mux);
if (!getPaneExit) return;
for (const session of this.sessions.values()) {
const muxName = session.muxName;
// No pane, so nothing to report — and `setPaneExit()` would force UNKNOWN
// for such a session anyway.
if (!muxName) continue;
if (!session.setPaneExit(getPaneExit(muxName))) continue;
this.persistSessionState(session);
this.broadcastSessionStateDebounced(session.id);
}
}
// ========== Web Push ==========
/** Map SSE event names to push notification payloads */
@@ -3336,6 +3371,15 @@ export class WebServer extends EventEmitter {
// the conversation the CLI was on when the server stopped, which is
// what a re-attach must point the viewer at instead of the launch id.
claudeSessionChain: savedState?.claudeSessionChain,
// What the previous run last observed of this pane's agent. Carried
// over so the first persist after boot does not blank a record that
// says the agent exited; the attach below drops it, and the
// pane-exit watcher's own tick replaces it with a first-hand
// reading (not the stats collector — see `startPaneExitWatcher`).
paneExit: savedState?.paneExit,
// A record rebuilt from the socket has no provenance, so its
// apparent locality is a guess (see `MuxSession.discovered`).
discoveredMuxSession: muxSession.discovered,
// The pane's last output, previous run's value. Without it every
// restart restamped all sessions "now" (constructor + the attach
// repaint within the same second), flattening the home screens'
@@ -3545,6 +3589,15 @@ export class WebServer extends EventEmitter {
(this.mux as { startMouseModeSync: (ms?: number) => void }).startMouseModeSync();
}
// Ark0N/Codeman#446 — poll every pane for an exited agent. Always start,
// even with no sessions, for the same reason as the two watchers around
// it: sessions arrive later. Deliberately NOT folded into the stats
// collector above, which the browser arms and disarms with the Monitor
// panel and which boot skips entirely when nothing was recovered.
if ('startPaneExitWatcher' in this.mux) {
(this.mux as { startPaneExitWatcher: (ms?: number) => void }).startPaneExitWatcher();
}
// COD-108 — start the remote-session auto-reconnect watcher (tmux only).
// Always-on (D3) with a `remoteAutoReconnect` kill-switch the watcher reads
// each tick. Start even with no sessions — remote sessions may arrive later.