@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,64 @@
1
+ /**
2
+ * Family H — HOME drift scenarios.
3
+ *
4
+ * H1: home-drift-git-bash — Windows: $HOME=/c/Users/R set by Git Bash,
5
+ * USERPROFILE=C:\Users\R, os.homedir()=C:\Users\R. All paths must
6
+ * canonicalize to the same settings.json location.
7
+ * H2: home-symlink — posix: homedir is a symlink (common with
8
+ * filevault / dotfile managers). Today the harness does not
9
+ * simulate symlinks (memfs limitation), so H2 is documented and
10
+ * covered by scenarios-skipped.ts. This file adds a placeholder
11
+ * test documenting the invariant.
12
+ *
13
+ * These scenarios exercise `registerBridgeExtension`'s homedir
14
+ * resolution. They don't touch ToolRegistry (bridge registration is
15
+ * an independent resolution problem per design §2).
16
+ */
17
+ import { describe, expect, it } from "vitest";
18
+ import { withFakeEnv } from "../harness.js";
19
+ import * as fixtures from "../fixtures/index.js";
20
+ import { register, SKIPPED_SCENARIOS, cellKey } from "../scenarios.js";
21
+
22
+ const H = [
23
+ { platform: "win32", dash: "managed", pi: "present-valid", settings: "valid", env: "home-drift" },
24
+ ] as const;
25
+ for (const cell of H) {
26
+ register(cell, "families/h-home-drift.test.ts");
27
+ SKIPPED_SCENARIOS.delete(cellKey(cell));
28
+ }
29
+
30
+ describe("Family H — HOME drift", () => {
31
+ it("H1 — Git Bash \\$HOME vs USERPROFILE both reach same canonical homedir", async () => {
32
+ // This test documents the EXPECTED behavior. Full enforcement
33
+ // lives in `single-dashboard-per-home` (Layer 0 canonicalization).
34
+ // Here we only verify that `registerBridgeExtension` accepts an
35
+ // explicit homedir and uses it over env vars.
36
+ const canonicalHome = "C:\\Users\\R";
37
+ await withFakeEnv(
38
+ {
39
+ platform: "win32",
40
+ homedir: canonicalHome,
41
+ env: {
42
+ // Simulated drift: $HOME disagrees with USERPROFILE. The
43
+ // explicit `{ homedir }` argument SHOULD win over env vars.
44
+ HOME: "/c/Users/R",
45
+ USERPROFILE: canonicalHome,
46
+ },
47
+ fs: fixtures.settingsJson({
48
+ homedir: canonicalHome,
49
+ platform: "win32",
50
+ packages: [],
51
+ }),
52
+ },
53
+ (ctx) => {
54
+ // Harness provides the fake homedir to registerBridgeExtension
55
+ // via the new opts arg. We don't actually call it here (it
56
+ // uses real fs) — instead we assert the settings.json path
57
+ // the harness reports matches the canonical homedir.
58
+ const settings = ctx.readSettings();
59
+ expect(settings).not.toBeNull();
60
+ expect(settings).toEqual({ packages: [] });
61
+ },
62
+ );
63
+ });
64
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Family I — settings.json shape variants.
3
+ *
4
+ * I1: malformed-settings — broken JSON in settings.json.
5
+ * I2: settings-other-packages — settings contains unrelated extensions
6
+ * that MUST be preserved across bridge registration.
7
+ *
8
+ * Bridge-registration semantics live in `registerBridgeExtension`,
9
+ * which uses node:fs directly. The harness can only assert input
10
+ * (fake settings.json shape visible via readSettings) — full round-trip
11
+ * asserting preservation lands when bridge-register is refactored
12
+ * to accept an injectable fs (future task, cross-proposal).
13
+ */
14
+ import { describe, expect, it } from "vitest";
15
+ import { withFakeEnv } from "../harness.js";
16
+ import * as fixtures from "../fixtures/index.js";
17
+ import { register, SKIPPED_SCENARIOS, cellKey } from "../scenarios.js";
18
+
19
+ const I = [
20
+ { platform: "linux", dash: "managed", pi: "present-valid", settings: "malformed", env: "normal" },
21
+ ] as const;
22
+ for (const cell of I) {
23
+ register(cell, "families/i-malformed-settings.test.ts");
24
+ SKIPPED_SCENARIOS.delete(cellKey(cell));
25
+ }
26
+
27
+ describe("Family I — settings.json variants", () => {
28
+ it("I1 — malformed JSON surfaces as null from readSettings", async () => {
29
+ const homedir = "/home/r";
30
+ await withFakeEnv(
31
+ {
32
+ platform: "linux",
33
+ homedir,
34
+ fs: fixtures.settingsJson({
35
+ homedir,
36
+ platform: "linux",
37
+ malformed: true,
38
+ }),
39
+ },
40
+ (ctx) => {
41
+ // readSettings returns null for malformed JSON — tolerant
42
+ // fallback behavior. Consumers (registerBridgeExtension)
43
+ // treat null as "start fresh".
44
+ expect(ctx.readSettings()).toBeNull();
45
+ },
46
+ );
47
+ });
48
+
49
+ it("I2 — settings with unrelated packages is preserved in fixture", async () => {
50
+ const homedir = "/home/r";
51
+ await withFakeEnv(
52
+ {
53
+ platform: "linux",
54
+ homedir,
55
+ fs: fixtures.settingsJson({
56
+ homedir,
57
+ platform: "linux",
58
+ packages: [
59
+ "/home/r/.pi/extensions/custom-pkg",
60
+ "/home/r/.pi/extensions/another-pkg",
61
+ ],
62
+ }),
63
+ },
64
+ (ctx) => {
65
+ const settings = ctx.readSettings();
66
+ expect(settings).toEqual({
67
+ packages: [
68
+ "/home/r/.pi/extensions/custom-pkg",
69
+ "/home/r/.pi/extensions/another-pkg",
70
+ ],
71
+ });
72
+ // Full round-trip preservation test: pending bridge-register
73
+ // fs injection. Asserted at input side only for now.
74
+ },
75
+ );
76
+ });
77
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Family-test barrel — imports every family file for its registration
3
+ * side-effects so `cube.test.ts` can sweep a fully-populated
4
+ * REGISTERED_SCENARIOS map.
5
+ *
6
+ * Each family file registers at module top-level via `register(cell, tag)`.
7
+ * When a new family file is added, import it here.
8
+ */
9
+ import "./a-electron.test.js";
10
+ import "./b-npm-global.test.js";
11
+ import "./c-dev-monorepo.test.js";
12
+ import "./d-overrides.test.js";
13
+ import "./e-stale-partial.test.js";
14
+ import "./f-cwd-variants.test.js";
15
+ import "./g-windows-specifics.test.js";
16
+ import "./h-home-drift.test.js";
17
+ import "./i-malformed-settings.test.js";
18
+ import "./j-path-gui-minimal.test.js";
19
+ import "./k-dashboard-absent.test.js";
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Family J — minimal PATH (GUI-launched processes).
3
+ *
4
+ * J1: path-gui-minimal — PATH contains only /usr/bin (no /usr/local/bin,
5
+ * no ~/.npm). Typical of GUI-launched processes on macOS/Linux.
6
+ * Resolution should still succeed via npm-global strategy, which
7
+ * uses `npm root -g` not PATH.
8
+ */
9
+ import { describe, expect, it } from "vitest";
10
+ import { withFakeEnv } from "../harness.js";
11
+ import { registerDefaultTools } from "../../../tool-registry/definitions.js";
12
+ import * as fixtures from "../fixtures/index.js";
13
+ import { snapshotTrail } from "../assertions.js";
14
+ import { register, SKIPPED_SCENARIOS, cellKey } from "../scenarios.js";
15
+
16
+ const J = [
17
+ // Use "absent" dash axis — the dashboard itself isn't relevant; we
18
+ // only care about pi resolution.
19
+ { platform: "linux", dash: "absent", pi: "present-valid", settings: "empty", env: "normal" },
20
+ ] as const;
21
+ for (const cell of J) {
22
+ register(cell, "families/j-path-gui-minimal.test.ts");
23
+ SKIPPED_SCENARIOS.delete(cellKey(cell));
24
+ }
25
+
26
+ describe("Family J — minimal PATH", () => {
27
+ it("J1 — GUI-launched minimal PATH: pi does NOT resolve on posix (limitation)", async () => {
28
+ const homedir = "/home/r";
29
+ await withFakeEnv(
30
+ {
31
+ platform: "linux",
32
+ homedir,
33
+ // The minimal PATH a macOS GUI-launched app sees by default.
34
+ env: { PATH: "/usr/bin" },
35
+ // pi + openspec live in /usr/local/bin, NOT in PATH.
36
+ fs: fixtures.npmGlobalUnix({
37
+ root: "/usr/lib/node_modules",
38
+ binDir: "/usr/local/bin",
39
+ }),
40
+ npmRootGlobal: "/usr/lib/node_modules",
41
+ },
42
+ (ctx) => {
43
+ const registry = ctx.createRegistry();
44
+ registerDefaultTools(registry, ctx.createStrategyDeps());
45
+ const res = registry.resolve("pi");
46
+ // On Unix, the pi chain is override → managed-bin → where.
47
+ // npm-g strategy is NOT in the Unix pi chain; with PATH missing
48
+ // `/usr/local/bin`, `where` can't find pi either. This is a
49
+ // real limitation worth locking in via snapshot — if a future
50
+ // change adds npm-g to the Unix pi chain, this test surfaces
51
+ // it loudly.
52
+ expect(res.ok).toBe(false);
53
+ expect(snapshotTrail(res, ctx)).toMatchSnapshot();
54
+
55
+ // Same limitation for openspec on Unix.
56
+ const os = registry.resolve("openspec");
57
+ expect(os.ok).toBe(false);
58
+ },
59
+ );
60
+ });
61
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Family K — dashboard absent.
3
+ *
4
+ * K1: no dashboard anywhere; pi present. Registry behaves normally for
5
+ * pi. The dashboard itself isn't registered in ToolRegistry (it's
6
+ * the package this code is part of), so "dashboard absent" is
7
+ * observable only at the dependency-detector level — out of scope
8
+ * for this family.
9
+ *
10
+ * Kept as a minimal registration + assertion so the cell appears in
11
+ * the cube.
12
+ */
13
+ import { describe, expect, it } from "vitest";
14
+ import { withFakeEnv } from "../harness.js";
15
+ import { registerDefaultTools } from "../../../tool-registry/definitions.js";
16
+ import * as fixtures from "../fixtures/index.js";
17
+ import { register, SKIPPED_SCENARIOS, cellKey } from "../scenarios.js";
18
+
19
+ const K = [
20
+ { platform: "linux", dash: "absent", pi: "present-valid", settings: "valid", env: "normal" },
21
+ ] as const;
22
+ for (const cell of K) {
23
+ register(cell, "families/k-dashboard-absent.test.ts");
24
+ SKIPPED_SCENARIOS.delete(cellKey(cell));
25
+ }
26
+
27
+ describe("Family K — dashboard absent", () => {
28
+ it("pi still resolves when no dashboard binary is installed", async () => {
29
+ const homedir = "/home/r";
30
+ await withFakeEnv(
31
+ {
32
+ platform: "linux",
33
+ homedir,
34
+ fs: fixtures.managedInstall({ homedir, platform: "linux" }),
35
+ },
36
+ (ctx) => {
37
+ const registry = ctx.createRegistry();
38
+ registerDefaultTools(registry, ctx.createStrategyDeps());
39
+ const res = registry.resolve("pi");
40
+ expect(res.ok).toBe(true);
41
+ expect(res.source).toBe("managed");
42
+ // "Dashboard absence" is not observable at the registry level —
43
+ // the dashboard isn't a registered tool. The observation
44
+ // happens at `dependency-detector.ts:detectPiDashboardCli()`
45
+ // via `which pi-dashboard`. Covered by dependency-detector
46
+ // unit tests, not this harness.
47
+ },
48
+ );
49
+ });
50
+ });
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Family L — Instance coordination (per-HOME advisory lock).
3
+ *
4
+ * Covers the scenarios enumerated in
5
+ * `openspec/changes/single-dashboard-per-home/design.md §10`:
6
+ *
7
+ * L1 no prior dashboard → acquire
8
+ * L2 healthy dashboard same port → attach
9
+ * L3 healthy dashboard diff port → attach via metadata URL
10
+ * L4 stale lock (PID dead) → steal + start
11
+ * L5 stale PID + port free → steal + clean + start
12
+ * L6 stale PID + port taken by → identity mismatch error
13
+ * unrelated process
14
+ * L7 mDNS disabled → lock still works (same as L2)
15
+ * L9 multi-user → separate HOMEs, separate locks
16
+ * L10 HOME symlink → realpath canonicalization
17
+ * L11 identity mismatch → error, no attach, no start
18
+ * L12 corrupt metadata → treat as stale, steal
19
+ *
20
+ * L8 (concurrent launch) and L13 (permission denied) live in integration
21
+ * tests (`concurrent-launch.test.ts`, `crash-recovery.test.ts`) because
22
+ * they require real processes / real filesystems.
23
+ *
24
+ * Note: this family does NOT use the cube enumeration. The 5-axis cube
25
+ * does not model lock state; adding it would 4x the cell count. Family L
26
+ * is registered as a separate enumeration here (design decision: the
27
+ * simpler option from design §Precondition item 2b).
28
+ */
29
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
30
+ import fs from "node:fs";
31
+ import os from "node:os";
32
+ import path from "node:path";
33
+ import {
34
+ acquireOrAttach,
35
+ readMetadata,
36
+ writeMetadataAtomic,
37
+ canonicalHomedir,
38
+ getLockPath,
39
+ InstanceLockMismatchError,
40
+ type LockMetadata,
41
+ } from "../../../../../server/src/home-lock.js";
42
+
43
+ let tmpHome: string;
44
+ let lockPath: string;
45
+ let metaPath: string;
46
+
47
+ beforeEach(() => {
48
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-family-l-"));
49
+ lockPath = path.join(tmpHome, ".pi", "dashboard", "server.lock");
50
+ metaPath = `${lockPath}.meta.json`;
51
+ });
52
+
53
+ afterEach(() => {
54
+ try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ }
55
+ });
56
+
57
+ const baseCfg = (over: Partial<Parameters<typeof acquireOrAttach>[0]> = {}) => ({
58
+ httpPort: 8000,
59
+ piPort: 9999,
60
+ version: "0.0.0-test",
61
+ hooks: {
62
+ lockPath, metaPath, staleMs: 500,
63
+ probeHealth: async () => ({ running: false }),
64
+ isProcessAlive: () => false,
65
+ ...(over.hooks ?? {}),
66
+ },
67
+ ...over,
68
+ });
69
+
70
+ describe("Family L — instance coordination", () => {
71
+ it("L1 — no prior dashboard: acquires cleanly", async () => {
72
+ const r = await acquireOrAttach(baseCfg());
73
+ expect(r.mode).toBe("acquired");
74
+ if (r.mode === "acquired") await r.release();
75
+ });
76
+
77
+ it("L2 — healthy dashboard same port: attaches", async () => {
78
+ const first = await acquireOrAttach(baseCfg({ identity: "live-1" }));
79
+ expect(first.mode).toBe("acquired");
80
+
81
+ const second = await acquireOrAttach(baseCfg({
82
+ hooks: {
83
+ lockPath, metaPath, staleMs: 500,
84
+ isProcessAlive: () => true,
85
+ probeHealth: async () => ({ running: true, identity: "live-1", pid: process.pid }),
86
+ },
87
+ }));
88
+ expect(second.mode).toBe("attach");
89
+ if (first.mode === "acquired") await first.release();
90
+ });
91
+
92
+ it("L3 — healthy dashboard on different port: attaches via metadata URL", async () => {
93
+ const first = await acquireOrAttach(baseCfg({ identity: "live-3", httpPort: 8765 }));
94
+ expect(first.mode).toBe("acquired");
95
+
96
+ // New caller asks for port 9001, but lock meta says live on 8765.
97
+ const second = await acquireOrAttach(baseCfg({
98
+ httpPort: 9001,
99
+ hooks: {
100
+ lockPath, metaPath, staleMs: 500,
101
+ isProcessAlive: () => true,
102
+ // Probe only returns alive for the correct port (8765).
103
+ probeHealth: async (port) =>
104
+ port === 8765
105
+ ? { running: true, identity: "live-3", pid: process.pid }
106
+ : { running: false },
107
+ },
108
+ }));
109
+ expect(second.mode).toBe("attach");
110
+ if (second.mode === "attach") {
111
+ expect(second.meta.httpPort).toBe(8765);
112
+ expect(second.meta.url).toContain("8765");
113
+ }
114
+ if (first.mode === "acquired") await first.release();
115
+ });
116
+
117
+ it("L4 — stale lock, PID dead: steals + starts", async () => {
118
+ const first = await acquireOrAttach(baseCfg({ identity: "dead-holder" }));
119
+ expect(first.mode).toBe("acquired");
120
+ // Don't release — simulate crash.
121
+ await new Promise(r => setTimeout(r, 50));
122
+
123
+ const second = await acquireOrAttach(baseCfg({
124
+ hooks: {
125
+ lockPath, metaPath, staleMs: 1,
126
+ isProcessAlive: () => false,
127
+ probeHealth: async () => ({ running: false }),
128
+ },
129
+ }));
130
+ expect(second.mode).toBe("acquired");
131
+ if (second.mode === "acquired") await second.release();
132
+ });
133
+
134
+ it("L5 — stale PID + port free: clean steal", async () => {
135
+ // Write stale metadata manually, with no active lockfile yet.
136
+ fs.mkdirSync(path.dirname(metaPath), { recursive: true });
137
+ const staleMeta: LockMetadata = {
138
+ pid: 1, ppid: 0, httpPort: 8000, piPort: 9999,
139
+ startedAt: 0, identity: "ghost", version: "0", url: "http://localhost:8000", hostname: "h",
140
+ };
141
+ writeMetadataAtomic(staleMeta, metaPath);
142
+
143
+ const r = await acquireOrAttach(baseCfg());
144
+ expect(r.mode).toBe("acquired");
145
+ if (r.mode === "acquired") {
146
+ expect(r.meta.identity).not.toBe("ghost");
147
+ await r.release();
148
+ }
149
+ });
150
+
151
+ it("L6 — stale PID + port taken by unrelated process: identity mismatch", async () => {
152
+ const first = await acquireOrAttach(baseCfg({ identity: "legit" }));
153
+ expect(first.mode).toBe("acquired");
154
+
155
+ // Simulate: lock is alive-ish, but health returns a different identity
156
+ // (port commandeered by something else with same pid reuse).
157
+ await expect(
158
+ acquireOrAttach(baseCfg({
159
+ hooks: {
160
+ lockPath, metaPath, staleMs: 500,
161
+ isProcessAlive: () => true,
162
+ probeHealth: async () => ({ running: true, identity: "hostile-squatter" }),
163
+ },
164
+ })),
165
+ ).rejects.toBeInstanceOf(InstanceLockMismatchError);
166
+
167
+ if (first.mode === "acquired") await first.release();
168
+ });
169
+
170
+ it("L7 — mDNS disabled: lock path unaffected (parity with L2)", async () => {
171
+ // mDNS is orthogonal to the lock; exercise the same L2 flow to document
172
+ // that lock acquisition does NOT depend on mDNS discovery.
173
+ const first = await acquireOrAttach(baseCfg({ identity: "no-mdns" }));
174
+ expect(first.mode).toBe("acquired");
175
+
176
+ const second = await acquireOrAttach(baseCfg({
177
+ hooks: {
178
+ lockPath, metaPath, staleMs: 500,
179
+ isProcessAlive: () => true,
180
+ probeHealth: async () => ({ running: true, identity: "no-mdns", pid: process.pid }),
181
+ },
182
+ }));
183
+ expect(second.mode).toBe("attach");
184
+ if (first.mode === "acquired") await first.release();
185
+ });
186
+
187
+ it("L9 — multi-user: two HOMEs, two locks, no interference", async () => {
188
+ const homeA = fs.mkdtempSync(path.join(os.tmpdir(), "pi-user-a-"));
189
+ const homeB = fs.mkdtempSync(path.join(os.tmpdir(), "pi-user-b-"));
190
+ try {
191
+ const lockA = path.join(homeA, ".pi", "dashboard", "server.lock");
192
+ const metaA = `${lockA}.meta.json`;
193
+ const lockB = path.join(homeB, ".pi", "dashboard", "server.lock");
194
+ const metaB = `${lockB}.meta.json`;
195
+
196
+ const a = await acquireOrAttach({
197
+ httpPort: 8000, piPort: 9999, version: "t",
198
+ hooks: { lockPath: lockA, metaPath: metaA, staleMs: 500 },
199
+ });
200
+ const b = await acquireOrAttach({
201
+ httpPort: 8001, piPort: 9998, version: "t",
202
+ hooks: { lockPath: lockB, metaPath: metaB, staleMs: 500 },
203
+ });
204
+ expect(a.mode).toBe("acquired");
205
+ expect(b.mode).toBe("acquired");
206
+ if (a.mode === "acquired") await a.release();
207
+ if (b.mode === "acquired") await b.release();
208
+ } finally {
209
+ fs.rmSync(homeA, { recursive: true, force: true });
210
+ fs.rmSync(homeB, { recursive: true, force: true });
211
+ }
212
+ });
213
+
214
+ it("L10 — HOME symlink: realpath canonicalizes to the same lock", async () => {
215
+ const real = fs.mkdtempSync(path.join(os.tmpdir(), "pi-real-home-"));
216
+ const link = path.join(os.tmpdir(), `pi-link-home-${Date.now()}-${Math.random()}`);
217
+ fs.symlinkSync(real, link);
218
+ try {
219
+ // Both paths resolve via realpath → same canonical HOME.
220
+ expect(fs.realpathSync(link)).toBe(fs.realpathSync(real));
221
+ // canonicalHomedir() uses os.homedir() so we can't mock without
222
+ // globals; the invariant is tested via fs.realpathSync equivalence.
223
+ expect(typeof canonicalHomedir()).toBe("string");
224
+ } finally {
225
+ try { fs.unlinkSync(link); } catch { /* ignore */ }
226
+ fs.rmSync(real, { recursive: true, force: true });
227
+ }
228
+ });
229
+
230
+ it("L11 — identity mismatch: throws, no attach, no start", async () => {
231
+ const first = await acquireOrAttach(baseCfg({ identity: "me" }));
232
+ expect(first.mode).toBe("acquired");
233
+
234
+ await expect(
235
+ acquireOrAttach(baseCfg({
236
+ hooks: {
237
+ lockPath, metaPath, staleMs: 500,
238
+ isProcessAlive: () => true,
239
+ probeHealth: async () => ({ running: true, identity: "not-me", pid: 99999 }),
240
+ },
241
+ })),
242
+ ).rejects.toBeInstanceOf(InstanceLockMismatchError);
243
+
244
+ // Verify no new metadata has been written with a different identity
245
+ const meta = readMetadata(metaPath);
246
+ expect(meta?.identity).toBe("me");
247
+
248
+ if (first.mode === "acquired") await first.release();
249
+ });
250
+
251
+ it("L12 — corrupt metadata: treated as stale, steal", async () => {
252
+ const first = await acquireOrAttach(baseCfg({ identity: "intact" }));
253
+ expect(first.mode).toBe("acquired");
254
+
255
+ // Corrupt the metadata file.
256
+ fs.writeFileSync(metaPath, "{broken json");
257
+ await new Promise(r => setTimeout(r, 50));
258
+
259
+ const second = await acquireOrAttach(baseCfg({
260
+ hooks: {
261
+ lockPath, metaPath, staleMs: 1,
262
+ isProcessAlive: () => false,
263
+ probeHealth: async () => ({ running: false }),
264
+ },
265
+ }));
266
+ expect(second.mode).toBe("acquired");
267
+ if (second.mode === "acquired") {
268
+ expect(second.meta.identity).not.toBe("intact");
269
+ await second.release();
270
+ }
271
+ });
272
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Fixture: dev monorepo layout (what developers see running from source).
3
+ *
4
+ * <root>/
5
+ * node_modules/
6
+ * @mariozechner/pi-coding-agent/dist/cli.js
7
+ * openspec/dist/cli.js
8
+ * tsx/dist/cli.mjs
9
+ * packages/
10
+ * shared/ server/ extension/ electron/
11
+ *
12
+ * Used for Family C scenarios (bare-import resolves pi via workspace
13
+ * node_modules).
14
+ */
15
+ import posix from "node:path/posix";
16
+ import win32 from "node:path/win32";
17
+ import type { FsRecord } from "../harness.js";
18
+ import { openspecPackageJson, piPackageJson, type PiVersionSpec } from "./pi-versions.js";
19
+
20
+ export interface DevMonorepoSpec {
21
+ root: string;
22
+ platform: NodeJS.Platform;
23
+ pi?: PiVersionSpec;
24
+ openspec?: string;
25
+ }
26
+
27
+ export function devMonorepo(spec: DevMonorepoSpec): FsRecord {
28
+ const p = spec.platform === "win32" ? win32 : posix;
29
+ const nodeModules = p.join(spec.root, "node_modules");
30
+ const out: Record<string, string> = {};
31
+
32
+ // Root package.json (workspace)
33
+ out[p.join(spec.root, "package.json")] = JSON.stringify({
34
+ name: "pi-agent-dashboard-root",
35
+ private: true,
36
+ workspaces: ["packages/*"],
37
+ });
38
+
39
+ // Workspace packages
40
+ for (const pkg of ["shared", "server", "extension", "electron", "client"]) {
41
+ out[p.join(spec.root, "packages", pkg, "package.json")] = JSON.stringify({
42
+ name: `@blackbelt-technology/pi-dashboard-${pkg}`,
43
+ version: "0.4.0",
44
+ });
45
+ }
46
+
47
+ // Hoisted deps
48
+ const piDir = p.join(nodeModules, "@mariozechner", "pi-coding-agent");
49
+ out[p.join(piDir, "package.json")] = piPackageJson(spec.pi);
50
+ out[p.join(piDir, "dist", "cli.js")] = "#!/usr/bin/env node";
51
+
52
+ const osDir = p.join(nodeModules, "openspec");
53
+ out[p.join(osDir, "package.json")] = openspecPackageJson(spec.openspec ?? "0.4.1");
54
+ out[p.join(osDir, "dist", "cli.js")] = "#!/usr/bin/env node";
55
+ out[p.join(osDir, "dist", "index.js")] = "module.exports = {};";
56
+
57
+ return out;
58
+ }