@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
@@ -112,10 +112,13 @@ describe("autoStartServer", () => {
112
112
 
113
113
  const result = await autoStartServer(baseConfig, deps);
114
114
 
115
- expect(deps.notify).toHaveBeenCalledWith(
116
- "Dashboard server failed to start: exited",
117
- "warning",
118
- );
115
+ expect(deps.notify).toHaveBeenCalledTimes(1);
116
+ const [msg, level] = (deps.notify as any).mock.calls[0];
117
+ expect(msg).toMatch(/Dashboard server failed to start: exited/);
118
+ // Spec requirement (fix-windows-server-parity): failure notification
119
+ // MUST include the absolute path to ~/.pi/dashboard/server.log.
120
+ expect(msg).toMatch(/server\.log/);
121
+ expect(level).toBe("warning");
119
122
  expect(result.server).toBeUndefined();
120
123
  });
121
124
 
@@ -164,4 +167,92 @@ describe("autoStartServer", () => {
164
167
 
165
168
  expect(result.server).toEqual({ host: "myhost.local", port: 8000, piPort: 9999 });
166
169
  });
170
+
171
+ describe("onLaunchStart / onLaunchEnd callbacks", () => {
172
+ it("fires onLaunchStart then onLaunchEnd(true) when launch succeeds", async () => {
173
+ const onLaunchStart = vi.fn();
174
+ const onLaunchEnd = vi.fn();
175
+ const deps = makeDeps({
176
+ launchServer: vi.fn().mockResolvedValue({ success: true, message: "ok" }),
177
+ onLaunchStart,
178
+ onLaunchEnd,
179
+ });
180
+
181
+ await autoStartServer(baseConfig, deps);
182
+
183
+ expect(onLaunchStart).toHaveBeenCalledTimes(1);
184
+ expect(onLaunchEnd).toHaveBeenCalledTimes(1);
185
+ expect(onLaunchEnd).toHaveBeenCalledWith(true);
186
+ });
187
+
188
+ it("fires onLaunchStart then onLaunchEnd(false) when launch fails", async () => {
189
+ const onLaunchStart = vi.fn();
190
+ const onLaunchEnd = vi.fn();
191
+ const deps = makeDeps({
192
+ launchServer: vi.fn().mockResolvedValue({ success: false, message: "boom" }),
193
+ isDashboardRunning: vi.fn().mockResolvedValue({ running: false }),
194
+ onLaunchStart,
195
+ onLaunchEnd,
196
+ });
197
+
198
+ await autoStartServer(baseConfig, deps);
199
+
200
+ expect(onLaunchStart).toHaveBeenCalledTimes(1);
201
+ expect(onLaunchEnd).toHaveBeenCalledTimes(1);
202
+ expect(onLaunchEnd).toHaveBeenCalledWith(false);
203
+ });
204
+
205
+ it("fires onLaunchEnd(true) when launch fails but recheck finds running server", async () => {
206
+ // Race scenario: another agent started the server during our launch attempt.
207
+ const onLaunchStart = vi.fn();
208
+ const onLaunchEnd = vi.fn();
209
+ const deps = makeDeps({
210
+ launchServer: vi.fn().mockResolvedValue({ success: false, message: "EADDRINUSE" }),
211
+ isDashboardRunning: vi.fn()
212
+ .mockResolvedValueOnce({ running: false }) // before launch
213
+ .mockResolvedValueOnce({ running: true }), // after launch (recheck)
214
+ onLaunchStart,
215
+ onLaunchEnd,
216
+ });
217
+
218
+ await autoStartServer(baseConfig, deps);
219
+
220
+ expect(onLaunchStart).toHaveBeenCalledTimes(1);
221
+ expect(onLaunchEnd).toHaveBeenCalledWith(true);
222
+ });
223
+
224
+ it("does NOT fire onLaunchStart when mDNS finds a local server (no launch happens)", async () => {
225
+ const onLaunchStart = vi.fn();
226
+ const onLaunchEnd = vi.fn();
227
+ const local: DiscoveredServer = {
228
+ host: "localhost", port: 8000, piPort: 9999,
229
+ isLocal: true, source: "mdns",
230
+ };
231
+ const deps = makeDeps({
232
+ discoverDashboard: vi.fn().mockResolvedValue([local]),
233
+ onLaunchStart,
234
+ onLaunchEnd,
235
+ });
236
+
237
+ await autoStartServer(baseConfig, deps);
238
+
239
+ expect(onLaunchStart).not.toHaveBeenCalled();
240
+ expect(onLaunchEnd).not.toHaveBeenCalled();
241
+ });
242
+
243
+ it("does NOT fire onLaunchStart when health check finds an already-running server", async () => {
244
+ const onLaunchStart = vi.fn();
245
+ const onLaunchEnd = vi.fn();
246
+ const deps = makeDeps({
247
+ isDashboardRunning: vi.fn().mockResolvedValue({ running: true }),
248
+ onLaunchStart,
249
+ onLaunchEnd,
250
+ });
251
+
252
+ await autoStartServer(baseConfig, deps);
253
+
254
+ expect(onLaunchStart).not.toHaveBeenCalled();
255
+ expect(onLaunchEnd).not.toHaveBeenCalled();
256
+ });
257
+ });
167
258
  });
