From 0718b92267b2eb4d32b7dcefef022fe917de7a72 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Fri, 19 Jun 2026 10:24:34 +0200 Subject: [PATCH] feat(v0.4): multi-device session sharing (backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relax the one-WS-per-session invariant (#5) so multiple devices mirror the same live terminal (everyone sees output, anyone can type — tmux-style). - types.ts: Session.attachedWs → clients Set + clientDims Map; add cwd; SessionManager.list() + LiveSessionInfo - session.ts: broadcast() output/exit to all clients; attachWs JOINS (no kick, takes dims); detachWs removes one client, stamps detachedAt only when the last leaves; setClientDims resizes PTY to the MIN cols/rows across clients - manager.ts: join instead of kick; broadcast status; onExit drops only when a client was attached; list() for discovery - server.ts: GET /live-sessions; resize → per-client setClientDims; close → detach one client; permission gate uses clients.size - tests: rewrote session/manager tests for multi-client (join/broadcast/min-dims/ last-detach) + list(); 222 pass, tsc clean --- src/server.ts | 20 +++-- src/session/manager.ts | 45 ++++++---- src/session/session.ts | 89 ++++++++++++++----- src/types.ts | 34 ++++++-- test/manager.test.ts | 73 ++++++++++++---- test/session.test.ts | 193 ++++++++++++++++++++++++++--------------- 6 files changed, 318 insertions(+), 136 deletions(-) diff --git a/src/server.ts b/src/server.ts index ba8fad0..077e28e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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, resize } from './session/session.js' +import { detachWs, writeInput, setClientDims } from './session/session.js' import type { Config } from './types.js' import { WS_OPEN } from './types.js' @@ -111,6 +111,12 @@ export function startServer(cfg: Config): { close(): Promise } { res.json(listSessions(50)) }) + // ── Live sessions (v0.4) — running server sessions, for multi-device discovery. + // Any LAN device opening the app fetches this to show the host's sessions as tabs. + app.get('/live-sessions', (_req, res) => { + res.json(manager.list()) + }) + // ── 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. @@ -140,8 +146,8 @@ export function startServer(cfg: Config): { close(): Promise } { const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined const session = typeof sessionId === 'string' ? manager.get(sessionId) : undefined - // Nobody watching this tab (no session / not attached) → let Claude prompt itself. - if (sessionId === undefined || session === undefined || session.attachedWs === null) { + // Nobody watching this tab (no session / no clients) → let Claude prompt itself. + if (sessionId === undefined || session === undefined || session.clients.size === 0) { res.json({}) return } @@ -262,7 +268,8 @@ export function startServer(cfg: Config): { close(): Promise } { if (msg.type === 'input') { writeInput(session, msg.data) } else if (msg.type === 'resize') { - resize(session, msg.cols, msg.rows) + // Per-client dims; the PTY tracks the min across all sharing devices. + setClientDims(session, ws, msg.cols, msg.rows) } else if (msg.type === 'approve') { // H3: resolve the held PermissionRequest with allow. resolvePending(boundSessionId, permDecision('allow')) @@ -288,8 +295,9 @@ export function startServer(cfg: Config): { close(): Promise } { const session = manager.get(boundSessionId) if (session === undefined) return - // Invariant #2: never kill the PTY on WS close — detach only. - detachWs(session, Date.now()) + // Invariant #2: never kill the PTY on WS close — detach THIS client only + // (the PTY stays alive for other devices and for reconnect). + detachWs(session, ws, Date.now()) }) // ── WS error ───────────────────────────────────────────────────────────── diff --git a/src/session/manager.ts b/src/session/manager.ts index c7596a2..a630176 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -15,8 +15,10 @@ * at exit time, we keep it in the table so the L1 reconnect path can replay. * * Invariants (ARCHITECTURE §8): - * - #2: WS close → detach, never kill PTY. - * - #5: one ws per session; later attach kicks the earlier one. + * - #2: WS close → detach ONE client, never kill PTY (reap only when the last leaves). + * - #5 (relaxed v0.4): a session may have MANY concurrent clients (multi-device + * mirror sharing); a new attach JOINS instead of kicking. Output/exit/status + * broadcast to all; the PTY size is the min across clients (tmux-style). * - #6: every ws.send is guarded by readyState === WS_OPEN (handled inside session.ts). * * Coding style: immutable-update preference, no console.log, errors explicit. @@ -26,13 +28,14 @@ import type { ClaudeStatus, Config, Dims, + LiveSessionInfo, Session, SessionManager, WebSocketLike, } from '../types.js'; import { WS_OPEN } from '../types.js'; import { serialize } from '../protocol.js'; -import { createSession, attachWs, kill } from './session.js'; +import { createSession, attachWs, broadcast, kill } from './session.js'; import { hasSession, tmuxName } from './tmux.js'; /** @@ -69,8 +72,8 @@ export function createSessionManager(cfg: Config): SessionManager { * re-send the exit message before removing the session. */ function onSessionExit(session: Session): void { - if (session.attachedWs !== null) { - // ws was attached at exit time — session already notified, safe to drop. + if (session.clients.size > 0) { + // ≥1 client was attached at exit time — they got the `exit` frame, safe to drop. sessions = new Map(sessions); sessions.delete(session.meta.id); } @@ -89,18 +92,16 @@ 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); - const kicked = attachWs(session, ws); - if (kicked !== null) kicked.close(); + attachWs(session, ws, dims); sessions = new Map(sessions).set(session.meta.id, session); return session; } const existing = sessions.get(sessionId); - // ── Case 2: hit a live session ─────────────────────────────────────────── + // ── Case 2: hit a live session → JOIN it (multi-device sharing) ─────────── if (existing !== undefined && existing.exitedAt === null) { - const kicked = attachWs(existing, ws); - if (kicked !== null) kicked.close(); + attachWs(existing, ws, dims); return existing; } @@ -121,8 +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); - const kickedRevived = attachWs(revived, ws); - if (kickedRevived !== null) kickedRevived.close(); + attachWs(revived, ws, dims); sessions = new Map(sessions).set(revived.meta.id, revived); return revived; } @@ -130,8 +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); - const kicked = attachWs(session, ws); - if (kicked !== null) kicked.close(); + attachWs(session, ws, dims); sessions = new Map(sessions).set(session.meta.id, session); return session; } @@ -140,6 +139,20 @@ export function createSessionManager(cfg: Config): SessionManager { return sessions.get(id); } + /** Live sessions for multi-device discovery (newest first). */ + function list(): LiveSessionInfo[] { + return [...sessions.values()] + .map((s) => ({ + id: s.meta.id, + createdAt: s.meta.createdAt, + clientCount: s.clients.size, + status: s.claudeStatus, + exited: s.exitedAt !== null, + cwd: s.cwd, + })) + .sort((a, b) => b.createdAt - a.createdAt); + } + /** * 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). @@ -159,7 +172,7 @@ export function createSessionManager(cfg: Config): SessionManager { }; if (detail !== undefined) msg.detail = detail; if (pending) msg.pending = true; - sendIfOpen(session.attachedWs, msg); + broadcast(session, msg); } /** @@ -210,5 +223,5 @@ export function createSessionManager(cfg: Config): SessionManager { sessions = new Map(); } - return { handleAttach, get, handleHookEvent, reapIdle, shutdown }; + return { handleAttach, get, list, handleHookEvent, reapIdle, shutdown }; } diff --git a/src/session/session.ts b/src/session/session.ts index 6806219..48cb4c2 100644 --- a/src/session/session.ts +++ b/src/session/session.ts @@ -47,6 +47,30 @@ function sendIfOpen(ws: WebSocketLike | null, msg: ServerMessage): void { } } +/** 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). * @@ -95,28 +119,30 @@ export function createSession( const session: Session = { meta, buffer: createRingBuffer(cfg.scrollbackBytes), - attachedWs: null, + 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, forward to the live ws. + // onData: persist to scrollback, refresh liveness, broadcast to all clients. pty.onData((chunk) => { session.buffer.append(chunk); session.lastOutputAt = Date.now(); - sendIfOpen(session.attachedWs, { type: 'output', data: chunk }); + broadcast(session, { type: 'output', data: chunk }); }); - // onExit: record exit, notify the attached ws (if any, L1), then bubble up. + // onExit: record exit, notify every client (L1 if none), then bubble up. pty.onExit(({ exitCode }) => { session.exitedAt = Date.now(); session.exitCode = exitCode; - sendIfOpen(session.attachedWs, { type: 'exit', code: exitCode }); + broadcast(session, { type: 'exit', code: exitCode }); onExit(session); }); @@ -124,27 +150,34 @@ export function createSession( } /** - * Bind `ws` to the session: swap the pointer to the new ws FIRST (so live - * forwarding never targets a kicked socket), replay the scrollback, then let - * the real-time stream continue. Returns the kicked old ws (caller closes it). + * 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. + * No kicking — devices share the session (invariant #5 relaxed for v0.4). */ -export function attachWs(session: Session, ws: WebSocketLike): WebSocketLike | null { - const previous = session.attachedWs; - - // Pointer first: later attach wins; forwarding henceforth only reaches `ws`. - session.attachedWs = ws; +export function attachWs(session: Session, ws: WebSocketLike, dims: Dims): void { + session.clients.add(ws); + session.clientDims.set(ws, dims); session.detachedAt = null; + applyMinDims(session); - // Replay the buffered scrollback so the reconnecting client sees the last screen. + // Replay the buffered scrollback so the joining client sees the last screen. sendIfOpen(ws, { type: 'output', data: session.buffer.snapshot() }); - - return previous; } -/** Detach the ws and stamp detachedAt. NEVER kills the PTY (vibe-coding core). */ -export function detachWs(session: Session, now: number): void { - session.attachedWs = null; - session.detachedAt = now; +/** + * 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). */ @@ -153,11 +186,19 @@ export function writeInput(session: Session, data: string): void { session.pty.write(data); } -/** Resize the PTY. No-op after exit (L4); idempotent when dims are unchanged. */ -export function resize(session: Session, cols: number, rows: number): void { +/** + * 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; - if (session.pty.cols === cols && session.pty.rows === rows) return; - session.pty.resize(cols, rows); + session.clientDims.set(ws, { cols, rows }); + applyMinDims(session); } /** diff --git a/src/types.ts b/src/types.ts index e69ad34..32a7520 100644 --- a/src/types.ts +++ b/src/types.ts @@ -140,9 +140,15 @@ export interface SessionMeta { export interface Session { readonly meta: SessionMeta; readonly buffer: RingBuffer; - /** attached ws, or null = detached but PTY still alive (vibe-coding core). */ - attachedWs: WebSocketLike | null; - detachedAt: number | null; // detach time + /** All currently-attached clients (multi-device mirror sharing). Empty set = + * detached but PTY still alive (vibe-coding core). Output/exit/status are + * broadcast to every client; any client can send input (shared control). */ + readonly clients: Set; + /** Per-client requested terminal dims; the PTY uses the MIN cols/rows across + * all clients (tmux-style) so every device sees content without overflow. */ + readonly clientDims: Map; + /** Time the LAST client left (clients became empty); null while ≥1 attached. */ + detachedAt: number | null; /** last pty.onData timestamp; reapIdle liveness proxy (M3). */ lastOutputAt: number; /** PTY exit time; null = alive. Once set: writeInput/resize are ignored (L4), @@ -151,6 +157,8 @@ export interface Session { exitCode: number | null; /** Claude Code activity from hooks (H2); updated by manager.handleHookEvent. */ claudeStatus: ClaudeStatus; + /** Spawn directory, for the /live-sessions label; null when unknown. */ + cwd: string | null; /** tmux session name (H1) when running under tmux, else null. */ readonly tmuxName: string | null; readonly pty: IPty; @@ -159,14 +167,24 @@ 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): WebSocketLike | null // returns kicked old ws -// detachWs(session: Session, now: number): void // never kills PTY -// writeInput(session: Session, data: string): void // no-op after exit (L4) -// resize(session: Session, cols: number, rows: number): void // no-op after exit; idempotent +// attachWs(session: Session, ws: WebSocketLike, dims: Dims): void // adds a client, replays buffer (no kick) +// 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) // kill(session: Session): void /* ──────────────────────── manager (§3.5) ─────────────────────── */ +/** A live (or just-exited) server session, for the /live-sessions discovery list. */ +export interface LiveSessionInfo { + id: string; + createdAt: number; + clientCount: number; // how many devices are currently attached + status: ClaudeStatus; + exited: boolean; + cwd: string | null; +} + export interface SessionManager { handleAttach( ws: WebSocketLike, @@ -176,6 +194,8 @@ export interface SessionManager { cwd?: string, ): Session; get(id: string): Session | undefined; + /** Live sessions for multi-device discovery (newest first). */ + list(): LiveSessionInfo[]; /** Set a session's Claude status (from a hook) and push it to the attached ws (H2/H3). */ handleHookEvent( sessionId: string, diff --git a/test/manager.test.ts b/test/manager.test.ts index d5f5a89..0756f2d 100644 --- a/test/manager.test.ts +++ b/test/manager.test.ts @@ -92,7 +92,7 @@ describe('handleAttach — null sessionId', () => { expect(session).toBeDefined(); expect(session.meta.id).toBeTruthy(); - expect(session.attachedWs).toBe(ws); + expect(session.clients.has(ws)).toBe(true); }); it('stores the session so get() returns it', () => { @@ -114,7 +114,7 @@ describe('handleAttach — null sessionId', () => { // ── handleAttach: hit live session ──────────────────────────────────────────── describe('handleAttach — hit live session', () => { - it('attaches the new ws and returns the same session', () => { + it('JOINS the new ws and returns the same session (multi-device share)', () => { const mgr = createSessionManager(CFG); const ws1 = createMockWs(); const session = mgr.handleAttach(ws1, null, DIMS, 1_000); @@ -124,10 +124,12 @@ describe('handleAttach — hit live session', () => { const session2 = mgr.handleAttach(ws2, id, DIMS, 2_000); expect(session2).toBe(session); - expect(session.attachedWs).toBe(ws2); + // Both clients share the session now. + expect(session.clients.has(ws1)).toBe(true); + expect(session.clients.has(ws2)).toBe(true); }); - it('kicks (closes) the previously attached ws', () => { + it('does NOT kick the previously attached ws (v0.4 sharing)', () => { const mgr = createSessionManager(CFG); const ws1 = createMockWs(); const session = mgr.handleAttach(ws1, null, DIMS, 1_000); @@ -136,8 +138,24 @@ describe('handleAttach — hit live session', () => { const ws2 = createMockWs(); mgr.handleAttach(ws2, id, DIMS, 2_000); - // The old ws should have been closed by the manager. - expect(ws1.closed).toBe(true); + // The old ws must stay open — devices mirror the session. + expect(ws1.closed).toBe(false); + expect(session.clients.size).toBe(2); + }); + + it('broadcasts live output to ALL joined clients', () => { + const mgr = createSessionManager(CFG); + const ws1 = createMockWs(); + const session = mgr.handleAttach(ws1, null, DIMS, 1_000); + const ws2 = createMockWs(); + mgr.handleAttach(ws2, session.meta.id, DIMS, 2_000); + ws1.sent.length = 0; + ws2.sent.length = 0; + + (session.pty as MockIPty).emitData('to everyone'); + + expect(parseSent(ws1).some((m) => m.type === 'output' && m.data === 'to everyone')).toBe(true); + expect(parseSent(ws2).some((m) => m.type === 'output' && m.data === 'to everyone')).toBe(true); }); it('replays the buffer to the new ws', () => { @@ -170,7 +188,7 @@ describe('handleAttach — hit already-exited session (L1)', () => { (session.pty as MockIPty).emitData('last output before exit'); mgr.handleAttach(createMockWs(), id, DIMS, 2_000); // re-attach so we can detach // Force detach by patching directly (simulates WS close event). - session.attachedWs = null; + session.clients.clear(); session.detachedAt = 3_000; // Now PTY exits while detached — session preserved (L1). @@ -271,7 +289,7 @@ describe('handleAttach — hit already-exited session (L1)', () => { const id = session.meta.id; // Detach by simulating WS close. - session.attachedWs = null; + session.clients.clear(); session.detachedAt = 2_000; // PTY exits while detached. @@ -297,7 +315,7 @@ describe('handleAttach — hit already-exited session (L1)', () => { const id = session.meta.id; // Detach and exit. - session.attachedWs = null; + session.clients.clear(); session.detachedAt = 2_000; (session.pty as MockIPty).emitExit(0); @@ -322,7 +340,7 @@ describe('handleAttach — session not found', () => { expect(session).toBeDefined(); // The returned session has a NEW id (not the bogus one). expect(session.meta.id).not.toBe(unknownId); - expect(session.attachedWs).toBe(ws); + expect(session.clients.has(ws)).toBe(true); }); it('the newly created session is stored in the table', () => { @@ -372,7 +390,7 @@ describe('reapIdle (M3)', () => { const id = session.meta.id; // Detach at t=2000. - session.attachedWs = null; + session.clients.clear(); session.detachedAt = 2_000; // Output arrives at t=5000 (AFTER detach) — refresh lastOutputAt. @@ -394,7 +412,7 @@ describe('reapIdle (M3)', () => { const id = session.meta.id; // Detach at t=2000; no output after detach. - session.attachedWs = null; + session.clients.clear(); session.detachedAt = 2_000; session.lastOutputAt = 1_500; // before detach → max = 2000 @@ -412,7 +430,7 @@ describe('reapIdle (M3)', () => { nextPty = createMockPty(); const wsA = createMockWs(); const sessionA = mgr.handleAttach(wsA, null, DIMS, 1_000); - sessionA.attachedWs = null; + sessionA.clients.clear(); sessionA.detachedAt = 1_000; sessionA.lastOutputAt = 1_000; @@ -420,7 +438,7 @@ describe('reapIdle (M3)', () => { nextPty = createMockPty(); const wsB = createMockWs(); const sessionB = mgr.handleAttach(wsB, null, DIMS, 1_000); - sessionB.attachedWs = null; + sessionB.clients.clear(); sessionB.detachedAt = 1_000; sessionB.lastOutputAt = 1_000; @@ -442,6 +460,31 @@ describe('reapIdle (M3)', () => { }); }); +// ── list (v0.4 multi-device discovery) ──────────────────────────────────────── +describe('list', () => { + it('returns live sessions with client counts, newest first', () => { + const mgr = createSessionManager(CFG); + + nextPty = createMockPty(); + const a = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); + nextPty = createMockPty(); + const b = mgr.handleAttach(createMockWs(), null, DIMS, 2_000); + // a second device joins session b → clientCount 2 + mgr.handleAttach(createMockWs(), b.meta.id, DIMS, 2_500); + + const list = mgr.list(); + expect(list.map((s) => s.id)).toEqual([b.meta.id, a.meta.id]); // newest first + expect(list.find((s) => s.id === b.meta.id)?.clientCount).toBe(2); + expect(list.find((s) => s.id === a.meta.id)?.clientCount).toBe(1); + expect(list.every((s) => s.exited === false)).toBe(true); + }); + + it('returns an empty array when there are no sessions', () => { + const mgr = createSessionManager(CFG); + expect(mgr.list()).toEqual([]); + }); +}); + // ── shutdown ────────────────────────────────────────────────────────────────── describe('shutdown', () => { it('kills all sessions and clears the table', () => { @@ -488,7 +531,7 @@ describe('onExit removes session from table when ws attached at exit time (L2)', const id = session.meta.id; // Detach first. - session.attachedWs = null; + session.clients.clear(); session.detachedAt = 2_000; // Then PTY exits. diff --git a/test/session.test.ts b/test/session.test.ts index 70f990b..435c321 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -1,15 +1,16 @@ /** - * T12 tests — session/session.ts (single-session lifecycle). + * T12 tests — session/session.ts (PTY lifecycle + multi-client sharing). * * ARCHITECTURE §3.4 / §4.4 + the cross-validated fixes: * - M4: spawn failure THROWS, never swallowed. * - M5: every ws.send is guarded by `ws.readyState === WS_OPEN`. - * - L1: detach-then-exit keeps the session (exit not delivered when attachedWs is null). - * - L4: writeInput/resize are no-ops once the PTY has exited. + * - L1: detach-then-exit keeps the session (exit not delivered when no client attached). + * - L4: writeInput/setClientDims are no-ops once the PTY has exited. + * - v0.4: a session may hold MANY clients (multi-device mirror). Output/exit are + * broadcast to all; the PTY size is the MIN cols/rows across clients. * * node-pty is mocked (`vi.mock`) so `spawn` returns a MockIPty — the sandbox blocks - * real posix_spawn, and the mock lets us drive onData/onExit deterministically and - * assert lifecycle without a real shell. + * real posix_spawn, and the mock lets us drive onData/onExit deterministically. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; @@ -19,8 +20,6 @@ import { WS_OPEN } from '../src/types.js'; import { createMockPty, type MockIPty } from './helpers/mock-pty.js'; // ── node-pty mock ─────────────────────────────────────────────────────────── -// spawn returns whatever `nextPty` points at, and records its call args so we -// can assert how createSession invoked it (and force it to throw for M4). let nextPty: MockIPty; let spawnError: Error | null = null; const spawnCalls: Array<{ file: string; args: string[] | string; options: unknown }> = []; @@ -34,7 +33,7 @@ vi.mock('node-pty', () => ({ })); // Imported AFTER vi.mock so the module under test binds to the mocked spawn. -const { createSession, attachWs, detachWs, writeInput, resize, kill } = await import( +const { createSession, attachWs, detachWs, writeInput, setClientDims, kill } = await import( '../src/session/session.js' ); @@ -105,7 +104,7 @@ describe('createSession', () => { expect(s.pty).toBe(nextPty); expect(s.meta.shellPath).toBe(CFG.shellPath); expect(s.meta.createdAt).toBe(1_000); - expect(s.attachedWs).toBeNull(); + expect(s.clients.size).toBe(0); expect(s.exitedAt).toBeNull(); expect(s.exitCode).toBeNull(); }); @@ -118,7 +117,7 @@ describe('createSession', () => { }); }); -// ── onData → buffer + forward ────────────────────────────────────────────────── +// ── onData → buffer + broadcast ──────────────────────────────────────────────── describe('onData', () => { it('appends output to the ring buffer and refreshes lastOutputAt', () => { const s = newSession(); @@ -127,15 +126,14 @@ describe('onData', () => { pty.emitData('hello '); pty.emitData('world'); - // buffer snapshot prepends the M2 soft-reset, then the verbatim output. expect(s.buffer.snapshot()).toBe('\x1b[0mhello world'); expect(s.lastOutputAt).toBeGreaterThanOrEqual(1_000); }); - it('forwards output to the attached ws as an `output` message', () => { + it('broadcasts output to the attached client as an `output` message', () => { const s = newSession(); const ws = createMockWs(); - attachWs(s, ws); + attachWs(s, ws, DIMS); ws.sent.length = 0; // drop the replay frame from attach (s.pty as MockIPty).emitData('abc'); @@ -143,16 +141,31 @@ describe('onData', () => { expect(received(ws)).toEqual([{ type: 'output', data: 'abc' }]); }); - it('still buffers output when no ws is attached (does not throw)', () => { + it('broadcasts output to EVERY client sharing the session (multi-device)', () => { + const s = newSession(); + const a = createMockWs(); + const b = createMockWs(); + attachWs(s, a, DIMS); + attachWs(s, b, DIMS); + a.sent.length = 0; + b.sent.length = 0; + + (s.pty as MockIPty).emitData('shared'); + + expect(received(a)).toEqual([{ type: 'output', data: 'shared' }]); + expect(received(b)).toEqual([{ type: 'output', data: 'shared' }]); + }); + + it('still buffers output when no client is attached (does not throw)', () => { const s = newSession(); expect(() => (s.pty as MockIPty).emitData('orphan')).not.toThrow(); expect(s.buffer.snapshot()).toContain('orphan'); }); - it('does NOT send to a ws whose readyState is not OPEN (M5)', () => { + it('does NOT send to a client whose readyState is not OPEN (M5)', () => { const s = newSession(); const ws = createMockWs(WS_OPEN); - attachWs(s, ws); + attachWs(s, ws, DIMS); ws.sent.length = 0; ws.readyState = 3; // CLOSED @@ -162,60 +175,97 @@ describe('onData', () => { }); }); -// ── attachWs ────────────────────────────────────────────────────────────────── +// ── attachWs (join, no kick) ────────────────────────────────────────────────── describe('attachWs', () => { - it('replays the buffer snapshot to the newly attached ws', () => { + it('replays the buffer snapshot to the newly attached client', () => { const s = newSession(); (s.pty as MockIPty).emitData('prior output'); const ws = createMockWs(); - const kicked = attachWs(s, ws); + attachWs(s, ws, DIMS); - expect(kicked).toBeNull(); - expect(s.attachedWs).toBe(ws); + expect(s.clients.has(ws)).toBe(true); expect(received(ws)).toEqual([{ type: 'output', data: '\x1b[0mprior output' }]); }); - it('points attachedWs at the new ws BEFORE returning the kicked old ws (later wins)', () => { + it('a second attach JOINS (does not kick) — both clients stay connected', () => { const s = newSession(); - const oldWs = createMockWs(); - attachWs(s, oldWs); + const a = createMockWs(); + attachWs(s, a, DIMS); - const newWs = createMockWs(); - const kicked = attachWs(s, newWs); + const b = createMockWs(); + attachWs(s, b, DIMS); - // pointer is swapped first; caller is responsible for closing the kicked ws. - expect(s.attachedWs).toBe(newWs); - expect(kicked).toBe(oldWs); - expect(oldWs.closed).toBe(false); // attachWs must NOT close it itself + // Both present; the first was NOT closed. + expect(s.clients.has(a)).toBe(true); + expect(s.clients.has(b)).toBe(true); + expect(a.closed).toBe(false); - // forwarding now only ever reaches the new ws. - newWs.sent.length = 0; - (s.pty as MockIPty).emitData('after kick'); - expect(received(newWs)).toEqual([{ type: 'output', data: 'after kick' }]); - expect(oldWs.sent.some((f) => f.includes('after kick'))).toBe(false); + // Only the joining client got the replay; live output reaches both. + a.sent.length = 0; + b.sent.length = 0; + (s.pty as MockIPty).emitData('live'); + expect(received(a)).toEqual([{ type: 'output', data: 'live' }]); + expect(received(b)).toEqual([{ type: 'output', data: 'live' }]); + }); + + it('resizes the PTY to the MIN dims across all clients (tmux-style)', () => { + const s = newSession(); // spawn dims 80x24 + const wide = createMockWs(); + const narrow = createMockWs(); + + attachWs(s, wide, { cols: 200, rows: 50 }); + // one client at 200x50 → PTY grows to 200x50 + expect(s.pty.cols).toBe(200); + expect(s.pty.rows).toBe(50); + + attachWs(s, narrow, { cols: 90, rows: 30 }); + // min(200,90)=90, min(50,30)=30 + expect(s.pty.cols).toBe(90); + expect(s.pty.rows).toBe(30); }); }); -// ── detachWs ────────────────────────────────────────────────────────────────── +// ── detachWs (remove one client) ────────────────────────────────────────────── describe('detachWs', () => { - it('clears attachedWs and stamps detachedAt but NEVER kills the PTY', () => { + it('removes the client and stamps detachedAt only when the LAST one leaves', () => { const s = newSession(); - const ws = createMockWs(); - attachWs(s, ws); + const a = createMockWs(); + const b = createMockWs(); + attachWs(s, a, DIMS); + attachWs(s, b, DIMS); - detachWs(s, 5_000); + detachWs(s, a, 5_000); + // one client remains → still "attached", no detach stamp + expect(s.clients.has(a)).toBe(false); + expect(s.clients.has(b)).toBe(true); + expect(s.detachedAt).toBeNull(); - expect(s.attachedWs).toBeNull(); - expect(s.detachedAt).toBe(5_000); + detachWs(s, b, 6_000); + // last client gone → detached + expect(s.clients.size).toBe(0); + expect(s.detachedAt).toBe(6_000); expect((s.pty as MockIPty).killed).toBe(false); }); - it('keeps the PTY alive after detach: further onData still fills the buffer', () => { + it('lets the PTY grow back when a small client leaves', () => { + const s = newSession(); + const wide = createMockWs(); + const narrow = createMockWs(); + attachWs(s, wide, { cols: 200, rows: 50 }); + attachWs(s, narrow, { cols: 90, rows: 30 }); // clamps to 90x30 + + detachWs(s, narrow, 5_000); + // only the wide client remains → PTY expands back to 200x50 + expect(s.pty.cols).toBe(200); + expect(s.pty.rows).toBe(50); + }); + + it('keeps the PTY alive after the last detach: further onData still fills the buffer', () => { const s = newSession(); const ws = createMockWs(); - attachWs(s, ws); - detachWs(s, 5_000); + attachWs(s, ws, DIMS); + detachWs(s, ws, 5_000); (s.pty as MockIPty).emitData('background work'); @@ -226,48 +276,50 @@ describe('detachWs', () => { // ── onExit ──────────────────────────────────────────────────────────────────── describe('onExit', () => { - it('sets exitedAt/exitCode, sends `exit` to the attached ws, and calls injected onExit', () => { + it('sets exitedAt/exitCode, broadcasts `exit` to all clients, and calls injected onExit', () => { const onExit = vi.fn(); nextPty = createMockPty(); const s = createSession(CFG, DIMS, 1_000, onExit); - const ws = createMockWs(); - attachWs(s, ws); - ws.sent.length = 0; + const a = createMockWs(); + const b = createMockWs(); + attachWs(s, a, DIMS); + attachWs(s, b, DIMS); + a.sent.length = 0; + b.sent.length = 0; (s.pty as MockIPty).emitExit(0); expect(s.exitedAt).not.toBeNull(); expect(s.exitCode).toBe(0); - expect(received(ws)).toEqual([{ type: 'exit', code: 0 }]); + expect(received(a)).toEqual([{ type: 'exit', code: 0 }]); + expect(received(b)).toEqual([{ type: 'exit', code: 0 }]); expect(onExit).toHaveBeenCalledWith(s); }); - it('after detach, exit is NOT delivered but the session is preserved (L1)', () => { + it('after the last detach, exit is NOT delivered but the session is preserved (L1)', () => { const onExit = vi.fn(); nextPty = createMockPty(); const s = createSession(CFG, DIMS, 1_000, onExit); const ws = createMockWs(); - attachWs(s, ws); - detachWs(s, 5_000); + attachWs(s, ws, DIMS); + detachWs(s, ws, 5_000); ws.sent.length = 0; (s.pty as MockIPty).emitExit(137); - // exit happened: state recorded, but no ws to notify → nothing delivered. expect(s.exitedAt).not.toBeNull(); expect(s.exitCode).toBe(137); expect(ws.sent).toHaveLength(0); - // session preserved: buffer intact (caller/manager decides removal via onExit). expect(onExit).toHaveBeenCalledWith(s); }); - it('does not send exit to a ws whose readyState is not OPEN (M5)', () => { + it('does not send exit to a client whose readyState is not OPEN (M5)', () => { nextPty = createMockPty(); const s = createSession(CFG, DIMS, 1_000, () => {}); const ws = createMockWs(WS_OPEN); - attachWs(s, ws); + attachWs(s, ws, DIMS); ws.sent.length = 0; ws.readyState = 3; // CLOSED @@ -277,27 +329,30 @@ describe('onExit', () => { }); }); -// ── writeInput / resize after exit (L4) + resize idempotence ────────────────── -describe('writeInput / resize', () => { +// ── writeInput / setClientDims after exit (L4) + dims idempotence ────────────── +describe('writeInput / setClientDims', () => { it('writeInput forwards to pty.write while alive', () => { const s = newSession(); writeInput(s, 'ls\r'); expect((s.pty as MockIPty).writes).toEqual(['ls\r']); }); - it('resize forwards to pty.resize while alive', () => { + it('setClientDims forwards to pty.resize while alive', () => { const s = newSession(); - resize(s, 100, 40); - expect((s.pty as MockIPty).resizes).toEqual([{ cols: 100, rows: 40 }]); + const ws = createMockWs(); + attachWs(s, ws, DIMS); + setClientDims(s, ws, 100, 40); + expect((s.pty as MockIPty).resizes).toContainEqual({ cols: 100, rows: 40 }); }); - it('resize is idempotent: same cols/rows are skipped', () => { + it('setClientDims is idempotent: same min cols/rows are skipped', () => { const s = newSession(); - resize(s, 80, 24); // equal to the spawn dims → no-op + const ws = createMockWs(); + attachWs(s, ws, DIMS); // 80x24, equals spawn dims → no resize yet expect((s.pty as MockIPty).resizes).toHaveLength(0); - resize(s, 90, 24); // changed → applied - resize(s, 90, 24); // unchanged again → skipped + setClientDims(s, ws, 90, 24); // changed → applied + setClientDims(s, ws, 90, 24); // unchanged again → skipped expect((s.pty as MockIPty).resizes).toEqual([{ cols: 90, rows: 24 }]); }); @@ -309,11 +364,13 @@ describe('writeInput / resize', () => { expect((s.pty as MockIPty).writes).toHaveLength(0); }); - it('ignores resize once the PTY has exited (L4)', () => { + it('ignores setClientDims once the PTY has exited (L4)', () => { const s = newSession(); + const ws = createMockWs(); + attachWs(s, ws, DIMS); (s.pty as MockIPty).emitExit(0); - resize(s, 200, 50); + setClientDims(s, ws, 200, 50); expect((s.pty as MockIPty).resizes).toHaveLength(0); }); });