pi-link 0.2.0 → 0.4.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
@@ -6,6 +6,7 @@
6
6
  // pi-link <name> [--global|-g] [flags...]
7
7
  // Resume or create a named session, connected to link.
8
8
  // pi-link --list [--global|-g] List pi-link sessions in current cwd (or everywhere).
9
+ // pi-link --status [--json] Show terminals connected to the running hub right now.
9
10
  // pi-link --resolve <name> [--global|-g]
10
11
  // Print just the session path (machine-readable).
11
12
  // pi-link --version Print the installed pi-link version.
@@ -71,25 +72,42 @@ function resolveSessionDir(cwd, agentDir) {
71
72
  }
72
73
 
73
74
  // Reads a session JSONL file and returns its display name, cwd, id, link
74
- // status, and message count.
75
+ // status, and message count. Returns null when `scopeCwd` is given and the
76
+ // session's header names a different cwd.
75
77
  //
76
78
  // Name precedence: latest valid `link-name` custom entry wins as the
77
79
  // authoritative pi-link name. `session_info.name` is only a fallback for
78
80
  // sessions that never set a link-name. Historical link-names are not aliases.
79
- async function getSessionMeta(filePath) {
81
+ //
82
+ // A scoped scan only ever keeps sessions from `scopeCwd`, so a session whose
83
+ // header names another cwd is abandoned there instead of being read to EOF for
84
+ // a name that would be filtered out anyway. Pi writes that header as the first
85
+ // complete line of the file, before any history.
86
+ //
87
+ // Only `undefined` means unscoped: a normalized scope is the empty string at
88
+ // POSIX root, which is a real scope and must not read as "no scope".
89
+ async function getSessionMeta(filePath, scopeCwd) {
80
90
  let linkName;
81
91
  let sessionName;
82
92
  let cwd;
83
93
  let id;
84
94
  let hasLinkName = false;
85
95
  let messages = 0;
86
- const rl = createInterface({ input: createReadStream(filePath, "utf-8"), crlfDelay: Infinity });
96
+ const input = createReadStream(filePath, "utf-8");
97
+ const rl = createInterface({ input, crlfDelay: Infinity });
87
98
  for await (const line of rl) {
88
99
  if (!line) continue;
89
100
  try {
90
101
  const entry = JSON.parse(line);
91
102
  if (entry.type === "session") {
92
- if (typeof entry.cwd === "string") cwd = entry.cwd;
103
+ if (typeof entry.cwd === "string") {
104
+ if (scopeCwd !== undefined && normalizePath(entry.cwd) !== scopeCwd) {
105
+ rl.close();
106
+ input.destroy();
107
+ return null;
108
+ }
109
+ cwd = entry.cwd;
110
+ }
93
111
  if (typeof entry.id === "string") id = entry.id;
94
112
  } else if (entry.type === "session_info" && typeof entry.name === "string") {
95
113
  sessionName = normalizeName(entry.name) || undefined;
@@ -134,6 +152,11 @@ const useAnsi =
134
152
  const bold = (s) => (useAnsi ? `\x1b[1m${s}\x1b[22m` : s);
135
153
  const dim = (s) => (useAnsi ? `\x1b[2m${s}\x1b[22m` : s);
136
154
 
155
+ // What `--status` prints for a field the hub could not report. Declared here,
156
+ // above the dispatcher: the mode handlers are hoisted functions, but a `const`
157
+ // read from one would still be in its temporal dead zone when dispatch runs.
158
+ const UNKNOWN = "?";
159
+
137
160
  function relTime(d) {
138
161
  const sec = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000));
139
162
  if (sec < 60) return `${sec}s ago`;
@@ -146,9 +169,10 @@ function relTime(d) {
146
169
  return d.toISOString().slice(0, 10);
147
170
  }
148
171
 
149
- async function loadSessionRecord(filePath) {
172
+ async function loadSessionRecord(filePath, scopeCwd) {
150
173
  try {
151
- const meta = await getSessionMeta(filePath);
174
+ const meta = await getSessionMeta(filePath, scopeCwd);
175
+ if (!meta) return null; // known-foreign: not even worth a stat
152
176
  const stats = await stat(filePath);
153
177
  return { ...meta, modified: stats.mtime, path: filePath };
154
178
  } catch {
@@ -156,11 +180,12 @@ async function loadSessionRecord(filePath) {
156
180
  }
157
181
  }
158
182
 
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) {
183
+ // Returns meta + mtime + path for every readable session in `dir`, or only
184
+ // those from `scopeCwd` when it is given. Custom layout is flat
185
+ // (<dir>/*.jsonl); default layout has one subdir level per encoded cwd
186
+ // (<dir>/<sub>/*.jsonl). Errors on individual files/dirs are silently skipped
187
+ // — active or partially-written sessions are tolerated.
188
+ async function scanSessions(dir, isCustom, scopeCwd) {
164
189
  let entries;
165
190
  try {
166
191
  entries = await readdir(dir, { withFileTypes: true });
@@ -172,7 +197,7 @@ async function scanSessions(dir, isCustom) {
172
197
  if (isCustom) {
173
198
  for (const entry of entries) {
174
199
  if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
175
- tasks.push(loadSessionRecord(join(dir, entry.name)));
200
+ tasks.push(loadSessionRecord(join(dir, entry.name), scopeCwd));
176
201
  }
177
202
  } else {
178
203
  for (const sub of entries) {
@@ -182,7 +207,7 @@ async function scanSessions(dir, isCustom) {
182
207
  try { files = await readdir(subPath); } catch { continue; }
183
208
  for (const file of files) {
184
209
  if (!file.endsWith(".jsonl")) continue;
185
- tasks.push(loadSessionRecord(join(subPath, file)));
210
+ tasks.push(loadSessionRecord(join(subPath, file), scopeCwd));
186
211
  }
187
212
  }
188
213
  }
@@ -190,28 +215,28 @@ async function scanSessions(dir, isCustom) {
190
215
  return (await Promise.all(tasks)).filter((s) => s !== null);
191
216
  }
192
217
 
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>`
218
+ // Find sessions whose current display name matches `targetName`, restricted to
219
+ // `scopeCwd` when given and searched across every cwd when not. Falls back to
220
+ // `session_info.name` for sessions without a link-name (so `pi-link <name>`
197
221
  // 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))
222
+ //
223
+ // The cwd predicate is applied here as well as in the scan: a session whose
224
+ // header carries no cwd is not rejected early, and must still be left out of a
225
+ // scoped result.
226
+ async function findSessionsByName(targetName, dir, isCustom, scopeCwd) {
227
+ return (await scanSessions(dir, isCustom, scopeCwd))
201
228
  .filter((s) => s.name === targetName)
229
+ .filter((s) => scopeCwd === undefined || (s.cwd && normalizePath(s.cwd) === scopeCwd))
202
230
  .map((s) => ({ path: s.path, cwd: s.cwd || "?", modified: s.modified }))
203
231
  .sort((a, b) => b.modified.getTime() - a.modified.getTime());
204
- const local = all.filter((s) => normalizePath(s.cwd) === localCwd);
205
- return { local, all };
206
232
  }
207
233
 
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))
234
+ // List pi-link sessions (those with at least one link-name entry), restricted
235
+ // to `scopeCwd` when given and covering every cwd when not.
236
+ async function listSessions({ dir, isCustom, scopeCwd }) {
237
+ return (await scanSessions(dir, isCustom, scopeCwd))
213
238
  .filter((s) => s.hasLinkName)
214
- .filter((s) => all || (s.cwd && normalizePath(s.cwd) === localCwd))
239
+ .filter((s) => scopeCwd === undefined || (s.cwd && normalizePath(s.cwd) === scopeCwd))
215
240
  .map((s) => ({
216
241
  name: s.name || "(unnamed)",
217
242
  cwd: s.cwd || "?",
@@ -280,11 +305,13 @@ function fail(msg) {
280
305
  function printHelp() {
281
306
  console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
282
307
  console.error(" pi-link --list [--global|-g]");
308
+ console.error(" pi-link --status [--json]");
283
309
  console.error(" pi-link --resolve <name> [--global|-g]");
284
310
  console.error(" pi-link --version");
285
311
  console.error("");
286
312
  console.error("By default, name lookup is scoped to the current cwd.");
287
313
  console.error("--global / -g widens the search to sessions in any cwd.");
314
+ console.error("--list reads saved sessions; --status asks the running hub who is connected.");
288
315
  }
289
316
 
290
317
  function printVersion() {
@@ -303,6 +330,7 @@ function describeMode(mode) {
303
330
  case "help": return "--help";
304
331
  case "version": return "--version";
305
332
  case "list": return "--list";
333
+ case "status": return "--status";
306
334
  case "resolve": return "--resolve";
307
335
  case "launcher": return "session name";
308
336
  default: return mode;
@@ -319,10 +347,11 @@ function describeMode(mode) {
319
347
  // 5. Launcher passthrough (mode launcher) with orphan-positional rejection
320
348
 
321
349
  const state = {
322
- mode: null, // null | "help" | "version" | "list" | "resolve" | "launcher"
350
+ mode: null, // null | "help" | "version" | "list" | "status" | "resolve" | "launcher"
323
351
  resolveName: null,
324
352
  launcherName: null,
325
353
  global: false,
354
+ json: false,
326
355
  piPassthrough: [],
327
356
  };
328
357
 
@@ -364,11 +393,25 @@ for (let i = 0; i < rawArgs.length; i++) {
364
393
  break;
365
394
  }
366
395
 
396
+ // `--json` modifies --status only. Claimed before a mode exists so order does
397
+ // not matter, but never in launcher mode, where it belongs to pi.
398
+ if (a === "--json" && (state.mode === null || state.mode === "status")) {
399
+ state.json = true;
400
+ continue;
401
+ }
402
+
367
403
  // Phase 2: mode-selecting flags.
368
404
  if (a === "--list") {
369
405
  setMode("list");
370
406
  continue;
371
407
  }
408
+ // Selects the wrapper's own mode only before a session name has been seen.
409
+ // After that it is pi's flag, exactly like `--json` above — intercepting it
410
+ // unconditionally would break `pi-link foo --status`.
411
+ if (a === "--status" && state.mode !== "launcher") {
412
+ setMode("status");
413
+ continue;
414
+ }
372
415
  if (a.startsWith("--resolve=")) {
373
416
  setMode("resolve");
374
417
  if (state.resolveName !== null) fail(`--resolve specified more than once`);
@@ -397,6 +440,9 @@ for (let i = 0; i < rawArgs.length; i++) {
397
440
  if (state.mode === "list") {
398
441
  fail(`--list does not accept argument: ${a}\n Usage: pi-link --list [--global|-g]`);
399
442
  }
443
+ if (state.mode === "status") {
444
+ fail(`--status does not accept arguments: ${a}\n Usage: pi-link --status [--json]`);
445
+ }
400
446
  if (state.mode === "resolve") {
401
447
  fail(`--resolve accepts exactly one name; got extra: ${a}`);
402
448
  }
@@ -446,6 +492,14 @@ if (state.mode === "resolve") {
446
492
  }
447
493
  state.resolveName = normalized;
448
494
  }
495
+ // `--status` reads one running hub, so a cwd scope is meaningless rather than
496
+ // merely unused: silently ignoring `-g` would imply a filter that cannot exist.
497
+ if (state.mode === "status" && state.global) {
498
+ fail(`cannot combine --status and --global`);
499
+ }
500
+ if (state.json && state.mode !== "status") {
501
+ fail(`--json is only valid with --status`);
502
+ }
449
503
  if (state.mode === "launcher") {
450
504
  const normalized = normalizeName(state.launcherName);
451
505
  if (!normalized) {
@@ -469,6 +523,9 @@ switch (state.mode) {
469
523
  case "list":
470
524
  await runList(state);
471
525
  break;
526
+ case "status":
527
+ await runStatus(state);
528
+ break;
472
529
  case "resolve":
473
530
  await runResolve(state);
474
531
  break;
@@ -481,9 +538,14 @@ switch (state.mode) {
481
538
 
482
539
  // ── Mode handlers ──────────────────────────────────────────────────────────
483
540
 
541
+ // A local operation scans only its own cwd; `--global` scans every cwd.
542
+ function localScope(state) {
543
+ return state.global ? undefined : normalizePath(process.cwd());
544
+ }
545
+
484
546
  async function runList(state) {
485
547
  const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
486
- const sessions = await listSessions({ all: state.global, dir, isCustom });
548
+ const sessions = await listSessions({ dir, isCustom, scopeCwd: localScope(state) });
487
549
  if (sessions.length === 0) {
488
550
  console.log(state.global ? "No pi-link sessions found." : "No pi-link sessions found in this cwd.");
489
551
  console.log("Start one: pi-link <name>");
@@ -510,11 +572,149 @@ async function runList(state) {
510
572
  }
511
573
  }
512
574
 
575
+ // ── Status (live hub query) ────────────────────────────────────────────────
576
+ //
577
+ // `--list` reads saved history; `--status` asks the hub who is connected now.
578
+ // The two answer different questions, so they share no code path.
579
+
580
+ // Mirrors the extension's formatTokens so both renderings of one number agree.
581
+ function formatTokens(n) {
582
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
583
+ if (n >= 1_000) return `${Math.round(n / 1000)}K`;
584
+ return `${n}`;
585
+ }
586
+
587
+ // `92K/272K (34%)`, or `?/272K` when the hub has a window but no token count.
588
+ // Only reached for a validated payload, so `c` is null or a well-typed snapshot;
589
+ // a non-positive window is still possible and still means nothing to report.
590
+ function formatContext(c) {
591
+ if (!c || c.window <= 0) return UNKNOWN;
592
+ const window = formatTokens(c.window);
593
+ if (typeof c.tokens !== "number") return `${UNKNOWN}/${window}`;
594
+ return `${formatTokens(c.tokens)}/${window} (${Math.round((c.tokens / c.window) * 100)}%)`;
595
+ }
596
+
597
+ function formatAge(seconds) {
598
+ if (seconds < 60) return `${seconds}s`;
599
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
600
+ return `${Math.floor(seconds / 3600)}h`;
601
+ }
602
+
603
+ // `status` and `sinceSeconds` are an optional pair: the hub omits them for a
604
+ // terminal it has registered but not yet heard from. Absence means unknown, so
605
+ // it must render as unknown — printing `idle` there would be an invention, and
606
+ // acting on it is the misreporting this command exists to end.
607
+ function formatTerminalStatus(entry) {
608
+ if (typeof entry.status !== "string") return UNKNOWN;
609
+ return `${entry.status} (${formatAge(entry.sinceSeconds)})`;
610
+ }
611
+
612
+ function failUnsupported() {
613
+ console.error("Link hub does not support /status \u2014 update pi-link and restart terminals.");
614
+ process.exit(1);
615
+ }
616
+
617
+ function failNoHub(port) {
618
+ console.error(`No link hub running on :${port}.`);
619
+ process.exit(2);
620
+ }
621
+
622
+ // The frozen contract, checked before any field is read. Anything else on the
623
+ // port — a different service, a newer hub, a truncated proxy — must produce the
624
+ // unsupported message, never a stack trace. Unknown extra fields stay allowed.
625
+ function isContextField(c) {
626
+ if (c === null) return true;
627
+ if (!c || typeof c !== "object") return false;
628
+ return (c.tokens === null || typeof c.tokens === "number") && typeof c.window === "number";
629
+ }
630
+
631
+ function isTerminalEntry(entry, index) {
632
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
633
+ if (typeof entry.name !== "string") return false;
634
+ // Hub first, then clients — the ordering the renderer relies on.
635
+ if (entry.role !== (index === 0 ? "hub" : "client")) return false;
636
+ // `status` and `sinceSeconds` are one optional pair: both or neither.
637
+ //
638
+ // The value is checked for shape, not vocabulary. `idle`/`thinking`/
639
+ // `compacting`/`tool:<name>` are today's kinds, but that set has already grown
640
+ // once (`compacting` arrived after 0.3.0) and the CLI never branches on it — it
641
+ // only prints it. Freezing the list here would make a newer hub's fifth kind
642
+ // reject the whole payload, and only while some terminal happened to be in that
643
+ // state: an intermittent failure telling the user to update. An empty string is
644
+ // still rejected, because it renders as a blank cell with a bare duration.
645
+ const hasStatus = "status" in entry;
646
+ if (hasStatus !== ("sinceSeconds" in entry)) return false;
647
+ if (hasStatus && (typeof entry.status !== "string" || entry.status === "" || typeof entry.sinceSeconds !== "number")) {
648
+ return false;
649
+ }
650
+ if ("cwd" in entry && typeof entry.cwd !== "string") return false;
651
+ return "context" in entry && isContextField(entry.context);
652
+ }
653
+
654
+ function isStatusPayload(payload) {
655
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
656
+ if (typeof payload.hub !== "string" || typeof payload.port !== "number") return false;
657
+ // A hub always reports itself, so an empty list is not this contract.
658
+ if (!Array.isArray(payload.terminals) || payload.terminals.length === 0) return false;
659
+ if (!payload.terminals.every(isTerminalEntry)) return false;
660
+ return payload.terminals[0].name === payload.hub;
661
+ }
662
+
663
+ async function runStatus(state) {
664
+ // Deliberately unvalidated: a bad value fails the fetch and is reported with
665
+ // the port in the message, which is the diagnosis.
666
+ const port = process.env.PI_LINK_PORT ?? 9900;
667
+ // One deadline covers the whole exchange, headers and body alike, so it has to
668
+ // be readable afterwards to tell a timeout from a malformed answer.
669
+ const deadline = AbortSignal.timeout(2000);
670
+ let response;
671
+ try {
672
+ response = await fetch(`http://127.0.0.1:${port}/status`, { signal: deadline });
673
+ } catch {
674
+ // Refused, unreachable or timed out: no hub is answering at this instant.
675
+ failNoHub(port);
676
+ }
677
+
678
+ if (!response.ok) failUnsupported();
679
+
680
+ let body;
681
+ try {
682
+ body = await response.text();
683
+ } catch {
684
+ // A listener that sends headers and then stalls is still an unanswered
685
+ // query, not an old hub: every timeout reports as "no hub".
686
+ if (deadline.aborted) failNoHub(port);
687
+ failUnsupported();
688
+ }
689
+
690
+ let payload;
691
+ try {
692
+ payload = JSON.parse(body);
693
+ } catch {
694
+ failUnsupported();
695
+ }
696
+ if (!isStatusPayload(payload)) failUnsupported();
697
+
698
+ if (state.json) {
699
+ process.stdout.write(body);
700
+ return;
701
+ }
702
+
703
+ // Payload order is meaningful: the hub is first, then clients sorted by name.
704
+ console.log(
705
+ renderTable(payload.terminals, [
706
+ { header: "NAME", get: (e) => e.name },
707
+ { header: "STATUS", get: (e) => formatTerminalStatus(e) },
708
+ { header: "CONTEXT", get: (e) => formatContext(e.context) },
709
+ { header: "CWD", get: (e) => (e.cwd ? displayPath(e.cwd) : UNKNOWN), dim: true },
710
+ ]),
711
+ );
712
+ }
713
+
513
714
  async function runResolve(state) {
514
715
  const name = state.resolveName; // already normalized
515
716
  const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
516
- const { local, all } = await findSessionsByName(name, dir, isCustom);
517
- const matches = state.global ? all : local;
717
+ const matches = await findSessionsByName(name, dir, isCustom, localScope(state));
518
718
  if (matches.length === 1) {
519
719
  process.stdout.write(matches[0].path);
520
720
  return; // exit 0
@@ -523,18 +723,17 @@ async function runResolve(state) {
523
723
  printCandidates(name, matches); // exits 1
524
724
  }
525
725
  // matches.length === 0 → not found; exit 2 to distinguish from ambiguous.
726
+ // A local scan never reads names from other cwds, so the advice is offered
727
+ // without claiming anything is there.
526
728
  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
- }
729
+ if (!state.global) console.error("Use --global to search other cwds.");
530
730
  process.exit(2);
531
731
  }
532
732
 
533
733
  async function runLauncher(state) {
534
734
  const name = state.launcherName; // already normalized
535
735
  const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
536
- const { local, all } = await findSessionsByName(name, dir, isCustom);
537
- const matches = state.global ? all : local;
736
+ const matches = await findSessionsByName(name, dir, isCustom, localScope(state));
538
737
  if (matches.length > 1) {
539
738
  printCandidates(name, matches);
540
739
  }
@@ -544,9 +743,8 @@ async function runLauncher(state) {
544
743
  console.error(`Resuming session: ${matches[0].path}`);
545
744
  piArgs.push("--session", matches[0].path);
546
745
  } 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"}.)`);
746
+ if (!state.global) {
747
+ console.error(`No "${name}" found in this cwd. Use --global to search other cwds.`);
550
748
  }
551
749
  console.error("Starting new session.");
552
750
  }