@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,232 @@
1
+ /**
2
+ * Bootstrap install reconciler driven by ~/.pi/dashboard/installable.json.
3
+ * Invoked by cli.ts before app.listen.
4
+ *
5
+ * File-absent path is a deliberate no-op: Bridge and Standalone starters
6
+ * never write installable.json; only Electron seeds it on first run.
7
+ * When the file is absent, this function logs and returns immediately so
8
+ * bootstrap.status transitions to "ready" without delay.
9
+ *
10
+ * See change: simplify-electron-bootstrap-derived-state.
11
+ */
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { createRequire } from "node:module";
15
+ import { getManagedDir } from "@blackbelt-technology/pi-dashboard-shared/managed-paths.js";
16
+ import {
17
+ readInstallableList,
18
+ type InstallablePackage,
19
+ } from "@blackbelt-technology/pi-dashboard-shared/installable-list.js";
20
+ import { bootstrapInstall } from "@blackbelt-technology/pi-dashboard-shared/bootstrap-install.js";
21
+ import { getDefaultRegistry } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
22
+ import type { BootstrapStateStore } from "./bootstrap-state.js";
23
+
24
+ // ── Injectable helpers (overridable in tests) ──────────────────────────────
25
+
26
+ export type InstallProgressCallback = (line: string) => void;
27
+ export type PackageInstaller = (
28
+ pkg: InstallablePackage,
29
+ onOutput: InstallProgressCallback,
30
+ ) => Promise<void>;
31
+
32
+ // ── Installed-check ────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Return true if `pkgName` is resolvable from `managedDir/node_modules`.
36
+ * Version satisfies check is intentionally omitted (no semver dep) —
37
+ * we treat "resolves at all" as satisfied. This is sufficient for the
38
+ * Phase B bootstrap use case.
39
+ */
40
+ export function isNpmPackageInstalled(pkgName: string, managedDir: string): boolean {
41
+ try {
42
+ // createRequire resolves from the given path; look in managedDir/node_modules.
43
+ const req = createRequire(path.join(managedDir, "package.json"));
44
+ req.resolve(pkgName + "/package.json");
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ // ── Default installers ─────────────────────────────────────────────────────
52
+
53
+ async function defaultNpmInstall(
54
+ pkg: InstallablePackage,
55
+ managedDir: string,
56
+ onOutput: InstallProgressCallback,
57
+ ): Promise<void> {
58
+ const spec =
59
+ pkg.version && pkg.version !== "*"
60
+ ? `${pkg.name}@${pkg.version}`
61
+ : pkg.name;
62
+ const res = await bootstrapInstall({
63
+ packages: [spec],
64
+ managedDir,
65
+ progress: (p) => {
66
+ if (p.output) onOutput(p.output);
67
+ },
68
+ });
69
+ if (!res.ok) {
70
+ throw new Error(res.error);
71
+ }
72
+ }
73
+
74
+ async function defaultPiExtensionInstall(
75
+ pkg: InstallablePackage,
76
+ onOutput: InstallProgressCallback,
77
+ ): Promise<void> {
78
+ const registry = getDefaultRegistry();
79
+ const { module: piModule } = await registry.resolveModule<{
80
+ DefaultPackageManager: any;
81
+ SettingsManager: any;
82
+ }>("pi-coding-agent");
83
+ const agentDir = path.join(os.homedir(), ".pi", "agent");
84
+ const settingsManager = piModule.SettingsManager.create(process.cwd(), agentDir);
85
+ const pm = new piModule.DefaultPackageManager({
86
+ cwd: process.cwd(),
87
+ agentDir,
88
+ settingsManager,
89
+ });
90
+ pm.setProgressCallback((event: { message?: string }) => {
91
+ if (event.message) onOutput(event.message);
92
+ });
93
+ await pm.installAndPersist(pkg.name, { local: false });
94
+ }
95
+
96
+ // ── Options ────────────────────────────────────────────────────────────────
97
+
98
+ export interface BootstrapInstallFromListOptions {
99
+ /** Override config dir for installable.json (default: ~/.pi/dashboard/). */
100
+ configDir?: string;
101
+ /** Override managed dir for npm installs (default: ~/.pi-dashboard/). */
102
+ managedDir?: string;
103
+ /**
104
+ * Injectable npm installer. Defaults to bootstrapInstall.
105
+ * Receives the InstallablePackage and a streaming output callback.
106
+ */
107
+ npmInstall?: PackageInstaller;
108
+ /**
109
+ * Injectable pi-extension installer. Defaults to pi DefaultPackageManager.
110
+ * Receives the InstallablePackage and a streaming output callback.
111
+ */
112
+ piInstall?: PackageInstaller;
113
+ /**
114
+ * Injectable installed-check for npm packages.
115
+ * Defaults to isNpmPackageInstalled.
116
+ */
117
+ isInstalled?: (pkg: InstallablePackage, managedDir: string) => boolean;
118
+ }
119
+
120
+ // ── Main reconciler ────────────────────────────────────────────────────────
121
+
122
+ /**
123
+ * Reconcile packages from installable.json against the managed directory.
124
+ *
125
+ * - File absent: log and return immediately (not a failure).
126
+ * - Per package: check installed → skip or install.
127
+ * - Required failure: set bootstrap status=failed, throw (abort server start).
128
+ * - Optional failure: log, record in failed[], continue.
129
+ */
130
+ export async function bootstrapInstallFromList(
131
+ bootstrapState: BootstrapStateStore,
132
+ opts?: BootstrapInstallFromListOptions,
133
+ ): Promise<void> {
134
+ const configDir =
135
+ opts?.configDir ?? path.join(os.homedir(), ".pi", "dashboard");
136
+ const managedDir = opts?.managedDir ?? getManagedDir();
137
+
138
+ // Read installable.json; absent file is a deliberate no-op.
139
+ const list = await readInstallableList(configDir);
140
+ if (list === null) {
141
+ console.log(
142
+ "[bootstrap] bootstrap.installable.skipped reason=file-not-found",
143
+ );
144
+ return;
145
+ }
146
+
147
+ // Only process packages that are active (not deprecated, not defaultOff).
148
+ const packages = list.packages.filter((p) => !p.deprecated && !p.defaultOff);
149
+ const total = packages.length;
150
+ let installedCount = 0;
151
+ const failed: string[] = [];
152
+
153
+ // Stamp initial installable progress into bootstrap state.
154
+ bootstrapState.set({ installable: { total, installed: 0, failed: [] } });
155
+
156
+ const checkInstalled =
157
+ opts?.isInstalled ?? ((p, dir) => isNpmPackageInstalled(p.name, dir));
158
+ const doNpmInstall: PackageInstaller =
159
+ opts?.npmInstall ??
160
+ ((p, cb) => defaultNpmInstall(p, managedDir, cb));
161
+ const doPiInstall: PackageInstaller =
162
+ opts?.piInstall ?? defaultPiExtensionInstall;
163
+
164
+ for (const pkg of packages) {
165
+ // Fast path: already installed (npm packages only; pi-extension always attempts).
166
+ if (pkg.kind === "npm" && checkInstalled(pkg, managedDir)) {
167
+ console.log(
168
+ `[bootstrap] bootstrap.installable.package name=${pkg.name} status=satisfied`,
169
+ );
170
+ installedCount++;
171
+ bootstrapState.set({
172
+ installable: { total, installed: installedCount, failed },
173
+ });
174
+ continue;
175
+ }
176
+
177
+ // Emit installing progress.
178
+ bootstrapState.set({
179
+ progress: { step: pkg.name, output: "installing..." },
180
+ installable: { total, installed: installedCount, failed },
181
+ });
182
+
183
+ try {
184
+ const onOutput = (line: string): void => {
185
+ bootstrapState.set({ progress: { step: pkg.name, output: line } });
186
+ };
187
+
188
+ if (pkg.kind === "npm") {
189
+ await doNpmInstall(pkg, onOutput);
190
+ } else {
191
+ await doPiInstall(pkg, onOutput);
192
+ }
193
+
194
+ installedCount++;
195
+ bootstrapState.set({
196
+ progress: undefined,
197
+ installable: { total, installed: installedCount, failed },
198
+ });
199
+ console.log(
200
+ `[bootstrap] bootstrap.installable.package name=${pkg.name} status=done`,
201
+ );
202
+ } catch (err) {
203
+ const message = err instanceof Error ? err.message : String(err);
204
+ console.error(
205
+ `[bootstrap] bootstrap.installable.package name=${pkg.name} status=error error=${message}`,
206
+ );
207
+ failed.push(pkg.name);
208
+ bootstrapState.set({
209
+ progress: undefined,
210
+ installable: { total, installed: installedCount, failed: [...failed] },
211
+ });
212
+
213
+ if (pkg.required) {
214
+ const errorMessage = `Required package "${pkg.name}" failed to install: ${message}`;
215
+ bootstrapState.set({
216
+ status: "failed",
217
+ error: { message: errorMessage },
218
+ });
219
+ throw new Error(errorMessage);
220
+ }
221
+ // Optional package failure: log, continue to next package.
222
+ }
223
+ }
224
+
225
+ // Final state snapshot.
226
+ bootstrapState.set({
227
+ installable: { total, installed: installedCount, failed },
228
+ });
229
+ console.log(
230
+ `[bootstrap] bootstrap.installable.done total=${total} installed=${installedCount} failed=${failed.length}`,
231
+ );
232
+ }
@@ -1,3 +1,5 @@
1
+ import type { DashboardStarter } from "@blackbelt-technology/pi-dashboard-shared/dashboard-starter.js";
2
+
1
3
  /**
2
4
  * In-memory bootstrap state store for the dashboard server.
3
5
  *
@@ -51,6 +53,22 @@ export interface BootstrapState {
51
53
  compatibility?: BootstrapCompatibility;
52
54
  /** Set when `registerBridgeExtension` fails after a successful install. */
53
55
  bridgeRegistrationError?: string;
56
+ /**
57
+ * Who started this server process. Defaults to "Standalone" (direct CLI).
58
+ * Set at boot time from `parseDashboardStarter(process.env)`.
59
+ */
60
+ starter?: DashboardStarter;
61
+ /**
62
+ * Installable list reconciliation progress.
63
+ * Set by bootstrapInstallFromList during Phase B reconcile.
64
+ * See change: simplify-electron-bootstrap-derived-state.
65
+ */
66
+ installable?: {
67
+ total: number;
68
+ installed: number;
69
+ /** Package names that failed to install. */
70
+ failed: string[];
71
+ };
54
72
  }
55
73
 
56
74
  export type BootstrapListener = (state: BootstrapState) => void;
@@ -15,7 +15,44 @@ import { createHeadlessPidRegistry, type HeadlessPidRegistry } from "./headless-
15
15
  import type { PendingForkRegistry } from "./pending-fork-registry.js";
16
16
  import type { SessionOrderManager } from "./session-order-manager.js";
17
17
  import type { PreferencesStore } from "./preferences-store.js";
18
- import type { DirectoryService } from "./directory-service.js";
18
+ import { hasOpenSpecDir, type DirectoryService } from "./directory-service.js";
19
+
20
+ /**
21
+ * Pure helper: build the per-cwd `openspec_update` messages a freshly
22
+ * connecting browser should receive. One message per known cwd.
23
+ * Disambiguates three states:
24
+ * - cache populated → cached payload
25
+ * - openspec dir but cold → { initialized: false, pending: true }
26
+ * - no openspec dir → { initialized: false, pending: false }
27
+ *
28
+ * Exported so cold-boot snapshot semantics can be unit-tested without
29
+ * spinning up a WS server. See change: fix-cold-boot-openspec-protocol.
30
+ */
31
+ export function buildOpenSpecConnectSnapshot(
32
+ directoryService: Pick<DirectoryService, "knownDirectories" | "getOpenSpecData">,
33
+ hasDir: (cwd: string) => boolean,
34
+ ): Array<ServerToBrowserMessage> {
35
+ const out: Array<ServerToBrowserMessage> = [];
36
+ for (const cwd of directoryService.knownDirectories()) {
37
+ const cached = directoryService.getOpenSpecData(cwd);
38
+ if (cached && cached.initialized) {
39
+ out.push({ type: "openspec_update", cwd, data: cached });
40
+ } else if (hasDir(cwd)) {
41
+ out.push({
42
+ type: "openspec_update",
43
+ cwd,
44
+ data: { initialized: false, pending: true, changes: [] },
45
+ });
46
+ } else {
47
+ out.push({
48
+ type: "openspec_update",
49
+ cwd,
50
+ data: { initialized: false, pending: false, changes: [] },
51
+ });
52
+ }
53
+ }
54
+ return out;
55
+ }
19
56
  import { createPendingResumeRegistry, type PendingResumeRegistry } from "./pending-resume-registry.js";
20
57
  import { createViewedSessionTracker, type ViewedSessionTracker } from "./viewed-session-tracker.js";
21
58
  import type { TerminalManager } from "./terminal-manager.js";
@@ -208,10 +245,21 @@ export function createBrowserGateway(
208
245
  const subs = new Set<string>();
209
246
  subscriptions.set(ws, subs);
210
247
 
211
- // Send all sessions on connect (client filters by hidden flag)
212
- const allSessions = sessionManager.listAll();
213
- for (const session of allSessions) {
214
- sendTo(ws, { type: "session_added", session });
248
+ // Atomic snapshot of the full session registry + per-cwd orders.
249
+ // Replaces the legacy per-session `session_added` loop and per-cwd
250
+ // `sessions_reordered` loop. Client REPLACES (not merges) its
251
+ // `sessions` Map and `sessionOrderMap` on receipt so stale ids from a
252
+ // previous server lifetime are dropped atomically.
253
+ // See change: fix-stale-sessions-on-reconnect.
254
+ {
255
+ const sessionsSnapshot = sessionManager.listAll();
256
+ const orders: Record<string, string[]> = {};
257
+ if (sessionOrderManager) {
258
+ for (const [cwd, sessionIds] of Object.entries(sessionOrderManager.getAllOrders())) {
259
+ if (sessionIds.length > 0) orders[cwd] = sessionIds;
260
+ }
261
+ }
262
+ sendTo(ws, { type: "sessions_snapshot", sessions: sessionsSnapshot, orders });
215
263
  }
216
264
 
217
265
  // Send pinned directories on connect
@@ -219,23 +267,12 @@ export function createBrowserGateway(
219
267
  sendTo(ws, { type: "pinned_dirs_updated", paths: preferencesStore.getPinnedDirectories() });
220
268
  }
221
269
 
222
- // Send session orders for all cwds
223
- if (sessionOrderManager) {
224
- const allOrders = sessionOrderManager.getAllOrders();
225
- for (const [cwd, sessionIds] of Object.entries(allOrders)) {
226
- if (sessionIds.length > 0) {
227
- sendTo(ws, { type: "sessions_reordered", cwd, sessionIds });
228
- }
229
- }
230
- }
231
-
232
- // Send cached OpenSpec data for all known directories
270
+ // Send OpenSpec data for every known directory — exactly one
271
+ // `openspec_update` per cwd, never silently omit.
272
+ // See change: fix-cold-boot-openspec-protocol.
233
273
  if (directoryService) {
234
- for (const cwd of directoryService.knownDirectories()) {
235
- const data = directoryService.getOpenSpecData(cwd);
236
- if (data && data.initialized) {
237
- sendTo(ws, { type: "openspec_update", cwd, data });
238
- }
274
+ for (const msg of buildOpenSpecConnectSnapshot(directoryService, hasOpenSpecDir)) {
275
+ sendTo(ws, msg);
239
276
  }
240
277
  }
241
278
 
@@ -148,6 +148,10 @@ export function handlePiGatewayForward(
148
148
  case "request_models":
149
149
  piGateway.sendToSession(msg.sessionId, { type: "request_models", sessionId: msg.sessionId });
150
150
  break;
151
+ case "request_providers":
152
+ // See change: replace-hardcoded-provider-lists.
153
+ piGateway.sendToSession(msg.sessionId, { type: "request_providers", sessionId: msg.sessionId });
154
+ break;
151
155
  case "set_thinking_level":
152
156
  piGateway.sendToSession(msg.sessionId, { type: "set_thinking_level", sessionId: msg.sessionId, level: msg.level });
153
157
  break;
@@ -4,7 +4,11 @@
4
4
  import type { BrowserToServerMessage } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
5
5
  import type { BrowserHandlerContext } from "./handler-context.js";
6
6
  import { spawnPiSession } from "../process-manager.js";
7
+ import { ToolResolver } from "@blackbelt-technology/pi-dashboard-shared/platform/binary-lookup.js";
7
8
  import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
9
+ import { preflightSpawn } from "../spawn-preflight.js";
10
+ import { getSpawnRegisterWatchdog } from "../spawn-register-watchdog.js";
11
+ import { appendSpawnFailure } from "../spawn-failure-log.js";
8
12
  import { createBranchedSessionFile } from "../session-file-reader.js";
9
13
  import {
10
14
  killPidWithGroup,
@@ -313,6 +317,24 @@ export async function handleSpawnSession(
313
317
  pendingAttachRegistry?.enqueue(msg.cwd, msg.attachProposal);
314
318
  }
315
319
 
320
+ // ── Preflight: fast synchronous checks before spawning. See change: spawn-failure-diagnostics.
321
+ const preflightResolver = new ToolResolver({ processExecPath: process.execPath, useLoginShell: false });
322
+ const preflight = preflightSpawn(msg.cwd, { resolver: preflightResolver });
323
+ if (!preflight.ok) {
324
+ const message = preflight.reasons.map((r) => r.message).join("; ");
325
+ sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: false, message });
326
+ sendTo(ws, { type: "spawn_error", cwd: msg.cwd, strategy, message, code: "PREFLIGHT_FAILED", reasons: preflight.reasons });
327
+ appendSpawnFailure({
328
+ ts: new Date().toISOString(),
329
+ cwd: msg.cwd,
330
+ strategy,
331
+ code: "PREFLIGHT_FAILED",
332
+ message,
333
+ reasons: preflight.reasons,
334
+ });
335
+ return;
336
+ }
337
+
316
338
  // Catch both thrown exceptions and { success: false } results; surface as
317
339
  // spawn_error so the UI can render a retryable banner instead of failing
318
340
  // silently. Previous behaviour left the user staring at an empty state
@@ -327,13 +349,49 @@ export async function handleSpawnSession(
327
349
  }
328
350
  sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: spawnResult.success, message: spawnResult.message });
329
351
  if (!spawnResult.success) {
330
- sendTo(ws, { type: "spawn_error", cwd: msg.cwd, strategy, message: spawnResult.message });
352
+ sendTo(ws, {
353
+ type: "spawn_error",
354
+ cwd: msg.cwd,
355
+ strategy,
356
+ message: spawnResult.message,
357
+ ...(spawnResult.code ? { code: spawnResult.code } : {}),
358
+ ...(spawnResult.stderr ? { stderr: spawnResult.stderr } : {}),
359
+ });
360
+ appendSpawnFailure({
361
+ ts: new Date().toISOString(),
362
+ cwd: msg.cwd,
363
+ strategy,
364
+ code: spawnResult.code ?? "SPAWN_ERRNO",
365
+ message: spawnResult.message,
366
+ ...(spawnResult.stderr ? { stderrTail: spawnResult.stderr } : {}),
367
+ });
368
+ } else {
369
+ // Arm watchdog for every successful spawn. See change: spawn-failure-diagnostics.
370
+ const watchdog = getSpawnRegisterWatchdog();
371
+ watchdog.arm({
372
+ pid: spawnResult.pid,
373
+ cwd: msg.cwd,
374
+ mechanism: strategy as import("@blackbelt-technology/pi-dashboard-shared/platform/spawn-mechanism.js").SpawnMechanism,
375
+ logPath: spawnResult.logPath,
376
+ // Read-on-arm: pass current config value so a Settings change takes effect
377
+ // on the next spawn without a server restart. See change: spawn-failure-diagnostics (fix W1).
378
+ timeoutMs: config.spawnRegisterTimeoutMs,
379
+ ws,
380
+ });
331
381
  }
332
382
  } catch (err) {
333
383
  const message = err instanceof Error ? err.message : String(err);
334
384
  const stderr = err instanceof Error && "stderr" in err ? String((err as { stderr: unknown }).stderr).slice(-2048) : undefined;
335
385
  sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: false, message });
