@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
@@ -10,29 +10,92 @@
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
12
12
  import os from "node:os";
13
+ import { createRequire } from "node:module";
13
14
 
14
15
  /**
15
- * Find the bundled extension directory relative to a base directory.
16
- * Looks for `packages/extension/` (monorepo layout) under baseDir.
17
- *
18
- * Returns null if:
19
- * - Directory not found
20
- * - No package.json in the directory
21
- * - Path is under /tmp/.mount_* (unstable AppImage mount)
16
+ * Check that a candidate path is a valid, stable extension directory.
17
+ * Returns true when the directory exists, contains a package.json, and
18
+ * is NOT under /tmp/.mount_* (unstable AppImage mount).
22
19
  */
23
- export function findBundledExtension(baseDir: string): string | null {
24
- const candidate = path.resolve(baseDir, "packages", "extension");
25
- if (!fs.existsSync(candidate) || !fs.existsSync(path.join(candidate, "package.json"))) {
26
- return null;
20
+ function isValidExtensionPath(candidate: string): boolean {
21
+ if (!fs.existsSync(candidate)) return false;
22
+ if (!fs.existsSync(path.join(candidate, "package.json"))) return false;
23
+ if (candidate.includes("/tmp/.mount_")) {
24
+ console.warn(
25
+ "[dashboard] AppImage detected — extension path is temporary, skipping registration:",
26
+ candidate,
27
+ );
28
+ return false;
27
29
  }
30
+ return true;
31
+ }
28
32
 
29
- // Reject unstable AppImage temp mount paths
30
- if (candidate.includes("/tmp/.mount_")) {
31
- console.warn("[dashboard] AppImage detected extension path is temporary, skipping registration:", candidate);
33
+ /**
34
+ * Optional dependency injection for `findBundledExtension`. Tests pass
35
+ * `{ resolvePackage: () => null }` to disable the node-resolver fallback.
36
+ */
37
+ export interface FindExtensionDeps {
38
+ /**
39
+ * Resolve `@blackbelt-technology/pi-dashboard-extension/package.json`
40
+ * via Node's module resolver. Return the absolute package.json path
41
+ * or null. Defaults to `createRequire(import.meta.url).resolve(...)`.
42
+ */
43
+ resolvePackage?: () => string | null;
44
+ }
45
+
46
+ function defaultResolvePackage(): string | null {
47
+ try {
48
+ const req = createRequire(import.meta.url);
49
+ return req.resolve("@blackbelt-technology/pi-dashboard-extension/package.json");
50
+ } catch {
32
51
  return null;
33
52
  }
53
+ }
54
+
55
+ /**
56
+ * Find the bundled extension directory.
57
+ *
58
+ * Resolution order:
59
+ * 1. Monorepo layout: `<baseDir>/packages/extension/`.
60
+ * 2. Node module resolution: `@blackbelt-technology/pi-dashboard-extension/package.json`
61
+ * via `require.resolve` from this module. Works in ANY install layout
62
+ * (flat `node_modules/`, scoped, nested, pnpm, npm-g). This is the
63
+ * canonical identity-based lookup and the only reliable strategy
64
+ * when pi-dashboard is installed via `npm i -g`.
65
+ *
66
+ * Returns null if both strategies fail, the resolved directory doesn't
67
+ * have a package.json, or the path is under /tmp/.mount_* (AppImage).
68
+ *
69
+ * See change: unified-bootstrap-install.
70
+ */
71
+ export function findBundledExtension(
72
+ baseDir: string,
73
+ deps: FindExtensionDeps = {},
74
+ ): string | null {
75
+ // Strategy 1: monorepo sibling layout.
76
+ const monorepoCandidate = path.resolve(baseDir, "packages", "extension");
77
+ if (isValidExtensionPath(monorepoCandidate)) return monorepoCandidate;
78
+
79
+ // Strategy 2: Node module resolver. This works for the `npm i -g
80
+ // pi-dashboard` layout where the extension is shipped as a runtime dep
81
+ // of pi-dashboard-server.
82
+ const resolver = deps.resolvePackage ?? defaultResolvePackage;
83
+ const extPkgJson = resolver();
84
+ if (extPkgJson) {
85
+ const extDir = path.dirname(extPkgJson);
86
+ if (isValidExtensionPath(extDir)) return extDir;
87
+ }
88
+
89
+ return null;
90
+ }
34
91
 
35
- return candidate;
92
+ /** Optional overrides for testing / multi-HOME scenarios. */
93
+ export interface BridgeRegisterOptions {
94
+ /**
95
+ * Override the HOME used to locate settings.json. When omitted,
96
+ * falls back to `$HOME || $USERPROFILE || os.homedir()` (existing behavior).
97
+ */
98
+ homedir?: string;
36
99
  }
37
100
 
38
101
  /**
@@ -44,12 +107,16 @@ export function findBundledExtension(baseDir: string): string | null {
44
107
  *
45
108
  * No-op if the path is already registered.
46
109
  */
47
- export function registerBridgeExtension(extensionPath: string): void {
110
+ export function registerBridgeExtension(
111
+ extensionPath: string,
112
+ opts: BridgeRegisterOptions = {},
113
+ ): void {
48
114
  // Compute at call time so tests can override HOME
49
- const settingsPath = path.join(
50
- process.env.HOME || process.env.USERPROFILE || os.homedir(),
51
- ".pi", "agent", "settings.json",
52
- );
115
+ const home = opts.homedir
116
+ ?? process.env.HOME
117
+ ?? process.env.USERPROFILE
118
+ ?? os.homedir();
119
+ const settingsPath = path.join(home, ".pi", "agent", "settings.json");
53
120
  const settingsDir = path.dirname(settingsPath);
54
121
  fs.mkdirSync(settingsDir, { recursive: true });
55
122
 
@@ -125,6 +125,21 @@ export interface SpawnResultBrowserMessage {
125
125
  message: string;
126
126
  }
127
127
 
128
+ /**
129
+ * Emitted when a session spawn fails — either because `spawnPiSession` threw,
130
+ * returned `{ success: false }`, or the spawned child crashed immediately.
131
+ * Carries enough context for the UI to render a retryable error banner
132
+ * instead of leaving the user staring at a silent empty state.
133
+ */
134
+ export interface SpawnErrorMessage {
135
+ type: "spawn_error";
136
+ cwd: string;
137
+ strategy: string;
138
+ message: string;
139
+ /** Up to ~2 KB tail of stderr captured from the failed child, if any. */
140
+ stderr?: string;
141
+ }
142
+
128
143
  export interface SessionsReorderedMessage {
129
144
  type: "sessions_reordered";
130
145
  cwd: string;
@@ -226,6 +241,58 @@ export interface PiCoreUpdateCompleteMessage {
226
241
  sessionsReloaded: number;
227
242
  }
228
243
 
244
+ /**
245
+ * Bootstrap state snapshot. Mirrors `BootstrapState` in
246
+ * `packages/server/src/bootstrap-state.ts` but kept as a structural
247
+ * subset here so the shared package doesn't take a runtime dependency
248
+ * on the server package.
249
+ *
250
+ * See change: unified-bootstrap-install.
251
+ */
252
+ export interface BootstrapStateSnapshot {
253
+ status: "ready" | "installing" | "failed";
254
+ progress?: { step: string; pct?: number; output?: string };
255
+ error?: { message: string; stack?: string };
256
+ version?: { pi?: string; openspec?: string; tsx?: string };
257
+ compatibility?: {
258
+ minimum: string;
259
+ recommended: string;
260
+ maximum: string | null;
261
+ current?: string;
262
+ upgradeRecommended?: boolean;
263
+ upgradeDashboard?: boolean;
264
+ };
265
+ bridgeRegistrationError?: string;
266
+ }
267
+
268
+ /**
269
+ * Broadcast on every bootstrap-state transition. Browsers use this to
270
+ * render the first-run install banner, the upgrade-pi progress display,
271
+ * and version-skew hints.
272
+ */
273
+ export interface BootstrapStatusUpdateMessage {
274
+ type: "bootstrap_status_update";
275
+ state: BootstrapStateSnapshot;
276
+ }
277
+
278
+ /**
279
+ * Broadcast when a queued pi-dependent operation (e.g. a session-spawn
280
+ * request accepted with 202 during "installing") finishes running after
281
+ * the bootstrap transitioned to "ready". Clients that stored a ticketId
282
+ * from the 202 response can correlate the outcome via this message.
283
+ *
284
+ * `success` is true when the queued handler resolved without throwing;
285
+ * `error` carries the thrown message string when `success` is false.
286
+ *
287
+ * See change: unified-bootstrap-install.
288
+ */
289
+ export interface BootstrapTicketCompleteMessage {
290
+ type: "bootstrap_ticket_complete";
291
+ ticketId: string;
292
+ success: boolean;
293
+ error?: string;
294
+ }
295
+
229
296
  /** Sent when a package operation finishes (success or failure). */
230
297
  export interface PackageOperationCompleteMessage {
231
298
  type: "package_operation_complete";
@@ -255,6 +322,7 @@ export type ServerToBrowserMessage =
255
322
  | SessionsListBrowserMessage
256
323
  | ResumeResultBrowserMessage
257
324
  | SpawnResultBrowserMessage
325
+ | SpawnErrorMessage
258
326
  | SessionsReorderedMessage
259
327
  | PinnedDirsUpdatedMessage
260
328
  | TerminalAddedMessage
@@ -274,7 +342,9 @@ export type ServerToBrowserMessage =
274
342
  | BrowserPromptRequestMessage
275
343
  | BrowserPromptDismissMessage
276
344
  | BrowserPromptCancelMessage
277
- | ModelsRefreshedMessage;
345
+ | ModelsRefreshedMessage
346
+ | BootstrapStatusUpdateMessage
347
+ | BootstrapTicketCompleteMessage;
278
348
 
279
349
  // ── Browser → Server ────────────────────────────────────────────────
280
350
 
@@ -41,6 +41,24 @@ export const DEFAULT_MEMORY_LIMITS: MemoryLimitsConfig = {
41
41
  maxWsBufferBytes: 4 * 1024 * 1024,
42
42
  };
43
43
 
44
+ export interface OpenSpecPollConfig {
45
+ /** Poll interval in seconds. Default 30. Clamped to [5, 3600]. */
46
+ pollIntervalSeconds: number;
47
+ /** Max concurrent `openspec` CLI invocations across all dirs. Default 3. Clamped to [1, 16]. */
48
+ maxConcurrentSpawns: number;
49
+ /** `"mtime"` skips re-polling unchanged changes; `"always"` polls unconditionally. Default `"mtime"`. */
50
+ changeDetection: "mtime" | "always";
51
+ /** Max per-directory phase jitter in seconds. 0 disables jitter. Default 5. Clamped to [0, 60]. */
52
+ jitterSeconds: number;
53
+ }
54
+
55
+ export const DEFAULT_OPENSPEC_POLL: OpenSpecPollConfig = {
56
+ pollIntervalSeconds: 30,
57
+ maxConcurrentSpawns: 3,
58
+ changeDetection: "mtime",
59
+ jitterSeconds: 5,
60
+ };
61
+
44
62
  export interface EditorConfig {
45
63
  /** Override path to code-server binary */
46
64
  binary?: string;
@@ -75,6 +93,8 @@ export interface DashboardConfig {
75
93
  defaultModel: string;
76
94
  memoryLimits: MemoryLimitsConfig;
77
95
  editor: EditorConfig;
96
+ /** OpenSpec background polling behavior (interval, concurrency, change detection, jitter) */
97
+ openspec: OpenSpecPollConfig;
78
98
  /** Networks trusted for full access without authentication (CIDR, wildcard, exact IP) */
79
99
  trustedNetworks: string[];
80
100
  /** Merged trustedNetworks + auth.bypassHosts (deduplicated). Computed at load time. */
@@ -108,6 +128,7 @@ const DEFAULTS: DashboardConfig = {
108
128
  defaultModel: "",
109
129
  memoryLimits: { ...DEFAULT_MEMORY_LIMITS },
110
130
  editor: { ...DEFAULT_EDITOR_CONFIG },
131
+ openspec: { ...DEFAULT_OPENSPEC_POLL },
111
132
  trustedNetworks: [],
112
133
  resolvedTrustedNetworks: [],
113
134
  cors: { allowedOrigins: [] },
@@ -117,28 +138,57 @@ const DEFAULTS: DashboardConfig = {
117
138
 
118
139
  /**
119
140
  * Parse and validate the auth config section.
120
- * Returns undefined if auth is not configured or has no providers.
141
+ *
142
+ * Returns undefined ONLY when nothing auth-relevant is configured — that is,
143
+ * when none of `providers`, `bypassHosts`, or `bypassUrls` has any content.
144
+ *
145
+ * When providers is empty but bypassHosts or bypassUrls is populated, this
146
+ * function returns a valid AuthConfig with an empty providers map. The auth
147
+ * plugin already no-ops in that case (providerRegistry.size === 0 → skip
148
+ * OAuth route + cookie plugin registration), so no OAuth flow activates
149
+ * accidentally. But returning an object here lets the caller populate
150
+ * resolvedTrustedNetworks from auth.bypassHosts — which is the entire
151
+ * point of allowing this shape. Before this change, parseAuthConfig
152
+ * returned undefined on empty-providers, which nuked auth.bypassHosts
153
+ * before the resolvedTrustedNetworks merge could read it, and users
154
+ * without OAuth lost remote network access after the UI started writing
155
+ * to auth.bypassHosts. See openspec/changes/fix-trusted-networks-no-oauth.
121
156
  */
122
157
  function parseAuthConfig(raw: any): AuthConfig | undefined {
123
158
  if (!raw || typeof raw !== "object") return undefined;
124
159
  const providers = raw.providers;
125
- if (!providers || typeof providers !== "object" || Object.keys(providers).length === 0) {
126
- return undefined;
127
- }
128
- // Validate each provider has at least clientId and clientSecret
160
+ const hasProviders =
161
+ providers && typeof providers === "object" && Object.keys(providers).length > 0;
162
+ const hasHosts = Array.isArray(raw.bypassHosts) && raw.bypassHosts.length > 0;
163
+ const hasUrls = Array.isArray(raw.bypassUrls) && raw.bypassUrls.length > 0;
164
+ if (!hasProviders && !hasHosts && !hasUrls) return undefined;
165
+
166
+ // Validate each provider has at least clientId and clientSecret.
167
+ // validProviders may end up empty when providers is {} or all entries
168
+ // are malformed — that's fine, the caller tolerates it as long as
169
+ // bypassHosts or bypassUrls carries the auth-relevant content.
129
170
  const validProviders: Record<string, AuthProviderConfig> = {};
130
- for (const [key, value] of Object.entries(providers)) {
131
- const p = value as any;
132
- if (p && typeof p === "object" && p.clientId && p.clientSecret) {
133
- validProviders[key] = {
134
- clientId: p.clientId,
135
- clientSecret: p.clientSecret,
136
- ...(p.issuerUrl ? { issuerUrl: p.issuerUrl } : {}),
137
- ...(p.name ? { name: p.name } : {}),
138
- };
171
+ if (hasProviders) {
172
+ for (const [key, value] of Object.entries(providers as Record<string, unknown>)) {
173
+ const p = value as any;
174
+ if (p && typeof p === "object" && p.clientId && p.clientSecret) {
175
+ validProviders[key] = {
176
+ clientId: p.clientId,
177
+ clientSecret: p.clientSecret,
178
+ ...(p.issuerUrl ? { issuerUrl: p.issuerUrl } : {}),
179
+ ...(p.name ? { name: p.name } : {}),
180
+ };
181
+ }
139
182
  }
140
183
  }
141
- if (Object.keys(validProviders).length === 0) return undefined;
184
+
185
+ // If providers was declared but all entries are malformed AND there is no
186
+ // bypass content, fall back to undefined — same "nothing auth-relevant"
187
+ // rule as the top-level gate.
188
+ if (Object.keys(validProviders).length === 0 && !hasHosts && !hasUrls) {
189
+ return undefined;
190
+ }
191
+
142
192
  return {
143
193
  secret: raw.secret ?? "",
144
194
  providers: validProviders,
@@ -157,6 +207,27 @@ function parseEditorConfig(raw: any): EditorConfig {
157
207
  };
158
208
  }
159
209
 
210
+ function clampNumber(raw: any, fallback: number, min: number, max: number): number {
211
+ const n = typeof raw === "number" && Number.isFinite(raw) ? raw : fallback;
212
+ if (n < min) return min;
213
+ if (n > max) return max;
214
+ return n;
215
+ }
216
+
217
+ function parseOpenSpecPollConfig(raw: any): OpenSpecPollConfig {
218
+ if (!raw || typeof raw !== "object") return { ...DEFAULT_OPENSPEC_POLL };
219
+ const changeDetection =
220
+ raw.changeDetection === "always" || raw.changeDetection === "mtime"
221
+ ? raw.changeDetection
222
+ : DEFAULT_OPENSPEC_POLL.changeDetection;
223
+ return {
224
+ pollIntervalSeconds: clampNumber(raw.pollIntervalSeconds, DEFAULT_OPENSPEC_POLL.pollIntervalSeconds, 5, 3600),
225
+ maxConcurrentSpawns: clampNumber(raw.maxConcurrentSpawns, DEFAULT_OPENSPEC_POLL.maxConcurrentSpawns, 1, 16),
226
+ changeDetection,
227
+ jitterSeconds: clampNumber(raw.jitterSeconds, DEFAULT_OPENSPEC_POLL.jitterSeconds, 0, 60),
228
+ };
229
+ }
230
+
160
231
  function parseMemoryLimits(raw: any): MemoryLimitsConfig {
161
232
  if (!raw || typeof raw !== "object") return { ...DEFAULT_MEMORY_LIMITS };
162
233
  return {
@@ -217,6 +288,7 @@ export function loadConfig(): DashboardConfig {
217
288
  auth: parseAuthConfig(parsed.auth),
218
289
  memoryLimits: parseMemoryLimits(parsed.memoryLimits),
219
290
  editor: parseEditorConfig(parsed.editor),
291
+ openspec: parseOpenSpecPollConfig(parsed.openspec),
220
292
  trustedNetworks: parseTrustedNetworks(parsed.trustedNetworks),
221
293
  resolvedTrustedNetworks: [],
222
294
  cors: {
@@ -1,15 +1,42 @@
1
1
  /**
2
- * Shared constants for the managed install directory (~/.pi-dashboard/).
2
+ * Shared constants + getters for the managed install directory (~/.pi-dashboard/).
3
3
  * Single source of truth — all packages import from here.
4
+ *
5
+ * Constants (MANAGED_DIR, MANAGED_BIN, PI_SETTINGS_PATH) reflect the live
6
+ * environment at module-load time. Production code continues to use them.
7
+ *
8
+ * Getters (getManagedDir, getManagedBin, getPiSettingsPath) accept an
9
+ * optional `{ homedir }` override so tests (and the bootstrap harness)
10
+ * can reason about alternate HOME directories without mutating globals.
4
11
  */
5
12
  import path from "node:path";
6
13
  import os from "node:os";
7
14
 
15
+ /** Env override surface used by the getters (subset of PlatformEnv). */
16
+ export interface ManagedPathsEnv {
17
+ homedir?: string;
18
+ }
19
+
20
+ /** Root directory for managed installs (pi, openspec, tsx). */
21
+ export function getManagedDir(env?: ManagedPathsEnv): string {
22
+ return path.join(env?.homedir ?? os.homedir(), ".pi-dashboard");
23
+ }
24
+
25
+ /** Bin directory for managed install executables. */
26
+ export function getManagedBin(env?: ManagedPathsEnv): string {
27
+ return path.join(getManagedDir(env), "node_modules", ".bin");
28
+ }
29
+
30
+ /** Path to pi's global settings file. */
31
+ export function getPiSettingsPath(env?: ManagedPathsEnv): string {
32
+ return path.join(env?.homedir ?? os.homedir(), ".pi", "agent", "settings.json");
33
+ }
34
+
8
35
  /** Root directory for managed installs (pi, openspec, tsx). */
9
- export const MANAGED_DIR = path.join(os.homedir(), ".pi-dashboard");
36
+ export const MANAGED_DIR = getManagedDir();
10
37
 
11
38
  /** Bin directory for managed install executables. */
12
- export const MANAGED_BIN = path.join(MANAGED_DIR, "node_modules", ".bin");
39
+ export const MANAGED_BIN = getManagedBin();
13
40
 
14
41
  /** Path to pi's global settings file. */
15
- export const PI_SETTINGS_PATH = path.join(os.homedir(), ".pi", "agent", "settings.json");
42
+ export const PI_SETTINGS_PATH = getPiSettingsPath();
@@ -1,45 +1,31 @@
1
1
  /**
2
2
  * Polls the openspec CLI to gather change data for the session's project.
3
- * Uses async child processes to avoid blocking the event loop.
3
+ *
4
+ * This module is a thin aggregator over `platform/openspec.ts`: it
5
+ * calls the Recipe-based primitives and combines `list` + per-change
6
+ * `status` into the dashboard's `OpenSpecData` shape.
7
+ *
8
+ * Two public flavors:
9
+ *
10
+ * - `pollOpenSpec` (sync) — for the bridge extension where async
11
+ * isn't practical. Uses `run()` under the hood; each call blocks
12
+ * the event loop for ~200-2000ms per openspec invocation.
13
+ *
14
+ * - `pollOpenSpecAsync` (async) — for the server's directory service.
15
+ * Routes through the runner's `runAsync()` so every spawn goes
16
+ * through the same binary resolution, `.cmd` shell handling, and
17
+ * `windowsHide: true` default as everything else. Status queries
18
+ * run in parallel via `Promise.all`, keeping the event loop free
19
+ * on Windows where openspec.cmd startup is slow (~2s per call).
20
+ *
21
+ * See change: consolidate-tool-resolution.
4
22
  */
5
- import { spawnSync, execFile } from "node:child_process";
6
- import { promisify } from "node:util";
23
+ import { listOr, statusOr, OPENSPEC_LIST, OPENSPEC_STATUS } from "./platform/openspec.js";
24
+ import { runAsync, unwrap } from "./platform/runner.js";
7
25
  import type { OpenSpecData, OpenSpecChange, OpenSpecArtifact } from "./types.js";
8
26
 
9
- const execFileAsync = promisify(execFile);
10
27
  const EMPTY_DATA: OpenSpecData = { initialized: false, changes: [] };
11
28
 
12
- /** Synchronous version — only used by bridge extension where async isn't practical */
13
- function runOpenSpecSync(args: string[], cwd: string): unknown | null {
14
- try {
15
- const result = spawnSync("openspec", args, {
16
- cwd,
17
- encoding: "utf-8",
18
- stdio: ["pipe", "pipe", "pipe"],
19
- timeout: 10_000,
20
- });
21
- if (result.status !== 0 || !result.stdout) return null;
22
- return JSON.parse(result.stdout);
23
- } catch {
24
- return null;
25
- }
26
- }
27
-
28
- /** Async version — non-blocking, used by server */
29
- async function runOpenSpecAsync(args: string[], cwd: string): Promise<unknown | null> {
30
- try {
31
- const { stdout } = await execFileAsync("openspec", args, {
32
- cwd,
33
- encoding: "utf-8",
34
- timeout: 10_000,
35
- });
36
- if (!stdout) return null;
37
- return JSON.parse(stdout);
38
- } catch {
39
- return null;
40
- }
41
- }
42
-
43
29
  export function buildOpenSpecData(
44
30
  listResult: { changes?: Array<{ name: string; status: string; completedTasks: number; totalTasks: number }> } | null,
45
31
  statusResults: Map<string, { artifacts?: Array<{ id: string; status: string }>; isComplete?: boolean } | null>,
@@ -72,30 +58,61 @@ export function buildOpenSpecData(
72
58
  return { initialized: true, changes };
73
59
  }
74
60
 
75
- /** Synchronous poll — blocks event loop. Used by bridge extension. */
61
+ /**
62
+ * Synchronous poll — blocks the event loop. Used by the bridge extension
63
+ * where async isn't practical (some pi extension hooks are sync).
64
+ */
76
65
  export function pollOpenSpec(cwd: string): OpenSpecData {
77
- const listResult = runOpenSpecSync(["list", "--json"], cwd) as any;
66
+ const listResult = listOr({ cwd }) as any;
78
67
  if (!listResult || !Array.isArray(listResult.changes)) return EMPTY_DATA;
79
68
 
80
69
  const statusResults = new Map<string, any>();
81
70
  for (const c of listResult.changes) {
82
- statusResults.set(c.name, runOpenSpecSync(["status", "--change", c.name, "--json"], cwd));
71
+ statusResults.set(c.name, statusOr({ cwd, change: c.name }));
83
72
  }
84
73
  return buildOpenSpecData(listResult, statusResults);
85
74
  }
86
75
 
87
- /** Async poll — non-blocking. Used by server directory service. */
76
+ /**
77
+ * Run `openspec list --json` for a single cwd. Exposed so callers that
78
+ * want their own concurrency control or mtime-gate logic can compose
79
+ * the list + per-change status calls themselves.
80
+ */
81
+ export async function runOpenSpecList(cwd: string): Promise<
82
+ | { changes?: Array<{ name: string; status: string; completedTasks: number; totalTasks: number }> }
83
+ | null
84
+ > {
85
+ return unwrap(await runAsync(OPENSPEC_LIST, { cwd }, { cwd }), null) as any;
86
+ }
87
+
88
+ /**
89
+ * Run `openspec status --change <name> --json` for a single change.
90
+ * Exposed for the same reason as `runOpenSpecList`.
91
+ */
92
+ export async function runOpenSpecStatus(
93
+ cwd: string,
94
+ changeName: string,
95
+ ): Promise<{ artifacts?: Array<{ id: string; status: string }>; isComplete?: boolean } | null> {
96
+ return unwrap(await runAsync(OPENSPEC_STATUS, { cwd, change: changeName }, { cwd }), null) as any;
97
+ }
98
+
99
+ /**
100
+ * Async poll — genuinely async. Runs per-change status queries in
101
+ * parallel via the shared `runAsync()`, so each spawn goes through the
102
+ * central binary resolution + `windowsHide: true` default.
103
+ */
88
104
  export async function pollOpenSpecAsync(cwd: string): Promise<OpenSpecData> {
89
- const listResult = await runOpenSpecAsync(["list", "--json"], cwd) as any;
105
+ const listResult = unwrap(await runAsync(OPENSPEC_LIST, { cwd }, { cwd }), null) as
106
+ | { changes?: Array<{ name: string; status: string; completedTasks: number; totalTasks: number }> }
107
+ | null;
90
108
  if (!listResult || !Array.isArray(listResult.changes)) return EMPTY_DATA;
91
109
 
92
- // Run all status queries in parallel
93
- const entries = await Promise.all(
94
- listResult.changes.map(async (c: any): Promise<[string, any]> => {
95
- const status = await runOpenSpecAsync(["status", "--change", c.name, "--json"], cwd);
96
- return [c.name, status];
110
+ const statusEntries = await Promise.all(
111
+ listResult.changes.map(async (c) => {
112
+ const result = await runAsync(OPENSPEC_STATUS, { cwd, change: c.name }, { cwd });
113
+ return [c.name, unwrap(result, null)] as const;
97
114
  }),
98
115
  );
99
- const statusResults = new Map<string, any>(entries);
116
+ const statusResults = new Map<string, any>(statusEntries);
100
117
  return buildOpenSpecData(listResult, statusResults);
101
118
  }