A `web_*` tmux session that outlives the process which created it was unreachable
from the UI, permanently. Nothing enumerated tmux — src/session/tmux.ts had
hasSession but no list — so recovery only worked if a client still had the id in
localStorage. list() walks the in-memory table, and so does reapIdle, so such a
session could not be listed, previewed, joined or killed, and never aged out.
On this host that was 69 tmux sessions with 5 clients: 64 unreachable, the oldest
from 26 Jun, one of them still running a Claude Code session with 7 agents.
This is the "catalogue" approach: make them visible and actionable WITHOUT
adopting them. Nothing is attached, because attaching makes tmux resize the window
to the new client's dimensions and SIGWINCH whatever is running inside — doing
that to dozens of live TUIs at startup is exactly the outcome to avoid. Adoption
stays an explicit user action: opening an orphan re-attaches by id through the
existing re-attach path, and it becomes an ordinary live session.
src/session/tmux.ts listSessions() + parseSessionList() (pure, so the filter
rules are unit-testable) and capturePane(), which reads a
session's current screen via `capture-pane -p -e` — no
client, no resize, colour preserved.
src/session/manager.ts listOrphans/captureOrphan/killOrphan/killOrphansIdleSince
src/server.ts GET /orphan-sessions, GET /orphan-sessions/:id/preview,
DELETE /orphan-sessions/:id, DELETE /orphan-sessions
?idleDays=N
public/ a "Recoverable" section under the home grid, reusing the
existing card/thumbnail machinery via orphanAsLive()
Guards, all enforced in the manager so no route can forget one:
- the tmux name must be `web_` + a UUID v4 (SESSION_ID_RE, the same gate the
attach protocol applies). This is what stops a hostile or malformed name from
reaching `tmux -t` — a literal `-t`, or path traversal — and it means a tmux
session the user created for their own work is never enumerated, never
previewed and never offered for deletion.
- an id the table already owns is refused everywhere: killing it via tmux would
end the shell behind a live PTY's back and leave a zombie table entry. Those go
through killById.
- every function is inert when cfg.useTmux is off, so the non-tmux deployment is
byte-for-byte unchanged.
- bulk cleanup skips ATTACHED sessions and requires idleDays >= 1: there is no
"delete everything" form of the route. "This server does not track it" is not
evidence that it is abandoned — a plain `tmux attach` and a second server
process both report as attached.
- reads carry no Origin guard (same threat model as /live-sessions); both DELETEs
do. Preview gets its own rate-limit bucket since each call spawns a process.
Two things the live run exposed and this fixes: previews were loading
sequentially (69 tmux spawns per 5 s refresh) — now once per card, fire-and-forget
— and the grid is capped at 24 cards because each thumbnail is an xterm instance.
The remainder is stated in the UI with the tmux command to reach it, never
silently truncated.
Separate wire type from LiveSessionInfo on purpose: an orphan supports none of the
live-session operations, and the Android/iOS clients decode /live-sessions, so a
variant shape there would break them.
Tests: 2203 unit (+42: parse/filter rules, all four manager guards, tmux-off
inertness) and 8 integration against real tmux — create a throwaway session,
list it, capture it while asserting it stays unattached, reject non-UUID ids,
reject a foreign Origin, kill it, 404 the second time. The integration file never
issues the bulk DELETE: it runs against the host's real tmux server and would end
the developer's own sessions. Verified live against all 69 with none harmed.
357 lines
12 KiB
TypeScript
357 lines
12 KiB
TypeScript
/**
|
||
* public/preview-grid.ts — shared live-preview thumbnail helpers.
|
||
*
|
||
* The launcher (home session chooser) and the manage page both render a grid of
|
||
* the host's running sessions as read-only xterm thumbnails fed by
|
||
* GET /live-sessions/:id/preview. This module holds the DRY core: the small DOM
|
||
* helper, time/status formatting, the preview card factory, the scaling fit, and
|
||
* the preview fetch — so both pages import it instead of duplicating ~80%.
|
||
*/
|
||
|
||
import { Terminal } from '@xterm/xterm'
|
||
import type {
|
||
LiveSessionInfo,
|
||
OrphanSessionInfo,
|
||
StatusTelemetry,
|
||
ClaudeStatus,
|
||
} from '../src/types.js'
|
||
|
||
/** Shape of GET /live-sessions/:id/preview. */
|
||
export interface SessionPreview {
|
||
id: string
|
||
cols: number
|
||
rows: number
|
||
data: string
|
||
}
|
||
|
||
/** Reset screen + scrollback + cursor before writing the tail. */
|
||
export const PREVIEW_CLEAR = '\x1b[2J\x1b[3J\x1b[H\x1b[0m'
|
||
export const PREVIEW_THEME = { background: '#0e0f13', foreground: '#e7e8ec', cursor: '#0e0f13' }
|
||
|
||
/** Create an element with an optional class and text content. */
|
||
export function el<K extends keyof HTMLElementTagNameMap>(
|
||
tag: K,
|
||
cls?: string,
|
||
text?: string,
|
||
): HTMLElementTagNameMap[K] {
|
||
const node = document.createElement(tag)
|
||
if (cls) node.className = cls
|
||
if (text !== undefined) node.textContent = text
|
||
return node
|
||
}
|
||
|
||
/** Coarse "Ns / Nm / Nh / Nd ago" formatter. */
|
||
export function relTime(ms: number): string {
|
||
const s = Math.max(0, (Date.now() - ms) / 1000)
|
||
if (s < 60) return `${Math.floor(s)}s`
|
||
if (s < 3600) return `${Math.floor(s / 60)}m`
|
||
if (s < 86400) return `${Math.floor(s / 3600)}h`
|
||
return `${Math.floor(s / 86400)}d`
|
||
}
|
||
|
||
/** Human label for a Claude activity status. Supports all ClaudeStatus values including 'stuck'. */
|
||
export function statusText(s: LiveSessionInfo['status']): string {
|
||
if (s === 'working') return '⚙ working'
|
||
if (s === 'waiting') return '⏳ waiting'
|
||
if (s === 'idle') return '✓ idle'
|
||
if (s === 'stuck') return '⚠ stuck'
|
||
return '·'
|
||
}
|
||
|
||
/** Display name for a session: last cwd segment, else the short id. */
|
||
export function sessionName(s: LiveSessionInfo): string {
|
||
return s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8)
|
||
}
|
||
|
||
/** A rendered preview card: the element tree plus the live xterm and its parts. */
|
||
export interface PreviewCard {
|
||
el: HTMLElement
|
||
term: Terminal
|
||
inner: HTMLElement
|
||
thumb: HTMLElement
|
||
watch: HTMLElement
|
||
status: HTMLElement
|
||
meta: HTMLElement
|
||
}
|
||
|
||
export interface MakeCardOpts {
|
||
/** Called when the card (thumbnail or Open action) is activated. */
|
||
onOpen: (id: string) => void
|
||
/** Optional extra action button(s) appended to the card actions row. */
|
||
extraActions?: (s: LiveSessionInfo) => HTMLElement[]
|
||
/** Use an <a href> Open link instead of a button (manage page). */
|
||
openHref?: (id: string) => string
|
||
}
|
||
|
||
/** Build a preview card for one session (read-only xterm thumbnail). */
|
||
export function makePreviewCard(s: LiveSessionInfo, opts: MakeCardOpts): PreviewCard {
|
||
const cardEl = el('div', 'mg-card')
|
||
|
||
const head = el('div', 'mg-card-head')
|
||
const title = el('span', 'mg-name', sessionName(s))
|
||
const status = el('span', `mg-status mg-${s.status}`, statusText(s.status))
|
||
const watch = el('span', s.clientCount > 0 ? 'mg-watch live' : 'mg-watch', `👁 ${s.clientCount}`)
|
||
head.append(title, status, watch)
|
||
|
||
const thumb = el('div', 'mg-thumb')
|
||
const inner = el('div', 'mg-thumb-inner')
|
||
thumb.append(inner)
|
||
thumb.addEventListener('click', () => opts.onOpen(s.id))
|
||
|
||
const meta = el('div', 'mg-meta')
|
||
|
||
const actions = el('div', 'mg-actions')
|
||
if (opts.openHref) {
|
||
const open = el('a', 'mg-open', 'Open ↗') as HTMLAnchorElement
|
||
open.href = opts.openHref(s.id)
|
||
actions.append(open)
|
||
} else {
|
||
const open = el('button', 'mg-open', 'Open ↗')
|
||
open.addEventListener('click', () => opts.onOpen(s.id))
|
||
actions.append(open)
|
||
}
|
||
if (opts.extraActions) actions.append(...opts.extraActions(s))
|
||
|
||
const term = new Terminal({
|
||
cols: Math.max(2, s.cols),
|
||
rows: Math.max(2, s.rows),
|
||
disableStdin: true,
|
||
cursorBlink: false,
|
||
fontFamily: 'Menlo, Consolas, monospace',
|
||
fontSize: 12,
|
||
scrollback: 0,
|
||
theme: PREVIEW_THEME,
|
||
})
|
||
term.open(inner)
|
||
|
||
cardEl.append(head, thumb, meta, actions)
|
||
return { el: cardEl, term, inner, thumb, watch, status, meta }
|
||
}
|
||
|
||
/** Re-apply a session's live status/watch/meta fields to its card in place. */
|
||
export function updatePreviewCard(card: PreviewCard, s: LiveSessionInfo): void {
|
||
card.status.className = `mg-status mg-${s.status}`
|
||
card.status.textContent = statusText(s.status)
|
||
card.watch.className = s.clientCount > 0 ? 'mg-watch live' : 'mg-watch'
|
||
card.watch.textContent = `👁 ${s.clientCount}`
|
||
}
|
||
|
||
/** Scale the rendered xterm down so the full screen fits `thumbW` px wide. */
|
||
export function fitThumb(card: PreviewCard, thumbW: number, thumbMaxH: number): void {
|
||
const w = card.inner.offsetWidth
|
||
const h = card.inner.offsetHeight
|
||
if (w === 0 || h === 0) return
|
||
const scale = Math.min(1, thumbW / w)
|
||
card.inner.style.transform = `scale(${scale})`
|
||
card.thumb.style.height = `${Math.min(h * scale, thumbMaxH)}px`
|
||
}
|
||
|
||
/** Fetch a session preview, returning null on any failure (best-effort). */
|
||
export async function fetchPreview(id: string): Promise<SessionPreview | null> {
|
||
try {
|
||
const res = await fetch(`/live-sessions/${id}/preview`)
|
||
if (!res.ok) return null
|
||
return (await res.json()) as SessionPreview
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/** 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 (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()
|
||
card.term.write(PREVIEW_CLEAR + p.data, () => requestAnimationFrame(() => fitThumb(card, thumbW, thumbMaxH)))
|
||
}
|
||
|
||
/** Fetch + render a session's preview into its card (best-effort, no throw). */
|
||
export async function loadPreviewInto(
|
||
id: string,
|
||
card: PreviewCard,
|
||
thumbW: number,
|
||
thumbMaxH: number,
|
||
): Promise<void> {
|
||
const p = await fetchPreview(id)
|
||
if (p) renderPreview(card, p, thumbW, thumbMaxH)
|
||
}
|
||
|
||
/** Fetch the host's live sessions list, returning [] on failure. */
|
||
export async function fetchLiveSessions(): Promise<LiveSessionInfo[]> {
|
||
try {
|
||
const res = await fetch('/live-sessions')
|
||
const data: unknown = await res.json()
|
||
return Array.isArray(data) ? (data as LiveSessionInfo[]) : []
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
/* ── 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<OrphanSessionInfo[]> {
|
||
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<SessionPreview | null> {
|
||
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<boolean> {
|
||
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<string[]> {
|
||
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).
|
||
*
|
||
* Shows: context-usage bar (>80% = warning colour), $cost chip, model chip,
|
||
* and a PR badge. When `telemetry.at` is older than `staleTtlMs` the container
|
||
* receives the class `tg-stale` so CSS can grey it out. When `costBudgetUsd` is
|
||
* set (>0) and `costUsd >= costBudgetUsd`, the cost chip gets `tg-cost-warn`
|
||
* (W3 quick-wins b), mirroring the ctx>80% warn path.
|
||
*
|
||
* Security: all telemetry strings are set via `textContent` (SEC-H5); the PR
|
||
* link href is only set when `url.protocol === 'https:'` (SEC-L5).
|
||
* Zero `innerHTML` anywhere.
|
||
*/
|
||
export function renderTelemetryGauge(
|
||
container: HTMLElement,
|
||
telemetry: StatusTelemetry | null,
|
||
staleTtlMs: number,
|
||
costBudgetUsd?: number,
|
||
): void {
|
||
// Clear existing children
|
||
while (container.firstChild) container.removeChild(container.firstChild)
|
||
|
||
if (!telemetry) {
|
||
container.classList.remove('tg-stale')
|
||
return
|
||
}
|
||
|
||
const isStale = Date.now() - telemetry.at > staleTtlMs
|
||
if (isStale) container.classList.add('tg-stale')
|
||
else container.classList.remove('tg-stale')
|
||
|
||
// Context-usage bar
|
||
if (telemetry.contextUsedPct !== undefined) {
|
||
const bar = el('div', 'tg-ctx-bar')
|
||
const fill = el('div', 'tg-ctx-fill')
|
||
fill.style.width = `${Math.min(100, telemetry.contextUsedPct)}%`
|
||
if (telemetry.contextUsedPct > 80) fill.classList.add('tg-ctx-warn')
|
||
bar.append(fill, el('span', 'tg-ctx-label', `ctx ${Math.round(telemetry.contextUsedPct)}%`))
|
||
container.append(bar)
|
||
}
|
||
|
||
// Cost chip — W3(b): warn-styled once cost crosses the configured budget.
|
||
if (telemetry.costUsd !== undefined) {
|
||
const cost = el('span', 'tg-cost', `$${telemetry.costUsd.toFixed(4)}`)
|
||
if (costBudgetUsd !== undefined && costBudgetUsd > 0 && telemetry.costUsd >= costBudgetUsd) {
|
||
cost.classList.add('tg-cost-warn')
|
||
}
|
||
container.append(cost)
|
||
}
|
||
|
||
// Model chip
|
||
if (telemetry.model !== undefined) {
|
||
container.append(el('span', 'tg-model', telemetry.model))
|
||
}
|
||
|
||
// PR badge
|
||
if (telemetry.pr !== undefined) {
|
||
const badge = el('span', 'tg-pr')
|
||
const link = el('a', 'tg-pr-link')
|
||
link.textContent = `PR #${telemetry.pr.number}`
|
||
|
||
// SEC-L5: only set href for https URLs
|
||
try {
|
||
const parsed = new URL(telemetry.pr.url)
|
||
if (parsed.protocol === 'https:') {
|
||
link.href = telemetry.pr.url
|
||
link.target = '_blank'
|
||
link.rel = 'noopener noreferrer'
|
||
}
|
||
} catch {
|
||
// Invalid URL — leave href unset
|
||
}
|
||
|
||
badge.append(link)
|
||
if (telemetry.pr.reviewState !== undefined) {
|
||
badge.append(el('span', 'tg-pr-state', telemetry.pr.reviewState))
|
||
}
|
||
container.append(badge)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Render a Claude status badge into `container` (clears first).
|
||
* 'stuck' shows the ⚠ warning symbol (A5). Uses only textContent (SEC-H5).
|
||
*/
|
||
export function renderStatusBadge(container: HTMLElement, status: ClaudeStatus): void {
|
||
while (container.firstChild) container.removeChild(container.firstChild)
|
||
container.append(el('span', `sb-badge sb-${status}`, statusText(status)))
|
||
}
|