@pi-unipi/unipi 2.19.3 → 2.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.20.1] — 2026-09-17
10
+
11
+ ### Fixed
12
+
13
+ - `ask-user`: **`ask_user` no longer self-cancels inside a subagent.** Pi's RPC mode reports `ctx.hasUI` as `true` while `ui.custom()` is a stub returning `undefined` (`rpc-mode.js:152` vs `runner.js:274`), so the tool passed its UI guard, read the stub's `undefined` as a cancel, and answered "User cancelled the selection" without the user ever seeing a prompt — the Fusion sidekick then reasoned as if the user had refused to answer. `packages/ask-user/tools.ts` now short-circuits in the new exported `isSubagentChild()` helper *before* the `settings.enabled` check, returning `isError: true` with instructions to stop and escalate the question, options, and recommendation to the lead. Applies to every `UNIPI_SUBAGENT_CHILD=1` child, not only Fusion.
14
+ - `fusion`: **a handoff that outlives its waiter now wakes the lead.** When a `hasPendingMessages` interrupt or a `waitForReport` timeout left a handoff running with nobody attached, the report landed silently in `runtime.reports` and the UI went quiet. New `createCompletionDelivery()` in `packages/fusion/src/tools.ts` delivers the `sidekick-completion` follow-up exactly once per handoff via `attach`/`detach`/`consume`: it fires only if the report lands (or already landed) while no waiter is attached, and never when a waiter returned the report inline. `block:false` now routes through the same single mechanism instead of its own `.then`.
15
+
16
+ ### Added
17
+
18
+ - `fusion`: **"sidekick still working" wake line.** While the sidekick is busy and the lead's turn has already ended — a background handoff, an interrupted one, or a wait that timed out — an animated line above the editor shows the live tool count and elapsed time. Reuses `createSpinnerLine` from `@pi-unipi/core` with the install-once latch pattern from `packages/background-tasks`, so the 80 ms frame timer is never leaked by progress ticks, and keeps `pane.report_agent`/`herdr:blocked` untouched.
19
+
20
+ ### Changed
21
+
22
+ - `fusion`: **sidekick transcripts render on the main surface.** The `N steps · expand to see the transcript` collapse stub is gone: a completed handoff now renders its events inline in the lead's own markdown/tool format, marked as sidekick-origin by the existing `▍` rail and windowed to the last 8 events (`TRANSCRIPT_WINDOW`) with the `… N earlier steps` affordance. `expanded` now controls only tool-output verbosity. The now-redundant `fusion-sidekick` transcript widget above the editor was removed and `packages/fusion/src/sidekick-widget.ts` deleted; the wake line covers the gap and the full transcript arrives in the message flow (blocking tool result and completion card).
23
+
24
+ **Note.** `2.20.0` was burned by a registry-side staging fault and never published in full: npm partially failed with `E409 Cannot publish over previously staged version "2.20.0"` (npm/cli#9889), so 20 of 23 packages landed while `@pi-unipi/web-api`, `@pi-unipi/workflow` and the `@pi-unipi/unipi` umbrella were left with an unusable version number, and npm 11 has no client-side cleanup for a staged version (`npm stage` does not exist). `2.20.1` carries the same changes and is the first complete release of this work.
25
+
9
26
  ## [2.19.3] — 2026-09-16
10
27
 
11
28
  ### Fixed
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Unipi
2
2
 
3
- 22 workspace packages that turn Pi into a full development workstation. Structured workflows, persistent memory, parallel agents, web research, notifications, context management, command autocomplete, and a live status bar — all wired together through a shared event system.
3
+ 23 workspace packages that turn Pi into a full development workstation. Structured workflows, persistent memory, parallel agents, web research, notifications, context management, command autocomplete, and a live status bar — all wired together through a shared event system.
4
4
 
5
5
  One command installs everything:
6
6
  ```bash
@@ -25,6 +25,10 @@ pi install npm:@pi-unipi/unipi
25
25
 
26
26
  **[Subagents](./packages/subagents/README.md)** — Parallel execution with file locking. Spawn background agents to research, fix, or build while the main agent keeps going.
27
27
 
28
+ **[Fusion](./packages/fusion/README.md)** — Run two models as one: a frontier lead delegates to a cheaper persistent sidekick that keeps its own context and shells, and stays in the loop while the sidekick works. Pair them from `/unipi:model`; `/unipi:fusion-stats` shows what the sidekick saved you.
29
+
30
+ **[Background Tasks](./packages/background-tasks/README.md)** — Long-running commands and delegate agents that survive the turn that started them, with a live dock, output capture, and a wake line while the agent waits on them.
31
+
28
32
  **[Web API](./packages/web-api/README.md)** — Web search, page reading, content summarization. Defaults to [wigolo](https://github.com/KnockOutEZ/wigolo), a local-first engine with multi-engine search and on-device reranking — $0/query, no API key. Plus a smart-fetch engine with browser-grade TLS fingerprinting. Paid providers as fallbacks, and auto-selection falls through when a provider is unavailable.
29
33
 
30
34
  **[Image](./packages/image/README.md)** — Generate images from a text prompt and analyze existing ones. 34 image models, plus vision analysis on any model that accepts image input, with a customizable system prompt.
@@ -112,6 +116,8 @@ Coexists triggers enhance behavior when packages are installed together. Workflo
112
116
  | `web_search` / `multi_web_content_read` / `web_llm_summarize` | web-api | Web research |
113
117
  | `image_generate` / `image_recognize` | image | Image generation and vision (recognize auto-hides while the session model has vision) |
114
118
  | `notify_user` | notify | Push notifications |
119
+ | `sidekick` / `read_subagent` | fusion | Delegate to the persistent sidekick and collect its report |
120
+ | `bg_run` / `bg_status` / `bg_logs` / `bg_kill` / `bg_delegate` / `bg_result` | background-tasks | Long-running commands and background delegate agents |
115
121
  | `ask_user` | ask-user | User input |
116
122
  | `compact` / `session_recall` / `sandbox` | compactor | Context management |
117
123
  | `ctx_env` | utility | Environment info |
@@ -137,6 +143,8 @@ unipi/
137
143
  │ ├── memory/ # SQLite + vector search
138
144
  │ ├── compactor/ # Context engine
139
145
  │ ├── subagents/ # Parallel execution
146
+ │ ├── fusion/ # Lead + sidekick pairing
147
+ │ ├── background-tasks/ # Long-running tasks and delegates
140
148
  │ ├── web-api/ # Web research
141
149
  │ ├── image/ # Image generation and vision
142
150
  │ ├── mcp/ # MCP server integration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "2.19.3",
3
+ "version": "2.20.1",
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.19.3",
88
- "@pi-unipi/background-tasks": "2.19.3",
89
- "@pi-unipi/btw": "2.19.3",
90
- "@pi-unipi/command-enchantment": "2.19.3",
91
- "@pi-unipi/compactor": "2.19.3",
92
- "@pi-unipi/core": "2.19.3",
93
- "@pi-unipi/footer": "2.19.3",
94
- "@pi-unipi/image": "2.19.3",
95
- "@pi-unipi/fusion": "2.19.3",
96
- "@pi-unipi/info-screen": "2.19.3",
97
- "@pi-unipi/input-shortcuts": "2.19.3",
98
- "@pi-unipi/kanboard": "2.19.3",
99
- "@pi-unipi/mcp": "2.19.3",
100
- "@pi-unipi/memory": "2.19.3",
101
- "@pi-unipi/milestone": "2.19.3",
102
- "@pi-unipi/notify": "2.19.3",
103
- "@pi-unipi/ralph": "2.19.3",
104
- "@pi-unipi/subagents": "2.19.3",
105
- "@pi-unipi/trajectory": "2.19.3",
106
- "@pi-unipi/updater": "2.19.3",
107
- "@pi-unipi/utility": "2.19.3",
108
- "@pi-unipi/web-api": "2.19.3",
109
- "@pi-unipi/workflow": "2.19.3"
87
+ "@pi-unipi/ask-user": "2.20.1",
88
+ "@pi-unipi/background-tasks": "2.20.1",
89
+ "@pi-unipi/btw": "2.20.1",
90
+ "@pi-unipi/command-enchantment": "2.20.1",
91
+ "@pi-unipi/compactor": "2.20.1",
92
+ "@pi-unipi/core": "2.20.1",
93
+ "@pi-unipi/footer": "2.20.1",
94
+ "@pi-unipi/image": "2.20.1",
95
+ "@pi-unipi/fusion": "2.20.1",
96
+ "@pi-unipi/info-screen": "2.20.1",
97
+ "@pi-unipi/input-shortcuts": "2.20.1",
98
+ "@pi-unipi/kanboard": "2.20.1",
99
+ "@pi-unipi/mcp": "2.20.1",
100
+ "@pi-unipi/memory": "2.20.1",
101
+ "@pi-unipi/milestone": "2.20.1",
102
+ "@pi-unipi/notify": "2.20.1",
103
+ "@pi-unipi/ralph": "2.20.1",
104
+ "@pi-unipi/subagents": "2.20.1",
105
+ "@pi-unipi/trajectory": "2.20.1",
106
+ "@pi-unipi/updater": "2.20.1",
107
+ "@pi-unipi/utility": "2.20.1",
108
+ "@pi-unipi/web-api": "2.20.1",
109
+ "@pi-unipi/workflow": "2.20.1"
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.19.3",
3
+ "version": "2.20.1",
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.19.3"
43
+ "@pi-unipi/core": "2.20.1"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -19,6 +19,14 @@ import { renderLauncherUI } from "./launcher-ui.js";
19
19
  import { getAskUserSettings } from "./config.js";
20
20
  import { queueCompactHandoff, queueDirectHandoff } from "./handoff.js";
21
21
 
22
+ /**
23
+ * Whether this process is a subagent child (subagents package or fusion sidekick).
24
+ * Subagent children never talk to the user directly — their lead owns ambiguity.
25
+ */
26
+ export function isSubagentChild(env: Record<string, string | undefined> = process.env): boolean {
27
+ return env.UNIPI_SUBAGENT_CHILD === "1";
28
+ }
29
+
22
30
  /**
23
31
  * Register ask-user tools.
24
32
  */
@@ -130,6 +138,28 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
130
138
  timeout?: number;
131
139
  };
132
140
 
141
+ // Subagent children have no user to talk to. Refuse loudly so the
142
+ // subagent surfaces the question to its lead instead of guessing.
143
+ if (isSubagentChild()) {
144
+ return {
145
+ content: [
146
+ {
147
+ type: "text",
148
+ text:
149
+ "ask_user is not available inside a subagent. You cannot talk to the user directly — only your lead can. Do not guess or assume an answer. Stop and state the question, the options you considered, and your recommendation in your report so your lead can decide.",
150
+ },
151
+ ],
152
+ isError: true,
153
+ details: {
154
+ question,
155
+ response: {
156
+ kind: "cancelled",
157
+ comment: "ask_user is not available inside a subagent",
158
+ } as AskUserResponse,
159
+ },
160
+ };
161
+ }
162
+
133
163
  // Check settings
134
164
  const settings = getAskUserSettings();
135
165
  if (!settings.enabled) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/command-enchantment",
3
- "version": "2.19.3",
3
+ "version": "2.20.1",
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.19.3",
3
+ "version": "2.20.1",
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.19.3",
13
+ "@pi-unipi/core": "2.20.1",
14
14
  "turndown": "^7.2.4"
15
15
  },
16
16
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/btw",
3
- "version": "2.19.3",
3
+ "version": "2.20.1",
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.19.3"
40
+ "@pi-unipi/core": "2.20.1"
41
41
  },
42
42
  "pi": {
43
43
  "extensions": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/compactor",
3
- "version": "2.19.3",
3
+ "version": "2.20.1",
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.19.3",
38
- "@pi-unipi/info-screen": "2.19.3",
37
+ "@pi-unipi/core": "2.20.1",
38
+ "@pi-unipi/info-screen": "2.20.1",
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.19.3",
3
+ "version": "2.20.1",
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.19.3",
3
+ "version": "2.20.1",
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.19.3",
36
- "@pi-unipi/background-tasks": "2.19.3"
35
+ "@pi-unipi/core": "2.20.1",
36
+ "@pi-unipi/background-tasks": "2.20.1"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -92,14 +92,19 @@ 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. Background tasks keep that handoff open through their completion
95
+ handoff. A handoff whose lead turn has ended started with `block:false`,
96
+ abandoned by a user message, or timed out — still wakes the lead with that same
97
+ completion notification when its report lands, exactly once. Background tasks
98
+ keep that handoff open through their completion
96
99
  notification and any follow-up turn, so the report is not released at an
97
100
  intermediate checkpoint. If a prompt arrives while the child is processing,
98
101
  Fusion retries it once with pi's `followUp` streaming behavior. Sidekick work
99
102
  renders as ordinary tool activity — handoff ids, `agent_id`, and
100
- `read_subagent` protocol text never reach the terminal. Detached
101
- handoffs stay visible in a live sidekick widget above the editor until their
102
- completion card arrives.
103
+ `read_subagent` protocol text never reach the terminal. Sidekick output keeps
104
+ that same format on the main surface, marked as sidekick-origin by a `▍` rail.
105
+ While the sidekick works and the lead's turn is over, an animated
106
+ `sidekick working` line above the editor shows the live tool count and elapsed
107
+ time until the report lands.
103
108
  `read_subagent({agent_id?, block?, timeout?})` reads or waits for a handoff.
104
109
 
105
110
  The child receives `UNIPI_FUSION_CHILD=1` and `UNIPI_SUBAGENT_CHILD=1`; the
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/fusion",
3
- "version": "2.19.3",
3
+ "version": "2.20.1",
4
4
  "description": "Devin-style model picker, fusion presets (lead + sidekick), and Local Fusion runtime for UniPi",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -32,8 +32,8 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "2.19.3",
36
- "@pi-unipi/subagents": "2.19.3"
35
+ "@pi-unipi/core": "2.20.1",
36
+ "@pi-unipi/subagents": "2.20.1"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-ai": "^0.84.0",
@@ -16,7 +16,7 @@
16
16
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
17
  import type { Api, Model } from "@earendil-works/pi-ai";
18
18
  import type { AutocompleteProvider, AutocompleteSuggestions } from "@earendil-works/pi-tui";
19
- import { setSharedFusionStatus, UNIPI_PREFIX } from "@pi-unipi/core";
19
+ import { createSpinnerLine, setSharedFusionStatus, UNIPI_PREFIX } from "@pi-unipi/core";
20
20
  import { homedir } from "node:os";
21
21
  import { join } from "node:path";
22
22
  import {
@@ -40,8 +40,7 @@ import { estimateSavings } from "./savings.js";
40
40
  import { EDIT_NUDGE, bashNudge, leadPolicy, sidekickSystemPrompt, type FusionIdentity } from "./prompts.js";
41
41
  import { isTrivialShell, BASH_NUDGE_EVERY } from "./nudge.js";
42
42
  import { registerFusionTools } from "./tools.js";
43
- import { shouldShowSidekickWidget } from "./sidekick-widget.js";
44
- import { frameSidekick, markdownText, renderSidekickTranscript, sidekickWorkingHeader, type ThemeLike } from "./transcript.js";
43
+ import { duration } from "./transcript.js";
45
44
 
46
45
  export const MODEL_COMMAND = `${UNIPI_PREFIX}model`;
47
46
  export const PRESET_COMMAND = `${UNIPI_PREFIX}fusion-preset`;
@@ -50,6 +49,73 @@ export function sidekickSessionPath(leadSessionId?: string): string {
50
49
  return join(homedir(), ".unipi", "state", "fusion", "sidekick", `${leadSessionId ?? "default"}.jsonl`);
51
50
  }
52
51
 
52
+ export const SIDEKICK_WAKE_WIDGET_KEY = "fusion-sidekick-wake";
53
+
54
+ /**
55
+ * The lead is idle when pi says so. Any failure (including a context without
56
+ * `isIdle`) counts as idle — same contract as the background-tasks wake line.
57
+ */
58
+ export function isLeadIdle(ctx: { isIdle(): boolean }): boolean {
59
+ try {
60
+ return ctx.isIdle();
61
+ } catch {
62
+ return true;
63
+ }
64
+ }
65
+
66
+ /** Body of the live line. `undefined` collapses it without disposing the widget. */
67
+ export function sidekickWakeText(progress: { toolCalls: number; startedAt: number } | undefined): string | undefined {
68
+ if (!progress) return undefined;
69
+ return `sidekick working · ${String(progress.toolCalls)} tool calls · ${duration(Date.now() - progress.startedAt)} — resumes automatically when done`;
70
+ }
71
+
72
+ export interface SidekickWakeLine {
73
+ publish(ctx: ExtensionContext | undefined): void;
74
+ clear(ctx: ExtensionContext | undefined): void;
75
+ }
76
+
77
+ /**
78
+ * Self-animating "sidekick still working" line, shown while the sidekick is
79
+ * busy but the lead's turn has already ended (pi stopped its own loader).
80
+ *
81
+ * The widget owns an 80 ms frame timer, so it is installed ONCE while the
82
+ * condition holds and removed when it stops — never re-installed per progress
83
+ * tick.
84
+ */
85
+ export function createSidekickWakeLine(deps: {
86
+ isBusy: () => boolean;
87
+ progress: () => { toolCalls: number; startedAt: number } | undefined;
88
+ }): SidekickWakeLine {
89
+ let installed = false;
90
+
91
+ const removable = (ctx: ExtensionContext | undefined): ctx is ExtensionContext =>
92
+ Boolean(ctx?.hasUI) && typeof ctx?.ui.setWidget === "function";
93
+
94
+ return {
95
+ publish(ctx) {
96
+ if (!removable(ctx)) return;
97
+ const want = deps.isBusy() && isLeadIdle(ctx);
98
+ if (want && !installed) {
99
+ ctx.ui.setWidget(
100
+ SIDEKICK_WAKE_WIDGET_KEY,
101
+ createSpinnerLine({ text: () => sidekickWakeText(deps.progress()) }),
102
+ { placement: "aboveEditor" },
103
+ );
104
+ installed = true;
105
+ } else if (!want && installed) {
106
+ ctx.ui.setWidget(SIDEKICK_WAKE_WIDGET_KEY, undefined);
107
+ installed = false;
108
+ }
109
+ },
110
+ clear(ctx) {
111
+ if (!installed) return;
112
+ installed = false;
113
+ if (!removable(ctx)) return;
114
+ ctx.ui.setWidget(SIDEKICK_WAKE_WIDGET_KEY, undefined);
115
+ },
116
+ };
117
+ }
118
+
53
119
  type Registry = { getAvailable(): Model<Api>[]; find(provider: string, id: string): Model<Api> | undefined };
54
120
 
55
121
  function registryOf(ctx: ExtensionContext): Registry | undefined {
@@ -120,10 +186,11 @@ export default function fusionExtension(pi: ExtensionAPI): void {
120
186
  let leadToolCalls = 0;
121
187
  let editNudgedThisTurn = false;
122
188
  let bashStreak = 0;
123
- let attached = false;
124
- let widgetTimer: NodeJS.Timeout | undefined;
125
- let widgetTimerKind: "publish" | "clear" | undefined;
126
- let widgetLastAt = 0;
189
+
190
+ const wakeLine = createSidekickWakeLine({
191
+ isBusy: () => runtime?.isBusy() === true,
192
+ progress: () => runtime?.progress(),
193
+ });
127
194
 
128
195
  function identity(ctx: ExtensionContext): FusionIdentity {
129
196
  const reg = registryOf(ctx);
@@ -167,78 +234,11 @@ export default function fusionExtension(pi: ExtensionAPI): void {
167
234
  }
168
235
  }
169
236
 
170
- function clearSidekickWidget(): void {
171
- if (widgetTimer !== undefined) {
172
- clearTimeout(widgetTimer);
173
- widgetTimer = undefined;
174
- widgetTimerKind = undefined;
175
- }
176
- const ctx = lastCtx;
177
- if (!ctx?.hasUI || typeof ctx.ui.setWidget !== "function") return;
178
- const clear = () => {
179
- widgetTimer = undefined;
180
- widgetTimerKind = undefined;
181
- widgetLastAt = Date.now();
182
- ctx.ui.setWidget("fusion-sidekick", undefined);
183
- };
184
- const wait = Math.max(0, 150 - (Date.now() - widgetLastAt));
185
- if (wait === 0) clear();
186
- else {
187
- widgetTimerKind = "clear";
188
- widgetTimer = setTimeout(clear, wait);
189
- widgetTimer.unref();
190
- }
191
- }
192
-
193
- function publishSidekickWidget(): void {
194
- const ctx = lastCtx;
195
- if (!ctx?.hasUI || typeof ctx.ui.setWidget !== "function") return;
196
- const progress = runtime?.progress();
197
- if (!runtime || !shouldShowSidekickWidget(runtime.isBusy(), attached) || !progress) {
198
- ctx.ui.setWidget("fusion-sidekick", undefined);
199
- return;
200
- }
201
- ctx.ui.setWidget("fusion-sidekick", (_tui, theme) => {
202
- const themeLike = theme as unknown as ThemeLike & { bg: (color: string, text: string) => string };
203
- return frameSidekick(themeLike, "working", renderSidekickTranscript(themeLike, {
204
- events: progress.events,
205
- droppedEvents: progress.droppedEvents,
206
- header: sidekickWorkingHeader(themeLike, progress),
207
- expanded: false,
208
- isPartial: true,
209
- renderText: markdownText,
210
- }));
211
- }, { placement: "aboveEditor" });
212
- }
213
-
214
- function publishSidekickWidgetLater(): void {
215
- const ctx = lastCtx;
216
- if (!ctx?.hasUI || typeof ctx.ui.setWidget !== "function") return;
217
- if (widgetTimer !== undefined && widgetTimerKind === "publish") return;
218
- if (widgetTimer !== undefined) {
219
- clearTimeout(widgetTimer);
220
- widgetTimer = undefined;
221
- widgetTimerKind = undefined;
222
- }
223
- const wait = Math.max(0, 150 - (Date.now() - widgetLastAt));
224
- const publish = () => {
225
- widgetTimer = undefined;
226
- widgetTimerKind = undefined;
227
- widgetLastAt = Date.now();
228
- publishSidekickWidget();
229
- };
230
- if (wait === 0) publish();
231
- else {
232
- widgetTimerKind = "publish";
233
- widgetTimer = setTimeout(publish, wait);
234
- widgetTimer.unref();
235
- }
236
- }
237
-
238
- function publishStatusLater(): void {
239
- if (lastCtx) {
240
- publishStatus(lastCtx);
241
- publishSidekickWidgetLater();
237
+ function publishStatusLater(ctx?: ExtensionContext): void {
238
+ const target = ctx ?? lastCtx;
239
+ if (target) {
240
+ publishStatus(target);
241
+ wakeLine.publish(target);
242
242
  }
243
243
  }
244
244
 
@@ -263,8 +263,7 @@ export default function fusionExtension(pi: ExtensionAPI): void {
263
263
  }
264
264
 
265
265
  function stopRuntime(): void {
266
- clearSidekickWidget();
267
- attached = false;
266
+ wakeLine.clear(lastCtx);
268
267
  runtime?.kill();
269
268
  runtime = undefined;
270
269
  leadToolCalls = 0;
@@ -285,20 +284,10 @@ export default function fusionExtension(pi: ExtensionAPI): void {
285
284
 
286
285
  registerFusionTools(pi, {
287
286
  getRuntime,
288
- onReport: (ctx) => {
289
- attached = false;
290
- clearSidekickWidget();
291
- publishStatus(ctx);
292
- },
287
+ onReport: (ctx) => publishStatusLater(ctx),
293
288
  onHandoffStart: (ctx) => publishStatus(ctx),
294
- onAttach: () => {
295
- attached = true;
296
- clearSidekickWidget();
297
- },
298
- onDetach: () => {
299
- attached = false;
300
- publishSidekickWidgetLater();
301
- },
289
+ onAttach: (ctx) => publishStatusLater(ctx),
290
+ onDetach: (ctx) => publishStatusLater(ctx),
302
291
  });
303
292
  pi.registerCommand("unipi:fusion-stats", {
304
293
  description: "Estimated Fusion savings (sidekick tokens priced at lead rates)",
@@ -308,6 +297,10 @@ export default function fusionExtension(pi: ExtensionAPI): void {
308
297
  pi.on("turn_start", () => {
309
298
  editNudgedThisTurn = false;
310
299
  });
300
+ // The wake line's condition flips when the LEAD goes idle, not when the
301
+ // sidekick reports progress, so re-evaluate on both turn end and settle.
302
+ pi.on("turn_end", (_event, ctx) => publishStatusLater(ctx));
303
+ pi.on("agent_settled", (_event, ctx) => publishStatusLater(ctx));
311
304
  pi.on("tool_result", (event) => {
312
305
  if (active?.kind !== "fusion") return;
313
306
  const toolName: string = event.toolName;