# 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.3–v0.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-only（Tailscale 推荐）、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:`（M6），CSP 仍 `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`。
> 加上 **STUCK（A5 新增，非 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 curl，priority 映射（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-HTTP（CLAUDE.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 签名 → 每个存储的 PushSubscription（404/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 token（H3）**：以 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.2（C1 核心）**：**在零浏览器 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.5（C2）**：在不安全上下文（http LAN）下 UI 隐藏 🔔 并提示需 HTTPS/Tailscale，不崩；HTTPS 下订阅成功。
- **AC-A1.6**：VAPID env 未设 → push 端点 503、UI 隐藏开关、无崩。
- **AC-A1.7（H3）**：`WEBTERM_NTFY_*` 设置后 `setup-hooks.mjs` 写出会 ping ntfy 的命令且**正确 priority**；**settings.json 与进程 argv 中不含任何 token 字面量**；web-terminal 外该 curl no-op。
- **AC-A1.8（M1）**：`/hook/decision` 拒绝缺/外来 Origin（403）与坏/陈旧 token（403）；resolve 后旧 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 暂不做：服务端 STT（Whisper 等）、语音命令（如"approve"）、唤醒词/常听、录音存储。

### A2.7 验收标准
- **AC-A2.1**：支持的移动浏览器上按住 🎤 说话显示 interim，松手把最终转写打进当前终端。
- **AC-A2.2**：使用麦克风时软键盘不弹（touch `preventDefault`）。
- **AC-A2.3**：无 `SpeechRecognition` 的浏览器隐藏 🎤 且不报错。
- **AC-A2.4**：自动发送开 → 以 `\r` 结尾；关 → 等手动 ⏎。
- **AC-A2.5（L2）**：抓包/服务端日志确认无任何音频抵达服务端。

---

## 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 | 用户调色板：增/改/删命名 snippet，localStorage 持久；每条含文本 + 是否补 `\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-only，TECH_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.4（SP7）**：hook body 的工具名/路径经 sanitize（无控制字符）并截断后才显示。
- **AC-A4.5（H1）**：`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 | 首次越阈经 A1（push/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.reapIntervalMs（src/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 节拍（不新 timer，KISS）。
- **新改动（重新界定 Owns）**：`manager.ts` 加 `sweepStuck()` + manager↔notifyService 接线；`Session` 加 `stuckNotified` flag。**依赖 A1 先落地 notifyService（M5）**。

### 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.3（H4）**：扫描不加新 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'])     （标注 untracked，L3）
   └ parseUnifiedDiff + parseNumstat → DiffResult { files: DiffFile[], staged, truncated }
   ▼ JSON → public/diff.ts（renderDiff，纯 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.4（SP7）**：含 `<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 双 meter；lines +/−、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.3（M3）**：**晚加入设备 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/what（branch/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.4（M2）**：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 模式出计划要退出 plan（ExitPlanMode）时，手机弹三选一门：**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/default；keep planning=deny/留 plan） | P0 |
| FR-B4.7 | 模式选择持久化（localStorage）；后台 tab 命中 plan gate 触发**既有通知（依赖 A1，M5）** | P1 |
| FR-B4.8 | "auto"（绕过权限）需 UI 二次确认 + 视觉警示（高危），可 env 全局禁用 | P1 |
| FR-B4.9 | statusLine（B2）若上报 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.6（H5）**：研究 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 需 HTTPS（Tailscale Serve/TLS）**；裸 LAN 回退到 ntfy/Pushover 桥（C2） |
| **秘密泄露** | A1 VAPID（env、不入日志、缺失降级）、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 throw；whitelist 事件名、截断长度、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.5，L2） |
| **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 env（token **不入 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 schema（B2）、`--permission-mode` 值集 + ExitPlanMode 投递 + decision-JSON 能否带 mode（B4）。**未确认则 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. **B3（worktree）**：唯一写盘、安全最重，但与通知链独立，可并行推进。
7. **B2（遥测）**：受 R0 阻断；类型确认后并入。
8. **B4（plan 中继）**：受 R0 阻断 + 依赖 A1（背景 tab plan gate 通知，M5）。

### 7.2 共享文件 → 单一 owner（C3 核心）
| 共享文件 | 唯一 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 token，H3)、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 切 acceptEdits；tool 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%；端点经集成测试（真起 server）；A1 push 在模拟 TLS/安全上下文下注册成功。

---

# 9. 一句话总结 (One-line Summary)

> v0.7 **走开即工作台**：A 段靠"hook 触发 → 出站推送 → 锁屏决策回 held 通道"闭合走开循环（核心修正：hold 门要在零客户端但有推送订阅时也 hold，且 push 需 HTTPS）；B 段靠"只读 git-exec（diff）+ 受控写 git-exec（worktree）+ loopback JSON 摄入（statusLine）+ held-permission 新 gate（plan）"在终端之上长出工作台。**服务端仍不解析一个终端字节**；9 个特性合并为一个单-owner-per-shared-file 的波次计划，B2/B4 受一个阻断式 schema 研究 spike 把关。
