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

@@ -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,