@gonrocca/zero-pi 0.1.10 → 0.1.11

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 CHANGED
@@ -181,26 +181,103 @@ the most recent recommendation.
181
181
  `skill-loop.md` gives the agent a closed learning loop so solutions are reused
182
182
  instead of re-derived.
183
183
 
184
+ ### ZERO terminal theme (`themes/zero-sdd.json`)
185
+
186
+ `zero-pi` ships a Pi theme named `zero-sdd`: a dark, high-contrast terminal
187
+ palette with cyan, amber, mint, rose, and violet accents tuned for SDD work.
188
+ Select it from `/settings`, or set `"theme": "zero-sdd"` in Pi settings.
189
+
184
190
  ### Startup banner (`extensions/startup-banner.ts`)
185
191
 
186
- Renders the `ZERO` wordmark in ANSI Shadow figlet style with a purple → amber
187
- shimmer sweep the moment a pi session starts, then settles into a static
188
- gradient glow. Pure ANSI 24-bit colour, no runtime dependencies.
192
+ Renders the `ZERO` wordmark as ASCII-safe Tetris cells. In the default animated
193
+ mode, the cells assemble from the bottom up when a pi session starts, then
194
+ settle into the completed ZERO banner. Pure ANSI 24-bit colour, no runtime
195
+ dependencies.
189
196
 
190
197
  The render runs synchronously before pi draws its UI, so the animation never
191
198
  fights pi's renderer.
192
199
 
200
+ ### Working-phrase ticker (`extensions/working-phrases.ts`)
201
+
202
+ Pi shows a single static `Working...` line while the agent is busy. This
203
+ extension replaces it with a context-aware, rotating phrase:
204
+
205
+ - **While a tool runs** — a tool-specific line: `Leyendo archivos…`,
206
+ `Ejecutando comandos…`, `Buscando en el código…`. MCP tools are named by
207
+ server (`Consultando un MCP…`).
208
+ - **While a zero sub-agent runs** — the SDD phase it owns:
209
+ `Explorando el código…`, `Planeando la solución…`,
210
+ `Construyendo la implementación…`, `Revisando el veredicto…`.
211
+ - **While the model thinks** — a rotation of playful verbs (`Maquinando…`,
212
+ `Rumiando…`, `Cocinando…`). Once a `/forge` run is detected the rotation
213
+ biases toward SDD vocabulary.
214
+
215
+ It also installs a theme-tinted braille spinner that gently breathes through
216
+ the palette's `dim`/`muted`/`accent` colours. The extension uses only pi's
217
+ public extension API, and every handler is defensive — the indicator can never
218
+ break a session.
219
+
193
220
  Control it with the `ZERO_BANNER` environment variable:
194
221
 
195
222
  | `ZERO_BANNER` | Effect |
196
223
  | ------------- | ------ |
197
- | _(unset)_ / `shimmer` | Animated shimmer sweep, then settle (default) |
198
- | `static` | Settled gradient only, no animation |
224
+ | _(unset)_ / `shimmer` | Animated Tetris assembly, then settle (default) |
225
+ | `static` | Completed Tetris banner only, no animation |
199
226
  | `off` | Render nothing |
200
227
 
201
228
  Colour is skipped automatically when `NO_COLOR` is set or the output is not a
202
229
  TTY.
203
230
 
