@blackbelt-technology/pi-agent-dashboard 0.4.6 → 0.5.1

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 (181) hide show
  1. package/AGENTS.md +339 -190
  2. package/README.md +50 -7
  3. package/docs/architecture.md +238 -23
  4. package/package.json +23 -13
  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__/build-provider-catalogue.test.ts +176 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +68 -0
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
  13. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  14. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  15. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  16. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  17. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  18. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  19. package/packages/extension/src/ask-user-tool.ts +1 -1
  20. package/packages/extension/src/bridge-context.ts +1 -1
  21. package/packages/extension/src/bridge.ts +169 -4
  22. package/packages/extension/src/command-handler.ts +65 -2
  23. package/packages/extension/src/flow-event-wiring.ts +1 -1
  24. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  25. package/packages/extension/src/multiselect-list.ts +1 -1
  26. package/packages/extension/src/pi-env.d.ts +16 -9
  27. package/packages/extension/src/prompt-expander.ts +50 -2
  28. package/packages/extension/src/provider-register.ts +132 -8
  29. package/packages/extension/src/retry-tracker.ts +123 -0
  30. package/packages/extension/src/server-launcher.ts +18 -1
  31. package/packages/extension/src/session-sync.ts +15 -1
  32. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  33. package/packages/server/package.json +6 -6
  34. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  35. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  36. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  37. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  38. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  39. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  40. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  41. package/packages/server/src/__tests__/cli-parse.test.ts +22 -4
  42. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  43. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  44. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  45. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  46. package/packages/server/src/__tests__/directory-service.test.ts +1 -1
  47. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  48. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +154 -0
  49. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  50. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  51. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  52. package/packages/server/src/__tests__/headless-pid-registry.test.ts +83 -0
  53. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  54. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  55. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  56. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  57. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  58. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  59. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  60. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  61. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  62. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  63. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +236 -0
  64. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  65. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  66. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  67. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  68. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  69. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  70. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  71. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +44 -0
  72. package/packages/server/src/__tests__/recommended-routes.test.ts +1 -1
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  74. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  75. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  76. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  77. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  78. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  79. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +250 -0
  80. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  81. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  82. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  83. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  84. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  85. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  86. package/packages/server/src/bootstrap-state.ts +18 -0
  87. package/packages/server/src/browser-gateway.ts +70 -24
  88. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  89. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  90. package/packages/server/src/browser-handlers/session-action-handler.ts +159 -18
  91. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  92. package/packages/server/src/changelog-fs.ts +167 -0
  93. package/packages/server/src/changelog-parser.ts +321 -0
  94. package/packages/server/src/changelog-remote.ts +134 -0
  95. package/packages/server/src/cli.ts +23 -2
  96. package/packages/server/src/directory-service.ts +31 -0
  97. package/packages/server/src/event-wiring.ts +105 -5
  98. package/packages/server/src/headless-pid-registry.ts +54 -5
  99. package/packages/server/src/home-lock.d.ts +124 -0
  100. package/packages/server/src/home-lock.js +330 -0
  101. package/packages/server/src/home-lock.js.map +1 -0
  102. package/packages/server/src/idle-timer.ts +15 -1
  103. package/packages/server/src/pending-client-correlations.ts +73 -0
  104. package/packages/server/src/pending-fork-registry.ts +24 -12
  105. package/packages/server/src/pi-core-checker.ts +77 -17
  106. package/packages/server/src/pi-core-updater.ts +81 -15
  107. package/packages/server/src/pi-dev-version-check.ts +145 -0
  108. package/packages/server/src/pi-gateway.ts +10 -0
  109. package/packages/server/src/pi-version-skew.ts +12 -4
  110. package/packages/server/src/process-manager.ts +115 -21
  111. package/packages/server/src/provider-auth-handlers.ts +9 -0
  112. package/packages/server/src/provider-auth-storage.ts +83 -51
  113. package/packages/server/src/provider-catalogue-cache.ts +47 -0
  114. package/packages/server/src/routes/doctor-routes.ts +140 -0
  115. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  116. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  117. package/packages/server/src/routes/provider-auth-routes.ts +14 -1
  118. package/packages/server/src/routes/provider-routes.ts +4 -4
  119. package/packages/server/src/routes/system-routes.ts +38 -1
  120. package/packages/server/src/server.ts +85 -66
  121. package/packages/server/src/session-api.ts +54 -3
  122. package/packages/server/src/session-bootstrap.ts +27 -12
  123. package/packages/server/src/session-discovery.ts +11 -4
  124. package/packages/server/src/session-file-reader.ts +1 -1
  125. package/packages/server/src/session-scanner.ts +4 -2
  126. package/packages/server/src/spawn-failure-log.ts +130 -0
  127. package/packages/server/src/spawn-preflight.ts +82 -0
  128. package/packages/server/src/spawn-register-watchdog.ts +291 -0
  129. package/packages/server/src/spawn-token.ts +20 -0
  130. package/packages/server/src/terminal-manager.ts +12 -1
  131. package/packages/shared/package.json +1 -1
  132. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +25 -17
  133. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  134. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  135. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  136. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  137. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  138. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +6 -3
  139. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  140. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  141. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  142. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  143. package/packages/shared/src/__tests__/config.test.ts +48 -0
  144. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  145. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  146. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  147. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  148. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  149. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  150. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  151. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  152. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  153. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  154. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  155. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  156. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  157. package/packages/shared/src/__tests__/resolve-jiti.test.ts +140 -9
  158. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  159. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  160. package/packages/shared/src/bootstrap-install.ts +197 -3
  161. package/packages/shared/src/browser-protocol.ts +155 -1
  162. package/packages/shared/src/changelog-types.ts +111 -0
  163. package/packages/shared/src/config.ts +15 -0
  164. package/packages/shared/src/dashboard-starter.ts +33 -0
  165. package/packages/shared/src/doctor-core.ts +821 -0
  166. package/packages/shared/src/index.ts +9 -0
  167. package/packages/shared/src/installable-list.ts +152 -0
  168. package/packages/shared/src/launch-source-flag.ts +14 -0
  169. package/packages/shared/src/launch-source-types.ts +18 -0
  170. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  171. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  172. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  173. package/packages/shared/src/platform/node-spawn.ts +29 -21
  174. package/packages/shared/src/protocol.ts +54 -2
  175. package/packages/shared/src/resolve-jiti.ts +62 -9
  176. package/packages/shared/src/rest-api.ts +4 -0
  177. package/packages/shared/src/skill-block-parser.ts +115 -0
  178. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  179. package/packages/shared/src/tool-registry/definitions.ts +33 -8
  180. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  181. package/packages/shared/src/types.ts +64 -0
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Unit tests for `hasOpenSpecDir` — synchronous spawn-free probe used by
3
+ * the WS on-connect snapshot to disambiguate "no openspec here" from
4
+ * "openspec here, polling pending".
5
+ *
6
+ * See change: fix-cold-boot-openspec-protocol.
7
+ */
8
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+ import { hasOpenSpecDir } from "../directory-service.js";
13
+
14
+ describe("hasOpenSpecDir", () => {
15
+ let tmp: string;
16
+
17
+ beforeEach(() => {
18
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "has-openspec-dir-"));
19
+ });
20
+
21
+ afterEach(() => {
22
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
23
+ });
24
+
25
+ it("returns true when <cwd>/openspec/changes exists as a directory", () => {
26
+ fs.mkdirSync(path.join(tmp, "openspec", "changes"), { recursive: true });
27
+ expect(hasOpenSpecDir(tmp)).toBe(true);
28
+ });
29
+
30
+ it("returns false when openspec dir is absent (ENOENT)", () => {
31
+ expect(hasOpenSpecDir(tmp)).toBe(false);
32
+ });
33
+
34
+ it("returns false when openspec exists but openspec/changes does not", () => {
35
+ fs.mkdirSync(path.join(tmp, "openspec"));
36
+ expect(hasOpenSpecDir(tmp)).toBe(false);
37
+ });
38
+
39
+ it("returns false when openspec/changes is a regular file, not a directory", () => {
40
+ fs.mkdirSync(path.join(tmp, "openspec"));
41
+ fs.writeFileSync(path.join(tmp, "openspec", "changes"), "not a dir");
42
+ expect(hasOpenSpecDir(tmp)).toBe(false);
43
+ });
44
+
45
+ it("returns false when openspec/changes is a symlink to a non-directory", () => {
46
+ fs.mkdirSync(path.join(tmp, "openspec"));
47
+ const target = path.join(tmp, "target.txt");
48
+ fs.writeFileSync(target, "x");
49
+ fs.symlinkSync(target, path.join(tmp, "openspec", "changes"));
50
+ expect(hasOpenSpecDir(tmp)).toBe(false);
51
+ });
52
+
53
+ it("returns true when openspec/changes is a symlink to a directory", () => {
54
+ fs.mkdirSync(path.join(tmp, "openspec"));
55
+ const target = path.join(tmp, "target-dir");
56
+ fs.mkdirSync(target);
57
+ fs.symlinkSync(target, path.join(tmp, "openspec", "changes"));
58
+ expect(hasOpenSpecDir(tmp)).toBe(true);
59
+ });
60
+
61
+ it("returns false for a non-existent cwd", () => {
62
+ expect(hasOpenSpecDir("/this/path/does/not/exist/__nope__")).toBe(false);
63
+ });
64
+ });
@@ -231,3 +231,86 @@ 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
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Tests for /api/health response shape after Phase A additions.
3
+ *
4
+ * Asserts:
5
+ * - `pid` field is present (regression pin).
6
+ * - `starter` field is present, defaults to "Standalone".
7
+ *
8
+ * Note: the "Standalone default for missing DASHBOARD_STARTER" case is
9
+ * also covered exhaustively in packages/shared/src/__tests__/dashboard-starter.test.ts.
10
+ * This test pins the contract at the HTTP layer so a refactor cannot silently
11
+ * drop either field from the health response.
12
+ */
13
+ import { describe, it, expect, afterEach } from "vitest";
14
+ import { createTestServer, type TestServerHandle } from "../test-support/test-server.js";
15
+
16
+ let handle: TestServerHandle | undefined;
17
+
18
+ describe("GET /api/health — Phase A shape", () => {
19
+ afterEach(async () => {
20
+ if (handle) {
21
+ try { await handle.stop(); } catch { /* already stopped */ }
22
+ handle = undefined;
23
+ }
24
+ });
25
+
26
+ it("includes pid field (regression pin)", async () => {
27
+ handle = await createTestServer();
28
+ const res = await fetch(`http://localhost:${handle.httpPort}/api/health`);
29
+ expect(res.status).toBe(200);
30
+ const body = await res.json() as Record<string, unknown>;
31
+ expect(typeof body.pid).toBe("number");
32
+ expect(body.pid).toBe(process.pid);
33
+ });
34
+
35
+ it("includes starter field defaulting to Standalone", async () => {
36
+ handle = await createTestServer();
37
+ const res = await fetch(`http://localhost:${handle.httpPort}/api/health`);
38
+ expect(res.status).toBe(200);
39
+ const body = await res.json() as Record<string, unknown>;
40
+ // When bootstrapState has no starter set, defaults to "Standalone".
41
+ expect(body.starter).toBe("Standalone");
42
+ });
43
+ });
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { createIdleTimer } from "../idle-timer.js";
3
+ import type { PiGateway } from "../pi-gateway.js";
4
+ import type { ServerConfig } from "../server.js";
5
+
6
+ // See change: fix-terminal-half-height-dual-mount.
7
+ // Pure unit tests against the idle-timer's predicate-driven gating.
8
+ // Avoids the full-server I/O races that have the auto-shutdown.test.ts
9
+ // suite skipped under fake timers.
10
+
11
+ function makeConfig(): ServerConfig {
12
+ return {
13
+ port: 0,
14
+ piPort: 0,
15
+ dev: true,
16
+ autoShutdown: true,
17
+ shutdownIdleSeconds: 2,
18
+ tunnel: false,
19
+ pingInterval: 0,
20
+ editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
21
+ } as ServerConfig;
22
+ }
23
+
24
+ function makeGateway(connectionCount = 0): PiGateway {
25
+ return {
26
+ connectionCount: () => connectionCount,
27
+ onEmpty: undefined,
28
+ onConnection: undefined,
29
+ } as unknown as PiGateway;
30
+ }
31
+
32
+ describe("idle-timer respects active terminals", () => {
33
+ beforeEach(() => {
34
+ vi.useFakeTimers();
35
+ });
36
+ afterEach(() => {
37
+ vi.useRealTimers();
38
+ });
39
+
40
+ it("does NOT shut down when one or more terminals are alive", async () => {
41
+ const gateway = makeGateway(0);
42
+ let terminalCount = 1;
43
+ const timer = createIdleTimer(makeConfig(), gateway, () => terminalCount > 0);
44
+ const stopFn = vi.fn().mockResolvedValue(undefined);
45
+ timer.setStopFn(stopFn);
46
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
47
+
48
+ timer.start();
49
+ // Advance well past shutdownIdleSeconds.
50
+ await vi.advanceTimersByTimeAsync(3000);
51
+
52
+ expect(stopFn).not.toHaveBeenCalled();
53
+ expect(exitSpy).not.toHaveBeenCalled();
54
+ exitSpy.mockRestore();
55
+ timer.cancel();
56
+ });
57
+
58
+ it("shuts down when no pi sessions AND no terminals are alive", async () => {
59
+ const gateway = makeGateway(0);
60
+ const timer = createIdleTimer(makeConfig(), gateway, () => false);
61
+ const stopFn = vi.fn().mockResolvedValue(undefined);
62
+ timer.setStopFn(stopFn);
63
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
64
+
65
+ timer.start();
66
+ // First tick: realIdleMs is 0 because lastConnectionTimestamp = 0.
67
+ // The implementation guards on realIdleMs < shutdownIdleSeconds*1000
68
+ // and restarts; so we need two ticks separated by enough wall time.
69
+ await vi.advanceTimersByTimeAsync(2000);
70
+ // After the first tick the timer has restarted; advance again.
71
+ await vi.advanceTimersByTimeAsync(2000);
72
+
73
+ expect(stopFn).toHaveBeenCalled();
74
+ expect(exitSpy).toHaveBeenCalledWith(0);
75
+ exitSpy.mockRestore();
76
+ });
77
+
78
+ it("re-arms (does not shut down) when terminals appear mid-countdown", async () => {
79
+ const gateway = makeGateway(0);
80
+ let terminalCount = 0;
81
+ const timer = createIdleTimer(makeConfig(), gateway, () => terminalCount > 0);
82
+ const stopFn = vi.fn().mockResolvedValue(undefined);
83
+ timer.setStopFn(stopFn);
84
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
85
+
86
+ timer.start();
87
+ // Just before the timer fires, a terminal appears.
88
+ await vi.advanceTimersByTimeAsync(1500);
89
+ terminalCount = 1;
90
+ // Let it fire.
91
+ await vi.advanceTimersByTimeAsync(1500);
92
+
93
+ expect(stopFn).not.toHaveBeenCalled();
94
+ expect(exitSpy).not.toHaveBeenCalled();
95
+ exitSpy.mockRestore();
96
+ timer.cancel();
97
+ });
98
+
99
+ it("default predicate (no terminals) preserves legacy single-arg call site behavior", async () => {
100
+ // Caller may construct without the third arg; default is () => false.
101
+ const gateway = makeGateway(0);
102
+ const timer = createIdleTimer(makeConfig(), gateway);
103
+ const stopFn = vi.fn().mockResolvedValue(undefined);
104
+ timer.setStopFn(stopFn);
105
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
106
+
107
+ timer.start();
108
+ await vi.advanceTimersByTimeAsync(2000);
109
+ await vi.advanceTimersByTimeAsync(2000);
110
+
111
+ expect(stopFn).toHaveBeenCalled();
112
+ expect(exitSpy).toHaveBeenCalledWith(0);
113
+ exitSpy.mockRestore();
114
+ });
115
+ });
@@ -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,92 @@
1
+ /**
2
+ * On-connect snapshot semantics: emits exactly one `openspec_update`
3
+ * per known cwd, with correct `pending` value.
4
+ *
5
+ * See change: fix-cold-boot-openspec-protocol.
6
+ */
7
+ import { describe, it, expect, vi } from "vitest";
8
+ import { buildOpenSpecConnectSnapshot } from "../browser-gateway.js";
9
+ import type { OpenSpecData } from "@blackbelt-technology/pi-dashboard-shared/types.js";
10
+
11
+ function ds(map: Record<string, OpenSpecData | undefined>) {
12
+ return {
13
+ knownDirectories: vi.fn(() => Object.keys(map)),
14
+ getOpenSpecData: vi.fn((cwd: string) => map[cwd]),
15
+ };
16
+ }
17
+
18
+ describe("buildOpenSpecConnectSnapshot", () => {
19
+ it("emits cached payload for cwds with initialized data (no pending field)", () => {
20
+ const cached: OpenSpecData = { initialized: true, changes: [{ name: "x" } as never] };
21
+ const msgs = buildOpenSpecConnectSnapshot(ds({ "/p": cached }), () => true);
22
+ expect(msgs).toHaveLength(1);
23
+ expect(msgs[0]).toEqual({ type: "openspec_update", cwd: "/p", data: cached });
24
+ });
25
+
26
+ it("emits pending: true when openspec dir exists but cache is empty", () => {
27
+ const msgs = buildOpenSpecConnectSnapshot(
28
+ ds({ "/p": { initialized: false, changes: [] } }),
29
+ (cwd) => cwd === "/p",
30
+ );
31
+ expect(msgs).toEqual([
32
+ {
33
+ type: "openspec_update",
34
+ cwd: "/p",
35
+ data: { initialized: false, pending: true, changes: [] },
36
+ },
37
+ ]);
38
+ });
39
+
40
+ it("emits pending: true when openspec dir exists but cache is undefined", () => {
41
+ const msgs = buildOpenSpecConnectSnapshot(
42
+ ds({ "/p": undefined }),
43
+ () => true,
44
+ );
45
+ expect(msgs).toEqual([
46
+ {
47
+ type: "openspec_update",
48
+ cwd: "/p",
49
+ data: { initialized: false, pending: true, changes: [] },
50
+ },
51
+ ]);
52
+ });
53
+
54
+ it("emits pending: false when no openspec dir exists", () => {
55
+ const msgs = buildOpenSpecConnectSnapshot(
56
+ ds({ "/p": undefined }),
57
+ () => false,
58
+ );
59
+ expect(msgs).toEqual([
60
+ {
61
+ type: "openspec_update",
62
+ cwd: "/p",
63
+ data: { initialized: false, pending: false, changes: [] },
64
+ },
65
+ ]);
66
+ });
67
+
68
+ it("emits exactly one message per known cwd, mixed states preserved", () => {
69
+ const cached: OpenSpecData = { initialized: true, changes: [{ name: "x" } as never] };
70
+ const map = { "/hot": cached, "/cold": undefined, "/none": undefined };
71
+ const msgs = buildOpenSpecConnectSnapshot(
72
+ ds(map),
73
+ (cwd) => cwd === "/cold",
74
+ );
75
+ expect(msgs).toHaveLength(3);
76
+ expect(msgs[0]).toEqual({ type: "openspec_update", cwd: "/hot", data: cached });
77
+ expect(msgs[1]).toEqual({
78
+ type: "openspec_update",
79
+ cwd: "/cold",
80
+ data: { initialized: false, pending: true, changes: [] },
81
+ });
82
+ expect(msgs[2]).toEqual({
83
+ type: "openspec_update",
84
+ cwd: "/none",
85
+ data: { initialized: false, pending: false, changes: [] },
86
+ });
87
+ });
88
+
89
+ it("returns empty array when there are no known directories", () => {
90
+ expect(buildOpenSpecConnectSnapshot(ds({}), () => true)).toEqual([]);
91
+ });
92
+ });
@@ -2,7 +2,7 @@
2
2
  * Tests for loadPiPackageManager() resolution chain in package-manager-wrapper.ts.
