pi-link 0.3.0 → 0.4.1
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 +36 -0
- package/README.md +725 -621
- package/bin/pi-link.mjs +178 -1
- package/index.ts +169 -16
- package/package.json +1 -1
- package/skills/pi-link-coordination/SKILL.md +124 -124
package/bin/pi-link.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// pi-link <name> [--global|-g] [flags...]
|
|
7
7
|
// Resume or create a named session, connected to link.
|
|
8
8
|
// pi-link --list [--global|-g] List pi-link sessions in current cwd (or everywhere).
|
|
9
|
+
// pi-link --status [--json] Show terminals connected to the running hub right now.
|
|
9
10
|
// pi-link --resolve <name> [--global|-g]
|
|
10
11
|
// Print just the session path (machine-readable).
|
|
11
12
|
// pi-link --version Print the installed pi-link version.
|
|
@@ -151,6 +152,11 @@ const useAnsi =
|
|
|
151
152
|
const bold = (s) => (useAnsi ? `\x1b[1m${s}\x1b[22m` : s);
|
|
152
153
|
const dim = (s) => (useAnsi ? `\x1b[2m${s}\x1b[22m` : s);
|
|
153
154
|
|
|
155
|
+
// What `--status` prints for a field the hub could not report. Declared here,
|
|
156
|
+
// above the dispatcher: the mode handlers are hoisted functions, but a `const`
|
|
157
|
+
// read from one would still be in its temporal dead zone when dispatch runs.
|
|
158
|
+
const UNKNOWN = "?";
|
|
159
|
+
|
|
154
160
|
function relTime(d) {
|
|
155
161
|
const sec = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000));
|
|
156
162
|
if (sec < 60) return `${sec}s ago`;
|
|
@@ -299,11 +305,13 @@ function fail(msg) {
|
|
|
299
305
|
function printHelp() {
|
|
300
306
|
console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
|
|
301
307
|
console.error(" pi-link --list [--global|-g]");
|
|
308
|
+
console.error(" pi-link --status [--json]");
|
|
302
309
|
console.error(" pi-link --resolve <name> [--global|-g]");
|
|
303
310
|
console.error(" pi-link --version");
|
|
304
311
|
console.error("");
|
|
305
312
|
console.error("By default, name lookup is scoped to the current cwd.");
|
|
306
313
|
console.error("--global / -g widens the search to sessions in any cwd.");
|
|
314
|
+
console.error("--list reads saved sessions; --status asks the running hub who is connected.");
|
|
307
315
|
}
|
|
308
316
|
|
|
309
317
|
function printVersion() {
|
|
@@ -322,6 +330,7 @@ function describeMode(mode) {
|
|
|
322
330
|
case "help": return "--help";
|
|
323
331
|
case "version": return "--version";
|
|
324
332
|
case "list": return "--list";
|
|
333
|
+
case "status": return "--status";
|
|
325
334
|
case "resolve": return "--resolve";
|
|
326
335
|
case "launcher": return "session name";
|
|
327
336
|
default: return mode;
|
|
@@ -338,10 +347,11 @@ function describeMode(mode) {
|
|
|
338
347
|
// 5. Launcher passthrough (mode launcher) with orphan-positional rejection
|
|
339
348
|
|
|
340
349
|
const state = {
|
|
341
|
-
mode: null, // null | "help" | "version" | "list" | "resolve" | "launcher"
|
|
350
|
+
mode: null, // null | "help" | "version" | "list" | "status" | "resolve" | "launcher"
|
|
342
351
|
resolveName: null,
|
|
343
352
|
launcherName: null,
|
|
344
353
|
global: false,
|
|
354
|
+
json: false,
|
|
345
355
|
piPassthrough: [],
|
|
346
356
|
};
|
|
347
357
|
|
|
@@ -383,11 +393,25 @@ for (let i = 0; i < rawArgs.length; i++) {
|
|
|
383
393
|
break;
|
|
384
394
|
}
|
|
385
395
|
|
|
396
|
+
// `--json` modifies --status only. Claimed before a mode exists so order does
|
|
397
|
+
// not matter, but never in launcher mode, where it belongs to pi.
|
|
398
|
+
if (a === "--json" && (state.mode === null || state.mode === "status")) {
|
|
399
|
+
state.json = true;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
|
|
386
403
|
// Phase 2: mode-selecting flags.
|
|
387
404
|
if (a === "--list") {
|
|
388
405
|
setMode("list");
|
|
389
406
|
continue;
|
|
390
407
|
}
|
|
408
|
+
// Selects the wrapper's own mode only before a session name has been seen.
|
|
409
|
+
// After that it is pi's flag, exactly like `--json` above — intercepting it
|
|
410
|
+
// unconditionally would break `pi-link foo --status`.
|
|
411
|
+
if (a === "--status" && state.mode !== "launcher") {
|
|
412
|
+
setMode("status");
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
391
415
|
if (a.startsWith("--resolve=")) {
|
|
392
416
|
setMode("resolve");
|
|
393
417
|
if (state.resolveName !== null) fail(`--resolve specified more than once`);
|
|
@@ -416,6 +440,9 @@ for (let i = 0; i < rawArgs.length; i++) {
|
|
|
416
440
|
if (state.mode === "list") {
|
|
417
441
|
fail(`--list does not accept argument: ${a}\n Usage: pi-link --list [--global|-g]`);
|
|
418
442
|
}
|
|
443
|
+
if (state.mode === "status") {
|
|
444
|
+
fail(`--status does not accept arguments: ${a}\n Usage: pi-link --status [--json]`);
|
|
445
|
+
}
|
|
419
446
|
if (state.mode === "resolve") {
|
|
420
447
|
fail(`--resolve accepts exactly one name; got extra: ${a}`);
|
|
421
448
|
}
|
|
@@ -465,6 +492,14 @@ if (state.mode === "resolve") {
|
|
|
465
492
|
}
|
|
466
493
|
state.resolveName = normalized;
|
|
467
494
|
}
|
|
495
|
+
// `--status` reads one running hub, so a cwd scope is meaningless rather than
|
|
496
|
+
// merely unused: silently ignoring `-g` would imply a filter that cannot exist.
|
|
497
|
+
if (state.mode === "status" && state.global) {
|
|
498
|
+
fail(`cannot combine --status and --global`);
|
|
499
|
+
}
|
|
500
|
+
if (state.json && state.mode !== "status") {
|
|
501
|
+
fail(`--json is only valid with --status`);
|
|
502
|
+
}
|
|
468
503
|
if (state.mode === "launcher") {
|
|
469
504
|
const normalized = normalizeName(state.launcherName);
|
|
470
505
|
if (!normalized) {
|
|
@@ -488,6 +523,9 @@ switch (state.mode) {
|
|
|
488
523
|
case "list":
|
|
489
524
|
await runList(state);
|
|
490
525
|
break;
|
|
526
|
+
case "status":
|
|
527
|
+
await runStatus(state);
|
|
528
|
+
break;
|
|
491
529
|
case "resolve":
|
|
492
530
|
await runResolve(state);
|
|
493
531
|
break;
|
|
@@ -534,6 +572,145 @@ async function runList(state) {
|
|
|
534
572
|
}
|
|
535
573
|
}
|
|
536
574
|
|
|
575
|
+
// ── Status (live hub query) ────────────────────────────────────────────────
|
|
576
|
+
//
|
|
577
|
+
// `--list` reads saved history; `--status` asks the hub who is connected now.
|
|
578
|
+
// The two answer different questions, so they share no code path.
|
|
579
|
+
|
|
580
|
+
// Mirrors the extension's formatTokens so both renderings of one number agree.
|
|
581
|
+
function formatTokens(n) {
|
|
582
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
583
|
+
if (n >= 1_000) return `${Math.round(n / 1000)}K`;
|
|
584
|
+
return `${n}`;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// `92K/272K (34%)`, or `?/272K` when the hub has a window but no token count.
|
|
588
|
+
// Only reached for a validated payload, so `c` is null or a well-typed snapshot;
|
|
589
|
+
// a non-positive window is still possible and still means nothing to report.
|
|
590
|
+
function formatContext(c) {
|
|
591
|
+
if (!c || c.window <= 0) return UNKNOWN;
|
|
592
|
+
const window = formatTokens(c.window);
|
|
593
|
+
if (typeof c.tokens !== "number") return `${UNKNOWN}/${window}`;
|
|
594
|
+
return `${formatTokens(c.tokens)}/${window} (${Math.round((c.tokens / c.window) * 100)}%)`;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function formatAge(seconds) {
|
|
598
|
+
if (seconds < 60) return `${seconds}s`;
|
|
599
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
|
600
|
+
return `${Math.floor(seconds / 3600)}h`;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// `status` and `sinceSeconds` are an optional pair: the hub omits them for a
|
|
604
|
+
// terminal it has registered but not yet heard from. Absence means unknown, so
|
|
605
|
+
// it must render as unknown — printing `idle` there would be an invention, and
|
|
606
|
+
// acting on it is the misreporting this command exists to end.
|
|
607
|
+
function formatTerminalStatus(entry) {
|
|
608
|
+
if (typeof entry.status !== "string") return UNKNOWN;
|
|
609
|
+
return `${entry.status} (${formatAge(entry.sinceSeconds)})`;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function failUnsupported() {
|
|
613
|
+
console.error("Link hub does not support /status \u2014 update pi-link and restart terminals.");
|
|
614
|
+
process.exit(1);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function failNoHub(port) {
|
|
618
|
+
console.error(`No link hub running on :${port}.`);
|
|
619
|
+
process.exit(2);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// The frozen contract, checked before any field is read. Anything else on the
|
|
623
|
+
// port — a different service, a newer hub, a truncated proxy — must produce the
|
|
624
|
+
// unsupported message, never a stack trace. Unknown extra fields stay allowed.
|
|
625
|
+
function isContextField(c) {
|
|
626
|
+
if (c === null) return true;
|
|
627
|
+
if (!c || typeof c !== "object") return false;
|
|
628
|
+
return (c.tokens === null || typeof c.tokens === "number") && typeof c.window === "number";
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function isTerminalEntry(entry, index) {
|
|
632
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
|
|
633
|
+
if (typeof entry.name !== "string") return false;
|
|
634
|
+
// Hub first, then clients — the ordering the renderer relies on.
|
|
635
|
+
if (entry.role !== (index === 0 ? "hub" : "client")) return false;
|
|
636
|
+
// `status` and `sinceSeconds` are one optional pair: both or neither.
|
|
637
|
+
//
|
|
638
|
+
// The value is checked for shape, not vocabulary. `idle`/`thinking`/
|
|
639
|
+
// `compacting`/`tool:<name>` are today's kinds, but that set has already grown
|
|
640
|
+
// once (`compacting` arrived after 0.3.0) and the CLI never branches on it — it
|
|
641
|
+
// only prints it. Freezing the list here would make a newer hub's fifth kind
|
|
642
|
+
// reject the whole payload, and only while some terminal happened to be in that
|
|
643
|
+
// state: an intermittent failure telling the user to update. An empty string is
|
|
644
|
+
// still rejected, because it renders as a blank cell with a bare duration.
|
|
645
|
+
const hasStatus = "status" in entry;
|
|
646
|
+
if (hasStatus !== ("sinceSeconds" in entry)) return false;
|
|
647
|
+
if (hasStatus && (typeof entry.status !== "string" || entry.status === "" || typeof entry.sinceSeconds !== "number")) {
|
|
648
|
+
return false;
|
|
649
|
+
}
|
|
650
|
+
if ("cwd" in entry && typeof entry.cwd !== "string") return false;
|
|
651
|
+
return "context" in entry && isContextField(entry.context);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function isStatusPayload(payload) {
|
|
655
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
|
|
656
|
+
if (typeof payload.hub !== "string" || typeof payload.port !== "number") return false;
|
|
657
|
+
// A hub always reports itself, so an empty list is not this contract.
|
|
658
|
+
if (!Array.isArray(payload.terminals) || payload.terminals.length === 0) return false;
|
|
659
|
+
if (!payload.terminals.every(isTerminalEntry)) return false;
|
|
660
|
+
return payload.terminals[0].name === payload.hub;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function runStatus(state) {
|
|
664
|
+
// Deliberately unvalidated: a bad value fails the fetch and is reported with
|
|
665
|
+
// the port in the message, which is the diagnosis.
|
|
666
|
+
const port = process.env.PI_LINK_PORT ?? 9900;
|
|
667
|
+
// One deadline covers the whole exchange, headers and body alike, so it has to
|
|
668
|
+
// be readable afterwards to tell a timeout from a malformed answer.
|
|
669
|
+
const deadline = AbortSignal.timeout(2000);
|
|
670
|
+
let response;
|
|
671
|
+
try {
|
|
672
|
+
response = await fetch(`http://127.0.0.1:${port}/status`, { signal: deadline });
|
|
673
|
+
} catch {
|
|
674
|
+
// Refused, unreachable or timed out: no hub is answering at this instant.
|
|
675
|
+
failNoHub(port);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (!response.ok) failUnsupported();
|
|
679
|
+
|
|
680
|
+
let body;
|
|
681
|
+
try {
|
|
682
|
+
body = await response.text();
|
|
683
|
+
} catch {
|
|
684
|
+
// A listener that sends headers and then stalls is still an unanswered
|
|
685
|
+
// query, not an old hub: every timeout reports as "no hub".
|
|
686
|
+
if (deadline.aborted) failNoHub(port);
|
|
687
|
+
failUnsupported();
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
let payload;
|
|
691
|
+
try {
|
|
692
|
+
payload = JSON.parse(body);
|
|
693
|
+
} catch {
|
|
694
|
+
failUnsupported();
|
|
695
|
+
}
|
|
696
|
+
if (!isStatusPayload(payload)) failUnsupported();
|
|
697
|
+
|
|
698
|
+
if (state.json) {
|
|
699
|
+
process.stdout.write(body);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Payload order is meaningful: the hub is first, then clients sorted by name.
|
|
704
|
+
console.log(
|
|
705
|
+
renderTable(payload.terminals, [
|
|
706
|
+
{ header: "NAME", get: (e) => e.name },
|
|
707
|
+
{ header: "STATUS", get: (e) => formatTerminalStatus(e) },
|
|
708
|
+
{ header: "CONTEXT", get: (e) => formatContext(e.context) },
|
|
709
|
+
{ header: "CWD", get: (e) => (e.cwd ? displayPath(e.cwd) : UNKNOWN), dim: true },
|
|
710
|
+
]),
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
537
714
|
async function runResolve(state) {
|
|
538
715
|
const name = state.resolveName; // already normalized
|
|
539
716
|
const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
|
package/index.ts
CHANGED
|
@@ -12,12 +12,15 @@
|
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
14
|
VERSION as PI_VERSION,
|
|
15
|
+
keyHint,
|
|
16
|
+
keyText,
|
|
15
17
|
type ExtensionAPI,
|
|
16
18
|
type ExtensionContext,
|
|
17
19
|
} from "@earendil-works/pi-coding-agent";
|
|
18
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
20
|
+
import { Box, Text, type Component } from "@earendil-works/pi-tui";
|
|
19
21
|
import { Type } from "typebox";
|
|
20
22
|
import * as crypto from "node:crypto";
|
|
23
|
+
import { createServer, type Server as HttpServer } from "node:http";
|
|
21
24
|
import * as os from "node:os";
|
|
22
25
|
|
|
23
26
|
import { WebSocket, WebSocketServer } from "ws";
|
|
@@ -39,6 +42,8 @@ const CONNECT_HANDSHAKE_TIMEOUT_MS = 5_000;
|
|
|
39
42
|
const FLUSH_DELAY_MS = 200;
|
|
40
43
|
const BATCH_MAX_ITEMS = 20;
|
|
41
44
|
const BATCH_MAX_CHARS = 16_000;
|
|
45
|
+
// Visual rows of an incoming message kept while Pi's global expansion is off.
|
|
46
|
+
const PREVIEW_ROWS = 6;
|
|
42
47
|
|
|
43
48
|
// ─── Protocol ────────────────────────────────────────────────────────────────
|
|
44
49
|
|
|
@@ -150,6 +155,49 @@ function piVersionSupported(version: string): boolean {
|
|
|
150
155
|
return prerelease === undefined; // exactly the floor: only the release qualifies
|
|
151
156
|
}
|
|
152
157
|
|
|
158
|
+
// keyText is empty when app.tools.expand has no binding, and the native hint would then
|
|
159
|
+
// read " to expand" with no key in it, naming nothing the reader can press.
|
|
160
|
+
function expansionHint(dim: (text: string) => string): string {
|
|
161
|
+
return keyText("app.tools.expand")
|
|
162
|
+
? keyHint("app.tools.expand", "to expand")
|
|
163
|
+
: dim("bind app.tools.expand to expand");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The first PREVIEW_ROWS rows of `content`, plus a hint when rows were dropped.
|
|
168
|
+
*
|
|
169
|
+
* The rows come from rendering `content` at the real width, not from splitting the
|
|
170
|
+
* raw string on newlines: one long logical line wraps into many visual rows, and
|
|
171
|
+
* the same message yields different rows in a narrow window. Rendering is Text's
|
|
172
|
+
* job here, cached by (text, width), so the preview stays a slice of the identical
|
|
173
|
+
* output the expanded view shows.
|
|
174
|
+
*/
|
|
175
|
+
function messagePreview(
|
|
176
|
+
content: Text,
|
|
177
|
+
dim: (text: string) => string,
|
|
178
|
+
): Component {
|
|
179
|
+
const hint = new Text("", 0, 0);
|
|
180
|
+
return {
|
|
181
|
+
render(width: number): string[] {
|
|
182
|
+
const rows = content.render(width);
|
|
183
|
+
if (rows.length <= PREVIEW_ROWS) return rows;
|
|
184
|
+
// Rebuilt every render so a rebound key is never shown stale; it is one short
|
|
185
|
+
// line, and Text still caches the content rows, which are the expensive part.
|
|
186
|
+
hint.setText(
|
|
187
|
+
dim(`... (${rows.length - PREVIEW_ROWS} more lines, `) +
|
|
188
|
+
expansionHint(dim) +
|
|
189
|
+
dim(")"),
|
|
190
|
+
);
|
|
191
|
+
// The budget covers content only, so the hint may wrap rather than be cut off.
|
|
192
|
+
return [...rows.slice(0, PREVIEW_ROWS), ...hint.render(width)];
|
|
193
|
+
},
|
|
194
|
+
invalidate(): void {
|
|
195
|
+
content.invalidate();
|
|
196
|
+
hint.invalidate();
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
153
201
|
// ─── Extension ───────────────────────────────────────────────────────────────
|
|
154
202
|
|
|
155
203
|
export default function (pi: ExtensionAPI) {
|
|
@@ -209,6 +257,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
209
257
|
|
|
210
258
|
// Hub state
|
|
211
259
|
let wss: WebSocketServer | null = null;
|
|
260
|
+
// The hub owns the HTTP server the WS server rides on, because `wss.close()`
|
|
261
|
+
// never closes a server it was handed. Nulled wherever `wss` is.
|
|
262
|
+
let hubHttpServer: HttpServer | null = null;
|
|
212
263
|
const hubClients = new Map<WebSocket, string>(); // ws → terminal name
|
|
213
264
|
const hubTerminalStatuses = new Map<string, LinkStatus>(); // hub-authoritative
|
|
214
265
|
const hubTerminalContexts = new Map<string, ContextSnapshot>(); // hub-authoritative
|
|
@@ -226,6 +277,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
226
277
|
promise: Promise<void>;
|
|
227
278
|
socket: WebSocket | null; // dialing, not yet `ws`
|
|
228
279
|
server: WebSocketServer | null; // binding, not yet `wss`
|
|
280
|
+
httpServer: HttpServer | null; // binding, not yet `hubHttpServer`
|
|
229
281
|
};
|
|
230
282
|
let connectionAttempt: ConnectionAttempt | null = null;
|
|
231
283
|
|
|
@@ -600,6 +652,52 @@ export default function (pi: ExtensionAPI) {
|
|
|
600
652
|
return Array.from(allTerminalNames()).sort();
|
|
601
653
|
}
|
|
602
654
|
|
|
655
|
+
/**
|
|
656
|
+
* Hub: the `GET /status` snapshot. Pure reads — it mutates nothing and sends
|
|
657
|
+
* nothing, so observing the link cannot disturb it.
|
|
658
|
+
*
|
|
659
|
+
* Hub entry first, then clients sorted by name, so pollers see a stable order.
|
|
660
|
+
* `status`/`sinceSeconds` and `cwd` are omitted rather than invented when the
|
|
661
|
+
* hub has not heard them yet: a client is in `hubClients` from `register`, but
|
|
662
|
+
* its first `status_update` arrives a round trip later, and reporting a fresh
|
|
663
|
+
* peer as "idle" would be exactly the false inventory this endpoint exists to
|
|
664
|
+
* remove.
|
|
665
|
+
*/
|
|
666
|
+
function buildStatusPayload() {
|
|
667
|
+
const now = Date.now();
|
|
668
|
+
|
|
669
|
+
const describe = (name: string, entryRole: "hub" | "client") => {
|
|
670
|
+
const status = getStatusFor(name);
|
|
671
|
+
const cwd = getCwdFor(name);
|
|
672
|
+
const context = getContextFor(name);
|
|
673
|
+
return {
|
|
674
|
+
name,
|
|
675
|
+
role: entryRole,
|
|
676
|
+
...(status
|
|
677
|
+
? {
|
|
678
|
+
status: statusIdentity(status),
|
|
679
|
+
sinceSeconds: Math.round((now - status.since) / 1000),
|
|
680
|
+
}
|
|
681
|
+
: {}),
|
|
682
|
+
...(cwd ? { cwd } : {}),
|
|
683
|
+
context: context
|
|
684
|
+
? { tokens: context.tokens, window: context.contextWindow }
|
|
685
|
+
: null,
|
|
686
|
+
};
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
return {
|
|
690
|
+
hub: terminalName,
|
|
691
|
+
port: DEFAULT_PORT,
|
|
692
|
+
terminals: [
|
|
693
|
+
describe(terminalName, "hub"),
|
|
694
|
+
...Array.from(hubClients.values())
|
|
695
|
+
.sort()
|
|
696
|
+
.map((name) => describe(name, "client")),
|
|
697
|
+
],
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
603
701
|
function safeParse(data: string): LinkMessage | null {
|
|
604
702
|
try {
|
|
605
703
|
return JSON.parse(data);
|
|
@@ -975,10 +1073,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
975
1073
|
|
|
976
1074
|
function startHub(attempt: ConnectionAttempt): Promise<boolean> {
|
|
977
1075
|
return new Promise((resolve) => {
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1076
|
+
// Owning the HTTP server is what makes `GET /status` possible: a port-bound
|
|
1077
|
+
// `WebSocketServer` builds its own and answers every plain request with 426.
|
|
1078
|
+
// `ws` forwards this server's `listening` and `error`, so the election below
|
|
1079
|
+
// is unchanged.
|
|
1080
|
+
const httpServer = createServer((req, res) => {
|
|
1081
|
+
if (req.method === "GET" && req.url === "/status") {
|
|
1082
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1083
|
+
res.end(JSON.stringify(buildStatusPayload()));
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
res.writeHead(404);
|
|
1087
|
+
res.end();
|
|
981
1088
|
});
|
|
1089
|
+
attempt.httpServer = httpServer;
|
|
1090
|
+
|
|
1091
|
+
const server = new WebSocketServer({ server: httpServer });
|
|
982
1092
|
attempt.server = server;
|
|
983
1093
|
|
|
984
1094
|
// The phase settles once. `error` and a pre-listen `close` both report the
|
|
@@ -988,16 +1098,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
988
1098
|
if (settled) return;
|
|
989
1099
|
settled = true;
|
|
990
1100
|
if (attempt.server === server) attempt.server = null;
|
|
1101
|
+
if (attempt.httpServer === httpServer) attempt.httpServer = null;
|
|
991
1102
|
resolve(established);
|
|
992
1103
|
};
|
|
993
1104
|
|
|
994
1105
|
server.on("listening", () => {
|
|
995
1106
|
if (!attemptIsCurrent(attempt)) {
|
|
996
1107
|
server.close();
|
|
1108
|
+
httpServer.close();
|
|
997
1109
|
settle(false);
|
|
998
1110
|
return;
|
|
999
1111
|
}
|
|
1000
1112
|
wss = server;
|
|
1113
|
+
hubHttpServer = httpServer;
|
|
1001
1114
|
// If a client `/link-name` was in flight when the previous hub vanished,
|
|
1002
1115
|
// this terminal is now establishing hub identity, so honor that pending
|
|
1003
1116
|
// request. Otherwise keep the last hub-assigned identity — don't replay
|
|
@@ -1033,6 +1146,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1033
1146
|
// Reached when a pending server is cancelled; a no-op once established.
|
|
1034
1147
|
settle(false);
|
|
1035
1148
|
});
|
|
1149
|
+
|
|
1150
|
+
// Last, so no forwarded event can arrive before its handler exists.
|
|
1151
|
+
httpServer.listen(DEFAULT_PORT, "127.0.0.1");
|
|
1036
1152
|
});
|
|
1037
1153
|
}
|
|
1038
1154
|
|
|
@@ -1128,6 +1244,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1128
1244
|
promise: Promise.resolve(),
|
|
1129
1245
|
socket: null,
|
|
1130
1246
|
server: null,
|
|
1247
|
+
httpServer: null,
|
|
1131
1248
|
};
|
|
1132
1249
|
connectionAttempt = attempt;
|
|
1133
1250
|
attempt.promise = runAttempt(attempt);
|
|
@@ -1172,8 +1289,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1172
1289
|
const attempt = connectionAttempt;
|
|
1173
1290
|
if (!attempt) return;
|
|
1174
1291
|
connectionAttempt = null;
|
|
1175
|
-
attempt
|
|
1176
|
-
|
|
1292
|
+
// Read every handle first: closing the WS server can settle the attempt, and
|
|
1293
|
+
// settling clears these fields. Closing the HTTP server is not optional — it
|
|
1294
|
+
// holds the port, so a skipped close squats :9900 for the whole machine.
|
|
1295
|
+
const { socket, server, httpServer } = attempt;
|
|
1296
|
+
socket?.close();
|
|
1297
|
+
server?.close();
|
|
1298
|
+
httpServer?.close();
|
|
1177
1299
|
}
|
|
1178
1300
|
|
|
1179
1301
|
function scheduleReconnect() {
|
|
@@ -1219,6 +1341,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1219
1341
|
hubClients.clear();
|
|
1220
1342
|
wss.close();
|
|
1221
1343
|
wss = null;
|
|
1344
|
+
hubHttpServer?.close();
|
|
1345
|
+
hubHttpServer = null;
|
|
1222
1346
|
}
|
|
1223
1347
|
|
|
1224
1348
|
role = "disconnected";
|
|
@@ -1413,8 +1537,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
1413
1537
|
return textResult("Not connected to link", { error: "not_connected" });
|
|
1414
1538
|
}
|
|
1415
1539
|
|
|
1416
|
-
|
|
1417
|
-
|
|
1540
|
+
// Collapsed one-line preview for the two outgoing tools. Whitespace is normalized so a
|
|
1541
|
+
// multiline message stays on the tool's single preview line, then clipped to 60
|
|
1542
|
+
// characters. The hint appears only when characters are actually hidden: expanding shows
|
|
1543
|
+
// the original text, so collapsing whitespace alone is not something to advertise.
|
|
1544
|
+
// Styling is applied per segment, like messagePreview above: the hint ends with a
|
|
1545
|
+
// foreground reset, so punctuation appended after it would render undimmed.
|
|
1546
|
+
function truncatePreview(text: string, dim: (text: string) => string) {
|
|
1547
|
+
const preview = text.replace(/\s+/g, " ");
|
|
1548
|
+
return preview.length > 60
|
|
1549
|
+
? dim(preview.slice(0, 60) + "... (") +
|
|
1550
|
+
expansionHint(dim) +
|
|
1551
|
+
dim(")")
|
|
1552
|
+
: dim(preview);
|
|
1418
1553
|
}
|
|
1419
1554
|
|
|
1420
1555
|
// Shared "target not found" result for the send/compact tools.
|
|
@@ -1489,16 +1624,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
1489
1624
|
return textResult(`${verb} ${target}`, { to: params.to });
|
|
1490
1625
|
},
|
|
1491
1626
|
|
|
1492
|
-
renderCall(args, theme) {
|
|
1627
|
+
renderCall(args, theme, context) {
|
|
1628
|
+
const dim = (text: string) => theme.fg("dim", text);
|
|
1493
1629
|
const preview =
|
|
1494
1630
|
typeof args.message === "string"
|
|
1495
|
-
?
|
|
1496
|
-
|
|
1631
|
+
? context.expanded
|
|
1632
|
+
? dim(args.message)
|
|
1633
|
+
: truncatePreview(args.message, dim)
|
|
1634
|
+
: dim("...");
|
|
1497
1635
|
const text =
|
|
1498
1636
|
theme.fg("toolTitle", theme.bold("link_send ")) +
|
|
1499
1637
|
theme.fg("accent", args.to) +
|
|
1500
1638
|
"\n " +
|
|
1501
|
-
|
|
1639
|
+
preview;
|
|
1502
1640
|
return new Text(text, 0, 0);
|
|
1503
1641
|
},
|
|
1504
1642
|
|
|
@@ -1602,11 +1740,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
1602
1740
|
});
|
|
1603
1741
|
},
|
|
1604
1742
|
|
|
1605
|
-
renderCall(args, theme) {
|
|
1743
|
+
renderCall(args, theme, context) {
|
|
1606
1744
|
let text = theme.fg("toolTitle", theme.bold("link_compact "));
|
|
1607
1745
|
text += theme.fg("accent", String(args.to));
|
|
1746
|
+
const dim = (t: string) => theme.fg("dim", t);
|
|
1608
1747
|
if (typeof args.instructions === "string")
|
|
1609
|
-
text +=
|
|
1748
|
+
text +=
|
|
1749
|
+
"\n " +
|
|
1750
|
+
(context.expanded
|
|
1751
|
+
? dim(args.instructions)
|
|
1752
|
+
: truncatePreview(args.instructions, dim));
|
|
1610
1753
|
return new Text(text, 0, 0);
|
|
1611
1754
|
},
|
|
1612
1755
|
|
|
@@ -1838,12 +1981,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
1838
1981
|
|
|
1839
1982
|
// ── Message renderer ─────────────────────────────────────────────────────
|
|
1840
1983
|
|
|
1841
|
-
pi.registerMessageRenderer("link", (message,
|
|
1984
|
+
pi.registerMessageRenderer("link", (message, options, theme) => {
|
|
1842
1985
|
const from =
|
|
1843
1986
|
(message.details as Record<string, unknown> | undefined)?.from ?? "link";
|
|
1844
1987
|
const text =
|
|
1845
1988
|
theme.fg("accent", `⚡ [${from}] `) +
|
|
1846
1989
|
theme.fg("text", String(message.content));
|
|
1847
|
-
|
|
1990
|
+
const content = new Text(text, 0, 0);
|
|
1991
|
+
// The same panel Pi draws around extension messages by default: returning our own
|
|
1992
|
+
// component bypasses the host's box, so a link message would otherwise sit unframed
|
|
1993
|
+
// among framed ones. `outputPad` is the user's configured output padding.
|
|
1994
|
+
const box = new Box(options.outputPad, 1, (t) => theme.bg("customMessageBg", t));
|
|
1995
|
+
box.addChild(
|
|
1996
|
+
options.expanded
|
|
1997
|
+
? content
|
|
1998
|
+
: messagePreview(content, (s) => theme.fg("muted", s)),
|
|
1999
|
+
);
|
|
2000
|
+
return box;
|
|
1848
2001
|
});
|
|
1849
2002
|
}
|
package/package.json
CHANGED