@blackbelt-technology/pi-agent-dashboard 0.4.5 → 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 (133) hide show
  1. package/AGENTS.md +342 -267
  2. package/README.md +51 -2
  3. package/docs/architecture.md +266 -25
  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-bus.test.ts +44 -0
  10. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  11. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  12. package/packages/extension/src/__tests__/vcs-info-jj.test.ts +145 -0
  13. package/packages/extension/src/__tests__/{git-info.test.ts → vcs-info.test.ts} +6 -6
  14. package/packages/extension/src/bridge-context.ts +7 -0
  15. package/packages/extension/src/bridge.ts +142 -4
  16. package/packages/extension/src/command-handler.ts +6 -0
  17. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  18. package/packages/extension/src/model-tracker.ts +35 -1
  19. package/packages/extension/src/prompt-bus.ts +4 -3
  20. package/packages/extension/src/prompt-expander.ts +50 -2
  21. package/packages/extension/src/provider-register.ts +117 -0
  22. package/packages/extension/src/server-launcher.ts +18 -1
  23. package/packages/extension/src/session-sync.ts +6 -1
  24. package/packages/extension/src/vcs-info.ts +184 -0
  25. package/packages/server/package.json +4 -4
  26. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  27. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  28. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  29. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  30. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  31. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  32. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
  33. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  34. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  35. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  36. package/packages/server/src/__tests__/is-unread-trigger.test.ts +4 -2
  37. package/packages/server/src/__tests__/jj-routes.test.ts +93 -0
  38. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  39. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +114 -0
  40. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
  41. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  42. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  43. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  44. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
  45. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  46. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  47. package/packages/server/src/__tests__/session-diff-vcs.test.ts +61 -0
  48. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  49. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  50. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  51. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
  52. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  53. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  54. package/packages/server/src/__tests__/system-routes-restart.test.ts +4 -4
  55. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  56. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  57. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  58. package/packages/server/src/bootstrap-state.ts +18 -0
  59. package/packages/server/src/browser-gateway.ts +58 -21
  60. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  61. package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
  62. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  63. package/packages/server/src/cli.ts +22 -0
  64. package/packages/server/src/directory-service.ts +31 -0
  65. package/packages/server/src/event-wiring.ts +57 -2
  66. package/packages/server/src/home-lock.d.ts +124 -0
  67. package/packages/server/src/home-lock.js +330 -0
  68. package/packages/server/src/home-lock.js.map +1 -0
  69. package/packages/server/src/idle-timer.ts +15 -1
  70. package/packages/server/src/openspec-tasks.ts +50 -19
  71. package/packages/server/src/pi-core-updater.ts +65 -9
  72. package/packages/server/src/pi-gateway.ts +6 -0
  73. package/packages/server/src/process-manager.ts +62 -11
  74. package/packages/server/src/provider-auth-handlers.ts +9 -0
  75. package/packages/server/src/provider-auth-storage.ts +83 -51
  76. package/packages/server/src/provider-catalogue-cache.ts +41 -0
  77. package/packages/server/src/routes/doctor-routes.ts +140 -0
  78. package/packages/server/src/routes/jj-routes.ts +386 -0
  79. package/packages/server/src/routes/provider-auth-routes.ts +9 -0
  80. package/packages/server/src/routes/session-routes.ts +12 -3
  81. package/packages/server/src/routes/system-routes.ts +38 -1
  82. package/packages/server/src/server.ts +16 -9
  83. package/packages/server/src/session-bootstrap.ts +27 -12
  84. package/packages/server/src/session-diff.ts +118 -1
  85. package/packages/server/src/session-discovery.ts +10 -3
  86. package/packages/server/src/session-scanner.ts +4 -2
  87. package/packages/server/src/spawn-failure-log.ts +130 -0
  88. package/packages/server/src/spawn-preflight.ts +82 -0
  89. package/packages/server/src/spawn-register-watchdog.ts +236 -0
  90. package/packages/server/src/terminal-manager.ts +12 -1
  91. package/packages/shared/package.json +1 -1
  92. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
  93. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
  94. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  95. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  96. package/packages/shared/src/__tests__/config.test.ts +48 -0
  97. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  98. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  99. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  100. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  101. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  102. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  103. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  104. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  105. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  106. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  107. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  108. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  109. package/packages/shared/src/__tests__/platform-jj.test.ts +339 -0
  110. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  111. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +18 -2
  112. package/packages/shared/src/bootstrap-install.ts +196 -2
  113. package/packages/shared/src/browser-protocol.ts +112 -1
  114. package/packages/shared/src/config.ts +29 -0
  115. package/packages/shared/src/dashboard-starter.ts +33 -0
  116. package/packages/shared/src/diff-types.ts +17 -0
  117. package/packages/shared/src/doctor-core.ts +821 -0
  118. package/packages/shared/src/index.ts +9 -0
  119. package/packages/shared/src/installable-list.ts +152 -0
  120. package/packages/shared/src/launch-source-flag.ts +14 -0
  121. package/packages/shared/src/launch-source-types.ts +18 -0
  122. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  123. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  124. package/packages/shared/src/platform/jj.ts +405 -0
  125. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  126. package/packages/shared/src/protocol.ts +60 -2
  127. package/packages/shared/src/rest-api.ts +4 -0
  128. package/packages/shared/src/skill-block-parser.ts +115 -0
  129. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  130. package/packages/shared/src/tool-registry/definitions.ts +19 -5
  131. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  132. package/packages/shared/src/types.ts +91 -0
  133. package/packages/extension/src/git-info.ts +0 -55
