@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.
Files changed (46) hide show
  1. package/.claude-plugin/marketplace.json +12 -0
  2. package/CHANGELOG.md +19 -0
  3. package/LICENSE +21 -0
  4. package/README.md +54 -0
  5. package/package.json +41 -0
  6. package/plugins/agent-hub/.claude-plugin/plugin.json +11 -0
  7. package/plugins/agent-hub/.mcp.json +8 -0
  8. package/plugins/agent-hub/server.js +15832 -0
  9. package/src/adapters/acp.ts +183 -0
  10. package/src/adapters/claude-channel.ts +146 -0
  11. package/src/adapters/codex-appserver.ts +323 -0
  12. package/src/adapters/local-worker.ts +232 -0
  13. package/src/cli/init.ts +43 -0
  14. package/src/cli/launch.ts +66 -0
  15. package/src/cli/main.js +9 -0
  16. package/src/cli/main.ts +434 -0
  17. package/src/cli/setup.ts +79 -0
  18. package/src/cli/statusline-tee.ts +33 -0
  19. package/src/hub/ask.ts +163 -0
  20. package/src/hub/board.ts +110 -0
  21. package/src/hub/budget.ts +268 -0
  22. package/src/hub/bus.ts +251 -0
  23. package/src/hub/control-client.ts +78 -0
  24. package/src/hub/daemon.ts +658 -0
  25. package/src/hub/envelope.ts +95 -0
  26. package/src/hub/hub-tools.ts +50 -0
  27. package/src/hub/inference.ts +130 -0
  28. package/src/hub/peers.ts +68 -0
  29. package/src/hub/ports.ts +29 -0
  30. package/src/hub/routing.ts +160 -0
  31. package/src/hub/tasks.ts +293 -0
  32. package/src/local/deny.ts +36 -0
  33. package/src/local/sandbox.ts +94 -0
  34. package/src/local/tools.ts +160 -0
  35. package/src/memory/brief.ts +59 -0
  36. package/src/memory/capture.ts +83 -0
  37. package/src/memory/client.ts +79 -0
  38. package/src/memory/recall.ts +55 -0
  39. package/src/omniroute/client.ts +163 -0
  40. package/src/switchyard/config.ts +45 -0
  41. package/src/switchyard/sidecar.ts +102 -0
  42. package/src/version.ts +4 -0
  43. package/templates/AGENTS.block.md +11 -0
  44. package/templates/CLAUDE.block.md +10 -0
  45. package/templates/config.json +25 -0
  46. package/templates/routing.toml +84 -0
