@blackbelt-technology/pi-agent-dashboard 0.5.0 → 0.5.2

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 (201) hide show
  1. package/AGENTS.md +26 -5
  2. package/README.md +49 -7
  3. package/docs/architecture.md +129 -1
  4. package/package.json +15 -15
  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__/bridge-slash-command-routing.test.ts +362 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +78 -8
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
  13. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  14. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  15. package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
  16. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  17. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  18. package/packages/extension/src/ask-user-tool.ts +1 -1
  19. package/packages/extension/src/bridge-context.ts +68 -4
  20. package/packages/extension/src/bridge.ts +79 -11
  21. package/packages/extension/src/command-handler.ts +95 -15
  22. package/packages/extension/src/flow-event-wiring.ts +1 -1
  23. package/packages/extension/src/multiselect-list.ts +1 -1
  24. package/packages/extension/src/pi-env.d.ts +16 -9
  25. package/packages/extension/src/prompt-expander.ts +74 -63
  26. package/packages/extension/src/provider-register.ts +16 -9
  27. package/packages/extension/src/retry-tracker.ts +123 -0
  28. package/packages/extension/src/server-launcher.ts +31 -70
  29. package/packages/extension/src/session-sync.ts +10 -1
  30. package/packages/extension/src/slash-dispatch.ts +123 -0
  31. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  32. package/packages/server/bin/pi-dashboard.mjs +84 -0
  33. package/packages/server/package.json +8 -7
  34. package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
  35. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  36. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  37. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  38. package/packages/server/src/__tests__/cli-parse.test.ts +16 -4
  39. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
  40. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  41. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  42. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  43. package/packages/server/src/__tests__/directory-service.test.ts +2 -2
  44. package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
  45. package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
  46. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
  47. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  48. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  49. package/packages/server/src/__tests__/headless-pid-registry.test.ts +316 -0
  50. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  51. package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
  52. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
  53. package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
  55. package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
  56. package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
  57. package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
  58. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
  59. package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
  60. package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
  61. package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
  62. package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
  63. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  64. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  65. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  66. package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
  67. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  68. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  69. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  70. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
  71. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  72. package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
  73. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  74. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  75. package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
  76. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  77. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
  78. package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
  79. package/packages/server/src/__tests__/recommended-routes.test.ts +3 -3
  80. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  81. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
  82. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  83. package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
  84. package/packages/server/src/auth-plugin.ts +3 -0
  85. package/packages/server/src/bootstrap-state.ts +10 -0
  86. package/packages/server/src/browser-gateway.ts +27 -10
  87. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  88. package/packages/server/src/browser-handlers/session-action-handler.ts +128 -19
  89. package/packages/server/src/changelog-fs.ts +167 -0
  90. package/packages/server/src/changelog-parser.ts +321 -0
  91. package/packages/server/src/changelog-remote.ts +134 -0
  92. package/packages/server/src/cli.ts +62 -82
  93. package/packages/server/src/config-api.ts +14 -2
  94. package/packages/server/src/directory-service.ts +106 -4
  95. package/packages/server/src/event-wiring.ts +90 -6
  96. package/packages/server/src/headless-pid-registry.ts +344 -37
  97. package/packages/server/src/legacy-pi-cleanup.ts +151 -0
  98. package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
  99. package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
  100. package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
  101. package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
  102. package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
  103. package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
  104. package/packages/server/src/model-proxy/api-key-store.ts +87 -0
  105. package/packages/server/src/model-proxy/auth-gate.ts +116 -0
  106. package/packages/server/src/model-proxy/concurrency.ts +76 -0
  107. package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
  108. package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
  109. package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
  110. package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
  111. package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
  112. package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
  113. package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
  114. package/packages/server/src/model-proxy/convert/index.ts +8 -0
  115. package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
  116. package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
  117. package/packages/server/src/model-proxy/convert/types.ts +70 -0
  118. package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
  119. package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
  120. package/packages/server/src/model-proxy/internal-registry.ts +157 -0
  121. package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
  122. package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
  123. package/packages/server/src/model-proxy/request-log.ts +53 -0
  124. package/packages/server/src/model-proxy/streamer.ts +59 -0
  125. package/packages/server/src/openspec-group-store.ts +490 -0
  126. package/packages/server/src/pending-client-correlations.ts +73 -0
  127. package/packages/server/src/pending-fork-registry.ts +24 -12
  128. package/packages/server/src/pi-core-checker.ts +77 -17
  129. package/packages/server/src/pi-core-updater.ts +16 -6
  130. package/packages/server/src/pi-dev-version-check.ts +145 -0
  131. package/packages/server/src/pi-gateway.ts +4 -0
  132. package/packages/server/src/pi-version-skew.ts +12 -4
  133. package/packages/server/src/process-manager.ts +182 -11
  134. package/packages/server/src/provider-auth-storage.ts +29 -47
  135. package/packages/server/src/provider-catalogue-cache.ts +24 -18
  136. package/packages/server/src/restart-helper.ts +17 -16
  137. package/packages/server/src/routes/bootstrap-routes.ts +37 -0
  138. package/packages/server/src/routes/jj-routes.ts +3 -0
  139. package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
  140. package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
  141. package/packages/server/src/routes/model-proxy-routes.ts +330 -0
  142. package/packages/server/src/routes/openspec-group-routes.ts +231 -0
  143. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  144. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  145. package/packages/server/src/routes/provider-auth-routes.ts +8 -1
  146. package/packages/server/src/routes/provider-routes.ts +28 -5
  147. package/packages/server/src/routes/system-routes.ts +44 -2
  148. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
  149. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
  150. package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
  151. package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
  152. package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
  153. package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
  154. package/packages/server/src/server.ts +254 -60
  155. package/packages/server/src/session-api.ts +63 -4
  156. package/packages/server/src/session-discovery.ts +1 -1
  157. package/packages/server/src/session-file-reader.ts +1 -1
  158. package/packages/server/src/spawn-register-watchdog.ts +62 -7
  159. package/packages/server/src/spawn-token.ts +20 -0
  160. package/packages/server/src/tunnel-watchdog.ts +230 -0
  161. package/packages/server/src/tunnel.ts +5 -1
  162. package/packages/shared/package.json +1 -1
  163. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
  164. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
  165. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  166. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  167. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  168. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  169. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  170. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  172. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  173. package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
  174. package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
  175. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
  176. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  177. package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
  178. package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
  179. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
  180. package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
  181. package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
  182. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  183. package/packages/shared/src/bootstrap-install.ts +1 -1
  184. package/packages/shared/src/browser-protocol.ts +70 -0
  185. package/packages/shared/src/changelog-types.ts +111 -0
  186. package/packages/shared/src/config.ts +172 -2
  187. package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
  188. package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
  189. package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
  190. package/packages/shared/src/platform/binary-lookup.ts +204 -0
  191. package/packages/shared/src/platform/node-spawn.ts +71 -26
  192. package/packages/shared/src/protocol.ts +27 -1
  193. package/packages/shared/src/recommended-extensions.ts +18 -0
  194. package/packages/shared/src/rest-api.ts +219 -1
  195. package/packages/shared/src/server-launcher.ts +277 -0
  196. package/packages/shared/src/skill-block-parser.ts +1 -1
  197. package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
  198. package/packages/shared/src/tool-registry/definitions.ts +15 -3
  199. package/packages/shared/src/types.ts +62 -0
  200. package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -53
  201. package/packages/shared/src/resolve-jiti.ts +0 -102
