@narumitw/pi-image-drop 0.18.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.
package/src/runtime.ts ADDED
@@ -0,0 +1,417 @@
1
+ import { basename } from "node:path";
2
+ import type { ImageContent } from "@earendil-works/pi-ai";
3
+ import type {
4
+ ExtensionAPI,
5
+ ExtensionContext,
6
+ InputEvent,
7
+ InputEventResult,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { BatchError, BatchStore, digestImages, type ProcessedImage } from "./batch.js";
10
+ import { ImageProcessor } from "./images.js";
11
+ import { readEffectivePiImageSettings } from "./pi-settings.js";
12
+ import { ImageDropServer, type ImageDropServerOptions } from "./server.js";
13
+ import { type ImageDropSettings, loadSettings } from "./settings.js";
14
+
15
+ const WIDGET_KEY = "image-drop";
16
+
17
+ type LatestEventHandler = (event: unknown, ctx: ExtensionContext) => void | Promise<void>;
18
+ type LatestExtensionAPI = ExtensionAPI & {
19
+ on(event: "agent_settled", handler: LatestEventHandler): void;
20
+ };
21
+
22
+ type ServerControl = Pick<ImageDropServer, "issueLink" | "broadcastState" | "close">;
23
+ type ProcessorControl = Pick<ImageProcessor, "process">;
24
+
25
+ export interface RuntimeDependencies {
26
+ loadSettings: typeof loadSettings;
27
+ readPiSettings: typeof readEffectivePiImageSettings;
28
+ startServer(options: ImageDropServerOptions): Promise<ServerControl>;
29
+ createProcessor(): ProcessorControl;
30
+ }
31
+
32
+ const DEFAULT_DEPENDENCIES: RuntimeDependencies = {
33
+ loadSettings,
34
+ readPiSettings: readEffectivePiImageSettings,
35
+ startServer: (options) => ImageDropServer.start(options),
36
+ createProcessor: () => new ImageProcessor(2),
37
+ };
38
+
39
+ export class ImageDropRuntime {
40
+ private readonly dependencies: RuntimeDependencies;
41
+ private batch?: BatchStore;
42
+ private settings?: ImageDropSettings;
43
+ private context?: ExtensionContext;
44
+ private server?: ServerControl;
45
+ private serverStarting?: Promise<ServerControl>;
46
+ private processor?: ProcessorControl;
47
+ private sessionAbort = new AbortController();
48
+ private generation = 0;
49
+ private closed = true;
50
+ private lastPiSettingsWarning = "";
51
+
52
+ constructor(
53
+ private readonly pi: ExtensionAPI,
54
+ dependencies: Partial<RuntimeDependencies> = {},
55
+ ) {
56
+ this.dependencies = { ...DEFAULT_DEPENDENCIES, ...dependencies };
57
+ }
58
+
59
+ register(): void {
60
+ this.pi.registerCommand("image-drop", {
61
+ description: "Show a local browser link for staging images",
62
+ handler: async (_args, ctx) => {
63
+ this.context = ctx;
64
+ await this.recoverOrphanedReservation(ctx);
65
+ try {
66
+ const server = await this.ensureServer(ctx);
67
+ const link = server.issueLink();
68
+ if (this.batch?.publicState().phase === "empty") {
69
+ ctx.ui.setWidget(WIDGET_KEY, [`🖼️ Image Drop: ${link}`]);
70
+ } else {
71
+ this.updateWidget(ctx);
72
+ }
73
+ ctx.ui.notify(`Image Drop: ${link}`, "info");
74
+ } catch (error) {
75
+ ctx.ui.notify(`Image Drop could not start: ${formatError(error)}`, "error");
76
+ }
77
+ },
78
+ });
79
+
80
+ this.pi.on("session_start", async (_event, ctx) => this.start(ctx));
81
+ this.pi.on("session_shutdown", async (_event, ctx) => this.shutdown(ctx));
82
+ this.pi.on("input", async (event, ctx) => this.handleInput(event, ctx));
83
+ this.pi.on("before_agent_start", async () => this.batch?.markPreflightStarted());
84
+ this.pi.on("message_start", async (event, ctx) => this.handleMessageStart(event, ctx));
85
+ (this.pi as LatestExtensionAPI).on("agent_settled", async (_event, ctx) => {
86
+ await this.recoverReservation(ctx, "Queued image message was not delivered; restored it.");
87
+ });
88
+ }
89
+
90
+ async start(ctx: ExtensionContext): Promise<void> {
91
+ this.generation += 1;
92
+ this.closed = true;
93
+ this.sessionAbort.abort();
94
+ await this.releaseServer();
95
+ this.batch?.close();
96
+ const result = await this.dependencies.loadSettings();
97
+ this.settings = result.settings;
98
+ this.batch = new BatchStore(result.settings);
99
+ this.processor = this.dependencies.createProcessor();
100
+ this.sessionAbort = new AbortController();
101
+ this.context = ctx;
102
+ this.closed = false;
103
+ this.lastPiSettingsWarning = "";
104
+ const warning = "warning" in result ? result.warning : undefined;
105
+ if (result.kind === "invalid" || warning) {
106
+ ctx.ui.notify(warning ?? "Image Drop settings ignored.", "warning");
107
+ }
108
+ this.updateWidget(ctx);
109
+ }
110
+
111
+ async shutdown(ctx: ExtensionContext): Promise<void> {
112
+ this.generation += 1;
113
+ this.closed = true;
114
+ this.sessionAbort.abort();
115
+ await this.releaseServer();
116
+ this.batch?.close();
117
+ this.batch = undefined;
118
+ this.settings = undefined;
119
+ this.processor = undefined;
120
+ this.context = undefined;
121
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
122
+ }
123
+
124
+ getBatchForTesting(): BatchStore | undefined {
125
+ return this.batch;
126
+ }
127
+
128
+ async handleInput(event: InputEvent, ctx: ExtensionContext): Promise<InputEventResult> {
129
+ this.context = ctx;
130
+ if (this.closed || event.source !== "interactive" || !event.text.trim() || !this.batch) {
131
+ return { action: "continue" };
132
+ }
133
+ if (this.batch.currentReservation()) {
134
+ await this.recoverOrphanedReservation(ctx);
135
+ if (this.batch.currentReservation()) return { action: "continue" };
136
+ // The current input arrived at the recovery boundary. Preserve it alongside the
137
+ // restored text and require an explicit resubmission rather than consuming it.
138
+ this.restoreEditor(ctx, event.text);
139
+ return { action: "handled" };
140
+ }
141
+ const state = this.batch.publicState();
142
+ if (state.phase === "empty") return { action: "continue" };
143
+ if (state.phase !== "ready") {
144
+ this.restoreEditor(ctx, event.text);
145
+ ctx.ui.notify(this.blockedReason(state.phase), "warning");
146
+ return { action: "handled" };
147
+ }
148
+ if (!supportsImages(ctx)) {
149
+ this.restoreEditor(ctx, event.text);
150
+ ctx.ui.notify("The current model does not support image input.", "warning");
151
+ return { action: "handled" };
152
+ }
153
+ const piSettings = await this.dependencies.readPiSettings(ctx.cwd, ctx.isProjectTrusted());
154
+ this.notifyPiSettingsWarnings(ctx, piSettings.warnings);
155
+ if (piSettings.blockImages) {
156
+ this.restoreEditor(ctx, event.text);
157
+ ctx.ui.notify("Pi image sending is disabled. Enable images in /settings first.", "warning");
158
+ return { action: "handled" };
159
+ }
160
+ if (!(await this.reprocessForAutoResize(piSettings.autoResize, ctx, event.text))) {
161
+ return { action: "handled" };
162
+ }
163
+
164
+ try {
165
+ const reservation = this.batch.reserveMessage(event.text, event.streamingBehavior);
166
+ this.server?.broadcastState();
167
+ this.updateWidget(ctx);
168
+ return {
169
+ action: "transform",
170
+ text: event.text,
171
+ images: [...(event.images ?? []), ...reservation.images],
172
+ };
173
+ } catch (error) {
174
+ this.restoreEditor(ctx, event.text);
175
+ ctx.ui.notify(formatError(error), "warning");
176
+ return { action: "handled" };
177
+ }
178
+ }
179
+
180
+ addReadyImageForTesting(
181
+ id: string,
182
+ name: string,
183
+ source: Buffer,
184
+ processed: ProcessedImage,
185
+ ): void {
186
+ if (!this.batch) throw new Error("Runtime has not started");
187
+ this.batch.reserveItems([{ id, name, size: source.byteLength }]);
188
+ this.batch.startProcessing(id, source);
189
+ this.batch.complete(id, processed, true);
190
+ this.server?.broadcastState();
191
+ if (this.context) this.updateWidget(this.context);
192
+ }
193
+
194
+ private async reprocessForAutoResize(
195
+ autoResize: boolean,
196
+ ctx: ExtensionContext,
197
+ text: string,
198
+ ): Promise<boolean> {
199
+ const batch = this.batch;
200
+ const processor = this.processor;
201
+ const settings = this.settings;
202
+ if (!batch || !processor || !settings) return false;
203
+ let jobs: Array<{ id: string; source: Buffer }>;
204
+ try {
205
+ jobs = batch.beginAutoResizeReprocessing(autoResize);
206
+ } catch (error) {
207
+ this.restoreEditor(ctx, text);
208
+ ctx.ui.notify(formatError(error), "warning");
209
+ return false;
210
+ }
211
+ if (jobs.length === 0) return true;
212
+ const generation = this.generation;
213
+ const signal = this.sessionAbort.signal;
214
+ this.server?.broadcastState();
215
+ this.updateWidget(ctx);
216
+ await Promise.all(
217
+ jobs.map(async ({ id, source }) => {
218
+ try {
219
+ const processed = await processor.process(source, {
220
+ autoResize,
221
+ maxImagePixels: settings.maxImagePixels,
222
+ signal,
223
+ });
224
+ if (generation === this.generation) batch.complete(id, processed, autoResize);
225
+ } catch (error) {
226
+ if (generation !== this.generation || signal.aborted) return;
227
+ try {
228
+ batch.fail(id, formatError(error));
229
+ } catch (failure) {
230
+ if (!(failure instanceof BatchError) || failure.code !== "not-found") throw failure;
231
+ }
232
+ } finally {
233
+ this.server?.broadcastState();
234
+ }
235
+ }),
236
+ );
237
+ if (generation !== this.generation || batch.publicState().phase !== "ready") {
238
+ this.restoreEditor(ctx, text);
239
+ if (generation === this.generation) {
240
+ ctx.ui.notify(
241
+ "Images could not be updated for the current auto-resize setting.",
242
+ "warning",
243
+ );
244
+ this.updateWidget(ctx);
245
+ }
246
+ return false;
247
+ }
248
+ this.updateWidget(ctx);
249
+ return true;
250
+ }
251
+
252
+ private async ensureServer(ctx: ExtensionContext): Promise<ServerControl> {
253
+ if (this.closed || !this.batch || !this.settings || !this.processor) {
254
+ throw new Error("the Pi session is not ready");
255
+ }
256
+ if (this.server) return this.server;
257
+ if (!this.serverStarting) {
258
+ const generation = this.generation;
259
+ const processor = this.processor;
260
+ const starting = this.dependencies.startServer({
261
+ batch: this.batch,
262
+ settings: this.settings,
263
+ projectName: basename(ctx.cwd) || ctx.cwd,
264
+ sessionName: ctx.sessionManager.getSessionName(),
265
+ cwd: ctx.cwd,
266
+ process: (source, options) => processor.process(source, options),
267
+ getAutoResize: () => this.processingSettings(),
268
+ onStateChange: () => {
269
+ if (generation === this.generation && this.context) this.updateWidget(this.context);
270
+ },
271
+ });
272
+ this.serverStarting = starting.then(async (server) => {
273
+ if (generation !== this.generation || this.closed) {
274
+ await server.close();
275
+ throw new Error("the Pi session changed while the server was starting");
276
+ }
277
+ this.server = server;
278
+ return server;
279
+ });
280
+ }
281
+ const starting = this.serverStarting;
282
+ try {
283
+ return await starting;
284
+ } finally {
285
+ if (this.serverStarting === starting) this.serverStarting = undefined;
286
+ }
287
+ }
288
+
289
+ private async processingSettings(): Promise<boolean> {
290
+ const ctx = this.context;
291
+ if (!ctx || this.closed) throw new Error("The Pi session has ended.");
292
+ if (!supportsImages(ctx)) throw new Error("The current model does not support image input.");
293
+ const settings = await this.dependencies.readPiSettings(ctx.cwd, ctx.isProjectTrusted());
294
+ this.notifyPiSettingsWarnings(ctx, settings.warnings);
295
+ if (settings.blockImages) {
296
+ throw new Error("Pi image sending is disabled. Enable images in /settings first.");
297
+ }
298
+ return settings.autoResize;
299
+ }
300
+
301
+ private async releaseServer(): Promise<void> {
302
+ const server = this.server;
303
+ this.server = undefined;
304
+ if (server) await server.close();
305
+ const starting = this.serverStarting;
306
+ this.serverStarting = undefined;
307
+ if (starting) {
308
+ try {
309
+ await (await starting).close();
310
+ } catch {
311
+ // A failed or stale startup has no live server left to release.
312
+ }
313
+ }
314
+ }
315
+
316
+ private async handleMessageStart(event: unknown, ctx: ExtensionContext): Promise<void> {
317
+ this.context = ctx;
318
+ const reservation = this.batch?.currentReservation();
319
+ if (!reservation) return;
320
+ const images = userMessageImages(event);
321
+ if (images.length < reservation.images.length) return;
322
+ const attached = images.slice(-reservation.images.length);
323
+ if (digestImages(attached) !== reservation.digest) return;
324
+ this.batch?.commitReservation(reservation.digest);
325
+ this.server?.broadcastState();
326
+ this.updateWidget(ctx);
327
+ }
328
+
329
+ private async recoverOrphanedReservation(ctx: ExtensionContext): Promise<void> {
330
+ if (!this.batch?.currentReservation()) return;
331
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
332
+ await this.recoverReservation(ctx, "Restored an image message that did not start.");
333
+ }
334
+
335
+ private async recoverReservation(ctx: ExtensionContext, notice: string): Promise<void> {
336
+ const restored = this.batch?.restoreReservation();
337
+ if (!restored) return;
338
+ this.restoreEditor(ctx, restored.text);
339
+ this.server?.broadcastState();
340
+ this.updateWidget(ctx);
341
+ ctx.ui.notify(notice, "warning");
342
+ }
343
+
344
+ private restoreEditor(ctx: ExtensionContext, text: string): void {
345
+ try {
346
+ const current = ctx.ui.getEditorText();
347
+ const restored = !current.trim() || current === text ? text : `${current}\n\n${text}`;
348
+ ctx.ui.setEditorText(restored);
349
+ } catch {
350
+ // Session replacement can invalidate a captured UI context; state cleanup still proceeds.
351
+ }
352
+ }
353
+
354
+ private updateWidget(ctx: ExtensionContext): void {
355
+ const state = this.batch?.publicState();
356
+ if (!state || state.phase === "empty" || state.phase === "closed") {
357
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
358
+ return;
359
+ }
360
+ const ready = state.items.filter((item) => item.status === "ready").length;
361
+ const uploading = state.items.filter(
362
+ (item) => item.status === "uploading" || item.status === "processing",
363
+ ).length;
364
+ const errors = state.items.filter((item) => item.status === "error").length;
365
+ let text = `🖼️ ${ready}/${state.items.length} images ready`;
366
+ if (uploading > 0) text += ` · ${uploading} uploading`;
367
+ if (errors > 0) text += ` · ${errors} need attention`;
368
+ if (state.phase === "reserved") text = `🖼️ ${state.items.length} images queued`;
369
+ ctx.ui.setWidget(WIDGET_KEY, [text]);
370
+ }
371
+
372
+ private notifyPiSettingsWarnings(ctx: ExtensionContext, warnings: string[]): void {
373
+ const message = warnings.join("\n");
374
+ if (!message) {
375
+ this.lastPiSettingsWarning = "";
376
+ return;
377
+ }
378
+ if (message === this.lastPiSettingsWarning) return;
379
+ this.lastPiSettingsWarning = message;
380
+ ctx.ui.notify(message, "warning");
381
+ }
382
+
383
+ private blockedReason(phase: string): string {
384
+ return phase === "blocked"
385
+ ? "Resolve or delete failed images before sending."
386
+ : "Wait for every image to finish uploading before sending.";
387
+ }
388
+ }
389
+
390
+ function supportsImages(ctx: ExtensionContext): boolean {
391
+ return ctx.model?.input.includes("image") ?? false;
392
+ }
393
+
394
+ function userMessageImages(event: unknown): ImageContent[] {
395
+ if (!isRecord(event) || !isRecord(event.message) || event.message.role !== "user") return [];
396
+ const content = event.message.content;
397
+ if (!Array.isArray(content)) return [];
398
+ return content.filter(isImageContent);
399
+ }
400
+
401
+ function isImageContent(value: unknown): value is ImageContent {
402
+ return (
403
+ isRecord(value) &&
404
+ value.type === "image" &&
405
+ typeof value.data === "string" &&
406
+ typeof value.mimeType === "string"
407
+ );
408
+ }
409
+
410
+ function isRecord(value: unknown): value is Record<string, unknown> {
411
+ return typeof value === "object" && value !== null && !Array.isArray(value);
412
+ }
413
+
414
+ function formatError(error: unknown): string {
415
+ if (error instanceof BatchError || error instanceof Error) return error.message;
416
+ return String(error);
417
+ }