@pi-archimedes/image-paste 2.12.0 → 2.14.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/README.md CHANGED
@@ -18,13 +18,13 @@ Or the full suite instead:
18
18
  pi install npm:pi-archimedes
19
19
  ```
20
20
 
21
- New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0. If you need Pi, [its quickstart](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/quickstart.md) starts with `npm install -g --ignore-scripts @earendil-works/pi-coding-agent`. After installing Pi, choose one installation command above, then `cd` into your project, run `pi`, and use `/login` and `/model` inside the session. The [setup section](https://github.com/danielcherubini/pi-archimedes#setup) covers it end to end. `/reload` picks the extension up in a running session.
21
+ New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0. If you need Pi, [its quickstart](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/quickstart.md) starts with `npm install -g --ignore-scripts @earendil-works/pi-coding-agent`. After installing Pi, choose one installation command above, then `cd` into your project, run `pi`, and use `/login` and `/model` inside the session. The [setup section](https://github.com/archimedes-codes/pi-archimedes#setup) covers it end to end. `/reload` picks the extension up in a running session.
22
22
 
23
23
  ## How it works
24
24
 
25
25
  - **Markers on paste** — pasting inserts a visible `[Image #1]` placeholder at the cursor; each paste gets the next number.
26
26
  - **Attachment on submit** — when you send the message, your text is scanned for markers and the matching queued images are attached to it. Delete a marker and its image is not attached — the text is the source of truth.
27
- - **Inline preview** — a preview of the attached images is sent and rendered in the TUI. Previews appear where your terminal renders inline images; on a terminal that can't show them, the image is still attached — only the preview falls back.
27
+ - **Inline preview** — a preview of the attached images is sent and rendered in the TUI. Previews appear where your terminal renders inline images; on a terminal that can't show them, the image is still attached — only the preview falls back. Turn it off with `/archimedes` → Image Paste Preview, or `archimedes.imagePaste.preview: false`; images still attach.
28
28
  - **Same key, wider scope** — the paste key is Pi's own (`app.clipboard.pasteImage`), and taking it means covering everything it did, so with no image on the clipboard the handler falls back to what Pi does: copied **file paths** are inserted at the cursor with Pi's own spacing, and otherwise the clipboard **text** is inserted. One keypress produces one outcome, in that order: image, then paths, then text. Nothing on the clipboard leaves the draft untouched and says nothing, exactly as Pi does.
29
29
  - **Size guard** — 20 MiB **per image**, rejected with a clear message before it wastes context or hangs the submit.
30
30
  - **Bounded reads** — every clipboard read has a deadline (the same budget Pi gives its own clipboard commands), so a helper that stops answering — an X11/Wayland session that went unresponsive, say — reports `Clipboard paste failed: … timed out` instead of silently taking the paste key out of service for the rest of the session.
@@ -66,12 +66,13 @@ New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0.
66
66
  |---------|------|---------|-------------|
67
67
  | `enabled` | bool | `true` | On/off for the extension, via `/plugins` (suite-managed) |
68
68
  | `suppressNativePaste` | bool | `true` | `false` gives the paste key back to Pi: the suite's `app.clipboard.pasteImage` entry is removed from `~/.pi/agent/keybindings.json` (only that entry, only if the file holds nothing else but the suite's own `"$schema"` line) **and** no paste shortcut is registered. It also keeps the first-run offer silent — there is nothing to unbind — without spending `keybindingsPromptDone`, so turning it back on still brings the offer. You get Pi's paste and lose image attach / queue / preview on those keys. Applies at the next `/new` or `/reload`. Anything that is not the strict boolean `false` — `"false"`, `0` — keeps the default. |
69
+ | `preview` | bool | `true` | `false` stops the inline preview of attached images on submit. Images still attach. Also in `/archimedes` → Image Paste Preview. Read on every submit, so no `/reload` needed. Anything that is not the strict boolean `false` keeps the default. |
69
70
  | `keybindingsPromptDone` | bool | `false` | Set true once the first-run keybindings.json offer has been answered (accept or decline); delete to re-open the offer |
70
71
 
71
72
  In the suite on/off is managed by the suite: toggle via `/plugins` (`archimedes.imagePaste.enabled`, default on).
72
73
 
73
74
  ## Part of the suite
74
75
 
