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 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` `/r5` | Instantly switch to the N-th most recent session (`/r1` = latest) | <50ms (stat-only) |
16
- | `pi --r N` / `--r1`…`--r5` / `--rn` | Startup flag: open pi and immediately resume the N-th most recent session | <50ms (stat-only) |
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-fast-resume
24
+ pi install npm:pi-resume
25
25
  ```
26
26
 
27
27
  ## Commands
28
28
 
29
- ### `/r1` `/r5` — Instant Ranked Resume
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
- - … up to `/r5` — 5th most recent
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-5):
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 --r3 # open pi in the 3rd most recent
49
- pi --r 3 # same, numeric form
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`…`/r5`. Invalid values show an error and start a normal
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`…`/r5` and `/rs` list) are never touched.
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`…`/r5`**: `readdir` → `stat` each `.jsonl` → sort by mtime → exclude current → `switchSession(others[rank-1])`
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/spex66/pi-fast-resume.git
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
@@ -2,10 +2,10 @@
2
2
  * pi-fast-resume — fast session resume without reading all .jsonl files.
3
3
  *
4
4
  * Commands:
5
- * /r1 .. /r5 — instantly switch to the N-th most recent session
6
- * (stat-only; /r1 = latest, /r5 = 5th, current excluded)
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 .. --r5 — boolean form of the same (pi --r1 = resume latest)
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 = 5;
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 { target, othersCount } = rankTarget(files, currentFile, rank);
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 .. --r5, pi --rn (alias for --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 { target, pos, total } = navTarget(files, currentFile, dir);
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.0",
4
- "description": "Fast session resume for pi coding agent — /r1../r5 ranked resume, /rn and /rp step navigation, pi --r1/--rn startup flags, /rs paginated picker, /rds subagent session cleanup",
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/spex66/pi-fast-resume.git"
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 exist.
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
- ): { target?: T; othersCount: number } {
19
- const others = files.filter((f) => f.file !== currentFile);
20
- return { target: others[rank - 1], othersCount: others.length };
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
- ): { target?: T; pos: number; total: number } {
48
+ hidden: Hidden<T> = never,
49
+ ): Promise<{ target?: T; pos: number; total: number }> {
33
50
  const idx = files.findIndex((f) => f.file === currentFile);
34
- const target = idx === -1 ? (dir === 1 ? files[0] : undefined) : files[idx + dir];
35
- const pos = target ? files.indexOf(target) + 1 : 0;
36
- return { target, pos, total: files.length };
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;