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:
@@ -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<void> } {
|
||||
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<void> } {
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
45
src/types.ts
45
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;
|
||||
|
||||
Reference in New Issue
Block a user