@blackbelt-technology/pi-agent-dashboard 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/AGENTS.md +87 -114
  2. package/README.md +408 -430
  3. package/docs/architecture.md +465 -12
  4. package/package.json +10 -5
  5. package/packages/extension/package.json +14 -4
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +40 -8
  7. package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
  8. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  9. package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
  10. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
  11. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  12. package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
  13. package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
  14. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  15. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  16. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  17. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  18. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  19. package/packages/extension/src/ask-user-tool.ts +5 -4
  20. package/packages/extension/src/bridge.ts +171 -17
  21. package/packages/extension/src/dev-build.ts +1 -1
  22. package/packages/extension/src/git-info.ts +9 -19
  23. package/packages/extension/src/multiselect-list.ts +146 -0
  24. package/packages/extension/src/multiselect-polyfill.ts +43 -0
  25. package/packages/extension/src/pi-env.d.ts +1 -0
  26. package/packages/extension/src/process-scanner.ts +72 -38
  27. package/packages/extension/src/provider-register.ts +304 -16
  28. package/packages/extension/src/server-auto-start.ts +27 -1
  29. package/packages/extension/src/server-launcher.ts +83 -27
  30. package/packages/server/package.json +16 -2
  31. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  32. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  33. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  34. package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
  35. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  36. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  37. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  38. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  39. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  40. package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
  41. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  42. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  43. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  44. package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
  45. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  46. package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
  47. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  48. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  49. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  50. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  51. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  52. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
  53. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  54. package/packages/server/src/__tests__/pi-version-skew.test.ts +237 -0
  55. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  56. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  57. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  58. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  59. package/packages/server/src/__tests__/restart-helper.test.ts +111 -0
  60. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  61. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  62. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  63. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  64. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  65. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  66. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  67. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  68. package/packages/server/src/__tests__/tunnel.test.ts +13 -7
  69. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  70. package/packages/server/src/bootstrap-queue.ts +130 -0
  71. package/packages/server/src/bootstrap-state.ts +131 -0
  72. package/packages/server/src/browse.ts +8 -3
  73. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  74. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  75. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  76. package/packages/server/src/cli.ts +310 -39
  77. package/packages/server/src/config-api.ts +16 -0
  78. package/packages/server/src/directory-service.ts +270 -39
  79. package/packages/server/src/editor-detection.ts +12 -9
  80. package/packages/server/src/editor-manager.ts +19 -4
  81. package/packages/server/src/editor-pid-registry.ts +9 -8
  82. package/packages/server/src/editor-registry.ts +22 -25
  83. package/packages/server/src/git-operations.ts +1 -1
  84. package/packages/server/src/headless-pid-registry.ts +7 -20
  85. package/packages/server/src/home-lock-release.ts +72 -0
  86. package/packages/server/src/home-lock.ts +389 -0
  87. package/packages/server/src/node-guard.ts +52 -0
  88. package/packages/server/src/package-manager-wrapper.ts +207 -47
  89. package/packages/server/src/pi-core-checker.ts +1 -1
  90. package/packages/server/src/pi-core-updater.ts +7 -1
  91. package/packages/server/src/pi-resource-scanner.ts +5 -8
  92. package/packages/server/src/pi-version-skew.ts +207 -0
  93. package/packages/server/src/preferences-store.ts +17 -3
  94. package/packages/server/src/process-manager.ts +403 -222
  95. package/packages/server/src/provider-probe.ts +234 -0
  96. package/packages/server/src/restart-helper.ts +141 -0
  97. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  98. package/packages/server/src/routes/openspec-routes.ts +25 -1
  99. package/packages/server/src/routes/pi-core-routes.ts +24 -1
  100. package/packages/server/src/routes/provider-auth-routes.ts +8 -8
  101. package/packages/server/src/routes/provider-routes.ts +43 -0
  102. package/packages/server/src/routes/recommended-routes.ts +10 -12
  103. package/packages/server/src/routes/system-routes.ts +20 -33
  104. package/packages/server/src/routes/tool-routes.ts +153 -0
  105. package/packages/server/src/server-pid.ts +5 -9
  106. package/packages/server/src/server.ts +211 -10
  107. package/packages/server/src/session-api.ts +77 -8
  108. package/packages/server/src/session-bootstrap.ts +17 -3
  109. package/packages/server/src/session-diff.ts +21 -21
  110. package/packages/server/src/terminal-manager.ts +61 -20
  111. package/packages/server/src/tunnel.ts +42 -28
  112. package/packages/shared/package.json +10 -3
  113. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  114. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  115. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  116. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  117. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  118. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  119. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  120. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  121. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  122. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  123. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  124. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  125. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  126. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  127. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  128. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  129. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  130. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  131. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  132. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  133. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  134. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  135. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  136. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  137. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  138. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  139. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  140. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  141. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  142. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  143. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  144. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  145. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  146. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  147. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  148. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  149. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  150. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  151. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  152. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  153. package/packages/shared/src/__tests__/config.test.ts +56 -0
  154. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  155. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  156. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  157. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  158. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  159. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
  160. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
  161. package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
  162. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  163. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  164. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  165. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  166. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  167. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  168. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  169. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  170. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  171. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  172. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  173. package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
  174. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  175. package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
  176. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  177. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  178. package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
  179. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  180. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  181. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  182. package/packages/shared/src/bootstrap-install.ts +212 -0
  183. package/packages/shared/src/bridge-register.ts +87 -20
  184. package/packages/shared/src/browser-protocol.ts +71 -1
  185. package/packages/shared/src/config.ts +87 -15
  186. package/packages/shared/src/managed-paths.ts +31 -4
  187. package/packages/shared/src/openspec-poller.ts +63 -46
  188. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  189. package/packages/shared/src/platform/commands.ts +100 -0
  190. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  191. package/packages/shared/src/platform/exec.ts +220 -0
  192. package/packages/shared/src/platform/git.ts +155 -0
  193. package/packages/shared/src/platform/index.ts +16 -0
  194. package/packages/shared/src/platform/node-spawn.ts +154 -0
  195. package/packages/shared/src/platform/npm.ts +162 -0
  196. package/packages/shared/src/platform/openspec.ts +91 -0
  197. package/packages/shared/src/platform/paths.ts +276 -0
  198. package/packages/shared/src/platform/process-identify.ts +126 -0
  199. package/packages/shared/src/platform/process-scan.ts +94 -0
  200. package/packages/shared/src/platform/process.ts +168 -0
  201. package/packages/shared/src/platform/runner.ts +369 -0
  202. package/packages/shared/src/platform/shell.ts +44 -0
  203. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  204. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  205. package/packages/shared/src/protocol.ts +23 -0
  206. package/packages/shared/src/recommended-extensions.ts +18 -2
  207. package/packages/shared/src/resolve-jiti.ts +62 -3
  208. package/packages/shared/src/rest-api.ts +26 -0
  209. package/packages/shared/src/semaphore.ts +83 -0
  210. package/packages/shared/src/state-replay.ts +9 -0
  211. package/packages/shared/src/tool-registry/definitions.ts +434 -0
  212. package/packages/shared/src/tool-registry/index.ts +56 -0
  213. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  214. package/packages/shared/src/tool-registry/registry.ts +262 -0
  215. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  216. package/packages/shared/src/tool-registry/types.ts +180 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Cross-platform process enumeration primitives: is-process-running,
