@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
@@ -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
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Safe child-process wrappers that always set `windowsHide: true`.
3
+ *
4
+ * Rationale
5
+ * ─────────
6
+ * On Windows, spawning a `.cmd` shim (or anything node.exe wraps via cmd.exe)
7
+ * flashes a cmd-prompt window unless `windowsHide: true` is passed. This is
8
+ * a universal source of visible-UI bugs in this project: bridge process
9
+ * scanners, git polls, openspec polls, terminal subprocess checks, etc.
10
+ * Every spawn site needed to remember to set the flag, and we kept missing
11
+ * some (session-diff, git-operations, update-checker, doctor, tunnel, ...).
12
+ *
13
+ * Rather than fixing this per call site forever, this module wraps the
14
+ * Node `child_process` API with `windowsHide: true` as the default. Callers
15
+ * can still override by explicitly passing `windowsHide: false` if they
16
+ * genuinely want a visible console (none of our callers do).
17
+ *
18
+ * **Every spawn in packages/*\/src SHOULD import from here** instead of
19
+ * directly from `node:child_process`. A repo-level check can fail if
20
+ * direct imports sneak back in. See change: consolidate-platform-handlers.
21
+ */
22
+ import {
23
+ execSync as nodeExecSync,
24
+ exec as nodeExec,
25
+ execFile as nodeExecFile,
26
+ spawnSync as nodeSpawnSync,
27
+ spawn as nodeSpawn,
28
+ type ExecSyncOptions,
29
+ type ExecOptions,
30
+ type ExecFileOptions,
31
+ type SpawnSyncOptions,
32
+ type SpawnOptions,
33
+ type ChildProcess,
34
+ type SpawnSyncReturns,
35
+ } from "node:child_process";
36
+ import { promisify } from "node:util";
37
+
38
+ // ── Argv safety (Windows .cmd / .bat handling) ─────────────────────────────
39
+
40
+ /**
41
+ * Build a spawn-safe argv for ANY command on ANY platform.
42
+ *
43
+ * The canonical way to invoke subprocesses without flashing cmd.exe
44
+ * console windows on Windows. Handles three cases:
45
+ *
46
+ * 1. Windows + `.cmd` / `.bat` shim → explicit `cmd.exe /c <cmd> <args>`.
47
+ * This is the ONLY reliable way to invoke `.cmd` files without the
48
+ * flashing-console bug (Node issue #21825, which happens when
49
+ * `shell: true` is combined with `.cmd` + `detached` + `windowsHide`).
50
+ * cmd.exe respects `windowsHide: true` on its own console directly.
51
+ *
52
+ * 2. Windows + native binary (`.exe`) → direct argv.
53
+ *
54
+ * 3. Unix (any binary or shell script) → direct argv.
55
+ *
56
+ * Always returns `{ shell: false, windowsHide: true }` — NEVER uses
57
+ * `shell: true`. Callers pass these spawn options along with the argv.
58
+ *
59
+ * Example:
60
+ * const { argv, spawnOptions } = buildSafeArgv("npm.cmd", ["root", "-g"]);
61
+ * spawnSync(argv[0], argv.slice(1), { cwd, env, ...spawnOptions });
62
+ *
63
+ * See change: consolidate-windows-spawn-and-platform-handlers.
64
+ */
65
+ export interface SafeArgv {
66
+ argv: string[];
67
+ spawnOptions: { shell: false; windowsHide: true };
68
+ }
69
+
70
+ export function buildSafeArgv(
71
+ cmd: string,
72
+ args: readonly string[] = [],
73
+ platform: NodeJS.Platform = process.platform,
74
+ ): SafeArgv {
75
+ if (platform === "win32") {
76
+ // Route through cmd.exe for TWO cases:
77
+ // 1. Explicit .cmd/.bat shim — Node can't spawn these directly
78
+ // with shell:false (CVE-2024-27980 fix in Node >= 20.12).
79
+ // 2. Extensionless name (e.g. "npm", "pi", "git") — Windows
80
+ // resolves these via PATHEXT, but only shells do. Without
81
+ // cmd.exe, spawn("npm") returns ENOENT because there's no
82
+ // literal "npm" binary — just "npm.cmd".
83
+ // Native .exe / absolute paths bypass cmd.exe (no PATHEXT needed).
84
+ //
85
+ // /d = skip AutoRun, /s = treat quoted first token as command
86
+ // (preserves spaces), /c = run and exit. cmd.exe honors
87
+ // windowsHide on its console, so inner .cmd's node.exe inherits an
88
+ // invisible console — no flash.
89
+ const isShim = /\.(cmd|bat)$/i.test(cmd);
90
+ const hasExtension = /\.[A-Za-z0-9]+$/.test(cmd);
91
+ if (isShim || !hasExtension) {
92
+ return {
93
+ argv: ["cmd.exe", "/d", "/s", "/c", cmd, ...args],
94
+ spawnOptions: { shell: false, windowsHide: true },
95
+ };
96
+ }
97
+ }
98
+ return {
99
+ argv: [cmd, ...args],
100
+ spawnOptions: { shell: false, windowsHide: true },
101
+ };
102
+ }
103
+
104
+ // ── Option helpers ──────────────────────────────────────────────────────────
105
+
106
+ type AnyOptions = { windowsHide?: boolean } | undefined;
107
+
108
+ /**
109
+ * Merge caller options with `windowsHide: true` as the default.
110
+ * Explicit `windowsHide: false` from the caller is honored (for the rare
111
+ * case where a visible console is actually desired).
112
+ */
113
+ function withHide<T extends AnyOptions>(opts: T): T & { windowsHide: boolean } {
114
+ const hide = opts?.windowsHide ?? true;
115
+ return { ...(opts ?? {}), windowsHide: hide } as T & { windowsHide: boolean };
116
+ }
117
+
118
+ // ── Synchronous wrappers ────────────────────────────────────────────────────
119
+
120
+ /** Wrapped `execSync`. Always `windowsHide: true` unless overridden. */
121
+ export function execSync(command: string, options: ExecSyncOptions & { encoding: BufferEncoding }): string;
122
+ export function execSync(command: string, options?: ExecSyncOptions): Buffer | string;
123
+ export function execSync(
124
+ command: string,
125
+ options?: ExecSyncOptions,
126
+ ): Buffer | string {
127
+ return nodeExecSync(command, withHide(options));
128
+ }
129
+
130
+ /** Wrapped `spawnSync`. Always `windowsHide: true` unless overridden. */
131
+ export function spawnSync<T extends string | Buffer = Buffer>(
132
+ command: string,
133
+ args?: readonly string[],
134
+ options?: SpawnSyncOptions,
135
+ ): SpawnSyncReturns<T> {
136
+ return nodeSpawnSync(command, args ?? [], withHide(options)) as SpawnSyncReturns<T>;
137
+ }
138
+
139
+ // ── Asynchronous (callback) wrappers ────────────────────────────────────────
140
+
141
+ /** Wrapped `exec` (callback form). */
142
+ export function exec(
143
+ command: string,
144
+ callback?: (err: Error | null, stdout: string, stderr: string) => void,
145
+ ): ChildProcess;
146
+ export function exec(
147
+ command: string,
148
+ options: ExecOptions,
149
+ callback?: (err: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void,
150
+ ): ChildProcess;
151
+ export function exec(
152
+ command: string,
153
+ optionsOrCallback?: ExecOptions | ((err: Error | null, stdout: any, stderr: any) => void),
154
+ maybeCallback?: (err: Error | null, stdout: any, stderr: any) => void,
155
+ ): ChildProcess {
156
+ if (typeof optionsOrCallback === "function") {
157
+ return nodeExec(command, withHide(undefined) as ExecOptions, optionsOrCallback);
158
+ }
159
+ return nodeExec(command, withHide(optionsOrCallback) as ExecOptions, maybeCallback);
160
+ }
161
+
162
+ /** Wrapped `execFile` (callback form). */
163
+ export function execFile(
164
+ file: string,
165
+ args: readonly string[] | undefined,
166
+ options: ExecFileOptions,
167
+ callback?: (err: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void,
168
+ ): ChildProcess;
169
+ export function execFile(
170
+ file: string,
171
+ args?: readonly string[],
172
+ callback?: (err: Error | null, stdout: string, stderr: string) => void,
173
+ ): ChildProcess;
174
+ export function execFile(
175
+ file: string,
176
+ args?: readonly string[],
177
+ optionsOrCallback?: ExecFileOptions | ((err: Error | null, stdout: any, stderr: any) => void),
178
+ maybeCallback?: (err: Error | null, stdout: any, stderr: any) => void,
179
+ ): ChildProcess {
180
+ if (typeof optionsOrCallback === "function") {
181
+ return nodeExecFile(file, args ?? [], withHide(undefined) as ExecFileOptions, optionsOrCallback);
182
+ }
183
+ return nodeExecFile(file, args ?? [], withHide(optionsOrCallback) as ExecFileOptions, maybeCallback);
184
+ }
185
+
186
+ /** Wrapped `spawn`. Always `windowsHide: true` unless overridden. */
187
+ export function spawn(
188
+ command: string,
189
+ args?: readonly string[],
190
+ options?: SpawnOptions,
191
+ ): ChildProcess {
192
+ return nodeSpawn(command, args ?? [], withHide(options));
193
+ }
194
+
195
+ // ── Promise-returning variants ──────────────────────────────────────────────
196
+
197
+ /** Promise-returning exec. */
198
+ export const execAsync = promisify(exec) as unknown as (
199
+ command: string,
200
+ options?: ExecOptions,
201
+ ) => Promise<{ stdout: string | Buffer; stderr: string | Buffer }>;
202
+
203
+ /** Promise-returning execFile. */
204
+ export const execFileAsync = promisify(execFile) as unknown as (
205
+ file: string,
206
+ args?: readonly string[],
207
+ options?: ExecFileOptions,
208
+ ) => Promise<{ stdout: string | Buffer; stderr: string | Buffer }>;
209
+
210
+ // ── Types pass-through for convenience ──────────────────────────────────────
211
+
212
+ export type {
213
+ ExecSyncOptions,
214
+ ExecOptions,
215
+ ExecFileOptions,
216
+ SpawnSyncOptions,
217
+ SpawnOptions,
218
+ ChildProcess,
219
+ SpawnSyncReturns,
220
+ };