/** * 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_ 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(); }