pi-resume 1.0.2 → 1.1.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
@@ -13,6 +13,8 @@ Commands that use `stat()` + lazy partial reads:
13
13
  | Command | What it does | Speed |
14
14
  |---------|-------------|-------|
15
15
  | `/r1` … `/r5` | Instantly switch to the N-th most recent session (`/r1` = latest) | <50ms (stat-only) |
16
+ | `pi --r N` / `--r1`…`--r5` / `--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
 
@@ -36,6 +38,31 @@ The current session is always excluded from the ranking, so `/r1` reliably jumps
36
38
  to the previous session. If fewer sessions exist than the requested rank, a
37
39
  notice is shown and nothing is switched.
38
40
 
41
+ ### `pi --r N` — Resume at Startup
42
+
43
+ Start pi and immediately switch to the N-th most recent session (1-5):
44
+
45
+ ```bash
46
+ pi --r1 # open pi in the latest session
47
+ pi --rn # same (alias for --r1)
48
+ pi --r3 # open pi in the 3rd most recent
49
+ pi --r 3 # same, numeric form
50
+ ```
51
+
52
+ Same ranking as `/r1`…`/r5`. Invalid values show an error and start a normal
53
+ new session. Interactive mode only (ignored with `-p`).
54
+
55
+ ### `/rn` / `/rp` — Step Navigation
56
+
57
+ Walk the mtime-sorted session list relative to the **current** session:
58
+
59
+ - `/rn` — next session (one step **older**)
60
+ - `/rp` — previous session (one step **newer**)
61
+
62
+ Useful after `/r1` lands on the wrong session: keep pressing `/rn` to walk back
63
+ in time instead of recalculating ranks. A `(pos/total)` indicator is shown on
64
+ each switch. At the ends of the list a notice is shown and nothing is switched.
65
+
39
66
  ### `/rs` — Smart Resume
40
67
 
41
68
  Shows a paginated list of recent sessions:
@@ -50,8 +77,9 @@ Auto-escalates: if 7d is empty, jumps to 14d, then all.
50
77
  ### Configuration
51
78
 
52
79
  ```
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)
80
+ /rs set # Show current settings
81
+ /rs set page 30 # Sessions per page (1-50, default: 20; out-of-range values are clamped)
82
+ /rs set days 14 # Day filter for first tier (0-30, 0 = no filter, default: 7; clamped)
55
83
  ```
56
84
 
57
85
  Config is stored in `~/.pi/agent/extensions/pi-fast-resume/config.json`.
@@ -4,6 +4,11 @@
4
4
  * Commands:
5
5
  * /r1 .. /r5 — instantly switch to the N-th most recent session
6
6
  * (stat-only; /r1 = latest, /r5 = 5th, current excluded)
7
+ * pi --r N — same as /rN but at startup (pi --r 1 = resume latest)
8
+ * pi --r1 .. --r5 — 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)
@@ -11,7 +16,12 @@
11
16
  * (asks for confirmation; top-level sessions untouched)
12
17
  */
13
18
 
14
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import type {
20
+ ExtensionAPI,
21
+ ExtensionContext,
22
+ ExtensionCommandContext,
23
+ SessionStartEvent,
24
+ } from "@earendil-works/pi-coding-agent";
15
25
  import {
16
26
  statScan,
17
27
  scanPage,
@@ -20,8 +30,10 @@ import {
20
30
  deleteSubagentTrees,
21
31
  } from "../src/scanner.ts";
22
32
  import { formatEntry, truncate, sessionLabel, formatSize } from "../src/format.ts";
23
- import { loadConfig, saveConfig } from "../src/config.ts";
33
+ import { loadConfig, saveConfig, clampPage, clampDays } from "../src/config.ts";
24
34
  import { getSessionDir } from "../src/session-dir.ts";
35
+ import { rankTarget, navTarget, parseRankFlag } from "../src/nav.ts";
36
+ import { buildPickerItems, resolveChoice } from "../src/picker.ts";
25
37
 
26
38
  const DAY_TIERS = [7, 14, 0] as const;
27
39
 
@@ -34,49 +46,148 @@ const ordinal = (n: number): string => {
34
46
  return `${n}${suffix} most recent`;
35
47
  };
36
48
 
49
+ /** Report a scan error to the user (used as statScan/scanPage onError). */
50
+ const scanErrorNotifier = (ctx: ExtensionContext) => (message: string) =>
51
+ ctx.ui.notify(message, "error");
52
+
53
+ const sessionDirFor = (ctx: ExtensionContext): string =>
54
+ getSessionDir(ctx.cwd, ctx.sessionManager.getSessionFile() ?? undefined);
55
+
56
+ /** Switch to a session file with a "Resumed: <label>" notice. */
57
+ async function switchTo(
58
+ ctx: ExtensionCommandContext,
59
+ target: { file: string; mtime: Date; size: number },
60
+ prefix = "Resumed",
61
+ ): Promise<void> {
62
+ const meta = await readSessionMeta(target.file, target);
63
+ await ctx.switchSession(target.file, {
64
+ withSession: async (newCtx) => {
65
+ newCtx.ui.notify(`${prefix}: ${truncate(sessionLabel(meta), 50)}`, "info");
66
+ },
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Guard: session switching needs a command-capable interactive context.
72
+ * session_start receives a plain ExtensionContext; in TUI mode the runtime
73
+ * context also carries switchSession, in -p/json modes it does not.
74
+ */
75
+ function asSwitchable(ctx: ExtensionContext): ExtensionCommandContext | undefined {
76
+ if (typeof (ctx as ExtensionCommandContext).switchSession === "function") {
77
+ return ctx as ExtensionCommandContext;
78
+ }
79
+ ctx.ui.notify("Session resume is only available in interactive mode", "error");
80
+ return undefined;
81
+ }
82
+
83
+ /** Resume the rank-th most recent session (1 = latest, current excluded). */
84
+ async function resumeRank(rank: number, baseCtx: ExtensionContext): Promise<void> {
85
+ const ctx = asSwitchable(baseCtx);
86
+ if (!ctx) return;
87
+
88
+ const files = await statScan(sessionDirFor(ctx), scanErrorNotifier(ctx));
89
+ if (files.length === 0) {
90
+ ctx.ui.notify("No sessions found", "error");
91
+ return;
92
+ }
93
+
94
+ const currentFile = ctx.sessionManager.getSessionFile() ?? undefined;
95
+ const { target, othersCount } = rankTarget(files, currentFile, rank);
96
+
97
+ if (!target) {
98
+ ctx.ui.notify(
99
+ othersCount === 0
100
+ ? "No other sessions to resume"
101
+ : `Only ${othersCount} other session${othersCount === 1 ? "" : "s"} available`,
102
+ "info",
103
+ );
104
+ return;
105
+ }
106
+
107
+ await switchTo(ctx, target);
108
+ }
109
+
37
110
  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.
111
+ // /r1 .. /rN — jump to the rank-th most recent session.
40
112
  for (let rank = 1; rank <= MAX_RANK; rank++) {
41
113
  pi.registerCommand(`r${rank}`, {
42
114
  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);
115
+ handler: async (_args, ctx) => resumeRank(rank, ctx),
116
+ });
117
+ }
118
+
119
+ // Startup flags: pi --r N, pi --r1 .. --r5, pi --rn (alias for --r1).
120
+ pi.registerFlag("r", {
121
+ description: `Resume the N-th most recent session at startup (1-${MAX_RANK}, 1 = latest)`,
122
+ type: "string",
123
+ });
124
+ for (let rank = 1; rank <= MAX_RANK; rank++) {
125
+ pi.registerFlag(`r${rank}`, {
126
+ description: `Resume the ${ordinal(rank)} session at startup`,
127
+ type: "boolean",
128
+ });
129
+ }
130
+ pi.registerFlag("rn", {
131
+ description: "Resume the most recent session at startup (alias for --r1)",
132
+ type: "boolean",
133
+ });
46
134
 
135
+ /** Resolve requested startup rank from flags, or undefined if none set. */
136
+ const startupRank = (ctx: ExtensionContext): number | undefined => {
137
+ const parsed = parseRankFlag(pi.getFlag("r"), MAX_RANK);
138
+ if (parsed) {
139
+ if ("error" in parsed) {
140
+ ctx.ui.notify(parsed.error, "error");
141
+ return undefined;
142
+ }
143
+ return parsed.rank;
144
+ }
145
+ for (let rank = 1; rank <= MAX_RANK; rank++) {
146
+ if (pi.getFlag(`r${rank}`) === true) return rank;
147
+ }
148
+ if (pi.getFlag("rn") === true) return 1;
149
+ return undefined;
150
+ };
151
+
152
+ pi.on("session_start", async (event: SessionStartEvent, ctx) => {
153
+ if (event.reason !== "startup") return;
154
+ const rank = startupRank(ctx);
155
+ if (rank !== undefined) await resumeRank(rank, ctx);
156
+ });
157
+
158
+ // /rn — step to the next OLDER session; /rp — step to the next NEWER one.
159
+ const NAV = [
160
+ { cmd: "rn", dir: 1, desc: "Resume the next (older) session", edge: "Already at the oldest session" },
161
+ { cmd: "rp", dir: -1, desc: "Resume the previous (newer) session", edge: "Already at the newest session" },
162
+ ] as const;
163
+
164
+ for (const { cmd, dir, desc, edge } of NAV) {
165
+ pi.registerCommand(cmd, {
166
+ description: desc,
167
+ handler: async (_args, ctx) => {
168
+ const files = await statScan(sessionDirFor(ctx), scanErrorNotifier(ctx));
47
169
  if (files.length === 0) {
48
170
  ctx.ui.notify("No sessions found", "error");
49
171
  return;
50
172
  }
51
173
 
52
174
  const currentFile = ctx.sessionManager.getSessionFile() ?? undefined;
53
- const others = files.filter((f: { file: string }) => f.file !== currentFile);
175
+ const { target, pos, total } = navTarget(files, currentFile, dir);
54
176
 
55
- const target = others[rank - 1];
56
177
  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
- );
178
+ ctx.ui.notify(edge, "info");
63
179
  return;
64
180
  }
65
181
 
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
- });
182
+ await switchTo(ctx, target, `Resumed (${pos}/${total})`);
72
183
  },
73
184
  });
74
185
  }
75
186
 
76
187
  pi.registerCommand("rds", {
77
188
  description: "Delete subagent session trees for the current project (with confirmation)",
78
- handler: async (_args: string, ctx: any) => {
79
- const sessionDir = getSessionDir(ctx.cwd);
189
+ handler: async (_args, ctx) => {
190
+ const sessionDir = sessionDirFor(ctx);
80
191
  const trees = await scanSubagentTrees(sessionDir);
81
192
 
82
193
  if (trees.length === 0) {
@@ -112,34 +223,54 @@ export default function (pi: ExtensionAPI) {
112
223
 
113
224
  pi.registerCommand("rs", {
114
225
  description: "Smart resume: paginated session picker (last 20, Load more, tier filter)",
115
- handler: async (args: string, ctx: any) => {
226
+ handler: async (args, ctx) => {
116
227
  const parts = (args || "").trim().split(/\s+/);
117
228
  const cfg = loadConfig();
118
229
 
119
- // /rs set page N | /rs set days N
230
+ // /rs set show current config
231
+ // /rs set page N | days N → update (out-of-range values are clamped)
120
232
  if (parts[0] === "set") {
121
233
  const key = parts[1];
122
- const val = parseInt(parts[2], 10);
234
+ const val = parseInt(parts[2] ?? "", 10);
123
235
 
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");
236
+ if (!key) {
237
+ ctx.ui.notify(
238
+ `Current: page ${cfg.pageSize}, days ${cfg.maxDays || "off"} — /rs set page N | /rs set days N`,
239
+ "info",
240
+ );
128
241
  return;
129
242
  }
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");
243
+
244
+ let applied: string | undefined;
245
+ if (key === "page" && !isNaN(val)) {
246
+ cfg.pageSize = clampPage(val);
247
+ applied =
248
+ cfg.pageSize === val
249
+ ? `Page size set to ${val}`
250
+ : `Page size clamped to ${cfg.pageSize} (valid: 1-50)`;
251
+ } else if (key === "days" && !isNaN(val)) {
252
+ cfg.maxDays = clampDays(val);
253
+ applied =
254
+ cfg.maxDays === 0
255
+ ? "Day filter disabled"
256
+ : cfg.maxDays === val
257
+ ? `Max days set to ${val}`
258
+ : `Max days clamped to ${cfg.maxDays} (valid: 0-30)`;
259
+ }
260
+
261
+ if (!applied) {
262
+ ctx.ui.notify("Usage: /rs set page N (1-50) | /rs set days N (0-30)", "error");
134
263
  return;
135
264
  }
136
265
 
137
- ctx.ui.notify("Usage: /rs set page N (1-50) | /rs set days N (0-30)", "error");
266
+ const saveError = saveConfig(cfg);
267
+ ctx.ui.notify(saveError ?? applied, saveError ? "error" : "info");
138
268
  return;
139
269
  }
140
270
 
141
- const sessionDir = getSessionDir(ctx.cwd);
271
+ const sessionDir = sessionDirFor(ctx);
142
272
  const currentFile = ctx.sessionManager.getSessionFile() ?? undefined;
273
+ const onError = scanErrorNotifier(ctx);
143
274
 
144
275
  let tierIndex = 0;
145
276
  let offset = 0;
@@ -154,6 +285,7 @@ export default function (pi: ExtensionAPI) {
154
285
  cfg.pageSize,
155
286
  currentDays > 0 ? currentDays : undefined,
156
287
  currentFile,
288
+ onError,
157
289
  );
158
290
 
159
291
  if (entries.length === 0 && offset === 0) {
@@ -165,58 +297,46 @@ export default function (pi: ExtensionAPI) {
165
297
  return;
166
298
  }
167
299
 
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
- }
300
+ const termWidth = process.stdout.columns || 80;
301
+ const items = buildPickerItems(
302
+ entries.map((e) => formatEntry(e, termWidth)),
303
+ {
304
+ remaining: hasMore ? total - offset - entries.length : undefined,
305
+ nextTierLabel:
306
+ nextTierDays !== undefined ? (nextTierDays > 0 ? `${nextTierDays}d` : "all") : undefined,
307
+ },
308
+ );
179
309
 
180
310
  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}`;
