@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 +82 -5
- package/extensions/conversation-resume.ts +399 -0
- package/extensions/provider-guard-extension.ts +195 -0
- package/extensions/provider-guard.ts +183 -0
- package/extensions/startup-banner.ts +120 -151
- package/extensions/working-phrases.ts +295 -0
- package/package.json +13 -2
- package/themes/zero-sdd.json +76 -0
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
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
198
|
-
| `static` |
|
|
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
|
+
}
|