@staix/agent-hub 0.3.1 → 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 +15 -0
- package/README.md +51 -3
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +171 -26
- package/src/adapters/acp.ts +6 -2
- package/src/adapters/claude-channel.ts +32 -6
- package/src/adapters/codex-appserver.ts +37 -9
- package/src/cli/main.ts +117 -65
- package/src/hub/child-process.ts +44 -0
- package/src/hub/control-client.ts +56 -13
- package/src/hub/daemon.ts +106 -42
- package/src/hub/lifecycle.ts +181 -0
- package/src/hub/manager.ts +232 -0
- package/src/hub/ports.ts +40 -11
- package/src/hub/project.ts +93 -0
- package/src/hub/registry.ts +204 -0
- package/src/hub/ui.ts +29 -4
- package/src/memory/recall.ts +26 -3
- package/src/switchyard/sidecar.ts +39 -5
- package/src/ui/index.html +103 -18
package/src/cli/main.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
3
|
-
import { existsSync,
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { ControlClient, readControl } from "../hub/control-client.ts";
|
|
6
|
-
import { loadConfig
|
|
6
|
+
import { loadConfig } from "../hub/daemon.ts";
|
|
7
|
+
import { projectContext } from "../hub/project.ts";
|
|
8
|
+
import { Registry, type Project } from "../hub/registry.ts";
|
|
9
|
+
import { inspectProject, startProject, stopProject, runProjectDaemon } from "../hub/lifecycle.ts";
|
|
10
|
+
import { openManager, startManager, stopManager } from "../hub/manager.ts";
|
|
7
11
|
import type { BusEvent } from "../hub/bus.ts";
|
|
8
|
-
import { allocatePorts, CODEX_APP, CODEX_PROXY, CONTROL, SWITCHYARD } from "../hub/ports.ts";
|
|
9
12
|
import { OmniRoute } from "../omniroute/client.ts";
|
|
10
13
|
import { MemoryClient } from "../memory/client.ts";
|
|
11
14
|
import { init } from "./init.ts";
|
|
@@ -17,6 +20,12 @@ import { createInterface } from "node:readline/promises";
|
|
|
17
20
|
|
|
18
21
|
const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one project directory
|
|
19
22
|
|
|
23
|
+
ahub --project <path|id> <command> select a repository or worktree explicitly
|
|
24
|
+
ahub projects [--json] list registered projects and live status
|
|
25
|
+
ahub projects remove <id> forget a stopped registration (keeps project files)
|
|
26
|
+
ahub status --all show every registered project
|
|
27
|
+
ahub ui --all [--no-open] open the unified project dashboard
|
|
28
|
+
ahub ui --all --stop stop only the dashboard manager
|
|
20
29
|
ahub setup [--yes] install or update the Claude Code channel plugin from this package, then run doctor
|
|
21
30
|
ahub init write .agenthub/config.json and the CLAUDE.md / AGENTS.md marker blocks
|
|
22
31
|
ahub up [--unattended] start the daemon for this directory
|
|
@@ -45,25 +54,69 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
|
|
|
45
54
|
ahub permit <id> <option> answer a permission request shown by tail ("deny" cancels)
|
|
46
55
|
ahub status | logs [-f] | doctor | kill`;
|
|
47
56
|
|
|
48
|
-
const
|
|
49
|
-
|
|
57
|
+
const argv = process.argv.slice(2);
|
|
58
|
+
let selector: string | undefined;
|
|
59
|
+
if (argv[0] === "--project") {
|
|
60
|
+
argv.shift();
|
|
61
|
+
selector = argv.shift();
|
|
62
|
+
if (!selector || selector.startsWith("--")) fail("--project needs a path or project ID");
|
|
63
|
+
}
|
|
64
|
+
const [cmd = "help", ...args] = argv;
|
|
65
|
+
let selected: { root: string; stateDir: string };
|
|
66
|
+
try {
|
|
67
|
+
if (selector) {
|
|
68
|
+
const registry = new Registry();
|
|
69
|
+
try {
|
|
70
|
+
const known = registry.get(selector);
|
|
71
|
+
const context = known ?? projectContext(selector, {});
|
|
72
|
+
const registered = known ?? registry.list().find((p) => p.root === context.root);
|
|
73
|
+
selected = registered ?? context;
|
|
74
|
+
} finally { registry.close(); }
|
|
75
|
+
} else selected = projectContext(process.cwd());
|
|
76
|
+
} catch (error) { fail((error as Error).message); }
|
|
77
|
+
const cwd = selected.root;
|
|
78
|
+
const stateDir = selected.stateDir;
|
|
79
|
+
try { process.chdir(cwd); } catch { fail(`project directory is unavailable: ${cwd}`); }
|
|
50
80
|
const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
|
|
51
|
-
const
|
|
81
|
+
const lifecycle = { inspectProject, startProject, stopProject };
|
|
82
|
+
const connect = () => ControlClient.connect(stateDir, { role: "console", projectRoot: cwd });
|
|
52
83
|
|
|
53
|
-
|
|
84
|
+
function registeredProject(): Project {
|
|
85
|
+
const registry = new Registry();
|
|
86
|
+
try { return registry.register(cwd, stateDir); }
|
|
87
|
+
finally { registry.close(); }
|
|
88
|
+
}
|
|
54
89
|
|
|
55
90
|
async function healthy(): Promise<boolean> {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
91
|
+
let hub: ControlClient | undefined;
|
|
92
|
+
try { hub = await connect(); const reply = await hub.request({ t: "status" }, 3000); return reply.status?.cwd === cwd; }
|
|
93
|
+
catch { return false; }
|
|
94
|
+
finally { hub?.close(); }
|
|
59
95
|
}
|
|
60
96
|
|
|
61
97
|
function exec(bin: string, argv: string[]): never {
|
|
62
|
-
const res = spawnSync(bin, argv, { stdio: "inherit", env: { ...process.env, AGENTHUB_STATE_DIR: stateDir } });
|
|
98
|
+
const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env: { ...process.env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
|
|
63
99
|
if (res.error) fail(`cannot run ${bin}: ${res.error.message}`);
|
|
64
100
|
process.exit(res.status ?? 1);
|
|
65
101
|
}
|
|
66
102
|
|
|
103
|
+
async function projectRows() {
|
|
104
|
+
const registry = new Registry();
|
|
105
|
+
try { return await Promise.all(registry.list().map(async (project) => ({ ...project, ...await inspectProject(project) }))); }
|
|
106
|
+
finally { registry.close(); }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function printProjects(json = false) {
|
|
110
|
+
const rows = await projectRows();
|
|
111
|
+
if (json) return console.log(JSON.stringify(rows, null, 2));
|
|
112
|
+
for (const row of rows) {
|
|
113
|
+
console.log(`${row.id} ${row.state.padEnd(12)} ${row.root}`);
|
|
114
|
+
if (row.status) console.log(` peers ${Object.keys(row.status.peers ?? {}).length}, control ${row.status.controlPort}, tasks ${JSON.stringify(row.status.tasks ?? {})}`);
|
|
115
|
+
if (row.error) console.log(` ${row.error}`);
|
|
116
|
+
}
|
|
117
|
+
if (!rows.length) console.log("No registered projects. Run ahub init in a project directory.");
|
|
118
|
+
}
|
|
119
|
+
|
|
67
120
|
function fail(message: string): never {
|
|
68
121
|
console.error(`ahub: ${message}`);
|
|
69
122
|
process.exit(1);
|
|
@@ -118,19 +171,48 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
118
171
|
"--version": () => console.log(VERSION),
|
|
119
172
|
version: () => console.log(VERSION),
|
|
120
173
|
|
|
174
|
+
projects: async () => {
|
|
175
|
+
if (args[0] === "remove") {
|
|
176
|
+
if (args.length !== 2) fail("usage: ahub projects remove <id>");
|
|
177
|
+
const registry = new Registry();
|
|
178
|
+
try { registry.remove(args[1]!); } finally { registry.close(); }
|
|
179
|
+
console.log("registration removed; project files were kept");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (args.some((arg) => arg !== "--json")) fail("usage: ahub projects [--json]");
|
|
183
|
+
await printProjects(args.includes("--json"));
|
|
184
|
+
},
|
|
185
|
+
|
|
121
186
|
ui: async () => {
|
|
122
|
-
if (args.some((arg) =>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
187
|
+
if (args.some((arg) => !["--no-open", "--all", "--stop"].includes(arg))) fail("usage: ahub ui [--all] [--no-open] | --all --stop");
|
|
188
|
+
if (args.includes("--stop")) {
|
|
189
|
+
if (!args.includes("--all") || args.includes("--no-open")) fail("usage: ahub ui --all --stop");
|
|
190
|
+
await stopManager();
|
|
191
|
+
return console.log("dashboard manager stopped; project hubs were kept running");
|
|
192
|
+
}
|
|
193
|
+
let url: string;
|
|
194
|
+
if (args.includes("--all")) url = await openManager();
|
|
195
|
+
else {
|
|
196
|
+
const hub = await connect();
|
|
197
|
+
try {
|
|
198
|
+
const res = await hub.request({ t: "ui" }, 10_000);
|
|
199
|
+
if (!res.ok) fail(res.error);
|
|
200
|
+
url = res.url;
|
|
201
|
+
} finally { hub.close(); }
|
|
202
|
+
}
|
|
203
|
+
if (args.includes("--no-open")) return console.log(url);
|
|
128
204
|
const opener = process.platform === "darwin" ? "open" : "xdg-open";
|
|
129
|
-
const opened = spawnSync(opener, [
|
|
130
|
-
if (opened.error || opened.status !== 0) console.log(`Open this one-time link within 60 seconds:\n${
|
|
205
|
+
const opened = spawnSync(opener, [url], { stdio: "ignore", timeout: 10_000 });
|
|
206
|
+
if (opened.error || opened.status !== 0) console.log(`Open this one-time link within 60 seconds:\n${url}`);
|
|
131
207
|
else console.log("Dashboard opened. The session expires in one hour; run ahub ui to reopen it.");
|
|
132
208
|
},
|
|
133
209
|
|
|
210
|
+
manager: async () => {
|
|
211
|
+
const manager = await startManager({ lifecycle });
|
|
212
|
+
for (const signal of ["SIGINT", "SIGTERM"] as const) process.on(signal, () => void manager.stop());
|
|
213
|
+
await manager.stopped;
|
|
214
|
+
},
|
|
215
|
+
|
|
134
216
|
setup: async () => {
|
|
135
217
|
const root = join(import.meta.dir, "..", "..");
|
|
136
218
|
const state = () => {
|
|
@@ -158,46 +240,22 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
158
240
|
|
|
159
241
|
init: () => {
|
|
160
242
|
const changed = init(cwd);
|
|
243
|
+
registeredProject();
|
|
161
244
|
console.log(changed.length ? changed.map((p) => `wrote ${p}`).join("\n") : "already up to date");
|
|
162
245
|
},
|
|
163
246
|
|
|
164
|
-
// Internal: the detached daemon process started by
|
|
247
|
+
// Internal: the detached daemon process started by ahub up or the manager.
|
|
165
248
|
daemon: async () => {
|
|
166
|
-
// Long-lived process: one bad adapter callback must not take every peer down. stderr is hub.log.
|
|
167
249
|
process.on("unhandledRejection", (e) => console.error(`${new Date().toISOString()} unhandled rejection:`, e));
|
|
168
250
|
process.on("uncaughtException", (e) => console.error(`${new Date().toISOString()} uncaught exception:`, e));
|
|
169
|
-
|
|
170
|
-
const daemon = await startDaemon({
|
|
171
|
-
cwd,
|
|
172
|
-
stateDir,
|
|
173
|
-
controlPort: base + CONTROL,
|
|
174
|
-
codexAppPort: base + CODEX_APP,
|
|
175
|
-
codexProxyPort: base + CODEX_PROXY,
|
|
176
|
-
switchyardPort: base + SWITCHYARD,
|
|
177
|
-
unattended: unattendedEnv || args.includes("--unattended"),
|
|
178
|
-
});
|
|
179
|
-
for (const sig of ["SIGINT", "SIGTERM"] as const) process.on(sig, () => void daemon.stop());
|
|
180
|
-
await daemon.stopped;
|
|
181
|
-
process.exit(0);
|
|
251
|
+
await runProjectDaemon(registeredProject(), unattendedEnv || args.includes("--unattended"));
|
|
182
252
|
},
|
|
183
253
|
|
|
184
254
|
up: async () => {
|
|
185
|
-
if (await healthy()) return console.log("hub is already running");
|
|
186
255
|
const unattended = unattendedEnv || args.includes("--unattended");
|
|
187
256
|
if (unattended) console.error(UNATTENDED_WARNING);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
spawn(process.execPath, [import.meta.path, "daemon", ...(unattended ? ["--unattended"] : [])], {
|
|
191
|
-
cwd,
|
|
192
|
-
detached: true,
|
|
193
|
-
stdio: ["ignore", log, log],
|
|
194
|
-
env: { ...process.env, AGENTHUB_STATE_DIR: stateDir },
|
|
195
|
-
}).unref();
|
|
196
|
-
for (let i = 0; i < 50; i++) {
|
|
197
|
-
if (await healthy()) return console.log(`ahub up (${readControl(stateDir)!.url}), state in ${stateDir}`);
|
|
198
|
-
await Bun.sleep(100);
|
|
199
|
-
}
|
|
200
|
-
fail(`daemon did not start; see ${join(stateDir, "hub.log")}`);
|
|
257
|
+
const status = await startProject(registeredProject(), { unattended });
|
|
258
|
+
console.log(`ahub up (ws://127.0.0.1:${status.controlPort}), state in ${stateDir}`);
|
|
201
259
|
},
|
|
202
260
|
|
|
203
261
|
claude: () => {
|
|
@@ -370,6 +428,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
370
428
|
},
|
|
371
429
|
|
|
372
430
|
status: async () => {
|
|
431
|
+
if (args.includes("--all")) return printProjects(args.includes("--json"));
|
|
373
432
|
const hub = await connect();
|
|
374
433
|
const { status } = await hub.request({ t: "status" });
|
|
375
434
|
hub.close();
|
|
@@ -385,23 +444,16 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
385
444
|
logs: () => exec("tail", [args.includes("-f") ? "-f" : "-n100", join(stateDir, "hub.log")]),
|
|
386
445
|
|
|
387
446
|
kill: async () => {
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
if (!existsSync(pidFile)) return console.log("hub is not running");
|
|
396
|
-
// The pid file may be stale and the pid reused: signal it only if it still is a hub daemon.
|
|
397
|
-
const pid = Number(readFileSync(pidFile, "utf8"));
|
|
398
|
-
const owner = spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" }).stdout ?? "";
|
|
399
|
-
if (/main\.ts daemon|ahub daemon/.test(owner)) {
|
|
400
|
-
process.kill(pid, "SIGTERM");
|
|
401
|
-
return console.log("hub signalled");
|
|
447
|
+
const registry = new Registry();
|
|
448
|
+
let project: Project | undefined;
|
|
449
|
+
try { project = registry.list().find((p) => p.root === cwd && p.stateDir === stateDir); }
|
|
450
|
+
finally { registry.close(); }
|
|
451
|
+
if (!project) {
|
|
452
|
+
if (!readControl(stateDir) && !existsSync(join(stateDir, "hub.pid"))) return console.log("hub is not running");
|
|
453
|
+
fail("hub has no matching registration; use its matching CLI to stop it before upgrading");
|
|
402
454
|
}
|
|
403
|
-
|
|
404
|
-
console.log("hub
|
|
455
|
+
await stopProject(project);
|
|
456
|
+
console.log("hub stopped");
|
|
405
457
|
},
|
|
406
458
|
|
|
407
459
|
doctor: async () => {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stop a child owned by this hub and confirm that it exited. A timeout is an
|
|
5
|
+
* incomplete shutdown, even after SIGKILL, because callers must not reuse a
|
|
6
|
+
* port or claim ownership while the old process may still be alive.
|
|
7
|
+
*/
|
|
8
|
+
export async function stopOwnedProcess(proc: ChildProcess, termMs = 1_000, killMs = 2_000): Promise<void> {
|
|
9
|
+
if (proc.exitCode !== null || proc.signalCode !== null || proc.pid === undefined) return;
|
|
10
|
+
|
|
11
|
+
const waitForExit = (timeoutMs: number): Promise<boolean> =>
|
|
12
|
+
new Promise((resolve) => {
|
|
13
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
14
|
+
const done = (exited: boolean) => {
|
|
15
|
+
if (timer) clearTimeout(timer);
|
|
16
|
+
proc.off("exit", onExit);
|
|
17
|
+
proc.off("error", onError);
|
|
18
|
+
resolve(exited);
|
|
19
|
+
};
|
|
20
|
+
const onExit = () => done(true);
|
|
21
|
+
const onError = () => done(true); // spawn errors have no owned process to wait for
|
|
22
|
+
proc.once("exit", onExit);
|
|
23
|
+
proc.once("error", onError);
|
|
24
|
+
timer = setTimeout(() => done(false), timeoutMs);
|
|
25
|
+
timer.unref?.();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
proc.kill("SIGTERM");
|
|
30
|
+
} catch {
|
|
31
|
+
if (proc.exitCode !== null || proc.signalCode !== null) return;
|
|
32
|
+
throw new Error(`owned child ${proc.pid} could not be terminated`);
|
|
33
|
+
}
|
|
34
|
+
if (proc.exitCode !== null || proc.signalCode !== null || (await waitForExit(termMs))) return;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
proc.kill("SIGKILL");
|
|
38
|
+
} catch {
|
|
39
|
+
if (proc.exitCode !== null || proc.signalCode !== null) return;
|
|
40
|
+
throw new Error(`owned child ${proc.pid} could not be killed`);
|
|
41
|
+
}
|
|
42
|
+
if (proc.exitCode !== null || proc.signalCode !== null || (await waitForExit(killMs))) return;
|
|
43
|
+
throw new Error(`owned child ${proc.pid} shutdown incomplete after SIGKILL`);
|
|
44
|
+
}
|
|
@@ -1,25 +1,41 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { projectContext } from "./project.ts";
|
|
3
4
|
|
|
4
5
|
export function stateDirFor(cwd: string): string {
|
|
5
|
-
return
|
|
6
|
+
return projectContext(cwd).stateDir;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap. The plugin is installed apart from the daemon, so they can drift. */
|
|
9
|
-
export const PROTOCOL =
|
|
10
|
+
export const PROTOCOL = 7; // project/instance identity and console-only dashboard forwarding
|
|
10
11
|
|
|
11
12
|
export interface Hello {
|
|
12
13
|
/** `tools`: acts for `peer` (task tools, hub_send) without being a delivery target: the MCP server Kimi and Codex run. */
|
|
13
14
|
role: "peer" | "console" | "tools";
|
|
14
15
|
peer?: string;
|
|
16
|
+
projectId?: string;
|
|
17
|
+
instanceId?: string;
|
|
18
|
+
projectRoot?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ControlDescriptor {
|
|
22
|
+
url: string;
|
|
23
|
+
token: string;
|
|
24
|
+
projectId?: string;
|
|
25
|
+
instanceId?: string;
|
|
26
|
+
cwd?: string;
|
|
27
|
+
protocol?: number;
|
|
28
|
+
pid?: number;
|
|
15
29
|
}
|
|
16
30
|
|
|
17
31
|
/** Where the daemon of this state dir listens. Re-read before every connect: port and token change per run. */
|
|
18
|
-
export function readControl(stateDir: string):
|
|
32
|
+
export function readControl(stateDir: string): ControlDescriptor | undefined {
|
|
19
33
|
try {
|
|
20
34
|
const status = JSON.parse(readFileSync(join(stateDir, "status.json"), "utf8"));
|
|
21
35
|
const token = readFileSync(join(stateDir, "control-token"), "utf8").trim();
|
|
22
|
-
|
|
36
|
+
if (!Number.isInteger(status.controlPort) || status.controlPort < 1 || status.controlPort > 65535 || !token) return undefined;
|
|
37
|
+
return { url: `ws://127.0.0.1:${status.controlPort}`, token, projectId: status.projectId,
|
|
38
|
+
instanceId: status.instanceId, cwd: status.cwd, protocol: status.protocol, pid: status.pid };
|
|
23
39
|
} catch {
|
|
24
40
|
return undefined;
|
|
25
41
|
}
|
|
@@ -30,36 +46,63 @@ export class ControlClient {
|
|
|
30
46
|
private nextRid = 1;
|
|
31
47
|
private readonly pending = new Map<number, (msg: any) => void>();
|
|
32
48
|
onPush: (msg: any) => void = () => {};
|
|
33
|
-
|
|
49
|
+
/** `code`/`reason`: what the hub closed with, e.g. 4000 when another client attached as the same peer. */
|
|
50
|
+
onClose: (code: number, reason: string) => void = () => {};
|
|
34
51
|
|
|
35
52
|
private constructor(private readonly ws: WebSocket) {}
|
|
36
53
|
|
|
37
|
-
static connect(stateDir: string, hello: Hello): Promise<ControlClient> {
|
|
54
|
+
static connect(stateDir: string, hello: Hello, timeoutMs = 3000): Promise<ControlClient> {
|
|
38
55
|
const control = readControl(stateDir);
|
|
39
56
|
if (!control) return Promise.reject(new Error(`no hub running for ${stateDir} (run: ahub up)`));
|
|
57
|
+
if (control.protocol !== undefined && control.protocol !== PROTOCOL) {
|
|
58
|
+
return Promise.reject(Object.assign(new Error(`wire version mismatch: hub speaks ${control.protocol}, CLI speaks ${PROTOCOL}; stop it with its matching CLI, then upgrade and restart`), { code: 4426 }));
|
|
59
|
+
}
|
|
60
|
+
if ((hello.projectId && hello.projectId !== control.projectId) ||
|
|
61
|
+
(hello.instanceId && hello.instanceId !== control.instanceId) ||
|
|
62
|
+
(hello.projectRoot && hello.projectRoot !== control.cwd)) {
|
|
63
|
+
return Promise.reject(Object.assign(new Error("hub project or instance does not match the selected project"), { code: 4404 }));
|
|
64
|
+
}
|
|
65
|
+
const expected = { projectId: hello.projectId ?? control.projectId, instanceId: hello.instanceId ?? control.instanceId,
|
|
66
|
+
projectRoot: hello.projectRoot ?? control.cwd };
|
|
40
67
|
return new Promise((resolve, reject) => {
|
|
41
68
|
const ws = new WebSocket(control.url);
|
|
42
69
|
const client = new ControlClient(ws);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
70
|
+
const timer = setTimeout(() => refuse(new Error(`hub connection timed out at ${control.url}`)), timeoutMs);
|
|
71
|
+
const refuse = (error: Error) => { clearTimeout(timer); reject(error); ws.close(); };
|
|
72
|
+
ws.onerror = () => refuse(new Error(`cannot reach hub at ${control.url}`));
|
|
73
|
+
ws.onclose = (ev) => {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
// A refused hello carries its reason (bad token, wire version, peer id); say that, not a guess.
|
|
76
|
+
reject(Object.assign(new Error(`hub closed the connection: ${ev.reason || "stale token?"}`), { code: ev.code }));
|
|
46
77
|
for (const done of client.pending.values()) done({ ok: false, error: "hub connection closed" });
|
|
47
78
|
client.pending.clear();
|
|
48
|
-
client.onClose();
|
|
79
|
+
client.onClose(ev.code, ev.reason);
|
|
49
80
|
};
|
|
50
81
|
ws.onmessage = (ev) => {
|
|
51
|
-
|
|
82
|
+
let msg: any;
|
|
83
|
+
try { msg = JSON.parse(String(ev.data)); } catch { return refuse(new Error("invalid hub response")); }
|
|
84
|
+
if (!msg || typeof msg !== "object" || Array.isArray(msg)) return refuse(new Error("invalid hub response"));
|
|
52
85
|
const done = msg.rid !== undefined ? client.pending.get(msg.rid) : undefined;
|
|
53
86
|
if (!done) return client.onPush(msg);
|
|
54
87
|
client.pending.delete(msg.rid);
|
|
55
88
|
done(msg);
|
|
56
89
|
};
|
|
57
|
-
ws.onopen = () => void client.request({ t: "hello", v: PROTOCOL, token: control.token, ...hello }).then(() =>
|
|
90
|
+
ws.onopen = () => void client.request({ t: "hello", v: PROTOCOL, token: control.token, ...hello, ...expected }, timeoutMs).then((reply) => {
|
|
91
|
+
if (reply.t !== "welcome" || reply.ok === false) return refuse(new Error(reply.error ?? "hub refused handshake"));
|
|
92
|
+
if ((expected.projectId && reply.projectId !== expected.projectId) ||
|
|
93
|
+
(expected.instanceId && reply.instanceId !== expected.instanceId) ||
|
|
94
|
+
(expected.projectRoot && reply.cwd !== expected.projectRoot)) {
|
|
95
|
+
return refuse(Object.assign(new Error("connected hub has a different project or instance"), { code: 4404 }));
|
|
96
|
+
}
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
resolve(client);
|
|
99
|
+
});
|
|
58
100
|
});
|
|
59
101
|
}
|
|
60
102
|
|
|
61
103
|
/** `timeoutMs`: for calls a person waits on. The hub has its own budget; this one only makes sure the CLI never hangs. */
|
|
62
|
-
request(msg: Record<string, unknown>, timeoutMs
|
|
104
|
+
request(msg: Record<string, unknown>, timeoutMs = 30_000): Promise<any> {
|
|
105
|
+
if (this.ws.readyState !== WebSocket.OPEN) return Promise.resolve({ ok: false, error: "hub connection is not open" });
|
|
63
106
|
const rid = this.nextRid++;
|
|
64
107
|
return new Promise((resolve) => {
|
|
65
108
|
const timer = timeoutMs ? setTimeout(() => (this.pending.delete(rid), resolve({ ok: false, error: `no answer from the hub within ${Math.round(timeoutMs / 1000)} s` })), timeoutMs) : undefined;
|