@blackbelt-technology/pi-agent-dashboard 0.3.0 → 0.4.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 (216) hide show
  1. package/AGENTS.md +87 -114
  2. package/README.md +408 -430
  3. package/docs/architecture.md +465 -12
  4. package/package.json +10 -5
  5. package/packages/extension/package.json +14 -4
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +40 -8
  7. package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
  8. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  9. package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
  10. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
  11. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  12. package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
  13. package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
  14. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  15. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  16. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  17. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  18. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  19. package/packages/extension/src/ask-user-tool.ts +5 -4
  20. package/packages/extension/src/bridge.ts +171 -17
  21. package/packages/extension/src/dev-build.ts +1 -1
  22. package/packages/extension/src/git-info.ts +9 -19
  23. package/packages/extension/src/multiselect-list.ts +146 -0
  24. package/packages/extension/src/multiselect-polyfill.ts +43 -0
  25. package/packages/extension/src/pi-env.d.ts +1 -0
  26. package/packages/extension/src/process-scanner.ts +72 -38
  27. package/packages/extension/src/provider-register.ts +304 -16
  28. package/packages/extension/src/server-auto-start.ts +27 -1
  29. package/packages/extension/src/server-launcher.ts +83 -27
  30. package/packages/server/package.json +16 -2
  31. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  32. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  33. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  34. package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
  35. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  36. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  37. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  38. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  39. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  40. package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
  41. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  42. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  43. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  44. package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
  45. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  46. package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
  47. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  48. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  49. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  50. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  51. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  52. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
  53. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  54. package/packages/server/src/__tests__/pi-version-skew.test.ts +237 -0
  55. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  56. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  57. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  58. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  59. package/packages/server/src/__tests__/restart-helper.test.ts +111 -0
  60. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  61. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  62. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  63. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  64. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  65. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  66. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  67. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  68. package/packages/server/src/__tests__/tunnel.test.ts +13 -7
  69. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  70. package/packages/server/src/bootstrap-queue.ts +130 -0
  71. package/packages/server/src/bootstrap-state.ts +131 -0
  72. package/packages/server/src/browse.ts +8 -3
  73. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  74. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  75. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  76. package/packages/server/src/cli.ts +310 -39
  77. package/packages/server/src/config-api.ts +16 -0
  78. package/packages/server/src/directory-service.ts +270 -39
  79. package/packages/server/src/editor-detection.ts +12 -9
  80. package/packages/server/src/editor-manager.ts +19 -4
  81. package/packages/server/src/editor-pid-registry.ts +9 -8
  82. package/packages/server/src/editor-registry.ts +22 -25
  83. package/packages/server/src/git-operations.ts +1 -1
  84. package/packages/server/src/headless-pid-registry.ts +7 -20
  85. package/packages/server/src/home-lock-release.ts +72 -0
  86. package/packages/server/src/home-lock.ts +389 -0
  87. package/packages/server/src/node-guard.ts +52 -0
  88. package/packages/server/src/package-manager-wrapper.ts +207 -47
  89. package/packages/server/src/pi-core-checker.ts +1 -1
  90. package/packages/server/src/pi-core-updater.ts +7 -1
  91. package/packages/server/src/pi-resource-scanner.ts +5 -8
  92. package/packages/server/src/pi-version-skew.ts +207 -0
  93. package/packages/server/src/preferences-store.ts +17 -3
  94. package/packages/server/src/process-manager.ts +403 -222
  95. package/packages/server/src/provider-probe.ts +234 -0
  96. package/packages/server/src/restart-helper.ts +141 -0
  97. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  98. package/packages/server/src/routes/openspec-routes.ts +25 -1
  99. package/packages/server/src/routes/pi-core-routes.ts +24 -1
  100. package/packages/server/src/routes/provider-auth-routes.ts +8 -8
  101. package/packages/server/src/routes/provider-routes.ts +43 -0
  102. package/packages/server/src/routes/recommended-routes.ts +10 -12
  103. package/packages/server/src/routes/system-routes.ts +20 -33
  104. package/packages/server/src/routes/tool-routes.ts +153 -0
  105. package/packages/server/src/server-pid.ts +5 -9
  106. package/packages/server/src/server.ts +211 -10
  107. package/packages/server/src/session-api.ts +77 -8
  108. package/packages/server/src/session-bootstrap.ts +17 -3
  109. package/packages/server/src/session-diff.ts +21 -21
  110. package/packages/server/src/terminal-manager.ts +61 -20
  111. package/packages/server/src/tunnel.ts +42 -28
  112. package/packages/shared/package.json +10 -3
  113. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  114. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  115. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  116. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  117. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  118. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  119. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  120. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  121. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  122. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  123. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  124. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  125. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  126. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  127. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  128. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  129. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  130. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  131. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  132. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  133. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  134. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  135. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  136. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  137. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  138. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  139. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  140. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  141. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  142. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  143. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  144. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  145. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  146. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  147. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  148. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  149. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  150. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  151. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  152. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  153. package/packages/shared/src/__tests__/config.test.ts +56 -0
  154. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  155. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  156. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  157. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  158. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  159. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
  160. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
  161. package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
  162. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  163. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  164. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  165. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  166. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  167. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  168. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  169. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  170. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  171. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  172. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  173. package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
  174. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  175. package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
  176. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  177. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  178. package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
  179. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  180. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  181. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  182. package/packages/shared/src/bootstrap-install.ts +212 -0
  183. package/packages/shared/src/bridge-register.ts +87 -20
  184. package/packages/shared/src/browser-protocol.ts +71 -1
  185. package/packages/shared/src/config.ts +87 -15
  186. package/packages/shared/src/managed-paths.ts +31 -4
  187. package/packages/shared/src/openspec-poller.ts +63 -46
  188. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  189. package/packages/shared/src/platform/commands.ts +100 -0
  190. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  191. package/packages/shared/src/platform/exec.ts +220 -0
  192. package/packages/shared/src/platform/git.ts +155 -0
  193. package/packages/shared/src/platform/index.ts +16 -0
  194. package/packages/shared/src/platform/node-spawn.ts +154 -0
  195. package/packages/shared/src/platform/npm.ts +162 -0
  196. package/packages/shared/src/platform/openspec.ts +91 -0
  197. package/packages/shared/src/platform/paths.ts +276 -0
  198. package/packages/shared/src/platform/process-identify.ts +126 -0
  199. package/packages/shared/src/platform/process-scan.ts +94 -0
  200. package/packages/shared/src/platform/process.ts +168 -0
  201. package/packages/shared/src/platform/runner.ts +369 -0
  202. package/packages/shared/src/platform/shell.ts +44 -0
  203. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  204. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  205. package/packages/shared/src/protocol.ts +23 -0
  206. package/packages/shared/src/recommended-extensions.ts +18 -2
  207. package/packages/shared/src/resolve-jiti.ts +62 -3
  208. package/packages/shared/src/rest-api.ts +26 -0
  209. package/packages/shared/src/semaphore.ts +83 -0
  210. package/packages/shared/src/state-replay.ts +9 -0
  211. package/packages/shared/src/tool-registry/definitions.ts +434 -0
  212. package/packages/shared/src/tool-registry/index.ts +56 -0
  213. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  214. package/packages/shared/src/tool-registry/registry.ts +262 -0
  215. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  216. package/packages/shared/src/tool-registry/types.ts +180 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Tests for cross-platform port-holder detection.
