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:
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user