@pi-unipi/unipi 2.17.2 → 2.18.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +20 -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/README.md +3 -3
  7. package/packages/btw/extensions/btw.ts +3 -2
  8. package/packages/btw/package.json +2 -2
  9. package/packages/btw/skills/btw/SKILL.md +2 -0
  10. package/packages/compactor/package.json +3 -3
  11. package/packages/core/fusion-status.ts +6 -0
  12. package/packages/core/package.json +1 -1
  13. package/packages/footer/package.json +3 -3
  14. package/packages/footer/src/glance-editor.ts +22 -8
  15. package/packages/fusion/package.json +3 -3
  16. package/packages/fusion/src/index.ts +43 -6
  17. package/packages/fusion/src/nudge.ts +7 -0
  18. package/packages/fusion/src/picker.ts +61 -30
  19. package/packages/fusion/src/prompts.ts +19 -3
  20. package/packages/fusion/src/sidekick-runtime.ts +83 -7
  21. package/packages/fusion/src/tools.ts +67 -14
  22. package/packages/fusion/src/transcript.ts +108 -0
  23. package/packages/image/package.json +2 -2
  24. package/packages/info-screen/package.json +2 -2
  25. package/packages/input-shortcuts/package.json +2 -2
  26. package/packages/kanboard/package.json +2 -2
  27. package/packages/mcp/package.json +2 -2
  28. package/packages/memory/package.json +3 -3
  29. package/packages/milestone/package.json +2 -2
  30. package/packages/notify/package.json +2 -2
  31. package/packages/ralph/package.json +3 -3
  32. package/packages/subagents/agents/reviewer.md +1 -0
  33. package/packages/subagents/package.json +2 -2
  34. package/packages/subagents/src/index.ts +2 -0
  35. package/packages/subagents/src/pi-args.ts +6 -0
  36. package/packages/trajectory/package.json +1 -1
  37. package/packages/unipi/bundled.js +704 -339
  38. package/packages/updater/package.json +2 -2
  39. package/packages/updater/src/tui/changelog-overlay.ts +2 -2
  40. package/packages/updater/src/tui/list-detail-overlay.ts +12 -8
  41. package/packages/updater/src/tui/readme-overlay.ts +2 -2
  42. package/packages/updater/src/tui/update-overlay.ts +30 -25
  43. package/packages/utility/package.json +2 -2
  44. package/packages/web-api/package.json +2 -2
  45. package/packages/workflow/package.json +2 -2
@@ -68,9 +68,25 @@ You have a \`sidekick\` tool: a persistent subagent that works alongside you on
68
68
  - **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.`;
69
69
  }
70
70
 
