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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/AGENTS.md +339 -190
  2. package/README.md +50 -7
  3. package/docs/architecture.md +238 -23
  4. package/package.json +23 -13
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
  7. package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +68 -0
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
  13. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  14. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  15. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  16. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  17. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  18. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  19. package/packages/extension/src/ask-user-tool.ts +1 -1
  20. package/packages/extension/src/bridge-context.ts +1 -1
  21. package/packages/extension/src/bridge.ts +169 -4
  22. package/packages/extension/src/command-handler.ts +65 -2
  23. package/packages/extension/src/flow-event-wiring.ts +1 -1
  24. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  25. package/packages/extension/src/multiselect-list.ts +1 -1
  26. package/packages/extension/src/pi-env.d.ts +16 -9
  27. package/packages/extension/src/prompt-expander.ts +50 -2
  28. package/packages/extension/src/provider-register.ts +132 -8
  29. package/packages/extension/src/retry-tracker.ts +123 -0
  30. package/packages/extension/src/server-launcher.ts +18 -1
  31. package/packages/extension/src/session-sync.ts +15 -1
  32. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  33. package/packages/server/package.json +6 -6
  34. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  35. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  36. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  37. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  38. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  39. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  40. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  41. package/packages/server/src/__tests__/cli-parse.test.ts +22 -4
  42. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  43. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  44. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  45. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  46. package/packages/server/src/__tests__/directory-service.test.ts +1 -1
  47. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  48. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +154 -0
  49. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  50. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  51. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  52. package/packages/server/src/__tests__/headless-pid-registry.test.ts +83 -0
  53. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  54. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  55. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  56. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  57. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  58. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  59. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  60. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  61. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  62. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  63. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +236 -0
  64. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  65. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  66. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  67. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  68. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  69. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  70. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  71. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +44 -0
  72. package/packages/server/src/__tests__/recommended-routes.test.ts +1 -1
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  74. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  75. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  76. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  77. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  78. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  79. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +250 -0
  80. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  81. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  82. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  83. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  84. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  85. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  86. package/packages/server/src/bootstrap-state.ts +18 -0
  87. package/packages/server/src/browser-gateway.ts +70 -24
  88. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  89. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  90. package/packages/server/src/browser-handlers/session-action-handler.ts +159 -18
  91. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  92. package/packages/server/src/changelog-fs.ts +167 -0
  93. package/packages/server/src/changelog-parser.ts +321 -0
  94. package/packages/server/src/changelog-remote.ts +134 -0
  95. package/packages/server/src/cli.ts +23 -2
  96. package/packages/server/src/directory-service.ts +31 -0
  97. package/packages/server/src/event-wiring.ts +105 -5
  98. package/packages/server/src/headless-pid-registry.ts +54 -5
  99. package/packages/server/src/home-lock.d.ts +124 -0
  100. package/packages/server/src/home-lock.js +330 -0
  101. package/packages/server/src/home-lock.js.map +1 -0
  102. package/packages/server/src/idle-timer.ts +15 -1
  103. package/packages/server/src/pending-client-correlations.ts +73 -0
  104. package/packages/server/src/pending-fork-registry.ts +24 -12
  105. package/packages/server/src/pi-core-checker.ts +77 -17
  106. package/packages/server/src/pi-core-updater.ts +81 -15
  107. package/packages/server/src/pi-dev-version-check.ts +145 -0
  108. package/packages/server/src/pi-gateway.ts +10 -0
  109. package/packages/server/src/pi-version-skew.ts +12 -4
  110. package/packages/server/src/process-manager.ts +115 -21
  111. package/packages/server/src/provider-auth-handlers.ts +9 -0
  112. package/packages/server/src/provider-auth-storage.ts +83 -51
  113. package/packages/server/src/provider-catalogue-cache.ts +47 -0
  114. package/packages/server/src/routes/doctor-routes.ts +140 -0
  115. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  116. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  117. package/packages/server/src/routes/provider-auth-routes.ts +14 -1
  118. package/packages/server/src/routes/provider-routes.ts +4 -4
  119. package/packages/server/src/routes/system-routes.ts +38 -1
  120. package/packages/server/src/server.ts +85 -66
  121. package/packages/server/src/session-api.ts +54 -3
  122. package/packages/server/src/session-bootstrap.ts +27 -12
  123. package/packages/server/src/session-discovery.ts +11 -4
  124. package/packages/server/src/session-file-reader.ts +1 -1
  125. package/packages/server/src/session-scanner.ts +4 -2
  126. package/packages/server/src/spawn-failure-log.ts +130 -0
  127. package/packages/server/src/spawn-preflight.ts +82 -0
  128. package/packages/server/src/spawn-register-watchdog.ts +291 -0
  129. package/packages/server/src/spawn-token.ts +20 -0
  130. package/packages/server/src/terminal-manager.ts +12 -1
  131. package/packages/shared/package.json +1 -1
  132. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +25 -17
  133. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  134. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  135. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  136. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  137. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  138. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +6 -3
  139. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  140. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  141. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  142. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  143. package/packages/shared/src/__tests__/config.test.ts +48 -0
  144. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  145. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  146. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  147. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  148. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  149. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  150. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  151. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  152. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  153. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  154. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  155. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  156. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  157. package/packages/shared/src/__tests__/resolve-jiti.test.ts +140 -9
  158. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  159. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  160. package/packages/shared/src/bootstrap-install.ts +197 -3
  161. package/packages/shared/src/browser-protocol.ts +155 -1
  162. package/packages/shared/src/changelog-types.ts +111 -0
  163. package/packages/shared/src/config.ts +15 -0
  164. package/packages/shared/src/dashboard-starter.ts +33 -0
  165. package/packages/shared/src/doctor-core.ts +821 -0
  166. package/packages/shared/src/index.ts +9 -0
  167. package/packages/shared/src/installable-list.ts +152 -0
  168. package/packages/shared/src/launch-source-flag.ts +14 -0
  169. package/packages/shared/src/launch-source-types.ts +18 -0
  170. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  171. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  172. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  173. package/packages/shared/src/platform/node-spawn.ts +29 -21
  174. package/packages/shared/src/protocol.ts +54 -2
  175. package/packages/shared/src/resolve-jiti.ts +62 -9
  176. package/packages/shared/src/rest-api.ts +4 -0
  177. package/packages/shared/src/skill-block-parser.ts +115 -0
  178. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  179. package/packages/shared/src/tool-registry/definitions.ts +33 -8
  180. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  181. package/packages/shared/src/types.ts +64 -0
