@pi-unipi/unipi 2.18.1 → 2.19.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 (40) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/package.json +24 -24
  3. package/packages/ask-user/package.json +2 -2
  4. package/packages/autocomplete/package.json +1 -1
  5. package/packages/background-tasks/package.json +2 -2
  6. package/packages/btw/package.json +2 -2
  7. package/packages/compactor/package.json +3 -3
  8. package/packages/core/package.json +1 -1
  9. package/packages/footer/package.json +3 -3
  10. package/packages/fusion/README.md +9 -2
  11. package/packages/fusion/package.json +3 -3
  12. package/packages/fusion/src/index.ts +93 -2
  13. package/packages/fusion/src/sidekick-runtime.ts +62 -19
  14. package/packages/fusion/src/sidekick-widget.ts +3 -0
  15. package/packages/fusion/src/tools.ts +69 -38
  16. package/packages/fusion/src/transcript.ts +15 -2
  17. package/packages/image/package.json +2 -2
  18. package/packages/info-screen/package.json +2 -2
  19. package/packages/input-shortcuts/package.json +2 -2
  20. package/packages/kanboard/package.json +2 -2
  21. package/packages/mcp/package.json +2 -2
  22. package/packages/memory/package.json +3 -3
  23. package/packages/milestone/package.json +2 -2
  24. package/packages/notify/README.md +21 -1
  25. package/packages/notify/activity.ts +21 -0
  26. package/packages/notify/events.ts +137 -14
  27. package/packages/notify/index.ts +4 -0
  28. package/packages/notify/package.json +2 -2
  29. package/packages/notify/settings.ts +31 -1
  30. package/packages/notify/skills/configure-notify/SKILL.md +29 -0
  31. package/packages/notify/tui/settings-overlay.ts +104 -3
  32. package/packages/notify/types.ts +12 -0
  33. package/packages/ralph/package.json +3 -3
  34. package/packages/subagents/package.json +2 -2
  35. package/packages/trajectory/package.json +1 -1
  36. package/packages/unipi/bundled.js +3547 -1339
  37. package/packages/updater/package.json +2 -2
  38. package/packages/utility/package.json +2 -2
  39. package/packages/web-api/package.json +2 -2
  40. package/packages/workflow/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -6,6 +6,38 @@ 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.1] — 2026-09-16
10
+
11
+ ### Changed
12
+
13
+ - `fusion`: **sidekick work renders as ordinary tool activity — the two-agent chrome is gone.** `sidekick`/`read_subagent` results no longer wrap output in the `▍` rail + `customMessageBg` frame; during a blocking wait the sidekick's tool calls and messages render inline like the lead's own blocks, matching Devin's single-agent presentation. Report headers dropped the handoff id (tool-call count, duration, and token usage remain), and `read_subagent`'s call line shows `· waiting`/`· snapshot` instead of the id.
14
+
15
+ ### Fixed
16
+
17
+ - `fusion`: **handoff internals no longer leak into the terminal.** Model-facing result text still carries ids and protocol instructions the lead needs, but a new `displayText()` sanitizer strips handoff uuids, `agent_id` markers, `<subagent_completion_notification>` tags, and `use read_subagent` instructions from anything rendered as plain content, rewriting `Handoff <uuid> failed` to `The handoff failed`. The `block:false` result renders as a one-line `◆ sidekick · continuing in background` instead of the raw "Handoff <uuid> started… use read_subagent to wait" text.
18
+
19
+ ### Added
20
+
21
+ - `fusion`: **detached handoffs stay visible in a live widget above the editor.** A `block:false` handoff previously left the terminal showing only "started in the background" while the sidekick kept working — a real session looked idle for minutes mid-task. A `fusion-sidekick` widget (`ctx.ui.setWidget`, `aboveEditor`) now streams the same transcript until the completion card arrives; it is suppressed while a blocking wait is attached (`shouldShowSidekickWidget` in `sidekick-widget.ts`) and cleared when the report lands.
22
+
23
+ ## [2.19.0] — 2026-09-16
24
+
25
+ ### Added
26
+
27
+ - `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.
28
+
29
+ ### Changed
30
+
31
+ - `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.
32
+ - `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.
33
+ - `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.
34
+
35
+ ### Fixed
36
+
37
+ - `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).
38
+ - `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.
39
+ - `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.
40
+
9
41
  ## [2.18.1] — 2026-09-16
10
42
 
11
43
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "2.18.1",
3
+ "version": "2.19.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.18.0",
88
- "@pi-unipi/background-tasks": "2.18.0",
89
- "@pi-unipi/btw": "2.18.1",
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.1",
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.1",
105
- "@pi-unipi/trajectory": "2.18.0",
106
- "@pi-unipi/updater": "2.18.1",
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.1",
88
+ "@pi-unipi/background-tasks": "2.19.1",
89
+ "@pi-unipi/btw": "2.19.1",
90
+ "@pi-unipi/command-enchantment": "2.19.1",
91
+ "@pi-unipi/compactor": "2.19.1",
92
+ "@pi-unipi/core": "2.19.1",
93
+ "@pi-unipi/footer": "2.19.1",
94
+ "@pi-unipi/image": "2.19.1",
95
+ "@pi-unipi/fusion": "2.19.1",
96
+ "@pi-unipi/info-screen": "2.19.1",
97
+ "@pi-unipi/input-shortcuts": "2.19.1",
98
+ "@pi-unipi/kanboard": "2.19.1",
99
+ "@pi-unipi/mcp": "2.19.1",
100
+ "@pi-unipi/memory": "2.19.1",
101
+ "@pi-unipi/milestone": "2.19.1",
102
+ "@pi-unipi/notify": "2.19.1",
103
+ "@pi-unipi/ralph": "2.19.1",
104
+ "@pi-unipi/subagents": "2.19.1",
105
+ "@pi-unipi/trajectory": "2.19.1",
106
+ "@pi-unipi/updater": "2.19.1",
107
+ "@pi-unipi/utility": "2.19.1",
108
+ "@pi-unipi/web-api": "2.19.1",
109
+ "@pi-unipi/workflow": "2.19.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.18.0",
3
+ "version": "2.19.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.18.0"
43
+ "@pi-unipi/core": "2.19.1"
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.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.18.0",
3
+ "version": "2.19.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.18.0",
13
+ "@pi-unipi/core": "2.19.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.18.1",
3
+ "version": "2.19.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.18.0"
40
+ "@pi-unipi/core": "2.19.1"
41
41
  },
42
42
  "pi": {
43
43
  "extensions": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/compactor",
3
- "version": "2.18.0",
3
+ "version": "2.19.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.18.0",
38
- "@pi-unipi/info-screen": "2.18.0",
37
+ "@pi-unipi/core": "2.19.1",
38
+ "@pi-unipi/info-screen": "2.19.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.18.0",
3
+ "version": "2.19.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.18.0",
3
+ "version": "2.19.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.18.0",
36
- "@pi-unipi/background-tasks": "2.18.0"
35
+ "@pi-unipi/core": "2.19.1",
36
+ "@pi-unipi/background-tasks": "2.19.1"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -92,8 +92,15 @@ 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. Sidekick work
99
+ 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({agent_id?, block?, timeout?})` reads or waits for a handoff.
97
104
 
