Files
web-terminal/docs/FEATURE_WALKAWAY_WORKBENCH.md
Yaojia Wang 4f1d3ebc6b docs(v0.7): PRD + implementation plan for Walk-away Workbench (Band A + B)
Authored via multi-agent orchestration (PRD: Band A ∥ Band B → architect
review → integrate; Plan: frontend ∥ backend ∥ security → review → integrate).

- docs/FEATURE_WALKAWAY_WORKBENCH.md — PRD for 9 features:
  Band A (push+lock-screen approve, voice, quick-reply chips, activity
  timeline, stuck/idle alert) and Band B (read-only git diff, statusLine
  cost/context/PR gauges, UI worktree-create, plan-mode relay).
- docs/PLAN_WALKAWAY_WORKBENCH.md — 27 tasks (waves W-1..W4), disjoint Owns,
  contract resolutions, 30-item security checklist, dispatch schedule, AC map.

Planning only — no source changed.
2026-06-30 15:51:58 +02:00

727 lines
63 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Feature: Walk-away Workbench 走开即工作台 (web-terminal v0.7)
> **状态 / Status**: 提案 / Draft探索完成已并入两个 Band 的草案并应用评审意见,待评审后进入 PLAN
> **目标 / Goal**: v0.7 把 web-terminal 从"能远程看的终端"推进成一个**走开即工作台 (Walk-away Workbench)**,分两个 Band
> - **Band A — 闭合"走开"循环 (Finish the walk-away loop)**:主机**主动找到你的手机**Web Push / ntfy让你**在锁屏上批准/拒绝**一个被 hold 住的工具权限(不开 app并把移动端输入做得更快语音、快捷回复加上"我离开时发生了什么"时间线,和"会话卡住了"主动告警。
> - **Band B — 在终端之上长出工作台 (Become a workbench above the terminal)**:每个会话的**只读 git diff/review**、**statusLine 遥测**(成本/上下文/PR/额度仪表)、**从 UI 建 git worktree**(真并行不互踩)、**plan-mode/权限模式中继**(启动时选模式 + plan 门三选一)。
> - **关键结论 / Key finding**9 个特性几乎全部是**两条既有侧信道的延伸**——(1) loopback hook 侧信道(`POST /hook`、held `POST /hook/permission``src/server.ts:261-303`)与已安装的 PWA`public/sw.js`、`public/manifest.webmanifest`(2) 安全 git-exec 模式(`execFileAsync('git', …)`,无 shell`src/http/projects.ts`)。**服务端依旧不解析一个终端字节**——新增的只有 `git` 子进程、带外 JSON、和一个出站的推送签名。**B3建 worktree是唯一写盘特性**,必须挂 Origin/CSRF 守卫;**A1 的锁屏审批是本版本最大的正确性改动**(要改 `/hook/permission` 的"无客户端不 hold"门,见 §A1
---
## 0. 文档导航 (Section Map)
| # | 章节 | 对应需求 |
|---|------|----------|
| 1 | 概览、目标、非目标 (Overview / Goals / Non-Goals) | 必备 (1) |
| 2 | 共享设计原则 (Shared Design Principles) | 必备 (3) |
| 3 | Band A — A1…A5各含用户故事 / FR 表 / 设计与数据流 / 安全 / 配置 / 验收) | 必备 (2) |
| 4 | Band B — B1…B4同上结构 | 必备 (2) |
| 5 | 合并安全章节 (Consolidated Security) | 必备 (4) |
| 6 | 合并配置 env 表 (Consolidated Config) | 必备 (5) |
| 7 | 优先级与跨特性依赖排程 (Prioritization & Dependency Ordering) | 必备 (6) |
| 8 | 验收标准汇总 (Acceptance-Criteria Summary) | 必备 (7) |
| 9 | 一句话总结 | — |
---
## 1. 概览、目标与非目标 (Overview, Goals & Non-Goals)
### 1.1 问题陈述 (Problem)
今天的核心用例是 **vibe coding**:派 Claude Code 一个任务、走开、回来从任意设备看进展。v0.3v0.6 已经做到"会话不随断连而死""多设备镜像""缩略图墙""项目面板""远程 approve/reject"。但循环仍有两道缺口:
1. **走开后没人叫你**——会话需要批准工具或跑完了,除非你盯着 tab否则不知道A 段补齐"主机主动找你 + 锁屏直接处理")。
2. **终端之上没有工作台**——回看 Claude 改了什么只能滚字节缓冲;看不到这次烧了多少钱/上下文还剩多少;多 agent 并行会踩同一个工作树B 段把这些做成结构化的、带外的视图与受控写操作)。
### 1.2 目标 (Goals)
- **闭合走开循环**NEEDS-INPUT / DONE / STUCK 三类信号主动推到手机;锁屏 Allow/Deny 即可让 Claude 继续,**全程不开 app**。
- **更快的移动输入**:语音口述、快捷回复 chips、可保存的提示词调色板。
- **离开期回放**:每会话的人话时间线("跑了 Bash · 改了 3 个文件 · 等批准 · 完成")。
- **终端之上的工作台**:只读 diff/review、per-tab 遥测仪表、从 UI 建 worktree、plan-mode 中继。
- **不破坏护城河**自托管、不经云、byte-shuttle、LAN-onlyTailscale 推荐、config 无硬编码、immutable、TDD ≥80%。
### 1.3 非目标 (Non-Goals全 Band 通用)
- **不加登录/鉴权/多用户隔离**(属未来 Band C。A1 引入的"按 pending 决策的能力 token"只是让锁屏 Allow/Deny 安全的最小手段,**不是**应用级 auth。
- **不在服务端解析终端语义 / ANSI**——所有新信号都是带外 JSON 或 git 子进程输出xterm.js 仍是唯一的终端解释者。
- **不做服务端语音转写 / 文件编辑器 / 行级回评 agent / worktree 看板 / 跨会话成本汇总 / 推送任意自定义消息**(各特性 §"暂不做"细列)。
- **不把本服务暴露到公网**。Band A1 的 Web Push 需要 HTTPS——通过 **Tailscale Serve / TLS** 实现(见 §2 与 §A1 的可行性前提),而非裸 LAN-over-HTTP 的公网化。
---
## 2. 共享设计原则 (Shared Design Principles)
适用于全部 9 个特性的不可协商前提,逐项落到既有代码:
- **SP1 — Byte-shuttle 不变**:服务端不新增任何 ANSI/终端语义解析。git 数据流、statusLine 遥测、时间线全部**带外**HTTP/JSON 或 git 子进程),与终端 WS 字节流完全隔离——与 v0.3 hook 侧信道同构。
- **SP2 — 复用 hook 侧信道**Claude 进程 POST 来的端点(`/hook``/hook/permission`,未来 `/hook/status``/hook/decision`。loopback-only 的摄入端点用 `isLoopback(req.socket.remoteAddress)` 拦非本机(`src/server.ts:70-78`**唯一例外**是 `/hook/decision`A1它被**远程**设备的 SW 调用,故改用 `requireAllowedOrigin` + 能力 token见 §A1.5、§5
- **SP3 — 复用 held-permission 机器**`pendingApprovals` / `resolvePending` / `permDecision``src/server.ts:114-122, 61-63`。A1 的锁屏决策与 B4 的 plan 门都是这条通道上的**新 resolver / 新 gate 类型**,不新建审批状态机。
- **SP4 — 复用安全 git-exec 模式(强制)**:任何 `git` 调用走 `execFileAsync('git', [...])`**无 shell**+ timeout + `maxBuffer` 截断 + `mapWithConcurrency` 并发上限,照搬 `src/http/projects.ts``src/http/worktrees.ts`。**绝不**字符串拼 shell。入口 path 一律 `path.isAbsolute()` + `fs.stat().isDirectory()` + `hasGitEntry` 三连校验。
- **SP5 — 只读 vs 状态变更的守卫口径(已确立)**:只读发现端点(`/projects``/live-sessions`、A4 `/…/events`、B1 `/projects/diff`**不挂** Origin 守卫;任何写/副作用端点B3 `/projects/worktree`、A1 `/push/subscribe``/hook/decision`**必须**挂 `requireAllowedOrigin``src/server.ts:215-222`,已被 `DELETE /live-sessions``POST /open-in-editor` 使用)。
- **SP6 — PWA 是交付载体**A1 在 `public/sw.js``push`/`notificationclick`,在 `manifest.webmanifest` 之上利用既有可安装性。WS 仍随页面协议选 `ws:`/`wss:`M6CSP 仍 `script-src 'self'``src/server.ts:135-138`)。
- **SP7 — 不信任外部数据 + 防 XSS**git diff/statusLine/hook body/snippet 标签都是**未经信任的工具/文件内容**。前端一律 `textContent`/安全 DOM 构建(`el()`**禁止 `innerHTML` 渲染外部内容**`makeLauncher``innerHTML` 仅限可信品牌 SVG不可作先例。未信任 JSON 用 `unknown` + 逐字段收窄(仿 `parseHookEvent`never throw。
- **SP8 — config 无硬编码 + env 命名约定L4****服务端 `cfg` 读的 env 一律无前缀**(如 `PORT``VAPID_PRIVATE_KEY``WORKTREE_ROOT`**spawn 注入 / hook 脚本读的 env 一律 `WEBTERM_` 前缀**(如 `WEBTERM_SESSION``WEBTERM_STATUSLINE_URL``WEBTERM_NTFY_TOKEN`)。新参数走 `src/config.ts``parse*` 帮手 + `src/types.ts``Config`。秘密在启动校验/缺失优雅降级never 硬编码、never 入日志。
- **SP9 — 类型契约集中(协调点)**:所有新共享类型加在 `src/types.ts`**单一 owner 任务**,见 §7模块内不本地重声明。
- **SP10 — 共享 UI 组件M4**A4 时间线、A5 卡住徽标、B2 仪表、既有 working/waiting/idle 状态点都渲染进 `tabs.ts`/`preview-grid.ts`/`projects.ts`。统一抽出**一个 per-session 状态/仪表组件**与**一个 `refreshTab` owner**,复用既有状态色语义,避免三个任务抢同一片 DOM、重复实现陈旧/着色逻辑。
---
# 3. Band A — 闭合"走开"循环
> **触发点A 段共用)**:服务端已把 hook 折成粗 `ClaudeStatus``parseHookEvent``src/http/hook.ts:24-57`),并经 `manager.handleHookEvent` 广播(`src/session/manager.ts:196-212`。Band A 挂在同样两个转移上:
> - **NEEDS-INPUT高优** = `PermissionRequest` 或 `Notification(notification_type==='permission_prompt')` → `waiting``PermissionRequest` 会被服务端 **hold** 在 `pendingApprovals` 等决策。
> - **DONE低优** = `Stop`/`SessionEnd` → `idle`。
> 加上 **STUCKA5 新增,非 hook 触发)** = 输出静默超阈值。三类信号都经 A1 的统一通知服务出站。
---
## A1. 移动推送 + 锁屏审批 (Mobile Push + Lock-Screen Triage)
### A1.1 用户故事 (User Stories)
- **US-A1a核心**:装了 PWA 后Claude 需要批准工具时,我手机锁屏收到带 **Allow / Deny** 的推送;点一下就解决并让 Claude 继续——**我不开 app**。
- **US-A1b**Claude 跑完Stop/SessionEnd我收到**低优**通知("会话完成"),知道该回来看。
- **US-A1c**:不想依赖浏览器 PWA 时hook 脚本可**额外** ping 一个 **ntfy / Pushover** topic任何装了那个 app 的设备都会响——与 PWA 是否安装/打开无关(也是裸 LAN-over-HTTP 部署下的可用回退,见 A1.3 可行性前提)。
### A1.2 功能需求 (Functional Requirements)
| ID | 需求 | 优先级 |
|----|------|--------|
| A1-FR1 | Web Push 订阅:`POST /push/subscribe` 存浏览器 `PushSubscription``DELETE /push/subscribe` 删;持久化到 config 目录下的 JSON重启存活。**写端点,挂 `requireAllowedOrigin`** | P0 |
| A1-FR2 | 服务端在 §3 两个转移上用 VAPID 签名发 Web Push**NEEDS-INPUT**(高优、`requireInteraction`)与 **DONE**(低优)。依赖 `web-push` npm 包(见 L1/§5 供应链) | P0 |
| A1-FR3 | `GET /push/vapid-key` 暴露公钥给 SW 订阅;私钥+subject 来自 env。VAPID env 未设 → 整个 push **优雅禁用**(端点 503、UI 隐藏开关、不崩) | P0 |
| A1-FR4 | **改 `/hook/permission` 的 hold 门(最关键,见 A1.4 C1**:当前"`session.clients.size === 0` → 立即 `{}` 不 hold"会让"零 tab 走开"时**根本不 hold、不 waiting、不推送**。改为:**有 ≥1 个挂着的客户端 _或_ push 已启用且有 ≥1 个活跃订阅 → hold**hold 时铸一个**按 pending 决策**的能力 token | P0 |
| A1-FR5 | SW `push` 渲染通知NEEDS-INPUT 带 **Allow / Deny** 动作按钮payload 携带 `sessionId`、工具名、短 detail、和**那次 pending 决策的能力 token** | P0 |
| A1-FR6 | SW `notificationclick`(Allow/Deny) → `POST /hook/decision {sessionId, decision, token}`,校验 token + Origin 后 `resolvePending(...permDecision(...))`(与 WS approve/reject 同效)。点正文(无动作)= 打开 app 并聚焦该会话 | P0 |
| A1-FR7 | 同一 `tag`(按 session) 使通知**替换不堆叠**;决策被另一设备/超时(`cfg.permTimeoutMs`)解决后,其它设备的通知被**更新/清除**(无失效的 Allow/Deny | P1 |
| A1-FR8 | **ntfy/Pushover 桥(秘密走 env见 H3**`scripts/setup-hooks.mjs``WEBTERM_NTFY_URL`/`WEBTERM_NTFY_TOPIC`(或 `WEBTERM_PUSHOVER_*`)存在时,给 hook 命令追加一条 fire-and-forget curlpriority 映射NEEDS-INPUT=high、DONE=low。**token 以 spawn 注入的 `$WEBTERM_NTFY_TOKEN` 引用,绝不写字面量进 settings.json** | P1 |
| A1-FR9 | 全局/按会话**静音**开关(客户端 localStorage 偏好)+ 服务端全局 DND env 默认 | P2 |
### A1.3 UX 与可行性前提 (UX & Feasibility)
- **可行性前提C2置顶**Service Worker + `pushManager.subscribe` **要求安全上下文**。手机访问 `http://192.168.x.x:3000` **不是**安全上下文(只有 `localhost` 是),而项目文档默认是裸 LAN-over-HTTPCLAUDE.md / TECH_DOC §7。因此 **Band A1 的 Web Push 需要 HTTPS**——推荐 **Tailscale Serve / TLS**。前端必须 `!isSecureContext` 检测并给出明确文案("启用推送需经 HTTPS/Tailscale 访问"),并在不安全上下文下隐藏 🔔 开关、引导改用 **A1-FR8 的 ntfy/Pushover 桥**curl 直连外部服务,不受安全上下文限制,是裸 LAN 部署的回退。iOS 还额外要求 PWA **已加到主屏**。CSP `connect-src 'self' ws: wss:` 在 TLS 同源下满足 push 端点可达。
- **设置**:工具栏 🔔 开关 → 浏览器权限提示 → `pushManager.subscribe(vapidKey)``POST /push/subscribe`;开关反映 granted/denied/unsupported/insecure-context。
- **NEEDS-INPUT 通知**:标题 `⏳ Claude needs you — <repo/session>`,正文 = 工具名 + detail 首行,动作 **[Allow] [Deny]**`requireInteraction:true`。Allow/Deny 不开 app 即解决;下次打开有 toast 确认结果。
- **DONE 通知**`✅ Session done — <repo/session>`,无动作、低优、点开聚焦该 tab。
- **移动优先**核心价值就是锁屏处理happy path 零前台交互;不可用时降级到 ntfy/Pushover。
### A1.4 数据流 (Design / Data Flow)
```
Claude hook (host) ──► POST /hook 或 held POST /hook/permission (loopback)
│ parseHookEvent → status/hook/permission: ★改门★
│ hold ⇔ (session.clients.size>0) OR (push 启用 && 有活跃订阅) ← C1 修正
│ hold 时铸 per-decision capability token存入 pendingApprovals 条目
manager.handleHookEvent → 转移 (waiting / idle) → notifyService.notify(session, class, token?) ← 新
▼ web-push 用 VAPID 签名 → 每个存储的 PushSubscription404/410 自动剪除)
device SW 'push' (public/sw.js) → showNotification(actions:[Allow,Deny], data:{sessionId,token})
│ 用户点 Allow
SW 'notificationclick' → POST /hook/decision {sessionId, decision, token} ← 新路由远程→Origin+token 守卫)
│ 校验 Origin + token按 pending 决策、resolve/超时即失效)→ resolvePending(sessionId, permDecision(...))
held curl 返回 decision JSON → Claude 继续(与 WS approve/reject 同路径src/server.ts:439-445, :61-63
```
- **新共享类型(加 `src/types.ts`**`PushSubscriptionRecord`endpoint/keys/createdAt`pendingApprovals` 条目扩 `token` + `expiresAt``NotifyClass = 'needs-input'|'done'|'stuck'`
- **新文件**`src/push/push-service.ts`(签名/发送/通知服务)、`src/push/subscription-store.ts`(持久化/剪除)、`public/push.ts`(订阅流 + 🔔 + 决策 fetch
### A1.5 安全考量 (Security)
- **`/hook/decision` 是新敏感面**:它被**远程**设备 SW 调用(非 loopback`isLoopback` 不适用),故必须 (1) 过 `requireAllowedOrigin`CSWSH/CSRF(2) 校验**能力 token**。**token 绑定到"该 session + 当前那次 pending 请求"resolve/超时即失效**M1——泄漏的长期订阅 token 不能解决任意未来审批。失配/陈旧 → 403。对 `/push/subscribe``/hook/decision` 限流。
- **VAPID 私钥 + subject 是秘密** → 仅 env、启动校验、绝不入日志缺失 → 禁用而非崩。
- **订阅库**含 endpoint+keys按敏感处理文件权限 600绝不经任何 GET 暴露);返回 404/410 的订阅剪除;`PUSH_MAX_SUBS` 封顶防 DoS。
- **ntfy tokenH3**:以 spawn 注入 env 引用,**不写进 settings.json也不进 curl 字面 argv**(避免 `ps`/备份文件泄密)。
- **payload 最小化**:只带 session id + 工具名 + 短 detail无命令输出、无秘密守 byte-shuttle 边界)。
- **供应链L1**:新增 `web-push` 运行时依赖——纳入安全清单与锁版本。
### A1.6 配置 (Config)
| Env | 默认 | 说明 |
|-----|------|------|
| `VAPID_PUBLIC_KEY` | 未设→push 关 | 公钥(暴露给客户端) |
| `VAPID_PRIVATE_KEY` | —(秘密) | 私钥;启用 push 必需 |
| `VAPID_SUBJECT` | `mailto:admin@localhost` | VAPID `sub` |
| `PUSH_STORE_PATH` | `<homeDir>/.web-terminal/push-subs.json` | 订阅持久化文件600 |
| `PUSH_MAX_SUBS` | `50` | 订阅数上限DoS |
| `NOTIFY_DONE` | `1` | 是否发低优 DONE 推送 |
| `NOTIFY_DND` | `0` | 全局免打扰默认A1-FR9 |
| `DECISION_TOKEN_TTL_MS` | =`PERM_TIMEOUT_MS` | 能力 token 寿命(随 pending 决策) |
| spawn/hook env`WEBTERM_NTFY_URL`/`WEBTERM_NTFY_TOPIC`/`WEBTERM_NTFY_TOKEN`/`WEBTERM_PUSHOVER_TOKEN`/`WEBTERM_PUSHOVER_USER` | 未设→桥关 | ntfy/Pushover 桥token 走 env不入 settings.json |
### A1.7 暂不做 (Out of Scope)
- 任意/自定义用户推送、营销、定时通知。
- 多用户订阅归属 / 按用户路由(无 auth — Band C
- 原生 iOS/Android app 或 FCM/APNs 直连(仅 Web Push + ntfy/Pushover
- 富媒体/图片通知。
### A1.8 验收标准 (Acceptance)
- **AC-A1.1**:装 PWA 并开 🔔 后,`POST /push/subscribe` 204`GET /push/vapid-key` 返回配置的公钥。
- **AC-A1.2C1 核心)****在零浏览器 tab 挂着**的情况下Claude 触发工具批准 → 手机锁屏出现带 Allow/Deny 的通知;点 Allow 解决 held 请求并让 Claude 继续,**不开 app**;点 Deny 则拒绝。
- **AC-A1.3**:一台设备解决(或 `cfg.permTimeoutMs` 超时)后,其它设备的通知被替换/清除(无失效 Allow/Deny
- **AC-A1.4**`Stop`/`SessionEnd``NOTIFY_DONE=1` 时到达低优 DONE 通知。
- **AC-A1.5C2**在不安全上下文http LAN下 UI 隐藏 🔔 并提示需 HTTPS/Tailscale不崩HTTPS 下订阅成功。
- **AC-A1.6**VAPID env 未设 → push 端点 503、UI 隐藏开关、无崩。
- **AC-A1.7H3**`WEBTERM_NTFY_*` 设置后 `setup-hooks.mjs` 写出会 ping ntfy 的命令且**正确 priority****settings.json 与进程 argv 中不含任何 token 字面量**web-terminal 外该 curl no-op。
- **AC-A1.8M1**`/hook/decision` 拒绝缺/外来 Origin403与坏/陈旧 token403resolve 后旧 token → 403。
---
## A2. 语音口述 (Push-to-Talk Mic on Mobile Input Bar)
### A2.1 用户故事
- **US-A2**:移动端按住 🎤 说话,松手后转写文本被打进 Claude可选自动 Enter无需软键盘。
### A2.2 功能需求
| ID | 需求 | 优先级 |
|----|------|--------|
| A2-FR1 | 按住录音Web Speech `SpeechRecognition`)、松手停止;最终转写经既有 raw-bytes 路径发送 | P0 |
| A2-FR2 | 实时 interim 转写显示在小 overlay/chip发送前可见 | P1 |
| A2-FR3 | "发送 vs 仅插入"设置(是否补 `\r`);默认仅插入、用户手动点 ⏎ | P1 |
| A2-FR4 | 不支持的浏览器(如非 Chromium隐藏 🎤,绝不抛错 | P0 |
| A2-FR5 | 识别语言可选(默认 `navigator.language` | P2 |
### A2.3 UX 与数据流
- 🎤 置于键栏(`public/keybar.ts``touchstart`/`touchend` 按住语义并 `preventDefault` 保持软键盘不弹(仿 `keybar.ts:104-108`);按住时脉冲红点 + interim chip桌面点击切换回退仿 `:111-114`)。
- 松手 → `onSend(transcript [+ '\r'])` = `TerminalSession.send``public/terminal-session.ts:316-319`)→ `{type:'input'}``pty.write`byte-shuttle 不变)。**纯前端,零服务端改动**。
### A2.4 安全考量
- 麦克风权限浏览器把关;转写作 raw `input.data` 逐字透传(协议禁止过滤输入内容),无新注入面。
- **隐私警示L2**Chrome Web Speech 会把音频流给 Google 服务——**文档明示**;这是客户端选择,自托管服务端**永不接触音频**(设 AC 验证)。
### A2.5 配置:无 env前端。客户端偏好 `dictation.autoSend`/`dictation.lang`localStorage
### A2.6 暂不做:服务端 STTWhisper 等)、语音命令(如"approve")、唤醒词/常听、录音存储。
### A2.7 验收标准
- **AC-A2.1**:支持的移动浏览器上按住 🎤 说话显示 interim松手把最终转写打进当前终端。
- **AC-A2.2**使用麦克风时软键盘不弹touch `preventDefault`)。
- **AC-A2.3**:无 `SpeechRecognition` 的浏览器隐藏 🎤 且不报错。
- **AC-A2.4**:自动发送开 → 以 `\r` 结尾;关 → 等手动 ⏎。
- **AC-A2.5L2**:抓包/服务端日志确认无任何音频抵达服务端。
---
## A3. 快捷回复 chips + 提示词调色板 (Quick-Reply Chips + Saved Prompt Palette)
### A3.1 用户故事
- **US-A3a**:移动端在键栏旁有 tap-to-send chips`yes``continue``1/2/3``Esc`),一点即发。
- **US-A3b**:可保存自定义 snippet"run the tests"、"write a commit"、"/clear")到调色板,一点即发,跨会话持久。
### A3.2 功能需求
| ID | 需求 | 优先级 |
|----|------|--------|
| A3-FR1 | 内置快捷 chips`yes\r`/`continue\r`/`1\r`/`2\r`/`3\r`/`Esc`),各经既有 `onSend` 发送 | P0 |
| A3-FR2 | 用户调色板:增/改/删命名 snippetlocalStorage 持久;每条含文本 + 是否补 `\r` | P0 |
| A3-FR3 | 一点发送任意 snippet同键栏 `ws.send` 机制) | P0 |
| A3-FR4 | 重排/收藏;首次运行播种合理默认集 | P1 |
| A3-FR5 | 长按编辑(移动)/ 右键(桌面) | P2 |
| A3-FR6 | 调色板 JSON 导入/导出(手动跨设备搬运) | P2 |
### A3.3 UX 与数据流
- chips 横向滚动行,置于键栏内/上,视觉与既有键栏一致;`` 开小型调色板编辑器(复用既有玻璃浮层样式)。点 chip 即发(无确认),编辑显式(长按/``)。
- 点 chip/snippet → `onSend(text [+ '\r'])` = `TerminalSession.send``terminal-session.ts:316-319`)→ `{type:'input'}``pty.write`。调色板存 localStorage**无服务端**。
### A3.4 安全考量
- snippet 是用户文本,按"和打字同信任级"作 raw input**标签以 `textContent` 渲染**,绝不 `innerHTML` 用户串SP7。localStorage 按 origin 隔离、无服务端存储、无新端点。
### A3.5 配置:无 env客户端调色板/默认在 localStorage
### A3.6 暂不做:服务端/团队共享 snippet 库(无 auth — Band C、参数化模板、经服务端同步调色板导入/导出是手动桥)。
### A3.7 验收标准
- **AC-A3.1**:内置 chips 出现并发正确字节(含 `\r`)。
- **AC-A3.2**:可增/改/删/重排 snippet刷新后持久。
- **AC-A3.3**:点 snippet 经与键栏同路径发送(终端确实收到)。
- **AC-A3.4**:含特殊字符的标签作惰性文本渲染(无 DOM/脚本注入)。
---
## A4. 活动时间线 ("What Happened While I Was Gone")
### A4.1 用户故事
- **US-A4**:回来时在缩略图/详情旁看到**人话、带时间戳的流**"10:02 ran Bash · 10:03 edited 3 files · 10:05 waiting for approval (Write) · 10:09 done",几秒看懂离开期发生了什么。
### A4.2 功能需求
| ID | 需求 | 优先级 |
|----|------|--------|
| A4-FR1 | 把离散 hook 事件(`PreToolUse`、**`PostToolUse`**、`Notification``Stop``SessionEnd``UserPromptSubmit`)存入**每会话、带时间戳、有界**的内存事件环。**注意:当前安装器 `FF_EVENTS` 不含 `PostToolUse`H1见 §A4.5),必须补注册** | P0 |
| A4-FR2 | `GET /live-sessions/:id/events` 返回该会话最近时间线(按数封顶) | P0 |
| A4-FR3 | 每事件含:时间戳、事件类、工具名(有则)、服务端派生的人话短标签(`PreToolUse(Bash)`→"ran Bash" | P0 |
| A4-FR4 | 前端在详情/缩略图旁渲染流newest-first开着时自动刷新 | P0 |
| A4-FR5 | 有界环(每会话 `cfg.timelineMax`),旧的淘汰;与字节环互补不替代 | P0 |
| A4-FR6 | hook body 提供文件路径时Edit/Write 的 `tool_input`)捕获"改了哪些文件",否则回退工具名 | P1 |
| A4-FR7 | 折叠快速重复事件("ran Bash ×4" | P2 |
### A4.3 UX
- 详情页/缩略图卡片的 "Timeline" 面板:`HH:MM · 图标 · 标签` 竖列按类着色working=中性、waiting=琥珀、done=绿,复用既有状态语义,**经 §SP10 的共享组件**)。时间线**补充**实时缩略图(`RingBuffer.tail()``src/server.ts:197-209`):缩略图是"现在屏幕长啥样",时间线是"这段时间发生了啥"。
### A4.4 数据流与**对 hook 摄入的扩展H2非纯复用**
```
POST /hook (loopback) body: hook_event_name, tool_name, notification_type, [tool_input]
│ ★ parseHookEvent 当前只回 {sessionId,status,detail(=tool_name)},会丢掉时间戳/tool_input ★
│ → A4 必须扩展 hook 摄入:要么 parseHookEvent 回更富事件,要么把 raw body 传给时间线 appender
▼ status 路径不变 + append 离散 TimelineEvent → session.timeline有界环sanitize
GET /live-sessions/:id/events → 最近 TimelineEvent[] → 前端面板(可见时轮询)
```
- **被编辑面(明确列出,非"reuse"**`src/http/hook.ts``parseHookEvent` 或新 appender+ `/hook` 路由(`src/server.ts:266-272` 当前只把 `{status,detail}` 传给 manager+ `scripts/setup-hooks.mjs`(补 `PostToolUse`)。
- **新文件**`src/session/timeline.ts`(有界环 + 标签派生 + sanitize纯、可单测`public/timeline.ts`(面板)。
- **新类型**`TimelineEvent``src/types.ts``Session` 加有界 `timeline`"在不可变 meta 上挂可变 runtime handle"模式,同 `buffer`/`lastOutputAt`)。
### A4.5 安全考量
-`/live-sessions` 同威胁模型:只读发现、不挂 Origin 守卫(守卫给状态变更路由);暴露工具名/文件路径给 LAN 设备——与"已对 LAN 交出 shell"一致Tailscale-onlyTECH_DOC §7
- **未信任 hook body**whitelist `hook_event_name`、截断字符串长度、**sanitize 控制字符**(复用 `sanitizeForLog``src/server.ts:80-86`)后再存路径/工具名。有界环防事件洪水撑爆内存DoS
### A4.6 配置
| Env | 默认 | 说明 |
|-----|------|------|
| `TIMELINE_MAX` | `200` | 每会话事件环上限 |
| `TIMELINE_ENABLED` | `1` | 捕获/服务时间线总开关 |
### A4.7 暂不做跨重启持久化仅内存同会话、Claude 推理的 token 级全转录(`~/.claude` 已有深度回放)、服务端 ANSI 推断活动(守 byte-shuttle、时间线搜索/分析。
### A4.8 验收标准
- **AC-A4.1**:跑一个 Claude 任务产出离散、带时间戳、人话的条目工具用、waiting、done
- **AC-A4.2**`GET /live-sessions/:id/events` 返回最近事件,受 `TIMELINE_MAX` 界。
- **AC-A4.3**:面板在缩略图/详情旁渲染并随新事件更新。
- **AC-A4.4SP7**hook body 的工具名/路径经 sanitize无控制字符并截断后才显示。
- **AC-A4.5H1**`setup-hooks.mjs` 安装后 `FF_EVENTS``PostToolUse`"完成 Bash"类事件确实到达。
- **AC-A4.6**`TIMELINE_ENABLED=0` 不存事件、端点返回空。
---
## A5. 卡住/静默主动告警 (Stuck / Idle Proactive Alert)
### A5.1 用户故事
- **US-A5**:会话既没"完成"也没 hold 审批、只是**静默**(卡在未挂 hook 的提示,或 wedged若**无输出超 N 分钟**而它看着在忙,我收到"会话可能卡住 — 12 分钟前最后活动"告警。
### A5.2 功能需求
| ID | 需求 | 优先级 |
|----|------|--------|
| A5-FR1 | 检测静默:复用 `lastOutputAt``now - lastOutputAt > STUCK_TTL` 且非 idle/已退出 → stuck-候选 | P0 |
| A5-FR2 | 首次越阈经 A1push/ntfy发通知**每个 stuck 回合只发一次**(输出恢复后重新武装) | P0 |
| A5-FR3 | 检测扫描跑在**既有 reaper 间隔**`cfg.reapIntervalMs`),不新增常驻 timer | P0 |
| A5-FR4 | UI 出 "stuck" 徽标(缩略图墙/tab 状态,经 §SP10 共享组件),无 push 也可见 | P1 |
| A5-FR5 | 阈值可配;`0` 禁用 | P0 |
| A5-FR6 | 可选抑制"从未为 vibe-coding 启动且零客户端"的会话——但默认无脑告警(你就是走开了) | P2 |
### A5.3 设计与数据流(**对 reaper/manager 的真实改动H4**
```
reaper 每 cfg.reapIntervalMssrc/server.ts:317-321 → manager.reapIdle
│ ★ reapIdle 当前只遍历 detached 会话if detachedAt===null continue
│ → A5 需要 manager 新方法 sweepStuck()(或在 reapTimer 里加独立 sweep 回调),
│ 遍历 attached-或-detached 的存活、非 idle 会话,并把 notifyService 注入 manager
▼ if now - session.lastOutputAt > STUCK_TTL && !session.stuckNotified
→ session.stuckNotified = true → notifyService.notify(session,'stuck') ← 经 A1
▼ 新 pty 输出lastOutputAt 前进)→ 重置 session.stuckNotified重新武装
```
- **复用**`lastOutputAt``src/types.ts:163`,已是 orphan-reclaim 的 liveness 代理reaper 节拍(不新 timerKISS
- **新改动(重新界定 Owns**`manager.ts``sweepStuck()` + manager↔notifyService 接线;`Session``stuckNotified` flag。**依赖 A1 先落地 notifyServiceM5**。
### A5.4 安全考量
- 无新端点,服务端内部;继承 A1 通知安全token、payload 最小化)。阈值经 env、校验为非负整数复用 `parseNonNegativeInt``src/config.ts:42-55`0=禁用。**每回合至多一次告警A5-FR2**,防告警风暴。
### A5.5 配置
| Env | 默认 | 说明 |
|-----|------|------|
| `STUCK_TTL` | `600`10 分钟) | 静默窗口;`0` 禁用 |
| `STUCK_ALERT` | `1` | 卡住告警总开关 |
### A5.6 暂不做:经进程组/子进程探测的真挂死检测node-pty 跨平台不可靠,同 orphan-reclaim 限制ARCHITECTURE §3.5/M3、自动补救发输入/杀会话)、每命令"预期时长"自适应阈值。
### A5.7 验收标准
- **AC-A5.1**:无输出达 `STUCK_TTL`(且非 idle/退出)→ 经 A1 通道恰发一次 stuck 通知。
- **AC-A5.2**:输出恢复后 flag 重新武装,后续静默可再告警。
- **AC-A5.3H4**:扫描不加新 timer跑在既有 reaper pass 内),且**确实覆盖 attached 会话**(不止 detached
- **AC-A5.4**`STUCK_TTL=0``STUCK_ALERT=0` 禁用、无通知。
- **AC-A5.5**:缩略图墙/tab 状态出 "stuck" 徽标。
---
# 4. Band B — 在终端之上长出工作台
---
## B1. 每会话只读 git diff / review (Read-only Git Diff / Review)
### B1.1 用户故事
- **US-B1.1(核心)**:手机点项目/会话的 **Diff** → 看到**按文件分组**的 unified diff每文件带 `+增/-删` 统计、绿/红高亮、可折叠/跳转——只读,不怕误触改坏。
- **US-B1.2**:可在 **未暂存(working)/已暂存(staged)** 间切换。
- **US-B1.3**:窄屏不横向滚到吐——可切 unified↔split默认窄屏 unified + 软换行。
- **US-B1.4(仅记录)**:行级回评给 agent——Band B 不做。
### B1.2 功能需求
| ID | 需求 | 优先级 |
|----|------|--------|
| FR-B1.1 | `GET /projects/diff?path=<abs>&staged=0\|1` 只读端点:跑 `git diff [--staged] --` 取 patch返回结构化 diff | P0 |
| FR-B1.2 | 复用安全 git-exec`execFile`(无 shell+ timeout + maxBuffer 截断 + 入口 path 校验(绝对+目录+git 仓库) | P0 |
| FR-B1.3 | 纯函数 `parseUnifiedDiff(patch)` / `parseNumstat(out)``DiffFile[]`(可单测、零 DOM | P0 |
| FR-B1.4 | 前端 diff 查看器:按文件分组、+/- 徽标、行级绿/红、文件侧栏跳转/折叠 | P0 |
| FR-B1.5 | working/staged 切换;**用 `git status --porcelain`/numstat 标注 untracked**(避免 `--no-index` 的反直觉双操作数与预期非零退出L3 | P1 |
| FR-B1.6 | 入口:项目详情页 + 每会话工具栏各加 Diff/Review 按钮(会话用其 spawn cwd 作 `path` | P1 |
| FR-B1.7 | unified↔split 切换;窄屏默认 unified+软换行;空 diff 友好态 | P1 |
| FR-B1.8 | 大 diff 防爆:服务端按文件数/总字节封顶,超限返回 `truncated` 标记 | P2 |
| FR-B1.9 | 已提交对比:`?base=<rev>``git diff <base>`base 走 `git rev-parse --verify` 白名单,见安全) | P2 |
### B1.3 设计与数据流
```
详情/会话工具栏 ── Diff ──► GET /projects/diff?path=<abs>&staged=0
▼ src/http/diff.ts
├ 入口校验 (绝对 + isDirectory + hasGitEntry) ← 复用 projects.ts:389-399, 112-119
├ execFileAsync('git', ['diff','--no-color', staged?'--staged':null, '--']) ← 无 shell, timeout, maxBuffer
├ execFileAsync('git', ['diff','--numstat', ...]) +/- 行数)
├ execFileAsync('git', ['status','--porcelain']) (标注 untrackedL3
└ parseUnifiedDiff + parseNumstat → DiffResult { files: DiffFile[], staged, truncated }
▼ JSON → public/diff.tsrenderDiff纯 DOM/textContent无 innerHTML
```
- **新类型(`src/types.ts`**`DiffLine{kind,text}``DiffFile{oldPath,newPath,status,added,removed,binary,hunks}``DiffResult{files,staged,truncated}`
- **新文件**`src/http/diff.ts``public/diff.ts`
### B1.4 安全考量
- **只读**:仅 `git diff`(无写参数),不挂 Origin 守卫(同 `/projects/detail` 口径)。
- **无 shell 注入**`execFile` 数组参数;`--` 终结选项防 `path`/`base` 被当 flag。
- **path 越界**:绝对 + isDirectory + hasGitEntry 三连;`base`FR-B1.9)必须 `git rev-parse --verify` 白名单化,否则保持在 P0 之外。
- **XSS关键**diff 是任意文件内容——**全程 `textContent`/`el()`,零 `innerHTML`**CSP 兜底。
- **DoS**timeout + maxBuffer + 文件/字节封顶FR-B1.8)。
### B1.5 配置
| Env | 默认 | 说明 |
|-----|------|------|
| `DIFF_TIMEOUT_MS` | `2000` | 单次 `git diff` 超时 |
| `DIFF_MAX_BYTES` | `2097152` (2MB) | patch 截断上限 |
| `DIFF_MAX_FILES` | `300` | 返回文件数上限,超出标 truncated |
### B1.6 暂不做:行级回评(仅记录)、编辑/暂存/丢弃/`git add -p`/commit破坏只读+byte-shuttle明确不做、语法着色不引第三方高亮库
### B1.7 验收标准
- **AC-B1.1**:有未提交改动的 repo 调端点返回正确 `DiffFile[]`+/- 与 `git diff --numstat` 一致;`staged=1` 返回已暂存集。
- **AC-B1.2**:非 git/不存在 path → 404超大 diff → `truncated:true` 且不 OOM。
- **AC-B1.3**:手机查看器逐文件跳转/折叠、working↔staged 切换可用,无横向滚到吐。
- **AC-B1.4SP7**:含 `<script>`/ANSI/反引号的内容原样作文本显示,不执行、不破坏布局。
- **AC-B1.5**`parseUnifiedDiff`/`parseNumstat` 纯函数单测 ≥80%(含重命名/新增/删除/二进制/空 diff/untracked
---
## B2. statusLine 遥测 → per-tab 成本/上下文/PR 仪表
> **⚠ 研究优先H5阻断式前提**B2 的字段假设(`context_window.used_percentage`、`cost.total_cost_usd`、`rate`(5h/7d)、`pr`**必须先对照真实 statusLine stdin schema 核实**,再让 §7 的 `T-types` 冻结 `StatusTelemetry`。研究未确认 → 该任务返回 `[!] BLOCKED`,不得猜测字段名。
### B2.1 用户故事
- **US-B2.1(核心)**tab 上一眼看到**上下文进度条**、**累计 $ 成本**、**模型 chip**、**未关闭 PR 徽标**,不用进会话翻。
- **US-B2.2**:缩略图墙/项目卡片也显示,挑"快烧爆上下文/快没额度"的优先处理。
- **US-B2.3**:看到 5h/7d 速率额度仪表,避免撞墙。
### B2.2 功能需求
| ID | 需求 | 优先级 |
|----|------|--------|
| FR-B2.1 | 随 `setup-hooks` 安装 statusLine 脚本:读 stdin statusLine JSON → POST 到 `$WEBTERM_STATUSLINE_URL`(带 `X-Webterm-Session`),并回一行精简文本给 Claude 状态行 | P0 |
| FR-B2.2 | `POST /hook/status`loopback-only复用 `isLoopback``parseStatusLine` 解析→存该 session 最新遥测→广播给挂着的客户端 | P0 |
| FR-B2.3 | 按 sessionId 存最新遥测(类比 `claudeStatus`**attach 时随状态一起补发**(晚加入设备立即看到) | P0 |
| FR-B2.4 | 新 `ServerMessage` 类型 `telemetry`**同步更新 `serialize`、客户端 `handle` switch、任何 exhaustiveness 检查**M3`TerminalSession` 暴露 getter + `onTelemetry` 回调 | P0 |
| FR-B2.5 | 每 tab 渲染:上下文进度条 + `$` 成本 + 模型 chip + PR 徽标(带 review_state 颜色),**经 §SP10 共享仪表组件** | P0 |
| FR-B2.6 | 缩略图/项目卡片/manage 页复用同组小仪表 | P1 |
| FR-B2.7 | 5h/7d rate-limit 双 meterlines +/、effort 作 tooltip | P1 |
| FR-B2.8 | 解析对缺字段宽容(缺哪个不渲染哪个),陈旧遥测(>TTL置灰 | P1 |
| FR-B2.9 | PR 徽标点击 = 复制/打开 PR URL同源 UI 内展示,外跳需用户显式点击) | P2 |
### B2.3 设计与数据流
```
Claude statusLine ──stdin JSON──► scripts/statusline.mjs
├ POST "$WEBTERM_STATUSLINE_URL" -H "X-Webterm-Session: $WEBTERM_SESSION" --data-binary @-
└ echo 一行精简状态给 Claude (cost · ctx% · model)
▼ (loopback) POST /hook/status (isLoopback 复用)
▼ src/http/statusline.ts parseStatusLine(sessionId, body) → StatusTelemetry | null (纯, never throw, 仿 hook.ts:24)
▼ manager.handleStatusLine(id, telemetry) → 存 session.telemetry + broadcast {type:'telemetry',...}
▼ public/terminal-session.ts case 'telemetry'(仿 status case :269-275→ onTelemetry
▼ public/tabs.ts per-tab 仪表(仿 refreshTab :468· 缩略图/卡片复用
```
- **spawn env 注入B2 唯一的 spawn 改动)**:在 `src/session/session.ts:94-98` 现有 `WEBTERM_SESSION`/`WEBTERM_HOOK_URL` 旁加 `WEBTERM_STATUSLINE_URL: http://127.0.0.1:${cfg.port}/hook/status`web-terminal 外该变量未设 → curl no-op。
- **新类型(`src/types.ts`**`StatusTelemetry{contextUsedPct?,costUsd?,linesAdded?,linesRemoved?,model?,effort?,pr?{number,url,reviewState?},rate?{fiveHourPct?,sevenDayPct?},at}``ServerMessage |= {type:'telemetry',telemetry}``Session.telemetry: StatusTelemetry|null``LiveSessionInfo`/`ProjectSessionRef` 可选带最新遥测。
- **新文件**`src/http/statusline.ts``scripts/statusline.mjs`
### B2.4 安全考量
- **loopback-only**`/hook/status` 复用 `isLoopback`——外部 LAN 设备无法伪造遥测。
- **未信任 JSON**`parseStatusLine``unknown` + 逐字段收窄,缺/脏字段安全降级never throw。
- **XSS**model/PR URL 等走 `textContent`PR URL 不自动 `window.open`,仅用户显式点跳。
- **体量**`express.json({limit:'64kb'})`(同 `/hook`),只存"最新一条",无累积。
### B2.5 配置
| Env | 默认 | 说明 |
|-----|------|------|
| `STATUSLINE_TTL_MS` | `30000` | 多久未更新视为陈旧(前端置灰) |
| spawn env`WEBTERM_STATUSLINE_URL` | 服务端按 `cfg.port` 注入 | 脚本 POST 目标,非用户配置 |
### B2.6 暂不做:遥测历史/趋势图、跨会话成本汇总(只存最新一条)、服务端解析 statusLine 的 ANSI/格式(仍 byte-shuttle只摄入结构化 JSON、把遥测做成 push 告警(属 A1 范畴)。
### B2.7 验收标准
- **AC-B2.1**`npm run setup-hooks``~/.claude/settings.json` 含 statusLine 段且幂等、`--remove` 干净移除。
- **AC-B2.2**:跑会话后 tab 出现上下文进度条 + $ 成本 + 模型 chip。
- **AC-B2.3M3****晚加入设备 attach 时立即收到当前遥测**(与既有 `status` 回放一起补发)。
- **AC-B2.4**:缺字段 JSON → 只渲染已有项不报错;陈旧(>TTL置灰。
- **AC-B2.5**:非本机 POST `/hook/status` → 403。
- **AC-B2.6**`parseStatusLine` 纯函数单测 ≥80%(满/缺/脏/空);`ServerMessage` 序列化/反序列化往返测试覆盖 `telemetry`
---
## B3. 从 UI 建 git worktree ⚠ 状态变更 (唯一写盘特性)
### B3.1 用户故事
- **US-B3.1(核心)**:项目详情页点 ** New worktree**,填新分支名、选 agent系统 `git worktree add -b <branch>` 建独立目录并在那里开会话——多 agent 真并行不互踩。
- **US-B3.2**:新 worktree 立刻出现在列表,新 tab 标注分支名。
- **US-B3.3(仅记录)**prune/promote/merge worktree——Band B 不做。
### B3.2 功能需求
| ID | 需求 | 优先级 |
|----|------|--------|
| FR-B3.1 | `POST /projects/worktree`**Origin/CSRF 守卫**`{path, branch, base?}`;执行 `git worktree add -b <branch> <computedDir> [<base>]` | P0 |
| FR-B3.2 | 严格校验:`path` 绝对+isDirectory+isGit`branch` 过 git ref 规则子集(拒控制字符/`..`/前导 `-`/空白/`~^:?*[`/`@{`computedDir 必须落在受控 base 内 | P0 |
| FR-B3.3 | 安全 exec`execFile('git', [...])` 无 shell + timeout + 数量上限;失败返回结构化 error不泄完整 stderr | P0 |
| FR-B3.4 | 新目录 = `<WORKTREE_ROOT 或 <repo>-worktrees>/<sanitized-branch>`(可配 base | P0 |
| FR-B3.5 | 成功返回新 `path`+`branch`;前端调既有 `openProject(newPath, label, cmd)` 在该目录开会话 | P0 |
| FR-B3.6 | 详情页表单:分支名 + base 选择 + agent 选择claude/codex建好刷新 worktree 列表 | P1 |
| FR-B3.7 | 防重:分支/目录已存在 → 明确报错并提示进入已有 worktree | P1 |
| FR-B3.8 | 移动端友好表单(大点击区、键栏不挡) | P1 |
| FR-B3.9 | 从现有分支建(不带 `-b` | P2 |
### B3.3 设计与数据流
```
详情页 ── New worktree (branch, base?, agent) ──► POST /projects/worktree {path, branch, base?}
▼ requireAllowedOrigin (src/server.ts:215) + express.json({limit:'4kb'}) ← 仿 /open-in-editor :247
▼ src/http/worktrees.ts createWorktree(repoPath, branch, opts)
├ validateBranchName(branch) (纯, 单测)
├ computeWorktreeDir(repoPath, branch, cfg.worktreeRoot) → 受控、防越界 (见安全 M2)
├ 入口校验 (repoPath 绝对+isDirectory+hasGitEntry)
└ execFileAsync('git', ['worktree','add','-b',branch, dir, base?]) ← 无 shell, timeout
▼ { ok:true, path:dir, branch } | { ok:false, status, error }
▼ public/projects.ts 表单提交 → 成功则 hooks.onOpenProject(dir, `${repo}:${branch}`, cmd)
▼ TabApp.openProject(dir, label, cmd)(终端侧零改动)
```
- **新类型/函数**`CreateWorktreeResult{ok,path?,branch?,status?,error?}``src/types.ts``validateBranchName`/`computeWorktreeDir`(纯,单测)/`createWorktree`(扩展既有 `src/http/worktrees.ts`,与 `listWorktrees` 同文件)。
### B3.4 安全考量(**本特性最重**
- **CSRF/Origin**:写端点必须 `requireAllowedOrigin`(否则恶意页可无预检 POST 建分支/写盘)——同 `DELETE /live-sessions``/open-in-editor`
- **命令注入**`execFile` 数组参数,绝不 shell 拼接git 选项前置、用户值在 `--` 后。
- **分支名注入/越界**`validateBranchName``..`/越界 `/`/控制字符/前导 `-`(防当 flag/空白/`~^:?*[`/`@{`,遵 git ref 规则子集。
- **路径越界关键M2**`computeWorktreeDir``path.resolve` 后**对两端先 `fs.realpath` 再做前缀 `startsWith` 包含校验**(防 symlink 化的 `WORKTREE_ROOT` 或 repo 绕过字符串前缀sanitize 分支为目录段(`/``-`),杜绝写到任意位置。`base`FR-B3.9)若接受须 `git rev-parse --verify`
- **DoS/资源**timeout + 单次只建一个 + 数量上限error 文案不回传完整 git stderr。
- **审计**`sanitizeForLog``src/server.ts:80-86`)记录 who/whatbranch/path 截断、去控制字符)。
### B3.5 配置
| Env | 默认 | 说明 |
|-----|------|------|
| `WORKTREE_ENABLED` | `1` | 总开关(保守部署可关此写能力) |
| `WORKTREE_ROOT` | `<repo>-worktrees`(同级) | 新 worktree 落地根;强制所有 worktree 落此前缀内 |
| `WORKTREE_TIMEOUT_MS` | `10000` | `git worktree add` 超时 |
### B3.6 暂不做prune/promote/merge/remove worktree仅记录、worktree 看板、远程分支跟踪/`--track`/PR 自动开。
### B3.7 验收标准
- **AC-B3.1**:填新分支名 → 在 `WORKTREE_ROOT` 下建出目录,`git worktree list` 可见,新 tab 在该目录跑起 agent。
- **AC-B3.2**:缺/伪造 Origin → 403非 git path → 4xx非法分支名`../x``-rf`、控制字符)→ 拒绝且不执行 git。
- **AC-B3.3**:分支/目录已存在 → 明确错误,无半成品残留。
- **AC-B3.4M2**symlink 化的 `WORKTREE_ROOT`/repo 不能绕过包含校验realpath 后仍被拒)。
- **AC-B3.5**:两个 worktree 各开一个 agent 互不踩工作树(手动并行正确性)。
- **AC-B3.6**`validateBranchName`/`computeWorktreeDir` 纯函数单测 ≥80%(含越界/注入/symlink+ 端点集成测试(真起 server、临时 repo
---
## B4. plan-mode / 权限模式中继 (Permission-mode Relay)
> **⚠ 研究优先H5阻断式前提**`--permission-mode` 取值(`default`/`acceptEdits`/`plan`/`bypassPermissions`、ExitPlanMode 是否经 `/hook/permission` 到达、以及 `permDecision` 的 decision JSON 能否携带"更新后的权限模式"**必须先对照 Claude Code 官方文档/源码确认**——当前 `permDecision``src/server.ts:61-63`)只发 `{hookSpecificOutput:{hookEventName:'PermissionRequest',decision:{behavior}}}`**无证据**能携带 mode。研究未确认 → 任务返回 `[!] BLOCKED`,不得猜测形状。"auto" 暂映射 `bypassPermissions`PRD 标注待核。
### B4.1 用户故事
- **US-B4.1(核心)**开会话时选权限模式default/acceptEdits/plan/auto`--permission-mode` 传给 CLI。
- **US-B4.2**plan 模式出计划要退出 planExitPlanMode手机弹三选一门**approve+auto接受编辑直接干/ approve+review逐项确认/ keep planning继续规划**,复用既有 held-permission 通道。
### B4.2 功能需求
| ID | 需求 | 优先级 |
|----|------|--------|
| FR-B4.1 | 启动会话可选 permission mode映射 CLI `--permission-mode <mode>` | P0 |
| FR-B4.2 | `openProject`/launcher 的 cmd 从 `'claude\r'` 升级为 `claude --permission-mode <mode>\r`(仅当选了非默认) | P0 |
| FR-B4.3 | plan-gate 识别:`/hook/permission` 收到 `tool_name==='ExitPlanMode'`(待研究确认)时标记 **plan gate**pending 带类型标志 | P0 |
| FR-B4.4 | 协议扩展:`ServerMessage.status``gate?:'tool'\|'plan'``ClientMessage` approve 携带决策approve+auto / approve+review / keep planning | P0 |
| FR-B4.5 | 审批栏(`updateApprovalBar`plan gate 时渲染**三按钮**;普通 tool gate 仍两按钮 | P0 |
| FR-B4.6 | 决策→Claude`permDecision` 扩展为写回带目标 permission mode 的 decision JSON**形状待研究确认**plan 批准后切 acceptEdits/defaultkeep planning=deny/留 plan | P0 |
| FR-B4.7 | 模式选择持久化localStorage后台 tab 命中 plan gate 触发**既有通知(依赖 A1M5** | P1 |
| FR-B4.8 | "auto"(绕过权限)需 UI 二次确认 + 视觉警示(高危),可 env 全局禁用 | P1 |
| FR-B4.9 | statusLineB2若上报 permission mode则 tab chip 显示当前模式 | P2 |
### B4.3 设计与数据流
```
启动 (launcher/卡片) ── 选 mode ──► cmd = `claude --permission-mode <mode>\r`(复用 openProject 链路)
── plan 运行 ──►
Claude 出计划 → ExitPlanMode → PermissionRequest hook (held)
▼ POST /hook/permission (isLoopback, :277)
├ tool_name==='ExitPlanMode'(待核)→ gate='plan'park 进 pendingApprovals (:299)
└ manager.handleHookEvent(id,'waiting',tool,pending=true, gate='plan') 广播
▼ public/terminal-session.ts status case 记 gate仿 :269-275
▼ public/tabs.ts updateApprovalBar (:193) — plan 时 3 按钮:
├ approve+auto → ws {type:'approve', mode:'acceptEdits'} → permDecision(allow, mode)
├ approve+review → ws {type:'approve', mode:'default'} → permDecision(allow, mode)
└ keep planning → ws {type:'reject'} → permDecision(deny)(留 plan
▼ resolvePending 写回 decision JSON 给 curl→Claude (:116-122, :61-63)
```
- **新类型/协议(`src/types.ts`**`type PermissionMode = 'default'|'acceptEdits'|'plan'|'auto'``ClientMessage` approve `|= {type:'approve', mode?:PermissionMode}``ServerMessage.status``gate?:'tool'|'plan'``permDecision(behavior, mode?)`**形状待核**)。
### B4.4 安全考量
- **loopback-only**plan gate 仍走 `/hook/permission``isLoopback`),决策只能由本机 Claude 取走。
- **多设备一致性**:保持"最后一个观看者离开才释放 hold"`src/server.ts:472-474`plan gate 同理。
- **"auto"/bypassPermissions 高危**等于无人值守全自动改盘——UI 二次确认 + 显著警示 + `ALLOW_AUTO_MODE` 全局可禁用。
- **未信任决策输入**ws 的 `mode``parseClientMessage` 边界白名单校验仅四枚举非法降级为最保守default 或 deny
- **超时回退不变**plan gate 超时回退到 Claude 自身交互提示(`cfg.permTimeoutMs`),不卡死。
### B4.5 配置
| Env | 默认 | 说明 |
|-----|------|------|
| `DEFAULT_PERMISSION_MODE` | `default` | 未选时启动会话用的模式 |
| `ALLOW_AUTO_MODE` | `0` | 是否允许 "auto"bypassPermissions默认禁高危全自动 |
| (复用)`PERM_TIMEOUT_MS` | `300000` | plan/tool gate hold 超时,沿用既有(`src/config.ts:32`)。**held-curl `--max-time` 必须 > 此值L5** |
### B4.6 暂不做:自定义 per-tool 细粒度 allow 规则集、会话运行中动态切模式(除 plan gate 结果切换外)、非 Claude agent 的权限模式hook/plan 目前 Claude 专属,标注未来中立化)。
### B4.7 验收标准
- **AC-B4.1**:选 plan 模式开会话 → 实际以 `claude --permission-mode plan` 启动。
- **AC-B4.2**plan 出计划触发门 → 手机审批栏出现**三**按钮approve+auto 后以 acceptEdits 继续keep planning 则留 plan。
- **AC-B4.3**:普通 tool gate 仍两按钮(无回归);多设备下关一台不取消他人门;超时回退到 Claude 自身提示。
- **AC-B4.4**`ALLOW_AUTO_MODE=0` 时 UI 不提供/禁用 auto非法 `mode` ws 帧被边界拒绝并安全降级。
- **AC-B4.5**`permDecision(mode)`、cmd 构造、`parseClientMessage``approve.mode` 的校验均有单测;`/hook/permission` plan-gate 路径有集成测试。
- **AC-B4.6H5**:研究 spike 已确认 `--permission-mode` 值集、ExitPlanMode 投递路径、decision-JSON 携带 mode 的形状(或记录无法携带、改走替代方案)后方可实现。
---
# 5. 合并安全章节 (Consolidated Security)
整体守 byte-shuttle/LAN 哲学(带外 JSON、git 子进程、前端-only A2/A3、无服务端 ANSI 解析)。逐风险归并:
| 风险类 | 涉及特性 | 缓解(落到代码) |
|--------|----------|------------------|
| **CSWSH/CSRF状态变更** | A1(`/push/subscribe`,`/hook/decision`)、B3(`/projects/worktree`) | 全挂 `requireAllowedOrigin``src/server.ts:215-222`)。**`/hook/decision` 是唯一远程非-loopback 的敏感写端点**,额外要能力 token |
| **loopback 伪造** | A1 hold 门、A4 hook 摄入、B2 `/hook/status`、B4 plan gate | 摄入端点全过 `isLoopback``:70-78`statusLine/hook/permission 进程恒在本机 |
| **能力 token最小授权非 app auth** | A1 `/hook/decision`、A5/B4 经 A1 | token **绑定到 session+当前 pending 决策、resolve/超时即失效**M1失配/陈旧 → 403限流 |
| **安全上下文不可用** | A1 Web Push | `!isSecureContext` 检测 + UI 文案;**Band A1 需 HTTPSTailscale Serve/TLS**;裸 LAN 回退到 ntfy/Pushover 桥C2 |
| **秘密泄露** | A1 VAPIDenv、不入日志、缺失降级、A1 ntfy token**spawn env 引用,不入 settings.json/argv**H3 | env-only + 启动校验 + 600 文件权限;订阅库绝不经 GET 暴露 |
| **命令注入** | B1 diff、B3 worktree | `execFile('git', [...])` 无 shell + `--` 终结选项;用户值作独立 argv |
| **路径越界** | B1、B3 | 绝对+isDirectory+hasGitEntry 三连B3 额外 **realpath 两端 + 前缀包含**M2+ 分支 sanitize 为目录段 |
| **分支名/ref 注入** | B3、B1(`base`) | `validateBranchName`git ref 规则子集);`base``git rev-parse --verify` 白名单 |
| **XSS未信任工具/文件内容)** | B1 diff、B2 遥测、A3 标签、A4 时间线 | 一律 `textContent`/`el()`,禁 `innerHTML` 渲染外部内容CSP `script-src 'self'` 兜底SP7 |
| **未信任 JSON** | A4 hook body、B2 statusLine、B4 mode | `unknown` + 逐字段收窄、never throwwhitelist 事件名、截断长度、sanitize 控制字符 |
| **DoS / 资源** | A1(`PUSH_MAX_SUBS`)、A4(`TIMELINE_MAX` 环)、A5(每回合一告警)、B1(timeout/maxBuffer/文件封顶)、B2(只存最新一条)、B3(timeout/数量上限) | 见各特性配置 |
| **告警风暴** | A5、A1 | tag 去重 + 每 stuck 回合至多一次A5-FR2 |
| **供应链** | A1 `web-push` | 新运行时依赖锁版本、纳入安全清单L1 |
| **隐私(音频外流)** | A2 | 文档明示 Chrome Web Speech 流向 Google服务端永不接触音频AC-A2.5L2 |
| **timeout 配置一致性** | A1/B4 hold | held-curl `--max-time``setup-hooks.mjs:36` 现为 350必须 > `cfg.permTimeoutMs`(现 300纳入 config 校验,避免裸 350 字面量L5 |
---
# 6. 合并配置 env 表 (Consolidated Config)
> 命名约定SP8 / L4**服务端 `cfg` 读 = 无前缀****spawn 注入 / hook 脚本读 = `WEBTERM_` 前缀**。全部经 `src/config.ts` 的 `parse*` 帮手解析秘密缺失优雅降级never 硬编码。
### 服务端 cfg env无前缀
| Env | 默认 | 特性 |
|-----|------|------|
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` | 未设→push 关 / —(秘密)/ `mailto:admin@localhost` | A1 |
| `PUSH_STORE_PATH` | `<homeDir>/.web-terminal/push-subs.json` | A1 |
| `PUSH_MAX_SUBS` | `50` | A1 |
| `NOTIFY_DONE` | `1` | A1 |
| `NOTIFY_DND` | `0` | A1 |
| `DECISION_TOKEN_TTL_MS` | =`PERM_TIMEOUT_MS` | A1 |
| `TIMELINE_MAX` | `200` | A4 |
| `TIMELINE_ENABLED` | `1` | A4 |
| `STUCK_TTL` | `600`(秒) | A5 |
| `STUCK_ALERT` | `1` | A5 |
| `DIFF_TIMEOUT_MS` | `2000` | B1 |
| `DIFF_MAX_BYTES` | `2097152` | B1 |
| `DIFF_MAX_FILES` | `300` | B1 |
| `STATUSLINE_TTL_MS` | `30000` | B2 |
| `WORKTREE_ENABLED` | `1` | B3 |
| `WORKTREE_ROOT` | `<repo>-worktrees` | B3 |
| `WORKTREE_TIMEOUT_MS` | `10000` | B3 |
| `DEFAULT_PERMISSION_MODE` | `default` | B4 |
| `ALLOW_AUTO_MODE` | `0` | B4 |
| (复用,校验 `--max-time` > 之)`PERM_TIMEOUT_MS` | `300000` | A1/B4 (L5) |
### spawn 注入 / hook 脚本 env`WEBTERM_` 前缀)
| Env | 来源 | 特性 |
|-----|------|------|
| `WEBTERM_STATUSLINE_URL` | spawn 按 `cfg.port` 注入(`session.ts` | B2 |
| `WEBTERM_NTFY_URL` / `WEBTERM_NTFY_TOPIC` / `WEBTERM_NTFY_TOKEN` | spawn/hook envtoken **不入 settings.json**H3 | A1 |
| `WEBTERM_PUSHOVER_TOKEN` / `WEBTERM_PUSHOVER_USER` | spawn/hook env | A1 |
---
# 7. 优先级与跨特性依赖排程 (Prioritization & Dependency Ordering)
> **C3 修正(最重要的排程约束)**:原 Band A、Band B 两张任务表都各自宣称独占 `src/types.ts`、`src/server.ts`、`src/session/manager.ts`、`public/tabs.ts`、`public/terminal-session.ts`、`scripts/setup-hooks.mjs`——违反 `Owns:` disjoint 不变式。**本节合并为一个跨 Band 波次计划,每个共享文件只有一个 owner 任务**;其余为 disjoint 新文件,可并行。沿用 v0.6 做法(`docs/FEATURE_PROJECT_MANAGER.md §7`)派给 `module-builder`,建议 `isolation: worktree`TDD ≥80%。
### 7.1 优先级与"为什么这个顺序"
1. **R0 研究 spike阻断H5**:核实 statusLine schemaB2`--permission-mode` 值集 + ExitPlanMode 投递 + decision-JSON 能否带 modeB4。**未确认则 B2/B4 的类型不得在 `T-types` 冻结,相关任务返回 `[!] BLOCKED`。**
2. **A1 通知服务先行M5**A5、B4-FR7 都消费 A1 的 `notifyService`/push 通道——provider 必须先落地,排程不得把消费者排在 provider 前。
3. **B1只读 diff**:纯复用、低风险、高价值,可与 A 段并行早做。
4. **A2/A3前端-only**:独立、低风险,任意时段可落。
5. **A4时间线**:依赖对 hook 摄入的扩展H2
6. **B3worktree**:唯一写盘、安全最重,但与通知链独立,可并行推进。
7. **B2遥测**:受 R0 阻断;类型确认后并入。
8. **B4plan 中继)**:受 R0 阻断 + 依赖 A1背景 tab plan gate 通知M5
### 7.2 共享文件 → 单一 ownerC3 核心)
| 共享文件 | 唯一 owner 任务 | 合并了哪些特性的改动 |
|----------|-----------------|----------------------|
| `src/types.ts` | **T-types** | 全部新共享类型A1 token/订阅、A4 `TimelineEvent`、B1 `DiffResult`、B2 `StatusTelemetry`+`ServerMessage`、B3 `CreateWorktreeResult`、B4 `PermissionMode`+协议扩展、各 `Config`/`Session` 字段) |
| `src/config.ts` | **T-config** | 全部新 env 解析 + L5 的 `--max-time`>`permTimeoutMs` 校验 |
| `src/session/manager.ts` | **T-manager** | A4 时间线 append、B2 telemetry 存+广播、A5 `sweepStuck()`(H4)、B4 gate flag、manager↔notifyService 接线(M5) |
| `src/session/session.ts` | **T-spawn-env** | B2 `WEBTERM_STATUSLINE_URL` + A1 `WEBTERM_NTFY_*` 注入 |
| `src/http/hook.ts` + `/hook` 路由 | **T-hook-intake** | A4 扩展摄入(保留时间戳/`tool_input`H2、B4 gate 识别协调 |
| `src/server.ts` | **T-server-wire** | A1 `/push/*`+`/hook/decision`+改 hold 门(C1)、A4 `/…/events`、A5 reaper 加 sweep 回调、B1 `/projects/diff`、B2 `/hook/status`、B3 `/projects/worktree`、B4 扩 `/hook/permission` gate + approve mode |
| `scripts/setup-hooks.mjs` | **T-hooks-installer** | A4 补 `PostToolUse`(H1)、A1 ntfy/Pushover 桥(env tokenH3)、B2 statusLine 段、L5 `--max-time` 取自/校验于 config |
| `public/tabs.ts` | **T-tabs** | B2 仪表、A5 stuck 徽标、B4 三按钮审批+mode 选择+cmd、A2/A3/A4 面板挂载点、**§SP10 共享状态/仪表组件 + 单一 `refreshTab` owner(M4)** |
| `public/terminal-session.ts` | **T-termsession** | B2 telemetry 捕获+`onTelemetry`、B4 gate 捕获 |
| `public/projects.ts` | **T-projects-ui** | B1 diff 入口、B3 New-worktree 表单 |
| `public/sw.js` | **T-sw** | A1 `push`/`notificationclick`(保留不拦 `/term`/`/hook` |
### 7.3 Disjoint 新文件任务(可并行)
| 任务 | Owns新文件 | 特性 |
|------|----------------|------|
| **N-push** | `src/push/push-service.ts` + `src/push/subscription-store.ts` (+ test) | A1 |
| **N-timeline** | `src/session/timeline.ts`(有界环/标签/sanitize(+ test) | A4 |
| **N-diff-be** | `src/http/diff.ts` (+ test) | B1 |
| **N-statusline-be** | `src/http/statusline.ts` (+ test) | B2 |
| **N-worktree** | `src/http/worktrees.ts`(加 `createWorktree`/`validateBranchName`/`computeWorktreeDir`,与 `listWorktrees` 同文件)(+ test) | B3 |
| **N-statusline-script** | `scripts/statusline.mjs` | B2 |
| **N-push-ui** | `public/push.ts`(订阅流 + 🔔 + 决策 fetch | A1 |
| **N-voice** | `public/voice.ts` | A2 |
| **N-quickreply** | `public/quick-reply.ts` | A3 |
| **N-timeline-ui** | `public/timeline.ts` | A4 |
| **N-diff-ui** | `public/diff.ts` | B1 |
### 7.4 波次 (Waves)
- **W-1阻断研究**`R0`B2/B4 schema 确认)。**先于 T-types 对 B2/B4 字段的冻结。**
- **W0协调点**`T-types` → 然后 `T-config``T-spawn-env`
- **W1并行新文件 + 纯核心)**`N-push``N-timeline``N-diff-be``N-statusline-be`(待 R0`N-worktree``N-statusline-script``T-hook-intake`、以及前端 `N-voice``N-quickreply``N-diff-ui``N-timeline-ui``N-push-ui``T-sw`
- **W2后端汇聚单 owner串行避免 clobber**`T-manager`(依赖 N-push/N-timeline/统计核心)→ `T-server-wire`(依赖全部 be 任务 + T-manager`T-hooks-installer`
- **W3前端汇聚单 owner**`T-termsession``T-tabs`(含 SP10 共享组件)、`T-projects-ui`
- **W4验证报告-only**:集成/E2E真起 server、临时 repo、HTTPS 上下文模拟 push 注册),按 §8 汇总验收;缺陷回路给对应 owner不跨 lane 改文件)。
### 7.5 显式依赖边
- `A5 → A1`notifyService`B4-FR7 → A1`(背景通知);`B2/B4 → R0`schema`T-server-wire → {N-push, N-diff-be, N-statusline-be, N-worktree, T-hook-intake, T-manager}``T-tabs/T-termsession/T-projects-ui → {T-server-wire + 各 N-*-ui}``T-config/T-manager/...→ T-types`
---
# 8. 验收标准汇总 (Acceptance-Criteria Summary)
| 特性 | 关键验收(含本次评审修正点) |
|------|------------------------------|
| **A1** | 零 tab 走开时锁屏 Allow/Deny 即解决 held 请求(C1, AC-A1.2);不安全上下文隐藏 🔔 并提示需 HTTPS(C2, AC-A1.5)VAPID 未设 503/隐藏不崩ntfy token 不入 settings.json/argv(H3, AC-A1.7);陈旧/外来 Origin/坏 token → 403(M1, AC-A1.8)DONE 低优通知 |
| **A2** | 按住 🎤 说话→打进终端;软键盘不弹;无 API 隐藏不崩;无音频抵达服务端(L2) |
| **A3** | 内置 chips + 可持久调色板,同键栏路径发送;标签惰性文本(无注入) |
| **A4** | 离散人话时间线;`/…/events``TIMELINE_MAX` 界;安装器含 `PostToolUse`(H1)hook body sanitize 后显示(SP7)`TIMELINE_ENABLED=0` 空 |
| **A5** | 静默达 `STUCK_TTL` 经 A1 恰发一次;恢复后重新武装;扫描覆盖 attached 会话且无新 timer(H4)0 禁用 |
| **B1** | 结构化 diff + numstat 一致working/staged非 git→404超大→truncated 不 OOM`<script>` 作文本(SP7)纯解析单测≥80%untracked 经 porcelain 标注(L3) |
| **B2** | tab 仪表;**晚加入设备 attach 立即收到遥测**(M3, AC-B2.3);缺字段宽容/陈旧置灰非本机→403`telemetry` 序列化往返测试R0 已确认字段(H5) |
| **B3** | 在 `WORKTREE_ROOT` 建出并开 agent缺/伪造 Origin→403非法分支名拒绝不执行**realpath 后仍拒 symlink 绕过**(M2);并行不互踩;纯函数+集成测试 |
| **B4** | plan 模式启动plan 门三按钮且 approve+auto 切 acceptEditstool gate 仍两按钮无回归;多设备一致;`ALLOW_AUTO_MODE=0` 禁 auto非法 mode 边界拒绝R0 已确认 schema(H5) |
| **跨特性** | 共享文件单 owner 无 clobber(C3)env 命名约定(L4)held-curl `--max-time`>`permTimeoutMs` 经 config 校验(L5);共享状态/仪表组件无 DOM 争用(M4) |
全局门槛所有纯核心diff/statusLine/timeline 解析、branch/dir 校验、push payload、label 派生)单测 ≥80%;端点经集成测试(真起 serverA1 push 在模拟 TLS/安全上下文下注册成功。
---
# 9. 一句话总结 (One-line Summary)
> v0.7 **走开即工作台**A 段靠"hook 触发 → 出站推送 → 锁屏决策回 held 通道"闭合走开循环核心修正hold 门要在零客户端但有推送订阅时也 hold且 push 需 HTTPSB 段靠"只读 git-execdiff+ 受控写 git-execworktree+ loopback JSON 摄入statusLine+ held-permission 新 gateplan"在终端之上长出工作台。**服务端仍不解析一个终端字节**9 个特性合并为一个单-owner-per-shared-file 的波次计划B2/B4 受一个阻断式 schema 研究 spike 把关。