The flat session list didn't tell you what each session was doing. Rebuild
/manage.html as a full-page grid where each card renders a LIVE preview
thumbnail — a read-only xterm showing the session's actual current screen,
scaled down like a screenshot — so you can recognize each at a glance.
- RingBuffer.tail(maxBytes): newest scrollback on chunk boundaries (no ANSI/UTF-8
split); GET /live-sessions/:id/preview returns {cols,rows,data} (no attach, so
it doesn't inflate watcher counts or keep sessions alive)
- manage.ts: card grid; per card a scaled read-only Terminal renders CLEAR+tail,
auto-refresh every 4s, click thumbnail/Open to join, Kill / bulk kill
- bundles xterm into manage.js (+manage.css linked in manage.html)
- ring-buffer tail tests
Verified: 3 sessions rendered as recognizable thumbnails (top / git log / ls),
colored, with status/age; click opens. 228 tests green, tsc clean.
230 lines
11 KiB
TypeScript
230 lines
11 KiB
TypeScript
/**
|
|
* src/types.ts — FROZEN shared contracts (T2).
|
|
*
|
|
* Single source of truth, imported READ-ONLY by every other module.
|
|
* Transcribed from ARCHITECTURE §3. **Dependency-free on purpose**: no `ws`,
|
|
* no `@types/node` ambient (`NodeJS.*`), no DOM — so BOTH the backend and the
|
|
* browser frontend can import it without dragging in each other's type space.
|
|
*
|
|
* Need a new shared type? Change it HERE (a coordination point). Do not
|
|
* redeclare it locally in another module.
|
|
*
|
|
* Three deliberate refinements vs ARCHITECTURE §3 (for the portability goal):
|
|
* 1. `loadConfig` takes `EnvLike` instead of `NodeJS.ProcessEnv` (process.env is assignable).
|
|
* 2. `Session.attachedWs` is `WebSocketLike` instead of `ws`'s `WebSocket` (a real ws socket
|
|
* is structurally assignable); `WS_OPEN` exported for the M5 readyState guard.
|
|
* 3. `Config.wsPath` added (invariant 8 requires the WS path to be config, not hardcoded).
|
|
*/
|
|
|
|
/* ──────────────────────── config (§3.1) ──────────────────────── */
|
|
|
|
export interface Config {
|
|
readonly port: number; // PORT, default 3000
|
|
readonly bindHost: string; // BIND_HOST, default '0.0.0.0'
|
|
readonly shellPath: string; // SHELL_PATH, default process.env.SHELL ?? '/bin/zsh'
|
|
readonly homeDir: string; // PTY cwd, default os.homedir()
|
|
readonly idleTtlMs: number; // IDLE_TTL, default 24h
|
|
readonly scrollbackBytes: number; // ring buffer capacity, default 2MB
|
|
readonly maxPayloadBytes: number; // max WS frame bytes, default 1MB (L5)
|
|
readonly wsPath: string; // WS upgrade path, default '/term' (L3; invariant 8)
|
|
readonly useTmux: boolean; // H1: spawn the shell inside tmux so it survives a server restart
|
|
readonly allowedOrigins: readonly string[]; // derived from NIC IPs, NOT bindHost (M1)
|
|
}
|
|
|
|
/** process.env is structurally assignable to this; keeps the file free of `NodeJS.*`. */
|
|
export type EnvLike = Readonly<Record<string, string | undefined>>;
|
|
|
|
// impl anchor [src/config.ts]:
|
|
// export function loadConfig(env: EnvLike): Config // invalid values → throw (fail-fast)
|
|
|
|
/* ─────────────────────── protocol (§3.2) ─────────────────────── */
|
|
|
|
/** client → server. approve/reject (H3) resolve a held PermissionRequest.
|
|
* attach.cwd (M6) = spawn a new session in this directory ("new tab here"). */
|
|
export type ClientMessage =
|
|
| { type: 'attach'; sessionId: string | null; cwd?: string }
|
|
| { type: 'input'; data: string }
|
|
| { type: 'resize'; cols: number; rows: number }
|
|
// v0.4: this client stopped actively viewing (tab hidden) — withdraw its size
|
|
// vote so a background mirror doesn't clamp the shared PTY (min-dims).
|
|
| { type: 'blur' }
|
|
| { type: 'approve' }
|
|
| { type: 'reject' };
|
|
|
|
/** Claude Code activity, derived from hooks (H2). 'unknown' = no hook signal yet. */
|
|
export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown';
|
|
|
|
/** server → client. exit.code = shell code; -1 when spawn never succeeded (M4).
|
|
* exit.reason optional normally, REQUIRED on spawn failure / abnormal exit.
|
|
* status (H2/H3) = Claude Code activity; `pending` true when a tool approval
|
|
* is held server-side and the client can approve/reject it. */
|
|
export type ServerMessage =
|
|
| { type: 'attached'; sessionId: string }
|
|
| { type: 'output'; data: string }
|
|
| { type: 'exit'; code: number; reason?: string }
|
|
| { type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean };
|
|
|
|
/** parseClientMessage result — never throws; errors flow here (§5.3). */
|
|
export type ParseResult =
|
|
| { ok: true; message: ClientMessage }
|
|
| { ok: false; error: string };
|
|
|
|
export interface Dims {
|
|
cols: number;
|
|
rows: number;
|
|
}
|
|
|
|
// impl anchors [src/protocol.ts]:
|
|
// export const SESSION_ID_RE: RegExp // UUID v4 (M7)
|
|
// export function parseClientMessage(raw: string): ParseResult // NEVER throws
|
|
// export function serialize(msg: ServerMessage): string
|
|
|
|
/* ──────────────────────── origin (§3.3) ──────────────────────── */
|
|
|
|
// impl anchor [src/http/origin.ts]:
|
|
// export function isOriginAllowed(origin: string | undefined, allowed: readonly string[]): boolean
|
|
// host+port must both match; undefined Origin rejected by default.
|
|
|
|
/* ─────────────────── PTY abstraction (§3.4) ──────────────────── */
|
|
|
|
export interface IDisposable {
|
|
dispose(): void;
|
|
}
|
|
|
|
/** node-pty's IEvent shape: subscribe a listener, get a disposable back. */
|
|
export type PtyEvent<T> = (listener: (e: T) => void) => IDisposable;
|
|
|
|
/** Subset of node-pty's IPty that we use. A real node-pty IPty is assignable to
|
|
* this; test/helpers/mock-pty.ts (T3) implements exactly this. */
|
|
export interface IPty {
|
|
readonly pid: number;
|
|
readonly cols: number;
|
|
readonly rows: number;
|
|
onData: PtyEvent<string>;
|
|
onExit: PtyEvent<{ exitCode: number; signal?: number }>;
|
|
write(data: string): void;
|
|
resize(cols: number, rows: number): void;
|
|
kill(signal?: string): void;
|
|
}
|
|
|
|
/* ────────────────────── ring buffer (§3.4) ───────────────────── */
|
|
|
|
export interface RingBuffer {
|
|
append(chunk: string): void;
|
|
/** full buffer for replay; prepend `\x1b[0m` soft-reset as a safety net (M2). */
|
|
snapshot(): string;
|
|
/** last ~maxBytes of recent output, on whole-chunk boundaries (never splits an
|
|
* ANSI/UTF-8 sequence), for a read-only session preview. No soft-reset prefix. */
|
|
tail(maxBytes: number): string;
|
|
}
|
|
|
|
// impl anchor [src/session/ring-buffer.ts]:
|
|
// export function createRingBuffer(maxBytes: number): RingBuffer
|
|
// M2: measure REAL bytes (Buffer), evict the whole oldest chunk — never split a
|
|
// multi-byte UTF-8 codepoint or an ANSI escape sequence mid-way.
|
|
|
|
/* ──────────────────────── session (§3.4) ─────────────────────── */
|
|
|
|
/** The minimal WebSocket the server holds per session. A real `ws` WebSocket is
|
|
* structurally assignable; keeping this local makes types.ts portable. */
|
|
export interface WebSocketLike {
|
|
readonly readyState: number;
|
|
send(data: string): void;
|
|
close(): void;
|
|
}
|
|
|
|
/** WebSocket.OPEN. Guard every send with `ws.readyState === WS_OPEN` (M5). */
|
|
export const WS_OPEN = 1;
|
|
|
|
/** Immutable snapshot, fixed at creation (§5.2). */
|
|
export interface SessionMeta {
|
|
readonly id: string; // crypto.randomUUID() — UUID v4
|
|
readonly createdAt: number; // caller-supplied timestamp (injectable for tests)
|
|
readonly shellPath: string;
|
|
}
|
|
|
|
export interface Session {
|
|
readonly meta: SessionMeta;
|
|
readonly buffer: RingBuffer;
|
|
/** 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),
|
|
* and attach replays the buffer then re-sends `exit` (L1). */
|
|
exitedAt: number | null;
|
|
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;
|
|
}
|
|
|
|
// 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): void // adds a client, replays buffer (no size vote until it resizes)
|
|
// 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 active viewers (L4 no-op after exit)
|
|
// clearClientDims(session: Session, ws: WebSocketLike): void // withdraw this client's size vote (tab hidden / blur)
|
|
// 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;
|
|
cols: number; // current PTY size (for the manage page)
|
|
rows: number;
|
|
}
|
|
|
|
export interface SessionManager {
|
|
handleAttach(
|
|
ws: WebSocketLike,
|
|
sessionId: string | null,
|
|
dims: Dims,
|
|
now: number,
|
|
cwd?: string,
|
|
): Session;
|
|
get(id: string): Session | undefined;
|
|
/** Live sessions for multi-device discovery (newest first). */
|
|
list(): LiveSessionInfo[];
|
|
/** Kill a session by id (manage page): close its clients, kill the PTY, drop it.
|
|
* Returns true if a session was found and killed. */
|
|
killById(id: string): boolean;
|
|
/** Set a session's Claude status (from a hook) and push it to the attached ws (H2/H3). */
|
|
handleHookEvent(
|
|
sessionId: string,
|
|
status: ClaudeStatus,
|
|
detail?: string,
|
|
pending?: boolean,
|
|
): void;
|
|
/** reclaim when now - max(detachedAt, lastOutputAt) > idleTtlMs (M3). Returns count. */
|
|
reapIdle(now: number): number;
|
|
shutdown(): void;
|
|
}
|
|
|
|
// impl anchor [src/session/manager.ts]:
|
|
// export function createSessionManager(cfg: Config): SessionManager
|
|
|
|
/* ─────────────────────── frontend (§5/§6.3) ──────────────────── */
|
|
|
|
/** keybar.ts (T10) exports a mount fn of this shape; main.ts (T11) calls it. */
|
|
export type MountKeybar = (onSend: (data: string) => void) => void;
|