@blackbelt-technology/pi-agent-dashboard 0.2.9 → 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 (238) hide show
  1. package/AGENTS.md +64 -8
  2. package/README.md +308 -101
  3. package/docs/architecture.md +515 -16
  4. package/package.json +14 -7
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
  7. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  8. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
  9. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  10. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  11. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  12. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  13. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  14. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  15. package/packages/extension/src/ask-user-tool.ts +289 -20
  16. package/packages/extension/src/bridge.ts +107 -6
  17. package/packages/extension/src/command-handler.ts +34 -39
  18. package/packages/extension/src/dev-build.ts +1 -1
  19. package/packages/extension/src/git-info.ts +9 -19
  20. package/packages/extension/src/pi-env.d.ts +1 -0
  21. package/packages/extension/src/process-scanner.ts +72 -38
  22. package/packages/extension/src/prompt-expander.ts +25 -4
  23. package/packages/extension/src/provider-register.ts +304 -16
  24. package/packages/extension/src/server-auto-start.ts +27 -1
  25. package/packages/extension/src/server-launcher.ts +71 -27
  26. package/packages/server/package.json +17 -2
  27. package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
  28. package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
  29. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  30. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  31. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  32. package/packages/server/src/__tests__/browse-endpoint.test.ts +246 -10
  33. package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
  34. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  35. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  36. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  37. package/packages/server/src/__tests__/cors.test.ts +34 -2
  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-manager-pid-registry.test.ts +168 -0
  41. package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
  42. package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
  43. package/packages/server/src/__tests__/editor-registry.test.ts +29 -15
  44. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  45. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  46. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  47. package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
  48. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  49. package/packages/server/src/__tests__/git-operations.test.ts +9 -7
  50. package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
  51. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  52. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  53. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  54. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  55. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  56. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
  57. package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
  58. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +126 -0
  59. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  60. package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
  61. package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
  62. package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
  63. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  64. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  65. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  66. package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
  67. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  68. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  69. package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
  70. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  71. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  72. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  74. package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
  75. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
  76. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
  77. package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
  78. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  79. package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
  80. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  81. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  82. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  83. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  84. package/packages/server/src/__tests__/tunnel.test.ts +103 -6
  85. package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
  86. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  87. package/packages/server/src/bootstrap-queue.ts +130 -0
  88. package/packages/server/src/bootstrap-state.ts +131 -0
  89. package/packages/server/src/browse.ts +108 -9
  90. package/packages/server/src/browser-gateway.ts +16 -3
  91. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  92. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  93. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  94. package/packages/server/src/cli.ts +256 -32
  95. package/packages/server/src/config-api.ts +16 -0
  96. package/packages/server/src/directory-service.ts +270 -39
  97. package/packages/server/src/editor-detection.ts +12 -9
  98. package/packages/server/src/editor-manager.ts +39 -5
  99. package/packages/server/src/editor-pid-registry.ts +199 -0
  100. package/packages/server/src/editor-registry.ts +22 -25
  101. package/packages/server/src/fix-pty-permissions.ts +44 -0
  102. package/packages/server/src/git-operations.ts +1 -1
  103. package/packages/server/src/headless-pid-registry.ts +16 -20
  104. package/packages/server/src/home-lock-release.ts +72 -0
  105. package/packages/server/src/home-lock.ts +389 -0
  106. package/packages/server/src/node-guard.ts +52 -0
  107. package/packages/server/src/npm-search-proxy.ts +71 -0
  108. package/packages/server/src/openspec-tasks.ts +158 -0
  109. package/packages/server/src/package-manager-wrapper.ts +225 -34
  110. package/packages/server/src/pi-core-checker.ts +290 -0
  111. package/packages/server/src/pi-core-updater.ts +172 -0
  112. package/packages/server/src/pi-gateway.ts +7 -0
  113. package/packages/server/src/pi-resource-scanner.ts +5 -8
  114. package/packages/server/src/pi-version-skew.ts +196 -0
  115. package/packages/server/src/preferences-store.ts +17 -3
  116. package/packages/server/src/process-manager.ts +403 -222
  117. package/packages/server/src/provider-probe.ts +234 -0
  118. package/packages/server/src/restart-helper.ts +130 -0
  119. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  120. package/packages/server/src/routes/file-routes.ts +30 -3
  121. package/packages/server/src/routes/openspec-routes.ts +107 -1
  122. package/packages/server/src/routes/pi-core-routes.ts +140 -0
  123. package/packages/server/src/routes/provider-auth-routes.ts +12 -10
  124. package/packages/server/src/routes/provider-routes.ts +55 -2
  125. package/packages/server/src/routes/recommended-routes.ts +225 -0
  126. package/packages/server/src/routes/system-routes.ts +30 -34
  127. package/packages/server/src/routes/tool-routes.ts +153 -0
  128. package/packages/server/src/server-pid.ts +5 -9
  129. package/packages/server/src/server.ts +363 -26
  130. package/packages/server/src/session-api.ts +77 -8
  131. package/packages/server/src/session-bootstrap.ts +17 -3
  132. package/packages/server/src/session-diff.ts +21 -21
  133. package/packages/server/src/terminal-manager.ts +65 -20
  134. package/packages/server/src/test-env-guard.ts +26 -0
  135. package/packages/server/src/test-support/test-server.ts +63 -0
  136. package/packages/server/src/tunnel.ts +172 -34
  137. package/packages/shared/package.json +10 -3
  138. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  139. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  140. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  141. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  142. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  143. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  144. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  145. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  146. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  147. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  148. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  149. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  150. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  151. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  152. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  153. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  154. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  155. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  156. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  157. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  158. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  159. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  160. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  161. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  162. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  163. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  164. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  165. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  166. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  167. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  168. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  169. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  170. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  172. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  173. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  174. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  175. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  176. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  177. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  178. package/packages/shared/src/__tests__/config.test.ts +59 -3
  179. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  180. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  181. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  182. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  183. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  184. package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
  185. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  186. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  187. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  188. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  189. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  190. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  191. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  192. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  193. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  194. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  195. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  196. package/packages/shared/src/__tests__/recommended-extensions.test.ts +156 -0
  197. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  198. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  199. package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
  200. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  201. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  202. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  203. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  204. package/packages/shared/src/bootstrap-install.ts +212 -0
  205. package/packages/shared/src/bridge-register.ts +87 -20
  206. package/packages/shared/src/browser-protocol.ts +93 -1
  207. package/packages/shared/src/config.ts +87 -15
  208. package/packages/shared/src/managed-paths.ts +31 -4
  209. package/packages/shared/src/openspec-poller.ts +71 -49
  210. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  211. package/packages/shared/src/platform/commands.ts +100 -0
  212. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  213. package/packages/shared/src/platform/exec.ts +220 -0
  214. package/packages/shared/src/platform/git.ts +155 -0
  215. package/packages/shared/src/platform/index.ts +15 -0
  216. package/packages/shared/src/platform/npm.ts +162 -0
  217. package/packages/shared/src/platform/openspec.ts +91 -0
  218. package/packages/shared/src/platform/paths.ts +276 -0
  219. package/packages/shared/src/platform/process-identify.ts +126 -0
  220. package/packages/shared/src/platform/process-scan.ts +94 -0
  221. package/packages/shared/src/platform/process.ts +168 -0
  222. package/packages/shared/src/platform/runner.ts +369 -0
  223. package/packages/shared/src/platform/shell.ts +44 -0
  224. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  225. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  226. package/packages/shared/src/recommended-extensions.ts +196 -0
  227. package/packages/shared/src/resolve-jiti.ts +62 -3
  228. package/packages/shared/src/rest-api.ts +97 -0
  229. package/packages/shared/src/semaphore.ts +83 -0
  230. package/packages/shared/src/source-matching.ts +126 -0
  231. package/packages/shared/src/test-support/setup-home.ts +74 -0
  232. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  233. package/packages/shared/src/tool-registry/index.ts +56 -0
  234. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  235. package/packages/shared/src/tool-registry/registry.ts +262 -0
  236. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  237. package/packages/shared/src/tool-registry/types.ts +180 -0
  238. package/packages/shared/src/types.ts +7 -0
