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

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 (112) hide show
  1. package/AGENTS.md +339 -190
  2. package/README.md +31 -0
  3. package/docs/architecture.md +238 -23
  4. package/package.json +14 -4
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
  7. package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
  8. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
  9. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  10. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  11. package/packages/extension/src/bridge.ts +110 -1
  12. package/packages/extension/src/command-handler.ts +6 -0
  13. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  14. package/packages/extension/src/prompt-expander.ts +50 -2
  15. package/packages/extension/src/provider-register.ts +117 -0
  16. package/packages/extension/src/server-launcher.ts +18 -1
  17. package/packages/extension/src/session-sync.ts +5 -0
  18. package/packages/server/package.json +4 -4
  19. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  20. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  21. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  22. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  23. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  24. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  25. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
  26. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  27. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  28. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  29. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  30. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
  31. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  32. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  33. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  34. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
  35. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  36. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  37. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  38. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  39. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  40. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
  41. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  42. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  43. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  44. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  45. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  46. package/packages/server/src/bootstrap-state.ts +18 -0
  47. package/packages/server/src/browser-gateway.ts +58 -21
  48. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  49. package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
  50. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  51. package/packages/server/src/cli.ts +21 -0
  52. package/packages/server/src/directory-service.ts +31 -0
  53. package/packages/server/src/event-wiring.ts +48 -2
  54. package/packages/server/src/home-lock.d.ts +124 -0
  55. package/packages/server/src/home-lock.js +330 -0
  56. package/packages/server/src/home-lock.js.map +1 -0
  57. package/packages/server/src/idle-timer.ts +15 -1
  58. package/packages/server/src/pi-core-updater.ts +65 -9
  59. package/packages/server/src/pi-gateway.ts +6 -0
  60. package/packages/server/src/process-manager.ts +62 -11
  61. package/packages/server/src/provider-auth-handlers.ts +9 -0
  62. package/packages/server/src/provider-auth-storage.ts +83 -51
  63. package/packages/server/src/provider-catalogue-cache.ts +41 -0
  64. package/packages/server/src/routes/doctor-routes.ts +140 -0
  65. package/packages/server/src/routes/provider-auth-routes.ts +9 -0
  66. package/packages/server/src/routes/system-routes.ts +38 -1
  67. package/packages/server/src/server.ts +8 -7
  68. package/packages/server/src/session-bootstrap.ts +27 -12
  69. package/packages/server/src/session-discovery.ts +10 -3
  70. package/packages/server/src/session-scanner.ts +4 -2
  71. package/packages/server/src/spawn-failure-log.ts +130 -0
  72. package/packages/server/src/spawn-preflight.ts +82 -0
  73. package/packages/server/src/spawn-register-watchdog.ts +236 -0
  74. package/packages/server/src/terminal-manager.ts +12 -1
  75. package/packages/shared/package.json +1 -1
  76. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
  77. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
  78. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  79. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  80. package/packages/shared/src/__tests__/config.test.ts +48 -0
  81. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  82. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  83. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  84. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  85. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  86. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  87. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  88. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  89. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  90. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  91. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  92. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  93. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  94. package/packages/shared/src/bootstrap-install.ts +196 -2
  95. package/packages/shared/src/browser-protocol.ts +112 -1
  96. package/packages/shared/src/config.ts +15 -0
  97. package/packages/shared/src/dashboard-starter.ts +33 -0
  98. package/packages/shared/src/doctor-core.ts +821 -0
  99. package/packages/shared/src/index.ts +9 -0
  100. package/packages/shared/src/installable-list.ts +152 -0
  101. package/packages/shared/src/launch-source-flag.ts +14 -0
  102. package/packages/shared/src/launch-source-types.ts +18 -0
  103. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  104. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  105. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  106. package/packages/shared/src/protocol.ts +46 -2
  107. package/packages/shared/src/rest-api.ts +4 -0
  108. package/packages/shared/src/skill-block-parser.ts +115 -0
  109. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  110. package/packages/shared/src/tool-registry/definitions.ts +18 -5
  111. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  112. package/packages/shared/src/types.ts +57 -0
