pi-link 0.2.0 → 0.3.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 +42 -0
- package/README.md +156 -215
- package/bin/pi-link.mjs +60 -39
- package/index.ts +467 -547
- package/package.json +1 -1
- package/skills/pi-link-coordination/SKILL.md +89 -79
package/index.ts
CHANGED
|
@@ -6,13 +6,14 @@
|
|
|
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";
|
|
@@ -23,14 +24,19 @@ import { WebSocket, WebSocketServer } from "ws";
|
|
|
23
24
|
|
|
24
25
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
25
26
|
|
|
27
|
+
// Pi 0.84.2 is the floor: `agent_settled` and `ctx.isIdle()` are the whole basis of
|
|
28
|
+
// the settled lifecycle and the remote-compaction guard below, and there is one code
|
|
29
|
+
// path for them. Package installation does not check the host version, so an older
|
|
30
|
+
// Pi installs pi-link successfully and must be refused at load.
|
|
31
|
+
const MIN_PI_VERSION = [0, 84, 2];
|
|
32
|
+
|
|
26
33
|
const DEFAULT_PORT = 9900;
|
|
27
|
-
const PROMPT_INACTIVITY_MS = 90_000;
|
|
28
|
-
const PROMPT_HARD_CEILING_MS = 1_800_000;
|
|
29
34
|
const COMPACT_TIMEOUT_MS = 180_000;
|
|
30
35
|
const RECONNECT_DELAY_MS = 2000;
|
|
31
|
-
|
|
36
|
+
// Bounds the HTTP Upgrade only. Without it `ws` waits forever, so a listener that
|
|
37
|
+
// accepts the socket and never answers leaves the terminal offline with no retry.
|
|
38
|
+
const CONNECT_HANDSHAKE_TIMEOUT_MS = 5_000;
|
|
32
39
|
const FLUSH_DELAY_MS = 200;
|
|
33
|
-
const IDLE_RETRY_MS = 500;
|
|
34
40
|
const BATCH_MAX_ITEMS = 20;
|
|
35
41
|
const BATCH_MAX_CHARS = 16_000;
|
|
36
42
|
|
|
@@ -67,22 +73,6 @@ interface ChatMsg {
|
|
|
67
73
|
from: string;
|
|
68
74
|
to: string;
|
|
69
75
|
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
76
|
}
|
|
87
77
|
interface StatusUpdateMsg {
|
|
88
78
|
type: "status_update";
|
|
@@ -115,6 +105,7 @@ interface CompactResponseMsg {
|
|
|
115
105
|
type LinkStatus =
|
|
116
106
|
| { kind: "idle"; since: number }
|
|
117
107
|
| { kind: "thinking"; since: number }
|
|
108
|
+
| { kind: "compacting"; since: number }
|
|
118
109
|
| { kind: "tool"; toolName: string; since: number };
|
|
119
110
|
|
|
120
111
|
type ContextSnapshot = { tokens: number | null; contextWindow: number };
|
|
@@ -125,16 +116,53 @@ type LinkMessage =
|
|
|
125
116
|
| TerminalJoinedMsg
|
|
126
117
|
| TerminalLeftMsg
|
|
127
118
|
| ChatMsg
|
|
128
|
-
| PromptRequestMsg
|
|
129
|
-
| PromptResponseMsg
|
|
130
119
|
| StatusUpdateMsg
|
|
131
120
|
| ErrorMsg
|
|
132
121
|
| CompactRequestMsg
|
|
133
122
|
| CompactResponseMsg;
|
|
134
123
|
|
|
124
|
+
/**
|
|
125
|
+
* True when Pi is at or above MIN_PI_VERSION. A fixed floor needs an ordered compare
|
|
126
|
+
* of three numbers, not a semver dependency — but it does need SemVer's shape, so the
|
|
127
|
+
* core rejects leading zeros, an optional prerelease is captured because it lowers
|
|
128
|
+
* precedence, and optional build metadata is matched and then ignored because it
|
|
129
|
+
* carries none. A prerelease of the floor itself precedes it, so `0.84.2-beta.1` is
|
|
130
|
+
* below `0.84.2` while `0.85.0-beta.1` is above it on its core alone. Each suffix is
|
|
131
|
+
* a dot-separated series of nonempty identifiers, so `0.84.2+.` and `0.85.0-alpha..1`
|
|
132
|
+
* are malformed. Anything unparsable, or with a component too large to compare
|
|
133
|
+
* exactly, is refused rather than guessed at.
|
|
134
|
+
*/
|
|
135
|
+
function piVersionSupported(version: string): boolean {
|
|
136
|
+
const parsed =
|
|
137
|
+
/^(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(
|
|
138
|
+
version.trim(),
|
|
139
|
+
);
|
|
140
|
+
if (!parsed) return false;
|
|
141
|
+
// A numeric prerelease identifier may not carry a leading zero. `0rc` may, being
|
|
142
|
+
// alphanumeric, and so may a build identifier, which never affects precedence.
|
|
143
|
+
const prerelease = parsed[4];
|
|
144
|
+
if (prerelease?.split(".").some((id) => /^0\d+$/.test(id))) return false;
|
|
145
|
+
for (let i = 0; i < 3; i++) {
|
|
146
|
+
const part = Number(parsed[i + 1]);
|
|
147
|
+
if (!Number.isSafeInteger(part)) return false;
|
|
148
|
+
if (part !== MIN_PI_VERSION[i]) return part > MIN_PI_VERSION[i];
|
|
149
|
+
}
|
|
150
|
+
return prerelease === undefined; // exactly the floor: only the release qualifies
|
|
151
|
+
}
|
|
152
|
+
|
|
135
153
|
// ─── Extension ───────────────────────────────────────────────────────────────
|
|
136
154
|
|
|
137
155
|
export default function (pi: ExtensionAPI) {
|
|
156
|
+
// First statement, so an unsupported host leaves behind no flag, event, tool,
|
|
157
|
+
// command, timer or socket to half-run with. Pi reports a factory that throws as an
|
|
158
|
+
// extension load error naming this message, and keeps running without pi-link.
|
|
159
|
+
if (!piVersionSupported(PI_VERSION)) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`pi-link requires Pi >=${MIN_PI_VERSION.join(".")} (detected ${PI_VERSION || "unknown"}); ` +
|
|
162
|
+
`upgrade Pi, or pin pi-link 0.2.x for Pi 0.74–0.84.1.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
138
166
|
pi.registerFlag("link", {
|
|
139
167
|
description: "Connect to link on startup",
|
|
140
168
|
type: "boolean",
|
|
@@ -163,12 +191,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
163
191
|
let startupConnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
164
192
|
|
|
165
193
|
// Status tracking (local truth)
|
|
166
|
-
let agentRunning = false;
|
|
194
|
+
let agentRunning = false; // agent_start until agent_settled, not until agent_end
|
|
167
195
|
let compactRunning = false; // true while compacting for a remote request
|
|
168
|
-
let
|
|
196
|
+
let localCompacting = false; // true while compacting for a human /compact
|
|
197
|
+
let compactDeadline: ReturnType<typeof setTimeout> | undefined;
|
|
198
|
+
let wasCompactionGated = false; // gate state syncCompactionStatus() last acted on
|
|
199
|
+
// toolCallId → toolName. Pi runs tools in parallel by default and both tool
|
|
200
|
+
// events carry the call id, so one slot per call is the only way an end can clear
|
|
201
|
+
// the call it belongs to. Insertion-ordered, which is what picks the display.
|
|
202
|
+
const activeTools = new Map<string, string>();
|
|
169
203
|
let stateSince = Date.now();
|
|
170
|
-
let
|
|
171
|
-
let lastPushedTool: string | null = null;
|
|
204
|
+
let lastPushedStatus: string | null = null; // identity of the last published status
|
|
172
205
|
const terminalStatuses = new Map<string, LinkStatus>(); // other terminals
|
|
173
206
|
const terminalContexts = new Map<string, ContextSnapshot>(); // other terminals' context
|
|
174
207
|
let currentCwd = "";
|
|
@@ -184,19 +217,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
184
217
|
// Client state
|
|
185
218
|
let ws: WebSocket | null = null;
|
|
186
219
|
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
>();
|
|
220
|
+
// Establishment. One attempt owns every pending transport across the whole
|
|
221
|
+
// client-then-hub sequence, because a transport can emit callbacks from
|
|
222
|
+
// construction onward while `ws`/`wss` are still empty. The record itself is the
|
|
223
|
+
// generation token: a callback that captured it can tell whether it is still the
|
|
224
|
+
// current owner by identity alone, and cancellation has handles to close.
|
|
225
|
+
type ConnectionAttempt = {
|
|
226
|
+
promise: Promise<void>;
|
|
227
|
+
socket: WebSocket | null; // dialing, not yet `ws`
|
|
228
|
+
server: WebSocketServer | null; // binding, not yet `wss`
|
|
229
|
+
};
|
|
230
|
+
let connectionAttempt: ConnectionAttempt | null = null;
|
|
200
231
|
|
|
201
232
|
// Pending compact responses (sender waiting for remote compaction to finish)
|
|
202
233
|
const pendingCompactResponses = new Map<
|
|
@@ -211,11 +242,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
211
242
|
}
|
|
212
243
|
>();
|
|
213
244
|
|
|
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
|
|
245
|
+
// Inbox: fixed-window batching; every batch is delivered to the receiver's model
|
|
219
246
|
const inbox: { from: string; content: string }[] = [];
|
|
220
247
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
221
248
|
|
|
@@ -251,12 +278,45 @@ export default function (pi: ExtensionAPI) {
|
|
|
251
278
|
}
|
|
252
279
|
|
|
253
280
|
function deriveStatus(): LinkStatus {
|
|
254
|
-
|
|
255
|
-
|
|
281
|
+
// Highest precedence, so that reporting "compacting" and deferring delivery are
|
|
282
|
+
// the same condition rather than two that can disagree. In reachable states it
|
|
283
|
+
// competes only with "idle" — a compaction runs with no tool and no agent run —
|
|
284
|
+
// but where it could overlap, the gate is the more actionable fact: work sent
|
|
285
|
+
// here waits, and link_compact declines.
|
|
286
|
+
if (compactionGated()) return { kind: "compacting", since: stateSince };
|
|
287
|
+
const tool = displayedTool();
|
|
288
|
+
if (tool) return { kind: "tool", toolName: tool, since: stateSince };
|
|
256
289
|
if (agentRunning) return { kind: "thinking", since: stateSince };
|
|
257
290
|
return { kind: "idle", since: stateSince };
|
|
258
291
|
}
|
|
259
292
|
|
|
293
|
+
/**
|
|
294
|
+
* The tool a peer is shown while several run at once: the first still active, by
|
|
295
|
+
* start order. A later start never displaces it, so parallel work does not churn
|
|
296
|
+
* the status; when it ends the next one takes over.
|
|
297
|
+
*/
|
|
298
|
+
function displayedTool(): string | null {
|
|
299
|
+
for (const name of activeTools.values()) return name;
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The single definition of "the same status": what a peer sees, as one comparable
|
|
305
|
+
* value. Both users of that question go through here — pushStatus() dedupes on it,
|
|
306
|
+
* and every handler compares it before and after mutating to decide whether
|
|
307
|
+
* stateSince moves. One function, so the clock and the wire cannot come to
|
|
308
|
+
* disagree about what changed; restarting the clock on a change nobody can see
|
|
309
|
+
* would publish nothing now and make the next push carry a duration nobody
|
|
310
|
+
* observed. Two calls of the same tool handing over are one status by this rule.
|
|
311
|
+
*
|
|
312
|
+
* The two forms cannot collide: every non-tool kind is a fixed literal from the
|
|
313
|
+
* LinkStatus union with no colon in it, and the tool form is always prefixed, so
|
|
314
|
+
* no toolName can spell a kind.
|
|
315
|
+
*/
|
|
316
|
+
function statusIdentity(s: LinkStatus): string {
|
|
317
|
+
return s.kind === "tool" ? `tool:${s.toolName}` : s.kind;
|
|
318
|
+
}
|
|
319
|
+
|
|
260
320
|
function captureContext(): ContextSnapshot | undefined {
|
|
261
321
|
if (!ctx) return undefined;
|
|
262
322
|
if (typeof ctx.getContextUsage !== "function") return undefined; // older Pi
|
|
@@ -269,12 +329,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
269
329
|
function pushStatus(force = false) {
|
|
270
330
|
if (role === "disconnected") return;
|
|
271
331
|
const status = deriveStatus();
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
return;
|
|
276
|
-
lastPushedKind = newKind;
|
|
277
|
-
lastPushedTool = newTool;
|
|
332
|
+
const identity = statusIdentity(status);
|
|
333
|
+
if (!force && identity === lastPushedStatus) return;
|
|
334
|
+
lastPushedStatus = identity;
|
|
278
335
|
const context = captureContext(); // only when we actually push
|
|
279
336
|
const msg: StatusUpdateMsg = {
|
|
280
337
|
type: "status_update",
|
|
@@ -373,14 +430,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
373
430
|
if (startupConnectTimer) clearTimeout(startupConnectTimer);
|
|
374
431
|
startupConnectTimer = setTimeout(() => {
|
|
375
432
|
startupConnectTimer = null;
|
|
376
|
-
if (!disposed && ctx) initialize();
|
|
433
|
+
if (!disposed && ctx) void initialize();
|
|
377
434
|
}, 0);
|
|
378
435
|
}
|
|
379
436
|
|
|
380
|
-
// ── Inbox:
|
|
437
|
+
// ── Inbox: batched delivery ──────────────────────────────────────────────
|
|
381
438
|
|
|
439
|
+
// The first queued message opens the window and later arrivals join it, so the
|
|
440
|
+
// deadline belongs to the message that started it. Rearming here instead would
|
|
441
|
+
// make the window trailing-edge, and a stream whose gaps stay under the delay
|
|
442
|
+
// could postpone delivery for as long as it kept arriving.
|
|
382
443
|
function scheduleFlush(delay: number) {
|
|
383
|
-
if (flushTimer)
|
|
444
|
+
if (flushTimer) return;
|
|
384
445
|
flushTimer = setTimeout(flushInbox, delay);
|
|
385
446
|
}
|
|
386
447
|
|
|
@@ -389,18 +450,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
389
450
|
if (inbox.length === 0) return;
|
|
390
451
|
if (!ctx) return;
|
|
391
452
|
|
|
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
|
-
}
|
|
453
|
+
// Compacting: hold everything and return WITHOUT rescheduling. setCompacting()
|
|
454
|
+
// drains on release, so polling a compaction that may run to the 180s ceiling
|
|
455
|
+
// would be ~900 wakeups for no information.
|
|
456
|
+
if (compactionGated()) return;
|
|
404
457
|
|
|
405
458
|
// Select batch: up to BATCH_MAX_ITEMS, ~BATCH_MAX_CHARS total (soft cap —
|
|
406
459
|
// first item always included even if oversized, others deferred to next flush)
|
|
@@ -425,12 +478,89 @@ export default function (pi: ExtensionAPI) {
|
|
|
425
478
|
);
|
|
426
479
|
inbox.splice(0, batch.length);
|
|
427
480
|
|
|
428
|
-
//
|
|
481
|
+
// Items held back by the batch caps go out in the next window
|
|
429
482
|
if (inbox.length > 0) {
|
|
430
|
-
scheduleFlush(
|
|
483
|
+
scheduleFlush(FLUSH_DELAY_MS);
|
|
431
484
|
}
|
|
432
485
|
}
|
|
433
486
|
|
|
487
|
+
/**
|
|
488
|
+
* True exactly while delivery is deferred. Also what the terminal reports as its
|
|
489
|
+
* status, so availability and delivery cannot disagree.
|
|
490
|
+
*
|
|
491
|
+
* The two flags are not duplication. compactRunning is set synchronously by the
|
|
492
|
+
* compact_request handler before it calls ctx.compact(), covering the window
|
|
493
|
+
* before session_before_compact arrives; localCompacting covers a human /compact,
|
|
494
|
+
* which pi-link never initiates.
|
|
495
|
+
*
|
|
496
|
+
* Both are load-bearing because Pi will not save us here: AgentSession.prompt()
|
|
497
|
+
* refuses to run during compaction, but sendCustomMessage reaches _runAgentPrompt
|
|
498
|
+
* directly and is not covered by that guard.
|
|
499
|
+
*/
|
|
500
|
+
function compactionGated() {
|
|
501
|
+
return localCompacting || compactRunning;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Record a compaction gate transition. Call after any change to either flag.
|
|
506
|
+
*
|
|
507
|
+
* wasCompactionGated tracks the gate itself, deliberately NOT lastPushedStatus: the
|
|
508
|
+
* two diverge exactly while disconnected, when pushStatus() returns before
|
|
509
|
+
* recording anything, and a gate that opened and closed unseen would then leave
|
|
510
|
+
* stateSince stranded at the moment compaction began. Entering and leaving are one
|
|
511
|
+
* transition each, so the two flag moves of a remote compaction report once.
|
|
512
|
+
*
|
|
513
|
+
* The local record is updated whether or not publication is possible; pushStatus()
|
|
514
|
+
* decides that separately.
|
|
515
|
+
*/
|
|
516
|
+
function syncCompactionStatus() {
|
|
517
|
+
const gated = compactionGated();
|
|
518
|
+
if (gated === wasCompactionGated) return;
|
|
519
|
+
wasCompactionGated = gated;
|
|
520
|
+
stateSince = Date.now(); // the duration shown is of the compaction, not what preceded it
|
|
521
|
+
pushStatus();
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Drain the inbox once NO gate remains. Call after clearing either flag.
|
|
526
|
+
*
|
|
527
|
+
* Both gates must be checked together, because a remote compact sets both:
|
|
528
|
+
* ctx.compact() reaches Pi's compact(), which reports reason "manual", so
|
|
529
|
+
* session_before_compact sets localCompacting on top of compactRunning. Pi emits
|
|
530
|
+
* session_compact strictly before it resolves and fires onComplete, so releasing
|
|
531
|
+
* on either flag alone can arm a flush that finds the other flag still standing,
|
|
532
|
+
* returns without rescheduling, and strands the inbox with no release left.
|
|
533
|
+
*/
|
|
534
|
+
function releaseInbox() {
|
|
535
|
+
if (!localCompacting && !compactRunning && inbox.length > 0) {
|
|
536
|
+
scheduleFlush(FLUSH_DELAY_MS);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Gate and release inbox delivery around a local manual compaction.
|
|
542
|
+
*
|
|
543
|
+
* The deadline is the only backstop. A failed manual compaction emits
|
|
544
|
+
* `compaction_end` to session listeners only, never to extensions, and Pi
|
|
545
|
+
* clears its compaction controller without aborting it — so success is the sole
|
|
546
|
+
* positive ending an extension can observe. The timer handle must be explicit
|
|
547
|
+
* and cleared on every transition: a bare setTimeout outlives its own
|
|
548
|
+
* compaction and would release a *later* compaction's flag.
|
|
549
|
+
*
|
|
550
|
+
* COMPACT_TIMEOUT_MS is reused only to avoid a new constant. It shares a value
|
|
551
|
+
* with the remote-request wait by coincidence, not by meaning.
|
|
552
|
+
*/
|
|
553
|
+
function setCompacting(on: boolean) {
|
|
554
|
+
localCompacting = on;
|
|
555
|
+
clearTimeout(compactDeadline);
|
|
556
|
+
compactDeadline = on
|
|
557
|
+
? setTimeout(() => setCompacting(false), COMPACT_TIMEOUT_MS)
|
|
558
|
+
: undefined;
|
|
559
|
+
// Release drains; it never polls. Nothing else wakes a waiting inbox.
|
|
560
|
+
if (!on) releaseInbox();
|
|
561
|
+
syncCompactionStatus();
|
|
562
|
+
}
|
|
563
|
+
|
|
434
564
|
// ── Connection intent ──────────────────────────────────────────────────
|
|
435
565
|
|
|
436
566
|
function shouldConnect(): boolean {
|
|
@@ -441,16 +571,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
441
571
|
return pi.getFlag("link") === true;
|
|
442
572
|
}
|
|
443
573
|
|
|
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
|
-
}
|
|
574
|
+
// ── Pending compact helpers ──────────────────────────────────────────────
|
|
454
575
|
|
|
455
576
|
function cleanupPendingCompact(requestId: string) {
|
|
456
577
|
const pending = pendingCompactResponses.get(requestId);
|
|
@@ -460,29 +581,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
460
581
|
return pending;
|
|
461
582
|
}
|
|
462
583
|
|
|
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
584
|
function allTerminalNames(): Set<string> {
|
|
487
585
|
const names = new Set<string>();
|
|
488
586
|
names.add(terminalName); // hub's own name
|
|
@@ -537,18 +635,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
537
635
|
* still reject via protocol-level error responses).
|
|
538
636
|
*/
|
|
539
637
|
function routeMessage(
|
|
540
|
-
msg:
|
|
541
|
-
| ChatMsg
|
|
542
|
-
| PromptRequestMsg
|
|
543
|
-
| PromptResponseMsg
|
|
544
|
-
| CompactRequestMsg
|
|
545
|
-
| CompactResponseMsg,
|
|
638
|
+
msg: ChatMsg | CompactRequestMsg | CompactResponseMsg,
|
|
546
639
|
): boolean {
|
|
547
640
|
if (role === "hub") {
|
|
548
|
-
if (msg.to === "*") {
|
|
549
|
-
hubBroadcast(msg, msg.from);
|
|
550
|
-
return true;
|
|
551
|
-
}
|
|
552
641
|
if (msg.to === terminalName) {
|
|
553
642
|
handleIncoming(msg);
|
|
554
643
|
return true;
|
|
@@ -561,35 +650,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
561
650
|
// Target not found — send error back to sender
|
|
562
651
|
const errText = `Terminal "${msg.to}" not found`;
|
|
563
652
|
const errorMsg: LinkMessage =
|
|
564
|
-
msg.type === "
|
|
653
|
+
msg.type === "compact_request"
|
|
565
654
|
? {
|
|
566
|
-
type: "
|
|
655
|
+
type: "compact_response",
|
|
567
656
|
id: msg.id,
|
|
568
657
|
from: terminalName,
|
|
569
658
|
to: msg.from,
|
|
570
|
-
|
|
571
|
-
|
|
659
|
+
ok: false,
|
|
660
|
+
reason: "not_found",
|
|
572
661
|
}
|
|
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 };
|
|
662
|
+
: { type: "error", message: errText };
|
|
583
663
|
|
|
584
664
|
if (msg.from === terminalName) {
|
|
585
|
-
// For
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
if (
|
|
589
|
-
errorMsg.type === "prompt_response" ||
|
|
590
|
-
errorMsg.type === "compact_response"
|
|
591
|
-
)
|
|
592
|
-
handleIncoming(errorMsg);
|
|
665
|
+
// For compact_request, deliver the error response locally so the
|
|
666
|
+
// matching pending map resolves. For chat, skip — the tool result
|
|
667
|
+
// (via return false) is sufficient; no extra UI toast.
|
|
668
|
+
if (errorMsg.type === "compact_response") handleIncoming(errorMsg);
|
|
593
669
|
} else {
|
|
594
670
|
hubClientByName(msg.from)?.send(JSON.stringify(errorMsg));
|
|
595
671
|
}
|
|
@@ -654,20 +730,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
654
730
|
terminalCwds.delete(msg.name);
|
|
655
731
|
terminalContexts.delete(msg.name);
|
|
656
732
|
}
|
|
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
|
-
}
|
|
733
|
+
// Fail any pending compact request to the departed terminal
|
|
671
734
|
for (const [id, pending] of pendingCompactResponses) {
|
|
672
735
|
if (pending.targetName === msg.name) {
|
|
673
736
|
const p = cleanupPendingCompact(id);
|
|
@@ -690,62 +753,60 @@ export default function (pi: ExtensionAPI) {
|
|
|
690
753
|
terminalStatuses.set(msg.name, msg.status);
|
|
691
754
|
if (msg.context) terminalContexts.set(msg.name, msg.context);
|
|
692
755
|
else if (msg.context === null) terminalContexts.delete(msg.name);
|
|
693
|
-
resetInactivityFor(msg.name);
|
|
694
756
|
break;
|
|
695
757
|
|
|
696
758
|
// ── Chat message ──
|
|
697
759
|
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
|
-
}
|
|
760
|
+
inbox.push({ from: msg.from, content: msg.content });
|
|
761
|
+
scheduleFlush(FLUSH_DELAY_MS);
|
|
712
762
|
break;
|
|
713
763
|
|
|
714
764
|
// ── Another terminal asks us to compact our context ──
|
|
715
765
|
case "compact_request": {
|
|
716
|
-
|
|
766
|
+
const { id, from } = msg;
|
|
767
|
+
const respond = (ok: boolean, reason?: string) =>
|
|
717
768
|
routeMessage({
|
|
718
769
|
type: "compact_response",
|
|
719
|
-
id
|
|
770
|
+
id,
|
|
720
771
|
from: terminalName,
|
|
721
|
-
to:
|
|
722
|
-
ok
|
|
723
|
-
reason
|
|
772
|
+
to: from,
|
|
773
|
+
ok,
|
|
774
|
+
reason,
|
|
724
775
|
});
|
|
776
|
+
// Answered before the busy question, and not through finish(): no capability
|
|
777
|
+
// and no context are refusals of a request we never took on, so nothing here
|
|
778
|
+
// owns the gate to clear or the inbox to release.
|
|
779
|
+
if (!ctx || !ctx.compact) {
|
|
780
|
+
respond(false, "unsupported");
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
// Pi's idle state is the authority on whether this terminal is working.
|
|
784
|
+
// agentRunning is not: Pi may still retry, run an automatic compaction, or
|
|
785
|
+
// drain a queued continuation inside a run whose agent_end already fired, and
|
|
786
|
+
// compact() would abort that work and compact the same branch a second time.
|
|
787
|
+
// compactionGated() adds what Pi's idle flag cannot cover — a manual
|
|
788
|
+
// compaction is not an agent run — and keeps declining while either gate
|
|
789
|
+
// stands, so we never touch a compaction we did not start.
|
|
790
|
+
if (!ctx.isIdle() || compactionGated()) {
|
|
791
|
+
respond(false, "busy");
|
|
725
792
|
break;
|
|
726
793
|
}
|
|
727
|
-
const { id, from } = msg;
|
|
728
794
|
let finished = false;
|
|
729
795
|
const finish = (ok: boolean, reason?: string) => {
|
|
730
796
|
if (finished) return;
|
|
731
797
|
finished = true;
|
|
732
798
|
compactRunning = false;
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
reason,
|
|
740
|
-
});
|
|
799
|
+
releaseInbox(); // last gate may clear here, after session_compact already fired
|
|
800
|
+
// Only reverts status if localCompacting is also clear. A failure after
|
|
801
|
+
// session_before_compact leaves it standing, so the terminal truthfully
|
|
802
|
+
// keeps reporting compacting until the deadline or agent_start.
|
|
803
|
+
syncCompactionStatus();
|
|
804
|
+
respond(ok, reason);
|
|
741
805
|
};
|
|
742
|
-
if (!ctx?.compact) {
|
|
743
|
-
finish(false, "unsupported");
|
|
744
|
-
break;
|
|
745
|
-
}
|
|
746
806
|
compactRunning = true;
|
|
807
|
+
syncCompactionStatus();
|
|
747
808
|
notify(`"${from}" requested compact`, "info");
|
|
748
|
-
// compact() aborts the current turn first, so the
|
|
809
|
+
// compact() aborts the current turn first, so the idle guard above
|
|
749
810
|
// keeps us from interrupting active work. The runtime guarantees
|
|
750
811
|
// exactly one of onComplete/onError fires, so compactRunning can't
|
|
751
812
|
// get stuck and the sender won't hang.
|
|
@@ -762,52 +823,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
762
823
|
break;
|
|
763
824
|
}
|
|
764
825
|
|
|
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
826
|
// ── Response to a compact we requested ──
|
|
812
827
|
case "compact_response": {
|
|
813
828
|
const pending = cleanupPendingCompact(msg.id);
|
|
@@ -907,7 +922,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
907
922
|
hubTerminalStatuses.set(clientName, msg.status);
|
|
908
923
|
if (msg.context) hubTerminalContexts.set(clientName, msg.context);
|
|
909
924
|
else if (msg.context === null) hubTerminalContexts.delete(clientName);
|
|
910
|
-
resetInactivityFor(clientName);
|
|
911
925
|
const normalized: StatusUpdateMsg = {
|
|
912
926
|
type: "status_update",
|
|
913
927
|
name: clientName,
|
|
@@ -921,13 +935,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
921
935
|
return;
|
|
922
936
|
}
|
|
923
937
|
|
|
924
|
-
// Route chat
|
|
938
|
+
// Route chat and compact messages.
|
|
925
939
|
// Normalize `from` to the hub's authoritative socket→name mapping,
|
|
926
940
|
// mirroring the status_update path above. Don't trust the client.
|
|
927
941
|
if (
|
|
928
942
|
msg.type === "chat" ||
|
|
929
|
-
msg.type === "prompt_request" ||
|
|
930
|
-
msg.type === "prompt_response" ||
|
|
931
943
|
msg.type === "compact_request" ||
|
|
932
944
|
msg.type === "compact_response"
|
|
933
945
|
) {
|
|
@@ -961,17 +973,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
961
973
|
|
|
962
974
|
// ── Start as hub ─────────────────────────────────────────────────────────
|
|
963
975
|
|
|
964
|
-
function startHub(): Promise<boolean> {
|
|
976
|
+
function startHub(attempt: ConnectionAttempt): Promise<boolean> {
|
|
965
977
|
return new Promise((resolve) => {
|
|
966
978
|
const server = new WebSocketServer({
|
|
967
979
|
port: DEFAULT_PORT,
|
|
968
980
|
host: "127.0.0.1",
|
|
969
981
|
});
|
|
982
|
+
attempt.server = server;
|
|
983
|
+
|
|
984
|
+
// The phase settles once. `error` and a pre-listen `close` both report the
|
|
985
|
+
// same failure, and closing a cancelled server reports it a third time.
|
|
986
|
+
let settled = false;
|
|
987
|
+
const settle = (established: boolean) => {
|
|
988
|
+
if (settled) return;
|
|
989
|
+
settled = true;
|
|
990
|
+
if (attempt.server === server) attempt.server = null;
|
|
991
|
+
resolve(established);
|
|
992
|
+
};
|
|
970
993
|
|
|
971
994
|
server.on("listening", () => {
|
|
972
|
-
if (
|
|
995
|
+
if (!attemptIsCurrent(attempt)) {
|
|
973
996
|
server.close();
|
|
974
|
-
|
|
997
|
+
settle(false);
|
|
975
998
|
return;
|
|
976
999
|
}
|
|
977
1000
|
wss = server;
|
|
@@ -988,11 +1011,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
988
1011
|
`Link hub started on :${DEFAULT_PORT} as "${terminalName}"`,
|
|
989
1012
|
"info",
|
|
990
1013
|
);
|
|
991
|
-
|
|
1014
|
+
settle(true);
|
|
992
1015
|
});
|
|
993
1016
|
|
|
994
1017
|
server.on("connection", (clientWs) => {
|
|
995
|
-
|
|
1018
|
+
// Only the established hub may adopt a client. A cancelled listener can
|
|
1019
|
+
// still receive one while it unwinds, and teardown clears both of these.
|
|
1020
|
+
if (wss !== server || role !== "hub") {
|
|
996
1021
|
clientWs.close();
|
|
997
1022
|
return;
|
|
998
1023
|
}
|
|
@@ -1001,30 +1026,45 @@ export default function (pi: ExtensionAPI) {
|
|
|
1001
1026
|
|
|
1002
1027
|
server.on("error", () => {
|
|
1003
1028
|
// Port in use → someone else is the hub
|
|
1004
|
-
|
|
1029
|
+
settle(false);
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
server.on("close", () => {
|
|
1033
|
+
// Reached when a pending server is cancelled; a no-op once established.
|
|
1034
|
+
settle(false);
|
|
1005
1035
|
});
|
|
1006
1036
|
});
|
|
1007
1037
|
}
|
|
1008
1038
|
|
|
1009
1039
|
// ── Connect as client ────────────────────────────────────────────────────
|
|
1010
1040
|
|
|
1011
|
-
function connectAsClient(): Promise<boolean> {
|
|
1041
|
+
function connectAsClient(attempt: ConnectionAttempt): Promise<boolean> {
|
|
1012
1042
|
return new Promise((resolve) => {
|
|
1013
|
-
const socket = new WebSocket(`ws://127.0.0.1:${DEFAULT_PORT}
|
|
1014
|
-
|
|
1043
|
+
const socket = new WebSocket(`ws://127.0.0.1:${DEFAULT_PORT}`, {
|
|
1044
|
+
handshakeTimeout: CONNECT_HANDSHAKE_TIMEOUT_MS,
|
|
1045
|
+
});
|
|
1046
|
+
attempt.socket = socket;
|
|
1047
|
+
|
|
1048
|
+
// The phase settles once. A failed dial arrives as `error` then `close`, and
|
|
1049
|
+
// ws reports a handshake timeout the same way, so both must be idempotent.
|
|
1050
|
+
let settled = false;
|
|
1051
|
+
const settle = (established: boolean) => {
|
|
1052
|
+
if (settled) return;
|
|
1053
|
+
settled = true;
|
|
1054
|
+
if (attempt.socket === socket) attempt.socket = null;
|
|
1055
|
+
resolve(established);
|
|
1056
|
+
};
|
|
1015
1057
|
|
|
1016
1058
|
socket.on("open", () => {
|
|
1017
|
-
if (
|
|
1059
|
+
if (!attemptIsCurrent(attempt)) {
|
|
1018
1060
|
socket.close();
|
|
1019
|
-
|
|
1020
|
-
resolved = true;
|
|
1021
|
-
resolve(false);
|
|
1022
|
-
}
|
|
1061
|
+
settle(false);
|
|
1023
1062
|
return;
|
|
1024
1063
|
}
|
|
1064
|
+
// Pending becomes established in one step, so no other code can observe a
|
|
1065
|
+
// socket that is neither.
|
|
1025
1066
|
ws = socket;
|
|
1026
1067
|
role = "client";
|
|
1027
|
-
resolved = true;
|
|
1028
1068
|
// Register with preferred name if available, otherwise current name
|
|
1029
1069
|
socket.send(
|
|
1030
1070
|
JSON.stringify({
|
|
@@ -1034,35 +1074,34 @@ export default function (pi: ExtensionAPI) {
|
|
|
1034
1074
|
context: captureContext(),
|
|
1035
1075
|
} satisfies RegisterMsg),
|
|
1036
1076
|
);
|
|
1037
|
-
|
|
1077
|
+
settle(true);
|
|
1038
1078
|
});
|
|
1039
1079
|
|
|
1040
1080
|
socket.on("message", (raw) => {
|
|
1041
|
-
|
|
1081
|
+
// Only the established socket speaks for this terminal; a cancelled or
|
|
1082
|
+
// superseded one is inert.
|
|
1083
|
+
if (ws !== socket || !isRuntimeLive()) return;
|
|
1042
1084
|
const msg = safeParse(raw.toString());
|
|
1043
1085
|
if (msg) handleIncoming(msg);
|
|
1044
1086
|
});
|
|
1045
1087
|
|
|
1046
1088
|
socket.on("close", () => {
|
|
1089
|
+
settle(false); // pre-open failure; a no-op once established
|
|
1090
|
+
if (ws !== socket) return; // a stale socket owns none of the state below
|
|
1047
1091
|
ws = null;
|
|
1048
1092
|
if (disposed) return;
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
scheduleReconnect();
|
|
1057
|
-
}
|
|
1093
|
+
role = "disconnected";
|
|
1094
|
+
connectedTerminals = [];
|
|
1095
|
+
updateStatus();
|
|
1096
|
+
|
|
1097
|
+
if (!manuallyDisconnected) {
|
|
1098
|
+
notify("Disconnected from link hub", "warning");
|
|
1099
|
+
scheduleReconnect();
|
|
1058
1100
|
}
|
|
1059
1101
|
});
|
|
1060
1102
|
|
|
1061
1103
|
socket.on("error", () => {
|
|
1062
|
-
|
|
1063
|
-
resolved = true;
|
|
1064
|
-
resolve(false);
|
|
1065
|
-
}
|
|
1104
|
+
settle(false);
|
|
1066
1105
|
socket.close();
|
|
1067
1106
|
});
|
|
1068
1107
|
});
|
|
@@ -1070,17 +1109,71 @@ export default function (pi: ExtensionAPI) {
|
|
|
1070
1109
|
|
|
1071
1110
|
// ── Initialize (auto-discover) ──────────────────────────────────────────
|
|
1072
1111
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1112
|
+
/** True while `attempt` still owns establishment and the terminal still wants it. */
|
|
1113
|
+
function attemptIsCurrent(attempt: ConnectionAttempt): boolean {
|
|
1114
|
+
return connectionAttempt === attempt && !disposed && !manuallyDisconnected;
|
|
1115
|
+
}
|
|
1075
1116
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1117
|
+
/**
|
|
1118
|
+
* Single-flight: startup, reconnect and `/link-connect` all join the one attempt
|
|
1119
|
+
* in flight instead of dialing again, because `role` stays "disconnected" for as
|
|
1120
|
+
* long as establishment takes and is therefore no guard at all.
|
|
1121
|
+
*/
|
|
1122
|
+
function initialize(): Promise<void> {
|
|
1123
|
+
if (disposed || manuallyDisconnected) return Promise.resolve();
|
|
1124
|
+
if (connectionAttempt) return connectionAttempt.promise;
|
|
1125
|
+
// The record is the generation token, so it has to exist before the first
|
|
1126
|
+
// transport does; `promise` is replaced on the next line.
|
|
1127
|
+
const attempt: ConnectionAttempt = {
|
|
1128
|
+
promise: Promise.resolve(),
|
|
1129
|
+
socket: null,
|
|
1130
|
+
server: null,
|
|
1131
|
+
};
|
|
1132
|
+
connectionAttempt = attempt;
|
|
1133
|
+
attempt.promise = runAttempt(attempt);
|
|
1134
|
+
return attempt.promise;
|
|
1135
|
+
}
|
|
1078
1136
|
|
|
1079
|
-
|
|
1080
|
-
|
|
1137
|
+
async function runAttempt(attempt: ConnectionAttempt) {
|
|
1138
|
+
try {
|
|
1139
|
+
// Try connecting to an existing hub
|
|
1140
|
+
if (await connectAsClient(attempt)) return;
|
|
1141
|
+
if (!attemptIsCurrent(attempt)) return;
|
|
1142
|
+
|
|
1143
|
+
// No hub found — become the hub
|
|
1144
|
+
if (await startHub(attempt)) return;
|
|
1145
|
+
if (!attemptIsCurrent(attempt)) return;
|
|
1146
|
+
|
|
1147
|
+
// Port busy but couldn't connect (rare race). Retry after delay.
|
|
1148
|
+
scheduleReconnect();
|
|
1149
|
+
} finally {
|
|
1150
|
+
// Only while still the owner: an attempt cancelled mid-flight must not clear
|
|
1151
|
+
// the slot a newer one has already taken.
|
|
1152
|
+
if (connectionAttempt === attempt) connectionAttempt = null;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1081
1155
|
|
|
1082
|
-
|
|
1083
|
-
|
|
1156
|
+
/**
|
|
1157
|
+
* Drop the attempt in flight. Invalidating it first means any callback arriving
|
|
1158
|
+
* while its transports unwind is already stale; closing the pending handles is
|
|
1159
|
+
* what makes those callbacks arrive at all, so the attempt settles instead of
|
|
1160
|
+
* being abandoned. Also clears both connect timers, so a disconnect before the
|
|
1161
|
+
* startup callback constructs nothing.
|
|
1162
|
+
*/
|
|
1163
|
+
function cancelConnectionAttempt() {
|
|
1164
|
+
if (startupConnectTimer) {
|
|
1165
|
+
clearTimeout(startupConnectTimer);
|
|
1166
|
+
startupConnectTimer = null;
|
|
1167
|
+
}
|
|
1168
|
+
if (reconnectTimer) {
|
|
1169
|
+
clearTimeout(reconnectTimer);
|
|
1170
|
+
reconnectTimer = null;
|
|
1171
|
+
}
|
|
1172
|
+
const attempt = connectionAttempt;
|
|
1173
|
+
if (!attempt) return;
|
|
1174
|
+
connectionAttempt = null;
|
|
1175
|
+
attempt.socket?.close();
|
|
1176
|
+
attempt.server?.close();
|
|
1084
1177
|
}
|
|
1085
1178
|
|
|
1086
1179
|
function scheduleReconnect() {
|
|
@@ -1089,36 +1182,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
1089
1182
|
reconnectTimer = setTimeout(() => {
|
|
1090
1183
|
reconnectTimer = null;
|
|
1091
1184
|
if (role === "disconnected" && !disposed && !manuallyDisconnected)
|
|
1092
|
-
initialize();
|
|
1185
|
+
void initialize();
|
|
1093
1186
|
}, delay);
|
|
1094
1187
|
}
|
|
1095
1188
|
|
|
1096
1189
|
// ── Cleanup ──────────────────────────────────────────────────────────────
|
|
1097
1190
|
|
|
1098
1191
|
function disconnect() {
|
|
1099
|
-
//
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
reconnectTimer = null;
|
|
1103
|
-
}
|
|
1192
|
+
// Cancel establishment first, so nothing in flight can commit state behind us.
|
|
1193
|
+
// This also clears the reconnect and startup timers.
|
|
1194
|
+
cancelConnectionAttempt();
|
|
1104
1195
|
|
|
1105
|
-
//
|
|
1106
|
-
if (keepaliveTimer) {
|
|
1107
|
-
clearInterval(keepaliveTimer);
|
|
1108
|
-
keepaliveTimer = null;
|
|
1109
|
-
}
|
|
1110
|
-
pendingRemotePrompt = null;
|
|
1196
|
+
// Clear link-owned remote compaction state; a local /compact survives disconnect.
|
|
1111
1197
|
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
|
-
}
|
|
1198
|
+
// Runs before role is cleared, so peers still get a final status; more to the
|
|
1199
|
+
// point, the local gate record stays honest for the reconnect.
|
|
1200
|
+
syncCompactionStatus();
|
|
1122
1201
|
for (const id of [...pendingCompactResponses.keys()]) {
|
|
1123
1202
|
const pending = cleanupPendingCompact(id);
|
|
1124
1203
|
if (pending) {
|
|
@@ -1150,31 +1229,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
1150
1229
|
hubTerminalContexts.clear();
|
|
1151
1230
|
terminalCwds.clear();
|
|
1152
1231
|
hubTerminalCwds.clear();
|
|
1153
|
-
|
|
1154
|
-
lastPushedTool = null;
|
|
1232
|
+
lastPushedStatus = null;
|
|
1155
1233
|
updateStatus();
|
|
1156
1234
|
|
|
1157
|
-
// Inbox survives disconnect
|
|
1158
|
-
|
|
1159
|
-
if (inbox.length > 0 && !flushTimer) {
|
|
1160
|
-
scheduleFlush(FLUSH_DELAY_MS);
|
|
1161
|
-
}
|
|
1235
|
+
// Inbox survives disconnect; flush unless a local /compact still gates it.
|
|
1236
|
+
if (!flushTimer) releaseInbox();
|
|
1162
1237
|
}
|
|
1163
1238
|
|
|
1164
1239
|
function cleanup() {
|
|
1165
1240
|
disposed = true;
|
|
1166
|
-
|
|
1167
|
-
clearTimeout(startupConnectTimer);
|
|
1168
|
-
startupConnectTimer = null;
|
|
1169
|
-
}
|
|
1241
|
+
// disconnect() cancels the attempt in flight, including the startup timer.
|
|
1170
1242
|
disconnect();
|
|
1171
1243
|
ctx = undefined;
|
|
1172
|
-
// Full teardown: clear inbox and
|
|
1244
|
+
// Full teardown: clear inbox and both timers. The compaction deadline runs to
|
|
1245
|
+
// 180s, so it would otherwise outlive the extension and fire after teardown.
|
|
1173
1246
|
inbox.length = 0;
|
|
1174
1247
|
if (flushTimer) {
|
|
1175
1248
|
clearTimeout(flushTimer);
|
|
1176
1249
|
flushTimer = null;
|
|
1177
1250
|
}
|
|
1251
|
+
setCompacting(false);
|
|
1178
1252
|
}
|
|
1179
1253
|
|
|
1180
1254
|
// ── Lifecycle events ─────────────────────────────────────────────────────
|
|
@@ -1250,69 +1324,83 @@ export default function (pi: ExtensionAPI) {
|
|
|
1250
1324
|
});
|
|
1251
1325
|
|
|
1252
1326
|
pi.on("agent_start", async () => {
|
|
1327
|
+
const before = statusIdentity(deriveStatus());
|
|
1253
1328
|
agentRunning = true;
|
|
1254
|
-
|
|
1255
|
-
|
|
1329
|
+
// Safe only under the current deployment, not by Pi's guarantees:
|
|
1330
|
+
// AgentSession.prompt() refuses to run during compaction, and the only caller
|
|
1331
|
+
// of pi.sendMessage here — flushInbox() — is itself gated, so nothing can start
|
|
1332
|
+
// a run mid-compaction. Another extension calling pi.sendMessage with
|
|
1333
|
+
// triggerTurn: true would void that: its message starts a run during a
|
|
1334
|
+
// compaction, agent_start clears this flag, and delivery reopens into a
|
|
1335
|
+
// compaction that is still rebuilding context.
|
|
1336
|
+
setCompacting(false);
|
|
1337
|
+
activeTools.clear(); // defensive: a run cannot begin owing tools from the last one
|
|
1338
|
+
if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
|
|
1256
1339
|
pushStatus();
|
|
1257
1340
|
});
|
|
1258
1341
|
|
|
1342
|
+
pi.on("session_before_compact", async (event) => {
|
|
1343
|
+
// Manual only. Automatic (threshold/overflow) compaction runs inside the agent
|
|
1344
|
+
// run, so a delivered message takes Pi's steering arm and _runAutoCompaction
|
|
1345
|
+
// returns hasQueuedMessages() to drain it afterwards. Gating it would replace a
|
|
1346
|
+
// working Pi path with our own.
|
|
1347
|
+
//
|
|
1348
|
+
// There is deliberately no abort listener: event.signal firing is not an ending.
|
|
1349
|
+
// Pi passes that signal into the summarizer and its compaction controller lives
|
|
1350
|
+
// until the catch/finally, so releasing on abort would re-open delivery while the
|
|
1351
|
+
// aborted compaction is still unwinding. A cancelled compaction is released by
|
|
1352
|
+
// the user's next run (agent_start) or by the deadline.
|
|
1353
|
+
//
|
|
1354
|
+
// Accepted gap: compact() aborts, authorises and prepares *before* emitting this
|
|
1355
|
+
// event, so a flush in that window can still start a turn against context about
|
|
1356
|
+
// to be rebuilt. The message itself survives — it is persisted and restored — so
|
|
1357
|
+
// only the turn is wasted. No heuristics to guess at the window. This is a
|
|
1358
|
+
// manual-compaction limit only; remote compaction is already gated by
|
|
1359
|
+
// compactRunning, set before ctx.compact() is ever called.
|
|
1360
|
+
if (event.reason === "manual") setCompacting(true);
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1259
1363
|
pi.on("session_compact", async () => {
|
|
1364
|
+
setCompacting(false); // compaction succeeded
|
|
1260
1365
|
// Tokens just dropped sharply — force a push so peers see the new context.
|
|
1261
1366
|
pushStatus(true);
|
|
1262
1367
|
});
|
|
1263
1368
|
|
|
1264
1369
|
pi.on("tool_execution_start", async (event) => {
|
|
1265
|
-
|
|
1266
|
-
|
|
1370
|
+
const before = statusIdentity(deriveStatus());
|
|
1371
|
+
activeTools.set(event.toolCallId, event.toolName);
|
|
1372
|
+
if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
|
|
1267
1373
|
pushStatus();
|
|
1268
1374
|
});
|
|
1269
1375
|
|
|
1270
|
-
pi.on("tool_execution_end", async () => {
|
|
1271
|
-
|
|
1272
|
-
|
|
1376
|
+
pi.on("tool_execution_end", async (event) => {
|
|
1377
|
+
const before = statusIdentity(deriveStatus());
|
|
1378
|
+
activeTools.delete(event.toolCallId); // this call only; others may still run
|
|
1379
|
+
if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
|
|
1273
1380
|
pushStatus();
|
|
1274
1381
|
});
|
|
1275
1382
|
|
|
1276
|
-
pi.on("agent_end", async (
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1383
|
+
pi.on("agent_end", async () => {
|
|
1384
|
+
const before = statusIdentity(deriveStatus());
|
|
1385
|
+
// agentRunning deliberately survives this event. Pi may still auto-retry, run an
|
|
1386
|
+
// automatic compaction, or drain a queued continuation, all inside the same run;
|
|
1387
|
+
// reporting idle here would advertise a terminal that is still working.
|
|
1388
|
+
activeTools.clear(); // defensive: an unmatched end would otherwise pin the status
|
|
1389
|
+
if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
|
|
1280
1390
|
pushStatus();
|
|
1391
|
+
});
|
|
1281
1392
|
|
|
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
|
-
}
|
|
1393
|
+
pi.on("agent_settled", async (_event, settledCtx) => {
|
|
1394
|
+
// The authoritative end of a run: Pi emits this once no retry, compaction or
|
|
1395
|
+
// queued continuation is left. It can still be followed immediately by a new run
|
|
1396
|
+
// another extension started during settlement, whose agent_start already set the
|
|
1397
|
+
// flag we would be clearing — so ask Pi instead of assuming, and leave a newer
|
|
1398
|
+
// run reporting thinking.
|
|
1399
|
+
if (!settledCtx.isIdle()) return;
|
|
1400
|
+
const before = statusIdentity(deriveStatus());
|
|
1401
|
+
agentRunning = false;
|
|
1402
|
+
if (statusIdentity(deriveStatus()) !== before) stateSince = Date.now();
|
|
1403
|
+
pushStatus();
|
|
1316
1404
|
});
|
|
1317
1405
|
|
|
1318
1406
|
// ── Tool helpers ──────────────────────────────────────────────────────────
|
|
@@ -1325,11 +1413,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1325
1413
|
return textResult("Not connected to link", { error: "not_connected" });
|
|
1326
1414
|
}
|
|
1327
1415
|
|
|
1328
|
-
function truncatePreview(text: string
|
|
1329
|
-
return text.length >
|
|
1416
|
+
function truncatePreview(text: string) {
|
|
1417
|
+
return text.length > 60 ? text.slice(0, 60) + "..." : text;
|
|
1330
1418
|
}
|
|
1331
1419
|
|
|
1332
|
-
// Shared "target not found" result for the send/
|
|
1420
|
+
// Shared "target not found" result for the send/compact tools.
|
|
1333
1421
|
// Returns null when the target is present, so callers can `if (miss) return miss;`.
|
|
1334
1422
|
function targetNotFound(to: string) {
|
|
1335
1423
|
return connectedTerminals.includes(to)
|
|
@@ -1359,48 +1447,37 @@ export default function (pi: ExtensionAPI) {
|
|
|
1359
1447
|
name: "link_send",
|
|
1360
1448
|
label: "Link Send",
|
|
1361
1449
|
description: [
|
|
1362
|
-
"Send a message to
|
|
1363
|
-
|
|
1450
|
+
"Send a message to one other Pi terminal on the link.",
|
|
1451
|
+
"The message always acts: it steers a busy receiver at its next safe boundary, or starts a turn on an idle one.",
|
|
1364
1452
|
].join(" "),
|
|
1365
1453
|
promptSnippet:
|
|
1366
1454
|
"Send a message to another Pi terminal on the local link network",
|
|
1367
1455
|
parameters: Type.Object({
|
|
1368
|
-
to: Type.String({
|
|
1369
|
-
description: 'Target terminal name, or "*" for broadcast',
|
|
1370
|
-
}),
|
|
1456
|
+
to: Type.String({ description: "Target terminal name" }),
|
|
1371
1457
|
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
1458
|
}),
|
|
1379
1459
|
|
|
1380
1460
|
async execute(_toolCallId, params) {
|
|
1381
1461
|
if (role === "disconnected") return notConnectedResult();
|
|
1382
1462
|
|
|
1383
1463
|
// 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;
|
|
1464
|
+
if (params.to === terminalName) {
|
|
1465
|
+
return textResult("Cannot send to yourself", {
|
|
1466
|
+
to: params.to,
|
|
1467
|
+
error: "self_target",
|
|
1468
|
+
});
|
|
1393
1469
|
}
|
|
1470
|
+
const miss = targetNotFound(params.to);
|
|
1471
|
+
if (miss) return miss;
|
|
1394
1472
|
|
|
1395
1473
|
const delivered = routeMessage({
|
|
1396
1474
|
type: "chat",
|
|
1397
1475
|
from: terminalName,
|
|
1398
1476
|
to: params.to,
|
|
1399
1477
|
content: params.message,
|
|
1400
|
-
triggerTurn: params.triggerTurn ?? false,
|
|
1401
1478
|
});
|
|
1402
1479
|
|
|
1403
|
-
const target =
|
|
1480
|
+
const target = `"${params.to}"`;
|
|
1404
1481
|
if (!delivered) {
|
|
1405
1482
|
return textResult(`Failed to send to ${target}`, {
|
|
1406
1483
|
to: params.to,
|
|
@@ -1409,22 +1486,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
1409
1486
|
}
|
|
1410
1487
|
// Hub delivery is authoritative; client delivery is optimistic (hub routes)
|
|
1411
1488
|
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
|
-
});
|
|
1489
|
+
return textResult(`${verb} ${target}`, { to: params.to });
|
|
1416
1490
|
},
|
|
1417
1491
|
|
|
1418
1492
|
renderCall(args, theme) {
|
|
1419
|
-
const target = args.to === "*" ? "broadcast" : args.to;
|
|
1420
1493
|
const preview =
|
|
1421
1494
|
typeof args.message === "string"
|
|
1422
1495
|
? truncatePreview(args.message)
|
|
1423
1496
|
: "...";
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1497
|
+
const text =
|
|
1498
|
+
theme.fg("toolTitle", theme.bold("link_send ")) +
|
|
1499
|
+
theme.fg("accent", args.to) +
|
|
1500
|
+
"\n " +
|
|
1501
|
+
theme.fg("dim", preview);
|
|
1428
1502
|
return new Text(text, 0, 0);
|
|
1429
1503
|
},
|
|
1430
1504
|
|
|
@@ -1437,7 +1511,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1437
1511
|
description: [
|
|
1438
1512
|
"Ask another Pi terminal to compact its context window and wait until it finishes.",
|
|
1439
1513
|
"Returns once the target has compacted, so you can immediately send it new work.",
|
|
1440
|
-
"
|
|
1514
|
+
"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
1515
|
].join(" "),
|
|
1442
1516
|
promptSnippet: "Ask another Pi terminal to compact its context window",
|
|
1443
1517
|
parameters: Type.Object({
|
|
@@ -1477,7 +1551,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1477
1551
|
if (pending) {
|
|
1478
1552
|
pending.resolve(
|
|
1479
1553
|
textResult(
|
|
1480
|
-
`Compact request to "${params.to}" timed out
|
|
1554
|
+
`Compact request to "${params.to}" timed out after ${COMPACT_TIMEOUT_MS / 1000}s; the target may still be compacting.`,
|
|
1481
1555
|
{ to: params.to, error: "timeout" },
|
|
1482
1556
|
),
|
|
1483
1557
|
);
|
|
@@ -1539,136 +1613,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1539
1613
|
renderResult: (result, _options, theme) => renderIconResult(result, theme),
|
|
1540
1614
|
});
|
|
1541
1615
|
|
|
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
1616
|
pi.registerTool({
|
|
1673
1617
|
name: "link_list",
|
|
1674
1618
|
label: "Link List",
|
|
@@ -1859,39 +1803,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1859
1803
|
},
|
|
1860
1804
|
});
|
|
1861
1805
|
|
|
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
1806
|
pi.registerCommand("link-disconnect", {
|
|
1886
1807
|
description: "Disconnect from the link",
|
|
1887
1808
|
handler: async (_args, _ctx) => {
|
|
1888
1809
|
pi.appendEntry("link-active", { active: false });
|
|
1889
1810
|
manuallyDisconnected = true;
|
|
1890
1811
|
if (role === "disconnected") {
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
}
|
|
1812
|
+
// Nothing is established, but a startup or reconnect attempt may still be
|
|
1813
|
+
// dialing or binding; persisted intent has to win over it too.
|
|
1814
|
+
cancelConnectionAttempt();
|
|
1895
1815
|
_ctx.ui.notify("Link disconnected", "info");
|
|
1896
1816
|
return;
|
|
1897
1817
|
}
|