3
3
  *
4
4
  * Separate from package-manager-wrapper.test.ts because that file mocks
5
- * "@mariozechner/pi-coding-agent" so direct-import succeeds and the
5
+ * "@earendil-works/pi-coding-agent" so direct-import succeeds and the
6
6
  * fallback paths never execute.
7
7
  *
8
8
  * These tests exercise the managed-install and global-npm fallbacks.
@@ -15,10 +15,10 @@ import * as path from "node:path";
15
15
  // Force the direct import to fail so resolution falls through to the
16
16
  // managed-install / global-npm paths. vi.mock is hoisted; the factory
17
17
  // throws at import time which mimics pi not being an installed dependency.
18
- vi.mock("@mariozechner/pi-coding-agent", () => {
18
+ vi.mock("@earendil-works/pi-coding-agent", () => {
19
19
  throw new Error("not installed as direct dependency");
20
20
  });
21
- vi.mock("@oh-my-pi/pi-coding-agent", () => {
21
+ vi.mock("@mariozechner/pi-coding-agent", () => {
22
22
  throw new Error("not installed as direct dependency");
23
23
  });
24
24
 
@@ -60,7 +60,7 @@ describe("loadPiPackageManager resolution chain", () => {
60
60
  tmpHome,
61
61
  ".pi-dashboard",
62
62
  "node_modules",
63
- "@mariozechner",
63
+ "@earendil-works",
64
64
  "pi-coding-agent",
65
65
  "dist",
66
66
  );
@@ -13,7 +13,7 @@ import {
13
13
  } from "../package-manager-wrapper.js";
14
14
 
15
15
  // Mock pi dependency (pulled transitively by package-manager-wrapper)
16
- vi.mock("@mariozechner/pi-coding-agent", () => ({
16
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
17
17
  DefaultPackageManager: function() { return {}; },
18
18
  SettingsManager: { create: () => ({}) },
19
19
  }));
@@ -1,6 +1,11 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { createPendingForkRegistry } from "../pending-fork-registry.js";
3
3
 
4
+ // See change: spawn-correlation-token \u2014 registry is now keyed by spawnToken
5
+ // (UUID minted by the server per spawn invocation) instead of cwd. This
6
+ // closes the multi-fork-in-same-cwd race where the second `recordFork`
7
+ // would overwrite the first's parentSessionId.
8
+
4
9
  describe("PendingForkRegistry", () => {
5
10
  beforeEach(() => {
6
11
  vi.useFakeTimers();
@@ -10,60 +15,79 @@ describe("PendingForkRegistry", () => {
10
15
  vi.useRealTimers();
11
16
  });
12
17
 
13
- it("records and consumes a fork", () => {
18
+ it("records and consumes a fork by token", () => {
14
19
  const reg = createPendingForkRegistry();
15
- reg.recordFork("/project", "parent-1");
16
- const result = reg.consumeFork("/project");
20
+ reg.recordFork("tok_a", "parent-1");
21
+ const result = reg.consumeFork("tok_a");
17
22
  expect(result).toBe("parent-1");
18
23
  });
19
24
 
20
25
  it("returns undefined when no fork pending", () => {
21
26
  const reg = createPendingForkRegistry();
22
- expect(reg.consumeFork("/project")).toBeUndefined();
27
+ expect(reg.consumeFork("tok_unknown")).toBeUndefined();
23
28
  });
24
29
 
25
30
  it("consume clears the entry", () => {
26
31
  const reg = createPendingForkRegistry();
27
- reg.recordFork("/project", "parent-1");
28
- reg.consumeFork("/project");
29
- expect(reg.consumeFork("/project")).toBeUndefined();
32
+ reg.recordFork("tok_a", "parent-1");
33
+ reg.consumeFork("tok_a");
34
+ expect(reg.consumeFork("tok_a")).toBeUndefined();
30
35
  });
31
36
 
32
37
  it("expires after 30 seconds", () => {
33
38
  const reg = createPendingForkRegistry();
34
- reg.recordFork("/project", "parent-1");
39
+ reg.recordFork("tok_a", "parent-1");
35
40
  vi.advanceTimersByTime(30_001);
36
- expect(reg.consumeFork("/project")).toBeUndefined();
41
+ expect(reg.consumeFork("tok_a")).toBeUndefined();
37
42
  });
38
43
 
39
44
  it("does not expire before 30 seconds", () => {
40
45
  const reg = createPendingForkRegistry();
41
- reg.recordFork("/project", "parent-1");
46
+ reg.recordFork("tok_a", "parent-1");
42
47
  vi.advanceTimersByTime(29_999);
43
- expect(reg.consumeFork("/project")).toBe("parent-1");
48
+ expect(reg.consumeFork("tok_a")).toBe("parent-1");
49
+ });
50
+
51
+ it("re-recording with same token replaces parent", () => {
52
+ const reg = createPendingForkRegistry();
53
+ reg.recordFork("tok_a", "parent-1");
54
+ reg.recordFork("tok_a", "parent-2");
55
+ expect(reg.consumeFork("tok_a")).toBe("parent-2");
44
56
  });
45
57
 
46
- it("latest fork overwrites previous for same cwd", () => {
58
+ it("different tokens are independent", () => {
47
59
  const reg = createPendingForkRegistry();
48
- reg.recordFork("/project", "parent-1");
49
- reg.recordFork("/project", "parent-2");
50
- expect(reg.consumeFork("/project")).toBe("parent-2");
60
+ reg.recordFork("tok_a", "parent-a");
61
+ reg.recordFork("tok_b", "parent-b");
62
+ expect(reg.consumeFork("tok_a")).toBe("parent-a");
63
+ expect(reg.consumeFork("tok_b")).toBe("parent-b");
51
64
  });
52
65
 
53
- it("different cwds are independent", () => {
66
+ it("multi-fork-in-same-cwd: each fork keyed by its own token, no overwrite", () => {
67
+ // Regression: the prior cwd-keyed registry would overwrite the first
68
+ // fork's parent when a second fork issued in the same cwd recorded
69
+ // its intent before the first's bridge registered. Token-keying
70
+ // makes the two intents independent.
54
71
  const reg = createPendingForkRegistry();
55
- reg.recordFork("/a", "parent-a");
56
- reg.recordFork("/b", "parent-b");
57
- expect(reg.consumeFork("/a")).toBe("parent-a");
58
- expect(reg.consumeFork("/b")).toBe("parent-b");
72
+ reg.recordFork("tok_fork1", "parent-A");
73
+ reg.recordFork("tok_fork2", "parent-B");
74
+ // Bridge connect order can be reversed:
75
+ expect(reg.consumeFork("tok_fork2")).toBe("parent-B");
76
+ expect(reg.consumeFork("tok_fork1")).toBe("parent-A");
59
77
  });
60
78
 
61
79
  it("dispose clears all timers", () => {
62
80
  const reg = createPendingForkRegistry();
63
- reg.recordFork("/a", "parent-a");
64
- reg.recordFork("/b", "parent-b");
81
+ reg.recordFork("tok_a", "parent-a");
82
+ reg.recordFork("tok_b", "parent-b");
65
83
  reg.dispose();
66
- expect(reg.consumeFork("/a")).toBeUndefined();
67
- expect(reg.consumeFork("/b")).toBeUndefined();
84
+ expect(reg.consumeFork("tok_a")).toBeUndefined();
85
+ expect(reg.consumeFork("tok_b")).toBeUndefined();
86
+ });
87
+
88
+ it("empty token is rejected on record", () => {
89
+ const reg = createPendingForkRegistry();
90
+ reg.recordFork("", "parent-1");
91
+ expect(reg.consumeFork("")).toBeUndefined();
68
92
  });
69
93
  });