@@ -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
@@ -145,6 +147,7 @@ export function buildConfig(flags: Partial<ServerConfig>): ServerConfig {
145
147
  maxWsBufferBytes: fileConfig.memoryLimits.maxWsBufferBytes,
146
148
  editor: fileConfig.editor,
147
149
  openspec: fileConfig.openspec,
150
+ reattachPlacement: fileConfig.reattachPlacement,
148
151
  resolvedTrustedNetworks: fileConfig.resolvedTrustedNetworks,
149
152
  corsAllowedOrigins: fileConfig.cors.allowedOrigins,
150
153
  };
@@ -165,6 +168,12 @@ async function runForeground(config: ServerConfig): Promise<void> {
165
168
  assertNodeVersionSupported();
166
169
  const server = await createServer(config);
167
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
+
168
177
  let shuttingDown = false;
169
178
  const shutdown = async () => {
170
179
  if (shuttingDown) {
@@ -180,6 +189,19 @@ async function runForeground(config: ServerConfig): Promise<void> {
180
189
  process.on("SIGINT", shutdown);
181
190
  process.on("SIGTERM", shutdown);
182
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
+
183
205
  await server.start();
184
206
 
185
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[];
@@ -11,11 +11,12 @@ import type { PendingForkRegistry } from "./pending-fork-registry.js";
11
11
  import type { DirectoryService } from "./directory-service.js";
12
12
  import { extractSessionUpdates, isActivityEvent, isUnreadTrigger } from "./event-status-extraction.js";
13
13
  import type { ViewedSessionTracker } from "./viewed-session-tracker.js";
14
+ import { setCatalogueForSession } from "./provider-catalogue-cache.js";
14
15
  import { spawnPiSession } from "./process-manager.js";
15
16
  import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
16
17
  import { writeSessionMeta } from "@blackbelt-technology/pi-dashboard-shared/session-meta.js";
17
18
  import type { DashboardSession } from "@blackbelt-technology/pi-dashboard-shared/types.js";
18
- import { detectOpenSpecActivity } from "@blackbelt-technology/pi-dashboard-shared/openspec-activity-detector.js";
19
+ import { detectOpenSpecActivity, isValidOpenSpecChangeSlug } from "@blackbelt-technology/pi-dashboard-shared/openspec-activity-detector.js";
19
20
  import { extractTurnStats } from "@blackbelt-technology/pi-dashboard-shared/stats-extractor.js";
20
21
  import { attachRenameTarget, isNameAutoSetFromAttachment } from "./proposal-attach-naming.js";
21
22
 
@@ -217,10 +218,19 @@ export function wireEvents(deps: EventWiringDeps): void {
217
218
  // Server-side OpenSpec activity detection from forwarded events
218
219
  // Skip during replay — replayed events from a forked session would set stale phase/change
219
220
  if (msg.event.eventType === "tool_execution_start" && !replayingSessions.has(sessionId)) {
220
- const detected = detectOpenSpecActivity(
221
+ const detectedRaw = detectOpenSpecActivity(
221
222
  msg.event.data.toolName as string,
222
223
  msg.event.data.args as Record<string, unknown> | undefined,
223
224
  );
225
+ // Defense-in-depth (see change: fix-uuid-rename-bug). Even if a future
226
+ // detector regression returns a junk-shaped `changeName` (UUID, mixed
227
+ // case, etc.), refuse to stamp openspecChange / attachedProposal /
228
+ // name. Manual attach paths (browser handler, REST) bypass this and
229
+ // accept any name from a server-curated list.
230
+ const detected =
231
+ detectedRaw && (!detectedRaw.changeName || isValidOpenSpecChangeSlug(detectedRaw.changeName))
232
+ ? detectedRaw
233
+ : null;
224
234
  if (detected) {
225
235
  const session = sessionManager.get(sessionId);
226
236
  const activityUpdates: Partial<DashboardSession> = {};
@@ -582,6 +592,15 @@ export function wireEvents(deps: EventWiringDeps): void {
582
592
  browserGateway.broadcastSessionUpdated(sessionId, gitUpdates);
583
593
  }
584
594
 
595
+ if (msg.type === "jj_state_update") {
596
+ // jjState is intentionally allowed to be `undefined` (no jj) when
597
+ // the bridge sends `null`; the session-manager update applies the
598
+ // value verbatim. See change: add-jj-workspace-plugin.
599
+ const jjUpdates = { jjState: msg.jjState ?? undefined };
600
+ sessionManager.update(sessionId, jjUpdates);
601
+ browserGateway.broadcastSessionUpdated(sessionId, jjUpdates);
602
+ }
603
+
585
604
  if (msg.type === "files_list") {
586
605
  browserGateway.sendToSubscribers(sessionId, {
587
606
  type: "files_list",
@@ -601,6 +620,14 @@ export function wireEvents(deps: EventWiringDeps): void {
601
620
  } as any);
602
621
  }
603
622
 
623
+ if (msg.type === "providers_list") {
624
+ // Cache the bridge-pushed catalogue. Browsers don't subscribe to it
625
+ // directly; they read via GET /api/provider-auth/status.
626
+ // See change: replace-hardcoded-provider-lists.
627
+ setCatalogueForSession(sessionId, msg.providers);
628
+ browserGateway.broadcastToAll({ type: "models_refreshed" } as any);
629
+ }
630
+
604
631
  if (msg.type === "roles_list") {
605
632
  browserGateway.broadcastToAll({
606
633
  type: "roles_list",
@@ -669,6 +696,34 @@ export function wireEvents(deps: EventWiringDeps): void {
669
696
  } as any);
670
697
  }
671
698
 
699
+ // ── Asset register: per-session image asset cache + broadcast ──
700
+ // See change: chat-markdown-local-images-and-math.
701
+ if (msg.type === "asset_register") {
702
+ const { hash, mimeType, data } = msg;
703
+ // Reject malformed messages defensively. The bridge always populates
704
+ // these fields; this guard is purely defense-in-depth so a
705
+ // misbehaving extension cannot inject placeholder asset entries.
706
+ if (typeof hash === "string" && hash.length > 0 &&
707
+ typeof mimeType === "string" && mimeType.length > 0 &&
708
+ typeof data === "string" && data.length > 0) {
709
+ const session = sessionManager.get(sessionId);
710
+ if (session) {
711
+ const next = { ...(session.assets ?? {}) };
712
+ next[hash] = { data, mimeType };
713
+ sessionManager.update(sessionId, { assets: next });
714
+ }
715
+ // Broadcast verbatim regardless of whether the session is known —
716
+ // mirrors the Phase-1 / Phase-2 contract for extension UI messages.
717
+ browserGateway.sendToSubscribers(sessionId, {
718
+ type: "asset_register",
719
+ sessionId,
720
+ hash,
721
+ mimeType,
722
+ data,
723
+ } as any);
724
+ }
725
+ }
726
+
672
727
  // ── Extension UI System (Phase 2): live decorator cache + broadcast ──
673
728
  // See change: add-extension-ui-decorations.
674
729
  if (msg.type === "ext_ui_decorator") {
@@ -0,0 +1,124 @@
1
+ /** Metadata written alongside the lock file. JSON-serialized. */
2
+ export interface LockMetadata {
3
+ pid: number;
4
+ ppid: number;
5
+ httpPort: number;
6
+ piPort: number;
7
+ startedAt: number;
8
+ /** Stable per-instance identifier. Verified against /api/health to detect
9
+ * "port in use by unrelated dashboard or stale process with same pid." */
10
+ identity: string;
11
+ version: string;
12
+ url: string;
13
+ hostname: string;
14
+ }
15
+ /** Result of `acquireOrAttach`. Callers branch on `mode`. */
16
+ export type LockAcquireResult = {
17
+ mode: "acquired";
18
+ meta: LockMetadata;
19
+ /** Release the lock + remove the metadata sidecar. Idempotent. */
20
+ release: () => Promise<void>;
21
+ } | {
22
+ mode: "attach";
23
+ meta: LockMetadata;
24
+ };
25
+ /** Thrown when port is held by an unrelated process. Non-fatal to this
26
+ * module; caller decides (exit with message / retry / override). */
27
+ export declare class InstanceLockMismatchError extends Error {
28
+ readonly meta: LockMetadata;
29
+ readonly observedIdentity: string | null;
30
+ readonly code = "E_INSTANCE_MISMATCH";
31
+ constructor(meta: LockMetadata, observedIdentity: string | null);
32
+ }
33
+ export interface AcquireConfig {
34
+ httpPort: number;
35
+ piPort: number;
36
+ version: string;
37
+ identity?: string;
38
+ /** Injection hooks for tests. Production callers pass no options. */
39
+ hooks?: AcquireHooks;
40
+ }
41
+ export interface AcquireHooks {
42
+ now?: () => number;
43
+ hostname?: () => string;
44
+ lockPath?: string;
45
+ metaPath?: string;
46
+ probeHealth?: (port: number) => Promise<{
47
+ running: boolean;
48
+ pid?: number;
49
+ identity?: string;
50
+ } | null>;
51
+ isProcessAlive?: (pid: number) => boolean;
52
+ /** Stale threshold forwarded to `proper-lockfile`. Default 10s. */
53
+ staleMs?: number;
54
+ }
55
+ /**
56
+ * Canonical HOME directory.
57
+ *
58
+ * Uses `os.userInfo().homedir` in preference to `os.homedir()` because on
59
+ * POSIX the latter honors the `$HOME` environment variable (Node docs say:
60
+ * "On POSIX, it uses the `$HOME` environment variable if defined"), which
61
+ * the design (§4) explicitly prohibits — a GUI-launched process and a
62
+ * shell-launched process would otherwise disagree on "where HOME is".
63
+ * `userInfo().homedir` consults `getpwuid(3)` on POSIX, immune to `$HOME`.
64
+ *
65
+ * On Windows, both APIs ultimately use `USERPROFILE`, so the Git Bash
66
+ * drift case (`$HOME=/c/Users/R` vs `USERPROFILE=C:\Users\R`) is handled
67
+ * either way; keeping `userInfo().homedir` first is still correct.
68
+ *
69
+ * Result is then passed through `fs.realpathSync` to collapse symlinks,
70
+ * FileVault migrations, and other canonicalization drift. Tolerant: falls
71
+ * back to the raw path if realpath fails.
72
+ */
73
+ export declare function canonicalHomedir(): string;
74
+ /**
75
+ * Lock file path. This is what `proper-lockfile` locks.
76
+ */
77
+ export declare function getLockPath(homedir?: string): string;
78
+ /**
79
+ * Metadata sidecar path (`<lockPath>.meta.json`).
80
+ */
81
+ export declare function getMetaPath(lockPath?: string): string;
82
+ /**
83
+ * Atomically write the metadata sidecar via tmp + rename.
84
+ * Never leaves a partial file visible.
85
+ */
86
+ export declare function writeMetadataAtomic(meta: LockMetadata, metaPath?: string): void;
87
+ /**
88
+ * Read the metadata sidecar. Returns null on any failure (missing, corrupt,
89
+ * permission-denied). Callers MUST treat null as "assume stale."
90
+ */
91
+ export declare function readMetadata(metaPath?: string): LockMetadata | null;
92
+ /**
93
+ * Remove the metadata sidecar. Silent on any error (missing is fine).
94
+ */
95
+ export declare function removeMetadata(metaPath?: string): void;
96
+ /**
97
+ * Determine if the recorded lock holder is a responsive, identity-matching
98
+ * dashboard. Returns:
99
+ * - `"alive-match"`: attach to it
100
+ * - `"alive-mismatch"`: someone else is on that port
101
+ * - `"dead"`: treat as stale, proceed to acquire
102
+ */
103
+ export declare function isLockHolderResponsive(meta: LockMetadata, hooks?: Pick<AcquireHooks, "probeHealth" | "isProcessAlive">): Promise<"alive-match" | "alive-mismatch" | "dead">;
104
+ /**
105
+ * Acquire the per-HOME lock, or fall back to attach semantics if a live
106
+ * dashboard already holds it.
107
+ *
108
+ * Flow:
109
+ * 1. Ensure `~/.pi/dashboard/` exists (proper-lockfile requires parent).
110
+ * 2. `proper-lockfile.lock(path, { stale, retries: 0 })`
111
+ * ↪ on success: write metadata, return { mode: "acquired", release }
112
+ * ↪ on ELOCKED: read metadata, check liveness
113
+ * - dead: steal via `proper-lockfile.lock({ realpath:false, stale: 0 })`
114
+ * (Note: proper-lockfile already does stale-stealing when
115
+ * `stale` is configured — we just retry once.)
116
+ * - alive-match: return { mode: "attach", meta }
117
+ * - alive-mismatch: throw InstanceLockMismatchError
118
+ */
119
+ export declare function acquireOrAttach(config: AcquireConfig): Promise<LockAcquireResult>;
120
+ /**
121
+ * True when the user has opted out of the per-HOME lock. Caller should
122
+ * log a warning and skip acquireOrAttach when set.
123
+ */
124
+ export declare function isLockDisabled(env?: NodeJS.ProcessEnv): boolean;