@zhuxixi/pi-agent-board 0.7.0 → 0.9.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 (70) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +16 -12
  3. package/VERIFY.md +2 -1
  4. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
  5. package/docs/RELEASE.md +148 -0
  6. package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
  7. package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
  8. package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
  9. package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
  10. package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
  11. package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
  12. package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
  13. package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
  14. package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
  15. package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
  16. package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
  17. package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
  18. package/docs/superpowers/plans/2026-09-23-a5-mid-stream-assertion.md +25 -0
  19. package/docs/superpowers/plans/2026-09-23-cwd-browse-tab-drill.md +393 -0
  20. package/docs/superpowers/plans/2026-09-23-terminal-query-forwarding.md +51 -0
  21. package/docs/superpowers/plans/2026-09-23-unconditional-detach-key.md +58 -0
  22. package/docs/superpowers/plans/2026-09-24-control-reconcile-flake.md +698 -0
  23. package/docs/superpowers/plans/2026-09-24-probe-attached-clients.md +605 -0
  24. package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
  25. package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
  26. package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
  27. package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
  28. package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
  29. package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
  30. package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
  31. package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
  32. package/docs/superpowers/specs/2026-09-23-a5-mid-stream-assertion-design.md +43 -0
  33. package/docs/superpowers/specs/2026-09-23-cwd-browse-tab-drill-design.md +95 -0
  34. package/docs/superpowers/specs/2026-09-23-terminal-query-forwarding-design.md +63 -0
  35. package/docs/superpowers/specs/2026-09-23-unconditional-detach-key-design.md +57 -0
  36. package/docs/superpowers/specs/2026-09-24-control-reconcile-flake-design.md +98 -0
  37. package/docs/superpowers/specs/2026-09-24-probe-attached-clients-design.md +190 -0
  38. package/package.json +3 -2
  39. package/runner/job-runner.mjs +8 -7
  40. package/runner/pty-runner.mjs +633 -31
  41. package/runner/state-coordinator.mjs +43 -17
  42. package/runner/state-runner.mjs +6 -5
  43. package/scripts/release.mjs +446 -0
  44. package/scripts/run-perf-gate.mjs +40 -0
  45. package/src/commands/agent-board.ts +8 -8
  46. package/src/commands/attach-flow.ts +5 -5
  47. package/src/core/control-protocol.mjs +482 -0
  48. package/src/core/editor-state-reporter.mjs +11 -1
  49. package/src/core/foreground-preview-cache.mjs +117 -0
  50. package/src/core/host-protocol.mjs +62 -0
  51. package/src/core/launch-options.mjs +37 -0
  52. package/src/core/locks.mjs +68 -14
  53. package/src/core/paths.mjs +35 -3
  54. package/src/core/pid.mjs +32 -1
  55. package/src/core/pty-attach-jiggle-controller.mjs +27 -3
  56. package/src/core/pty-attach-reconnect.mjs +13 -6
  57. package/src/core/pty-attach-render.mjs +20 -0
  58. package/src/core/state-commands.mjs +88 -6
  59. package/src/core/status-consistency.mjs +98 -0
  60. package/src/core/store.mjs +59 -13
  61. package/src/core/terminal-attach-client.mjs +809 -0
  62. package/src/core/terminal-attach-protocol.mjs +252 -0
  63. package/src/core/terminal-model.mjs +222 -0
  64. package/src/core/terminal-query-sequences.mjs +132 -0
  65. package/src/core/terminal-snapshot.mjs +440 -0
  66. package/src/index.ts +12 -4
  67. package/src/runtime/service.mjs +284 -16
  68. package/src/ui/dashboard.ts +61 -41
  69. package/src/ui/pty-attach.ts +318 -72
  70. package/src/core/pty-input.mjs +0 -47
