@pi-unipi/unipi 2.18.0 → 2.19.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 (48) hide show
  1. package/CHANGELOG.md +29 -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/package.json +1 -1
  12. package/packages/footer/package.json +3 -3
  13. package/packages/fusion/README.md +5 -2
  14. package/packages/fusion/package.json +3 -3
  15. package/packages/fusion/src/picker.ts +61 -30
  16. package/packages/fusion/src/sidekick-runtime.ts +62 -19
  17. package/packages/fusion/src/tools.ts +11 -3
  18. package/packages/image/package.json +2 -2
  19. package/packages/info-screen/package.json +2 -2
  20. package/packages/input-shortcuts/package.json +2 -2
  21. package/packages/kanboard/package.json +2 -2
  22. package/packages/mcp/package.json +2 -2
  23. package/packages/memory/package.json +3 -3
  24. package/packages/milestone/package.json +2 -2
  25. package/packages/notify/README.md +21 -1
  26. package/packages/notify/activity.ts +21 -0
  27. package/packages/notify/events.ts +137 -14
  28. package/packages/notify/index.ts +4 -0
  29. package/packages/notify/package.json +2 -2
  30. package/packages/notify/settings.ts +31 -1
  31. package/packages/notify/skills/configure-notify/SKILL.md +29 -0
  32. package/packages/notify/tui/settings-overlay.ts +104 -3
  33. package/packages/notify/types.ts +12 -0
  34. package/packages/ralph/package.json +3 -3
  35. package/packages/subagents/agents/reviewer.md +1 -0
  36. package/packages/subagents/package.json +2 -2
  37. package/packages/subagents/src/index.ts +2 -0
  38. package/packages/subagents/src/pi-args.ts +6 -0
  39. package/packages/trajectory/package.json +1 -1
  40. package/packages/unipi/bundled.js +9034 -2642
  41. package/packages/updater/package.json +2 -2
  42. package/packages/updater/src/tui/changelog-overlay.ts +2 -2
  43. package/packages/updater/src/tui/list-detail-overlay.ts +12 -8
  44. package/packages/updater/src/tui/readme-overlay.ts +2 -2
  45. package/packages/updater/src/tui/update-overlay.ts +30 -25
  46. package/packages/utility/package.json +2 -2
  47. package/packages/web-api/package.json +2 -2
  48. package/packages/workflow/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -6,6 +6,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.19.0] — 2026-09-16
