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.
224 lines
7.9 KiB
TypeScript
224 lines
7.9 KiB
TypeScript
/**
|
|
* src/session/session.ts (T12) — single PTY session: lifecycle + ws plumbing.
|
|
*
|
|
* Owns one node-pty process and its scrollback. Wires:
|
|
* - pty.onData → ring buffer + (forward to attachedWs as `output`),
|
|
* - pty.onExit → stamp exitedAt/exitCode → (notify attachedWs with `exit`)
|
|
* → call the injected onExit so the manager can drop it (L2).
|
|
*
|
|
* Cross-validated fixes embedded here (ARCHITECTURE §3.4 / §4.4):
|
|
* - M4: spawn failure THROWS — createSession does not swallow it; server.ts
|
|
* catches and serializes a single `exit(-1, reason)` for that connection.
|
|
* - M5: every ws.send is guarded by `ws.readyState === WS_OPEN`.
|
|
* - L1: detach-then-exit keeps the session; with no attachedWs the `exit` is
|
|
* not delivered now — it is re-sent on the next attach (manager's job).
|
|
* - L4: writeInput/resize are silently ignored once the PTY has exited
|
|
* (calling write/resize on a dead PTY throws).
|
|
*
|
|
* The session never holds a reference to the manager; the exit event bubbles
|
|
* across that layer purely through the injected `onExit` callback.
|
|
*
|
|
* Imports src/types.ts (frozen, read-only) and createRingBuffer (T6).
|
|
* node-pty's `spawn` is imported here and mocked in tests so no real shell runs.
|
|
*/
|
|
|
|
import { randomUUID } from 'node:crypto';
|
|
import { spawn } from 'node-pty';
|
|
|
|
import type {
|
|
Config,
|
|
Dims,
|
|
IPty,
|
|
Session,
|
|
SessionMeta,
|
|
ServerMessage,
|
|
WebSocketLike,
|
|
} from '../types.js';
|
|
import { WS_OPEN } from '../types.js';
|
|
import { serialize } from '../protocol.js';
|
|
import { createRingBuffer } from './ring-buffer.js';
|
|
import { tmuxName, killSession } from './tmux.js';
|
|
|
|
/** Send a server message to `ws` only if it is OPEN (M5). Never throws on a
|
|
* closed socket; forwarding to a dead ws is simply a no-op. */
|
|
function sendIfOpen(ws: WebSocketLike | null, msg: ServerMessage): void {
|
|
if (ws !== null && ws.readyState === WS_OPEN) {
|
|
ws.send(serialize(msg));
|
|
}
|
|
}
|
|
|
|
/** Broadcast a server message to every attached client (multi-device sharing). */
|
|
export function broadcast(session: Session, msg: ServerMessage): void {
|
|
for (const ws of session.clients) sendIfOpen(ws, msg);
|
|
}
|
|
|
|
/**
|
|
* Resize the PTY to the MIN cols/rows across all attached clients (tmux-style),
|
|
* so a small phone and a wide laptop sharing the session both see content
|
|
* without overflow. No-op after exit (L4), when no client is attached, or when
|
|
* the computed dims are unchanged (idempotent).
|
|
*/
|
|
function applyMinDims(session: Session): void {
|
|
if (session.exitedAt !== null || session.clientDims.size === 0) return;
|
|
let cols = Infinity;
|
|
let rows = Infinity;
|
|
for (const d of session.clientDims.values()) {
|
|
cols = Math.min(cols, d.cols);
|
|
rows = Math.min(rows, d.rows);
|
|
}
|
|
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
|
|
if (session.pty.cols === cols && session.pty.rows === rows) return;
|
|
session.pty.resize(cols, rows);
|
|
}
|
|
|
|
/**
|
|
* Spawn a PTY and wire its data/exit streams. Throws on spawn failure (M4).
|
|
*
|
|
* @param onExit injected by the manager — invoked after exitedAt is set so the
|
|
* session can be removed from the table (L2).
|
|
*/
|
|
export function createSession(
|
|
cfg: Config,
|
|
dims: Dims,
|
|
now: number,
|
|
onExit: (session: Session) => void,
|
|
// H1: pass an existing id to RE-ATTACH to a surviving tmux session (e.g. after
|
|
// a server restart). Default = a fresh id for a brand-new session.
|
|
id: string = randomUUID(),
|
|
// M6: spawn directory for a new session ("new tab here"); defaults to homeDir.
|
|
cwd?: string,
|
|
): Session {
|
|
// The id is injected into the shell env so Claude Code hooks know which tab an
|
|
// event belongs to ($WEBTERM_SESSION) and where to POST ($WEBTERM_HOOK_URL, H2).
|
|
const tName = cfg.useTmux ? tmuxName(id) : null;
|
|
|
|
// H1: under tmux, the node-pty process is a tmux CLIENT; `new-session -A`
|
|
// attaches to web_<id> if it exists (restart survival) or creates it.
|
|
const file = tName !== null ? 'tmux' : cfg.shellPath;
|
|
const args = tName !== null ? ['new-session', '-A', '-s', tName, cfg.shellPath] : [];
|
|
|
|
// M4: let a spawn failure (e.g. missing shell / tmux) propagate synchronously.
|
|
const pty: IPty = spawn(file, args, {
|
|
name: 'xterm-256color',
|
|
cols: dims.cols,
|
|
rows: dims.rows,
|
|
cwd: cwd ?? cfg.homeDir,
|
|
env: {
|
|
...process.env,
|
|
WEBTERM_SESSION: id,
|
|
WEBTERM_HOOK_URL: `http://127.0.0.1:${cfg.port}/hook`,
|
|
},
|
|
});
|
|
|
|
const meta: SessionMeta = {
|
|
id,
|
|
createdAt: now,
|
|
shellPath: cfg.shellPath,
|
|
};
|
|
|
|
const session: Session = {
|
|
meta,
|
|
buffer: createRingBuffer(cfg.scrollbackBytes),
|
|
clients: new Set(),
|
|
clientDims: new Map(),
|
|
detachedAt: null,
|
|
lastOutputAt: now,
|
|
exitedAt: null,
|
|
exitCode: null,
|
|
claudeStatus: 'unknown',
|
|
cwd: cwd ?? null,
|
|
tmuxName: tName,
|
|
pty,
|
|
};
|
|
|
|
// onData: persist to scrollback, refresh liveness, broadcast to all clients.
|
|
pty.onData((chunk) => {
|
|
session.buffer.append(chunk);
|
|
session.lastOutputAt = Date.now();
|
|
broadcast(session, { type: 'output', data: chunk });
|
|
});
|
|
|
|
// onExit: record exit, notify every client (L1 if none), then bubble up.
|
|
pty.onExit(({ exitCode }) => {
|
|
session.exitedAt = Date.now();
|
|
session.exitCode = exitCode;
|
|
broadcast(session, { type: 'exit', code: exitCode });
|
|
onExit(session);
|
|
});
|
|
|
|
return session;
|
|
}
|
|
|
|
/**
|
|
* 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): void {
|
|
session.clients.add(ws);
|
|
session.detachedAt = null;
|
|
|
|
// Replay the buffered scrollback so the joining client sees the last screen.
|
|
sendIfOpen(ws, { type: 'output', data: session.buffer.snapshot() });
|
|
}
|
|
|
|
/**
|
|
* Remove one client. NEVER kills the PTY (vibe-coding core). Only when the LAST
|
|
* client leaves do we stamp detachedAt (starts the idle-reclaim clock); while
|
|
* other clients remain, re-derive the shared size (a leaver may let it grow).
|
|
*/
|
|
export function detachWs(session: Session, ws: WebSocketLike, now: number): void {
|
|
session.clients.delete(ws);
|
|
session.clientDims.delete(ws);
|
|
if (session.clients.size === 0) {
|
|
session.detachedAt = now;
|
|
} else {
|
|
applyMinDims(session);
|
|
}
|
|
}
|
|
|
|
/** Forward input to the PTY. No-op once the PTY has exited (L4). */
|
|
export function writeInput(session: Session, data: string): void {
|
|
if (session.exitedAt !== null) return;
|
|
session.pty.write(data);
|
|
}
|
|
|
|
/**
|
|
* Record one client's requested dims and resize the PTY to the new min across
|
|
* all clients (tmux-style). No-op after exit (L4); idempotent when unchanged.
|
|
*/
|
|
export function setClientDims(
|
|
session: Session,
|
|
ws: WebSocketLike,
|
|
cols: number,
|
|
rows: number,
|
|
): void {
|
|
if (session.exitedAt !== null) return;
|
|
session.clientDims.set(ws, { cols, rows });
|
|
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
|
|
* session running. Always also kills the client pty.
|
|
*/
|
|
export function kill(session: Session): void {
|
|
if (session.tmuxName !== null) killSession(session.tmuxName);
|
|
session.pty.kill();
|
|
}
|