@@ -5,6 +5,7 @@
5
5
  import { WebSocketServer, WebSocket } from "ws";
6
6
  import type {
7
7
  ServerToBrowserMessage,
8
+ BrowserOpenSpecUpdateMessage,
8
9
  BrowserToServerMessage,
9
10
  } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
10
11
  import type { SessionManager } from "./memory-session-manager.js";
@@ -15,7 +16,7 @@ import { createHeadlessPidRegistry, type HeadlessPidRegistry } from "./headless-
15
16
  import type { PendingForkRegistry } from "./pending-fork-registry.js";
16
17
  import type { SessionOrderManager } from "./session-order-manager.js";
17
18
  import type { PreferencesStore } from "./preferences-store.js";
18
- import { hasOpenSpecDir, type DirectoryService } from "./directory-service.js";
19
+ import { hasOpenSpecDir, hasOpenSpecRoot, type DirectoryService } from "./directory-service.js";
19
20
 
20
21
  /**
21
22
  * Pure helper: build the per-cwd `openspec_update` messages a freshly
@@ -31,23 +32,30 @@ import { hasOpenSpecDir, type DirectoryService } from "./directory-service.js";
31
32
  export function buildOpenSpecConnectSnapshot(
32
33
  directoryService: Pick<DirectoryService, "knownDirectories" | "getOpenSpecData">,
33
34
  hasDir: (cwd: string) => boolean,
34
- ): Array<ServerToBrowserMessage> {
35
- const out: Array<ServerToBrowserMessage> = [];
35
+ hasRoot: (cwd: string) => boolean = hasDir,
36
+ ): Array<BrowserOpenSpecUpdateMessage> {
37
+ const out: Array<BrowserOpenSpecUpdateMessage> = [];
36
38
  for (const cwd of directoryService.knownDirectories()) {
37
39
  const cached = directoryService.getOpenSpecData(cwd);
40
+ const root = hasRoot(cwd);
38
41
  if (cached && cached.initialized) {
39
- out.push({ type: "openspec_update", cwd, data: cached });
42
+ // Cached payload already carries `hasOpenspecDir` set by `pollOne`; if
43
+ // an old cache entry predates that field, fill it from the live probe.
44
+ const data = cached.hasOpenspecDir === undefined
45
+ ? { ...cached, hasOpenspecDir: root }
46
+ : cached;
47
+ out.push({ type: "openspec_update", cwd, data });
40
48
  } else if (hasDir(cwd)) {
41
49
  out.push({
42
50
  type: "openspec_update",
43
51
  cwd,
44
- data: { initialized: false, pending: true, changes: [] },
52
+ data: { initialized: false, pending: true, changes: [], hasOpenspecDir: root },
45
53
  });
46
54
  } else {
47
55
  out.push({
48
56
  type: "openspec_update",
49
57
  cwd,
50
- data: { initialized: false, pending: false, changes: [] },
58
+ data: { initialized: false, pending: false, changes: [], hasOpenspecDir: root },
51
59
  });
52
60
  }
53
61
  }
@@ -68,7 +76,7 @@ import { handlePinDirectory, handleUnpinDirectory, handleReorderPinnedDirs, hand
68
76
  export interface BrowserGateway {
69
77
  wss: WebSocketServer;
70
78
  broadcastEvent(sessionId: string, seq: number, event: any): void;
71
- broadcastSessionAdded(session: any): void;
79
+ broadcastSessionAdded(session: any, opts?: { spawnRequestId?: string }): void;
72
80
  broadcastSessionUpdated(sessionId: string, updates: any): void;
73
81
  broadcastSessionRemoved(sessionId: string): void;
74
82
  sendToSubscribers(sessionId: string, msg: ServerToBrowserMessage): void;
@@ -119,6 +127,7 @@ export function createBrowserGateway(
119
127
  maxWsBufferBytes?: number,
120
128
  pendingAttachRegistry?: import("./pending-attach-registry.js").PendingAttachRegistry,
121
129
  pendingResumeIntents?: import("./pending-resume-intent-registry.js").PendingResumeIntentRegistry,
130
+ pendingClientCorrelations?: import("./pending-client-correlations.js").PendingClientCorrelations,
122
131
  ): BrowserGateway {
123
132
  const wss = new WebSocketServer({ noServer: true });
124
133
 
@@ -271,7 +280,7 @@ export function createBrowserGateway(
271
280
  // `openspec_update` per cwd, never silently omit.
272
281
  // See change: fix-cold-boot-openspec-protocol.
273
282
  if (directoryService) {
274
- for (const msg of buildOpenSpecConnectSnapshot(directoryService, hasOpenSpecDir)) {
283
+ for (const msg of buildOpenSpecConnectSnapshot(directoryService, hasOpenSpecDir, hasOpenSpecRoot)) {
275
284
  sendTo(ws, msg);
276
285
  }
277
286
  }
@@ -307,6 +316,7 @@ export function createBrowserGateway(
307
316
  headlessPidRegistry, pendingResumeRegistry, pendingDashboardSpawns,
308
317
  pendingAttachRegistry,
309
318
  pendingResumeIntents,
319
+ pendingClientCorrelations,
310
320
  sendTo, broadcast, getSubscribers, replayPendingUiRequests,
311
321
  trackUiRequest: trackUiRequest,
312
322
  markReplaying(targetWs, sessionId) {
@@ -574,8 +584,15 @@ export function createBrowserGateway(
574
584
  }
575
585
  },
576
586
 
577
- broadcastSessionAdded(session: any) {
578
- broadcast({ type: "session_added", session });
587
+ broadcastSessionAdded(session: any, opts?: { spawnRequestId?: string }) {
588
+ // Carry the originating client `requestId` (when known) so the
589
+ // browser can auto-select / dismiss its placeholder by exact
590
+ // correlation. See change: spawn-correlation-token.
591
+ broadcast({
592
+ type: "session_added",
593
+ session,
594
+ ...(opts?.spawnRequestId ? { spawnRequestId: opts.spawnRequestId } : {}),
595
+ });
579
596
  },
580
597
 
581
598
  broadcastSessionUpdated(sessionId: string, updates: any) {
@@ -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,6 +1,7 @@
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";
@@ -18,6 +19,20 @@ import {
18
19
  findPidByMarker,
19
20
  } from "@blackbelt-technology/pi-dashboard-shared/platform/process-identify.js";
20
21
  import { shouldInterceptReload } from "./session-action-helpers.js";
22
+ import { keeperOptsFromSpawnResult } from "../headless-pid-registry.js";
23
+
24
+ /**
25
+ * Status message + code emitted when fork is attempted on a session whose
26
+ * `.jsonl` does not exist on disk yet (empty session, no persisted entries).
27
+ * The dashboard silently degrades to a fresh spawn in the same cwd — fork
28
+ * has no history to copy, so the user-meaningful semantic of "fork" and
29
+ * "new" is identical here. The structured code lets the client surface a
30
+ * non-blocking toast.
31
+ * See change: fix-fork-empty-session-silent-timeout.
32
+ */
33
+ export const FORK_DEGRADED_TO_NEW_MESSAGE =
34
+ "Started a fresh session \u2014 the source had no persisted history to fork from.";
35
+ export const FORK_DEGRADED_TO_NEW_CODE = "FORK_DEGRADED_TO_NEW";
21
36
 
