@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
@@ -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,52 +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 */ }
32
-
33
- // Try managed install at ~/.pi-dashboard/node_modules/ (Electron portable/standalone)
34
- const managedDir = path.join(os.homedir(), ".pi-dashboard");
35
- for (const pkgName of ["@mariozechner/pi-coding-agent", "@oh-my-pi/pi-coding-agent"]) {
36
- try {
37
- const entryPath = path.join(managedDir, "node_modules", 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 */ }
44
- }
124
+ /** Pi-coding-agent's public surface, as consumed by this wrapper. */
125
+ interface PiModule {
126
+ DefaultPackageManager: any;
127
+ SettingsManager: any;
128
+ }
45
129
 
46
- // Resolve from global npm install (pi is typically installed globally)
47
- for (const pkgName of ["@mariozechner/pi-coding-agent", "@oh-my-pi/pi-coding-agent"]) {
48
- try {
49
- const npmRoot = execSync("npm root -g", { encoding: "utf-8", timeout: 10_000 }).trim();
50
- const entryPath = path.join(npmRoot, pkgName, "dist", "index.js");
51
- const mod = await import(pathToFileURL(entryPath).href);
52
- if (mod.DefaultPackageManager) {
53
- piModuleCache = { DefaultPackageManager: mod.DefaultPackageManager, SettingsManager: mod.SettingsManager };
54
- return piModuleCache;
55
- }
56
- } 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
+ );
57
141
  }
142
+ return module;
143
+ }
58
144
 
59
- throw new Error(
60
- "pi-coding-agent is not installed. Package management requires pi to be installed (globally or as a dependency)."
61
- );
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");
62
148
  }
63
149
 
150
+ /** Re-export so route handlers can `instanceof`-check for the rich error. */
151
+ export { ModuleResolutionError };
152
+
64
153
  export type PackageScope = "global" | "local";
65
154
  export type PackageAction = "install" | "remove" | "update";
66
155
 
@@ -78,6 +167,8 @@ export interface OperationResult {
78
167
  scope: PackageScope;
79
168
  success: boolean;
80
169
  error?: string;
170
+ /** On failure: full resolution trail if pi couldn't be loaded. */
171
+ diagnostics?: Resolution;
81
172
  }
82
173
 
83
174
  export type ProgressListener = (operationId: string, event: ProgressEvent) => void;