231
+ ### Provider guard (`extensions/provider-guard.ts`)
232
+
233
+ zero-pi watches model switches and steps in when you move to a metered
234
+ provider. When you switch to a model on the `anthropic` provider — which bills
235
+ per token and draws down your metered extra-usage pool — the guard offers to
236
+ redirect you to the equivalent model on `pi-claude-cli`, the provider backed by
237
+ your subscription's limits.
238
+
239
+ The redirect is offered through a confirmation dialog, and that dialog is your
240
+ escape hatch: say "yes" and the guard switches you to the subscription-backed
241
+ equivalent; say "no" (or cancel) and you stay on the metered `anthropic`
242
+ provider with no further nagging. When there is no equivalent model on
243
+ `pi-claude-cli`, and when a model is restored on session start rather than
244
+ switched deliberately, the guard skips the modal and just shows a one-line
245
+ warning. Switching to a subscription provider is a complete no-op — no dialog,
246
+ no notification, no noise.
247
+
248
+ ### Conversation resume (`extensions/conversation-resume.ts`)
249
+
250
+ When pi exits normally, zero-pi writes a local handoff note:
251
+
252
+ ```
253
+ .pi/zero-resume.md
254
+ ```
255
+
256
+ The note includes the exact restore command for the persisted pi session:
257
+
258
+ ```bash
259
+ pi --session '<session-file>'
260
+ ```
261
+
262
+ When a session id is available it also writes the shorter form:
263
+
264
+ ```bash
265
+ pi --session <session-id>
266
+ ```
267
+
268
+ `pi --resume` is listed too for the interactive session picker. The file also
269
+ keeps a concise conversation tail, enough to remember the last user intent and
270
+ assistant progress without replacing pi's real session history.
271
+
272
+ Generate it manually at any time:
273
+
274
+ ```text
275
+ /zero-resume
276
+ ```
277
+
278
+ The resume lives under `.pi/`, and zero-pi creates `.pi/.gitignore` so local
279
+ conversation context is not accidentally committed.
280
+
204
281
  ## Relationship to `zero`
205
282
 
