pi-link 0.1.15 → 0.1.16

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/index.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * First terminal to connect becomes the hub; others join as clients.
7
7
  * Hub loss triggers automatic promotion of a surviving client.
8
8
  *
9
- * Tools: link_send, link_prompt, link_list
9
+ * Tools: link_send, link_prompt, link_list, link_compact
10
10
  * Commands: /link, /link-name, /link-broadcast, /link-connect, /link-disconnect
11
11
  */
12
12
 
@@ -26,6 +26,7 @@ import { WebSocket, WebSocketServer } from "ws";
26
26
  const DEFAULT_PORT = 9900;
27
27
  const PROMPT_INACTIVITY_MS = 90_000;
28
28
  const PROMPT_HARD_CEILING_MS = 1_800_000;
29
+ const COMPACT_TIMEOUT_MS = 180_000;
29
30
  const RECONNECT_DELAY_MS = 2000;
30
31
  const KEEPALIVE_INTERVAL_MS = 30_000;
31
32
  const FLUSH_DELAY_MS = 200;
@@ -39,6 +40,7 @@ interface RegisterMsg {
39
40
  type: "register";
40
41
  name: string;
41
42
  cwd?: string;
43
+ context?: ContextSnapshot;
42
44
  }
43
45
  interface WelcomeMsg {
44
46
  type: "welcome";
@@ -46,12 +48,14 @@ interface WelcomeMsg {
46
48
  terminals: string[];
47
49
  statuses?: Record<string, LinkStatus>;
48
50
  cwds?: Record<string, string>;
51
+ contexts?: Record<string, ContextSnapshot>;
49
52
  }
50
53
  interface TerminalJoinedMsg {
51
54
  type: "terminal_joined";
52
55
  name: string;
53
56
  terminals: string[];
54
57
  cwd?: string;
58
+ context?: ContextSnapshot;
55
59
  }
56
60
  interface TerminalLeftMsg {
57
61
  type: "terminal_left";
@@ -84,17 +88,37 @@ interface StatusUpdateMsg {
84
88
  type: "status_update";
85
89
  name: string;
86
90
  status: LinkStatus;
91
+ // Per-terminal LLM context. Absent = old terminal (ignore); null = clear
92
+ // stored value; object = store. Only status_update carries the null-clear.
93
+ context?: ContextSnapshot | null;
87
94
  }
88
95
  interface ErrorMsg {
89
96
  type: "error";
90
97
  message: string;
91
98
  }
99
+ interface CompactRequestMsg {
100
+ type: "compact_request";
101
+ id: string;
102
+ from: string;
103
+ to: string;
104
+ instructions?: string;
105
+ }
106
+ interface CompactResponseMsg {
107
+ type: "compact_response";
108
+ id: string;
109
+ from: string;
110
+ to: string;
111
+ ok: boolean;
112
+ reason?: string; // "busy" | "not_found" | "unsupported" | error text; absent on success
113
+ }
92
114
 
93
115
  type LinkStatus =
94
116
  | { kind: "idle"; since: number }
95
117
  | { kind: "thinking"; since: number }
96
118
  | { kind: "tool"; toolName: string; since: number };
97
119
 
120
+ type ContextSnapshot = { tokens: number | null; contextWindow: number };
121
+
98
122
  type LinkMessage =
99
123
  | RegisterMsg
100
124
  | WelcomeMsg
@@ -104,7 +128,9 @@ type LinkMessage =
104
128
  | PromptRequestMsg
105
129
  | PromptResponseMsg
106
130
  | StatusUpdateMsg
107
- | ErrorMsg;
131
+ | ErrorMsg
132
+ | CompactRequestMsg
133
+ | CompactResponseMsg;
108
134
 
109
135
  // ─── Extension ───────────────────────────────────────────────────────────────
110
136
 
@@ -138,11 +164,13 @@ export default function (pi: ExtensionAPI) {
138
164
 
139
165
  // Status tracking (local truth)
140
166
  let agentRunning = false;
167
+ let compactRunning = false; // true while compacting for a remote request
141
168
  let activeToolName: string | null = null;
142
169
  let stateSince = Date.now();
143
170
  let lastPushedKind: string | null = null;
144
171
  let lastPushedTool: string | null = null;
145
172
  const terminalStatuses = new Map<string, LinkStatus>(); // other terminals
173
+ const terminalContexts = new Map<string, ContextSnapshot>(); // other terminals' context
146
174
  let currentCwd = "";
147
175
  const terminalCwds = new Map<string, string>(); // other terminals' cwds
148
176
 
@@ -150,6 +178,7 @@ export default function (pi: ExtensionAPI) {
150
178
  let wss: WebSocketServer | null = null;
151
179
  const hubClients = new Map<WebSocket, string>(); // ws → terminal name
152
180
  const hubTerminalStatuses = new Map<string, LinkStatus>(); // hub-authoritative
181
+ const hubTerminalContexts = new Map<string, ContextSnapshot>(); // hub-authoritative
153
182
  const hubTerminalCwds = new Map<string, string>(); // hub-authoritative (excludes self)
154
183
 
155
184
  // Client state
@@ -169,6 +198,19 @@ export default function (pi: ExtensionAPI) {
169
198
  }
170
199
  >();
171
200
 
201
+ // Pending compact responses (sender waiting for remote compaction to finish)
202
+ const pendingCompactResponses = new Map<
203
+ string,
204
+ {
205
+ resolve: (result: {
206
+ content: { type: "text"; text: string }[];
207
+ details: Record<string, unknown>;
208
+ }) => void;
209
+ targetName: string;
210
+ timeout: ReturnType<typeof setTimeout>;
211
+ }
212
+ >();
213
+
172
214
  // Pending remote prompt (this terminal is executing a prompt for someone else)
173
215
  let pendingRemotePrompt: { id: string; from: string } | null = null;
174
216
  let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
@@ -215,6 +257,15 @@ export default function (pi: ExtensionAPI) {
215
257
  return { kind: "idle", since: stateSince };
216
258
  }
217
259
 
260
+ function captureContext(): ContextSnapshot | undefined {
261
+ if (!ctx) return undefined;
262
+ if (typeof ctx.getContextUsage !== "function") return undefined; // older Pi
263
+ const usage = ctx.getContextUsage();
264
+ if (!usage) return undefined;
265
+ if (usage.contextWindow <= 0) return undefined; // no real context to report
266
+ return { tokens: usage.tokens, contextWindow: usage.contextWindow };
267
+ }
268
+
218
269
  function pushStatus(force = false) {
219
270
  if (role === "disconnected") return;
220
271
  const status = deriveStatus();
@@ -224,10 +275,12 @@ export default function (pi: ExtensionAPI) {
224
275
  return;
225
276
  lastPushedKind = newKind;
226
277
  lastPushedTool = newTool;
278
+ const context = captureContext(); // only when we actually push
227
279
  const msg: StatusUpdateMsg = {
228
280
  type: "status_update",
229
281
  name: terminalName,
230
282
  status,
283
+ context: context ?? null, // explicit null tells peers to clear
231
284
  };
232
285
  if (role === "hub") {
233
286
  hubBroadcast(msg, terminalName);
@@ -236,6 +289,30 @@ export default function (pi: ExtensionAPI) {
236
289
  }
237
290
  }
238
291
 
292
+ // Canonicalize a link/session name: trim + collapse internal whitespace.
293
+ // Returns undefined for nullish/blank so callers can fall through precedence.
294
+ function normalizeName(name: string | undefined | null): string | undefined {
295
+ const n = name?.trim().replace(/\s+/g, " ");
296
+ return n ? n : undefined;
297
+ }
298
+
299
+ // Latest custom session entry of a given type (last-write-wins), or undefined.
300
+ function latestCustomData(
301
+ customType: string,
302
+ ): Record<string, unknown> | undefined {
303
+ if (!ctx) return undefined;
304
+ const entries = ctx.sessionManager.getEntries();
305
+ for (let i = entries.length - 1; i >= 0; i--) {
306
+ const e = entries[i] as {
307
+ type: string;
308
+ customType?: string;
309
+ data?: Record<string, unknown>;
310
+ };
311
+ if (e.type === "custom" && e.customType === customType) return e.data;
312
+ }
313
+ return undefined;
314
+ }
315
+
239
316
  function formatDuration(since: number): string {
240
317
  const sec = Math.floor((Date.now() - since) / 1000);
241
318
  if (sec < 60) return `${sec}s`;
@@ -249,6 +326,20 @@ export default function (pi: ExtensionAPI) {
249
326
  return `${s.kind} (${dur})`;
250
327
  }
251
328
 
329
+ function formatTokens(n: number): string {
330
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
331
+ if (n >= 1_000) return `${Math.round(n / 1000)}K`;
332
+ return `${n}`;
333
+ }
334
+
335
+ function formatContext(c: ContextSnapshot | null | undefined): string {
336
+ if (!c || c.contextWindow <= 0) return ""; // guard against bad wire data
337
+ const window = formatTokens(c.contextWindow);
338
+ if (c.tokens === null) return `?/${window}`;
339
+ const percent = Math.round((c.tokens / c.contextWindow) * 100);
340
+ return `${formatTokens(c.tokens)}/${window} (${percent}%)`;
341
+ }
342
+
252
343
  function getStatusFor(name: string): LinkStatus | null {
253
344
  if (name === terminalName) return deriveStatus();
254
345
  const map = role === "hub" ? hubTerminalStatuses : terminalStatuses;
@@ -261,6 +352,12 @@ export default function (pi: ExtensionAPI) {
261
352
  return terminalCwds.get(name) ?? null;
262
353
  }
263
354
 
355
+ function getContextFor(name: string): ContextSnapshot | null {
356
+ if (name === terminalName) return captureContext() ?? null;
357
+ if (role === "hub") return hubTerminalContexts.get(name) ?? null;
358
+ return terminalContexts.get(name) ?? null;
359
+ }
360
+
264
361
  function shortenPath(cwd: string): string {
265
362
  const home = os.homedir().replace(/\\/g, "/");
266
363
  const normalized = cwd.replace(/\\/g, "/");
@@ -337,14 +434,10 @@ export default function (pi: ExtensionAPI) {
337
434
  // ── Connection intent ──────────────────────────────────────────────────
338
435
 
339
436
  function shouldConnect(_ctx: ExtensionContext): boolean {
340
- const saved = _ctx.sessionManager
341
- .getEntries()
342
- .filter(
343
- (e: { type: string; customType?: string }) =>
344
- e.type === "custom" && e.customType === "link-active",
345
- )
346
- .pop() as { data?: { active?: boolean } } | undefined;
347
- if (saved?.data?.active !== undefined) return saved.data.active;
437
+ const data = latestCustomData("link-active") as
438
+ | { active?: boolean }
439
+ | undefined;
440
+ if (data?.active !== undefined) return data.active;
348
441
  return pi.getFlag("link") === true;
349
442
  }
350
443
 
@@ -359,6 +452,14 @@ export default function (pi: ExtensionAPI) {
359
452
  return pending;
360
453
  }
361
454
 
455
+ function cleanupPendingCompact(requestId: string) {
456
+ const pending = pendingCompactResponses.get(requestId);
457
+ if (!pending) return null;
458
+ clearTimeout(pending.timeout);
459
+ pendingCompactResponses.delete(requestId);
460
+ return pending;
461
+ }
462
+
362
463
  function makeInactivityTimeout(requestId: string, targetName: string) {
363
464
  return setTimeout(() => {
364
465
  const pending = cleanupPending(requestId);
@@ -436,7 +537,12 @@ export default function (pi: ExtensionAPI) {
436
537
  * still reject via protocol-level error responses).
437
538
  */
438
539
  function routeMessage(
439
- msg: ChatMsg | PromptRequestMsg | PromptResponseMsg,
540
+ msg:
541
+ | ChatMsg
542
+ | PromptRequestMsg
543
+ | PromptResponseMsg
544
+ | CompactRequestMsg
545
+ | CompactResponseMsg,
440
546
  ): boolean {
441
547
  if (role === "hub") {
442
548
  if (msg.to === "*") {
@@ -464,13 +570,26 @@ export default function (pi: ExtensionAPI) {
464
570
  response: "",
465
571
  error: errText,
466
572
  }
467
- : { type: "error", message: errText };
573
+ : msg.type === "compact_request"
574
+ ? {
575
+ type: "compact_response",
576
+ id: msg.id,
577
+ from: terminalName,
578
+ to: msg.from,
579
+ ok: false,
580
+ reason: "not_found",
581
+ }
582
+ : { type: "error", message: errText };
468
583
 
469
584
  if (msg.from === terminalName) {
470
- // For prompt_request, deliver the error response locally so
471
- // pendingPromptResponses resolves. For chat, skip — the tool
472
- // result (via return false) is sufficient; no extra UI toast.
473
- if (errorMsg.type === "prompt_response") handleIncoming(errorMsg);
585
+ // For prompt_request/compact_request, deliver the error response
586
+ // locally so the matching pending map resolves. For chat, skip — the
587
+ // tool result (via return false) is sufficient; no extra UI toast.
588
+ if (
589
+ errorMsg.type === "prompt_response" ||
590
+ errorMsg.type === "compact_response"
591
+ )
592
+ handleIncoming(errorMsg);
474
593
  } else {
475
594
  hubClientByName(msg.from)?.send(JSON.stringify(errorMsg));
476
595
  }
@@ -494,6 +613,7 @@ export default function (pi: ExtensionAPI) {
494
613
  connectedTerminals = msg.terminals;
495
614
  terminalStatuses.clear();
496
615
  terminalCwds.clear();
616
+ terminalContexts.clear();
497
617
  if (msg.statuses) {
498
618
  for (const [name, status] of Object.entries(msg.statuses)) {
499
619
  terminalStatuses.set(name, status);
@@ -504,6 +624,11 @@ export default function (pi: ExtensionAPI) {
504
624
  terminalCwds.set(name, cwd);
505
625
  }
506
626
  }
627
+ if (msg.contexts) {
628
+ for (const [name, c] of Object.entries(msg.contexts)) {
629
+ terminalContexts.set(name, c);
630
+ }
631
+ }
507
632
  updateStatus();
508
633
  notify(
509
634
  `Joined link as "${terminalName}" (${connectedTerminals.length} online)`,
@@ -516,6 +641,8 @@ export default function (pi: ExtensionAPI) {
516
641
  case "terminal_joined":
517
642
  connectedTerminals = msg.terminals;
518
643
  if (role !== "hub" && msg.cwd) terminalCwds.set(msg.name, msg.cwd);
644
+ if (role !== "hub" && msg.context)
645
+ terminalContexts.set(msg.name, msg.context);
519
646
  updateStatus();
520
647
  notify(`"${msg.name}" joined the link`, "info");
521
648
  break;
@@ -523,8 +650,11 @@ export default function (pi: ExtensionAPI) {
523
650
  case "terminal_left":
524
651
  connectedTerminals = msg.terminals;
525
652
  terminalStatuses.delete(msg.name);
526
- if (role !== "hub") terminalCwds.delete(msg.name);
527
- // Fail any pending prompts to the departed terminal immediately
653
+ if (role !== "hub") {
654
+ terminalCwds.delete(msg.name);
655
+ terminalContexts.delete(msg.name);
656
+ }
657
+ // Fail any pending prompts/compacts to the departed terminal
528
658
  for (const [id, pending] of pendingPromptResponses) {
529
659
  if (pending.targetName === msg.name) {
530
660
  const p = cleanupPending(id);
@@ -538,6 +668,19 @@ export default function (pi: ExtensionAPI) {
538
668
  }
539
669
  }
540
670
  }
671
+ for (const [id, pending] of pendingCompactResponses) {
672
+ if (pending.targetName === msg.name) {
673
+ const p = cleanupPendingCompact(id);
674
+ if (p) {
675
+ p.resolve(
676
+ textResult(`Terminal "${msg.name}" disconnected`, {
677
+ to: msg.name,
678
+ error: "disconnected",
679
+ }),
680
+ );
681
+ }
682
+ }
683
+ }
541
684
  updateStatus();
542
685
  notify(`"${msg.name}" left the link`, "info");
543
686
  break;
@@ -545,6 +688,8 @@ export default function (pi: ExtensionAPI) {
545
688
  // ── Status update from another terminal ──
546
689
  case "status_update":
547
690
  terminalStatuses.set(msg.name, msg.status);
691
+ if (msg.context) terminalContexts.set(msg.name, msg.context);
692
+ else if (msg.context === null) terminalContexts.delete(msg.name);
548
693
  resetInactivityFor(msg.name);
549
694
  break;
550
695
 
@@ -566,9 +711,60 @@ export default function (pi: ExtensionAPI) {
566
711
  }
567
712
  break;
568
713
 
714
+ // ── Another terminal asks us to compact our context ──
715
+ case "compact_request": {
716
+ if (agentRunning || pendingRemotePrompt || compactRunning) {
717
+ routeMessage({
718
+ type: "compact_response",
719
+ id: msg.id,
720
+ from: terminalName,
721
+ to: msg.from,
722
+ ok: false,
723
+ reason: "busy",
724
+ });
725
+ break;
726
+ }
727
+ const { id, from } = msg;
728
+ let finished = false;
729
+ const finish = (ok: boolean, reason?: string) => {
730
+ if (finished) return;
731
+ finished = true;
732
+ compactRunning = false;
733
+ routeMessage({
734
+ type: "compact_response",
735
+ id,
736
+ from: terminalName,
737
+ to: from,
738
+ ok,
739
+ reason,
740
+ });
741
+ };
742
+ if (!ctx?.compact) {
743
+ finish(false, "unsupported");
744
+ break;
745
+ }
746
+ compactRunning = true;
747
+ notify(`"${from}" requested compact`, "info");
748
+ // compact() aborts the current turn first, so the busy guard above
749
+ // keeps us from interrupting active work. The runtime guarantees
750
+ // exactly one of onComplete/onError fires, so compactRunning can't
751
+ // get stuck and the sender won't hang.
752
+ try {
753
+ ctx.compact({
754
+ customInstructions: msg.instructions,
755
+ onComplete: () => finish(true),
756
+ onError: (e) =>
757
+ finish(false, e instanceof Error ? e.message : String(e)),
758
+ });
759
+ } catch (e) {
760
+ finish(false, e instanceof Error ? e.message : String(e));
761
+ }
762
+ break;
763
+ }
764
+
569
765
  // ── Another terminal asks us to run a prompt ──
570
766
  case "prompt_request":
571
- if (agentRunning || pendingRemotePrompt) {
767
+ if (agentRunning || pendingRemotePrompt || compactRunning) {
572
768
  routeMessage({
573
769
  type: "prompt_response",
574
770
  id: msg.id,
@@ -610,6 +806,30 @@ export default function (pi: ExtensionAPI) {
610
806
  break;
611
807
  }
612
808
 
809
+ // ── Response to a compact we requested ──
810
+ case "compact_response": {
811
+ const pending = cleanupPendingCompact(msg.id);
812
+ if (pending) {
813
+ // Use the requested target, not msg.from: a hub-synthesized
814
+ // not_found response comes from the hub, not the worker.
815
+ const target = pending.targetName;
816
+ if (msg.ok) {
817
+ pending.resolve(
818
+ textResult(`Compacted "${target}"`, { to: target }),
819
+ );
820
+ } else {
821
+ const reason = msg.reason ?? "failed";
822
+ pending.resolve(
823
+ textResult(`Compact on "${target}" not done: ${reason}`, {
824
+ to: target,
825
+ error: reason,
826
+ }),
827
+ );
828
+ }
829
+ }
830
+ break;
831
+ }
832
+
613
833
  case "error":
614
834
  notify(`Link: ${msg.message}`, "error");
615
835
  break;
@@ -631,6 +851,7 @@ export default function (pi: ExtensionAPI) {
631
851
  clientName = uniqueName(msg.name);
632
852
  hubClients.set(clientWs, clientName);
633
853
  if (msg.cwd) hubTerminalCwds.set(clientName, msg.cwd);
854
+ if (msg.context) hubTerminalContexts.set(clientName, msg.context);
634
855
  const list = terminalList();
635
856
  connectedTerminals = list;
636
857
  updateStatus();
@@ -646,6 +867,12 @@ export default function (pi: ExtensionAPI) {
646
867
  for (const [name, cwd] of hubTerminalCwds) {
647
868
  if (name !== clientName) cwds[name] = cwd;
648
869
  }
870
+ const contexts: Record<string, ContextSnapshot> = {};
871
+ const hubContext = captureContext();
872
+ if (hubContext) contexts[terminalName] = hubContext; // hub's own context
873
+ for (const [name, c] of hubTerminalContexts) {
874
+ if (name !== clientName) contexts[name] = c;
875
+ }
649
876
  clientWs.send(
650
877
  JSON.stringify({
651
878
  type: "welcome",
@@ -653,15 +880,17 @@ export default function (pi: ExtensionAPI) {
653
880
  terminals: list,
654
881
  statuses,
655
882
  cwds,
883
+ contexts,
656
884
  } satisfies WelcomeMsg),
657
885
  );
658
886
 
659
- // Notify everyone else (include joiner's cwd)
887
+ // Notify everyone else (include joiner's cwd + context)
660
888
  const joined: TerminalJoinedMsg = {
661
889
  type: "terminal_joined",
662
890
  name: clientName,
663
891
  terminals: list,
664
892
  cwd: msg.cwd,
893
+ context: msg.context,
665
894
  };
666
895
  hubBroadcast(joined, clientName);
667
896
  return;
@@ -673,11 +902,14 @@ export default function (pi: ExtensionAPI) {
673
902
  // Status update — store and fan out to other clients only (not back to hub)
674
903
  if (msg.type === "status_update") {
675
904
  hubTerminalStatuses.set(clientName, msg.status);
905
+ if (msg.context) hubTerminalContexts.set(clientName, msg.context);
906
+ else if (msg.context === null) hubTerminalContexts.delete(clientName);
676
907
  resetInactivityFor(clientName);
677
908
  const normalized: StatusUpdateMsg = {
678
909
  type: "status_update",
679
910
  name: clientName,
680
911
  status: msg.status,
912
+ context: msg.context, // undefined omitted by JSON; null forwarded to clear
681
913
  };
682
914
  const json = JSON.stringify(normalized);
683
915
  for (const [otherWs, name] of hubClients) {
@@ -692,7 +924,9 @@ export default function (pi: ExtensionAPI) {
692
924
  if (
693
925
  msg.type === "chat" ||
694
926
  msg.type === "prompt_request" ||
695
- msg.type === "prompt_response"
927
+ msg.type === "prompt_response" ||
928
+ msg.type === "compact_request" ||
929
+ msg.type === "compact_response"
696
930
  ) {
697
931
  routeMessage({ ...msg, from: clientName });
698
932
  }
@@ -700,20 +934,21 @@ export default function (pi: ExtensionAPI) {
700
934
 
701
935
  clientWs.on("close", () => {
702
936
  if (disposed) return;
703
- if (clientName) {
704
- hubClients.delete(clientWs);
705
- hubTerminalStatuses.delete(clientName);
706
- hubTerminalCwds.delete(clientName);
707
- const list = terminalList();
708
- connectedTerminals = list;
709
- updateStatus();
710
- const left: TerminalLeftMsg = {
711
- type: "terminal_left",
712
- name: clientName,
713
- terminals: list,
714
- };
715
- hubBroadcast(left, clientName);
716
- }
937
+ const name = hubClients.get(clientWs);
938
+ if (!name) return; // already removed (e.g. via disconnect) — ignore stale event
939
+ hubClients.delete(clientWs);
940
+ hubTerminalStatuses.delete(name);
941
+ hubTerminalContexts.delete(name);
942
+ hubTerminalCwds.delete(name);
943
+ const list = terminalList();
944
+ connectedTerminals = list;
945
+ updateStatus();
946
+ const left: TerminalLeftMsg = {
947
+ type: "terminal_left",
948
+ name,
949
+ terminals: list,
950
+ };
951
+ hubBroadcast(left, name);
717
952
  });
718
953
 
719
954
  clientWs.on("error", () => {
@@ -793,6 +1028,7 @@ export default function (pi: ExtensionAPI) {
793
1028
  type: "register",
794
1029
  name: preferredName ?? terminalName,
795
1030
  cwd: currentCwd || undefined,
1031
+ context: captureContext(),
796
1032
  } satisfies RegisterMsg),
797
1033
  );
798
1034
  resolve(true);
@@ -869,8 +1105,9 @@ export default function (pi: ExtensionAPI) {
869
1105
  keepaliveTimer = null;
870
1106
  }
871
1107
  pendingRemotePrompt = null;
1108
+ compactRunning = false;
872
1109
 
873
- // Clean up pending prompts
1110
+ // Clean up pending prompts and compacts
874
1111
  for (const id of [...pendingPromptResponses.keys()]) {
875
1112
  const pending = cleanupPending(id);
876
1113
  if (pending) {
@@ -879,6 +1116,14 @@ export default function (pi: ExtensionAPI) {
879
1116
  );
880
1117
  }
881
1118
  }
1119
+ for (const id of [...pendingCompactResponses.keys()]) {
1120
+ const pending = cleanupPendingCompact(id);
1121
+ if (pending) {
1122
+ pending.resolve(
1123
+ textResult("Link disconnected", { error: "disconnected" }),
1124
+ );
1125
+ }
1126
+ }
882
1127
 
883
1128
  // Close client connection
884
1129
  if (ws) {
@@ -898,6 +1143,8 @@ export default function (pi: ExtensionAPI) {
898
1143
  connectedTerminals = [];
899
1144
  terminalStatuses.clear();
900
1145
  hubTerminalStatuses.clear();
1146
+ terminalContexts.clear();
1147
+ hubTerminalContexts.clear();
901
1148
  terminalCwds.clear();
902
1149
  hubTerminalCwds.clear();
903
1150
  lastPushedKind = null;
@@ -943,7 +1190,7 @@ export default function (pi: ExtensionAPI) {
943
1190
  const cliRaw = pi.getFlag("link-name");
944
1191
  let cliFlagName: string | undefined;
945
1192
  if (typeof cliRaw === "string") {
946
- cliFlagName = cliRaw.trim().replace(/\s+/g, " ");
1193
+ cliFlagName = normalizeName(cliRaw);
947
1194
  if (!cliFlagName) {
948
1195
  console.error("Error: --link-name requires a non-empty value.");
949
1196
  process.exit(1);
@@ -952,7 +1199,7 @@ export default function (pi: ExtensionAPI) {
952
1199
 
953
1200
  const envRaw = process.env.PI_LINK_NAME;
954
1201
  delete process.env.PI_LINK_NAME;
955
- const envFlagName = envRaw?.trim().replace(/\s+/g, " ") || undefined;
1202
+ const envFlagName = normalizeName(envRaw);
956
1203
 
957
1204
  const flagName = cliFlagName ?? envFlagName;
958
1205
  const fromEnv = !cliFlagName && !!envFlagName;
@@ -964,19 +1211,12 @@ export default function (pi: ExtensionAPI) {
964
1211
  // Skip append if the saved name already matches; persistence is needed
965
1212
  // only for first-time set or actual change. Reduces session-file growth
966
1213
  // on repeated startups (common in automation).
967
- let latestSaved: string | undefined;
968
- const entries = _ctx.sessionManager.getEntries();
969
- for (let i = entries.length - 1; i >= 0; i--) {
970
- const e = entries[i] as {
971
- type: string;
972
- customType?: string;
973
- data?: { name?: unknown };
974
- };
975
- if (e.type !== "custom" || e.customType !== "link-name") continue;
976
- if (typeof e.data?.name === "string") latestSaved = e.data.name;
977
- break;
978
- }
979
- if (latestSaved?.trim().replace(/\s+/g, " ") !== flagName) {
1214
+ const latest = latestCustomData("link-name") as
1215
+ | { name?: unknown }
1216
+ | undefined;
1217
+ const latestSaved =
1218
+ typeof latest?.name === "string" ? latest.name : undefined;
1219
+ if (normalizeName(latestSaved) !== flagName) {
980
1220
  pi.appendEntry("link-name", { name: flagName });
981
1221
  }
982
1222
 
@@ -984,18 +1224,15 @@ export default function (pi: ExtensionAPI) {
984
1224
  // Public --link-name is link-only.
985
1225
  if (fromEnv && !pi.getSessionName()) pi.setSessionName(flagName);
986
1226
  } else {
987
- const saved = _ctx.sessionManager
988
- .getEntries()
989
- .filter(
990
- (e: { type: string; customType?: string }) =>
991
- e.type === "custom" && e.customType === "link-name",
992
- )
993
- .pop() as { data?: { name?: string } } | undefined;
994
- if (saved?.data?.name) {
995
- preferredName = saved.data.name;
1227
+ const saved = latestCustomData("link-name") as
1228
+ | { name?: string }
1229
+ | undefined;
1230
+ const savedName = normalizeName(saved?.name);
1231
+ if (savedName) {
1232
+ preferredName = savedName;
996
1233
  terminalName = preferredName;
997
1234
  } else {
998
- const sessionName = pi.getSessionName()?.trim().replace(/\s+/g, " ");
1235
+ const sessionName = normalizeName(pi.getSessionName());
999
1236
  if (sessionName) terminalName = sessionName;
1000
1237
  }
1001
1238
  }
@@ -1014,6 +1251,11 @@ export default function (pi: ExtensionAPI) {
1014
1251
  pushStatus();
1015
1252
  });
1016
1253
 
1254
+ pi.on("session_compact", async () => {
1255
+ // Tokens just dropped sharply — force a push so peers see the new context.
1256
+ pushStatus(true);
1257
+ });
1258
+
1017
1259
  pi.on("tool_execution_start", async (event) => {
1018
1260
  activeToolName = event.toolName;
1019
1261
  stateSince = Date.now();
@@ -1082,6 +1324,30 @@ export default function (pi: ExtensionAPI) {
1082
1324
  return text.length > max ? text.slice(0, max) + "..." : text;
1083
1325
  }
1084
1326
 
1327
+ // Shared "target not found" result for the send/prompt/compact tools.
1328
+ // Returns null when the target is present, so callers can `if (miss) return miss;`.
1329
+ function targetNotFound(to: string) {
1330
+ return connectedTerminals.includes(to)
1331
+ ? null
1332
+ : textResult(
1333
+ `Terminal "${to}" not found. Connected: ${connectedTerminals.join(", ")}`,
1334
+ { to, error: "not_found" },
1335
+ );
1336
+ }
1337
+
1338
+ // Shared ✓/✗ result renderer for link_send and link_compact.
1339
+ function renderIconResult(
1340
+ result: { content: { type: string; text?: string }[]; details?: unknown },
1341
+ theme: { fg(role: string, text: string): string },
1342
+ ) {
1343
+ const txt = result.content[0];
1344
+ const details = result.details as Record<string, unknown> | undefined;
1345
+ const icon = details?.error
1346
+ ? theme.fg("error", "✗ ")
1347
+ : theme.fg("success", "✓ ");
1348
+ return new Text(icon + (txt?.type === "text" ? txt.text : ""), 0, 0);
1349
+ }
1350
+
1085
1351
  // ── Tools ────────────────────────────────────────────────────────────────
1086
1352
 
1087
1353
  pi.registerTool({
@@ -1110,11 +1376,9 @@ export default function (pi: ExtensionAPI) {
1110
1376
  if (role === "disconnected") return notConnectedResult();
1111
1377
 
1112
1378
  // Pre-validate target exists locally (best-effort, catches typos and definitely-absent names)
1113
- if (params.to !== "*" && !connectedTerminals.includes(params.to)) {
1114
- return textResult(
1115
- `Terminal "${params.to}" not found. Connected: ${connectedTerminals.join(", ")}`,
1116
- { to: params.to, error: "not_found" },
1117
- );
1379
+ if (params.to !== "*") {
1380
+ const miss = targetNotFound(params.to);
1381
+ if (miss) return miss;
1118
1382
  }
1119
1383
 
1120
1384
  const delivered = routeMessage({
@@ -1153,14 +1417,108 @@ export default function (pi: ExtensionAPI) {
1153
1417
  return new Text(text, 0, 0);
1154
1418
  },
1155
1419
 
1156
- renderResult(result, _options, theme) {
1157
- const txt = result.content[0];
1158
- const details = result.details as Record<string, unknown> | undefined;
1159
- const icon = details?.error
1160
- ? theme.fg("error", "✗ ")
1161
- : theme.fg("success", "✓ ");
1162
- return new Text(icon + (txt?.type === "text" ? txt.text : ""), 0, 0);
1420
+ renderResult: (result, _options, theme) => renderIconResult(result, theme),
1421
+ });
1422
+
1423
+ pi.registerTool({
1424
+ name: "link_compact",
1425
+ label: "Link Compact",
1426
+ description: [
1427
+ "Ask another Pi terminal to compact its context window and wait until it finishes.",
1428
+ "Returns once the target has compacted, so you can immediately send it new work.",
1429
+ "Busy targets (mid-turn or already compacting) decline; retry when idle.",
1430
+ ].join(" "),
1431
+ promptSnippet: "Ask another Pi terminal to compact its context window",
1432
+ parameters: Type.Object({
1433
+ to: Type.String({ description: "Target terminal name" }),
1434
+ instructions: Type.Optional(
1435
+ Type.String({
1436
+ description: "Optional custom compaction instructions for the target",
1437
+ }),
1438
+ ),
1439
+ }),
1440
+
1441
+ async execute(_toolCallId, params, signal) {
1442
+ if (role === "disconnected") return notConnectedResult();
1443
+
1444
+ if (params.to === terminalName) {
1445
+ return textResult("Cannot compact yourself - use /compact.", {
1446
+ to: params.to,
1447
+ error: "self_target",
1448
+ });
1449
+ }
1450
+
1451
+ const miss = targetNotFound(params.to);
1452
+ if (miss) return miss;
1453
+
1454
+ const requestId = crypto.randomUUID();
1455
+
1456
+ return new Promise((resolve) => {
1457
+ const timeout = setTimeout(() => {
1458
+ const pending = cleanupPendingCompact(requestId);
1459
+ if (pending) {
1460
+ pending.resolve(
1461
+ textResult(
1462
+ `Compact request to "${params.to}" timed out (${COMPACT_TIMEOUT_MS / 1000}s)`,
1463
+ { to: params.to, error: "timeout" },
1464
+ ),
1465
+ );
1466
+ }
1467
+ }, COMPACT_TIMEOUT_MS);
1468
+
1469
+ pendingCompactResponses.set(requestId, {
1470
+ resolve,
1471
+ targetName: params.to,
1472
+ timeout,
1473
+ });
1474
+
1475
+ signal?.addEventListener(
1476
+ "abort",
1477
+ () => {
1478
+ const pending = cleanupPendingCompact(requestId);
1479
+ if (pending) {
1480
+ pending.resolve(
1481
+ textResult("Compact request aborted", {
1482
+ to: params.to,
1483
+ error: "aborted",
1484
+ }),
1485
+ );
1486
+ }
1487
+ },
1488
+ { once: true },
1489
+ );
1490
+
1491
+ const delivered = routeMessage({
1492
+ type: "compact_request",
1493
+ id: requestId,
1494
+ from: terminalName,
1495
+ to: params.to,
1496
+ instructions: params.instructions,
1497
+ });
1498
+
1499
+ if (!delivered && pendingCompactResponses.has(requestId)) {
1500
+ const pending = cleanupPendingCompact(requestId);
1501
+ if (pending) {
1502
+ pending.resolve(
1503
+ textResult(`Failed to request compact on "${params.to}"`, {
1504
+ to: params.to,
1505
+ error: "not_delivered",
1506
+ }),
1507
+ );
1508
+ }
1509
+ }
1510
+ });
1163
1511
  },
1512
+
1513
+ renderCall(args, theme) {
1514
+ let text = theme.fg("toolTitle", theme.bold("link_compact "));
1515
+ text += theme.fg("accent", String(args.to));
1516
+ if (typeof args.instructions === "string")
1517
+ text += "\n " + theme.fg("dim", truncatePreview(args.instructions));
1518
+ return new Text(text, 0, 0);
1519
+ },
1520
+
1521
+ renderResult: (result, _options, theme) => renderIconResult(result, theme),
1164
1522
  });
1165
1523
 
1166
1524
  pi.registerTool({
@@ -1188,12 +1546,8 @@ export default function (pi: ExtensionAPI) {
1188
1546
  });
1189
1547
  }
1190
1548
 
1191
- if (!connectedTerminals.includes(params.to)) {
1192
- return textResult(
1193
- `Terminal "${params.to}" not found. Connected: ${connectedTerminals.join(", ")}`,
1194
- { to: params.to, error: "not_found" },
1195
- );
1196
- }
1549
+ const miss = targetNotFound(params.to);
1550
+ if (miss) return miss;
1197
1551
 
1198
1552
  const requestId = crypto.randomUUID();
1199
1553
 
@@ -1302,6 +1656,7 @@ export default function (pi: ExtensionAPI) {
1302
1656
 
1303
1657
  const statuses: Record<string, string> = {};
1304
1658
  const cwds: Record<string, string> = {};
1659
+ const contexts: Record<string, ContextSnapshot> = {};
1305
1660
  const list = connectedTerminals
1306
1661
  .map((name) => {
1307
1662
  const status = getStatusFor(name);
@@ -1309,8 +1664,12 @@ export default function (pi: ExtensionAPI) {
1309
1664
  if (statusStr) statuses[name] = statusStr;
1310
1665
  const cwd = getCwdFor(name);
1311
1666
  if (cwd) cwds[name] = cwd;
1667
+ const context = getContextFor(name);
1668
+ if (context) contexts[name] = context;
1669
+ const ctxStr = formatContext(context);
1312
1670
  const marker = name === terminalName ? " (you)" : "";
1313
1671
  let line = ` \u2022 ${name}${marker}${statusStr ? " " + statusStr : ""}`;
1672
+ if (ctxStr) line += ` \u00b7 ${ctxStr}`;
1314
1673
  if (cwd) line += `\n cwd: ${cwd}`;
1315
1674
  return line;
1316
1675
  })
@@ -1320,6 +1679,7 @@ export default function (pi: ExtensionAPI) {
1320
1679
  terminals: connectedTerminals,
1321
1680
  statuses,
1322
1681
  cwds,
1682
+ contexts,
1323
1683
  self: terminalName,
1324
1684
  role,
1325
1685
  });
@@ -1331,6 +1691,7 @@ export default function (pi: ExtensionAPI) {
1331
1691
  terminals?: string[];
1332
1692
  statuses?: Record<string, string>;
1333
1693
  cwds?: Record<string, string>;
1694
+ contexts?: Record<string, ContextSnapshot>;
1334
1695
  self?: string;
1335
1696
  role?: string;
1336
1697
  }
@@ -1347,11 +1708,13 @@ export default function (pi: ExtensionAPI) {
1347
1708
  const isSelf = name === details.self;
1348
1709
  const status = details.statuses?.[name] ?? "";
1349
1710
  const cwd = details.cwds?.[name];
1711
+ const ctxStr = formatContext(details.contexts?.[name]);
1350
1712
  const nameStr = isSelf ? `\u2022 ${name} (you)` : `\u2022 ${name}`;
1351
1713
  text +=
1352
1714
  "\n " +
1353
1715
  (isSelf ? theme.fg("accent", nameStr) : theme.fg("text", nameStr)) +
1354
- (status ? " " + theme.fg("dim", status) : "");
1716
+ (status ? " " + theme.fg("dim", status) : "") +
1717
+ (ctxStr ? theme.fg("dim", " \u00b7 " + ctxStr) : "");
1355
1718
  if (cwd) text += "\n " + theme.fg("dim", `cwd: ${shortenPath(cwd)}`);
1356
1719
  }
1357
1720
  return new Text(text, 0, 0);
@@ -1371,8 +1734,10 @@ export default function (pi: ExtensionAPI) {
1371
1734
  const status = getStatusFor(name);
1372
1735
  const statusStr = status ? formatStatus(status) : "";
1373
1736
  const cwd = getCwdFor(name);
1737
+ const ctxStr = formatContext(getContextFor(name));
1374
1738
  const marker = name === terminalName ? " (you)" : "";
1375
1739
  let line = `${name}${marker}${statusStr ? ": " + statusStr : ""}`;
1740
+ if (ctxStr) line += ` \u00b7 ${ctxStr}`;
1376
1741
  if (cwd) line += `\n cwd: ${shortenPath(cwd)}`;
1377
1742
  return line;
1378
1743
  });
@@ -1386,10 +1751,10 @@ export default function (pi: ExtensionAPI) {
1386
1751
  pi.registerCommand("link-name", {
1387
1752
  description: "Change link name. No arg = use session name",
1388
1753
  handler: async (args, _ctx) => {
1389
- let newName = args.trim();
1754
+ let newName = normalizeName(args) ?? "";
1390
1755
  if (!newName) {
1391
1756
  // No argument: use session name if available
1392
- const sessionName = pi.getSessionName()?.trim().replace(/\s+/g, " ");
1757
+ const sessionName = normalizeName(pi.getSessionName());
1393
1758
  if (sessionName) {
1394
1759
  newName = sessionName;
1395
1760
  } else {
@@ -1444,6 +1809,7 @@ export default function (pi: ExtensionAPI) {
1444
1809
  name: newName,
1445
1810
  terminals: list,
1446
1811
  cwd: currentCwd,
1812
+ context: captureContext(),
1447
1813
  },
1448
1814
  terminalName,
1449
1815
  );