pi-resume 1.0.2 → 1.2.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/README.md CHANGED
@@ -12,7 +12,9 @@ Commands that use `stat()` + lazy partial reads:
12
12
 
13
13
  | Command | What it does | Speed |
14
14
  |---------|-------------|-------|
15
- | `/r1` `/r5` | Instantly switch to the N-th most recent session (`/r1` = latest) | <50ms (stat-only) |
15
+ | `/r1` / `/r2` | Instantly switch to the latest / 2nd most recent session | <50ms (stat-only) |
16
+ | `pi --r N` / `--r1` / `--r2` / `--rn` | Startup flag: open pi and immediately resume the N-th most recent session | <50ms (stat-only) |
17
+ | `/rn` / `/rp` | Step to the next (older) / previous (newer) session relative to the current one | <50ms (stat-only) |
16
18
  | `/rs` | Paginated picker: last 20, with tier navigation | <200ms first page |
17
19
  | `/rds` | Delete subagent session trees for the current project (with confirmation) | — |
18
20
 
@@ -24,18 +26,46 @@ pi install npm:pi-fast-resume
24
26
 
25
27
  ## Commands
26
28
 
27
- ### `/r1` `/r5` — Instant Ranked Resume
29
+ ### `/r1` / `/r2` — Instant Ranked Resume
28
30
 
29
31
  Switch to the N-th most recent session (by mtime) in one step. No picker, no parsing.
30
32
 
31
33
  - `/r1` — most recent session
32
34
  - `/r2` — 2nd most recent
33
- - … up to `/r5` — 5th most recent
35
+
36
+ For anything further back use `/rn` / `/rp` — the rank changes every time you
37
+ switch (the current session is excluded), so deep ranks are more confusing
38
+ than stepping.
34
39
 
35
40
  The current session is always excluded from the ranking, so `/r1` reliably jumps
36
41
  to the previous session. If fewer sessions exist than the requested rank, a
37
42
  notice is shown and nothing is switched.
38
43
 
44
+ ### `pi --r N` — Resume at Startup
45
+
46
+ Start pi and immediately switch to the N-th most recent session (1-2):
47
+
48
+ ```bash
49
+ pi --r1 # open pi in the latest session
50
+ pi --rn # same (alias for --r1)
51
+ pi --r2 # open pi in the 2nd most recent
52
+ pi --r 2 # same, numeric form
53
+ ```
54
+
55
+ Same ranking as `/r1`/`/r2`. Invalid values show an error and start a normal
56
+ new session. Interactive mode only (ignored with `-p`).
57
+
58
+ ### `/rn` / `/rp` — Step Navigation
59
+
60
+ Walk the mtime-sorted session list relative to the **current** session:
61
+
62
+ - `/rn` — next session (one step **older**)
63
+ - `/rp` — previous session (one step **newer**)
64
+
65
+ Useful after `/r1` lands on the wrong session: keep pressing `/rn` to walk back
66
+ in time instead of recalculating ranks. A `(pos/total)` indicator is shown on
67
+ each switch. At the ends of the list a notice is shown and nothing is switched.
68
+
39
69
  ### `/rs` — Smart Resume
40
70
 
41
71
  Shows a paginated list of recent sessions:
@@ -50,8 +80,9 @@ Auto-escalates: if 7d is empty, jumps to 14d, then all.
50
80
  ### Configuration
51
81
 
52
82
  ```
53
- /rs set page 30 # Sessions per page (1-50, default: 20)
54
- /rs set days 14 # Day filter for first tier (0-30, 0 = no filter, default: 7)
83
+ /rs set # Show current settings
84
+ /rs set page 30 # Sessions per page (1-50, default: 20; out-of-range values are clamped)
85
+ /rs set days 14 # Day filter for first tier (0-30, 0 = no filter, default: 7; clamped)
55
86
  ```
56
87
 
57
88
  Config is stored in `~/.pi/agent/extensions/pi-fast-resume/config.json`.
@@ -66,11 +97,32 @@ by hundreds of MB.
66
97
  `/rds` scans the **current project only**, shows how many trees / runs / MB
67
98
  would be freed, asks for **confirmation**, then recursively deletes just those
68
99
  subagent tree subdirectories. Your real top-level `*.jsonl` sessions (the ones
69
- `/r1`…`/r5` and `/rs` list) are never touched.
100
+ `/r1`/`/r2` and `/rs` list) are never touched.
101
+
102
+ ### Sessions open in another pi are skipped
103
+
104
+ Each pi instance with this extension records its current session file in
105
+ `~/.pi/agent/extensions/pi-fast-resume/active/<pid>.json` (removed on exit;
106
+ dead pids are ignored). `/r1`, `/r2`, `/rn`, `/rp`, `/rs` and `--r` never land
107
+ on a chat you have open in another terminal.
108
+
109
+ ### Legacy subagent forks are tidied up once
110
+
111
+ pi-subagents < 0.53 stored forked child sessions **loose** in the project
112
+ sessions dir — same filename shape and `parentSession` header as your own
113
+ `/fork`s, so they polluted `/resume` and every navigation command. Since 0.53
114
+ the canonical place is `<parent>/forks/<file>.jsonl`.
115
+
116
+ On startup this extension moves such files there (detached, in the background,
117
+ one time per file). A file is moved only if it has a `parentSession` header
118
+ **and** contains the delegated-subagent task prompt or a `subagent-*` session
119
+ name; manual `/fork`s are untouched. The current session and sessions open in
120
+ other pi instances are skipped; existing targets are never overwritten. After
121
+ that, nothing needs filtering and every command is stat-only again.
70
122
 
