@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
@@ -2,6 +2,8 @@
2
2
  * Auto-start logic for the dashboard server.
3
3
  * Uses mDNS discovery first, falls back to health check, then auto-starts.
4
4
  */
5
+ import os from "node:os";
6
+ import path from "node:path";
5
7
 
6
8
  export interface DiscoveredServer {
7
9
  host: string;
@@ -16,6 +18,20 @@ export interface AutoStartDeps {
16
18
  isDashboardRunning: (port: number) => Promise<{ running: boolean; portConflict?: boolean }>;
17
19
  launchServer: (config: any) => Promise<{ success: boolean; message: string }>;
18
20
  notify: (message: string, level: "info" | "warning") => void;
21
+ /**
22
+ * Optional callback fired immediately BEFORE `launchServer(config)` is
23
+ * invoked. Used by TUI-aware callers (bridge extension) to show a
24
+ * "starting dashboard server" spinner. NOT fired during mDNS discovery
25
+ * or health-check phases — only when an actual server process is
26
+ * about to be spawned.
27
+ */
28
+ onLaunchStart?: () => void;
29
+ /**
30
+ * Optional callback fired after `launchServer` resolves (success or
31
+ * failure), AND after the post-launch mDNS re-discovery + recheck.
32
+ * Passes the final success state so the caller can clear spinners.
33
+ */
34
+ onLaunchEnd?: (success: boolean) => void;
19
35
  }
20
36
 
21
37
  export interface AutoStartResult {
@@ -58,8 +74,10 @@ export async function autoStartServer(
58
74
  }
59
75
 
60
76
  // 3. Auto-start server
77
+ deps.onLaunchStart?.();
61
78
  const result = await deps.launchServer(config);
62
79
  if (result.success) {
80
+ deps.onLaunchEnd?.(true);
63
81
  deps.notify(`🌐 Dashboard started at http://localhost:${config.port}`, "info");
64
82
 
65
83
  // Wait for mDNS advertisement from the newly started server (up to 10s)
@@ -79,9 +97,17 @@ export async function autoStartServer(
79
97
  // Another agent may have started the server concurrently — recheck before warning
80
98
  const recheck = await deps.isDashboardRunning(config.port);
81
99
  if (recheck.running) {
100
+ deps.onLaunchEnd?.(true);
82
101
  return { server: { host: "localhost", port: config.port, piPort: config.piPort } };
83
102
  }
84
103
 
85
- deps.notify(`Dashboard server failed to start: ${result.message}`, "warning");
104
+ // Surface the log path so users can inspect the crash output without having
105
+ // to know the convention. See change: fix-windows-server-parity.
106
+ deps.onLaunchEnd?.(false);
107
+ const logPath = path.join(os.homedir(), ".pi", "dashboard", "server.log");
108
+ deps.notify(
109
+ `Dashboard server failed to start: ${result.message}\nSee log: ${logPath}`,
110
+ "warning",
111
+ );
86
112
  return {};
87
113
  }
@@ -3,13 +3,18 @@
3
3
  * The spawned server runs in foreground mode (no subcommand) and writes
4
4
  * its own PID file at ~/.pi/dashboard/server.pid.
5
5
  */
6
- import { spawn } from "node:child_process";
6
+ import { spawnDetached, waitForReady } from "@blackbelt-technology/pi-dashboard-shared/platform/detached-spawn.js";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
7
9
  import path from "node:path";
10
+ import { createRequire } from "node:module";
8
11
  import { fileURLToPath } from "node:url";
9
12
  import type { DashboardConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
10
13
  import { resolveJitiImport } from "@blackbelt-technology/pi-dashboard-shared/resolve-jiti.js";
14
+ import { isDashboardRunning } from "@blackbelt-technology/pi-dashboard-shared/server-identity.js";
11
15
 
12
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ const require = createRequire(import.meta.url);
13
18
 
14
19
  export interface LaunchResult {
15
20
  success: boolean;
@@ -17,11 +22,26 @@ export interface LaunchResult {
17
22
  }
18
23
 
19
24
  /**
20
- * Resolve the dashboard server CLI script path relative to this extension file.
21
- * From packages/extension/src/server-launcher.ts → packages/server/src/cli.ts
25
+ * Resolve the dashboard server CLI script path.
26
+ *
27
+ * Handles two layouts:
28
+ * 1. Monorepo dev: `<repo>/packages/extension/src/` → `<repo>/packages/server/src/cli.ts`
29
+ * 2. Installed : `<x>/node_modules/@blackbelt-technology/pi-dashboard-extension/src/`
30
+ * → `<x>/node_modules/@blackbelt-technology/pi-dashboard-server/src/cli.ts`
31
+ *
32
+ * Uses Node's module resolver (`require.resolve`) to find the server package
33
+ * and joins `src/cli.ts`. Falls back to the monorepo-relative path so existing
34
+ * dev workflows keep working even if the server package isn't resolvable (e.g.
35
+ * a pristine checkout with no node_modules yet).
22
36
  */
23
37
  export function resolveServerCliPath(): string {
24
- return path.resolve(__dirname, "..", "..", "server", "src", "cli.ts");
38
+ try {
39
+ const serverPkgJson = require.resolve("@blackbelt-technology/pi-dashboard-server/package.json");
40
+ return path.resolve(path.dirname(serverPkgJson), "src", "cli.ts");
41
+ } catch {
42
+ // Dev-repo fallback: <extension>/src/../../server/src/cli.ts
43
+ return path.resolve(__dirname, "..", "..", "server", "src", "cli.ts");
44
+ }
25
45
  }
26
46
 
27
47
  /**
@@ -43,36 +63,60 @@ export async function launchServer(config: DashboardConfig): Promise<LaunchResul
43
63
  const args = buildSpawnArgs(config);
44
64
 
45
65
  try {
46
- // Spawn server using pi's jiti TypeScript loader (resolved to absolute path).
47
- // The server writes its own PID file on startup, so
48
- // `pi-dashboard status` can detect it.
49
- const child = spawn(process.execPath, ["--import", resolveJitiImport(), cliPath, ...args], {
50
- detached: true,
51
- stdio: "ignore",
66
+ // Open the server.log in append mode so any startup error is visible.
67
+ // Matches the log location used by `pi-dashboard start`.
68
+ let logFd: number | undefined;
69
+ try {
70
+ const logDir = path.join(os.homedir(), ".pi", "dashboard");
71
+ fs.mkdirSync(logDir, { recursive: true });
72
+ const logPath = path.join(logDir, "server.log");
73
+ logFd = fs.openSync(logPath, "a");
74
+ fs.writeSync(
75
+ logFd,
76
+ `\n[${new Date().toISOString()}] bridge auto-start (parent pid ${process.pid}, port ${config.port})\n`,
77
+ );
78
+ } catch { /* if we can't open the log, spawn still works */ }
79
+
80
+ // Spawn server via the detached-spawn primitive. resolveJitiImport()
81
+ // returns a file:// URL (required on Windows for node --import).
82
+ const r = await spawnDetached({
83
+ cmd: process.execPath,
84
+ args: ["--import", resolveJitiImport(), cliPath, ...args],
52
85
  env: { ...process.env },
86
+ logFd,
53
87
  });
54
88
 
55
- child.unref();
56
-
57
- // Monitor for early exit (within 2s)
58
- const earlyExit = await new Promise<boolean>((resolve) => {
59
- const timer = setTimeout(() => {
60
- resolve(false); // No early exit — server is running
61
- }, 2000);
89
+ // Close the parent's copy of the log fd — the child has its own.
90
+ if (logFd !== undefined) {
91
+ try { fs.closeSync(logFd); } catch { /* ignore */ }
92
+ }
62
93
 
63
- child.on("exit", () => {
64
- clearTimeout(timer);
65
- resolve(true); // Exited early — failure
66
- });
94
+ if (!r.ok || !r.process) {
95
+ return { success: false, message: `Server process failed to spawn: ${r.error ?? "unknown"}` };
96
+ }
67
97
 
68
- child.on("error", () => {
69
- clearTimeout(timer);
70
- resolve(true);
71
- });
98
+ // Wait for the server to actually become available via positive
99
+ // HTTP probe. NO deadline — we rely on child-exit for failure
100
+ // detection. A timeout here only catches the pathological case
101
+ // "process alive but never ready", which is rarer than the
102
+ // false-positive case "slow cold-start mistakenly flagged as
103
+ // failure" (Fastify + jiti compile + session scan can take 15–30s
104
+ // on Windows). If the child crashes, `waitForReady` returns
105
+ // { ok: false, error: "child exited with code N" } via its
106
+ // `child` listener. If the child hangs alive-but-broken, the user
107
+ // can kill it manually — timers don't help that case anyway.
108
+ const ready = await waitForReady({
109
+ probe: async () => (await isDashboardRunning(config.port)).running,
110
+ pollIntervalMs: 300,
111
+ child: r.process,
112
+ // deadlineMs intentionally omitted — wait indefinitely.
72
113
  });
73
114
 
74
- if (earlyExit) {
75
- return { success: false, message: "Server process exited immediately" };
115
+ if (!ready.ok) {
116
+ return {
117
+ success: false,
118
+ message: `Server process failed: ${ready.error ?? "unknown"}. See ~/.pi/dashboard/server.log`,
119
+ };
76
120
  }
77
121
 
78
122
  return { success: true, message: "Server started" };
@@ -1,8 +1,19 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-server",
3
- "version": "0.2.9",
3
+ "version": "0.4.0",
4
4
  "description": "Dashboard server for monitoring and interacting with pi agent sessions",
5
5
  "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "engines": {
10
+ "node": ">=22.18.0"
11
+ },
12
+ "piCompatibility": {
13
+ "minimum": "0.6.7",
14
+ "recommended": "0.6.7",
15
+ "maximum": null
16
+ },
6
17
  "main": "src/cli.ts",
7
18
  "bin": {
8
19
  "pi-dashboard": "src/cli.ts"
@@ -15,7 +26,9 @@
15
26
  "postinstall": "node scripts/fix-pty-permissions.cjs"
16
27
  },
17
28
  "dependencies": {
18
- "@blackbelt-technology/pi-dashboard-shared": "*",
29
+ "@blackbelt-technology/pi-dashboard-extension": "^0.4.0",
30
+ "@blackbelt-technology/pi-dashboard-shared": "^0.4.0",
31
+ "@fastify/compress": "^8.3.1",
19
32
  "@fastify/cookie": "^11.0.2",
20
33
  "@fastify/cors": "^11.0.0",
21
34
  "@fastify/http-proxy": "^11.4.3",
@@ -27,11 +40,13 @@
27
40
  "fastify": "^5.0.0",
28
41
  "jsonwebtoken": "^9.0.3",
29
42
  "node-pty": "^1.1.0",
43
+ "proper-lockfile": "^4.1.2",
30
44
  "ws": "^8.18.0"
31
45
  },
32
46
  "devDependencies": {
33
47
  "@types/diff": "^7.0.0",
34
48
  "@types/jsonwebtoken": "^9.0.9",
49
+ "@types/proper-lockfile": "^4.1.4",
35
50
  "@types/ws": "^8.18.1"
36
51
  }
37
52
  }
@@ -15,6 +15,13 @@ async function connectSession(piPort: number, sessionId: string): Promise<WebSoc
15
15
  cwd: "/tmp",
16
16
  source: "cli",
17
17
  }));
18
+ // Without replay_complete, event-wiring treats incoming events as replay
19
+ // and suppresses auto-attach. Send it immediately so subsequent events run
20
+ // through the normal live path.
21
+ ws.send(JSON.stringify({
22
+ type: "replay_complete",
23
+ sessionId,
24
+ }));
18
25
  setTimeout(resolve, 50);
19
26
  });