3
+ * ps/tasklist pattern-matching, elapsed-time parsing.
4
+ *
5
+ * Every OS-dependent helper accepts injectable `platform` and `exec`
6
+ * parameters (defaulting to `process.platform` and `execSync`), so tests
7
+ * can exercise both branches without mutating the global `process.platform`.
8
+ * See change: consolidate-platform-handlers.
9
+ */
10
+
11
+ import { execSync } from "./exec.js";
12
+
13
+ type ExecFn = (cmd: string, opts: { encoding: "utf-8"; stdio?: any }) => string;
14
+
15
+ export interface ProcessScanOpts {
16
+ /** Override platform (defaults to process.platform). */
17
+ platform?: NodeJS.Platform;
18
+ /** Override execSync (for tests). */
19
+ exec?: ExecFn;
20
+ }
21
+
22
+ function defaultExec(cmd: string, opts: { encoding: "utf-8"; stdio?: any }): string {
23
+ return execSync(cmd, { ...opts, windowsHide: true }) as unknown as string;
24
+ }
25
+
26
+ // ── Elapsed-time parsing (pure, platform-agnostic) ──────────────────────────
27
+
28
+ /**
29
+ * Parse `ps -o etime=` format into milliseconds. Handles:
30
+ * - `mm:ss` (e.g. "02:15" → 135000)
31
+ * - `hh:mm:ss` (e.g. "01:30:00" → 5400000)
32
+ * - `dd-hh:mm:ss` (e.g. "2-03:00:00" → 183600000)
33
+ *
34
+ * Returns 0 for empty or unparseable input.
35
+ */
36
+ export function parseEtime(etime: string): number {
37
+ const trimmed = etime.trim();
38
+ if (!trimmed) return 0;
39
+
40
+ let days = 0;
41
+ let rest = trimmed;
42
+
43
+ const dashIdx = rest.indexOf("-");
44
+ if (dashIdx !== -1) {
45
+ days = parseInt(rest.slice(0, dashIdx), 10);
46
+ if (isNaN(days)) return 0;
47
+ rest = rest.slice(dashIdx + 1);
48
+ }
49
+
50
+ const parts = rest.split(":").map((p) => parseInt(p, 10));
51
+ if (parts.some(isNaN)) return 0;
52
+
53
+ let hours = 0, minutes = 0, seconds = 0;
54
+ if (parts.length === 3) {
55
+ [hours, minutes, seconds] = parts;
56
+ } else if (parts.length === 2) {
57
+ [minutes, seconds] = parts;
58
+ } else {
59
+ return 0;
60
+ }
61
+
62
+ return ((days * 86400) + (hours * 3600) + (minutes * 60) + seconds) * 1000;
63
+ }
64
+
65
+ // ── Process-running check ───────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Check whether a process matching `pattern` is currently running.
69
+ * - win32: `tasklist /FI "IMAGENAME eq <pattern>" /NH` — pattern is the
70
+ * executable image name (e.g. "Code.exe"). Returns true if the
71
+ * output contains the pattern.
72
+ * - unix: `pgrep -f "<pattern>"` — pattern is any substring of the
73
+ * command-line (e.g. "/Applications/Zed.app"). Returns true if
74
+ * pgrep exits with code 0 (at least one match).
75
+ *
76
+ * Best-effort: any failure returns `false`.
77
+ */
78
+ export function isProcessRunning(pattern: string, opts: ProcessScanOpts = {}): boolean {
79
+ const platform = opts.platform ?? process.platform;
80
+ const exec = opts.exec ?? defaultExec;
81
+ try {
82
+ if (platform === "win32") {
83
+ const result = exec(`tasklist /FI "IMAGENAME eq ${pattern}" /NH`, {
84
+ encoding: "utf-8",
85
+ stdio: "pipe",
86
+ });
87
+ return String(result).includes(pattern);
88
+ }
89
+ exec(`pgrep -f "${pattern}"`, { encoding: "utf-8", stdio: "pipe" });
90
+ return true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Cross-platform process primitives: port cleanup, kill, liveness, group-kill.
3
+ *
4
+ * Every OS-dependent helper takes an optional `platform` parameter
5
+ * (defaulting to `process.platform`) so tests can exercise both branches
6
+ * without mutating the global `process.platform`. See change:
7
+ * consolidate-platform-handlers.
8
+ */
9
+
10
+ import { execSync } from "./exec.js";
11
+
12
+ export type ExecFn = (cmd: string, opts: { encoding: "utf-8" }) => string;
13
+ export type KillFn = (pid: number, signal: NodeJS.Signals | number) => void;
14
+
15
+ export interface ProcessOpts {
16
+ /** Override platform (defaults to process.platform). */
17
+ platform?: NodeJS.Platform;
18
+ /** Override execSync (for tests). */
19
+ exec?: ExecFn;
20
+ /** Override process.kill (for tests). */
21
+ kill?: KillFn;
22
+ }
23
+
24
+ function defaultExec(cmd: string, opts: { encoding: "utf-8" }): string {
25
+ // Always suppress the cmd.exe window flash on Windows. The primitives that
26
+ // use this (findPortHolders via netstat, killProcess via taskkill) don't
27
+ // need user visibility.
28
+ return execSync(cmd, { ...opts, windowsHide: true }) as unknown as string;
29
+ }
30
+
31
+ function defaultKill(pid: number, signal: NodeJS.Signals | number): void {
32
+ process.kill(pid, signal);
33
+ }
34
+
35
+ // ── Port-holder detection ────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Parse `netstat -ano -p tcp` output for PIDs listening on a port (Windows).
39
+ * Pure function, exported for testing.
40
+ *
41
+ * Example input line:
42
+ * " TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING 12345"
43
+ */
44
+ export function parseNetstatListeners(output: string, port: number, selfPid: number): number[] {
45
+ const pids: number[] = [];
46
+ const portSuffix = `:${port}`;
47
+ for (const line of output.split(/\r?\n/)) {
48
+ const trimmed = line.trim();
49
+ if (!trimmed || !/^\s*TCP/i.test(line)) continue;
50
+ if (!/LISTENING/i.test(line)) continue;
51
+ const cols = trimmed.split(/\s+/);
52
+ if (cols.length < 5) continue;
53
+ const local = cols[1];
54
+ if (!local.endsWith(portSuffix)) continue;
55
+ const pid = Number.parseInt(cols[cols.length - 1], 10);
56
+ if (Number.isFinite(pid) && pid > 0 && pid !== selfPid) pids.push(pid);
57
+ }
58
+ return pids;
59
+ }
60
+
61
+ /**
62
+ * Find PIDs holding a TCP port. Cross-platform:
63
+ * - win32: `netstat -ano -p tcp` → parse LISTENING rows
64
+ * - unix: `lsof -t -i :<port> -sTCP:LISTEN`
65
+ *
66
+ * Best-effort: any failure returns []. Excludes the current process PID.
67
+ */
68
+ export function findPortHolders(port: number, opts: ProcessOpts = {}): number[] {
69
+ const platform = opts.platform ?? process.platform;
70
+ const exec = opts.exec ?? defaultExec;
71
+ try {
72
+ if (platform === "win32") {
73
+ const output = exec("netstat -ano -p tcp", { encoding: "utf-8" });
74
+ return parseNetstatListeners(String(output), port, process.pid);
75
+ }
76
+ const output = exec(`lsof -t -i :${port} -sTCP:LISTEN 2>/dev/null`, { encoding: "utf-8" });
77
+ return String(output).trim().split("\n").map(Number).filter((n) => n > 0 && n !== process.pid);
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+
83
+ // ── Liveness ─────────────────────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Check whether a PID is alive. Cross-platform via `process.kill(pid, 0)`.
87
+ */
88
+ export function isProcessAlive(pid: number, opts: { kill?: KillFn } = {}): boolean {
89
+ const kill = opts.kill ?? defaultKill;
90
+ try {
91
+ kill(pid, 0);
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ // ── Termination ──────────────────────────────────────────────────────────────
99
+
100
+ export interface KillProcessResult {
101
+ ok: boolean;
102
+ forced: boolean;
103
+ }
104
+
105
+ /**
106
+ * Terminate a process, cross-platform:
107
+ * - win32: `taskkill /F /T /PID <pid>` (tree kill, immediate)
108
+ * - unix: SIGTERM → wait up to `timeoutMs` → SIGKILL if still alive
109
+ *
110
+ * Returns `{ ok, forced }`. `ok` is true if the process was terminated (or
111
+ * was already dead); `forced` is true if SIGKILL was needed on Unix.
112
+ */
113
+ export async function killProcess(
114
+ pid: number,
115
+ opts: ProcessOpts & { timeoutMs?: number } = {},
116
+ ): Promise<KillProcessResult> {
117
+ const platform = opts.platform ?? process.platform;
118
+ const exec = opts.exec ?? defaultExec;
119
+ const kill = opts.kill ?? defaultKill;
120
+ const timeoutMs = opts.timeoutMs ?? 5000;
121
+
122
+ if (!isProcessAlive(pid, { kill })) return { ok: false, forced: false };
123
+
124
+ if (platform === "win32") {
125
+ try {
126
+ exec(`taskkill /F /T /PID ${pid}`, { encoding: "utf-8" });
127
+ return { ok: true, forced: false };
128
+ } catch {
129
+ return { ok: false, forced: false };
130
+ }
131
+ }
132
+
133
+ try {
134
+ kill(pid, "SIGTERM");
135
+ } catch {
136
+ return { ok: false, forced: false };
137
+ }
138
+
139
+ const deadline = Date.now() + timeoutMs;
140
+ while (Date.now() < deadline) {
141
+ await new Promise((r) => setTimeout(r, 200));
142
+ if (!isProcessAlive(pid, { kill })) return { ok: true, forced: false };
143
+ }
144
+ try {
145
+ kill(pid, "SIGKILL");
146
+ } catch {
147
+ /* already dead */
148
+ }
149
+ return { ok: true, forced: true };
150
+ }
151
+
152
+ // ── Process-group kill (for detached children) ───────────────────────────────
153
+
154
+ /**
155
+ * Signal a process, targeting the process group on Unix (negative PID) and
156
+ * the PID directly on Windows. Used for detached children spawned with their
157
+ * own process group.
158
+ */
159
+ export function killPidWithGroup(
160
+ pid: number,
161
+ signal: NodeJS.Signals,
162
+ opts: ProcessOpts = {},
163
+ ): void {
164
+ const platform = opts.platform ?? process.platform;
165
+ const kill = opts.kill ?? defaultKill;
166
+ const target = platform === "win32" ? pid : -pid;
167
+ kill(target, signal);
168
+ }
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Recipe runner — the engine that executes structured subprocess Recipes.
3
+ *
4
+ * A Recipe is pure data: it describes *what* to run (argv from input),
5
+ * *how to parse* the stdout, and policy (timeout, tolerated exit codes).
6
+ * The runner owns *how to spawn*: binary resolution via `ToolResolver`,
7
+ * always-safe defaults (`windowsHide: true`, no shell interpolation),
8
+ * timeout enforcement, and uniform error normalization to `Result<T>`.
9
+ *
10
+ * Tool modules (`platform/git.ts`, `platform/openspec.ts`, `platform/npm.ts`)
11
+ * declare Recipes and call `run()`. They never touch `child_process`,
12
+ * `process.platform`, or `windowsHide`.
13
+ *
14
+ * See change: platform-command-executor.
15
+ */
16
+ import path from "node:path";
17
+ import { existsSync } from "node:fs";
18
+ import { spawnSync, spawn, buildSafeArgv } from "./exec.js";
19
+ import { ToolResolver } from "./binary-lookup.js";
20
+ // The tool registry publishes itself on a well-known `globalThis` symbol
21
+ // when `getDefaultRegistry()` is first called from any consumer. The
22
+ // runner reads that global to avoid a load-order cycle (tool-registry
23
+ // → platform/npm.ts → this file) that would otherwise trip Node's
24
+ // ESM/CJS translator with ERR_INTERNAL_ASSERTION on certain boots.
25
+ // See change: consolidate-tool-resolution.
26
+
27
+ // ── Types ───────────────────────────────────────────────────────────────────
28
+
29
+ /** A Recipe is a pure-data description of a subprocess operation. */
30
+ export interface Recipe<Input, Output> {
31
+ /** Build the command + args from the typed input. First element is the command name. */
32
+ argv: (input: Input) => readonly string[];
33
+ /** Parse stdout (and optionally the input) into the typed result. */
34
+ parse: (stdout: string, input: Input) => Output;
35
+ /** Per-recipe timeout override (default: 5000ms). */
36
+ timeout?: number;
37
+ /**
38
+ * Exit codes to treat as "empty success" instead of an error. Useful for
39
+ * commands like `git diff` that exit 1 when there's no diff.
40
+ */
41
+ tolerate?: readonly number[];
42
+ }
43
+
44
+ /** Context passed to `run()` alongside the input. */
45
+ export interface RunCtx {
46
+ /** Working directory for the spawn. */
47
+ cwd?: string;
48
+ /** Environment variables (merged over process.env). */
49
+ env?: NodeJS.ProcessEnv;
50
+ /** Override timeout for this call (takes precedence over recipe.timeout). */
51
+ timeout?: number;
52
+ }
53
+
54
+ /** Discriminated error type surfaced by `run()`. */
55
+ export type ExecError =
56
+ | { kind: "not-found"; binary: string }
57
+ | { kind: "timeout"; timeoutMs: number; binary: string }
58
+ | { kind: "exit"; code: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }
59
+ | { kind: "spawn-failure"; message: string };
60
+
61
+ /** Typed Result — no thrown exceptions for the 4 error kinds above. */
62
+ export type Result<T> = { ok: true; value: T } | { ok: false; error: ExecError };
63
+
64
+ // ── Resolver cache ──────────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * Low-level ToolResolver kept as the fallback for unregistered binary
68
+ * names. Registered names flow through the shared `ToolRegistry` so
69
+ * user overrides apply uniformly to every Recipe.
70
+ * See change: consolidate-tool-resolution.
71
+ */
72
+ const sharedResolver = new ToolResolver({
73
+ processExecPath: process.execPath,
74
+ useLoginShell: true,
75
+ });
76
+
77
+ /**
78
+ * Test-only hook: invalidate the registry cache. Preserved as a thin
79
+ * shim over `registry.rescan()` so existing test suites keep working
80
+ * after migrating away from the runner's private `resolverCache`.
81
+ */
82
+ export function resetResolverCache(): void {
83
+ try {
84
+ const reg = tryGetRegistry();
85
+ if (reg) reg.rescan();
86
+ } catch { /* isolated tests */ }
87
+ }
88
+
89
+ // Lazy registry accessor via `globalThis` symbol. The tool-registry
90
+ // module writes itself there inside `getDefaultRegistry()`. Returns
91
+ // `null` until some consumer (e.g. the server's `/api/tools` route or
92
+ // the package-manager wrapper) constructs the registry; the runner
93
+ // then falls back to `ToolResolver.which()` for that single call.
94
+ interface LazyRegistry {
95
+ has(n: string): boolean;
96
+ resolve(n: string): { ok: boolean; path: string | null };
97
+ resolveExecutor(n: string): { ok: boolean; argv: string[] };
98
+ rescan(): void;
99
+ }
100
+ const GLOBAL_REGISTRY_KEY = Symbol.for("pi-dashboard.tool-registry");
101
+ function tryGetRegistry(): LazyRegistry | null {
102
+ const reg = (globalThis as unknown as { [k: symbol]: LazyRegistry | undefined })[GLOBAL_REGISTRY_KEY];
103
+ return reg ?? null;
104
+ }
105
+
106
+ /**
107
+ * Is the argv[0] already a filesystem path (absolute or relative)? Then the
108
+ * caller supplied the binary directly and we should not try to resolve it
109
+ * via PATH/where/which — just use it as-is.
110
+ */
111
+ function isPathLike(cmd: string): boolean {
112
+ if (path.isAbsolute(cmd)) return true;
113
+ if (cmd.startsWith("./") || cmd.startsWith("../")) return true;
114
+ if (cmd.startsWith(".\\") || cmd.startsWith("..\\")) return true;
115
+ return false;
116
+ }
117
+
118
+ /**
119
+ * Resolve a binary name to an absolute path.
120
+ *
121
+ * Strategy:
122
+ * 1. Path-like argv (absolute / relative) → use as-is if it exists.
123
+ * 2. Name is registered in `ToolRegistry` → delegate to the registry
124
+ * so overrides, managed strategies, and diagnostics apply
125
+ * uniformly. The registry has its own per-instance cache; the
126
+ * runner no longer maintains a private `resolverCache`.
127
+ * 3. Name is not registered → fall back to `ToolResolver.which` for
128
+ * ad-hoc binaries (zrok, code-server, custom tools) that the
129
+ * dashboard hasn't formally declared.
130
+ *
131
+ * Imported lazily from `../tool-registry/index.js` to keep the runner
132
+ * usable at module-init time even if the registry hasn't finished
133
+ * loading its overrides yet.
134
+ */
135
+ function resolveBinary(name: string): string | null {
136
+ if (isPathLike(name)) {
137
+ if (existsSync(name)) return name;
138
+ return null;
139
+ }
140
+ // Registered tools flow through the registry (overrides + diagnostics).
141
+ // The `tool-registry` module imports this file transitively via
142
+ // `platform/npm.ts`; the cycle is benign at function-call time because
143
+ // every module has finished evaluating by the time `resolveBinary` is
144
+ // first invoked (it's called only from inside `run()`).
145
+ const registry = tryGetRegistry();
146
+ if (registry && registry.has(name)) {
147
+ const resolved = registry.resolve(name);
148
+ return resolved.ok ? resolved.path : null;
149
+ }
150
+ return sharedResolver.which(name);
151
+ }
152
+
153
+ /**
154
+ * Resolve a Recipe's argv[0] to a spawn-ready argv via the tool
155
+ * registry's `resolveExecutor`. This is the path that lets `npm`,
156
+ * `openspec`, `pi` all resolve to `[node.exe, <script>.js]` on
157
+ * Windows — bypassing `.cmd` shims and the console-flash chain.
158
+ *
159
+ * Returns `null` when the binary is unknown AND not on PATH.
160
+ *
161
+ * Non-registered names fall back to `ToolResolver.which()` (single
162
+ * path, no executor wrapping). Path-like names (absolute/relative
163
+ * paths) are trusted as-is.
164
+ */
165
+ function resolveExecutorArgv(name: string, recipeArgs: readonly string[]): string[] | null {
166
+ if (isPathLike(name)) {
167
+ if (existsSync(name)) return [name, ...recipeArgs];
168
+ return null;
169
+ }
170
+ const registry = tryGetRegistry();
171
+ if (registry && registry.has(name)) {
172
+ const exec = registry.resolveExecutor(name);
173
+ if (exec.ok && exec.argv.length > 0) {
174
+ return [...exec.argv, ...recipeArgs];
175
+ }
176
+ return null;
177
+ }
178
+ const p = sharedResolver.which(name);
179
+ return p ? [p, ...recipeArgs] : null;
180
+ }
181
+
182
+ // ── The engine ──────────────────────────────────────────────────────────────
183
+
184
+ const DEFAULT_TIMEOUT_MS = 5000;
185
+
186
+ /**
187
+ * Execute a Recipe against a typed input. Returns a `Result<Output>`.
188
+ * Never throws for recognized error conditions (not-found / timeout /
189
+ * exit / spawn-failure) — surfaces them as typed errors instead.
190
+ */
191
+ export function run<Input, Output>(
192
+ recipe: Recipe<Input, Output>,
193
+ input: Input,
194
+ ctx: RunCtx = {},
195
+ ): Result<Output> {
196
+ const argv = recipe.argv(input);
197
+ if (argv.length === 0) {
198
+ return { ok: false, error: { kind: "spawn-failure", message: "Recipe produced empty argv" } };
199
+ }
200
+
201
+ const [rawCmd, ...recipeArgs] = argv;
202
+ const execArgv = resolveExecutorArgv(rawCmd, recipeArgs);
203
+ if (!execArgv) {
204
+ return { ok: false, error: { kind: "not-found", binary: rawCmd } };
205
+ }
206
+
207
+ const timeout = ctx.timeout ?? recipe.timeout ?? DEFAULT_TIMEOUT_MS;
208
+
209
+ // Route every command through `buildSafeArgv` — the canonical
210
+ // Windows-safe subprocess invocation. `execArgv` is already
211
+ // `[node.exe, <script>.js, ...args]` for executor-kind tools, so
212
+ // buildSafeArgv sees node.exe (.exe → direct spawn) and returns
213
+ // the argv unchanged. For non-executor tools resolving to `.cmd`,
214
+ // buildSafeArgv wraps in `cmd.exe /d /s /c`.
215
+ //
216
+ // See change: consolidate-windows-spawn-and-platform-handlers.
217
+ const [execCmd, ...execArgs] = execArgv;
218
+ const { argv: safeArgv, spawnOptions } = buildSafeArgv(execCmd, execArgs);
219
+
220
+ try {
221
+ const result = spawnSync(safeArgv[0], safeArgv.slice(1), {
222
+ cwd: ctx.cwd,
223
+ env: ctx.env ? { ...process.env, ...ctx.env } : undefined,
224
+ encoding: "utf-8",
225
+ timeout,
226
+ stdio: ["pipe", "pipe", "pipe"],
227
+ ...spawnOptions, // shell: false, windowsHide: true
228
+ });
229
+
230
+ // spawnSync error path: either it set .error (e.g. spawn failure) or
231
+ // it timed out (in which case signal === "SIGTERM" on Node >= 15).
232
+ if (result.error) {
233
+ const err = result.error as NodeJS.ErrnoException;
234
+ if (err.code === "ETIMEDOUT" || err.message?.includes("ETIMEDOUT")) {
235
+ return { ok: false, error: { kind: "timeout", timeoutMs: timeout, binary: rawCmd } };
236
+ }
237
+ return { ok: false, error: { kind: "spawn-failure", message: err.message } };
238
+ }
239
+
240
+ // Node's spawnSync signals timeout by setting signal = SIGTERM and status = null.
241
+ if (result.status === null && result.signal) {
242
+ return { ok: false, error: { kind: "timeout", timeoutMs: timeout, binary: rawCmd } };
243
+ }
244
+
245
+ const stdout = typeof result.stdout === "string" ? result.stdout : String(result.stdout ?? "");
246
+ const stderr = typeof result.stderr === "string" ? result.stderr : String(result.stderr ?? "");
247
+
248
+ const status = result.status;
249
+ const tolerated = status !== 0 && recipe.tolerate?.includes(status ?? -1);
250
+ if (status === 0 || tolerated) {
251
+ return { ok: true, value: recipe.parse(stdout, input) };
252
+ }
253
+ return { ok: false, error: { kind: "exit", code: status, signal: result.signal, stdout, stderr } };
254
+ } catch (err) {
255
+ return {
256
+ ok: false,
257
+ error: { kind: "spawn-failure", message: err instanceof Error ? err.message : String(err) },
258
+ };
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Async sibling of `run()`. Same Recipe contract, same binary
264
+ * resolution, same `.cmd`/shell handling, same error normalization
265
+ * — but spawns via `platform/exec.ts`'s wrapped `spawn` (with stdout
266
+ * captured to a Promise) instead of `spawnSync`, so callers can run
267
+ * many recipes concurrently without blocking the event loop.
268
+ *
269
+ * Use this from server code paths that iterate over many inputs (e.g.
270
+ * `openspec status --change <name>` across ~20 changes). The sync
271
+ * `run()` is fine for one-off calls or for callers that must stay
272
+ * sync (the bridge extension's sync hooks).
273
+ *
274
+ * `windowsHide: true` comes from the shared `spawn` wrapper — the
275
+ * same invariant the sync runner relies on. Do not re-introduce a
276
+ * bare `child_process.spawn` elsewhere.
277
+ *
278
+ * See change: consolidate-tool-resolution (async runner follow-up).
279
+ */
280
+ export function runAsync<Input, Output>(
281
+ recipe: Recipe<Input, Output>,
282
+ input: Input,
283
+ ctx: RunCtx = {},
284
+ ): Promise<Result<Output>> {
285
+ const argv = recipe.argv(input);
286
+ if (argv.length === 0) {
287
+ return Promise.resolve({ ok: false, error: { kind: "spawn-failure", message: "Recipe produced empty argv" } });
288
+ }
289
+
290
+ const [rawCmd, ...recipeArgs] = argv;
291
+ const execArgv = resolveExecutorArgv(rawCmd, recipeArgs);
292
+ if (!execArgv) {
293
+ return Promise.resolve({ ok: false, error: { kind: "not-found", binary: rawCmd } });
294
+ }
295
+
296
+ const timeout = ctx.timeout ?? recipe.timeout ?? DEFAULT_TIMEOUT_MS;
297
+
298
+ // Executor-kind tools resolve to `[node.exe, script.js, ...]` on
299
+ // Windows so buildSafeArgv's `.cmd` wrapping is a no-op here — pure
300
+ // node.exe spawn, no cmd.exe in the chain.
301
+ const [execCmd, ...execArgs] = execArgv;
302
+ const { argv: safeArgv, spawnOptions } = buildSafeArgv(execCmd, execArgs);
303
+
304
+ return new Promise<Result<Output>>((resolve) => {
305
+ let stdout = "";
306
+ let stderr = "";
307
+ let settled = false;
308
+ const settle = (r: Result<Output>) => {
309
+ if (settled) return;
310
+ settled = true;
311
+ resolve(r);
312
+ };
313
+
314
+ let child: import("node:child_process").ChildProcess;
315
+ try {
316
+ child = spawn(safeArgv[0], safeArgv.slice(1), {
317
+ cwd: ctx.cwd,
318
+ env: ctx.env ? { ...process.env, ...ctx.env } : undefined,
319
+ stdio: ["ignore", "pipe", "pipe"],
320
+ ...spawnOptions, // shell: false, windowsHide: true
321
+ });
322
+ } catch (err) {
323
+ settle({ ok: false, error: { kind: "spawn-failure", message: err instanceof Error ? err.message : String(err) } });
324
+ return;
325
+ }
326
+
327
+ const timer = setTimeout(() => {
328
+ try { child.kill("SIGTERM"); } catch { /* ignore */ }
329
+ settle({ ok: false, error: { kind: "timeout", timeoutMs: timeout, binary: rawCmd } });
330
+ }, timeout);
331
+
332
+ child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf-8"); });
333
+ child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf-8"); });
334
+
335
+ child.on("error", (err: NodeJS.ErrnoException) => {
336
+ clearTimeout(timer);
337
+ if (err.code === "ETIMEDOUT" || err.message?.includes("ETIMEDOUT")) {
338
+ settle({ ok: false, error: { kind: "timeout", timeoutMs: timeout, binary: rawCmd } });
339
+ return;
340
+ }
341
+ settle({ ok: false, error: { kind: "spawn-failure", message: err.message } });
342
+ });
343
+
344
+ child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
345
+ clearTimeout(timer);
346
+ const tolerated = code !== 0 && code !== null && recipe.tolerate?.includes(code);
347
+ if (code === 0 || tolerated) {
348
+ try {
349
+ settle({ ok: true, value: recipe.parse(stdout, input) });
350
+ } catch (err) {
351
+ settle({ ok: false, error: { kind: "spawn-failure", message: err instanceof Error ? err.message : String(err) } });
352
+ }
353
+ return;
354
+ }
355
+ settle({ ok: false, error: { kind: "exit", code, signal, stdout, stderr } });
356
+ });
357
+ });
358
+ }
359
+ // ── Helpers ─────────────────────────────────────────────────────────────────
360
+
361
+ /**
362
+ * Get the value or a fallback. Use when the caller doesn't care about the
363
+ * error discriminant (best-effort operations).
364
+ *
365
+ * const branch = unwrap(git.currentBranch({ cwd }), null);
366
+ */
367
+ export function unwrap<T>(result: Result<T>, fallback: T): T {
368
+ return result.ok ? result.value : fallback;
369
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Cross-platform shell and terminal-environment primitives.
3
+ *
4
+ * `detectShell` and `getTerminalEnvHints` accept an injectable `platform`
5
+ * and `env` parameters (defaulting to `process.platform` and `process.env`)
6
+ * so tests can exercise both branches without global mutation.
7
+ * See change: consolidate-platform-handlers.
8
+ */
9
+
10
+ export interface ShellOpts {
11
+ /** Override platform (defaults to process.platform). */
12
+ platform?: NodeJS.Platform;
13
+ /** Override env (defaults to process.env). */
14
+ env?: NodeJS.ProcessEnv;
15
+ }
16
+
17
+ /**
18
+ * Detect the appropriate shell for the current platform:
19
+ * - win32: `%COMSPEC%` if set, else `"powershell.exe"`
20
+ * - unix: `$SHELL` if set, else `"/bin/bash"`
21
+ */
22
+ export function detectShell(opts: ShellOpts = {}): string {
23
+ const platform = opts.platform ?? process.platform;
24
+ const env = opts.env ?? process.env;
25
+ if (platform === "win32") {
26
+ return env.COMSPEC || "powershell.exe";
27
+ }
28
+ return env.SHELL || "/bin/bash";
29
+ }
30
+
31
+ /**
32
+ * Extra environment variables to set when spawning a PTY, per platform.
33
+ * Currently only Windows sets `TERM=cygwin` (when not already set) so that
34
+ * curses/readline-style apps render correctly in node-pty on Windows.
35
+ */
36
+ export function getTerminalEnvHints(opts: ShellOpts = {}): Record<string, string> {
37
+ const platform = opts.platform ?? process.platform;
38
+ const env = opts.env ?? process.env;
39
+ const hints: Record<string, string> = {};
40
+ if (platform === "win32" && !env.TERM) {
41
+ hints.TERM = "cygwin";
42
+ }
43
+ return hints;
44
+ }