@blackbelt-technology/pi-agent-dashboard 0.5.3 → 0.5.4

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 (212) hide show
  1. package/AGENTS.md +19 -30
  2. package/README.md +69 -1
  3. package/docs/architecture.md +89 -165
  4. package/package.json +10 -7
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/bridge-default-model-gate.test.ts +47 -0
  7. package/packages/extension/src/__tests__/bridge-followup-chat-order.test.ts +215 -0
  8. package/packages/extension/src/__tests__/bridge-followup-multi-entry.test.ts +202 -0
  9. package/packages/extension/src/__tests__/bridge-queue-update-forward.test.ts +77 -0
  10. package/packages/extension/src/__tests__/bridge-retry-ordering.test.ts +148 -0
  11. package/packages/extension/src/__tests__/bridge-shadow-queue-drain.test.ts +221 -0
  12. package/packages/extension/src/__tests__/bridge-shadow-queue-gate.test.ts +299 -0
  13. package/packages/extension/src/__tests__/bridge-shutdown-reset.test.ts +238 -0
  14. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +127 -31
  15. package/packages/extension/src/__tests__/command-handler.test.ts +105 -3
  16. package/packages/extension/src/__tests__/fixtures/usage-limit-error-strings.ts +127 -0
  17. package/packages/extension/src/__tests__/source-detector.test.ts +15 -0
  18. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +12 -0
  19. package/packages/extension/src/bridge-default-model-gate.ts +32 -0
  20. package/packages/extension/src/bridge.ts +299 -20
  21. package/packages/extension/src/command-handler.ts +53 -7
  22. package/packages/extension/src/dashboard-default-adapter.ts +5 -0
  23. package/packages/extension/src/prompt-bus.ts +15 -0
  24. package/packages/extension/src/slash-dispatch.ts +30 -15
  25. package/packages/extension/src/source-detector.ts +13 -5
  26. package/packages/extension/src/usage-limit-orderer.ts +18 -1
  27. package/packages/server/bin/pi-dashboard.mjs +62 -14
  28. package/packages/server/package.json +9 -5
  29. package/packages/server/src/__tests__/browser-gateway-register-handler.test.ts +69 -0
  30. package/packages/server/src/__tests__/cli-env-no-clobber.test.ts +46 -0
  31. package/packages/server/src/__tests__/cli-no-bootstrap-references.test.ts +69 -0
  32. package/packages/server/src/__tests__/cli-parse.test.ts +9 -10
  33. package/packages/server/src/__tests__/cli-version.test.ts +151 -0
  34. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +9 -0
  35. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +9 -0
  36. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +9 -0
  37. package/packages/server/src/__tests__/directory-service-toctou.test.ts +9 -0
  38. package/packages/server/src/__tests__/directory-service.test.ts +9 -0
  39. package/packages/server/src/__tests__/doctor-route.test.ts +53 -0
  40. package/packages/server/src/__tests__/event-wiring-queue-state.test.ts +156 -0
  41. package/packages/server/src/__tests__/event-wiring-resume-clear.test.ts +105 -0
  42. package/packages/server/src/__tests__/health-shape.test.ts +35 -12
  43. package/packages/server/src/__tests__/installed-package-enricher.test.ts +12 -12
  44. package/packages/server/src/__tests__/is-activity-event.test.ts +4 -7
  45. package/packages/server/src/__tests__/package-routes.test.ts +6 -2
  46. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +10 -13
  47. package/packages/server/src/__tests__/pi-core-checker.test.ts +2 -2
  48. package/packages/server/src/__tests__/pi-version-skew.test.ts +3 -2
  49. package/packages/server/src/__tests__/plugin-activation-routes.test.ts +267 -0
  50. package/packages/server/src/__tests__/plugin-intent-cache.test.ts +75 -0
  51. package/packages/server/src/__tests__/preferences-store.test.ts +196 -0
  52. package/packages/server/src/__tests__/reattach-placement.test.ts +9 -0
  53. package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
  54. package/packages/server/src/__tests__/recovery-server.test.ts +203 -0
  55. package/packages/server/src/__tests__/session-action-handler-clear-queue.test.ts +153 -0
  56. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +43 -0
  57. package/packages/server/src/__tests__/session-order-manager.test.ts +9 -0
  58. package/packages/server/src/__tests__/session-order-reboot.test.ts +9 -0
  59. package/packages/server/src/__tests__/session-ordering-integration.test.ts +9 -0
  60. package/packages/server/src/browser-gateway.ts +83 -5
  61. package/packages/server/src/browser-handlers/directory-handler.ts +69 -0
  62. package/packages/server/src/browser-handlers/session-action-handler.ts +89 -0
  63. package/packages/server/src/browser-handlers/subscription-handler.ts +23 -0
  64. package/packages/server/src/changelog-parser.ts +1 -1
  65. package/packages/server/src/cli.ts +68 -250
  66. package/packages/server/src/event-status-extraction.ts +14 -62
  67. package/packages/server/src/event-wiring.ts +23 -10
  68. package/packages/server/src/memory-session-manager.ts +4 -0
  69. package/packages/server/src/pi-core-checker.ts +1 -1
  70. package/packages/server/src/pi-dev-version-check.ts +1 -1
  71. package/packages/server/src/pi-version-skew.ts +24 -46
  72. package/packages/server/src/plugin-intent-cache.ts +67 -0
  73. package/packages/server/src/preferences-store.ts +199 -13
  74. package/packages/server/src/recovery-server.ts +366 -0
  75. package/packages/server/src/routes/__tests__/manifest-route.test.ts +138 -0
  76. package/packages/server/src/routes/doctor-routes.ts +26 -21
  77. package/packages/server/src/routes/manifest-route.ts +162 -0
  78. package/packages/server/src/routes/openspec-routes.ts +4 -25
  79. package/packages/server/src/routes/pi-changelog-routes.ts +5 -24
  80. package/packages/server/src/routes/pi-core-routes.ts +3 -23
  81. package/packages/server/src/routes/plugin-activation-routes.ts +193 -0
  82. package/packages/server/src/routes/recommended-routes.ts +21 -0
  83. package/packages/server/src/routes/system-routes.ts +73 -11
  84. package/packages/server/src/server.ts +105 -307
  85. package/packages/server/src/session-api.ts +5 -63
  86. package/packages/shared/package.json +1 -1
  87. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +28 -0
  88. package/packages/shared/src/__tests__/binary-lookup-spawn-env.test.ts +61 -0
  89. package/packages/shared/src/__tests__/binary-lookup.test.ts +16 -0
  90. package/packages/shared/src/__tests__/bridge-register.test.ts +67 -0
  91. package/packages/shared/src/__tests__/ci-electron-no-side-effects.test.ts +129 -0
  92. package/packages/shared/src/__tests__/config.test.ts +40 -0
  93. package/packages/shared/src/__tests__/dashboard-paths.test.ts +81 -0
  94. package/packages/shared/src/__tests__/ensure-windows-path.test.ts +112 -0
  95. package/packages/shared/src/__tests__/intent-types.test.ts +120 -0
  96. package/packages/shared/src/__tests__/jiti-packages-parity.test.ts +85 -0
  97. package/packages/shared/src/__tests__/legacy-managed-dir.test.ts +59 -0
  98. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +12 -0
  99. package/packages/shared/src/__tests__/no-electron-execpath-spawn.test.ts +149 -0
  100. package/packages/shared/src/__tests__/no-flow-command-route-claims.test.ts +71 -0
  101. package/packages/shared/src/__tests__/no-flow-references-in-shell.test.ts +221 -0
  102. package/packages/shared/src/__tests__/no-managed-dir-reference.test.ts +134 -0
  103. package/packages/shared/src/__tests__/no-pi-dashboard-version-jiti-gate.test.ts +41 -0
  104. package/packages/shared/src/__tests__/no-primitive-direct-import.test.ts +235 -0
  105. package/packages/shared/src/__tests__/no-server-imports-in-resolver.test.ts +53 -0
  106. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +54 -101
  107. package/packages/shared/src/__tests__/node-spawn.test.ts +29 -13
  108. package/packages/shared/src/__tests__/pi-package-resolver.test.ts +300 -0
  109. package/packages/shared/src/__tests__/plugin-activation-contracts.test.ts +74 -0
  110. package/packages/shared/src/__tests__/plugin-bridge-classify-source.test.ts +73 -0
  111. package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +17 -5
  112. package/packages/shared/src/__tests__/plugin-bridge-register-packages.test.ts +233 -0
  113. package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +19 -9
  114. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +154 -15
  115. package/packages/shared/src/__tests__/recommended-extensions.test.ts +28 -10
  116. package/packages/shared/src/__tests__/resolver-parity-with-scanner.test.ts +76 -0
  117. package/packages/shared/src/__tests__/server-identity.test.ts +127 -0
  118. package/packages/shared/src/__tests__/server-launcher.test.ts +35 -0
  119. package/packages/shared/src/__tests__/source-matching.test.ts +5 -5
  120. package/packages/shared/src/__tests__/sync-versions-spec.test.ts +76 -0
  121. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +50 -2
  122. package/packages/shared/src/bridge-register.ts +35 -2
  123. package/packages/shared/src/browser-protocol.ts +176 -2
  124. package/packages/shared/src/config.ts +12 -0
  125. package/packages/shared/src/dashboard-paths.ts +69 -0
  126. package/packages/shared/src/dashboard-plugin/index.ts +2 -0
  127. package/packages/shared/src/dashboard-plugin/intent-types.ts +93 -0
  128. package/packages/shared/src/dashboard-plugin/manifest-types.ts +55 -1
  129. package/packages/shared/src/dashboard-plugin/plugin-status.ts +82 -0
  130. package/packages/shared/src/dashboard-plugin/slot-props.ts +11 -0
  131. package/packages/shared/src/dashboard-plugin/slot-types.ts +16 -2
  132. package/packages/shared/src/dashboard-plugin/ui-primitives.ts +287 -0
  133. package/packages/shared/src/dashboard-starter.ts +22 -0
  134. package/packages/shared/src/doctor-core.ts +49 -27
  135. package/packages/shared/src/launch-source-types.ts +9 -9
  136. package/packages/shared/src/legacy-managed-dir.ts +97 -0
  137. package/packages/shared/src/mdns-discovery.ts +4 -1
  138. package/packages/shared/src/pi-package-resolver.ts +388 -0
  139. package/packages/shared/src/platform/binary-lookup.ts +27 -3
  140. package/packages/shared/src/platform/ensure-windows-path.ts +95 -0
  141. package/packages/shared/src/platform/exec.ts +22 -0
  142. package/packages/shared/src/platform/node-spawn.ts +42 -41
  143. package/packages/shared/src/plugin-bridge-register.ts +275 -18
  144. package/packages/shared/src/protocol.ts +94 -2
  145. package/packages/shared/src/recommended-extensions.ts +34 -10
  146. package/packages/shared/src/server-identity.ts +74 -5
  147. package/packages/shared/src/server-launcher.ts +20 -0
  148. package/packages/shared/src/source-matching.ts +1 -1
  149. package/packages/shared/src/tool-registry/__tests__/node-script-toargv-fallback.test.ts +84 -0
  150. package/packages/shared/src/tool-registry/definitions.ts +91 -7
  151. package/packages/shared/src/types.ts +12 -8
  152. package/scripts/maybe-patch-package.cjs +44 -0
  153. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +0 -263
  154. package/packages/server/src/__tests__/bootstrap-queue.test.ts +0 -120
  155. package/packages/server/src/__tests__/bootstrap-routes.test.ts +0 -125
  156. package/packages/server/src/__tests__/bootstrap-state.test.ts +0 -119
  157. package/packages/server/src/__tests__/cli-bootstrap.test.ts +0 -36
  158. package/packages/server/src/__tests__/event-status-extraction-flow.test.ts +0 -55
  159. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +0 -149
  160. package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +0 -180
  161. package/packages/server/src/__tests__/post-install-rescan.test.ts +0 -134
  162. package/packages/server/src/__tests__/system-routes-reextract.test.ts +0 -91
  163. package/packages/server/src/bootstrap-install-from-list.ts +0 -232
  164. package/packages/server/src/bootstrap-queue.ts +0 -130
  165. package/packages/server/src/bootstrap-state.ts +0 -159
  166. package/packages/server/src/legacy-pi-cleanup.ts +0 -151
  167. package/packages/server/src/routes/bootstrap-routes.ts +0 -125
  168. package/packages/shared/src/__tests__/bootstrap/README.md +0 -133
  169. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +0 -378
  170. package/packages/shared/src/__tests__/bootstrap/assertions.ts +0 -136
  171. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +0 -47
  172. package/packages/shared/src/__tests__/bootstrap/cube.ts +0 -66
  173. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +0 -84
  174. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +0 -90
  175. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +0 -34
  176. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +0 -20
  177. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +0 -62
  178. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +0 -34
  179. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +0 -49
  180. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +0 -12
  181. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +0 -156
  182. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +0 -157
  183. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +0 -102
  184. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +0 -76
  185. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +0 -94
  186. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +0 -87
  187. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +0 -143
  188. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +0 -64
  189. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +0 -77
  190. package/packages/shared/src/__tests__/bootstrap/families/index.ts +0 -19
  191. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +0 -61
  192. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +0 -50
  193. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +0 -272
  194. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +0 -58
  195. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +0 -84
  196. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +0 -9
  197. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +0 -85
  198. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +0 -122
  199. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +0 -36
  200. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +0 -39
  201. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +0 -220
  202. package/packages/shared/src/__tests__/bootstrap/harness.ts +0 -413
  203. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +0 -125
  204. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +0 -132
  205. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +0 -72
  206. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +0 -68
  207. package/packages/shared/src/__tests__/install-managed-node.test.ts +0 -192
  208. package/packages/shared/src/__tests__/installable-list.test.ts +0 -130
  209. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +0 -52
  210. package/packages/shared/src/bootstrap-install.ts +0 -406
  211. package/packages/shared/src/installable-list.ts +0 -152
  212. package/packages/shared/src/launch-source-flag.ts +0 -14
