pi-link 0.5.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -6,6 +6,32 @@ This changelog is based on the git history from `2026-03-21` (initial commit) th
6
6
 
7
7
  ---
8
8
 
9
+ ## 0.5.2 — 2026-09-29
10
+
11
+ ### Fixed
12
+
13
+ - **Session-directory errors no longer look like an empty session history.** If the CLI cannot enumerate the session root for a reason other than a missing directory, it reports the error and exits with code `1`. The launcher no longer starts a new session on that false absence, `--list` no longer reports no sessions, and `--resolve` no longer reports the name as not found. A missing root still means no sessions yet; handling of individual session files and subdirectories is unchanged.
14
+
15
+ - **A terminal named `__proto__` keeps its status, cwd and context snapshots.** The name-keyed records in the initial welcome and `link_list` details now use dictionaries without a prototype, so that accepted name is copied into those records and serialized as an ordinary own property rather than lost through JavaScript's inherited setter. No names are newly reserved.
16
+
17
+ - **`pi-link --version` fails explicitly when package metadata cannot supply a version.** An unreadable or malformed `package.json`, or a missing version, now produces an error and a nonzero exit instead of printing `unknown` and exiting successfully. A healthy installation still prints only its version.
18
+
19
+ ### Changed
20
+
21
+ - **Internal cleanup reuses existing status and delivery helpers.** Status formatting uses the existing identity formatter, disconnect relies on the inbox scheduler's existing timer guard, and client status updates use the shared hub broadcast path instead of duplicating it. Those updates keep their authoritative sender name, recipients and context rules; the hub now stores them through self-delivery after sending to peers, as the shared path already does. Two unreachable CLI `break` statements after `process.exit(0)` were also removed.
22
+
23
+ ---
24
+
25
+ ## 0.5.1 — 2026-09-22
26
+
27
+ ### Fixed
28
+
29
+ - **pi-link's tools are callable directly under Oh My Pi.** The three tools are registered with `loadMode: "essential"`, so harnesses that hide extension tools by default, such as Oh My Pi, list them with the other callable tools instead of behind a discovery path. Pi has no such field and ignores it.
30
+
31
+ - **Peers see a terminal's context usage change after a model switch or tree navigation.** A terminal now republishes its context when its model changes or it moves to another branch of the session tree. Before, an idle terminal kept showing its previous figure until its next run.
32
+
33
+ ---
34
+
9
35
  ## 0.5.0 — 2026-09-16
10
36
 
11
37
  ### Added
package/README.md CHANGED
@@ -663,11 +663,13 @@ The extension hooks into Pi's agent lifecycle events:
663
663
  - **`tool_execution_end`** → Drops that one call. The display stays `tool:<name>` while other calls remain, moves to the next call when the displayed one ends, and returns to `thinking` after the last one while the agent run continues.
664
664
  - **`session_before_compact`** → For a manual compaction, raises the gate that holds inbox delivery. Automatic (threshold/overflow) compaction is left alone: it runs inside the agent run, where Pi already queues and drains steered messages itself.
665
665
  - **`session_compact`** → Clears the local compaction gate and force-pushes a `status_update` so peers see the new (post-compaction) context usage immediately.
666
+ - **`model_select`** → Force-pushes a `status_update`: the context window, and so the usage percentage, belongs to the model.
667
+ - **`session_tree`** → Force-pushes a `status_update`: context usage belongs to the active branch.
666
668
  - **`session_shutdown`** → Full cleanup via `cleanup()`: closes all sockets, resolves pending promises, and disposes the extension.
667
669
 
668
- The five agent and tool handlers — `agent_start`, `agent_end`, `agent_settled` (when Pi still reports the session idle), `tool_execution_start` and `tool_execution_end` — each recompute the status and hand it to `pushStatus()`, which publishes only when the display identity changed since the last publish: a second concurrent tool starting behind the one already shown is silent, and so is a mutation that leaves a raised compaction gate on display. The session handlers keep the separate paths described in their own bullets, and `session_shutdown` cleans up rather than pushing. `disconnect()` is the only thing that clears the stored baseline, and `pushStatus()` returns immediately while the terminal is disconnected, so handler events in that interval restore nothing; once connected again, either a client's forced `welcome` push or the first of those ordinary handler events restores it. `session_compact` always pushes, whether the identity changed or not — including while a remote request's gate still holds the displayed identity.
670
+ The five agent and tool handlers — `agent_start`, `agent_end`, `agent_settled` (when Pi still reports the session idle), `tool_execution_start` and `tool_execution_end` — each recompute the status and hand it to `pushStatus()`, which publishes only when the display identity changed since the last publish: a second concurrent tool starting behind the one already shown is silent, and so is a mutation that leaves a raised compaction gate on display. The session handlers keep the separate paths described in their own bullets, and `session_shutdown` cleans up rather than pushing. `disconnect()` is the only thing that clears the stored baseline, and `pushStatus()` returns immediately while the terminal is disconnected, so handler events in that interval restore nothing; once connected again, either a client's forced `welcome` push or the first of those ordinary handler events restores it. `session_compact`, `model_select` and `session_tree` always push, whether the identity changed or not — including while a remote request's gate still holds the displayed identity.
669
671
 
