Files
web-terminal/docs/PROGRESS_LOG.md

950 lines
201 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 实施进度日志 (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)
> 新会话读到的第一块。保持准确,只描述"此刻"。
### 🗂 [x] A · 让服务器"看不见"的 tmux session 重新可达(2026-07-30,worktree `orphan-tmux-sessions`)
- **起因**: 用户问"关掉桌面 App 会不会把 tmux session 一起关掉"。查证过程中发现一个**功能缺口**:`tmux ls`**69 个** `web_*` session,只有 **5 个**有 client —— 另外 64 个**从 UI 完全不可达**,其中一个还跑着 7 个 agent 的 Claude Code。最老的到 6 月 26 日。
- **根因**: `src/session/tmux.ts` 只有 `hasSession`,**没有 list**。`manager.list()` 只遍历内存表,`reapIdle` 也只遍历内存表。所以一个"活过了创建它的进程"的 tmux session:列不出、看不到缩略图、join 不了、UI 里杀不掉,连 `IDLE_TTL` 都碰不到它 → **永久堆积**。唯一恢复路径是 `manager.ts` 的 Case 3.5(客户端带着 localStorage 里的 id 来 attach),**必须先知道 id**。
- **选型 A vs B(与用户讨论后定 A)**: **A = 目录式**,只读枚举 + `capture-pane` 预览 + 杀,**不收养**。**B = 收养式**,启动时把每个都 `createSession` 进表。选 A 的决定性理由:B 要为 69 个 session 各起一个 tmux client + 2MB RingBuffer(≈138MB),而且 **attach 会按客户端尺寸重设窗口** → 对 64 个跑着 TUI 的 session 同时 SIGWINCH,会把那个 7-agent 的会话强行重排版。**B 其实是 A 的自然结果**:在 A 的列表里点开某个孤儿,走现成的 Case 3.5 就把它变成表里的一等公民 —— 按需付费,而不是启动时一次性付清。
- **实现**: `tmux.ts``listSessions()` + `parseSessionList()`(纯函数,过滤规则可单测)+ `capturePane()`(`capture-pane -p -e`,**不 attach、不 resize**、保留颜色);`manager.ts``listOrphans/captureOrphan/killOrphan/killOrphansIdleSince/countOrphansIdleSince`;`server.ts``GET /orphan-sessions``GET /orphan-sessions/count-idle``GET /orphan-sessions/:id/preview``DELETE /orphan-sessions/:id``DELETE /orphan-sessions?idleDays=N`;前端在首页 grid 下面加一个 **RECOVERABLE** 区,通过 `orphanAsLive()` 适配器**复用现成的卡片/缩略图机制**(不另写一套)。
- **安全护栏(全部在 manager 里,路由无法漏掉)**: ① tmux 名必须是 `web_` + **UUID v4**(复用 `SESSION_ID_RE`,与 attach 协议同一道闸)—— 这既挡住恶意/畸形名字打到 `tmux -t`(字面量 `-t`、路径穿越),也保证**用户自己建的 tmux session 永不被枚举、预览或提供删除**;② 内存表已有的 id 一律拒绝 —— 用 tmux 杀掉会在活着的 PTY 背后终结 shell 并留下僵尸表项,那条路必须走 `killById`;③ `useTmux` 关闭时全部惰性,非 tmux 部署逐字节不变;④ 批量清理**跳过 attached**(`tmux attach` 和第二个 server 进程都算 attached,"本 server 不管它"不等于"没人用")且 `idleDays >= 1`,**没有"删除全部"的形态**;⑤ 读路由无 Origin 守卫(与 `/live-sessions` 同威胁模型),两个 DELETE 都有;⑥ 预览有独立限流桶。
- **对抗式审查发现 10 个缺陷,其中一个是 CRITICAL —— 必须记住的坑**:
- **`#{session_activity}` 是 CLIENT 时钟,不是输出时钟**。它只在 attach 和按键时前进,detached 的 session 无论 shell 打印多少都**永不前进**。tmux 3.6a 实测:detached session 跑 `while true; do echo tick; sleep 1; done`,6 秒后 `session_activity` 仍等于 `session_created`,而 **`#{window_activity}` 精确跟上了输出**。于是"闲置 7 天"实际含义是"7 天没人打字",`Clean up idle 7d+` 会杀掉**正在干活的长跑无人值守会话** —— 正是本 App 存在的理由(vibe coding:丢个任务走开)。**在本机 69 个 session 上实测:旧时钟判死 36 个,新时钟判死 33 个 —— 3 个正在产出输出的会话离被删只差一次点击。**已改用 `window_activity`,并加了一条会在格式串回退时失败的测试。
- **HIGH 无 timeout**:`maxBuffer` 只管洪水不管**等待**,而同步 exec 无法被事件循环打断 → tmux server 被 SIGSTOP 就永久挂死整个 server。5 处调用(含本功能之前就有的 3 处)全部加上。
- **HIGH 同步阻塞轮询路径**:`listSessions` 实测 **73ms**`capturePane` **58ms**,而每台设备首页 5 秒刷一次 —— 每次都是 73ms 事件循环停转,期间**任何设备的任何终端都收不到一个字节**。改为 async(manager 三个函数连带改 async)。服务器首先是 byte-shuttle,轮询路径不许阻塞。
- **HIGH 清理确认框数错了**:数的是**卡片**(封顶 24),而清理匹配的是服务端枚举的全部 —— 本机会显示"24"然后终结 33 个 shell。新增 `count-idle` 路由回答真实数字,拿不到就拒绝执行。
- **MEDIUM ×4**:失败与空被折叠成同一个 `[]`(一次 429 就读成"孤儿全没了",拆掉所有已挂载的 xterm);预览闭锁条件是"卡片建过"而非"预览成功"(丢一次请求就永久空白);refresh 从 3 处进入且中间有 await 却无守卫(慢轮询会复活已杀的卡片、或往已清空的 root 里重挂)—— 加 generation 计数器,每个 await 后校验;`renderPreview` 可能写入已 dispose 的 xterm(xterm 抛 `Object has been disposed`)—— 给 `PreviewCard` 加显式 `disposed` 标记 + `disposeCard()` 统一入口。**刻意不用 `el.isConnected`**:尚未 append 的卡片是活的,混淆二者会丢掉首次渲染 —— 这一点是被两个既有的 preview-grid 测试正确抓出来的。
- **LOW**:kill/cleanup 丢弃返回值,拒绝(代理下 Origin 不符的 403、竞态的 404)看起来像按钮坏了 —— 改为回显。
- **性能/规模决策**: 预览**每张卡只取一次**(不随 5s 刷新重取),网格**封顶 24 张**(每张缩略图是一个 xterm 实例),超出部分**在 UI 里明说**并给出 `tmux attach -t web_<id>` 兜底 —— 不静默截断。
- **验证**: `tsc` 双 project 干净;单测 **2209 全过**(+42);e2e **27/27**;新增孤儿集成测试 **8/8** 跑真 tmux(建一个一次性 session → 列出 → capture 并断言它**仍是 attached=0** → 拒非 UUID id → 拒外来 Origin → 杀掉 → 第二次 404)。**该集成文件刻意从不发起批量 DELETE** —— 它跑在宿主机真实 tmux server 上,会终结开发者自己的会话。`window_activity` 的验证在**独立 socket(`tmux -L`)**上做,全程未触及用户的 69 个 session,事后确认仍是 69。
- **遗留 / 待办**: B(启动收养 + 让 `reapIdle` 管到孤儿)未做,需要配"按需 attach"(`Session.pty` 可空)才划算 —— 那要动 Session 形状和不变量 #2。另:桌面 App 默认 `USE_TMUX=0`(`desktop/src/server-config.ts:41`),所以**在 App 里起的 session 不受 tmux 保活**,退出 App 就会被杀;这与"走开再回来"的初衷冲突,已向用户指出,是否改默认值待定。
- **第二轮对抗式审查又抓到一个安全洞(必须记住)**: **`tmux -t <name>` 的解析顺序是 精确名 → 名字前缀 → fnmatch**。这直接绕过本模块的核心承诺("只碰 `web_` + UUID v4,用户自己建的 tmux session 永不被枚举/删除"):一个叫 `web_<uuid>_mine` 的 session 被 `parseSessionList` 拒绝(后缀不是 UUID)因此**从不出现在列表里**,但 `-t web_<uuid>` 会**前缀匹配到它** —— 于是打向一个不存在 id 的 DELETE 会杀掉用户自己的会话,预览会打印它的屏幕。tmux 3.6a 独立 socket 实测:`has-session -t web_<uuid>` 命中 `web_<uuid>_MINE`,加 `=``can't find session`;`capture-pane -p -t web_<uuid>` **打印出了 DECOY 的内容**,`-t =web_<uuid>:` 则失败。**要两种 target 形态**:`=` 只限定 target 的 session 部分,所以 session target(`has-session`/`kill-session`)用 `=name`,而 `capture-pane` 收的是 **pane** target,裸 `=name` 会被 tmux 直接拒("can't find pane"),必须写 `=name:`。已加集成测试:真的立一个 decoy,断言列表不含它、预览和 DELETE 都 404、且它**依然活着**。
- 另外三条:① 改为 **`max(window_activity, session_activity)`** —— 两个时钟**没有偏序**,`window_activity` 跟输出,而"attach 但没输出"只推 `session_activity`;本机实测有一个 session 的 `session_activity``window_activity` **新 4 秒**(另一个反过来新 25 天)。问题是"这儿有没有发生过任何事",答案就是两者取晚。② `parseSessionList` 曾接受**空数字字段**:`Number('')` 是 0 且 0 是有限值,于是空时钟被读成"纪元创建、之后一直闲置" → 立刻符合清理条件;改为必须是纯数字。③ 批量清理**杀之前重读一遍世界**:候选列表是快照而循环要花时间,期间 session 可能被 attach 或被收养进表 —— 这是不可逆路径,不信快照。④ `captureOrphan` 去掉了多余的 `hasSession` 预探测:那是每张缩略图**第二次** tmux spawn、而且是**同步**的、还在整格刷新的路径上,而 `capture-pane` 本来就会在 session 不存在时干净失败。护栏拆成纯函数半边(`mayActOnOrphan`:UUID 形状 + 不在表里,两条路共用)和存在性探测(只有毁灭性路径需要)。
- **验证(第二轮)**: 单测 **2213 全过**,孤儿集成 **9/9**(含 decoy 回归)。确认 `has-session -t =<name>` 仍能解析本机 69 个真实 session,**Case 3.5 重连路径未受影响**。
- **已知抖动(非本次引入)**: `test/integration/server.test.ts` 的 "H1 shell state survives a server restart" 在 27 个用例并发跑且机器满载时约 1/3 概率失败,**单独跑 4/4 通过** —— 就是日志里早已记录的真实 PTY 时序抖动。
- **commit**: `4892fa7`(功能)+ `f6ef19e`(第一轮审查修复)+ `d39a0ab`(第二轮:精确 target + 双时钟),合并 `22a7929`
### 🎨 [x] w6 收尾 · 设计稿逐条对照,补掉 Git 面板的落地偏差(2026-07-29/30,worktree `panel-design-fidelity` + `panel-fidelity-leftovers`)
- **起因**: 用户截图说"按钮和文字框还是很奇怪"。**先查客户端再查代码,结论是两件事都成立**:
1. **截图那个客户端根本没拿到改动**。它是 `/Applications/Web Terminal.app`,前端**打包进 bundle**(`extraResources`),那份资源冻结在 **17:59**,早于全部样式提交;内嵌 `style.css``.proj-wt-branch-input` / `.proj-diff-toggle` **一条规则都没有**,所以退回系统默认白框。提交列表却是新的 —— 数据走 HTTP API 实时,CSS 是静态文件,于是出现"数据新、样式旧"。浏览器 `:3000` 一直是对的。**教训:前端改完必须重打包桌面 App**(签名 + sealed resources,直接替换包内文件会破签名,只能 `npm run dist:mac`)。
2. **确实还有真偏差**。跑了一次设计稿对照审计(6 个维度 finder + 对抗性 refute 复核,18 个 agent),得 **59 处**;逐条在真实浏览器的 live DOM 上验证过再改。
- **改动 · 控件"有类名没规则",渲染成原生控件**(这就是用户说的"奇怪"): `.proj-wt-form` **完全没有布局规则** → 输入框和 Create Worktree 贴在一起、一出校验错误按钮就被挤到下一行;`.proj-wt-error` / `.proj-wt-actions-error` 没规则 → 校验失败以 **16px 近白色正文**渲染,和内容分不开;worktree 的 ✕ 与 Prune 同样零规则;`.proj-fanout-mode` 没有 `appearance:none` → WebKit 保留原生浅色下拉(全panel 唯一一个);**`--danger` 从未定义过**,所有校验文案都在 fallback 到不属于调色板的红。
- **改动 · 层叠 bug(后面的同权重规则静默覆盖前面的)**: `.proj-wt-row-current` 的琥珀 wash + 边框**完全失效** —— "你在这里"和其它行长得一模一样(`.proj-wt-row``background`/`border` **简写**在文件更靠后,等权重后写胜);`.proj-wt-branch` 没有 `font-family`/`font-size`,继承 **16px UA 默认无衬线**,是整页最大的字。
- **改动 · 布局**: `.proj-syncband` **去掉 `flex-wrap`**(设计稿 `.sync` 本来就不换行)—— 换行会在 **7211000px** 之间排出参差空行,900px 时 Fetch 独占第二行;改为只让"长句那一格"(`proj-sync-cell-flex`)让出宽度,于是一路到 720px 断点前都是单行。提交行从 flex 改**4 列 grid**,且 ↑ 标记**每行都渲染**(空占位)—— 原来只有未推送行有标记,导致**没有两行的列位置相同**,未推送那组读不出是一组。已推送的 subject 降到 `--text-dim`,分界线才有意义。
- **改动 · 面板的"诚实性"(设计稿两条硬规矩)**: ① `?? 0``undefined` 折成 0,导致**计数未知时也能显示绿色 `✓ in sync`** —— 设计稿唯一明令禁止的事;改为未知渲染 `↑ —` / `↓ —` 并标 `unverified`,绿色要求真零 + 新鲜 fetch。② 陈旧的 `behind` 会把**数字本身**染成警告红;而"没 fetch 过"是默认状态,等于整条面板长期在喊"这仓库坏了"。疑虑改为只挂在 `unverified` 芯片和脚注上,**永不碰数字**(落后是信息,不是错误)。
- **改动 · 收尾四条(第二个 worktree)**: detached HEAD 补短 sha(`SyncState.head`,从 `readSyncState` **已经读过**的 `.git/HEAD` 文本里取,零额外 I/O、零额外 git 调用);ghost 按钮**统一为一套几何**(此前漂成三种尺寸,且都用满强度 `--text`),四个 outline 按钮集中声明一处并**删掉靠后的重复块** —— 等权重的第二个块正是当初漂移的成因;`.proj-branch` 补 border + 等宽(它是全 panel 唯一没边框的芯片);View Diff 上方补 `Changes` 分区标题(此前贴在同步条下沿,读起来像同步条的一部分)。
- **改动 · 语义 token**: 新增 `--sunk` / `--warn` / `--ok` / `--dirty` / `--mono-font`,替掉散落的 hex 字面量;全 panel 统一一套芯片语言;分区标题改成设计稿的"低字重 + 宽字距大写",计数拆成独立的浅色元素。
- **新增 `.gitattributes`** —— `public/projects.ts diff`。该文件**故意含真实 NUL 字节**(命名空间哨兵 `'\0active'` / `'\0other'`,为的是绝不可能和真实 `First.Second` 键碰撞),git 因此判定为二进制,任何 diff 都只打印 `Binary files differ``--stat``Bin 66815 -> 68701 bytes`,**整个文件无法 review**。`diff` 属性让 git 按文本 diff,且**不设 `text`**,所以不做行尾归一化、磁盘字节不变。同一个坑在 shell 里也有:`grep` 只打印 `Binary file … matches`,**看起来像没匹配到** —— 用 `grep -a`。(另:macOS 是 BSD grep,`\|` 不支持,要 `-aE`。)
- **验证**: `tsc` 两个 project 全干净。单测 **2167 全过**(78 文件),新增 **7 个**回归测试:未知计数不给绿色、疑虑不落在数字上、零不用 accent、detached 有/无 sha(前端 2 + 后端 2)。集成 **27/27**
- **真实浏览器逐宽度验证**(不是靠读 CSS 推):1280/1000/900/780/721/600/420px —— 同步条 721px 以上恒单行、以下堆叠成列;提交行每行列位完全对齐(实测 x = 198/230/314/380);任何宽度都不产生横向滚动。ghost 按钮实测 `.proj-diff-toggle``.proj-wt-open` 几何完全一致(同为 h=31)。
- **自己也踩了一次同类层叠 bug 并修掉**:第一版把"≤720px 丢掉 age 列"的规则写在了 `.proj-commit-row` 基础规则**之前** —— **media query 不增加特异性**,等权重下后写者胜,于是 3 轨声明失效、subject 掉进 56px 的 age 轨被压成一条。已挪到基础规则之后,420px 实测 subject 264px。
- **决策 / 偏离**: 审计给的 59 条里,**token 名映射差异**(设计稿自带 token 名 vs 本项目真实主题 token)一律不算偏差;设计稿的**文档外壳**(`.wrap`/`.note`/表格等)不是产品 UI,不动。`.proj-commitlog-count` 按设计稿改成 `--text-dim`(此前是 accent)—— 设计稿把 accent 留给同步条里的数字。
- **遗留 / 待办**: 无。59 条已全部处理(高/中/低全收)。**桌面 App 已用 `npm run dist:mac` 重新打包并签名校验通过**(Team ID `C738Z66SRW`,与原装一致),产物 `desktop/dist-app/*.dmg` + `*.zip`;**安装与重启 App 需用户自己做**(旧 App 仍在跑旧资源)。
- **注意**: 集成测试 `test/integration/server.test.ts` 在**并发负载下**(我同时开了第二个 server + 无头浏览器)6 次里挂过 2 次,失败点是 WebSocket 关闭时序(约 862 行),**与本次改动无关**;单独重跑 5/5 通过。这与上一条日志里"真实 PTY 的 E2E 不能和整套共用机器"是同一个已知抖动。
- **commit**: `950d229`(合并 `a25fe30`)+ `dba8355`(合并 `e506d0d`)。
### 🌿 [x] w6 项目详情 Git 面板 — G1G7 全部完成(2026-07-29,worktree `project-detail-git-mockup`)
- **动机**: 详情页只有一个光秃秃的 `●`,回答不了"有没有 commit 没 push / 现在在哪个 worktree"。设计稿 `docs/mockups/project-detail-git.html`,计划 `docs/plans/w6-project-git-panel.md`
- **贯穿全篇的一条硬规矩**: `ahead/behind` 比的是**本地缓存**的 `@{u}`,不 fetch 永远不动。**本仓库当时就是活教材** —— `↑9` 是真的,而 `↓0` 假的:`FETCH_HEAD` 停在 7-10,已 19 天。因此:`ahead` 永远可信(只用本地 ref,绝不标记陈旧);`behind` 超过 `FETCH_STALE_MS`(1h)就打 `stale`;**只有 `↑0 ↓0` 且刚 fetch 过这一个状态允许显示绿色**;`upstream === undefined`(新 worktree 分支的常态)必须显式说 `no upstream`,绝不能因为"没数字"掉进绿色分支。
- **G1 后端同步状态** (`src/types.ts`, `src/http/projects.ts`): 新增 `SyncState`(upstream/ahead/behind/lastFetchMs/detached)+ `ProjectDetail.sync``dirtyCount`。**全部为可选新增字段** —— Android/iOS 客户端解码同一批 JSON,不改名不删字段。`readSync` 助手本已存在(列表页在用),`buildProjectDetail` 从来没接;新增 `readUpstream``readLastFetchMs`(读 `FETCH_HEAD` mtime,不 spawn)、`readDirtyCount`(一次 porcelain 同时出布尔和计数)。
- **顺手修掉一个既有 bug**: `readBranch` 直接读 `<repo>/.git/HEAD`,而**链接 worktree 的 `.git` 是文件不是目录**,所以在任何 worktree 里都读不到分支。新增 `resolveGitDirs()` 同时解析 per-worktree `gitDir`(放 HEAD)和共享 `commonDir`(放 FETCH_HEAD)。
- **G2 fetch 路由** (`src/http/git-ops.ts`, `src/server.ts`): `POST /projects/git/fetch` + `fetch()` 引擎。纪律照抄 push:**remote 一律服务端推导**(当前分支 upstream,否则唯一 remote;0 个 → 400,≥2 个无 upstream → 409),**绝不接受 body 里的 remote/refspec**,错误信息永远是白话不是 raw stderr(SEC-M10)。只动 `refs/remotes/*`,不碰工作树/索引/分支,不是 pull。**自己的限流桶**,不与 push 抢额度。失败时**不更新** `lastFetchMs`,让 UI 继续显示 stale 而不是假装刷新过。
- **计划里的一条写错了**: 原计划说"顺手给 push 加 `GIT_TERMINAL_PROMPT=0`" —— push 早就有 `NO_PROMPT_ENV` 了,无需改。
- **G3 同步状态条** (`public/projects.ts` `makeSyncBand`, `public/style.css`): 取代光秃秃的 `●`。upstream 名 + ↑n + ↓n + stale 标记 + dirty 计数 + Fetch 按钮(detached 时禁用)。`nowMs` 可注入以便测试。in-flight 期间 `fetching` 标志挡住第二次点击。
- **G4 推送分界线** (`src/http/git-log.ts`, `public/git-log.ts`): `CommitLogEntry.unpushed` + `GitLogResult.upstream`,**服务端**从 `git rev-list @{u}..HEAD` 打标。**刻意不用"前 N 行就是未推送"这个便宜做法** —— `git log` 按日期排序,合入一个较老的分支会把未推送的 commit 插到已推送的**下面**,那样会把未推送的说成已推送(和 ↓0 撒谎是同一类错误)。短 hash(`%h`)与 rev-list 的全 SHA 用**前缀匹配**,避免缩写长度不一致。前端只在有 upstream 时画一次分界线。
- **G5 worktree 面板** (`public/projects.ts`): 标题从 `worktrees.length > 1 ? 'Worktrees' : 'Branch'` 改成恒为 `Worktrees (n)`;当前 worktree 那行复用 band 的芯片词汇显示 `↑n` / `● n` / `no upstream`。**其余行保持无状态** —— 不知道就什么都不显示,不猜。
- **验证**: `tsc --noEmit` 干净、`npm run build` 干净。新增 **33 个测试全过**(2128 → 2161)。分模块:`projects.test.ts` 43、`git-ops.test.ts` 29、`projects-panel.test.ts` 90、`git-log.test.ts`(前端)17 +(后端)17。
- **全量 `npm test` 有 9 个失败,但全部是既有测试,没有一个是 w6 的**。在 develop HEAD 上跑基线同样失败(5 个)。根因已定位:**vitest 默认 `testTimeout` 是 5s**,而这些 fixture 要串行 spawn 612 个 `git`(init/config/commit/clone/push),全量并行时超预算 → `Test timed out in 5000ms`。我给**自己新增的** describe 块显式加了 `{ timeout: 30_000 }`;**既有测试没动**(改共享测试配置超出本次范围)。
- **G6 成本控制** (`src/http/projects.ts`, `public/projects.ts`): **否决了计划里的 `.git` mtime 缓存**。用 HEAD/index/reflog 做指纹时,`git push` 更新 remote-tracking ref **不会**改动其中任何一个 —— 缓存的 `ahead` 会在推送成功后继续宣称"还有 9 个要推",正是这个功能存在的意义所要避免的那种自信的谎话。改用三条**不可能变陈旧**的措施:
1. **并发合流**:同一仓库的并发探测共用一个 promise,settle 即删。N 个设备看同一个项目 = 一次探测,且**不跨时间缓存任何东西**。
2. **数据未变则跳过重渲染**:原来每 5s 重建整棵子树,顺带**重新挂载 commit log**(又是两次 `git` spawn)去重画一模一样的行。
3. **页面隐藏时暂停**:后台标签页 / 手机熄屏时完全停止探测。
- **G7 逐 worktree 状态** (`src/http/projects.ts`, `src/server.ts`, `public/projects.ts`): 新增 `buildWorktreeState` + `GET /projects/worktree/state`(比 `/projects/detail` 窄:一行只要 branch/sync/dirty,给 N 行都跑一遍列 worktree + 读 CLAUDE.md 是在为不渲染的数据花 spawn)。
- **前置缺口已补**:`ProjectSessionRef` 加了 `cwd`。配套的 `countSessionsByWorktree` 用**最深匹配** —— `.claude/worktrees/<name>` 就在主 checkout **里面**,前缀匹配会把每个 worktree 的 session 同时算到父仓库头上。
- 偏离计划:改成**每次打开项目探一次**而不是"展开某行才探"。状态在客户端缓存、且被"未变则跳过"挡住,成本一样,但信息不用点就有。
- **验证**: `tsc --noEmit` 干净、`npm run build` 干净。新增 **46 个测试全过**(2128 → 2174)。
- **顺手修掉了既有测试的抖动,`npm test` 现在端到端全绿**(单元趟 78 文件 / 2147,e2e 趟 27,连续多次)。两个独立原因:
1. **fixture 撑破了 vitest 的 5s 默认值**。git 类的要串行 spawn 612 个 `git`,真实服务器类的要起 server + shell。给相关 `describe` 加显式 `{ timeout: 30_000 }`,真实 PTY 的等待抽成具名的 `PTY_WAIT_MS`。11 个失败 → 约 1 个。
2. **真实 PTY 的 E2E 文件不能和整套共用机器**`test/integration/server.test.ts` 单独跑 27/27 反复稳定通过,一进全量并行就抖 —— 它要在几秒内断言真实提示符输出,而 8 个 worker 正把机器榨干,这个预算根本不成立。继续调大数字只是让抖动换个地方出现,所以 `npm test` 改成**两趟**:`test:unit`(其余全部,并行)然后 `test:e2e`(该文件独占)。`vitest run` 仍然一次跑全部。
- 另外给 H1 的 `finally` 里那个 `await srv.close()` 加了上限。它原本无上限,会把函数体里的真实错误吞掉、只报一个光秃秃的超时 —— 上面这个诊断是先把失败变得可读之后才做得出来的。
- **更正**:上一条日志把这 3 个失败写成"沙箱环境限制,与代码无关",**那是错的**。`PTY_AVAILABLE` 在本机是 `true`,这些用例根本没跳过,是真的在跑并且卡在时间预算上。
- **遗留 / 待办**: 无(G1G7 全部完成)。设计稿里画的每样东西都落地了。
- **commit**: 已合入 develop(`8fe1f52` / `cc811dd`,合并 `e81c426`)。后续的设计稿对照收尾见上一条。
### 🧹 [x] 补完上一条留下的三个遗留(2026-07-29,紧接死锁修复之后)
- **① `.gitignore` 把源码吞了**: `agent/src/dist/buildBinary.ts` 是打包配置(源码),却被通配的 `dist/` 规则排除,**从未提交**——全新 clone 既过不了 `agent/src/index.ts` 的类型检查,也导入不了已提交的 `agent/test/buildBinary.test.ts`。修:**先解除目录排除**(`!agent/src/dist/` + `!agent/src/dist/**`)再提交文件 —— git **不会进入被排除的目录**,所以只否定文件名是无效的(实测确认)。反向验证 `agent/dist/``dist/``public/build/``desktop/dist-app/` 仍被忽略。
- **② 隧道日志不带主机标识**: `pair` 从 enroll 响应拿到 hostId/subdomain 后直接丢弃,长跑的 `run` 进程无从得知,所以每条日志都是 `{"subdomain":null,"hostId":null}` —— 包括 8 天故障期那 6380 条告警,恰恰是最需要知道"是哪台机器"的时候。修:新增 `agent/src/config/hostRecord.ts`(`saveHostRecord`/`loadHostRecord`/`subdomainFromCertPem`/`resolveHostIdentity`),enroll 时落 `host.json`;解析优先级 **config(env/argv) > 记录文件 > 叶证书 SPIFFE SAN**。第三档是给**记录文件出现之前入网的老主机**的:它们的证书里本来就有子域名,因此**无需重新 pair** 就能把标识找回来。接入点:`certs/nativeRenew.ts`(续期告警)与 `cli/deps.ts`(健康状态行)。
- **③ 手机轨没有恢复路径**: 新增 `POST /device/:id/recover`,与主机版同构 —— 证书走 body、device-CA 路径验证、SPIFFE 解析、`notBefore` **绝不宽限**,外加完整 registry 一致性检查(active + 同账户 + `:id` 与证书一致 + 同 key),**只放宽 `notAfter`**。nginx 在 enroll vhost 加 `location ~ ^/device/[^/]+/recover$`
- **验证**: 单测 agent **300/300**、control-plane **296/296**,两侧 `tsc` 干净(TDD 先 RED 后 GREEN)。
- **线上实测**:`/device/xyz/recover` 已能抵达 CP(返回 CP 的 `{"error":"rejected"}` JSON 而非 nginx 页面);回归 —— 伪造主机证书打 `/recover` 仍 401、过期证书打 `/renew` 仍被 nginx 拦为 400、`/enroll` 免证书路径未受影响。
- **标识实测(本机 h7fd8)**:写入 `host.json` 后日志出现 `subdomain: h7fd8` + `host_id: 7190ecd4-…`;**把 `host.json` 移走**后重启,新一跳仍能从证书 SPIFFE SAN 恢复出 `subdomain: h7fd8`(`host_id: (none)`,证书里本就没有)——老装机回退路径实盘确认。隧道端到端 **HTTP 200** 不受影响。
- **遗留**: **iOS / Android 客户端尚未接线去调用 `/device/:id/recover`** —— 服务端能力已具备,客户端触发还没有。设备证书过期时目前仍需重新 enroll。
- **commit**: `2a602d5`(合并 `d92caed`)。
### 🔥 [x] 隧道断了 8 天 —— 证书过期后**自动续期死锁**,已修 + 已上线(2026-07-29)
- **现象**: 用户问"远端 relay 是否在运行"。服务端**全绿**(nginx/frps/cp-zte/panel-zte/xray/pg/redis 全 active,LE 通配证书有效至 2026-10-05,`*.terminal.yaojia.wang` 通配 DNS 正常),但 **frps `:7000` 已建立连接数 = 0**,`h7fd8.terminal.yaojia.wang` 打不通。Mac 上 frpc 在跑,每 20s 重试一次,全部 `connect to server error: EOF`(frps 在 mTLS 阶段拒)。
- **根因(死锁)**: Mac 的 frp-client 叶证书 **2026-07-21 13:55 UTC 过期**(TTL 仅 ~24h)。`POST /renew` 是**用它自己要续的那张证书做 mTLS 鉴权**的 —— 证书一过期就再也换不了证书。触发链:续期窗口(T-8h)那天 Mac 在睡眠,醒来时 DNS 未就绪 → 24 次 `getaddrinfo ENOTFOUND enroll.terminal.yaojia.wang` 错过窗口 → 过期后改报 `client certificate has expired; renew before dialling`,**重复 6380 次、8 天、永不自愈**(agent.log 涨到 3MB)。
- **三层都 fail-closed**,只改一层没用:① agent `transport/dial.ts` `buildTlsOptions``CertExpiredError`,连 socket 都不开;② nginx `enroll.conf``location = /renew` 要求 `$ssl_client_verify = SUCCESS`;③ CP `api/renew.ts` `assertPresentedCertTrusted` 再查一次有效期 → 401。
- **两个假设被实测推翻(重要,别再踩)**:
1. `ssl_verify_client optional` 在客户端证书**验证失败时直接返回裸 400**(`400 The SSL certificate error`),请求**根本到不了 location** —— 所以任何 `if ($ssl_client_verify …)` 都救不了。
2. `optional_no_ca` **也不行**:它只容忍**链**错误。见 nginx 的 `ngx_ssl_verify_error_optional()` —— 只含 `DEPTH_ZERO_SELF_SIGNED_CERT` / `SELF_SIGNED_CERT_IN_CHAIN` / `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` / `UNABLE_TO_VERIFY_LEAF_SIGNATURE`,**不含 `X509_V_ERR_CERT_HAS_EXPIRED`**。已在 VPS 上用真过期证书打 :8472 实测确认。结论:**nginx 在任何模式下都不会转发过期客户端证书**。
- **最终方案(用户选定:30 天宽限窗口 + 超窗回落配对码)**: 恢复路径**放弃 mTLS**,改为**不带客户端证书的普通 HTTPS POST,过期证书放进 body**。不丢鉴权 —— **CSR 是用同一把私钥自签的**,而签名网关本来就强制 CSR PoP(`ca/csr.ts` `verifyCsrPoP`)+ `CSR key == 注册 key`,持有私钥的证明与原来 TLS 握手给的完全等价。重放一张(公开的)证书而没有私钥,最多拿到一张**自己用不了**的证书。
- **agent**: `dial.ts` 与 mTLS renew 通道**保持严格 fail-closed**(放宽已从 TLS 层完全移除)。改由 rotator 逐次决策:有效 → mTLS `/renew`;过期但在宽限内 → 普通 `/recover`;超出宽限 → 终态 `onExhausted`,**一次告警后停止重试**(不再发任何请求),日志直接给出 `web-terminal-agent pair <CODE>`。新增 `recoverCert()``recoveryUrlFor()``DEFAULT_EXPIRED_RENEW_GRACE_MS`(30d)、`CertExpiredBeyondGraceError`;`AgentConfig` 加可选 `recoverUrl`(env `RECOVER_URL`)。
- **CP**: `/renew` **恢复严格**(grace 0,与终端器行为一致)。宽限只给新增的 `POST /recover` —— 证书从 **body** 读、**忽略 `x-client-cert` 头**,然后跑**与 `/renew` 完全相同**的信任链:frp-client-CA 锚点做真 X.509 路径验证 → SPIFFE 解析 → `notBefore`(**永不宽限**)→ registry `active` + 账户一致。**吊销照样生效**。
- **deploy**: 不需要新 vhost / 新 SNI / 新 DNS —— 只往现有 enroll vhost 加一个 `location = /recover`(+ `limit_req` 10r/m)。文档 `deploy/nginx/enroll-recover-location.md`(含 nginx 源码引证)。
- **验证**:
- 单测:agent **289/289**、control-plane **290/290**,两侧 `tsc --noEmit` 干净。TDD 全程先 RED 后 GREEN。
- 承重测试:**「山寨 CA 签的、SPIFFE SAN 与真证书逐字节相同的伪造证书 → 401」**。这条最关键 —— 这条路径上 nginx 已不再验链,它一旦变红,`/recover` 就成了无鉴权发证机。已在**线上实测 401**。
- **线上端到端自愈实测**:把那张真·过期 8 天的证书塞回 keystore → 重启 agent → 日志 `frp-client cert rotated; restarting frpc onto the fresh leaf` → 证书自动换成新的 24h 叶(Jul30 08:06)→ frpc `login to server success` / `start proxy success` → 设备证书走完整链路 `:443 → SNI → :8470 mTLS → frps → frpc → Mac base app` 拿到 **HTTP 200**(返回本机真实 session 列表)→ 健康探针 `healthy: true`。**全程零人工介入** —— 正是生产卡死 8 天的那个场景。
- 回归:`/renew` 对过期证书仍 400(nginx 拦住,未放宽);4 区 SNI 探针(frp/通配/enroll/xray-Reality)全部未受影响。
- **应急恢复(修复前先做的)**: 用 frp-client CA 给 h7fd8 补签了一张 7 天过桥证书(同一把私钥、同子域名、同 SPIFFE SAN,经 `openssl ca` 登记进 index.txt 因此可吊销),先把隧道救活。自愈生效后它已被换成 CP 正常签发的 24h 叶。
- **遗留 / 待办**:
- **`agent/src/dist/buildBinary.ts` 是源码却被 `.gitignore``dist/` 规则吞掉,从未提交**(既有问题,与本次无关):任何全新 clone/worktree 都缺它 → `agent/src/index.ts` 类型检查失败、已提交的 `test/buildBinary.test.ts` 直接导入失败。建议加 `!agent/src/dist/` 例外。
- agent 日志里 `subdomain: (none)` / `host_id: (none)`:enroll 后未把 subdomain/hostId 落进配置,导致告警缺主机标识(既有问题)。
- 手机轨(device)目前**没有** `/recover` 对应路径,设备证书过期仍需重新 enroll;CP 侧宽限代码是通用的,补一条路由即可。
- 24h TTL + 单次定时续期 + 笔记本睡眠 的组合仍偏脆,宽限窗口是兜底而非根治。
- **文件**: `agent/src/certs/rotation.ts` · `agent/src/certs/nativeRenew.ts` · `agent/src/config/agentConfig.ts` · `control-plane/src/api/renew.ts` · `deploy/nginx/enroll-recover-location.md`(新) · 各自测试。
- **commit**: `f3f4d8b`(第一版,方向对但 nginx 那条路走不通)+ `5509c81`(实测后改为 body 传证书,最终版)。
### 🐛 [x] Mac 版中文全变 `_` + 框线渲染错乱 —— PTY 缺 UTF-8 locale(2026-07-28)
- **现象**(用户截图,Mac 版):Claude Code TUI 里所有中文变成 `_`;banner/输入框只剩零散横线,圆角边框消失。
- **根因**(实测确认,不是字体也不是前端):`src/session/session.ts` spawn PTY 时用 `env: {...process.env}`,而**服务端由 launchd/Finder 启动,环境里根本没有 `LANG`/`LC_*`**(`~/Library/LaunchAgents/com.web-terminal.base-app.plist``EnvironmentVariables` 只有 PATH/PORT/BIND_HOST)。tmux 逐 client 从 `LC_ALL`/`LC_CTYPE`/`LANG` 判断 UTF-8 能力,全空 → 进入**非 UTF-8 模式**,在**服务端就把字节改写掉**:宽字符→`_``✓ ⏺` 等→`_``═║╔╗` 降级成 DEC Special Graphics(`ESC ( 0` + `qxlk`)。所以前端 xterm 收到的已经是残骸,改字体/renderer 救不回来。
- 用户 `~/.zshrc` 里其实**有** `LANG=sv_SE.UTF-8`,但没用:tmux 是 PTY 的根进程,它做完 UTF-8 判定之后 shell 才 source profile。登录 shell(`-l`)救不了 tmux。
- node-pty 探针实证:无 LANG → `MARK ________ ==BOX== \e(0qxlk`;有 LANG → `MARK 中文测试 ==BOX== ═║╔╗ ✓`
- **修复**: 新增 `src/session/locale.ts``withUtf8Locale(env)`,在 `createSession()` 的 spawn env 上先过一道。POSIX 优先级 `LC_ALL > LC_CTYPE > LANG`;**已是 UTF-8 就原样不动**(用户的 `zh_CN.UTF-8`/`ja_JP.UTF-8` 保留),只在缺失或非 UTF-8(`C`/`POSIX`/`ISO8859-1`)时补 `en_US.UTF-8`(并清掉会盖过它的非 UTF-8 `LC_ALL`/`LC_CTYPE`)。
- **验证**:
- `test/locale.test.ts` 9 例(TDD:先 RED 再 GREEN);`tsc --noEmit` 干净。
- 端到端:用 `env -u LANG -u LC_ALL -u LC_CTYPE` 复现 launchd 环境跑 server → 新建 session → `中文测试:你好,世界!`/`🎉 ✅`/`╭─╮ ═║╔╗ █▓▒░ ⏺ ⎿` **DOM 里逐字符核对全部正确**(修复前同一条命令得到 `________`)。
- 之后 `npm run build` + `launchctl kickstart` 让真正的 launchd 单元(仍无 LANG)接管,再复验一次通过。
- 回归:`test/integration/server.test.ts`(含 ⑥ CJK ring-buffer replay、H1 tmux 重启存活)+ git-ops + worktree + projects 共 **107 例全绿**。(全量 79 文件并跑时有 4 例超时 flake,单跑全过,与本改动无关。)
- 存量 tmux session **无需重建**:探针证明用带 LANG 的新 client 重新 attach 一个"非 UTF-8 模式下创建"的旧 session,新输出即恢复正常(只有已滚进 scrollback 的旧 `_` 救不回)。
- **遗留**: `/Applications/Web Terminal.app/Contents/Resources/dist/` 里打包的是 7/18 的旧代码,**仍未修**——桌面 Electron 版要 `cd desktop && npm run dist:mac` 重打包(或就地补 `dist/session/*.js`,注意 adhoc 签名会失效)。仓库内 `dist/` 与 launchd base-app 已修好。
- **文件**: `src/session/locale.ts`(新)· `src/session/session.ts`(spawn env)· `test/locale.test.ts`(新)。
### 🔐 零接触隧道 Enrollment 产品化 + Control Panel(2026-07-18~23,当前活跃)
- **需求**: 用户要"本机 app 一跑就自动连服务器、自动管证书;手机连接时自动搞定证书"——即**产品级零接触**(引导时一次动作,之后自动签发+续期,永不再管)。诚实边界:第一次签发无法零人类动作(否则谁碰端点谁拿证书),所以"引导一次,之后免管"。
- **产物**: [docs/PLAN_ZERO_TOUCH_ROLLOUT.md](./PLAN_ZERO_TOUCH_ROLLOUT.md)(执行计划,含预检/两轨/风险)。分支 `feat/zero-touch-enrollment`(13 提交)→ **已合并 `develop`**(merge `7c1d433`)+ control-panel(`675de77`)。**未 push origin**。记忆见 `mac-tunnel-autoconnect`
- **[x] 主机轨 Track A — 上线 + 实机验证**: `web-terminal-agent pair <码> --install` → CP 原生 enroll(真 frp-client 证书) → 装 base-app+agent launchd 单元 → frpc 自动连 frps → **公网 `h7fd8.terminal.yaojia.wang` 实测 200**。**自动续期实测成功**:证书 Jul19 13:41→Jul20 05:55 前移、frpc 无缝切、隧道不断。旧手动 mac1 已退役。桌面 GUI 骑 :3000(S1 `10688b0`)。
- CP 代码:B1 登录/device:enroll bearer(`fff011b`**A2-prep 生产 native-CA 接线**(磁盘 P-256 CA,`af63014`)· renew 接受 escaped-PEM(`55d177e`)。
- agent 代码:A5 native 自动续期(`9a5909f`)+ 实机 shakedown 修复(`9f7f5c0`/`1e398c7`/`c98f5e6`):hostContentSecret 可选、caChain 数组+DER→PEM、launchd 绝对 node+PATH、agent 单元缺 env、renew 用**系统根**验 LE 服务器(原错钉私有 CA)、launchd 单元加日志 sink。
- **[x] 手机轨 Track B — 服务端实机验证 + 两端 code-complete(真机 QA 待做)**:
- **服务端**:curl 全程实测 `login → enrollToken(PASETO) → device/enroll(P-256 CSR) → 设备证书(device-CA,SAN 绑定子域名) → 用它 mTLS 访问 h7fd8 → 200`
- **iOS**(`07bcbf0` + review):契约 6 点全对、已接 UI(主机菜单「自动获取证书」→ SE keygen→CSR→enroll→SecIdentity→静默续期),可直接真机 QA。
- **Android**(`5e427dc` + `0b35dc0`):enroll 库建好但原**没接 UI**——已补(EnrollmentScreen+VM+Hilt,照搬 iOS)+ 修 renew(改 mTLS-only 无 bearer)+ 缓存刷新。gradle assembleDebug/单测/kover 绿。
- **VPS 部署(8.138.1.192)**: 新 CP = systemd `cp-zte.service`(loopback :8080,从 worktree `/root/wt-zte` 跑 tsx,env `/etc/relay/control-plane-zte.env` 含 NATIVE_*/OPERATOR_*)· nginx enroll vhost(`/enroll`·`/device/enroll`·`/auth/login` 免证书,`/renew`·`/device/:id/renew` mTLS + CA bundle)· **frps-ctrl 用 frp-client-CA 重签**(让 agent enroll caChain 验得了控制通道服务器证书)。
- **[x] Control Panel — 新建 + 上线**: 新包 `control-panel/`(Fastify auth-broker + esbuild SPA,55 测试 + 对抗安全审查修了 1 HIGHrate-limit trustProxy+3 项)。**在线 `https://panel.terminal.yaojia.wang`**(operator 口令登录 → 看主机/状态/证书到期、铸配对码QR+pair 命令〕、吊销主机);面板每调用现铸 60s manage token 打 CP loopback admin API。systemd `panel-zte.service` + nginx `panel.conf`(:8091,X-Forwarded-For $remote_addr 配 trustProxy)。已用面板清掉 5 个失败 pair 的孤儿主机(留 h7fd8)。
- **[x] Android 本机运行验证**: 装 emulator + android-35 arm64 镜像 → build APK(67MB)→ 装 + 启动 → **截图确认 UI 正常渲染**(「配对主机」界面)、无崩溃。(StrongBox 硬件密钥模拟器验不了,回落软件密钥;真机 QA 仍待做。)
- **验证**: CP 281 测试、agent 281 测试、android assembleDebug+单测+kover、panel 55 测试——全绿,每块 orchestrator 独立复验。主机隧道 + 手机服务端 + 面板均**真实 HTTP 200 端到端实测**。
- **决策/偏离**: R1(生产 native-CA)从"配置"重判为"真代码任务"(env.ts 无 NATIVE_*、server.ts 未接线),已建 file-backed KmsResolver 用磁盘 CA;现网无 njs 故 renew 证书头归一化放 CP 侧;manage token off-band 铸(capability 私钥)。
- **遗留/待办**: ① iOS/Android **真机 QA**(StrongBox + 手点 UI); ② push origin / 未做; ③ 面板"实时在线状态"(查 frps 已注册 proxy)小增强; ④ native-enroll 容忍 + agent-unit env + renew 头归一化 缺专门单测; ⑤ CP 跑在 worktree,别删 /root/wt-zte。
- **commit / 分支**: `feat/zero-touch-enrollment`(13) → merge `develop` `7c1d433` + `675de77`;VPS bare repo 有 develop。
### 🗺️ ROADMAP 落地 — Wave 1-4 八个功能(2026-07-12,当前活跃;多 agent 并行计划 + 逐个 builder 实施)
- **产物**: [docs/ROADMAP.md](./ROADMAP.md)(分层 backlog)+ [docs/plans/](./plans/)(8 份可直接照写的实施计划,并行生成)。全在 `develop`
- **编排**: 主线洞察"产品把一切都**采集**了却**没拿去行动**",两个原语(PTY-inject、审批预览)解锁一片。流程 = **并行生成 8 份计划**(1 个 workflow / 8 agent)→ **逐个功能派 builder 在主工作树实现**(一次一个,避免踩 server.ts 等共享文件)→ **orchestrator 独立复验**(typecheck 两 config + full suite + build:web + 安全审)→ 提交 → 下一个。
- **[x] 八个全绿(独立复验,非仅采信 builder)**,每个单独 commit:
1. **W1 可点链接/文件路径** `debf47d``public/link-paths.ts` 纯 matcher + xterm link provider;URL scheme 白名单 + noopener;附加 `openFileInEditor`(`--goto file:line`,现有路由只能开目录)。
2. **W1 审批预览** `e062065` — 手机上 Approve 前先看到 Bash 命令/Edit diff。`src/http/approval-preview.ts` 有界 sanitize(40 行/200 字/4KB),走 gate 同款晚加入者重发;渲染只走 textContent/renderDiffFile。
3. **W2 PTY-inject + idle 队列** `3076843` — 地基原语。`POST /live-sessions/:id/queue`(Origin+限流+SESSION_ID_RE),idle 时 drain 一条(去抖 timer + pop-one + settle 复检 三重防重复),注入复用 writeInput 字节原样进 PTY。
4. **W3 diff-vs-base** `b119c31``?base=<rev>` 审整条分支。三层防选项注入:isPlausibleRev → `rev-parse --verify --end-of-options` → 只有解析出的 sha 进 `git diff <sha>... --`
5. **W3 PR/CI chip** `7551f8a``src/http/gh.ts` 单次 `gh pr view --json`;缺 gh/未登录/无 PR 全降级不抛;PR title 走 textContent。
6. **W3 quick wins** `1dd12b0` — 项目卡 ahead/behind + 最近提交时间;成本预算告警(`COST_BUDGET_USD` 单次 latch + push);`/digest` 重连摘要;`/projects/log` 最近提交。
7. **W4 worktree 删除/prune** `552f35c` — 破坏性,护栏:必须在 `git worktree list`(realpath 匹配)、拒主 worktree、容器内、脏树要 force、locked 拒、错误归类、`git worktree remove` 不用 rm -rf。
8. **W4 stage/commit/push** `19f241d` — 手机审完直接落地。MVP 只 stage/commit/push 当前分支(砍 discard/checkout);push 的 remote+branch 从 repo 读、绝不 `--force`/`+refspec`;三路由 Origin+GIT_OPS_ENABLED+限流;路径 realpath 容器内 + `--` 后作 argv;错误归类不泄露。
- **已知测试抖动(非回归)**: 两个真-PTY/tmux 集成测试(`ring buffer` 重放、`H1 tmux`)在沙箱满负载下撞默认 5s / 自身 20s 超时;**单独跑或 `--test-timeout=30000` 全绿**(full suite @30s = **2005/2006**,唯一 red 是 H1 tmux 撞自身上限)。逻辑无回归。
- **未 push**: 全部本地 `develop`,领先 origin/develop 一批。
### 🖥️ SPLIT-GRID 看板 — 桌面多 session 分屏(2026-07-11)
- **需求**: web/Mac 大屏、开多个 tab 时,把 `#term` 大窗切成 1×2 / 2×2 宫格,多个 **live 可交互**终端同屏,方便"vibe coding"时盯多个 Claude session。手机不做(<1024px 强制 single)。
- **分支**: `feat/split-grid-view`( `feat/tunnel-automation`)。**用户决策(AskUserQuestion)**:全部阶段(v1v2v3)用多 agent + loop 完成;审批用**每格内联 ✓/✗**;成员=**前 N tab(拖拽换序控制)**;布局=**single + 1×2 + 2×2**。
- **编排**: orchestrator 亲写互锁的 5 文件(并行 builder 会互相踩),每阶段后**并行对抗式 review workflow**(4 lens 逐条 verify)→ confirmed 复验绿 commit loop 下一阶段
- **[x] v1 DONE(2026-07-11)** orchestrator 独立复验全绿:`npm run typecheck`(前后端两 config)干净`build:web` 干净、**全套 1566 测试通过**(+33 新增),覆盖率 grid-layout.ts 95%/tabs.ts 94%(≥80 门槛)。
- **核心设计**: `activeIndex` 语义**不变**=聚焦格(keybar/voice/approval 全部零改动);新增 `gridLayout` + 派生 `visibleIndices`;每个 pane 包进 `.term-cell`(header + 终端 + 可选内联审批 footer);`applyLayout()` 独占 pane 显隐/grid class/cell 排序/占位符;`#term` CSS grid。**服务端/WS 协议零改动**。
- **文件**: `public/grid-layout.ts`(纯逻辑 + matchMedia + 持久化 + toolbar 分段控件)、`tabs.ts`(applyLayout/renderCell/renderInlineApprove/setFocused/setGridLayout/refitVisible + `activate()` 改为**board-aware**)、`terminal-session.ts`(`show({focus})` 4 格抢焦点 + `onFocus` 回调)、`main.ts`( toggle + refitVisible)、`style.css`(cell/grid/焦点环/pending 脉冲/内联审批/占位符/toggle + `.term-pane` position:relative flex )。新测 `test/grid-layout.test.ts` + `tabs.test.ts` 追加 split-grid
- **交叉验证抓修 3 真缺陷**:**HIGH** `activate()` 原先非 board-aware:满格(4/4)时点 "+" 会让新 tab 成为 activeIndex `display:none`,用户对着看不见的 session 打字(空最常见路径触发)。修:把 off-boardmoveTab 上板逻辑折进 `activate()`,`setFocused`/`setGridLayout` 委托之。**LOW** 通知抑制未算 `homeForced`(⌂ 覆盖时 pane 实际不在屏);修:`onScreen = !homeForced && isVisible`。**LOW** toggle 触控尺寸(coarse-pointer 平板);修:`@media (pointer:coarse)` 加大全部补了回归测试
- **[x] v2 DONE(2026-07-11)** orchestrator 独立复验全绿:typecheck config 干净build:web 干净、**全套 1579 测试**(+13),覆盖率 grid-layout 95%/tabs 93%。加了:**1×3(row-3)/2×3(grid-6)布局**、**Ctrl+`/Ctrl+Shift+` 循环焦点**(main.ts capture keydown cycleFocus,单格模式不吞键)、**每格最大化 ⛶**(`.maximized` 覆盖层)、** tab 到格**(wireCellDropTarget 复用 dragIndex)。用单个 `grid` 标记类承载共享 cell 样式(不再枚举每个 lay-*)。
- **交叉验证抓修 3 真缺陷**:**HIGH** 最大化原用 `grid-column/row: 1/-1` **span 网格**是错的:CSS Grid 把兄弟格挤进隐式行→"最大化"格变成细条(非全屏)+ 兄弟格 box 变化触发 ResizeObserver→**给后台 live PTY 发错误 resize(伪 SIGWINCH)**评审在** headless Chrome 里实测复现**。修:`.maximized` `position:absolute; inset:0; z-index:4` 脱离网格流做覆盖层。**独立 Playwright 复验**:最大化格填满 #term(1000×700)、兄弟格尺寸 0px 变化,对照组证实旧规则确会 891×572 细条+兄弟塌成 52px。**MED** 最大化时被盖住的 pending amber 脉冲外溢;修:`(!maximized || focused)` 。**LOW** `.cell-max` coarse-pointer 触控尺寸均补回归测试;并把 Ctrl+` 键匹配抽成 `matchFocusCycleKey` 纯函数单测
- **[x] v3 DONE(2026-07-11)** 三个特性,orchestrator 独立复验全绿(typecheck config + build:web 干净、**全套 1615 测试**、全局覆盖率 89% stmts/82% branch 80 门槛)。分三 commit:
- **v3a 只读 monitor (cd97114)**:每格 👁 切换 liveread-only`public/cell-monitor.ts` 轮询 `GET /live-sessions/:id/preview` 写只读 xterm,** attach WS不发 resize**,故不驱动共享 PTY 尺寸——解决"小格盯 session 会缩掉别的设备全屏"的跨设备 shrinkmonitor live pane 保持 hidden;toggle-off/ tab/离开 grid 皆清理
- **v3b 可拖拽分隔条(007e598)**:格间 gutter 拖拽调每布局 col/row fr 比例(`adjustSplit` 纯函数:相邻轨道互让clamp 0.3守恒),内联 grid-template + 持久化 `web-terminal:grid-splits`;拖拽中 reposition 不重建 handle
- **v3c 布局预设(007e598)**:`public/grid-presets.ts` toolbar 下拉,/应用/删命名的 布局+split(`web-terminal:grid-presets`);外点/Esc 同名替换XSS-safe(textContent)。
- **交叉验证抓修 4 类真缺陷(6 confirmed,2 dup)**:**HIGH** `splitForLayout({'grid-4':null})` TypeError(`null!==undefined` 过守卫后读 `null.cols`),每次渲染都炸整个 tab UI 砖掉;修:守卫加 `!==null && typeof==='object'`。**MED** monitor attach (id=null)切换:按钮显 active 但格仍 live 且发 resize,且不自愈;修:`onSessionId` 到达时 reconcile(re-render 使 startMonitor 生效)。**MED** `renderGutters` 拖拽中被并发 applyLayout 销毁重建丢拖拽监听;修:`draggingGutter` 标志跳过重建。**LOW** grid-presets 硬编码 1024改用 `GRID_MIN_WIDTH`均补回归测试( null-split 不抛reconnect-reconcile拖拽中重渲染 handle 存活)。
- **✅ 全部阶段(v1+v2+v3)完成**:5 commit(06814ba/5475b66/cd97114/007e598 + fix)。每阶段 = 实现→typecheck/test/build 绿→**并行多 lens 对抗式 review workflow + 逐条 verify**→ confirmed复验commitmaximize HIGH 几何缺陷经** headless Chrome(Playwright)实测**证实修复服务端/WS 协议零改动;单格模式行为不变
- **探索产物**: 交互式原型 artifact(single/1×2/2×2 切换点击移焦内联审批)已给用户看过并据此拍板方向
### 🔐 TUNNEL AUTOMATION — 零接触隧道注入(客户永不碰证书/密钥;2026-07-08)
- **计划**: [PLAN_TUNNEL_AUTOMATION.md](./PLAN_TUNNEL_AUTOMATION.md)(design-locked)。目标:host 一条命令 onboarddevice 登录一次即在硬件里生成不可导出密钥CSR拿证书,** .p12/AirDrop,私钥永不离设备**。三轨:A 控制面/PKIB host agentC 原生客户端(iOS/Android/desktop)。基础设施(frps/device-CA/frp-client-CA/nginx:8470 mTLS)已在 VPS M1 上线;本工作流建的是**自动化**(签发端点njs certHost 绑定硬件 keygenhost onboard)。
- **分支**: `feat/tunnel-automation`( develop)。 **MVP fast-path §7**(10 tracked task,见任务表 #1#10),依赖图强制:A1 crypto A2 契约 A3 nginx 绑定为顺序地基,之后按职责(backend/host/iOS)扇出
- **编排(loop 模式)**: 每个 task = 一个 Workflow(TDD builder `typescript-reviewer`+`security-reviewer` 并行交叉验证 自动修 confirmed critical/high orchestrator 独立复验 tests 绿 记本日志 解锁下游)。三个 workflow 并行:A1(backend/crypto)、A2a(backend/契约)、B2h+B3(host/agent),文件互斥不冲突
- **地基三块全绿( orchestrator 独立复验,非仅采信 agent)**:**[x] A1**(x509-assembler+csr-ec;40 测试+openssl verify+tsc 干净)、**[x] A2a**(契约编辑;relay-auth 133 + relay-contracts 85)、**[x] B2h+B3**(frpcToml+frpcBinary;agent 全套 184)。详见下方条目。**[x] A3**(nginx certHost 绑定)DONE orchestrator 已亲自审计 njs 解析器 + zone-anchor 加固(见下方条目)。
- **Wave 2 全绿**: **[x] B1**(frp-client P-256 签发 + host keygen)、**[x] A4**(device-enroll + 会话令牌)。**统一复验**:control-plane 全套 **206/206**`tsc --noEmit` exit 0(B1+A4 合并态干净,交叉噪声已消)。**A4 交叉验证抓到并修复 1 CRITICAL**:enroll 路由曾把客户端传的 `subdomain` 直接烧进 device 叶子 dNSName SAN 且无归属校验account A bearer 可铸 `victim.*` 证书绕过隔离;已修(归属校验 `ownerOfSubdomain==accountId` + 复用 host charset/reserved 规则 + 用规范化后的标签),负例测试覆盖(B 请求 A subdomain403未认领403非法400自有201),orchestrator 已亲读 device-enroll.ts 审计确认
- **Wave 3 三路扇出**(不同职责/,文件全互斥):**[x] C-iOS**(iOS:SecureEnclave keygen + CSR + Keychain 重构)DONE orchestrator 独立复验 `swift build` 干净 + `swift test` **27/27** + **跨语言:真 control-plane verifyCsrPoPEc 接受 Swift 生成的 CSR(ok=true)**;**[x] B5**(host:两服务安装 + BIND_HOST S-GATE + `pair --install`)DONE 独立复验 agent **214/214** + tsc 干净;**[x] A6**(backend:/renew + rotate.ts X.509)DONE 独立复验 control-plane **224/224** + tsc 干净;**已复用 A4 教训**(/renew 身份取自当前 mTLS 证书 + 注册表记录绝不信 body,anti-smuggling 测试覆盖)。**[x] B4**(host 原生 run-loop + frpc 监督 + health probe + keystore P-256)DONE 独立复验 agent **247/247**
- **✅ MVP 10 任务代码完成 + 集成复验全绿(2026-07-08)**:**716 测试跨全部包**(relay-contracts 85 / relay-auth 133 / control-plane 224 / agent 247 / iOS ClientTLS 27),4 TS tsc exit 0,swift 27MVP 分层保证证实(契约auth控制面 签发/注入/续期host agent 安装/运行/keystoreiOS 硬件注入 全共存绿)。
- **交叉验证共抓修 5 个真缺陷(否则静默上线)**:A4 客户端 subdomain 跨租户绕过(CRITICAL)、B5 loopback 守卫绕过 ×2(HIGH)、A6 device 续期不延期(HIGH)、B4 health monitor 永不 healthy + frpc 管道不排空致挂起(HIGH)——每个安全关键修复 orchestrator 均亲自审计
- **[x] 加固 pass DONE**:9 confirmed-review 修复(Ed25519 长度守卫csr-ec 不可变 + timingSafeEqualBytesgetCertSub readTlv 父容器边界[ crafted-DER 回归:修前泄 'evil' 修后 '']、renew 有界限流 + csr max + 当前证书链/过期校验systemd 全路径控制符守卫keystore prime256v1 校验@peculiar/asn1-* 声明为直接依赖)。**最终全绿 734 测试**(relay-contracts 85 / relay-auth 133 / control-plane 235 / agent 254 / iOS ClientTLS 27),4 TS tsc 全干净
- **✅ MVP 代码全部完成(10/10 任务)+ 5 个真缺陷经交叉验证抓修剩余纯属"需真基础设施"**(见下 ACC RUNBOOK):不能在本环境伪造
- **「继续round 集成推进(2026-07-09)**:**[x] #11 frpc tar.gz 解压**(traversal-safe USTAR + frp v0.61.1 sha256 pins;**对真 frp 归档解出的 frpc `tar -xO` 逐字节一致** 14,259,442 字节;agent 254267)。**[x] #14 原生 boot 接线**(boot/native-ca.ts frp-client-CA+device-CA 两个 P-256 CA[DEV 自签/PROD KMS fail-fast];main.ts 组合 issuers 共享一个 DeviceStore + 注册 device-enroll + renew 路由 + anchors + 生产 fail-closed 断言;Ed25519 relay /enroll 原样不动)。**wired app e2e 证实**(integration-native.test.ts 6 测试,fastify inject):device mint→/device/enroll(自有 alice)→201 真叶子链到 device-CA + dNSName /device/:id/renew201 subdomain;归属门 e2e:未认领403跨租户 B-on-A403 token401;host 叶子→/renew201 链到 frp-client-CA不信任 CA401。**[x] #15 原生 host /enroll P-256 arm DONE**:/enroll CSR 密钥类型分叉(EC-P256原生 frp-client 叶子 via 已接线 hostSigner;Ed25519relay 原样);**把安全关键的配对门 `gateAndConsumePairingCode` redeem.ts 抽出供两臂共用**(单次 CAS+锁定+过期+PoP/防替换 顺序不变,relay 逐字节证实不变);**anti-smuggling 再守**(dNSName SAN 只来自服务端分配的 subdomain,body subdomain 惰性——负例证实);hostContentSecret:nullhost onboard e2e 证实:配对码→/enroll(EC CSR)→201 叶子链到 frp-client-CA + dNSName /renew201 subdomain;单次重放/防替换负例绿。**当前全绿 758 测试**(cp 246 / agent 267 / relay-auth 133 / relay-contracts 85 / iOS 27),4 TS tsc 全干净
- **🎯 后端 onboard 全链路已接线 + wired-app e2e 证实**:host enroll + device enroll + host/device renew + 归属隔离门 + 链校验 全部跑通(fastify inject)。**代码侧至此完成**;剩余小对齐(#16:agent pair.ts 解析原生响应形状生产 NODE_ENV 守卫machineId 去重需 relay-contracts 加列)+ 真基础设施(KMS/nginx 部署/真机 iOS/ VPS frps)。
- **ACC RUNBOOK(#10; VPS `8.138.1.192` + iPhone + KMS,不可本地伪造)**:
1. **KMS/CA 物料**:为 frp-client-CA(P-256)+ device-CA(P-256)provision KMS 密钥,`buildCaSigner` fail-fast 需真 KMS ref;boot 装配 frpclient-issue/device-issue/rotate 用真签发器
2. **接线(#14)**:control-plane main.ts 注册 device-enroll + renew(**并传 hostCaAnchorsDer/deviceCaAnchorsDer**,否则链/过期校验静默 no-op)+ device-grant/session
3. **nginx(部署,安全关键)**:装 getCertSub.js /etc/nginx/njs/ + main-context `load_module ...js_module.so`;frp-mtls.conf 已有 certHost map;renew 若走 header 认证,**nginx 必须由 $ssl_client_cert x-client-cert strip 客户端自带**。
4. **frpc 真二进制(#11.9)**:B3 tar.gz 解压 + 换真 sha256 校验和(frp 发布 checksums),host `run` 才能真监督 frpc
5. **host onboard**:真 Mac/Linux `pair <code> --install`(BIND_HOST loopback S-GATE 已守)→ systemd/launchd unit `https://<sub>.terminal.yaojia.wang` 可达
6. **iOS 真机**:Apple entitlement + 物理设备构建 App( ClientTLS enroll seam,#12)→ 登录 SE keygen enroll mTLS
- **已在本环境证实(无需 VPS)**:真 nginx+njs docker 跨租户 **403 / 自有 200 / no-cert 400**;Swift CSR 被真 control-plane verifyCsrPoPEc 接受;BIND_HOST S-GATE fail-closed;734 单元/集成测试。**剩余为真机/真网/ KMS 验收。**
- **⚠ B5 评审揭出两个真集成缺口( task #13,阻塞完整 host-side ACC)**: keystore.loadIdentity 仍写死 Ed25519不认 B1 `alg` 判别式 存的 P-256 身份加载错;② cli.ts `run` 对所有 host 无条件走 legacy runTunnel(relay rendezvous)→ 原生 host 未监督 frpc( B4/H4 原生 run-loop, 10-task 分解从 B4 跳到了 B5)。这两块 + frpc tar.gz 解压(#11.9) host 真正打通隧道所需。**A2b 会话令牌子系统(logindevice:enroll token)已并入 A4**——它是门控所有 device 注入的唯一 bootstrap 凭据,过于安全敏感,orchestrator 在建 A4(消费该 token 的端点)时亲自设计(计划允许 MVP stub OAuth / 手贴 pairing code)。
- **HARDENING backlog(task #11,非阻塞,confirmed review findings 延后)**: A1 Ed25519 签名长度守卫`@peculiar/asn1-*` 声明为直接依赖csr-ec `UNIFORM_FAILURE` 改每次新字面量复用 `timingSafeEqualBytes`signer/sigAlg 运行时交叉校验;B3 —**tar.gz 解压(frp .tar.gz;当前校验+放置的是压缩包非内层 frpc 二进制,B5 pair --install 端到端前必须补)**、把占位 sha256(64 0)换成 frp 发布校验和
#### [x] A2a — 契约编辑: `enroll` right + SPIFFE `/device/` arm + `spiffeIdFor(kind)`(FIX C-native-1, H-cp-2, H-native-6;2026-07-08)
- **交付**: 向后兼容的冻结契约编辑(**会话令牌子系统刻意不在本轮**)。① `relay-contracts/src/capability/token.ts`:`CapabilityRight` 联合类型 + `CapabilityRightSchema` z.enum 各加 `'enroll'`(`relay-auth/capability/issue.ts` 自动接受,已由 issueverify 测试证实)。② `relay-auth/src/types.ts`:`SPIFFE_ID_RE`+`SpiffeIdSchema` `(?:host|device)` 同时接受两 arm,保留 `..`/`*` 拒绝;host 形式仍原样通过。③ `relay-auth/src/agent/spiffe.ts`:`spiffeIdFor(accountId, id, domain?, kind:'host'|'device'='host')`——现有 3-arg 调用者(`control-plane/ca/issue.ts`)不变;`PARSE_RE` 捕获 kind;`parseSpiffeId → {accountId,id,kind}`;`index.ts` 导出 `SpiffeKind`。④ `relay-auth/src/agent/verify-mtls.ts`:新 parse 形状 + **host-only kind 守卫**(`not_host_cert`)——`/device/` 证书永不能在 host 控制信道被接受( device-id hostId 碰撞跨租户),**收紧**信任而非放宽`relay.` trust-domain 前缀刻意保留( FIX H-2 延后;强制点是 dNSName SAN SPIFFE URI)。
- **测试()**: `relay-auth/test/spiffe.test.ts`(host 向后兼容;device arm;**cross-track**:CP 签发端将来盖的原始 device SAN 能被 relay-auth schema+parser 接受且等于共享 builder 输出签发方/验证方不漂移;malformed/traversal/wildcard/unknown-kind 拒绝)、`relay-auth/test/mtls.test.ts`( 29 round-trip 断言改 `{accountId,id,kind}` 锁步 + device 证书在 host 信道被拒)、`relay-auth/test/capability.test.ts`(issueverify `rights:['enroll']`)、`relay-contracts/test/capability-pairing.test.ts`(`parse('enroll')` ok / 未知仍抛)。
- **验证(orchestrator 独立实测,非仅采信 agent)**: TDD RED-first(6 relay-auth + 2 relay-contracts 先红)。`cd relay-auth && npx vitest run` **133 passed**;`cd relay-contracts && npx vitest run` **85 passed**;`git diff` 复核 3 处信任关键 hunk(regex 非捕获组无放宽 / verify-mtls 守卫为收紧 / token 加法式)。交叉验证:两评审均 **approve**, 2 LOW(`m[2]! as SpiffeKind` 断言可换运行时守卫;trust-domain 清理延后)——不阻塞
- **偏差/决策**: `parseSpiffeId` 返回 `{accountId,id,kind}` hostId 别名(锁步更新唯一调用者 verify-mtls);新增 `not_host_cert` 防御分支。**阻塞**: 。**下一步**: A4 device-issue `spiffeIdFor(accountId, deviceId, trustDomain, 'device')` device SAN
#### [x] A1 — x509-assembler + csr-ec(KMS-signed 真 X.509 + P-256 PoP;FIX C-1;2026-07-08)
- **交付**: 单一签发原语,附加式(不改 issue.ts/rotate.ts,那是 A6)。① `control-plane/src/ca/x509-assembler.ts`(NEW):`assembleCertificate(input)` `@peculiar/asn1-x509` schema v3 TBSCertificate + `AsnConvert.serialize`(不手写整证 DER),**序列化后的 TBS** `CaSigner.sign(tbsDer)` (KMS 边界——原始 CA 私钥永不载入,INV9),再包成 Certificate(嵌入 exact `tbsCertificateRaw`)。两签名族:`ed25519`(OID 1.3.101.112,raw 64B BIT STRING) `ecdsa-p256`(OID 1.2.840.10045.4.3.2,导出 `normalizeEcdsaSignatureToDer` raw P1363 rs DER ECDSA-Sig-Value,正整数前导零处理; DER 则透传);tbs.signature 与外层 signatureAlgorithm 同一 OIDsubject pubkey CryptoKey SPKI DER。② `control-plane/src/ca/csr-ec.ts`(NEW):`verifyCsrPoPEc(csr)→{ok,embeddedPubSpki}` 解析 P-256 PKCS#10强制 EC P-256验自签 PoP,**fail-closed + uniform**(malformed/ P256/坏签统一 `{ok:false, []}`);`buildCsrEc()` 测试助手。③ `boot/ca-wiring.ts`(EDIT):加 `inProcessP256CaSigner`(TEST/DEV P-256 CA,返回 raw P1363)。
- **验证(orchestrator 实测)**: `npx vitest run test/x509-assembler.test.ts test/csr-ec.test.ts test/ca.test.ts test/interop.test.ts test/rotate.test.ts` **5 files / 40 passed**;`npx tsc --noEmit` exit 0;全套 132 passed 无回归5 个验收门全过:(a) Ed25519 P-256 叶子各 `new x509.X509Certificate(der)` 重解析;(b) 各自对 CA 公钥验签 + CA 负例;(c) **SAN 含 dNSName `alice.terminal.yaojia.wang` + URI `spiffe://…/host/alice` 双值 round-trip**(A3 njs 要解析的正是这些字节);(d) 合法 CSR ok:true篡改/垃圾/Ed25519/P-384 统一 ok:false;(e) **真 openssl 3.0.18 `x509 -text` + `verify -CAfile ca.pem leaf.pem` → OK**
- **交叉验证**: ts + security 两评审,verdict warn/approve,**0 confirmed critical/high**(未触发自动修);9 medium/low 记入 **task #11 HARDENING**(依赖声明Ed25519 长度守卫UNIFORM_FAILURE 不可变signer/sigAlg 交叉校验等,均非阻塞)。**阻塞**: 。**下一步**: B1 `frpclient-issue.ts` / A4 `device-issue.ts` `assembleCertificate` 出叶子;设备/host P-256 CSR `verifyCsrPoPEc`
#### [x] B2h+B3 — 原生 frpc.toml 写入器 + 固定版 frpc 校验下载(host-agent;2026-07-08)
- **交付**(严守 lane,未碰 service/installclikeysCAcontrol-planerelay-*)。① `agent/src/transport/frpcToml.ts`(NEW):`buildNativeFrpcToml(opts)` frp v0.61 TOML(PLAN_NATIVE_TUNNEL §4:serverAddr 默认 8.138.1.192/可配serverPort 443transport.tls.enable/serverName=frp.terminal.yaojia.wang/disableCustomTLSFirstByte/certFile/keyFile/trustedCaFile、auth.token、`[[proxies]]` type=http subdomain/localIP=127.0.0.1/localPort);**反 SSRF 硬不变量**:localIP loopback `FrpcTomlError`(负例测试);边界校验 DNS-label subdomainport 1-65535非空路径/token控制符拒绝Windows 路径转义;弃用 v0.8 `[common]/tls_enable`(断言不存在)。② `agent/src/provision/frpcBinary.ts`(NEW):`detectFrpcPlatform`(darwin/linux×arm64/amd64)、`FRPC_RELEASES` 固定 {version,url,sha256}、`provisionFrpc` buildBinary 纪律:fetch写临时到盘→**SHA-256 校验先于 place/exec**→符合 chmod 0755+原子 rename不符 rm 临时+throw(什么都不放);fetch/fs/sha256 皆可注入,默认 fetch 强制 https( -k)。
- **验证(orchestrator 实测)**: `cd agent && npx vitest run test/frpcToml.test.ts test/frpcBinary.test.ts` **18 passed**;全套 184 passed;tsc exit 0TDD RED-first 确认
- **交叉验证 + orchestrator 已修**: 评审 verdict warn,2 confirmed(未达自动修的 critical/high ,orchestrator 亲手补):**MEDIUM** `defaultFetch` `fetch()` 未包 try/catch网络失败抛未类型化错(已包成 `FrpcProvisionError`);**LOW** 固定临时名 TOCTOU(已改 `${TMP_NAME}.${pid}.${randomBytes}` 每次唯一)。修后 frpcBinary 7/7 仍绿。**延后(task #11)**: **tar.gz 解压(当前放置的是压缩包,B5 端到端前必补)**占位 sha256 换真校验和可选签名校验。**阻塞**:
#### [x] A3 — nginx cert→Host 绑定(FIX C-native-3;单一承重租户隔离控制;2026-07-08)
- **交付**: `ssl_verify_client on`(device-CA)之上,njs 解析叶子 dNSName SAN,`map` 要求其最左标签 == 请求 SNI 最左标签否则 `return 403`,关闭 Model A skeleton-key。① `deploy/nginx/njs/getCertSub.js`(NEW):纯函数 `extractLeftmostDnsLabel(pem)`——手写 base64→**定长 DER 遍历** SAN(OID 55 1D 11)→取第一个 dNSName(0x82,而非 spiffe URI)→最左标签;主机名字节限 `[0-9A-Za-z.-]`( `:` map-key 走私);**任何** 畸形/ SAN/截断 DER `''`(fail-closed,空值永不满足 map)。只用 njsNode API,故同一源码 Node 单测 + njs 生产同跑。② `deploy/nginx/frp-mtls.conf`(EXTEND):`js_import`+`js_set $cert_sub`+**zone-anchored map** `"~^(?<s>[^:]+):(?P=s)\.terminal\.yaojia\.wang$"`+单个 `if ($cert_host_ok = 0) { return 403; }`(规则在 map,不散 if)。③ docker 集成测试 `deploy/nginx/test/integration/{Dockerfile,run.sh,…}`
- **验证(orchestrator 亲自审计 + 独立实测)**: 亲读 getCertSub.js 全文确认解析器结构正确且真 fail-closed(PEM body `CERTIFICATE-----` 尾防头字母泄入 base64拒不定长/超长长度结构化走 TBS→[3]→ExtensionsSAN)。`npx vitest run test/getcertsub.test.ts` **22 passed**(含真 A1-assembler 铸的 P-256 叶子);全套 control-plane 153 passed;tsc exit 0。** nginx+njs docker 集成 5/5**:alice-cert@alice=200、**bob-cert@alice=403(跨租户负例)**、alice-cert@bob=403、bob-cert@bob=200、no-cert=400。
- **orchestrator 加固(评审 confirmed MEDIUM,亲手补)**: map 原为 label-only `(?P=s)\.`,不锚全 zone——虽被 stream SNI 路由 + `server_name` 正则**双重**约束(实际不可利用),仍改为**锚定全 zone `.terminal.yaojia.wang$`** 使 map **自足**(不依赖 server_name 正确性), `alice.evil.com→deny` 断言(label-only 会误放)。评审 verdict approve/warn,自动修移除了 security 评审遗留的 scratch 测试文件(曾破 tsc);2 LOW(readTlv 父容器边界critical=true 覆盖) task #11(均无利用路径,cert 已过 ssl_verify_client)。**阻塞**:
#### [x] B1 — frp-client P-256 host 叶子签发 + host P-256 keygen/CSR(FIX H-host-2/H-host-4;2026-07-08)
- **交付**: `control-plane/src/ca/frpclient-issue.ts`(NEW)`createFrpClientLeafSigner` 仿 issue.ts 形状但**P-256**:CSR `verifyCsrPoPEc`(A1)host 注册表门控(bound/active/non-revoked + 嵌入 EC-SPKI==注册 pubkey,uniform `LeafSignError`,拒绝时 CA `sign()` 不可达)、 `assembleCertificate`(A1)`sigAlg:'ecdsa-p256'` + frp-client-CA ;SAN = dNSName `<sub>.terminal.yaojia.wang`(A3 强制点)+ URI `spiffeIdFor(accountId,sub,trustDomain,'host')`;CA:falseEKU clientAuthKU digitalSignature、[now-60s,now+24h]。② `agent/src/keys/identity.ts`(EXTEND) `alg:'ed25519'|'p256'` 判别式 + `generateP256Identity`/`p256IdentityFromPrivatePem`(私钥永不出机);③ `agent/src/enroll/csr.ts`(EXTEND)P-256 ecdsa-with-SHA256 PKCS#10,Ed25519 路径逐字节不变
- **验证(orchestrator 独立复验,A4 并发期隔离跑)**: control-plane `frpclient-issue.test.ts` **10/10**(叶子 re-parse + frp-client-CA 验签SAN 双值 + relay-auth parseSpiffeId kind 'host'、EKU/CA:falsegate uniform 拒绝、** agent enroll/csr 铸的 P-256 CSR verifyCsrPoPEc pubkey 逐字节流入叶子**);agent `identity/csr/cli` **27/27**(全套 194/194)。my-files tsc 干净
- **交叉验证**: 评审 approve/warn;自动修 no-op(唯一 "high" **误判**——实为并发 A4 `DeviceLeafSignerDeps.devices` tsc 缺失,fix agent 正确拒碰 A4 文件)。B1 自身 findings LOW( #11):**frp-client 签发器尚未接入 enroll 路径(redeem.ts:110 仍用通用签发器)**、gate step-2 单测缺cli.test `as never`。**偏差**:AgentIdentity 加必填 `alg` 判别式(改了一处 mock);P-256 pubkey EC SPKI DER(匹配注册表比对)。**阻塞**: frps refused-without-cert bring-up 未本地跑( frps 二进制;已在 VPS M1 证实)→ VPS 验收
#### [x] A4 — device enrollment + 最小会话令牌子系统(FIX C-2, C-native-1;2026-07-08)
- **交付**(control-plane, deny-by-default / uniform-reject / immutable / Zod-boundary)。① `auth/session.ts`:`mintDeviceEnrollToken`/`verifyDeviceEnrollToken`/`loginToAccountId`(单租户 STUB seam)/`requireEnrollRight`;device:enroll = §4.3 capability token(`rights:['enroll']`,sub=accountId,aud='device-enroll',10m TTL [60s,60m]), relay-auth `signPaseto` (**** `issueCapabilityToken`——后者硬夹 60s connect clamp + 要求 host/DPoP,"独立于 connect clamp 的无 host 分钟级 enroll token"[FIX C-native-1] 结构冲突;同一 Ed25519 v4.public 原语 + §4.3 body,冻结验证器原样接受)。② `registry/devices.ts`:`DeviceRecord`+`DeviceRegistry`+`createMemoryDeviceStore`(镜像 hosts.ts,INV8 版本化状态原子交换)+ 每账户 CAP + 滑窗 rate-limit。③ `ca/device-issue.ts`:P-256 device 叶子经 assembleCertificate,注册表门控(verifyCsrPoPEc + bound/active/non-revoked + pubkey-match,uniform),SAN dNSName + `spiffeIdFor(...,'device')`,EKU clientAuth/CA:false。④ `api/device-enroll.ts`:`buildDeviceEnrollRouter`(可注册 fastify 插件),POST /device/enroll + /device/attest/challenge(stub);401/403/429/400 uniform。⑤ `store/ports.ts` ADD-ONLY DeviceStore 端口
- **交叉验证抓到并修复 CRITICAL**(两评审均 block): enroll 路由曾把客户端 `subdomain` 直烧进叶子 dNSName SAN **无归属校验 + charset/reserved 限制** account A bearer 可铸 account B `victim.terminal.yaojia.wang` 证书,A3 nginx 会放行 **击穿整个隔离模型**自动修已闭合:`accountId` 只来自已验 token(`token.sub`);`normalizeSubdomain`+`isValidSubdomain`(复用 host onboarding 规则)+ 归属门控 `ownerOfSubdomain(sub)===accountId`(否则 403;未认领与外账户均 403,无存在性 oracle);SAN 用规范化后标签(非原始客户端字段)。
- **验证(orchestrator 独立 + 亲审)**: 亲读 `device-enroll.ts` 确认修复正确;`grep` 确认负例测试覆盖(B 请求 A subdomain403 line190未认领403非法400wrong-right bearer403自有201)。**统一 `npx vitest run` 25 files / 206 passed**;`npx tsc --noEmit` exit 0(含并发 B1 frpclient-issue.ts,无交叉噪声)。
- **偏差/延后( #11)**: enroll token `cnf.jkt` 是随机占位符 **DPoP 绑定推迟,当前是 bearer token**5 bootstrap 滥用仅靠 TTL+cap+rate 缓解,符合 MVP 计划);cap/rate registerDevice 非原子(TOCTOU MEDIUM);rate-limit CSR PoP 之后(LOW);device.* 审计未加(共享 enum,推迟);DeviceRecord 类型 + 内存 store 暂放 registry/devices.ts(model/records.ts + store/memory.ts 本任务冻结)。插件未接入 main.ts(集成步骤)。**阻塞**:
#### [x] C-iOS — SecureEnclave keygen + 手写 CSR + DeviceEnrollmentClient + Keychain enroll 重构(FIX C-native ClientTLS trap;2026-07-08)
- **交付**(全在 `ios/Packages/ClientTLS` leaf ——身份/mTLS 类型所在处,仅依赖 Security/Foundation;**全程 SecKey/Security.framework,避开 ClientTLS 陷阱**:不用 CryptoKit SecureEnclave.P256,不用 SecKeyCreateWithData-over-SE(-25300))。① `SecureEnclaveKey.swift`(NEW):`SecKeyCreateRandomKey` + `kSecAttrTokenIDSecureEnclave` + ECSECPrimeRandom(256) + `kSecAttrIsPermanent` + `SecAccessControl(.privateKeyUsage)`;私钥不可导出/永不出机;Simulator 有软件 fallback + SE-unavailable 错误。② `CertificateSigningRequest.swift`(NEW):手写 canonical DER PKCS#10(id-ecPublicKey+prime256v1 SPKI),`SecKeyCreateSignature(.ecdsaSignatureMessageX962SHA256)` 自签,sig OID ecdsa-with-SHA256。③ `DeviceEnrollmentClient.swift`(NEW):POST /device/enroll(bearer + A4 body + base64 CSR)+ attest/challenge + /device/:id/renew seam + `EnrollmentResult.isRenewalDue` 轮换 seam。④ `KeychainClientIdentityStore.swift`(MOD):加 `enroll()/renew()/remove()` —— SE key`SecItemAdd(kSecClassCertificate)` 存回叶子`loadIdentity` `SecItemCopyMatching(kSecClassIdentity)`,使 **MutualTLSChallengeResponder/ClientIdentity/urlCredential 原样复用**;`loadIdentity` 优先 SE 身份回退 legacy .p12(双信任迁移窗);保留 PKCS12Importer
- **验证(orchestrator 独立实测)**: `swift build` 干净;`swift test` **27/27**(CSR 自签经 `SecKeyVerifySignature` + SPKI/alg OID 逐字节 + PKCS#10 三元素结构 + enroll 请求映射 + saveloadIdentity roundtrip + 既有 mTLS responder 测试仍过)。**跨语言金标准**:Swift 生成的真 CSR 喂给真 control-plane `verifyCsrPoPEc` **ok=true**(91 字节 P-256 SPKI)。SourceKit 曾误报"cannot find type"——已证伪(build 干净);`ZZEmitCSRCrossCheck.swift` 是一次性交叉验证临时文件,已删(仅剩 .build 索引残留)。
- **交叉验证**: swift + security 两评审 verdict warn,**0 critical/high**;security 评审**正向确认属性 15 成立**(私钥不可导出/bearer 仅用于 enroll 请求/PoP 是真 SE 签名/轮换干净替换/SE-优先回退)。findings medium/low #12(2 `as!` 强转corrupt-record、**App enroll UI 注入 + URLSessionHTTPTransport 适配**)。**真机 SE keygen + SecIdentity roundtrip 需物理设备 + Keychain entitlement 设备验收**。**阻塞**:
#### [x] B5 — 两服务安装 + BIND_HOST loopback S-GATE + `pair --install`(FIX C-host-1/M-host-2service/L-host-zone;2026-07-08)
- **交付**(agent ,复用模块全 import 不改)。① **BIND_HOST S-GATE(FIX C-host-1,CRITICAL)**:`normalizeBindHost()`+`BindHostError`;缺省127.0.0.1, loopback(0.0.0.0/LAN IP/::); `buildInstallOptions`( env) `installService`(任何 write )双重强制,拒绝的安装零输出。② **两个 unit(FIX M-host-2service)**:参数化 launchd/systemd(label/ExecStart/programArguments/description),`installService` base-app(`node dist/server.js` + base-app env) agent(`<bin> run` 监督 frpc base-app env),`uninstallService` 双拆。③ **zone terminal(FIX L-host-zone)**:`assertNativeZone()`,base-app origin `https://<sub>.terminal.<domain>`(复用 originConfig,不削弱)。④ **cli `pair <CODE> --install`**:generateP256Identity(FIX H-host-2)→saveIdentityenrollNative(P-256 CSR + POST /enroll)→provisionFrpc(B3)→writeFrpcConfig(buildNativeFrpcToml)→installService( unit)→打印 URL;legacy `pair`( --install)不变
- **交叉验证抓到并修复 HIGH×2(loopback 守卫绕过,同一 bug class)**:`isLoopbackBindHost`(install.ts)**姊妹 anti-SSRF 守卫** `isLoopbackWsUrl`(agentConfig.ts)都用 `startsWith('127.')` `127.0.0.1.attacker.example.com`/`127.evil.net` 被当 loopback(Node DNS 解析再 bind 正是 S-GATE 要防的绕过)。自动修抽出共享 `agent/src/net/loopbackLiteral.ts`(`node:net isIPv4` 要求整串是 dotted-quad + 首段 127, localhost/::1/[::1],余皆拒),两处守卫同修
- **验证(orchestrator 独立 + 亲审)**: 亲读 `loopbackLiteral.ts` 确认严格 fail-closed;`grep` 确认绕过回归测试在(`normalizeBindHost('127.0.0.1.attacker.example.com')`/`127.evil.net` + 专门 loopbackLiteral.test.ts)。**agent 全套 `npx vitest run` 30 files / 214 passed**;`tsc --noEmit` exit 0;S-GATE 负例确认 0 0 运行
- **偏差/延后**: 原生 `run` frpc 监督 = B4/H4(未建,记 **#13**);keystore.loadIdentity P-256 分支( #13);frpcBinary 占位 sha256(#11.9);systemd `Environment=` 注入守卫一致性(MEDIUM)+ BASE_APP_ENV_KEYS SCROLLBACK_BYTES/MAX_PAYLOAD_BYTES(LOW)+ cli 若干 minor 记入 backlog。**阻塞**: (B5 本体完成;端到端 host 隧道待 #13)。
#### [x] A6-min — POST /renew(host)+ /device/:id/renew + rotate.ts 真 X.509(FIX H-host-3;2026-07-08)
- **交付**: `ca/rotate.ts`(UPGRADE) DEV JSON 占位,`createLeafRenewer` 委托 P-256 签发器(frpclient-issue/device-issue assembleCertificate,sigAlg ecdsa-p256,CA 签在 KMS )出真 X.509;`renewHostLeaf(hostId, subjectPubkey, csr)` + `renewDeviceLeaf(deviceId, csr)` `RenewedLeaf{cert,caChain,notAfter}`(notAfter cert 读回)。② `api/renew.ts`(NEW)fastify 插件:POST /renew(host)+ POST /device/:id/renew,**仅由当前 mTLS 客户端证书认证**(可注入 PresentedClientCert seam,默认从 x-client-cert base64-DER);身份(accountId + subdomain/deviceId + subject pubkey)**重解析当前证书的 SPIFFE SAN**(relay-auth parseSpiffeId),**绝不取自 body**(body {csr})。**anti-smuggling(A4 教训)**:重签 SAN 从注册表记录取同一 subdomain;把当前证书 pubkey subject key 使委托门控强制 MVP 同密钥(不换钥)。每身份滑窗限流uniform:无/坏证书 401撤销/未知/账户或 deviceId 或密钥不符 403换钥/ CSR 400超限 429成功 201
- **交叉验证抓到并修复 HIGH**: `renewDeviceLeaf` 曾每次复用**原始** notAfter(读静态 DeviceRecord.notAfter,enroll 时设一次)→ 续期不延期最终发已过期证书自动修:续期先让注册表对 active 设备重算+持久化 `now()+ttl` 再签,notAfter 从发出的 cert 读回
- **验证(orchestrator 独立 + 亲查修复)**: 亲读 rotate.ts 确认 device 续期先延期;**统一 control-plane `npx vitest run` 26 files / 224 passed**;`tsc --noEmit` exit 0;rotate.test 升级为断言真 re-parse+CA 验签的 X.509(host+device subdomain换钥拒撤销拒no-smuggling),renew.test 13
- **延后( #14,上线前必办)**: buildRenewRouter 未接 main.ts;**部署安全:nginx 必须由 $ssl_client_cert x-client-cert strip 客户端自带的**(否则头可伪造身份);当前证书链/过期校验 + "过期证书" 测试;rate-limiter Map 有界化;csr max 长度。**阻塞**:
#### [x] B4/H4 — 原生 run-loop(frpc 监督 + loopback health)+ keystore P-256 loadIdentity(2026-07-08)
- **交付**(agent )。① `keys/keystore.ts` loadIdentity 按存储密钥 alg 分支(`createPrivateKey(pem).asymmetricKeyType`:ed25519原路径逐字节不变;ecp256IdentityFromPrivatePem)——存的 P-256 frp-client 身份能 round-trip(alg:p256 + 可用 ECDSA 签名钥)。② `health/probe.ts`(NEW):4 个可注入 seam 子检查(frpc alivebase-app loopback GET http://127.0.0.1:PORTfrpc 'start proxy success' 日志扫描cert 未近过期 8h )、`renderHealthStatus` 只印非敏感标识(subdomain/host id/过期 ISO/布尔,绝无 key/cert/token/CSR,INV9)、`startHealthMonitor`。③ `transport/frpSupervise.ts`(NEW——计划标 REUSE 但从未存在):spawn frpc + backoff 重启(1s..cap30s,稳定 60s reset),IO 全注入。④ `cli.ts` run 分支:P-256 身份 + 写好的 frpc.toml superviseFrpc;否则 legacy runTunnel(保留)。
- **交叉验证抓到并修复 HIGH**: `readFrpcLog` `<stateDir>/frpc.log` **无人写它** 恒返 '' HealthReport.healthy **永不为真**(H4 监控形同虚设); frpc 子进程 stdio pipe 不排空 缓冲填满致 write(2) 阻塞挂起自动修:`createFileLoggingSpawn` frpc stdout/stderr tee 进持久化(每次 spawn 截断)日志,同时排空管道(一并解决挂起 MEDIUM)。
- **验证(orchestrator 独立)**: agent 全套 **247/247**(29+ 新:probe 19frpSupervise 5keystore/cli),tsc 干净;keystore P-256 round-trip 断言(alg + pubkey 验签);status 无敏感泄漏断言( PRIVATE KEY/CERTIFICATE/token/csr)。
- **偏差/延后**: frpc 二进制运行仍待 B3 tar.gz 解压(#11);frpc-stdoutlog 已由 tee 打通(生产可用);keystore 仅按 asymmetricKeyType='ec' 分支未细究 namedCurve(LOW, #11);cli args.code! runCli 内守卫(LOW)。**阻塞**:
### 🤖 ANDROID CLIENT — 与 iOS 对齐的原生客户端(计划 + 地基已落;2026-07-08)
- **计划**: [ANDROID_CLIENT_PLAN.md](./ANDROID_CLIENT_PLAN.md)( agent 探索 iOS综合4 视角评审终版)。镜像 iOS SPM 包为 Gradle 模块;36 任务 AW0AW6三个开放问题已定:R2(Termux terminal-view/emulator **Apache-2.0** + JitPack, GPL-阻塞,独立验证更正)、R1(接受 FCM 尽力而为后台送达)、R7(服务端用 `google-auth-library`)。
- **本机环境约束**: JDK+Gradle 可用, **Android SDK 为空** ** Kotlin/JVM 模块**能真构建验证;Android 框架层(`:app`/`:terminal-view`/UI/FCM)只能写待有 SDK 的机器构建
- **[x] AW0 + AW1( Kotlin 地基)DONE commit `4ea8f78`**: 一个实施 Workflow(A1 脚手架A2 冻结 `:wire-protocol` 契约A3 fakes 并行 A4/A5/A6/A8/A9/A10 统一 Verify 3 路评审)。**验证(实测)**: `./gradlew clean test` = **218 测试 0 失败**(wire-protocol 47 / session-core 60 / api-client 69 / client-tls 27 / test-support 15),clean 重建可复现
- 模块:`:wire-protocol`(冻结契约 + HostEndpoint CSWSH origin 派生 + 手写 codec 逐字节对齐 `JSON.stringify`)、`:session-core`(ReconnectMachine 退避/PingScheduler/GateTracker 双行 epoch 守卫/AwayDigest/TitleSanitizer/**KeyByteMap 逐字节匹配 `public/keybar.ts`**)、`:api-client`( REST + Origin-仅-守卫 + prefs 未知键保留 + pairing 分级)、`:client-tls`(纯半:PKCS12/别名逻辑/证书摘要)、`:test-support`(fakes)。
- **评审 3 视角各 8/10;findings 已修 + 11 回归测试**:sessionId JSON 注入转义(deep-link 攻击面)、PingScheduler CancellationException 传播PairingProbe 超时关闭泄漏HostEndpoint trimHostClassifier 提升 `:wire-protocol` 统一类型Pkcs12 错密码判定 BouncyCastle 可移植lone-surrogate 转义
- **待办**: AW2+(SessionEngine/wiring 冻结)、AW3(`:terminal-view` Termux 封装, SDK)、AW4 UI AW5 push/mTLS 框架半 + **A33 服务端 FCM**(`/push/fcm-token` 路由现缺,api-client 已标注)。这些需在有 Android SDK 的环境构建验证
### 🚧 RELAY-PHASE1 — 把原生 rendezvous-relay 部署到单台 VPS(8.138.1.192,阿里云;2026-07-06)
- **目标**: DEPLOY_RELAY §4 **Phase 1「1b 全量持久化 staging」**VPS Docker 自建 Postgres+Redis;已备案域名走 443 + Let's Encrypt;agent 跑在操作者本机向外拨号完整文件级方案见 [PLAN_RELAY_PHASE1.md](./PLAN_RELAY_PHASE1.md)
- **进度**: **Wave A/B/C/D/E 代码全部完成并通过 Verify** —— 一个后台 Workflow(12 agents,0 error)跑完 A2B1B5B6CD1EVerify 门槛:**四个包 tsc 全干净可构建包全 build 成功314/314 测试通过**(control-plane 103 / relay-run 74 / agent 137 + relay-web 118)。对抗式安全评审:**所有硬不变量 PASS**(INV2 opaque spliceOrigin/CSWSH 精确匹配mTLS INV14 registry 门控token-mint 密码学撤销 INV12jti 单次参数化 SQL)。
- **各任务**: A2 P3 server 入口+Redis 总线 `[x]`;B1 共享 store EnforceDeps `[x]`;B2 registry-backed MtlsVerifier `[x]`(异步阻抗由 B5 `bridgeAsyncMtls` per-DER 预取桥无锁解决);B3 store RouteResolver `[x]`;B4 撤销订阅 `[x]`;B5 `main-phase1.ts` 组合入口 + staging `/auth/mint` 签发 `[x]`;C agent `dist/cli.js` 构建+`runTunnel` 运行入口 `[x]`;D1 同源托管 relay-web `[x]`;E systemd+脚本+RUNBOOK `[x]`;**B6 relay-web 登录/DPoP `[~] PARTIAL`**。
- **B7 收尾 `[x]` DONE**: 功能阻塞已闭环 `browser-server.ts` 现从 `term.dpop.<b64u>` 子协议读 DPoP proof `UpgradeRequest.dpop`(头优先否则回退子协议),真实浏览器可通过 DPoP 。② F1 `/auth/mint` Redis IP 限流(HMAC 盐哈希 key,burst5/refill0.2,早于口令比对,耗尽 429);F2 `activeSessionCount` 接真实同租户在线 WS 计数;F5 错误日志只记 `e.message(+.code)` DSN 泄漏relay-run `npx vitest run` **10 files / 92 passed**tsc 干净。**F3/F4(撤销后 agent 空转重连slowloris) LOW Phase 2 backlog。**
- **⚠ 端到端仅可在 VPS 验证**:浏览器登录tokenDPoPupgradeagent 拼接,本地只到类型/单元层E2E5 远端执行见 `deploy/RUNBOOK.md`( VPS 访问权)。
- **远端( VPS 执行, `deploy/RUNBOOK.md`)**: DNSLet's Encrypt 签证私有 enrollment CA + capability keypair 生成阿里云安全组放行 443/AGENT_PORTsystemd 起两个服务端到端 enrolldial浏览器点进 shell
- **未做/推迟到 Phase 2**: KMSF6 replayWebAuthn step-up(staging `NO_STEPUP_POLICY`)、通配多租户F3/F4
- **未做/推迟到 Phase 2**: KMS(dev signer 暂用)、F6 replay(`loadReplay` fail-closed)、WebAuthn step-up(staging `NO_STEPUP_POLICY`)、通配多租户
#### [x] A1 — Postgres store 适配器(P3)+ 迁移运行器(2026-07-06)
- **交付**: `createPgStores(query): Stores` 9 store 端口映射为参数化 SQL(零字符串拼接);`runMigrations(query)` 按文件名顺序跑 `db/migrations/*.sql`(幂等)。
- **文件(全在 Owns )**: `control-plane/src/store/pg.ts``src/db/migrate.ts``db/migrations/0002_routes.sql`(新增 0001 routes )、`test/store/pg.test.ts`
- **语义对齐 memory.ts**: INV8 accounts/hosts `swapStatus` 用单条 **writable CTE**(UPDATE 指针 + INSERT 版本行)原子完成,无显式事务;重复插入捕获 pg `23505``throw duplicate`;`casRedeem` 单赢家 `UPDATE...WHERE redeemed_at IS NULL RETURNING`;RouteStore `expires_at`,`expires_at<=now()` 视为不存在(fail-closed INV7)+ 惰性 DELETE
- **验证(实测)**: `npx vitest run test/store/pg.test.ts` **23/23 通过**(自起 `postgres:16` 一次性容器 :5433,afterAll 自动 `docker rm -f`);`pg.ts` 覆盖 **96.72% stmts / 100% funcs**
- **偏差**: `0002_routes.sql` 新增;② SubdomainStore 无独立预留表, `hosts.subdomain UNIQUE` 归属裁决;③ migrate `;` 切分逐条执行(参数化路径拒绝多语句)。**阻塞**:
#### [x] A3 — 真能力验证器(relay-auth verifyCapabilityToken)+ 同步→异步 seam(2026-07-06)
- **交付**: 抛异常的 `refuseAllVerifier` stub 换成委托 P5 异步 `verifyCapabilityToken` 的真验证器;`await` 贯通整个 authorizer 路径默认仍 fail-closed(异步化后 reject401)。
- **文件**: `src/api/authz.ts`(`CapabilityVerifier.verify``Promise`)、`src/api/provision.ts`(5 个路由 call site `await principal(req)`)、`src/boot/verifier.ts`(;`configureCapabilityVerifyKey` `env.capabilitySignPubkey` 32 字节导入 WebCrypto verify key 后调 relay-auth `configureVerifyKey`,桥接 `CAPABILITY_SIGN_PUBKEY_B64` vs `RELAY_AUTH_VERIFY_PUBKEY` 命名差)、`src/main.ts``package.json`( `relay-auth: file:../relay-auth`)、`test/verifier.test.ts`()+ 更新 api 测试 stub 为异步
- **验证(实测)**: `npx tsc --noEmit` 全项目干净;`npx vitest run` **15 files / 101 tests pass**
- **偏差**: 手建 `control-plane/node_modules/relay-auth` 符号链接以配合新 file: 依赖(`npm install` 会重建)。**阻塞**:
### ✅ NATIVE-TUNNEL — 原生 mTLS 反向隧道:四轨代码全部落地(2026-07-07,分支 `feat/relay-phase1`,commits 5337281/e38e6d1/bb09495/d0c249c)
- **背景**: 原生 iOS/Android/桌面客户端讲 base-app 协议( E2E relay),需经用户 VPS 反向隧道(frp)回到本机 `127.0.0.1:3000`。**base app 无鉴权 mTLS 客户端证书是唯一闸门**。总计划见 [PLAN_NATIVE_TUNNEL.md](./PLAN_NATIVE_TUNNEL.md)(Model A 单主人机队;LE 泛证书;frps 控制通道 mTLS+token;CRL 第一天上;`BIND_HOST=127.0.0.1` 强制)。
- **执行**: 两个后台 Workflow —— (1) buildreview 四轨并行(8 agents,0 error,860k tok);(2) 修复两个 HIGH(2 agents,0 error,308k tok)。四轨文件互不相交(`ios/`·`desktop/`·`agent/`·`deploy/`),** `src/` 改动**(计划承诺兑现)。
- **各轨(全部已验证)**:
- **C-iOS `[x]`**: `ios/Packages/ClientTLS`(SecIdentity 包装PKCS12 导入含类型化错误keychain 存储 `AfterFirstUnlockThisDeviceOnly` `MutualTLSChallengeResponder` 真值表跨平台 X.509 DER 摘要)。两个 transport + SessionThumbnail **惰性 `@Sendable ()->ClientIdentity?` provider**(WS 每连接HTTP 每证书挑战解析 装证书后免重启生效)。`ClientCertScreen` `hostMenu` 设备证书入口可达;PairingViewModel `*.terminal.yaojia.wang` 无证书时挡探测mTLS 拒绝重映射为 clientCertRejected。**验证**: ClientTLS 14/14SessionCore 93/93xcodegen+xcodebuild BUILD SUCCEEDED
- **C-Desktop `[x]`**(D-1/D-2): `remote-hosts.ts` + DesktopPrefs(不可变向后兼容)+ 托盘选择器;`will-navigate` 动态锁定选中远端 originnull 失败关闭(无开放重定向);`select-client-certificate` handler `event.preventDefault()` 优先 issuer CN 选证缺证弹框不静默。**验证**: `npm run typecheck` 干净
- **S2 host-packaging `[x]`**: launchd `<EnvironmentVariables>`systemd `EnvironmentFile`originConfig zone 参数化(默认 `term` 保留);`InstallOptions` CLI install createCliDeps installService seam writers 全程贯通,生成单元携带 `BIND_HOST`(隧道机默认 `127.0.0.1`)/`ALLOWED_ORIGINS`/`PORT`/`SHELL_PATH`/`IDLE_TTL`/`USE_TMUX`;systemd 拒绝控制字符。**验证**: agent typecheck+build 干净vitest **166/166**(154+12 )。
- **V2/V7 VPS 脚本(可提交件)`[x]`**: `gen-device-ca.sh`(EC P-256,`--kind frp-client`)、`issue-device-cert.sh`(P-256 leaf,EKU=clientAuth only,.p12 `-legacy`+.pem, CSR 注入 serverAuth/CA:TRUE)、`revoke-device.sh`(CRL 强制)、`frp/{frps,frpc}` 模板(`disableCustomTLSFirstByte=true`)、`nginx/frp-mtls.conf`(:8470 TLS 终止 + `ssl_verify_client on` + `ssl_crl`,WS 升级代理 :7080)、`nginx/stream-sni-additions.md`(两条加性 SNI map,合并式共存)。**验证**: tmp 目录跑通 genissue`openssl verify`revokeCRL(OpenSSL 3.0.18);无密钥/证书误入库
- **对抗式评审结论**: Desktop/VPS 直接 PASS;iOS/S2 各命中 1 HIGH(ClientCertScreen 无入口 / 环境注入未接到 install CLI)—— 均属跨 lane 收尾接线,已由第二个 Workflow 补齐并复验
- **实机 VPS 部署 V0V6 完成,M1 达成 `[x]`(2026-07-07,8.138.1.192)**: frps 0.61.1(systemd `frps.service`,控制 :7000 + vhost :7080,控制通道 mTLS+token);P-256 device CA + frp-client CA(openssl ca db + CRL);nginx `:8470`(`ssl_verify_client on` + device-CA + `ssl_crl` WS 代理 :7080);stream map **加性合并** `frp.terminal→7000`/`*.terminal→8470`(保留 `*.term→8443`/`writer→8444`/`default→10443`,`.bak` 已存)。**M1 全绿**:无证书400 ;设备证书200 `/live-sessions` JSON(全链路 :443SNI:8470 mTLSfrpsfrpcbase app);frpc frp-client 证书控制层拒(`error: EOF`);吊销设备证书400另一证书仍 200(CRL,`nginx -s reload` 后即时);4-zone SNI oracle 共存(xray/writer 未动)。base app **零改动**详见 memory `native-tunnel-m1-live`
- **LE 泛证书已上线 `[x]`(2026-07-07)**: VPSLE 被墙(curl err 28),改在**本机 Mac** `acme.sh` **手动 DNS-01**(用户不交 `Ali_Key`/`Ali_Secret`,手动加两条 `_acme-challenge.terminal` TXT,`dig` 验证),本机签出 **Let's Encrypt 泛证书 `*.terminal.yaojia.wang`(ECC,exp 2026-10-05)**,`scp` VPS `/etc/relay/frp-tls/`,`systemctl restart nginx`(graceful reload worker 竞态,restart 才稳)。** `-k` 公网信任验证全绿**:无证书400设备证书200 `/live-sessions` JSON;共存 oracle 不变。**续期注意**:手动模式不自动续,2026-10-05 前需重跑本机手动流程(或补 Ali 凭证走 dns_ali 自动续)。
- **M2 现在只剩设备侧**: `feat/relay-phase1` iOS ClientTLS 版本 build 装到 iPhone;② 设置设备证书 导入 `.p12`(测试证书 `/root/deploy-native/out-dev/test-device.p12`,口令 `testpass123`);③ 配对 `https://t1.terminal.yaojia.wang`t1 现指向 VPS 自身的临时 base app;真机 iPhone笔记本 用例需把笔记本按 `deploy/frp/README.md` 上线为一台 host
- **旁注(非本次改动)**: E2E 浏览器 relay `:8443` 在部署前已 DOWN(只有 :8444 writer ), `terminal.term` 无证书;`*.term→8443` 路由从未被动过
### ✅ 修复:底部快捷键栏遮挡终端内容(iOS 安全区,2026-07-06,main)
- **现象(用户截图,iPhone)**: 底部快捷键栏(Esc/Esc²/⇧Tab…)压住终端最后一行(Claude Code "bypass permissions" 提示行只露上半)。
- **根因(`public/`)**: `index.html` 设了 `viewport-fit=cover`(布局铺满物理屏延伸进刘海/Home 指示条), `style.css` **全程没有任何 `env(safe-area-inset-*)` 补偿**于是 `#keybar`(`fixed; bottom:0; height:--keybar-h`)整条压进 Home 指示条区, `#term` 只预留了裸 `--keybar-h`,末行与键栏相撞教科书级 cover-无-safe-area 缺陷
- **修复( CSS, JS)**: `:root` 新增 `--safe-t/--safe-b = env(safe-area-inset-top/bottom, 0px)`,穿过所有贴边固定元素:`#tabbar` 顶部让出刘海(border-box + `padding-top:--safe-t`)、`#term` inset `calc(--tabbar-h+--safe-t) … calc(--keybar-h+--safe-b)``#keybar` 高度 `calc(--keybar-h+--safe-b)` + `padding-bottom:--safe-b`(芯片留在顶部 --keybar-h,Home 条上方)、`#approvalbar` `body.home-open #term` 底部`#searchbox`/`#settingspanel` 顶部同步inset=0 时全部塌回原值 桌面/无安全区平台**零回归**。
- **验证(headless,390×844,注入 `--safe-t:59px --safe-b:34px` 模拟 iPhone)**: 开一个真实会话后量测 `term_bottom==keybar_top==764`(gap=0,无重叠)、`tabbar_bottom==99`(=40+59,让出刘海);截图确认芯片位于 Home 指示条上方终端不再被遮`npm run build:web` 通过
- **待办**: 未提交;未在真机 iPhone 上复验( headless 模拟安全区)。
### ✅ 修复:项目面板把父文件夹当项目 & 会话点亮所有祖先项目(2026-07-06,分支 `feat/ios-client`)
- **现象(用户截图)**: 只在 `web-terminal` 跑了一个会话, "Active now" 同时显示 `web-terminal`/`Documents`/`yiukai` 三张卡,且父文件夹本身被列为项目
- **根因(`src/http/projects.ts`)**: `belongsTo` 纯前缀匹配 会话按 cwd 归属到**每一个**祖先项目;② 历史合并(`mergeHistory`)把曾经跑过会话的 cwd( `~``~/Documents`)原样列为项目
- **修复(TDD,先红后绿)**: 新增 `assignSessions` —— 每个 live 会话只归属**最深**的包含项目(`buildProjects` 改用;`buildProjectDetail` 保留前缀匹配,单项目详情页语义不变);② 新增 `dropParentFolders` —— 丢弃" git 且包含其他已列项目"的父文件夹条目(discovery 阶段过滤,进缓存)。
- **验证**: 新增 3 (最深归属/父文件夹剔除/独立非 git 历史项目保留);`test/projects.test.ts` 29/29 绿;全量 `npm test` 53 files / **1473** 全绿;`tsc --noEmit` 干净前端零改动("Active now" 分组消费 `sessions[]`,服务器修正后自然收敛)。
### ✅ 全端应用图标 "Orbit" + 托盘模板图(完成 — 2026-07-06,main,commit 86d100a)
用户选定 "Orbit"(节点绕方块光标核环绕=agent 自主替你运行),品牌沿用桌面 `--accent #E3A64A` 琥珀金 + 暖近黑 `#100F0D`矢量母版三份(App 圆角暖底 / iOS 满幅不透明 / 托盘纯黑透明加粗),`rsvg-convert`(`brew install librsvg`)栅格化铺各端:
- **iOS/iPad**: `Assets.xcassets/AppIcon.appiconset`(单尺寸 1024 不透明,Xcode 自动生成全套;此前**无图标**)。构建验证 `AppIcon60x60@2x`/`76x76@2x~ipad` Assets.car;模拟器主屏实拍确认
- **桌面 App**: `desktop/resources/icon.png`(圆角 1024)→ `npm run dist:mac` 重打包出新 icns + 156MB DMG(`dist-app/Web Terminal-0.1.0-arm64.dmg`),顺带带上 projects 修复装机需 `xattr -cr` quarantine(ad-hoc 签名 Gatekeeper)。
- **桌面托盘**: `trayTemplate.png`/`@2x` 换成正确的 macOS **模板图**(黑形状+透明底,系统按亮/暗菜单栏自动染色)——根治"一坨白"。
- **web/PWA**: `public/icon.svg` 矢量。**待办**: 分支/main 未推送;图标对比页(5 创新+5 经典) job scratchpad 未入库
### ✅ iOS 客户端全面 UX/UI 打磨(完成 — 2026-07-05,分支 `feat/ios-client`,commit 660a404;精致原生方向)
用户定方向精致原生 + 可重构」(靛紫 #7C8CFF 强调语义状态色SF Mono 数字系统材质)。编排 = 一个 ultracode `Workflow`(9 agents,4 阶段):3 路审计(易用性/视觉/无障碍, boot sim 取证)→ **冻结 DesignSystem**(协调点,防各 agent 各挑配色)→ 4 组文件互斥并行套用 验收
- **审计**: 3 视角共产出 ~40 findings,共识根因=「无设计系统」——全 App stock 系统蓝状态仅靠颜色(违反色+,idle 蓝撞 unread )、gate 决策按钮 <44pt间距/圆角魔数无刻度数字非 tabular缩略图纯黑块gate 中英混排reduceMotion 未处理
- **冻结 DesignSystem**(`ios/App/WebTerm/DesignSystem/**` 4 文件): Tokens(自适应 accent 靛紫语义 status 间距 2-24 刻度圆角 sm8/md12/lg16Motion gated by reduceMotionHaptics)、Typography(SF ramp + mono tabular)、StatusStyle(status+SF Symbol+中文,单一真源,+)、Primitives(StatusBadge/TelemetryChip/Card/SectionHeader/DSButtonStyle 可复用)。 `.tint(DS.Palette.accent)` 单点注入
- **4 组套用( DONE,纯视觉零逻辑改动)**: G1 列表行重构+状态徽标+telemetry 芯片+缩略图占位;G2 终端 gate (≥44pt approve/reject)+SwiftTerm accent 主题+KeyBar/reconnect/quick-reply/digest;G3 配对 hero+警告分层视觉化+Projects 卡片+Timeline/Diff;G4 导航 chrome+iPad 分栏占位(accent 图标)+隐私遮罩+动效+`ContinueLastBanner`/`ProjectsToolbarItem` 抽共享组件gate 文案落中文
- **验收 PASS_WITH_FINDINGS,设计评分 8.5/10,1 finding**: TimelineSheet class色仍裸色(全改动里唯一越界)→ orchestrator `DS.Palette.timelineTool/timelineUser` token + TimelineSheet 全走 DS + 更新钉旧裸色的测试
- **验证(亲验)**: iPhone 16 **290** + iPad Pro 11 **290** 全绿( finding );+集成绿;iPad 分栏占位截图确认靛紫强调色生效。**未推送**。**待用户目视**: 会话列表行/gate /配对页真机观感(截图受通知授权弹窗遮挡,建议真机或点过授权后看)。iPad 侧栏 List(selection:) 高亮审计标记为列表组 Owns 后续 pass」——当前 onOpen 已驱动切换,视觉选中高亮可再补
### ✅ iOS 客户端 iPad 适配(完成 — 2026-07-05,分支 `feat/ios-client`;自适应布局,非分叉)
`docs/PLAN_IOS_IPAD.md`(5 人日)编排:W0 orchestrator 亲做 一个 ultracode `Workflow`(4 agents: T-iPad-2 核心串行 T-iPad-3 T-iPad-4 T-iPad-5 验收)。
- **[x] T-iPad-1 device family(orchestrator 亲做,commit 77502ec)**: `TARGETED_DEVICE_FAMILY` target `1``1,2`;iPad `~ipad` 四方向(iPhone 不动);ios.yml iPad Pro 11 测试腿实测:产物 `UIDeviceFamily [1,2]`iPad 原生满屏(不再 iPhone 黑边)。
- **[x] T-iPad-2 AdaptiveRootView + NavigationSplitView + LayoutPolicy(核心)**: `LayoutPolicy.mode(hSizeClass)` 唯一 size-class 决策点(regularsplit/compactstack,仿 PrivacyShadePolicy);`AdaptiveRootView` 把横切布线(**隐私遮罩 ZStack 顶层两分支共享**/scenePhase/deepLink/sheets)上提唯一一处;`StackRootView`= RootView body **原样搬迁**(iPhone 字节级零回归);`SplitRootView`= SessionListScreen(复用不改)+ TerminalContainerView(复用不改, `.id`);AppCoordinator SidebarItem 路由桥(非平行生命周期)。
- **[x] T-iPad-3 KeyBar 自适应 + 指针右键菜单**: `KeyBarVisibility` 纯谓词(硬件键盘在场默认隐);`TerminalContextMenu`(iPad idiom,复制选区/开新会话/结束会话,一一路由既有通道);kill APIClient Origin 单测
- **[x] T-iPad-4 Projects 大屏化**: `ProjectsGridLayout.columnCount`(iPhone 1/iPad 按宽 1-3);iPad 多列网格复用同款 row/分组/prefs 逻辑零改;Timeline 已有 detent 两端合理。**决策**: Projects iPad form sheet 呈现,其内 hSizeClass compact 改用 `userInterfaceIdiom` 判据( size class 正交)。
- **[x] T-iPad-5 验收 PASS_WITH_FINDINGS orchestrator 修复 4/4**: **iPhone 零回归硬门守住**(277 App+261 +10 集成);iPad 分栏工作; CRITICAL/HIGH/安全回归修复:MED kill 菜单项无法渲染 `onKillSession` 贯穿 TerminalContainerView两根视图 + `AppCoordinator.killCurrentSession()`(复用 kill 通道);②MED split 未消费 route `AdaptiveRootView` `.loading/.pairing` 强制走 StackRootView(iPad 首启配对不被困在空态);③LOW 继续上次横幅补进 split sidebar(截图确认);④LOW iPad happy-path XCUITest **接受推迟**(split 选中逻辑已由 SidebarSelectionTests 9 测真 engine 覆盖,stack happy-path 未变,XCUITest 单跑 7-11min 且脆,成本不划算)。**验证(亲验)**: iPhone 16 **277** + iPad Pro 11 **278**(+kill 桥测试)全绿;iPad 分栏 sidebar+detail 截图确认
- **合计**: iPad 适配零改 `ios/Packages/**`/`src/`/`public/`;纯逻辑包设备无关直接复用。**待用户**: 真机 iPad(分栏手势/硬件键盘/指针右键/Stage Manager)DEFERRED;多窗口(拖会话开新窗并排两终端)另立下一期(本期单场景)。
### ✅ iOS 客户端 P1 实施(完成 — 2026-07-05,分支 `feat/ios-client`,P0+P1 共 14 commits,未推送/未合并)
- **[x] P1-A(W6+W7 首发: T-iOS-37 T-iOS-20 T-iOS-38,3 builders + verify 6/6 PASS;`/push/apns-token` 线形状由 orchestrator 预冻结,两侧并行编码零失配——verify 逐项对照 method/path/body key/hex 规则/状态码/守卫/限额全 match)**:
- **T-iOS-37 server lastOutputAt**: `LiveSessionInfo.lastOutputAt?: number` 可选增量 + `manager.list()` 一行映射 + 4 ;web grep 零消费者,tsc+全量双证兼容
- **T-iOS-20 server APNs**: `src/push/apns.ts`(576 )——env 三件套 all-or-disabled(缺失路由不挂载,启动不 crash,密钥材料零日志)、token store 照抄 subscription-store 约定(幂等/上限/0600)、**手写 ES256 JWT(node:crypto ieee-p1363,零新依赖)**、NEEDS-INPUT(priority 10+WEBTERM_GATE+capability token)/DONE(priority 5)、payload 最小化结构性保证(构造函数只读 meta.id,cwd/命令内容不可能出现)、410/BadDeviceToken 逐出`combineNotifyServices` web-push 并联互不阻塞;`hasPushTarget` 扩展( APNs token 也能 hold gate)。**65 **含双 e2e(本地 h2c APNs:held gate双通道同发同 token→/hook/decision 放行)。新增 `APNS_HOST` env(sandbox 切换+测试零网络)。**npm test 53 files/1470 全绿,tsc 干净**。
- **T-iOS-38 APIClient P1 契约**: APNs token builders(hex 校验镜像服务器联网前拒绝显式 ASCII 字符集防全角)、projects/detail(**证实线上无 namespace 字段**—— web 端分组概念;lossy 解码;percent-encode 单点+ + 规避)、prefs(**未知顶层键往返逐字节存活**—— iOS 覆写 web 偏好,关键陷阱已测;JSONValue 独立 integer case 4242.0)、公开四层 HostNetworkTier(W3 dedup 欠账,PairingVM 后续 pass 可切换)。76/76 绿,覆盖 95.26%。
- 遗留(LOW/cosmetic): Swift doc comment server 行号引用漂移;PairingVM 分类去重待后续 pass
- **[x] P1-B UI 九连链(222425272623292821 DONE,10 agents;verify BLOCKEDorchestrator 修复后放行)**:
- **T-iOS-22 DeepLinkRouter**: 全字段白名单(scheme/action/path /query 键精确/重复键拒)、UUID 复用冻结 Validation(v4);冷启动单槽 stash;`route(from:)` push-tap 复用;非法链接计数不回显内容(防日志注入)。21
- **T-iOS-24 Timeline sheet**: 呈现顺序逐行镜像 web render()(slice 50reverse);`[]`=disabled空态非错误;复用 AwayDigestView 既有 onExpand(T-iOS-14 零改动);label Text(verbatim:)。14
- **T-iOS-25 Quick-reply**: 内置 6 chips 逐项镜像 quick-reply.ts(Esc/Enter KeyByteMap 零手写转义);显示条件=持牌 gate(流上唯一 waiting 信号)&&!readOnly;UserDefaults lossy 载入16
- **T-iOS-27 Diff 查看器**: App DiffFetcher(RO Origin;并注记归 T-iOS-38 后续收编);hunks 着色/staged 切换/truncated 横幅/lazy list;diff 字节 verbatim 渲染
- **T-iOS-26 Projects**: 分组逐条镜像 projects.ts ** key web 逐字节一致**(collapsed /prefs 跨端共享,key 漂移=两端互丢折叠);**prefs clobber 防线**——GET 失败toggle 仅本地绝不 PUT(空底盘 PUT=清库)、PUT 采纳服务器 echo;`claude\r` bootstrap 镜像 tabs.ts:679
- **T-iOS-23 切换器**: UnreadLedger(单调水位,严格 >)+TitleSanitizer(C0/C1/DEL+bidi/零宽全表,字素簇截断 256)入 SessionCore(+15 包测);LiveSessionInfo.lastOutputAt 容错解码(授权增量);列表边界二次净化。
- **T-iOS-29 杂项**: new-in-cwd(cwd 不可信,非绝对路径按未知;**bootstrap 绝不复注入**——开新 shell≠起 claude,专项断言);exit 横幅"开新会话";**加建修复潜在 bug**: RootView 给容器补 `.id(controller.id)`(呈现中换 controller 不换 SwiftTerm 视图→终端假死)。
- **T-iOS-28 缩略图**: 离屏 SwiftTerm 渲染管线(LRU 32/并发闸 2/预览 256KiB 上限/网格钳制),缓存键 (sessionId,lastOutputAt),CADisplayLink 注销不泄漏。
- **T-iOS-21 PushRegistrar+锁屏**: WEBTERM_GATE category(Allow=.authenticationRequired、Deny 免认证 fail-safe、均无 .foreground,形状测试钉死);device token 对每 host 注册+记账重试;NotificationActionHandler(sessionId 复用 DeepLinkRouter 白名单、token 校验后仅入参绝不落盘、beginBackgroundTask 包 POST、403→本地通知兜底)。锁屏真机行为 DEFERRED(需付费账号)。
- **verify(独立 agent)**: 包 261(SessionCore 92/APIClient 77/…)+App 247+集成 10 全绿;7 项语义抽查全 PASS(含恶意标题/putPrefs 唯一调用点走保留路径/token 零持久化 grep);Owns 审计干净;**但抓到 CRITICAL——生产模式启动即崩**: @MainActor UNNotificationCenterAdapter 的 completion 闭包继承主 actor 隔离,UN center 在后台队列回调→Swift 6 executor 断言 trap(测试全绿盖不住:push 接线在测试宿主下被跳过——**测试盲区实证**)。**orchestrator 修复**: 三处闭包加 `@Sendable` 字面量标注(不继承隔离);重建+实机启动验证:进程存活、崩溃报告零新增、通知授权弹窗弹出(=走过原 trap 点)、弹窗期间隐私遮罩正确覆盖(scenePhase .inactive)。
- 遗留(LOW,归 P1-C 复核): deep link 未断言 userinfo/port(`webterminal://user:pass@open` 仍路由;id 均 v4 校验+host 只经 store 解析,影响可忽略);Diff fetcher 收编 APIClient 待后续 pass。
- **[x] P1-C 验收+安全复核(T-iOS-30,2 reviewer ∥,report-only)—— 双双 PASS,零 findings**:
- **Part A 功能验收**: 亲跑 261 包测+247 App 测+10 集成全绿;**启动崩溃修复复核 PASS**(3 次实机启动零崩溃,通知授权弹窗弹出=走过原 trap 点,崩溃报告计数停在修复前的 2);**`simctl push` 实测**注入 WEBTERM_GATE payload → 通知横幅真实渲染("Needs input / Session 3d34818d",目视确认最小化:仅状态词+8 位前缀,无 cwd/命令/token);F-iOS-15 服务端切片实测(双会话 `lastOutputAt` 差分正确);**意外收获**——对**你的真实服务器**(127.0.0.1:3000,pre-T-iOS-37 旧构建)实测旧版兼容降级:App 直接渲染真实会话列表(stuck 红点/`未知目录` 标题回退/`0 台设备在看 · 51×50` 与服务器精确一致/无 unread 点=正确降级)。真机项(锁屏 tap/Face ID、unread 点亮目视、OSC 标题目视)DEFERRED 附手工步骤。
- **Part B 安全复核(对抗式)**: APNs payload 最小化 post-P1-B 仍成立(OSC 标题不进 alert,grep 证实);**capability token 全链路 CLEAN**——issue(server randomUUID+TTL)→payload(仅 gate)→handler 校验后仅入参 hookDecision→**零落盘**(grep Push/ 及全 App 无 UserDefaults/Keychain/file 写;唯一持久 token 是内存态 device token)→单次消费(server resolvePending 一次性)→**两侧日志零 token**;deep-link 50+ fuzz 全部安全(userinfo/port LOW 判定 inert——从不用于构造请求、id 仍 v4 校验,继续接受);锁屏 Allow `.authenticationRequired` 形状测试绿;prefs 唯一写入点仍守空底盘;P1-B 新攻击面(缩略图字节只进 SwiftTerm、diff verbatim、标题净化覆盖全渲染点)扫盘无泄漏;残余风险台账完整。
- **P1 收官统计**: 累计 **518 项自动化**(261 包测 + 247 App 测 + 10 集成)+ 服务器 1470 测全绿;新增能力=远程 APNs 推送(锁屏 Allow/Deny+Face ID)、deep link、Projects 项目列表(分组/收藏/在仓库起 Claude)、多会话切换器(unread+OSC 标题净化)、Timeline/Diff/Quick-reply/缩略图;服务器仅 §0.3 声明的两处增量触点(lastOutputAt + APNs)。**待用户**: ①真机走查(T-iOS-30 报告手工清单);②Apple 付费账号(APNs 真机端到端+TestFlight);③推送分支跑首轮 GH Actions(ui-test 腿/ios17 runtime);④P2(语音 PTT/worktree/终端内搜索/主题/web ?join= 互通)是否开工——P2 任务需先按 P0 规格扩写 RED 清单再分派(§7 P2 注)。
### ✅ iOS 客户端 P0 实施(完成 — 2026-07-05,分支 `feat/ios-client`,7 commits,未推送/未合并)
按 PLAN_IOS_CLIENT §8 批次推进;编排 = 每波一个 ultracode `Workflow`(builder 按任务派发 + 独立 verify agent 复跑验收)。
- **[x] W0 基础(T-iOS-1/2/3/4,5 agents,verify 6/6 PASS)**:
- **T-iOS-1 脚手架**: `ios/` 全结构(project.yml/XcodeGen、5 包空壳、WebTermApp 空窗、`.github/workflows/ios.yml`);Info.plist 经 plutil 核对 = §5.2 逐字(五段 CIDR,**全程零 NSAllowsArbitraryLoads**);bundle id `com.yaojia.webterm`。**偏差**: "default MainActor isolation" 属 Swift 6.2+,本机 6.1 → Swift 6 语言模式 + strict concurrency + 显式 @MainActor 替代(已注 project.yml)。**环境事实**: 本机 Xcode 16.3 原本无 iOS 模拟器 runtime → `xcodebuild -downloadPlatform iOS` 装 iOS 18.4(22E238)后模拟器构建绿。
- **T-iOS-2 Day-1 双 spike**: **平台事实定音——URLSessionWebSocketTask 可发自定义 Origin(①无 Origin→401 ②精确匹配→101+attached ④端口失配→401),不切 Starscream**;③ 1.5MB 回放在默认 1MiB 上限失败(withKnownIssue 记录)、16MiB 成功,ESC/C0 对抗(转义≈3.6MB)成功。**勘误(已写回 PLAN §1/§3.2/T-iOS-9)**: 超限 errno 实测 NSPOSIXErrorDomain **40=EMSGSIZE**(计划原文误标 ENOBUFS=55),T-iOS-9 归类以 40 为主、55 兜底。ServerHarness 自举真服务器(tsx spawn+death-pipe watchdog,无孤儿进程)。真机 smoke 4 项 DEFERRED(无真机),SpikeTerminalScreen 已编入 App target 待 T-iOS-18。
- **T-iOS-3 WireProtocol(冻结)**: §3.1 全类型 + Tunables §3.2.1 全表;**59 tests 全绿、覆盖率 100%**(llvm-cov 226/226);300 轮 roundtrip property + 500 轮 fuzz(decodeServer 永不 crash);**跨实现实测**: Swift encoder 输出经 tsx 喂给真 `src/protocol.ts` parseClientMessage → 13 帧+mode 顶层恢复全 PASS。自此冻结。
- **T-iOS-4 TestSupport**: FakeTransport(actor,scripted failures+按连接记帧)/FakeClock(class+锁,`waitForSleepers` 确定性栅栏,零真实等待)/FakeHTTPTransport(记录 headers 供 Origin-iff-G 断言);3 smoke 全绿。
- **verify(独立 agent 复跑)**: 模拟器构建 exit 0;59+3 tests;覆盖率 100%;spike 6 tests+1 known issue;`git status` 零越界。
- **[x] W1 叶子包(T-iOS-5/6/7/8,4 builders;编排 [5→6 串行] ∥ 7 ∥ 8——5/6 同在 SessionCore 包,主树并行会互坏编译)**:
- **T-iOS-5 ReconnectMachine+PingScheduler**: 纯 reducer,§3.2 签名逐字;backoff 1s→…→30s 封顶镜像 terminal-session.ts(连上才归零,retry/foregrounded 不重置阶梯——防狂按打穿);Ping 25s/连续 2 miss→connectionLost/取消与断线显式区分。16 测,0 真实等待,文件级覆盖 100%/95%。
- **T-iOS-6 GateState+AwayDigest**: epoch 语义逐行镜像 terminal-session.ts:306-311(上升沿 +1、持续帧刷新 detail、下降沿不重置计数器);`canDecide(epoch:)` 防误批;affordance 映射逐字对齐 tabs.ts:345-347(绝不发 raw auto);digest 词表与 `TimelineEvent.knownClasses` 有相等性绊线测试;防御:乱序容忍、Date→ms clamp 不 trap。25 测,两文件 100%。
- **T-iOS-7 HostRegistry**: §3.3 契约;KeychainHostStore 经 SecItemShim 缝(swift test 打 fake;真 shim kSecUseDataProtectionKeychain+AfterFirstUnlockThisDeviceOnly,属性字典单点构造+逐字单测);InMemoryHostStore 入 Sources 供 App-VM 测试。30 测,own-Sources 覆盖 88.1%。
- **T-iOS-8 APIClient+探针**: 44 测先全绿后 **`[!] BLOCKED` 正确上报冻结契约自相矛盾**——§3.4 `runPairingProbe → Result<Host,…>` 中 Host 是 §3.3 HostRegistry 类型,而 APIClient 依赖边仅 WireProtocol(§1 零耦合),签名无法编译(两轮 review 均漏)。**裁定(orchestrator,三选一取 c)**: 探针职责=验证 endpoint → 返回 `Result<HostEndpoint,…>`,Host{id,name} 由 T-iOS-12 VM 构造(id/name 本非探针所知);同时按法定路径加 `Tunables.pairingProbeTimeout`(10s,§3.2.1 新增行+绊线断言)。裁定已写回 PLAN §3.4/§3.2.1/T-iOS-8,公开包装+冒烟测试由 orchestrator 落地。Origin **iff**-G 入测试名;探针②成功后必带 Origin kill 不留孤儿;403 kill→originRejected(HTTP 侧 G 守卫也是配对必验项)。45 测,own-Sources 覆盖 98.1%。
- **W1 验证(orchestrator 亲验)**: 5 包 178 tests 全绿(59+3+41+30+45);覆盖率(own-Sources 口径)SessionCore 99.1%/HostRegistry 88.1%/APIClient 98.1%,全过 80% 门;`git status` 全部在 Owns 内零越界。**发现归 T-iOS-16**: §9 覆盖率命令的 ignore regex 不排静态链入的依赖包源码,CI 接线时须按包过滤(本次已按正确口径亲算)。
- **[x] W2 连接核心(T-iOS-9→10 串行;首次派发 T-iOS-9 agent 死于 API 连接中断——现场零文件、直接 resume 重跑,非任务失败)**:
- **T-iOS-9 URLSessionTermTransport**: 冻结 TransportConnection 不动,ping 经 SessionCore 内部(非冻结)`PingableTermTransport/ConnectionPinger` 扩展点接入;pong 时限复用 `Tunables.pingInterval`(无新常量);EMSGSIZE 归类 40 主/55 兜底→类型化 `.replayTooLarge`;connect 失败原样重抛底层错误(保住 PairingError.classify);状态 delegate 驱动,close 三态可区分;测试服务器 = 裸 TCP 手写 RFC6455 framing(可逐字验 Origin/注入 binary/超帧)。11 测,文件覆盖 97.83%,iOS triple 编译过。
- **T-iOS-10 SessionEngine actor**: 单 Task 生命周期循环 + generation 计数防陈旧任务复活;首帧铁律 attach→lastKnownDims resize→排队帧按序 flush;终态(exit / replayTooLarge)绝不进 backoff;digest 断点原子消费恰好一次;gate 决策 canDecide 兜底、断线期决策丢弃不入队(重连后 gate 可能换代);防御:非 v4 UUID/相对 cwd 剥离(服务器会静默丢帧致挂死)。**决策**: 初始 resize 依赖首次 sizeChanged/notifyForegrounded(冻结 open 无 dims 参数,SwiftTerm 首次布局即触发,已注 doc);digest limit 传 Int.max(截断属 UI,T-iOS-14)。20 测,SessionEngine.swift 94.98%(480 行,<800 硬上限,内聚裁量已记)。
- **W2 验证(独立 agent,5/5 PASS)**: SessionCore 72 (41 W1 完好+31 );四包回归全绿(**累计 209 tests**);own-Sources 覆盖 96.68%;冻结契约零改动;4 项语义抽查(Origin 单点 :169/16MiB 来自 Tunables/replayTooLarge 断重连路径逐行核/decodeServer 唯一入口 nil-drop)全数在源码落实
- **[x] W3 UI+提前项(7 agents: [T-iOS-11121314] 串行链 T-iOS-16 T-iOS-17;协调点 = orchestrator 预建 WebTermTests 单测 bundle(project.yml+5 包依赖,模拟器实测过)并单独提交)**:
- **T-iOS-11 Terminal+KeyBar**: KeyByteMap(SessionCore 纯数据)逐字节复刻 keybar.ts 17 (⏎=\r \n 专项断言);TerminalViewModel 消费 engine.events,replayTooLarge不可重试态+话术逐字断言;**决策**: 硬件 UIKeyCommand 仅注册 Esc/⇧Tab/Ctrl 和弦,箭头不注册(固定 CSI 会破 DECCKM 应用光标模式,vim/htop 错乱)——有排除测试;OSC52 剪贴板拒绝链接仅 http(s)。
- **T-iOS-12 Pairing**: Phase 状态机;扫码confirmingHost,**确认前零网络**(recordedRequests/connectAttempts 空断言);公网需显式勾选确认5.4 四层警告 13 组参数化3.4 裁定落地(VM 构造 Host store);host 分级 VM 内重实现(APIClient internal 粒度不足,重复已标注归 T-iOS-38 去重);真机 DataScanner 编译隔离,generic/platform=iOS 构建过
- **T-iOS-13 SessionList**: 轮询 Tunables.listPollInterval,离开断言 0 sleeper+10 周期零请求(无泄漏);**发现**: LiveSessionInfo pending 字段(已核 manager.list())→ 徽标经 setPendingApproval overlay T-iOS-15 gate 事件接入;kill 乐观移除+404=成功/403 回滚;staleness 严格 > 镜像 web;PR 链接 https-only(SEC-L5 镜像)。
- **T-iOS-14 Gate/Digest UI**: GateViewModel 独立 VM(events 与 engine 分离注入,T-iOS-15 fan-out 零改动);三选一映射唯一事实源=SessionCore Affordance.clientMessage(acceptEdits/default/reject,App 层零 allowAutoMode 逻辑);tap-epoch+affordance 双守卫;haptic 每 epoch 恰一次(HapticSignaling 注入);digest 自动淡出 FakeClock 驱动;文案逐字镜像 tabs.ts:326-350。
- **T-iOS-16 集成 CI**: 吸收 spike 重构 harness;10 测对真自举 Node 服务器(echo/stty resize 1..1000/16MiB+ESC/C0 对抗回放/JOIN mirror/401/401/403/kill→WS close 而非 exit 帧/自然 exit 广播);ios.yml=包矩阵+coverage-gate.sh(own-Sources 过滤,修正 §9 已知缺陷)+App 测试+集成 job;覆盖率门红→绿演示实录;CI 平台未跑(如实注明)。
- **T-iOS-17 ntfy 文档**: ios/README.md;只读验证(引 setup-hooks.mjs file:line),**未触碰用户真实 hook 配置**(orchestrator 安全约束);payload 最小化逐行核;真机端到端 DEFERRED。
- **W3 验证(独立 agent,全项 PASS)**: 包套件 59+3+77+30+45;App 55 测 TEST SUCCEEDED;集成 10 测+1 known issue(故意的 1MiB 复现);语义抽查 5/5(KeyByteMap 逐字节 16 项、三选一字面量+零 allowAutoMode、零网络断言实测在 :60、轮询走 Tunables、CI 覆盖率 own-Sources 过滤);Owns 审计零越界。**累计 224 单测 + 10 集成**。
- **[x] W4 汇合(T-iOS-15,1 builder + verify 8/8 PASS)**:
- **T-iOS-15 App 接线**: `Wiring/` 9 文件——AppEnvironment 生产 DI(真 Keychain/真探针/URLSessionHTTPTransport 零逻辑透传,Origin 规则全留 APIClient)、EventFanOut(单消费者 engine.events → TerminalVM+GateVM+SessionActivityBridge 三分支保序广播)、bridge(.adopted→存 lastSessionId/.gate→列表 ⚠ overlay/.exited→清两者,防「继续上次」指向死会话)、TerminalSessionController(.background→close 干净 detach;suspend 后 .active→重建栈+open,generation 换 SwiftUI identity 触发首次 sizeChanged 夺回全屏)、**PrivacyShade 严格 `scenePhase != .active`**(三相位测试)、冷启动路由纯函数;WebTermApp 薄 @main;**删除 SpikeTerminalScreen**(grep 零残留)。
- **两项自动化走查代理**: ①LiveServerSmokeTests——签名宿主内真生产 DI 图端到端(探针→host 入库→liveSessions→attach→echo 回显→close→kill 清理);②KeychainHostStoreLiveTests——真 Keychain 往返+`SecItemCopyMatching` 断言 `kSecAttrAccessible==AfterFirstUnlockThisDeviceOnly`+非 synchronizable(T-iOS-7 递延项闭合)。模拟器 boot 截屏:配对页正常渲染。
- **环境硬墙(新发现,已记 harness)**: repo 在 `~/Documents`(TCC 保护)时,模拟器 App 语境 posix_spawn 的 node 读 repo 文件被 tccd 无限阻塞(sample 实测卡 `uv_cwd→__open_nocancel`;平台二进制豁免)→ SimServerHarness 双模式:A 自举(CI/非 TCC 路径)/B `WEBTERM_SERVER_URL` 外部注入;TCC 下 0.001s 快速失败+指引。**本机跑法**: 起服务器后 `xcrun simctl spawn booted launchctl setenv WEBTERM_SERVER_URL …` 再 xcodebuild test(命令行 TEST_RUNNER_ 前缀实测传不进宿主 App 测试进程);裸跑 smoke 红=有意显式失败非回归。
- **偏差闭合(orchestrator 代 T-iOS-11 owner 修)**: W4 曾记「alive-engine `.active` 未调 notifyForegrounded(dims:)——W3 无 dims 钩子」→ TerminalViewModel 增 `lastSentDims`(仅记有效 resize,invalid 不覆盖,有测试)+ controller `.active` 分支接 `notifyForegrounded(dims:)`(无有效 dims 时跳过,SwiftTerm 首次布局兜底)。**76 App 测全绿**(75+1)。
- **verify(独立 agent,8/8)**: 214 包测+76 App 测+10 集成;spike 删净;遮罩规则/生命周期双向 close 路径/组装安全(G 全走 APIClient、零 ad-hoc URLSession、零 ArbitraryLoads、Origin 零手拼)逐项在源码核实;Owns 零越界。
- **[x] W5 验收 + findings 修复(2 reviewer ∥ + 1 fix builder;均 PASS_WITH_FINDINGS→已闭合)**:
- **T-iOS-18 F 走查**: 全部机器可执行项本轮亲自重跑(集成 10/App 76/包 214 + 模拟器冷启动·后台·重启截屏);F-iOS-1..13 逐条 EXECUTED/EVIDENCED(指认测试名)/DEFERRED(真机)(QR 扫码/IME/震动/切换器遮罩目检/ntfy 端到端,附手工步骤清单)。
- **T-iOS-19 安全核对**: 对产物审——Origin 写入点全树恰两处且单点派生;G/RO 分界;**拆构建产物**验五段 CIDR+零 ArbitraryLoads+双 usage description;Keychain 属性;P0 不该在的面(deep link/麦克风)确认缺席;残余风险(isCaptured 跳过/ws 明文模型/epoch 仅客户端)记录完整。
- **findings(0 CRITICAL/HIGH; 1 MED+3 LOW,全部闭合)**: ①MED §9 XCUITest happy path 缺失 → orchestrator 建 WebTermUITests target/scheme,fix agent 落地唯一用例:**配对(手输)→列表→attach→KeyBar ^L→注入 held gate(POST /hook/permission)→点 Approve→断言服务器真放行 behavior==allow**,服务器为 oracle(XCUITest 读不了 SwiftTerm 字形);幂等三分支(全新/已配对/空态)全被实走;**两次真实跑通**(439s/677s,EXIT=0)。②LOW CI 缺 iOS17 下限腿 → ios.yml 增 ios17-floor job(runtime 缺失大声跳过/存在必判,CI-only 本机未验)。③LOW URLSession.shared 磁盘缓存可落盘 preview 终端字节 → 生产 HTTPTransport 默认 .ephemeral。④LOW XcodeGen target 级默认覆盖 project 级 TARGETED_DEVICE_FAMILY(实际打包 [1,2]) → 移 target 级,产物已验 [1]。
- **XCUITest 实证发现(已记 ios.yml/用例注释)**: `TEST_RUNNER_` env 只在 xcodebuild **进程环境**里才透传 runner(命令行 KEY=VALUE 是 build setting 到不了);**`CODE_SIGNING_ALLOWED=NO` 打断真 Keychain(-34018)**→ 顺手修掉既有 app-tests 腿同 flag(否则 KeychainHostStoreLiveTests 在 GH runner 必红);XCUITest typeText 够不着 SwiftTerm(无 AX 键盘焦点)→ 输入断言走 KeyBar ^L+清屏应答;SwiftTerm 光标闪烁烧 XCUITest 60s idle-wait/事件(单跑 7-11min,已压到 3 次终端交互)。
- **遗留观察(report-only,非阻塞)**: 配对探针偶发残留 clientCount=0 会话(killProbeSession 竞态,run 8 一例)——归 T-iOS-8 owner 复核;真 GH Actions 首跑待推送后确认(ui-test 腿/ios17 runtime 镜像现状)。
- **P0 收官统计**: 306 项自动化(214 包测 + 76 App 测 + 10 集成 + 1 XCUITest[计 1,参数化更多]) 全绿;覆盖率 own-Sources SessionCore 96.7%/HostRegistry 88.1%/APIClient 98.1%/WireProtocol 100%;19/19 任务完成(全部偏差有日志);服务器 src/ public/ **零改动**。**待用户**: ①真机走查清单(T-iOS-18 报告内);②推送分支跑首轮 GH Actions;③P1 开工前拍板:Apple 付费账号(APNs/TestFlight)与 bundle id/App 名(现 com.yaojia.webterm/WebTerm)。P1 首批 = T-iOS-20(APNs server) ∥ T-iOS-37(lastOutputAt) ∥ T-iOS-38(APIClient P1 契约)。**偏差(orchestrator 决策,W1 起生效)**: 并行 builder 不用 git worktree(Workflow worktree 合并回主树增加失败面),改为主树直跑——文件互斥 + 同包任务串行化,禁 agent 碰 git;每波末独立 verify + orchestrator commit 兜底。
### ✅ iOS 客户端实施计划 — `docs/PLAN_IOS_CLIENT.md`(规划完成 — 2026-07-04,main,未提交;纯文档,零代码改动)
**多 agent 编排 = 一个 ultracode `Workflow`(66 agents,7 阶段)**:5 读者并行(wire contract 逐 file:line / 功能矩阵 / relay 现状 / 模板规范 / iOS 平台联网调研)→ 3 架构师独立竞标(full-native / WKWebView-hybrid / phased)→ 3 评委多视角打分(UX/工程/安全,**phased 26.5 胜出**,native 23.5、hybrid 16.5,21 条 mustSteal 嫁接)→ 综合简报 → 写手出稿(843 行)→ **4 评审交叉验证**(对照 src/ 源码逐条核实 / iOS API 联网核查 / 安全 / 计划质量)→ 每条 finding 对抗式 verify(CRITICAL/HIGH 双票)→ fixer 应用。
- **方案**: Phased-Native「口袋驾驶舱」—— SwiftUI + SwiftTerm(MIT/SPM),4 个纯 SwiftPM 包(WireProtocol/SessionCore/HostRegistry/APIClient)+ 薄 App 胶水,`ios/` 顶层新包;前台会话单条活 WS + 其余 HTTP 轮询;iOS 17+/Swift 6/XcodeGen;**P0 服务器零触点**(通知复用既有 ntfy 桥 scripts/setup-hooks.mjs:227-238),P1 仅两处声明触点(APNs sender + `LiveSessionInfo.lastOutputAt`,后者为 TS 任务 T-iOS-37)。relay/E2E 明确推迟(已审计 TS 加密不得随手 Swift 重写)。
- **任务机**: 38 个任务(T-iOS-1…38),P0=19 任务 W0W5 ≈13 人日、P1 W6W8 ≈17.5、P2 W9W10 ≈8,总 ≈38.5 人日;每任务 Owns/Depends/测试先行 Steps/Accept/估算/分派模型。关键平台事实:原生客户端 **WS 握手必须显式带白名单内 `Origin` 头**(src/http/origin.ts:27 对 undefined 默认拒);`URLSessionWebSocketTask.maximumMessageSize` 需调大到 16 MiB(回放单帧 JSON \u 转义最坏 6×2MB)。
- **Review 结果**: 35 findings → **32 confirmed 全部修复**、3 rejected(含「ATS 不收 CIDR」被 verify 驳回 —— iOS 17+ 确实支持 CIDR exception domains)。修复亮点:plan-gate 三选一映射纠正为 `acceptEdits/default/reject`(对照 public/tabs.ts:345-347);「:443 破坏 Origin 匹配」为**臆造 gotcha,反向纠正**(new URL() 双侧归一化);ntfy 桥从「新建」改「复用既有」;CRITICAL 共享类型无主 → 全部收编进 WireProtocol(T-iOS-3);W7 Owns 重叠拆出 T-iOS-38;锁屏 Allow 加 `.authenticationRequired`(Face ID 确认才批 shell 命令);QR 扫码改「先确认再发包」防恶意配对。
- **验证(orchestrator 亲验)**: 文档 943 行落盘、11 节结构完整、fixer 27 组修复日志逐条对账;`git status` 仅新增该 .md,src/ 零改动。**未提交**。下一步:用户批准后按 §8 分派批次开工(W0 首日 spike 实测 Origin header)。
### ✅ 安全审计 relay-e2e + relay-auth — 上线前深度审计 + 修复(完成 — 2026-07-02,分支 `security/relay-auth-audit-fixes`,未提交)
上线门:自动生成的 E2E/鉴权核心须过安全专家审计(前次复审判 approve-with-changes)。**多 agent 编排 = 两个 ultracode `Workflow`**:①**审计**(99 agents)—— 11 维 finder 并行(两包)→ 每个 finding 3 lens 对抗式 verify(exploitability/crypto-spec/refuter 多数票)→ 第二轮「重复 review」深挖 → 每包 completeness critic → dedup+定级;②**修复循环**(10 agents)—— foundation(types)→ 3 组文件互斥并行修复(module fixers)→ **verify 循环至 tsc 干净 + 全绿** → 5 路对抗式 re-audit 确认每个 exploit 已闭合。
- **审计结论**: **relay-e2e = PASS 零改动**(所有候选在 verify 阶段被驳回:确定性 nonce 安全 —— 方向分离 + 真实 agent 侧 `createReplaySealer` 各自持独立单调 seq;strict-successor recv guard 无法导致 replay-accept)。**relay-auth = approve-with-changes,5 条确认**(2 HIGH / 1 MED / 2 LOW),全部在 human-auth/enforcement 层,全部包内可修。驳回但存档(不再复议):E2E replay nonce reuse、mTLS EKU、SPIFFE 域未 pin、reattach host-scope、TOTP 时序。完整报告见 `docs/REVIEW_RELAY_SECURITY.md`
- **修复**(均含回归测试): **F1 HIGH** step-up 因子降级 —— `needsStepUp` 现把新鲜度绑定到**精确方法**(`stepUpMethod`),钓来的 TOTP 不再满足 passkey;**F2 HIGH** step-up 在新建会话路径被完全跳过(生产 caller `term-relay/data-plane/upgrade.ts:105` 硬编码 `principal:null` → 生产环境 step-up 形同虚设)—— 改为**主机驱动 + fail-closed**(`policy.required` 时 principal 缺失/过期/错方法一律 403);**F3 MED** WebAuthn 断言未绑定已存凭据 —— 现校验 credentialId 且转发 pubkey 给 verifier;**F4 LOW** DPoP verify 未捕获抛出 → INV15 门未审计地崩(审计逃逸+DoS)—— `verifyDpopProof` 全体 try/catch→false + `coreAuthorize` 边界兜成干净审计 401 + issue 校验 cnfJkt 格式;**F5 LOW** 验证过的 signCount 被丢弃 → 克隆检测失效 —— `finishAuthentication` 现返回 `{principal,newSignCount}` 供持久化。
- **验证(orchestrator 亲验)**: relay-auth `tsc --noEmit` 干净 + **122 测试全绿**(基线 104,+18 回归);relay-e2e 未动、tsc 干净、76 全绿。re-audit **5/5 闭合**(置信 0.950.97,均有回归测试)。
- **遗留/behavioral note(非阻塞)**: F2 改为 fail-closed 后,**任何 `stepUpPolicyFor(host).required===true` 的主机,P1(term-relay)必须在 upgrade ctx 传入已认证 principal**,否则正确地被拒;当前 v0.9 默认 `NO_STEPUP_POLICY.required===false`,现网非 step-up 主机不受影响。P1 集成时须补此接线(见 [[relay-stepup-needs-principal-wiring]])。
- **第二轮独立 review(Fable 5,换模型增强多样性)+ F6 修复**: 第二个 `Workflow`(fix-regression 猎取 + 新角度 sweep + 对抗式 re-attack 被驳回项)判:**5 条修复 0 regression**、被驳回/D-i-D 项 re-attack **0 存活**(确认真不可利用),但**新增 1 条 HIGH——F6**:relay-e2e `replay-key.ts` 的可恢复 `K_content` 仅由 `(hostContentSecret, sessionId)` 决定(稳定),而真实消费者 `agent/src/e2e/replaySeal.ts``let seq=0n` 在每次重建(重启/重连)时归零 → 两代 sealer 在**相同 (key, nonce)** 下封不同明文 = 灾难性 AEAD 复用。**第一轮曾误驳此项**(verifier 看到"独立单调计数器"就类比 live 路径判安全,漏了 K_content 跨重启稳定);第二轮定向 re-attack + orchestrator 亲读 `replaySeal.ts` 证实为真。**修法(用户选 epoch-in-key,4 包)**:`ReplayKeyParams``epoch`;`deriveContentKey` 把 epoch 以 `sessionId‖0x1f‖epoch` 混入 HKDF salt;`createReplaySealer` 每代 `randomUUID()` 新 epoch 并暴露;`ReplaySource` 带 epoch、浏览器按之重推。**验证(亲验)**:4 包 tsc 干净 + 全绿(relay-contracts 81 / relay-e2e 78[+2 F6 回归] / agent 133 / relay-web 99);re-audit 判闭合(回归证:两代同 seq=0 nonce 但不同 key)。**残留(fail-closed 非复用)**:ring-buffer 传输(P1/P2)须持久化并按代下发 epoch —— 已记 TODO,当前 replay 路径尚未端到端接线(`manage-page.loadReplay` 仍是 throw stub),故 F6 此刻是潜伏漏洞。完整报告 `docs/REVIEW_RELAY_SECURITY.md`(含 F6 + RESOLUTION)。
- **动态 e2e 安全测试台(新增 `e2e/` 包)**: 跨包 harness `buildRelayWorld()` 把**真实** P5(auth)+P4(crypto)+P2(agent replay) 通过内存 seam + 不可信 relay「RelaySpy」攻击者视角接起来(仅 I/O 边界 fake,所有安全检查走生产代码路径)。全流程 issue cap→upgrade/authz→handshake→sealed session 过 relay→reattach→revoke。**21 测试全绿、tsc 干净(亲验)**。把 F1/F2/F3/F4/F6 从「静态+单测」升级为「动态攻击者实测」,另加 MITM/reflection/replay/reorder/INV2(live+replay)/cross-tenant/single-use。F5(signCount)为返回形状改动、由 relay-auth 单测覆盖;RelaySpy 代替 term-relay mux(真多进程/浏览器 e2e 需尚未建的 run tooling)。用符号链接免 `npm install`(`e2e/node_modules/*` → 各包源码)。覆盖表见 `docs/REVIEW_RELAY_SECURITY.md`。**未提交**,留待用户决定。
### ✅ 桌面客户端 v0.1 — Electron 一体化壳(代码完成 — 2026-07-01,分支 `feat/desktop-electron`,未提交)
Mac/Windows 桌面 App,**内嵌现有 Node 服务器 + node-pty**(all-in-one):主进程 `startEmbeddedServer` 复用 `src/server.ts``startServer(cfg)`/`loadConfig`(**服务器零改动** —— 它本就导出可编程启动且 import 无副作用),窗口 `loadURL('http://127.0.0.1:<port>/')` 复用**前端零改动**(前端严格同源,`location.host` 自动指向内嵌服务器;Origin 白名单默认含 localhost)。核心价值 = **原生通知**(轮询 `/live-sessions``computeNotifications` → 系统通知)+ 托盘常驻 + 深链 `terminalapp://` + 开机自启。
- **编排**: ultracode `Workflow` —— 脚手架(orchestrator 冻结 `desktop/src/types.ts` 契约 + package/tsconfig/esbuild/electron-builder)→ Build(4 builder 并行、文件互斥、对齐冻结签名)→ Review(correctness/security/typescript 三 lens 并行、schema 结构化 findings)。纯逻辑尽量抽出可单测;Electron glue(main/window/tray/menu/preload/embedded-server/logger)作为不可单测 wiring 排除出覆盖率(沿用既有 vitest.config 先例)。**无用 zod、无用 electron-store**(遵循项目手写校验 + 手写 JSON 持久化的最小依赖惯例)。
- **交付**(`desktop/src/` 17 文件 + `test/desktop/` 9 测试文件): 纯模块 `port`(pickFreePort)/`shell`(平台默认 shell,Win→powershell)/`server-config`(env 覆盖映射)/`deep-link`(解析+防注入)/`notify-policy`(通知策略)/`notifications`(批量 diff)/`live-poll`(边界校验 /live-sessions)/`prefs`(手写校验)/`settings-store`(JSON 持久化);glue `embedded-server`/`window`(加固:contextIsolation+sandbox+拒绝外域导航)/`menu`/`tray`/`preload`(最小 bridge)/`main`/`logger`
- **Review 结果**: **无 CRITICAL/HIGH 安全问题**(Electron 加固与不可信输入处理被评为扎实)。修复的真实 bug(orchestrator 直接改+补测): ① 冷启动深链丢失(缓冲 open-url/argv 到 ready 后派发) ② `createTray` 无守卫会崩启动(try/catch,失败无托盘继续) ③ 首轮轮询通知轰炸(首 tick 只建基线) ④ poller 捕获过期 prefs(每 tick 读 settings.get) ⑤ notify 'tool tool' 病句→'tool approval' ⑥ live-poll Windows 反斜杠路径 + 未过滤 exited ⑦ `NotificationsResult.next`→ReadonlyMap。
- **验证**: 桌面 `tsc --noEmit` 0 错;**全量 1401 tests 绿**(基线 1307 +94 桌面,零回归);覆盖率全局 91.65/85.39/92.44/93.48(阈值 80×4 通过)、`desktop/src` 97.56/95.04/100/97.81;esbuild 打包 `main.cjs`/`preload.cjs` 产出、electron/node-pty 保持 external、dist 走运行时动态 import。
- **本环境无法验证(留真机/CI,DESKTOP_PLAN P1 D5D7)**: `.exe`(Windows,需真机/无 wine)。**审批门通知源**(`/live-sessions` 不带 pendingApproval)留 P2/D8 接 hook 侧信道 —— 策略已实现且测试,当前以 status→'waiting' 为代理。方案文档 `docs/DESKTOP_PLAN.md`
- **✅ Mac 打包 + 启动验证(2026-07-01,本机 arm64,已实测)**: `electron-builder``.dmg`(arm64,深度 ad-hoc 签名)。**关键修复**:首版只打了 node-pty、漏了服务器其它 npm 依赖(express/ws/web-push),导致**仅原地能跑**(沿目录树借了仓库根 `node_modules`),移到 `/Applications` 即崩 `Cannot find package 'express'`。改 `electron-builder.yml`:把 `dist/` + `public/` + `node_modules`(过滤 electron/electron-builder/esbuild/typescript 等构建工具;node-pty 经 `npmRebuild` 为 Electron 43 ABI 重编译)**全部铺到磁盘 Resources**(Model 2,与 `embedded-server.ts``process.resourcesPath` 解析匹配),desktop/package.json 补 express/ws/web-push 依赖。**仓库外副本直接执行验证通过**:服务器起、`GET /` + `/build/main.js → 200``/live-sessions → []`、node-pty 加载 OK、无 `Cannot find` 报错。`main.ts` 默认不注册开机自启(消除 native "Operation not permitted" 噪音)。**Gatekeeper**:未签名 ad-hoc 在 Sequoia(darwin24)双击被拦 → `xattr -cr` 清 quarantine/provenance + 深度签名后 `open`/双击可正常启动(实测 PID 存活、200)。产物 `desktop/dist-app/Web Terminal-0.1.0-arm64.dmg`(~206M,含完整 node_modules)。**仅 arm64;Intel 需 `--x64`/`--universal`;Windows 需真机;对外分发需 Apple Developer 签名+公证。**
### ✅ VC 语音命令映射 — 语音直接 approve/reject 权限门(完成 — 2026-07-01,分支 `v0.6-projects`,未提交)
上下文门控:仅当活动 tab 有 held 权限门(`pendingApproval===true``gate==='tool'`)时,语音「确认/批准/yes…」→ 复用现有 `{type:'approve'}` WS 通道、「拒绝/取消/no…」→ `{type:'reject'}`;其余一律口述不变。**后端零改动**。安全设计:整句精确匹配(否定/口水词攻不破)+ 前导否定守卫 + reject 优先 + 置信度门(仅 approve)+ **可撤销确认窗口(1.5s,提交时复检门身份/连接,防 TOCTOU)** + stale-gate epoch。新增 `public/voice-commands.ts`(纯匹配器 100% 覆盖)+ `public/voice-confirm.ts`(确认窗口 100%),接线 `tabs/voice/terminal-session/main.ts`。**1307 tests 绿**,双 review(code+security)判 CRITICAL 已全修复并补测。规划 `docs/PLAN_VOICE_COMMANDS.md`,详见 Detailed Log 首条。**待办**:confidence fail-open 取舍留给用户;tabs.ts 968 行待拆分。
### ✅ v0.6 Projects 命名空间自动分组 + 跨设备 prefs(完成 — 2026-07-01,分支 `v0.6-projects`,未提交)
Projects 页去杂乱:按点分隔命名空间自动折叠成 section(`Billo.Platform ·8` / `Billo.Infrastructure ·2` / `Other`)+ 置顶 "Active now" + 无前缀时退化为扁平网格。收藏/折叠态改**服务端持久化**(`GET/PUT /prefs``~/.web-terminal-prefs.json`,离线回退 localStorage 镜像 + 迁移旧 `proj-favs`)跨设备同步。1227 tests 绿,真浏览器验证折叠跨 reload 从服务端载回。详见 Detailed Log 首条。
### ✅ v0.6 Project Manager 项目工作台(完成 — 2026-06-30,分支 `v0.6-projects`)
- **全部 6 任务完成并验证**(P1P6,多 agent 编排)。**398 测试全绿**(自 341 +57)· 双 typecheck 干净 · `build:web` OK · 覆盖率 90.98/81.95/89.28/93.16(阈值 80×4 通过)· **真浏览器 smoke 通过**
- **编排方式**: ultracode `Workflow` 动态编排——Build(P2 后端 ∥ P5 前端,`module-builder`)→ Wire(P4 端点 ∥ P6 接线)→ Review(`module-reviewer` 验收 + `typescript-reviewer` + `security-reviewer`,report-only 并行)→ 对每个 HIGH/CRITICAL 对抗式 Verify(`module-reviewer`)→ Fix(`module-builder` 仅改已确认项)。**P6 首跑因 transient `server_error` 挂掉(未落任何改动)→ stop + `resumeFromRunId` 恢复:P2/P4/P5 命中缓存秒回,P6 重跑成功。**
- **交付**:
- **P2** `src/http/projects.ts`(15 测试,93.37% 覆盖): `parseGitHead`(纯) + `buildProjects(cfg, liveSessions)`;BFS 扫 .git(深度上限、跳过 node_modules/dotdir/symlink、命中即停下钻)→ 读 `.git/HEAD` 分支 + `git status --porcelain` dirty(`execFile` 无 shell、2s 超时、并发 8)→ 合并 `~/.claude/projects` cwd(复用 `history.listSessions`)→ 按 cwd 前缀归并 live sessions(每次 fresh)→ TTL 缓存(仅缓存磁盘发现)+ **in-flight 去重**
- **P5** `public/projects.ts`(纯 helper 30+ 测试) + style 追加: `mountProjects`(镜像 `mountLauncher`);1:N 卡片(分支 chip / dirty 点 / session 行 / ` start claude here`)、搜索过滤、收藏置顶(localStorage);`.home-seg` 段控样式也在此定义。
- **P4** `src/server.ts`: `GET /projects`(只读、无 Origin 守卫、try/catch 兜底 `[]`)+ 5 集成测试(真起 server)。
- **P6** `public/tabs.ts`: `openProject`(同名 `#n` 后缀、`addEntry(null,label,cwd,'claude\r')`)+ `countOpenWithTitlePrefix` + 首页 **Sessions↔Projects 段控**(`updateHomeView`);7 新测试。
- **Review 12 findings → 4 HIGH 全确认并修复**: ① **A3 关键**:`openProject`/`newTabForResume` 缺回车 `\r`(claude 不自动执行)→ 补 `\r`;② 段控被定位面板盖住 → 面板 `inset:44px 0 0 0`;③ `mergeHistory` 串行 I/O → `mapWithConcurrency`;④ discover 并发缓存击穿 → in-flight promise 去重。
- **Orchestrator 追加加固**(MEDIUM/LOW,本人直接改 + 补测): 前端 `fetchProjects``normalizeProject`(API 响应边界校验,缺 `sessions` 不再 TypeError)+ `getFavs` 过滤非字符串(localStorage 防污);`config.parseProjectRoots` 相对路径 fail-fast(+ config 补 v0.6 解析测试,config.ts 95.87%→98%)。`style.css` 1143 行 > 800 上限属**既有债**(v0.6 前已超),esbuild 仍打单文件,留作后续拆分。
- **真浏览器 smoke**(`PROJECT_ROOTS=~/Documents/git`,83 真 repo): 首页段控 Sessions/Projects 切换 ✓;Projects 面板 83 卡片(`web-terminal` 显示 `branch:v0.6-projects` + dirty 正确,含 `feat/...` 斜杠分支)+ 搜索框 ✓;点 `web-terminal` 卡 → 开 tab 标签 `web-terminal`、首页隐藏、**终端出现 claude 启动界面**(A3 端到端)✓;console 仅既有 `/favicon.ico` 404 + apple-mobile 警告,无 v0.6 报错。
- **待办**: 真机/手机验收(同历史 F4/F7 遗留);可选 server.ts 抽 routes;`style.css` 拆分。
- **当前阶段**: **v0.3 全部完成**(H1H4 + M3/M6/M7 已并入 main;**O2 历史浏览** 在分支 `o2-history`,待合并)。
- ✅ Step1 前端快赢 · ✅ H2/H4 状态感知 · ✅ H3 远程批准 · ✅ H1 tmux 保活 · ✅ M3/M7/M6 · ✅ **O2 历史会话浏览/resume** · ✅ tech-debt 清理(@ts-ignore 去掉)· ✅ UI:终端不再打印 Connecting/Connected(靠标签点)。
-**现代化 UI 主题**(design-token 调色板/靛蓝强调色/圆角芯片标签+键栏/毛玻璃弹层)· ✅ **键栏功能字幕**(每键下方标作用)+ 新增 ^R/^L/^D(共 17 键)· ✅ **⌨ 快捷键速查弹层**(`public/shortcuts.ts`)。
-**v0.4 多设备共享会话**:放宽不变式 #5 —— 一个 session 可挂多个 WS 客户端(镜像;输出/退出/状态广播,谁都能打字)。`GET /live-sessions` + 打开应用自动把主机活动会话**显示为 tab**;`🔗` 分享二维码 / `?join=<id>` 加入指定会话。
-**镜像尺寸 = 最近活跃端优先(latest-writer-wins)**:共享 PTY 只能一个尺寸,之前取最小值导致宽屏桌面被 iPad 夹小(iPad 全屏、桌面留黑边)。改为"最近 fit/聚焦的设备决定尺寸",你正在用的设备永远全屏;attach/detach/`blur` 都不改尺寸,前端在 pane 显示 + 窗口聚焦时重发 dims(切设备即夺回全屏)。实测两端:200×50 → iPad 100×40 → 桌面 refit 200×50 → iPad blur 不变。后端纯客户端实测镜像输出 + 共享输入通过。
-**🗂 会话管理页**(`/manage.html` 独立页,非弹窗):**实时预览缩略图网格** —— 每张卡片用只读 xterm 渲染该 session 当前屏幕(像截图,缩放),一眼看出每个 session 在干嘛;`RingBuffer.tail()` + `GET /live-sessions/:id/preview`(不 attach、不影响观看数/回收),每 4s 刷新;可点缩略图/Open 进入、Kill / 批量 Kill。实测 3 个 session 缩略图(top / git log / ls)彩色可辨,Kill 3→2→0。
-**v0.5 首页 = 会话选择器(launcher)**:打开应用不再自动建/恢复 tab,而是落到选择器 —— 主机所有运行中 session 的实时缩略图网格,用户自己点开(回放完整 scrollback)或 ` New session`;关掉最后一个 tab 回到选择器。`public/launcher.ts`,复用 `.mg-*` 卡片样式。实测:首次加载 0 tab + 缩略图;Open→建 tab、选择器隐藏;关最后一个→选择器回来。
- ⬜ 仅剩:**O1 token 认证**(可选,默认关)+ **F4/F7 真机验收**(局域网/手机)。
- **225 测试全绿**;工具栏 🔍搜索 ⚙设置 ▦仪表盘 🕘历史 ⌨快捷键 🔗分享 🗂管理 📱QR。
- **下一步**: O1(可选)或真机验收。
- v0.2/v0.1 历史见下方条目。
- **Wave**: **W0W5 基本完成**。v0.1 功能完整、测试与真机浏览器验证通过。仅剩 2 项需用户真设备。
- **进度**: **20/21 任务**(T19 待真手机)· **187 自动化测试全绿** · 真 PTY 端到端验证 · **真浏览器 smoke 通过**
- **验收 F1F9**: ✅ F1/F2(浏览器 smoke:渲染+输入输出+全彩)· ✅ F3(resize 无错)· ⬜ **F4 需局域网真机** · ✅ F5/F6(集成 ⑤⑥ 回放 CJK/ANSI)· ⬜ **F7 需真手机**(软键盘栏)· ✅ F8(浏览器 exit→重连提示)· ✅ F9(集成+curl 401)。
- **下一步(交用户)**: 真手机/另一台电脑开 `http://<你IP>:3000` 实测 **F4**(局域网可用)与 **F7**(Esc/Shift+Tab/方向键等触摸键生效)。
- **阻塞**: 无(F4/F7 非阻塞,属需物理设备的人工验收)。
- **次要 tech-debt**: main.ts 1 处 `@ts-ignore`(CSS import);未来加 `*.css` d.ts。
- **最后更新**: 2026-06-20(并入 review 全面修复,见下方条目)
---
## 维护性修复条目 (Review Fixes)
### 2026-06-20 · 按 REVIEW_REPORT 逐项修复(安全/架构/质量/测试)
- **状态**: `[x]` DONE(typecheck 干净 · **341 测试全绿**(16 文件,自 228 起 +113)· `build:web` 通过 · 覆盖率 90.3/80.7/87.6/92.4 ≥80 阈值已在 `vitest.config.ts` 强制)。
- **来源**: 4 个 agent 并行 review 产出 `docs/REVIEW_REPORT.md`;用户决定修全部四档,`/sessions` 保持现状仅加说明,`clientDims`/`blur` 死状态彻底删除。计划见 `~/.claude/plans/piped-marinating-parrot.md`
- **🔴 关键**:
- 多设备审批竞态(`server.ts` ws.close):仅当最后一个 client 离开时才 `resolvePending` —— 关一个镜像不再取消另一台正在看的待批准。
- 会话无上限 DoS:新增 `Config.maxSessions`(默认 50,env `MAX_SESSIONS`)+ `manager.assertUnderSessionCap()`,超限抛错复用 M4 的 exit(-1) 路径。
- 信号处理器泄漏:`onSignal`/`onUncaught` 提具名引用,`close()``process.off` 全移除(去掉测试 `setMaxListeners(50)` 遮掩)。
- 前端 `initialInput` 700ms 定时器:跟踪 `initialInputTimer``dispose()` 清理、`disposed` 守卫、`INITIAL_INPUT_DELAY_MS` 常量。
- `tabs.ts addEntry``null as unknown as TerminalSession` 类型洞:先建 session 再建 entry。
- **🟠 应修**: 安全头中间件(无 helmet)+ `DELETE /live-sessions[/:id]` Origin/CSRF 守卫(403);`history.ts``fs/promises`(`listSessions` 异步,`/sessions` await);**彻底删除 `clientDims`+`blur`**(types/session/protocol/server/manager 注释/terminal-session.hide + 相关测试);WS 连接级限频(`maxMsgsPerSec` 默认 2000,超限丢帧不断连);`/sessions` 保持行为,加注释 + `TECH_DOC §7` 记录已接受风险。
- **🟡 测试**: 新增 `test/{tmux,preview-grid,terminal-session,tabs}.test.ts`(后两者 jsdom + mock WebSocket);扩 `history/config/manager/integration` 覆盖 killById/handleHookEvent/maxSessions/resolveUseTmux/live-sessions/preview-404/approve-reject/origin-guard/限频/信号泄漏回归。
- **⚪ 卫生**: `parsePositiveInt``parseNonNegativeInt`;ALLOWED_ORIGINS scheme 校验;`server.ts` 日志注入 sanitize + `isLoopback` 健壮化(127.0.0.0/8 + IPv4-mapped);运维常量(`PERM_TIMEOUT_MS`/`REAP_INTERVAL_MS`/`PREVIEW_BYTES`)入 Config;抽 `public/preview-grid.ts` 消除 launcher/manage 重复(类型复用 `LiveSessionInfo`);ARCHITECTURE §8 记录运行时句柄可变例外(H6)+ #5/#7/#8 更新;CLAUDE.md「auto-show as tabs」→ v0.5 launcher;「min across clients」陈旧注释清扫。
- **验证**: `npm run typecheck` PASS · `npx vitest run` 341/341 · `npm run build:web` OK · `npx vitest run --coverage` 退出 0(阈值 80×4)。
- **决策 / 偏离**: 覆盖率 `include` 收窄到 `src/**` + 四个有逻辑的前端模块,排除纯 DOM 接线/入口胶水(E2E 范畴),**未下调任何阈值**。未把审批状态机搬进 manager(报告 #10)—— close-handler 修法已正确最小,`server.ts` 抽 routes 留作可选后续。
- **遗留 / 待办**: 真机验收 F4/F7(同 v0.1 遗留);可选 server.ts 抽 `http/routes.ts`;胶水文件若要纳入覆盖率需补 E2E。
- **commit**: (本次提交)
---
## 任务总览 (Task Overview)
> 任务定义见 [PLAN.md](./PLAN.md);按 Wave 分组。`Steps` 完成数 = PLAN 中该任务的勾选项。
> **多 agent 并行**:同一 Wave 内任务互不依赖,可并行;跨 Wave 按依赖推进。
| 任务 | Wave | 模块 | 状态 | Steps (完成/总) | 完成日期 |
|------|------|------|------|------------------|----------|
| T1 | W0 | 脚手架 + 装齐依赖 | `[x]` | 5/5 | 2026-06-16 |
| T2 | W0 | `src/types.ts` 共享契约(冻结源) | `[x]` | 8/8 | 2026-06-16 |
| T3 | W0 | mock IPty 测试替身 | `[x]` | 2/2 | 2026-06-16 |
| T4 | W1 | `config.ts` | `[x]` | 5/5 | 2026-06-16 |
| T5 | W1 | `protocol.ts` | `[x]` | 4/4 | 2026-06-16 |
| T6 | W1 | `ring-buffer.ts` | `[x]` | 4/4 | 2026-06-16 |
| T7 | W1 | `http/origin.ts` | `[x]` | 3/3 | 2026-06-16 |
| T8 | W1 | `public/index.html` | `[x]` | 1/1 | 2026-06-16 |
| T9 | W1 | `public/style.css` | `[x]` | 1/1 | 2026-06-16 |
| T10 | W1 | `public/keybar.ts` | `[x]` | 4/4 | 2026-06-16 |
| T11 | W1 | `public/main.ts` | `[x]` | 6/6 | 2026-06-16 |
| T12 | W2 | `session/session.ts` | `[x]` | 7/7 | 2026-06-16 |
| T13 | W3 | `session/manager.ts` | `[x]` | 6/6 | 2026-06-16 |
| T14 | W4 | `server.ts` 接线 | `[x]` | 6/6 | 2026-06-17 |
| T15 | W4 | 集成 / E2E 测试 | `[x]` | 2/2 | 2026-06-17 |
| T16 | W5 | 验收 F1F3(终端/TUI/resize) | `[x]` | 3/3 | 2026-06-17 |
| T17 | W5 | 验收 F4+F9(LAN/Origin) | `[~]` | 1/2 | 2026-06-17 |
| T18 | W5 | 验收 F5+F6(保活/回放) | `[x]` | 2/2 | 2026-06-17 |
| T19 | W5 | 验收 F7(移动键栏) | `[ ]` | 0/1 | 需真机 |
| T20 | W5 | 验收 F8(退出处理) | `[x]` | 2/2 | 2026-06-17 |
| T21 | W5 | 安全核对 + README + LOG 收尾 | `[x]` | 3/3 | 2026-06-17 |
---
## 验收清单映射 (Acceptance — TECH_DOC §8)
> 功能验收独立于阶段勾选;F* 全绿才算 v0.1 完成。
| 编号 | 功能 | 状态 | 验证方式 / 备注 |
|------|------|------|-----------------|
| F1 | 交互式终端(ls/补全/Ctrl+C/历史) | `[x]` | 浏览器 smoke:输入→输出往返 |
| F2 | 全彩 + 全屏程序(vim/top/claude) | `[x]` | 浏览器 smoke:彩色提示渲染(深 TUI 同理) |
| F3 | 终端自适应(resize 不错位) | `[x]` | viewport 变更存活、无 error |
| F4 | 局域网访问(手机/他机) | `[ ]` | **需真机**(同 WiFi 开 http://<IP>:3000);Origin 白名单已含网卡 IP |
| F5 | 会话保活(断开重连仍在跑) | `[x]` | 集成 ⑤;tmux 重启保活(H1) |
| F6 | 重连回放(环形缓冲) | `[x]` | 集成 ⑥:ANSI/中文不乱码(M2) |
| F7 | 移动快捷键栏 | `[ ]` | **需真手机**(触摸键生效、不弹软键盘) |
| F8 | 进程退出处理 | `[x]` | 浏览器 smoke:exit→重连提示;detach 后退出 L1 |
| F9 | Origin 校验(401) | `[x]` | 集成 ①②;curl 坏 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-07-01 · VC 语音命令映射 — 上下文门控 confirm/reject(多 agent 编排 + 双 review)
- **状态**: `[x]` DONE(未提交,分支 `v0.6-projects`)
- **背景**: 已有 v0.7 A2 push-to-talk 只做「语音→文字」口述;本次加**语音命令层**,让语音直接 approve/reject 权限门。规划见 `docs/PLAN_VOICE_COMMANDS.md`(多 agent:理解→前后端计划→4 路对抗 review→综合)。锁定决策:①上下文感知(仅当活动 tab `pendingApproval===true` 才当命令,否则口述不变)②只做 confirm 类,复用现有 `approve`/`reject` WS 通道(**后端零改动**)③危险键不接线,two-step 组件仅用于 approve 确认窗口。
- **改动**:
- 新增 `public/voice-commands.ts`(纯匹配器,**100% 覆盖**):`matchCommand` = 整句精确 + 前导否定守卫 + reject 优先 + 置信度门(仅 approve);中英确认/否定词表。
- 新增 `public/voice-confirm.ts`(确认窗口控制器,**100% 覆盖**):`createApproveConfirm(1500)` arm/cancel/isArmed,纯计时(注入 setTimeout,无 DOM)。
- `public/voice.ts`:`onTranscript` 多传 `confidence`(仍只转写)。`public/terminal-session.ts`:加 `pendingEpoch`(false→true 翻转自增,stale-gate nonce)。`public/tabs.ts`:`handleVoiceTranscript` 分派 + 确认窗口 arm + WS 断开闪「未连接」+ 单独 `#voice-confirm` overlay(避 hideInterim 清屏)。`public/main.ts`:接 `onVoiceTrigger` 点亮 🎤。`vitest.config.ts`:两新模块入 coverage.include(orchestrator 协调编辑)。
- 测试:`test/voice-commands.test.ts`/`test/voice-confirm.test.ts` 新建;`test/tabs.test.ts` 加 VC 集成(含 6 条 review-fix 用例);`test/voice.test.ts` 4 处旧断言随 `(text,confidence)` 新签名更新 + 补 confidence 转发断言。
- **验证**: `npm run typecheck` 干净;`npm test`**1307 passed / 43 files**;`build:web` OK;覆盖率 voice-commands/voice-confirm **100×4**、tabs.ts 94/84/89/97、TOTAL 91.3/84.6/91.7/93.2(80×4 通过)。`git diff src/` 空(后端 NONE 成立)。
- **决策 / 偏离 PLAN**:
- **实现 workflow 首跑失败**(review 阶段某 agent 6 次 stall)——但 Build V1/V2 已落文件;改为 orchestrator 亲自跑验证 + 用普通 agent 并行做 code/security review(避开 workflow 卡死)。
- **两份 review 独立收敛,判 CRITICAL/BLOCK**,均已修复并补测:①**确认窗口 TOCTOU**:`onCommit` 现复检 `pendingApproval && gate==='tool' && pendingEpoch===armedEpoch && connected`,否则丢弃;②**`cancel()` 从未接线**:接入新话语顶替(handler 顶部)、reject 分支、`#voice-confirm` 点击;③**WS 断开延迟提交错位**:提交前复检连接。
- **偏离 PLAN §4**:「松开麦克风取消」在 PTT 时序下不成立(松麦克风正是触发最终转录/arm 的动作),改为 **tap + 新话语顶替 + reject 取消** 三条真实取消路径。
- **`startsWithNegation` 重写**(review MEDIUM):原 `prefix+rest[0]` 实际只测 prefix[0](巧对但脆弱),改为显式测 `rest`
- **遗留 / 待办**:
- **confidence fail-open**(review MEDIUM,**保留**):`confidence===undefined` 时置信度门短路(符合 §D「有置信度时才门控」),靠确认窗口兜底误听;若要 fail-safe(undefined→不批准)是一行改动,但会让不报告置信度的引擎(部分非 Chromium)语音批准失效——留给用户定。
- **`public/tabs.ts` 已 968 行 > 800 上限**(既有债 + 本次 ~99 行):本次仅加最小 diff,**待后续抽 `voice-controller` 拆分**(decision F)。
- **commit**: N/A(未提交)
- **状态**: `[x]` DONE。双 typecheck 干净 · `build:web` OK · 全量 `vitest run` **1227 passed (41 files)** · 真浏览器端到端验证通过。
- **需求**(用户): Projects 页扁平网格太乱(50100 repo)。多 agent 探索(4 路:自动分组/手动文件夹/密度分段/竞品调研)结论一致 → **主方向=按点分隔命名空间自动分组**(repo 名如 `Billo.Platform.Payment` 天然带层级),配 **Active-now 置顶**;手动文件夹只是加分项非主线。用户选:**范围=自动分组+Active band**,**持久化=服务端**(收藏/折叠态跨设备同步,修掉 localStorage 每设备各一份的缺陷)。
- **改动**:
- 后端: `src/types.ts``UiPrefs {favourites[], collapsed{}}` + `Config.prefsStorePath`;`src/config.ts` 解析 `PREFS_STORE_PATH`(默认 `~/.web-terminal-prefs.json`,仿 push-store);新增 `src/http/prefs-store.ts`(`sanitizePrefs`/`loadPrefsStore`,镜像 subscription-store:全字段有界强制、缺失/损坏→空默认、不可变替换);`src/server.ts``GET /prefs`(只读无 Origin 守卫,同 /projects)+ `PUT /prefs`(状态变更→`requireAllowedOrigin` CSRF 守卫 + body sanitize)。
- 前端: 新增 `public/prefs.ts`(`loadPrefs/savePrefs`:服务端为准、离线回退 localStorage 镜像、迁移旧 `proj-favs`);`public/projects.ts` 加纯函数 `groupProjects()`(depth-2 命名空间键 + `MIN_GROUP_SIZE=2` 阈值→单成员落 "Other" + 无命名空间收益时退化为单 flat 组 + 命名空间组按最近活跃排序 + 复制活跃项进置顶 "Active now" 组)、`displayLabel()`(组内卡片只显示前缀后的尾段,如 `Payment`)、`makeGroupHeader()`(caret+label+count+"N active" 徽标,可折叠/键盘可达,Active-now 不可折叠);`makeProjectCard` 加可选 `displayName` 形参;`renderGrid` 改为按组渲染(每组一个 `.proj-grid-cards` 子网格 + 折叠态来自内存 prefs + 搜索时强制展开命中组 + 保留 scroll);`mountProjects` 状态改用内存 `favs`/`collapsed`,`init()` 首次 `loadPrefs` 后再渲染,5s tick 只拉 projects 不重载 prefs(避免覆盖本地编辑);折叠/收藏改走 `persist()``style.css`:`.proj-grid` 改为纵向 section 栈 + 新 `.proj-grid-cards` 承接原 minmax 网格 + `.proj-group*` 组头(sticky、hover、focus-visible、`● N active` 绿色徽标)。
- **未做(按用户选的最小范围)**: 密度/紧凑列表切换、group-by 控件、frecency、模糊搜索、手动文件夹——均为探索报告里的后续可选层。
- **验证**: `npx tsc --noEmit` 干净;`npm run build:web` OK(bundle 665kb);`vitest run` 1227 passed(新增 `test/prefs-store.test.ts` 12 项 sanitize/load/persist + `test/projects-panel.test.ts` 加 21 项 groupProjects/displayLabel)。真浏览器(临时 server + 假点名 repo):Projects 页渲染出 `▼ Billo.Platform ·8`(卡片尾段 B2B.Api/Payment/…)、`▼ Billo.Infrastructure ·2`(EventBus/MongoDb)、`▼ Other ·12`;点组头折叠 → live DOM `collapsed`+0 卡、`PUT /prefs``{"Billo.Platform":true}`;**整页 reload 后仍折叠(从服务端 /prefs 载回)**=跨设备持久化打通。控制台 503/404 为既有(无 VAPID 的 push + 缺图标),非本次改动;/prefs、/projects、/config/ui 均 200。
- **决策 / 偏离 PLAN**: 无既有 PLAN 覆盖此增量;探索用 4 个并行 general-purpose agent(用户明确"用多 agent 探索")。`getFavs/saveFavs` 仍导出(旧测试引用)但 mountProjects 不再调用,由 `public/prefs.ts` 取代。
- **遗留 / 待办**: ① 两个同名 depth-2 repo(如两个 `Billo.Customer`)会成一个以自身命名的组、卡片显示全名(displayLabel 无尾段)——功能正确、观感略冗余,可接受。② 5s tick 不重载 prefs → 另一设备的改动要下次进页才见(单用户可接受)。③ 后续如上手动文件夹/密度切换,持久化接口已就绪(prefs-store 可扩字段)。
- **commit**: (待提交)
### 2026-06-30 · v0.7 Walk-away Workbench — 多 agent 并行实现 (Band A+B)
- **状态**: `[x]` BUILT + 全量门禁绿(详细验收/打磨待续)。双 typecheck 干净 · `build:web` OK · 全量 vitest + 覆盖率 91.37/84.06/92.18/93.37(≥80×4)。
- **实现**: 按 `docs/PLAN_WALKAWAY_WORKBENCH.md` 的 27 任务波次(R0→W0→W1×14→W2→W3→W4)以 `module-builder` 并行实现;**23 任务 done, 0 blocked**。功能: A1 Web Push + 锁屏审批(新依赖 web-push)、A2 语音口述、A3 快捷回复 chips + 提示词调色板、A4 活动时间线、A5 卡住/静默告警;B1 只读 git diff、B2 statusLine 遥测仪表、B3 从 UI 建 worktree、B4 plan/权限模式中继。新增 `src/push/*``src/http/{diff,statusline}.ts``public/{diff,timeline,...}.ts`、statusLine 脚本等;扩 hook/manager/server-wire/termsession/tabs/projects-ui/sw。
- **Orchestrator 修复 (W0 协调缺口)**: T-types 漏给 `Config` 接口加 21 个 v0.7 字段(只在 config.ts 返回对象里);手工把 21 字段补进 `src/types.ts` 的 Config + 调 `config.ts`(签名回 `:Config`、base 去 satisfies)使其编译。移除过严的 W0 gate(全量 tsc 在契约领先实现期本就不能过——该检查归 W4/收尾)后 `resumeFromRunId` 续跑 W1W4。
- **W4 评审**: 安全 6 项全过(每个状态变更路由带 Origin/CSRF;VAPID 私钥不外泄/不记日志;git `execFile` 无注入;WS Origin 防御无回归)。**遗留(非阻塞, 无 CRITICAL/HIGH)**: ① protocol.ts `approve.mode` 校验应入 `parseClientMessage`(现 server.ts `parseApproveMode` 兜底, 功能与安全正确)② CSP `connect-src` 去掉 `ws:/wss:` 通配(既有, 低危)③ worktree `base` ref 加格式校验 ④ protocol/preview-grid 补 2 个针对性测试。完整 27 条任务日志见 workflow result 文件。
- **验证**: `npx tsc -p tsconfig.json/.web.json --noEmit` 干净;`npx vitest run --coverage` 全绿 ≥80×4;`npm run build:web` OK;`web-push` 已 npm install。
- **commit**: (本次提交)
### 2026-06-30 · v0.6 工具栏图标换成线性图标 + hover tooltip
- **状态**: `[x]` DONE。web typecheck 干净 · `build:web` OK · 真浏览器验证通过。
- **需求**(用户): 工具栏那些 emoji 图标(🔍⚙▦🕘⌨🔗📱)不好看,换成符合主题色的图标;hover 时显示图标含义。
- **改动**(纯前端): `public/icons.ts` 加 7 个 **lucide 线性图标**(MIT,`stroke=currentColor`,脚本从 unpkg 抓取生成):`ICON_SEARCH/SETTINGS/DASHBOARD/HISTORY/KEYBOARD/SHARE/DEVICE`。7 个工具栏模块(search/settings/dashboard/history/shortcuts/share/qr)把 `textContent=emoji` 换成 `innerHTML=ICON_*` + `dataset.tip`(tooltip 文案)+ `aria-label``style.css` `.toolbtn`:`svg` 18px、默认 `--text-dim`、hover 变 **Amber**;新增 `[data-tip]::after` **CSS hover tooltip**(右对齐避免右边缘裁切)。
- **验证**: `npx tsc -p tsconfig.web.json` 干净;`build:web` OK;bundle 内 0 toolbar emoji;真浏览器:7 按钮均为线性图标(`--text-dim` 色)、hover 变琥珀 + tooltip(实测 "All sessions" 等显示)。
- **遗留**: share/qr 的 aria-label 仍是旧描述(无害,屏幕阅读器友好);可选统一。
- **commit**: (本次提交)
### 2026-06-30 · v0.6 "New session" 改为网格卡片(去掉别扭的大按钮)
- **状态**: `[x]` DONE。web typecheck 干净 · `build:web` OK · 真浏览器验证通过。
- **需求**(用户): 头部那个大的实心琥珀 " New session" 按钮很别扭(与精致的分段 pill 不协调)。
- **改动**(纯前端 `public/launcher.ts` + style): 删头部 `.launcher-new` 按钮 + `.launcher-actions`;改为 grid 内的 **`.mg-new-card` 虚线" New session"瓦片**(与 session 卡同尺寸,始终排在网格第一格,每次 refresh 重新 prepend),hover 变琥珀。删冗余 `.mg-empty` 空态(瓦片即 CTA)。头部只剩标题 + 段控,更干净。
- **验证**: `npx tsc -p tsconfig.web.json` 干净;`build:web` OK;`preview-grid` 测试 14 全绿;真浏览器:Sessions 网格首格为虚线 New 瓦片 + 其后 session 卡(Open/Kill),无旧大按钮。
- **commit**: (本次提交)
### 2026-06-30 · v0.6 删除 manage 独立页 — Sessions 选择器即会话管理
- **状态**: `[x]` DONE。**453 测试全绿** · web typecheck 干净 · `build:web` OK(仅 main,无 manage 产物)· 真浏览器验证通过。
- **需求**(用户): manage 页面不要了——在 **Sessions 选择器**这里直接管理 session(添加 + 删除)。
- **改动**:
- **删** `public/manage.html` + `public/manage.ts`(及 build 产物);`package.json``build:web`/`dev:web` 去掉 `public/manage.ts` 入口。
- **删** `public/main.ts` 工具栏 🗂 按钮(原跳 `/manage.html`);**删** `public/launcher.ts` 头部 "🗂 Manage" 链接。
- **加** `launcher.ts` 每张 session 卡 **Kill ✕**(`makePreviewCard``extraActions`,复用 manage 的 `killOne`:`DELETE /live-sessions/:id` → refresh)。`.mg-kill` 样式已存在,直接复用。
- 后端 DELETE/preview 路由保留(仍由 launcher kill + 缩略图 + projects 复用);注释里的 "manage page" 字样属历史,无害。
- **结果**: Sessions 视图 = 完整会话管理——**New session**(加)· **Open ↗**(进)· **Kill ✕**(删),配实时缩略图。bulk「Kill all/detached」未迁移(如需可加)。
- **验证**: `npx tsc -p tsconfig.web.json` 干净;`npx vitest run` 453/453;`build:web` 仅出 main(无 manage.*);真浏览器:Sessions 卡含 Open+Kill、无 Manage 入口、点 Kill 2→1、`GET /manage.html`→404。
- **遗留**: manage-only 死 CSS(`#manage-root`/`.mg-header`/`.mg-bar`/`.mg-title`/`.mg-sub`/`.mg-btn`)留作可选清理;README 若提 manage 页待更新。
- **commit**: (本次提交)
### 2026-06-30 · v0.6 项目网格卡片 — 直接关掉 session
- **状态**: `[x]` DONE。51 projects-panel 测试全绿(+1)· web typecheck 干净 · `build:web` OK · 真浏览器验证通过。
- **需求**(用户): Projects 页面(网格卡片)下应该能直接关掉 session(此前只有详情页有 Kill,卡片 session 行只能点进入)。
- **改动**(纯前端 `public/projects.ts` + style): `makeSessionRow` 加可选 `onKill` → 行尾 ✕ 按钮(`.proj-session-kill`,stopPropagation 不触发进入);`makeProjectCard` 加可选 `onKillSession` 透传;`mountProjects``killAndRefresh`(`DELETE /live-sessions/:id` 后 refresh)传入网格卡片。CSS `.proj-session-kill`(hover 变红)。
- **测试**(+1): 卡片 session 行——无 onKillSession 时无 ✕;有时点 ✕ 调 onKill(id) 且不触发 onEnterSession。
- **验证**: `npx tsc -p tsconfig.web.json` 干净;`npx vitest run projects-panel` 51/51;真浏览器:web-terminal 卡 2 个 session 行 → 点 ✕ → 杀掉并刷新为 1 行。
- **commit**: (本次提交)
### 2026-06-30 · v0.6 项目详情页 — 展示 CLAUDE.md + /init 生成/更新按钮
- **状态**: `[x]` DONE。**458 测试全绿**(+5)· 双 typecheck 干净 · `build:web` OK · 真端到端(curl + 真浏览器)验证通过。
- **需求**(用户): 详情页展示 CLAUDE.md,加一个按钮自动更新 / 生成 CLAUDE.md。决策(AskUserQuestion): **展示 + 一个交互式 /init 按钮**(非静默后台覆盖,便于 review)。
- **改动**:
- 后端: `ProjectDetail``hasClaudeMd`/`claudeMd?`;`projects.ts` `readClaudeMd`(读 `<repo>/CLAUDE.md`,截断 64KB,best-effort)并入 `buildProjectDetail` 的 Promise.all。
- 前端: `renderProjectDetail`**CLAUDE.md 段**——标题行带按钮(有则 `↻ Update`、无则 `✨ Generate`),内容滚动框(`.proj-claudemd`,monospace,max-h 320);无则空态提示。按钮 → `onOpenProject(path, name, 'claude "/init"\\r')` 开交互式 Claude 跑 `/init`(复用 launcher,零新后端,byte-shuttle 不变)。style 加 `.proj-section-row`/`.proj-claudemd-btn`/`.proj-claudemd`
- 测试(+5): buildProjectDetail 读/无 CLAUDE.md;renderProjectDetail 有内容+Update、无内容+Generate+空态、按钮调 `onOpenProject(...,'claude "/init"\\r')`
- **验证**: `npx tsc`(双)干净;`npx vitest run` 458/458;`build:web` OK;curl `/projects/detail` 返回 `hasClaudeMd:true` + 13KB 内容;真浏览器:web-terminal 详情显示 CLAUDE.md 滚动框 + ↻ Update 按钮(段序 Branch/Active sessions/CLAUDE.md/Start)。
- **注**: `/init``claude "/init"` 作为初始 prompt;若该形式不直接触发斜杠命令,会话是交互式的、用户可手动输 `/init`(一行可调)。
- **commit**: (本次提交)
### 2026-06-30 · v0.6 项目详情页 — branch/worktree + 详细 active session
- **状态**: `[x]` DONE。**452 测试全绿**(+21)· 双 typecheck 干净 · `build:web` OK · 覆盖率 90.16/81.32(`worktrees.ts` 100% / `projects.ts` 93.8%)· 真端到端(curl + 真浏览器)验证通过。
- **需求**(用户): 每个 project 的**详情页**——详细显示具体 active 的 session,以及具体的 **branch 或 worktree**
- **设计**: 点项目卡的**名字**进入应用内详情视图(`renderProjectDetail`,在 Projects 面板内切换,带"← All projects"返回)。分区:① 头部(名/路径/branch chip/dirty)② **Branch / Worktrees**(`git worktree list` 每条:branch 或 `detached @ head` + main/current/locked/prunable 标签 + 路径;单 worktree 即显示分支)③ **Active sessions(N)**(每条:状态点 + 标题 + `状态·👁设备数·started 多久前` + **Open** + **Kill ✕**)④ **Start** 启动器行(复用 Claude/Codex/Code)。再进 Projects 总是回到网格(不残留详情)。
- **后端**: 新增 `src/http/worktrees.ts`(`parseWorktrees`(纯)+ `listWorktrees`,`git worktree list --porcelain`,execFile 无 shell,2s 超时,best-effort→[]);`projects.ts``buildProjectDetail(cfg, path, liveSessions)`(校验绝对存在目录→否则 null;复用 readBranch/readDirty/hasGitEntry/matchSessions;**不缓存**,详情按需要新鲜)。`src/server.ts` `GET /projects/detail?path=`(只读、无 Origin 守卫,同 /projects;path 缺→400、非法/不存在→404)。types 加 `WorktreeInfo`/`ProjectDetail`
- **前端**(`public/projects.ts` + style): `fetchProjectDetail`/`killSession`;`makeProjectCard` 加可选 `onOpenDetail`(名字变可点链接 `.proj-name-link`);`mountProjects` 加 grid↔detail 视图切换 + 自动刷新路由(详情每 5s 刷新);Kill 走 `DELETE /live-sessions/:id`(同源带 Origin)。详情视图样式 `.proj-detail*`/`.proj-wt-*`/`.proj-dsession*`。session meta 在 status=unknown 时省略状态点(不显示 `· ·`)。
- **测试**(+21): `test/worktrees.test.ts`(parse:单/多/detached/locked/prunable/无尾空行/空);`projects.test.ts` buildProjectDetail(相对/不存在/文件→null、非 git 目录、假 repo 读 branch、session 按 cwd 前缀归并、**真 git init worktree**(realpath 规避 /var symlink));`projects-panel.test.ts` renderProjectDetail(null→not found+Back、头部、worktree 行+current tag、detached、session Open/Kill 接线+忽略 exited、空态+启动器、非 git)+ 名字链接。
- **验证**: `npx tsc`(双)干净;`npx vitest run --coverage` 452/452(≥80×4);真浏览器:开 web-terminal 的 Claude session→进详情→显示 branch `v0.6-projects`(MAIN/CURRENT)+ Active sessions(1)(web-terminal,👁1,Open/✕)+ Start 启动器 + 返回。curl `/projects/detail` 返回 worktree(main/current/head)+ 缺/相对 path→400/404。
- **安全**: `/projects/detail` 只读(git branch/status/worktree-list,execFile 无 shell + 超时),path 校验绝对存在目录;与 /projects 同威胁模型(不新增可变更面)。
- **commit**: (本次提交)
### 2026-06-30 · v0.6 项目卡启动器 — Claude · Codex · VS Code logo 按钮
- **状态**: `[x]` DONE。覆盖率 90.6/81.48/88.71/92.71(≥80×4)· 双 typecheck 干净 · `build:web` OK · 真浏览器 + 真 VS Code 端到端验证通过。
- **需求**(用户): 把卡片上大的" start claude here"换成 **Claude logo**;新增 **VS Code logo**(点击在主机打开 VSC)与 **Codex logo** 按钮;logo 编排要"符合应用性"。
- **设计**: 每张项目卡的动作区改为**启动器行**——3 个品牌 logo 图标按钮(logo 在上、小字标签在下、touch 友好、各带 tooltip),有运行中 session 时 session 行列在启动器上方。Claude=珊瑚色爆发、Codex=OpenAI blossom(近白)、Code=VS Code 蓝丝带;logo 用 `fill=currentColor`,颜色由 CSS 品牌色驱动。
- **行为**: **Claude**→新终端 tab 跑 `claude`;**Codex**→新终端 tab 跑 `codex`(复用 `openProject(path,name,cmd)`,`onOpenProject``cmd?` 参数);**VS Code**→`POST /open-in-editor` 让**主机**跑 `code <path>`(文件在主机,故在主机开;不分查看设备)。主机已确认装有 claude/codex/code。
- **活跃高亮**(用户追加): 项目若有**活跃 Claude session**(运行中且 hook 状态 ≠ `unknown`——纯 shell/codex 保持 unknown 不算),Claude logo 按钮**珊瑚色高亮**(coral ring + 淡底 + 浅色标签);>1 个时角标显示数量。`activeClaudeCount(project)` 判定。
- **改动**:
- 新增 `public/icons.ts`(simple-icons 的 Claude/OpenAI/VSCode 单路径 SVG,脚本生成避免手抄出错)。
- `public/projects.ts`: `makeLauncherRow`/`makeLauncher` + `openProjectInEditor`(fetch `/open-in-editor`);`ProjectsHooks.onOpenProject``cmd?`;导出 `makeProjectCard` 便于测试;删旧 `.proj-new` 用法。`public/tabs.ts` 透传 cmd。`public/style.css` 新增 `.proj-launchers/.proj-launch*`(品牌色),删旧 `.proj-new` 规则。
- 后端: `src/http/editor.ts` `openInEditor(cfg,path)`(校验绝对路径+存在+目录→`execFile`(无 shell)启动,best-effort,error 事件兜底+unref);`src/server.ts` `POST /open-in-editor`(Origin 守卫,与 DELETE 同档);`Config.editorCmd`(`EDITOR_CMD`,默认 `code`)入 types/config。
- 测试: 新增 `test/editor.test.ts`(校验 400/404 + `editorCmd='true'` 成功 204);`config.test.ts` 加 editorCmd;`projects-panel.test.ts` 加 makeProjectCard 启动器行(3 按钮/logo/Claude·Codex cmd)。
- **验证**: `npx tsc`(双)干净;`npx vitest run --coverage` 全绿(≥80×4);`build:web` OK。真端到端:curl `/open-in-editor` 无 Origin→403、缺/相对/不存在 path→400/400/404、合法 dir→204 且 **VS Code(Electron pid)实际打开**;真浏览器 Projects 视图每卡 3 logo 按钮(珊瑚/白/蓝,标签 Claude/Codex/Code,tooltip 正确)。
- **安全**: `/open-in-editor` 状态变更→Origin 守卫(CSRF);`execFile` argv 传参无 shell 注入面;path 校验为绝对存在目录;不超出"应用本就给主机 shell"的既有威胁模型。命令来自 config 非用户。
- **commit**: (本次提交)
### 2026-06-30 · v0.6 UI 打磨 — 首页段控重设计 + 选择器隐藏键栏
- **状态**: `[x]` DONE。415 测试全绿 · 双 typecheck 干净 · `build:web` OK · 真浏览器验证通过。
- **反馈**(用户): ① Sessions/Projects 段控在 UI 上"有点别扭"(原为两个独立描边方块浮在顶部);② 首页(Sessions/Projects 视图)底部那排终端快捷键栏没必要显示。
- **修复**(纯前端,`public/style.css` + `public/tabs.ts`): ① `.home-seg` 重设计为 **iOS 风分段控件**——一个圆角"轨道"(`surface-2` 底 + 圆角 999px + 内边距)内含两个 pill 按钮,激活项填 Amber;`width:fit-content + margin:auto` 居中;去掉 first/last/only-child 圆角拼接;面板 `inset` 44→68px 适配新高度。② `TabApp.updateHomeView` 在显示首页时给 `document.body``home-open` 类,CSS `body.home-open #keybar{display:none}` 隐藏键栏;打开 tab(终端激活)即恢复。
- **追加**(用户反馈"段控独占一行、空白太多"): 桌面端(`@media min-width:769px`)把段控 `position:absolute` 浮到**右上角**,与过滤框/启动器表头**同一行**;面板 `inset` 归 0(从顶部铺满),`.launcher-head` 留 240px 右内边距避开段控;移动端保持居中堆叠(防止与全宽过滤框重叠)。另:首页隐藏键栏时 `body.home-open #term{bottom:0}` 收回键栏占位。纯 CSS(`style.css` 直出,无需打包)。真浏览器实测:Projects 段控与过滤框同行(seg.top 56 / filter.top 62)、卡片在其下;Sessions 视图段控不与 New/Manage 重叠。
- **commit**: (本次提交)
### 2026-06-30 · v0.6 UX 修复 — 会话内无法返回 Projects 首页
- **状态**: `[x]` DONE。**415 测试全绿**(+3)· 双 typecheck 干净 · `build:web` OK · 真浏览器验证通过。
- **问题**(用户反馈): 打开某个项目进入终端 tab 后,首页的 Sessions/Projects 段控被隐藏,只能**关掉所有 tab** 才能回到选择器再开下一个项目——没有"返回 Projects"的入口。
- **修复**(`public/tabs.ts` + `public/style.css`): 标签栏新增 **⌂ Home 按钮**;点击把首页选择器(Sessions/Projects 段控 + 对应面板)**叠加显示在当前 tab 之上**(`homeForced` 标志:`updateHomeView` 改为 `tabs.length===0 || homeForced` 时显示并隐藏所有终端 pane)。再点 ⌂ 或点任一 tab(`activate` 重置 `homeForced`)即回到终端;从叠加层开新会话/项目也会自动收起。关最后一个 tab 时复位 `homeForced`。⌂ 叠加时高亮(`.tab-home.active`,Amber)。
- **测试**(`test/tabs.test.ts` +3): ⌂ 叠加首页再切回;叠加层切到 Projects 并开第二个项目后自动收起;0 tab 时 ⌂ 为 no-op。
- **验证**: `npx tsc -p tsconfig.json/.web.json` 干净;`npx vitest run` 415/415;真浏览器:开 shell tab→段控隐藏→点 ⌂→段控+面板叠加显示、⌂ 高亮→切 Projects 面板可见→再点 ⌂→回到终端。
- **commit**: (本次提交)
### 2026-06-30 · v0.6 Project Manager(P1P6,多 agent Workflow 编排)
- **状态**: `[x]` DONE(分支 `v0.6-projects`)。**398 测试全绿** · 双 typecheck 干净 · `build:web` OK · 覆盖率 90.98/81.95/89.28/93.16(≥80×4)· **真浏览器 smoke 通过**
- **目标**: 首页新增 **Projects 视图**——发现主机上的 git 仓库,点卡片 → 开 tab(标签=repo 名、cwd=仓库、自动跑 `claude`);卡片显示分支/dirty/该项目运行中的 sessions(1:N)。设计见 `docs/FEATURE_PROJECT_MANAGER.md`
- **编排**(ultracode `Workflow` 动态): Build(P2 后端 ∥ P5 前端)→ Wire(P4 端点 ∥ P6 接线)→ Review(`module-reviewer` 验收 + `typescript-reviewer` + `security-reviewer` 并行 report-only)→ HIGH/CRITICAL 对抗式 Verify → Fix。**P6 首跑 transient `server_error` 挂掉(0 改动落地)→ `TaskStop` + `Workflow{resumeFromRunId}` 恢复;P2/P4/P5 缓存命中,P6 重跑成功。** 地基 P1(types)/P3(config)上一会话已落,本会话先 commit 为 `dc5d073` 作干净基线。
- **改动**:
- `src/http/projects.ts`(新,P2): `parseGitHead`(纯)、`buildProjects(cfg, liveSessions)`;内部 `scanRepos`(BFS 深度上限/跳 node_modules·dotdir·symlink/命中即停)、`readBranch``readDirty`(`execFile` git status,2s 超时,并发 8)、`mergeHistory`(复用 `history.listSessions`)、缓存 `discoverProjects`(TTL + in-flight 去重)、fresh session 按 cwd 前缀归并、`_clearProjectCache`(测试)。`test/projects.test.ts`(15)。
- `public/projects.ts`(新,P5): `mountProjects` + 纯 helper `filterProjects/sortProjects/getFavs/saveFavs/toggleFav/normalizeProject``test/projects-panel.test.ts`(36)。`public/style.css` 追加 Projects 面板 + `.home-seg` 段控样式。
- `src/server.ts`(P4): `GET /projects`(只读、无 Origin 守卫、try/catch→`[]`)。`test/integration/projects-endpoint.test.ts`(5,真起 server)。
- `public/tabs.ts`(P6): `openProject`(同名 `#n``addEntry(...,'claude\r')`)、`countOpenWithTitlePrefix``updateHomeView` + `.home-seg` 段控。`test/tabs.test.ts` +7。
- **Review→Fix**: 12 findings,4 HIGH 全确认修复——① **A3 关键缺回车 `\r`**(`openProject`/`newTabForResume`)② 段控被定位面板遮挡(面板 `inset:44px 0 0 0`)③ `mergeHistory` 串行→`mapWithConcurrency` ④ discover 并发缓存击穿→in-flight 去重。Orchestrator 追加加固(MEDIUM/LOW + 测试): `normalizeProject`(API 边界校验)、`getFavs` 滤非字符串、`parseProjectRoots` 相对路径 fail-fast、config v0.6 解析补测(config.ts→98%)。
- **验证**: `npx tsc -p tsconfig.json/.web.json --noEmit` 干净;`npx vitest run` 398/398;`npm run build:web` OK;`npx vitest run --coverage` 退出 0(阈值 80×4)。真浏览器(`PROJECT_ROOTS=~/Documents/git`,83 repo):段控切换 ✓、面板 83 卡片(分支/dirty/斜杠分支正确)+ 搜索 ✓、点卡 → tab `web-terminal` + 终端出现 claude 启动界面(A3 端到端)✓、console 无 v0.6 报错(仅既有 favicon 404)。
- **决策 / 偏离**: 缓存只缓存磁盘发现、session 每次 fresh 归并(避免陈旧);git worktree 看板/文件树编辑器/ticket 拉取 = v0.6 明确不做(FEATURE §9)。`public/projects.ts`/`tabs.ts` 的 mount* DOM 接线沿用既有约定不纳入覆盖率(纯 helper 已测)。`style.css` 1143>800 = 既有债,留作拆分。
- **遗留 / 待办**: 真机 F4/F7(同历史);可选 `server.ts``http/routes.ts`;`style.css` 拆分。
- **commit**: (本次提交)
### 2026-06-16 · T1 脚手架 + 一次性装齐依赖
- **状态**: `[x]` DONE
- **改动**: `package.json`(scripts + 全部依赖 + `postinstall`)、`tsconfig.json`(后端 NodeNext/strict)、
`tsconfig.web.json`(前端 DOM/Bundler,当前 noEmit 仅类型检查)、`vitest.config.ts`(node 环境 +
`passWithNoTests`)、`.gitignore``package-lock.json`、目录骨架 `src{,/http,/session} public test{,/helpers,/integration}`(各带 `.gitkeep`)。
- **验证**:
- `npm install` 成功(运行时 72 包 + 开发包,0 漏洞)。解析版本(均比预期新的主版本):express **5.2**
@xterm/xterm **6.0**、addon-fit 0.11、ws 8.21、node-pty 1.1;dev:typescript **6.0**、vitest **4.1**、tsx 4.22、@types/node 25。
- `node -e require('node-pty')` → OK;**真实 PTY spawn** `echo hello-from-pty` → 16 bytes、exitCode 0(沙箱外验证)。
- `npm test` → exit 0(passWithNoTests)。
- **决策 / 偏离 PLAN**:
1. **node-pty spawn-helper 修复**:1.1.0 的 prebuild 自带 `spawn-helper` 但权限 `-rw-r--r--`(缺 +x),
导致 `posix_spawnp failed`。加 `postinstall: chmod +x .../prebuilds/darwin-*/spawn-helper`(对 Linux 无害,`|| true`),
使每次 install / 每个 worktree 自动修复。**T12 spawn PTY 依赖此修复。**
2. 依赖解析到较新主版本(express 5 / xterm 6 / typescript 6 / vitest 4):**T7/T11/T14/T15 注意 API 差异**
(express 5 路由、xterm 6 import 形态)。
3. 加了 PLAN 未列的脚本 `dev`/`typecheck``postinstall`(便利 + 必需修复),属合理补充,未改 PLAN 意图。
- **遗留 / 待办**: 无(原前端打包待决项已解决,见下补记)。
- **commit**: `b126cf0`
### 2026-06-18 · v0.3 Step 5 — 中价值 M3/M7/M6(主题/仪表盘/同目录新标签)
- **状态**: `[x]` DONE。真浏览器验证通过。212 测试全绿。
- **M3 主题/字体**(commit `f79f14b`):`settings.ts`(⚙ 面板:dark/light/solarized + 字号 A/A+),localStorage 持久化;`terminal-session.applyTheme`;TabApp.applySettings 应用到所有+新标签。验证:light 主题渲染+持久化。
- **M7 仪表盘**(commit `f79f14b`):`dashboard.ts`(▦ 浮层列出所有标签:连接点+文件夹+Claude 状态;点行切换;打开时每秒刷新);TabApp.snapshot()/focusTab()。验证:列出 2 标签、点击切换。
- **M6 同目录新标签**(commit `87e1173`):`title-util.cwdFromOsc7`(解析 OSC 7 cwd)+4 单测;terminal-session 注册 OSC7 handler→cwd getter,新会话 attach 带 cwd;protocol/types attach.cwd(绝对路径);session/manager/server 透传到 spawn cwd;tabs `+` 在当前 tab 的 cwd 开新标签(无则回 home)。验证:server attach.cwd 生效;`cd /private/tmp``+`→新标签 pwd=/private/tmp(此 zsh 发 OSC7)。
- **工具栏**: 🔍搜索 · ⚙设置 · ▦仪表盘 · 📱QR。
- **commit**: `f79f14b`(M3+M7)、`87e1173`(M6)
### 2026-06-17 · v0.3 Step 4 — H1 tmux 保活(服务重启不丢会话)
- **状态**: `[x]` DONE。**真 tmux 集成测试验证通过**(commit `9099f73`)。**至此 H1H4 全部高价值功能完成。**
- **机制**: useTmux 时把 shell 跑进 `tmux new-session -A -s web_<id>`;node-pty 进程只是 tmux **客户端**。服务重启后,客户端带同 sessionId 重连 → manager 发现 `web_<id>` tmux 会话仍在 → 用同 id 重建客户端(`-A` 附着)→ 接回原 shell。
- **改动**: 新增 `src/session/tmux.ts`(同步 CLI 封装 available/has/kill,best-effort);config `useTmux`(env `USE_TMUX` 1/0/auto→检测 PATH);session createSession 加可选 id(重连用)+ tmux 分支 spawn + `Session.tmuxName` + kill 走 `tmux kill-session`;manager handleAttach 加 Case 3.5(重启后重附)+ shutdown 对 tmux 会话**只杀客户端 pty 不杀 tmux**(保活)。
- **测试**: 共享集成 cfg 设 `USE_TMUX:0`(防 tmux 泄漏);单测 CFG `useTmux:false`;集成 `H1`(真 tmux,gate=tmux+PTY):设 shell 变量 → **重启 server** → 同 id 重连 → 变量仍在(`MARK-tmuxlives`)。208 测试全绿,tsc 通过。
- **注**: 当前后台 dev server(:3000)是 H3 之前启动的、不含 H1;要试 tmux 保活需 `USE_TMUX=1 npm start`
- **commit**: `9099f73`
### 2026-06-17 · v0.3 Step 3 — H3 远程批准/拒绝(不打字)
- **状态**: `[x]` DONE。**端到端真浏览器验证通过**(commit `9a6150c`)。
- **机制**: `PermissionRequest` hook 用 curl POST 到 `/hook/permission` 并**阻塞等响应**;服务端长挂该 HTTP 响应,推 `status{waiting,pending:true}` 给前端 → 前端显示 **[✓Approve][✗Reject]** 横幅 → 点按发 ws `approve`/`reject` → 服务端用 `{hookSpecificOutput.decision.behavior:allow|deny}` 回应被挂的请求 → curl stdout 把决定交给 Claude。**全程不打字**。
- **兜底**: 5 分钟超时 / 断开即释放 → 回空 `{}`,Claude 回退到自己的交互提示;web-terminal 外 curl 无 URL → no-op。
- **改动**: types(ClientMessage approve/reject、status.pending)、protocol(白名单+解析)、manager(pending 透传)、server(`/hook/permission` 长挂 + approve/reject 路由 + resolvePending)、setup-hooks(PermissionRequest 用 held 命令)、FE(terminal-session approve()/reject()+pending、tabs 批准横幅)。
- **验证**: 207 测试全绿(协议 approve/reject 单测 + 集成 ⑧ held→approve→allow);浏览器实测横幅"Claude wants to use Bash"→Approve→held 请求得到 allow→横幅消失(截图 /tmp/v03-approve.png)。
- **commit**: `9a6150c`
### 2026-06-17 · v0.3 Step 2 — H2/H4 Claude 状态感知(headline)
- **状态**: `[x]` DONE(分支 `v0.3-cockpit`)。**端到端真浏览器验证通过**。
- **机制**: Claude Code hooks → 服务端 `/hook` 侧信道 → WS `status` 帧 → 标签角标实时更新。架构演进:服务端获得 hook 侧信道(终端流仍是 byte-shuttle)。
- **H2 服务端**(commit `a411c89`):
- types:`ClaudeStatus` + ServerMessage `status`;Session.claudeStatus;manager.handleHookEvent。
- session:spawn env 注入 `WEBTERM_SESSION`(哪个标签)+ `WEBTERM_HOOK_URL`(本机 /hook)。
- `src/http/hook.ts` 纯映射(PreToolUse→working、PermissionRequest/permission_prompt→waiting、Stop/idle_prompt→idle);6 单测。
- server:`POST /hook`(仅 loopback,express.json 64kb)→ 推 status。**顺带修真 bug**:`closeIdleConnections()` 让 hook keep-alive 空闲连接不再卡住 close()/SIGTERM。
- 集成 ⑦(真 PTY):attach → POST /hook → ws 收到 status(注意测试要在 fetch 前挂监听,推送是同步的)。
- **H4 前端**(commit `c81cebd`):
- terminal-session 处理 status → claudeStatus + onClaudeStatus。
- tabs:每标签 Claude 角标(⚙工作/⏳等批准/✓空闲);**非活动 + waiting 标签加琥珀高亮 + 浏览器通知**(首次切换请求权限)。
- `scripts/setup-hooks.mjs` + `npm run setup-hooks`:幂等装/卸 ~/.claude/settings.json 的 command hook(内联 curl,web-terminal 外是 no-op),自动备份。
- **验证**: 205 测试全绿;浏览器实测 PermissionRequest→⏳、PreToolUse→⚙、Stop→✓;非活动 waiting 标签琥珀+⏳(截图 /tmp/v03-status.png)。0 console error。
- **commit**: `a411c89`(H2 服务端)、`c81cebd`(H4 前端+脚本)
### 2026-06-17 · v0.3 Step 1 — 前端快赢(键栏/搜索/链接/QR/PWA)
- **状态**: `[x]` DONE(分支 `v0.3-cockpit`;纯前端,无服务端改动)
- **计划**: `~/.claude/plans/shimmering-wondering-island.md`(v0.3 cockpit,已批准)。
- **H4-keybar**: 键栏加 Claude 快捷键 Esc·Esc/^O/^T/^B + 每键 tooltip(KEY_MAP 14 键;commit `eaeacf3`)。
- **M1 搜索**: `@xterm/addon-search` 每终端 + 🔍 工具栏按钮 + 搜索框(Enter 下一个/Shift+Enter 上一个/Esc 关)。
- **M2 可点击链接**: `@xterm/addon-web-links`
- **结构**: 标签栏拆成 `#tabs`(可滚)+ `#toolbar`(工具按钮区),为后续 QR/设置/仪表盘复用。
- **M5 QR**: 📱 按钮 → 客户端渲染 location.origin 的二维码(经 LAN IP 打开即可分享)+ localhost 提示(commit `fbc218c`)。
- **M4 PWA**: manifest + icon.svg + sw.js(network-first,不拦 /term//hook)+ 注册;可安装。
- **验证(真浏览器)**: 14 键栏按钮、搜索高亮命中、🔍📱 工具按钮、SW controlled、QR canvas 渲染、0 console error。199 测试全绿、双 tsc、build 通过。截图 /tmp/v03-quickwins.png、/tmp/v03-qr.png。
- **commit**: `eaeacf3``fbc218c`
### 2026-06-17 · v0.2 修复 — 切换标签要点多次
- **状态**: `[x]` FIXED
- **现象**: 点标签切换常常点一两下不生效,要多点几次。
- **根因**: 整个标签 `draggable=true`,点击时鼠标**微动一点点就被当成拖拽**(而非点击)→ 切换失效;且每次 activity/title/status 事件都 `replaceChildren()` 全量重建标签栏,点击瞬间元素可能被销毁。
- **修复**(`tabs.ts` 重构渲染):① 切换改 `pointerdown`(按下即切,先于拖拽判定,触摸也生效);② 标签 DOM 建一次,状态点/未读/标题**就地更新**(refreshTab),仅增删/排序/改名才全量重建;③ 关闭按钮/改名框 pointerdown stopPropagation 避免误激活。
- **验证(真浏览器)**: 连续单击 tab0→2→1→0 active 每次都跟随;CDP 真实点击也切换;改名/拖拽不受影响。198 测试全绿。
- **commit**: `e058751`
### 2026-06-17 · v0.2 — 标签按当前文件夹自动命名
- **状态**: `[x]` DONE(纯前端)
- **需求**: 标签名用当前文件夹,易区分。
- **改动**: 新增 `public/title-util.ts``folderFromTitle()`——解析 shell OSC 标题 `user@host:~/path` 取最后一段目录名(home 保留 `~`);纯函数、无依赖、**7 单测**。`terminal-session.ts``onTitleChange` 经此函数转为文件夹名。标题优先级:手动改名 > 文件夹名 > Term N。
- **验证(真浏览器)**: 198 测试全绿、tsc+build ✓。实测 `cd` 实时更新:`~` → 项目目录 `web-terminal``docs``~`
- **commit**: `4091283`
### 2026-06-17 · v0.2 — 标签状态点 + 活动指示 + 拖拽排序
- **状态**: `[x]` DONE(纯前端)
- **改动**:
- `terminal-session.ts`:跟踪 `SessionStatus`(connecting/connected/reconnecting/exited)+ `onStatus` 回调。**Bug 修复**:`onTitle` 之前只在 opts 里、构造函数从没绑定 `onTitleChange` —— 自动标题原本是死代码,现已接上(实测两标签显示 `host:~` 自动标题)。
- `tabs.ts`:每标签**状态点**(颜色=连接态:绿/琥珀/红);**未读活动**(后台标签来新输出→`hasActivity`→蓝环,切到该标签即清);**HTML5 拖拽排序**(保留活动标签、持久化)。
- `style.css`:`.tab-dot` 配色、`.unread` 蓝环、`.dragging`/`.drag-over` 反馈。
- **验证(orchestrator,真浏览器)**: 191 测试全绿、双 typecheck、build:web ✓。browse 实测:连接后绿点;Term1 后台收到延迟输出→未读蓝环(`unread:true`);拖拽 `AAA|BBB``BBB|AAA` 且**reload 后顺序+自定义名都保留**。截图 /tmp/webterm-dots.png。
- **commit**: `b5f21e5`
### 2026-06-17 · v0.2 — 多标签 + 标题 + Claude Code 快捷键栏
- **状态**: `[x]` DONE(纯前端;后端 manager 早已多会话,无需改后端/协议)
- **需求**: 用户要 ①多标签 ②标签加标题方便区分 ③适配平板/手机 ④移动端易触发 Claude Code 快捷键(按钮触发快捷键)。
- **UX 调研**(general-purpose 联网,参考 iTerm2/Warp/VS Code/Windows Terminal/Ghostty/Chrome):双击就地改名是现代主流(Warp/VS Code/Arc);自动标题用 OSC 0/2;中键关闭、× 悬停显示、省略号+tooltip、Cmd/Ctrl+数字切换是 Tier-1。验证了"OSC 自动标题 + 双击改名"方案正确。
- **改动**(`public/`):
- 新增 `terminal-session.ts`:从旧 main.ts 抽出"单会话 = 1 Terminal + 1 WS + 重连",加 show/hide/fit/focus/dispose + onTitle/onActivity 回调。
- 新增 `tabs.ts`:TabApp 多标签管理 + 标签栏 UI。**每标签独立 WS/会话**(沿用 1WS↔1会话协议);自动标题(onTitleChange)+ **双击就地改名**(手动>自动>Term N);中键关闭、× 按钮、省略号+tooltip;localStorage 持久化 `{id,title}[]`(兼容 v0.1 单会话 key 迁移)。关闭标签=detach(PTY 不死)。
- `keybar.ts`:加 `slash` 键;导出 `KEYBAR_BUTTONS` 按 Claude 频率排序(Esc 标 primary);+4 单测。
- `main.ts` 瘦身为引导;`index.html``#tabbar`;`style.css` 重写:键栏**全设备常显**(桌面也可点按钮触发快捷键)、`@media(pointer:coarse)` 触摸加大、Esc 琥珀色高亮、标签栏/改名框/活动下划线。
- **决策**:键盘快捷键(Cmd+T 等)**不做**——浏览器宿主会抢,改走点击/触摸 UI(对 web 终端正确取舍)。
- **验证(orchestrator,关沙箱真浏览器)**: 两 typecheck ✓;`npm test` **191 全绿**(+4 keybar);`build:web` ✓。browse 实测:双击改名 Term1→"claude"、两标签会话独立(claude | Term 2)、键栏桌面可见 10 键 Esc primary、窄屏可滚动可用。截图 /tmp/webterm-ux-desktop.png、-phone.png。
- **commit**: `4f66016`
### 2026-06-17 · W5 — 验收 + 收尾(T16T21)
- **状态**: T16 ✅ · T17 部分(F9✅/F4 待真机)· T18 ✅ · T19 待真机 · T20 ✅ · T21 ✅
- **T21**(orchestrator):写 `README.md`(install/run/config/security);安全自查——无硬编码 secret(grep 空)、Origin+路径+maxPayload 校验已接线(server.ts 确认)、无 eval/exec、input 原样透传给 PTY(byte-shuttle 设计意图);`npm run build``dist/server.js` 可产出。
- **F1/F2/F3/F8 浏览器 smoke**(关沙箱起 server + gstack browse 无头 Chromium):
· 加载 http://127.0.0.1:38080 → **.xterm 挂载、0 console error、WS 连上、彩色状态行 + 真 zsh 彩色提示**(F1/F2)。
· 聚焦 `.xterm-helper-textarea` 输入 `echo hello-webterm` → 回显 + 输出 `hello-webterm`(**输入→WS→PTY→shell→输出→xterm 全链路**)。截图 /tmp/webterm-smoke2.png。
· 输入 `exit` → 终端显示 "Process exited code=0 Press Enter to reconnect…"(F8)。
· 改 viewport(F3)→ 终端存活、无 console error。
· curl 复核:GET / 200、/build/main.js 200、坏 Origin upgrade → 401。
- **决策**: F4(局域网多设备)、F7(手机软键盘栏)需物理设备,无法在本机无头验证 → 交用户真机。其余 7/9 F 项已自动化验证。
- **遗留**: T19(F7)待真手机;F4 待局域网真机;main.ts 的 css `@ts-ignore` 小债。
- **commit**: `81585ad`(README/安全)+ 本条 LOG
### 2026-06-17 · W2/W3/W4 — 会话核心 + 集成(T12/T13/T14/T15)
- **状态**: `[x]` ALL DONE · 串行(各有依赖),单 builder/任务
- **T12 `session/session.ts`**(opus,19 测试):createSession/attachWs/detachWs/writeInput/resize/kill;onData→buffer+lastOutputAt+转发;onExit→exitedAt/exitCode+发exit+注入onExit;**spawn 失败抛(M4)**;sendIfOpen 守卫 readyState(M5);detach 不杀 PTY;exit 后 write/resize 忽略(L4);detach 后退出保留会话(L1)。测试 `vi.mock('node-pty')`→mock IPty,沙箱内可跑。
- **T13 `session/manager.ts`**(sonnet,21 测试):handleAttach 四路径(新建/存活/已退出 L1/查不到);reapIdle `now-max(detachedAt,lastOutputAt)>idleTtl`(M3);shutdown 全 kill;注入 onExit 在线退出删表、detach 后退出保留待 L1(L2)。
- **T14 `server.ts`**(sonnet,~200 行):Express 静态 public/+build/;`WebSocketServer({noServer,maxPayload})`(L5);upgrade 门(非 wsPath→destroy、坏 Origin→401、过→handleUpgrade,L3);connection 等 attach,try/catch→spawn 失败 exit(-1)+关连接(M4);message 路由;close→detach(不杀);SIGINT/TERM→shutdown;reapIdle 定时器;safeSend 守卫(M5)。导出 startServer 供 T15。
- **T15 集成**(sonnet,6 用例):真 ws 客户端打 startServer。①坏Origin 401 ②错路径 destroy ③超 maxPayload 1009 ④spawn失败 exit(-1) —— 沙箱内全绿;⑤attach→output ⑥重连回放(F5/F6,含 `__MARK_测试__` CJK)—— **orchestrator 加 `PTY_AVAILABLE` 门控(沙箱自动 skip,真机跑),关沙箱验证 6/6 全绿**
- **验证(orchestrator 独立复核)**: 每步 `tsc --noEmit` ✓ + 全量 `npm test` 递增至 **187 全绿**;集成 sandbox-off 6/6(真 PTY)。
- **决策 / 偏离**: T12 onData 时间戳用 Date.now()(事件时刻,符合 lastOutputAt 语义);T15 由 orchestrator 加 PTY 可用性门控(原 `it.skip``itPty`),使真机会跑、沙箱自动跳过。
- **commit**: `20212dd`(T12)·`a2897b2`(T13)·`0dac54b`(T14)·`cdec19a`(T15)
### 2026-06-16 · W1 前端批次(T8/T9/T10/T11)
- **状态**: `[x]` ALL DONE
- **派活方式**: 因 T11 import T10 的 mountKeybar 且验收是整目录 tsc.web,分两步:**先并行 T8+T9+T10(haiku),后 T11(sonnet)**。general-purpose 内联角色,共享树 + 文件不交叉。
- **改动**:
- **T8** `public/index.html`:#term/#keybar、viewport、`<script type=module src=./build/main.js>` + link ./build/main.css 与 ./style.css(按 §1 约定)。
- **T9** `public/style.css`:全屏终端、键栏 fixed 底部 flex、**>768px 隐藏**、深色;不碰 xterm 内部样式。
- **T10** `public/keybar.ts`(+ test):KEY_MAP 纯数据(Esc/Shift+Tab/方向/Enter=\r/Ctrl+C/Tab)+ mountKeybar(onSend) touchstart→onSend+preventDefault。13 测试。
- **T11** `public/main.ts`:esbuild 入口(import xterm css);xterm+FitAddon;WS scheme 随协议(M6);attach 首帧带 localStorage sessionId;onData→input、output→write、exit→ANSI 提示按回车重连;ResizeObserver 防抖 resize;指数退避重连≤30s;mountKeybar 接 ws.send;**无 console.log**(状态写终端)。
- **验证(orchestrator 独立复核)**: backend `tsc --noEmit` ✓;frontend `tsc -p tsconfig.web.json` ✓;`npm test` **6 文件 / 141 测试全绿**(新增 keybar 13);**`npm run build:web` esbuild 打包成功**(main.js 430kb + main.css 6kb);build/ 已 gitignore 未入库。
- **决策 / 偏离**: T11 用 1 处 `@ts-ignore` 压 CSS import(Owns 仅 main.ts,无法另建 d.ts;记为 tech-debt);exit 后按回车 clear sessionId 再新建(契合 L1:已退出会话由服务端回放+补发 exit,前端不 reattach)。
- **遗留**: 上述 css d.ts 小债;前端真实浏览器行为留 W5(T16T20)实测。
- **commit**: `326d347`
### 2026-06-16 · W1 后端批次(T3/T4/T5/T6/T7)— 5 个并行 builder
- **状态**: `[x]` ALL DONE
- **派活方式**: 5 个 `general-purpose` subagent 并行(自定义 module-builder agent 需重启会话才加载,故本会话内联角色),
共享工作树 + **文件所有权严格不交叉** + scoped `vitest run <name>`(官方文件分区法,替代 worktree;合并即 commit)。模型按 §4。
- **改动**(各 agent 仅在 Owns 内):
- **T3** `test/helpers/mock-pty.ts`(+ smoke test):IPty 替身,`emitData`/`emitExit` 手动触发、记录 write/resize/kill、onData/onExit 返回 IDisposable。
- **T4** `src/config.ts`(+ test):`loadConfig(EnvLike)`;M1 origins 由 `os.networkInterfaces()` 推导(含 localhost,**剔除 0.0.0.0/internal**,http+https),wsPath 默认 /term,非法值 fail-fast,`Object.freeze`
- **T5** `src/protocol.ts`(+ test):`SESSION_ID_RE`(UUID v4)、`parseClientMessage`(永不抛,全分支校验,M7 拒 `abc123`)、`serialize`
- **T6** `src/session/ring-buffer.ts`(+ test):M2 按真实字节(Buffer)计量、按 chunk 边界淘汰、不切碎 UTF-8/ANSI、snapshot 补 `\x1b[0m`
- **T7** `src/http/origin.ts`(+ test):用 WHATWG `URL` 比 protocol+host+port,undefined/解析失败拒绝。
- **验证(orchestrator 独立复核,非仅信 agent)**: `npx tsc -p tsconfig.json --noEmit` PASS;`npm test`(全量)
**5 文件 / 128 测试全绿**(T4 32 + T5 60 + T6 15 + T7 12 + T3 9)。
- **决策 / 偏离**: 无源码偏离。流程上:本批用 general-purpose 内联角色(见上)。T6 对"单块 > 容量"按 M2"绝不切碎"保留整块(测试固化),属忠实落地。
- **遗留 / 待办**: W1 前端 T8T11 待派;之后 T12 session(mock+ring-buffer 已就绪)。
- **commit**: `0fa0b69`
### 2026-06-16 · T2 冻结共享类型契约 src/types.ts
- **状态**: `[x]` DONE
- **改动**: 新建 `src/types.ts`(纯类型,无实现);删 `src/.gitkeep`。含 Config、ClientMessage/ServerMessage/
ParseResult、Dims、IPty(子集)+ IDisposable/PtyEvent、RingBuffer、WebSocketLike + WS_OPEN、SessionMeta/Session
(lastOutputAt/exitedAt/exitCode)、SessionManager、MountKeybar;各模块工厂函数签名以注释列为 import 锚点。
同步更新 ARCHITECTURE §3.1/§3.4(加 wsPath、EnvLike、WebSocketLike 注记,防漂移)。
- **验证**: `npx tsc -p tsconfig.json --noEmit` PASS;grep 确认无 `ws`/`NodeJS`/DOM 外部类型依赖(仅注释提及)。
- **决策 / 偏离 ARCHITECTURE §3**(为"前后端共享 → 必须无外部类型依赖"):
1. `loadConfig(env: EnvLike)` 替代 `NodeJS.ProcessEnv`(`EnvLike = Readonly<Record<string,string|undefined>>`,process.env 可赋值)。
2. `Session.attachedWs: WebSocketLike` 替代 `ws``WebSocket`(真实 ws 结构兼容);导出 `WS_OPEN=1` 供 M5 守卫。
3. **新增 `Config.wsPath`**(默认 '/term'):不变量 8 要求 WS 路径入 config,§3.1 原 Config 漏列,已补;T4 需从 env `WS_PATH` 读、T14 用 `cfg.wsPath`
三处均已回写 ARCHITECTURE,types.ts 为权威冻结源。
- **遗留 / 待办**: 无。W1 全部任务现可只读 import `src/types.ts`。**注意**:IPty/WebSocketLike 是结构子集,
实现方(T12/T14)可把真实 node-pty IPty / ws.WebSocket 直接赋值(结构兼容)。
- **commit**: (见下次提交)
### 2026-06-16 · T1 补记 — 前端打包定为 esbuild
- **状态**: `[x]` DONE
- **改动**: 装 `esbuild`(dev);`package.json``build:web`/`dev:web`(打包 `public/main.ts``public/build/main.js`);
`.gitignore` 改忽略 `public/build/`;`tsconfig.web.json` 注释更新为"仅类型检查";前端构建约定写入
`PLAN §1` 并更新 T8/T11/T14 与 `DISPATCH.md``ARCHITECTURE §5`
- **验证**: esbuild 0.28.1;临时入口 `--bundle --format=esm --outdir=public/build``main.js`+`.map`,smoke 通过。
- **决策**: 三选一中选 **esbuild**(极快、零配置、与 node-pty/tsx 生态一致);**现在装好**避免 W1 并发 install。
约定:入口 main.ts、产物 public/build/(已 gitignore)、index.html 用 `<script type=module src=./build/main.js>`、server 静态托管 public/。
- **commit**: (见下次提交)
### 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