mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
* 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>
195 lines
7.2 KiB
TypeScript
195 lines
7.2 KiB
TypeScript
// Port: none (pure model + fake-DOM row builders — no browser, no server).
|
|
//
|
|
// The phone overview and the desktop home rail showing an exited agent as
|
|
// "exited" rather than "idle" (Ark0N/Codeman#446).
|
|
//
|
|
// The server publishes `session.paneExit` when the agent inside a local tmux
|
|
// pane has exited, while `status` stays `idle` or `busy` by design. Part 1 of
|
|
// #446 taught the tab strip and the rich rail rows to say so; both home screens
|
|
// still said "idle" beside nothing running. `_mobileOverviewExit()`
|
|
// (mobile-overview.js) is now the one rule all three surfaces read. It changes
|
|
// what a row SHOWS and leaves its `state` alone, because `state` picks the
|
|
// section and the sort order.
|
|
//
|
|
// `paneExitLabel()` is lifted out of the shipped app.js rather than restated
|
|
// here, so a change to what counts as "exited" there reaches these tests.
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
|
|
|
/** The shipped `paneExitLabel()` from app.js, as source text. */
|
|
function paneExitLabelSource(): string {
|
|
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
|
const match = appJs.match(/function paneExitLabel\(paneExit\) \{[\s\S]*?\n\}\n/);
|
|
if (!match) throw new Error('paneExitLabel() not found in app.js');
|
|
return match[0];
|
|
}
|
|
|
|
function fakeElement(): any {
|
|
const el: any = {
|
|
className: '',
|
|
type: '',
|
|
title: '',
|
|
textContent: '',
|
|
dataset: {},
|
|
style: {},
|
|
children: [] as any[],
|
|
setAttribute() {},
|
|
appendChild(child: any) {
|
|
el.children.push(child);
|
|
return child;
|
|
},
|
|
};
|
|
return el;
|
|
}
|
|
|
|
/** Every className in a fake-DOM subtree, depth first. */
|
|
function classNames(el: any): string[] {
|
|
return [el.className, ...(el.children || []).flatMap(classNames)].filter(Boolean);
|
|
}
|
|
|
|
function loadApp({ withPaneExitLabel = true } = {}) {
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
console,
|
|
window: { innerWidth: 1512 },
|
|
document: {
|
|
documentElement: { getAttribute: () => null },
|
|
getElementById: () => null,
|
|
createElement: () => fakeElement(),
|
|
createElementNS: () => fakeElement(),
|
|
},
|
|
MobileDetection: { getDeviceType: () => 'desktop' },
|
|
});
|
|
if (withPaneExitLabel) vm.runInContext(paneExitLabelSource(), context, { filename: 'app.js' });
|
|
for (const file of ['constants.js', 'mobile-overview.js', 'home-sessions.js']) {
|
|
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
|
|
}
|
|
const app = new (CodemanApp as any)();
|
|
app.getSessionName = (session: any) => session.name || session.id.slice(0, 8);
|
|
app._shortenHomePath = (p: string) => p || '';
|
|
app.loadAppSettingsFromStorage = () => ({});
|
|
app.cases = [];
|
|
app.pendingHooks = new Map();
|
|
return app;
|
|
}
|
|
|
|
const EXIT_AT = 1_700_000_000_000;
|
|
|
|
function sessions(list: Array<Record<string, any>>) {
|
|
return new Map(list.map((s) => [s.id, { status: 'idle', mode: 'claude', workingDir: '/w', ...s }]));
|
|
}
|
|
|
|
describe('the desktop home rail', () => {
|
|
it('says "exited" for an idle session whose agent exited, and measures from the exit', () => {
|
|
const app = loadApp();
|
|
app.sessions = sessions([{ id: 'gone', paneExit: { status: 3, at: EXIT_AT }, lastActivityAt: 5 }]);
|
|
app.sessionOrder = ['gone'];
|
|
|
|
const [row] = app.buildHomeSessionRows();
|
|
expect(row.state).toBe('idle');
|
|
expect(row.display).toBe('exited');
|
|
expect(row.pill).toBe('exited');
|
|
expect(row.since).toEqual({ key: 'exited', at: EXIT_AT });
|
|
});
|
|
|
|
it('draws a neutral row: no idle or working class on the row, the dot or the pill', () => {
|
|
// A pane whose agent died mid-turn still has `status: 'busy'`, so without
|
|
// the display class its dot would pulse green beside "exited".
|
|
const app = loadApp();
|
|
app.sessions = sessions([{ id: 'gone', status: 'busy', paneExit: { at: EXIT_AT } }]);
|
|
app.sessionOrder = ['gone'];
|
|
|
|
const [row] = app.buildHomeSessionRows();
|
|
expect(row.state).toBe('working');
|
|
const classes = classNames(app._buildHomeSessionRow(row));
|
|
expect(classes).toEqual(
|
|
expect.arrayContaining([
|
|
'home-sessions-row home-sessions-row--exited',
|
|
'home-sessions-dot home-sessions-dot--exited',
|
|
'home-sessions-pill home-sessions-pill--exited',
|
|
])
|
|
);
|
|
expect(classes.join(' ')).not.toMatch(/--(working|idle)\b/);
|
|
});
|
|
|
|
it('lets a pending permission prompt win over the exit', () => {
|
|
const app = loadApp();
|
|
app.sessions = sessions([{ id: 'blocked', paneExit: { status: 0, at: EXIT_AT } }]);
|
|
app.sessionOrder = ['blocked'];
|
|
app.pendingHooks = new Map([['blocked', new Set(['permission_prompt'])]]);
|
|
|
|
const [row] = app.buildHomeSessionRows();
|
|
expect(row.display).toBe('needs');
|
|
expect(row.pill).toBe('needs you');
|
|
});
|
|
|
|
it('leaves a session without an exit exactly as it was', () => {
|
|
const app = loadApp();
|
|
app.sessions = sessions([{ id: 'live', lastActivityAt: 5 }]);
|
|
app.sessionOrder = ['live'];
|
|
|
|
const [row] = app.buildHomeSessionRows();
|
|
expect(row.display).toBe('idle');
|
|
expect(row.pill).toBe('idle');
|
|
expect(row.since).toEqual({ key: 'idle', at: 5 });
|
|
});
|
|
|
|
it('degrades to no override when a stale cached app.js lacks paneExitLabel', () => {
|
|
const app = loadApp({ withPaneExitLabel: false });
|
|
app.sessions = sessions([{ id: 'gone', paneExit: { status: 3, at: EXIT_AT } }]);
|
|
app.sessionOrder = ['gone'];
|
|
|
|
expect(app.buildHomeSessionRows()[0].pill).toBe('idle');
|
|
});
|
|
});
|
|
|
|
describe('the phone overview', () => {
|
|
it('says "exited" and keeps the row in its section', () => {
|
|
const app = loadApp();
|
|
const model = app.buildMobileOverviewModel({
|
|
sessions: sessions([
|
|
{ id: 'gone', paneExit: { status: 137, at: EXIT_AT } },
|
|
{ id: 'live', lastActivityAt: 5 },
|
|
]),
|
|
cases: [],
|
|
sessionOrder: ['gone', 'live'],
|
|
});
|
|
|
|
const byId = Object.fromEntries(model.current.map((r: any) => [r.id, r]));
|
|
expect(byId.gone).toMatchObject({ state: 'idle', display: 'exited', pill: 'exited' });
|
|
expect(byId.gone.since).toEqual({ key: 'exited', at: EXIT_AT });
|
|
expect(byId.live).toMatchObject({ display: 'idle', pill: 'idle' });
|
|
});
|
|
|
|
it('draws a neutral row', () => {
|
|
const app = loadApp();
|
|
const model = app.buildMobileOverviewModel({
|
|
sessions: sessions([{ id: 'gone', status: 'busy', paneExit: { at: EXIT_AT } }]),
|
|
cases: [],
|
|
});
|
|
app._pendingApprovalForSession = () => null;
|
|
|
|
const classes = classNames(app._buildMobileOverviewRow(model.current[0]));
|
|
expect(classes).toEqual(
|
|
expect.arrayContaining([
|
|
'mobile-overview-row mobile-overview-row--exited',
|
|
'mobile-overview-dot mobile-overview-dot--exited',
|
|
'mobile-overview-pill mobile-overview-pill--exited',
|
|
])
|
|
);
|
|
expect(classes.join(' ')).not.toMatch(/--(working|idle)\b/);
|
|
});
|
|
});
|
|
|
|
describe('the exited pill styles', () => {
|
|
it('gives both home screens a neutral exited pill', () => {
|
|
expect(readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8')).toMatch(/\.home-sessions-pill--exited \{/);
|
|
expect(readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8')).toMatch(/\.mobile-overview-pill--exited \{/);
|
|
});
|
|
});
|