@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
@@ -3,11 +3,31 @@
3
3
  * Replaces scattered whichSync/resolvePiCommand/resolveTsxCommand implementations
4
4
  * with a single configurable resolver.
5
5
  */
6
- import { execSync } from "node:child_process";
6
+ import { execSync, spawnSync, buildSafeArgv } from "./exec.js";
7
7
  import { existsSync } from "node:fs";
8
8
  import path from "node:path";
9
9
  import os from "node:os";
10
- import { MANAGED_BIN, MANAGED_DIR } from "./managed-paths.js";
10
+ import { MANAGED_BIN, MANAGED_DIR } from "../managed-paths.js";
11
+
12
+ /**
13
+ * Well-known globalThis symbol for the default `ToolRegistry`.
14
+ *
15
+ * The registry publishes itself here when first constructed (see
16
+ * `tool-registry/index.ts::getDefaultRegistry`). Delegation avoids a
17
+ * static import cycle (tool-registry strategies already import from
18
+ * this file).
19
+ *
20
+ * See change: consolidate-windows-spawn-and-platform-handlers.
21
+ */
22
+ const GLOBAL_REGISTRY_KEY = Symbol.for("pi-dashboard.tool-registry");
23
+ interface LazyRegistry {
24
+ has(n: string): boolean;
25
+ resolveExecutor(n: string): { ok: boolean; argv: string[] };
26
+ }
27
+ function tryGetRegistry(): LazyRegistry | null {
28
+ const reg = (globalThis as unknown as { [k: symbol]: LazyRegistry | undefined })[GLOBAL_REGISTRY_KEY];
29
+ return reg ?? null;
30
+ }
11
31
 
