pi-resume 1.2.1 → 1.3.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 +12 -7
- package/extensions/index.ts +9 -3
- package/package.json +1 -1
- package/src/active.ts +61 -8
- package/src/format.ts +7 -3
- package/src/migrate.ts +79 -45
package/README.md
CHANGED
|
@@ -103,8 +103,10 @@ subagent tree subdirectories. Your real top-level `*.jsonl` sessions (the ones
|
|
|
103
103
|
|
|
104
104
|
Each pi instance with this extension records its current session file in
|
|
105
105
|
`~/.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.
|
|
106
|
+
dead or reused pids are ignored). `/r1`, `/r2`, `/rn`, `/rp` and `--r` never
|
|
107
|
+
land on a chat you have open in another terminal. `/rs` still lists such
|
|
108
|
+
sessions, marked with `⦿`, so you can see where a chat went — selecting one is
|
|
109
|
+
refused instead of hijacking the other instance.
|
|
108
110
|
|
|
109
111
|
### Legacy subagent forks are tidied up once
|
|
110
112
|
|
|
@@ -114,11 +116,14 @@ sessions dir — same filename shape and `parentSession` header as your own
|
|
|
114
116
|
the canonical place is `<parent>/forks/<file>.jsonl`.
|
|
115
117
|
|
|
116
118
|
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
|
-
|
|
119
|
+
one time per file). A file is moved only if **all** hold: it was created before
|
|
120
|
+
the fix (2026-08-20), it has a `parentSession` header, it contains the
|
|
121
|
+
delegated-subagent task prompt, and **no human message follows the last task
|
|
122
|
+
prompt** (only orchestrator follow-ups such as steering). That last rule keeps
|
|
123
|
+
manual `/fork`s safe even when their inherited history contains a subagent task.
|
|
124
|
+
The current session and sessions open in other pi instances are skipped;
|
|
125
|
+
existing targets are never overwritten. After that, nothing needs filtering and
|
|
126
|
+
every command is stat-only again.
|
|
122
127
|
|
|
123
128
|
## How it works
|
|
124
129
|
|
package/extensions/index.ts
CHANGED
|
@@ -313,7 +313,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
313
313
|
const sessionDir = sessionDirFor(ctx);
|
|
314
314
|
const currentFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
|
315
315
|
const onError = scanErrorNotifier(ctx);
|
|
316
|
-
|
|
316
|
+
// The picker SHOWS sessions open in another pi (marked) instead of hiding
|
|
317
|
+
// them, so the user can see where a "missing" chat went; selecting one is
|
|
318
|
+
// refused rather than hijacking the other instance's session.
|
|
319
|
+
const elsewhere = await activeElsewhere();
|
|
317
320
|
|
|
318
321
|
let tierIndex = 0;
|
|
319
322
|
let offset = 0;
|
|
@@ -329,7 +332,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
329
332
|
currentDays > 0 ? currentDays : undefined,
|
|
330
333
|
currentFile,
|
|
331
334
|
onError,
|
|
332
|
-
hidden,
|
|
333
335
|
);
|
|
334
336
|
|
|
335
337
|
if (entries.length === 0 && offset === 0) {
|
|
@@ -343,7 +345,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
343
345
|
|
|
344
346
|
const termWidth = process.stdout.columns || 80;
|
|
345
347
|
const items = buildPickerItems(
|
|
346
|
-
entries.map((e) => formatEntry(e, termWidth)),
|
|
348
|
+
entries.map((e) => formatEntry(e, termWidth, elsewhere.has(e.file))),
|
|
347
349
|
{
|
|
348
350
|
remaining: hasMore ? total - offset - entries.length : undefined,
|
|
349
351
|
nextTierLabel:
|
|
@@ -368,6 +370,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
368
370
|
case "entry": {
|
|
369
371
|
const selected = entries[action.index];
|
|
370
372
|
if (!selected) return;
|
|
373
|
+
if (elsewhere.has(selected.file)) {
|
|
374
|
+
ctx.ui.notify("That session is open in another pi — not switching", "info");
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
371
377
|
const result = await ctx.switchSession(selected.file, {
|
|
372
378
|
withSession: async (newCtx) => {
|
|
373
379
|
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.0",
|
|
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;
|