@@ -17,6 +17,22 @@ describe("server-launcher", () => {
17
17
  it("should point to a file that actually exists on disk", () => {
18
18
  expect(existsSync(resolveServerCliPath())).toBe(true);
19
19
  });
20
+
21
+ it("uses require.resolve so it adapts to installed layout", () => {
22
+ // Regression: the monorepo-relative path math
23
+ // (`<extension>/../../server/src/cli.ts`) produced
24
+ // `<scope>/server/src/cli.ts` instead of
25
+ // `<scope>/pi-dashboard-server/src/cli.ts` when the extension
26
+ // was installed into `node_modules/@blackbelt-technology/`. The
27
+ // resolver must locate the server via package name, not sibling
28
+ // path arithmetic.
29
+ const cliPath = resolveServerCliPath();
30
+ // Either layout is fine; we just must NOT produce the broken
31
+ // `@blackbelt-technology/server/src/cli.ts` shape.
32
+ expect(cliPath).not.toMatch(/@blackbelt-technology[\\/]+server[\\/]+src[\\/]+cli\.ts$/);
33
+ // And must land on pi-dashboard-server (installed) or packages/server (dev).
34
+ expect(cliPath).toMatch(/(pi-dashboard-server|packages[\\/]+server)[\\/]+src[\\/]+cli\.ts$/);
35
+ });
20
36
  });
21
37
 
22
38
  describe("buildSpawnArgs", () => {
@@ -6,7 +6,8 @@
6
6
  * register ask_user. Runtime registration bypasses detectExtensionConflicts.
7
7
  */
8
8
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
- import { Type } from "@sinclair/typebox";
9
+ import { Type } from "typebox";
10
+ import { polyfillMultiselect } from "./multiselect-polyfill.js";
10
11
 
11
12
  // ──────────────────────────────────────────────────────────────────────────
12
13
  // Single-question schema arms (reused inside the batch arm's questions array)
@@ -121,7 +122,7 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
121
122
  name: "ask_user",
122
123
  label: "Ask User",
123
124
  description:
124
- "Ask the user a question interactively. Use this when you need clarification, confirmation, or a choice from the user before proceeding.",
125
+ "Ask the user a question interactively. Use this when you need clarification, confirmation, or a choice from the user before proceeding. UI provides a Select all toggle; do not add one.",
125
126
  promptSnippet:
126
127
  "Ask the user interactive questions (confirm, select, multiselect, input, or batch — multiple related questions at once)",
127
128
  promptGuidelines: [
@@ -254,7 +255,7 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
254
255
  `ask_user batch: sub-question method "multiselect" requires a non-empty "options" array.`,
255
256
  );
256
257
  }
257
- answer = await (ctx.ui as any).multiselect(subTitle, opts, subMsg);
258
+ answer = await polyfillMultiselect(ctx, subTitle, opts, subMsg);
258
259
  break;
259
260
  }
