feat(v0.4): multi-device session sharing (backend)

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
This commit is contained in:
Yaojia Wang
2026-06-19 10:24:34 +02:00
parent bae4d72929
commit 0718b92267
6 changed files with 318 additions and 136 deletions

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, 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<void> } {
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<void> } {
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<void> } {
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<void> } {
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 ─────────────────────────────────────────────────────────────

View File

@@ -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 };
}

View File

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

View File

@@ -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<WebSocketLike>;
/** 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<WebSocketLike, Dims>;
/** 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,