pi-vision-handoff 0.5.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vision-handoff",
3
- "version": "0.5.0",
3
+ "version": "0.5.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/dataloader.ts CHANGED
@@ -91,19 +91,68 @@ export class DescriptionLoader implements Disposable {
91
91
  private dispatchScheduled = false;
92
92
  private turnModelRegistry: ModelRegistry | null = null;
93
93
  private turnVisionModel: Model<Api> | null = null;
94
- private turnSignal: AbortSignal | undefined;
94
+ /** Turn-level abort controller. In-flight describer batches (`runBatch`)
95
+ * are wired to `turnAbortController.signal` so a user cancel (ESC) aborts
96
+ * them EVEN WHEN the batch was dispatched before the run's live abort
97
+ * signal existed — the `before_agent_start` / paste-time prewarm case,
98
+ * where `ctx.signal` is `undefined` (the agent run hasn't started yet).
99
+ * The run's live signal, once it arrives via `bindTurnContext`, is forwarded
100
+ * into `turnAbortController.abort()`. Because `runBatch` holds a reference
101
+ * to this controller's signal (not a snapshot of `ctx.signal`), a batch
102
+ * that started during prewarm becomes abortable the instant a later
103
+ * `bindTurnContext` (from `tool_result`/`context`) brings the live signal.
104
+ * Reset per turn via {@link resetTurnAbort} so a previous turn's cancel
105
+ * can't poison the next turn's prewarm. */
106
+ private turnAbortController = new AbortController();
107
+ /** The run signal currently forwarded into {@link turnAbortController}, so
108
+ * the abort listener is attached at most once per signal (the same signal
109
+ * is bound by every `tool_result`/`context` event in a turn). */
110
+ private wiredSignal: AbortSignal | undefined;
95
111
  private pendingTurnPrompt = "";
96
112
 
97
113
  constructor(private readonly deps: LoaderDeps) {}
98
114
 
99
- /** Bind the turn context (model registry, resolved vision model, abort signal)
100
- * for the loader's next dispatch. Called from every handler that may trigger
101
- * `loadDescription()`. */
115
+ /** Bind the turn context (model registry, resolved vision model, abort
116
+ * signal) for the loader's next dispatch. Called from every handler that
117
+ * may trigger `loadDescription()`.
118
+ *
119
+ * The abort signal is forwarded into the loader's {@link turnAbortController}
120
+ * rather than stored directly. Storing `ctx.signal` directly would leave a
121
+ * prewarm-dispatched batch (started in `before_agent_start`, where
122
+ * `ctx.signal` is `undefined` because the run hasn't started) with no abort
123
+ * wire — ESC couldn't cancel it, so the `tool_result` handler would only
124
+ * discard the result AFTER the vision call ran to completion. Forwarding
125
+ * the live signal into a stable, loader-owned controller lets an in-flight
126
+ * prewarm batch be aborted the moment the live signal arrives. */
102
127
  bindTurnContext(ctx: { modelRegistry: ModelRegistry; signal?: AbortSignal }): void {
103
128
  this.turnModelRegistry = ctx.modelRegistry;
104
- this.turnSignal = ctx.signal;
105
129
  const resolved = this.deps.resolveVisionModel(ctx.modelRegistry, this.deps.getConfig().visionModel!);
106
130
  if (resolved) this.turnVisionModel = resolved;
131
+ const signal = ctx.signal;
132
+ if (!signal) return;
133
+ if (signal.aborted) {
134
+ this.turnAbortController.abort();
135
+ this.wiredSignal = signal;
136
+ return;
137
+ }
138
+ if (signal === this.wiredSignal) return; // already forwarded this signal
139
+ this.wiredSignal = signal;
140
+ signal.addEventListener("abort", () => this.turnAbortController.abort(), { once: true });
141
+ }
142
+
143
+ /** Reset the turn-level abort controller for a fresh turn. Call at turn
144
+ * boundaries (`before_agent_start`, paste-time prewarm) so a previous turn's
145
+ * cancel doesn't leave {@link turnAbortController} aborted — which would
146
+ * make every subsequent dispatch short-circuit to UNAVAILABLE. A
147
+ * non-aborted controller is reused (avoids orphaning an in-flight paste
148
+ * prewarm holding its signal); only an aborted one is replaced. `wiredSignal`
149
+ * is always cleared so the next live signal re-wires. Safe at a real turn
150
+ * boundary, where the prior turn's batch has settled. */
151
+ resetTurnAbort(): void {
152
+ if (this.turnAbortController.signal.aborted) {
153
+ this.turnAbortController = new AbortController();
154
+ }
155
+ this.wiredSignal = undefined;
107
156
  }
108
157
 
109
158
  /** Capture this turn's user prompt so every image in the turn is described in
@@ -160,7 +209,9 @@ export class DescriptionLoader implements Disposable {
160
209
  this.dispatchScheduled = false;
161
210
  this.turnModelRegistry = null;
162
211
  this.turnVisionModel = null;
163
- this.turnSignal = undefined;
212
+ // Fresh controller on session reset (no in-flight batch to orphan).
213
+ this.turnAbortController = new AbortController();
214
+ this.wiredSignal = undefined;
164
215
  this.pendingTurnPrompt = "";
165
216
  }
166
217
 
@@ -194,7 +245,7 @@ export class DescriptionLoader implements Disposable {
194
245
  for (const cb of batch.callbacks) cb.resolve(UNAVAILABLE);
195
246
  return;
196
247
  }
197
- if (this.turnSignal?.aborted) {
248
+ if (this.turnAbortController.signal.aborted) {
198
249
  for (const key of batch.keys) this.cache.delete(key);
199
250
  for (const cb of batch.callbacks) cb.resolve(UNAVAILABLE);
200
251
  return;
@@ -209,7 +260,7 @@ export class DescriptionLoader implements Disposable {
209
260
  this.turnModelRegistry,
210
261
  cfg,
211
262
  this.deps,
212
- this.turnSignal,
263
+ this.turnAbortController.signal,
213
264
  );
214
265
  // Build per-hash results, then fan them out to every callback. This reaches
215
266
  // duplicate-hash callbacks that a key-indexed loop would have skipped (and
package/vision-handoff.ts CHANGED
@@ -144,6 +144,13 @@ function prewarmClipboardPath(path: string, modelRegistry: ModelRegistry): void
144
144
  resolvePrewarmImage(read.buf, read.mimeType, resizeImage)
145
145
  .then((img) => {
146
146
  if (!img) return;
147
+ // Paste happens while the agent is idle (no run → no live signal).
148
+ // Reset the turn-abort controller so a previous turn's ESC doesn't leave
149
+ // it aborted and short-circuit this prewarm; the next submit's
150
+ // `before_agent_start` resets again. The prewarm batch uses the loader's
151
+ // controller and becomes abortable once a run starts and binds a live
152
+ // signal.
153
+ loader.resetTurnAbort();
147
154
  loader.setPendingTurnPrompt("");
148
155
  loader.bindTurnContext({ modelRegistry });
149
156
  loader.loadDescription(img).catch(() => {});
@@ -248,6 +255,18 @@ export default function (pi: ExtensionAPI) {
248
255
  if (!isConfigured(config)) return;
249
256
  if (!isHandoffTarget(ctx.model, config)) return;
250
257
 
258
+ // Fresh turn → fresh turn-abort controller. `before_agent_start` fires
259
+ // BEFORE the agent run starts, so `ctx.signal` is undefined here (the run's
260
+ // abort signal doesn't exist yet — it's created in `agent.prompt()` →
261
+ // `runWithLifecycle`, which runs AFTER this event). The prewarm below
262
+ // dispatches describer batches now, and they must be abortable once the
263
+ // run's live signal arrives — so reset the loader's turn-abort controller
264
+ // here, and let the later `tool_result`/`context` binds forward the live
265
+ // signal into it. Without this reset, a previous turn's ESC would leave
266
+ // the controller aborted and every dispatch would short-circuit to
267
+ // UNAVAILABLE.
268
+ loader.resetTurnAbort();
269
+
251
270
  // Capture this turn's user prompt so every image in the turn — attached or
252
271
  // read via the read tool — is described in the same request context.
253
272
  loader.setPendingTurnPrompt(event.prompt || "");