/** * 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 maxSessions: number; // cap on concurrent PTY sessions (DoS guard), default 50 readonly maxMsgsPerSec: number; // per-connection WS message rate cap (DoS guard), default 2000 readonly permTimeoutMs: number; // H3: how long a held PermissionRequest waits before fallback readonly reapIntervalMs: number; // idle-reaper sweep interval readonly previewBytes: number; // manage-page preview: bytes of scrollback tail rendered 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) // v0.6 Project Manager — project discovery (impl: src/config.ts T-PM3) readonly projectRoots: readonly string[]; // PROJECT_ROOTS, default [homeDir] readonly projectScanDepth: number; // PROJECT_SCAN_DEPTH, default 4 readonly projectScanTtlMs: number; // PROJECT_SCAN_TTL, default 10000 (discovery cache) readonly projectDirtyCheck: boolean; // PROJECT_DIRTY_CHECK, default true (git status per repo) readonly editorCmd: string; // EDITOR_CMD, default 'code' — POST /open-in-editor launches ` ` on the host // v0.7 Walk-away Workbench — 21 new fields (impl: src/config.ts T-config) // A1 push notifications (vapid* are SECRETS — never log/expose to clients) readonly vapidPublicKey: string | undefined; // VAPID_PUBLIC_KEY readonly vapidPrivateKey: string | undefined; // VAPID_PRIVATE_KEY (secret) readonly vapidSubject: string | undefined; // VAPID_SUBJECT (mailto:/url) readonly pushStorePath: string; // PUSH_STORE_PATH, default ~/.web-terminal-push-subs.json readonly pushMaxSubs: number; // PUSH_MAX_SUBS, default 50 readonly prefsStorePath: string; // PREFS_STORE_PATH, default ~/.web-terminal-prefs.json (Projects UI prefs) readonly notifyDone: boolean; // NOTIFY_DONE, default true readonly notifyDnd: boolean; // NOTIFY_DND, default false readonly decisionTokenTtlMs: number; // DECISION_TOKEN_TTL_MS, default = permTimeoutMs // A4 activity timeline readonly timelineMax: number; // TIMELINE_MAX, default 200 readonly timelineEnabled: boolean; // TIMELINE_ENABLED, default true // A5 stuck detection readonly stuckTtlMs: number; // STUCK_TTL (seconds env) → ms, default 600s readonly stuckAlert: boolean; // STUCK_ALERT, default true // B1 git diff readonly diffTimeoutMs: number; // DIFF_TIMEOUT_MS, default 2000 readonly diffMaxBytes: number; // DIFF_MAX_BYTES, default 2MB readonly diffMaxFiles: number; // DIFF_MAX_FILES, default 300 // B2 statusLine telemetry readonly statuslineTtlMs: number; // STATUSLINE_TTL_MS, default 30000 // B3 git worktree creation readonly worktreeEnabled: boolean; // WORKTREE_ENABLED, default true readonly worktreeRoot: string | undefined; // WORKTREE_ROOT (undefined → computed) readonly worktreeTimeoutMs: number; // WORKTREE_TIMEOUT_MS, default 10000 // B4 permission-mode relay readonly defaultPermissionMode: PermissionMode; // DEFAULT_PERMISSION_MODE, default 'default' readonly allowAutoMode: boolean; // ALLOW_AUTO_MODE, default false (SEC-M5) } /** 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"). * approve.mode (B4) = permission mode to write back when resolving a `plan` * gate (e.g. approve+auto → 'acceptEdits'); omitted for ordinary tool gates. */ export type ClientMessage = | { type: 'attach'; sessionId: string | null; cwd?: string } | { type: 'input'; data: string } | { type: 'resize'; cols: number; rows: number } | { type: 'approve'; mode?: PermissionMode } | { type: 'reject' }; /** Claude Code activity, derived from hooks (H2). 'unknown' = no hook signal yet. * 'stuck' (A5) = output silent past STUCK_TTL while not idle/exited; set by * manager.sweepStuck and re-armed to 'working' on the next pty output. */ export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown' | 'stuck'; /** Which kind of held approval a `status` carries (B4). 'plan' = an ExitPlanMode * gate (three-way approve/auto/keep-planning); 'tool' = an ordinary tool gate. */ export type PermissionGate = 'tool' | 'plan'; /** 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; `gate` (B4) tells * the client whether the held approval is a 'tool' or 'plan' gate. * telemetry (B2) = latest statusLine telemetry broadcast for this session. */ 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; gate?: PermissionGate; } | { type: 'telemetry'; telemetry: StatusTelemetry }; /** 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; /** 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; /** Bounded, timestamped activity ring (A4); newest appended, oldest evicted at * cfg.timelineMax. Mutable runtime handle on the immutable meta (like buffer). * Replaced wholesale via appendEvent (never mutated in place). */ timeline: readonly TimelineEvent[]; /** A5: true once a stuck alert fired this round; re-armed (→false) by the next * pty.onData so each silent round alerts at most once. */ stuckNotified: boolean; /** B2: latest statusLine telemetry for this session; null until first report. */ telemetry: StatusTelemetry | 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 = latest-writer-wins (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; cols: number; // current PTY size (for the manage page) rows: number; telemetry?: StatusTelemetry | null; // B2: latest telemetry for the thumbnail wall } /* ───────────────── project manager (v0.6, §4.3 FEATURE doc) ──────────────── */ /** One running session belonging to a project (live-sessions归并 by cwd). * Mirrors LiveSessionInfo fields so buildProjects can map directly. */ export interface ProjectSessionRef { id: string; title?: string; // tab title / derived label (e.g. 'claude', 'shell'); optional status: ClaudeStatus; // reuse hook status (working|waiting|idle|unknown) clientCount: number; // mirror devices currently attached createdAt: number; exited: boolean; } /** A discovered project (git repo or recently-used cwd) for the Projects panel. * impl: src/http/projects.ts buildProjects(cfg, liveSessions) — T-PM2. */ export interface ProjectInfo { name: string; // repo directory name path: string; // absolute path isGit: boolean; branch?: string; // current branch (git repos only) dirty?: boolean; // uncommitted changes (when projectDirtyCheck) lastActiveMs?: number; // newest ~/.claude/projects mtime for this cwd; sort key sessions: ProjectSessionRef[]; // running sessions in this project (1:N; may be empty) } /** One entry from `git worktree list --porcelain` (project detail view). */ export interface WorktreeInfo { path: string; // worktree absolute path branch?: string; // branch name (refs/heads/x → x); undefined when detached head?: string; // short HEAD sha isMain: boolean; // the repo's primary worktree (listed first) isCurrent: boolean; // this worktree is the requested project path locked?: boolean; // `git worktree lock`ed prunable?: boolean; // git considers it prunable (gone working tree) } /** Detailed view of one project: branch/worktrees + its running sessions. * impl: src/http/projects.ts buildProjectDetail(cfg, path, liveSessions). */ export interface ProjectDetail { name: string; path: string; isGit: boolean; branch?: string; dirty?: boolean; worktrees: WorktreeInfo[]; // empty for a non-git dir sessions: ProjectSessionRef[]; // running sessions under this path (fresh) hasClaudeMd: boolean; // a CLAUDE.md exists at the project root claudeMd?: string; // its content (truncated for display) when present } 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), append a timeline event (A4) * when eventClass is supplied + timeline is enabled, and broadcast to all * attached clients (H2/H3). gate (B4) marks a 'tool'/'plan' approval gate. */ handleHookEvent( sessionId: string, status: ClaudeStatus, detail?: string, pending?: boolean, gate?: PermissionGate, eventClass?: string, toolName?: string, ): void; /** B2: store the latest statusLine telemetry for a session and broadcast a * `telemetry` message to all attached clients. */ handleStatusLine(id: string, telemetry: StatusTelemetry): void; /** A5: on the existing reaper tick (no new timer, H4), mark live non-idle * sessions whose output has been silent past STUCK_TTL as 'stuck', broadcast, * and notify once per silent round (re-armed by the next pty output). */ sweepStuck(now: number): 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, notifyService?: NotifyService): SessionManager // notifyService is injected (DI, M5) so the manager never imports push-service // directly (avoids a manager↔push-service cycle); sweepStuck uses it for 'stuck'. /* ───────────── v0.7 Walk-away Workbench (FEATURE_WALKAWAY_WORKBENCH) ───────────── * * The five FE↔BE contract resolutions from PLAN_WALKAWAY_WORKBENCH §3, frozen * here so the frontend and backend agree on ONE spelling/shape. Field names for * StatusTelemetry (B2) and the value set for PermissionMode (B4) are derived * from the R0 statusLine / --permission-mode research findings. */ /* ── B4 permission-mode relay (§3.5) ── */ /** --permission-mode values the relay exposes (R0-confirmed). 'auto' is its own * Claude Code mode (background safety checks), distinct from bypassPermissions * (the high-risk modes dontAsk/bypassPermissions are intentionally not surfaced). * Validated at the WS boundary; gated server-side by ALLOW_AUTO_MODE (SEC-M5). */ export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'auto'; /* ── A1 push notifications (§3.3, §A1) ── */ /** The three proactive signals pushed to the phone (§3.3 / §A1). */ export type NotifyClass = 'needs-input' | 'done' | 'stuck'; /** Outbound push body — ONE shape: push-service sends it, sw-push.js reads `cls` * (§3.3 review #3). Minimal by design: no raw terminal output, no secrets. * `token` is the per-decision capability token (only on needs-input). */ export interface PushPayload { sessionId: string; toolName?: string; detail?: string; token?: string; cls: NotifyClass; } /** A stored browser PushSubscription (persisted to a 600-perm JSON, never via GET). */ export interface PushSubscriptionRecord { endpoint: string; keys: { p256dh: string; auth: string }; createdAt: number; } /** The notification provider, injected into the manager (DI, M5) so it never * imports push-service directly. isEnabled() is false when VAPID keys are unset * (graceful disable). notify() honors DND/NOTIFY_DONE internally. */ export interface NotifyService { isEnabled(): boolean; notify(session: Session, cls: NotifyClass, token?: string): Promise; } /* ── B2 statusLine telemetry (§3.5, derived from R0 schema) ── */ /** Per-session telemetry derived server-side from the Claude Code statusLine * stdin JSON (R0). All metric fields are optional — parsing is tolerant (missing * field → undefined, never throw); `at` is the server receive time (Date.now()). * Field mapping (R0 → here): context_window.used_percentage→contextUsedPct, * cost.total_cost_usd→costUsd, cost.total_lines_added/removed→linesAdded/Removed, * model.display_name→model, effort.level→effort, pr→pr, rate_limits→rate. */ export interface StatusTelemetry { contextUsedPct?: number; costUsd?: number; linesAdded?: number; linesRemoved?: number; model?: string; effort?: string; pr?: { number: number; url: string; reviewState?: string }; rate?: { fiveHourPct?: number; sevenDayPct?: number }; at: number; } /* ── A4 activity timeline (§3.1) ── */ /** Server-DERIVED semantic class for a timeline event (review #1). The raw hook * name is mapped by timeline.ts deriveClass(); the FE consumes `class` directly * for icon/color and never re-derives from hook names. */ export type TimelineClass = 'tool' | 'waiting' | 'done' | 'stuck' | 'user'; /** One bounded, timestamped activity entry (A4). `label` is the server-derived * human phrase ("ran Bash", "edited 3 files"); `toolName` is sanitized (≤200, * control chars stripped). `at` is the server Date.now() at ingest. */ export interface TimelineEvent { at: number; class: TimelineClass; toolName?: string; label: string; } /* ── B1 read-only git diff (§3.2) ── */ /** ONE spelling (semantic words, review #2). Parsing lives ONLY in the backend * (src/http/diff.ts); public/diff.ts is render-only. */ export type DiffLineKind = 'added' | 'removed' | 'context' | 'hunk' | 'meta'; export interface DiffLine { kind: DiffLineKind; text: string; } export interface DiffHunk { header: string; lines: DiffLine[]; } export type FileStatus = | 'modified' | 'added' | 'deleted' | 'renamed' | 'binary' | 'untracked'; export interface DiffFile { oldPath: string; newPath: string; status: FileStatus; added: number; removed: number; binary: boolean; hunks: DiffHunk[]; } export interface DiffResult { files: DiffFile[]; staged: boolean; truncated: boolean; } /* ── B3 worktree creation (§3.5) ── */ /** Result of POST /projects/worktree (B3). `error` carries a safe message only * (never raw git stderr, SEC-M10); `status` is the HTTP status to return. */ export interface CreateWorktreeResult { ok: boolean; path?: string; branch?: string; status?: number; error?: string; } /* ── v0.6 Projects UI preferences (server-persisted, cross-device) ── */ /** Cross-device UI preferences for the Projects launcher (impl: src/http/prefs-store.ts). * Persisted host-side so favourites + group collapse-state follow the user from * laptop to phone (localStorage was per-device). Holds NO secrets — just project * paths and collapse booleans; served over GET /prefs, written via PUT /prefs. */ export interface UiPrefs { favourites: string[]; // favourited project paths (★), replaces per-device proj-favs collapsed: Record; // namespace group-key → collapsed? (true = hidden) } /* ── GET /config/ui (review #4) ── */ /** Client-readable UI configuration (GET /config/ui). `allowAutoMode` mirrors * the server ALLOW_AUTO_MODE gate so the FE can hide the high-risk 'auto' * permission mode when the server forbids it (SEC-M5). */ export interface UiConfig { allowAutoMode: boolean; } /* ─────────────────────── 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;