@blackbelt-technology/pi-agent-dashboard 0.4.6 → 0.5.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 (181) hide show
  1. package/AGENTS.md +339 -190
  2. package/README.md +50 -7
  3. package/docs/architecture.md +238 -23
  4. package/package.json +23 -13
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
  7. package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +68 -0
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
  13. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  14. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  15. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  16. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  17. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  18. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  19. package/packages/extension/src/ask-user-tool.ts +1 -1
  20. package/packages/extension/src/bridge-context.ts +1 -1
  21. package/packages/extension/src/bridge.ts +169 -4
  22. package/packages/extension/src/command-handler.ts +65 -2
  23. package/packages/extension/src/flow-event-wiring.ts +1 -1
  24. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  25. package/packages/extension/src/multiselect-list.ts +1 -1
  26. package/packages/extension/src/pi-env.d.ts +16 -9
  27. package/packages/extension/src/prompt-expander.ts +50 -2
  28. package/packages/extension/src/provider-register.ts +132 -8
  29. package/packages/extension/src/retry-tracker.ts +123 -0
  30. package/packages/extension/src/server-launcher.ts +18 -1
  31. package/packages/extension/src/session-sync.ts +15 -1
  32. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  33. package/packages/server/package.json +6 -6
  34. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  35. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  36. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  37. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  38. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  39. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  40. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  41. package/packages/server/src/__tests__/cli-parse.test.ts +22 -4
  42. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  43. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  44. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  45. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  46. package/packages/server/src/__tests__/directory-service.test.ts +1 -1
  47. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  48. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +154 -0
  49. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  50. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  51. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  52. package/packages/server/src/__tests__/headless-pid-registry.test.ts +83 -0
  53. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  54. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  55. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  56. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  57. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  58. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  59. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  60. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  61. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  62. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  63. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +236 -0
  64. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  65. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  66. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  67. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  68. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  69. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  70. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  71. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +44 -0
  72. package/packages/server/src/__tests__/recommended-routes.test.ts +1 -1
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  74. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  75. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  76. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  77. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  78. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  79. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +250 -0
  80. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  81. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  82. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  83. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  84. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  85. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  86. package/packages/server/src/bootstrap-state.ts +18 -0
  87. package/packages/server/src/browser-gateway.ts +70 -24
  88. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  89. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  90. package/packages/server/src/browser-handlers/session-action-handler.ts +159 -18
  91. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  92. package/packages/server/src/changelog-fs.ts +167 -0
  93. package/packages/server/src/changelog-parser.ts +321 -0
  94. package/packages/server/src/changelog-remote.ts +134 -0
  95. package/packages/server/src/cli.ts +23 -2
  96. package/packages/server/src/directory-service.ts +31 -0
  97. package/packages/server/src/event-wiring.ts +105 -5
  98. package/packages/server/src/headless-pid-registry.ts +54 -5
  99. package/packages/server/src/home-lock.d.ts +124 -0
  100. package/packages/server/src/home-lock.js +330 -0
  101. package/packages/server/src/home-lock.js.map +1 -0
  102. package/packages/server/src/idle-timer.ts +15 -1
  103. package/packages/server/src/pending-client-correlations.ts +73 -0
  104. package/packages/server/src/pending-fork-registry.ts +24 -12
  105. package/packages/server/src/pi-core-checker.ts +77 -17
  106. package/packages/server/src/pi-core-updater.ts +81 -15
  107. package/packages/server/src/pi-dev-version-check.ts +145 -0
  108. package/packages/server/src/pi-gateway.ts +10 -0
  109. package/packages/server/src/pi-version-skew.ts +12 -4
  110. package/packages/server/src/process-manager.ts +115 -21
  111. package/packages/server/src/provider-auth-handlers.ts +9 -0
  112. package/packages/server/src/provider-auth-storage.ts +83 -51
  113. package/packages/server/src/provider-catalogue-cache.ts +47 -0
  114. package/packages/server/src/routes/doctor-routes.ts +140 -0
  115. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  116. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  117. package/packages/server/src/routes/provider-auth-routes.ts +14 -1
  118. package/packages/server/src/routes/provider-routes.ts +4 -4
  119. package/packages/server/src/routes/system-routes.ts +38 -1
  120. package/packages/server/src/server.ts +85 -66
  121. package/packages/server/src/session-api.ts +54 -3
  122. package/packages/server/src/session-bootstrap.ts +27 -12
  123. package/packages/server/src/session-discovery.ts +11 -4
  124. package/packages/server/src/session-file-reader.ts +1 -1
  125. package/packages/server/src/session-scanner.ts +4 -2
  126. package/packages/server/src/spawn-failure-log.ts +130 -0
  127. package/packages/server/src/spawn-preflight.ts +82 -0
  128. package/packages/server/src/spawn-register-watchdog.ts +291 -0
  129. package/packages/server/src/spawn-token.ts +20 -0
  130. package/packages/server/src/terminal-manager.ts +12 -1
  131. package/packages/shared/package.json +1 -1
  132. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +25 -17
  133. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  134. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  135. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  136. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  137. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  138. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +6 -3
  139. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  140. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  141. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  142. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  143. package/packages/shared/src/__tests__/config.test.ts +48 -0
  144. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  145. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  146. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  147. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  148. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  149. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  150. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  151. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  152. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  153. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  154. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  155. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  156. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  157. package/packages/shared/src/__tests__/resolve-jiti.test.ts +140 -9
  158. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  159. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  160. package/packages/shared/src/bootstrap-install.ts +197 -3
  161. package/packages/shared/src/browser-protocol.ts +155 -1
  162. package/packages/shared/src/changelog-types.ts +111 -0
  163. package/packages/shared/src/config.ts +15 -0
  164. package/packages/shared/src/dashboard-starter.ts +33 -0
  165. package/packages/shared/src/doctor-core.ts +821 -0
  166. package/packages/shared/src/index.ts +9 -0
  167. package/packages/shared/src/installable-list.ts +152 -0
  168. package/packages/shared/src/launch-source-flag.ts +14 -0
  169. package/packages/shared/src/launch-source-types.ts +18 -0
  170. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  171. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  172. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  173. package/packages/shared/src/platform/node-spawn.ts +29 -21
  174. package/packages/shared/src/protocol.ts +54 -2
  175. package/packages/shared/src/resolve-jiti.ts +62 -9
  176. package/packages/shared/src/rest-api.ts +4 -0
  177. package/packages/shared/src/skill-block-parser.ts +115 -0
  178. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  179. package/packages/shared/src/tool-registry/definitions.ts +33 -8
  180. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  181. package/packages/shared/src/types.ts +64 -0
