@blackbelt-technology/pi-agent-dashboard 0.2.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/AGENTS.md +64 -8
  2. package/README.md +308 -101
  3. package/docs/architecture.md +515 -16
  4. package/package.json +14 -7
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
  7. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  8. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
  9. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  10. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  11. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  12. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  13. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  14. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  15. package/packages/extension/src/ask-user-tool.ts +289 -20
  16. package/packages/extension/src/bridge.ts +107 -6
  17. package/packages/extension/src/command-handler.ts +34 -39
  18. package/packages/extension/src/dev-build.ts +1 -1
  19. package/packages/extension/src/git-info.ts +9 -19
  20. package/packages/extension/src/pi-env.d.ts +1 -0
  21. package/packages/extension/src/process-scanner.ts +72 -38
  22. package/packages/extension/src/prompt-expander.ts +25 -4
  23. package/packages/extension/src/provider-register.ts +304 -16
  24. package/packages/extension/src/server-auto-start.ts +27 -1
  25. package/packages/extension/src/server-launcher.ts +71 -27
  26. package/packages/server/package.json +17 -2
  27. package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
  28. package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
  29. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  30. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  31. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  32. package/packages/server/src/__tests__/browse-endpoint.test.ts +246 -10
  33. package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
  34. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  35. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  36. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  37. package/packages/server/src/__tests__/cors.test.ts +34 -2
  38. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  39. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  40. package/packages/server/src/__tests__/editor-manager-pid-registry.test.ts +168 -0
  41. package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
  42. package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
  43. package/packages/server/src/__tests__/editor-registry.test.ts +29 -15
  44. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  45. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  46. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  47. package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
  48. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  49. package/packages/server/src/__tests__/git-operations.test.ts +9 -7
  50. package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
  51. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  52. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  53. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  54. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  55. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  56. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
  57. package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
  58. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +126 -0
  59. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  60. package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
  61. package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
  62. package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
  63. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  64. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  65. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  66. package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
  67. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  68. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  69. package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
  70. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  71. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  72. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  74. package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
  75. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
  76. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
  77. package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
  78. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  79. package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
  80. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  81. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  82. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  83. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  84. package/packages/server/src/__tests__/tunnel.test.ts +103 -6
  85. package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
  86. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  87. package/packages/server/src/bootstrap-queue.ts +130 -0
  88. package/packages/server/src/bootstrap-state.ts +131 -0
  89. package/packages/server/src/browse.ts +108 -9
  90. package/packages/server/src/browser-gateway.ts +16 -3
  91. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  92. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  93. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  94. package/packages/server/src/cli.ts +256 -32
  95. package/packages/server/src/config-api.ts +16 -0
  96. package/packages/server/src/directory-service.ts +270 -39
  97. package/packages/server/src/editor-detection.ts +12 -9
  98. package/packages/server/src/editor-manager.ts +39 -5
  99. package/packages/server/src/editor-pid-registry.ts +199 -0
  100. package/packages/server/src/editor-registry.ts +22 -25
  101. package/packages/server/src/fix-pty-permissions.ts +44 -0
  102. package/packages/server/src/git-operations.ts +1 -1
  103. package/packages/server/src/headless-pid-registry.ts +16 -20
  104. package/packages/server/src/home-lock-release.ts +72 -0
  105. package/packages/server/src/home-lock.ts +389 -0
  106. package/packages/server/src/node-guard.ts +52 -0
  107. package/packages/server/src/npm-search-proxy.ts +71 -0
  108. package/packages/server/src/openspec-tasks.ts +158 -0
  109. package/packages/server/src/package-manager-wrapper.ts +225 -34
  110. package/packages/server/src/pi-core-checker.ts +290 -0
  111. package/packages/server/src/pi-core-updater.ts +172 -0
  112. package/packages/server/src/pi-gateway.ts +7 -0
  113. package/packages/server/src/pi-resource-scanner.ts +5 -8
  114. package/packages/server/src/pi-version-skew.ts +196 -0
  115. package/packages/server/src/preferences-store.ts +17 -3
  116. package/packages/server/src/process-manager.ts +403 -222
  117. package/packages/server/src/provider-probe.ts +234 -0
  118. package/packages/server/src/restart-helper.ts +130 -0
  119. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  120. package/packages/server/src/routes/file-routes.ts +30 -3
  121. package/packages/server/src/routes/openspec-routes.ts +107 -1
  122. package/packages/server/src/routes/pi-core-routes.ts +140 -0
  123. package/packages/server/src/routes/provider-auth-routes.ts +12 -10
  124. package/packages/server/src/routes/provider-routes.ts +55 -2
  125. package/packages/server/src/routes/recommended-routes.ts +225 -0
  126. package/packages/server/src/routes/system-routes.ts +30 -34
  127. package/packages/server/src/routes/tool-routes.ts +153 -0
  128. package/packages/server/src/server-pid.ts +5 -9
  129. package/packages/server/src/server.ts +363 -26
  130. package/packages/server/src/session-api.ts +77 -8
  131. package/packages/server/src/session-bootstrap.ts +17 -3
  132. package/packages/server/src/session-diff.ts +21 -21
  133. package/packages/server/src/terminal-manager.ts +65 -20
  134. package/packages/server/src/test-env-guard.ts +26 -0
  135. package/packages/server/src/test-support/test-server.ts +63 -0
  136. package/packages/server/src/tunnel.ts +172 -34
  137. package/packages/shared/package.json +10 -3
  138. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  139. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  140. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  141. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  142. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  143. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  144. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  145. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  146. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  147. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  148. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  149. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  150. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  151. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  152. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  153. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  154. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  155. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  156. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  157. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  158. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  159. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  160. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  161. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  162. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  163. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  164. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  165. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  166. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  167. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  168. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  169. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  170. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  172. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  173. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  174. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  175. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  176. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  177. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  178. package/packages/shared/src/__tests__/config.test.ts +59 -3
  179. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  180. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  181. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  182. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  183. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  184. package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
  185. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  186. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  187. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  188. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  189. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  190. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  191. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  192. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  193. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  194. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  195. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  196. package/packages/shared/src/__tests__/recommended-extensions.test.ts +156 -0
  197. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  198. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  199. package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
  200. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  201. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  202. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  203. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  204. package/packages/shared/src/bootstrap-install.ts +212 -0
  205. package/packages/shared/src/bridge-register.ts +87 -20
  206. package/packages/shared/src/browser-protocol.ts +93 -1
  207. package/packages/shared/src/config.ts +87 -15
  208. package/packages/shared/src/managed-paths.ts +31 -4
  209. package/packages/shared/src/openspec-poller.ts +71 -49
  210. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  211. package/packages/shared/src/platform/commands.ts +100 -0
  212. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  213. package/packages/shared/src/platform/exec.ts +220 -0
  214. package/packages/shared/src/platform/git.ts +155 -0
  215. package/packages/shared/src/platform/index.ts +15 -0
  216. package/packages/shared/src/platform/npm.ts +162 -0
  217. package/packages/shared/src/platform/openspec.ts +91 -0
  218. package/packages/shared/src/platform/paths.ts +276 -0
  219. package/packages/shared/src/platform/process-identify.ts +126 -0
  220. package/packages/shared/src/platform/process-scan.ts +94 -0
  221. package/packages/shared/src/platform/process.ts +168 -0
  222. package/packages/shared/src/platform/runner.ts +369 -0
  223. package/packages/shared/src/platform/shell.ts +44 -0
  224. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  225. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  226. package/packages/shared/src/recommended-extensions.ts +196 -0
  227. package/packages/shared/src/resolve-jiti.ts +62 -3
  228. package/packages/shared/src/rest-api.ts +97 -0
  229. package/packages/shared/src/semaphore.ts +83 -0
  230. package/packages/shared/src/source-matching.ts +126 -0
  231. package/packages/shared/src/test-support/setup-home.ts +74 -0
  232. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  233. package/packages/shared/src/tool-registry/index.ts +56 -0
  234. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  235. package/packages/shared/src/tool-registry/registry.ts +262 -0
  236. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  237. package/packages/shared/src/tool-registry/types.ts +180 -0
  238. package/packages/shared/src/types.ts +7 -0