336
- sendTo(ws, { type: "spawn_error", cwd: msg.cwd, strategy, message, stderr });
386
+ sendTo(ws, { type: "spawn_error", cwd: msg.cwd, strategy, message, code: "SPAWN_ERRNO", stderr });
387
+ appendSpawnFailure({
388
+ ts: new Date().toISOString(),
389
+ cwd: msg.cwd,
390
+ strategy,
391
+ code: "SPAWN_ERRNO",
392
+ message,
393
+ ...(stderr ? { stderrTail: stderr } : {}),
394
+ });
337
395
  }
338
396
  }
339
397
 
@@ -68,7 +68,7 @@ async function sendEventBatches(
68
68
  * Called immediately after every `replayPendingUiRequests` site so the full
69
69
  * replay ordering is:
70
70
  *
71
- * events → pending UI requests → ui_modules_list → ui_data_list → ext_ui_decorator
71
+ * asset_register batch → events → pending UI requests → ui_modules_list → ui_data_list → ext_ui_decorator
72
72
  *
73
73
  * Exported so unit tests can drive it without standing up a full subscribe
74
74
  * pipeline. See changes: add-extension-ui-modal, add-extension-ui-decorations.
@@ -96,6 +96,35 @@ export function replayUiState(
96
96
  }
97
97
  }
