@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
@@ -1,310 +1,491 @@
1
1
  /**
2
- * Process manager for spawning pi sessions via tmux or headless (RPC mode).
2
+ * Process manager for spawning pi sessions.
3
+ *
4
+ * Dispatch is owned by `platform/spawn-mechanism.ts`'s `selectMechanism`.
5
+ * Per-mechanism spawn is owned by `platform/detached-spawn.ts`. This
6
+ * module's job is: resolve pi + tool availability, build per-mechanism
7
+ * command, delegate.
8
+ *
9
+ * Invariants:
10
+ * - No direct `process.platform === "..."` branches in this file.
11
+ * All platform-aware behaviour lives in `platform/**`.
12
+ * - Every mechanism branch builds pi argv uniformly from
13
+ * `buildHeadlessArgs` or its wt/tmux counterpart; `sessionFile`
14
+ * and `mode` are never dropped by any branch.
15
+ *
16
+ * See change: consolidate-windows-spawn-and-platform-handlers.
3
17
  */
4
- import { execSync, spawn, type ChildProcess } from "node:child_process";
5
- import { existsSync } from "node:fs";
18
+ import { existsSync, mkdirSync, openSync, closeSync } from "node:fs";
6
19
  import path from "node:path";
7
20
  import os from "node:os";
21
+ import type { ChildProcess } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
8
22
  import type { SpawnStrategy } from "@blackbelt-technology/pi-dashboard-shared/config.js";
9
23
  import { MANAGED_BIN } from "@blackbelt-technology/pi-dashboard-shared/managed-paths.js";
10
- import { ToolResolver } from "@blackbelt-technology/pi-dashboard-shared/tool-resolver.js";
24
+ import { ToolResolver } from "@blackbelt-technology/pi-dashboard-shared/platform/binary-lookup.js";
25
+ import { execSync, spawnSync, buildSafeArgv } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
26
+ import {
27
+ spawnDetached,
28
+ waitForNoCrash,
29
+ } from "@blackbelt-technology/pi-dashboard-shared/platform/detached-spawn.js";
30
+ import {
31
+ selectMechanism,
32
+ buildWtArgs,
33
+ sessionFlagsToArgv,
34
+ type SpawnMechanism,
35
+ type UserSpawnStrategy,
36
+ } from "@blackbelt-technology/pi-dashboard-shared/platform/spawn-mechanism.js";
37
+
38
+ // ── Resolver seam (injectable for tests) ────────────────────────────────────
39
+
40
+ let resolver: ToolResolver = new ToolResolver({ processExecPath: process.execPath });
41
+
42
+ /** Inject a resolver — used by tests. Production code never calls this. */
43
+ export function setResolver(r: ToolResolver): void {
44
+ resolver = r;
45
+ }
11
46
 
12
- /** Server-side resolverknows the current process node binary. */
13
- const resolver = new ToolResolver({ processExecPath: process.execPath });
47
+ /** Reset to default used by tests to clean up. */
48
+ export function resetResolver(): void {
49
+ resolver = new ToolResolver({ processExecPath: process.execPath });
50
+ }
14
51
 
15
- /** Build env with managed install bin + current node binary dir prepended to PATH.
16
- * Delegates to ToolResolver.buildSpawnEnv().
17
- */
52
+ // ── Public API ─────────────────────────────────────────────────────────────
53
+
54
+ export interface SessionOptions {
55
+ sessionFile?: string;
56
+ mode?: "continue" | "fork";
57
+ strategy?: SpawnStrategy;
58
+ }
59
+
60
+ export interface SpawnResult {
61
+ success: boolean;
62
+ message: string;
63
+ pid?: number;
64
+ process?: ChildProcess;
65
+ /** True when spawned from the dashboard (for writing session meta) */
66
+ dashboardSpawned?: boolean;
67
+ }
68
+
69
+ /** Build env with managed install bin + current node binary dir prepended to PATH. */
18
70
  export function buildSpawnEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
19
71
  return resolver.buildSpawnEnv(baseEnv);
20
72
  }
21
73
 
