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
This commit is contained in:
Yaojia Wang
2026-06-16 06:54:57 +02:00
commit dda09ef2c6
7 changed files with 1407 additions and 0 deletions

422
docs/ARCHITECTURE.md Normal file
View File

@@ -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` 11000 的整数(`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 | S2S3 |
| S6 | `server.ts` 接线 | 手动:浏览器连通 | S2S5 |
| S7 | `public/`(main + keybar) | 手动:vim/top/resize/手机 | S6 |
| S8 | 联调验收 F1F9 | 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)。
```

337
docs/PLAN.md Normal file
View File

@@ -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# 并行) T16T20 F1F9 实测 · T21 安全+README+LOG 收尾
```
**关键并行事实**:前端 T8T11 不依赖任何后端模块(只依赖 §4 线协议形状,已在 types.ts),
可与 W1W4 整条后端链**全程并行**,直到 W5 才与运行中的 server 汇合实测。
> ARCH §6 的 S1S8 是线性视角;本计划是其**并行重排**。每个任务标了 `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**: 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`、引 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 · 验收 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. 建议的并行分派(参考)
| 批次 | 可同时开工的任务 | 说明 |
|------|------------------|------|
| 第 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;模型只是建议,可按实测调整。

107
docs/PROGRESS_LOG.md Normal file
View File

@@ -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 | 验收 F1F3(终端/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`(细粒度 T1T21、为多 agent 并行设计);`CLAUDE.md` 加入 plan/log 工作流约束。
- **验证**: 文档自检;LOG 任务总览的 Steps 总数已与 PLAN 各任务勾选项对齐。
- **决策 / 偏离 PLAN**: 采用 TypeScript(ARCHITECTURE §0);两份设计文档已多团队交叉验证并修订 12 项;计划按依赖分 W0W5 波次,W1 含 9 个零交叉并行任务。
- **遗留 / 待办**: 进入 **W0 / T1**(脚手架 + 装齐依赖);T1 完成后 T2 冻结 `src/types.ts`,随后 W1 可大规模并行。
- **commit**: N/A

350
docs/TECH_DOC.md Normal file
View File

@@ -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 必须是 11000 的整数(防御恶意/异常值打挂 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 | 联调验收 F1F7(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。