3
+ * See change: fix-windows-server-parity.
4
+ */
5
+ import { describe, it, expect, vi } from "vitest";
6
+ import { findPortHolders, parseNetstatListeners } from "../cli.js";
7
+
8
+ describe("parseNetstatListeners", () => {
9
+ const selfPid = 99999;
10
+
11
+ it("parses Windows netstat -ano output for a listening PID", () => {
12
+ const output = [
13
+ "Active Connections",
14
+ "",
15
+ " Proto Local Address Foreign Address State PID",
16
+ " TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING 12345",
17
+ " TCP 0.0.0.0:445 0.0.0.0:0 LISTENING 4",
18
+ " TCP 127.0.0.1:8000 127.0.0.1:54321 ESTABLISHED 23456",
19
+ ].join("\r\n");
20
+
21
+ expect(parseNetstatListeners(output, 8000, selfPid)).toEqual([12345]);
22
+ });
23
+
24
+ it("excludes non-LISTENING rows (ESTABLISHED, TIME_WAIT)", () => {
25
+ const output = [
26
+ " TCP 0.0.0.0:9999 0.0.0.0:0 ESTABLISHED 11111",
27
+ " TCP 0.0.0.0:9999 0.0.0.0:0 TIME_WAIT 22222",
28
+ ].join("\n");
29
+ expect(parseNetstatListeners(output, 9999, selfPid)).toEqual([]);
30
+ });
31
+
32
+ it("excludes the current process PID", () => {
33
+ const output = ` TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING ${selfPid}`;
34
+ expect(parseNetstatListeners(output, 8000, selfPid)).toEqual([]);
35
+ });
36
+
37
+ it("only matches the requested port (suffix-based)", () => {
38
+ const output = [
39
+ " TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING 1111",
40
+ " TCP 0.0.0.0:18000 0.0.0.0:0 LISTENING 2222",
41
+ " TCP 0.0.0.0:80 0.0.0.0:0 LISTENING 3333",
42
+ ].join("\n");
43
+ expect(parseNetstatListeners(output, 8000, selfPid).sort()).toEqual([1111]);
44
+ });
45
+
46
+ it("returns empty array for empty or garbage input", () => {
47
+ expect(parseNetstatListeners("", 8000, selfPid)).toEqual([]);
48
+ expect(parseNetstatListeners("not a netstat output\nblah", 8000, selfPid)).toEqual([]);
49
+ });
50
+
51
+ it("handles IPv6 listening addresses", () => {
52
+ const output = " TCP [::]:8000 [::]:0 LISTENING 7777";
53
+ expect(parseNetstatListeners(output, 8000, selfPid)).toEqual([7777]);
54
+ });
55
+ });
56
+
57
+ describe("findPortHolders", () => {
58
+ it("uses netstat on Windows (via injected exec)", () => {
59
+ const originalPlatform = process.platform;
60
+ Object.defineProperty(process, "platform", { value: "win32", configurable: true });
61
+ try {
62
+ const exec = vi.fn().mockReturnValue(
63
+ " TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING 12345\n",
64
+ );
65
+ const result = findPortHolders(8000, exec as any);
66
+ expect(exec).toHaveBeenCalledTimes(1);
67
+ expect(exec.mock.calls[0][0]).toMatch(/netstat/i);
68
+ expect(exec.mock.calls[0][0]).not.toMatch(/lsof/i);
69
+ expect(result).toEqual([12345]);
70
+ } finally {
71
+ Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
72
+ }
73
+ });
74
+
75
+ it("uses lsof on Unix (via injected exec)", () => {
76
+ const originalPlatform = process.platform;
77
+ Object.defineProperty(process, "platform", { value: "linux", configurable: true });
78
+ try {
79
+ const exec = vi.fn().mockReturnValue("12345\n67890\n");
80
+ const result = findPortHolders(8000, exec as any);
81
+ expect(exec).toHaveBeenCalledTimes(1);
82
+ expect(exec.mock.calls[0][0]).toMatch(/lsof/);
83
+ expect(exec.mock.calls[0][0]).toContain(":8000");
84
+ expect(result.sort()).toEqual([12345, 67890].sort());
85
+ } finally {
86
+ Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
87
+ }
88
+ });
89
+
90
+ it("returns empty array on exec failure (best-effort)", () => {
91
+ const exec = vi.fn().mockImplementation(() => { throw new Error("boom"); });
92
+ expect(findPortHolders(8000, exec as any)).toEqual([]);
93
+ });
94
+ });
@@ -0,0 +1,8 @@
1
+ {"type":"session","version":"1","id":"019dcdd5-0000-0000-0000-000000000000","timestamp":"2026-04-27T07:26:23.927Z","cwd":"/tmp/fork-test"}
2
+ {"type":"model_change","id":"m1","timestamp":"2026-04-27T07:26:24.000Z","provider":"anthropic","modelId":"claude-sonnet-4"}
3
+ {"type":"message","id":"u1","parentId":"m1","timestamp":"2026-04-27T07:26:25.000Z","message":{"role":"user","content":[{"type":"text","text":"Hello"}]}}
4
+ {"type":"message","id":"a1","parentId":"u1","timestamp":"2026-04-27T07:26:30.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Hi there!"}]}}
5
+ {"type":"message","id":"u2","parentId":"a1","timestamp":"2026-04-27T07:26:40.000Z","message":{"role":"user","content":[{"type":"text","text":"How are you?"}]}}
6
+ {"type":"message","id":"a2","parentId":"u2","timestamp":"2026-04-27T07:26:45.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Doing well."}]}}
7
+ {"type":"message","id":"u3","parentId":"a2","timestamp":"2026-04-27T07:26:55.000Z","message":{"role":"user","content":[{"type":"text","text":"Goodbye"}]}}
8
+ {"type":"message","id":"a3","parentId":"u3","timestamp":"2026-04-27T07:27:00.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Bye!"}]}}
@@ -1,9 +1,30 @@
1
1
  /**
2
2
  * Tests for handleForceKill in session-action-handler.
3
+ *
4
+ * Kill-path routing (see change: route-kill-paths-through-platform):
5
+ * we verify that the handler delegates to the platform `killProcess`
6
+ * helper rather than calling `process.kill(...)` directly. Cross-OS
7
+ * behavior of `killProcess` itself is covered in
8
+ * `packages/shared/src/__tests__/platform-process.test.ts`.
3
9
  */