@@ -14,10 +14,10 @@ describe("RECOMMENDED_EXTENSIONS manifest", () => {
14
14
  [
15
15
  "pi-anthropic-messages",
16
16
  "pi-agent-browser",
17
+ "pi-dashboard-subagents",
17
18
  "pi-flows",
18
19
  "pi-memory-honcho",
19
20
  "pi-web-access",
20
- "tintinweb-pi-subagents",
21
21
  ].sort(),
22
22
  );
23
23
  });
@@ -34,9 +34,21 @@ describe("RECOMMENDED_EXTENSIONS manifest", () => {
34
34
  expect(["required", "strongly-suggested", "optional"]).toContain(entry.status);
35
35
  expect(Array.isArray(entry.unlocks)).toBe(true);
36
36
  expect(entry.unlocks.length).toBeGreaterThan(0);
37
+ // dashboardPlugin is optional; when present, must be a non-empty string.
38
+ // See change: add-plugin-activation-ui.
39
+ if (entry.dashboardPlugin !== undefined) {
40
+ expect(typeof entry.dashboardPlugin).toBe("string");
41
+ expect(entry.dashboardPlugin.length).toBeGreaterThan(0);
42
+ }
37
43
  }
38
44
  });
39
45
 
46
+ it("pi-memory-honcho declares its companion dashboard plugin id", () => {
47
+ // See change: add-plugin-activation-ui (Layer 1.5).
48
+ const entry = getRecommendedExtension("pi-memory-honcho");
49
+ expect(entry?.dashboardPlugin).toBe("honcho");
50
+ });
51
+
40
52
  it("pi-anthropic-messages is marked required and uses HTTPS git URL", () => {
41
53
  const entry = getRecommendedExtension("pi-anthropic-messages");
42
54
  expect(entry).toBeDefined();
@@ -53,11 +65,16 @@ describe("RECOMMENDED_EXTENSIONS manifest", () => {
53
65
  expect(entry?.toolsRegistered).toContain("flow_write");
54
66
  });
55
67
 
56
- it("tintinweb-pi-subagents registers Agent under its canonical capitalization", () => {
57
- const entry = getRecommendedExtension("tintinweb-pi-subagents");
68
+ it("pi-dashboard-subagents registers Agent and pairs with the subagents plugin", () => {
69
+ // See change: add-subagent-inspector.
70
+ const entry = getRecommendedExtension("pi-dashboard-subagents");
58
71
  expect(entry).toBeDefined();
59
- expect(entry?.source).toBe("npm:@tintinweb/pi-subagents");
60
- expect(entry?.toolsRegistered).toContain("Agent");
72
+ expect(entry?.source).toBe(
73
+ "https://github.com/BlackBeltTechnology/pi-dashboard-subagents.git",
74
+ );
75
+ expect(entry?.toolsRegistered).toEqual(["Agent"]);
76
+ expect(entry?.dashboardPlugin).toBe("subagents");
77
+ expect(entry?.autowired).toBe(true);
61
78
  });
62
79
 
63
80
  it("npm-sourced entries use the npm: prefix", () => {
@@ -67,7 +84,6 @@ describe("RECOMMENDED_EXTENSIONS manifest", () => {
67
84
  "pi-agent-browser",
68
85
  "pi-memory-honcho",
69
86
  "pi-web-access",
70
- "tintinweb-pi-subagents",
71
87
  ].sort(),
72
88
  );
73
89
  });
@@ -80,7 +96,7 @@ describe("RECOMMENDED_EXTENSIONS manifest", () => {
80
96
  expect(entry.source).toMatch(/^https:\/\/github\.com\/[^/]+\/[^/]+\.git$/);
81
97
  }
82
98
  expect(gitEntries.map((e) => e.id).sort()).toEqual(
83
- ["pi-anthropic-messages", "pi-flows"].sort(),
99
+ ["pi-anthropic-messages", "pi-dashboard-subagents", "pi-flows"].sort(),
84
100
  );
85
101
  });
86
102
  });
@@ -105,14 +121,14 @@ describe("getRecommendedByStatus", () => {
105
121
  it("filters by strongly-suggested", () => {
106
122
  const suggested = getRecommendedByStatus("strongly-suggested");
107
123
  expect(suggested.map((e) => e.id).sort()).toEqual(
108
- ["pi-flows", "pi-web-access", "tintinweb-pi-subagents"].sort(),
124
+ ["pi-flows", "pi-web-access"].sort(),
109
125
  );
110
126
  });
111
127
 
112
128
  it("filters by optional", () => {
113
129
  const optional = getRecommendedByStatus("optional");
114
130
  expect(optional.map((e) => e.id).sort()).toEqual(
115
- ["pi-agent-browser", "pi-memory-honcho"].sort(),
131
+ ["pi-agent-browser", "pi-dashboard-subagents", "pi-memory-honcho"].sort(),
116
132
  );
117
133
  });
118
134
  });
@@ -139,8 +155,10 @@ describe("BUNDLED_EXTENSION_IDS manifest", () => {
139
155
  // blocking the bundle-recommended-extensions.mjs license check.
140
156
  // Re-add when https://github.com/BlackBeltTechnology/pi-flows has
141
157
  // a license declared.
158
+ // pi-dashboard-subagents added in add-subagent-inspector §13.6
159
+ // (git source + MIT license, both gates pass).
142
160
  expect([...BUNDLED_EXTENSION_IDS].sort()).toEqual(
143
- ["pi-anthropic-messages"].sort(),
161
+ ["pi-anthropic-messages", "pi-dashboard-subagents"].sort(),
144
162
  );
145
163
  });
146
164
 
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Cross-helper parity: `packages/shared/src/pi-package-resolver.ts`
3
+ * duplicates source-kind parsing + install-path computation from
4
+ * `packages/server/src/pi-resource-scanner.ts` (the older server-only
5
+ * helper). If a future maintainer updates one without the other,
6
+ * package resolution diverges silently between the dashboard plugin
7
+ * bridges (shared) and the server-side resources scanner.
8
+ *
9
+ * This test is a structural pin: it asserts the scanner source still
10
+ * contains the same source-kind prefixes the shared resolver handles
11
+ * AND the same install-path layout strings (`.pi/git`, `.pi/agent/git`,
12
+ * `node_modules`). The cross-package file is read via fs only — no
13
+ * import statement so the shared package's tsconfig rootDir is
14
+ * respected.
15
+ *
16
+ * If this fails, sync the two helpers by hand (the resolver here in
17
+ * shared, the scanner in server). A follow-up cleanup that has the
18
+ * scanner consume the shared helper would retire this test.
19
+ *
20
+ * See change: add-shared-pi-package-resolver (Decision D3).
21
+ */
22
+ import { describe, it, expect } from "vitest";
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import url from "node:url";
26
+
27
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
28
+ const scannerPath = path.resolve(
29
+ __dirname,
30
+ "..",
31
+ "..",
32
+ "..",
33
+ "server",
34
+ "src",
35
+ "pi-resource-scanner.ts",
36
+ );
37
+ const resolverPath = path.resolve(__dirname, "..", "pi-package-resolver.ts");
38
+
39
+ describe("pi-package-resolver / pi-resource-scanner parity (structural)", () => {
40
+ it("scanner source recognizes all source-kind prefixes the resolver handles", () => {
41
+ const scannerSrc = fs.readFileSync(scannerPath, "utf-8");
42
+ // The shared resolver parses these prefixes; the scanner must too.
43
+ for (const prefix of ['"npm:"', '"git:"', '"https://"', '"http://"', '"ssh://"']) {
44
+ expect(scannerSrc.includes(`.startsWith(${prefix})`)).toBe(true);
45
+ }
46
+ });
47
+
48
+ it("both helpers reference identical install-path layout markers", () => {
49
+ const scannerSrc = fs.readFileSync(scannerPath, "utf-8");
50
+ const resolverSrc = fs.readFileSync(resolverPath, "utf-8");
51
+ // Both helpers must layer git installs under a "git" subdir of either
52
+ // the agentDir (user scope) or `<cwd>/.pi/` (project scope). If one
53
+ // ever switches to e.g. `"repos"` while the other stays on `"git"`,
54
+ // resolutions diverge silently. Marker assertion accepts both spellings.
55
+ for (const [label, src] of [["scanner", scannerSrc], ["resolver", resolverSrc]] as const) {
56
+ const hasGitMarker =
57
+ src.includes('"agent", "git"') ||
58
+ src.includes('".pi", "agent", "git"') ||
59
+ src.includes('"git"');
60
+ expect(hasGitMarker, `${label} must reference a "git" subdir marker`).toBe(true);
61
+ }
62
+ // project-scope <cwd>/.pi/<arm> marker is identical in both.
63
+ for (const [label, src] of [["scanner", scannerSrc], ["resolver", resolverSrc]] as const) {
64
+ expect(src.includes('".pi"'), `${label} must reference the ".pi" config dir`).toBe(true);
65
+ }
66
+ });
67
+
68
+ it("resolver and scanner both consume npm.rootGlobalOr for npm: arm", () => {
69
+ const scannerSrc = fs.readFileSync(scannerPath, "utf-8");
70
+ const resolverSrc = fs.readFileSync(resolverPath, "utf-8");
71
+ // Both must obtain the npm global root the same way; if one stops
72
+ // using this helper the other will go stale.
73
+ expect(scannerSrc.includes("rootGlobalOr")).toBe(true);
74
+ expect(resolverSrc.includes("rootGlobalOr")).toBe(true);
75
+ });
76
+ });
@@ -70,4 +70,131 @@ describe("isDashboardRunning", () => {
70
70
  expect.any(Object),
71
71
  );
72
72
  });