@@ -0,0 +1,140 @@
1
+ /**
2
+ * GET /api/doctor — server-side diagnostic endpoint.
3
+ *
4
+ * Calls `runSharedChecks(...)` with server-appropriate `deps`, post-stamps
5
+ * `section` + `suggestion`, returns `{ checks, summary, generatedAt }`.
6
+ *
7
+ * Auth-gated identically to `/api/config`; unauthenticated requests yield
8
+ * the same status code as `/api/config` (the auth plugin's onRequest hook
9
+ * intercepts before this handler runs).
10
+ *
11
+ * On a thrown error from `runSharedChecks`, returns a 200 with a single
12
+ * fallback `error` row rather than a 500 — the web client always has
13
+ * something to render. See change: doctor-rich-output (tasks 4.1–4.5).
14
+ */
15
+ import type { FastifyInstance } from "fastify";
16
+ import path from "node:path";
17
+ import os from "node:os";
18
+ import { existsSync, readFileSync } from "node:fs";
19
+ import {
20
+ runSharedChecks,
21
+ stampSectionsAndSuggestions,
22
+ safeExec,
23
+ type DoctorCheck,
24
+ type DoctorReport,
25
+ type SharedChecksDeps,
26
+ } from "@blackbelt-technology/pi-dashboard-shared/doctor-core.js";
27
+
28
+ function getManagedDir(): string {
29
+ return process.env.MANAGED_DIR || path.join(os.homedir(), ".pi-dashboard");
30
+ }
31
+
32
+ function detectSystemNode(): { found: boolean; path?: string } {
33
+ const cmd = process.platform === "win32" ? "where node" : "which node"; // platform-branch-ok: localised PATH-lookup primitive
34
+ const r = safeExec(cmd, { timeoutMs: 3000 });
35
+ if (!r.ok) return { found: false };
36
+ const first = r.stdout.trim().split("\n")[0];
37
+ return first ? { found: true, path: first } : { found: false };
38
+ }
39
+
40
+ function detectOnPath(name: string): { found: boolean; path?: string; source?: string } {
41
+ const cmd = process.platform === "win32" ? `where ${name}` : `which ${name}`; // platform-branch-ok: localised PATH-lookup primitive
42
+ const r = safeExec(cmd, { timeoutMs: 3000 });
43
+ if (!r.ok) return { found: false };
44
+ const first = r.stdout.trim().split("\n")[0];
45
+ return first ? { found: true, path: first, source: "system" } : { found: false };
46
+ }
47
+
48
+ function isApiKeyConfigured(): boolean {
49
+ try {
50
+ const settings = path.join(os.homedir(), ".pi", "agent", "settings.json");
51
+ if (!existsSync(settings)) return false;
52
+ const data = JSON.parse(readFileSync(settings, "utf-8"));
53
+ if (data?.anthropicApiKey || data?.openaiApiKey || data?.apiKey) return true;
54
+ if (data?.providers && typeof data.providers === "object") {
55
+ for (const v of Object.values(data.providers as Record<string, unknown>)) {
56
+ if (v && typeof v === "object" && "apiKey" in (v as object)) return true;
57
+ }
58
+ }
59
+ return false;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ function buildDefaultDeps(): SharedChecksDeps {
66
+ return {
67
+ managedDir: getManagedDir(),
68
+ detectSystemNode,
69
+ detectPi: () => detectOnPath("pi"),
70
+ detectOpenSpec: () => detectOnPath("openspec"),
71
+ isApiKeyConfigured,
72
+ probeServer: async () => {
73
+ const r = safeExec("curl -sf http://localhost:8000/api/health", { timeoutMs: 3000 });
74
+ if (!r.ok || !r.stdout.trim()) return { running: false };
75
+ try {
76
+ const h = JSON.parse(r.stdout);
77
+ return {
78
+ running: true,
79
+ version: typeof h.version === "string" ? h.version : undefined,
80
+ mode: typeof h.mode === "string" ? h.mode : undefined,
81
+ starter: typeof h.starter === "string" ? h.starter : null,
82
+ installable:
83
+ h.installable && typeof h.installable === "object"
84
+ ? {
85
+ total: h.installable.total ?? 0,
86
+ installed: h.installable.installed ?? 0,
87
+ failed: Array.isArray(h.installable.failed) ? h.installable.failed : [],
88
+ }
89
+ : null,
90
+ };
91
+ } catch {
92
+ return { running: true };
93
+ }
94
+ },
95
+ };
96
+ }
97
+
98
+ function summarize(checks: DoctorCheck[]): DoctorReport["summary"] {
99
+ return {
100
+ ok: checks.filter((c) => c.status === "ok").length,
101
+ warnings: checks.filter((c) => c.status === "warning").length,
102
+ errors: checks.filter((c) => c.status === "error").length,
103
+ };
104
+ }
105
+
106
+ export interface DoctorRouteDeps {
107
+ /** Override for tests — substitutes a different `runSharedChecks` deps shape (or throws to exercise fault tolerance). */
108
+ buildDeps?: () => SharedChecksDeps;
109
+ }
110
+
111
+ export function registerDoctorRoutes(fastify: FastifyInstance, deps: DoctorRouteDeps = {}): void {
112
+ fastify.get("/api/doctor", async (_request, _reply): Promise<DoctorReport> => {
113
+ try {
114
+ const sharedDeps = deps.buildDeps ? deps.buildDeps() : buildDefaultDeps();
115
+ const checks = await runSharedChecks(sharedDeps);
116
+ stampSectionsAndSuggestions(checks);
117
+ return {
118
+ checks,
119
+ summary: summarize(checks),
120
+ generatedAt: Date.now(),
121
+ };
122
+ } catch (err) {
123
+ const e = err instanceof Error ? err : new Error(String(err));
124
+ const fallback: DoctorCheck = {
125
+ name: "Doctor failed to produce a report",
126
+ section: "diagnostics",
127
+ status: "error",
128
+ message: "Unexpected internal failure",
129
+ detail: `${e.message}\n${(e.stack || "").split("\n").slice(0, 4).join("\n")}`,
130
+ suggestion:
131
+ "Check `~/.pi-dashboard/doctor.log` on the server, then file an issue with the captured error.",
132
+ };
133
+ return {
134
+ checks: [fallback],
135
+ summary: { ok: 0, warnings: 0, errors: 1 },
136
+ generatedAt: Date.now(),
137
+ };
138
+ }
139
+ });
140
+ }
@@ -18,6 +18,7 @@ import {
18
18
  resolveAuthJsonKey,
19
19
  type ApiKeyCredential,
20
20
  } from "../provider-auth-storage.js";
21
+ import { getLatestCatalogue } from "../provider-catalogue-cache.js";
21
22
  import { startCallbackServer } from "../oauth-callback-server.js";
22
23
  import type { PiGateway } from "../pi-gateway.js";
23
24
  import type { BrowserGateway } from "../browser-gateway.js";
@@ -98,6 +99,14 @@ export function registerProviderAuthRoutes(
98
99
 
99
100
  // Full status (OAuth + API key)
100
101
  fastify.get("/api/provider-auth/status", async () => {
102
+ // Cold-cache nudge: if no bridge has pushed a catalogue yet, ask
103
+ // every connected pi to send one. Best-effort, doesn't block this
104
+ // response. See change: replace-hardcoded-provider-lists.
105
+ if (getLatestCatalogue().length === 0) {
106
+ for (const sid of piGateway.getConnectedSessionIds()) {
107
+ piGateway.sendToSession(sid, { type: "request_providers", sessionId: sid });
108
+ }
109
+ }
101
110
  return getAuthStatus();
102
111
  });
103
112
 
@@ -19,8 +19,10 @@ import { spawn } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.j
19
19
  import path from "node:path";
20
20
  import os from "node:os";
21
21
  import { localhostGuard, netmaskToCidrBits, networkAddress } from "../localhost-guard.js";
22
+ import { readSpawnFailures } from "../spawn-failure-log.js";
22
23
  import { getPluginStatusStore } from "@blackbelt-technology/dashboard-plugin-runtime/server";
23
24
  import type { NetworkInterface } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
25
+ import type { BootstrapStateStore } from "../bootstrap-state.js";
24
26
 
25
27
  export function registerSystemRoutes(
26
28
  fastify: FastifyInstance,
@@ -33,9 +35,10 @@ export function registerSystemRoutes(
33
35
  version?: string;
34
36
  directoryService?: DirectoryService;
35
37
  piGateway?: PiGateway;
38
+ bootstrapState?: BootstrapStateStore;
36
39
  },
37
40
  ) {
38
- const { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version, directoryService, piGateway } = deps;
41
+ const { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version, directoryService, piGateway, bootstrapState } = deps;
39
42
 
40
43
  // Quiesce windows for the bridge `server_restarting` broadcast. See change
41
44
  // `fix-restart-bridge-auto-start-race`. Bridges that receive this message
@@ -189,6 +192,8 @@ export function registerSystemRoutes(
189
192
  return {
190
193
  ok: true,
191
194
  pid: process.pid,
195
+ starter: bootstrapState?.get().starter ?? "Standalone",
196
+ installable: bootstrapState?.get().installable,
192
197
  version: version ?? "unknown",
193
198
  uptime: Math.floor((Date.now() - serverStartTime) / 1000),
194
199
  mode: config.dev ? "dev" : "production",
@@ -223,6 +228,26 @@ export function registerSystemRoutes(
223
228
  },
224
229
  );
225
230
 
231
+ // Re-extract endpoint — Electron-only; 403 for Bridge/Standalone, 202 for Electron.
232
+ // See change: simplify-electron-bootstrap-derived-state (task 6.4).
233
+ fastify.post(
234
+ "/api/electron/reextract",
235
+ { preHandler: networkGuard },
236
+ async (_request, reply) => {
237
+ const starter = bootstrapState?.get().starter ?? "Standalone";
238
+ if (starter !== "Electron") {
239
+ reply.status(403);
240
+ return {
241
+ error: "reextract_not_allowed",
242
+ message: `Re-extract is only available when the server was started by Electron (current starter: ${starter})`,
243
+ starter,
244
+ };
245
+ }
246
+ reply.status(202);
247
+ return { ok: true, message: "Re-extraction scheduled. Electron will restart the server." };
248
+ },
249
+ );
250
+
226
251
  // Restart endpoint — flush state, spawn new server, then exit
227
252
  fastify.post<{ Body: { dev?: boolean } }>(
228
253
  "/api/restart",
@@ -269,6 +294,18 @@ export function registerSystemRoutes(
269
294
  );
270
295
 
271
296
  // Network interfaces for trusted networks UI (localhost-only for security)
297
+ // GET /api/spawn-failures — rolling log of failed spawn attempts. See change: spawn-failure-diagnostics.
298
+ fastify.get<{ Querystring: { limit?: string } }>(
299
+ "/api/spawn-failures",
300
+ async (request) => {
301
+ const rawLimit = request.query.limit;
302
+ const parsed = rawLimit !== undefined ? parseInt(rawLimit, 10) : NaN;
303
+ const limit = Number.isNaN(parsed) ? 50 : parsed;
304
+ const entries = readSpawnFailures(limit);
305
+ return { entries };
306
+ },
307
+ );
308
+
272
309
  fastify.get(
273
310
  "/api/network-interfaces",
274
311
  { preHandler: localhostGuard },
@@ -23,7 +23,7 @@ import { createPendingResumeIntentRegistry } from "./pending-resume-intent-regis
23
23
  import { applyReattachPolicy } from "./reattach-placement.js";
24
24
 
25
25
  // pending-load-manager removed — server loads sessions directly via DirectoryService
26
- import { createDirectoryService, type DirectoryService } from "./directory-service.js";
26
+ import { createDirectoryService, isOpenSpecDataEmpty, type DirectoryService } from "./directory-service.js";
27
27
  import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
28
28
  import { createTerminalGateway, type TerminalGateway } from "./terminal-gateway.js";
29
29
  import { writePid, removePid } from "./server-pid.js";
@@ -45,6 +45,7 @@ import { registerGitRoutes } from "./routes/git-routes.js";
45
45
  import { registerFileRoutes } from "./routes/file-routes.js";
46
46
  import { registerOpenSpecRoutes } from "./routes/openspec-routes.js";
47
47
  import { registerSystemRoutes } from "./routes/system-routes.js";
48
+ import { registerDoctorRoutes } from "./routes/doctor-routes.js";
48
49
  import { registerProviderAuthRoutes } from "./routes/provider-auth-routes.js";
49
50
  import { registerPackageRoutes } from "./routes/package-routes.js";
50
51
  import { registerRecommendedRoutes, invalidateRecommendedCache } from "./routes/recommended-routes.js";
@@ -143,10 +144,6 @@ export interface PostInstallRepairDeps {
143
144
  browserGateway: { broadcastToAll(msg: ServerToBrowserMessage): void };
144
145
  }
145
146
 
146
- function isOpenSpecDataEmpty(d: OpenSpecData | undefined): boolean {
147
- if (!d) return true;
148
- return !d.initialized && (!d.changes || d.changes.length === 0);
149
- }
150
147
 
151
148
  /**
152
149
  * Centralized post-install repair work fired on every `installing → ready`
@@ -563,7 +560,9 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
563
560
  });
564
561
 
565
562
  // Auto-shutdown idle timer
566
- const idleTimer = createIdleTimer(config, piGateway);
563
+ // Active terminals keep the server alive even when no pi sessions are
564
+ // attached. See change: fix-terminal-half-height-dual-mount.
565
+ const idleTimer = createIdleTimer(config, piGateway, () => terminalManager.list().length > 0);
567
566
 
568
567
  const fastify = Fastify({
569
568
  logger: false,
@@ -709,7 +708,9 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
709
708
  if (data) browserGateway.broadcastToAll({ type: "openspec_update", cwd, data });
710
709
  },
711
710
  });
712
- registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion, directoryService, piGateway });
711
+ registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion, directoryService, piGateway, bootstrapState });
712
+ // GET /api/doctor — see change: doctor-rich-output (task 4.2). Auth-gated identically to /api/config.
713
+ registerDoctorRoutes(fastify);
713
714
  registerToolRoutes(fastify, { registry: getDefaultRegistry(), networkGuard });
714
715
  registerJjRoutes(fastify, { browserGateway, pendingAttachRegistry, networkGuard });
715
716
 
@@ -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
  }
@@ -6,6 +6,7 @@
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
  }
@@ -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
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Synchronous spawn preflight check.
3
+ *
4
+ * Runs before every `spawnPiSession` invocation to catch fast-fail conditions
5
+ * (bad cwd, missing binaries) without racing the spawn itself. All checks run
6
+ * regardless of earlier failures so the caller gets all reasons in one pass.
7
+ *
8
+ * The ToolResolver passed in MUST have `useLoginShell: false` — preflight
9
+ * must never spawn a login shell on the spawn-click hot path. If a resolver
10
+ * with `useLoginShell: true` is passed, the check still runs but a one-time
11
+ * warning is emitted.
12
+ *
13
+ * See change: spawn-failure-diagnostics.
14
+ */
15
+ import { existsSync, accessSync, statSync, constants } from "node:fs";
16
+ import { ToolResolver } from "@blackbelt-technology/pi-dashboard-shared/platform/binary-lookup.js";
17
+
18
+ export interface PreflightReason {
19
+ code: string;
20
+ message: string;
21
+ }
22
+
23
+ export interface PreflightResult {
24
+ ok: boolean;
25
+ reasons: PreflightReason[];
26
+ }
27
+
28
+ /**
29
+ * Run all preflight checks for `cwd` and return the accumulated reasons.
30
+ * `ok` is `true` iff `reasons.length === 0`.
31
+ *
32
+ * @param deps.resolver - Must be constructed with `useLoginShell: false`.
33
+ * If omitted, a login-shell-disabled resolver is created automatically.
34
+ * Passing a resolver with `useLoginShell: true` violates the preflight
35
+ * contract; the function still runs but may call into the login shell.
36
+ */
37
+ export function preflightSpawn(
38
+ cwd: string,
39
+ deps?: { resolver?: ToolResolver },
40
+ ): PreflightResult {
41
+ const resolver = deps?.resolver ?? new ToolResolver({ processExecPath: process.execPath, useLoginShell: false });
42
+
43
+ const reasons: PreflightReason[] = [];
44
+
45
+ // 1. cwd exists
46
+ const cwdExists = existsSync(cwd);
47
+ if (!cwdExists) {
48
+ reasons.push({ code: "DIR_MISSING", message: `Directory does not exist: ${cwd}` });
49
+ // No point checking isDirectory / writable if it doesn't exist.
50
+ } else {
51
+ // 2. cwd is a directory
52
+ try {
53
+ const stat = statSync(cwd);
54
+ if (!stat.isDirectory()) {
55
+ reasons.push({ code: "DIR_NOT_DIRECTORY", message: `Path is not a directory: ${cwd}` });
56
+ }
57
+ } catch (err: any) {
58
+ reasons.push({ code: "DIR_NOT_DIRECTORY", message: `Cannot stat path: ${err.message}` });
59
+ }
60
+
61
+ // 3. cwd is writable
62
+ try {
63
+ accessSync(cwd, constants.W_OK);
64
+ } catch {
65
+ reasons.push({ code: "DIR_NOT_WRITABLE", message: `Directory is not writable: ${cwd}` });
66
+ }
67
+ }
68
+
69
+ // 4. pi resolves
70
+ const piCmd = resolver.resolvePi();
71
+ if (piCmd === null) {
72
+ reasons.push({ code: "PI_NOT_FOUND", message: "pi binary not found via managed install or system PATH" });
73
+ }
74
+
75
+ // 5. node resolves
76
+ const nodeCmd = resolver.resolveNode();
77
+ if (nodeCmd === null) {
78
+ reasons.push({ code: "NODE_NOT_FOUND", message: "node binary not found via managed install or system PATH" });
79
+ }
80
+
81
+ return { ok: reasons.length === 0, reasons };
82
+ }