@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,434 @@
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
+ import type { Strategy } from "./types.js";
26
+
27
+ // ── Classifier ──────────────────────────────────────────────────────────────
28
+
29
+ /** Classifier: strategies → Source. Shared across binary and module tools. */
30
+ function classify(strategyName: string): Source {
31
+ if (strategyName === "override") return "override";
32
+ if (strategyName === "managed") return "managed";
33
+ if (strategyName === "npm-global") return "npm-global";
34
+ if (strategyName === "bare-import") return "bare-import";
35
+ // `where` and anything else — resolved via PATH — classifies as system.
36
+ return "system";
37
+ }
38
+
39
+ // ── Binary definitions ──────────────────────────────────────────────────────
40
+
41
+ function binaryDef(binaryName: string, deps?: StrategyDeps): ToolDefinition {
42
+ return {
43
+ name: binaryName,
44
+ kind: "binary",
45
+ strategies: [
46
+ overrideStrategy(binaryName, deps),
47
+ managedBinStrategy(binaryName, deps),
48
+ whereStrategy(binaryName, deps),
49
+ ],
50
+ classify,
51
+ };
52
+ }
53
+
54
+ // ── Module definitions ──────────────────────────────────────────────────────
55
+
56
+ /** Sibling probe for an aliased package name (pi: `@mariozechner/*` + `@oh-my-pi/*`). */
57
+ function moduleDefWithAliases(
58
+ canonicalName: string,
59
+ pkgNames: readonly string[],
60
+ entry: string,
61
+ deps?: StrategyDeps,
62
+ ): ToolDefinition {
63
+ const strategies = [overrideStrategy(canonicalName, deps)];
64
+ for (const pkg of pkgNames) strategies.push(bareImportStrategy(pkg));
65
+ for (const pkg of pkgNames) strategies.push(managedModuleStrategy(pkg, entry, deps));
66
+ for (const pkg of pkgNames) strategies.push(npmGlobalStrategy(pkg, entry, deps));
67
+ return { name: canonicalName, kind: "module", strategies, classify };
68
+ }
69
+
70
+ // ── Build-time module definitions (electron, node-pty) ────────────────────
71
+
72
+ /**
73
+ * Bare-import strategy that resolves `<pkg>/package.json` and returns the
74
+ * containing directory. Used for build-time tools whose useful artifact is
75
+ * a sibling file of `package.json` (e.g. `electron/install.js`,
76
+ * `node-pty/prebuilds/`). Mirrors the semantics that build-time consumers
77
+ * (`publish.yml`, `Dockerfile.build`, `scripts/fix-pty-permissions.cjs`)
78
+ * need — see change: register-build-time-tools.
79
+ *
80
+ * `searchPaths` are passed to Node's resolver as the `paths` option,
81
+ * making the lookup work whether the package is hoisted to the repo root
82
+ * or nested under a workspace.
83
+ */
84
+ function bareImportPackageDirStrategy(
85
+ pkgName: string,
86
+ searchPaths?: readonly string[],
87
+ deps?: StrategyDeps,
88
+ ): Strategy {
89
+ const fallbackResolve = (id: string, from: string): string | null => {
90
+ try {
91
+ if (searchPaths && searchPaths.length > 0) {
92
+ const req = createRequire(from) as unknown as {
93
+ resolve(id: string, opts?: { paths?: readonly string[] }): string;
94
+ };
95
+ return req.resolve(id, { paths: searchPaths });
96
+ }
97
+ return createRequire(from).resolve(id);
98
+ } catch {
99
+ return null;
100
+ }
101
+ };
102
+ const resolveModule = deps?.resolveModule ?? fallbackResolve;
103
+ return {
104
+ name: "bare-import",
105
+ run() {
106
+ const pkgJson = resolveModule(`${pkgName}/package.json`, import.meta.url);
107
+ if (!pkgJson) {
108
+ return { ok: false, reason: `cannot resolve ${pkgName}/package.json` };
109
+ }
110
+ return { ok: true, path: path.dirname(pkgJson) };
111
+ },
112
+ };
113
+ }
114
+
115
+ /** Module def that returns the package directory (containing package.json). */
116
+ function packageDirModuleDef(
117
+ toolName: string,
118
+ pkgName: string,
119
+ options: { searchPaths?: readonly string[]; includeManaged?: boolean },
120
+ deps?: StrategyDeps,
121
+ ): ToolDefinition {
122
+ const strategies: Strategy[] = [
123
+ overrideStrategy(toolName, deps),
124
+ bareImportPackageDirStrategy(pkgName, options.searchPaths, deps),
125
+ ];
126
+ if (options.includeManaged) {
127
+ strategies.push(managedModuleStrategy(pkgName, "package.json", deps));
128
+ }
129
+ return { name: toolName, kind: "module", strategies, classify };
130
+ }
131
+
132
+ // ── Registration ─────────────────────────────────────────────────
133
+
134
+ // Tools intentionally NOT registered:
135
+ // - `tsx` — a TypeScript *loader* used via `node --import tsx`,
136
+ // not a tool the dashboard spawns. When pi is installed, pi ships
137
+ // jiti which the server prefers; otherwise tsx is co-installed
138
+ // as a dev dep of the server package.
139
+ // - `pi-dashboard` — that's the package this code is part of.
140
+ // "Is it installed" is a bootstrap concern handled directly in
141
+ // `packages/electron/src/lib/dependency-detector.ts`.
142
+ //
143
+ // Build-time tools (see change: register-build-time-tools):
144
+ // - `electron` — module, returns the package directory containing
145
+ // `install.js`. Resolved with paths anchored at
146
+ // `packages/electron` to handle hoisted vs. nested
147
+ // layouts uniformly.
148
+ // - `node-pty` — module, returns the package directory containing
149
+ // `prebuilds/`. Standard module resolution suffices.
150
+ // See change: consolidate-tool-resolution (follow-up).
151
+
152
+ /**
153
+ * Shared `toArgv` for Node-script executors (pi, openspec, npm).
154
+ *
155
+ * On Windows + `.js` resolved path → prepend node.exe to bypass the
156
+ * `.cmd` shim entirely (no cmd.exe in the spawn chain → no console
157
+ * flash). Elsewhere → direct invocation.
158
+ *
159
+ * This is the heart of the "no cmd flash" story: every CLI that ships
160
+ * as `.cmd` on Windows and is actually a Node script should be
161
+ * registered with this `toArgv` so the spawn becomes
162
+ * `node.exe <script.js>` (pure console-subsystem inherit, no new
163
+ * window ever).
164
+ */
165
+ const nodeScriptToArgv: ToolDefinition["toArgv"] = (resolvedPath, { platform, registry }) => {
166
+ if (platform === "win32" && /\.js$/i.test(resolvedPath)) {
167
+ const node = registry.resolve("node");
168
+ if (node.ok && node.path) return [node.path, resolvedPath];
169
+ }
170
+ return [resolvedPath];
171
+ };
172
+
173
+ /**
174
+ * Executor definition for `pi` — ONE tool, OS dispatch inside.
175
+ *
176
+ * On Windows, the strategy chain finds pi-coding-agent's `dist/cli.js`
177
+ * (managed → bare-import → npm-global), and `toArgv` wraps it with
178
+ * `node.exe` to produce `[node.exe, cli.js]`. Falls back to `pi.cmd`
179
+ * on PATH when the cli.js is nowhere to be found.
180
+ *
181
+ * On Unix, the chain finds `pi` on PATH; argv = [pi].
182
+ */
183
+ function piExecutorDef(deps?: StrategyDeps): ToolDefinition {
184
+ const piPkgAliases = ["@mariozechner/pi-coding-agent", "@oh-my-pi/pi-coding-agent"];
185
+ const cliEntry = path.join("dist", "cli.js");
186
+
187
+ const winStrategies = [
188
+ overrideStrategy("pi", deps),
189
+ ...piPkgAliases.map((pkg) => bareImportCliStrategy(pkg, cliEntry, deps)),
190
+ ...piPkgAliases.map((pkg) => managedModuleStrategy(pkg, cliEntry, deps)),
191
+ ...piPkgAliases.map((pkg) => npmGlobalStrategy(pkg, cliEntry, deps)),
192
+ managedBinStrategy("pi", deps),
193
+ whereStrategy("pi", deps),
194
+ ];
195
+
196
+ const unixStrategies = [
197
+ overrideStrategy("pi", deps),
198
+ managedBinStrategy("pi", deps),
199
+ whereStrategy("pi", deps),
200
+ ];
201
+
202
+ return {
203
+ name: "pi",
204
+ kind: "executor",
205
+ strategies: unixStrategies,
206
+ platformStrategies: { win32: winStrategies },
207
+ toArgv: nodeScriptToArgv,
208
+ classify,
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Executor definition for `openspec`.
214
+ *
215
+ * On Windows: finds `@fission-ai/openspec/bin/openspec.js` via managed
216
+ * → bare-import → npm-global. `toArgv` wraps with node.exe.
217
+ * On Unix: finds `openspec` binary on PATH.
218
+ */
219
+ function openspecExecutorDef(deps?: StrategyDeps): ToolDefinition {
220
+ const pkgName = "@fission-ai/openspec";
221
+ const cliEntry = path.join("bin", "openspec.js");
222
+
223
+ const winStrategies = [
224
+ overrideStrategy("openspec", deps),
225
+ bareImportCliStrategy(pkgName, cliEntry),
226
+ managedModuleStrategy(pkgName, cliEntry, deps),
227
+ npmGlobalStrategy(pkgName, cliEntry, deps),
228
+ managedBinStrategy("openspec", deps),
229
+ whereStrategy("openspec", deps),
230
+ ];
231
+
232
+ const unixStrategies = [
233
+ overrideStrategy("openspec", deps),
234
+ managedBinStrategy("openspec", deps),
235
+ whereStrategy("openspec", deps),
236
+ ];
237
+
238
+ return {
239
+ name: "openspec",
240
+ kind: "executor",
241
+ strategies: unixStrategies,
242
+ platformStrategies: { win32: winStrategies },
243
+ toArgv: nodeScriptToArgv,
244
+ classify,
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Executor definition for `npm`.
250
+ *
251
+ * npm is bundled with Node itself, not a standalone npm install. On
252
+ * Windows: find `<node-dir>/node_modules/npm/bin/npm-cli.js` by
253
+ * looking beside the resolved `node.exe`. Fallback: PATH lookup
254
+ * (which returns npm.cmd).
255
+ * On Unix: find `npm` on PATH.
256
+ *
257
+ * Motivation: npm.cmd internally runs `node.exe npm-cli.js`, and the
258
+ * inner node.exe can allocate a new console (Node issue #21825). By
259
+ * resolving to npm-cli.js directly + spawning via node.exe ourselves,
260
+ * we bypass cmd.exe + npm.cmd entirely.
261
+ */
262
+ function npmExecutorDef(deps?: StrategyDeps): ToolDefinition {
263
+ const npmRelativeToNode = path.join("node_modules", "npm", "bin", "npm-cli.js");
264
+
265
+ // Custom strategy: find npm-cli.js beside the resolved node.exe.
266
+ // We can't pre-compute the node path at definition time (the registry
267
+ // isn't fully constructed yet), so the strategy resolves node
268
+ // lazily at run time via the global registry hook.
269
+ const npmCliBesideNodeStrategy = {
270
+ name: "managed", // classified as managed because it ships with node
271
+ run(): { ok: true; path: string } | { ok: false; reason: string } {
272
+ // Find node.exe from process.execPath or environment.
273
+ const nodeExe = process.execPath;
274
+ if (!nodeExe) return { ok: false, reason: "process.execPath unset" };
275
+ const nodeDir = path.dirname(nodeExe);
276
+ const candidate = path.join(nodeDir, npmRelativeToNode);
277
+ try {
278
+ if (existsSync(candidate)) return { ok: true, path: candidate };
279
+ return { ok: false, reason: `missing: ${candidate}` };
280
+ } catch (err) {
281
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
282
+ }
283
+ },
284
+ };
285
+
286
+ const winStrategies = [
287
+ overrideStrategy("npm", deps),
288
+ npmCliBesideNodeStrategy,
289
+ whereStrategy("npm", deps),
290
+ ];
291
+
292
+ const unixStrategies = [
293
+ overrideStrategy("npm", deps),
294
+ whereStrategy("npm", deps),
295
+ ];
296
+
297
+ return {
298
+ name: "npm",
299
+ kind: "executor",
300
+ strategies: unixStrategies,
301
+ platformStrategies: { win32: winStrategies },
302
+ toArgv: nodeScriptToArgv,
303
+ classify,
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Helper: bare-import strategy that, on success, transforms the
309
+ * resolved `package.json` into the sibling `<entry>` path. Used by
310
+ * the pi executor to find pi-coding-agent's cli.js via the same
311
+ * module-resolution algorithm as `import()`.
312
+ */
313
+ function bareImportCliStrategy(
314
+ pkgName: string,
315
+ entryRelative: string,
316
+ deps?: StrategyDeps,
317
+ ) {
318
+ // Default uses the real module resolver anchored to this file;
319
+ // tests inject a fake via deps.resolveModule.
320
+ const resolveModule: NonNullable<StrategyDeps["resolveModule"]> =
321
+ deps?.resolveModule
322
+ ?? ((id, from) => {
323
+ try {
324
+ return createRequire(from).resolve(id);
325
+ } catch {
326
+ return null;
327
+ }
328
+ });
329
+ return {
330
+ name: "bare-import",
331
+ run(): { ok: true; path: string } | { ok: false; reason: string } {
332
+ const pkgJson = resolveModule(`${pkgName}/package.json`, import.meta.url);
333
+ if (!pkgJson) {
334
+ return { ok: false, reason: `cannot resolve ${pkgName}/package.json` };
335
+ }
336
+ const entry = path.join(path.dirname(pkgJson), entryRelative);
337
+ return { ok: true, path: entry };
338
+ },
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Register the standard set of dashboard tools. Idempotent — callers
344
+ * may re-register to supply custom strategy deps (e.g. tests).
345
+ */
346
+ export function registerDefaultTools(registry: ToolRegistry, deps?: StrategyDeps): void {
347
+ // Executor-kind tools — Node scripts shipped as .cmd shims on
348
+ // Windows. Each registers as [node.exe, <script>.js] to bypass
349
+ // cmd.exe and the console-flash chain (Node issue #21825).
350
+ registry.register(piExecutorDef(deps));
351
+ registry.register(openspecExecutorDef(deps));
352
+ registry.register(npmExecutorDef(deps));
353
+
354
+ // Native binaries — no interpreter needed.
355
+ registry.register(binaryDef("node", deps));
356
+ registry.register(binaryDef("git", deps));
357
+ registry.register(binaryDef("zrok", deps));
358
+
359
+ // Platform-conditional process-inspection utilities. These are only
360
+ // called by `packages/shared/src/platform/process.ts` on their native
361
+ // platform — registering the non-native tools would surface as red
362
+ // "not found" rows in the Settings → Tools UI even though the code
363
+ // never calls them there.
364
+ //
365
+ // Honours the registry's `platform` so tests that inject `platform:
366
+ // "win32"` from a Linux host still exercise the Windows tool set.
367
+ //
368
+ // Windows system utilities used by the bridge's process scanner.
369
+ // Registered so callers resolve to full `.exe` paths (e.g.
370
+ // `C:\Windows\System32\wbem\wmic.exe`) and spawn directly — no
371
+ // PATHEXT resolution, no cmd.exe wrapping, windowsHide:true honored
372
+ // all the way down. See change: consolidate-windows-spawn-and-platform-handlers.
373
+ if (registry.getPlatform() === "win32") {
374
+ registry.register(binaryDef("wmic", deps));
375
+ registry.register(binaryDef("powershell", deps));
376
+ registry.register(binaryDef("tasklist", deps));
377
+ registry.register(binaryDef("taskkill", deps));
378
+ } else {
379
+ // POSIX process-inspection utilities. Used by `isProcessRunning`,
380
+ // `findPidByMarker`, `isProcessLikePi` in platform/process.ts.
381
+ registry.register(binaryDef("ps", deps));
382
+ registry.register(binaryDef("pgrep", deps));
383
+ }
384
+ // Windows Terminal — optional, override + where only (not part of
385
+ // managed install, not on Unix).
386
+ registry.register({
387
+ name: "wt",
388
+ kind: "binary",
389
+ strategies: [
390
+ overrideStrategy("wt", deps),
391
+ whereStrategy("wt", deps),
392
+ ],
393
+ classify,
394
+ });
395
+
396
+ // Node module entry for pi-coding-agent — used by DefaultPackageManager
397
+ // to IMPORT pi as a library (not spawn it as a process). Distinct from
398
+ // the `pi` executor above.
399
+ registry.register(
400
+ moduleDefWithAliases(
401
+ "pi-coding-agent",
402
+ ["@mariozechner/pi-coding-agent", "@oh-my-pi/pi-coding-agent"],
403
+ path.join("dist", "index.js"),
404
+ deps,
405
+ ),
406
+ );
407
+
408
+ // Build-time tools (see change: register-build-time-tools).
409
+ registry.register(
410
+ packageDirModuleDef(
411
+ "electron",
412
+ "electron",
413
+ {
414
+ searchPaths: [path.resolve("packages/electron")],
415
+ includeManaged: true,
416
+ },
417
+ deps,
418
+ ),
419
+ );
420
+ registry.register(
421
+ packageDirModuleDef(
422
+ "node-pty",
423
+ "node-pty",
424
+ { includeManaged: false },
425
+ deps,
426
+ ),
427
+ );
428
+ }
429
+
430
+ /** Handy re-exports for callers that want raw definitions for testing. */
431
+ export const _internals = {
432
+ binaryDef,
433
+ moduleDefWithAliases,
434
+ };
@@ -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
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Persistent per-tool path overrides at `~/.pi/dashboard/tool-overrides.json`.
3
+ *
4
+ * Schema:
5
+ * { "version": 1, "overrides": { "<toolName>": { "path": "<abs>" } } }
6
+ *
7
+ * Design notes (see change: consolidate-tool-resolution, design §5):
8
+ * - Separate from `config.json` — path overrides are machine-local and
9
+ * should NOT follow a user's dotfiles across machines.
10
+ * - Atomic write via the same tmp+rename pattern used by
11
+ * `server/src/json-store.ts` (duplicated here to keep `shared`
12
+ * self-contained; the two live in different packages).
13
+ * - Malformed files are treated as empty. No throw, no crash.
14
+ */
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import os from "node:os";
18
+
19
+ /** Path to the overrides file. Exposed for tests and the settings UI. */
20
+ export function defaultOverridesPath(): string {
21
+ return path.join(os.homedir(), ".pi", "dashboard", "tool-overrides.json");
22
+ }
23
+
24
+ /** Internal shape persisted to disk. `version` lets us evolve later. */
25
+ interface OverridesFile {
26
+ version: 1;
27
+ overrides: Record<string, { path: string }>;
28
+ }
29
+
30
+ export interface OverridesStoreDeps {
31
+ filePath?: string;
32
+ /** Logger hook (defaults to console.warn). Tests inject a sink. */
33
+ warn?(message: string): void;
34
+ }
35
+
36
+ /**
37
+ * Read-through + write-through in-memory store. One instance per registry.
38
+ * Keeps the disk read lazy — the file is only touched on first access.
39
+ */
40
+ export class OverridesStore {
41
+ private readonly filePath: string;
42
+ private readonly warn: (message: string) => void;
43
+ private cache: Record<string, string> | null = null;
44
+
45
+ constructor(deps: OverridesStoreDeps = {}) {
46
+ this.filePath = deps.filePath ?? defaultOverridesPath();
47
+ this.warn = deps.warn ?? ((m) => console.warn(`[tool-registry] ${m}`));
48
+ }
49
+
50
+ /** Snapshot of current overrides. Lazy-loads from disk on first call. */
51
+ list(): Readonly<Record<string, string>> {
52
+ if (this.cache === null) this.cache = this.load();
53
+ return this.cache;
54
+ }
55
+
56
+ /** Set one override + persist. */
57
+ set(name: string, overridePath: string): void {
58
+ const current = this.cache ?? this.load();
59
+ current[name] = overridePath;
60
+ this.cache = current;
61
+ this.persist(current);
62
+ }
63
+
64
+ /** Remove one override + persist. No-op if absent. */
65
+ clear(name: string): void {
66
+ const current = this.cache ?? this.load();
67
+ if (!(name in current)) return;
68
+ delete current[name];
69
+ this.cache = current;
70
+ this.persist(current);
71
+ }
72
+
73
+ /** Drop the in-memory cache; next `list()` re-reads the file. */
74
+ invalidate(): void {
75
+ this.cache = null;
76
+ }
77
+
78
+ // ── Internal ─────────────────────────────────────────────────────────
79
+
80
+ private load(): Record<string, string> {
81
+ try {
82
+ if (!fs.existsSync(this.filePath)) return {};
83
+ const raw = fs.readFileSync(this.filePath, "utf-8");
84
+ if (!raw.trim()) return {};
85
+ const parsed = JSON.parse(raw) as Partial<OverridesFile>;
86
+ if (!parsed || typeof parsed !== "object" || !parsed.overrides) {
87
+ this.warn(`malformed overrides file at ${this.filePath}; ignoring`);
88
+ return {};
89
+ }
90
+ const out: Record<string, string> = {};
91
+ for (const [name, entry] of Object.entries(parsed.overrides)) {
92
+ if (entry && typeof entry === "object" && typeof (entry as { path?: unknown }).path === "string") {
93
+ out[name] = (entry as { path: string }).path;
94
+ }
95
+ }
96
+ return out;
97
+ } catch (err) {
98
+ this.warn(
99
+ `failed to read overrides file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`,
100
+ );
101
+ return {};
102
+ }
103
+ }
104
+
105
+ private persist(overrides: Record<string, string>): void {
106
+ const dir = path.dirname(this.filePath);
107
+ fs.mkdirSync(dir, { recursive: true });
108
+ const data: OverridesFile = {
109
+ version: 1,
110
+ overrides: Object.fromEntries(
111
+ Object.entries(overrides).map(([k, v]) => [k, { path: v }]),
112
+ ),
113
+ };
114
+ const tmpPath = this.filePath + ".tmp";
115
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n");
116
+ fs.renameSync(tmpPath, this.filePath);
117
+ }
118
+ }