feat(sessions): surface tmux sessions the server does not track
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.
This commit is contained in:
@@ -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<SessionPreview | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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<LiveSessionInfo[]> {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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).
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user