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 +59 -7
- package/extensions/index.ts +248 -84
- package/package.json +2 -2
- package/src/active.ts +77 -0
- package/src/config.ts +11 -8
- package/src/format.ts +15 -2
- package/src/migrate.ts +128 -0
- package/src/nav.ts +81 -0
- package/src/pi-dir.ts +11 -0
- package/src/picker.ts +58 -0
- package/src/scanner.ts +90 -4
- package/src/session-dir.ts +9 -8
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`
|
|
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`
|
|
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
|
-
|
|
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
|
|
54
|
-
/rs set
|
|
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
|
|
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
|
|
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.
|
package/extensions/index.ts
CHANGED
|
@@ -2,16 +2,31 @@
|
|
|
2
2
|
* pi-fast-resume — fast session resume without reading all .jsonl files.
|
|
3
3
|
*
|
|
4
4
|
* Commands:
|
|
5
|
-
* /r1 .. /
|
|
6
|
-
* (stat-only; /r1 = latest, /
|
|
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 {
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
79
|
-
const sessionDir =
|
|
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
|
|
268
|
+
handler: async (args, ctx) => {
|
|
116
269
|
const parts = (args || "").trim().split(/\s+/);
|
|
117
270
|
const cfg = loadConfig();
|
|
118
271
|
|
|
119
|
-
// /rs set
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
308
|
+
const saveError = saveConfig(cfg);
|
|
309
|
+
ctx.ui.notify(saveError ?? applied, saveError ? "error" : "info");
|
|
138
310
|
return;
|
|
139
311
|
}
|
|
140
312
|
|
|
141
|
-
const sessionDir =
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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");
|
|
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
|
-
|
|
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
|
|
4
|
-
"description": "Fast session resume for pi coding agent — /r1
|
|
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
|
-
|
|
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/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(
|
|
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;
|
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
|
}
|