206
283
  `zero-pi` is the pi-specific layer of the **[zero](https://github.com/gonzalonicolasr/zero)**
@@ -0,0 +1,399 @@
1
+ // zero-pi - conversation resume on shutdown.
2
+ //
3
+ // Pi already persists the full session in ~/.pi/agent/sessions. This extension
4
+ // leaves a small project-local handoff note when the user quits pi, with the
5
+ // exact command needed to restore that session plus a concise conversation tail.
6
+ //
7
+ // It intentionally avoids model calls during shutdown: quitting should stay
8
+ // fast and reliable, even offline or without an API key.
9
+
10
+ import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ const RESUME_DIR = ".pi";
14
+ const RESUME_FILE = "zero-resume.md";
15
+ const DEFAULT_MAX_ITEMS = 12;
16
+ const DEFAULT_MAX_CHARS = 1200;
17
+
18
+ type NotifyType = "info" | "warning" | "error";
19
+
20
+ interface ContentBlock {
21
+ type?: string;
22
+ text?: string;
23
+ thinking?: string;
24
+ name?: string;
25
+ arguments?: unknown;
26
+ mimeType?: string;
27
+ }
28
+
29
+ interface MessageLike {
30
+ role?: string;
31
+ content?: unknown;
32
+ command?: string;
33
+ output?: string;
34
+ summary?: string;
35
+ timestamp?: number;
36
+ customType?: string;
37
+ }
38
+
39
+ export interface SessionEntryLike {
40
+ type?: string;
41
+ id?: string;
42
+ timestamp?: string;
43
+ message?: MessageLike;
44
+ summary?: string;
45
+ tokensBefore?: number;
46
+ }
47
+
48
+ export interface ResumeMetadata {
49
+ cwd?: string;
50
+ generatedAt?: Date;
51
+ maxChars?: number;
52
+ maxItems?: number;
53
+ reason?: string;
54
+ sessionFile?: string;
55
+ sessionId?: string;
56
+ }
57
+
58
+ export interface ResumeItem {
59
+ label: string;
60
+ text: string;
61
+ timestamp?: string;
62
+ }
63
+
64
+ interface PiUI {
65
+ notify?(message: string, type?: NotifyType): void;
66
+ }
67
+
68
+ interface PiSessionManager {
69
+ getBranch?(): SessionEntryLike[];
70
+ getSessionFile?(): string | undefined;
71
+ getSessionId?(): string | undefined;
72
+ }
73
+
74
+ interface PiContext {
75
+ cwd?: string;
76
+ hasUI?: boolean;
77
+ ui?: PiUI;
78
+ sessionManager?: PiSessionManager;
79
+ }
80
+
81
+ interface PiCommandContext extends PiContext {}
82
+
83
+ interface PiAPI {
84
+ on?(event: string, handler: (event: unknown, ctx: PiContext) => Promise<void> | void): void;
85
+ registerCommand?(
86
+ name: string,
87
+ options: {
88
+ description?: string;
89
+ handler: (args: string, ctx: PiCommandContext) => Promise<void> | void;
90
+ },
91
+ ): void;
92
+ }
93
+
94
+ /** Convert a shell argument into a single-quoted literal for PowerShell/sh. */
95
+ export function quoteShellArg(value: string): string {
96
+ return `'${value.replace(/'/g, "''")}'`;
97
+ }
98
+
99
+ /** The project-local resume path. */
100
+ export function resumePath(cwd: string): string {
101
+ return join(cwd, RESUME_DIR, RESUME_FILE);
102
+ }
103
+
104
+ /** Extract readable text from pi message content blocks. */
105
+ export function contentToText(content: unknown): string {
106
+ if (typeof content === "string") return content.trim();
107
+ if (!Array.isArray(content)) return "";
108
+
109
+ const parts: string[] = [];
110
+ for (const raw of content) {
111
+ if (!raw || typeof raw !== "object") continue;
112
+ const block = raw as ContentBlock;
113
+ if (block.type === "text" && typeof block.text === "string") {
114
+ parts.push(block.text);
115
+ continue;
116
+ }
117
+ if (block.type === "toolCall" && typeof block.name === "string") {
118
+ parts.push(`[tool call: ${block.name}]`);
119
+ continue;
120
+ }
121
+ if (block.type === "image") {
122
+ parts.push(`[image${block.mimeType ? `: ${block.mimeType}` : ""}]`);
123
+ }
124
+ }
125
+ return parts.join("\n").trim();
126
+ }
127
+
128
+ function collapseWhitespace(text: string): string {
129
+ return text.replace(/\r\n/g, "\n").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
130
+ }
131
+
132
+ function truncateText(text: string, maxChars: number): string {
133
+ const clean = collapseWhitespace(text);
134
+ if (clean.length <= maxChars) return clean;
135
+ return `${clean.slice(0, Math.max(0, maxChars - 15)).trimEnd()}\n...[truncated]`;
136
+ }
137
+
138
+ function timestampFromMessage(entry: SessionEntryLike): string | undefined {
139
+ if (entry.timestamp) return entry.timestamp;
140
+ const ts = entry.message?.timestamp;
141
+ return typeof ts === "number" ? new Date(ts).toISOString() : undefined;
142
+ }
143
+
144
+ function entryToResumeItem(entry: SessionEntryLike, maxChars: number): ResumeItem | null {
145
+ if (entry.type === "compaction" && typeof entry.summary === "string") {
146
+ return {
147
+ label: "Compaction",
148
+ text: truncateText(entry.summary, maxChars),
149
+ timestamp: entry.timestamp,
150
+ };
151
+ }
152
+
153
+ if (entry.type === "branch_summary" && typeof entry.summary === "string") {
154
+ return {
155
+ label: "Branch summary",
156
+ text: truncateText(entry.summary, maxChars),
157
+ timestamp: entry.timestamp,
158
+ };
159
+ }
160
+
161
+ if (entry.type !== "message" || !entry.message) return null;
162
+ const message = entry.message;
163
+ const role = message.role ?? "message";
164
+
165
+ if (role === "toolResult") return null;
166
+
167
+ if (role === "bashExecution") {
168
+ const command = typeof message.command === "string" ? message.command : "";
169
+ const output = typeof message.output === "string" ? message.output : "";
170
+ const text = [`$ ${command}`, output].filter(Boolean).join("\n");
171
+ return text
172
+ ? { label: "Shell", text: truncateText(text, maxChars), timestamp: timestampFromMessage(entry) }
173
+ : null;
174
+ }
175
+
176
+ if (role === "compactionSummary" && typeof message.summary === "string") {
177
+ return {
178
+ label: "Compaction",
179
+ text: truncateText(message.summary, maxChars),
180
+ timestamp: timestampFromMessage(entry),
181
+ };
182
+ }
183
+
184
+ const text = contentToText(message.content);
185
+ if (!text) return null;
186
+
187
+ const label =
188
+ role === "user"
189
+ ? "User"
190
+ : role === "assistant"
191
+ ? "Assistant"
192
+ : role === "custom"
193
+ ? `Custom${message.customType ? ` (${message.customType})` : ""}`
194
+ : role;
195
+
196
+ return { label, text: truncateText(text, maxChars), timestamp: timestampFromMessage(entry) };
197
+ }
198
+
199
+ /** Convert branch entries into the concise items shown in the resume note. */
200
+ export function conversationItems(
201
+ entries: SessionEntryLike[],
202
+ maxChars = DEFAULT_MAX_CHARS,
203
+ ): ResumeItem[] {
204
+ return entries
205
+ .map((entry) => entryToResumeItem(entry, maxChars))
206
+ .filter((item): item is ResumeItem => item !== null);
207
+ }
208
+
209
+ function restoreCommands(sessionFile?: string, sessionId?: string): string[] {
210
+ const commands: string[] = [];
211
+ if (sessionFile) commands.push(`pi --session ${quoteShellArg(sessionFile)}`);
212
+ if (sessionId) commands.push(`pi --session ${sessionId}`);
213
+ commands.push("pi --resume");
214
+ return commands;
215
+ }
216
+
217
+ /** Render the project-local markdown resume. */
218
+ export function buildConversationResume(
219
+ entries: SessionEntryLike[],
220
+ metadata: ResumeMetadata = {},
221
+ ): string {
222
+ const generatedAt = metadata.generatedAt ?? new Date();
223
+ const maxItems = metadata.maxItems ?? DEFAULT_MAX_ITEMS;
224
+ const maxChars = metadata.maxChars ?? DEFAULT_MAX_CHARS;
225
+ const items = conversationItems(entries, maxChars).slice(-maxItems);
226
+ const commands = restoreCommands(metadata.sessionFile, metadata.sessionId);
227
+
228
+ const lines: string[] = [
229
+ "# ZERO Pi Resume",
230
+ "",
231
+ "> Local handoff note. It may contain conversation context; keep `.pi/` out of commits.",
232
+ "",
233
+ "## Restore",
234
+ "",
235
+ "Exact command:",
236
+ "",
237
+ "```powershell",
238
+ commands[0],
239
+ "```",
240
+ "",
241
+ ];
242
+
243
+ if (metadata.sessionId) {
244
+ lines.push("Same session by id:", "", "```powershell", `pi --session ${metadata.sessionId}`, "```", "");
245
+ }
246
+
247
+ lines.push(
248
+ "Open the interactive picker:",
249
+ "",
250
+ "```powershell",
251
+ "pi --resume",
252
+ "```",
253
+ "",
254
+ "## Session",
255
+ "",
256
+ `- Updated: ${generatedAt.toISOString()}`,
257
+ );
258
+
259
+ if (metadata.reason) lines.push(`- Shutdown reason: ${metadata.reason}`);
260
+ if (metadata.cwd) lines.push(`- CWD: ${metadata.cwd}`);
261
+ if (metadata.sessionFile) lines.push(`- Session file: ${metadata.sessionFile}`);
262
+ if (metadata.sessionId) lines.push(`- Session id: ${metadata.sessionId}`);
263
+
264
+ lines.push("", "## Conversation Tail", "");
265
+
266
+ if (items.length === 0) {
267
+ lines.push("_No conversation messages were available yet._", "");
268
+ } else {
269
+ for (const item of items) {
270
+ const suffix = item.timestamp ? ` - ${item.timestamp}` : "";
271
+ lines.push(`### ${item.label}${suffix}`, "", item.text, "");
272
+ }
273
+ }
274
+
275
+ lines.push(
276
+ "## Continue Prompt",
277
+ "",
278
+ "Continue from this ZERO Pi resume. If you need the full context, run the restore command above first.",
279
+ "",
280
+ );
281
+
282
+ return `${lines.join("\n").replace(/\n{3,}/g, "\n\n")}\n`;
283
+ }
284
+
285
+ function ensureResumeDir(cwd: string): string {
286
+ const dir = join(cwd, RESUME_DIR);
287
+ mkdirSync(dir, { recursive: true });
288
+
289
+ const ignorePath = join(dir, ".gitignore");
290
+ if (!existsSync(ignorePath)) {
291
+ writeFileSync(ignorePath, "*\n!.gitignore\n", "utf8");
292
+ }
293
+
294
+ return dir;
295
+ }
296
+
297
+ /** Write the resume atomically and return the path written. */
298
+ export function writeConversationResume(
299
+ cwd: string,
300
+ entries: SessionEntryLike[],
301
+ metadata: ResumeMetadata = {},
302
+ ): string {
303
+ const dir = ensureResumeDir(cwd);
304
+ const target = join(dir, RESUME_FILE);
305
+ const tmp = join(dir, `${RESUME_FILE}.tmp`);
306
+ const text = buildConversationResume(entries, metadata);
307
+ writeFileSync(tmp, text, "utf8");
308
+ renameSync(tmp, target);
309
+ return target;
310
+ }
311
+
312
+ function notify(ctx: PiContext, message: string, type: NotifyType): void {
313
+ try {
314
+ ctx.ui?.notify?.(message, type);
315
+ } catch {
316
+ // UI notifications are best-effort only.
317
+ }
318
+ }
319
+
320
+ function readBranch(ctx: PiContext): SessionEntryLike[] {
321
+ try {
322
+ return ctx.sessionManager?.getBranch?.() ?? [];
323
+ } catch {
324
+ return [];
325
+ }
326
+ }
327
+
328
+ function sessionFile(ctx: PiContext): string | undefined {
329
+ try {
330
+ return ctx.sessionManager?.getSessionFile?.();
331
+ } catch {
332
+ return undefined;
333
+ }
334
+ }
335
+
336
+ function sessionId(ctx: PiContext): string | undefined {
337
+ try {
338
+ return ctx.sessionManager?.getSessionId?.();
339
+ } catch {
340
+ return undefined;
341
+ }
342
+ }
343
+
344
+ function writeFromContext(ctx: PiContext, reason: string): string | null {
345
+ const cwd = ctx.cwd;
346
+ if (!cwd) return null;
347
+
348
+ const branch = readBranch(ctx);
349
+ if (branch.length === 0 && !sessionFile(ctx) && !sessionId(ctx)) return null;
350
+
351
+ return writeConversationResume(cwd, branch, {
352
+ cwd,
353
+ reason,
354
+ sessionFile: sessionFile(ctx),
355
+ sessionId: sessionId(ctx),
356
+ });
357
+ }
358
+
359
+ export default function register(pi?: PiAPI): void {
360
+ if (!pi) return;
361
+
362
+ if (typeof pi.on === "function") {
363
+ pi.on("session_shutdown", async (event, ctx) => {
364
+ const reason = (event as { reason?: string } | undefined)?.reason ?? "unknown";
365
+ if (reason !== "quit") return;
366
+ if (process.env.ZERO_RESUME === "off" || process.env.ZERO_RESUME === "0") return;
367
+
368
+ try {
369
+ const path = writeFromContext(ctx, reason);
370
+ if (path) notify(ctx, `zero resume written: ${path}`, "info");
371
+ } catch (err) {
372
+ notify(ctx, `zero resume failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
373
+ }
374
+ });
375
+ }
376
+
377
+ if (typeof pi.registerCommand === "function") {
378
+ pi.registerCommand("zero-resume", {
379
+ description: "Write .pi/zero-resume.md with restore command and conversation tail",
380
+ handler: async (_args, ctx) => {
381
+ try {
382
+ const path = writeFromContext(ctx, "manual");
383
+ if (!path) {
384
+ notify(ctx, "zero-resume: no persisted session context found", "warning");
385
+ return;
386
+ }
387
+ const command = sessionFile(ctx)
388
+ ? `pi --session ${quoteShellArg(sessionFile(ctx)!)}`
389
+ : sessionId(ctx)
390
+ ? `pi --session ${sessionId(ctx)}`
391
+ : "pi --resume";
392
+ notify(ctx, `zero-resume: wrote ${path}\nrestore: ${command}`, "info");
393
+ } catch (err) {
394
+ notify(ctx, `zero-resume: ${err instanceof Error ? err.message : String(err)}`, "error");
395
+ }
396
+ },
397
+ });
398
+ }
399
+ }
@@ -0,0 +1,195 @@
1
+ // zero-pi — metered-provider guard, pi wiring.
2
+ //
3
+ // A thin pi extension that wires the pure decision logic in `provider-guard.ts`
4
+ // to the `model_select` hook. Whenever pi reports a model switch, this handler
5
+ // classifies it (in the pure module) and executes the resulting `GuardAction`:
6
+ // a silent no-op for subscription providers, a non-blocking `warning` for a
7
+ // metered provider with no equivalent (or a session `restore`), or — for a
8
+ // deliberate switch to a metered provider that has an equivalent — a `confirm`
9
+ // dialog offering to redirect to the subscription provider.
10
+ //
11
+ // All decisions live in `provider-guard.ts`; this file only declares the
12
+ // minimal pi-API surface it uses, hooks `model_select`, and performs the I/O
13
+ // (`confirm`/`notify`/`setModel`). Both `register` and the handler are wrapped
14
+ // in a swallowing `try/catch` — exactly like `autotune-extension.ts`, a failure
15
+ // must never break a pi session. The package stays dependency-free: only
16
+ // `provider-guard.ts` is imported (no `node:*` is needed here, no third party).
17
+
18
+ import {
19
+ classifyModelSwitch,
20
+ redirectFailedMessage,
21
+ type ModelLike,
22
+ } from "./provider-guard.ts";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Minimal local pi-API interfaces
26
+ // ---------------------------------------------------------------------------
27
+ //
28
+ // `provider-guard-extension.ts` declares only the slice of pi's API it uses,
29
+ // exactly like `autotune-extension.ts` and `zero-models.ts`. The guard never
30
+ // depends on pi's full type surface — just the shapes below.
31
+
32
+ /** The minimum a `Model` of pi must expose for the guard. Mirrors `ModelLike`
33
+ * of the pure module — `setModel`/the registry both speak this shape. */
34
+ interface PiModel {
35
+ provider: string;
36
+ id: string;
37
+ }
38
+
39
+ /** The model registry slice the guard uses: a `find` that resolves a model by
40
+ * `(provider, id)` or returns `undefined` when no such model exists. */
41
+ interface PiModelRegistry {
42
+ find(provider: string, modelId: string): PiModel | undefined;
43
+ }
44
+
45
+ /** The slice of pi's UI surface the guard uses. `confirm` may be sync or async
46
+ * (pi versions differ); both are handled uniformly with `await`. */
47
+ interface PiUI {
48
+ confirm(title: string, message: string): Promise<boolean> | boolean;
49
+ notify(message: string, type?: "info" | "warning" | "error"): void;
50
+ }
51
+
52
+ /** The context pi passes to a `model_select` handler. `modelRegistry` is
53
+ * optional — if absent the guard degrades to warn-only (it can no longer
54
+ * resolve equivalents) rather than breaking. */
55
+ interface PiModelSelectContext {
56
+ ui: PiUI;
57
+ modelRegistry?: PiModelRegistry;
58
+ }
59
+
60
+ /** The `model_select` event pi emits on a model change. The guard only reads
61
+ * `model` and `source`; `source` is `set`/`cycle`/`restore` or, defensively,
62
+ * any other string a future pi version might report. */
63
+ interface PiModelSelectEvent {
64
+ type: "model_select";
65
+ model?: PiModel;
66
+ source?: "set" | "cycle" | "restore" | string;
67
+ }
68
+
69
+ /** The slice of pi's extension API the guard uses: `on` to hook events and
70
+ * `setModel` to apply a redirection. */
71
+ interface PiExtensionAPI {
72
+ on(
73
+ event: string,
74
+ handler: (event: PiModelSelectEvent, ctx: PiModelSelectContext) => void | Promise<void>,
75
+ ): void;
76
+ setModel(model: PiModel): Promise<boolean>;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Handler
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * The `model_select` guard handler.
85
+ *
86
+ * Runs entirely inside `register`'s swallowing `try/catch`, but is also wrapped
87
+ * again here so no failure of `find`/`confirm`/`notify`/`setModel` can ever
88
+ * propagate out of a pi session. The flow:
89
+ *
90
+ * 1. Validate `event`/`event.model` — a malformed event returns cleanly.
91
+ * 2. Build the injected `lookup` over `ctx.modelRegistry.find`, wrapped in a
92
+ * `try/catch` that returns `undefined`. If `modelRegistry` is absent,
93
+ * degrade to an always-`undefined` lookup (warn-only).
94
+ * 3. `classifyModelSwitch(model, source, lookup)` → a `GuardAction`.
95
+ * 4. Execute the action: `ignore` → no-op; `warn` → `notify(..., "warning")`;
96
+ * `offer-redirect` → `confirm`, and on a truthy answer `setModel` +
97
+ * `notify(..., "info")` (or the failure notice if `setModel` returns
98
+ * `false`). A falsy `confirm` leaves the user on the metered provider.
99
+ */
100
+ async function handleModelSelect(
101
+ event: PiModelSelectEvent,
102
+ ctx: PiModelSelectContext,
103
+ pi: PiExtensionAPI,
104
+ ): Promise<void> {
105
+ try {
106
+ // 1. Malformed event — no event, no model, or no UI to talk to: bail out.
107
+ if (!event || !event.model || !ctx || !ctx.ui || typeof ctx.ui.notify !== "function") {
108
+ return;
109
+ }
110
+
111
+ // 2. Build the injected lookup. `classifyModelSwitch` assumes a total
112
+ // lookup, so `find` is wrapped in a `try/catch` returning `undefined`.
113
+ // If `modelRegistry` is absent the guard cannot resolve equivalents —
114
+ // degrade to an always-`undefined` lookup so a metered provider still
115
+ // produces a warning rather than breaking.
116
+ const registry = ctx.modelRegistry;
117
+ const lookup = (provider: string, id: string): ModelLike | undefined => {
118
+ if (!registry || typeof registry.find !== "function") return undefined;
119
+ try {
120
+ return registry.find(provider, id);
121
+ } catch {
122
+ // A registry lookup failure degrades to "no equivalent", never throws.
123
+ return undefined;
124
+ }
125
+ };
126
+
127
+ // 3. Classify the switch in the pure module.
128
+ const action = classifyModelSwitch(event.model, event.source, lookup);
129
+
130
+ // 4. Execute the action.
131
+ if (action.kind === "ignore") {
132
+ // Non-metered provider or malformed event — nothing to do.
133
+ return;
134
+ }
135
+
136
+ if (action.kind === "warn") {
137
+ // Metered provider with no equivalent, or a session `restore` — a single
138
+ // non-blocking warning, no modal, no `setModel`.
139
+ ctx.ui.notify(action.message, "warning");
140
+ return;
141
+ }
142
+
143
+ // action.kind === "offer-redirect" — deliberate switch to a metered
144
+ // provider that has a subscription equivalent. Offer the redirect; the
145
+ // `confirm` dialog itself is the user's escape (AC 1.5).
146
+ const accepted = await ctx.ui.confirm(action.confirmTitle, action.confirmMessage);
147
+ if (!accepted) {
148
+ // User declined or cancelled — leave them on the metered provider, no
149
+ // second warning, no `setModel`.
150
+ return;
151
+ }
152
+
153
+ const applied = await pi.setModel(action.safeModel);
154
+ if (applied) {
155
+ // Redirection took effect — confirm it with an `info` notification.
156
+ ctx.ui.notify(action.redirectMessage, "info");
157
+ } else {
158
+ // `setModel` returned `false`: the switch did not apply. Do not claim
159
+ // "redirected" — emit the optional failure notice instead.
160
+ ctx.ui.notify(
161
+ redirectFailedMessage(action.safeModel.id, action.safeModel.provider),
162
+ "warning",
163
+ );
164
+ }
165
+ } catch {
166
+ // Any failure of the guard must never break a pi session.
167
+ }
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Registration
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * The pi extension entry point.
176
+ *
177
+ * pi calls this once when the extension loads. It wires the guard handler to
178
+ * the `model_select` event. The whole body is wrapped in a swallowing
179
+ * `try/catch`, and the handler is wrapped again, so neither registration nor a
180
+ * later failure can ever break a pi session. Called with no or an invalid
181
+ * `pi` (missing, or no `.on` function), it no-ops cleanly.
182
+ */
183
+ export default function register(pi?: unknown): void {
184
+ try {
185
+ if (!pi || typeof (pi as PiExtensionAPI).on !== "function") {
186
+ return;
187
+ }
188
+ const api = pi as PiExtensionAPI;
189
+ api.on("model_select", (event: PiModelSelectEvent, ctx: PiModelSelectContext): Promise<void> => {
190
+ return handleModelSelect(event, ctx, api);
191
+ });
192
+ } catch {
193
+ // Registration itself must never break a pi session.
194
+ }
195
+ }