260
261
  case "input":
@@ -336,7 +337,7 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
336
337
  result = await ctx.ui.select(title, options, msgOpts);
337
338
  break;
338
339
  case "multiselect":
339
- result = await (ctx.ui as any).multiselect(title, options, msgOpts);
340
+ result = await polyfillMultiselect(ctx, title, options, msgOpts);
340
341
  break;
341
342
  case "input":
342
343
  result = await ctx.ui.input(title, params.placeholder, msgOpts);
@@ -5,6 +5,7 @@
5
5
  * forwards all pi events, and relays commands back.
6
6
  */
7
7
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
+ import { Loader } from "@mariozechner/pi-tui";
8
9
  import { ConnectionManager } from "./connection.js";
9
10
  import { detectSessionSource } from "./source-detector.js";
10
11
  import { mapEventToProtocol } from "./event-forwarder.js";
@@ -25,7 +26,7 @@ import { expandPromptTemplateFromDisk } from "./prompt-expander.js";
25
26
  import { PromptBus } from "./prompt-bus.js";
26
27
  import { DashboardDefaultAdapter } from "./dashboard-default-adapter.js";
27
28
  import { registerAskUserTool } from "./ask-user-tool.js";
28
- import { activate as activateProviderRegister, onProviderChanged } from "./provider-register.js";
29
+ import { activate as activateProviderRegister, onProviderChanged, reloadProviders } from "./provider-register.js";
29
30
  import type { FlowInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
30
31
  import { startMetricsMonitor, stopMetricsMonitor, collectMetrics } from "./process-metrics.js";
31
32
  import { scanChildProcesses } from "./process-scanner.js";
@@ -185,6 +186,61 @@ function initBridge(pi: ExtensionAPI) {
185
186
  let lastThinkingLevel: string | undefined;
186
187
  let promptBus: PromptBus | undefined;
187
188
 
189
+ // ── Per-message entry id tracking (for fix-per-message-fork) ──
190
+ // Pi 0.69+ awaits extension handlers BEFORE sessionManager.appendMessage runs,
191
+ // which means getLeafId() at emit time returns the previous leaf, not the
192
+ // entry id of the message currently being emitted. We solve this by:
193
+ // 1. Wrapping ctx.sessionManager.appendMessage at session_start to stamp
194
+ // the just-generated entry id onto the message object reference.
195
+ // 2. Deferring the message_end enrichment-and-send via setTimeout(0) so
196
+ // the awaited dispatcher unwinds and appendMessage runs in between.
197
+ // 3. Stamping a nonce on message_start/message_end events; emitting an
198
+ // entry_persisted event after appendMessage so the client reducer can
199
+ // back-fill user-message ChatMessage.entryId.
200
+ // See change: fix-per-message-fork.
201
+ const idByMessage = new WeakMap<object, string>();
202
+ const pendingNonces = new WeakMap<object, string>();
203
+ let nonceCounter = 0;
204
+ const nextNonce = (): string => `n-${++nonceCounter}-${Date.now()}`;
205
+ let appendMessageWrapped = false;
206
+ let lastWrappedSm: any = null;
207
+
208
+ /**
209
+ * Wrap ctx.sessionManager.appendMessage once per session so that when pi
210
+ * generates an entry id we capture it in the WeakMap and emit
211
+ * entry_persisted to the server.
212
+ */
213
+ function wrapAppendMessageForCtx(ctx: any): void {
214
+ const sm = ctx?.sessionManager;
215
+ if (!sm || typeof sm.appendMessage !== "function") return;
216
+ // Re-wrap when sessionManager identity changes (session replacement).
217
+ if (sm === lastWrappedSm && appendMessageWrapped) return;
218
+ const original = sm.appendMessage.bind(sm);
219
+ sm.appendMessage = (msg: any, ...rest: any[]) => {
220
+ const result = original(msg, ...rest);
221
+ try {
222
+ if (msg && typeof msg === "object" && typeof msg.id === "string") {
223
+ idByMessage.set(msg as object, msg.id);
224
+ const nonce = pendingNonces.get(msg as object);
225
+ if (nonce && sessionReady && isActive()) {
226
+ const ev = {
227
+ type: "entry_persisted",
228
+ entryId: msg.id,
229
+ nonce,
230
+ };
231
+ connection.send(mapEventToProtocol(sessionId, ev));
232
+ pendingNonces.delete(msg as object);
233
+ }
234
+ }
235
+ } catch (err) {
236
+ console.error("[dashboard] entry_persisted emit failed:", err);
237
+ }
238
+ return result;
239
+ };
240
+ lastWrappedSm = sm;
241
+ appendMessageWrapped = true;
242
+ }
243
+
188
244
  /** Wrap a callback so errors log instead of crashing the host pi agent. */
189
245
  function safe<T extends (...args: any[]) => any>(fn: T): T {
190
246
  return ((...args: any[]) => {
@@ -216,6 +272,17 @@ function initBridge(pi: ExtensionAPI) {
216
272
  // Reload auth credentials when dashboard notifies of changes
217
273
  if (msg.type === "credentials_updated") {
218
274
  try {
275
+ // Hot-reload providers.json diff BEFORE refreshing the registry,
276
+ // so any newly added providers are registered before getAvailable() runs.
277
+ const diff = await reloadProviders(pi).catch((err) => {
278
+ console.error("[dashboard] reloadProviders failed:", err);
279
+ return { added: [], removed: [], changed: [] };
280
+ });
281
+ if (diff.added.length || diff.removed.length || diff.changed.length) {
282
+ console.log(
283
+ `[dashboard] hot-reloaded providers: added=${JSON.stringify(diff.added)} removed=${JSON.stringify(diff.removed)} changed=${JSON.stringify(diff.changed)}`,
284
+ );
285
+ }
219
286
  cachedModelRegistry?.authStorage?.reload?.();
220
287
  cachedModelRegistry?.refresh?.();
221
288
  } catch (err) { console.error("[dashboard] credentials reload failed:", err); }
@@ -600,30 +667,53 @@ function initBridge(pi: ExtensionAPI) {
600
667
  }
601
668
  }
602
669
 
603
- // For message_start, enrich with entryId immediately (current leaf)
670
+ // For message_start: stamp a nonce on the event so the client reducer
671
+ // can correlate a later entry_persisted back-fill with this bubble.
672
+ // We do NOT attach entryId here — the message has no id yet on pi
673
+ // 0.69+ (persistence is deferred to message_end). See change:
674
+ // fix-per-message-fork.
604
675
  if (eventType === "message_start") {
605
- const entryId = ctx.sessionManager?.getLeafId?.();
606
- if (entryId) {
607
- const enriched = { ...event, entryId };
676
+ wrapAppendMessageForCtx(ctx);
677
+ const messageRef = (event as any).message;
678
+ if (messageRef && typeof messageRef === "object") {
679
+ const nonce = nextNonce();
680
+ pendingNonces.set(messageRef as object, nonce);
681
+ const enriched = { ...event, nonce };
608
682
  const msg = mapEventToProtocol(sessionId, enriched);
609
683
  connection.send(msg);
610
684
  return;
611
685
  }
612
686
  }
613
687
 
614
- // For message_end, defer getLeafId() so it runs after pi core persists the entry.
615
- // Pi core calls _emit (which invokes this handler) BEFORE appendMessage (which updates leafId).
616
- // Since _emit doesn't await async handlers, yielding via queueMicrotask lets appendMessage
617
- // run first, so getLeafId() returns the correct entry ID for the just-persisted message.
688
+ // For message_end: defer the SEND via setTimeout(0). Pi 0.69+ runs
689
+ // sessionManager.appendMessage AFTER the awaited extension dispatcher
690
+ // returns, so a queueMicrotask deferral is no longer enough. By the
691
+ // time the macrotask fires, appendMessage has run, pi has mutated
692
+ // event.message.id in place, and the wrapped appendMessage above has
693
+ // populated idByMessage. We also stamp a nonce so a downstream
694
+ // entry_persisted can correlate (covers user message_end where the
695
+ // earlier message_start nonce is what the reducer is waiting on).
696
+ // See change: fix-per-message-fork.
618
697
  if (eventType === "message_end") {
619
- await new Promise<void>(resolve => queueMicrotask(resolve));
620
- const entryId = ctx.sessionManager?.getLeafId?.();
621
- if (entryId) {
622
- const enriched = { ...event, entryId };
623
- const msg = mapEventToProtocol(sessionId, enriched);
624
- connection.send(msg);
625
- return;
698
+ wrapAppendMessageForCtx(ctx);
699
+ const messageRef = (event as any).message;
700
+ const nonce = messageRef && typeof messageRef === "object"
701
+ ? (pendingNonces.get(messageRef as object) ?? nextNonce())
702
+ : nextNonce();
703
+ if (messageRef && typeof messageRef === "object" && !pendingNonces.has(messageRef as object)) {
704
+ pendingNonces.set(messageRef as object, nonce);
626
705
  }
706
+ setTimeout(() => {
707
+ if (!isActive() || !sessionReady) return;
708
+ const entryId =
709
+ (messageRef && typeof messageRef === "object" && typeof messageRef.id === "string" ? messageRef.id : undefined)
710
+ ?? (messageRef ? idByMessage.get(messageRef as object) : undefined)
711
+ ?? ctx.sessionManager?.getLeafId?.();
712
+ const enriched = { ...event, entryId, nonce };
713
+ const protoMsg = mapEventToProtocol(sessionId, enriched);
714
+ connection.send(protoMsg);
715
+ }, 0);
716
+ return;
627
717
  }
628
718
 
629
719
  const msg = mapEventToProtocol(sessionId, event);
@@ -682,6 +772,15 @@ function initBridge(pi: ExtensionAPI) {
682
772
  cachedCtx = ctx;
683
773
  sessionId = newSessionId;
684
774
 
775
+ // Wrap sessionManager.appendMessage so that future message_end events can
776
+ // recover the just-generated entry id, even when their setTimeout(0)
777
+ // fires before pi has finished mutating event.message in place. The
778
+ // helper is idempotent and re-wraps on session replacement.
779
+ // See change: fix-per-message-fork.
780
+ appendMessageWrapped = false;
781
+ lastWrappedSm = null;
782
+ wrapAppendMessageForCtx(ctx);
783
+
685
784
  // Register ask_user at runtime (not at load time) to avoid static
686
785
  // tool-name conflicts with other extensions like pi-flows.
687
786
  registerAskUserTool(pi);
@@ -951,17 +1050,72 @@ function initBridge(pi: ExtensionAPI) {
951
1050
  }
952
1051
 
953
1052
  // Discover or auto-start server (non-blocking — connection will reconnect)
1053
+ //
1054
+ // When a real launchServer() is about to run (not on mDNS/health-check
1055
+ // paths), mount an animated TUI widget above the editor using pi-tui's
1056
+ // Loader (a real Component, self-animating at 80ms, like pi-flows'
1057
+ // architect-widget). The previous implementation used
1058
+ // ctx.ui.setStatus(...) which only writes a footer string and relies on
1059
+ // the TUI render loop being ticked elsewhere — on the cold-start path
1060
+ // nothing else requests renders, so the spinner never animated and often
1061
+ // never appeared. setWidget(key, factory, {placement:"aboveEditor"}) gives
1062
+ // us a managed component that owns its own render loop and is always
1063
+ // visible while the launch is in flight.
1064
+ let spinnerTimer: NodeJS.Timeout | null = null;
1065
+ let spinnerStart = 0;
1066
+ let activeLoader: Loader | null = null;
1067
+ const stopSpinner = () => {
1068
+ if (spinnerTimer) {
1069
+ clearInterval(spinnerTimer);
1070
+ spinnerTimer = null;
1071
+ }
1072
+ activeLoader = null;
1073
+ ctx.ui.setWidget("pi-dashboard-launch", undefined);
1074
+ };
954
1075
  autoStartServer(config, {
955
1076
  discoverDashboard,
956
1077
  isDashboardRunning,
957
1078
  launchServer,
958
1079
  notify: (msg, level) => ctx.ui.notify(msg, level),
1080
+ onLaunchStart: () => {
1081
+ spinnerStart = Date.now();
1082
+ const buildMessage = () => {
1083
+ const elapsed = Math.floor((Date.now() - spinnerStart) / 1000);
1084
+ return `starting dashboard server … (${elapsed}s)`;
1085
+ };
1086
+ ctx.ui.setWidget(
1087
+ "pi-dashboard-launch",
1088
+ (tui: unknown, theme: { fg: (role: string, s: string) => string }) => {
1089
+ const loader = new Loader(
1090
+ tui as ConstructorParameters<typeof Loader>[0],
1091
+ (s: string) => theme.fg("accent", s),
1092
+ (s: string) => theme.fg("muted", s),
1093
+ buildMessage(),
1094
+ );
1095
+ activeLoader = loader;
1096
+ // Loader has stop() but no dispose(); wire dispose so that
1097
+ // setExtensionWidget's teardown stops the 80ms animation interval.
1098
+ (loader as Loader & { dispose?: () => void }).dispose = () => loader.stop();
1099
+ return loader;
1100
+ },
1101
+ { placement: "aboveEditor" },
1102
+ );
1103
+ // Refresh the elapsed-seconds label every second. Frame animation is
1104
+ // driven by the Loader's own 80ms interval.
1105
+ spinnerTimer = setInterval(() => {
1106
+ activeLoader?.setMessage(buildMessage());
1107
+ }, 1000);
1108
+ },
1109
+ onLaunchEnd: () => {
1110
+ stopSpinner();
1111
+ },
959
1112
  }).then((result) => {
1113
+ stopSpinner(); // safety net — covers onLaunchEnd not firing
960
1114
  if (result.server && result.server.piPort !== config.piPort) {
961
1115
  // Server found on a different piPort than configured — update connection URL
962
1116
  connection.updateUrl(`ws://${result.server.host === 'localhost' ? 'localhost' : result.server.host}:${result.server.piPort}`);
963
1117
  }
964
- }).catch(() => {});
1118
+ }).catch(() => { stopSpinner(); });
965
1119
 
966
1120
  // Send initial git info
967
1121
  sendGitInfoIfChanged(ctx.cwd);
@@ -2,7 +2,7 @@
2
2
  * Dev build-on-reload helper.
3
3
  * Builds the Vite client and requests server shutdown.
4
4
  */
5
- import { execSync as defaultExecSync } from "node:child_process";
5
+ import { execSync as defaultExecSync } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
6
6
 
7
7
  export interface DevBuildOptions {
8
8
  packageRoot: string;
@@ -1,7 +1,11 @@
1
1
  /**
2
2
  * Git info gathering — detects branch, remote URL, and PR number.
3
+ * Delegates to the shared git tool module so there's no inline execSync
4
+ * and every call benefits from the runner's safety defaults (windowsHide,
5
+ * timeout, tolerated exit codes).
6
+ * See change: platform-command-executor.
3
7
  */
4
- import { execSync } from "node:child_process";
8
+ import * as git from "@blackbelt-technology/pi-dashboard-shared/platform/git.js";
5
9
  import { buildGitLinks, type GitLinks } from "./git-link-builder.js";
6
10
 
7
11
  export interface GitInfo {
@@ -11,39 +15,25 @@ export interface GitInfo {
11
15
  gitPrUrl?: string;
12
16
  }
13
17
 
14
- /** Run a shell command and return trimmed stdout, or undefined on failure. */
15
- function runGit(command: string, cwd: string): string | undefined {
16
- try {
17
- return execSync(command, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 }).trim();
18
- } catch {
19
- return undefined;
20
- }
21
- }
22
-
23
18
  /** Detect the current git branch. Returns short SHA for detached HEAD. */
24
19
  export function detectBranch(cwd: string): string | undefined {
25
- const ref = runGit("git rev-parse --abbrev-ref HEAD", cwd);
20
+ const ref = git.currentBranchOr({ cwd });
26
21
  if (!ref) return undefined;
27
22
  if (ref === "HEAD") {
28
23
  // Detached HEAD — return short commit SHA
29
- return runGit("git rev-parse --short HEAD", cwd) ?? "HEAD";
24
+ return git.headShaOr({ cwd, short: true }) ?? "HEAD";
30
25
  }
31
26
  return ref;
32
27
  }
33
28
 
34
29
  /** Detect the remote origin URL. */
35
30
  export function detectRemoteUrl(cwd: string): string | undefined {
36
- return runGit("git remote get-url origin", cwd);
31
+ return git.remoteUrlOr({ cwd });
37
32
  }
38
33
 
39
34
  /** Detect the PR number via gh CLI (best effort). */
40
35
  export function detectPrNumber(cwd: string): number | undefined {
41
- const result = runGit("gh pr view --json number -q .number", cwd);
42
- if (result) {
43
- const num = parseInt(result, 10);
44
- if (!isNaN(num)) return num;
45
- }
46
- return undefined;
36
+ return git.prNumberOr({ cwd });
47
37
  }
48
38
 
49
39
  /** Gather all git info for a directory. Returns undefined if not a git repo. */
@@ -0,0 +1,146 @@
1
+ /**
2
+ * MultiSelectList — a TUI multi-select component implementing pi-tui's
3
+ * `Component` interface. Used by `polyfillMultiselect` to emulate the
4
+ * `ctx.ui.multiselect(...)` call that `pi-coding-agent`'s `ExtensionUIContext`
5
+ * does not expose natively.
6
+ *
7
+ * Keyboard contract (intentional — no "select all" binding in TUI):
8
+ * ↑ / k move cursor up
9
+ * ↓ / j move cursor down
10
+ * space toggle the checked state of the current item
11
+ * enter confirm → onConfirm(selected[])
12
+ * esc cancel → onCancel()
13
+ *
14
+ * The selected array preserves the original option order, not toggle order.
15
+ */
16
+
17
+ interface Item {
18
+ value: string;
19
+ label: string;
20
+ description?: string;
21
+ checked: boolean;
22
+ }
23
+
24
+ /**
25
+ * Minimal shape of pi-tui's `Component` interface — we avoid importing from
26
+ * `@mariozechner/pi-tui` directly so this module stays compile-friendly when
27
+ * that peer dep isn't present (e.g. in unit tests running via vitest without
28
+ * the full pi runtime).
29
+ */
30
+ export interface ComponentLike {
31
+ render(width: number): string[];
32
+ handleInput?(data: string): void;
33
+ }
34
+
35
+ const CURSOR = "▸ ";
36
+ const NO_CURSOR = " ";
37
+ const CHECKED = "[x]";
38
+ const UNCHECKED = "[ ]";
39
+ const FOOTER_HINT = "space toggle · enter confirm · esc cancel";
40
+
41
+ const MAX_VISIBLE = 10;
42
+
43
+ function truncate(text: string, maxWidth: number): string {
44
+ if (maxWidth <= 1) return "";
45
+ if (text.length <= maxWidth) return text;
46
+ if (maxWidth <= 1) return "…";
47
+ return text.slice(0, Math.max(0, maxWidth - 1)) + "…";
48
+ }
49
+
50
+ export class MultiSelectList implements ComponentLike {
51
+ private items: Item[];
52
+ private cursor = 0;
53
+ private scrollOffset = 0;
54
+
55
+ onConfirm?: (selectedValues: string[]) => void;
56
+ onCancel?: () => void;
57
+
58
+ constructor(
59
+ private title: string,
60
+ options: string[],
61
+ private message?: string,
62
+ ) {
63
+ this.items = options.map((opt) => ({
64
+ value: opt,
65
+ label: opt,
66
+ checked: false,
67
+ }));
68
+ }
69
+
70
+ /** Expose current state for testing / adapters. */
71
+ getItems(): readonly Item[] {
72
+ return this.items;
73
+ }
74
+ getCursor(): number {
75
+ return this.cursor;
76
+ }
77
+
78
+ /** Return values of currently checked items in original option order. */
79
+ private selectedValues(): string[] {
80
+ return this.items.filter((it) => it.checked).map((it) => it.value);
81
+ }
82
+
83
+ render(width: number): string[] {
84
+ const lines: string[] = [];
85
+ if (this.title) lines.push(truncate(this.title, width));
86
+ if (this.message) lines.push(truncate(this.message, width));
87
+ if (lines.length > 0) lines.push("");
88
+
89
+ // Scroll window around cursor.
90
+ const visible = Math.min(MAX_VISIBLE, this.items.length);
91
+ if (this.cursor < this.scrollOffset) {
92
+ this.scrollOffset = this.cursor;
93
+ } else if (this.cursor >= this.scrollOffset + visible) {
94
+ this.scrollOffset = this.cursor - visible + 1;
95
+ }
96
+
97
+ for (let i = 0; i < visible; i++) {
98
+ const idx = this.scrollOffset + i;
99
+ const item = this.items[idx];
100
+ if (!item) break;
101
+ const marker = idx === this.cursor ? CURSOR : NO_CURSOR;
102
+ const box = item.checked ? CHECKED : UNCHECKED;
103
+ let line = `${marker}${box} ${item.label}`;
104
+ if (item.description) line += ` — ${item.description}`;
105
+ lines.push(truncate(line, width));
106
+ }
107
+
108
+ if (this.items.length > visible) {
109
+ lines.push(` (${this.cursor + 1}/${this.items.length})`);
110
+ }
111
+
112
+ lines.push("");
113
+ lines.push(truncate(FOOTER_HINT, width));
114
+ return lines;
115
+ }
116
+
117
+ handleInput(data: string): void {
118
+ // Escape
119
+ if (data === "\u001b" || data === "\x1b") {
120
+ this.onCancel?.();
121
+ return;
122
+ }
123
+ // Enter (CR or LF)
124
+ if (data === "\r" || data === "\n") {
125
+ this.onConfirm?.(this.selectedValues());
126
+ return;
127
+ }
128
+ // Space — toggle current
129
+ if (data === " ") {
130
+ const item = this.items[this.cursor];
131
+ if (item) item.checked = !item.checked;
132
+ return;
133
+ }
134
+ // Arrow up / k
135
+ if (data === "\u001b[A" || data === "k") {
136
+ if (this.cursor > 0) this.cursor--;
137
+ return;
138
+ }
139
+ // Arrow down / j
140
+ if (data === "\u001b[B" || data === "j") {
141
+ if (this.cursor < this.items.length - 1) this.cursor++;
142
+ return;
143
+ }
144
+ // Everything else (including "a", "A", bulk-toggle attempts) is a no-op.
145
+ }
146
+ }