# 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] iOS 收尾波 — 6 项整改 + P2 全波 + 两端令牌(2026-07-30,worktree `ios-completion`) - **起因**: 先做了一次 16-agent 的 iOS 完成度审计(2026-07-29),结论是"代码层面基本完工、交付层面卡在真机门口":43 个计划任务 27 DONE / 11 PARTIAL / 5 MISSING,**从未在任何真机上跑过一次**,且已落后服务端两个月的新功能。用户要求"全部实现",并定了三个范围问题:P2 全 5 项在内、Apple 账号是**免费个人 team**、Android 令牌一起补。 - **编排**: 契约先冻结在 [`docs/plans/ios-completion.md`](./plans/ios-completion.md) §1(orchestrator 亲自核服务端源码),再派 3 个 Workflow 共 22 个 agent。**A 串行 → B 并行 5 → C 串行 4 → D 并行 3 → E 条件 → 收尾波 4**。C 波刻意串行:新增 `.swift` 必须 `xcodegen generate` 重写**共享的** `.xcodeproj`,并行必互踩。 - 中途整个 C+D 波(7 个 agent)被 provider 侧 `529 Overloaded`/`ECONNRESET` 打挂;`resumeFromRunId` 把 A/B 从缓存回放、只重跑失败的,零返工。 - **冻结契约(§1.1,踩过的坑都在里面)**: cookie 名 `webterm_auth`;`POST /auth` 体 `{"token":"…"}`;**`Accept` 头不能含 `text/html`**——含了会被当表单走 302 而不是 204/401;**204 但无 `Set-Cookie` = 服务端根本没开鉴权**,绝不能据此认为"已认证";原生客户端**自己手写 `Cookie` 头**,不解析 `Set-Cookie`、不用系统 cookie jar(URLSession/OkHttp 的 jar 在 WS upgrade 上行为不一致且难测);**令牌不替代 Origin**,两者都要带。 - **真机构建解锁(最高杠杆,一次关掉 7 项验收阻塞)**: `DEVELOPMENT_TEAM=C738Z66SRW` + **target 级** `CODE_SIGN_IDENTITY`(废弃串 `"iPhone Developer"` 是 XcodeGen 的 product-type preset 在 target 层注入的,project 级怎么写都盖不住)→ 免费个人 team 上 `** BUILD SUCCEEDED **`,`-allowProvisioningUpdates` 现场签发 7 天 profile。**`aps-environment` 做成 env 开关且默认不挂**(`WEBTERM_PUSH_ENTITLEMENTS`),并**反向验证过必要性**:打开后真机构建报 `Personal development teams … do not support the Push Notifications capability`。 - **顺手挖出一个会卡死全波的坑**: SwiftTerm 用浮动版本 `from: 1.13.0`,而 `.xcodeproj` 是 gitignore 的 → **任何人一 `xcodegen generate` 就解析到 1.15.0**,它在 1.14.0 新增的 `public var hasActiveSelection` 与 `TerminalScreen.swift` 本地同名属性冲突,且**加 `override` 修不了**(上游是 `public` 不是 `open`)。A1 先钉死版本止血,C3 删掉本地属性改用上游的、再抬到 1.15.0 根治。 - **交付**(全部实测,非引用): | | 之前 | 之后 | |---|---|---| | 包测试 | 310 | **452** | | App 测试 | 296(1 失败) | **550**(iPhone + iPad 各一遍,**0 known issue**) | | 集成测试 | 10 | **32** | | Android 测试 | 687 | **691** | | ClientTLS 覆盖率 | 55.76%(**不在门内**) | **89.49%**(已入门) | | 覆盖率门 | 4 个包 | **5 个包全过**(最低余量 +9.49pp) | | 真机构建 | `BUILD FAILED` | **`BUILD SUCCEEDED`** | - **令牌**: 两端全链路(APIClient `/auth` 探针 + Keychain/Keystore 存储 + WS upgrade 带 Cookie + 401 终态不进退避环)。 - **git 面板 parity**: iOS 补齐 `/projects/{log,pr,git/*,worktree*}` + `/sessions` + 队列——这是与 Android 和 web 差了两个月的那一块。 - **P2 全 5 项**: 语音 PTT(epoch 防误发)、终端内搜索、worktree 生命周期、主题 + Dynamic Type、web `?join=` 互通。 - **CI**: 修好 iOS 三条腿(缺 `npm ci` 会**硬失败**不是 skip / 缺 iPad UI 腿 / iOS 17 缺 runtime 时**静默报绿**),新建 `android.yml`(此前 Android 零 CI)。 - **复核抓到并已修的 2 个 HIGH**: ① iOS 的 WS 令牌是**与主机无关**地解析的,混合机群(一台开令牌一台没开)下令牌门主机永远开不了终端,且 App 内无解 → 改为每主机一个 transport;② Android 把主机自身的 git 凭据 401(`src/http/git-ops.ts:108`)误报成"你的访问令牌错了",因为通用 401 映射跑在路由映射前面 → git 写路由标 `ROUTE_DEFINED`,保住服务端原文案。 - **orchestrator 的两个裁定**: ① 令牌策略在 iOS 有 3 份、Android 1 份,**不解冻 `WireProtocol` 去收敛**(它是冻结的跨语言契约,共享密钥的 helper 不该进去),改为在 `IntegrationTests` 加漂移守卫——它是全树唯一能同时依赖三个包的目标;② `PairingProbe.swift` 越界改动是我特批的(B1 已收工释放该包),复核把它记为 LOW,已知悉。 - **诚实的未闭合项**: release ipa 层核对(免费 team 无分发通道);真机人工腿(口述 / 扫码 / 锁屏 Face ID / IME);APNs 端到端(需付费账号);**两个 workflow 从未在任何 runner 上跑过**——本仓库唯一 remote 是自建 Gitea,GH Actions 从来没执行过,本地已逐条复跑其命令;Android instrumented 腿(令牌静态加密的唯一证明)设为 `workflow_dispatch` 触发,理由写在文件头:没人见它绿过的腿若设为必过,只会逼出一个假绿。 - **一个诚实的取舍**: 键栏在 AX3–AX5 的**字号**封顶在 `.accessibility2`。T-iOS-34 的验收原文是"最大字号不破版",现在任何档都不裁切;但"AX5 键帽以 AX5 字号渲染"是假的——不封顶时 AX5 需 108.24pt,会把终端吃掉。封顶值由测试钉死不得漂移。 - **合并回 develop 时撞上了 `android-blocker-fixes`**(同期另一会话,见下一条)。**两边各自独立实现了 `WEBTERM_TOKEN` 闸**:它们走响应式(探针失败→提示→提交),本波走前置式(确认页填→`POST /auth`→Keystore)。合并是**组合**不是二选一——两条入口汇到同一个 `performProbe` 收口,两条路径都把令牌落到 WS upgrade 会读的那个 store。 - **测试全绿不等于合并正确**:1224 个 Android 测试 0 失败的情况下,单独跑的语义复核仍抓出 **3 个真缺陷,其中 2 个是合并引入的**—— ① **解除配对不再擦除凭据**(🔴 安全):`HostRemover` 擦 cookie 但不擦新加的 `AccessTokenStore`。令牌就是 cookie 的值(整个 shell 的凭据),于是解绑后仍留在磁盘**且是活的**——`AccessTokenSource` 按 origin 取而不是按配对记录取,同一 URL 重新添加、令牌框留空会**静默用残留令牌认证**。两个分支单独都没这个洞。 ② **"两套 cookie 机制能安全共存"是错的**(🟠):复核 agent 反编译了本仓库钉死的 OkHttp 4.12.0 的 `BridgeInterceptor` 并用真 `MockWebServer` 复现——判据是"jar 非空"而**不是**"jar 里有这台主机的 `webterm_auth`",且是 `header()` **整头替换**。实测:jar 里有个无关 cookie(代理下发的)→ 令牌被从 REST **和 WS upgrade** 上整个抹掉;jar 里有陈旧 `webterm_auth` → 发出去的是陈旧值。修法是给 jar 按名过滤(读/写/水合三处),并补上**production 接线**下的回归测试——原有两个测试一个用 `NO_COOKIES` 建 client、一个用 `AccessTokenSource.NONE` 建 jar,**从来没有两者同时活着**,这正是缺陷能溜过去的原因。 ③ **索引里的 AndroidManifest 是非法 XML**(🔴):git 把两边的 `android:allowBackup="false"` 自动合成了**重复属性**,无冲突标记。工作树被修好了但没 `git add`,照常 commit 会提交坏的那份、Android 构建在 manifest 解析就挂。 - 另修 2 项:`204 无 Set-Cookie`(=服务端没开鉴权)在响应式路径上被错误持久化,违反 §1.1 冻结规则;`ROUTE_DEFINED` 范围过宽——服务端唯一路由自有的 401 是 `src/http/git-ops.ts:108`,只有 stage/commit/push/fetch 能到达,worktree 三条根本产生不了,却被钉成 ROUTE_DEFINED(令牌门主机上会把 gate-401 显示成 git 拒绝)。iOS 侧同样的过宽 pinning 一并修。 - **合并后实测**: Android **1236** 测试 0 失败(10 模块)、kover 四个受门模块 92–96%、iOS 452 包测 + **32** 集成测(对**合并后**的真服务器,含 develop 新加的孤儿 tmux 那套)+ 550 App 测 0 known issue + 覆盖率门 5/5。`src/` 与 develop HEAD 逐字节相同。 - **遗留(非缺陷,是设计取舍)**: 两套 cookie 机制仍并存,按名过滤后不会互相抹掉,但 jar 仍优先于手写头。要让 store 无条件胜出得二选一(收敛成单机制 = 重写一边的测试套,或加 app→network interceptor 传递),那是带真实设计决策的后续项,不是 merge 能顺手做的。已用一条测试把当前优先级钉死。 ### 🤖 [x] Android 客户端"能装但不能用"修复 — 7 个真实缺陷已修,设备上 67 个 instrumented 测试全绿(2026-07-30,worktree `android-blocker-fixes`) - **起因**: 例行问"android 客户端完成状况如何"。`android/PROGRESS_ANDROID.md` 写着 **"✅ ANDROID CLIENT COMPLETE — all 36 plan tasks landed"**,617 个 JVM 测试全绿,APK 也出得来。**这个"完成"只在编译层面成立** —— 从来没在任何真机或模拟器上跑过,而一旦跑,第一个动作就崩。审计(45 agent,含逐条对抗验证)+ 修复(多波 agent)已把 blocker 清掉。 - **为什么 JVM 测试全绿也漏掉了**: 一个结构性原因 —— **没有任何 JVM 测试会实例化 Android `View`**。`:terminal-view` 那 17 个测试测的是纯逻辑,渲染/输入/触摸整条路径无人触碰。 - **三个让 App 在真机上不可用的缺陷**(每一条都反编译 `terminal-view-v0.118.0.aar` 逐偏移确认): 1. **按任何键必崩**。`RemoteTerminalView` 只绑了 emulator,从没调用 `setTerminalViewClient` —— KDoc 写着"A17 会装",A17 实际只交付了 `HardwareKeyRouter` 和 KeyBar(而 KeyBar 之所以能用,恰恰因为它绕过了 view)。stock `TerminalView` 在 `onKeyDown` offset 82、`onCreateInputConnection` offset 0、`onKeyUp`、`onKeyPreIme` 无判空解引用 `mClient`。**装个 client 还不够**:返回 false 会继续走到 offset 150 的 `mTermSession.write()`,而 `mTermSession` 永远是 null(`TerminalSession` 是 final,而 fork 进程正是本 App 绝不能做的事,§6.1)。修法:client 自己消费按键,且 `KeyRouting` **刻意没有"交还 stock"这个分支** —— 崩溃变成不可表达,而不只是被绕开。键表仍由 Termux `KeyHandler` 决定,DECCKM 照旧发 `ESC O A`。 2. **resize 从来不发**。`RemoteTerminalSession.updateSize` 零调用点,stock 兜底也是死的(`TerminalView.updateSize` 无 session 时 offset 18-25 直接 return),PTY 一直停在服务端的 80×24,所有全屏 TUI 都排版进错误的框。`TerminalResizeDriver` 现在从真实字距驱动 —— 字距走**公开** accessor `TerminalRenderer.getFontWidth()`/`getFontLineSpacing()`(agent 自己 `javap` 发现它们是 public),**不用反射**(R8 下会静默失效)也**不重测 Paint**(会与渲染器实际所用漂移,正是 §R5 风险)。 3. **局域网连不上**。`network_security_config.xml` 是自己标着 STUB 的全局禁明文,而 `HostEndpoint` 接受 `http` 并派生 `ws://`。它注释里承诺的 CIDR 白名单**平台根本不支持**(NSC 格式没有任何 netmask/prefix 属性,`` 只装一个主机名或一个 IP 字面量)。改为 base-config 放开 + `*.terminal.yaojia.wang` 显式禁明文 + 信任锚钉死 system + `OkHttpClientFactory` 在传输层拒绝向非私有地址发起明文 —— 平台表达不了的收窄,放到 HTTP 客户端里做。plan §6.9/§8 的错误描述已一并更正。 - **对抗评审又挖出三个,其中一条评审自己判错了**: - **在 alternate-screen TUI 里滑动滚屏仍会崩**。触摸路径完全绕过 `TerminalViewClient`:`doScroll` 在 alternate buffer 激活时把滚动转成 `handleKeyCode`,而那里解引用 `mTermSession`。**Claude Code 本身就是 alternate-screen TUI**,所以这是主界面正常使用中的崩溃。`TerminalScrollGesture` 抢占纵向拖动并复现 `doScroll` 的三个分支,连 fling runnable 和 `onGenericMotionEvent` 一并封死。 - **`autofill()` 无判空解引用 `mTermSession`**,而该 view 自称可自动填充 → 任何密码管理器一填就崩。整个子树已排除出 autofill 结构。 - 评审要求**恢复** stock 文字选择(此前被 focus 改造弄成不可达)。**这条它错了**:ActionMode 自己的 Copy/Paste 处理器也解引用空 session(`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 / 126-136),恢复可达性只会把崩溃搬到用户点 Copy 那一刻 —— 比没有选择功能更糟。**长按保持被消费,复制功能如实记为功能缺口**(真正的修法是基于 `TerminalBuffer` + `ClipboardManager` 自建选择层)。 - **被静默丢弃的服务端数据,现已解码**: W2 `queue` 帧在 `ServerMessageType` 里没有成员,`decodeServer` 返回 null,`SessionEngine` 当"无法解码"丢掉 —— "N queued"徽标在 Android 上**从来不可能出现**;W1 `status.preview` 完全没建模,导致远程审批是**盲批**(只看到工具名,看不到要执行的命令/diff)。两者均已解码。一条规矩用测试钉住:**preview 坏掉绝不能连帧一起丢** —— `pending` 才是让 gate 出现的信号。 - **当作功能发布的死代码**: `ThumbnailPipeline`(会话列表预览)、`QuickReply`(芯片+调色板)、`PointerContextMenu` 三者实现完整、单测齐全、**生产代码零调用点**。均已接线。`ThumbnailPipeline` 当年被**明确跳过**的交叉评审(AW3 条目自己承认)这次补做了。 - **凭据处理**: `WEBTERM_TOKEN` cookie 现在在 REST 和 WS upgrade 上都携带,用 Tink AEAD + AndroidKeyStore 封装,**fail-closed**(封装失败就不持久化,绝不退回明文);`allowBackup=false` 阻止这个 30 天 shell 凭据经 Google Drive / D2D 外泄。 - **v0.6 git 面板数据层**: 服务端 8fe1f52 之后的字段 Android 全没解码。已补 `SyncState`/`dirtyCount`/`cwd`/`unpushed`/`upstream` + `GET /projects/worktree/state` + `POST /projects/git/fetch`。两条易错语义**编码成行为**而非靠调用点自觉:`SyncState.isFullySynced(now)` 是唯一被认可的"能否显示绿色"判断(任何未知即 false,**无 upstream ≠ 0**);`GitLogResult.upstreamBoundaryIndex` 找**最后一个**未推送提交(取前 N 行会朝危险方向出错)。 - **验证**: `./gradlew test :app:assembleDebug :app:testDebugUnitTest koverVerify` —— blocker 修复提交时 **757 个 JVM 测试全绿**(基线 617);`:wire-protocol`/`:session-core`/`:api-client` 三处 koverVerify 均过。commit `3e5cfdc`、`3fe719f`、`35d32f4`。 - **方法与其代价(如实记录)**: 分波多 agent(TDD builder → 对抗评审 → 修复)。**API 端持续 529 过载,13 个 agent 里 8 个、11 个里 9 个被打掉**;协议层和 REST 层两轮全灭后由 orchestrator 亲自补写。这也是为什么会话成本冲到 ~$360。对抗评审确实值 —— 滑动滚屏那个 CRITICAL 崩溃是 builder 漏掉、reviewer 抓到的。 - **仍未完成(不要把上面读成已完成)**: - **A34 / A35 完全没有代码** —— `:app` 连 androidTest 源集都没有,也没有 benchmark 模块。AW6 条目把它们标成 `[x]` 是因为它们被转成了清单条目,不是实现。 - **设备 QA 仍约 0%** —— 仓库里唯一的真机证据是一张配对页的模拟器截图。上面每一条修复都只有 JVM 级验证。 - release 签名/版本号/minify;S2 FCM 真机投递;复制功能。 - `android/PROGRESS_ANDROID.md` 顶部已加"CORRECTION"段落说明上述全部。 - **后续同日完成(本条目最初写的"仍未完成"已不成立)**: - **设备上真跑起来了**: `:app/src/androidTest/**` 67 个 instrumented 测试 + `macrobenchmark/`,在 `emulator-5554` 上对本仓库自己的服务器**全绿(67/0)**。含四个崩溃回归、A34 E2E(attach→output、ring buffer 重放、用第二个故意配坏的服务器测 spawn 失败、`POST /hook/decision` 解 gate、**F9 坏 Origin 拒绝**)、以及 plan §7 点名的 Compose 测试。模拟器要打通宿主机必须 `ALLOWED_ORIGINS=http://10.0.2.2:` —— 服务端的 allowedOrigins 从网卡 IP 派生,而 10.0.2.2 是模拟器对宿主机的别名、不是任何网卡地址。 - **设备上又抓出第四个崩溃**: `onDraw` → `TerminalRow.getStyle` 的 `AIOOBE`,而 `PROGRESS_ANDROID.md` 原本把它写成"Accepted (not a defect) — 撕裂读会自我修正"。两半都是错的:它是进程直杀;而且"matches upstream Termux"也不成立 —— 上游 `append` 由 `MainThreadHandler` 在主线程调用,压根没有并发读者。根因是 `TerminalEmulator.resize` **先**发布新 `mColumns`、**后**才重新分配行数组,`length == index` 那个"可疑的一致性"是**扩容的确定性签名**。**而且这个竞态以前是休眠的** —— resize 从不发生,列数就不变,没有宽度差可撕;是本次把 resize 修好才让它变可达。修法:emulator mutation 全部挪到渲染线程(与上游一致),单写者模型不变。已先复现成 JVM 测试,所以这条回归以后不需要设备就能守住。 - **审计遗留 6 项 + 复制功能全部关闭**: 证书静默轮换(顺带修掉一个会让**每次续期都失败**的 `renew()` 解码 bug,并指出 iOS 有个"只轮换一次"的潜在缺陷 Android 通过从叶证书反推 `renewAfter` 避开了)、`/device/:id/recover`(走非 mTLS,因为过期证书没法给自己的恢复做认证)、W2 inject-queue 三条路由、未读水位持久化、host 移除 UI、`/config/ui` 被尊重且**fail-closed**、以及基于 `TerminalBuffer.getSelectedText` 自建的选择/复制层(stock ActionMode 的 Copy 处理器解引用空 session,所以不能恢复它)。 - **最终门禁**: `./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest :macrobenchmark:assembleBenchmark koverVerify` → BUILD SUCCESSFUL,**1150 JVM 测试 / 0 失败**(基线 617);instrumented **67/0**。commit `3e5cfdc`→`390dd11` 共 9 个。 - **仍然开放**: **S2**(FCM 真机投递,需要 ≥2 台物理设备在 Doze/厂商省电下验证,模拟器答不了);`DEVICE_QA_CHECKLIST.md` 里没有自动化覆盖的其余设备可观测项;一个 instrumented 测试在**不传 host 参数**且模拟器被反复安装卸载后会杀掉测试进程,按文档化的调用方式+干净安装则稳定通过 —— 上真机再判。 - **本次合并时发现的新 parity 缺口(记录,未做)**: `develop` 在本分支之后新增了 **孤儿 tmux session** 功能 —— `OrphanSessionInfo` 类型 + 5 条路由(`GET /orphan-sessions`、`/count-idle`、`/:id/preview`、`DELETE /orphan-sessions`、`/:id`)。**Android 客户端完全不知道它们**。服务端刻意把它做成独立类型而非 `LiveSessionInfo` 上的 flag,所以现有解码器不受影响、不会解错 —— 这是一个新增缺口而不是回归。iOS 同样没有。 ### 🗂 [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_` 兜底 —— 不静默截断。 - **验证**: `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。 - **顺带修掉一个更严重的设计缺陷(用户确认后改)**: 桌面 App 之前把 `USE_TMUX=0` **钉死在所有平台**,不只 Windows。没有 tmux 时 shell 是内嵌 server 的**直接子进程**,于是 `SessionManager.shutdown` 走 kill 分支 —— **退出 App(托盘 Quit、注销、自动更新)会把所有 session 一起杀掉**。这直接否掉了本 App 的立身之本:丢个任务给 Claude Code、走开、换台设备再连上;一个活不过关窗口的 session 做不到这件事。而独立 server 一直是对的(`USE_TMUX` 未设 → `auto` → 探测二进制是否存在)。现改为非 Windows 默认 `auto`,Windows 仍强制关(tmux 是 *nix 的东西),显式 ambient `USE_TMUX` 依然优先,所以 `USE_TMUX=0` 仍是退出开关。 - **测试断言的是端到端结果而不是那个字符串**:`buildServerEnv` 喂进 `loadConfig` 后 `useTmux === true`(装了 tmux)、显式退出时为 `false`。原来的 bug 正是这两层**对同一个值的含义理解不一致**,只断言字符串是抓不到的。 - **注意**:改动之前在 App 里起的 session **不会被追溯保护** —— 它们背后本来就没有 tmux session,仍会随 App 一起死。只有装了新包之后新建的才活得下来。 - **遗留 / 待办**: B(启动收养 + 让 `reapIdle` 管到孤儿)未做,需要配"按需 attach"(`Session.pty` 可空)才划算 —— 那要动 Session 形状和不变量 #2。 - **第二轮对抗式审查又抓到一个安全洞(必须记住)**: **`tmux -t ` 的解析顺序是 精确名 → 名字前缀 → fnmatch**。这直接绕过本模块的核心承诺("只碰 `web_` + UUID v4,用户自己建的 tmux session 永不被枚举/删除"):一个叫 `web__mine` 的 session 被 `parseSessionList` 拒绝(后缀不是 UUID)因此**从不出现在列表里**,但 `-t web_` 会**前缀匹配到它** —— 于是打向一个不存在 id 的 DELETE 会杀掉用户自己的会话,预览会打印它的屏幕。tmux 3.6a 独立 socket 实测:`has-session -t web_` 命中 `web__MINE`,加 `=` 则 `can't find session`;`capture-pane -p -t web_` **打印出了 DECOY 的内容**,`-t =web_:` 则失败。**要两种 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 =` 仍能解析本机 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 + 双时钟)+ `5833529`(桌面 App tmux 默认值),合并 `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` 本来就不换行)—— 换行会在 **721–1000px** 之间排出参差空行,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 面板 — G1–G7 全部完成(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` 直接读 `/.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 6–12 个 `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/` 就在主 checkout **里面**,前缀匹配会把每个 worktree 的 session 同时算到父仓库头上。 - 偏离计划:改成**每次打开项目探一次**而不是"展开某行才探"。状态在客户端缓存、且被"未变则跳过"挡住,成本一样,但信息不用点就有。 - **验证**: `tsc --noEmit` 干净、`npm run build` 干净。新增 **46 个测试全过**(2128 → 2174)。 - **顺手修掉了既有测试的抖动,`npm test` 现在端到端全绿**(单元趟 78 文件 / 2147,e2e 趟 27,连续多次)。两个独立原因: 1. **fixture 撑破了 vitest 的 5s 默认值**。git 类的要串行 spawn 6–12 个 `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`,这些用例根本没跳过,是真的在跑并且卡在时间预算上。 - **遗留 / 待办**: 无(G1–G7 全部完成)。设计稿里画的每样东西都落地了。 - **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 `。新增 `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 HIGH〔rate-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=` 审整条分支。三层防选项注入:isPlausibleRev → `rev-parse --verify --end-of-options` → 只有解析出的 sha 进 `git diff ... --`。 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)**:全部阶段(v1→v2→v3)用多 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-board→moveTab 上板逻辑折进 `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)**:每格 👁 切换 live↔read-only。`public/cell-monitor.ts` 轮询 `GET /live-sessions/:id/preview` 写只读 xterm,**不 attach WS、不发 resize**,故不驱动共享 PTY 尺寸——解决"小格盯 session 会缩掉别的设备全屏"的跨设备 shrink。monitor 时 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→复验→commit。maximize 的 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 一条命令 onboard、device 登录一次即在硬件里生成不可导出密钥→CSR→拿证书,**无 .p12/AirDrop,私钥永不离设备**。三轨:A 控制面/PKI、B host agent、C 原生客户端(iOS/Android/desktop)。基础设施(frps/device-CA/frp-client-CA/nginx:8470 mTLS)已在 VPS M1 上线;本工作流建的是**自动化**(签发端点、njs cert→Host 绑定、硬件 keygen、host 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 cert→Host 绑定)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 的 subdomain→403、未认领→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 27。MVP 分层保证证实(契约→auth→控制面 签发/注入/续期→host agent 安装/运行/keystore→iOS 硬件注入 全共存绿)。 - **交叉验证共抓修 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 不可变 + timingSafeEqualBytes、getCertSub 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 254→267)。**[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/renew→201 同 subdomain;归属门 e2e:未认领→403、跨租户 B-on-A→403、缺 token→401;host 叶子→/renew→201 链到 frp-client-CA、不信任 CA→401。**[x] #15 原生 host /enroll P-256 arm DONE**:/enroll 按 CSR 密钥类型分叉(EC-P256→原生 frp-client 叶子 via 已接线 hostSigner;Ed25519→relay 原样);**把安全关键的配对门 `gateAndConsumePairingCode` 从 redeem.ts 抽出供两臂共用**(单次 CAS+锁定+过期+PoP/防替换 顺序不变,relay 逐字节证实不变);**anti-smuggling 再守**(dNSName SAN 只来自服务端分配的 subdomain,body 的 subdomain 惰性——负例证实);hostContentSecret:null。host onboard e2e 证实:配对码→/enroll(EC CSR)→201 叶子链到 frp-client-CA + dNSName → /renew→201 同 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 已有 cert→Host 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 --install`(BIND_HOST loopback S-GATE 已守)→ 两 systemd/launchd unit → `https://.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 会话令牌子系统(login→device: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` 自动接受,已由 issue→verify 测试证实)。② `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`(issue→verify `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 r‖s 转 DER ECDSA-Sig-Value,正整数前导零处理;已 DER 则透传);tbs.signature 与外层 signatureAlgorithm 同一 OID。subject 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/install、cli、keys、CA、control-plane、relay-*)。① `agent/src/transport/frpcToml.ts`(NEW):`buildNativeFrpcToml(opts)` 出 frp v0.61 TOML(PLAN_NATIVE_TUNNEL §4:serverAddr 默认 8.138.1.192/可配、serverPort 443、transport.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 subdomain、port 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 0。TDD 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)。只用 njs∩Node API,故同一源码 Node 单测 + njs 生产同跑。② `deploy/nginx/frp-mtls.conf`(EXTEND):`js_import`+`js_set $cert_sub`+**zone-anchored map** `"~^(?[^:]+):(?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]→Extensions→SAN)。`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 `.terminal.yaojia.wang`(A3 强制点)+ URI `spiffeIdFor(accountId,sub,trustDomain,'host')`;CA:false、EKU clientAuth、KU 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:false、gate 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 的 subdomain→403 line190、未认领→403、非法→400、wrong-right bearer→403、自有→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 请求映射 + save→loadIdentity 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 评审**正向确认属性 1–5 成立**(私钥不可导出/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(` run` 监督 frpc、无 base-app env),`uninstallService` 双拆。③ **zone terminal(FIX L-host-zone)**:`assertNativeZone()`,base-app origin `https://.terminal.`(复用 originConfig,不削弱)。④ **cli `pair --install`**:generateP256Identity(FIX H-host-2)→saveIdentity→enrollNative(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→原路径逐字节不变;ec→p256IdentityFromPrivatePem)——存的 P-256 frp-client 身份能 round-trip(alg:p256 + 可用 ECDSA 签名钥)。② `health/probe.ts`(NEW):4 个可注入 seam 子检查(frpc alive、base-app loopback GET http://127.0.0.1:PORT、frpc '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` 读 `/frpc.log` 但**无人写它** → 恒返 '' → HealthReport.healthy **永不为真**(H4 监控形同虚设);且 frpc 子进程 stdio pipe 不排空 → 缓冲填满致 write(2) 阻塞挂起。自动修:`createFileLoggingSpawn` 把 frpc stdout/stderr tee 进持久化(每次 spawn 截断)日志,同时排空管道(一并解决挂起 MEDIUM)。 - **验证(orchestrator 独立)**: agent 全套 **247/247**(29+ 新:probe 19、frpSupervise 5、keystore/cli),tsc 干净;keystore P-256 round-trip 断言(alg + 原 pubkey 验签);status 无敏感泄漏断言(无 PRIVATE KEY/CERTIFICATE/token/csr)。 - **偏差/延后**: 真 frpc 二进制运行仍待 B3 tar.gz 解压(#11);frpc-stdout→log 已由 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 任务 AW0–AW6。三个开放问题已定: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 trim、HostClassifier 提升 `: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)跑完 A2、B1–B5、B6、C、D1、E。Verify 门槛:**四个包 tsc 全干净、可构建包全 build 成功、314/314 测试通过**(control-plane 103 / relay-run 74 / agent 137 + relay-web 118)。对抗式安全评审:**所有硬不变量 PASS**(INV2 opaque splice、Origin/CSWSH 精确匹配、mTLS INV14 registry 门控、token-mint 密码学、撤销 INV12、jti 单次、参数化 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.` 子协议读 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 验证**:浏览器登录→token→DPoP→upgrade→agent 拼接,本地只到类型/单元层。E2–E5 远端执行见 `deploy/RUNBOOK.md`(需 VPS 访问权)。 - **远端(需 VPS 执行,见 `deploy/RUNBOOK.md`)**: DNS、Let's Encrypt 签证、私有 enrollment CA + capability keypair 生成、阿里云安全组放行 443/AGENT_PORT、systemd 起两个服务、端到端 enroll→dial→浏览器点进 shell。 - **未做/推迟到 Phase 2**: 真 KMS、F6 replay、WebAuthn 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(异步化后 reject→401)。 - **文件**: `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) build→review 四轨并行(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/14、SessionCore 93/93、xcodegen+xcodebuild BUILD SUCCEEDED。 - **C-Desktop `[x]`**(D-1/D-2): `remote-hosts.ts` + DesktopPrefs(不可变、向后兼容)+ 托盘选择器;`will-navigate` 动态锁定选中远端 origin、null 失败关闭(无开放重定向);`select-client-certificate` handler `event.preventDefault()` 优先、按 issuer CN 选证、缺证弹框不静默。**验证**: `npm run typecheck` 干净。 - **S2 host-packaging `[x]`**: launchd 写 ``、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 目录跑通 gen→issue→`openssl verify`→revoke→CRL(OpenSSL 3.0.18);无密钥/证书误入库。 - **对抗式评审结论**: Desktop/VPS 直接 PASS;iOS/S2 各命中 1 个 HIGH(ClientCertScreen 无入口 / 环境注入未接到 install CLI)—— 均属跨 lane 收尾接线,已由第二个 Workflow 补齐并复验。 - **实机 VPS 部署 V0→V6 完成,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(全链路 :443→SNI→:8470 mTLS→frps→frpc→base 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)**: VPS→LE 被墙(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/lg16、Motion gated by reduceMotion、Haptics)、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 决策点(regular→split/compact→stack,仿 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 防 42→42.0)、公开四层 HostNetworkTier(W3 dedup 欠账,PairingVM 后续 pass 可切换)。76/76 绿,覆盖 95.26%。 - 遗留(LOW/cosmetic): Swift doc comment 里 server 行号引用漂移;PairingVM 分类去重待后续 pass。 - **[x] P1-B UI 九连链(22→24→25→27→26→23→29→28→21 全 DONE,10 agents;verify 判 BLOCKED→orchestrator 修复后放行)**: - **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 50→reverse);`[]`=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 是 §3.3 HostRegistry 类型,而 APIClient 依赖边仅 WireProtocol(§1 零耦合),签名无法编译(两轮 review 均漏)。**裁定(orchestrator,三选一取 c)**: 探针职责=验证 endpoint → 返回 `Result`,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-11→12→13→14] 串行链 ∥ 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 任务 W0–W5 ≈13 人日、P1 W6–W8 ≈17.5、P2 W9–W10 ≈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.95–0.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:/')` 复用**前端零改动**(前端严格同源,`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 D5–D7)**: `.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 任务完成并验证**(P1–P6,多 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 全部完成**(H1–H4 + 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=` 加入指定会话。 - ✅ **镜像尺寸 = 最近活跃端优先(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**: **W0–W5 基本完成**。v0.1 功能完整、测试与真机浏览器验证通过。仅剩 2 项需用户真设备。 - **进度**: **20/21 任务**(T19 待真手机)· **187 自动化测试全绿** · 真 PTY 端到端验证 · **真浏览器 smoke 通过**。 - **验收 F1–F9**: ✅ 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 | 验收 F1–F3(终端/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://: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) > **倒序**,最新在最上。每个子任务一条。复制下方模板填写。 ### 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 页扁平网格太乱(50–100 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` 续跑 W1–W4。 - **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`(读 `/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 `(文件在主机,故在主机开;不分查看设备)。主机已确认装有 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(P1–P6,多 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`)。**至此 H1–H4 全部高价值功能完成。** - **机制**: useTmux 时把 shell 跑进 `tmux new-session -A -s web_`;node-pty 进程只是 tmux **客户端**。服务重启后,客户端带同 sessionId 重连 → manager 发现 `web_` 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 — 验收 + 收尾(T16–T21) - **状态**: 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、`