@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
@@ -18,12 +18,13 @@ import { createPreferencesStore, type PreferencesStore } from "./preferences-sto
18
18
  import { createMetaPersistence, type MetaPersistence } from "./meta-persistence.js";
19
19
  import { createSessionOrderManager, type SessionOrderManager } from "./session-order-manager.js";
20
20
  import { createPendingForkRegistry, type PendingForkRegistry } from "./pending-fork-registry.js";
21
+ import { createPendingClientCorrelations } from "./pending-client-correlations.js";
21
22
  import { createPendingAttachRegistry } from "./pending-attach-registry.js";
22
23
  import { createPendingResumeIntentRegistry } from "./pending-resume-intent-registry.js";
23
24
  import { applyReattachPolicy } from "./reattach-placement.js";
24
25
 
25
26
  // pending-load-manager removed — server loads sessions directly via DirectoryService
26
- import { createDirectoryService, type DirectoryService } from "./directory-service.js";
27
+ import { createDirectoryService, isOpenSpecDataEmpty, type DirectoryService } from "./directory-service.js";
27
28
  import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
28
29
  import { createTerminalGateway, type TerminalGateway } from "./terminal-gateway.js";
29
30
  import { writePid, removePid } from "./server-pid.js";
@@ -45,10 +46,12 @@ import { registerGitRoutes } from "./routes/git-routes.js";
45
46
  import { registerFileRoutes } from "./routes/file-routes.js";
46
47
  import { registerOpenSpecRoutes } from "./routes/openspec-routes.js";
47
48
  import { registerSystemRoutes } from "./routes/system-routes.js";
49
+ import { registerDoctorRoutes } from "./routes/doctor-routes.js";
48
50
  import { registerProviderAuthRoutes } from "./routes/provider-auth-routes.js";
49
51
  import { registerPackageRoutes } from "./routes/package-routes.js";
50
52
  import { registerRecommendedRoutes, invalidateRecommendedCache } from "./routes/recommended-routes.js";
51
53
  import { registerPiCoreRoutes } from "./routes/pi-core-routes.js";
54
+ import { registerPiChangelogRoutes } from "./routes/pi-changelog-routes.js";
52
55
  import { PiCoreChecker } from "./pi-core-checker.js";
53
56
  import { PiCoreUpdater } from "./pi-core-updater.js";
54
57
  import { registerToolRoutes } from "./routes/tool-routes.js";
@@ -65,6 +68,7 @@ import { createEditorPidRegistry } from "./editor-pid-registry.js";
65
68
  import { registerEditorRoutes } from "./routes/editor-routes.js";
66
69
  import { registerKnownServersRoutes } from "./routes/known-servers-routes.js";
67
70
  import { registerPluginConfigRoutes } from "./routes/plugin-config-routes.js";
71
+ import { writeConfigPartial } from "./config-api.js";
68
72
  import { loadServerEntries, discoverPlugins, getPluginStatusStore } from "@blackbelt-technology/dashboard-plugin-runtime/server";
69
73
  import { createServerPluginContext } from "@blackbelt-technology/dashboard-plugin-runtime/server";
70
74
  import { getPluginConfig as getPluginConfigFromFile } from "@blackbelt-technology/pi-dashboard-shared/config.js";
@@ -143,10 +147,6 @@ export interface PostInstallRepairDeps {
143
147
  browserGateway: { broadcastToAll(msg: ServerToBrowserMessage): void };
144
148
  }
145
149
 
146
- function isOpenSpecDataEmpty(d: OpenSpecData | undefined): boolean {
147
- if (!d) return true;
148
- return !d.initialized && (!d.changes || d.changes.length === 0);
149
- }
150
150
 
