From 90a95f562b950a125aa4b3d24d725adb490ebea2 Mon Sep 17 00:00:00 2001 From: Michael Grundberg Date: Mon, 21 Sep 2026 10:01:09 +0200 Subject: [PATCH] feat(session): publish and persist a local pane's agent exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-` 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) --- src/session.ts | 112 ++++++++++++ src/web/server.ts | 52 ++++++ test/session-pane-exit.test.ts | 312 +++++++++++++++++++++++++++++++++ 3 files changed, 476 insertions(+) create mode 100644 test/session-pane-exit.test.ts diff --git a/src/session.ts b/src/session.ts index fdad22f8..a1a0294c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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 { probeDockerCliVersion } from './docker-hosts.js'; @@ -542,6 +543,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; @@ -718,6 +733,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. */ @@ -870,6 +889,15 @@ 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 stats tick replaces it with a + // first-hand reading. + 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. @@ -1097,6 +1125,71 @@ 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-` 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. Each caller is already followed + * by the route's or recovery's own persist, and the pane-exit watcher would + * reach the same answer within one interval regardless. + */ + 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 @@ -1620,6 +1713,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 @@ -1760,6 +1857,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) { @@ -1844,6 +1949,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; } @@ -1898,6 +2006,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; } diff --git a/src/web/server.ts b/src/web/server.ts index 8d8f9368..7257eb2a 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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. diff --git a/test/session-pane-exit.test.ts b/test/session-pane-exit.test.ts new file mode 100644 index 00000000..bed3ebf5 --- /dev/null +++ b/test/session-pane-exit.test.ts @@ -0,0 +1,312 @@ +/** + * @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'; + +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 = {}) { + 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('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 }).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 }).mux; + const session = localMuxSession(); + (web as unknown as { sessions: Map }).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 }).sessions.set(remoteSession.id, remoteSession); + mux.getPaneExit = () => EXIT; + (mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated'); + + expect(remoteSession.paneExit).toBeUndefined(); + }); +});