Files
web-terminal/docs/PLAN.md
Yaojia Wang 409b208928 build: adopt esbuild for frontend bundling (build:web → public/build/)
- esbuild dev dep; build:web/dev:web bundle public/main.ts → public/build/main.js
- gitignore public/build/; tsconfig.web.json now typecheck-only
- frontend build convention documented in PLAN §1, DISPATCH, ARCHITECTURE §5
- resolves the T1 deferred decision
2026-06-16 07:36:01 +02:00

346 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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# 并行) T16T20 F1F9 实测 · T21 安全+README+LOG 收尾
```
**关键并行事实**:前端 T8T11 不依赖任何后端模块(只依赖 §4 线协议形状,已在 types.ts),
可与 W1W4 整条后端链**全程并行**,直到 W5 才与运行中的 server 汇合实测。
> ARCH §6 的 S1S8 是线性视角;本计划是其**并行重排**。每个任务标了 `ARCH:` 做溯源。
**前端构建约定(T1 已定,T8/T11/T14 遵守)**:打包器 = **esbuild**
- 入口 `public/main.ts`;`npm run build:web` → 打包到 **`public/build/main.js`**(若 main.ts `import '@xterm/xterm/css/xterm.css'`,
esbuild 同时出 `public/build/main.css`)。开发用 `npm run dev:web`(--watch)。`public/build/` 已 gitignore。
- `index.html``<script type="module" src="./build/main.js">` 加载,并 `<link rel="stylesheet" href="./build/main.css">`(若有)+ 自己的 `style.css`
- `server.ts` 静态托管整个 `public/`(含 `public/build/`)。`tsconfig.web.json` 仅类型检查(noEmit)。
---
## 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**: T5T11
- **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,T6T11
- **Steps**:
- [ ] `export const SESSION_ID_RE`(UUID v4)
- [ ] `parseClientMessage(raw)`:JSON 守卫 → type 白名单 → `resize` cols/rows 11000 整数 → `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,T7T11
- **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**: T4T6,T8T11
- **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`;`<script type="module" src="./build/main.js">` + `style.css`(及 `./build/main.css` 若有);viewport meta(移动端)。**按§1 前端构建约定**
- **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**:
- [ ] (本文件是 esbuild 打包入口,见§1 约定)xterm + FitAddon 初始化 + `import '@xterm/xterm/css/xterm.css'`;`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/`(含 esbuild 产物 `public/build/`,见§1 约定);提示:start 前需 `npm run build:web`(或 `dev:web` 监听)
- [ ] `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 · 验收 F1F3:终端基础 / 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**: T14T20 · **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 个任务零交叉,但**单批并行控制在 ~35 个 agent**(官方甜区;>5 收益递减、烧 token)。
> agent 不足时拆成两轮跑(如先 T4T7 纯函数,再 T8T11 前端),仍远快于线性。
---
## 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 时序断言 |
| T16T20 验收 | **reviewer** | sonnet | — | 只读报告;修复回流给 owner builder |
| T21 收尾 | orchestrator | sonnet | — | 写 README/LOG,主会话执行 |
> Sonnet 协调 + Haiku/Opus 分工的混编,比全程单模型更省也更准(官方:混编约 +12%)。
> 不确定就用 sonnet;模型只是建议,可按实测调整。