feat(session): close sessions whose agent exited cleanly (#486)

* fix(cleanup): keep .claude-images while a sibling session uses the same dir

cleanupSession() recursively removes {workingDir}/.claude-images. That
directory belongs to the working directory rather than to the session, and
several sessions routinely share one case directory, so closing one session
deleted the pasted images a live sibling still referred to.

The removal now runs only when no other live session has the same working
directory. A session that is itself being cleaned up does not count as live,
so two sessions of one case closed together still remove the dir.

Split out ahead of the exited-agent sweep for Ark0N/Codeman#446, which closes
sessions unattended and would otherwise make the loss routine.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(session): close sessions whose agent exited cleanly (#446)

Part 2 of Ark0N/Codeman#446. Part 1 records an exited agent as
SessionState.paneExit. A session whose agent the user ended with /exit is
now closed through cleanupSession(), the same path the X button takes, so
finished sessions stop piling up on the board. The lifecycle log records
the reason as "agent exited cleanly (status 0)", and the conversation stays
resumable from the Resume list.

shouldCloseCleanlyExitedSession() in the new pure module pane-exit-sweep.ts
holds the rule. It closes a session only when all of these hold:

- The exit status is an explicit numeric 0 with no signal. An absent status
  is how a SIGKILL presents on tmux 3.2a, so it counts as unknown and the
  row stays. A non-zero status or any signal also keeps the row, with the
  exit code on the tab.
- Two authoritative pane reads agreed on that exit.
  TmuxManager.getPaneExitReadCount() counts them, and a failed, empty or
  skipped read neither confirms nor resets the count.
- No start, attach or relaunch is running for the pane.
  Session.paneLifecycleInFlight covers _setupOrAttachMuxSession(), whose
  dead-pane branch revives an exited pane on purpose, and restartCli().

setPaneExit() already scopes paneExit to local mux-backed sessions, so
remote, docker and direct-PTY sessions are never closed.

planRebootRestore() now refuses a record whose persisted paneExit is a
clean exit. That covers an agent that exited just before a reboot, before
the sweep reached it. A crashed agent's record stays eligible, like its row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(web): show "exited" on the phone overview and desktop home rail (#446)

Part 1 of Ark0N/Codeman#446 taught the tab strip and the rich rail rows
to say that a session's agent has exited. The phone overview and the
desktop home rail still said "idle", beside a green or pulsing dot.

_mobileOverviewExit() in mobile-overview.js is now the one rule for all
three surfaces, and _sidebarRichRow() uses it as well. It changes what a
row shows and leaves the row's state alone, because the state still picks
the section and the sort order. An exited row gets an "exited" pill, a
neutral dot and row accent, and a duration measured from when the server
first saw the pane dead. A pending permission prompt or question still
wins, as it does on the tab.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cleanup): close the gaps review found in the #446 sweep and image guard

Four fixes from a dual review of Ark0N/Codeman#446 part 2.

- The .claude-images guard compares canonical paths, so a sibling that
  reaches the same directory through a symlink keeps it. Its comment used to
  say that case only missed a deletion; it caused one.
- A detached session counts as a live sibling. DELETE ?killMux=false removes
  it from the server's map while its pane keeps running, so the guard now
  reads persisted records too, and exempts only sessions being killed rather
  than every session in cleaningUp.
- A session being closed refuses startInteractive() and startShell(). The
  /interactive route awaits listener setup before the start, and a start
  that raced the close could launch a CLI in a tmux session whose record was
  then deleted. A failed close clears the mark again.
- The clean-exit sweep tries each exit once, keyed by session id and the
  exit's at stamp, so a close that fails is not retried and logged every
  two seconds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(session): keep a clean exit that lands within 10 s of a pane start (#446)

A CLI that prints a startup error ("not logged in", a bad profile, a config
error) and exits 0 used to lose its tab, and the error with it, about 4 s
after launch. The sweep now keeps any clean exit that lands within
CLEAN_EXIT_MIN_PANE_LIFETIME_MS (10 s) of the last start, attach or relaunch
finishing (Session.paneStartedAt, stamped when _withPaneLifecycle ends). The
row stays as "exited (0)" for the user to read and close.

Verified on an isolated instance: a shell that ran `exit 0` 2 s after start
kept its row, one that exited after 13 s was closed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Grundberg
2026-09-24 22:18:07 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d67da5c9d0
commit b80d47aff8
21 changed files with 1253 additions and 50 deletions
+69 -1
View File
@@ -12,7 +12,8 @@
* image dir.
*/
import fs from 'node:fs/promises';
import { join } from 'node:path';
import { realpathSync } from 'node:fs';
import { join, resolve } from 'node:path';
import type { SessionPort } from './ports/index.js';
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
@@ -53,6 +54,73 @@ export async function sweepPasteImagesOnce(
return { scanned, deleted };
}
/**
* The path two sessions must share to share a paste-image dir: the canonical
* path when it can be resolved, so a sibling that reaches the same directory
* through a symlink matches, and the normalised path otherwise (a directory
* that no longer exists has nothing left to protect).
*/
function canonicalDir(dir: string): string {
try {
return realpathSync(dir);
} catch {
return resolve(dir);
}
}
/** One session the paste-image guard weighs: its id, directory and, for a persisted record, its status. */
export interface PasteImageDirUser {
id: string;
workingDir: string;
status?: string;
}
/**
* Does another live session still use this working directory's paste-image
* dir? Deleting a session removes `{workingDir}/.claude-images` recursively,
* and several sessions routinely share one case directory, so without this
* check closing one session deletes the pasted images a sibling in the same
* case still refers to.
*
* Two kinds of sibling count as live:
*
* - a session in the server's map, unless it is itself being killed;
* - a persisted record whose status is not `stopped`. That covers a session
* detached with `killMux=false`, which leaves the server's map while its
* tmux pane keeps running, and a session whose detach is still in progress.
*
* A session being KILLED does not count. Without that exemption, killing two
* sessions of one case concurrently (a bulk delete, or the exited-agent sweep
* closing two panes on one tick) would have each defer to the other, and
* neither would remove the dir.
*
* Erring toward "in use" only costs a missed deletion, which the periodic
* sweep above ages out. A pinned record whose tmux session is gone keeps its
* status through boot pruning, so it holds the dir this way until unpinned.
*/
export function pasteImageDirInUseByOtherSession(input: {
live: Iterable<PasteImageDirUser>;
persisted: Iterable<PasteImageDirUser>;
closingId: string;
workingDir: string;
killing: ReadonlySet<string>;
}): boolean {
const target = canonicalDir(input.workingDir);
const matches = (user: PasteImageDirUser): boolean =>
user.id !== input.closingId &&
!input.killing.has(user.id) &&
!!user.workingDir &&
canonicalDir(user.workingDir) === target;
for (const user of input.live) {
if (matches(user)) return true;
}
for (const user of input.persisted) {
if (user.status === 'stopped') continue;
if (matches(user)) return true;
}
return false;
}
export function startPasteImageGc(ctx: Pick<SessionPort, 'sessions'>): () => void {
const initial = setTimeout(() => {
void sweepPasteImagesOnce(ctx);
+5 -10
View File
@@ -4918,9 +4918,10 @@ class CodemanApp {
// `state` keys SESSION_ACTIVITY_RANK and the sort, while `status` stays idle
// or busy for an exited pane by design, so without this the muted dot sits
// beside a pill saying "idle". A pending alert still wins, exactly as it
// does for the dot.
const exited = !!paneExitLabel(session.paneExit) && (state === 'idle' || state === 'working');
const exitAt = exited ? Number(session.paneExit.at) || 0 : 0;
// does for the dot. The rule is `_mobileOverviewExit()`, shared with both
// home screens so the three surfaces agree on which sessions have exited.
const exit = this._mobileOverviewExit ? this._mobileOverviewExit(state, session) : null;
const exited = !!exit;
return {
state,
exited,
@@ -4931,13 +4932,7 @@ class CodemanApp {
// state pill and never replaces it.
watching: typeof session.watching === 'string' ? session.watching : '',
createdAt: Number(session.createdAt) || 0,
since: exitAt
? { key: 'exited', at: exitAt }
: exited
? null
: this._mobileOverviewSince
? this._mobileOverviewSince(state, session)
: null,
since: exit ? exit.since : this._mobileOverviewSince ? this._mobileOverviewSince(state, session) : null,
};
}
+11 -5
View File
@@ -201,6 +201,8 @@ Object.assign(CodemanApp.prototype, {
const session = this.sessions.get(id);
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id));
// Guarded: a stale cached mobile-overview.js may predate the helper.
const exit = this._mobileOverviewExit ? this._mobileOverviewExit(state, session) : null;
const mode = session.mode || 'claude';
return {
id,
@@ -211,7 +213,10 @@ Object.assign(CodemanApp.prototype, {
caseName: matched ? matched.name : '',
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
state,
pill: HOME_SESSIONS_PILL_LABEL[state] || state,
// What the row's dot, accent and pill show. It differs from `state` only
// for an exited agent (Ark0N/Codeman#446), whose state still sorts it.
display: exit ? 'exited' : state,
pill: exit ? 'exited' : HOME_SESSIONS_PILL_LABEL[state] || state,
// What the pane's footer says is still running in the background, straight off
// the session payload. Same field, same meaning as on the phone overview.
watching: typeof session.watching === 'string' ? session.watching : '',
@@ -224,7 +229,7 @@ Object.assign(CodemanApp.prototype, {
lastSubmitAt: Number(session.lastSubmitAt) || 0,
// "how long has it been like this", resolved by the phone overview's
// helper so both home screens label the same stamp with the same word.
since: this._mobileOverviewSince(state, session),
since: exit ? exit.since : this._mobileOverviewSince(state, session),
};
});
@@ -392,7 +397,8 @@ Object.assign(CodemanApp.prototype, {
_buildHomeSessionRow(row) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'home-sessions-row home-sessions-row--' + row.state;
const display = row.display || row.state;
item.className = 'home-sessions-row home-sessions-row--' + display;
item.dataset.hsAction = 'session';
item.dataset.hsSession = row.id;
item.title = row.dir ? `${row.name} (${row.dir})` : row.name;
@@ -409,7 +415,7 @@ Object.assign(CodemanApp.prototype, {
}
const dot = document.createElement('span');
dot.className = 'home-sessions-dot home-sessions-dot--' + row.state;
dot.className = 'home-sessions-dot home-sessions-dot--' + display;
dot.setAttribute('aria-hidden', 'true');
item.appendChild(dot);
@@ -441,7 +447,7 @@ Object.assign(CodemanApp.prototype, {
item.appendChild(body);
const pill = document.createElement('span');
pill.className = 'home-sessions-pill home-sessions-pill--' + row.state;
pill.className = 'home-sessions-pill home-sessions-pill--' + display;
// Skipped by i18n on purpose: generic single words ("idle", "done", "error")
// that collide with state strings on other surfaces.
pill.setAttribute('data-i18n-skip', '');
+40 -5
View File
@@ -161,6 +161,36 @@ Object.assign(CodemanApp.prototype, {
return { key: MOBILE_OVERVIEW_SINCE_LABEL[state] || state, at };
},
/**
* The exited-agent override for one row (Ark0N/Codeman#446), or null when
* the row shows its state as usual.
*
* The server publishes `session.paneExit` once the agent inside a local tmux
* pane has exited, while `status` stays `idle` or `busy` by design. So a row
* classified as idle or working may really be a pane with nothing running
* in it. This overrides what the row SHOWS, never its `state`: `state` still
* picks the section and the sort, the way `_sidebarRichRow()` (app.js) does
* for the detailed sidebar and rail. A pending alert still wins, because a
* human being blocked outranks the agent having exited.
*
* Shared by the phone overview, the desktop home rail and the rich tab rows,
* so the three cannot disagree about which sessions have exited.
*
* Guarded like every other cross-file call: `paneExitLabel()` lives in
* app.js, and a stale cached app.js must degrade to no override, not throw.
*
* @returns {{since: {key: string, at: number}|null}|null}
*/
_mobileOverviewExit(state, session) {
if (state !== 'idle' && state !== 'working') return null;
if (typeof paneExitLabel !== 'function' || !paneExitLabel(session.paneExit)) return null;
// `at` is when this server first saw the pane dead, which is what "exited
// 2m" should measure. A row without it shows no duration at all rather
// than a working or idle stamp that no longer describes the pane.
const at = Number(session.paneExit.at) || 0;
return { since: at ? { key: 'exited', at } : null };
},
/**
* Longest-prefix match of a workingDir against the case list, so a session
* started in a subdirectory still belongs to its case. Mirrors the matching in
@@ -202,6 +232,7 @@ Object.assign(CodemanApp.prototype, {
const rows = sessions.map((session) => {
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
const state = this._mobileOverviewState(session, pendingHooks.get && pendingHooks.get(session.id));
const exit = this._mobileOverviewExit(state, session);
const orderIndex = order.indexOf(session.id);
return {
id: session.id,
@@ -210,7 +241,10 @@ Object.assign(CodemanApp.prototype, {
caseName: matched ? matched.name : '',
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
state,
pill: MOBILE_OVERVIEW_PILL_LABEL[state] || state,
// What the row's dot, accent and pill show. It differs from `state` only
// for an exited agent, whose state still decides the section and sort.
display: exit ? 'exited' : state,
pill: exit ? 'exited' : MOBILE_OVERVIEW_PILL_LABEL[state] || state,
// What the pane's own footer says is still running in the background ("1 monitor",
// "2 shells"), straight off the session payload. A row that has one is quiet
// because the agent is waiting for that, not because it is waiting for you.
@@ -222,7 +256,7 @@ Object.assign(CodemanApp.prototype, {
// pair resolved for DISPLAY, and the two must not drift apart.
lastActivityAt: Number(session.lastActivityAt) || 0,
lastSubmitAt: Number(session.lastSubmitAt) || 0,
since: this._mobileOverviewSince(state, session),
since: exit ? exit.since : this._mobileOverviewSince(state, session),
orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
};
});
@@ -698,12 +732,13 @@ Object.assign(CodemanApp.prototype, {
_buildMobileOverviewRow(row) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'mobile-overview-row mobile-overview-row--' + row.state;
const display = row.display || row.state;
item.className = 'mobile-overview-row mobile-overview-row--' + display;
item.dataset.moAction = 'session';
item.dataset.moSession = row.id;
const dot = document.createElement('span');
dot.className = 'mobile-overview-dot mobile-overview-dot--' + row.state;
dot.className = 'mobile-overview-dot mobile-overview-dot--' + display;
dot.setAttribute('aria-hidden', 'true');
item.appendChild(dot);
@@ -736,7 +771,7 @@ Object.assign(CodemanApp.prototype, {
item.appendChild(body);
const pill = document.createElement('span');
pill.className = 'mobile-overview-pill mobile-overview-pill--' + row.state;
pill.className = 'mobile-overview-pill mobile-overview-pill--' + display;
// Skipped by i18n on purpose: the labels are generic single words ("idle",
// "done", "error") that collide with state strings on other surfaces.
pill.setAttribute('data-i18n-skip', '');
+7
View File
@@ -2978,6 +2978,13 @@ html.mobile-init .file-browser-panel {
color: var(--green);
}
/* An exited agent (Ark0N/Codeman#446): neutral, since nothing is running behind
the row. The dot and the row take `--exited` too and keep their base rules. */
.mobile-overview-pill--exited {
border-color: var(--text-muted);
color: var(--text-muted);
}
/* Accent, and none of the three above: a session watching work it started itself
is not asking the user for anything, and red and yellow are what say it is. */
.mobile-overview-pill--watching {
+9
View File
@@ -16392,6 +16392,15 @@ html[data-tab-orientation='vertical'] .home-sessions {
color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
}
/* An exited agent (Ark0N/Codeman#446): neutral, like the rich rail's exited pill.
No green at all, since nothing is running behind this row. The dot and the row
take the `--exited` class too and fall back to their neutral base rules. */
.home-sessions-pill--exited {
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
border-color: color-mix(in srgb, var(--text-muted) 30%, var(--border));
color: var(--text-muted);
}
/* Accent, deliberately none of the three above: a session that is watching something
it started (a monitor, a backgrounded shell, a cloud session) is not asking for
anything, so it must not borrow the red or the yellow that mean it is. This badge
+97 -3
View File
@@ -33,7 +33,8 @@ import fastifyCookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
import fastifyWebsocket from '@fastify/websocket';
import fastifyMultipart from '@fastify/multipart';
import { startPasteImageGc } from './paste-image-gc.js';
import { pasteImageDirInUseByOtherSession, startPasteImageGc } from './paste-image-gc.js';
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from 'node:fs';
@@ -1321,16 +1322,32 @@ export class WebServer extends EventEmitter {
// Clean up all resources associated with a session
// Track sessions currently being cleaned up to prevent concurrent cleanup races
private cleaningUp: Set<string> = new Set();
/**
* The subset of {@link cleaningUp} whose tmux session is being KILLED rather
* than detached. The paste-image guard needs the difference: a detaching
* session keeps running in tmux and still uses its working directory.
*/
private killingSessions: Set<string> = new Set();
private async cleanupSession(sessionId: string, killMux: boolean = true, reason?: string): Promise<void> {
// Guard against concurrent cleanup of the same session
if (this.cleaningUp.has(sessionId)) return;
this.cleaningUp.add(sessionId);
if (killMux) this.killingSessions.add(sessionId);
// Refuse a start or attach from here on (Ark0N/Codeman#446): a start that
// raced this cleanup would launch a CLI in a tmux session whose record is
// about to be deleted, leaving an orphan the next boot rediscovers.
const session = this.sessions.get(sessionId);
session?.markClosing(true);
try {
await this._doCleanupSession(sessionId, killMux, reason);
} finally {
this.cleaningUp.delete(sessionId);
this.killingSessions.delete(sessionId);
// A cleanup that failed leaves the session on the board, so it must be
// startable again.
if (this.sessions.get(sessionId) === session) session?.markClosing(false);
}
}
@@ -1482,8 +1499,24 @@ export class WebServer extends EventEmitter {
attachmentRegistry.clearSession(sessionId);
// Stop watching for images in this session's directory
imageWatcher.unwatchSession(sessionId);
// Clean up pasted images directory for this session
if (killMux && session.workingDir) {
// Clean up pasted images directory for this session. The dir belongs to the
// working directory rather than the session, so it stays while another live
// session in the same case still uses it (Ark0N/Codeman#446).
if (
killMux &&
session.workingDir &&
!pasteImageDirInUseByOtherSession({
live: this.sessions.values(),
persisted: Object.entries(this.store.getSessions()).map(([id, record]) => ({
id,
workingDir: record.workingDir,
status: record.status,
})),
closingId: sessionId,
workingDir: session.workingDir,
killing: this.killingSessions,
})
) {
const pasteImageDir = join(session.workingDir, '.claude-images');
try {
rmSync(pasteImageDir, { recursive: true, force: true });
@@ -2502,6 +2535,9 @@ export class WebServer extends EventEmitter {
* 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.
*
* Once the records are current, {@link closeCleanlyExitedSessions} closes the
* sessions whose agent the user ended.
*/
private applyPaneExits(): void {
const getPaneExit = this.mux.getPaneExit?.bind(this.mux);
@@ -2515,6 +2551,63 @@ export class WebServer extends EventEmitter {
this.persistSessionState(session);
this.broadcastSessionStateDebounced(session.id);
}
this.closeCleanlyExitedSessions();
}
/**
* Close every session whose agent exited cleanly, through the same
* `cleanupSession()` the X button uses (Ark0N/Codeman#446). A pinned session
* is demoted to `status: 'stopped'` there rather than removed, and either way
* the reboot restore stops offering it back. The conversation stays
* resumable, since the Resume list reads the lifecycle log and the transcript
* files, and the pane owns neither.
*
* `shouldCloseCleanlyExitedSession()` (`pane-exit-sweep.ts`) holds the rule:
* an explicit status of 0, confirmed by more than one pane read, with no
* start or attach in flight and not within seconds of one (a startup error). A crashed agent keeps its row with the exit
* code on it. `session.paneExit` is already scoped to local mux-backed
* sessions by `setPaneExit()`, so a remote, docker or direct-PTY session is
* never closed here.
*
* The close runs in the background. `cleanupSession()` ignores a second call
* for a session it is already closing, and the `closing` check below keeps
* the next tick from queueing one.
*
* Each exit is attempted ONCE, keyed by session id and the exit's `at`
* stamp. A close that fails leaves the session on the board with its exit
* badge, which is where a crashed agent's row would be too, rather than
* retrying and logging every two seconds. A new exit in the same pane has a
* new `at` and gets its own attempt.
*/
/** Exits the clean-exit sweep has already tried to close, as `<sessionId>:<exit.at>`. */
private cleanExitCloseAttempts: Set<string> = new Set();
private closeCleanlyExitedSessions(): void {
const readCount = this.mux.getPaneExitReadCount?.bind(this.mux);
if (!readCount) return;
// Forget attempts for sessions that are gone, so the set stays bounded.
for (const key of this.cleanExitCloseAttempts) {
if (!this.sessions.has(key.slice(0, key.lastIndexOf(':')))) this.cleanExitCloseAttempts.delete(key);
}
for (const session of [...this.sessions.values()]) {
const muxName = session.muxName;
if (!muxName) continue;
const close = shouldCloseCleanlyExitedSession({
paneExit: session.paneExit,
confirmingReads: readCount(muxName),
paneLifecycleInFlight: session.paneLifecycleInFlight,
closing: this.cleaningUp.has(session.id),
paneStartedAt: session.paneStartedAt,
});
if (!close) continue;
const attempt = `${session.id}:${session.paneExit?.at ?? 0}`;
if (this.cleanExitCloseAttempts.has(attempt)) continue;
this.cleanExitCloseAttempts.add(attempt);
console.log(`[Server] Closing session ${session.id} (${session.name}): ${CLEAN_EXIT_CLOSE_REASON}`);
void this.cleanupSession(session.id, true, CLEAN_EXIT_CLOSE_REASON).catch((err) => {
console.error(`[Server] Failed to close cleanly exited session ${session.id}:`, err);
});
}
}
// ========== Web Push ==========
@@ -3963,6 +4056,7 @@ export class WebServer extends EventEmitter {
}
this.activePlanOrchestrators.clear();
this.cleaningUp.clear();
this.killingSessions.clear();
// Dispose push store (flush pending saves)
this.pushStore.dispose();