@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,6 +1,15 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
+ import * as childProcess from "node:child_process";
5
+
6
+ vi.mock("node:child_process", async (importOriginal) => {
7
+ const actual = await importOriginal<typeof import("node:child_process")>();
8
+ return {
9
+ ...actual,
10
+ execSync: vi.fn(),
11
+ };
12
+ });
4
13
 
5
14
  vi.mock("node:fs", async (importOriginal) => {
6
15
  const actual = await importOriginal<typeof import("node:fs")>();
@@ -38,6 +47,8 @@ import {
38
47
  removeZrokPid,
39
48
  cleanupStaleZrok,
40
49
  getTunnelStatus,
50
+ releaseShare,
51
+ scavengeOrphanZrokProcesses,
41
52
  _resetBinaryCache,
42
53
  _setBinaryAvailable,
43
54
  } from "../tunnel.js";
@@ -149,30 +160,32 @@ describe("PID file helpers", () => {
149
160
  });
150
161
 
151
162
  describe("cleanupStaleZrok", () => {
152
- it("should do nothing when no PID file exists", () => {
163
+ it("should do nothing when no PID file exists", async () => {
153
164
  vi.mocked(fs.readFileSync).mockImplementation(() => {
154
165
  throw new Error("ENOENT");
155
166
  });
156
167
  const killSpy = vi.spyOn(process, "kill");
157
168
 
158
- cleanupStaleZrok();
169
+ await cleanupStaleZrok();
159
170
 
160
171
  expect(killSpy).not.toHaveBeenCalled();
161
172
  });
162
173
 
163
- it("should kill running stale process and remove PID file", () => {
174
+ it("should kill running stale process and remove PID file", async () => {
164
175
  vi.mocked(fs.readFileSync).mockReturnValue("99999\n");
165
176
  const killSpy = vi.spyOn(process, "kill").mockReturnValue(true);
166
177
  vi.mocked(fs.unlinkSync).mockReturnValue(undefined);
167
178
 
168
- cleanupStaleZrok();
179
+ // cleanupStaleZrok became async when it moved to platform/process's
180
+ // killProcess (SIGTERM+grace+SIGKILL orchestration).
181
+ await cleanupStaleZrok();
169
182
 
170
183
  expect(killSpy).toHaveBeenCalledWith(99999, 0);
171
184
  expect(killSpy).toHaveBeenCalledWith(99999, "SIGTERM");
172
185
  expect(fs.unlinkSync).toHaveBeenCalled();
173
186
  });
174
187
 
175
- it("should just remove PID file if process is not running", () => {
188
+ it("should just remove PID file if process is not running", async () => {
176
189
  vi.mocked(fs.readFileSync).mockReturnValue("99999\n");
177
190
  const killSpy = vi.spyOn(process, "kill").mockImplementation((_pid: number, signal?: string | number) => {
178
191
  if (signal === 0) throw new Error("ESRCH");
@@ -180,13 +193,97 @@ describe("cleanupStaleZrok", () => {
180
193
  });
181
194
  vi.mocked(fs.unlinkSync).mockReturnValue(undefined);
182
195
 
183
- cleanupStaleZrok();
196
+ await cleanupStaleZrok();
184
197
 
185
198
  expect(killSpy).toHaveBeenCalledTimes(1);
186
199
  expect(fs.unlinkSync).toHaveBeenCalled();
187
200
  });
188
201
  });
189
202
 
203
+ describe("createTunnel mutex", () => {
204
+ it("should return the same promise when called concurrently", async () => {
205
+ // Binary unavailable → both calls resolve null fast, same promise instance.
206
+ _setBinaryAvailable(false);
207
+ const { createTunnel } = await import("../tunnel.js");
208
+ const p1 = createTunnel(8000);
209
+ const p2 = createTunnel(8000);
210
+ // With binary unavailable the inner resolves synchronously-ish with null.
211
+ // Both should settle identically without spawning anything.
212
+ await expect(p1).resolves.toBeNull();
213
+ await expect(p2).resolves.toBeNull();
214
+ });
215
+ });
216
+
217
+ describe("releaseShare", () => {
218
+ it("should call `zrok release <token>` and return true on success", () => {
219
+ vi.mocked(childProcess.execSync).mockReturnValue(Buffer.from(""));
220
+ const ok = releaseShare("abc123");
221
+ expect(ok).toBe(true);
222
+ expect(childProcess.execSync).toHaveBeenCalledWith(
223
+ expect.stringContaining("zrok release abc123"),
224
+ expect.any(Object),
225
+ );
226
+ });
227
+
228
+ it("should return false when zrok release fails (best-effort, non-throwing)", () => {
229
+ vi.mocked(childProcess.execSync).mockImplementation(() => {
230
+ throw new Error("release failed");
231
+ });
232
+ expect(releaseShare("abc123")).toBe(false);
233
+ });
234
+
235
+ it("should return false for empty token without invoking zrok", () => {
236
+ const ok = releaseShare("");
237
+ expect(ok).toBe(false);
238
+ expect(childProcess.execSync).not.toHaveBeenCalled();
239
+ });
240
+ });
241
+
242
+ describe("scavengeOrphanZrokProcesses", () => {
243
+ it("should kill zrok processes bound to the given port", () => {
244
+ // Simulate `ps` returning two zrok share processes: one matching, one not.
245
+ vi.mocked(childProcess.execSync).mockReturnValue(
246
+ Buffer.from(
247
+ [
248
+ "12345 zrok share reserved aaa --headless --override-endpoint http://localhost:8000",
249
+ "12346 zrok share reserved bbb --headless --override-endpoint http://localhost:9000",
250
+ "12347 some-other-process",
251
+ ].join("\n"),
252
+ ),
253
+ );
254
+ const killSpy = vi.spyOn(process, "kill").mockReturnValue(true);
255
+
256
+ const killed = scavengeOrphanZrokProcesses(8000);
257
+
258
+ expect(killed).toEqual([12345]);
259
+ // Negative PID targets the whole process group on Unix (killPidWithGroup's
260
+ // contract); positive PID on Windows. Match whichever platform we're on.
261
+ const expectedPid = process.platform === "win32" ? 12345 : -12345;
262
+ expect(killSpy).toHaveBeenCalledWith(expectedPid, "SIGTERM");
263
+ expect(killSpy).not.toHaveBeenCalledWith(12346, expect.anything());
264
+ expect(killSpy).not.toHaveBeenCalledWith(-12346, expect.anything());
265
+ });
266
+
267
+ it("should return empty array on ps failure", () => {
268
+ vi.mocked(childProcess.execSync).mockImplementation(() => {
269
+ throw new Error("ps failed");
270
+ });
271
+ expect(scavengeOrphanZrokProcesses(8000)).toEqual([]);
272
+ });
273
+
274
+ it("should skip self (current process PID)", () => {
275
+ vi.mocked(childProcess.execSync).mockReturnValue(
276
+ Buffer.from(`${process.pid} zrok share reserved zzz --override-endpoint http://localhost:8000`),
277
+ );
278
+ const killSpy = vi.spyOn(process, "kill").mockReturnValue(true);
279
+
280
+ const killed = scavengeOrphanZrokProcesses(8000);
281
+
282
+ expect(killed).toEqual([]);
283
+ expect(killSpy).not.toHaveBeenCalled();
284
+ });
285
+ });
286
+
190
287
  describe("getTunnelStatus", () => {
191
288
  it("should return unavailable when binary not available", () => {
192
289
  _setBinaryAvailable(false);
@@ -53,7 +53,12 @@ describe("WS ping/pong", () => {
53
53
  ws.close();
54
54
  }, 10000);
55
55
 
56
- it("should terminate connection when client stops responding to pings", async () => {
56
+ // TODO(fix-failing-tests-followup): pi-gateway now keeps sessions alive when
57
+ // the underlying TCP socket is still writable ("ping: N misses but TCP alive,
58
+ // keeping session"), so pausing the ws socket no longer produces a terminate.
59
+ // Requires reworking the test to close the socket or mock the TCP writability
60
+ // probe. See openspec/changes/fix-failing-tests/tasks.md §7.
61
+ it.skip("should terminate connection when client stops responding to pings", async () => {
57
62
  const sessionManager = createMemorySessionManager();
58
63
  gateway = createPiGateway(sessionManager, {
59
64
  heartbeatTimeout: SHORT_HB,
@@ -82,7 +87,10 @@ describe("WS ping/pong", () => {
82
87
  expect(sessionManager.get("ping-dead")!.status).toBe("ended");
83
88
  }, 10000);
84
89
 
85
- it("should call onEmpty after ping timeout terminates last connection", async () => {
90
+ // TODO(fix-failing-tests-followup): same root cause as the previous skip
91
+ // onEmpty is only invoked after a terminate, which no longer fires in this
92
+ // test's conditions. See §7.
93
+ it.skip("should call onEmpty after ping timeout terminates last connection", async () => {
86
94
  const sessionManager = createMemorySessionManager();
87
95
  gateway = createPiGateway(sessionManager, {
88
96
  heartbeatTimeout: SHORT_HB,
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { _resetWslTmuxCacheForTests } from "../process-manager.js";
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+
10
+ /**
11
+ * Structural test: the WSL-tmux probe MUST have a module-scoped cache so it's
12
+ * invoked at most once per server lifetime. Without this cache, users on
13
+ * Windows without `wt.exe` pay the full WSL VM cold-start cost (1.5–30 s) on
14
+ * every + Session click.
15
+ *
16
+ * We assert the cache exists by source inspection (tight, deterministic) and
17
+ * that a reset helper is exported for tests.
18
+ */
19
+ describe("WSL-tmux probe cache invariant", () => {
20
+ const src = readFileSync(
21
+ path.resolve(__dirname, "../process-manager.ts"),
22
+ "utf-8",
23
+ );
24
+
25
+ it("process-manager.ts declares _wslTmuxAvailabilityCache", () => {
26
+ expect(src).toMatch(/let\s+_wslTmuxAvailabilityCache\s*:\s*boolean\s*\|\s*null\s*=\s*null/);
27
+ });
28
+
29
+ it("isWslTmuxAvailable() returns the cached value when non-null", () => {
30
+ expect(src).toMatch(/if\s*\(\s*_wslTmuxAvailabilityCache\s*!==\s*null\s*\)\s*return\s+_wslTmuxAvailabilityCache/);
31
+ });
32
+
33
+ it("fallback-log fires at most once per server run", () => {
34
+ expect(src).toMatch(/_wslFallbackLogged\s*=\s*true/);
35
+ expect(src).toMatch(/if\s*\(\s*!_wslTmuxAvailabilityCache\s*&&\s*!_wslFallbackLogged\s*\)/);
36
+ });
37
+
38
+ it("exports a cache-reset helper for tests", () => {
39
+ expect(typeof _resetWslTmuxCacheForTests).toBe("function");
40
+ // reset is idempotent — safe to call repeatedly
41
+ _resetWslTmuxCacheForTests();
42
+ _resetWslTmuxCacheForTests();
43
+ });
44
+ });
@@ -0,0 +1,130 @@
1
+ /**
2
+ * In-memory queue for pi-dependent operations deferred during bootstrap
3
+ * install. When `bootstrapState.status === "installing"`, callers should
4
+ * enqueue their handler and return a 202 Accepted with the ticketId.
5
+ * When status transitions to "ready", `flushAll()` runs every queued
6
+ * handler sequentially in enqueue order.
7
+ *
8
+ * Queue is process-local and NOT persisted. If the dashboard crashes
9
+ * mid-install, queued requests are lost — documented as a known
10
+ * limitation in design.md §16.2.
11
+ *
12
+ * See change: unified-bootstrap-install.
13
+ */
14
+ import { randomUUID } from "node:crypto";
15
+
16
+ export interface QueuedTicket<T> {
17
+ ticketId: string;
18
+ /**
19
+ * Resolves when the queued handler runs (or rejects if it throws).
20
+ * Call sites can await this when they want to synchronously return
21
+ * the eventual result — but 202-Accepted flows MUST NOT await, they
22
+ * return the ticketId to the client immediately.
23
+ */
24
+ result: Promise<T>;
25
+ }
26
+
27
+ export interface BootstrapQueue {
28
+ enqueue<T>(handler: () => Promise<T>): QueuedTicket<T>;
29
+ flushAll(): Promise<void>;
30
+ /** Number of currently pending tickets. */
31
+ size(): number;
32
+ /** Drop all pending tickets without running them (used at shutdown). */
33
+ clear(reason?: string): void;
34
+ /**
35
+ * Register a listener invoked after each ticket runs (success or
36
+ * failure). The server wires this to a `bootstrap_ticket_complete`
37
+ * WS broadcast so browser clients can correlate the outcome of a
38
+ * 202-accepted request via their stored ticketId.
39
+ * See change: unified-bootstrap-install.
40
+ */
41
+ onTicketComplete(
42
+ listener: (evt: { ticketId: string; success: boolean; error?: string }) => void,
43
+ ): () => void;
44
+ }
45
+
46
+ interface PendingEntry {
47
+ ticketId: string;
48
+ run: () => Promise<void>;
49
+ /** Reject the caller's `result` promise. Called by `clear()` to
50
+ * drain tickets at shutdown. */
51
+ reject: (err: unknown) => void;
52
+ }
53
+
54
+ export function createBootstrapQueue(): BootstrapQueue {
55
+ const pending: PendingEntry[] = [];
56
+ const listeners = new Set<
57
+ (evt: { ticketId: string; success: boolean; error?: string }) => void
58
+ >();
59
+
60
+ function notify(evt: { ticketId: string; success: boolean; error?: string }): void {
61
+ for (const l of listeners) {
62
+ try {
63
+ l(evt);
64
+ } catch (err) {
65
+ console.error("[bootstrap-queue] ticket-complete listener threw:", err);
66
+ }
67
+ }
68
+ }
69
+
70
+ return {
71
+ enqueue<T>(handler: () => Promise<T>): QueuedTicket<T> {
72
+ const ticketId = randomUUID();
73
+ let resolve!: (value: T) => void;
74
+ let reject!: (err: unknown) => void;
75
+ const result = new Promise<T>((res, rej) => {
76
+ resolve = res;
77
+ reject = rej;
78
+ });
79
+ pending.push({
80
+ ticketId,
81
+ reject,
82
+ run: async () => {
83
+ try {
84
+ const value = await handler();
85
+ resolve(value);
86
+ notify({ ticketId, success: true });
87
+ } catch (err) {
88
+ reject(err);
89
+ const message = err instanceof Error ? err.message : String(err);
90
+ notify({ ticketId, success: false, error: message });
91
+ }
92
+ },
93
+ });
94
+ return { ticketId, result };
95
+ },
96
+
97
+ async flushAll(): Promise<void> {
98
+ while (pending.length > 0) {
99
+ const entry = pending.shift();
100
+ if (!entry) break;
101
+ try {
102
+ await entry.run();
103
+ } catch (err) {
104
+ // Handler errors propagate via the ticket's `result` promise;
105
+ // they should never reach here unless there's a bug in `run`.
106
+ console.error(`[bootstrap-queue] ticket ${entry.ticketId} run threw:`, err);
107
+ }
108
+ }
109
+ },
110
+
111
+ size() {
112
+ return pending.length;
113
+ },
114
+
115
+ clear(reason = "queue cleared") {
116
+ const drained = pending.splice(0, pending.length);
117
+ for (const entry of drained) {
118
+ // Reject the caller's `result` promise directly and broadcast the
119
+ // completion so any browser holding the ticketId learns the
120
+ // outcome.
121
+ entry.reject(new Error(reason));
122
+ notify({ ticketId: entry.ticketId, success: false, error: reason });
123
+ }
124
+ },
125
+ onTicketComplete(listener) {
126
+ listeners.add(listener);
127
+ return () => listeners.delete(listener);
128
+ },
129
+ };
130
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * In-memory bootstrap state store for the dashboard server.
3
+ *
4
+ * Tracks the degraded-mode status during first-run pi install, upgrade
5
+ * operations, and version-skew detection. Subscribers (browser gateway,
6
+ * CLI progress printer) receive a snapshot on every `set()` call.
7
+ *
8
+ * See change: unified-bootstrap-install.
9
+ */
10
+
11
+ export type BootstrapStatus = "ready" | "installing" | "failed";
12
+
13
+ export interface BootstrapProgress {
14
+ /** Package / phase being processed (e.g. "pi-coding-agent", "bridge-register"). */
15
+ step: string;
16
+ /** Optional completion percentage (0..100). */
17
+ pct?: number;
18
+ /** Last line of npm output or other streaming context. */
19
+ output?: string;
20
+ }
21
+
22
+ export interface BootstrapError {
23
+ message: string;
24
+ stack?: string;
25
+ }
26
+
27
+ export interface BootstrapVersions {
28
+ pi?: string;
29
+ openspec?: string;
30
+ tsx?: string;
31
+ }
32
+
33
+ export interface BootstrapCompatibility {
34
+ minimum: string;
35
+ recommended: string;
36
+ /** null = no upper bound enforced yet. */
37
+ maximum: string | null;
38
+ /** Current resolved pi version, or undefined when pi is unresolved. */
39
+ current?: string;
40
+ /** Hint that the user should upgrade pi (below recommended). */
41
+ upgradeRecommended?: boolean;
42
+ /** Hint that the user should upgrade the dashboard itself (above maximum). */
43
+ upgradeDashboard?: boolean;
44
+ }
45
+
46
+ export interface BootstrapState {
47
+ status: BootstrapStatus;
48
+ progress?: BootstrapProgress;
49
+ error?: BootstrapError;
50
+ version?: BootstrapVersions;
51
+ compatibility?: BootstrapCompatibility;
52
+ /** Set when `registerBridgeExtension` fails after a successful install. */
53
+ bridgeRegistrationError?: string;
54
+ }
55
+
56
+ export type BootstrapListener = (state: BootstrapState) => void;
57
+
58
+ export interface BootstrapStateStore {
59
+ get(): BootstrapState;
60
+ /**
61
+ * Merge `partial` into the current state. Passing `undefined` for a
62
+ * key explicitly clears it (e.g. `set({ progress: undefined })` removes
63
+ * the progress line after completion). Broadcasts to all subscribers.
64
+ */
65
+ set(partial: Partial<BootstrapState>): void;
66
+ subscribe(listener: BootstrapListener): () => void;
67
+ /** Clear all listeners (used in tests + server shutdown). */
68
+ dispose(): void;
69
+ /**
70
+ * Record the package list used by the most recent `bootstrapInstall`
71
+ * call. Used by `POST /api/bootstrap/retry` to re-run the exact failed
72
+ * set rather than a hard-coded default. Not part of the WS-broadcast
73
+ * snapshot — it's purely side-channel metadata for the server.
74
+ * See change: unified-bootstrap-install (verification follow-up).
75
+ */
76
+ setLastInstallPackages(packages: readonly string[]): void;
77
+ /** Read the last install set. Returns a fresh copy. */
78
+ getLastInstallPackages(): string[];
79
+ }
80
+
81
+ /**
82
+ * Create a fresh bootstrap state store. `initial` is merged over the
83
+ * default `{ status: "ready" }`.
84
+ */
85
+ export function createBootstrapState(
86
+ initial?: Partial<BootstrapState>,
87
+ ): BootstrapStateStore {
88
+ let state: BootstrapState = { status: "ready", ...initial };
89
+ let lastInstallPackages: string[] = [];
90
+ const listeners = new Set<BootstrapListener>();
91
+
92
+ function notify(): void {
93
+ const snapshot = { ...state };
94
+ for (const l of listeners) {
95
+ try {
96
+ l(snapshot);
97
+ } catch (err) {
98
+ // Listener errors are non-fatal — log but continue.
99
+ console.error("[bootstrap-state] listener threw:", err);
100
+ }
101
+ }
102
+ }
103
+
104
+ return {
105
+ get() {
106
+ return { ...state };
107
+ },
108
+ set(partial) {
109
+ // Merge: explicit `undefined` in partial clears the field.
110
+ state = { ...state, ...partial } as BootstrapState;
111
+ // Strip keys whose value is undefined to keep the snapshot tidy.
112
+ for (const key of Object.keys(partial) as (keyof BootstrapState)[]) {
113
+ if (partial[key] === undefined) delete state[key];
114
+ }
115
+ notify();
116
+ },
117
+ subscribe(listener) {
118
+ listeners.add(listener);
119
+ return () => listeners.delete(listener);
120
+ },
121
+ dispose() {
122
+ listeners.clear();
123
+ },
124
+ setLastInstallPackages(packages) {
125
+ lastInstallPackages = [...packages];
126
+ },
127
+ getLastInstallPackages() {
128
+ return [...lastInstallPackages];
129
+ },
130
+ };
131
+ }
@@ -5,16 +5,40 @@ import fs from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import os from "node:os";
7
7
  import type { BrowseEntry, BrowseResult } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
8
+ import { isFilesystemRoot } from "@blackbelt-technology/pi-dashboard-shared/platform/paths.js";
8
9
 
9
10
  const MAX_ENTRIES = 200;
11
+ const WORD_BOUNDARY_CHARS = new Set(["-", "_", ".", " ", "/"]);
12
+
13
+ /**
14
+ * Compute the rank tier for a name against a lowercase query.
15
+ * Lower tier = better match.
16
+ * 0: exact match
17
+ * 1: prefix match
18
+ * 2: word-boundary substring (preceded by -, _, ., space, /)
19
+ * 3: plain substring
20
+ * 4: no match (filter out)
21
+ */
22
+ function rankTier(name: string, qLower: string): number {
23
+ const nameLower = name.toLowerCase();
24
+ if (nameLower === qLower) return 0;
25
+ if (nameLower.startsWith(qLower)) return 1;
26
+ const idx = nameLower.indexOf(qLower);
27
+ if (idx < 0) return 4;
28
+ const prev = nameLower[idx - 1];
29
+ if (idx === 0 || (prev !== undefined && WORD_BOUNDARY_CHARS.has(prev))) return 2;
30
+ return 3;
31
+ }
10
32
 
11
33
  /**
12
34
  * List subdirectories of a given path.
13
35
  * Excludes hidden directories (starting with ".").
14
36
  * Detects .git and .pi subdirectories for visual hints.
15
- * Caps at 200 entries, sorted alphabetically.
37
+ * When `q` is non-empty, filters by case-insensitive substring and ranks
38
+ * (exact → prefix → word-boundary → substring), alphabetical within tier.
39
+ * Caps at 200 entries AFTER filtering/ranking.
16
40
  */
17
- export async function listDirectories(dirPath?: string): Promise<BrowseResult> {
41
+ export async function listDirectories(dirPath?: string, q?: string): Promise<BrowseResult> {
18
42
  const resolved = dirPath ?? os.homedir();
19
43
 
20
44
  // Verify the directory exists and is a directory
@@ -26,14 +50,28 @@ export async function listDirectories(dirPath?: string): Promise<BrowseResult> {
26
50
  const rawEntries = await fs.readdir(resolved, { withFileTypes: true });
27
51
 
28
52
  // Filter: directories only, no hidden dirs
29
- const dirs = rawEntries.filter(
53
+ let dirs = rawEntries.filter(
30
54
  (e) => e.isDirectory() && !e.name.startsWith(".")
31
55
  );
32
56
 
33
- // Sort alphabetically
34
- dirs.sort((a, b) => a.name.localeCompare(b.name));
57
+ // Apply optional substring filter + tiered ranking
58
+ const qTrim = (q ?? "").trim();
59
+ if (qTrim) {
60
+ const qLower = qTrim.toLowerCase();
61
+ const ranked = dirs
62
+ .map((d) => ({ d, tier: rankTier(d.name, qLower) }))
63
+ .filter((x) => x.tier < 4);
64
+ ranked.sort((a, b) => {
65
+ if (a.tier !== b.tier) return a.tier - b.tier;
66
+ return a.d.name.toLowerCase().localeCompare(b.d.name.toLowerCase());
67
+ });
68
+ dirs = ranked.map((x) => x.d);
69
+ } else {
70
+ // Alphabetical, case-insensitive
71
+ dirs.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
72
+ }
35
73
 
36
- // Cap at MAX_ENTRIES
74
+ // Cap at MAX_ENTRIES (AFTER filtering/ranking)
37
75
  const capped = dirs.slice(0, MAX_ENTRIES);
38
76
 
39
77
  // Build entries with isGit/isPi detection
@@ -48,8 +86,69 @@ export async function listDirectories(dirPath?: string): Promise<BrowseResult> {
48
86
  })
49
87
  );
50
88
 
51
- // Parent: null for root
52
- const parent = resolved === "/" ? null : path.dirname(resolved);
89
+ // Parent: null for any filesystem root (`/`, `C:\`, `\\server\share\`).
90
+ // Previously this was `resolved === "/"`, which only recognized the Unix
91
+ // root — on Windows `path.dirname("B:\\")` returns `"B:\\"`, so the
92
+ // picker showed a useless `..` entry at drive roots.
93
+ // See change: platform-path-normalization.
94
+ const parent = isFilesystemRoot(resolved) ? null : path.dirname(resolved);
95
+
96
+ return { entries, parent, current: resolved, platform: process.platform };
97
+ }
98
+
99
+ /**
100
+ * Validate a directory name for mkdir.
101
+ * Returns null if valid, or an error message string if invalid.
102
+ */
103
+ export function validateMkdirName(name: string): string | null {
104
+ if (typeof name !== "string") return "invalid name";
105
+ if (name.length === 0) return "invalid name";
106
+ // No leading/trailing whitespace (also rejects whitespace-only)
107
+ if (name !== name.trim()) return "invalid name";
108
+ if (name === "." || name === "..") return "invalid name";
109
+ if (name.includes("/") || name.includes("\\")) return "invalid name";
110
+ if (name.includes("\0")) return "invalid name";
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * Create a new directory under `parent` named `name`.
116
+ * Validates inputs, verifies parent exists and is a directory,
117
+ * and creates the target non-recursively (fails if it already exists).
118
+ * Returns the absolute path of the created directory.
119
+ *
120
+ * Throws Error with one of these messages:
121
+ * - "invalid name"
122
+ * - "parent not found"
123
+ * - "parent is not a directory"
124
+ * - "already exists"
125
+ * - or an OS error message for other failures.
126
+ */
127
+ export async function createDirectory(parent: string, name: string): Promise<string> {
128
+ const nameErr = validateMkdirName(name);
129
+ if (nameErr) throw new Error(nameErr);
130
+
131
+ if (typeof parent !== "string" || parent.length === 0 || !path.isAbsolute(parent)) {
132
+ throw new Error("parent not found");
133
+ }
53
134
 
54
- return { entries, parent, current: resolved };
135
+ let parentStat;
136
+ try {
137
+ parentStat = await fs.stat(parent);
138
+ } catch {
139
+ throw new Error("parent not found");
140
+ }
141
+ if (!parentStat.isDirectory()) {
142
+ throw new Error("parent is not a directory");
143
+ }
144
+
145
+ const target = path.join(parent, name);
146
+ try {
147
+ await fs.mkdir(target, { recursive: false });
148
+ } catch (err: unknown) {
149
+ const e = err as NodeJS.ErrnoException;
150
+ if (e?.code === "EEXIST") throw new Error("already exists");
151
+ throw err;
152
+ }
153
+ return target;
55
154
  }
@@ -240,8 +240,16 @@ export function createBrowserGateway(
240
240
 
241
241
 
242
242
  ws.on("message", async (raw) => {
243
+ // Malformed (non-JSON) frames are silently dropped. Only frame-parse
244
+ // errors are swallowed here — handler exceptions are logged below so
245
+ // real bugs (e.g. node-pty spawn failures) are not silently hidden.
246
+ let msg: BrowserToServerMessage;
247
+ try {
248
+ msg = JSON.parse(raw.toString()) as BrowserToServerMessage;
249
+ } catch {
250
+ return;
251
+ }
243
252
  try {
244
- const msg = JSON.parse(raw.toString()) as BrowserToServerMessage;
245
253
  const ctx: BrowserHandlerContext = {
246
254
  ws, sessionManager, eventStore, piGateway,
247
255
  pendingForkRegistry, sessionOrderManager, preferencesStore,
@@ -432,8 +440,13 @@ export function createBrowserGateway(
432
440
  handlePiGatewayForward(msg, ctx);
433
441
  break;
434
442
  }
435
- } catch {
436
- // Ignore malformed messages
443
+ } catch (err) {
444
+ const type = (msg as { type?: string } | undefined)?.type ?? "unknown";
445
+ console.error(
446
+ `[browser-gw] handler error type=${type}:`,
447
+ err,
448
+ );
449
+ // Connection intentionally remains open so subsequent messages are still processed.
437
450
  }
438
451
  });
439
452