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.
This commit is contained in:
726
docs/FEATURE_WALKAWAY_WORKBENCH.md
Normal file
726
docs/FEATURE_WALKAWAY_WORKBENCH.md
Normal file
@@ -0,0 +1,726 @@
|
||||
# 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 把关。
|
||||
536
docs/PLAN_WALKAWAY_WORKBENCH.md
Normal file
536
docs/PLAN_WALKAWAY_WORKBENCH.md
Normal file
@@ -0,0 +1,536 @@
|
||||
# v0.7 Walk-away Workbench — Implementation Plan (Phased)
|
||||
|
||||
> 版本: v0.7 · 来源真相: [`FEATURE_WALKAWAY_WORKBENCH.md`](./FEATURE_WALKAWAY_WORKBENCH.md) (PRD is source of truth for scope).
|
||||
> 本文把 PRD 的 9 个特性 (A1–A5, B1–B4) 拆成**细粒度、单-owner-per-shared-file、可多 agent 并行**的任务,
|
||||
> 合并了 FE / BE / Security 三份草案并**已应用评审修正 (#1–#16)**。
|
||||
> 约定遵循 [`PLAN.md`](./PLAN.md): 稳定任务 ID + `Owns:` disjoint 文件 + `Depends:` + 建议模型/隔离 + 依赖波次。
|
||||
> 进度记录在 [`PROGRESS_LOG.md`](./PROGRESS_LOG.md) (orchestrator 独写, subagent 只返回条目, G1)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 多 Agent 并行规则 (开工前必读)
|
||||
|
||||
三条铁律 (同 PLAN.md §0, 此处不重复, 以 CLAUDE.md / PLAN.md 为准):
|
||||
|
||||
1. **文件所有权独占** — 每任务 `Owns:` 列出独占写的文件; **只有该任务可改这些文件**。所有新共享类型回 `src/types.ts` (T-types 独占冻结),模块内不本地重声明 (SP9)。
|
||||
2. **只依赖接口不依赖实现** — 任务间经 `src/types.ts` 的 interface / 函数签名解耦; 接口在 W0 就绪后即可与实现方并行编码, 仅在集成/测试时汇合。
|
||||
3. **LOG 由 orchestrator 独写** — subagent **不碰** `PROGRESS_LOG.md`; 把"条目模板"日志作为最终返回结果交回。遇文档未定义的歧义 → 停下返回 `[!] BLOCKED`, **绝不猜** (尤其 B2/B4 的 R0 字段)。
|
||||
|
||||
执行 agent: `module-builder` (TDD 实现单任务)、`module-reviewer` (只读复查/验收)。每任务**测试与实现同属一个 agent**, 不要拆分。
|
||||
|
||||
---
|
||||
|
||||
## 1. 依赖关系与并行波次 (Waves)
|
||||
|
||||
```
|
||||
W-1 研究 spike (阻断 B2/B4 字段冻结)
|
||||
R0 statusLine schema + --permission-mode 值集 + ExitPlanMode 投递 + decision-JSON 能否带 mode
|
||||
│
|
||||
▼
|
||||
W0 协调点 (串行, 阻塞下游)
|
||||
T-types (冻结所有新共享类型 + 5 个契约决议见 §3) → T-config · T-spawn-env (可并行起草, 类型就绪即编码)
|
||||
│
|
||||
▼
|
||||
W1 叶子模块 (全部并行, 文件 disjoint —— 并行度最高)
|
||||
后端新文件: N-push · N-timeline · N-diff-be · N-statusline-be(待R0) · N-worktree · N-statusline-script(待R0) · T-hook-intake
|
||||
前端新文件: N-push-ui · N-voice(+keybar.ts) · N-quickreply · N-timeline-ui · N-diff-ui · T-sw(+sw-push.js) · T-preview-grid
|
||||
│
|
||||
▼
|
||||
W2 后端汇聚 (单 owner, T-manager → T-server-wire 串行; T-hooks-installer 并行)
|
||||
T-manager (依赖 N-push/N-timeline) → T-server-wire (依赖全部 BE 任务 + T-manager) · T-hooks-installer
|
||||
│
|
||||
▼
|
||||
W3 前端汇聚 (单 owner, T-termsession → T-tabs; T-projects-ui 并行)
|
||||
T-termsession (B2/B4 捕获) → T-tabs (依赖全部 N-*-ui + T-preview-grid + T-termsession) · T-projects-ui
|
||||
│
|
||||
▼
|
||||
W4 验证 (报告-only, 不跨 lane 改文件)
|
||||
V-integration (真起 server + 临时 repo) · V-security (守卫/token/realpath 抽检) · V-fe-pwa (HTTPS push / 跨浏览器)
|
||||
```
|
||||
|
||||
**关键并行事实**: W1 的 13 个任务零文件交叉, 全程可并行 (受 ~3–5 agent/批的甜区约束, 见 §6)。前端 N-*-ui 与后端 N-* 在 W1 全程并行; 仅在 W3 T-tabs/T-projects-ui 汇合时才需要 BE 端点就绪。
|
||||
|
||||
**显式依赖边** (PRD §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-server-wire 契约, T-termsession, T-preview-grid, 全部 N-*-ui}`; `所有 → T-types`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 协调点 (Shared Files — 触碰顺序)
|
||||
|
||||
每个共享文件**只有一个 owner 任务** (C3 修正)。下表是触碰顺序; 任何其它任务**只读 import**, 不得编辑。
|
||||
|
||||
| 共享文件 | 唯一 owner | 波次 | 合并的特性改动 |
|
||||
|----------|-----------|------|----------------|
|
||||
| `src/types.ts` | **T-types** | W0 | 全部新共享类型 + §3 的 5 个契约决议。**必须最先冻结**, 否则下游编译失败 |
|
||||
| `src/config.ts` | **T-config** | W0 | 21 个新 env 解析 + L5 `--max-time`>`permTimeoutMs` 校验 |
|
||||
| `src/session/session.ts` | **T-spawn-env** | W0 | B2 `WEBTERM_STATUSLINE_URL` + A1 `WEBTERM_NTFY_*` 注入; session 字段初始化; A5 stuck 重新武装 (见 §3.5 协调) |
|
||||
| `src/http/hook.ts` | **T-hook-intake** | W1 | A4 扩展摄入 (`HookEventFull`: 保留 `at`/`tool_input`/`eventClass`); B4 `gate='plan'` 识别 |
|
||||
| `src/session/manager.ts` | **T-manager** | W2 | A4 timeline append · B2 telemetry 存+广播+晚加入补发(M3) · A5 `sweepStuck()`(H4) · B4 gate · manager↔notifyService 注入(M5) |
|
||||
| `src/server.ts` | **T-server-wire** | W2 | A1 `/push/*`+`/hook/decision`+改 hold 门(C1)+能力token · A4 `/…/events` · A5 reaper sweep · B1 `/projects/diff` · B2 `/hook/status` · B3 `/projects/worktree` · B4 扩 `/hook/permission` gate + approve mode + auto 拒绝 · `GET /config/ui`(#4) |
|
||||
| `scripts/setup-hooks.mjs` | **T-hooks-installer** | W2 | A4 补 `PostToolUse`(H1) · A1 ntfy/Pushover 桥(env token, H3) · B2 statusLine 段(R0) · L5 `--max-time` 取自 config |
|
||||
| `public/preview-grid.ts` | **T-preview-grid** | W1 | (#6) `renderTelemetryGauge` · `renderStatusBadge` · `statusText('stuck')`; **冻结导出签名供 W1 importer 安全使用** |
|
||||
| `public/terminal-session.ts` | **T-termsession** | W3 | B2 telemetry 捕获+`onTelemetry` · B4 `pendingGate` 捕获 + `approve(mode?)` · `case 'telemetry'` + exhaustiveness |
|
||||
| `public/tabs.ts` | **T-tabs** | W3 | B2 仪表 · A5 stuck 徽标 · B4 三按钮审批+mode选择+cmd · A1/A2/A3/A4 面板挂载 · **SP10 单一 `refreshTab` owner**(M4) |
|
||||
| `public/projects.ts` | **T-projects-ui** | W3 | B1 diff 入口 · B3 New-worktree 表单 · A4 timeline 区 |
|
||||
| `public/keybar.ts` | **N-voice** (#7) | W1 | A2 `mountKeybar(onSend, opts?)` 加 `onVoiceTrigger?`; **仅 N-voice 编辑**, 其它任务只 consume `mountKeybar` |
|
||||
| `public/sw.js` + `public/sw-push.js` | **T-sw** | W1 | A1 `push`/`notificationclick` wiring (sw.js) + 纯可测 helpers (sw-push.js, #11) |
|
||||
|
||||
> **不在任何 Owns: 的共享文件**: `docs/PROGRESS_LOG.md` (orchestrator 独写)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 契约决议 (Contract Resolutions) — T-types 必须先冻结
|
||||
|
||||
评审发现 FE↔BE 草案有 5 处类型契约冲突 (会编译失败或运行期静默错)。**这些必须在 W1 派发前于 T-types 一次性定死**, 否则前后端各写各的:
|
||||
|
||||
### 3.1 `TimelineEvent` — 用**派生语义 class** (评审 #1)
|
||||
BE 草案用 `eventClass`(原始 hook 名), FE 用 `class`(语义集) —— 字段名与语义都冲突。**决议**: 服务端 `src/session/timeline.ts` 的 `deriveClass()` 把原始 hook 名映射为语义类, FE 直接消费, 不自造。
|
||||
```ts
|
||||
export type TimelineClass = 'tool' | 'waiting' | 'done' | 'stuck' | 'user';
|
||||
export interface TimelineEvent {
|
||||
at: number; // 服务端 Date.now()
|
||||
class: TimelineClass; // 服务端派生的语义类 (FE icon/着色用)
|
||||
toolName?: string; // sanitize 后的 tool_name (≤200, 去控制字符)
|
||||
label: string; // 服务端派生人话 ("ran Bash", "edited 3 files")
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 `DiffLine.kind` — 一种拼写 + diff 解析只在后端 (评审 #2)
|
||||
BE 用 `'+'|'-'|...`, FE 测试用 `'added'|'removed'|...` —— 冲突。**决议**: 用语义词拼写; **解析全在 `src/http/diff.ts` (N-diff-be)**, `public/diff.ts` (N-diff-ui) **render-only**, 删除 FE 的 `parseUnifiedDiff`/`parseNumstat`/`mergeDiffStats` (DRY)。
|
||||
```ts
|
||||
export type DiffLineKind = 'added' | 'removed' | 'context' | 'hunk' | 'meta';
|
||||
export interface DiffLine { kind: DiffLineKind; text: string }
|
||||
export interface DiffHunk { header: string; lines: DiffLine[] }
|
||||
export type FileStatus = 'modified' | 'added' | 'deleted' | 'renamed' | 'binary' | 'untracked';
|
||||
export interface DiffFile { oldPath: string; newPath: string; status: FileStatus; added: number; removed: number; binary: boolean; hunks: DiffHunk[] }
|
||||
export interface DiffResult { files: DiffFile[]; staged: boolean; truncated: boolean }
|
||||
```
|
||||
|
||||
### 3.3 Push payload — 一种 shape, FE 读 `cls` (评审 #3)
|
||||
BE 发 `{cls,...}`, FE sw.js 读 `payload.class`/`payload.detail` —— 字段名不符且缺 `detail`。**决议** (PRD A1.4 契约):
|
||||
```ts
|
||||
// push-service 发 / sw-push.js 读 (同一 shape):
|
||||
interface PushPayload { sessionId: string; toolName?: string; detail?: string; token?: string; cls: NotifyClass }
|
||||
export type NotifyClass = 'needs-input' | 'done' | 'stuck';
|
||||
```
|
||||
|
||||
### 3.4 `ClaudeStatus` 加 `'stuck'` + 服务端广播 (评审 #5)
|
||||
A5 徽标 keys off `claudeStatus==='stuck'` (AC-A5.5), 但 BE 草案只 `notify('stuck')`, 既不置 status 也不广播。**决议**:
|
||||
- T-types: `ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown' | 'stuck'`。
|
||||
- T-manager `sweepStuck()`: 置 `session.claudeStatus='stuck'` **并** `broadcast({type:'status',status:'stuck'})` (不止 notify)。
|
||||
- **重新武装** (T-spawn-env, §3.5): 新输出到达时若 status 仍是 `'stuck'` → 复位 `'working'` 并广播, 否则徽标永久卡住。
|
||||
|
||||
### 3.5 协议 + Session/Config 字段 + 跨任务协调
|
||||
```ts
|
||||
// 协议 (T-types + T-server protocol via T-types):
|
||||
ClientMessage |= { type: 'approve'; mode?: PermissionMode } // B4
|
||||
ServerMessage |= { type: 'telemetry'; telemetry: StatusTelemetry } // B2
|
||||
// status 消息加 gate:
|
||||
{ type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean; gate?: 'tool' | 'plan' }
|
||||
|
||||
export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'auto'; // B4 (auto 待 R0 确认映射 bypassPermissions)
|
||||
export interface StatusTelemetry { // B2 — 字段 BLOCKED on R0, 见 §W-1
|
||||
contextUsedPct?: number; costUsd?: number; linesAdded?: number; linesRemoved?: number;
|
||||
model?: string; effort?: string; pr?: { number: number; url: string; reviewState?: string };
|
||||
rate?: { fiveHourPct?: number; sevenDayPct?: number }; at: number;
|
||||
}
|
||||
export interface PushSubscriptionRecord { endpoint: string; keys: { p256dh: string; auth: string }; createdAt: number }
|
||||
export interface NotifyService { isEnabled(): boolean; notify(session: Session, cls: NotifyClass, token?: string): Promise<void> }
|
||||
export interface CreateWorktreeResult { ok: boolean; path?: string; branch?: string; status?: number; error?: string }
|
||||
|
||||
// Session 加可变 runtime 字段 (同 buffer/lastOutputAt 的"不可变 meta 上挂可变 handle"例外):
|
||||
interface Session { /* ... */ timeline: readonly TimelineEvent[]; stuckNotified: boolean; telemetry: StatusTelemetry | null }
|
||||
|
||||
// SessionManager 加方法:
|
||||
handleStatusLine(id: string, telemetry: StatusTelemetry): void; // B2
|
||||
sweepStuck(now: number): void; // A5 (用注入的 NotifyService)
|
||||
handleHookEvent(sessionId, status, detail?, pending?, gate?, eventClass?, toolName?): void; // A4 扩展
|
||||
// createSessionManager(cfg, notifyService?) — DI, 避免 manager 直 import push-service (循环依赖)
|
||||
```
|
||||
**单一遥测真相源 (评审 #15)**: 遥测只存 `TerminalSession.telemetry` (getter); `TabEntry` **不**重复存; `refreshTab` 读 `entry.session.telemetry`。
|
||||
**`/hook/decision` 响应 (评审 #14)**: 统一 `204` (SW 不读 body); 契约表对齐 204。
|
||||
|
||||
---
|
||||
|
||||
## 4. 任务清单 (Tasks)
|
||||
|
||||
> 状态图例: `[ ]` TODO · `[~]` 进行中 · `[x]` 完成 · `[!]` 受阻。勾选与记录由 orchestrator 写入 PROGRESS_LOG.md。
|
||||
> 模型路由: 轻活 Haiku、攻坚 Opus、其余 Sonnet。`isolation: worktree` 仅用于真正并发编辑的 builder。
|
||||
|
||||
### 4.0 任务总表 (Summary)
|
||||
|
||||
| ID | 标题 | 特性 | Owns (独占写) | Depends | 模型 | 隔离 | 学科 |
|
||||
|----|------|------|---------------|---------|------|------|------|
|
||||
| **R0** | 研究 spike (statusLine/permission-mode schema) | B2,B4 | (无源码; 产出 `docs/R0_FINDINGS.md`) | — | sonnet | — | research |
|
||||
| **T-types** | 冻结新共享类型 + §3 契约 | 全部 | `src/types.ts` | R0(B2/B4 字段) | sonnet | — | BE |
|
||||
| **T-config** | 21 新 env 解析 + L5 校验 | 全部 | `src/config.ts`, `test/config.test.ts`(扩) | T-types | sonnet | worktree | BE |
|
||||
| **T-spawn-env** | spawn env 注入 + session 字段 + stuck 重新武装 | A1,A5,B2 | `src/session/session.ts`, `test/session.test.ts`(扩) | T-types | sonnet | — | BE |
|
||||
| **N-push** | push-service + subscription-store | A1 | `src/push/push-service.ts`, `src/push/subscription-store.ts`, `test/push/*.test.ts` | T-types | **opus** | worktree | BE/sec |
|
||||
| **N-timeline** | 有界事件环 + 标签/类派生 + sanitize | A4 | `src/session/timeline.ts`, `test/session/timeline.test.ts` | T-types | sonnet | worktree | BE |
|
||||
| **N-diff-be** | `parseUnifiedDiff`/`parseNumstat`/`getDiff` | B1 | `src/http/diff.ts`, `test/http/diff.test.ts` | T-types | **opus** | worktree | BE/sec |
|
||||
| **N-statusline-be** | `parseStatusLine` (tolerant, never throw) | B2 | `src/http/statusline.ts`, `test/http/statusline.test.ts` | T-types, **R0** | sonnet | worktree | BE |
|
||||
| **N-worktree** | `validateBranchName`/`computeWorktreeDir`/`createWorktree` | B3 | `src/http/worktrees.ts`(扩), `test/http/worktrees-create.test.ts` | T-types | **opus** | worktree | BE/sec |
|
||||
| **N-statusline-script** | `scripts/statusline.mjs` | B2 | `scripts/statusline.mjs` | **R0** | haiku | worktree | BE |
|
||||
| **T-hook-intake** | `parseHookEvent`→`HookEventFull` (at/tool_input/eventClass/gate) | A4,B4 | `src/http/hook.ts`, `test/hook.test.ts`(扩) | T-types | sonnet | worktree | BE |
|
||||
| **T-preview-grid** | 共享仪表/徽标组件 (SP10) | A4,A5,B2 | `public/preview-grid.ts`, `test/telemetry-gauge.test.ts` | T-types | sonnet | worktree | FE |
|
||||
| **N-push-ui** | 订阅流 + 🔔 toggle + insecure-context 检测 | A1 | `public/push.ts`, `test/push.test.ts` | T-types | sonnet | worktree | FE/sec |
|
||||
| **N-voice** | Web Speech 包装 + keybar 🎤 注入 | A2 | `public/voice.ts`, `public/keybar.ts`, `test/voice.test.ts` | T-types | sonnet | worktree | FE |
|
||||
| **N-quickreply** | 内置 chips + 调色板 CRUD | A3 | `public/quick-reply.ts`, `test/quick-reply.test.ts` | T-types | sonnet | worktree | FE |
|
||||
| **N-timeline-ui** | 时间线面板渲染 + 轮询 | A4 | `public/timeline.ts`, `test/timeline.test.ts` | T-types | sonnet | worktree | FE |
|
||||
| **N-diff-ui** | diff 查看器 (render-only) | B1 | `public/diff.ts`, `test/diff.test.ts` | T-types | sonnet | worktree | FE/sec |
|
||||
| **T-sw** | SW push/notificationclick + 纯 helpers | A1 | `public/sw.js`, `public/sw-push.js`, `test/sw-push.test.ts` | T-types | sonnet | worktree | FE/sec |
|
||||
| **T-manager** | timeline/telemetry/sweepStuck/notifyService DI | A4,A5,B2 | `src/session/manager.ts`, `test/manager.test.ts`(扩) | T-types, N-push, N-timeline | **opus** | — | BE |
|
||||
| **T-server-wire** | 全部新路由 + C1 hold 门 + token + 限流 + auto 拒绝 | 全部 | `src/server.ts`, `test/integration/*.test.ts` | N-push,N-diff-be,N-statusline-be,N-worktree,T-hook-intake,T-manager | **opus** | — | BE/sec |
|
||||
| **T-hooks-installer** | `PostToolUse`+ntfy 桥+statusLine 段+`--max-time` | A1,A4,B2 | `scripts/setup-hooks.mjs`, `test/setup-hooks.test.ts`(若有) | T-config, R0 | sonnet | — | BE/sec |
|
||||
| **T-termsession** | telemetry/gate 捕获 + `approve(mode?)` | B2,B4 | `public/terminal-session.ts`, `test/terminal-session.test.ts`(扩) | T-types | sonnet | — | FE |
|
||||
| **T-tabs** | 仪表+stuck 徽标+3按钮审批+mode+面板挂载 | A1-A5,B2,B4 | `public/tabs.ts`, `test/tabs.test.ts`(扩) | T-preview-grid, T-termsession, N-push-ui, N-voice, N-quickreply, N-timeline-ui | **opus** | worktree | FE |
|
||||
| **T-projects-ui** | diff 入口 + worktree 表单 + timeline 区 | A4,B1,B3 | `public/projects.ts`, `test/worktree-form.test.ts` | N-diff-ui, N-timeline-ui, T-server-wire 契约 | sonnet | worktree | FE/sec |
|
||||
| **V-integration** | 端到端集成 (真起 server, 临时 repo) | 全部 | (报告-only) | W2,W3 | sonnet(reviewer) | — | QA |
|
||||
| **V-security** | 守卫/token/realpath/XSS 抽检 | 全部 | (报告-only) | W2,W3 | sonnet(reviewer) | — | sec |
|
||||
| **V-fe-pwa** | HTTPS push 注册 + 跨浏览器降级 | A1,A2 | (报告-only) | W3 | sonnet(reviewer) | — | QA |
|
||||
|
||||
**总计: 27 个任务** (1 研究 + 3 协调 + 14 W1 叶子 + 3 W2 后端 + 3 W3 前端 + 3 W4 验证)。
|
||||
|
||||
---
|
||||
|
||||
### 4.1 W-1 · 研究 spike (阻断)
|
||||
|
||||
#### R0 · statusLine / permission-mode schema 确认 `[ ]`
|
||||
- **Wave**: W-1 · **Feature**: B2, B4 · **Owns**: 无源码 (产出 `docs/R0_FINDINGS.md`) · **Depends**: 无 · **Model**: sonnet · **Isolation**: —
|
||||
- **Why blocking**: B2 `StatusTelemetry` 字段名 + B4 `permDecision` 能否携带 mode, **不得猜测** (H5)。先于 T-types 对 B2/B4 字段的冻结。
|
||||
- **Steps**:
|
||||
- [ ] 核实真实 Claude Code statusLine stdin JSON schema (字段名/类型: `context_window.used_percentage`? `cost.total_cost_usd`? `rate`(5h/7d)? `pr`?) — 用 Context7/官方文档/本机 `~/.claude` 实测。
|
||||
- [ ] 核实 `--permission-mode` 接受值集 (`default`/`acceptEdits`/`plan`/`bypassPermissions`)。
|
||||
- [ ] 核实 `ExitPlanMode` 是否经 `/hook/permission` 到达 (tool_name 形态)。
|
||||
- [ ] 核实 `permDecision` 的 decision JSON 能否写回"更新后的 permission mode" (当前 `src/server.ts:61` 只发 behavior; **无证据**能带 mode — 若不能, 记录替代方案: plan 批准后改走 `claude` 输入/重启 with mode)。
|
||||
- **Accept (AC-B2.6/AC-B4.6)**: 四项均有文档证据或明确"无法"记录; T-types/N-statusline-be/N-statusline-script/B4 路径据此实现; 未确认项 → 相关任务 `[!] BLOCKED`。
|
||||
|
||||
---
|
||||
|
||||
### 4.2 W0 · 协调点 (串行, 阻塞下游)
|
||||
|
||||
#### T-types · 冻结新共享类型 + §3 契约 `[ ]`
|
||||
- **Wave**: W0 · **Feature**: 全部 · **Owns**: `src/types.ts` · **Depends**: R0(仅 B2/B4 字段) · **Model**: sonnet · **Isolation**: —
|
||||
- **Steps**:
|
||||
- [ ] 应用 §3.1–§3.5 全部决议: `TimelineEvent`(语义 `class`)、`DiffLine.kind`(语义词)、`PushPayload`/`NotifyClass`/`PushSubscriptionRecord`/`NotifyService`、`ClaudeStatus += 'stuck'`、`StatusTelemetry`(R0)、`PermissionMode`、`CreateWorktreeResult`、`DiffFile`/`DiffHunk`/`DiffResult`。
|
||||
- [ ] 协议扩展: `ClientMessage.approve.mode?`、`ServerMessage |= telemetry`、`status.gate?`。
|
||||
- [ ] `Session` 加 `timeline`/`stuckNotified`/`telemetry`; `SessionManager` 加 `handleStatusLine`/`sweepStuck`/扩 `handleHookEvent`; `createSessionManager(cfg, notifyService?)`。
|
||||
- [ ] `LiveSessionInfo` 可选带 `telemetry?` (缩略图墙)。
|
||||
- [ ] B2/B4 字段若 R0 未完成 → 标 `// TODO(R0)` 并对该子集返回 BLOCKED, 不冻结。
|
||||
- **Accept**: `tsc --noEmit` 通过; 无实现纯类型; §3 五项契约可被 FE/BE 双侧 import。
|
||||
|
||||
#### T-config · 21 新 env 解析 + L5 校验 `[ ]`
|
||||
- **Wave**: W0 · **Feature**: 全部 · **Owns**: `src/config.ts`, `test/config.test.ts`(扩) · **Depends**: T-types · **Model**: sonnet · **Isolation**: worktree
|
||||
- **Steps** (用既有 `parseNonNegativeInt`/`parseBool` 帮手; 评审 #16 确认 **21 个**新解析):
|
||||
- [ ] A1: `VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY`(秘密)/`VAPID_SUBJECT`/`PUSH_STORE_PATH`/`PUSH_MAX_SUBS`(50)/`NOTIFY_DONE`(1)/`NOTIFY_DND`(0)/`DECISION_TOKEN_TTL_MS`(=permTimeoutMs)。
|
||||
- [ ] A4: `TIMELINE_MAX`(200)/`TIMELINE_ENABLED`(1)。 A5: `STUCK_TTL`(600s×1000)/`STUCK_ALERT`(1)。
|
||||
- [ ] B1: `DIFF_TIMEOUT_MS`(2000)/`DIFF_MAX_BYTES`(2MB)/`DIFF_MAX_FILES`(300)。 B2: `STATUSLINE_TTL_MS`(30000)。
|
||||
- [ ] B3: `WORKTREE_ENABLED`(1)/`WORKTREE_ROOT`(undef→`<repo>-worktrees`)/`WORKTREE_TIMEOUT_MS`(10000)。
|
||||
- [ ] B4: `DEFAULT_PERMISSION_MODE`(白名单 4 值, 默认 default)/`ALLOW_AUTO_MODE`(0)。
|
||||
- [ ] **L5 校验**: `permTimeoutMs > 0`; 记录 `--max-time` 须 > permTimeoutMs (实际注入在 T-hooks-installer)。秘密缺失只记 "configured"/"missing", 不入日志。
|
||||
- [ ] 返回对象 `Object.freeze`。
|
||||
- **Security**: SEC-C5 (VAPID 私钥不入日志); SEC-L4 (保留 `isHttpOrigin` 拒非 http(s) scheme); SEC-M4 (L5)。
|
||||
- **Accept (AC-A1.6)**: 默认值/覆盖/非法值抛错; VAPID 未设 → push 标记禁用; `vitest run config` 全绿。
|
||||
|
||||
#### T-spawn-env · spawn env + session 字段 + stuck 重新武装 `[ ]`
|
||||
- **Wave**: W0 · **Feature**: A1, A5, B2 · **Owns**: `src/session/session.ts`, `test/session.test.ts`(扩) · **Depends**: T-types · **Model**: sonnet · **Isolation**: —
|
||||
- **Steps**:
|
||||
- [ ] spawn env 加 `WEBTERM_STATUSLINE_URL: http://127.0.0.1:${cfg.port}/hook/status` (B2); `WEBTERM_NTFY_*` 由 `process.env` 透传 (不在此设 token, H3)。
|
||||
- [ ] session 初始化: `timeline: Object.freeze([])`、`stuckNotified: false`、`telemetry: null`。
|
||||
- [ ] `pty.onData`: 既有 `lastOutputAt=Date.now()` 旁加 `session.stuckNotified=false`; **§3.4 重新武装**: 若 `session.claudeStatus==='stuck'` → 置 `'working'` 并 broadcast `{type:'status',status:'working'}` (复用 session.ts 既有 broadcast util)。
|
||||
- **协调注记**: claudeStatus 通常由 T-manager 改; 此处 onData 的复位是 A5 重新武装的唯一可靠点 (同 lastOutputAt 已在此处)。T-manager owner 知悉, 不重复实现。
|
||||
- **Accept (AC-A5.2)**: 输出恢复后 stuck flag/status 复位; spawn env 含 statusline URL; `vitest run session` 全绿。
|
||||
|
||||
---
|
||||
|
||||
### 4.3 W1 · 叶子模块 (全部并行)
|
||||
|
||||
#### N-push · push-service + subscription-store `[ ]`
|
||||
- **Wave**: W1 · **Feature**: A1 · **Owns**: `src/push/push-service.ts`, `src/push/subscription-store.ts`, `test/push/push-service.test.ts`, `test/push/subscription-store.test.ts` · **Depends**: T-types · **Model**: opus · **Isolation**: worktree
|
||||
- **Steps**:
|
||||
- [ ] `subscription-store`: `loadSubscriptionStore(path, maxSubs)`; `list()`(返回 frozen)/`add`(FIFO 封顶)/`remove`/`prune(dead[])`/`persist()`(`mode:0o600`)。缺文件→空; 坏 JSON→空 (best-effort, 校验 endpoint+keys)。
|
||||
- [ ] `push-service` (实现 `NotifyService`): `isEnabled()`=两 VAPID key 均在; `notify(session, cls, token?)`: DND/`notifyDone` 短路; payload=§3.3 `PushPayload` (无 raw 输出/秘密); `tag=sessionId` (替换不堆叠, M6/A1-FR7); `requireInteraction` 仅 needs-input; 404/410→`prune`+`persist`; 其它错误记日志续发。
|
||||
- **Security**: SEC-C5 (私钥不入日志); SEC-M1 (`PUSH_MAX_SUBS` 封顶); SEC-M2 (600 权限, 绝不经 GET); SEC-L1 (`web-push` 锁版本, `npm audit`); payload 最小化。
|
||||
- **Accept**: `isEnabled` 真值表; needs-input/done/stuck 分支; 404/410 剪除; DND no-op; mock `web-push`; ≥80%。
|
||||
|
||||
#### N-timeline · 有界事件环 + 标签/类派生 `[ ]`
|
||||
- **Wave**: W1 · **Feature**: A4 · **Owns**: `src/session/timeline.ts`, `test/session/timeline.test.ts` · **Depends**: T-types · **Model**: sonnet · **Isolation**: worktree
|
||||
- **Steps** (纯函数, 零 DOM):
|
||||
- [ ] `sanitizeField(s, max=200)`: 去 `\x00-\x1f`, 截断。
|
||||
- [ ] `deriveClass(eventName)` → `TimelineClass` (§3.1: PreToolUse/PostToolUse→`tool`, PermissionRequest/Notification(permission_prompt)→`waiting`, Stop/SessionEnd→`done`, UserPromptSubmit→`user`)。
|
||||
- [ ] `deriveLabel(eventName, toolName?)` → 人话 ("ran Bash"/"edited <tool>"/"waiting for approval"/"done")。
|
||||
- [ ] `makeTimelineEvent(eventName, toolName, at)` → `TimelineEvent | null` (whitelist eventName, 否则 null)。
|
||||
- [ ] `appendEvent(events, ev, maxLen)`: 返回**新数组**, 超 `maxLen` 淘汰最旧 (NEVER 改入参)。
|
||||
- **Security**: SEC-H6 (sanitize 工具名/路径); SEC-M8 (环上限 append 时强制)。
|
||||
- **Accept (AC-A4.4, AC-A4.5)**: 每类一例; sanitize/截断; `appendEvent` 不可变 + 淘汰 (append `MAX+10` → 长度 ≤ MAX); ≥80%。
|
||||
|
||||
#### N-diff-be · diff 解析 + `getDiff` `[ ]`
|
||||
- **Wave**: W1 · **Feature**: B1 · **Owns**: `src/http/diff.ts`, `test/http/diff.test.ts` · **Depends**: T-types · **Model**: opus · **Isolation**: worktree
|
||||
- **Steps**:
|
||||
- [ ] `parseNumstat(out)` → `Map<path,{added,removed}>` (含 binary `-\t-`、rename)。
|
||||
- [ ] `parseUnifiedDiff(patch, numstat)` → `DiffFile[]` (单/多 hunk、rename、new/`/dev/null`、delete、binary、空→[]; never throw; 坏行→context)。
|
||||
- [ ] `getDiff(repoPath, {staged, cfg})`: `execFileAsync('git', ['diff','--no-color', staged?'--staged':[], '--'], {cwd,timeout:diffTimeoutMs,maxBuffer:diffMaxBytes})` + `--numstat` + `status --porcelain`(untracked, L3); `truncated = files>diffMaxFiles || patch.length>=diffMaxBytes`。
|
||||
- **Security**: SEC-H7 (路径三连校验在路由层, 此处 `--` 终结选项); SEC-M9 (maxBuffer/maxFiles DoS); SEC-H4 (diff 内容作为纯 text 字段返回, 不渲染)。FR-B1.9 `?base` **明确 P2 延后** (评审 #13; 需 `git rev-parse --verify` 白名单)。
|
||||
- **Accept (AC-B1.1, AC-B1.5)**: numstat/unified 各分支; +/- 与 `git diff --numstat` 一致; `<script>` 内容原样入 `DiffLine.text`; ≥80% + 临时 repo 集成。
|
||||
|
||||
#### N-statusline-be · `parseStatusLine` `[ ]`
|
||||
- **Wave**: W1 · **Feature**: B2 · **Owns**: `src/http/statusline.ts`, `test/http/statusline.test.ts` · **Depends**: T-types, **R0** · **Model**: sonnet · **Isolation**: worktree
|
||||
- **Steps**: `parseStatusLine(body: unknown): StatusTelemetry | null` — `unknown`+逐字段收窄 (仿 `parseHookEvent`); 非对象→null; 缺/脏字段→undefined; 数值 `Number.isFinite` 校验; 恒设 `at:Date.now()`; never throw。
|
||||
- **Security**: SEC-M7 (`unknown`+narrowing, 字符串长度上限)。 **若 R0 未确认字段名 → `[!] BLOCKED`** (H5)。
|
||||
- **Accept (AC-B2.6)**: 满/缺/脏/空; never throw; ≥80%。
|
||||
|
||||
#### N-worktree · 分支校验 + worktree 创建 `[ ]`
|
||||
- **Wave**: W1 · **Feature**: B3 · **Owns**: `src/http/worktrees.ts`(扩, 与 `listWorktrees` 同文件), `test/http/worktrees-create.test.ts` · **Depends**: T-types · **Model**: opus · **Isolation**: worktree
|
||||
- **Steps**:
|
||||
- [ ] `validateBranchName(b)`: 拒 空/`>250`/前导 `-`/`..`/尾 `.lock`/控制字符/空白/`~^:?*[\`/`@{`/首尾或连续 `/`。
|
||||
- [ ] `sanitizeBranchForDir(b)`: `/`→`-`, 去首尾 `-`, 不安全 FS 字符→`-`。
|
||||
- [ ] `computeWorktreeDir(repoPath, sanitized, root?)`: base=`root ?? <dirname>/<basename>-worktrees`; **M2: `fs.realpath(base)` + `realpath(candidate)` 后 `startsWith(realBase+sep)`** (防 symlink 绕过); 越界 throw。
|
||||
- [ ] `createWorktree(repoPath, branch, {base?, worktreeRoot?, timeoutMs})`: validate → repoPath 三连校验 → computeDir → `execFileAsync('git',['worktree','add','-b',branch,'--',dir, base].filter)` ; 分类错 (branch exists→409/path exists→409/其它→500), `error` 安全文案不带 raw stderr。
|
||||
- **Security (本特性最重)**: SEC-H2 (分支注入/前导 `-`); SEC-H3/M2 (realpath 包含); SEC-M10 (不泄 git stderr); `execFile` 无 shell + `--`。
|
||||
- **Accept (AC-B3.4, AC-B3.6)**: validate 每拒绝模式; symlink root 越界被拒; ≥80% + 端点集成 (真 repo)。
|
||||
|
||||
#### N-statusline-script · `scripts/statusline.mjs` `[ ]`
|
||||
- **Wave**: W1 · **Feature**: B2 · **Owns**: `scripts/statusline.mjs` · **Depends**: **R0** · **Model**: haiku · **Isolation**: worktree
|
||||
- **Steps**: 读 stdin statusLine JSON → `POST $WEBTERM_STATUSLINE_URL -H "X-Webterm-Session: $WEBTERM_SESSION" --data-binary @-`; echo 一行精简 (cost·ctx%·model) 给 Claude 状态行; web-terminal 外 (`WEBTERM_STATUSLINE_URL` 未设) → no-op。 **R0 未定 schema → BLOCKED**。
|
||||
- **Accept**: 本机 POST 到 `/hook/status` 成功; 非 web-terminal 环境静默。
|
||||
|
||||
#### T-hook-intake · `parseHookEvent` → `HookEventFull` `[ ]`
|
||||
- **Wave**: W1 · **Feature**: A4, B4 · **Owns**: `src/http/hook.ts`, `test/hook.test.ts`(扩) · **Depends**: T-types · **Model**: sonnet · **Isolation**: worktree
|
||||
- **Steps**: 新增 `HookEventFull {sessionId, status, detail?, at, eventClass, toolInput?, gate?}`; `parseHookEvent` 改返回该型 (status/detail 逻辑不变); 加 `at=Date.now()`、`eventClass`(原始 hook 名 whitelist)、`toolInput`(原样 unknown)、`gate='plan'` when `eventClass==='PermissionRequest' && tool==='ExitPlanMode'` (R0 待核)。
|
||||
- **Security**: SEC-M7 (`unknown`+narrowing, never throw)。
|
||||
- **Accept**: 既有 status 测试不回归; 新字段 (at/eventClass/gate) 有测试; ≥80%。
|
||||
|
||||
#### T-preview-grid · 共享仪表/徽标组件 (SP10) `[ ]`
|
||||
- **Wave**: W1 · **Feature**: A4, A5, B2 · **Owns**: `public/preview-grid.ts`, `test/telemetry-gauge.test.ts` · **Depends**: T-types · **Model**: sonnet · **Isolation**: worktree
|
||||
- **Why W1 (评审 #6, #8)**: 单一 owner; W1 即冻结导出签名, 让 W3 的 T-tabs/T-projects-ui 安全 import; **测试与实现同任务同波次** (不跨波)。
|
||||
- **Steps**: 加 `renderTelemetryGauge(container, telemetry, staleTtlMs)` (上下文条+$成本+model chip+PR 徽标, `>80%` 变色, `>TTL` 置灰); `renderStatusBadge(container, status)` (含 `'stuck'`=⚠); 扩 `statusText()` 支持 `'stuck'`。**全程 `el()`/`textContent`, 零 `innerHTML`**; PR URL 仅用户点击复制/打开。
|
||||
- **Security**: SEC-H5 (telemetry 字段 textContent); SEC-L5 (PR URL `new URL().protocol==='https:'` 校验后才入 href)。
|
||||
- **Accept**: gauge 渲染 (缺字段宽容/陈旧置灰/`<script>`作文本/PR URL scheme 校验); ≥80%。
|
||||
|
||||
#### N-push-ui · 订阅流 + 🔔 toggle `[ ]`
|
||||
- **Wave**: W1 · **Feature**: A1 · **Owns**: `public/push.ts`, `test/push.test.ts` · **Depends**: T-types · **Model**: sonnet · **Isolation**: worktree
|
||||
- **Steps**: `checkPushSupport()` (unsupported/insecure-context/vapid-missing/permission-denied/available/subscribed); `fetchVapidKey()` (503→null); `subscribePush`/`unsubscribePush` (POST/DELETE `/push/subscribe`); `mountPushToggle(container, opts)` (insecure→灰铃+提示 HTTPS/Tailscale; vapid-missing→隐藏)。`localStorage['web-terminal:push-muted']` 仅影响**应用内** (评审 #12: A1-FR9 记为 in-app-only; 全局 DND 走 `NOTIFY_DND`)。
|
||||
- **Security**: SEC-H8 (`isSecureContext` 检测); SEC-C4 路由侧 (本任务只发请求)。
|
||||
- **Accept (AC-A1.1, AC-A1.5)**: 各 support 状态; insecure 隐藏不崩; mock SW/Notification/fetch; ≥80%。 (notificationclick 测试归 T-sw, 评审 #11)。
|
||||
|
||||
#### N-voice · Web Speech + keybar 🎤 `[ ]`
|
||||
- **Wave**: W1 · **Feature**: A2 · **Owns**: `public/voice.ts`, `public/keybar.ts`, `test/voice.test.ts` · **Depends**: T-types · **Model**: sonnet · **Isolation**: worktree
|
||||
- **Steps**: `isSpeechSupported()`; `createVoiceInput(onTranscript, opts?)` → `VoiceInput | null` (start/stop/dispose/isActive; interim 回调; `autoSend`→补 `\r`)。`keybar.ts`: `mountKeybar(onSend, opts?)` 加 `onVoiceTrigger?`, supported 时 append 🎤 (touchstart/touchend 推杆 + `preventDefault` 防软键盘)。**纯前端, 零服务端, 无音频抵达后端**。
|
||||
- **Security**: SEC-L2 (Chrome→Google 音频披露文案; AC-A2.5 验无音频抵达服务端)。
|
||||
- **Accept (AC-A2.x)**: 不支持隐藏不崩; interim/final; autoSend 补/不补 `\r`; dispose 后无回调; ≥80%。
|
||||
|
||||
#### N-quickreply · chips + 调色板 `[ ]`
|
||||
- **Wave**: W1 · **Feature**: A3 · **Owns**: `public/quick-reply.ts`, `test/quick-reply.test.ts` · **Depends**: T-types · **Model**: sonnet · **Isolation**: worktree
|
||||
- **Steps**: `BUILT_IN_CHIPS` (yes/continue/1/2/3/Esc); 不可变 CRUD (`addChip`/`removeChip`/`reorderChip`/`updateChip` 返回新数组); `loadPalette`/`savePalette` (localStorage, 坏数据不抛); `mountQuickReply(container,{onSend})` (点 chip→`onSend(text+(appendEnter?'\r':''))`; `+` 开内联编辑器; **标签 `textContent`**)。
|
||||
- **Security**: SEC-L3 (snippet 标签 textContent, 防注入)。
|
||||
- **Accept (AC-A3.x)**: CRUD 不可变; round-trip 持久; 坏数据不抛; appendEnter 行为; `<script>` 作文本; ≥80%。
|
||||
|
||||
#### N-timeline-ui · 时间线面板 `[ ]`
|
||||
- **Wave**: W1 · **Feature**: A4 · **Owns**: `public/timeline.ts`, `test/timeline.test.ts` · **Depends**: T-types · **Model**: sonnet · **Isolation**: worktree
|
||||
- **Steps**: `normalizeTimelineEvent(raw)` (安全收窄→null); `timelineIcon(cls)` (按 §3.1 `TimelineClass`); `renderTimelineEvent(ev)` ("HH:MM · icon · label", `textContent`, 复用 `relTime`); `fetchTimeline(id)` (err→[]); `mountTimeline(container,id,opts?)` (newest-first, 可见时轮询 `refreshMs`, `maxEvents` 上限, empty 态)。
|
||||
- **Security**: SEC-H6 (label textContent)。
|
||||
- **Accept (AC-A4.3)**: normalize 防御; icon 映射; textContent; 轮询起停; dispose 清 interval; ≥80%。
|
||||
|
||||
#### N-diff-ui · diff 查看器 (render-only) `[ ]`
|
||||
- **Wave**: W1 · **Feature**: B1 · **Owns**: `public/diff.ts`, `test/diff.test.ts` · **Depends**: T-types · **Model**: sonnet · **Isolation**: worktree
|
||||
- **Steps (评审 #2: render-only, 无解析)**: `fetchDiff(repoPath, staged)` (GET `/projects/diff`); `normalizeDiffResult(raw)` (→null if invalid); `renderDiffFile(file, opts)`/`renderDiff(result)` (按文件分组, +/- 徽标, 折叠, truncated 警告, 空态; **全程 `textContent`/`el()`**); `mountDiffViewer(container, repoPath, {onClose})` (working|staged 切换)。
|
||||
- **Security (关键)**: SEC-H4 (diff 内容**全程 textContent, 零 innerHTML**)。
|
||||
- **Accept (AC-B1.3, AC-B1.4)**: `<script>`/`&`/ANSI 作文本; truncated 警告; 空态; staged 切换; ≥80%。
|
||||
|
||||
#### T-sw · SW push/notificationclick + 纯 helpers `[ ]`
|
||||
- **Wave**: W1 · **Feature**: A1 · **Owns**: `public/sw.js`, `public/sw-push.js`, `test/sw-push.test.ts` · **Depends**: T-types · **Model**: sonnet · **Isolation**: worktree
|
||||
- **Steps (评审 #11: 抽纯 helper)**:
|
||||
- [ ] `sw-push.js` (纯, 无 SW 全局): `buildPushNotification(payload)` → `{title, options}` (needs-input 带 Allow/Deny + `requireInteraction`, `tag=sessionId`); `resolveNotificationClick(notification, action)` → `{kind:'decision', body}` | `{kind:'focus', url}` (Allow/Deny→`/hook/decision`{sessionId,decision,token}; 正文→`/?session=<id>`)。
|
||||
- [ ] `sw.js`: `importScripts('./sw-push.js')`; `addEventListener('push'/'notificationclick')` 调上面 helper; fetch bypass 扩 `/push/*` (除既有 `/term`/`/hook`)。
|
||||
- **Security**: SEC-C1 (decision 带 token, 远程→Origin+token 在路由层校验); `credentials:'same-origin'`。
|
||||
- **Accept (AC-A1.2, AC-A1.3)**: `buildPushNotification` 各 class; `resolveNotificationClick` allow/deny/default 路由 (jsdom mock); ≥80% (live addEventListener 验证归 W4)。
|
||||
|
||||
---
|
||||
|
||||
### 4.4 W2 · 后端汇聚 (单 owner)
|
||||
|
||||
#### T-manager · timeline/telemetry/sweepStuck/notifyService `[ ]`
|
||||
- **Wave**: W2 · **Feature**: A4, A5, B2 · **Owns**: `src/session/manager.ts`, `test/manager.test.ts`(扩) · **Depends**: T-types, N-push(NotifyService), N-timeline(appendEvent) · **Model**: opus · **Isolation**: —
|
||||
- **Steps**:
|
||||
- [ ] `createSessionManager(cfg, notifyService?)` DI (避免直 import push-service)。
|
||||
- [ ] `handleStatusLine(id, telemetry)`: 存 `session.telemetry` + `broadcast({type:'telemetry',telemetry})` (B2)。
|
||||
- [ ] `handleHookEvent(...)` 扩: `cfg.timelineEnabled && eventClass` → `session.timeline = appendEvent(...)`; broadcast status 带 `gate` (A4/B4)。
|
||||
- [ ] `sweepStuck(now)` (A5/H4): 跳过 exited/idle/已 notified; `now-lastOutputAt > stuckTtlMs` → `claudeStatus='stuck'` + **broadcast status='stuck'** (§3.4 评审 #5) + `stuckNotified=true` + `notifyService?.notify(session,'stuck')`; `stuckAlert=0||stuckTtlMs=0` 禁用。**覆盖 attached 与 detached** 存活会话。
|
||||
- [ ] `handleAttach` Case 2 (M3): 晚加入设备补发 `telemetry`(若有) + 当前 status (pending 部分留 T-server-wire)。
|
||||
- [ ] `list()` 带 `telemetry?` (LiveSessionInfo)。
|
||||
- **Security**: SEC-M3 (每 stuck 回合一告警, 重新武装在 T-spawn-env)。
|
||||
- **Accept (AC-A5.1, AC-A5.3, AC-B2.3)**: sweepStuck 覆盖 attached、一回合一次; 晚加入补发遥测; ≥80%。
|
||||
|
||||
#### T-server-wire · 全部新路由 + C1 + token + 限流 `[ ]`
|
||||
- **Wave**: W2 · **Feature**: 全部 · **Owns**: `src/server.ts`, `test/integration/push.test.ts`, `test/integration/timeline-events.test.ts`, `test/integration/worktree.test.ts` · **Depends**: N-push, N-diff-be, N-statusline-be, N-worktree, T-hook-intake, T-manager · **Model**: opus · **Isolation**: —
|
||||
- **Steps**:
|
||||
- [ ] 初始化: `loadSubscriptionStore` → `createPushService` → `createSessionManager(cfg, pushService)`; `pendingApprovals` 条目扩 `{res,timer,token,expiresAt}`。
|
||||
- [ ] **C1 hold 门修正 (`src/server.ts:288`, SEC-C2)**: `shouldHold = clients.size>0 || (pushService.isEnabled() && subStore.list().length>0)`; 仅 hold 路径铸 `crypto.randomUUID()` token + `void pushService.notify(session,'needs-input',token)`。
|
||||
- [ ] 新路由: `GET /push/vapid-key`(公钥, 503 if disabled); `POST/DELETE /push/subscribe`(**`requireAllowedOrigin`** SEC-C4 + 限流); `POST /hook/decision`(**远程**: `requireAllowedOrigin` SEC-C1 + 限流 + token 校验 {session+resolve/超时失效} → `resolvePending(permDecision(...))` → 返回 **204**); `GET /live-sessions/:id/events`(只读, `timelineEnabled` else []); `GET /projects/diff`(只读 + 路径三连校验 SEC-H7 → `getDiff`); `POST /hook/status`(**`isLoopback`** SEC-H1 → `parseStatusLine` → `handleStatusLine`); `POST /projects/worktree`(**`requireAllowedOrigin`** SEC-C3 + `worktreeEnabled` → `createWorktree`); `GET /config/ui`(只读, 返回 `{allowAutoMode}`, 评审 #4)。
|
||||
- [ ] **限流 (评审 #9, SEC-H9)**: 每-IP 滑窗 — `/hook/decision` **≤10/min**、`/push/subscribe` **≤5/min** (内存 Map, 无外部包)。
|
||||
- [ ] `/hook` 路由: 传 `eventClass`+`toolInput` 给 `handleHookEvent`; Stop/SessionEnd → `pushService.notify(session,'done')`。
|
||||
- [ ] `/hook/permission`: `gate='plan'` 透传; B4 approve 携带 mode。
|
||||
- [ ] **WS approve mode (评审 #10, SEC-M5)**: 处理 `{type:'approve', mode}` → `permDecision(behavior, mode)`; **`ALLOW_AUTO_MODE=0` 时拒绝 `mode:'auto'` 降级 default** (即使越过 FE 隐藏到达)。
|
||||
- [ ] reaper (`src/server.ts:317`): 同间隔加 `manager.sweepStuck(Date.now())` (H4, 无新 timer)。
|
||||
- **Security**: SEC-C1/C2/C3/C4/H1/H7/H9/M1。
|
||||
- **Accept (AC-A1.2, AC-A1.8, AC-B2.5, AC-B3.2)**: C1 零客户端但有订阅→hold; decision 坏/陈旧 token/外来 Origin→403; loopback 校验; 限流 429; auto 拒绝; 集成全绿。
|
||||
|
||||
#### T-hooks-installer · 安装器扩展 `[ ]`
|
||||
- **Wave**: W2 · **Feature**: A1, A4, B2 · **Owns**: `scripts/setup-hooks.mjs`, `test/setup-hooks.test.ts`(若有) · **Depends**: T-config, R0 · **Model**: sonnet · **Isolation**: —
|
||||
- **Steps**: H1 `FF_EVENTS` 补 `PostToolUse`; L5 `--max-time = ceil(PERM_TIMEOUT_MS/1000)+缓冲` (取自 env, 不写裸字面量); H3 ntfy/Pushover 桥 (仅 `WEBTERM_NTFY_URL`+`TOPIC` 存在时追加 curl, **token 走 `$WEBTERM_NTFY_TOKEN` env, 不入 settings.json/argv**, priority high/low); B2 statusLine 段 (R0 schema, 幂等 MARKER, `--remove` 干净)。
|
||||
- **Security**: SEC-C6 (token 不入 settings.json/argv); SEC-M4 (`--max-time`>permTimeoutMs)。
|
||||
- **Accept (AC-A1.7, AC-A4.5, AC-B2.1)**: `FF_EVENTS` 含 PostToolUse; ntfy 命令正确 priority 且 **grep settings.json 无 token 字面量**; statusLine 幂等/`--remove`。
|
||||
|
||||
---
|
||||
|
||||
### 4.5 W3 · 前端汇聚 (单 owner)
|
||||
|
||||
#### T-termsession · telemetry/gate 捕获 + `approve(mode?)` `[ ]`
|
||||
- **Wave**: W3 · **Feature**: B2, B4 · **Owns**: `public/terminal-session.ts`, `test/terminal-session.test.ts`(扩) · **Depends**: T-types · **Model**: sonnet · **Isolation**: —
|
||||
- **Steps**: 加 `onTelemetry?` 回调 + `telemetry`/`pendingGate` getter (**单一遥测真相源**, 评审 #15); `handle()` 加 `case 'telemetry'` (+ exhaustiveness/assertNever); `status` case 记 `pendingGate = msg.gate ?? null`; `approve(mode?)` → `sendMsg({type:'approve', ...(mode?{mode}:{})})`。
|
||||
- **Accept (AC-B2.6)**: telemetry 捕获+回调; gate 记录; approve(mode) 序列化; ≥80% + `telemetry` 往返。
|
||||
|
||||
#### T-tabs · 仪表+徽标+审批+面板挂载 (最大 FE 任务) `[ ]`
|
||||
- **Wave**: W3 · **Feature**: A1–A5, B2, B4 · **Owns**: `public/tabs.ts`, `test/tabs.test.ts`(扩) · **Depends**: T-preview-grid, T-termsession, N-push-ui, N-voice, N-quickreply, N-timeline-ui · **Model**: opus · **Isolation**: worktree
|
||||
- **Steps**:
|
||||
- [ ] **SP10 单一 `refreshTab` owner (M4)**: stuck 徽标 (`claudeStatus==='stuck'`→⚠) + telemetry gauge (`renderTelemetryGauge`, 读 `entry.session.telemetry`)。
|
||||
- [ ] B4 `updateApprovalBar` (`tabs.ts:193`): plan gate (`pendingGate==='plan'`)→**3 按钮** (Approve+Auto→`approve('acceptEdits')` / Approve+Review→`approve('default')` / Keep Planning→`reject()`); tool gate 仍 2 按钮 (无回归)。
|
||||
- [ ] B4 `openProject(...,mode?)`: cmd 升级 `claude --permission-mode <mode>` (非 default 时); `loadDefaultMode()` (localStorage, `auto` 仅当 `allowAutoMode`); `loadUiConfig()` fetch `GET /config/ui`。
|
||||
- [ ] A1 `mountPushToggle` (构造器一次, 铃在 tabBar); A2 `setupVoice` (mic→`handleVoiceTrigger`→`createVoiceInput`→`sendToActive`, interim overlay); A3 `mountQuickReply` (`#quickreply` 行在 `#keybar` 上); A4 timeline 面板 toggle (`mountTimeline`, 懒挂、关 tab dispose)。
|
||||
- [ ] `TabEntry` 加 `timelineHandle` (**不**加 telemetry, 评审 #15)。
|
||||
- **Security**: SEC-M5 (auto 二次确认+警示, `allowAutoMode` 门); SEC-M6 (tag 去重已在 SW)。
|
||||
- **Accept (AC-A5.5, AC-B2.2, AC-B4.2, AC-B4.3, AC-B4.4)**: stuck 徽标; tab 仪表; plan 3 按钮/tool 2 按钮无回归; auto 隐藏 when 0; ≥80%。
|
||||
|
||||
#### T-projects-ui · diff 入口 + worktree 表单 + timeline 区 `[ ]`
|
||||
- **Wave**: W3 · **Feature**: A4, B1, B3 · **Owns**: `public/projects.ts`, `test/worktree-form.test.ts` · **Depends**: N-diff-ui, N-timeline-ui, T-server-wire 契约 · **Model**: sonnet · **Isolation**: worktree
|
||||
- **Steps**: B1 `renderProjectDetail` 加 "View Diff" → `mountDiffViewer` 内联面板 (会话行用 cwd); B3 `renderNewWorktreeForm` (`validateBranchNameClient` 客户端预校验 + `createWorktree` POST + 成功 `hooks.onOpenProject(dir,label,'claude\r')`; **错误 `textContent`**); A4 "Activity" 区 (running session 各 `mountTimeline`, 重渲/onBack dispose)。
|
||||
- **Security**: SEC-H4 (diff textContent); SEC-L3/H6 (错误/标签 textContent); B3 写端点 Origin 在路由层。
|
||||
- **Accept (AC-B1.3, AC-B3.1)**: 表单校验; diff 内联; timeline 区; server 错误作 textContent; ≥80%。
|
||||
|
||||
---
|
||||
|
||||
### 4.6 W4 · 验证 (报告-only, 不跨 lane 改文件)
|
||||
|
||||
> 同 PLAN.md §5 G4: reviewer 只产 findings (标 severity + owning task); 修复派回 owner builder。
|
||||
|
||||
#### V-integration · 端到端集成 `[ ]`
|
||||
- **Owns**: 无 (报告) · **Depends**: W2, W3 · **Model**: sonnet (reviewer)
|
||||
- **Steps**: 真起 server + 临时 git repo — C1 hold 门 (零客户端有订阅→hold); timeline 实时 hook 轮询; diff 真 repo; worktree 创建并开 tab; plan-gate 3 按钮解决; stuck 越 TTL; statusline.mjs→遥测仪表; `TIMELINE_ENABLED=0`/`WORKTREE_ENABLED=0`/`STUCK_TTL=0` 禁用路径。
|
||||
- **Accept**: PRD §8 全部 AC 对照运行通过。
|
||||
|
||||
#### V-security · 安全抽检 `[ ]`
|
||||
- **Owns**: 无 (报告) · **Depends**: W2, W3 · **Model**: sonnet (security-reviewer)
|
||||
- **Steps**: `/hook/decision` 坏 Origin/陈旧 token/loopback IP → 403; `/push/subscribe`/`/projects/worktree` 缺 Origin → 403; 限流 429; symlink 越界 worktree → 拒; diff/telemetry/timeline `<script>` 作文本; **grep settings.json + 进程 argv 无 ntfy/VAPID token 字面量** (AC-A1.7); VAPID 私钥不入日志; `web-push` `npm audit`。
|
||||
- **Accept (合并安全章节 §5)**: 安全表逐行核; 无 CRITICAL/HIGH 未决。
|
||||
|
||||
#### V-fe-pwa · HTTPS push + 跨浏览器 `[ ]`
|
||||
- **Owns**: 无 (报告) · **Depends**: W3 · **Model**: sonnet (reviewer)
|
||||
- **Steps**: HTTPS/Tailscale 上下文 push 订阅成功 + 锁屏 Allow/Deny 解决; 不安全上下文隐藏 🔔; Safari/iOS Web Speech 不支持→mic 隐藏; SW push 需加主屏→降级 ntfy 桥。
|
||||
- **Accept (AC-A1.5, AC-A2.3, C2)**: 安全上下文订阅成功; 不安全/不支持优雅降级。
|
||||
|
||||
---
|
||||
|
||||
## 5. 安全检查清单 (折叠到任务)
|
||||
|
||||
完整缓解见 PRD §5 合并安全章节; 下表把每条挂到 owner 任务 (severity 同评审约定: CRITICAL=阻断合并):
|
||||
|
||||
| 安全项 | Severity | Owner 任务 |
|
||||
|--------|----------|-----------|
|
||||
| SEC-C1 `/hook/decision` Origin+能力token | CRITICAL | T-server-wire (+ T-sw 发 token) |
|
||||
| SEC-C2 C1 hold 门修正 (零客户端有订阅→hold) | CRITICAL | T-server-wire |
|
||||
| SEC-C3 `/projects/worktree` `requireAllowedOrigin` | CRITICAL | T-server-wire |
|
||||
| SEC-C4 `/push/subscribe` (POST/DELETE) Origin | CRITICAL | T-server-wire |
|
||||
| SEC-C5 VAPID 私钥不日志/不经 GET/缺失降级 | CRITICAL | T-config, N-push |
|
||||
| SEC-C6 ntfy token 仅 env, 不入 settings.json/argv | CRITICAL | T-hooks-installer |
|
||||
| SEC-H1 `/hook/status` loopback-only | HIGH | T-server-wire |
|
||||
| SEC-H2 `validateBranchName` 前导`-`/注入 | HIGH | N-worktree |
|
||||
| SEC-H3/M2 realpath 两端包含校验 | HIGH | N-worktree |
|
||||
| SEC-H4 diff 内容 textContent | HIGH | N-diff-ui, T-projects-ui |
|
||||
| SEC-H5 telemetry 字段 textContent + PR URL scheme | HIGH | T-preview-grid |
|
||||
| SEC-H6 timeline label sanitize + textContent | HIGH | N-timeline, N-timeline-ui |
|
||||
| SEC-H7 `/projects/diff` 路径三连 + `--` | HIGH | N-diff-be, T-server-wire |
|
||||
| SEC-H8 push 需安全上下文检测 | HIGH | N-push-ui |
|
||||
| SEC-H9 限流 (decision≤10/min, subscribe≤5/min) | HIGH | T-server-wire |
|
||||
| SEC-M1 `PUSH_MAX_SUBS` 封顶 | MEDIUM | N-push |
|
||||
| SEC-M2 订阅库 600 / 不经 GET | MEDIUM | N-push |
|
||||
| SEC-M3 stuck 每回合一告警 + 重新武装 | MEDIUM | T-manager, T-spawn-env |
|
||||
| SEC-M4 `--max-time`>permTimeoutMs | MEDIUM | T-config, T-hooks-installer |
|
||||
| SEC-M5 `ALLOW_AUTO_MODE=0` 服务端拒 auto | MEDIUM | T-server-wire (+ T-tabs 隐藏, T-types 白名单) |
|
||||
| SEC-M6 tag 去重 | MEDIUM | N-push, T-sw |
|
||||
| SEC-M7 `unknown`+narrowing (statusline/hook) | MEDIUM | N-statusline-be, T-hook-intake |
|
||||
| SEC-M8 timeline 环上限 | MEDIUM | N-timeline |
|
||||
| SEC-M9 diff DoS (maxBuffer/maxFiles) | MEDIUM | N-diff-be |
|
||||
| SEC-M10 不泄 git stderr | MEDIUM | N-worktree |
|
||||
| SEC-L1 `web-push` 锁版本/audit | LOW | N-push, V-security |
|
||||
| SEC-L2 音频隐私披露 | LOW | N-voice |
|
||||
| SEC-L3 snippet 标签 textContent | LOW | N-quickreply |
|
||||
| SEC-L4 `ALLOWED_ORIGINS` scheme 校验保留 | LOW | T-config |
|
||||
| SEC-L5 PR URL https scheme 校验 | LOW | T-preview-grid |
|
||||
|
||||
---
|
||||
|
||||
## 6. 建议的多 Agent 分派排程 (Dispatch Schedule)
|
||||
|
||||
> 单批并行控制在 ~3–5 个 agent (官方甜区)。worktree 隔离前提: 先 commit W0 产物再派 W1 隔离 builder。
|
||||
|
||||
| 批次 | 波次 | 并行任务 (≤5/批) | 说明 |
|
||||
|------|------|------------------|------|
|
||||
| 1 | W-1 | **R0** | 串行研究 spike, 阻断 B2/B4 字段 |
|
||||
| 2 | W0 | **T-types** | 串行冻结契约 (§3 五项决议), 全员依赖 |
|
||||
| 3 | W0 | **T-config, T-spawn-env** | 2 并行 (类型就绪) |
|
||||
| 4a | W1 | **N-push, N-diff-be, N-worktree** | 后端高风险 (opus×2+opus) |
|
||||
| 4b | W1 | **N-timeline, N-statusline-be, N-statusline-script, T-hook-intake** | 后端其余 (statusline 待 R0) |
|
||||
| 5a | W1 | **T-preview-grid, N-diff-ui, N-timeline-ui** | 前端 (preview-grid 先, 供 W3) |
|
||||
| 5b | W1 | **N-push-ui, N-voice, N-quickreply, T-sw** | 前端其余 |
|
||||
| 6 | W2 | **T-manager** → 然后 **T-server-wire**; **T-hooks-installer** 并行 | manager 先于 server-wire (串行); installer 独立文件并行 |
|
||||
| 7 | W3 | **T-termsession** → 然后 **T-tabs**; **T-projects-ui** 并行 | termsession 先 (tabs 依赖 onTelemetry); tabs/projects 文件 disjoint 可并行 |
|
||||
| 8 | W4 | **V-integration, V-security, V-fe-pwa** | 3 reviewer 并行, 报告-only |
|
||||
|
||||
> 批 4a/4b 与 5a/5b 共 13 个 W1 任务全 disjoint, agent 充足可并发更多; 不足则按批跑。
|
||||
|
||||
---
|
||||
|
||||
## 7. 验收映射 (AC → 任务)
|
||||
|
||||
> 全局门槛: 纯核心 (diff/statusLine/timeline 解析、branch/dir 校验、push payload、label/类派生) 单测 ≥80%; 端点经集成测试 (真起 server); A1 push 在模拟 TLS/安全上下文注册成功。
|
||||
|
||||
| 特性 | 关键 AC (PRD §8) | 主验收任务 |
|
||||
|------|------------------|-----------|
|
||||
| **A1** | C1 零 tab 锁屏解决(AC-A1.2)、不安全上下文隐藏铃(AC-A1.5)、VAPID 未设 503(AC-A1.6)、token 不入 settings.json(AC-A1.7)、坏/陈旧 token 403(AC-A1.8) | N-push, N-push-ui, T-sw, T-server-wire, T-hooks-installer, V-integration, V-security, V-fe-pwa |
|
||||
| **A2** | 按住说话打进终端、软键盘不弹、不支持隐藏、无音频抵达(AC-A2.5) | N-voice, T-tabs, V-fe-pwa |
|
||||
| **A3** | 内置+持久调色板同路径发送、标签惰性文本(AC-A3.4) | N-quickreply, T-tabs |
|
||||
| **A4** | 人话时间线、`/…/events` 受界、`PostToolUse`(AC-A4.5)、sanitize(AC-A4.4)、`TIMELINE_ENABLED=0` 空 | N-timeline, N-timeline-ui, T-hook-intake, T-manager, T-hooks-installer, T-projects-ui/T-tabs |
|
||||
| **A5** | 静默一次告警、恢复重新武装(AC-A5.2)、覆盖 attached 无新 timer(AC-A5.3)、stuck 徽标(AC-A5.5)、0 禁用 | T-manager, T-spawn-env, T-server-wire, T-tabs, T-preview-grid |
|
||||
| **B1** | 结构化 diff+numstat 一致、非 git→404、超大 truncated、`<script>` 作文本(AC-B1.4)、解析≥80%(AC-B1.5)、untracked(L3) | N-diff-be, N-diff-ui, T-server-wire, T-projects-ui |
|
||||
| **B2** | tab 仪表、晚加入补发(M3, AC-B2.3)、缺字段宽容/陈旧置灰、非本机 403(AC-B2.5)、`telemetry` 往返、R0 确认 | N-statusline-be, N-statusline-script, T-manager, T-server-wire, T-termsession, T-preview-grid, T-tabs |
|
||||
| **B3** | 建出并开 agent、缺 Origin 403、非法分支拒、realpath 拒 symlink(AC-B3.4)、并行不互踩、纯+集成 | N-worktree, T-server-wire, T-projects-ui, V-security |
|
||||
| **B4** | plan 启动、3 按钮 approve+auto 切 acceptEdits(AC-B4.2)、tool 2 按钮无回归、`ALLOW_AUTO_MODE=0` 禁 auto、非法 mode 拒、R0 确认(AC-B4.6) | T-types, T-termsession, T-tabs, T-server-wire, R0 |
|
||||
| **跨特性** | 共享文件单 owner(C3)、env 命名(L4)、`--max-time`>permTimeoutMs(L5)、共享仪表无 DOM 争用(M4) | §2 协调点, T-config, T-hooks-installer, T-preview-grid |
|
||||
|
||||
---
|
||||
|
||||
## 8. 评审修正落点速查 (Review Fixes → 本计划)
|
||||
|
||||
| # | 修正 | 落点 |
|
||||
|---|------|------|
|
||||
| 1 | `TimelineEvent` 用派生语义 `class` | §3.1, T-types, N-timeline `deriveClass` |
|
||||
| 2 | `DiffLine.kind` 一种拼写 + diff 只在后端解析 | §3.2, N-diff-be(解析), N-diff-ui(render-only) |
|
||||
| 3 | Push payload 一种 shape, FE 读 `cls` | §3.3, N-push, T-sw |
|
||||
| 4 | 加 `GET /config/ui` | T-server-wire, T-tabs `loadUiConfig` |
|
||||
| 5 | `'stuck'` 入 union + 服务端广播 + 重新武装 | §3.4, T-types, T-manager, T-spawn-env |
|
||||
| 6 | `preview-grid.ts` 单 owner | §2, **T-preview-grid** (新增 W1 任务) |
|
||||
| 7 | `keybar.ts` 归属明确 | §2, **N-voice** owns keybar.ts (非 T-sw) |
|
||||
| 8 | gauge 测试与实现同任务同波次 | T-preview-grid (`test/telemetry-gauge.test.ts` 同 W1) |
|
||||
| 9 | 限流收紧 (decision≤10/min, subscribe≤5/min) | T-server-wire |
|
||||
| 10 | 服务端 `ALLOW_AUTO_MODE` 拒 auto | T-server-wire |
|
||||
| 11 | SW 抽纯 helper 可测 | T-sw (`sw-push.js` + `test/sw-push.test.ts`) |
|
||||
| 12 | A1-FR9 mute 记为 in-app-only | N-push-ui (注记), 全局 DND 走 `NOTIFY_DND` |
|
||||
| 13 | FR-B1.9 `?base` 明确延后 P2 | N-diff-be (注记) |
|
||||
| 14 | `/hook/decision` 响应 204 | §3.5, T-server-wire |
|
||||
| 15 | 遥测单一真相源 (TerminalSession) | §3.5, T-termsession, T-tabs (TabEntry 不重复) |
|
||||
| 16 | config 计数确认 21 | T-config |
|
||||
|
||||
---
|
||||
|
||||
## 9. 一句话总结
|
||||
|
||||
> v0.7 拆成 **27 个单-owner-per-shared-file 任务**, 4 波 (协调→叶子并行→后端汇聚→前端汇聚) + 1 阻断研究 (R0) + 3 报告-only 验证; 5 处 FE↔BE 类型契约在 §3 于 T-types 一次定死; 安全 30 项折叠到 owner; **服务端仍不解析一个终端字节** (新增仅 git 子进程、带外 JSON、出站推送签名)。
|
||||
Reference in New Issue
Block a user