From 4f1d3ebc6be9724a75ec2af0daed5e5424294e32 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 30 Jun 2026 15:51:58 +0200 Subject: [PATCH] docs(v0.7): PRD + implementation plan for Walk-away Workbench (Band A + B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/FEATURE_WALKAWAY_WORKBENCH.md | 726 +++++++++++++++++++++++++++++ docs/PLAN_WALKAWAY_WORKBENCH.md | 536 +++++++++++++++++++++ 2 files changed, 1262 insertions(+) create mode 100644 docs/FEATURE_WALKAWAY_WORKBENCH.md create mode 100644 docs/PLAN_WALKAWAY_WORKBENCH.md diff --git a/docs/FEATURE_WALKAWAY_WORKBENCH.md b/docs/FEATURE_WALKAWAY_WORKBENCH.md new file mode 100644 index 0000000..11b20fc --- /dev/null +++ b/docs/FEATURE_WALKAWAY_WORKBENCH.md @@ -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 — `,正文 = 工具名 + detail 首行,动作 **[Allow] [Deny]**,`requireInteraction:true`。Allow/Deny 不开 app 即解决;下次打开有 toast 确认结果。 +- **DONE 通知**:`✅ Session done — `,无动作、低优、点开聚焦该 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` | `/.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=&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=` 跑 `git diff `(base 走 `git rev-parse --verify` 白名单,见安全) | P2 | + +### B1.3 设计与数据流 +``` +详情/会话工具栏 ── Diff ──► GET /projects/diff?path=&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)**:含 `