feat(session): publish and persist a local pane's agent exit

The mux layer now knows a pane's agent has exited. This puts it on the session
record, where the board and, later, the reboot restore can see it.

`SessionState.paneExit` carries `{ status?, signal?, at }` and rides the
existing `session:updated` broadcast through `toState()`. No new SSE event. The
server pulls each answer from `mux.getPaneExit()` rather than off a broadcast
payload, so the raw reading never reaches a browser: for a remote or docker
session that reading is the death of an ssh client or a `docker exec`, not of
the agent.

The field is tri-state, and the third state is its absence: `undefined` means
Codeman does not know, and it never reads as alive. `Session.setPaneExit()`
forces that unknown for every shape a dead local pane does not describe. A
direct-PTY session owns no pane. A remote SSH session's local pane holds the
ssh client, whose death means a transport drop OR an exit, which is the
ambiguity PR #355 settled by not guessing. A docker case's local pane holds a
`docker exec` into the container's own tmux. And a session rebuilt from the
socket has no provenance at all: `reconcileSessions()` gives it a synthetic
`restored-<fragment>` id that matches no `state.json` entry, so a remote
session rediscovered after `mux-sessions.json` was lost arrives with no
`remote` field and looks local — `MuxSession.discovered` marks it, and absent
metadata there counts as unproven rather than as proof. The scoping lives on
`Session` rather than in `TmuxManager` so there is one copy of the rule.

`status` and `pid` are untouched. `status: 'error'` belongs to the PTY-exit
circuit breaker and makes the browser offer a restart, and a null `pid` is what
makes the browser re-attach and launch a fresh CLI. A reading that repeats the
previous answer writes nothing and broadcasts nothing.

An unknown answer never reads as alive, but a stale KNOWN one would keep
reading as exited, so `clearPaneExitForNewPane()` retracts it on every path
that puts a new command in the pane: the start/attach path, the `restartCli()`
relaunch behind a custom-model switch, and the remote reattach. Without the
second of those, switching an endpoint on an exited session launched a new
command and then persisted and broadcast the old exit straight back onto it.

`toState()` is also what `state.json` persists, so the record survives a
reboot, which is the only thing that does: a reboot takes the tmux server, and
with it every live signal and every `mux-sessions.json` entry. Nothing reads it
there yet — making the restore refuse such a session is a behavior change that
belongs with the part that closes them. Recovery threads the saved value back
through the constructor so the first persist after boot cannot blank it.

Refs Ark0N/Codeman#446.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Grundberg
2026-09-21 10:01:09 +02:00
co-authored by Claude Opus 5
parent 02dc46dcd7
commit 90a95f562b
3 changed files with 476 additions and 0 deletions
+52
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,14 @@ 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 stats
// tick replaces it with a first-hand reading.
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 +3588,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.