@@ -91,6 +182,11 @@ export class PackageManagerWrapper {
91
182
  private onComplete: CompleteListener | undefined;
92
183
  /** Called after successful operation; returns number of sessions reloaded. */
93
184
  private reloadSessions: (() => Promise<number>) | undefined;
185
+ private readonly registry: ToolRegistry;
186
+
187
+ constructor(registry: ToolRegistry = getDefaultRegistry()) {
188
+ this.registry = registry;
189
+ }
94
190
 
95
191
  setProgressListener(listener: ProgressListener | undefined) {
96
192
  this.onProgress = listener;
@@ -167,11 +263,63 @@ export class PackageManagerWrapper {
167
263
 
168
264
  // ── Internal ────────────────────────────────────────────────────
169
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
+
170
286
  private async createPackageManager(cwd?: string) {
171
- const { DefaultPackageManager, SettingsManager } = await loadPiPackageManager();
172
287
  const effectiveCwd = cwd ?? process.cwd();
173
- const settingsManager = SettingsManager.create(effectiveCwd, AGENT_DIR);
174
- 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);
175
323
  }
176
324
 
177
325
  private async executeOperation(operationId: string, req: OperationRequest): Promise<void> {
@@ -205,6 +353,10 @@ export class PackageManagerWrapper {
205
353
 
206
354
  result.success = true;
207
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
+
208
360
  // Reload all sessions after successful operation
209
361
  if (this.reloadSessions) {
210
362
  try {
@@ -215,7 +367,15 @@ export class PackageManagerWrapper {
215
367
  }
216
368
  }
217
369
  } catch (err: any) {
218
- 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
+ }
219
379
  } finally {
220
380
  this.busy = false;
221
381
  this.onComplete?.(result);
@@ -16,7 +16,7 @@
16
16
  * Version fetch reuses `fetchPackageMeta()` from the npm-search proxy.
17
17
  * Results are cached for 5 minutes.
18
18
  */
19
- import { execFile } from "node:child_process";
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
20
  import { promisify } from "node:util";
21
21
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
22
22
  import path from "node:path";
@@ -6,7 +6,7 @@
6
6
  * Coordinates with PackageManagerWrapper's busy-lock so extension
7
7
  * operations and core updates can't run concurrently.
8
8
  */
9
- import { spawn } from "node:child_process";
9
+ import { spawn } from "node:child_process"; // ban:child_process-ok npm-update streams stdout/stderr via pipe for progress events; refactor to platform/spawn Recipe is tracked tech debt
10
10
  import path from "node:path";
11
11
  import os from "node:os";
12
12
  import { existsSync } from "node:fs";
@@ -50,10 +50,16 @@ function defaultRunNpmUpdate(
50
50
  return;
51
51
  }
52
52
 
53
+ // On Windows, system npm is npm.cmd (batch wrapper) — spawn("npm")
54
+ // without the .cmd extension fails with ENOENT. shell:true routes
55
+ // the invocation through cmd.exe which resolves via PATHEXT.
56
+ // See change: route-kill-paths-through-platform (same class of bug).
53
57
  const child = spawn("npm", args, {
54
58
  cwd,
55
59
  stdio: ["ignore", "pipe", "pipe"],
56
60
  env: process.env,
61
+ shell: process.platform === "win32", // platform-branch-ok: shell:true required on Windows so PATHEXT resolves npm.cmd (spawn('npm') without .cmd ENOENTs)
62
+ windowsHide: true,
57
63
  });
58
64
 
59
65
  const timer = setTimeout(() => {
@@ -5,7 +5,7 @@
5
5
  import * as fs from "node:fs";
6
6
  import * as path from "node:path";
7
7
  import * as os from "node:os";
8
- import { execSync } from "node:child_process";
8
+ import * as npm from "@blackbelt-technology/pi-dashboard-shared/platform/npm.js";
9
9
  import type { PiResource, PiResourceScope, PiPackageInfo, PiResourcesResult } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
10
10
 
11
11
  // ── Frontmatter Parsing ─────────────────────────────────────────────
@@ -179,13 +179,10 @@ let cachedNpmGlobalRoot: string | null = null;
179
179
 
180
180
  function getNpmGlobalRoot(): string | null {
181
181
  if (cachedNpmGlobalRoot !== null) return cachedNpmGlobalRoot;
182
- try {
183
- cachedNpmGlobalRoot = execSync("npm root -g", { encoding: "utf-8", timeout: 10_000 }).trim();
184
- return cachedNpmGlobalRoot;
185
- } catch {
186
- cachedNpmGlobalRoot = "";
187
- return null;
188
- }
182
+ // Delegate to shared npm module which caches the result itself and
183
+ // handles windowsHide / timeout. See change: platform-command-executor.
184
+ cachedNpmGlobalRoot = npm.rootGlobalOr("");
185
+ return cachedNpmGlobalRoot || null;
189
186
  }
190
187
 
191
188
  /** Visible for testing — reset cached npm root */
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Version-skew detection for pi-coding-agent.
3
+ *
4
+ * Reads `piCompatibility` from `packages/server/package.json` and the
5
+ * currently-resolved pi version from its `package.json`, then populates
6
+ * `bootstrapState.compatibility` with hints the UI banner uses to show
7
+ * upgrade suggestions.
8
+ *
9
+ * See change: unified-bootstrap-install \u00a79.
10
+ */
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { createRequire } from "node:module";
14
+ import type { BootstrapCompatibility, BootstrapStateStore } from "./bootstrap-state.js";
15
+ import { getDefaultRegistry, type ToolRegistry } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
16
+
17
+ /**
18
+ * Parse a semver-ish string into its three numeric segments. Returns
19
+ * null when the string doesn't match `<n>.<n>.<n>` (with optional
20
+ * pre-release / build suffix which we ignore for comparison). This is
21
+ * deliberately minimal \u2014 pi versions have always been `0.x.y` and we
22
+ * don't want to pull in the `semver` dep.
23
+ */
24
+ export function parseVersion(v: string): [number, number, number] | null {
25
+ const m = v.trim().replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
26
+ if (!m) return null;
27
+ return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];
28
+ }
29
+
30
+ /**
31
+ * Compare two version strings. Returns -1 if `a < b`, 0 if equal, 1 if
32
+ * `a > b`. Unparseable strings sort as equal (conservative \u2014 don't flag
33
+ * weird versions as outdated).
34
+ */
35
+ export function compareVersions(a: string, b: string): -1 | 0 | 1 {
36
+ const A = parseVersion(a);
37
+ const B = parseVersion(b);
38
+ if (!A || !B) return 0;
39
+ for (let i = 0; i < 3; i++) {
40
+ if (A[i] < B[i]) return -1;
41
+ if (A[i] > B[i]) return 1;
42
+ }
43
+ return 0;
44
+ }
45
+
46
+ /**
47
+ * Return true if `version` is less than `threshold`. Delegates to
48
+ * `compareVersions` so unparseable strings never flag as "too old".
49
+ */
50
+ export function isBelow(version: string, threshold: string): boolean {
51
+ return compareVersions(version, threshold) < 0;
52
+ }
53
+
54
+ /**
55
+ * Return true if `version` is strictly above `threshold`. `threshold`
56
+ * may include a `.x` wildcard in the patch slot (e.g. `"0.9.x"`); in
57
+ * that case the wildcard matches any patch, so `"0.9.5"` is NOT above
58
+ * `"0.9.x"` but `"0.10.0"` is.
59
+ */
60
+ export function isAbove(version: string, threshold: string): boolean {
61
+ const thresholdClean = threshold.replace(/\.x$/i, ".99999");
62
+ return compareVersions(version, thresholdClean) > 0;
63
+ }
64
+
65
+ /**
66
+ * Read the server's declared compatibility range from its own package.json.
67
+ * Falls back to the hard-coded defaults when the field is missing or
68
+ * malformed (shouldn't happen in practice).
69
+ */
70
+ export function readPiCompatibility(serverPkgJsonPath: string): Pick<
71
+ BootstrapCompatibility,
72
+ "minimum" | "recommended" | "maximum"
73
+ > {
74
+ try {
75
+ const raw = fs.readFileSync(serverPkgJsonPath, "utf8");
76
+ const parsed = JSON.parse(raw) as {
77
+ piCompatibility?: { minimum?: string; recommended?: string; maximum?: string | null };
78
+ };
79
+ const c = parsed.piCompatibility;
80
+ if (c && typeof c.minimum === "string" && typeof c.recommended === "string") {
81
+ return {
82
+ minimum: c.minimum,
83
+ recommended: c.recommended,
84
+ maximum: c.maximum ?? null,
85
+ };
86
+ }
87
+ } catch {
88
+ /* fall through */
89
+ }
90
+ return { minimum: "0.6.7", recommended: "0.6.7", maximum: null };
91
+ }
92
+
93
+ /**
94
+ * Read the currently-resolved pi version from `<pi-module>/../package.json`.
95
+ * Returns undefined when pi isn't resolvable or the package.json can't
96
+ * be parsed.
97
+ */
98
+ export function readCurrentPiVersion(registry: ToolRegistry = getDefaultRegistry()): string | undefined {
99
+ try {
100
+ const req = createRequire(import.meta.url);
101
+ const pkgJson = req.resolve("@mariozechner/pi-coding-agent/package.json");
102
+ const raw = fs.readFileSync(pkgJson, "utf8");
103
+ const parsed = JSON.parse(raw) as { version?: string };
104
+ if (typeof parsed.version === "string") return parsed.version;
105
+ } catch {
106
+ /* not resolvable yet */
107
+ }
108
+ // Fall back to the registry's resolved path + ../package.json.
109
+ try {
110
+ const res = registry.resolve("pi");
111
+ if (res.ok && res.path) {
112
+ const candidate = path.join(path.dirname(path.dirname(res.path)), "package.json");
113
+ if (fs.existsSync(candidate)) {
114
+ const raw = fs.readFileSync(candidate, "utf8");
115
+ const parsed = JSON.parse(raw) as { version?: string };
116
+ if (typeof parsed.version === "string") return parsed.version;
117
+ }
118
+ }
119
+ } catch {
120
+ /* ignore */
121
+ }
122
+ return undefined;
123
+ }
124
+
125
+ /**
126
+ * Compute the `compatibility` snapshot from a compatibility range and
127
+ * the current pi version (or undefined when not yet installed). Pure
128
+ * function \u2014 all I/O is done by callers.
129
+ */
130
+ export function computeCompatibility(
131
+ range: Pick<BootstrapCompatibility, "minimum" | "recommended" | "maximum">,
132
+ current: string | undefined,
133
+ ): BootstrapCompatibility {
134
+ const out: BootstrapCompatibility = { ...range, current };
135
+ if (!current) return out;
136
+ if (isBelow(current, range.minimum)) {
137
+ // Minimum-violated is signalled by leaving `upgradeRecommended` true
138
+ // AND letting callers populate `bootstrapState.error` with the
139
+ // block-ops message.
140
+ out.upgradeRecommended = true;
141
+ return out;
142
+ }
143
+ if (isBelow(current, range.recommended)) {
144
+ out.upgradeRecommended = true;
145
+ }
146
+ if (range.maximum && isAbove(current, range.maximum)) {
147
+ out.upgradeDashboard = true;
148
+ }
149
+ return out;
150
+ }
151
+
152
+ interface CacheEntry {
153
+ value: BootstrapCompatibility;
154
+ /** Milliseconds epoch when this entry should be discarded. */
155
+ expiresAt: number;
156
+ }
157
+
158
+ let cached: CacheEntry | undefined;
159
+ const CACHE_TTL_MS = 60_000;
160
+
161
+ /**
162
+ * Convenience wrapper: read range + current version, compute result,
163
+ * cache for 60 s. `store` is called with a structured compatibility
164
+ * update and (when minimum is violated) a blocking `error` message.
165
+ */
166
+ export function updateBootstrapCompatibility(
167
+ store: BootstrapStateStore,
168
+ serverPkgJsonPath: string,
169
+ registry: ToolRegistry = getDefaultRegistry(),
170
+ now: () => number = Date.now,
171
+ ): BootstrapCompatibility {
172
+ const t = now();
173
+ if (cached && t < cached.expiresAt) {
174
+ store.set({ compatibility: cached.value });
175
+ return cached.value;
176
+ }
177
+ const range = readPiCompatibility(serverPkgJsonPath);
178
+ const current = readCurrentPiVersion(registry);
179
+ const computed = computeCompatibility(range, current);
180
+ cached = { value: computed, expiresAt: t + CACHE_TTL_MS };
181
+ store.set({ compatibility: computed });
182
+ // Minimum-violated → block pi-dependent ops by setting `error`.
183
+ if (current && isBelow(current, range.minimum)) {
184
+ store.set({
185
+ error: {
186
+ message: `pi version ${current} is below minimum ${range.minimum}. Please run \`pi-dashboard upgrade-pi\`.`,
187
+ },
188
+ });
189
+ }
190
+ return computed;
191
+ }
192
+
193
+ /** Test helper: clear the 60-second cache between runs. */
194
+ export function _resetVersionSkewCache(): void {
195
+ cached = undefined;
196
+ }
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import { CONFIG_DIR } from "@blackbelt-technology/pi-dashboard-shared/config.js";
8
8
  import { readJsonFile, writeJsonFile } from "./json-store.js";
9
9
  import { safeRealpathSync } from "./resolve-path.js";
10
+ import { normalizePath } from "@blackbelt-technology/pi-dashboard-shared/platform/paths.js";
10
11
 
11
12
  export const PREFERENCES_FILE = path.join(CONFIG_DIR, "preferences.json");
12
13
 
@@ -32,10 +33,23 @@ const DEBOUNCE_MS = 1000;
32
33
  export function createPreferencesStore(filePath: string = PREFERENCES_FILE): PreferencesStore {
33
34
  const data: PreferencesData = readJsonFile<PreferencesData>(filePath, { sessionOrder: {}, pinnedDirectories: [] });
34
35
  let sessionOrder: Record<string, string[]> = data.sessionOrder ?? {};
35
- // Resolve symlinks in stored pinned paths on load
36
+ // Normalize + resolve symlinks in stored pinned paths on load. Normalize
37
+ // FIRST so cosmetic drift (trailing separator, mixed separators,
38
+ // drive-letter case on Windows) collapses before realpath — then
39
+ // realpath handles symlinks. Order matters: realpath can fail for
40
+ // not-yet-existing paths, so we keep its best-effort fallback.
41
+ // See change: platform-path-normalization.
36
42
  const rawPinned = data.pinnedDirectories ?? [];
37
- let pinnedDirectories: string[] = rawPinned.map(safeRealpathSync);
38
- // Deduplicate in case symlinks resolved to the same path
43
+ // IMPORTANT: wrap in arrow fn — `Array.prototype.map` passes `(element,
44
+ // index, array)`, and `normalizePath`'s 2nd param is a `platform:
45
+ // NodeJS.Platform`. Passing the index (a number) silently disables the
46
+ // Windows branch at runtime.
47
+ let pinnedDirectories: string[] = rawPinned
48
+ .map((p) => normalizePath(p))
49
+ .map((p) => safeRealpathSync(p));
50
+ // Deduplicate post-normalization. Two previously-different entries that
51
+ // collapse to the same canonical form (e.g., with and without trailing
52
+ // slash) become one stored entry.
39
53
  pinnedDirectories = [...new Set(pinnedDirectories)];
40
54
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
41
55
  let dirty = pinnedDirectories.length !== rawPinned.length || pinnedDirectories.some((p, i) => p !== rawPinned[i]);