- 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
20 KiB
Web Terminal 实施计划 (Phased Plan)
版本: v0.1 · 定位: 把 ARCHITECTURE.md 的契约拆成细粒度、低耦合的任务, 供多 agent 并行开发。完成情况记录在 PROGRESS_LOG.md。 工作流约束见 CLAUDE.md(查 PLAN → 做子任务(TDD) → 验证 → 更新 LOG)。
0. 多 Agent 并行规则(开工前必读)
并行的三条铁律,违反会导致 agent 互相踩踏:
- 文件所有权独占:每个任务标注
Owns:,只有该任务可创建/修改这些文件。src/types.ts(共享契约)由 T2 独占并冻结,其余任务只读 import;若需新增类型, 先回到 T2 改契约(协调点),不得各自在自己文件里另立类型。 - 只依赖接口,不依赖实现:任务间通过
src/types.ts的 interface / 函数签名解耦。Depends:仅表示"需要对方的接口或产物",多数情况接口在 W0 就已就绪,故可与"实现方"并行编码, 仅在集成/测试时才真正汇合。 - LOG 由 orchestrator 独写,subagent 不碰(G1):
docs/PROGRESS_LOG.md是共享文件, 不在任何任务的Owns:里。被派的 subagent 不要写 LOG,而是把日志条目作为最终返回结果交回; 由主会话(orchestrator)在每批 agent 返回后统一追加。详见 CLAUDE.md。
每个任务自带 TDD(测试与实现同属一个 agent、同一组文件),所以不要把"写测试"和"写实现"
拆给不同 agent —— 那会制造同文件冲突。任务内的 Steps: 清单就是细粒度拆分。
执行用的项目 agent(见 .claude/agents/):module-builder(TDD 实现单个任务)、
module-reviewer(只读复查/验收)。orchestrator = 主会话,按 §3 波次派活、按 §4 选模型/隔离。
subagent 不能问用户、不能互相通信:遇到文档未定义的歧义 → 停下返回 [!] BLOCKED,绝不猜。
参考文档
必读文档清单、ARCH: 字段用法、M*/L* 代号含义 —— 见 CLAUDE.md → Development Workflow。
该规则对所有任务(尤其上下文隔离的子 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:做溯源。
前端构建约定(T1 已定,T8/T11/T14 遵守):打包器 = esbuild。
- 入口
public/main.ts;npm run build:web→ 打包到public/build/main.js(若 main.tsimport '@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)RingBufferinterface(§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)
- 默认值;各 env 覆盖;非法值抛错;allowedOrigins 不含
- 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 白名单 →resizecols/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 消息
- 三种合法 client 消息往返;每条非法分支(坏 JSON、未知 type、cols 越界/非整、data 非 string、sessionId=
- 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 双匹配;白名单命中 → trueundefinedOrigin 默认拒绝(集中决定,不散落 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防软键盘- 字节表导出为纯数据,便于单测
- 按键→字节表:Esc
- 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(...))
- (本文件是 esbuild 打包入口,见§1 约定)xterm + FitAddon 初始化 +
- 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()→续流;返回被踢旧 wsdetachWs:置 detachedAt,不杀 PTYwriteInput/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);HTTPupgrade:非/term→destroy、Origin 不过→401\r\n\r\n+destroy、通过→handleUpgrade→emit connectionconnection:等首帧 attach→try handleAttach回 attached;catch→sendexit(-1,reason)+close(仅此连接,不 fail-fast)(M4)message:parse→ok:false 丢弃记日志,否则路由 writeInput/resizeclose→detach;SIGINT/SIGTERM→shutdown;ws.send 前查 OPEN(M5)ws.maxPayload = cfg.maxPayloadBytes(L5);路径常量入 config
- Express 托管
- 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. 建议的并行分派(参考)
每个任务填好的 dispatch prompt(可直接复制调用 subagent)见 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;模型只是建议,可按实测调整。