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/CHANGELOG.md +39 -0
- package/README.md +682 -650
- package/index.ts +475 -86
- package/package.json +1 -1
- package/skills/pi-link-coordination/SKILL.md +38 -47
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(
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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:
|
|
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
|
-
:
|
|
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
|
|
471
|
-
//
|
|
472
|
-
// result (via return false) is sufficient; no extra UI toast.
|
|
473
|
-
if (
|
|
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")
|
|
527
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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 =
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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()
|
|
1238
|
+
const sessionName = normalizeName(pi.getSessionName());
|
|
999
1239
|
if (sessionName) terminalName = sessionName;
|
|
1000
1240
|
}
|
|
1001
1241
|
}
|
|
1002
1242
|
|
|
1003
|
-
if (flagName || shouldConnect(
|
|
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 !== "*"
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
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
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
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
|
-
|
|
1192
|
-
|
|
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
|
|
1777
|
+
let newName = normalizeName(args) ?? "";
|
|
1390
1778
|
if (!newName) {
|
|
1391
1779
|
// No argument: use session name if available
|
|
1392
|
-
const sessionName = pi.getSessionName()
|
|
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
|
);
|