# 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 } 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→`-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 "/"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` (含 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` 一致; `