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 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. A `(pos/total)` indicator is shown on
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`, `/rs` and `--r` never land
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 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.
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
 
@@ -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
- const { target, pos, total } = await navTarget(files, currentFile, dir, hidden);
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
- const hidden = await hiddenPredicate();
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.2.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 { join } from "node:path";
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 pid)
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
- try {
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") result.add(rec.file);
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
- export function formatEntry(e: SessionEntry, maxWidth = 80): string {
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 labelMax = Math.max(10, maxWidth - prefix.length - PICKER_ROW_MARGIN);
46
- return prefix + truncate(sessionLabel(e), labelMax);
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 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).
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
- * 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.
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
- const NAME_MARKER = '"name":"subagent-';
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 readSlice(file: string, position: number, length: number): Promise<string> {
39
+ async function readHead(file: string, size: number): Promise<string> {
28
40
  const fh = await open(file, "r");
29
41
  try {
30
- const buf = Buffer.alloc(length);
31
- const { bytesRead } = await fh.read(buf, 0, length, position);
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 readSlice(ref.file, 0, Math.min(HEAD_BYTES, ref.size));
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
- /** Is this fork a subagent child (vs. a manual /fork)? */
78
- async function isSubagentChild(ref: StatResult): Promise<boolean> {
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 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);
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 mtime-sorted list, skipping
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
  */