311
+ const rangeLabel = `Sessions ${offset + 1}-${offset + entries.length} of ${total}${filterLabel}`;
185
312
 
186
313
  const choice = await ctx.ui.select(rangeLabel, items);
314
+ const action = resolveChoice(items, choice, entries.length);
187
315
 
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");
316
+ switch (action.kind) {
317
+ case "more":
318
+ offset += cfg.pageSize;
319
+ continue;
320
+ case "tier":
321
+ tierIndex++;
322
+ offset = 0;
323
+ continue;
324
+ case "entry": {
325
+ const selected = entries[action.index];
326
+ if (!selected) return;
327
+ const result = await ctx.switchSession(selected.file, {
328
+ withSession: async (newCtx) => {
329
+ newCtx.ui.notify(`Resumed: ${truncate(sessionLabel(selected), 50)}`, "info");
330
+ },
331
+ });
332
+ if (result.cancelled) {
333
+ ctx.ui.notify("Session switch was cancelled", "info");
334
+ }
335
+ return;
215
336
  }
216
- return;
337
+ case "cancel":
338
+ return;
217
339
  }
218
-
219
- return;
220
340
  }
221
341
  },
222
342
  });
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.1.0",
4
+ "description": "Fast session resume for pi coding agent — /r1../r5 ranked resume, /rn and /rp step navigation, pi --r1/--rn startup flags, /rs paginated picker, /rds subagent session cleanup",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "keywords": [
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/nav.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Pure session-navigation logic: ranked resume (/r1../rN, --r) and
3
+ * step navigation (/rn, /rp). No pi SDK dependency.
4
+ */
5
+
6
+ export interface FileRef {
7
+ file: string;
8
+ }
9
+
10
+ /**
11
+ * Rank-th most recent session, current excluded (rank 1 = latest).
12
+ * Returns the target or undefined, plus how many candidates exist.
13
+ */
14
+ export function rankTarget<T extends FileRef>(
15
+ files: T[],
16
+ currentFile: string | undefined,
17
+ rank: number,
18
+ ): { target?: T; othersCount: number } {
19
+ const others = files.filter((f) => f.file !== currentFile);
20
+ return { target: others[rank - 1], othersCount: others.length };
21
+ }
22
+
23
+ /**
24
+ * Step relative to the current session in the mtime-sorted list.
25
+ * dir = 1 → older, dir = -1 → newer. An unsaved current session
26
+ * (not in the list) is treated as the newest.
27
+ */
28
+ export function navTarget<T extends FileRef>(
29
+ files: T[],
30
+ currentFile: string | undefined,
31
+ dir: 1 | -1,
32
+ ): { target?: T; pos: number; total: number } {
33
+ const idx = files.findIndex((f) => f.file === currentFile);
34
+ const target = idx === -1 ? (dir === 1 ? files[0] : undefined) : files[idx + dir];
35
+ const pos = target ? files.indexOf(target) + 1 : 0;
36
+ return { target, pos, total: files.length };
37
+ }
38
+
39
+ /**
40
+ * Parse the --r startup flag value.
41
+ * Returns a rank, an error message, or undefined when the flag is unset.
42
+ */
43
+ export function parseRankFlag(
44
+ raw: unknown,
45
+ maxRank: number,
46
+ ): { rank: number } | { error: string } | undefined {
47
+ if (raw === undefined || raw === null || raw === false) return undefined;
48
+ const rank = parseInt(String(raw), 10);
49
+ if (isNaN(rank) || rank < 1 || rank > maxRank) {
50
+ return { error: `--r expects a number 1-${maxRank} (got "${raw}")` };
51
+ }
52
+ return { rank };
53
+ }
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,9 @@ export async function scanPage(
144
222
  limit: number,
145
223
  maxDays?: number,
146
224
  excludeFile?: string,
225
+ onError?: (message: string) => void,
147
226
  ): Promise<{ entries: SessionEntry[]; total: number; hasMore: boolean }> {
148
- const all = await statScan(sessionDir);
227
+ const all = await statScan(sessionDir, onError);
149
228
 
150
229
  let filtered = all;
151
230
  if (maxDays && maxDays > 0) {
@@ -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
  }