feat(tmux): report a dead pane's exit from the batched pane list

Codeman creates every tmux pane with `remain-on-exit on`. When the agent exits,
tmux keeps the pane, the tmux session, and the `tmux attach-session` process
Codeman records as the session's pid, so no PTY exit handler fires and nothing
writes the exit down. tmux itself knows: it marks the pane dead and reports the
exit status. This reads that.

`PANE_LIST_FORMAT` gains `#{pane_dead}`, `#{pane_dead_status}` and
`#{pane_dead_signal}`, and `startPaneExitWatcher()` refreshes a
muxName-to-observation map from ONE batched `tmux list-panes -a` per tick. Boot
reconciliation already ran that same call, so it now fills the map too and
recovery starts with a reading.

The watcher owns its own interval rather than riding `startStatsCollection()`,
which the issue suggested. That collector is armed when a browser opens the
Monitor panel and DISARMED when it closes it, and boot skips it entirely unless
recovery found a live session, so a session created on a freshly booted server
would publish nothing and one browser could turn detection off for every other.
Measured on an isolated instance: a dead pane with status 0 reported nothing
until `POST /api/mux-sessions/stats/start` was called by hand. It is still one
batched read per tick; only the timer changed.

Three rules keep a positive answer trustworthy. A session answers only when
tmux listed exactly one pane for it, because Codeman never splits a pane and a
session the user split by hand has none that speaks for the agent. A pane
answers only when `#{pane_dead}` said 1 or 0, because an empty field is a tmux
that did not answer. An absent status stays absent rather than becoming 0:
measured on tmux 3.2a, a SIGKILLed pane reports neither a status nor a signal,
and calling that a clean exit would be wrong in the direction that matters.

Two guards stop a slow read undoing a fast one. `EXEC_TIMEOUT_MS` is 5000 ms
against a 2000 ms interval, so a read can outlive two ticks: one already in
flight suppresses the next, and a generation counter that every
`clearPaneExit()` bumps discards a read that started before a respawn or a
kill. An observation also carries its pane pid, so a second command in the same
pane that exits the same way starts a new timestamp rather than inheriting the
first death's.

A non-empty read of `list-panes -a` is authoritative for the whole socket, so
sessions missing from it are pruned, which also bounds the map as tmux sessions
come and go outside `killSession()`. A failed or empty read retracts nothing.

The manager reports the raw pane reading and applies no session-shape scoping,
because the remote-reconnect watcher beside it needs exactly that raw reading.

`parsePaneList` becomes `parsePaneRows`, returning one row per pane instead of
a name-to-pid map; reconciliation builds its map from the rows. The parser's
existing cases carry over unchanged, including the launchd/systemd literal-tab
regression from PR #71.

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:00:54 +02:00
co-authored by Claude Opus 5
parent 9466acfc1a
commit 02dc46dcd7
4 changed files with 546 additions and 32 deletions
+49
View File
@@ -625,6 +625,40 @@ 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".
*/
export interface PaneExit {
/** tmux `#{pane_dead_status}` — the command's exit code. Absent when tmux reported none. */
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 +814,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;
}
/**