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 ─────────────────────────────────────────────────────────────