@zhuxixi/pi-agent-board 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +6 -3
  3. package/VERIFY.md +2 -1
  4. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
  5. package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
  6. package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
  7. package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
  8. package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
  9. package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
  10. package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
  11. package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
  12. package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
  13. package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
  14. package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
  15. package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
  16. package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
  17. package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
  18. package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
  19. package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
  20. package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
  21. package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
  22. package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
  23. package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
  24. package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
  25. package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
  26. package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
  27. package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
  28. package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
  29. package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
  30. package/package.json +3 -2
  31. package/runner/job-runner-legacy.mjs +68 -0
  32. package/runner/job-runner.mjs +371 -67
  33. package/runner/pty-runner-legacy.mjs +50 -0
  34. package/runner/pty-runner.mjs +685 -58
  35. package/runner/state-coordinator.mjs +429 -0
  36. package/runner/state-runner.mjs +90 -15
  37. package/scripts/run-perf-gate.mjs +40 -0
  38. package/src/commands/agent-board.ts +8 -8
  39. package/src/commands/attach-flow.ts +5 -5
  40. package/src/commands/bg.ts +2 -1
  41. package/src/core/control-protocol.mjs +482 -0
  42. package/src/core/coordinator-client.mjs +313 -0
  43. package/src/core/coordinator-journal.mjs +282 -0
  44. package/src/core/coordinator-protocol.mjs +12 -0
  45. package/src/core/editor-state-reporter.mjs +11 -1
  46. package/src/core/foreground-preview-cache.mjs +117 -0
  47. package/src/core/host-protocol.mjs +24 -0
  48. package/src/core/launch.mjs +15 -0
  49. package/src/core/locks.mjs +68 -14
  50. package/src/core/paths.mjs +48 -0
  51. package/src/core/pid.mjs +32 -1
  52. package/src/core/pty-attach-jiggle-controller.mjs +83 -6
  53. package/src/core/pty-attach-reconnect.mjs +13 -6
  54. package/src/core/pty-attach-render.mjs +50 -0
  55. package/src/core/state-commands.mjs +699 -0
  56. package/src/core/status-consistency.mjs +98 -0
  57. package/src/core/store.mjs +59 -13
  58. package/src/core/terminal-attach-client.mjs +803 -0
  59. package/src/core/terminal-attach-protocol.mjs +252 -0
  60. package/src/core/terminal-model.mjs +222 -0
  61. package/src/core/terminal-snapshot.mjs +440 -0
  62. package/src/core/types.mjs +2 -0
  63. package/src/index.ts +12 -4
  64. package/src/runtime/service.mjs +694 -121
  65. package/src/ui/dashboard.ts +67 -92
  66. package/src/ui/pty-attach.ts +298 -72
  67. package/src/core/pty-input.mjs +0 -47
