@pi-unipi/unipi 2.17.2 → 2.18.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/package.json +24 -24
  3. package/packages/ask-user/package.json +2 -2
  4. package/packages/autocomplete/package.json +1 -1
  5. package/packages/background-tasks/package.json +2 -2
  6. package/packages/btw/package.json +2 -2
  7. package/packages/compactor/package.json +3 -3
  8. package/packages/core/fusion-status.ts +6 -0
  9. package/packages/core/package.json +1 -1
  10. package/packages/footer/package.json +3 -3
  11. package/packages/footer/src/glance-editor.ts +22 -8
  12. package/packages/fusion/package.json +3 -3
  13. package/packages/fusion/src/index.ts +43 -6
  14. package/packages/fusion/src/nudge.ts +7 -0
  15. package/packages/fusion/src/prompts.ts +19 -3
  16. package/packages/fusion/src/sidekick-runtime.ts +83 -7
  17. package/packages/fusion/src/tools.ts +67 -14
  18. package/packages/fusion/src/transcript.ts +108 -0
  19. package/packages/image/package.json +2 -2
  20. package/packages/info-screen/package.json +2 -2
  21. package/packages/input-shortcuts/package.json +2 -2
  22. package/packages/kanboard/package.json +2 -2
  23. package/packages/mcp/package.json +2 -2
  24. package/packages/memory/package.json +3 -3
  25. package/packages/milestone/package.json +2 -2
  26. package/packages/notify/package.json +2 -2
  27. package/packages/ralph/package.json +3 -3
  28. package/packages/subagents/package.json +2 -2
  29. package/packages/trajectory/package.json +1 -1
  30. package/packages/unipi/bundled.js +283 -45
  31. package/packages/updater/package.json +2 -2
  32. package/packages/utility/package.json +2 -2
  33. package/packages/web-api/package.json +2 -2
  34. package/packages/workflow/package.json +2 -2
@@ -26381,8 +26381,8 @@ function buildOverlayTranscript(entries, theme) {
26381
26381
  if (options.blankBefore !== false) {
26382
26382
  pushBlankLine();
26383
26383
  }
26384
- const firstLine2 = bodyLines.shift() ?? "";
26385
- lines.push(`${header}${firstLine2 ? ` ${style(firstLine2)}` : ""}`);
26384
+ const firstLine4 = bodyLines.shift() ?? "";
26385
+ lines.push(`${header}${firstLine4 ? ` ${style(firstLine4)}` : ""}`);
26386
26386
  for (const line of bodyLines) {
26387
26387
  lines.push(`${blockIndent}${style(line)}`);
26388
26388
  }
@@ -33186,34 +33186,34 @@ function registerAskUserTools(pi) {
33186
33186
  };
33187
33187
  }
33188
33188
  const response = result2.response;
33189
- let contentText;
33189
+ let contentText2;
33190
33190
  switch (response.kind) {
33191
33191
  case "selection": {
33192
33192
  const selections = response.selections || [];
33193
- contentText = selections.length === 1 ? `User selected: ${selections[0]}` : `User selected: ${selections.join(", ")}`;
33193
+ contentText2 = selections.length === 1 ? `User selected: ${selections[0]}` : `User selected: ${selections.join(", ")}`;
33194
33194
  break;
33195
33195
  }
33196
33196
  case "freeform":
33197
- contentText = `User wrote: ${response.text}`;
33197
+ contentText2 = `User wrote: ${response.text}`;
33198
33198
  break;
33199
33199
  case "combined": {
33200
33200
  const selections = response.selections || [];
33201
33201
  const selText = selections.length === 1 ? selections[0] : selections.join(", ");
33202
- contentText = `User selected: ${selText} and wrote: ${response.text}`;
33202
+ contentText2 = `User selected: ${selText} and wrote: ${response.text}`;
33203
33203
  break;
33204
33204
  }
33205
33205
  case "end_turn":
33206
33206
  ctx.abort();
33207
- contentText = "User chose to end the turn.";
33207
+ contentText2 = "User chose to end the turn.";
33208
33208
  break;
33209
33209
  case "new_session":
33210
- contentText = response.prefill ? `User chose to hand off to: ${response.prefill}` : "User chose a handoff without a prefill.";
33210
+ contentText2 = response.prefill ? `User chose to hand off to: ${response.prefill}` : "User chose a handoff without a prefill.";
33211
33211
  break;
33212
33212
  case "timed_out":
33213
- contentText = "User did not respond (timed out)";
33213
+ contentText2 = "User did not respond (timed out)";
33214
33214
  break;
33215
33215
  default:
33216
- contentText = "No response";
33216
+ contentText2 = "No response";
33217
33217
  }
33218
33218
  if (response.kind === "new_session") {
33219
33219
  const prefill = response.prefill || "";
@@ -33265,14 +33265,14 @@ Preparing for new task. Summarize previous work concisely, preserving only what'
33265
33265
  }
33266
33266
  const launchLabel = launcherResult.action === "compact" ? "compact" : "direct";
