commit dda09ef2c60337c08eceed5def02395857d6880d Author: Yaojia Wang Date: Tue Jun 16 06:54:57 2026 +0200 docs: web-terminal v0.1 spec, plan, and multi-agent dev config - TECH_DOC + ARCHITECTURE (cross-validated, 12 fixes M1-M7/L1-L5) - PLAN: waves W0-W5, tasks T1-T21 for multi-agent parallel dev - PROGRESS_LOG: orchestrator-owned cross-session memory - CLAUDE.md: required-reading + orchestrator-worker workflow - .claude/agents: module-builder, module-reviewer diff --git a/.claude/agents/module-builder.md b/.claude/agents/module-builder.md new file mode 100644 index 0000000..86cca91 --- /dev/null +++ b/.claude/agents/module-builder.md @@ -0,0 +1,49 @@ +--- +name: module-builder +description: TDD-implements ONE task (T1–T21) from docs/PLAN.md for the web-terminal project. Use to build a single module/file-set in isolation during parallel multi-agent development. Stays strictly within the task's Owns: files and returns a progress-log entry instead of writing the log itself. +tools: Read, Write, Edit, Bash, Grep, Glob +model: sonnet +--- + +You are a focused module builder for the **web-terminal** project (a browser↔local-shell terminal over WebSocket; see `CLAUDE.md`). You implement **exactly one PLAN task** end-to-end with TDD, then hand back a log entry. You run with isolated context, possibly in parallel with other builders — so discipline about scope and contracts is everything. + +## On invocation, you will be told a task ID (e.g. `T6`). Do this in order: + +1. **Read the required docs first — do not skip (your context is fresh):** + - `CLAUDE.md` → "Development Workflow" (the rules you operate under). + - `docs/PLAN.md` → find your task. Read its `Owns:`, `Depends:`, `Steps:`, `Accept:`, and the `ARCH:` field. + - `docs/ARCHITECTURE.md` → the exact §section your `ARCH:` points to, **plus every `M*`/`L*` anchor named in your Steps** (search the file for e.g. `M2`). These are the easy-to-get-wrong points. + - `docs/TECH_DOC.md` → the same-numbered section for background/rationale when needed. + - `src/types.ts` (if it exists) → the **frozen** shared contracts you import from. Never redeclare these types locally. + +2. **TDD the task** (per ARCHITECTURE §6/§7): + - Write the tests first (RED) covering every Step and the `Accept:` criteria, run them, see them fail. + - Implement the minimum to pass (GREEN). Refactor. + - Run the task's acceptance command (e.g. `npx vitest run `) and confirm green. + - Follow the global coding style: immutable data (return new objects, no mutation), small focused files, explicit error handling, no hardcoded values (use config), input validation at boundaries. + +3. **Honor the hard constraints:** + - **Only create/modify files in your task's `Owns:` list.** Touching anything else (especially `src/types.ts` or other modules) corrupts parallel work. If you genuinely need a new shared type or a change outside your lane, **STOP** and return BLOCKED (see below) — do not edit it yourself. + - You **cannot ask the user questions** and **cannot talk to other agents**. If the docs leave a real ambiguity, do not guess — return BLOCKED. + - Depend on **interfaces** (from `src/types.ts`), not other modules' implementations. + +4. **Verify before reporting.** Re-run the acceptance command. If it isn't green, either fix it or report honestly that it's failing — never claim success you didn't observe. + +## Your deliverable: end your FINAL message with a paste-ready log entry + +You must NOT edit `docs/PROGRESS_LOG.md` (shared file, owned by the orchestrator). Instead, finish with exactly this block, filled in: + +``` +### · +- **状态**: [x] DONE (or [!] BLOCKED) +- **改动**: <files:functions you created/changed — only your Owns:> +- **验证**: <exact command run + result, e.g. `npx vitest run ring-buffer` → 9 passed> +- **决策 / 偏离 PLAN**: <none, or what you decided and why> +- **遗留 / 待办**: <none, or follow-ups> +- **阻塞**(only if BLOCKED): <precise question/obstacle; what you need to proceed> +- **commit**: N/A +``` + +If BLOCKED: stop work, leave the repo in a clean state (don't leave half-written files outside a coherent state), set 状态 to `[!] BLOCKED`, and make the 阻塞 line specific enough that the orchestrator can resolve it in one step. + +Keep your prose summary short — the log entry is the real handoff. diff --git a/.claude/agents/module-reviewer.md b/.claude/agents/module-reviewer.md new file mode 100644 index 0000000..9b4eccd --- /dev/null +++ b/.claude/agents/module-reviewer.md @@ -0,0 +1,39 @@ +--- +name: module-reviewer +description: Read-only reviewer / acceptance checker for the web-terminal project. Use to review a completed task's diff or to run an acceptance check (F1–F9) and REPORT findings. It never edits source — fixes are routed back to the owning module-builder or the orchestrator. +tools: Read, Grep, Glob, Bash +model: sonnet +--- + +You are a read-only reviewer for the **web-terminal** project (see `CLAUDE.md`). You verify correctness against the spec and **report** — you do **not** fix code. This keeps parallel work safe: only a module's owning builder edits its files. + +## On invocation you'll be told what to review (a task ID like `T12`, a file set, or an acceptance item like `F6`). Do this: + +1. **Read the contracts your review is judged against:** + - `docs/ARCHITECTURE.md` — the relevant §section, the **invariant checklist (§8)**, and every `M*`/`L*` anchor relevant to the target (search the file for the code, e.g. `M2`). + - `docs/TECH_DOC.md` — protocol (§4), security (§7), acceptance criteria (§8 / the F-table) as relevant. + - `docs/PLAN.md` — the target task's `Steps:` and `Accept:`. + +2. **Review against these, in priority order:** + - **Invariants (ARCHITECTURE §8)** — e.g. server never parses terminal semantics; WS disconnect never kills PTY; `parseClientMessage` never throws; `ws.send` guarded by `readyState`; one ws per session; Origin checked at upgrade; no hardcoded values; `input.data` passed through verbatim. + - **The `M*`/`L*` fixes** — confirm each relevant one is actually implemented correctly (e.g. M1 allowedOrigins not derived from `0.0.0.0`; M2 ring buffer doesn't split UTF-8/ANSI; M4 spawn-failure path; L1 detach-then-exit keeps the session). + - **The task's `Accept:` criteria** and Steps coverage — including whether tests actually exercise the tricky branches, not just the happy path. + - **Coding style** — immutability (no mutation), small files, explicit error handling, boundary validation. + - Adversarially probe: try to find the input/ordering/edge case that breaks it. Default to skepticism. + +3. **Run only read-only / test commands** (e.g. `npx vitest run <name>`, `git diff`, `grep`). Do not modify source, config, or docs. + +## Your deliverable: a findings report (end your final message with it) + +``` +## Review: <target> — verdict: PASS / CHANGES NEEDED +- coverage: <which Steps/invariants/M*L* you checked> + +### Findings (each routed to an owner) +1. [severity: critical/high/medium/low] <file:line or area> — <what's wrong, why it violates which invariant/M*/Accept> → route to: <owning task, e.g. T6 module-builder> +2. ... + +(If PASS: say so explicitly and note what you verified.) +``` + +Be concrete and cite the exact invariant / `M*` / `Accept:` clause each finding violates, and **name the owning task** so the orchestrator knows which builder to send the fix to. If you found nothing real after genuine effort, say PASS — do not invent issues. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4f3edb1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,103 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Status + +**Planning stage — no code yet.** The `docs/` folder holds the authoritative spec; there is no `src/`, `public/`, or `package.json` yet. Read these before implementing: + +- `docs/TECH_DOC.md` — design & rationale (the **why**). Authoritative for protocol (§4), session model (§5.2), security (§7), acceptance criteria (§8). +- `docs/ARCHITECTURE.md` — development guide (the **how**). Module contracts at **function-signature level**, dependency rules, build order (§6 S1–S8), and the invariant checklist (§8). +- `docs/PLAN.md` — the task plan. Dependency-ordered **waves W0–W5**, fine-grained **tasks T1–T21** for multi-agent parallelism (each task has `Owns:` / `Depends:`; per-task model & isolation in §4). +- `docs/PROGRESS_LOG.md` — the **memory file** (cross-session). Orchestrator-owned; subagents return their entry rather than writing it. See workflow below. +- `.claude/agents/` — project subagents: `module-builder` (TDD-implements one task), `module-reviewer` (read-only review). + +**Language decision: TypeScript (`.ts`), not `.js`** — ARCHITECTURE §0 records this divergence from TECH_DOC's original `.js` filenames. Wherever the two docs conflict, ARCHITECTURE wins on *how* (it was cross-validated and corrected); TECH_DOC wins on *why/scope*. + +## Development Workflow: Plan & Progress Log (MANDATORY) + +Work proceeds against a **phased plan** and is tracked in a **progress log that acts as cross-session memory**. A new Claude instance must be able to read the log and know exactly where things stand. Follow these rules: + +**Required reading before starting ANY task (especially sub-agents — context is isolated):** +- [`docs/TECH_DOC.md`](docs/TECH_DOC.md) — design rationale, scope, protocol (§4), session model (§5.2), security (§7), acceptance (§8). The **why**. +- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — function-signature-level contracts, dependency rules, the **invariant checklist (§8)**. The **how**. On conflict, ARCHITECTURE wins on *how*, TECH_DOC on *why/scope*. +- The task's `ARCH:` field (in PLAN.md) points to the exact sections to read for that task; also read any `M*`/`L*` anchors named in its Steps. +- **`M1`–`M7` / `L1`–`L5`** are the 17 cross-validated fixes (M=medium, L=low) embedded as same-named anchors in ARCHITECTURE.md (some echoed in TECH_DOC.md). Seeing `(M2)` in a task → **search `ARCHITECTURE.md` for "M2"** to get the full rationale and correct approach. These are the easiest things to get wrong — read them before writing. + +**The plan (`docs/PLAN.md`):** +- The build is split into **waves W0–W5** by dependency, with **fine-grained tasks `T1`–`T21`** designed for **multi-agent parallelism**. Same-wave tasks are independent and own disjoint files; cross-wave follows dependencies. See PLAN §0–§1. +- Every task has a stable ID (e.g. `T6`) and an `Owns:` file list. **Never edit files outside your task's `Owns:`** — that's how parallel agents avoid clobbering each other. `src/types.ts` (T2) is the frozen shared-contract source; if a new type is needed, change it there (a coordination point), don't redeclare locally. +- The plan is the source of *intent*; do not silently deviate. If reality forces a change, update `PLAN.md` AND record the deviation in the log. + +**The log (`docs/PROGRESS_LOG.md`) — cross-session memory. WHO writes it depends on mode (G1):** +- `PROGRESS_LOG.md` is a **shared file** — it is **not** in any task's `Owns:`. To avoid concurrent-write clobbering, only **one writer** touches it: the **orchestrator** (the main session that dispatches tasks). +- **If you are a dispatched subagent doing one task:** do **NOT** edit `PROGRESS_LOG.md`. Instead, **end your final message with a ready-to-paste log entry** (the "条目模板" block from the log) describing status, files/functions touched, how it was **verified** (test/command + result), decisions/deviations, blockers, next step. That entry IS your deliverable back to the orchestrator. +- **If you are the orchestrator (or working solo):** read the log at the **start of every session**; after each subagent returns (or after each solo sub-task), **append its entry immediately** and update the "当前焦点 / Current Focus" block — not in a batch at the end. An untracked completed task is a memory loss. +- Be factual — log what actually happened (tests failing, steps skipped), never aspirational. Never pre-fill future tasks as done. + +**Ordering — solo:** consult `PLAN.md` → do the task (TDD per ARCHITECTURE §6/§7, stay within `Owns:`) → verify → append to `PROGRESS_LOG.md` → next task. Commit boundaries map to tasks where practical. + +### Multi-agent orchestration (when running tasks in parallel) + +This repo is structured for **orchestrator-worker** parallelism (the official Claude Code pattern). The **main session is the orchestrator**; it dispatches PLAN tasks to subagents, waits, then synthesizes. Rules: + +- **Dispatch by wave**: only same-wave, file-disjoint tasks run concurrently. Keep the parallel batch to **~3–5 agents** (diminishing returns / token cost beyond that). PLAN §3 is the dispatch schedule; PLAN §4 gives each task's **model** and **isolation**. +- **Use the project agents** in [`.claude/agents/`](.claude/agents): `module-builder` (TDD-implements one task) and `module-reviewer` (read-only review). Don't hand parallel work to a generic agent — these encode tool limits, the required-reading rule, the `Owns:` boundary, and the log-return contract. +- **Subagents cannot talk to each other or ask the user mid-task** (architectural limit). So: tasks must be self-contained; if a subagent hits an ambiguity not resolved by the docs, it must **stop and return a `[!] BLOCKED` entry — never guess**. The orchestrator resolves it (asks you if needed) and re-dispatches. +- **File isolation**: file-ownership (`Owns:`) prevents logical conflicts. For genuinely concurrent edits, also run builders with **`isolation: worktree`** (each gets a throwaway git worktree) so parallel `tsc`/`vitest`/edits never collide on the working tree. PLAN §4 marks which tasks want it. +- **Verification tasks (W5) are report-only** by default: a reviewer/acceptance agent **finds** issues and returns them; **fixes are routed back to the owning module's builder** (or done by the orchestrator), so no agent edits files outside its lane. + +## What This Is + +A browser-based terminal that exposes the host machine's local shell over WebSocket, so any device on the LAN can open `http://<host-ip>:3000` and get an interactive shell. The primary use case is **vibe coding** — sending Claude Code a task, walking away, and reconnecting from any device (phone/tablet/another computer) to check on it. This drives two non-obvious requirements: **sessions must survive disconnects** and **the mobile UI must be usable**. + +**Explicitly out of scope (v0.1):** auth/login, multi-user isolation, SSH jump hosts, containers, and any public-internet exposure. + +## Planned Commands + +```bash +npm install # triggers node-pty native compilation (needs Xcode CLT on macOS) +npm start # listens on 0.0.0.0:3000 +npm test # unit tests (vitest, all modules) +``` + +Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST` — `0.0.0.0` is never a valid Origin); see ARCHITECTURE §3.1. + +## Architecture (the parts that span files) + +The server is a **byte-shuttle, not a terminal**. It does not parse ANSI/terminal semantics — xterm.js (browser) interprets escape sequences and renders; node-pty (server) provides the pseudo-terminal so the shell believes it has a real TTY. This separation is the central simplification — keep it. Don't add terminal-semantic parsing on the server. + +Data flow: `keypress → xterm onData → WS → pty.write() → shell stdin`, and `shell stdout → pty onData → WS → xterm.write() → screen`. + +### Session/connection decoupling (the most important design point) + +**PTY lifecycle ≠ WebSocket lifecycle.** A WS disconnect must NOT kill the PTY — the Claude Code task running inside has to keep going. Sessions are keyed by `sessionId` (client stores it in localStorage): + +- `attach(null)` → spawn a new PTY + shell; PTY output goes into a ~2MB **ring buffer** and (if a WS is attached) forwards live. +- `attach(sessionId)` → look up the session, **replay the ring buffer**, then resume the live stream. This is what makes "refresh the page and the Claude session is still there" work. +- WS close → `detach` (mark session unwatched, PTY keeps running), not kill. +- Only one WS attached per session at a time — a new attach kicks the old WS to avoid interleaved input. +- Orphan reclaim: detached longer than `IDLE_TTL` (default 24h) **with no new output since detach** → reclaim. (node-pty can't reliably detect a foreground child, so liveness is approximated via last-output time — see ARCHITECTURE §3.5 / M3.) On server exit, `pty.kill()` all sessions (no cross-restart persistence in v0.1; tmux backend is a v0.2 idea). + +Represent session state as immutable snapshots (id/start-time fixed at creation); hold mutable runtime handles (pty/ws) separately. + +### WebSocket protocol (`protocol.ts`) + +Single WS connection, JSON text frames. `attach` must be the **first message**. Client→server types: `attach` / `input` / `resize`. Server→client types: `attached` / `output` / `exit`. See TECH_DOC §4 for exact shapes. + +`resize` must be its own message type (it triggers `ioctl(TIOCSWINSZ)` → SIGWINCH so full-screen TUIs like vim/top redraw correctly). Validate at the boundary: `type` must be whitelisted, `resize` cols/rows must be integers 1–1000, `input.data` must be a string passed through verbatim (it's raw keyboard bytes — do not filter content). + +### Security (non-negotiable) + +This app hands a full shell to anyone who can reach the port. **Origin-header validation on the WS handshake is the one defense that cannot be skipped** — it blocks Cross-Site WebSocket Hijacking (a malicious page in your browser trying to connect to `ws://<lan-ip>:3000`). Reject foreign origins (401). Never port-forward / tunnel this to the public internet. Tailscale is the recommended deployment over bare LAN. + +### Frontend (`public/`) + +xterm.js + FitAddon. WS URL is same-origin, with the **scheme following the page protocol** (`wss:` on HTTPS, else `ws:`) so no IP config is needed and Tailscale/TLS (HTTPS) deploys avoid mixed-content blocking (M6). Auto-reconnect with exponential backoff (1s/2s/4s… cap 30s), carrying the localStorage `sessionId`. A **mobile touch key-bar** (hidden >768px) sends Esc `\x1b`, Shift+Tab `\x1b[Z`, arrows, Enter `\r`, Ctrl+C `\x03`, Tab `\t` directly via `ws.send` (bypassing xterm to avoid popping the soft keyboard) — these are Claude Code's high-frequency keys that phone keyboards can't produce. + +## Gotchas (from the spec) + +- Enter sends `\r` (0x0D), not `\n` — easy to get wrong when synthesizing input. +- `fit()` must run after the container has real dimensions; calling it while `display:none` yields NaN. +- node-pty needs `npm rebuild` after a major Node version bump. +- Don't double-handle IME — xterm.js already manages composition events; don't add your own keydown listener for CJK input. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..4e978c3 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,422 @@ +# Web Terminal 架构文档(开发指引) + +> 版本: v0.1 · 日期: 2026-06-12 +> 定位: **开发指引** —— 把 [TECH_DOC.md](./TECH_DOC.md) 的设计落到模块边界、接口契约、函数签名和实现顺序。 +> 写代码时对照本文档逐模块填充;设计意图与取舍请回看 TECH_DOC。 + +--- + +## 0. 与 TECH_DOC 的差异(先读) + +| 项 | TECH_DOC | 本文档(采纳) | 原因 | +|----|----------|--------------|------| +| 语言 | `.js` | **TypeScript(`.ts`)** | 强类型表达模块契约,IDE 可校验协议消息形状 | +| 构建 | 直接 `node` | `tsc` 编译到 `dist/`,或 `tsx` 直跑 | TS 需要编译步骤 | + +其余设计(byte-shuttle 原则、attach/detach 会话模型、协议、安全)完全遵循 TECH_DOC。 + +--- + +## 1. 分层与依赖方向 + +依赖只能从上往下,**不允许反向或跨层**: + +``` +┌─ 入口层 ──────────────────────────────────────────┐ +│ server.ts Express 静态服务 + HTTP + WS 升级 │ +└───────┬───────────────────────────┬───────────────┘ + │ 创建/路由 │ Origin 校验 +┌───────▼──────────┐ ┌────────▼──────────┐ +│ session/ │ │ http/origin.ts │ 无状态纯函数 +│ manager.ts │ └───────────────────┘ +│ session.ts │ +└───────┬──────────┘ + │ 编解码消息 +┌───────▼──────────┐ ┌───────────────────┐ +│ protocol.ts │ │ config.ts │ 常量,被所有层读取 +│ (纯函数, 无副作用) │ └───────────────────┘ +└──────────────────┘ +``` + +核心原则(继承自 TECH_DOC §2.1):**服务端是字节搬运工,不解析终端语义**。 +`protocol.ts` 只认 JSON 信封;PTY 输出的 ANSI 字节对服务端是不透明的 blob。 + +--- + +## 2. 目录结构 + +``` +web-terminal/ +├── docs/ +│ ├── TECH_DOC.md # 设计与取舍(why) +│ └── ARCHITECTURE.md # 本文档(how) +├── src/ +│ ├── server.ts # 入口:HTTP + WS 升级 + Origin 校验接线 (<80 行) +│ ├── config.ts # 环境变量读取 + 常量 (<60 行) +│ ├── protocol.ts # 消息类型 + parse/serialize + 校验 (纯函数) +│ ├── http/ +│ │ └── origin.ts # Origin 白名单校验 (纯函数) +│ └── session/ +│ ├── manager.ts # 会话表:create/attach/detach/reap +│ └── session.ts # 单会话:PTY 句柄 + ring buffer + ws 绑定 +├── public/ +│ ├── index.html +│ ├── main.ts # xterm 初始化 + WS 客户端 + 重连 +│ ├── keybar.ts # 移动触摸键栏 +│ └── style.css +├── test/ +│ ├── protocol.test.ts # 协议编解码 + 校验(TDD 起点) +│ └── manager.test.ts # 会话生命周期(PTY 用 mock) +├── tsconfig.json +└── package.json +``` + +文件遵循小文件原则,单一职责,均 < 200 行。 + +--- + +## 3. 模块契约(函数签名级) + +### 3.1 `config.ts` + +环境变量在启动时一次性读取并冻结,杜绝运行时硬编码(继承 TECH_DOC §10)。 + +```ts +export interface Config { + readonly port: number; // PORT,默认 3000 + readonly bindHost: string; // BIND_HOST,默认 '0.0.0.0' + readonly shellPath: string; // SHELL_PATH,默认 process.env.SHELL ?? '/bin/zsh' + readonly homeDir: string; // PTY cwd,默认 os.homedir() + readonly idleTtlMs: number; // IDLE_TTL,默认 24h + readonly scrollbackBytes: number; // ring buffer 容量,默认 2MB + readonly maxPayloadBytes: number; // 单条 WS 帧上限,默认 1MB(远超正常键盘输入,防巨帧打爆内存) + readonly allowedOrigins: readonly string[]; // 见下方推导规则 +} + +/** + * 读取并校验环境变量;缺失用默认值,非法值(端口越界等)直接抛错 fail-fast。 + * + * allowedOrigins 推导(关键,M1):**不能**从 bindHost 推导 —— + * 默认 bindHost='0.0.0.0' 是监听通配地址,浏览器永远不会以 http://0.0.0.0:3000 为 Origin。 + * 正确来源 = 枚举本机各网卡 IPv4(os.networkInterfaces(),非内部地址) + * + 'localhost' / '127.0.0.1' + * + 可选主机名(<host>.local) + * → 对每个生成 http://<host>:<port>(和 https:// 同形,见 M6) + * + ALLOWED_ORIGINS 环境变量追加项。 + * isOriginAllowed 应同时比对 host 与 port。 + * 否则 F4(局域网任意设备访问)会被 F9(Origin 校验)误判 401 —— 二者直接打架。 + */ +export function loadConfig(env: NodeJS.ProcessEnv): Config; +``` + +### 3.2 `protocol.ts`(纯函数,先用 TDD 实现) + +消息形状即 TECH_DOC §4。用可辨识联合(discriminated union)表达: + +```ts +// 客户端 → 服务端 +export type ClientMessage = + | { type: 'attach'; sessionId: string | null } + | { type: 'input'; data: string } + | { type: 'resize'; cols: number; rows: number }; + +// 服务端 → 客户端 +export type ServerMessage = + | { type: 'attached'; sessionId: string } + | { type: 'output'; data: string } + | { type: 'exit'; code: number; reason?: string }; + // exit.code: shell 正常退出码;spawn 从未成功用 -1(见 §3.6 / §4.4)。 + // exit.reason: 正常退出可省;**spawn 失败 / 异常退出时必填**,向用户说明原因。 + +/** sessionId 格式常量,放 protocol.ts(分层上 protocol 不依赖 session 模块)。 */ +export const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; // UUID v4 + +export type ParseResult = + | { ok: true; message: ClientMessage } + | { ok: false; error: string }; // 非法消息丢弃 + 记日志,不抛 + +/** 解析 + 校验外部输入(系统边界,TECH_DOC §5.3)。永不抛异常,错误走 ParseResult。 */ +export function parseClientMessage(raw: string): ParseResult; + +/** 序列化服务端消息为 WS 文本帧。内部可信数据,直接 JSON.stringify。 */ +export function serialize(msg: ServerMessage): string; +``` + +**校验规则**(必须在 `parseClientMessage` 内实现,见 TECH_DOC §5.3): +- 入参 `raw` 在送入 `parseClientMessage` 前,调用方先按 `cfg.maxPayloadBytes` 拒收超大帧 + (并把 `ws` 的 `maxPayload` 设为同值,L5:防单条数百 MB 巨帧打爆内存)。 +- 合法 JSON 且 `type` 在白名单内,否则 `{ ok: false }`。 +- `resize`: `cols`/`rows` 为 1–1000 的整数(`Number.isInteger` + 范围)。 +- `input.data`: 必须是 `string`,**原样透传不过滤**(它是键盘字节;服务端不解释字节, + 透传本身不引入注入风险)。 +- `attach.sessionId`: `string | null`;若为 string,**必须匹配 `SESSION_ID_RE`(UUID v4)**, + 否则 `{ ok: false }`。注意 TECH_DOC §4 示例里的 `"abc123"` 仅为示意,真实值是 `crypto.randomUUID()`。 + +### 3.3 `http/origin.ts`(纯函数,安全关键) + +```ts +/** CSWSH 防线(TECH_DOC §7)。Origin 不在白名单 → false → 握手返回 401。 */ +export function isOriginAllowed(origin: string | undefined, allowed: readonly string[]): boolean; +``` + +约定:`undefined` Origin(非浏览器客户端)的处理策略在此集中决定。默认拒绝; +如需放行本机 curl 调试,通过 config 显式开关,**不要散落 if 判断**。 + +### 3.4 `session/session.ts` —— 单会话 + +会话状态按 TECH_DOC §5.2 拆成**不可变快照** + **可变运行时句柄**: + +```ts +/** 创建时固定,永不修改(immutable)。 */ +export interface SessionMeta { + readonly id: string; // crypto.randomUUID() + readonly createdAt: number; // 由调用方传入时间戳(便于测试注入) + readonly shellPath: string; +} + +export interface Session { + readonly meta: SessionMeta; + /** 输出环形缓冲(2MB),重连回放用。淘汰语义见下方 RingBuffer。 */ + readonly buffer: RingBuffer; + /** 当前附着的 ws,null = 已 detach 但 PTY 仍活(vibe coding 核心)。 */ + attachedWs: WebSocket | null; + detachedAt: number | null; // detach 时刻 + /** 最近一次 pty.onData 的时间戳。孤儿回收用它近似"是否仍在跑前台作业"(M3)。 */ + lastOutputAt: number; + /** PTY 退出时刻;null = 仍存活。非 null 后:writeInput/resize 静默忽略(L4), + * attach 命中时回放 buffer 后立即补发 exit(L1)。 */ + exitedAt: number | null; + exitCode: number | null; + readonly pty: IPty; // node-pty 句柄 +} + +/** + * spawn PTY,接好 onData→buffer→(转发 ws) 与 onExit。不持有 ws。 + * + * onData:append buffer、刷新 lastOutputAt、若 attachedWs 存在则转发。 + * onExit(M4/L1/L2):置 exitedAt/exitCode;若 attachedWs 存在则发 exit; + * 再调用注入的 onExit 回调,把"从会话表移除"的职责交回 manager + * (session 不持有 manager,靠回调跨层,见 §3.5)。 + * + * 失败契约(M4):node-pty 在 shell 路径不存在等情况下会**同步抛错**。 + * createSession **不**吞掉它 —— 让它抛出,由 manager.handleAttach 透传给 server.ts 捕获, + * server.ts serialize 一条 { type:'exit', code:-1, reason } 后 close 该连接(只关连接,不 fail-fast)。 + */ +export function createSession( + cfg: Config, + dims: { cols: number; rows: number }, + now: number, + onExit: (session: Session) => void, // manager 注入:用于从会话表移除(L2) +): Session; + +/** 把 ws 绑定到会话:先回放 buffer,再接续实时流。返回踢掉的旧 ws(若有)。 */ +export function attachWs(session: Session, ws: WebSocket): WebSocket | null; + +/** 解绑 ws,标记 detachedAt;**不杀 PTY**。 */ +export function detachWs(session: Session, now: number): void; + +// writeInput/resize:exitedAt!=null 时**直接 return**(L4:对已退出 PTY 调 write/resize 会抛错)。 +export function writeInput(session: Session, data: string): void; // pty.write +// resize 另做幂等:cols/rows 与当前相同则跳过,天然抑制高频 resize(SIGWINCH 抖动)。 +export function resize(session: Session, cols: number, rows: number): void; // pty.resize → SIGWINCH +export function kill(session: Session): void; // pty.kill,进程退出/回收时调用 +``` + +`RingBuffer` 是独立小工具(可放 `session/ring-buffer.ts`): + +```ts +export interface RingBuffer { + append(chunk: string): void; + snapshot(): string; // 当前缓冲全量,attach 回放用 +} +export function createRingBuffer(maxBytes: number): RingBuffer; +``` + +**裁剪语义(M2,易踩)**:容量单位是**真实字节**(`scrollbackBytes`),但 PTY 输出是 ANSI 字节流, +**不能在任意字节处切断** —— 否则会把一条 ANSI 转义序列(如 `ESC[1;32m`)或一个多字节 UTF-8 码点 +切成两半,重连回放第一屏花屏/乱码,而这正是 vibe coding 重连看 Claude TUI 的主场景。约定: +- 内部以 `Buffer`(`'utf8'`)按真实字节计量,使容量与 `scrollbackBytes` 语义一致 + (`string.length` 是 UTF-16 码元数 ≠ 字节数,不可直接当字节用); +- **按 chunk(append 整块)边界整体淘汰最旧块**,保证不切碎序列(代价是容量略浮动); +- 兜底:`snapshot()` 开头补一个软复位 `ESC[0m`,即使最旧块边界仍残留半截属性也能止血。 +- 这与不变量 #1"字节是不透明 blob"不冲突:不解析语义 ≠ 可在任意字节处切割。 +`ring-buffer.test.ts` 须覆盖"淘汰不切碎多字节码点 / 不切碎转义序列"。 + +### 3.5 `session/manager.ts` —— 会话表 + +```ts +export interface SessionManager { + /** + * attach 入口: + * - sessionId=null → 新建。 + * - 命中存活会话 → attachWs(回放 + 续流)。 + * - 命中**已退出**会话(exitedAt!=null,L1)→ 回放最后 buffer 后立即补发 exit,再移除;不静默新建。 + * - 查不到(含格式非法已被 §3.2 拦掉)→ 新建并返回新 id。 + * createSession 抛错(spawn 失败,M4)不在此吞掉,向上抛给 server.ts。 + */ + handleAttach(ws: WebSocket, sessionId: string | null, dims: Dims, now: number): Session; + get(id: string): Session | undefined; + /** + * 孤儿回收(M3)。判据 = detach 超过 idleTtl **且** 自 detach 起无新输出 + * (now - max(detachedAt, lastOutputAt) > idleTtl)。 + * 不依赖"PTY 前台子进程"——node-pty 不跨平台暴露前台进程组,无法可靠判定; + * 用 lastOutputAt 近似"仍在跑任务",且该字段可被 mock IPty 驱动、可测。 + * 定时器周期调用。返回回收数量。 + */ + reapIdle(now: number): number; + /** 服务端退出时统一清理(TECH_DOC §5.2)。 */ + shutdown(): void; +} + +export function createSessionManager(cfg: Config): SessionManager; +``` + +**约束(逐条对应 TECH_DOC §5.2):** +- WS close → `detachWs`,**不** kill PTY。 +- 同一会话同时只允许一个 ws,后到踢前者(`attachWs` 返回旧 ws,调用方先把 `attachedWs` 指向新 ws、 + 再 close 旧 ws,确保转发逻辑永不向被踢/关闭中的 ws 写)。 +- PTY 退出:`createSession` 内部 onExit → 置 exitedAt → 有 ws 则发 exit → 调 manager 注入的回调从表移除 + (L2:session 不持有 manager,退出事件靠注入回调跨层冒泡)。 +- detach 后才退出(无 ws 可通知,L1):**不立即丢弃会话**,保留 buffer + exitedAt, + 等用户带旧 id 回来 attach 时回放 + 补发 exit;若一直无人 attach,由 reapIdle 兜底回收。 + +### 3.6 `server.ts` —— 接线层(薄) + +只做接线,无业务逻辑: + +```ts +export function startServer(cfg: Config): { close(): Promise<void> }; +``` + +职责: +1. Express 托管 `public/`(及编译后的前端)。 +2. **WS 用 `new WebSocketServer({ noServer: true })` 创建**(L3:不传 `server`,否则它会自挂 + upgrade 监听,与下面手写的 upgrade 校验双重处理同一 socket)。 +3. HTTP server `upgrade` 事件(单一升级入口): + - `pathname !== '/term'`(路径常量入 config,不散落硬编码)→ `socket.destroy()`; + - `isOriginAllowed` 不通过 → `socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')` + `destroy()`; + - 全部通过 → `wss.handleUpgrade(req, socket, head, ws => wss.emit('connection', ws, req))`。 +4. WS `connection`:等首条 `attach` → `try { manager.handleAttach() }` 回 `attached`; + **`catch`(spawn 失败,M4)→ `send({type:'exit', code:-1, reason})` 后 close,只关该连接,不 fail-fast**。 +5. WS `message`:`parseClientMessage`,`ok:false` 丢弃+记日志,否则路由到 `writeInput`/`resize`。 +6. WS `close`:`manager` detach(**不** kill PTY)。 +7. `process.on('SIGINT'/'SIGTERM')` → `manager.shutdown()`;`uncaughtException` 记录后退出 + (fail-fast 仅用于真正未预期错误;spawn 失败、send 失败等已知路径不得走到这里,TECH_DOC §5.4)。 + +**转发统一守卫(M5)**:任何 `ws.send` 前必须 `if (ws.readyState === WebSocket.OPEN)`;转发只对 +`session.attachedWs` 发送。send 失败的处理是**只 detach 该 ws、绝不 kill PTY**(否则违反不变量 #2)—— +注意区别于 TECH_DOC §5.4 笼统的"清理会话"措辞。 + +--- + +## 4. 关键流程时序 + +### 4.1 新建会话 + +``` +ws 连接 → 等待首帧 +client → {attach, sessionId:null} + manager.handleAttach(null) → createSession(spawn pty) → attachWs +server → {attached, sessionId:"<uuid>"} + pty.onData → buffer.append → ws.send {output} +``` + +### 4.2 重连回放(F5/F6,vibe coding 命脉) + +``` +新 ws 连接(旧 ws 可能还活着) +client → {attach, sessionId:"<uuid>"} + manager.get(id) 命中 + attachWs: 踢掉旧 ws → buffer.snapshot() 一次性 send → 之后接实时流 +server → {attached} → {output: <回放>} → {output: <实时>}... +``` +关键:**断开期间 pty.onData 仍写入 buffer**(因为 detach 不停 PTY),所以离线输出不丢。 + +### 4.3 resize + +``` +client → {resize, cols, rows} // 独立消息,不混进 input + exitedAt? → 忽略;cols/rows 未变? → 忽略(幂等) + pty.resize(cols, rows) → ioctl(TIOCSWINSZ) → shell 收 SIGWINCH → 全屏程序重绘 +``` + +### 4.4 PTY 退出 / spawn 失败(L1 / M4) + +``` +[在 detach 状态下退出] + pty.onExit → 置 exitedAt/exitCode → attachedWs 为 null,exit 暂不投递 + 会话保留(buffer + exitedAt) + client(旧 id 回来) → {attach, sessionId} + 命中已退出会话 → server → {output:<回放>} → {exit, code} // 用户仍看到末屏与退出码 + 之后由 reapIdle 兜底回收 + +[spawn 失败] + client → {attach, sessionId:null} + handleAttach → createSession 同步抛错 → server.ts catch + server → {exit, code:-1, reason:"spawn failed: ..."} → close(仅此连接) +``` + +--- + +## 5. 前端架构(`public/`) + +| 文件 | 职责 | 关键点 | +|------|------|--------| +| `main.ts` | xterm + FitAddon 初始化、WS 客户端、重连 | WS URL 同源,**scheme 随页面协议**:`(location.protocol === 'https:' ? 'wss' : 'ws') + '://' + location.host + '/term'`(M6:HTTPS 页面下硬写 `ws://` 会被浏览器 mixed-content 拦截,而 Tailscale/TLS 部署正是 HTTPS);`term.onData → ws.send {input}`;`onmessage → term.write(output)` | +| `keybar.ts` | 移动触摸键栏 | `touchstart` 直接 `ws.send {input,data}`,**绕过 xterm** 避免弹软键盘;>768px 隐藏 | + +**重连状态机**(TECH_DOC §6.2):指数退避 1s/2s/4s…上限 30s,携带 localStorage 的 sessionId; +`fit()` 必须在容器有真实尺寸后调用(`display:none` 会得到 NaN,TECH_DOC §9 坑位)。 + +按键字节表(keybar):Esc `\x1b` / Shift+Tab `\x1b[Z` / 方向 `\x1b[A..D` / Enter `\r` / Ctrl+C `\x03` / Tab `\t`。 +注意回车是 `\r`(0x0D)不是 `\n`。 + +--- + +## 6. 实现顺序(对应 TECH_DOC §9,TDD) + +| 步骤 | 模块 | 验证 | 依赖 | +|------|------|------|------| +| S1 | 脚手架 + `package.json` + `tsconfig` + node-pty 编译验证 | `npm install` 通过 | — | +| S2 | `config.ts` | 单测:默认值 + 非法值抛错 | — | +| S3 | `protocol.ts` + `ring-buffer.ts` | **先写测试**(RED→GREEN),覆盖校验分支 | — | +| S4 | `http/origin.ts` | 单测:白名单/非法/undefined | config | +| S5 | `session.ts` + `manager.ts`(PTY 用 mock) | `manager.test.ts`:attach/detach 不杀 PTY、踢旧 ws、reap | S2–S3 | +| S6 | `server.ts` 接线 | 手动:浏览器连通 | S2–S5 | +| S7 | `public/`(main + keybar) | 手动:vim/top/resize/手机 | S6 | +| S8 | 联调验收 F1–F9 | TECH_DOC §8 验收表 | 全部 | + +**先做 S3**:协议与缓冲是纯函数,最适合 TDD 起步且无外部依赖,能在没接 PTY/WS 时就锁定边界行为。 + +--- + +## 7. 测试策略 + +- **单元**(`test/`): + - `protocol`:编解码 + 全部校验分支(含 `SESSION_ID_RE` 接受 UUID v4 / 拒绝 `abc123`、超 `maxPayloadBytes` 拒收)。 + - `config`:`allowedOrigins` 由网卡 IP 推导(注入假 `networkInterfaces`),**不含** `0.0.0.0`(M1)。 + - `origin`:白名单/非法/undefined;host+port 双匹配。 + - `ring-buffer`:淘汰不切碎多字节码点 / 不切碎 ANSI 序列、按字节计量(M2)。 +- **会话生命周期**:`manager.test.ts` 用 **mock IPty**(`{ write, resize, kill, onData, onExit }`,可手动触发 + onData/onExit)验证:"WS 断开不杀 PTY""后到踢前者""reapIdle 用 lastOutputAt 判据(M3):有新输出不回收"" + detach 后 onExit 保留会话、attach 回放+补发 exit(L1)""对已退出 PTY 的 write/resize 被忽略(L4)"。 +- **集成/E2E**:server 起真 WS,断言 attach→attached→output 时序;重连回放覆盖 F5/F6; + 覆盖 spawn 失败 → exit(-1)+close 仅关连接(M4)、非 `/term` 路径与坏 Origin 被拒(L3/F9)。 +- 覆盖率目标见全局 testing 规范;PTY/xterm 真实交互部分以手动验收(§6 S7/S8)补足。 + +--- + +## 8. 不变量清单(代码评审对照) + +1. 服务端不解析 ANSI/终端语义,PTY 字节是不透明 blob(但 RingBuffer 淘汰须按 chunk/码点边界,不可任意字节切割,M2)。 +2. WS 断开 **永不** kill PTY —— 只 detach;`ws.send` 失败也只 detach 该 ws,不动 PTY(M5)。 +3. `parseClientMessage` 永不抛异常,非法输入走 `ParseResult.ok=false`。 +4. 会话 meta 不可变;运行时句柄(ws/detachedAt/lastOutputAt/exitedAt)单独可变。 +5. 一个会话同一时刻最多一个附着 ws;转发前先重置 `attachedWs` 指针再 close 旧 ws。 +6. 任何 `ws.send` 前必须 `readyState === OPEN`(M5)。 +7. Origin 校验在 upgrade 阶段完成(`noServer` + `handleUpgrade`),失败 401,不进入 WS 逻辑;路径限 `/term`(L3)。 +8. 无硬编码:所有可变参数走 `config.ts`(端口、shell、TTL、scrollback、maxPayload、WS 路径、allowedOrigins)。 +9. `input.data` 原样透传,不做内容过滤。 +10. PTY 退出后 `writeInput`/`resize` 静默忽略;exitedAt 会话保留至被 attach 回放或 reapIdle 回收(L1/L4)。 +11. 服务端不假设前端已防抖:`resize` 在服务端做幂等(值未变则跳过);WS 帧受 `maxPayload` 上限约束(L5)。 +``` \ No newline at end of file diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..2d3ea09 --- /dev/null +++ b/docs/PLAN.md @@ -0,0 +1,337 @@ +# Web Terminal 实施计划 (Phased Plan) + +> 版本: v0.1 · 定位: 把 [ARCHITECTURE.md](./ARCHITECTURE.md) 的契约拆成**细粒度、低耦合**的任务, +> 供**多 agent 并行开发**。完成情况记录在 [PROGRESS_LOG.md](./PROGRESS_LOG.md)。 +> 工作流约束见 [CLAUDE.md](../CLAUDE.md)(查 PLAN → 做子任务(TDD) → 验证 → 更新 LOG)。 + +--- + +## 0. 多 Agent 并行规则(开工前必读) + +并行的三条铁律,违反会导致 agent 互相踩踏: + +1. **文件所有权独占**:每个任务标注 `Owns:`,只有该任务可创建/修改这些文件。 + `src/types.ts`(共享契约)由 **T2 独占并冻结**,其余任务**只读** import;若需新增类型, + 先回到 T2 改契约(协调点),不得各自在自己文件里另立类型。 +2. **只依赖接口,不依赖实现**:任务间通过 `src/types.ts` 的 interface / 函数签名解耦。 + `Depends:` 仅表示"需要对方的**接口或产物**",多数情况接口在 W0 就已就绪,故可与"实现方"并行编码, + 仅在**集成/测试时**才真正汇合。 +3. **LOG 由 orchestrator 独写,subagent 不碰**(G1):`docs/PROGRESS_LOG.md` 是共享文件, + **不在任何任务的 `Owns:` 里**。被派的 subagent **不要写 LOG**,而是把日志条目作为**最终返回结果**交回; + 由主会话(orchestrator)在每批 agent 返回后统一追加。详见 [CLAUDE.md](../CLAUDE.md)。 + +每个任务自带 TDD(测试与实现同属一个 agent、同一组文件),所以**不要**把"写测试"和"写实现" +拆给不同 agent —— 那会制造同文件冲突。任务内的 `Steps:` 清单就是细粒度拆分。 + +**执行用的项目 agent**(见 [`.claude/agents/`](../.claude/agents)):`module-builder`(TDD 实现单个任务)、 +`module-reviewer`(只读复查/验收)。orchestrator = 主会话,按 §3 波次派活、按 §4 选模型/隔离。 +subagent **不能问用户、不能互相通信**:遇到文档未定义的歧义 → 停下返回 `[!] BLOCKED`,**绝不猜**。 + +### 参考文档 + +**必读文档清单、`ARCH:` 字段用法、`M*/L*` 代号含义 —— 见 [CLAUDE.md → Development Workflow](../CLAUDE.md)。** +该规则对所有任务(尤其上下文隔离的子 agent)统一生效,此处不重复,以 CLAUDE.md 为准。 +要点:每个任务读其 `ARCH:` 指向的章节 + Steps 里的 `M*/L*` 锚点(在 ARCHITECTURE.md 内搜同名代号)。 + +--- + +## 1. 依赖关系与并行波次 (Waves) + +``` +W0 基础(串行, 阻塞全部) + T1 脚手架+装齐依赖 → T2 共享契约 types.ts → T3 mock IPty + │ + ▼ +W1 叶子模块(全部并行, 互不依赖 —— 并行度最高) + 后端纯函数: T4 config · T5 protocol · T6 ring-buffer · T7 origin + 前端(独立于后端整条链): T8 index.html · T9 style.css · T10 keybar · T11 main + │ + ▼ +W2 T12 session(依赖 ring-buffer 实现 + mock IPty) + │ + ▼ +W3 T13 manager(依赖 session) + │ + ▼ +W4 集成(汇合点) T14 server 接线 · T15 集成/E2E 测试 + │ + ▼ +W5 验收与收尾(按 F# 并行) T16–T20 F1–F9 实测 · T21 安全+README+LOG 收尾 +``` + +**关键并行事实**:前端 T8–T11 不依赖任何后端模块(只依赖 §4 线协议形状,已在 types.ts), +可与 W1–W4 整条后端链**全程并行**,直到 W5 才与运行中的 server 汇合实测。 + +> ARCH §6 的 S1–S8 是线性视角;本计划是其**并行重排**。每个任务标了 `ARCH:` 做溯源。 + +--- + +## 2. 任务清单 + +> 状态图例同 LOG:`[ ]` TODO · `[~]` 进行中 · `[x]` 完成 · `[!]` 受阻。 +> 本文件只定义任务;勾选与详细记录由 **orchestrator** 写入 PROGRESS_LOG.md(subagent 只返回条目,见 §0 铁律 3)。 +> 每个任务的建议**模型 / 隔离**见 §4。 + +### W0 · 基础(串行) + +#### T1 · 脚手架 + 一次性装齐依赖 `[ ]` +- **Wave/ARCH**: W0 / S1 · **Owns**: `package.json`、`tsconfig.json`、`tsconfig.web.json`、`.gitignore`、`vitest.config.ts`、目录骨架 +- **Depends**: 无 · **Parallel-safe**: 无(必须最先) +- **Steps**: + - [ ] `npm init`;**一次性**声明全部依赖(避免后续并发 `npm install` 改 package.json 冲突): + 运行时 `express ws node-pty @xterm/xterm @xterm/addon-fit`;开发 `typescript tsx vitest @types/node @types/ws @types/express` + - [ ] 后端 `tsconfig.json`(target ES2022, module NodeNext, strict, outDir dist)+ 前端 `tsconfig.web.json`(lib DOM, 给 public/) + - [ ] scripts:`start`(tsx src/server.ts)、`build`(tsc)、`test`(vitest run)、`test:watch`、`build:web` + - [ ] 建空目录:`src/`、`src/http/`、`src/session/`、`public/`、`test/`、`test/helpers/` + - [ ] 验证 **node-pty 原生编译通过**(macOS 需 Xcode CLT):`npm install` 无错 + `node -e "require('node-pty')"` +- **Accept**: `npm install` 成功、`npm test` 能跑(0 测试也算通过)、node-pty 可 require + +#### T2 · 共享类型契约 `src/types.ts` `[ ]` +- **Wave/ARCH**: W0 / §3 · **Owns**: `src/types.ts` (**全局唯一契约源,完成后冻结**) +- **Depends**: T1 · **Parallel-safe**: 无(它是所有人的依赖根) +- **Steps**(逐个从 ARCHITECTURE §3 誊写为可编译 TS): + - [ ] `Config`(含 `maxPayloadBytes`、`allowedOrigins`;§3.1) + - [ ] `ClientMessage` / `ServerMessage` / `ParseResult`(§3.2) + - [ ] `Dims = { cols: number; rows: number }` + - [ ] `SessionMeta` / `Session`(含 `lastOutputAt`/`exitedAt`/`exitCode`;§3.4) + - [ ] `RingBuffer` interface(§3.4) + - [ ] `IPty` 子集 interface(`pid, cols, rows, onData, onExit, write, resize, kill`)—— 供实现与 mock 共用 + - [ ] 前端契约:`mountKeybar(onSend: (data: string) => void): void` 的类型(供 T10/T11 解耦) + - [ ] 各模块对外函数签名以 `declare`/注释列出(creatSession/attachWs/handleAttach/...),作为 import 锚点 +- **Accept**: `tsc --noEmit` 通过;无实现、纯类型 + +#### T3 · mock IPty 测试替身 `[ ]` +- **Wave/ARCH**: W0 / §7 · **Owns**: `test/helpers/mock-pty.ts` +- **Depends**: T2(`IPty` 接口) · **Parallel-safe**: 与 W1 全部 +- **Steps**: + - [ ] `createMockPty()` 实现 `IPty`,可**手动触发** `emitData(s)` / `emitExit(code)` + - [ ] 记录收到的 `write`/`resize`/`kill` 调用,供断言 +- **Accept**: 自带 1 个 smoke 测试:emitData 后监听者收到、kill 被记录 + +--- + +### W1 · 叶子模块(全部并行) + +#### T4 · `config.ts` `[ ]` +- **Wave/ARCH**: W1 / S2 §3.1 · **Owns**: `src/config.ts`、`test/config.test.ts` +- **Depends**: T2 · **Parallel-safe**: T5–T11 +- **Steps**: + - [ ] `loadConfig(env)`:读取 `PORT/SHELL_PATH/BIND_HOST/IDLE_TTL/SCROLLBACK_BYTES/MAX_PAYLOAD_BYTES/ALLOWED_ORIGINS`,缺失给默认值 + - [ ] **`allowedOrigins` 由 `os.networkInterfaces()` 各网卡 IPv4 + localhost + 可选主机名推导**(M1,**不**从 bindHost),`http(s)://<host>:<port>`,再并入 `ALLOWED_ORIGINS` + - [ ] 非法值(端口越界、TTL 非数)→ 抛错 fail-fast + - [ ] 返回对象 `Object.freeze`(不可变) +- **Steps(测试)**: + - [ ] 默认值;各 env 覆盖;非法值抛错;allowedOrigins **不含 `0.0.0.0`**、含注入的假网卡 IP(mock networkInterfaces) +- **Accept**: `vitest run config` 全绿 + +#### T5 · `protocol.ts` `[ ]` +- **Wave/ARCH**: W1 / S3 §3.2 · **Owns**: `src/protocol.ts`、`test/protocol.test.ts` +- **Depends**: T2 · **Parallel-safe**: T4,T6–T11 +- **Steps**: + - [ ] `export const SESSION_ID_RE`(UUID v4) + - [ ] `parseClientMessage(raw)`:JSON 守卫 → type 白名单 → `resize` cols/rows 1–1000 整数 → `input.data` 必为 string(原样) → `attach.sessionId` 为 `null` 或匹配 `SESSION_ID_RE`,否则 `{ok:false}` + - [ ] **永不抛异常**,所有错误走 `ParseResult.ok=false` + - [ ] `serialize(msg)` → JSON 文本 +- **Steps(测试)**: + - [ ] 三种合法 client 消息往返;每条非法分支(坏 JSON、未知 type、cols 越界/非整、data 非 string、sessionId=`abc123` 被拒、合法 UUID 通过);serialize 三种 server 消息 +- **Accept**: `vitest run protocol` 全绿;模糊输入不抛 + +#### T6 · `ring-buffer.ts` `[ ]` +- **Wave/ARCH**: W1 / S3 §3.4 · **Owns**: `src/session/ring-buffer.ts`、`test/ring-buffer.test.ts` +- **Depends**: T2 · **Parallel-safe**: T4,T5,T7–T11 +- **Steps**: + - [ ] `createRingBuffer(maxBytes)`:内部以 `Buffer`('utf8')按**真实字节**计量(M2) + - [ ] `append(chunk)`:超容量时**按 chunk 边界整体淘汰最旧块**(不切碎 UTF-8 码点 / ANSI 序列) + - [ ] `snapshot()`:开头补软复位 `\x1b[0m` 兜底,再拼当前缓冲 +- **Steps(测试)**: + - [ ] 容量内全量回放;超容量淘汰最旧;**多字节中文不被截断**;**ANSI 序列不被从中间切断**;字节计量正确(非 string.length) +- **Accept**: `vitest run ring-buffer` 全绿 + +#### T7 · `http/origin.ts` `[ ]` +- **Wave/ARCH**: W1 / S4 §3.3 · **Owns**: `src/http/origin.ts`、`test/origin.test.ts` +- **Depends**: T2 · **Parallel-safe**: T4–T6,T8–T11 +- **Steps**: + - [ ] `isOriginAllowed(origin, allowed)`:host+port 双匹配;白名单命中 → true + - [ ] `undefined` Origin 默认拒绝(集中决定,不散落 if) +- **Steps(测试)**: + - [ ] 白名单命中放行;域名/端口不符拒绝;`undefined` 拒绝;evil.com 拒绝 +- **Accept**: `vitest run origin` 全绿 + +#### T8 · `public/index.html` `[ ]` +- **Wave/ARCH**: W1 / S7 §5 · **Owns**: `public/index.html` +- **Depends**: 无(可在 T2 前都行) · **Parallel-safe**: 全部 +- **Steps**: [ ] 终端容器 `#term`、键栏挂载点 `#keybar`、引 main.ts/style.css;viewport meta(移动端) +- **Accept**: 浏览器打开无控制台报错(静态) + +#### T9 · `public/style.css` `[ ]` +- **Wave/ARCH**: W1 / S7 §5 · **Owns**: `public/style.css` +- **Depends**: 无 · **Parallel-safe**: 全部 +- **Steps**: [ ] 全屏终端布局;键栏固定底部、`>768px` 隐藏;深色背景 +- **Accept**: 桌面/窄屏两种宽度布局正确(静态目测) + +#### T10 · `public/keybar.ts` `[ ]` +- **Wave/ARCH**: W1 / S7 §6.3 · **Owns**: `public/keybar.ts`、`test/keybar.test.ts` +- **Depends**: T2(`mountKeybar` 契约) · **Parallel-safe**: 全部 +- **Steps**: + - [ ] 按键→字节表:Esc `\x1b`、Shift+Tab `\x1b[Z`、↑↓←→、Enter `\r`、Ctrl+C `\x03`、Tab `\t` + - [ ] `mountKeybar(onSend)`:`touchstart` 直接 `onSend(bytes)`(绕过 xterm),`preventDefault` 防软键盘 + - [ ] 字节表导出为纯数据,便于单测 +- **Steps(测试)**: [ ] 每个按钮映射到正确字节(对纯字节表单测,DOM 部分手动验) +- **Accept**: `vitest run keybar` 全绿 + +#### T11 · `public/main.ts` `[ ]` +- **Wave/ARCH**: W1 / S7 §5/§6 · **Owns**: `public/main.ts` +- **Depends**: T2 + T10(仅 `mountKeybar` 签名,可并行) · **Parallel-safe**: 后端全部 +- **Steps**: + - [ ] xterm + FitAddon 初始化;`fit()` 在容器有真实尺寸后调用(§9 坑) + - [ ] WS:**scheme 随页面协议** `https→wss`(M6),URL `…/term`,首帧发 `attach`(localStorage 取 sessionId) + - [ ] `term.onData → ws.send {input}`;`onmessage` 按 type 处理(attached 存 id、output→`term.write`、exit→提示重连) + - [ ] `ResizeObserver`/resize → `fit()` → 发 `resize`(防抖 100ms) + - [ ] 断线指数退避重连 1/2/4…≤30s,携带 sessionId;彩色状态行 + - [ ] `mountKeybar(data => ws.send(...))` +- **Accept**: W5 联调时验(本任务先完成编码 + `tsc -p tsconfig.web.json` 通过) + +--- + +### W2 · 会话核心 + +#### T12 · `session/session.ts` `[ ]` +- **Wave/ARCH**: W2 / S5 §3.4 · **Owns**: `src/session/session.ts`、`test/session.test.ts` +- **Depends**: T2、T6(createRingBuffer)、T3(mock IPty);spawn 用 node-pty · **Parallel-safe**: T13 可在本任务**接口**就绪后并行起草 +- **Steps**: + - [ ] `createSession(cfg, dims, now, onExit)`:spawn PTY、建 ring buffer、接 onData(append+刷新 `lastOutputAt`+转发 attachedWs)、接 onExit(置 `exitedAt`/`exitCode`→有 ws 则发 exit→调注入 onExit) + - [ ] spawn 失败**不吞**,向上抛(M4) + - [ ] `attachWs`:重置 attachedWs 指针→回放 `snapshot()`→续流;返回被踢旧 ws + - [ ] `detachWs`:置 detachedAt,**不杀 PTY** + - [ ] `writeInput`/`resize`:`exitedAt!=null` 直接 return(L4);resize 幂等(值未变跳过) + - [ ] `kill` +- **Steps(测试,mock IPty)**: + - [ ] onData 进 buffer 并转发;**detach 不杀 PTY**;attach 回放快照;后到踢前者(指针先换再 close);detach 后 emitExit 保留会话;对已退出 PTY 的 write/resize 被忽略;send 前查 readyState(用 mock ws) +- **Accept**: `vitest run session` 全绿 + +--- + +### W3 · 会话表 + +#### T13 · `session/manager.ts` `[ ]` +- **Wave/ARCH**: W3 / S5 §3.5 · **Owns**: `src/session/manager.ts`、`test/manager.test.ts` +- **Depends**: T12 · **Parallel-safe**: —(可与 W1 剩余/前端并行) +- **Steps**: + - [ ] `handleAttach`:null→新建;命中存活→attachWs;命中**已退出**→回放+补发 exit 后移除(L1);查不到→新建返回新 id;createSession 抛错向上抛(M4) + - [ ] `get(id)` + - [ ] `reapIdle(now)`:`now - max(detachedAt, lastOutputAt) > idleTtl` 才回收(M3),返回回收数 + - [ ] `shutdown()`:kill 所有会话 + - [ ] 注入给 createSession 的 onExit:从会话表删除(L2) +- **Steps(测试,mock IPty)**: + - [ ] 新建/命中/已退出三路径;reapIdle:有新输出**不**回收、超时无输出回收;shutdown 全 kill +- **Accept**: `vitest run manager` 全绿 + +--- + +### W4 · 集成(汇合点) + +#### T14 · `server.ts` 接线 `[ ]` +- **Wave/ARCH**: W4 / S6 §3.6 · **Owns**: `src/server.ts` +- **Depends**: T4、T5、T7、T13 · **Parallel-safe**: T15 可并行起草用例 +- **Steps**: + - [ ] Express 托管 `public/` + 编译产物 + - [ ] `WebSocketServer({ noServer:true })`(L3);HTTP `upgrade`:非 `/term`→destroy、Origin 不过→`401\r\n\r\n`+destroy、通过→`handleUpgrade`→emit connection + - [ ] `connection`:等首帧 attach→`try handleAttach` 回 attached;`catch`→send `exit(-1,reason)`+close(仅此连接,不 fail-fast)(M4) + - [ ] `message`:parse→ok:false 丢弃记日志,否则路由 writeInput/resize + - [ ] `close`→detach;`SIGINT/SIGTERM`→shutdown;ws.send 前查 OPEN(M5) + - [ ] `ws.maxPayload = cfg.maxPayloadBytes`(L5);路径常量入 config +- **Accept**: 本机浏览器能连通拿到 shell;坏 Origin 被 401(交 T15 自动化) + +#### T15 · 集成 / E2E 测试 `[ ]` +- **Wave/ARCH**: W4 / S6 §7 · **Owns**: `test/integration/*.test.ts` +- **Depends**: T14 · **Parallel-safe**: — +- **Steps**(起真实 WS): + - [ ] attach→attached→output 时序;reconnect 回放(F5/F6,断言 ANSI/中文不乱) + - [ ] spawn 失败→exit(-1)+仅关连接(M4);坏 Origin/非 `/term`→拒(F9/L3);超 maxPayload 帧被拒(L5) +- **Accept**: `vitest run integration` 全绿 + +--- + +### W5 · 验收与收尾(按 F# 并行) + +> 每个 F 验收独立,可分派不同 agent(用 `module-reviewer`);均对照 TECH_DOC §8。需要真机/真浏览器,以手动+脚本结合。 +> **G4 — 验收任务只报告、不改源码**:reviewer 发现问题 → 返回 findings(标 severity + **owning task**); +> 修复**派回该模块的 `module-builder`**(或 orchestrator 处理),避免验收 agent 跨 `Owns:` 改文件。 +> 验收 agent **Owns: 无源码**(只产出 findings 报告;orchestrator 据此回写 LOG)。 + +#### T16 · 验收 F1–F3:终端基础 / TUI / resize `[ ]` +- **Depends**: T14、T11 · **Owns**: 无(report-only) +- **Steps**: [ ] ls/补全/Ctrl+C/方向键历史(F1) [ ] vim/top/claude 颜色与重绘(F2) [ ] 拖窗口行列实时调整、TUI 不错位(F3) + +#### T17 · 验收 F4 + F9:局域网访问 + Origin `[ ]` +- **Depends**: T14 · **Steps**: [ ] 手机/他机 `http://<IP>:3000` 可用(F4) [ ] 跨源 WS 握手被 401、非 `/term` 被拒(F9) +- **注**: 重点确认 allowedOrigins 含本机网卡 IP(M1 回归) + +#### T18 · 验收 F5 + F6:会话保活 + 回放 `[ ]` +- **Depends**: T14 · **Steps**: [ ] 发长任务→刷新/换设备→仍在跑、内容不丢(F5) [ ] 离线期输出重连回放、ANSI/中文不乱码(F6/M2) + +#### T19 · 验收 F7:移动快捷键栏 `[ ]` +- **Depends**: T11、T10 · **Steps**: [ ] 真机按 Esc/Shift+Tab/方向/Enter/Ctrl+C 生效、不弹软键盘 + +#### T20 · 验收 F8:进程退出处理 `[ ]` +- **Depends**: T14 · **Steps**: [ ] shell 退出提示重连、回车开新会话 [ ] **detach 后退出**回来仍见末屏+exit(L1) + +#### T21 · 安全核对 + README + LOG 收尾 `[ ]` +- **Depends**: T14–T20 · **Owns**: `README.md` · **由 orchestrator 执行**(因涉及写 `docs/PROGRESS_LOG.md` 收尾,不派并行 subagent) +- **注**: 安全核对部分可派 `module-reviewer`(只读)产出报告,orchestrator 据此改 README/LOG +- **Steps**: + - [ ] 对照 TECH_DOC §7 风险表逐条核(Origin、明文嗅探提示、严禁公网) + - [ ] README:`npm install/start/test`、查 IP、Tailscale 部署、安全警告 + - [ ] LOG 标记 v0.1 完成;`npm run build` 产物可 `npm start` + +--- + +## 3. 建议的并行分派(参考) + +| 批次 | 可同时开工的任务 | 说明 | +|------|------------------|------| +| 第 1 批 | T1 | 串行,装齐依赖 | +| 第 2 批 | T2 | 串行,冻结契约 | +| 第 3 批 | **T3,T4,T5,T6,T7,T8,T9,T10,T11** | 9 个并行(后端纯函数 + 全部前端) | +| 第 4 批 | T12 | ring-buffer 就绪后 | +| 第 5 批 | T13 | session 就绪后 | +| 第 6 批 | T14 →(就绪后)T15 | 集成 | +| 第 7 批 | **T16,T17,T18,T19,T20** 并行,后 T21 | 验收 | + +> 第 3 批 9 个任务零交叉,但**单批并行控制在 ~3–5 个 agent**(官方甜区;>5 收益递减、烧 token)。 +> agent 不足时拆成两轮跑(如先 T4–T7 纯函数,再 T8–T11 前端),仍远快于线性。 + +--- + +## 4. 模型与隔离分配(多 agent 用) + +> 路由原则(官方):轻活 Haiku 省成本、攻坚 Opus、其余 Sonnet。`isolation: worktree` 仅用于 +> **真正并发编辑**的 builder(避免同一工作树上 `tsc`/`vitest`/写文件互相干扰);串行/只读任务不需要。 +> 用法:`Agent(subagent_type:"module-builder", model:<下表>, isolation:<下表>)`。 +> +> **worktree 前提(需 git)**:worktree 从某个 commit 派生,所以必须**先 `git init`,并把 W0(T1/T2/T3) +> 提交**,再派 worktree 隔离的 W1 builder —— 否则它们的隔离工作树里没有 `types.ts`,会编译失败。 +> 每完成一波、合并回主分支后再派下一波。(本仓库已 `git init`。) + +| 任务 | 类型 | 建议模型 | 隔离 | 理由 | +|------|------|----------|------|------| +| T1 脚手架 | builder | sonnet | — | node-pty 原生编译可能要排错;串行 | +| T2 types 契约 | builder | sonnet | — | 契约精度要紧;串行,全员依赖 | +| T3 mock IPty | builder | haiku | worktree | 小而独立 | +| T4 config | builder | sonnet | worktree | 网卡 IP 推导 + M1 需推理 | +| T5 protocol | builder | sonnet | worktree | 校验分支多,正确性敏感 | +| T6 ring-buffer | builder | **opus** | worktree | M2 字节/UTF-8/ANSI 边界是最易错的纯模块 | +| T7 origin | builder | sonnet | worktree | 安全相关,宁稳 | +| T8 index.html | builder | haiku | worktree | 静态骨架 | +| T9 style.css | builder | haiku | worktree | 纯样式 | +| T10 keybar | builder | haiku | worktree | 字节表 + 简单 DOM | +| T11 main.ts | builder | sonnet | worktree | xterm + 重连状态机,前端最重 | +| T12 session | builder | **opus** | — | 并发/生命周期最硬(detach/onExit/踢 ws/L1/L4) | +| T13 manager | builder | sonnet | — | reapIdle/已退出路径;串行接 T12 | +| T14 server 接线 | builder | sonnet | — | noServer/handleUpgrade/M5;汇合点,串行 | +| T15 集成/E2E | builder | sonnet | — | 真实 WS 时序断言 | +| T16–T20 验收 | **reviewer** | sonnet | — | 只读报告;修复回流给 owner builder | +| T21 收尾 | orchestrator | sonnet | — | 写 README/LOG,主会话执行 | + +> Sonnet 协调 + Haiku/Opus 分工的混编,比全程单模型更省也更准(官方:混编约 +12%)。 +> 不确定就用 sonnet;模型只是建议,可按实测调整。 diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md new file mode 100644 index 0000000..0cf6488 --- /dev/null +++ b/docs/PROGRESS_LOG.md @@ -0,0 +1,107 @@ +# Web Terminal 实施进度日志 (Progress Log) + +> **用途**: 记录 [PLAN.md](./PLAN.md) 各任务的完成情况,作为**跨会话记忆文件**。 +> 新会话开工前**必读本文件**。只记**已发生的事实**(含失败、跳过),不预填未来,不写愿望。 +> **谁来写(G1)**: 本文件**仅由 orchestrator(主会话)写入** —— 它不在任何任务的 `Owns:` 里, +> 防止并行 subagent 并发写冲突。被派的 subagent **不写本文件**,而是把日志条目作为返回结果交回, +> 由 orchestrator 在每批 agent 返回后**立即追加**(不要攒到最后)。 +> 完整维护规则见 [CLAUDE.md → Development Workflow](../CLAUDE.md)。 + +--- + +## 状态图例 + +| 标记 | 含义 | +|------|------| +| `[ ]` | TODO — 未开始 | +| `[~]` | IN PROGRESS — 进行中 | +| `[x]` | DONE — 完成且已验证 | +| `[!]` | BLOCKED — 受阻(须在详细日志写明阻塞原因) | + +--- + +## 当前焦点 (Current Focus) + +> 新会话读到的第一块。保持准确,只描述"此刻"。 + +- **Wave**: 未开工 → 下一步 W0 +- **任务**: T1(脚手架 + 装齐依赖) +- **下一步**: 执行 T1,装齐全部依赖、验证 node-pty 原生编译 +- **阻塞**: 无 +- **最后更新**: 2026-06-15 + +--- + +## 任务总览 (Task Overview) + +> 任务定义见 [PLAN.md](./PLAN.md);按 Wave 分组。`Steps` 完成数 = PLAN 中该任务的勾选项。 +> **多 agent 并行**:同一 Wave 内任务互不依赖,可并行;跨 Wave 按依赖推进。 + +| 任务 | Wave | 模块 | 状态 | Steps (完成/总) | 完成日期 | +|------|------|------|------|------------------|----------| +| T1 | W0 | 脚手架 + 装齐依赖 | `[ ]` | 0/5 | — | +| T2 | W0 | `src/types.ts` 共享契约(冻结源) | `[ ]` | 0/8 | — | +| T3 | W0 | mock IPty 测试替身 | `[ ]` | 0/2 | — | +| T4 | W1 | `config.ts` | `[ ]` | 0/5 | — | +| T5 | W1 | `protocol.ts` | `[ ]` | 0/4 | — | +| T6 | W1 | `ring-buffer.ts` | `[ ]` | 0/4 | — | +| T7 | W1 | `http/origin.ts` | `[ ]` | 0/3 | — | +| T8 | W1 | `public/index.html` | `[ ]` | 0/1 | — | +| T9 | W1 | `public/style.css` | `[ ]` | 0/1 | — | +| T10 | W1 | `public/keybar.ts` | `[ ]` | 0/4 | — | +| T11 | W1 | `public/main.ts` | `[ ]` | 0/6 | — | +| T12 | W2 | `session/session.ts` | `[ ]` | 0/7 | — | +| T13 | W3 | `session/manager.ts` | `[ ]` | 0/6 | — | +| T14 | W4 | `server.ts` 接线 | `[ ]` | 0/6 | — | +| T15 | W4 | 集成 / E2E 测试 | `[ ]` | 0/2 | — | +| T16 | W5 | 验收 F1–F3(终端/TUI/resize) | `[ ]` | 0/3 | — | +| T17 | W5 | 验收 F4+F9(LAN/Origin) | `[ ]` | 0/2 | — | +| T18 | W5 | 验收 F5+F6(保活/回放) | `[ ]` | 0/2 | — | +| T19 | W5 | 验收 F7(移动键栏) | `[ ]` | 0/1 | — | +| T20 | W5 | 验收 F8(退出处理) | `[ ]` | 0/2 | — | +| T21 | W5 | 安全核对 + README + LOG 收尾 | `[ ]` | 0/3 | — | + +--- + +## 验收清单映射 (Acceptance — TECH_DOC §8) + +> 功能验收独立于阶段勾选;F* 全绿才算 v0.1 完成。 + +| 编号 | 功能 | 状态 | 验证方式 / 备注 | +|------|------|------|-----------------| +| F1 | 交互式终端(ls/补全/Ctrl+C/历史) | `[ ]` | | +| F2 | 全彩 + 全屏程序(vim/top/claude) | `[ ]` | | +| F3 | 终端自适应(resize 不错位) | `[ ]` | | +| F4 | 局域网访问(手机/他机) | `[ ]` | 与 F9 联测,确认 Origin 白名单含网卡 IP | +| F5 | 会话保活(断开重连仍在跑) | `[ ]` | | +| F6 | 重连回放(环形缓冲) | `[ ]` | 验证 ANSI/中文不乱码(M2) | +| F7 | 移动快捷键栏 | `[ ]` | | +| F8 | 进程退出处理 | `[ ]` | 含 detach 后退出补发 exit(L1) | +| F9 | Origin 校验(401) | `[ ]` | 含 `/term` 路径限制(L3) | + +--- + +## 详细日志 (Detailed Log) + +> **倒序**,最新在最上。每个子任务一条。复制下方模板填写。 + +<!-- ========== 条目模板(复制此块,删除注释) ========== +### YYYY-MM-DD · S?.? <子任务标题> + +- **状态**: `[x]` DONE / `[~]` IN PROGRESS / `[!]` BLOCKED +- **改动**: <文件:函数/范围,如 src/protocol.ts:parseClientMessage、test/protocol.test.ts> +- **验证**: <跑了什么命令 + 结果。如 `npm test` → 12 passed;或贴关键断言> +- **决策 / 偏离 PLAN**: <无 / 为何偏离 + 是否已回写 PLAN.md> +- **遗留 / 待办**: <无 / 后续子任务需注意的点> +- **阻塞**(仅 `[!]` 时): <卡在哪,需要什么> +- **commit**: <hash 或 N/A> +======================================================= --> + +### 2026-06-15 · T0 项目记录起点(计划就绪) + +- **状态**: `[x]` DONE +- **改动**: 创建 `docs/PROGRESS_LOG.md`、`docs/PLAN.md`(细粒度 T1–T21、为多 agent 并行设计);`CLAUDE.md` 加入 plan/log 工作流约束。 +- **验证**: 文档自检;LOG 任务总览的 Steps 总数已与 PLAN 各任务勾选项对齐。 +- **决策 / 偏离 PLAN**: 采用 TypeScript(ARCHITECTURE §0);两份设计文档已多团队交叉验证并修订 12 项;计划按依赖分 W0–W5 波次,W1 含 9 个零交叉并行任务。 +- **遗留 / 待办**: 进入 **W0 / T1**(脚手架 + 装齐依赖);T1 完成后 T2 冻结 `src/types.ts`,随后 W1 可大规模并行。 +- **commit**: N/A diff --git a/docs/TECH_DOC.md b/docs/TECH_DOC.md new file mode 100644 index 0000000..bdcb551 --- /dev/null +++ b/docs/TECH_DOC.md @@ -0,0 +1,350 @@ +# Web Terminal 技术文档 + +> 版本: v0.2(草案) · 日期: 2026-06-12 +> 定位: 快速原型 / 学习项目 · 主用途: **vibe coding(在任何设备上驾驶 Claude Code)** +> 形态: 浏览器访问部署机的本地 Shell,同一局域网内其他设备也可访问 + +--- + +## 1. 项目概述 + +### 1.1 目标 + +在浏览器中提供一个功能完整的终端,连接到服务器(部署机)的本地 Shell。 +任何局域网内的设备(其他电脑、平板、手机)打开 `http://<主机IP>:3000` 即可获得一个交互式终端。 + +### 1.2 非目标(本期不做) + +- 用户注册 / 登录 / 多用户隔离 +- SSH 跳板到其他远程主机 +- 容器 / K8s 后端 +- 公网部署(无认证,**严禁暴露公网**) + +--- + +## 2. 总体架构 + +``` +┌──────────────── 浏览器(局域网任意设备) ────────────────┐ +│ ┌─────────────────────────────────────────────┐ │ +│ │ xterm.js ── 终端渲染、键盘输入、光标、ANSI 转义 │ │ +│ │ fit addon ── 自适应窗口大小 │ │ +│ └───────────────┬─────────────────────────────┘ │ +│ │ WebSocket(双向字节流 + 控制消息) │ +└──────────────────┼─────────────────────────────────────┘ + │ +┌──────────────────▼───────────── Node.js 服务端 ────────┐ +│ ┌────────────┐ ┌──────────────┐ ┌─────────────┐ │ +│ │ Express │ │ ws │ │ node-pty │ │ +│ │ 静态资源服务 │ │ WebSocket 网关 │──▶│ PTY 进程管理 │ │ +│ └────────────┘ └──────────────┘ └──────┬──────┘ │ +│ │ fork │ +│ ┌──────▼──────┐ │ +│ │ zsh / bash │ │ +│ │ (伪终端从端) │ │ +│ └─────────────┘ │ +└────────────────────────────────────────────────────────┘ +``` + +### 2.1 核心原理:PTY(伪终端) + +直接 `spawn('zsh')` 得到的是普通管道,shell 会认为自己**不在终端里**: +没有颜色、没有行编辑、vim/top 等全屏程序无法工作。 + +PTY(pseudo-terminal)是内核提供的一对设备(master/slave): +- **slave 端**接给 shell,shell 以为自己连着真终端(isatty() = true) +- **master 端**由 Node 进程持有,读写原始字节流(含 ANSI 转义序列) + +`node-pty` 封装了 `forkpty()` 系统调用。**xterm.js 负责解释这些转义序列并渲染**, +服务端不解析任何终端语义,只做字节搬运 —— 这是整个架构最重要的简化。 + +### 2.2 数据流 + +``` +按键 ──▶ xterm.js onData ──▶ WS 发送 ──▶ pty.write() ──▶ shell stdin +shell stdout/stderr ──▶ pty onData ──▶ WS 发送 ──▶ xterm.write() ──▶ 屏幕 +``` + +每个 WebSocket 连接对应一个独立的 PTY + shell 进程(1:1 会话模型)。 + +--- + +## 3. 技术选型 + +| 组件 | 选择 | 理由 | +|------|------|------| +| 终端前端 | **@xterm/xterm 5.x** | 事实标准(VS Code、Hyper、JupyterLab 同款),ANSI 支持最完整 | +| 自适应布局 | @xterm/addon-fit | 官方插件,窗口缩放时重算行列数 | +| 伪终端 | **node-pty** | 微软维护,VS Code 同款,跨平台(macOS/Linux/Windows ConPTY) | +| Web 框架 | Express 4 | 只用来托管静态页面,最熟悉、最简单 | +| WebSocket | **ws** | 最轻量的标准实现,无额外协议封装,利于学习原始机制 | +| 运行时 | Node.js ≥ 18 | node-pty 预编译二进制覆盖范围内 | + +**为什么不用 Socket.IO**:它自带重连、房间、降级轮询,但封装掉了 WebSocket 原始细节; +学习场景下用裸 `ws` 更能看清协议本质。后续需要自动重连时可以手写(也是学习点)。 + +**现成替代品(知晓即可)**:ttyd(C)、wetty(Node)、GoTTY(Go)。 +本项目目的是理解原理,所以自己实现;遇到设计问题可参考 wetty 源码。 + +--- + +## 4. WebSocket 消息协议 + +单 WebSocket 连接,JSON 文本帧。自定义极简协议: + +```jsonc +// 客户端 → 服务端 +{ "type": "attach", "sessionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479" } // 首条消息:附着已有会话;为 null 则新建 +{ "type": "input", "data": "ls -la\r" } // 键盘输入(原始字节) +{ "type": "resize", "cols": 120, "rows": 40 } // 终端尺寸变化 + +// 服务端 → 客户端 +{ "type": "attached", "sessionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479" } // 附着成功,随后先回放缓冲再接实时流 +{ "type": "output", "data": "..." } // PTY 输出(含 ANSI 序列) +{ "type": "exit", "code": 0, "reason": "..." } // shell 退出;reason 仅异常/spawn 失败时填,正常退出可省 +``` + +> sessionId 是 `crypto.randomUUID()`(UUID v4),服务端按该格式校验(见 ARCHITECTURE §3.2 `SESSION_ID_RE`)。 + +设计说明: +- **attach 是首条消息**:客户端把 sessionId 存在 localStorage,刷新/换设备时带上, + 服务端先回放该会话的环形缓冲,再接续实时流 —— 这就是"刷新页面 Claude 会话不丢"的机制。 +- **resize 必须是独立消息**:PTY 尺寸要通过 `ioctl(TIOCSWINSZ)` 设置, + shell 收到 SIGWINCH 后全屏程序(vim/top)才能正确重绘。 +- 原型期用 JSON 足够;若追求吞吐,可升级为二进制帧(首字节做 opcode)。 + +--- + +## 5. 服务端设计 + +### 5.1 模块划分 + +``` +web-terminal/ +├── docs/ +│ └── TECH_DOC.md # 本文档 +├── src/ +│ ├── server.js # 入口:Express + HTTP + WS 升级,~60 行 +│ ├── config.js # 端口、shell 路径、scrollback 等常量 +│ ├── session.js # 会话:1 WS ↔ 1 PTY 的生命周期管理 +│ └── protocol.js # 消息编解码 + 校验(parse/serialize) +├── public/ +│ ├── index.html # 页面骨架 +│ ├── main.js # xterm 初始化 + WS 客户端逻辑 +│ └── style.css # 全屏终端布局 +├── test/ +│ └── protocol.test.js # 协议编解码单元测试 +└── package.json +``` + +遵循小文件原则:每个文件单一职责,均 < 200 行。 + +### 5.2 会话生命周期(session.js)—— attach/detach 模型 + +会话与连接解耦:**PTY 的生命周期 ≠ WebSocket 的生命周期**(vibe coding 核心需求)。 + +``` +attach(sessionId=null) + └─▶ 新建:pty.spawn(SHELL, [], { cwd: HOME, cols, rows, env }) + ├─ pty.onData ──▶ 写入环形缓冲(2MB) ──▶ 若有附着的 ws 则转发 + ├─ ws 'message' ──▶ 校验 ──▶ pty.write() / pty.resize() + ├─ ws 'close' ──▶ detach(会话标记为"无人观看",PTY 继续运行!) + └─ pty.onExit ──▶ 通知附着的 ws ──▶ 从会话表移除 + +attach(sessionId="abc123") + └─▶ 查会话表 ──▶ 回放环形缓冲 ──▶ 接管实时流(旧 ws 若还在则踢掉) +``` + +关键约束: +- **WS 断开不杀 PTY** —— 这是与普通 web terminal 最大的区别; + 里面跑着的 Claude Code 任务必须继续执行。 +- **孤儿会话回收**:detach 超过 `IDLE_TTL`(默认 24h)**且自 detach 起无新输出**时回收。 + (原"PTY 内无前台子进程"判据落地不了——node-pty 不跨平台暴露前台进程组;改用"最近输出时间戳" + 近似"是否仍在跑任务",可测,见 ARCHITECTURE §3.5。) + 服务端进程退出时统一 `pty.kill()` 所有会话(v0.1 不做跨重启保活,v0.2 用 tmux 解决)。 +- 一个会话同一时刻只允许一个附着的 WS(后到者踢前者),避免双端输入交错。 +- 会话状态以不可变快照记录(创建时固定 id/启动时间),运行时句柄(pty/ws)单独持有。 + +### 5.3 输入校验(系统边界) + +WS 消息是外部输入,逐条校验: +- WS 帧大小设上限(`maxPayload`,默认 1MB,远超正常键盘输入),超限帧丢弃——防单条巨帧打爆内存 +- 必须是合法 JSON,`type` 在白名单内,否则丢弃并记日志 +- `resize` 的 cols/rows 必须是 1–1000 的整数(防御恶意/异常值打挂 ioctl);服务端再做幂等(值未变跳过), + **不假设前端已防抖** +- `input.data` 必须是 string(原样透传给 PTY,不做内容过滤——它就是键盘字节;服务端不解释字节,不引入注入) + +### 5.4 错误处理 + +- node-pty spawn 失败(shell 路径不存在等)→ `createSession` 同步抛错,由 `server.ts` 捕获 → + 向客户端发 `exit`(code:-1 + reason)+ 关闭**该连接**(注意:只关连接,**不**走 `uncaughtException` 杀进程) +- 转发 `ws.send` 前必须查 `readyState === OPEN`;send 失败 → **只 detach 该 ws,绝不 kill PTY** + (笼统说"清理会话"会误杀正在跑 Claude 的 PTY,违反保活) +- 对**已退出**的 PTY 调 `write`/`resize` 一律忽略(否则 node-pty 抛错) +- 输出侧背压:转发前看 `ws.bufferedAmount`,超阈值暂缓(终端输出可丢,环形缓冲仍留作回放) +- 服务端监听 `uncaughtException` 仅记录后退出(fail fast),仅用于真正未预期错误;上述已知路径不得走到这里 + +--- + +## 6. 前端设计 + +### 6.1 初始化流程(main.js) + +1. `new Terminal({ scrollback: 5000, fontFamily: 'Menlo, monospace', theme })` +2. 加载 FitAddon,`term.open(el)` → `fit()` +3. 建立 WS:`ws://${location.host}/term`(同源,无需配置 IP) +4. 绑定 `term.onData → ws.send(input)`、`ws.onmessage → term.write(output)` +5. `ResizeObserver` / window resize → `fit()` → 发送 resize 消息(防抖 100ms) + +### 6.2 连接状态反馈 + +- 连接中 / 已断开时在终端打印彩色状态行(直接写 ANSI 序列,顺便练习转义码) +- shell 退出后显示 "进程已退出,按回车重新连接" —— 回车触发重建 WS +- 断线后自动重连(指数退避:1s/2s/4s…上限 30s),携带 localStorage 中的 sessionId + +### 6.3 移动快捷键栏(Claude Code 必需) + +手机软键盘打不出 Claude Code 的高频按键,在终端下方固定一排触摸按钮: + +| 按钮 | 发送字节 | Claude Code 中的作用 | +|------|----------|---------------------| +| Esc | `\x1b` | 打断 Claude 当前回答 | +| Shift+Tab | `\x1b[Z` | 切换 auto-accept / plan 模式 | +| ↑ ↓ ← → | `\x1b[A` 等 | 菜单/历史选择 | +| Enter | `\r` | 确认 | +| Ctrl+C | `\x03` | 终止进程 | +| Tab | `\t` | 补全 | + +实现:按钮 `touchstart` 直接 `ws.send(input)`,不经过 xterm,避免软键盘弹出。 +桌面端(宽度 > 768px)默认隐藏该栏。 + +--- + +## 7. 安全设计(必读) + +> ⚠️ 本项目 = **把你电脑的完整 shell 权限交给任何能访问该端口的人**。 + +| 风险 | 对策 | +|------|------| +| 公网暴露 = 远程任意代码执行 | 只绑定局域网;**永远不要**端口映射/内网穿透到公网 | +| 局域网内他人访问(咖啡店/办公室 WiFi) | 默认绑定 `0.0.0.0` 但文档显著警告;只在可信家庭网络使用 | +| **明文嗅探 / 中间人(MITM)** | `ws://` 不加密,键盘输入(可能含密码、API key、claude token)与全部输出在局域网明文传输,同网段可被动监听、ARP 欺骗可劫持。Origin 校验对此**无任何防护**。缓解:**Tailscale(WireGuard 加密)** 或本地自签 TLS 走 `wss://`——在不可信网络下这不是可选优化,而是必需加固 | +| 跨站 WebSocket 劫持(CSWSH):恶意网页在你浏览器里偷连 ws://内网IP | **校验 Origin 头**,只允许来自本服务页面的连接(零成本,必须做) | +| 后续想加最简认证 | 预留:URL token(`?token=xxx` 比对环境变量),v0.2 可选 | + +Origin 校验是无认证方案里**唯一不能省**的防线,因为攻击者无需在你的局域网内, +只要你访问了恶意网页,网页里的 JS 就能尝试连接 `ws://192.168.x.x:3000`。 + +**Origin 白名单的正确来源(关键)**:`0.0.0.0` 是监听通配地址,浏览器永远不会以它为 Origin。 +白名单须由**本机各网卡 IPv4 + localhost + 可选主机名**推导出 `http(s)://<host>:<port>`,而非从 `bindHost` 推导, +否则 F4(局域网设备访问)会被 F9(Origin 校验)误判 401——二者打架。实现见 ARCHITECTURE §3.1/§3.3。 +前端 WS 的 scheme 随页面协议(`https→wss`),避免 Tailscale/TLS(HTTPS)部署下 `ws://` 被 mixed-content 拦截。 + +--- + +## 8. 功能清单 + +> 设计原则:本终端的主用途是 **vibe coding** —— 给 Claude Code 发任务后人离开, +> 之后从任意设备(手机/平板/另一台电脑)回来查看和确认。 +> 因此"会话不死"和"移动端可操作"是核心需求,不是锦上添花(见第 11 节调研)。 + +### v0.1 MVP(本期) + +| # | 功能 | 验收标准 | +|---|------|----------| +| F1 | 浏览器交互式终端 | `ls`、tab 补全、Ctrl+C、方向键历史均正常 | +| F2 | 全彩 + 全屏程序支持 | `vim`、`top`、`claude` TUI 颜色与重绘正确 | +| F3 | 终端自适应 | 拖动浏览器窗口,行列数实时调整,TUI 不错位 | +| F4 | 局域网访问 | 同网段手机/电脑通过 `http://<IP>:3000` 可用 | +| F5 | **会话保活** | 刷新页面/换设备重连后,Claude Code 会话仍在跑,内容不丢 | +| F6 | **重连回放** | 重连时回放离线期间的输出(环形缓冲,默认 2MB/会话) | +| F7 | **移动快捷键栏** | 手机上可按 Esc、Shift+Tab、↑↓←→、Enter、Ctrl+C | +| F8 | 进程退出处理 | shell 退出后提示重连,回车开新会话 | +| F9 | Origin 校验 | 来自其他源的 WS 握手被拒绝(401) | + +会话保活实现方式(F5):PTY 不再随 WS 断开而销毁。会话由服务端以 sessionId 管理, +WS 断开只是"分离"(detach),PTY 与其中运行的 Claude Code 继续存活; +新 WS 携带 sessionId 即可重新附着(attach)。服务端重启保活(tmux 方案)留给 v0.2。 + +### v0.2 候选(按对 vibe coding 的价值排序) + +- **Claude Code 状态感知**:通过 Claude hooks(Stop / PermissionRequest / PreToolUse) + 把"工作中 / 等待批准 / 空闲"推送到页面标题与会话列表(参考 claude-coterminal、control-center) +- **多会话仪表盘**:并行开多个 Claude agent,列表显示各会话状态,点击切换 +- **历史会话恢复**:扫描 `~/.claude/projects/*/` 的 JSONL,一键 `claude --resume <id>` + (参考 cc-remote-term 的 history-index 设计) +- **tmux 后端**:PTY 活在 tmux 里,服务重启也不丢会话(同类项目的事实标准方案) +- 浏览器通知:Claude 等待输入时推送提醒 +- 简单 token 认证、复制粘贴增强(@xterm/addon-web-links) + +--- + +## 9. 任务拆解 + +| 阶段 | 内容 | 预估 | +|------|------|------| +| T1 | 项目脚手架 + 依赖安装(node-pty 需本地编译,验证环境) | 0.5h | +| T2 | protocol.js + 单元测试(TDD:先写编解码与校验测试) | 0.5h | +| T3 | session.js + server.js(PTY ↔ WS 桥接,Origin 校验) | 1h | +| T4 | 前端 index.html + main.js(xterm + fit + 状态提示) | 1h | +| T5 | 联调验收 F1–F7(vim/top/resize/局域网手机实测) | 0.5h | + +### 已知坑位 + +- **node-pty 编译**:macOS 需要 Xcode Command Line Tools;Node 大版本升级后需 `npm rebuild` +- **`\r` vs `\n`**:终端回车发送的是 `\r`(0x0D),不是 `\n`,自己拼输入时容易踩 +- **fit 时机**:必须在容器有实际尺寸后调用,`display:none` 时 fit 会得到 NaN +- **中文/IME 输入**:xterm.js 已处理 composition event,但要避免自己重复监听 keydown + +--- + +## 10. 运行方式(规划) + +```bash +npm install # node-pty 触发原生编译 +npm start # 监听 0.0.0.0:3000 +npm test # 协议层单元测试 + +# 局域网访问: +# 1. 查本机 IP:ipconfig getifaddr en0 +# 2. 其他设备打开 http://<IP>:3000 +``` + +配置通过环境变量覆盖(无硬编码):`PORT`、`SHELL_PATH`、`BIND_HOST`、`IDLE_TTL`。 + +--- + +## 11. 同类项目调研(2026-06) + +调研目的:本项目主用途是 vibe coding,需要对 Claude Code 友好。以下是同赛道 +开源项目的共性设计,v0.1/v0.2 功能取舍即来源于此。 + +### 11.1 重点参考项目 + +| 项目 | 技术栈 | 值得借鉴的设计 | +|------|--------|----------------| +| [cc-remote-term](https://github.com/AliceLJY/cc-remote-term) | Next.js + ws + node-pty + tmux | tmux 保活;5MB 输出环形缓冲重连回放;扫描 `~/.claude/projects/` 的历史会话浏览器 + 一键 `--resume`;iOS 触摸键栏 | +| [open-claude-remote](https://github.com/StephenTowne/open-claude-remote) | Node + xterm.js | 移动端细节:QR 码连接、长按复制、点击聚焦、50K 行回滚、多网卡 IP 探测 | +| [claude-coterminal](https://github.com/adamorad/claude-coterminal) | FastAPI + tmux | 用 Claude hooks(Stop/PreToolUse)检测会话状态(工作中/等输入/完成),手机一眼看到哪个会话在等人 | +| [control-center](https://github.com/charan1319/control-center) | Node + SQLite + tmux | hooks POST 到服务端 → WS 推送状态;多会话仪表盘;Telegram 通知 | +| [claude-command-center](https://github.com/afstkla/claude-command-center) | Node + Express + tmux | 不进终端即可批准/拒绝工具调用的快捷按钮;ntfy 手机推送;WS + SSE 双传输 | +| [claudecodeui / CloudCLI](https://github.com/siteboon/claudecodeui) | 全家桶 | 直接读写 `~/.claude` 配置;聊天界面 + 文件浏览器 + git 面板(超出本项目范围,仅作参照) | + +### 11.2 提炼的共性模式("Claude Code 友好"的定义) + +1. **会话与连接解耦**(所有项目):PTY 活在服务端(多数用 tmux),浏览器只是附着的视图。 + vibe coding 的工作流是"发任务 → 离开 → 回来看",连接断了任务不能死。 +2. **重连回放缓冲**:环形缓冲保留输出,重连先回放再续流。 +3. **移动触摸键栏**:Esc / Shift+Tab / 方向键是 Claude Code 高频键,软键盘打不出。 +4. **状态感知靠 hooks 而非解析终端输出**:解析 TUI 输出脆弱(Claude 改版即坏), + 正确做法是利用 Claude Code 官方 hooks 机制写状态文件 / POST 事件。 +5. **历史会话 = 读 `~/.claude/projects/*/` JSONL**,恢复 = `claude --resume <id>`, + 不需要自己存任何对话数据。 +6. **安全上偏好 Tailscale 而非裸局域网**:多数项目文档推荐 Tailscale, + 免认证又加密;本项目 v0.1 仍按局域网 + Origin 校验,Tailscale 作为部署建议。 + +### 11.3 与现成方案的关系 + +若只为使用,cc-remote-term / CloudCLI 已可直接安装。本项目坚持自研的理由是学习 +PTY/WS/终端协议本身;设计决策时优先参考 cc-remote-term(目标形态最接近, +代码结构清晰),移动端体验参考 open-claude-remote。