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

20 KiB
Raw Blame History

Web Terminal 实施计划 (Phased Plan)

版本: v0.1 · 定位: 把 ARCHITECTURE.md 的契约拆成细粒度、低耦合的任务, 供多 agent 并行开发。完成情况记录在 PROGRESS_LOG.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

每个任务自带 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# 并行)  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.jsontsconfig.jsontsconfig.web.json.gitignorevitest.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:watchbuild: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(含 maxPayloadBytesallowedOrigins;§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.tstest/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,缺失给默认值
    • allowedOriginsos.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.tstest/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.sessionIdnull 或匹配 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.tstest/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.tstest/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.tstest/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.tstest/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.tstest/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

批次 可同时开工的任务 说明
第 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;模型只是建议,可按实测调整。