@pi-unipi/fusion 2.17.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/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # @pi-unipi/fusion
2
+
3
+ Local Fusion for UniPi: a Devin-inspired lead + persistent sidekick pair, a
4
+ curated model preset, and a keyboard-first picker (`/unipi:model`). It uses
5
+ pi's extension and RPC APIs natively.
6
+
7
+ ## `/unipi:model` — the picker
8
+
9
+ ```
10
+ ╭ Model ───────────────────────────────────────────────────────────────╮
11
+ │/ Type to search │
12
+ │───────────────────────────────────────────────────────────────────────│
13
+ │❭ Fusion ← ▰▰▰▱▱ → Medium Lead Claude Fable… ▾ │
14
+ │· GLM-5.3 Flash ✱ ▰▰▰▰▰ Max │
15
+ │· Claude Fable 5.1 ▰▰▰▱▱ Medium │
16
+ │· DeepSeek V4.1 Flash ✱ ▰▱▱▱▱ High │
17
+ │ ↓ more below │
18
+ │ │
19
+ │ ━━━━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
20
+ │ Input Cached input Output Sidekick input │
21
+ │ $10 / 1M $0.25 / 1M $50 / 1M $0.2 / 1M │
22
+ │ Sidekick cached input Sidekick output │
23
+ │ $0.02 / 1M $1.2 / 1M │
24
+ │ │
25
+ │ ✱ New ✱ Promotion ✱ Beta · Pairs frontier intelligence with cost-efficient execution │
26
+ │↑↓ select · tab lead · ←→ effort · ↵ confirm · esc cancel │
27
+ ╰───────────────────────────────────────────────────────────────────────╯
28
+ ```
29
+
30
+ - **Row order**: the active selection pinned first, then the Fusion row (when a
31
+ pair is configured), then recent (≤5, MRU), then the preset models, then
32
+ **every other available model** — the catalogue is never hidden; the preset
33
+ only controls ordering. When a single model is selected its row lights up
34
+ with `✓`; when Fusion is selected the selection lives on the Fusion row and
35
+ plain rows stay unmarked.
36
+ - **`←`/`→`** steps the highlighted row's effort (pi thinking level:
37
+ off → minimal → low → medium → high → xhigh → max) and it is **remembered per
38
+ model**. The five-cell bar fills with `▰` and uses `▱` for remaining levels.
39
+ The Fusion row keeps its own lead/sidekick efforts, so adjusting Fusion never
40
+ rewrites a model's standalone level.
41
+ - **`tab`** on the Fusion row cycles focus: effort → lead → sidekick
42
+ (`shift+tab` reverses). Focused columns open an inline dropdown over the
43
+ preset lists; `↵` applies, `esc` collapses.
44
+ - **Price panel**: the highlighted model's blended price is marked on a
45
+ logarithmic red→orange→yellow→green→cyan→blue→violet slider. Fusion shows
46
+ `Input`, `Cached input`, `Output`, `Sidekick input`, `Sidekick cached input`,
47
+ and `Sidekick output`.
48
+ - **Confirm** applies: `pi.setModel`, `pi.setThinkingLevel(effort)`, updates
49
+ MRU + persisted active selection, and shows `Fusion · Lead ◆ Sidekick` in
50
+ the footer. Switching the model through pi's own `/model`/Ctrl+P drops
51
+ Fusion mode.
52
+ - Because pi intercepts `/model` inside its editor before extensions run, the
53
+ command cannot be replaced — instead typing `/model` pins `/unipi:model`
54
+ as the **first autocomplete suggestion**.
55
+ - Fast Mode is not implemented because pi has no equivalent capability.
56
+
57
+ Hand-edited preset badge metadata renders `✱` as New, Promotion, or Beta:
58
+
59
+ ```json
60
+ {"badges": {"provider/model": "new"}}
61
+ ```
62
+
63
+ ## `/unipi:fusion-preset` — curation
64
+
65
+ Two-column checklist over every available model:
66
+
67
+ ```
68
+ ╭ Fusion preset ───────────────────────────────────────────────────────╮
69
+ │Fusion preset · 2 lead · 2 sidekick · writes to global │
70
+ │Search: (type to filter) │
71
+ │ L S model │
72
+ │ › [x] [ ] provider/lead │
73
+ │ [ ] [x] provider/sidekick │
74
+ │↑↓ select · ←→ column · space toggle sidekick · Enter save · esc cancel │
75
+ ╰───────────────────────────────────────────────────────────────────────╯
76
+ ```
77
+
78
+ - `←`/`→` (or `tab`) switches the L/S column, `space` toggles membership,
79
+ `Enter` saves & closes, `esc` cancels, and typing filters. Selected models
80
+ float to the top so the curated set is always visible.
81
+ - Defaults are not edited here: confirming a Fusion pair in `/unipi:model`
82
+ records it as the preset default.
83
+
84
+ Any model key in the registry is allowed — there is no OAuth/subscription gate.
85
+
86
+ ## Persistent sidekick
87
+
88
+ Confirming a Fusion pair selects the lead for the session. The `sidekick` tool
89
+ runs one lazy-spawned child pi per lead session; its session file is persisted
90
+ at `~/.unipi/state/fusion/sidekick/<lead-session-id>.jsonl`. Context and shells
91
+ persist across handoffs. The sidekick's context compacts independently of the
92
+ lead's (it is its own pi session). `sidekick({message, block:true})` waits by
93
+ default; `block:false` returns immediately and delivers a
94
+ `<subagent_completion_notification>`. Calling it while busy steers the same
95
+ handoff. `read_subagent({agent_id?, block?, timeout?})` reads or waits for a
96
+ handoff.
97
+
98
+ The child receives `UNIPI_FUSION_CHILD=1` and `UNIPI_SUBAGENT_CHILD=1`; the
99
+ Fusion extension guard prevents child processes from registering Fusion tools,
100
+ commands, or lead policy.
101
+
102
+ The lead gets the persistent delegation policy through `before_agent_start`,
103
+ and the first direct `edit`/`write` receives a one-time delegation nudge.
104
+ `/unipi:fusion-stats` reports sidekick tokens, costs at sidekick and lead
105
+ rates, estimated savings, handoff count, and runtime state. The footer shows
106
+ savings above `$0.005`.
107
+
108
+ ## Storage
109
+
110
+ ```json
111
+ // ~/.unipi/config/fusion/preset.json (global)
112
+ // <cwd>/.unipi/fusion-preset.json (project override; lists replace, objects merge)
113
+ {
114
+ "schema_version": 1,
115
+ "lead": ["provider/lead"],
116
+ "sidekick": ["provider/sidekick"],
117
+ "default": {"lead": "provider/lead", "sidekick": "provider/sidekick"},
118
+ "effort": {"provider/sidekick": "high"},
119
+ "badges": {"provider/sidekick": "new"},
120
+ "recent": ["provider/lead"],
121
+ "active": {"kind": "fusion", "lead": "provider/lead", "sidekick": "provider/sidekick"}
122
+ }
123
+ ```
124
+
125
+ ## Status
126
+
127
+ - [x] Preset store, project layering, curation UI, autocomplete boost
128
+ - [x] Picker effort bars, price slider, price panel, badges, legend
129
+ - [x] Persistent sidekick runtime, blocking/non-blocking tools, steering,
130
+ progress, completion card, session isolation
131
+ - [x] Lead policy, first-edit nudge, savings estimate, footer and stats command
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@pi-unipi/fusion",
3
+ "version": "2.17.0",
4
+ "description": "Devin-style model picker, fusion presets (lead + sidekick), and Local Fusion runtime for UniPi",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "license": "MIT",
8
+ "author": "Neuron Mr White",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/Neuron-Mr-White/unipi.git",
12
+ "directory": "packages/fusion"
13
+ },
14
+ "homepage": "https://github.com/Neuron-Mr-White/unipi#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/Neuron-Mr-White/unipi/issues"
17
+ },
18
+ "keywords": [
19
+ "pi-package",
20
+ "pi-extension",
21
+ "pi-coding-agent",
22
+ "unipi",
23
+ "fusion",
24
+ "model-picker",
25
+ "sidekick"
26
+ ],
27
+ "files": [
28
+ "src/**/*.ts",
29
+ "README.md"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "dependencies": {
35
+ "@pi-unipi/core": "2.17.0",
36
+ "@pi-unipi/subagents": "2.17.0"
37
+ },
38
+ "peerDependencies": {
39
+ "@earendil-works/pi-ai": "^0.84.0",
40
+ "@earendil-works/pi-coding-agent": "^0.84.0",
41
+ "@earendil-works/pi-tui": "^0.84.0"
42
+ },
43
+ "scripts": {
44
+ "test": "npx tsx --test tests/**/*.test.ts"
45
+ },
46
+ "pi": {
47
+ "extensions": [
48
+ "./src/index.ts"
49
+ ],
50
+ "skills": [],
51
+ "prompts": [],
52
+ "themes": []
53
+ }
54
+ }
package/src/index.ts ADDED
@@ -0,0 +1,378 @@
1
+ /**
2
+ * @pi-unipi/fusion — extension entry
3
+ *
4
+ * Commands
5
+ * /unipi:model Devin-style picker over the curated preset
6
+ * /unipi:fusion-preset Curate the preset (lead / sidekick lists, defaults)
7
+ *
8
+ * Autocomplete: when the user types `/model`, `/unipi:model` is pinned as the
9
+ * first suggestion (pi's own `/model` cannot be overridden by extensions).
10
+ *
11
+ * Runtime (this package, later step): the `sidekick` tool + lead policy.
12
+ * For now, confirming a Fusion pair sets the lead as the session model and
13
+ * records the pair; the footer shows `Fusion · Lead ◆ Sidekick`.
14
+ */
15
+
16
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
+ import type { Api, Model } from "@earendil-works/pi-ai";
18
+ import type { AutocompleteProvider, AutocompleteSuggestions } from "@earendil-works/pi-tui";
19
+ import { setSharedFusionStatus, UNIPI_PREFIX } from "@pi-unipi/core";
20
+ import { homedir } from "node:os";
21
+ import { join } from "node:path";
22
+ import {
23
+ effortLabel,
24
+ isEffortLevel,
25
+ loadPreset,
26
+ modelKey,
27
+ pushRecent,
28
+ saveCuration,
29
+ saveRuntimeState,
30
+ splitModelKey,
31
+ type ActiveSelection,
32
+ type EffortLevel,
33
+ type FusionPreset,
34
+ } from "./preset.js";
35
+ import { ModelPicker, type PickerModel, type PickerResult } from "./picker.js";
36
+ import { PresetEditor, type PresetEditorResult } from "./preset-editor.js";
37
+ import { SidekickRuntime } from "./sidekick-runtime.js";
38
+ import { estimateSavings } from "./savings.js";
39
+ import { FIRST_EDIT_NUDGE, leadPolicy, sidekickSystemPrompt, type FusionIdentity } from "./prompts.js";
40
+ import { registerFusionTools } from "./tools.js";
41
+
42
+ export const MODEL_COMMAND = `${UNIPI_PREFIX}model`;
43
+ export const PRESET_COMMAND = `${UNIPI_PREFIX}fusion-preset`;
44
+
45
+ export function sidekickSessionPath(leadSessionId?: string): string {
46
+ return join(homedir(), ".unipi", "state", "fusion", "sidekick", `${leadSessionId ?? "default"}.jsonl`);
47
+ }
48
+
49
+ type Registry = { getAvailable(): Model<Api>[]; find(provider: string, id: string): Model<Api> | undefined };
50
+
51
+ function registryOf(ctx: ExtensionContext): Registry | undefined {
52
+ const r = (ctx as unknown as { modelRegistry?: Registry }).modelRegistry;
53
+ return r && typeof r.getAvailable === "function" ? r : undefined;
54
+ }
55
+
56
+ /** Key-based lookup (provider ids can contain slashes, so splitModelKey+find is unreliable). */
57
+ const modelBykey = new Map<string, Model<Api>>();
58
+ function findModel(reg: Registry | undefined, key: string): Model<Api> | undefined {
59
+ if (modelBykey.size === 0 && reg) for (const m of reg.getAvailable()) modelBykey.set(modelKey(m), m);
60
+ return modelBykey.get(key);
61
+ }
62
+
63
+ function costOf(m: Model<Api> | undefined): PickerModel["cost"] {
64
+ const cost = m?.cost;
65
+ return cost && typeof cost.input === "number"
66
+ ? { input: cost.input, cachedInput: cost.cacheRead ?? 0, output: cost.output }
67
+ : undefined;
68
+ }
69
+
70
+ function toPickerModel(m: Model<Api>, badge?: FusionPreset["badges"][string]): PickerModel {
71
+ return {
72
+ key: modelKey(m),
73
+ name: m.name || m.id,
74
+ provider: m.provider,
75
+ badge,
76
+ cost: costOf(m),
77
+ reasoning: Boolean(m.reasoning),
78
+ };
79
+ }
80
+
81
+ function currentEffort(ctx: ExtensionContext, pi: ExtensionAPI): EffortLevel {
82
+ const fromCtx = (ctx as { thinkingLevel?: unknown }).thinkingLevel;
83
+ if (isEffortLevel(fromCtx)) return fromCtx;
84
+ try {
85
+ const level = pi.getThinkingLevel();
86
+ if (isEffortLevel(level)) return level;
87
+ } catch {
88
+ /* not available in this context */
89
+ }
90
+ return "medium";
91
+ }
92
+
93
+ /** Pin `/unipi:model` first whenever the user is typing `/model…`. */
94
+ export function createModelBoostProvider(current: AutocompleteProvider): AutocompleteProvider {
95
+ return {
96
+ ...current,
97
+ async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
98
+ const base = await current.getSuggestions(lines, cursorLine, cursorCol, options);
99
+ const text = (lines[cursorLine] ?? "").slice(0, cursorCol);
100
+ if (!/^\/m(o(d(e(l)?)?)?)?$/iu.test(text)) return base;
101
+ if (!base) return base;
102
+ const idx = base.items.findIndex((i) => i.value === MODEL_COMMAND);
103
+ if (idx <= 0) return base;
104
+ const items = [...base.items];
105
+ const [boosted] = items.splice(idx, 1);
106
+ if (boosted === undefined) return base;
107
+ return { ...base, items: [boosted, ...items] };
108
+ },
109
+ };
110
+ }
111
+
112
+ export default function fusionExtension(pi: ExtensionAPI): void {
113
+ if (process.env.UNIPI_FUSION_CHILD === "1") return;
114
+
115
+ let active: ActiveSelection | undefined;
116
+ let runtime: SidekickRuntime | undefined;
117
+ let nudged = false;
118
+
119
+ function identity(ctx: ExtensionContext): FusionIdentity {
120
+ const reg = registryOf(ctx);
121
+ const names = (k: string) => findModel(reg, k)?.name || splitModelKey(k)?.id || k;
122
+ return {
123
+ leadName: names(active?.kind === "fusion" ? active.lead : ""),
124
+ leadEffort: active?.kind === "fusion" ? effortLabel(active.leadEffort ?? "medium") : "",
125
+ sidekickName: names(active?.kind === "fusion" ? active.sidekick : ""),
126
+ sidekickEffort: active?.kind === "fusion" ? effortLabel(active.sidekickEffort ?? "medium") : "",
127
+ };
128
+ }
129
+
130
+ function statusSavings(ctx: ExtensionContext): number | undefined {
131
+ if (active?.kind !== "fusion" || runtime === undefined) return undefined;
132
+ const reg = registryOf(ctx);
133
+ const lead = findModel(reg, active.lead);
134
+ const side = findModel(reg, active.sidekick);
135
+ return estimateSavings(runtime.usage, costOf(lead), costOf(side)).savedUsd;
136
+ }
137
+
138
+ function publishStatus(ctx: ExtensionContext): void {
139
+ const reg = registryOf(ctx);
140
+ const names = (k: string) => findModel(reg, k)?.name || splitModelKey(k)?.id || k;
141
+ if (active?.kind === "fusion") {
142
+ // Displayed in the input-box model slot (footer glance frame):
143
+ // the working lead lit, the sidekick muted.
144
+ setSharedFusionStatus({
145
+ leadName: names(active.lead),
146
+ leadEffort: active.leadEffort ?? "",
147
+ sidekickName: names(active.sidekick),
148
+ sidekickEffort: active.sidekickEffort ?? "",
149
+ savedUsd: statusSavings(ctx),
150
+ });
151
+ } else {
152
+ setSharedFusionStatus(undefined);
153
+ }
154
+ }
155
+
156
+ function leadSessionId(ctx: ExtensionContext): string {
157
+ const manager = ctx.sessionManager as { getSessionId?: () => string | undefined } | undefined;
158
+ return manager?.getSessionId?.() ?? "default";
159
+ }
160
+
161
+ function getRuntime(ctx: ExtensionContext): SidekickRuntime | undefined {
162
+ if (active?.kind !== "fusion") return undefined;
163
+ if (runtime === undefined) {
164
+ runtime = new SidekickRuntime({
165
+ cwd: ctx.cwd ?? process.cwd(),
166
+ model: active.sidekick,
167
+ thinking: active.sidekickEffort ?? "medium",
168
+ sessionFile: sidekickSessionPath(leadSessionId(ctx)),
169
+ systemPrompt: sidekickSystemPrompt(identity(ctx)),
170
+ });
171
+ }
172
+ return runtime;
173
+ }
174
+
175
+ function stopRuntime(): void {
176
+ runtime?.kill();
177
+ runtime = undefined;
178
+ }
179
+
180
+ function savingsStats(ctx: ExtensionContext): string {
181
+ if (active?.kind !== "fusion" || runtime === undefined) return "Fusion is not active — pick a Fusion pair with /unipi:model.";
182
+ const reg = registryOf(ctx);
183
+ const savings = estimateSavings(runtime.usage, costOf(findModel(reg, active.lead)), costOf(findModel(reg, active.sidekick)));
184
+ return `Sidekick tokens: in ${String(runtime.usage.input)} · out ${String(runtime.usage.output)} · cached ${String(runtime.usage.cacheRead)} · cache write ${String(runtime.usage.cacheWrite)}\nSidekick cost: $${savings.sidekickUsd.toFixed(2)} · at lead prices: $${savings.atLeadUsd.toFixed(2)} · saved: $${savings.savedUsd.toFixed(2)}\nHandoffs: ${String(runtime.reports.size)} · runtime alive: ${String(runtime.isAlive())} · busy: ${String(runtime.isBusy())}`;
185
+ }
186
+
187
+ registerFusionTools(pi, {
188
+ getRuntime,
189
+ onReport: (ctx) => publishStatus(ctx),
190
+ });
191
+ pi.registerCommand("unipi:fusion-stats", {
192
+ description: "Estimated Fusion savings (sidekick tokens priced at lead rates)",
193
+ handler: async (_args, ctx) => ctx.ui.notify(savingsStats(ctx), "info"),
194
+ });
195
+ pi.on("before_agent_start", (event, ctx) => active?.kind === "fusion" ? { systemPrompt: `${event.systemPrompt}\n\n${leadPolicy(identity(ctx))}` } : undefined);
196
+ pi.on("tool_result", (event) => {
197
+ if (active?.kind !== "fusion" || nudged || (event.toolName !== "edit" && event.toolName !== "write")) return;
198
+ nudged = true;
199
+ return { content: [...event.content, { type: "text", text: FIRST_EDIT_NUDGE }] };
200
+ });
201
+
202
+ async function applyResult(ctx: ExtensionContext, result: PickerResult, preset: FusionPreset, loaded: { globalPath: string; projectPath: string; hasProjectLayer: boolean }): Promise<void> {
203
+ if (result.type === "cancelled") return;
204
+ const samePair = result.type === "fusion" && active?.kind === "fusion" && active.lead === result.lead && active.sidekick === result.sidekick;
205
+ const reg = registryOf(ctx);
206
+ const targetKey = result.type === "single" ? result.model : result.lead;
207
+ const model = findModel(reg, targetKey);
208
+ if (!model) {
209
+ ctx.ui.notify(`Model ${targetKey} is not available. Run /login or fix the preset.`, "error");
210
+ return;
211
+ }
212
+ const ok = await pi.setModel(model);
213
+ if (!ok) {
214
+ ctx.ui.notify(`Could not switch to ${targetKey}.`, "error");
215
+ return;
216
+ }
217
+ if (!samePair) stopRuntime();
218
+ const effort = result.type === "single" ? result.effort : result.leadEffort;
219
+ try {
220
+ pi.setThinkingLevel(effort);
221
+ } catch {
222
+ /* provider may not support thinking */
223
+ }
224
+ active =
225
+ result.type === "single"
226
+ ? { kind: "single", model: result.model }
227
+ : {
228
+ kind: "fusion",
229
+ lead: result.lead,
230
+ sidekick: result.sidekick,
231
+ leadEffort: result.leadEffort,
232
+ sidekickEffort: result.sidekickEffort,
233
+ };
234
+ const recent = pushRecent(preset.recent, targetKey);
235
+ if (result.type === "fusion") {
236
+ // Remember the confirmed pair as the preset default (the preset editor
237
+ // never edits defaults; confirming here is the natural place).
238
+ const layerPath = loaded.hasProjectLayer ? loaded.projectPath : loaded.globalPath;
239
+ saveCuration(layerPath, {
240
+ lead: preset.lead.includes(result.lead) ? preset.lead : [result.lead, ...preset.lead],
241
+ sidekick: preset.sidekick.includes(result.sidekick)
242
+ ? preset.sidekick
243
+ : [result.sidekick, ...preset.sidekick],
244
+ default: { lead: result.lead, sidekick: result.sidekick },
245
+ });
246
+ }
247
+ saveRuntimeState(loaded.globalPath, { effort: result.effortMap, recent, active });
248
+ publishStatus(ctx);
249
+ const label =
250
+ result.type === "single"
251
+ ? `${model.name || model.id} · ${effortLabel(effort)}`
252
+ : `Fusion · ${model.name || model.id} ${effortLabel(effort)} ◆ ${
253
+ findModel(reg, result.sidekick)?.name ?? splitModelKey(result.sidekick)?.id ?? result.sidekick
254
+ } ${effortLabel(result.sidekickEffort)}`;
255
+ ctx.ui.notify(label, "info");
256
+ }
257
+
258
+ pi.registerCommand("unipi:model", {
259
+ description: "Pick a model or a Fusion lead+sidekick pair (Devin-style picker over your preset)",
260
+ handler: async (_args, ctx) => {
261
+ if (!ctx.hasUI) throw new Error(`/${MODEL_COMMAND} needs the interactive TUI`);
262
+ const reg = registryOf(ctx);
263
+ if (!reg) {
264
+ ctx.ui.notify("Model registry unavailable in this context.", "error");
265
+ return;
266
+ }
267
+ const cwd = ctx.cwd ?? process.cwd();
268
+ const loaded = loadPreset(cwd);
269
+ const preset = loaded.preset;
270
+ const models = reg.getAvailable().map((m) => toPickerModel(m, preset.badges[modelKey(m)]));
271
+ if (models.length === 0) {
272
+ ctx.ui.notify("No models available. Use /login to add a provider.", "warning");
273
+ return;
274
+ }
275
+ const currentKey = ctx.model ? modelKey(ctx.model) : undefined;
276
+ // Session truth wins over persisted state: if the user switched via pi's
277
+ // own /model since, show that as the pinned row.
278
+ if (active === undefined && preset.active !== undefined) active = preset.active;
279
+ if (currentKey !== undefined) {
280
+ if (active === undefined || (active.kind === "single" && active.model !== currentKey) || (active.kind === "fusion" && active.lead !== currentKey)) {
281
+ active = { kind: "single", model: currentKey };
282
+ }
283
+ }
284
+ const fallbackEffort = currentEffort(ctx, pi);
285
+ const result = await ctx.ui.custom<PickerResult>(
286
+ (tui, theme, _kb, done) =>
287
+ new ModelPicker({
288
+ state: {
289
+ models,
290
+ fusionLeads: preset.lead,
291
+ fusionSidekicks: preset.sidekick,
292
+ fusionDefault: preset.default,
293
+ recent: preset.recent,
294
+ active,
295
+ currentModelKey: currentKey,
296
+ effort: preset.effort,
297
+ fallbackEffort,
298
+ },
299
+ theme: { fg: (c, s) => theme.fg(c as never, s), bold: (s) => theme.bold(s) },
300
+ onDone: done,
301
+ onRenderRequest: () => tui.requestRender(),
302
+ }),
303
+ {
304
+ overlay: true,
305
+ overlayOptions: { anchor: "center", width: "88%", minWidth: 72, maxHeight: "80%" },
306
+ },
307
+ );
308
+ await applyResult(ctx, result, preset, loaded);
309
+ },
310
+ });
311
+
312
+ pi.registerCommand("unipi:fusion-preset", {
313
+ description: "Curate the model preset used by /unipi:model (lead + sidekick lists, defaults)",
314
+ handler: async (_args, ctx) => {
315
+ if (!ctx.hasUI) throw new Error(`/${PRESET_COMMAND} needs the interactive TUI`);
316
+ const reg = registryOf(ctx);
317
+ if (!reg) {
318
+ ctx.ui.notify("Model registry unavailable in this context.", "error");
319
+ return;
320
+ }
321
+ const cwd = ctx.cwd ?? process.cwd();
322
+ const loaded = loadPreset(cwd);
323
+ const models = reg.getAvailable().map((m) => ({ key: modelKey(m), name: m.name || m.id }));
324
+ const result = await ctx.ui.custom<PresetEditorResult>(
325
+ (tui, theme, _kb, done) =>
326
+ new PresetEditor({
327
+ models,
328
+ initial: { lead: loaded.preset.lead, sidekick: loaded.preset.sidekick, default: loaded.preset.default },
329
+ active,
330
+ initialTarget: loaded.hasProjectLayer ? "project" : "global",
331
+ theme: { fg: (c, s) => theme.fg(c as never, s), bold: (s) => theme.bold(s) },
332
+ onDone: done,
333
+ onRenderRequest: () => tui.requestRender(),
334
+ }),
335
+ {
336
+ overlay: true,
337
+ overlayOptions: { anchor: "center", width: "80%", minWidth: 64, maxHeight: "80%" },
338
+ },
339
+ );
340
+ if (result.type !== "saved") return;
341
+ const path = result.target === "project" ? loaded.projectPath : loaded.globalPath;
342
+ saveCuration(path, result.curation);
343
+ ctx.ui.notify(
344
+ `Saved preset → ${path}\n${String(result.curation.lead.length)} lead · ${String(result.curation.sidekick.length)} sidekick`,
345
+ "info",
346
+ );
347
+ },
348
+ });
349
+
350
+ pi.on("session_start", (_e, ctx) => {
351
+ stopRuntime();
352
+ nudged = false;
353
+ modelBykey.clear();
354
+ active = loadPreset(ctx.cwd ?? process.cwd()).preset.active;
355
+ // Only keep a Fusion status if the session actually runs on that lead.
356
+ if (active?.kind === "fusion" && ctx.model && modelKey(ctx.model) !== active.lead) active = undefined;
357
+ publishStatus(ctx);
358
+ if (ctx.hasUI) ctx.ui.addAutocompleteProvider(createModelBoostProvider);
359
+ });
360
+
361
+ pi.on("session_shutdown", () => {
362
+ stopRuntime();
363
+ setSharedFusionStatus(undefined);
364
+ });
365
+
366
+ pi.on("model_select", (event, ctx) => {
367
+ // The user switched through pi's own /model or Ctrl+P: leave Fusion mode
368
+ // unless the new model is still the lead.
369
+ if (active?.kind === "fusion" && modelKey(event.model) !== active.lead) {
370
+ stopRuntime();
371
+ active = { kind: "single", model: modelKey(event.model) };
372
+ publishStatus(ctx);
373
+ }
374
+ });
375
+ }
376
+
377
+ export { loadPreset } from "./preset.js";
378
+ export type { ActiveSelection } from "./preset.js";