@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
@@ -45,6 +45,37 @@ export interface DirectoryAddedResult {
45
45
  openspecData: OpenSpecData;
46
46
  }
47
47
 
48
+ /**
49
+ * `true` if `OpenSpecData` represents "no useful data yet" — either
50
+ * absent, or `{ initialized: false, changes: [] }` (cold cache).
51
+ * Used by broadcast call-sites to decide whether a transition from
52
+ * empty→populated warrants firing `openspec_update`. Pulled into
53
+ * shared scope so `server.ts` (post-install repair) and
54
+ * `session-bootstrap.ts` (initial poll) both use the same predicate.
55
+ *
56
+ * See change: fix-cold-boot-openspec-protocol.
57
+ */
58
+ export function isOpenSpecDataEmpty(d: OpenSpecData | undefined): boolean {
59
+ if (!d) return true;
60
+ return !d.initialized && (!d.changes || d.changes.length === 0);
61
+ }
62
+
63
+ /**
64
+ * Synchronous, spawn-free probe for `<cwd>/openspec/changes`. Returns
65
+ * `true` iff the path exists and is a directory. Used by the WS
66
+ * on-connect snapshot to disambiguate "no openspec here" from
67
+ * "openspec here, polling pending". ~10 μs per call.
68
+ *
69
+ * See change: fix-cold-boot-openspec-protocol.
70
+ */
71
+ export function hasOpenSpecDir(cwd: string): boolean {
72
+ try {
73
+ return fs.statSync(path.join(cwd, "openspec", "changes")).isDirectory();
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
48
79
  export interface DirectoryService {
49
80
  knownDirectories(): string[];
50
81
  discoverSessions(cwd: string): DiscoveredSession[];
@@ -11,11 +11,12 @@ import type { PendingForkRegistry } from "./pending-fork-registry.js";
11
11
  import type { DirectoryService } from "./directory-service.js";
12
12
  import { extractSessionUpdates, isActivityEvent, isUnreadTrigger } from "./event-status-extraction.js";
13
13
  import type { ViewedSessionTracker } from "./viewed-session-tracker.js";
14
+ import { setCatalogueForSession } from "./provider-catalogue-cache.js";
14
15
  import { spawnPiSession } from "./process-manager.js";
15
16
  import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
16
17
  import { writeSessionMeta } from "@blackbelt-technology/pi-dashboard-shared/session-meta.js";
17
18
  import type { DashboardSession } from "@blackbelt-technology/pi-dashboard-shared/types.js";
18
- import { detectOpenSpecActivity } from "@blackbelt-technology/pi-dashboard-shared/openspec-activity-detector.js";
19
+ import { detectOpenSpecActivity, isValidOpenSpecChangeSlug } from "@blackbelt-technology/pi-dashboard-shared/openspec-activity-detector.js";
19
20
  import { extractTurnStats } from "@blackbelt-technology/pi-dashboard-shared/stats-extractor.js";
20
21
  import { attachRenameTarget, isNameAutoSetFromAttachment } from "./proposal-attach-naming.js";
21
22
 
@@ -42,6 +43,14 @@ export interface EventWiringDeps {
42
43
  * See change: session-card-unread-stripes.
43
44
  */
44
45
  viewedSessionTracker?: ViewedSessionTracker;
46
+ /**
47
+ * Optional client-correlation registry. When provided, the wiring
48
+ * consumes the requestId for the resolved spawnToken after a successful
49
+ * three-tier link and surfaces it on `session_added` as `spawnRequestId`,
50
+ * letting the client auto-select / dismiss its placeholder by exact
51
+ * correlation. See change: spawn-correlation-token.
52
+ */
53
+ pendingClientCorrelations?: import("./pending-client-correlations.js").PendingClientCorrelations;
45
54
  }
46
55
 
47
56
  /**
@@ -61,6 +70,7 @@ export function wireEvents(deps: EventWiringDeps): void {
61
70
  pendingDashboardSpawns,
62
71
  pendingAttachRegistry,
63
72
  viewedSessionTracker,
73
+ pendingClientCorrelations,
64
74
  } = deps;
65
75
 
66
76
  // Broadcast placeholder session to browsers when auto-created from early events
@@ -217,10 +227,19 @@ export function wireEvents(deps: EventWiringDeps): void {
217
227
  // Server-side OpenSpec activity detection from forwarded events
218
228
  // Skip during replay — replayed events from a forked session would set stale phase/change
219
229
  if (msg.event.eventType === "tool_execution_start" && !replayingSessions.has(sessionId)) {
220
- const detected = detectOpenSpecActivity(
230
+ const detectedRaw = detectOpenSpecActivity(
221
231
  msg.event.data.toolName as string,
222
232
  msg.event.data.args as Record<string, unknown> | undefined,
223
233
  );
234
+ // Defense-in-depth (see change: fix-uuid-rename-bug). Even if a future
235
+ // detector regression returns a junk-shaped `changeName` (UUID, mixed
236
+ // case, etc.), refuse to stamp openspecChange / attachedProposal /
237
+ // name. Manual attach paths (browser handler, REST) bypass this and
238
+ // accept any name from a server-curated list.
239
+ const detected =
240
+ detectedRaw && (!detectedRaw.changeName || isValidOpenSpecChangeSlug(detectedRaw.changeName))
241
+ ? detectedRaw
242
+ : null;
224
243
  if (detected) {
225
244
  const session = sessionManager.get(sessionId);
226
245
  const activityUpdates: Partial<DashboardSession> = {};
@@ -442,7 +461,34 @@ export function wireEvents(deps: EventWiringDeps): void {
442
461
  }
443
462
  }
444
463
 
445
- browserGateway.headlessPidRegistry.linkSession(sessionId, msg.cwd);
464
+ // Three-tier link: token → pid → cwd-FIFO. Each tier is independently
465
+ // correct; `linkByToken` is the strong identity introduced by
466
+ // `spawn-correlation-token`. cwd-FIFO is the legacy fallback for old
467
+ // bridges that send neither token nor pid (and is logged so we can see
468
+ // when it actually triggers).
469
+ let linked = false;
470
+ if (msg.spawnToken) {
471
+ linked = browserGateway.headlessPidRegistry.linkByToken(msg.spawnToken, sessionId, msg.pid);
472
+ }
473
+ if (!linked && msg.pid !== undefined) {
474
+ linked = browserGateway.headlessPidRegistry.linkByPid(sessionId, msg.pid);
475
+ }
476
+ if (!linked) {
477
+ if (msg.spawnToken || msg.pid !== undefined) {
478
+ console.error(
479
+ `[event-wiring] cwd-FIFO fallback for session ${sessionId} — token=${msg.spawnToken ?? ""} pid=${msg.pid ?? ""} cwd=${msg.cwd}`,
480
+ );
481
+ }
482
+ browserGateway.headlessPidRegistry.linkSession(sessionId, msg.cwd);
483
+ }
484
+
485
+ // Resolve the originating browser `requestId` (when known) so the
486
+ // upcoming session_added broadcast can carry spawnRequestId and the
487
+ // client can auto-select / dismiss its placeholder.
488
+ // See change: spawn-correlation-token.
489
+ const spawnRequestId = (msg.spawnToken && pendingClientCorrelations)
490
+ ? pendingClientCorrelations.consume(msg.spawnToken)
491
+ : undefined;
446
492
 
447
493
  const isNewSession = !knownSessionIds.has(sessionId);
448
494
  knownSessionIds.add(sessionId);
@@ -459,7 +505,11 @@ export function wireEvents(deps: EventWiringDeps): void {
459
505
  }
460
506
  }
461
507
 
462
- const forkParent = pendingForkRegistry.consumeFork(msg.cwd);
508
+ // Fork-parent lookup is keyed by spawn token (was: cwd, racy on
509
+ // multi-fork-in-same-cwd). See change: spawn-correlation-token.
510
+ const forkParent = msg.spawnToken
511
+ ? pendingForkRegistry.consumeFork(msg.spawnToken)
512
+ : undefined;
463
513
  sessionOrderManager.insert(msg.cwd, sessionId);
464
514
 
465
515
  if (forkParent) {
@@ -479,7 +529,7 @@ export function wireEvents(deps: EventWiringDeps): void {
479
529
 
480
530
  const updatedSession = sessionManager.get(sessionId);
481
531
  if (updatedSession) {
482
- browserGateway.broadcastSessionAdded(updatedSession);
532
+ browserGateway.broadcastSessionAdded(updatedSession, spawnRequestId ? { spawnRequestId } : undefined);
483
533
  }
484
534
 
485
535
  const isNewCwd = !sessionManager.listAll().some(
@@ -610,6 +660,28 @@ export function wireEvents(deps: EventWiringDeps): void {
610
660
  } as any);
611
661
  }
612
662
 
663
+ if (msg.type === "providers_list") {
664
+ // Cache the bridge-pushed catalogue. Browsers don't subscribe to it
665
+ // directly; they read via GET /api/provider-auth/status.
666
+ // Broadcast `models_refreshed` ONLY when the catalogue contents
667
+ // actually changed. Routine state-syncs (every fork/resume/reconnect/
668
+ // subscribe) re-send identical content; broadcasting unconditionally
669
+ // wipes every browser's modelsMap and — because App.tsx's
670
+ // auto-subscribe effect skips re-requesting models for any session
671
+ // that's already in `subscribedRef`, leaves previously-visited
672
+ // sessions with an empty model selector until reconnect.
673
+ //
674
+ // The catalogue cache is now a pure read consumer for the Settings
675
+ // UI (`GET /api/provider-auth/status`). No broadcast: the model-
676
+ // selector dropdown lives on the independent `models_list` channel
677
+ // which is per-session-broadcast already; per-session updates are
678
+ // self-healing without a global wipe.
679
+ // See changes: replace-hardcoded-provider-lists,
680
+ // fix-providers-list-spurious-models-refreshed,
681
+ // simplify-model-selection-channels.
682
+ setCatalogueForSession(sessionId, msg.providers);
683
+ }
684
+
613
685
  if (msg.type === "roles_list") {
614
686
  browserGateway.broadcastToAll({
615
687
  type: "roles_list",
@@ -678,6 +750,34 @@ export function wireEvents(deps: EventWiringDeps): void {
678
750
  } as any);
679
751
  }
680
752
 
753
+ // ── Asset register: per-session image asset cache + broadcast ──
754
+ // See change: chat-markdown-local-images-and-math.
755
+ if (msg.type === "asset_register") {
756
+ const { hash, mimeType, data } = msg;
757
+ // Reject malformed messages defensively. The bridge always populates
758
+ // these fields; this guard is purely defense-in-depth so a
759
+ // misbehaving extension cannot inject placeholder asset entries.
760
+ if (typeof hash === "string" && hash.length > 0 &&
761
+ typeof mimeType === "string" && mimeType.length > 0 &&
762
+ typeof data === "string" && data.length > 0) {
763
+ const session = sessionManager.get(sessionId);
764
+ if (session) {
765
+ const next = { ...(session.assets ?? {}) };
766
+ next[hash] = { data, mimeType };
767
+ sessionManager.update(sessionId, { assets: next });
768
+ }
769
+ // Broadcast verbatim regardless of whether the session is known —
770
+ // mirrors the Phase-1 / Phase-2 contract for extension UI messages.
771
+ browserGateway.sendToSubscribers(sessionId, {
772
+ type: "asset_register",
773
+ sessionId,
774
+ hash,
775
+ mimeType,
776
+ data,
777
+ } as any);
778
+ }
779
+ }
780
+
681
781
  // ── Extension UI System (Phase 2): live decorator cache + broadcast ──
682
782
  // See change: add-extension-ui-decorations.
683
783
  if (msg.type === "ext_ui_decorator") {
@@ -23,6 +23,13 @@ export interface HeadlessEntry {
23
23
  process: ChildProcess;
24
24
  sessionId?: string;
25
25
  spawnedAt: number;
26
+ /**
27
+ * Server-minted spawn correlation token. Stored at `register` time.
28
+ * Used by `linkByToken` (tier 1) to resolve sessionId↔pid mapping
29
+ * deterministically, replacing the racy cwd-FIFO `linkSession`.
30
+ * See change: spawn-correlation-token.
31
+ */
32
+ spawnToken?: string;
26
33
  }
27
34
 
28
35
  /** Serialized format for disk persistence */
@@ -37,9 +44,31 @@ interface PidFileData {
37
44
  }
38
45
 
39
46
  export interface HeadlessPidRegistry {
40
- /** Register a newly spawned headless process. */
41
- register(pid: number, cwd: string, proc: ChildProcess): void;
42
- /** Link a session ID to a tracked PID by matching cwd (FIFO). */
47
+ /**
48
+ * Register a newly spawned headless process. The optional `spawnToken`
49
+ * is the server-minted UUID injected into the spawned process's env;
50
+ * storing it lets `linkByToken` resolve identity precisely later.
51
+ * See change: spawn-correlation-token.
52
+ */
53
+ register(pid: number, cwd: string, proc: ChildProcess, spawnToken?: string): void;
54
+ /**
55
+ * Tier 1 link: find entry by `spawnToken`, set its `sessionId`. Returns
56
+ * `true` on match. The strongest identity — used when the bridge sent
57
+ * `session_register.spawnToken`. See change: spawn-correlation-token.
58
+ */
59
+ linkByToken(spawnToken: string, sessionId: string, pid?: number): boolean;
60
+ /**
61
+ * Tier 2 link: find entry by `pid` (where `!sessionId`), set its
62
+ * `sessionId`. Returns `true` on match. Used when the bridge sent
63
+ * `session_register.pid` but no token. See change: spawn-correlation-token.
64
+ */
65
+ linkByPid(sessionId: string, pid: number): boolean;
66
+ /**
67
+ * Tier 3 (legacy) link: find first entry by `cwd` where `!sessionId`,
68
+ * set its `sessionId`. Returns `true` on match. Cwd-FIFO fallback for
69
+ * old bridges that send neither token nor pid. Race-prone for
70
+ * concurrent same-cwd spawns; tiers 1–2 should pre-empt this.
71
+ */
43
72
  linkSession(sessionId: string, cwd: string): boolean;
44
73
  /** Get the PID linked to a session ID. */
45
74
  getPid(sessionId: string): number | undefined;
@@ -84,8 +113,8 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
84
113
  }
85
114
 
86
115
  return {
87
- register(pid: number, cwd: string, proc: ChildProcess) {
88
- entries.set(pid, { pid, cwd, process: proc, spawnedAt: Date.now() });
116
+ register(pid: number, cwd: string, proc: ChildProcess, spawnToken?: string) {
117
+ entries.set(pid, { pid, cwd, process: proc, spawnedAt: Date.now(), spawnToken });
89
118
  proc.on("exit", () => {
90
119
  entries.delete(pid);
91
120
  persist();
@@ -93,6 +122,26 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
93
122
  persist();
94
123
  },
95
124
 
125
+ linkByToken(spawnToken: string, sessionId: string, _pid?: number): boolean {
126
+ if (!spawnToken) return false;
127
+ for (const entry of entries.values()) {
128
+ if (entry.spawnToken === spawnToken && !entry.sessionId) {
129
+ entry.sessionId = sessionId;
130
+ return true;
131
+ }
132
+ }
133
+ return false;
134
+ },
135
+
136
+ linkByPid(sessionId: string, pid: number): boolean {
137
+ const entry = entries.get(pid);
138
+ if (entry && !entry.sessionId) {
139
+ entry.sessionId = sessionId;
140
+ return true;
141
+ }
142
+ return false;
143
+ },
144
+
96
145
  linkSession(sessionId: string, cwd: string): boolean {
97
146
  for (const entry of entries.values()) {
98
147
  if (entry.cwd === cwd && !entry.sessionId) {
@@ -0,0 +1,124 @@
1
+ /** Metadata written alongside the lock file. JSON-serialized. */
2
+ export interface LockMetadata {
3
+ pid: number;
4
+ ppid: number;
5
+ httpPort: number;
6
+ piPort: number;
7
+ startedAt: number;
8
+ /** Stable per-instance identifier. Verified against /api/health to detect
9
+ * "port in use by unrelated dashboard or stale process with same pid." */
10
+ identity: string;
11
+ version: string;
12
+ url: string;
13
+ hostname: string;
14
+ }
15
+ /** Result of `acquireOrAttach`. Callers branch on `mode`. */
16
+ export type LockAcquireResult = {
17
+ mode: "acquired";
18
+ meta: LockMetadata;
19
+ /** Release the lock + remove the metadata sidecar. Idempotent. */
20
+ release: () => Promise<void>;
21
+ } | {
22
+ mode: "attach";
23
+ meta: LockMetadata;
24
+ };
25
+ /** Thrown when port is held by an unrelated process. Non-fatal to this
26
+ * module; caller decides (exit with message / retry / override). */
27
+ export declare class InstanceLockMismatchError extends Error {
28
+ readonly meta: LockMetadata;
29
+ readonly observedIdentity: string | null;
30
+ readonly code = "E_INSTANCE_MISMATCH";
31
+ constructor(meta: LockMetadata, observedIdentity: string | null);
32
+ }
33
+ export interface AcquireConfig {
34
+ httpPort: number;
35
+ piPort: number;
36
+ version: string;
37
+ identity?: string;
38
+ /** Injection hooks for tests. Production callers pass no options. */
39
+ hooks?: AcquireHooks;
40
+ }
41
+ export interface AcquireHooks {
42
+ now?: () => number;
43
+ hostname?: () => string;
44
+ lockPath?: string;
45
+ metaPath?: string;
46
+ probeHealth?: (port: number) => Promise<{
47
+ running: boolean;
48
+ pid?: number;
49
+ identity?: string;
50
+ } | null>;
51
+ isProcessAlive?: (pid: number) => boolean;
52
+ /** Stale threshold forwarded to `proper-lockfile`. Default 10s. */
53
+ staleMs?: number;
54
+ }
55
+ /**
56
+ * Canonical HOME directory.
57
+ *
58
+ * Uses `os.userInfo().homedir` in preference to `os.homedir()` because on
59
+ * POSIX the latter honors the `$HOME` environment variable (Node docs say:
60
+ * "On POSIX, it uses the `$HOME` environment variable if defined"), which
61
+ * the design (§4) explicitly prohibits — a GUI-launched process and a
62
+ * shell-launched process would otherwise disagree on "where HOME is".
63
+ * `userInfo().homedir` consults `getpwuid(3)` on POSIX, immune to `$HOME`.
64
+ *
65
+ * On Windows, both APIs ultimately use `USERPROFILE`, so the Git Bash
66
+ * drift case (`$HOME=/c/Users/R` vs `USERPROFILE=C:\Users\R`) is handled
67
+ * either way; keeping `userInfo().homedir` first is still correct.
68
+ *
69
+ * Result is then passed through `fs.realpathSync` to collapse symlinks,
70
+ * FileVault migrations, and other canonicalization drift. Tolerant: falls
71
+ * back to the raw path if realpath fails.
72
+ */
73
+ export declare function canonicalHomedir(): string;
74
+ /**
75
+ * Lock file path. This is what `proper-lockfile` locks.
76
+ */
77
+ export declare function getLockPath(homedir?: string): string;
78
+ /**
79
+ * Metadata sidecar path (`<lockPath>.meta.json`).
80
+ */
81
+ export declare function getMetaPath(lockPath?: string): string;
82
+ /**
83
+ * Atomically write the metadata sidecar via tmp + rename.
84
+ * Never leaves a partial file visible.
85
+ */
86
+ export declare function writeMetadataAtomic(meta: LockMetadata, metaPath?: string): void;
87
+ /**
88
+ * Read the metadata sidecar. Returns null on any failure (missing, corrupt,
89
+ * permission-denied). Callers MUST treat null as "assume stale."
90
+ */
91
+ export declare function readMetadata(metaPath?: string): LockMetadata | null;
92
+ /**
93
+ * Remove the metadata sidecar. Silent on any error (missing is fine).
94
+ */
95
+ export declare function removeMetadata(metaPath?: string): void;
96
+ /**
97
+ * Determine if the recorded lock holder is a responsive, identity-matching
98
+ * dashboard. Returns:
99
+ * - `"alive-match"`: attach to it
100
+ * - `"alive-mismatch"`: someone else is on that port
101
+ * - `"dead"`: treat as stale, proceed to acquire
102
+ */
103
+ export declare function isLockHolderResponsive(meta: LockMetadata, hooks?: Pick<AcquireHooks, "probeHealth" | "isProcessAlive">): Promise<"alive-match" | "alive-mismatch" | "dead">;
104
+ /**
105
+ * Acquire the per-HOME lock, or fall back to attach semantics if a live
106
+ * dashboard already holds it.
107
+ *
108
+ * Flow:
109
+ * 1. Ensure `~/.pi/dashboard/` exists (proper-lockfile requires parent).
110
+ * 2. `proper-lockfile.lock(path, { stale, retries: 0 })`
111
+ * ↪ on success: write metadata, return { mode: "acquired", release }
112
+ * ↪ on ELOCKED: read metadata, check liveness
113
+ * - dead: steal via `proper-lockfile.lock({ realpath:false, stale: 0 })`
114
+ * (Note: proper-lockfile already does stale-stealing when
115
+ * `stale` is configured — we just retry once.)
116
+ * - alive-match: return { mode: "attach", meta }
117
+ * - alive-mismatch: throw InstanceLockMismatchError
118
+ */
119
+ export declare function acquireOrAttach(config: AcquireConfig): Promise<LockAcquireResult>;
120
+ /**
121
+ * True when the user has opted out of the per-HOME lock. Caller should
122
+ * log a warning and skip acquireOrAttach when set.
123
+ */
124
+ export declare function isLockDisabled(env?: NodeJS.ProcessEnv): boolean;