pi-resume 1.2.1 → 1.3.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 +18 -8
- package/extensions/index.ts +13 -5
- package/package.json +1 -1
- package/src/active.ts +61 -8
- package/src/format.ts +7 -3
- package/src/migrate.ts +79 -45
- package/src/nav.ts +32 -2
package/README.md
CHANGED
|
@@ -63,7 +63,12 @@ Walk the mtime-sorted session list relative to the **current** session:
|
|
|
63
63
|
- `/rp` — previous session (one step **newer**)
|
|
64
64
|
|
|
65
65
|
Useful after `/r1` lands on the wrong session: keep pressing `/rn` to walk back
|
|
66
|
-
in time instead of recalculating ranks.
|
|
66
|
+
in time instead of recalculating ranks.
|
|
67
|
+
|
|
68
|
+
`/rn` / `/rp` walk by **creation time** (the timestamp in the session filename),
|
|
69
|
+
not by mtime: resuming a session makes extensions append to it, which bumps its
|
|
70
|
+
mtime and would otherwise reshuffle the list into a ping-pong between two
|
|
71
|
+
sessions. `/r1` and `/rs` still rank by last activity (mtime). A `(pos/total)` indicator is shown on
|
|
67
72
|
each switch. At the ends of the list a notice is shown and nothing is switched.
|
|
68
73
|
|
|
69
74
|
### `/rs` — Smart Resume
|
|
@@ -103,8 +108,10 @@ subagent tree subdirectories. Your real top-level `*.jsonl` sessions (the ones
|
|
|
103
108
|
|
|
104
109
|
Each pi instance with this extension records its current session file in
|
|
105
110
|
`~/.pi/agent/extensions/pi-fast-resume/active/<pid>.json` (removed on exit;
|
|
106
|
-
dead pids are ignored). `/r1`, `/r2`, `/rn`, `/rp
|
|
107
|
-
on a chat you have open in another terminal.
|
|
111
|
+
dead or reused pids are ignored). `/r1`, `/r2`, `/rn`, `/rp` and `--r` never
|
|
112
|
+
land on a chat you have open in another terminal. `/rs` still lists such
|
|
113
|
+
sessions, marked with `⦿`, so you can see where a chat went — selecting one is
|
|
114
|
+
refused instead of hijacking the other instance.
|
|
108
115
|
|
|
109
116
|
### Legacy subagent forks are tidied up once
|
|
110
117
|
|
|
@@ -114,11 +121,14 @@ sessions dir — same filename shape and `parentSession` header as your own
|
|
|
114
121
|
the canonical place is `<parent>/forks/<file>.jsonl`.
|
|
115
122
|
|
|
116
123
|
On startup this extension moves such files there (detached, in the background,
|
|
117
|
-
one time per file). A file is moved only if it
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
124
|
+
one time per file). A file is moved only if **all** hold: it was created before
|
|
125
|
+
the fix (2026-08-20), it has a `parentSession` header, it contains the
|
|
126
|
+
delegated-subagent task prompt, and **no human message follows the last task
|
|
127
|
+
prompt** (only orchestrator follow-ups such as steering). That last rule keeps
|
|
128
|
+
manual `/fork`s safe even when their inherited history contains a subagent task.
|
|
129
|
+
The current session and sessions open in other pi instances are skipped;
|
|
130
|
+
existing targets are never overwritten. After that, nothing needs filtering and
|
|
131
|
+
every command is stat-only again.
|
|
122
132
|
|
|
123
133
|
## How it works
|
|
124
134
|
|
package/extensions/index.ts
CHANGED
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
import { formatEntry, truncate, sessionLabel, formatSize } from "../src/format.ts";
|
|
38
38
|
import { loadConfig, saveConfig, clampPage, clampDays } from "../src/config.ts";
|
|
39
39
|
import { getSessionDir } from "../src/session-dir.ts";
|
|
40
|
-
import { rankTarget, navTarget, parseRankFlag, type Hidden } from "../src/nav.ts";
|
|
40
|
+
import { rankTarget, navTarget, sortByCreated, parseRankFlag, type Hidden } from "../src/nav.ts";
|
|
41
41
|
import { buildPickerItems, resolveChoice } from "../src/picker.ts";
|
|
42
42
|
import { markActive, unmarkActive, activeElsewhere } from "../src/active.ts";
|
|
43
43
|
import { findLegacyForks, migrateLegacyForks } from "../src/migrate.ts";
|
|
@@ -214,7 +214,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
214
214
|
|
|
215
215
|
const currentFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
|
216
216
|
const hidden = await hiddenPredicate();
|
|
217
|
-
|
|
217
|
+
// Walk by creation time: resuming bumps mtime, which would reshuffle
|
|
218
|
+
// an mtime-ordered walk into a ping-pong between two sessions.
|
|
219
|
+
const { target, pos, total } = await navTarget(sortByCreated(files), currentFile, dir, hidden);
|
|
218
220
|
|
|
219
221
|
if (!target) {
|
|
220
222
|
ctx.ui.notify(edge, "info");
|
|
@@ -313,7 +315,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
313
315
|
const sessionDir = sessionDirFor(ctx);
|
|
314
316
|
const currentFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
|
315
317
|
const onError = scanErrorNotifier(ctx);
|
|
316
|
-
|
|
318
|
+
// The picker SHOWS sessions open in another pi (marked) instead of hiding
|
|
319
|
+
// them, so the user can see where a "missing" chat went; selecting one is
|
|
320
|
+
// refused rather than hijacking the other instance's session.
|
|
321
|
+
const elsewhere = await activeElsewhere();
|
|
317
322
|
|
|
318
323
|
let tierIndex = 0;
|
|
319
324
|
let offset = 0;
|
|
@@ -329,7 +334,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
329
334
|
currentDays > 0 ? currentDays : undefined,
|
|
330
335
|
currentFile,
|
|
331
336
|
onError,
|
|
332
|
-
hidden,
|
|
333
337
|
);
|
|
334
338
|
|
|
335
339
|
if (entries.length === 0 && offset === 0) {
|
|
@@ -343,7 +347,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
343
347
|
|
|
344
348
|
const termWidth = process.stdout.columns || 80;
|
|
345
349
|
const items = buildPickerItems(
|
|
346
|
-
entries.map((e) => formatEntry(e, termWidth)),
|
|
350
|
+
entries.map((e) => formatEntry(e, termWidth, elsewhere.has(e.file))),
|
|
347
351
|
{
|
|
348
352
|
remaining: hasMore ? total - offset - entries.length : undefined,
|
|
349
353
|
nextTierLabel:
|
|
@@ -368,6 +372,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
368
372
|
case "entry": {
|
|
369
373
|
const selected = entries[action.index];
|
|
370
374
|
if (!selected) return;
|
|
375
|
+
if (elsewhere.has(selected.file)) {
|
|
376
|
+
ctx.ui.notify("That session is open in another pi — not switching", "info");
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
371
379
|
const result = await ctx.switchSession(selected.file, {
|
|
372
380
|
withSession: async (newCtx) => {
|
|
373
381
|
newCtx.ui.notify(`Resumed: ${truncate(sessionLabel(selected), 50)}`, "info");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-resume",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
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",
|
package/src/active.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { readdir, readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
|
12
|
-
import {
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
import { basename, join } from "node:path";
|
|
13
14
|
import { getPiAgentDir } from "./pi-dir.ts";
|
|
14
15
|
|
|
15
16
|
const activeDir = (): string => join(getPiAgentDir(), "extensions", "pi-fast-resume", "active");
|
|
@@ -25,6 +26,42 @@ function pidAlive(pid: number): boolean {
|
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* `ps` command names for the given pids (one call). Empty map when `ps` is
|
|
31
|
+
* unavailable (Windows, minimal containers). Names are compared like-for-like
|
|
32
|
+
* — whatever `ps` reports for our own pid at write time vs. now — so script
|
|
33
|
+
* launchers (`pi` via `#!/usr/bin/env node`) and comm truncation don't matter.
|
|
34
|
+
*/
|
|
35
|
+
async function psNames(pids: number[]): Promise<Map<number, string>> {
|
|
36
|
+
const names = new Map<number, string>();
|
|
37
|
+
if (pids.length === 0 || process.platform === "win32") return names;
|
|
38
|
+
const out = await new Promise<string>((resolve) =>
|
|
39
|
+
execFile("ps", ["-o", "pid=,comm=", "-p", pids.join(",")], (err, stdout) => resolve(err ? "" : stdout)),
|
|
40
|
+
);
|
|
41
|
+
for (const line of out.split("\n")) {
|
|
42
|
+
const m = line.trim().match(/^(\d+)\s+(.*)$/);
|
|
43
|
+
if (m) names.set(Number(m[1]), basename(m[2]!.trim()));
|
|
44
|
+
}
|
|
45
|
+
return names;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Guard against pid reuse after a crash: a stale record whose pid was later
|
|
50
|
+
* handed to an unrelated process must not hide a session forever. Records
|
|
51
|
+
* carry the `ps` name seen at write time; a live pid whose current name
|
|
52
|
+
* differs is treated as stale. Returns pids that pass (or cannot be checked).
|
|
53
|
+
*/
|
|
54
|
+
async function verifyExecutables(expected: Map<number, string>): Promise<Set<number>> {
|
|
55
|
+
const current = await psNames([...expected.keys()]);
|
|
56
|
+
if (current.size === 0) return new Set(expected.keys()); // no ps — trust liveness
|
|
57
|
+
const ok = new Set<number>();
|
|
58
|
+
for (const [pid, exe] of expected) {
|
|
59
|
+
const now = current.get(pid);
|
|
60
|
+
if (now === undefined || now === exe) ok.add(pid);
|
|
61
|
+
}
|
|
62
|
+
return ok;
|
|
63
|
+
}
|
|
64
|
+
|
|
28
65
|
/** Record that this process has `file` open. No-op for unsaved sessions. */
|
|
29
66
|
export async function markActive(file: string | undefined, pid = process.pid): Promise<void> {
|
|
30
67
|
if (!file) {
|
|
@@ -32,8 +69,9 @@ export async function markActive(file: string | undefined, pid = process.pid): P
|
|
|
32
69
|
return;
|
|
33
70
|
}
|
|
34
71
|
try {
|
|
72
|
+
const exe = (await psNames([pid])).get(pid);
|
|
35
73
|
await mkdir(activeDir(), { recursive: true });
|
|
36
|
-
await writeFile(recordPath(pid), JSON.stringify({ file }));
|
|
74
|
+
await writeFile(recordPath(pid), JSON.stringify(exe ? { file, exe } : { file }));
|
|
37
75
|
} catch {}
|
|
38
76
|
}
|
|
39
77
|
|
|
@@ -45,8 +83,8 @@ export async function unmarkActive(pid = process.pid): Promise<void> {
|
|
|
45
83
|
}
|
|
46
84
|
|
|
47
85
|
/**
|
|
48
|
-
* Session files held open by OTHER live processes. Stale records (dead
|
|
49
|
-
* are deleted as a side effect.
|
|
86
|
+
* Session files held open by OTHER live pi processes. Stale records (dead
|
|
87
|
+
* pid, or pid reused by a different executable) are deleted as a side effect.
|
|
50
88
|
*/
|
|
51
89
|
export async function activeElsewhere(selfPid = process.pid): Promise<Set<string>> {
|
|
52
90
|
const result = new Set<string>();
|
|
@@ -56,22 +94,37 @@ export async function activeElsewhere(selfPid = process.pid): Promise<Set<string
|
|
|
56
94
|
} catch {
|
|
57
95
|
return result;
|
|
58
96
|
}
|
|
97
|
+
|
|
98
|
+
const live = new Map<number, { path: string; file: string; exe?: string }>();
|
|
59
99
|
await Promise.all(
|
|
60
100
|
names.map(async (name) => {
|
|
61
101
|
const pid = parseInt(name, 10);
|
|
62
102
|
if (!name.endsWith(".json") || isNaN(pid) || pid === selfPid) return;
|
|
63
103
|
const path = join(activeDir(), name);
|
|
64
104
|
if (!pidAlive(pid)) {
|
|
65
|
-
|
|
66
|
-
await unlink(path);
|
|
67
|
-
} catch {}
|
|
105
|
+
await unlink(path).catch(() => {});
|
|
68
106
|
return;
|
|
69
107
|
}
|
|
70
108
|
try {
|
|
71
109
|
const rec = JSON.parse(await readFile(path, "utf8"));
|
|
72
|
-
if (typeof rec?.file === "string")
|
|
110
|
+
if (typeof rec?.file === "string") {
|
|
111
|
+
live.set(pid, { path, file: rec.file, exe: typeof rec.exe === "string" ? rec.exe : undefined });
|
|
112
|
+
}
|
|
73
113
|
} catch {}
|
|
74
114
|
}),
|
|
75
115
|
);
|
|
116
|
+
|
|
117
|
+
// Records with an exe name get verified; legacy records without one are trusted.
|
|
118
|
+
const toVerify = new Map<number, string>();
|
|
119
|
+
for (const [pid, rec] of live) if (rec.exe) toVerify.set(pid, rec.exe);
|
|
120
|
+
const verified = await verifyExecutables(toVerify);
|
|
121
|
+
|
|
122
|
+
for (const [pid, rec] of live) {
|
|
123
|
+
if (rec.exe && !verified.has(pid)) {
|
|
124
|
+
await unlink(rec.path).catch(() => {});
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
result.add(rec.file);
|
|
128
|
+
}
|
|
76
129
|
return result;
|
|
77
130
|
}
|
package/src/format.ts
CHANGED
|
@@ -40,8 +40,12 @@ export function sessionLabel(e: SessionEntry): string {
|
|
|
40
40
|
// break cursor navigation in the picker.
|
|
41
41
|
const PICKER_ROW_MARGIN = 6;
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
/** Label marker for a session currently open in another pi process. */
|
|
44
|
+
export const OPEN_ELSEWHERE_MARK = "⦿ ";
|
|
45
|
+
|
|
46
|
+
export function formatEntry(e: SessionEntry, maxWidth = 80, openElsewhere = false): string {
|
|
44
47
|
const prefix = `${formatAge(e.mtime).padEnd(10)} ${formatSize(e.size).padEnd(8)} `;
|
|
45
|
-
const
|
|
46
|
-
|
|
48
|
+
const mark = openElsewhere ? OPEN_ELSEWHERE_MARK : "";
|
|
49
|
+
const labelMax = Math.max(10, maxWidth - prefix.length - mark.length - PICKER_ROW_MARGIN);
|
|
50
|
+
return prefix + mark + truncate(sessionLabel(e), labelMax);
|
|
47
51
|
}
|
package/src/migrate.ts
CHANGED
|
@@ -1,66 +1,59 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* One-time migration of legacy subagent fork sessions.
|
|
3
3
|
*
|
|
4
|
-
* pi-subagents < 0.53 wrote forked child sessions loose in the
|
|
5
|
-
* sessions dir, so they show up in /resume and in every navigation
|
|
6
|
-
* Since 0.53 the canonical location is
|
|
7
|
-
* (nested under the parent's session
|
|
8
|
-
* removed together with the tree by
|
|
4
|
+
* pi-subagents < 0.53 (2026-08-20) wrote forked child sessions loose in the
|
|
5
|
+
* project sessions dir, so they show up in /resume and in every navigation
|
|
6
|
+
* command. Since 0.53 the canonical location is
|
|
7
|
+
* `<parent-basename>/forks/<file>.jsonl` (nested under the parent's session
|
|
8
|
+
* tree, invisible to top-level listings, removed together with the tree by
|
|
9
|
+
* /rds).
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* A loose file is a subagent fork when ALL hold:
|
|
12
|
+
* 1. it was created before the fix (filename timestamp < LEGACY_CUTOFF);
|
|
13
|
+
* 2. its header carries `parentSession`;
|
|
14
|
+
* 3. it contains the delegated-subagent task prompt;
|
|
15
|
+
* 4. after the LAST task prompt there is no human user message — only
|
|
16
|
+
* orchestrator follow-ups (`Task:`, `Mid-run steering…`, attachments).
|
|
17
|
+
*
|
|
18
|
+
* (4) is essential: a manual /fork of a session whose history contains a
|
|
19
|
+
* subagent task inherits the marker text but then continues with the user's
|
|
20
|
+
* own messages. Such sessions must never be touched.
|
|
21
|
+
*
|
|
22
|
+
* After one run nothing is left to scan (only legacy manual forks, which are
|
|
23
|
+
* re-verified — a small, non-growing set), so navigation stays stat-only.
|
|
15
24
|
*/
|
|
16
25
|
|
|
17
26
|
import { open, rename, mkdir, access } from "node:fs/promises";
|
|
18
27
|
import { createReadStream } from "node:fs";
|
|
28
|
+
import { createInterface } from "node:readline";
|
|
19
29
|
import { join, dirname, basename } from "node:path";
|
|
20
30
|
import type { StatResult } from "./scanner.ts";
|
|
21
31
|
|
|
22
32
|
const HEAD_BYTES = 2048;
|
|
23
|
-
const TAIL_BYTES = 4096;
|
|
24
33
|
const TASK_MARKER = "You are a delegated subagent";
|
|
25
|
-
|
|
34
|
+
/** User-role messages a subagent orchestrator injects (not a human). */
|
|
35
|
+
const ORCHESTRATOR_PREFIXES = ["Task:", "Mid-run steering", "<file ", "<attachment"];
|
|
36
|
+
/** pi-subagents 0.53.0 release: forks created on/after this are already nested. */
|
|
37
|
+
export const LEGACY_CUTOFF = "2026-08-20";
|
|
26
38
|
|
|
27
|
-
async function
|
|
39
|
+
async function readHead(file: string, size: number): Promise<string> {
|
|
28
40
|
const fh = await open(file, "r");
|
|
29
41
|
try {
|
|
30
|
-
const
|
|
31
|
-
const
|
|
42
|
+
const len = Math.min(HEAD_BYTES, size);
|
|
43
|
+
const buf = Buffer.alloc(len);
|
|
44
|
+
const { bytesRead } = await fh.read(buf, 0, len, 0);
|
|
32
45
|
return buf.subarray(0, bytesRead).toString("utf8");
|
|
33
46
|
} finally {
|
|
34
47
|
await fh.close();
|
|
35
48
|
}
|
|
36
49
|
}
|
|
37
50
|
|
|
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
51
|
/** Parent session path from the header line, or undefined if not a fork. */
|
|
59
52
|
async function parentOf(ref: StatResult): Promise<string | undefined> {
|
|
60
53
|
if (ref.size === 0) return undefined;
|
|
61
54
|
let head: string;
|
|
62
55
|
try {
|
|
63
|
-
head = await
|
|
56
|
+
head = await readHead(ref.file, ref.size);
|
|
64
57
|
} catch {
|
|
65
58
|
return undefined;
|
|
66
59
|
}
|
|
@@ -74,14 +67,50 @@ async function parentOf(ref: StatResult): Promise<string | undefined> {
|
|
|
74
67
|
}
|
|
75
68
|
}
|
|
76
69
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
70
|
+
/** Text of a user-role message line, or undefined if it is not one. */
|
|
71
|
+
function userText(line: string): string | undefined {
|
|
72
|
+
if (!line.includes('"role":"user"')) return undefined;
|
|
79
73
|
try {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
74
|
+
const msg = JSON.parse(line)?.message;
|
|
75
|
+
if (msg?.role !== "user" || !Array.isArray(msg.content)) return undefined;
|
|
76
|
+
return msg.content
|
|
77
|
+
.filter((b: any) => b?.type === "text" && typeof b.text === "string")
|
|
78
|
+
.map((b: any) => b.text)
|
|
79
|
+
.join("")
|
|
80
|
+
.trimStart();
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const isOrchestrator = (text: string): boolean => ORCHESTRATOR_PREFIXES.some((p) => text.startsWith(p));
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Single streamed pass: was a task prompt seen, and did a human write
|
|
90
|
+
* anything after the last one? Never buffers the file.
|
|
91
|
+
*/
|
|
92
|
+
async function isSubagentChild(file: string): Promise<boolean> {
|
|
93
|
+
let markerSeen = false;
|
|
94
|
+
let humanAfter = false;
|
|
95
|
+
try {
|
|
96
|
+
const rl = createInterface({
|
|
97
|
+
input: createReadStream(file, { encoding: "utf8" }),
|
|
98
|
+
crlfDelay: Infinity,
|
|
99
|
+
});
|
|
100
|
+
for await (const line of rl) {
|
|
101
|
+
if (line.includes(TASK_MARKER)) {
|
|
102
|
+
markerSeen = true;
|
|
103
|
+
humanAfter = false;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!markerSeen || humanAfter) continue;
|
|
107
|
+
const text = userText(line);
|
|
108
|
+
if (text && !isOrchestrator(text)) humanAfter = true;
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
return markerSeen && !humanAfter;
|
|
85
114
|
}
|
|
86
115
|
|
|
87
116
|
export interface LegacyFork {
|
|
@@ -97,14 +126,19 @@ export function forkTarget(file: string, parentSession: string): string {
|
|
|
97
126
|
return join(dirname(file), basename(parentSession, ".jsonl"), "forks", basename(file));
|
|
98
127
|
}
|
|
99
128
|
|
|
129
|
+
/** Created before the pi-subagents fix? (ISO timestamp prefix sorts lexically.) */
|
|
130
|
+
export function isLegacyName(file: string): boolean {
|
|
131
|
+
return basename(file) < LEGACY_CUTOFF;
|
|
132
|
+
}
|
|
133
|
+
|
|
100
134
|
/** Find loose subagent fork sessions among `files`, skipping `skip` paths. */
|
|
101
135
|
export async function findLegacyForks(files: StatResult[], skip: Set<string> = new Set()): Promise<LegacyFork[]> {
|
|
102
136
|
const out: LegacyFork[] = [];
|
|
103
137
|
for (const ref of files) {
|
|
104
|
-
if (skip.has(ref.file)) continue;
|
|
138
|
+
if (skip.has(ref.file) || !isLegacyName(ref.file)) continue;
|
|
105
139
|
const parent = await parentOf(ref);
|
|
106
140
|
if (!parent) continue;
|
|
107
|
-
if (!(await isSubagentChild(ref))) continue;
|
|
141
|
+
if (!(await isSubagentChild(ref.file))) continue;
|
|
108
142
|
out.push({ file: ref.file, target: forkTarget(ref.file, parent) });
|
|
109
143
|
}
|
|
110
144
|
return out;
|
package/src/nav.ts
CHANGED
|
@@ -16,6 +16,36 @@ export type Hidden<T extends FileRef> = (f: T) => Promise<boolean>;
|
|
|
16
16
|
|
|
17
17
|
const never = async () => false;
|
|
18
18
|
|
|
19
|
+
// pi session filenames: `<ISO timestamp with '-' instead of ':' and '.'>_<uuid>.jsonl`
|
|
20
|
+
// e.g. 2026-08-10T14-31-05-921Z_019fec15-....jsonl
|
|
21
|
+
const FILENAME_TS_RE = /(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z_/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Session creation time from the filename (immutable), or undefined if the
|
|
25
|
+
* name doesn't follow pi's pattern.
|
|
26
|
+
*/
|
|
27
|
+
export function createdAtFromName(file: string): number | undefined {
|
|
28
|
+
const base = file.slice(file.lastIndexOf("/") + 1);
|
|
29
|
+
const m = FILENAME_TS_RE.exec(base);
|
|
30
|
+
if (!m) return undefined;
|
|
31
|
+
const t = Date.parse(`${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z`);
|
|
32
|
+
return isNaN(t) ? undefined : t;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Stable order for step navigation: newest-created first.
|
|
37
|
+
*
|
|
38
|
+
* mtime is NOT stable for walking: resuming a session makes extensions append
|
|
39
|
+
* entries (e.g. state on session_start), which bumps its mtime to "now" and
|
|
40
|
+
* reshuffles the list, so /rn, /rn, /rn ping-pongs between two sessions.
|
|
41
|
+
* Creation time never changes, so a walk over it is predictable. Files
|
|
42
|
+
* without a parseable timestamp fall back to mtime.
|
|
43
|
+
*/
|
|
44
|
+
export function sortByCreated<T extends FileRef & { mtime: Date }>(files: T[]): T[] {
|
|
45
|
+
const key = (f: T) => createdAtFromName(f.file) ?? f.mtime.getTime();
|
|
46
|
+
return [...files].sort((a, b) => key(b) - key(a));
|
|
47
|
+
}
|
|
48
|
+
|
|
19
49
|
/**
|
|
20
50
|
* Rank-th most recent visible session, current excluded (rank 1 = latest).
|
|
21
51
|
* Returns the target or undefined, plus how many visible candidates were seen.
|
|
@@ -36,8 +66,8 @@ export async function rankTarget<T extends FileRef>(
|
|
|
36
66
|
}
|
|
37
67
|
|
|
38
68
|
/**
|
|
39
|
-
* Step relative to the current session in the
|
|
40
|
-
* hidden files. dir = 1 → older, dir = -1 → newer. An unsaved current session
|
|
69
|
+
* Step relative to the current session in the given list (callers pass
|
|
70
|
+
* sortByCreated() output for a stable walk), skipping hidden files. dir = 1 → older, dir = -1 → newer. An unsaved current session
|
|
41
71
|
* (not in the list) is treated as the newest. `pos`/`total` are raw list
|
|
42
72
|
* indices (1-based) for the on-screen indicator.
|
|
43
73
|
*/
|