pi-resume 1.3.2 → 1.4.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/extensions/index.ts +14 -383
- package/package.json +1 -1
- package/src/config.ts +33 -0
- package/src/ext/context.ts +65 -0
- package/src/ext/messages.ts +31 -0
- package/src/ext/picker-command.ts +108 -0
- package/src/ext/rds.ts +34 -0
- package/src/ext/resume.ts +63 -0
- package/src/ext/startup.ts +99 -0
package/extensions/index.ts
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* (stat-only; /r1 = latest, /r2 = 2nd, current excluded)
|
|
7
7
|
* pi --r N — same as /rN but at startup (pi --r 1 = resume latest)
|
|
8
8
|
* pi --r1 .. --r2 — boolean form of the same (pi --r1 = resume latest)
|
|
9
|
-
* pi --rn — alias for --r1
|
|
9
|
+
* pi --rn — alias for --r1
|
|
10
10
|
* /rn / /rp — step to the next (older) / previous (newer) session
|
|
11
|
-
* relative to the current one (by
|
|
11
|
+
* relative to the current one (by creation time)
|
|
12
12
|
* /rs — paginated session picker (last 20, "Load more", tier filter)
|
|
13
13
|
* /rs set page N — set page size (1-50)
|
|
14
14
|
* /rs set days N — set maxDays filter (0-30, 0 = no limit)
|
|
@@ -19,389 +19,20 @@
|
|
|
19
19
|
* (src/active.ts). At startup, legacy subagent fork sessions left loose by
|
|
20
20
|
* pi-subagents < 0.53 are moved once into `<parent>/forks/` (src/migrate.ts),
|
|
21
21
|
* so afterwards nothing needs filtering and every command stays stat-only.
|
|
22
|
+
*
|
|
23
|
+
* Layout: src/* are pure modules (no pi dependency, unit-tested);
|
|
24
|
+
* src/ext/* glue them to the pi API, one file per concern.
|
|
22
25
|
*/
|
|
23
26
|
|
|
24
|
-
import type {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
} from "@earendil-works/pi-coding-agent";
|
|
30
|
-
import {
|
|
31
|
-
statScan,
|
|
32
|
-
scanPage,
|
|
33
|
-
readSessionMeta,
|
|
34
|
-
scanSubagentTrees,
|
|
35
|
-
deleteSubagentTrees,
|
|
36
|
-
} from "../src/scanner.ts";
|
|
37
|
-
import { formatEntry, truncate, sessionLabel, formatSize } from "../src/format.ts";
|
|
38
|
-
import { loadConfig, saveConfig, clampPage, clampDays } from "../src/config.ts";
|
|
39
|
-
import { getSessionDir } from "../src/session-dir.ts";
|
|
40
|
-
import { rankTarget, navTarget, sortByCreated, parseRankFlag, type Hidden } from "../src/nav.ts";
|
|
41
|
-
import { buildPickerItems, resolveChoice } from "../src/picker.ts";
|
|
42
|
-
import { markActive, unmarkActive, activeElsewhere } from "../src/active.ts";
|
|
43
|
-
import { findLegacyForks, migrateLegacyForks } from "../src/migrate.ts";
|
|
44
|
-
|
|
45
|
-
const DAY_TIERS = [7, 14, 0] as const;
|
|
46
|
-
|
|
47
|
-
// How many ranked instant-resume commands to register: /r1 .. /rN.
|
|
48
|
-
const MAX_RANK = 2;
|
|
49
|
-
|
|
50
|
-
const ordinal = (n: number): string => {
|
|
51
|
-
if (n === 1) return "most recent";
|
|
52
|
-
const suffix = n === 2 ? "nd" : n === 3 ? "rd" : "th";
|
|
53
|
-
return `${n}${suffix} most recent`;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
/** Report a scan error to the user (used as statScan/scanPage onError). */
|
|
57
|
-
const scanErrorNotifier = (ctx: ExtensionContext) => (message: string) =>
|
|
58
|
-
ctx.ui.notify(message, "error");
|
|
59
|
-
|
|
60
|
-
const sessionDirFor = (ctx: ExtensionContext): string =>
|
|
61
|
-
getSessionDir(ctx.cwd, ctx.sessionManager.getSessionFile() ?? undefined);
|
|
62
|
-
|
|
63
|
-
/** Hidden-file predicate: sessions open in another live pi (one readdir). */
|
|
64
|
-
async function hiddenPredicate(): Promise<Hidden<{ file: string }>> {
|
|
65
|
-
const elsewhere = await activeElsewhere();
|
|
66
|
-
return async (f) => elsewhere.has(f.file);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Move legacy loose subagent forks into `<parent>/forks/`. Runs detached in
|
|
71
|
-
* the background at startup; only reports when something actually moved.
|
|
72
|
-
* Skips the current session and sessions held by other live pi processes.
|
|
73
|
-
*/
|
|
74
|
-
async function migrateForksInBackground(ctx: ExtensionContext): Promise<void> {
|
|
75
|
-
const sessionDir = sessionDirFor(ctx);
|
|
76
|
-
const files = await statScan(sessionDir);
|
|
77
|
-
const skip = await activeElsewhere();
|
|
78
|
-
const current = ctx.sessionManager.getSessionFile();
|
|
79
|
-
if (current) skip.add(current);
|
|
80
|
-
const forks = await findLegacyForks(files, skip);
|
|
81
|
-
if (forks.length === 0) return;
|
|
82
|
-
const moved = await migrateLegacyForks(forks);
|
|
83
|
-
if (moved > 0) {
|
|
84
|
-
ctx.ui.notify(`Moved ${moved} legacy subagent fork session${moved === 1 ? "" : "s"} into <parent>/forks/`, "info");
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Switch to a session file with a "Resumed: <label>" notice. */
|
|
89
|
-
async function switchTo(
|
|
90
|
-
ctx: ExtensionCommandContext,
|
|
91
|
-
target: { file: string; mtime: Date; size: number },
|
|
92
|
-
prefix = "Resumed",
|
|
93
|
-
): Promise<void> {
|
|
94
|
-
const meta = await readSessionMeta(target.file, target);
|
|
95
|
-
await ctx.switchSession(target.file, {
|
|
96
|
-
withSession: async (newCtx) => {
|
|
97
|
-
newCtx.ui.notify(`${prefix}: ${truncate(sessionLabel(meta), 50)}`, "info");
|
|
98
|
-
},
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Guard: session switching needs a command-capable interactive context.
|
|
104
|
-
* session_start receives a plain ExtensionContext; in TUI mode the runtime
|
|
105
|
-
* context also carries switchSession, in -p/json modes it does not.
|
|
106
|
-
*/
|
|
107
|
-
function asSwitchable(ctx: ExtensionContext): ExtensionCommandContext | undefined {
|
|
108
|
-
if (typeof (ctx as ExtensionCommandContext).switchSession === "function") {
|
|
109
|
-
return ctx as ExtensionCommandContext;
|
|
110
|
-
}
|
|
111
|
-
ctx.ui.notify("Session resume is only available in interactive mode", "error");
|
|
112
|
-
return undefined;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Resume the rank-th most recent session (1 = latest, current excluded). */
|
|
116
|
-
async function resumeRank(rank: number, baseCtx: ExtensionContext): Promise<void> {
|
|
117
|
-
const ctx = asSwitchable(baseCtx);
|
|
118
|
-
if (!ctx) return;
|
|
119
|
-
|
|
120
|
-
const files = await statScan(sessionDirFor(ctx), scanErrorNotifier(ctx));
|
|
121
|
-
if (files.length === 0) {
|
|
122
|
-
ctx.ui.notify("No sessions found", "error");
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const currentFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
|
127
|
-
const hidden = await hiddenPredicate();
|
|
128
|
-
const { target, othersCount } = await rankTarget(files, currentFile, rank, hidden);
|
|
129
|
-
|
|
130
|
-
if (!target) {
|
|
131
|
-
ctx.ui.notify(
|
|
132
|
-
othersCount === 0
|
|
133
|
-
? "No other sessions to resume"
|
|
134
|
-
: `Only ${othersCount} other session${othersCount === 1 ? "" : "s"} available`,
|
|
135
|
-
"info",
|
|
136
|
-
);
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
await switchTo(ctx, target);
|
|
141
|
-
}
|
|
27
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import { registerResumeCommands } from "../src/ext/resume.ts";
|
|
29
|
+
import { registerPicker } from "../src/ext/picker-command.ts";
|
|
30
|
+
import { registerRds } from "../src/ext/rds.ts";
|
|
31
|
+
import { registerStartup } from "../src/ext/startup.ts";
|
|
142
32
|
|
|
143
33
|
export default function (pi: ExtensionAPI) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
handler: async (_args, ctx) => resumeRank(rank, ctx),
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Startup flags: pi --r N, pi --r1 .. --r2, pi --rn (alias for --r1).
|
|
153
|
-
pi.registerFlag("r", {
|
|
154
|
-
description: `Resume the N-th most recent session at startup (1-${MAX_RANK}, 1 = latest)`,
|
|
155
|
-
type: "string",
|
|
156
|
-
});
|
|
157
|
-
for (let rank = 1; rank <= MAX_RANK; rank++) {
|
|
158
|
-
pi.registerFlag(`r${rank}`, {
|
|
159
|
-
description: `Resume the ${ordinal(rank)} session at startup`,
|
|
160
|
-
type: "boolean",
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
pi.registerFlag("rn", {
|
|
164
|
-
description: "Resume the most recent session at startup (alias for --r1)",
|
|
165
|
-
type: "boolean",
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
/** Resolve requested startup rank from flags, or undefined if none set. */
|
|
169
|
-
const startupRank = (ctx: ExtensionContext): number | undefined => {
|
|
170
|
-
const parsed = parseRankFlag(pi.getFlag("r"), MAX_RANK);
|
|
171
|
-
if (parsed) {
|
|
172
|
-
if ("error" in parsed) {
|
|
173
|
-
ctx.ui.notify(parsed.error, "error");
|
|
174
|
-
return undefined;
|
|
175
|
-
}
|
|
176
|
-
return parsed.rank;
|
|
177
|
-
}
|
|
178
|
-
for (let rank = 1; rank <= MAX_RANK; rank++) {
|
|
179
|
-
if (pi.getFlag(`r${rank}`) === true) return rank;
|
|
180
|
-
}
|
|
181
|
-
if (pi.getFlag("rn") === true) return 1;
|
|
182
|
-
return undefined;
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
pi.on("session_start", async (event: SessionStartEvent, ctx) => {
|
|
186
|
-
// Advertise which session this pi holds so other instances skip it.
|
|
187
|
-
await markActive(ctx.sessionManager.getSessionFile() ?? undefined);
|
|
188
|
-
if (event.reason !== "startup") return;
|
|
189
|
-
void migrateForksInBackground(ctx).catch(() => {});
|
|
190
|
-
const rank = startupRank(ctx);
|
|
191
|
-
if (rank === undefined) return;
|
|
192
|
-
if (!ctx.hasUI) {
|
|
193
|
-
ctx.ui.notify("--r/--rn: session resume needs interactive mode", "error");
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
// session_start only gets a plain ExtensionContext (no switchSession);
|
|
197
|
-
// dispatch our own slash command so it runs with a command context.
|
|
198
|
-
// Switching while a provider refresh is in flight aborts it and crashes
|
|
199
|
-
// pi (uncaught AbortError from the provider), so wait for it to settle.
|
|
200
|
-
try {
|
|
201
|
-
await ctx.modelRegistry.refresh();
|
|
202
|
-
} catch {}
|
|
203
|
-
pi.sendUserMessage(`/r${rank}`, { expandPromptTemplates: true });
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
// Fires on switch (followed by session_start) and on exit.
|
|
207
|
-
pi.on("session_shutdown", async () => {
|
|
208
|
-
await unmarkActive();
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
// /rn — step to the next OLDER session; /rp — step to the next NEWER one.
|
|
212
|
-
const NAV = [
|
|
213
|
-
{ cmd: "rn", dir: 1, desc: "Resume the next (older) session", edge: "Already at the oldest session" },
|
|
214
|
-
{ cmd: "rp", dir: -1, desc: "Resume the previous (newer) session", edge: "Already at the newest session" },
|
|
215
|
-
] as const;
|
|
216
|
-
|
|
217
|
-
for (const { cmd, dir, desc, edge } of NAV) {
|
|
218
|
-
pi.registerCommand(cmd, {
|
|
219
|
-
description: desc,
|
|
220
|
-
handler: async (_args, ctx) => {
|
|
221
|
-
const files = await statScan(sessionDirFor(ctx), scanErrorNotifier(ctx));
|
|
222
|
-
if (files.length === 0) {
|
|
223
|
-
ctx.ui.notify("No sessions found", "error");
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const currentFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
|
228
|
-
const hidden = await hiddenPredicate();
|
|
229
|
-
// Walk by creation time: resuming bumps mtime, which would reshuffle
|
|
230
|
-
// an mtime-ordered walk into a ping-pong between two sessions.
|
|
231
|
-
const { target, pos, total } = await navTarget(sortByCreated(files), currentFile, dir, hidden);
|
|
232
|
-
|
|
233
|
-
if (!target) {
|
|
234
|
-
ctx.ui.notify(edge, "info");
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
await switchTo(ctx, target, `Resumed (${pos}/${total})`);
|
|
239
|
-
},
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
pi.registerCommand("rds", {
|
|
244
|
-
description: "Delete subagent session trees for the current project (with confirmation)",
|
|
245
|
-
handler: async (_args, ctx) => {
|
|
246
|
-
const sessionDir = sessionDirFor(ctx);
|
|
247
|
-
const trees = await scanSubagentTrees(sessionDir);
|
|
248
|
-
|
|
249
|
-
if (trees.length === 0) {
|
|
250
|
-
ctx.ui.notify("No subagent sessions to delete for this project", "info");
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const totalRuns = trees.reduce((s, t) => s + t.runs, 0);
|
|
255
|
-
const totalBytes = trees.reduce((s, t) => s + t.bytes, 0);
|
|
256
|
-
|
|
257
|
-
const summary =
|
|
258
|
-
`Delete ${trees.length} subagent tree${trees.length === 1 ? "" : "s"} ` +
|
|
259
|
-
`(${totalRuns} run${totalRuns === 1 ? "" : "s"}, ${formatSize(totalBytes)})?`;
|
|
260
|
-
|
|
261
|
-
const choice = await ctx.ui.select(summary, [
|
|
262
|
-
`Delete ${trees.length} tree${trees.length === 1 ? "" : "s"} (${formatSize(totalBytes)})`,
|
|
263
|
-
"Cancel",
|
|
264
|
-
]);
|
|
265
|
-
|
|
266
|
-
if (!choice || choice === "Cancel") {
|
|
267
|
-
ctx.ui.notify("Cancelled — nothing deleted", "info");
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
const removed = await deleteSubagentTrees(sessionDir, trees);
|
|
272
|
-
ctx.ui.notify(
|
|
273
|
-
`Deleted ${removed}/${trees.length} subagent tree${removed === 1 ? "" : "s"} ` +
|
|
274
|
-
`(${formatSize(totalBytes)} freed)`,
|
|
275
|
-
removed === trees.length ? "info" : "error",
|
|
276
|
-
);
|
|
277
|
-
},
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
pi.registerCommand("rs", {
|
|
281
|
-
description: "Smart resume: paginated session picker (last 20, Load more, tier filter)",
|
|
282
|
-
handler: async (args, ctx) => {
|
|
283
|
-
const parts = (args || "").trim().split(/\s+/);
|
|
284
|
-
const cfg = loadConfig();
|
|
285
|
-
|
|
286
|
-
// /rs set → show current config
|
|
287
|
-
// /rs set page N | days N → update (out-of-range values are clamped)
|
|
288
|
-
if (parts[0] === "set") {
|
|
289
|
-
const key = parts[1];
|
|
290
|
-
const val = parseInt(parts[2] ?? "", 10);
|
|
291
|
-
|
|
292
|
-
if (!key) {
|
|
293
|
-
ctx.ui.notify(
|
|
294
|
-
`Current: page ${cfg.pageSize}, days ${cfg.maxDays || "off"} — /rs set page N | /rs set days N`,
|
|
295
|
-
"info",
|
|
296
|
-
);
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
let applied: string | undefined;
|
|
301
|
-
if (key === "page" && !isNaN(val)) {
|
|
302
|
-
cfg.pageSize = clampPage(val);
|
|
303
|
-
applied =
|
|
304
|
-
cfg.pageSize === val
|
|
305
|
-
? `Page size set to ${val}`
|
|
306
|
-
: `Page size clamped to ${cfg.pageSize} (valid: 1-50)`;
|
|
307
|
-
} else if (key === "days" && !isNaN(val)) {
|
|
308
|
-
cfg.maxDays = clampDays(val);
|
|
309
|
-
applied =
|
|
310
|
-
cfg.maxDays === 0
|
|
311
|
-
? "Day filter disabled"
|
|
312
|
-
: cfg.maxDays === val
|
|
313
|
-
? `Max days set to ${val}`
|
|
314
|
-
: `Max days clamped to ${cfg.maxDays} (valid: 0-30)`;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
if (!applied) {
|
|
318
|
-
ctx.ui.notify("Usage: /rs set page N (1-50) | /rs set days N (0-30)", "error");
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const saveError = saveConfig(cfg);
|
|
323
|
-
ctx.ui.notify(saveError ?? applied, saveError ? "error" : "info");
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
const sessionDir = sessionDirFor(ctx);
|
|
328
|
-
const currentFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
|
329
|
-
const onError = scanErrorNotifier(ctx);
|
|
330
|
-
// The picker SHOWS sessions open in another pi (marked) instead of hiding
|
|
331
|
-
// them, so the user can see where a "missing" chat went; selecting one is
|
|
332
|
-
// refused rather than hijacking the other instance's session.
|
|
333
|
-
const elsewhere = await activeElsewhere();
|
|
334
|
-
|
|
335
|
-
let tierIndex = 0;
|
|
336
|
-
let offset = 0;
|
|
337
|
-
|
|
338
|
-
while (true) {
|
|
339
|
-
const currentDays = DAY_TIERS[tierIndex] ?? 0;
|
|
340
|
-
const nextTierDays = DAY_TIERS[tierIndex + 1];
|
|
341
|
-
|
|
342
|
-
const { entries, total, hasMore } = await scanPage(
|
|
343
|
-
sessionDir,
|
|
344
|
-
offset,
|
|
345
|
-
cfg.pageSize,
|
|
346
|
-
currentDays > 0 ? currentDays : undefined,
|
|
347
|
-
currentFile,
|
|
348
|
-
onError,
|
|
349
|
-
);
|
|
350
|
-
|
|
351
|
-
if (entries.length === 0 && offset === 0) {
|
|
352
|
-
if (tierIndex < DAY_TIERS.length - 1) {
|
|
353
|
-
tierIndex++;
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
ctx.ui.notify("No sessions found", "info");
|
|
357
|
-
return;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
const termWidth = process.stdout.columns || 80;
|
|
361
|
-
const items = buildPickerItems(
|
|
362
|
-
entries.map((e) => formatEntry(e, termWidth, elsewhere.has(e.file))),
|
|
363
|
-
{
|
|
364
|
-
remaining: hasMore ? total - offset - entries.length : undefined,
|
|
365
|
-
nextTierLabel:
|
|
366
|
-
nextTierDays !== undefined ? (nextTierDays > 0 ? `${nextTierDays}d` : "all") : undefined,
|
|
367
|
-
},
|
|
368
|
-
);
|
|
369
|
-
|
|
370
|
-
const filterLabel = currentDays > 0 ? ` (last ${currentDays}d)` : "";
|
|
371
|
-
const rangeLabel = `Sessions ${offset + 1}-${offset + entries.length} of ${total}${filterLabel}`;
|
|
372
|
-
|
|
373
|
-
const choice = await ctx.ui.select(rangeLabel, items);
|
|
374
|
-
const action = resolveChoice(items, choice, entries.length);
|
|
375
|
-
|
|
376
|
-
switch (action.kind) {
|
|
377
|
-
case "more":
|
|
378
|
-
offset += cfg.pageSize;
|
|
379
|
-
continue;
|
|
380
|
-
case "tier":
|
|
381
|
-
tierIndex++;
|
|
382
|
-
offset = 0;
|
|
383
|
-
continue;
|
|
384
|
-
case "entry": {
|
|
385
|
-
const selected = entries[action.index];
|
|
386
|
-
if (!selected) return;
|
|
387
|
-
if (elsewhere.has(selected.file)) {
|
|
388
|
-
ctx.ui.notify("That session is open in another pi — not switching", "info");
|
|
389
|
-
continue;
|
|
390
|
-
}
|
|
391
|
-
const result = await ctx.switchSession(selected.file, {
|
|
392
|
-
withSession: async (newCtx) => {
|
|
393
|
-
newCtx.ui.notify(`Resumed: ${truncate(sessionLabel(selected), 50)}`, "info");
|
|
394
|
-
},
|
|
395
|
-
});
|
|
396
|
-
if (result.cancelled) {
|
|
397
|
-
ctx.ui.notify("Session switch was cancelled", "info");
|
|
398
|
-
}
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
401
|
-
case "cancel":
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
},
|
|
406
|
-
});
|
|
34
|
+
registerResumeCommands(pi);
|
|
35
|
+
registerPicker(pi);
|
|
36
|
+
registerRds(pi);
|
|
37
|
+
registerStartup(pi);
|
|
407
38
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-resume",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Fast session resume for pi coding agent — /r1,/r2 ranked resume, /rn and /rp step navigation, pi --r1/--rn startup flags, /rs paginated picker, /rds subagent session cleanup; skips sessions open in other pi instances, tidies legacy subagent forks",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/config.ts
CHANGED
|
@@ -52,3 +52,36 @@ export function saveConfig(cfg: Config): string | undefined {
|
|
|
52
52
|
return `Failed to save config: ${err instanceof Error ? err.message : String(err)}`;
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Apply `/rs set <key> <value>`. Pure: returns the new config + a message, or
|
|
58
|
+
* an error string for unknown keys / non-numeric values. Out-of-range values
|
|
59
|
+
* are clamped and the message says so.
|
|
60
|
+
*/
|
|
61
|
+
export function applySetting(
|
|
62
|
+
cfg: Config,
|
|
63
|
+
key: string | undefined,
|
|
64
|
+
rawValue: string | undefined,
|
|
65
|
+
): { cfg: Config; message: string } | { error: true } {
|
|
66
|
+
const val = parseInt(rawValue ?? "", 10);
|
|
67
|
+
if (isNaN(val)) return { error: true };
|
|
68
|
+
|
|
69
|
+
if (key === "page") {
|
|
70
|
+
const pageSize = clampPage(val);
|
|
71
|
+
return {
|
|
72
|
+
cfg: { ...cfg, pageSize },
|
|
73
|
+
message: pageSize === val ? `Page size set to ${val}` : `Page size clamped to ${pageSize} (valid: ${MIN_PAGE}-${MAX_PAGE})`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (key === "days") {
|
|
77
|
+
const maxDays = clampDays(val);
|
|
78
|
+
const message =
|
|
79
|
+
maxDays === 0
|
|
80
|
+
? "Day filter disabled"
|
|
81
|
+
: maxDays === val
|
|
82
|
+
? `Max days set to ${val}`
|
|
83
|
+
: `Max days clamped to ${maxDays} (valid: ${MIN_DAYS}-${MAX_DAYS})`;
|
|
84
|
+
return { cfg: { ...cfg, maxDays }, message };
|
|
85
|
+
}
|
|
86
|
+
return { error: true };
|
|
87
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared glue between pi contexts and the pure src/ modules.
|
|
3
|
+
* Everything that touches ExtensionContext but isn't a command lives here.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ExtensionContext, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { statScan, readSessionMeta, type StatResult } from "../scanner.ts";
|
|
8
|
+
import { getSessionDir } from "../session-dir.ts";
|
|
9
|
+
import { activeElsewhere } from "../active.ts";
|
|
10
|
+
import { truncate, sessionLabel } from "../format.ts";
|
|
11
|
+
import type { Hidden } from "../nav.ts";
|
|
12
|
+
import { MSG } from "./messages.ts";
|
|
13
|
+
|
|
14
|
+
export const LABEL_MAX = 50;
|
|
15
|
+
|
|
16
|
+
export const currentSessionFile = (ctx: ExtensionContext): string | undefined =>
|
|
17
|
+
ctx.sessionManager.getSessionFile() ?? undefined;
|
|
18
|
+
|
|
19
|
+
export const sessionDirFor = (ctx: ExtensionContext): string =>
|
|
20
|
+
getSessionDir(ctx.cwd, currentSessionFile(ctx));
|
|
21
|
+
|
|
22
|
+
export const notifyError = (ctx: ExtensionContext) => (message: string) => ctx.ui.notify(message, "error");
|
|
23
|
+
|
|
24
|
+
export interface SessionSet {
|
|
25
|
+
files: StatResult[];
|
|
26
|
+
currentFile: string | undefined;
|
|
27
|
+
/** Sessions open in another live pi process. */
|
|
28
|
+
elsewhere: Set<string>;
|
|
29
|
+
/** Hidden-file predicate for the nav walkers (= open elsewhere). */
|
|
30
|
+
hidden: Hidden<{ file: string }>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One stat-only scan + active-registry read, shared by every command.
|
|
35
|
+
* Returns undefined (after notifying) when there is nothing to navigate.
|
|
36
|
+
*/
|
|
37
|
+
export async function loadSessions(ctx: ExtensionContext): Promise<SessionSet | undefined> {
|
|
38
|
+
const files = await statScan(sessionDirFor(ctx), notifyError(ctx));
|
|
39
|
+
if (files.length === 0) {
|
|
40
|
+
ctx.ui.notify(MSG.noSessions, "error");
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const elsewhere = await activeElsewhere();
|
|
44
|
+
return {
|
|
45
|
+
files,
|
|
46
|
+
currentFile: currentSessionFile(ctx),
|
|
47
|
+
elsewhere,
|
|
48
|
+
hidden: async (f) => elsewhere.has(f.file),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Switch to a session file and announce it in the new session. */
|
|
53
|
+
export async function switchTo(
|
|
54
|
+
ctx: ExtensionCommandContext,
|
|
55
|
+
target: StatResult,
|
|
56
|
+
announce: (label: string) => string = MSG.resumed,
|
|
57
|
+
): Promise<{ cancelled: boolean }> {
|
|
58
|
+
const meta = await readSessionMeta(target.file, target);
|
|
59
|
+
const label = truncate(sessionLabel(meta), LABEL_MAX);
|
|
60
|
+
return ctx.switchSession(target.file, {
|
|
61
|
+
withSession: async (newCtx) => {
|
|
62
|
+
newCtx.ui.notify(announce(label), "info");
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** All user-facing strings in one place. */
|
|
2
|
+
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
3
|
+
|
|
4
|
+
export const MSG = {
|
|
5
|
+
noSessions: "No sessions found",
|
|
6
|
+
noOtherSessions: "No other sessions to resume",
|
|
7
|
+
onlyN: (n: number) => `Only ${plural(n, "other session")} available`,
|
|
8
|
+
resumed: (label: string) => `Resumed: ${label}`,
|
|
9
|
+
resumedAt: (pos: number, total: number, label: string) => `Resumed (${pos}/${total}): ${label}`,
|
|
10
|
+
atOldest: "Already at the oldest session",
|
|
11
|
+
atNewest: "Already at the newest session",
|
|
12
|
+
openElsewhere: "That session is open in another pi — not switching",
|
|
13
|
+
switchCancelled: "Session switch was cancelled",
|
|
14
|
+
startupNeedsUI: "--r/--rn: session resume needs interactive mode",
|
|
15
|
+
movedForks: (n: number) => `Moved ${plural(n, "legacy subagent fork session")} into <parent>/forks/`,
|
|
16
|
+
rds: {
|
|
17
|
+
none: "No subagent sessions to delete for this project",
|
|
18
|
+
confirm: (trees: number, runs: number, size: string) =>
|
|
19
|
+
`Delete ${plural(trees, "subagent tree")} (${plural(runs, "run")}, ${size})?`,
|
|
20
|
+
deleteOption: (trees: number, size: string) => `Delete ${plural(trees, "tree")} (${size})`,
|
|
21
|
+
cancel: "Cancel",
|
|
22
|
+
cancelled: "Cancelled — nothing deleted",
|
|
23
|
+
done: (removed: number, total: number, size: string) =>
|
|
24
|
+
`Deleted ${removed}/${total} subagent tree${removed === 1 ? "" : "s"} (${size} freed)`,
|
|
25
|
+
},
|
|
26
|
+
set: {
|
|
27
|
+
current: (page: number, days: number) =>
|
|
28
|
+
`Current: page ${page}, days ${days || "off"} — /rs set page N | /rs set days N`,
|
|
29
|
+
usage: "Usage: /rs set page N (1-50) | /rs set days N (0-30)",
|
|
30
|
+
},
|
|
31
|
+
} as const;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** /rs — paginated session picker, plus `/rs set` config. */
|
|
2
|
+
|
|
3
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { scanPage } from "../scanner.ts";
|
|
5
|
+
import { formatEntry } from "../format.ts";
|
|
6
|
+
import { loadConfig, saveConfig, applySetting, type Config } from "../config.ts";
|
|
7
|
+
import { buildPickerItems, resolveChoice } from "../picker.ts";
|
|
8
|
+
import { activeElsewhere } from "../active.ts";
|
|
9
|
+
import { currentSessionFile, sessionDirFor, notifyError, switchTo } from "./context.ts";
|
|
10
|
+
import { MSG } from "./messages.ts";
|
|
11
|
+
|
|
12
|
+
/** Day-filter tiers the picker escalates through: 7d → 14d → all. */
|
|
13
|
+
const DAY_TIERS = [7, 14, 0] as const;
|
|
14
|
+
|
|
15
|
+
const tierLabel = (days: number) => (days > 0 ? `${days}d` : "all");
|
|
16
|
+
|
|
17
|
+
function handleSet(ctx: ExtensionCommandContext, cfg: Config, key?: string, value?: string): void {
|
|
18
|
+
if (!key) {
|
|
19
|
+
ctx.ui.notify(MSG.set.current(cfg.pageSize, cfg.maxDays), "info");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const res = applySetting(cfg, key, value);
|
|
23
|
+
if ("error" in res) {
|
|
24
|
+
ctx.ui.notify(MSG.set.usage, "error");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const saveError = saveConfig(res.cfg);
|
|
28
|
+
ctx.ui.notify(saveError ?? res.message, saveError ? "error" : "info");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function runPicker(ctx: ExtensionCommandContext, cfg: Config): Promise<void> {
|
|
32
|
+
const sessionDir = sessionDirFor(ctx);
|
|
33
|
+
const currentFile = currentSessionFile(ctx);
|
|
34
|
+
const onError = notifyError(ctx);
|
|
35
|
+
// The picker SHOWS sessions open in another pi (marked) instead of hiding
|
|
36
|
+
// them, so the user can see where a "missing" chat went; selecting one is
|
|
37
|
+
// refused rather than hijacking the other instance's session.
|
|
38
|
+
const elsewhere = await activeElsewhere();
|
|
39
|
+
const termWidth = process.stdout.columns || 80;
|
|
40
|
+
|
|
41
|
+
let tierIndex = 0;
|
|
42
|
+
let offset = 0;
|
|
43
|
+
|
|
44
|
+
while (true) {
|
|
45
|
+
const days = DAY_TIERS[tierIndex] ?? 0;
|
|
46
|
+
const nextTier = DAY_TIERS[tierIndex + 1];
|
|
47
|
+
|
|
48
|
+
const { entries, total, hasMore } = await scanPage(
|
|
49
|
+
sessionDir, offset, cfg.pageSize, days > 0 ? days : undefined, currentFile, onError,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
if (entries.length === 0 && offset === 0) {
|
|
53
|
+
if (nextTier !== undefined) {
|
|
54
|
+
tierIndex++;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
ctx.ui.notify(MSG.noSessions, "info");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const items = buildPickerItems(
|
|
62
|
+
entries.map((e) => formatEntry(e, termWidth, elsewhere.has(e.file))),
|
|
63
|
+
{
|
|
64
|
+
remaining: hasMore ? total - offset - entries.length : undefined,
|
|
65
|
+
nextTierLabel: nextTier !== undefined ? tierLabel(nextTier) : undefined,
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const filter = days > 0 ? ` (last ${days}d)` : "";
|
|
70
|
+
const title = `Sessions ${offset + 1}-${offset + entries.length} of ${total}${filter}`;
|
|
71
|
+
const action = resolveChoice(items, await ctx.ui.select(title, items), entries.length);
|
|
72
|
+
|
|
73
|
+
switch (action.kind) {
|
|
74
|
+
case "more":
|
|
75
|
+
offset += cfg.pageSize;
|
|
76
|
+
continue;
|
|
77
|
+
case "tier":
|
|
78
|
+
tierIndex++;
|
|
79
|
+
offset = 0;
|
|
80
|
+
continue;
|
|
81
|
+
case "entry": {
|
|
82
|
+
const selected = entries[action.index];
|
|
83
|
+
if (!selected) return;
|
|
84
|
+
if (elsewhere.has(selected.file)) {
|
|
85
|
+
ctx.ui.notify(MSG.openElsewhere, "info");
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const result = await switchTo(ctx, selected);
|
|
89
|
+
if (result.cancelled) ctx.ui.notify(MSG.switchCancelled, "info");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
case "cancel":
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function registerPicker(pi: ExtensionAPI): void {
|
|
99
|
+
pi.registerCommand("rs", {
|
|
100
|
+
description: "Smart resume: paginated session picker (last 20, Load more, tier filter)",
|
|
101
|
+
handler: async (args, ctx) => {
|
|
102
|
+
const [sub, key, value] = (args || "").trim().split(/\s+/);
|
|
103
|
+
const cfg = loadConfig();
|
|
104
|
+
if (sub === "set") return handleSet(ctx, cfg, key, value);
|
|
105
|
+
await runPicker(ctx, cfg);
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
package/src/ext/rds.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** /rds — delete subagent session trees for the current project. */
|
|
2
|
+
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { scanSubagentTrees, deleteSubagentTrees } from "../scanner.ts";
|
|
5
|
+
import { formatSize } from "../format.ts";
|
|
6
|
+
import { sessionDirFor } from "./context.ts";
|
|
7
|
+
import { MSG } from "./messages.ts";
|
|
8
|
+
|
|
9
|
+
export function registerRds(pi: ExtensionAPI): void {
|
|
10
|
+
pi.registerCommand("rds", {
|
|
11
|
+
description: "Delete subagent session trees for the current project (with confirmation)",
|
|
12
|
+
handler: async (_args, ctx) => {
|
|
13
|
+
const sessionDir = sessionDirFor(ctx);
|
|
14
|
+
const trees = await scanSubagentTrees(sessionDir);
|
|
15
|
+
if (trees.length === 0) {
|
|
16
|
+
ctx.ui.notify(MSG.rds.none, "info");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const runs = trees.reduce((s, t) => s + t.runs, 0);
|
|
21
|
+
const size = formatSize(trees.reduce((s, t) => s + t.bytes, 0));
|
|
22
|
+
const deleteOption = MSG.rds.deleteOption(trees.length, size);
|
|
23
|
+
|
|
24
|
+
const choice = await ctx.ui.select(MSG.rds.confirm(trees.length, runs, size), [deleteOption, MSG.rds.cancel]);
|
|
25
|
+
if (choice !== deleteOption) {
|
|
26
|
+
ctx.ui.notify(MSG.rds.cancelled, "info");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const removed = await deleteSubagentTrees(sessionDir, trees);
|
|
31
|
+
ctx.ui.notify(MSG.rds.done(removed, trees.length, size), removed === trees.length ? "info" : "error");
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /r1 .. /rN — ranked resume, and /rn //rp — step navigation.
|
|
3
|
+
* Both are "pick a target from the scanned list, then switchTo()".
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { rankTarget, navTarget, sortByCreated } from "../nav.ts";
|
|
8
|
+
import { loadSessions, switchTo } from "./context.ts";
|
|
9
|
+
import { MSG } from "./messages.ts";
|
|
10
|
+
|
|
11
|
+
/** How many /rN commands (and --rN flags) to register. */
|
|
12
|
+
export const MAX_RANK = 2;
|
|
13
|
+
|
|
14
|
+
export const ordinal = (n: number): string => {
|
|
15
|
+
if (n === 1) return "most recent";
|
|
16
|
+
const suffix = n === 2 ? "nd" : n === 3 ? "rd" : "th";
|
|
17
|
+
return `${n}${suffix} most recent`;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Resume the rank-th most recent session (1 = latest, current excluded). */
|
|
21
|
+
export async function resumeRank(rank: number, ctx: ExtensionCommandContext): Promise<void> {
|
|
22
|
+
const s = await loadSessions(ctx);
|
|
23
|
+
if (!s) return;
|
|
24
|
+
|
|
25
|
+
const { target, othersCount } = await rankTarget(s.files, s.currentFile, rank, s.hidden);
|
|
26
|
+
if (!target) {
|
|
27
|
+
ctx.ui.notify(othersCount === 0 ? MSG.noOtherSessions : MSG.onlyN(othersCount), "info");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
await switchTo(ctx, target);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Step one session older (dir = 1) or newer (dir = -1) than the current. */
|
|
34
|
+
export async function resumeStep(dir: 1 | -1, ctx: ExtensionCommandContext): Promise<void> {
|
|
35
|
+
const s = await loadSessions(ctx);
|
|
36
|
+
if (!s) return;
|
|
37
|
+
|
|
38
|
+
// Walk by creation time: resuming bumps mtime, which would reshuffle an
|
|
39
|
+
// mtime-ordered walk into a ping-pong between two sessions.
|
|
40
|
+
const { target, pos, total } = await navTarget(sortByCreated(s.files), s.currentFile, dir, s.hidden);
|
|
41
|
+
if (!target) {
|
|
42
|
+
ctx.ui.notify(dir === 1 ? MSG.atOldest : MSG.atNewest, "info");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
await switchTo(ctx, target, (label) => MSG.resumedAt(pos, total, label));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function registerResumeCommands(pi: ExtensionAPI): void {
|
|
49
|
+
for (let rank = 1; rank <= MAX_RANK; rank++) {
|
|
50
|
+
pi.registerCommand(`r${rank}`, {
|
|
51
|
+
description: `Instantly resume the ${ordinal(rank)} session`,
|
|
52
|
+
handler: async (_args, ctx) => resumeRank(rank, ctx),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
pi.registerCommand("rn", {
|
|
56
|
+
description: "Resume the next (older) session",
|
|
57
|
+
handler: async (_args, ctx) => resumeStep(1, ctx),
|
|
58
|
+
});
|
|
59
|
+
pi.registerCommand("rp", {
|
|
60
|
+
description: "Resume the previous (newer) session",
|
|
61
|
+
handler: async (_args, ctx) => resumeStep(-1, ctx),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Startup flags (pi --r N / --rN / --rn), active-session registry hooks and
|
|
3
|
+
* the one-off legacy fork migration.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ExtensionAPI, ExtensionContext, SessionStartEvent } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { statScan } from "../scanner.ts";
|
|
8
|
+
import { parseRankFlag } from "../nav.ts";
|
|
9
|
+
import { markActive, unmarkActive, activeElsewhere } from "../active.ts";
|
|
10
|
+
import { findLegacyForks, migrateLegacyForks } from "../migrate.ts";
|
|
11
|
+
import { currentSessionFile, sessionDirFor } from "./context.ts";
|
|
12
|
+
import { MAX_RANK, ordinal } from "./resume.ts";
|
|
13
|
+
import { MSG } from "./messages.ts";
|
|
14
|
+
|
|
15
|
+
export type FlagReader = (name: string) => unknown;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Which rank the startup flags ask for: --r N wins, then --r1..--rN, then --rn.
|
|
19
|
+
* Pure: takes a flag reader so it can be unit-tested without pi.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveStartupRank(
|
|
22
|
+
getFlag: FlagReader,
|
|
23
|
+
maxRank = MAX_RANK,
|
|
24
|
+
): { rank: number } | { error: string } | undefined {
|
|
25
|
+
const parsed = parseRankFlag(getFlag("r"), maxRank);
|
|
26
|
+
if (parsed) return parsed;
|
|
27
|
+
for (let rank = 1; rank <= maxRank; rank++) {
|
|
28
|
+
if (getFlag(`r${rank}`) === true) return { rank };
|
|
29
|
+
}
|
|
30
|
+
if (getFlag("rn") === true) return { rank: 1 };
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Move legacy loose subagent forks into `<parent>/forks/`. Runs detached in
|
|
36
|
+
* the background at startup; only reports when something actually moved.
|
|
37
|
+
* Skips the current session and sessions held by other live pi processes.
|
|
38
|
+
*/
|
|
39
|
+
async function migrateForksInBackground(ctx: ExtensionContext): Promise<void> {
|
|
40
|
+
const files = await statScan(sessionDirFor(ctx));
|
|
41
|
+
const skip = await activeElsewhere();
|
|
42
|
+
const current = currentSessionFile(ctx);
|
|
43
|
+
if (current) skip.add(current);
|
|
44
|
+
const forks = await findLegacyForks(files, skip);
|
|
45
|
+
if (forks.length === 0) return;
|
|
46
|
+
const moved = await migrateLegacyForks(forks);
|
|
47
|
+
if (moved > 0) ctx.ui.notify(MSG.movedForks(moved), "info");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function resumeAtStartup(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
51
|
+
const wanted = resolveStartupRank((name) => pi.getFlag(name));
|
|
52
|
+
if (!wanted) return;
|
|
53
|
+
if ("error" in wanted) {
|
|
54
|
+
ctx.ui.notify(wanted.error, "error");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (!ctx.hasUI) {
|
|
58
|
+
ctx.ui.notify(MSG.startupNeedsUI, "error");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// session_start only gets a plain ExtensionContext (no switchSession), so
|
|
62
|
+
// dispatch our own slash command to run with a command context. Switching
|
|
63
|
+
// while a provider refresh is in flight aborts it and crashes pi (uncaught
|
|
64
|
+
// AbortError from the provider), so let the refresh finish first.
|
|
65
|
+
try {
|
|
66
|
+
await ctx.modelRegistry.refresh();
|
|
67
|
+
} catch {}
|
|
68
|
+
pi.sendUserMessage(`/r${wanted.rank}`, { expandPromptTemplates: true });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function registerStartup(pi: ExtensionAPI): void {
|
|
72
|
+
pi.registerFlag("r", {
|
|
73
|
+
description: `Resume the N-th most recent session at startup (1-${MAX_RANK}, 1 = latest)`,
|
|
74
|
+
type: "string",
|
|
75
|
+
});
|
|
76
|
+
for (let rank = 1; rank <= MAX_RANK; rank++) {
|
|
77
|
+
pi.registerFlag(`r${rank}`, {
|
|
78
|
+
description: `Resume the ${ordinal(rank)} session at startup`,
|
|
79
|
+
type: "boolean",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
pi.registerFlag("rn", {
|
|
83
|
+
description: "Resume the most recent session at startup (alias for --r1)",
|
|
84
|
+
type: "boolean",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
pi.on("session_start", async (event: SessionStartEvent, ctx) => {
|
|
88
|
+
// Advertise which session this pi holds so other instances skip it.
|
|
89
|
+
await markActive(currentSessionFile(ctx));
|
|
90
|
+
if (event.reason !== "startup") return;
|
|
91
|
+
void migrateForksInBackground(ctx).catch(() => {});
|
|
92
|
+
await resumeAtStartup(pi, ctx);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Fires on switch (followed by session_start) and on exit.
|
|
96
|
+
pi.on("session_shutdown", async () => {
|
|
97
|
+
await unmarkActive();
|
|
98
|
+
});
|
|
99
|
+
}
|