12
32
  export interface ResolverContext {
13
33
  /** Extra bin dirs to search before system PATH (e.g., bundled Node dir). */
@@ -59,25 +79,27 @@ export class ToolResolver {
59
79
  }
60
80
 
61
81
  /**
62
- * Resolve pi as [cmd, ...prefixArgs].
63
- * On Windows, avoids .cmd by returning [node.exe, cli.js].
82
+ * Resolve pi as spawn-ready argv `[cmd, ...prefixArgs]`.
83
+ *
84
+ * Fully delegates to `ToolRegistry.resolveExecutor("pi")`, which
85
+ * owns per-OS discovery + interpreter assembly (on Windows: find
86
+ * `pi-coding-agent/dist/cli.js` via managed/bare-import/npm-global
87
+ * and prepend `node.exe`; on Unix: find `pi` binary on PATH).
88
+ *
89
+ * Returns null when the registry is not yet constructed AND pi is
90
+ * not on PATH (very early boot / standalone tests). Production code
91
+ * always has the registry available before spawn.
64
92
  */
65
93
  resolvePi(): string[] | null {
66
- if (process.platform === "win32") {
67
- // Avoid .cmd — resolve pi's JS entry point directly
68
- const piCli = path.join(MANAGED_BIN, "..", "@mariozechner", "pi-coding-agent", "dist", "cli.js");
69
- if (existsSync(piCli)) {
70
- const node = this.resolveNode();
71
- if (node) return [node, piCli];
72
- }
73
- // Fallback to .cmd
74
- const cmd = path.join(MANAGED_BIN, "pi.cmd");
75
- if (existsSync(cmd)) return [cmd];
94
+ const registry = tryGetRegistry();
95
+ if (registry?.has("pi")) {
96
+ const exec = registry.resolveExecutor("pi");
97
+ if (exec.ok && exec.argv.length > 0) return exec.argv;
76
98
  }
77
-
99
+ // No registry in this process (e.g. legacy bootstrap) — fall back
100
+ // to PATH lookup so the method still works for non-server callers.
78
101
  const piPath = this.which("pi");
79
- if (piPath) return [piPath];
80
- return null;
102
+ return piPath ? [piPath] : null;
81
103
  }
82
104
 
83
105
  /**
@@ -160,28 +182,106 @@ export class ToolResolver {
160
182
 
161
183
  // ── Internal helpers ──────────────────────────────────────────────────────────
162
184
 
163
- /** Resolve a command on the current process PATH via which/where. */
164
- function whichSync(cmd: string): string | null {
165
- const whichCmd = process.platform === "win32" ? "where" : "which";
185
+ /**
186
+ * Run `where|which <target>` and return ALL stdout lines (trimmed,
187
+ * non-empty), or `[]`.
188
+ *
189
+ * Uses `spawnSync` via `buildSafeArgv` — no shell interpretation at
190
+ * all. `execSync("where tmux")` used to route through cmd.exe (because
191
+ * execSync takes a shell command string); spawnSync with argv bypasses
192
+ * that entirely. Guaranteed no cmd.exe console flash.
193
+ *
194
+ * See change: consolidate-windows-spawn-and-platform-handlers.
195
+ */
196
+ function whereAllLines(whichCmd: string, target: string): string[] {
166
197
  try {
167
- return execSync(`${whichCmd} ${cmd}`, {
198
+ const { argv, spawnOptions } = buildSafeArgv(whichCmd, [target]);
199
+ const result = spawnSync<string>(argv[0], argv.slice(1), {
168
200
  encoding: "utf-8",
169
201
  stdio: ["pipe", "pipe", "pipe"],
170
- }).trim().split("\n")[0];
202
+ ...spawnOptions,
203
+ });
204
+ if (result.status !== 0) return [];
205
+ const text = typeof result.stdout === "string" ? result.stdout : String(result.stdout ?? "");
206
+ return text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
171
207
  } catch {
172
- return null;
208
+ return [];
173
209
  }
174
210
  }
175
211
 
212
+ /** Extract the file extension (lower-cased, including the dot) from a path, or "". */
213
+ function extOf(p: string): string {
214
+ const slash = Math.max(p.lastIndexOf("\\"), p.lastIndexOf("/"));
215
+ const dot = p.lastIndexOf(".");
216
+ return dot > slash ? p.slice(dot).toLowerCase() : "";
217
+ }
218
+
219
+ /**
220
+ * Resolve a command on PATH.
221
+ *
222
+ * Unix: the first `which <name>` hit is authoritative.
223
+ *
224
+ * Windows: `where <name>` lists ALL PATH matches — a directory may contain
225
+ * both a bash shim (extensionless, e.g. `pi`) and a Windows-native form
226
+ * (`pi.cmd`). Node's `spawn()` cannot execute extensionless shims on
227
+ * Windows without `shell: true`, so we pick the first line whose extension
228
+ * is in PATHEXT. Falls back to the first line if none match (preserves
229
+ * whatever the user set up).
230
+ *
231
+ * Single `where` invocation — no per-extension probe loop — to keep
232
+ * resolution fast (especially when the command is missing entirely).
233
+ */
234
+ function whichSync(cmd: string): string | null {
235
+ const isWin = process.platform === "win32";
236
+ if (!isWin) {
237
+ const lines = whereAllLines("which", cmd);
238
+ return lines[0] ?? null;
239
+ }
240
+
241
+ const lines = whereAllLines("where", cmd);
242
+ if (lines.length === 0) return null;
243
+
244
+ // If the caller already specified an extension, trust their pick.
245
+ const callerHasExt = /\.[A-Za-z0-9]+$/.test(cmd);
246
+ if (callerHasExt) return lines[0];
247
+
248
+ // Preference order: PATHEXT (user's actual Windows search path) or a
249
+ // standard default. Lower-cased for case-insensitive matching.
250
+ const pathextRaw = process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC;.PS1";
251
+ const pathext = pathextRaw.split(";").map((e) => e.trim().toLowerCase()).filter(Boolean);
252
+
253
+ // Pick the first line whose extension matches PATHEXT, scanning by
254
+ // preference order (lower index = more preferred).
255
+ let best: string | null = null;
256
+ let bestRank = Infinity;
257
+ for (const line of lines) {
258
+ const rank = pathext.indexOf(extOf(line));
259
+ if (rank === -1) continue;
260
+ if (rank < bestRank) {
261
+ best = line;
262
+ bestRank = rank;
263
+ }
264
+ }
265
+ if (best) return best;
266
+
267
+ // No PATHEXT-matching entry — fall back to first line (could be a bash
268
+ // shim). The runner layer handles the `.cmd` / `.bat` spawn-via-shell
269
+ // case separately; extensionless shims will still ENOENT but that's
270
+ // the right signal to the caller.
271
+ return lines[0];
272
+ }
273
+
176
274
  /** Resolve a command via login shell (picks up nvm/volta/homebrew paths). */
177
275
  function whichViaLoginShell(cmd: string): string | null {
178
276
  const shell = process.env.SHELL || "/bin/zsh";
179
277
  try {
180
- const output = execSync(`${shell} -ilc "which ${cmd}"`, {
278
+ const raw = execSync(`${shell} -ilc "which ${cmd}"`, {
181
279
  encoding: "utf-8",
182
280
  stdio: ["pipe", "pipe", "pipe"],
183
281
  timeout: 5000,
184
- }).trim();
282
+ windowsHide: true,
283
+ });
284
+ const output = (typeof raw === "string" ? raw : String(raw)).trim();
185
285
  // Extract absolute path from potentially noisy login shell output
186
286
  const pathLine = output.split("\n").find(l => l.trim().startsWith("/"));
187
287
  return pathLine?.trim() || null;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Cross-platform OS-command primitives: open URL in default browser,
3
+ * detect whether the host is a virtual machine.
4
+ *
5
+ * Every OS-dependent helper accepts injectable `platform` and `exec`
6
+ * (or `child_exec` for async) parameters, so tests can exercise both
7
+ * branches without mutating globals.
8
+ * See change: consolidate-platform-handlers.
9
+ */
10
+
11
+ import { exec as childExec, execSync } from "./exec.js";
12
+
13
+ type ExecFn = (cmd: string, opts: { encoding: "utf-8"; timeout?: number }) => string;
14
+ export type AsyncExecFn = (cmd: string, cb: (err: Error | null) => void) => void;
15
+
16
+ export interface CommandsOpts {
17
+ /** Override platform (defaults to process.platform). */
18
+ platform?: NodeJS.Platform;
19
+ /** Override synchronous exec (for VM detection tests). */
20
+ exec?: ExecFn;
21
+ /** Override async exec (for openBrowser tests). */
22
+ asyncExec?: AsyncExecFn;
23
+ }
24
+
25
+ function defaultExec(cmd: string, opts: { encoding: "utf-8"; timeout?: number }): string {
26
+ return execSync(cmd, { ...opts, windowsHide: true }) as unknown as string;
27
+ }
28
+
29
+ function defaultAsyncExec(cmd: string, cb: (err: Error | null) => void): void {
30
+ childExec(cmd, { windowsHide: true }, (err) => cb(err));
31
+ }
32
+
33
+ // ── Open URL in default browser ─────────────────────────────────────────────
34
+
35
+ /**
36
+ * Open a URL in the system's default browser. Fire-and-forget; errors are
37
+ * logged via `onError` but not thrown.
38
+ * - darwin: `open "<url>"`
39
+ * - win32: `start "" "<url>"`
40
+ * - linux: `xdg-open "<url>"`
41
+ */
42
+ export function openBrowser(
43
+ url: string,
44
+ opts: CommandsOpts & { onError?: (err: Error) => void } = {},
45
+ ): void {
46
+ const platform = opts.platform ?? process.platform;
47
+ const asyncExec = opts.asyncExec ?? defaultAsyncExec;
48
+ const quoted = JSON.stringify(url);
49
+ const cmd =
50
+ platform === "darwin" ? `open ${quoted}`
51
+ : platform === "win32" ? `start "" ${quoted}`
52
+ : `xdg-open ${quoted}`;
53
+ asyncExec(cmd, (err) => {
54
+ if (err && opts.onError) opts.onError(err);
55
+ });
56
+ }
57
+
58
+ // ── Virtual-machine detection ───────────────────────────────────────────────
59
+
60
+ /**
61
+ * Best-effort virtual-machine detection. Uses platform-specific probes:
62
+ * - darwin: `sysctl -n hw.model` looks for VMware/VirtualBox/Parallels
63
+ * - linux: `systemd-detect-virt` — non-`none` output means VM
64
+ * - win32: `wmic bios get serialnumber` + `wmic computersystem get manufacturer,model`
65
+ * patterns: VMware | VirtualBox | VBOX | Parallels | Virtual Machine | Hyper-V
66
+ *
67
+ * Returns `false` on any probe failure (best-effort).
68
+ */
69
+ export function isVirtualMachine(opts: CommandsOpts = {}): boolean {
70
+ const platform = opts.platform ?? process.platform;
71
+ const exec = opts.exec ?? defaultExec;
72
+ try {
73
+ if (platform === "darwin") {
74
+ const model = String(exec("sysctl -n hw.model", { encoding: "utf-8" })).trim();
75
+ return /VMware|VirtualBox|Parallels/i.test(model);
76
+ }
77
+ if (platform === "linux") {
78
+ const virt = String(exec("systemd-detect-virt 2>/dev/null || echo none", { encoding: "utf-8" })).trim();
79
+ return virt !== "none" && virt.length > 0;
80
+ }
81
+ if (platform === "win32") {
82
+ const checks = [
83
+ "wmic bios get serialnumber",
84
+ "wmic computersystem get manufacturer,model",
85
+ ];
86
+ for (const cmd of checks) {
87
+ try {
88
+ const out = String(exec(cmd, { encoding: "utf-8", timeout: 5000 }));
89
+ if (/VMware|VirtualBox|VBOX|Parallels|Virtual Machine|Hyper-V/i.test(out)) return true;
90
+ } catch {
91
+ /* try next */
92
+ }
93
+ }
94
+ return false;
95
+ }
96
+ } catch {
97
+ /* ignore */
98
+ }
99
+ return false;
100
+ }
@@ -0,0 +1,305 @@
1
+ /**
2
+ * OS-aware detached-child spawn primitives.
3
+ *
4
+ * The dashboard spawns several kinds of long-lived detached children
5
+ * (pi sessions, dashboard server from Electron or bridge, CLI restart
6
+ * orchestrator) and every site re-implemented the same spawn-then-wait
7
+ * dance with slightly different defaults — producing lifecycle bugs on
8
+ * Windows (children in the parent's libuv kill-on-close job → die on
9
+ * restart) and ~200 LOC of near-duplicated boilerplate. This module
10
+ * consolidates them into three primitives:
11
+ *
12
+ * • spawnDetached — spawn with libuv-correct defaults
13
+ * • waitForNoCrash — did the child survive a fixed window?
14
+ * • waitForReady — did the child pass a positive probe?
15
+ *
16
+ * Key invariants (see change: consolidate-windows-spawn-and-platform-handlers):
17
+ *
18
+ * 1. `detached: true` on every platform. On Windows, libuv only excludes
19
+ * a child from the parent's global Job Object (which has
20
+ * JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE) when `detached: true` — that is
21
+ * the PGID-equivalent mechanism, and it is the correct default for
22
+ * every detached-child we spawn.
23
+ * 2. `shell: false` always. `.cmd` shims must be pre-resolved to
24
+ * `node.exe + cli.js` by the caller (via ToolResolver.resolvePi /
25
+ * resolveTsx etc.). `shell: true + detached + windowsHide + .cmd`
26
+ * triggers Node issue #21825 (flashing console window).
27
+ * 3. `stdio[0] = "ignore"` always. A parent-owned pipe breaks when the
28
+ * parent dies (EPIPE on first write); file fds survive.
29
+ * 4. `windowsHide: true` always (defence in depth alongside detached).
30
+ *
31
+ * All OS-dependent helpers accept an optional trailing
32
+ * `platform: NodeJS.Platform` parameter so tests can exercise both branches
33
+ * without mutating `process.platform`. See AGENTS.md invariant.
34
+ */
35
+ import type { ChildProcess, SpawnOptions } from "node:child_process";
36
+ import { spawn as safeSpawn } from "./exec.js";
37
+
38
+ // ── spawnDetached ───────────────────────────────────────────────────────────
39
+
40
+ export interface SpawnDetachedOptions {
41
+ /** Absolute path to the binary. MUST be pre-resolved — no `.cmd` shims. */
42
+ cmd: string;
43
+ /** Argv tokens. Passed verbatim to `spawn()`; no shell interpretation. */
44
+ args: string[];
45
+ /** Working directory for the child. */
46
+ cwd?: string;
47
+ /** Environment for the child. Defaults to `process.env` via Node. */
48
+ env?: NodeJS.ProcessEnv;
49
+ /**
50
+ * Optional file descriptor for stderr. When omitted, stderr is "ignore".
51
+ * Caller is responsible for `fs.openSync(logPath, "a")` and closing the
52
+ * parent's copy after spawn (the child retains its dup via stdio
53
+ * inheritance). File fds survive parent death; pipes do not.
54
+ */
55
+ logFd?: number;
56
+ /**
57
+ * stdin mode. Default: "ignore" — child's stdin closes immediately.
58
+ *
59
+ * Use `"pipe"` when the child is a Node program whose RPC mode
60
+ * listens for stdin `end` events and shuts down on EOF (e.g.
61
+ * pi-coding-agent's `--mode rpc`). A parent-held pipe keeps the
62
+ * child's stdin open as long as the parent is alive. Note: when
63
+ * the parent process dies, Windows closes the pipe and the child
64
+ * gets EOF — i.e., stdio:"pipe" undoes the session-survives-
65
+ * server-restart invariant of `detached: true`. Callers must pick
66
+ * one property (durability vs RPC-mode keep-alive) consciously.
67
+ */
68
+ stdinMode?: "ignore" | "pipe";
69
+ /**
70
+ * Whether to detach the child from the parent's libuv Job Object
71
+ * (Windows) / process group (POSIX). Default: `true`.
72
+ *
73
+ * When `true` (default):
74
+ * - Windows: child is excluded from the parent's Job Object, so
75
+ * killing the parent does NOT kill the child. Downside: libuv
76
+ * allocates a new console for the child unless all stdio slots
77
+ * are "ignore" (see libuv `src/win/process.c` — `CREATE_NO_WINDOW`
78
+ * is only set when no stdio slot has `UV_INHERIT_FD`). With a
79
+ * parent-held stdin pipe or file-fd stdout/stderr, brief console
80
+ * flashes occur despite `windowsHide: true` (which only applies
81
+ * `SW_HIDE` — hides AFTER allocation).
82
+ * - POSIX: child is placed in its own process group.
83
+ *
84
+ * When `false`:
85
+ * - Windows: child stays in parent's Job Object. `CREATE_NO_WINDOW`
86
+ * is irrelevant (no new console allocation). No flash regardless
87
+ * of stdio shape. Child dies with parent (Job Object closure).
88
+ * - POSIX: child inherits parent's process group. Child dies with
89
+ * parent on SIGTERM to the group.
90
+ *
91
+ * Use `false` when the child's lifecycle is deliberately tied to the
92
+ * parent (e.g., pi-session spawn where RPC stdin-EOF already ties
93
+ * them). Use default (`true`) for everything that must outlive its
94
+ * parent (server auto-start, CLI daemon, Electron server launch).
95
+ *
96
+ * See change: prep-for-develop-merge (restores the behavior of
97
+ * commit d331850 that was silently overridden by 5ab7956's universal
98
+ * detached:true invariant).
99
+ */
100
+ detach?: boolean;
101
+ /**
102
+ * Override platform for testing. Does not affect spawn behaviour (Node's
103
+ * `spawn` is platform-aware internally) but is surfaced here so future
104
+ * platform-specific branches stay out of callers.
105
+ */
106
+ platform?: NodeJS.Platform;
107
+ }
108
+
109
+ export interface SpawnDetachedResult {
110
+ ok: boolean;
111
+ pid?: number;
112
+ process?: ChildProcess;
113
+ error?: string;
114
+ }
115
+
116
+ /**
117
+ * Spawn a detached child with libuv-correct defaults on every platform.
118
+ *
119
+ * Returns `{ ok: true, pid, process }` on success. Returns `{ ok: false,
120
+ * error }` when the child has no PID or fails synchronously. Async errors
121
+ * are surfaced via a short (200 ms) grace period: if `ok: false` is
122
+ * returned, either the child never started or it errored immediately.
123
+ */
124
+ export async function spawnDetached(opts: SpawnDetachedOptions): Promise<SpawnDetachedResult> {
125
+ const stdioIn: "ignore" | "pipe" = opts.stdinMode ?? "ignore";
126
+ const stdio: ("ignore" | "pipe" | number)[] = [stdioIn, "ignore", opts.logFd ?? "ignore"];
127
+
128
+ let child: ChildProcess;
129
+ let spawnError: string | null = null;
130
+ try {
131
+ child = safeSpawn(opts.cmd, opts.args, {
132
+ cwd: opts.cwd,
133
+ env: opts.env,
134
+ detached: opts.detach ?? true,
135
+ stdio,
136
+ shell: false,
137
+ windowsHide: true,
138
+ } as SpawnOptions);
139
+ } catch (err: any) {
140
+ return { ok: false, error: err?.message ?? String(err) };
141
+ }
142
+
143
+ child.on("error", (err: Error) => {
144
+ spawnError = err.message;
145
+ });
146
+
147
+ // unref() so Node's event loop doesn't keep the parent alive because of
148
+ // this child. Harmless when the child has its own stdio file fds.
149
+ try { child.unref(); } catch { /* ignore */ }
150
+
151
+ // Short grace window for synchronous / near-synchronous spawn errors
152
+ // (ENOENT is emitted via 'error' on nextTick, not thrown).
153
+ if (!child.pid) {
154
+ await delay(200);
155
+ return { ok: false, error: spawnError ?? "spawn failed: no PID" };
156
+ }
157
+
158
+ // If the child errored inside the grace window, surface it even though
159
+ // we have a PID (some failures emit both: PID assigned then ENOENT on
160
+ // the exec itself).
161
+ await delay(5);
162
+ if (spawnError) {
163
+ return { ok: false, error: spawnError, pid: child.pid, process: child };
164
+ }
165
+
166
+ return { ok: true, pid: child.pid, process: child };
167
+ }
168
+
169
+ // ── waitForNoCrash ─────────────────────────────────────────────────────────
170
+
171
+ export interface WaitForNoCrashOptions {
172
+ /** The child returned by spawnDetached(). */
173
+ child: ChildProcess;
174
+ /** How long to wait before declaring "didn't crash" (ms). */
175
+ windowMs: number;
176
+ /**
177
+ * If > 0, capture up to N bytes from the child's stderr stream (if a
178
+ * pipe is attached — which requires stdio[2] to be "pipe" rather than
179
+ * a file fd). Only useful when the caller manually attached a pipe
180
+ * before calling this; `spawnDetached` does NOT use pipes by default.
181
+ */
182
+ captureStderrBytes?: number;
183
+ }
184
+
185
+ export interface WaitForNoCrashResult {
186
+ ok: boolean;
187
+ exitCode?: number | null;
188
+ stderrTail?: string;
189
+ }
190
+
191
+ /**
192
+ * Wait up to `windowMs` milliseconds; return ok:true if the child is
193
+ * still alive at the end, or ok:false if it exited within the window.
194
+ *
195
+ * The primary use case: pi spawn on Windows where we want to catch
196
+ * "pi crashed on startup due to missing module / config error" without
197
+ * blocking the response for the full startup handshake.
198
+ */
199
+ export async function waitForNoCrash(opts: WaitForNoCrashOptions): Promise<WaitForNoCrashResult> {
200
+ const { child, windowMs, captureStderrBytes } = opts;
201
+
202
+ let stderrBuf = "";
203
+ const cap = captureStderrBytes ?? 0;
204
+ const onStderr = cap > 0 && child.stderr
205
+ ? (chunk: Buffer) => {
206
+ stderrBuf += chunk.toString();
207
+ if (stderrBuf.length > cap) stderrBuf = stderrBuf.slice(-cap);
208
+ }
209
+ : null;
210
+ if (onStderr) child.stderr!.on("data", onStderr);
211
+
212
+ const exitCode = await Promise.race([
213
+ new Promise<number | null>((resolve) => child.once("exit", resolve)),
214
+ delay(windowMs).then(() => undefined),
215
+ ]);
216
+
217
+ if (onStderr) child.stderr!.off("data", onStderr);
218
+
219
+ if (exitCode === undefined) {
220
+ return { ok: true };
221
+ }
222
+ return {
223
+ ok: false,
224
+ exitCode,
225
+ stderrTail: stderrBuf ? stderrBuf.trim() : undefined,
226
+ };
227
+ }
228
+
229
+ // ── waitForReady ───────────────────────────────────────────────────────────
230
+
231
+ export interface WaitForReadyOptions {
232
+ /** Called repeatedly until it resolves true, or deadline elapses. */
233
+ probe: () => Promise<boolean>;
234
+ /**
235
+ * Maximum total wait (ms). Omit (or pass `undefined`) to wait
236
+ * indefinitely — in that case the only way to end the wait early is
237
+ * the optional `child` crashing. Use the infinite form when the
238
+ * caller trusts child-exit detection to cover the failure path and
239
+ * doesn't want a false-positive timeout on slow-but-working starts
240
+ * (e.g., cold jiti compile on Windows).
241
+ */
242
+ deadlineMs?: number;
243
+ /** Poll interval (ms). Default 500. */
244
+ pollIntervalMs?: number;
245
+ /**
246
+ * Optional child for early failure detection. If provided, an `error`
247
+ * event or non-zero `exit` short-circuits the wait.
248
+ */
249
+ child?: ChildProcess;
250
+ }
251
+
252
+ export interface WaitForReadyResult {
253
+ ok: boolean;
254
+ error?: string;
255
+ }
256
+
257
+ /**
258
+ * Poll `probe()` until it resolves true, or return timeout / early-failure.
259
+ * When `deadlineMs` is undefined, polls indefinitely until the probe
260
+ * succeeds or the child crashes.
261
+ */
262
+ export async function waitForReady(opts: WaitForReadyOptions): Promise<WaitForReadyResult> {
263
+ const { probe, deadlineMs, pollIntervalMs = 500, child } = opts;
264
+
265
+ let childError: string | null = null;
266
+ const onError = (err: Error) => { childError = err.message; };
267
+ const onExit = (code: number | null) => {
268
+ if (code !== 0 && code !== null) {
269
+ childError = `child exited with code ${code}`;
270
+ } else if (code === 0) {
271
+ // Exit 0 is fine for short-lived children (e.g., wt.exe). Don't
272
+ // treat it as an error — the probe decides readiness.
273
+ }
274
+ };
275
+ if (child) {
276
+ child.on("error", onError);
277
+ child.on("exit", onExit);
278
+ }
279
+
280
+ const deadline = deadlineMs === undefined ? Infinity : Date.now() + deadlineMs;
281
+ try {
282
+ while (Date.now() < deadline) {
283
+ if (childError) return { ok: false, error: childError };
284
+ try {
285
+ if (await probe()) return { ok: true };
286
+ } catch {
287
+ // Swallow probe errors — treat as "not ready yet".
288
+ }
289
+ await delay(pollIntervalMs);
290
+ }
291
+ if (childError) return { ok: false, error: childError };
292
+ return { ok: false, error: "timeout" };
293
+ } finally {
294
+ if (child) {
295
+ child.off("error", onError);
296
+ child.off("exit", onExit);
297
+ }
298
+ }
299
+ }
300
+
301
+ // ── helpers ────────────────────────────────────────────────────────────────
302
+
303
+ function delay(ms: number): Promise<void> {
304
+ return new Promise((r) => setTimeout(r, ms));
305
+ }