pi-link 0.2.0 → 0.4.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 +64 -0
- package/README.md +719 -680
- package/bin/pi-link.mjs +238 -40
- package/index.ts +547 -550
- package/package.json +1 -1
- package/skills/pi-link-coordination/SKILL.md +124 -114
package/index.ts
CHANGED
|
@@ -6,31 +6,38 @@
|
|
|
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,
|
|
10
|
-
* Commands: /link, /link-name, /link-
|
|
9
|
+
* Tools: link_send, link_list, link_compact
|
|
10
|
+
* Commands: /link, /link-name, /link-connect, /link-disconnect
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
import {
|
|
14
|
+
VERSION as PI_VERSION,
|
|
15
|
+
type ExtensionAPI,
|
|
16
|
+
type ExtensionContext,
|
|
16
17
|
} from "@earendil-works/pi-coding-agent";
|
|
17
18
|
import { Text } from "@earendil-works/pi-tui";
|
|
18
19
|
import { Type } from "typebox";
|
|
19
20
|
import * as crypto from "node:crypto";
|
|
21
|
+
import { createServer, type Server as HttpServer } from "node:http";
|
|
20
22
|
import * as os from "node:os";
|
|
21
23
|
|
|
22
24
|
import { WebSocket, WebSocketServer } from "ws";
|
|
23
25
|
|
|
24
26
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
25
27
|
|
|
28
|
+
// Pi 0.84.2 is the floor: `agent_settled` and `ctx.isIdle()` are the whole basis of
|
|
29
|
+
// the settled lifecycle and the remote-compaction guard below, and there is one code
|
|
30
|
+
// path for them. Package installation does not check the host version, so an older
|
|
31
|
+
// Pi installs pi-link successfully and must be refused at load.
|
|
32
|
+
const MIN_PI_VERSION = [0, 84, 2];
|
|
33
|
+
|
|
26
34
|
const DEFAULT_PORT = 9900;
|
|
27
|
-
const PROMPT_INACTIVITY_MS = 90_000;
|
|
28
|
-
const PROMPT_HARD_CEILING_MS = 1_800_000;
|
|
29
35
|
const COMPACT_TIMEOUT_MS = 180_000;
|
|
30
36
|
const RECONNECT_DELAY_MS = 2000;
|
|
31
|
-
|
|
37
|
+
// Bounds the HTTP Upgrade only. Without it `ws` waits forever, so a listener that
|
|
38
|
+
// accepts the socket and never answers leaves the terminal offline with no retry.
|
|
39
|
+
const CONNECT_HANDSHAKE_TIMEOUT_MS = 5_000;
|
|
32
40
|
const FLUSH_DELAY_MS = 200;
|
|
33
|
-
const IDLE_RETRY_MS = 500;
|
|
34
41
|
const BATCH_MAX_ITEMS = 20;
|
|
35
42
|
const BATCH_MAX_CHARS = 16_000;
|
|
36
43
|
|
|
@@ -67,22 +74,6 @@ interface ChatMsg {
|
|
|
67
74
|
from: string;
|
|
68
75
|
to: string;
|
|
69
76
|
content: string;
|
|
70
|
-
triggerTurn: boolean;
|
|
71
|
-
}
|
|
72
|
-
interface PromptRequestMsg {
|
|
73
|
-
type: "prompt_request";
|
|
74
|
-
id: string;
|
|
75
|
-
from: string;
|
|
76
|
-
to: string;
|
|
77
|
-
prompt: string;
|
|
78
|
-
}
|
|
79
|
-
interface PromptResponseMsg {
|
|
80
|
-
type: "prompt_response";
|
|
81
|
-
id: string;
|
|
82
|
-
from: string;
|
|
83
|
-
to: string;
|
|
84
|
-
response: string;
|
|
85
|
-
error?: string;
|
|
86
77
|
}
|
|
87
78
|
interface StatusUpdateMsg {
|
|
88
79
|
type: "status_update";
|
|
@@ -115,6 +106,7 @@ interface CompactResponseMsg {
|
|
|
115
106
|
type LinkStatus =
|
|
116
107
|
| { kind: "idle"; since: number }
|
|
117
108
|
| { kind: "thinking"; since: number }
|
|
109
|
+
| { kind: "compacting"; since: number }
|
|
118
110
|
| { kind: "tool"; toolName: string; since: number };
|
|
119
111
|
|
|
120
112
|
type ContextSnapshot = { tokens: number | null; contextWindow: number };
|
|
@@ -125,16 +117,53 @@ type LinkMessage =
|
|
|
125
117
|
| TerminalJoinedMsg
|
|
126
118
|
| TerminalLeftMsg
|
|
127
119
|
| ChatMsg
|
|
128
|
-
| PromptRequestMsg
|
|
129
|
-
| PromptResponseMsg
|
|
130
120
|
| StatusUpdateMsg
|
|
131
121
|
| ErrorMsg
|
|
132
122
|
| CompactRequestMsg
|
|
133
123
|
| CompactResponseMsg;
|
|
134
124
|
|
|
125
|
+
/**
|
|
126
|
+
* True when Pi is at or above MIN_PI_VERSION. A fixed floor needs an ordered compare
|
|
127
|
+
* of three numbers, not a semver dependency — but it does need SemVer's shape, so the
|
|
128
|
+
* core rejects leading zeros, an optional prerelease is captured because it lowers
|
|
129
|
+
* precedence, and optional build metadata is matched and then ignored because it
|
|
130
|
+
* carries none. A prerelease of the floor itself precedes it, so `0.84.2-beta.1` is
|
|
131
|
+
* below `0.84.2` while `0.85.0-beta.1` is above it on its core alone. Each suffix is
|
|
132
|
+
* a dot-separated series of nonempty identifiers, so `0.84.2+.` and `0.85.0-alpha..1`
|
|
133
|
+
* are malformed. Anything unparsable, or with a component too large to compare
|
|
134
|
+
* exactly, is refused rather than guessed at.
|
|
135
|
+
*/
|
|
136
|
+
function piVersionSupported(version: string): boolean {
|
|
137
|
+
const parsed =
|
|
138
|
+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
|
|
139
|
+
version.trim(),
|
|
140
|
+
);
|
|
141
|
+
if (!parsed) return false;
|
|
142
|
+
// A numeric prerelease identifier may not carry a leading zero. `0rc` may, being
|
|
143
|
+
// alphanumeric, and so may a build identifier, which never affects precedence.
|
|
144
|
+
const prerelease = parsed[4];
|
|
145
|
+
if (prerelease?.split(".").some((id) => /^0\d+$/.test(id))) return false;
|
|
146
|
+
for (let i = 0; i < 3; i++) {
|
|
147
|
+
const part = Number(parsed[i + 1]);
|
|
148
|
+
if (!Number.isSafeInteger(part)) return false;
|
|
149
|
+
if (part !== MIN_PI_VERSION[i]) return part > MIN_PI_VERSION[i];
|
|
150
|
+
}
|
|
151
|
+
return prerelease === undefined; // exactly the floor: only the release qualifies
|
|
152
|
+
}
|
|
153
|
+
|
|
135
154
|
// ─── Extension ───────────────────────────────────────────────────────────────
|
|
136
155
|
|
|
137
156
|
export default function (pi: ExtensionAPI) {
|
|
157
|
+
// First statement, so an unsupported host leaves behind no flag, event, tool,
|
|
158
|
+
// command, timer or socket to half-run with. Pi reports a factory that throws as an
|
|
159
|
+
// extension load error naming this message, and keeps running without pi-link.
|
|
160
|
+
if (!piVersionSupported(PI_VERSION)) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`pi-link requires Pi >=${MIN_PI_VERSION.join(".")} (detected ${PI_VERSION || "unknown"}); ` +
|
|
163
|
+
`upgrade Pi, or pin pi-link 0.2.x for Pi 0.74–0.84.1.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
138
167
|
pi.registerFlag("link", {
|
|
139
168
|
description: "Connect to link on startup",
|
|
140
169
|
type: "boolean",
|
|
@@ -163,12 +192,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
163
192
|
let startupConnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
164
193
|
|
|
165
194
|
// Status tracking (local truth)
|
|
166
|
-
let agentRunning = false;
|
|
195
|
+
let agentRunning = false; // agent_start until agent_settled, not until agent_end
|
|
167
196
|
let compactRunning = false; // true while compacting for a remote request
|
|
168
|
-
let
|
|
197
|
+
let localCompacting = false; // true while compacting for a human /compact
|
|
198
|
+
let compactDeadline: ReturnType<typeof setTimeout> | undefined;
|
|
199
|
+
let wasCompactionGated = false; // gate state syncCompactionStatus() last acted on
|
|
200
|
+
// toolCallId → toolName. Pi runs tools in parallel by default and both tool
|
|
201
|
+
// events carry the call id, so one slot per call is the only way an end can clear
|
|
202
|
+
// the call it belongs to. Insertion-ordered, which is what picks the display.
|
|
203
|
+
const activeTools = new Map<string, string>();
|
|
169
204
|
let stateSince = Date.now();
|
|
170
|
-
let
|
|
171
|
-
let lastPushedTool: string | null = null;
|
|
205
|
+
let lastPushedStatus: string | null = null; // identity of the last published status
|
|
172
206
|
const terminalStatuses = new Map<string, LinkStatus>(); // other terminals
|
|
173
207
|
const terminalContexts = new Map<string, ContextSnapshot>(); // other terminals' context
|
|
174
208
|
let currentCwd = "";
|
|
@@ -176,6 +210,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
176
210
|
|
|
177
211
|
// Hub state
|
|
178
212
|
let wss: WebSocketServer | null = null;
|
|
213
|
+
// The hub owns the HTTP server the WS server rides on, because `wss.close()`
|
|
214
|
+
// never closes a server it was handed. Nulled wherever `wss` is.
|
|
215
|
+
let hubHttpServer: HttpServer | null = null;
|
|
179
216
|
const hubClients = new Map<WebSocket, string>(); // ws → terminal name
|
|
180
217
|
const hubTerminalStatuses = new Map<string, LinkStatus>(); // hub-authoritative
|
|
181
218
|
const hubTerminalContexts = new Map<string, ContextSnapshot>(); // hub-authoritative
|
|
@@ -184,19 +221,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
184
221
|
// Client state
|
|
185
222
|
let ws: WebSocket | null = null;
|
|
186
223
|
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
>();
|
|
224
|
+
// Establishment. One attempt owns every pending transport across the whole
|
|
225
|
+
// client-then-hub sequence, because a transport can emit callbacks from
|
|
226
|
+
// construction onward while `ws`/`wss` are still empty. The record itself is the
|
|
227
|
+
// generation token: a callback that captured it can tell whether it is still the
|
|
228
|
+
// current owner by identity alone, and cancellation has handles to close.
|
|
229
|
+
type ConnectionAttempt = {
|
|
230
|
+
promise: Promise<void>;
|
|
231
|
+
socket: WebSocket | null; // dialing, not yet `ws`
|
|
232
|
+
server: WebSocketServer | null; // binding, not yet `wss`
|
|
233
|
+
httpServer: HttpServer | null; // binding, not yet `hubHttpServer`
|
|
234
|
+
};
|
|
235
|
+
let connectionAttempt: ConnectionAttempt | null = null;
|
|
200
236
|
|
|
201
237
|
// Pending compact responses (sender waiting for remote compaction to finish)
|
|
202
238
|
const pendingCompactResponses = new Map<
|
|
@@ -211,11 +247,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
211
247
|
}
|
|
212
248
|
>();
|
|
213
249
|
|
|
214
|
-
//
|
|
215
|
-
let pendingRemotePrompt: { id: string; from: string } | null = null;
|
|
216
|
-
let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
|
217
|
-
|
|
218
|
-
// Inbox: idle-gated batched delivery for triggerTurn:true messages
|
|
250
|
+
// Inbox: fixed-window batching; every batch is delivered to the receiver's model
|
|
219
251
|
const inbox: { from: string; content: string }[] = [];
|
|
220
252
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
221
253
|
|
|
@@ -251,12 +283,45 @@ export default function (pi: ExtensionAPI) {
|
|
|
251
283
|
}
|
|
252
284
|
|
|
253
285
|
function deriveStatus(): LinkStatus {
|
|
254
|
-
|
|
255
|
-
|
|
286
|
+
// Highest precedence, so that reporting "compacting" and deferring delivery are
|
|
287
|
+
// the same condition rather than two that can disagree. In reachable states it
|
|
288
|
+
// competes only with "idle" — a compaction runs with no tool and no agent run —
|
|
289
|
+
// but where it could overlap, the gate is the more actionable fact: work sent
|
|
290
|
+
// here waits, and link_compact declines.
|
|
291
|
+
if (compactionGated()) return { kind: "compacting", since: stateSince };
|
|
292
|
+
const tool = displayedTool();
|
|
293
|
+
if (tool) return { kind: "tool", toolName: tool, since: stateSince };
|
|
256
294
|
if (agentRunning) return { kind: "thinking", since: stateSince };
|
|
257
295
|
return { kind: "idle", since: stateSince };
|
|
258
296
|
}
|
|
259
297
|
|
|
298
|
+
/**
|
|
299
|
+
* The tool a peer is shown while several run at once: the first still active, by
|
|
300
|
+
* start order. A later start never displaces it, so parallel work does not churn
|
|
301
|
+
* the status; when it ends the next one takes over.
|
|
302
|
+
*/
|
|
303
|
+
function displayedTool(): string | null {
|
|
304
|
+
for (const name of activeTools.values()) return name;
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The single definition of "the same status": what a peer sees, as one comparable
|
|
310
|
+
* value. Both users of that question go through here — pushStatus() dedupes on it,
|
|
311
|
+
* and every handler compares it before and after mutating to decide whether
|
|
312
|
+
* stateSince moves. One function, so the clock and the wire cannot come to
|
|
313
|
+
* disagree about what changed; restarting the clock on a change nobody can see
|
|
314
|
+
* would publish nothing now and make the next push carry a duration nobody
|
|
315
|
+
* observed. Two calls of the same tool handing over are one status by this rule.
|
|
316
|
+
*
|
|
317
|
+
* The two forms cannot collide: every non-tool kind is a fixed literal from the
|
|
318
|
+
* LinkStatus union with no colon in it, and the tool form is always prefixed, so
|
|
319
|
+
* no toolName can spell a kind.
|
|
320
|
+
*/
|
|
321
|
+
function statusIdentity(s: LinkStatus): string {
|
|
322
|
+
return s.kind === "tool" ? `tool:${s.toolName}` : s.kind;
|
|
323
|
+
}
|
|
324
|
+
|
|
260
325
|
function captureContext(): ContextSnapshot | undefined {
|
|
261
326
|
if (!ctx) return undefined;
|
|
262
327
|
if (typeof ctx.getContextUsage !== "function") return undefined; // older Pi
|
|
@@ -269,12 +334,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
269
334
|
function pushStatus(force = false) {
|
|
270
335
|
if (role === "disconnected") return;
|
|
271
336
|
const status = deriveStatus();
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
return;
|
|
276
|
-
lastPushedKind = newKind;
|
|
277
|
-
lastPushedTool = newTool;
|
|
337
|
+
const identity = statusIdentity(status);
|
|
338
|
+
if (!force && identity === lastPushedStatus) return;
|
|
339
|
+
lastPushedStatus = identity;
|
|
278
340
|
const context = captureContext(); // only when we actually push
|
|
279
341
|
const msg: StatusUpdateMsg = {
|
|
280
342
|
type: "status_update",
|
|
@@ -373,14 +435,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
373
435
|
if (startupConnectTimer) clearTimeout(startupConnectTimer);
|
|
374
436
|
startupConnectTimer = setTimeout(() => {
|
|
375
437
|
startupConnectTimer = null;
|
|
376
|
-
if (!disposed && ctx) initialize();
|
|
438
|
+
if (!disposed && ctx) void initialize();
|
|
377
439
|
}, 0);
|
|
378
440
|
}
|
|
379
441
|
|
|
380
|
-
// ── Inbox:
|
|
442
|
+
// ── Inbox: batched delivery ──────────────────────────────────────────────
|
|
381
443
|
|
|
444
|
+
// The first queued message opens the window and later arrivals join it, so the
|
|
445
|
+
// deadline belongs to the message that started it. Rearming here instead would
|
|
446
|
+
// make the window trailing-edge, and a stream whose gaps stay under the delay
|
|
447
|
+
// could postpone delivery for as long as it kept arriving.
|
|
382
448
|
function scheduleFlush(delay: number) {
|
|
383
|
-
if (flushTimer)
|
|
449
|
+
if (flushTimer) return;
|
|
384
450
|
flushTimer = setTimeout(flushInbox, delay);
|
|
385
451
|
}
|
|
386
452
|
|
|
@@ -389,18 +455,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
389
455
|
if (inbox.length === 0) return;
|
|
390
456
|
if (!ctx) return;
|
|
391
457
|
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
idle = ctx.isIdle();
|
|
397
|
-
} catch {
|
|
398
|
-
return; // stale context — bail without retry
|
|
399
|
-
}
|
|
400
|
-
if (!idle) {
|
|
401
|
-
scheduleFlush(IDLE_RETRY_MS);
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
458
|
+
// Compacting: hold everything and return WITHOUT rescheduling. setCompacting()
|
|
459
|
+
// drains on release, so polling a compaction that may run to the 180s ceiling
|
|
460
|
+
// would be ~900 wakeups for no information.
|
|
461
|
+
if (compactionGated()) return;
|
|
404
462
|
|
|
405
463
|
// Select batch: up to BATCH_MAX_ITEMS, ~BATCH_MAX_CHARS total (soft cap —
|
|
406
464
|
// first item always included even if oversized, others deferred to next flush)
|
|
@@ -425,12 +483,89 @@ export default function (pi: ExtensionAPI) {
|
|
|
425
483
|
);
|
|
426
484
|
inbox.splice(0, batch.length);
|
|
427
485
|
|
|
428
|
-
//
|
|
486
|
+
// Items held back by the batch caps go out in the next window
|
|
429
487
|
if (inbox.length > 0) {
|
|
430
|
-
scheduleFlush(
|
|
488
|
+
scheduleFlush(FLUSH_DELAY_MS);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* True exactly while delivery is deferred. Also what the terminal reports as its
|
|
494
|
+
* status, so availability and delivery cannot disagree.
|
|
495
|
+
*
|
|
496
|
+
* The two flags are not duplication. compactRunning is set synchronously by the
|
|
497
|
+
* compact_request handler before it calls ctx.compact(), covering the window
|
|
498
|
+
* before session_before_compact arrives; localCompacting covers a human /compact,
|
|
499
|
+
* which pi-link never initiates.
|
|
500
|
+
*
|
|
501
|
+
* Both are load-bearing because Pi will not save us here: AgentSession.prompt()
|
|
502
|
+
* refuses to run during compaction, but sendCustomMessage reaches _runAgentPrompt
|
|
503
|
+
* directly and is not covered by that guard.
|
|
504
|
+
*/
|
|
505
|
+
function compactionGated() {
|
|
506
|
+
return localCompacting || compactRunning;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Record a compaction gate transition. Call after any change to either flag.
|
|
511
|
+
*
|
|
512
|
+
* wasCompactionGated tracks the gate itself, deliberately NOT lastPushedStatus: the
|
|
513
|
+
* two diverge exactly while disconnected, when pushStatus() returns before
|
|
514
|
+
* recording anything, and a gate that opened and closed unseen would then leave
|
|
515
|
+
* stateSince stranded at the moment compaction began. Entering and leaving are one
|
|
516
|
+
* transition each, so the two flag moves of a remote compaction report once.
|
|
517
|
+
*
|
|
518
|
+
* The local record is updated whether or not publication is possible; pushStatus()
|
|
519
|
+
* decides that separately.
|
|
520
|
+
*/
|
|
521
|
+
function syncCompactionStatus() {
|
|
522
|
+
const gated = compactionGated();
|
|
523
|
+
if (gated === wasCompactionGated) return;
|
|
524
|
+
wasCompactionGated = gated;
|
|
525
|
+
stateSince = Date.now(); // the duration shown is of the compaction, not what preceded it
|
|
526
|
+
pushStatus();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Drain the inbox once NO gate remains. Call after clearing either flag.
|
|
531
|
+
*
|
|
532
|
+
* Both gates must be checked together, because a remote compact sets both:
|
|
533
|
+
* ctx.compact() reaches Pi's compact(), which reports reason "manual", so
|
|
534
|
+
* session_before_compact sets localCompacting on top of compactRunning. Pi emits
|
|
535
|
+
* session_compact strictly before it resolves and fires onComplete, so releasing
|
|
536
|
+
* on either flag alone can arm a flush that finds the other flag still standing,
|
|
537
|
+
* returns without rescheduling, and strands the inbox with no release left.
|
|
538
|
+
*/
|
|
539
|
+
function releaseInbox() {
|
|
540
|
+
if (!localCompacting && !compactRunning && inbox.length > 0) {
|
|
541
|
+
scheduleFlush(FLUSH_DELAY_MS);
|
|
431
542
|
}
|
|
432
543
|
}
|
|
433
544
|
|
|
545
|
+
/**
|
|
546
|
+
* Gate and release inbox delivery around a local manual compaction.
|
|
547
|
+
*
|
|
548
|
+
* The deadline is the only backstop. A failed manual compaction emits
|
|
549
|
+
* `compaction_end` to session listeners only, never to extensions, and Pi
|
|
550
|
+
* clears its compaction controller without aborting it — so success is the sole
|
|
551
|
+
* positive ending an extension can observe. The timer handle must be explicit
|
|
552
|
+
* and cleared on every transition: a bare setTimeout outlives its own
|
|
553
|
+
* compaction and would release a *later* compaction's flag.
|
|
554
|
+
*
|
|
555
|
+
* COMPACT_TIMEOUT_MS is reused only to avoid a new constant. It shares a value
|
|
556
|
+
* with the remote-request wait by coincidence, not by meaning.
|
|
557
|
+
*/
|
|
558
|
+
function setCompacting(on: boolean) {
|
|
559
|
+
localCompacting = on;
|
|
560
|
+
clearTimeout(compactDeadline);
|
|
561
|
+
compactDeadline = on
|
|
562
|
+
? setTimeout(() => setCompacting(false), COMPACT_TIMEOUT_MS)
|
|
563
|
+
: undefined;
|
|
564
|
+
// Release drains; it never polls. Nothing else wakes a waiting inbox.
|
|
565
|
+
if (!on) releaseInbox();
|
|
566
|
+
syncCompactionStatus();
|
|
567
|
+
}
|
|
568
|
+
|
|
434
569
|
// ── Connection intent ──────────────────────────────────────────────────
|
|
435
570
|
|
|
436
571
|
function shouldConnect(): boolean {
|
|
@@ -441,16 +576,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
441
576
|
return pi.getFlag("link") === true;
|
|
442
577
|
}
|
|
443
578
|
|
|
444
|
-
// ── Pending
|
|
445
|
-
|
|
446
|
-
function cleanupPending(requestId: string) {
|
|
447
|
-
const pending = pendingPromptResponses.get(requestId);
|
|
448
|
-
if (!pending) return null;
|
|
449
|
-
clearTimeout(pending.inactivityTimeout);
|
|
450
|
-
clearTimeout(pending.ceilingTimeout);
|
|
451
|
-
pendingPromptResponses.delete(requestId);
|
|
452
|
-
return pending;
|
|
453
|
-
}
|
|
579
|
+
// ── Pending compact helpers ──────────────────────────────────────────────
|
|
454
580
|
|
|
455
581
|
function cleanupPendingCompact(requestId: string) {
|
|
456
582
|
const pending = pendingCompactResponses.get(requestId);
|
|
@@ -460,29 +586,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
460
586
|
return pending;
|
|
461
587
|
}
|
|
462
588
|
|
|
463
|
-
function makeInactivityTimeout(requestId: string, targetName: string) {
|
|
464
|
-
return setTimeout(() => {
|
|
465
|
-
const pending = cleanupPending(requestId);
|
|
466
|
-
if (pending) {
|
|
467
|
-
pending.resolve(
|
|
468
|
-
textResult(
|
|
469
|
-
`Prompt to "${targetName}" timed out (no activity for ${PROMPT_INACTIVITY_MS / 1000}s)`,
|
|
470
|
-
{ to: targetName, error: "timeout" },
|
|
471
|
-
),
|
|
472
|
-
);
|
|
473
|
-
}
|
|
474
|
-
}, PROMPT_INACTIVITY_MS);
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
function resetInactivityFor(targetName: string) {
|
|
478
|
-
for (const [id, pending] of pendingPromptResponses) {
|
|
479
|
-
if (pending.targetName === targetName) {
|
|
480
|
-
clearTimeout(pending.inactivityTimeout);
|
|
481
|
-
pending.inactivityTimeout = makeInactivityTimeout(id, targetName);
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
589
|
function allTerminalNames(): Set<string> {
|
|
487
590
|
const names = new Set<string>();
|
|
488
591
|
names.add(terminalName); // hub's own name
|
|
@@ -502,6 +605,52 @@ export default function (pi: ExtensionAPI) {
|
|
|
502
605
|
return Array.from(allTerminalNames()).sort();
|
|
503
606
|
}
|
|
504
607
|
|
|
608
|
+
/**
|
|
609
|
+
* Hub: the `GET /status` snapshot. Pure reads — it mutates nothing and sends
|
|
610
|
+
* nothing, so observing the link cannot disturb it.
|
|
611
|
+
*
|
|
612
|
+
* Hub entry first, then clients sorted by name, so pollers see a stable order.
|
|
613
|
+
* `status`/`sinceSeconds` and `cwd` are omitted rather than invented when the
|
|
614
|
+
* hub has not heard them yet: a client is in `hubClients` from `register`, but
|
|
615
|
+
* its first `status_update` arrives a round trip later, and reporting a fresh
|
|
616
|
+
* peer as "idle" would be exactly the false inventory this endpoint exists to
|
|
617
|
+
* remove.
|
|
618
|
+
*/
|
|
619
|
+
function buildStatusPayload() {
|
|
620
|
+
const now = Date.now();
|
|
621
|
+
|
|
622
|
+
const describe = (name: string, entryRole: "hub" | "client") => {
|
|
623
|
+
const status = getStatusFor(name);
|
|
624
|
+
const cwd = getCwdFor(name);
|
|
625
|
+
const context = getContextFor(name);
|
|
626
|
+
return {
|
|
627
|
+
name,
|
|
628
|
+
role: entryRole,
|
|
629
|
+
...(status
|
|
630
|
+
? {
|
|
631
|
+
status: statusIdentity(status),
|
|
632
|
+
sinceSeconds: Math.round((now - status.since) / 1000),
|
|
633
|
+
}
|
|
634
|
+
: {}),
|
|
635
|
+
...(cwd ? { cwd } : {}),
|
|
636
|
+
context: context
|
|
637
|
+
? { tokens: context.tokens, window: context.contextWindow }
|
|
638
|
+
: null,
|
|
639
|
+
};
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
return {
|
|
643
|
+
hub: terminalName,
|
|
644
|
+
port: DEFAULT_PORT,
|
|
645
|
+
terminals: [
|
|
646
|
+
describe(terminalName, "hub"),
|
|
647
|
+
...Array.from(hubClients.values())
|
|
648
|
+
.sort()
|
|
649
|
+
.map((name) => describe(name, "client")),
|
|
650
|
+
],
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
505
654
|
function safeParse(data: string): LinkMessage | null {
|
|
506
655
|
try {
|
|
507
656
|
return JSON.parse(data);
|
|
@@ -537,18 +686,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
537
686
|
* still reject via protocol-level error responses).
|
|
538
687
|
*/
|
|
539
688
|
function routeMessage(
|
|
540
|
-
msg:
|
|
541
|
-
| ChatMsg
|
|
542
|
-
| PromptRequestMsg
|
|
543
|
-
| PromptResponseMsg
|
|
544
|
-
| CompactRequestMsg
|
|
545
|
-
| CompactResponseMsg,
|
|
689
|
+
msg: ChatMsg | CompactRequestMsg | CompactResponseMsg,
|
|
546
690
|
): boolean {
|
|
547
691
|
if (role === "hub") {
|
|
548
|
-
if (msg.to === "*") {
|
|
549
|
-
hubBroadcast(msg, msg.from);
|
|
550
|
-
return true;
|
|
551
|
-
}
|
|
552
692
|
if (msg.to === terminalName) {
|
|
553
693
|
handleIncoming(msg);
|
|
554
694
|
return true;
|
|
@@ -561,35 +701,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
561
701
|
// Target not found — send error back to sender
|
|
562
702
|
const errText = `Terminal "${msg.to}" not found`;
|
|
563
703
|
const errorMsg: LinkMessage =
|
|
564
|
-
msg.type === "
|
|
704
|
+
msg.type === "compact_request"
|
|
565
705
|
? {
|
|
566
|
-
type: "
|
|
706
|
+
type: "compact_response",
|
|
567
707
|
id: msg.id,
|
|
568
708
|
from: terminalName,
|
|
569
709
|
to: msg.from,
|
|
570
|
-
|
|
571
|
-
|
|
710
|
+
ok: false,
|
|
711
|
+
reason: "not_found",
|
|
572
712
|
}
|
|
573
|
-
:
|
|
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 };
|
|
713
|
+
: { type: "error", message: errText };
|
|
583
714
|
|
|
584
715
|
if (msg.from === terminalName) {
|
|
585
|
-
// For
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
if (
|
|
589
|
-
errorMsg.type === "prompt_response" ||
|
|
590
|
-
errorMsg.type === "compact_response"
|
|
591
|
-
)
|
|
592
|
-
handleIncoming(errorMsg);
|
|
716
|
+
// For compact_request, deliver the error response locally so the
|
|
717
|
+
// matching pending map resolves. For chat, skip — the tool result
|
|
718
|
+
// (via return false) is sufficient; no extra UI toast.
|
|
719
|
+
if (errorMsg.type === "compact_response") handleIncoming(errorMsg);
|
|
593
720
|
} else {
|
|
594
721
|
hubClientByName(msg.from)?.send(JSON.stringify(errorMsg));
|
|
595
722
|
}
|
|
@@ -654,20 +781,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
654
781
|
terminalCwds.delete(msg.name);
|
|
655
782
|
terminalContexts.delete(msg.name);
|
|
656
783
|
}
|
|
657
|
-
// Fail any pending
|
|
658
|
-
for (const [id, pending] of pendingPromptResponses) {
|
|
659
|
-
if (pending.targetName === msg.name) {
|
|
660
|
-
const p = cleanupPending(id);
|
|
661
|
-
if (p) {
|
|
662
|
-
p.resolve(
|
|
663
|
-
textResult(`Terminal "${msg.name}" disconnected`, {
|
|
664
|
-
to: msg.name,
|
|
665
|
-
error: "disconnected",
|
|
666
|
-
}),
|
|
667
|
-
);
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
}
|
|
784
|
+
// Fail any pending compact request to the departed terminal
|
|
671
785
|
for (const [id, pending] of pendingCompactResponses) {
|
|
672
786
|
if (pending.targetName === msg.name) {
|
|
673
787
|
const p = cleanupPendingCompact(id);
|
|
@@ -690,62 +804,60 @@ export default function (pi: ExtensionAPI) {
|
|
|
690
804
|
terminalStatuses.set(msg.name, msg.status);
|
|
691
805
|
if (msg.context) terminalContexts.set(msg.name, msg.context);
|
|
692
806
|
else if (msg.context === null) terminalContexts.delete(msg.name);
|
|
693
|
-
resetInactivityFor(msg.name);
|
|
694
807
|
break;
|
|
695
808
|
|
|
696
809
|
// ── Chat message ──
|
|
697
810
|
case "chat":
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
scheduleFlush(FLUSH_DELAY_MS);
|
|
701
|
-
} else {
|
|
702
|
-
pi.sendMessage(
|
|
703
|
-
{
|
|
704
|
-
customType: "link",
|
|
705
|
-
content: msg.content,
|
|
706
|
-
display: true,
|
|
707
|
-
details: { from: msg.from },
|
|
708
|
-
},
|
|
709
|
-
{ triggerTurn: false, deliverAs: "steer" },
|
|
710
|
-
);
|
|
711
|
-
}
|
|
811
|
+
inbox.push({ from: msg.from, content: msg.content });
|
|
812
|
+
scheduleFlush(FLUSH_DELAY_MS);
|
|
712
813
|
break;
|
|
713
814
|
|
|
714
815
|
// ── Another terminal asks us to compact our context ──
|
|
715
816
|
case "compact_request": {
|
|
716
|
-
|
|
817
|
+
const { id, from } = msg;
|
|
818
|
+
const respond = (ok: boolean, reason?: string) =>
|
|
717
819
|
routeMessage({
|
|
718
820
|
type: "compact_response",
|
|
719
|
-
id
|
|
821
|
+
id,
|
|
720
822
|
from: terminalName,
|
|
721
|
-
to:
|
|
722
|
-
ok
|
|
723
|
-
reason
|
|
823
|
+
to: from,
|
|
824
|
+
ok,
|
|
825
|
+
reason,
|
|
724
826
|
});
|
|
827
|
+
// Answered before the busy question, and not through finish(): no capability
|
|
828
|
+
// and no context are refusals of a request we never took on, so nothing here
|
|
829
|
+
// owns the gate to clear or the inbox to release.
|
|
830
|
+
if (!ctx || !ctx.compact) {
|
|
831
|
+
respond(false, "unsupported");
|
|
832
|
+
break;
|
|
833
|
+
}
|
|
834
|
+
// Pi's idle state is the authority on whether this terminal is working.
|
|
835
|
+
// agentRunning is not: Pi may still retry, run an automatic compaction, or
|
|
836
|
+
// drain a queued continuation inside a run whose agent_end already fired, and
|
|
837
|
+
// compact() would abort that work and compact the same branch a second time.
|
|
838
|
+
// compactionGated() adds what Pi's idle flag cannot cover — a manual
|
|
839
|
+
// compaction is not an agent run — and keeps declining while either gate
|
|
840
|
+
// stands, so we never touch a compaction we did not start.
|
|
841
|
+
if (!ctx.isIdle() || compactionGated()) {
|
|
842
|
+
respond(false, "busy");
|
|
725
843
|
break;
|
|
726
844
|
}
|
|
727
|
-
const { id, from } = msg;
|
|
728
845
|
let finished = false;
|
|
729
846
|
const finish = (ok: boolean, reason?: string) => {
|
|
730
847
|
if (finished) return;
|
|
731
848
|
finished = true;
|
|
732
849
|
compactRunning = false;
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
reason,
|
|
740
|
-
});
|
|
850
|
+
releaseInbox(); // last gate may clear here, after session_compact already fired
|
|
851
|
+
// Only reverts status if localCompacting is also clear. A failure after
|
|
852
|
+
// session_before_compact leaves it standing, so the terminal truthfully
|
|
853
|
+
// keeps reporting compacting until the deadline or agent_start.
|
|
854
|
+
syncCompactionStatus();
|
|
855
|
+
respond(ok, reason);
|
|
741
856
|
};
|
|
742
|
-
if (!ctx?.compact) {
|
|
743
|
-
finish(false, "unsupported");
|
|
744
|
-
break;
|
|
745
|
-
}
|
|
746
857
|
compactRunning = true;
|
|
858
|
+
syncCompactionStatus();
|
|
747
859
|
notify(`"${from}" requested compact`, "info");
|
|
748
|
-
// compact() aborts the current turn first, so the
|
|
860
|
+
// compact() aborts the current turn first, so the idle guard above
|
|
749
861
|
// keeps us from interrupting active work. The runtime guarantees
|
|
750
862
|
// exactly one of onComplete/onError fires, so compactRunning can't
|
|
751
863
|
// get stuck and the sender won't hang.
|
|
@@ -762,52 +874,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
762
874
|
break;
|
|
763
875
|
}
|
|
764
876
|
|
|
765
|
-
// ── Another terminal asks us to run a prompt ──
|
|
766
|
-
case "prompt_request":
|
|
767
|
-
if (agentRunning || pendingRemotePrompt || compactRunning) {
|
|
768
|
-
routeMessage({
|
|
769
|
-
type: "prompt_response",
|
|
770
|
-
id: msg.id,
|
|
771
|
-
from: terminalName,
|
|
772
|
-
to: msg.from,
|
|
773
|
-
response: "",
|
|
774
|
-
error: "Terminal is busy",
|
|
775
|
-
});
|
|
776
|
-
} else {
|
|
777
|
-
pendingRemotePrompt = { id: msg.id, from: msg.from };
|
|
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.
|
|
781
|
-
if (keepaliveTimer) clearInterval(keepaliveTimer);
|
|
782
|
-
keepaliveTimer = setInterval(
|
|
783
|
-
() => pushStatus(true),
|
|
784
|
-
KEEPALIVE_INTERVAL_MS,
|
|
785
|
-
);
|
|
786
|
-
notify(`Running remote prompt from "${msg.from}"`, "info");
|
|
787
|
-
pi.sendUserMessage(
|
|
788
|
-
`[Remote prompt from "${msg.from}"]\n\n${msg.prompt}`,
|
|
789
|
-
);
|
|
790
|
-
}
|
|
791
|
-
break;
|
|
792
|
-
|
|
793
|
-
// ── Response to a prompt we sent ──
|
|
794
|
-
case "prompt_response": {
|
|
795
|
-
const pending = cleanupPending(msg.id);
|
|
796
|
-
if (pending) {
|
|
797
|
-
if (msg.error) {
|
|
798
|
-
pending.resolve(
|
|
799
|
-
textResult(`Error from "${msg.from}": ${msg.error}`, {
|
|
800
|
-
from: msg.from,
|
|
801
|
-
error: msg.error,
|
|
802
|
-
}),
|
|
803
|
-
);
|
|
804
|
-
} else {
|
|
805
|
-
pending.resolve(textResult(msg.response, { from: msg.from }));
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
break;
|
|
809
|
-
}
|
|
810
|
-
|
|
811
877
|
// ── Response to a compact we requested ──
|
|
812
878
|
case "compact_response": {
|
|
813
879
|
const pending = cleanupPendingCompact(msg.id);
|
|
@@ -907,7 +973,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
907
973
|
hubTerminalStatuses.set(clientName, msg.status);
|
|
908
974
|
if (msg.context) hubTerminalContexts.set(clientName, msg.context);
|
|
909
975
|
else if (msg.context === null) hubTerminalContexts.delete(clientName);
|
|
910
|
-
resetInactivityFor(clientName);
|
|
911
976
|
const normalized: StatusUpdateMsg = {
|
|
912
977
|
type: "status_update",
|
|
913
978
|
name: clientName,
|
|
@@ -921,13 +986,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
921
986
|
return;
|
|
922
987
|
}
|
|
923
988
|
|
|
924
|
-
// Route chat
|
|
989
|
+
// Route chat and compact messages.
|
|
925
990
|
// Normalize `from` to the hub's authoritative socket→name mapping,
|
|
926
991
|
// mirroring the status_update path above. Don't trust the client.
|
|
927
992
|
if (
|
|
928
993
|
msg.type === "chat" ||
|
|
929
|
-
msg.type === "prompt_request" ||
|
|
930
|
-
msg.type === "prompt_response" ||
|
|
931
994
|
msg.type === "compact_request" ||
|
|
932
995
|
msg.type === "compact_response"
|
|
933
996
|
) {
|
|
@@ -961,20 +1024,46 @@ export default function (pi: ExtensionAPI) {
|
|
|
961
1024
|
|
|
962
1025
|
// ── Start as hub ─────────────────────────────────────────────────────────
|
|
963
1026
|
|
|
964
|
-
function startHub(): Promise<boolean> {
|
|
1027
|
+
function startHub(attempt: ConnectionAttempt): Promise<boolean> {
|
|
965
1028
|
return new Promise((resolve) => {
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1029
|
+
// Owning the HTTP server is what makes `GET /status` possible: a port-bound
|
|
1030
|
+
// `WebSocketServer` builds its own and answers every plain request with 426.
|
|
1031
|
+
// `ws` forwards this server's `listening` and `error`, so the election below
|
|
1032
|
+
// is unchanged.
|
|
1033
|
+
const httpServer = createServer((req, res) => {
|
|
1034
|
+
if (req.method === "GET" && req.url === "/status") {
|
|
1035
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1036
|
+
res.end(JSON.stringify(buildStatusPayload()));
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
res.writeHead(404);
|
|
1040
|
+
res.end();
|
|
969
1041
|
});
|
|
1042
|
+
attempt.httpServer = httpServer;
|
|
1043
|
+
|
|
1044
|
+
const server = new WebSocketServer({ server: httpServer });
|
|
1045
|
+
attempt.server = server;
|
|
1046
|
+
|
|
1047
|
+
// The phase settles once. `error` and a pre-listen `close` both report the
|
|
1048
|
+
// same failure, and closing a cancelled server reports it a third time.
|
|
1049
|
+
let settled = false;
|
|
1050
|
+
const settle = (established: boolean) => {
|
|
1051
|
+
if (settled) return;
|
|
1052
|
+
settled = true;
|
|
1053
|
+
if (attempt.server === server) attempt.server = null;
|
|
1054
|
+
if (attempt.httpServer === httpServer) attempt.httpServer = null;
|
|
1055
|
+
resolve(established);
|
|
1056
|
+
};
|
|
970
1057
|
|
|
971
1058
|
server.on("listening", () => {
|
|
972
|
-
if (
|
|
1059
|
+
if (!attemptIsCurrent(attempt)) {
|
|
973
1060
|
server.close();
|
|
974
|
-
|
|
1061
|
+
httpServer.close();
|
|
1062
|
+
settle(false);
|
|
975
1063
|
return;
|
|
976
1064
|
}
|
|
977
1065
|
wss = server;
|
|
1066
|
+
hubHttpServer = httpServer;
|
|
978
1067
|
// If a client `/link-name` was in flight when the previous hub vanished,
|
|
979
1068
|
// this terminal is now establishing hub identity, so honor that pending
|
|
980
1069
|
// request. Otherwise keep the last hub-assigned identity — don't replay
|
|
@@ -988,11 +1077,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
988
1077
|
`Link hub started on :${DEFAULT_PORT} as "${terminalName}"`,
|
|
989
1078
|
"info",
|
|
990
1079
|
);
|
|
991
|
-
|
|
1080
|
+
settle(true);
|
|
992
1081
|
});
|
|
993
1082
|
|
|
994
1083
|
server.on("connection", (clientWs) => {
|
|
995
|
-
|
|
1084
|
+
// Only the established hub may adopt a client. A cancelled listener can
|
|
1085
|
+
// still receive one while it unwinds, and teardown clears both of these.
|
|
1086
|
+
if (wss !== server || role !== "hub") {
|
|
996
1087
|
clientWs.close();
|
|
997
1088
|
return;
|
|
998
1089
|
}
|
|
@@ -1001,30 +1092,48 @@ export default function (pi: ExtensionAPI) {
|
|
|
1001
1092
|
|
|
1002
1093
|
server.on("error", () => {
|
|
1003
1094
|
// Port in use → someone else is the hub
|
|
1004
|
-
|
|
1095
|
+
settle(false);
|
|
1005
1096
|
});
|
|
1097
|
+
|
|
1098
|
+
server.on("close", () => {
|
|
1099
|
+
// Reached when a pending server is cancelled; a no-op once established.
|
|
1100
|
+
settle(false);
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
// Last, so no forwarded event can arrive before its handler exists.
|
|
1104
|
+
httpServer.listen(DEFAULT_PORT, "127.0.0.1");
|
|
1006
1105
|
});
|
|
1007
1106
|
}
|
|
1008
1107
|
|
|
1009
1108
|
// ── Connect as client ────────────────────────────────────────────────────
|
|
1010
1109
|
|
|
1011
|
-
function connectAsClient(): Promise<boolean> {
|
|
1110
|
+
function connectAsClient(attempt: ConnectionAttempt): Promise<boolean> {
|
|
1012
1111
|
return new Promise((resolve) => {
|
|
1013
|
-
const socket = new WebSocket(`ws://127.0.0.1:${DEFAULT_PORT}
|
|
1014
|
-
|
|
1112
|
+
const socket = new WebSocket(`ws://127.0.0.1:${DEFAULT_PORT}`, {
|
|
1113
|
+
handshakeTimeout: CONNECT_HANDSHAKE_TIMEOUT_MS,
|
|
1114
|
+
});
|
|
1115
|
+
attempt.socket = socket;
|
|
1116
|
+
|
|
1117
|
+
// The phase settles once. A failed dial arrives as `error` then `close`, and
|
|
1118
|
+
// ws reports a handshake timeout the same way, so both must be idempotent.
|
|
1119
|
+
let settled = false;
|
|
1120
|
+
const settle = (established: boolean) => {
|
|
1121
|
+
if (settled) return;
|
|
1122
|
+
settled = true;
|
|
1123
|
+
if (attempt.socket === socket) attempt.socket = null;
|
|
1124
|
+
resolve(established);
|
|
1125
|
+
};
|
|
1015
1126
|
|
|
1016
1127
|
socket.on("open", () => {
|
|
1017
|
-
if (
|
|
1128
|
+
if (!attemptIsCurrent(attempt)) {
|
|
1018
1129
|
socket.close();
|
|
1019
|
-
|
|
1020
|
-
resolved = true;
|
|
1021
|
-
resolve(false);
|
|
1022
|
-
}
|
|
1130
|
+
settle(false);
|
|
1023
1131
|
return;
|
|
1024
1132
|
}
|
|
1133
|
+
// Pending becomes established in one step, so no other code can observe a
|
|
1134
|
+
// socket that is neither.
|
|
1025
1135
|
ws = socket;
|
|
1026
1136
|
role = "client";
|
|
1027
|
-
resolved = true;
|
|
1028
1137
|
// Register with preferred name if available, otherwise current name
|
|
1029
1138
|
socket.send(
|
|
1030
1139
|
JSON.stringify({
|
|
@@ -1034,35 +1143,34 @@ export default function (pi: ExtensionAPI) {
|
|
|
1034
1143
|
context: captureContext(),
|
|
1035
1144
|
} satisfies RegisterMsg),
|
|
1036
1145
|
);
|
|
1037
|
-
|
|
1146
|
+
settle(true);
|
|
1038
1147
|
});
|
|
1039
1148
|
|
|
1040
1149
|
socket.on("message", (raw) => {
|
|
1041
|
-
|
|
1150
|
+
// Only the established socket speaks for this terminal; a cancelled or
|
|
1151
|
+
// superseded one is inert.
|
|
1152
|
+
if (ws !== socket || !isRuntimeLive()) return;
|
|
1042
1153
|
const msg = safeParse(raw.toString());
|
|
1043
1154
|
if (msg) handleIncoming(msg);
|
|
1044
1155
|
});
|
|
1045
1156
|
|
|
1046
1157
|
socket.on("close", () => {
|
|
1158
|
+
settle(false); // pre-open failure; a no-op once established
|
|
1159
|
+
if (ws !== socket) return; // a stale socket owns none of the state below
|
|
1047
1160
|
ws = null;
|
|
1048
1161
|
if (disposed) return;
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
scheduleReconnect();
|
|
1057
|
-
}
|
|
1162
|
+
role = "disconnected";
|
|
1163
|
+
connectedTerminals = [];
|
|
1164
|
+
updateStatus();
|
|
1165
|
+
|
|
1166
|
+
if (!manuallyDisconnected) {
|
|
1167
|
+
notify("Disconnected from link hub", "warning");
|
|
1168
|
+
scheduleReconnect();
|
|
1058
1169
|
}
|
|
1059
1170
|
});
|
|
1060
1171
|
|
|
1061
1172
|
socket.on("error", () => {
|
|
1062
|
-
|
|
1063
|
-
resolved = true;
|
|
1064
|
-
resolve(false);
|
|
1065
|
-
}
|
|
1173
|
+
settle(false);
|
|
1066
1174
|
socket.close();
|
|
1067
1175
|
});
|
|
1068
1176
|
});
|
|
@@ -1070,17 +1178,77 @@ export default function (pi: ExtensionAPI) {
|
|
|
1070
1178
|
|
|
1071
1179
|
// ── Initialize (auto-discover) ──────────────────────────────────────────
|
|
1072
1180
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1181
|
+
/** True while `attempt` still owns establishment and the terminal still wants it. */
|
|
1182
|
+
function attemptIsCurrent(attempt: ConnectionAttempt): boolean {
|
|
1183
|
+
return connectionAttempt === attempt && !disposed && !manuallyDisconnected;
|
|
1184
|
+
}
|
|
1075
1185
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1186
|
+
/**
|
|
1187
|
+
* Single-flight: startup, reconnect and `/link-connect` all join the one attempt
|
|
1188
|
+
* in flight instead of dialing again, because `role` stays "disconnected" for as
|
|
1189
|
+
* long as establishment takes and is therefore no guard at all.
|
|
1190
|
+
*/
|
|
1191
|
+
function initialize(): Promise<void> {
|
|
1192
|
+
if (disposed || manuallyDisconnected) return Promise.resolve();
|
|
1193
|
+
if (connectionAttempt) return connectionAttempt.promise;
|
|
1194
|
+
// The record is the generation token, so it has to exist before the first
|
|
1195
|
+
// transport does; `promise` is replaced on the next line.
|
|
1196
|
+
const attempt: ConnectionAttempt = {
|
|
1197
|
+
promise: Promise.resolve(),
|
|
1198
|
+
socket: null,
|
|
1199
|
+
server: null,
|
|
1200
|
+
httpServer: null,
|
|
1201
|
+
};
|
|
1202
|
+
connectionAttempt = attempt;
|
|
1203
|
+
attempt.promise = runAttempt(attempt);
|
|
1204
|
+
return attempt.promise;
|
|
1205
|
+
}
|
|
1078
1206
|
|
|
1079
|
-
|
|
1080
|
-
|
|
1207
|
+
async function runAttempt(attempt: ConnectionAttempt) {
|
|
1208
|
+
try {
|
|
1209
|
+
// Try connecting to an existing hub
|
|
1210
|
+
if (await connectAsClient(attempt)) return;
|
|
1211
|
+
if (!attemptIsCurrent(attempt)) return;
|
|
1212
|
+
|
|
1213
|
+
// No hub found — become the hub
|
|
1214
|
+
if (await startHub(attempt)) return;
|
|
1215
|
+
if (!attemptIsCurrent(attempt)) return;
|
|
1216
|
+
|
|
1217
|
+
// Port busy but couldn't connect (rare race). Retry after delay.
|
|
1218
|
+
scheduleReconnect();
|
|
1219
|
+
} finally {
|
|
1220
|
+
// Only while still the owner: an attempt cancelled mid-flight must not clear
|
|
1221
|
+
// the slot a newer one has already taken.
|
|
1222
|
+
if (connectionAttempt === attempt) connectionAttempt = null;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1081
1225
|
|
|
1082
|
-
|
|
1083
|
-
|
|
1226
|
+
/**
|
|
1227
|
+
* Drop the attempt in flight. Invalidating it first means any callback arriving
|
|
1228
|
+
* while its transports unwind is already stale; closing the pending handles is
|
|
1229
|
+
* what makes those callbacks arrive at all, so the attempt settles instead of
|
|
1230
|
+
* being abandoned. Also clears both connect timers, so a disconnect before the
|
|
1231
|
+
* startup callback constructs nothing.
|
|
1232
|
+
*/
|
|
1233
|
+
function cancelConnectionAttempt() {
|
|
1234
|
+
if (startupConnectTimer) {
|
|
1235
|
+
clearTimeout(startupConnectTimer);
|
|
1236
|
+
startupConnectTimer = null;
|
|
1237
|
+
}
|
|
1238
|
+
if (reconnectTimer) {
|
|
1239
|
+
clearTimeout(reconnectTimer);
|
|
1240
|
+
reconnectTimer = null;
|
|
1241
|
+
}
|
|
1242
|
+
const attempt = connectionAttempt;
|
|
1243
|
+
if (!attempt) return;
|
|
1244
|
+
connectionAttempt = null;
|
|
1245
|
+
// Read every handle first: closing the WS server can settle the attempt, and
|
|
1246
|
+
// settling clears these fields. Closing the HTTP server is not optional — it
|
|
1247
|
+
// holds the port, so a skipped close squats :9900 for the whole machine.
|
|
1248
|
+
const { socket, server, httpServer } = attempt;
|
|
1249
|
+
socket?.close();
|
|
1250
|
+
server?.close();
|
|
1251
|
+
httpServer?.close();
|
|
1084
1252
|
}
|
|
1085
1253
|
|
|
1086
1254
|
function scheduleReconnect() {
|
|
@@ -1089,36 +1257,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
1089
1257
|
reconnectTimer = setTimeout(() => {
|
|
1090
1258
|
reconnectTimer = null;
|
|
1091
1259
|
if (role === "disconnected" && !disposed && !manuallyDisconnected)
|
|
1092
|
-
initialize();
|
|
1260
|
+
void initialize();
|
|
1093
1261
|
}, delay);
|
|
1094
1262
|
}
|
|
1095
1263
|
|
|
1096
1264
|
// ── Cleanup ──────────────────────────────────────────────────────────────
|
|
1097
1265
|
|
|
1098
1266
|
function disconnect() {
|
|
1099
|
-
//
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
reconnectTimer = null;
|
|
1103
|
-
}
|
|
1267
|
+
// Cancel establishment first, so nothing in flight can commit state behind us.
|
|
1268
|
+
// This also clears the reconnect and startup timers.
|
|
1269
|
+
cancelConnectionAttempt();
|
|
1104
1270
|
|
|
1105
|
-
//
|
|
1106
|
-
if (keepaliveTimer) {
|
|
1107
|
-
clearInterval(keepaliveTimer);
|
|
1108
|
-
keepaliveTimer = null;
|
|
1109
|
-
}
|
|
1110
|
-
pendingRemotePrompt = null;
|
|
1271
|
+
// Clear link-owned remote compaction state; a local /compact survives disconnect.
|
|
1111
1272
|
compactRunning = false;
|
|
1112
|
-
|
|
1113
|
-
//
|
|
1114
|
-
|
|
1115
|
-
const pending = cleanupPending(id);
|
|
1116
|
-
if (pending) {
|
|
1117
|
-
pending.resolve(
|
|
1118
|
-
textResult("Link disconnected", { error: "disconnected" }),
|
|
1119
|
-
);
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1273
|
+
// Runs before role is cleared, so peers still get a final status; more to the
|
|
1274
|
+
// point, the local gate record stays honest for the reconnect.
|
|
1275
|
+
syncCompactionStatus();
|
|
1122
1276
|
for (const id of [...pendingCompactResponses.keys()]) {
|
|
1123
1277
|
const pending = cleanupPendingCompact(id);
|
|
1124
1278
|
if (pending) {
|
|
@@ -1140,6 +1294,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1140
1294
|
hubClients.clear();
|
|
1141
1295
|
wss.close();
|
|
1142
1296
|
wss = null;
|
|
1297
|
+
hubHttpServer?.close();
|
|
1298
|
+
hubHttpServer = null;
|
|
1143
1299
|
}
|
|
1144
1300
|
|
|
1145
1301
|
role = "disconnected";
|
|
@@ -1150,31 +1306,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
1150
1306
|
hubTerminalContexts.clear();
|
|
1151
1307
|
terminalCwds.clear();
|
|
1152
1308
|
hubTerminalCwds.clear();
|
|
1153
|
-
|
|
1154
|
-
lastPushedTool = null;
|
|
1309
|
+
lastPushedStatus = null;
|
|
1155
1310
|
updateStatus();
|
|
1156
1311
|
|
|
1157
|
-
// Inbox survives disconnect
|
|
1158
|
-
|
|
1159
|
-
if (inbox.length > 0 && !flushTimer) {
|
|
1160
|
-
scheduleFlush(FLUSH_DELAY_MS);
|
|
1161
|
-
}
|
|
1312
|
+
// Inbox survives disconnect; flush unless a local /compact still gates it.
|
|
1313
|
+
if (!flushTimer) releaseInbox();
|
|
1162
1314
|
}
|
|
1163
1315
|
|
|
1164
1316
|
function cleanup() {
|
|
1165
1317
|
disposed = true;
|
|
1166
|
-
|
|
1167
|
-
clearTimeout(startupConnectTimer);
|
|
1168
|
-
startupConnectTimer = null;
|
|
1169
|
-
}
|
|
1318
|
+
// disconnect() cancels the attempt in flight, including the startup timer.
|
|
1170
1319
|
disconnect();
|
|
1171
1320
|
ctx = undefined;
|
|
1172
|
-
// Full teardown: clear inbox and
|
|
1321
|
+
// Full teardown: clear inbox and both timers. The compaction deadline runs to
|
|
1322
|
+
// 180s, so it would otherwise outlive the extension and fire after teardown.
|
|
1173
1323
|
inbox.length = 0;
|
|
1174
1324
|
if (flushTimer) {
|
|
1175
1325
|
clearTimeout(flushTimer);
|
|
1176
1326
|
flushTimer = null;
|
|
1177
1327
|
}
|
|
1328
|
+
setCompacting(false);
|
|
1178
1329
|
}
|
|
1179
1330
|
|
|
1180
1331
|
// ── Lifecycle events ─────────────────────────────────────────────────────
|
|
@@ -1250,69 +1401,83 @@ export default function (pi: ExtensionAPI) {
|
|
|
1250
1401
|
});
|
|
1251
1402
|
|
|
1252
1403
|
pi.on("agent_start", async () => {
|
|
1404
|
+
const before = statusIdentity(deriveStatus());
|
|
1253
1405
|
agentRunning = true;
|
|
1254
|
-
|
|
1255
|
-
|
|
1406
|
+
// Safe only under the current deployment, not by Pi's guarantees:
|
|
1407
|
+
// AgentSession.prompt() refuses to run during compaction, and the only caller
|
|
1408
|
+
// of pi.sendMessage here — flushInbox() — is itself gated, so nothing can start
|
|
1409
|
+
// a run mid-compaction. Another extension calling pi.sendMessage with
|
|
1410
|
+
// triggerTurn: true would void that: its message starts a run during a
|
|
1411
|
+
// compaction, agent_start clears this flag, and delivery reopens into a
|
|
1412
|
+
// compaction that is still rebuilding context.
|
|
1413
|
+
setCompacting(false);
|
|
1414
|
+
activeTools.clear(); // defensive: a run cannot begin owing tools from the last one
|
|
1415
|
+
if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
|
|
1256
1416
|
pushStatus();
|
|
1257
1417
|
});
|
|
1258
1418
|
|
|
1419
|
+
pi.on("session_before_compact", async (event) => {
|
|
1420
|
+
// Manual only. Automatic (threshold/overflow) compaction runs inside the agent
|
|
1421
|
+
// run, so a delivered message takes Pi's steering arm and _runAutoCompaction
|
|
1422
|
+
// returns hasQueuedMessages() to drain it afterwards. Gating it would replace a
|
|
1423
|
+
// working Pi path with our own.
|
|
1424
|
+
//
|
|
1425
|
+
// There is deliberately no abort listener: event.signal firing is not an ending.
|
|
1426
|
+
// Pi passes that signal into the summarizer and its compaction controller lives
|
|
1427
|
+
// until the catch/finally, so releasing on abort would re-open delivery while the
|
|
1428
|
+
// aborted compaction is still unwinding. A cancelled compaction is released by
|
|
1429
|
+
// the user's next run (agent_start) or by the deadline.
|
|
1430
|
+
//
|
|
1431
|
+
// Accepted gap: compact() aborts, authorises and prepares *before* emitting this
|
|
1432
|
+
// event, so a flush in that window can still start a turn against context about
|
|
1433
|
+
// to be rebuilt. The message itself survives — it is persisted and restored — so
|
|
1434
|
+
// only the turn is wasted. No heuristics to guess at the window. This is a
|
|
1435
|
+
// manual-compaction limit only; remote compaction is already gated by
|
|
1436
|
+
// compactRunning, set before ctx.compact() is ever called.
|
|
1437
|
+
if (event.reason === "manual") setCompacting(true);
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1259
1440
|
pi.on("session_compact", async () => {
|
|
1441
|
+
setCompacting(false); // compaction succeeded
|
|
1260
1442
|
// Tokens just dropped sharply — force a push so peers see the new context.
|
|
1261
1443
|
pushStatus(true);
|
|
1262
1444
|
});
|
|
1263
1445
|
|
|
1264
1446
|
pi.on("tool_execution_start", async (event) => {
|
|
1265
|
-
|
|
1266
|
-
|
|
1447
|
+
const before = statusIdentity(deriveStatus());
|
|
1448
|
+
activeTools.set(event.toolCallId, event.toolName);
|
|
1449
|
+
if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
|
|
1267
1450
|
pushStatus();
|
|
1268
1451
|
});
|
|
1269
1452
|
|
|
1270
|
-
pi.on("tool_execution_end", async () => {
|
|
1271
|
-
|
|
1272
|
-
|
|
1453
|
+
pi.on("tool_execution_end", async (event) => {
|
|
1454
|
+
const before = statusIdentity(deriveStatus());
|
|
1455
|
+
activeTools.delete(event.toolCallId); // this call only; others may still run
|
|
1456
|
+
if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
|
|
1273
1457
|
pushStatus();
|
|
1274
1458
|
});
|
|
1275
1459
|
|
|
1276
|
-
pi.on("agent_end", async (
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1460
|
+
pi.on("agent_end", async () => {
|
|
1461
|
+
const before = statusIdentity(deriveStatus());
|
|
1462
|
+
// agentRunning deliberately survives this event. Pi may still auto-retry, run an
|
|
1463
|
+
// automatic compaction, or drain a queued continuation, all inside the same run;
|
|
1464
|
+
// reporting idle here would advertise a terminal that is still working.
|
|
1465
|
+
activeTools.clear(); // defensive: an unmatched end would otherwise pin the status
|
|
1466
|
+
if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
|
|
1280
1467
|
pushStatus();
|
|
1468
|
+
});
|
|
1281
1469
|
|
|
1282
|
-
|
|
1283
|
-
//
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
//
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
pendingRemotePrompt = null;
|
|
1294
|
-
|
|
1295
|
-
// Find the last assistant text in this run
|
|
1296
|
-
let responseText = "";
|
|
1297
|
-
for (let i = event.messages.length - 1; i >= 0; i--) {
|
|
1298
|
-
const msg = event.messages[i];
|
|
1299
|
-
if (msg.role === "assistant") {
|
|
1300
|
-
responseText = msg.content
|
|
1301
|
-
.filter((c: { type: string }) => c.type === "text")
|
|
1302
|
-
.map((c: { type: string; text?: string }) => c.text ?? "")
|
|
1303
|
-
.join("\n");
|
|
1304
|
-
break;
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
routeMessage({
|
|
1309
|
-
type: "prompt_response",
|
|
1310
|
-
id,
|
|
1311
|
-
from: terminalName,
|
|
1312
|
-
to: from,
|
|
1313
|
-
response: responseText || "(no response)",
|
|
1314
|
-
});
|
|
1315
|
-
}
|
|
1470
|
+
pi.on("agent_settled", async (_event, settledCtx) => {
|
|
1471
|
+
// The authoritative end of a run: Pi emits this once no retry, compaction or
|
|
1472
|
+
// queued continuation is left. It can still be followed immediately by a new run
|
|
1473
|
+
// another extension started during settlement, whose agent_start already set the
|
|
1474
|
+
// flag we would be clearing — so ask Pi instead of assuming, and leave a newer
|
|
1475
|
+
// run reporting thinking.
|
|
1476
|
+
if (!settledCtx.isIdle()) return;
|
|
1477
|
+
const before = statusIdentity(deriveStatus());
|
|
1478
|
+
agentRunning = false;
|
|
1479
|
+
if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
|
|
1480
|
+
pushStatus();
|
|
1316
1481
|
});
|
|
1317
1482
|
|
|
1318
1483
|
// ── Tool helpers ──────────────────────────────────────────────────────────
|
|
@@ -1325,11 +1490,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1325
1490
|
return textResult("Not connected to link", { error: "not_connected" });
|
|
1326
1491
|
}
|
|
1327
1492
|
|
|
1328
|
-
function truncatePreview(text: string
|
|
1329
|
-
return text.length >
|
|
1493
|
+
function truncatePreview(text: string) {
|
|
1494
|
+
return text.length > 60 ? text.slice(0, 60) + "..." : text;
|
|
1330
1495
|
}
|
|
1331
1496
|
|
|
1332
|
-
// Shared "target not found" result for the send/
|
|
1497
|
+
// Shared "target not found" result for the send/compact tools.
|
|
1333
1498
|
// Returns null when the target is present, so callers can `if (miss) return miss;`.
|
|
1334
1499
|
function targetNotFound(to: string) {
|
|
1335
1500
|
return connectedTerminals.includes(to)
|
|
@@ -1359,48 +1524,37 @@ export default function (pi: ExtensionAPI) {
|
|
|
1359
1524
|
name: "link_send",
|
|
1360
1525
|
label: "Link Send",
|
|
1361
1526
|
description: [
|
|
1362
|
-
"Send a message to
|
|
1363
|
-
|
|
1527
|
+
"Send a message to one other Pi terminal on the link.",
|
|
1528
|
+
"The message always acts: it steers a busy receiver at its next safe boundary, or starts a turn on an idle one.",
|
|
1364
1529
|
].join(" "),
|
|
1365
1530
|
promptSnippet:
|
|
1366
1531
|
"Send a message to another Pi terminal on the local link network",
|
|
1367
1532
|
parameters: Type.Object({
|
|
1368
|
-
to: Type.String({
|
|
1369
|
-
description: 'Target terminal name, or "*" for broadcast',
|
|
1370
|
-
}),
|
|
1533
|
+
to: Type.String({ description: "Target terminal name" }),
|
|
1371
1534
|
message: Type.String({ description: "Message content" }),
|
|
1372
|
-
triggerTurn: Type.Optional(
|
|
1373
|
-
Type.Boolean({
|
|
1374
|
-
description:
|
|
1375
|
-
"Whether to trigger an LLM turn on the receiver (default: false)",
|
|
1376
|
-
}),
|
|
1377
|
-
),
|
|
1378
1535
|
}),
|
|
1379
1536
|
|
|
1380
1537
|
async execute(_toolCallId, params) {
|
|
1381
1538
|
if (role === "disconnected") return notConnectedResult();
|
|
1382
1539
|
|
|
1383
1540
|
// Pre-validate target exists locally (best-effort, catches typos and definitely-absent names)
|
|
1384
|
-
if (params.to
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
});
|
|
1390
|
-
}
|
|
1391
|
-
const miss = targetNotFound(params.to);
|
|
1392
|
-
if (miss) return miss;
|
|
1541
|
+
if (params.to === terminalName) {
|
|
1542
|
+
return textResult("Cannot send to yourself", {
|
|
1543
|
+
to: params.to,
|
|
1544
|
+
error: "self_target",
|
|
1545
|
+
});
|
|
1393
1546
|
}
|
|
1547
|
+
const miss = targetNotFound(params.to);
|
|
1548
|
+
if (miss) return miss;
|
|
1394
1549
|
|
|
1395
1550
|
const delivered = routeMessage({
|
|
1396
1551
|
type: "chat",
|
|
1397
1552
|
from: terminalName,
|
|
1398
1553
|
to: params.to,
|
|
1399
1554
|
content: params.message,
|
|
1400
|
-
triggerTurn: params.triggerTurn ?? false,
|
|
1401
1555
|
});
|
|
1402
1556
|
|
|
1403
|
-
const target =
|
|
1557
|
+
const target = `"${params.to}"`;
|
|
1404
1558
|
if (!delivered) {
|
|
1405
1559
|
return textResult(`Failed to send to ${target}`, {
|
|
1406
1560
|
to: params.to,
|
|
@@ -1409,22 +1563,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
1409
1563
|
}
|
|
1410
1564
|
// Hub delivery is authoritative; client delivery is optimistic (hub routes)
|
|
1411
1565
|
const verb = role === "hub" ? "Sent to" : "Sent to hub for delivery to";
|
|
1412
|
-
return textResult(`${verb} ${target}`, {
|
|
1413
|
-
to: params.to,
|
|
1414
|
-
triggerTurn: params.triggerTurn ?? false,
|
|
1415
|
-
});
|
|
1566
|
+
return textResult(`${verb} ${target}`, { to: params.to });
|
|
1416
1567
|
},
|
|
1417
1568
|
|
|
1418
1569
|
renderCall(args, theme) {
|
|
1419
|
-
const target = args.to === "*" ? "broadcast" : args.to;
|
|
1420
1570
|
const preview =
|
|
1421
1571
|
typeof args.message === "string"
|
|
1422
1572
|
? truncatePreview(args.message)
|
|
1423
1573
|
: "...";
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1574
|
+
const text =
|
|
1575
|
+
theme.fg("toolTitle", theme.bold("link_send ")) +
|
|
1576
|
+
theme.fg("accent", args.to) +
|
|
1577
|
+
"\n " +
|
|
1578
|
+
theme.fg("dim", preview);
|
|
1428
1579
|
return new Text(text, 0, 0);
|
|
1429
1580
|
},
|
|
1430
1581
|
|
|
@@ -1437,7 +1588,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1437
1588
|
description: [
|
|
1438
1589
|
"Ask another Pi terminal to compact its context window and wait until it finishes.",
|
|
1439
1590
|
"Returns once the target has compacted, so you can immediately send it new work.",
|
|
1440
|
-
"
|
|
1591
|
+
"A target declines unless Pi reports its session idle and no manual compaction holds its gate, so an active run, retry, automatic compaction, queued continuation or reported `compacting` all decline.",
|
|
1441
1592
|
].join(" "),
|
|
1442
1593
|
promptSnippet: "Ask another Pi terminal to compact its context window",
|
|
1443
1594
|
parameters: Type.Object({
|
|
@@ -1477,7 +1628,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1477
1628
|
if (pending) {
|
|
1478
1629
|
pending.resolve(
|
|
1479
1630
|
textResult(
|
|
1480
|
-
`Compact request to "${params.to}" timed out
|
|
1631
|
+
`Compact request to "${params.to}" timed out after ${COMPACT_TIMEOUT_MS / 1000}s; the target may still be compacting.`,
|
|
1481
1632
|
{ to: params.to, error: "timeout" },
|
|
1482
1633
|
),
|
|
1483
1634
|
);
|
|
@@ -1539,136 +1690,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1539
1690
|
renderResult: (result, _options, theme) => renderIconResult(result, theme),
|
|
1540
1691
|
});
|
|
1541
1692
|
|
|
1542
|
-
pi.registerTool({
|
|
1543
|
-
name: "link_prompt",
|
|
1544
|
-
label: "Link Prompt",
|
|
1545
|
-
description: [
|
|
1546
|
-
"Send a prompt to another Pi terminal and wait for its LLM to respond.",
|
|
1547
|
-
"The remote terminal processes the prompt as if a user typed it,",
|
|
1548
|
-
"then returns the assistant's response. Times out after 90s of inactivity.",
|
|
1549
|
-
].join(" "),
|
|
1550
|
-
promptSnippet:
|
|
1551
|
-
"Send a prompt to another Pi terminal and receive its LLM response",
|
|
1552
|
-
parameters: Type.Object({
|
|
1553
|
-
to: Type.String({ description: "Target terminal name" }),
|
|
1554
|
-
prompt: Type.String({ description: "Prompt to send" }),
|
|
1555
|
-
}),
|
|
1556
|
-
|
|
1557
|
-
async execute(_toolCallId, params, signal) {
|
|
1558
|
-
if (signal?.aborted) {
|
|
1559
|
-
return textResult("Prompt request aborted", {
|
|
1560
|
-
to: params.to,
|
|
1561
|
-
error: "aborted",
|
|
1562
|
-
});
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
if (role === "disconnected") return notConnectedResult();
|
|
1566
|
-
|
|
1567
|
-
if (params.to === terminalName) {
|
|
1568
|
-
return textResult("Cannot prompt yourself", {
|
|
1569
|
-
to: params.to,
|
|
1570
|
-
error: "self_target",
|
|
1571
|
-
});
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
|
-
const miss = targetNotFound(params.to);
|
|
1575
|
-
if (miss) return miss;
|
|
1576
|
-
|
|
1577
|
-
const requestId = crypto.randomUUID();
|
|
1578
|
-
|
|
1579
|
-
return new Promise((resolve) => {
|
|
1580
|
-
const inactivityTimeout = makeInactivityTimeout(requestId, params.to);
|
|
1581
|
-
|
|
1582
|
-
const ceilingTimeout = setTimeout(() => {
|
|
1583
|
-
const pending = cleanupPending(requestId);
|
|
1584
|
-
if (pending) {
|
|
1585
|
-
pending.resolve(
|
|
1586
|
-
textResult(
|
|
1587
|
-
`Prompt to "${params.to}" hit hard ceiling (${PROMPT_HARD_CEILING_MS / 60_000}min)`,
|
|
1588
|
-
{ to: params.to, error: "timeout" },
|
|
1589
|
-
),
|
|
1590
|
-
);
|
|
1591
|
-
}
|
|
1592
|
-
}, PROMPT_HARD_CEILING_MS);
|
|
1593
|
-
|
|
1594
|
-
pendingPromptResponses.set(requestId, {
|
|
1595
|
-
resolve,
|
|
1596
|
-
targetName: params.to,
|
|
1597
|
-
inactivityTimeout,
|
|
1598
|
-
ceilingTimeout,
|
|
1599
|
-
});
|
|
1600
|
-
|
|
1601
|
-
// Abort handling
|
|
1602
|
-
signal?.addEventListener(
|
|
1603
|
-
"abort",
|
|
1604
|
-
() => {
|
|
1605
|
-
const pending = cleanupPending(requestId);
|
|
1606
|
-
if (pending) {
|
|
1607
|
-
pending.resolve(
|
|
1608
|
-
textResult("Prompt request aborted", {
|
|
1609
|
-
to: params.to,
|
|
1610
|
-
error: "aborted",
|
|
1611
|
-
}),
|
|
1612
|
-
);
|
|
1613
|
-
}
|
|
1614
|
-
},
|
|
1615
|
-
{ once: true },
|
|
1616
|
-
);
|
|
1617
|
-
|
|
1618
|
-
const delivered = routeMessage({
|
|
1619
|
-
type: "prompt_request",
|
|
1620
|
-
id: requestId,
|
|
1621
|
-
from: terminalName,
|
|
1622
|
-
to: params.to,
|
|
1623
|
-
prompt: params.prompt,
|
|
1624
|
-
});
|
|
1625
|
-
|
|
1626
|
-
if (!delivered) {
|
|
1627
|
-
const pending = cleanupPending(requestId);
|
|
1628
|
-
if (pending) {
|
|
1629
|
-
pending.resolve(
|
|
1630
|
-
textResult(`Failed to send prompt to "${params.to}"`, {
|
|
1631
|
-
to: params.to,
|
|
1632
|
-
error: "not_delivered",
|
|
1633
|
-
}),
|
|
1634
|
-
);
|
|
1635
|
-
}
|
|
1636
|
-
}
|
|
1637
|
-
});
|
|
1638
|
-
},
|
|
1639
|
-
|
|
1640
|
-
renderCall(args, theme) {
|
|
1641
|
-
const preview =
|
|
1642
|
-
typeof args.prompt === "string" ? truncatePreview(args.prompt) : "...";
|
|
1643
|
-
let text = theme.fg("toolTitle", theme.bold("link_prompt "));
|
|
1644
|
-
text += theme.fg("accent", args.to ?? "...");
|
|
1645
|
-
text += "\n " + theme.fg("dim", preview);
|
|
1646
|
-
return new Text(text, 0, 0);
|
|
1647
|
-
},
|
|
1648
|
-
|
|
1649
|
-
renderResult(result, _options, theme) {
|
|
1650
|
-
const txt = result.content[0];
|
|
1651
|
-
const details = result.details as Record<string, unknown> | undefined;
|
|
1652
|
-
if (details?.error) {
|
|
1653
|
-
return new Text(
|
|
1654
|
-
theme.fg("error", "✗ ") + (txt?.type === "text" ? txt.text : ""),
|
|
1655
|
-
0,
|
|
1656
|
-
0,
|
|
1657
|
-
);
|
|
1658
|
-
}
|
|
1659
|
-
const from = details?.from ?? "unknown";
|
|
1660
|
-
const response = txt?.type === "text" ? txt.text : "";
|
|
1661
|
-
const preview = truncatePreview(response, 200);
|
|
1662
|
-
return new Text(
|
|
1663
|
-
theme.fg("success", "✓ ") +
|
|
1664
|
-
theme.fg("accent", `[${from}] `) +
|
|
1665
|
-
theme.fg("text", preview),
|
|
1666
|
-
0,
|
|
1667
|
-
0,
|
|
1668
|
-
);
|
|
1669
|
-
},
|
|
1670
|
-
});
|
|
1671
|
-
|
|
1672
1693
|
pi.registerTool({
|
|
1673
1694
|
name: "link_list",
|
|
1674
1695
|
label: "Link List",
|
|
@@ -1859,39 +1880,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1859
1880
|
},
|
|
1860
1881
|
});
|
|
1861
1882
|
|
|
1862
|
-
pi.registerCommand("link-broadcast", {
|
|
1863
|
-
description: "Broadcast a message to all connected terminals",
|
|
1864
|
-
handler: async (args, _ctx) => {
|
|
1865
|
-
const message = args.trim();
|
|
1866
|
-
if (!message) {
|
|
1867
|
-
_ctx.ui.notify("Usage: /link-broadcast <message>", "warning");
|
|
1868
|
-
return;
|
|
1869
|
-
}
|
|
1870
|
-
if (role === "disconnected") {
|
|
1871
|
-
_ctx.ui.notify("Not connected to link", "warning");
|
|
1872
|
-
return;
|
|
1873
|
-
}
|
|
1874
|
-
routeMessage({
|
|
1875
|
-
type: "chat",
|
|
1876
|
-
from: terminalName,
|
|
1877
|
-
to: "*",
|
|
1878
|
-
content: message,
|
|
1879
|
-
triggerTurn: false,
|
|
1880
|
-
});
|
|
1881
|
-
_ctx.ui.notify("Broadcast sent", "info");
|
|
1882
|
-
},
|
|
1883
|
-
});
|
|
1884
|
-
|
|
1885
1883
|
pi.registerCommand("link-disconnect", {
|
|
1886
1884
|
description: "Disconnect from the link",
|
|
1887
1885
|
handler: async (_args, _ctx) => {
|
|
1888
1886
|
pi.appendEntry("link-active", { active: false });
|
|
1889
1887
|
manuallyDisconnected = true;
|
|
1890
1888
|
if (role === "disconnected") {
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
}
|
|
1889
|
+
// Nothing is established, but a startup or reconnect attempt may still be
|
|
1890
|
+
// dialing or binding; persisted intent has to win over it too.
|
|
1891
|
+
cancelConnectionAttempt();
|
|
1895
1892
|
_ctx.ui.notify("Link disconnected", "info");
|
|
1896
1893
|
return;
|
|
1897
1894
|
}
|