670
- Status updates are push-based: each terminal broadcasts changes to the hub, which fans them out. New joiners receive a status snapshot for all terminals in the `welcome` message. Context updates reuse the same status path, including a forced post-compaction update.
672
+ Status updates are push-based: each terminal broadcasts changes to the hub, which fans them out. New joiners receive a status snapshot for all terminals in the `welcome` message. Context updates reuse the same status path, including forced updates after a compaction, a model change and tree navigation.
671
673
 
672
674
  ### Inbox
673
675
 
package/bin/pi-link.mjs CHANGED
@@ -183,14 +183,17 @@ async function loadSessionRecord(filePath, scopeCwd) {
183
183
  // Returns meta + mtime + path for every readable session in `dir`, or only
184
184
  // those from `scopeCwd` when it is given. Custom layout is flat
185
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.
186
+ // (<dir>/<sub>/*.jsonl). A missing `dir` means no sessions yet; any other
187
+ // failure to read it is fatal, since reporting no sessions would be a lie.
188
+ // Errors on individual files/dirs are silently skipped — active or
189
+ // partially-written sessions are tolerated.
188
190
  async function scanSessions(dir, isCustom, scopeCwd) {
189
191
  let entries;
190
192
  try {
191
193
  entries = await readdir(dir, { withFileTypes: true });
192
- } catch {
193
- return [];
194
+ } catch (err) {
195
+ if (err.code === "ENOENT") return [];
196
+ fail(`cannot read session directory: ${err.message}`);
194
197
  }
195
198
 
196
199
  const tasks = [];
@@ -315,14 +318,14 @@ function printHelp() {
315
318
  }
316
319
 
317
320
  function printVersion() {
321
+ let pkg;
318
322
  try {
319
- const pkg = JSON.parse(
320
- readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
321
- );
322
- console.log(pkg.version ?? "unknown");
323
- } catch {
324
- console.log("unknown");
323
+ pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
324
+ } catch (err) {
325
+ fail(`cannot read pi-link's package.json: ${err.message}`);
325
326
  }
327
+ if (!pkg.version) fail("pi-link's package.json has no version");
328
+ console.log(pkg.version);
326
329
  }
327
330
 
328
331
  function describeMode(mode) {
@@ -515,11 +518,9 @@ switch (state.mode) {
515
518
  case "help":
516
519
  printHelp();
517
520
  process.exit(0);
518
- break; // unreachable; present to satisfy no-fallthrough lints
519
521
  case "version":
520
522
  printVersion();
521
523
  process.exit(0);
522
- break; // unreachable; present to satisfy no-fallthrough lints
523
524
  case "list":
524
525
  await runList(state);
525
526
  break;
package/index.ts CHANGED
@@ -44,6 +44,9 @@ const BATCH_MAX_ITEMS = 20;
44
44
  const BATCH_MAX_CHARS = 16_000;
45
45
  // Visual rows of an incoming message kept while Pi's global expansion is off.
46
46
  const PREVIEW_ROWS = 6;
47
+ // Consumed by harnesses that hide extension tools by default (Oh My Pi);
48
+ // ignored by Pi, whose ToolDefinition has no such field.
49
+ const TOP_LEVEL_TOOL = { loadMode: "essential" } as const;
47
50
 
48
51
  // ─── Protocol ────────────────────────────────────────────────────────────────
49
52
 
@@ -418,9 +421,7 @@ export default function (pi: ExtensionAPI) {
418
421
  }
419
422
 
420
423
  function formatStatus(s: LinkStatus): string {
421
- const dur = formatDuration(s.since);
422
- if (s.kind === "tool") return `tool:${s.toolName} (${dur})`;
423
- return `${s.kind} (${dur})`;
424
+ return `${statusIdentity(s)} (${formatDuration(s.since)})`;
424
425
  }
425
426
 
426
427
  function formatTokens(n: number): string {
@@ -960,14 +961,15 @@ export default function (pi: ExtensionAPI) {
960
961
  hubClients.set(clientWs, clientName);
961
962
  const list = terminalList();
962
963
 
963
- // Confirm to the new client (with status, cwd and context snapshots)
964
- const statuses: Record<string, LinkStatus> = {};
964
+ // Confirm to the new client (with status, cwd and context snapshots).
965
+ // Keyed by terminal name, so no prototype: a plain {} would swallow "__proto__".
966
+ const statuses: Record<string, LinkStatus> = Object.create(null);
965
967
  statuses[terminalName] = deriveStatus(); // hub's own status
966
968
  for (const [name, status] of terminalStatuses) statuses[name] = status;
967
- const cwds: Record<string, string> = {};
969
+ const cwds: Record<string, string> = Object.create(null);
968
970
  if (currentCwd) cwds[terminalName] = currentCwd; // hub's own cwd
969
971
  for (const [name, cwd] of terminalCwds) cwds[name] = cwd;
970
- const contexts: Record<string, ContextSnapshot> = {};
972
+ const contexts: Record<string, ContextSnapshot> = Object.create(null);
971
973
  const hubContext = captureContext();
972
974
  if (hubContext) contexts[terminalName] = hubContext; // hub's own context
973
975
  for (const [name, c] of terminalContexts) contexts[name] = c;
@@ -998,21 +1000,16 @@ export default function (pi: ExtensionAPI) {
998
1000
  // Ignore messages from unregistered clients
999
1001
  if (!clientName) return;
1000
1002
 
1001
- // Status update — store and fan out to other clients only (not back to hub)
1003
+ // Status update: rebuilt under the authoritative name, fanned out to the other
1004
+ // clients, and stored by the hub through its own self-delivery.
1002
1005
  if (msg.type === "status_update") {
1003
- terminalStatuses.set(clientName, msg.status);
1004
- if (msg.context) terminalContexts.set(clientName, msg.context);
1005
- else if (msg.context === null) terminalContexts.delete(clientName);
1006
1006
  const normalized: StatusUpdateMsg = {
1007
1007
  type: "status_update",
1008
1008
  name: clientName,
1009
1009
  status: msg.status,
1010
1010
  context: msg.context, // undefined omitted by JSON; null forwarded to clear
1011
1011
  };
1012
- const json = JSON.stringify(normalized);
1013
- for (const [otherWs, name] of hubClients) {
1014
- if (name !== clientName) otherWs.send(json);
1015
- }
1012
+ hubBroadcast(normalized, clientName);
1016
1013
  return;
1017
1014
  }
1018
1015
 
@@ -1337,7 +1334,7 @@ export default function (pi: ExtensionAPI) {
1337
1334
  updateStatus();
1338
1335
 
1339
1336
  // Inbox survives disconnect; flush unless a local /compact still gates it.
1340
- if (!flushTimer) releaseInbox();
1337
+ releaseInbox();
1341
1338
  }
1342
1339
 
1343
1340
  function cleanup() {
@@ -1469,6 +1466,14 @@ export default function (pi: ExtensionAPI) {
1469
1466
  pushStatus(true);
1470
1467
  });
1471
1468
 
1469
+ pi.on("model_select", async () => {
1470
+ pushStatus(true); // the window, and so the usage percentage, belongs to the model
1471
+ });
1472
+
1473
+ pi.on("session_tree", async () => {
1474
+ pushStatus(true); // context usage belongs to the active branch
1475
+ });
1476
+
1472
1477
  pi.on("tool_execution_start", async (event) => {
1473
1478
  const before = statusIdentity(deriveStatus());
1474
1479
  activeTools.set(event.toolCallId, event.toolName);
@@ -1566,6 +1571,7 @@ export default function (pi: ExtensionAPI) {
1566
1571
  // ── Tools ────────────────────────────────────────────────────────────────
1567
1572
 
1568
1573
  pi.registerTool({
1574
+ ...TOP_LEVEL_TOOL,
1569
1575
  name: "link_send",
1570
1576
  label: "Link Send",
1571
1577
  description: [
@@ -1630,6 +1636,7 @@ export default function (pi: ExtensionAPI) {
1630
1636
  });
1631
1637
 
1632
1638
  pi.registerTool({
1639
+ ...TOP_LEVEL_TOOL,
1633
1640
  name: "link_compact",
1634
1641
  label: "Link Compact",
1635
1642
  description: [
@@ -1742,6 +1749,7 @@ export default function (pi: ExtensionAPI) {
1742
1749
  });
1743
1750
 
1744
1751
  pi.registerTool({
1752
+ ...TOP_LEVEL_TOOL,
1745
1753
  name: "link_list",
1746
1754
  label: "Link List",
1747
1755
  description: "List the Pi terminals in your group currently connected to the link.",
@@ -1751,9 +1759,10 @@ export default function (pi: ExtensionAPI) {
1751
1759
  async execute() {
1752
1760
  if (role === "disconnected") return notConnectedResult();
1753
1761
 
1754
- const statuses: Record<string, string> = {};
1755
- const cwds: Record<string, string> = {};
1756
- const contexts: Record<string, ContextSnapshot> = {};
1762
+ // Keyed by terminal name, so no prototype (see the welcome snapshots).
1763
+ const statuses: Record<string, string> = Object.create(null);
1764
+ const cwds: Record<string, string> = Object.create(null);
1765
+ const contexts: Record<string, ContextSnapshot> = Object.create(null);
1757
1766
  const visible = visibleTerminals();
1758
1767
  const list = visible
1759
1768
  .map((name) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-link",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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",