Files
web-terminal/public/launcher.ts
Yaojia Wang 4892fa7b49 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.
2026-07-30 10:40:10 +02:00

268 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* public/launcher.ts — the home "session chooser" shown when no tab is open.
*
* Opening the app no longer auto-creates or auto-restores tabs. Instead this
* start screen lists the host's running sessions as live preview thumbnails
* (read-only xterm, same as the manage page) so the user picks which to open —
* or starts a new one. Sessions persist server-side, so opening one replays its
* full scrollback. The thumbnail card + preview plumbing is shared with the
* manage page via public/preview-grid.ts (DRY).
*/
import type { LiveSessionInfo, OrphanSessionInfo } from '../src/types.js'
import {
el,
relTime,
makePreviewCard,
updatePreviewCard,
loadPreviewInto,
fetchLiveSessions,
fetchOrphanSessions,
fetchOrphanPreview,
renderPreview,
orphanAsLive,
killOrphanReq,
cleanupOrphansReq,
type PreviewCard,
} from './preview-grid.js'
export interface LauncherHooks {
onOpen: (id: string) => void
onNew: () => void
}
const THUMB_W = 320
const THUMB_MAX_H = 200
const REFRESH_MS = 5000
export interface Launcher {
setVisible(v: boolean): void
refresh(): void
}
export function mountLauncher(host: HTMLElement, hooks: LauncherHooks): Launcher {
const root = el('div', 'launcher')
root.style.display = 'none'
host.appendChild(root)
const head = el('div', 'launcher-head')
head.append(el('div', 'launcher-title', 'Your sessions'))
const sub = el('div', 'launcher-sub', '')
head.append(sub)
root.append(head)
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')
newTile.title = 'Start a new session'
newTile.setAttribute('aria-label', 'New session')
newTile.append(el('div', 'mg-new-plus', ''), el('div', 'mg-new-label', 'New session'))
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(() => {})
void refresh()
}
function makeCard(s: LiveSessionInfo): PreviewCard {
const kill = el('button', 'mg-kill', 'Kill ✕')
kill.title = 'Kill this session'
kill.addEventListener('click', () => void killOne(s.id))
return makePreviewCard(s, {
onOpen: (id) => hooks.onOpen(id),
extraActions: () => [kill],
})
}
async function refresh(): Promise<void> {
const sessions = await fetchLiveSessions()
sub.textContent = sessions.length
? `${sessions.length} running on this host — pick one to open`
: 'No sessions running yet'
// Keep the New-session tile as the first grid cell.
if (grid.firstChild !== newTile) grid.prepend(newTile)
const seen = new Set<string>()
for (const s of sessions) {
seen.add(s.id)
let card = cards.get(s.id)
if (!card) {
card = makeCard(s)
cards.set(s.id, card)
grid.append(card.el)
}
updatePreviewCard(card, s)
card.meta.textContent = `${s.cwd ?? 'unknown dir'} · ${s.cols}×${s.rows} · ${relTime(s.createdAt)} old`
void loadPreviewInto(s.id, card, THUMB_W, THUMB_MAX_H)
}
for (const [id, card] of cards) {
if (!seen.has(id)) {
card.term.dispose()
card.el.remove()
cards.delete(id)
}
}
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 sessions 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'
if (v) {
void refresh()
if (timer === null) {
timer = setInterval(() => {
if (root.style.display !== 'none') void refresh()
}, REFRESH_MS)
}
} else {
if (timer !== null) {
clearInterval(timer)
timer = null
}
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 {
void refresh()
},
}
}