22
37
  /**
23
38
  * Find headless pi PIDs associated with a session-id marker and kill them.
@@ -162,7 +177,13 @@ export async function handleHeadlessReload(
162
177
  }
163
178
 
164
179
  if (spawnResult.pid && spawnResult.process) {
165
- headlessPidRegistry.register(spawnResult.pid, session.cwd, spawnResult.process);
180
+ headlessPidRegistry.register(
181
+ spawnResult.pid,
182
+ session.cwd,
183
+ spawnResult.process,
184
+ spawnResult.spawnToken,
185
+ keeperOptsFromSpawnResult(spawnResult),
186
+ );
166
187
  }
167
188
 
168
189
  emitCommandFeedback(ctx, msg.sessionId, "completed");
@@ -221,7 +242,13 @@ export async function handleSendPrompt(
221
242
  pendingDashboardSpawns?.set(promptSession.cwd, (pendingDashboardSpawns?.get(promptSession.cwd) ?? 0) + 1);
222
243
  }
223
244
  if (spawnResult.process && spawnResult.pid) {
224
- headlessPidRegistry.register(spawnResult.pid, promptSession.cwd, spawnResult.process);
245
+ headlessPidRegistry.register(
246
+ spawnResult.pid,
247
+ promptSession.cwd,
248
+ spawnResult.process,
249
+ spawnResult.spawnToken,
250
+ keeperOptsFromSpawnResult(spawnResult),
251
+ );
225
252
  }
226
253
  } else {
227
254
  const sent = piGateway.sendToSession(msg.sessionId, {
@@ -240,10 +267,10 @@ export async function handleResumeSession(
240
267
  msg: Extract<BrowserToServerMessage, { type: "resume_session" }>,
241
268
  ctx: BrowserHandlerContext,
242
269
  ): Promise<void> {
243
- const { ws, sessionManager, pendingForkRegistry, headlessPidRegistry, pendingDashboardSpawns, pendingResumeIntents, sendTo } = ctx;
270
+ const { ws, sessionManager, pendingForkRegistry, headlessPidRegistry, pendingDashboardSpawns, pendingResumeIntents, pendingClientCorrelations, sendTo } = ctx;
244
271
  const session = sessionManager.get(msg.sessionId);
245
272
  if (!session) {
246
- sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session not found" });
273
+ sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session not found", requestId: msg.requestId });
247
274
  return;
248
275
  }
249
276
  // Resolve placement intent. Old browsers omit the field; default to
@@ -252,28 +279,72 @@ export async function handleResumeSession(
252
279
  // See change: differentiate-resume-intent-by-trigger.
253
280
  const placement: "front" | "keep" = msg.placement ?? "front";
254
281
  if (!session.sessionFile) {
255
- sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session file is unknown (pre-migration session)" });
282
+ sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session file is unknown (pre-migration session)", requestId: msg.requestId });
256
283
  return;
257
284
  }
258
285
  if (msg.mode === "continue" && session.status !== "ended") {
259
- sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session is already active" });
286
+ sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session is already active", requestId: msg.requestId });
260
287
  return;
261
288
  }
262
289
  if (session.resuming) {
263
- sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session is already being resumed" });
290
+ sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session is already being resumed", requestId: msg.requestId });
264
291
  return;
265
292
  }
266
- if (msg.mode === "fork" && pendingForkRegistry) {
267
- pendingForkRegistry.recordFork(session.cwd, msg.sessionId);
293
+ // Fork preflight: silent-degrade when the source session has no on-disk
294
+ // JSONL yet (empty session, no persisted entries). `pi --fork <missing>`
295
+ // would crash silently and produce a 30s register-timeout; instead we
296
+ // spawn a fresh pi in the same cwd and surface `code: FORK_DEGRADED_TO_NEW`
297
+ // so the client can render a non-blocking toast. The parent's
298
+ // attachedProposal (if any) is inherited via `pendingAttachRegistry`
299
+ // since fork's own inheritance path doesn't run on this branch.
300
+ // See change: fix-fork-empty-session-silent-timeout.
301
+ if (msg.mode === "fork" && session.sessionFile && !existsSync(session.sessionFile)) {
302
+ // Inherit attachedProposal from parent so the new session still
303
+ // tracks the change the user was working on.
304
+ const pendingAttachRegistry = ctx.pendingAttachRegistry;
305
+ if (session.attachedProposal && pendingAttachRegistry) {
306
+ pendingAttachRegistry.enqueue(session.cwd, session.attachedProposal);
307
+ }
308
+ const degradeConfig = loadConfig();
309
+ // Fresh spawn: no sessionFile, no mode — just `pi --mode rpc`.
310
+ const degradeResult = await spawnPiSession(session.cwd, {
311
+ strategy: degradeConfig.spawnStrategy,
312
+ });
313
+ if (degradeResult.process && degradeResult.pid) {
314
+ headlessPidRegistry.register(
315
+ degradeResult.pid,
316
+ session.cwd,
317
+ degradeResult.process,
318
+ degradeResult.spawnToken,
319
+ keeperOptsFromSpawnResult(degradeResult),
320
+ );
321
+ }
322
+ if (msg.requestId && degradeResult.spawnToken && pendingClientCorrelations) {
323
+ pendingClientCorrelations.record(degradeResult.spawnToken, msg.requestId);
324
+ }
325
+ if (degradeResult.dashboardSpawned && degradeResult.success) {
326
+ pendingDashboardSpawns?.set(
327
+ session.cwd,
328
+ (pendingDashboardSpawns?.get(session.cwd) ?? 0) + 1,
329
+ );
330
+ }
331
+ sendTo(ws, {
332
+ type: "resume_result",
333
+ sessionId: msg.sessionId,
334
+ success: degradeResult.success,
335
+ message: degradeResult.success ? FORK_DEGRADED_TO_NEW_MESSAGE : degradeResult.message,
336
+ requestId: msg.requestId,
337
+ ...(degradeResult.success ? { code: FORK_DEGRADED_TO_NEW_CODE } : {}),
338
+ });
339
+ return;
268
340
  }
269
-
270
341
  // For fork-from-message: create a pruned session file first
271
342
  let forkSessionFile = session.sessionFile;
272
343
  if (msg.mode === "fork" && msg.entryId) {
273
344
  try {
274
345
  forkSessionFile = createBranchedSessionFile(session.sessionFile, msg.entryId);
275
346
  } catch (err: any) {
276
- sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: `Fork from entry failed: ${err.message}` });
347
+ sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: `Fork from entry failed: ${err.message}`, requestId: msg.requestId });
277
348
  return;
278
349
  }
279
350
  }
@@ -293,26 +364,45 @@ export async function handleResumeSession(
293
364
  mode: msg.mode,
294
365
  strategy: resumeConfig.spawnStrategy,
295
366
  });
367
+ // Record fork parent keyed by spawn token (was: keyed by cwd, racy on
368
+ // multi-fork-in-same-cwd). See change: spawn-correlation-token.
369
+ if (msg.mode === "fork" && pendingForkRegistry && result.spawnToken) {
370
+ pendingForkRegistry.recordFork(result.spawnToken, msg.sessionId);
371
+ }
372
+ // Record client-correlation so the eventual session_added carries
373
+ // spawnRequestId. See change: spawn-correlation-token.
374
+ if (msg.requestId && result.spawnToken && pendingClientCorrelations) {
375
+ pendingClientCorrelations.record(result.spawnToken, msg.requestId);
376
+ }
296
377
  if (result.dashboardSpawned && result.success) {
297
378
  pendingDashboardSpawns?.set(session.cwd, (pendingDashboardSpawns?.get(session.cwd) ?? 0) + 1);
298
379
  }
299
380
  if (result.process && result.pid) {
300
- headlessPidRegistry.register(result.pid, session.cwd, result.process);
381
+ headlessPidRegistry.register(
382
+ result.pid,
383
+ session.cwd,
384
+ result.process,
385
+ result.spawnToken,
386
+ keeperOptsFromSpawnResult(result),
387
+ );
301
388
  }
302
- sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: result.success, message: result.message });
389
+ sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: result.success, message: result.message, requestId: msg.requestId });
303
390
  }
304
391
 
305
392
  export async function handleSpawnSession(
306
393
  msg: Extract<BrowserToServerMessage, { type: "spawn_session" }>,
307
394
  ctx: BrowserHandlerContext,
308
395
  ): Promise<void> {
309
- const { ws, headlessPidRegistry, pendingDashboardSpawns, pendingAttachRegistry, sendTo } = ctx;
396
+ const { ws, headlessPidRegistry, pendingDashboardSpawns, pendingAttachRegistry, pendingClientCorrelations, sendTo } = ctx;
310
397
  const config = loadConfig();
311
398
  const strategy = config.spawnStrategy ?? "tmux";
312
399
 
313
400
  // Queue the optional attach intent BEFORE awaiting the spawn so a fast
314
401
  // bridge `session_register` cannot lose the intent. See change:
315
- // add-folder-task-checker-and-spawn-attach.
402
+ // add-folder-task-checker-and-spawn-attach. NOTE: at this point we don't
403
+ // yet have a spawnToken (spawn hasn't run); we enqueue by cwd-FIFO and
404
+ // re-record by token after spawnPiSession returns. See change:
405
+ // spawn-correlation-token.
316
406
  if (typeof msg.attachProposal === "string" && msg.attachProposal.length > 0) {
317
407
  pendingAttachRegistry?.enqueue(msg.cwd, msg.attachProposal);
318
408
  }
@@ -322,7 +412,7 @@ export async function handleSpawnSession(
322
412
  const preflight = preflightSpawn(msg.cwd, { resolver: preflightResolver });
323
413
  if (!preflight.ok) {
324
414
  const message = preflight.reasons.map((r) => r.message).join("; ");
325
- sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: false, message });
415
+ sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: false, message, requestId: msg.requestId });
326
416
  sendTo(ws, { type: "spawn_error", cwd: msg.cwd, strategy, message, code: "PREFLIGHT_FAILED", reasons: preflight.reasons });
327
417
  appendSpawnFailure({
328
418
  ts: new Date().toISOString(),
@@ -342,12 +432,30 @@ export async function handleSpawnSession(
342
432
  try {
343
433
  const spawnResult = await spawnPiSession(msg.cwd, { strategy });
344
434
  if (spawnResult.process && spawnResult.pid) {
345
- headlessPidRegistry.register(spawnResult.pid, msg.cwd, spawnResult.process);
435
+ headlessPidRegistry.register(
436
+ spawnResult.pid,
437
+ msg.cwd,
438
+ spawnResult.process,
439
+ spawnResult.spawnToken,
440
+ keeperOptsFromSpawnResult(spawnResult),
441
+ );
442
+ }
443
+ // Record client-correlation so the eventual session_added carries
444
+ // spawnRequestId. See change: spawn-correlation-token.
445
+ if (msg.requestId && spawnResult.spawnToken && pendingClientCorrelations) {
446
+ pendingClientCorrelations.record(spawnResult.spawnToken, msg.requestId);
346
447
  }
347
448
  if (spawnResult.dashboardSpawned && spawnResult.success) {
348
449
  pendingDashboardSpawns?.set(msg.cwd, (pendingDashboardSpawns?.get(msg.cwd) ?? 0) + 1);
349
450
  }
350
- sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: spawnResult.success, message: spawnResult.message });
451
+ sendTo(ws, {
452
+ type: "spawn_result",
453
+ cwd: msg.cwd,
454
+ success: spawnResult.success,
455
+ message: spawnResult.message,
456
+ requestId: msg.requestId,
457
+ ...(spawnResult.pid ? { pid: spawnResult.pid } : {}),
458
+ });
351
459
  if (!spawnResult.success) {
352
460
  sendTo(ws, {
353
461
  type: "spawn_error",
@@ -377,12 +485,13 @@ export async function handleSpawnSession(
377
485
  // on the next spawn without a server restart. See change: spawn-failure-diagnostics (fix W1).
378
486
  timeoutMs: config.spawnRegisterTimeoutMs,
379
487
  ws,
488
+ spawnToken: spawnResult.spawnToken,
380
489
  });
381
490
  }
382
491
  } catch (err) {
383
492
  const message = err instanceof Error ? err.message : String(err);
384
493
  const stderr = err instanceof Error && "stderr" in err ? String((err as { stderr: unknown }).stderr).slice(-2048) : undefined;
385
- sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: false, message });
494
+ sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: false, message, requestId: msg.requestId });
386
495
  sendTo(ws, { type: "spawn_error", cwd: msg.cwd, strategy, message, code: "SPAWN_ERRNO", stderr });
387
496
  appendSpawnFailure({
388
497
  ts: new Date().toISOString(),
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Filesystem helpers for locating an installed package's
3
+ * `CHANGELOG.md` and `package.json`, plus deriving a public GitHub
4
+ * URL from the `repository` field.
5
+ *
6
+ * Search order matches the ToolRegistry resolution chain for `pi`:
7
+ * 1. Managed install (`~/.pi-dashboard/node_modules/<pkg>/`)
8
+ * 2. Bare-import via `createRequire` (process resolves the package
9
+ * through its own node_modules — covers npm-global on Unix when
10
+ * the symlink lands inside this Node prefix, and dev-checkout
11
+ * paths during local builds).
12
+ *
13
+ * Both helpers return `null` rather than throwing on absence so route
14
+ * handlers can degrade to the empty-changelog response per spec
15
+ * `pi-changelog-display#Scenario: Package not installed returns empty`.
16
+ *
17
+ * See change: pi-update-whats-new-panel.
18
+ */
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import os from "node:os";
22
+ import { createRequire } from "node:module";
23
+
24
+ /** Default managed install root. Test seam: caller may override. */
25
+ function defaultManagedDir(): string {
26
+ return path.join(os.homedir(), ".pi-dashboard");
27
+ }
28
+
29
+ /**
30
+ * Locate a package's CHANGELOG.md on disk.
31
+ *
32
+ * Returns `{ changelogPath, packageDir }` so callers can read the
33
+ * adjacent `package.json` without re-resolving.
34
+ *
35
+ * Strategy:
36
+ * 1. `<managedDir>/node_modules/<pkg>/CHANGELOG.md` (Electron + CLI bootstrap).
37
+ * 2. `createRequire(import.meta.url).resolve("<pkg>/package.json")` then
38
+ * look for `CHANGELOG.md` next to it (covers bare-import / npm-global
39
+ * via the standard Node resolution mechanism).
40
+ *
41
+ * Returns `null` when no readable CHANGELOG can be located.
42
+ */
43
+ export interface ChangelogLocation {
44
+ changelogPath: string;
45
+ packageDir: string;
46
+ }
47
+
48
+ export interface FindOptions {
49
+ /** Override managed dir for tests. */
50
+ managedDir?: string;
51
+ /**
52
+ * Override the require-resolver used for bare-import lookup. Tests
53
+ * pass a stub that throws to force the managed path; production
54
+ * uses `createRequire(import.meta.url).resolve`.
55
+ */
56
+ resolveBareImport?: (pkgJsonSpec: string) => string;
57
+ }
58
+
59
+ export function findChangelogPath(
60
+ pkg: string,
61
+ opts: FindOptions = {},
62
+ ): ChangelogLocation | null {
63
+ // Strategy 1: managed install.
64
+ const managedDir = opts.managedDir ?? defaultManagedDir();
65
+ const managedPkg = path.join(managedDir, "node_modules", pkg);
66
+ const managedCl = path.join(managedPkg, "CHANGELOG.md");
67
+ if (fs.existsSync(managedCl)) {
68
+ return { changelogPath: managedCl, packageDir: managedPkg };
69
+ }
70
+
71
+ // Strategy 2: bare-import via require.resolve.
72
+ const resolver =
73
+ opts.resolveBareImport ??
74
+ ((spec: string) => createRequire(import.meta.url).resolve(spec));
75
+ try {
76
+ const pkgJsonPath = resolver(`${pkg}/package.json`);
77
+ const dir = path.dirname(pkgJsonPath);
78
+ const cl = path.join(dir, "CHANGELOG.md");
79
+ if (fs.existsSync(cl)) {
80
+ return { changelogPath: cl, packageDir: dir };
81
+ }
82
+ } catch {
83
+ /* not resolvable — fall through */
84
+ }
85
+
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * Read and parse `package.json` next to a previously-located
91
+ * CHANGELOG. Returns the parsed object or `null` on read/parse error.
92
+ */
93
+ export function readPackageJson(packageDir: string): Record<string, unknown> | null {
94
+ const p = path.join(packageDir, "package.json");
95
+ try {
96
+ const raw = fs.readFileSync(p, "utf8");
97
+ const parsed = JSON.parse(raw);
98
+ if (parsed && typeof parsed === "object") {
99
+ return parsed as Record<string, unknown>;
100
+ }
101
+ return null;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Derive a public GitHub URL pointing at the CHANGELOG given a
109
+ * `package.json#repository` field. Returns `null` when the field is
110
+ * missing, not GitHub-hosted, or unparseable.
111
+ *
112
+ * Accepted shapes (per spec `pi-changelog-display#Requirement: Changelog URL derivation`):
113
+ * - `"github:org/repo"` shorthand
114
+ * - `"https://github.com/org/repo.git"` URL string
115
+ * - `{ "type": "git", "url": "git+https://github.com/org/repo.git" }`
116
+ * - same object form with optional `"directory": "packages/foo"` (monorepo)
117
+ */
118
+ export function deriveChangelogUrl(repository: unknown): string | null {
119
+ if (!repository) return null;
120
+
121
+ let urlStr: string | null = null;
122
+ let directory: string | null = null;
123
+
124
+ if (typeof repository === "string") {
125
+ urlStr = repository;
126
+ } else if (typeof repository === "object" && repository !== null) {
127
+ const rec = repository as Record<string, unknown>;
128
+ if (typeof rec.url === "string") urlStr = rec.url;
129
+ if (typeof rec.directory === "string" && rec.directory.length > 0) {
130
+ directory = rec.directory.replace(/^\/+|\/+$/g, "");
131
+ }
132
+ }
133
+ if (!urlStr) return null;
134
+
135
+ const m = parseGitHubUrl(urlStr);
136
+ if (!m) return null;
137
+
138
+ const subPath = directory ? `${directory}/` : "";
139
+ return `https://github.com/${m.org}/${m.repo}/blob/main/${subPath}CHANGELOG.md`;
140
+ }
141
+
142
+ /**
143
+ * Parse the various GitHub URL forms used in `package.json#repository`
144
+ * into `{ org, repo }`. Returns null for non-GitHub or unparseable
145
+ * inputs.
146
+ */
147
+ function parseGitHubUrl(s: string): { org: string; repo: string } | null {
148
+ const trimmed = s.trim();
149
+
150
+ // github:org/repo shorthand
151
+ let m = trimmed.match(/^github:([^/]+)\/([^/#]+)/i);
152
+ if (m) return { org: m[1], repo: stripGitSuffix(m[2]) };
153
+
154
+ // git+https://github.com/org/repo.git
155
+ // https://github.com/org/repo
156
+ // git://github.com/org/repo.git
157
+ // ssh://git@github.com/org/repo.git
158
+ // git@github.com:org/repo.git
159
+ m = trimmed.match(/(?:^|[/@:])github\.com[/:]([^/]+)\/([^/#?]+)/i);
160
+ if (m) return { org: m[1], repo: stripGitSuffix(m[2]) };
161
+
162
+ return null;
163
+ }
164
+
165
+ function stripGitSuffix(repo: string): string {
166
+ return repo.replace(/\.git$/i, "");
167
+ }