@pi-unipi/fusion 2.18.0 → 2.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/package.json +3 -3
- package/src/picker.ts +61 -30
- package/src/sidekick-runtime.ts +62 -19
- package/src/tools.ts +11 -3
package/README.md
CHANGED
|
@@ -92,8 +92,11 @@ persist across handoffs. The sidekick's context compacts independently of the
|
|
|
92
92
|
lead's (it is its own pi session). `sidekick({message, block:true})` waits by
|
|
93
93
|
default; `block:false` returns immediately and delivers a
|
|
94
94
|
`<subagent_completion_notification>`. Calling it while busy steers the same
|
|
95
|
-
handoff.
|
|
96
|
-
|
|
95
|
+
handoff. Background tasks keep that handoff open through their completion
|
|
96
|
+
notification and any follow-up turn, so the report is not released at an
|
|
97
|
+
intermediate checkpoint. If a prompt arrives while the child is processing,
|
|
98
|
+
Fusion retries it once with pi's `followUp` streaming behavior.
|
|
99
|
+
`read_subagent({agent_id?, block?, timeout?})` reads or waits for a handoff.
|
|
97
100
|
|
|
98
101
|
The child receives `UNIPI_FUSION_CHILD=1` and `UNIPI_SUBAGENT_CHILD=1`; the
|
|
99
102
|
Fusion extension guard prevents child processes from registering Fusion tools,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/fusion",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.0",
|
|
4
4
|
"description": "Devin-style model picker, fusion presets (lead + sidekick), and Local Fusion runtime for UniPi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@pi-unipi/core": "2.
|
|
36
|
-
"@pi-unipi/subagents": "2.
|
|
35
|
+
"@pi-unipi/core": "2.19.0",
|
|
36
|
+
"@pi-unipi/subagents": "2.19.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@earendil-works/pi-ai": "^0.84.0",
|
package/src/picker.ts
CHANGED
|
@@ -14,10 +14,11 @@
|
|
|
14
14
|
* $10 / 1M $0.25 / 1M $50 / 1M $0.2 / 1M $1.2 / 1M
|
|
15
15
|
* ↑/↓ select · ←/→ effort · tab lead · Enter confirm · esc cancel
|
|
16
16
|
*
|
|
17
|
-
* Row order: the active selection pinned first, then the Fusion
|
|
18
|
-
*
|
|
19
|
-
* EVERY other available model — the
|
|
20
|
-
* only controls ordering. Typing filters
|
|
17
|
+
* Row order: the active selection pinned first, then the always-visible Fusion
|
|
18
|
+
* row (disabled with a setup hint when no pair is configured), then recent
|
|
19
|
+
* (≤5, MRU), then the preset models, then EVERY other available model — the
|
|
20
|
+
* catalogue is never hidden, the preset only controls ordering. Typing filters
|
|
21
|
+
* all rows except the pinned one.
|
|
21
22
|
*
|
|
22
23
|
* ←/→ steps the highlighted row's effort. Per-model effort is remembered for
|
|
23
24
|
* plain model rows; the Fusion row keeps its own lead/sidekick efforts so
|
|
@@ -219,7 +220,7 @@ export class ModelPicker {
|
|
|
219
220
|
out.push({ kind: "model", key: active.model });
|
|
220
221
|
seen.add(active.model);
|
|
221
222
|
}
|
|
222
|
-
if (!pinnedFusion
|
|
223
|
+
if (!pinnedFusion) out.push({ kind: "fusion" });
|
|
223
224
|
|
|
224
225
|
const ordered: ModelKey[] = [
|
|
225
226
|
...this.state.recent,
|
|
@@ -268,6 +269,18 @@ export class ModelPicker {
|
|
|
268
269
|
return;
|
|
269
270
|
}
|
|
270
271
|
|
|
272
|
+
const disabledFusion = row?.kind === "fusion" && !this.fusionAvailable();
|
|
273
|
+
if (
|
|
274
|
+
disabledFusion &&
|
|
275
|
+
(matchesKey(data, Key.tab) ||
|
|
276
|
+
matchesKey(data, Key.left) ||
|
|
277
|
+
matchesKey(data, Key.right) ||
|
|
278
|
+
matchesKey(data, Key.enter) ||
|
|
279
|
+
data === "\r")
|
|
280
|
+
) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
271
284
|
if (inDropdown) {
|
|
272
285
|
const items = this.dropdownItems();
|
|
273
286
|
if (matchesKey(data, Key.up)) {
|
|
@@ -406,11 +419,12 @@ export class ModelPicker {
|
|
|
406
419
|
private renderRow(row: Row, highlighted: boolean, width: number): string {
|
|
407
420
|
const t = this.theme;
|
|
408
421
|
const pointer = highlighted ? t.fg("accent", "❭") : t.fg("dim", "·");
|
|
422
|
+
const disabledFusion = row.kind === "fusion" && !this.fusionAvailable();
|
|
409
423
|
// The Fusion composite gets the check when it is the active selection —
|
|
410
424
|
// same affordance a single active model gets on its own row.
|
|
411
425
|
const marker =
|
|
412
426
|
row.kind === "fusion"
|
|
413
|
-
? this.state.active?.kind === "fusion"
|
|
427
|
+
? !disabledFusion && this.state.active?.kind === "fusion"
|
|
414
428
|
? t.fg("success", "✓")
|
|
415
429
|
: " "
|
|
416
430
|
: this.markerFor(row.key);
|
|
@@ -418,9 +432,13 @@ export class ModelPicker {
|
|
|
418
432
|
const nameRaw = row.kind === "fusion" ? "Fusion" : this.nameOf(row.key, NAME_COL - 1);
|
|
419
433
|
const name =
|
|
420
434
|
row.kind === "fusion"
|
|
421
|
-
?
|
|
422
|
-
?
|
|
423
|
-
|
|
435
|
+
? disabledFusion
|
|
436
|
+
? highlighted
|
|
437
|
+
? t.fg("muted", t.bold(nameRaw))
|
|
438
|
+
: t.fg("dim", nameRaw)
|
|
439
|
+
: highlighted
|
|
440
|
+
? t.fg("accent", t.bold(nameRaw))
|
|
441
|
+
: t.fg("text", nameRaw)
|
|
424
442
|
: working
|
|
425
443
|
? t.fg("accent", t.bold(nameRaw))
|
|
426
444
|
: highlighted
|
|
@@ -431,25 +449,33 @@ export class ModelPicker {
|
|
|
431
449
|
const badgeGlyph = badge === undefined ? "" : ` ${t.fg(badge === "new" ? "success" : badge === "promotion" ? "accent" : "warning", "✱")}`;
|
|
432
450
|
|
|
433
451
|
const level = row.kind === "fusion" ? this.fusionLeadEffort : this.effortFor(row.key);
|
|
434
|
-
const arrowsOn = highlighted && this.focus === "effort";
|
|
452
|
+
const arrowsOn = !disabledFusion && highlighted && this.focus === "effort";
|
|
435
453
|
const left = arrowsOn ? t.fg("accent", "←") : " ";
|
|
436
454
|
const right = arrowsOn ? t.fg("accent", "→") : " ";
|
|
437
|
-
const label =
|
|
455
|
+
const label = disabledFusion
|
|
456
|
+
? t.fg("dim", effortLabel(level))
|
|
457
|
+
: highlighted
|
|
458
|
+
? t.fg("accent", effortLabel(level))
|
|
459
|
+
: t.fg("muted", effortLabel(level));
|
|
438
460
|
|
|
439
|
-
let line = `${pointer} ${marker} ${pad(`${name}${badgeGlyph}`, NAME_COL)} ${left} ${this.bar(level, highlighted)} ${right} ${pad(label, 8)}`;
|
|
461
|
+
let line = `${pointer} ${marker} ${pad(`${name}${badgeGlyph}`, NAME_COL)} ${left} ${this.bar(level, !disabledFusion && highlighted)} ${right} ${pad(label, 8)}`;
|
|
440
462
|
|
|
441
463
|
if (row.kind === "fusion") {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
464
|
+
if (disabledFusion) {
|
|
465
|
+
line += ` ${t.fg("dim", "not configured — run /unipi:fusion-preset")}`;
|
|
466
|
+
} else {
|
|
467
|
+
const leadName = this.nameOf(this.lead, 14);
|
|
468
|
+
const sideName = this.nameOf(this.sidekick, 14);
|
|
469
|
+
const leadFocused = highlighted && this.focus === "lead";
|
|
470
|
+
const sideFocused = highlighted && this.focus === "sidekick";
|
|
471
|
+
const leadText = leadFocused
|
|
472
|
+
? `${t.fg("accent", t.bold("Lead"))} ${t.fg("accent", leadName)} ${t.fg("accent", "▾")}`
|
|
473
|
+
: `${t.fg("dim", "Lead")} ${t.fg("text", leadName)} ${t.fg("dim", "▾")}`;
|
|
474
|
+
const sideText = sideFocused
|
|
475
|
+
? `${t.fg("accent", t.bold("Sidekick"))} ${t.fg("accent", sideName)} ${t.fg("accent", "▾")}`
|
|
476
|
+
: `${t.fg("dim", "Sidekick")} ${t.fg("text", sideName)} ${t.fg("dim", "▾")}`;
|
|
477
|
+
line += ` ${leadText} ${sideText}`;
|
|
478
|
+
}
|
|
453
479
|
}
|
|
454
480
|
return truncateToWidth(line, Math.max(1, width - 1));
|
|
455
481
|
}
|
|
@@ -478,6 +504,7 @@ export class ModelPicker {
|
|
|
478
504
|
private renderPricePanel(row: Row | undefined, width: number): string[] {
|
|
479
505
|
const t = this.theme;
|
|
480
506
|
if (row === undefined) return [];
|
|
507
|
+
const disabledFusion = row.kind === "fusion" && !this.fusionAvailable();
|
|
481
508
|
const primaryKey = row.kind === "fusion" ? this.lead : row.key;
|
|
482
509
|
const primary = primaryKey === undefined ? undefined : this.modelsByKey.get(primaryKey);
|
|
483
510
|
const side = row.kind === "fusion" && this.sidekick !== undefined ? this.modelsByKey.get(this.sidekick) : undefined;
|
|
@@ -505,18 +532,20 @@ export class ModelPicker {
|
|
|
505
532
|
const head = cols.map(([h]) => pad(t.fg("dim", h), colWidth)).join("");
|
|
506
533
|
const vals = cols.map(([, v]) => pad(t.fg("text", v), colWidth)).join("");
|
|
507
534
|
const desc =
|
|
508
|
-
|
|
509
|
-
? t.fg("
|
|
510
|
-
:
|
|
511
|
-
? t.fg("dim", "
|
|
512
|
-
:
|
|
535
|
+
disabledFusion
|
|
536
|
+
? t.fg("warning", "Run /unipi:fusion-preset to enable Fusion — a powerful lead model plans and reviews while a cheaper sidekick executes, for frontier performance at lower cost")
|
|
537
|
+
: row.kind === "fusion"
|
|
538
|
+
? t.fg("dim", "Pairs frontier intelligence with cost-efficient execution")
|
|
539
|
+
: primary?.reasoning
|
|
540
|
+
? t.fg("dim", "Reasoning model · ←/→ adjusts thinking effort")
|
|
541
|
+
: t.fg("dim", "Non-reasoning model · effort is ignored by the provider");
|
|
513
542
|
const badges = this.state.models.some((m) => m.badge !== undefined)
|
|
514
543
|
? `${t.fg("success", "✱")} ${t.fg("dim", "New")} ${t.fg("accent", "✱")} ${t.fg("dim", "Promotion")} ${t.fg("warning", "✱")} ${t.fg("dim", "Beta")} ${t.fg("dim", "·")}`
|
|
515
544
|
: "";
|
|
516
545
|
const noPricing = row.kind === "fusion"
|
|
517
546
|
? !hasPricing(primaryCost) || !hasPricing(sideCost)
|
|
518
547
|
: !hasPricing(primaryCost);
|
|
519
|
-
const pricing = noPricing ? t.fg("dim", " · no pricing data from provider") : "";
|
|
548
|
+
const pricing = !disabledFusion && noPricing ? t.fg("dim", " · no pricing data from provider") : "";
|
|
520
549
|
const description = `${badges}${badges.length > 0 ? " " : ""}${desc}${pricing}`;
|
|
521
550
|
return [truncateToWidth(` ${head}`, width - 1), truncateToWidth(` ${vals}`, width - 1), truncateToWidth(` ${description}`, width - 1)];
|
|
522
551
|
}
|
|
@@ -524,7 +553,9 @@ export class ModelPicker {
|
|
|
524
553
|
private hintLine(row: Row | undefined): string {
|
|
525
554
|
const t = this.theme;
|
|
526
555
|
const parts: string[] = [];
|
|
527
|
-
if (row?.kind === "fusion" && this.
|
|
556
|
+
if (row?.kind === "fusion" && !this.fusionAvailable()) {
|
|
557
|
+
parts.push("↑↓ select", "esc cancel");
|
|
558
|
+
} else if (row?.kind === "fusion" && this.focus !== "effort") {
|
|
528
559
|
parts.push("↑↓ select", `tab ${this.focus === "lead" ? "sidekick" : "effort"}`, "↵ apply", "esc collapse");
|
|
529
560
|
} else {
|
|
530
561
|
parts.push("↑↓ select");
|
package/src/sidekick-runtime.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface SidekickSpawnConfig {
|
|
|
15
15
|
spawn?: typeof defaultSpawn;
|
|
16
16
|
command?: { command: string; args: string[] };
|
|
17
17
|
onProgress?: () => void;
|
|
18
|
+
settleGraceMs?: number;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export interface SidekickUsage {
|
|
@@ -54,11 +55,15 @@ export interface HandoffReport {
|
|
|
54
55
|
|
|
55
56
|
interface PendingHandoff {
|
|
56
57
|
id: string;
|
|
58
|
+
message: string;
|
|
57
59
|
startedAt: number;
|
|
58
60
|
usage: SidekickUsage;
|
|
59
61
|
progress: HandoffProgress;
|
|
62
|
+
retriedPrompt: boolean;
|
|
63
|
+
openBgTasks: number;
|
|
64
|
+
settled: boolean;
|
|
65
|
+
settleTimer?: NodeJS.Timeout;
|
|
60
66
|
resolve: (report: HandoffReport) => void;
|
|
61
|
-
reject: (error: Error) => void;
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
const emptyUsage = (): SidekickUsage => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
|
|
@@ -193,16 +198,35 @@ export class SidekickRuntime {
|
|
|
193
198
|
}
|
|
194
199
|
}
|
|
195
200
|
|
|
201
|
+
private requestLastAssistantText(): void {
|
|
202
|
+
const current = this.pending;
|
|
203
|
+
if (current === undefined) return;
|
|
204
|
+
this.responseText = (text) => this.finish(this.abortRequested ? "aborted" : this.pendingError === undefined ? "completed" : "error", text, this.pendingError);
|
|
205
|
+
this.responseError = (error) => this.finish("error", undefined, error.message);
|
|
206
|
+
try {
|
|
207
|
+
this.send({ type: "get_last_assistant_text" });
|
|
208
|
+
} catch (error) {
|
|
209
|
+
this.finish("error", undefined, error instanceof Error ? error.message : String(error));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
196
213
|
private handleMessage(message: Record<string, unknown>): void {
|
|
197
214
|
if (message.type === "response") {
|
|
198
215
|
const command = message.command;
|
|
199
216
|
if (command === "prompt" && message.success === false) {
|
|
200
|
-
const
|
|
217
|
+
const errorText = String(message.error ?? "Sidekick prompt rejected");
|
|
201
218
|
const current = this.pending;
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
219
|
+
if (current === undefined) return;
|
|
220
|
+
if (/already processing/i.test(errorText) && !current.retriedPrompt) {
|
|
221
|
+
current.retriedPrompt = true;
|
|
222
|
+
try {
|
|
223
|
+
this.send({ id: current.id, type: "prompt", message: current.message, streamingBehavior: "followUp" });
|
|
224
|
+
} catch (error) {
|
|
225
|
+
this.finish("error", undefined, error instanceof Error ? error.message : String(error));
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
this.finish("error", undefined, errorText);
|
|
229
|
+
}
|
|
206
230
|
} else if (command === "get_last_assistant_text") {
|
|
207
231
|
const data = message.data as Record<string, unknown> | undefined;
|
|
208
232
|
const text = typeof data?.text === "string" ? data.text : this.pending?.progress.textTail ?? "";
|
|
@@ -224,6 +248,7 @@ export class SidekickRuntime {
|
|
|
224
248
|
this.closeOpenText();
|
|
225
249
|
this.pending.progress.toolCalls += 1;
|
|
226
250
|
const args = message.args !== undefined && typeof message.args === "object" && message.args !== null ? message.args as Record<string, unknown> : undefined;
|
|
251
|
+
if (message.toolName === "bg_run" && args?.notifyOnCompletion !== false && args?.triggerOnCompletion !== false) this.pending.openBgTasks += 1;
|
|
227
252
|
const argsText = args === undefined ? "" : JSON.stringify(args).replace(/\s+/gu, " ");
|
|
228
253
|
const summary = `${String(message.toolName ?? "tool")}(${argsText})`.slice(0, 40);
|
|
229
254
|
this.pending.progress.recentTools = [...this.pending.progress.recentTools, summary].slice(-6);
|
|
@@ -237,6 +262,7 @@ export class SidekickRuntime {
|
|
|
237
262
|
event.endedAt = Date.now();
|
|
238
263
|
event.isError = message.isError === true;
|
|
239
264
|
event.output = this.toolOutput(message.result);
|
|
265
|
+
if (event.name === "bg_run" && event.isError) this.pending.openBgTasks = Math.max(0, this.pending.openBgTasks - 1);
|
|
240
266
|
}
|
|
241
267
|
this.notifyProgress();
|
|
242
268
|
} else if (message.type === "message_update") {
|
|
@@ -251,7 +277,19 @@ export class SidekickRuntime {
|
|
|
251
277
|
}
|
|
252
278
|
} else if (message.type === "message_end") {
|
|
253
279
|
const msg = message.message as Record<string, unknown> | undefined;
|
|
254
|
-
if (msg?.role === "
|
|
280
|
+
if (msg?.role === "custom" && msg.customType === "background-task-notification") {
|
|
281
|
+
this.pending.openBgTasks = Math.max(0, this.pending.openBgTasks - 1);
|
|
282
|
+
if (this.pending.openBgTasks === 0 && this.pending.settled) {
|
|
283
|
+
clearTimeout(this.pending.settleTimer);
|
|
284
|
+
this.pending.settleTimer = setTimeout(() => {
|
|
285
|
+
const current = this.pending;
|
|
286
|
+
if (current === undefined || !current.settled || current.openBgTasks > 0) return;
|
|
287
|
+
this.requestLastAssistantText();
|
|
288
|
+
}, this.cfg.settleGraceMs ?? 3000);
|
|
289
|
+
this.pending.settleTimer.unref();
|
|
290
|
+
}
|
|
291
|
+
this.notifyProgress();
|
|
292
|
+
} else if (msg?.role === "assistant") {
|
|
255
293
|
this.closeOpenText();
|
|
256
294
|
this.notifyProgress();
|
|
257
295
|
if (msg.stopReason === "error" && typeof msg.errorMessage === "string") this.pendingError = msg.errorMessage;
|
|
@@ -267,16 +305,18 @@ export class SidekickRuntime {
|
|
|
267
305
|
});
|
|
268
306
|
}
|
|
269
307
|
}
|
|
308
|
+
} else if (message.type === "agent_start") {
|
|
309
|
+
clearTimeout(this.pending.settleTimer);
|
|
310
|
+
this.pending.settleTimer = undefined;
|
|
311
|
+
this.pending.settled = false;
|
|
312
|
+
this.notifyProgress();
|
|
270
313
|
} else if (message.type === "agent_settled") {
|
|
271
|
-
this.
|
|
272
|
-
this.
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
try {
|
|
276
|
-
this.send({ type: "get_last_assistant_text" });
|
|
277
|
-
} catch (error) {
|
|
278
|
-
this.finish("error", undefined, error instanceof Error ? error.message : String(error));
|
|
314
|
+
this.pending.settled = true;
|
|
315
|
+
if (this.pending.openBgTasks > 0) {
|
|
316
|
+
this.notifyProgress();
|
|
317
|
+
return;
|
|
279
318
|
}
|
|
319
|
+
this.requestLastAssistantText();
|
|
280
320
|
}
|
|
281
321
|
}
|
|
282
322
|
|
|
@@ -293,6 +333,8 @@ export class SidekickRuntime {
|
|
|
293
333
|
private finish(status: HandoffReport["status"], text?: string, error?: string): void {
|
|
294
334
|
const current = this.pending;
|
|
295
335
|
if (current === undefined) return;
|
|
336
|
+
clearTimeout(current.settleTimer);
|
|
337
|
+
current.settleTimer = undefined;
|
|
296
338
|
this.pending = undefined;
|
|
297
339
|
this.responseText = undefined;
|
|
298
340
|
this.responseError = undefined;
|
|
@@ -323,19 +365,20 @@ export class SidekickRuntime {
|
|
|
323
365
|
const id = randomUUID();
|
|
324
366
|
const startedAt = Date.now();
|
|
325
367
|
let resolve!: (report: HandoffReport) => void;
|
|
326
|
-
|
|
327
|
-
const done = new Promise<HandoffReport>((res, rej) => {
|
|
368
|
+
const done = new Promise<HandoffReport>((res) => {
|
|
328
369
|
resolve = res;
|
|
329
|
-
reject = rej;
|
|
330
370
|
});
|
|
331
371
|
this.pendingError = undefined;
|
|
332
372
|
this.pending = {
|
|
333
373
|
id,
|
|
374
|
+
message,
|
|
334
375
|
startedAt,
|
|
335
376
|
usage: emptyUsage(),
|
|
336
377
|
progress: { toolCalls: 0, recentTools: [], textTail: "", startedAt, events: [], droppedEvents: 0 },
|
|
378
|
+
retriedPrompt: false,
|
|
379
|
+
openBgTasks: 0,
|
|
380
|
+
settled: false,
|
|
337
381
|
resolve,
|
|
338
|
-
reject,
|
|
339
382
|
};
|
|
340
383
|
this.latestHandoff = { id, done };
|
|
341
384
|
try {
|
package/src/tools.ts
CHANGED
|
@@ -60,7 +60,7 @@ async function waitForReport(
|
|
|
60
60
|
ctx: ExtensionContext,
|
|
61
61
|
onUpdate?: (update: unknown) => void,
|
|
62
62
|
timeoutMs = 2700000,
|
|
63
|
-
): Promise<{ report?: HandoffReport; interrupted?: string; aborted?: boolean }> {
|
|
63
|
+
): Promise<{ report?: HandoffReport; interrupted?: string; aborted?: boolean; error?: string }> {
|
|
64
64
|
const started = Date.now();
|
|
65
65
|
let lastProgressKey = "";
|
|
66
66
|
while (true) {
|
|
@@ -72,8 +72,14 @@ async function waitForReport(
|
|
|
72
72
|
const remaining = timeoutMs - (Date.now() - started);
|
|
73
73
|
if (remaining <= 0) return {};
|
|
74
74
|
const timer = new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), Math.min(500, remaining)));
|
|
75
|
-
const
|
|
76
|
-
|
|
75
|
+
const outcome = await Promise.race([
|
|
76
|
+
done.then((report) => ({ report }), (error) => ({ error: error instanceof Error ? error.message : String(error) })),
|
|
77
|
+
timer,
|
|
78
|
+
]);
|
|
79
|
+
if (outcome !== undefined) {
|
|
80
|
+
if ("error" in outcome) return { error: outcome.error };
|
|
81
|
+
return { report: outcome.report };
|
|
82
|
+
}
|
|
77
83
|
const progress = progressText(runtime, id);
|
|
78
84
|
const key = progressKey(runtime, id);
|
|
79
85
|
if (key !== lastProgressKey) {
|
|
@@ -175,6 +181,7 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
|
|
|
175
181
|
deps.onReport?.(ctx, waited.report);
|
|
176
182
|
return result(reportText(waited.report), waited.report, waited.report.status !== "completed");
|
|
177
183
|
}
|
|
184
|
+
if (waited.error) return result(`Handoff ${handoff.id} failed: ${waited.error}`, undefined, true);
|
|
178
185
|
if (waited.aborted) return result(`${progressText(runtime, handoff.id)}\nHandoff ${handoff.id} aborted.`, undefined, true);
|
|
179
186
|
if (waited.interrupted) return result(`A user message arrived while the sidekick (agent_id ${handoff.id}) was working. The handoff continues in the background. Act on the user's message first, then call read_subagent({agent_id:"${handoff.id}", block:true}) to collect the report or sidekick({message}) to redirect it.\n${waited.interrupted}`);
|
|
180
187
|
return result(`Handoff ${handoff.id} is still running.\n${progressText(runtime, handoff.id)}`);
|
|
@@ -205,6 +212,7 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
|
|
|
205
212
|
deps.onReport?.(ctx, waited.report);
|
|
206
213
|
return result(reportText(waited.report), waited.report, waited.report.status !== "completed");
|
|
207
214
|
}
|
|
215
|
+
if (waited.error) return result(`Handoff ${id} failed: ${waited.error}`, undefined, true);
|
|
208
216
|
if (waited.aborted) return result(`Handoff ${id} aborted.`, undefined, true);
|
|
209
217
|
if (waited.interrupted) return result(`A user message arrived while the sidekick (agent_id ${id}) was working.\n${waited.interrupted}`);
|
|
210
218
|
return result(`Handoff ${id} is still running.\n${progressText(runtime, id)}`);
|