@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/hub/bus.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { HUB, MAX_HOP, newEnvelope, parseMarker, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
2
|
+
import type { PeerAdapter } from "./peers.ts";
|
|
3
|
+
|
|
4
|
+
export type BusEvent =
|
|
5
|
+
| { t: "envelope"; env: Envelope; dropped?: "hop" | "fyi" }
|
|
6
|
+
| { t: "overflow"; env: Envelope; peer: PeerId }
|
|
7
|
+
| { t: "undeliverable"; env: Envelope; peer: PeerId }
|
|
8
|
+
| { t: "state"; peer: PeerId; state: PeerState };
|
|
9
|
+
|
|
10
|
+
export interface BusOptions {
|
|
11
|
+
retryMs: number;
|
|
12
|
+
/** A queue this long is delivered without waiting for the batch window. */
|
|
13
|
+
batchMax: number;
|
|
14
|
+
/** How long a status envelope may wait for company before it is delivered on its own. */
|
|
15
|
+
batchMs: number;
|
|
16
|
+
queueCap: number;
|
|
17
|
+
/** Optional: rewrite a delivery before it goes out (M6 digest condensation). Must return its input on any failure. */
|
|
18
|
+
condense?: (envs: Envelope[]) => Promise<Envelope[]>;
|
|
19
|
+
}
|
|
20
|
+
export const DEFAULT_BUS: BusOptions = { retryMs: 1000, batchMax: 3, batchMs: 15_000, queueCap: 200 };
|
|
21
|
+
|
|
22
|
+
const SEEN_CAP = 2048;
|
|
23
|
+
const MAX_ATTEMPTS = 3;
|
|
24
|
+
const DIGEST_MAX = 10;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* N-peer fan-out. Never delivers back to `from`, caps hops, dedupes by id. Each peer has one queue,
|
|
28
|
+
* delivered as a whole (one envelope, or a digest) when the peer is idle and the queue is ready:
|
|
29
|
+
* it holds an `important` envelope, or `batchMax` envelopes, or its oldest one has waited `batchMs`.
|
|
30
|
+
*/
|
|
31
|
+
export class Bus {
|
|
32
|
+
readonly peers = new Map<PeerId, PeerAdapter>();
|
|
33
|
+
private readonly opts: BusOptions;
|
|
34
|
+
private readonly queues = new Map<PeerId, Envelope[]>();
|
|
35
|
+
private readonly prefaces = new Map<PeerId, Envelope>();
|
|
36
|
+
private readonly paused = new Set<PeerId>();
|
|
37
|
+
private readonly timers = new Map<PeerId, ReturnType<typeof setTimeout>>();
|
|
38
|
+
private readonly draining = new Set<PeerId>();
|
|
39
|
+
private readonly seen = new Map<string, Envelope>();
|
|
40
|
+
private readonly taps = new Set<(e: BusEvent) => void>();
|
|
41
|
+
private readonly withdrawn = new Set<string>();
|
|
42
|
+
/** What each peer was last handed, next to what it stands for: an adapter that reports a failure later hands back the former. */
|
|
43
|
+
private readonly lastDelivery = new Map<PeerId, { out: Envelope[]; originals: Envelope[] }>();
|
|
44
|
+
private readonly attempts = new Map<string, number>(); // `${peer}:${envelope id}` -> failed deliveries
|
|
45
|
+
|
|
46
|
+
constructor(opts: Partial<BusOptions> = {}) {
|
|
47
|
+
this.opts = { ...DEFAULT_BUS, ...opts };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
add(peer: PeerAdapter): void {
|
|
51
|
+
this.peers.set(peer.id, peer);
|
|
52
|
+
if (!this.queues.has(peer.id)) this.queues.set(peer.id, []);
|
|
53
|
+
peer.onMessage = (text, opts) => {
|
|
54
|
+
const { priority, body } = parseMarker(text);
|
|
55
|
+
if (body) this.publish(newEnvelope(peer.id, body, { priority, ...opts }));
|
|
56
|
+
};
|
|
57
|
+
peer.onFailed = (envs) => {
|
|
58
|
+
// The adapter got the condensed list; what has to come back is what that list replaced.
|
|
59
|
+
const last = this.lastDelivery.get(peer.id);
|
|
60
|
+
const same = !!last && envs.length === last.out.length && envs.every((e, i) => e.id === last.out[i]!.id);
|
|
61
|
+
this.failed(peer.id, same ? last.originals : envs);
|
|
62
|
+
};
|
|
63
|
+
peer.onState = () => {
|
|
64
|
+
this.emit({ t: "state", peer: peer.id, state: this.stateOf(peer.id) });
|
|
65
|
+
void this.drain(peer.id);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
tap(fn: (e: BusEvent) => void): () => void {
|
|
70
|
+
this.taps.add(fn);
|
|
71
|
+
return () => this.taps.delete(fn);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The adapter's state, or `paused` while the peer is held by `pause()`. */
|
|
75
|
+
stateOf(id: PeerId): PeerState {
|
|
76
|
+
const state = this.peers.get(id)?.state ?? "offline";
|
|
77
|
+
return this.paused.has(id) && state !== "offline" ? "paused" : state;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
pause(id: PeerId): void {
|
|
81
|
+
this.paused.add(id);
|
|
82
|
+
this.emit({ t: "state", peer: id, state: this.stateOf(id) });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
resume(id: PeerId): void {
|
|
86
|
+
if (!this.paused.delete(id)) return;
|
|
87
|
+
this.emit({ t: "state", peer: id, state: this.stateOf(id) });
|
|
88
|
+
void this.drain(id);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Context the hub wants the peer to see once: it rides in front of the next delivery instead of costing a turn of its own. */
|
|
92
|
+
preface(id: PeerId, body: string): void {
|
|
93
|
+
this.prefaces.set(id, newEnvelope(HUB, body, { to: [id], kind: "presence" }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Returns the peers the envelope was queued for. */
|
|
97
|
+
publish(env: Envelope): PeerId[] {
|
|
98
|
+
if (this.seen.has(env.id)) return [];
|
|
99
|
+
this.seen.set(env.id, env);
|
|
100
|
+
if (this.seen.size > SEEN_CAP) this.seen.delete(this.seen.keys().next().value as string);
|
|
101
|
+
|
|
102
|
+
// fyi is for the record (console now, task board from M4), never worth an agent's turn.
|
|
103
|
+
const dropped = env.hop > MAX_HOP ? "hop" : env.priority === "fyi" ? "fyi" : undefined;
|
|
104
|
+
this.emit({ t: "envelope", env, ...(dropped ? { dropped } : {}) });
|
|
105
|
+
if (dropped) return [];
|
|
106
|
+
|
|
107
|
+
const targets = (env.to ?? [...this.peers.keys()]).filter((id) => id !== env.from && this.peers.has(id));
|
|
108
|
+
for (const id of targets) {
|
|
109
|
+
const peer = this.peers.get(id)!;
|
|
110
|
+
if (env.priority === "important" && peer.steer && this.stateOf(id) === "busy") {
|
|
111
|
+
// Not queued while the steer is in flight, or an idle transition would deliver it a second time.
|
|
112
|
+
peer.steer([env]).catch(() => this.enqueue(id, env, true));
|
|
113
|
+
} else this.enqueue(id, env);
|
|
114
|
+
}
|
|
115
|
+
return targets;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A recently published envelope, for resolving `reply_to`. */
|
|
119
|
+
get(id: string): Envelope | undefined {
|
|
120
|
+
return this.seen.get(id);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Take back an envelope that has not been delivered yet (a request that has expired). True when it was removed from a
|
|
125
|
+
* queue now; an envelope whose steer is still in flight is not in any queue, so its id is remembered and it is
|
|
126
|
+
* dropped if the refused steer tries to queue it later.
|
|
127
|
+
*/
|
|
128
|
+
withdraw(envelopeId: string): boolean {
|
|
129
|
+
this.withdrawn.add(envelopeId);
|
|
130
|
+
if (this.withdrawn.size > 256) this.withdrawn.delete(this.withdrawn.values().next().value as string);
|
|
131
|
+
let removed = false;
|
|
132
|
+
for (const queue of this.queues.values()) {
|
|
133
|
+
const i = queue.findIndex((e) => e.id === envelopeId);
|
|
134
|
+
if (i !== -1) removed = queue.splice(i, 1).length > 0;
|
|
135
|
+
}
|
|
136
|
+
return removed;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
queued(id: PeerId): number {
|
|
140
|
+
return this.queues.get(id)?.length ?? 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private enqueue(id: PeerId, env: Envelope, front = false): void {
|
|
144
|
+
if (this.withdrawn.has(env.id)) return;
|
|
145
|
+
const queue = this.queues.get(id)!;
|
|
146
|
+
if (front) queue.unshift(env);
|
|
147
|
+
else queue.push(env);
|
|
148
|
+
if (queue.length > this.opts.queueCap) {
|
|
149
|
+
const victim = queue.findIndex((e) => e.priority !== "important");
|
|
150
|
+
const [lost] = queue.splice(victim === -1 ? 0 : victim, 1);
|
|
151
|
+
this.emit({ t: "overflow", env: lost!, peer: id });
|
|
152
|
+
}
|
|
153
|
+
void this.drain(id);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 0 = deliver now; otherwise how long the oldest envelope still has to wait for company. */
|
|
157
|
+
private wait(queue: Envelope[]): number {
|
|
158
|
+
if (queue.length >= this.opts.batchMax || queue.some((e) => e.priority === "important")) return 0;
|
|
159
|
+
return Math.max(0, queue[0]!.ts + this.opts.batchMs - Date.now());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async drain(id: PeerId): Promise<void> {
|
|
163
|
+
if (this.draining.has(id)) return;
|
|
164
|
+
this.draining.add(id);
|
|
165
|
+
try {
|
|
166
|
+
const peer = this.peers.get(id)!;
|
|
167
|
+
const queue = this.queues.get(id)!;
|
|
168
|
+
while (queue.length && this.stateOf(id) === "idle") {
|
|
169
|
+
const wait = this.wait(queue);
|
|
170
|
+
if (wait > 0) {
|
|
171
|
+
this.arm(id, wait);
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
const preface = this.prefaces.get(id);
|
|
175
|
+
this.prefaces.delete(id);
|
|
176
|
+
const batch = this.take(id, queue);
|
|
177
|
+
const delivery = preface ? [preface, ...batch] : batch;
|
|
178
|
+
// What goes out may be condensed; what comes back on failure is always the originals. An important envelope
|
|
179
|
+
// is why the queue became ready, so it never waits on a model call.
|
|
180
|
+
const mayCondense = this.opts.condense && !delivery.some((e) => e.priority === "important");
|
|
181
|
+
const out = mayCondense ? await this.opts.condense!(delivery).catch(() => delivery) : delivery;
|
|
182
|
+
if (mayCondense && this.stateOf(id) !== "idle") {
|
|
183
|
+
// The peer got busy while the delivery was prepared. Nothing was attempted: back to the head of the queue,
|
|
184
|
+
// without counting against the envelopes.
|
|
185
|
+
if (preface) this.prefaces.set(id, preface);
|
|
186
|
+
queue.unshift(...batch.filter((e) => !this.withdrawn.has(e.id)));
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
// A reply may name any item of `out` as its parent (Claude's reply_to): the digest has to be resolvable too.
|
|
190
|
+
for (const e of out) if (!this.seen.has(e.id)) this.seen.set(e.id, e);
|
|
191
|
+
this.lastDelivery.set(id, { out, originals: delivery });
|
|
192
|
+
try {
|
|
193
|
+
await peer.deliver(out);
|
|
194
|
+
} catch {
|
|
195
|
+
this.failed(id, delivery);
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} finally {
|
|
200
|
+
this.draining.delete(id);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The next delivery: important envelopes first (they are why the queue is ready), then the rest in order.
|
|
206
|
+
* An envelope that already failed once goes alone, so a poison one cannot take a digest down with it.
|
|
207
|
+
*/
|
|
208
|
+
private take(id: PeerId, queue: Envelope[]): Envelope[] {
|
|
209
|
+
const ordered = [...queue.filter((e) => e.priority === "important"), ...queue.filter((e) => e.priority !== "important")];
|
|
210
|
+
const batch: Envelope[] = [];
|
|
211
|
+
for (const env of ordered) {
|
|
212
|
+
const failedBefore = this.attempts.has(`${id}:${env.id}`);
|
|
213
|
+
if (failedBefore && batch.length) break;
|
|
214
|
+
batch.push(env);
|
|
215
|
+
if (failedBefore || batch.length === DIGEST_MAX) break;
|
|
216
|
+
}
|
|
217
|
+
for (const env of batch) queue.splice(queue.indexOf(env), 1);
|
|
218
|
+
return batch;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private arm(id: PeerId, ms: number): void {
|
|
222
|
+
clearTimeout(this.timers.get(id));
|
|
223
|
+
const timer = setTimeout(() => void this.drain(id), ms);
|
|
224
|
+
timer.unref?.();
|
|
225
|
+
this.timers.set(id, timer);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** At-least-once: back to the queue head and retried after a pause; given up after MAX_ATTEMPTS so one poison envelope cannot block the peer. */
|
|
229
|
+
private failed(id: PeerId, envs: Envelope[]): void {
|
|
230
|
+
const keep = envs.filter((env) => {
|
|
231
|
+
// Only the recall block is a preface. The hub also sends task and review envelopes, and those are retried like any other.
|
|
232
|
+
if (env.from === HUB && env.kind === "presence") {
|
|
233
|
+
this.prefaces.set(id, env); // rides again on the next delivery, without counting as a failed envelope
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
const key = `${id}:${env.id}`;
|
|
237
|
+
const n = (this.attempts.get(key) ?? 0) + 1;
|
|
238
|
+
if (n < MAX_ATTEMPTS) return this.attempts.set(key, n);
|
|
239
|
+
this.attempts.delete(key);
|
|
240
|
+
this.emit({ t: "undeliverable", env, peer: id });
|
|
241
|
+
return false;
|
|
242
|
+
});
|
|
243
|
+
this.queues.get(id)!.unshift(...keep);
|
|
244
|
+
// The idle transition caused by the failure itself may have been swallowed by the `draining` guard.
|
|
245
|
+
this.arm(id, this.opts.retryMs);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private emit(e: BusEvent): void {
|
|
249
|
+
for (const tap of this.taps) tap(e);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export function stateDirFor(cwd: string): string {
|
|
5
|
+
return process.env.AGENTHUB_STATE_DIR ?? join(cwd, ".agenthub", "state");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`. The plugin is installed apart from the daemon, so they can drift. */
|
|
9
|
+
export const PROTOCOL = 5;
|
|
10
|
+
|
|
11
|
+
export interface Hello {
|
|
12
|
+
/** `tools`: acts for `peer` (task tools, hub_send) without being a delivery target: the MCP server Kimi and Codex run. */
|
|
13
|
+
role: "peer" | "console" | "tools";
|
|
14
|
+
peer?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 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): { url: string; token: string } | undefined {
|
|
19
|
+
try {
|
|
20
|
+
const status = JSON.parse(readFileSync(join(stateDir, "status.json"), "utf8"));
|
|
21
|
+
const token = readFileSync(join(stateDir, "control-token"), "utf8").trim();
|
|
22
|
+
return { url: `ws://127.0.0.1:${status.controlPort}`, token };
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One authenticated control connection. `request` correlates replies by `rid`; everything else goes to `onPush`. */
|
|
29
|
+
export class ControlClient {
|
|
30
|
+
private nextRid = 1;
|
|
31
|
+
private readonly pending = new Map<number, (msg: any) => void>();
|
|
32
|
+
onPush: (msg: any) => void = () => {};
|
|
33
|
+
onClose: () => void = () => {};
|
|
34
|
+
|
|
35
|
+
private constructor(private readonly ws: WebSocket) {}
|
|
36
|
+
|
|
37
|
+
static connect(stateDir: string, hello: Hello): Promise<ControlClient> {
|
|
38
|
+
const control = readControl(stateDir);
|
|
39
|
+
if (!control) return Promise.reject(new Error(`no hub running for ${stateDir} (run: ahub up)`));
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const ws = new WebSocket(control.url);
|
|
42
|
+
const client = new ControlClient(ws);
|
|
43
|
+
ws.onerror = () => reject(new Error(`cannot reach hub at ${control.url}`));
|
|
44
|
+
ws.onclose = () => {
|
|
45
|
+
reject(new Error("hub closed the connection (stale token?)"));
|
|
46
|
+
for (const done of client.pending.values()) done({ ok: false, error: "hub connection closed" });
|
|
47
|
+
client.pending.clear();
|
|
48
|
+
client.onClose();
|
|
49
|
+
};
|
|
50
|
+
ws.onmessage = (ev) => {
|
|
51
|
+
const msg = JSON.parse(String(ev.data));
|
|
52
|
+
const done = msg.rid !== undefined ? client.pending.get(msg.rid) : undefined;
|
|
53
|
+
if (!done) return client.onPush(msg);
|
|
54
|
+
client.pending.delete(msg.rid);
|
|
55
|
+
done(msg);
|
|
56
|
+
};
|
|
57
|
+
ws.onopen = () => void client.request({ t: "hello", v: PROTOCOL, token: control.token, ...hello }).then(() => resolve(client));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `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?: number): Promise<any> {
|
|
63
|
+
const rid = this.nextRid++;
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
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;
|
|
66
|
+
this.pending.set(rid, (reply) => (clearTimeout(timer), resolve(reply)));
|
|
67
|
+
this.ws.send(JSON.stringify({ ...msg, rid }));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
send(msg: Record<string, unknown>): void {
|
|
72
|
+
this.ws.send(JSON.stringify(msg));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
close(): void {
|
|
76
|
+
this.ws.close();
|
|
77
|
+
}
|
|
78
|
+
}
|