22
- export interface PlatformInfo {
23
- strategy: "tmux" | "wsl" | "cmd";
24
- platform: string;
74
+ /**
75
+ * Escape a string for safe use inside a POSIX shell command.
76
+ * Used by buildTmuxCommand for tmux/wsl-tmux argv construction.
77
+ */
78
+ export function shellEscape(s: string): string {
79
+ if (/^[a-zA-Z0-9_./:=@-]+$/.test(s)) return s;
80
+ return `'${s.replace(/'/g, "'\\''")}'`;
25
81
  }
26
82
 
27
- export function detectPlatform(platform?: string): PlatformInfo {
28
- const p = platform ?? process.platform;
29
-
30
- if (p === "darwin" || p === "linux") {
31
- return { strategy: "tmux", platform: p };
32
- }
33
- if (p === "win32") {
34
- return { strategy: "wsl", platform: p };
35
- }
36
- return { strategy: "tmux", platform: p };
83
+ /**
84
+ * Build the argv tail for a headless pi invocation: `--mode rpc` plus
85
+ * `--session <file>` or `--fork <file>` when options provide them.
86
+ */
87
+ export function buildHeadlessArgs(options?: SessionOptions): string[] {
88
+ return ["--mode", "rpc", ...sessionFlagsToArgv(options ?? {})];
37
89
  }
38
90
 
39
- export interface SessionOptions {
40
- sessionFile?: string;
41
- mode?: "continue" | "fork";
42
- strategy?: SpawnStrategy;
91
+ /**
92
+ * Build the argv tail for an INTERACTIVE pi invocation (wt, tmux, wsl-tmux):
93
+ * no `--mode rpc`; just session/fork flags when provided.
94
+ */
95
+ export function buildInteractivePiArgs(options?: SessionOptions): string[] {
96
+ return sessionFlagsToArgv(options ?? {});
43
97
  }
44
98
 
99
+ /**
100
+ * Build a tmux shell command string to run pi in a new tmux window/session.
101
+ * Kept as a string (not argv) because tmux is invoked via `execSync(cmd)`.
102
+ */
45
103
  export function buildTmuxCommand(cwd: string, sessionExists: boolean, options?: SessionOptions): string {
46
104
  const safeCwd = shellEscape(cwd);
47
- let piCmd = `cd ${safeCwd} && pi`;
48
-
49
- if (options?.sessionFile && options?.mode === "continue") {
50
- piCmd = `cd ${safeCwd} && pi --session ${shellEscape(options.sessionFile)}`;
51
- } else if (options?.sessionFile && options?.mode === "fork") {
52
- piCmd = `cd ${safeCwd} && pi --fork ${shellEscape(options.sessionFile)}`;
53
- }
54
-
105
+ const flags = sessionFlagsToArgv(options ?? {})
106
+ .map(shellEscape)
107
+ .join(" ");
108
+ const piCmd = flags ? `cd ${safeCwd} && pi ${flags}` : `cd ${safeCwd} && pi`;
55
109
  if (sessionExists) {
56
110
  return `tmux new-window -t pi-dashboard -c ${safeCwd} "${piCmd}"`;
57
111
  }
58
112
  return `tmux new-session -d -s pi-dashboard -c ${safeCwd} "${piCmd}"`;
59
113
  }
60
114
 
115
+ // ── Availability probes (isolated, one place) ───────────────────────────────
116
+
61
117
  function isTmuxAvailable(): boolean {
62
118
  try {
63
- execSync("which tmux", { stdio: "ignore" });
64
- return true;
119
+ // `which` / `where` already baked into ToolResolver.
120
+ return resolver.which("tmux") !== null;
65
121
  } catch {
66
122
  return false;
67
123
  }
68
124
  }
69
125
 
70
- function dashboardSessionExists(): boolean {
126
+ function isWtAvailable(): boolean {
71
127
  try {
72
- execSync("tmux has-session -t pi-dashboard 2>/dev/null", { stdio: "ignore" });
73
- return true;
128
+ return resolver.which("wt") !== null;
74
129
  } catch {
75
130
  return false;
76
131
  }
77
132
  }
78
133
 
79
- export interface SpawnResult {
80
- success: boolean;
81
- message: string;
82
- pid?: number;
83
- process?: ChildProcess;
84
- /** True when spawned from the dashboard (for writing session meta) */
85
- dashboardSpawned?: boolean;
134
+ // Cache the WSL-tmux probe for the server lifetime. On machines with a broken
135
+ // WSL install (e.g. Docker Desktop WSL mount failure) this single probe can
136
+ // cost 30+ seconds — we MUST NOT pay it on every + Session click. The result
137
+ // can only change if the user installs/uninstalls WSL or tmux, which requires
138
+ // a server restart anyway.
139
+ let _wslTmuxAvailabilityCache: boolean | null = null;
140
+ let _wslFallbackLogged = false;
141
+
142
+ /** Test-only: reset the cache so tests can exercise both branches. */
143
+ export function _resetWslTmuxCacheForTests(): void {
144
+ _wslTmuxAvailabilityCache = null;
145
+ _wslFallbackLogged = false;
86
146
  }
87
147
 
88
- export function buildHeadlessArgs(options?: SessionOptions): string[] {
89
- const args = ["--mode", "rpc"];
90
-
91
- if (options?.sessionFile && options?.mode === "continue") {
92
- args.push("--session", options.sessionFile);
93
- } else if (options?.sessionFile && options?.mode === "fork") {
94
- args.push("--fork", options.sessionFile);
148
+ function isWslTmuxAvailable(): boolean {
149
+ // WSL tmux probe. Route through `buildSafeArgv` so there is NO
150
+ // cmd.exe-as-shell in the path — `spawnSync("wsl", ["which", "tmux"])`
151
+ // with windowsHide:true + shell:false keeps the console invisible.
152
+ // `wsl.exe` itself still spins up WSL briefly, but that's background
153
+ // (no visible window). Only invoked after `wt` is known absent.
154
+ //
155
+ // Cached for the server lifetime (see comment on _wslTmuxAvailabilityCache).
156
+ if (_wslTmuxAvailabilityCache !== null) return _wslTmuxAvailabilityCache;
157
+ try {
158
+ const { argv, spawnOptions } = buildSafeArgv("wsl", ["which", "tmux"]);
159
+ const r = spawnSync(argv[0], argv.slice(1), {
160
+ stdio: "ignore",
161
+ timeout: 1500,
162
+ ...spawnOptions,
163
+ });
164
+ _wslTmuxAvailabilityCache = r.status === 0;
165
+ } catch {
166
+ _wslTmuxAvailabilityCache = false;
95
167
  }
168
+ if (!_wslTmuxAvailabilityCache && !_wslFallbackLogged) {
169
+ _wslFallbackLogged = true;
170
+ console.error(
171
+ "[spawn] Windows Terminal (wt.exe) not on PATH and WSL tmux unavailable \u2014 " +
172
+ "falling back to headless session spawn. Install Windows Terminal for a " +
173
+ "nicer UX: https://aka.ms/terminal",
174
+ );
175
+ }
176
+ return _wslTmuxAvailabilityCache;
177
+ }
96
178
 
97
- return args;
179
+ function dashboardSessionExists(): boolean {
180
+ try {
181
+ execSync("tmux has-session -t pi-dashboard 2>/dev/null", { stdio: "ignore" });
182
+ return true;
183
+ } catch {
184
+ return false;
185
+ }
98
186
  }
99
187
 
100
- /** Resolve the pi command as [command, ...prefixArgs].
101
- * Delegates to ToolResolver.resolvePi().
102
- */
188
+ /** Resolve pi as argv. Prefers node.exe + cli.js on Windows (avoids .cmd). */
103
189
  function resolvePiCommand(): string[] | null {
104
190
  return resolver.resolvePi();
105
191
  }
106
192
 
107
- /** Windows-specific headless spawn with error detection and stderr capture.
108
- * Waits briefly to detect immediate process death (e.g., missing deps, config errors).
193
+ // ── Mechanism dispatch ─────────────────────────────────────────────────────
194
+
195
+ /**
196
+ * Select the spawn mechanism for this invocation using lazy tool
197
+ * availability probing. Each probe runs a subprocess, so we short-
198
+ * circuit as soon as a mechanism is decided — crucially, the WSL
199
+ * probe (`wsl which tmux`) spins up the WSL VM on Windows and is
200
+ * the most expensive, so we only run it when wt is ALREADY known
201
+ * absent and the user hasn't asked for headless.
202
+ *
203
+ * Ordering mirrors `selectMechanism`'s decision rules:
204
+ * 1. electronMode or userStrategy=headless → no probes at all
205
+ * 2. Unix → probe tmux only
206
+ * 3. Windows → probe wt first; probe wsl-tmux only if wt is absent
109
207
  */
110
- async function spawnHeadlessWindows(
111
- cwd: string,
112
- piCmd: string[],
113
- args: string[],
114
- env: NodeJS.ProcessEnv,
115
- ): Promise<SpawnResult> {
116
- const [bin, ...prefixArgs] = piCmd;
117
- const needsShell = bin.endsWith(".cmd");
118
- const spawnBin = needsShell ? `"${bin}"` : bin;
119
- const spawnArgs = needsShell
120
- ? [...prefixArgs, ...args].map(a => `"${a}"`)
121
- : [...prefixArgs, ...args];
208
+ function chooseMechanism(options?: SessionOptions, electronMode = false): SpawnMechanism {
209
+ const userStrategy: UserSpawnStrategy = options?.strategy === "headless" ? "headless" : "tmux";
210
+ const platform = process.platform;
122
211
 
123
- const cmdForLog = `${bin} ${[...prefixArgs, ...args].join(" ")}`;
124
- console.error(`[spawn] Windows headless: ${cmdForLog} (cwd=${cwd})`);
212
+ // Short-circuit #1: headless requires no probes.
213
+ if (electronMode || userStrategy === "headless") {
214
+ return "headless";
215
+ }
125
216
 
126
- // Capture stderr for diagnostics (pi might log errors there)
127
- const child = spawn(spawnBin, spawnArgs, {
128
- cwd,
129
- detached: false,
130
- stdio: ["pipe", "ignore", "pipe"],
131
- env,
132
- shell: needsShell,
133
- windowsHide: true,
134
- });
217
+ // Unix: tmux or headless.
218
+ if (platform === "linux" || platform === "darwin") {
219
+ return selectMechanism({
220
+ platform,
221
+ userStrategy,
222
+ electronMode,
223
+ available: { tmux: isTmuxAvailable(), wt: false, wslTmux: false },
224
+ });
225
+ }
135
226
 
136
- // Collect stderr for early crash diagnostics
137
- let stderrBuf = "";
138
- child.stderr?.on("data", (chunk: Buffer) => {
139
- stderrBuf += chunk.toString();
140
- // Limit memory: keep only last 4 KB
141
- if (stderrBuf.length > 4096) stderrBuf = stderrBuf.slice(-4096);
142
- });
227
+ // Windows: wt first (cheap `where wt`). Only probe WSL when wt is
228
+ // absent `wsl which tmux` starts the WSL VM and is slow + flashy.
229
+ if (platform === "win32") {
230
+ const wt = isWtAvailable();
231
+ if (wt) {
232
+ return selectMechanism({
233
+ platform,
234
+ userStrategy,
235
+ electronMode,
236
+ available: { tmux: false, wt: true, wslTmux: false },
237
+ });
238
+ }
239
+ const wslTmux = isWslTmuxAvailable();
240
+ return selectMechanism({
241
+ platform,
242
+ userStrategy,
243
+ electronMode,
244
+ available: { tmux: false, wt: false, wslTmux },
245
+ });
246
+ }
143
247
 
144
- // Handle async spawn errors (e.g., ENOENT if binary disappears between check and exec)
145
- let spawnError: string | null = null;
146
- child.on("error", (err: Error) => {
147
- spawnError = err.message;
148
- console.error(`[spawn] Windows spawn error: ${err.message}`);
149
- });
248
+ // Unknown platform headless.
249
+ return "headless";
250
+ }
150
251
 
151
- child.unref();
152
- (child.stdin as any)?.unref();
153
- child.stderr?.unref();
252
+ // ── Main entry point ───────────────────────────────────────────────────────
154
253
 
155
- // Guard: if pid is undefined, spawn failed synchronously
156
- if (!child.pid) {
157
- // Wait briefly for the async error event
158
- await new Promise(r => setTimeout(r, 200));
159
- return {
160
- success: false,
161
- message: `Failed to spawn pi: ${spawnError || "unknown error (no PID)"}. Command: ${cmdForLog}`,
162
- };
254
+ export async function spawnPiSession(
255
+ cwd: string,
256
+ options?: SessionOptions & { electronMode?: boolean },
257
+ ): Promise<SpawnResult> {
258
+ if (!existsSync(cwd)) {
259
+ return { success: false, message: `Directory does not exist: ${cwd}` };
163
260
  }
164
261
 
165
- // Wait briefly to detect immediate crash (e.g., missing module, config error)
166
- const exitCode = await Promise.race([
167
- new Promise<number | null>(resolve => child.on("exit", resolve)),
168
- new Promise<undefined>(resolve => setTimeout(() => resolve(undefined), 1500)),
169
- ]);
170
-
171
- if (exitCode !== undefined) {
172
- const detail = stderrBuf.trim()
173
- ? `\nstderr: ${stderrBuf.trim().split("\n").slice(-5).join("\n")}`
174
- : "";
175
- console.error(`[spawn] Pi exited immediately with code ${exitCode}${detail}`);
262
+ const mechanism = chooseMechanism(options, options?.electronMode ?? false);
263
+
264
+ switch (mechanism) {
265
+ case "tmux": return spawnTmux(cwd, options);
266
+ case "wt": return spawnWt(cwd, options);
267
+ case "wsl-tmux": return spawnWslTmux(cwd, options);
268
+ case "headless": return spawnHeadless(cwd, options);
269
+ }
270
+ }
271
+
272
+ // ── Per-mechanism spawn ────────────────────────────────────────────────────
273
+
274
+ function spawnTmux(cwd: string, options?: SessionOptions): SpawnResult {
275
+ const exists = dashboardSessionExists();
276
+ const cmd = buildTmuxCommand(cwd, exists, options);
277
+ try {
278
+ execSync(cmd, { stdio: "ignore" });
176
279
  return {
177
- success: false,
178
- message: `Pi process exited immediately (code ${exitCode}).${detail}\nCommand: ${cmdForLog}`,
280
+ success: true,
281
+ dashboardSpawned: true,
282
+ message: `Pi session spawned in tmux (${exists ? "new window" : "new session"})`,
179
283
  };
284
+ } catch (err: any) {
285
+ return { success: false, message: `Failed to spawn session: ${err.message}` };
286
+ }
287
+ }
288
+
289
+ function spawnWslTmux(cwd: string, options?: SessionOptions): SpawnResult {
290
+ try {
291
+ const cmd = `wsl ${buildTmuxCommand(cwd, false, options)}`;
292
+ execSync(cmd, { stdio: "ignore" });
293
+ return { success: true, dashboardSpawned: true, message: "Pi session spawned via WSL tmux" };
294
+ } catch (err: any) {
295
+ return { success: false, message: `Failed to spawn via WSL tmux: ${err.message}` };
296
+ }
297
+ }
298
+
299
+ async function spawnWt(cwd: string, options?: SessionOptions): Promise<SpawnResult> {
300
+ const wt = resolver.which("wt");
301
+ if (!wt) {
302
+ return { success: false, message: "Windows Terminal (wt.exe) not found" };
303
+ }
304
+ const piCmd = resolvePiCommand();
305
+ if (!piCmd) {
306
+ return { success: false, message: `pi binary not found. Checked: ${MANAGED_BIN} and system PATH.` };
307
+ }
308
+
309
+ const piArgv = [...piCmd, ...buildInteractivePiArgs(options)];
310
+ const args = buildWtArgs({ cwd, title: path.basename(cwd) || "pi", piArgv });
311
+
312
+ const r = await spawnDetached({
313
+ cmd: wt,
314
+ args,
315
+ cwd,
316
+ env: buildSpawnEnv(),
317
+ });
318
+
319
+ if (!r.ok) {
320
+ return { success: false, message: `Failed to launch Windows Terminal: ${r.error}` };
180
321
  }
181
322
 
182
323
  return {
183
324
  success: true,
184
325
  dashboardSpawned: true,
185
- message: `Pi session spawned headless (pid ${child.pid})`,
186
- pid: child.pid,
187
- process: child,
326
+ message: "Pi session spawned in Windows Terminal",
327
+ pid: r.pid,
328
+ process: r.process,
188
329
  };
189
330
  }
190
331
 
191
332
  async function spawnHeadless(cwd: string, options?: SessionOptions): Promise<SpawnResult> {
192
- try {
193
- const args = buildHeadlessArgs(options);
194
- const env = buildSpawnEnv();
195
-
196
- // Pre-check: verify pi binary exists
197
- const piCmd_ = resolvePiCommand();
198
- if (!piCmd_) {
199
- return {
200
- success: false,
201
- message: `pi binary not found. Checked: ${MANAGED_BIN}/pi and system PATH.`,
202
- };
203
- }
204
-
205
- if (process.platform === "win32") {
206
- return await spawnHeadlessWindows(cwd, piCmd_, args, env);
207
- }
208
-
209
- // Unix (macOS / Linux / WSL): wrap with "tail -f /dev/null | pi" so stdin
210
- // is an internal pipe that survives GC and server restarts.
211
- // detached: true creates a new process group; we kill via -pid later.
212
- const piBin = piCmd_[0];
213
- const piCmd = [shellEscape(piBin), ...args.map(shellEscape)].join(" ");
214
- // Use "tail -f /dev/null" to keep stdin pipe open for pi.
215
- // Unlike "sleep N", tail -f /dev/null works correctly even when
216
- // the outer shell's stdin is /dev/null (stdio:"ignore"),
217
- // which breaks "sleep | pi" on some Linux systems.
218
- const child = spawn("sh", ["-c", `tail -f /dev/null | ${piCmd}`], {
219
- cwd,
220
- detached: true,
221
- stdio: "ignore",
222
- env,
223
- });
224
- child.unref();
333
+ const args = buildHeadlessArgs(options);
334
+ const env = buildSpawnEnv();
335
+ const piCmd = resolvePiCommand();
336
+ if (!piCmd) {
337
+ return { success: false, message: `pi binary not found. Checked: ${MANAGED_BIN} and system PATH.` };
338
+ }
339
+ const [bin, ...prefixArgs] = piCmd;
225
340
 
226
- return {
227
- success: true,
228
- dashboardSpawned: true,
229
- message: `Pi session spawned headless (pid ${child.pid})`,
230
- pid: child.pid,
231
- process: child,
232
- };
233
- } catch (err: any) {
234
- return {
235
- success: false,
236
- message: `Failed to spawn headless session: ${err.message}`,
237
- };
341
+ const platform = process.platform;
342
+ if (platform === "win32") {
343
+ return spawnHeadlessDetached(cwd, bin, prefixArgs, args, env);
238
344
  }
239
- }
240
345
 
241
- /** Escape a string for safe use in a shell command. */
242
- export function shellEscape(s: string): string {
243
- if (/^[a-zA-Z0-9_./:=@-]+$/.test(s)) return s;
244
- return `'${s.replace(/'/g, "'\\''")}'`;
346
+ // Unix: use the sh -c "tail -f /dev/null | pi" wrapper so pi's stdin is
347
+ // an internal pipe that survives GC. Pass through the detached-spawn
348
+ // primitive so all the libuv defaults (detached, stdio, windowsHide) are
349
+ // uniform. The wrapper is a domain-specific stdin-survival trick — it
350
+ // belongs here (process-manager), not inside the primitive.
351
+ const piLine = [shellEscape(bin), ...[...prefixArgs, ...args].map(shellEscape)].join(" ");
352
+ const r = await spawnDetached({
353
+ cmd: "sh",
354
+ args: ["-c", `tail -f /dev/null | ${piLine}`],
355
+ cwd,
356
+ env,
357
+ });
358
+ if (!r.ok) {
359
+ return { success: false, message: `Failed to spawn headless (Unix): ${r.error}` };
360
+ }
361
+ return {
362
+ success: true,
363
+ dashboardSpawned: true,
364
+ message: `Pi session spawned headless (pid ${r.pid})`,
365
+ pid: r.pid,
366
+ process: r.process,
367
+ };
245
368
  }
246
369
 
247
- export async function spawnPiSession(cwd: string, options?: SessionOptions & { electronMode?: boolean }): Promise<SpawnResult> {
248
- if (!existsSync(cwd)) {
370
+ /**
371
+ * Windows headless spawn using the detached-spawn primitive.
372
+ *
373
+ * Key correctness fixes vs. the previous spawnHeadlessWindows:
374
+ * • detached: true (via primitive) — excludes from libuv's
375
+ * kill-on-close job; sessions survive
376
+ * server restart.
377
+ * • shell: false (via primitive) — sidesteps Node issue
378
+ * #21825 and cmd.exe /d /s /c edge cases.
379
+ * Requires pi to be [node.exe, cli.js],
380
+ * NOT pi.cmd. If only pi.cmd is on PATH,
381
+ * we surface an actionable error.
382
+ * • stdio[0] = "ignore" — no parent-owned stdin pipe.
383
+ * • stdio[2] = logFd — stderr to a persisted log file (not
384
+ * a pipe that dies with the parent).
385
+ * • Crash window 300 ms (was 1500 ms) — via waitForNoCrash.
386
+ */
387
+ async function spawnHeadlessDetached(
388
+ cwd: string,
389
+ bin: string,
390
+ prefixArgs: string[],
391
+ args: string[],
392
+ env: NodeJS.ProcessEnv,
393
+ ): Promise<SpawnResult> {
394
+ // Refuse to go through cmd.exe — the managed install must be present
395
+ // so resolvePiCommand returned [node.exe, cli.js]. If someone has
396
+ // only pi.cmd on PATH, point them at the wizard / managed install.
397
+ if (bin.toLowerCase().endsWith(".cmd") || bin.toLowerCase().endsWith(".bat")) {
249
398
  return {
250
399
  success: false,
251
- message: `Directory does not exist: ${cwd}`,
400
+ message:
401
+ "Windows pi spawn requires node.exe + cli.js (managed install). " +
402
+ "Found only pi.cmd on PATH. Run the dashboard setup wizard or " +
403
+ "install pi via the dashboard's Packages view.",
252
404
  };
253
405
  }
254
406
 
255
- // Electron mode always forces headless, skipping tmux detection entirely
256
- if (options?.electronMode || options?.strategy === "headless") {
257
- return spawnHeadless(cwd, options);
407
+ // Prepare a per-session log file for stderr capture.
408
+ const logDir = path.join(os.homedir(), ".pi", "dashboard", "sessions");
409
+ try { mkdirSync(logDir, { recursive: true }); } catch { /* ignore */ }
410
+ const logPath = path.join(logDir, `pi-spawn-${Date.now()}-${Math.floor(Math.random() * 1e6)}.log`);
411
+
412
+ let logFd: number | undefined;
413
+ try {
414
+ logFd = openSync(logPath, "a");
415
+ } catch {
416
+ // If we can't open the log, proceed without stderr capture; still spawn.
417
+ logFd = undefined;
258
418
  }
259
419
 
260
- const platform = detectPlatform();
420
+ const cmdForLog = `${bin} ${[...prefixArgs, ...args].join(" ")}`;
421
+ console.error(`[spawn] Windows headless (detached): ${cmdForLog} (cwd=${cwd}, log=${logPath})`);
422
+
423
+ // CRITICAL: pi's `--mode rpc` listens for `process.stdin.on("end")`
424
+ // and calls shutdown() on EOF. With `stdio[0] = "ignore"`, stdin
425
+ // closes immediately and pi exits before resume completes. Use a
426
+ // parent-held pipe so pi's stdin stays open as long as the dashboard
427
+ // server is alive.
428
+ //
429
+ // Trade-off: when the dashboard server process dies, Windows closes
430
+ // the pipe handle, pi sees EOF, and shuts down. This is the opposite
431
+ // of the Unix `sh -c "tail -f /dev/null | pi"` wrapper (which keeps
432
+ // stdin open via an internal process-group pipe that survives
433
+ // parent death). On Windows we accept "pi dies with dashboard" as
434
+ // the cost of RPC mode working reliably. A future keeper-process
435
+ // approach could restore the durability invariant.
436
+ //
437
+ // detach: false — restores the behaviour of commit d331850 that was
438
+ // silently overridden by 5ab7956's universal `detached: true` invariant.
439
+ // On Windows, `detached: true` allocates a new console for the child
440
+ // unless all stdio slots are "ignore" (libuv `src/win/process.c` only
441
+ // sets CREATE_NO_WINDOW when no slot has UV_INHERIT_FD). With `stdin:
442
+ // "pipe"` we ALWAYS have UV_INHERIT_FD on stdio[0], so CREATE_NO_WINDOW
443
+ // can never fire, and `windowsHide: true` only applies SW_HIDE after
444
+ // allocation — producing brief console flashes on every session spawn.
445
+ // `detach: false` keeps the child inside the parent's Job Object (no
446
+ // new console needed — no flash). "pi dies with dashboard" invariant is
447
+ // unchanged: stdin-EOF on parent death already ties them together.
448
+ //
449
+ // See change: prep-for-develop-merge.
450
+ const r = await spawnDetached({
451
+ cmd: bin,
452
+ args: [...prefixArgs, ...args],
453
+ cwd,
454
+ env,
455
+ logFd,
456
+ stdinMode: "pipe",
457
+ detach: false,
458
+ });
261
459
 
262
- if (platform.strategy === "tmux") {
263
- if (!isTmuxAvailable()) {
264
- return {
265
- success: false,
266
- message: "tmux is not installed. Install it to spawn sessions from the dashboard.",
267
- };
268
- }
460
+ // We don't need the parent's copy of the log fd; the child has its own.
461
+ if (logFd !== undefined) {
462
+ try { closeSync(logFd); } catch { /* ignore */ }
463
+ }
269
464
 
270
- const exists = dashboardSessionExists();
271
- const cmd = buildTmuxCommand(cwd, exists, options);
272
-
273
- try {
274
- execSync(cmd, { stdio: "ignore" });
275
- return {
276
- success: true,
277
- dashboardSpawned: true,
278
- message: `Pi session spawned in tmux (${exists ? "new window" : "new session"})`,
279
- };
280
- } catch (err: any) {
281
- return {
282
- success: false,
283
- message: `Failed to spawn session: ${err.message}`,
284
- };
285
- }
465
+ if (!r.ok || !r.process || !r.pid) {
466
+ return {
467
+ success: false,
468
+ message: `Failed to spawn pi: ${r.error ?? "unknown error"}. Command: ${cmdForLog}`,
469
+ };
286
470
  }
287
471
 
288
- if (platform.strategy === "wsl") {
289
- try {
290
- // Try WSL tmux first
291
- execSync("wsl which tmux", { stdio: "ignore" });
292
- const cmd = `wsl ${buildTmuxCommand(cwd, false)}`;
293
- execSync(cmd, { stdio: "ignore" });
294
- return { success: true, dashboardSpawned: true, message: "Pi session spawned via WSL tmux" };
295
- } catch {
296
- // Fallback to cmd
297
- try {
298
- spawn("cmd", ["/c", `cd /d "${cwd}" && pi`], {
299
- detached: true,
300
- stdio: "ignore",
301
- }).unref();
302
- return { success: true, dashboardSpawned: true, message: "Pi session spawned via cmd" };
303
- } catch (err: any) {
304
- return { success: false, message: `Failed to spawn: ${err.message}` };
305
- }
306
- }
472
+ // Short crash-detection window so we return fast on the happy path
473
+ // but still catch immediate crashes (missing modules, config errors).
474
+ const gate = await waitForNoCrash({ child: r.process, windowMs: 300 });
475
+ if (!gate.ok) {
476
+ return {
477
+ success: false,
478
+ message:
479
+ `Pi process exited immediately (code ${gate.exitCode}). ` +
480
+ `See ${logPath} for details.\nCommand: ${cmdForLog}`,
481
+ };
307
482
  }
308
483
 
309
- return { success: false, message: "Unsupported platform" };
484
+ return {
485
+ success: true,
486
+ dashboardSpawned: true,
487
+ message: `Pi session spawned headless (pid ${r.pid})`,
488
+ pid: r.pid,
489
+ process: r.process,
490
+ };
310
491
  }