71
- /** One-time reminder appended to the lead's first direct edit/write while Fusion is active. */
72
- export const FIRST_EDIT_NUDGE =
73
- "<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 — multiple files, anything that needs a test run, anything you would want to look over again — write a brief and hand it to `sidekick` instead: you design and review, it implements and verifies. This reminder is shown once.</system_guidance>";
71
+ /**
72
+ * Recurring reminder appended to a direct edit/write by the lead while Fusion
73
+ * is active (at most once per agent turn). Mirrors Devin's harness, which
74
+ * re-issues this guidance on every direct implementation action rather than once.
75
+ */
76
+ export const EDIT_NUDGE =
77
+ "<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 — 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>";
78
+
79
+ /** Kept for compatibility with earlier imports; the nudge is no longer one-time. */
80
+ export const FIRST_EDIT_NUDGE = EDIT_NUDGE;
81
+
82
+ /**
83
+ * Appended after the lead has run several consecutive non-trivial shell
84
+ * commands itself without a handoff. Builds, tests, installs, environment
85
+ * repair and multi-step shell work are the sidekick's job by default.
86
+ */
87
+ export function bashNudge(count: number): string {
88
+ 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>`;
89
+ }
74
90
 
75
91
  export function sidekickSystemPrompt(id: FusionIdentity): string {
76
92
  return `## Role: Fusion sidekick
@@ -14,6 +14,7 @@ export interface SidekickSpawnConfig {
14
14
  systemPrompt: string;
15
15
  spawn?: typeof defaultSpawn;
16
16
  command?: { command: string; args: string[] };
17
+ onProgress?: () => void;
17
18
  }
18
19
 
19
20
  export interface SidekickUsage {
@@ -24,11 +25,20 @@ export interface SidekickUsage {
24
25
  cost: number;
25
26
  }
26
27
 
28
+ export type SidekickEvent =
29
+ | { kind: "text"; text: string; open: boolean }
30
+ | { kind: "tool"; toolCallId: string; name: string; args: Record<string, unknown> | undefined; output: string; isError: boolean; done: boolean; startedAt: number; endedAt?: number };
31
+
32
+ export const MAX_EVENTS = 300;
33
+ export const MAX_TOOL_OUTPUT = 4000;
34
+
27
35
  export interface HandoffProgress {
28
36
  toolCalls: number;
29
37
  recentTools: string[];
30
38
  textTail: string;
31
39
  startedAt: number;
40
+ events: SidekickEvent[];
41
+ droppedEvents: number;
32
42
  }
33
43
 
34
44
  export interface HandoffReport {
@@ -38,6 +48,7 @@ export interface HandoffReport {
38
48
  usage: SidekickUsage;
39
49
  toolCalls: number;
40
50
  durationMs: number;
51
+ events: SidekickEvent[];
41
52
  error?: string;
42
53
  }
43
54
 
@@ -79,6 +90,47 @@ export class SidekickRuntime {
79
90
  return this.pending !== undefined;
80
91
  }
81
92
 
93
+ totalToolCalls(): number {
94
+ let total = this.pending?.progress.toolCalls ?? 0;
95
+ for (const report of this.reports.values()) total += report.toolCalls;
96
+ return total;
97
+ }
98
+
99
+ private notifyProgress(): void {
100
+ try {
101
+ this.cfg.onProgress?.();
102
+ } catch {
103
+ // Progress updates must not affect the handoff.
104
+ }
105
+ }
106
+
107
+ private appendEvent(event: SidekickEvent): void {
108
+ const progress = this.pending?.progress;
109
+ if (!progress) return;
110
+ progress.events.push(event);
111
+ if (progress.events.length > MAX_EVENTS) {
112
+ progress.events.shift();
113
+ progress.droppedEvents += 1;
114
+ }
115
+ }
116
+
117
+ private closeOpenText(): void {
118
+ const events = this.pending?.progress.events;
119
+ const last = events?.at(-1);
120
+ if (last?.kind === "text" && last.open) last.open = false;
121
+ }
122
+
123
+ private toolOutput(result: unknown): string {
124
+ if (typeof result === "object" && result !== null && Array.isArray((result as { content?: unknown }).content)) {
125
+ return ((result as { content: unknown[] }).content)
126
+ .map((part) => typeof part === "object" && part !== null && typeof (part as { text?: unknown }).text === "string" ? (part as { text: string }).text : typeof part === "string" ? part : "")
127
+ .filter(Boolean)
128
+ .join("\n")
129
+ .slice(-MAX_TOOL_OUTPUT);
130
+ }
131
+ return String(result ?? "").slice(-MAX_TOOL_OUTPUT);
132
+ }
133
+
82
134
  private send(value: Record<string, unknown>): void {
83
135
  if (!this.child?.stdin?.writable) throw new Error("Sidekick process is not writable");
84
136
  this.child.stdin.write(`${JSON.stringify(value)}\n`);
@@ -169,19 +221,39 @@ export class SidekickRuntime {
169
221
  }
170
222
  if (this.pending === undefined) return;
171
223
  if (message.type === "tool_execution_start") {
224
+ this.closeOpenText();
172
225
  this.pending.progress.toolCalls += 1;
173
- const args = message.args === undefined ? "" : JSON.stringify(message.args).replace(/\s+/gu, " ");
174
- const summary = `${String(message.toolName ?? "tool")}(${args})`.slice(0, 40);
226
+ const args = message.args !== undefined && typeof message.args === "object" && message.args !== null ? message.args as Record<string, unknown> : undefined;
227
+ const argsText = args === undefined ? "" : JSON.stringify(args).replace(/\s+/gu, " ");
228
+ const summary = `${String(message.toolName ?? "tool")}(${argsText})`.slice(0, 40);
175
229
  this.pending.progress.recentTools = [...this.pending.progress.recentTools, summary].slice(-6);
230
+ this.appendEvent({ kind: "tool", toolCallId: String(message.toolCallId ?? ""), name: String(message.toolName ?? "tool"), args, output: "", isError: false, done: false, startedAt: Date.now() });
231
+ this.notifyProgress();
232
+ } else if (message.type === "tool_execution_end") {
233
+ const toolCallId = String(message.toolCallId ?? "");
234
+ const event = [...this.pending.progress.events].reverse().find((entry): entry is Extract<SidekickEvent, { kind: "tool" }> => entry.kind === "tool" && entry.toolCallId === toolCallId);
235
+ if (event) {
236
+ event.done = true;
237
+ event.endedAt = Date.now();
238
+ event.isError = message.isError === true;
239
+ event.output = this.toolOutput(message.result);
240
+ }
241
+ this.notifyProgress();
176
242
  } else if (message.type === "message_update") {
177
- const event = (message.assistantMessageEvent ?? message) as Record<string, unknown>;
178
- if (event.type === "text_delta") {
179
- const delta = typeof event.delta === "string" ? event.delta : typeof event.text === "string" ? event.text : "";
243
+ const streamEvent = (message.assistantMessageEvent ?? message) as Record<string, unknown>;
244
+ if (streamEvent.type === "text_delta") {
245
+ const delta = typeof streamEvent.delta === "string" ? streamEvent.delta : typeof streamEvent.text === "string" ? streamEvent.text : "";
180
246
  this.pending.progress.textTail = `${this.pending.progress.textTail}${delta}`.slice(-400);
247
+ const last = this.pending.progress.events.at(-1);
248
+ if (last?.kind === "text" && last.open) last.text += delta;
249
+ else this.appendEvent({ kind: "text", text: delta, open: true });
250
+ this.notifyProgress();
181
251
  }
182
252
  } else if (message.type === "message_end") {
183
253
  const msg = message.message as Record<string, unknown> | undefined;
184
254
  if (msg?.role === "assistant") {
255
+ this.closeOpenText();
256
+ this.notifyProgress();
185
257
  if (msg.stopReason === "error" && typeof msg.errorMessage === "string") this.pendingError = msg.errorMessage;
186
258
  const usage = msg.usage as Record<string, unknown> | undefined;
187
259
  if (usage) {
@@ -231,11 +303,13 @@ export class SidekickRuntime {
231
303
  usage: { ...current.usage },
232
304
  toolCalls: current.progress.toolCalls,
233
305
  durationMs: Date.now() - current.startedAt,
306
+ events: current.progress.events.map((event) => ({ ...event })),
234
307
  ...(error === undefined ? {} : { error }),
235
308
  };
236
309
  this.reports.set(report.id, report);
237
310
  if (this.latestHandoff?.id === report.id) this.latestHandoff.report = report;
238
311
  current.resolve(report);
312
+ this.notifyProgress();
239
313
  this.abortRequested = false;
240
314
  this.pendingError = undefined;
241
315
  }
@@ -259,7 +333,7 @@ export class SidekickRuntime {
259
333
  id,
260
334
  startedAt,
261
335
  usage: emptyUsage(),
262
- progress: { toolCalls: 0, recentTools: [], textTail: "", startedAt },
336
+ progress: { toolCalls: 0, recentTools: [], textTail: "", startedAt, events: [], droppedEvents: 0 },
263
337
  resolve,
264
338
  reject,
265
339
  };
@@ -273,7 +347,9 @@ export class SidekickRuntime {
273
347
  }
274
348
 
275
349
  progress(id?: string): HandoffProgress | undefined {
276
- if (this.pending !== undefined && (id === undefined || id === this.pending.id)) return { ...this.pending.progress, recentTools: [...this.pending.progress.recentTools] };
350
+ if (this.pending !== undefined && (id === undefined || id === this.pending.id)) {
351
+ return { ...this.pending.progress, recentTools: [...this.pending.progress.recentTools], events: this.pending.progress.events.map((event) => ({ ...event })) };
352
+ }
277
353
  return undefined;
278
354
  }
279
355
 
@@ -1,7 +1,8 @@
1
- import { Box, Text } from "@earendil-works/pi-tui";
2
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { Markdown, Text, type Component } from "@earendil-works/pi-tui";
2
+ import { getMarkdownTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
- import type { SidekickRuntime, HandoffReport } from "./sidekick-runtime.js";
4
+ import type { SidekickRuntime, HandoffProgress, HandoffReport } from "./sidekick-runtime.js";
5
+ import { frameSidekick, renderSidekickTranscript, type ThemeLike as TranscriptTheme } from "./transcript.js";
5
6
 
6
7
  const SidekickParams = Type.Object({
7
8
  message: Type.String({ description: "A concrete implementation or verification brief for the sidekick" }),
@@ -16,12 +17,17 @@ const ReadSubagentParams = Type.Object({
16
17
  export interface FusionToolDeps {
17
18
  getRuntime: (ctx: ExtensionContext) => SidekickRuntime | undefined;
18
19
  onReport?: (ctx: ExtensionContext, report: HandoffReport) => void;
20
+ onHandoffStart?: (ctx: ExtensionContext) => void;
19
21
  }
20
22
 
21
23
  function duration(ms: number): string {
22
24
  return `${(ms / 1000).toFixed(1)}s`;
23
25
  }
24
26
 
27
+ function firstLine(value: string): string {
28
+ return value.split("\n", 1)[0] ?? "";
29
+ }
30
+
25
31
  function progressText(runtime: SidekickRuntime, id: string): string {
26
32
  const progress = runtime.progress(id);
27
33
  if (!progress) return "No active handoff progress.";
@@ -33,7 +39,9 @@ function progressText(runtime: SidekickRuntime, id: string): string {
33
39
 
34
40
  function progressKey(runtime: SidekickRuntime, id: string): string {
35
41
  const progress = runtime.progress(id);
36
- return progress === undefined ? "" : `${String(progress.toolCalls)}|${progress.recentTools.join("|")}|${progress.textTail}`;
42
+ if (!progress) return "";
43
+ const last = progress.events.at(-1);
44
+ 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)}` : ""}`;
37
45
  }
38
46
 
39
47
  function reportText(report: HandoffReport): string {
@@ -70,7 +78,7 @@ async function waitForReport(
70
78
  const key = progressKey(runtime, id);
71
79
  if (key !== lastProgressKey) {
72
80
  lastProgressKey = key;
73
- onUpdate?.({ content: [{ type: "text", text: progress }] });
81
+ onUpdate?.({ content: [{ type: "text", text: progress }], details: { progress: runtime.progress(id) } });
74
82
  }
75
83
  }
76
84
  }
@@ -90,15 +98,52 @@ type ThemeLike = {
90
98
  bold: (text: string) => string;
91
99
  };
92
100
 
93
- function renderCompletionCard(theme: ThemeLike, report: HandoffReport | undefined): Box {
94
- const tone = report?.status === "completed" ? "toolSuccessBg" : "toolErrorBg";
95
- const head = report
96
- ? `${theme.fg(report.status === "completed" ? "success" : "error", "◆")} ${theme.fg("accent", theme.bold(`sidekick done · ${report.id}`))} ${theme.fg("dim", `· ${String(report.toolCalls)} tool calls · ${duration(report.durationMs)}`)}`
97
- : `${theme.fg("accent", "◆")} ${theme.fg("accent", theme.bold("sidekick done"))}`;
98
- const lines = [head, ...(report?.text.split("\n").slice(0, 6).map((line) => theme.fg("dim", line)) ?? [])];
99
- const box = new Box(1, 0, (text) => theme.bg(tone, text));
100
- box.addChild(new Text(lines.join("\n"), 0, 0));
101
- return box;
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());
107
+ }
108
+
109
+ function contentText(content: unknown): string {
110
+ if (!Array.isArray(content)) return String(content ?? "");
111
+ 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
+ }
113
+
114
+ 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;
116
+ const progress = details?.progress;
117
+ const report = progress ? undefined : details as HandoffReport | undefined;
118
+ const events = progress?.events ?? report?.events;
119
+ 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), {
126
+ events,
127
+ droppedEvents: progress?.droppedEvents,
128
+ header,
129
+ expanded: options.expanded === true,
130
+ isPartial: partial,
131
+ report,
132
+ renderText: markdownText,
133
+ }));
134
+ }
135
+
136
+ 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), {
140
+ 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)}`)}`,
142
+ expanded: false,
143
+ isPartial: false,
144
+ report,
145
+ renderText: markdownText,
146
+ }));
102
147
  }
103
148
 
104
149
  export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): void {
@@ -109,10 +154,15 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
109
154
  label: "Sidekick",
110
155
  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.",
111
156
  parameters: SidekickParams,
157
+ 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)),
159
+ renderResult: (result, options, theme) => renderToolTranscript(result, options, theme as unknown as ThemeLike, "sidekick"),
112
160
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
113
161
  const runtime = deps.getRuntime(ctx);