75
- In [pi-archimedes](https://github.com/danielcherubini/pi-archimedes), image-paste works alongside the framed editor, the status bar, and the todo board; on/off is managed by the suite (`/plugins`, `archimedes.imagePaste.enabled`, default on).
76
+ In [pi-archimedes](https://github.com/archimedes-codes/pi-archimedes), image-paste works alongside the framed editor, the status bar, and the todo board; on/off is managed by the suite (`/plugins`, `archimedes.imagePaste.enabled`, default on).
76
77
 
77
- ← [Back to pi-archimedes](https://github.com/danielcherubini/pi-archimedes)
78
+ ← [Back to pi-archimedes](https://github.com/archimedes-codes/pi-archimedes)
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@pi-archimedes/image-paste",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "repository": {
5
5
  "type": "git",
6
- "url": "https://github.com/danielcherubini/pi-archimedes.git"
6
+ "url": "https://github.com/archimedes-codes/pi-archimedes.git"
7
7
  },
8
8
  "type": "module",
9
9
  "keywords": [
@@ -16,10 +16,11 @@
16
16
  "main": "./src/index.ts",
17
17
  "exports": {
18
18
  ".": "./src/index.ts",
19
- "./keybinding-offer": "./src/keybinding-offer.ts"
19
+ "./keybinding-offer": "./src/keybinding-offer.ts",
20
+ "./settings": "./src/settings.ts"
20
21
  },
21
22
  "dependencies": {
22
- "@pi-archimedes/core": "2.12.0"
23
+ "@pi-archimedes/core": "2.14.0"
23
24
  },
24
25
  "peerDependencies": {
25
26
  "@earendil-works/pi-coding-agent": ">=0.86.0",
@@ -11,10 +11,92 @@ vi.mock("./keybinding-offer.js", () => ({
11
11
  suiteMayClaimPasteKey: vi.fn(() => true),
12
12
  }));
13
13
 
14
+ // The isolation case below drives a real paste through the standalone handlers,
15
+ // so the clipboard readers are mocked (no shelling out) and the settings read is
16
+ // pointed at a scratch dir — `core/settings-io` captures
17
+ // `join(getAgentDir(), "settings.json")` at module load.
18
+ const { tempDir, fs, join, mockReadClipboardImage } = vi.hoisted(() => {
19
+ const fs = require("node:fs");
20
+ const { join } = require("node:path");
21
+ const { tmpdir } = require("node:os");
22
+ const { randomUUID } = require("node:crypto");
23
+ const dir = join(tmpdir(), `image-paste-default-export-${randomUUID()}`);
24
+ fs.mkdirSync(dir, { recursive: true });
25
+ return { tempDir: dir, fs, join, mockReadClipboardImage: vi.fn() };
26
+ });
27
+
28
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
29
+ getAgentDir: () => tempDir,
30
+ convertToPng: () => undefined,
31
+ }));
32
+
33
+ vi.mock("./clipboard.js", () => ({
34
+ readClipboardImage: mockReadClipboardImage,
35
+ readClipboardFilePaths: vi.fn(async () => null),
36
+ readClipboardText: vi.fn(async () => null),
37
+ buildPathInsertion: vi.fn(),
38
+ clipboardTextForEditor: vi.fn(),
39
+ }));
40
+
14
41
  const { runNativePasteRestore } = await import("./keybinding-offer.js");
15
42
  const defaultExport = (await import("./index.js")).default;
16
43
 
44
+ type Handler = (...args: unknown[]) => unknown;
45
+
46
+ interface SubmitResult {
47
+ action: string;
48
+ images?: unknown[];
49
+ }
50
+
51
+ /**
52
+ * One standalone session: a factory run of its own, with the handlers that run
53
+ * handed to pi. pi caches the extension MODULE and re-invokes the factory per
54
+ * session, so two of these are two sessions in one process (#156).
55
+ */
56
+ function standaloneSession() {
57
+ const inserted: string[] = [];
58
+ const on = vi.fn();
59
+ const registerShortcut = vi.fn();
60
+ const pi = {
61
+ on,
62
+ registerShortcut,
63
+ registerMessageRenderer: vi.fn(),
64
+ sendMessage: vi.fn(),
65
+ } as any;
66
+ defaultExport(pi);
67
+
68
+ const ctx = {
69
+ hasUI: true,
70
+ ui: { notify: vi.fn(), pasteToEditor: (text: string) => inserted.push(text), getEditorText: () => "" },
71
+ };
72
+ const start = on.mock.calls.find((c: Array<unknown>) => c[0] === "session_start")?.[1] as Handler;
73
+ const shutdown = on.mock.calls.find((c: Array<unknown>) => c[0] === "session_shutdown")?.[1] as Handler;
74
+ const input = on.mock.calls.find((c: Array<unknown>) => c[0] === "input")?.[1] as Handler;
75
+ const press = registerShortcut.mock.calls[0]![1].handler as Handler;
76
+
77
+ return {
78
+ inserted,
79
+ /** Fire this session's `session_start`. */
80
+ start: () => start({}, ctx),
81
+ /** Fire this session's `session_shutdown`. */
82
+ shutdown: () => shutdown({}, ctx),
83
+ /** Press this session's paste shortcut. */
84
+ press: () => press(ctx),
85
+ /** Submit `text` through this session's `input` handler. */
86
+ submit: (text: string) => input({ text, source: "interactive" }) as Promise<SubmitResult>,
87
+ };
88
+ }
89
+
17
90
  describe("image-paste default export (standalone factory)", () => {
91
+ beforeEach(() => {
92
+ fs.rmSync(join(tempDir, "settings.json"), { force: true });
93
+ mockReadClipboardImage.mockReset();
94
+ mockReadClipboardImage.mockResolvedValue({
95
+ bytes: new Uint8Array([1, 2, 3]),
96
+ mimeType: "image/png",
97
+ });
98
+ });
99
+
18
100
  it("is a function that registers paste + per-session lifecycle on the pi extension", () => {
19
101
  expect(typeof defaultExport).toBe("function");
20
102
 
@@ -37,19 +119,34 @@ describe("image-paste default export (standalone factory)", () => {
37
119
  );
38
120
  });
39
121
 
40
- it("resets the per-session queue on session_start and clears it on shutdown", () => {
41
- const on = vi.fn();
42
- const pi = { on, registerShortcut: vi.fn(), registerMessageRenderer: vi.fn() } as any;
43
- defaultExport(pi);
44
-
45
- const startHandler = on.mock.calls.find((c: Array<unknown>) => c[0] === "session_start")?.[1];
46
- const shutdownHandler = on.mock.calls.find((c: Array<unknown>) => c[0] === "session_shutdown")?.[1];
47
- expect(typeof startHandler).toBe("function");
48
- expect(typeof shutdownHandler).toBe("function");
49
-
50
- // Must not throw when wired to a session context
51
- expect(() => (startHandler as (...a: unknown[]) => unknown)({}, { hasUI: true })).not.toThrow();
52
- expect(() => (shutdownHandler as (...a: unknown[]) => unknown)({}, {})).not.toThrow();
122
+ it("a session's shutdown clears its OWN queue and leaves another session's intact", async () => {
123
+ // #156: pi caches the module and re-runs the factory per session, so the
124
+ // lifecycle handlers of two sessions coexist in one process. The old
125
+ // module-level state meant a child's `session_shutdown` cleared the MAIN
126
+ // session's queue (dead paste key, silently) and a child's
127
+ // `session_start` replaced it (the queued image never attached). Both
128
+ // halves are asserted here through the standalone wiring; the same
129
+ // guarantee through the named export is in session-isolation.test.ts.
130
+ const main = standaloneSession();
131
+ await main.start();
132
+ await main.press(); // main's queue now holds "[Image #1]"
133
+ expect(main.inserted).toEqual(["[Image #1] "]);
134
+
135
+ const child = standaloneSession();
136
+ await child.start();
137
+ await child.shutdown();
138
+
139
+ // The main session's queue survived the child's teardown: its shortcut
140
+ // reaches the clipboard again rather than returning at the `!queue` guard.
141
+ await main.press();
142
+ expect(mockReadClipboardImage).toHaveBeenCalledTimes(2);
143
+ expect(main.inserted).toEqual(["[Image #1] ", "[Image #2] "]);
144
+
145
+ // …and both queued images still attach on the main session's submit — the
146
+ // child's session_start did not orphan the first one.
147
+ const result = await main.submit("[Image #1] and [Image #2] look");
148
+ expect(result.action).toBe("transform");
149
+ expect(result.images).toHaveLength(2);
53
150
  });
54
151
  });
55
152
 
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  } from "./clipboard.js";
16
16
  import { runNativePasteRestore, suiteMayClaimPasteKey } from "./keybinding-offer.js";
17
17
  import { registerImagePreview, sendPreviewMessage } from "./preview.js";
18
+ import { previewEnabled } from "./settings.js";
18
19
  import type { ClipboardImage, PendingImage, ImageMarker } from "./types.js";
19
20
 
20
21
  const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; // 20MB
@@ -171,12 +172,32 @@ function getImagePasteShortcuts(): KeyId[] {
171
172
  return ["ctrl+v", "alt+v", "ctrl+alt+v"] as KeyId[];
172
173
  }
173
174
 
174
- // Module-level state — registered once, queue reset per session
175
- let _ctx: ExtensionContext | null = null;
176
- let _queue: ImageQueue | null = null;
177
- let _pasting = false;
175
+ /**
176
+ * The per-session handle `registerImagePaste` hands back (#156).
177
+ *
178
+ * pi caches an extension MODULE per process but re-invokes the factory with a
179
+ * fresh `pi` for every session, so state that lives at module scope belongs to
180
+ * the process, not to a session: a subordinate in-process session (a third-party
181
+ * subagent child) running its `session_shutdown` used to null the MAIN session's
182
+ * queue through the package's module-level teardown, and the paste handler
183
+ * then returned before reading the clipboard — silently, with no message. The
184
+ * handle is what makes "this session's state" expressible: whoever holds it can
185
+ * only ever touch the session that created it.
186
+ */
187
+ export interface ImagePasteSession {
188
+ /** Adopt this session's ctx and start a fresh queue. Call from session_start. */
189
+ initSession(ctx: ExtensionContext): void;
190
+ /** Drop this session's state. Idempotent; touches nothing outside this session. */
191
+ shutdown(): void;
192
+ }
193
+
194
+ export function registerImagePaste(pi: ExtensionAPI): ImagePasteSession {
195
+ // Per-session state — created by THIS factory run, reachable only through the
196
+ // handle returned below. Never module scope: see ImagePasteSession (#156).
197
+ let ctxRef: ExtensionContext | null = null;
198
+ let queue: ImageQueue | null = null;
199
+ let pasting = false;
178
200
 
179
- export function registerImagePaste(pi: ExtensionAPI): void {
180
201
  // Register preview renderer (once)
181
202
  registerImagePreview(pi);
182
203
 
@@ -205,13 +226,13 @@ export function registerImagePaste(pi: ExtensionAPI): void {
205
226
  // bounded (a `spawnSync` timeout per CLI candidate,
206
227
  // `NATIVE_READ_TIMEOUT_MS` per native read) — because an unbounded read
207
228
  // here would hold the mutex forever and take the key out of service.
208
- if (_pasting) return;
209
- if (!_queue || !ctx.hasUI) return;
210
- _pasting = true;
229
+ if (pasting) return;
230
+ if (!queue || !ctx.hasUI) return;
231
+ pasting = true;
211
232
  try {
212
- await dispatchClipboardPaste(ctx, _queue);
233
+ await dispatchClipboardPaste(ctx, queue);
213
234
  } finally {
214
- _pasting = false;
235
+ pasting = false;
215
236
  }
216
237
  };
217
238
 
@@ -234,13 +255,13 @@ export function registerImagePaste(pi: ExtensionAPI): void {
234
255
  if (event.source === "extension") {
235
256
  return { action: "continue" as const };
236
257
  }
237
- if (!_queue || !_queue.markers.length || !_ctx) {
258
+ if (!queue || !queue.markers.length || !ctxRef) {
238
259
  return { action: "continue" as const };
239
260
  }
240
261
 
241
262
  // ── Marker matching: use consistent key (trimmed text) ──
242
263
  let hasMarkers = false;
243
- for (const marker of _queue.markers) {
264
+ for (const marker of queue.markers) {
244
265
  if (event.text.includes(markerKey(marker))) {
245
266
  hasMarkers = true;
246
267
  break;
@@ -249,19 +270,19 @@ export function registerImagePaste(pi: ExtensionAPI): void {
249
270
 
250
271
  if (!hasMarkers) {
251
272
  // No markers found — clear queue (user removed them)
252
- _queue.images.length = 0;
253
- _queue.markers.length = 0;
254
- _queue.nextIndex = 1;
273
+ queue.images.length = 0;
274
+ queue.markers.length = 0;
275
+ queue.nextIndex = 1;
255
276
  return { action: "continue" as const };
256
277
  }
257
278
 
258
279
  // Match markers to images using trimmed key
259
280
  const imagesToAttach: PendingImage[] = [];
260
281
 
261
- for (const marker of _queue.markers) {
282
+ for (const marker of queue.markers) {
262
283
  const key = markerKey(marker);
263
284
  if (event.text.includes(key)) {
264
- const pending = _queue.images.find((img) => img.id === marker.id);
285
+ const pending = queue.images.find((img) => img.id === marker.id);
265
286
  if (pending) {
266
287
  imagesToAttach.push(pending);
267
288
  }
@@ -269,19 +290,21 @@ export function registerImagePaste(pi: ExtensionAPI): void {
269
290
  }
270
291
 
271
292
  // Clear queue
272
- _queue.images.length = 0;
273
- _queue.markers.length = 0;
274
- _queue.nextIndex = 1;
293
+ queue.images.length = 0;
294
+ queue.markers.length = 0;
295
+ queue.nextIndex = 1;
275
296
 
276
297
  if (imagesToAttach.length === 0) {
277
298
  return { action: "continue" as const };
278
299
  }
279
300
 
280
301
  // Send preview message for TUI display (images are invisible in user messages)
281
- try {
282
- sendPreviewMessage(pi, imagesToAttach);
283
- } catch {
284
- // Preview is optional — don't fail the submit
302
+ if (previewEnabled()) {
303
+ try {
304
+ sendPreviewMessage(pi, imagesToAttach);
305
+ } catch {
306
+ // Preview is optional — don't fail the submit
307
+ }
285
308
  }
286
309
 
287
310
  return {
@@ -294,29 +317,30 @@ export function registerImagePaste(pi: ExtensionAPI): void {
294
317
  })),
295
318
  };
296
319
  });
297
- }
298
320
 
299
- // Called on session_start to initialize/reset the queue
300
- export function initImagePasteSession(ctx: ExtensionContext): void {
301
- _ctx = ctx;
302
- _queue = createImageQueue();
303
- }
304
-
305
- // Called on session_shutdown to clear state
306
- export function shutdownImagePaste(): void {
307
- _ctx = null;
308
- _queue = null;
309
- _pasting = false;
321
+ return {
322
+ initSession(ctx: ExtensionContext): void {
323
+ ctxRef = ctx;
324
+ queue = createImageQueue();
325
+ },
326
+ shutdown(): void {
327
+ ctxRef = null;
328
+ queue = null;
329
+ pasting = false;
330
+ },
331
+ };
310
332
  }
311
333
 
312
334
  // ── Default export (for standalone pi.extensions loading) ─────────────────
313
335
 
314
336
  // Pi's standalone loader requires a default export functioning as the
315
337
  // extension factory. Meta imports the named exports directly (register
316
- // + session lifecycle), so this default export is standalone-only.
338
+ // + the handle it returns), so this default export is standalone-only.
317
339
  export default function (pi: ExtensionAPI): void {
318
- registerImagePaste(pi);
319
- // Session lifecycle: pi requires per-session ctx for image pasting.
340
+ const session = registerImagePaste(pi);
341
+ // Session lifecycle: pi requires per-session ctx for image pasting, and the
342
+ // state now lives in the handle above, so this session can only ever reset
343
+ // its own queue (#156 — a foreign session's shutdown used to clear this one).
320
344
  //
321
345
  // Registered at the TOP LEVEL of this factory — never inside another
322
346
  // session_start handler, which would accumulate handlers on /reload
@@ -329,7 +353,9 @@ export default function (pi: ExtensionAPI): void {
329
353
  // from keybindings.json, and the gate in `registerImagePaste` above stops us
330
354
  // claiming the key. Both read the same setting per session.
331
355
  pi.on("session_start", async (_event, ctx) => {
332
- initImagePasteSession(ctx);
356
+ // The queue must be live BEFORE the restore: the restore can reload, and a
357
+ // session that reloaded with no queue would have a dead paste key.
358
+ session.initSession(ctx);
333
359
  // A broken keybindings.json must never fail a session start; the detail
334
360
  // goes to the log sink, never the console.
335
361
  try {
@@ -342,6 +368,6 @@ export default function (pi: ExtensionAPI): void {
342
368
  }
343
369
  });
344
370
  pi.on("session_shutdown", (_event, _ctx) => {
345
- shutdownImagePaste();
371
+ session.shutdown();
346
372
  });
347
373
  }
@@ -223,7 +223,7 @@ describe("offerKeybindingFix — gate matrix", () => {
223
223
  if (mode !== "tui") expect(confirm).not.toHaveBeenCalled();
224
224
 
225
225
  if (shouldAsk) {
226
- // confirm is called with the TITLE FIRST (docs/extensions.md:165)
226
+ // confirm is called with the TITLE FIRST (confirm(title, message))
227
227
  const [title, message] = callArgs(confirm)[0] as [string, string];
228
228
  expect(title).toBe("First run");
229
229
  expect(typeof message).toBe("string");
@@ -308,7 +308,7 @@ export async function offerKeybindingFix(ctx: ExtensionContext): Promise<boolean
308
308
  return false;
309
309
  }
310
310
 
311
- // Gate 5: ask (confirm signature is TITLE first — docs/extensions.md:165)
311
+ // Gate 5: ask (confirm(title, message): TITLE first — pi's types.d.ts)
312
312
  const confirmed = await ctx.ui.confirm(CONFIRM_TITLE, CONFIRM_MESSAGE);
313
313
 
314
314
  // No, or cancel (Esc/timeout — `confirm` resolves `false` either way counts
@@ -1,9 +1,10 @@
1
1
  import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
- // The mutex lives in the shortcut handler in `index.ts` and the readers live in
4
- // `clipboard.ts`, so this file deliberately mocks *neither*: the real dispatch
5
- // runs against a faked clipboard helper and a faked command runner, which is the
6
- // only way to ask the question that matters to a user — does `ctrl+v` come back?
3
+ // The mutex is the `pasting` flag in `registerImagePaste` (per session since
4
+ // #156) and the readers live in `clipboard.ts`, so this file deliberately mocks
5
+ // *neither*: the real dispatch runs against a faked clipboard helper and a faked
6
+ // command runner, which is the only way to ask the question that matters to a
7
+ // user — does `ctrl+v` come back?
7
8
  const { mockGetNativeClipboard, mockSpawnSync } = vi.hoisted(() => ({
8
9
  mockGetNativeClipboard: vi.fn(),
9
10
  mockSpawnSync: vi.fn(),
@@ -21,7 +22,7 @@ vi.mock("node:child_process", async (importOriginal) => {
21
22
 
22
23
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
23
24
 
24
- import { initImagePasteSession, registerImagePaste } from "./index.js";
25
+ import { registerImagePaste } from "./index.js";
25
26
  import { NATIVE_READ_TIMEOUT_MS } from "./clipboard.js";
26
27
 
27
28
  /** Every CLI candidate is missing, so the native helper is the only reader. */
@@ -64,8 +65,8 @@ function createHarness(): Harness {
64
65
  on: () => {},
65
66
  } as unknown as ExtensionAPI;
66
67
 
67
- registerImagePaste(pi);
68
- initImagePasteSession(ctx);
68
+ const session = registerImagePaste(pi);
69
+ session.initSession(ctx);
69
70
  const pressKey = handlers.get("ctrl+v")!;
70
71
  return { ctx, press: () => pressKey(ctx), inserted, notifications };
71
72
  }
@@ -121,7 +122,7 @@ describe("the ctrl+v mutex against a clipboard that never answers", () => {
121
122
  await vi.advanceTimersByTimeAsync(NATIVE_READ_TIMEOUT_MS * 3);
122
123
  // RED without a deadline on the native read: this await never finishes,
123
124
  // the promise stays pending, and every later press is dropped by the
124
- // `_pasting` mutex — `ctrl+v` is dead until the session restarts, with no
125
+ // `pasting` mutex — `ctrl+v` is dead until the session restarts, with no
125
126
  // message saying so (measured live: first press still pending after
126
127
  // 7.1 s, both later presses silently swallowed, nothing notified).
127
128
  await expect(press).resolves.toBeUndefined();
@@ -0,0 +1,174 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // `archimedes.imagePaste.preview: false` skips the inline preview message on
4
+ // submit; the image still attaches and the paste-time notify still fires.
5
+ //
6
+ // Settings: `core/settings-io` captures `join(getAgentDir(), "settings.json")`
7
+ // at module load, hence one scratch dir for the whole file, rewritten per case.
8
+ // Clipboard: the readers are mocked so a paste yields image bytes without
9
+ // shelling out.
10
+
11
+ const { tempDir, fs, join, mockReadClipboardImage } = vi.hoisted(() => {
12
+ const fs = require("node:fs");
13
+ const { join } = require("node:path");
14
+ const { tmpdir } = require("node:os");
15
+ const { randomUUID } = require("node:crypto");
16
+ const dir = join(tmpdir(), `image-paste-preview-setting-${randomUUID()}`);
17
+ fs.mkdirSync(dir, { recursive: true });
18
+ return { tempDir: dir, fs, join, mockReadClipboardImage: vi.fn() };
19
+ });
20
+
21
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
22
+ getAgentDir: () => tempDir,
23
+ convertToPng: () => undefined,
24
+ }));
25
+
26
+ vi.mock("./clipboard.js", () => ({
27
+ readClipboardImage: mockReadClipboardImage,
28
+ readClipboardFilePaths: vi.fn(async () => null),
29
+ readClipboardText: vi.fn(async () => null),
30
+ buildPathInsertion: vi.fn(),
31
+ clipboardTextForEditor: vi.fn(),
32
+ }));
33
+
34
+ const { registerImagePaste } = await import("./index.js");
35
+ type ImagePasteSession = ReturnType<typeof registerImagePaste>;
36
+ const { saveImagePastePreview } = await import("./settings.js");
37
+
38
+ const NAMESPACE = "archimedes.imagePaste";
39
+ const settingsPath = (): string => join(tempDir, "settings.json");
40
+
41
+ function writeSettings(imagePaste: Record<string, unknown> | undefined): void {
42
+ const settings: Record<string, unknown> = {};
43
+ if (imagePaste !== undefined) settings[NAMESPACE] = imagePaste;
44
+ fs.writeFileSync(settingsPath(), JSON.stringify(settings), "utf-8");
45
+ }
46
+
47
+ type Handler = (...args: unknown[]) => unknown;
48
+
49
+ interface SubmitResult {
50
+ action: string;
51
+ images?: unknown[];
52
+ }
53
+
54
+ // The handle of the most recently registered session. State is per session now
55
+ // (#156), so the resets below drop exactly the session the previous case left
56
+ // behind — the module-level `shutdownImagePaste()` they replace dropped every
57
+ // session's at once, which was the bug.
58
+ let activeSession: ImagePasteSession | null = null;
59
+
60
+ /** Register once; `pasteAndSubmit()` then drives the same handlers each time. */
61
+ function registerSession() {
62
+ const pi = {
63
+ on: vi.fn(),
64
+ registerShortcut: vi.fn(),
65
+ registerMessageRenderer: vi.fn(),
66
+ registerCommand: vi.fn(),
67
+ sendMessage: vi.fn(),
68
+ };
69
+ const notify = vi.fn();
70
+ const ctx = { hasUI: true, ui: { notify, pasteToEditor: vi.fn() } };
71
+
72
+ const session = registerImagePaste(pi as never);
73
+ session.initSession(ctx as never);
74
+ activeSession = session;
75
+
76
+ const paste = pi.registerShortcut.mock.calls[0]![1].handler as Handler;
77
+ const input = pi.on.mock.calls.find((call) => call[0] === "input")![1] as Handler;
78
+
79
+ /** Paste one image through the shortcut and submit it. */
80
+ async function pasteAndSubmit(): Promise<SubmitResult> {
81
+ await paste(ctx);
82
+ return (await input({ text: "[Image #1] look", source: "interactive" })) as SubmitResult;
83
+ }
84
+
85
+ return { pi, notify, pasteAndSubmit, session };
86
+ }
87
+
88
+ /** Register, paste one image through the shortcut, submit it; report what happened. */
89
+ async function pasteAndSubmit() {
90
+ const session = registerSession();
91
+ const result = await session.pasteAndSubmit();
92
+ return { pi: session.pi, notify: session.notify, result };
93
+ }
94
+
95
+ beforeEach(() => {
96
+ fs.rmSync(settingsPath(), { force: true });
97
+ activeSession?.shutdown();
98
+ activeSession = null;
99
+ mockReadClipboardImage.mockReset();
100
+ mockReadClipboardImage.mockResolvedValue({ bytes: new Uint8Array([1, 2, 3]), mimeType: "image/png" });
101
+ });
102
+
103
+ describe("archimedes.imagePaste.preview", () => {
104
+ it("sends the preview when the setting is absent", async () => {
105
+ const { pi, result } = await pasteAndSubmit();
106
+ expect(result.action).toBe("transform");
107
+ expect(pi.sendMessage).toHaveBeenCalledTimes(1);
108
+ });
109
+
110
+ it("sends the preview when preview is true", async () => {
111
+ writeSettings({ preview: true });
112
+ const { pi } = await pasteAndSubmit();
113
+ expect(pi.sendMessage).toHaveBeenCalledTimes(1);
114
+ });
115
+
116
+ it("skips the preview when preview is false, but still notifies and attaches", async () => {
117
+ writeSettings({ preview: false });
118
+ const { pi, notify, result } = await pasteAndSubmit();
119
+
120
+ expect(pi.sendMessage).not.toHaveBeenCalled();
121
+ expect(notify).toHaveBeenCalledWith("Image attached from clipboard.", "info");
122
+ expect(result.action).toBe("transform");
123
+ expect(result.images).toHaveLength(1);
124
+ });
125
+
126
+ it("re-reads the setting on each submit through the same registered handler (no /reload)", async () => {
127
+ const session = registerSession();
128
+
129
+ const first = await session.pasteAndSubmit();
130
+ expect(session.pi.sendMessage).toHaveBeenCalledTimes(1);
131
+ expect(first.action).toBe("transform");
132
+ expect(first.images).toHaveLength(1);
133
+
134
+ writeSettings({ preview: false });
135
+ const second = await session.pasteAndSubmit();
136
+ expect(session.pi.sendMessage).toHaveBeenCalledTimes(1);
137
+ expect(second.action).toBe("transform");
138
+ expect(second.images).toHaveLength(1);
139
+
140
+ writeSettings({ preview: true });
141
+ const third = await session.pasteAndSubmit();
142
+ expect(session.pi.sendMessage).toHaveBeenCalledTimes(2);
143
+ expect(third.images).toHaveLength(1);
144
+ });
145
+
146
+ it("treats a value that is not the boolean false as on", async () => {
147
+ for (const value of ["false", 0]) {
148
+ writeSettings({ preview: value });
149
+ // Drop the previous iteration's session (its own handle — #156).
150
+ activeSession?.shutdown();
151
+ const { pi } = await pasteAndSubmit();
152
+ expect(pi.sendMessage, `preview=${JSON.stringify(value)}`).toHaveBeenCalledTimes(1);
153
+ }
154
+ });
155
+ });
156
+
157
+ describe("saveImagePastePreview (the /archimedes row)", () => {
158
+ it("merges preview into the namespace, keeps its other keys, and the next submit obeys it", async () => {
159
+ writeSettings({ enabled: true, suppressNativePaste: true, keybindingsPromptDone: true });
160
+ const session = registerSession();
161
+
162
+ saveImagePastePreview(false);
163
+ expect(JSON.parse(fs.readFileSync(settingsPath(), "utf-8"))[NAMESPACE]).toEqual({
164
+ enabled: true,
165
+ suppressNativePaste: true,
166
+ keybindingsPromptDone: true,
167
+ preview: false,
168
+ });
169
+
170
+ const result = await session.pasteAndSubmit();
171
+ expect(session.pi.sendMessage).not.toHaveBeenCalled();
172
+ expect(result.images).toHaveLength(1);
173
+ });
174
+ });
@@ -0,0 +1,154 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+
5
+ // ── #156: one module instance, two sessions, no shared queue ────────────────
6
+ //
7
+ // pi caches an extension MODULE per process but re-invokes its factory with a
8
+ // fresh `pi` per session, so a subordinate in-process session (a third-party
9
+ // subagent child) shares the module with the main session. When that child ran
10
+ // its `session_shutdown`, the package's module-level `shutdownImagePaste()`
11
+ // nulled the MAIN session's queue and the paste handler returned at
12
+ // `if (!queue || !ctx.hasUI) return;` before touching the clipboard — silently.
13
+ // Reproduced live against pi 1.5.0 with a control: `alt+v` attached `[Image #1]`
14
+ // before the child's shutdown and produced literally nothing after it.
15
+ //
16
+ // The fix is a handle: `registerImagePaste` creates its state per factory run
17
+ // and hands back `{ initSession, shutdown }`, so a session can only ever drop
18
+ // its own state. This file drives TWO sessions through ONE module instance —
19
+ // which is exactly how pi runs it — and asserts the main session survives both
20
+ // foreign lifecycle events. Assertions come from the `input` handler's return
21
+ // value, so no pi internals are involved.
22
+ //
23
+ // Settings: `core/settings-io` captures `join(getAgentDir(), "settings.json")`
24
+ // at module load, hence one scratch dir for the whole file (absent settings =
25
+ // the defaults: shortcuts claimed, preview on). Clipboard: the readers are
26
+ // mocked so a paste yields image bytes without shelling out.
27
+
28
+ const { tempDir, fs, join, mockReadClipboardImage } = vi.hoisted(() => {
29
+ const fs = require("node:fs");
30
+ const { join } = require("node:path");
31
+ const { tmpdir } = require("node:os");
32
+ const { randomUUID } = require("node:crypto");
33
+ const dir = join(tmpdir(), `image-paste-session-isolation-${randomUUID()}`);
34
+ fs.mkdirSync(dir, { recursive: true });
35
+ return { tempDir: dir, fs, join, mockReadClipboardImage: vi.fn() };
36
+ });
37
+
38
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
39
+ getAgentDir: () => tempDir,
40
+ convertToPng: () => undefined,
41
+ }));
42
+
43
+ vi.mock("./clipboard.js", () => ({
44
+ readClipboardImage: mockReadClipboardImage,
45
+ readClipboardFilePaths: vi.fn(async () => null),
46
+ readClipboardText: vi.fn(async () => null),
47
+ buildPathInsertion: vi.fn(),
48
+ clipboardTextForEditor: vi.fn(),
49
+ }));
50
+
51
+ const { registerImagePaste } = await import("./index.js");
52
+
53
+ type Handler = (...args: unknown[]) => unknown;
54
+
55
+ interface SubmitResult {
56
+ action: string;
57
+ images?: unknown[];
58
+ }
59
+
60
+ /**
61
+ * One session, exactly as pi builds one: a fresh `pi` handle through the same
62
+ * module instance. `initSession()` / `shutdown()` are the handle's lifecycle
63
+ * methods (the session's `session_start` / `session_shutdown`); `paste` and
64
+ * `input` drive the handlers registration handed to pi.
65
+ */
66
+ function makeSession() {
67
+ const pi = {
68
+ on: vi.fn(),
69
+ registerShortcut: vi.fn(),
70
+ registerMessageRenderer: vi.fn(),
71
+ registerCommand: vi.fn(),
72
+ sendMessage: vi.fn(),
73
+ };
74
+ const inserted: string[] = [];
75
+ const notifications: Array<[string, string | undefined]> = [];
76
+ // A partial ctx, cast the way paste-mutex.test.ts casts its harness ctx: the
77
+ // package only ever reads `hasUI` and `ui` from it.
78
+ const ctx = {
79
+ hasUI: true,
80
+ ui: {
81
+ notify: (message: string, type?: "info" | "warning" | "error") => {
82
+ notifications.push([message, type]);
83
+ },
84
+ pasteToEditor: (text: string) => {
85
+ inserted.push(text);
86
+ },
87
+ getEditorText: () => "",
88
+ },
89
+ } as unknown as ExtensionContext;
90
+
91
+ const session = registerImagePaste(pi as never);
92
+
93
+ const pasteHandler = pi.registerShortcut.mock.calls[0]![1].handler as Handler;
94
+ const inputHandler = pi.on.mock.calls.find((call) => call[0] === "input")![1] as Handler;
95
+
96
+ return {
97
+ ...session,
98
+ pi,
99
+ ctx,
100
+ inserted,
101
+ notifications,
102
+ /** Press the package's paste shortcut for this session. */
103
+ paste: async (context = ctx) => {
104
+ await pasteHandler(context);
105
+ },
106
+ /** Submit an event through this session's `input` handler. */
107
+ input: async (event: { text: string; source: string }): Promise<SubmitResult> =>
108
+ (await inputHandler(event)) as SubmitResult,
109
+ };
110
+ }
111
+
112
+ beforeEach(() => {
113
+ fs.rmSync(join(tempDir, "settings.json"), { force: true });
114
+ mockReadClipboardImage.mockReset();
115
+ mockReadClipboardImage.mockResolvedValue({ bytes: new Uint8Array([1, 2, 3]), mimeType: "image/png" });
116
+ });
117
+
118
+ describe("#156 image-paste session isolation", () => {
119
+ it("a foreign session's shutdown does not stop the main session's paste", async () => {
120
+ const main = makeSession(); // registerImagePaste(mainPi) + main.initSession(mainCtx)
121
+ main.initSession(main.ctx); // main session_start
122
+
123
+ const child = makeSession(); // second factory run, same module — pi does this per session
124
+ child.initSession(child.ctx); // child session_start
125
+ child.shutdown(); // child session_shutdown (reason: "quit")
126
+
127
+ // 1. main's shortcut still reaches the clipboard. Pre-fix this returned at
128
+ // the `!queue` guard and the reader never ran — no notify, no marker, no
129
+ // message: a dead key.
130
+ await main.paste(main.ctx);
131
+
132
+ expect(mockReadClipboardImage, "the reader never ran for the main session").toHaveBeenCalledTimes(1);
133
+ expect(main.inserted).toEqual(["[Image #1] "]);
134
+ expect(main.notifications).toEqual([["Image attached from clipboard.", "info"]]);
135
+ });
136
+
137
+ it("a foreign session_start does not orphan the main session's queued image", async () => {
138
+ const main = makeSession();
139
+ main.initSession(main.ctx);
140
+ await main.paste(main.ctx); // the marker "[Image #1]" is in the editor
141
+ expect(main.inserted).toEqual(["[Image #1] "]);
142
+
143
+ const child = makeSession();
144
+ child.initSession(child.ctx); // child session_start only — no shutdown
145
+
146
+ // 2. main's queued image still attaches on submit. Pre-fix `_queue` was
147
+ // reassigned by the child's session_start, so main's marker matched
148
+ // nothing and the submit silently dropped the attached image.
149
+ const result = await main.input({ text: "[Image #1] look", source: "interactive" });
150
+
151
+ expect(result.action).toBe("transform");
152
+ expect(result.images).toHaveLength(1);
153
+ });
154
+ });
@@ -0,0 +1,33 @@
1
+ import type { SettingItem } from "@earendil-works/pi-tui";
2
+
3
+ import { loadConfig, updateConfig } from "@pi-archimedes/core/settings-io";
4
+
5
+ // Kept free of the package's main entry (clipboard, renderer) so meta's
6
+ // /archimedes panel can import it without loading the lazily-loaded plugin.
7
+
8
+ const NAMESPACE = "archimedes.imagePaste";
9
+
10
+ /**
11
+ * `archimedes.imagePaste.preview`, default on. Only the strict boolean `false`
12
+ * disables it. Read per call so a settings change applies on the next submit.
13
+ */
14
+ export function previewEnabled(): boolean {
15
+ return loadConfig<{ preview: boolean }>(NAMESPACE, { preview: true }).preview !== false;
16
+ }
17
+
18
+ export function getImagePasteSettingsItems(): SettingItem[] {
19
+ return [
20
+ {
21
+ id: "imagePastePreview",
22
+ label: "Image Paste Preview",
23
+ description: "Show an inline preview of pasted images after you submit",
24
+ currentValue: previewEnabled() ? "On" : "Off",
25
+ values: ["On", "Off"],
26
+ },
27
+ ];
28
+ }
29
+
30
+ /** Merges `preview` into the namespace; its other keys are kept. */
31
+ export function saveImagePastePreview(on: boolean): void {
32
+ updateConfig<Record<string, unknown>>(NAMESPACE, {}, (cfg) => ({ ...cfg, preview: on }));
33
+ }