73
+
74
+ it("returns version from health response when present", async () => {
75
+ globalThis.fetch = vi.fn().mockResolvedValue({
76
+ ok: true,
77
+ json: () => Promise.resolve({ ok: true, pid: 12345, version: "1.2.3" }),
78
+ });
79
+ const result = await isDashboardRunning(8000);
80
+ expect(result).toEqual({ running: true, pid: 12345, version: "1.2.3" });
81
+ });
82
+ });
83
+
84
+ describe("isDashboardRunning retry semantics (cherry-pick 2)", () => {
85
+ const originalFetch = globalThis.fetch;
86
+
87
+ afterEach(() => {
88
+ globalThis.fetch = originalFetch;
89
+ });
90
+
91
+ it("single-shot is the default (retries=0, 1 attempt)", async () => {
92
+ const sleep = vi.fn().mockResolvedValue(undefined);
93
+ globalThis.fetch = vi.fn().mockRejectedValue(
94
+ Object.assign(new Error("timeout"), { name: "AbortError" }),
95
+ );
96
+ const result = await isDashboardRunning(8000, "localhost", { _sleep: sleep });
97
+ expect(result).toEqual({ running: false });
98
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
99
+ expect(sleep).not.toHaveBeenCalled();
100
+ });
101
+
102
+ it("retries=2 with two transient failures then success returns running:true", async () => {
103
+ const sleep = vi.fn().mockResolvedValue(undefined);
104
+ const abort = Object.assign(new Error("timeout"), { name: "AbortError" });
105
+ let call = 0;
106
+ globalThis.fetch = vi.fn().mockImplementation(() => {
107
+ call++;
108
+ if (call < 3) return Promise.reject(abort);
109
+ return Promise.resolve({
110
+ ok: true,
111
+ json: () => Promise.resolve({ ok: true, pid: 999 }),
112
+ } as Response);
113
+ });
114
+
115
+ const result = await isDashboardRunning(8000, "localhost", {
116
+ retries: 2,
117
+ retryDelayMs: 100,
118
+ _sleep: sleep,
119
+ });
120
+ expect(result).toEqual({ running: true, pid: 999 });
121
+ expect(globalThis.fetch).toHaveBeenCalledTimes(3);
122
+ expect(sleep).toHaveBeenCalledTimes(2);
123
+ expect(sleep).toHaveBeenNthCalledWith(1, 100);
124
+ expect(sleep).toHaveBeenNthCalledWith(2, 100);
125
+ });
126
+
127
+ it("portConflict:true short-circuits retries", async () => {
128
+ const sleep = vi.fn().mockResolvedValue(undefined);
129
+ globalThis.fetch = vi.fn().mockResolvedValue({
130
+ ok: true,
131
+ json: () => Promise.resolve({ status: "ok", service: "nginx" }),
132
+ } as Response);
133
+
134
+ const result = await isDashboardRunning(8000, "localhost", {
135
+ retries: 5,
136
+ _sleep: sleep,
137
+ });
138
+ expect(result).toEqual({ running: false, portConflict: true });
139
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
140
+ expect(sleep).not.toHaveBeenCalled();
141
+ });
142
+
143
+ it("ECONNREFUSED with default retries=0 returns running:false without retry", async () => {
144
+ const sleep = vi.fn().mockResolvedValue(undefined);
145
+ globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
146
+
147
+ const result = await isDashboardRunning(8000, "localhost", { _sleep: sleep });
148
+ expect(result).toEqual({ running: false });
149
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
150
+ expect(sleep).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it("exhausted retries return the last non-success result", async () => {
154
+ const sleep = vi.fn().mockResolvedValue(undefined);
155
+ globalThis.fetch = vi.fn().mockRejectedValue(
156
+ Object.assign(new Error("timeout"), { name: "AbortError" }),
157
+ );
158
+ const result = await isDashboardRunning(8000, "localhost", {
159
+ retries: 3,
160
+ _sleep: sleep,
161
+ });
162
+ expect(result).toEqual({ running: false });
163
+ expect(globalThis.fetch).toHaveBeenCalledTimes(4); // 1 + 3 retries
164
+ expect(sleep).toHaveBeenCalledTimes(3);
165
+ });
166
+
167
+ it("custom timeoutMs is passed to AbortController", async () => {
168
+ // Indirect assertion: with timeoutMs=10 and a fetch that takes 50ms,
169
+ // the abort fires and we get running:false. With timeoutMs=200, the
170
+ // same fetch succeeds.
171
+ const makeSlowFetch = (delayMs: number) =>
172
+ vi.fn().mockImplementation(
173
+ (_url: string, init: RequestInit) =>
174
+ new Promise((resolve, reject) => {
175
+ const timer = setTimeout(
176
+ () =>
177
+ resolve({
178
+ ok: true,
179
+ json: () => Promise.resolve({ ok: true, pid: 1 }),
180
+ } as Response),
181
+ delayMs,
182
+ );
183
+ init.signal?.addEventListener("abort", () => {
184
+ clearTimeout(timer);
185
+ const err = new Error("aborted");
186
+ err.name = "AbortError";
187
+ reject(err);
188
+ });
189
+ }),
190
+ );
191
+
192
+ globalThis.fetch = makeSlowFetch(50);
193
+ const tight = await isDashboardRunning(8000, "localhost", { timeoutMs: 10 });
194
+ expect(tight).toEqual({ running: false });
195
+
196
+ globalThis.fetch = makeSlowFetch(10);
197
+ const loose = await isDashboardRunning(8000, "localhost", { timeoutMs: 200 });
198
+ expect(loose).toEqual({ running: true, pid: 1 });
199
+ });
73
200
  });
@@ -202,6 +202,41 @@ describe("launchDashboardServer — env merge", () => {
202
202
  });
203
203
  });
