@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 +17 -0
- package/README.md +9 -1
- package/package.json +24 -24
- package/packages/ask-user/package.json +2 -2
- package/packages/ask-user/tools.ts +30 -0
- package/packages/autocomplete/package.json +1 -1
- package/packages/background-tasks/package.json +2 -2
- package/packages/btw/package.json +2 -2
- package/packages/compactor/package.json +3 -3
- package/packages/core/package.json +1 -1
- package/packages/footer/package.json +3 -3
- package/packages/fusion/README.md +9 -4
- package/packages/fusion/package.json +3 -3
- package/packages/fusion/src/index.ts +87 -94
- package/packages/fusion/src/tools.ts +77 -10
- package/packages/fusion/src/transcript.ts +13 -11
- package/packages/image/package.json +2 -2
- package/packages/info-screen/package.json +2 -2
- package/packages/input-shortcuts/package.json +2 -2
- package/packages/kanboard/package.json +2 -2
- package/packages/mcp/package.json +2 -2
- package/packages/memory/package.json +3 -3
- package/packages/milestone/package.json +2 -2
- package/packages/notify/package.json +2 -2
- package/packages/ralph/package.json +3 -3
- package/packages/subagents/package.json +2 -2
- package/packages/trajectory/package.json +1 -1
- package/packages/unipi/bundled.js +1377 -3330
- package/packages/updater/package.json +2 -2
- package/packages/utility/package.json +2 -2
- package/packages/web-api/package.json +2 -2
- package/packages/workflow/package.json +2 -2
- package/packages/fusion/src/sidekick-widget.ts +0 -3
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
|
-
|
|
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.
|
|
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.
|
|
88
|
-
"@pi-unipi/background-tasks": "2.
|
|
89
|
-
"@pi-unipi/btw": "2.
|
|
90
|
-
"@pi-unipi/command-enchantment": "2.
|
|
91
|
-
"@pi-unipi/compactor": "2.
|
|
92
|
-
"@pi-unipi/core": "2.
|
|
93
|
-
"@pi-unipi/footer": "2.
|
|
94
|
-
"@pi-unipi/image": "2.
|
|
95
|
-
"@pi-unipi/fusion": "2.
|
|
96
|
-
"@pi-unipi/info-screen": "2.
|
|
97
|
-
"@pi-unipi/input-shortcuts": "2.
|
|
98
|
-
"@pi-unipi/kanboard": "2.
|
|
99
|
-
"@pi-unipi/mcp": "2.
|
|
100
|
-
"@pi-unipi/memory": "2.
|
|
101
|
-
"@pi-unipi/milestone": "2.
|
|
102
|
-
"@pi-unipi/notify": "2.
|
|
103
|
-
"@pi-unipi/ralph": "2.
|
|
104
|
-
"@pi-unipi/subagents": "2.
|
|
105
|
-
"@pi-unipi/trajectory": "2.
|
|
106
|
-
"@pi-unipi/updater": "2.
|
|
107
|
-
"@pi-unipi/utility": "2.
|
|
108
|
-
"@pi-unipi/web-api": "2.
|
|
109
|
-
"@pi-unipi/workflow": "2.
|
|
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.
|
|
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.
|
|
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/background-tasks",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
38
|
-
"@pi-unipi/info-screen": "2.
|
|
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/footer",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
36
|
-
"@pi-unipi/background-tasks": "2.
|
|
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.
|
|
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.
|
|
101
|
-
|
|
102
|
-
|
|
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.
|
|
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.
|
|
36
|
-
"@pi-unipi/subagents": "2.
|
|
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 {
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|