@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,105 @@
1
+ /**
2
+ * Unit tests for the shell-callable resolver wrapper at
3
+ * `packages/shared/bin/pi-dashboard-resolve-tool.cjs`.
4
+ *
5
+ * We spawn the wrapper as a child process (matching its real-world use)
6
+ * and assert stdout/stderr/exit-code per the spec scenarios in
7
+ * openspec/changes/register-build-time-tools/specs/tool-registry/spec.md
8
+ *
9
+ * See change: register-build-time-tools.
10
+ */
11
+ import { describe, expect, it } from "vitest";
12
+ import { spawnSync } from "node:child_process";
13
+ import path from "node:path";
14
+ import url from "node:url";
15
+
16
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
17
+ const repoRoot = path.resolve(here, "..", "..", "..", "..");
18
+ const SCRIPT = path.join(
19
+ repoRoot,
20
+ "packages",
21
+ "shared",
22
+ "bin",
23
+ "pi-dashboard-resolve-tool.cjs",
24
+ );
25
+
26
+ function run(args: string[]) {
27
+ return spawnSync(process.execPath, [SCRIPT, ...args], {
28
+ cwd: repoRoot,
29
+ encoding: "utf8",
30
+ // Isolate from any user override file so tests are deterministic.
31
+ env: {
32
+ ...process.env,
33
+ // Point HOME at /tmp so ~/.pi/dashboard/tool-overrides.json is
34
+ // (almost certainly) absent, keeping the resolver in the
35
+ // bare-import branch.
36
+ HOME: "/tmp/pi-dashboard-resolve-tool-test",
37
+ },
38
+ });
39
+ }
40
+
41
+ describe("pi-dashboard-resolve-tool.cjs", () => {
42
+ it("prints absolute path to stdout for `electron`", () => {
43
+ const r = run(["electron"]);
44
+ expect(r.status).toBe(0);
45
+ expect(r.stdout.trim()).toMatch(/[\\/]node_modules[\\/]electron$/);
46
+ expect(r.stderr).toBe("");
47
+ });
48
+
49
+ it("prints absolute path to stdout for `node-pty`", () => {
50
+ const r = run(["node-pty"]);
51
+ expect(r.status).toBe(0);
52
+ expect(r.stdout.trim()).toMatch(/[\\/]node_modules[\\/]node-pty$/);
53
+ expect(r.stderr).toBe("");
54
+ });
55
+
56
+ it("emits JSON Resolution shape with --json", () => {
57
+ const r = run(["electron", "--json"]);
58
+ expect(r.status).toBe(0);
59
+ const parsed = JSON.parse(r.stdout);
60
+ expect(parsed.name).toBe("electron");
61
+ expect(parsed.ok).toBe(true);
62
+ expect(parsed.path).toMatch(/electron$/);
63
+ expect(parsed.source).toBe("bare-import");
64
+ expect(Array.isArray(parsed.tried)).toBe(true);
65
+ // First strategy attempted is `override`, then `bare-import`.
66
+ expect(parsed.tried.map((t: { strategy: string }) => t.strategy)).toEqual([
67
+ "override",
68
+ "bare-import",
69
+ ]);
70
+ expect(typeof parsed.resolvedAt).toBe("number");
71
+ });
72
+
73
+ it("exits 1 with stderr when tool name is unknown", () => {
74
+ const r = run(["nonexistent-tool"]);
75
+ expect(r.status).toBe(1);
76
+ expect(r.stdout).toBe("");
77
+ expect(r.stderr).toContain("nonexistent-tool");
78
+ expect(r.stderr).toContain("not registered");
79
+ });
80
+
81
+ it("exits 1 with stderr when --json is passed for unknown tool", () => {
82
+ const r = run(["nonexistent-tool", "--json"]);
83
+ expect(r.status).toBe(1);
84
+ expect(r.stderr).toContain("not registered");
85
+ });
86
+
87
+ it("exits 1 with usage message when no tool name given", () => {
88
+ const r = run([]);
89
+ expect(r.status).toBe(1);
90
+ expect(r.stderr).toContain("usage:");
91
+ expect(r.stderr).toContain("registered:");
92
+ });
93
+
94
+ it("strategy chain order in --json mirrors definitions.ts", () => {
95
+ // Both build-time tools share the same chain shape: override → bare-import.
96
+ for (const tool of ["electron", "node-pty"]) {
97
+ const r = run([tool, "--json"]);
98
+ expect(r.status).toBe(0);
99
+ const parsed = JSON.parse(r.stdout);
100
+ expect(
101
+ parsed.tried.map((t: { strategy: string }) => t.strategy),
102
+ ).toEqual(["override", "bare-import"]);
103
+ }
104
+ });
105
+ });
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createSemaphore } from "../semaphore.js";
3
+
4
+ function defer<T = void>(): { promise: Promise<T>; resolve: (v: T) => void; reject: (e: any) => void } {
5
+ let resolve!: (v: T) => void;
6
+ let reject!: (e: any) => void;
7
+ const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
8
+ return { promise, resolve, reject };
9
+ }
10
+
11
+ describe("createSemaphore", () => {
12
+ it("throws when max < 1", () => {
13
+ expect(() => createSemaphore(0)).toThrow();
14
+ expect(() => createSemaphore(-1)).toThrow();
15
+ });
16
+
17
+ it("runs tasks immediately up to the cap", async () => {
18
+ const sem = createSemaphore(2);
19
+ const a = defer<string>();
20
+ const b = defer<string>();
21
+ const pa = sem.run(() => a.promise);
22
+ const pb = sem.run(() => b.promise);
23
+ expect(sem.size()).toBe(2);
24
+ a.resolve("a");
25
+ b.resolve("b");
26
+ expect(await pa).toBe("a");
27
+ expect(await pb).toBe("b");
28
+ expect(sem.size()).toBe(0);
29
+ });
30
+
31
+ it("caps concurrency: third task waits", async () => {
32
+ const sem = createSemaphore(2);
33
+ const a = defer<string>();
34
+ const b = defer<string>();
35
+ const c = defer<string>();
36
+
37
+ let cStarted = false;
38
+ const pa = sem.run(() => a.promise);
39
+ const pb = sem.run(() => b.promise);
40
+ const pc = sem.run(() => { cStarted = true; return c.promise; });
41
+
42
+ // Wait a microtask for queue placement
43
+ await Promise.resolve();
44
+ expect(cStarted).toBe(false);
45
+ expect(sem.size()).toBe(3); // active + queued
46
+
47
+ a.resolve("a");
48
+ await pa;
49
+ // c should now be started
50
+ await Promise.resolve();
51
+ expect(cStarted).toBe(true);
52
+ b.resolve("b");
53
+ c.resolve("c");
54
+ expect(await pb).toBe("b");
55
+ expect(await pc).toBe("c");
56
+ });
57
+
58
+ it("FIFO order of queued tasks", async () => {
59
+ const sem = createSemaphore(1);
60
+ const order: string[] = [];
61
+ const blockers = [defer<void>(), defer<void>(), defer<void>()];
62
+ const ps = blockers.map((d, i) =>
63
+ sem.run(async () => { order.push(`start-${i}`); await d.promise; order.push(`end-${i}`); }),
64
+ );
65
+ await Promise.resolve();
66
+ blockers[0].resolve(); await ps[0];
67
+ blockers[1].resolve(); await ps[1];
68
+ blockers[2].resolve(); await ps[2];
69
+ expect(order).toEqual(["start-0", "end-0", "start-1", "end-1", "start-2", "end-2"]);
70
+ });
71
+
72
+ it("releases slot on reject so queued tasks still run", async () => {
73
+ const sem = createSemaphore(1);
74
+ const failed = sem.run(async () => { throw new Error("boom"); });
75
+ await expect(failed).rejects.toThrow("boom");
76
+ const ok = sem.run(async () => "ok");
77
+ expect(await ok).toBe("ok");
78
+ });
79
+
80
+ it("setMax increases cap and drains queued tasks immediately", async () => {
81
+ const sem = createSemaphore(1);
82
+ const a = defer<void>();
83
+ const b = defer<void>();
84
+ let bStarted = false;
85
+ const pa = sem.run(() => a.promise);
86
+ const pb = sem.run(() => { bStarted = true; return b.promise; });
87
+ await Promise.resolve();
88
+ expect(bStarted).toBe(false);
89
+
90
+ sem.setMax(2);
91
+ await Promise.resolve();
92
+ expect(bStarted).toBe(true);
93
+
94
+ a.resolve(); b.resolve();
95
+ await pa; await pb;
96
+ });
97
+
98
+ it("setMax shrinking does not interrupt in-flight tasks but caps new ones", async () => {
99
+ const sem = createSemaphore(3);
100
+ const a = defer<void>();
101
+ const b = defer<void>();
102
+ const pa = sem.run(() => a.promise);
103
+ const pb = sem.run(() => b.promise);
104
+
105
+ sem.setMax(1);
106
+ const c = defer<void>();
107
+ let cStarted = false;
108
+ const pc = sem.run(() => { cStarted = true; return c.promise; });
109
+ await Promise.resolve();
110
+ expect(cStarted).toBe(false);
111
+
112
+ a.resolve(); b.resolve();
113
+ await pa; await pb;
114
+ await Promise.resolve();
115
+ expect(cStarted).toBe(true);
116
+ c.resolve();
117
+ await pc;
118
+ });
119
+ });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Tests for platform/spawn-mechanism.ts pure selector + argv builders.
3
+ *
4
+ * Every test passes `platform` explicitly. Never mutates process.platform.
5
+ */
6
+ import { describe, it, expect } from "vitest";
7
+ import {
8
+ selectMechanism,
9
+ buildWtArgs,
10
+ sessionFlagsToArgv,
11
+ type SpawnMechanismContext,
12
+ } from "../platform/spawn-mechanism.js";
13
+
14
+ function ctx(overrides: Partial<SpawnMechanismContext> = {}): SpawnMechanismContext {
15
+ return {
16
+ platform: "linux",
17
+ userStrategy: "tmux",
18
+ electronMode: false,
19
+ available: { tmux: false, wt: false, wslTmux: false },
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ describe("selectMechanism", () => {
25
+ it("electron mode always returns headless", () => {
26
+ expect(selectMechanism(ctx({ electronMode: true, platform: "win32", available: { tmux: false, wt: true, wslTmux: true } }))).toBe("headless");
27
+ expect(selectMechanism(ctx({ electronMode: true, platform: "linux", available: { tmux: true, wt: false, wslTmux: false } }))).toBe("headless");
28
+ expect(selectMechanism(ctx({ electronMode: true, platform: "darwin", available: { tmux: true, wt: false, wslTmux: false } }))).toBe("headless");
29
+ });
30
+
31
+ it("userStrategy headless always returns headless", () => {
32
+ expect(selectMechanism(ctx({ userStrategy: "headless", platform: "win32", available: { tmux: false, wt: true, wslTmux: true } }))).toBe("headless");
33
+ expect(selectMechanism(ctx({ userStrategy: "headless", platform: "linux", available: { tmux: true, wt: false, wslTmux: false } }))).toBe("headless");
34
+ });
35
+
36
+ it("Linux with tmux returns tmux", () => {
37
+ expect(selectMechanism(ctx({ platform: "linux", available: { tmux: true, wt: false, wslTmux: false } }))).toBe("tmux");
38
+ });
39
+
40
+ it("macOS with tmux returns tmux", () => {
41
+ expect(selectMechanism(ctx({ platform: "darwin", available: { tmux: true, wt: false, wslTmux: false } }))).toBe("tmux");
42
+ });
43
+
44
+ it("Linux without tmux returns headless", () => {
45
+ expect(selectMechanism(ctx({ platform: "linux", available: { tmux: false, wt: false, wslTmux: false } }))).toBe("headless");
46
+ });
47
+
48
+ it("Windows with wt returns wt", () => {
49
+ expect(selectMechanism(ctx({ platform: "win32", available: { tmux: false, wt: true, wslTmux: false } }))).toBe("wt");
50
+ });
51
+
52
+ it("Windows with wt AND wsl-tmux prefers wt", () => {
53
+ expect(selectMechanism(ctx({ platform: "win32", available: { tmux: false, wt: true, wslTmux: true } }))).toBe("wt");
54
+ });
55
+
56
+ it("Windows with only wsl-tmux returns wsl-tmux", () => {
57
+ expect(selectMechanism(ctx({ platform: "win32", available: { tmux: false, wt: false, wslTmux: true } }))).toBe("wsl-tmux");
58
+ });
59
+
60
+ it("Windows with nothing available returns headless", () => {
61
+ expect(selectMechanism(ctx({ platform: "win32", available: { tmux: false, wt: false, wslTmux: false } }))).toBe("headless");
62
+ });
63
+
64
+ it("unknown platform falls back to headless", () => {
65
+ expect(selectMechanism(ctx({ platform: "openbsd" as NodeJS.Platform, available: { tmux: true, wt: false, wslTmux: false } }))).toBe("headless");
66
+ });
67
+ });
68
+
69
+ describe("buildWtArgs", () => {
70
+ it("produces argv in expected order", () => {
71
+ const argv = buildWtArgs({
72
+ cwd: "C:\\proj",
73
+ title: "proj",
74
+ piArgv: ["C:\\node.exe", "cli.js", "--mode", "rpc"],
75
+ });
76
+ expect(argv).toEqual([
77
+ "-w", "0",
78
+ "new-tab",
79
+ "-d", "C:\\proj",
80
+ "--title", "proj",
81
+ "--",
82
+ "C:\\node.exe", "cli.js", "--mode", "rpc",
83
+ ]);
84
+ });
85
+
86
+ it("preserves cwd with spaces as a single argv element", () => {
87
+ const argv = buildWtArgs({
88
+ cwd: "C:\\Users\\Bob's Project (2)",
89
+ title: "x",
90
+ piArgv: ["pi"],
91
+ });
92
+ expect(argv).toContain("C:\\Users\\Bob's Project (2)");
93
+ expect(argv.filter(a => a.includes("Bob"))).toHaveLength(1);
94
+ });
95
+
96
+ it("places piArgv after -- sentinel with --fork intact", () => {
97
+ const argv = buildWtArgs({
98
+ cwd: "C:\\proj",
99
+ title: "proj",
100
+ piArgv: ["node.exe", "cli.js", "--fork", "C:\\x\\session.jsonl"],
101
+ });
102
+ const sentinelIdx = argv.indexOf("--");
103
+ expect(sentinelIdx).toBeGreaterThan(0);
104
+ expect(argv.slice(sentinelIdx + 1)).toEqual(["node.exe", "cli.js", "--fork", "C:\\x\\session.jsonl"]);
105
+ });
106
+
107
+ it("never includes -p profile flag", () => {
108
+ const argv = buildWtArgs({ cwd: "C:\\x", title: "y", piArgv: ["pi"] });
109
+ expect(argv).not.toContain("-p");
110
+ });
111
+ });
112
+
113
+ describe("sessionFlagsToArgv", () => {
114
+ it("returns --session file for continue mode", () => {
115
+ expect(sessionFlagsToArgv({ sessionFile: "/s/abc.jsonl", mode: "continue" })).toEqual(["--session", "/s/abc.jsonl"]);
116
+ });
117
+
118
+ it("returns --fork file for fork mode", () => {
119
+ expect(sessionFlagsToArgv({ sessionFile: "C:\\s\\abc.jsonl", mode: "fork" })).toEqual(["--fork", "C:\\s\\abc.jsonl"]);
120
+ });
121
+
122
+ it("returns empty array with no file", () => {
123
+ expect(sessionFlagsToArgv({})).toEqual([]);
124
+ expect(sessionFlagsToArgv({ mode: "continue" })).toEqual([]);
125
+ expect(sessionFlagsToArgv({ mode: "fork" })).toEqual([]);
126
+ });
127
+
128
+ it("returns empty array with file but no mode", () => {
129
+ expect(sessionFlagsToArgv({ sessionFile: "/s/x.jsonl" })).toEqual([]);
130
+ });
131
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Round-trip test for state-replay (per change: fix-per-message-fork):
3
+ * for every persisted entry, the reducer-equivalent message_start /
4
+ * message_end carries entryId === entry.id. Replay does NOT need
5
+ * entry_persisted back-fill because it reads from the persisted JSONL.
6
+ */
7
+ import { describe, it, expect } from "vitest";
8
+ import { replayEntriesAsEvents } from "../state-replay.js";
9
+
10
+ describe("replayEntriesAsEvents — entryId fidelity", () => {
11
+ it("stamps entryId on user message_start matching the source entry id", () => {
12
+ const sessionId = "sess-1";
13
+ const entries = [
14
+ {
15
+ type: "message",
16
+ id: "u1",
17
+ parentId: "root",
18
+ timestamp: "2026-04-27T07:26:25.000Z",
19
+ message: { role: "user", content: [{ type: "text", text: "Hello" }] },
20
+ },
21
+ ];
22
+
23
+ const events = replayEntriesAsEvents(sessionId, entries);
24
+ const start = events.find((e) => e.event.eventType === "message_start");
25
+ expect(start).toBeDefined();
26
+ expect((start!.event.data as any).entryId).toBe("u1");
27
+ });
28
+
29
+ it("stamps entryId on assistant message_end matching the source entry id", () => {
30
+ const sessionId = "sess-1";
31
+ const entries = [
32
+ {
33
+ type: "message",
34
+ id: "a1",
35
+ parentId: "u1",
36
+ timestamp: "2026-04-27T07:26:30.000Z",
37
+ message: { role: "assistant", content: [{ type: "text", text: "Hi!" }] },
38
+ },
39
+ ];
40
+
41
+ const events = replayEntriesAsEvents(sessionId, entries);
42
+ const end = events.find((e) => e.event.eventType === "message_end");
43
+ expect(end).toBeDefined();
44
+ expect((end!.event.data as any).entryId).toBe("a1");
45
+ });
46
+
47
+ it("emits no entry_persisted events during replay", () => {
48
+ const sessionId = "sess-1";
49
+ const entries = [
50
+ {
51
+ type: "message",
52
+ id: "u1",
53
+ timestamp: "2026-04-27T07:26:25.000Z",
54
+ message: { role: "user", content: [{ type: "text", text: "Hi" }] },
55
+ },
56
+ {
57
+ type: "message",
58
+ id: "a1",
59
+ parentId: "u1",
60
+ timestamp: "2026-04-27T07:26:30.000Z",
61
+ message: { role: "assistant", content: [{ type: "text", text: "Hello!" }] },
62
+ },
63
+ ];
64
+
65
+ const events = replayEntriesAsEvents(sessionId, entries);
66
+ const persisted = events.filter((e) => e.event.eventType === "entry_persisted");
67
+ expect(persisted).toHaveLength(0);
68
+ });
69
+ });
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Tests for the standard tool definitions (strategies + registration).
3
+ *
4
+ * We inject fake `exists` / `which` / `npmRootGlobal` so tests are
5
+ * deterministic across platforms and don't depend on the test host's
6
+ * real filesystem or PATH.
7
+ */
8
+ import { describe, it, expect } from "vitest";
9
+ import path from "node:path";
10
+ import os from "node:os";
11
+ import {
12
+ ToolRegistry,
13
+ registerDefaultTools,
14
+ OverridesStore,
15
+ } from "../tool-registry/index.js";
16
+
17
+ function freshRegistry(opts: {
18
+ exists?: (p: string) => boolean;
19
+ which?: (name: string) => string | null;
20
+ npmRootGlobal?: () => string;
21
+ overrides?: Record<string, string>;
22
+ platform?: NodeJS.Platform;
23
+ }) {
24
+ const store = new OverridesStore({
25
+ filePath: path.join(os.tmpdir(), `tool-registry-test-${Math.random()}.json`),
26
+ warn: () => {},
27
+ });
28
+ for (const [k, v] of Object.entries(opts.overrides ?? {})) store.set(k, v);
29
+
30
+ const r = new ToolRegistry({
31
+ overrides: store,
32
+ platform: opts.platform ?? "linux",
33
+ });
34
+ registerDefaultTools(r, {
35
+ exists: opts.exists ?? (() => false),
36
+ which: opts.which ?? (() => null),
37
+ npmRootGlobal: opts.npmRootGlobal ?? (() => ""),
38
+ });
39
+ return r;
40
+ }
41
+
42
+ describe("pi binary definition", () => {
43
+ it("chain order: override → managed → where", () => {
44
+ const r = freshRegistry({ which: (n) => (n === "pi" ? "/usr/bin/pi" : null) });
45
+ const res = r.resolve("pi");
46
+ expect(res.tried.map((t) => t.strategy)).toEqual([
47
+ "override",
48
+ "managed",
49
+ "where",
50
+ ]);
51
+ expect(res.ok).toBe(true);
52
+ expect(res.path).toBe("/usr/bin/pi");
53
+ expect(res.source).toBe("system");
54
+ });
55
+
56
+ it("managed wins over system when MANAGED_BIN/pi exists", () => {
57
+ const managed = path.join(os.homedir(), ".pi-dashboard", "node_modules", ".bin", "pi");
58
+ const r = freshRegistry({
59
+ exists: (p) => p === managed,
60
+ which: () => "/usr/bin/pi",
61
+ platform: "linux",
62
+ });
63
+ const res = r.resolve("pi");
64
+ expect(res.ok).toBe(true);
65
+ expect(res.path).toBe(managed);
66
+ expect(res.source).toBe("managed");
67
+ });
68
+
69
+ it("picks .cmd extension on Windows", () => {
70
+ const managed = path.join(os.homedir(), ".pi-dashboard", "node_modules", ".bin", "pi.cmd");
71
+ const r = freshRegistry({
72
+ exists: (p) => p === managed,
73
+ platform: "win32",
74
+ });
75
+ const res = r.resolve("pi");
76
+ expect(res.ok).toBe(true);
77
+ expect(res.path).toBe(managed);
78
+ });
79
+
80
+ it("override wins when set and path exists", () => {
81
+ const custom = "/opt/custom/pi";
82
+ const r = freshRegistry({
83
+ overrides: { pi: custom },
84
+ exists: (p) => p === custom, // validate() passes
85
+ });
86
+ const res = r.resolve("pi");
87
+ expect(res.ok).toBe(true);
88
+ expect(res.path).toBe(custom);
89
+ expect(res.source).toBe("override");
90
+ });
91
+
92
+ it("invalid override falls through to next strategy with 'invalid:' reason", () => {
93
+ const r = freshRegistry({
94
+ overrides: { pi: "/does/not/exist" },
95
+ which: () => "/usr/bin/pi",
96
+ exists: (p) => p === "/usr/bin/pi", // override path fails validate
97
+ });
98
+ const res = r.resolve("pi");
99
+ expect(res.ok).toBe(true);
100
+ expect(res.source).toBe("system");
101
+ expect(res.tried[0].strategy).toBe("override");
102
+ expect(res.tried[0].result).toMatch(/^invalid:/);
103
+ });
104
+ });
105
+
106
+ describe("pi-coding-agent module definition", () => {
107
+ it("probes both @mariozechner and @oh-my-pi alias names", () => {
108
+ const r = freshRegistry({ exists: () => false });
109
+ const res = r.resolve("pi-coding-agent");
110
+ const names = res.tried.map((t) => t.strategy);
111
+ // First strategy: override. Then two bare-import (one per alias),
112
+ // then two managed, then two npm-global.
113
+ expect(names[0]).toBe("override");
114
+ expect(names.filter((n) => n === "bare-import").length).toBe(2);
115
+ expect(names.filter((n) => n === "managed").length).toBe(2);
116
+ expect(names.filter((n) => n === "npm-global").length).toBe(2);
117
+ });
118
+
119
+ it("managed strategy hits ~/.pi-dashboard/node_modules/<pkg>/dist/index.js", () => {
120
+ const managed = path.join(
121
+ os.homedir(), ".pi-dashboard", "node_modules",
122
+ "@mariozechner", "pi-coding-agent", "dist", "index.js",
123
+ );
124
+ const r = freshRegistry({ exists: (p) => p === managed });
125
+ const res = r.resolve("pi-coding-agent");
126
+ expect(res.ok).toBe(true);
127
+ expect(res.path).toBe(managed);
128
+ expect(res.source).toBe("managed");
129
+ });
130
+
131
+ it("npm-global strategy uses <npm root -g>/<pkg>/dist/index.js", () => {
132
+ const npmRoot = "/npm/global/root";
133
+ const entry = path.join(npmRoot, "@mariozechner", "pi-coding-agent", "dist", "index.js");
134
+ const r = freshRegistry({
135
+ exists: (p) => p === entry,
136
+ npmRootGlobal: () => npmRoot,
137
+ });
138
+ const res = r.resolve("pi-coding-agent");
139
+ expect(res.ok).toBe(true);
140
+ expect(res.path).toBe(entry);
141
+ expect(res.source).toBe("npm-global");
142
+ });
143
+
144
+ it("fails cleanly when no strategy succeeds", () => {
145
+ const r = freshRegistry({
146
+ exists: () => false,
147
+ npmRootGlobal: () => "",
148
+ });
149
+ const res = r.resolve("pi-coding-agent");
150
+ expect(res.ok).toBe(false);
151
+ expect(res.path).toBeNull();
152
+ expect(res.source).toBeNull();
153
+ // Trail should include override + 2 bare-import + 2 managed + 2 npm-global.
154
+ expect(res.tried.length).toBeGreaterThanOrEqual(5);
155
+ expect(res.tried.some((t) => t.strategy === "npm-global")).toBe(true);
156
+ });
157
+ });
158
+
159
+ describe("openspec binary definition", () => {
160
+ it("finds openspec.cmd under managed bin on Windows", () => {
161
+ const managed = path.join(os.homedir(), ".pi-dashboard", "node_modules", ".bin", "openspec.cmd");
162
+ const r = freshRegistry({ exists: (p) => p === managed, platform: "win32" });
163
+ const res = r.resolve("openspec");
164
+ expect(res.ok).toBe(true);
165
+ expect(res.path).toBe(managed);
166
+ });
167
+
168
+ it("falls through managed → where on Unix when managed is absent", () => {
169
+ const r = freshRegistry({
170
+ exists: () => false,
171
+ which: (n) => (n === "openspec" ? "/usr/local/bin/openspec" : null),
172
+ platform: "darwin",
173
+ });
174
+ const res = r.resolve("openspec");
175
+ expect(res.ok).toBe(true);
176
+ expect(res.source).toBe("system");
177
+ expect(res.path).toBe("/usr/local/bin/openspec");
178
+ });
179
+ });
180
+
181
+ describe("registered tool set", () => {
182
+ it("registers pi, pi-coding-agent, openspec, npm, node, git, zrok, wt", () => {
183
+ const r = freshRegistry({});
184
+ for (const name of ["pi", "pi-coding-agent", "openspec", "npm", "node", "git", "zrok", "wt"]) {
185
+ expect(r.has(name)).toBe(true);
186
+ }
187
+ });
188
+
189
+ it("wt resolves via where when found", () => {
190
+ const r = freshRegistry({
191
+ platform: "win32",
192
+ which: (name) => (name === "wt" ? "C:\\WindowsApps\\wt.exe" : null),
193
+ });
194
+ const res = r.resolve("wt");
195
+ expect(res.ok).toBe(true);
196
+ expect(res.path).toBe("C:\\WindowsApps\\wt.exe");
197
+ expect(res.source).toBe("system");
198
+ });
199
+
200
+ it("wt unavailable returns ok:false without error", () => {
201
+ const r = freshRegistry({ platform: "win32", which: () => null });
202
+ const res = r.resolve("wt");
203
+ expect(res.ok).toBe(false);
204
+ });
205
+
206
+ it("does NOT register tsx (it's a loader, not a spawn target)", () => {
207
+ const r = freshRegistry({});
208
+ expect(r.has("tsx")).toBe(false);
209
+ });
210
+
211
+ it("registers Windows-only process utilities on win32, NOT ps/pgrep", () => {
212
+ const r = freshRegistry({ platform: "win32" });
213
+ expect(r.has("tasklist")).toBe(true);
214
+ expect(r.has("taskkill")).toBe(true);
215
+ expect(r.has("wmic")).toBe(true);
216
+ expect(r.has("powershell")).toBe(true);
217
+ // ps/pgrep are POSIX-only; they'd always show "not found" on Windows
218
+ // and pollute the Tools UI with red rows the code never calls.
219
+ expect(r.has("ps")).toBe(false);
220
+ expect(r.has("pgrep")).toBe(false);
221
+ });
222
+
223
+ it("registers POSIX process utilities on linux/darwin, NOT tasklist etc.", () => {
224
+ for (const platform of ["linux", "darwin"] as NodeJS.Platform[]) {
225
+ const r = freshRegistry({ platform });
226
+ expect(r.has("ps")).toBe(true);
227
+ expect(r.has("pgrep")).toBe(true);
228
+ expect(r.has("tasklist")).toBe(false);
229
+ expect(r.has("taskkill")).toBe(false);
230
+ expect(r.has("wmic")).toBe(false);
231
+ expect(r.has("powershell")).toBe(false);
232
+ }
233
+ });
234
+
235
+ it("does NOT register pi-dashboard (it's the package this code is part of)", () => {
236
+ const r = freshRegistry({});
237
+ expect(r.has("pi-dashboard")).toBe(false);
238
+ });
239
+ });