98
98
 
99
+ /**
100
+ * Replay the per-session image asset registry to a single browser. Sends one
101
+ * `asset_register` message per `(hash, { data, mimeType })` entry in
102
+ * `Session.assets`. Called BEFORE `sendEventBatches` so any `pi-asset:<hash>`
103
+ * tokens in replayed `message_update` / `message_end` events have their
104
+ * referent in the client's session map by the time they're reduced.
105
+ *
106
+ * See change: chat-markdown-local-images-and-math.
107
+ */
108
+ export function replaySessionAssets(
109
+ ws: WebSocket,
110
+ sessionId: string,
111
+ ctx: Pick<BrowserHandlerContext, "sessionManager" | "sendTo">,
112
+ ): void {
113
+ const { sessionManager, sendTo } = ctx;
114
+ const session = sessionManager.get(sessionId);
115
+ if (!session?.assets) return;
116
+ for (const [hash, asset] of Object.entries(session.assets)) {
117
+ if (!asset || typeof asset.data !== "string" || typeof asset.mimeType !== "string") continue;
118
+ sendTo(ws, {
119
+ type: "asset_register",
120
+ sessionId,
121
+ hash,
122
+ mimeType: asset.mimeType,
123
+ data: asset.data,
124
+ } as any);
125
+ }
126
+ }
127
+
99
128
  export function handleSubscribe(
100
129
  msg: Extract<BrowserToServerMessage, { type: "subscribe" }>,
101
130
  subs: Set<string>,
@@ -108,6 +137,8 @@ export function handleSubscribe(
108
137
  // while the browser is actually subscribed (responses use sendToSubscribers).
109
138
  piGateway.sendToSession(msg.sessionId, { type: "request_commands", sessionId: msg.sessionId });
110
139
  piGateway.sendToSession(msg.sessionId, { type: "request_models", sessionId: msg.sessionId });
140
+ // See change: replace-hardcoded-provider-lists.
141
+ piGateway.sendToSession(msg.sessionId, { type: "request_providers", sessionId: msg.sessionId });
111
142
  piGateway.sendToSession(msg.sessionId, { type: "request_roles", sessionId: msg.sessionId });
112
143
 
113
144
  if (eventStore.hasEvents(msg.sessionId)) {
@@ -122,6 +153,10 @@ export function handleSubscribe(
122
153
  if (MAX_REPLAY_EVENTS > 0 && events.length > MAX_REPLAY_EVENTS) {
123
154
  events = events.slice(events.length - MAX_REPLAY_EVENTS);
124
155
  }
156
+ // Replay asset registry BEFORE events so pi-asset:<hash> tokens in
157
+ // message_update / message_end resolve on first reduce.
158
+ // See change: chat-markdown-local-images-and-math.
159
+ replaySessionAssets(ws, msg.sessionId, ctx);
125
160
  markReplaying(ws, msg.sessionId);
126
161
  sendEventBatches(ws, msg.sessionId, events, sendTo).then((lastSent) => {
127
162
  clearReplaying(ws, msg.sessionId, lastSent);
@@ -133,8 +168,18 @@ export function handleSubscribe(
133
168
  if (MAX_REPLAY_EVENTS > 0 && events.length > MAX_REPLAY_EVENTS) {
134
169
  events = events.slice(events.length - MAX_REPLAY_EVENTS);
135
170
  }
136
- // Suppress live events during delta replay to prevent out-of-order delivery
137
- if (lastSeq > 0 && events.length > 0) {
171
+ // Replay asset registry on every subscribe (delta or full). Cheap when
172
+ // empty; assets already known to the client are simply re-overwritten
173
+ // with identical bytes. See change: chat-markdown-local-images-and-math.
174
+ replaySessionAssets(ws, msg.sessionId, ctx);
175
+ // Suppress live events during paginated replay to prevent out-of-order
176
+ // delivery. The client's `event_replay` reset rule (firstSeq <= maxSeq)
177
+ // misfires if a live `event` arrives between batches and bumps maxSeq
178
+ // past the next batch's firstSeq — wiping state to a fresh build of
179
+ // only the last batch. Suppression+catch-up via clearReplaying preserves
180
+ // ordering for both cold (lastSeq=0) and warm (lastSeq>0) subscribes.
181
+ // See change: fix-cold-subscribe-replay-interleave.
182
+ if (events.length > 0) {
138
183
  markReplaying(ws, msg.sessionId);
139
184
  sendEventBatches(ws, msg.sessionId, events, sendTo).then((lastSent) => {
140
185
  clearReplaying(ws, msg.sessionId, lastSent);
@@ -172,6 +217,8 @@ export function handleSubscribe(
172
217
  }
173
218
  const subscribers = getSubscribers(msg.sessionId);
174
219
  for (const sub of subscribers) {
220
+ // Asset registry first — see change: chat-markdown-local-images-and-math.
221
+ replaySessionAssets(sub, msg.sessionId, ctx);
175
222
  await sendEventBatches(sub, msg.sessionId, stored, sendTo);
176
223
  replayPendingUiRequests(sub, msg.sessionId);
177
224
  replayUiState(sub, msg.sessionId, ctx);
@@ -53,6 +53,8 @@ import {
53
53
  import type { DashboardServer } from "./server.js";
54
54
  import { updateBootstrapCompatibility } from "./pi-version-skew.js";
55
55
  import type { BootstrapStateStore } from "./bootstrap-state.js";
56
+ import { parseDashboardStarter } from "@blackbelt-technology/pi-dashboard-shared/dashboard-starter.js";
57
+ import { bootstrapInstallFromList } from "./bootstrap-install-from-list.js";
56
58
 
57
59
  /**
58
60
  * Emit a stderr warning at CLI startup when the resolved pi version is
@@ -166,6 +168,12 @@ async function runForeground(config: ServerConfig): Promise<void> {
166
168
  assertNodeVersionSupported();
167
169
  const server = await createServer(config);
168
170
 
171
+ // Stamp the bootstrap state with who started this server process.
172
+ // parseDashboardStarter defaults to "Standalone" when DASHBOARD_STARTER is unset.
173
+ const starter = parseDashboardStarter(process.env);
174
+ server.bootstrapState.set({ starter });
175
+ console.log(`[bootstrap] starter=${starter}`);
176
+
169
177
  let shuttingDown = false;
170
178
  const shutdown = async () => {
171
179
  if (shuttingDown) {
@@ -181,6 +189,19 @@ async function runForeground(config: ServerConfig): Promise<void> {
181
189
  process.on("SIGINT", shutdown);
182
190
  process.on("SIGTERM", shutdown);
183
191
 
192
+ // Reconcile installable.json before binding the port.
193
+ // Required-package failures throw and prevent server start.
194
+ // Optional failures are logged and continue.
195
+ // File-absent is a no-op (Bridge/Standalone starters don't seed installable.json).
196
+ // See change: simplify-electron-bootstrap-derived-state.
197
+ try {
198
+ await bootstrapInstallFromList(server.bootstrapState);
199
+ } catch (err) {
200
+ const message = err instanceof Error ? err.message : String(err);
201
+ console.error(`[bootstrap] installable reconcile failed (required package): ${message}`);
202
+ process.exit(1);
203
+ }
204
+
184
205
  await server.start();
185
206
 
186
207
  // Kick off the degraded-mode first-run bootstrap if pi is unresolvable.
@@ -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[];