@@ -0,0 +1,875 @@
1
+ # ← detach 锚点反劫持 + editor reporter 端点修复 Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** attach 表面的 `←` 不再被聊天区反色内容吞掉(tier-1 锚点加形态校验),且子 pi 的 editor-state reporter 恢复连接(runner 注入真实端点 + 身份握手,且不污染 warm-host 回收计数)。
6
+
7
+ **Architecture:** 分两半。A 半:把「哪一行是编辑器行」的判定从 UI 类的私有方法抽成 `src/core/pty-input.mjs` 的纯函数 `pickEditorAnchorLine`,判据是「反色**字符**数 === 1」(宽字符占 2 cell 但 1 字符,故不能用 cell 数),UI 只负责把缓冲行投影成 `{text, inverseCharCount}`。B 半:runner 在 spawn 子进程时注入 `AGENT_BOARD_CONTROL_SOCKET`(legacy 注入稳定端点、owned 注入 per-instance 端点);reporter 用纯函数 `resolveControlEndpoint` 解析并发送 `clientId:"editor-reporter"` 的 hello;runner 用纯函数 `classifyClientHello` 识别它,把该 socket 移出 `clients` 计数集合转入 `editorReporters`,从而既恢复权威编辑器状态、又不让常驻连接掐死 warm-host 回收。
8
+
9
+ **Tech Stack:** Node 24(`node --test`)、TypeScript(`tsc --noEmit`)、`@xterm/headless` 6.x、`node-pty`、纯 ESM `.mjs` 核心模块。
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md`
12
+
13
+ ## Global Constraints
14
+
15
+ - 验收 ID 与 spec §3 一一对应;每个 task 的 commit 前必须跑通该 task 列出的命令。
16
+ - **可测性拆分是硬约束**:判定逻辑必须留在纯函数里;`src/ui/pty-attach.ts` 只做缓冲行投影(`BufferLine → {text, inverseCharCount}`),不得把判定逻辑写回 UI。
17
+ - **判据是「反色字符数」(累加 `getChars().length`),不是「反色 cell 数」**:`草` 占 2 cell / 1 字符。按 cell 计数会破坏场景 B 的 draft 保护。
18
+ - 单元测试命令:`node --test test/<file>.test.mjs`;全量:`npm test`;类型:`npm run typecheck`。
19
+ - smoke 命令:`node --experimental-transform-types test-support/detach-gate-smoke.ts`(输出一行 JSON)。
20
+ - 不做 spec §5 的非目标:不动 pi 渲染、不引入边框/光标结构锚点、不重构 warm-host 回收策略、不动 `Ctrl+←` 与断开态 `←` 语义。
21
+ - `git add <file>` 逐个 stage,**不要** `git add -A`。
22
+ - 所有命令在 worktree 根目录执行:`C:\Users\27499\proj\opensource\pi-agent-board\.pi\worktrees\issue-103-detach-anchor-reporter-endpoint`。
23
+
24
+ ---
25
+
26
+ ### Task 1: `pickEditorAnchorLine` 纯函数(A1)
27
+
28
+ **Files:**
29
+ - Modify: `src/core/pty-input.mjs`(文件末尾新增导出函数)
30
+ - Test: `test/pty-input.test.mjs`(文件末尾新增用例)
31
+
32
+ **Interfaces:**
33
+ - Consumes: 同文件已有的 `isProbablyPiInputLine(line)` / `isProbablyEmptyPiInputLine(line)`。
34
+ - Produces: `pickEditorAnchorLine(candidates) → { empty: boolean } | null`,其中 `candidates: Array<{ text?: string, inverseCharCount?: number }>`,顺序为**自底向上**(缓冲区最后一行在前)。`null` 表示"没有可信的编辑器行,交回调用方继续兜底"。
35
+
36
+ - [ ] **Step 1: 写失败测试**
37
+
38
+ 在 `test/pty-input.test.mjs` 末尾追加(先把 `pickEditorAnchorLine` 加进文件顶部的 import 列表):
39
+
40
+ ```js
41
+ test("pickEditorAnchorLine trusts a single inverse char on a glyph line (legacy draft)", () => {
42
+ assert.deepEqual(pickEditorAnchorLine([{ text: "> 草稿", inverseCharCount: 1 }]), { empty: false });
43
+ assert.deepEqual(pickEditorAnchorLine([{ text: "> ", inverseCharCount: 1 }]), { empty: true });
44
+ });
45
+
46
+ test("pickEditorAnchorLine trusts a single inverse char on a blank non-glyph line (new-style fake cursor)", () => {
47
+ assert.deepEqual(pickEditorAnchorLine([{ text: "", inverseCharCount: 1 }]), { empty: true });
48
+ assert.deepEqual(pickEditorAnchorLine([{ text: " ", inverseCharCount: 1 }]), { empty: true });
49
+ });
50
+
51
+ test("pickEditorAnchorLine skips multi-char inverse chat content (issue #103 scenarios H/I)", () => {
52
+ const diff = { text: "+ 65 ## R2 · #822 新 step 挂链顺序调研", inverseCharCount: 15 };
53
+ const banner = { text: " Session saved ", inverseCharCount: 15 };
54
+ assert.equal(pickEditorAnchorLine([diff]), null);
55
+ assert.equal(pickEditorAnchorLine([banner]), null);
56
+ // bottom-up order: the banner sits below the editor line, so a later candidate wins
57
+ assert.deepEqual(pickEditorAnchorLine([banner, { text: "", inverseCharCount: 1 }]), { empty: true });
58
+ });
59
+
60
+ test("pickEditorAnchorLine skips a new-style draft line and keeps scanning (R1 trade-off)", () => {
61
+ // Text + a single inverse fake cursor, no prompt glyph: deliberately untrusted
62
+ // so the escape chain still releases the user (spec §2.1).
63
+ assert.equal(pickEditorAnchorLine([{ text: "草稿", inverseCharCount: 1 }]), null);
64
+ // …but a trusted editor line above it still anchors.
65
+ assert.deepEqual(pickEditorAnchorLine([{ text: "草稿", inverseCharCount: 1 }, { text: "> ", inverseCharCount: 1 }]), { empty: true });
66
+ });
67
+
68
+ test("pickEditorAnchorLine ignores zero-count, malformed and empty input", () => {
69
+ assert.equal(pickEditorAnchorLine([{ text: " ", inverseCharCount: 0 }]), null);
70
+ assert.equal(pickEditorAnchorLine([{}]), null);
71
+ assert.equal(pickEditorAnchorLine([]), null);
72
+ assert.equal(pickEditorAnchorLine(undefined), null);
73
+ });
74
+ ```
75
+
76
+ - [ ] **Step 2: 跑测试确认失败**
77
+
78
+ Run: `node --test test/pty-input.test.mjs`
79
+ Expected: FAIL —— `pickEditorAnchorLine is not a function`(import 报 undefined)。
80
+
81
+ - [ ] **Step 3: 实现**
82
+
83
+ 在 `src/core/pty-input.mjs` 末尾追加:
84
+
85
+ ```js
86
+ /**
87
+ * Pick the editor anchor line out of the buffer's inverse-video lines.
88
+ *
89
+ * Pi draws the editor's fake cursor as exactly ONE inverse CHARACTER (an
90
+ * inverse space on an empty line, an inverse glyph inside a draft). Chat-area
91
+ * inverse content — diff hunks from renderDiff, the inverse notification
92
+ * banner, search highlights — is always a multi-character run. That difference
93
+ * is the discriminator, because the editor line is frequently absent from the
94
+ * buffer entirely (differential frames skip unchanged lines), which is what
95
+ * made the old "bottom-most inverse line wins" anchor read chat content as a
96
+ * draft and swallow ← (issue #103).
97
+ *
98
+ * Candidates are scanned bottom-up; anything that is not a single inverse
99
+ * character is chat content and is skipped rather than trusted. A glyph row
100
+ * keeps the pre-existing semantics of scenarios B/B3. A blank non-glyph row is
101
+ * the new-style fake cursor. A single-inverse-character row that carries text
102
+ * but no glyph is the new-style draft line: R1 deliberately does NOT trust it,
103
+ * so the escape chain still releases the user (spec §2.1).
104
+ *
105
+ * Count characters, not cells: wide glyphs occupy two cells but yield one
106
+ * `getChars()` entry (`草` = 2 cells / 1 char), so a cell-based rule would skip
107
+ * every CJK fake cursor and break draft protection.
108
+ *
109
+ * @param {Array<{ text?: string, inverseCharCount?: number }>} candidates bottom-up
110
+ * @returns {{ empty: boolean } | null} null = no trusted editor line (fall through)
111
+ */
112
+ export function pickEditorAnchorLine(candidates) {
113
+ for (const candidate of candidates ?? []) {
114
+ if (Number(candidate?.inverseCharCount ?? 0) !== 1) continue;
115
+ const text = String(candidate?.text ?? "");
116
+ if (isProbablyPiInputLine(text)) return { empty: isProbablyEmptyPiInputLine(text) };
117
+ if (text.trim().length === 0) return { empty: true };
118
+ }
119
+ return null;
120
+ }
121
+ ```
122
+
123
+ - [ ] **Step 4: 跑测试确认通过**
124
+
125
+ Run: `node --test test/pty-input.test.mjs`
126
+ Expected: PASS(原有 5 个用例 + 新增 5 个)。
127
+
128
+ - [ ] **Step 5: Commit**
129
+
130
+ ```bash
131
+ git add src/core/pty-input.mjs test/pty-input.test.mjs
132
+ git commit -m "feat(core): add pickEditorAnchorLine inverse-character anchor rule (issue #103)"
133
+ ```
134
+
135
+ ---
136
+
137
+ ### Task 2: tier-1 接入 + smoke 场景 O1/O2/O3(A2–A5)
138
+
139
+ **Files:**
140
+ - Modify: `src/ui/pty-attach.ts`(`:8` import、`:357-370` `findLastInverseCellLine` → `collectInverseCellLines`、`:372-400` `childInputLooksEmpty`)
141
+ - Modify: `test-support/detach-gate-smoke.ts`(K2 之后插入 O1/O2/O3)
142
+ - Modify: `test/pty-attach-detach-gate.test.mjs`(新增 3 条断言)
143
+
144
+ **Interfaces:**
145
+ - Consumes: Task 1 的 `pickEditorAnchorLine`。
146
+ - Produces: `PtyAttachComponent.collectInverseCellLines(active) → Array<{ text: string, inverseCharCount: number }>`(private;UI 侧唯一的副作用隔离点)。
147
+
148
+ - [ ] **Step 1: 写失败场景(smoke)**
149
+
150
+ 在 `test-support/detach-gate-smoke.ts` 的 K2 场景块之后(`// L. Issue #89:` 之前)插入:
151
+
152
+ ```ts
153
+ // O1. Issue #103: a chat-area diff hunk paints its changed fragments with
154
+ // inverse video (renderDiff) and there is no fake-cursor line in the buffer.
155
+ // The old tier-1 anchor grabbed that hunk and read "draft", so ← was forwarded
156
+ // and the user was trapped. Chat content must never veto detach.
157
+ {
158
+ const { attach, sent, didDetach } = makeAttach();
159
+ const DIFF_LINE = "\x1b[48;2;230;233;239m \x1b[38;2;64;160;43m+ 65 ## \x1b[7mR2 · \x1b[27m#\x1b[7m822 新 step 挂链顺序调研\x1b[27m";
160
+ await writeToTerm(attach, "chat\r\n" + DIFF_LINE + "\r\n ");
161
+ (attach as unknown as { connected: boolean }).connected = true;
162
+ attach.handleInput("\x1b[D");
163
+ out.leftDetachesWithDiffHighlightInChat = didDetach() && sent.length === 1 && sent[0].type === "detach";
164
+ attach.dispose();
165
+ }
166
+
167
+ // O2. Issue #103: the notification banner renders the whole entry inverse
168
+ // (`\x1b[7m … \x1b[27m`) — same hijack, same requirement.
169
+ {
170
+ const { attach, sent, didDetach } = makeAttach();
171
+ await writeToTerm(attach, "chat\r\n\x1b[7m Session saved \x1b[27m\r\n ");
172
+ (attach as unknown as { connected: boolean }).connected = true;
173
+ attach.handleInput("\x1b[D");
174
+ out.leftDetachesWithInverseBannerInChat = didDetach() && sent.length === 1 && sent[0].type === "detach";
175
+ attach.dispose();
176
+ }
177
+
178
+ // O3. The R1 trade-off, pinned on purpose (spec §2.1; same spirit as K2): a
179
+ // new-style draft line (text + one inverse fake cursor, no prompt glyph) with
180
+ // no pushed editor_state is NOT trusted by the anchor rule, so ← escapes
181
+ // instead of being forwarded. Detach keeps the child session running, so the
182
+ // draft is not lost — a spurious detach beats a trapped user (#42/#48).
183
+ {
184
+ const { attach, sent, didDetach } = makeAttach();
185
+ await writeToTerm(attach, "chat content\r\n\x1b[7m草\x1b[27m稿");
186
+ (attach as unknown as { connected: boolean }).connected = true;
187
+ attach.handleInput("\x1b[D");
188
+ out.leftDetachesOnNewStyleDraftWithoutReporter = didDetach() && sent.length === 1 && sent[0].type === "detach";
189
+ attach.dispose();
190
+ }
191
+ ```
192
+
193
+ - [ ] **Step 2: 跑 smoke 确认 O1/O2 失败、O3 也失败**
194
+
195
+ Run: `node --experimental-transform-types test-support/detach-gate-smoke.ts`
196
+ Expected: 输出的 JSON 中 `leftDetachesWithDiffHighlightInChat: false`、`leftDetachesWithInverseBannerInChat: false`(当前 bug);`leftDetachesOnNewStyleDraftWithoutReporter` 可能为 `true`(旧 tier-1 把该行当编辑器行 → 判非空 → 不 detach,所以也是 `false`)—— 三个键都要记下来,修完必须都为 `true`。
197
+
198
+ - [ ] **Step 3: 实现(UI 投影 + 判定接入)**
199
+
200
+ `src/ui/pty-attach.ts` 第 8 行 import 改为:
201
+
202
+ ```ts
203
+ import { isProbablyEmptyPiInputLine, isProbablyPiInputLine, pickEditorAnchorLine, resolveEditorEmpty } from "../core/pty-input.mjs";
204
+ ```
205
+
206
+ 把 `:357-370` 的 `findLastInverseCellLine` 整体替换为:
207
+
208
+ ```ts
209
+ /** Bottom-up projection of every buffer line carrying inverse-video cells,
210
+ * with the inverse CHARACTER count. Pi's editor fake cursor is exactly one
211
+ * inverse character; chat-area diff hunks (renderDiff) and the inverse
212
+ * notification banner are multi-character runs — pickEditorAnchorLine does
213
+ * the discrimination. Counting characters rather than cells keeps wide
214
+ * glyphs (2 cells, 1 char) counted once (issue #103). */
215
+ private collectInverseCellLines(active: {
216
+ baseY: number;
217
+ length: number;
218
+ getLine(index: number): BufferLineLike | undefined;
219
+ }): Array<{ text: string; inverseCharCount: number }> {
220
+ const lines: Array<{ text: string; inverseCharCount: number }> = [];
221
+ for (let y = active.baseY + active.length - 1; y >= active.baseY; y--) {
222
+ const line = active.getLine(y);
223
+ if (!line) continue;
224
+ let inverseCharCount = 0;
225
+ for (let x = 0; x < line.length; x++) {
226
+ const cell = line.getCell(x);
227
+ if (cell?.isInverse()) inverseCharCount += cell.getChars().length;
228
+ }
229
+ if (inverseCharCount > 0) lines.push({ text: line.translateToString(true) ?? "", inverseCharCount });
230
+ }
231
+ return lines;
232
+ }
233
+ ```
234
+
235
+ `childInputLooksEmpty()` 中的 tier-1 段(注释 + `findLastInverseCellLine` + `isProbablyEmptyPiInputLine`)替换为:
236
+
237
+ ```ts
238
+ // The terminal cursor is not a reliable anchor for the editor line:
239
+ // while Pi streams output (or right after attach) the cursor rests on
240
+ // working/output lines, never the input line, so a genuinely empty
241
+ // editor was misread as non-empty and ← stopped detaching (issue #66).
242
+ // Pi's editor line carries an inverse fake cursor, but the chat area is
243
+ // full of inverse content too (diff hunks, the notification banner), and
244
+ // the editor line is often missing from the buffer entirely — so the
245
+ // anchor must also look like an editor line, and chat content must be
246
+ // skipped rather than trusted (issue #103).
247
+ const anchor = pickEditorAnchorLine(this.collectInverseCellLines(active));
248
+ if (anchor !== null) return anchor.empty;
249
+ ```
250
+
251
+ (tier-2 的字形扫描段与末尾 `return true` 保持不变。)
252
+
253
+ - [ ] **Step 4: 跑 smoke 确认全绿**
254
+
255
+ Run: `node --experimental-transform-types test-support/detach-gate-smoke.ts`
256
+ Expected: JSON 中 `leftDetachesWithDiffHighlightInChat`、`leftDetachesWithInverseBannerInChat`、`leftDetachesOnNewStyleDraftWithoutReporter` 全为 `true`,且既有键(`leftStaysGatedOnNonEmptyLine`、`leftDetachesWhenCursorOffEmptyInputLine`、`leftDetachesOnGlyphLineWithoutFakeCursor`、`leftDetachesOnTableRowsWithoutFakeCursor`、`leftDetachesOnContentGlyphFallback` 等)保持 `true`。
257
+
258
+ - [ ] **Step 5: 把 smoke 键接进单测**
259
+
260
+ 在 `test/pty-attach-detach-gate.test.mjs` 的 `assert.equal(parsed.staleSocketEventsDoNotClearCurrent, ...)` 之前插入:
261
+
262
+ ```js
263
+ assert.equal(parsed.leftDetachesWithDiffHighlightInChat, true, "← must detach when chat-area diff inverse content sits above an empty editor line (issue #103)");
264
+ assert.equal(parsed.leftDetachesWithInverseBannerInChat, true, "← must detach when an inverse notification banner sits above an empty editor line (issue #103)");
265
+ assert.equal(parsed.leftDetachesOnNewStyleDraftWithoutReporter, true, "← must escape on an untrusted new-style draft rather than trap the user (issue #103, R1 trade-off)");
266
+ ```
267
+
268
+ - [ ] **Step 6: 跑测试与类型检查**
269
+
270
+ Run: `node --test test/pty-attach-detach-gate.test.mjs test/pty-input.test.mjs && npm run typecheck`
271
+ Expected: 全 PASS,typecheck 0 error。
272
+
273
+ - [ ] **Step 7: Commit**
274
+
275
+ ```bash
276
+ git add src/ui/pty-attach.ts test-support/detach-gate-smoke.ts test/pty-attach-detach-gate.test.mjs
277
+ git commit -m "fix(attach): stop chat-area inverse content from hijacking the ← gate (issue #103)"
278
+ ```
279
+
280
+ ---
281
+
282
+ ### Task 3: `resolveControlEndpoint` 纯函数(A6)
283
+
284
+ **Files:**
285
+ - Modify: `src/core/paths.mjs`(`controlSocketPathFor` 之后新增导出函数)
286
+ - Test: `test/socket-path.test.mjs`(末尾新增用例)
287
+
288
+ **Interfaces:**
289
+ - Produces: `resolveControlEndpoint({ envSocketPath, platform, root, viewId }) → string`。Task 5 的 reporter 接线消费它。
290
+
291
+ - [ ] **Step 1: 写失败测试**
292
+
293
+ `test/socket-path.test.mjs` 顶部 import 追加 `resolveControlEndpoint`,末尾追加:
294
+
295
+ ```js
296
+ // ---- hosted child control endpoint discovery (issue #103) -------------------
297
+
298
+ test("resolveControlEndpoint prefers the runner-injected endpoint (issue #103)", () => {
299
+ const injected = "/tmp/root/views/view_1/control.i1.sock";
300
+ assert.equal(
301
+ resolveControlEndpoint({ envSocketPath: injected, platform: "linux", root: "/tmp/root", viewId: "view_1" }),
302
+ injected,
303
+ );
304
+ const pipe = "\\\\.\\pipe\\pi-agent-board-view_1-deadbeef";
305
+ assert.equal(
306
+ resolveControlEndpoint({ envSocketPath: pipe, platform: "win32", root: "C:\\root", viewId: "view_1" }),
307
+ pipe,
308
+ "a win32 pipe name is passed through verbatim",
309
+ );
310
+ });
311
+
312
+ test("resolveControlEndpoint falls back to the stable per-view endpoint (issue #103)", () => {
313
+ const expected = controlSocketPathFor("linux", "/tmp/root", "view_abc123");
314
+ for (const envSocketPath of [undefined, null, "", " "]) {
315
+ assert.equal(
316
+ resolveControlEndpoint({ envSocketPath, platform: "linux", root: "/tmp/root", viewId: "view_abc123" }),
317
+ expected,
318
+ `env value ${JSON.stringify(envSocketPath)} must fall back to the stable endpoint`,
319
+ );
320
+ }
321
+ assert.equal(
322
+ resolveControlEndpoint({ platform: "win32", root: "C:\\root", viewId: "view_abc123" }),
323
+ controlSocketPathFor("win32", "C:\\root", "view_abc123"),
324
+ );
325
+ });
326
+ ```
327
+
328
+ - [ ] **Step 2: 跑测试确认失败**
329
+
330
+ Run: `node --test test/socket-path.test.mjs`
331
+ Expected: FAIL —— `resolveControlEndpoint is not a function`。
332
+
333
+ - [ ] **Step 3: 实现**
334
+
335
+ 在 `src/core/paths.mjs` 的 `controlSocketPath` 定义之后插入:
336
+
337
+ ```js
338
+ /**
339
+ * Endpoint a hosted child's editor-state reporter should connect to.
340
+ *
341
+ * The runner knows which endpoint it actually bound and injects it into the
342
+ * child env as AGENT_BOARD_CONTROL_SOCKET (issue #103): since #70 the owned
343
+ * runner binds a per-instance address, while the reporter kept dialing the
344
+ * stable per-view one, so it never connected and the attach gate lost its
345
+ * authoritative editor state. The injected value wins; the stable address
346
+ * remains the fallback for legacy hosts and older runners that predate the key.
347
+ * @param {{ envSocketPath?: string | null, platform: "win32"|"linux"|"darwin", root: string, viewId: string }} args
348
+ */
349
+ export function resolveControlEndpoint({ envSocketPath, platform, root, viewId }) {
350
+ const injected = typeof envSocketPath === "string" ? envSocketPath.trim() : "";
351
+ return injected.length > 0 ? injected : controlSocketPathFor(platform, root, viewId);
352
+ }
353
+ ```
354
+
355
+ - [ ] **Step 4: 跑测试确认通过**
356
+
357
+ Run: `node --test test/socket-path.test.mjs`
358
+ Expected: PASS(原有用例 + 新增 2 个)。
359
+
360
+ - [ ] **Step 5: Commit**
361
+
362
+ ```bash
363
+ git add src/core/paths.mjs test/socket-path.test.mjs
364
+ git commit -m "feat(core): resolve the hosted control endpoint from the runner env (issue #103)"
365
+ ```
366
+
367
+ ---
368
+
369
+ ### Task 4: `classifyClientHello` 协议助手(A8/A9 的前置)
370
+
371
+ **Files:**
372
+ - Create: `src/core/host-protocol.mjs`
373
+ - Test: `test/host-protocol.test.mjs`
374
+
375
+ **Interfaces:**
376
+ - Produces: `CLIENT_ID_PROBE = "probe"`、`CLIENT_ID_EDITOR_REPORTER = "editor-reporter"`、`classifyClientHello(msg) → "probe" | "editor-reporter" | "client"`。Task 6 的 runner 与 Task 5 的 reporter 各自引用常量,保证两端字符串不漂移。
377
+
378
+ - [ ] **Step 1: 写失败测试**
379
+
380
+ 新建 `test/host-protocol.test.mjs`:
381
+
382
+ ```js
383
+ import assert from "node:assert/strict";
384
+ import { test } from "node:test";
385
+ import { CLIENT_ID_EDITOR_REPORTER, CLIENT_ID_PROBE, classifyClientHello } from "../src/core/host-protocol.mjs";
386
+
387
+ test("classifyClientHello recognizes the read-only probe handshake", () => {
388
+ assert.equal(classifyClientHello({ type: "hello", clientId: CLIENT_ID_PROBE }), "probe");
389
+ });
390
+
391
+ test("classifyClientHello recognizes the resident editor reporter (issue #103)", () => {
392
+ assert.equal(classifyClientHello({ type: "hello", clientId: CLIENT_ID_EDITOR_REPORTER }), "editor-reporter");
393
+ });
394
+
395
+ test("classifyClientHello treats every other hello as a real client", () => {
396
+ assert.equal(classifyClientHello({ type: "hello", clientId: "ui-test" }), "client");
397
+ assert.equal(classifyClientHello({ type: "hello" }), "client");
398
+ assert.equal(classifyClientHello({ type: "hello", clientId: 42 }), "client");
399
+ assert.equal(classifyClientHello({}), "client");
400
+ assert.equal(classifyClientHello(null), "client");
401
+ assert.equal(classifyClientHello(undefined), "client");
402
+ });
403
+ ```
404
+
405
+ - [ ] **Step 2: 跑测试确认失败**
406
+
407
+ Run: `node --test test/host-protocol.test.mjs`
408
+ Expected: FAIL —— `Cannot find module '../src/core/host-protocol.mjs'`。
409
+
410
+ - [ ] **Step 3: 实现**
411
+
412
+ 新建 `src/core/host-protocol.mjs`:
413
+
414
+ ```js
415
+ /** Control-socket client identity helpers (issue #103).
416
+ *
417
+ * Client ids travel in the `hello` handshake. The runner uses them to keep
418
+ * bookkeeping-only connections out of `attachedClients` / `attachedEver`:
419
+ * the attach resolver's probes are read-only, and the hosted child's
420
+ * editor-state reporter is a resident connection — counting either would pin
421
+ * every host against warm-host reclaim (issue #75 / #103 §C).
422
+ */
423
+
424
+ /** Read-only endpoint probe (attach resolver); never counts as attached. */
425
+ export const CLIENT_ID_PROBE = "probe";
426
+ /** Resident editor-state reporter inside a hosted child; never counts as attached. */
427
+ export const CLIENT_ID_EDITOR_REPORTER = "editor-reporter";
428
+
429
+ /**
430
+ * @param {{ type?: string, clientId?: string } | null | undefined} msg
431
+ * @returns {"probe" | "editor-reporter" | "client"}
432
+ */
433
+ export function classifyClientHello(msg) {
434
+ const clientId = msg && typeof msg.clientId === "string" ? msg.clientId : "";
435
+ if (clientId === CLIENT_ID_PROBE) return "probe";
436
+ if (clientId === CLIENT_ID_EDITOR_REPORTER) return "editor-reporter";
437
+ return "client";
438
+ }
439
+ ```
440
+
441
+ - [ ] **Step 4: 跑测试确认通过**
442
+
443
+ Run: `node --test test/host-protocol.test.mjs`
444
+ Expected: PASS(3 个用例)。
445
+
446
+ - [ ] **Step 5: Commit**
447
+
448
+ ```bash
449
+ git add src/core/host-protocol.mjs test/host-protocol.test.mjs
450
+ git commit -m "feat(core): classify control-socket client hellos (issue #103)"
451
+ ```
452
+
453
+ ---
454
+
455
+ ### Task 5: reporter 身份握手 + 端点接线(A7)
456
+
457
+ **Files:**
458
+ - Modify: `src/core/editor-state-reporter.mjs`(`connect` 事件处理器)
459
+ - Modify: `src/index.ts`(`:12` import、`:115-123` reporter 构造)
460
+ - Test: `test/editor-state-reporter.test.mjs`(新增 1 个用例 + 修正既有 `sent.length` 断言)
461
+
462
+ **Interfaces:**
463
+ - Consumes: Task 3 的 `resolveControlEndpoint`、Task 4 的 `CLIENT_ID_EDITOR_REPORTER`。
464
+ - Produces: reporter 在每次 `connect` 成功后发送 `{ type: "hello", clientId: "editor-reporter" }`,随后才推 `editor_state`。
465
+
466
+ - [ ] **Step 1: 写失败测试**
467
+
468
+ 在 `test/editor-state-reporter.test.mjs` 末尾追加:
469
+
470
+ ```js
471
+ test("reporter identifies itself with a hello on every (re)connect (issue #103)", () => {
472
+ const sched = fakeScheduler();
473
+ const first = fakeSocket();
474
+ const second = fakeSocket();
475
+ let call = 0;
476
+ const reporter = createEditorStateReporter({
477
+ getEditorText: () => "",
478
+ connect: () => (++call === 1 ? first : second),
479
+ scheduler: sched,
480
+ intervalMs: 100,
481
+ });
482
+ reporter.start();
483
+ first.emitConnect();
484
+ assert.deepEqual(JSON.parse(first.sent[0]), { type: "hello", clientId: "editor-reporter" });
485
+ sched.fireOne(100);
486
+ assert.deepEqual(JSON.parse(first.sent[1]), { type: "editor_state", empty: true });
487
+ // A reconnect must re-announce: the runner needs the id to keep this socket
488
+ // out of attachedClients (issue #103 §C).
489
+ first.emit("close");
490
+ sched.fireOne(1100);
491
+ second.emitConnect();
492
+ assert.deepEqual(JSON.parse(second.sent[0]), { type: "hello", clientId: "editor-reporter" });
493
+ reporter.stop();
494
+ });
495
+ ```
496
+
497
+ - [ ] **Step 2: 跑测试确认失败**
498
+
499
+ Run: `node --test test/editor-state-reporter.test.mjs`
500
+ Expected: FAIL —— 新用例首帧是 `editor_state` 而非 hello。
501
+
502
+ - [ ] **Step 3: 实现 reporter 握手**
503
+
504
+ `src/core/editor-state-reporter.mjs`:在文件顶部 import 区之后加入(该文件目前无 import,直接放常量定义):
505
+
506
+ ```js
507
+ import { CLIENT_ID_EDITOR_REPORTER } from "./host-protocol.mjs";
508
+ ```
509
+
510
+ 并把 `tryConnect()` 中的 connect 处理器:
511
+
512
+ ```js
513
+ s?.on?.("connect", () => { if (socket === s) { backoffMs = 1000; startPolling(); } });
514
+ ```
515
+
516
+ 改为:
517
+
518
+ ```js
519
+ s?.on?.("connect", () => {
520
+ if (socket !== s) return;
521
+ backoffMs = 1000;
522
+ // Announce the client id before any state: the runner keeps this
523
+ // resident socket out of attachedClients/attachedEver so warm-host
524
+ // reclaim still sees an idle host (issue #103 §C).
525
+ send({ type: "hello", clientId: CLIENT_ID_EDITOR_REPORTER });
526
+ startPolling();
527
+ });
528
+ ```
529
+
530
+ 注意 `send()` 内部检查 `if (!socket) return;` —— 此时 `socket === s` 已赋值,故 hello 一定发出。
531
+
532
+ - [ ] **Step 4: 修正既有断言(hello 使帧数 +1)**
533
+
534
+ `test/editor-state-reporter.test.mjs` 中这些断言按新协议更新:
535
+
536
+ 1. `"reporter polls and sends only on text change (A1)"`:把
537
+ `assert.deepEqual(socket.sent.map((l) => JSON.parse(l)), [{ type: "editor_state", empty: true }]);`
538
+ 改为
539
+ `assert.deepEqual(socket.sent.map((l) => JSON.parse(l)), [{ type: "hello", clientId: "editor-reporter" }, { type: "editor_state", empty: true }]);`
540
+ 并把该用例后续的 `assert.equal(socket.sent.length, 2)` / `3` 各 +1(变成 3 / 4)。
541
+ 2. `"reporter stop is idempotent and ends polling (A1)"`:`assert.equal(socket.sent.length, 1)` → `2`。
542
+ 3. `"reporter retries connect with capped backoff then recovers (A2)"`:`assert.equal(socket.sent.length, 0)`(connect 前)保持 `0`;末尾 `assert.equal(socket.sent.length, 1)` → `2`。
543
+ 4. `"reporter reconnects after socket close (A2)"`:`assert.equal(first.sent.length, 1)` → `2`;`assert.equal(second.sent.length, 1)` → `2`。
544
+ 5. `"reporter survives a throwing getEditorText (A1 hardening)"`:`assert.equal(socket.sent.length, 0)` → `1`(hello 已发,editor_state 因抛错未发);末尾 `assert.equal(socket.sent.length, 1)` → `2`,且 `assert.deepEqual(JSON.parse(socket.sent[0]), ...)` → `JSON.parse(socket.sent[1])`。
545
+
546
+ - [ ] **Step 5: 跑测试确认通过**
547
+
548
+ Run: `node --test test/editor-state-reporter.test.mjs`
549
+ Expected: PASS(6 个用例)。
550
+
551
+ - [ ] **Step 6: 接线 `src/index.ts`**
552
+
553
+ 第 12 行 import 改为:
554
+
555
+ ```ts
556
+ import { controlSocketPathFor, defaultRoot, resolveControlEndpoint } from "./core/paths.mjs";
557
+ ```
558
+
559
+ reporter 构造里的 `connect` 改为:
560
+
561
+ ```ts
562
+ connect: () => createConnection(resolveControlEndpoint({
563
+ // The runner injects the endpoint it actually bound (issue #103);
564
+ // falling back to the stable per-view address keeps older runners
565
+ // and legacy hosts working.
566
+ envSocketPath: process.env.AGENT_BOARD_CONTROL_SOCKET,
567
+ platform: process.platform as "win32" | "linux" | "darwin",
568
+ root,
569
+ viewId: hostedViewId,
570
+ })),
571
+ ```
572
+
573
+ (若 `controlSocketPathFor` 因此在该文件不再被使用,从 import 中移除它。)
574
+
575
+ - [ ] **Step 7: 类型检查**
576
+
577
+ Run: `npm run typecheck`
578
+ Expected: 0 error。(若有 "unused import" 报错,删掉未使用的 `controlSocketPathFor`。)
579
+
580
+ - [ ] **Step 8: Commit**
581
+
582
+ ```bash
583
+ git add src/core/editor-state-reporter.mjs src/index.ts test/editor-state-reporter.test.mjs
584
+ git commit -m "fix(reporter): identify as editor-reporter and dial the injected endpoint (issue #103)"
585
+ ```
586
+
587
+ ---
588
+
589
+ ### Task 6: runner 反污染 + env 注入(A8/A9)
590
+
591
+ **Files:**
592
+ - Modify: `runner/pty-runner.mjs`(import、legacy spawn env `:226-236`、legacy server handler `:302-330`、legacy hello case `:340-343`、legacy shutdown `:403-408`、owned spawn env `:784-796`、owned server handler `:702-732`、owned hello case `:906-916`、owned finishHost `:558-564`)
593
+ - Modify: `test-support/fake-pty-pi.mjs`(新增可选 env 捕获)
594
+ - Test: `test/pty-runner.integration.test.mjs`(新增 1 个用例)
595
+
596
+ **Interfaces:**
597
+ - Consumes: Task 4 的 `CLIENT_ID_*` / `classifyClientHello`。
598
+ - Produces: 两处 spawn env 中的 `AGENT_BOARD_CONTROL_SOCKET`(= 该宿主实际绑定的端点);runner 端 `editorReporters: Set<Socket>`(reporter socket 不计入 `clients`、不翻 `attachedEver`)。
599
+
600
+ - [ ] **Step 1: fake pi 支持 env 捕获(供 A8 的端到端断言)**
601
+
602
+ `test-support/fake-pty-pi.mjs` 在 `FAKE_PTY_ARGV_CAPTURE_PATH` 块之后插入:
603
+
604
+ ```js
605
+ if (process.env.FAKE_PTY_ENV_CAPTURE_PATH) {
606
+ appendFileSync(process.env.FAKE_PTY_ENV_CAPTURE_PATH, `${process.env.AGENT_BOARD_CONTROL_SOCKET ?? ""}\n`);
607
+ }
608
+ ```
609
+
610
+ - [ ] **Step 2: 写失败测试**
611
+
612
+ 在 `test/pty-runner.integration.test.mjs` 的 `"probe connections leave host.json untouched; real clients flip attachedEver (CR r1 f3)"` 用例之后插入:
613
+
614
+ ```js
615
+ test("editor reporter connections leave attachedClients/attachedEver untouched (issue #103)", async () => {
616
+ const root = freshRoot();
617
+ const envCapture = join(root, "child-env.txt");
618
+ let runner;
619
+ let childPid;
620
+ try {
621
+ // NOTE: launchOwnedRunner spreads opts.config over its defaults, and the default
622
+ // env is `{ AGENT_BOARD_ALLOW_PIPE_FALLBACK: "1" }` — pass the whole env object,
623
+ // or the pipe fallback disappears on Windows.
624
+ const { runner: r, socketPath } = await launchOwnedRunner(root, "v1", "i103", {
625
+ config: { env: { AGENT_BOARD_ALLOW_PIPE_FALLBACK: "1", FAKE_PTY_ENV_CAPTURE_PATH: envCapture } },
626
+ });
627
+ runner = r;
628
+ const host = await waitFor(() => {
629
+ const h = readHost(root, "v1");
630
+ return h?.state === "alive" && h?.readyAt != null && h?.childPid ? h : false;
631
+ });
632
+ childPid = host.childPid;
633
+
634
+ // The runner must hand the child the endpoint it actually bound (#103 §B).
635
+ const injected = await waitFor(() => {
636
+ try {
637
+ return readFileSync(envCapture, "utf8").trim() || false;
638
+ } catch {
639
+ return false;
640
+ }
641
+ }, 5000);
642
+ assert.equal(injected, socketPath, "the child env must carry the per-instance control endpoint");
643
+
644
+ // Resident reporter: identity hello, then editor_state keeps flowing.
645
+ const reporter = createConnection(socketPath);
646
+ reporter.on("error", () => {});
647
+ await once(reporter, "connect");
648
+ const reporterMessages = [];
649
+ let buf = "";
650
+ reporter.on("data", (chunk) => {
651
+ buf += chunk.toString();
652
+ const lines = buf.split("\n");
653
+ buf = lines.pop() ?? "";
654
+ for (const line of lines) if (line.trim()) reporterMessages.push(JSON.parse(line));
655
+ });
656
+ reporter.write(JSON.stringify({ type: "hello", clientId: "editor-reporter" }) + "\n");
657
+ await waitFor(() => reporterMessages.find((m) => m.type === "hello"));
658
+
659
+ const afterReporter = await waitFor(() => {
660
+ const h = readHost(root, "v1");
661
+ return h && h.attachedClients === 0 && h.attachedEver !== true ? h : false;
662
+ }, 3000);
663
+ assert.equal(afterReporter.attachedClients, 0, "a resident reporter must not count as an attached client");
664
+ assert.notEqual(afterReporter.attachedEver, true, "a resident reporter must not mark the host attached");
665
+ reporter.write(JSON.stringify({ type: "editor_state", empty: true }) + "\n");
666
+
667
+ // A real UI client still counts, and detaching it releases the host even
668
+ // though the reporter stays connected (the warm-host reclaim guard).
669
+ const client = createConnection(socketPath);
670
+ client.on("error", () => {});
671
+ await once(client, "connect");
672
+ client.write(JSON.stringify({ type: "hello", clientId: "ui-test" }) + "\n");
673
+ const counted = await waitFor(() => {
674
+ const h = readHost(root, "v1");
675
+ return h && h.attachedClients === 1 ? h : false;
676
+ }, 3000);
677
+ assert.equal(counted.attachedClients, 1, "a real client still counts as attached");
678
+ client.destroy();
679
+ const released = await waitFor(() => {
680
+ const h = readHost(root, "v1");
681
+ return h && h.attachedClients === 0 ? h : false;
682
+ }, 3000);
683
+ assert.equal(released.attachedClients, 0, "reporter-only host must read as detached for warm-host reclaim");
684
+ reporter.destroy();
685
+
686
+ // Cleanup stays on the tested path: natural child exit.
687
+ const exitClient = createConnection(socketPath);
688
+ exitClient.on("error", () => {});
689
+ await once(exitClient, "connect");
690
+ send(exitClient, { type: "input", data: "exit\r" });
691
+ await waitForExit(runner, 5000);
692
+ exitClient.destroy();
693
+ } finally {
694
+ try { runner?.kill("SIGKILL"); } catch {}
695
+ if (childPid) { try { process.kill(childPid, "SIGKILL"); } catch {} }
696
+ await new Promise((r) => setTimeout(r, 50));
697
+ rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
698
+ }
699
+ });
700
+ ```
701
+
702
+ `launchOwnedRunner(root, viewId, instanceId, { config })` 会把 `config` 键覆盖进 host-config(定义见 `test/pty-runner.integration.test.mjs:592-626`),断言本身不依赖其它覆盖项。
703
+
704
+ - [ ] **Step 3: 跑测试确认失败**
705
+
706
+ Run: `node --test test/pty-runner.integration.test.mjs`
707
+ Expected: FAIL —— `injected` 为空(env 未注入)、或 `attachedClients` 为 1(reporter 被计数)。
708
+
709
+ - [ ] **Step 4: 实现 runner 改动**
710
+
711
+ `runner/pty-runner.mjs`:
712
+
713
+ (a) 顶部 import 区新增:
714
+
715
+ ```js
716
+ import { classifyClientHello } from "../src/core/host-protocol.mjs";
717
+ ```
718
+
719
+ (b) **legacy spawn env**(`:226-236` 的 `const env = {...}`)在 `AGENT_BOARD_HOSTED: "pty",` 之后插入一行:
720
+
721
+ ```js
722
+ // The endpoint this host actually bound: the child's editor-state reporter
723
+ // dials it instead of guessing the stable per-view address (issue #103).
724
+ AGENT_BOARD_CONTROL_SOCKET: socketPath,
725
+ ```
726
+
727
+ (c) **owned spawn env**(`:784-796`,同样结构)插入同样的 `AGENT_BOARD_CONTROL_SOCKET: socketPath,`。
728
+
729
+ (d) **legacy 客户端集合**:`:123` 的 `const clients = new Set();` 之后新增:
730
+
731
+ ```js
732
+ /** Resident editor-state reporters: connected but never "attached" (#103). */
733
+ const editorReporters = new Set();
734
+ ```
735
+
736
+ (e) **legacy server handler**(`:302-330`):
737
+
738
+ - 删除 `update({ attachedEver: true });`(连接即翻是 #103 §C 的污染源);
739
+ - `close` 与 `error` 两个处理器里,在 `clients.delete(socket);` 之后各加一行 `editorReporters.delete(socket);`。
740
+
741
+ (f) **legacy hello case**(`:340-343`)替换为:
742
+
743
+ ```js
744
+ case "hello": {
745
+ // Bookkeeping-only clients must never pin the host against warm-host
746
+ // reclaim (issue #103 §C): probes are read-only, the editor reporter
747
+ // is resident. A reporter socket leaves `clients` (the attachedClients
748
+ // source) but stays writable so editor_state keeps flowing.
749
+ const kind = classifyClientHello(msg);
750
+ if (kind === "client") update({ attachedEver: true });
751
+ if (kind === "editor-reporter") {
752
+ clients.delete(socket);
753
+ editorReporters.add(socket);
754
+ update();
755
+ }
756
+ send(socket, { type: "hello", status: host, editorEmpty });
757
+ break;
758
+ }
759
+ ```
760
+
761
+ (g) **legacy shutdown**(`:403-408` 的 `for (const client of clients) { ... client.end(); }`)之后新增:
762
+
763
+ ```js
764
+ for (const reporter of editorReporters) {
765
+ try { reporter.end(); } catch { /* best effort */ }
766
+ }
767
+ editorReporters.clear();
768
+ ```
769
+
770
+ (h) **owned 客户端集合**:`:449` 的 `const clients = new Set();` 之后新增同样的 `editorReporters` 集合定义(含注释)。
771
+
772
+ (i) **owned server handler**(`:702-732`):
773
+
774
+ - `socket.on("close")` 与 `socket.on("error")` 中,在 `terminalSubscriptions.delete(socket);` 之后各加 `editorReporters.delete(socket);`。
775
+
776
+ (j) **owned hello case**(`:906-916`)替换为:
777
+
778
+ ```js
779
+ case "hello": {
780
+ // Probe and reporter sockets are bookkeeping-only: neither may flip
781
+ // attachedEver nor keep attachedClients non-zero, or warm-host reclaim
782
+ // never fires and hosts leak (issue #103 §C).
783
+ const kind = classifyClientHello(msg);
784
+ if (kind === "probe") {
785
+ socket.markProbe?.();
786
+ } else if (kind === "editor-reporter") {
787
+ clients.delete(socket);
788
+ terminalSubscriptions.delete(socket);
789
+ editorReporters.add(socket);
790
+ ownedUpdate((cur) => ({ ...cur }));
791
+ } else {
792
+ ownedUpdate((cur) => ({ ...cur, attachedEver: true }));
793
+ }
794
+ send(socket, { type: "hello", status: host, editorEmpty });
795
+ break;
796
+ }
797
+ ```
798
+
799
+ (k) **owned finishHost**(`:558-564` 的 `for (const c of clients) { ... } clients.clear();`)之后新增:
800
+
801
+ ```js
802
+ for (const reporter of editorReporters) {
803
+ try { reporter.destroy(); } catch { /* best effort */ }
804
+ }
805
+ editorReporters.clear();
806
+ ```
807
+
808
+ 注:probe clientId 的比较改由 `classifyClientHello` 统一,`CLIENT_ID_PROBE` 常量与 `"probe"` 字面量语义一致,故既有 probe 集成测试必须继续通过。
809
+
810
+ - [ ] **Step 5: 确认 legacy `attachedEver` 没有生产消费方**
811
+
812
+ Run: `grep -rn "attachedEver" src runner | grep -v node_modules`
813
+ Expected: 只有 `src/core/types.mjs:144`(类型注释)与 `runner/pty-runner.mjs:159`(初始化)、`:304`(legacy connect)、`:914`(owned hello)—— **没有任何生产代码读取它**,读取方只有测试断言。
814
+ Action: 若 grep 出现计划外的读取方,停下来报告,不要继续推迟 legacy 的翻转。
815
+
816
+ - [ ] **Step 6: 跑集成测试与相关单测**
817
+
818
+ Run: `node --test test/pty-runner.integration.test.mjs test/host-protocol.test.mjs test/warm-host-sweeper.test.mjs test/warm-host-sweep.integration.test.mjs`
819
+ Expected: 全 PASS —— 新用例的三个 `attachedClients`/`attachedEver` 断言成立,且既有 probe 用例(`"probe connections leave host.json untouched; real clients flip attachedEver"`)仍然通过。
820
+
821
+ - [ ] **Step 7: Commit**
822
+
823
+ ```bash
824
+ git add runner/pty-runner.mjs test-support/fake-pty-pi.mjs test/pty-runner.integration.test.mjs
825
+ git commit -m "fix(host): keep the editor reporter out of attachedClients (issue #103)"
826
+ ```
827
+
828
+ ---
829
+
830
+ ### Task 7: 全量验证与验收对账(A10 + U1–U4 准备)
831
+
832
+ **Files:**
833
+ - 无代码改动(只跑门禁 + 记录)
834
+
835
+ **Interfaces:**
836
+ - Consumes: Task 1–6 的全部产物。
837
+ - Produces: 验收矩阵的执行记录(给 CR / issue 评论用)。
838
+
839
+ - [ ] **Step 1: 静态 + 全量测试**
840
+
841
+ Run: `npm run typecheck && npm test`
842
+ Expected: typecheck 0 error;全量测试 0 fail(若出现与本改动无关的既有 flaky,按 issue #95/#121 的已知不稳定性记录,并单独重跑该文件确认)。
843
+
844
+ - [ ] **Step 2: smoke 复跑**
845
+
846
+ Run: `node --experimental-transform-types test-support/detach-gate-smoke.ts`
847
+ Expected: 全键 `true`。
848
+
849
+ - [ ] **Step 3: 覆盖率门禁**
850
+
851
+ Run: `npm run test:coverage`
852
+ Expected: 通过(若覆盖率阈值因新增文件未覆盖而失败,补测对应纯函数的边界用例,而不是放宽阈值)。
853
+
854
+ - [ ] **Step 4: 记录验收对账**
855
+
856
+ 把 A1–A10 的实际命令与结果、以及 U1–U4 的"待实机执行"状态写进 issue #103 评论(U 项在合并前无法自动执行时保留 `pending`,不得宣称全部验收完成)。
857
+
858
+ - [ ] **Step 5: Commit(仅在产生了文档改动时)**
859
+
860
+ ```bash
861
+ git add <changed-docs>
862
+ git commit -m "docs: record issue #103 acceptance evidence"
863
+ ```
864
+
865
+ ## 验收矩阵 → Task 映射
866
+
867
+ | 验收 ID | Task |
868
+ |---|---|
869
+ | A1 | Task 1 |
870
+ | A2 / A3 / A4 / A5 | Task 2(smoke O1/O2/O3 + 既有场景回归) |
871
+ | A6 | Task 3 |
872
+ | A7 | Task 5 |
873
+ | A8 / A9 | Task 6 |
874
+ | A10 | Task 7 |
875
+ | U1–U4 | Task 7 Step 4 记录,合并前由用户在真机执行 |