pi-link 0.1.16 → 0.2.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/CHANGELOG.md CHANGED
@@ -6,6 +6,45 @@ This changelog is based on the git history from `2026-03-21` (initial commit) th
6
6
 
7
7
  ---
8
8
 
9
+ ## 0.2.0 — 2026-07-17
10
+
11
+ ### Added
12
+
13
+ - **`pi-link --version` prints the installed package version.** It writes the bare semver to stdout and exits successfully, so users can identify the installed build during testing.
14
+
15
+ ### Breaking
16
+
17
+ - **Removed the deprecated `pi-link list` and `pi-link resolve <name>` command forms.** They now error with migration guidance; use `pi-link --list` and `pi-link --resolve <name>` instead. The canonical flag forms are unchanged.
18
+
19
+ ### Changed
20
+
21
+ - **The wrapper no longer reserves legacy `--all` / `-a` flags.** Before a session name, they receive the normal unknown-argument error; after a session name, they pass through to Pi unchanged. Use `--global` / `-g` for pi-link's cross-directory session lookup.
22
+
23
+ ### Fixed
24
+
25
+ - **Malformed saved link names no longer crash startup.** A non-string `link-name` session entry is ignored and naming falls through to the session name or random default; well-formed name precedence is unchanged.
26
+
27
+ ---
28
+
29
+ ## 0.1.17 — 2026-07-01
30
+
31
+ ### Changed
32
+
33
+ - **`link_send` now rejects self-targets.** Sending to your own link name returns an immediate `self_target` error instead of delivering the message back to yourself, matching the existing `link_prompt` and `link_compact` behavior.
34
+
35
+ ### Fixed
36
+
37
+ - **Disconnected link tool calls are now marked as errors.** `notConnectedResult()` tags `error: "not_connected"`, so callers and renderers treat a disconnected call as a failure instead of a success-looking result.
38
+ - **Hub ignores duplicate `register` messages.** A second `register` on an already-registered client socket is now dropped instead of re-running the join flow (which could reassign the name and emit a spurious re-join).
39
+ - **`link_prompt` and `link_compact` honor an already-aborted signal.** Both tools now check `signal.aborted` at the top of `execute` and return an `error: "aborted"` result immediately, rather than starting work that was cancelled before it began.
40
+
41
+ ### Internal
42
+
43
+ - Removed the unused `ExtensionContext` parameter from `shouldConnect()`.
44
+ - Clarified the keepalive comment: it presumes `sendUserMessage()` starts a run, with the sender's 30-minute hard ceiling as the backstop if it ever doesn't.
45
+
46
+ ---
47
+
9
48
  ## 0.1.16 — 2026-06-09
10
49
 
11
50
  ### Added
package/README.md CHANGED
@@ -220,8 +220,6 @@ Resume: pi-link <name>
220
220
 
221
221
  For scripting, `pi-link --resolve <name>` prints just the session path (machine-readable, no other output). Exit codes: `0` on single match, `1` if ambiguous (multiple matches printed to stderr), `2` if not found.
222
222
 
223
- > The legacy subcommand forms `pi-link list` and `pi-link resolve <name>` still work but print a stderr deprecation warning and will be removed in a future release. Switch to the flag forms above.
224
-
225
223
  ---
226
224
 
227
225
  ## LLM Tools
@@ -255,7 +253,7 @@ Note: `triggerTurn` does **not** cause the response to come back to the caller -
255
253
 
256
254
  > **Broadcast note:** Sending to `"*"` delivers to **all other terminals** - the sender is excluded.
257
255
 
