@pi-unipi/unipi 2.19.3 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import { Text, type Component } from "@earendil-works/pi-tui";
2
2
  import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
4
  import type { SidekickRuntime, HandoffProgress, HandoffReport } from "./sidekick-runtime.js";
5
- import { duration, markdownText, renderSidekickTranscript, sidekickWorkingHeader } from "./transcript.js";
5
+ import { duration, frameSidekick, markdownText, renderSidekickTranscript, sidekickWorkingHeader } from "./transcript.js";
6
6
 
7
7
  const SidekickParams = Type.Object({
8
8
  message: Type.String({ description: "A concrete implementation or verification brief for the sidekick" }),
@@ -22,6 +22,46 @@ export interface FusionToolDeps {
22
22
  onDetach?: (ctx: ExtensionContext) => void;
23
23
  }
24
24
 
25
+ /**
26
+ * Exactly-once completion delivery for handoffs nobody is waiting on.
27
+ *
28
+ * `attach`/`detach` bracket every waiting period. The completion is sent only
29
+ * if the report lands (or has already landed) while no waiter is attached, and
30
+ * only once per handoff id.
31
+ */
32
+ export function createCompletionDelivery(send: (report: HandoffReport) => void): {
33
+ attach(id: string): void;
34
+ detach(id: string, done: Promise<HandoffReport>): void;
35
+ consume(id: string): void;
36
+ } {
37
+ const waiting = new Set<string>();
38
+ const armed = new Set<string>();
39
+ const delivered = new Set<string>();
40
+
41
+ return {
42
+ attach(id) {
43
+ waiting.add(id);
44
+ },
45
+ detach(id, done) {
46
+ waiting.delete(id);
47
+ // One continuation per handoff, however many times a waiter gives up.
48
+ if (armed.has(id)) return;
49
+ armed.add(id);
50
+ void done
51
+ .then((report) => {
52
+ if (waiting.has(id) || delivered.has(id)) return;
53
+ delivered.add(id);
54
+ send(report);
55
+ })
56
+ .catch(() => undefined);
57
+ },
58
+ consume(id) {
59
+ waiting.delete(id);
60
+ delivered.add(id);
61
+ },
62
+ };
63
+ }
64
+
25
65
  function firstLine(value: string): string {
26
66
  return value.split("\n", 1)[0] ?? "";
27
67
  }
@@ -101,6 +141,21 @@ type ThemeLike = {
101
141
  bold: (text: string) => string;
102
142
  };
103
143
 
144
+ type FramedTheme = ThemeLike & { bg: (color: string, text: string) => string };
145
+
146
+ function transcriptStatus(partial: boolean, report: HandoffReport | undefined): "working" | "completed" | "error" {
147
+ if (partial) return "working";
148
+ return report?.status === "completed" ? "completed" : "error";
149
+ }
150
+
151
+ /**
152
+ * Sidekick output on the main surface: the same markdown/tool format the lead
153
+ * uses, marked as sidekick-origin by the existing `▍` rail.
154
+ */
155
+ function frameTranscript(theme: ThemeLike, status: "working" | "completed" | "error", content: Component): Component {
156
+ return frameSidekick(theme as FramedTheme, status, content);
157
+ }
158
+
104
159
  // Model-facing result text carries handoff ids and protocol instructions the
105
160
  // lead needs; none of it may reach the terminal. Anything rendered as plain
106
161
  // content goes through this first.
@@ -147,7 +202,7 @@ function renderToolTranscript(result: { content?: unknown; details?: unknown; is
147
202
  const partial = progress !== undefined;
148
203
  if (!events) return new Text(displayText(contentText(result.content)), 0, 0);
149
204
  const header = partial ? sidekickWorkingHeader(theme, progress, label) : reportHeader(theme, label, report);
150
- return renderSidekickTranscript(theme, {
205
+ return frameTranscript(theme, transcriptStatus(partial, report), renderSidekickTranscript(theme, {
151
206
  events,
152
207
  droppedEvents: progress?.droppedEvents,
153
208
  header,
@@ -155,24 +210,29 @@ function renderToolTranscript(result: { content?: unknown; details?: unknown; is
155
210
  isPartial: partial,
156
211
  report,
157
212
  renderText: markdownText,
158
- });
213
+ }));
159
214
  }
160
215
 
161
216
  function renderCompletionCard(theme: ThemeLike, report: HandoffReport | undefined): Component {
162
217
  if (!report) return new Text(`${theme.fg("accent", "◆")} ${theme.fg("accent", theme.bold("sidekick done"))}`, 0, 0);
163
- return renderSidekickTranscript(theme, {
218
+ return frameTranscript(theme, transcriptStatus(false, report), renderSidekickTranscript(theme, {
164
219
  events: report.events ?? [],
165
220
  header: reportHeader(theme, "sidekick", report),
166
221
  expanded: false,
167
222
  isPartial: false,
168
223
  report,
169
224
  renderText: markdownText,
170
- });
225
+ }));
171
226
  }
172
227
 
173
228
  export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): void {
174
229
  pi.registerMessageRenderer("sidekick-completion", (message: { details?: HandoffReport }, _options, theme) => renderCompletionCard(theme as unknown as ThemeLike, message.details));
175
230
 
231
+ // One delivery mechanism for every handoff nobody is waiting on.
232
+ const completion = createCompletionDelivery((report) => {
233
+ pi.sendMessage(completionMessage(report) as never, { deliverAs: "followUp", triggerTurn: true } as never);
234
+ });
235
+
176
236
  pi.registerTool({
177
237
  name: "sidekick",
178
238
  label: "Sidekick",
@@ -187,19 +247,20 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
187
247
  const handoff = runtime.handoff(params.message);
188
248
  if (!wasBusy) deps.onHandoffStart?.(ctx);
189
249
  if (params.block === false) {
190
- void handoff.done.then((report) => {
191
- deps.onReport?.(ctx, report);
192
- pi.sendMessage(completionMessage(report) as never, { deliverAs: "followUp", triggerTurn: true } as never);
193
- }).catch(() => undefined);
250
+ void handoff.done.then((report) => deps.onReport?.(ctx, report)).catch(() => undefined);
251
+ completion.detach(handoff.id, handoff.done);
194
252
  deps.onDetach?.(ctx);
195
253
  return result(`Handoff ${handoff.id} started in the background. You will receive a <subagent_completion_notification agent_id="${handoff.id}"> when it finishes; use read_subagent to wait.`, { background: true, id: handoff.id });
196
254
  }
197
255
  deps.onAttach?.(ctx);
256
+ completion.attach(handoff.id);
198
257
  const waited = await waitForReport(runtime, handoff.id, handoff.done, signal, ctx, onUpdate ? (update) => onUpdate(update as never) : undefined);
199
258
  if (waited.report) {
259
+ completion.consume(handoff.id);
200
260
  deps.onReport?.(ctx, waited.report);
201
261
  return result(reportText(waited.report), waited.report, waited.report.status !== "completed");
202
262
  }
263
+ completion.detach(handoff.id, handoff.done);
203
264
  deps.onDetach?.(ctx);
204
265
  if (waited.error) return result(`Handoff ${handoff.id} failed: ${waited.error}`, undefined, true);
205
266
  if (waited.aborted) return result(`${progressText(runtime, handoff.id)}\nHandoff ${handoff.id} aborted.`, undefined, true);
@@ -222,16 +283,22 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
222
283
  if (!latest) return result("No sidekick handoff has run yet.", undefined, true);
223
284
  const id = params.agent_id ?? latest.id;
224
285
  const selected = runtime.reports.get(id);
225
- if (selected) return result(reportText(selected), selected, selected.status !== "completed");
286
+ if (selected) {
287
+ completion.consume(id);
288
+ return result(reportText(selected), selected, selected.status !== "completed");
289
+ }
226
290
  if (id !== latest.id) return result(`No sidekick handoff found for ${id}.`, undefined, true);
227
291
  if (params.block !== true) return result(`Handoff ${id} is still running.\n${progressText(runtime, id)}`, { progress: runtime.progress(id), id });
228
292
  deps.onAttach?.(ctx);
293
+ completion.attach(id);
229
294
  const timeoutMs = (params.timeout ?? 2700) * 1000;
230
295
  const waited = await waitForReport(runtime, id, latest.done, signal, ctx, onUpdate ? (update) => onUpdate(update as never) : undefined, timeoutMs);
231
296
  if (waited.report) {
297
+ completion.consume(id);
232
298
  deps.onReport?.(ctx, waited.report);
233
299
  return result(reportText(waited.report), waited.report, waited.report.status !== "completed");
234
300
  }
301
+ completion.detach(id, latest.done);
235
302
  deps.onDetach?.(ctx);
236
303
  if (waited.error) return result(`Handoff ${id} failed: ${waited.error}`, undefined, true);
237
304
  if (waited.aborted) return result(`Handoff ${id} aborted.`, undefined, true);
@@ -53,6 +53,13 @@ export interface TranscriptOptions {
53
53
  renderText: (markdown: string) => Component;
54
54
  }
55
55
 
56
+ /**
57
+ * Non-expanded transcripts show this many most recent events. Windowing is the
58
+ * only cap: a completed handoff renders its events, it is never collapsed into
59
+ * a "N steps" stub.
60
+ */
61
+ export const TRANSCRIPT_WINDOW = 8;
62
+
56
63
  function firstLine(value: string): string {
57
64
  return value.split("\n", 1)[0] ?? "";
58
65
  }
@@ -91,14 +98,8 @@ export function renderSidekickTranscript(theme: ThemeLike, opts: TranscriptOptio
91
98
  const box = new Container();
92
99
  box.addChild(new Text(opts.header, 0, 0));
93
100
 
94
- if (!opts.isPartial && !opts.expanded) {
95
- if (opts.report?.text) box.addChild(opts.renderText(opts.report.text));
96
- box.addChild(new Text(theme.fg("dim", `${String(opts.events.length)} steps · expand to see the transcript`), 0, 0));
97
- return box;
98
- }
99
-
100
101
  const dropped = opts.droppedEvents ?? 0;
101
- const start = opts.expanded ? 0 : Math.max(0, opts.events.length - 8);
102
+ const start = opts.expanded ? 0 : Math.max(0, opts.events.length - TRANSCRIPT_WINDOW);
102
103
  if (opts.expanded && dropped > 0) {
103
104
  box.addChild(new Text(theme.fg("dim", `… ${String(dropped)} earliest steps dropped`), 0, 0));
104
105
  } else if (start > 0) {
@@ -110,12 +111,13 @@ export function renderSidekickTranscript(theme: ThemeLike, opts: TranscriptOptio
110
111
  else if (event.text.trim()) box.addChild(opts.renderText(event.text.trim()));
111
112
  }
112
113
 
113
- if (!opts.isPartial && opts.expanded && opts.report?.text) {
114
+ if (!opts.isPartial && opts.report?.text) {
114
115
  // The final assistant message usually IS the report; don't print it twice.
115
116
  const last = opts.events.at(-1);
116
- if (last?.kind === "text" && last.text.trim() === opts.report.text.trim()) return box;
117
- box.addChild(new Text(theme.fg("dim", "── report ──"), 0, 0));
118
- box.addChild(opts.renderText(opts.report.text));
117
+ if (!(last?.kind === "text" && last.text.trim() === opts.report.text.trim())) {
118
+ box.addChild(new Text(theme.fg("dim", "── report ──"), 0, 0));
119
+ box.addChild(opts.renderText(opts.report.text));
120
+ }
119
121
  }
120
122
  return box;
121
123
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/image",
3
- "version": "2.19.3",
3
+ "version": "2.20.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.19.3"
37
+ "@pi-unipi/core": "2.20.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.19.3",
3
+ "version": "2.20.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.19.3"
36
+ "@pi-unipi/core": "2.20.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.19.3",
3
+ "version": "2.20.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.19.3"
36
+ "@pi-unipi/core": "2.20.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.19.3",
3
+ "version": "2.20.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.19.3"
42
+ "@pi-unipi/core": "2.20.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.19.3",
3
+ "version": "2.20.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.19.3"
33
+ "@pi-unipi/core": "2.20.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.19.3",
3
+ "version": "2.20.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.19.3",
43
- "@pi-unipi/info-screen": "2.19.3",
42
+ "@pi-unipi/core": "2.20.0",
43
+ "@pi-unipi/info-screen": "2.20.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.19.3",
3
+ "version": "2.20.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.19.3"
35
+ "@pi-unipi/core": "2.20.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.19.3",
3
+ "version": "2.20.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.19.3",
37
+ "@pi-unipi/core": "2.20.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.19.3",
3
+ "version": "2.20.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.19.3",
35
- "@pi-unipi/info-screen": "2.19.3"
34
+ "@pi-unipi/core": "2.20.0",
35
+ "@pi-unipi/info-screen": "2.20.0"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@earendil-works/pi-ai": "^0.84.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/subagents",
3
- "version": "2.19.3",
3
+ "version": "2.20.0",
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.19.3",
12
+ "@pi-unipi/core": "2.20.0",
13
13
  "@earendil-works/pi-agent-core": "^0.84.0",
14
14
  "acorn": "8.18.0"
15
15
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/trajectory",
3
- "version": "2.19.3",
3
+ "version": "2.20.0",
4
4
  "description": "Live UniPi trajectory inspector for Pi sessions",
5
5
  "type": "module",
6
6
  "main": "index.ts",