pi-link 0.2.0 → 0.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/bin/pi-link.mjs CHANGED
@@ -71,25 +71,42 @@ function resolveSessionDir(cwd, agentDir) {
71
71
  }
72
72
 
73
73
  // Reads a session JSONL file and returns its display name, cwd, id, link
74
- // status, and message count.
74
+ // status, and message count. Returns null when `scopeCwd` is given and the
75
+ // session's header names a different cwd.
75
76
  //
76
77
  // Name precedence: latest valid `link-name` custom entry wins as the
77
78
  // authoritative pi-link name. `session_info.name` is only a fallback for
78
79
  // sessions that never set a link-name. Historical link-names are not aliases.
79
- async function getSessionMeta(filePath) {
80
+ //
81
+ // A scoped scan only ever keeps sessions from `scopeCwd`, so a session whose
82
+ // header names another cwd is abandoned there instead of being read to EOF for
83
+ // a name that would be filtered out anyway. Pi writes that header as the first
84
+ // complete line of the file, before any history.
85
+ //
86
+ // Only `undefined` means unscoped: a normalized scope is the empty string at
87
+ // POSIX root, which is a real scope and must not read as "no scope".
88
+ async function getSessionMeta(filePath, scopeCwd) {
80
89
  let linkName;
81
90
  let sessionName;
82
91
  let cwd;
83
92
  let id;
84
93
  let hasLinkName = false;
85
94
  let messages = 0;
86
- const rl = createInterface({ input: createReadStream(filePath, "utf-8"), crlfDelay: Infinity });
95
+ const input = createReadStream(filePath, "utf-8");
96
+ const rl = createInterface({ input, crlfDelay: Infinity });
87
97
  for await (const line of rl) {
88
98
  if (!line) continue;
89
99
  try {
90
100
  const entry = JSON.parse(line);
91
101
  if (entry.type === "session") {
92
- if (typeof entry.cwd === "string") cwd = entry.cwd;
102
+ if (typeof entry.cwd === "string") {
103
+ if (scopeCwd !== undefined && normalizePath(entry.cwd) !== scopeCwd) {
104
+ rl.close();
105
+ input.destroy();
106
+ return null;
107
+ }
108
+ cwd = entry.cwd;
109
+ }
93
110
  if (typeof entry.id === "string") id = entry.id;
94
111
  } else if (entry.type === "session_info" && typeof entry.name === "string") {
95
112
  sessionName = normalizeName(entry.name) || undefined;
@@ -146,9 +163,10 @@ function relTime(d) {
146
163
  return d.toISOString().slice(0, 10);
147
164
  }
148
165
 
149
- async function loadSessionRecord(filePath) {
166
+ async function loadSessionRecord(filePath, scopeCwd) {
150
167
  try {
151
- const meta = await getSessionMeta(filePath);
168
+ const meta = await getSessionMeta(filePath, scopeCwd);
169
+ if (!meta) return null; // known-foreign: not even worth a stat
152
170
  const stats = await stat(filePath);
153
171
  return { ...meta, modified: stats.mtime, path: filePath };
154
172
  } catch {
@@ -156,11 +174,12 @@ async function loadSessionRecord(filePath) {
156
174
  }
157
175
  }
158
176
 
159
- // Returns meta + mtime + path for every readable session in `dir`. Custom
160
- // layout is flat (<dir>/*.jsonl); default layout has one subdir level per
161
- // encoded cwd (<dir>/<sub>/*.jsonl). Errors on individual files/dirs are
162
- // silently skipped — active or partially-written sessions are tolerated.
163
- async function scanSessions(dir, isCustom) {
177
+ // Returns meta + mtime + path for every readable session in `dir`, or only
178
+ // those from `scopeCwd` when it is given. Custom layout is flat
179
+ // (<dir>/*.jsonl); default layout has one subdir level per encoded cwd
180
+ // (<dir>/<sub>/*.jsonl). Errors on individual files/dirs are silently skipped
181
+ // — active or partially-written sessions are tolerated.
182
+ async function scanSessions(dir, isCustom, scopeCwd) {
164
183
  let entries;
165
184
  try {
166
185
  entries = await readdir(dir, { withFileTypes: true });
@@ -172,7 +191,7 @@ async function scanSessions(dir, isCustom) {
172
191
  if (isCustom) {
173
192
  for (const entry of entries) {
174
193
  if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
175
- tasks.push(loadSessionRecord(join(dir, entry.name)));
194
+ tasks.push(loadSessionRecord(join(dir, entry.name), scopeCwd));
176
195
  }
177
196
  } else {
178
197
  for (const sub of entries) {
@@ -182,7 +201,7 @@ async function scanSessions(dir, isCustom) {
182
201
  try { files = await readdir(subPath); } catch { continue; }
183
202
  for (const file of files) {
184
203
  if (!file.endsWith(".jsonl")) continue;
185
- tasks.push(loadSessionRecord(join(subPath, file)));
204
+ tasks.push(loadSessionRecord(join(subPath, file), scopeCwd));
186
205
  }
187
206
  }
188
207
  }
@@ -190,28 +209,28 @@ async function scanSessions(dir, isCustom) {
190
209
  return (await Promise.all(tasks)).filter((s) => s !== null);
191
210
  }
192
211
 
193
- // Find sessions whose current display name matches `targetName`. Returns both
194
- // local-cwd matches and all matches (cross-cwd) so the caller can default to
195
- // local while still surfacing a hint when non-local matches exist. Falls back
196
- // to `session_info.name` for sessions without a link-name (so `pi-link <name>`
212
+ // Find sessions whose current display name matches `targetName`, restricted to
213
+ // `scopeCwd` when given and searched across every cwd when not. Falls back to
214
+ // `session_info.name` for sessions without a link-name (so `pi-link <name>`
197
215
  // can attach link to a previously-unlinked named session).
198
- async function findSessionsByName(targetName, dir, isCustom) {
199
- const localCwd = normalizePath(process.cwd());
200
- const all = (await scanSessions(dir, isCustom))
216
+ //
217
+ // The cwd predicate is applied here as well as in the scan: a session whose
218
+ // header carries no cwd is not rejected early, and must still be left out of a
219
+ // scoped result.
220
+ async function findSessionsByName(targetName, dir, isCustom, scopeCwd) {
221
+ return (await scanSessions(dir, isCustom, scopeCwd))
201
222
  .filter((s) => s.name === targetName)
223
+ .filter((s) => scopeCwd === undefined || (s.cwd && normalizePath(s.cwd) === scopeCwd))
202
224
  .map((s) => ({ path: s.path, cwd: s.cwd || "?", modified: s.modified }))
203
225
  .sort((a, b) => b.modified.getTime() - a.modified.getTime());
204
- const local = all.filter((s) => normalizePath(s.cwd) === localCwd);
205
- return { local, all };
206
226
  }
207
227
 
208
- // List pi-link sessions (those with at least one link-name entry). Default
209
- // scope is current cwd; `all` widens to every directory.
210
- async function listSessions({ all, dir, isCustom }) {
211
- const localCwd = normalizePath(process.cwd());
212
- return (await scanSessions(dir, isCustom))
228
+ // List pi-link sessions (those with at least one link-name entry), restricted
229
+ // to `scopeCwd` when given and covering every cwd when not.
230
+ async function listSessions({ dir, isCustom, scopeCwd }) {
231
+ return (await scanSessions(dir, isCustom, scopeCwd))
213
232
  .filter((s) => s.hasLinkName)
214
- .filter((s) => all || (s.cwd && normalizePath(s.cwd) === localCwd))
233
+ .filter((s) => scopeCwd === undefined || (s.cwd && normalizePath(s.cwd) === scopeCwd))
215
234
  .map((s) => ({
216
235
  name: s.name || "(unnamed)",
217
236
  cwd: s.cwd || "?",
@@ -481,9 +500,14 @@ switch (state.mode) {
481
500
 
482
501
  // ── Mode handlers ──────────────────────────────────────────────────────────
483
502
 
503
+ // A local operation scans only its own cwd; `--global` scans every cwd.
504
+ function localScope(state) {
505
+ return state.global ? undefined : normalizePath(process.cwd());
506
+ }
507
+
484
508
  async function runList(state) {
485
509
  const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
486
- const sessions = await listSessions({ all: state.global, dir, isCustom });
510
+ const sessions = await listSessions({ dir, isCustom, scopeCwd: localScope(state) });
487
511
  if (sessions.length === 0) {
488
512
  console.log(state.global ? "No pi-link sessions found." : "No pi-link sessions found in this cwd.");
489
513
  console.log("Start one: pi-link <name>");
@@ -513,8 +537,7 @@ async function runList(state) {
513
537
  async function runResolve(state) {
514
538
  const name = state.resolveName; // already normalized
515
539
  const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
516
- const { local, all } = await findSessionsByName(name, dir, isCustom);
517
- const matches = state.global ? all : local;
540
+ const matches = await findSessionsByName(name, dir, isCustom, localScope(state));
518
541
  if (matches.length === 1) {
519
542
  process.stdout.write(matches[0].path);
520
543
  return; // exit 0
@@ -523,18 +546,17 @@ async function runResolve(state) {
523
546
  printCandidates(name, matches); // exits 1
524
547
  }
525
548
  // matches.length === 0 → not found; exit 2 to distinguish from ambiguous.
549
+ // A local scan never reads names from other cwds, so the advice is offered
550
+ // without claiming anything is there.
526
551
  console.error(`No session named "${name}" found${state.global ? "" : " in this cwd"}.`);
527
- if (!state.global && all.length > 0) {
528
- console.error(`(${all.length} match${all.length === 1 ? "" : "es"} in other cwds — try --global to consider ${all.length === 1 ? "it" : "them"}.)`);
529
- }
552
+ if (!state.global) console.error("Use --global to search other cwds.");
530
553
  process.exit(2);
531
554
  }
532
555
 
533
556
  async function runLauncher(state) {
534
557
  const name = state.launcherName; // already normalized
535
558
  const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
536
- const { local, all } = await findSessionsByName(name, dir, isCustom);
537
- const matches = state.global ? all : local;
559
+ const matches = await findSessionsByName(name, dir, isCustom, localScope(state));
538
560
  if (matches.length > 1) {
539
561
  printCandidates(name, matches);
540
562
  }
@@ -544,9 +566,8 @@ async function runLauncher(state) {
544
566
  console.error(`Resuming session: ${matches[0].path}`);
545
567
  piArgs.push("--session", matches[0].path);
546
568
  } else {
547
- if (!state.global && all.length > local.length) {
548
- const elsewhere = all.length - local.length;
549
- console.error(`No "${name}" in this cwd. (${elsewhere} match${elsewhere === 1 ? "" : "es"} in other cwds — use --global to consider ${elsewhere === 1 ? "it" : "them"}.)`);
569
+ if (!state.global) {
570
+ console.error(`No "${name}" found in this cwd. Use --global to search other cwds.`);
550
571
  }
551
572
  console.error("Starting new session.");
552
573
  }