@@ -4,12 +4,14 @@
4
4
  * Global extension that connects to the dashboard server,
5
5
  * forwards all pi events, and relays commands back.
6
6
  */
7
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
- import { Loader } from "@mariozechner/pi-tui";
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import { Loader } from "@earendil-works/pi-tui";
9
9
  import { ConnectionManager } from "./connection.js";
10
10
  import { detectSessionSource } from "./source-detector.js";
11
11
  import { mapEventToProtocol } from "./event-forwarder.js";
12
12
  import { createCommandHandler } from "./command-handler.js";
13
+ import { RetryTracker } from "./retry-tracker.js";
14
+ import { UsageLimitOrderer } from "./usage-limit-orderer.js";
13
15
  import fs from "node:fs";
14
16
  import os from "node:os";
15
17
  import path from "node:path";
@@ -27,7 +29,7 @@ import { PromptBus } from "./prompt-bus.js";
27
29
  import { DashboardDefaultAdapter } from "./dashboard-default-adapter.js";
28
30
  import { registerAskUserTool } from "./ask-user-tool.js";
29
31
  import { decodeMultiselectAnswer } from "./multiselect-decode.js";
30
- import { activate as activateProviderRegister, onProviderChanged, reloadProviders } from "./provider-register.js";
32
+ import { activate as activateProviderRegister, onProviderChanged, reloadProviders, buildProviderCatalogue } from "./provider-register.js";
31
33
  import type { FlowInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
32
34
  import { startMetricsMonitor, stopMetricsMonitor, collectMetrics } from "./process-metrics.js";
33
35
  import { scanChildProcesses } from "./process-scanner.js";
@@ -37,6 +39,7 @@ import { sendStateSync as _sendStateSync, replaySessionEntries as _replaySession
37
39
  import { sendModelUpdateIfChanged as _sendModelUpdateIfChanged, sendSessionNameIfChanged as _sendSessionNameIfChanged, sendGitInfoIfChanged as _sendGitInfoIfChanged, sendJjStateIfChanged as _sendJjStateIfChanged, resetReconnectCaches as _resetReconnectCaches } from "./model-tracker.js";
38
40
  import { registerFlowEventListeners, FLOW_EVENT_MAP, SUBAGENT_EVENT_MAP } from "./flow-event-wiring.js";
39
41
  import { refreshUiModules, subscribeUiInvalidate, handleUiManagement, type UiModulesBridgeCtx } from "./ui-modules.js";
42
+ import { inlineMessageText, type ReadFileOutcome } from "./markdown-image-inliner.js";
40
43
 
41
44
  const HEARTBEAT_INTERVAL = 15_000;
42
45
  const GIT_POLL_INTERVAL = 30_000;
@@ -190,6 +193,22 @@ function initBridge(pi: ExtensionAPI) {
190
193
  let hasRegisteredOnce = false; // see change: reattach-move-to-front
191
194
  let promptBus: PromptBus | undefined;
192
195
 
196
+ // Provider-retry synthesis trackers. pi's ExtensionAPI does not expose
197
+ // `auto_retry_*` events, so the bridge synthesizes them from observed
198
+ // `message_end` / `agent_end` events. See change: fix-provider-retry-infinite-loop.
199
+ const retryTracker = new RetryTracker();
200
+ const usageLimitOrderer = new UsageLimitOrderer();
201
+
202
+ /** Forward a synthesized auto_retry_* event using the standard event_forward shape. */
203
+ const sendSyntheticRetryEvent = (eventType: string, data: Record<string, unknown>): void => {
204
+ if (!isActive() || !sessionReady) return;
205
+ connection.send({
206
+ type: "event_forward",
207
+ sessionId,
208
+ event: { eventType, timestamp: Date.now(), data },
209
+ });
210
+ };
211
+
193
212
  // ── Per-message entry id tracking (for fix-per-message-fork) ──
194
213
  // Pi 0.69+ awaits extension handlers BEFORE sessionManager.appendMessage runs,
195
214
  // which means getLeafId() at emit time returns the previous leaf, not the
@@ -209,6 +228,96 @@ function initBridge(pi: ExtensionAPI) {
209
228
  let appendMessageWrapped = false;
210
229
  let lastWrappedSm: any = null;
211
230
 
231
+ // ---------------------------------------------------------------------
232
+ // Markdown-image inliner state (chat-markdown-local-images-and-math).
233
+ // Per-sessionId set of asset hashes for which an `asset_register` has
234
+ // already been emitted on this WebSocket. Survives across message events
235
+ // within the same session; reset when the session id changes (in
236
+ // session_start). The Map keys are sessionId strings.
237
+ // ---------------------------------------------------------------------
238
+ const emittedAssetHashesBySession = new Map<string, Set<string>>();
239
+ function getEmittedAssetHashes(sid: string): Set<string> {
240
+ let s = emittedAssetHashesBySession.get(sid);
241
+ if (!s) {
242
+ s = new Set<string>();
243
+ emittedAssetHashesBySession.set(sid, s);
244
+ }
245
+ return s;
246
+ }
247
+
248
+ /**
249
+ * Synchronous fs probe + read for the inliner. Wraps `fs.statSync` /
250
+ * `fs.readFileSync` and maps Node errno strings to the
251
+ * `ReadFileOutcome.kind` enum used by the pure inliner. Order: stat
252
+ * first so directories report EISDIR even when the path has no file
253
+ * extension.
254
+ */
255
+ function inlinerReadFile(absolutePath: string): ReadFileOutcome {
256
+ try {
257
+ const st = fs.statSync(absolutePath);
258
+ if (st.isDirectory()) return { ok: false, kind: "EISDIR" };
259
+ if (!st.isFile()) return { ok: false, kind: "EOTHER" };
260
+ const bytes = fs.readFileSync(absolutePath);
261
+ return { ok: true, bytes };
262
+ } catch (err: any) {
263
+ const code = err?.code;
264
+ if (code === "ENOENT") return { ok: false, kind: "ENOENT" };
265
+ if (code === "EACCES") return { ok: false, kind: "EACCES" };
266
+ if (code === "EISDIR") return { ok: false, kind: "EISDIR" };
267
+ return { ok: false, kind: "EOTHER" };
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Apply the markdown-image inliner to an assistant message_update /
273
+ * message_end event. Mutates `event.message.content` in place (string
274
+ * → rewritten string; array<{type:"text",text}> → rewritten text in
275
+ * each text block). Emits `asset_register` messages BEFORE returning so
276
+ * the caller's subsequent `connection.send(eventForward)` lands AFTER
277
+ * the assets it references. User-role and thinking events are no-ops.
278
+ */
279
+ function maybeInlineAssistantImages(event: any): void {
280
+ const msg = event?.message;
281
+ if (!msg || typeof msg !== "object") return;
282
+ if (msg.role !== "assistant") return;
283
+ // Use the *current* live cwd if available; fall back to the bridge
284
+ // process cwd. The inliner resolves relative `./pic.png` against this.
285
+ const cwd = (cachedCtx?.cwd as string | undefined) ?? process.cwd();
286
+ const alreadyEmitted = getEmittedAssetHashes(sessionId);
287
+ const allAssets: { hash: string; mimeType: string; data: string }[] = [];
288
+
289
+ const rewriteOne = (text: string): string => {
290
+ const r = inlineMessageText(text, {
291
+ readFile: inlinerReadFile,
292
+ cwd,
293
+ alreadyEmitted,
294
+ });
295
+ for (const a of r.assetsToEmit) allAssets.push(a);
296
+ return r.rewritten;
297
+ };
298
+
299
+ if (typeof msg.content === "string") {
300
+ msg.content = rewriteOne(msg.content);
301
+ } else if (Array.isArray(msg.content)) {
302
+ for (const block of msg.content) {
303
+ if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") {
304
+ block.text = rewriteOne(block.text);
305
+ }
306
+ }
307
+ }
308
+
309
+ // Send each new asset BEFORE the (rewritten) message event lands.
310
+ for (const a of allAssets) {
311
+ connection.send({
312
+ type: "asset_register",
313
+ sessionId,
314
+ hash: a.hash,
315
+ mimeType: a.mimeType,
316
+ data: a.data,
317
+ });
318
+ }
319
+ }
320
+
212
321
  /**
213
322
  * Wrap ctx.sessionManager.appendMessage once per session so that when pi
214
323
  * generates an entry id we capture it in the WeakMap and emit
@@ -330,6 +439,8 @@ function initBridge(pi: ExtensionAPI) {
330
439
  id: m.id,
331
440
  }));
332
441
  connection.send({ type: "models_list", sessionId, models });
442
+ // See change: replace-hardcoded-provider-lists.
443
+ connection.send({ type: "providers_list", sessionId, providers: buildProviderCatalogue() });
333
444
  } catch (err) { console.error("[dashboard] models_list push failed:", err); }
334
445
  }
335
446
  return;
@@ -555,6 +666,14 @@ function initBridge(pi: ExtensionAPI) {
555
666
  if (cachedCtx?.abort) {
556
667
  cachedCtx.abort();
557
668
  }
669
+ // Clear retry-synthesis trackers — the user-initiated abort path
670
+ // already synthesizes its own auto_retry_end via command-handler.
671
+ // See change: fix-provider-retry-infinite-loop.
672
+ retryTracker.noteAbort(sessionId);
673
+ usageLimitOrderer.noteRetryEnd(sessionId);
674
+ },
675
+ isIdle: () => {
676
+ try { return cachedCtx?.isIdle?.() ?? false; } catch { return false; }
558
677
  },
559
678
  eventSink: (msg) => connection.send(msg),
560
679
  compact: (opts) => {
@@ -709,7 +828,25 @@ function initBridge(pi: ExtensionAPI) {
709
828
  if (!sessionReady) return;
710
829
  // Track agent streaming state (survives reconnect/reload)
711
830
  if (eventType === "agent_start") getBridgeState().isAgentStreaming = true;
712
- if (eventType === "agent_end") getBridgeState().isAgentStreaming = false;
831
+ if (eventType === "agent_end") {
832
+ getBridgeState().isAgentStreaming = false;
833
+ // Provider-retry synthesis: forward auto_retry_end BEFORE agent_end
834
+ // when retries were in flight, so the dashboard's retry banner
835
+ // clears before the error banner appears. The usage-limit orderer
836
+ // takes precedence (it carries the actual error string); the retry
837
+ // tracker handles the non-usage-limit case. See change:
838
+ // fix-provider-retry-infinite-loop.
839
+ const orderedSynth = usageLimitOrderer.maybeSynthesize(sessionId, (event as any));
840
+ if (orderedSynth) {
841
+ sendSyntheticRetryEvent(orderedSynth.eventType, orderedSynth.data);
842
+ retryTracker.noteAbort(sessionId); // clear tracker; orderer's event is authoritative
843
+ } else {
844
+ const trackerSynth = retryTracker.observeAgentEnd(sessionId, event as any);
845
+ if (trackerSynth) {
846
+ sendSyntheticRetryEvent(trackerSynth.eventType, trackerSynth.data);
847
+ }
848
+ }
849
+ }
713
850
  // For model_select, enrich the event data with thinkingLevel
714
851
  if (eventType === "model_select") {
715
852
  const enriched = { ...event, thinkingLevel: (pi as any).getThinkingLevel?.() };
@@ -765,6 +902,11 @@ function initBridge(pi: ExtensionAPI) {
765
902
  if (messageRef && typeof messageRef === "object" && !pendingNonces.has(messageRef as object)) {
766
903
  pendingNonces.set(messageRef as object, nonce);
767
904
  }
905
+ // Apply markdown image inliner to assistant content. Mutates
906
+ // event.message.content in place AND ships any new asset_register
907
+ // messages immediately so they precede the deferred message_end
908
+ // send below. See change: chat-markdown-local-images-and-math.
909
+ maybeInlineAssistantImages(event);
768
910
  setTimeout(() => {
769
911
  if (!isActive() || !sessionReady) return;
770
912
  const entryId =
@@ -774,10 +916,29 @@ function initBridge(pi: ExtensionAPI) {
774
916
  const enriched = { ...event, entryId, nonce };
775
917
  const protoMsg = mapEventToProtocol(sessionId, enriched);
776
918
  connection.send(protoMsg);
919
+ // After forwarding the original message_end, ask the retry tracker
920
+ // whether to synthesize an auto_retry_* event. See change:
921
+ // fix-provider-retry-infinite-loop.
922
+ const synthetic = retryTracker.observeMessageEnd(sessionId, messageRef as any);
923
+ if (synthetic) {
924
+ sendSyntheticRetryEvent(synthetic.eventType, synthetic.data);
925
+ if (synthetic.eventType === "auto_retry_start") {
926
+ usageLimitOrderer.noteRetryStart(sessionId);
927
+ } else {
928
+ usageLimitOrderer.noteRetryEnd(sessionId);
929
+ }
930
+ }
777
931
  }, 0);
778
932
  return;
779
933
  }
780
934
 
935
+ // Apply markdown image inliner to assistant message_update events.
936
+ // For other event types this is a no-op (role check inside the helper).
937
+ // See change: chat-markdown-local-images-and-math.
938
+ if (eventType === "message_update") {
939
+ maybeInlineAssistantImages(event);
940
+ }
941
+
781
942
  const msg = mapEventToProtocol(sessionId, event);
782
943
  connection.send(msg);
783
944
  }));
@@ -1155,6 +1316,8 @@ function initBridge(pi: ExtensionAPI) {
1155
1316
  id: m.id,
1156
1317
  }));
1157
1318
  connection.send({ type: "models_list", sessionId, models });
1319
+ // See change: replace-hardcoded-provider-lists.
1320
+ connection.send({ type: "providers_list", sessionId, providers: buildProviderCatalogue() });
1158
1321
  } catch { /* modelRegistry not available */ }
1159
1322
  }
1160
1323
 
@@ -1374,6 +1537,8 @@ function initBridge(pi: ExtensionAPI) {
1374
1537
  id: m.id,
1375
1538
  }));
1376
1539
  connection.send({ type: "models_list", sessionId, models });
1540
+ // See change: replace-hardcoded-provider-lists.
1541
+ connection.send({ type: "providers_list", sessionId, providers: buildProviderCatalogue() });
1377
1542
  } catch { /* ignore */ }