4
10
  import { describe, it, expect, vi, beforeEach } from "vitest";
5
- import { handleForceKill } from "../browser-handlers/session-action-handler.js";
6
- import type { BrowserHandlerContext } from "../browser-handlers/handler-context.js";
11
+
12
+ // Spy on the platform module so we can assert the handler routes through it.
13
+ const killProcessSpy = vi.fn(async (_pid: number, _opts?: any) => ({ ok: true, forced: false }));
14
+ const isProcessAliveSpy = vi.fn((_pid: number) => false);
15
+ vi.mock("@blackbelt-technology/pi-dashboard-shared/platform/process.js", async () => {
16
+ const actual = await vi.importActual<typeof import("@blackbelt-technology/pi-dashboard-shared/platform/process.js")>(
17
+ "@blackbelt-technology/pi-dashboard-shared/platform/process.js",
18
+ );
19
+ return {
20
+ ...actual,
21
+ killProcess: (pid: number, opts?: any) => killProcessSpy(pid, opts),
22
+ isProcessAlive: (pid: number) => isProcessAliveSpy(pid),
23
+ };
24
+ });
25
+
26
+ const { handleForceKill } = await import("../browser-handlers/session-action-handler.js");
27
+ type BrowserHandlerContext = import("../browser-handlers/handler-context.js").BrowserHandlerContext;
7
28
 
