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