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 +30 -2
- package/extensions/index.ts +201 -81
- package/package.json +2 -2
- package/src/config.ts +11 -8
- package/src/format.ts +15 -2
- package/src/nav.ts +53 -0
- package/src/pi-dir.ts +11 -0
- package/src/picker.ts +58 -0
- package/src/scanner.ts +83 -4
- package/src/session-dir.ts +9 -8
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
|
|
54
|
-
/rs set
|
|
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`.
|
package/extensions/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
79
|
-
const sessionDir =
|
|
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
|
|
226
|
+
handler: async (args, ctx) => {
|
|
116
227
|
const parts = (args || "").trim().split(/\s+/);
|
|
117
228
|
const cfg = loadConfig();
|
|
118
229
|
|
|
119
|
-
// /rs set
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
266
|
+
const saveError = saveConfig(cfg);
|
|
267
|
+
ctx.ui.notify(saveError ?? applied, saveError ? "error" : "info");
|
|
138
268
|
return;
|
|
139
269
|
}
|
|
140
270
|
|
|
141
|
-
const sessionDir =
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
"description": "Fast session resume for pi coding agent — /r1../r5 ranked
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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(
|
|
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) {
|
package/src/session-dir.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Resolve the pi session directory for a given cwd.
|
|
3
|
-
*
|
|
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
|
}
|