@staix/agent-hub 0.2.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/.claude-plugin/marketplace.json +12 -0
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +54 -0
- package/package.json +41 -0
- package/plugins/agent-hub/.claude-plugin/plugin.json +11 -0
- package/plugins/agent-hub/.mcp.json +8 -0
- package/plugins/agent-hub/server.js +15832 -0
- package/src/adapters/acp.ts +183 -0
- package/src/adapters/claude-channel.ts +146 -0
- package/src/adapters/codex-appserver.ts +323 -0
- package/src/adapters/local-worker.ts +232 -0
- package/src/cli/init.ts +43 -0
- package/src/cli/launch.ts +66 -0
- package/src/cli/main.js +9 -0
- package/src/cli/main.ts +434 -0
- package/src/cli/setup.ts +79 -0
- package/src/cli/statusline-tee.ts +33 -0
- package/src/hub/ask.ts +163 -0
- package/src/hub/board.ts +110 -0
- package/src/hub/budget.ts +268 -0
- package/src/hub/bus.ts +251 -0
- package/src/hub/control-client.ts +78 -0
- package/src/hub/daemon.ts +658 -0
- package/src/hub/envelope.ts +95 -0
- package/src/hub/hub-tools.ts +50 -0
- package/src/hub/inference.ts +130 -0
- package/src/hub/peers.ts +68 -0
- package/src/hub/ports.ts +29 -0
- package/src/hub/routing.ts +160 -0
- package/src/hub/tasks.ts +293 -0
- package/src/local/deny.ts +36 -0
- package/src/local/sandbox.ts +94 -0
- package/src/local/tools.ts +160 -0
- package/src/memory/brief.ts +59 -0
- package/src/memory/capture.ts +83 -0
- package/src/memory/client.ts +79 -0
- package/src/memory/recall.ts +55 -0
- package/src/omniroute/client.ts +163 -0
- package/src/switchyard/config.ts +45 -0
- package/src/switchyard/sidecar.ts +102 -0
- package/src/version.ts +4 -0
- package/templates/AGENTS.block.md +11 -0
- package/templates/CLAUDE.block.md +10 -0
- package/templates/config.json +25 -0
- package/templates/routing.toml +84 -0
package/src/cli/main.ts
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync, openSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { ControlClient, readControl } from "../hub/control-client.ts";
|
|
6
|
+
import { loadConfig, startDaemon, stateDirFor } from "../hub/daemon.ts";
|
|
7
|
+
import type { BusEvent } from "../hub/bus.ts";
|
|
8
|
+
import { allocatePorts, CODEX_APP, CODEX_PROXY, CONTROL, SWITCHYARD } from "../hub/ports.ts";
|
|
9
|
+
import { OmniRoute } from "../omniroute/client.ts";
|
|
10
|
+
import { MemoryClient } from "../memory/client.ts";
|
|
11
|
+
import { init } from "./init.ts";
|
|
12
|
+
import { buildLaunch, UNATTENDED_WARNING } from "./launch.ts";
|
|
13
|
+
import { nextStep, parseList, pluginState, type InstalledPlugin, type Marketplace } from "./setup.ts";
|
|
14
|
+
import { CLASSES } from "../hub/board.ts";
|
|
15
|
+
import { VERSION } from "../version.ts";
|
|
16
|
+
import { createInterface } from "node:readline/promises";
|
|
17
|
+
|
|
18
|
+
const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one project directory
|
|
19
|
+
|
|
20
|
+
ahub setup [--yes] install or update the Claude Code channel plugin from this package, then run doctor
|
|
21
|
+
ahub init write .agenthub/config.json and the CLAUDE.md / AGENTS.md marker blocks
|
|
22
|
+
ahub up [--unattended] start the daemon for this directory
|
|
23
|
+
ahub claude [args...] launch Claude Code with the hub channel [--unattended]
|
|
24
|
+
ahub codex [args...] start the Codex adapter and attach the TUI [--unattended]
|
|
25
|
+
ahub kimi [--model <alias>] start Kimi headless under ACP
|
|
26
|
+
ahub local [--route <id> | --model <id>]
|
|
27
|
+
start the hub-native worker on the self-hosted models (routing.toml)
|
|
28
|
+
ahub say [@peer ...] <text> send as the console user (no @peer = broadcast); delivered at once,
|
|
29
|
+
start the text with [STATUS] to let it batch or [FYI] for the record only
|
|
30
|
+
ahub pause|resume <peer> hold a peer's deliveries in its queue / release them
|
|
31
|
+
ahub budget quota windows per peer, and who is paused until when
|
|
32
|
+
ahub budget set <peer> <0..1> [--resets-in 30m] [--window 5h|week] feed a reading by hand (also: test the relay)
|
|
33
|
+
ahub budget resume <peer> override a budget pause; readings are ignored for that peer until the window resets
|
|
34
|
+
ahub board [state] the task board
|
|
35
|
+
ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--detail <text>]
|
|
36
|
+
ahub task show|escalate <id> full task with history (PII text included) / hand it to the next peer in escalate_to
|
|
37
|
+
ahub task assign <id> <peer> give a task to a peer yourself
|
|
38
|
+
ahub review <id> approved|changes_requested [note...]
|
|
39
|
+
ahub remember <text...> save a note to the memory all agents share
|
|
40
|
+
ahub ask [--remember] <question...> answer from the task board, shared memory and this run's log, with the ids it rests on
|
|
41
|
+
ahub route explain <id> why a task went where it went
|
|
42
|
+
ahub route explain --class <c> <title...> what would happen to such a task now
|
|
43
|
+
ahub tail live stream of messages, states and permission requests
|
|
44
|
+
ahub permit <id> <option> answer a permission request shown by tail ("deny" cancels)
|
|
45
|
+
ahub status | logs [-f] | doctor | kill`;
|
|
46
|
+
|
|
47
|
+
const cwd = process.cwd();
|
|
48
|
+
const stateDir = stateDirFor(cwd);
|
|
49
|
+
const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
|
|
50
|
+
const [cmd = "help", ...args] = process.argv.slice(2);
|
|
51
|
+
|
|
52
|
+
const connect = () => ControlClient.connect(stateDir, { role: "console" });
|
|
53
|
+
|
|
54
|
+
async function healthy(): Promise<boolean> {
|
|
55
|
+
const control = readControl(stateDir);
|
|
56
|
+
if (!control) return false;
|
|
57
|
+
return fetch(control.url.replace("ws:", "http:") + "/healthz").then((r) => r.ok, () => false);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function exec(bin: string, argv: string[]): never {
|
|
61
|
+
const res = spawnSync(bin, argv, { stdio: "inherit", env: { ...process.env, AGENTHUB_STATE_DIR: stateDir } });
|
|
62
|
+
if (res.error) fail(`cannot run ${bin}: ${res.error.message}`);
|
|
63
|
+
process.exit(res.status ?? 1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fail(message: string): never {
|
|
67
|
+
console.error(`ahub: ${message}`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function render(e: BusEvent): string {
|
|
72
|
+
if (e.t === "state") return ` . ${e.peer} is ${e.state}`;
|
|
73
|
+
if (e.t === "undeliverable") return ` ! gave up delivering ${e.env.id} (from ${e.env.from}) to ${e.peer}`;
|
|
74
|
+
if (e.t === "envelope" && e.env.from === "hub" && e.env.kind !== "chat") {
|
|
75
|
+
return `${new Date(e.env.ts).toLocaleTimeString()} hub -> ${e.env.to?.join(",")} [${e.env.kind}${e.env.refs?.task ? ` #${e.env.refs.task}` : ""}]\n${e.env.body.split("\n")[0]!.replace(/^/, " ")}`;
|
|
76
|
+
}
|
|
77
|
+
if (e.t === "overflow") return ` ! ${e.peer}'s queue is full: dropped ${e.env.id} (from ${e.env.from})`;
|
|
78
|
+
const { env } = e;
|
|
79
|
+
const note = e.dropped === "hop" ? " [not delivered: hop limit]" : e.dropped === "fyi" ? " [fyi: record only]" : "";
|
|
80
|
+
const head = `${env.from} -> ${env.to?.join(",") ?? "*"}${env.priority === "important" ? " !" : ""}${note}`;
|
|
81
|
+
return `${new Date(env.ts).toLocaleTimeString()} ${head}\n${env.body.replace(/^/gm, " ")}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function taskOp(op: string, a: Record<string, unknown>): Promise<string> {
|
|
85
|
+
const hub = await connect();
|
|
86
|
+
const res = await hub.request({ t: "task", op, args: a });
|
|
87
|
+
hub.close();
|
|
88
|
+
if (!res.ok) fail(res.error);
|
|
89
|
+
return res.text;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** `--flag value` pairs pulled out of an argument list; the rest keeps its order. */
|
|
93
|
+
function takeFlags(argv: string[], single: string[], repeated: string[]) {
|
|
94
|
+
const one: Record<string, string> = {};
|
|
95
|
+
const many: Record<string, string[]> = {};
|
|
96
|
+
const rest: string[] = [];
|
|
97
|
+
for (let i = 0; i < argv.length; i++) {
|
|
98
|
+
const a = argv[i]!;
|
|
99
|
+
if (single.includes(a)) one[a] = argv[++i] ?? fail(`${a} needs a value`);
|
|
100
|
+
else if (repeated.includes(a)) (many[a] ??= []).push(argv[++i] ?? fail(`${a} needs a value`));
|
|
101
|
+
else rest.push(a);
|
|
102
|
+
}
|
|
103
|
+
return { one, many, rest };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function hold(t: "pause" | "resume"): Promise<void> {
|
|
107
|
+
if (!args[0]) fail(`usage: hub ${t} <peer>`);
|
|
108
|
+
const hub = await connect();
|
|
109
|
+
const res = await hub.request({ t, peer: args[0] });
|
|
110
|
+
hub.close();
|
|
111
|
+
if (!res.ok) fail(res.error);
|
|
112
|
+
console.log(`${args[0]} is ${res.state}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const commands: Record<string, () => Promise<void> | void> = {
|
|
116
|
+
help: () => console.log(USAGE),
|
|
117
|
+
"--version": () => console.log(VERSION),
|
|
118
|
+
version: () => console.log(VERSION),
|
|
119
|
+
|
|
120
|
+
setup: async () => {
|
|
121
|
+
const root = join(import.meta.dir, "..", "..");
|
|
122
|
+
const state = () => {
|
|
123
|
+
const out = (argv: string[]) => spawnSync("claude", argv, { encoding: "utf8" }).stdout ?? "";
|
|
124
|
+
return nextStep(parseList<InstalledPlugin>(out(["plugin", "list", "--json"])), parseList<Marketplace>(out(["plugin", "marketplace", "list", "--json"])), root);
|
|
125
|
+
};
|
|
126
|
+
let step = state();
|
|
127
|
+
if (!step) console.log(`Claude Code plugin agent-hub@agent-hub ${VERSION} is installed from this package.`);
|
|
128
|
+
else {
|
|
129
|
+
console.log(`ahub setup changes your Claude Code plugin configuration, one step at a time, re-checking after each. First step:\n ${step.argv.join(" ")}\n ${step.why}`);
|
|
130
|
+
if (!args.includes("--yes")) {
|
|
131
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
132
|
+
const answer = (await rl.question("Proceed (this and the steps that follow from it)? [y/N] ")).trim().toLowerCase();
|
|
133
|
+
rl.close();
|
|
134
|
+
if (answer !== "y" && answer !== "yes") return console.log("nothing was changed");
|
|
135
|
+
}
|
|
136
|
+
for (let i = 0; step && i < 6; i++, step = state()) {
|
|
137
|
+
console.log(`> ${step.argv.join(" ")} (${step.why})`);
|
|
138
|
+
if (spawnSync(step.argv[0]!, step.argv.slice(1), { stdio: "inherit" }).status !== 0) fail(`"${step.argv.join(" ")}" failed; nothing after it was run`);
|
|
139
|
+
}
|
|
140
|
+
if (step) fail(`still not done after 6 steps; next would be: ${step.argv.join(" ")}`);
|
|
141
|
+
}
|
|
142
|
+
await commands.doctor!();
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
init: () => {
|
|
146
|
+
const changed = init(cwd);
|
|
147
|
+
console.log(changed.length ? changed.map((p) => `wrote ${p}`).join("\n") : "already up to date");
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
// Internal: the detached daemon process started by `ahub up`.
|
|
151
|
+
daemon: async () => {
|
|
152
|
+
// Long-lived process: one bad adapter callback must not take every peer down. stderr is hub.log.
|
|
153
|
+
process.on("unhandledRejection", (e) => console.error(`${new Date().toISOString()} unhandled rejection:`, e));
|
|
154
|
+
process.on("uncaughtException", (e) => console.error(`${new Date().toISOString()} uncaught exception:`, e));
|
|
155
|
+
const base = allocatePorts(cwd);
|
|
156
|
+
const daemon = await startDaemon({
|
|
157
|
+
cwd,
|
|
158
|
+
stateDir,
|
|
159
|
+
controlPort: base + CONTROL,
|
|
160
|
+
codexAppPort: base + CODEX_APP,
|
|
161
|
+
codexProxyPort: base + CODEX_PROXY,
|
|
162
|
+
switchyardPort: base + SWITCHYARD,
|
|
163
|
+
unattended: unattendedEnv || args.includes("--unattended"),
|
|
164
|
+
});
|
|
165
|
+
for (const sig of ["SIGINT", "SIGTERM"] as const) process.on(sig, () => void daemon.stop());
|
|
166
|
+
await daemon.stopped;
|
|
167
|
+
process.exit(0);
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
up: async () => {
|
|
171
|
+
if (await healthy()) return console.log("hub is already running");
|
|
172
|
+
const unattended = unattendedEnv || args.includes("--unattended");
|
|
173
|
+
if (unattended) console.error(UNATTENDED_WARNING);
|
|
174
|
+
mkdirSync(stateDir, { recursive: true });
|
|
175
|
+
const log = openSync(join(stateDir, "hub.log"), "a");
|
|
176
|
+
spawn(process.execPath, [import.meta.path, "daemon", ...(unattended ? ["--unattended"] : [])], {
|
|
177
|
+
cwd,
|
|
178
|
+
detached: true,
|
|
179
|
+
stdio: ["ignore", log, log],
|
|
180
|
+
env: { ...process.env, AGENTHUB_STATE_DIR: stateDir },
|
|
181
|
+
}).unref();
|
|
182
|
+
for (let i = 0; i < 50; i++) {
|
|
183
|
+
if (await healthy()) return console.log(`ahub up (${readControl(stateDir)!.url}), state in ${stateDir}`);
|
|
184
|
+
await Bun.sleep(100);
|
|
185
|
+
}
|
|
186
|
+
fail(`daemon did not start; see ${join(stateDir, "hub.log")}`);
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
claude: () => {
|
|
190
|
+
// `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
|
|
191
|
+
// project local, then project, then user.
|
|
192
|
+
let original: { command?: string; refreshInterval?: number; padding?: number } | undefined;
|
|
193
|
+
for (const file of [join(cwd, ".claude", "settings.local.json"), join(cwd, ".claude", "settings.json"), join(process.env.HOME ?? "", ".claude", "settings.json")]) {
|
|
194
|
+
try {
|
|
195
|
+
original ??= JSON.parse(readFileSync(file, "utf8")).statusLine;
|
|
196
|
+
} catch {
|
|
197
|
+
// no such file, or no status line in it
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const launch = buildLaunch("claude", args, { unattended: unattendedEnv, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original } : {}) } });
|
|
201
|
+
if (launch.warning) console.error(launch.warning);
|
|
202
|
+
exec(launch.cmd, launch.args);
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
codex: async () => {
|
|
206
|
+
const launch0 = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: "pending" }); // refuse bad flags before starting anything
|
|
207
|
+
const hub = await connect();
|
|
208
|
+
const res = await hub.request({ t: "start", peer: "codex" });
|
|
209
|
+
hub.close();
|
|
210
|
+
if (!res.ok) fail(res.error);
|
|
211
|
+
const launch = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: res.proxyUrl, codexBin: loadConfig(cwd).codex_bin });
|
|
212
|
+
if (launch0.warning) console.error(launch0.warning);
|
|
213
|
+
exec(launch.cmd, launch.args);
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
kimi: async () => {
|
|
217
|
+
const i = args.indexOf("--model");
|
|
218
|
+
const model = i === -1 ? undefined : args[i + 1] ?? fail("--model needs an alias");
|
|
219
|
+
const hub = await connect();
|
|
220
|
+
const res = await hub.request({ t: "start", peer: "kimi", args: { model } });
|
|
221
|
+
hub.close();
|
|
222
|
+
if (!res.ok) fail(res.error);
|
|
223
|
+
console.log(res.already ? "kimi is already attached" : 'kimi attached (headless). Talk to it with: ahub say @kimi "..."');
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
local: async () => {
|
|
227
|
+
const opt = (flag: string) => (args.includes(flag) ? args[args.indexOf(flag) + 1] ?? fail(`${flag} needs a value`) : undefined);
|
|
228
|
+
const hub = await connect();
|
|
229
|
+
const res = await hub.request({ t: "start", peer: "local", args: { route: opt("--route"), model: opt("--model") } });
|
|
230
|
+
hub.close();
|
|
231
|
+
if (!res.ok) fail(res.error);
|
|
232
|
+
console.log(res.already ? "local is already attached" : `local attached on ${res.model}. Give it work with: ahub say @local "..."`);
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
say: async () => {
|
|
236
|
+
const lead = args.findIndex((a) => !/^@[a-z][a-z0-9-]*$/.test(a)); // only leading @tokens are recipients
|
|
237
|
+
const to = args.slice(0, lead === -1 ? args.length : lead).map((a) => a.slice(1));
|
|
238
|
+
const body = lead === -1 ? "" : args.slice(lead).join(" ");
|
|
239
|
+
const hub = await connect();
|
|
240
|
+
const res = await hub.request({ t: "send", body, to });
|
|
241
|
+
hub.close();
|
|
242
|
+
if (!res.ok) fail(res.error);
|
|
243
|
+
if (res.recorded) return console.log("recorded only ([FYI]); no peer was interrupted");
|
|
244
|
+
console.log(res.targets.length ? `queued for: ${res.targets.join(", ")}` : "no peers attached; nothing delivered");
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
tail: async () => {
|
|
248
|
+
const hub = await connect();
|
|
249
|
+
hub.onPush = (msg) => {
|
|
250
|
+
if (msg.t === "event") console.log(render(msg.e));
|
|
251
|
+
else if (msg.t === "notice") console.log(` * ${msg.line}`);
|
|
252
|
+
else if (msg.t === "permission") {
|
|
253
|
+
const options = msg.options.map((o: any) => `${o.optionId} (${o.name})`).join(", ");
|
|
254
|
+
console.log(` ? ${msg.peer} asks permission: ${String(msg.title).replace(/\n/g, "\n | ")}\n answer with: ahub permit ${msg.id} <${options}> | deny`);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
hub.onClose = () => process.exit(0);
|
|
258
|
+
hub.send({ t: "tail" });
|
|
259
|
+
await new Promise(() => {});
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
budget: async () => {
|
|
263
|
+
const hub = await connect();
|
|
264
|
+
let set: Record<string, unknown> | undefined;
|
|
265
|
+
if (args[0] === "resume") {
|
|
266
|
+
if (!args[1]) fail("usage: ahub budget resume <peer>");
|
|
267
|
+
const res = await hub.request({ t: "budget", resume: args[1] });
|
|
268
|
+
hub.close();
|
|
269
|
+
if (!res.ok) fail(res.error);
|
|
270
|
+
return console.log(`${args[1]} resumed; the coordinator leaves it alone until its window resets`);
|
|
271
|
+
}
|
|
272
|
+
if (args[0] === "set") {
|
|
273
|
+
const flags = takeFlags(args.slice(1), ["--resets-in", "--window"], []);
|
|
274
|
+
const [peer, used] = flags.rest;
|
|
275
|
+
if (!peer || used === undefined) fail("usage: ahub budget set <peer> <0..1> [--resets-in 30m] [--window 5h|week]");
|
|
276
|
+
const m = /^(\d+)(s|m|h)$/.exec(flags.one["--resets-in"] ?? "");
|
|
277
|
+
if (flags.one["--resets-in"] && !m) fail("--resets-in takes a duration like 90s, 30m or 5h");
|
|
278
|
+
set = { peer, used: Number(used), window: flags.one["--window"], ...(m ? { resetsInMs: Number(m[1]) * { s: 1000, m: 60_000, h: 3_600_000 }[m[2] as "s" | "m" | "h"] } : {}) };
|
|
279
|
+
}
|
|
280
|
+
const res = await hub.request({ t: "budget", ...(set ? { set } : {}) });
|
|
281
|
+
hub.close();
|
|
282
|
+
if (!res.ok) fail(res.error);
|
|
283
|
+
const peers = Object.entries(res.budget as Record<string, any>);
|
|
284
|
+
if (!peers.length) return console.log(`no quota readings yet (gate ${res.gate}). Sources: Codex rate limits, Claude's status line (ahub claude), ahub budget set.`);
|
|
285
|
+
const left = (at: number) => { const s = Math.max(0, Math.round((at - Date.now()) / 1000)); return s >= 3600 ? `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`; };
|
|
286
|
+
for (const [peer, b] of peers) {
|
|
287
|
+
console.log(`${peer}${b.paused ? ` PAUSED: ${b.paused.reason}, resumes in ${left(b.paused.resetsAt)}` : ""}`);
|
|
288
|
+
for (const w of b.windows) console.log(` ${String(w.id).padEnd(7)} ${String(Math.round(w.used * 100)).padStart(3)}%${w.resetsAt ? ` resets in ${left(w.resetsAt)}` : ""} [${w.source}, ${Math.round((Date.now() - w.at) / 1000)}s ago${w.stale ? ", STALE" : ""}]`);
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
board: async () => {
|
|
293
|
+
const tasks = JSON.parse(await taskOp("hub_task_list", args[0] ? { state: args[0] } : {})) as any[];
|
|
294
|
+
if (!tasks.length) return console.log("no tasks");
|
|
295
|
+
for (const t of tasks) console.log(`#${String(t.id).padEnd(4)} ${t.state.padEnd(18)} ${t.class.padEnd(10)} ${(t.owner ?? "-").padEnd(8)} review:${(t.reviewer ?? "-").padEnd(8)} ${t.title}${t.signals.includes("pii") ? ` (ahub task show ${t.id})` : ""}`);
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
task: async () => {
|
|
299
|
+
const [sub, ...rest] = args;
|
|
300
|
+
if (sub === "show") return console.log(await taskOp("task_show", { id: rest[0] }));
|
|
301
|
+
if (sub === "escalate") return console.log(await taskOp("task_escalate", { id: rest[0] }));
|
|
302
|
+
if (sub === "assign") return console.log(await taskOp("task_assign", { id: rest[0], peer: rest[1] }));
|
|
303
|
+
if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | escalate <id>");
|
|
304
|
+
// `--class` is the explicit form. A first word that is a class name is still taken as the class (the documented
|
|
305
|
+
// short form), but said out loud: "review the auth module" would otherwise be filed as class review, silently.
|
|
306
|
+
const flags = takeFlags(rest, ["--owner", "--detail", "--class"], ["--path"]);
|
|
307
|
+
const positional = !flags.one["--class"] && (CLASSES as readonly string[]).includes(flags.rest[0] ?? "") && flags.rest.length > 1;
|
|
308
|
+
const cls = flags.one["--class"] ?? (positional ? flags.rest[0] : undefined);
|
|
309
|
+
const title = (positional ? flags.rest.slice(1) : flags.rest).join(" ");
|
|
310
|
+
if (positional) console.error(`note: "${cls}" was taken as the class and left out of the title; use --class <c> when the title itself starts with that word`);
|
|
311
|
+
console.log(await taskOp("hub_task_propose", { ...(cls ? { class: cls } : {}), title, owner: flags.one["--owner"], detail: flags.one["--detail"], ...(flags.many["--path"]?.length ? { refs: { paths: flags.many["--path"] } } : {}) }));
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
review: async () => {
|
|
315
|
+
const [id, verdict, ...note] = args;
|
|
316
|
+
if (!id || !verdict) fail("usage: ahub review <id> approved|changes_requested [note...]");
|
|
317
|
+
console.log(await taskOp("hub_review", { id: Number(id), verdict, note: note.join(" ") }));
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
remember: async () => console.log(await taskOp("hub_remember", { text: args.join(" ") })),
|
|
321
|
+
|
|
322
|
+
ask: async () => {
|
|
323
|
+
const remember = args.includes("--remember");
|
|
324
|
+
const question = args.filter((a) => a !== "--remember").join(" ");
|
|
325
|
+
if (!question.trim()) fail("usage: ahub ask [--remember] <question...>");
|
|
326
|
+
const hub = await connect();
|
|
327
|
+
const res = await hub.request({ t: "ask", question, remember }, 75_000); // above the hub's own budget (probe + 45 s model call)
|
|
328
|
+
hub.close();
|
|
329
|
+
if (!res.ok) fail(res.error);
|
|
330
|
+
console.log(res.answer ?? `(${res.note})`);
|
|
331
|
+
// "Nothing found" means the list did not answer the question: printing it anyway would only look like an answer.
|
|
332
|
+
if (res.evidence.length && (res.found || res.note)) {
|
|
333
|
+
console.log("\nEvidence:");
|
|
334
|
+
for (const e of res.evidence as { id: string; text: string }[]) console.log(` ${e.id.padEnd(12)} ${e.text.slice(0, 150)}`);
|
|
335
|
+
}
|
|
336
|
+
if (res.pii) console.log("\n(PII is involved: shown here only, not saved anywhere)");
|
|
337
|
+
if (res.saved) console.log(`\n${res.saved}`);
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
route: async () => {
|
|
341
|
+
if (args[0] !== "explain") fail("usage: ahub route explain <id> | --class <class> <title...>");
|
|
342
|
+
const flags = takeFlags(args.slice(1), ["--class"], []);
|
|
343
|
+
console.log(await taskOp("route_explain", flags.one["--class"] ? { class: flags.one["--class"], title: flags.rest.join(" ") } : { id: flags.rest[0] }));
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
pause: () => hold("pause"),
|
|
347
|
+
resume: () => hold("resume"),
|
|
348
|
+
|
|
349
|
+
permit: async () => {
|
|
350
|
+
const [id, option] = args;
|
|
351
|
+
if (!id || !option) fail("usage: ahub permit <id> <option|deny>");
|
|
352
|
+
const hub = await connect();
|
|
353
|
+
hub.send({ t: "permit", id, ...(option === "deny" ? {} : { option }) });
|
|
354
|
+
await hub.request({ t: "status" }); // flush before closing
|
|
355
|
+
hub.close();
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
status: async () => {
|
|
359
|
+
const hub = await connect();
|
|
360
|
+
const { status } = await hub.request({ t: "status" });
|
|
361
|
+
hub.close();
|
|
362
|
+
console.log(`hub pid ${status.pid}, control 127.0.0.1:${status.controlPort}, ${status.cwd}`);
|
|
363
|
+
const peers = Object.entries(status.peers as Record<string, { state: string; queued: number }>);
|
|
364
|
+
for (const [id, p] of peers) console.log(` ${id.padEnd(8)} ${p.state.padEnd(8)} queued ${p.queued}${(p as any).paused ? ` (${(p as any).paused})` : ""}${(p as any).servedBy ? ` last call: ${(p as any).servedBy}` : ""}`);
|
|
365
|
+
if (status.switchyard) console.log(` switchyard: ${status.switchyard}`);
|
|
366
|
+
const counts = Object.entries(status.tasks ?? {}).map(([s, n]) => `${n} ${s}`).join(", ");
|
|
367
|
+
if (counts) console.log(` tasks: ${counts} (ahub board)`);
|
|
368
|
+
if (!peers.length) console.log(" no peers attached yet (ahub claude | ahub codex | ahub kimi)");
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
logs: () => exec("tail", [args.includes("-f") ? "-f" : "-n100", join(stateDir, "hub.log")]),
|
|
372
|
+
|
|
373
|
+
kill: async () => {
|
|
374
|
+
const hub = await connect().catch(() => undefined);
|
|
375
|
+
if (hub) {
|
|
376
|
+
hub.send({ t: "kill" });
|
|
377
|
+
await new Promise<void>((r) => (hub.onClose = r));
|
|
378
|
+
return console.log("hub stopped");
|
|
379
|
+
}
|
|
380
|
+
const pidFile = join(stateDir, "hub.pid");
|
|
381
|
+
if (!existsSync(pidFile)) return console.log("hub is not running");
|
|
382
|
+
// The pid file may be stale and the pid reused: signal it only if it still is a hub daemon.
|
|
383
|
+
const pid = Number(readFileSync(pidFile, "utf8"));
|
|
384
|
+
const owner = spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" }).stdout ?? "";
|
|
385
|
+
if (/main\.ts daemon|ahub daemon/.test(owner)) {
|
|
386
|
+
process.kill(pid, "SIGTERM");
|
|
387
|
+
return console.log("hub signalled");
|
|
388
|
+
}
|
|
389
|
+
for (const f of ["hub.pid", "status.json", "control-token"]) rmSync(join(stateDir, f), { force: true });
|
|
390
|
+
console.log("hub is not running (removed stale state files)");
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
doctor: async () => {
|
|
394
|
+
const version = (bin: string) => {
|
|
395
|
+
const res = spawnSync(bin, ["--version"], { encoding: "utf8" });
|
|
396
|
+
return res.status === 0 ? res.stdout.trim().split("\n")[0]! : undefined;
|
|
397
|
+
};
|
|
398
|
+
const row = (ok: boolean | undefined, name: string, detail: string) =>
|
|
399
|
+
console.log(` ${ok === undefined ? "?" : ok ? "ok" : "--"} ${name.padEnd(22)} ${detail}`);
|
|
400
|
+
|
|
401
|
+
for (const bin of ["bun", "claude", "codex", "kimi"]) {
|
|
402
|
+
const v = version(bin);
|
|
403
|
+
row(!!v, bin, v ?? "not found on PATH");
|
|
404
|
+
}
|
|
405
|
+
const up = await healthy();
|
|
406
|
+
row(up, "ahub daemon", up ? readControl(stateDir)!.url : "not running (ahub up)");
|
|
407
|
+
const plugin = pluginState(parseList<InstalledPlugin>(spawnSync("claude", ["plugin", "list", "--json"], { encoding: "utf8" }).stdout ?? ""), join(import.meta.dir, "..", ".."));
|
|
408
|
+
row(plugin.state === "current", "claude plugin", plugin.state === "missing" ? "missing: run ahub setup" : plugin.state === "current" ? `agent-hub@agent-hub ${plugin.version}` : `agent-hub@agent-hub is stale (${plugin.why}): run ahub setup`);
|
|
409
|
+
|
|
410
|
+
const config = loadConfig(cwd);
|
|
411
|
+
const omni = new OmniRoute(config.omniroute);
|
|
412
|
+
const gateway = await omni.base();
|
|
413
|
+
row(!!gateway, "omniroute", gateway ? `${new URL(gateway).host} healthy` : config.omniroute.urls.length || process.env.AGENTHUB_OMNIROUTE_URL ? "no candidate reachable (VPN off?); ahub local cannot run" : "not configured: set omniroute.urls in .agenthub/config.json (any OpenAI-compatible gateway); ahub local cannot run");
|
|
414
|
+
row(!!omni.apiKey(), "omniroute key", omni.apiKey() ? "present" : "missing: set OMNIROUTE_API_KEY or omniroute.api_key_file in .agenthub/config.json");
|
|
415
|
+
const sy = spawnSync(process.env.AGENTHUB_SWITCHYARD_BIN ?? "switchyard-server", ["--version"], { encoding: "utf8" });
|
|
416
|
+
row(sy.status === 0 ? true : undefined, "switchyard", sy.status === 0 ? sy.stdout.trim() : "not installed: ahub local uses fixed_model on OmniRoute (cargo install --locked switchyard-server)");
|
|
417
|
+
|
|
418
|
+
const memory = new MemoryClient();
|
|
419
|
+
const mem = await memory.health();
|
|
420
|
+
row(mem.ok, "memory worker", mem.ok ? `claude-mem ${mem.version ?? ""} at ${memory.url}` : `unavailable at ${memory.url} (the hub works without it)`);
|
|
421
|
+
const bridge = spawnSync("dot", ["ai", "memory", "status"], { encoding: "utf8" });
|
|
422
|
+
const out = bridge.error ? "" : bridge.stdout.replace(/\x1b\[[0-9;]*m/g, "");
|
|
423
|
+
for (const tool of ["codex", "kimi"]) {
|
|
424
|
+
row(bridge.error ? undefined : new RegExp(`✓\\s+${tool} ready`).test(out), `memory capture: ${tool}`, bridge.error ? "unknown (`dot ai memory status` not available)" : "per `dot ai memory status`");
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const run = commands[cmd] ?? (() => fail(`unknown command "${cmd}"\n\n${USAGE}`));
|
|
430
|
+
try {
|
|
431
|
+
await run();
|
|
432
|
+
} catch (e) {
|
|
433
|
+
fail((e as Error).message);
|
|
434
|
+
}
|
package/src/cli/setup.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { VERSION } from "../version.ts";
|
|
4
|
+
|
|
5
|
+
export const PLUGIN = "agent-hub@agent-hub";
|
|
6
|
+
const MARKETPLACE = "agent-hub";
|
|
7
|
+
|
|
8
|
+
export interface InstalledPlugin {
|
|
9
|
+
id: string;
|
|
10
|
+
version?: string;
|
|
11
|
+
installPath?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface Marketplace {
|
|
14
|
+
name: string;
|
|
15
|
+
path?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `claude plugin list --json` / `claude plugin marketplace list --json`. Anything that does not parse is an empty list. */
|
|
19
|
+
export function parseList<T>(json: string): T[] {
|
|
20
|
+
try {
|
|
21
|
+
const d = JSON.parse(json);
|
|
22
|
+
return Array.isArray(d) ? d : [];
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const real = (p: string) => {
|
|
29
|
+
try {
|
|
30
|
+
return realpathSync(p);
|
|
31
|
+
} catch {
|
|
32
|
+
return p;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const read = (p: string) => {
|
|
36
|
+
try {
|
|
37
|
+
return readFileSync(p, "utf8");
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type PluginState = { state: "missing" } | { state: "current"; version: string } | { state: "stale"; version: string; why: string };
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Is the installed Claude plugin the one this hub ships? The version is compared, and so is the bundle itself: Claude
|
|
47
|
+
* Code runs a cached copy, and a copy taken from an earlier build of the same version speaks an older wire protocol.
|
|
48
|
+
*/
|
|
49
|
+
export function pluginState(plugins: InstalledPlugin[], packageRoot: string): PluginState {
|
|
50
|
+
const mine = plugins.find((p) => p.id === PLUGIN);
|
|
51
|
+
if (!mine) return { state: "missing" };
|
|
52
|
+
const version = mine.version ?? "unknown";
|
|
53
|
+
if (version !== VERSION) return { state: "stale", version, why: `version ${version}, this hub is ${VERSION}` };
|
|
54
|
+
const shipped = read(join(packageRoot, "plugins", "agent-hub", "server.js"));
|
|
55
|
+
const cached = mine.installPath ? read(join(mine.installPath, "server.js")) : undefined;
|
|
56
|
+
if (shipped !== undefined && cached !== undefined && shipped !== cached) return { state: "stale", version, why: "same version, but the cached bundle differs from the one this hub ships" };
|
|
57
|
+
return { state: "current", version };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SetupStep {
|
|
61
|
+
why: string;
|
|
62
|
+
argv: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The next thing `ahub setup` has to do, or nothing. One step at a time: the state is read again after every step,
|
|
67
|
+
* because what a step leaves behind is Claude Code's business (removing a marketplace may uninstall its plugins).
|
|
68
|
+
*/
|
|
69
|
+
export function nextStep(plugins: InstalledPlugin[], marketplaces: Marketplace[], packageRoot: string): SetupStep | undefined {
|
|
70
|
+
const root = real(packageRoot);
|
|
71
|
+
const market = marketplaces.find((m) => m.name === MARKETPLACE);
|
|
72
|
+
if (market && real(market.path ?? "") !== root) return { why: `the ${MARKETPLACE} marketplace points at ${market.path ?? "another source"}, not at this package`, argv: ["claude", "plugin", "marketplace", "remove", MARKETPLACE] };
|
|
73
|
+
if (!market) return { why: "register this package as a Claude Code plugin marketplace", argv: ["claude", "plugin", "marketplace", "add", root] };
|
|
74
|
+
const state = pluginState(plugins, root);
|
|
75
|
+
if (state.state === "missing") return { why: "install the channel plugin", argv: ["claude", "plugin", "install", PLUGIN] };
|
|
76
|
+
// Uninstall rather than update: with an unchanged version number an update has nothing to do.
|
|
77
|
+
if (state.state === "stale") return { why: `replace the installed plugin (${state.why})`, argv: ["claude", "plugin", "uninstall", PLUGIN] };
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Claude Code pipes a JSON document to its status line command on every render, and that document carries
|
|
3
|
+
// `rate_limits` (five_hour, seven_day: used_percentage, resets_at). `ahub claude` puts this script in front of the
|
|
4
|
+
// user's own status line command: it records the limits for the hub's budget coordinator and then runs the original
|
|
5
|
+
// command with the same input, so the status line looks exactly as before. It must never fail the render.
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
const input = await Bun.stdin.text();
|
|
11
|
+
let own = "";
|
|
12
|
+
try {
|
|
13
|
+
const limits = JSON.parse(input).rate_limits;
|
|
14
|
+
const pct = (w: any) => (typeof w?.used_percentage === "number" ? `${Math.round(w.used_percentage)}%` : "-");
|
|
15
|
+
if (limits) own = `agent-hub 5h ${pct(limits.five_hour)} wk ${pct(limits.seven_day)}`;
|
|
16
|
+
const dir = process.env.AGENTHUB_STATE_DIR;
|
|
17
|
+
if (limits && dir) {
|
|
18
|
+
mkdirSync(dir, { recursive: true });
|
|
19
|
+
const file = join(dir, "claude-usage.json");
|
|
20
|
+
writeFileSync(`${file}.tmp`, JSON.stringify({ at: Date.now(), rate_limits: limits }));
|
|
21
|
+
renameSync(`${file}.tmp`, file);
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
// not JSON, or the state dir is gone: the status line still has to render
|
|
25
|
+
}
|
|
26
|
+
const original = process.env.AGENTHUB_STATUSLINE_CMD;
|
|
27
|
+
if (original) {
|
|
28
|
+
const res = spawnSync("/bin/sh", ["-c", original], { input, encoding: "utf8", timeout: 5000 });
|
|
29
|
+
process.stdout.write(res.stdout ?? "");
|
|
30
|
+
} else {
|
|
31
|
+
// No status line of the user's own to wrap: `--settings` still replaces whatever Claude Code would show, so say something useful.
|
|
32
|
+
process.stdout.write(`${own}\n`);
|
|
33
|
+
}
|