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

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