@@ -15,7 +15,44 @@ import { createHeadlessPidRegistry, type HeadlessPidRegistry } from "./headless-
15
15
  import type { PendingForkRegistry } from "./pending-fork-registry.js";
16
16
  import type { SessionOrderManager } from "./session-order-manager.js";
17
17
  import type { PreferencesStore } from "./preferences-store.js";
18
- import type { DirectoryService } from "./directory-service.js";
18
+ import { hasOpenSpecDir, type DirectoryService } from "./directory-service.js";
19
+
20
+ /**
21
+ * Pure helper: build the per-cwd `openspec_update` messages a freshly
22
+ * connecting browser should receive. One message per known cwd.
23
+ * Disambiguates three states:
24
+ * - cache populated → cached payload
25
+ * - openspec dir but cold → { initialized: false, pending: true }
26
+ * - no openspec dir → { initialized: false, pending: false }
27
+ *
28
+ * Exported so cold-boot snapshot semantics can be unit-tested without
29
+ * spinning up a WS server. See change: fix-cold-boot-openspec-protocol.
30
+ */
31
+ export function buildOpenSpecConnectSnapshot(
32
+ directoryService: Pick<DirectoryService, "knownDirectories" | "getOpenSpecData">,
33
+ hasDir: (cwd: string) => boolean,
34
+ ): Array<ServerToBrowserMessage> {
35
+ const out: Array<ServerToBrowserMessage> = [];
36
+ for (const cwd of directoryService.knownDirectories()) {
37
+ const cached = directoryService.getOpenSpecData(cwd);
38
+ if (cached && cached.initialized) {
39
+ out.push({ type: "openspec_update", cwd, data: cached });
40
+ } else if (hasDir(cwd)) {
41
+ out.push({
42
+ type: "openspec_update",
43
+ cwd,
44
+ data: { initialized: false, pending: true, changes: [] },
45
+ });
46
+ } else {
47
+ out.push({
48
+ type: "openspec_update",
49
+ cwd,
50
+ data: { initialized: false, pending: false, changes: [] },
51
+ });
52
+ }
53
+ }
54
+ return out;
55
+ }
19
56
  import { createPendingResumeRegistry, type PendingResumeRegistry } from "./pending-resume-registry.js";
20
57
  import { createViewedSessionTracker, type ViewedSessionTracker } from "./viewed-session-tracker.js";
21
58
  import type { TerminalManager } from "./terminal-manager.js";