@@ -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
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Session-spawn mechanism selection.
3
+ *
4
+ * The user expresses preference via a two-valued config type
5
+ * (`SpawnStrategy` = "tmux" | "headless"). The dashboard internally
6
+ * decides WHICH actual mechanism to use given the OS and what's
7
+ * available on this host. This module is the single source of truth
8
+ * for that decision.
9
+ *
10
+ * Mechanisms:
11
+ * • "tmux" — Unix terminal multiplexer (Linux, macOS)
12
+ * • "wt" — Windows Terminal new-tab (Win10/11)
13
+ * • "wsl-tmux" — WSL-hosted tmux (Windows, niche)
14
+ * • "headless" — RPC-mode pi, no TTY, bridge over WebSocket
15
+ *
16
+ * `selectMechanism` is pure: no I/O, no subprocess calls. Availability
17
+ * is determined by the caller (typically via `ToolRegistry.resolve`)
18
+ * and passed in. This keeps the decision trivially testable.
19
+ *
20
+ * See change: consolidate-windows-spawn-and-platform-handlers.
21
+ */
22
+
23
+ export type SpawnMechanism = "tmux" | "wt" | "wsl-tmux" | "headless";
24
+
25
+ /** User-visible config value (from `SpawnStrategy` in shared/config.ts). */
26
+ export type UserSpawnStrategy = "tmux" | "headless";
27
+
28
+ export interface SpawnMechanismContext {
29
+ platform: NodeJS.Platform;
30
+ userStrategy: UserSpawnStrategy;
31
+ electronMode: boolean;
32
+ available: {
33
+ tmux: boolean;
34
+ wt: boolean;
35
+ wslTmux: boolean;
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Select one spawn mechanism for this platform given the user's
41
+ * preference, the electron-mode flag, and tool availability.
42
+ *
43
+ * Rules (in order):
44
+ * 1. electronMode forces "headless".
45
+ * 2. userStrategy "headless" forces "headless".
46
+ * 3. Unix (linux/darwin): tmux if available, else headless.
47
+ * 4. Windows: wt > wsl-tmux > headless.
48
+ * 5. Any other platform falls back to headless.
49
+ */
50
+ export function selectMechanism(ctx: SpawnMechanismContext): SpawnMechanism {
51
+ if (ctx.electronMode) return "headless";
52
+ if (ctx.userStrategy === "headless") return "headless";
53
+
54
+ if (ctx.platform === "linux" || ctx.platform === "darwin") {
55
+ return ctx.available.tmux ? "tmux" : "headless";
56
+ }
57
+ if (ctx.platform === "win32") {
58
+ if (ctx.available.wt) return "wt";
59
+ if (ctx.available.wslTmux) return "wsl-tmux";
60
+ return "headless";
61
+ }
62
+ return "headless";
63
+ }
64
+
65
+ // ── Windows Terminal argv builder ───────────────────────────────────────────
66
+
67
+ export interface WtArgsOptions {
68
+ /** Absolute cwd for the new tab. Spaces / parens / quotes are safe in argv form. */
69
+ cwd: string;
70
+ /** Tab title, typically the basename of cwd. */
71
+ title: string;
72
+ /**
73
+ * Pre-resolved pi argv: typically [node.exe, cli.js, --mode?, rpc?, --fork?, file?].
74
+ * Interactive wt sessions OMIT --mode rpc so pi runs its TUI.
75
+ */
76
+ piArgv: string[];
77
+ }
78
+
79
+ /**
80
+ * Build argv (NOT a shell string) to invoke Windows Terminal so it opens
81
+ * a new tab in the existing WT window and runs `piArgv` there.
82
+ *
83
+ * Design notes:
84
+ * • argv form — passed to spawn with shell:false, so wt re-parses it
85
+ * internally. No need to escape spaces, semicolons, or quotes in cwd.
86
+ * • `-w 0` reuses the most-recently-used WT window; new tab, not new
87
+ * window. Matches tmux `new-window` semantics.
88
+ * • No `-p <profile>` — respect the user's default WT profile
89
+ * (cmd / pwsh / WSL).
90
+ * • `--` sentinel before piArgv so any `-` or `/` prefix in piArgv
91
+ * can't be misparsed as a wt option.
92
+ */
93
+ export function buildWtArgs(opts: WtArgsOptions): string[] {
94
+ return [
95
+ "-w", "0",
96
+ "new-tab",
97
+ "-d", opts.cwd,
98
+ "--title", opts.title,
99
+ "--",
100
+ ...opts.piArgv,
101
+ ];
102
+ }
103
+
104
+ // ── Shared helper: append session/fork flags uniformly ─────────────────────
105
+
106
+ export interface SessionFlags {
107
+ sessionFile?: string;
108
+ mode?: "continue" | "fork";
109
+ }
110
+
111
+ /**
112
+ * Return `["--session", file]` or `["--fork", file]` or `[]`.
113
+ * Every mechanism MUST use this to append flags; dropping them silently
114
+ * is the exact bug that motivated this change (B1, B2).
115
+ */
116
+ export function sessionFlagsToArgv(flags: SessionFlags): string[] {
117
+ if (flags.sessionFile && flags.mode === "continue") {
118
+ return ["--session", flags.sessionFile];
119
+ }
120
+ if (flags.sessionFile && flags.mode === "fork") {
121
+ return ["--fork", flags.sessionFile];
122
+ }
123
+ return [];
124
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Subprocess adapter — strategy pattern for OS-aware subprocess invocation.
3
+ *
4
+ * The adapter is the single point of entry for spawning any subprocess
5
+ * from dashboard code or from libraries we wrap. It dispatches to a
6
+ * platform-specific implementation:
7
+ *
8
+ * - Windows: `.cmd`/`.bat` shims go through explicit `cmd.exe /d /s /c`
9
+ * invocation with `windowsHide: true` and `shell: false` (the only
10
+ * reliable way to avoid Node issue #21825's flashing console).
11
+ * Native `.exe`s spawn directly.
12
+ * - Unix: direct spawn, no shell, no special cases.
13
+ *
14
+ * Why an adapter instead of a global monkey-patch?
15
+ *
16
+ * - Explicit dependency injection. Callers (and tests) know exactly
17
+ * which spawn implementation they get.
18
+ * - Isolated — third-party code that needs this behaviour gets it via
19
+ * a thin subclass that consumes the adapter (see
20
+ * `createSafePackageManagerClass` in
21
+ * `packages/server/src/package-manager-wrapper.ts`). No cross-
22
+ * cutting global state.
23
+ * - Testable: fake adapter => assert argv without spawning real
24
+ * subprocesses.
25
+ *
26
+ * See change: consolidate-windows-spawn-and-platform-handlers.
27
+ */
28
+ import type {
29
+ ChildProcess,
30
+ SpawnOptions,
31
+ SpawnSyncOptions,
32
+ SpawnSyncReturns,
33
+ } from "node:child_process";
34
+ import {
35
+ spawn as safeSpawn,
36
+ spawnSync as safeSpawnSync,
37
+ buildSafeArgv,
38
+ } from "./exec.js";
39
+
40
+ // ── Interface ──────────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Cross-platform subprocess adapter. Implementations guarantee:
44
+ * - `windowsHide: true` on Windows, always.
45
+ * - No `shell: true` ever — `.cmd` shims are invoked via explicit
46
+ * `cmd.exe /d /s /c` argv.
47
+ * - Arg arrays are passed verbatim, no shell-escaping surprises.
48
+ */
49
+ export interface SubprocessAdapter {
50
+ /** Async spawn. Returns the live ChildProcess. */
51
+ spawn(command: string, args?: readonly string[], options?: SpawnOptions): ChildProcess;
52
+
53
+ /** Synchronous spawn. Blocks until completion. */
54
+ spawnSync<T extends string | Buffer = Buffer>(
55
+ command: string,
56
+ args?: readonly string[],
57
+ options?: SpawnSyncOptions,
58
+ ): SpawnSyncReturns<T>;
59
+ }
60
+
61
+ // ── Windows implementation ─────────────────────────────────────────────────
62
+
63
+ class WindowsSubprocessAdapter implements SubprocessAdapter {
64
+ spawn(command: string, args: readonly string[] = [], options?: SpawnOptions): ChildProcess {
65
+ const { argv, spawnOptions } = buildSafeArgv(command, args, "win32");
66
+ return safeSpawn(argv[0], argv.slice(1), { ...(options ?? {}), ...spawnOptions });
67
+ }
68
+
69
+ spawnSync<T extends string | Buffer = Buffer>(
70
+ command: string,
71
+ args: readonly string[] = [],
72
+ options?: SpawnSyncOptions,
73
+ ): SpawnSyncReturns<T> {
74
+ const { argv, spawnOptions } = buildSafeArgv(command, args, "win32");
75
+ return safeSpawnSync<T>(argv[0], argv.slice(1), { ...(options ?? {}), ...spawnOptions });
76
+ }
77
+ }
78
+
79
+ // ── Unix implementation ────────────────────────────────────────────────────
80
+
81
+ class UnixSubprocessAdapter implements SubprocessAdapter {
82
+ spawn(command: string, args: readonly string[] = [], options?: SpawnOptions): ChildProcess {
83
+ return safeSpawn(command, args, { ...(options ?? {}), shell: false });
84
+ }
85
+
86
+ spawnSync<T extends string | Buffer = Buffer>(
87
+ command: string,
88
+ args: readonly string[] = [],
89
+ options?: SpawnSyncOptions,
90
+ ): SpawnSyncReturns<T> {
91
+ return safeSpawnSync<T>(command, args, { ...(options ?? {}), shell: false });
92
+ }
93
+ }
94
+
95
+ // ── Factory ────────────────────────────────────────────────────────────────
96
+
97
+ /**
98
+ * Return the appropriate adapter for the given platform. Default:
99
+ * `process.platform`. Tests pass explicit values without mutating the
100
+ * global.
101
+ */
102
+ export function createSubprocessAdapter(
103
+ platform: NodeJS.Platform = process.platform,
104
+ ): SubprocessAdapter {
105
+ if (platform === "win32") return new WindowsSubprocessAdapter();
106
+ return new UnixSubprocessAdapter();
107
+ }
108
+
109
+ /**
110
+ * Process-wide default adapter. Constructed lazily on first access.
111
+ * Callers that want a different strategy (e.g. tests injecting a fake)
112
+ * pass the adapter explicitly to their constructor instead of using
113
+ * this singleton.
114
+ */
115
+ let defaultAdapter: SubprocessAdapter | null = null;
116
+ export function getDefaultSubprocessAdapter(): SubprocessAdapter {
117
+ if (!defaultAdapter) defaultAdapter = createSubprocessAdapter();
118
+ return defaultAdapter;
119
+ }
120
+
121
+ /** Test-only: drop the cached default adapter. */
122
+ export function _resetDefaultSubprocessAdapter(): void {
123
+ defaultAdapter = null;
124
+ }