pi-link 0.1.15 → 0.1.17

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, "/");
@@ -336,15 +433,11 @@ export default function (pi: ExtensionAPI) {
336
433
 
337
434
  // ── Connection intent ──────────────────────────────────────────────────
338
435
 
339
- 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;
436
+ function shouldConnect(): boolean {
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,
@@ -579,7 +775,9 @@ export default function (pi: ExtensionAPI) {
579
775
  });
580
776
  } else {
581
777
  pendingRemotePrompt = { id: msg.id, from: msg.from };
582
- // 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.
583
781
  if (keepaliveTimer) clearInterval(keepaliveTimer);
584
782
  keepaliveTimer = setInterval(
585
783
  () => pushStatus(true),
@@ -610,6 +808,30 @@ export default function (pi: ExtensionAPI) {
610
808
  break;
611
809
  }
612
810
 
811
+ // ── Response to a compact we requested ──
812
+ case "compact_response": {
813
+ const pending = cleanupPendingCompact(msg.id);
814
+ if (pending) {
815
+ // Use the requested target, not msg.from: a hub-synthesized
816
+ // not_found response comes from the hub, not the worker.
817
+ const target = pending.targetName;
818
+ if (msg.ok) {
819
+ pending.resolve(
820
+ textResult(`Compacted "${target}"`, { to: target }),
821
+ );
822
+ } else {
823
+ const reason = msg.reason ?? "failed";
824
+ pending.resolve(
825
+ textResult(`Compact on "${target}" not done: ${reason}`, {
826
+ to: target,
827
+ error: reason,
828
+ }),
829
+ );
830
+ }
831
+ }
832
+ break;
833
+ }
834
+
613
835
  case "error":
614
836
  notify(`Link: ${msg.message}`, "error");
615
837
  break;
@@ -628,9 +850,11 @@ export default function (pi: ExtensionAPI) {
628
850
 
629
851
  // First message must be register
630
852
  if (msg.type === "register") {
853
+ if (clientName) return; // already registered — ignore duplicate
631
854
  clientName = uniqueName(msg.name);
632
855
  hubClients.set(clientWs, clientName);
633
856
  if (msg.cwd) hubTerminalCwds.set(clientName, msg.cwd);
857
+ if (msg.context) hubTerminalContexts.set(clientName, msg.context);
634
858
  const list = terminalList();
635
859
  connectedTerminals = list;
636
860
  updateStatus();
@@ -646,6 +870,12 @@ export default function (pi: ExtensionAPI) {
646
870
  for (const [name, cwd] of hubTerminalCwds) {
647
871
  if (name !== clientName) cwds[name] = cwd;
648
872
  }
873
+ const contexts: Record<string, ContextSnapshot> = {};
874
+ const hubContext = captureContext();
875
+ if (hubContext) contexts[terminalName] = hubContext; // hub's own context
876
+ for (const [name, c] of hubTerminalContexts) {
877
+ if (name !== clientName) contexts[name] = c;
878
+ }
649
879
  clientWs.send(
650
880
  JSON.stringify({
651
881
  type: "welcome",
@@ -653,15 +883,17 @@ export default function (pi: ExtensionAPI) {
653
883
  terminals: list,
654
884
  statuses,
655
885
  cwds,
886
+ contexts,
656
887
  } satisfies WelcomeMsg),
657
888
  );
658
889
 
659
- // Notify everyone else (include joiner's cwd)
890
+ // Notify everyone else (include joiner's cwd + context)
660
891
  const joined: TerminalJoinedMsg = {
661
892
  type: "terminal_joined",
662
893
  name: clientName,
663
894
  terminals: list,
664
895
  cwd: msg.cwd,
896
+ context: msg.context,
665
897
  };
666
898
  hubBroadcast(joined, clientName);
667
899
  return;
@@ -673,11 +905,14 @@ export default function (pi: ExtensionAPI) {
673
905
  // Status update — store and fan out to other clients only (not back to hub)
674
906
  if (msg.type === "status_update") {
675
907
  hubTerminalStatuses.set(clientName, msg.status);
908
+ if (msg.context) hubTerminalContexts.set(clientName, msg.context);
909
+ else if (msg.context === null) hubTerminalContexts.delete(clientName);
676
910
  resetInactivityFor(clientName);
677
911
  const normalized: StatusUpdateMsg = {
678
912
  type: "status_update",
679
913
  name: clientName,
680
914
  status: msg.status,
915
+ context: msg.context, // undefined omitted by JSON; null forwarded to clear
681
916
  };
682
917
  const json = JSON.stringify(normalized);
683
918
  for (const [otherWs, name] of hubClients) {
@@ -692,7 +927,9 @@ export default function (pi: ExtensionAPI) {
692
927
  if (
693
928
  msg.type === "chat" ||
694
929
  msg.type === "prompt_request" ||
695
- msg.type === "prompt_response"
930
+ msg.type === "prompt_response" ||
931
+ msg.type === "compact_request" ||
932
+ msg.type === "compact_response"
696
933
  ) {
697
934
  routeMessage({ ...msg, from: clientName });
698
935
  }
@@ -700,20 +937,21 @@ export default function (pi: ExtensionAPI) {
700
937
 
701
938
  clientWs.on("close", () => {
702
939
  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
- }
940
+ const name = hubClients.get(clientWs);
941
+ if (!name) return; // already removed (e.g. via disconnect) — ignore stale event
942
+ hubClients.delete(clientWs);
943
+ hubTerminalStatuses.delete(name);
944
+ hubTerminalContexts.delete(name);
945
+ hubTerminalCwds.delete(name);
946
+ const list = terminalList();
947
+ connectedTerminals = list;
948
+ updateStatus();
949
+ const left: TerminalLeftMsg = {
950
+ type: "terminal_left",
951
+ name,
952
+ terminals: list,
953
+ };
954
+ hubBroadcast(left, name);
717
955
  });
718
956
 
719
957
  clientWs.on("error", () => {
@@ -793,6 +1031,7 @@ export default function (pi: ExtensionAPI) {
793
1031
  type: "register",
794
1032
  name: preferredName ?? terminalName,
795
1033
  cwd: currentCwd || undefined,
1034
+ context: captureContext(),
796
1035
  } satisfies RegisterMsg),
797
1036
  );
798
1037
  resolve(true);
@@ -869,8 +1108,9 @@ export default function (pi: ExtensionAPI) {
869
1108
  keepaliveTimer = null;
870
1109
  }
871
1110
  pendingRemotePrompt = null;
1111
+ compactRunning = false;
872
1112
 
873
- // Clean up pending prompts
1113
+ // Clean up pending prompts and compacts
874
1114
  for (const id of [...pendingPromptResponses.keys()]) {
875
1115
  const pending = cleanupPending(id);
876
1116
  if (pending) {
@@ -879,6 +1119,14 @@ export default function (pi: ExtensionAPI) {
879
1119
  );
880
1120
  }
881
1121
  }
1122
+ for (const id of [...pendingCompactResponses.keys()]) {
1123
+ const pending = cleanupPendingCompact(id);
1124
+ if (pending) {
1125
+ pending.resolve(
1126
+ textResult("Link disconnected", { error: "disconnected" }),
1127
+ );
1128
+ }
1129
+ }
882
1130
 
883
1131
  // Close client connection
884
1132
  if (ws) {
@@ -898,6 +1146,8 @@ export default function (pi: ExtensionAPI) {
898
1146
  connectedTerminals = [];
899
1147
  terminalStatuses.clear();
900
1148
  hubTerminalStatuses.clear();
1149
+ terminalContexts.clear();
1150
+ hubTerminalContexts.clear();
901
1151
  terminalCwds.clear();
902
1152
  hubTerminalCwds.clear();
903
1153
  lastPushedKind = null;
@@ -943,7 +1193,7 @@ export default function (pi: ExtensionAPI) {
943
1193
  const cliRaw = pi.getFlag("link-name");
944
1194
  let cliFlagName: string | undefined;
945
1195
  if (typeof cliRaw === "string") {
946
- cliFlagName = cliRaw.trim().replace(/\s+/g, " ");
1196
+ cliFlagName = normalizeName(cliRaw);
947
1197
  if (!cliFlagName) {
948
1198
  console.error("Error: --link-name requires a non-empty value.");
949
1199
  process.exit(1);
@@ -952,7 +1202,7 @@ export default function (pi: ExtensionAPI) {
952
1202
 
953
1203
  const envRaw = process.env.PI_LINK_NAME;
954
1204
  delete process.env.PI_LINK_NAME;
955
- const envFlagName = envRaw?.trim().replace(/\s+/g, " ") || undefined;
1205
+ const envFlagName = normalizeName(envRaw);
956
1206
 
957
1207
  const flagName = cliFlagName ?? envFlagName;
958
1208
  const fromEnv = !cliFlagName && !!envFlagName;
@@ -964,19 +1214,12 @@ export default function (pi: ExtensionAPI) {
964
1214
  // Skip append if the saved name already matches; persistence is needed
965
1215
  // only for first-time set or actual change. Reduces session-file growth
966
1216
  // 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) {
1217
+ const latest = latestCustomData("link-name") as
1218
+ | { name?: unknown }
1219
+ | undefined;
1220
+ const latestSaved =
1221
+ typeof latest?.name === "string" ? latest.name : undefined;
1222
+ if (normalizeName(latestSaved) !== flagName) {
980
1223
  pi.appendEntry("link-name", { name: flagName });
981
1224
  }
982
1225
 
@@ -984,23 +1227,20 @@ export default function (pi: ExtensionAPI) {
984
1227
  // Public --link-name is link-only.
985
1228
  if (fromEnv && !pi.getSessionName()) pi.setSessionName(flagName);
986
1229
  } 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;
1230
+ const saved = latestCustomData("link-name") as
1231
+ | { name?: string }
1232
+ | undefined;
1233
+ const savedName = normalizeName(saved?.name);
1234
+ if (savedName) {
1235
+ preferredName = savedName;
996
1236
  terminalName = preferredName;
997
1237
  } else {
998
- const sessionName = pi.getSessionName()?.trim().replace(/\s+/g, " ");
1238
+ const sessionName = normalizeName(pi.getSessionName());
999
1239
  if (sessionName) terminalName = sessionName;
1000
1240
  }
1001
1241
  }
1002
1242
 
1003
- if (flagName || shouldConnect(_ctx)) scheduleStartupConnect();
1243
+ if (flagName || shouldConnect()) scheduleStartupConnect();
1004
1244
  });
1005
1245
 
1006
1246
  pi.on("session_shutdown", async () => {
@@ -1014,6 +1254,11 @@ export default function (pi: ExtensionAPI) {
1014
1254
  pushStatus();
1015
1255
  });
1016
1256
 
1257
+ pi.on("session_compact", async () => {
1258
+ // Tokens just dropped sharply — force a push so peers see the new context.
1259
+ pushStatus(true);
1260
+ });
1261
+
1017
1262
  pi.on("tool_execution_start", async (event) => {
1018
1263
  activeToolName = event.toolName;
1019
1264
  stateSince = Date.now();
@@ -1075,13 +1320,37 @@ export default function (pi: ExtensionAPI) {
1075
1320
  }
1076
1321
 
1077
1322
  function notConnectedResult() {
1078
- return textResult("Not connected to link");
1323
+ return textResult("Not connected to link", { error: "not_connected" });
1079
1324
  }
1080
1325
 
1081
1326
  function truncatePreview(text: string, max = 60) {
1082
1327
  return text.length > max ? text.slice(0, max) + "..." : text;
1083
1328
  }
1084
1329
 
1330
+ // Shared "target not found" result for the send/prompt/compact tools.
1331
+ // Returns null when the target is present, so callers can `if (miss) return miss;`.
1332
+ function targetNotFound(to: string) {
1333
+ return connectedTerminals.includes(to)
1334
+ ? null
1335
+ : textResult(
1336
+ `Terminal "${to}" not found. Connected: ${connectedTerminals.join(", ")}`,
1337
+ { to, error: "not_found" },
1338
+ );
1339
+ }
1340
+
1341
+ // Shared ✓/✗ result renderer for link_send and link_compact.
1342
+ function renderIconResult(
1343
+ result: { content: { type: string; text?: string }[]; details?: unknown },
1344
+ theme: { fg(role: string, text: string): string },
1345
+ ) {
1346
+ const txt = result.content[0];
1347
+ const details = result.details as Record<string, unknown> | undefined;
1348
+ const icon = details?.error
1349
+ ? theme.fg("error", "✗ ")
1350
+ : theme.fg("success", "✓ ");
1351
+ return new Text(icon + (txt?.type === "text" ? txt.text : ""), 0, 0);
1352
+ }
1353
+
1085
1354
  // ── Tools ────────────────────────────────────────────────────────────────
1086
1355
 
1087
1356
  pi.registerTool({
@@ -1110,11 +1379,15 @@ export default function (pi: ExtensionAPI) {
1110
1379
  if (role === "disconnected") return notConnectedResult();
1111
1380
 
1112
1381
  // 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
- );
1382
+ if (params.to !== "*") {
1383
+ if (params.to === terminalName) {
1384
+ return textResult("Cannot send to yourself", {
1385
+ to: params.to,
1386
+ error: "self_target",
1387
+ });
1388
+ }
1389
+ const miss = targetNotFound(params.to);
1390
+ if (miss) return miss;
1118
1391
  }
1119
1392
 
1120
1393
  const delivered = routeMessage({
@@ -1153,14 +1426,115 @@ export default function (pi: ExtensionAPI) {
1153
1426
  return new Text(text, 0, 0);
1154
1427
  },
1155
1428
 
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);
1429
+ renderResult: (result, _options, theme) => renderIconResult(result, theme),
1430
+ });
1431
+
1432
+ pi.registerTool({
1433
+ name: "link_compact",
1434
+ label: "Link Compact",
1435
+ description: [
1436
+ "Ask another Pi terminal to compact its context window and wait until it finishes.",
1437
+ "Returns once the target has compacted, so you can immediately send it new work.",
1438
+ "Busy targets (mid-turn or already compacting) decline; retry when idle.",
1439
+ ].join(" "),
1440
+ promptSnippet: "Ask another Pi terminal to compact its context window",
1441
+ parameters: Type.Object({
1442
+ to: Type.String({ description: "Target terminal name" }),
1443
+ instructions: Type.Optional(
1444
+ Type.String({
1445
+ description: "Optional custom compaction instructions for the target",
1446
+ }),
1447
+ ),
1448
+ }),
1449
+
1450
+ async execute(_toolCallId, params, signal) {
1451
+ if (signal?.aborted) {
1452
+ return textResult("Compact request aborted", {
1453
+ to: params.to,
1454
+ error: "aborted",
1455
+ });
1456
+ }
1457
+
1458
+ if (role === "disconnected") return notConnectedResult();
1459
+
1460
+ if (params.to === terminalName) {
1461
+ return textResult("Cannot compact yourself - use /compact.", {
1462
+ to: params.to,
1463
+ error: "self_target",
1464
+ });
1465
+ }
1466
+
1467
+ const miss = targetNotFound(params.to);
1468
+ if (miss) return miss;
1469
+
1470
+ const requestId = crypto.randomUUID();
1471
+
1472
+ return new Promise((resolve) => {
1473
+ const timeout = setTimeout(() => {
1474
+ const pending = cleanupPendingCompact(requestId);
1475
+ if (pending) {
1476
+ pending.resolve(
1477
+ textResult(
1478
+ `Compact request to "${params.to}" timed out (${COMPACT_TIMEOUT_MS / 1000}s)`,
1479
+ { to: params.to, error: "timeout" },
1480
+ ),
1481
+ );
1482
+ }
1483
+ }, COMPACT_TIMEOUT_MS);
1484
+
1485
+ pendingCompactResponses.set(requestId, {
1486
+ resolve,
1487
+ targetName: params.to,
1488
+ timeout,
1489
+ });
1490
+
1491
+ signal?.addEventListener(
1492
+ "abort",
1493
+ () => {
1494
+ const pending = cleanupPendingCompact(requestId);
1495
+ if (pending) {
1496
+ pending.resolve(
1497
+ textResult("Compact request aborted", {
1498
+ to: params.to,
1499
+ error: "aborted",
1500
+ }),
1501
+ );
1502
+ }
1503
+ },
1504
+ { once: true },
1505
+ );
1506
+
1507
+ const delivered = routeMessage({
1508
+ type: "compact_request",
1509
+ id: requestId,
1510
+ from: terminalName,
1511
+ to: params.to,
1512
+ instructions: params.instructions,
1513
+ });
1514
+
1515
+ if (!delivered && pendingCompactResponses.has(requestId)) {
1516
+ const pending = cleanupPendingCompact(requestId);
1517
+ if (pending) {
1518
+ pending.resolve(
1519
+ textResult(`Failed to request compact on "${params.to}"`, {
1520
+ to: params.to,
1521
+ error: "not_delivered",
1522
+ }),
1523
+ );
1524
+ }
1525
+ }
1526
+ });
1527
+ },
1528
+
1529
+ renderCall(args, theme) {
1530
+ let text = theme.fg("toolTitle", theme.bold("link_compact "));
1531
+ text += theme.fg("accent", String(args.to));
1532
+ if (typeof args.instructions === "string")
1533
+ text += "\n " + theme.fg("dim", truncatePreview(args.instructions));
1534
+ return new Text(text, 0, 0);
1163
1535
  },
1536
+
1537
+ renderResult: (result, _options, theme) => renderIconResult(result, theme),
1164
1538
  });
1165
1539
 
1166
1540
  pi.registerTool({
@@ -1179,6 +1553,13 @@ export default function (pi: ExtensionAPI) {
1179
1553
  }),
1180
1554
 
1181
1555
  async execute(_toolCallId, params, signal) {
1556
+ if (signal?.aborted) {
1557
+ return textResult("Prompt request aborted", {
1558
+ to: params.to,
1559
+ error: "aborted",
1560
+ });
1561
+ }
1562
+
1182
1563
  if (role === "disconnected") return notConnectedResult();
1183
1564
 
1184
1565
  if (params.to === terminalName) {
@@ -1188,12 +1569,8 @@ export default function (pi: ExtensionAPI) {
1188
1569
  });
1189
1570
  }
1190
1571
 
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
- }
1572
+ const miss = targetNotFound(params.to);
1573
+ if (miss) return miss;
1197
1574
 
1198
1575
  const requestId = crypto.randomUUID();
1199
1576
 
@@ -1302,6 +1679,7 @@ export default function (pi: ExtensionAPI) {
1302
1679
 
1303
1680
  const statuses: Record<string, string> = {};
1304
1681
  const cwds: Record<string, string> = {};
1682
+ const contexts: Record<string, ContextSnapshot> = {};
1305
1683
  const list = connectedTerminals
1306
1684
  .map((name) => {
1307
1685
  const status = getStatusFor(name);
@@ -1309,8 +1687,12 @@ export default function (pi: ExtensionAPI) {
1309
1687
  if (statusStr) statuses[name] = statusStr;
1310
1688
  const cwd = getCwdFor(name);
1311
1689
  if (cwd) cwds[name] = cwd;
1690
+ const context = getContextFor(name);
1691
+ if (context) contexts[name] = context;
1692
+ const ctxStr = formatContext(context);
1312
1693
  const marker = name === terminalName ? " (you)" : "";
1313
1694
  let line = ` \u2022 ${name}${marker}${statusStr ? " " + statusStr : ""}`;
1695
+ if (ctxStr) line += ` \u00b7 ${ctxStr}`;
1314
1696
  if (cwd) line += `\n cwd: ${cwd}`;
1315
1697
  return line;
1316
1698
  })
@@ -1320,6 +1702,7 @@ export default function (pi: ExtensionAPI) {
1320
1702
  terminals: connectedTerminals,
1321
1703
  statuses,
1322
1704
  cwds,
1705
+ contexts,
1323
1706
  self: terminalName,
1324
1707
  role,
1325
1708
  });
@@ -1331,6 +1714,7 @@ export default function (pi: ExtensionAPI) {
1331
1714
  terminals?: string[];
1332
1715
  statuses?: Record<string, string>;
1333
1716
  cwds?: Record<string, string>;
1717
+ contexts?: Record<string, ContextSnapshot>;
1334
1718
  self?: string;
1335
1719
  role?: string;
1336
1720
  }
@@ -1347,11 +1731,13 @@ export default function (pi: ExtensionAPI) {
1347
1731
  const isSelf = name === details.self;
1348
1732
  const status = details.statuses?.[name] ?? "";
1349
1733
  const cwd = details.cwds?.[name];
1734
+ const ctxStr = formatContext(details.contexts?.[name]);
1350
1735
  const nameStr = isSelf ? `\u2022 ${name} (you)` : `\u2022 ${name}`;
1351
1736
  text +=
1352
1737
  "\n " +
1353
1738
  (isSelf ? theme.fg("accent", nameStr) : theme.fg("text", nameStr)) +
1354
- (status ? " " + theme.fg("dim", status) : "");
1739
+ (status ? " " + theme.fg("dim", status) : "") +
1740
+ (ctxStr ? theme.fg("dim", " \u00b7 " + ctxStr) : "");
1355
1741
  if (cwd) text += "\n " + theme.fg("dim", `cwd: ${shortenPath(cwd)}`);
1356
1742
  }
1357
1743
  return new Text(text, 0, 0);
@@ -1371,8 +1757,10 @@ export default function (pi: ExtensionAPI) {
1371
1757
  const status = getStatusFor(name);
1372
1758
  const statusStr = status ? formatStatus(status) : "";
1373
1759
  const cwd = getCwdFor(name);
1760
+ const ctxStr = formatContext(getContextFor(name));
1374
1761
  const marker = name === terminalName ? " (you)" : "";
1375
1762
  let line = `${name}${marker}${statusStr ? ": " + statusStr : ""}`;
1763
+ if (ctxStr) line += ` \u00b7 ${ctxStr}`;
1376
1764
  if (cwd) line += `\n cwd: ${shortenPath(cwd)}`;
1377
1765
  return line;
1378
1766
  });
@@ -1386,10 +1774,10 @@ export default function (pi: ExtensionAPI) {
1386
1774
  pi.registerCommand("link-name", {
1387
1775
  description: "Change link name. No arg = use session name",
1388
1776
  handler: async (args, _ctx) => {
1389
- let newName = args.trim();
1777
+ let newName = normalizeName(args) ?? "";
1390
1778
  if (!newName) {
1391
1779
  // No argument: use session name if available
1392
- const sessionName = pi.getSessionName()?.trim().replace(/\s+/g, " ");
1780
+ const sessionName = normalizeName(pi.getSessionName());
1393
1781
  if (sessionName) {
1394
1782
  newName = sessionName;
1395
1783
  } else {
@@ -1444,6 +1832,7 @@ export default function (pi: ExtensionAPI) {
1444
1832
  name: newName,
1445
1833
  terminals: list,
1446
1834
  cwd: currentCwd,
1835
+ context: captureContext(),
1447
1836
  },
1448
1837
  terminalName,
1449
1838
  );