@blackbelt-technology/pi-agent-dashboard 0.3.0 → 0.4.0

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 (197) hide show
  1. package/AGENTS.md +67 -116
  2. package/README.md +93 -7
  3. package/docs/architecture.md +408 -9
  4. package/package.json +6 -4
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  7. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  8. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  9. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  10. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  11. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  12. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  13. package/packages/extension/src/bridge.ts +69 -2
  14. package/packages/extension/src/dev-build.ts +1 -1
  15. package/packages/extension/src/git-info.ts +9 -19
  16. package/packages/extension/src/pi-env.d.ts +1 -0
  17. package/packages/extension/src/process-scanner.ts +72 -38
  18. package/packages/extension/src/provider-register.ts +304 -16
  19. package/packages/extension/src/server-auto-start.ts +27 -1
  20. package/packages/extension/src/server-launcher.ts +71 -27
  21. package/packages/server/package.json +16 -2
  22. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  23. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  24. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  25. package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
  26. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  27. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  28. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  29. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  30. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  31. package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
  32. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  33. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  34. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  35. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  36. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  37. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  38. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  39. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  40. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  41. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
  42. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  43. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  44. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  45. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  46. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  47. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  48. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  49. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  50. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  51. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  52. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  53. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  55. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  56. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  57. package/packages/server/src/__tests__/tunnel.test.ts +13 -7
  58. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  59. package/packages/server/src/bootstrap-queue.ts +130 -0
  60. package/packages/server/src/bootstrap-state.ts +131 -0
  61. package/packages/server/src/browse.ts +8 -3
  62. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  63. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  64. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  65. package/packages/server/src/cli.ts +256 -32
  66. package/packages/server/src/config-api.ts +16 -0
  67. package/packages/server/src/directory-service.ts +270 -39
  68. package/packages/server/src/editor-detection.ts +12 -9
  69. package/packages/server/src/editor-manager.ts +19 -4
  70. package/packages/server/src/editor-pid-registry.ts +9 -8
  71. package/packages/server/src/editor-registry.ts +22 -25
  72. package/packages/server/src/git-operations.ts +1 -1
  73. package/packages/server/src/headless-pid-registry.ts +7 -20
  74. package/packages/server/src/home-lock-release.ts +72 -0
  75. package/packages/server/src/home-lock.ts +389 -0
  76. package/packages/server/src/node-guard.ts +52 -0
  77. package/packages/server/src/package-manager-wrapper.ts +207 -47
  78. package/packages/server/src/pi-core-checker.ts +1 -1
  79. package/packages/server/src/pi-core-updater.ts +7 -1
  80. package/packages/server/src/pi-resource-scanner.ts +5 -8
  81. package/packages/server/src/pi-version-skew.ts +196 -0
  82. package/packages/server/src/preferences-store.ts +17 -3
  83. package/packages/server/src/process-manager.ts +403 -222
  84. package/packages/server/src/provider-probe.ts +234 -0
  85. package/packages/server/src/restart-helper.ts +130 -0
  86. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  87. package/packages/server/src/routes/openspec-routes.ts +25 -1
  88. package/packages/server/src/routes/pi-core-routes.ts +24 -1
  89. package/packages/server/src/routes/provider-auth-routes.ts +8 -8
  90. package/packages/server/src/routes/provider-routes.ts +43 -0
  91. package/packages/server/src/routes/recommended-routes.ts +10 -12
  92. package/packages/server/src/routes/system-routes.ts +20 -33
  93. package/packages/server/src/routes/tool-routes.ts +153 -0
  94. package/packages/server/src/server-pid.ts +5 -9
  95. package/packages/server/src/server.ts +211 -10
  96. package/packages/server/src/session-api.ts +77 -8
  97. package/packages/server/src/session-bootstrap.ts +17 -3
  98. package/packages/server/src/session-diff.ts +21 -21
  99. package/packages/server/src/terminal-manager.ts +61 -20
  100. package/packages/server/src/tunnel.ts +42 -28
  101. package/packages/shared/package.json +10 -3
  102. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  103. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  104. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  105. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  106. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  107. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  108. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  109. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  110. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  111. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  112. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  113. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  114. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  115. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  116. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  117. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  118. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  119. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  120. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  121. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  122. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  123. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  124. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  125. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  126. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  127. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  128. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  129. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  130. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  131. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  132. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  133. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  134. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  135. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  136. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  137. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  138. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  139. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  140. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  141. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  142. package/packages/shared/src/__tests__/config.test.ts +56 -0
  143. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  144. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  145. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  146. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  147. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  148. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  149. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  150. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  151. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  152. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  153. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  154. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  155. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  156. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  157. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  158. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  159. package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
  160. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  161. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  162. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  163. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  164. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  165. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  166. package/packages/shared/src/bootstrap-install.ts +212 -0
  167. package/packages/shared/src/bridge-register.ts +87 -20
  168. package/packages/shared/src/browser-protocol.ts +71 -1
  169. package/packages/shared/src/config.ts +87 -15
  170. package/packages/shared/src/managed-paths.ts +31 -4
  171. package/packages/shared/src/openspec-poller.ts +63 -46
  172. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  173. package/packages/shared/src/platform/commands.ts +100 -0
  174. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  175. package/packages/shared/src/platform/exec.ts +220 -0
  176. package/packages/shared/src/platform/git.ts +155 -0
  177. package/packages/shared/src/platform/index.ts +15 -0
  178. package/packages/shared/src/platform/npm.ts +162 -0
  179. package/packages/shared/src/platform/openspec.ts +91 -0
  180. package/packages/shared/src/platform/paths.ts +276 -0
  181. package/packages/shared/src/platform/process-identify.ts +126 -0
  182. package/packages/shared/src/platform/process-scan.ts +94 -0
  183. package/packages/shared/src/platform/process.ts +168 -0
  184. package/packages/shared/src/platform/runner.ts +369 -0
  185. package/packages/shared/src/platform/shell.ts +44 -0
  186. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  187. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  188. package/packages/shared/src/recommended-extensions.ts +18 -2
  189. package/packages/shared/src/resolve-jiti.ts +62 -3
  190. package/packages/shared/src/rest-api.ts +26 -0
  191. package/packages/shared/src/semaphore.ts +83 -0
  192. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  193. package/packages/shared/src/tool-registry/index.ts +56 -0
  194. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  195. package/packages/shared/src/tool-registry/registry.ts +262 -0
  196. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  197. package/packages/shared/src/tool-registry/types.ts +180 -0