71
123
  ## How it works
72
124
 
73
- 1. **`/r1`…`/r5`**: `readdir` → `stat` each `.jsonl` → sort by mtime → exclude current → `switchSession(others[rank-1])`
125
+ 1. **`/r1`/`/r2`**: `readdir` → `stat` each `.jsonl` → sort by mtime → exclude current + open elsewhere → `switchSession(others[rank-1])`
74
126
  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
75
127
 
76
128
  No full file parsing. No `buildSessionInfo()`. No reading message content beyond the first user message.
@@ -2,16 +2,31 @@
2
2
  * pi-fast-resume — fast session resume without reading all .jsonl files.
3
3
  *
4
4
  * Commands:
5
- * /r1 .. /r5 — instantly switch to the N-th most recent session
6
- * (stat-only; /r1 = latest, /r5 = 5th, current excluded)
5
+ * /r1 .. /r2 — instantly switch to the N-th most recent session
6
+ * (stat-only; /r1 = latest, /r2 = 2nd, current excluded)
7
+ * pi --r N — same as /rN but at startup (pi --r 1 = resume latest)
8
+ * pi --r1 .. --r2 — boolean form of the same (pi --r1 = resume latest)
9
+ * pi --rn — alias for --r1 (mirrors /rn from a fresh session)
10
+ * /rn / /rp — step to the next (older) / previous (newer) session
11
+ * relative to the current one (by mtime)
7
12
  * /rs — paginated session picker (last 20, "Load more", tier filter)
8
13
  * /rs set page N — set page size (1-50)
9
14
  * /rs set days N — set maxDays filter (0-30, 0 = no limit)
10
15
  * /rds — delete all subagent session trees for the current project
11
16
  * (asks for confirmation; top-level sessions untouched)
17
+ *
18
+ * All navigation skips sessions currently open in another live pi process
19
+ * (src/active.ts). At startup, legacy subagent fork sessions left loose by
20
+ * pi-subagents < 0.53 are moved once into `<parent>/forks/` (src/migrate.ts),
21
+ * so afterwards nothing needs filtering and every command stays stat-only.
12
22
  */
13
23
 
14
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
24
+ import type {
25
+ ExtensionAPI,
26
+ ExtensionContext,
27
+ ExtensionCommandContext,
28
+ SessionStartEvent,
29
+ } from "@earendil-works/pi-coding-agent";
15
30
  import {
16
31
  statScan,
17
32
  scanPage,
@@ -20,13 +35,17 @@ import {
20
35
  deleteSubagentTrees,
21
36
  } from "../src/scanner.ts";
22
37
  import { formatEntry, truncate, sessionLabel, formatSize } from "../src/format.ts";
23
- import { loadConfig, saveConfig } from "../src/config.ts";
38
+ import { loadConfig, saveConfig, clampPage, clampDays } from "../src/config.ts";
24
39
  import { getSessionDir } from "../src/session-dir.ts";
40
+ import { rankTarget, navTarget, 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";
25
44
 
26
45
  const DAY_TIERS = [7, 14, 0] as const;
27
46
 
28
47
  // How many ranked instant-resume commands to register: /r1 .. /rN.
29
- const MAX_RANK = 5;
48
+ const MAX_RANK = 2;
30
49
 
31
50
  const ordinal = (n: number): string => {
32
51
  if (n === 1) return "most recent";
@@ -34,49 +53,183 @@ const ordinal = (n: number): string => {
34
53
  return `${n}${suffix} most recent`;
35
54
  };
36
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
+ }
142
+
37
143
  export default function (pi: ExtensionAPI) {
38
- // Register /r1 .. /rN — each jumps to the rank-th most recent session
39
- // (by mtime, current session excluded). /r1 = latest, /r2 = 2nd, etc.
144
+ // /r1 .. /rN — jump to the rank-th most recent session.
40
145
  for (let rank = 1; rank <= MAX_RANK; rank++) {
41
146
  pi.registerCommand(`r${rank}`, {
42
147
  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);
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
+ });
46
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) await resumeRank(rank, ctx);
192
+ });
193
+
194
+ // Fires on switch (followed by session_start) and on exit.
195
+ pi.on("session_shutdown", async () => {
196
+ await unmarkActive();
197
+ });
198
+
199
+ // /rn — step to the next OLDER session; /rp — step to the next NEWER one.
200
+ const NAV = [
201
+ { cmd: "rn", dir: 1, desc: "Resume the next (older) session", edge: "Already at the oldest session" },
202
+ { cmd: "rp", dir: -1, desc: "Resume the previous (newer) session", edge: "Already at the newest session" },
203
+ ] as const;
204
+
205
+ for (const { cmd, dir, desc, edge } of NAV) {
206
+ pi.registerCommand(cmd, {
207
+ description: desc,
208
+ handler: async (_args, ctx) => {
209
+ const files = await statScan(sessionDirFor(ctx), scanErrorNotifier(ctx));
47
210
  if (files.length === 0) {
48
211
  ctx.ui.notify("No sessions found", "error");
49
212
  return;
50
213
  }
51
214
 
52
215
  const currentFile = ctx.sessionManager.getSessionFile() ?? undefined;
53
- const others = files.filter((f: { file: string }) => f.file !== currentFile);
216
+ const hidden = await hiddenPredicate();
217
+ const { target, pos, total } = await navTarget(files, currentFile, dir, hidden);
54
218
 
55
- const target = others[rank - 1];
56
219
  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
- );
220
+ ctx.ui.notify(edge, "info");
63
221
  return;
64
222
  }
65
223
 
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
- });
224
+ await switchTo(ctx, target, `Resumed (${pos}/${total})`);
72
225
  },
