pi-vision-handoff 0.8.2 โ†’ 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,8 +42,9 @@ No `settings.json` touched. No per-provider glue. Pick a describer once and ever
42
42
  - **๐Ÿ”Œ Provider-agnostic** โ€” uses pi's own model execution machinery (`@earendil-works/pi-ai`'s `completeSimple()`), so it works with any provider/configured model, including custom provider extensions.
43
43
  - **๐Ÿง  Automatic targets** โ€” by default, handoff applies to *every* model that lacks native vision. Opt out with `/vision-handoff auto off`.
44
44
  - **๐Ÿ—‚๏ธ Explicit overrides** โ€” force handoff for specific models (e.g. a weak vision model) with `/vision-handoff add`.
45
- - **โšก Pre-warmed at paste-enter** โ€” the moment you press enter, `before_agent_start` scans the prompt for pasted clipboard image temp-file paths (pi writes pasted images to `<tmpdir>/pi-clipboard-<uuid>.<ext>` and inserts the path as text), reads them, and kicks off the ONE batched vision call concurrent with the agent's first response โ€” so by the time the agent reads the files, they're already cache hits.
45
+ - **โšก Pre-warmed at paste-enter** โ€” the moment you press enter, `before_agent_start` scans the prompt for pasted image temp-file paths โ€” pi-clipboard, localterm-paste, and any other paste mechanism that writes a pasted image to a temp file and inserts the path as text โ€” reads them, and kicks off the ONE batched vision call concurrent with the agent's first response โ€” so by the time the agent reads the files, they're already cache hits.
46
46
  - **๐Ÿš€ Paste-time prewarm (opt-in)** โ€” `prewarmPastedImages` (off by default) wraps the editor so pasted clipboard images are described the *instant* their path lands in the prompt โ€” before you hit enter โ€” not at submit. The vision call starts concurrent with you typing your question. Tradeoff: the description is generated without your typed question as context (the question usually isn't entered yet at paste time), and a paste-then-abandon wastes one vision call. TUI only; inactive if another extension replaces the editor. Toggle with `/vision-handoff prewarm on`.
47
+ - **๐Ÿ Async pasted-path fallback (opt-in)** โ€” `asyncClipboardHandoff` races the normal read path against asynchronous delivery. A direct `read` or nested Fabric `pi.read` of the pasted path cancels the fallback and uses the normal tool-result/context flow; otherwise the completed description is queued as a steering message. Its transcript row is collapsed to one line by default and expands with `Ctrl+O`. Toggle it in the picker with `Ctrl+A` or run `/vision-handoff fallback on`.
47
48
  - **๐Ÿ›ก๏ธ Graceful degradation** โ€” no API key? Describer unreachable? Aborted? The image is replaced with a clean `[Image: description unavailable]` placeholder instead of crashing your turn.
48
49
  - **๐Ÿ“‹ Error logging** โ€” every describer failure and every `image description failed` warning is appended as a JSONL line to `~/.pi/agent/logs/pi-vision-handoff/errors.log`, so a warning like `image description failed โ€” unknown error` is troubleshootable: the detailed reason (exception stack, `stopReason`, config snapshot) is captured at the describer's failure source even when the in-memory error string was already reset by the time the warning fired. Size-capped with a single `.1` backup.
49
50
  - **๐Ÿ“Š Usage reporting** โ€” every real describer call reports model + tokens (and Neuralwatt energy/cost when the vision model is a Neuralwatt model), via `pi.appendEntry` + a `vision-handoff:usage` event for live consumers.
@@ -62,6 +63,7 @@ No `settings.json` touched. No per-provider glue. Pick a describer once and ever
62
63
  | `/vision-handoff enable` / `disable` | Master switch (keeps your configured model) |
63
64
  | `/vision-handoff auto on` / `off` | Toggle automatic handoff for all non-vision models |
64
65
  | `/vision-handoff prewarm on` / `off` | Toggle paste-time prewarm (opt-in, off by default) |
66
+ | `/vision-handoff fallback on` / `off` | Race matching reads against async pasted-path description injection (opt-in) |
65
67
  | `/vision-handoff add ollama/llava:13b` | Force handoff for an extra model |
66
68
  | `/vision-handoff remove ollama/llava:13b` | Stop forcing handoff for a model |
67
69
  | `/vision-handoff clear` | Clear the configured vision model |
@@ -78,6 +80,7 @@ Created automatically at `~/.pi/agent/extensions/pi-vision-handoff.json` on firs
78
80
  "autoHandoff": true,
79
81
  "handoffModels": ["ollama/llava:13b"],
80
82
  "prewarmPastedImages": false,
83
+ "asyncClipboardHandoff": false,
81
84
  "maxTokens": null,
82
85
  "cacheMax": 50,
83
86
  "maxDescriptionLines": 0,
@@ -93,6 +96,7 @@ Created automatically at `~/.pi/agent/extensions/pi-vision-handoff.json` on firs
93
96
  | `autoHandoff` | `true` | Apply handoff to every model whose `input` does not include `image`. |
94
97
  | `handoffModels` | `[]` | Extra `provider/id` refs that should also receive handoff. |
95
98
  | `prewarmPastedImages` | `false` | **Opt-in paste-time prewarm.** When `true`, a custom editor wrapper describes pasted clipboard images the instant their temp-file path lands in the prompt (pre-submit), instead of at submit. Trades a bit of description quality (the description is generated without your typed question as context, since the question usually isn't entered yet at paste time) and a speculative vision call on paste-then-abandon, for earlier prewarm. TUI mode only; inactive if another extension has replaced the editor. |
99
+ | `asyncClipboardHandoff` | `false` | **Opt-in async fallback.** Starts from the existing submit-time prewarm. A matching direct `read` or Fabric-nested `pi.read` cancels message delivery; if no matching read wins, the description is queued asynchronously as a collapsed custom message and triggers/continues the agent turn. |
96
100
  | `maxTokens` | _(unset = model max, clamped to context window)_ | Cap on a single description's output. `null`/unset = use the vision model's declared max output (`model.maxTokens`), clamped so `maxTokens + 8192 <= contextWindow` (a model whose declared max equals its full context window would otherwise be rejected with a 400). Set a number only to cap cost/latency. A truncation is always surfaced via a `[... description truncated โ€ฆ]` marker when the model hits the limit, so a cut-off description is never mistaken for complete. |
97
101
  | `cacheMax` | `50` | Max described images kept in the in-memory cache per session. |
98
102
  | `maxDescriptionLines` | `0` | Cap on description lines (`0` = unbounded). Default keeps the full description so the `read` tool's native collapse (`ctrl+o`) handles compactness and the model gets complete context; setting `> 0` applies a lossy head-cap to both the TUI render and the model. |
@@ -155,11 +159,7 @@ pi -e ./vision-handoff.ts
155
159
 
156
160
  ## How It Works
157
161
 
158
- The extension implements the **Facebook DataLoader pattern** for image
159
- descriptions. The `read` tools are the `load()` callers; a per-image cache
160
- memoizes promises; all `load()` calls in the same execution frame coalesce
161
- into ONE batched vision call, dispatched via `setImmediate` after the poll
162
- phase (so reads completing together batch instead of splitting into N calls).
162
+ The extension implements the **Facebook DataLoader pattern** for image descriptions. The `read` tools are the `load()` callers; a per-image cache memoizes promises; all `load()` calls in the same execution frame coalesce into ONE batched vision call, dispatched via `setImmediate` after the poll phase (so reads completing together batch instead of splitting into N calls).
163
163
 
164
164
  With `prewarmPastedImages` on (opt-in), an even earlier stage runs:
165
165
 
@@ -179,6 +179,8 @@ With `prewarmPastedImages` on (opt-in), an even earlier stage runs:
179
179
  replaced the editor (installing over one would break paste, since
180
180
  pi wires paste-image to the outermost editor only)
181
181
 
182
+ With `asyncClipboardHandoff` on, submit-time prewarm also schedules a non-blocking delivery race. A matching `read` tool call for the pasted path cancels only the custom-message injection (the in-flight description remains available to that read); if description delivery wins, Pi queues it as a steering message. The full description remains model-visible while the transcript shows a one-line row until `Ctrl+O` expands it.
183
+
182
184
  The default submit-time pipeline below runs regardless:
183
185
 
184
186
  โ†’ before_agent_start
@@ -187,9 +189,9 @@ The default submit-time pipeline below runs regardless:
187
189
  โ€ข PRE-WARMS at paste-enter via the loader, two sources coalescing into
188
190
  ONE batch:
189
191
  1. attached image blocks (event.images) โ€” vision-capable targets
190
- 2. pasted clipboard image FILE PATHS in the prompt text โ€” the common
191
- non-vision flow: pi writes each pasted image to
192
- `<tmpdir>/pi-clipboard-<uuid>.<ext>` and inserts the PATH as text
192
+ 2. pasted image FILE PATHS in the prompt text โ€” the common non-vision
193
+ flow: pi-clipboard, localterm-paste, and other paste mechanisms
194
+ write each pasted image to a temp file and insert the PATH as text
193
195
  at the cursor, so on a non-vision model they arrive as path tokens
194
196
  in `event.prompt`, NOT as `event.images`. The extension scans the
195
197
  prompt for those temp paths (confined to the OS temp dir โ€” a
@@ -224,93 +226,25 @@ The default submit-time pipeline below runs regardless:
224
226
  payload (`emitContext` does a `structuredClone`), leaving storage
225
227
  intact for kitty inline rendering and `/resume`
226
228
 
227
- Because the describer runs during the **tool-result phase** (before the
228
- agent's next turn), not during the `context` transform (the critical path
229
- right before the LLM call), the agent gets described text immediately when
230
- its turn starts โ€” it never waits on the describer inline.
229
+ Because the describer runs during the **tool-result phase** (before the agent's next turn), not during the `context` transform (the critical path right before the LLM call), the agent gets described text immediately when its turn starts โ€” it never waits on the describer inline.
231
230
 
232
231
  ### Batching: the DataLoader
233
232
 
234
- A single frame's image set is described with **one** vision-model call, not
235
- one per image. `loadDescription(img)` is synchronous: on a cache miss it
236
- pushes the image's key (hash) into the current batch and returns a memoized
237
- promise; on a cache hit it returns the existing (in-flight or resolved)
238
- promise. `enqueuePostPromiseJob` schedules dispatch via `setImmediate` (the
239
- check phase, after the whole poll phase AND after the microtask queue
240
- drains), so every `load()` caller โ€” whether from sync code, a `.then`
241
- cascade, or a separate I/O callback in the same poll iteration โ€” registers
242
- its key before the single vision call fires. This is why N parallel reads
243
- coalesce into one call rather than splitting into N single-image calls:
244
- `setImmediate` defers past the entire poll phase, whereas DataLoader's
245
- classic `process.nextTick` would drain between the reads' I/O callbacks and
246
- fire a dispatch after the first read but before the second. The batched call
247
- sends every uncached image in a single user message with per-image
248
- `<<<IMAGE k>>> โ€ฆ <<<END>>>` delimiters; the response is parsed back into
249
- per-image descriptions (keyed by `sha256(mime + base64)` in the per-image
250
- cache). If the vision model ignores the delimiter format and the batched
251
- response can't be split, each unparsed image falls back to its own
252
- single-image `completeSimple()` call **in parallel** (no delimiters to cooperate
253
- with) โ€” descriptions still arrive together. Only when a per-image call
254
- itself genuinely fails (auth, timeout, empty) does that image degrade to
255
- `[Image: description unavailable]`; one bad image never voids the rest.
256
-
257
- Because pi runs parallel `read` tool calls via `Promise.all` and fires
258
- each read's `tool_result` event as that read's I/O completes (poll phase) โ€”
259
- via `agent.afterToolCall` โ†’ `finalizeExecutedToolCall` โ€” the loader's
260
- `setImmediate` dispatch defers to the check phase AFTER the whole poll
261
- iteration, so reads completing together share ONE batch โ†’ ONE vision call,
262
- all resolving together. Reads completing in separate poll iterations get
263
- separate calls, but always in parallel, never sequential. (The per-image
264
- cache also dedupes a duplicate `load()` of the same image in one frame:
265
- dispatch resolves every callback by hash, so a second load whose first cache
266
- entry was evicted mid-frame still resolves โ€” it never hangs.)
233
+ A single frame's image set is described with **one** vision-model call, not one per image. `loadDescription(img)` is synchronous: on a cache miss it pushes the image's key (hash) into the current batch and returns a memoized promise; on a cache hit it returns the existing (in-flight or resolved) promise. `enqueuePostPromiseJob` schedules dispatch via `setImmediate` (the check phase, after the whole poll phase AND after the microtask queue drains), so every `load()` caller โ€” whether from sync code, a `.then` cascade, or a separate I/O callback in the same poll iteration โ€” registers its key before the single vision call fires. This is why N parallel reads coalesce into one call rather than splitting into N single-image calls: `setImmediate` defers past the entire poll phase, whereas DataLoader's classic `process.nextTick` would drain between the reads' I/O callbacks and fire a dispatch after the first read but before the second. The batched call sends every uncached image in a single user message with per-image `<<<IMAGE k>>> โ€ฆ <<<END>>>` delimiters; the response is parsed back into per-image descriptions (keyed by `sha256(mime + base64)` in the per-image cache). If the vision model ignores the delimiter format and the batched response can't be split, each unparsed image falls back to its own single-image `completeSimple()` call **in parallel** (no delimiters to cooperate with) โ€” descriptions still arrive together. Only when a per-image call itself genuinely fails (auth, timeout, empty) does that image degrade to `[Image: description unavailable]`; one bad image never voids the rest.
234
+
235
+ Because pi runs parallel `read` tool calls via `Promise.all` and fires each read's `tool_result` event as that read's I/O completes (poll phase) โ€” via `agent.afterToolCall` โ†’ `finalizeExecutedToolCall` โ€” the loader's `setImmediate` dispatch defers to the check phase AFTER the whole poll iteration, so reads completing together share ONE batch โ†’ ONE vision call, all resolving together. Reads completing in separate poll iterations get separate calls, but always in parallel, never sequential. (The per-image cache also dedupes a duplicate `load()` of the same image in one frame: dispatch resolves every callback by hash, so a second load whose first cache entry was evicted mid-frame still resolves โ€” it never hangs.)
267
236
 
268
237
  **Failures are never cached.**
269
238
 
270
- **Failures are never cached.** A genuine describer failure returns
271
- `[Image: description unavailable]` for that turn but is NOT written to the
272
- per-image cache, so the next turn re-attempts (and surfaces the real error
273
- via a `ctx.ui.notify` warning instead of serving a stuck placeholder). This
274
- avoids a transient failure poisoning the cache for the rest of the session.
275
-
276
- **Per-image-set warning.** The `context` hook fires before every LLM turn,
277
- and describer failures aren't cached โ€” so without dedup the same broken
278
- vision model would re-warn every turn. Each distinct image is warned about
279
- at most once per session (tracked by image hash, reset on `/resume`/new
280
- session); subsequent turns for the same failing image degrade silently to
281
- the placeholder instead of spamming.
282
-
283
- **Timeout scales with batch size.** The describer generates an exhaustive
284
- multi-paragraph description per image, and the call is batched (N images โ†’
285
- 1 request). The timeout is `DESCRIBE_TIMEOUT_MS` (120s) plus a per-image
286
- budget (45s ร— (imageCount โˆ’ 1)), so a 5-image batch isn't held to the same
287
- wall-clock budget as one image. A timeout surfaces as `describer timed out
288
- after <N>s` rather than a misleading `stopReason "aborted"`.
289
-
290
- **No silent truncation.** The describer prompt says "be exhaustive", so the
291
- default `maxTokens` is **unset** โ€” the describer uses the vision model's
292
- declared max output (`model.maxTokens`) as the cap, rather than relying on a
293
- provider's small omitted-default. That value is clamped so `maxTokens + 8192
294
- <= contextWindow`: a model whose declared `maxTokens` equals its full
295
- `contextWindow` (e.g. a custom provider that sets both to the same number)
296
- would otherwise be rejected by the provider with a 400 (you can't request
297
- output tokens equal to the entire context window when you also have input),
298
- so the clamp subtracts a small input reserve. If the model still hits a token
299
- limit (a cap you set, or the provider's hard output maximum), `stopReason`
300
- becomes `"length"`; the describer appends a visible
301
- `[... description truncated โ€ฆ]` marker to the (still useful) partial text
302
- rather than letting a cut-off description pass as complete. For a batched call
303
- the marker lands on the last image being emitted when the cap hit (the one cut
304
- off mid-stream); earlier sections had `<<<END>>>` delimiters and are complete.
305
-
306
- **Aborts propagate.** The `context` hook runs in the foreground (pi awaits
307
- it before the LLM call), so a slow describer would also make aborting a turn
308
- slow โ€” pi has to wait for the transform to return. The hook therefore wires
309
- the turn's abort signal (`ctx.signal`, the active run's `AbortController`)
310
- into the describer's `completeSimple()` call, so a user cancel kills the in-flight
311
- vision request immediately. A deliberate abort is not warned about (it's
312
- not a vision-model failure) and the LLM-bound payload is left untouched since
313
- the turn is being torn down anyway.
239
+ **Failures are never cached.** A genuine describer failure returns `[Image: description unavailable]` for that turn but is NOT written to the per-image cache, so the next turn re-attempts (and surfaces the real error via a `ctx.ui.notify` warning instead of serving a stuck placeholder). This avoids a transient failure poisoning the cache for the rest of the session.
240
+
241
+ **Per-image-set warning.** The `context` hook fires before every LLM turn, and describer failures aren't cached โ€” so without dedup the same broken vision model would re-warn every turn. Each distinct image is warned about at most once per session (tracked by image hash, reset on `/resume`/new session); subsequent turns for the same failing image degrade silently to the placeholder instead of spamming.
242
+
243
+ **Timeout scales with batch size.** The describer generates an exhaustive multi-paragraph description per image, and the call is batched (N images โ†’ 1 request). The timeout is `DESCRIBE_TIMEOUT_MS` (120s) plus a per-image budget (45s ร— (imageCount โˆ’ 1)), so a 5-image batch isn't held to the same wall-clock budget as one image. A timeout surfaces as `describer timed out after <N>s` rather than a misleading `stopReason "aborted"`.
244
+
245
+ **No silent truncation.** The describer prompt says "be exhaustive", so the default `maxTokens` is **unset** โ€” the describer uses the vision model's declared max output (`model.maxTokens`) as the cap, rather than relying on a provider's small omitted-default. That value is clamped so `maxTokens + 8192 <= contextWindow`: a model whose declared `maxTokens` equals its full `contextWindow` (e.g. a custom provider that sets both to the same number) would otherwise be rejected by the provider with a 400 (you can't request output tokens equal to the entire context window when you also have input), so the clamp subtracts a small input reserve. If the model still hits a token limit (a cap you set, or the provider's hard output maximum), `stopReason` becomes `"length"`; the describer appends a visible `[... description truncated โ€ฆ]` marker to the (still useful) partial text rather than letting a cut-off description pass as complete. For a batched call the marker lands on the last image being emitted when the cap hit (the one cut off mid-stream); earlier sections had `<<<END>>>` delimiters and are complete.
246
+
247
+ **Aborts propagate.** The `context` hook runs in the foreground (pi awaits it before the LLM call), so a slow describer would also make aborting a turn slow โ€” pi has to wait for the transform to return. The hook therefore wires the turn's abort signal (`ctx.signal`, the active run's `AbortController`) into the describer's `completeSimple()` call, so a user cancel kills the in-flight vision request immediately. A deliberate abort is not warned about (it's not a vision-model failure) and the LLM-bound payload is left untouched since the turn is being torn down anyway.
314
248
 
315
249
  ### Image-block formats handled
316
250
 
@@ -321,10 +255,7 @@ the turn is being torn down anyway.
321
255
  | `context` (all messages) | `{ type: "input_image", image_url: "data:โ€ฆ" }` (OpenAI Responses) | detected by shape โ†’ replaced with `{ type: "input_text", text }` |
322
256
  | `context` (all messages) | `{ type: "image", source: { type: "base64", media_type, data } }` (Anthropic Messages) | detected by shape โ†’ replaced with `{ type: "text", text }` |
323
257
 
324
- The describer call itself goes through pi's normal model machinery (`completeSimple()`),
325
- **not** the agent event loop โ€” so it never re-triggers `context` (no recursion).
326
- The `read` tool result keeps its image block untouched (kitty inline + `/resume`);
327
- only the `context`-cloned LLM-bound payload has images swapped for text.
258
+ The describer call itself goes through pi's normal model machinery (`completeSimple()`), **not** the agent event loop โ€” so it never re-triggers `context` (no recursion). The `read` tool result keeps its image block untouched (kitty inline + `/resume`); only the `context`-cloned LLM-bound payload has images swapped for text.
328
259
 
329
260
  ### Usage reporting
330
261
 
@@ -370,7 +301,7 @@ One record is emitted per **real** describer call (a batched call describing sev
370
301
 
371
302
  ```bash
372
303
  pnpm install
373
- pnpm test # Vitest unit tests (77 passing)
304
+ pnpm test # Vitest unit tests
374
305
  pnpm typecheck # TypeScript validation
375
306
  pnpm lint:dead # Dead code detection (knip)
376
307
  ```
@@ -407,11 +338,7 @@ pnpm lint:dead # Dead code detection (knip)
407
338
 
408
339
  ## Acknowledgements
409
340
 
410
- The vision handoff concept and the exhaustive describer prompt originate from
411
- the [pi-umans-provider](https://github.com/monotykamary/pi-umans-provider) GLM 5.1
412
- pipeline. The picker TUI builds on the patterns from
413
- [pi-hide-providers](https://github.com/monotykamary/pi-hide-providers), which in
414
- turn mirror pi core's built-in selectors.
341
+ The vision handoff concept and the exhaustive describer prompt originate from the [pi-umans-provider](https://github.com/monotykamary/pi-umans-provider) GLM 5.1 pipeline. The picker TUI builds on the patterns from [pi-hide-providers](https://github.com/monotykamary/pi-hide-providers), which in turn mirror pi core's built-in selectors.
415
342
 
416
343
  ## License
417
344
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vision-handoff",
3
- "version": "0.8.2",
3
+ "version": "0.9.1",
4
4
  "description": "Give text-only pi models vision โ€” describe images with a vision model you pick via an interactive picker, then hand off the text description to non-vision models",
5
5
  "type": "module",
6
6
  "author": "Tom X Nguyen",
package/src/image.ts CHANGED
@@ -282,24 +282,30 @@ export function readImageBufferBounded(filePath: string): { buf: Buffer; mimeTyp
282
282
  return readImageBuffer(filePath);
283
283
  }
284
284
 
285
- /** Regex matching pi's pasted-clipboard temp image file paths anywhere in the
286
- * prompt text. pi writes pasted clipboard images to
287
- * `<tmpdir>/pi-clipboard-<uuid>.<ext>` and inserts the path as text at the
288
- * cursor, so on a non-vision model these arrive as path tokens in the user
289
- * prompt โ€” NOT as `event.images`. Matching them lets the extension pre-warm
290
- * the describer at paste-enter (concurrent with the agent's first response)
291
- * instead of waiting for the agent to `read` them. */
292
- const CLIPBOARD_IMAGE_PATH_RE = /(\S*pi-clipboard-[^\s]+\.(?:png|jpe?g|gif|webp))/gi;
285
+ /** Regex matching any pasted temp-image file path in the prompt text,
286
+ * regardless of which paste mechanism wrote it (pi-clipboard,
287
+ * localterm-paste, and others). We match any non-whitespace token ending in
288
+ * a supported image extension, then confine to the OS temp directory below.
289
+ * The confinement, not the filename pattern, is the security boundary. */
290
+ const PASTED_IMAGE_PATH_RE = /(\S+\.(?:png|jpe?g|gif|webp))/gi;
291
+ const URL_RE = new RegExp('^[a-z][a-z0-9+.-]*://', 'i');
292
+ const LEADING_WRAP_RE = new RegExp('^[^A-Za-z0-9_/@.~-]+');
293
293
 
294
- /** Extract pasted clipboard image file paths from a prompt. Confined to the
295
- * OS temp directory so an attacker-crafted prompt can't trick the extension
296
- * into reading arbitrary files โ€” only pi's own clipboard temp files qualify. */
297
- export function findClipboardImagePaths(prompt: string): string[] {
294
+ /** Extract pasted image file paths from a prompt. Confined to the OS temp
295
+ * directory so an attacker-crafted prompt can't trick the extension into
296
+ * reading arbitrary files โ€” only temp-dir image files qualify (this covers
297
+ * every paste mechanism: pi-clipboard, localterm-paste, and others). URLs
298
+ * (http(s)://, file://, ...) are skipped so they aren't joined into the temp
299
+ * dir and mistaken for local files; leading wrapping chars (parentheses,
300
+ * quotes) are stripped so quoted/parenthesized paths still resolve. */
301
+ export function findPastedImagePaths(prompt: string): string[] {
298
302
  const tmp = tmpdir();
299
303
  const paths = new Set<string>();
300
- for (const m of prompt.matchAll(CLIPBOARD_IMAGE_PATH_RE)) {
301
- const p = m[1];
302
- if (!p) continue;
304
+ for (const m of prompt.matchAll(PASTED_IMAGE_PATH_RE)) {
305
+ const token = m[1];
306
+ if (!token) continue;
307
+ if (URL_RE.test(token)) continue;
308
+ const p = token.replace(LEADING_WRAP_RE, "");
303
309
  const abs = isAbsolute(p) ? p : join(tmp, p);
304
310
  // Ensure the resolved candidate stays inside the temp directory.
305
311
  if (abs.startsWith(tmp + sep) || abs === tmp) paths.add(abs);
@@ -307,16 +313,16 @@ export function findClipboardImagePaths(prompt: string): string[] {
307
313
  return [...paths];
308
314
  }
309
315
 
310
- /** Diff the clipboard image paths in `text` against `known`, returning those
316
+ /** Diff the pasted image paths in `text` against `known`, returning those
311
317
  * that are newly appeared (not yet seen). Used by the paste-time prewarm
312
318
  * editor to prewarm only newly-pasted paths on each text change, without
313
- * re-reading already-seen ones. `findClipboardImagePaths` already dedups
319
+ * re-reading already-seen ones. `findPastedImagePaths` already dedups
314
320
  * within a single text, so each returned path is unique and seen at most
315
- * once. Returns [] when `text` holds no clipboard paths (e.g. ordinary
321
+ * once. Returns [] when `text` holds no pasted paths (e.g. ordinary
316
322
  * typing) โ€” so the editor's per-keystroke cost when the opt-in is on is one
317
323
  * regex scan that almost always yields nothing. */
318
324
  export function diffPrewarmPaths(text: string, known: Set<string>): string[] {
319
- const paths = findClipboardImagePaths(text);
325
+ const paths = findPastedImagePaths(text);
320
326
  const newPaths: string[] = [];
321
327
  for (const p of paths) {
322
328
  if (!known.has(p)) newPaths.push(p);
package/src/index.ts CHANGED
@@ -149,6 +149,11 @@ export interface VisionHandoffConfig {
149
149
  * a custom editor would break clipboard paste, since pi wires paste-image
150
150
  * to the outermost editor only). */
151
151
  prewarmPastedImages: boolean;
152
+ /** Opt-in: asynchronously inject descriptions for pasted clipboard paths when
153
+ * the target model does not read them. The normal read/tool_result path wins
154
+ * the race: a matching read cancels the queued fallback injection while
155
+ * reusing the same in-flight description. */
156
+ asyncClipboardHandoff: boolean;
152
157
  /** Max output tokens for a single description. `undefined` (default) = use the
153
158
  * vision model's declared max output (`model.maxTokens`) as the cap โ€” the
154
159
  * highest the model supports โ€” so the "be exhaustive" prompt isn't defeated
@@ -186,6 +191,7 @@ export const DEFAULT_CONFIG: VisionHandoffConfig = {
186
191
  autoHandoff: true,
187
192
  handoffModels: [],
188
193
  prewarmPastedImages: false,
194
+ asyncClipboardHandoff: false,
189
195
  maxTokens: undefined,
190
196
  cacheMax: DEFAULT_CACHE_MAX,
191
197
  maxDescriptionLines: DEFAULT_MAX_DESCRIPTION_LINES,
@@ -236,6 +242,9 @@ export function normalizeConfig(raw: unknown): VisionHandoffConfig {
236
242
  .filter((m) => m && parseModelRef(m));
237
243
  }
238
244
  if (typeof obj.prewarmPastedImages === "boolean") base.prewarmPastedImages = obj.prewarmPastedImages;
245
+ if (typeof obj.asyncClipboardHandoff === "boolean") {
246
+ base.asyncClipboardHandoff = obj.asyncClipboardHandoff;
247
+ }
239
248
  // maxTokens: optional. undefined (default) = no artificial cap. Only set when
240
249
  // a valid positive finite number is given; any other value leaves it unset.
241
250
  if (typeof obj.maxTokens === "number" && Number.isFinite(obj.maxTokens) && obj.maxTokens > 0) {
@@ -21,6 +21,8 @@ import {
21
21
  matchesKey,
22
22
  Spacer,
23
23
  Text,
24
+ truncateToWidth,
25
+ wrapTextWithAnsi,
24
26
  } from "@earendil-works/pi-tui";
25
27
  import type { Theme } from "@earendil-works/pi-coding-agent";
26
28
  import type { ThinkingLevel } from "@earendil-works/pi-ai";
@@ -48,6 +50,8 @@ export interface VisionModelSelectorResult {
48
50
  thinking: boolean;
49
51
  /** Thinking effort chosen in the picker. */
50
52
  thinkingLevel: ThinkingLevel;
53
+ /** Whether pasted paths should be injected if no matching read wins. */
54
+ asyncClipboardHandoff: boolean;
51
55
  }
52
56
 
53
57
  export class VisionModelSelectorComponent implements Component {
@@ -65,6 +69,7 @@ export class VisionModelSelectorComponent implements Component {
65
69
  private currentRef: string | null;
66
70
  private thinking: boolean;
67
71
  private thinkingLevel: ThinkingLevel;
72
+ private asyncClipboardHandoff: boolean;
68
73
 
69
74
  private _focused = false;
70
75
  get focused(): boolean {
@@ -87,6 +92,7 @@ export class VisionModelSelectorComponent implements Component {
87
92
  currentRef: string | null,
88
93
  currentThinking: boolean,
89
94
  currentThinkingLevel: ThinkingLevel,
95
+ currentAsyncClipboardHandoff: boolean,
90
96
  done: (result: VisionModelSelectorResult) => void,
91
97
  ) {
92
98
  this.theme = theme;
@@ -94,6 +100,7 @@ export class VisionModelSelectorComponent implements Component {
94
100
  this.currentRef = currentRef;
95
101
  this.thinking = currentThinking;
96
102
  this.thinkingLevel = currentThinkingLevel;
103
+ this.asyncClipboardHandoff = currentAsyncClipboardHandoff;
97
104
  this.allItems = this.buildItems(allModels);
98
105
  this.filteredItems = this.allItems;
99
106
 
@@ -116,9 +123,21 @@ export class VisionModelSelectorComponent implements Component {
116
123
  const lines: string[] = [];
117
124
  lines.push(...new DynamicBorder((s) => this.theme.fg("accent", s)).render(width));
118
125
  lines.push("");
119
- lines.push(this.theme.fg("accent", this.theme.bold("Vision Handoff")));
120
126
  lines.push(
121
- this.theme.fg("muted", "Pick a vision-capable model to describe images for text-only models."),
127
+ truncateToWidth(
128
+ this.theme.fg("accent", this.theme.bold("Vision Handoff")),
129
+ width,
130
+ "",
131
+ ),
132
+ );
133
+ lines.push(
134
+ ...wrapTextWithAnsi(
135
+ this.theme.fg(
136
+ "muted",
137
+ "Pick a vision-capable model to describe images for text-only models.",
138
+ ),
139
+ width,
140
+ ),
122
141
  );
123
142
  lines.push("");
124
143
  lines.push(...this.searchInput.render(width));
@@ -127,7 +146,7 @@ export class VisionModelSelectorComponent implements Component {
127
146
  lines.push("");
128
147
  lines.push(...this.footerText.render(width));
129
148
  lines.push(...new DynamicBorder((s) => this.theme.fg("accent", s)).render(width));
130
- return lines;
149
+ return lines.map((line) => truncateToWidth(line, width, ""));
131
150
  }
132
151
 
133
152
  handleInput(data: string): void {
@@ -180,6 +199,12 @@ export class VisionModelSelectorComponent implements Component {
180
199
  return;
181
200
  }
182
201
 
202
+ if (matchesKey(data, Key.ctrl("a"))) {
203
+ this.asyncClipboardHandoff = !this.asyncClipboardHandoff;
204
+ this.updateList();
205
+ return;
206
+ }
207
+
183
208
  // Thinking controls โ€” reuse pi's own app.thinking.* keybindings so the
184
209
  // hints and behaviour match the rest of pi: ctrl+t toggles thinking
185
210
  // on/off, shift+tab cycles the effort level. Intercepted before the
@@ -263,6 +288,7 @@ export class VisionModelSelectorComponent implements Component {
263
288
  `ctrl+s done`,
264
289
  `${keyText("app.thinking.toggle")} thinking`,
265
290
  `${keyText("app.thinking.cycle")} effort`,
291
+ `ctrl+a async fallback`,
266
292
  `esc cancel`,
267
293
  this.searchInput.getValue() ? `${this.filteredItems.length - 1} match` : `${totalCount} models ยท ${visionCount} vision`,
268
294
  ];
@@ -365,6 +391,12 @@ export class VisionModelSelectorComponent implements Component {
365
391
  );
366
392
  }
367
393
  this.renderThinkingDetail(selected);
394
+ const fallback = this.asyncClipboardHandoff
395
+ ? this.theme.fg("success", "on")
396
+ : this.theme.fg("muted", "off");
397
+ this.listContainer.addChild(
398
+ new Text(this.theme.fg("dim", ` Async pasted-path fallback: ${fallback}`), 0, 0),
399
+ );
368
400
  }
369
401
 
370
402
  this.footerText.setText(this.getFooterText());
@@ -394,11 +426,23 @@ export class VisionModelSelectorComponent implements Component {
394
426
  }
395
427
 
396
428
  private confirm(item: DisplayItem): void {
397
- this.done({ ref: item.ref, cancelled: false, thinking: this.thinking, thinkingLevel: this.thinkingLevel });
429
+ this.done({
430
+ ref: item.ref,
431
+ cancelled: false,
432
+ thinking: this.thinking,
433
+ thinkingLevel: this.thinkingLevel,
434
+ asyncClipboardHandoff: this.asyncClipboardHandoff,
435
+ });
398
436
  }
399
437
 
400
438
  private finish(cancelled: boolean): void {
401
- this.done({ ref: null, cancelled, thinking: this.thinking, thinkingLevel: this.thinkingLevel });
439
+ this.done({
440
+ ref: null,
441
+ cancelled,
442
+ thinking: this.thinking,
443
+ thinkingLevel: this.thinkingLevel,
444
+ asyncClipboardHandoff: this.asyncClipboardHandoff,
445
+ });
402
446
  }
403
447
 
404
448
  /** Cycle the thinking effort forward through {@link THINKING_LEVELS},
package/vision-handoff.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  * before_agent_start โ†’ warm the description cache for attached images AND
12
12
  * pasted clipboard image file paths found in the prompt text (pre-warm at
13
13
  * paste-enter, concurrent with the agent's first response).
14
+ * optional async clipboard fallback โ†’ race matching read tool calls against a
15
+ * non-blocking steering-message injection; matching reads cancel delivery.
14
16
  * tool_result (read) โ†’ loadDescription() each read-tool image and AWAIT the
15
17
  * shared batch, so N parallel reads coalesce into ONE vision call. The
16
18
  * descriptions land in the tool results before the agent's next turn.
@@ -51,11 +53,12 @@ import {
51
53
  type VisionHandoffUsageRecord,
52
54
  } from "./src/usage.js";
53
55
  import { DescriptionLoader, UNAVAILABLE, type LoaderDeps } from "./src/dataloader.js";
54
- import { imageHash, findClipboardImagePaths, readImageBuffer, readImageBufferBounded, resolvePrewarmImage, isOmittedImageNote } from "./src/image.js";
56
+ import { imageHash, findPastedImagePaths, readImageBuffer, readImageBufferBounded, resolvePrewarmImage, isOmittedImageNote } from "./src/image.js";
55
57
  import { appendVisionError } from "./src/error-log.js";
56
58
  import { resizeImage } from "@earendil-works/pi-coding-agent";
57
59
  import { VisionModelSelectorComponent, type VisionModelSelectorResult } from "./src/vision-model-selector.js";
58
60
  import { PrewarmEditor } from "./src/prewarm-editor.js";
61
+ import { Text } from "@earendil-works/pi-tui";
59
62
 
60
63
  let config: VisionHandoffConfig = readConfig();
61
64
 
@@ -85,6 +88,110 @@ let editorInstalled = false;
85
88
  let visionModelCache: { ref: string; model: Model<Api> } | null = null;
86
89
  let visionModelUnresolvedRef: string | null = null;
87
90
 
91
+ interface PendingAsyncClipboardHandoff {
92
+ token: symbol;
93
+ paths: Set<string>;
94
+ cancelled: boolean;
95
+ }
96
+
97
+ interface PreparedClipboardImage {
98
+ path: string;
99
+ image: ExtractedImage;
100
+ description: Promise<string>;
101
+ }
102
+
103
+ let pendingAsyncClipboardHandoff: PendingAsyncClipboardHandoff | null = null;
104
+
105
+ function cancelAsyncClipboardHandoff(): void {
106
+ if (!pendingAsyncClipboardHandoff) return;
107
+ pendingAsyncClipboardHandoff.cancelled = true;
108
+ pendingAsyncClipboardHandoff = null;
109
+ }
110
+
111
+ function isPendingClipboardRead(input: unknown, cwd: string): boolean {
112
+ const pending = pendingAsyncClipboardHandoff;
113
+ if (!pending || !input || typeof input !== "object") return false;
114
+ const raw = (input as { path?: unknown }).path;
115
+ if (typeof raw !== "string") return false;
116
+ const path = raw.startsWith("@") ? raw.slice(1) : raw;
117
+ const absolute = isAbsolute(path) ? path : resolve(cwd, path);
118
+ return pending.paths.has(absolute);
119
+ }
120
+
121
+ function scheduleAsyncClipboardInjection(
122
+ pi: ExtensionAPI,
123
+ ctx: ExtensionContext,
124
+ paths: string[],
125
+ prepared: Promise<PreparedClipboardImage | null>[],
126
+ ): void {
127
+ cancelAsyncClipboardHandoff();
128
+ const pending: PendingAsyncClipboardHandoff = {
129
+ token: Symbol("async-clipboard-handoff"),
130
+ paths: new Set(paths),
131
+ cancelled: false,
132
+ };
133
+ pendingAsyncClipboardHandoff = pending;
134
+
135
+ void (async () => {
136
+ const entries = (await Promise.all(prepared)).filter(
137
+ (entry): entry is PreparedClipboardImage => entry !== null,
138
+ );
139
+ if (entries.length === 0) {
140
+ if (pendingAsyncClipboardHandoff?.token === pending.token) {
141
+ pendingAsyncClipboardHandoff = null;
142
+ }
143
+ return;
144
+ }
145
+ const descriptions = await Promise.all(entries.map((entry) => entry.description));
146
+
147
+ // Always yield out of before_agent_start, even when paste-time prewarm made
148
+ // every description an immediate cache hit. This lets Pi enter the active
149
+ // agent run before sendMessage queues the fallback as a steering message.
150
+ await new Promise<void>((resolveImmediate) => setImmediate(resolveImmediate));
151
+ if (
152
+ pending.cancelled ||
153
+ pendingAsyncClipboardHandoff?.token !== pending.token ||
154
+ !config.asyncClipboardHandoff ||
155
+ !isConfigured(config)
156
+ ) {
157
+ return;
158
+ }
159
+
160
+ warnFailedImages(
161
+ ctx,
162
+ entries.map((entry) => entry.image),
163
+ descriptions,
164
+ lastDescriberError ?? "unknown error",
165
+ );
166
+ const content = [
167
+ "Asynchronous vision handoff for pasted image path(s):",
168
+ ...entries.map(
169
+ (entry, index) => `${entry.path}\n${descriptions[index] ?? UNAVAILABLE}`,
170
+ ),
171
+ ].join("\n\n");
172
+
173
+ pendingAsyncClipboardHandoff = null;
174
+ try {
175
+ pi.sendMessage(
176
+ {
177
+ customType: "vision-handoff-async",
178
+ content,
179
+ display: true,
180
+ details: { imageCount: entries.length },
181
+ },
182
+ { deliverAs: "steer", triggerTurn: true },
183
+ );
184
+ } catch {
185
+ // The session may have been replaced/reloaded while the description was
186
+ // in flight. The stale extension instance must not affect the new one.
187
+ }
188
+ })().catch(() => {
189
+ if (pendingAsyncClipboardHandoff?.token === pending.token) {
190
+ pendingAsyncClipboardHandoff = null;
191
+ }
192
+ });
193
+ }
194
+
88
195
  // Usage reporter; wired to pi.appendEntry + pi.events.emit in the default
89
196
  // export. No-op until then so the describer is safe to call before wiring.
90
197
  let reportUsage: (record: VisionHandoffUsageRecord) => void = () => {};
@@ -236,6 +343,24 @@ function warnFailedImages(
236
343
  export default function (pi: ExtensionAPI) {
237
344
  config = readConfig();
238
345
 
346
+ pi.registerMessageRenderer("vision-handoff-async", (message, { expanded }, theme) => {
347
+ const details = message.details as { imageCount?: number } | undefined;
348
+ const count = details?.imageCount ?? 1;
349
+ const label = `Vision handoff ยท ${count} pasted image${count === 1 ? "" : "s"}`;
350
+ const hint = expanded ? "Ctrl+O to collapse" : "Ctrl+O to expand";
351
+ const summary = theme.fg("dim", `๐Ÿ‘ ${label} ยท ${hint}`);
352
+ if (!expanded) return new Text(summary, 0, 0);
353
+ // Apply the dim (grey) style per line: the TUI appends a full SGR reset at
354
+ // the end of each rendered line, so a single style wrapper would only tint
355
+ // the first line. Text wraps with wrapTextWithAnsi (ANSI-preserving), and
356
+ // each line here carries its own dim code so wrapped continuations stay grey.
357
+ const contentStr = typeof message.content === "string" ? message.content : "";
358
+ const body = contentStr.length > 0
359
+ ? contentStr.split("\n").map((line) => theme.fg("dim", line)).join("\n")
360
+ : "";
361
+ return new Text(body ? `${summary}\n${body}` : summary, 0, 0);
362
+ });
363
+
239
364
  // Wire the usage reporter to pi's persistence + event bus. appendEntry
240
365
  // persists the record so it replays on session resume/branch, and the event
241
366
  // lets live consumers filter on one channel for tokens AND energy. Each call
@@ -257,6 +382,7 @@ export default function (pi: ExtensionAPI) {
257
382
  };
258
383
 
259
384
  pi.on("session_start", async (_event, ctx) => {
385
+ cancelAsyncClipboardHandoff();
260
386
  // Reload in case the user edited the config on disk from another session.
261
387
  config = readConfig();
262
388
  visionModelCache = null;
@@ -268,6 +394,7 @@ export default function (pi: ExtensionAPI) {
268
394
  });
269
395
 
270
396
  pi.on("before_agent_start", async (event, ctx) => {
397
+ cancelAsyncClipboardHandoff();
271
398
  if (!isConfigured(config)) return;
272
399
  if (!isHandoffTarget(ctx.model, config)) return;
273
400
 
@@ -298,9 +425,10 @@ export default function (pi: ExtensionAPI) {
298
425
  // 1. Attached image blocks (event.images) โ€” vision-capable targets where
299
426
  // the user message itself carries image blocks (e.g. `pi --image`).
300
427
  //
301
- // 2. Pasted clipboard image FILE PATHS in the prompt text โ€” the common
302
- // non-vision flow. pi's `handleClipboardImagePaste` writes each pasted
303
- // image to `<tmpdir>/pi-clipboard-<uuid>.<ext>` and inserts the PATH as
428
+ // 2. Pasted image FILE PATHS in the prompt text โ€” the common non-vision
429
+ // flow. pi's `handleClipboardImagePaste` (and other paste mechanisms like
430
+ // localterm-paste) write each pasted image to a temp file and insert the
431
+ // PATH as
304
432
  // text at the cursor; on a non-vision model these arrive as path tokens
305
433
  // in `event.prompt`, NOT as `event.images`. We scan the prompt for those
306
434
  // temp paths, read the files, and `loadDescription()` them so the ONE
@@ -331,17 +459,49 @@ export default function (pi: ExtensionAPI) {
331
459
  // describes an image the agent will see). A file that can't be read, isn't
332
460
  // a supported image, or fails to resize is skipped (the agent's `read`
333
461
  // will still describe it via `tool_result` if it emits an image block).
334
- for (const p of findClipboardImagePaths(event.prompt || "")) {
335
- const read = readImageBuffer(p);
336
- if (!read) continue;
337
- resolvePrewarmImage(read.buf, read.mimeType, resizeImage)
338
- .then((img) => {
339
- if (img) loader.loadDescription(img).catch(() => {});
340
- })
341
- .catch(() => {});
462
+ const clipboardPaths = findPastedImagePaths(event.prompt || "");
463
+ const preparedClipboardImages = clipboardPaths.map(
464
+ async (path): Promise<PreparedClipboardImage | null> => {
465
+ try {
466
+ const read = readImageBuffer(path);
467
+ if (!read) return null;
468
+ const image = await resolvePrewarmImage(read.buf, read.mimeType, resizeImage);
469
+ if (!image) return null;
470
+ const description = loader.loadDescription(image);
471
+ description.catch(() => {});
472
+ return { path, image, description };
473
+ } catch {
474
+ return null;
475
+ }
476
+ },
477
+ );
478
+
479
+ if (config.asyncClipboardHandoff && clipboardPaths.length > 0) {
480
+ scheduleAsyncClipboardInjection(pi, ctx, clipboardPaths, preparedClipboardImages);
481
+ } else {
482
+ // Start every preparation task even when no consumer awaits it. Each task
483
+ // calls loadDescription as soon as resize completes, preserving the
484
+ // original fire-and-forget submit-time prewarm behavior.
485
+ for (const prepared of preparedClipboardImages) prepared.catch(() => {});
342
486
  }
343
487
  });
344
488
 
489
+ // A direct read and a nested pi.read both emit a read tool_call. If it targets
490
+ // one of this turn's pasted clipboard paths before the async fallback is
491
+ // injected, the normal tool_result/context path wins and the queued custom
492
+ // message is cancelled. The in-flight description is deliberately retained:
493
+ // the read result reuses it as a cache/in-flight hit.
494
+ pi.on("tool_call", (event, ctx) => {
495
+ if (event.toolName !== "read" && event.toolName !== "pi.read") return;
496
+ if (isPendingClipboardRead(event.input, ctx.cwd)) {
497
+ cancelAsyncClipboardHandoff();
498
+ }
499
+ });
500
+
501
+ pi.on("session_shutdown", () => {
502
+ cancelAsyncClipboardHandoff();
503
+ });
504
+
345
505
  // The PRIMARY injection point: the `read` tool's `tool_result` handler.
346
506
  // When the agent reads image files, this fires for each read result. It
347
507
  // calls the loader's `loadDescription(img)` for every image block and
@@ -565,7 +725,7 @@ export default function (pi: ExtensionAPI) {
565
725
  pi.registerCommand("vision-handoff", {
566
726
  description: HANDOFF_COMMAND_DESCRIPTION,
567
727
  getArgumentCompletions(prefix: string) {
568
- const subcommands = ["select", "model", "status", "enable", "disable", "auto", "thinking", "prewarm", "add", "remove", "clear", "help"];
728
+ const subcommands = ["select", "model", "status", "enable", "disable", "auto", "thinking", "prewarm", "fallback", "add", "remove", "clear", "help"];
569
729
  const matches = subcommands.filter((s) => s.startsWith(prefix));
570
730
  return matches.length > 0 ? matches.map((s) => ({ value: s, label: s })) : null;
571
731
  },
@@ -601,6 +761,8 @@ async function handleHandoffCommand(ctx: ExtensionCommandContext, args: string):
601
761
  " Set the vision describer's thinking effort (off = disabled)",
602
762
  " /vision-handoff prewarm <on|off>",
603
763
  " Toggle describing pasted images at paste-time (opt-in, off by default)",
764
+ " /vision-handoff fallback <on|off>",
765
+ " Inject pasted-image descriptions asynchronously when no matching read wins",
604
766
  " /vision-handoff add <p/id> Force handoff for an extra model",
605
767
  " /vision-handoff remove <p/id> Stop forcing handoff for a model",
606
768
  " /vision-handoff clear Clear the configured vision model",
@@ -611,6 +773,7 @@ async function handleHandoffCommand(ctx: ExtensionCommandContext, args: string):
611
773
  " read images through a dataloader (one batched vision call); context swaps",
612
774
  " image blocks in the payload for the cached text description.",
613
775
  " prewarm on wraps the editor to describe pasted images at paste-time.",
776
+ " fallback on asynchronously injects a collapsed description unless a matching read wins.",
614
777
  ].join("\n"),
615
778
  "info",
616
779
  );
@@ -657,6 +820,11 @@ async function handleHandoffCommand(ctx: ExtensionCommandContext, args: string):
657
820
  return;
658
821
  }
659
822
 
823
+ if (subcommand === "fallback") {
824
+ handleFallbackSubcommand(ctx, rest);
825
+ return;
826
+ }
827
+
660
828
  if (subcommand === "clear") {
661
829
  updateConfig(
662
830
  ctx,
@@ -743,6 +911,7 @@ function updateConfig(
743
911
  message: string,
744
912
  ): void {
745
913
  const next = transform(config);
914
+ if (!next.asyncClipboardHandoff) cancelAsyncClipboardHandoff();
746
915
  const path = writeConfig(next);
747
916
  config = next;
748
917
  visionModelCache = null;
@@ -806,6 +975,31 @@ function handlePrewarmSubcommand(ctx: ExtensionCommandContext, rest: string): vo
806
975
  updateConfig(ctx, (c) => ({ ...c, prewarmPastedImages: on }), note);
807
976
  }
808
977
 
978
+ /** Handle /vision-handoff fallback <on|off>. */
979
+ function handleFallbackSubcommand(ctx: ExtensionCommandContext, rest: string): void {
980
+ const value = rest.trim().toLowerCase();
981
+ if (!value) {
982
+ ctx.ui.notify(
983
+ `Async pasted-path fallback: ${config.asyncClipboardHandoff ? "on" : "off"}.\n` +
984
+ "Usage: /vision-handoff fallback <on|off>",
985
+ "info",
986
+ );
987
+ return;
988
+ }
989
+ if (value !== "on" && value !== "off") {
990
+ ctx.ui.notify("Usage: /vision-handoff fallback <on|off>", "warning");
991
+ return;
992
+ }
993
+ const on = value === "on";
994
+ updateConfig(
995
+ ctx,
996
+ (c) => ({ ...c, asyncClipboardHandoff: on }),
997
+ on
998
+ ? "Async pasted-path fallback on โ€” a matching read wins the race; otherwise the description is injected as a collapsed message."
999
+ : "Async pasted-path fallback off.",
1000
+ );
1001
+ }
1002
+
809
1003
  async function showSelector(ctx: ExtensionCommandContext): Promise<void> {
810
1004
  if (!ctx.hasUI) {
811
1005
  ctx.ui.notify("/vision-handoff requires interactive mode.", "error");
@@ -825,12 +1019,15 @@ async function showSelector(ctx: ExtensionCommandContext): Promise<void> {
825
1019
  if (thinkingPick === undefined) return;
826
1020
  const thinking = thinkingPick === "on";
827
1021
  const thinkingLevel = config.thinkingLevel;
1022
+ const fallbackPick = await ctx.ui.select("Async pasted-path fallback", ["on", "off"]);
1023
+ if (fallbackPick === undefined) return;
1024
+ const asyncClipboardHandoff = fallbackPick === "on";
828
1025
  const thinkingNote = thinking
829
1026
  ? `thinking on (${thinkingLevel})${ref ? " \u2014 applies only if the vision model supports reasoning" : ""}`
830
1027
  : "thinking off";
831
1028
  updateConfig(
832
1029
  ctx,
833
- (c) => ({ ...c, visionModel: ref, thinking, thinkingLevel }),
1030
+ (c) => ({ ...c, visionModel: ref, thinking, thinkingLevel, asyncClipboardHandoff }),
834
1031
  ref ? `Vision model set to ${ref} \u00b7 ${thinkingNote}` : `Vision model cleared \u00b7 ${thinkingNote}`,
835
1032
  );
836
1033
  if (!ref) {
@@ -846,6 +1043,7 @@ async function showSelector(ctx: ExtensionCommandContext): Promise<void> {
846
1043
  config.visionModel,
847
1044
  config.thinking,
848
1045
  config.thinkingLevel,
1046
+ config.asyncClipboardHandoff,
849
1047
  (r) => done(r),
850
1048
  );
851
1049
  return {
@@ -870,6 +1068,7 @@ async function showSelector(ctx: ExtensionCommandContext): Promise<void> {
870
1068
  const ref = result.ref;
871
1069
  const thinking = result.thinking;
872
1070
  const thinkingLevel = result.thinkingLevel;
1071
+ const asyncClipboardHandoff = result.asyncClipboardHandoff;
873
1072
  // Fold the thinking state into the single updateConfig notify so the
874
1073
  // "thinking off" message can't overwrite the model-change message.
875
1074
  const thinkingNote = thinking
@@ -877,7 +1076,7 @@ async function showSelector(ctx: ExtensionCommandContext): Promise<void> {
877
1076
  : "thinking off";
878
1077
  updateConfig(
879
1078
  ctx,
880
- (c) => ({ ...c, visionModel: ref, thinking, thinkingLevel }),
1079
+ (c) => ({ ...c, visionModel: ref, thinking, thinkingLevel, asyncClipboardHandoff }),
881
1080
  ref ? `Vision model set to ${ref} ยท ${thinkingNote}` : `Vision model cleared ยท ${thinkingNote}`,
882
1081
  );
883
1082
  if (!ref) {
@@ -893,6 +1092,7 @@ function showStatus(ctx: ExtensionCommandContext): void {
893
1092
  lines.push(`Handoff targets (explicit): ${config.handoffModels.length ? config.handoffModels.join(", ") : "(none)"}`);
894
1093
  lines.push(`Thinking: ${config.thinking ? `on (${config.thinkingLevel})` : "off"}`);
895
1094
  lines.push(`Paste-time prewarm: ${config.prewarmPastedImages ? `on${editorInstalled ? "" : " (inactive โ€” another custom editor is active)"}` : "off"}`);
1095
+ lines.push(`Async pasted-path fallback: ${config.asyncClipboardHandoff ? "on" : "off"}`);
896
1096
  lines.push(`maxTokens: ${config.maxTokens ?? "unbounded"} ยท cacheMax: ${config.cacheMax} ยท maxDescriptionLines: ${config.maxDescriptionLines === 0 ? "unbounded" : config.maxDescriptionLines}`);
897
1097
 
898
1098
  const model = ctx.model;