@blackbelt-technology/pi-agent-dashboard 0.5.0 → 0.5.2

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 (201) hide show
  1. package/AGENTS.md +26 -5
  2. package/README.md +49 -7
  3. package/docs/architecture.md +129 -1
  4. package/package.json +15 -15
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
  7. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +78 -8
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
  13. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  14. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  15. package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
  16. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  17. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  18. package/packages/extension/src/ask-user-tool.ts +1 -1
  19. package/packages/extension/src/bridge-context.ts +68 -4
  20. package/packages/extension/src/bridge.ts +79 -11
  21. package/packages/extension/src/command-handler.ts +95 -15
  22. package/packages/extension/src/flow-event-wiring.ts +1 -1
  23. package/packages/extension/src/multiselect-list.ts +1 -1
  24. package/packages/extension/src/pi-env.d.ts +16 -9
  25. package/packages/extension/src/prompt-expander.ts +74 -63
  26. package/packages/extension/src/provider-register.ts +16 -9
  27. package/packages/extension/src/retry-tracker.ts +123 -0
  28. package/packages/extension/src/server-launcher.ts +31 -70
  29. package/packages/extension/src/session-sync.ts +10 -1
  30. package/packages/extension/src/slash-dispatch.ts +123 -0
  31. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  32. package/packages/server/bin/pi-dashboard.mjs +84 -0
  33. package/packages/server/package.json +8 -7
  34. package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
  35. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  36. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  37. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  38. package/packages/server/src/__tests__/cli-parse.test.ts +16 -4
  39. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
  40. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  41. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  42. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  43. package/packages/server/src/__tests__/directory-service.test.ts +2 -2
  44. package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
  45. package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
  46. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
  47. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  48. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  49. package/packages/server/src/__tests__/headless-pid-registry.test.ts +316 -0
  50. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  51. package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
  52. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
  53. package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
  55. package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
  56. package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
  57. package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
  58. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
  59. package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
  60. package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
  61. package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
  62. package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
  63. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  64. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  65. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  66. package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
  67. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  68. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  69. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  70. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
  71. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  72. package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
  73. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  74. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  75. package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
  76. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  77. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
  78. package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
  79. package/packages/server/src/__tests__/recommended-routes.test.ts +3 -3
  80. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  81. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
  82. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  83. package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
  84. package/packages/server/src/auth-plugin.ts +3 -0
  85. package/packages/server/src/bootstrap-state.ts +10 -0
  86. package/packages/server/src/browser-gateway.ts +27 -10
  87. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  88. package/packages/server/src/browser-handlers/session-action-handler.ts +128 -19
  89. package/packages/server/src/changelog-fs.ts +167 -0
  90. package/packages/server/src/changelog-parser.ts +321 -0
  91. package/packages/server/src/changelog-remote.ts +134 -0
  92. package/packages/server/src/cli.ts +62 -82
  93. package/packages/server/src/config-api.ts +14 -2
  94. package/packages/server/src/directory-service.ts +106 -4
  95. package/packages/server/src/event-wiring.ts +90 -6
  96. package/packages/server/src/headless-pid-registry.ts +344 -37
  97. package/packages/server/src/legacy-pi-cleanup.ts +151 -0
  98. package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
  99. package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
  100. package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
  101. package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
  102. package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
  103. package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
  104. package/packages/server/src/model-proxy/api-key-store.ts +87 -0
  105. package/packages/server/src/model-proxy/auth-gate.ts +116 -0
  106. package/packages/server/src/model-proxy/concurrency.ts +76 -0
  107. package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
  108. package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
  109. package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
  110. package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
  111. package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
  112. package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
  113. package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
  114. package/packages/server/src/model-proxy/convert/index.ts +8 -0
  115. package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
  116. package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
  117. package/packages/server/src/model-proxy/convert/types.ts +70 -0
  118. package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
  119. package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
  120. package/packages/server/src/model-proxy/internal-registry.ts +157 -0
  121. package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
  122. package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
  123. package/packages/server/src/model-proxy/request-log.ts +53 -0
  124. package/packages/server/src/model-proxy/streamer.ts +59 -0
  125. package/packages/server/src/openspec-group-store.ts +490 -0
  126. package/packages/server/src/pending-client-correlations.ts +73 -0
  127. package/packages/server/src/pending-fork-registry.ts +24 -12
  128. package/packages/server/src/pi-core-checker.ts +77 -17
  129. package/packages/server/src/pi-core-updater.ts +16 -6
  130. package/packages/server/src/pi-dev-version-check.ts +145 -0
  131. package/packages/server/src/pi-gateway.ts +4 -0
  132. package/packages/server/src/pi-version-skew.ts +12 -4
  133. package/packages/server/src/process-manager.ts +182 -11
  134. package/packages/server/src/provider-auth-storage.ts +29 -47
  135. package/packages/server/src/provider-catalogue-cache.ts +24 -18
  136. package/packages/server/src/restart-helper.ts +17 -16
  137. package/packages/server/src/routes/bootstrap-routes.ts +37 -0
  138. package/packages/server/src/routes/jj-routes.ts +3 -0
  139. package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
  140. package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
  141. package/packages/server/src/routes/model-proxy-routes.ts +330 -0
  142. package/packages/server/src/routes/openspec-group-routes.ts +231 -0
  143. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  144. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  145. package/packages/server/src/routes/provider-auth-routes.ts +8 -1
  146. package/packages/server/src/routes/provider-routes.ts +28 -5
  147. package/packages/server/src/routes/system-routes.ts +44 -2
  148. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
  149. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
  150. package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
  151. package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
  152. package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
  153. package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
  154. package/packages/server/src/server.ts +254 -60
  155. package/packages/server/src/session-api.ts +63 -4
  156. package/packages/server/src/session-discovery.ts +1 -1
  157. package/packages/server/src/session-file-reader.ts +1 -1
  158. package/packages/server/src/spawn-register-watchdog.ts +62 -7
  159. package/packages/server/src/spawn-token.ts +20 -0
  160. package/packages/server/src/tunnel-watchdog.ts +230 -0
  161. package/packages/server/src/tunnel.ts +5 -1
  162. package/packages/shared/package.json +1 -1
  163. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
  164. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
  165. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  166. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  167. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  168. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  169. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  170. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  172. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  173. package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
  174. package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
  175. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
  176. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  177. package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
  178. package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
  179. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
  180. package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
  181. package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
  182. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  183. package/packages/shared/src/bootstrap-install.ts +1 -1
  184. package/packages/shared/src/browser-protocol.ts +70 -0
  185. package/packages/shared/src/changelog-types.ts +111 -0
  186. package/packages/shared/src/config.ts +172 -2
  187. package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
  188. package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
  189. package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
  190. package/packages/shared/src/platform/binary-lookup.ts +204 -0
  191. package/packages/shared/src/platform/node-spawn.ts +71 -26
  192. package/packages/shared/src/protocol.ts +27 -1
  193. package/packages/shared/src/recommended-extensions.ts +18 -0
  194. package/packages/shared/src/rest-api.ts +219 -1
  195. package/packages/shared/src/server-launcher.ts +277 -0
  196. package/packages/shared/src/skill-block-parser.ts +1 -1
  197. package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
  198. package/packages/shared/src/tool-registry/definitions.ts +15 -3
  199. package/packages/shared/src/types.ts +62 -0
  200. package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -53
  201. package/packages/shared/src/resolve-jiti.ts +0 -102
