@gonrocca/zero-pi 0.1.11 → 0.1.12

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.
@@ -1,399 +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
- }
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
+ }