@@ -82,7 +82,7 @@ export interface EnrichedRecommendedExtension extends RecommendedExtension {
82
82
  export const RECOMMENDED_EXTENSIONS: readonly RecommendedExtension[] = [
83
83
  {
84
84
  id: "pi-anthropic-messages",
85
- source: "git@github.com:BlackBeltTechnology/pi-anthropic-messages.git",
85
+ source: "https://github.com/BlackBeltTechnology/pi-anthropic-messages.git",
86
86
  displayName: "pi-anthropic-messages",
87
87
  fallbackDescription:
88
88
  "Protocol bridge that makes pi's custom tools work with any " +
@@ -114,7 +114,7 @@ export const RECOMMENDED_EXTENSIONS: readonly RecommendedExtension[] = [
114
114
  },
115
115
  {
116
116
  id: "pi-flows",
117
- source: "git@github.com:BlackBeltTechnology/pi-flows.git",
117
+ source: "https://github.com/BlackBeltTechnology/pi-flows.git",
118
118
  displayName: "pi-flows",
119
119
  fallbackDescription:
120
120
  "Flow engine, dashboard, and orchestration extensions for pi. " +
@@ -167,6 +167,22 @@ export const RECOMMENDED_EXTENSIONS: readonly RecommendedExtension[] = [
167
167
  },
168
168
  ];
169
169
 
170
+ /**
171
+ * Ids of recommended extensions that ship inside the Electron installer
172
+ * as a pre-bundled source tree. See
173
+ * `packages/electron/scripts/bundle-recommended-extensions.sh` and
174
+ * `installBundledExtensions()` in `dependency-installer.ts`. Every id
175
+ * MUST also appear in `RECOMMENDED_EXTENSIONS` and MUST have a git-based
176
+ * `source` (enforced by a test).
177
+ *
178
+ * Kept deliberately short — only first-party, source-only, native-dep-free
179
+ * extensions belong here.
180
+ */
181
+ export const BUNDLED_EXTENSION_IDS: readonly string[] = [
182
+ "pi-anthropic-messages",
183
+ "pi-flows",
184
+ ];
185
+
170
186
  /** Retrieve a recommended entry by id, or `undefined`. */
171
187
  export function getRecommendedExtension(id: string): RecommendedExtension | undefined {
172
188
  return RECOMMENDED_EXTENSIONS.find((e) => e.id === id);
@@ -8,8 +8,9 @@
8
8
  */
9
9
 
10
10
  import { createRequire } from "node:module";
11
- import { realpathSync } from "node:fs";
11
+ import { existsSync, realpathSync } from "node:fs";
12
12
  import path from "node:path";
13
+ import { pathToFileURL } from "node:url";
13
14
 
14
15
  const JITI_PACKAGES = [
15
16
  "@mariozechner/jiti",
@@ -17,8 +18,38 @@ const JITI_PACKAGES = [
17
18
  ];
18
19
 
19
20
  /**
20
- * Returns the absolute path to jiti's register hook (lib/jiti-register.mjs).
21
+ * Pure helper: given a jiti package.json path, return the file:// URL of
22
+ * its register hook. Exported for testing — no I/O.
23
+ *
24
+ * Returns a file:// URL (not a raw path) because Node >= 20 on Windows
25
+ * rejects raw absolute paths with a drive letter for --import (parses
26
+ * "C:" / "B:" as a URL scheme → ERR_UNSUPPORTED_ESM_URL_SCHEME). file://
27
+ * URLs are accepted on every OS.
28
+ * See change: fix-windows-server-parity.
29
+ */
30
+ export function buildJitiRegisterUrl(pkgJsonPath: string): string {
31
+ // Detect Windows-style input (drive letter + backslash) regardless of
32
+ // host OS, so unit tests can exercise the Windows path contract on macOS/Linux.
33
+ // Production behaviour is unchanged because the host-OS `path`/`pathToFileURL`
34
+ // match the input style automatically.
35
+ const isWindowsStyle = /^[A-Za-z]:[\\/]/.test(pkgJsonPath);
36
+ if (isWindowsStyle) {
37
+ // Manually build file:///C:/path/lib/jiti-register.mjs — pathToFileURL on
38
+ // POSIX hosts URL-encodes backslashes rather than treating them as
39
+ // separators. Do the join with path.win32 and format the URL ourselves.
40
+ const registerPath = path.win32.join(path.win32.dirname(pkgJsonPath), "lib", "jiti-register.mjs");
41
+ return `file:///${registerPath.replace(/\\/g, "/")}`;
42
+ }
43
+ const registerPath = path.join(path.dirname(pkgJsonPath), "lib", "jiti-register.mjs");
44
+ return pathToFileURL(registerPath).href;
45
+ }
46
+
47
+ /**
48
+ * Returns jiti's register hook as a file:// URL suitable for `node --import`.
21
49
  * Uses process.argv[1] (pi's entry point) to anchor module resolution.
50
+ *
51
+ * The return value is ALWAYS a file:// URL (never a raw path). See
52
+ * buildJitiRegisterUrl for the URL contract rationale.
22
53
  */
23
54
  export function resolveJitiImport(): string {
24
55
  const anchor = process.argv[1];
@@ -30,7 +61,7 @@ export function resolveJitiImport(): string {
30
61
  for (const jiti of JITI_PACKAGES) {
31
62
  try {
32
63
  const pkgJson = req.resolve(`${jiti}/package.json`);
33
- return path.join(path.dirname(pkgJson), "lib", "jiti-register.mjs");
64
+ return buildJitiRegisterUrl(pkgJson);
34
65
  } catch { /* next */ }
35
66
  }
36
67
  } catch { /* fall through */ }
@@ -41,3 +72,31 @@ export function resolveJitiImport(): string {
41
72
  "Is @mariozechner/pi-coding-agent or @oh-my-pi/pi-coding-agent installed?"
42
73
  );
43
74
  }
75
+
76
+ /**
77
+ * Resolve jiti's register hook from an arbitrary anchor path (e.g. a
78
+ * pi-coding-agent package.json in a managed install, or a pi binary on
79
+ * the system PATH). Returns a file:// URL or null if jiti cannot be
80
+ * resolved from the anchor.
81
+ *
82
+ * This is the Electron/managed-install variant of `resolveJitiImport`
83
+ * — the difference is the caller supplies the anchor explicitly
84
+ * instead of using `process.argv[1]`. Consolidates what used to be a
85
+ * duplicate `resolveJitiFromAnchor` in
86
+ * `packages/electron/src/lib/server-lifecycle.ts`.
87
+ * See change: consolidate-platform-handlers.
88
+ */
89
+ export function resolveJitiFromAnchor(anchorPath: string): string | null {
90
+ if (!existsSync(anchorPath)) return null;
91
+ try {
92
+ const req = createRequire(anchorPath);
93
+ for (const jiti of JITI_PACKAGES) {
94
+ try {
95
+ const pkgJson = req.resolve(`${jiti}/package.json`);
96
+ const registerPath = path.join(path.dirname(pkgJson), "lib", "jiti-register.mjs");
97
+ if (existsSync(registerPath)) return pathToFileURL(registerPath).href;
98
+ } catch { /* next */ }
99
+ }
100
+ } catch { /* ignore */ }
101
+ return null;
102
+ }
@@ -80,6 +80,16 @@ export interface BrowseResult {
80
80
  entries: BrowseEntry[];
81
81
  parent: string | null;
82
82
  current: string;
83
+ /**
84
+ * The server's `process.platform` — lets the client use OS-correct path
85
+ * handling (separator, case-sensitivity, drive-letter rules) without
86
+ * having to sniff `navigator.userAgent`. Optional for backward
87
+ * compatibility; consumers fall back to inferring from the `current`
88
+ * path shape when absent.
89
+ *
90
+ * See change: platform-path-normalization.
91
+ */
92
+ platform?: NodeJS.Platform;
83
93
  }
84
94
 
85
95
  export type BrowseResponse = ApiResponse<BrowseResult>;
@@ -352,3 +362,19 @@ export interface NetworkInterface {
352
362
  export type ListRecommendedExtensionsResponse = ApiResponse<{
353
363
  recommended: EnrichedRecommendedExtension[];
354
364
  }>;
365
+
366
+ // ── Tool registry ────────────────────
367
+
368
+ import type { Resolution } from "./tool-registry/types.js";
369
+ export type { Resolution, Source, TriedEntry } from "./tool-registry/types.js";
370
+
371
+ export type ListToolsResponse = ApiResponse<{ tools: Resolution[] }>;
372
+ export type GetToolResponse = ApiResponse<Resolution>;
373
+
374
+ export interface RescanToolsRequest {
375
+ name?: string;
376
+ }
377
+
378
+ export interface SetToolOverrideRequest {
379
+ path: string;
380
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Tiny FIFO semaphore for throttling concurrent async operations.
3
+ *
4
+ * Used by the server's openspec polling scheduler to cap how many
5
+ * `openspec` CLI spawns may be running at once. Rolled in-repo instead
6
+ * of pulling `p-limit` because we need `setMax()` for live reconfig
7
+ * (when the user edits `openspec.maxConcurrentSpawns` in settings).
8
+ *
9
+ * Contract:
10
+ * - `run(fn)` runs `fn` through the gate. At most `max` tasks are
11
+ * in-flight; excess tasks queue FIFO.
12
+ * - `setMax(n)` resizes. Growing drains the queue up to the new cap
13
+ * on the next microtask. Shrinking does not interrupt in-flight
14
+ * tasks; it only affects newly queued ones.
15
+ * - `size()` = active + queued.
16
+ * - If the task throws/rejects, the slot is released and queued
17
+ * tasks proceed.
18
+ */
19
+ export interface Semaphore {
20
+ run<T>(fn: () => Promise<T>): Promise<T>;
21
+ setMax(n: number): void;
22
+ size(): number;
23
+ }
24
+
25
+ export function createSemaphore(max: number): Semaphore {
26
+ if (!Number.isFinite(max) || max < 1) {
27
+ throw new Error(`Semaphore max must be a positive integer, got ${max}`);
28
+ }
29
+ let limit = Math.floor(max);
30
+ let active = 0;
31
+ const queue: Array<() => void> = [];
32
+
33
+ function drain() {
34
+ while (active < limit && queue.length > 0) {
35
+ const next = queue.shift()!;
36
+ active++;
37
+ next();
38
+ }
39
+ }
40
+
41
+ function release() {
42
+ active--;
43
+ // Schedule drain on microtask so `run()` callers see a stable state first.
44
+ queueMicrotask(drain);
45
+ }
46
+
47
+ return {
48
+ run<T>(fn: () => Promise<T>): Promise<T> {
49
+ return new Promise<T>((resolve, reject) => {
50
+ const start = () => {
51
+ let settled = false;
52
+ try {
53
+ Promise.resolve()
54
+ .then(fn)
55
+ .then(
56
+ (value) => { if (!settled) { settled = true; release(); resolve(value); } },
57
+ (err) => { if (!settled) { settled = true; release(); reject(err); } },
58
+ );
59
+ } catch (err) {
60
+ if (!settled) { settled = true; release(); reject(err); }
61
+ }
62
+ };
63
+ if (active < limit) {
64
+ active++;
65
+ start();
66
+ } else {
67
+ queue.push(start);
68
+ }
69
+ });
70
+ },
71
+ setMax(n: number): void {
72
+ if (!Number.isFinite(n) || n < 1) {
73
+ throw new Error(`Semaphore max must be a positive integer, got ${n}`);
74
+ }
75
+ limit = Math.floor(n);
76
+ // Drain synchronously so callers that do `setMax(n); await tick` see queued tasks started.
77
+ drain();
78
+ },
79
+ size(): number {
80
+ return active + queue.length;
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Registered tool definitions.
3
+ *
4
+ * Each definition declares an ordered strategy chain. Individual
5
+ * strategies are responsible for validating their own resolved paths
6
+ * (they use the injected `exists` from StrategyDeps), so tests can
7
+ * inject fakes without triggering real `fs.existsSync` lookups.
8
+ *
9
+ * See change: consolidate-tool-resolution.
10
+ */
11
+ import { existsSync } from "node:fs";
12
+ import { createRequire } from "node:module";
13
+ import path from "node:path";
14
+ import type { ToolDefinition, Source } from "./types.js";
15
+ import type { ToolRegistry } from "./registry.js";
16
+ import {
17
+ type StrategyDeps,
18
+ bareImportStrategy,
19
+ managedBinStrategy,
20
+ managedModuleStrategy,
21
+ npmGlobalStrategy,
22
+ overrideStrategy,
23
+ whereStrategy,
24
+ } from "./strategies.js";
25
+
26
+ // ── Classifier ──────────────────────────────────────────────────────────────
27
+
28
+ /** Classifier: strategies → Source. Shared across binary and module tools. */
29
+ function classify(strategyName: string): Source {
30
+ if (strategyName === "override") return "override";
31
+ if (strategyName === "managed") return "managed";
32
+ if (strategyName === "npm-global") return "npm-global";
33
+ if (strategyName === "bare-import") return "bare-import";
34
+ // `where` and anything else — resolved via PATH — classifies as system.
35
+ return "system";
36
+ }
37
+
38
+ // ── Binary definitions ──────────────────────────────────────────────────────
39
+
40
+ function binaryDef(binaryName: string, deps?: StrategyDeps): ToolDefinition {
41
+ return {
42
+ name: binaryName,
43
+ kind: "binary",
44
+ strategies: [
45
+ overrideStrategy(binaryName, deps),
46
+ managedBinStrategy(binaryName, deps),
47
+ whereStrategy(binaryName, deps),
48
+ ],
49
+ classify,
50
+ };
51
+ }
52
+
53
+ // ── Module definitions ──────────────────────────────────────────────────────
54
+
55
+ /** Sibling probe for an aliased package name (pi: `@mariozechner/*` + `@oh-my-pi/*`). */
56
+ function moduleDefWithAliases(
57
+ canonicalName: string,
58
+ pkgNames: readonly string[],
59
+ entry: string,
60
+ deps?: StrategyDeps,
61
+ ): ToolDefinition {
62
+ const strategies = [overrideStrategy(canonicalName, deps)];
63
+ for (const pkg of pkgNames) strategies.push(bareImportStrategy(pkg));
64
+ for (const pkg of pkgNames) strategies.push(managedModuleStrategy(pkg, entry, deps));
65
+ for (const pkg of pkgNames) strategies.push(npmGlobalStrategy(pkg, entry, deps));
66
+ return { name: canonicalName, kind: "module", strategies, classify };
67
+ }
68
+
69
+ // ── Registration ─────────────────────────────────────────────────
70
+
71
+ // Tools intentionally NOT registered:
72
+ // - `tsx` — a TypeScript *loader* used via `node --import tsx`,
73
+ // not a tool the dashboard spawns. When pi is installed, pi ships
74
+ // jiti which the server prefers; otherwise tsx is co-installed
75
+ // as a dev dep of the server package.
76
+ // - `pi-dashboard` — that's the package this code is part of.
77
+ // "Is it installed" is a bootstrap concern handled directly in
78
+ // `packages/electron/src/lib/dependency-detector.ts`.
79
+ // See change: consolidate-tool-resolution (follow-up).
80
+
81
+ /**
82
+ * Shared `toArgv` for Node-script executors (pi, openspec, npm).
83
+ *
84
+ * On Windows + `.js` resolved path → prepend node.exe to bypass the
85
+ * `.cmd` shim entirely (no cmd.exe in the spawn chain → no console
86
+ * flash). Elsewhere → direct invocation.
87
+ *
88
+ * This is the heart of the "no cmd flash" story: every CLI that ships
89
+ * as `.cmd` on Windows and is actually a Node script should be
90
+ * registered with this `toArgv` so the spawn becomes
91
+ * `node.exe <script.js>` (pure console-subsystem inherit, no new
92
+ * window ever).
93
+ */
94
+ const nodeScriptToArgv: ToolDefinition["toArgv"] = (resolvedPath, { platform, registry }) => {
95
+ if (platform === "win32" && /\.js$/i.test(resolvedPath)) {
96
+ const node = registry.resolve("node");
97
+ if (node.ok && node.path) return [node.path, resolvedPath];
98
+ }
99
+ return [resolvedPath];
100
+ };
101
+
102
+ /**
103
+ * Executor definition for `pi` — ONE tool, OS dispatch inside.
104
+ *
105
+ * On Windows, the strategy chain finds pi-coding-agent's `dist/cli.js`
106
+ * (managed → bare-import → npm-global), and `toArgv` wraps it with
107
+ * `node.exe` to produce `[node.exe, cli.js]`. Falls back to `pi.cmd`
108
+ * on PATH when the cli.js is nowhere to be found.
109
+ *
110
+ * On Unix, the chain finds `pi` on PATH; argv = [pi].
111
+ */
112
+ function piExecutorDef(deps?: StrategyDeps): ToolDefinition {
113
+ const piPkgAliases = ["@mariozechner/pi-coding-agent", "@oh-my-pi/pi-coding-agent"];
114
+ const cliEntry = path.join("dist", "cli.js");
115
+
116
+ const winStrategies = [
117
+ overrideStrategy("pi", deps),
118
+ ...piPkgAliases.map((pkg) => bareImportCliStrategy(pkg, cliEntry, deps)),
119
+ ...piPkgAliases.map((pkg) => managedModuleStrategy(pkg, cliEntry, deps)),
120
+ ...piPkgAliases.map((pkg) => npmGlobalStrategy(pkg, cliEntry, deps)),
121
+ managedBinStrategy("pi", deps),
122
+ whereStrategy("pi", deps),
123
+ ];
124
+
125
+ const unixStrategies = [
126
+ overrideStrategy("pi", deps),
127
+ managedBinStrategy("pi", deps),
128
+ whereStrategy("pi", deps),
129
+ ];
130
+
131
+ return {
132
+ name: "pi",
133
+ kind: "executor",
134
+ strategies: unixStrategies,
135
+ platformStrategies: { win32: winStrategies },
136
+ toArgv: nodeScriptToArgv,
137
+ classify,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Executor definition for `openspec`.
143
+ *
144
+ * On Windows: finds `@fission-ai/openspec/bin/openspec.js` via managed
145
+ * → bare-import → npm-global. `toArgv` wraps with node.exe.
146
+ * On Unix: finds `openspec` binary on PATH.
147
+ */
148
+ function openspecExecutorDef(deps?: StrategyDeps): ToolDefinition {
149
+ const pkgName = "@fission-ai/openspec";
150
+ const cliEntry = path.join("bin", "openspec.js");
151
+
152
+ const winStrategies = [
153
+ overrideStrategy("openspec", deps),
154
+ bareImportCliStrategy(pkgName, cliEntry),
155
+ managedModuleStrategy(pkgName, cliEntry, deps),
156
+ npmGlobalStrategy(pkgName, cliEntry, deps),
157
+ managedBinStrategy("openspec", deps),
158
+ whereStrategy("openspec", deps),
159
+ ];
160
+
161
+ const unixStrategies = [
162
+ overrideStrategy("openspec", deps),
163
+ managedBinStrategy("openspec", deps),
164
+ whereStrategy("openspec", deps),
165
+ ];
166
+
167
+ return {
168
+ name: "openspec",
169
+ kind: "executor",
170
+ strategies: unixStrategies,
171
+ platformStrategies: { win32: winStrategies },
172
+ toArgv: nodeScriptToArgv,
173
+ classify,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Executor definition for `npm`.
179
+ *
180
+ * npm is bundled with Node itself, not a standalone npm install. On
181
+ * Windows: find `<node-dir>/node_modules/npm/bin/npm-cli.js` by
182
+ * looking beside the resolved `node.exe`. Fallback: PATH lookup
183
+ * (which returns npm.cmd).
184
+ * On Unix: find `npm` on PATH.
185
+ *
186
+ * Motivation: npm.cmd internally runs `node.exe npm-cli.js`, and the
187
+ * inner node.exe can allocate a new console (Node issue #21825). By
188
+ * resolving to npm-cli.js directly + spawning via node.exe ourselves,
189
+ * we bypass cmd.exe + npm.cmd entirely.
190
+ */
191
+ function npmExecutorDef(deps?: StrategyDeps): ToolDefinition {
192
+ const npmRelativeToNode = path.join("node_modules", "npm", "bin", "npm-cli.js");
193
+
194
+ // Custom strategy: find npm-cli.js beside the resolved node.exe.
195
+ // We can't pre-compute the node path at definition time (the registry
196
+ // isn't fully constructed yet), so the strategy resolves node
197
+ // lazily at run time via the global registry hook.
198
+ const npmCliBesideNodeStrategy = {
199
+ name: "managed", // classified as managed because it ships with node
200
+ run(): { ok: true; path: string } | { ok: false; reason: string } {
201
+ // Find node.exe from process.execPath or environment.
202
+ const nodeExe = process.execPath;
203
+ if (!nodeExe) return { ok: false, reason: "process.execPath unset" };
204
+ const nodeDir = path.dirname(nodeExe);
205
+ const candidate = path.join(nodeDir, npmRelativeToNode);
206
+ try {
207
+ if (existsSync(candidate)) return { ok: true, path: candidate };
208
+ return { ok: false, reason: `missing: ${candidate}` };
209
+ } catch (err) {
210
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
211
+ }
212
+ },
213
+ };
214
+
215
+ const winStrategies = [
216
+ overrideStrategy("npm", deps),
217
+ npmCliBesideNodeStrategy,
218
+ whereStrategy("npm", deps),
219
+ ];
220
+
221
+ const unixStrategies = [
222
+ overrideStrategy("npm", deps),
223
+ whereStrategy("npm", deps),
224
+ ];
225
+
226
+ return {
227
+ name: "npm",
228
+ kind: "executor",
229
+ strategies: unixStrategies,
230
+ platformStrategies: { win32: winStrategies },
231
+ toArgv: nodeScriptToArgv,
232
+ classify,
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Helper: bare-import strategy that, on success, transforms the
238
+ * resolved `package.json` into the sibling `<entry>` path. Used by
239
+ * the pi executor to find pi-coding-agent's cli.js via the same
240
+ * module-resolution algorithm as `import()`.
241
+ */
242
+ function bareImportCliStrategy(
243
+ pkgName: string,
244
+ entryRelative: string,
245
+ deps?: StrategyDeps,
246
+ ) {
247
+ // Default uses the real module resolver anchored to this file;
248
+ // tests inject a fake via deps.resolveModule.
249
+ const resolveModule: NonNullable<StrategyDeps["resolveModule"]> =
250
+ deps?.resolveModule
251
+ ?? ((id, from) => {
252
+ try {
253
+ return createRequire(from).resolve(id);
254
+ } catch {
255
+ return null;
256
+ }
257
+ });
258
+ return {
259
+ name: "bare-import",
260
+ run(): { ok: true; path: string } | { ok: false; reason: string } {
261
+ const pkgJson = resolveModule(`${pkgName}/package.json`, import.meta.url);
262
+ if (!pkgJson) {
263
+ return { ok: false, reason: `cannot resolve ${pkgName}/package.json` };
264
+ }
265
+ const entry = path.join(path.dirname(pkgJson), entryRelative);
266
+ return { ok: true, path: entry };
267
+ },
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Register the standard set of dashboard tools. Idempotent — callers
273
+ * may re-register to supply custom strategy deps (e.g. tests).
274
+ */
275
+ export function registerDefaultTools(registry: ToolRegistry, deps?: StrategyDeps): void {
276
+ // Executor-kind tools — Node scripts shipped as .cmd shims on
277
+ // Windows. Each registers as [node.exe, <script>.js] to bypass
278
+ // cmd.exe and the console-flash chain (Node issue #21825).
279
+ registry.register(piExecutorDef(deps));
280
+ registry.register(openspecExecutorDef(deps));
281
+ registry.register(npmExecutorDef(deps));
282
+
283
+ // Native binaries — no interpreter needed.
284
+ registry.register(binaryDef("node", deps));
285
+ registry.register(binaryDef("git", deps));
286
+ registry.register(binaryDef("zrok", deps));
287
+
288
+ // Platform-conditional process-inspection utilities. These are only
289
+ // called by `packages/shared/src/platform/process.ts` on their native
290
+ // platform — registering the non-native tools would surface as red
291
+ // "not found" rows in the Settings → Tools UI even though the code
292
+ // never calls them there.
293
+ //
294
+ // Honours the registry's `platform` so tests that inject `platform:
295
+ // "win32"` from a Linux host still exercise the Windows tool set.
296
+ //
297
+ // Windows system utilities used by the bridge's process scanner.
298
+ // Registered so callers resolve to full `.exe` paths (e.g.
299
+ // `C:\Windows\System32\wbem\wmic.exe`) and spawn directly — no
300
+ // PATHEXT resolution, no cmd.exe wrapping, windowsHide:true honored
301
+ // all the way down. See change: consolidate-windows-spawn-and-platform-handlers.
302
+ if (registry.getPlatform() === "win32") {
303
+ registry.register(binaryDef("wmic", deps));
304
+ registry.register(binaryDef("powershell", deps));
305
+ registry.register(binaryDef("tasklist", deps));
306
+ registry.register(binaryDef("taskkill", deps));
307
+ } else {
308
+ // POSIX process-inspection utilities. Used by `isProcessRunning`,
309
+ // `findPidByMarker`, `isProcessLikePi` in platform/process.ts.
310
+ registry.register(binaryDef("ps", deps));
311
+ registry.register(binaryDef("pgrep", deps));
312
+ }
313
+ // Windows Terminal — optional, override + where only (not part of
314
+ // managed install, not on Unix).
315
+ registry.register({
316
+ name: "wt",
317
+ kind: "binary",
318
+ strategies: [
319
+ overrideStrategy("wt", deps),
320
+ whereStrategy("wt", deps),
321
+ ],
322
+ classify,
323
+ });
324
+
325
+ // Node module entry for pi-coding-agent — used by DefaultPackageManager
326
+ // to IMPORT pi as a library (not spawn it as a process). Distinct from
327
+ // the `pi` executor above.
328
+ registry.register(
329
+ moduleDefWithAliases(
330
+ "pi-coding-agent",
331
+ ["@mariozechner/pi-coding-agent", "@oh-my-pi/pi-coding-agent"],
332
+ path.join("dist", "index.js"),
333
+ deps,
334
+ ),
335
+ );
336
+ }
337
+
338
+ /** Handy re-exports for callers that want raw definitions for testing. */
339
+ export const _internals = {
340
+ binaryDef,
341
+ moduleDefWithAliases,
342
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Tool registry — single-source resolver for every external binary/module
3
+ * the dashboard depends on. See change: consolidate-tool-resolution.
4
+ *
5
+ * Quick start:
6
+ *
7
+ * import { getDefaultRegistry } from "@blackbelt-technology/pi-dashboard-shared/tool-registry";
8
+ * const r = getDefaultRegistry().resolve("pi");
9
+ * if (r.ok) spawn(r.path!, args);
10
+ */
11
+ export * from "./types.js";
12
+ export { OverridesStore, defaultOverridesPath } from "./overrides.js";
13
+ export { ToolRegistry } from "./registry.js";
14
+ export { registerDefaultTools } from "./definitions.js";
15
+ export * from "./strategies.js";
16
+
17
+ import { ToolRegistry } from "./registry.js";
18
+ import { registerDefaultTools } from "./definitions.js";
19
+
20
+ /**
21
+ * Lazily-constructed process-wide registry. Most callers should use this
22
+ * instead of constructing their own. Tests should pass a fresh
23
+ * `new ToolRegistry({...})` with injected deps.
24
+ *
25
+ * The registry is also published on `globalThis` under a symbol so that
26
+ * `platform/runner.ts` can pick it up synchronously without a module
27
+ * import (which would create a load-order cycle through `platform/npm.ts`).
28
+ */
29
+ const GLOBAL_KEY = Symbol.for("pi-dashboard.tool-registry");
30
+ type GlobalSlot = { [GLOBAL_KEY]?: ToolRegistry };
31
+
32
+ let defaultRegistry: ToolRegistry | null = null;
33
+ export function getDefaultRegistry(): ToolRegistry {
34
+ if (!defaultRegistry) {
35
+ defaultRegistry = new ToolRegistry();
36
+ registerDefaultTools(defaultRegistry);
37
+ (globalThis as unknown as GlobalSlot)[GLOBAL_KEY] = defaultRegistry;
38
+ }
39
+ return defaultRegistry;
40
+ }
41
+
42
+ /**
43
+ * Global accessor for consumers that cannot import this module at the
44
+ * top level (i.e. `platform/runner.ts`, which is part of a load-order
45
+ * cycle). Returns `null` if `getDefaultRegistry()` hasn't been called
46
+ * yet anywhere in the process.
47
+ */
48
+ export function peekGlobalRegistry(): ToolRegistry | null {
49
+ return (globalThis as unknown as GlobalSlot)[GLOBAL_KEY] ?? null;
50
+ }
51
+
52
+ /** Test-only: drop the process-wide registry so the next call rebuilds. */
53
+ export function _resetDefaultRegistry(): void {
54
+ defaultRegistry = null;
55
+ (globalThis as unknown as GlobalSlot)[GLOBAL_KEY] = undefined;
56
+ }