73
226
  });
74
227
  }
75
228
 
76
229
  pi.registerCommand("rds", {
77
230
  description: "Delete subagent session trees for the current project (with confirmation)",
78
- handler: async (_args: string, ctx: any) => {
79
- const sessionDir = getSessionDir(ctx.cwd);
231
+ handler: async (_args, ctx) => {
232
+ const sessionDir = sessionDirFor(ctx);
80
233
  const trees = await scanSubagentTrees(sessionDir);
81
234
 
82
235
  if (trees.length === 0) {
@@ -112,34 +265,55 @@ export default function (pi: ExtensionAPI) {
112
265
 
113
266
  pi.registerCommand("rs", {
114
267
  description: "Smart resume: paginated session picker (last 20, Load more, tier filter)",
115
- handler: async (args: string, ctx: any) => {
268
+ handler: async (args, ctx) => {
116
269
  const parts = (args || "").trim().split(/\s+/);
117
270
  const cfg = loadConfig();
118
271
 
119
- // /rs set page N | /rs set days N
272
+ // /rs set show current config
273
+ // /rs set page N | days N → update (out-of-range values are clamped)
120
274
  if (parts[0] === "set") {
121
275
  const key = parts[1];
122
- const val = parseInt(parts[2], 10);
276
+ const val = parseInt(parts[2] ?? "", 10);
123
277
 
124
- if (key === "page" && !isNaN(val) && val >= 1 && val <= 50) {
125
- cfg.pageSize = val;
126
- saveConfig(cfg);
127
- ctx.ui.notify(`Page size set to ${val}`, "info");
278
+ if (!key) {
279
+ ctx.ui.notify(
280
+ `Current: page ${cfg.pageSize}, days ${cfg.maxDays || "off"} — /rs set page N | /rs set days N`,
281
+ "info",
282
+ );
128
283
  return;
129
284
  }
130
- if (key === "days" && !isNaN(val) && val >= 0 && val <= 30) {
131
- cfg.maxDays = val;
132
- saveConfig(cfg);
133
- ctx.ui.notify(val === 0 ? "Day filter disabled" : `Max days set to ${val}`, "info");
285
+
286
+ let applied: string | undefined;
287
+ if (key === "page" && !isNaN(val)) {
288
+ cfg.pageSize = clampPage(val);
289
+ applied =
290
+ cfg.pageSize === val
291
+ ? `Page size set to ${val}`
292
+ : `Page size clamped to ${cfg.pageSize} (valid: 1-50)`;
293
+ } else if (key === "days" && !isNaN(val)) {
294
+ cfg.maxDays = clampDays(val);
295
+ applied =
296
+ cfg.maxDays === 0
297
+ ? "Day filter disabled"
298
+ : cfg.maxDays === val
299
+ ? `Max days set to ${val}`
300
+ : `Max days clamped to ${cfg.maxDays} (valid: 0-30)`;
301
+ }
302
+
303
+ if (!applied) {
304
+ ctx.ui.notify("Usage: /rs set page N (1-50) | /rs set days N (0-30)", "error");
134
305
  return;
135
306
  }
136
307
 
137
- ctx.ui.notify("Usage: /rs set page N (1-50) | /rs set days N (0-30)", "error");
308
+ const saveError = saveConfig(cfg);
309
+ ctx.ui.notify(saveError ?? applied, saveError ? "error" : "info");
138
310
  return;
139
311
  }
140
312
 
141
- const sessionDir = getSessionDir(ctx.cwd);
313
+ const sessionDir = sessionDirFor(ctx);
142
314
  const currentFile = ctx.sessionManager.getSessionFile() ?? undefined;
315
+ const onError = scanErrorNotifier(ctx);
316
+ const hidden = await hiddenPredicate();
143
317
 
144
318
  let tierIndex = 0;
145
319
  let offset = 0;
@@ -154,6 +328,8 @@ export default function (pi: ExtensionAPI) {
154
328
  cfg.pageSize,
155
329
  currentDays > 0 ? currentDays : undefined,
156
330
  currentFile,
331
+ onError,
332
+ hidden,
157
333
  );
158
334
 
159
335
  if (entries.length === 0 && offset === 0) {
@@ -165,58 +341,46 @@ export default function (pi: ExtensionAPI) {
165
341
  return;
166
342
  }
167
343
 
168
- const items: string[] = entries.map((e) => formatEntry(e));
169
-
170
- if (hasMore) {
171
- const remaining = total - offset - entries.length;
172
- items.push(`▼ Load more... (${remaining} remaining)`);
173
- }
174
-
175
- if (nextTierDays !== undefined) {
176
- const tierLabel = nextTierDays > 0 ? `${nextTierDays}d` : "all";
177
- items.push(`▼ Show ${tierLabel}`);
178
- }
344
+ const termWidth = process.stdout.columns || 80;
345
+ const items = buildPickerItems(
346
+ entries.map((e) => formatEntry(e, termWidth)),
347
+ {
348
+ remaining: hasMore ? total - offset - entries.length : undefined,
349
+ nextTierLabel:
350
+ nextTierDays !== undefined ? (nextTierDays > 0 ? `${nextTierDays}d` : "all") : undefined,
351
+ },
352
+ );
179
353
 
180
354
  const filterLabel = currentDays > 0 ? ` (last ${currentDays}d)` : "";
181
- const rangeLabel =
182
- offset === 0
183
- ? `Sessions 1-${entries.length} of ${total}${filterLabel}`
184
- : `Sessions ${offset + 1}-${offset + entries.length} of ${total}${filterLabel}`;
355
+ const rangeLabel = `Sessions ${offset + 1}-${offset + entries.length} of ${total}${filterLabel}`;
185
356
 
186
357
  const choice = await ctx.ui.select(rangeLabel, items);
358
+ const action = resolveChoice(items, choice, entries.length);
187
359
 
188
- if (choice === undefined || choice === null) return;
189
-
190
- const choiceIndex = items.indexOf(choice);
191
-
192
- if (hasMore && choiceIndex === entries.length) {
193
- offset += cfg.pageSize;
194
- continue;
195
- }
196
-
197
- if (choice.startsWith("▼ Show ") && nextTierDays !== undefined) {
198
- tierIndex++;
199
- offset = 0;
200
- continue;
201
- }
202
-
203
- if (choiceIndex >= 0 && choiceIndex < entries.length) {
204
- const selected = entries[choiceIndex];
205
- if (!selected) return;
206
-
207
- const result = await ctx.switchSession(selected.file, {
208
- withSession: async (newCtx: any) => {
209
- newCtx.ui.notify(`Resumed: ${truncate(sessionLabel(selected), 50)}`, "info");
210
- },
211
- });
212
-
213
- if (result.cancelled) {
214
- ctx.ui.notify("Session switch was cancelled", "info");
360
+ switch (action.kind) {
361
+ case "more":
362
+ offset += cfg.pageSize;
363
+ continue;
364
+ case "tier":
365
+ tierIndex++;
366
+ offset = 0;
367
+ continue;
368
+ case "entry": {
369
+ const selected = entries[action.index];
370
+ if (!selected) return;
371
+ const result = await ctx.switchSession(selected.file, {
372
+ withSession: async (newCtx) => {
373
+ newCtx.ui.notify(`Resumed: ${truncate(sessionLabel(selected), 50)}`, "info");
374
+ },
375
+ });
376
+ if (result.cancelled) {
377
+ ctx.ui.notify("Session switch was cancelled", "info");
378
+ }
379
+ return;
215
380
  }
216
- return;
381
+ case "cancel":
382
+ return;
217
383
  }
218
-
219
- return;
220
384
  }
221
385
  },
222
386
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-resume",
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",
3
+ "version": "1.2.0",
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",
7
7
  "keywords": [
package/src/active.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Registry of sessions currently open in live pi processes on this machine.
3
+ *
4
+ * Every pi instance that loads this extension writes
5
+ * `<pi-agent-dir>/extensions/pi-fast-resume/active/<pid>.json` = { file }
6
+ * on session_start and removes it on session_shutdown. Readers skip records
7
+ * whose pid is dead (crash / kill -9), so stale files are harmless and are
8
+ * cleaned up opportunistically.
9
+ */
10
+
11
+ import { readdir, readFile, writeFile, mkdir, unlink } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { getPiAgentDir } from "./pi-dir.ts";
14
+
15
+ const activeDir = (): string => join(getPiAgentDir(), "extensions", "pi-fast-resume", "active");
16
+ const recordPath = (pid: number): string => join(activeDir(), `${pid}.json`);
17
+
18
+ function pidAlive(pid: number): boolean {
19
+ try {
20
+ process.kill(pid, 0);
21
+ return true;
22
+ } catch (err: any) {
23
+ // EPERM = exists but not ours; still alive.
24
+ return err?.code === "EPERM";
25
+ }
26
+ }
27
+
28
+ /** Record that this process has `file` open. No-op for unsaved sessions. */
29
+ export async function markActive(file: string | undefined, pid = process.pid): Promise<void> {
30
+ if (!file) {
31
+ await unmarkActive(pid);
32
+ return;
33
+ }
34
+ try {
35
+ await mkdir(activeDir(), { recursive: true });
36
+ await writeFile(recordPath(pid), JSON.stringify({ file }));
37
+ } catch {}
38
+ }
39
+
40
+ /** Remove this process's record (session closed / pi exiting). */
41
+ export async function unmarkActive(pid = process.pid): Promise<void> {
42
+ try {
43
+ await unlink(recordPath(pid));
44
+ } catch {}
45
+ }
46
+
47
+ /**
48
+ * Session files held open by OTHER live processes. Stale records (dead pid)
49
+ * are deleted as a side effect.
50
+ */
51
+ export async function activeElsewhere(selfPid = process.pid): Promise<Set<string>> {
52
+ const result = new Set<string>();
53
+ let names: string[];
54
+ try {
55
+ names = await readdir(activeDir());
56
+ } catch {
57
+ return result;
58
+ }
59
+ await Promise.all(
60
+ names.map(async (name) => {
61
+ const pid = parseInt(name, 10);
62
+ if (!name.endsWith(".json") || isNaN(pid) || pid === selfPid) return;
63
+ const path = join(activeDir(), name);
64
+ if (!pidAlive(pid)) {
65
+ try {
66
+ await unlink(path);
67
+ } catch {}
68
+ return;
69
+ }
70
+ try {
71
+ const rec = JSON.parse(await readFile(path, "utf8"));
72
+ if (typeof rec?.file === "string") result.add(rec.file);
73
+ } catch {}
74
+ }),
75
+ );
76
+ return result;
77
+ }
package/src/config.ts CHANGED
@@ -5,12 +5,9 @@
5
5
 
6
6
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
7
7
  import { join } from "node:path";
8
+ import { getPiAgentDir } from "./pi-dir.ts";
8
9
 
9
- const CONFIG_DIR = join(
10
- process.env.PI_CODING_AGENT_DIR || join(process.env.HOME || "~", ".pi", "agent"),
11
- "extensions",
12
- "pi-fast-resume",
13
- );
10
+ const CONFIG_DIR = join(getPiAgentDir(), "extensions", "pi-fast-resume");
14
11
  const CONFIG_PATH = join(CONFIG_DIR, "config.json");
15
12
 
16
13
  export interface Config {
@@ -45,7 +42,13 @@ export function loadConfig(): Config {
45
42
  return { ...DEFAULTS };
46
43
  }
47
44
 
48
- export function saveConfig(cfg: Config): void {
49
- if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
50
- writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
45
+ /** Save config. Returns an error message on failure instead of throwing. */
46
+ export function saveConfig(cfg: Config): string | undefined {
47
+ try {
48
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
49
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
50
+ return undefined;
51
+ } catch (err) {
52
+ return `Failed to save config: ${err instanceof Error ? err.message : String(err)}`;
53
+ }
51
54
  }
package/src/format.ts CHANGED
@@ -29,6 +29,19 @@ export function sessionLabel(e: SessionEntry): string {
29
29
  return e.name || e.firstMessage || e.id || "untitled";
30
30
  }
31
31
 
32
- export function formatEntry(e: SessionEntry): string {
33
- return `${formatAge(e.mtime).padEnd(10)} ${formatSize(e.size).padEnd(8)} ${truncate(sessionLabel(e), 60)}`;
32
+ /**
33
+ * Format one picker row, fitting into `maxWidth` columns (terminal width).
34
+ * Reserves space for the age/size columns and the picker's cursor/indent
35
+ * so rows never wrap (wrapped rows break selection navigation in the TUI).
36
+ */
37
+ // Columns the TUI select list draws around each row (cursor arrow, indent,
38
+ // uniquify suffix like " (12)"). Keeping rows shorter than
39
+ // terminal width minus this margin prevents line wrapping, which would
40
+ // break cursor navigation in the picker.
41
+ const PICKER_ROW_MARGIN = 6;
42
+
43
+ export function formatEntry(e: SessionEntry, maxWidth = 80): string {
44
+ const prefix = `${formatAge(e.mtime).padEnd(10)} ${formatSize(e.size).padEnd(8)} `;
45
+ const labelMax = Math.max(10, maxWidth - prefix.length - PICKER_ROW_MARGIN);
46
+ return prefix + truncate(sessionLabel(e), labelMax);
34
47
  }
package/src/migrate.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * One-time migration of legacy subagent fork sessions.
3
+ *
4
+ * pi-subagents < 0.53 wrote forked child sessions loose in the project
5
+ * sessions dir, so they show up in /resume and in every navigation command.
6
+ * Since 0.53 the canonical location is `<parent-basename>/forks/<file>.jsonl`
7
+ * (nested under the parent's session tree, invisible to top-level listings,
8
+ * removed together with the tree by /rds).
9
+ *
10
+ * This module finds loose files that (a) carry a `parentSession` header and
11
+ * (b) contain the delegated-subagent task prompt or a `subagent-*` session
12
+ * name, and renames them into the canonical location. Manual /fork sessions
13
+ * have (a) but not (b) and are left alone. After one run there is nothing
14
+ * left to scan, so navigation stays stat-only.
15
+ */
16
+
17
+ import { open, rename, mkdir, access } from "node:fs/promises";
18
+ import { createReadStream } from "node:fs";
19
+ import { join, dirname, basename } from "node:path";
20
+ import type { StatResult } from "./scanner.ts";
21
+
22
+ const HEAD_BYTES = 2048;
23
+ const TAIL_BYTES = 4096;
24
+ const TASK_MARKER = "You are a delegated subagent";
25
+ const NAME_MARKER = '"name":"subagent-';
26
+
27
+ async function readSlice(file: string, position: number, length: number): Promise<string> {
28
+ const fh = await open(file, "r");
29
+ try {
30
+ const buf = Buffer.alloc(length);
31
+ const { bytesRead } = await fh.read(buf, 0, length, position);
32
+ return buf.subarray(0, bytesRead).toString("utf8");
33
+ } finally {
34
+ await fh.close();
35
+ }
36
+ }
37
+
38
+ /** Streamed substring search; never buffers the whole file. */
39
+ function streamContains(file: string, marker: string): Promise<boolean> {
40
+ return new Promise((resolve) => {
41
+ const stream = createReadStream(file, { encoding: "utf8", highWaterMark: 1 << 20 });
42
+ let carry = "";
43
+ const keep = marker.length - 1;
44
+ stream.on("data", (chunk) => {
45
+ const text = carry + chunk;
46
+ if (text.includes(marker)) {
47
+ stream.destroy();
48
+ resolve(true);
49
+ return;
50
+ }
51
+ carry = text.slice(-keep);
52
+ });
53
+ stream.on("end", () => resolve(false));
54
+ stream.on("error", () => resolve(false));
55
+ });
56
+ }
57
+
58
+ /** Parent session path from the header line, or undefined if not a fork. */
59
+ async function parentOf(ref: StatResult): Promise<string | undefined> {
60
+ if (ref.size === 0) return undefined;
61
+ let head: string;
62
+ try {
63
+ head = await readSlice(ref.file, 0, Math.min(HEAD_BYTES, ref.size));
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ const firstLine = head.split("\n", 1)[0] ?? "";
68
+ if (!firstLine.includes('"parentSession"')) return undefined;
69
+ try {
70
+ const parent = JSON.parse(firstLine)?.parentSession;
71
+ return typeof parent === "string" && parent ? parent : undefined;
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ }
76
+
77
+ /** Is this fork a subagent child (vs. a manual /fork)? */
78
+ async function isSubagentChild(ref: StatResult): Promise<boolean> {
79
+ try {
80
+ const len = Math.min(TAIL_BYTES, ref.size);
81
+ const tail = await readSlice(ref.file, ref.size - len, len);
82
+ if (tail.includes(NAME_MARKER)) return true;
83
+ } catch {}
84
+ return streamContains(ref.file, TASK_MARKER);
85
+ }
86
+
87
+ export interface LegacyFork {
88
+ file: string;
89
+ target: string;
90
+ }
91
+
92
+ /**
93
+ * Canonical nested location for a fork: next to the parent's session tree,
94
+ * inside the child's own sessions dir.
95
+ */
96
+ export function forkTarget(file: string, parentSession: string): string {
97
+ return join(dirname(file), basename(parentSession, ".jsonl"), "forks", basename(file));
98
+ }
99
+
100
+ /** Find loose subagent fork sessions among `files`, skipping `skip` paths. */
101
+ export async function findLegacyForks(files: StatResult[], skip: Set<string> = new Set()): Promise<LegacyFork[]> {
102
+ const out: LegacyFork[] = [];
103
+ for (const ref of files) {
104
+ if (skip.has(ref.file)) continue;
105
+ const parent = await parentOf(ref);
106
+ if (!parent) continue;
107
+ if (!(await isSubagentChild(ref))) continue;
108
+ out.push({ file: ref.file, target: forkTarget(ref.file, parent) });
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /** Rename each fork into place. Existing targets are never overwritten. Returns moved count. */
114
+ export async function migrateLegacyForks(forks: LegacyFork[]): Promise<number> {
115
+ let moved = 0;
116
+ for (const { file, target } of forks) {
117
+ try {
118
+ await access(target);
119
+ continue; // already there — leave both untouched, never clobber
120
+ } catch {}
121
+ try {
122
+ await mkdir(dirname(target), { recursive: true });
123
+ await rename(file, target);
124
+ moved++;
125
+ } catch {}
126
+ }
127
+ return moved;
128
+ }
package/src/nav.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Pure session-navigation logic: ranked resume (/r1../rN, --r) and
3
+ * step navigation (/rn, /rp). No pi SDK dependency.
4
+ *
5
+ * All walkers take an async `hidden` predicate and skip files it rejects.
6
+ * The predicate is evaluated lazily, only for files actually visited, so a
7
+ * costly check (e.g. scanning a fork file) is paid per candidate, not per
8
+ * directory.
9
+ */
10
+
11
+ export interface FileRef {
12
+ file: string;
13
+ }
14
+
15
+ export type Hidden<T extends FileRef> = (f: T) => Promise<boolean>;
16
+
17
+ const never = async () => false;
18
+
19
+ /**
20
+ * Rank-th most recent visible session, current excluded (rank 1 = latest).
21
+ * Returns the target or undefined, plus how many visible candidates were seen.
22
+ */
23
+ export async function rankTarget<T extends FileRef>(
24
+ files: T[],
25
+ currentFile: string | undefined,
26
+ rank: number,
27
+ hidden: Hidden<T> = never,
28
+ ): Promise<{ target?: T; othersCount: number }> {
29
+ let seen = 0;
30
+ for (const f of files) {
31
+ if (f.file === currentFile || (await hidden(f))) continue;
32
+ seen++;
33
+ if (seen === rank) return { target: f, othersCount: seen };
34
+ }
35
+ return { target: undefined, othersCount: seen };
36
+ }
37
+
38
+ /**
39
+ * Step relative to the current session in the mtime-sorted list, skipping
40
+ * hidden files. dir = 1 → older, dir = -1 → newer. An unsaved current session
41
+ * (not in the list) is treated as the newest. `pos`/`total` are raw list
42
+ * indices (1-based) for the on-screen indicator.
43
+ */
44
+ export async function navTarget<T extends FileRef>(
45
+ files: T[],
46
+ currentFile: string | undefined,
47
+ dir: 1 | -1,
48
+ hidden: Hidden<T> = never,
49
+ ): Promise<{ target?: T; pos: number; total: number }> {
50
+ const idx = files.findIndex((f) => f.file === currentFile);
51
+ let i = idx === -1 ? (dir === 1 ? 0 : -1) : idx + dir;
52
+ while (i >= 0 && i < files.length) {
53
+ const f = files[i]!;
54
+ if (!(await hidden(f))) return { target: f, pos: i + 1, total: files.length };
55
+ i += dir;
56
+ }
57
+ return { target: undefined, pos: 0, total: files.length };
58
+ }
59
+
60
+ /** Keep only visible files (order preserved). */
61
+ export async function filterVisible<T extends FileRef>(files: T[], hidden: Hidden<T>): Promise<T[]> {
62
+ const out: T[] = [];
63
+ for (const f of files) if (!(await hidden(f))) out.push(f);
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * Parse the --r startup flag value.
69
+ * Returns a rank, an error message, or undefined when the flag is unset.
70
+ */
71
+ export function parseRankFlag(
72
+ raw: unknown,
73
+ maxRank: number,
74
+ ): { rank: number } | { error: string } | undefined {
75
+ if (raw === undefined || raw === null || raw === false) return undefined;
76
+ const rank = parseInt(String(raw), 10);
77
+ if (isNaN(rank) || rank < 1 || rank > maxRank) {
78
+ return { error: `--r expects a number 1-${maxRank} (got "${raw}")` };
79
+ }
80
+ return { rank };
81
+ }
package/src/pi-dir.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Single source of truth for the pi agent base directory.
3
+ * Mirrors pi's own resolution: $PI_CODING_AGENT_DIR or ~/.pi/agent.
4
+ */
5
+
6
+ import { join } from "node:path";
7
+ import { homedir } from "node:os";
8
+
9
+ export function getPiAgentDir(): string {
10
+ return process.env.PI_CODING_AGENT_DIR || join(process.env.HOME || homedir(), ".pi", "agent");
11
+ }
package/src/picker.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Pure picker-menu logic for /rs. No pi SDK dependency.
3
+ *
4
+ * ctx.ui.select() returns the chosen STRING, so every menu item must be
5
+ * unique — otherwise resolving the string back to an index picks the wrong
6
+ * session (first duplicate wins). uniquify() guarantees uniqueness.
7
+ */
8
+
9
+ export const LOAD_MORE_PREFIX = "▼ Load more...";
10
+ export const SHOW_TIER_PREFIX = "▼ Show ";
11
+
12
+ export type PickerAction =
13
+ | { kind: "entry"; index: number }
14
+ | { kind: "more" }
15
+ | { kind: "tier" }
16
+ | { kind: "cancel" };
17
+
18
+ /** Make labels unique by suffixing duplicates with ` (2)`, ` (3)`, … */
19
+ export function uniquify(labels: string[]): string[] {
20
+ const seen = new Map<string, number>();
21
+ return labels.map((label) => {
22
+ const count = (seen.get(label) ?? 0) + 1;
23
+ seen.set(label, count);
24
+ return count === 1 ? label : `${label} (${count})`;
25
+ });
26
+ }
27
+
28
+ /** Build the full item list: unique entry rows + optional action rows. */
29
+ export function buildPickerItems(
30
+ entryLabels: string[],
31
+ opts: { remaining?: number; nextTierLabel?: string } = {},
32
+ ): string[] {
33
+ const items = uniquify(entryLabels);
34
+ if (opts.remaining !== undefined) {
35
+ items.push(`${LOAD_MORE_PREFIX} (${opts.remaining} remaining)`);
36
+ }
37
+ if (opts.nextTierLabel !== undefined) {
38
+ items.push(`${SHOW_TIER_PREFIX}${opts.nextTierLabel}`);
39
+ }
40
+ return items;
41
+ }
42
+
43
+ /** Resolve a select() result back to an action. */
44
+ export function resolveChoice(
45
+ items: string[],
46
+ choice: string | undefined | null,
47
+ entryCount: number,
48
+ ): PickerAction {
49
+ if (choice === undefined || choice === null) return { kind: "cancel" };
50
+
51
+ const index = items.indexOf(choice);
52
+ if (index >= 0 && index < entryCount) return { kind: "entry", index };
53
+
54
+ if (choice.startsWith(LOAD_MORE_PREFIX)) return { kind: "more" };
55
+ if (choice.startsWith(SHOW_TIER_PREFIX)) return { kind: "tier" };
56
+
57
+ return { kind: "cancel" };
58
+ }
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, rm } from "node:fs/promises";
9
+ import { readdir, stat, rm, open } 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";
@@ -31,12 +31,22 @@ export interface StatResult {
31
31
  /**
32
32
  * Fast stat-only scan: readdir + stat, sorted by mtime desc.
33
33
  * No file content is read.
34
+ *
35
+ * A missing directory (no sessions yet) returns []. Other readdir errors
36
+ * (permissions, I/O) are surfaced via onError so callers can distinguish
37
+ * "no sessions" from "could not read sessions".
34
38
  */
35
- export async function statScan(sessionDir: string): Promise<StatResult[]> {
39
+ export async function statScan(
40
+ sessionDir: string,
41
+ onError?: (message: string) => void,
42
+ ): Promise<StatResult[]> {
36
43
  let entries: string[];
37
44
  try {
38
45
  entries = await readdir(sessionDir);
39
- } catch {
46
+ } catch (err: any) {
47
+ if (err?.code !== "ENOENT" && onError) {
48
+ onError(`Cannot read sessions dir: ${err?.message ?? err}`);
49
+ }
40
50
  return [];
41
51
  }
42
52
 
@@ -60,9 +70,20 @@ export async function statScan(sessionDir: string): Promise<StatResult[]> {
60
70
  return valid;
61
71
  }
62
72
 
73
+ // Metadata cache keyed by file path, validated by (mtime, size). A session
74
+ // file only changes by appending (mtime+size change), so a hit is always
75
+ // fresh. Keeps /rs "Load more" and repeated opens I/O-free for known files.
76
+ const metaCache = new Map<string, { mtimeMs: number; size: number; entry: SessionEntry }>();
77
+
78
+ /** Test-only: clear the metadata cache. */
79
+ export function clearMetaCache(): void {
80
+ metaCache.clear();
81
+ }
82
+
63
83
  /**
64
84
  * Read session metadata from first ~50 lines of a .jsonl file.
65
85
  * Caller provides mtime/size from prior stat() to avoid double-stat.
86
+ * Results are cached by (file, mtime, size).
66
87
  */
67
88
  export async function readSessionMeta(
68
89
  filePath: string,
@@ -82,6 +103,12 @@ export async function readSessionMeta(
82
103
  } catch {}
83
104
  }
84
105
 
106
+ const cached = metaCache.get(filePath);
107
+ if (cached && cached.mtimeMs === entry.mtime.getTime() && cached.size === entry.size) {
108
+ // Return a copy with the caller-provided mtime/size (identical anyway).
109
+ return { ...cached.entry, mtime: entry.mtime, size: entry.size };
110
+ }
111
+
85
112
  const MAX_LINES = 50;
86
113
  let lineCount = 0;
87
114
 
@@ -131,9 +158,60 @@ export async function readSessionMeta(
131
158
  rl.close();
132
159
  } catch {}
133
160
 
161
+ // Session renames APPEND a session_info entry at the end of the file, so
162
+ // a name found in the tail overrides whatever the head scan saw.
163
+ const tailName = await readTailName(filePath, entry.size);
164
+ if (tailName) entry.name = tailName;
165
+
166
+ metaCache.set(filePath, { mtimeMs: entry.mtime.getTime(), size: entry.size, entry: { ...entry } });
134
167
  return entry;
135
168
  }
136
169
 
170
+ // How many bytes of the file tail to inspect for a trailing session_info.
171
+ const TAIL_BYTES = 4096;
172
+
173
+ /**
174
+ * Read the last TAIL_BYTES of a session file and return the name from the
175
+ * LAST parseable session_info line, if any. Cheap: one small read.
176
+ */
177
+ async function readTailName(filePath: string, size: number): Promise<string | undefined> {
178
+ if (size <= 0) return undefined;
179
+
180
+ const readLen = Math.min(TAIL_BYTES, size);
181
+ const position = size - readLen;
182
+
183
+ let text: string;
184
+ try {
185
+ const fh = await open(filePath, "r");
186
+ try {
187
+ const buf = Buffer.alloc(readLen);
188
+ const { bytesRead } = await fh.read(buf, 0, readLen, position);
189
+ text = buf.subarray(0, bytesRead).toString("utf8");
190
+ } finally {
191
+ await fh.close();
192
+ }
193
+ } catch {
194
+ return undefined;
195
+ }
196
+
197
+ const lines = text.split("\n");
198
+ // When reading mid-file, the first line is likely a partial record — skip it.
199
+ const firstValid = position > 0 ? 1 : 0;
200
+ for (let i = lines.length - 1; i >= firstValid; i--) {
201
+ const line = lines[i]?.trim();
202
+ if (!line) continue;
203
+ try {
204
+ const parsed = JSON.parse(line);
205
+ if (parsed.type === "session_info" && typeof parsed.name === "string" && parsed.name.trim()) {
206
+ return parsed.name.trim();
207
+ }
208
+ } catch {
209
+ // partial or non-JSON line — keep walking up
210
+ }
211
+ }
212
+ return undefined;
213
+ }
214
+
137
215
  /**
138
216
  * Scan a page of sessions with metadata.
139
217
  * Pass known mtime/size from statScan to avoid double stat().
@@ -144,8 +222,10 @@ export async function scanPage(
144
222
  limit: number,
145
223
  maxDays?: number,
146
224
  excludeFile?: string,
225
+ onError?: (message: string) => void,
226
+ hidden?: (f: StatResult) => Promise<boolean>,
147
227
  ): Promise<{ entries: SessionEntry[]; total: number; hasMore: boolean }> {
148
- const all = await statScan(sessionDir);
228
+ const all = await statScan(sessionDir, onError);
149
229
 
150
230
  let filtered = all;
151
231
  if (maxDays && maxDays > 0) {
@@ -157,6 +237,12 @@ export async function scanPage(
157
237
  filtered = filtered.filter((f) => f.file !== excludeFile);
158
238
  }
159
239
 
240
+ if (hidden) {
241
+ const kept: StatResult[] = [];
242
+ for (const f of filtered) if (!(await hidden(f))) kept.push(f);
243
+ filtered = kept;
244
+ }
245
+
160
246
  const total = filtered.length;
161
247
  const page = filtered.slice(offset, offset + limit);
162
248
  const hasMore = offset + limit < total;
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * Resolve the pi session directory for a given cwd.
3
- * Mirrors pi's internal encoding: --<path-with-dashes>--
3
+ *
4
+ * Preferred: derive it from the current session file (authoritative —
5
+ * survives changes to pi's internal path encoding). Fallback: mirror
6
+ * pi's encoding of cwd → `--<path-with-dashes>--`.
4
7
  */
5
8
 
6
- import { join } from "node:path";
9
+ import { join, dirname } from "node:path";
10
+ import { getPiAgentDir } from "./pi-dir.ts";
7
11
 
8
- export function getSessionDir(cwd: string): string {
12
+ export function getSessionDir(cwd: string, currentSessionFile?: string): string {
13
+ if (currentSessionFile) return dirname(currentSessionFile);
9
14
  const resolved = cwd.replace(/^\//, "").replace(/[/\\:]/g, "-");
10
- return join(
11
- process.env.PI_CODING_AGENT_DIR || join(process.env.HOME || "~", ".pi", "agent"),
12
- "sessions",
13
- `--${resolved}--`,
14
- );
15
+ return join(getPiAgentDir(), "sessions", `--${resolved}--`);
15
16
  }