pi-resume 1.0.0 → 1.0.2

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/LICENSE CHANGED
File without changes
package/README.md CHANGED
@@ -8,12 +8,13 @@ Built-in `/resume` reads and parses **every line** of every session file to buil
8
8
 
9
9
  ## Solution
10
10
 
11
- Two commands that use `stat()` + lazy partial reads:
11
+ Commands that use `stat()` + lazy partial reads:
12
12
 
13
13
  | Command | What it does | Speed |
14
14
  |---------|-------------|-------|
15
- | `/r2` | Instantly switch to the most recent session | <50ms (stat-only) |
15
+ | `/r1` … `/r5` | Instantly switch to the N-th most recent session (`/r1` = latest) | <50ms (stat-only) |
16
16
  | `/rs` | Paginated picker: last 20, with tier navigation | <200ms first page |
17
+ | `/rds` | Delete subagent session trees for the current project (with confirmation) | — |
17
18
 
18
19
  ## Install
19
20
 
@@ -23,9 +24,17 @@ pi install npm:pi-fast-resume
23
24
 
24
25
  ## Commands
25
26
 
26
- ### `/r2` — Instant Resume
27
+ ### `/r1` … `/r5` — Instant Ranked Resume
27
28
 
28
- Switches to the most recent session (by mtime) in one step. No picker, no parsing.
29
+ Switch to the N-th most recent session (by mtime) in one step. No picker, no parsing.
30
+
31
+ - `/r1` — most recent session
32
+ - `/r2` — 2nd most recent
33
+ - … up to `/r5` — 5th most recent
34
+
35
+ The current session is always excluded from the ranking, so `/r1` reliably jumps
36
+ to the previous session. If fewer sessions exist than the requested rank, a
37
+ notice is shown and nothing is switched.
29
38
 
30
39
  ### `/rs` — Smart Resume
31
40
 
@@ -47,9 +56,21 @@ Auto-escalates: if 7d is empty, jumps to 14d, then all.
47
56
 
48
57
  Config is stored in `~/.pi/agent/extensions/pi-fast-resume/config.json`.
49
58
 
59
+ ### `/rds` — Delete Subagent Sessions
60
+
61
+ pi stores every subagent run under a subdirectory named like a top-level
62
+ session (`<timestamp>_<uuid>/`), containing `<runId>/run-N/session.jsonl`.
63
+ These accumulate on every subagent invocation and can bloat the sessions folder
64
+ by hundreds of MB.
65
+
66
+ `/rds` scans the **current project only**, shows how many trees / runs / MB
67
+ would be freed, asks for **confirmation**, then recursively deletes just those
68
+ subagent tree subdirectories. Your real top-level `*.jsonl` sessions (the ones
69
+ `/r1`…`/r5` and `/rs` list) are never touched.
70
+
50
71
  ## How it works
51
72
 
52
- 1. **`/r2`**: `readdir` → `stat` each `.jsonl` → sort by mtime → `switchSession(newest)`
73
+ 1. **`/r1`…`/r5`**: `readdir` → `stat` each `.jsonl` → sort by mtime → exclude current → `switchSession(others[rank-1])`
53
74
  2. **`/rs`**: Same stat scan, then read only the **first ~50 lines** of each file on the current page to extract session name and first user message
54
75
 
55
76
  No full file parsing. No `buildSessionInfo()`. No reading message content beyond the first user message.
@@ -2,46 +2,111 @@
2
2
  * pi-fast-resume — fast session resume without reading all .jsonl files.
3
3
  *
4
4
  * Commands:
5
- * /r2 — instantly switch to the most recent session (stat-only)
5
+ * /r1 .. /r5 — instantly switch to the N-th most recent session
6
+ * (stat-only; /r1 = latest, /r5 = 5th, current excluded)
6
7
  * /rs — paginated session picker (last 20, "Load more", tier filter)
7
8
  * /rs set page N — set page size (1-50)
8
9
  * /rs set days N — set maxDays filter (0-30, 0 = no limit)
10
+ * /rds — delete all subagent session trees for the current project
11
+ * (asks for confirmation; top-level sessions untouched)
9
12
  */
10
13
 
11
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
- import { statScan, scanPage, readSessionMeta } from "../src/scanner.ts";
13
- import { formatEntry, truncate, sessionLabel } from "../src/format.ts";
15
+ import {
16
+ statScan,
17
+ scanPage,
18
+ readSessionMeta,
19
+ scanSubagentTrees,
20
+ deleteSubagentTrees,
21
+ } from "../src/scanner.ts";
22
+ import { formatEntry, truncate, sessionLabel, formatSize } from "../src/format.ts";
14
23
  import { loadConfig, saveConfig } from "../src/config.ts";
15
24
  import { getSessionDir } from "../src/session-dir.ts";
16
25
 
17
26
  const DAY_TIERS = [7, 14, 0] as const;
18
27
 
