@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 +2 -2
- package/src/default-export.test.ts +110 -13
- package/src/index.ts +60 -37
- package/src/keybinding-offer.test.ts +1 -1
- package/src/keybinding-offer.ts +1 -1
- package/src/paste-mutex.test.ts +9 -8
- package/src/preview-setting.test.ts +16 -6
- package/src/session-isolation.test.ts +154 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/image-paste",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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("
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
expect(
|
|
52
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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 (
|
|
210
|
-
if (!
|
|
211
|
-
|
|
229
|
+
if (pasting) return;
|
|
230
|
+
if (!queue || !ctx.hasUI) return;
|
|
231
|
+
pasting = true;
|
|
212
232
|
try {
|
|
213
|
-
await dispatchClipboardPaste(ctx,
|
|
233
|
+
await dispatchClipboardPaste(ctx, queue);
|
|
214
234
|
} finally {
|
|
215
|
-
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
282
|
+
for (const marker of queue.markers) {
|
|
263
283
|
const key = markerKey(marker);
|
|
264
284
|
if (event.text.includes(key)) {
|
|
265
|
-
const pending =
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
// +
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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");
|
package/src/keybinding-offer.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/paste-mutex.test.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
// The mutex
|
|
4
|
-
// `clipboard.ts`, so this file deliberately mocks
|
|
5
|
-
// runs against a faked clipboard helper and a faked
|
|
6
|
-
// only way to ask the question that matters to a
|
|
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 {
|
|
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
|
-
|
|
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
|
-
// `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|