98
105
  The child receives `UNIPI_FUSION_CHILD=1` and `UNIPI_SUBAGENT_CHILD=1`; the
99
106
  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.1",
3
+ "version": "2.19.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.18.0",
36
- "@pi-unipi/subagents": "2.18.1"
35
+ "@pi-unipi/core": "2.19.1",
36
+ "@pi-unipi/subagents": "2.19.1"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-ai": "^0.84.0",
@@ -40,6 +40,8 @@ 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
45
 
44
46
  export const MODEL_COMMAND = `${UNIPI_PREFIX}model`;
45
47
  export const PRESET_COMMAND = `${UNIPI_PREFIX}fusion-preset`;
@@ -118,6 +120,10 @@ export default function fusionExtension(pi: ExtensionAPI): void {
118
120
  let leadToolCalls = 0;
119
121
  let editNudgedThisTurn = false;
120
122
  let bashStreak = 0;
123
+ let attached = false;
124
+ let widgetTimer: NodeJS.Timeout | undefined;
125
+ let widgetTimerKind: "publish" | "clear" | undefined;
126
+ let widgetLastAt = 0;
121
127
 
122
128
  function identity(ctx: ExtensionContext): FusionIdentity {
123
129
  const reg = registryOf(ctx);
@@ -161,8 +167,79 @@ export default function fusionExtension(pi: ExtensionAPI): void {
161
167
  }
162
168
  }
163
169
 
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
+
164
238
  function publishStatusLater(): void {
165
- if (lastCtx) publishStatus(lastCtx);
239
+ if (lastCtx) {
240
+ publishStatus(lastCtx);
241
+ publishSidekickWidgetLater();
242
+ }
166
243
  }
167
244
 
168
245
  function leadSessionId(ctx: ExtensionContext): string {
@@ -186,6 +263,8 @@ export default function fusionExtension(pi: ExtensionAPI): void {
186
263
  }
187
264
 
188
265
  function stopRuntime(): void {
266
+ clearSidekickWidget();
267
+ attached = false;
189
268
  runtime?.kill();
190
269
  runtime = undefined;
191
270
  leadToolCalls = 0;
@@ -206,8 +285,20 @@ export default function fusionExtension(pi: ExtensionAPI): void {
206
285
 
207
286
  registerFusionTools(pi, {
208
287
  getRuntime,
209
- onReport: (ctx) => publishStatus(ctx),
288
+ onReport: (ctx) => {
289
+ attached = false;
290
+ clearSidekickWidget();
291
+ publishStatus(ctx);
292
+ },
210
293
  onHandoffStart: (ctx) => publishStatus(ctx),
294
+ onAttach: () => {
295
+ attached = true;
296
+ clearSidekickWidget();
297
+ },
298
+ onDetach: () => {
299
+ attached = false;
300
+ publishSidekickWidgetLater();
301
+ },
211
302
  });
212
303
  pi.registerCommand("unipi:fusion-stats", {
213
304
  description: "Estimated Fusion savings (sidekick tokens priced at lead rates)",
@@ -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 {
@@ -0,0 +1,3 @@
1
+ export function shouldShowSidekickWidget(busy: boolean, attached: boolean): boolean {
2
+ return busy && !attached;
3
+ }