fix(v0.4): mirror size-clamp bug + session manager page

The mirror looked broken because a backgrounded tab on one device still pinned
the shared PTY to its (default 80x24) size — so the device actively viewing got
a cramped terminal.

Fix: only an ACTIVELY-VIEWING client votes on PTY size.
- attachWs no longer seeds a default size vote (a join may be a hidden mirror)
- new 'blur' client message + clearClientDims(): a tab going hidden withdraws its
  size vote (still mirrors output); show() re-casts it
- PTY size = min cols/rows across clients that have actually reported dims
- session/protocol tests cover hidden-mirror-doesn't-clamp + blur

Session manager (separate page, for the 'too many sessions' problem):
- GET stays; add DELETE /live-sessions/:id and DELETE /live-sessions[?detached=1]
- manager.killById(); LiveSessionInfo gains cols/rows
- public/manage.html + manage.ts: list/open/kill sessions, kill-all / kill-detached,
  auto-refresh; 🗂 toolbar button; bundled as a 2nd esbuild entry

Verified: two concurrent clients mirror output + shared input; manage page
lists/kills (3→2→0). 225 tests green, tsc clean.
This commit is contained in:
Yaojia Wang
2026-06-19 11:04:38 +02:00
parent 22210fadbc
commit 021a514b2d
13 changed files with 467 additions and 44 deletions

View File

