pi-resume 1.3.0 → 1.3.2

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
@@ -55,6 +55,10 @@ pi --r 2 # same, numeric form
55
55
  Same ranking as `/r1`/`/r2`. Invalid values show an error and start a normal
56
56
  new session. Interactive mode only (ignored with `-p`).
57
57
 
58
+ Under the hood the flag waits for the provider/model refresh to finish and then
59
+ dispatches `/rN` as a command (session switching is only available to commands,
60
+ and switching mid-refresh would abort it).
61
+
58
62
  ### `/rn` / `/rp` — Step Navigation
59
63
 
60
64
  Walk the mtime-sorted session list relative to the **current** session:
@@ -63,7 +67,12 @@ Walk the mtime-sorted session list relative to the **current** session:
63
67
  - `/rp` — previous session (one step **newer**)
64
68
 
65
69
  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
70
+ in time instead of recalculating ranks.
71
+
72
+ `/rn` / `/rp` walk by **creation time** (the timestamp in the session filename),
73
+ not by mtime: resuming a session makes extensions append to it, which bumps its
74
+ mtime and would otherwise reshuffle the list into a ping-pong between two
75
+ sessions. `/r1` and `/rs` still rank by last activity (mtime). A `(pos/total)` indicator is shown on
67
76
  each switch. At the ends of the list a notice is shown and nothing is switched.
68
77
 
69
78
  ### `/rs` — Smart Resume
@@ -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";
@@ -188,7 +188,19 @@ export default function (pi: ExtensionAPI) {
188
188
  if (event.reason !== "startup") return;
189
189
  void migrateForksInBackground(ctx).catch(() => {});
190
190
  const rank = startupRank(ctx);
191
- if (rank !== undefined) await resumeRank(rank, ctx);
191
+ if (rank === undefined) return;
192
+ if (!ctx.hasUI) {
193
+ ctx.ui.notify("--r/--rn: session resume needs interactive mode", "error");
194
+ return;
195
+ }
196
+ // session_start only gets a plain ExtensionContext (no switchSession);
197
+ // dispatch our own slash command so it runs with a command context.
198
+ // Switching while a provider refresh is in flight aborts it and crashes
199
+ // pi (uncaught AbortError from the provider), so wait for it to settle.
200
+ try {
201
+ await ctx.modelRegistry.refresh();
202
+ } catch {}
203
+ pi.sendUserMessage(`/r${rank}`, { expandPromptTemplates: true });
192
204
  });
193
205
 
194
206
  // Fires on switch (followed by session_start) and on exit.
@@ -214,7 +226,9 @@ export default function (pi: ExtensionAPI) {
214
226
 
215
227
  const currentFile = ctx.sessionManager.getSessionFile() ?? undefined;
216
228
  const hidden = await hiddenPredicate();
217
- const { target, pos, total } = await navTarget(files, currentFile, dir, hidden);
229
+ // Walk by creation time: resuming bumps mtime, which would reshuffle
230
+ // an mtime-ordered walk into a ping-pong between two sessions.
231
+ const { target, pos, total } = await navTarget(sortByCreated(files), currentFile, dir, hidden);
218
232
 
219
233
  if (!target) {
220
234
  ctx.ui.notify(edge, "info");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-resume",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
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",
@@ -34,8 +34,8 @@
34
34
  "@earendil-works/pi-tui": "*"
35
35
  },
36
36
  "devDependencies": {
37
- "@earendil-works/pi-coding-agent": "*",
38
- "@earendil-works/pi-tui": "*",
37
+ "@earendil-works/pi-coding-agent": "^0.84.4",
38
+ "@earendil-works/pi-tui": "^0.84.4",
39
39
  "typescript": "^5.0.0"
40
40
  },
41
41
  "scripts": {
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
  */