33267
33267
  if (handoff.status === "editor_prefill") {
33268
- contentText = `Queued ${launchLabel} handoff fell back to editor prefill: ${handoff.prefill}`;
33268
+ contentText2 = `Queued ${launchLabel} handoff fell back to editor prefill: ${handoff.prefill}`;
33269
33269
  } else if (handoff.status === "failed") {
33270
- contentText = `Failed to queue ${launchLabel} handoff: ${handoff.prefill ?? prefill}`;
33270
+ contentText2 = `Failed to queue ${launchLabel} handoff: ${handoff.prefill ?? prefill}`;
33271
33271
  } else {
33272
- contentText = `Queued ${launchLabel} handoff: ${handoff.prefill ?? prefill}`;
33272
+ contentText2 = `Queued ${launchLabel} handoff: ${handoff.prefill ?? prefill}`;
33273
33273
  }
33274
33274
  return {
33275
- content: [{ type: "text", text: contentText }],
33275
+ content: [{ type: "text", text: contentText2 }],
33276
33276
  details: {
33277
33277
  ...detailsBase,
33278
33278
  response: {
@@ -33287,7 +33287,7 @@ Preparing for new task. Summarize previous work concisely, preserving only what'
33287
33287
  };
33288
33288
  }
33289
33289
  return {
33290
- content: [{ type: "text", text: contentText }],
33290
+ content: [{ type: "text", text: contentText2 }],
33291
33291
  details: {
33292
33292
  ...detailsBase,
33293
33293
  response
@@ -44693,6 +44693,19 @@ var SEP = " \u2502 ";
44693
44693
  var RESET2 = "\x1B[0m";
44694
44694
  var LEAD_FG = "\x1B[1m\x1B[38;2;181;189;104m";
44695
44695
  var DIM_FG = "\x1B[38;2;120;124;134m";
44696
+ function renderFusionStatus(fusion) {
44697
+ const lead = `${fusion.leadName}${fusion.leadEffort ? ` ${fusion.leadEffort}` : ""}`;
44698
+ const side = `${fusion.sidekickName}${fusion.sidekickEffort ? ` ${fusion.sidekickEffort}` : ""}`;
44699
+ const busy = fusion.busy === true;
44700
+ const leadCalls = fusion.leadToolCalls ?? 0;
44701
+ const sidekickCalls = fusion.sidekickToolCalls ?? 0;
44702
+ const totalCalls = leadCalls + sidekickCalls;
44703
+ const saved = fusion.savedUsd !== void 0 && fusion.savedUsd > 5e-3 ? ` \xB7 saved $${fusion.savedUsd.toFixed(2)}` : "";
44704
+ const split = totalCalls > 0 ? ` \xB7 ${String(sidekickCalls)}/${String(totalCalls)} calls` : "";
44705
+ const core = busy ? `${DIM_FG}Fusion \xB7 ${lead}${RESET2} ${LEAD_FG}\u25C6 ${side} \xB7 working${RESET2}` : `${LEAD_FG}Fusion \xB7 ${lead}${RESET2} ${DIM_FG}\u25C6 ${side}${RESET2}`;
44706
+ const tail = `${split}${saved}`;
44707
+ return tail ? `${core}${DIM_FG}${tail}${RESET2}` : core;
44708
+ }
44696
44709
  var ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|_[^\x07]*(?:\x07|\x1b\\))/g;
44697
44710
  function stripControls(text) {
44698
44711
  return text.replace(ANSI_RE, "").replace(/[\r\n\t]/g, " ");
@@ -44789,11 +44802,7 @@ var GlanceEditor = class _GlanceEditor extends CustomEditor {
44789
44802
  const winLabel = st.contextWindow > 0 ? `/${fmtTokens(st.contextWindow)}` : "";
44790
44803
  rightParts.push(`${pctLabel}${winLabel}`);
44791
44804
  if (st.fusion) {
44792
- const lead = `${st.fusion.leadName}${st.fusion.leadEffort ? ` ${st.fusion.leadEffort}` : ""}`;
44793
- const side = `${st.fusion.sidekickName}${st.fusion.sidekickEffort ? ` ${st.fusion.sidekickEffort}` : ""}`;
44794
- rightParts.push(
44795
- `${LEAD_FG}Fusion \xB7 ${lead}${RESET2} ${DIM_FG}\u25C6 ${side}${st.fusion.savedUsd !== void 0 && st.fusion.savedUsd > 5e-3 ? ` \xB7 saved $${st.fusion.savedUsd.toFixed(2)}` : ""}${RESET2}`
44796
- );
44805
+ rightParts.push(renderFusionStatus(st.fusion));
44797
44806
  } else {
44798
44807
  if (st.modelName) rightParts.push(st.modelName);
44799
44808
  if (st.thinkingLevel && st.thinkingLevel !== "off") {
@@ -51095,6 +51104,8 @@ import { mkdirSync as mkdirSync44, unlinkSync as unlinkSync12, writeFileSync as
51095
51104
  import { tmpdir as tmpdir7 } from "node:os";
51096
51105
  import { dirname as dirname49, join as join75 } from "node:path";
51097
51106
  import { randomUUID as randomUUID11 } from "node:crypto";
51107
+ var MAX_EVENTS = 300;
51108
+ var MAX_TOOL_OUTPUT = 4e3;
51098
51109
  var emptyUsage = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
51099
51110
  var SidekickRuntime = class {
51100
51111
  cfg;
@@ -51119,6 +51130,37 @@ var SidekickRuntime = class {
51119
51130
  isBusy() {
51120
51131
  return this.pending !== void 0;
51121
51132
  }
51133
+ totalToolCalls() {
51134
+ let total = this.pending?.progress.toolCalls ?? 0;
51135
+ for (const report of this.reports.values()) total += report.toolCalls;
51136
+ return total;
51137
+ }
51138
+ notifyProgress() {
51139
+ try {
51140
+ this.cfg.onProgress?.();
51141
+ } catch {
51142
+ }
51143
+ }
51144
+ appendEvent(event) {
51145
+ const progress = this.pending?.progress;
51146
+ if (!progress) return;
51147
+ progress.events.push(event);
51148
+ if (progress.events.length > MAX_EVENTS) {
51149
+ progress.events.shift();
51150
+ progress.droppedEvents += 1;
51151
+ }
51152
+ }
51153
+ closeOpenText() {
51154
+ const events = this.pending?.progress.events;
51155
+ const last = events?.at(-1);
51156
+ if (last?.kind === "text" && last.open) last.open = false;
51157
+ }
51158
+ toolOutput(result2) {
51159
+ if (typeof result2 === "object" && result2 !== null && Array.isArray(result2.content)) {
51160
+ return result2.content.map((part) => typeof part === "object" && part !== null && typeof part.text === "string" ? part.text : typeof part === "string" ? part : "").filter(Boolean).join("\n").slice(-MAX_TOOL_OUTPUT);
51161
+ }
51162
+ return String(result2 ?? "").slice(-MAX_TOOL_OUTPUT);
51163
+ }
51122
51164
  send(value) {
51123
51165
  if (!this.child?.stdin?.writable) throw new Error("Sidekick process is not writable");
51124
51166
  this.child.stdin.write(`${JSON.stringify(value)}
@@ -51210,19 +51252,39 @@ var SidekickRuntime = class {
51210
51252
  }
51211
51253
  if (this.pending === void 0) return;
51212
51254
  if (message.type === "tool_execution_start") {
51255
+ this.closeOpenText();
51213
51256
  this.pending.progress.toolCalls += 1;
51214
- const args = message.args === void 0 ? "" : JSON.stringify(message.args).replace(/\s+/gu, " ");
51215
- const summary = `${String(message.toolName ?? "tool")}(${args})`.slice(0, 40);
51257
+ const args = message.args !== void 0 && typeof message.args === "object" && message.args !== null ? message.args : void 0;
51258
+ const argsText = args === void 0 ? "" : JSON.stringify(args).replace(/\s+/gu, " ");
51259
+ const summary = `${String(message.toolName ?? "tool")}(${argsText})`.slice(0, 40);
51216
51260
  this.pending.progress.recentTools = [...this.pending.progress.recentTools, summary].slice(-6);
51261
+ this.appendEvent({ kind: "tool", toolCallId: String(message.toolCallId ?? ""), name: String(message.toolName ?? "tool"), args, output: "", isError: false, done: false, startedAt: Date.now() });
51262
+ this.notifyProgress();
51263
+ } else if (message.type === "tool_execution_end") {
51264
+ const toolCallId = String(message.toolCallId ?? "");
51265
+ const event = [...this.pending.progress.events].reverse().find((entry) => entry.kind === "tool" && entry.toolCallId === toolCallId);
51266
+ if (event) {
51267
+ event.done = true;
51268
+ event.endedAt = Date.now();
51269
+ event.isError = message.isError === true;
51270
+ event.output = this.toolOutput(message.result);
51271
+ }
51272
+ this.notifyProgress();
51217
51273
  } else if (message.type === "message_update") {
51218
- const event = message.assistantMessageEvent ?? message;
51219
- if (event.type === "text_delta") {
51220
- const delta = typeof event.delta === "string" ? event.delta : typeof event.text === "string" ? event.text : "";
51274
+ const streamEvent = message.assistantMessageEvent ?? message;
51275
+ if (streamEvent.type === "text_delta") {
51276
+ const delta = typeof streamEvent.delta === "string" ? streamEvent.delta : typeof streamEvent.text === "string" ? streamEvent.text : "";
51221
51277
  this.pending.progress.textTail = `${this.pending.progress.textTail}${delta}`.slice(-400);
51278
+ const last = this.pending.progress.events.at(-1);
51279
+ if (last?.kind === "text" && last.open) last.text += delta;
51280
+ else this.appendEvent({ kind: "text", text: delta, open: true });
51281
+ this.notifyProgress();
51222
51282
  }
51223
51283
  } else if (message.type === "message_end") {
51224
51284
  const msg = message.message;
51225
51285
  if (msg?.role === "assistant") {
51286
+ this.closeOpenText();
51287
+ this.notifyProgress();
51226
51288
  if (msg.stopReason === "error" && typeof msg.errorMessage === "string") this.pendingError = msg.errorMessage;
51227
51289
  const usage2 = msg.usage;
51228
51290
  if (usage2) {
@@ -51270,11 +51332,13 @@ var SidekickRuntime = class {
51270
51332
  usage: { ...current.usage },
51271
51333
  toolCalls: current.progress.toolCalls,
51272
51334
  durationMs: Date.now() - current.startedAt,
51335
+ events: current.progress.events.map((event) => ({ ...event })),
51273
51336
  ...error === void 0 ? {} : { error }
51274
51337
  };
51275
51338
  this.reports.set(report.id, report);
51276
51339
  if (this.latestHandoff?.id === report.id) this.latestHandoff.report = report;
51277
51340
  current.resolve(report);
51341
+ this.notifyProgress();
51278
51342
  this.abortRequested = false;
51279
51343
  this.pendingError = void 0;
51280
51344
  }
@@ -51297,7 +51361,7 @@ var SidekickRuntime = class {
51297
51361
  id,
51298
51362
  startedAt,
51299
51363
  usage: emptyUsage(),
51300
- progress: { toolCalls: 0, recentTools: [], textTail: "", startedAt },
51364
+ progress: { toolCalls: 0, recentTools: [], textTail: "", startedAt, events: [], droppedEvents: 0 },
51301
51365
  resolve: resolve22,
51302
51366
  reject
51303
51367
  };
@@ -51310,7 +51374,9 @@ var SidekickRuntime = class {
51310
51374
  return { id, done };
51311
51375
  }
51312
51376
  progress(id) {
51313
- if (this.pending !== void 0 && (id === void 0 || id === this.pending.id)) return { ...this.pending.progress, recentTools: [...this.pending.progress.recentTools] };
51377
+ if (this.pending !== void 0 && (id === void 0 || id === this.pending.id)) {
51378
+ return { ...this.pending.progress, recentTools: [...this.pending.progress.recentTools], events: this.pending.progress.events.map((event) => ({ ...event })) };
51379
+ }
51314
51380
  return void 0;
51315
51381
  }
51316
51382
  latest() {
@@ -51387,7 +51453,10 @@ You have a \`sidekick\` tool: a persistent subagent that works alongside you on
51387
51453
  - **Premature implementation briefing.** Working solo you would catch a wrong assumption as you go; when delegating, the sidekick executes what you wrote, and changing course afterwards is expensive. Hold your plan to a higher confidence bar than you would need to start yourself. Every claim your plan depends on must either be verified or explicitly marked as a hypothesis for the sidekick to verify; verification instructions must cover every surface you discovered.
51388
51454
  - **Promoting a delegated hypothesis to a confirmed conclusion.** When a report ranks candidate causes, the ranking is not a verdict. Present a cause as the root cause only if evidence shows its code path actually executes in the reported scenario; otherwise present it as the leading hypothesis and name the check that would settle it.`;
51389
51455
  }
51390
- var FIRST_EDIT_NUDGE = "<system_guidance>You made a direct edit yourself instead of delegating to the sidekick. That is fine for a trivially small change (one you can make and confirm in 1-2 turns with nothing left to test). For anything larger \u2014 multiple files, anything that needs a test run, anything you would want to look over again \u2014 write a brief and hand it to `sidekick` instead: you design and review, it implements and verifies. This reminder is shown once.</system_guidance>";
51456
+ var EDIT_NUDGE = "<system_guidance>You made a direct edit yourself instead of delegating to the sidekick. This is a reminder that implementation and verification are to be delegated by default. ONLY implement a step yourself if it is trivially small (you can make the edit AND confirm it in 1-2 of your own turns, with nothing left to test afterwards) or correctness-critical (queries against shared data systems, eval/grading text, pipeline or threshold configuration \u2014 you author and check those regardless of size). For anything else, write a brief and hand it to `sidekick`: you design and review, it implements and verifies.</system_guidance>";
51457
+ function bashNudge(count) {
51458
+ return `<system_guidance>You have run ${String(count)} non-trivial shell commands yourself since the last handoff. Builds, test runs, installs, environment setup or repair, and any multi-step shell work are to be delegated to the \`sidekick\` by default; it runs on the same machine and remembers earlier handoffs, so a short brief with the goal, the exact commands or checks you want, and the done-criteria is enough. Keep running commands yourself only when a single read-only command answers a question you need right now, or when the user is waiting on an urgent deliverable.</system_guidance>`;
51459
+ }
51391
51460
  function sidekickSystemPrompt(id) {
51392
51461
  return `## Role: Fusion sidekick
51393
51462
  You are the sidekick half of a Fusion pair. A lead agent (${id.leadName}) plans, talks to the user, and reviews; you do the hands-on work it hands you. You run on the same machine and repository as the lead, with your own shell sessions. Your conversation persists across handoffs: remember what you did, what you learned, and which processes you left running.
@@ -51411,9 +51480,97 @@ How you report (your final message is what the lead reads \u2014 make it the who
51411
51480
  Prefer compact, idiomatic code that follows the repository's existing conventions; do not add or remove comments unless asked; do not create documentation files unless asked.`;
51412
51481
  }
51413
51482
 
51483
+ // packages/fusion/src/nudge.ts
51484
+ var BASH_NUDGE_EVERY = 4;
51485
+ var TRIVIAL = /^\s*(?:cd\s+\S+\s*(?:&&|;)\s*)?(?:git\s+(?:status|log|diff|branch|show|remote|rev-parse)|ls|pwd|cat|head|tail|wc|echo|which|type|rg|grep|find|stat|file|du|df|env|printenv|date|whoami|tmux\s+capture-pane|npm\s+(?:view|whoami|ls))\b[^|;&]*$/;
51486
+ function isTrivialShell(command) {
51487
+ return TRIVIAL.test(command.trim());
51488
+ }
51489
+
51414
51490
  // packages/fusion/src/tools.ts
51415
- import { Box as Box3, Text as Text8 } from "@earendil-works/pi-tui";
51491
+ import { Markdown as Markdown2, Text as Text9 } from "@earendil-works/pi-tui";
51492
+ import { getMarkdownTheme as getMarkdownTheme2 } from "@earendil-works/pi-coding-agent";
51416
51493
  import { Type as Type11 } from "typebox";
51494
+
51495
+ // packages/fusion/src/transcript.ts
51496
+ import { Box as Box3, Container as Container3, Text as Text8 } from "@earendil-works/pi-tui";
51497
+ var RailComponent = class {
51498
+ constructor(inner, rail) {
51499
+ this.inner = inner;
51500
+ this.rail = rail;
51501
+ }
51502
+ inner;
51503
+ rail;
51504
+ render(width) {
51505
+ return this.inner.render(Math.max(1, width - 2)).map((line) => `${this.rail} ${line}`);
51506
+ }
51507
+ invalidate() {
51508
+ this.inner.invalidate?.();
51509
+ }
51510
+ handleInput(data) {
51511
+ this.inner.handleInput?.(data);
51512
+ }
51513
+ };
51514
+ function frameSidekick(theme, status, content) {
51515
+ const railColor = status === "working" ? "accent" : status === "completed" ? "success" : "error";
51516
+ const rail = theme.fg(railColor, "\u258D");
51517
+ const boxed = new Box3(1, 0, (text) => theme.bg("customMessageBg", text));
51518
+ boxed.addChild(new RailComponent(content, rail));
51519
+ return boxed;
51520
+ }
51521
+ function firstLine2(value) {
51522
+ return value.split("\n", 1)[0] ?? "";
51523
+ }
51524
+ function truncate2(value, max) {
51525
+ return value.length > max ? `${value.slice(0, Math.max(0, max - 1))}\u2026` : value;
51526
+ }
51527
+ function primaryArg(name, args) {
51528
+ if (!args) return "";
51529
+ const value = name === "bash" ? args.command : name === "read" || name === "edit" || name === "write" ? args.path ?? args.file_path ?? args.filePath : name === "sidekick" ? args.message : Object.values(args).find((entry) => typeof entry === "string");
51530
+ return typeof value === "string" ? truncate2(firstLine2(value), 100) : "";
51531
+ }
51532
+ function toolComponent(theme, event, expanded) {
51533
+ const title = `${event.isError ? theme.fg("error", "\u2717 ") : ""}${theme.fg("toolTitle", theme.bold(event.name))}`;
51534
+ const argument = primaryArg(event.name, event.args);
51535
+ const lines = [`${title}${argument.length > 0 ? ` ${theme.fg("accent", argument)}` : ""}`];
51536
+ if (!event.done) {
51537
+ lines[0] += theme.fg("warning", " \u22EF running");
51538
+ } else if (event.output.length > 0) {
51539
+ const output = event.output.split("\n");
51540
+ const visible = expanded ? output.slice(-40) : output.slice(-3);
51541
+ lines.push(...visible.map((line) => theme.fg("toolOutput", truncate2(line, 160))));
51542
+ }
51543
+ return new Text8(lines.join("\n"), 0, 0);
51544
+ }
51545
+ function renderSidekickTranscript(theme, opts) {
51546
+ const box = new Container3();
51547
+ box.addChild(new Text8(opts.header, 0, 0));
51548
+ if (!opts.isPartial && !opts.expanded) {
51549
+ if (opts.report?.text) box.addChild(opts.renderText(opts.report.text));
51550
+ box.addChild(new Text8(theme.fg("dim", `${String(opts.events.length)} steps \xB7 expand to see the transcript`), 0, 0));
51551
+ return box;
51552
+ }
51553
+ const dropped = opts.droppedEvents ?? 0;
51554
+ const start = opts.expanded ? 0 : Math.max(0, opts.events.length - 8);
51555
+ if (opts.expanded && dropped > 0) {
51556
+ box.addChild(new Text8(theme.fg("dim", `\u2026 ${String(dropped)} earliest steps dropped`), 0, 0));
51557
+ } else if (start > 0) {
51558
+ box.addChild(new Text8(theme.fg("dim", `\u2026 ${String(start + dropped)} earlier steps`), 0, 0));
51559
+ }
51560
+ for (const event of opts.events.slice(start)) {
51561
+ if (event.kind === "tool") box.addChild(toolComponent(theme, event, opts.expanded));
51562
+ else if (event.text.trim()) box.addChild(opts.renderText(event.text.trim()));
51563
+ }
51564
+ if (!opts.isPartial && opts.expanded && opts.report?.text) {
51565
+ const last = opts.events.at(-1);
51566
+ if (last?.kind === "text" && last.text.trim() === opts.report.text.trim()) return box;
51567
+ box.addChild(new Text8(theme.fg("dim", "\u2500\u2500 report \u2500\u2500"), 0, 0));
51568
+ box.addChild(opts.renderText(opts.report.text));
51569
+ }
51570
+ return box;
51571
+ }
51572
+
51573
+ // packages/fusion/src/tools.ts
51417
51574
  var SidekickParams = Type11.Object({
51418
51575
  message: Type11.String({ description: "A concrete implementation or verification brief for the sidekick" }),
51419
51576
  block: Type11.Optional(Type11.Boolean({ description: "Wait for completion (default true)" }))
@@ -51426,6 +51583,9 @@ var ReadSubagentParams = Type11.Object({
51426
51583
  function duration(ms) {
51427
51584
  return `${(ms / 1e3).toFixed(1)}s`;
51428
51585
  }
51586
+ function firstLine3(value) {
51587
+ return value.split("\n", 1)[0] ?? "";
51588
+ }
51429
51589
  function progressText(runtime, id) {
51430
51590
  const progress = runtime.progress(id);
51431
51591
  if (!progress) return "No active handoff progress.";
@@ -51438,7 +51598,9 @@ ${progress.recentTools.map((tool) => ` ${tool}`).join("\n")}` : "";
51438
51598
  }
51439
51599
  function progressKey(runtime, id) {
51440
51600
  const progress = runtime.progress(id);
51441
- return progress === void 0 ? "" : `${String(progress.toolCalls)}|${progress.recentTools.join("|")}|${progress.textTail}`;
51601
+ if (!progress) return "";
51602
+ const last = progress.events.at(-1);
51603
+ return `${String(progress.toolCalls)}|${progress.recentTools.join("|")}|${progress.textTail}|${String(progress.events.length)}|${last?.kind === "tool" ? `${String(last.output.length)}|${String(last.done)}` : last?.kind === "text" ? `${String(last.text.length)}|${String(last.open)}` : ""}`;
51442
51604
  }
51443
51605
  function reportText(report) {
51444
51606
  return `${report.text}
@@ -51466,7 +51628,7 @@ async function waitForReport(runtime, id, done, signal, ctx, onUpdate, timeoutMs
51466
51628
  const key = progressKey(runtime, id);
51467
51629
  if (key !== lastProgressKey) {
51468
51630
  lastProgressKey = key;
51469
- onUpdate?.({ content: [{ type: "text", text: progress }] });
51631
+ onUpdate?.({ content: [{ type: "text", text: progress }], details: { progress: runtime.progress(id) } });
51470
51632
  }
51471
51633
  }
51472
51634
  }
@@ -51480,13 +51642,46 @@ ${report.text}
51480
51642
  details: report
51481
51643
  };
51482
51644
  }
51645
+ function transcriptTheme(theme) {
51646
+ return { fg: (color2, text) => theme.fg(color2, text), bold: (text) => theme.bold(text) };
51647
+ }
51648
+ function markdownText(markdown) {
51649
+ return new Markdown2(markdown, 0, 0, getMarkdownTheme2());
51650
+ }
51651
+ function contentText(content) {
51652
+ if (!Array.isArray(content)) return String(content ?? "");
51653
+ return content.map((part) => typeof part === "object" && part !== null && typeof part.text === "string" ? part.text : "").filter(Boolean).join("\n");
51654
+ }
51655
+ function renderToolTranscript(result2, options, theme, label) {
51656
+ const details = result2.details;
51657
+ const progress = details?.progress;
51658
+ const report = progress ? void 0 : details;
51659
+ const events = progress?.events ?? report?.events;
51660
+ const partial = progress !== void 0;
51661
+ const status = partial ? "working" : report?.status === "completed" && !result2.isError ? "completed" : "error";
51662
+ if (!events) return frameSidekick(theme, status, new Text9(contentText(result2.content), 0, 0));
51663
+ const header = partial ? `${theme.fg("accent", theme.bold(`\u25C6 ${label} working`))} ${theme.fg("dim", `\xB7 ${String(progress.toolCalls)} tool calls \xB7 ${duration(Date.now() - progress.startedAt)}`)}` : `${theme.fg(report?.status === "completed" ? "success" : "error", "\u25C6")} ${theme.fg("accent", theme.bold(`${label} ${report?.status ?? "done"}`))} ${theme.fg("dim", report ? `\xB7 ${report.id} \xB7 ${String(report.toolCalls)} tool calls \xB7 ${duration(report.durationMs)} \xB7 in ${String(report.usage.input)} / out ${String(report.usage.output)} tokens` : "")}`;
51664
+ return frameSidekick(theme, status, renderSidekickTranscript(transcriptTheme(theme), {
51665
+ events,
51666
+ droppedEvents: progress?.droppedEvents,
51667
+ header,
51668
+ expanded: options.expanded === true,
51669
+ isPartial: partial,
51670
+ report,
51671
+ renderText: markdownText
51672
+ }));
51673
+ }
51483
51674
  function renderCompletionCard2(theme, report) {
51484
- const tone = report?.status === "completed" ? "toolSuccessBg" : "toolErrorBg";
51485
- const head = report ? `${theme.fg(report.status === "completed" ? "success" : "error", "\u25C6")} ${theme.fg("accent", theme.bold(`sidekick done \xB7 ${report.id}`))} ${theme.fg("dim", `\xB7 ${String(report.toolCalls)} tool calls \xB7 ${duration(report.durationMs)}`)}` : `${theme.fg("accent", "\u25C6")} ${theme.fg("accent", theme.bold("sidekick done"))}`;
51486
- const lines = [head, ...report?.text.split("\n").slice(0, 6).map((line) => theme.fg("dim", line)) ?? []];
51487
- const box = new Box3(1, 0, (text) => theme.bg(tone, text));
51488
- box.addChild(new Text8(lines.join("\n"), 0, 0));
51489
- return box;
51675
+ const status = report?.status === "completed" ? "completed" : "error";
51676
+ if (!report) return frameSidekick(theme, "error", new Text9(`${theme.fg("accent", "\u25C6")} ${theme.fg("accent", theme.bold("sidekick done"))}`, 0, 0));
51677
+ return frameSidekick(theme, status, renderSidekickTranscript(transcriptTheme(theme), {
51678
+ events: report.events ?? [],
51679
+ header: `${theme.fg(report.status === "completed" ? "success" : "error", "\u25C6")} ${theme.fg("accent", theme.bold(`sidekick ${report.status}`))} ${theme.fg("dim", `\xB7 ${report.id} \xB7 ${String(report.toolCalls)} tool calls \xB7 ${duration(report.durationMs)}`)}`,
51680
+ expanded: false,
51681
+ isPartial: false,
51682
+ report,
51683
+ renderText: markdownText
51684
+ }));
51490
51685
  }
51491
51686
  function registerFusionTools(pi, deps) {
51492
51687
  pi.registerMessageRenderer("sidekick-completion", (message, _options, theme) => renderCompletionCard2(theme, message.details));
@@ -51495,10 +51690,15 @@ function registerFusionTools(pi, deps) {
51495
51690
  label: "Sidekick",
51496
51691
  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.",
51497
51692
  parameters: SidekickParams,
51693
+ renderShell: "self",
51694
+ renderCall: (args, theme) => frameSidekick(theme, "working", new Text9(`${theme.fg("toolTitle", theme.bold("\u25C6 sidekick"))} ${theme.fg("dim", firstLine3(String(args.message)).slice(0, 100))}`, 0, 0)),
51695
+ renderResult: (result2, options, theme) => renderToolTranscript(result2, options, theme, "sidekick"),
51498
51696
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
51499
51697
  const runtime = deps.getRuntime(ctx);
51500
51698
  if (!runtime) return result("Fusion is not active \u2014 pick a Fusion pair with /unipi:model.", void 0, true);
51699
+ const wasBusy = runtime.isBusy();
51501
51700
  const handoff = runtime.handoff(params.message);
51701
+ if (!wasBusy) deps.onHandoffStart?.(ctx);
51502
51702
  if (params.block === false) {
51503
51703
  void handoff.done.then((report) => {
51504
51704
  deps.onReport?.(ctx, report);
@@ -51524,6 +51724,9 @@ ${progressText(runtime, handoff.id)}`);
51524
51724
  label: "Read Sidekick",
51525
51725
  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.",
51526
51726
  parameters: ReadSubagentParams,
51727
+ renderShell: "self",
51728
+ renderCall: (args, theme) => frameSidekick(theme, "working", new Text9(`${theme.fg("toolTitle", theme.bold("\u25C6 read_subagent"))} ${theme.fg("dim", args.agent_id ?? "latest")}`, 0, 0)),
51729
+ renderResult: (result2, options, theme) => renderToolTranscript(result2, options, theme, "read_subagent"),
51527
51730
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
51528
51731
  const runtime = deps.getRuntime(ctx);
51529
51732
  if (!runtime) return result("Fusion is not active \u2014 pick a Fusion pair with /unipi:model.", void 0, true);
@@ -51610,7 +51813,10 @@ function fusionExtension(pi) {
51610
51813
  if (process.env.UNIPI_FUSION_CHILD === "1") return;
51611
51814
  let active;
51612
51815
  let runtime;
51613
- let nudged = false;
51816
+ let lastCtx;
51817
+ let leadToolCalls = 0;
51818
+ let editNudgedThisTurn = false;
51819
+ let bashStreak = 0;
51614
51820
  function identity(ctx) {
51615
51821
  const reg = registryOf(ctx);
51616
51822
  const names = (k) => findModel(reg, k)?.name || splitModelKey(k)?.id || k;
@@ -51630,6 +51836,7 @@ function fusionExtension(pi) {
51630
51836
  return estimateSavings(runtime.usage, costOf(lead, preset2.prices[active.lead]), costOf(side, preset2.prices[active.sidekick])).savedUsd;
51631
51837
  }
51632
51838
  function publishStatus(ctx) {
51839
+ lastCtx = ctx;
51633
51840
  const reg = registryOf(ctx);
51634
51841
  const names = (k) => findModel(reg, k)?.name || splitModelKey(k)?.id || k;
51635
51842
  if (active?.kind === "fusion") {
@@ -51638,12 +51845,18 @@ function fusionExtension(pi) {
51638
51845
  leadEffort: active.leadEffort ?? "",
51639
51846
  sidekickName: names(active.sidekick),
51640
51847
  sidekickEffort: active.sidekickEffort ?? "",
51641
- savedUsd: statusSavings(ctx)
51848
+ savedUsd: statusSavings(ctx),
51849
+ busy: runtime?.isBusy() ?? false,
51850
+ leadToolCalls,
51851
+ sidekickToolCalls: runtime?.totalToolCalls() ?? 0
51642
51852
  });
51643
51853
  } else {
51644
51854
  setSharedFusionStatus(void 0);
51645
51855
  }
51646
51856
  }
51857
+ function publishStatusLater() {
51858
+ if (lastCtx) publishStatus(lastCtx);
51859
+ }
51647
51860
  function leadSessionId(ctx) {
51648
51861
  const manager2 = ctx.sessionManager;
51649
51862
  return manager2?.getSessionId?.() ?? "default";
@@ -51656,7 +51869,8 @@ function fusionExtension(pi) {
51656
51869
  model: active.sidekick,
51657
51870
  thinking: active.sidekickEffort ?? "medium",
51658
51871
  sessionFile: sidekickSessionPath(leadSessionId(ctx)),
51659
- systemPrompt: sidekickSystemPrompt(identity(ctx))
51872
+ systemPrompt: sidekickSystemPrompt(identity(ctx)),
51873
+ onProgress: () => publishStatusLater()
51660
51874
  });
51661
51875
  }
51662
51876
  return runtime;
@@ -51664,6 +51878,7 @@ function fusionExtension(pi) {
51664
51878
  function stopRuntime() {
51665
51879
  runtime?.kill();
51666
51880
  runtime = void 0;
51881
+ leadToolCalls = 0;
51667
51882
  }
51668
51883
  function savingsStats(ctx) {
51669
51884
  if (active?.kind !== "fusion" || runtime === void 0) return "Fusion is not active \u2014 pick a Fusion pair with /unipi:model.";
@@ -51679,7 +51894,8 @@ Handoffs: ${String(runtime.reports.size)} \xB7 runtime alive: ${String(runtime.i
51679
51894
  }
51680
51895
  registerFusionTools(pi, {
51681
51896
  getRuntime,
51682
- onReport: (ctx) => publishStatus(ctx)
51897
+ onReport: (ctx) => publishStatus(ctx),
51898
+ onHandoffStart: (ctx) => publishStatus(ctx)
51683
51899
  });
51684
51900
  pi.registerCommand("unipi:fusion-stats", {
51685
51901
  description: "Estimated Fusion savings (sidekick tokens priced at lead rates)",
@@ -51688,10 +51904,31 @@ Handoffs: ${String(runtime.reports.size)} \xB7 runtime alive: ${String(runtime.i
51688
51904
  pi.on("before_agent_start", (event, ctx) => active?.kind === "fusion" ? { systemPrompt: `${event.systemPrompt}
51689
51905
 
51690
51906
  ${leadPolicy(identity(ctx))}` } : void 0);
51907
+ pi.on("turn_start", () => {
51908
+ editNudgedThisTurn = false;
51909
+ });
51691
51910
  pi.on("tool_result", (event) => {
51692
- if (active?.kind !== "fusion" || nudged || event.toolName !== "edit" && event.toolName !== "write") return;
51693
- nudged = true;
51694
- return { content: [...event.content, { type: "text", text: FIRST_EDIT_NUDGE }] };
51911
+ if (active?.kind !== "fusion") return;
51912
+ const toolName = event.toolName;
51913
+ if (toolName === "sidekick" || toolName === "read_subagent") {
51914
+ bashStreak = 0;
51915
+ return;
51916
+ }
51917
+ leadToolCalls += 1;
51918
+ publishStatusLater();
51919
+ if (toolName === "edit" || toolName === "write") {
51920
+ if (editNudgedThisTurn) return;
51921
+ editNudgedThisTurn = true;
51922
+ return { content: [...event.content, { type: "text", text: EDIT_NUDGE }] };
51923
+ }
51924
+ if (toolName !== "bash") return;
51925
+ const command = typeof event.input.command === "string" ? event.input.command : "";
51926
+ if (isTrivialShell(command)) return;
51927
+ bashStreak += 1;
51928
+ if (bashStreak < BASH_NUDGE_EVERY) return;
51929
+ const content = [...event.content, { type: "text", text: bashNudge(bashStreak) }];
51930
+ bashStreak = 0;
51931
+ return { content };
51695
51932
  });
51696
51933
  async function applyResult(ctx, result2, preset2, loaded) {
51697
51934
  if (result2.type === "cancelled") return;
@@ -51824,7 +52061,8 @@ ${String(result2.curation.lead.length)} lead \xB7 ${String(result2.curation.side
51824
52061
  });
51825
52062
  pi.on("session_start", async (_e, ctx) => {
51826
52063
  stopRuntime();
51827
- nudged = false;
52064
+ editNudgedThisTurn = false;
52065
+ bashStreak = 0;
51828
52066
  modelBykey.clear();
51829
52067
  active = loadPreset(ctx.cwd ?? process.cwd()).preset.active;
51830
52068
  if (active?.kind === "fusion" && (!ctx.model || modelKey(ctx.model) !== active.lead)) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/updater",
3
- "version": "2.17.2",
3
+ "version": "2.18.0",
4
4
  "description": "Auto-updater, changelog browser, and readme browser for Unipi — checks npm registry, renders CHANGELOG.md and README.md files in TUI overlays",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pi-unipi/core": "2.17.0"
37
+ "@pi-unipi/core": "2.18.0"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/utility",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "Utility commands and tools for Pi coding agent — lifecycle, diagnostics, cache, analytics, display, batch execution",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -36,7 +36,7 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
- "@pi-unipi/core": "2.17.0"
39
+ "@pi-unipi/core": "2.18.0"
40
40
  },
41
41
  "devDependencies": {},
42
42
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/web-api",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "Web search, read, and summarize tools with provider-based backend selection for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -31,7 +31,7 @@
31
31
  "README.md"
32
32
  ],
33
33
  "dependencies": {
34
- "@pi-unipi/core": "2.17.0",
34
+ "@pi-unipi/core": "2.18.0",
35
35
  "defuddle": "^0.18.1",
36
36
  "linkedom": "^0.18.12",
37
37
  "wreq-js": "^2.3.0"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/workflow",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "Structured development workflow commands for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -30,7 +30,7 @@
30
30
  "access": "public"
31
31
  },
32
32
  "dependencies": {
33
- "@pi-unipi/core": "2.17.0"
33
+ "@pi-unipi/core": "2.18.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@earendil-works/pi-ai": "^0.84.0",