28
+ // How many ranked instant-resume commands to register: /r1 .. /rN.
29
+ const MAX_RANK = 5;
30
+
31
+ const ordinal = (n: number): string => {
32
+ if (n === 1) return "most recent";
33
+ const suffix = n === 2 ? "nd" : n === 3 ? "rd" : "th";
34
+ return `${n}${suffix} most recent`;
35
+ };
36
+
19
37
  export default function (pi: ExtensionAPI) {
20
- pi.registerCommand("r2", {
21
- description: "Instantly resume the most recent session",
38
+ // Register /r1 .. /rN — each jumps to the rank-th most recent session
39
+ // (by mtime, current session excluded). /r1 = latest, /r2 = 2nd, etc.
40
+ for (let rank = 1; rank <= MAX_RANK; rank++) {
41
+ pi.registerCommand(`r${rank}`, {
42
+ description: `Instantly resume the ${ordinal(rank)} session`,
43
+ handler: async (_args: string, ctx: any) => {
44
+ const sessionDir = getSessionDir(ctx.cwd);
45
+ const files = await statScan(sessionDir);
46
+
47
+ if (files.length === 0) {
48
+ ctx.ui.notify("No sessions found", "error");
49
+ return;
50
+ }
51
+
52
+ const currentFile = ctx.sessionManager.getSessionFile() ?? undefined;
53
+ const others = files.filter((f: { file: string }) => f.file !== currentFile);
54
+
55
+ const target = others[rank - 1];
56
+ if (!target) {
57
+ ctx.ui.notify(
58
+ others.length === 0
59
+ ? "No other sessions to resume"
60
+ : `Only ${others.length} other session${others.length === 1 ? "" : "s"} available`,
61
+ "info",
62
+ );
63
+ return;
64
+ }
65
+
66
+ const meta = await readSessionMeta(target.file, target);
67
+ await ctx.switchSession(target.file, {
68
+ withSession: async (newCtx: any) => {
69
+ newCtx.ui.notify(`Resumed: ${truncate(sessionLabel(meta), 50)}`, "info");
70
+ },
71
+ });
72
+ },
73
+ });
74
+ }
75
+
76
+ pi.registerCommand("rds", {
77
+ description: "Delete subagent session trees for the current project (with confirmation)",
22
78
  handler: async (_args: string, ctx: any) => {
23
79
  const sessionDir = getSessionDir(ctx.cwd);
24
- const files = await statScan(sessionDir);
80
+ const trees = await scanSubagentTrees(sessionDir);
25
81
 
26
- if (files.length === 0) {
27
- ctx.ui.notify("No sessions found", "error");
82
+ if (trees.length === 0) {
83
+ ctx.ui.notify("No subagent sessions to delete for this project", "info");
28
84
  return;
29
85
  }
30
86
 
31
- const currentFile = ctx.sessionManager.getSessionFile() ?? undefined;
32
- const target = files.find((f: { file: string }) => f.file !== currentFile);
87
+ const totalRuns = trees.reduce((s, t) => s + t.runs, 0);
88
+ const totalBytes = trees.reduce((s, t) => s + t.bytes, 0);
89
+
90
+ const summary =
91
+ `Delete ${trees.length} subagent tree${trees.length === 1 ? "" : "s"} ` +
92
+ `(${totalRuns} run${totalRuns === 1 ? "" : "s"}, ${formatSize(totalBytes)})?`;
93
+
94
+ const choice = await ctx.ui.select(summary, [
95
+ `Delete ${trees.length} tree${trees.length === 1 ? "" : "s"} (${formatSize(totalBytes)})`,
96
+ "Cancel",
97
+ ]);
33
98
 
34
- if (!target) {
35
- ctx.ui.notify("No other sessions to resume", "info");
99
+ if (!choice || choice === "Cancel") {
100
+ ctx.ui.notify("Cancelled nothing deleted", "info");
36
101
  return;
37
102
  }
38
103
 
39
- const meta = await readSessionMeta(target.file, target);
40
- await ctx.switchSession(target.file, {
41
- withSession: async (newCtx: any) => {
42
- newCtx.ui.notify(`Resumed: ${truncate(sessionLabel(meta), 50)}`, "info");
43
- },
44
- });
104
+ const removed = await deleteSubagentTrees(sessionDir, trees);
105
+ ctx.ui.notify(
106
+ `Deleted ${removed}/${trees.length} subagent tree${removed === 1 ? "" : "s"} ` +
107
+ `(${formatSize(totalBytes)} freed)`,
108
+ removed === trees.length ? "info" : "error",
109
+ );
45
110
  },
46
111
  });
47
112
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-resume",
3
- "version": "1.0.0",
4
- "description": "Fast session resume for pi coding agent — /r2 instant resume, /rs paginated picker",
3
+ "version": "1.0.2",
4
+ "description": "Fast session resume for pi coding agent — /r1../r5 ranked instant resume, /rs paginated picker, /rds subagent session cleanup",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "keywords": [
package/src/config.ts CHANGED
File without changes
package/src/format.ts CHANGED
File without changes
package/src/scanner.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * - First ~50 lines for header, session_info, first user message
7
7
  */
8
8
 
9
- import { readdir, stat } from "node:fs/promises";
9
+ import { readdir, stat, rm } from "node:fs/promises";
10
10
  import { createReadStream } from "node:fs";
11
11
  import { createInterface } from "node:readline";
12
12
  import { join } from "node:path";
@@ -165,3 +165,104 @@ export async function scanPage(
165
165
 
166
166
  return { entries, total, hasMore };
167
167
  }
168
+
169
+ // A session-tree subdir is named exactly like a top-level session file, minus
170
+ // the `.jsonl` suffix: `<ISO-timestamp>_<uuid>`. pi nests every subagent run
171
+ // (`<runId>/run-N/session.jsonl`) under such a directory. We match that shape
172
+ // so we never touch unrelated folders.
173
+ const SESSION_TREE_RE = /^\d{4}-\d{2}-\d{2}T[\dZ.-]+_[0-9a-f-]{8,}$/i;
174
+
175
+ export interface SubagentTree {
176
+ dir: string; // absolute path to the <timestamp>_<uuid> subdir
177
+ name: string; // the directory basename
178
+ runs: number; // count of run-*/session.jsonl files under it
179
+ bytes: number; // total size of the tree on disk
180
+ }
181
+
182
+ /**
183
+ * Find subagent session trees for a project dir.
184
+ *
185
+ * pi stores every subagent run under a subdirectory named like a top-level
186
+ * session (`<timestamp>_<uuid>/`), containing `<runId>/run-N/session.jsonl`.
187
+ * These accumulate fast and bloat the sessions folder. This scans ONLY the
188
+ * given project dir (non-recursive at the top), returning each matching
189
+ * subdir with its run count and on-disk size. Top-level `*.jsonl` session
190
+ * files (the real user sessions) are never included.
191
+ */
192
+ export async function scanSubagentTrees(sessionDir: string): Promise<SubagentTree[]> {
193
+ let entries: string[];
194
+ try {
195
+ entries = await readdir(sessionDir, { withFileTypes: true }) as any;
196
+ } catch {
197
+ return [];
198
+ }
199
+
200
+ const dirs = (entries as unknown as { name: string; isDirectory: () => boolean }[])
201
+ .filter((e) => e.isDirectory() && SESSION_TREE_RE.test(e.name))
202
+ .map((e) => e.name);
203
+
204
+ const trees = await Promise.all(
205
+ dirs.map(async (name) => {
206
+ const dir = join(sessionDir, name);
207
+ const { runs, bytes } = await measureTree(dir);
208
+ return { dir, name, runs, bytes };
209
+ }),
210
+ );
211
+
212
+ // Newest first (by directory-name timestamp, which sorts lexicographically).
213
+ trees.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0));
214
+ return trees;
215
+ }
216
+
217
+ /** Recursively count `session.jsonl` (run-N) files and total bytes under a dir. */
218
+ async function measureTree(dir: string): Promise<{ runs: number; bytes: number }> {
219
+ let runs = 0;
220
+ let bytes = 0;
221
+
222
+ async function walk(d: string): Promise<void> {
223
+ let items: { name: string; isDirectory: () => boolean; isFile: () => boolean }[];
224
+ try {
225
+ items = (await readdir(d, { withFileTypes: true })) as any;
226
+ } catch {
227
+ return;
228
+ }
229
+ for (const it of items) {
230
+ const full = join(d, it.name);
231
+ if (it.isDirectory()) {
232
+ await walk(full);
233
+ } else if (it.isFile()) {
234
+ try {
235
+ const s = await stat(full);
236
+ bytes += s.size;
237
+ if (it.name === "session.jsonl") runs++;
238
+ } catch {}
239
+ }
240
+ }
241
+ }
242
+
243
+ await walk(dir);
244
+ return { runs, bytes };
245
+ }
246
+
247
+ /**
248
+ * Delete a list of subagent tree directories (recursively). Returns the count
249
+ * successfully removed. Guards against deleting anything that isn't a
250
+ * session-tree subdir under the given sessionDir.
251
+ */
252
+ export async function deleteSubagentTrees(
253
+ sessionDir: string,
254
+ trees: SubagentTree[],
255
+ ): Promise<number> {
256
+ let removed = 0;
257
+ for (const t of trees) {
258
+ // Safety: the dir must live directly under sessionDir and match the shape.
259
+ if (join(sessionDir, t.name) !== t.dir || !SESSION_TREE_RE.test(t.name)) {
260
+ continue;
261
+ }
262
+ try {
263
+ await rm(t.dir, { recursive: true, force: true });
264
+ removed++;
265
+ } catch {}
266
+ }
267
+ return removed;
268
+ }
File without changes