114
162
  if (!runtime) return result("Fusion is not active — pick a Fusion pair with /unipi:model.", undefined, true);
163
+ const wasBusy = runtime.isBusy();
115
164
  const handoff = runtime.handoff(params.message);
165
+ if (!wasBusy) deps.onHandoffStart?.(ctx);
116
166
  if (params.block === false) {
117
167
  void handoff.done.then((report) => {
118
168
  deps.onReport?.(ctx, report);
@@ -136,6 +186,9 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
136
186
  label: "Read Sidekick",
137
187
  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.",
138
188
  parameters: ReadSubagentParams,
189
+ 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)),
191
+ renderResult: (result, options, theme) => renderToolTranscript(result, options, theme as unknown as ThemeLike, "read_subagent"),
139
192
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
140
193
  const runtime = deps.getRuntime(ctx);
141
194
  if (!runtime) return result("Fusion is not active — pick a Fusion pair with /unipi:model.", undefined, true);
@@ -0,0 +1,108 @@
1
+ import { Box, Container, Text, type Component } from "@earendil-works/pi-tui";
2
+ import type { SidekickEvent } from "./sidekick-runtime.js";
3
+
4
+ export interface ThemeLike {
5
+ fg: (color: string, text: string) => string;
6
+ bold: (text: string) => string;
7
+ }
8
+
9
+ export class RailComponent implements Component {
10
+ constructor(private readonly inner: Component, private readonly rail: string) {}
11
+
12
+ render(width: number): string[] {
13
+ return this.inner.render(Math.max(1, width - 2)).map((line) => `${this.rail} ${line}`);
14
+ }
15
+
16
+ invalidate(): void {
17
+ this.inner.invalidate?.();
18
+ }
19
+
20
+ handleInput(data: string): void {
21
+ this.inner.handleInput?.(data);
22
+ }
23
+ }
24
+
25
+ export function frameSidekick(theme: ThemeLike & { bg: (color: string, text: string) => string }, status: "working" | "completed" | "error", content: Component): Component {
26
+ const railColor = status === "working" ? "accent" : status === "completed" ? "success" : "error";
27
+ const rail = theme.fg(railColor, "▍");
28
+ const boxed = new Box(1, 0, (text) => theme.bg("customMessageBg", text));
29
+ boxed.addChild(new RailComponent(content, rail));
30
+ return boxed;
31
+ }
32
+
33
+ export interface TranscriptOptions {
34
+ events: readonly SidekickEvent[];
35
+ droppedEvents?: number;
36
+ header: string;
37
+ expanded: boolean;
38
+ isPartial: boolean;
39
+ report?: { text: string };
40
+ renderText: (markdown: string) => Component;
41
+ }
42
+
43
+ function firstLine(value: string): string {
44
+ return value.split("\n", 1)[0] ?? "";
45
+ }
46
+
47
+ function truncate(value: string, max: number): string {
48
+ return value.length > max ? `${value.slice(0, Math.max(0, max - 1))}…` : value;
49
+ }
50
+
51
+ export function primaryArg(name: string, args: Record<string, unknown> | undefined): string {
52
+ if (!args) return "";
53
+ const value = name === "bash"
54
+ ? args.command
55
+ : name === "read" || name === "edit" || name === "write"
56
+ ? args.path ?? args.file_path ?? args.filePath
57
+ : name === "sidekick"
58
+ ? args.message
59
+ : Object.values(args).find((entry) => typeof entry === "string");
60
+ return typeof value === "string" ? truncate(firstLine(value), 100) : "";
61
+ }
62
+
63
+ function toolComponent(theme: ThemeLike, event: Extract<SidekickEvent, { kind: "tool" }>, expanded: boolean): Text {
64
+ const title = `${event.isError ? theme.fg("error", "✗ ") : ""}${theme.fg("toolTitle", theme.bold(event.name))}`;
65
+ const argument = primaryArg(event.name, event.args);
66
+ const lines = [`${title}${argument.length > 0 ? ` ${theme.fg("accent", argument)}` : ""}`];
67
+ if (!event.done) {
68
+ lines[0] += theme.fg("warning", " ⋯ running");
69
+ } else if (event.output.length > 0) {
70
+ const output = event.output.split("\n");
71
+ const visible = expanded ? output.slice(-40) : output.slice(-3);
72
+ lines.push(...visible.map((line) => theme.fg("toolOutput", truncate(line, 160))));
73
+ }
74
+ return new Text(lines.join("\n"), 0, 0);
75
+ }
76
+
77
+ export function renderSidekickTranscript(theme: ThemeLike, opts: TranscriptOptions): Component {
78
+ const box = new Container();
79
+ box.addChild(new Text(opts.header, 0, 0));
80
+
81
+ if (!opts.isPartial && !opts.expanded) {
82
+ if (opts.report?.text) box.addChild(opts.renderText(opts.report.text));
83
+ box.addChild(new Text(theme.fg("dim", `${String(opts.events.length)} steps · expand to see the transcript`), 0, 0));
84
+ return box;
85
+ }
86
+
87
+ const dropped = opts.droppedEvents ?? 0;
88
+ const start = opts.expanded ? 0 : Math.max(0, opts.events.length - 8);
89
+ if (opts.expanded && dropped > 0) {
90
+ box.addChild(new Text(theme.fg("dim", `… ${String(dropped)} earliest steps dropped`), 0, 0));
91
+ } else if (start > 0) {
92
+ box.addChild(new Text(theme.fg("dim", `… ${String(start + dropped)} earlier steps`), 0, 0));
93
+ }
94
+
95
+ for (const event of opts.events.slice(start)) {
96
+ if (event.kind === "tool") box.addChild(toolComponent(theme, event, opts.expanded));
97
+ else if (event.text.trim()) box.addChild(opts.renderText(event.text.trim()));
98
+ }
99
+
100
+ if (!opts.isPartial && opts.expanded && opts.report?.text) {
101
+ // The final assistant message usually IS the report; don't print it twice.
102
+ const last = opts.events.at(-1);
103
+ if (last?.kind === "text" && last.text.trim() === opts.report.text.trim()) return box;
104
+ box.addChild(new Text(theme.fg("dim", "── report ──"), 0, 0));
105
+ box.addChild(opts.renderText(opts.report.text));
106
+ }
107
+ return box;
108
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/image",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "Image generation and image recognition tools for the Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "src/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-ai": "^0.84.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/info-screen",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "Dashboard and module registry for Unipi — configurable info overlay with tabbed groups",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "2.17.0"
36
+ "@pi-unipi/core": "2.18.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/input-shortcuts",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "Keyboard shortcuts for stash/restore, undo/redo, clipboard, and thinking toggle — chord-based overlay system",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "2.17.0"
36
+ "@pi-unipi/core": "2.18.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/kanboard",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "Visualization layer for unipi workflow — HTTP server with htmx/Alpine.js UI, modular parsers, TUI overlay, and kanban board",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -39,7 +39,7 @@
39
39
  "access": "public"
40
40
  },
41
41
  "dependencies": {
42
- "@pi-unipi/core": "2.17.0"
42
+ "@pi-unipi/core": "2.18.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/mcp",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "MCP server management extension for Pi coding agent — browse, add, configure, and use MCP servers",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -30,7 +30,7 @@
30
30
  "test": "npx tsx --test tests/**/*.test.ts"
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-coding-agent": "^0.84.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/memory",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "Persistent cross-session memory with MemPalace backend (auto-installed) and SQLite fallback for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -39,8 +39,8 @@
39
39
  "README.md"
40
40
  ],
41
41
  "dependencies": {
42
- "@pi-unipi/core": "2.17.0",
43
- "@pi-unipi/info-screen": "2.17.0",
42
+ "@pi-unipi/core": "2.18.0",
43
+ "@pi-unipi/info-screen": "2.18.0",
44
44
  "js-yaml": "^4.1.0"
45
45
  },
46
46
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/milestone",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "Lifecycle layer for project-level goals — MILESTONES.md tracking, session hooks, auto-sync",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -32,7 +32,7 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "2.17.0"
35
+ "@pi-unipi/core": "2.18.0"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/notify",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "Cross-platform notification extension for Pi — native OS, Gotify, and Telegram notifications for agent lifecycle events",
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
  "node-notifier": "^10.0.1"
39
39
  },