20
27
  });
@@ -53,6 +60,8 @@ function sendToolEvent(ws: WebSocket, sessionId: string, opts: { phase?: string;
53
60
  }));
54
61
  }
55
62
  if (opts.changeName) {
63
+ // Use Write (active) so auto-attach fires — Read is passive and only sets openspecChange,
64
+ // not attachedProposal (see event-wiring.ts: attach requires detected.isActive).
56
65
  ws.send(JSON.stringify({
57
66
  type: "event_forward",
58
67
  sessionId,
@@ -60,7 +69,7 @@ function sendToolEvent(ws: WebSocket, sessionId: string, opts: { phase?: string;
60
69
  eventType: "tool_execution_start",
61
70
  timestamp: Date.now(),
62
71
  data: {
63
- toolName: "Read",
72
+ toolName: "Write",
64
73
  args: { path: `openspec/changes/${opts.changeName}/proposal.md` },
65
74
  },
66
75
  },
@@ -35,7 +35,11 @@ describe("Server auto-shutdown", () => {
35
35
  }
36
36
  });
37
37
 
38
- it("should shut down after idle timeout when no sessions connect", async () => {
38
+ // TODO(fix-failing-tests-followup): fake-timer + real HTTP close races; idle-timer
39
+ // fires (console log confirms) but `process.exit(0)` is reached only after
40
+ // `await stopServer()` resolves, which depends on real I/O not driven by
41
+ // `vi.advanceTimersByTimeAsync`. See openspec/changes/fix-failing-tests/tasks.md §7.
42
+ it.skip("should shut down after idle timeout when no sessions connect", async () => {
39
43
  const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
40
44
 
41
45
  await server.start();
@@ -46,7 +50,9 @@ describe("Server auto-shutdown", () => {
46
50
  exitSpy.mockRestore();
47
51
  });
48
52
 
49
- it("should not shut down when autoShutdown is false", async () => {
53
+ // TODO(fix-failing-tests-followup): afterEach hook times out; `server.stop()`
54
+ // under fake timers doesn't drain real I/O cleanly. See §7.
55
+ it.skip("should not shut down when autoShutdown is false", async () => {
50
56
  await server.stop();
51
57
  testPort += 2;
52
58
  server = await createServer({
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Unit tests for the in-memory bootstrap ticket queue.
3
+ *
4
+ * See change: unified-bootstrap-install.
5
+ */
6
+ import { describe, it, expect } from "vitest";
7
+ import { createBootstrapQueue } from "../bootstrap-queue.js";
8
+
9
+ describe("bootstrap-queue", () => {
10
+ it("enqueue returns a unique ticketId + pending result", () => {
11
+ const q = createBootstrapQueue();
12
+ const a = q.enqueue(async () => "A");
13
+ const b = q.enqueue(async () => "B");
14
+ expect(a.ticketId).not.toBe(b.ticketId);
15
+ expect(q.size()).toBe(2);
16
+ });
17
+
18
+ it("flushAll runs handlers in enqueue order and resolves results", async () => {
19
+ const q = createBootstrapQueue();
20
+ const order: string[] = [];
21
+ const a = q.enqueue(async () => {
22
+ order.push("a");
23
+ return "A";
24
+ });
25
+ const b = q.enqueue(async () => {
26
+ order.push("b");
27
+ return "B";
28
+ });
29
+ await q.flushAll();
30
+ expect(order).toEqual(["a", "b"]);
31
+ await expect(a.result).resolves.toBe("A");
32
+ await expect(b.result).resolves.toBe("B");
33
+ expect(q.size()).toBe(0);
34
+ });
35
+
36
+ it("handler exceptions reject the ticket promise", async () => {
37
+ const q = createBootstrapQueue();
38
+ const t = q.enqueue(async () => {
39
+ throw new Error("boom");
40
+ });
41
+ await q.flushAll();
42
+ await expect(t.result).rejects.toThrow("boom");
43
+ });
44
+
45
+ it("onTicketComplete fires success=true for resolved handlers", async () => {
46
+ const q = createBootstrapQueue();
47
+ const events: Array<{ ticketId: string; success: boolean; error?: string }> = [];
48
+ q.onTicketComplete((e) => events.push(e));
49
+ const t = q.enqueue(async () => 42);
50
+ await q.flushAll();
51
+ await t.result;
52
+ expect(events).toEqual([{ ticketId: t.ticketId, success: true }]);
53
+ });
54
+
55
+ it("onTicketComplete fires success=false with error message on rejection", async () => {
56
+ const q = createBootstrapQueue();
57
+ const events: Array<{ ticketId: string; success: boolean; error?: string }> = [];
58
+ q.onTicketComplete((e) => events.push(e));
59
+ const t = q.enqueue(async () => {
60
+ throw new Error("oh no");
61
+ });
62
+ await q.flushAll();
63
+ await t.result.catch(() => undefined);
64
+ expect(events).toEqual([
65
+ { ticketId: t.ticketId, success: false, error: "oh no" },
66
+ ]);
67
+ });
68
+
69
+ it("onTicketComplete returns an unsubscribe function", async () => {
70
+ const q = createBootstrapQueue();
71
+ const events: unknown[] = [];
72
+ const off = q.onTicketComplete((e) => events.push(e));
73
+ off();
74
+ q.enqueue(async () => "x");
75
+ await q.flushAll();
76
+ expect(events).toEqual([]);
77
+ });
78
+
79
+ it("clear drops pending tickets with an error result and broadcasts completion", async () => {
80
+ const q = createBootstrapQueue();
81
+ const events: Array<{ ticketId: string; success: boolean; error?: string }> = [];
82
+ q.onTicketComplete((e) => events.push(e));
83
+ const t = q.enqueue(async () => "never runs");
84
+ q.clear("server shutting down");
85
+ await t.result.catch(() => undefined);
86
+ expect(events).toHaveLength(1);
87
+ expect(events[0]).toMatchObject({
88
+ ticketId: t.ticketId,
89
+ success: false,
90
+ error: "server shutting down",
91
+ });
92
+ expect(q.size()).toBe(0);
93
+ });
94
+
95
+ it("multiple listeners all receive the completion event", async () => {
96
+ const q = createBootstrapQueue();
97
+ const a: unknown[] = [];
98
+ const b: unknown[] = [];
99
+ q.onTicketComplete((e) => a.push(e));
100
+ q.onTicketComplete((e) => b.push(e));
101
+ const t = q.enqueue(async () => "ok");
102
+ await q.flushAll();
103
+ await t.result;
104
+ expect(a).toHaveLength(1);
105
+ expect(b).toHaveLength(1);
106
+ });
107
+
108
+ it("a listener that throws does not block other listeners", async () => {
109
+ const q = createBootstrapQueue();
110
+ const seen: unknown[] = [];
111
+ q.onTicketComplete(() => {
112
+ throw new Error("listener crash");
113
+ });
114
+ q.onTicketComplete((e) => seen.push(e));
115
+ const t = q.enqueue(async () => "ok");
116
+ await q.flushAll();
117
+ await t.result;
118
+ expect(seen).toHaveLength(1);
119
+ });
120
+ });
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Route tests for `/api/bootstrap/*`.
3
+ *
4
+ * Spins up a minimal Fastify instance with the bootstrap routes wired
5
+ * to a fresh state store and a pair of spy triggers. No real network
6
+ * access, no real subprocesses.
7
+ *
8
+ * See change: unified-bootstrap-install.
9
+ */
10
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
11
+ import Fastify, { type FastifyInstance } from "fastify";
12
+ import { createBootstrapState, type BootstrapStateStore } from "../bootstrap-state.js";
13
+ import { registerBootstrapRoutes } from "../routes/bootstrap-routes.js";
14
+
15
+ const noopGuard = async () => {
16
+ /* allow all requests in tests */
17
+ };
18
+
19
+ interface Harness {
20
+ app: FastifyInstance;
21
+ state: BootstrapStateStore;
22
+ upgradeCalls: string[];
23
+ retryCalls: string[];
24
+ }
25
+
26
+ async function makeHarness(): Promise<Harness> {
27
+ const app = Fastify({ logger: false });
28
+ const state = createBootstrapState();
29
+ const upgradeCalls: string[] = [];
30
+ const retryCalls: string[] = [];
31
+
32
+ registerBootstrapRoutes(app, {
33
+ bootstrapState: state,
34
+ networkGuard: noopGuard,
35
+ triggerUpgradePi: async (ticketId) => {
36
+ upgradeCalls.push(ticketId);
37
+ },
38
+ triggerRetry: async (ticketId) => {
39
+ retryCalls.push(ticketId);
40
+ },
41
+ });
42
+
43
+ await app.ready();
44
+ return { app, state, upgradeCalls, retryCalls };
45
+ }
46
+
47
+ describe("bootstrap-routes", () => {
48
+ let h: Harness;
49
+
50
+ beforeEach(async () => {
51
+ h = await makeHarness();
52
+ });
53
+
54
+ afterEach(async () => {
55
+ await h.app.close();
56
+ });
57
+
58
+ describe("GET /api/bootstrap/status", () => {
59
+ it("returns the current state (default ready)", async () => {
60
+ const res = await h.app.inject({ method: "GET", url: "/api/bootstrap/status" });
61
+ expect(res.statusCode).toBe(200);
62
+ expect(res.json()).toEqual({ status: "ready" });
63
+ });
64
+
65
+ it("reflects subsequent state changes", async () => {
66
+ h.state.set({ status: "installing", progress: { step: "pi" } });
67
+ const res = await h.app.inject({ method: "GET", url: "/api/bootstrap/status" });
68
+ expect(res.json()).toMatchObject({
69
+ status: "installing",
70
+ progress: { step: "pi" },
71
+ });
72
+ });
73
+ });
74
+
75
+ describe("POST /api/bootstrap/upgrade-pi", () => {
76
+ it("returns 202 with a ticketId and invokes the trigger", async () => {
77
+ const res = await h.app.inject({ method: "POST", url: "/api/bootstrap/upgrade-pi" });
78
+ expect(res.statusCode).toBe(202);
79
+ const body = res.json() as { ticketId: string; status: string };
80
+ expect(body.status).toBe("accepted");
81
+ expect(typeof body.ticketId).toBe("string");
82
+ expect(body.ticketId.length).toBeGreaterThan(0);
83
+ // Trigger runs async — await a microtask.
84
+ await new Promise((r) => setImmediate(r));
85
+ expect(h.upgradeCalls).toEqual([body.ticketId]);
86
+ });
87
+
88
+ it("returns 409 when an install is already in progress", async () => {
89
+ h.state.set({ status: "installing" });
90
+ const res = await h.app.inject({ method: "POST", url: "/api/bootstrap/upgrade-pi" });
91
+ expect(res.statusCode).toBe(409);
92
+ expect(h.upgradeCalls).toEqual([]);
93
+ });
94
+
95
+ it("is allowed when status is failed (to upgrade after a previous failure)", async () => {
96
+ h.state.set({ status: "failed", error: { message: "network" } });
97
+ const res = await h.app.inject({ method: "POST", url: "/api/bootstrap/upgrade-pi" });
98
+ expect(res.statusCode).toBe(202);
99
+ });
100
+ });
101
+
102
+ describe("POST /api/bootstrap/retry", () => {
103
+ it("returns 409 when status is ready", async () => {
104
+ const res = await h.app.inject({ method: "POST", url: "/api/bootstrap/retry" });
105
+ expect(res.statusCode).toBe(409);
106
+ expect(h.retryCalls).toEqual([]);
107
+ });
108
+
109
+ it("returns 409 when status is installing", async () => {
110
+ h.state.set({ status: "installing" });
111
+ const res = await h.app.inject({ method: "POST", url: "/api/bootstrap/retry" });
112
+ expect(res.statusCode).toBe(409);
113
+ expect(h.retryCalls).toEqual([]);
114
+ });
115
+
116
+ it("returns 202 when status is failed and invokes the trigger", async () => {
117
+ h.state.set({ status: "failed", error: { message: "network" } });
118
+ const res = await h.app.inject({ method: "POST", url: "/api/bootstrap/retry" });
119
+ expect(res.statusCode).toBe(202);
120
+ const body = res.json() as { ticketId: string };
121
+ await new Promise((r) => setImmediate(r));
122
+ expect(h.retryCalls).toEqual([body.ticketId]);
123
+ });
124
+ });
125
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Unit tests for the in-memory bootstrap state store.
3
+ *
4
+ * See change: unified-bootstrap-install.
5
+ */
6
+ import { describe, it, expect } from "vitest";
7
+ import { createBootstrapState } from "../bootstrap-state.js";
8
+
9
+ describe("bootstrap-state", () => {
10
+ it("defaults to status=ready", () => {
11
+ const s = createBootstrapState();
12
+ expect(s.get()).toEqual({ status: "ready" });
13
+ });
14
+
15
+ it("applies initial overrides", () => {
16
+ const s = createBootstrapState({
17
+ status: "installing",
18
+ progress: { step: "pi", output: "starting" },
19
+ });
20
+ const state = s.get();
21
+ expect(state.status).toBe("installing");
22
+ expect(state.progress).toEqual({ step: "pi", output: "starting" });
23
+ });
24
+
25
+ it("set merges partial into state", () => {
26
+ const s = createBootstrapState();
27
+ s.set({ status: "installing", progress: { step: "pi" } });
28
+ expect(s.get().status).toBe("installing");
29
+ s.set({ progress: { step: "openspec" } });
30
+ expect(s.get().progress).toEqual({ step: "openspec" });
31
+ expect(s.get().status).toBe("installing");
32
+ });
33
+
34
+ it("set with undefined explicitly clears a key", () => {
35
+ const s = createBootstrapState({ progress: { step: "pi" } });
36
+ expect(s.get().progress).toBeDefined();
37
+ s.set({ progress: undefined });
38
+ expect(s.get().progress).toBeUndefined();
39
+ });
40
+
41
+ it("notifies subscribers on set", () => {
42
+ const s = createBootstrapState();
43
+ const calls: string[] = [];
44
+ s.subscribe((st) => calls.push(st.status));
45
+ s.set({ status: "installing" });
46
+ s.set({ status: "ready" });
47
+ expect(calls).toEqual(["installing", "ready"]);
48
+ });
49
+
50
+ it("subscribe returns an unsubscribe function", () => {
51
+ const s = createBootstrapState();
52
+ const calls: string[] = [];
53
+ const off = s.subscribe((st) => calls.push(st.status));
54
+ s.set({ status: "installing" });
55
+ off();
56
+ s.set({ status: "ready" });
57
+ expect(calls).toEqual(["installing"]);
58
+ });
59
+
60
+ it("listener errors do not stop other listeners", () => {
61
+ const s = createBootstrapState();
62
+ const calls: string[] = [];
63
+ s.subscribe(() => {
64
+ throw new Error("boom");
65
+ });
66
+ s.subscribe((st) => calls.push(st.status));
67
+ s.set({ status: "installing" });
68
+ expect(calls).toEqual(["installing"]);
69
+ });
70
+
71
+ it("dispose clears all listeners", () => {
72
+ const s = createBootstrapState();
73
+ const calls: string[] = [];
74
+ s.subscribe((st) => calls.push(st.status));
75
+ s.dispose();
76
+ s.set({ status: "installing" });
77
+ expect(calls).toEqual([]);
78
+ });
79
+
80
+ it("get returns a fresh snapshot (external mutation does not affect store)", () => {
81
+ const s = createBootstrapState({ progress: { step: "pi" } });
82
+ const snap = s.get();
83
+ snap.status = "failed";
84
+ expect(s.get().status).toBe("ready");
85
+ });
86
+
87
+ describe("lastInstallPackages", () => {
88
+ it("defaults to an empty array", () => {
89
+ const s = createBootstrapState();
90
+ expect(s.getLastInstallPackages()).toEqual([]);
91
+ });
92
+
93
+ it("records and returns a fresh copy", () => {
94
+ const s = createBootstrapState();
95
+ s.setLastInstallPackages(["pi", "openspec"]);
96
+ const got = s.getLastInstallPackages();
97
+ expect(got).toEqual(["pi", "openspec"]);
98
+ // External mutation does not affect the stored value.
99
+ got.push("tsx");
100
+ expect(s.getLastInstallPackages()).toEqual(["pi", "openspec"]);
101
+ });
102
+
103
+ it("accepts a readonly input without type error", () => {
104
+ const s = createBootstrapState();
105
+ const readonlyInput: readonly string[] = ["a", "b"];
106
+ s.setLastInstallPackages(readonlyInput);
107
+ expect(s.getLastInstallPackages()).toEqual(["a", "b"]);
108
+ });
109
+
110
+ it("is independent of status broadcast (not part of snapshot)", () => {
111
+ const s = createBootstrapState();
112
+ const seen: string[] = [];
113
+ s.subscribe((st) => seen.push(st.status));
114
+ s.setLastInstallPackages(["pi"]);
115
+ // setLastInstallPackages MUST NOT trigger a listener.
116
+ expect(seen).toEqual([]);
117
+ });
118
+ });
119
+ });