204
204
 
205
+ describe("launchDashboardServer — onChildExit (cherry-pick 6a)", () => {
206
+ it("invokes onChildExit when child emits exit after readiness", async () => {
207
+ const child = makeFakeChild();
208
+ const onChildExit = vi.fn();
209
+ await launchDashboardServer(baseOpts({
210
+ _spawnNodeScript: spawnSpy(() => child),
211
+ onChildExit,
212
+ }));
213
+ // Simulate post-readiness crash
214
+ (child as unknown as EventEmitter).emit("exit", 1, null);
215
+ expect(onChildExit).toHaveBeenCalledOnce();
216
+ expect(onChildExit).toHaveBeenCalledWith(1, null);
217
+ });
218
+
219
+ it("fires only once even if exit emitted twice", async () => {
220
+ const child = makeFakeChild();
221
+ const onChildExit = vi.fn();
222
+ await launchDashboardServer(baseOpts({
223
+ _spawnNodeScript: spawnSpy(() => child),
224
+ onChildExit,
225
+ }));
226
+ (child as unknown as EventEmitter).emit("exit", 0, null);
227
+ (child as unknown as EventEmitter).emit("exit", 0, null);
228
+ expect(onChildExit).toHaveBeenCalledOnce(); // child.once not child.on
229
+ });
230
+
231
+ it("does NOT attach any listener when onChildExit omitted", async () => {
232
+ const child = makeFakeChild();
233
+ const listenersBefore = (child as unknown as EventEmitter).listenerCount("exit");
234
+ await launchDashboardServer(baseOpts({ _spawnNodeScript: spawnSpy(() => child) }));
235
+ const listenersAfter = (child as unknown as EventEmitter).listenerCount("exit");
236
+ expect(listenersAfter).toBe(listenersBefore);
237
+ });
238
+ });
239
+
205
240
  describe("launchDashboardServer — entry URL-wrapping", () => {
206
241
  // The launcher delegates to spawnNodeScript, which uses
207
242
  // `shouldUrlWrapEntry(loader, platform)`. We verify the launcher
@@ -14,16 +14,16 @@ describe("parseSourceKey", () => {
14
14
  });
15
15
 
16
16
  it("parses scoped npm name without version", () => {
17
- expect(parseSourceKey("npm:@tintinweb/pi-subagents")).toEqual({
17
+ expect(parseSourceKey("npm:@scope/example-pkg")).toEqual({
18
18
  kind: "npm",
19
- name: "@tintinweb/pi-subagents",
19
+ name: "@scope/example-pkg",
20
20
  });
21
21
  });
22
22
 
23
23
  it("parses scoped npm name with version", () => {
24
- expect(parseSourceKey("npm:@tintinweb/pi-subagents@0.5.2")).toEqual({
24
+ expect(parseSourceKey("npm:@scope/example-pkg@0.5.2")).toEqual({
25
25
  kind: "npm",
26
- name: "@tintinweb/pi-subagents",
26
+ name: "@scope/example-pkg",
27
27
  });
28
28
  });
29
29
 
@@ -67,7 +67,7 @@ describe("sourcesMatch", () => {
67
67
 
68
68
  it("matches scoped npm names", () => {
69
69
  expect(
70
- sourcesMatch("npm:@tintinweb/pi-subagents@0.5.2", "npm:@tintinweb/pi-subagents"),
70
+ sourcesMatch("npm:@scope/example-pkg@0.5.2", "npm:@scope/example-pkg"),
71
71
  ).toBe(true);
72
72
  });
73
73
 
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Unit tests for `scripts/sync-versions-spec.js::isRewritableSemverSpec`.
3
+ *
4
+ * Lives in `packages/shared/__tests__/` because the repo's vitest projects all
5
+ * scope to `packages/`; the helper itself stays in `scripts/` (pure JS, no
6
+ * runtime deps) so the release script can `import` it without crossing into
7
+ * a workspace package boundary.
8
+ *
9
+ * The classifier decides which dependency specifiers are eligible for the
10
+ * release bump's `^<version>` rewrite. False values are deliberate human
11
+ * overrides and MUST be preserved (the bug this guards: a future hotfix
12
+ * pin like `"*"` being silently rewritten on the next release).
13
+ */
14
+ import { describe, it, expect } from "vitest";
15
+ // @ts-expect-error — pure JS helper one level above the package; no .d.ts
16
+ import { isRewritableSemverSpec } from "../../../../scripts/sync-versions-spec.js";
17
+
18
+ describe("isRewritableSemverSpec", () => {
19
+ describe("rewritable forms (returns true)", () => {
20
+ it.each([
21
+ ["plain", "0.5.0"],
22
+ ["caret", "^0.5.0"],
23
+ ["tilde", "~0.5.0"],
24
+ ["caret + prerelease", "^0.5.0-alpha.1"],
25
+ ["plain + prerelease", "0.5.0-rc.0"],
26
+ ["caret + build", "^0.5.0+sha.abc"],
27
+ ["caret + prerelease + build", "^0.5.0-alpha.1+sha.abc"],
28
+ ["multi-digit", "10.20.30"],
29
+ ])("returns true for %s (%s)", (_label, spec) => {
30
+ expect(isRewritableSemverSpec(spec)).toBe(true);
31
+ });
32
+ });
33
+
34
+ describe("preserved forms (returns false)", () => {
35
+ it.each([
36
+ ["wildcard", "*"],
37
+ ["latest tag", "latest"],
38
+ ["dist-tag", "next"],
39
+ ["workspace protocol", "workspace:*"],
40
+ ["workspace caret", "workspace:^0.5.0"],
41
+ ["github URL", "github:owner/repo#sha"],
42
+ ["github tarball URL", "https://github.com/o/r/tarball/main"],
43
+ ["git+ssh URL", "git+ssh://git@github.com/o/r.git"],
44
+ ["file path", "file:../foo"],
45
+ ["plain http tarball", "http://example.com/x.tgz"],
46
+ ["range gte", ">=1.0.0"],
47
+ ["range or-union", "1.0.0 || 2.0.0"],
48
+ ["range hyphen", "1.0.0 - 2.0.0"],
49
+ ["range x", "1.x"],
50
+ ["range x-dotted", "1.x.x"],
51
+ ["empty", ""],
52
+ ["whitespace only", " "],
53
+ ["partial caret", "^"],
54
+ ])("returns false for %s (%s)", (_label, spec) => {
55
+ expect(isRewritableSemverSpec(spec)).toBe(false);
56
+ });
57
+ });
58
+
59
+ describe("non-string inputs", () => {
60
+ it("returns false for undefined", () => {
61
+ expect(isRewritableSemverSpec(undefined)).toBe(false);
62
+ });
63
+
64
+ it("returns false for null", () => {
65
+ expect(isRewritableSemverSpec(null)).toBe(false);
66
+ });
67
+
68
+ it("returns false for number", () => {
69
+ expect(isRewritableSemverSpec(0.5)).toBe(false);
70
+ });
71
+
72
+ it("returns false for object", () => {
73
+ expect(isRewritableSemverSpec({ version: "1.0.0" })).toBe(false);
74
+ });
75
+ });
76
+ });
@@ -40,11 +40,24 @@ function freshRegistry(opts: {
40
40
  }
41
41
 
42
42
  describe("pi binary definition", () => {
43
- it("chain order: override → managed → where", () => {
44
- const r = freshRegistry({ which: (n) => (n === "pi" ? "/usr/bin/pi" : null) });
43
+ it("chain order: override → bare-import ×2 → managed → where", () => {
44
+ // bare-import strategies probe both pi-coding-agent aliases
45
+ // (@earendil-works + @mariozechner) before falling through to
46
+ // managed-bin and PATH. They fail in this fixture because the
47
+ // injected `exists` returns false for all paths.
48
+ // See change: eliminate-electron-runtime-install F9.
49
+ const r = freshRegistry({
50
+ which: (n) => (n === "pi" ? "/usr/bin/pi" : null),
51
+ // No resolveModule injection — real resolver runs against the
52
+ // repo's node_modules. The bare-import strategy returns a
53
+ // path, but `exists: () => false` invalidates it, so the chain
54
+ // falls through to `where`.
55
+ });
45
56
  const res = r.resolve("pi");
46
57
  expect(res.tried.map((t) => t.strategy)).toEqual([
47
58
  "override",
59
+ "bare-import",
60
+ "bare-import",
48
61
  "managed",
49
62
  "where",
50
63
  ]);
@@ -53,6 +66,41 @@ describe("pi binary definition", () => {
53
66
  expect(res.source).toBe("system");
54
67
  });
55
68
 
69
+ it("bare-import wins over PATH when bundled cli.js exists (F9)", () => {
70
+ // Simulates the Electron immutable-bundle architecture: a
71
+ // bundled @earendil-works/pi-coding-agent ships inside the
72
+ // server's own node_modules. With no PATH, no managed dir,
73
+ // bare-import must resolve the bundled cli.js — otherwise the
74
+ // server falls into bootstrapInstall() and writes to
75
+ // ~/.pi-dashboard/ (the failure mode F9 documents).
76
+ const bundledPkgJson =
77
+ "/Volumes/PI Dashboard/PI-Dashboard.app/Contents/Resources/server/node_modules/@earendil-works/pi-coding-agent/package.json";
78
+ const bundledCli =
79
+ "/Volumes/PI Dashboard/PI-Dashboard.app/Contents/Resources/server/node_modules/@earendil-works/pi-coding-agent/dist/cli.js";
80
+ const r = new ToolRegistry({
81
+ overrides: new OverridesStore({
82
+ filePath: path.join(os.tmpdir(), `f9-test-${Math.random()}.json`),
83
+ warn: () => {},
84
+ }),
85
+ platform: "linux",
86
+ });
87
+ registerDefaultTools(r, {
88
+ exists: (p) => p === bundledCli, // only the bundled cli.js exists
89
+ which: () => null, // no PATH
90
+ npmRootGlobal: () => "", // no npm-global
91
+ resolveModule: (id, _from) =>
92
+ id === "@earendil-works/pi-coding-agent/package.json"
93
+ ? bundledPkgJson
94
+ : null,
95
+ });
96
+ const res = r.resolve("pi");
97
+ expect(res.ok).toBe(true);
98
+ expect(res.path).toBe(bundledCli);
99
+ expect(res.tried.find((t) => t.strategy === "bare-import")?.result).toBe(
100
+ "ok",
101
+ );
102
+ });
103
+
56
104
  it("managed wins over system when MANAGED_BIN/pi exists", () => {
57
105
  const managed = path.join(os.homedir(), ".pi-dashboard", "node_modules", ".bin", "pi");
58
106
  const r = freshRegistry({
@@ -43,6 +43,23 @@ export interface FindExtensionDeps {
43
43
  resolvePackage?: () => string | null;
44
44
  }
45
45
 
46
+ /**
47
+ * Read `name` from `<dir>/package.json`. Returns null on any error
48
+ * (missing file, unreadable, invalid JSON, missing name field).
49
+ * Used for identity-based dedup in `registerBridgeExtension`.
50
+ */
51
+ function readPackageName(dir: string): string | null {
52
+ try {
53
+ const pkgPath = path.join(dir, "package.json");
54
+ if (!fs.existsSync(pkgPath)) return null;
55
+ const raw = fs.readFileSync(pkgPath, "utf-8");
56
+ const parsed = JSON.parse(raw);
57
+ return typeof parsed?.name === "string" ? parsed.name : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
46
63
  function defaultResolvePackage(): string | null {
47
64
  try {
48
65
  const req = createRequire(import.meta.url);
@@ -133,12 +150,28 @@ export function registerBridgeExtension(
133
150
  // Already registered?
134
151
  if (packages.includes(extensionPath)) return;
135
152
 
136
- // Non-destructive cleanup: only remove broken dashboard paths
153
+ // Compute the identity (package.json#name) of the new entry. We use it
154
+ // to dedupe across install layouts (dev / .app / npm-global / legacy
155
+ // managed dir) that all register the same extension under different
156
+ // absolute paths.
157
+ const newIdentity = readPackageName(extensionPath);
158
+
159
+ // Non-destructive cleanup: drop stale dashboard paths AND drop any
160
+ // local entry with the same package.json#name as the new one
161
+ // (most-recently-asserted path wins). npm:-scheme entries pass through
162
+ // untouched.
137
163
  const cleaned = packages.filter((p) => {
138
164
  if (typeof p !== "string") return true;
139
165
  const isLocalPath = p.startsWith("/") || /^[a-zA-Z]:[/\\]/.test(p);
140
166
  if (!isLocalPath) return true;
141
- // Only consider dashboard-related paths for cleanup
167
+
168
+ // Identity dedup: same package name as the incoming entry?
169
+ if (newIdentity) {
170
+ const existingIdentity = readPackageName(p);
171
+ if (existingIdentity && existingIdentity === newIdentity) return false;
172
+ }
173
+
174
+ // Only consider dashboard-related paths for path-based cleanup
142
175
  // Normalize: lowercase + collapse spaces/hyphens so "PI Dashboard" matches "pi-dashboard"
143
176
  const normalized = p.toLowerCase().replace(/[\s_-]/g, "");
144
177
  if (!normalized.includes("pidashboard") && !normalized.includes("piagentdashboard")) return true;