@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 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. `read_subagent({agent_id?, block?, timeout?})` reads or waits for a
96
- handoff.
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.18.1",
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.18.0",
36
- "@pi-unipi/subagents": "2.18.1"
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) publishStatus(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) => publishStatus(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)",
@@ -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 error = new Error(String(message.error ?? "Sidekick prompt rejected"));
217
+ const errorText = String(message.error ?? "Sidekick prompt rejected");
201
218
  const current = this.pending;
202
- this.pending = undefined;
203
- this.responseError = undefined;
204
- this.responseText = undefined;
205
- current?.reject(error);
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 === "assistant") {
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.responseText = undefined;
272
- this.responseError = undefined;
273
- this.responseText = (text) => this.finish(this.abortRequested ? "aborted" : this.pendingError === undefined ? "completed" : "error", text, this.pendingError);
274
- this.responseError = (error) => this.finish("error", undefined, error.message);
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
- let reject!: (error: Error) => void;
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 {
@@ -0,0 +1,3 @@
1
+ export function shouldShowSidekickWidget(busy: boolean, attached: boolean): boolean {
2
+ return busy && !attached;
3
+ }
package/src/tools.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { Markdown, Text, type Component } from "@earendil-works/pi-tui";
2
- import { getMarkdownTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
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 { frameSidekick, renderSidekickTranscript, type ThemeLike as TranscriptTheme } from "./transcript.js";
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 report = await Promise.race([done, timer]);
76
- if (report !== undefined) return { report };
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
- function transcriptTheme(theme: ThemeLike): TranscriptTheme {
102
- return { fg: (color, text) => theme.fg(color, text), bold: (text) => theme.bold(text) };
103
- }
104
-
105
- function markdownText(markdown: string): Markdown {
106
- return new Markdown(markdown, 0, 0, getMarkdownTheme());
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 (HandoffReport & { progress?: HandoffProgress }) | { progress?: HandoffProgress } | undefined;
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
- const status = partial ? "working" : report?.status === "completed" && !result.isError ? "completed" : "error";
121
- if (!events) return frameSidekick(theme, status, new Text(contentText(result.content), 0, 0));
122
- const header = partial
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
- const status = report?.status === "completed" ? "completed" : "error";
138
- if (!report) return frameSidekick(theme, "error", new Text(`${theme.fg("accent", "◆")} ${theme.fg("accent", theme.bold("sidekick done"))}`, 0, 0));
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: `${theme.fg(report.status === "completed" ? "success" : "error", "")} ${theme.fg("accent", theme.bold(`sidekick ${report.status}`))} ${theme.fg("dim", `· ${report.id} · ${String(report.toolCalls)} tool calls · ${duration(report.durationMs)}`)}`,
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) => frameSidekick(theme as unknown as ThemeLike, "working", new Text(`${theme.fg("toolTitle", theme.bold("◆ sidekick"))} ${theme.fg("dim", firstLine(String(args.message)).slice(0, 100))}`, 0, 0)),
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
- 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.`);
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) => frameSidekick(theme as unknown as ThemeLike, "working", new Text(`${theme.fg("toolTitle", theme.bold("◆ read_subagent"))} ${theme.fg("dim", args.agent_id ?? "latest")}`, 0, 0)),
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 type { SidekickEvent } from "./sidekick-runtime.js";
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