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,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<string, PreviewCard>()
|
||||
const orphanCards = new Map<string, PreviewCard>()
|
||||
let timer: ReturnType<typeof setInterval> | 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string>()
|
||||
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_<id>`
|
||||
: ''
|
||||
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 {
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user