pi-resume 1.1.0 → 1.2.1
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 +36 -12
- package/extensions/index.ts +52 -8
- package/package.json +3 -3
- package/src/active.ts +77 -0
- package/src/migrate.ts +128 -0
- package/src/nav.ts +42 -14
- package/src/scanner.ts +7 -0
package/README.md
CHANGED
|
@@ -12,8 +12,8 @@ Commands that use `stat()` + lazy partial reads:
|
|
|
12
12
|
|
|
13
13
|
| Command | What it does | Speed |
|
|
14
14
|
|---------|-------------|-------|
|
|
15
|
-
| `/r1`
|
|
16
|
-
| `pi --r N` / `--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
17
|
| `/rn` / `/rp` | Step to the next (older) / previous (newer) session relative to the current one | <50ms (stat-only) |
|
|
18
18
|
| `/rs` | Paginated picker: last 20, with tier navigation | <200ms first page |
|
|
19
19
|
| `/rds` | Delete subagent session trees for the current project (with confirmation) | — |
|
|
@@ -21,18 +21,21 @@ Commands that use `stat()` + lazy partial reads:
|
|
|
21
21
|
## Install
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
pi install npm:pi-
|
|
24
|
+
pi install npm:pi-resume
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
## Commands
|
|
28
28
|
|
|
29
|
-
### `/r1`
|
|
29
|
+
### `/r1` / `/r2` — Instant Ranked Resume
|
|
30
30
|
|
|
31
31
|
Switch to the N-th most recent session (by mtime) in one step. No picker, no parsing.
|
|
32
32
|
|
|
33
33
|
- `/r1` — most recent session
|
|
34
34
|
- `/r2` — 2nd most recent
|
|
35
|
-
|
|
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.
|
|
36
39
|
|
|
37
40
|
The current session is always excluded from the ranking, so `/r1` reliably jumps
|
|
38
41
|
to the previous session. If fewer sessions exist than the requested rank, a
|
|
@@ -40,16 +43,16 @@ notice is shown and nothing is switched.
|
|
|
40
43
|
|
|
41
44
|
### `pi --r N` — Resume at Startup
|
|
42
45
|
|
|
43
|
-
Start pi and immediately switch to the N-th most recent session (1-
|
|
46
|
+
Start pi and immediately switch to the N-th most recent session (1-2):
|
|
44
47
|
|
|
45
48
|
```bash
|
|
46
49
|
pi --r1 # open pi in the latest session
|
|
47
50
|
pi --rn # same (alias for --r1)
|
|
48
|
-
pi --
|
|
49
|
-
pi --r
|
|
51
|
+
pi --r2 # open pi in the 2nd most recent
|
|
52
|
+
pi --r 2 # same, numeric form
|
|
50
53
|
```
|
|
51
54
|
|
|
52
|
-
Same ranking as `/r1
|
|
55
|
+
Same ranking as `/r1`/`/r2`. Invalid values show an error and start a normal
|
|
53
56
|
new session. Interactive mode only (ignored with `-p`).
|
|
54
57
|
|
|
55
58
|
### `/rn` / `/rp` — Step Navigation
|
|
@@ -94,11 +97,32 @@ by hundreds of MB.
|
|
|
94
97
|
`/rds` scans the **current project only**, shows how many trees / runs / MB
|
|
95
98
|
would be freed, asks for **confirmation**, then recursively deletes just those
|
|
96
99
|
subagent tree subdirectories. Your real top-level `*.jsonl` sessions (the ones
|
|
97
|
-
`/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.
|
|
98
122
|
|
|
99
123
|
## How it works
|
|
100
124
|
|
|
101
|
-
1. **`/r1
|
|
125
|
+
1. **`/r1`/`/r2`**: `readdir` → `stat` each `.jsonl` → sort by mtime → exclude current + open elsewhere → `switchSession(others[rank-1])`
|
|
102
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
|
|
103
127
|
|
|
104
128
|
No full file parsing. No `buildSessionInfo()`. No reading message content beyond the first user message.
|
|
@@ -106,7 +130,7 @@ No full file parsing. No `buildSessionInfo()`. No reading message content beyond
|
|
|
106
130
|
## Development
|
|
107
131
|
|
|
108
132
|
```bash
|
|
109
|
-
git clone https://github.com/
|
|
133
|
+
git clone https://github.com/axelbaumlisto/pi-fast-resume.git
|
|
110
134
|
cd pi-fast-resume
|
|
111
135
|
npm install
|
|
112
136
|
npm test
|
package/extensions/index.ts
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
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
7
|
* pi --r N — same as /rN but at startup (pi --r 1 = resume latest)
|
|
8
|
-
* pi --r1 .. --
|
|
8
|
+
* pi --r1 .. --r2 — boolean form of the same (pi --r1 = resume latest)
|
|
9
9
|
* pi --rn — alias for --r1 (mirrors /rn from a fresh session)
|
|
10
10
|
* /rn / /rp — step to the next (older) / previous (newer) session
|
|
11
11
|
* relative to the current one (by mtime)
|
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
* /rs set days N — set maxDays filter (0-30, 0 = no limit)
|
|
15
15
|
* /rds — delete all subagent session trees for the current project
|
|
16
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.
|
|
17
22
|
*/
|
|
18
23
|
|
|
19
24
|
import type {
|
|
@@ -32,13 +37,15 @@ import {
|
|
|
32
37
|
import { formatEntry, truncate, sessionLabel, formatSize } from "../src/format.ts";
|
|
33
38
|
import { loadConfig, saveConfig, clampPage, clampDays } from "../src/config.ts";
|
|
34
39
|
import { getSessionDir } from "../src/session-dir.ts";
|
|
35
|
-
import { rankTarget, navTarget, parseRankFlag } from "../src/nav.ts";
|
|
40
|
+
import { rankTarget, navTarget, parseRankFlag, type Hidden } from "../src/nav.ts";
|
|
36
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";
|
|
37
44
|
|
|
38
45
|
const DAY_TIERS = [7, 14, 0] as const;
|
|
39
46
|
|
|
40
47
|
// How many ranked instant-resume commands to register: /r1 .. /rN.
|
|
41
|
-
const MAX_RANK =
|
|
48
|
+
const MAX_RANK = 2;
|
|
42
49
|
|
|
43
50
|
const ordinal = (n: number): string => {
|
|
44
51
|
if (n === 1) return "most recent";
|
|
@@ -53,6 +60,31 @@ const scanErrorNotifier = (ctx: ExtensionContext) => (message: string) =>
|
|
|
53
60
|
const sessionDirFor = (ctx: ExtensionContext): string =>
|
|
54
61
|
getSessionDir(ctx.cwd, ctx.sessionManager.getSessionFile() ?? undefined);
|
|
55
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
|
+
|
|
56
88
|
/** Switch to a session file with a "Resumed: <label>" notice. */
|
|
57
89
|
async function switchTo(
|
|
58
90
|
ctx: ExtensionCommandContext,
|
|
@@ -92,7 +124,8 @@ async function resumeRank(rank: number, baseCtx: ExtensionContext): Promise<void
|
|
|
92
124
|
}
|
|
93
125
|
|
|
94
126
|
const currentFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
|
95
|
-
const
|
|
127
|
+
const hidden = await hiddenPredicate();
|
|
128
|
+
const { target, othersCount } = await rankTarget(files, currentFile, rank, hidden);
|
|
96
129
|
|
|
97
130
|
if (!target) {
|
|
98
131
|
ctx.ui.notify(
|
|
@@ -116,7 +149,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
116
149
|
});
|
|
117
150
|
}
|
|
118
151
|
|
|
119
|
-
// Startup flags: pi --r N, pi --r1 .. --
|
|
152
|
+
// Startup flags: pi --r N, pi --r1 .. --r2, pi --rn (alias for --r1).
|
|
120
153
|
pi.registerFlag("r", {
|
|
121
154
|
description: `Resume the N-th most recent session at startup (1-${MAX_RANK}, 1 = latest)`,
|
|
122
155
|
type: "string",
|
|
@@ -150,11 +183,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
150
183
|
};
|
|
151
184
|
|
|
152
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);
|
|
153
188
|
if (event.reason !== "startup") return;
|
|
189
|
+
void migrateForksInBackground(ctx).catch(() => {});
|
|
154
190
|
const rank = startupRank(ctx);
|
|
155
191
|
if (rank !== undefined) await resumeRank(rank, ctx);
|
|
156
192
|
});
|
|
157
193
|
|
|
194
|
+
// Fires on switch (followed by session_start) and on exit.
|
|
195
|
+
pi.on("session_shutdown", async () => {
|
|
196
|
+
await unmarkActive();
|
|
197
|
+
});
|
|
198
|
+
|
|
158
199
|
// /rn — step to the next OLDER session; /rp — step to the next NEWER one.
|
|
159
200
|
const NAV = [
|
|
160
201
|
{ cmd: "rn", dir: 1, desc: "Resume the next (older) session", edge: "Already at the oldest session" },
|
|
@@ -172,7 +213,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
172
213
|
}
|
|
173
214
|
|
|
174
215
|
const currentFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
|
175
|
-
const
|
|
216
|
+
const hidden = await hiddenPredicate();
|
|
217
|
+
const { target, pos, total } = await navTarget(files, currentFile, dir, hidden);
|
|
176
218
|
|
|
177
219
|
if (!target) {
|
|
178
220
|
ctx.ui.notify(edge, "info");
|
|
@@ -271,6 +313,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
271
313
|
const sessionDir = sessionDirFor(ctx);
|
|
272
314
|
const currentFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
|
273
315
|
const onError = scanErrorNotifier(ctx);
|
|
316
|
+
const hidden = await hiddenPredicate();
|
|
274
317
|
|
|
275
318
|
let tierIndex = 0;
|
|
276
319
|
let offset = 0;
|
|
@@ -286,6 +329,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
286
329
|
currentDays > 0 ? currentDays : undefined,
|
|
287
330
|
currentFile,
|
|
288
331
|
onError,
|
|
332
|
+
hidden,
|
|
289
333
|
);
|
|
290
334
|
|
|
291
335
|
if (entries.length === 0 && offset === 0) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-resume",
|
|
3
|
-
"version": "1.1
|
|
4
|
-
"description": "Fast session resume for pi coding agent — /r1
|
|
3
|
+
"version": "1.2.1",
|
|
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": [
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
],
|
|
14
14
|
"repository": {
|
|
15
15
|
"type": "git",
|
|
16
|
-
"url": "https://github.com/
|
|
16
|
+
"url": "https://github.com/axelbaumlisto/pi-fast-resume.git"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
19
|
"extensions",
|
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/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
CHANGED
|
@@ -1,39 +1,67 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure session-navigation logic: ranked resume (/r1../rN, --r) and
|
|
3
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.
|
|
4
9
|
*/
|
|
5
10
|
|
|
6
11
|
export interface FileRef {
|
|
7
12
|
file: string;
|
|
8
13
|
}
|
|
9
14
|
|
|
15
|
+
export type Hidden<T extends FileRef> = (f: T) => Promise<boolean>;
|
|
16
|
+
|
|
17
|
+
const never = async () => false;
|
|
18
|
+
|
|
10
19
|
/**
|
|
11
|
-
* Rank-th most recent session, current excluded (rank 1 = latest).
|
|
12
|
-
* Returns the target or undefined, plus how many candidates
|
|
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.
|
|
13
22
|
*/
|
|
14
|
-
export function rankTarget<T extends FileRef>(
|
|
23
|
+
export async function rankTarget<T extends FileRef>(
|
|
15
24
|
files: T[],
|
|
16
25
|
currentFile: string | undefined,
|
|
17
26
|
rank: number,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 };
|
|
21
36
|
}
|
|
22
37
|
|
|
23
38
|
/**
|
|
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.
|
|
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.
|
|
27
43
|
*/
|
|
28
|
-
export function navTarget<T extends FileRef>(
|
|
44
|
+
export async function navTarget<T extends FileRef>(
|
|
29
45
|
files: T[],
|
|
30
46
|
currentFile: string | undefined,
|
|
31
47
|
dir: 1 | -1,
|
|
32
|
-
|
|
48
|
+
hidden: Hidden<T> = never,
|
|
49
|
+
): Promise<{ target?: T; pos: number; total: number }> {
|
|
33
50
|
const idx = files.findIndex((f) => f.file === currentFile);
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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;
|
|
37
65
|
}
|
|
38
66
|
|
|
39
67
|
/**
|
package/src/scanner.ts
CHANGED
|
@@ -223,6 +223,7 @@ export async function scanPage(
|
|
|
223
223
|
maxDays?: number,
|
|
224
224
|
excludeFile?: string,
|
|
225
225
|
onError?: (message: string) => void,
|
|
226
|
+
hidden?: (f: StatResult) => Promise<boolean>,
|
|
226
227
|
): Promise<{ entries: SessionEntry[]; total: number; hasMore: boolean }> {
|
|
227
228
|
const all = await statScan(sessionDir, onError);
|
|
228
229
|
|
|
@@ -236,6 +237,12 @@ export async function scanPage(
|
|
|
236
237
|
filtered = filtered.filter((f) => f.file !== excludeFile);
|
|
237
238
|
}
|
|
238
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
|
+
|
|
239
246
|
const total = filtered.length;
|
|
240
247
|
const page = filtered.slice(offset, offset + limit);
|
|
241
248
|
const hasMore = offset + limit < total;
|