151
151
  /**
152
152
  * Centralized post-install repair work fired on every `installing → ready`
@@ -272,6 +272,10 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
272
272
  const metaPersistence = createMetaPersistence();
273
273
  const sessionOrderManager = createSessionOrderManager(preferencesStore);
274
274
  const pendingForkRegistry = createPendingForkRegistry();
275
+ // Maps spawnToken → originating browser requestId. Surfaced as
276
+ // session_added.spawnRequestId so the client can auto-select / dismiss
277
+ // its placeholder by exact correlation. See change: spawn-correlation-token.
278
+ const pendingClientCorrelations = createPendingClientCorrelations();
275
279
 
276
280
  // Restore sessions from per-session .meta.json files (scans ~/.pi/agent/sessions/)
277
281
  const scanResult = scanAllSessions();
@@ -475,7 +479,11 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
475
479
  knownSessionIds.add(s.id);
476
480
  }
477
481
 
478
- const directoryService = createDirectoryService(preferencesStore, sessionManager, config.openspec);
482
+ const directoryService = createDirectoryService(
483
+ preferencesStore,
484
+ sessionManager,
485
+ config.openspec,
486
+ );
479
487
 
480
488
  // mDNS peer discovery state
481
489
  let mdnsBrowser: DashboardBrowser | null = null;
@@ -524,7 +532,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
524
532
  },
525
533
  });
526
534
 
527
- const browserGateway = createBrowserGateway(sessionManager, eventStore, piGateway, undefined, pendingForkRegistry, sessionOrderManager, preferencesStore, directoryService, terminalManager, pendingDashboardSpawns, config.maxWsBufferBytes, pendingAttachRegistry, pendingResumeIntents);
535
+ const browserGateway = createBrowserGateway(sessionManager, eventStore, piGateway, undefined, pendingForkRegistry, sessionOrderManager, preferencesStore, directoryService, terminalManager, pendingDashboardSpawns, config.maxWsBufferBytes, pendingAttachRegistry, pendingResumeIntents, pendingClientCorrelations);
528
536
 
529
537
  // Resolve package version once at startup
530
538
  const __require = createRequire(import.meta.url);
@@ -560,10 +568,13 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
560
568
  pendingDashboardSpawns,
561
569
  pendingAttachRegistry,
562
570
  viewedSessionTracker: browserGateway.viewedSessionTracker,
571
+ pendingClientCorrelations,
563
572
  });
564
573
 
565
574
  // Auto-shutdown idle timer
566
- const idleTimer = createIdleTimer(config, piGateway);
575
+ // Active terminals keep the server alive even when no pi sessions are
576
+ // attached. See change: fix-terminal-half-height-dual-mount.
577
+ const idleTimer = createIdleTimer(config, piGateway, () => terminalManager.list().length > 0);
567
578
 
568
579
  const fastify = Fastify({
569
580
  logger: false,
@@ -689,6 +700,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
689
700
  bootstrapState,
690
701
  bootstrapQueue,
691
702
  pendingResumeIntents,
703
+ pendingAttachRegistry,
692
704
  });
693
705
 
694
706
  // Register route modules
@@ -709,7 +721,9 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
709
721
  if (data) browserGateway.broadcastToAll({ type: "openspec_update", cwd, data });
710
722
  },
711
723
  });
712
- registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion, directoryService, piGateway });
724
+ registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion, directoryService, piGateway, bootstrapState });
725
+ // GET /api/doctor — see change: doctor-rich-output (task 4.2). Auth-gated identically to /api/config.
726
+ registerDoctorRoutes(fastify);
713
727
  registerToolRoutes(fastify, { registry: getDefaultRegistry(), networkGuard });
714
728
  registerJjRoutes(fastify, { browserGateway, pendingAttachRegistry, networkGuard });
715
729
 
@@ -720,11 +734,11 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
720
734
  bootstrapState,
721
735
  networkGuard,
722
736
  triggerUpgradePi: async () => {
723
- const packages = ["@mariozechner/pi-coding-agent"];
737
+ const packages = ["@earendil-works/pi-coding-agent"];
724
738
  bootstrapState.setLastInstallPackages(packages);
725
739
  bootstrapState.set({
726
740
  status: "installing",
727
- progress: { step: "@mariozechner/pi-coding-agent", output: "starting upgrade…" },
741
+ progress: { step: "@earendil-works/pi-coding-agent", output: "starting upgrade…" },
728
742
  error: undefined,
729
743
  });
730
744
  try {
@@ -778,7 +792,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
778
792
  const prev = bootstrapState.getLastInstallPackages();
779
793
  const packages = prev.length > 0
780
794
  ? prev
781
- : ["@mariozechner/pi-coding-agent", "@fission-ai/openspec", "tsx"];
795
+ : ["@earendil-works/pi-coding-agent", "@fission-ai/openspec", "tsx"];
782
796
  bootstrapState.set({
783
797
  status: "installing",
784
798
  progress: { step: "retry", output: `restarting install (${packages.length} pkg${packages.length === 1 ? "" : "s"})…` },
@@ -899,6 +913,8 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
899
913
  message: event.message,
900
914
  });
901
915
  });
916
+ registerPiChangelogRoutes(fastify, { bootstrapState });
917
+
902
918
  registerPiCoreRoutes(fastify, {
903
919
  piCoreChecker,
904
920
  piCoreUpdater,
@@ -1076,6 +1092,63 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
1076
1092
 
1077
1093
  piGateway.start(config.piPort);
1078
1094
 
1095
+ // Load plugin server entries BEFORE fastify.listen() so plugins can
1096
+ // register routes. Fastify rejects route registration after listen().
1097
+ // Failure-isolated per-plugin via loader; awaited so all routes are
1098
+ // mounted before requests can arrive.
1099
+ try {
1100
+ await loadServerEntries({
1101
+ isEnabled: (pluginId) => {
1102
+ const cfg = loadConfig();
1103
+ const pluginCfg = getPluginConfigFromFile(cfg, pluginId) as Record<string, unknown>;
1104
+ return pluginCfg.enabled !== false;
1105
+ },
1106
+ createContext: (plugin) => createServerPluginContext(
1107
+ {
1108
+ fastify,
1109
+ sessionManager: {
1110
+ listActive: () => sessionManager.listActive(),
1111
+ listAll: () => sessionManager.listAll(),
1112
+ getSession: (id: string) => sessionManager.get(id),
1113
+ },
1114
+ eventStore: {
1115
+ getEvents: (sessionId) => eventStore.getEvents(sessionId, 0),
1116
+ getLatestEvent: (sessionId) => {
1117
+ const events = eventStore.getEvents(sessionId, 0);
1118
+ return events.length > 0 ? events[events.length - 1] : undefined;
1119
+ },
1120
+ },
1121
+ broadcastToSubscribers: (msg) => browserGateway.broadcast(msg as any),
1122
+ registerPiHandler: (_type, _handler) => {},
1123
+ registerBrowserHandler: (_type, _handler) => {},
1124
+ getPluginConfig: (id) => {
1125
+ const cfg = loadConfig();
1126
+ return getPluginConfigFromFile(cfg, id);
1127
+ },
1128
+ updatePluginConfig: async (id, partial) => {
1129
+ const cfg = loadConfig();
1130
+ const current = getPluginConfigFromFile(cfg, id);
1131
+ const merged = { ...current, ...partial };
1132
+ let rawConfig: Record<string, unknown> = {};
1133
+ try {
1134
+ const raw = (await import('node:fs')).default.readFileSync(CONFIG_FILE, 'utf-8');
1135
+ rawConfig = JSON.parse(raw);
1136
+ } catch { /* start fresh */ }
1137
+ rawConfig.plugins = { ...(rawConfig.plugins as Record<string, unknown> ?? {}), [id]: merged };
1138
+ const fs = (await import('node:fs')).default;
1139
+ const tmpFile = CONFIG_FILE + '.tmp.' + process.pid;
1140
+ fs.writeFileSync(tmpFile, JSON.stringify(rawConfig, null, 2) + '\n');
1141
+ fs.renameSync(tmpFile, CONFIG_FILE);
1142
+ browserGateway.broadcast({ type: 'plugin_config_update', id, config: merged } as any);
1143
+ },
1144
+ },
1145
+ plugin.manifest.id,
1146
+ ),
1147
+ });
1148
+ } catch (err) {
1149
+ console.error('[plugin-loader] Unexpected error during pre-listen load:', err);
1150
+ }
1151
+
1079
1152
  fastify.server.on("upgrade", (request, socket, head) => {
1080
1153
  // Access check for WebSocket upgrades
1081
1154
  const remoteAddress = request.socket.remoteAddress || "";
@@ -1159,60 +1232,6 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
1159
1232
  // Discover sessions and start OpenSpec polling (async, non-blocking)
1160
1233
  discoverAndBroadcastSessions({ sessionManager, browserGateway, directoryService });
1161
1234
 
1162
- // Load plugin server entries (non-blocking; failures isolated per plugin)
1163
- loadServerEntries({
1164
- isEnabled: (pluginId) => {
1165
- const cfg = loadConfig();
1166
- const pluginCfg = getPluginConfigFromFile(cfg, pluginId) as Record<string, unknown>;
1167
- return pluginCfg.enabled !== false; // default: enabled
1168
- },
1169
- createContext: (plugin) => createServerPluginContext(
1170
- {
1171
- fastify,
1172
- sessionManager: {
1173
- listActive: () => sessionManager.listActive(),
1174
- listAll: () => sessionManager.listAll(),
1175
- getSession: (id: string) => sessionManager.get(id),
1176
- },
1177
- eventStore: {
1178
- getEvents: (sessionId) => eventStore.getEvents(sessionId, 0),
1179
- getLatestEvent: (sessionId) => {
1180
- const events = eventStore.getEvents(sessionId, 0);
1181
- return events.length > 0 ? events[events.length - 1] : undefined;
1182
- },
1183
- },
1184
- broadcastToSubscribers: (msg) => browserGateway.broadcast(msg as any),
1185
- // Plugin pi/browser handler registration — stub for now;
1186
- // full dynamic handler registration requires a registry refactor
1187
- // tracked in extract-*-as-plugin changes.
1188
- registerPiHandler: (_type, _handler) => {},
1189
- registerBrowserHandler: (_type, _handler) => {},
1190
- getPluginConfig: (id) => {
1191
- const cfg = loadConfig();
1192
- return getPluginConfigFromFile(cfg, id);
1193
- },
1194
- updatePluginConfig: async (id, partial) => {
1195
- // Inline partial write (reuses CONFIG_FILE path from shared config)
1196
- const cfg = loadConfig();
1197
- const current = getPluginConfigFromFile(cfg, id);
1198
- const merged = { ...current, ...partial };
1199
- let rawConfig: Record<string, unknown> = {};
1200
- try {
1201
- const raw = (await import('node:fs')).default.readFileSync(CONFIG_FILE, 'utf-8');
1202
- rawConfig = JSON.parse(raw);
1203
- } catch { /* start fresh */ }
1204
- rawConfig.plugins = { ...(rawConfig.plugins as Record<string, unknown> ?? {}), [id]: merged };
1205
- const fs = (await import('node:fs')).default;
1206
- const tmpFile = CONFIG_FILE + '.tmp.' + process.pid;
1207
- fs.writeFileSync(tmpFile, JSON.stringify(rawConfig, null, 2) + '\n');
1208
- fs.renameSync(tmpFile, CONFIG_FILE);
1209
- browserGateway.broadcast({ type: 'plugin_config_update', id, config: merged } as any);
1210
- },
1211
- },
1212
- plugin.manifest.id,
1213
- ),
1214
- }).catch((err) => console.error('[plugin-loader] Unexpected error:', err));
1215
-
1216
1235
  // Auto-register plugin bridge entries
1217
1236
  const discoveredPlugins = discoverPlugins();
1218
1237
  const pluginsWithBridges = discoveredPlugins
@@ -3,6 +3,7 @@
3
3
  * These expose WebSocket-only operations as HTTP endpoints
4
4
  * for use by skills, scripts, and external tooling.
5
5
  */
6
+ import { existsSync } from "node:fs";
6
7
  import type { FastifyInstance } from "fastify";
7
8
  import type { SessionManager } from "./memory-session-manager.js";
8
9
  import type { PiGateway } from "./pi-gateway.js";
@@ -15,6 +16,7 @@ import type { PendingResumeIntentRegistry } from "./pending-resume-intent-regist
15
16
  import type { BootstrapStateStore } from "./bootstrap-state.js";
16
17
  import type { BootstrapQueue } from "./bootstrap-queue.js";
17
18
  import { attachRenameTarget, detachShouldClearName } from "./proposal-attach-naming.js";
19
+ import { FORK_DEGRADED_TO_NEW_MESSAGE, FORK_DEGRADED_TO_NEW_CODE } from "./browser-handlers/session-action-handler.js";
18
20
 
19
21
  export interface SessionApiDeps {
20
22
  sessionManager: SessionManager;
@@ -36,6 +38,13 @@ export interface SessionApiDeps {
36
38
  * See change: preserve-session-order-on-reboot.
37
39
  */
38
40
  pendingResumeIntents?: PendingResumeIntentRegistry;
41
+ /**
42
+ * Optional pending-attach registry. When provided, the resume endpoint's
43
+ * fork-empty-session degradation path inherits the parent's
44
+ * `attachedProposal` for the new spawn.
45
+ * See change: fix-fork-empty-session-silent-timeout.
46
+ */
47
+ pendingAttachRegistry?: import("./pending-attach-registry.js").PendingAttachRegistry;
39
48
  }
40
49
 
41
50
  type IdParams = { Params: { id: string } };
@@ -48,7 +57,7 @@ function getSessionOrFail(sessionManager: SessionManager, id: string): { session
48
57
  }
49
58
 
50
59
  export function registerSessionApi(fastify: FastifyInstance, deps: SessionApiDeps) {
51
- const { sessionManager, piGateway, browserGateway, pendingForkRegistry, pendingDashboardSpawns, bootstrapState, bootstrapQueue, pendingResumeIntents } = deps;
60
+ const { sessionManager, piGateway, browserGateway, pendingForkRegistry, pendingDashboardSpawns, bootstrapState, bootstrapQueue, pendingResumeIntents, pendingAttachRegistry } = deps;
52
61
 
53
62
  /**
54
63
  * Gate pi-dependent operations on bootstrap status. Returns:
@@ -282,8 +291,44 @@ export function registerSessionApi(fastify: FastifyInstance, deps: SessionApiDep
282
291
  reply.code(409);
283
292
  return { success: false, error: "session is already being resumed" } satisfies ApiResponse;
284
293
  }
285
- if (mode === "fork" && pendingForkRegistry) {
286
- pendingForkRegistry.recordFork(session.cwd, id);
294
+ // Fork preflight: silent-degrade when the source has no on-disk JSONL.
295
+ // Mirrors the WS-handler logic. See change:
296
+ // fix-fork-empty-session-silent-timeout.
297
+ if (mode === "fork" && !existsSync(session.sessionFile)) {
298
+ // Inherit attachedProposal from parent.
299
+ if (session.attachedProposal && pendingAttachRegistry) {
300
+ pendingAttachRegistry.enqueue(session.cwd, session.attachedProposal);
301
+ }
302
+ const degradeConfig = loadConfig();
303
+ const degradeResult = await spawnPiSession(session.cwd, {
304
+ strategy: degradeConfig.spawnStrategy,
305
+ });
306
+ if (degradeResult.process && degradeResult.pid) {
307
+ browserGateway.headlessPidRegistry.register(
308
+ degradeResult.pid,
309
+ session.cwd,
310
+ degradeResult.process,
311
+ degradeResult.spawnToken,
312
+ );
313
+ }
314
+ if (degradeResult.dashboardSpawned && degradeResult.success) {
315
+ pendingDashboardSpawns?.set(
316
+ session.cwd,
317
+ (pendingDashboardSpawns?.get(session.cwd) ?? 0) + 1,
318
+ );
319
+ }
320
+ if (!degradeResult.success) {
321
+ reply.code(500);
322
+ return {
323
+ success: false,
324
+ error: degradeResult.message,
325
+ } satisfies ApiResponse;
326
+ }
327
+ return {
328
+ success: true,
329
+ data: { message: FORK_DEGRADED_TO_NEW_MESSAGE },
330
+ code: FORK_DEGRADED_TO_NEW_CODE,
331
+ } satisfies ApiResponse<{ message: string }>;
287
332
  }
288
333
  // Tag the user-resume intent BEFORE spawning. REST resume always
289
334
  // uses "front" placement — the only "keep" path is drag-to-resume
@@ -297,6 +342,12 @@ export function registerSessionApi(fastify: FastifyInstance, deps: SessionApiDep
297
342
  mode,
298
343
  strategy: config.spawnStrategy,
299
344
  });
345
+ // Fork bookkeeping uses the spawn token (not cwd) so two concurrent
346
+ // forks in the same cwd correlate correctly. See change:
347
+ // spawn-correlation-token.
348
+ if (mode === "fork" && pendingForkRegistry && spawnResult.spawnToken) {
349
+ pendingForkRegistry.recordFork(spawnResult.spawnToken, id);
350
+ }
300
351
  if (spawnResult.dashboardSpawned && spawnResult.success) {
301
352
  pendingDashboardSpawns?.set(session.cwd, (pendingDashboardSpawns?.get(session.cwd) ?? 0) + 1);
302
353
  }
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import type { SessionManager } from "./memory-session-manager.js";
6
6
  import type { BrowserGateway } from "./browser-gateway.js";
7
- import type { DirectoryService } from "./directory-service.js";
7
+ import { isOpenSpecDataEmpty, type DirectoryService } from "./directory-service.js";
8
8
  import { extractSessionStats } from "./session-stats-reader.js";
9
9
 
10
10
  export interface SessionBootstrapDeps {
@@ -75,20 +75,35 @@ export async function discoverAndBroadcastSessions(deps: SessionBootstrapDeps):
75
75
 
76
76
  // Initial OpenSpec poll for all known directories.
77
77
  //
78
- // NOTE: `refreshOpenSpec` / `pollOpenSpec` is currently synchronous internally
79
- // (spawnSync per change) — on Windows with many active changes (~19) and
80
- // multiple pinned directories this can block the event loop for minutes,
81
- // making the HTTP server unresponsive during startup. We intentionally do
82
- // NOT await it here so HTTP + WebSocket startup completes immediately;
83
- // openspec data populates in the background and pushes `openspec_update`
84
- // broadcasts to browsers as each directory finishes.
78
+ // Fire-and-forget: `refreshOpenSpec` / `pollOpenSpec` is synchronous internally
79
+ // (spawnSync per change) — on Windows with many active changes and multiple
80
+ // pinned directories this can block the event loop for minutes, making the
81
+ // HTTP server unresponsive during startup. We intentionally do NOT await it
82
+ // here so HTTP + WebSocket startup completes immediately.
85
83
  //
86
- // A proper fix is to migrate the openspec Recipe to async spawn; tracked
87
- // separately. See change: consolidate-tool-resolution.
84
+ // After each directory's poll completes, broadcast `openspec_update` to all
85
+ // connected browsers if the prior cache was empty/undefined or the polled
86
+ // data differs from prior — mirroring the proven `runPostInstallRepair`
87
+ // pattern in `server.ts`. This is what unblocks cold-boot Electron clients
88
+ // that connected before the cache was hot.
89
+ //
90
+ // A proper fix for the slow `spawnSync` path is to migrate the openspec
91
+ // Recipe to async spawn; tracked separately. See change:
92
+ // consolidate-tool-resolution. This change covers the broadcast wiring only.
93
+ // See change: fix-cold-boot-openspec-protocol.
88
94
  void Promise.all(
89
95
  directoryService.knownDirectories().map(async (cwd) => {
90
- try { await directoryService.refreshOpenSpec(cwd); }
91
- catch (err) { console.error(`[dashboard] initial openspec poll failed for ${cwd}:`, err); }
96
+ try {
97
+ const prior = directoryService.getOpenSpecData(cwd);
98
+ const fresh = await directoryService.refreshOpenSpec(cwd);
99
+ const priorEmpty = isOpenSpecDataEmpty(prior);
100
+ const dataDiffers = JSON.stringify(prior) !== JSON.stringify(fresh);
101
+ if (priorEmpty || dataDiffers) {
102
+ browserGateway.broadcastToAll({ type: "openspec_update", cwd, data: fresh });
103
+ }
104
+ } catch (err) {
105
+ console.error(`[dashboard] initial openspec poll failed for ${cwd}:`, err);
106
+ }
92
107
  }),
93
108
  );
94
109
  }
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Standalone session discovery — lists pi sessions for a given cwd
3
- * without requiring @mariozechner/pi-coding-agent.
3
+ * without requiring @earendil-works/pi-coding-agent.
4
4
  * Reads session JSONL files from ~/.pi/agent/sessions/<encoded-cwd>/.
5
5
  */
6
6
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import os from "node:os";
9
+ import { condenseForFirstMessage } from "@blackbelt-technology/pi-dashboard-shared/skill-block-parser.js";
9
10
 
10
11
  export interface DiscoveredSession {
11
12
  id: string;
@@ -53,15 +54,21 @@ function readSessionHeader(filePath: string): {
53
54
  if (entry.type === "session_info" && entry.name) {
54
55
  name = entry.name;
55
56
  }
56
- // Find first user message
57
+ // Find first user message. Skill invocations are stored as a
58
+ // `<skill name=...>...</skill>\n\nargs` envelope (~264 chars for typical
59
+ // absolute paths) which is longer than the 200-char firstMessage budget,
60
+ // so a naive .slice(0, 200) cuts the wrapper in half. condenseForFirstMessage
61
+ // returns the condensed slash form (`/skill:name args`) when the input
62
+ // matches the envelope, falling back to the raw slice otherwise.
63
+ // See change: render-skill-invocations-collapsibly.
57
64
  if (!firstMessage && entry.type === "message" && entry.message?.role === "user") {
58
65
  const msg = entry.message;
59
66
  if (typeof msg.content === "string") {
60
- firstMessage = msg.content.slice(0, 200);
67
+ firstMessage = condenseForFirstMessage(msg.content, 200);
61
68
  } else if (Array.isArray(msg.content)) {
62
69
  for (const part of msg.content) {
63
70
  if (part.type === "text" && part.text) {
64
- firstMessage = part.text.slice(0, 200);
71
+ firstMessage = condenseForFirstMessage(part.text, 200);
65
72
  break;
66
73
  }
67
74
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Standalone JSONL session file reader.
3
- * Reads pi session files without requiring @mariozechner/pi-coding-agent.
3
+ * Reads pi session files without requiring @earendil-works/pi-coding-agent.
4
4
  * Falls back to linear entry order (no tree branching support).
5
5
  */
6
6
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
@@ -8,6 +8,7 @@ import { join } from "node:path";
8
8
  import os from "node:os";
9
9
  import type { DashboardSession, SessionSource } from "@blackbelt-technology/pi-dashboard-shared/types.js";
10
10
  import { type SessionMeta, metaPath, readSessionMeta, writeSessionMeta } from "@blackbelt-technology/pi-dashboard-shared/session-meta.js";
11
+ import { condenseForFirstMessage } from "@blackbelt-technology/pi-dashboard-shared/skill-block-parser.js";
11
12
  import { extractSessionStats } from "./session-stats-reader.js";
12
13
 
13
14
  function getSessionsDir(): string {
@@ -236,14 +237,15 @@ function readJsonlHeaderSync(filePath: string): { id: string; cwd: string; name?
236
237
  const entry = JSON.parse(line);
237
238
  if (entry.type === "session" && entry.id) header = entry;
238
239
  if (entry.type === "session_info" && entry.name) name = entry.name;
240
+ // See change: render-skill-invocations-collapsibly.
239
241
  if (!firstMessage && entry.type === "message" && entry.message?.role === "user") {
240
242
  const msg = entry.message;
241
243
  if (typeof msg.content === "string") {
242
- firstMessage = msg.content.slice(0, 200);
244
+ firstMessage = condenseForFirstMessage(msg.content, 200);
243
245
  } else if (Array.isArray(msg.content)) {
244
246
  for (const part of msg.content) {
245
247
  if (part.type === "text" && part.text) {
246
- firstMessage = part.text.slice(0, 200);
248
+ firstMessage = condenseForFirstMessage(part.text, 200);
247
249
  break;
248
250
  }
249
251
  }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Rolling NDJSON log of failed pi session spawn attempts.
3
+ *
4
+ * Location: ~/.pi/dashboard/sessions/spawn-failures.log
5
+ * Rotation: single-shot at 10 MB (renames to .log.1, overwrites any prior .log.1).
6
+ * Format: one JSON object per line, terminated by \n.
7
+ *
8
+ * See change: spawn-failure-diagnostics.
9
+ */
10
+ import {
11
+ appendFileSync,
12
+ existsSync,
13
+ mkdirSync,
14
+ readFileSync,
15
+ renameSync,
16
+ statSync,
17
+ } from "node:fs";
18
+ import path from "node:path";
19
+ import os from "node:os";
20
+ import type { PreflightReason } from "./spawn-preflight.js";
21
+
22
+ export interface SpawnFailureEntry {
23
+ /** ISO 8601 UTC timestamp. */
24
+ ts: string;
25
+ cwd: string;
26
+ strategy: string;
27
+ code: string;
28
+ message: string;
29
+ stderrTail?: string;
30
+ pid?: number;
31
+ reasons?: PreflightReason[];
32
+ }
33
+
34
+ const LOG_MAX_BYTES = 10 * 1024 * 1024; // 10 MB
35
+ const DEFAULT_LIMIT = 50;
36
+ const MAX_LIMIT = 500;
37
+
38
+ let _logDirOverride: string | null = null;
39
+
40
+ /** Override the log directory — for tests only. See change: spawn-failure-diagnostics. */
41
+ export function _setLogDirForTests(dir: string | null): void {
42
+ _logDirOverride = dir;
43
+ }
44
+
45
+ function logDir(): string {
46
+ return _logDirOverride ?? path.join(os.homedir(), ".pi", "dashboard", "sessions");
47
+ }
48
+
49
+ function logPath(): string {
50
+ return path.join(logDir(), "spawn-failures.log");
51
+ }
52
+
53
+ function logPath1(): string {
54
+ return path.join(logDir(), "spawn-failures.log.1");
55
+ }
56
+
57
+ /**
58
+ * Append a failure entry to the rolling log.
59
+ * Never throws — errors are caught and reported via `console.error`.
60
+ */
61
+ export function appendSpawnFailure(entry: SpawnFailureEntry): void {
62
+ try {
63
+ const dir = logDir();
64
+ mkdirSync(dir, { recursive: true });
65
+
66
+ const filePath = logPath();
67
+ const line = JSON.stringify(entry) + "\n";
68
+
69
+ // Rotate if file exceeds threshold.
70
+ if (existsSync(filePath)) {
71
+ try {
72
+ const { size } = statSync(filePath);
73
+ if (size > LOG_MAX_BYTES) {
74
+ renameSync(filePath, logPath1());
75
+ }
76
+ } catch {
77
+ // If stat/rename fails, just write anyway.
78
+ }
79
+ }
80
+
81
+ appendFileSync(filePath, line, "utf-8");
82
+ } catch (err) {
83
+ console.error("[spawn-failure-log] Failed to append entry:", err);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Read the last `limit` entries from the rolling log (both .log.1 and .log).
89
+ * Skips malformed lines. Returns [] when no log exists.
90
+ */
91
+ export function readSpawnFailures(limit: number = DEFAULT_LIMIT): SpawnFailureEntry[] {
92
+ const effectiveLimit = Number.isNaN(limit) ? DEFAULT_LIMIT : Math.max(0, Math.min(limit, MAX_LIMIT));
93
+ if (effectiveLimit === 0) return [];
94
+
95
+ const lines: string[] = [];
96
+
97
+ // Read older log first, then newer.
98
+ for (const filePath of [logPath1(), logPath()]) {
99
+ if (!existsSync(filePath)) continue;
100
+ try {
101
+ const content = readFileSync(filePath, "utf-8");
102
+ lines.push(...content.split("\n").filter((l) => l.trim()));
103
+ } catch {
104
+ // Skip unreadable file.
105
+ }
106
+ }
107
+
108
+ // Parse, skipping malformed lines.
109
+ const entries: SpawnFailureEntry[] = [];
110
+ for (const line of lines) {
111
+ try {
112
+ const obj = JSON.parse(line) as Record<string, unknown>;
113
+ // Require the minimum fields.
114
+ if (
115
+ typeof obj.ts === "string" &&
116
+ typeof obj.cwd === "string" &&
117
+ typeof obj.strategy === "string" &&
118
+ typeof obj.code === "string" &&
119
+ typeof obj.message === "string"
120
+ ) {
121
+ entries.push(obj as unknown as SpawnFailureEntry);
122
+ }
123
+ } catch {
124
+ // Skip malformed line.
125
+ }
126
+ }
127
+
128
+ // Return last N in file order.
129
+ return entries.length <= effectiveLimit ? entries : entries.slice(entries.length - effectiveLimit);
130
+ }