@pi-archimedes/image-paste 2.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/image-paste",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/archimedes-codes/pi-archimedes.git"
@@ -20,7 +20,7 @@
20
20
  "./settings": "./src/settings.ts"
21
21
  },
22
22
  "dependencies": {
23
- "@pi-archimedes/core": "2.13.0"
23
+ "@pi-archimedes/core": "2.14.0"
24
24
  },
25
25
  "peerDependencies": {
26
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
@@ -172,12 +172,32 @@ function getImagePasteShortcuts(): KeyId[] {
172
172
  return ["ctrl+v", "alt+v", "ctrl+alt+v"] as KeyId[];
173
173
  }
174
174
 
175
- // Module-level state — registered once, queue reset per session
176
- let _ctx: ExtensionContext | null = null;
177
- let _queue: ImageQueue | null = null;
178
- 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;
179
200
 
180
- export function registerImagePaste(pi: ExtensionAPI): void {
181
201
  // Register preview renderer (once)
182
202
  registerImagePreview(pi);
183
203
 
@@ -206,13 +226,13 @@ export function registerImagePaste(pi: ExtensionAPI): void {
206
226
  // bounded (a `spawnSync` timeout per CLI candidate,
207
227
  // `NATIVE_READ_TIMEOUT_MS` per native read) — because an unbounded read
208
228
  // here would hold the mutex forever and take the key out of service.
209
- if (_pasting) return;
210
- if (!_queue || !ctx.hasUI) return;
211
- _pasting = true;
229
+ if (pasting) return;
230
+ if (!queue || !ctx.hasUI) return;
231
+ pasting = true;
212
232
  try {
213
- await dispatchClipboardPaste(ctx, _queue);
233
+ await dispatchClipboardPaste(ctx, queue);
214
234
  } finally {
215
- _pasting = false;
235
+ pasting = false;
216
236
  }
217
237
  };
218
238
 
@@ -235,13 +255,13 @@ export function registerImagePaste(pi: ExtensionAPI): void {
235
255
  if (event.source === "extension") {
236
256
  return { action: "continue" as const };
237
257
  }
238
- if (!_queue || !_queue.markers.length || !_ctx) {
258
+ if (!queue || !queue.markers.length || !ctxRef) {
239
259
  return { action: "continue" as const };
240
260
  }
241
261
 
242
262
  // ── Marker matching: use consistent key (trimmed text) ──
243
263
  let hasMarkers = false;
244
- for (const marker of _queue.markers) {
264
+ for (const marker of queue.markers) {
245
265
  if (event.text.includes(markerKey(marker))) {
246
266
  hasMarkers = true;
247
267
  break;
@@ -250,19 +270,19 @@ export function registerImagePaste(pi: ExtensionAPI): void {
250
270
 
251
271
  if (!hasMarkers) {
252
272
  // No markers found — clear queue (user removed them)
253
- _queue.images.length = 0;
254
- _queue.markers.length = 0;
255
- _queue.nextIndex = 1;
273
+ queue.images.length = 0;
274
+ queue.markers.length = 0;
275
+ queue.nextIndex = 1;
256
276
  return { action: "continue" as const };
257
277
  }
258
278
 
259
279
  // Match markers to images using trimmed key
260
280
  const imagesToAttach: PendingImage[] = [];
261
281
 
262
- for (const marker of _queue.markers) {
282
+ for (const marker of queue.markers) {
263
283
  const key = markerKey(marker);
264
284
  if (event.text.includes(key)) {
265
- const pending = _queue.images.find((img) => img.id === marker.id);
285
+ const pending = queue.images.find((img) => img.id === marker.id);
266
286
  if (pending) {
267
287
  imagesToAttach.push(pending);
268
288
  }
@@ -270,9 +290,9 @@ export function registerImagePaste(pi: ExtensionAPI): void {
270
290
  }
271
291
 
272
292
  // Clear queue
273
- _queue.images.length = 0;
274
- _queue.markers.length = 0;
275
- _queue.nextIndex = 1;
293
+ queue.images.length = 0;
294
+ queue.markers.length = 0;
295
+ queue.nextIndex = 1;
276
296
 
277
297
  if (imagesToAttach.length === 0) {
278
298
  return { action: "continue" as const };
@@ -297,29 +317,30 @@ export function registerImagePaste(pi: ExtensionAPI): void {
297
317
  })),
298
318
  };
299
319
  });
300
- }
301
-
302
- // Called on session_start to initialize/reset the queue
303
- export function initImagePasteSession(ctx: ExtensionContext): void {
304
- _ctx = ctx;
305
- _queue = createImageQueue();
306
- }
307
320
 
308
- // Called on session_shutdown to clear state
309
- export function shutdownImagePaste(): void {
310
- _ctx = null;
311
- _queue = null;
312
- _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
+ };
313
332
  }
314
333
 
315
334
  // ── Default export (for standalone pi.extensions loading) ─────────────────
316
335
 
317
336
  // Pi's standalone loader requires a default export functioning as the
318
337
  // extension factory. Meta imports the named exports directly (register
319
- // + session lifecycle), so this default export is standalone-only.
338
+ // + the handle it returns), so this default export is standalone-only.
320
339
  export default function (pi: ExtensionAPI): void {
321
- registerImagePaste(pi);
322
- // 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).
323
344
  //
324
345
  // Registered at the TOP LEVEL of this factory — never inside another
325
346
  // session_start handler, which would accumulate handlers on /reload
@@ -332,7 +353,9 @@ export default function (pi: ExtensionAPI): void {
332
353
  // from keybindings.json, and the gate in `registerImagePaste` above stops us
333
354
  // claiming the key. Both read the same setting per session.
334
355
  pi.on("session_start", async (_event, ctx) => {
335
- 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);
336
359
  // A broken keybindings.json must never fail a session start; the detail
337
360
  // goes to the log sink, never the console.
338
361
  try {
@@ -345,6 +368,6 @@ export default function (pi: ExtensionAPI): void {
345
368
  }
346
369
  });
347
370
  pi.on("session_shutdown", (_event, _ctx) => {
348
- shutdownImagePaste();
371
+ session.shutdown();
349
372
  });
350
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();
@@ -31,7 +31,8 @@ vi.mock("./clipboard.js", () => ({
31
31
  clipboardTextForEditor: vi.fn(),
32
32
  }));
33
33
 
34
- const { registerImagePaste, initImagePasteSession, shutdownImagePaste } = await import("./index.js");
34
+ const { registerImagePaste } = await import("./index.js");
35
+ type ImagePasteSession = ReturnType<typeof registerImagePaste>;
35
36
  const { saveImagePastePreview } = await import("./settings.js");
36
37
 
37
38
  const NAMESPACE = "archimedes.imagePaste";
@@ -50,6 +51,12 @@ interface SubmitResult {
50
51
  images?: unknown[];
51
52
  }
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
+
53
60
  /** Register once; `pasteAndSubmit()` then drives the same handlers each time. */
54
61
  function registerSession() {
55
62
  const pi = {
@@ -62,8 +69,9 @@ function registerSession() {
62
69
  const notify = vi.fn();
63
70
  const ctx = { hasUI: true, ui: { notify, pasteToEditor: vi.fn() } };
64
71
 
65
- registerImagePaste(pi as never);
66
- initImagePasteSession(ctx as never);
72
+ const session = registerImagePaste(pi as never);
73
+ session.initSession(ctx as never);
74
+ activeSession = session;
67
75
 
68
76
  const paste = pi.registerShortcut.mock.calls[0]![1].handler as Handler;
69
77
  const input = pi.on.mock.calls.find((call) => call[0] === "input")![1] as Handler;
@@ -74,7 +82,7 @@ function registerSession() {
74
82
  return (await input({ text: "[Image #1] look", source: "interactive" })) as SubmitResult;
75
83
  }
76
84
 
77
- return { pi, notify, pasteAndSubmit };
85
+ return { pi, notify, pasteAndSubmit, session };
78
86
  }
79
87
 
80
88
  /** Register, paste one image through the shortcut, submit it; report what happened. */
@@ -86,7 +94,8 @@ async function pasteAndSubmit() {
86
94
 
87
95
  beforeEach(() => {
88
96
  fs.rmSync(settingsPath(), { force: true });
89
- shutdownImagePaste();
97
+ activeSession?.shutdown();
98
+ activeSession = null;
90
99
  mockReadClipboardImage.mockReset();
91
100
  mockReadClipboardImage.mockResolvedValue({ bytes: new Uint8Array([1, 2, 3]), mimeType: "image/png" });
92
101
  });
@@ -137,7 +146,8 @@ describe("archimedes.imagePaste.preview", () => {
137
146
  it("treats a value that is not the boolean false as on", async () => {
138
147
  for (const value of ["false", 0]) {
139
148
  writeSettings({ preview: value });
140
- shutdownImagePaste();
149
+ // Drop the previous iteration's session (its own handle — #156).
150
+ activeSession?.shutdown();
141
151
  const { pi } = await pasteAndSubmit();
142
152
  expect(pi.sendMessage, `preview=${JSON.stringify(value)}`).toHaveBeenCalledTimes(1);
143
153
  }
@@ -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
+ });