pi-link 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +126 -82
- package/bin/pi-link.mjs +10 -8
- package/index.ts +286 -223
- package/package.json +1 -1
- package/skills/pi-link-tools/SKILL.md +23 -47
package/index.ts
CHANGED
|
@@ -34,7 +34,10 @@ import { WebSocket, WebSocketServer } from "ws";
|
|
|
34
34
|
const MIN_PI_VERSION = [0, 84, 2];
|
|
35
35
|
|
|
36
36
|
const DEFAULT_PORT = 9900;
|
|
37
|
+
// Backstop for the manual-compaction delivery gate; see setCompacting().
|
|
37
38
|
const COMPACT_TIMEOUT_MS = 300_000;
|
|
39
|
+
// How often a target holding a remote compaction request checks whether it can start.
|
|
40
|
+
const COMPACT_WAIT_CHECK_MS = 500;
|
|
38
41
|
const RECONNECT_DELAY_MS = 2000;
|
|
39
42
|
// Bounds the HTTP Upgrade only. Without it `ws` waits forever, so a listener that
|
|
40
43
|
// accepts the socket and never answers leaves the terminal offline with no retry.
|
|
@@ -107,7 +110,7 @@ interface CompactResponseMsg {
|
|
|
107
110
|
from: string;
|
|
108
111
|
to: string;
|
|
109
112
|
ok: boolean;
|
|
110
|
-
reason?: string; //
|
|
113
|
+
reason?: string; // a code compactReasonText() reads, or error text; absent on success
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
type LinkStatus =
|
|
@@ -144,6 +147,28 @@ function piVersionSupported(version: string): boolean {
|
|
|
144
147
|
return true; // exactly the floor
|
|
145
148
|
}
|
|
146
149
|
|
|
150
|
+
/**
|
|
151
|
+
* The reason a `compact_response` gives, as the requester shows it. The wire keeps
|
|
152
|
+
* the codes; anything else, such as a runtime's own message, is shown as sent.
|
|
153
|
+
* Only targets on earlier versions answer `busy`: they declined instead of waiting.
|
|
154
|
+
*/
|
|
155
|
+
function compactReasonText(reason: string | undefined): string {
|
|
156
|
+
switch (reason) {
|
|
157
|
+
case "busy":
|
|
158
|
+
return "target busy";
|
|
159
|
+
case "not_found":
|
|
160
|
+
return "target not found";
|
|
161
|
+
case "unsupported":
|
|
162
|
+
return "compaction unavailable on target";
|
|
163
|
+
case "already_requested":
|
|
164
|
+
return "target already has a compaction request waiting or running";
|
|
165
|
+
case "tree_navigated":
|
|
166
|
+
return "target navigated its session tree while the request waited";
|
|
167
|
+
default:
|
|
168
|
+
return reason ?? "failed";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
147
172
|
// keyText is empty when app.tools.expand has no binding, and the native hint would then
|
|
148
173
|
// read " to expand" with no key in it, naming nothing the reader can press.
|
|
149
174
|
function expansionHint(dim: (text: string) => string): string {
|
|
@@ -201,14 +226,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
201
226
|
}
|
|
202
227
|
|
|
203
228
|
pi.registerFlag("link", {
|
|
204
|
-
description: "Connect to link on startup",
|
|
229
|
+
description: "Connect to the link on startup",
|
|
205
230
|
type: "boolean",
|
|
206
231
|
default: false,
|
|
207
232
|
});
|
|
208
233
|
|
|
209
234
|
pi.registerFlag("link-name", {
|
|
210
235
|
description:
|
|
211
|
-
"Set the
|
|
236
|
+
"Set the link name on startup (link identity only; does not affect session)",
|
|
212
237
|
type: "string",
|
|
213
238
|
});
|
|
214
239
|
|
|
@@ -229,8 +254,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
229
254
|
|
|
230
255
|
// Status tracking (local truth)
|
|
231
256
|
let agentRunning = false; // agent_start until agent_settled, not until agent_end
|
|
232
|
-
let compactRunning = false; //
|
|
233
|
-
|
|
257
|
+
let compactRunning = false; // gate held while running a remote compaction request
|
|
258
|
+
// The one remote compaction request this terminal holds, from receipt until it is
|
|
259
|
+
// answered. compactWaitTimer is set exactly while it waits to start.
|
|
260
|
+
let remoteCompact: CompactRequestMsg | null = null;
|
|
261
|
+
let compactWaitTimer: ReturnType<typeof setTimeout> | undefined;
|
|
262
|
+
let localCompacting = false; // gate set by Pi's manual-reason hook (human or remote)
|
|
234
263
|
let compactDeadline: ReturnType<typeof setTimeout> | undefined;
|
|
235
264
|
let wasCompactionGated = false; // gate state syncCompactionStatus() last acted on
|
|
236
265
|
// toolCallId → toolName. Pi runs tools in parallel by default and both tool
|
|
@@ -267,21 +296,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
267
296
|
};
|
|
268
297
|
let connectionAttempt: ConnectionAttempt | null = null;
|
|
269
298
|
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
content: { type: "text"; text: string }[];
|
|
276
|
-
details: Record<string, unknown>;
|
|
277
|
-
}) => void;
|
|
278
|
-
targetName: string;
|
|
279
|
-
timeout: ReturnType<typeof setTimeout>;
|
|
280
|
-
}
|
|
281
|
-
>();
|
|
299
|
+
// link_compact requests this terminal sent and still tracks: request ID → requested
|
|
300
|
+
// target. The first outcome reported ends tracking; a response arriving after that
|
|
301
|
+
// is ignored. There is no deadline, because a target may wait indefinitely before
|
|
302
|
+
// it starts.
|
|
303
|
+
const pendingCompactResponses = new Map<string, string>();
|
|
282
304
|
|
|
283
|
-
// Inbox: fixed-window batching; every batch is delivered to the receiver's model
|
|
284
|
-
|
|
305
|
+
// Inbox: fixed-window batching; every batch is delivered to the receiver's model.
|
|
306
|
+
// Each entry is one rendered block: a received chat, or the outcome of a
|
|
307
|
+
// link_compact this terminal requested.
|
|
308
|
+
const inbox: string[] = [];
|
|
285
309
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
286
310
|
|
|
287
311
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
@@ -320,7 +344,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
320
344
|
// the same condition rather than two that can disagree. In reachable states it
|
|
321
345
|
// competes only with "idle" — a compaction runs with no tool and no agent run —
|
|
322
346
|
// but where it could overlap, the gate is the more actionable fact: work sent
|
|
323
|
-
// here waits, and link_compact
|
|
347
|
+
// here waits, and so does a link_compact request.
|
|
324
348
|
if (compactionGated()) return { kind: "compacting", since: stateSince };
|
|
325
349
|
const tool = displayedTool();
|
|
326
350
|
if (tool) return { kind: "tool", toolName: tool, since: stateSince };
|
|
@@ -488,9 +512,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
488
512
|
if (inbox.length === 0) return;
|
|
489
513
|
if (!ctx) return;
|
|
490
514
|
|
|
491
|
-
// Compacting: hold everything and return WITHOUT rescheduling.
|
|
492
|
-
//
|
|
493
|
-
// would be ~1500 wakeups for no information.
|
|
515
|
+
// Compacting: hold everything and return WITHOUT rescheduling. releaseInbox()
|
|
516
|
+
// schedules delivery once both gates clear, so polling a hold that may last
|
|
517
|
+
// to the 300s ceiling would be ~1500 wakeups for no information.
|
|
494
518
|
if (compactionGated()) return;
|
|
495
519
|
|
|
496
520
|
// Select batch: up to BATCH_MAX_ITEMS, ~BATCH_MAX_CHARS total (soft cap —
|
|
@@ -498,8 +522,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
498
522
|
const batch: string[] = [];
|
|
499
523
|
let totalChars = 0;
|
|
500
524
|
for (let i = 0; i < inbox.length && batch.length < BATCH_MAX_ITEMS; i++) {
|
|
501
|
-
const
|
|
502
|
-
const text = `From "${item.from}":\n${item.content}`;
|
|
525
|
+
const text = inbox[i];
|
|
503
526
|
if (batch.length > 0 && totalChars + text.length > BATCH_MAX_CHARS) break;
|
|
504
527
|
batch.push(text);
|
|
505
528
|
totalChars += text.length;
|
|
@@ -526,14 +549,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
526
549
|
* True exactly while delivery is deferred. Also what the terminal reports as its
|
|
527
550
|
* status, so availability and delivery cannot disagree.
|
|
528
551
|
*
|
|
529
|
-
* The two flags are not duplication. compactRunning is set synchronously
|
|
530
|
-
*
|
|
531
|
-
* before session_before_compact arrives
|
|
532
|
-
*
|
|
552
|
+
* The two flags are not duplication. compactRunning is set synchronously when a
|
|
553
|
+
* remote request starts, before ctx.compact() is called, covering the window
|
|
554
|
+
* before session_before_compact arrives. localCompacting is set by that hook for
|
|
555
|
+
* reason "manual", whether initiated by a human or a remote request that reaches
|
|
556
|
+
* the hook. It can survive a failed compaction, so a gate is not proof of a live
|
|
557
|
+
* compaction process.
|
|
533
558
|
*
|
|
534
559
|
* Both are load-bearing because Pi will not save us here: AgentSession.prompt()
|
|
535
|
-
* refuses to run during compaction, but sendCustomMessage
|
|
536
|
-
*
|
|
560
|
+
* refuses to run during compaction, but sendCustomMessage starts a run directly,
|
|
561
|
+
* bypassing prompt()'s guard.
|
|
537
562
|
*/
|
|
538
563
|
function compactionGated() {
|
|
539
564
|
return localCompacting || compactRunning;
|
|
@@ -560,14 +585,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
560
585
|
}
|
|
561
586
|
|
|
562
587
|
/**
|
|
563
|
-
*
|
|
588
|
+
* Schedule inbox delivery once NO gate remains. Call after clearing either flag.
|
|
564
589
|
*
|
|
565
|
-
* Both gates must be checked together, because a remote compact
|
|
566
|
-
* ctx.compact() reaches Pi's compact(), which reports reason "manual"
|
|
567
|
-
* session_before_compact sets localCompacting on top of compactRunning
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
590
|
+
* Both gates must be checked together, because a remote compact can set both:
|
|
591
|
+
* ctx.compact() reaches Pi's compact(), which reports reason "manual". If it reaches
|
|
592
|
+
* session_before_compact, that hook sets localCompacting on top of compactRunning;
|
|
593
|
+
* an early rejection may never reach the hook. Pi emits session_compact strictly
|
|
594
|
+
* before it resolves and fires onComplete, so releasing on either flag alone can
|
|
595
|
+
* arm a flush that finds the other flag still standing, returns without
|
|
596
|
+
* rescheduling, and strands the inbox with no release left.
|
|
571
597
|
*/
|
|
572
598
|
function releaseInbox() {
|
|
573
599
|
if (!localCompacting && !compactRunning && inbox.length > 0) {
|
|
@@ -576,7 +602,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
576
602
|
}
|
|
577
603
|
|
|
578
604
|
/**
|
|
579
|
-
* Gate and release inbox delivery
|
|
605
|
+
* Gate and release inbox delivery for Pi's manual-reason compaction hook.
|
|
580
606
|
*
|
|
581
607
|
* The deadline is the only backstop pi-link uses. Pi 0.84.3+ reports failure and
|
|
582
608
|
* abort through `session_compact_failed`, which pi-link does not handle (see
|
|
@@ -584,9 +610,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
584
610
|
* compaction-ending event it handles. The timer handle must be explicit
|
|
585
611
|
* and cleared on every transition: a bare setTimeout outlives its own
|
|
586
612
|
* compaction and would release a *later* compaction's flag.
|
|
587
|
-
*
|
|
588
|
-
* COMPACT_TIMEOUT_MS also bounds the remote-request wait. The two share a
|
|
589
|
-
* value, not a meaning: nothing here depends on their being equal.
|
|
590
613
|
*/
|
|
591
614
|
function setCompacting(on: boolean) {
|
|
592
615
|
localCompacting = on;
|
|
@@ -594,7 +617,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
594
617
|
compactDeadline = on
|
|
595
618
|
? setTimeout(() => setCompacting(false), COMPACT_TIMEOUT_MS)
|
|
596
619
|
: undefined;
|
|
597
|
-
// Release
|
|
620
|
+
// Release schedules delivery; it never polls.
|
|
598
621
|
if (!on) releaseInbox();
|
|
599
622
|
syncCompactionStatus();
|
|
600
623
|
}
|
|
@@ -611,12 +634,119 @@ export default function (pi: ExtensionAPI) {
|
|
|
611
634
|
|
|
612
635
|
// ── Pending compact helpers ──────────────────────────────────────────────
|
|
613
636
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
637
|
+
/**
|
|
638
|
+
* End tracking of a link_compact request and report its outcome to this terminal's
|
|
639
|
+
* model through the inbox, so it is delivered like a received message. The line
|
|
640
|
+
* names the requested target, never a response's `from`: a hub-synthesized
|
|
641
|
+
* not_found comes from the hub. It carries no `From` header, because the outcome
|
|
642
|
+
* may come from the hub or from this terminal rather than from the target.
|
|
643
|
+
*/
|
|
644
|
+
function reportCompact(requestId: string, outcome: string) {
|
|
645
|
+
const targetName = pendingCompactResponses.get(requestId);
|
|
646
|
+
if (targetName === undefined) return;
|
|
618
647
|
pendingCompactResponses.delete(requestId);
|
|
619
|
-
|
|
648
|
+
inbox.push(`link_compact "${targetName}" [${requestId}]: ${outcome}`);
|
|
649
|
+
scheduleFlush(FLUSH_DELAY_MS);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ── Remote compaction requests (target side) ─────────────────────────────
|
|
653
|
+
|
|
654
|
+
function answerCompact(
|
|
655
|
+
request: CompactRequestMsg,
|
|
656
|
+
ok: boolean,
|
|
657
|
+
reason?: string,
|
|
658
|
+
) {
|
|
659
|
+
routeMessage({
|
|
660
|
+
type: "compact_response",
|
|
661
|
+
id: request.id,
|
|
662
|
+
from: terminalName,
|
|
663
|
+
to: request.from,
|
|
664
|
+
ok,
|
|
665
|
+
reason,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Whether a remote request has to wait before it starts. Pi's idle state is the
|
|
671
|
+
* authority on whether this terminal is working; agentRunning is not: Pi may still
|
|
672
|
+
* retry, run an automatic compaction, or drain a queued continuation inside a run
|
|
673
|
+
* whose agent_end already fired, and compact() would abort that work. Pi 1.2's
|
|
674
|
+
* idle flag also covers its own compactions; at the 0.84.2 floor it covers only
|
|
675
|
+
* the agent run. Our gates extend beyond that: a manual compaction's
|
|
676
|
+
* localCompacting can remain after a failure, until its backstop.
|
|
677
|
+
*/
|
|
678
|
+
function remoteCompactMustWait(context: ExtensionContext) {
|
|
679
|
+
return !context.isIdle() || compactionGated();
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Start the held remote request once the terminal is free, checking again every
|
|
684
|
+
* COMPACT_WAIT_CHECK_MS until then. Polling, because no Pi event marks every
|
|
685
|
+
* moment the terminal becomes free: session_compact fires before Pi clears its
|
|
686
|
+
* compacting state, and a gate left by a failed compaction is released by its
|
|
687
|
+
* backstop or the next run. There is no deadline. Waiting raises no gate, so
|
|
688
|
+
* messages are still delivered, and one delivered into Pi's work keeps the
|
|
689
|
+
* terminal busy. One still in the inbox's batching window does not: it can lose
|
|
690
|
+
* to the start, and is then held until the compaction ends.
|
|
691
|
+
*
|
|
692
|
+
* Observing idle and then calling ctx.compact() is not atomic: work another
|
|
693
|
+
* extension or Pi itself starts in between can still be aborted by compact().
|
|
694
|
+
* A request served on arrival has always had the same window.
|
|
695
|
+
*/
|
|
696
|
+
function startRemoteCompactWhenFree(
|
|
697
|
+
request: CompactRequestMsg,
|
|
698
|
+
context: ExtensionContext,
|
|
699
|
+
) {
|
|
700
|
+
if (remoteCompactMustWait(context)) {
|
|
701
|
+
compactWaitTimer = setTimeout(
|
|
702
|
+
() => startRemoteCompactWhenFree(request, context),
|
|
703
|
+
COMPACT_WAIT_CHECK_MS,
|
|
704
|
+
);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
compactWaitTimer = undefined;
|
|
708
|
+
let finished = false;
|
|
709
|
+
const finish = (ok: boolean, reason?: string) => {
|
|
710
|
+
if (finished) return;
|
|
711
|
+
finished = true;
|
|
712
|
+
remoteCompact = null;
|
|
713
|
+
compactRunning = false;
|
|
714
|
+
releaseInbox(); // last gate may clear here, after session_compact already fired
|
|
715
|
+
// Only reverts status if localCompacting is also clear. A failure after
|
|
716
|
+
// session_before_compact leaves it standing, so the terminal keeps
|
|
717
|
+
// reporting the hold as compacting until the deadline, agent_start,
|
|
718
|
+
// or a later successful compaction.
|
|
719
|
+
syncCompactionStatus();
|
|
720
|
+
answerCompact(request, ok, reason);
|
|
721
|
+
};
|
|
722
|
+
compactRunning = true;
|
|
723
|
+
syncCompactionStatus();
|
|
724
|
+
// Pi reports the compaction's outcome through these callbacks once compact()
|
|
725
|
+
// settles; finish() clears compactRunning and answers the request, once. A
|
|
726
|
+
// failure is answered, not retried.
|
|
727
|
+
try {
|
|
728
|
+
context.compact({
|
|
729
|
+
customInstructions: request.instructions,
|
|
730
|
+
onComplete: () => finish(true),
|
|
731
|
+
onError: (e) =>
|
|
732
|
+
finish(false, e instanceof Error ? e.message : String(e)),
|
|
733
|
+
});
|
|
734
|
+
} catch (e) {
|
|
735
|
+
finish(false, e instanceof Error ? e.message : String(e));
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* End the held remote request if it is still waiting, and return it. A running
|
|
741
|
+
* one is left to finish.
|
|
742
|
+
*/
|
|
743
|
+
function dropWaitingRemoteCompact() {
|
|
744
|
+
if (compactWaitTimer === undefined) return null;
|
|
745
|
+
clearTimeout(compactWaitTimer);
|
|
746
|
+
compactWaitTimer = undefined;
|
|
747
|
+
const request = remoteCompact;
|
|
748
|
+
remoteCompact = null;
|
|
749
|
+
return request;
|
|
620
750
|
}
|
|
621
751
|
|
|
622
752
|
function allTerminalNames(): Set<string> {
|
|
@@ -718,9 +848,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
718
848
|
|
|
719
849
|
/**
|
|
720
850
|
* Route a message to its destination. Works in both hub and client roles.
|
|
721
|
-
* Returns true
|
|
722
|
-
*
|
|
723
|
-
* still reject via protocol-level error responses
|
|
851
|
+
* Returns true after local handling or handoff to a connection, not a receipt ACK.
|
|
852
|
+
* The hub resolves the recipient locally; a client hands routing to the hub,
|
|
853
|
+
* which may still reject via protocol-level error responses.
|
|
724
854
|
*/
|
|
725
855
|
function routeMessage(
|
|
726
856
|
msg: ChatMsg | CompactRequestMsg | CompactResponseMsg,
|
|
@@ -754,7 +884,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
754
884
|
|
|
755
885
|
if (msg.from === terminalName) {
|
|
756
886
|
// For compact_request, deliver the error response locally so the
|
|
757
|
-
//
|
|
887
|
+
// pending request reports it. For chat, skip — the tool result
|
|
758
888
|
// (via return false) is sufficient; no extra UI toast.
|
|
759
889
|
if (errorMsg.type === "compact_response") handleIncoming(errorMsg);
|
|
760
890
|
} else {
|
|
@@ -798,7 +928,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
798
928
|
}
|
|
799
929
|
updateStatus();
|
|
800
930
|
notify(
|
|
801
|
-
`Joined link as "${terminalName}" (${visibleTerminals().length} online)`,
|
|
931
|
+
`Joined the link as "${terminalName}" (${visibleTerminals().length} online)`,
|
|
802
932
|
"info",
|
|
803
933
|
);
|
|
804
934
|
pushStatus(true);
|
|
@@ -819,17 +949,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
819
949
|
terminalStatuses.delete(msg.name);
|
|
820
950
|
terminalCwds.delete(msg.name);
|
|
821
951
|
terminalContexts.delete(msg.name);
|
|
822
|
-
//
|
|
823
|
-
for (const [id,
|
|
824
|
-
if (
|
|
825
|
-
|
|
826
|
-
pending.resolve(
|
|
827
|
-
textResult(`Terminal "${msg.name}" disconnected`, {
|
|
828
|
-
to: msg.name,
|
|
829
|
-
error: "disconnected",
|
|
830
|
-
}),
|
|
831
|
-
);
|
|
832
|
-
}
|
|
952
|
+
// A departed target can no longer answer; whether it compacted is unknown.
|
|
953
|
+
for (const [id, targetName] of pendingCompactResponses) {
|
|
954
|
+
if (targetName === msg.name)
|
|
955
|
+
reportCompact(id, "left the link before answering; result unknown");
|
|
833
956
|
}
|
|
834
957
|
updateStatus();
|
|
835
958
|
if (groupOf(msg.name) === groupOf(terminalName))
|
|
@@ -845,95 +968,41 @@ export default function (pi: ExtensionAPI) {
|
|
|
845
968
|
|
|
846
969
|
// ── Chat message ──
|
|
847
970
|
case "chat":
|
|
848
|
-
inbox.push({
|
|
971
|
+
inbox.push(`From "${msg.from}":\n${msg.content}`);
|
|
849
972
|
scheduleFlush(FLUSH_DELAY_MS);
|
|
850
973
|
break;
|
|
851
974
|
|
|
852
975
|
// ── Another terminal asks us to compact our context ──
|
|
853
976
|
case "compact_request": {
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
type: "compact_response",
|
|
858
|
-
id,
|
|
859
|
-
from: terminalName,
|
|
860
|
-
to: from,
|
|
861
|
-
ok,
|
|
862
|
-
reason,
|
|
863
|
-
});
|
|
864
|
-
// Answered before the busy question, and not through finish(): no capability
|
|
865
|
-
// and no context are refusals of a request we never took on, so nothing here
|
|
866
|
-
// owns the gate to clear or the inbox to release.
|
|
977
|
+
// Answered at once, without taking the request on: with no capability or no
|
|
978
|
+
// context there is nothing to wait for, and a second request is refused
|
|
979
|
+
// rather than queued, merged or allowed to replace the held one.
|
|
867
980
|
if (!ctx || !ctx.compact) {
|
|
868
|
-
|
|
981
|
+
answerCompact(msg, false, "unsupported");
|
|
869
982
|
break;
|
|
870
983
|
}
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
// drain a queued continuation inside a run whose agent_end already fired, and
|
|
874
|
-
// compact() would abort that work and compact the same branch a second time.
|
|
875
|
-
// compactionGated() adds what Pi's idle flag cannot cover — a manual
|
|
876
|
-
// compaction is not an agent run — and keeps declining while either gate
|
|
877
|
-
// stands, so we never touch a compaction we did not start.
|
|
878
|
-
if (!ctx.isIdle() || compactionGated()) {
|
|
879
|
-
respond(false, "busy");
|
|
984
|
+
if (remoteCompact) {
|
|
985
|
+
answerCompact(msg, false, "already_requested");
|
|
880
986
|
break;
|
|
881
987
|
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
// keeps reporting compacting until the deadline or agent_start.
|
|
891
|
-
syncCompactionStatus();
|
|
892
|
-
respond(ok, reason);
|
|
893
|
-
};
|
|
894
|
-
compactRunning = true;
|
|
895
|
-
syncCompactionStatus();
|
|
896
|
-
notify(`"${from}" requested compact`, "info");
|
|
897
|
-
// compact() aborts the current turn first, so the idle guard above
|
|
898
|
-
// keeps us from interrupting active work. Pi reports the compaction's
|
|
899
|
-
// outcome through these callbacks once compact() settles; finish() clears
|
|
900
|
-
// compactRunning and answers the request.
|
|
901
|
-
try {
|
|
902
|
-
ctx.compact({
|
|
903
|
-
customInstructions: msg.instructions,
|
|
904
|
-
onComplete: () => finish(true),
|
|
905
|
-
onError: (e) =>
|
|
906
|
-
finish(false, e instanceof Error ? e.message : String(e)),
|
|
907
|
-
});
|
|
908
|
-
} catch (e) {
|
|
909
|
-
finish(false, e instanceof Error ? e.message : String(e));
|
|
910
|
-
}
|
|
988
|
+
remoteCompact = msg;
|
|
989
|
+
notify(
|
|
990
|
+
remoteCompactMustWait(ctx)
|
|
991
|
+
? `"${msg.from}" asked this terminal to compact; waiting until it is idle`
|
|
992
|
+
: `"${msg.from}" asked this terminal to compact`,
|
|
993
|
+
"info",
|
|
994
|
+
);
|
|
995
|
+
startRemoteCompactWhenFree(msg, ctx);
|
|
911
996
|
break;
|
|
912
997
|
}
|
|
913
998
|
|
|
914
999
|
// ── Response to a compact we requested ──
|
|
915
|
-
case "compact_response":
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
const target = pending.targetName;
|
|
921
|
-
if (msg.ok) {
|
|
922
|
-
pending.resolve(
|
|
923
|
-
textResult(`Compacted "${target}"`, { to: target }),
|
|
924
|
-
);
|
|
925
|
-
} else {
|
|
926
|
-
const reason = msg.reason ?? "failed";
|
|
927
|
-
pending.resolve(
|
|
928
|
-
textResult(`Compact on "${target}" not done: ${reason}`, {
|
|
929
|
-
to: target,
|
|
930
|
-
error: reason,
|
|
931
|
-
}),
|
|
932
|
-
);
|
|
933
|
-
}
|
|
934
|
-
}
|
|
1000
|
+
case "compact_response":
|
|
1001
|
+
reportCompact(
|
|
1002
|
+
msg.id,
|
|
1003
|
+
msg.ok ? "compacted" : `not done: ${compactReasonText(msg.reason)}`,
|
|
1004
|
+
);
|
|
935
1005
|
break;
|
|
936
|
-
}
|
|
937
1006
|
|
|
938
1007
|
case "error":
|
|
939
1008
|
notify(`Link: ${msg.message}`, "error");
|
|
@@ -1115,7 +1184,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1115
1184
|
});
|
|
1116
1185
|
|
|
1117
1186
|
server.on("error", () => {
|
|
1118
|
-
//
|
|
1187
|
+
// Listen failed; a port conflict is possible, but does not prove another hub.
|
|
1119
1188
|
settle(false);
|
|
1120
1189
|
});
|
|
1121
1190
|
|
|
@@ -1193,7 +1262,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1193
1262
|
updateStatus();
|
|
1194
1263
|
|
|
1195
1264
|
if (!manuallyDisconnected) {
|
|
1196
|
-
notify("Disconnected from link hub", "warning");
|
|
1265
|
+
notify("Disconnected from the link hub; reconnecting...", "warning");
|
|
1197
1266
|
scheduleReconnect();
|
|
1198
1267
|
}
|
|
1199
1268
|
});
|
|
@@ -1243,7 +1312,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1243
1312
|
if (await startHub(attempt)) return;
|
|
1244
1313
|
if (!attemptIsCurrent(attempt)) return;
|
|
1245
1314
|
|
|
1246
|
-
//
|
|
1315
|
+
// Establishment failed (e.g. a race, port conflict, or other listen failure).
|
|
1316
|
+
// Retry after delay.
|
|
1247
1317
|
scheduleReconnect();
|
|
1248
1318
|
} finally {
|
|
1249
1319
|
// Only while still the owner: an attempt cancelled mid-flight must not clear
|
|
@@ -1297,15 +1367,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
1297
1367
|
// This also clears the reconnect and startup timers.
|
|
1298
1368
|
cancelConnectionAttempt();
|
|
1299
1369
|
|
|
1300
|
-
//
|
|
1370
|
+
// A remote request still waiting is dropped unanswered: the link it would answer
|
|
1371
|
+
// over is closing, and its requester sees this terminal leave. A running one
|
|
1372
|
+
// keeps its slot until it finishes. Clear the remote-request gate; the
|
|
1373
|
+
// manual-reason hook's gate survives disconnect.
|
|
1374
|
+
dropWaitingRemoteCompact();
|
|
1301
1375
|
compactRunning = false;
|
|
1302
1376
|
// Runs before role is cleared, so peers still get a final status; more to the
|
|
1303
1377
|
// point, the local gate record stays honest for the reconnect.
|
|
1304
1378
|
syncCompactionStatus();
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1379
|
+
// A response could only arrive over the link being closed. The inbox survives
|
|
1380
|
+
// disconnect, so these are delivered once no gate holds it; on shutdown,
|
|
1381
|
+
// cleanup() clears them with the rest of the inbox.
|
|
1382
|
+
for (const id of pendingCompactResponses.keys()) {
|
|
1383
|
+
reportCompact(
|
|
1384
|
+
id,
|
|
1385
|
+
"link disconnected before an answer; result unknown, the target may still be compacting",
|
|
1309
1386
|
);
|
|
1310
1387
|
}
|
|
1311
1388
|
|
|
@@ -1333,7 +1410,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1333
1410
|
lastPushedStatus = null;
|
|
1334
1411
|
updateStatus();
|
|
1335
1412
|
|
|
1336
|
-
// Inbox survives disconnect;
|
|
1413
|
+
// Inbox survives disconnect; schedule delivery unless localCompacting still holds it.
|
|
1337
1414
|
releaseInbox();
|
|
1338
1415
|
}
|
|
1339
1416
|
|
|
@@ -1448,14 +1525,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
1448
1525
|
// There is deliberately no abort listener: event.signal firing is not an ending.
|
|
1449
1526
|
// Pi passes that signal into the summarizer and its compaction controller lives
|
|
1450
1527
|
// until the catch/finally, so releasing on abort would re-open delivery while the
|
|
1451
|
-
// aborted compaction is still unwinding. A cancelled compaction is released
|
|
1452
|
-
// the
|
|
1528
|
+
// aborted compaction is still unwinding. A cancelled compaction's gate is released
|
|
1529
|
+
// by the next run (agent_start), the deadline, or a later successful compaction.
|
|
1453
1530
|
//
|
|
1454
|
-
// Accepted gap: compact() aborts
|
|
1531
|
+
// Accepted gap: compact() aborts and prepares *before* emitting this
|
|
1455
1532
|
// event, so a flush in that window can still start a turn against context about
|
|
1456
1533
|
// to be rebuilt. The message itself survives — it is persisted and restored — so
|
|
1457
1534
|
// only the turn is wasted. No heuristics to guess at the window. This is a
|
|
1458
|
-
//
|
|
1535
|
+
// human /compact limit only; remote compaction is already gated by
|
|
1459
1536
|
// compactRunning, set before ctx.compact() is ever called.
|
|
1460
1537
|
if (event.reason === "manual") setCompacting(true);
|
|
1461
1538
|
});
|
|
@@ -1471,6 +1548,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1471
1548
|
});
|
|
1472
1549
|
|
|
1473
1550
|
pi.on("session_tree", async () => {
|
|
1551
|
+
// A completed navigation moved the session off the branch a waiting request
|
|
1552
|
+
// was made for. Only a waiting request is ended; a running one is left to
|
|
1553
|
+
// finish and answer.
|
|
1554
|
+
const waiting = dropWaitingRemoteCompact();
|
|
1555
|
+
if (waiting) answerCompact(waiting, false, "tree_navigated");
|
|
1474
1556
|
pushStatus(true); // context usage belongs to the active branch
|
|
1475
1557
|
});
|
|
1476
1558
|
|
|
@@ -1518,7 +1600,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1518
1600
|
}
|
|
1519
1601
|
|
|
1520
1602
|
function notConnectedResult() {
|
|
1521
|
-
return textResult("Not connected to link", { error: "not_connected" });
|
|
1603
|
+
return textResult("Not connected to the link", { error: "not_connected" });
|
|
1522
1604
|
}
|
|
1523
1605
|
|
|
1524
1606
|
// Collapsed one-line preview for the two outgoing tools. Whitespace is normalized so a
|
|
@@ -1575,8 +1657,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1575
1657
|
name: "link_send",
|
|
1576
1658
|
label: "Link Send",
|
|
1577
1659
|
description: [
|
|
1578
|
-
"Send a message to one other Pi terminal on the link.",
|
|
1660
|
+
"Send a message to one other Pi terminal on the link, using its exact name from link_list.",
|
|
1579
1661
|
"The message always acts: it steers a busy receiver at its next safe boundary, or starts a turn on an idle one.",
|
|
1662
|
+
"To reply to a chat, use link_send to the sender in that chat's From block, even if it arrived while you were busy or alongside other chats in a batch; an ordinary reply stays local.",
|
|
1663
|
+
"Replies and compaction outcomes start a turn if you are idle, so no polling or sleeping is needed to receive them; link_list reports status, not replies or outcomes.",
|
|
1580
1664
|
].join(" "),
|
|
1581
1665
|
promptSnippet:
|
|
1582
1666
|
"Send a message to another Pi terminal on the local link network",
|
|
@@ -1606,12 +1690,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
1606
1690
|
|
|
1607
1691
|
const target = `"${params.to}"`;
|
|
1608
1692
|
if (!delivered) {
|
|
1609
|
-
|
|
1693
|
+
// The hub found no reachable target; a client's hub connection is not open.
|
|
1694
|
+
const reason =
|
|
1695
|
+
role === "hub" ? "target not found" : "connection to the link hub is not open";
|
|
1696
|
+
return textResult(`Failed to send to ${target}: ${reason}`, {
|
|
1610
1697
|
to: params.to,
|
|
1611
1698
|
error: "not_delivered",
|
|
1612
1699
|
});
|
|
1613
1700
|
}
|
|
1614
|
-
//
|
|
1701
|
+
// The hub hands off to the target connection; clients hand routing to the hub.
|
|
1702
|
+
// Neither transport handoff confirms receipt.
|
|
1615
1703
|
const verb = role === "hub" ? "Sent to" : "Sent to hub for delivery to";
|
|
1616
1704
|
return textResult(`${verb} ${target}`, { to: params.to });
|
|
1617
1705
|
},
|
|
@@ -1640,8 +1728,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1640
1728
|
name: "link_compact",
|
|
1641
1729
|
label: "Link Compact",
|
|
1642
1730
|
description: [
|
|
1643
|
-
"Ask another Pi terminal to compact its context window
|
|
1644
|
-
"
|
|
1731
|
+
"Ask another Pi terminal to compact its context window.",
|
|
1732
|
+
"Returns as soon as the request is sent, with its request ID; the outcome arrives later as a link notification naming the target and that ID.",
|
|
1733
|
+
"The target waits, with no deadline, until Pi reports its session idle and no compaction holds its gate, then compacts; an active run, retry, automatic compaction, queued continuation or reported `compacting` delays it, and the outcome can take that long.",
|
|
1734
|
+
"A target holds one request at a time and declines another while one waits or runs.",
|
|
1645
1735
|
].join(" "),
|
|
1646
1736
|
promptSnippet: "Ask another Pi terminal to compact its context window",
|
|
1647
1737
|
parameters: Type.Object({
|
|
@@ -1674,62 +1764,31 @@ export default function (pi: ExtensionAPI) {
|
|
|
1674
1764
|
if (miss) return miss;
|
|
1675
1765
|
|
|
1676
1766
|
const requestId = crypto.randomUUID();
|
|
1767
|
+
// Tracked before routing, because the hub can synthesize a response in this
|
|
1768
|
+
// same stack. From here on the request belongs to the extension, not to this
|
|
1769
|
+
// call: the signal is not consulted again, and there is no remote cancel.
|
|
1770
|
+
pendingCompactResponses.set(requestId, params.to);
|
|
1677
1771
|
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
{ to: params.to, error: "timeout" },
|
|
1686
|
-
),
|
|
1687
|
-
);
|
|
1688
|
-
}
|
|
1689
|
-
}, COMPACT_TIMEOUT_MS);
|
|
1690
|
-
|
|
1691
|
-
pendingCompactResponses.set(requestId, {
|
|
1692
|
-
resolve,
|
|
1693
|
-
targetName: params.to,
|
|
1694
|
-
timeout,
|
|
1695
|
-
});
|
|
1772
|
+
const delivered = routeMessage({
|
|
1773
|
+
type: "compact_request",
|
|
1774
|
+
id: requestId,
|
|
1775
|
+
from: terminalName,
|
|
1776
|
+
to: params.to,
|
|
1777
|
+
instructions: params.instructions,
|
|
1778
|
+
});
|
|
1696
1779
|
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
textResult("Compact request aborted", {
|
|
1704
|
-
to: params.to,
|
|
1705
|
-
error: "aborted",
|
|
1706
|
-
}),
|
|
1707
|
-
);
|
|
1708
|
-
}
|
|
1709
|
-
},
|
|
1710
|
-
{ once: true },
|
|
1780
|
+
// Still tracked means it was never handed on. No longer tracked means it was
|
|
1781
|
+
// answered in this stack, and that outcome is already in the inbox.
|
|
1782
|
+
if (!delivered && pendingCompactResponses.delete(requestId)) {
|
|
1783
|
+
return textResult(
|
|
1784
|
+
`Failed to request compact on "${params.to}": connection to the link hub is not open`,
|
|
1785
|
+
{ to: params.to, error: "not_delivered" },
|
|
1711
1786
|
);
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
to: params.to,
|
|
1718
|
-
instructions: params.instructions,
|
|
1719
|
-
});
|
|
1720
|
-
|
|
1721
|
-
if (!delivered) {
|
|
1722
|
-
const pending = cleanupPendingCompact(requestId);
|
|
1723
|
-
if (pending) {
|
|
1724
|
-
pending.resolve(
|
|
1725
|
-
textResult(`Failed to request compact on "${params.to}"`, {
|
|
1726
|
-
to: params.to,
|
|
1727
|
-
error: "not_delivered",
|
|
1728
|
-
}),
|
|
1729
|
-
);
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
});
|
|
1787
|
+
}
|
|
1788
|
+
return textResult(
|
|
1789
|
+
`Compact request sent to "${params.to}" [${requestId}]; the result will arrive as a link notification.`,
|
|
1790
|
+
{ to: params.to, id: requestId },
|
|
1791
|
+
);
|
|
1733
1792
|
},
|
|
1734
1793
|
|
|
1735
1794
|
renderCall(args, theme, context) {
|
|
@@ -1810,7 +1869,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1810
1869
|
|
|
1811
1870
|
let text = theme.fg("toolTitle", theme.bold("link "));
|
|
1812
1871
|
text += theme.fg("muted", `(${details.role}) `);
|
|
1813
|
-
|
|
1872
|
+
const count = details.terminals.length;
|
|
1873
|
+
text += theme.fg("accent", `${count} terminal${count !== 1 ? "s" : ""}`);
|
|
1814
1874
|
for (const name of details.terminals) {
|
|
1815
1875
|
const isSelf = name === details.self;
|
|
1816
1876
|
const status = details.statuses?.[name] ?? "";
|
|
@@ -1857,7 +1917,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1857
1917
|
});
|
|
1858
1918
|
|
|
1859
1919
|
pi.registerCommand("link-name", {
|
|
1860
|
-
description: "Change link name
|
|
1920
|
+
description: "Change link name (without an argument, use the session name)",
|
|
1861
1921
|
handler: async (args, _ctx) => {
|
|
1862
1922
|
let newName = normalizeName(args) ?? "";
|
|
1863
1923
|
if (!newName) {
|
|
@@ -1946,15 +2006,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
1946
2006
|
handler: async (_args, _ctx) => {
|
|
1947
2007
|
pi.appendEntry("link-active", { active: false });
|
|
1948
2008
|
manuallyDisconnected = true;
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
_ctx.ui.notify("Link disconnected", "info");
|
|
1954
|
-
return;
|
|
1955
|
-
}
|
|
2009
|
+
// Also when nothing is established: a startup or reconnect attempt may still
|
|
2010
|
+
// be dialing or binding, and compact requests sent before the hub was lost are
|
|
2011
|
+
// still tracked. Persisted intent has to win over both.
|
|
2012
|
+
const wasConnected = role !== "disconnected";
|
|
1956
2013
|
disconnect();
|
|
1957
|
-
_ctx.ui.notify(
|
|
2014
|
+
_ctx.ui.notify(
|
|
2015
|
+
wasConnected
|
|
2016
|
+
? "Disconnected from the link; automatic reconnect disabled"
|
|
2017
|
+
: "Already disconnected; automatic reconnect disabled",
|
|
2018
|
+
"info",
|
|
2019
|
+
);
|
|
1958
2020
|
},
|
|
1959
2021
|
});
|
|
1960
2022
|
|
|
@@ -1977,10 +2039,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1977
2039
|
// ── Message renderer ─────────────────────────────────────────────────────
|
|
1978
2040
|
|
|
1979
2041
|
pi.registerMessageRenderer("link", (message, options, theme) => {
|
|
1980
|
-
|
|
1981
|
-
|
|
2042
|
+
// Only messages restored from older sessions carry a sender in details.from. A
|
|
2043
|
+
// current batch has none: its content already opens with `[Link: ...]`.
|
|
2044
|
+
const from = (message.details as Record<string, unknown> | undefined)?.from;
|
|
1982
2045
|
const text =
|
|
1983
|
-
theme.fg("accent", `⚡ [${from}] `) +
|
|
2046
|
+
theme.fg("accent", from === undefined ? "⚡ " : `⚡ [${from}] `) +
|
|
1984
2047
|
theme.fg("text", String(message.content));
|
|
1985
2048
|
const content = new Text(text, 0, 0);
|
|
1986
2049
|
// The same panel Pi draws around extension messages by default: returning our own
|