@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
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { appendFileSync, chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { ServerWebSocket } from "bun";
|
|
5
|
+
import { AcpPeer, type PermissionRequest } from "../adapters/acp.ts";
|
|
6
|
+
import { CodexPeer } from "../adapters/codex-appserver.ts";
|
|
7
|
+
import { LocalPeer } from "../adapters/local-worker.ts";
|
|
8
|
+
import { Capture, skipTools } from "../memory/capture.ts";
|
|
9
|
+
import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
|
|
10
|
+
import { Sidecar } from "../switchyard/sidecar.ts";
|
|
11
|
+
import { Briefs } from "../memory/brief.ts";
|
|
12
|
+
import { Board, type TaskClass } from "./board.ts";
|
|
13
|
+
import { Budget, claudeWindows, codexWindows, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
|
|
14
|
+
import { HUB } from "./envelope.ts";
|
|
15
|
+
import { trimToTokens } from "../memory/recall.ts";
|
|
16
|
+
import { statSync } from "node:fs";
|
|
17
|
+
import type { BusEvent } from "./bus.ts";
|
|
18
|
+
import { DEFAULT_ROLES, roleContract, TASK_TOOLS } from "./hub-tools.ts";
|
|
19
|
+
import { Tasks } from "./tasks.ts";
|
|
20
|
+
import { DEFAULT_INFERENCE, DIGEST, Inference, type InferenceConfig } from "./inference.ts";
|
|
21
|
+
import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
|
|
22
|
+
import { currentRouting, detectSignals } from "./routing.ts";
|
|
23
|
+
import { Bus } from "./bus.ts";
|
|
24
|
+
import { PROTOCOL, stateDirFor } from "./control-client.ts";
|
|
25
|
+
import { newEnvelope, parseMarker, USER, type Envelope, type PeerId } from "./envelope.ts";
|
|
26
|
+
import { BasePeer, DEFAULT_WATCHDOG_MS } from "./peers.ts";
|
|
27
|
+
import { MemoryClient, workerUrl } from "../memory/client.ts";
|
|
28
|
+
import { projectChain, recallFor } from "../memory/recall.ts";
|
|
29
|
+
|
|
30
|
+
export interface HubConfig {
|
|
31
|
+
watchdog_ms: number;
|
|
32
|
+
kimi_cmd: string[];
|
|
33
|
+
codex_bin: string;
|
|
34
|
+
batch_max: number;
|
|
35
|
+
batch_ms: number;
|
|
36
|
+
queue_cap: number;
|
|
37
|
+
memory: { enabled: boolean; worker_url?: string; inject_tokens: number; brief_items: number };
|
|
38
|
+
roles: Record<string, string[]>;
|
|
39
|
+
budget: BudgetConfig;
|
|
40
|
+
inference: InferenceConfig;
|
|
41
|
+
omniroute: OmniRouteConfig;
|
|
42
|
+
local: { deny: string[]; bash_network: boolean; max_steps: number; read_allow: string[] };
|
|
43
|
+
}
|
|
44
|
+
export const DEFAULT_CONFIG: HubConfig = {
|
|
45
|
+
watchdog_ms: DEFAULT_WATCHDOG_MS,
|
|
46
|
+
kimi_cmd: ["kimi", "acp"],
|
|
47
|
+
codex_bin: "codex",
|
|
48
|
+
batch_max: 3,
|
|
49
|
+
batch_ms: 15_000,
|
|
50
|
+
queue_cap: 200,
|
|
51
|
+
memory: { enabled: true, inject_tokens: 2000, brief_items: 8 },
|
|
52
|
+
roles: DEFAULT_ROLES,
|
|
53
|
+
budget: DEFAULT_BUDGET,
|
|
54
|
+
inference: DEFAULT_INFERENCE,
|
|
55
|
+
omniroute: DEFAULT_OMNIROUTE,
|
|
56
|
+
local: { deny: [], bash_network: false, max_steps: 30, read_allow: [] },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export { stateDirFor };
|
|
60
|
+
|
|
61
|
+
/** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
|
|
62
|
+
const RESERVED_IDS = new Set([USER, "codex", "kimi", "local", "hub", DIGEST]);
|
|
63
|
+
const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
64
|
+
|
|
65
|
+
export function loadConfig(cwd: string): HubConfig {
|
|
66
|
+
try {
|
|
67
|
+
const file = JSON.parse(readFileSync(join(cwd, ".agenthub", "config.json"), "utf8"));
|
|
68
|
+
return {
|
|
69
|
+
...DEFAULT_CONFIG,
|
|
70
|
+
...file,
|
|
71
|
+
memory: { ...DEFAULT_CONFIG.memory, ...file.memory },
|
|
72
|
+
roles: { ...DEFAULT_CONFIG.roles, ...file.roles },
|
|
73
|
+
budget: { ...DEFAULT_CONFIG.budget, ...file.budget },
|
|
74
|
+
inference: { ...DEFAULT_CONFIG.inference, ...file.inference },
|
|
75
|
+
omniroute: { ...DEFAULT_CONFIG.omniroute, ...file.omniroute },
|
|
76
|
+
local: { ...DEFAULT_CONFIG.local, ...file.local },
|
|
77
|
+
};
|
|
78
|
+
} catch {
|
|
79
|
+
return DEFAULT_CONFIG;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface DaemonOptions {
|
|
84
|
+
cwd: string;
|
|
85
|
+
stateDir: string;
|
|
86
|
+
controlPort: number;
|
|
87
|
+
codexAppPort: number;
|
|
88
|
+
codexProxyPort: number;
|
|
89
|
+
/** 0 disables the Switchyard sidecar. */
|
|
90
|
+
switchyardPort?: number;
|
|
91
|
+
switchyardBin?: string;
|
|
92
|
+
config?: HubConfig;
|
|
93
|
+
/** Auto-approve ACP permission requests with the agent's allow_once option. */
|
|
94
|
+
unattended?: boolean;
|
|
95
|
+
permissionTimeoutMs?: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface Client {
|
|
99
|
+
authed: boolean;
|
|
100
|
+
role?: "peer" | "console" | "tools";
|
|
101
|
+
peer?: PeerId;
|
|
102
|
+
tail?: () => void;
|
|
103
|
+
}
|
|
104
|
+
type Sock = ServerWebSocket<Client>;
|
|
105
|
+
|
|
106
|
+
/** A peer that lives in another process and attaches over the control WS (the Claude channel plugin). */
|
|
107
|
+
class WsPeer extends BasePeer {
|
|
108
|
+
private sock: Sock | undefined;
|
|
109
|
+
attach(sock: Sock): void {
|
|
110
|
+
this.sock?.close(4000, "replaced");
|
|
111
|
+
this.sock = sock;
|
|
112
|
+
this.setState("idle");
|
|
113
|
+
}
|
|
114
|
+
detach(sock: Sock): void {
|
|
115
|
+
if (this.sock !== sock) return;
|
|
116
|
+
this.sock = undefined;
|
|
117
|
+
this.setState("offline");
|
|
118
|
+
}
|
|
119
|
+
async deliver(envs: Envelope[]): Promise<void> {
|
|
120
|
+
if (!this.sock) throw new Error(`${this.id} is offline`);
|
|
121
|
+
this.sock.send(JSON.stringify({ t: "deliver", envs }));
|
|
122
|
+
}
|
|
123
|
+
async start(): Promise<void> {}
|
|
124
|
+
async stop(): Promise<void> {
|
|
125
|
+
this.sock?.close();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function startDaemon(opts: DaemonOptions) {
|
|
130
|
+
const config = opts.config ?? loadConfig(opts.cwd);
|
|
131
|
+
mkdirSync(opts.stateDir, { recursive: true });
|
|
132
|
+
const logFile = join(opts.stateDir, "hub.log");
|
|
133
|
+
const log = (line: string) => appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`);
|
|
134
|
+
|
|
135
|
+
// Any local web page can open a WebSocket to a loopback port, so the control link needs a secret.
|
|
136
|
+
// The file is written only after the port is bound: a second daemon that loses the bind must not clobber it.
|
|
137
|
+
const token = randomBytes(24).toString("hex");
|
|
138
|
+
|
|
139
|
+
// The hub's own model calls (digest condensation, task triage) are wired below, once the gateway client exists.
|
|
140
|
+
let inference: Inference | undefined;
|
|
141
|
+
const bus = new Bus({ batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs) });
|
|
142
|
+
const memory = new MemoryClient(config.memory.worker_url ?? workerUrl(), 2000, log);
|
|
143
|
+
const chain = projectChain(opts.cwd);
|
|
144
|
+
const recalled = new Set<PeerId>(); // once per peer per hub run, however often the peer's session restarts
|
|
145
|
+
|
|
146
|
+
/** Session-start cross-platform recall. Awaited before the peer can receive anything, so it rides on the first delivery. Fail-open. */
|
|
147
|
+
async function ensurePreface(peer: PeerId): Promise<void> {
|
|
148
|
+
if (!config.memory.enabled || recalled.has(peer)) return;
|
|
149
|
+
const block = await recallFor(peer, memory, chain, config.memory.inject_tokens);
|
|
150
|
+
if (!block || recalled.has(peer)) return; // nothing yet (worker down, no memory): the peer's next session tries again
|
|
151
|
+
recalled.add(peer);
|
|
152
|
+
bus.preface(peer, block);
|
|
153
|
+
log(`recall ${peer}: ${block.length} chars`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const omni = new OmniRoute(config.omniroute, log);
|
|
157
|
+
/**
|
|
158
|
+
* PII decisions need a positive answer about the path a hub-owned model call will really take: a gateway that
|
|
159
|
+
* answered and is not behind Access, and no sidecar that was generated against the off-campus URL. Unknown is no.
|
|
160
|
+
*/
|
|
161
|
+
const onCampus = async (): Promise<boolean> => (await omni.onCampus()) && !(sidecar?.upstream && omni.isAccessHost(sidecar.upstream));
|
|
162
|
+
let sidecar: Sidecar | undefined; // L2, started by the first hub-owned model call, stopped with the hub
|
|
163
|
+
let sidecarRouting = "";
|
|
164
|
+
inference = new Inference(config.inference, { omni, sidecar: () => sidecar, route: "sy/fast", fixedModel: () => currentRouting(opts.cwd, log).local.fixed_model, log });
|
|
165
|
+
|
|
166
|
+
const consoles = new Set<Sock>();
|
|
167
|
+
/** A line for the human: hub.log and every open `ahub tail`. */
|
|
168
|
+
const notify = (line: string) => {
|
|
169
|
+
log(line);
|
|
170
|
+
for (const c of consoles) if (c.data.tail) c.send(JSON.stringify({ t: "notice", line }));
|
|
171
|
+
};
|
|
172
|
+
const board = new Board(join(opts.stateDir, "hub.db"));
|
|
173
|
+
const tasks = new Tasks({
|
|
174
|
+
board,
|
|
175
|
+
bus,
|
|
176
|
+
routing: () => currentRouting(opts.cwd, log), // routing.toml is edited while the hub runs: re-read on change, last good parse kept
|
|
177
|
+
cwd: opts.cwd,
|
|
178
|
+
project: chain.at(-1)!,
|
|
179
|
+
...(config.memory.enabled ? { memory, briefs: new Briefs(memory, chain.at(-1)!, config.memory.brief_items) } : {}),
|
|
180
|
+
notify,
|
|
181
|
+
triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
|
|
182
|
+
});
|
|
183
|
+
// ---- budget relay -------------------------------------------------------------------------------------------
|
|
184
|
+
const manualPaused = new Set<PeerId>(); // `ahub pause`: the coordinator never lifts these
|
|
185
|
+
const checkpointWaits = new Map<PeerId, (summary: string | undefined) => void>();
|
|
186
|
+
const PLATFORM: Record<PeerId, string> = { claude: "claude", codex: "codex", kimi: "kimi" };
|
|
187
|
+
const budget = new Budget(join(opts.stateDir, "hub.db"), config.budget, {
|
|
188
|
+
pause: (peer) => bus.pause(peer),
|
|
189
|
+
resume: (peer) => {
|
|
190
|
+
if (!manualPaused.has(peer)) bus.resume(peer);
|
|
191
|
+
},
|
|
192
|
+
requestCheckpoint: (peer) => {
|
|
193
|
+
const state = bus.stateOf(peer);
|
|
194
|
+
if (state !== "idle" && state !== "busy") return Promise.resolve(undefined); // nobody there to answer
|
|
195
|
+
const ask =
|
|
196
|
+
newEnvelope(HUB, "Checkpoint request: your quota window is almost used up and the hub is about to pause you. Finish the step you are on, write what you were doing, what is half done and what whoever continues must know to .agenthub/checkpoint.md, then call hub_checkpoint {summary} with the same text. Your open tasks will be handed to another peer; you will be resumed when the window resets.", { to: [peer], kind: "budget", priority: "important" });
|
|
197
|
+
bus.publish(ask);
|
|
198
|
+
return new Promise((resolve) => {
|
|
199
|
+
const timer = setTimeout(() => done(undefined), config.budget.checkpoint_timeout_s * 1000);
|
|
200
|
+
const done = (summary: string | undefined) => {
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
checkpointWaits.delete(peer);
|
|
203
|
+
// A busy peer may never have seen the request: left in its queue it would arrive after the resume, asking for a checkpoint of nothing.
|
|
204
|
+
if (summary === undefined) bus.withdraw(ask.id);
|
|
205
|
+
resolve(summary);
|
|
206
|
+
};
|
|
207
|
+
checkpointWaits.set(peer, done);
|
|
208
|
+
});
|
|
209
|
+
},
|
|
210
|
+
platformContext: async (peer) => {
|
|
211
|
+
const platform = PLATFORM[peer];
|
|
212
|
+
if (!config.memory.enabled || !platform) return undefined;
|
|
213
|
+
const text = await memory.contextInject(chain, platform);
|
|
214
|
+
return text?.startsWith("# [") ? `Recent ${platform} sessions in this project, from shared memory (the peer left no checkpoint):\n${trimToTokens(text.slice(Math.max(text.indexOf("\n### "), 0)).trim(), 800)}` : undefined;
|
|
215
|
+
},
|
|
216
|
+
attached: (peer) => bus.peers.has(peer),
|
|
217
|
+
// Somebody other than the paused peer has to be there, or the handoff would only leave its tasks without an owner.
|
|
218
|
+
canHandOff: (peer) => [...bus.peers.keys()].some((id) => id !== peer && ["idle", "busy"].includes(bus.stateOf(id))),
|
|
219
|
+
handoff: (peer, context) => tasks.reassignForPause(peer, context),
|
|
220
|
+
resumed: (record) => {
|
|
221
|
+
if (record.peer === "kimi") kimiTokens.length = 0; // a new window: the old counts would pause it again at once
|
|
222
|
+
const moved = record.moved.length ? `While you were paused these moved: ${record.moved.map((m) => `${m.title} (${m.role} -> ${m.to ?? "nobody"})`).join("; ")}. They stay where they are; ask the user if you should take one back.` : "Nothing was moved while you were paused.";
|
|
223
|
+
bus.publish(newEnvelope(HUB, `Your quota window has reset and the hub has resumed you (paused since ${new Date(record.since).toLocaleTimeString()}, ${record.reason}). ${moved} Messages queued for you follow.`, { to: [record.peer], kind: "budget", priority: "important" }));
|
|
224
|
+
},
|
|
225
|
+
notify: (line) => notify(line),
|
|
226
|
+
});
|
|
227
|
+
const kimiTokens: { at: number; n: number }[] = [];
|
|
228
|
+
let kimiSessionTotal = 0;
|
|
229
|
+
/** `total` is the session's running count: only what was added since the last update goes into the rolling window. */
|
|
230
|
+
const onKimiTokens = (total: number) => {
|
|
231
|
+
if (!config.budget.kimi_tokens_5h) return;
|
|
232
|
+
const now = Date.now();
|
|
233
|
+
const n = total >= kimiSessionTotal ? total - kimiSessionTotal : total; // a smaller total means a new session
|
|
234
|
+
kimiSessionTotal = total;
|
|
235
|
+
if (!n) return;
|
|
236
|
+
kimiTokens.push({ at: now, n });
|
|
237
|
+
while (kimiTokens.length && now - kimiTokens[0]!.at > 5 * 3_600_000) kimiTokens.shift();
|
|
238
|
+
const used = kimiTokens.reduce((s, t) => s + t.n, 0) / config.budget.kimi_tokens_5h;
|
|
239
|
+
budget.report("kimi", [{ id: "tokens", used, resetsAt: kimiTokens[0]!.at + 5 * 3_600_000, source: "kimi usage_update (soft limit)" }]);
|
|
240
|
+
};
|
|
241
|
+
// Claude's numbers arrive through the status line tee `ahub claude` installs (src/cli/statusline-tee.ts).
|
|
242
|
+
let claudeUsageSeen = 0;
|
|
243
|
+
const intervals = [
|
|
244
|
+
setInterval(() => budget.tick(), 30_000),
|
|
245
|
+
setInterval(() => {
|
|
246
|
+
try {
|
|
247
|
+
const file = join(opts.stateDir, "claude-usage.json");
|
|
248
|
+
const mtime = statSync(file).mtimeMs;
|
|
249
|
+
if (mtime === claudeUsageSeen) return;
|
|
250
|
+
claudeUsageSeen = mtime;
|
|
251
|
+
const usage = JSON.parse(readFileSync(file, "utf8"));
|
|
252
|
+
// The file's own timestamp, not "now": a file left behind by yesterday's session is a stale reading, not a fresh one.
|
|
253
|
+
budget.report("claude", claudeWindows(usage.rate_limits), false, Number(usage.at) || mtime);
|
|
254
|
+
} catch {
|
|
255
|
+
// no tee in this session, or a half-written file: next round
|
|
256
|
+
}
|
|
257
|
+
}, 5_000),
|
|
258
|
+
];
|
|
259
|
+
for (const i of intervals) i.unref?.();
|
|
260
|
+
|
|
261
|
+
/** What the console stream and the log may show: a private envelope (PII task) keeps its body to its recipients. */
|
|
262
|
+
const redact = (e: BusEvent): BusEvent => ("env" in e && e.env.private ? { ...e, env: { ...e.env, body: `[private${e.env.refs?.task ? `: task #${e.env.refs.task}, see ahub task show ${e.env.refs.task}` : ""}]` } } : e);
|
|
263
|
+
const SERVER_JS = join(import.meta.dir, "..", "..", "plugins", "agent-hub", "server.js");
|
|
264
|
+
const toolEnv = (peer: PeerId) => ({ AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: peer, AGENTHUB_STATE_DIR: opts.stateDir });
|
|
265
|
+
|
|
266
|
+
/** One entry point for the task tools, whoever calls them: MCP clients, the local worker, the console. */
|
|
267
|
+
async function taskOp(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
|
|
268
|
+
// Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
|
|
269
|
+
// claude-mem (a cloud observer) and a new task could be routed to a cloud peer without matching any pattern.
|
|
270
|
+
if (piiTurn && (op === "hub_remember" || op === "hub_task_propose")) throw new Error(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
271
|
+
// Lists are redacted for everyone but the on-prem worker, and only when it calls from inside this process: over the
|
|
272
|
+
// control WS anyone holding the token can claim to be "local". A board on a shared screen is a leak too, so the
|
|
273
|
+
// console reads a PII task's text deliberately, with `ahub task show <id>`.
|
|
274
|
+
const onPrem = inProcess && by === "local";
|
|
275
|
+
const line = (t: { id: number; state: string; owner: PeerId | null; reviewer: PeerId | null }) => `task #${t.id}: ${t.state}, owner ${t.owner ?? "none"}, reviewer ${t.reviewer ?? "none"}`;
|
|
276
|
+
switch (op) {
|
|
277
|
+
case "hub_task_propose":
|
|
278
|
+
return line(await tasks.propose(by, a));
|
|
279
|
+
case "hub_task_accept":
|
|
280
|
+
return line(tasks.accept(by, a.id));
|
|
281
|
+
case "hub_task_decline":
|
|
282
|
+
return line(await tasks.decline(by, a.id, a.reason));
|
|
283
|
+
case "hub_task_done":
|
|
284
|
+
return line(await tasks.done(by, a.id, a.summary, a.refs));
|
|
285
|
+
case "hub_review":
|
|
286
|
+
return line(await tasks.review(by, a.id, a.verdict, a.note));
|
|
287
|
+
case "hub_remember":
|
|
288
|
+
return tasks.remember(by, a);
|
|
289
|
+
case "hub_checkpoint": {
|
|
290
|
+
const summary = String(a.summary ?? "").trim();
|
|
291
|
+
if (!summary) throw new Error("summary is required");
|
|
292
|
+
budget.checkpointed(by, summary);
|
|
293
|
+
checkpointWaits.get(by)?.(summary);
|
|
294
|
+
return "checkpoint received; you will be paused now and resumed when your window resets";
|
|
295
|
+
}
|
|
296
|
+
case "hub_task_list":
|
|
297
|
+
return JSON.stringify(board.list(a.state).map((t) => (onPrem ? t : tasks.publicView(t))).map(({ history: _h, ...t }) => t));
|
|
298
|
+
}
|
|
299
|
+
if (by !== USER) throw new Error(`${op} is a console command`);
|
|
300
|
+
switch (op) {
|
|
301
|
+
case "task_show":
|
|
302
|
+
return JSON.stringify(board.get(Number(a.id)) ?? `no task #${a.id}`, null, 2);
|
|
303
|
+
case "task_assign":
|
|
304
|
+
return line(await tasks.assignTo(a.id, String(a.peer)));
|
|
305
|
+
case "task_escalate":
|
|
306
|
+
return line(await tasks.escalate(USER, a.id));
|
|
307
|
+
case "route_explain":
|
|
308
|
+
return tasks.explain(a.id !== undefined ? Number(a.id) : { title: String(a.title ?? ""), class: a.class as TaskClass }).join("\n");
|
|
309
|
+
}
|
|
310
|
+
throw new Error(`unknown task operation ${op}`);
|
|
311
|
+
}
|
|
312
|
+
const permissions = new Map<string, { push: string; done: (optionId: string | undefined) => void }>();
|
|
313
|
+
let stopping = false;
|
|
314
|
+
|
|
315
|
+
const pausedNote = (id: PeerId) => {
|
|
316
|
+
const r = budget.record(id); // one read per peer: status.json is rewritten on every bus event
|
|
317
|
+
return r ? { paused: `budget: ${r.reason}, resets ${new Date(r.resetsAt).toLocaleTimeString()}` } : {};
|
|
318
|
+
};
|
|
319
|
+
const status = () => ({
|
|
320
|
+
pid: process.pid,
|
|
321
|
+
cwd: opts.cwd,
|
|
322
|
+
controlPort: server.port,
|
|
323
|
+
codexProxyPort: opts.codexProxyPort,
|
|
324
|
+
peers: Object.fromEntries(
|
|
325
|
+
[...bus.peers].map(([id, p]) => [id, { state: bus.stateOf(id), queued: bus.queued(id), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}) }]),
|
|
326
|
+
),
|
|
327
|
+
...(sidecar ? { switchyard: sidecar.status } : {}),
|
|
328
|
+
tasks: board.counts(),
|
|
329
|
+
});
|
|
330
|
+
const writeStatus = () => {
|
|
331
|
+
const file = join(opts.stateDir, "status.json"); // clients parse this on every connect: replace it atomically
|
|
332
|
+
writeFileSync(`${file}.tmp`, `${JSON.stringify(status(), null, 2)}\n`);
|
|
333
|
+
renameSync(`${file}.tmp`, file);
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
bus.tap((e) => {
|
|
337
|
+
e = redact(e);
|
|
338
|
+
if (e.t === "state") log(`state ${e.peer} -> ${e.state}`);
|
|
339
|
+
else if (e.t === "undeliverable") log(`UNDELIVERABLE to ${e.peer} after retries: ${e.env.id} from ${e.env.from}`);
|
|
340
|
+
else if (e.t === "overflow") log(`OVERFLOW ${e.peer}: dropped ${e.env.id} from ${e.env.from}`);
|
|
341
|
+
else log(`msg ${e.env.from} -> ${e.env.to?.join(",") ?? "*"} ${e.env.priority} hop=${e.env.hop}${e.dropped ? ` NOT DELIVERED(${e.dropped})` : ""}: ${e.env.body.slice(0, 200)}`);
|
|
342
|
+
if (!stopping) writeStatus();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
async function onPermission(req: PermissionRequest): Promise<string | undefined> {
|
|
346
|
+
if (opts.unattended) return req.options.find((o) => o.kind === "allow_once")?.optionId;
|
|
347
|
+
const id = randomUUID().slice(0, 8);
|
|
348
|
+
// The title is written by an agent and read by the person approving it: escape sequences and carriage returns
|
|
349
|
+
// could repaint the terminal line, so everything but newline and tab is made visible.
|
|
350
|
+
const title = req.title.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
|
|
351
|
+
// The title can quote what a PII turn is about to write. It is for the person approving, on the console; the log
|
|
352
|
+
// (which `ahub ask` reads as evidence) only records that a request was made.
|
|
353
|
+
log(`permission ${id} requested by ${req.peer} (${title.length} chars, shown on the console)`);
|
|
354
|
+
const push = JSON.stringify({ t: "permission", id, ...req, title });
|
|
355
|
+
for (const c of consoles) if (c.data.tail) c.send(push);
|
|
356
|
+
return new Promise((resolve) => {
|
|
357
|
+
const timer = setTimeout(() => done(undefined), opts.permissionTimeoutMs ?? 120_000);
|
|
358
|
+
const done = (optionId: string | undefined) => {
|
|
359
|
+
clearTimeout(timer);
|
|
360
|
+
permissions.delete(id);
|
|
361
|
+
resolve(optionId);
|
|
362
|
+
};
|
|
363
|
+
permissions.set(id, { push, done }); // kept so a `ahub tail` opened later still sees it
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// One start per peer at a time: a second `ahub codex` must not tear down an adapter that is still coming up.
|
|
368
|
+
const starting = new Map<string, Promise<Record<string, unknown>>>();
|
|
369
|
+
function startPeer(peer: string, args: { model?: string; route?: string }): Promise<Record<string, unknown>> {
|
|
370
|
+
const running = starting.get(peer) ?? startPeerOnce(peer, args).finally(() => starting.delete(peer));
|
|
371
|
+
starting.set(peer, running);
|
|
372
|
+
return running;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function startPeerOnce(peer: string, args: { model?: string; route?: string }): Promise<Record<string, unknown>> {
|
|
376
|
+
const existing = bus.peers.get(peer);
|
|
377
|
+
if (existing && existing.state !== "offline") {
|
|
378
|
+
return { ok: true, already: true, ...(existing instanceof CodexPeer ? { proxyUrl: existing.proxyUrl } : {}) };
|
|
379
|
+
}
|
|
380
|
+
await existing?.stop();
|
|
381
|
+
if (peer === "kimi") {
|
|
382
|
+
const [bin, ...rest] = config.kimi_cmd;
|
|
383
|
+
const cmd = args.model ? [bin!, "--model", args.model, ...rest] : config.kimi_cmd;
|
|
384
|
+
const kimi = new AcpPeer("kimi", {
|
|
385
|
+
cmd,
|
|
386
|
+
cwd: opts.cwd,
|
|
387
|
+
watchdogMs: config.watchdog_ms,
|
|
388
|
+
onPermission,
|
|
389
|
+
log,
|
|
390
|
+
onTokens: onKimiTokens,
|
|
391
|
+
mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv("kimi")).map(([name, value]) => ({ name, value })) }],
|
|
392
|
+
preamble: roleContract("kimi", config.roles),
|
|
393
|
+
});
|
|
394
|
+
await ensurePreface("kimi");
|
|
395
|
+
bus.add(kimi);
|
|
396
|
+
await kimi.start();
|
|
397
|
+
return { ok: true };
|
|
398
|
+
}
|
|
399
|
+
if (peer === "codex") {
|
|
400
|
+
const codex = new CodexPeer("codex", {
|
|
401
|
+
appPort: opts.codexAppPort,
|
|
402
|
+
proxyPort: opts.codexProxyPort,
|
|
403
|
+
bin: config.codex_bin,
|
|
404
|
+
// The hub spawns this app-server, so it can give Codex the task tools without touching ~/.codex/config.toml
|
|
405
|
+
// (verified: app-server honours -c mcp_servers.* and the server reaches "ready").
|
|
406
|
+
extraArgs: [
|
|
407
|
+
["command", '"bun"'],
|
|
408
|
+
["args", JSON.stringify(["run", SERVER_JS])],
|
|
409
|
+
["env", `{${Object.entries(toolEnv("codex")).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(", ")}}`],
|
|
410
|
+
...["hub_send", ...TASK_TOOLS.map((t) => t.name)].map((name) => [`tools.${name}.approval_mode`, '"approve"']),
|
|
411
|
+
].flatMap(([k, v]) => ["-c", `mcp_servers.agent-hub.${k}=${v}`]),
|
|
412
|
+
preamble: roleContract("codex", config.roles),
|
|
413
|
+
onUsage: (rateLimits, hard) => budget.report("codex", codexWindows(rateLimits), hard),
|
|
414
|
+
usagePollMs: config.budget.poll_min * 60_000,
|
|
415
|
+
cwd: opts.cwd,
|
|
416
|
+
watchdogMs: config.watchdog_ms,
|
|
417
|
+
log,
|
|
418
|
+
});
|
|
419
|
+
await ensurePreface("codex");
|
|
420
|
+
bus.add(codex);
|
|
421
|
+
await codex.start();
|
|
422
|
+
return { ok: true, proxyUrl: codex.proxyUrl };
|
|
423
|
+
}
|
|
424
|
+
if (peer === "local") {
|
|
425
|
+
const routing = currentRouting(opts.cwd, log);
|
|
426
|
+
// `--model` pins a model on OmniRoute and skips L2; `--route` picks another Switchyard route.
|
|
427
|
+
const route = args.model ? undefined : (args.route ?? routing.local.route);
|
|
428
|
+
if (route && !routing.routes[route]) return { ok: false, error: `routing.toml has no route "${route}"` };
|
|
429
|
+
// The sidecar serves the routes it was generated from: a changed routing.toml needs a new one.
|
|
430
|
+
const routingKey = JSON.stringify([routing.targets, routing.routes]);
|
|
431
|
+
if (sidecar && routingKey !== sidecarRouting) {
|
|
432
|
+
sidecar.stop();
|
|
433
|
+
sidecar = undefined;
|
|
434
|
+
}
|
|
435
|
+
if (route && opts.switchyardPort) {
|
|
436
|
+
sidecarRouting = routingKey;
|
|
437
|
+
sidecar ??= new Sidecar({ routing, omni, stateDir: opts.stateDir, port: opts.switchyardPort, log, ...(opts.switchyardBin ? { bin: opts.switchyardBin } : {}) });
|
|
438
|
+
}
|
|
439
|
+
const capture = config.memory.enabled
|
|
440
|
+
? new Capture(memory, { project: chain.at(-1)!, cwd: opts.cwd, skip: skipTools(), deny: config.local.deny })
|
|
441
|
+
: undefined;
|
|
442
|
+
const permit = (title: string) =>
|
|
443
|
+
onPermission({
|
|
444
|
+
peer: "local",
|
|
445
|
+
title,
|
|
446
|
+
options: [
|
|
447
|
+
{ optionId: "allow", name: "Allow", kind: "allow_once" },
|
|
448
|
+
{ optionId: "deny", name: "Deny", kind: "reject_once" },
|
|
449
|
+
],
|
|
450
|
+
}).then((picked) => picked === "allow");
|
|
451
|
+
const local = new LocalPeer("local", {
|
|
452
|
+
cwd: opts.cwd,
|
|
453
|
+
omni,
|
|
454
|
+
...(sidecar && route ? { sidecar, route } : {}),
|
|
455
|
+
fixedModel: args.model ?? routing.local.fixed_model,
|
|
456
|
+
tools: { deny: config.local.deny, bashNetwork: config.local.bash_network, readAllow: config.local.read_allow, permit },
|
|
457
|
+
...(capture ? { capture } : {}),
|
|
458
|
+
taskTool: (name, a, turn) => taskOp("local", name, a, true, turn.pii),
|
|
459
|
+
turnPolicy: (envs) => tasks.turnPolicy(envs),
|
|
460
|
+
preamble: roleContract("local", config.roles),
|
|
461
|
+
watchdogMs: config.watchdog_ms,
|
|
462
|
+
maxSteps: config.local.max_steps,
|
|
463
|
+
log,
|
|
464
|
+
});
|
|
465
|
+
await ensurePreface("local");
|
|
466
|
+
bus.add(local);
|
|
467
|
+
await local.start();
|
|
468
|
+
return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
|
|
469
|
+
}
|
|
470
|
+
return { ok: false, error: `unknown peer "${peer}"` };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function onMessage(sock: Sock, msg: any): void {
|
|
474
|
+
const c = sock.data;
|
|
475
|
+
const reply = (body: Record<string, unknown>) => sock.send(JSON.stringify({ rid: msg.rid, ...body }));
|
|
476
|
+
if (!c.authed) {
|
|
477
|
+
if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
|
|
478
|
+
if (msg.v !== PROTOCOL) {
|
|
479
|
+
// An outdated plugin would drop every digest without a trace. Refuse it loudly instead.
|
|
480
|
+
log(`refused ${msg.role} ${msg.peer ?? ""}: wire version ${msg.v ?? 1}, hub speaks ${PROTOCOL} (claude plugin update agent-hub@agent-hub)`);
|
|
481
|
+
return sock.close(4426, `wire version mismatch: hub speaks ${PROTOCOL}; update the agent-hub plugin`);
|
|
482
|
+
}
|
|
483
|
+
c.authed = true;
|
|
484
|
+
c.role = msg.role === "peer" || msg.role === "tools" ? msg.role : "console";
|
|
485
|
+
if (c.role === "console") consoles.add(sock);
|
|
486
|
+
else if (c.role === "tools") {
|
|
487
|
+
// Acts for a peer the hub manages itself (kimi, codex): may send and use the board as that peer, is never a delivery target.
|
|
488
|
+
c.peer = String(msg.peer ?? "");
|
|
489
|
+
if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
|
|
490
|
+
} else {
|
|
491
|
+
c.peer = String(msg.peer ?? "claude");
|
|
492
|
+
if (!PEER_ID.test(c.peer) || RESERVED_IDS.has(c.peer)) return sock.close(4403, "peer id is reserved or malformed");
|
|
493
|
+
let peer = bus.peers.get(c.peer);
|
|
494
|
+
if (!peer) bus.add((peer = new WsPeer(c.peer)));
|
|
495
|
+
if (!(peer instanceof WsPeer)) return sock.close(4409, "peer id is taken by a hub-managed adapter");
|
|
496
|
+
const ws = peer;
|
|
497
|
+
void ensurePreface(c.peer).finally(() => {
|
|
498
|
+
if (sock.readyState === WebSocket.OPEN) ws.attach(sock);
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
return void reply({ t: "welcome" });
|
|
502
|
+
}
|
|
503
|
+
switch (msg.t) {
|
|
504
|
+
case "send": {
|
|
505
|
+
// A human at the console should not wait out the batch window; agents default to status.
|
|
506
|
+
const { priority, body } = parseMarker(String(msg.body ?? ""), c.peer ? "status" : "important");
|
|
507
|
+
if (!body) return void reply({ t: "sent", ok: false, error: "empty body" });
|
|
508
|
+
const to: PeerId[] | undefined = Array.isArray(msg.to) && msg.to.length ? msg.to.map(String) : undefined;
|
|
509
|
+
const unknown = to?.filter((id) => !bus.peers.has(id)) ?? [];
|
|
510
|
+
if (unknown.length) return void reply({ t: "sent", ok: false, error: `unknown peer: ${unknown.join(", ")}` });
|
|
511
|
+
const inReplyTo = msg.reply_to ? bus.get(String(msg.reply_to)) : undefined;
|
|
512
|
+
const targets = bus.publish(newEnvelope(c.peer ?? USER, body, { priority, ...(to ? { to } : {}), ...(inReplyTo ? { inReplyTo } : {}) }));
|
|
513
|
+
return void reply({ t: "sent", ok: true, targets, recorded: priority === "fyi" });
|
|
514
|
+
}
|
|
515
|
+
case "tail":
|
|
516
|
+
if (c.role !== "console" || c.tail) return;
|
|
517
|
+
c.tail = bus.tap((e) => sock.send(JSON.stringify({ t: "event", e: redact(e) })));
|
|
518
|
+
for (const p of permissions.values()) sock.send(p.push);
|
|
519
|
+
return;
|
|
520
|
+
case "status":
|
|
521
|
+
return void reply({ t: "status", status: status() });
|
|
522
|
+
case "start":
|
|
523
|
+
if (c.role !== "console") return;
|
|
524
|
+
startPeer(String(msg.peer), msg.args ?? {})
|
|
525
|
+
.catch((e: Error) => ({ ok: false, error: e.message }))
|
|
526
|
+
.then((r) => {
|
|
527
|
+
if (!r.ok) log(`start ${msg.peer} failed: ${r.error}`);
|
|
528
|
+
reply({ t: "started", ...r });
|
|
529
|
+
});
|
|
530
|
+
return;
|
|
531
|
+
case "task":
|
|
532
|
+
taskOp(c.peer ?? USER, String(msg.op), msg.args ?? {}).then(
|
|
533
|
+
(text) => reply({ t: "task", ok: true, text }),
|
|
534
|
+
(e: Error) => reply({ t: "task", ok: false, error: e.message }),
|
|
535
|
+
);
|
|
536
|
+
return;
|
|
537
|
+
case "pause":
|
|
538
|
+
case "resume": {
|
|
539
|
+
if (c.role !== "console") return;
|
|
540
|
+
const id = String(msg.peer);
|
|
541
|
+
if (!bus.peers.has(id)) return void reply({ t: msg.t, ok: false, error: `unknown peer: ${id}` });
|
|
542
|
+
if (msg.t === "pause") {
|
|
543
|
+
manualPaused.add(id);
|
|
544
|
+
bus.pause(id);
|
|
545
|
+
} else {
|
|
546
|
+
if (budget.record(id)) return void reply({ t: msg.t, ok: false, error: `${id} is paused by the budget coordinator until its window resets (ahub budget); to override: ahub budget resume ${id}` });
|
|
547
|
+
manualPaused.delete(id);
|
|
548
|
+
bus.resume(id);
|
|
549
|
+
}
|
|
550
|
+
return void reply({ t: msg.t, ok: true, state: bus.stateOf(id) });
|
|
551
|
+
}
|
|
552
|
+
case "ask":
|
|
553
|
+
// Console only: the evidence may hold PII task text (on campus), and the answer is for the person at the terminal.
|
|
554
|
+
if (c.role !== "console") return void reply({ t: "ask", ok: false, error: "ask is a console command" });
|
|
555
|
+
ask(String(msg.question ?? ""), {
|
|
556
|
+
board,
|
|
557
|
+
isPii: (t) => tasks.isPii(t),
|
|
558
|
+
onCampus,
|
|
559
|
+
isPiiText: (q) => currentRouting(opts.cwd, log).constraints.pii === "local_only" && detectSignals({ title: q, detail: "", refs: {} }, currentRouting(opts.cwd, log), opts.cwd).includes("pii"),
|
|
560
|
+
...(config.memory.enabled ? { memory } : {}),
|
|
561
|
+
project: chain.at(-1)!,
|
|
562
|
+
logFile,
|
|
563
|
+
...(inference ? { inference } : {}),
|
|
564
|
+
})
|
|
565
|
+
.then(async (res) => {
|
|
566
|
+
let saved: string | undefined;
|
|
567
|
+
if (msg.remember) {
|
|
568
|
+
if (res.pii) saved = "not saved: PII is involved";
|
|
569
|
+
else if (!res.found) saved = "not saved: there was no answer to save";
|
|
570
|
+
else if (!config.memory.enabled) saved = "not saved: memory is disabled";
|
|
571
|
+
else {
|
|
572
|
+
// Saved as what it is: a model's answer, attributed to the hub, under a title later asks exclude from their evidence.
|
|
573
|
+
const ok = await memory.save({
|
|
574
|
+
title: `${ASK_NOTE_TITLE}: ${String(msg.question).slice(0, 80)}`,
|
|
575
|
+
text: `Model-written answer from ahub ask, not a person's own finding.\nQ: ${String(msg.question).slice(0, 300)}\nA: ${res.answer}\nEvidence ids: ${res.evidence.map((e) => e.id).join(", ").slice(0, 600)}`,
|
|
576
|
+
project: chain.at(-1)!,
|
|
577
|
+
metadata: { peer: "hub", asked_by: USER, kind: "finding", source: "ahub ask" },
|
|
578
|
+
});
|
|
579
|
+
saved = ok ? "saved to shared memory as a model-written answer" : "memory worker unavailable; nothing saved";
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
reply({ t: "ask", ok: true, ...res, ...(saved ? { saved } : {}) });
|
|
583
|
+
})
|
|
584
|
+
.catch((e: Error) => reply({ t: "ask", ok: false, error: e.message }));
|
|
585
|
+
return;
|
|
586
|
+
case "budget":
|
|
587
|
+
if (c.role !== "console") return;
|
|
588
|
+
if (msg.resume) {
|
|
589
|
+
if (!budget.override(String(msg.resume))) return void reply({ t: "budget", ok: false, error: `${msg.resume} is not paused by the budget coordinator` });
|
|
590
|
+
} else if (msg.set) {
|
|
591
|
+
const used = Number(msg.set.used);
|
|
592
|
+
if (!bus.peers.has(String(msg.set.peer)) && !budget.record(String(msg.set.peer))) return void reply({ t: "budget", ok: false, error: `unknown peer: ${msg.set.peer}` });
|
|
593
|
+
if (!(used >= 0 && used <= 1)) return void reply({ t: "budget", ok: false, error: "used must be between 0 and 1" });
|
|
594
|
+
budget.report(String(msg.set.peer), [{ id: msg.set.window === "week" ? "week" : "5h", used, ...(msg.set.resetsInMs ? { resetsAt: Date.now() + Number(msg.set.resetsInMs) } : {}), source: "ahub budget set" }]);
|
|
595
|
+
}
|
|
596
|
+
return void reply({ t: "budget", ok: true, budget: budget.status(), gate: config.budget.gate });
|
|
597
|
+
case "permit":
|
|
598
|
+
if (c.role === "console") permissions.get(String(msg.id))?.done(msg.option ? String(msg.option) : undefined);
|
|
599
|
+
return;
|
|
600
|
+
case "kill":
|
|
601
|
+
if (c.role === "console") void stop();
|
|
602
|
+
return;
|
|
603
|
+
default:
|
|
604
|
+
// A newer CLI talking to an older hub must get an answer, not wait forever.
|
|
605
|
+
if (msg.rid !== undefined) reply({ t: String(msg.t), ok: false, error: `this hub does not know "${msg.t}" (restart it: ahub kill && ahub up)` });
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const server = Bun.serve<Client>({
|
|
610
|
+
hostname: "127.0.0.1",
|
|
611
|
+
port: opts.controlPort,
|
|
612
|
+
fetch(req, srv) {
|
|
613
|
+
if (req.headers.get("origin")) return new Response("forbidden", { status: 403 });
|
|
614
|
+
if (new URL(req.url).pathname === "/healthz") return new Response("ok");
|
|
615
|
+
return srv.upgrade(req, { data: { authed: false } }) ? undefined : new Response("agent-hub control");
|
|
616
|
+
},
|
|
617
|
+
websocket: {
|
|
618
|
+
message(sock, data) {
|
|
619
|
+
try {
|
|
620
|
+
onMessage(sock, JSON.parse(String(data)));
|
|
621
|
+
} catch (e) {
|
|
622
|
+
log(`bad control message: ${(e as Error).message}`);
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
close(sock) {
|
|
626
|
+
consoles.delete(sock);
|
|
627
|
+
sock.data.tail?.();
|
|
628
|
+
const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
|
|
629
|
+
if (peer instanceof WsPeer) peer.detach(sock);
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
async function stop(): Promise<void> {
|
|
635
|
+
if (stopping) return;
|
|
636
|
+
stopping = true;
|
|
637
|
+
log("hub stopping");
|
|
638
|
+
await Promise.allSettled([...bus.peers.values()].map((p) => p.stop()));
|
|
639
|
+
sidecar?.stop();
|
|
640
|
+
for (const i of intervals) clearInterval(i);
|
|
641
|
+
for (const done of checkpointWaits.values()) done(undefined);
|
|
642
|
+
budget.close();
|
|
643
|
+
board.close();
|
|
644
|
+
server.stop(true);
|
|
645
|
+
for (const f of ["hub.pid", "status.json", "control-token"]) rmSync(join(opts.stateDir, f), { force: true });
|
|
646
|
+
onStop?.();
|
|
647
|
+
}
|
|
648
|
+
let onStop: (() => void) | undefined;
|
|
649
|
+
|
|
650
|
+
const tokenFile = join(opts.stateDir, "control-token");
|
|
651
|
+
writeFileSync(tokenFile, token, { mode: 0o600 });
|
|
652
|
+
chmodSync(tokenFile, 0o600);
|
|
653
|
+
budget.restore(); // pauses recorded by an earlier hub run stay in force; unfinished handoffs wait for peers to attach
|
|
654
|
+
writeFileSync(join(opts.stateDir, "hub.pid"), `${process.pid}\n`);
|
|
655
|
+
writeStatus();
|
|
656
|
+
log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
|
|
657
|
+
return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
|
|
658
|
+
}
|