@pi-unipi/unipi 2.17.1 → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -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/package.json +2 -2
- 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/footer/src/index.ts +14 -7
- 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/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/package.json +2 -2
- package/packages/trajectory/package.json +1 -1
- package/packages/unipi/bundled.js +410 -119
- package/packages/updater/package.json +2 -2
- package/packages/updater/src/changelog.ts +7 -4
- package/packages/updater/src/index.ts +3 -1
- package/packages/updater/src/remote-changelog.ts +61 -0
- package/packages/updater/src/tui/update-overlay.ts +12 -10
- 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,22 @@ 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.0] — 2026-09-16
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- `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.
|
|
14
|
+
- `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.
|
|
15
|
+
- `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.
|
|
16
|
+
- `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.
|
|
17
|
+
|
|
18
|
+
## [2.17.2] — 2026-09-15
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- `footer`: **the startup update prompt no longer becomes unclosable after ~3.5s.** The deferred Glance editor install (`installGlanceEditor` in `src/index.ts`) calls `setEditorComponent()`, which internally re-focuses the editor and silently stole keyboard focus from whatever overlay was open — the updater prompt kept rendering while `q`/`n`/Esc typed into the input box. The install now snapshots the focused overlay (`tui.isOverlayFocused()` / `getFocusedComponent()`) and restores it after the swap, protecting the updater prompt, the boot dashboard, and any other overlay. New `tests/glance-focus.test.ts`.
|
|
23
|
+
- `updater`: **the update prompt now shows the release notes for the version being offered.** It previously parsed the `CHANGELOG.md` shipped with the *installed* package, which by definition never contains the newer version's entry, so it only ever showed an empty `Unreleased` header. New `src/remote-changelog.ts` fetches `raw.githubusercontent.com/Neuron-Mr-White/unipi/v<latest>/CHANGELOG.md` (5s timeout, falls back to `main` when the tag is not pushed yet, then to the local file when offline) and caches tag-served content under `~/.unipi/cache/updater/changelog-<version>.md`. `parseChangelog()` is split into `parseChangelogContent()` + a file wrapper; `getNewerVersions()` drops an empty `Unreleased` entry; the empty state reads `No changelog available for <version> (offline?)`.
|
|
24
|
+
|
|
9
25
|
## [2.17.1] — 2026-09-15
|
|
10
26
|
|
|
11
27
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/unipi",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.18.0",
|
|
4
4
|
"description": "All-in-one extension suite for Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -84,29 +84,29 @@
|
|
|
84
84
|
"typebox": "^1.1.38"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@pi-unipi/ask-user": "2.
|
|
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.0",
|
|
90
|
+
"@pi-unipi/command-enchantment": "2.18.0",
|
|
91
|
+
"@pi-unipi/compactor": "2.18.0",
|
|
92
|
+
"@pi-unipi/core": "2.18.0",
|
|
93
|
+
"@pi-unipi/footer": "2.18.0",
|
|
94
|
+
"@pi-unipi/image": "2.18.0",
|
|
95
|
+
"@pi-unipi/fusion": "2.18.0",
|
|
96
|
+
"@pi-unipi/info-screen": "2.18.0",
|
|
97
|
+
"@pi-unipi/input-shortcuts": "2.18.0",
|
|
98
|
+
"@pi-unipi/kanboard": "2.18.0",
|
|
99
|
+
"@pi-unipi/mcp": "2.18.0",
|
|
100
|
+
"@pi-unipi/memory": "2.18.0",
|
|
101
|
+
"@pi-unipi/milestone": "2.18.0",
|
|
102
|
+
"@pi-unipi/notify": "2.18.0",
|
|
103
|
+
"@pi-unipi/ralph": "2.18.0",
|
|
104
|
+
"@pi-unipi/subagents": "2.18.0",
|
|
105
|
+
"@pi-unipi/trajectory": "2.18.0",
|
|
106
|
+
"@pi-unipi/updater": "2.18.0",
|
|
107
|
+
"@pi-unipi/utility": "2.18.0",
|
|
108
|
+
"@pi-unipi/web-api": "2.18.0",
|
|
109
|
+
"@pi-unipi/workflow": "2.18.0"
|
|
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": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/btw",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.18.0",
|
|
4
4
|
"description": "A pi extension for parallel side conversations with /unipi:btw — part of the Unipi suite",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "extensions/btw.ts",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"@earendil-works/pi-tui": "^0.84.0"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@pi-unipi/core": "2.
|
|
40
|
+
"@pi-unipi/core": "2.18.0"
|
|
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.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") {
|
|
@@ -153,13 +153,10 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
153
153
|
// Glance-style input surface (pi-glance-inspired). Preserves all default
|
|
154
154
|
// editor behavior via CustomEditor subclassing; only paint differs.
|
|
155
155
|
//
|
|
156
|
-
// FOCUS-SAFETY DEFERRAL:
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
// and strand the dashboard unclosable (q/Esc would type into the editor).
|
|
161
|
-
// The boot overlay auto-closes after ~2s; we install after a grace period
|
|
162
|
-
// longer than any sane bootTimeoutMs.
|
|
156
|
+
// FOCUS-SAFETY DEFERRAL: the timer remains a grace period for the boot
|
|
157
|
+
// dashboard, but installGlanceEditor also restores any overlay focus after
|
|
158
|
+
// setEditorComponent() calls ui.setFocus(newEditor). This covers the
|
|
159
|
+
// updater prompt too, so q/Esc cannot be stranded in the editor.
|
|
163
160
|
state.glanceInstallTimer = setTimeout(() => installGlanceEditor(state, ctx), 3500);
|
|
164
161
|
|
|
165
162
|
// Sync TPS cursor with persisted assistant messages so streaming-hook
|
|
@@ -420,6 +417,15 @@ function installGlanceEditor(
|
|
|
420
417
|
): void {
|
|
421
418
|
if (st.glanceInstalled || !st.piContext || !st.glanceMode) return;
|
|
422
419
|
try {
|
|
420
|
+
const tui = st.tuiRef as (import("@earendil-works/pi-tui").TUI & {
|
|
421
|
+
isOverlayFocused?: () => boolean;
|
|
422
|
+
getFocusedComponent?: () => import("@earendil-works/pi-tui").Component | null;
|
|
423
|
+
}) | null | undefined;
|
|
424
|
+
const overlayFocused = tui !== undefined && tui !== null
|
|
425
|
+
&& (typeof tui.isOverlayFocused === "function" ? tui.isOverlayFocused() : tui.hasOverlay());
|
|
426
|
+
const overlayOwner = overlayFocused && typeof tui?.getFocusedComponent === "function"
|
|
427
|
+
? tui.getFocusedComponent() ?? null
|
|
428
|
+
: null;
|
|
423
429
|
const piCtx = st.piContext as Record<string, unknown> | undefined;
|
|
424
430
|
const cwd = (piCtx?.sessionManager as any)?.getCwd?.() ?? (piCtx as any)?.cwd ?? process.cwd();
|
|
425
431
|
const workspace = String(cwd).split("/").filter(Boolean).pop() ?? "~";
|
|
@@ -445,6 +451,7 @@ function installGlanceEditor(
|
|
|
445
451
|
};
|
|
446
452
|
}),
|
|
447
453
|
);
|
|
454
|
+
if (overlayOwner && tui) tui.setFocus(overlayOwner);
|
|
448
455
|
st.glanceInstalled = true;
|
|
449
456
|
} catch {
|
|
450
457
|
st.glanceInstalled = false;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/fusion",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.18.0",
|
|
4
4
|
"description": "Devin-style model picker, fusion presets (lead + sidekick), and Local Fusion runtime for UniPi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@pi-unipi/core": "2.
|
|
36
|
-
"@pi-unipi/subagents": "2.
|
|
35
|
+
"@pi-unipi/core": "2.18.0",
|
|
36
|
+
"@pi-unipi/subagents": "2.18.0"
|
|
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
|
+
}
|
|
@@ -68,9 +68,25 @@ You have a \`sidekick\` tool: a persistent subagent that works alongside you on
|
|
|
68
68
|
- **Promoting a delegated hypothesis to a confirmed conclusion.** When a report ranks candidate causes, the ranking is not a verdict. Present a cause as the root cause only if evidence shows its code path actually executes in the reported scenario; otherwise present it as the leading hypothesis and name the check that would settle it.`;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/**
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Recurring reminder appended to a direct edit/write by the lead while Fusion
|
|
73
|
+
* is active (at most once per agent turn). Mirrors Devin's harness, which
|
|
74
|
+
* re-issues this guidance on every direct implementation action rather than once.
|
|
75
|
+
*/
|
|
76
|
+
export const EDIT_NUDGE =
|
|
77
|
+
"<system_guidance>You made a direct edit yourself instead of delegating to the sidekick. This is a reminder that implementation and verification are to be delegated by default. ONLY implement a step yourself if it is trivially small (you can make the edit AND confirm it in 1-2 of your own turns, with nothing left to test afterwards) or correctness-critical (queries against shared data systems, eval/grading text, pipeline or threshold configuration — you author and check those regardless of size). For anything else, write a brief and hand it to `sidekick`: you design and review, it implements and verifies.</system_guidance>";
|
|
78
|
+
|
|
79
|
+
/** Kept for compatibility with earlier imports; the nudge is no longer one-time. */
|
|
80
|
+
export const FIRST_EDIT_NUDGE = EDIT_NUDGE;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Appended after the lead has run several consecutive non-trivial shell
|
|
84
|
+
* commands itself without a handoff. Builds, tests, installs, environment
|
|
85
|
+
* repair and multi-step shell work are the sidekick's job by default.
|
|
86
|
+
*/
|
|
87
|
+
export function bashNudge(count: number): string {
|
|
88
|
+
return `<system_guidance>You have run ${String(count)} non-trivial shell commands yourself since the last handoff. Builds, test runs, installs, environment setup or repair, and any multi-step shell work are to be delegated to the \`sidekick\` by default; it runs on the same machine and remembers earlier handoffs, so a short brief with the goal, the exact commands or checks you want, and the done-criteria is enough. Keep running commands yourself only when a single read-only command answers a question you need right now, or when the user is waiting on an urgent deliverable.</system_guidance>`;
|
|
89
|
+
}
|
|
74
90
|
|
|
75
91
|
export function sidekickSystemPrompt(id: FusionIdentity): string {
|
|
76
92
|
return `## Role: Fusion sidekick
|
|
@@ -14,6 +14,7 @@ export interface SidekickSpawnConfig {
|
|
|
14
14
|
systemPrompt: string;
|
|
15
15
|
spawn?: typeof defaultSpawn;
|
|
16
16
|
command?: { command: string; args: string[] };
|
|
17
|
+
onProgress?: () => void;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export interface SidekickUsage {
|
|
@@ -24,11 +25,20 @@ export interface SidekickUsage {
|
|
|
24
25
|
cost: number;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
export type SidekickEvent =
|
|
29
|
+
| { kind: "text"; text: string; open: boolean }
|
|
30
|
+
| { kind: "tool"; toolCallId: string; name: string; args: Record<string, unknown> | undefined; output: string; isError: boolean; done: boolean; startedAt: number; endedAt?: number };
|
|
31
|
+
|
|
32
|
+
export const MAX_EVENTS = 300;
|
|
33
|
+
export const MAX_TOOL_OUTPUT = 4000;
|
|
34
|
+
|
|
27
35
|
export interface HandoffProgress {
|
|
28
36
|
toolCalls: number;
|
|
29
37
|
recentTools: string[];
|
|
30
38
|
textTail: string;
|
|
31
39
|
startedAt: number;
|
|
40
|
+
events: SidekickEvent[];
|
|
41
|
+
droppedEvents: number;
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
export interface HandoffReport {
|
|
@@ -38,6 +48,7 @@ export interface HandoffReport {
|
|
|
38
48
|
usage: SidekickUsage;
|
|
39
49
|
toolCalls: number;
|
|
40
50
|
durationMs: number;
|
|
51
|
+
events: SidekickEvent[];
|
|
41
52
|
error?: string;
|
|
42
53
|
}
|
|
43
54
|
|
|
@@ -79,6 +90,47 @@ export class SidekickRuntime {
|
|
|
79
90
|
return this.pending !== undefined;
|
|
80
91
|
}
|
|
81
92
|
|
|
93
|
+
totalToolCalls(): number {
|
|
94
|
+
let total = this.pending?.progress.toolCalls ?? 0;
|
|
95
|
+
for (const report of this.reports.values()) total += report.toolCalls;
|
|
96
|
+
return total;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private notifyProgress(): void {
|
|
100
|
+
try {
|
|
101
|
+
this.cfg.onProgress?.();
|
|
102
|
+
} catch {
|
|
103
|
+
// Progress updates must not affect the handoff.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private appendEvent(event: SidekickEvent): void {
|
|
108
|
+
const progress = this.pending?.progress;
|
|
109
|
+
if (!progress) return;
|
|
110
|
+
progress.events.push(event);
|
|
111
|
+
if (progress.events.length > MAX_EVENTS) {
|
|
112
|
+
progress.events.shift();
|
|
113
|
+
progress.droppedEvents += 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private closeOpenText(): void {
|
|
118
|
+
const events = this.pending?.progress.events;
|
|
119
|
+
const last = events?.at(-1);
|
|
120
|
+
if (last?.kind === "text" && last.open) last.open = false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private toolOutput(result: unknown): string {
|
|
124
|
+
if (typeof result === "object" && result !== null && Array.isArray((result as { content?: unknown }).content)) {
|
|
125
|
+
return ((result as { content: unknown[] }).content)
|
|
126
|
+
.map((part) => typeof part === "object" && part !== null && typeof (part as { text?: unknown }).text === "string" ? (part as { text: string }).text : typeof part === "string" ? part : "")
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.join("\n")
|
|
129
|
+
.slice(-MAX_TOOL_OUTPUT);
|
|
130
|
+
}
|
|
131
|
+
return String(result ?? "").slice(-MAX_TOOL_OUTPUT);
|
|
132
|
+
}
|
|
133
|
+
|
|
82
134
|
private send(value: Record<string, unknown>): void {
|
|
83
135
|
if (!this.child?.stdin?.writable) throw new Error("Sidekick process is not writable");
|
|
84
136
|
this.child.stdin.write(`${JSON.stringify(value)}\n`);
|
|
@@ -169,19 +221,39 @@ export class SidekickRuntime {
|
|
|
169
221
|
}
|
|
170
222
|
if (this.pending === undefined) return;
|
|
171
223
|
if (message.type === "tool_execution_start") {
|
|
224
|
+
this.closeOpenText();
|
|
172
225
|
this.pending.progress.toolCalls += 1;
|
|
173
|
-
const args = message.args
|
|
174
|
-
const
|
|
226
|
+
const args = message.args !== undefined && typeof message.args === "object" && message.args !== null ? message.args as Record<string, unknown> : undefined;
|
|
227
|
+
const argsText = args === undefined ? "" : JSON.stringify(args).replace(/\s+/gu, " ");
|
|
228
|
+
const summary = `${String(message.toolName ?? "tool")}(${argsText})`.slice(0, 40);
|
|
175
229
|
this.pending.progress.recentTools = [...this.pending.progress.recentTools, summary].slice(-6);
|
|
230
|
+
this.appendEvent({ kind: "tool", toolCallId: String(message.toolCallId ?? ""), name: String(message.toolName ?? "tool"), args, output: "", isError: false, done: false, startedAt: Date.now() });
|
|
231
|
+
this.notifyProgress();
|
|
232
|
+
} else if (message.type === "tool_execution_end") {
|
|
233
|
+
const toolCallId = String(message.toolCallId ?? "");
|
|
234
|
+
const event = [...this.pending.progress.events].reverse().find((entry): entry is Extract<SidekickEvent, { kind: "tool" }> => entry.kind === "tool" && entry.toolCallId === toolCallId);
|
|
235
|
+
if (event) {
|
|
236
|
+
event.done = true;
|
|
237
|
+
event.endedAt = Date.now();
|
|
238
|
+
event.isError = message.isError === true;
|
|
239
|
+
event.output = this.toolOutput(message.result);
|
|
240
|
+
}
|
|
241
|
+
this.notifyProgress();
|
|
176
242
|
} else if (message.type === "message_update") {
|
|
177
|
-
const
|
|
178
|
-
if (
|
|
179
|
-
const delta = typeof
|
|
243
|
+
const streamEvent = (message.assistantMessageEvent ?? message) as Record<string, unknown>;
|
|
244
|
+
if (streamEvent.type === "text_delta") {
|
|
245
|
+
const delta = typeof streamEvent.delta === "string" ? streamEvent.delta : typeof streamEvent.text === "string" ? streamEvent.text : "";
|
|
180
246
|
this.pending.progress.textTail = `${this.pending.progress.textTail}${delta}`.slice(-400);
|
|
247
|
+
const last = this.pending.progress.events.at(-1);
|
|
248
|
+
if (last?.kind === "text" && last.open) last.text += delta;
|
|
249
|
+
else this.appendEvent({ kind: "text", text: delta, open: true });
|
|
250
|
+
this.notifyProgress();
|
|
181
251
|
}
|
|
182
252
|
} else if (message.type === "message_end") {
|
|
183
253
|
const msg = message.message as Record<string, unknown> | undefined;
|
|
184
254
|
if (msg?.role === "assistant") {
|
|
255
|
+
this.closeOpenText();
|
|
256
|
+
this.notifyProgress();
|
|
185
257
|
if (msg.stopReason === "error" && typeof msg.errorMessage === "string") this.pendingError = msg.errorMessage;
|
|
186
258
|
const usage = msg.usage as Record<string, unknown> | undefined;
|
|
187
259
|
if (usage) {
|
|
@@ -231,11 +303,13 @@ export class SidekickRuntime {
|
|
|
231
303
|
usage: { ...current.usage },
|
|
232
304
|
toolCalls: current.progress.toolCalls,
|
|
233
305
|
durationMs: Date.now() - current.startedAt,
|
|
306
|
+
events: current.progress.events.map((event) => ({ ...event })),
|
|
234
307
|
...(error === undefined ? {} : { error }),
|
|
235
308
|
};
|
|
236
309
|
this.reports.set(report.id, report);
|
|
237
310
|
if (this.latestHandoff?.id === report.id) this.latestHandoff.report = report;
|
|
238
311
|
current.resolve(report);
|
|
312
|
+
this.notifyProgress();
|
|
239
313
|
this.abortRequested = false;
|
|
240
314
|
this.pendingError = undefined;
|
|
241
315
|
}
|
|
@@ -259,7 +333,7 @@ export class SidekickRuntime {
|
|
|
259
333
|
id,
|
|
260
334
|
startedAt,
|
|
261
335
|
usage: emptyUsage(),
|
|
262
|
-
progress: { toolCalls: 0, recentTools: [], textTail: "", startedAt },
|
|
336
|
+
progress: { toolCalls: 0, recentTools: [], textTail: "", startedAt, events: [], droppedEvents: 0 },
|
|
263
337
|
resolve,
|
|
264
338
|
reject,
|
|
265
339
|
};
|
|
@@ -273,7 +347,9 @@ export class SidekickRuntime {
|
|
|
273
347
|
}
|
|
274
348
|
|
|
275
349
|
progress(id?: string): HandoffProgress | undefined {
|
|
276
|
-
if (this.pending !== undefined && (id === undefined || id === this.pending.id))
|
|
350
|
+
if (this.pending !== undefined && (id === undefined || id === this.pending.id)) {
|
|
351
|
+
return { ...this.pending.progress, recentTools: [...this.pending.progress.recentTools], events: this.pending.progress.events.map((event) => ({ ...event })) };
|
|
352
|
+
}
|
|
277
353
|
return undefined;
|
|
278
354
|
}
|
|
279
355
|
|