@pi-unipi/fusion 2.18.1 → 2.19.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 +9 -2
- package/package.json +3 -3
- package/src/index.ts +93 -2
- package/src/sidekick-runtime.ts +62 -19
- package/src/sidekick-widget.ts +3 -0
- package/src/tools.ts +69 -38
- package/src/transcript.ts +15 -2
package/README.md
CHANGED
|
@@ -92,8 +92,15 @@ 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. Sidekick work
|
|
99
|
+
renders as ordinary tool activity — handoff ids, `agent_id`, and
|
|
100
|
+
`read_subagent` protocol text never reach the terminal. Detached
|
|
101
|
+
handoffs stay visible in a live sidekick widget above the editor until their
|
|
102
|
+
completion card arrives.
|
|
103
|
+
`read_subagent({agent_id?, block?, timeout?})` reads or waits for a handoff.
|
|
97
104
|
|
|
98
105
|
The child receives `UNIPI_FUSION_CHILD=1` and `UNIPI_SUBAGENT_CHILD=1`; the
|
|
99
106
|
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.1",
|
|
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.1",
|
|
36
|
+
"@pi-unipi/subagents": "2.19.1"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@earendil-works/pi-ai": "^0.84.0",
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,8 @@ 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 { shouldShowSidekickWidget } from "./sidekick-widget.js";
|
|
44
|
+
import { frameSidekick, markdownText, renderSidekickTranscript, sidekickWorkingHeader, type ThemeLike } from "./transcript.js";
|
|
43
45
|
|
|
44
46
|
export const MODEL_COMMAND = `${UNIPI_PREFIX}model`;
|
|
45
47
|
export const PRESET_COMMAND = `${UNIPI_PREFIX}fusion-preset`;
|
|
@@ -118,6 +120,10 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
118
120
|
let leadToolCalls = 0;
|
|
119
121
|
let editNudgedThisTurn = false;
|
|
120
122
|
let bashStreak = 0;
|
|
123
|
+
let attached = false;
|
|
124
|
+
let widgetTimer: NodeJS.Timeout | undefined;
|
|
125
|
+
let widgetTimerKind: "publish" | "clear" | undefined;
|
|
126
|
+
let widgetLastAt = 0;
|
|
121
127
|
|
|
122
128
|
function identity(ctx: ExtensionContext): FusionIdentity {
|
|
123
129
|
const reg = registryOf(ctx);
|
|
@@ -161,8 +167,79 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
161
167
|
}
|
|
162
168
|
}
|
|
163
169
|
|
|
170
|
+
function clearSidekickWidget(): void {
|
|
171
|
+
if (widgetTimer !== undefined) {
|
|
172
|
+
clearTimeout(widgetTimer);
|
|
173
|
+
widgetTimer = undefined;
|
|
174
|
+
widgetTimerKind = undefined;
|
|
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
|
+
|
|
164
238
|
function publishStatusLater(): void {
|
|
165
|
-
if (lastCtx)
|
|
239
|
+
if (lastCtx) {
|
|
240
|
+
publishStatus(lastCtx);
|
|
241
|
+
publishSidekickWidgetLater();
|
|
242
|
+
}
|
|
166
243
|
}
|
|
167
244
|
|
|
168
245
|
function leadSessionId(ctx: ExtensionContext): string {
|
|
@@ -186,6 +263,8 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
186
263
|
}
|
|
187
264
|
|
|
188
265
|
function stopRuntime(): void {
|
|
266
|
+
clearSidekickWidget();
|
|
267
|
+
attached = false;
|
|
189
268
|
runtime?.kill();
|
|
190
269
|
runtime = undefined;
|
|
191
270
|
leadToolCalls = 0;
|
|
@@ -206,8 +285,20 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
206
285
|
|
|
207
286
|
registerFusionTools(pi, {
|
|
208
287
|
getRuntime,
|
|
209
|
-
onReport: (ctx) =>
|
|
288
|
+
onReport: (ctx) => {
|
|
289
|
+
attached = false;
|
|
290
|
+
clearSidekickWidget();
|
|
291
|
+
publishStatus(ctx);
|
|
292
|
+
},
|
|
210
293
|
onHandoffStart: (ctx) => publishStatus(ctx),
|
|
294
|
+
onAttach: () => {
|
|
295
|
+
attached = true;
|
|
296
|
+
clearSidekickWidget();
|
|
297
|
+
},
|
|
298
|
+
onDetach: () => {
|
|
299
|
+
attached = false;
|
|
300
|
+
publishSidekickWidgetLater();
|
|
301
|
+
},
|
|
211
302
|
});
|
|
212
303
|
pi.registerCommand("unipi:fusion-stats", {
|
|
213
304
|
description: "Estimated Fusion savings (sidekick tokens priced at lead rates)",
|
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
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { Text, type Component } from "@earendil-works/pi-tui";
|
|
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 {
|
|
5
|
+
import { duration, 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" }),
|
|
@@ -18,10 +18,8 @@ export interface FusionToolDeps {
|
|
|
18
18
|
getRuntime: (ctx: ExtensionContext) => SidekickRuntime | undefined;
|
|
19
19
|
onReport?: (ctx: ExtensionContext, report: HandoffReport) => void;
|
|
20
20
|
onHandoffStart?: (ctx: ExtensionContext) => void;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
function duration(ms: number): string {
|
|
24
|
-
return `${(ms / 1000).toFixed(1)}s`;
|
|
21
|
+
onAttach?: (ctx: ExtensionContext) => void;
|
|
22
|
+
onDetach?: (ctx: ExtensionContext) => void;
|
|
25
23
|
}
|
|
26
24
|
|
|
27
25
|
function firstLine(value: string): string {
|
|
@@ -60,7 +58,7 @@ async function waitForReport(
|
|
|
60
58
|
ctx: ExtensionContext,
|
|
61
59
|
onUpdate?: (update: unknown) => void,
|
|
62
60
|
timeoutMs = 2700000,
|
|
63
|
-
): Promise<{ report?: HandoffReport; interrupted?: string; aborted?: boolean }> {
|
|
61
|
+
): Promise<{ report?: HandoffReport; interrupted?: string; aborted?: boolean; error?: string }> {
|
|
64
62
|
const started = Date.now();
|
|
65
63
|
let lastProgressKey = "";
|
|
66
64
|
while (true) {
|
|
@@ -72,8 +70,14 @@ async function waitForReport(
|
|
|
72
70
|
const remaining = timeoutMs - (Date.now() - started);
|
|
73
71
|
if (remaining <= 0) return {};
|
|
74
72
|
const timer = new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), Math.min(500, remaining)));
|
|
75
|
-
const
|
|
76
|
-
|
|
73
|
+
const outcome = await Promise.race([
|
|
74
|
+
done.then((report) => ({ report }), (error) => ({ error: error instanceof Error ? error.message : String(error) })),
|
|
75
|
+
timer,
|
|
76
|
+
]);
|
|
77
|
+
if (outcome !== undefined) {
|
|
78
|
+
if ("error" in outcome) return { error: outcome.error };
|
|
79
|
+
return { report: outcome.report };
|
|
80
|
+
}
|
|
77
81
|
const progress = progressText(runtime, id);
|
|
78
82
|
const key = progressKey(runtime, id);
|
|
79
83
|
if (key !== lastProgressKey) {
|
|
@@ -94,16 +98,26 @@ function completionMessage(report: HandoffReport): { customType: string; content
|
|
|
94
98
|
|
|
95
99
|
type ThemeLike = {
|
|
96
100
|
fg: (color: string, text: string) => string;
|
|
97
|
-
bg: (color: string, text: string) => string;
|
|
98
101
|
bold: (text: string) => string;
|
|
99
102
|
};
|
|
100
103
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
function
|
|
106
|
-
return
|
|
104
|
+
// Model-facing result text carries handoff ids and protocol instructions the
|
|
105
|
+
// lead needs; none of it may reach the terminal. Anything rendered as plain
|
|
106
|
+
// content goes through this first.
|
|
107
|
+
const HANDOFF_ID = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
|
|
108
|
+
function displayText(text: string): string {
|
|
109
|
+
return text
|
|
110
|
+
.replace(/<\/?subagent_completion_notification[^>]*>/g, "")
|
|
111
|
+
.replace(/use `?read_subagent`?[^.\n]*\.?/gi, "")
|
|
112
|
+
.replace(HANDOFF_ID, "")
|
|
113
|
+
.replace(/agent_id[ =]?"?"?/g, "")
|
|
114
|
+
.replace(/read_subagent\([^)]*\)/g, "read_subagent")
|
|
115
|
+
.replace(/\(\s*\)/g, "")
|
|
116
|
+
.replace(/[ \t]{2,}/g, " ")
|
|
117
|
+
.replace(/\bHandoff\s*(?=(is|was|failed|aborted|started)\b)/g, "The handoff ")
|
|
118
|
+
.replace(/ for\s*\./g, ".")
|
|
119
|
+
.replace(/ +$/gm, "")
|
|
120
|
+
.trim();
|
|
107
121
|
}
|
|
108
122
|
|
|
109
123
|
function contentText(content: unknown): string {
|
|
@@ -111,18 +125,29 @@ function contentText(content: unknown): string {
|
|
|
111
125
|
return content.map((part) => typeof part === "object" && part !== null && typeof (part as { text?: unknown }).text === "string" ? (part as { text: string }).text : "").filter(Boolean).join("\n");
|
|
112
126
|
}
|
|
113
127
|
|
|
128
|
+
function backgroundComponent(theme: ThemeLike): Component {
|
|
129
|
+
return new Text(`${theme.fg("accent", theme.bold("◆ sidekick"))} ${theme.fg("dim", "· continuing in background")}`, 0, 0);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
type ToolDetails = Partial<HandoffReport> & { progress?: HandoffProgress; background?: boolean; id?: string };
|
|
133
|
+
|
|
134
|
+
function reportHeader(theme: ThemeLike, label: string, report: HandoffReport | undefined): string {
|
|
135
|
+
const status = report?.status ?? "done";
|
|
136
|
+
return `${theme.fg(status === "completed" ? "success" : "error", "◆")} ${theme.fg("accent", theme.bold(`${label} ${status}`))} ${theme.fg("dim", report ? `· ${String(report.toolCalls)} tool calls · ${duration(report.durationMs)} · in ${String(report.usage.input)} / out ${String(report.usage.output)} tokens` : "")}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
114
139
|
function renderToolTranscript(result: { content?: unknown; details?: unknown; isError?: boolean }, options: { expanded?: boolean }, theme: ThemeLike, label: "sidekick" | "read_subagent"): Component {
|
|
115
|
-
const details = result.details as
|
|
140
|
+
const details = result.details as ToolDetails | undefined;
|
|
141
|
+
if (details?.background === true) return backgroundComponent(theme);
|
|
142
|
+
const hasProgress = details !== undefined && "progress" in details;
|
|
116
143
|
const progress = details?.progress;
|
|
144
|
+
if (hasProgress && progress === undefined) return backgroundComponent(theme);
|
|
117
145
|
const report = progress ? undefined : details as HandoffReport | undefined;
|
|
118
146
|
const events = progress?.events ?? report?.events;
|
|
119
147
|
const partial = progress !== undefined;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
? `${theme.fg("accent", theme.bold(`◆ ${label} working`))} ${theme.fg("dim", `· ${String(progress.toolCalls)} tool calls · ${duration(Date.now() - progress.startedAt)}`)}`
|
|
124
|
-
: `${theme.fg(report?.status === "completed" ? "success" : "error", "◆")} ${theme.fg("accent", theme.bold(`${label} ${report?.status ?? "done"}`))} ${theme.fg("dim", report ? `· ${report.id} · ${String(report.toolCalls)} tool calls · ${duration(report.durationMs)} · in ${String(report.usage.input)} / out ${String(report.usage.output)} tokens` : "")}`;
|
|
125
|
-
return frameSidekick(theme, status, renderSidekickTranscript(transcriptTheme(theme), {
|
|
148
|
+
if (!events) return new Text(displayText(contentText(result.content)), 0, 0);
|
|
149
|
+
const header = partial ? sidekickWorkingHeader(theme, progress, label) : reportHeader(theme, label, report);
|
|
150
|
+
return renderSidekickTranscript(theme, {
|
|
126
151
|
events,
|
|
127
152
|
droppedEvents: progress?.droppedEvents,
|
|
128
153
|
header,
|
|
@@ -130,20 +155,19 @@ function renderToolTranscript(result: { content?: unknown; details?: unknown; is
|
|
|
130
155
|
isPartial: partial,
|
|
131
156
|
report,
|
|
132
157
|
renderText: markdownText,
|
|
133
|
-
})
|
|
158
|
+
});
|
|
134
159
|
}
|
|
135
160
|
|
|
136
161
|
function renderCompletionCard(theme: ThemeLike, report: HandoffReport | undefined): Component {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return frameSidekick(theme, status, renderSidekickTranscript(transcriptTheme(theme), {
|
|
162
|
+
if (!report) return new Text(`${theme.fg("accent", "◆")} ${theme.fg("accent", theme.bold("sidekick done"))}`, 0, 0);
|
|
163
|
+
return renderSidekickTranscript(theme, {
|
|
140
164
|
events: report.events ?? [],
|
|
141
|
-
header:
|
|
165
|
+
header: reportHeader(theme, "sidekick", report),
|
|
142
166
|
expanded: false,
|
|
143
167
|
isPartial: false,
|
|
144
168
|
report,
|
|
145
169
|
renderText: markdownText,
|
|
146
|
-
})
|
|
170
|
+
});
|
|
147
171
|
}
|
|
148
172
|
|
|
149
173
|
export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): void {
|
|
@@ -155,7 +179,7 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
|
|
|
155
179
|
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.",
|
|
156
180
|
parameters: SidekickParams,
|
|
157
181
|
renderShell: "self",
|
|
158
|
-
renderCall: (args, theme) =>
|
|
182
|
+
renderCall: (args, theme) => new Text(`${theme.fg("toolTitle", theme.bold("◆ sidekick"))} ${theme.fg("dim", firstLine(String(args.message)).slice(0, 100))}`, 0, 0),
|
|
159
183
|
renderResult: (result, options, theme) => renderToolTranscript(result, options, theme as unknown as ThemeLike, "sidekick"),
|
|
160
184
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
161
185
|
const runtime = deps.getRuntime(ctx);
|
|
@@ -168,16 +192,20 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
|
|
|
168
192
|
deps.onReport?.(ctx, report);
|
|
169
193
|
pi.sendMessage(completionMessage(report) as never, { deliverAs: "followUp", triggerTurn: true } as never);
|
|
170
194
|
}).catch(() => undefined);
|
|
171
|
-
|
|
195
|
+
deps.onDetach?.(ctx);
|
|
196
|
+
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 });
|
|
172
197
|
}
|
|
198
|
+
deps.onAttach?.(ctx);
|
|
173
199
|
const waited = await waitForReport(runtime, handoff.id, handoff.done, signal, ctx, onUpdate ? (update) => onUpdate(update as never) : undefined);
|
|
174
200
|
if (waited.report) {
|
|
175
201
|
deps.onReport?.(ctx, waited.report);
|
|
176
202
|
return result(reportText(waited.report), waited.report, waited.report.status !== "completed");
|
|
177
203
|
}
|
|
204
|
+
deps.onDetach?.(ctx);
|
|
205
|
+
if (waited.error) return result(`Handoff ${handoff.id} failed: ${waited.error}`, undefined, true);
|
|
178
206
|
if (waited.aborted) return result(`${progressText(runtime, handoff.id)}\nHandoff ${handoff.id} aborted.`, undefined, true);
|
|
179
|
-
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
|
-
return result(`Handoff ${handoff.id} is still running.\n${progressText(runtime, handoff.id)}
|
|
207
|
+
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}`, { progress: runtime.progress(handoff.id), id: handoff.id });
|
|
208
|
+
return result(`Handoff ${handoff.id} is still running.\n${progressText(runtime, handoff.id)}`, { progress: runtime.progress(handoff.id), id: handoff.id });
|
|
181
209
|
},
|
|
182
210
|
});
|
|
183
211
|
|
|
@@ -187,7 +215,7 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
|
|
|
187
215
|
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.",
|
|
188
216
|
parameters: ReadSubagentParams,
|
|
189
217
|
renderShell: "self",
|
|
190
|
-
renderCall: (args, theme) =>
|
|
218
|
+
renderCall: (args, theme) => new Text(`${theme.fg("toolTitle", theme.bold("◆ read_subagent"))} ${theme.fg("dim", args.block === false ? "· snapshot" : "· waiting")}`, 0, 0),
|
|
191
219
|
renderResult: (result, options, theme) => renderToolTranscript(result, options, theme as unknown as ThemeLike, "read_subagent"),
|
|
192
220
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
193
221
|
const runtime = deps.getRuntime(ctx);
|
|
@@ -198,16 +226,19 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
|
|
|
198
226
|
const selected = runtime.reports.get(id);
|
|
199
227
|
if (selected) return result(reportText(selected), selected, selected.status !== "completed");
|
|
200
228
|
if (id !== latest.id) return result(`No sidekick handoff found for ${id}.`, undefined, true);
|
|
201
|
-
if (params.block !== true) return result(`Handoff ${id} is still running.\n${progressText(runtime, id)}
|
|
229
|
+
if (params.block !== true) return result(`Handoff ${id} is still running.\n${progressText(runtime, id)}`, { progress: runtime.progress(id), id });
|
|
230
|
+
deps.onAttach?.(ctx);
|
|
202
231
|
const timeoutMs = (params.timeout ?? 2700) * 1000;
|
|
203
232
|
const waited = await waitForReport(runtime, id, latest.done, signal, ctx, onUpdate ? (update) => onUpdate(update as never) : undefined, timeoutMs);
|
|
204
233
|
if (waited.report) {
|
|
205
234
|
deps.onReport?.(ctx, waited.report);
|
|
206
235
|
return result(reportText(waited.report), waited.report, waited.report.status !== "completed");
|
|
207
236
|
}
|
|
237
|
+
deps.onDetach?.(ctx);
|
|
238
|
+
if (waited.error) return result(`Handoff ${id} failed: ${waited.error}`, undefined, true);
|
|
208
239
|
if (waited.aborted) return result(`Handoff ${id} aborted.`, undefined, true);
|
|
209
|
-
if (waited.interrupted) return result(`A user message arrived while the sidekick (agent_id ${id}) was working.\n${waited.interrupted}
|
|
210
|
-
return result(`Handoff ${id} is still running.\n${progressText(runtime, id)}
|
|
240
|
+
if (waited.interrupted) return result(`A user message arrived while the sidekick (agent_id ${id}) was working.\n${waited.interrupted}`, { progress: runtime.progress(id), id });
|
|
241
|
+
return result(`Handoff ${id} is still running.\n${progressText(runtime, id)}`, { progress: runtime.progress(id), id });
|
|
211
242
|
},
|
|
212
243
|
});
|
|
213
244
|
|
package/src/transcript.ts
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
|
-
import { Box, Container, Text, type Component } from "@earendil-works/pi-tui";
|
|
2
|
-
import
|
|
1
|
+
import { Box, Container, Markdown, Text, type Component } from "@earendil-works/pi-tui";
|
|
2
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { HandoffProgress, SidekickEvent } from "./sidekick-runtime.js";
|
|
3
4
|
|
|
4
5
|
export interface ThemeLike {
|
|
5
6
|
fg: (color: string, text: string) => string;
|
|
6
7
|
bold: (text: string) => string;
|
|
7
8
|
}
|
|
8
9
|
|
|
10
|
+
export function duration(ms: number): string {
|
|
11
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function markdownText(markdown: string): Component {
|
|
15
|
+
return new Markdown(markdown, 0, 0, getMarkdownTheme());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function sidekickWorkingHeader(theme: ThemeLike, progress: Pick<HandoffProgress, "toolCalls" | "startedAt">, label = "sidekick"): string {
|
|
19
|
+
return `${theme.fg("accent", theme.bold(`◆ ${label} working`))} ${theme.fg("dim", `· ${String(progress.toolCalls)} tool calls · ${duration(Date.now() - progress.startedAt)}`)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
9
22
|
export class RailComponent implements Component {
|
|
10
23
|
constructor(private readonly inner: Component, private readonly rail: string) {}
|
|
11
24
|
|