@@ -0,0 +1,605 @@
1
+ # probe 不得计入 attachedClients(issue #130)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:** 让 probe 连接在握手后立刻退出 `clients`(`attachedClients` 的唯一来源),并把这个记账策略收拢成一个纯函数,使两条 runner 路径不再漂移。
6
+
7
+ **Architecture:** 新增纯函数 `helloBookkeeping(kind)` 到 `src/core/host-protocol.mjs`,把「哪些 socket 计入 `attachedClients`」「要不要写 host.json」变成一张显式决策表;`runner/pty-runner.mjs` 的 legacy / owned 两条 hello 分支改为只消费该描述符、只做副作用。probe 在 hello 时移出 `clients` 与 `terminalSubscriptions`,并且**不**写 host.json(解析期每 150ms 一轮 probe,写一次就是写放大);内存计数在 hello 后立刻正确,下一次心跳(≤1s)落盘。
8
+
9
+ **Tech Stack:** Node.js ESM(`.mjs`)、`node:test`、`node:assert/strict`。无编译步骤;`npm run typecheck` 是 `tsc --noEmit` 对 `.ts`/JSDoc 的检查。
10
+
11
+ ## Global Constraints
12
+
13
+ - **工作根**:`WT=/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-130-probe-attached-clients`。所有读写用 `$WT/...` 绝对路径;git 用 `git -C "$WT"`。**不要在会话 cwd(主 checkout)里改任何文件**。
14
+ - **不变量**:`attachedClients` 的来源永远是 `clients.size`(legacy `:256`、owned `:611`),不得新增第二个计数来源。
15
+ - **判定逻辑收口**:`if (kind === "probe")` 这类判定只允许出现在 `helloBookkeeping` 内;两条 hello 分支不得再出现身份字符串比较(只消费描述符字段)。
16
+ - **非目标(不得顺手改)**:owned close/error 的 probe 写抑制(`probeSockets`/`markProbe`)、legacy close 的无条件 `update()`、`attachedEver` 语义、`clients` 的广播角色、warm-host 回收阈值。
17
+ - **注释即契约**:本 issue 的根因之一是注释声称 probe 不计数而实现计数。任何被改动的分支,其注释必须与实现一致。
18
+ - **提交**:英文 conventional commits;`git add <file>` 逐文件 stage,**禁止 `git add -A`**。
19
+ - **测试基线**:`main@3dad7f9` 的 `npm test` = **1047 tests / 1044 pass / 0 fail / 3 skipped**(30s,exit 0)。最终验收必须对比「无新增失败」。
20
+ - **平台**:本改动不引入 OS 分支;测试走既有跨平台基建(`P.hostEndpointPathFor` / `P.controlSocketPath`)。
21
+
22
+ ## 验收 ID 追溯总表
23
+
24
+ | 验收 ID(spec §3) | 落点任务 |
25
+ |---|---|
26
+ | A1 hello 记账策略纯函数(unit) | Task 1 |
27
+ | A3 legacy:probe 在连时不计入(integration) | Task 2 |
28
+ | A4 判别性:probe + 真实 client 并存计数恰为 1 | Task 2、Task 3 |
29
+ | A5 错值在一个心跳内自愈 | Task 2、Task 3 |
30
+ | A6 probe 应答零回归(真实 `probeHost` 判 ready) | Task 2、Task 3 |
31
+ | A7 既有 probe/reporter 语义零回归 | Task 2、Task 3、Task 4 |
32
+ | A2 owned:probe 在连时不计入(integration) | Task 3 |
33
+ | A8 静态 + 全量对比基线 | Task 4 |
34
+
35
+ 端到端组合证据(spec §3):A2/A3 + `test/warm-host-sweeper.test.mjs` A2 + `test/warm-host-sweep.integration.test.mjs` A6 ⇒ probe 不再延迟 warm-host 回收。
36
+
37
+ ---
38
+
39
+ ### Task 1: `helloBookkeeping` 纯函数 + 决策表单测(A1)
40
+
41
+ **Files:**
42
+ - Modify: `src/core/host-protocol.mjs`(新增导出函数 + 模块头注释对齐)
43
+ - Test: `test/host-protocol.test.mjs`(新增 4 个用例)
44
+
45
+ **Interfaces:**
46
+ - Consumes: 无(本任务是最底层,只有既有 `classifyClientHello`)
47
+ - Produces: `helloBookkeeping(kind: "probe" | "editor-reporter" | "client") → { keepInClients: boolean, registerReporter: boolean, flipAttachedEver: boolean, persist: boolean, suppressCloseWrite: boolean }`。Task 2 / Task 3 按这些字段名消费,**不得改名**。
48
+
49
+ - [ ] **Step 1: 写失败测试**
50
+
51
+ 在 `$WT/test/host-protocol.test.mjs` 末尾追加(该文件已 `import { CLIENT_ID_EDITOR_REPORTER, CLIENT_ID_PROBE, classifyClientHello }`,需把 `helloBookkeeping` 加入同一 import):
52
+
53
+ ```js
54
+ test("helloBookkeeping keeps a probe out of clients and out of host.json (issue #130)", () => {
55
+ assert.deepEqual(helloBookkeeping("probe"), {
56
+ keepInClients: false,
57
+ registerReporter: false,
58
+ flipAttachedEver: false,
59
+ persist: false,
60
+ suppressCloseWrite: true,
61
+ });
62
+ });
63
+
64
+ test("helloBookkeeping keeps the resident reporter bookkeeping-only (issue #103)", () => {
65
+ assert.deepEqual(helloBookkeeping("editor-reporter"), {
66
+ keepInClients: false,
67
+ registerReporter: true,
68
+ flipAttachedEver: false,
69
+ persist: true,
70
+ suppressCloseWrite: false,
71
+ });
72
+ });
73
+
74
+ test("helloBookkeeping counts a real client as a full attachment", () => {
75
+ assert.deepEqual(helloBookkeeping("client"), {
76
+ keepInClients: true,
77
+ registerReporter: false,
78
+ flipAttachedEver: true,
79
+ persist: true,
80
+ suppressCloseWrite: false,
81
+ });
82
+ });
83
+
84
+ test("helloBookkeeping is total over classifyClientHello and defaults to the conservative client row", () => {
85
+ // Every kind the handshake can produce must have a row — and an unknown kind
86
+ // must fall back to counting the socket, never to silently dropping it.
87
+ const produced = [
88
+ classifyClientHello({ type: "hello", clientId: CLIENT_ID_PROBE }),
89
+ classifyClientHello({ type: "hello", clientId: CLIENT_ID_EDITOR_REPORTER }),
90
+ classifyClientHello({ type: "hello" }),
91
+ ];
92
+ for (const kind of produced) {
93
+ const book = helloBookkeeping(kind);
94
+ assert.equal(typeof book.keepInClients, "boolean", `missing policy row for ${kind}`);
95
+ }
96
+ assert.equal(helloBookkeeping("probe").keepInClients, false, "the probe row is the one that must not count");
97
+ assert.deepEqual(helloBookkeeping(undefined), helloBookkeeping("client"), "unknown kinds count as clients (conservative)");
98
+ });
99
+ ```
100
+
101
+ - [ ] **Step 2: 跑测试确认失败**
102
+
103
+ Run: `cd "$WT" && node --test test/host-protocol.test.mjs`
104
+ Expected: FAIL — `SyntaxError: The requested module '../src/core/host-protocol.mjs' does not provide an export named 'helloBookkeeping'`
105
+
106
+ - [ ] **Step 3: 最小实现**
107
+
108
+ 在 `$WT/src/core/host-protocol.mjs` 的 `classifyClientHello` 之后追加:
109
+
110
+ ```js
111
+ /**
112
+ * Socket bookkeeping implied by a hello classification (issue #103 §C / #130).
113
+ *
114
+ * `probe` and `editor-reporter` are bookkeeping-only: neither may keep
115
+ * `attachedClients` — whose sole source is the runner's `clients` set —
116
+ * non-zero, or warm-host reclaim never fires and hosts leak (issue #75). Both
117
+ * therefore leave `clients` at hello time, while their socket stays writable so
118
+ * probe replies and editor_state keep flowing.
119
+ *
120
+ * `persist: false` for probes is deliberate: the attach resolver probes every
121
+ * HOST_PROBE_RETRY_MS (see host-probe.mjs), so one host.json write per probe
122
+ * would amplify fenced writes — the exact cost the runner's probe-socket write
123
+ * suppression exists to avoid. Dropping the socket from `clients` already makes
124
+ * the in-memory count right, and the next heartbeat (<=1s) persists it.
125
+ *
126
+ * `suppressCloseWrite: true` is the other half of that suppression: close is the
127
+ * wrong moment to flush a record a probe never changed.
128
+ *
129
+ * An unrecognized kind falls back to the real-client row on purpose — counting
130
+ * an unknown connection is the conservative direction (it can only delay
131
+ * reclaim, never risk killing a host with a live client).
132
+ *
133
+ * @param {"probe" | "editor-reporter" | "client"} kind
134
+ * @returns {{ keepInClients: boolean, registerReporter: boolean,
135
+ * flipAttachedEver: boolean, persist: boolean, suppressCloseWrite: boolean }}
136
+ */
137
+ export function helloBookkeeping(kind) {
138
+ switch (kind) {
139
+ case "probe":
140
+ return { keepInClients: false, registerReporter: false, flipAttachedEver: false, persist: false, suppressCloseWrite: true };
141
+ case "editor-reporter":
142
+ return { keepInClients: false, registerReporter: true, flipAttachedEver: false, persist: true, suppressCloseWrite: false };
143
+ default:
144
+ return { keepInClients: true, registerReporter: false, flipAttachedEver: true, persist: true, suppressCloseWrite: false };
145
+ }
146
+ }
147
+ ```
148
+
149
+ 同时把该文件模块头注释的第 3-6 行从「keep bookkeeping-only connections out of `attachedClients` / `attachedEver`」扩写为指向 `helloBookkeeping` 的显式契约(避免再次出现「注释声称、实现不做」):
150
+
151
+ ```js
152
+ * Client ids travel in the `hello` handshake. `helloBookkeeping` turns the
153
+ * classification into the explicit socket policy: probes and the resident
154
+ * editor-state reporter stay out of `attachedClients` / `attachedEver`, and
155
+ * probes additionally never trigger a host.json write — counting or writing
156
+ * either would pin every host against warm-host reclaim (issue #75 / #103 §C /
157
+ * #130).
158
+ ```
159
+
160
+ - [ ] **Step 4: 跑测试确认通过**
161
+
162
+ Run: `cd "$WT" && node --test test/host-protocol.test.mjs`
163
+ Expected: PASS — 7 tests(既有 3 + 新增 4),0 fail
164
+
165
+ - [ ] **Step 5: 提交**
166
+
167
+ ```bash
168
+ git -C "$WT" add src/core/host-protocol.mjs test/host-protocol.test.mjs
169
+ git -C "$WT" commit -m "feat(core): explicit hello socket policy, probes leave attachedClients (issue #130)"
170
+ ```
171
+
172
+ ---
173
+
174
+ ### Task 2: legacy 路径接线 + integration 覆盖(A3 / A4 / A5 / A6 / A7)
175
+
176
+ **Files:**
177
+ - Modify: `runner/pty-runner.mjs:422-437`(legacy `handleClientLine` 的 `case "hello"`)+ `:40` import
178
+ - Test: `test/pty-runner.integration.test.mjs`(在 `:1389` 之后新增一个用例)
179
+
180
+ **Interfaces:**
181
+ - Consumes: `helloBookkeeping(kind)`(Task 1 的精确签名与字段名)
182
+ - Produces: legacy hello 分支的记账行为——probe 在 hello 后不在 `clients` 内、不写 host.json;reporter 行为不变
183
+
184
+ - [ ] **Step 1: 写失败测试**
185
+
186
+ 在 `$WT/test/pty-runner.integration.test.mjs` 第 1389 行(`test("legacy runner keeps the editor reporter and probes out of attachedClients/attachedEver (issue #103)")` 的收尾 `});`)之后、`// ---- child-exit error attribution (issue #90) ----` 之前插入。该用例复用文件内既有 helper(`freshRoot` / `waitFor` / `hostReady` / `readHost` / `send` / `waitForExit` / `stopRunner` / `reapChild`)。
187
+
188
+ 先在文件头部 import 区加入真实探针:
189
+
190
+ ```js
191
+ import { probeHost } from "../src/core/host-probe.mjs";
192
+ ```
193
+
194
+ 再加用例:
195
+
196
+ ```js
197
+ test("legacy runner: a connected probe never inflates attachedClients (issue #130)", async () => {
198
+ const root = freshRoot();
199
+ let runner;
200
+ let childPid;
201
+ try {
202
+ // Legacy host: host-config WITHOUT `instanceId`, so `main()` dispatches
203
+ // `legacyMain()` and binds the stable control.sock endpoint.
204
+ const meta = createView(root, { id: "v1", name: "legacy-probe-count", cwd: process.cwd() });
205
+ const configPath = P.hostConfigPath(root, "v1");
206
+ atomicWriteJson(configPath, {
207
+ root,
208
+ viewId: "v1",
209
+ sessionFile: meta.sessionFile,
210
+ cwd: process.cwd(),
211
+ initialPrompt: null,
212
+ piCommand: process.execPath,
213
+ piArgsPrefix: [resolve("test-support/fake-pty-pi.mjs")],
214
+ model: null,
215
+ tools: null,
216
+ env: { AGENT_BOARD_ALLOW_PIPE_FALLBACK: "1" },
217
+ cols: 80,
218
+ rows: 24,
219
+ });
220
+ runner = spawn(process.execPath, [resolve("runner/pty-runner.mjs"), configPath], { stdio: ["ignore", "pipe", "pipe"] });
221
+ await waitFor(() => hostReady(root, "v1"));
222
+ childPid = readHost(root, "v1")?.childPid ?? null;
223
+ const socketPath = P.controlSocketPath(root, "v1");
224
+ const revBefore = readHost(root, "v1").revision;
225
+
226
+ // A probe that STAYS connected: the resolver's real pattern is
227
+ // connect → probe hello → destroy, but the count must already be clean
228
+ // after the hello — before the close is observed.
229
+ const probe = createConnection(socketPath);
230
+ probe.on("error", () => {});
231
+ await once(probe, "connect");
232
+ const probeMessages = [];
233
+ let probeBuf = "";
234
+ probe.on("data", (chunk) => {
235
+ probeBuf += chunk.toString();
236
+ const lines = probeBuf.split("\n");
237
+ probeBuf = lines.pop() ?? "";
238
+ for (const line of lines) if (line.trim()) probeMessages.push(JSON.parse(line));
239
+ });
240
+ probe.write(JSON.stringify({ type: "hello", clientId: "probe", wantOutput: false }) + "\n");
241
+ // Two hellos = the unsolicited one sent on connect + the reply to ours,
242
+ // which is sent AFTER classification ran — so the count is settled here.
243
+ await waitFor(() => probeMessages.filter((m) => m.type === "hello").length >= 2, 5000);
244
+
245
+ // A5/A3: cross at least one heartbeat write while the probe stays
246
+ // connected. That is exactly the write that used to persist 1.
247
+ const afterHeartbeat = await waitFor(() => {
248
+ const h = readHost(root, "v1");
249
+ return h && h.revision > revBefore ? h : false;
250
+ }, 5000);
251
+ assert.equal(afterHeartbeat.attachedClients, 0, "a connected probe must not be counted as attached");
252
+ assert.notEqual(afterHeartbeat.attachedEver, true, "a probe must never mark the host attached");
253
+
254
+ // A4: a real client alongside the probe must count exactly 1 — a probe
255
+ // still sitting in `clients` would make this 2.
256
+ const client = createConnection(socketPath);
257
+ client.on("error", () => {});
258
+ await once(client, "connect");
259
+ client.write(JSON.stringify({ type: "hello", clientId: "ui-test" }) + "\n");
260
+ const counted = await waitFor(() => {
261
+ const h = readHost(root, "v1");
262
+ return h && h.attachedClients === 1 ? h : false;
263
+ }, 3000);
264
+ assert.equal(counted.attachedClients, 1, "exactly one attached client — a counted probe would make this 2");
265
+ assert.equal(counted.attachedEver, true, "a real client still flips attachedEver");
266
+ client.destroy();
267
+ const released = await waitFor(() => {
268
+ const h = readHost(root, "v1");
269
+ return h && h.attachedClients === 0 ? h : false;
270
+ }, 3000);
271
+ assert.equal(released.attachedClients, 0, "probe-only host must read as detached for warm-host reclaim");
272
+
273
+ // A5 again, driven by the probe's own close: the persisted record must
274
+ // still read 0 after the probe goes away and a heartbeat lands.
275
+ const revAfterClient = readHost(root, "v1").revision;
276
+ probe.destroy();
277
+ const settled = await waitFor(() => {
278
+ const h = readHost(root, "v1");
279
+ return h && h.revision > revAfterClient ? h : false;
280
+ }, 5000);
281
+ assert.equal(settled.attachedClients, 0, "the record must stay at 0 once the probe is gone");
282
+
283
+ // A6: the real resolver probe still classifies this legacy host as ready
284
+ // (no expectedInstanceId → the legacy ready fallback).
285
+ const probeResult = await probeHost(socketPath, { expectedViewId: "v1", expectedInstanceId: null });
286
+ assert.equal(probeResult.classification, "ready", "the real resolver probe must still classify the host ready");
287
+
288
+ // Cleanup stays on the tested path: natural child exit.
289
+ const exitClient = createConnection(socketPath);
290
+ exitClient.on("error", () => {});
291
+ await once(exitClient, "connect");
292
+ send(exitClient, { type: "input", data: "exit\r" });
293
+ await waitForExit(runner, 5000);
294
+ exitClient.destroy();
295
+ } finally {
296
+ await stopRunner(runner);
297
+ reapChild(root, "v1");
298
+ await new Promise((r) => setTimeout(r, 50));
299
+ rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
300
+ }
301
+ });
302
+ ```
303
+
304
+ - [ ] **Step 2: 跑测试确认失败**
305
+
306
+ Run: `cd "$WT" && node --test --test-name-pattern="a connected probe never inflates attachedClients" test/pty-runner.integration.test.mjs`
307
+ Expected: FAIL — `AssertionError: a connected probe must not be counted as attached: 1 !== 0`(修复前的真实失败模式,与 spec §1.3 的实测一致)
308
+
309
+ - [ ] **Step 3: 改 runner 的 legacy hello 分支**
310
+
311
+ 把 `$WT/runner/pty-runner.mjs:40` 的 import 改为:
312
+
313
+ ```js
314
+ import { classifyClientHello, helloBookkeeping } from "../src/core/host-protocol.mjs";
315
+ ```
316
+
317
+ 把 `:422-437` 整段替换为:
318
+
319
+ ```js
320
+ case "hello": {
321
+ // Bookkeeping-only clients must never pin the host against warm-host
322
+ // reclaim (issue #103 §C / #130): probes are read-only and transient,
323
+ // the editor reporter is resident. Both leave `clients` — the sole
324
+ // source of `attachedClients` — while their socket stays writable so
325
+ // probe replies and editor_state keep flowing. The policy lives in
326
+ // `helloBookkeeping`, so this path and the owned one cannot drift.
327
+ const book = helloBookkeeping(classifyClientHello(msg));
328
+ if (!book.keepInClients) {
329
+ clients.delete(socket);
330
+ terminalSubscriptions.delete(socket);
331
+ }
332
+ if (book.registerReporter) editorReporters.add(socket);
333
+ if (book.flipAttachedEver) update({ attachedEver: true });
334
+ else if (book.persist) update();
335
+ send(socket, { type: "hello", status: host, editorEmpty, generation: GENERATION });
336
+ break;
337
+ }
338
+ ```
339
+
340
+ > `book.suppressCloseWrite` 在 legacy 分支**不出现**:legacy 的 socket 从未被赋 `markProbe`(只有 owned 的 listen 块赋值),调用它会是恒空操作。描述符仍然导出该字段,A1 单测照旧钉住「probe 不得靠 close 写 host.json」这条路径无关的不变量。
341
+
342
+ > 注意:legacy reporter 分支原来无条件 `update()`,现在由 `book.persist === true` 驱动,行为等价;probe 分支原来什么都不做,现在**只**移出集合、不 `update()` —— 这正是本任务的行为修复。
343
+
344
+ - [ ] **Step 4: 跑测试确认通过**
345
+
346
+ Run: `cd "$WT" && node --test --test-name-pattern="a connected probe never inflates attachedClients" test/pty-runner.integration.test.mjs`
347
+ Expected: PASS
348
+
349
+ Run(A7 同文件回归): `cd "$WT" && node --test test/pty-runner.integration.test.mjs`
350
+ Expected: PASS,0 fail(含既有 `:1159` / `:1206` / `:1289` 三条 probe/reporter 用例)
351
+
352
+ - [ ] **Step 5: 提交**
353
+
354
+ ```bash
355
+ git -C "$WT" add runner/pty-runner.mjs test/pty-runner.integration.test.mjs
356
+ git -C "$WT" commit -m "fix(runner): legacy probes leave attachedClients at hello (issue #130)"
357
+ ```
358
+
359
+ ---
360
+
361
+ ### Task 3: owned 路径接线 + integration 覆盖(A2 / A4 / A5 / A6 / A7)
362
+
363
+ **Files:**
364
+ - Modify: `runner/pty-runner.mjs:1091-1108`(owned `handleClientLine` 的 `case "hello"`)+ `:864-867` 注释
365
+ - Test: `test/pty-runner.integration.test.mjs`(在 Task 2 新增用例之后再加一个,即 `:1204` 之后、原 `:1206` 之前的位置)
366
+
367
+ **Interfaces:**
368
+ - Consumes: `helloBookkeeping(kind)`(Task 1);owned 侧的 `ownedUpdate` / `socket.markProbe`(既有,不改语义)
369
+ - Produces: owned hello 分支的记账行为——probe 在 hello 后不在 `clients` 内、不写 host.json,且 close/error 仍不写
370
+
371
+ - [ ] **Step 1: 写失败测试**
372
+
373
+ 把下面这个用例插入到 `$WT/test/pty-runner.integration.test.mjs` 中**紧接**原 `test("probe connections leave host.json untouched; real clients flip attachedEver (CR r1 f3)")` 的收尾 `});` 之后(该收尾原为 `:1204`),即插在 `test("editor reporter connections leave attachedClients/attachedEver untouched (issue #103)")` 之前:
374
+
375
+ ```js
376
+ test("owned runner: a connected probe never inflates attachedClients (issue #130)", async () => {
377
+ const root = freshRoot();
378
+ let runner;
379
+ let childPid;
380
+ try {
381
+ const { runner: r, socketPath } = await launchOwnedRunner(root, "v1", "i130");
382
+ runner = r;
383
+ const host = await waitFor(() => {
384
+ const h = readHost(root, "v1");
385
+ return h?.state === "alive" && h?.readyAt != null && h?.childPid ? h : false;
386
+ });
387
+ childPid = host.childPid;
388
+ const revBefore = readHost(root, "v1").revision;
389
+
390
+ // A probe that STAYS connected. The owned path suppresses the probe's
391
+ // close-time write on purpose (the resolver probes every 150ms), so the
392
+ // count has to be right at hello time — waiting for close is not enough.
393
+ const probe = createConnection(socketPath);
394
+ probe.on("error", () => {});
395
+ await once(probe, "connect");
396
+ const probeMessages = [];
397
+ let probeBuf = "";
398
+ probe.on("data", (chunk) => {
399
+ probeBuf += chunk.toString();
400
+ const lines = probeBuf.split("\n");
401
+ probeBuf = lines.pop() ?? "";
402
+ for (const line of lines) if (line.trim()) probeMessages.push(JSON.parse(line));
403
+ });
404
+ probe.write(JSON.stringify({ type: "hello", clientId: "probe", wantOutput: false }) + "\n");
405
+ // Two hellos = the unsolicited one sent on connect + the reply to ours,
406
+ // which is sent AFTER classification ran — so the count is settled here.
407
+ await waitFor(() => probeMessages.filter((m) => m.type === "hello").length >= 2, 5000);
408
+
409
+ // A2: cross at least one heartbeat write while the probe stays connected.
410
+ // That is exactly the write that used to persist attachedClients=1 and
411
+ // then keep it wrong for a full heartbeat period (measured: ~975ms).
412
+ const afterHeartbeat = await waitFor(() => {
413
+ const h = readHost(root, "v1");
414
+ return h && h.revision > revBefore ? h : false;
415
+ }, 5000);
416
+ assert.equal(afterHeartbeat.attachedClients, 0, "a connected probe must not be counted as attached");
417
+ assert.notEqual(afterHeartbeat.attachedEver, true, "a probe must never mark the host attached");
418
+
419
+ // A4: a real client alongside the probe must count exactly 1 — a probe
420
+ // still sitting in `clients` would make this 2.
421
+ const client = createConnection(socketPath);
422
+ client.on("error", () => {});
423
+ await once(client, "connect");
424
+ client.write(JSON.stringify({ type: "hello", clientId: "ui-test" }) + "\n");
425
+ const counted = await waitFor(() => {
426
+ const h = readHost(root, "v1");
427
+ return h && h.attachedClients === 1 ? h : false;
428
+ }, 3000);
429
+ assert.equal(counted.attachedClients, 1, "exactly one attached client — a counted probe would make this 2");
430
+ assert.equal(counted.attachedEver, true, "a real client still flips attachedEver");
431
+ client.destroy();
432
+ const released = await waitFor(() => {
433
+ const h = readHost(root, "v1");
434
+ return h && h.attachedClients === 0 ? h : false;
435
+ }, 3000);
436
+ assert.equal(released.attachedClients, 0, "probe-only host must read as detached for warm-host reclaim");
437
+
438
+ // A5: the probe's own close is write-suppressed on this path, so the
439
+ // record must already be 0 and stay 0 across the next heartbeat.
440
+ const revAfterClient = readHost(root, "v1").revision;
441
+ probe.destroy();
442
+ const settled = await waitFor(() => {
443
+ const h = readHost(root, "v1");
444
+ return h && h.revision > revAfterClient ? h : false;
445
+ }, 5000);
446
+ assert.equal(settled.attachedClients, 0, "the record must stay at 0 once the probe is gone");
447
+
448
+ // A6: the real resolver probe still classifies this host as ready, and
449
+ // leaves no residue behind.
450
+ const probeResult = await probeHost(socketPath, { expectedViewId: "v1", expectedInstanceId: "i130" });
451
+ assert.equal(probeResult.classification, "ready", "the real resolver probe must still classify the host ready");
452
+ assert.equal(readHost(root, "v1").attachedEver, true, "probeHost must not disturb the record's attachment history");
453
+
454
+ // Cleanup stays on the tested path: natural child exit.
455
+ const exitClient = createConnection(socketPath);
456
+ exitClient.on("error", () => {});
457
+ await once(exitClient, "connect");
458
+ send(exitClient, { type: "input", data: "exit\r" });
459
+ await waitForExit(runner, 5000);
460
+ exitClient.destroy();
461
+ } finally {
462
+ try { runner?.kill("SIGKILL"); } catch {}
463
+ if (childPid) { try { process.kill(childPid, "SIGKILL"); } catch {} }
464
+ await new Promise((r) => setTimeout(r, 50));
465
+ rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
466
+ }
467
+ });
468
+ ```
469
+
470
+ - [ ] **Step 2: 跑测试确认失败**
471
+
472
+ Run: `cd "$WT" && node --test --test-name-pattern="owned runner: a connected probe never inflates" test/pty-runner.integration.test.mjs`
473
+ Expected: FAIL — `AssertionError: a connected probe must not be counted as attached: 1 !== 0`
474
+
475
+ - [ ] **Step 3: 改 runner 的 owned hello 分支**
476
+
477
+ 把 `$WT/runner/pty-runner.mjs:1091-1108` 整段替换为:
478
+
479
+ ```js
480
+ case "hello": {
481
+ // Same bookkeeping contract as the legacy path (issue #103 §C /
482
+ // #130): probes and the resident editor reporter are
483
+ // bookkeeping-only — neither may flip attachedEver nor keep
484
+ // attachedClients non-zero, or warm-host reclaim never fires and
485
+ // hosts leak. The policy lives in `helloBookkeeping`.
486
+ const book = helloBookkeeping(classifyClientHello(msg));
487
+ if (!book.keepInClients) {
488
+ clients.delete(socket);
489
+ terminalSubscriptions.delete(socket);
490
+ }
491
+ if (book.registerReporter) editorReporters.add(socket);
492
+ // The probe's close-time host.json refresh stays suppressed: the
493
+ // resolver probes every HOST_PROBE_RETRY_MS, so flushing there
494
+ // would amplify fenced writes (see the probeSockets comment at the
495
+ // listen block). Being out of `clients` is what keeps the count
496
+ // right — this only avoids a pointless write.
497
+ if (book.suppressCloseWrite) socket.markProbe?.();
498
+ if (book.flipAttachedEver) ownedUpdate((cur) => ({ ...cur, attachedEver: true }));
499
+ else if (book.persist) ownedUpdate((cur) => ({ ...cur }));
500
+ send(socket, { type: "hello", status: host, editorEmpty, generation: GENERATION });
501
+ break;
502
+ }
503
+ ```
504
+
505
+ 同时把 `:864-867` 的注释从
506
+
507
+ ```js
508
+ // Probe connections (clientId:"probe" hello) must not write host.json: the
509
+ // attach resolver's 150ms probe loop would amplify fenced writes and flip
510
+ // attachedEver with no client ever attached (CR round-1 finding 3).
511
+ ```
512
+
513
+ 改为
514
+
515
+ ```js
516
+ // Probe connections (clientId:"probe" hello) must not write host.json: the
517
+ // attach resolver's 150ms probe loop would amplify fenced writes and flip
518
+ // attachedEver with no client ever attached (CR round-1 finding 3). The
519
+ // "not counted" half is handled at hello time instead (#130): the socket
520
+ // leaves `clients` there, so this WeakSet only suppresses the write.
521
+ ```
522
+
523
+ - [ ] **Step 4: 跑测试确认通过**
524
+
525
+ Run: `cd "$WT" && node --test --test-name-pattern="owned runner: a connected probe never inflates" test/pty-runner.integration.test.mjs`
526
+ Expected: PASS
527
+
528
+ Run(A7 全文件回归,含任务 2 的 legacy 用例): `cd "$WT" && node --test test/pty-runner.integration.test.mjs`
529
+ Expected: PASS,0 fail
530
+
531
+ - [ ] **Step 5: 提交**
532
+
533
+ ```bash
534
+ git -C "$WT" add runner/pty-runner.mjs test/pty-runner.integration.test.mjs
535
+ git -C "$WT" commit -m "fix(runner): owned probes leave attachedClients at hello (issue #130)"
536
+ ```
537
+
538
+ ---
539
+
540
+ ### Task 4: 全量验证 + 基线对比 + 组合证据(A7 / A8)
541
+
542
+ **Files:**
543
+ - 不改代码(只跑验证;若发现回归则回到对应任务修)
544
+
545
+ **Interfaces:**
546
+ - Consumes: Task 1–3 的全部产出
547
+ - Produces: 用于 PR 描述的验收证据(每条 A 的实际命令与结果)
548
+
549
+ - [ ] **Step 1: 静态检查**
550
+
551
+ Run: `cd "$WT" && npm run typecheck`
552
+ Expected: 0 error
553
+
554
+ - [ ] **Step 2: 全量测试对比基线**
555
+
556
+ Run: `cd "$WT" && npm test`
557
+ Expected: `# tests >= 1049`(基线 1047 + 新增 6 个用例)、`# fail 0`、`# pass = tests - 3 skipped`。任何 fail 都必须定位到 Task 1–3 的某个改动并修掉,不得以「基线也有」为由放过——基线是 **0 fail**。
558
+
559
+ - [ ] **Step 3: 端到端组合证据(不新建测试,逐条实跑并记录输出)**
560
+
561
+ Run: `cd "$WT" && node --test test/warm-host-sweeper.test.mjs`
562
+ Expected: PASS,含 `A2: attachedClients>0 永不淘汰`
563
+
564
+ Run: `cd "$WT" && node --test test/warm-host-sweep.integration.test.mjs`
565
+ Expected: PASS,含 `A6: 手动 sweep 后 idle host 收到 terminate`
566
+
567
+ 组合结论(写进 PR 描述):A2/A3 证明「probe 在连时 `attachedClients` 落盘为 0」;warm-host-sweeper A2 证明「`attachedClients>0` 不淘汰」;warm-host-sweep A6 证明「`attachedClients=0` 的 idle host 收到 terminate」。三者相接即「probe 不再延迟 warm-host 回收」。
568
+
569
+ - [ ] **Step 4: 确认工作区无残留**
570
+
571
+ Run: `cd "$WT" && git status --short`
572
+ Expected: 空(除 `docs/superpowers/plans/` 下的本计划文件若尚未提交)
573
+
574
+ - [ ] **Step 5: 提交**
575
+
576
+ ```bash
577
+ git -C "$WT" add docs/superpowers/plans/2026-09-24-probe-attached-clients.md
578
+ git -C "$WT" commit -m "docs(plan): issue #130 probe accounting implementation plan"
579
+ ```
580
+
581
+ ---
582
+
583
+ ## Self-Review
584
+
585
+ **1. Spec coverage(逐节对照)**
586
+
587
+ | spec 节 | 覆盖任务 |
588
+ |---|---|
589
+ | §2.1 决策 D1(移出 `clients`、不写 host.json) | Task 1(`persist:false`)+ Task 2/3(分支只 delete、不 update) |
590
+ | §2.2 决策 D2(抽 `helloBookkeeping`) | Task 1 |
591
+ | §2.3 改动点清单 | Task 2 Step 3、Task 3 Step 3(含 `:423-426` / `:1092-1094` / `:864-867` 三处注释对齐与 `host-protocol.mjs` 模块注释) |
592
+ | §3 A1–A8 | A1→Task 1;A3/A4/A5/A6(legacy)→Task 2;A2/A4/A5/A6(owned)→Task 3;A7→Task 2/3/4;A8→Task 4 |
593
+ | §4 可测性拆分(判定只存在于纯函数) | Global Constraints「判定逻辑收口」+ Task 2/3 的分支代码只消费字段 |
594
+ | §5 非目标 | Global Constraints「非目标」 |
595
+ | §6 风险(hello 一次且权威) | Task 1 的 `default` 行注释 + Task 2/3 Step 1 的 A4 断言(probe 在连时真实 client 计 1) |
596
+
597
+ **2. Placeholder scan**:无 TBD / TODO /「类似 Task N」;每个代码步骤都给了完整代码块。
598
+
599
+ **3. Type consistency**:`helloBookkeeping` 的五个字段名(`keepInClients` / `registerReporter` / `flipAttachedEver` / `persist` / `suppressCloseWrite`)在 Task 1 定义、Task 2 与 Task 3 消费,逐字一致;测试辅助函数名(`launchOwnedRunner` / `hostReady` / `stopRunner` / `reapChild` / `waitFor` / `waitForExit` / `send` / `freshRoot`)均取自 `test/pty-runner.integration.test.mjs` 既有定义。
600
+
601
+ **4. 已知实现注意点(给 implementer)**
602
+
603
+ - Task 3 的新用例插在「原 `:1204` 之后、原 `:1206` 之前」;Task 2 的新用例插在「原 `:1389` 之后」。两个任务都改同一个测试文件,**必须按 Task 2 → Task 3 顺序执行**,否则行号锚点失效(按 `test(...)` 标题定位而非纯行号更稳)。
604
+ - `probeHost` 的 import 只在 Task 2 加一次,Task 3 复用。
605
+ - 测量脚本 `/tmp/repro-130.mjs` 与 `/tmp/measure-130.mjs` 是 issue 评论里的证据来源,**不进仓库**。