pi-link 0.3.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.
@@ -151,6 +152,11 @@ const useAnsi =
151
152
  const bold = (s) => (useAnsi ? `\x1b[1m${s}\x1b[22m` : s);
152
153
  const dim = (s) => (useAnsi ? `\x1b[2m${s}\x1b[22m` : s);
153
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
+
154
160
  function relTime(d) {
155
161
  const sec = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000));
156
162
  if (sec < 60) return `${sec}s ago`;
@@ -299,11 +305,13 @@ function fail(msg) {
299
305
  function printHelp() {
300
306
  console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
301
307
  console.error(" pi-link --list [--global|-g]");
308
+ console.error(" pi-link --status [--json]");
302
309
  console.error(" pi-link --resolve <name> [--global|-g]");
303
310
  console.error(" pi-link --version");
304
311
  console.error("");
305
312
  console.error("By default, name lookup is scoped to the current cwd.");
306
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.");
307
315
  }
308
316
 
309
317
  function printVersion() {
@@ -322,6 +330,7 @@ function describeMode(mode) {
322
330
  case "help": return "--help";
323
331
  case "version": return "--version";
324
332
  case "list": return "--list";
333
+ case "status": return "--status";
325
334
  case "resolve": return "--resolve";
326
335
  case "launcher": return "session name";
327
336
  default: return mode;
@@ -338,10 +347,11 @@ function describeMode(mode) {
338
347
  // 5. Launcher passthrough (mode launcher) with orphan-positional rejection
339
348
 
340
349
  const state = {
341
- mode: null, // null | "help" | "version" | "list" | "resolve" | "launcher"
350
+ mode: null, // null | "help" | "version" | "list" | "status" | "resolve" | "launcher"
342
351
  resolveName: null,
343
352
  launcherName: null,
344
353
  global: false,
354
+ json: false,
345
355
  piPassthrough: [],
346
356
  };
347
357
 
@@ -383,11 +393,25 @@ for (let i = 0; i < rawArgs.length; i++) {
383
393
  break;
384
394
  }
385
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
+
386
403
  // Phase 2: mode-selecting flags.
387
404
  if (a === "--list") {
388
405
  setMode("list");
389
406
  continue;
390
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
+ }
391
415
  if (a.startsWith("--resolve=")) {
392
416
  setMode("resolve");
393
417
  if (state.resolveName !== null) fail(`--resolve specified more than once`);
@@ -416,6 +440,9 @@ for (let i = 0; i < rawArgs.length; i++) {
416
440
  if (state.mode === "list") {
417
441
  fail(`--list does not accept argument: ${a}\n Usage: pi-link --list [--global|-g]`);
418
442
  }
443
+ if (state.mode === "status") {
444
+ fail(`--status does not accept arguments: ${a}\n Usage: pi-link --status [--json]`);
445
+ }
419
446
  if (state.mode === "resolve") {
420
447
  fail(`--resolve accepts exactly one name; got extra: ${a}`);
421
448
  }
@@ -465,6 +492,14 @@ if (state.mode === "resolve") {
465
492
  }
466
493
  state.resolveName = normalized;
467
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
+ }
468
503
  if (state.mode === "launcher") {
469
504
  const normalized = normalizeName(state.launcherName);
470
505
  if (!normalized) {
@@ -488,6 +523,9 @@ switch (state.mode) {
488
523
  case "list":
489
524
  await runList(state);
490
525
  break;
526
+ case "status":
527
+ await runStatus(state);
528
+ break;
491
529
  case "resolve":
492
530
  await runResolve(state);
493
531
  break;
@@ -534,6 +572,145 @@ async function runList(state) {
534
572
  }
535
573
  }
536
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
+
537
714
  async function runResolve(state) {
538
715
  const name = state.resolveName; // already normalized
539
716
  const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
package/index.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  import { Text } from "@earendil-works/pi-tui";
19
19
  import { Type } from "typebox";
20
20
  import * as crypto from "node:crypto";
21
+ import { createServer, type Server as HttpServer } from "node:http";
21
22
  import * as os from "node:os";
22
23
 
23
24
  import { WebSocket, WebSocketServer } from "ws";
@@ -209,6 +210,9 @@ export default function (pi: ExtensionAPI) {
209
210
 
210
211
  // Hub state
211
212
  let wss: WebSocketServer | null = null;
213
+ // The hub owns the HTTP server the WS server rides on, because `wss.close()`
214
+ // never closes a server it was handed. Nulled wherever `wss` is.
215
+ let hubHttpServer: HttpServer | null = null;
212
216
  const hubClients = new Map<WebSocket, string>(); // ws → terminal name
213
217
  const hubTerminalStatuses = new Map<string, LinkStatus>(); // hub-authoritative
214
218
  const hubTerminalContexts = new Map<string, ContextSnapshot>(); // hub-authoritative
@@ -226,6 +230,7 @@ export default function (pi: ExtensionAPI) {
226
230
  promise: Promise<void>;
227
231
  socket: WebSocket | null; // dialing, not yet `ws`
228
232
  server: WebSocketServer | null; // binding, not yet `wss`
233
+ httpServer: HttpServer | null; // binding, not yet `hubHttpServer`
229
234
  };
230
235
  let connectionAttempt: ConnectionAttempt | null = null;
231
236
 
@@ -600,6 +605,52 @@ export default function (pi: ExtensionAPI) {
600
605
  return Array.from(allTerminalNames()).sort();
601
606
  }
602
607
 
608
+ /**
609
+ * Hub: the `GET /status` snapshot. Pure reads — it mutates nothing and sends
610
+ * nothing, so observing the link cannot disturb it.
611
+ *
612
+ * Hub entry first, then clients sorted by name, so pollers see a stable order.
613
+ * `status`/`sinceSeconds` and `cwd` are omitted rather than invented when the
614
+ * hub has not heard them yet: a client is in `hubClients` from `register`, but
615
+ * its first `status_update` arrives a round trip later, and reporting a fresh
616
+ * peer as "idle" would be exactly the false inventory this endpoint exists to
617
+ * remove.
618
+ */
619
+ function buildStatusPayload() {
620
+ const now = Date.now();
621
+
622
+ const describe = (name: string, entryRole: "hub" | "client") => {
623
+ const status = getStatusFor(name);
624
+ const cwd = getCwdFor(name);
625
+ const context = getContextFor(name);
626
+ return {
627
+ name,
628
+ role: entryRole,
629
+ ...(status
630
+ ? {
631
+ status: statusIdentity(status),
632
+ sinceSeconds: Math.round((now - status.since) / 1000),
633
+ }
634
+ : {}),
635
+ ...(cwd ? { cwd } : {}),
636
+ context: context
637
+ ? { tokens: context.tokens, window: context.contextWindow }
638
+ : null,
639
+ };
640
+ };
641
+
642
+ return {
643
+ hub: terminalName,
644
+ port: DEFAULT_PORT,
645
+ terminals: [
646
+ describe(terminalName, "hub"),
647
+ ...Array.from(hubClients.values())
648
+ .sort()
649
+ .map((name) => describe(name, "client")),
650
+ ],
651
+ };
652
+ }
653
+
603
654
  function safeParse(data: string): LinkMessage | null {
604
655
  try {
605
656
  return JSON.parse(data);
@@ -975,10 +1026,22 @@ export default function (pi: ExtensionAPI) {
975
1026
 
976
1027
  function startHub(attempt: ConnectionAttempt): Promise<boolean> {
977
1028
  return new Promise((resolve) => {
978
- const server = new WebSocketServer({
979
- port: DEFAULT_PORT,
980
- host: "127.0.0.1",
1029
+ // Owning the HTTP server is what makes `GET /status` possible: a port-bound
1030
+ // `WebSocketServer` builds its own and answers every plain request with 426.
1031
+ // `ws` forwards this server's `listening` and `error`, so the election below
1032
+ // is unchanged.
1033
+ const httpServer = createServer((req, res) => {
1034
+ if (req.method === "GET" && req.url === "/status") {
1035
+ res.writeHead(200, { "content-type": "application/json" });
1036
+ res.end(JSON.stringify(buildStatusPayload()));
1037
+ return;
1038
+ }
1039
+ res.writeHead(404);
1040
+ res.end();
981
1041
  });
1042
+ attempt.httpServer = httpServer;
1043
+
1044
+ const server = new WebSocketServer({ server: httpServer });
982
1045
  attempt.server = server;
983
1046
 
984
1047
  // The phase settles once. `error` and a pre-listen `close` both report the
@@ -988,16 +1051,19 @@ export default function (pi: ExtensionAPI) {
988
1051
  if (settled) return;
989
1052
  settled = true;
990
1053
  if (attempt.server === server) attempt.server = null;
1054
+ if (attempt.httpServer === httpServer) attempt.httpServer = null;
991
1055
  resolve(established);
992
1056
  };
993
1057
 
994
1058
  server.on("listening", () => {
995
1059
  if (!attemptIsCurrent(attempt)) {
996
1060
  server.close();
1061
+ httpServer.close();
997
1062
  settle(false);
998
1063
  return;
999
1064
  }
1000
1065
  wss = server;
1066
+ hubHttpServer = httpServer;
1001
1067
  // If a client `/link-name` was in flight when the previous hub vanished,
1002
1068
  // this terminal is now establishing hub identity, so honor that pending
1003
1069
  // request. Otherwise keep the last hub-assigned identity — don't replay
@@ -1033,6 +1099,9 @@ export default function (pi: ExtensionAPI) {
1033
1099
  // Reached when a pending server is cancelled; a no-op once established.
1034
1100
  settle(false);
1035
1101
  });
1102
+
1103
+ // Last, so no forwarded event can arrive before its handler exists.
1104
+ httpServer.listen(DEFAULT_PORT, "127.0.0.1");
1036
1105
  });
1037
1106
  }
1038
1107
 
@@ -1128,6 +1197,7 @@ export default function (pi: ExtensionAPI) {
1128
1197
  promise: Promise.resolve(),
1129
1198
  socket: null,
1130
1199
  server: null,
1200
+ httpServer: null,
1131
1201
  };
1132
1202
  connectionAttempt = attempt;
1133
1203
  attempt.promise = runAttempt(attempt);
@@ -1172,8 +1242,13 @@ export default function (pi: ExtensionAPI) {
1172
1242
  const attempt = connectionAttempt;
1173
1243
  if (!attempt) return;
1174
1244
  connectionAttempt = null;
1175
- attempt.socket?.close();
1176
- attempt.server?.close();
1245
+ // Read every handle first: closing the WS server can settle the attempt, and
1246
+ // settling clears these fields. Closing the HTTP server is not optional — it
1247
+ // holds the port, so a skipped close squats :9900 for the whole machine.
1248
+ const { socket, server, httpServer } = attempt;
1249
+ socket?.close();
1250
+ server?.close();
1251
+ httpServer?.close();
1177
1252
  }
1178
1253
 
1179
1254
  function scheduleReconnect() {
@@ -1219,6 +1294,8 @@ export default function (pi: ExtensionAPI) {
1219
1294
  hubClients.clear();
1220
1295
  wss.close();
1221
1296
  wss = null;
1297
+ hubHttpServer?.close();
1298
+ hubHttpServer = null;
1222
1299
  }
1223
1300
 
1224
1301
  role = "disconnected";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-link",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "WebSocket-based inter-terminal communication for Pi. Connect multiple Pi terminals over a local link network.",
5
5
  "author": "alvivar",
6
6
  "license": "MIT",