1378
1543
 
1379
1544
  // Retry pending default model — custom provider may now have its models
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { readdirSync } from "node:fs";
5
5
  import { join, relative } from "node:path";
6
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
7
  import type {
8
8
  ServerToExtensionMessage,
9
9
  ExtensionToServerMessage,
@@ -12,6 +12,7 @@ import { killProcessByPgid } from "./process-scanner.js";
12
12
  import type { FileEntry, PiSessionInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
13
13
  import { filterHiddenCommands } from "./bridge-context.js";
14
14
  import { expandPromptTemplateFromDisk } from "./prompt-expander.js";
15
+ import { buildProviderCatalogue } from "./provider-register.js";
15
16
 
16
17
  const IGNORE_DIRS = new Set([".git", "node_modules", ".next", "dist", "build", ".cache", "__pycache__", ".venv"]);
17
18
  const MAX_RESULTS = 20;
@@ -144,6 +145,11 @@ export function createCommandHandler(
144
145
  getThinkingLevel?: () => string | undefined;
145
146
  shutdown?: () => void;
146
147
  abort?: () => void;
148
+ /**
149
+ * Probe agent idleness for the persistent-abort scheduler.
150
+ * See change: fix-provider-retry-infinite-loop.
151
+ */
152
+ isIdle?: () => boolean;
147
153
  getCwd?: () => string;
148
154
  /** Callback to send events (e.g., bash_output, command_feedback) back to server */
149
155
  eventSink?: (msg: ExtensionToServerMessage) => void;
@@ -160,6 +166,33 @@ export function createCommandHandler(
160
166
  },
161
167
  ): CommandHandler {
162
168
  const getSessionId = typeof sessionIdOrGetter === "function" ? sessionIdOrGetter : () => sessionIdOrGetter;
169
+
170
+ /**
171
+ * Persistent-abort scheduler. Re-invokes `options.abort()` at 200ms
172
+ * intervals for up to 2 seconds, breaking early when `options.isIdle()`
173
+ * returns true. Closes the retry race window in pi-coding-agent.
174
+ * See change: fix-provider-retry-infinite-loop.
175
+ */
176
+ const PERSISTENT_ABORT_INTERVAL_MS = 200;
177
+ const PERSISTENT_ABORT_MAX_MS = 2000;
178
+ function schedulePersistentAbort(opts: NonNullable<typeof options>): void {
179
+ if (!opts.abort) return;
180
+ const startedAt = Date.now();
181
+ const interval = setInterval(() => {
182
+ if (Date.now() - startedAt >= PERSISTENT_ABORT_MAX_MS) {
183
+ clearInterval(interval);
184
+ return;
185
+ }
186
+ try {
187
+ if (opts.isIdle?.()) {
188
+ clearInterval(interval);
189
+ return;
190
+ }
191
+ } catch { /* probe failure — keep trying */ }
192
+ try { opts.abort?.(); } catch { /* idempotent */ }
193
+ }, PERSISTENT_ABORT_INTERVAL_MS);
194
+ }
195
+
163
196
  return {
164
197
  async handle(msg: ServerToExtensionMessage): Promise<ExtensionToServerMessage | undefined> {
165
198
  const sessionId = getSessionId();
@@ -291,6 +324,31 @@ export function createCommandHandler(
291
324
  if (options?.abort) {
292
325
  options.abort();
293
326
  }
327
+ // Synthesize an immediate auto_retry_end so the dashboard clears
328
+ // any in-flight retry banner without waiting for pi's natural
329
+ // auto_retry_end (which is delayed by the abortable-sleep cancel
330
+ // window AND, on extension API, never reaches us at all — see
331
+ // https://github.com/badlogic/pi-mono/discussions/2073). The
332
+ // reducer no-ops auto_retry_end when retryState is undefined,
333
+ // so this is idempotent against later events.
334
+ if (options?.eventSink) {
335
+ options.eventSink({
336
+ type: "event_forward",
337
+ sessionId,
338
+ event: {
339
+ eventType: "auto_retry_end",
340
+ timestamp: Date.now(),
341
+ data: { success: false, attempt: -1, finalError: "Aborted by user" },
342
+ },
343
+ });
344
+ }
345
+ // Persistent-abort scheduler: pi-coding-agent's _retryAbortController
346
+ // is briefly `undefined` between sleep-end and the next
347
+ // agent.continue() call. An abort that arrives in that window is
348
+ // a no-op against the retry. Re-invoke abort every 200ms for up
349
+ // to 2s, breaking early when the agent is idle.
350
+ // See change: fix-provider-retry-infinite-loop.
351
+ if (options) schedulePersistentAbort(options);
294
352
  return undefined;
295
353
 
296
354
  case "request_commands": {
@@ -344,6 +402,11 @@ export function createCommandHandler(
344
402
  return { type: "models_list", sessionId, models: [] };
345
403
  }
346
404
 
405
+ case "request_providers": {
406
+ // See change: replace-hardcoded-provider-lists.
407
+ return { type: "providers_list", sessionId, providers: buildProviderCatalogue() };
408
+ }
409
+
347
410
  case "set_thinking_level":
348
411
  if (options?.setThinkingLevel) {
349
412
  options.setThinkingLevel(msg.level);
@@ -388,7 +451,7 @@ export function createCommandHandler(
388
451
  case "list_sessions": {
389
452
  try {
390
453
  // Dynamic import to avoid hard dependency at module load
391
- const { SessionManager } = await import("@mariozechner/pi-coding-agent") as any;
454
+ const { SessionManager } = await import("@earendil-works/pi-coding-agent") as any;
392
455
  const cwd = msg.cwd || options?.getCwd?.() || process.cwd();
393
456
  const sessionInfos = await SessionManager.list(cwd);
394
457
  const sessions: PiSessionInfo[] = (sessionInfos || []).map((s: any) => ({
@@ -2,7 +2,7 @@
2
2
  * Flow event wiring: registers listeners for pi-flows events
3
3
  * and forwards them as protocol messages to the dashboard server.
4
4
  */
5
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import type { BridgeContext } from "./bridge-context.js";
7
7
  import { filterHiddenCommands } from "./bridge-context.js";
8
8
  import type { FlowInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Markdown image inliner for the bridge.
3
+ *
4
+ * Scans assistant message text for fully-closed `![alt](src)` markdown image
5
+ * tokens, reads any local-path file references, hashes the bytes, and
6
+ * rewrites the token to `![alt](pi-asset:<hash>)`. Bytes ride out of band
7
+ * via `asset_register` events (one per unique hash per session). The text
8
+ * itself only ever carries the short `pi-asset:<hash>` token, keeping
9
+ * streaming `message_update` events bandwidth-bounded.
10
+ *
11
+ * The core helper `inlineMessageText` is **pure** — all I/O is delegated to
12
+ * the injected `readFile` callback so tests can drive every branch with
13
+ * memory fixtures. The bridge wires `readFile = node:fs.readFileSync` plus
14
+ * a per-session `alreadyEmitted: Set<string>` of hashes already shipped.
15
+ *
16
+ * See change: chat-markdown-local-images-and-math.
17
+ */
18
+ import { createHash } from "node:crypto";
19
+ import path from "node:path";
20
+
21
+ /** Per-image hard cap (decision D8). */
22
+ export const MAX_PER_IMAGE_BYTES = 5 * 1024 * 1024;
23
+ /** Per-message cumulative cap on **newly-inlined** asset bytes (decision D8). */
24
+ export const MAX_PER_MESSAGE_BYTES = 20 * 1024 * 1024;
25
+
26
+ /** MIME allowlist keyed by lowercased extension. Decision D8. */
27
+ const MIME_BY_EXT: Record<string, string> = {
28
+ ".png": "image/png",
29
+ ".jpg": "image/jpeg",
30
+ ".jpeg": "image/jpeg",
31
+ ".gif": "image/gif",
32
+ ".webp": "image/webp",
33
+ ".svg": "image/svg+xml",
34
+ ".avif": "image/avif",
35
+ ".bmp": "image/bmp",
36
+ };
37
+
38
+ /** Matches a fully-closed `![alt](src)` markdown image token. */
39
+ const IMAGE_TOKEN_RE = /!\[([^\]\n]*)\]\(([^)\n\s]+)\)/g;
40
+
41
+ export interface ParsedImageToken {
42
+ /** The full original `![alt](src)` substring. */
43
+ token: string;
44
+ alt: string;
45
+ src: string;
46
+ /** Start offset within the input text. */
47
+ index: number;
48
+ length: number;
49
+ }
50
+
51
+ /**
52
+ * Find every fully-closed `![alt](src)` token in `text`. Partial tokens
53
+ * (e.g. `![alt](/path/x` without closing `)`) are NOT returned — the
54
+ * regex requires the closing paren. Tokens spanning newlines are NOT
55
+ * returned (markdown doesn't allow newlines inside the URL portion).
56
+ */
57
+ export function parseImageTokens(text: string): ParsedImageToken[] {
58
+ const out: ParsedImageToken[] = [];
59
+ let match: RegExpExecArray | null;
60
+ IMAGE_TOKEN_RE.lastIndex = 0;
61
+ while ((match = IMAGE_TOKEN_RE.exec(text)) !== null) {
62
+ out.push({
63
+ token: match[0],
64
+ alt: match[1] ?? "",
65
+ src: match[2] ?? "",
66
+ index: match.index,
67
+ length: match[0].length,
68
+ });
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /**
74
+ * Returns true iff `src` looks like a local filesystem path (i.e. NOT an
75
+ * already-resolved web URL, data URL, blob URL, fragment, or pre-rewritten
76
+ * `pi-asset:<hash>` token). Idempotency hinges on `pi-asset:` returning
77
+ * `false` here.
78
+ */
79
+ export function isLocalSrc(src: string): boolean {
80
+ if (!src) return false;
81
+ if (src.startsWith("data:")) return false;
82
+ if (src.startsWith("blob:")) return false;
83
+ if (src.startsWith("http://")) return false;
84
+ if (src.startsWith("https://")) return false;
85
+ if (src.startsWith("pi-asset:")) return false;
86
+ if (src.startsWith("#")) return false;
87
+ // `file://` prefix — treat the rest as a local path.
88
+ return true;
89
+ }
90
+
91
+ /**
92
+ * Resolve `src` to an absolute path against `cwd`. `file://` prefix is
93
+ * stripped. Absolute paths pass through. Relative paths resolve against
94
+ * `cwd`.
95
+ */
96
+ export function resolveLocalPath(src: string, cwd: string): string {
97
+ let raw = src;
98
+ if (raw.startsWith("file://")) raw = raw.slice("file://".length);
99
+ if (path.isAbsolute(raw)) return raw;
100
+ return path.resolve(cwd, raw);
101
+ }
102
+
103
+ /**
104
+ * Detect MIME from the file extension (case-insensitive). Returns null
105
+ * if the extension is not in the image allowlist.
106
+ */
107
+ export function mimeFromExtension(filePath: string): string | null {
108
+ const ext = path.extname(filePath).toLowerCase();
109
+ return MIME_BY_EXT[ext] ?? null;
110
+ }
111
+
112
+ /**
113
+ * Hash file bytes to a 16-hex-char identifier (sha256 truncated). Decision D4.
114
+ */
115
+ export function hashBytes(buf: Buffer): string {
116
+ return createHash("sha256").update(buf).digest("hex").slice(0, 16);
117
+ }
118
+
119
+ /** Format a byte count to a one-decimal MB string for placeholder text. */
120
+ function formatMB(bytes: number): string {
121
+ return (bytes / (1024 * 1024)).toFixed(1);
122
+ }
123
+
124
+ /** Bridge-side per-file read result; thin enough to mock in tests. */
125
+ export interface ReadFileResult {
126
+ ok: true;
127
+ bytes: Buffer;
128
+ }
129
+ export interface ReadFileError {
130
+ ok: false;
131
+ /** Use ENOENT/EACCES/EISDIR/EOTHER. EACCES is folded into ENOENT in placeholder text. */
132
+ kind: "ENOENT" | "EACCES" | "EISDIR" | "EOTHER";
133
+ }
134
+ export type ReadFileOutcome = ReadFileResult | ReadFileError;
135
+
136
+ export interface InlineOptions {
137
+ /** Synchronous file-read callback. Bridge wires this to `node:fs.readFileSync` + `fs.statSync`. */
138
+ readFile: (absolutePath: string) => ReadFileOutcome;
139
+ /** Working directory used to resolve relative srcs. */
140
+ cwd: string;
141
+ /**
142
+ * Per-session set of hashes for which an `asset_register` has already been
143
+ * emitted. The inliner checks-then-adds so dedup is automatic across
144
+ * multiple message events within the same session.
145
+ */
146
+ alreadyEmitted: Set<string>;
147
+ /** Override the per-image cap. Default `MAX_PER_IMAGE_BYTES`. */
148
+ maxPerImageBytes?: number;
149
+ /** Override the per-message cap. Default `MAX_PER_MESSAGE_BYTES`. */
150
+ maxPerMessageBytes?: number;
151
+ }
152
+
153
+ export interface AssetToEmit {
154
+ hash: string;
155
+ mimeType: string;
156
+ /** Base64-encoded bytes ready for the `asset_register` message. */
157
+ data: string;
158
+ }
159
+
160
+ export interface InlineResult {
161
+ /** The rewritten text with every applicable token replaced. */
162
+ rewritten: string;
163
+ /** Newly-discovered assets to emit BEFORE the message_update / message_end. */
164
+ assetsToEmit: AssetToEmit[];
165
+ }
166
+
167
+ /**
168
+ * Pure inliner. Scans `text` for image tokens; rewrites local-path tokens
169
+ * either to `![alt](pi-asset:<hash>)` (success) or to a visible placeholder
170
+ * text (file too large / unsupported MIME / read error / message budget
171
+ * exhausted). Tokens with web/data/blob/pi-asset/# srcs pass through
172
+ * unchanged. Idempotent — re-running on already-rewritten text yields the
173
+ * same output (because `pi-asset:` returns `false` from `isLocalSrc`).
174
+ */
175
+ export function inlineMessageText(text: string, opts: InlineOptions): InlineResult {
176
+ const tokens = parseImageTokens(text);
177
+ if (tokens.length === 0) {
178
+ return { rewritten: text, assetsToEmit: [] };
179
+ }
180
+
181
+ const maxPerImage = opts.maxPerImageBytes ?? MAX_PER_IMAGE_BYTES;
182
+ const maxPerMessage = opts.maxPerMessageBytes ?? MAX_PER_MESSAGE_BYTES;
183
+
184
+ const assetsToEmit: AssetToEmit[] = [];
185
+ let bytesInThisMessage = 0;
186
+
187
+ // Build the rewritten string by stitching segments separated by token
188
+ // replacements. Walk tokens left-to-right; tokens that pass through
189
+ // unchanged keep their original substring.
190
+ const out: string[] = [];
191
+ let cursor = 0;
192
+
193
+ for (const tok of tokens) {
194
+ // Append the segment before this token verbatim.
195
+ out.push(text.slice(cursor, tok.index));
196
+ cursor = tok.index + tok.length;
197
+
198
+ if (!isLocalSrc(tok.src)) {
199
+ // External / data: / pi-asset: / fragment — pass through unchanged.
200
+ out.push(tok.token);
201
+ continue;
202
+ }
203
+
204
+ const absPath = resolveLocalPath(tok.src, opts.cwd);
205
+
206
+ // Order matters here:
207
+ // 1. readFile FIRST so EISDIR / ENOENT / EACCES are reported with
208
+ // their proper placeholders even when the path has no extension
209
+ // (e.g. `/home/me` resolving to a directory).
210
+ // 2. mimeFromExtension after a successful read so an existing file
211
+ // with a non-image extension reports "unsupported image type"
212
+ // rather than a generic read failure.
213
+ // 3. hashBytes so we can consult `alreadyEmitted` BEFORE the
214
+ // per-image and per-message caps. Already-registered assets bypass
215
+ // caps because their bytes were paid for on the previous emission.
216
+ // 4. Per-image cap and per-message budget gate ONLY new emissions.
217
+ const outcome = opts.readFile(absPath);
218
+ if (!outcome.ok) {
219
+ // EACCES is folded into ENOENT placeholder to avoid leaking permission
220
+ // existence. EISDIR / EOTHER use the generic "read failed" wording.
221
+ if (outcome.kind === "ENOENT" || outcome.kind === "EACCES") {
222
+ out.push(`[image not found: ${tok.src}]`);
223
+ } else {
224
+ out.push(`[image read failed: ${tok.src}]`);
225
+ }
226
+ continue;
227
+ }
228
+
229
+ const mime = mimeFromExtension(absPath);
230
+ if (!mime) {
231
+ out.push(`[unsupported image type: ${tok.src}]`);
232
+ continue;
233
+ }
234
+
235
+ const hash = hashBytes(outcome.bytes);
236
+
237
+ if (opts.alreadyEmitted.has(hash)) {
238
+ // Bytes already shipped earlier in the session — only the token rewrites.
239
+ // Caps are bypassed: dedup means no new bytes go on the wire.
240
+ out.push(`![${tok.alt}](pi-asset:${hash})`);
241
+ continue;
242
+ }
243
+
244
+ const size = outcome.bytes.length;
245
+ if (size > maxPerImage) {
246
+ out.push(`[image too large: ${tok.src} (${formatMB(size)} MB)]`);
247
+ continue;
248
+ }
249
+
250
+ // New asset. Check the per-message budget before committing.
251
+ if (bytesInThisMessage + size > maxPerMessage) {
252
+ out.push(`[message asset budget exhausted: ${tok.src}]`);
253
+ continue;
254
+ }
255
+
256
+ bytesInThisMessage += size;
257
+ opts.alreadyEmitted.add(hash);
258
+ assetsToEmit.push({
259
+ hash,
260
+ mimeType: mime,
261
+ data: outcome.bytes.toString("base64"),
262
+ });
263
+ out.push(`![${tok.alt}](pi-asset:${hash})`);
264
+ }
265
+
266
+ out.push(text.slice(cursor));
267
+ return { rewritten: out.join(""), assetsToEmit };
268
+ }
@@ -23,7 +23,7 @@ interface Item {
23
23
 
24
24
  /**
25
25
  * Minimal shape of pi-tui's `Component` interface — we avoid importing from
26
- * `@mariozechner/pi-tui` directly so this module stays compile-friendly when
26
+ * `@earendil-works/pi-tui` directly so this module stays compile-friendly when
27
27
  * that peer dep isn't present (e.g. in unit tests running via vitest without
28
28
  * the full pi runtime).
29
29
  */