@@ -0,0 +1,232 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { renderDigest, replyParent, STANDING_INSTRUCTION, USER, type Envelope, type EnvelopeOpts, type PeerId } from "../hub/envelope.ts";
3
+ import { TASK_TOOL_NAMES, TASK_TOOLS } from "../hub/hub-tools.ts";
4
+ import { BasePeer } from "../hub/peers.ts";
5
+ import { profile } from "../local/sandbox.ts";
6
+ import { runTool, TOOL_SCHEMAS, touchedPaths, type ToolContext } from "../local/tools.ts";
7
+ import type { Capture } from "../memory/capture.ts";
8
+ import type { ChatMessage, ChatResult, OmniRoute } from "../omniroute/client.ts";
9
+ import type { Sidecar } from "../switchyard/sidecar.ts";
10
+
11
+ export interface LocalOptions {
12
+ cwd: string;
13
+ omni: OmniRoute;
14
+ /** Absent = always the fixed model on OmniRoute. */
15
+ sidecar?: Sidecar;
16
+ /** Switchyard route id asked of the sidecar. */
17
+ route?: string;
18
+ /** Model id sent straight to OmniRoute when the sidecar is absent, unhealthy or fails a call. */
19
+ fixedModel: string;
20
+ tools: { deny: string[]; permit: ToolContext["permit"]; bashNetwork?: boolean; readAllow?: string[] };
21
+ capture?: Capture;
22
+ /** Runs a hub task tool (hub_task_*, hub_review, hub_remember) as this peer. Absent = the tools are not offered. */
23
+ taskTool?: (name: string, args: Record<string, unknown>, turn: { pii: boolean }) => Promise<string>;
24
+ /** Per-turn policy from the task the delivery carries: the class's route, and whether it is a PII task. */
25
+ turnPolicy?: (envs: Envelope[]) => { route?: string; fixedModel?: string; pii: boolean; task?: string } | undefined;
26
+ /** Role contract, appended to the system prompt. */
27
+ preamble?: string;
28
+ watchdogMs?: number;
29
+ maxSteps?: number;
30
+ log?: (line: string) => void;
31
+ }
32
+
33
+ const HISTORY_CHARS = 100_000;
34
+ /** One turn may hold this much before its oldest tool outputs are replaced by a stub. */
35
+ const TURN_CHARS = 120_000;
36
+ /** Tools whose effects outlive a failed turn: once one ran, the turn is never redelivered. */
37
+ const SIDE_EFFECTS = new Set(["write", "edit", "bash", "git", "hub_send"]);
38
+ const chars = (msgs: ChatMessage[]) => msgs.reduce((n, m) => n + (m.content?.length ?? 0) + JSON.stringify(m.tool_calls ?? "").length, 0);
39
+
40
+ const asFunction = (t: { name: string; description: string; inputSchema: unknown }) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.inputSchema } });
41
+
42
+ const system = (cwd: string, preamble = "") =>
43
+ [
44
+ `You are "local", a coding agent run by agent-hub on a self-hosted model, working in ${cwd}.`,
45
+ "You take bulk and low-stakes work (mechanical edits, summaries, test runs) from the other agents and the hub console user.",
46
+ STANDING_INSTRUCTION,
47
+ "Tools: read, write, edit, bash, git, hub_send. They only work inside the project directory; secrets are unreadable; write, edit, bash and mutating git wait for the user's approval, so batch your changes and do not retry a refused call.",
48
+ "When the work is done, answer with a short conclusion: what changed, what you verified, what is left. No tool output, no code dumps.",
49
+ "If a message needs no work from you, answer in one line.",
50
+ preamble,
51
+ ].filter(Boolean).join("\n");
52
+
53
+ /** Hub-native agent loop: the hub owns every model call, so it can choose the model per call (L2) and the gateway (L3). */
54
+ export class LocalPeer extends BasePeer {
55
+ private readonly history: ChatMessage[] = [];
56
+ private readonly sessionId = `agent-hub-local-${randomUUID()}`;
57
+ private turn = 0; // generation guard, as in acp.ts: a turn aborted by the watchdog must not touch the next one
58
+ private abort: AbortController | undefined;
59
+ private readonly sandboxProfile: string; // built once: profile() spawns git and must stay off the per-call path
60
+ /** What served the last call, for `ahub status`. */
61
+ lastServedBy = "";
62
+
63
+ constructor(
64
+ id: PeerId,
65
+ private readonly opts: LocalOptions,
66
+ ) {
67
+ super(id, opts.watchdogMs);
68
+ this.sandboxProfile = profile(opts.cwd, opts.tools.bashNetwork ?? false, opts.tools.readAllow, opts.tools.deny);
69
+ }
70
+
71
+ async start(): Promise<void> {
72
+ this.opts.capture?.init(this.sessionId, "agent-hub local worker session");
73
+ this.setState("idle");
74
+ }
75
+
76
+ async stop(): Promise<void> {
77
+ this.turn++;
78
+ this.abort?.abort();
79
+ await this.opts.capture?.end();
80
+ this.setState("offline");
81
+ }
82
+
83
+ /** Resolves once the turn is claimed; a turn that cannot reach any model hands the envelopes back through onFailed. */
84
+ async deliver(envs: Envelope[]): Promise<void> {
85
+ if (this.state !== "idle") throw new Error(`${this.id} is ${this.state}`);
86
+ const turn = ++this.turn;
87
+ this.setState("busy");
88
+ // The turn works on its own message list and joins the history only as a whole, so a failed or aborted turn can
89
+ // never leave a tool call without its result (strict servers reject that history forever after).
90
+ const msgs: ChatMessage[] = [{ role: "user", content: renderDigest(envs, true) }];
91
+ const progress = { sideEffects: 0, last: "" };
92
+ const policy = this.opts.turnPolicy?.(envs);
93
+ // A PII turn speaks to the console user only, and privately: its answer must not be broadcast to cloud-hosted peers.
94
+ // The task id travels with it, so the board can keep the text (`ahub task show`) while tail and log show a stub.
95
+ const reply: EnvelopeOpts = { inReplyTo: replyParent(envs), ...(policy?.pii ? { to: [USER], private: true, priority: "important" as const, ...(policy.task ? { refs: { task: policy.task } } : {}) } : {}) };
96
+ this.run(envs, turn, msgs, progress, policy, reply)
97
+ .then((answer) => {
98
+ if (turn !== this.turn) return;
99
+ if (!policy?.pii) this.commit(msgs); // a PII turn leaves nothing in the history later turns send along
100
+ if (answer) this.onMessage?.(answer, reply);
101
+ })
102
+ .catch((e: Error) => {
103
+ if (turn !== this.turn) return; // aborted by the watchdog or stop(): nothing to report, nothing was committed
104
+ this.opts.log?.(`[${this.id}] turn failed: ${e.message}`);
105
+ if (!progress.sideEffects) return this.onFailed?.(envs); // nothing happened yet: safe to redeliver
106
+ // Tools already changed things. Redelivering would redo approved writes and commits, so report instead.
107
+ // A model call is the only thing that throws here, and every tool call before it has its result: msgs is consistent.
108
+ const note = `(turn failed after ${progress.sideEffects} tool call(s) with side effects: ${e.message.slice(0, 200)}. The work may be partial; check before repeating it.) ${progress.last}`.trim();
109
+ msgs.push({ role: "assistant", content: note });
110
+ if (!policy?.pii) this.commit(msgs);
111
+ this.onMessage?.(note, reply);
112
+ })
113
+ .finally(() => {
114
+ if (turn === this.turn && this.state === "busy") this.setState("idle");
115
+ });
116
+ }
117
+
118
+ protected override onWatchdog(): void {
119
+ this.turn++;
120
+ this.abort?.abort();
121
+ super.onWatchdog();
122
+ }
123
+
124
+ private async run(
125
+ envs: Envelope[],
126
+ turn: number,
127
+ msgs: ChatMessage[],
128
+ progress: { sideEffects: number; last: string },
129
+ policy: { route?: string; fixedModel?: string; pii: boolean; task?: string } | undefined,
130
+ reply: EnvelopeOpts,
131
+ ): Promise<string> {
132
+ const { maxSteps = 30 } = this.opts;
133
+ // claude-mem's observer is a cloud model: nothing of a PII turn is captured.
134
+ const capture = policy?.pii ? undefined : this.opts.capture;
135
+ // Positive confirmation, and for the path this turn will really take: a sidecar generated against the off-campus URL
136
+ // keeps sending there even after the client has found the campus gateway again.
137
+ const viaOffCampus = !!this.opts.sidecar?.upstream && this.opts.omni.isAccessHost(this.opts.sidecar.upstream);
138
+ if (policy?.pii && (viaOffCampus || !(await this.opts.omni.onCampus()))) {
139
+ return "Refused: this is a PII task and the only reachable gateway is off campus (Cloudflare Access). Connect the VPN and assign it again.";
140
+ }
141
+ const ctx: ToolContext = {
142
+ cwd: this.opts.cwd,
143
+ deny: this.opts.tools.deny,
144
+ permit: this.opts.tools.permit,
145
+ sandboxProfile: this.sandboxProfile,
146
+ send: (text, to) => {
147
+ this.onMessage?.(text, policy?.pii ? reply : { inReplyTo: replyParent(envs), ...(to?.length ? { to } : {}) });
148
+ return policy?.pii ? "sent to the console user only (PII task)" : "sent";
149
+ },
150
+ };
151
+ let usedTools = false;
152
+ for (let step = 0; step < maxSteps; step++) {
153
+ this.elide(msgs);
154
+ const res = await this.call(msgs, policy);
155
+ if (turn !== this.turn) return "";
156
+ this.touch();
157
+ msgs.push(res.message);
158
+ progress.last = res.message.content?.trim() || progress.last;
159
+ if (!res.message.tool_calls?.length) {
160
+ if (usedTools) capture?.summarize(progress.last);
161
+ return progress.last;
162
+ }
163
+ for (const call of res.message.tool_calls) {
164
+ // A tool has its own timeout (bash up to 600 s) and an approval can take 120 s: neither is the model going silent.
165
+ const alive = setInterval(() => this.state === "busy" && turn === this.turn && this.touch(), 30_000);
166
+ const name = call.function.name;
167
+ const running = TASK_TOOL_NAMES.has(name) && this.opts.taskTool ? this.opts.taskTool(name, safeParse(call.function.arguments), { pii: !!policy?.pii }).catch((e: Error) => `error: ${e.message}`) : runTool(name, call.function.arguments, ctx);
168
+ const output = await running.finally(() => clearInterval(alive));
169
+ if (turn !== this.turn) return "";
170
+ this.touch();
171
+ usedTools = true;
172
+ if (SIDE_EFFECTS.has(call.function.name) && !output.startsWith("error:")) progress.sideEffects++;
173
+ msgs.push({ role: "tool", tool_call_id: call.id, content: output });
174
+ capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
175
+ }
176
+ }
177
+ if (usedTools) capture?.summarize(progress.last);
178
+ return `(stopped after ${maxSteps} steps) ${progress.last}`.trim();
179
+ }
180
+
181
+ /** L2 when the sidecar is up, otherwise (or when a call through it fails) the fixed model on L3. */
182
+ private async call(turnMsgs: ChatMessage[], policy?: { route?: string; fixedModel?: string }): Promise<ChatResult> {
183
+ const { omni, sidecar } = this.opts;
184
+ // A task turn asks for its class's route; a route needs the sidecar, which exists only when the worker was started with one.
185
+ const route = sidecar ? (policy?.route ?? this.opts.route) : undefined;
186
+ const fixedModel = policy?.fixedModel ?? this.opts.fixedModel;
187
+ const tools = [...TOOL_SCHEMAS, ...(this.opts.taskTool ? TASK_TOOLS.map(asFunction) : [])];
188
+ this.abort = new AbortController();
189
+ const signal = this.abort.signal;
190
+ const messages: ChatMessage[] = [{ role: "system", content: system(this.opts.cwd, this.opts.preamble) }, ...this.history, ...turnMsgs];
191
+ const via = route ? await sidecar?.endpoint() : undefined;
192
+ if (via) {
193
+ try {
194
+ const res = await omni.chat({ model: route!, messages, tools }, { via, sessionId: this.sessionId, signal });
195
+ this.lastServedBy = `switchyard ${route} -> ${res.selectedModel ?? "?"}`;
196
+ return res;
197
+ } catch (e) {
198
+ if (signal.aborted) throw e;
199
+ sidecar!.disable((e as Error).message);
200
+ }
201
+ }
202
+ const res = await omni.chat({ model: fixedModel, messages, tools }, { signal });
203
+ this.lastServedBy = `omniroute ${fixedModel} (provider ${res.provider ?? "?"})`;
204
+ return res;
205
+ }
206
+
207
+ /** A finished turn joins the history; whole old turns (user message up to the next one) fall off the front, so tool calls keep their results. */
208
+ private commit(msgs: ChatMessage[]): void {
209
+ this.history.push(...msgs);
210
+ while (chars(this.history) > HISTORY_CHARS) {
211
+ const next = this.history.findIndex((m, i) => i > 0 && m.role === "user");
212
+ if (next === -1) break;
213
+ this.history.splice(0, next);
214
+ }
215
+ }
216
+
217
+ /** Inside a long turn the oldest tool outputs are replaced by a stub: the structure stays valid, the context stays bounded. */
218
+ private elide(msgs: ChatMessage[]): void {
219
+ for (const m of msgs) {
220
+ if (chars(msgs) <= TURN_CHARS) return;
221
+ if (m.role === "tool" && (m.content?.length ?? 0) > 200) m.content = "(output elided to save context; run the tool again if you need it)";
222
+ }
223
+ }
224
+ }
225
+
226
+ function safeParse(json: string): Record<string, unknown> {
227
+ try {
228
+ return JSON.parse(json) ?? {};
229
+ } catch {
230
+ return {};
231
+ }
232
+ }
@@ -0,0 +1,43 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ const TEMPLATES = join(import.meta.dir, "..", "..", "templates");
5
+ const BEGIN = "<!-- AGENT_HUB:BEGIN (managed by `ahub init`, edits inside are overwritten) -->";
6
+ const END = "<!-- AGENT_HUB:END -->";
7
+
8
+ /** Insert or replace the managed block. Idempotent: text outside the markers is never touched. */
9
+ export function upsertBlock(existing: string, block: string): string {
10
+ const managed = `${BEGIN}\n${block.trim()}\n${END}`;
11
+ const start = existing.indexOf("<!-- AGENT_HUB:BEGIN");
12
+ const end = existing.indexOf(END, Math.max(start, 0));
13
+ if (start !== -1 && end > start) return existing.slice(0, start) + managed + existing.slice(end + END.length);
14
+ return `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${managed}\n`;
15
+ }
16
+
17
+ /** Returns the paths it changed. */
18
+ export function init(cwd: string): string[] {
19
+ const changed: string[] = [];
20
+ const write = (path: string, next: string) => {
21
+ if (existsSync(path) && readFileSync(path, "utf8") === next) return;
22
+ writeFileSync(path, next);
23
+ changed.push(path);
24
+ };
25
+
26
+ mkdirSync(join(cwd, ".agenthub"), { recursive: true });
27
+ const config = join(cwd, ".agenthub", "config.json");
28
+ if (!existsSync(config)) write(config, readFileSync(join(TEMPLATES, "config.json"), "utf8"));
29
+
30
+ const routing = join(cwd, ".agenthub", "routing.toml");
31
+ if (!existsSync(routing)) write(routing, readFileSync(join(TEMPLATES, "routing.toml"), "utf8"));
32
+
33
+ for (const [file, template] of [["CLAUDE.md", "CLAUDE.block.md"], ["AGENTS.md", "AGENTS.block.md"]] as const) {
34
+ const path = join(cwd, file);
35
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
36
+ write(path, upsertBlock(existing, readFileSync(join(TEMPLATES, template), "utf8")));
37
+ }
38
+
39
+ const ignore = join(cwd, ".gitignore");
40
+ const lines = existsSync(ignore) ? readFileSync(ignore, "utf8") : "";
41
+ if (!lines.split("\n").includes(".agenthub/state/")) write(ignore, `${lines}${lines && !lines.endsWith("\n") ? "\n" : ""}.agenthub/state/\n`);
42
+ return changed;
43
+ }
@@ -0,0 +1,66 @@
1
+ // Launchers inject only the flags the hub owns and refuse user-supplied duplicates.
2
+ export const CLAUDE_CHANNEL = "plugin:agent-hub@agent-hub";
3
+
4
+ const OWNED: Record<"claude" | "codex", string[]> = {
5
+ claude: ["--dangerously-load-development-channels", "--dangerously-skip-permissions"],
6
+ codex: ["--remote", "--yolo", "--dangerously-bypass-approvals-and-sandbox"],
7
+ };
8
+
9
+ export const UNATTENDED_WARNING =
10
+ "WARNING: --unattended disables permission prompts. Other agents' messages are untrusted input and this agent will act on its own judgment without asking you.";
11
+
12
+ export interface Launch {
13
+ cmd: string;
14
+ args: string[];
15
+ warning?: string;
16
+ }
17
+
18
+ export interface StatusLineTee {
19
+ /** absolute path of src/cli/statusline-tee.ts */
20
+ script: string;
21
+ stateDir: string;
22
+ /** the user's own statusLine setting, wrapped so the status line looks the same */
23
+ original?: { command?: string; refreshInterval?: number; padding?: number };
24
+ }
25
+
26
+ const sh = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
27
+
28
+ /**
29
+ * Settings for one session that put the tee in front of the user's status line command. Claude Code's status line
30
+ * input carries `rate_limits`; this is the hub's Claude quota source. `~/.claude/settings.json` is never edited.
31
+ */
32
+ export function statusLineSettings(tee: StatusLineTee): string {
33
+ const command = `AGENTHUB_STATE_DIR=${sh(tee.stateDir)} AGENTHUB_STATUSLINE_CMD=${sh(tee.original?.command ?? "")} bun ${sh(tee.script)}`;
34
+ return JSON.stringify({ statusLine: { type: "command", command, refreshInterval: tee.original?.refreshInterval ?? 10, ...(tee.original?.padding !== undefined ? { padding: tee.original.padding } : {}) } });
35
+ }
36
+
37
+ export function buildLaunch(
38
+ tool: "claude" | "codex",
39
+ userArgs: string[],
40
+ ctx: { unattended: boolean; proxyUrl?: string; codexBin?: string; statusLine?: StatusLineTee },
41
+ ): Launch {
42
+ // Hub-level switches are consumed here; everything else passes through to the tool.
43
+ const passthrough = userArgs.filter((a) => !["--unattended", "--safe", "--new"].includes(a));
44
+ const dup = passthrough.find((a) => OWNED[tool].some((flag) => a === flag || a.startsWith(`${flag}=`)));
45
+ if (dup) throw new Error(`${dup} is managed by the hub; use "hub ${tool} --unattended" or drop the flag`);
46
+
47
+ const unattended = ctx.unattended || userArgs.includes("--unattended");
48
+ const warning = unattended ? { warning: UNATTENDED_WARNING } : {};
49
+ if (tool === "claude") {
50
+ // `--settings` takes one value, so a user-supplied one wins and Claude has no quota source for that session.
51
+ const own = passthrough.some((a) => a === "--settings" || a.startsWith("--settings="));
52
+ const tee = ctx.statusLine && !own ? ["--settings", statusLineSettings(ctx.statusLine)] : [];
53
+ const notes = [unattended ? UNATTENDED_WARNING : "", ctx.statusLine && own ? "note: you passed --settings, so the hub's status line tee is off and the budget coordinator cannot see Claude's quota (ahub budget set claude <0..1> still works)." : ""].filter(Boolean);
54
+ return {
55
+ cmd: "claude",
56
+ args: ["--dangerously-load-development-channels", CLAUDE_CHANNEL, ...(unattended ? ["--dangerously-skip-permissions"] : []), ...tee, ...passthrough],
57
+ ...(notes.length ? { warning: notes.join("\n") } : {}),
58
+ };
59
+ }
60
+ if (!ctx.proxyUrl) throw new Error("codex proxy url is missing");
61
+ return {
62
+ cmd: ctx.codexBin ?? "codex",
63
+ args: ["--enable", "tui_app_server", "--remote", ctx.proxyUrl, ...(unattended ? ["--dangerously-bypass-approvals-and-sandbox"] : []), ...passthrough],
64
+ ...warning,
65
+ };
66
+ }
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env bun
2
+ // Keep this entrypoint plain JavaScript: Node must fail before loading TypeScript or bun:sqlite.
3
+ const version = /^(\d+)\.(\d+)\.(\d+)$/.exec(process.versions.bun ?? "");
4
+ if (!version || !(Number(version[1]) > 1 || (Number(version[1]) === 1 && Number(version[2]) >= 3))) {
5
+ console.error("ahub: Bun >=1.3.0 is required. Install Bun: https://bun.sh");
6
+ process.exit(1);
7
+ }
8
+
9
+ await import("./main.ts");