10
+
11
+ ### Added
12
+
13
+ - `notify`: **unanswered blocking prompts are re-sent until someone acts** — `ask_user_prompt` and `permission_request` were fire-once, so a missed push left the agent parked with no further signal. New `renotify` config block (default on: `intervalMs: 120000`, `maxRepeats: 3`) re-dispatches the same title and message with a `(still waiting)` suffix at priority `high`; the loop is cancelled by any terminal keypress, `herdr:blocked` with `active: false`, `agent_start`, or session end, and re-arming replaces the pending timer (`armRenotify`/`disarmRenotify` in `events.ts`). New **Re-notify** tab in `/unipi:notify-settings` (Space toggles, `+`/`−` adjust interval and repeats). `maxRepeats: 0` sends the initial notification only.
14
+
15
+ ### Changed
16
+
17
+ - `notify`: **blocking prompts now dispatch at `high` priority** (`ask_user_prompt`/`permission_request` → ntfy 5, Gotify 8, breaking through DND) while `agent_end`/`agent_settled` dispatch at `low`. `dispatchNotification` already accepted a priority — the event path just never passed one; an explicit `notify_user` priority still wins.
18
+ - `fusion`: **a sidekick handoff now stays open while its background tasks run.** `SidekickRuntime` counts in-flight `bg_run` calls (`openBgTasks`, only when `notifyOnCompletion`/`triggerOnCompletion` are not `false`), and on `agent_settled` with tasks still running it holds the handoff instead of collecting the report at an intermediate checkpoint. The background-task completion message decrements the count; with none left and the agent settled, a `settleGraceMs` timer (new spawn-config option, default 3000, `unref`'d) waits for the follow-up turn before requesting the last assistant text, and `agent_start` clears it.
19
+ - `fusion`: **a sidekick prompt rejected as "already processing" is retried once** as a pi `followUp` (`streamingBehavior: "followUp"`, guarded by `retriedPrompt`) instead of failing the handoff; a second rejection finishes it as an error. `PendingHandoff` is now resolve-only.
20
+
21
+ ### Fixed
22
+
23
+ - `notify`: **no duplicate "agent finished" notification while a background task is still running.** `agent_end`/`agent_settled` notified for the intermediate turn that launched a `triggerOnCompletion` task and again when the wake started its own turn, making the session look finished when it wasn't. The lifecycle handlers now skip dispatch while the background-tasks shared registry reports a running wake-pending task; the registry is read off the `Symbol.for("unipi.background-tasks.shared-registry")` global, so there is no dependency or load-order coupling (synchronous, never blocks the lifecycle hook).
24
+ - `notify`: **`silenceAfterInput` no longer mutes blocking prompts.** Submitting a prompt with a keypress could silence the permission request that arrived seconds later — precisely the notification that must not be missed. `ask_user_prompt`/`permission_request` now bypass the quiet window, threaded through the existing `eventType` argument (`isBlockingEvent` in `activity.ts`); no new config.
25
+ - `fusion`: **a failed sidekick handoff is reported as a structured error result.** `waitForReport` races the handoff rejection into an `{ error }` outcome and `sidekick`/`read_subagent` return `Handoff <id> failed: <message>` with `isError`, instead of the rejection escaping through the race.
26
+
27
+ ## [2.18.1] — 2026-09-16
28
+
29
+ ### Fixed
30
+
31
+ - `updater`: update prompt / changelog detail bodies wrap to the overlay width instead of being truncated (was rendered at terminal width then chopped to the 80% overlay); footers advertise `↑/↓ j/k`.
32
+ - `subagents`: process-backed children now receive `--no-extensions` when the agent sets `extensions: false` (`childExtensionsArg` in `pi-args.ts`); builtin `reviewer` sets `extensions: false` so parent extensions can no longer change its read-only tool surface (#37).
33
+
34
+ ### Changed
35
+
36
+ - `btw`: the side-conversation session is read-only (`read, grep, find, ls`, no bash/edit/write) with an explicit read-only system instruction (#37).
37
+
9
38
  ## [2.18.0] — 2026-09-16
10
39
 
11
40
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "2.18.0",
3
+ "version": "2.19.0",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -84,29 +84,29 @@
84
84
  "typebox": "^1.1.38"
85
85
  },
86
86
  "dependencies": {
87
- "@pi-unipi/ask-user": "2.18.0",
88
- "@pi-unipi/background-tasks": "2.18.0",
89
- "@pi-unipi/btw": "2.18.0",
90
- "@pi-unipi/command-enchantment": "2.18.0",
91
- "@pi-unipi/compactor": "2.18.0",
92
- "@pi-unipi/core": "2.18.0",
93
- "@pi-unipi/footer": "2.18.0",
94
- "@pi-unipi/image": "2.18.0",
95
- "@pi-unipi/fusion": "2.18.0",
96
- "@pi-unipi/info-screen": "2.18.0",
97
- "@pi-unipi/input-shortcuts": "2.18.0",
98
- "@pi-unipi/kanboard": "2.18.0",
99
- "@pi-unipi/mcp": "2.18.0",
100
- "@pi-unipi/memory": "2.18.0",
101
- "@pi-unipi/milestone": "2.18.0",
102
- "@pi-unipi/notify": "2.18.0",
103
- "@pi-unipi/ralph": "2.18.0",
104
- "@pi-unipi/subagents": "2.18.0",
105
- "@pi-unipi/trajectory": "2.18.0",
106
- "@pi-unipi/updater": "2.18.0",
107
- "@pi-unipi/utility": "2.18.0",
108
- "@pi-unipi/web-api": "2.18.0",
109
- "@pi-unipi/workflow": "2.18.0"
87
+ "@pi-unipi/ask-user": "2.19.0",
88
+ "@pi-unipi/background-tasks": "2.19.0",
89
+ "@pi-unipi/btw": "2.19.0",
90
+ "@pi-unipi/command-enchantment": "2.19.0",
91
+ "@pi-unipi/compactor": "2.19.0",
92
+ "@pi-unipi/core": "2.19.0",
93
+ "@pi-unipi/footer": "2.19.0",
94
+ "@pi-unipi/image": "2.19.0",
95
+ "@pi-unipi/fusion": "2.19.0",
96
+ "@pi-unipi/info-screen": "2.19.0",
97
+ "@pi-unipi/input-shortcuts": "2.19.0",
98
+ "@pi-unipi/kanboard": "2.19.0",
99
+ "@pi-unipi/mcp": "2.19.0",
100
+ "@pi-unipi/memory": "2.19.0",
101
+ "@pi-unipi/milestone": "2.19.0",
102
+ "@pi-unipi/notify": "2.19.0",
103
+ "@pi-unipi/ralph": "2.19.0",
104
+ "@pi-unipi/subagents": "2.19.0",
105
+ "@pi-unipi/trajectory": "2.19.0",
106
+ "@pi-unipi/updater": "2.19.0",
107
+ "@pi-unipi/utility": "2.19.0",
108
+ "@pi-unipi/web-api": "2.19.0",
109
+ "@pi-unipi/workflow": "2.19.0"
110
110
  },
111
111
  "devDependencies": {
112
112
  "@earendil-works/pi-agent-core": "^0.84.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ask-user",
3
- "version": "2.18.0",
3
+ "version": "2.19.0",
4
4
  "description": "Structured user input tool for Pi coding agent — single-select, multi-select, freeform",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -40,7 +40,7 @@
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
- "@pi-unipi/core": "2.18.0"
43
+ "@pi-unipi/core": "2.19.0"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/command-enchantment",
3
- "version": "2.18.0",
3
+ "version": "2.19.0",
4
4
  "description": "Enhanced TUI autocomplete for /unipi:* commands — colored, sorted, and grouped by package",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/background-tasks",
3
- "version": "2.18.0",
3
+ "version": "2.19.0",
4
4
  "description": "Background tasks for UniPi — durable shell jobs and delegated background agents with a footer dock",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@earendil-works/pi-ai": "^0.84.0",
13
- "@pi-unipi/core": "2.18.0",
13
+ "@pi-unipi/core": "2.19.0",
14
14
  "turndown": "^7.2.4"
15
15
  },
16
16
  "devDependencies": {
@@ -2,7 +2,7 @@
2
2
 
3
3
  Side conversations that run in parallel. Ask a question using `/unipi:btw` while the main agent keeps working — the answer streams into a modal overlay without interrupting the current task.
4
4
 
5
- BTW opens a real Pi sub-session with coding-tool access. Use it to clarify something, explore an idea, or think through next steps without derailing the main turn. When you're ready, inject the thread back or summarize it.
5
+ BTW opens a real Pi sub-session with read-only file and search access. Use it to clarify something, explore an idea, or think through next steps without derailing the main turn. It cannot run commands or modify files; when you're ready, inject the thread back or summarize it.
6
6
 
7
7
  Based on [pi-btw](https://github.com/Neuron-Mr-White/pi-btw) by Dan Bachelder.
8
8
 
@@ -47,8 +47,8 @@ The BTW overlay opens top-centered so the main session remains visible underneat
47
47
  ## How It Works
48
48
 
49
49
  1. `/unipi:btw` creates or reuses a BTW sub-session
50
- 2. Your question runs in a real Pi session with tool access
51
- 3. The answer streams into the BTW modal overlay
50
+ 2. Your question runs in a real Pi session with read/search tool access (`read`, `grep`, `find`, `ls`)
51
+ 3. The read-only answer streams into the BTW modal overlay
52
52
  4. The thread continues until you clear it or inject it back
53
53
 
54
54
  `/unipi:btw-inject` sends the full thread to the main agent as a user message. If Pi is busy, it queues as a follow-up. `/unipi:btw-summarize` does the same but summarizes first.
@@ -2,7 +2,7 @@
2
2
  * @pi-unipi/btw — Side Conversation Extension
3
3
  *
4
4
  * A /unipi:btw side conversation channel that opens a real pi sub-session
5
- * with coding-tool access, running immediately even while the main
5
+ * with read-only coding-tool access, running immediately even while the main
6
6
  * agent is still busy.
7
7
  *
8
8
  * Based on pi-btw by Dan Bachelder, adapted for the Unipi suite.
@@ -64,6 +64,7 @@ const BTW_SYSTEM_PROMPT = [
64
64
  "If no main session messages are provided, treat this as a fully contextless tangent thread and rely only on the user's words plus your general instructions.",
65
65
  "Focus on answering the user's side questions, helping them think through ideas, or planning next steps.",
66
66
  "Do not act as if you need to continue unfinished work from the main session unless the user explicitly asks you to prepare something for injection back to it.",
67
+ "This aside is read-only: you can read and search files but cannot run commands or modify anything. Never claim to have edited, written, or executed something. If the user wants changes made, tell them to hand this thread off to the main session instead. Use /unipi:btw-inject or /unipi:btw-summarize to hand it off.",
67
68
  ].join(" ");
68
69
 
69
70
  const BTW_SUMMARIZE_SYSTEM_PROMPT =
@@ -1355,7 +1356,7 @@ export default function (pi: ExtensionAPI) {
1355
1356
  model: ctx.model,
1356
1357
  modelRuntime: sessionModelRuntime(ctx),
1357
1358
  thinkingLevel: pi.getThinkingLevel() as SessionThinkingLevel,
1358
- tools: ["read", "bash", "edit", "write"],
1359
+ tools: ["read", "grep", "find", "ls"],
1359
1360
  resourceLoader: createBtwResourceLoader(ctx),
1360
1361
  });
1361
1362
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/btw",
3
- "version": "2.18.0",
3
+ "version": "2.19.0",
4
4
  "description": "A pi extension for parallel side conversations with /unipi:btw — part of the Unipi suite",
5
5
  "type": "module",
6
6
  "main": "extensions/btw.ts",
@@ -37,7 +37,7 @@
37
37
  "@earendil-works/pi-tui": "^0.84.0"
38
38
  },
39
39
  "dependencies": {
40
- "@pi-unipi/core": "2.18.0"
40
+ "@pi-unipi/core": "2.19.0"
41
41
  },
42
42
  "pi": {
43
43
  "extensions": [
@@ -7,6 +7,8 @@ description: Helps you use the /unipi:btw side-conversation workflow effectively
7
7
 
8
8
  Use this skill when the user wants to work in parallel with the main agent instead of derailing the current turn.
9
9
 
10
+ BTW side sessions are read-only: they can read and search files with `read`, `grep`, `find`, and `ls`, but cannot run commands or modify files. Send requested changes to the main session with `/unipi:btw-inject` or `/unipi:btw-summarize`.
11
+
10
12
  ## When to use BTW
11
13
 
12
14
  Prefer the BTW workflow when the user wants to:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/compactor",
3
- "version": "2.18.0",
3
+ "version": "2.19.0",
4
4
  "description": "Context engine for Pi — zero-LLM compaction, session continuity, sandbox execution, and tool display optimization",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -34,8 +34,8 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pi-unipi/core": "2.18.0",
38
- "@pi-unipi/info-screen": "2.18.0",
37
+ "@pi-unipi/core": "2.19.0",
38
+ "@pi-unipi/info-screen": "2.19.0",
39
39
  "@earendil-works/pi-agent-core": "^0.84.0"
40
40
  },
41
41
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/core",
3
- "version": "2.18.0",
3
+ "version": "2.19.0",
4
4
  "description": "Shared utilities, event types, and constants for Unipi extension suite",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/footer",
3
- "version": "2.18.0",
3
+ "version": "2.19.0",
4
4
  "description": "Persistent status bar for Unipi — subscribes to UNIPI_EVENTS and renders key stats from all unipi packages",
5
5
  "type": "module",
6
6
  "main": "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/background-tasks": "2.18.0"
35
+ "@pi-unipi/core": "2.19.0",
36
+ "@pi-unipi/background-tasks": "2.19.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -92,8 +92,11 @@ 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.
99
+ `read_subagent({agent_id?, block?, timeout?})` reads or waits for a handoff.
97
100
 
98
101
  The child receives `UNIPI_FUSION_CHILD=1` and `UNIPI_SUBAGENT_CHILD=1`; the
99
102
  Fusion extension guard prevents child processes from registering Fusion tools,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/fusion",
3
- "version": "2.18.0",
3
+ "version": "2.19.0",
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.0"
35
+ "@pi-unipi/core": "2.19.0",
36
+ "@pi-unipi/subagents": "2.19.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-ai": "^0.84.0",
@@ -14,10 +14,11 @@
14
14
  * $10 / 1M $0.25 / 1M $50 / 1M $0.2 / 1M $1.2 / 1M
15
15
  * ↑/↓ select · ←/→ effort · tab lead · Enter confirm · esc cancel
16
16
  *
17
- * Row order: the active selection pinned first, then the Fusion row (when a
18
- * pair is configured), then recent (≤5, MRU), then the preset models, then
19
- * EVERY other available model — the catalogue is never hidden, the preset
20
- * only controls ordering. Typing filters all rows except the pinned one.
17
+ * Row order: the active selection pinned first, then the always-visible Fusion
18
+ * row (disabled with a setup hint when no pair is configured), then recent
19
+ * (≤5, MRU), then the preset models, then EVERY other available model — the
20
+ * catalogue is never hidden, the preset only controls ordering. Typing filters
21
+ * all rows except the pinned one.
21
22
  *
22
23
  * ←/→ steps the highlighted row's effort. Per-model effort is remembered for
23
24
  * plain model rows; the Fusion row keeps its own lead/sidekick efforts so
@@ -219,7 +220,7 @@ export class ModelPicker {
219
220
  out.push({ kind: "model", key: active.model });
220
221
  seen.add(active.model);
221
222
  }
222
- if (!pinnedFusion && this.fusionAvailable()) out.push({ kind: "fusion" });
223
+ if (!pinnedFusion) out.push({ kind: "fusion" });
223
224
 
224
225
  const ordered: ModelKey[] = [
225
226
  ...this.state.recent,
@@ -268,6 +269,18 @@ export class ModelPicker {
268
269
  return;
269
270
  }
270
271
 
272
+ const disabledFusion = row?.kind === "fusion" && !this.fusionAvailable();
273
+ if (
274
+ disabledFusion &&
275
+ (matchesKey(data, Key.tab) ||
276
+ matchesKey(data, Key.left) ||
277
+ matchesKey(data, Key.right) ||
278
+ matchesKey(data, Key.enter) ||
279
+ data === "\r")
280
+ ) {
281
+ return;
282
+ }
283
+
271
284
  if (inDropdown) {
272
285
  const items = this.dropdownItems();
273
286
  if (matchesKey(data, Key.up)) {
@@ -406,11 +419,12 @@ export class ModelPicker {
406
419
  private renderRow(row: Row, highlighted: boolean, width: number): string {
407
420
  const t = this.theme;
408
421
  const pointer = highlighted ? t.fg("accent", "❭") : t.fg("dim", "·");
422
+ const disabledFusion = row.kind === "fusion" && !this.fusionAvailable();
409
423
  // The Fusion composite gets the check when it is the active selection —
410
424
  // same affordance a single active model gets on its own row.
411
425
  const marker =
412
426
  row.kind === "fusion"
413
- ? this.state.active?.kind === "fusion"
427
+ ? !disabledFusion && this.state.active?.kind === "fusion"
414
428
  ? t.fg("success", "✓")
415
429
  : " "
416
430
  : this.markerFor(row.key);
@@ -418,9 +432,13 @@ export class ModelPicker {
418
432
  const nameRaw = row.kind === "fusion" ? "Fusion" : this.nameOf(row.key, NAME_COL - 1);
419
433
  const name =
420
434
  row.kind === "fusion"
421
- ? highlighted
422
- ? t.fg("accent", t.bold(nameRaw))
423
- : t.fg("text", nameRaw)
435
+ ? disabledFusion
436
+ ? highlighted
437
+ ? t.fg("muted", t.bold(nameRaw))
438
+ : t.fg("dim", nameRaw)
439
+ : highlighted
440
+ ? t.fg("accent", t.bold(nameRaw))
441
+ : t.fg("text", nameRaw)
424
442
  : working
425
443
  ? t.fg("accent", t.bold(nameRaw))
426
444
  : highlighted
@@ -431,25 +449,33 @@ export class ModelPicker {
431
449
  const badgeGlyph = badge === undefined ? "" : ` ${t.fg(badge === "new" ? "success" : badge === "promotion" ? "accent" : "warning", "✱")}`;
432
450
 
433
451
  const level = row.kind === "fusion" ? this.fusionLeadEffort : this.effortFor(row.key);
434
- const arrowsOn = highlighted && this.focus === "effort";
452
+ const arrowsOn = !disabledFusion && highlighted && this.focus === "effort";
435
453
  const left = arrowsOn ? t.fg("accent", "←") : " ";
436
454
  const right = arrowsOn ? t.fg("accent", "→") : " ";
437
- const label = highlighted ? t.fg("accent", effortLabel(level)) : t.fg("muted", effortLabel(level));
455
+ const label = disabledFusion
456
+ ? t.fg("dim", effortLabel(level))
457
+ : highlighted
458
+ ? t.fg("accent", effortLabel(level))
459
+ : t.fg("muted", effortLabel(level));
438
460
 
439
- let line = `${pointer} ${marker} ${pad(`${name}${badgeGlyph}`, NAME_COL)} ${left} ${this.bar(level, highlighted)} ${right} ${pad(label, 8)}`;
461
+ let line = `${pointer} ${marker} ${pad(`${name}${badgeGlyph}`, NAME_COL)} ${left} ${this.bar(level, !disabledFusion && highlighted)} ${right} ${pad(label, 8)}`;
440
462
 
441
463
  if (row.kind === "fusion") {
442
- const leadName = this.nameOf(this.lead, 14);
443
- const sideName = this.nameOf(this.sidekick, 14);
444
- const leadFocused = highlighted && this.focus === "lead";
445
- const sideFocused = highlighted && this.focus === "sidekick";
446
- const leadText = leadFocused
447
- ? `${t.fg("accent", t.bold("Lead"))} ${t.fg("accent", leadName)} ${t.fg("accent", "")}`
448
- : `${t.fg("dim", "Lead")} ${t.fg("text", leadName)} ${t.fg("dim", "")}`;
449
- const sideText = sideFocused
450
- ? `${t.fg("accent", t.bold("Sidekick"))} ${t.fg("accent", sideName)} ${t.fg("accent", "▾")}`
451
- : `${t.fg("dim", "Sidekick")} ${t.fg("text", sideName)} ${t.fg("dim", "▾")}`;
452
- line += ` ${leadText} ${sideText}`;
464
+ if (disabledFusion) {
465
+ line += ` ${t.fg("dim", "not configured — run /unipi:fusion-preset")}`;
466
+ } else {
467
+ const leadName = this.nameOf(this.lead, 14);
468
+ const sideName = this.nameOf(this.sidekick, 14);
469
+ const leadFocused = highlighted && this.focus === "lead";
470
+ const sideFocused = highlighted && this.focus === "sidekick";
471
+ const leadText = leadFocused
472
+ ? `${t.fg("accent", t.bold("Lead"))} ${t.fg("accent", leadName)} ${t.fg("accent", "▾")}`
473
+ : `${t.fg("dim", "Lead")} ${t.fg("text", leadName)} ${t.fg("dim", "▾")}`;
474
+ const sideText = sideFocused
475
+ ? `${t.fg("accent", t.bold("Sidekick"))} ${t.fg("accent", sideName)} ${t.fg("accent", "▾")}`
476
+ : `${t.fg("dim", "Sidekick")} ${t.fg("text", sideName)} ${t.fg("dim", "▾")}`;
477
+ line += ` ${leadText} ${sideText}`;
478
+ }
453
479
  }
454
480
  return truncateToWidth(line, Math.max(1, width - 1));
455
481
  }
@@ -478,6 +504,7 @@ export class ModelPicker {
478
504
  private renderPricePanel(row: Row | undefined, width: number): string[] {
479
505
  const t = this.theme;
480
506
  if (row === undefined) return [];
507
+ const disabledFusion = row.kind === "fusion" && !this.fusionAvailable();
481
508
  const primaryKey = row.kind === "fusion" ? this.lead : row.key;
482
509
  const primary = primaryKey === undefined ? undefined : this.modelsByKey.get(primaryKey);
483
510
  const side = row.kind === "fusion" && this.sidekick !== undefined ? this.modelsByKey.get(this.sidekick) : undefined;
@@ -505,18 +532,20 @@ export class ModelPicker {
505
532
  const head = cols.map(([h]) => pad(t.fg("dim", h), colWidth)).join("");
506
533
  const vals = cols.map(([, v]) => pad(t.fg("text", v), colWidth)).join("");
507
534
  const desc =
508
- row.kind === "fusion"
509
- ? t.fg("dim", "Pairs frontier intelligence with cost-efficient execution")
510
- : primary?.reasoning
511
- ? t.fg("dim", "Reasoning model · ←/→ adjusts thinking effort")
512
- : t.fg("dim", "Non-reasoning model · effort is ignored by the provider");
535
+ disabledFusion
536
+ ? t.fg("warning", "Run /unipi:fusion-preset to enable Fusion — a powerful lead model plans and reviews while a cheaper sidekick executes, for frontier performance at lower cost")
537
+ : row.kind === "fusion"
538
+ ? t.fg("dim", "Pairs frontier intelligence with cost-efficient execution")
539
+ : primary?.reasoning
540
+ ? t.fg("dim", "Reasoning model · ←/→ adjusts thinking effort")
541
+ : t.fg("dim", "Non-reasoning model · effort is ignored by the provider");
513
542
  const badges = this.state.models.some((m) => m.badge !== undefined)
514
543
  ? `${t.fg("success", "✱")} ${t.fg("dim", "New")} ${t.fg("accent", "✱")} ${t.fg("dim", "Promotion")} ${t.fg("warning", "✱")} ${t.fg("dim", "Beta")} ${t.fg("dim", "·")}`
515
544
  : "";
516
545
  const noPricing = row.kind === "fusion"
517
546
  ? !hasPricing(primaryCost) || !hasPricing(sideCost)
518
547
  : !hasPricing(primaryCost);
519
- const pricing = noPricing ? t.fg("dim", " · no pricing data from provider") : "";
548
+ const pricing = !disabledFusion && noPricing ? t.fg("dim", " · no pricing data from provider") : "";
520
549
  const description = `${badges}${badges.length > 0 ? " " : ""}${desc}${pricing}`;
521
550
  return [truncateToWidth(` ${head}`, width - 1), truncateToWidth(` ${vals}`, width - 1), truncateToWidth(` ${description}`, width - 1)];
522
551
  }
@@ -524,7 +553,9 @@ export class ModelPicker {
524
553
  private hintLine(row: Row | undefined): string {
525
554
  const t = this.theme;
526
555
  const parts: string[] = [];
527
- if (row?.kind === "fusion" && this.focus !== "effort") {
556
+ if (row?.kind === "fusion" && !this.fusionAvailable()) {
557
+ parts.push("↑↓ select", "esc cancel");
558
+ } else if (row?.kind === "fusion" && this.focus !== "effort") {
528
559
  parts.push("↑↓ select", `tab ${this.focus === "lead" ? "sidekick" : "effort"}`, "↵ apply", "esc collapse");
529
560
  } else {
530
561
  parts.push("↑↓ select");
@@ -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 {