@pi-unipi/unipi 2.17.2 → 2.18.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 +20 -0
- package/package.json +24 -24
- package/packages/ask-user/package.json +2 -2
- package/packages/autocomplete/package.json +1 -1
- package/packages/background-tasks/package.json +2 -2
- package/packages/btw/README.md +3 -3
- package/packages/btw/extensions/btw.ts +3 -2
- package/packages/btw/package.json +2 -2
- package/packages/btw/skills/btw/SKILL.md +2 -0
- package/packages/compactor/package.json +3 -3
- package/packages/core/fusion-status.ts +6 -0
- package/packages/core/package.json +1 -1
- package/packages/footer/package.json +3 -3
- package/packages/footer/src/glance-editor.ts +22 -8
- package/packages/fusion/package.json +3 -3
- package/packages/fusion/src/index.ts +43 -6
- package/packages/fusion/src/nudge.ts +7 -0
- package/packages/fusion/src/picker.ts +61 -30
- package/packages/fusion/src/prompts.ts +19 -3
- package/packages/fusion/src/sidekick-runtime.ts +83 -7
- package/packages/fusion/src/tools.ts +67 -14
- package/packages/fusion/src/transcript.ts +108 -0
- 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/agents/reviewer.md +1 -0
- package/packages/subagents/package.json +2 -2
- package/packages/subagents/src/index.ts +2 -0
- package/packages/subagents/src/pi-args.ts +6 -0
- package/packages/trajectory/package.json +1 -1
- package/packages/unipi/bundled.js +704 -339
- package/packages/updater/package.json +2 -2
- package/packages/updater/src/tui/changelog-overlay.ts +2 -2
- package/packages/updater/src/tui/list-detail-overlay.ts +12 -8
- package/packages/updater/src/tui/readme-overlay.ts +2 -2
- package/packages/updater/src/tui/update-overlay.ts +30 -25
- package/packages/utility/package.json +2 -2
- package/packages/web-api/package.json +2 -2
- package/packages/workflow/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [2.18.1] — 2026-09-16
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- `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`.
|
|
14
|
+
- `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).
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- `btw`: the side-conversation session is read-only (`read, grep, find, ls`, no bash/edit/write) with an explicit read-only system instruction (#37).
|
|
19
|
+
|
|
20
|
+
## [2.18.0] — 2026-09-16
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- `fusion`: **sidekick blocks have their own tint.** `sidekick`/`read_subagent` use pi's `renderShell: "self"` and paint the theme's `customMessageBg` (violet in the dark theme) instead of the grey/green/red ordinary tool backgrounds, with a `▍` left rail coloured by state (accent working · success completed · error failed) — `frameSidekick()`/`RailComponent` in `src/transcript.ts`; the completion card uses the same frame.
|
|
25
|
+
- `fusion`: **a running sidekick now renders like a regular agent transcript.** The `sidekick`/`read_subagent` tool blocks and the `sidekick-completion` card previously squeezed everything into one card — six 40-char tool summaries and a 400-char text tail with the sidekick's messages run together. The runtime now keeps a structured event log per handoff (`SidekickEvent`: assistant text blocks and tool calls with primary argument, trimmed output, error/running state; capped at 300 events / 4000 chars of output) and `renderSidekickTranscript()` in `src/transcript.ts` renders it: each tool on its own line (`bash ls -la /tmp/x` + last 3 output lines), each assistant message as its own markdown paragraph, `⋯ running` on the in-flight tool. Collapsed shows the last 8 steps while running and the report text when finished; pi's expand toggle (Ctrl+O) shows the full transcript.
|
|
26
|
+
- `fusion`/`footer`: **the footer now shows which half of the Fusion pair is working.** While a handoff runs the sidekick is lit with ` · working` and the lead dims; the slot also shows the lead/sidekick tool-call split (`◆ DeepSeek Flash high · 61/185 calls`). `SharedFusionStatus` gains `busy`, `leadToolCalls`, `sidekickToolCalls`; the runtime republishes on every sidekick tool call (`onProgress`) and on handoff start/end (`SidekickRuntime.totalToolCalls()`, `renderFusionStatus()` in `glance-editor.ts`). Motivated by a real session where the lead ran 124 shell commands itself and delegated once — the static `Fusion · Lead ◆ Sidekick` label made that invisible.
|
|
27
|
+
- `fusion`: **delegation nudges are now recurring, matching Devin's harness.** The one-time first-edit reminder is replaced by `EDIT_NUDGE` on every direct `edit`/`write` (at most once per agent turn) plus `bashNudge(n)` after every 4 consecutive non-trivial lead `bash` calls without a handoff (`isTrivialShell()` in `src/nudge.ts` exempts read-only commands such as `git status`, `ls`, `rg`); a `sidekick`/`read_subagent` call resets the streak.
|
|
28
|
+
|
|
9
29
|
## [2.17.2] — 2026-09-15
|
|
10
30
|
|
|
11
31
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/unipi",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.18.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.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"
|
|
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.18.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.
|
|
43
|
+
"@pi-unipi/core": "2.18.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/background-tasks",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.18.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.
|
|
13
|
+
"@pi-unipi/core": "2.18.0",
|
|
14
14
|
"turndown": "^7.2.4"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
package/packages/btw/README.md
CHANGED
|
@@ -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
|
|
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", "
|
|
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.
|
|
3
|
+
"version": "2.18.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.18.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.
|
|
3
|
+
"version": "2.18.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.
|
|
38
|
-
"@pi-unipi/info-screen": "2.
|
|
37
|
+
"@pi-unipi/core": "2.18.0",
|
|
38
|
+
"@pi-unipi/info-screen": "2.18.0",
|
|
39
39
|
"@earendil-works/pi-agent-core": "^0.84.0"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
@@ -18,6 +18,12 @@ export interface SharedFusionStatus {
|
|
|
18
18
|
sidekickEffort: string;
|
|
19
19
|
/** Estimated savings compared with pricing all sidekick usage at lead rates. */
|
|
20
20
|
savedUsd?: number;
|
|
21
|
+
/** A handoff is running on the sidekick right now. */
|
|
22
|
+
busy?: boolean;
|
|
23
|
+
/** Tool calls made directly by the lead in this session while Fusion was active. */
|
|
24
|
+
leadToolCalls?: number;
|
|
25
|
+
/** Tool calls made by the sidekick across all handoffs (completed + in flight). */
|
|
26
|
+
sidekickToolCalls?: number;
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
const KEY = Symbol.for("unipi.fusion.status");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/footer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.18.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.
|
|
36
|
-
"@pi-unipi/background-tasks": "2.
|
|
35
|
+
"@pi-unipi/core": "2.18.0",
|
|
36
|
+
"@pi-unipi/background-tasks": "2.18.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
@@ -40,7 +40,7 @@ export interface GlanceStatus {
|
|
|
40
40
|
* `Fusion · <lead> <effort> ◆ <sidekick>` — lead lit, sidekick muted —
|
|
41
41
|
* and the plain thinking slot is suppressed (efforts are inline).
|
|
42
42
|
*/
|
|
43
|
-
fusion: { leadName: string; leadEffort: string; sidekickName: string; sidekickEffort: string; savedUsd?: number } | null;
|
|
43
|
+
fusion: { leadName: string; leadEffort: string; sidekickName: string; sidekickEffort: string; savedUsd?: number; busy?: boolean; leadToolCalls?: number; sidekickToolCalls?: number } | null;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
const BORDER = {
|
|
@@ -57,6 +57,26 @@ const RESET = "\x1b[0m";
|
|
|
57
57
|
const LEAD_FG = "\x1b[1m\x1b[38;2;181;189;104m"; // bold accent (matches success tint)
|
|
58
58
|
const DIM_FG = "\x1b[38;2;120;124;134m"; // muted grey
|
|
59
59
|
|
|
60
|
+
type FusionDisplay = NonNullable<GlanceStatus["fusion"]>;
|
|
61
|
+
|
|
62
|
+
export function renderFusionStatus(fusion: FusionDisplay): string {
|
|
63
|
+
const lead = `${fusion.leadName}${fusion.leadEffort ? ` ${fusion.leadEffort}` : ""}`;
|
|
64
|
+
const side = `${fusion.sidekickName}${fusion.sidekickEffort ? ` ${fusion.sidekickEffort}` : ""}`;
|
|
65
|
+
const busy = fusion.busy === true;
|
|
66
|
+
const leadCalls = fusion.leadToolCalls ?? 0;
|
|
67
|
+
const sidekickCalls = fusion.sidekickToolCalls ?? 0;
|
|
68
|
+
const totalCalls = leadCalls + sidekickCalls;
|
|
69
|
+
const saved = fusion.savedUsd !== undefined && fusion.savedUsd > 0.005
|
|
70
|
+
? ` · saved $${fusion.savedUsd.toFixed(2)}`
|
|
71
|
+
: "";
|
|
72
|
+
const split = totalCalls > 0 ? ` · ${String(sidekickCalls)}/${String(totalCalls)} calls` : "";
|
|
73
|
+
const core = busy
|
|
74
|
+
? `${DIM_FG}Fusion · ${lead}${RESET} ${LEAD_FG}◆ ${side} · working${RESET}`
|
|
75
|
+
: `${LEAD_FG}Fusion · ${lead}${RESET} ${DIM_FG}◆ ${side}${RESET}`;
|
|
76
|
+
const tail = `${split}${saved}`;
|
|
77
|
+
return tail ? `${core}${DIM_FG}${tail}${RESET}` : core;
|
|
78
|
+
}
|
|
79
|
+
|
|
60
80
|
/**
|
|
61
81
|
* Every invisible sequence pi-tui embeds in editor lines: CSI SGR, OSC 133
|
|
62
82
|
* prompt-zone markers (`\x1b]133;A\x07`), and CURSOR_MARKER (`\x1b_pi:c\x07`,
|
|
@@ -226,13 +246,7 @@ export class GlanceEditor extends CustomEditor {
|
|
|
226
246
|
const winLabel = st.contextWindow > 0 ? `/${fmtTokens(st.contextWindow)}` : "";
|
|
227
247
|
rightParts.push(`${pctLabel}${winLabel}`);
|
|
228
248
|
if (st.fusion) {
|
|
229
|
-
|
|
230
|
-
// inline so the separate thinking slot is dropped.
|
|
231
|
-
const lead = `${st.fusion.leadName}${st.fusion.leadEffort ? ` ${st.fusion.leadEffort}` : ""}`;
|
|
232
|
-
const side = `${st.fusion.sidekickName}${st.fusion.sidekickEffort ? ` ${st.fusion.sidekickEffort}` : ""}`;
|
|
233
|
-
rightParts.push(
|
|
234
|
-
`${LEAD_FG}Fusion · ${lead}${RESET} ${DIM_FG}◆ ${side}${st.fusion.savedUsd !== undefined && st.fusion.savedUsd > 0.005 ? ` · saved $${st.fusion.savedUsd.toFixed(2)}` : ""}${RESET}`,
|
|
235
|
-
);
|
|
249
|
+
rightParts.push(renderFusionStatus(st.fusion));
|
|
236
250
|
} else {
|
|
237
251
|
if (st.modelName) rightParts.push(st.modelName);
|
|
238
252
|
if (st.thinkingLevel && st.thinkingLevel !== "off") {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/fusion",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.18.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.18.0",
|
|
36
|
+
"@pi-unipi/subagents": "2.18.1"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@earendil-works/pi-ai": "^0.84.0",
|
|
@@ -37,7 +37,8 @@ import { ModelPicker, type PickerModel, type PickerResult } from "./picker.js";
|
|
|
37
37
|
import { PresetEditor, type PresetEditorResult } from "./preset-editor.js";
|
|
38
38
|
import { SidekickRuntime } from "./sidekick-runtime.js";
|
|
39
39
|
import { estimateSavings } from "./savings.js";
|
|
40
|
-
import {
|
|
40
|
+
import { EDIT_NUDGE, bashNudge, leadPolicy, sidekickSystemPrompt, type FusionIdentity } from "./prompts.js";
|
|
41
|
+
import { isTrivialShell, BASH_NUDGE_EVERY } from "./nudge.js";
|
|
41
42
|
import { registerFusionTools } from "./tools.js";
|
|
42
43
|
|
|
43
44
|
export const MODEL_COMMAND = `${UNIPI_PREFIX}model`;
|
|
@@ -113,7 +114,10 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
113
114
|
|
|
114
115
|
let active: ActiveSelection | undefined;
|
|
115
116
|
let runtime: SidekickRuntime | undefined;
|
|
116
|
-
let
|
|
117
|
+
let lastCtx: ExtensionContext | undefined;
|
|
118
|
+
let leadToolCalls = 0;
|
|
119
|
+
let editNudgedThisTurn = false;
|
|
120
|
+
let bashStreak = 0;
|
|
117
121
|
|
|
118
122
|
function identity(ctx: ExtensionContext): FusionIdentity {
|
|
119
123
|
const reg = registryOf(ctx);
|
|
@@ -136,6 +140,7 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
136
140
|
}
|
|
137
141
|
|
|
138
142
|
function publishStatus(ctx: ExtensionContext): void {
|
|
143
|
+
lastCtx = ctx;
|
|
139
144
|
const reg = registryOf(ctx);
|
|
140
145
|
const names = (k: string) => findModel(reg, k)?.name || splitModelKey(k)?.id || k;
|
|
141
146
|
if (active?.kind === "fusion") {
|
|
@@ -147,12 +152,19 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
147
152
|
sidekickName: names(active.sidekick),
|
|
148
153
|
sidekickEffort: active.sidekickEffort ?? "",
|
|
149
154
|
savedUsd: statusSavings(ctx),
|
|
155
|
+
busy: runtime?.isBusy() ?? false,
|
|
156
|
+
leadToolCalls,
|
|
157
|
+
sidekickToolCalls: runtime?.totalToolCalls() ?? 0,
|
|
150
158
|
});
|
|
151
159
|
} else {
|
|
152
160
|
setSharedFusionStatus(undefined);
|
|
153
161
|
}
|
|
154
162
|
}
|
|
155
163
|
|
|
164
|
+
function publishStatusLater(): void {
|
|
165
|
+
if (lastCtx) publishStatus(lastCtx);
|
|
166
|
+
}
|
|
167
|
+
|
|
156
168
|
function leadSessionId(ctx: ExtensionContext): string {
|
|
157
169
|
const manager = ctx.sessionManager as { getSessionId?: () => string | undefined } | undefined;
|
|
158
170
|
return manager?.getSessionId?.() ?? "default";
|
|
@@ -167,6 +179,7 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
167
179
|
thinking: active.sidekickEffort ?? "medium",
|
|
168
180
|
sessionFile: sidekickSessionPath(leadSessionId(ctx)),
|
|
169
181
|
systemPrompt: sidekickSystemPrompt(identity(ctx)),
|
|
182
|
+
onProgress: () => publishStatusLater(),
|
|
170
183
|
});
|
|
171
184
|
}
|
|
172
185
|
return runtime;
|
|
@@ -175,6 +188,7 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
175
188
|
function stopRuntime(): void {
|
|
176
189
|
runtime?.kill();
|
|
177
190
|
runtime = undefined;
|
|
191
|
+
leadToolCalls = 0;
|
|
178
192
|
}
|
|
179
193
|
|
|
180
194
|
function savingsStats(ctx: ExtensionContext): string {
|
|
@@ -193,16 +207,38 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
193
207
|
registerFusionTools(pi, {
|
|
194
208
|
getRuntime,
|
|
195
209
|
onReport: (ctx) => publishStatus(ctx),
|
|
210
|
+
onHandoffStart: (ctx) => publishStatus(ctx),
|
|
196
211
|
});
|
|
197
212
|
pi.registerCommand("unipi:fusion-stats", {
|
|
198
213
|
description: "Estimated Fusion savings (sidekick tokens priced at lead rates)",
|
|
199
214
|
handler: async (_args, ctx) => ctx.ui.notify(savingsStats(ctx), "info"),
|
|
200
215
|
});
|
|
201
216
|
pi.on("before_agent_start", (event, ctx) => active?.kind === "fusion" ? { systemPrompt: `${event.systemPrompt}\n\n${leadPolicy(identity(ctx))}` } : undefined);
|
|
217
|
+
pi.on("turn_start", () => {
|
|
218
|
+
editNudgedThisTurn = false;
|
|
219
|
+
});
|
|
202
220
|
pi.on("tool_result", (event) => {
|
|
203
|
-
if (active?.kind !== "fusion"
|
|
204
|
-
|
|
205
|
-
|
|
221
|
+
if (active?.kind !== "fusion") return;
|
|
222
|
+
const toolName: string = event.toolName;
|
|
223
|
+
if (toolName === "sidekick" || toolName === "read_subagent") {
|
|
224
|
+
bashStreak = 0;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
leadToolCalls += 1;
|
|
228
|
+
publishStatusLater();
|
|
229
|
+
if (toolName === "edit" || toolName === "write") {
|
|
230
|
+
if (editNudgedThisTurn) return;
|
|
231
|
+
editNudgedThisTurn = true;
|
|
232
|
+
return { content: [...event.content, { type: "text" as const, text: EDIT_NUDGE }] };
|
|
233
|
+
}
|
|
234
|
+
if (toolName !== "bash") return;
|
|
235
|
+
const command = typeof event.input.command === "string" ? event.input.command : "";
|
|
236
|
+
if (isTrivialShell(command)) return;
|
|
237
|
+
bashStreak += 1;
|
|
238
|
+
if (bashStreak < BASH_NUDGE_EVERY) return;
|
|
239
|
+
const content = [...event.content, { type: "text" as const, text: bashNudge(bashStreak) }];
|
|
240
|
+
bashStreak = 0;
|
|
241
|
+
return { content };
|
|
206
242
|
});
|
|
207
243
|
|
|
208
244
|
async function applyResult(ctx: ExtensionContext, result: PickerResult, preset: FusionPreset, loaded: { globalPath: string; projectPath: string; hasProjectLayer: boolean }): Promise<void> {
|
|
@@ -355,7 +391,8 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
355
391
|
|
|
356
392
|
pi.on("session_start", async (_e, ctx) => {
|
|
357
393
|
stopRuntime();
|
|
358
|
-
|
|
394
|
+
editNudgedThisTurn = false;
|
|
395
|
+
bashStreak = 0;
|
|
359
396
|
modelBykey.clear();
|
|
360
397
|
active = loadPreset(ctx.cwd ?? process.cwd()).preset.active;
|
|
361
398
|
if (active?.kind === "fusion" && (!ctx.model || modelKey(ctx.model) !== active.lead)) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const BASH_NUDGE_EVERY = 4;
|
|
2
|
+
|
|
3
|
+
const TRIVIAL = /^\s*(?:cd\s+\S+\s*(?:&&|;)\s*)?(?:git\s+(?:status|log|diff|branch|show|remote|rev-parse)|ls|pwd|cat|head|tail|wc|echo|which|type|rg|grep|find|stat|file|du|df|env|printenv|date|whoami|tmux\s+capture-pane|npm\s+(?:view|whoami|ls))\b[^|;&]*$/;
|
|
4
|
+
|
|
5
|
+
export function isTrivialShell(command: string): boolean {
|
|
6
|
+
return TRIVIAL.test(command.trim());
|
|
7
|
+
}
|
|
@@ -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
|
|
18
|
-
*
|
|
19
|
-
* EVERY other available model — the
|
|
20
|
-
* only controls ordering. Typing filters
|
|
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
|
|
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
|
-
?
|
|
422
|
-
?
|
|
423
|
-
|
|
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 =
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
509
|
-
? t.fg("
|
|
510
|
-
:
|
|
511
|
-
? t.fg("dim", "
|
|
512
|
-
:
|
|
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.
|
|
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");
|