@@ -24,7 +24,7 @@ export const SESSION_ID_RE =
// ─── Type whitelist ───────────────────────────────────────────────────────────
const ALLOWED_TYPES = new Set<string>(['attach', 'input', 'resize', 'approve', 'reject'])
const ALLOWED_TYPES = new Set<string>(['attach', 'input', 'resize', 'blur', 'approve', 'reject'])
// ─── parseClientMessage ───────────────────────────────────────────────────────
@@ -73,6 +73,11 @@ export function parseClientMessage(raw: string): ParseResult {
return validateInput(obj)
}
// blur (v0.4) carries no payload — withdraw this client's size vote.
if (type === 'blur') {
return { ok: true, message: { type: 'blur' } }
}
// approve/reject (H3) carry no payload.
if (type === 'approve') {
return { ok: true, message: { type: 'approve' } }

View File

@@ -39,7 +39,7 @@ import { isOriginAllowed } from './http/origin.js'
import { parseHookEvent } from './http/hook.js'
import { listSessions } from './http/history.js'
import { createSessionManager } from './session/manager.js'
import { detachWs, writeInput, setClientDims } from './session/session.js'
import { detachWs, writeInput, setClientDims, clearClientDims } from './session/session.js'
import type { Config } from './types.js'
import { WS_OPEN } from './types.js'
@@ -117,6 +117,23 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
res.json(manager.list())
})
// Kill ALL sessions, or only detached ones (?detached=1) — manage page bulk action.
app.delete('/live-sessions', (req, res) => {
const onlyDetached = req.query['detached'] === '1'
let killed = 0
for (const s of manager.list()) {
if (onlyDetached && s.clientCount > 0) continue
if (manager.killById(s.id)) killed += 1
}
res.json({ killed })
})
// Kill one session by id — manage page per-row action.
app.delete('/live-sessions/:id', (req, res) => {
const ok = manager.killById(req.params.id)
res.status(ok ? 204 : 404).end()
})
// ── Claude Code hook side-channel (H2) ────────────────────────────────────
// Hooks running inside a spawned shell POST status here (loopback only — the
// shell runs on the host). sessionId arrives in the X-Webterm-Session header.
@@ -268,8 +285,11 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
if (msg.type === 'input') {
writeInput(session, msg.data)
} else if (msg.type === 'resize') {
// Per-client dims; the PTY tracks the min across all sharing devices.
// Per-client dims; the PTY tracks the min across actively-viewing devices.
setClientDims(session, ws, msg.cols, msg.rows)
} else if (msg.type === 'blur') {
// Tab hidden on this device — withdraw its size vote (still mirrors output).
clearClientDims(session, ws)
} else if (msg.type === 'approve') {
// H3: resolve the held PermissionRequest with allow.
resolvePending(boundSessionId, permDecision('allow'))

View File

@@ -92,7 +92,7 @@ export function createSessionManager(cfg: Config): SessionManager {
// M4: createSession may throw (spawn failure). Do NOT catch here.
// M6: cwd (if given) is the spawn directory for "new tab here".
const session = createSession(cfg, dims, now, onSessionExit, undefined, cwd);
attachWs(session, ws, dims);
attachWs(session, ws);
sessions = new Map(sessions).set(session.meta.id, session);
return session;
}
@@ -101,7 +101,7 @@ export function createSessionManager(cfg: Config): SessionManager {
// ── Case 2: hit a live session → JOIN it (multi-device sharing) ───────────
if (existing !== undefined && existing.exitedAt === null) {
attachWs(existing, ws, dims);
attachWs(existing, ws);
return existing;
}
@@ -122,7 +122,7 @@ export function createSessionManager(cfg: Config): SessionManager {
// to the still-running shell instead of spawning a fresh one.
if (cfg.useTmux && hasSession(tmuxName(sessionId))) {
const revived = createSession(cfg, dims, now, onSessionExit, sessionId);
attachWs(revived, ws, dims);
attachWs(revived, ws);
sessions = new Map(sessions).set(revived.meta.id, revived);
return revived;
}
@@ -130,7 +130,7 @@ export function createSessionManager(cfg: Config): SessionManager {
// ── Case 4: session not found → create a new one ─────────────────────────
// M4: createSession may throw. Do NOT catch here.
const session = createSession(cfg, dims, now, onSessionExit);
attachWs(session, ws, dims);
attachWs(session, ws);
sessions = new Map(sessions).set(session.meta.id, session);
return session;
}
@@ -149,10 +149,29 @@ export function createSessionManager(cfg: Config): SessionManager {
status: s.claudeStatus,
exited: s.exitedAt !== null,
cwd: s.cwd,
cols: s.pty.cols,
rows: s.pty.rows,
}))
.sort((a, b) => b.createdAt - a.createdAt);
}
/**
* Kill a session by id (manage page): close every attached client, kill the
* PTY (and tmux session, H1), and drop it from the table. Returns whether a
* session was found.
*/
function killById(id: string): boolean {
const session = sessions.get(id);
if (session === undefined) return false;
for (const ws of session.clients) {
if (ws.readyState === WS_OPEN) ws.close();
}
kill(session);
sessions = new Map(sessions);
sessions.delete(id);
return true;
}
/**
* H2: a Claude Code hook reported activity for `sessionId`. Record it and push
* a `status` frame to the attached ws (no-op if the session is gone/detached).
@@ -223,5 +242,5 @@ export function createSessionManager(cfg: Config): SessionManager {
sessions = new Map();
}
return { handleAttach, get, list, handleHookEvent, reapIdle, shutdown };
return { handleAttach, get, list, killById, handleHookEvent, reapIdle, shutdown };
}

View File

@@ -150,16 +150,18 @@ export function createSession(
}
/**
* Add `ws` as a client (multi-device sharing): register its dims, clear the
* detached stamp, re-derive the shared PTY size, then replay the scrollback to
* THIS client only so it sees the current screen. Other clients are untouched.
* Add `ws` as a client (multi-device sharing): clear the detached stamp, then
* replay the scrollback to THIS client only so it sees the current screen.
* No kicking — devices share the session (invariant #5 relaxed for v0.4).
*
* The client does NOT get a size vote here: only a client that is actively
* viewing the session sends a `resize` (the frontend skips hidden panes), so a
* background mirror never clamps the shared PTY. The vote is added on the first
* `setClientDims` and removed on `clearClientDims`/`detachWs`.
*/
export function attachWs(session: Session, ws: WebSocketLike, dims: Dims): void {
export function attachWs(session: Session, ws: WebSocketLike): void {
session.clients.add(ws);
session.clientDims.set(ws, dims);
session.detachedAt = null;
applyMinDims(session);
// Replay the buffered scrollback so the joining client sees the last screen.
sendIfOpen(ws, { type: 'output', data: session.buffer.snapshot() });
@@ -201,6 +203,15 @@ export function setClientDims(
applyMinDims(session);
}
/**
* Withdraw a client's size vote without detaching it (its tab was hidden). The
* client still receives output (it's a background mirror); it just stops
* constraining the shared PTY size. Re-votes on its next `setClientDims`.
*/
export function clearClientDims(session: Session, ws: WebSocketLike): void {
if (session.clientDims.delete(ws)) applyMinDims(session);
}
/**
* Kill the session (idle reclaim). Under tmux this ends the actual shell via
* `tmux kill-session` (H1); killing only the client pty would leave the tmux

View File

@@ -45,6 +45,9 @@ export type ClientMessage =
| { type: 'attach'; sessionId: string | null; cwd?: string }
| { type: 'input'; data: string }
| { type: 'resize'; cols: number; rows: number }
// v0.4: this client stopped actively viewing (tab hidden) — withdraw its size
// vote so a background mirror doesn't clamp the shared PTY (min-dims).
| { type: 'blur' }
| { type: 'approve' }
| { type: 'reject' };
@@ -167,10 +170,11 @@ export interface Session {
// impl anchors [src/session/session.ts]:
// createSession(cfg: Config, dims: Dims, now: number, onExit: (s: Session) => void): Session
// // spawn failure THROWS, not swallowed (M4)
// attachWs(session: Session, ws: WebSocketLike, dims: Dims): void // adds a client, replays buffer (no kick)
// attachWs(session: Session, ws: WebSocketLike): void // adds a client, replays buffer (no size vote until it resizes)
// detachWs(session: Session, ws: WebSocketLike, now: number): void // removes one client; never kills PTY
// writeInput(session: Session, data: string): void // no-op after exit (L4)
// setClientDims(session: Session, ws: WebSocketLike, cols, rows): void // PTY = min over clients (L4 no-op after exit)
// setClientDims(session: Session, ws: WebSocketLike, cols, rows): void // PTY = min over active viewers (L4 no-op after exit)
// clearClientDims(session: Session, ws: WebSocketLike): void // withdraw this client's size vote (tab hidden / blur)
// kill(session: Session): void
/* ──────────────────────── manager (§3.5) ─────────────────────── */
@@ -183,6 +187,8 @@ export interface LiveSessionInfo {
status: ClaudeStatus;
exited: boolean;
cwd: string | null;
cols: number; // current PTY size (for the manage page)
rows: number;
}
export interface SessionManager {
@@ -196,6 +202,9 @@ export interface SessionManager {
get(id: string): Session | undefined;
/** Live sessions for multi-device discovery (newest first). */
list(): LiveSessionInfo[];
/** 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;
/** Set a session's Claude status (from a hook) and push it to the attached ws (H2/H3). */
handleHookEvent(
sessionId: string,