# 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)://:`,再并入 `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://: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. 建议的并行分派(参考) > 每个任务**填好的 dispatch prompt**(可直接复制调用 subagent)见 [DISPATCH.md](./DISPATCH.md)。 | 批次 | 可同时开工的任务 | 说明 | |------|------------------|------| | 第 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;模型只是建议,可按实测调整。