diff --git a/public/launcher.ts b/public/launcher.ts index b778349..38f8325 100644 --- a/public/launcher.ts +++ b/public/launcher.ts @@ -9,7 +9,7 @@ * manage page via public/preview-grid.ts (DRY). */ -import type { LiveSessionInfo } from '../src/types.js' +import type { LiveSessionInfo, OrphanSessionInfo } from '../src/types.js' import { el, relTime, @@ -17,6 +17,12 @@ import { updatePreviewCard, loadPreviewInto, fetchLiveSessions, + fetchOrphanSessions, + fetchOrphanPreview, + renderPreview, + orphanAsLive, + killOrphanReq, + cleanupOrphansReq, type PreviewCard, } from './preview-grid.js' @@ -48,6 +54,24 @@ export function mountLauncher(host: HTMLElement, hooks: LauncherHooks): Launcher const grid = el('div', 'mg-grid') root.append(grid) + // A: tmux sessions that outlived the server process. Its own section, below the + // live grid, because these are recoveries rather than things you are working on + // — and it stays hidden entirely when there are none (the common case, and the + // only case when tmux is off). + const orphanSection = el('div', 'launcher-orphans') + orphanSection.style.display = 'none' + const orphanHead = el('div', 'launcher-orphans-head') + const orphanTitle = el('div', 'launcher-orphans-title', 'Recoverable') + const orphanSub = el('div', 'launcher-orphans-sub', '') + const cleanupBtn = el('button', 'launcher-cleanup', 'Clean up idle 7d+') + cleanupBtn.title = 'Kill unattached recoverable sessions with no tmux activity for 7 days' + orphanHead.append(orphanTitle, orphanSub, cleanupBtn) + const orphanGrid = el('div', 'mg-grid') + const orphanMore = el('div', 'launcher-orphans-more', '') + orphanMore.style.display = 'none' + orphanSection.append(orphanHead, orphanGrid, orphanMore) + root.append(orphanSection) + // "New session" is a tile that leads the grid (matches the session cards), // instead of a heavy header button. Re-prepended on every refresh. const newTile = el('button', 'mg-new-card') @@ -57,8 +81,14 @@ export function mountLauncher(host: HTMLElement, hooks: LauncherHooks): Launcher newTile.addEventListener('click', () => hooks.onNew()) const cards = new Map() + const orphanCards = new Map() let timer: ReturnType | null = null + const CLEANUP_IDLE_DAYS = 7 + /** Thumbnails are xterm instances; a host with dozens of stale tmux sessions + * would otherwise mount dozens of terminals on the home screen. */ + const MAX_ORPHAN_CARDS = 24 + /** Kill a session (DELETE /live-sessions/:id) then refresh the grid. */ async function killOne(id: string): Promise { await fetch(`/live-sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }).catch(() => {}) @@ -106,8 +136,106 @@ export function mountLauncher(host: HTMLElement, hooks: LauncherHooks): Launcher } } + await refreshOrphans() } + /** Kill one orphan, then refresh. */ + async function killOrphan(id: string): Promise { + await killOrphanReq(id) + void refresh() + } + + /** Opening an orphan revives it: attaching with its id re-adopts the tmux + * session in place, after which it appears in the live grid above. */ + function makeOrphanCard(o: OrphanSessionInfo): PreviewCard { + const kill = el('button', 'mg-kill', 'Kill ✕') + kill.title = 'End this session’s shell' + kill.addEventListener('click', () => void killOrphan(o.id)) + return makePreviewCard(orphanAsLive(o), { + onOpen: (id) => hooks.onOpen(id), + extraActions: () => [kill], + }) + } + + async function refreshOrphans(): Promise { + const orphans = await fetchOrphanSessions() + + if (orphans.length === 0) { + orphanSection.style.display = 'none' + for (const card of orphanCards.values()) { + card.term.dispose() + card.el.remove() + } + orphanCards.clear() + return + } + + orphanSection.style.display = '' + const idle = orphans.filter((o) => !o.attached).length + orphanSub.textContent = + `${orphans.length} tmux session${orphans.length === 1 ? '' : 's'} not open here — ` + + `open one to take it over${idle > 0 ? `, ${idle} unattended` : ''}` + + // Server-sorted most-recently-active first, so the cap keeps the ones you are + // plausibly looking for. The tail is what the cleanup button is for. + const shown = orphans.slice(0, MAX_ORPHAN_CARDS) + const hidden = orphans.length - shown.length + + const seen = new Set() + for (const o of shown) { + seen.add(o.id) + let card = orphanCards.get(o.id) + const isNew = card === undefined + if (card === undefined) { + card = makeOrphanCard(o) + orphanCards.set(o.id, card) + orphanGrid.append(card.el) + } + updatePreviewCard(card, orphanAsLive(o)) + card.meta.textContent = + `${o.cols}×${o.rows} · active ${relTime(o.lastActivityAt)} ago · ` + + `${relTime(o.createdAt)} old` + // ONCE per card, not on every 5 s tick: each preview spawns a tmux + // capture-pane, and re-capturing every card every tick would mean dozens of + // processes a minute for screens that are, by definition, not being driven + // from here. Fire-and-forget so a slow capture never stalls the next card. + if (isNew) { + const target = card + void fetchOrphanPreview(o.id).then((p) => { + if (p) renderPreview(target, p, THUMB_W, THUMB_MAX_H) + }) + } + } + for (const [id, card] of orphanCards) { + if (!seen.has(id)) { + card.term.dispose() + card.el.remove() + orphanCards.delete(id) + } + } + + // Never truncate silently. + orphanMore.textContent = + hidden > 0 + ? `${hidden} older session${hidden === 1 ? '' : 's'} not shown — clean up, or attach by id from a terminal with: tmux attach -t web_` + : '' + orphanMore.style.display = hidden > 0 ? '' : 'none' + } + + cleanupBtn.addEventListener('click', () => { + const n = orphanCards.size + if ( + !window.confirm( + `Kill unattached recoverable sessions with no activity for ${CLEANUP_IDLE_DAYS} days?\n\n` + + `This ends their shells. Sessions someone is attached to are never touched. ` + + `(${n} recoverable session${n === 1 ? '' : 's'} listed.)`, + ) + ) { + return + } + void cleanupOrphansReq(CLEANUP_IDLE_DAYS).then(() => refresh()) + }) + return { setVisible(v: boolean): void { root.style.display = v ? 'block' : 'none' @@ -126,6 +254,10 @@ export function mountLauncher(host: HTMLElement, hooks: LauncherHooks): Launcher for (const card of cards.values()) card.term.dispose() cards.clear() grid.replaceChildren() + for (const card of orphanCards.values()) card.term.dispose() + orphanCards.clear() + orphanGrid.replaceChildren() + orphanSection.style.display = 'none' } }, refresh(): void { diff --git a/public/preview-grid.ts b/public/preview-grid.ts index 48419c5..2947d6f 100644 --- a/public/preview-grid.ts +++ b/public/preview-grid.ts @@ -9,7 +9,12 @@ */ import { Terminal } from '@xterm/xterm' -import type { LiveSessionInfo, StatusTelemetry, ClaudeStatus } from '../src/types.js' +import type { + LiveSessionInfo, + OrphanSessionInfo, + StatusTelemetry, + ClaudeStatus, +} from '../src/types.js' /** Shape of GET /live-sessions/:id/preview. */ export interface SessionPreview { @@ -152,9 +157,12 @@ export async function fetchPreview(id: string): Promise { } } -/** Render a preview into a card's xterm, resizing + scaling to fit. */ +/** Render a preview into a card's xterm, resizing + scaling to fit. + * cols/rows of 0 mean "size unknown, keep what the card already has" — the + * orphan preview omits them because its dimensions came with the list, and + * resizing to Math.max(2, 0) would collapse the thumbnail to 2×2. */ export function renderPreview(card: PreviewCard, p: SessionPreview, thumbW: number, thumbMaxH: number): void { - if (card.term.cols !== Math.max(2, p.cols) || card.term.rows !== Math.max(2, p.rows)) { + if (p.cols > 0 && p.rows > 0 && (card.term.cols !== p.cols || card.term.rows !== p.rows)) { card.term.resize(Math.max(2, p.cols), Math.max(2, p.rows)) } card.term.reset() @@ -183,6 +191,80 @@ export async function fetchLiveSessions(): Promise { } } +/* ── A: orphan tmux sessions ───────────────────────────────────────────────── + Sessions that outlived the server process which created them. They reuse the + card machinery above through orphanAsLive, so there is one thumbnail + implementation rather than two that drift. */ + +/** Fetch untracked tmux sessions, returning [] on failure. */ +export async function fetchOrphanSessions(): Promise { + try { + const res = await fetch('/orphan-sessions') + const data: unknown = await res.json() + return Array.isArray(data) ? (data as OrphanSessionInfo[]) : [] + } catch { + return [] + } +} + +/** Fetch an orphan's screen (tmux capture-pane). null on any failure. */ +export async function fetchOrphanPreview(id: string): Promise { + try { + const res = await fetch(`/orphan-sessions/${encodeURIComponent(id)}/preview`) + if (!res.ok) return null + return (await res.json()) as SessionPreview + } catch { + return null + } +} + +/** + * Present an orphan as a LiveSessionInfo so makePreviewCard can render it. + * + * `status` is 'unknown', never 'idle': status comes from Claude Code's hooks into + * a session this server is streaming, and for an orphan we have not looked — so + * claiming 'idle' would assert something we do not know. `cwd` is null for the + * same reason. `clientCount` mirrors tmux's client count, so a session someone is + * attached to from a real terminal reads as watched rather than abandoned. + */ +export function orphanAsLive(o: OrphanSessionInfo): LiveSessionInfo { + return { + id: o.id, + createdAt: o.createdAt, + clientCount: o.attached ? 1 : 0, + status: 'unknown', + exited: false, + cwd: null, + cols: o.cols, + rows: o.rows, + lastOutputAt: o.lastActivityAt, + } +} + +/** Kill one orphan. Resolves true on 204. */ +export async function killOrphanReq(id: string): Promise { + try { + const res = await fetch(`/orphan-sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }) + return res.status === 204 + } catch { + return false + } +} + +/** Bulk-kill unattached orphans idle longer than `days`. Returns ids killed. */ +export async function cleanupOrphansReq(days: number): Promise { + try { + const res = await fetch(`/orphan-sessions?idleDays=${encodeURIComponent(String(days))}`, { + method: 'DELETE', + }) + if (!res.ok) return [] + const body = (await res.json()) as { ids?: unknown } + return Array.isArray(body.ids) ? (body.ids as string[]) : [] + } catch { + return [] + } +} + /** * Render a telemetry gauge into `container` (clears first). * diff --git a/public/style.css b/public/style.css index 7db0aa1..7b3ba29 100644 --- a/public/style.css +++ b/public/style.css @@ -1192,6 +1192,60 @@ body { color: var(--text-dim); font-size: 13px; } +/* A: recoverable (orphan) tmux sessions — a secondary section under the live grid, + separated by a rule so it never competes with what you are actually working on. + Hidden entirely when the list is empty. */ +.launcher-orphans { + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid var(--border); +} +.launcher-orphans-head { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px 16px; + margin-bottom: 16px; +} +.launcher-orphans-title { + font-size: 11px; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--text-faint); +} +.launcher-orphans-sub { + flex: 1 1 auto; + color: var(--text-dim); + font-size: 13px; +} +/* Destructive, so it is the ghost role rather than a filled button — the design + reserves the accent fill for the action you actually came to perform. */ +.launcher-cleanup { + font: inherit; + font-size: 12.5px; + cursor: pointer; + padding: 7px 15px; + border-radius: 7px; + border: 1px solid var(--border-strong); + background: transparent; + color: var(--text-dim); + flex: none; +} +.launcher-cleanup:hover { + border-color: var(--red); + color: var(--red); +} +.launcher-cleanup:focus-visible { + outline: 2px solid var(--text); + outline-offset: 2px; +} +/* The grid is capped, so say so rather than let the tail vanish silently. */ +.launcher-orphans-more { + margin-top: 14px; + font-family: var(--mono-font); + font-size: 11.5px; + color: var(--text-faint); +} .launcher-actions { display: flex; gap: 8px; diff --git a/src/server.ts b/src/server.ts index 7a934c2..c1294ea 100644 --- a/src/server.ts +++ b/src/server.ts @@ -95,6 +95,9 @@ const RATE_LIMIT_WINDOW_MS = 60_000 const DECISION_RATE_MAX = 10 // POST /hook/decision ≤ 10/min/IP const SUBSCRIBE_RATE_MAX = 5 // POST/DELETE /push/subscribe ≤ 5/min/IP const QUEUE_RATE_MAX = 20 // W2: POST/DELETE /live-sessions/:id/queue ≤ 20/min/IP +// A: GET /orphan-sessions/:id/preview ≤ 240/min/IP — one tmux capture-pane each, +// and a full cleanup grid refreshes every visible card at once. +const ORPHAN_PREVIEW_RATE_MAX = 240 const GIT_WRITE_RATE_MAX = 30 // W4: POST /projects/git/{stage,commit} ≤ 30/min/IP const GIT_PUSH_RATE_MAX = 6 // W4: POST /projects/git/push ≤ 6/min/IP (network-bound) const AUTH_RATE_MAX = 10 // w5-access-token: POST /auth + GET /?token= ≤ 10/min/IP (brute-force guard) @@ -251,6 +254,10 @@ export function startServer(cfg: Config): { close(): Promise } { const decisionLimiter = createRateLimiter(DECISION_RATE_MAX, RATE_LIMIT_WINDOW_MS) const subscribeLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS) const queueLimiter = createRateLimiter(QUEUE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W2 + // A: /orphan-sessions/:id/preview spawns one `tmux capture-pane` per call, and a + // cleanup grid can ask for many at once — its own bucket, sized for a full grid + // refresh, so it never eats the queue or git budgets. + const orphanLimiter = createRateLimiter(ORPHAN_PREVIEW_RATE_MAX, RATE_LIMIT_WINDOW_MS) const gitWriteLimiter = createRateLimiter(GIT_WRITE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 stage/commit const gitPushLimiter = createRateLimiter(GIT_PUSH_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 push // w6/G2 fetch gets its OWN bucket: it is the panel's refresh button, so a burst @@ -582,6 +589,65 @@ export function startServer(cfg: Config): { close(): Promise } { // Preview (v0.4 manage page) — the tail of a session's scrollback so the grid // can render a live read-only thumbnail of its current screen. No client/attach. + // ── Orphan tmux sessions (A) — `web_*` tmux sessions this server does NOT + // track, because they outlived the process that created them. Read-only routes + // carry no Origin guard (same threat model as /live-sessions); the DELETEs do, + // and every route re-validates the id via manager, which requires UUID v4 and + // refuses anything the table already owns. + // + // Deliberately a separate path from /live-sessions rather than an extra field on + // it: an orphan supports none of the live-session operations (no replay, status, + // telemetry or inject queue), and the Android/iOS clients decode + // /live-sessions — adding a variant shape there would break them. + app.get('/orphan-sessions', (_req, res) => { + res.json(manager.listOrphans()) + }) + + // Each call spawns a short-lived `tmux capture-pane`, so it gets the same per-IP + // bucket discipline as the other process-spawning routes. + app.get('/orphan-sessions/:id/preview', (req, res) => { + if (!orphanLimiter(req.socket.remoteAddress ?? '', Date.now())) { + res.status(429).end() + return + } + const id = req.params.id + if (!SESSION_ID_RE.test(id)) { + res.status(400).json({ error: 'invalid session id' }) + return + } + const data = manager.captureOrphan(id) + if (data === null) { + res.status(404).end() + return + } + // Same shape as the live preview so the frontend renders both with one path. + res.json({ id, cols: 0, rows: 0, data }) + }) + + // Bulk cleanup — kill UNATTACHED orphans idle longer than `idleDays`. Registered + // BEFORE /orphan-sessions/:id so it is not captured as an :id. `idleDays` is + // required and must be >= 1: there is no "delete everything" form of this route. + app.delete('/orphan-sessions', (req, res) => { + if (!requireAllowedOrigin(req, res)) return + const days = Number(req.query['idleDays']) + if (!Number.isFinite(days) || days < 1) { + res.status(400).json({ error: 'idleDays must be a number >= 1' }) + return + } + const killed = manager.killOrphansIdleSince(Date.now() - days * 86_400_000) + res.json({ killed: killed.length, ids: killed }) + }) + + app.delete('/orphan-sessions/:id', (req, res) => { + if (!requireAllowedOrigin(req, res)) return + const id = req.params.id + if (!SESSION_ID_RE.test(id)) { + res.status(400).json({ error: 'invalid session id' }) + return + } + res.status(manager.killOrphan(id) ? 204 : 404).end() + }) + app.get('/live-sessions/:id/preview', (req, res) => { const session = manager.get(req.params.id) if (session === undefined) { diff --git a/src/session/manager.ts b/src/session/manager.ts index cf44005..880a6d4 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -32,6 +32,7 @@ import type { Dims, EnqueueResult, LiveSessionInfo, + OrphanSessionInfo, NotifyService, PermissionGate, ServerMessage, @@ -44,7 +45,8 @@ import { WS_OPEN } from '../types.js'; import { serialize } from '../protocol.js'; import { createSession, attachWs, broadcast, kill, writeInput } from './session.js'; import { appendEvent, makeTimelineEvent } from './timeline.js'; -import { hasSession, tmuxName } from './tmux.js'; +import { hasSession, tmuxName, killSession, listSessions, capturePane } from './tmux.js'; +import { SESSION_ID_RE } from '../protocol.js'; /** * Send a serialised ServerMessage to `ws` only when it is OPEN (M5). @@ -415,10 +417,81 @@ export function createSessionManager( sessions = new Map(); } + /* ── A: tmux sessions this server does not track ─────────────────────────── + A `web_*` tmux session that outlived the process which made it is invisible + to everything above: list() only walks our own table, and so does reapIdle. + These four functions are the whole of the read/act surface for them. They + never adopt: no PTY is spawned and nothing is attached, because attaching + would resize the tmux window to the new client and SIGWINCH whatever is + running inside it. Joining (which DOES adopt, via handleAttach's re-attach + path) stays an explicit user action. + + One guard, applied identically everywhere: the id must satisfy + SESSION_ID_RE and must NOT be in our table. The first is what stops a + hostile or malformed name reaching `tmux -t`; the second keeps a live + session's shell from being killed behind its own PTY's back. */ + + /** True when `id` is a real, untracked tmux session of ours that we may act on. */ + function isActionableOrphan(id: string): boolean { + if (!cfg.useTmux) return false; + if (!SESSION_ID_RE.test(id)) return false; + if (sessions.has(id)) return false; // tracked ⇒ use get()/killById instead + return hasSession(tmuxName(id)); + } + + function listOrphans(): OrphanSessionInfo[] { + if (!cfg.useTmux) return []; + return listSessions() + .filter((t) => !sessions.has(t.id)) + .map((t) => ({ + id: t.id, + createdAt: t.createdAtMs, + lastActivityAt: t.lastActivityAtMs, + attached: t.attached, + cols: t.cols, + rows: t.rows, + })); + } + + /** The orphan's current screen, or null. Read-only: never attaches. */ + function captureOrphan(id: string): string | null { + if (!isActionableOrphan(id)) return null; + return capturePane(tmuxName(id)); + } + + /** End an orphan's shell. False when it is not ours, not there, or is tracked. */ + function killOrphan(id: string): boolean { + if (!isActionableOrphan(id)) return false; + killSession(tmuxName(id)); + return true; + } + + /** + * Bulk cleanup: kill every UNATTACHED orphan whose last tmux activity predates + * `cutoffMs`. Returns the ids killed. + * + * Attached sessions are skipped on purpose — tmux reporting a client means + * someone is in there from a real terminal or another server process, and + * "this server does not track it" is not evidence that it is abandoned. + */ + function killOrphansIdleSince(cutoffMs: number): string[] { + const killed: string[] = []; + for (const o of listOrphans()) { + if (o.attached) continue; + if (o.lastActivityAt >= cutoffMs) continue; + if (killOrphan(o.id)) killed.push(o.id); + } + return killed; + } + return { handleAttach, get, list, + listOrphans, + captureOrphan, + killOrphan, + killOrphansIdleSince, killById, handleHookEvent, handleStatusLine, diff --git a/src/session/tmux.ts b/src/session/tmux.ts index 451120f..b64a271 100644 --- a/src/session/tmux.ts +++ b/src/session/tmux.ts @@ -10,6 +10,38 @@ */ import { execFileSync } from 'node:child_process' +import { SESSION_ID_RE } from '../protocol.js' + +/** Upper bound on one `capture-pane` payload. A pane is one screen, so this is + * generous; it exists so a wedged tmux can never hand us unbounded output. */ +const CAPTURE_MAX_BUFFER = 512 * 1024 + +/** Tab-separated, machine-readable. tmux reports the two clocks in unix SECONDS. */ +const LIST_FORMAT = [ + '#{session_name}', + '#{session_created}', + '#{session_activity}', + '#{session_attached}', + '#{window_width}', + '#{window_height}', +].join('\t') + +/** + * One `web_*` tmux session as tmux itself describes it (A). + * + * `attached` is tmux's own client count, NOT "this server is streaming it": a + * session can be attached from a plain terminal, or by a second web-terminal + * process. So "we do not track it" and "nobody is using it" are different + * questions, and the UI must not conflate them. + */ +export interface TmuxSessionSummary { + readonly id: string + readonly createdAtMs: number + readonly lastActivityAtMs: number + readonly attached: boolean + readonly cols: number + readonly rows: number +} /** Is the tmux binary available on PATH? */ export function tmuxAvailable(): boolean { @@ -44,3 +76,94 @@ export function killSession(name: string): void { // already gone — fine } } + +/** + * Parse `tmux list-sessions -F LIST_FORMAT` output. Pure, so the parsing rules are + * testable without a tmux binary. + * + * Two filters, both load-bearing: + * - the name must start with `web_`, so we never enumerate (and therefore never + * offer to kill) a tmux session the user created for their own work; + * - the remainder must be a UUID v4 per SESSION_ID_RE — the same gate the attach + * protocol applies (M7). Anything else cannot have been created by this app, and + * refusing it here is also what keeps a hostile name from reaching `tmux -t` + * (e.g. a literal `-t`, or a name full of path traversal). + */ +export function parseSessionList(stdout: string): TmuxSessionSummary[] { + const rows: TmuxSessionSummary[] = [] + for (const line of stdout.split('\n')) { + if (line === '') continue + const parts = line.split('\t') + if (parts.length < 6) continue + + const [name, created, activity, attached, width, height] = parts as [ + string, + string, + string, + string, + string, + string, + ] + if (!name.startsWith('web_')) continue + const id = name.slice('web_'.length) + if (!SESSION_ID_RE.test(id)) continue + + const createdSec = Number(created) + const activitySec = Number(activity) + const cols = Number(width) + const rows_ = Number(height) + if ( + !Number.isFinite(createdSec) || + !Number.isFinite(activitySec) || + !Number.isFinite(cols) || + !Number.isFinite(rows_) + ) { + continue + } + + rows.push({ + id, + createdAtMs: createdSec * 1000, + lastActivityAtMs: activitySec * 1000, + attached: attached !== '0' && attached !== '', + cols, + rows: rows_, + }) + } + return rows.sort((a, b) => b.lastActivityAtMs - a.lastActivityAtMs) +} + +/** Every `web_*` tmux session on the host, most recently active first. Never + * throws; [] when tmux is missing or no tmux server is running. */ +export function listSessions(): TmuxSessionSummary[] { + try { + const out = execFileSync('tmux', ['list-sessions', '-F', LIST_FORMAT], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + maxBuffer: CAPTURE_MAX_BUFFER, + }) + return parseSessionList(out) + } catch { + return [] + } +} + +/** + * The session's CURRENT screen, without attaching. Returns null if it is gone. + * + * `-p` prints to stdout and `-e` keeps the escape sequences, so the bytes can go + * straight into a read-only xterm and keep their colour. Deliberately NOT + * `attach-session`: attaching would make tmux resize the window to the new + * client's dimensions and SIGWINCH whatever is running inside it. + */ +export function capturePane(name: string): string | null { + try { + return execFileSync('tmux', ['capture-pane', '-p', '-e', '-t', name], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + maxBuffer: CAPTURE_MAX_BUFFER, + }) + } catch { + return null + } +} diff --git a/src/types.ts b/src/types.ts index c7e5f96..f38fffd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -318,6 +318,37 @@ export interface LiveSessionInfo { readonly queueLength?: number; } +/** + * A tmux session on this host that the running server does NOT have in its table + * (A). It outlived the process that created it — a server restart, a different + * server process, or the desktop app quitting. + * + * It has no PTY, no ring buffer and no live stream here, so it is deliberately a + * DIFFERENT type from LiveSessionInfo rather than a flag on it: everything the + * manage grid does with a live session (replay, status, telemetry, inject queue) + * is unavailable, and a shared shape would invite callers to assume otherwise. + * What it does support: being listed, previewed via `tmux capture-pane` (no + * attach), killed, and joined — attaching with this id revives it in place + * through the existing re-attach path, at which point it becomes a real + * LiveSessionInfo. + * + * Before this existed such a session was unreachable from the UI forever: nothing + * enumerated tmux, so an id no client still remembered could not be listed, + * previewed, joined or killed, and the idle reaper never saw it either. + */ +export interface OrphanSessionInfo { + id: string; + createdAt: number; // epoch ms + /** tmux's OWN activity clock (epoch ms) — survives server restarts, unlike the + * in-memory lastOutputAt, so it is the only usable age for a cleanup policy. */ + lastActivityAt: number; + /** tmux client count > 0: attached from a real terminal or another server + * process. "Untracked here" does NOT mean "abandoned". */ + attached: boolean; + cols: number; + rows: number; +} + /* ───────────────── W5 fan-out board (parallel agent lanes) ───────────────── */ /** A group of running sessions sharing a repo/worktree-root (fan-out discovery). @@ -442,6 +473,20 @@ export interface SessionManager { get(id: string): Session | undefined; /** Live sessions for multi-device discovery (newest first). */ list(): LiveSessionInfo[]; + /* ── A: tmux sessions NOT in this server's table ────────────────────────── + All four are no-ops (empty / null / false) when cfg.useTmux is off, and all + reject an id that is not a UUID v4 or that the table already owns — a + tracked session must go through get()/killById so its PTY and table entry + stay in step. None of them adopt: nothing is attached, so a running TUI is + never resized. */ + /** Untracked `web_*` tmux sessions, most recently active first. */ + listOrphans(): OrphanSessionInfo[]; + /** An orphan's current screen via `tmux capture-pane` (no attach). */ + captureOrphan(id: string): string | null; + /** End an orphan's shell. False when it is not ours, gone, or tracked. */ + killOrphan(id: string): boolean; + /** Kill every UNATTACHED orphan last active before `cutoffMs`; returns the ids. */ + killOrphansIdleSince(cutoffMs: number): string[]; /** Kill a session by id (manage page): close its clients, kill the PTY, drop it. * Returns true if a session was found and killed. */ killById(id: string): boolean; diff --git a/test/integration/orphan-sessions.test.ts b/test/integration/orphan-sessions.test.ts new file mode 100644 index 0000000..5d5aa6f --- /dev/null +++ b/test/integration/orphan-sessions.test.ts @@ -0,0 +1,208 @@ +/** + * Integration test for the orphan-session routes (A): + * GET /orphan-sessions + * GET /orphan-sessions/:id/preview + * DELETE /orphan-sessions/:id + * + * SAFETY: this file NEVER issues `DELETE /orphan-sessions` (the bulk cleanup), + * because these tests run against the host's real tmux server and would end the + * developer's own long-running sessions. Bulk cleanup is covered with mocks in + * test/manager-orphans.test.ts. The only session touched here is one this file + * creates under a fresh UUID and tears down itself. + */ + +import net from 'node:net' +import { execFileSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { loadConfig } from '../../src/config.js' +import { startServer } from '../../src/server.js' +import type { OrphanSessionInfo } from '../../src/types.js' + +const TMUX_AVAILABLE = (() => { + try { + execFileSync('tmux', ['-V'], { stdio: 'ignore' }) + return true + } catch { + return false + } +})() +const itTmux = TMUX_AVAILABLE ? it : it.skip + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer() + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() + if (addr === null || typeof addr === 'string') { + srv.close() + reject(new Error('unexpected address type')) + return + } + const port = addr.port + srv.close(() => resolve(port)) + }) + srv.on('error', reject) + }) +} + +/* ── tmux off: the whole feature must be inert ──────────────────────────────── */ + +describe('orphan sessions — tmux disabled', () => { + let port: number + let handle: { close(): Promise } + const origin = (): string => `http://127.0.0.1:${port}` + + beforeAll(async () => { + port = await getFreePort() + handle = startServer( + loadConfig({ + PORT: String(port), + BIND_HOST: '127.0.0.1', + SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', + ALLOWED_ORIGINS: `http://127.0.0.1:${port}`, + USE_TMUX: '0', + IDLE_TTL: '86400', + }), + ) + await new Promise((r) => setTimeout(r, 100)) + }) + + afterAll(async () => { + await handle.close() + }) + + it('lists nothing, and does not require an Origin header (read-only)', async () => { + const res = await fetch(`${origin()}/orphan-sessions`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) + + it('404s a preview instead of shelling out to tmux', async () => { + const res = await fetch(`${origin()}/orphan-sessions/${randomUUID()}/preview`) + expect(res.status).toBe(404) + }) + + it('404s a kill', async () => { + const res = await fetch(`${origin()}/orphan-sessions/${randomUUID()}`, { + method: 'DELETE', + headers: { Origin: origin() }, + }) + expect(res.status).toBe(404) + }) +}) + +/* ── tmux on: real session, real capture, real kill ─────────────────────────── */ + +describe('orphan sessions — tmux enabled', () => { + let port: number + let handle: { close(): Promise } + const origin = (): string => `http://127.0.0.1:${port}` + + /** Our throwaway session. Never any other. */ + const id = randomUUID() + const name = `web_${id}` + let created = false + + beforeAll(async () => { + port = await getFreePort() + handle = startServer( + loadConfig({ + PORT: String(port), + BIND_HOST: '127.0.0.1', + SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', + ALLOWED_ORIGINS: `http://127.0.0.1:${port}`, + USE_TMUX: '1', + IDLE_TTL: '86400', + }), + ) + await new Promise((r) => setTimeout(r, 100)) + + if (TMUX_AVAILABLE) { + // Detached, printing a known marker so capture-pane has something to find. + execFileSync( + 'tmux', + ['new-session', '-d', '-s', name, `printf 'ORPHAN_MARKER_%s' OK; sleep 300`], + { stdio: 'ignore' }, + ) + created = true + await new Promise((r) => setTimeout(r, 400)) + } + }) + + afterAll(async () => { + if (created) { + try { + execFileSync('tmux', ['kill-session', '-t', name], { stdio: 'ignore' }) + } catch { + // the kill test already removed it — fine + } + } + await handle.close() + }) + + itTmux('lists our untracked tmux session as an orphan', async () => { + const res = await fetch(`${origin()}/orphan-sessions`) + expect(res.status).toBe(200) + const body = (await res.json()) as OrphanSessionInfo[] + const mine = body.find((o) => o.id === id) + expect(mine).toBeDefined() + expect(mine!.attached).toBe(false) // created with -d, nobody attached + expect(mine!.cols).toBeGreaterThan(0) + expect(mine!.lastActivityAt).toBeGreaterThan(0) + }) + + itTmux('previews the live screen without attaching to it', async () => { + const res = await fetch(`${origin()}/orphan-sessions/${id}/preview`) + expect(res.status).toBe(200) + const body = (await res.json()) as { id: string; data: string } + expect(body.id).toBe(id) + expect(body.data).toContain('ORPHAN_MARKER_OK') + + // The capture must not have left a client behind: still unattached. + const after = execFileSync('tmux', ['ls', '-F', '#{session_name} #{session_attached}'], { + encoding: 'utf8', + }) + expect(after).toContain(`${name} 0`) + }) + + itTmux('rejects a non-UUID id with 400, never reaching tmux', async () => { + for (const bad of ['-t', 'not-a-uuid', '../../etc/passwd']) { + const res = await fetch(`${origin()}/orphan-sessions/${encodeURIComponent(bad)}/preview`) + expect(res.status).toBe(400) + } + }) + + itTmux('refuses a kill with a foreign Origin (CSRF guard)', async () => { + const res = await fetch(`${origin()}/orphan-sessions/${id}`, { + method: 'DELETE', + headers: { Origin: 'http://evil.example' }, + }) + expect(res.status).toBe(403) + // and it is still alive + expect( + execFileSync('tmux', ['ls', '-F', '#{session_name}'], { encoding: 'utf8' }), + ).toContain(name) + }) + + itTmux('kills it, and 404s the second time', async () => { + const first = await fetch(`${origin()}/orphan-sessions/${id}`, { + method: 'DELETE', + headers: { Origin: origin() }, + }) + expect(first.status).toBe(204) + + const listing = execFileSync('tmux', ['ls', '-F', '#{session_name}'], { + encoding: 'utf8', + }).split('\n') + expect(listing).not.toContain(name) + + const second = await fetch(`${origin()}/orphan-sessions/${id}`, { + method: 'DELETE', + headers: { Origin: origin() }, + }) + expect(second.status).toBe(404) + }) +}) diff --git a/test/manager-orphans.test.ts b/test/manager-orphans.test.ts new file mode 100644 index 0000000..149ebac --- /dev/null +++ b/test/manager-orphans.test.ts @@ -0,0 +1,280 @@ +/** + * A — orphan tmux sessions: the manager side. + * + * An "orphan" is a `web_*` tmux session on the host that this server does NOT have + * in its table (it outlived the process that made it). These tests pin the three + * rules that keep the feature safe: + * + * 1. never enumerate or act on a tmux session that is not ours (`web_` + UUID v4); + * 2. never act on an id the table already owns — that path must stay killById, or + * we would kill the shell out from under a live PTY and leave a zombie entry; + * 3. do nothing at all when tmux is off, so the non-tmux deployment is unchanged. + * + * Both node-pty and the tmux CLI wrappers are mocked: no shell, no tmux binary. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import type { Config, Dims, WebSocketLike } from '../src/types.js'; +import { WS_OPEN } from '../src/types.js'; +import { createMockPty, type MockIPty } from './helpers/mock-pty.js'; + +let nextPty: MockIPty = createMockPty(); + +vi.mock('node-pty', () => ({ + spawn: () => nextPty, +})); + +// ── tmux CLI mock ──────────────────────────────────────────────────────────── +const tmuxList = vi.fn<() => unknown[]>(() => []); +const tmuxCapture = vi.fn<(name: string) => string | null>(() => null); +const tmuxKill = vi.fn<(name: string) => void>(() => {}); +const tmuxHas = vi.fn<(name: string) => boolean>(() => true); + +vi.mock('../src/session/tmux.js', () => ({ + tmuxAvailable: () => true, + tmuxName: (id: string) => `web_${id}`, + hasSession: (n: string) => tmuxHas(n), + killSession: (n: string) => tmuxKill(n), + listSessions: () => tmuxList(), + capturePane: (n: string) => tmuxCapture(n), +})); + +const { createSessionManager } = await import('../src/session/manager.js'); + +const U1 = '05caa88b-1f5f-45d9-bbbe-fc5955314870'; +const U2 = '3a63f3c7-acfe-4081-992f-c4dbd55b3d6b'; + +const BASE_CFG = { + port: 3000, + bindHost: '0.0.0.0', + shellPath: '/bin/zsh', + homeDir: '/home/tester', + idleTtlMs: 10_000, + scrollbackBytes: 2 * 1024 * 1024, + maxPayloadBytes: 1024 * 1024, + wsPath: '/term', + maxSessions: 50, + maxMsgsPerSec: 2000, + permTimeoutMs: 300_000, + reapIntervalMs: 60_000, + previewBytes: 24 * 1024, + useTmux: true, + allowedOrigins: [], + projectRoots: ['/home/tester'], + projectScanDepth: 4, + projectScanTtlMs: 10_000, + projectDirtyCheck: true, + editorCmd: 'code', + vapidPublicKey: undefined, + vapidPrivateKey: undefined, + vapidSubject: undefined, + pushStorePath: '/home/tester/.push.json', + pushMaxSubs: 50, + notifyDone: true, + notifyDnd: false, + decisionTokenTtlMs: 300_000, + timelineMax: 200, + timelineEnabled: true, + stuckTtlMs: 10_000, + stuckAlert: true, + diffTimeoutMs: 2000, + diffMaxBytes: 2 * 1024 * 1024, + diffMaxFiles: 300, + statuslineTtlMs: 30_000, + worktreeEnabled: true, + worktreeRoot: undefined, + worktreeTimeoutMs: 10_000, + maxFanoutLanes: 6, + defaultPermissionMode: 'default' as const, + allowAutoMode: false, + queueEnabled: true, + queueMaxItems: 10, + queueItemMaxBytes: 4096, + queueSettleMs: 1500, +} satisfies Config; + +const CFG: Config = BASE_CFG; +const CFG_NO_TMUX: Config = { ...BASE_CFG, useTmux: false }; +const DIMS: Dims = { cols: 80, rows: 24 }; + +function createMockWs(): WebSocketLike & { readyState: number } { + return { + readyState: WS_OPEN, + send() {}, + close() {}, + }; +} + +/** A tmux summary row as src/session/tmux.ts would produce it. */ +function summary(id: string, activityMs = 5_000, attached = false) { + return { + id, + createdAtMs: 1_000, + lastActivityAtMs: activityMs, + attached, + cols: 120, + rows: 40, + }; +} + +beforeEach(() => { + nextPty = createMockPty(); + tmuxList.mockReset().mockReturnValue([]); + tmuxCapture.mockReset().mockReturnValue(null); + tmuxKill.mockReset(); + tmuxHas.mockReset().mockReturnValue(true); +}); + +describe('listOrphans', () => { + it('reports a tmux session the table does not know about', () => { + tmuxList.mockReturnValue([summary(U1, 9_000, true)]); + const mgr = createSessionManager(CFG); + + expect(mgr.listOrphans()).toEqual([ + { + id: U1, + createdAt: 1_000, + lastActivityAt: 9_000, + attached: true, + cols: 120, + rows: 40, + }, + ]); + }); + + it('excludes sessions the table already owns — those are live, not orphans', () => { + const mgr = createSessionManager(CFG); + const live = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); + tmuxList.mockReturnValue([summary(live.meta.id), summary(U2)]); + + expect(mgr.listOrphans().map((o) => o.id)).toEqual([U2]); + }); + + it('is empty when tmux is off — the non-tmux deployment is untouched', () => { + tmuxList.mockReturnValue([summary(U1)]); + const mgr = createSessionManager(CFG_NO_TMUX); + + expect(mgr.listOrphans()).toEqual([]); + expect(tmuxList).not.toHaveBeenCalled(); + }); +}); + +describe('captureOrphan', () => { + it('returns the captured screen for an untracked session', () => { + tmuxCapture.mockReturnValue('screen contents'); + const mgr = createSessionManager(CFG); + + expect(mgr.captureOrphan(U1)).toBe('screen contents'); + expect(tmuxCapture).toHaveBeenCalledWith(`web_${U1}`); + }); + + it('refuses an id that is not a UUID v4 without invoking tmux at all', () => { + const mgr = createSessionManager(CFG); + + expect(mgr.captureOrphan('../../etc/passwd')).toBeNull(); + expect(mgr.captureOrphan('-t')).toBeNull(); + expect(tmuxCapture).not.toHaveBeenCalled(); + }); + + it('refuses when no such tmux session exists', () => { + tmuxHas.mockReturnValue(false); + const mgr = createSessionManager(CFG); + + expect(mgr.captureOrphan(U1)).toBeNull(); + expect(tmuxCapture).not.toHaveBeenCalled(); + }); + + it('refuses an id the table owns — a live session previews from its ring buffer', () => { + const mgr = createSessionManager(CFG); + const live = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); + + expect(mgr.captureOrphan(live.meta.id)).toBeNull(); + expect(tmuxCapture).not.toHaveBeenCalled(); + }); + + it('returns null when tmux is off', () => { + const mgr = createSessionManager(CFG_NO_TMUX); + expect(mgr.captureOrphan(U1)).toBeNull(); + expect(tmuxCapture).not.toHaveBeenCalled(); + }); +}); + +describe('killOrphan', () => { + it('kills the tmux session by its prefixed name', () => { + const mgr = createSessionManager(CFG); + + expect(mgr.killOrphan(U1)).toBe(true); + expect(tmuxKill).toHaveBeenCalledWith(`web_${U1}`); + }); + + it('refuses a non-UUID id without invoking tmux', () => { + const mgr = createSessionManager(CFG); + + expect(mgr.killOrphan('web_x')).toBe(false); + expect(mgr.killOrphan('-t')).toBe(false); + expect(mgr.killOrphan('')).toBe(false); + expect(tmuxKill).not.toHaveBeenCalled(); + }); + + it('refuses an id the table owns — that is killById’s job', () => { + const mgr = createSessionManager(CFG); + const live = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); + + expect(mgr.killOrphan(live.meta.id)).toBe(false); + expect(tmuxKill).not.toHaveBeenCalled(); + }); + + it('reports false when the session is already gone', () => { + tmuxHas.mockReturnValue(false); + const mgr = createSessionManager(CFG); + + expect(mgr.killOrphan(U1)).toBe(false); + expect(tmuxKill).not.toHaveBeenCalled(); + }); + + it('does nothing when tmux is off', () => { + const mgr = createSessionManager(CFG_NO_TMUX); + expect(mgr.killOrphan(U1)).toBe(false); + expect(tmuxKill).not.toHaveBeenCalled(); + }); +}); + +describe('killOrphansIdleSince', () => { + it('kills only sessions whose last activity is older than the cutoff', () => { + tmuxList.mockReturnValue([summary(U1, 1_000), summary(U2, 9_000)]); + const mgr = createSessionManager(CFG); + + expect(mgr.killOrphansIdleSince(5_000)).toEqual([U1]); + expect(tmuxKill).toHaveBeenCalledWith(`web_${U1}`); + expect(tmuxKill).toHaveBeenCalledTimes(1); + }); + + it('never kills a session someone is attached to from a terminal', () => { + // "Untracked by this server" is not "abandoned" — a plain `tmux attach` or a + // second server process both show up as attached, and bulk cleanup must not + // reach into either. + tmuxList.mockReturnValue([summary(U1, 1_000, true)]); + const mgr = createSessionManager(CFG); + + expect(mgr.killOrphansIdleSince(5_000)).toEqual([]); + expect(tmuxKill).not.toHaveBeenCalled(); + }); + + it('never kills a session the table owns, however idle tmux thinks it is', () => { + const mgr = createSessionManager(CFG); + const live = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); + tmuxList.mockReturnValue([summary(live.meta.id, 0)]); + + expect(mgr.killOrphansIdleSince(5_000)).toEqual([]); + expect(tmuxKill).not.toHaveBeenCalled(); + }); + + it('does nothing when tmux is off', () => { + tmuxList.mockReturnValue([summary(U1, 0)]); + const mgr = createSessionManager(CFG_NO_TMUX); + + expect(mgr.killOrphansIdleSince(5_000)).toEqual([]); + expect(tmuxKill).not.toHaveBeenCalled(); + }); +}); diff --git a/test/tmux.test.ts b/test/tmux.test.ts index cea0dea..f4814c2 100644 --- a/test/tmux.test.ts +++ b/test/tmux.test.ts @@ -12,7 +12,8 @@ vi.mock('node:child_process', () => ({ execFileSync: (...a: unknown[]) => mockExec(...a), })) -const { tmuxAvailable, tmuxName, hasSession, killSession } = await import('../src/session/tmux.js') +const { tmuxAvailable, tmuxName, hasSession, killSession, parseSessionList, listSessions, capturePane } = + await import('../src/session/tmux.js') beforeEach(() => { mockExec.mockReset() @@ -68,3 +69,95 @@ describe('killSession', () => { expect(() => killSession('web_gone')).not.toThrow() }) }) + +/* ── A: enumerate + preview tmux sessions the server does not track ─────────── */ + +const U1 = '05caa88b-1f5f-45d9-bbbe-fc5955314870' +const U2 = '3a63f3c7-acfe-4081-992f-c4dbd55b3d6b' + +describe('parseSessionList', () => { + it('parses tmux rows into summaries, converting seconds to ms', () => { + const out = `web_${U1}\t1783911991\t1783911992\t0\t80\t23\n` + expect(parseSessionList(out)).toEqual([ + { + id: U1, + createdAtMs: 1783911991_000, + lastActivityAtMs: 1783911992_000, + attached: false, + cols: 80, + rows: 23, + }, + ]) + }) + + it('sorts most-recently-active first', () => { + const out = [`web_${U1}\t100\t100\t0\t80\t24`, `web_${U2}\t100\t900\t1\t80\t24`].join('\n') + expect(parseSessionList(out).map((s) => s.id)).toEqual([U2, U1]) + }) + + it('reports a session attached from a real terminal', () => { + const out = `web_${U1}\t100\t100\t1\t80\t24` + expect(parseSessionList(out)[0]!.attached).toBe(true) + }) + + it('ignores sessions that are not ours — never touch the user’s own tmux', () => { + const out = ['mywork\t100\t100\t0\t80\t24', `web_${U1}\t100\t100\t0\t80\t24`].join('\n') + expect(parseSessionList(out).map((s) => s.id)).toEqual([U1]) + }) + + it('ignores a web_-prefixed name whose suffix is not a UUID v4', () => { + // Only ids the protocol could have produced are actionable: anything else is + // someone else's session that merely starts with web_. + const out = ['web_../../etc\t100\t100\t0\t80\t24', 'web_-t\t100\t100\t0\t80\t24'].join('\n') + expect(parseSessionList(out)).toEqual([]) + }) + + it('skips malformed and blank rows instead of throwing', () => { + const out = ['', 'garbage', `web_${U1}\tNaN\t100\t0\t80\t24`, `web_${U2}\t100\t100\t0\t80\t24`].join('\n') + expect(parseSessionList(out).map((s) => s.id)).toEqual([U2]) + }) + + it('returns an empty list for empty output (no tmux server)', () => { + expect(parseSessionList('')).toEqual([]) + }) +}) + +describe('listSessions', () => { + it('asks tmux for a machine-readable list', () => { + mockExec.mockReturnValue(`web_${U1}\t100\t100\t0\t80\t24\n`) + expect(listSessions().map((s) => s.id)).toEqual([U1]) + const [file, args] = mockExec.mock.calls[0] as [string, string[]] + expect(file).toBe('tmux') + expect(args[0]).toBe('list-sessions') + expect(args).toContain('-F') + }) + + it('returns [] when there is no tmux server (exec throws)', () => { + mockExec.mockImplementation(() => { + throw new Error('no server running') + }) + expect(listSessions()).toEqual([]) + }) +}) + +describe('capturePane', () => { + it('captures the current screen WITHOUT attaching', () => { + mockExec.mockReturnValue('hello\n') + expect(capturePane('web_x')).toBe('hello\n') + const [file, args] = mockExec.mock.calls[0] as [string, string[]] + expect(file).toBe('tmux') + expect(args[0]).toBe('capture-pane') + expect(args).toContain('-p') // print to stdout — read-only, no client + expect(args).toContain('-e') // keep escape sequences so colour survives + expect(args).toContain('web_x') + // Attaching would resize the session and SIGWINCH whatever is running in it. + expect(args).not.toContain('attach-session') + }) + + it('returns null when the session is gone', () => { + mockExec.mockImplementation(() => { + throw new Error("can't find session") + }) + expect(capturePane('web_gone')).toBeNull() + }) +})