Merge pull request #157 from aakhter/cod-162-session-manager-polish

Session Manager: pinning, cross-device ordering, name/prompt retention
This commit is contained in:
Ark0N
2026-07-20 14:43:11 +02:00
committed by GitHub
24 changed files with 1411 additions and 28 deletions
+94 -3
View File
@@ -28,9 +28,15 @@ export type UnifiedSessionItem = {
lastActivityAt?: number;
claudeSessionId?: string;
firstPrompt?: string;
/** Most recent user prompt from the transcript (COD-145), parallel to firstPrompt. */
lastPrompt?: string;
sizeBytes?: number;
projectKey?: string;
remote?: boolean;
/** Pinned to the top of the session manager list (COD-139). */
pinned?: boolean;
/** When the session was pinned (epoch ms) — orders the pinned group desc. */
pinnedAt?: number;
sources: string[];
stats?: { memoryMB: number; cpuPercent: number };
};
@@ -46,6 +52,8 @@ export type LiveSessionInput = {
createdAt?: number;
lastActivityAt?: number;
claudeSessionId?: string;
pinned?: boolean;
pinnedAt?: number;
};
/** Persisted session view (subset of `SessionState`). */
@@ -59,6 +67,8 @@ export type PersistedSessionInput = {
lastActivityAt?: number;
/** Claude conversation ID this session resumes (`SessionState.resumeSessionId`). */
claudeSessionId?: string;
pinned?: boolean;
pinnedAt?: number;
};
/** Lifecycle audit-log view. Entries are expected NEWEST-first (the order `SessionLifecycleLog.query()` returns). */
@@ -77,6 +87,8 @@ export type HistoryInput = {
sizeBytes: number;
lastModified: string;
firstPrompt?: string;
/** Most recent user prompt from the transcript (COD-145). */
lastPrompt?: string;
projectKey?: string;
};
@@ -149,6 +161,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
overwrite(item, 'workingDir', h.workingDir);
overwrite(item, 'sizeBytes', h.sizeBytes);
overwrite(item, 'firstPrompt', h.firstPrompt);
overwrite(item, 'lastPrompt', h.lastPrompt);
overwrite(item, 'projectKey', h.projectKey);
const ms = Date.parse(h.lastModified);
if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms;
@@ -175,6 +188,8 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
overwrite(item, 'workingDir', p.workingDir);
overwrite(item, 'createdAt', p.createdAt);
overwrite(item, 'lastActivityAt', p.lastActivityAt);
overwrite(item, 'pinned', p.pinned);
overwrite(item, 'pinnedAt', p.pinnedAt);
}
// 4) live (highest precedence)
@@ -189,6 +204,8 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
overwrite(item, 'createdAt', v.createdAt);
overwrite(item, 'lastActivityAt', v.lastActivityAt);
overwrite(item, 'claudeSessionId', v.claudeSessionId);
overwrite(item, 'pinned', v.pinned);
overwrite(item, 'pinnedAt', v.pinnedAt);
}
// 5) mux stats + remote flag (create item if mux-only)
@@ -200,6 +217,64 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
if (m.remote !== undefined) item.remote = m.remote;
}
// firstPrompt backfill (COD-140): the only source that sets firstPrompt is the
// transcript-history view, keyed by the Claude transcript file's UUID. A live/persisted
// row keyed by its Codeman id only inherits firstPrompt when that id happens to equal an
// on-disk transcript UUID. When it doesn't (stale/wrong claudeSessionId, post-/clear new
// uuid, resumed/attached/worktree session, transcript not yet flushed), the row shows
// "(no prompt captured)" even though a real transcript for that working dir exists under a
// different UUID. Backfill from the already-passed history: first try the claudeSessionId
// join, then the newest transcript in the same workingDir. Never overwrite a non-empty
// firstPrompt (so rows keyed to their own transcript are untouched).
const firstPromptByUuid = new Map<string, string>();
const firstPromptByWorkingDir = new Map<string, { prompt: string; ms: number }>();
// COD-145: lastPrompt rides the same backfill (build parallel indexes; never overwrite).
const lastPromptByUuid = new Map<string, string>();
const lastPromptByWorkingDir = new Map<string, { prompt: string; ms: number }>();
for (const h of sources.history ?? []) {
const ms = Date.parse(h.lastModified);
const ts = Number.isNaN(ms) ? -Infinity : ms;
if (h.firstPrompt) {
firstPromptByUuid.set(h.sessionId, h.firstPrompt);
if (h.workingDir) {
const existing = firstPromptByWorkingDir.get(h.workingDir);
if (!existing || ts > existing.ms) {
firstPromptByWorkingDir.set(h.workingDir, { prompt: h.firstPrompt, ms: ts });
}
}
}
if (h.lastPrompt) {
lastPromptByUuid.set(h.sessionId, h.lastPrompt);
if (h.workingDir) {
const existing = lastPromptByWorkingDir.get(h.workingDir);
if (!existing || ts > existing.ms) {
lastPromptByWorkingDir.set(h.workingDir, { prompt: h.lastPrompt, ms: ts });
}
}
}
}
for (const item of map.values()) {
if (!item.firstPrompt) {
// never overwrite an existing non-empty prompt
const byUuid = item.claudeSessionId ? firstPromptByUuid.get(item.claudeSessionId) : undefined;
if (byUuid) {
item.firstPrompt = byUuid;
} else if (item.workingDir) {
const byDir = firstPromptByWorkingDir.get(item.workingDir);
if (byDir) item.firstPrompt = byDir.prompt;
}
}
if (!item.lastPrompt) {
const byUuid = item.claudeSessionId ? lastPromptByUuid.get(item.claudeSessionId) : undefined;
if (byUuid) {
item.lastPrompt = byUuid;
} else if (item.workingDir) {
const byDir = lastPromptByWorkingDir.get(item.workingDir);
if (byDir) item.lastPrompt = byDir.prompt;
}
}
}
// Meaningfulness floor: keep real rows, drop bare lifecycle/mux-only noise.
const kept: UnifiedSessionItem[] = [];
for (const item of map.values()) {
@@ -211,8 +286,24 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
if (isReal) kept.push(item);
}
// Stable sort: lastActivityAt desc (undefined last), createdAt desc, sessionId asc.
// Stable sort (COD-139): pinned group first (pinnedAt desc, most-recently-pinned
// first), then unpinned by lastActivityAt desc (undefined last), createdAt desc,
// sessionId asc.
kept.sort((a, b) => {
const pa = a.pinned === true;
const pb = b.pinned === true;
if (pa !== pb) return pa ? -1 : 1; // pinned floats above unpinned
if (pa && pb) {
// Both pinned: most-recently-pinned first (undefined pinnedAt sorts last).
const ta = a.pinnedAt;
const tb = b.pinnedAt;
if (ta !== tb) {
if (ta === undefined) return 1;
if (tb === undefined) return -1;
return tb - ta;
}
// tie-break falls through to the activity/createdAt/id rules below.
}
const la = a.lastActivityAt;
const lb = b.lastActivityAt;
if (la !== lb) {
@@ -234,7 +325,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
}
/**
* Case-insensitive substring filter (name + firstPrompt + workingDir + sessionId)
* Case-insensitive substring filter (name + firstPrompt + lastPrompt + workingDir + sessionId)
* with offset/limit paging. `total` is the filtered count BEFORE paging.
*/
export function filterAndPaginate(
@@ -244,7 +335,7 @@ export function filterAndPaginate(
const q = (opts.q ?? '').trim().toLowerCase();
const filtered = q
? items.filter((it) => {
const hay = [it.name, it.firstPrompt, it.workingDir, it.sessionId]
const hay = [it.name, it.firstPrompt, it.lastPrompt, it.workingDir, it.sessionId]
.filter((v): v is string => typeof v === 'string')
.join(' ')
.toLowerCase();
+68
View File
@@ -0,0 +1,68 @@
/**
* @fileoverview Pure helpers for the global session tab-order (COD-131).
*
* Tab order (drag-and-drop reorder + Ctrl+Shift+{/}) is persisted server-side
* so it follows the user across devices. The server is authoritative; the
* browser's localStorage (`codeman-session-order`) is the offline fallback.
*
* These helpers are pure (no IO) so they can be unit-tested in isolation and
* reused by both the PUT /api/session-order route and the StateStore accessor.
*
* - `normalizeSessionOrder` coerces arbitrary input into a clean string[]
* (non-empty strings only, deduped with first occurrence winning).
* - `mergeSessionOrder` lets the pushing device's order win, while preserving
* any server-only ids the pushing device didn't know about — they fall to the
* END in their existing relative order, never dropped.
*/
/**
* Coerce arbitrary input into a clean ordered list of session ids:
* keep only non-empty strings and dedup (first occurrence wins).
*
* @param order - unknown input (expected to be a string[], but defensive)
* @returns a normalized string[] (empty array for non-array / all-junk input)
*/
export function normalizeSessionOrder(order: unknown): string[] {
if (!Array.isArray(order)) {
return [];
}
const seen = new Set<string>();
const result: string[] = [];
for (const entry of order) {
if (typeof entry !== 'string' || entry.length === 0) {
continue;
}
if (seen.has(entry)) {
continue;
}
seen.add(entry);
result.push(entry);
}
return result;
}
/**
* Merge an incoming order from a pushing device with the existing server order.
*
* The incoming order wins; any ids present in `existing` but NOT in `incoming`
* are appended at the END, preserving their relative order. This is the
* "server-only ids the pushing device didn't know about fall to the end, never
* dropped" rule.
*
* Both arguments are normalized first, so callers may pass raw input safely.
*
* @param incoming - the order the pushing device wants
* @param existing - the current server-side order
* @returns the merged, normalized order
*/
export function mergeSessionOrder(incoming: string[], existing: string[]): string[] {
const normalizedIncoming = normalizeSessionOrder(incoming);
const incomingSet = new Set(normalizedIncoming);
const merged = [...normalizedIncoming];
for (const id of normalizeSessionOrder(existing)) {
if (!incomingSet.has(id)) {
merged.push(id);
}
}
return merged;
}
+27
View File
@@ -342,6 +342,11 @@ export class Session extends EventEmitter {
// Image watcher setting (per-session toggle)
private _imageWatcherEnabled: boolean = false;
// Pin state (COD-139) — pinned sessions float to the top of the session
// manager list, ordered by pinnedAt descending (most-recently-pinned first).
private _pinned: boolean = false;
private _pinnedAt: number | null = null;
// Flicker filter setting (per-session toggle, applied on frontend)
private _flickerFilterEnabled: boolean = false;
@@ -992,6 +997,26 @@ export class Session extends EventEmitter {
this._imageWatcherEnabled = enabled;
}
/** Whether this session is pinned to the top of the session manager (COD-139). */
get pinned(): boolean {
return this._pinned;
}
/** When the session was pinned (epoch ms), or null when unpinned. */
get pinnedAt(): number | null {
return this._pinnedAt;
}
/**
* Set pin state (COD-139). Pinning stamps pinnedAt with now so the pinned
* group orders most-recently-pinned first; unpinning clears it. Idempotent:
* re-pinning an already-pinned session refreshes its pinnedAt.
*/
setPinned(pinned: boolean): void {
this._pinned = pinned;
this._pinnedAt = pinned ? Date.now() : null;
}
get flickerFilterEnabled(): boolean {
return this._flickerFilterEnabled;
}
@@ -1058,6 +1083,8 @@ export class Session extends EventEmitter {
autoResumeEnabled: this._autoOps.autoResumeEnabled,
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
imageWatcherEnabled: this._imageWatcherEnabled,
pinned: this._pinned || undefined,
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
totalCost: this._totalCost,
inputTokens: this._totalInputTokens,
outputTokens: this._totalOutputTokens,
+34
View File
@@ -278,6 +278,9 @@ export class StateStore {
if (this.state.cronJobRuns) {
parts.push(`"cronJobRuns":${JSON.stringify(this.state.cronJobRuns)}`);
}
if (this.state.sessionOrder) {
parts.push(`"sessionOrder":${JSON.stringify(this.state.sessionOrder)}`);
}
return `{${parts.join(',')}}`;
}
@@ -485,6 +488,25 @@ export class StateStore {
this.save();
}
/**
* COD-142: Remove a session's persisted record on kill UNLESS it is pinned.
* A pinned session is demoted to a lightweight `stopped` record (pin retained)
* so it stays visible in the session-manager pinned group and survives restart.
* Unpinned sessions are fully removed (unchanged behavior).
* @returns 'preserved' if demoted to stopped+pinned, 'removed' if deleted, 'absent' if no record existed.
*/
demoteOrRemoveSession(id: string): 'preserved' | 'removed' | 'absent' {
const existing = this.state.sessions[id];
if (!existing) return 'absent';
if (existing.pinned === true) {
// Demote in place: keep identity/resume fields + pin, mark stopped, clear live runtime.
this.setSession(id, { ...existing, status: 'stopped', pid: null });
return 'preserved';
}
this.removeSession(id);
return 'removed';
}
/**
* Cleans up stale sessions from state that don't have corresponding active sessions.
* @param activeSessionIds - Set of currently active session IDs
@@ -499,6 +521,7 @@ export class StateStore {
for (const sessionId of allSessionIds) {
if (!activeSessionIds.has(sessionId)) {
if (this.state.sessions[sessionId]?.pinned === true) continue; // COD-142: pinned records persist even with no live session
const name = this.state.sessions[sessionId]?.name;
cleaned.push({ id: sessionId, name });
delete this.state.sessions[sessionId];
@@ -630,6 +653,17 @@ export class StateStore {
this.save();
}
/** Returns the global tab order (ordered sessionIds), [] if unset. COD-131. */
getSessionOrder(): string[] {
return this.state.sessionOrder ?? [];
}
/** Persists the global tab order (ordered sessionIds) and triggers a debounced save. COD-131. */
setSessionOrder(order: string[]): void {
this.state.sessionOrder = order;
this.save();
}
/** Resets all state to initial values and saves immediately. */
reset(): void {
this.state = createInitialState();
+2
View File
@@ -116,6 +116,8 @@ export interface AppState {
cronJobs?: Record<string, CronJob>;
/** Scheduled job run history, keyed by run ID. */
cronJobRuns?: Record<string, CronJobRun>;
/** Global tab order shared across devices (ordered list of sessionIds) — COD-131 */
sessionOrder?: string[];
}
// ========== Default Configuration ==========
+4
View File
@@ -405,6 +405,10 @@ export interface SessionState {
autoResumeEnabled?: boolean;
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */
autoResumeAt?: number;
/** Pinned to the top of the session manager list (COD-139) */
pinned?: boolean;
/** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */
pinnedAt?: number;
/** Image watcher enabled for this session */
imageWatcherEnabled?: boolean;
/** Total cost in USD */
+60 -1
View File
@@ -291,6 +291,9 @@ const _SSE_HANDLER_MAP = [
// Clipboard
[SSE_EVENTS.CLIPBOARD_WRITE, '_onClipboardWrite'],
// Session order (global tab order sync, COD-131)
[SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'],
];
@@ -1500,6 +1503,26 @@ class CodemanApp {
console.error('[SSE] docker image build failed:', err);
}
});
// COD-139: a session:pinned event updates the local live-session pin flag (so
// a subsequent render is consistent) and re-sorts the open session manager /
// welcome list so pinned sessions float to the top.
addListener(SSE_EVENTS.SESSION_PINNED, (e) => {
let data = null;
try {
data = JSON.parse(e.data);
} catch {
/* ignore malformed payload */
}
if (data && data.id) {
const live = this.sessions.get(data.id);
if (live) {
live.pinned = data.pinned === true;
live.pinnedAt = data.pinned ? data.pinnedAt : undefined;
}
}
this._onSessionListMaybeChanged();
});
}
// ═══════════════════════════════════════════════════════════════
@@ -3023,6 +3046,13 @@ class CodemanApp {
// on another device).
try { localStorage.removeItem('codeman-tab-meta'); } catch {}
// COD-131: server is authoritative for global tab order. Seed localStorage
// from the server snapshot (if present) so syncSessionOrder() reconciles
// against the cross-device order rather than this device's stale local copy.
if (Array.isArray(data.sessionOrder) && data.sessionOrder.length) {
try { localStorage.setItem('codeman-session-order', JSON.stringify(data.sessionOrder)); } catch {}
}
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
this.syncSessionOrder();
@@ -3587,13 +3617,42 @@ class CodemanApp {
}
}
// Save session order to localStorage
// Save session order to localStorage and (debounced) sync to the server so it
// follows the user across devices (COD-131). localStorage stays the offline
// fallback; the server is authoritative and echoes back via SSE.
saveSessionOrder() {
try {
localStorage.setItem('codeman-session-order', JSON.stringify(this.sessionOrder));
} catch {
// Ignore storage errors
}
const order = [...this.sessionOrder];
this._debouncedCall('saveSessionOrderServer', () => {
fetch('/api/session-order', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order }),
}).catch(() => {});
}, 400);
}
// COD-131: another device (or our own debounced push) reordered tabs. Adopt the
// server order as the new base and reconcile to our currently-open sessions.
// Guard against no-op churn so an echo of our own push doesn't flicker the tabs.
_onSessionOrderChanged(data) {
if (!data || !Array.isArray(data.order)) return;
try {
localStorage.setItem('codeman-session-order', JSON.stringify(data.order));
} catch {
// Ignore storage errors
}
const before = JSON.stringify(this.sessionOrder);
this.syncSessionOrder();
// Only re-render when the reconciled order actually changed (avoids flicker
// when the broadcast is just an echo of the order we already have).
if (JSON.stringify(this.sessionOrder) !== before) {
this._fullRenderSessionTabs();
}
}
// Set up drag-and-drop handlers on tab elements
+4
View File
@@ -322,6 +322,7 @@ const SSE_EVENTS = {
SESSION_LIMIT_RESUME_CANCELLED: 'session:limitResumeCancelled',
SESSION_RESPAWN_BREAKER_TRIPPED: 'session:respawnBreakerTripped',
SESSION_CLI_INFO: 'session:cliInfo',
SESSION_PINNED: 'session:pinned',
SESSION_MESSAGE: 'session:message',
SESSION_INTERACTIVE: 'session:interactive',
SESSION_RUNNING: 'session:running',
@@ -490,6 +491,9 @@ const SSE_EVENTS = {
ADMIN_USERS_CHANGED: 'admin:usersChanged',
AUTH_PASSWORD_CHANGE_REQUIRED: 'auth:passwordChangeRequired',
DOCKER_CONTAINER_RECREATED: 'docker:containerRecreated',
// Session order (global tab order sync)
SESSION_ORDER_CHANGED: 'session:orderChanged',
};
// ═══════════════════════════════════════════════════════════════
+1 -1
View File
@@ -438,7 +438,7 @@ Object.assign(CodemanApp.prototype, {
btn.append(dirSpan, metaSpan);
btn.addEventListener('click', (e) => {
e.stopPropagation();
this.resumeHistorySession(s.sessionId, s.workingDir);
this.resumeHistorySession(s.sessionId, s.workingDir, s.name);
});
container.appendChild(btn);
}
+19
View File
@@ -2794,6 +2794,25 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
background: rgba(255, 255, 255, 0.05);
}
/* COD-139: pinned sessions float to the top with a subtle accent highlight. */
.history-item.is-pinned {
border-color: rgba(245, 158, 11, 0.35);
background: rgba(245, 158, 11, 0.08);
box-shadow: inset 3px 0 0 rgba(245, 158, 11, 0.7);
}
.history-item.is-pinned:hover {
border-color: rgba(245, 158, 11, 0.5);
background: rgba(245, 158, 11, 0.12);
}
.history-item-pin {
margin-right: 0.35rem;
font-size: 0.75rem;
line-height: 1;
vertical-align: baseline;
}
.history-item-main {
display: flex;
align-items: center;
+94 -17
View File
@@ -1239,8 +1239,10 @@ Object.assign(CodemanApp.prototype, {
const isLive = Array.isArray(s.sources) && s.sources.includes('live');
const isPinned = s.pinned === true;
const item = document.createElement('div');
item.className = 'history-item';
item.className = 'history-item' + (isPinned ? ' is-pinned' : '');
item.title = s.workingDir || '';
// Main row: clickable surface. A caller-supplied onActivate wins (the
@@ -1258,7 +1260,7 @@ Object.assign(CodemanApp.prototype, {
if (isLive && this.sessions.has(s.sessionId)) {
this.selectSession(s.sessionId);
} else {
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '');
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name);
}
})
);
@@ -1268,7 +1270,16 @@ Object.assign(CodemanApp.prototype, {
const titleSpan = document.createElement('span');
titleSpan.className = 'history-item-title';
titleSpan.textContent = s.name || s.firstPrompt || shortDir;
if (isPinned) {
// Filled pin glyph indicating the session is pinned to the top (COD-139).
const pin = document.createElement('span');
pin.className = 'history-item-pin';
pin.textContent = '📌';
pin.setAttribute('aria-label', 'Pinned');
pin.title = 'Pinned';
titleSpan.appendChild(pin);
}
titleSpan.appendChild(document.createTextNode(s.name || s.firstPrompt || shortDir));
// Badge row: mode (claude/codex/opencode/gemini/shell) + a LIVE pill.
const badgeRow = document.createElement('div');
@@ -1327,6 +1338,21 @@ Object.assign(CodemanApp.prototype, {
promptText.textContent = s.firstPrompt || '(no prompt captured)';
promptRow.append(promptLabel, promptText);
// COD-145: show the most recent user prompt too, but collapse single-prompt
// sessions (omit when there's no last prompt or it duplicates the first).
let lastPromptRow = null;
if (s.lastPrompt && s.lastPrompt !== s.firstPrompt) {
lastPromptRow = document.createElement('div');
lastPromptRow.className = 'history-detail-row';
const lastPromptLabel = document.createElement('span');
lastPromptLabel.className = 'history-detail-label';
lastPromptLabel.textContent = 'Last prompt';
const lastPromptText = document.createElement('span');
lastPromptText.className = 'history-detail-value history-detail-prompt';
lastPromptText.textContent = s.lastPrompt;
lastPromptRow.append(lastPromptLabel, lastPromptText);
}
const pathRow = document.createElement('div');
pathRow.className = 'history-detail-row';
const pathLabel = document.createElement('span');
@@ -1345,7 +1371,9 @@ Object.assign(CodemanApp.prototype, {
metaParts.push(s.sessionId.slice(0, 8));
metaRow.textContent = metaParts.join(' · ');
detail.append(promptRow, pathRow, metaRow);
detail.append(promptRow);
if (lastPromptRow) detail.append(lastPromptRow);
detail.append(pathRow, metaRow);
if (showViewAll && s.projectKey) {
const actionRow = document.createElement('div');
@@ -1463,13 +1491,27 @@ Object.assign(CodemanApp.prototype, {
} else {
// Resume by the Claude conversation UUID when present (resumed sessions
// carry theirs separately from their Codeman id).
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '');
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name);
}
this.closeSessionManager?.();
closeMenu();
}
);
// Pin / Unpin (COD-139) — floats the session to the top of the list.
const isPinned = s.pinned === true;
addItem(isPinned ? 'Unpin session' : 'Pin to top', async () => {
const ok = await this._setSessionPinned(s.sessionId, !isPinned);
if (ok) {
// Optimistic local flip so a re-render before the SSE event is consistent.
s.pinned = !isPinned;
this.showToast(!isPinned ? 'Pinned to top' : 'Unpinned', 'success');
} else {
this.showToast('Pin failed', 'error');
}
closeMenu();
});
// Open folder (only for a live+open session — file browser is session-scoped).
if (isLiveOpen) {
addItem('Open folder', () => {
@@ -1536,6 +1578,32 @@ Object.assign(CodemanApp.prototype, {
this._openRowMenuClose = closeMenu;
},
/**
* COD-139: Toggle a session's pin via POST /api/sessions/:id/pin.
* Pinned sessions float to the top of the session manager list. Returns true
* on success. The live re-sort happens when the session:pinned SSE event
* fires (handled in app.js), so callers don't need to re-render themselves.
* @param {string} sessionId
* @param {boolean} pinned explicit desired pin state (idempotent)
* @returns {Promise<boolean>}
*/
async _setSessionPinned(sessionId, pinned) {
try {
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/pin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ pinned }),
});
if (!res.ok) return false;
const data = await res.json();
return data?.success === true;
} catch (err) {
console.error('[_setSessionPinned]', err);
return false;
}
},
/** Number of history items shown before "Show More" */
_HISTORY_INITIAL_COUNT: 4,
@@ -1721,7 +1789,24 @@ Object.assign(CodemanApp.prototype, {
this._folderHistoryState = null;
},
async resumeHistorySession(sessionId, workingDir) {
// Choose the name for a resumed session: keep the session's own name when it
// has one, otherwise synthesize a fresh w<N>-<dir> name (next free w-number
// across open sessions). COD-143 — resume used to always generate a new name.
_resolveResumeName(existingName, workingDir) {
if (typeof existingName === 'string' && existingName.trim()) return existingName;
const dirName = (workingDir || '').split('/').pop() || 'session';
let startNumber = 1;
for (const [, session] of this.sessions) {
const match = session.name && session.name.match(/^w(\d+)-/);
if (match) {
const num = parseInt(match[1]);
if (num >= startNumber) startNumber = num + 1;
}
}
return `w${startNumber}-${dirName}`;
},
async resumeHistorySession(sessionId, workingDir, existingName) {
// Close the run mode menu if open
document.getElementById('runModeMenu')?.classList.remove('active');
// Close folder history modal if open
@@ -1730,17 +1815,9 @@ Object.assign(CodemanApp.prototype, {
this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`);
// Generate a session name from the working dir
const dirName = workingDir.split('/').pop() || 'session';
let startNumber = 1;
for (const [, session] of this.sessions) {
const match = session.name && session.name.match(/^w(\d+)-/);
if (match) {
const num = parseInt(match[1]);
if (num >= startNumber) startNumber = num + 1;
}
}
const name = `w${startNumber}-${dirName}`;
// Keep the session's own name when resuming; only synthesize a w<N>-<dir>
// name when the source row had none (COD-143).
const name = this._resolveResumeName(existingName, workingDir);
// Create session with resumeSessionId — include envOverrides so resumed
// conversations inherit current UI settings (effort, agent teams, etc.).
+130
View File
@@ -32,12 +32,15 @@ import {
AutoClearSchema,
AutoCompactSchema,
AutoResumeSchema,
PinSessionSchema,
ImageWatcherSchema,
FlickerFilterSchema,
QuickRunSchema,
QuickStartSchema,
InteractiveStartSchema,
SessionOrderUpdateSchema,
} from '../schemas.js';
import { mergeSessionOrder } from '../../session-order.js';
import {
autoConfigureRalph,
canAccessOwned,
@@ -334,6 +337,18 @@ export function registerSessionRoutes(
return (list as Array<{ owner?: string }>).filter((s) => canAccessOwned(user, s.owner));
});
// ========== Session Tab Order (global sync, COD-131) ==========
app.put('/api/session-order', async (req): Promise<ApiResponse<{ order: string[] }>> => {
const { order } = parseBody(SessionOrderUpdateSchema, req.body, 'Invalid session order');
// Server is authoritative but never drops ids it knows about that the
// pushing device hadn't loaded yet — those fall to the end (mergeSessionOrder).
const merged = mergeSessionOrder(order, ctx.store.getSessionOrder());
ctx.store.setSessionOrder(merged);
ctx.broadcast(SseEvent.SessionOrderChanged, { order: merged });
return { success: true, data: { order: merged } };
});
// ========== Session Creation ==========
app.post('/api/sessions', async (req) => {
@@ -1652,6 +1667,50 @@ export function registerSessionRoutes(
};
});
// ========== Pin (float to top of the session manager list, COD-139) ==========
app.post('/api/sessions/:id/pin', async (req) => {
const { id } = req.params as { id: string };
const body = parseBody(PinSessionSchema, req.body, 'Invalid request body');
const session = ctx.sessions.get(id);
if (session) {
if (!canAccessOwned(getAuthUser(req), session.owner)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`);
}
session.setPinned(body.pinned);
// Persist + broadcast session:updated (keeps tabs/state consistent), then a
// dedicated session:pinned event so the session manager list re-sorts live.
persistAndBroadcastSession(ctx, session);
ctx.broadcast(SseEvent.SessionPinned, {
id,
pinned: session.pinned,
pinnedAt: session.pinnedAt ?? undefined,
});
return {
success: true,
data: {
pinned: session.pinned,
pinnedAt: session.pinnedAt ?? undefined,
},
};
}
// COD-142 keeps a pinned session's record after kill (demoteOrRemoveSession),
// so pin toggles must also work WITHOUT a live Session — otherwise a
// pinned-then-killed record could never be unpinned (cleanup skips pinned
// records, and the record has no live session to route through).
const persisted = ctx.store.getSession(id);
if (!persisted || !canAccessOwned(getAuthUser(req), persisted.owner)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`);
}
const pinnedAt = body.pinned ? Date.now() : undefined;
ctx.store.setSession(id, { ...persisted, pinned: body.pinned || undefined, pinnedAt });
ctx.broadcast(SseEvent.SessionPinned, { id, pinned: body.pinned, pinnedAt });
return { success: true, data: { pinned: body.pinned, pinnedAt } };
});
// ========== Image Watcher ==========
app.post('/api/sessions/:id/image-watcher', async (req) => {
@@ -2249,6 +2308,59 @@ export function registerSessionRoutes(
return undefined;
}
/**
* Extract the text of the LAST user message from a JSONL transcript chunk
* (COD-145). Mirrors `extractFirstUserPrompt` exactly — same user-message
* detection, same noise/secret/slash-command filters, same 120-char cap — but
* keeps the last qualifying match instead of returning on the first. Scan the
* file tail for this (the most recent prompt lives near the end).
*/
function extractLastUserPrompt(text: string): string | undefined {
const MAX_PROMPT_LEN = 120;
let result: string | undefined;
let start = 0;
while (start < text.length) {
const end = text.indexOf('\n', start);
const line = end === -1 ? text.slice(start) : text.slice(start, end);
start = end === -1 ? text.length : end + 1;
if (!line.includes('"type":"user"')) continue;
try {
const entry = JSON.parse(line);
if (entry.type !== 'user' || !entry.message) continue;
const content = entry.message.content;
let msgText: string | undefined;
if (typeof content === 'string') {
msgText = content;
} else if (Array.isArray(content)) {
const textBlock = content.find((b: { type: string }) => b.type === 'text');
if (textBlock) msgText = textBlock.text;
}
if (!msgText) continue;
msgText = msgText
.replace(/<[^>]+>/g, '')
.replace(new RegExp(String.raw`\x1b\[[0-9;]*[a-zA-Z]`, 'g'), '')
.trim()
.replace(/\s+/g, ' ');
if (!msgText) continue;
if (
/^(Caveat:|init\b|clear\b|resume\b|\/[a-z][\w-]*\b|You are a |\[Request |Set model to )/i.test(msgText) ||
/^(Please )?(analyze|review) this codebase/i.test(msgText) ||
/^(Read|Implement the following) .+, then (search|list|check) /i.test(msgText) ||
/^\d+ vulnerabilit/i.test(msgText) ||
/\btoolu_/.test(msgText) ||
/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+/.test(msgText) ||
/\b(sk-ant-|ANTHROPIC_API_KEY|API_KEY=|SECRET|TOKEN=)/i.test(msgText) ||
msgText.length < 8
)
continue;
result = msgText.length > MAX_PROMPT_LEN ? msgText.slice(0, MAX_PROMPT_LEN) + '…' : msgText;
} catch {
// Malformed line — skip
}
}
return result;
}
/**
* Decode a Claude project key (e.g. "-Users-teigen-Documents-Workspace-AI-project-Mirror")
* back to a filesystem path ("/Users/teigen/Documents/Workspace/AI_project/Mirror").
@@ -2387,6 +2499,7 @@ export function registerSessionRoutes(
sizeBytes: number;
lastModified: string;
firstPrompt?: string;
lastPrompt?: string;
};
// Scan a single project directory and return all valid history sessions in it.
@@ -2432,6 +2545,17 @@ export function registerSessionRoutes(
if (tail) firstPrompt = extractFirstUserPrompt(tail);
}
// COD-145: last (most recent) user prompt lives near the END of the file, so
// prefer the tail. For large files where no tail was read yet, read one
// (mirrors the firstPrompt > 65536 block). Small files fit in `head`, which
// then contains the whole transcript — scan it for the last match instead.
if (!tail && fileStat.size > 65536) {
const tailBuf = Buffer.alloc(32768);
tail = await readFileTail(filePath, tailBuf, fileStat.size);
}
const lastPrompt =
(tail ? extractLastUserPrompt(tail) : undefined) ?? (head ? extractLastUserPrompt(head) : undefined);
out.push({
sessionId,
workingDir,
@@ -2439,6 +2563,7 @@ export function registerSessionRoutes(
sizeBytes: fileStat.size,
lastModified: fileStat.mtime.toISOString(),
firstPrompt,
lastPrompt,
});
}
return out;
@@ -2514,6 +2639,8 @@ export function registerSessionRoutes(
createdAt: st.createdAt,
lastActivityAt: st.lastActivityAt,
claudeSessionId: s.claudeSessionId ?? undefined,
pinned: st.pinned,
pinnedAt: st.pinnedAt,
};
});
@@ -2529,6 +2656,8 @@ export function registerSessionRoutes(
createdAt: p.createdAt,
lastActivityAt: p.lastActivityAt,
claudeSessionId: p.resumeSessionId,
pinned: p.pinned,
pinnedAt: p.pinnedAt,
}));
// Lifecycle audit log (newest-first, capped).
@@ -2562,6 +2691,7 @@ export function registerSessionRoutes(
sizeBytes: h.sizeBytes,
lastModified: h.lastModified,
firstPrompt: h.firstPrompt,
lastPrompt: h.lastPrompt,
projectKey: h.projectKey,
});
}
+13
View File
@@ -867,6 +867,11 @@ export const AutoResumeSchema = z.object({
enabled: z.boolean(),
});
/** POST /api/sessions/:id/pin (COD-139) — explicit pin state for idempotency. */
export const PinSessionSchema = z.object({
pinned: z.boolean(),
});
/** POST /api/sessions/:id/image-watcher */
export const ImageWatcherSchema = z.object({
enabled: z.boolean(),
@@ -969,6 +974,14 @@ export const CaseOrderSchema = z.object({
order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')),
});
/** PUT /api/session-order — global tab order (ordered sessionIds), COD-131 */
export const SessionOrderUpdateSchema = z.object({
// Bounded defensively: ids are uuid-ish (<=100 chars) and the client pushes only
// its open-tab order (max sessions is 50) — 500 leaves ample headroom while
// keeping a hostile/buggy client from persisting megabytes into state.json.
order: z.array(z.string().max(100)).max(500),
});
/** POST /api/auth/revoke */
export const RevokeSessionSchema = z.object({
sessionToken: z.string().min(1).max(200).optional(),
+2 -1
View File
@@ -1212,7 +1212,7 @@ export class WebServer extends EventEmitter {
// Only remove from state.json if we're also killing the mux session.
// When killMux=false (server shutdown), preserve state for recovery.
if (killMux) {
this.store.removeSession(sessionId);
this.store.demoteOrRemoveSession(sessionId);
}
}
@@ -1871,6 +1871,7 @@ export class WebServer extends EventEmitter {
timestamp: now,
inputCjkForm: process.env.INPUT_CJK_FORM?.toUpperCase() === 'ON',
planUsage: getLatestPlanUsage(), // last-known plan-usage telemetry, for the header chip on fresh load
sessionOrder: this.store.getSessionOrder(), // global tab order, synced across devices (COD-131)
};
this.cachedLightState = { data: result, timestamp: now };
+9
View File
@@ -84,6 +84,8 @@ export const SessionLimitResumeCancelled = 'session:limitResumeCancelled' as con
export const SessionRespawnBreakerTripped = 'session:respawnBreakerTripped' as const;
/** CLI version/model info detected from session output. */
export const SessionCliInfo = 'session:cliInfo' as const;
/** Session pin state changed (COD-139): pinned/unpinned in the session manager list. */
export const SessionPinned = 'session:pinned' as const;
/** General session message (e.g. status text). */
export const SessionMessage = 'session:message' as const;
/** Session entered interactive mode (claude or shell). */
@@ -404,6 +406,9 @@ export const AdminUsersChanged = 'admin:usersChanged' as const;
/** A user must change their password (targeted); the frontend shows the modal. */
export const AuthPasswordChangeRequired = 'auth:passwordChangeRequired' as const;
/** Global session tab order changed (synced across devices). COD-131. */
export const SessionOrderChanged = 'session:orderChanged' as const;
// ─── Namespace Re-export ─────────────────────────────────────────────────────
/**
@@ -433,6 +438,7 @@ export const SseEvent = {
SessionLimitResumeCancelled,
SessionRespawnBreakerTripped,
SessionCliInfo,
SessionPinned,
SessionMessage,
SessionInteractive,
SessionRunning,
@@ -602,4 +608,7 @@ export const SseEvent = {
AdminUsersChanged,
AuthPasswordChangeRequired,
DockerContainerRecreated,
// Session order (global tab order sync)
SessionOrderChanged,
} as const;
+8
View File
@@ -21,6 +21,10 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
const sessions = new Map<string, MockSession>();
sessions.set(sessionId, session);
// Stateful backing for the global tab order (COD-131) so route tests can
// assert that setSessionOrder() actually persists what the handler computed.
let sessionOrder: string[] = [];
return {
// -- SessionPort --
sessions,
@@ -65,6 +69,10 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
load: vi.fn(),
incrementSessionsCreated: vi.fn(),
setConfig: vi.fn(),
getSessionOrder: vi.fn(() => sessionOrder),
setSessionOrder: vi.fn((order: string[]) => {
sessionOrder = order;
}),
getAggregateStats: vi.fn(() => ({ totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0 })),
getGlobalStats: vi.fn(() => ({ sessionsCreated: 0 })),
getDailyStats: vi.fn(() => []),
+10
View File
@@ -229,6 +229,8 @@ export class MockSession extends EventEmitter {
color: this.color,
mode: this.mode,
muxName: this._muxName,
pinned: this.pinned || undefined,
pinnedAt: this.pinned ? (this.pinnedAt ?? undefined) : undefined,
};
}
@@ -241,6 +243,14 @@ export class MockSession extends EventEmitter {
if (!enabled) this.autoResumeAt = null;
});
/** Pin state (COD-139) */
pinned: boolean = false;
pinnedAt: number | null = null;
setPinned = vi.fn((pinned: boolean) => {
this.pinned = pinned;
this.pinnedAt = pinned ? Date.now() : null;
});
/** Check if session is busy */
isBusy = vi.fn(() => false);
+91
View File
@@ -0,0 +1,91 @@
/**
* @fileoverview COD-143 — resuming a session from the Session Manager must retain its
* original tab name, not synthesize a fresh `w<N>-<dir>` name every time.
*
* Root cause: `resumeHistorySession(sessionId, workingDir)` ignored the row's `name` and
* always built `w<N>-<dir>` from the working dir. The fix threads the name through and
* extracts the choice into a pure `_resolveResumeName(existingName, workingDir)` helper:
* prefer a non-empty existing name; otherwise generate the next free `w<N>-<dir>` by
* scanning open sessions' names.
*
* This pins the helper's contract:
* 1. a non-empty existing name is returned verbatim (custom name retained),
* 2. a missing/empty/whitespace name falls back to `w<N>-<dir>`,
* 3. the generated number is the next free w-index across `this.sessions`,
* 4. the generated dir segment is the basename of workingDir (or `session` when empty).
*
* Loaded via `vm` against a stub `CodemanApp` (no jsdom — same harness as
* file-browser-reveal.test.ts / connection-indicator.test.ts). terminal-ui.js does
* `Object.assign(CodemanApp.prototype, {...})` at module-eval, so we capture the real
* `_resolveResumeName` off the prototype and invoke it against a minimal host whose
* `sessions` is a Map.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
/** Eval the shipping terminal-ui.js into a vm with a stub CodemanApp, return its prototype. */
function loadTerminalUiPrototype(): Record<string, (...args: unknown[]) => unknown> {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
const context = vm.createContext({
console,
CodemanApp: class CodemanApp {},
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
document: { addEventListener: vi.fn(), getElementById: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
});
vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context);
return (context as { __proto: Record<string, (...args: unknown[]) => unknown> }).__proto;
}
const proto = loadTerminalUiPrototype();
/** Minimal host carrying the real `_resolveResumeName` + a sessions Map. */
function makeApp(sessionNames: string[] = []) {
const sessions = new Map<string, { name: string }>();
sessionNames.forEach((name, i) => sessions.set(`s${i}`, { name }));
return {
sessions,
_resolveResumeName: proto._resolveResumeName as (existingName: unknown, workingDir: unknown) => string,
};
}
describe('COD-143 _resolveResumeName', () => {
it('returns a non-empty existing name verbatim (custom name retained)', () => {
const app = makeApp(['w1-foo', 'w2-bar']);
expect(app._resolveResumeName.call(app, 'my-custom-tab', '/home/me/proj')).toBe('my-custom-tab');
});
it('falls back to w<N>-<dir> when no name is given', () => {
const app = makeApp([]);
expect(app._resolveResumeName.call(app, undefined, '/home/me/proj')).toBe('w1-proj');
});
it('treats empty / whitespace names as no-name (falls back)', () => {
const app = makeApp([]);
expect(app._resolveResumeName.call(app, '', '/a/b/widgets')).toBe('w1-widgets');
expect(app._resolveResumeName.call(app, ' ', '/a/b/widgets')).toBe('w1-widgets');
});
it('generated w-number is the next free index across open sessions', () => {
const app = makeApp(['w1-foo', 'w3-bar', 'plain-name']);
// highest w<N> is 3 → next is 4
expect(app._resolveResumeName.call(app, null, '/x/y/svc')).toBe('w4-svc');
});
it('uses "session" as the dir segment when workingDir is empty', () => {
const app = makeApp([]);
expect(app._resolveResumeName.call(app, '', '')).toBe('w1-session');
});
it('does not let a generated fallback clobber an explicit name even when sessions exist', () => {
const app = makeApp(['w1-foo', 'w2-bar']);
expect(app._resolveResumeName.call(app, 'keepme', '/p/q')).toBe('keepme');
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* @fileoverview Tests for PUT /api/session-order (global tab-order sync, COD-131).
*
* Uses app.inject() — no real HTTP ports needed.
* Asserts the uniform envelope contract:
* SUCCESS -> 2xx, { success: true, data: { order } }
* ERROR -> 4xx/5xx, { success: false, error, errorCode }
* and that the order is persisted to the (mock) StateStore + broadcast over SSE.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
// registerSessionRoutes pulls in session.js which can shell out; stub the bits
// that would touch the OS at import/registration time. None are needed by the
// session-order handler itself, but the module imports them.
vi.mock('node:child_process', async (orig) => {
const actual = await orig<typeof import('node:child_process')>();
return { ...actual, execFile: vi.fn(), spawn: vi.fn() };
});
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
interface LocalHarness {
app: FastifyInstance;
ctx: MockRouteContext;
}
async function buildHarness(): Promise<LocalHarness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx as unknown as Parameters<typeof registerSessionRoutes>[1]);
// Mirror production's uniform-envelope preSerialization hook (server.ts).
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (!req.url.startsWith('/api')) return done(null, payload);
if (payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('PUT /api/session-order', () => {
let harness: LocalHarness;
beforeEach(async () => {
harness = await buildHarness();
});
afterEach(async () => {
await harness.app.close();
});
it('persists the order and returns it in the envelope', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/session-order',
payload: { order: ['a', 'b', 'c'] },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body).toEqual({ success: true, data: { order: ['a', 'b', 'c'] } });
// Persisted to the store.
expect(harness.ctx.store.setSessionOrder).toHaveBeenCalledWith(['a', 'b', 'c']);
expect(harness.ctx.store.getSessionOrder()).toEqual(['a', 'b', 'c']);
// Broadcast over SSE.
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['a', 'b', 'c'] });
});
it('preserves a server-only id (unknown to the pushing device) at the end', async () => {
// Seed the store with an order containing a server-only id "z".
harness.ctx.store.setSessionOrder(['a', 'z', 'b']);
(harness.ctx.broadcast as ReturnType<typeof vi.fn>).mockClear();
const res = await harness.app.inject({
method: 'PUT',
url: '/api/session-order',
payload: { order: ['b', 'a'] },
});
expect(res.statusCode).toBe(200);
const body = res.json();
// Incoming order wins, server-only "z" falls to the end.
expect(body).toEqual({ success: true, data: { order: ['b', 'a', 'z'] } });
expect(harness.ctx.store.getSessionOrder()).toEqual(['b', 'a', 'z']);
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['b', 'a', 'z'] });
});
it('normalizes junk input (dedup + drop empties) before persisting', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/session-order',
payload: { order: ['a', 'a', '', 'b'] },
});
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ success: true, data: { order: ['a', 'b'] } });
});
it('rejects a non-array order with a 4xx envelope', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/session-order',
payload: { order: 'nope' },
});
expect(res.statusCode).toBeGreaterThanOrEqual(400);
const body = res.json();
expect(body.success).toBe(false);
expect(harness.ctx.store.setSessionOrder).not.toHaveBeenCalled();
});
});
+169
View File
@@ -0,0 +1,169 @@
/**
* @fileoverview Route tests for POST /api/sessions/:id/pin (COD-139).
*
* Pinning floats a session to the top of the unified session list. The route
* sets the session's pin flag, persists it, and broadcasts session:pinned.
* Uses app.inject() with the production-mirroring envelope harness.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
interface LocalHarness {
app: FastifyInstance;
ctx: MockRouteContext;
}
async function createEnvelopeHarness(): Promise<LocalHarness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx as never);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (!req.url.startsWith('/api')) return done(null, payload);
if (payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('POST /api/sessions/:id/pin', () => {
let harness: LocalHarness;
beforeEach(async () => {
harness = await createEnvelopeHarness();
});
afterEach(async () => {
await harness.app.close();
});
it('pins a session: sets state, persists, and broadcasts session:pinned', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/pin',
payload: { pinned: true },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.pinned).toBe(true);
expect(typeof body.data.pinnedAt).toBe('number');
expect(harness.ctx._session.pinned).toBe(true);
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
const broadcastCalls = harness.ctx.broadcast.mock.calls.map((c) => c[0]);
expect(broadcastCalls).toContain('session:pinned');
});
it('unpins a session and clears pinnedAt', async () => {
await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/pin',
payload: { pinned: true },
});
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/pin',
payload: { pinned: false },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data.pinned).toBe(false);
expect(body.data.pinnedAt).toBeUndefined();
expect(harness.ctx._session.pinned).toBe(false);
});
it('is idempotent for an explicit pinned value', async () => {
for (let i = 0; i < 3; i++) {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/pin',
payload: { pinned: true },
});
expect(res.statusCode).toBe(200);
expect(res.json().data.pinned).toBe(true);
}
expect(harness.ctx._session.pinned).toBe(true);
});
it('returns 404 for an unknown session', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/does-not-exist/pin',
payload: { pinned: true },
});
expect(res.statusCode).toBe(404);
expect(res.json().success).toBe(false);
});
it('rejects a missing/invalid body with 400', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/pin',
payload: { pinned: 'yes' },
});
expect(res.statusCode).toBe(400);
expect(res.json().success).toBe(false);
});
// COD-142 keeps a pinned session's persisted record after kill, so pin toggles
// must work WITHOUT a live Session — otherwise a pinned-then-killed record could
// never be unpinned (cleanupStaleSessions deliberately skips pinned records).
it('unpins a persisted-only (killed but pinned) record via the store fallback', async () => {
const persisted = { id: 'dead-1', name: 'Dead', status: 'stopped', pinned: true, pinnedAt: 123 };
(harness.ctx.store.getSession as ReturnType<typeof vi.fn>).mockReturnValue(persisted);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/dead-1/pin',
payload: { pinned: false },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data.pinned).toBe(false);
expect(body.data.pinnedAt).toBeUndefined();
expect(harness.ctx.store.setSession).toHaveBeenCalledWith(
'dead-1',
expect.objectContaining({ pinned: undefined, pinnedAt: undefined })
);
const broadcastCalls = harness.ctx.broadcast.mock.calls.map((c) => c[0]);
expect(broadcastCalls).toContain('session:pinned');
});
it('re-pins a persisted-only record via the store fallback', async () => {
const persisted = { id: 'dead-2', name: 'Dead2', status: 'stopped' };
(harness.ctx.store.getSession as ReturnType<typeof vi.fn>).mockReturnValue(persisted);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/dead-2/pin',
payload: { pinned: true },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data.pinned).toBe(true);
expect(typeof body.data.pinnedAt).toBe('number');
expect(harness.ctx.store.setSession).toHaveBeenCalledWith(
'dead-2',
expect.objectContaining({ pinned: true, pinnedAt: expect.any(Number) })
);
});
});
+224 -5
View File
@@ -163,6 +163,211 @@ describe('mergeUnifiedSessions', () => {
expect(merged).toHaveLength(1);
expect(merged[0].projectKey).toBe('-repo-alpha');
});
// COD-140: firstPrompt backfill — live sessions whose Codeman id does not match an
// on-disk transcript UUID still surface a first prompt (by claudeSessionId join, then
// by newest transcript in the same workingDir).
it('backfills firstPrompt onto a live session by claudeSessionId join (uuid-join)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-1', status: 'working', claudeSessionId: 'uuid-A', workingDir: '/w' }],
history: [
{
sessionId: 'uuid-A',
workingDir: '/w',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'fix the bug',
},
],
});
const live = merged.find((m) => m.sessionId === 'codeman-1');
expect(live).toBeDefined();
expect(live!.firstPrompt).toBe('fix the bug');
// The upstream unified-service alias map (COD-160/161) folds a history row keyed
// by the Claude conversation UUID into the owning live session (claudeSessionId
// join), so it does NOT surface as a separate item — the firstPrompt reaches the
// live row above rather than a duplicate uuid-A entry.
const hist = merged.find((m) => m.sessionId === 'uuid-A');
expect(hist).toBeUndefined();
});
it('falls back to the workingDir transcript when no uuid join exists (workingDir fallback)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-2', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/w2' }],
history: [
{
sessionId: 'uuid-other',
workingDir: '/w2',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'borrowed prompt',
},
],
});
const live = merged.find((m) => m.sessionId === 'codeman-2');
expect(live).toBeDefined();
expect(live!.firstPrompt).toBe('borrowed prompt');
});
it('uses the newest transcript per workingDir for the fallback (newest-wins)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-3', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/w3' }],
history: [
{
sessionId: 'uuid-old',
workingDir: '/w3',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'older prompt',
},
{
sessionId: 'uuid-new',
workingDir: '/w3',
sizeBytes: 6000,
lastModified: '2026-02-01T00:00:00.000Z',
firstPrompt: 'newer prompt',
},
],
});
const live = merged.find((m) => m.sessionId === 'codeman-3');
expect(live).toBeDefined();
expect(live!.firstPrompt).toBe('newer prompt');
});
it('never overwrites a firstPrompt that already merged from the session own transcript (no overwrite)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'self-uuid', status: 'working', claudeSessionId: 'self-uuid', workingDir: '/w4' }],
history: [
// the session's own transcript (keyed by its id) — provides the real prompt
{
sessionId: 'self-uuid',
workingDir: '/w4',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'own prompt',
},
// a newer sibling transcript in the same dir that must NOT clobber it
{
sessionId: 'sibling-uuid',
workingDir: '/w4',
sizeBytes: 6000,
lastModified: '2026-03-01T00:00:00.000Z',
firstPrompt: 'sibling prompt',
},
],
});
const self = merged.find((m) => m.sessionId === 'self-uuid');
expect(self).toBeDefined();
expect(self!.firstPrompt).toBe('own prompt');
});
it('leaves firstPrompt undefined when there is no transcript at all (no transcript)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-5', status: 'working', claudeSessionId: 'uuid-none', workingDir: '/empty' }],
});
const live = merged.find((m) => m.sessionId === 'codeman-5');
expect(live).toBeDefined();
expect(live!.firstPrompt).toBeUndefined();
});
// COD-145: lastPrompt backfill — mirrors the COD-140 firstPrompt path so the
// most-recent user prompt also reaches live rows whose id ≠ transcript UUID.
it('backfills lastPrompt onto a live session by claudeSessionId join (uuid-join)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-l1', status: 'working', claudeSessionId: 'uuid-LA', workingDir: '/wl' }],
history: [
{
sessionId: 'uuid-LA',
workingDir: '/wl',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'fix the bug',
lastPrompt: 'now ship it',
},
],
});
const live = merged.find((m) => m.sessionId === 'codeman-l1');
expect(live).toBeDefined();
expect(live!.lastPrompt).toBe('now ship it');
// The upstream unified-service alias map (COD-160/161) folds the UUID-keyed
// history row into the owning live session, so lastPrompt reaches the live row
// above rather than surfacing as a separate uuid-LA entry.
const hist = merged.find((m) => m.sessionId === 'uuid-LA');
expect(hist).toBeUndefined();
});
it('falls back to the workingDir transcript for lastPrompt when no uuid join exists (workingDir fallback)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-l2', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/wl2' }],
history: [
{
sessionId: 'uuid-other',
workingDir: '/wl2',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'borrowed first',
lastPrompt: 'borrowed last',
},
],
});
const live = merged.find((m) => m.sessionId === 'codeman-l2');
expect(live).toBeDefined();
expect(live!.lastPrompt).toBe('borrowed last');
});
it('uses the newest transcript per workingDir for the lastPrompt fallback (newest-wins)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-l3', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/wl3' }],
history: [
{
sessionId: 'uuid-old',
workingDir: '/wl3',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'older first',
lastPrompt: 'older last',
},
{
sessionId: 'uuid-new',
workingDir: '/wl3',
sizeBytes: 6000,
lastModified: '2026-02-01T00:00:00.000Z',
firstPrompt: 'newer first',
lastPrompt: 'newer last',
},
],
});
const live = merged.find((m) => m.sessionId === 'codeman-l3');
expect(live).toBeDefined();
expect(live!.lastPrompt).toBe('newer last');
});
it('never overwrites a lastPrompt that already merged from the session own transcript (no overwrite)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'self-luuid', status: 'working', claudeSessionId: 'self-luuid', workingDir: '/wl4' }],
history: [
{
sessionId: 'self-luuid',
workingDir: '/wl4',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'own first',
lastPrompt: 'own last',
},
{
sessionId: 'sibling-uuid',
workingDir: '/wl4',
sizeBytes: 6000,
lastModified: '2026-03-01T00:00:00.000Z',
firstPrompt: 'sibling first',
lastPrompt: 'sibling last',
},
],
});
const self = merged.find((m) => m.sessionId === 'self-luuid');
expect(self).toBeDefined();
expect(self!.lastPrompt).toBe('own last');
});
});
describe('filterAndPaginate', () => {
@@ -170,6 +375,14 @@ describe('filterAndPaginate', () => {
{ sessionId: 's1', name: 'Alpha build', sources: ['live'], workingDir: '/repo/alpha' },
{ sessionId: 's2', name: 'Beta', firstPrompt: 'fix the login bug', sources: ['history'], workingDir: '/repo/beta' },
{ sessionId: 's3', name: 'Gamma', sources: ['persisted'], workingDir: '/srv/gamma' },
{
sessionId: 's4',
name: 'Delta',
firstPrompt: 'start the migration',
lastPrompt: 'roll back the migration',
sources: ['history'],
workingDir: '/repo/delta',
},
];
it('filters by name (case-insensitive)', () => {
@@ -183,18 +396,24 @@ describe('filterAndPaginate', () => {
expect(filterAndPaginate(items, { q: '/srv/' }).sessions[0].sessionId).toBe('s3');
});
it('filters by lastPrompt (COD-145)', () => {
const r = filterAndPaginate(items, { q: 'roll back' });
expect(r.total).toBe(1);
expect(r.sessions[0].sessionId).toBe('s4');
});
it('reports total as the pre-page filtered count', () => {
const r = filterAndPaginate(items, { q: 'repo', limit: 1 });
// both s1 and s2 have /repo/ workingDir
expect(r.total).toBe(2);
// s1, s2, and s4 all have /repo/ workingDir
expect(r.total).toBe(3);
expect(r.sessions).toHaveLength(1);
});
it('clamps limit to a max of 500', () => {
const r = filterAndPaginate(items, { limit: 99999 });
expect(r.sessions).toHaveLength(items.length);
// clamp does not throw and returns all 3 (< 500)
expect(r.total).toBe(3);
// clamp does not throw and returns all items (< 500)
expect(r.total).toBe(items.length);
});
it('clamps limit to a min of 1', () => {
@@ -206,7 +425,7 @@ describe('filterAndPaginate', () => {
const page1 = filterAndPaginate(items, { offset: 0, limit: 2 });
const page2 = filterAndPaginate(items, { offset: 2, limit: 2 });
expect(page1.sessions.map((s) => s.sessionId)).toEqual(['s1', 's2']);
expect(page2.sessions.map((s) => s.sessionId)).toEqual(['s3']);
expect(page2.sessions.map((s) => s.sessionId)).toEqual(['s3', 's4']);
const overlap = page1.sessions
.map((s) => s.sessionId)
.filter((id) => page2.sessions.map((s2) => s2.sessionId).includes(id));
+74
View File
@@ -0,0 +1,74 @@
/**
* @fileoverview Unit tests for the pure session-order helpers
* (normalizeSessionOrder, mergeSessionOrder) used by global tab-order sync (COD-131).
*/
import { describe, it, expect } from 'vitest';
import { normalizeSessionOrder, mergeSessionOrder } from '../src/session-order.js';
describe('normalizeSessionOrder', () => {
it('keeps a clean array unchanged', () => {
expect(normalizeSessionOrder(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']);
});
it('dedups, first occurrence wins', () => {
expect(normalizeSessionOrder(['a', 'b', 'a', 'c', 'b'])).toEqual(['a', 'b', 'c']);
});
it('drops empty strings', () => {
expect(normalizeSessionOrder(['a', '', 'b', ' '])).toEqual(['a', 'b', ' ']);
expect(normalizeSessionOrder([''])).toEqual([]);
});
it('drops non-string entries', () => {
expect(normalizeSessionOrder(['a', 1, null, undefined, {}, 'b', true])).toEqual(['a', 'b']);
});
it('returns [] for non-array input', () => {
expect(normalizeSessionOrder(undefined)).toEqual([]);
expect(normalizeSessionOrder(null)).toEqual([]);
expect(normalizeSessionOrder('abc')).toEqual([]);
expect(normalizeSessionOrder(42)).toEqual([]);
expect(normalizeSessionOrder({ 0: 'a' })).toEqual([]);
});
it('returns [] for empty array', () => {
expect(normalizeSessionOrder([])).toEqual([]);
});
});
describe('mergeSessionOrder', () => {
it('incoming order wins', () => {
expect(mergeSessionOrder(['c', 'a', 'b'], ['a', 'b', 'c'])).toEqual(['c', 'a', 'b']);
});
it('appends server-only ids (not in incoming) at the end, preserving their relative order', () => {
expect(mergeSessionOrder(['a', 'b'], ['x', 'a', 'y', 'b', 'z'])).toEqual(['a', 'b', 'x', 'y', 'z']);
});
it('empty incoming yields the existing order (normalized)', () => {
expect(mergeSessionOrder([], ['a', 'b', 'c'])).toEqual(['a', 'b', 'c']);
});
it('empty existing yields the incoming order (normalized)', () => {
expect(mergeSessionOrder(['a', 'b', 'c'], [])).toEqual(['a', 'b', 'c']);
});
it('both empty yields empty', () => {
expect(mergeSessionOrder([], [])).toEqual([]);
});
it('normalizes both args (dedup + drop junk) before merging', () => {
expect(mergeSessionOrder(['a', 'a', '', 'b'], ['b', 'c', 'c', ''])).toEqual(['a', 'b', 'c']);
});
it('does not duplicate an id present in both', () => {
expect(mergeSessionOrder(['a', 'b'], ['b', 'a'])).toEqual(['a', 'b']);
});
it('handles non-array / junk inputs defensively', () => {
// @ts-expect-error testing runtime robustness against bad input
expect(mergeSessionOrder(null, ['a', 'b'])).toEqual(['a', 'b']);
// @ts-expect-error testing runtime robustness against bad input
expect(mergeSessionOrder(['a'], 'nope')).toEqual(['a']);
});
});
+85
View File
@@ -0,0 +1,85 @@
/**
* @fileoverview Unit tests for session pinning (COD-139) — the pure
* merge/sort layer in unified-session-service.ts.
*
* Pinned sessions float to the top of the unified session list, ordered by
* pinnedAt descending (most-recently-pinned first). Unpinned sessions keep the
* existing lastActivityAt-desc ordering. Pin state flows through both the live
* and persisted inputs so it survives a reload (live → persisted-only on boot).
*/
import { describe, it, expect } from 'vitest';
import { mergeUnifiedSessions } from '../src/services/unified-session-service.js';
describe('mergeUnifiedSessions — pinning (COD-139)', () => {
it('floats a pinned session above unpinned ones regardless of activity', () => {
const merged = mergeUnifiedSessions({
live: [
{ id: 'a', name: 'A', lastActivityAt: 100 },
{ id: 'b', name: 'B', lastActivityAt: 5000, pinned: true, pinnedAt: 200 },
{ id: 'c', name: 'C', lastActivityAt: 9000 },
],
});
// b is pinned → first, even though c is the most-recently active.
expect(merged.map((m) => m.sessionId)).toEqual(['b', 'c', 'a']);
expect(merged[0].pinned).toBe(true);
});
it('orders multiple pinned sessions by pinnedAt descending (most recent first)', () => {
const merged = mergeUnifiedSessions({
live: [
{ id: 'p1', name: 'P1', lastActivityAt: 1, pinned: true, pinnedAt: 100 },
{ id: 'p2', name: 'P2', lastActivityAt: 2, pinned: true, pinnedAt: 300 },
{ id: 'p3', name: 'P3', lastActivityAt: 3, pinned: true, pinnedAt: 200 },
{ id: 'u', name: 'U', lastActivityAt: 9999 },
],
});
// Pinned group sorted by pinnedAt desc: p2(300) p3(200) p1(100); then unpinned.
expect(merged.map((m) => m.sessionId)).toEqual(['p2', 'p3', 'p1', 'u']);
});
it('keeps the existing activity-desc order among unpinned sessions', () => {
const merged = mergeUnifiedSessions({
live: [
{ id: 'old', name: 'Old', lastActivityAt: 100 },
{ id: 'new', name: 'New', lastActivityAt: 900 },
{ id: 'mid', name: 'Mid', lastActivityAt: 500 },
],
});
expect(merged.map((m) => m.sessionId)).toEqual(['new', 'mid', 'old']);
});
it('surfaces pin state from a persisted-only session (survives reload)', () => {
// On boot, a live session becomes persisted-only (status stopped). The pin
// flag must come through the persisted input so it still floats to the top.
const merged = mergeUnifiedSessions({
persisted: [
{ id: 'fresh', name: 'Fresh', lastActivityAt: 5000 },
{ id: 'pinned', name: 'Pinned', lastActivityAt: 1, pinned: true, pinnedAt: 42 },
],
});
expect(merged[0].sessionId).toBe('pinned');
expect(merged[0].pinned).toBe(true);
expect(merged[0].pinnedAt).toBe(42);
});
it('live pin overrides a stale persisted unpinned value (live precedence)', () => {
const merged = mergeUnifiedSessions({
persisted: [{ id: 's', name: 'S', lastActivityAt: 10 }],
live: [{ id: 's', name: 'S', lastActivityAt: 10, pinned: true, pinnedAt: 77 }],
});
const s = merged.find((m) => m.sessionId === 's');
expect(s?.pinned).toBe(true);
expect(s?.pinnedAt).toBe(77);
});
it('treats pinned:false the same as unpinned', () => {
const merged = mergeUnifiedSessions({
live: [
{ id: 'x', name: 'X', lastActivityAt: 100, pinned: false },
{ id: 'y', name: 'Y', lastActivityAt: 900, pinned: false },
],
});
expect(merged.map((m) => m.sessionId)).toEqual(['y', 'x']);
});
});
+60
View File
@@ -141,6 +141,66 @@ describe('StateStore', () => {
});
});
describe('demoteOrRemoveSession and pinned cleanup (COD-142)', () => {
it('should preserve a pinned session as a stopped record on kill', () => {
const store = new StateStore(testFilePath);
const pinnedAt = Date.now();
store.setSession('pinned-1', {
...createMockSessionState('pinned-1'),
name: 'My Pinned Session',
workingDir: '/tmp/pinned',
pinned: true,
pinnedAt,
});
const result = store.demoteOrRemoveSession('pinned-1');
expect(result).toBe('preserved');
const preserved = store.getSession('pinned-1');
expect(preserved).not.toBeNull();
expect(preserved?.status).toBe('stopped');
expect(preserved?.pid).toBeNull();
expect(preserved?.pinned).toBe(true);
expect(preserved?.pinnedAt).toBe(pinnedAt);
expect(preserved?.name).toBe('My Pinned Session');
expect(preserved?.workingDir).toBe('/tmp/pinned');
});
it('should fully remove an unpinned session on kill', () => {
const store = new StateStore(testFilePath);
store.setSession('plain-1', createMockSessionState('plain-1'));
const result = store.demoteOrRemoveSession('plain-1');
expect(result).toBe('removed');
expect(store.getSession('plain-1')).toBeNull();
});
it('should report absent for an unknown session id', () => {
const store = new StateStore(testFilePath);
expect(store.demoteOrRemoveSession('does-not-exist')).toBe('absent');
});
it('should keep pinned records during cleanupStaleSessions but reap unpinned ones', () => {
const store = new StateStore(testFilePath);
store.setSession('pinned-1', {
...createMockSessionState('pinned-1'),
pinned: true,
pinnedAt: Date.now(),
});
store.setSession('plain-1', createMockSessionState('plain-1'));
const result = store.cleanupStaleSessions(new Set<string>());
expect(result.count).toBe(1);
expect(result.cleaned.map((c) => c.id)).toEqual(['plain-1']);
expect(result.cleaned.some((c) => c.id === 'pinned-1')).toBe(false);
expect(store.getSession('pinned-1')).not.toBeNull();
expect(store.getSession('plain-1')).toBeNull();
});
});
describe('task operations', () => {
it('should set and get tasks', () => {
const store = new StateStore(testFilePath);