@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.
- package/AGENTS.md +339 -190
- package/README.md +50 -7
- package/docs/architecture.md +238 -23
- package/package.json +23 -13
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
- package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +68 -0
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
- package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
- package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
- package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
- package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
- package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
- package/packages/extension/src/ask-user-tool.ts +1 -1
- package/packages/extension/src/bridge-context.ts +1 -1
- package/packages/extension/src/bridge.ts +169 -4
- package/packages/extension/src/command-handler.ts +65 -2
- package/packages/extension/src/flow-event-wiring.ts +1 -1
- package/packages/extension/src/markdown-image-inliner.ts +268 -0
- package/packages/extension/src/multiselect-list.ts +1 -1
- package/packages/extension/src/pi-env.d.ts +16 -9
- package/packages/extension/src/prompt-expander.ts +50 -2
- package/packages/extension/src/provider-register.ts +132 -8
- package/packages/extension/src/retry-tracker.ts +123 -0
- package/packages/extension/src/server-launcher.ts +18 -1
- package/packages/extension/src/session-sync.ts +15 -1
- package/packages/extension/src/usage-limit-orderer.ts +76 -0
- package/packages/server/package.json +6 -6
- package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
- package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
- package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
- package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
- package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
- package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
- package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +22 -4
- package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service.test.ts +1 -1
- package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +154 -0
- package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
- package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
- package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +83 -0
- package/packages/server/src/__tests__/health-shape.test.ts +43 -0
- package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
- package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
- package/packages/server/src/__tests__/package-routes.test.ts +1 -1
- package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
- package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
- package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +236 -0
- package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
- package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
- package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
- package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +44 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +1 -1
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
- package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
- package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
- package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
- package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +250 -0
- package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
- package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
- package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
- package/packages/server/src/bootstrap-install-from-list.ts +232 -0
- package/packages/server/src/bootstrap-state.ts +18 -0
- package/packages/server/src/browser-gateway.ts +70 -24
- package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
- package/packages/server/src/browser-handlers/handler-context.ts +9 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +159 -18
- package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
- package/packages/server/src/changelog-fs.ts +167 -0
- package/packages/server/src/changelog-parser.ts +321 -0
- package/packages/server/src/changelog-remote.ts +134 -0
- package/packages/server/src/cli.ts +23 -2
- package/packages/server/src/directory-service.ts +31 -0
- package/packages/server/src/event-wiring.ts +105 -5
- package/packages/server/src/headless-pid-registry.ts +54 -5
- package/packages/server/src/home-lock.d.ts +124 -0
- package/packages/server/src/home-lock.js +330 -0
- package/packages/server/src/home-lock.js.map +1 -0
- package/packages/server/src/idle-timer.ts +15 -1
- package/packages/server/src/pending-client-correlations.ts +73 -0
- package/packages/server/src/pending-fork-registry.ts +24 -12
- package/packages/server/src/pi-core-checker.ts +77 -17
- package/packages/server/src/pi-core-updater.ts +81 -15
- package/packages/server/src/pi-dev-version-check.ts +145 -0
- package/packages/server/src/pi-gateway.ts +10 -0
- package/packages/server/src/pi-version-skew.ts +12 -4
- package/packages/server/src/process-manager.ts +115 -21
- package/packages/server/src/provider-auth-handlers.ts +9 -0
- package/packages/server/src/provider-auth-storage.ts +83 -51
- package/packages/server/src/provider-catalogue-cache.ts +47 -0
- package/packages/server/src/routes/doctor-routes.ts +140 -0
- package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
- package/packages/server/src/routes/pi-core-routes.ts +1 -1
- package/packages/server/src/routes/provider-auth-routes.ts +14 -1
- package/packages/server/src/routes/provider-routes.ts +4 -4
- package/packages/server/src/routes/system-routes.ts +38 -1
- package/packages/server/src/server.ts +85 -66
- package/packages/server/src/session-api.ts +54 -3
- package/packages/server/src/session-bootstrap.ts +27 -12
- package/packages/server/src/session-discovery.ts +11 -4
- package/packages/server/src/session-file-reader.ts +1 -1
- package/packages/server/src/session-scanner.ts +4 -2
- package/packages/server/src/spawn-failure-log.ts +130 -0
- package/packages/server/src/spawn-preflight.ts +82 -0
- package/packages/server/src/spawn-register-watchdog.ts +291 -0
- package/packages/server/src/spawn-token.ts +20 -0
- package/packages/server/src/terminal-manager.ts +12 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +25 -17
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +6 -3
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
- package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
- package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
- package/packages/shared/src/__tests__/config.test.ts +48 -0
- package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
- package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
- package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
- package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
- package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
- package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
- package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
- package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
- package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
- package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
- package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
- package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +140 -9
- package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
- package/packages/shared/src/bootstrap-install.ts +197 -3
- package/packages/shared/src/browser-protocol.ts +155 -1
- package/packages/shared/src/changelog-types.ts +111 -0
- package/packages/shared/src/config.ts +15 -0
- package/packages/shared/src/dashboard-starter.ts +33 -0
- package/packages/shared/src/doctor-core.ts +821 -0
- package/packages/shared/src/index.ts +9 -0
- package/packages/shared/src/installable-list.ts +152 -0
- package/packages/shared/src/launch-source-flag.ts +14 -0
- package/packages/shared/src/launch-source-types.ts +18 -0
- package/packages/shared/src/openspec-activity-detector.ts +25 -7
- package/packages/shared/src/platform/detached-spawn.ts +13 -2
- package/packages/shared/src/platform/managed-node-path.ts +77 -0
- package/packages/shared/src/platform/node-spawn.ts +29 -21
- package/packages/shared/src/protocol.ts +54 -2
- package/packages/shared/src/resolve-jiti.ts +62 -9
- package/packages/shared/src/rest-api.ts +4 -0
- package/packages/shared/src/skill-block-parser.ts +115 -0
- package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
- package/packages/shared/src/tool-registry/definitions.ts +33 -8
- package/packages/shared/src/tool-registry/strategies.ts +42 -0
- 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 "@
|
|
8
|
-
import { Loader } from "@
|
|
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")
|
|
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 "@
|
|
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("@
|
|
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 "@
|
|
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 `` markdown image
|
|
5
|
+
* tokens, reads any local-path file references, hashes the bytes, and
|
|
6
|
+
* rewrites the token to ``. 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 `` markdown image token. */
|
|
39
|
+
const IMAGE_TOKEN_RE = /!\[([^\]\n]*)\]\(([^)\n\s]+)\)/g;
|
|
40
|
+
|
|
41
|
+
export interface ParsedImageToken {
|
|
42
|
+
/** The full original `` 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 `` token in `text`. Partial tokens
|
|
53
|
+
* (e.g. ``) 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 `` (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(``);
|
|
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(``);
|
|
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
|
-
* `@
|
|
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
|
*/
|