/** * 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>; // 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 = (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; 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; /** 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; /** 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;