@pi-unipi/fusion 2.19.2 → 2.20.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 +9 -4
- package/package.json +3 -3
- package/src/index.ts +87 -94
- package/src/tools.ts +77 -12
- package/src/transcript.ts +13 -11
- package/src/sidekick-widget.ts +0 -3
package/README.md
CHANGED
|
@@ -92,14 +92,19 @@ 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.
|
|
95
|
+
handoff. A handoff whose lead turn has ended — started with `block:false`,
|
|
96
|
+
abandoned by a user message, or timed out — still wakes the lead with that same
|
|
97
|
+
completion notification when its report lands, exactly once. Background tasks
|
|
98
|
+
keep that handoff open through their completion
|
|
96
99
|
notification and any follow-up turn, so the report is not released at an
|
|
97
100
|
intermediate checkpoint. If a prompt arrives while the child is processing,
|
|
98
101
|
Fusion retries it once with pi's `followUp` streaming behavior. Sidekick work
|
|
99
102
|
renders as ordinary tool activity — handoff ids, `agent_id`, and
|
|
100
|
-
`read_subagent` protocol text never reach the terminal.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
+
`read_subagent` protocol text never reach the terminal. Sidekick output keeps
|
|
104
|
+
that same format on the main surface, marked as sidekick-origin by a `▍` rail.
|
|
105
|
+
While the sidekick works and the lead's turn is over, an animated
|
|
106
|
+
`sidekick working` line above the editor shows the live tool count and elapsed
|
|
107
|
+
time until the report lands.
|
|
103
108
|
`read_subagent({agent_id?, block?, timeout?})` reads or waits for a handoff.
|
|
104
109
|
|
|
105
110
|
The child receives `UNIPI_FUSION_CHILD=1` and `UNIPI_SUBAGENT_CHILD=1`; the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/fusion",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.20.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.20.0",
|
|
36
|
+
"@pi-unipi/subagents": "2.20.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@earendil-works/pi-ai": "^0.84.0",
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
18
18
|
import type { AutocompleteProvider, AutocompleteSuggestions } from "@earendil-works/pi-tui";
|
|
19
|
-
import { setSharedFusionStatus, UNIPI_PREFIX } from "@pi-unipi/core";
|
|
19
|
+
import { createSpinnerLine, setSharedFusionStatus, UNIPI_PREFIX } from "@pi-unipi/core";
|
|
20
20
|
import { homedir } from "node:os";
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
import {
|
|
@@ -40,8 +40,7 @@ import { estimateSavings } from "./savings.js";
|
|
|
40
40
|
import { EDIT_NUDGE, bashNudge, leadPolicy, sidekickSystemPrompt, type FusionIdentity } from "./prompts.js";
|
|
41
41
|
import { isTrivialShell, BASH_NUDGE_EVERY } from "./nudge.js";
|
|
42
42
|
import { registerFusionTools } from "./tools.js";
|
|
43
|
-
import {
|
|
44
|
-
import { frameSidekick, markdownText, renderSidekickTranscript, sidekickWorkingHeader, type ThemeLike } from "./transcript.js";
|
|
43
|
+
import { duration } from "./transcript.js";
|
|
45
44
|
|
|
46
45
|
export const MODEL_COMMAND = `${UNIPI_PREFIX}model`;
|
|
47
46
|
export const PRESET_COMMAND = `${UNIPI_PREFIX}fusion-preset`;
|
|
@@ -50,6 +49,73 @@ export function sidekickSessionPath(leadSessionId?: string): string {
|
|
|
50
49
|
return join(homedir(), ".unipi", "state", "fusion", "sidekick", `${leadSessionId ?? "default"}.jsonl`);
|
|
51
50
|
}
|
|
52
51
|
|
|
52
|
+
export const SIDEKICK_WAKE_WIDGET_KEY = "fusion-sidekick-wake";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The lead is idle when pi says so. Any failure (including a context without
|
|
56
|
+
* `isIdle`) counts as idle — same contract as the background-tasks wake line.
|
|
57
|
+
*/
|
|
58
|
+
export function isLeadIdle(ctx: { isIdle(): boolean }): boolean {
|
|
59
|
+
try {
|
|
60
|
+
return ctx.isIdle();
|
|
61
|
+
} catch {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Body of the live line. `undefined` collapses it without disposing the widget. */
|
|
67
|
+
export function sidekickWakeText(progress: { toolCalls: number; startedAt: number } | undefined): string | undefined {
|
|
68
|
+
if (!progress) return undefined;
|
|
69
|
+
return `sidekick working · ${String(progress.toolCalls)} tool calls · ${duration(Date.now() - progress.startedAt)} — resumes automatically when done`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface SidekickWakeLine {
|
|
73
|
+
publish(ctx: ExtensionContext | undefined): void;
|
|
74
|
+
clear(ctx: ExtensionContext | undefined): void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Self-animating "sidekick still working" line, shown while the sidekick is
|
|
79
|
+
* busy but the lead's turn has already ended (pi stopped its own loader).
|
|
80
|
+
*
|
|
81
|
+
* The widget owns an 80 ms frame timer, so it is installed ONCE while the
|
|
82
|
+
* condition holds and removed when it stops — never re-installed per progress
|
|
83
|
+
* tick.
|
|
84
|
+
*/
|
|
85
|
+
export function createSidekickWakeLine(deps: {
|
|
86
|
+
isBusy: () => boolean;
|
|
87
|
+
progress: () => { toolCalls: number; startedAt: number } | undefined;
|
|
88
|
+
}): SidekickWakeLine {
|
|
89
|
+
let installed = false;
|
|
90
|
+
|
|
91
|
+
const removable = (ctx: ExtensionContext | undefined): ctx is ExtensionContext =>
|
|
92
|
+
Boolean(ctx?.hasUI) && typeof ctx?.ui.setWidget === "function";
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
publish(ctx) {
|
|
96
|
+
if (!removable(ctx)) return;
|
|
97
|
+
const want = deps.isBusy() && isLeadIdle(ctx);
|
|
98
|
+
if (want && !installed) {
|
|
99
|
+
ctx.ui.setWidget(
|
|
100
|
+
SIDEKICK_WAKE_WIDGET_KEY,
|
|
101
|
+
createSpinnerLine({ text: () => sidekickWakeText(deps.progress()) }),
|
|
102
|
+
{ placement: "aboveEditor" },
|
|
103
|
+
);
|
|
104
|
+
installed = true;
|
|
105
|
+
} else if (!want && installed) {
|
|
106
|
+
ctx.ui.setWidget(SIDEKICK_WAKE_WIDGET_KEY, undefined);
|
|
107
|
+
installed = false;
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
clear(ctx) {
|
|
111
|
+
if (!installed) return;
|
|
112
|
+
installed = false;
|
|
113
|
+
if (!removable(ctx)) return;
|
|
114
|
+
ctx.ui.setWidget(SIDEKICK_WAKE_WIDGET_KEY, undefined);
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
53
119
|
type Registry = { getAvailable(): Model<Api>[]; find(provider: string, id: string): Model<Api> | undefined };
|
|
54
120
|
|
|
55
121
|
function registryOf(ctx: ExtensionContext): Registry | undefined {
|
|
@@ -120,10 +186,11 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
120
186
|
let leadToolCalls = 0;
|
|
121
187
|
let editNudgedThisTurn = false;
|
|
122
188
|
let bashStreak = 0;
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
189
|
+
|
|
190
|
+
const wakeLine = createSidekickWakeLine({
|
|
191
|
+
isBusy: () => runtime?.isBusy() === true,
|
|
192
|
+
progress: () => runtime?.progress(),
|
|
193
|
+
});
|
|
127
194
|
|
|
128
195
|
function identity(ctx: ExtensionContext): FusionIdentity {
|
|
129
196
|
const reg = registryOf(ctx);
|
|
@@ -167,78 +234,11 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
167
234
|
}
|
|
168
235
|
}
|
|
169
236
|
|
|
170
|
-
function
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
const ctx = lastCtx;
|
|
177
|
-
if (!ctx?.hasUI || typeof ctx.ui.setWidget !== "function") return;
|
|
178
|
-
const clear = () => {
|
|
179
|
-
widgetTimer = undefined;
|
|
180
|
-
widgetTimerKind = undefined;
|
|
181
|
-
widgetLastAt = Date.now();
|
|
182
|
-
ctx.ui.setWidget("fusion-sidekick", undefined);
|
|
183
|
-
};
|
|
184
|
-
const wait = Math.max(0, 150 - (Date.now() - widgetLastAt));
|
|
185
|
-
if (wait === 0) clear();
|
|
186
|
-
else {
|
|
187
|
-
widgetTimerKind = "clear";
|
|
188
|
-
widgetTimer = setTimeout(clear, wait);
|
|
189
|
-
widgetTimer.unref();
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function publishSidekickWidget(): void {
|
|
194
|
-
const ctx = lastCtx;
|
|
195
|
-
if (!ctx?.hasUI || typeof ctx.ui.setWidget !== "function") return;
|
|
196
|
-
const progress = runtime?.progress();
|
|
197
|
-
if (!runtime || !shouldShowSidekickWidget(runtime.isBusy(), attached) || !progress) {
|
|
198
|
-
ctx.ui.setWidget("fusion-sidekick", undefined);
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
ctx.ui.setWidget("fusion-sidekick", (_tui, theme) => {
|
|
202
|
-
const themeLike = theme as unknown as ThemeLike & { bg: (color: string, text: string) => string };
|
|
203
|
-
return frameSidekick(themeLike, "working", renderSidekickTranscript(themeLike, {
|
|
204
|
-
events: progress.events,
|
|
205
|
-
droppedEvents: progress.droppedEvents,
|
|
206
|
-
header: sidekickWorkingHeader(themeLike, progress),
|
|
207
|
-
expanded: false,
|
|
208
|
-
isPartial: true,
|
|
209
|
-
renderText: markdownText,
|
|
210
|
-
}));
|
|
211
|
-
}, { placement: "aboveEditor" });
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function publishSidekickWidgetLater(): void {
|
|
215
|
-
const ctx = lastCtx;
|
|
216
|
-
if (!ctx?.hasUI || typeof ctx.ui.setWidget !== "function") return;
|
|
217
|
-
if (widgetTimer !== undefined && widgetTimerKind === "publish") return;
|
|
218
|
-
if (widgetTimer !== undefined) {
|
|
219
|
-
clearTimeout(widgetTimer);
|
|
220
|
-
widgetTimer = undefined;
|
|
221
|
-
widgetTimerKind = undefined;
|
|
222
|
-
}
|
|
223
|
-
const wait = Math.max(0, 150 - (Date.now() - widgetLastAt));
|
|
224
|
-
const publish = () => {
|
|
225
|
-
widgetTimer = undefined;
|
|
226
|
-
widgetTimerKind = undefined;
|
|
227
|
-
widgetLastAt = Date.now();
|
|
228
|
-
publishSidekickWidget();
|
|
229
|
-
};
|
|
230
|
-
if (wait === 0) publish();
|
|
231
|
-
else {
|
|
232
|
-
widgetTimerKind = "publish";
|
|
233
|
-
widgetTimer = setTimeout(publish, wait);
|
|
234
|
-
widgetTimer.unref();
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function publishStatusLater(): void {
|
|
239
|
-
if (lastCtx) {
|
|
240
|
-
publishStatus(lastCtx);
|
|
241
|
-
publishSidekickWidgetLater();
|
|
237
|
+
function publishStatusLater(ctx?: ExtensionContext): void {
|
|
238
|
+
const target = ctx ?? lastCtx;
|
|
239
|
+
if (target) {
|
|
240
|
+
publishStatus(target);
|
|
241
|
+
wakeLine.publish(target);
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
244
|
|
|
@@ -263,8 +263,7 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
263
263
|
}
|
|
264
264
|
|
|
265
265
|
function stopRuntime(): void {
|
|
266
|
-
|
|
267
|
-
attached = false;
|
|
266
|
+
wakeLine.clear(lastCtx);
|
|
268
267
|
runtime?.kill();
|
|
269
268
|
runtime = undefined;
|
|
270
269
|
leadToolCalls = 0;
|
|
@@ -285,20 +284,10 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
285
284
|
|
|
286
285
|
registerFusionTools(pi, {
|
|
287
286
|
getRuntime,
|
|
288
|
-
onReport: (ctx) =>
|
|
289
|
-
attached = false;
|
|
290
|
-
clearSidekickWidget();
|
|
291
|
-
publishStatus(ctx);
|
|
292
|
-
},
|
|
287
|
+
onReport: (ctx) => publishStatusLater(ctx),
|
|
293
288
|
onHandoffStart: (ctx) => publishStatus(ctx),
|
|
294
|
-
onAttach: () =>
|
|
295
|
-
|
|
296
|
-
clearSidekickWidget();
|
|
297
|
-
},
|
|
298
|
-
onDetach: () => {
|
|
299
|
-
attached = false;
|
|
300
|
-
publishSidekickWidgetLater();
|
|
301
|
-
},
|
|
289
|
+
onAttach: (ctx) => publishStatusLater(ctx),
|
|
290
|
+
onDetach: (ctx) => publishStatusLater(ctx),
|
|
302
291
|
});
|
|
303
292
|
pi.registerCommand("unipi:fusion-stats", {
|
|
304
293
|
description: "Estimated Fusion savings (sidekick tokens priced at lead rates)",
|
|
@@ -308,6 +297,10 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
308
297
|
pi.on("turn_start", () => {
|
|
309
298
|
editNudgedThisTurn = false;
|
|
310
299
|
});
|
|
300
|
+
// The wake line's condition flips when the LEAD goes idle, not when the
|
|
301
|
+
// sidekick reports progress, so re-evaluate on both turn end and settle.
|
|
302
|
+
pi.on("turn_end", (_event, ctx) => publishStatusLater(ctx));
|
|
303
|
+
pi.on("agent_settled", (_event, ctx) => publishStatusLater(ctx));
|
|
311
304
|
pi.on("tool_result", (event) => {
|
|
312
305
|
if (active?.kind !== "fusion") return;
|
|
313
306
|
const toolName: string = event.toolName;
|
package/src/tools.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Text, type Component } from "@earendil-works/pi-tui";
|
|
|
2
2
|
import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import type { SidekickRuntime, HandoffProgress, HandoffReport } from "./sidekick-runtime.js";
|
|
5
|
-
import { duration, markdownText, renderSidekickTranscript, sidekickWorkingHeader } from "./transcript.js";
|
|
5
|
+
import { duration, frameSidekick, markdownText, renderSidekickTranscript, sidekickWorkingHeader } from "./transcript.js";
|
|
6
6
|
|
|
7
7
|
const SidekickParams = Type.Object({
|
|
8
8
|
message: Type.String({ description: "A concrete implementation or verification brief for the sidekick" }),
|
|
@@ -22,6 +22,46 @@ export interface FusionToolDeps {
|
|
|
22
22
|
onDetach?: (ctx: ExtensionContext) => void;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Exactly-once completion delivery for handoffs nobody is waiting on.
|
|
27
|
+
*
|
|
28
|
+
* `attach`/`detach` bracket every waiting period. The completion is sent only
|
|
29
|
+
* if the report lands (or has already landed) while no waiter is attached, and
|
|
30
|
+
* only once per handoff id.
|
|
31
|
+
*/
|
|
32
|
+
export function createCompletionDelivery(send: (report: HandoffReport) => void): {
|
|
33
|
+
attach(id: string): void;
|
|
34
|
+
detach(id: string, done: Promise<HandoffReport>): void;
|
|
35
|
+
consume(id: string): void;
|
|
36
|
+
} {
|
|
37
|
+
const waiting = new Set<string>();
|
|
38
|
+
const armed = new Set<string>();
|
|
39
|
+
const delivered = new Set<string>();
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
attach(id) {
|
|
43
|
+
waiting.add(id);
|
|
44
|
+
},
|
|
45
|
+
detach(id, done) {
|
|
46
|
+
waiting.delete(id);
|
|
47
|
+
// One continuation per handoff, however many times a waiter gives up.
|
|
48
|
+
if (armed.has(id)) return;
|
|
49
|
+
armed.add(id);
|
|
50
|
+
void done
|
|
51
|
+
.then((report) => {
|
|
52
|
+
if (waiting.has(id) || delivered.has(id)) return;
|
|
53
|
+
delivered.add(id);
|
|
54
|
+
send(report);
|
|
55
|
+
})
|
|
56
|
+
.catch(() => undefined);
|
|
57
|
+
},
|
|
58
|
+
consume(id) {
|
|
59
|
+
waiting.delete(id);
|
|
60
|
+
delivered.add(id);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
25
65
|
function firstLine(value: string): string {
|
|
26
66
|
return value.split("\n", 1)[0] ?? "";
|
|
27
67
|
}
|
|
@@ -101,6 +141,21 @@ type ThemeLike = {
|
|
|
101
141
|
bold: (text: string) => string;
|
|
102
142
|
};
|
|
103
143
|
|
|
144
|
+
type FramedTheme = ThemeLike & { bg: (color: string, text: string) => string };
|
|
145
|
+
|
|
146
|
+
function transcriptStatus(partial: boolean, report: HandoffReport | undefined): "working" | "completed" | "error" {
|
|
147
|
+
if (partial) return "working";
|
|
148
|
+
return report?.status === "completed" ? "completed" : "error";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Sidekick output on the main surface: the same markdown/tool format the lead
|
|
153
|
+
* uses, marked as sidekick-origin by the existing `▍` rail.
|
|
154
|
+
*/
|
|
155
|
+
function frameTranscript(theme: ThemeLike, status: "working" | "completed" | "error", content: Component): Component {
|
|
156
|
+
return frameSidekick(theme as FramedTheme, status, content);
|
|
157
|
+
}
|
|
158
|
+
|
|
104
159
|
// Model-facing result text carries handoff ids and protocol instructions the
|
|
105
160
|
// lead needs; none of it may reach the terminal. Anything rendered as plain
|
|
106
161
|
// content goes through this first.
|
|
@@ -147,7 +202,7 @@ function renderToolTranscript(result: { content?: unknown; details?: unknown; is
|
|
|
147
202
|
const partial = progress !== undefined;
|
|
148
203
|
if (!events) return new Text(displayText(contentText(result.content)), 0, 0);
|
|
149
204
|
const header = partial ? sidekickWorkingHeader(theme, progress, label) : reportHeader(theme, label, report);
|
|
150
|
-
return renderSidekickTranscript(theme, {
|
|
205
|
+
return frameTranscript(theme, transcriptStatus(partial, report), renderSidekickTranscript(theme, {
|
|
151
206
|
events,
|
|
152
207
|
droppedEvents: progress?.droppedEvents,
|
|
153
208
|
header,
|
|
@@ -155,30 +210,34 @@ function renderToolTranscript(result: { content?: unknown; details?: unknown; is
|
|
|
155
210
|
isPartial: partial,
|
|
156
211
|
report,
|
|
157
212
|
renderText: markdownText,
|
|
158
|
-
});
|
|
213
|
+
}));
|
|
159
214
|
}
|
|
160
215
|
|
|
161
216
|
function renderCompletionCard(theme: ThemeLike, report: HandoffReport | undefined): Component {
|
|
162
217
|
if (!report) return new Text(`${theme.fg("accent", "◆")} ${theme.fg("accent", theme.bold("sidekick done"))}`, 0, 0);
|
|
163
|
-
return renderSidekickTranscript(theme, {
|
|
218
|
+
return frameTranscript(theme, transcriptStatus(false, report), renderSidekickTranscript(theme, {
|
|
164
219
|
events: report.events ?? [],
|
|
165
220
|
header: reportHeader(theme, "sidekick", report),
|
|
166
221
|
expanded: false,
|
|
167
222
|
isPartial: false,
|
|
168
223
|
report,
|
|
169
224
|
renderText: markdownText,
|
|
170
|
-
});
|
|
225
|
+
}));
|
|
171
226
|
}
|
|
172
227
|
|
|
173
228
|
export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): void {
|
|
174
229
|
pi.registerMessageRenderer("sidekick-completion", (message: { details?: HandoffReport }, _options, theme) => renderCompletionCard(theme as unknown as ThemeLike, message.details));
|
|
175
230
|
|
|
231
|
+
// One delivery mechanism for every handoff nobody is waiting on.
|
|
232
|
+
const completion = createCompletionDelivery((report) => {
|
|
233
|
+
pi.sendMessage(completionMessage(report) as never, { deliverAs: "followUp", triggerTurn: true } as never);
|
|
234
|
+
});
|
|
235
|
+
|
|
176
236
|
pi.registerTool({
|
|
177
237
|
name: "sidekick",
|
|
178
238
|
label: "Sidekick",
|
|
179
239
|
description: "Hand off work to your persistent sidekick subagent (one per session; context and shells persist across handoffs; runs on the same machine). block:true (default) waits and returns the report. block:false returns immediately and the report arrives later as a <subagent_completion_notification>. Calling again while a handoff is running injects the message as an interrupt rather than starting a second sidekick.",
|
|
180
240
|
parameters: SidekickParams,
|
|
181
|
-
renderShell: "self",
|
|
182
241
|
renderCall: (args, theme) => new Text(`${theme.fg("toolTitle", theme.bold("◆ sidekick"))} ${theme.fg("dim", firstLine(String(args.message)).slice(0, 100))}`, 0, 0),
|
|
183
242
|
renderResult: (result, options, theme) => renderToolTranscript(result, options, theme as unknown as ThemeLike, "sidekick"),
|
|
184
243
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
@@ -188,19 +247,20 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
|
|
|
188
247
|
const handoff = runtime.handoff(params.message);
|
|
189
248
|
if (!wasBusy) deps.onHandoffStart?.(ctx);
|
|
190
249
|
if (params.block === false) {
|
|
191
|
-
void handoff.done.then((report) =>
|
|
192
|
-
|
|
193
|
-
pi.sendMessage(completionMessage(report) as never, { deliverAs: "followUp", triggerTurn: true } as never);
|
|
194
|
-
}).catch(() => undefined);
|
|
250
|
+
void handoff.done.then((report) => deps.onReport?.(ctx, report)).catch(() => undefined);
|
|
251
|
+
completion.detach(handoff.id, handoff.done);
|
|
195
252
|
deps.onDetach?.(ctx);
|
|
196
253
|
return result(`Handoff ${handoff.id} started in the background. You will receive a <subagent_completion_notification agent_id="${handoff.id}"> when it finishes; use read_subagent to wait.`, { background: true, id: handoff.id });
|
|
197
254
|
}
|
|
198
255
|
deps.onAttach?.(ctx);
|
|
256
|
+
completion.attach(handoff.id);
|
|
199
257
|
const waited = await waitForReport(runtime, handoff.id, handoff.done, signal, ctx, onUpdate ? (update) => onUpdate(update as never) : undefined);
|
|
200
258
|
if (waited.report) {
|
|
259
|
+
completion.consume(handoff.id);
|
|
201
260
|
deps.onReport?.(ctx, waited.report);
|
|
202
261
|
return result(reportText(waited.report), waited.report, waited.report.status !== "completed");
|
|
203
262
|
}
|
|
263
|
+
completion.detach(handoff.id, handoff.done);
|
|
204
264
|
deps.onDetach?.(ctx);
|
|
205
265
|
if (waited.error) return result(`Handoff ${handoff.id} failed: ${waited.error}`, undefined, true);
|
|
206
266
|
if (waited.aborted) return result(`${progressText(runtime, handoff.id)}\nHandoff ${handoff.id} aborted.`, undefined, true);
|
|
@@ -214,7 +274,6 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
|
|
|
214
274
|
label: "Read Sidekick",
|
|
215
275
|
description: "Read a sidekick handoff report by agent_id (omit for the latest). block:true waits for completion (default timeout 2700s when omitted); block:false returns the current progress snapshot immediately.",
|
|
216
276
|
parameters: ReadSubagentParams,
|
|
217
|
-
renderShell: "self",
|
|
218
277
|
renderCall: (args, theme) => new Text(`${theme.fg("toolTitle", theme.bold("◆ read_subagent"))} ${theme.fg("dim", args.block === false ? "· snapshot" : "· waiting")}`, 0, 0),
|
|
219
278
|
renderResult: (result, options, theme) => renderToolTranscript(result, options, theme as unknown as ThemeLike, "read_subagent"),
|
|
220
279
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
@@ -224,16 +283,22 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
|
|
|
224
283
|
if (!latest) return result("No sidekick handoff has run yet.", undefined, true);
|
|
225
284
|
const id = params.agent_id ?? latest.id;
|
|
226
285
|
const selected = runtime.reports.get(id);
|
|
227
|
-
if (selected)
|
|
286
|
+
if (selected) {
|
|
287
|
+
completion.consume(id);
|
|
288
|
+
return result(reportText(selected), selected, selected.status !== "completed");
|
|
289
|
+
}
|
|
228
290
|
if (id !== latest.id) return result(`No sidekick handoff found for ${id}.`, undefined, true);
|
|
229
291
|
if (params.block !== true) return result(`Handoff ${id} is still running.\n${progressText(runtime, id)}`, { progress: runtime.progress(id), id });
|
|
230
292
|
deps.onAttach?.(ctx);
|
|
293
|
+
completion.attach(id);
|
|
231
294
|
const timeoutMs = (params.timeout ?? 2700) * 1000;
|
|
232
295
|
const waited = await waitForReport(runtime, id, latest.done, signal, ctx, onUpdate ? (update) => onUpdate(update as never) : undefined, timeoutMs);
|
|
233
296
|
if (waited.report) {
|
|
297
|
+
completion.consume(id);
|
|
234
298
|
deps.onReport?.(ctx, waited.report);
|
|
235
299
|
return result(reportText(waited.report), waited.report, waited.report.status !== "completed");
|
|
236
300
|
}
|
|
301
|
+
completion.detach(id, latest.done);
|
|
237
302
|
deps.onDetach?.(ctx);
|
|
238
303
|
if (waited.error) return result(`Handoff ${id} failed: ${waited.error}`, undefined, true);
|
|
239
304
|
if (waited.aborted) return result(`Handoff ${id} aborted.`, undefined, true);
|
package/src/transcript.ts
CHANGED
|
@@ -53,6 +53,13 @@ export interface TranscriptOptions {
|
|
|
53
53
|
renderText: (markdown: string) => Component;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Non-expanded transcripts show this many most recent events. Windowing is the
|
|
58
|
+
* only cap: a completed handoff renders its events, it is never collapsed into
|
|
59
|
+
* a "N steps" stub.
|
|
60
|
+
*/
|
|
61
|
+
export const TRANSCRIPT_WINDOW = 8;
|
|
62
|
+
|
|
56
63
|
function firstLine(value: string): string {
|
|
57
64
|
return value.split("\n", 1)[0] ?? "";
|
|
58
65
|
}
|
|
@@ -91,14 +98,8 @@ export function renderSidekickTranscript(theme: ThemeLike, opts: TranscriptOptio
|
|
|
91
98
|
const box = new Container();
|
|
92
99
|
box.addChild(new Text(opts.header, 0, 0));
|
|
93
100
|
|
|
94
|
-
if (!opts.isPartial && !opts.expanded) {
|
|
95
|
-
if (opts.report?.text) box.addChild(opts.renderText(opts.report.text));
|
|
96
|
-
box.addChild(new Text(theme.fg("dim", `${String(opts.events.length)} steps · expand to see the transcript`), 0, 0));
|
|
97
|
-
return box;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
101
|
const dropped = opts.droppedEvents ?? 0;
|
|
101
|
-
const start = opts.expanded ? 0 : Math.max(0, opts.events.length -
|
|
102
|
+
const start = opts.expanded ? 0 : Math.max(0, opts.events.length - TRANSCRIPT_WINDOW);
|
|
102
103
|
if (opts.expanded && dropped > 0) {
|
|
103
104
|
box.addChild(new Text(theme.fg("dim", `… ${String(dropped)} earliest steps dropped`), 0, 0));
|
|
104
105
|
} else if (start > 0) {
|
|
@@ -110,12 +111,13 @@ export function renderSidekickTranscript(theme: ThemeLike, opts: TranscriptOptio
|
|
|
110
111
|
else if (event.text.trim()) box.addChild(opts.renderText(event.text.trim()));
|
|
111
112
|
}
|
|
112
113
|
|
|
113
|
-
if (!opts.isPartial && opts.
|
|
114
|
+
if (!opts.isPartial && opts.report?.text) {
|
|
114
115
|
// The final assistant message usually IS the report; don't print it twice.
|
|
115
116
|
const last = opts.events.at(-1);
|
|
116
|
-
if (last?.kind === "text" && last.text.trim() === opts.report.text.trim())
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
if (!(last?.kind === "text" && last.text.trim() === opts.report.text.trim())) {
|
|
118
|
+
box.addChild(new Text(theme.fg("dim", "── report ──"), 0, 0));
|
|
119
|
+
box.addChild(opts.renderText(opts.report.text));
|
|
120
|
+
}
|
|
119
121
|
}
|
|
120
122
|
return box;
|
|
121
123
|
}
|
package/src/sidekick-widget.ts
DELETED