Files
web-terminal/src/types.ts
Yaojia Wang 4871e8ac3d feat(ios): P1-A — server touch-points (lastOutputAt, APNs sender+token endpoint) + APIClient P1 contract
T-iOS-37: LiveSessionInfo.lastOutputAt additive-optional field + manager.list() mapping (+4 tests; web consumers unaffected)
T-iOS-20: src/push/apns.ts — env-gated (all-or-disabled, no crash, zero key-material logging), hand-rolled ES256 JWT
(node:crypto ieee-p1363, zero new deps), NEEDS-INPUT/DONE payloads with structural minimization, token store per
subscription-store conventions, combineNotifyServices parallel to web-push, POST/DELETE /push/apns-token per frozen
wire shape (G-guard, 5/min/IP, 8kb); 65 tests incl. dual-channel e2e vs local fake APNs
T-iOS-38: APIClient builders — apns-token (client-side hex mirror, pre-network reject), projects/detail (lossy decode,
single-point percent-encoding), prefs (unknown-key byte-exact round-trip preservation), public four-tier HostNetworkTier
Coordination: webterminal:// CFBundleURLTypes pre-registered in project.yml for T-iOS-22
Verified: root 1470 tests + tsc clean; APIClient 76 tests, 95.26% coverage; wire-shape cross-check zero mismatches
2026-07-05 13:34:01 +02:00

517 lines
23 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 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 `<editorCmd> <repoPath>` 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<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").
* 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<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>;
/** 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
/** T-iOS-37: last pty.onData timestamp (epoch ms; = createdAt until first
* output — createSession inits it to spawn time). Serialized from the M3
* runtime record so clients can derive an unread watermark
* (lastOutputAt > local last-seen). Additive OPTIONAL: older consumers
* that build or read LiveSessionInfo without it stay valid. */
readonly lastOutputAt?: number;
}
/* ───────────────── 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<void>;
}
/* ── 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<string, boolean>; // 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;