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:
Yaojia Wang
2026-07-30 10:40:10 +02:00
parent ffc185aa2d
commit 4892fa7b49
10 changed files with 1162 additions and 6 deletions

View File

@@ -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;