@@ -231,3 +231,319 @@ describe("HeadlessPidRegistry orphan cleanup", () => {
231
231
  killSpy.mockRestore();
232
232
  });
233
233
  });
234
+
235
+ // See change: spawn-correlation-token — three-tier linking.
236
+ describe("HeadlessPidRegistry: three-tier link", () => {
237
+ it("register stores the spawnToken when provided", () => {
238
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
239
+ registry.register(100, "/p", mockProcess(), "tok_abc");
240
+ // No public accessor for the entry, but linkByToken proves storage.
241
+ expect(registry.linkByToken("tok_abc", "S1")).toBe(true);
242
+ expect(registry.getPid("S1")).toBe(100);
243
+ });
244
+
245
+ it("linkByToken returns false when token does not match", () => {
246
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
247
+ registry.register(100, "/p", mockProcess(), "tok_abc");
248
+ expect(registry.linkByToken("tok_other", "S1")).toBe(false);
249
+ expect(registry.getPid("S1")).toBeUndefined();
250
+ });
251
+
252
+ it("linkByToken returns false for empty token", () => {
253
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
254
+ registry.register(100, "/p", mockProcess(), "tok_abc");
255
+ expect(registry.linkByToken("", "S1")).toBe(false);
256
+ });
257
+
258
+ it("linkByToken does not relink an already-linked entry", () => {
259
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
260
+ registry.register(100, "/p", mockProcess(), "tok_abc");
261
+ expect(registry.linkByToken("tok_abc", "S1")).toBe(true);
262
+ expect(registry.linkByToken("tok_abc", "S2")).toBe(false);
263
+ expect(registry.getPid("S1")).toBe(100);
264
+ expect(registry.getPid("S2")).toBeUndefined();
265
+ });
266
+
267
+ it("linkByPid sets sessionId on the entry with that pid", () => {
268
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
269
+ registry.register(123, "/p", mockProcess());
270
+ expect(registry.linkByPid("S1", 123)).toBe(true);
271
+ expect(registry.getPid("S1")).toBe(123);
272
+ });
273
+
274
+ it("linkByPid returns false for unknown pid", () => {
275
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
276
+ registry.register(123, "/p", mockProcess());
277
+ expect(registry.linkByPid("S1", 999)).toBe(false);
278
+ });
279
+
280
+ it("linkByPid does not relink already-linked entry", () => {
281
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
282
+ registry.register(123, "/p", mockProcess());
283
+ expect(registry.linkByPid("S1", 123)).toBe(true);
284
+ expect(registry.linkByPid("S2", 123)).toBe(false);
285
+ });
286
+
287
+ it("closes the kill-fork-kills-parent race: distinct tokens for two same-cwd spawns", () => {
288
+ // Setup: parent S1 already linked. Concurrent fork is registered.
289
+ // Without token-link, cwd-FIFO would assign the fork's sessionId to
290
+ // parent's pid. With token-link, identity is exact.
291
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
292
+ registry.register(1000, "/proj", mockProcess(), "tok_parent");
293
+ registry.register(1234, "/proj", mockProcess(), "tok_fork");
294
+
295
+ // Bridge connect order is reversed (fork's bridge connects first):
296
+ expect(registry.linkByToken("tok_fork", "S_fork")).toBe(true);
297
+ expect(registry.linkByToken("tok_parent", "S_parent")).toBe(true);
298
+
299
+ // Each session resolves to its OWN pid — no swap.
300
+ expect(registry.getPid("S_fork")).toBe(1234);
301
+ expect(registry.getPid("S_parent")).toBe(1000);
302
+ });
303
+
304
+ it("linkByPid fixes the kill-fork-kills-parent race even without tokens (legacy bridge)", () => {
305
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
306
+ registry.register(1000, "/proj", mockProcess()); // no token (legacy)
307
+ registry.register(1234, "/proj", mockProcess()); // no token (legacy)
308
+
309
+ // Bridge supplies pid in session_register — link by pid is exact.
310
+ expect(registry.linkByPid("S_fork", 1234)).toBe(true);
311
+ expect(registry.linkByPid("S_parent", 1000)).toBe(true);
312
+
313
+ expect(registry.getPid("S_fork")).toBe(1234);
314
+ expect(registry.getPid("S_parent")).toBe(1000);
315
+ });
316
+ });
317
+
318
+ // See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 6 / task 6.5).
319
+ describe("HeadlessPidRegistry: keeper mode", () => {
320
+ it("register stores keeperPid + keeperSockPath when keeperOpts provided", () => {
321
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
322
+ const proc = mockProcess();
323
+ registry.register(7777, "/proj", proc, "tok_k", {
324
+ keeperPid: 7777,
325
+ keeperSockPath: "/tmp/sid.rpc.sock",
326
+ });
327
+ // Before bridge connects, getPid (no sessionId yet) is undefined.
328
+ registry.linkByToken("tok_k", "S_keep");
329
+ // No piPid passed → falls back to entry.pid (= keeper pid).
330
+ expect(registry.getPid("S_keep")).toBe(7777);
331
+ });
332
+
333
+ it("linkByToken in keeper mode stores piPid distinct from keeperPid", () => {
334
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
335
+ registry.register(8888, "/proj", mockProcess(), "tok_keep", {
336
+ keeperPid: 8888,
337
+ keeperSockPath: "/tmp/sid.sock",
338
+ });
339
+ // Bridge connects with pi's actual PID.
340
+ expect(registry.linkByToken("tok_keep", "S_keep", 5050)).toBe(true);
341
+ // getPid prefers piPid in keeper mode.
342
+ expect(registry.getPid("S_keep")).toBe(5050);
343
+ });
344
+
345
+ it("linkByToken non-keeper mode ignores pid arg (legacy behavior)", () => {
346
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
347
+ registry.register(100, "/proj", mockProcess(), "tok");
348
+ expect(registry.linkByToken("tok", "S1", 999)).toBe(true);
349
+ // Non-keeper: piPid not stored; getPid returns entry.pid.
350
+ expect(registry.getPid("S1")).toBe(100);
351
+ });
352
+
353
+ it("writeRpc returns false when no entry for sessionId", async () => {
354
+ const writer = { writeRpcToSockPath: vi.fn(async () => true), discoverExistingKeepers: vi.fn(async () => []) };
355
+ const registry = createHeadlessPidRegistry({
356
+ pidFilePath: join(makeTempDir(), "pids.json"),
357
+ keeperManager: writer,
358
+ });
359
+ expect(await registry.writeRpc("unknown-session", "line")).toBe(false);
360
+ expect(writer.writeRpcToSockPath).not.toHaveBeenCalled();
361
+ });
362
+
363
+ it("writeRpc returns false for non-keeper entry", async () => {
364
+ const writer = { writeRpcToSockPath: vi.fn(async () => true), discoverExistingKeepers: vi.fn(async () => []) };
365
+ const registry = createHeadlessPidRegistry({
366
+ pidFilePath: join(makeTempDir(), "pids.json"),
367
+ keeperManager: writer,
368
+ });
369
+ registry.register(100, "/proj", mockProcess());
370
+ registry.linkSession("S1", "/proj");
371
+ expect(await registry.writeRpc("S1", "line")).toBe(false);
372
+ expect(writer.writeRpcToSockPath).not.toHaveBeenCalled();
373
+ });
374
+
375
+ it("writeRpc delegates to keeper writer for keeper entry", async () => {
376
+ const writer = {
377
+ writeRpcToSockPath: vi.fn(async (_p: string, _l: string) => true),
378
+ discoverExistingKeepers: vi.fn(async () => []),
379
+ };
380
+ const registry = createHeadlessPidRegistry({
381
+ pidFilePath: join(makeTempDir(), "pids.json"),
382
+ keeperManager: writer,
383
+ });
384
+ registry.register(7777, "/proj", mockProcess(), "tok", {
385
+ keeperPid: 7777,
386
+ keeperSockPath: "/tmp/x.sock",
387
+ });
388
+ registry.linkByToken("tok", "S_keep");
389
+ const ok = await registry.writeRpc("S_keep", "hello");
390
+ expect(ok).toBe(true);
391
+ expect(writer.writeRpcToSockPath).toHaveBeenCalledWith("/tmp/x.sock", "hello");
392
+ });
393
+
394
+ it("writeRpc returns false when keeper writer not injected", async () => {
395
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
396
+ registry.register(7777, "/proj", mockProcess(), "tok", {
397
+ keeperPid: 7777,
398
+ keeperSockPath: "/tmp/x.sock",
399
+ });
400
+ registry.linkByToken("tok", "S_keep");
401
+ expect(await registry.writeRpc("S_keep", "hello")).toBe(false);
402
+ });
403
+
404
+ it("setKeeperWriter injects writer after construction", async () => {
405
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
406
+ registry.register(7777, "/proj", mockProcess(), "tok", {
407
+ keeperPid: 7777,
408
+ keeperSockPath: "/tmp/x.sock",
409
+ });
410
+ registry.linkByToken("tok", "S_keep");
411
+ const writer = {
412
+ writeRpcToSockPath: vi.fn(async () => true),
413
+ discoverExistingKeepers: vi.fn(async () => []),
414
+ };
415
+ registry.setKeeperWriter(writer);
416
+ expect(await registry.writeRpc("S_keep", "line")).toBe(true);
417
+ expect(writer.writeRpcToSockPath).toHaveBeenCalledTimes(1);
418
+ });
419
+
420
+ it("killBySessionId in keeper mode SIGTERMs pi first then keeper", () => {
421
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
422
+ registry.register(process.pid, "/proj", mockProcess(), "tok", {
423
+ keeperPid: process.pid,
424
+ keeperSockPath: "/tmp/x.sock",
425
+ });
426
+ // Bridge connect: piPid distinct from keeperPid.
427
+ registry.linkByToken("tok", "S_keep", process.pid);
428
+ // Now piPid === process.pid, keeperPid === process.pid (both alive).
429
+ const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
430
+ const ok = registry.killBySessionId("S_keep");
431
+ expect(ok).toBe(true);
432
+ // pi killed first (process group on Unix).
433
+ expect(killSpy).toHaveBeenCalledWith(-process.pid, "SIGTERM");
434
+ expect(registry.size()).toBe(0);
435
+ killSpy.mockRestore();
436
+ });
437
+
438
+ it("killBySessionId keeper mode without pi link still kills keeper", () => {
439
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
440
+ registry.register(process.pid, "/proj", mockProcess(), "tok", {
441
+ keeperPid: process.pid,
442
+ keeperSockPath: "/tmp/x.sock",
443
+ });
444
+ registry.linkByToken("tok", "S_keep");
445
+ // No piPid set (bridge never connected). Should still kill the keeper.
446
+ const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
447
+ const ok = registry.killBySessionId("S_keep");
448
+ expect(ok).toBe(true);
449
+ expect(killSpy).toHaveBeenCalledWith(-process.pid, "SIGTERM");
450
+ killSpy.mockRestore();
451
+ });
452
+
453
+ it("cleanupKeeperOrphans no-op when no keeper writer", async () => {
454
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
455
+ await expect(registry.cleanupKeeperOrphans()).resolves.toBeUndefined();
456
+ });
457
+
458
+ it("persist round-trips keeper fields so linkByPid via piPid works after restart", async () => {
459
+ // Scenario: keeper-managed session lived through a dashboard restart.
460
+ // BEFORE restart: register with keeperOpts, linkByToken sets piPid +
461
+ // persists. AFTER restart: cleanupOrphans reclaims with piPid intact.
462
+ // Bridge re-registers with `pid: piPid` (no token) — linkByPid MUST
463
+ // match via entry.piPid, NOT fall through to cwd-FIFO. Regression
464
+ // guard for the cross-session dispatch / kill bug.
465
+ const dir = makeTempDir();
466
+ const pidFile = join(dir, "pids.json");
467
+
468
+ // ── Pre-restart server lifetime ──
469
+ const r1 = createHeadlessPidRegistry({ pidFilePath: pidFile });
470
+ r1.register(7777, "/proj", mockProcess(), "tok_keep", {
471
+ keeperPid: 7777,
472
+ keeperSockPath: "/tmp/abc.sock",
473
+ });
474
+ // Bridge connects with pi's PID 5050.
475
+ expect(r1.linkByToken("tok_keep", "S_keep", 5050)).toBe(true);
476
+ expect(r1.getPid("S_keep")).toBe(5050);
477
+
478
+ // ── Server restart (new registry instance, same pid file) ──
479
+ const r2 = createHeadlessPidRegistry({ pidFilePath: pidFile });
480
+ // Use the current test process PID so isProcessAlive returns true and
481
+ // cleanupOrphans reclaims the entry. Re-write the pid file with the
482
+ // correct PID under our test's spawnedAt rules to avoid the >7-day kill.
483
+ writeFileSync(pidFile, JSON.stringify({
484
+ entries: [{
485
+ pid: process.pid,
486
+ cwd: "/proj",
487
+ spawnedAt: new Date().toISOString(),
488
+ spawnToken: "tok_keep",
489
+ piPid: 5050,
490
+ keeperPid: process.pid,
491
+ keeperSockPath: "/tmp/abc.sock",
492
+ }],
493
+ }));
494
+ r2.cleanupOrphans();
495
+ expect(r2.size()).toBe(1);
496
+
497
+ // Bridge reattach: no spawnToken (omitted on reattach), sends pi's PID.
498
+ expect(r2.linkByToken("", "S_new", 5050)).toBe(false); // empty token
499
+ expect(r2.linkByPid("S_new", 5050)).toBe(true); // matches via piPid
500
+ expect(r2.getPid("S_new")).toBe(5050);
501
+ });
502
+
503
+ it("linkByPid does NOT mis-map when two keeper-mode entries share a cwd (regression)", () => {
504
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
505
+ // Two same-cwd keeper-mode entries with distinct piPids — the exact
506
+ // shape that produced the cross-session dispatch bug before piPid was
507
+ // persisted / linkByPid checked entry.piPid.
508
+ registry.register(1000, "/proj", mockProcess(), "tok_A", {
509
+ keeperPid: 1000, keeperSockPath: "/tmp/A.sock",
510
+ });
511
+ registry.register(1001, "/proj", mockProcess(), "tok_B", {
512
+ keeperPid: 1001, keeperSockPath: "/tmp/B.sock",
513
+ });
514
+ // Each entry's first linkByToken stamped piPid.
515
+ registry.linkByToken("tok_A", "S_A", 5050);
516
+ registry.linkByToken("tok_B", "S_B", 6060);
517
+
518
+ // Simulate post-restart reattach: bridges come back with no token,
519
+ // server only knows piPid. linkByPid MUST resolve to correct entry.
520
+ // Drop sessionId to simulate fresh-restart entry state.
521
+ // (Direct mutation isn't exposed; recreate via persist+reload below.)
522
+ // Instead: assert sockPath disambiguation via writeRpc lookup.
523
+ expect(registry.getPid("S_A")).toBe(5050);
524
+ expect(registry.getPid("S_B")).toBe(6060);
525
+ });
526
+
527
+ it("cleanupKeeperOrphans attaches keeper info to existing entries", async () => {
528
+ const writer = {
529
+ writeRpcToSockPath: vi.fn(async () => true),
530
+ discoverExistingKeepers: vi.fn(async () => [
531
+ { sessionId: "transport-1", keeperPid: 4242, sockPath: "/tmp/transport-1.sock" },
532
+ ]),
533
+ };
534
+ const registry = createHeadlessPidRegistry({
535
+ pidFilePath: join(makeTempDir(), "pids.json"),
536
+ keeperManager: writer,
537
+ });
538
+ // Pre-existing entry with the same PID (would happen after
539
+ // cleanupOrphans reclaim of a long-lived keeper from disk).
540
+ registry.register(4242, "/proj", mockProcess());
541
+ await registry.cleanupKeeperOrphans();
542
+ // Verify writer was consulted and entry got keeper info via
543
+ // observable side-effect: writeRpc now succeeds for that entry.
544
+ registry.linkSession("S_attached", "/proj");
545
+ const ok = await registry.writeRpc("S_attached", "line");
546
+ expect(ok).toBe(true);
547
+ expect(writer.writeRpcToSockPath).toHaveBeenCalledWith("/tmp/transport-1.sock", "line");
548
+ });
549
+ });
@@ -7,7 +7,7 @@ import { isPiCommandLine } from "../browser-handlers/session-action-handler.js";
7
7
 