258
- Pre-validates the target name against the local terminal list before sending, catching typos early. See [Message Routing](#message-routing--error-handling) for delivery semantics.
256
+ Pre-validates the target name against the local terminal list before sending, catching typos early. See [Message Routing](#message-routing--error-handling) for delivery semantics. **Self-target rejection** - sending to yourself (`to` equals your own name) returns an immediate error.
259
257
 
260
258
  ### `link_prompt`
261
259
 
package/bin/pi-link.mjs CHANGED
@@ -8,9 +8,7 @@
8
8
  // pi-link --list [--global|-g] List pi-link sessions in current cwd (or everywhere).
9
9
  // pi-link --resolve <name> [--global|-g]
10
10
  // Print just the session path (machine-readable).
11
- //
12
- // Deprecated subcommand forms `pi-link list` / `pi-link resolve <name>` still
13
- // work for one release with a stderr warning.
11
+ // pi-link --version Print the installed pi-link version.
14
12
 
15
13
  import { readdir, stat } from "fs/promises";
16
14
  import { createReadStream, existsSync, readFileSync } from "fs";
@@ -19,6 +17,12 @@ import { join } from "path";
19
17
  import { homedir } from "os";
20
18
  import { spawn } from "child_process";
21
19
 
20
+ // Canonicalize a link/session name: trim + collapse internal whitespace.
21
+ // Must match the extension's normalizeName (index.ts).
22
+ function normalizeName(s) {
23
+ return s.trim().replace(/\s+/g, " ");
24
+ }
25
+
22
26
  // ── Pi config resolution ───────────────────────────────────────────────────
23
27
  // Match Pi's session-dir lookup order so list/resolve/<name> see what Pi sees.
24
28
  // Custom sessionDir → flat layout; default → <agentDir>/sessions/<encoded-cwd>.
@@ -88,11 +92,11 @@ async function getSessionMeta(filePath) {
88
92
  if (typeof entry.cwd === "string") cwd = entry.cwd;
89
93
  if (typeof entry.id === "string") id = entry.id;
90
94
  } else if (entry.type === "session_info" && typeof entry.name === "string") {
91
- sessionName = entry.name.trim().replace(/\s+/g, " ") || undefined;
95
+ sessionName = normalizeName(entry.name) || undefined;
92
96
  } else if (entry.type === "custom" && entry.customType === "link-name") {
93
97
  hasLinkName = true;
94
98
  if (entry.data && typeof entry.data.name === "string") {
95
- const n = entry.data.name.trim().replace(/\s+/g, " ");
99
+ const n = normalizeName(entry.data.name);
96
100
  if (n) linkName = n;
97
101
  }
98
102
  } else if (entry.type === "message" || entry.type === "user" || entry.type === "assistant") {
@@ -237,20 +241,9 @@ function renderTable(rows, columns) {
237
241
 
238
242
  const rawArgs = process.argv.slice(2);
239
243
 
240
- // Reject pi-link flags renamed in 0.1.12 with a clear pointer to the new name.
241
- // Same intent as `rejectManagedFlag` (specific message > generic "Unknown argument")
242
- // but for our own renames, not Pi-managed flags.
243
- function rejectRenamedFlag(token) {
244
- if (token === "--all" || token === "-a") {
245
- const replacement = token === "-a" ? "-g" : "--global";
246
- console.error(`Error: ${token} was renamed to ${replacement}.`);
247
- process.exit(1);
248
- }
249
- }
250
-
251
244
  // Reject Pi flags that pi-link manages, plus --link-name (which exists at the
252
245
  // `pi` level for link-only naming, but the wrapper's combined-mode contract
253
- // conflicts with it). Called from Phase 6 (mode entry) and Phase 7 (after
246
+ // conflicts with it). Called from Phase 4 (mode entry) and Phase 5 (after
254
247
  // launcher name), so it fires on both `pi-link --session foo` and
255
248
  // `pi-link foo --session bar` with the friendly message.
256
249
  function rejectManagedFlag(token) {
@@ -288,22 +281,27 @@ function printHelp() {
288
281
  console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
289
282
  console.error(" pi-link --list [--global|-g]");
290
283
  console.error(" pi-link --resolve <name> [--global|-g]");
284
+ console.error(" pi-link --version");
291
285
  console.error("");
292
286
  console.error("By default, name lookup is scoped to the current cwd.");
293
287
  console.error("--global / -g widens the search to sessions in any cwd.");
294
288
  }
295
289
 
296
- function printDeprecationWarning(form) {
297
- const canonical = form === "list" ? "--list" : "--resolve";
298
- console.error(
299
- `Warning: 'pi-link ${form}' is deprecated. Use 'pi-link ${canonical}' instead. ` +
300
- `(Subcommand form will be removed in a future release.)`,
301
- );
290
+ function printVersion() {
291
+ try {
292
+ const pkg = JSON.parse(
293
+ readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
294
+ );
295
+ console.log(pkg.version ?? "unknown");
296
+ } catch {
297
+ console.log("unknown");
298
+ }
302
299
  }
303
300
 
304
301
  function describeMode(mode) {
305
302
  switch (mode) {
306
303
  case "help": return "--help";
304
+ case "version": return "--version";
307
305
  case "list": return "--list";
308
306
  case "resolve": return "--resolve";
309
307
  case "launcher": return "session name";
@@ -313,23 +311,19 @@ function describeMode(mode) {
313
311
 
314
312
  // ── Parser ─────────────────────────────────────────────────────────────────
315
313
  //
316
- // Single sequential pass populates `state`; dispatcher reads it. Phases (per
317
- // PLAN-cli-flags.md):
318
- // 1. Renamed-flag rejection (always-on for --all/-a)
319
- // 2. Global flags (--global, --help, --)
320
- // 3. Mode-selecting flags (--list, --resolve, --resolve=<name>)
321
- // 4. Deprecated subcommands (list, resolve <name>) — only at mode-null position
322
- // 5. Mode-specific extra-token rejection (with deprecated-resolve leniency)
323
- // 6. Launcher mode entry (mode null + bare positional)
324
- // 7. Launcher passthrough (mode launcher) with orphan-positional rejection
314
+ // Single sequential pass populates `state`; dispatcher reads it. Phases:
315
+ // 1. Global flags (--global, --help, --version, --)
316
+ // 2. Mode-selecting flags (--list, --resolve, --resolve=<name>)
317
+ // 3. Mode-specific extra-token rejection
318
+ // 4. Launcher mode entry (mode null + bare positional)
319
+ // 5. Launcher passthrough (mode launcher) with orphan-positional rejection
325
320
 
326
321
  const state = {
327
- mode: null, // null | "help" | "list" | "resolve" | "launcher"
322
+ mode: null, // null | "help" | "version" | "list" | "resolve" | "launcher"
328
323
  resolveName: null,
329
324
  launcherName: null,
330
325
  global: false,
331
326
  piPassthrough: [],
332
- deprecated: null, // null | "list" | "resolve"
333
327
  };
334
328
 
335
329
  function setMode(mode) {
@@ -344,10 +338,7 @@ let lastWasFlag = false;
344
338
  for (let i = 0; i < rawArgs.length; i++) {
345
339
  const a = rawArgs[i];
346
340
 
347
- // Phase 1: renamed-flag rejection.
348
- rejectRenamedFlag(a);
349
-
350
- // Phase 2: global flags / scope-affecting tokens.
341
+ // Phase 1: global flags / scope-affecting tokens.
351
342
  if (a === "--global" || a === "-g") {
352
343
  state.global = true;
353
344
  lastWasFlag = false;
@@ -357,6 +348,10 @@ for (let i = 0; i < rawArgs.length; i++) {
357
348
  setMode("help"); // errors if combined with another mode
358
349
  continue;
359
350
  }
351
+ if (a === "--version") {
352
+ setMode("version"); // errors if combined with another mode
353
+ continue;
354
+ }
360
355
  if (a === "--") {
361
356
  // `--` only meaningful in launcher mode (separates pi flags from positionals).
362
357
  if (state.mode !== "launcher") {
@@ -369,7 +364,7 @@ for (let i = 0; i < rawArgs.length; i++) {
369
364
  break;
370
365
  }
371
366
 
372
- // Phase 3: mode-selecting flags.
367
+ // Phase 2: mode-selecting flags.
373
368
  if (a === "--list") {
374
369
  setMode("list");
375
370
  continue;
@@ -392,57 +387,36 @@ for (let i = 0; i < rawArgs.length; i++) {
392
387
  continue;
393
388
  }
394
389
 
395
- // Phase 4: deprecated subcommands (only at mode-null position).
396
- if (state.mode === null && (a === "list" || a === "resolve")) {
397
- state.deprecated = a;
398
- if (a === "list") {
399
- setMode("list");
400
- } else {
401
- setMode("resolve");
402
- const next = rawArgs[i + 1];
403
- if (next !== undefined && !next.startsWith("-")) {
404
- state.resolveName = next;
405
- i++;
406
- }
407
- // else: leave null; Phase 5 leniency or post-parse validation handles it.
408
- }
409
- continue;
410
- }
411
-
412
- // Phase 5: mode-specific extra-token rejection.
390
+ // Phase 3: mode-specific extra-token rejection.
413
391
  if (state.mode === "help") {
414
392
  fail(`--help does not accept arguments: ${a}`);
415
393
  }
394
+ if (state.mode === "version") {
395
+ fail(`--version does not accept arguments: ${a}`);
396
+ }
416
397
  if (state.mode === "list") {
417
398
  fail(`--list does not accept argument: ${a}\n Usage: pi-link --list [--global|-g]`);
418
399
  }
419
400
  if (state.mode === "resolve") {
420
- // Deprecated-form leniency: `pi-link resolve --global foo` was order-independent;
421
- // if we entered via deprecated path and haven't bound a name yet, take this.
422
- if (
423
- state.deprecated === "resolve" &&
424
- state.resolveName === null &&
425
- !a.startsWith("-")
426
- ) {
427
- state.resolveName = a;
428
- continue;
429
- }
430
401
  fail(`--resolve accepts exactly one name; got extra: ${a}`);
431
402
  }
432
403
 
433
- // Phase 6: launcher mode entry. state.mode === null here, no name set yet.
434
- // (lastWasFlag is still false here — only Phase 7 sets it, and Phase 7 requires launcher mode.)
404
+ // Phase 4: launcher mode entry. state.mode === null here, no name set yet.
405
+ // (lastWasFlag is still false here — only Phase 5 sets it, and Phase 5 requires launcher mode.)
435
406
  if (state.mode === null) {
436
407
  rejectManagedFlag(a);
437
408
  if (a.startsWith("-")) {
438
409
  fail(`Unknown argument: ${a}\n Usage: pi-link <name> [--global|-g] [pi flags...]`);
439
410
  }
411
+ if (a === "list" || a === "resolve") {
412
+ fail(`'pi-link ${a}' was removed. Use 'pi-link --${a}'.`);
413
+ }
440
414
  state.mode = "launcher";
441
415
  state.launcherName = a;
442
416
  continue;
443
417
  }
444
418
 
445
- // Phase 7: launcher mode, name set. Tokens go to passthrough or get rejected.
419
+ // Phase 5: launcher mode, name set. Tokens go to passthrough or get rejected.
446
420
  rejectManagedFlag(a);
447
421
  if (a.startsWith("-")) {
448
422
  state.piPassthrough.push(a);
@@ -466,24 +440,20 @@ if (state.mode === "resolve") {
466
440
  if (state.resolveName === null) {
467
441
  fail(`--resolve requires a name argument.\n Usage: pi-link --resolve <name> [--global|-g]`);
468
442
  }
469
- const normalized = state.resolveName.trim().replace(/\s+/g, " ");
443
+ const normalized = normalizeName(state.resolveName);
470
444
  if (!normalized) {
471
445
  fail(`--resolve requires a non-empty name argument.\n Usage: pi-link --resolve <name> [--global|-g]`);
472
446
  }
473
447
  state.resolveName = normalized;
474
448
  }
475
449
  if (state.mode === "launcher") {
476
- const normalized = state.launcherName.trim().replace(/\s+/g, " ");
450
+ const normalized = normalizeName(state.launcherName);
477
451
  if (!normalized) {
478
452
  fail(`session name cannot be empty.\n Usage: pi-link <name> [--global|-g] [pi flags...]`);
479
453
  }
480
454
  state.launcherName = normalized;
481
455
  }
482
456
 
483
- if (state.deprecated) {
484
- printDeprecationWarning(state.deprecated);
485
- }
486
-
487
457
  // ── Dispatch ───────────────────────────────────────────────────────────────
488
458
 
489
459
  switch (state.mode) {
@@ -492,6 +462,10 @@ switch (state.mode) {
492
462
  printHelp();
493
463
  process.exit(0);
494
464
  break; // unreachable; present to satisfy no-fallthrough lints
465
+ case "version":
466
+ printVersion();
467
+ process.exit(0);
468
+ break; // unreachable; present to satisfy no-fallthrough lints
495
469
  case "list":
496
470
  await runList(state);
497
471
  break;
@@ -517,18 +491,18 @@ async function runList(state) {
517
491
  }
518
492
  const columns = state.global
519
493
  ? [
520
- { header: "NAME", get: (s) => s.name },
521
- { header: "CWD", get: (s) => displayPath(s.cwd) },
522
- { header: "MODIFIED", get: (s) => relTime(s.modified), dim: true },
523
- { header: "MESSAGES", get: (s) => s.messages, dim: true },
524
- { header: "ID", get: (s) => s.id, dim: true },
525
- ]
494
+ { header: "NAME", get: (s) => s.name },
495
+ { header: "CWD", get: (s) => displayPath(s.cwd) },
496
+ { header: "MODIFIED", get: (s) => relTime(s.modified), dim: true },
497
+ { header: "MESSAGES", get: (s) => s.messages, dim: true },
498
+ { header: "ID", get: (s) => s.id, dim: true },
499
+ ]
526
500
  : [
527
- { header: "NAME", get: (s) => s.name },
528
- { header: "MODIFIED", get: (s) => relTime(s.modified), dim: true },
529
- { header: "MESSAGES", get: (s) => s.messages, dim: true },
530
- { header: "ID", get: (s) => s.id, dim: true },
531
- ];
501
+ { header: "NAME", get: (s) => s.name },
502
+ { header: "MODIFIED", get: (s) => relTime(s.modified), dim: true },
503
+ { header: "MESSAGES", get: (s) => s.messages, dim: true },
504
+ { header: "ID", get: (s) => s.id, dim: true },
505
+ ];
532
506
  console.log(renderTable(sessions, columns));
533
507
  if (process.stdout.isTTY) {
534
508
  console.log("");
package/index.ts CHANGED
@@ -433,7 +433,7 @@ export default function (pi: ExtensionAPI) {
433
433
 
434
434
  // ── Connection intent ──────────────────────────────────────────────────
435
435
 
436
- function shouldConnect(_ctx: ExtensionContext): boolean {
436
+ function shouldConnect(): boolean {
437
437
  const data = latestCustomData("link-active") as
438
438
  | { active?: boolean }
439
439
  | undefined;
@@ -775,7 +775,9 @@ export default function (pi: ExtensionAPI) {
775
775
  });
776
776
  } else {
777
777
  pendingRemotePrompt = { id: msg.id, from: msg.from };
778
- // Keepalive: periodic status push so sender knows we're alive
778
+ // Keepalive: periodic status push so sender knows we're alive.
779
+ // Keepalive presumes sendUserMessage() starts a run (platform contract);
780
+ // if it ever doesn't, the sender's 30 min hard ceiling is the backstop.
779
781
  if (keepaliveTimer) clearInterval(keepaliveTimer);
780
782
  keepaliveTimer = setInterval(
781
783
  () => pushStatus(true),
@@ -848,6 +850,7 @@ export default function (pi: ExtensionAPI) {
848
850
 
849
851
  // First message must be register
850
852
  if (msg.type === "register") {
853
+ if (clientName) return; // already registered — ignore duplicate
851
854
  clientName = uniqueName(msg.name);
852
855
  hubClients.set(clientWs, clientName);
853
856
  if (msg.cwd) hubTerminalCwds.set(clientName, msg.cwd);
@@ -1225,9 +1228,11 @@ export default function (pi: ExtensionAPI) {
1225
1228
  if (fromEnv && !pi.getSessionName()) pi.setSessionName(flagName);
1226
1229
  } else {
1227
1230
  const saved = latestCustomData("link-name") as
1228
- | { name?: string }
1231
+ | { name?: unknown }
1229
1232
  | undefined;
1230
- const savedName = normalizeName(saved?.name);
1233
+ const savedName = normalizeName(
1234
+ typeof saved?.name === "string" ? saved.name : undefined,
1235
+ );
1231
1236
  if (savedName) {
1232
1237
  preferredName = savedName;
1233
1238
  terminalName = preferredName;
@@ -1237,7 +1242,7 @@ export default function (pi: ExtensionAPI) {
1237
1242
  }
1238
1243
  }
1239
1244
 
1240
- if (flagName || shouldConnect(_ctx)) scheduleStartupConnect();
1245
+ if (flagName || shouldConnect()) scheduleStartupConnect();
1241
1246
  });
1242
1247
 
1243
1248
  pi.on("session_shutdown", async () => {
@@ -1317,7 +1322,7 @@ export default function (pi: ExtensionAPI) {
1317
1322
  }
1318
1323
 
1319
1324
  function notConnectedResult() {
1320
- return textResult("Not connected to link");
1325
+ return textResult("Not connected to link", { error: "not_connected" });
1321
1326
  }
1322
1327
 
1323
1328
  function truncatePreview(text: string, max = 60) {
@@ -1377,6 +1382,12 @@ export default function (pi: ExtensionAPI) {
1377
1382
 
1378
1383
  // Pre-validate target exists locally (best-effort, catches typos and definitely-absent names)
1379
1384
  if (params.to !== "*") {
1385
+ if (params.to === terminalName) {
1386
+ return textResult("Cannot send to yourself", {
1387
+ to: params.to,
1388
+ error: "self_target",
1389
+ });
1390
+ }
1380
1391
  const miss = targetNotFound(params.to);
1381
1392
  if (miss) return miss;
1382
1393
  }
@@ -1439,6 +1450,13 @@ export default function (pi: ExtensionAPI) {
1439
1450
  }),
1440
1451
 
1441
1452
  async execute(_toolCallId, params, signal) {
1453
+ if (signal?.aborted) {
1454
+ return textResult("Compact request aborted", {
1455
+ to: params.to,
1456
+ error: "aborted",
1457
+ });
1458
+ }
1459
+
1442
1460
  if (role === "disconnected") return notConnectedResult();
1443
1461
 
1444
1462
  if (params.to === terminalName) {
@@ -1496,7 +1514,7 @@ export default function (pi: ExtensionAPI) {
1496
1514
  instructions: params.instructions,
1497
1515
  });
1498
1516
 
1499
- if (!delivered && pendingCompactResponses.has(requestId)) {
1517
+ if (!delivered) {
1500
1518
  const pending = cleanupPendingCompact(requestId);
1501
1519
  if (pending) {
1502
1520
  pending.resolve(
@@ -1537,6 +1555,13 @@ export default function (pi: ExtensionAPI) {
1537
1555
  }),
1538
1556
 
1539
1557
  async execute(_toolCallId, params, signal) {
1558
+ if (signal?.aborted) {
1559
+ return textResult("Prompt request aborted", {
1560
+ to: params.to,
1561
+ error: "aborted",
1562
+ });
1563
+ }
1564
+
1540
1565
  if (role === "disconnected") return notConnectedResult();
1541
1566
 
1542
1567
  if (params.to === terminalName) {
@@ -1598,7 +1623,7 @@ export default function (pi: ExtensionAPI) {
1598
1623
  prompt: params.prompt,
1599
1624
  });
1600
1625
 
1601
- if (!delivered && pendingPromptResponses.has(requestId)) {
1626
+ if (!delivered) {
1602
1627
  const pending = cleanupPending(requestId);
1603
1628
  if (pending) {
1604
1629
  pending.resolve(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-link",
3
- "version": "0.1.16",
3
+ "version": "0.2.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",
@@ -48,7 +48,7 @@ Synchronous RPC. Send a prompt, wait for the response.
48
48
 
49
49
  ### `link_send`
50
50
 
51
- Fire-and-forget. Send to one terminal or `to: "*"` to broadcast to every other connected terminal; there is no exclusion filter.
51
+ Fire-and-forget. Send to one terminal or `to: "*"` to broadcast to every other connected terminal; there is no exclusion filter. Sending to yourself is rejected. A send to a name that isn't connected fails with an error listing who _is_ connected — delivery failure is visible to the sender, not silent.
52
52
 
53
53
  Set `triggerTurn: true` to queue async work on the receiver. The sender does **not** get an automatic response back.
54
54
 
@@ -69,7 +69,7 @@ Use `triggerTurn: false` for fire-and-forget status notifications only — when
69
69
 
70
70
  ### `link_compact`
71
71
 
72
- Blocks until the target finishes compacting, then returns. Ask another terminal to compact its context window into a summary, freeing space. `link_list` exposes context usage; `link_compact` is the lever when you decide a terminal should free context — and because the call only returns once compaction is done, you can immediately hand it more work with `link_send`/`link_prompt` (no sleep, no busy-bounce). Busy targets (mid-turn or already compacting) decline; retry when `link_list` shows them idle. You decide the threshold; pi-link just runs the compaction you ask for. Its optional `instructions` add an extra focus to that summary.
72
+ Blocks until the target finishes compacting, then returns (3 min ceiling — the call then errors for you, though the target may still finish compacting on its own; check it before assuming failure). Ask another terminal to compact its context window into a summary, freeing space. `link_list` exposes context usage; `link_compact` is the lever when you decide a terminal should free context — and because the call only returns once compaction is done, you can immediately hand it more work with `link_send`/`link_prompt` (no sleep, no busy-bounce). Busy targets (mid-turn or already compacting) decline; retry when `link_list` shows them idle. You decide the threshold; pi-link just runs the compaction you ask for. Its optional `instructions` add an extra focus to that summary.
73
73
 
74
74
  ---
75
75