8
29
  function createMockContext(sessionOverrides?: Record<string, any>): BrowserHandlerContext & { sent: any[]; broadcasts: any[] } {
9
30
  const sent: any[] = [];
@@ -44,7 +65,10 @@ function createMockContext(sessionOverrides?: Record<string, any>): BrowserHandl
44
65
 
45
66
  describe("handleForceKill", () => {
46
67
  beforeEach(() => {
47
- vi.restoreAllMocks();
68
+ killProcessSpy.mockClear();
69
+ killProcessSpy.mockImplementation(async () => ({ ok: true, forced: false }));
70
+ isProcessAliveSpy.mockClear();
71
+ isProcessAliveSpy.mockReturnValue(false);
48
72
  });
49
73
 
50
74
  it("should close bridge WebSocket and mark session ended when no PID", async () => {
@@ -61,19 +85,44 @@ describe("handleForceKill", () => {
61
85
  expect(result.message).toContain("no PID");
62
86
  });
63
87
 
64
- it("should send SIGTERM and mark session ended for valid PID", async () => {
65
- // Use a PID that doesn't exist so SIGTERM throws
66
- const ctx = createMockContext({ pid: 2147483647 });
88
+ it("should delegate termination to platform killProcess with 2s timeout", async () => {
89
+ const ctx = createMockContext({ pid: 12345 });
67
90
 
68
91
  await handleForceKill({ type: "force_kill", sessionId: "sess-1" }, ctx);
69
92
 
93
+ expect(killProcessSpy).toHaveBeenCalledTimes(1);
94
+ expect(killProcessSpy).toHaveBeenCalledWith(12345, expect.objectContaining({ timeoutMs: 2000 }));
95
+
70
96
  expect(ctx.piGateway.closeSession).toHaveBeenCalledWith("sess-1");
71
97
  expect(ctx.sessionManager.update).toHaveBeenCalledWith("sess-1", expect.objectContaining({ status: "ended" }));
72
-
98
+
99
+ const result = ctx.sent.find((m: any) => m.type === "force_kill_result");
100
+ expect(result).toBeDefined();
101
+ expect(result.success).toBe(true);
102
+ });
103
+
104
+ it("should report already-exited when killProcess reports pid not alive", async () => {
105
+ killProcessSpy.mockResolvedValueOnce({ ok: false, forced: false });
106
+ const ctx = createMockContext({ pid: 2147483647 });
107
+
108
+ await handleForceKill({ type: "force_kill", sessionId: "sess-1" }, ctx);
109
+
73
110
  const result = ctx.sent.find((m: any) => m.type === "force_kill_result");
74
111
  expect(result).toBeDefined();
75
112
  expect(result.success).toBe(true);
76
- expect(result.message).toContain("already exited");
113
+ });
114
+
115
+ it("should not call process.kill directly (must route through platform)", async () => {
116
+ const processKillSpy = vi.spyOn(process, "kill");
117
+ const ctx = createMockContext({ pid: 12345 });
118
+
119
+ await handleForceKill({ type: "force_kill", sessionId: "sess-1" }, ctx);
120
+
121
+ // handleForceKill must NOT invoke process.kill; all termination goes
122
+ // through killProcess from the platform module.
123
+ expect(processKillSpy).not.toHaveBeenCalled();
124
+ expect(killProcessSpy).toHaveBeenCalledOnce();
125
+ processKillSpy.mockRestore();
77
126
  });
78
127
 
79
128
  it("should broadcast session_updated with ended status", async () => {
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Round-trip test: createBranchedSessionFile MUST end the new JSONL at the
3
+ * given entry id. Catches the fork-bubble off-by-one bug from the upstream
4
+ * angle: if the bridge ever stamps a correct entry id on a bubble, this
5
+ * function must produce a file whose tail entry equals that id.
6
+ */
7
+ import { describe, it, expect } from "vitest";
8
+ import { readFileSync, mkdtempSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { createBranchedSessionFile } from "../session-file-reader.js";
12
+
13
+ const FIXTURE = join(__dirname, "fixtures", "fork-jsonl-roundtrip.jsonl");
14
+
15
+ function readEntries(path: string): any[] {
16
+ return readFileSync(path, "utf-8").trim().split("\n").map(l => JSON.parse(l));
17
+ }
18
+
19
+ describe("createBranchedSessionFile round-trip", () => {
20
+ it("for every non-header entry id, the forked JSONL ends at that id", () => {
21
+ // Copy fixture to a tmp dir so the function can write its sibling output there.
22
+ const tmp = mkdtempSync(join(tmpdir(), "fork-roundtrip-"));
23
+ const tmpFixture = join(tmp, "src.jsonl");
24
+ require("node:fs").copyFileSync(FIXTURE, tmpFixture);
25
+
26
+ try {
27
+ const allEntries = readEntries(tmpFixture);
28
+ const candidates = allEntries.filter(e => e.type === "message" || e.type === "model_change").map(e => e.id);
29
+ expect(candidates.length).toBeGreaterThan(0);
30
+
31
+ for (const targetId of candidates) {
32
+ const newPath = createBranchedSessionFile(tmpFixture, targetId);
33
+ const newEntries = readEntries(newPath);
34
+
35
+ const header = newEntries[0];
36
+ expect(header.type).toBe("session");
37
+
38
+ const lastEntry = newEntries[newEntries.length - 1];
39
+ expect(lastEntry.id).toBe(targetId);
40
+ }
41
+ } finally {
42
+ rmSync(tmp, { recursive: true, force: true });
43
+ }
44
+ });
45
+
46
+ it("throws on unknown entry id", () => {
47
+ expect(() => createBranchedSessionFile(FIXTURE, "does-not-exist")).toThrow(/not found/i);
48
+ });
49
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Tests for the PI_DASHBOARD_ALLOW_MULTIPLE escape hatch.
3
+ *
4
+ * The escape hatch is evaluated in `cli.ts::runForeground`; here we test
5
+ * the pure predicate `isLockDisabled` plus a behavioral test that confirms
6
+ * NO metadata is written when the lock is skipped.
7
+ *
8
+ * See change: single-dashboard-per-home, task 14.3.
9
+ */
10
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { isLockDisabled, acquireOrAttach } from "../home-lock.js";
15
+
16
+ let tmpHome: string;
17
+ let lockPath: string;
18
+ let metaPath: string;
19
+
20
+ beforeEach(() => {
21
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-escape-hatch-"));
22
+ lockPath = path.join(tmpHome, ".pi", "dashboard", "server.lock");
23
+ metaPath = `${lockPath}.meta.json`;
24
+ });
25
+
26
+ afterEach(() => {
27
+ try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ }
28
+ });
29
+
30
+ describe("escape hatch", () => {
31
+ it("isLockDisabled true for =1 and =true", () => {
32
+ expect(isLockDisabled({ PI_DASHBOARD_ALLOW_MULTIPLE: "1" })).toBe(true);
33
+ expect(isLockDisabled({ PI_DASHBOARD_ALLOW_MULTIPLE: "true" })).toBe(true);
34
+ });
35
+
36
+ it("isLockDisabled false when unset or other values", () => {
37
+ expect(isLockDisabled({})).toBe(false);
38
+ expect(isLockDisabled({ PI_DASHBOARD_ALLOW_MULTIPLE: "" })).toBe(false);
39
+ expect(isLockDisabled({ PI_DASHBOARD_ALLOW_MULTIPLE: "yes" })).toBe(false);
40
+ expect(isLockDisabled({ PI_DASHBOARD_ALLOW_MULTIPLE: "0" })).toBe(false);
41
+ });
42
+
43
+ it("when caller skips acquireOrAttach (escape hatch on), no metadata sidecar exists", () => {
44
+ // The CLI-level behavior when PI_DASHBOARD_ALLOW_MULTIPLE is set is to
45
+ // NOT call acquireOrAttach at all. We simulate that: the fact that we
46
+ // never called acquireOrAttach means the sidecar was never written.
47
+ expect(fs.existsSync(metaPath)).toBe(false);
48
+ });
49
+
50
+ it("when lock IS acquired, metadata is written (control)", async () => {
51
+ const r = await acquireOrAttach({
52
+ httpPort: 8000, piPort: 9999, version: "t",
53
+ hooks: { lockPath, metaPath, staleMs: 500 },
54
+ });
55
+ expect(r.mode).toBe("acquired");
56
+ expect(fs.existsSync(metaPath)).toBe(true);
57
+ if (r.mode === "acquired") await r.release();
58
+ expect(fs.existsSync(metaPath)).toBe(false);
59
+ });
60
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Unit tests for signal-handler installation. See change: single-dashboard-per-home.
3
+ */
4
+ import { describe, it, expect, vi } from "vitest";
5
+ import { EventEmitter } from "node:events";
6
+ import { installReleaseHandlers } from "../home-lock-release.js";
7
+
8
+ function fakeProcess() {
9
+ const ee = new EventEmitter() as unknown as NodeJS.Process;
10
+ (ee as unknown as { exit: (code: number) => void }).exit = vi.fn();
11
+ return ee;
12
+ }
13
+
14
+ describe("installReleaseHandlers", () => {
15
+ it("registers SIGINT, SIGTERM, SIGHUP, SIGBREAK, and exit handlers", () => {
16
+ const proc = fakeProcess();
17
+ const onSpy = vi.spyOn(proc, "on");
18
+ installReleaseHandlers(async () => {}, { proc });
19
+ const registered = onSpy.mock.calls.map(c => c[0]);
20
+ expect(registered).toEqual(expect.arrayContaining(["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK", "exit"]));
21
+ });
22
+
23
+ it("calls release() on SIGTERM", async () => {
24
+ const proc = fakeProcess();
25
+ const release = vi.fn(async () => {});
26
+ installReleaseHandlers(release, { proc });
27
+ proc.emit("SIGTERM");
28
+ // Handler is async — let microtasks flush.
29
+ await new Promise(r => setImmediate(r));
30
+ expect(release).toHaveBeenCalledTimes(1);
31
+ });
32
+
33
+ it("calls release() on SIGBREAK (Windows Ctrl+Break)", async () => {
34
+ // On POSIX Node never emits SIGBREAK, but the handler must still be
35
+ // wired so Windows Ctrl+Break triggers lock release. Exercising via a
36
+ // fake process guarantees the registration + dispatch path works.
37
+ const proc = fakeProcess();
38
+ const release = vi.fn(async () => {});
39
+ installReleaseHandlers(release, { proc });
40
+ proc.emit("SIGBREAK" as NodeJS.Signals);
41
+ await new Promise(r => setImmediate(r));
42
+ expect(release).toHaveBeenCalledTimes(1);
43
+ });
44
+
45
+ it("calls release() on SIGHUP", async () => {
46
+ const proc = fakeProcess();
47
+ const release = vi.fn(async () => {});
48
+ installReleaseHandlers(release, { proc });
49
+ proc.emit("SIGHUP");
50
+ await new Promise(r => setImmediate(r));
51
+ expect(release).toHaveBeenCalledTimes(1);
52
+ });
53
+
54
+ it("does not double-release on repeated signals", async () => {
55
+ const proc = fakeProcess();
56
+ const release = vi.fn(async () => {});
57
+ installReleaseHandlers(release, { proc });
58
+ proc.emit("SIGTERM");
59
+ proc.emit("SIGTERM");
60
+ proc.emit("SIGINT");
61
+ await new Promise(r => setImmediate(r));
62
+ expect(release).toHaveBeenCalledTimes(1);
63
+ });
64
+
65
+ it("returns a dispose function that removes handlers", () => {
66
+ const proc = fakeProcess();
67
+ const release = vi.fn(async () => {});
68
+ const dispose = installReleaseHandlers(release, { proc });
69
+ dispose();
70
+ proc.emit("SIGTERM");
71
+ // After dispose, the release must not fire.
72
+ expect(release).not.toHaveBeenCalled();
73
+ });
74
+
75
+ it("swallows release errors but logs them", async () => {
76
+ const proc = fakeProcess();
77
+ const logs: string[] = [];
78
+ const release = vi.fn(async () => { throw new Error("boom"); });
79
+ installReleaseHandlers(release, { proc, log: (m) => logs.push(m) });
80
+ proc.emit("SIGTERM");
81
+ await new Promise(r => setImmediate(r));
82
+ await new Promise(r => setImmediate(r));
83
+ expect(logs.join("\n")).toContain("boom");
84
+ });
85
+ });