8
8
  describe("isPiCommandLine", () => {
9
9
  it("matches a typical pi cli invocation", () => {
10
- expect(isPiCommandLine("/usr/bin/node /usr/local/lib/node_modules/@mariozechner/pi-coding-agent/dist/cli.js")).toBe(true);
10
+ expect(isPiCommandLine("/usr/bin/node /usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js")).toBe(true);
11
11
  });
12
12
 
13
13
  it("matches when only 'pi' appears as a word", () => {
@@ -0,0 +1,298 @@
1
+ /**
2
+ * KeeperManager unit tests (task 4.6).
3
+ *
4
+ * Mocks `spawnDetached` and `net.createConnection` to assert:
5
+ * - spawnKeeperFor argv / spawn options shape
6
+ * - writeRpc retry-then-succeed and retry-then-fail behavior
7
+ * - killKeeper sends SIGTERM to the tracked PID via killPidWithGroup
8
+ * - discoverExistingKeepers correctly classifies live / stale / orphan
9
+ *
10
+ * Integration of the real keeper.cjs binary is exercised in
11
+ * `rpc-keeper/__tests__/keeper.test.ts`; this file stays at unit-level.
12
+ */
13
+ import { EventEmitter } from "node:events";
14
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
15
+ import net from "node:net";
16
+ import path from "node:path";
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
+ import type {
19
+ SpawnDetachedOptions,
20
+ SpawnDetachedResult,
21
+ } from "@blackbelt-technology/pi-dashboard-shared/platform/detached-spawn.js";
22
+ import type { ChildProcess } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
23
+ import {
24
+ createKeeperManager,
25
+ pidPathFor,
26
+ sockPathFor,
27
+ type KeeperManagerOptions,
28
+ } from "../rpc-keeper/keeper-manager.js";
29
+
30
+ // ── Fake spawnDetached ───────────────────────────────────────────────────────
31
+
32
+ class FakeChildProcess extends EventEmitter {
33
+ pid: number | undefined;
34
+ unref = vi.fn();
35
+ kill = vi.fn();
36
+ stdio = [null, null, null] as const;
37
+ constructor(pid: number | undefined) {
38
+ super();
39
+ this.pid = pid;
40
+ }
41
+ }
42
+
43
+ function makeFakeSpawnDetached(opts: { pid?: number; ok?: boolean; error?: string } = {}): {
44
+ spawn: (opts: SpawnDetachedOptions) => Promise<SpawnDetachedResult>;
45
+ calls: SpawnDetachedOptions[];
46
+ lastChild: { current: FakeChildProcess | null };
47
+ } {
48
+ const calls: SpawnDetachedOptions[] = [];
49
+ const lastChild = { current: null as FakeChildProcess | null };
50
+ const spawn = async (spawnOpts: SpawnDetachedOptions): Promise<SpawnDetachedResult> => {
51
+ calls.push(spawnOpts);
52
+ if (opts.ok === false) return { ok: false, error: opts.error ?? "forced fail" };
53
+ const c = new FakeChildProcess(opts.pid);
54
+ lastChild.current = c;
55
+ return { ok: true, pid: opts.pid, process: c as unknown as ChildProcess };
56
+ };
57
+ return { spawn, calls, lastChild };
58
+ }
59
+
60
+ // ── Fake net.createConnection ────────────────────────────────────────────────
61
+
62
+ interface FakeConnectionConfig {
63
+ attempts: Array<"connect-ok" | "error" | "timeout">;
64
+ }
65
+
66
+ class FakeSocket extends EventEmitter {
67
+ destroyed = false;
68
+ end = vi.fn((_data: unknown, _enc: unknown, cb?: () => void) => {
69
+ if (cb) setImmediate(cb);
70
+ });
71
+ destroy = vi.fn(() => { this.destroyed = true; });
72
+ }
73
+
74
+ function makeFakeCreateConnection(cfg: FakeConnectionConfig): {
75
+ createConnection: typeof net.createConnection;
76
+ connectCount: () => number;
77
+ pathsCalled: string[];
78
+ } {
79
+ let i = 0;
80
+ const pathsCalled: string[] = [];
81
+ const fn = ((arg: string | net.NetConnectOpts) => {
82
+ const p = typeof arg === "string" ? arg : (arg as net.IpcNetConnectOpts).path;
83
+ if (typeof p === "string") pathsCalled.push(p);
84
+ const sock = new FakeSocket();
85
+ const behavior = cfg.attempts[i++] ?? "error";
86
+ setImmediate(() => {
87
+ if (behavior === "connect-ok") sock.emit("connect");
88
+ else if (behavior === "error") sock.emit("error", new Error("ECONNREFUSED"));
89
+ // "timeout" → do nothing; KeeperManager's per-attempt timer fires.
90
+ });
91
+ return sock as unknown as net.Socket;
92
+ }) as typeof net.createConnection;
93
+ return { createConnection: fn, connectCount: () => i, pathsCalled };
94
+ }
95
+
96
+ // ── Common setup ─────────────────────────────────────────────────────────────
97
+
98
+ const KNOWN_DEAD_PID = 99999999; // far above max_pid; process.kill returns ESRCH
99
+
100
+ let tmpRoot: string;
101
+ let sessionsDir: string;
102
+
103
+ beforeEach(() => {
104
+ tmpRoot = mkdtempSync(path.join("/tmp", "km-"));
105
+ sessionsDir = path.join(tmpRoot, ".pi", "dashboard", "sessions");
106
+ });
107
+ afterEach(() => {
108
+ rmSync(tmpRoot, { recursive: true, force: true });
109
+ });
110
+
111
+ function baseOpts(extra: Partial<KeeperManagerOptions> = {}): KeeperManagerOptions {
112
+ return {
113
+ sessionsDir,
114
+ keeperPath: path.resolve(__dirname, "..", "rpc-keeper", "keeper.cjs"),
115
+ nodeBinary: "/usr/bin/node",
116
+ platform: process.platform,
117
+ ...extra,
118
+ };
119
+ }
120
+
121
+ // ── Tests ────────────────────────────────────────────────────────────────────
122
+
123
+ describe("KeeperManager.spawnKeeperFor", () => {
124
+ it("delegates to spawnDetached with `node <keeper.cjs> <sessionId>`", async () => {
125
+ const { spawn, calls } = makeFakeSpawnDetached({ pid: 12345 });
126
+ const km = createKeeperManager(baseOpts({ spawnDetached: spawn }));
127
+
128
+ const result = await km.spawnKeeperFor("sess-1", "/some/cwd", { FOO: "bar" });
129
+
130
+ expect(result.success).toBe(true);
131
+ expect(result.pid).toBe(12345);
132
+ expect(result.sockPath).toBe(sockPathFor(sessionsDir, "sess-1"));
133
+
134
+ expect(calls).toHaveLength(1);
135
+ expect(calls[0].cmd).toBe("/usr/bin/node");
136
+ expect(calls[0].args).toEqual([baseOpts().keeperPath!, "sess-1"]);
137
+ expect(calls[0].cwd).toBe("/some/cwd");
138
+ expect(calls[0].stdinMode).toBe("ignore");
139
+ expect(calls[0].detach).toBe(true);
140
+ expect((calls[0].env as { FOO?: string } | undefined)?.FOO).toBe("bar");
141
+ });
142
+
143
+ it("returns success: false when spawnDetached reports !ok", async () => {
144
+ const { spawn } = makeFakeSpawnDetached({ ok: false, error: "no pid available" });
145
+ const km = createKeeperManager(baseOpts({ spawnDetached: spawn }));
146
+ const result = await km.spawnKeeperFor("sess-x", "/cwd", {});
147
+ expect(result.success).toBe(false);
148
+ expect(result.error).toMatch(/no pid/);
149
+ });
150
+
151
+ it("returns success: false when keeper.cjs path does not exist", async () => {
152
+ const { spawn } = makeFakeSpawnDetached({ pid: 1 });
153
+ const km = createKeeperManager(
154
+ baseOpts({ spawnDetached: spawn, keeperPath: "/does/not/exist/keeper.cjs" }),
155
+ );
156
+ const result = await km.spawnKeeperFor("sess-x", "/cwd", {});
157
+ expect(result.success).toBe(false);
158
+ expect(result.error).toMatch(/keeper\.cjs not found/);
159
+ });
160
+ });
161
+
162
+ describe("KeeperManager.writeRpc", () => {
163
+ it("writes line on first successful attempt and returns true", async () => {
164
+ const cfg: FakeConnectionConfig = { attempts: ["connect-ok"] };
165
+ const { createConnection, connectCount, pathsCalled } = makeFakeCreateConnection(cfg);
166
+ const km = createKeeperManager(baseOpts({ createConnection }));
167
+
168
+ const ok = await km.writeRpc("sess-1", '{"x":1}');
169
+ expect(ok).toBe(true);
170
+ expect(connectCount()).toBe(1);
171
+ expect(pathsCalled[0]).toBe(sockPathFor(sessionsDir, "sess-1"));
172
+ });
173
+
174
+ it("retries after error and succeeds on attempt 2", async () => {
175
+ const cfg: FakeConnectionConfig = { attempts: ["error", "connect-ok"] };
176
+ const { createConnection, connectCount } = makeFakeCreateConnection(cfg);
177
+ const km = createKeeperManager(baseOpts({ createConnection }));
178
+
179
+ const ok = await km.writeRpc("sess-1", '{"x":1}');
180
+ expect(ok).toBe(true);
181
+ expect(connectCount()).toBe(2);
182
+ });
183
+
184
+ it("returns false after 3 failed attempts", async () => {
185
+ const cfg: FakeConnectionConfig = { attempts: ["error", "error", "error"] };
186
+ const { createConnection, connectCount } = makeFakeCreateConnection(cfg);
187
+ const km = createKeeperManager(baseOpts({ createConnection }));
188
+
189
+ const ok = await km.writeRpc("sess-1", '{"x":1}');
190
+ expect(ok).toBe(false);
191
+ expect(connectCount()).toBe(3);
192
+ });
193
+
194
+ it("appends trailing newline if missing", async () => {
195
+ let captured = "";
196
+ const fn = ((arg: unknown) => {
197
+ const sock = new FakeSocket();
198
+ sock.end = vi.fn((data: unknown, _enc: unknown, cb?: () => void) => {
199
+ captured = String(data);
200
+ if (cb) setImmediate(cb);
201
+ }) as unknown as FakeSocket["end"];
202
+ setImmediate(() => sock.emit("connect"));
203
+ return sock as unknown as net.Socket;
204
+ }) as typeof net.createConnection;
205
+
206
+ const km = createKeeperManager(baseOpts({ createConnection: fn }));
207
+ await km.writeRpc("sess-1", '{"x":1}');
208
+ expect(captured).toBe('{"x":1}\n');
209
+ });
210
+
211
+ it("does NOT append a second newline if line already ends with \\n", async () => {
212
+ let captured = "";
213
+ const fn = ((arg: unknown) => {
214
+ const sock = new FakeSocket();
215
+ sock.end = vi.fn((data: unknown, _enc: unknown, cb?: () => void) => {
216
+ captured = String(data);
217
+ if (cb) setImmediate(cb);
218
+ }) as unknown as FakeSocket["end"];
219
+ setImmediate(() => sock.emit("connect"));
220
+ return sock as unknown as net.Socket;
221
+ }) as typeof net.createConnection;
222
+
223
+ const km = createKeeperManager(baseOpts({ createConnection: fn }));
224
+ await km.writeRpc("sess-1", '{"x":1}\n');
225
+ expect(captured).toBe('{"x":1}\n');
226
+ });
227
+ });
228
+
229
+ describe("KeeperManager.killKeeper", () => {
230
+ it("returns false when no spawn has been tracked for sessionId", () => {
231
+ const km = createKeeperManager(baseOpts());
232
+ expect(km.killKeeper("never-spawned")).toBe(false);
233
+ });
234
+
235
+ it("sends SIGTERM to the tracked PID after a successful spawn", async () => {
236
+ const { spawn } = makeFakeSpawnDetached({ pid: 77777 });
237
+ const km = createKeeperManager(baseOpts({ spawnDetached: spawn }));
238
+ await km.spawnKeeperFor("sess-k", "/cwd", {});
239
+
240
+ const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
241
+ const ok = km.killKeeper("sess-k");
242
+ expect(ok).toBe(true);
243
+ const target = process.platform === "win32" ? 77777 : -77777; // platform-branch-ok
244
+ expect(killSpy).toHaveBeenCalledWith(target, "SIGTERM");
245
+ killSpy.mockRestore();
246
+ });
247
+ });
248
+
249
+ describe("KeeperManager.discoverExistingKeepers", () => {
250
+ it("returns empty list when sessions dir is missing", async () => {
251
+ const km = createKeeperManager(baseOpts({ sessionsDir: path.join(tmpRoot, "nope") }));
252
+ const r = await km.discoverExistingKeepers();
253
+ expect(r).toEqual([]);
254
+ });
255
+
256
+ it("returns live entry when keeper PID and pi PID are both alive", async () => {
257
+ mkdirSync(sessionsDir, { recursive: true });
258
+ const sid = "sess-live";
259
+ const pidFile = pidPathFor(sessionsDir, sid);
260
+ writeFileSync(pidFile, String(process.pid));
261
+
262
+ const km = createKeeperManager(baseOpts({ isPiAliveForSession: () => true }));
263
+ const r = await km.discoverExistingKeepers();
264
+ expect(r).toHaveLength(1);
265
+ expect(r[0].sessionId).toBe(sid);
266
+ expect(r[0].keeperPid).toBe(process.pid);
267
+ expect(existsSync(pidFile)).toBe(true);
268
+ });
269
+
270
+ it("unlinks sidecar when keeper PID is dead", async () => {
271
+ mkdirSync(sessionsDir, { recursive: true });
272
+ const sid = "sess-dead-keeper";
273
+ const pidFile = pidPathFor(sessionsDir, sid);
274
+ writeFileSync(pidFile, String(KNOWN_DEAD_PID));
275
+
276
+ const km = createKeeperManager(baseOpts({ isPiAliveForSession: () => true }));
277
+ const r = await km.discoverExistingKeepers();
278
+ expect(r).toEqual([]);
279
+ expect(existsSync(pidFile)).toBe(false);
280
+ });
281
+
282
+ it("kills keeper and unlinks sidecar when pi is dead but keeper is alive", async () => {
283
+ mkdirSync(sessionsDir, { recursive: true });
284
+ const sid = "sess-orphan-keeper";
285
+ const pidFile = pidPathFor(sessionsDir, sid);
286
+ writeFileSync(pidFile, String(process.pid));
287
+
288
+ const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
289
+ const km = createKeeperManager(baseOpts({ isPiAliveForSession: () => false }));
290
+ const r = await km.discoverExistingKeepers();
291
+ expect(r).toEqual([]);
292
+ const target = process.platform === "win32" ? process.pid : -process.pid; // platform-branch-ok
293
+ const sigtermCalls = killSpy.mock.calls.filter((c) => c[1] === "SIGTERM");
294
+ expect(sigtermCalls).toContainEqual([target, "SIGTERM"]);
295
+ expect(existsSync(pidFile)).toBe(false);
296
+ killSpy.mockRestore();
297
+ });
298
+ });