pi-link 0.3.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 +22 -0
- package/README.md +719 -621
- package/bin/pi-link.mjs +178 -1
- package/index.ts +82 -5
- 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
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { Text } from "@earendil-works/pi-tui";
|
|
19
19
|
import { Type } from "typebox";
|
|
20
20
|
import * as crypto from "node:crypto";
|
|
21
|
+
import { createServer, type Server as HttpServer } from "node:http";
|
|
21
22
|
import * as os from "node:os";
|
|
22
23
|
|
|
23
24
|
import { WebSocket, WebSocketServer } from "ws";
|
|
@@ -209,6 +210,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
209
210
|
|
|
210
211
|
// Hub state
|
|
211
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;
|
|
212
216
|
const hubClients = new Map<WebSocket, string>(); // ws → terminal name
|
|
213
217
|
const hubTerminalStatuses = new Map<string, LinkStatus>(); // hub-authoritative
|
|
214
218
|
const hubTerminalContexts = new Map<string, ContextSnapshot>(); // hub-authoritative
|
|
@@ -226,6 +230,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
226
230
|
promise: Promise<void>;
|
|
227
231
|
socket: WebSocket | null; // dialing, not yet `ws`
|
|
228
232
|
server: WebSocketServer | null; // binding, not yet `wss`
|
|
233
|
+
httpServer: HttpServer | null; // binding, not yet `hubHttpServer`
|
|
229
234
|
};
|
|
230
235
|
let connectionAttempt: ConnectionAttempt | null = null;
|
|
231
236
|
|
|
@@ -600,6 +605,52 @@ export default function (pi: ExtensionAPI) {
|
|
|
600
605
|
return Array.from(allTerminalNames()).sort();
|
|
601
606
|
}
|
|
602
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
|
+
|
|
603
654
|
function safeParse(data: string): LinkMessage | null {
|
|
604
655
|
try {
|
|
605
656
|
return JSON.parse(data);
|
|
@@ -975,10 +1026,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
975
1026
|
|
|
976
1027
|
function startHub(attempt: ConnectionAttempt): Promise<boolean> {
|
|
977
1028
|
return new Promise((resolve) => {
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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();
|
|
981
1041
|
});
|
|
1042
|
+
attempt.httpServer = httpServer;
|
|
1043
|
+
|
|
1044
|
+
const server = new WebSocketServer({ server: httpServer });
|
|
982
1045
|
attempt.server = server;
|
|
983
1046
|
|
|
984
1047
|
// The phase settles once. `error` and a pre-listen `close` both report the
|
|
@@ -988,16 +1051,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
988
1051
|
if (settled) return;
|
|
989
1052
|
settled = true;
|
|
990
1053
|
if (attempt.server === server) attempt.server = null;
|
|
1054
|
+
if (attempt.httpServer === httpServer) attempt.httpServer = null;
|
|
991
1055
|
resolve(established);
|
|
992
1056
|
};
|
|
993
1057
|
|
|
994
1058
|
server.on("listening", () => {
|
|
995
1059
|
if (!attemptIsCurrent(attempt)) {
|
|
996
1060
|
server.close();
|
|
1061
|
+
httpServer.close();
|
|
997
1062
|
settle(false);
|
|
998
1063
|
return;
|
|
999
1064
|
}
|
|
1000
1065
|
wss = server;
|
|
1066
|
+
hubHttpServer = httpServer;
|
|
1001
1067
|
// If a client `/link-name` was in flight when the previous hub vanished,
|
|
1002
1068
|
// this terminal is now establishing hub identity, so honor that pending
|
|
1003
1069
|
// request. Otherwise keep the last hub-assigned identity — don't replay
|
|
@@ -1033,6 +1099,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1033
1099
|
// Reached when a pending server is cancelled; a no-op once established.
|
|
1034
1100
|
settle(false);
|
|
1035
1101
|
});
|
|
1102
|
+
|
|
1103
|
+
// Last, so no forwarded event can arrive before its handler exists.
|
|
1104
|
+
httpServer.listen(DEFAULT_PORT, "127.0.0.1");
|
|
1036
1105
|
});
|
|
1037
1106
|
}
|
|
1038
1107
|
|
|
@@ -1128,6 +1197,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1128
1197
|
promise: Promise.resolve(),
|
|
1129
1198
|
socket: null,
|
|
1130
1199
|
server: null,
|
|
1200
|
+
httpServer: null,
|
|
1131
1201
|
};
|
|
1132
1202
|
connectionAttempt = attempt;
|
|
1133
1203
|
attempt.promise = runAttempt(attempt);
|
|
@@ -1172,8 +1242,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1172
1242
|
const attempt = connectionAttempt;
|
|
1173
1243
|
if (!attempt) return;
|
|
1174
1244
|
connectionAttempt = null;
|
|
1175
|
-
attempt
|
|
1176
|
-
|
|
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();
|
|
1177
1252
|
}
|
|
1178
1253
|
|
|
1179
1254
|
function scheduleReconnect() {
|
|
@@ -1219,6 +1294,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1219
1294
|
hubClients.clear();
|
|
1220
1295
|
wss.close();
|
|
1221
1296
|
wss = null;
|
|
1297
|
+
hubHttpServer?.close();
|
|
1298
|
+
hubHttpServer = null;
|
|
1222
1299
|
}
|
|
1223
1300
|
|
|
1224
1301
|
role = "disconnected";
|
package/package.json
CHANGED