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:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user