@@ -31,7 +68,7 @@ import { handlePinDirectory, handleUnpinDirectory, handleReorderPinnedDirs, hand
31
68
  export interface BrowserGateway {
32
69
  wss: WebSocketServer;
33
70
  broadcastEvent(sessionId: string, seq: number, event: any): void;
34
- broadcastSessionAdded(session: any): void;
71
+ broadcastSessionAdded(session: any, opts?: { spawnRequestId?: string }): void;
35
72
  broadcastSessionUpdated(sessionId: string, updates: any): void;
36
73
  broadcastSessionRemoved(sessionId: string): void;
37
74
  sendToSubscribers(sessionId: string, msg: ServerToBrowserMessage): void;
@@ -82,6 +119,7 @@ export function createBrowserGateway(
82
119
  maxWsBufferBytes?: number,
83
120
  pendingAttachRegistry?: import("./pending-attach-registry.js").PendingAttachRegistry,
84
121
  pendingResumeIntents?: import("./pending-resume-intent-registry.js").PendingResumeIntentRegistry,
122
+ pendingClientCorrelations?: import("./pending-client-correlations.js").PendingClientCorrelations,
85
123
  ): BrowserGateway {
86
124
  const wss = new WebSocketServer({ noServer: true });
87
125
 
@@ -208,10 +246,21 @@ export function createBrowserGateway(
208
246
  const subs = new Set<string>();
209
247
  subscriptions.set(ws, subs);
210
248
 
211
- // Send all sessions on connect (client filters by hidden flag)
212
- const allSessions = sessionManager.listAll();
213
- for (const session of allSessions) {
214
- sendTo(ws, { type: "session_added", session });
249
+ // Atomic snapshot of the full session registry + per-cwd orders.
250
+ // Replaces the legacy per-session `session_added` loop and per-cwd
251
+ // `sessions_reordered` loop. Client REPLACES (not merges) its
252
+ // `sessions` Map and `sessionOrderMap` on receipt so stale ids from a
253
+ // previous server lifetime are dropped atomically.
254
+ // See change: fix-stale-sessions-on-reconnect.
255
+ {
256
+ const sessionsSnapshot = sessionManager.listAll();
257
+ const orders: Record<string, string[]> = {};
258
+ if (sessionOrderManager) {
259
+ for (const [cwd, sessionIds] of Object.entries(sessionOrderManager.getAllOrders())) {
260
+ if (sessionIds.length > 0) orders[cwd] = sessionIds;
261
+ }
262
+ }
263
+ sendTo(ws, { type: "sessions_snapshot", sessions: sessionsSnapshot, orders });
215
264
  }
216
265
 
217
266
  // Send pinned directories on connect
@@ -219,23 +268,12 @@ export function createBrowserGateway(
219
268
  sendTo(ws, { type: "pinned_dirs_updated", paths: preferencesStore.getPinnedDirectories() });
220
269
  }
221
270
 
222
- // Send session orders for all cwds
223
- if (sessionOrderManager) {
224
- const allOrders = sessionOrderManager.getAllOrders();
225
- for (const [cwd, sessionIds] of Object.entries(allOrders)) {
226
- if (sessionIds.length > 0) {
227
- sendTo(ws, { type: "sessions_reordered", cwd, sessionIds });
228
- }
229
- }
230
- }
231
-
232
- // Send cached OpenSpec data for all known directories
271
+ // Send OpenSpec data for every known directory — exactly one
272
+ // `openspec_update` per cwd, never silently omit.
273
+ // See change: fix-cold-boot-openspec-protocol.
233
274
  if (directoryService) {
234
- for (const cwd of directoryService.knownDirectories()) {
235
- const data = directoryService.getOpenSpecData(cwd);
236
- if (data && data.initialized) {
237
- sendTo(ws, { type: "openspec_update", cwd, data });
238
- }
275
+ for (const msg of buildOpenSpecConnectSnapshot(directoryService, hasOpenSpecDir)) {
276
+ sendTo(ws, msg);
239
277
  }
240
278
  }
241
279
 
@@ -270,6 +308,7 @@ export function createBrowserGateway(
270
308
  headlessPidRegistry, pendingResumeRegistry, pendingDashboardSpawns,
271
309
  pendingAttachRegistry,
272
310
  pendingResumeIntents,
311
+ pendingClientCorrelations,
273
312
  sendTo, broadcast, getSubscribers, replayPendingUiRequests,
274
313
  trackUiRequest: trackUiRequest,
275
314
  markReplaying(targetWs, sessionId) {
@@ -537,8 +576,15 @@ export function createBrowserGateway(
537
576
  }
538
577
  },
539
578
 
540
- broadcastSessionAdded(session: any) {
541
- broadcast({ type: "session_added", session });
579
+ broadcastSessionAdded(session: any, opts?: { spawnRequestId?: string }) {
580
+ // Carry the originating client `requestId` (when known) so the
581
+ // browser can auto-select / dismiss its placeholder by exact
582
+ // correlation. See change: spawn-correlation-token.
583
+ broadcast({
584
+ type: "session_added",
585
+ session,
586
+ ...(opts?.spawnRequestId ? { spawnRequestId: opts.spawnRequestId } : {}),
587
+ });
542
588
  },
543
589
 
544
590
  broadcastSessionUpdated(sessionId: string, updates: any) {
@@ -148,6 +148,10 @@ export function handlePiGatewayForward(
148
148
  case "request_models":
149
149
  piGateway.sendToSession(msg.sessionId, { type: "request_models", sessionId: msg.sessionId });
150
150
  break;
151
+ case "request_providers":
152
+ // See change: replace-hardcoded-provider-lists.
153
+ piGateway.sendToSession(msg.sessionId, { type: "request_providers", sessionId: msg.sessionId });
154
+ break;
151
155
  case "set_thinking_level":
152
156
  piGateway.sendToSession(msg.sessionId, { type: "set_thinking_level", sessionId: msg.sessionId, level: msg.level });
153
157
  break;
@@ -16,6 +16,7 @@ import type { HeadlessPidRegistry } from "../headless-pid-registry.js";
16
16
  import type { PendingResumeRegistry } from "../pending-resume-registry.js";
17
17
  import type { PendingAttachRegistry } from "../pending-attach-registry.js";
18
18
  import type { PendingResumeIntentRegistry } from "../pending-resume-intent-registry.js";
19
+ import type { PendingClientCorrelations } from "../pending-client-correlations.js";
19
20
 
20
21
  export interface BrowserHandlerContext {
21
22
  ws: WebSocket;
@@ -43,6 +44,14 @@ export interface BrowserHandlerContext {
43
44
  * See change: preserve-session-order-on-reboot.
44
45
  */
45
46
  pendingResumeIntents?: PendingResumeIntentRegistry;
47
+ /**
48
+ * Optional registry mapping `spawnToken → requestId` for client-side
49
+ * correlation. When set, browser-initiated spawns/resumes that carry a
50
+ * `requestId` are recorded so the eventual `session_added` broadcast
51
+ * carries `spawnRequestId` for auto-select / placeholder dismissal.
52
+ * See change: spawn-correlation-token.
53
+ */
54
+ pendingClientCorrelations?: PendingClientCorrelations;
46
55
  /** Send message to a specific WebSocket */
47
56
  sendTo(ws: WebSocket, msg: ServerToBrowserMessage): void;
48
57
  /** Broadcast to all connected browsers */
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * Session action handlers: send_prompt, abort, resume, spawn, shutdown, flow_control.
3
3
  */
4
+ import { existsSync } from "node:fs";
4
5
  import type { BrowserToServerMessage } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
5
6
  import type { BrowserHandlerContext } from "./handler-context.js";
6
7
  import { spawnPiSession } from "../process-manager.js";
8
+ import { ToolResolver } from "@blackbelt-technology/pi-dashboard-shared/platform/binary-lookup.js";
7
9
  import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
10
+ import { preflightSpawn } from "../spawn-preflight.js";
11
+ import { getSpawnRegisterWatchdog } from "../spawn-register-watchdog.js";
12
+ import { appendSpawnFailure } from "../spawn-failure-log.js";
8
13
  import { createBranchedSessionFile } from "../session-file-reader.js";
9
14
  import {
10
15
  killPidWithGroup,
@@ -15,6 +20,19 @@ import {
15
20
  } from "@blackbelt-technology/pi-dashboard-shared/platform/process-identify.js";
16
21
  import { shouldInterceptReload } from "./session-action-helpers.js";
17
22
 
23
+ /**
24
+ * Status message + code emitted when fork is attempted on a session whose
25
+ * `.jsonl` does not exist on disk yet (empty session, no persisted entries).
26
+ * The dashboard silently degrades to a fresh spawn in the same cwd — fork
27
+ * has no history to copy, so the user-meaningful semantic of "fork" and
28
+ * "new" is identical here. The structured code lets the client surface a
29
+ * non-blocking toast.
30
+ * See change: fix-fork-empty-session-silent-timeout.
31
+ */
32
+ export const FORK_DEGRADED_TO_NEW_MESSAGE =
33
+ "Started a fresh session \u2014 the source had no persisted history to fork from.";
34
+ export const FORK_DEGRADED_TO_NEW_CODE = "FORK_DEGRADED_TO_NEW";
35
+
18
36
  /**
19
37
  * Find headless pi PIDs associated with a session-id marker and kill them.
20
38
  * Delegates platform branching to `platform/process-identify.ts` — Windows
@@ -236,10 +254,10 @@ export async function handleResumeSession(
236
254
  msg: Extract<BrowserToServerMessage, { type: "resume_session" }>,
237
255
  ctx: BrowserHandlerContext,
238
256
  ): Promise<void> {
239
- const { ws, sessionManager, pendingForkRegistry, headlessPidRegistry, pendingDashboardSpawns, pendingResumeIntents, sendTo } = ctx;
257
+ const { ws, sessionManager, pendingForkRegistry, headlessPidRegistry, pendingDashboardSpawns, pendingResumeIntents, pendingClientCorrelations, sendTo } = ctx;
240
258
  const session = sessionManager.get(msg.sessionId);
241
259
  if (!session) {
242
- sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session not found" });
260
+ sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session not found", requestId: msg.requestId });
243
261
  return;
244
262
  }
245
263
  // Resolve placement intent. Old browsers omit the field; default to
@@ -248,28 +266,71 @@ export async function handleResumeSession(
248
266
  // See change: differentiate-resume-intent-by-trigger.
249
267
  const placement: "front" | "keep" = msg.placement ?? "front";
250
268
  if (!session.sessionFile) {
251
- sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session file is unknown (pre-migration session)" });
269
+ sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session file is unknown (pre-migration session)", requestId: msg.requestId });
252
270
  return;
253
271
  }
254
272
  if (msg.mode === "continue" && session.status !== "ended") {
255
- sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session is already active" });
273
+ sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session is already active", requestId: msg.requestId });
256
274
  return;
257
275
  }
258
276
  if (session.resuming) {
259
- sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session is already being resumed" });
277
+ sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session is already being resumed", requestId: msg.requestId });
260
278
  return;
261
279
  }
262
- if (msg.mode === "fork" && pendingForkRegistry) {
263
- pendingForkRegistry.recordFork(session.cwd, msg.sessionId);
280
+ // Fork preflight: silent-degrade when the source session has no on-disk
281
+ // JSONL yet (empty session, no persisted entries). `pi --fork <missing>`
282
+ // would crash silently and produce a 30s register-timeout; instead we
283
+ // spawn a fresh pi in the same cwd and surface `code: FORK_DEGRADED_TO_NEW`
284
+ // so the client can render a non-blocking toast. The parent's
285
+ // attachedProposal (if any) is inherited via `pendingAttachRegistry`
286
+ // since fork's own inheritance path doesn't run on this branch.
287
+ // See change: fix-fork-empty-session-silent-timeout.
288
+ if (msg.mode === "fork" && session.sessionFile && !existsSync(session.sessionFile)) {
289
+ // Inherit attachedProposal from parent so the new session still
290
+ // tracks the change the user was working on.
291
+ const pendingAttachRegistry = ctx.pendingAttachRegistry;
292
+ if (session.attachedProposal && pendingAttachRegistry) {
293
+ pendingAttachRegistry.enqueue(session.cwd, session.attachedProposal);
294
+ }
295
+ const degradeConfig = loadConfig();
296
+ // Fresh spawn: no sessionFile, no mode — just `pi --mode rpc`.
297
+ const degradeResult = await spawnPiSession(session.cwd, {
298
+ strategy: degradeConfig.spawnStrategy,
299
+ });
300
+ if (degradeResult.process && degradeResult.pid) {
301
+ headlessPidRegistry.register(
302
+ degradeResult.pid,
303
+ session.cwd,
304
+ degradeResult.process,
305
+ degradeResult.spawnToken,
306
+ );
307
+ }
308
+ if (msg.requestId && degradeResult.spawnToken && pendingClientCorrelations) {
309
+ pendingClientCorrelations.record(degradeResult.spawnToken, msg.requestId);
310
+ }
311
+ if (degradeResult.dashboardSpawned && degradeResult.success) {
312
+ pendingDashboardSpawns?.set(
313
+ session.cwd,
314
+ (pendingDashboardSpawns?.get(session.cwd) ?? 0) + 1,
315
+ );
316
+ }
317
+ sendTo(ws, {
318
+ type: "resume_result",
319
+ sessionId: msg.sessionId,
320
+ success: degradeResult.success,
321
+ message: degradeResult.success ? FORK_DEGRADED_TO_NEW_MESSAGE : degradeResult.message,
322
+ requestId: msg.requestId,
323
+ ...(degradeResult.success ? { code: FORK_DEGRADED_TO_NEW_CODE } : {}),
324
+ });
325
+ return;
264
326
  }
265
-
266
327
  // For fork-from-message: create a pruned session file first
267
328
  let forkSessionFile = session.sessionFile;
268
329
  if (msg.mode === "fork" && msg.entryId) {
269
330
  try {
270
331
  forkSessionFile = createBranchedSessionFile(session.sessionFile, msg.entryId);
271
332
  } catch (err: any) {
272
- sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: `Fork from entry failed: ${err.message}` });
333
+ sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: `Fork from entry failed: ${err.message}`, requestId: msg.requestId });
273
334
  return;
274
335
  }
275
336
  }
@@ -289,30 +350,61 @@ export async function handleResumeSession(
289
350
  mode: msg.mode,
290
351
  strategy: resumeConfig.spawnStrategy,
291
352
  });
353
+ // Record fork parent keyed by spawn token (was: keyed by cwd, racy on
354
+ // multi-fork-in-same-cwd). See change: spawn-correlation-token.
355
+ if (msg.mode === "fork" && pendingForkRegistry && result.spawnToken) {
356
+ pendingForkRegistry.recordFork(result.spawnToken, msg.sessionId);
357
+ }
358
+ // Record client-correlation so the eventual session_added carries
359
+ // spawnRequestId. See change: spawn-correlation-token.
360
+ if (msg.requestId && result.spawnToken && pendingClientCorrelations) {
361
+ pendingClientCorrelations.record(result.spawnToken, msg.requestId);
362
+ }
292
363
  if (result.dashboardSpawned && result.success) {
293
364
  pendingDashboardSpawns?.set(session.cwd, (pendingDashboardSpawns?.get(session.cwd) ?? 0) + 1);
294
365
  }
295
366
  if (result.process && result.pid) {
296
- headlessPidRegistry.register(result.pid, session.cwd, result.process);
367
+ headlessPidRegistry.register(result.pid, session.cwd, result.process, result.spawnToken);
297
368
  }
298
- sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: result.success, message: result.message });
369
+ sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: result.success, message: result.message, requestId: msg.requestId });
299
370
  }
300
371
 
301
372
  export async function handleSpawnSession(
302
373
  msg: Extract<BrowserToServerMessage, { type: "spawn_session" }>,
303
374
  ctx: BrowserHandlerContext,
304
375
  ): Promise<void> {
305
- const { ws, headlessPidRegistry, pendingDashboardSpawns, pendingAttachRegistry, sendTo } = ctx;
376
+ const { ws, headlessPidRegistry, pendingDashboardSpawns, pendingAttachRegistry, pendingClientCorrelations, sendTo } = ctx;
306
377
  const config = loadConfig();
307
378
  const strategy = config.spawnStrategy ?? "tmux";
308
379
 
309
380
  // Queue the optional attach intent BEFORE awaiting the spawn so a fast
310
381
  // bridge `session_register` cannot lose the intent. See change:
311
- // add-folder-task-checker-and-spawn-attach.
382
+ // add-folder-task-checker-and-spawn-attach. NOTE: at this point we don't
383
+ // yet have a spawnToken (spawn hasn't run); we enqueue by cwd-FIFO and
384
+ // re-record by token after spawnPiSession returns. See change:
385
+ // spawn-correlation-token.
312
386
  if (typeof msg.attachProposal === "string" && msg.attachProposal.length > 0) {
313
387
  pendingAttachRegistry?.enqueue(msg.cwd, msg.attachProposal);
314
388
  }
315
389
 
390
+ // ── Preflight: fast synchronous checks before spawning. See change: spawn-failure-diagnostics.
391
+ const preflightResolver = new ToolResolver({ processExecPath: process.execPath, useLoginShell: false });
392
+ const preflight = preflightSpawn(msg.cwd, { resolver: preflightResolver });
393
+ if (!preflight.ok) {
394
+ const message = preflight.reasons.map((r) => r.message).join("; ");
395
+ sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: false, message, requestId: msg.requestId });
396
+ sendTo(ws, { type: "spawn_error", cwd: msg.cwd, strategy, message, code: "PREFLIGHT_FAILED", reasons: preflight.reasons });
397
+ appendSpawnFailure({
398
+ ts: new Date().toISOString(),
399
+ cwd: msg.cwd,
400
+ strategy,
401
+ code: "PREFLIGHT_FAILED",
402
+ message,
403
+ reasons: preflight.reasons,
404
+ });
405
+ return;
406
+ }
407
+
316
408
  // Catch both thrown exceptions and { success: false } results; surface as
317
409
  // spawn_error so the UI can render a retryable banner instead of failing
318
410
  // silently. Previous behaviour left the user staring at an empty state
@@ -320,20 +412,69 @@ export async function handleSpawnSession(
320
412
  try {
321
413
  const spawnResult = await spawnPiSession(msg.cwd, { strategy });
322
414
  if (spawnResult.process && spawnResult.pid) {
323
- headlessPidRegistry.register(spawnResult.pid, msg.cwd, spawnResult.process);
415
+ headlessPidRegistry.register(spawnResult.pid, msg.cwd, spawnResult.process, spawnResult.spawnToken);
416
+ }
417
+ // Record client-correlation so the eventual session_added carries
418
+ // spawnRequestId. See change: spawn-correlation-token.
419
+ if (msg.requestId && spawnResult.spawnToken && pendingClientCorrelations) {
420
+ pendingClientCorrelations.record(spawnResult.spawnToken, msg.requestId);
324
421
  }
325
422
  if (spawnResult.dashboardSpawned && spawnResult.success) {
326
423
  pendingDashboardSpawns?.set(msg.cwd, (pendingDashboardSpawns?.get(msg.cwd) ?? 0) + 1);
327
424
  }
328
- sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: spawnResult.success, message: spawnResult.message });
425
+ sendTo(ws, {
426
+ type: "spawn_result",
427
+ cwd: msg.cwd,
428
+ success: spawnResult.success,
429
+ message: spawnResult.message,
430
+ requestId: msg.requestId,
431
+ ...(spawnResult.pid ? { pid: spawnResult.pid } : {}),
432
+ });
329
433
  if (!spawnResult.success) {
330
- sendTo(ws, { type: "spawn_error", cwd: msg.cwd, strategy, message: spawnResult.message });
434
+ sendTo(ws, {
435
+ type: "spawn_error",
436
+ cwd: msg.cwd,
437
+ strategy,
438
+ message: spawnResult.message,
439
+ ...(spawnResult.code ? { code: spawnResult.code } : {}),
440
+ ...(spawnResult.stderr ? { stderr: spawnResult.stderr } : {}),
441
+ });
442
+ appendSpawnFailure({
443
+ ts: new Date().toISOString(),
444
+ cwd: msg.cwd,
445
+ strategy,
446
+ code: spawnResult.code ?? "SPAWN_ERRNO",
447
+ message: spawnResult.message,
448
+ ...(spawnResult.stderr ? { stderrTail: spawnResult.stderr } : {}),
449
+ });
450
+ } else {
451
+ // Arm watchdog for every successful spawn. See change: spawn-failure-diagnostics.
452
+ const watchdog = getSpawnRegisterWatchdog();
453
+ watchdog.arm({
454
+ pid: spawnResult.pid,
455
+ cwd: msg.cwd,
456
+ mechanism: strategy as import("@blackbelt-technology/pi-dashboard-shared/platform/spawn-mechanism.js").SpawnMechanism,
457
+ logPath: spawnResult.logPath,
458
+ // Read-on-arm: pass current config value so a Settings change takes effect
459
+ // on the next spawn without a server restart. See change: spawn-failure-diagnostics (fix W1).
460
+ timeoutMs: config.spawnRegisterTimeoutMs,
461
+ ws,
462
+ spawnToken: spawnResult.spawnToken,
463
+ });
331
464
  }
332
465
  } catch (err) {
333
466
  const message = err instanceof Error ? err.message : String(err);
334
467
  const stderr = err instanceof Error && "stderr" in err ? String((err as { stderr: unknown }).stderr).slice(-2048) : undefined;
335
- sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: false, message });
336
- sendTo(ws, { type: "spawn_error", cwd: msg.cwd, strategy, message, stderr });
468
+ sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: false, message, requestId: msg.requestId });
469
+ sendTo(ws, { type: "spawn_error", cwd: msg.cwd, strategy, message, code: "SPAWN_ERRNO", stderr });
470
+ appendSpawnFailure({
471
+ ts: new Date().toISOString(),
472
+ cwd: msg.cwd,
473
+ strategy,
474
+ code: "SPAWN_ERRNO",
475
+ message,
476
+ ...(stderr ? { stderrTail: stderr } : {}),
477
+ });
337
478
  }
338
479
  }
339
480
 
@@ -68,7 +68,7 @@ async function sendEventBatches(
68
68
  * Called immediately after every `replayPendingUiRequests` site so the full
69
69
  * replay ordering is:
70
70
  *
71
- * events → pending UI requests → ui_modules_list → ui_data_list → ext_ui_decorator
71
+ * asset_register batch → events → pending UI requests → ui_modules_list → ui_data_list → ext_ui_decorator
72
72
  *
73
73
  * Exported so unit tests can drive it without standing up a full subscribe
74
74
  * pipeline. See changes: add-extension-ui-modal, add-extension-ui-decorations.
@@ -96,6 +96,35 @@ export function replayUiState(
96
96
  }
97
97
  }
98
98
 
99
+ /**
100
+ * Replay the per-session image asset registry to a single browser. Sends one
101
+ * `asset_register` message per `(hash, { data, mimeType })` entry in
102
+ * `Session.assets`. Called BEFORE `sendEventBatches` so any `pi-asset:<hash>`
103
+ * tokens in replayed `message_update` / `message_end` events have their
104
+ * referent in the client's session map by the time they're reduced.
105
+ *
106
+ * See change: chat-markdown-local-images-and-math.
107
+ */
108
+ export function replaySessionAssets(
109
+ ws: WebSocket,
110
+ sessionId: string,
111
+ ctx: Pick<BrowserHandlerContext, "sessionManager" | "sendTo">,
112
+ ): void {
113
+ const { sessionManager, sendTo } = ctx;
114
+ const session = sessionManager.get(sessionId);
115
+ if (!session?.assets) return;
116
+ for (const [hash, asset] of Object.entries(session.assets)) {
117
+ if (!asset || typeof asset.data !== "string" || typeof asset.mimeType !== "string") continue;
118
+ sendTo(ws, {
119
+ type: "asset_register",
120
+ sessionId,
121
+ hash,
122
+ mimeType: asset.mimeType,
123
+ data: asset.data,
124
+ } as any);
125
+ }
126
+ }
127
+
99
128
  export function handleSubscribe(
100
129
  msg: Extract<BrowserToServerMessage, { type: "subscribe" }>,
101
130
  subs: Set<string>,
@@ -108,6 +137,8 @@ export function handleSubscribe(
108
137
  // while the browser is actually subscribed (responses use sendToSubscribers).
109
138
  piGateway.sendToSession(msg.sessionId, { type: "request_commands", sessionId: msg.sessionId });
110
139
  piGateway.sendToSession(msg.sessionId, { type: "request_models", sessionId: msg.sessionId });
140
+ // See change: replace-hardcoded-provider-lists.
141
+ piGateway.sendToSession(msg.sessionId, { type: "request_providers", sessionId: msg.sessionId });
111
142
  piGateway.sendToSession(msg.sessionId, { type: "request_roles", sessionId: msg.sessionId });
112
143
 
113
144
  if (eventStore.hasEvents(msg.sessionId)) {
@@ -122,6 +153,10 @@ export function handleSubscribe(
122
153
  if (MAX_REPLAY_EVENTS > 0 && events.length > MAX_REPLAY_EVENTS) {
123
154
  events = events.slice(events.length - MAX_REPLAY_EVENTS);
124
155
  }
156
+ // Replay asset registry BEFORE events so pi-asset:<hash> tokens in
157
+ // message_update / message_end resolve on first reduce.
158
+ // See change: chat-markdown-local-images-and-math.
159
+ replaySessionAssets(ws, msg.sessionId, ctx);
125
160
  markReplaying(ws, msg.sessionId);
126
161
  sendEventBatches(ws, msg.sessionId, events, sendTo).then((lastSent) => {
127
162
  clearReplaying(ws, msg.sessionId, lastSent);
@@ -133,8 +168,18 @@ export function handleSubscribe(
133
168
  if (MAX_REPLAY_EVENTS > 0 && events.length > MAX_REPLAY_EVENTS) {
134
169
  events = events.slice(events.length - MAX_REPLAY_EVENTS);
135
170
  }
136
- // Suppress live events during delta replay to prevent out-of-order delivery
137
- if (lastSeq > 0 && events.length > 0) {
171
+ // Replay asset registry on every subscribe (delta or full). Cheap when
172
+ // empty; assets already known to the client are simply re-overwritten
173
+ // with identical bytes. See change: chat-markdown-local-images-and-math.
174
+ replaySessionAssets(ws, msg.sessionId, ctx);
175
+ // Suppress live events during paginated replay to prevent out-of-order
176
+ // delivery. The client's `event_replay` reset rule (firstSeq <= maxSeq)
177
+ // misfires if a live `event` arrives between batches and bumps maxSeq
178
+ // past the next batch's firstSeq — wiping state to a fresh build of
179
+ // only the last batch. Suppression+catch-up via clearReplaying preserves
180
+ // ordering for both cold (lastSeq=0) and warm (lastSeq>0) subscribes.
181
+ // See change: fix-cold-subscribe-replay-interleave.
182
+ if (events.length > 0) {
138
183
  markReplaying(ws, msg.sessionId);
139
184
  sendEventBatches(ws, msg.sessionId, events, sendTo).then((lastSent) => {
140
185
  clearReplaying(ws, msg.sessionId, lastSent);
@@ -172,6 +217,8 @@ export function handleSubscribe(
172
217
  }
173
218
  const subscribers = getSubscribers(msg.sessionId);
174
219
  for (const sub of subscribers) {
220
+ // Asset registry first — see change: chat-markdown-local-images-and-math.
221
+ replaySessionAssets(sub, msg.sessionId, ctx);
175
222
  await sendEventBatches(sub, msg.sessionId, stored, sendTo);
176
223
  replayPendingUiRequests(sub, msg.sessionId);
177
224
  replayUiState(sub, msg.sessionId, ctx);