@@ -2,12 +2,118 @@
2
2
  * Thin adapter around pi's DefaultPackageManager.
3
3
  * Serializes operations (one at a time), forwards progress events,
4
4
  * and triggers session reload on success.
5
+ *
6
+ * Pi module resolution is delegated to the shared `ToolRegistry`
7
+ * (`resolveModule("pi-coding-agent")`). All strategy chains, caching,
8
+ * and diagnostic trails live there — see change: consolidate-tool-resolution.
5
9
  */
6
10
  import * as os from "node:os";
7
11
  import * as path from "node:path";
8
12
  import * as crypto from "node:crypto";
9
- import { pathToFileURL } from "node:url";
10
- import { execSync } from "node:child_process";
13
+ import {
14
+ getDefaultRegistry,
15
+ ModuleResolutionError,
16
+ type ToolRegistry,
17
+ type Resolution,
18
+ } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
19
+ import {
20
+ getDefaultSubprocessAdapter,
21
+ type SubprocessAdapter,
22
+ } from "@blackbelt-technology/pi-dashboard-shared/platform/subprocess-adapter.js";
23
+
24
+ /**
25
+ * Resolve a command name through the tool registry's executor API.
26
+ * If the name is registered (e.g. "npm", "openspec", "pi"), returns
27
+ * the full executor argv — on Windows this is `[node.exe, <script>.js]`
28
+ * bypassing .cmd shims. Otherwise returns `[command, ...args]` verbatim
29
+ * so callers fall through to buildSafeArgv's generic handling.
30
+ *
31
+ * See change: consolidate-windows-spawn-and-platform-handlers.
32
+ */
33
+ function resolveViaRegistry(
34
+ registry: ToolRegistry,
35
+ command: string,
36
+ args: readonly string[],
37
+ ): string[] {
38
+ if (registry.has(command)) {
39
+ const exec = registry.resolveExecutor(command);
40
+ if (exec.ok && exec.argv.length > 0) {
41
+ return [...exec.argv, ...args];
42
+ }
43
+ }
44
+ return [command, ...args];
45
+ }
46
+
47
+ /**
48
+ * Subclass of pi's `DefaultPackageManager` that routes every subprocess
49
+ * through our OS-aware `SubprocessAdapter`. Pi's upstream implementation
50
+ * spawns with `shell: process.platform === "win32"` and no `windowsHide`,
51
+ * which on Windows triggers Node issue #21825 — flashing cmd console
52
+ * every time pi shells out to npm / git / etc.
53
+ *
54
+ * This class overrides the three spawn methods pi exposes on its own
55
+ * class (`spawnCommand`, `spawnCaptureCommand`, `runCommandSync`) and
56
+ * delegates them to the adapter. Other methods inherit unchanged;
57
+ * pi's internal `runCommand` / `runCommandCapture` call the overridden
58
+ * methods via `this.spawnCommand(...)` so they pick up the safe
59
+ * behaviour automatically.
60
+ *
61
+ * Constructor factory takes the base `DefaultPackageManager` class as
62
+ * input so we can extend it dynamically at runtime (pi is loaded via
63
+ * the tool registry's `resolveModule`, not a static import).
64
+ *
65
+ * See change: consolidate-windows-spawn-and-platform-handlers.
66
+ */
67
+ function createSafePackageManagerClass(
68
+ BaseClass: new (...args: any[]) => any,
69
+ adapter: SubprocessAdapter,
70
+ registry: ToolRegistry,
71
+ ): new (...args: any[]) => any {
72
+ return class SafePackageManager extends BaseClass {
73
+ // `spawnCommand` — used by pi for fire-and-forget installs where
74
+ // stdout/stderr are inherited (or piped for capture). Returns the
75
+ // live ChildProcess.
76
+ //
77
+ // Registry resolution: `command` arrives as "npm" / "git" etc.
78
+ // For registered executor tools this becomes `[node.exe, cli.js]`
79
+ // on Windows, bypassing .cmd entirely.
80
+ spawnCommand(command: string, args: readonly string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv }) {
81
+ const [cmd, ...finalArgs] = resolveViaRegistry(registry, command, args);
82
+ return adapter.spawn(cmd, finalArgs, {
83
+ cwd: options?.cwd,
84
+ stdio: "inherit",
85
+ });
86
+ }
87
+
88
+ // `spawnCaptureCommand` — used by pi when it wants to read stdout /
89
+ // stderr programmatically (e.g. `npm root -g`, `npm view <pkg>`).
90
+ spawnCaptureCommand(command: string, args: readonly string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv }) {
91
+ const [cmd, ...finalArgs] = resolveViaRegistry(registry, command, args);
92
+ return adapter.spawn(cmd, finalArgs, {
93
+ cwd: options?.cwd,
94
+ stdio: ["ignore", "pipe", "pipe"],
95
+ env: options?.env ? { ...process.env, ...options.env } : process.env,
96
+ });
97
+ }
98
+
99
+ // `runCommandSync` — used for quick synchronous checks.
100
+ runCommandSync(command: string, args: readonly string[]) {
101
+ const [cmd, ...finalArgs] = resolveViaRegistry(registry, command, args);
102
+ const result = adapter.spawnSync<string>(cmd, finalArgs, {
103
+ stdio: ["ignore", "pipe", "pipe"],
104
+ encoding: "utf-8",
105
+ });
106
+ if (result.status !== 0) {
107
+ const stderr = typeof result.stderr === "string" ? result.stderr : String(result.stderr ?? "");
108
+ const stdout = typeof result.stdout === "string" ? result.stdout : String(result.stdout ?? "");
109
+ throw new Error(`Failed to run ${command} ${args.join(" ")}: ${stderr || stdout}`);
110
+ }
111
+ const stdout = typeof result.stdout === "string" ? result.stdout : String(result.stdout ?? "");
112
+ return stdout.trim();
113
+ }
114
+ };
115
+ }
116
+
11
117
  export interface ProgressEvent {
12
118
  type: "start" | "progress" | "complete" | "error";
13
119
  action: "install" | "remove" | "update" | "clone" | "pull";
@@ -15,39 +121,35 @@ export interface ProgressEvent {
15
121
  message?: string;
16
122
  }
17
123
 
18
- /** Lazily import pi's PackageManager (optional peer dependency). */
19
- let piModuleCache: { DefaultPackageManager: any; SettingsManager: any } | null = null;
20
-
21
- async function loadPiPackageManager() {
22
- if (piModuleCache) return piModuleCache;
23
-
24
- // Try direct import first (works if installed as a dependency)
25
- try {
26
- const mod = await import("@mariozechner/pi-coding-agent") as any;
27
- if (mod.DefaultPackageManager) {
28
- piModuleCache = { DefaultPackageManager: mod.DefaultPackageManager, SettingsManager: mod.SettingsManager };
29
- return piModuleCache;
30
- }
31
- } catch { /* fall through to global resolution */ }
124
+ /** Pi-coding-agent's public surface, as consumed by this wrapper. */
125
+ interface PiModule {
126
+ DefaultPackageManager: any;
127
+ SettingsManager: any;
128
+ }
32
129
 
33
- // Resolve from global npm install (pi is typically installed globally)
34
- for (const pkgName of ["@mariozechner/pi-coding-agent", "@oh-my-pi/pi-coding-agent"]) {
35
- try {
36
- const npmRoot = execSync("npm root -g", { encoding: "utf-8", timeout: 10_000 }).trim();
37
- const entryPath = path.join(npmRoot, pkgName, "dist", "index.js");
38
- const mod = await import(pathToFileURL(entryPath).href);
39
- if (mod.DefaultPackageManager) {
40
- piModuleCache = { DefaultPackageManager: mod.DefaultPackageManager, SettingsManager: mod.SettingsManager };
41
- return piModuleCache;
42
- }
43
- } catch { /* fall through */ }
130
+ /**
131
+ * Resolve pi's package-manager API via the ToolRegistry. Surface the
132
+ * diagnostic trail on failure so callers (routes) can show the real
133
+ * reason instead of a generic "not installed" message.
134
+ */
135
+ async function loadPiPackageManager(registry: ToolRegistry = getDefaultRegistry()): Promise<PiModule> {
136
+ const { module } = await registry.resolveModule<PiModule>("pi-coding-agent");
137
+ if (!module.DefaultPackageManager) {
138
+ throw new Error(
139
+ "pi-coding-agent resolved but does not export DefaultPackageManager (unexpected package version)",
140
+ );
44
141
  }
142
+ return module;
143
+ }
45
144
 
46
- throw new Error(
47
- "pi-coding-agent is not installed. Package management requires pi to be installed (globally or as a dependency)."
48
- );
145
+ /** Debug helper: expose the raw Resolution for diagnostic surfaces. */
146
+ export function diagnosePiPackageManager(registry: ToolRegistry = getDefaultRegistry()): Resolution {
147
+ return registry.resolve("pi-coding-agent");
49
148
  }
50
149
 
150
+ /** Re-export so route handlers can `instanceof`-check for the rich error. */
151
+ export { ModuleResolutionError };
152
+
51
153
  export type PackageScope = "global" | "local";
52
154
  export type PackageAction = "install" | "remove" | "update";
53
155
 
@@ -65,6 +167,8 @@ export interface OperationResult {
65
167
  scope: PackageScope;
66
168
  success: boolean;
67
169
  error?: string;
170
+ /** On failure: full resolution trail if pi couldn't be loaded. */
171
+ diagnostics?: Resolution;
68
172
  }
69
173
 
70
174
  export type ProgressListener = (operationId: string, event: ProgressEvent) => void;
@@ -78,6 +182,11 @@ export class PackageManagerWrapper {
78
182
  private onComplete: CompleteListener | undefined;
79
183
  /** Called after successful operation; returns number of sessions reloaded. */
80
184
  private reloadSessions: (() => Promise<number>) | undefined;
185
+ private readonly registry: ToolRegistry;
186
+
187
+ constructor(registry: ToolRegistry = getDefaultRegistry()) {
188
+ this.registry = registry;
189
+ }
81
190
 
82
191
  setProgressListener(listener: ProgressListener | undefined) {
83
192
  this.onProgress = listener;
@@ -95,6 +204,24 @@ export class PackageManagerWrapper {
95
204
  return this.busy;
96
205
  }
97
206
 
207
+ /**
208
+ * Run an arbitrary async operation under the wrapper's busy-lock.
209
+ * Used by adjacent subsystems (e.g. PiCoreUpdater) to coordinate with
210
+ * extension install/update operations. Throws PackageOperationBusyError
211
+ * if a package operation is already running.
212
+ */
213
+ async runExclusive<T>(fn: () => Promise<T>): Promise<T> {
214
+ if (this.busy) {
215
+ throw new PackageOperationBusyError();
216
+ }
217
+ this.busy = true;
218
+ try {
219
+ return await fn();
220
+ } finally {
221
+ this.busy = false;
222
+ }
223
+ }
224
+
98
225
  /**
99
226
  * Start a package operation. Returns the operationId immediately.
100
227
  * Progress and completion are delivered via listeners.
@@ -136,11 +263,63 @@ export class PackageManagerWrapper {
136
263
 
137
264
  // ── Internal ────────────────────────────────────────────────────
138
265
 
266
+ /**
267
+ * Per-cwd cache of DefaultPackageManager instances. Each instance holds
268
+ * its own SettingsManager + filesystem state; on Windows
269
+ * `listConfiguredPackages` can take several seconds on cold
270
+ * instantiation, so reusing the same instance across repeat calls
271
+ * (same cwd) eliminates the cost of the `/api/packages/recommended` +
272
+ * `/api/packages/installed` flows firing back-to-back.
273
+ *
274
+ * We also dedupe *in-flight* instantiations via `pmPending`: if two
275
+ * concurrent callers both ask for the same cwd before the first
276
+ * instantiation resolves, they share the same Promise instead of
277
+ * spawning parallel `DefaultPackageManager` constructions (which
278
+ * compete for the event loop and can double cold-start latency).
279
+ *
280
+ * `run()` invalidates the relevant entry after an install/remove/update
281
+ * so stale state never persists past a mutation.
282
+ */
283
+ private readonly pmCache = new Map<string, unknown>();
284
+ private readonly pmPending = new Map<string, Promise<unknown>>();
285
+
139
286
  private async createPackageManager(cwd?: string) {
140
- const { DefaultPackageManager, SettingsManager } = await loadPiPackageManager();
141
287
  const effectiveCwd = cwd ?? process.cwd();
142
- const settingsManager = SettingsManager.create(effectiveCwd, AGENT_DIR);
143
- return new DefaultPackageManager({ cwd: effectiveCwd, agentDir: AGENT_DIR, settingsManager });
288
+ const cached = this.pmCache.get(effectiveCwd);
289
+ if (cached) return cached as any;
290
+ const inflight = this.pmPending.get(effectiveCwd);
291
+ if (inflight) return inflight as any;
292
+
293
+ const promise = (async () => {
294
+ const { DefaultPackageManager, SettingsManager } = await loadPiPackageManager(this.registry);
295
+ const settingsManager = SettingsManager.create(effectiveCwd, AGENT_DIR);
296
+ // Wrap pi's DefaultPackageManager in our SafePackageManager so
297
+ // every internal `spawn` / `spawnSync` / `runCommandSync` call
298
+ // routes through the OS-aware SubprocessAdapter. This is THE
299
+ // fix for cmd.exe flashes on Windows caused by pi's upstream
300
+ // `shell: true + no windowsHide` spawn pattern.
301
+ const SafePM = createSafePackageManagerClass(
302
+ DefaultPackageManager,
303
+ getDefaultSubprocessAdapter(),
304
+ this.registry,
305
+ );
306
+ const pm = new SafePM({ cwd: effectiveCwd, agentDir: AGENT_DIR, settingsManager });
307
+ this.pmCache.set(effectiveCwd, pm);
308
+ return pm;
309
+ })();
310
+ this.pmPending.set(effectiveCwd, promise);
311
+ try {
312
+ return await promise;
313
+ } finally {
314
+ this.pmPending.delete(effectiveCwd);
315
+ }
316
+ }
317
+
318
+ /** Drop the cached package manager for a cwd (after install/remove/update). */
319
+ private invalidatePackageManager(cwd?: string): void {
320
+ const effectiveCwd = cwd ?? process.cwd();
321
+ this.pmCache.delete(effectiveCwd);
322
+ this.pmPending.delete(effectiveCwd);
144
323
  }
145
324
 
146
325
  private async executeOperation(operationId: string, req: OperationRequest): Promise<void> {
@@ -174,6 +353,10 @@ export class PackageManagerWrapper {
174
353
 
175
354
  result.success = true;
176
355
 
356
+ // Invalidate the cached package manager for this cwd so future
357
+ // listInstalled() calls see the mutated settings.json.
358
+ this.invalidatePackageManager(req.cwd);
359
+
177
360
  // Reload all sessions after successful operation
178
361
  if (this.reloadSessions) {
179
362
  try {
@@ -184,7 +367,15 @@ export class PackageManagerWrapper {
184
367
  }
185
368
  }
186
369
  } catch (err: any) {
187
- result.error = err?.message ?? String(err);
370
+ // Pi-not-found: surface the full Resolution trail to the caller
371
+ // so the UI can render per-strategy failure reasons instead of
372
+ // the old opaque "pi-coding-agent is not installed" message.
373
+ if (err instanceof ModuleResolutionError) {
374
+ result.error = err.message;
375
+ result.diagnostics = err.resolution;
376
+ } else {
377
+ result.error = err?.message ?? String(err);
378
+ }
188
379
  } finally {
189
380
  this.busy = false;
190
381
  this.onComplete?.(result);
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Pi core version checker.
3
+ *
4
+ * Discovers installed pi-ecosystem CORE packages (pi-coding-agent itself,
5
+ * pi-agent-dashboard, pi-model-proxy, and similar globally-installed CLI
6
+ * tooling) and compares their versions against the npm registry.
7
+ *
8
+ * Complements the existing PackageManagerWrapper, which only manages
9
+ * packages listed in `settings.json packages[]` (extensions, skills,
10
+ * prompts, themes).
11
+ *
12
+ * Discovery sources:
13
+ * 1. Global npm (`npm list -g --depth=0 --json`)
14
+ * 2. Managed install (`~/.pi-dashboard/node_modules/`) — Electron path
15
+ *
16
+ * Version fetch reuses `fetchPackageMeta()` from the npm-search proxy.
17
+ * Results are cached for 5 minutes.
18
+ */
19
+ import { execFile } from "node:child_process"; // ban:child_process-ok pi-core check uses execFile + promisify for `npm list -g --json` output capture; refactoring to platform/spawn's Recipe engine is tracked tech debt
20
+ import { promisify } from "node:util";
21
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
22
+ import path from "node:path";
23
+ import os from "node:os";
24
+ import { fetchPackageMeta } from "./npm-search-proxy.js";
25
+
26
+ const execFileAsync = promisify(execFile);
27
+
28
+ const CACHE_TTL_MS = 5 * 60 * 1000;
29
+ const NPM_LIST_TIMEOUT_MS = 30_000;
30
+
31
+ /** ~/.pi-dashboard/ — Electron managed install dir */
32
+ const MANAGED_DIR = path.join(os.homedir(), ".pi-dashboard");
33
+ const MANAGED_NODE_MODULES = path.join(MANAGED_DIR, "node_modules");
34
+
35
+ /** Known core packages (not extensions). Order matters for display. */
36
+ export const CORE_PACKAGE_NAMES: readonly string[] = [
37
+ "@mariozechner/pi-coding-agent",
38
+ "@oh-my-pi/pi-coding-agent",
39
+ "@blackbelt-technology/pi-agent-dashboard",
40
+ "@blackbelt-technology/pi-model-proxy",
41
+ ];
42
+
43
+ /** Display name mapping for known packages. Falls back to package name. */
44
+ const DISPLAY_NAMES: Readonly<Record<string, string>> = {
45
+ "@mariozechner/pi-coding-agent": "pi (core agent)",
46
+ "@oh-my-pi/pi-coding-agent": "pi (core agent — fork)",
47
+ "@blackbelt-technology/pi-agent-dashboard": "pi-dashboard",
48
+ "@blackbelt-technology/pi-model-proxy": "pi-model-proxy",
49
+ };
50
+
51
+ export interface PiCorePackage {
52
+ name: string;
53
+ displayName: string;
54
+ currentVersion: string;
55
+ latestVersion: string | null;
56
+ updateAvailable: boolean;
57
+ installSource: "global" | "managed";
58
+ }
59
+
60
+ export interface PiCoreStatus {
61
+ packages: PiCorePackage[];
62
+ updatesAvailable: number;
63
+ lastChecked: string;
64
+ }
65
+
66
+ /** Resolve display name for a package. */
67
+ function resolveDisplayName(name: string): string {
68
+ return DISPLAY_NAMES[name] ?? name;
69
+ }
70
+
71
+ /**
72
+ * Heuristic to decide if a package is part of the pi ecosystem but NOT in
73
+ * the known-names list above. Matches bare-name pi packages on npm:
74
+ * - bare `pi-<name>`
75
+ * - scoped `@<scope>/pi-<name>`
76
+ * Note: extensions already managed by PackageManagerWrapper (via
77
+ * `settings.json packages[]`) are deliberately included if they are ALSO
78
+ * installed globally — the PiCoreChecker's discovery is a superset, and
79
+ * the UI layer decides which surface to show a package in.
80
+ */
81
+ function looksLikePiEcosystem(name: string): boolean {
82
+ if (CORE_PACKAGE_NAMES.includes(name)) return true;
83
+ // `pi-foo` or `pi` bare-scoped
84
+ if (/^pi-[a-z0-9-]+$/i.test(name)) return true;
85
+ // scoped variant: `@scope/pi-foo`
86
+ if (/^@[^/]+\/pi-[a-z0-9-]+$/i.test(name)) return true;
87
+ return false;
88
+ }
89
+
90
+ export interface NpmListRunner {
91
+ /** Run `npm list -g --depth=0 --json` and return stdout. */
92
+ (): Promise<string>;
93
+ }
94
+
95
+ export interface PiCoreCheckerOptions {
96
+ /** Inject npm-list runner (for tests). */
97
+ npmList?: NpmListRunner;
98
+ /** Inject version fetcher (for tests). */
99
+ fetchLatest?: (packageName: string) => Promise<string | null>;
100
+ /** Override managed directory (for tests). */
101
+ managedDir?: string;
102
+ }
103
+
104
+ /** Default npm runner uses execFile for safety. */
105
+ const defaultNpmList: NpmListRunner = async () => {
106
+ const { stdout } = await execFileAsync("npm", ["list", "-g", "--depth=0", "--json"], {
107
+ timeout: NPM_LIST_TIMEOUT_MS,
108
+ maxBuffer: 10 * 1024 * 1024,
109
+ });
110
+ return stdout;
111
+ };
112
+
113
+ const defaultFetchLatest = async (packageName: string): Promise<string | null> => {
114
+ const meta = await fetchPackageMeta(packageName);
115
+ return meta?.version ?? null;
116
+ };
117
+
118
+ export class PiCoreChecker {
119
+ private cache: { at: number; data: PiCoreStatus } | null = null;
120
+ private readonly npmList: NpmListRunner;
121
+ private readonly fetchLatest: (packageName: string) => Promise<string | null>;
122
+ private readonly managedNodeModules: string;
123
+
124
+ constructor(opts: PiCoreCheckerOptions = {}) {
125
+ this.npmList = opts.npmList ?? defaultNpmList;
126
+ this.fetchLatest = opts.fetchLatest ?? defaultFetchLatest;
127
+ this.managedNodeModules = opts.managedDir
128
+ ? path.join(opts.managedDir, "node_modules")
129
+ : MANAGED_NODE_MODULES;
130
+ }
131
+
132
+ /** Invalidate the cache (e.g. after an update completes). */
133
+ invalidate(): void {
134
+ this.cache = null;
135
+ }
136
+
137
+ /** Get version status. Returns cached data within 5 min unless `refresh`. */
138
+ async getStatus(refresh = false): Promise<PiCoreStatus> {
139
+ const now = Date.now();
140
+ if (!refresh && this.cache && now - this.cache.at < CACHE_TTL_MS) {
141
+ return this.cache.data;
142
+ }
143
+
144
+ // Discover packages from both sources. Managed takes precedence on conflict.
145
+ const global = await this.discoverGlobal();
146
+ const managed = this.discoverManaged();
147
+
148
+ const byName = new Map<string, { version: string; source: "global" | "managed" }>();
149
+ for (const entry of global) byName.set(entry.name, { version: entry.version, source: "global" });
150
+ for (const entry of managed) byName.set(entry.name, { version: entry.version, source: "managed" });
151
+
152
+ // Fetch latest versions in parallel.
153
+ const entries = Array.from(byName.entries());
154
+ const withLatest = await Promise.all(
155
+ entries.map(async ([name, info]) => {
156
+ let latest: string | null = null;
157
+ try {
158
+ latest = await this.fetchLatest(name);
159
+ } catch {
160
+ latest = null;
161
+ }
162
+ const updateAvailable = latest !== null && latest !== info.version;
163
+ const pkg: PiCorePackage = {
164
+ name,
165
+ displayName: resolveDisplayName(name),
166
+ currentVersion: info.version,
167
+ latestVersion: latest,
168
+ updateAvailable,
169
+ installSource: info.source,
170
+ };
171
+ return pkg;
172
+ }),
173
+ );
174
+
175
+ // Sort: known core packages first (in CORE_PACKAGE_NAMES order), then
176
+ // alphabetically. Then updates-available bubble up.
177
+ withLatest.sort((a, b) => {
178
+ const ai = CORE_PACKAGE_NAMES.indexOf(a.name);
179
+ const bi = CORE_PACKAGE_NAMES.indexOf(b.name);
180
+ if (ai !== -1 || bi !== -1) {
181
+ if (ai === -1) return 1;
182
+ if (bi === -1) return -1;
183
+ return ai - bi;
184
+ }
185
+ return a.name.localeCompare(b.name);
186
+ });
187
+
188
+ const status: PiCoreStatus = {
189
+ packages: withLatest,
190
+ updatesAvailable: withLatest.filter((p) => p.updateAvailable).length,
191
+ lastChecked: new Date().toISOString(),
192
+ };
193
+ this.cache = { at: now, data: status };
194
+ return status;
195
+ }
196
+
197
+ /** Discover pi-ecosystem packages installed via `npm -g`. */
198
+ private async discoverGlobal(): Promise<Array<{ name: string; version: string }>> {
199
+ let stdout = "";
200
+ try {
201
+ stdout = await this.npmList();
202
+ } catch (err) {
203
+ // `npm list` exits non-zero when it has warnings — stdout may still be valid JSON.
204
+ // execFile throws with .stdout attached in that case.
205
+ const maybe = (err as { stdout?: string })?.stdout;
206
+ if (typeof maybe === "string" && maybe.length > 0) {
207
+ stdout = maybe;
208
+ } else {
209
+ console.warn("[pi-core-checker] npm list -g failed:", (err as Error).message);
210
+ return [];
211
+ }
212
+ }
213
+
214
+ let parsed: unknown;
215
+ try {
216
+ parsed = JSON.parse(stdout);
217
+ } catch (err) {
218
+ console.warn("[pi-core-checker] npm list -g: failed to parse JSON:", (err as Error).message);
219
+ return [];
220
+ }
221
+
222
+ const deps = (parsed as { dependencies?: Record<string, { version?: string; resolved?: string }> })?.dependencies;
223
+ if (!deps || typeof deps !== "object") return [];
224
+
225
+ const out: Array<{ name: string; version: string }> = [];
226
+ for (const [name, info] of Object.entries(deps)) {
227
+ if (!looksLikePiEcosystem(name)) continue;
228
+ const version = typeof info?.version === "string" ? info.version : undefined;
229
+ if (!version) continue;
230
+ out.push({ name, version });
231
+ }
232
+ return out;
233
+ }
234
+
235
+ /** Discover pi-ecosystem packages in ~/.pi-dashboard/node_modules/. */
236
+ private discoverManaged(): Array<{ name: string; version: string }> {
237
+ if (!existsSync(this.managedNodeModules)) return [];
238
+ const out: Array<{ name: string; version: string }> = [];
239
+ let entries: string[];
240
+ try {
241
+ entries = readdirSync(this.managedNodeModules);
242
+ } catch {
243
+ return [];
244
+ }
245
+
246
+ for (const entry of entries) {
247
+ if (entry.startsWith(".")) continue;
248
+ const full = path.join(this.managedNodeModules, entry);
249
+ if (entry.startsWith("@")) {
250
+ // Scoped: iterate one level deeper.
251
+ let sub: string[];
252
+ try {
253
+ sub = readdirSync(full);
254
+ } catch {
255
+ continue;
256
+ }
257
+ for (const pkg of sub) {
258
+ const pkgName = `${entry}/${pkg}`;
259
+ if (!looksLikePiEcosystem(pkgName)) continue;
260
+ const v = this.readVersion(path.join(full, pkg));
261
+ if (v) out.push({ name: pkgName, version: v });
262
+ }
263
+ } else {
264
+ if (!looksLikePiEcosystem(entry)) continue;
265
+ const v = this.readVersion(full);
266
+ if (v) out.push({ name: entry, version: v });
267
+ }
268
+ }
269
+ return out;
270
+ }
271
+
272
+ private readVersion(pkgDir: string): string | null {
273
+ try {
274
+ const pj = path.join(pkgDir, "package.json");
275
+ if (!existsSync(pj)) return null;
276
+ if (!statSync(pj).isFile()) return null;
277
+ const parsed = JSON.parse(readFileSync(pj, "utf-8"));
278
+ return typeof parsed?.version === "string" ? parsed.version : null;
279
+ } catch {
280
+ return null;
281
+ }
282
+ }
283
+ }
284
+
285
+ export const _internal = {
286
+ looksLikePiEcosystem,
287
+ resolveDisplayName,
288
+ DISPLAY_NAMES,
289
+ MANAGED_NODE_MODULES,
290
+ };