40
40
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ralph",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "Long-running iterative development loops for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -31,8 +31,8 @@
31
31
  "test": "npx tsx --test reminder.test.ts"
32
32
  },
33
33
  "dependencies": {
34
- "@pi-unipi/core": "2.17.0",
35
- "@pi-unipi/info-screen": "2.17.0"
34
+ "@pi-unipi/core": "2.18.0",
35
+ "@pi-unipi/info-screen": "2.18.0"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@earendil-works/pi-ai": "^0.84.0",
@@ -2,6 +2,7 @@
2
2
  name: reviewer
3
3
  description: Versatile review specialist for code diffs, plans, proposed solutions, codebase health, and PR/issue validation
4
4
  tools: read, grep, find, ls
5
+ extensions: false
5
6
  thinking: high
6
7
  systemPromptMode: replace
7
8
  inheritProjectContext: true
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/subagents",
3
- "version": "2.17.0",
3
+ "version": "2.18.1",
4
4
  "description": "Subagents for UniPi — parallel execution, file locking, workflow integration",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -9,7 +9,7 @@
9
9
  "test": "npx tsx --test src/__tests__/*.test.ts"
10
10
  },
11
11
  "dependencies": {
12
- "@pi-unipi/core": "2.17.0",
12
+ "@pi-unipi/core": "2.18.0",
13
13
  "@earendil-works/pi-agent-core": "^0.84.0",
14
14
  "acorn": "8.18.0"
15
15
  },
@@ -37,6 +37,7 @@ import {
37
37
  matchesDetachInput,
38
38
  } from "./foreground-detach.js";
39
39
  import { coerceThinkingLevel } from "./agent-runner.js";
40
+ import { childExtensionsArg } from "./pi-args.js";
40
41
 
41
42
  /** Get info registry from global */
42
43
  function getInfoRegistry() {
@@ -300,6 +301,7 @@ export default function (pi: ExtensionAPI) {
300
301
  model: launch.model,
301
302
  thinking: launch.thinking,
302
303
  tools: agent.builtinToolNames,
304
+ extensions: childExtensionsArg(agent.extensions),
303
305
  timeoutMs: launch.timeoutMs,
304
306
  parentSessionId: asyncSessionId,
305
307
  config,