@staix/agent-hub 0.7.7 → 0.7.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -1
- package/README.md +5 -4
- package/docs/operations.md +53 -13
- package/docs/quickstart.md +1 -1
- package/docs/security.md +2 -1
- package/docs/smoke.md +52 -11
- package/docs/specs/2026-09-19-agent-hub-design.md +91 -3
- package/docs/specs/2026-09-20-durable-delivery-design.md +1 -1
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +7 -1
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10 -7
- package/src/adapters/acp.ts +13 -1
- package/src/adapters/claude-channel.ts +9 -5
- package/src/adapters/codex-appserver.ts +19 -4
- package/src/cli/init.ts +6 -2
- package/src/cli/main.ts +12 -5
- package/src/hub/bus.ts +9 -1
- package/src/hub/checks.ts +75 -0
- package/src/hub/config-trust.ts +84 -0
- package/src/hub/daemon.ts +161 -32
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/tasks.ts +122 -9
- package/templates/config.json +4 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
2
|
import type { Server, ServerWebSocket } from "bun";
|
|
3
|
-
import { renderDigest, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
3
|
+
import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
4
4
|
import { BasePeer } from "../hub/peers.ts";
|
|
5
5
|
import { childEnv, stopOwnedProcess } from "../hub/child-process.ts";
|
|
6
6
|
|
|
@@ -54,6 +54,7 @@ export class CodexPeer extends BasePeer {
|
|
|
54
54
|
private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error) => void; deliveryId?: string; kind?: "deliver" | "steer" }>();
|
|
55
55
|
private usageTimer: ReturnType<typeof setInterval> | undefined;
|
|
56
56
|
private injected: Envelope | undefined; // the hub envelope that started the current turn, if any
|
|
57
|
+
private answering: Envelope[] = []; // everything delivered or steered into the current turn: who its answer is for
|
|
57
58
|
private lastAnswer = "";
|
|
58
59
|
private readonly deltas = new Map<string, string[]>();
|
|
59
60
|
private primed = false;
|
|
@@ -146,6 +147,7 @@ export class CodexPeer extends BasePeer {
|
|
|
146
147
|
resolve: (result) => {
|
|
147
148
|
this.primed = true;
|
|
148
149
|
this.injected = replyParent(envs);
|
|
150
|
+
this.answering = [...envs];
|
|
149
151
|
const nativeTurn = typeof result?.turn?.id === "string" ? result.turn.id : undefined;
|
|
150
152
|
if (deliveryId) {
|
|
151
153
|
if (nativeTurn) this.addTurnDelivery(nativeTurn, deliveryId);
|
|
@@ -188,6 +190,7 @@ export class CodexPeer extends BasePeer {
|
|
|
188
190
|
this.primed = true;
|
|
189
191
|
// The turn now answers these too; the highest hop wins so a steer cannot reset the hop cap.
|
|
190
192
|
this.injected = replyParent(this.injected ? [this.injected, ...envs] : envs);
|
|
193
|
+
this.answering.push(...envs);
|
|
191
194
|
if (deliveryId) {
|
|
192
195
|
this.addTurnDelivery(expectedTurnId, deliveryId);
|
|
193
196
|
this.delivery({ id: deliveryId, state: "accepted" });
|
|
@@ -216,6 +219,7 @@ export class CodexPeer extends BasePeer {
|
|
|
216
219
|
for (const id of this.unboundDeliveries) this.delivery({ id, state: "needs_review", reason: "Codex turn watchdog timeout" });
|
|
217
220
|
this.turnDeliveries.clear(); this.unboundDeliveries.clear();
|
|
218
221
|
this.injected = undefined;
|
|
222
|
+
this.answering = [];
|
|
219
223
|
this.lastAnswer = "";
|
|
220
224
|
this.abandonSteers("turn went silent");
|
|
221
225
|
super.onWatchdog();
|
|
@@ -297,9 +301,17 @@ export class CodexPeer extends BasePeer {
|
|
|
297
301
|
for (const ids of this.turnDeliveries.values()) for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Codex TUI detached" });
|
|
298
302
|
for (const id of this.unboundDeliveries) this.delivery({ id, state: "needs_review", reason: "Codex TUI detached" });
|
|
299
303
|
this.turnDeliveries.clear(); this.unboundDeliveries.clear();
|
|
304
|
+
this.forgetTurn();
|
|
300
305
|
this.setState("offline");
|
|
301
306
|
}
|
|
302
307
|
|
|
308
|
+
/** Nothing of an interrupted turn may address a later answer: a TUI that comes back starts its own. */
|
|
309
|
+
private forgetTurn(): void {
|
|
310
|
+
this.injected = undefined;
|
|
311
|
+
this.answering = [];
|
|
312
|
+
this.lastAnswer = "";
|
|
313
|
+
}
|
|
314
|
+
|
|
303
315
|
private fromTui(link: Link, raw: string): void {
|
|
304
316
|
const msg = parse(raw);
|
|
305
317
|
if (msg?.id !== undefined && TRACKED.has(msg.method)) link.tracked.set(msg.id, msg.method);
|
|
@@ -329,6 +341,7 @@ export class CodexPeer extends BasePeer {
|
|
|
329
341
|
this.link = link;
|
|
330
342
|
this.threadId = threadId;
|
|
331
343
|
this.activeTurns.clear();
|
|
344
|
+
this.forgetTurn();
|
|
332
345
|
this.opts.log?.(`[${this.id}] thread ${threadId}`);
|
|
333
346
|
this.setState("idle");
|
|
334
347
|
this.readUsage();
|
|
@@ -385,11 +398,13 @@ export class CodexPeer extends BasePeer {
|
|
|
385
398
|
this.turnDeliveries.delete(nativeTurn);
|
|
386
399
|
}
|
|
387
400
|
const inReplyTo = this.injected;
|
|
401
|
+
const to = replyAudience(this.answering);
|
|
388
402
|
this.injected = undefined;
|
|
403
|
+
this.answering = [];
|
|
389
404
|
this.deltas.clear();
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
if (this.lastAnswer) this.onMessage?.(this.lastAnswer, inReplyTo ? { inReplyTo } : {});
|
|
405
|
+
// Addressed like every other adapter's answer (issue #3): each sender it answers, including one steered in;
|
|
406
|
+
// the bus maps a condensed delivery's `digest` back to the senders it replaced, a steer beside it or not.
|
|
407
|
+
if (this.lastAnswer) this.onMessage?.(this.lastAnswer, inReplyTo ? { inReplyTo, to } : {});
|
|
393
408
|
this.lastAnswer = "";
|
|
394
409
|
this.setState("idle");
|
|
395
410
|
}
|
package/src/cli/init.ts
CHANGED
|
@@ -59,7 +59,11 @@ export function init(cwd: string): string[] {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
const ignore = join(cwd, ".gitignore");
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
let lines = existsSync(ignore) ? readFileSync(ignore, "utf8") : "";
|
|
63
|
+
// The machine's own config: only a file nobody committed may choose commands and credential files (issue #17).
|
|
64
|
+
for (const entry of [".agenthub/state/", ".agenthub/config.local.json"]) {
|
|
65
|
+
if (!lines.split("\n").includes(entry)) lines = `${lines}${lines && !lines.endsWith("\n") ? "\n" : ""}${entry}\n`;
|
|
66
|
+
}
|
|
67
|
+
write(ignore, lines);
|
|
64
68
|
return changed;
|
|
65
69
|
}
|
package/src/cli/main.ts
CHANGED
|
@@ -96,6 +96,12 @@ try {
|
|
|
96
96
|
} else selected = projectContext(process.cwd());
|
|
97
97
|
} catch (error) { fail((error as Error).message); }
|
|
98
98
|
const cwd = selected.root;
|
|
99
|
+
/** The project config, saying on stderr which machine-local fields it had to ignore (issue #17). */
|
|
100
|
+
const projectConfig = () => {
|
|
101
|
+
const config = loadConfig(cwd);
|
|
102
|
+
for (const line of config.ignored ?? []) console.error(`note: ${line}; only a config file nobody committed may set it`);
|
|
103
|
+
return config;
|
|
104
|
+
};
|
|
99
105
|
const stateDir = selected.stateDir;
|
|
100
106
|
try { process.chdir(cwd); } catch { fail(`project directory is unavailable: ${cwd}`); }
|
|
101
107
|
const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
|
|
@@ -472,7 +478,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
472
478
|
if (!res.ok) fail(res.error);
|
|
473
479
|
const control = readControl(stateDir);
|
|
474
480
|
if (control?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, control.instanceId);
|
|
475
|
-
const launch = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: res.proxyUrl, codexBin:
|
|
481
|
+
const launch = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: res.proxyUrl, codexBin: projectConfig().codex_bin });
|
|
476
482
|
if (launch0.warning) console.error(launch0.warning);
|
|
477
483
|
exec(launch.cmd, launch.args);
|
|
478
484
|
},
|
|
@@ -517,7 +523,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
517
523
|
|
|
518
524
|
models: async () => {
|
|
519
525
|
const action = args[0] ?? "status";
|
|
520
|
-
const configured =
|
|
526
|
+
const configured = projectConfig().mlx;
|
|
521
527
|
const runtimeDir = configured.runtimeDir ? resolve(cwd, configured.runtimeDir) : join(homedir(), ".agenthub", "runtimes", "mlx");
|
|
522
528
|
const modelPath = configured.modelPath ? resolve(cwd, configured.modelPath) : join(homedir(), ".agenthub", "models", "qwen3-8b-mlx");
|
|
523
529
|
const mlxOptions = configured.provider === "ollama" ? configured : { ...configured, runtimeDir, modelPath };
|
|
@@ -757,13 +763,14 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
757
763
|
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`);
|
|
758
764
|
|
|
759
765
|
const config = loadConfig(cwd);
|
|
766
|
+
row(!config.ignored, "config", config.ignored ? `${config.ignored.join("; ")} (move them to .agenthub/config.local.json)` : "no machine-local field ignored");
|
|
760
767
|
const omni = new OmniRoute(config.omniroute);
|
|
761
768
|
const gateway = await omni.base();
|
|
762
|
-
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");
|
|
763
|
-
row(!!omni.apiKey(), "omniroute key", omni.apiKey() ? "present" : "missing: set OMNIROUTE_API_KEY or omniroute.api_key_file in .agenthub/config.json");
|
|
769
|
+
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.local.json (any OpenAI-compatible gateway); ahub local cannot run");
|
|
770
|
+
row(!!omni.apiKey(), "omniroute key", omni.apiKey() ? "present" : "missing: set OMNIROUTE_API_KEY or omniroute.api_key_file in .agenthub/config.local.json");
|
|
764
771
|
const sy = spawnSync(process.env.AGENTHUB_SWITCHYARD_BIN ?? "switchyard-server", ["--version"], { encoding: "utf8" });
|
|
765
772
|
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)");
|
|
766
|
-
const mlxConfig =
|
|
773
|
+
const mlxConfig = config.mlx;
|
|
767
774
|
const mlx = await inspectMlx({ ...mlxConfig, runtimeDir: mlxConfig.runtimeDir ? resolve(cwd, mlxConfig.runtimeDir) : undefined, modelPath: mlxConfig.modelPath ? resolve(cwd, mlxConfig.modelPath) : undefined });
|
|
768
775
|
row(mlx.state === "ready" || mlx.state === "stopped", "pi mlx", `${mlx.state}${mlx.model ? ` (${mlx.model})` : ""}${mlx.lastError ? `: ${mlx.lastError}` : ""}`);
|
|
769
776
|
|
package/src/hub/bus.ts
CHANGED
|
@@ -130,9 +130,12 @@ export class Bus {
|
|
|
130
130
|
const last = this.lastDelivery.get(peer.id);
|
|
131
131
|
const answers = opts?.inReplyTo?.id !== undefined && last?.out.some((e) => e.id === opts.inReplyTo!.id);
|
|
132
132
|
const originals = answers ? last!.originals : undefined;
|
|
133
|
+
// `digest` in the audience stands for the condensed delivery even when a steer, not the digest, is the parent.
|
|
134
|
+
const condensed = !!last?.out.some((e) => e.from === DIGEST);
|
|
135
|
+
const audienceOriginals = originals ?? (opts?.to?.includes(DIGEST) && condensed ? last?.originals : undefined);
|
|
133
136
|
this.publish(newEnvelope(peer.id, body, {
|
|
134
137
|
...opts,
|
|
135
|
-
...(opts?.to?.length ? { to: resolveTo(opts.to,
|
|
138
|
+
...(opts?.to?.length ? { to: resolveTo(opts.to, audienceOriginals) } : {}),
|
|
136
139
|
priority: opts?.priority ?? capPriority(peer, priority, opts?.inReplyTo, originals),
|
|
137
140
|
}));
|
|
138
141
|
};
|
|
@@ -387,6 +390,11 @@ export class Bus {
|
|
|
387
390
|
return () => this.taps.delete(fn);
|
|
388
391
|
}
|
|
389
392
|
|
|
393
|
+
/** Paused by the console or the budget relay, whatever the adapter's own state. */
|
|
394
|
+
isPaused(id: PeerId): boolean {
|
|
395
|
+
return this.paused.has(id);
|
|
396
|
+
}
|
|
397
|
+
|
|
390
398
|
/** The adapter's state, or `paused` while the peer is held by `pause()`. */
|
|
391
399
|
stateOf(id: PeerId): PeerState {
|
|
392
400
|
const state = this.peers.get(id)?.state ?? "offline";
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { childEnv } from "./child-process.ts";
|
|
3
|
+
|
|
4
|
+
export interface CheckResult {
|
|
5
|
+
code: number | null;
|
|
6
|
+
timedOut: boolean;
|
|
7
|
+
/** Killed because the hub stopped: not a verdict on the work. */
|
|
8
|
+
interrupted?: boolean;
|
|
9
|
+
/** The last lines of stdout and stderr together, capped. */
|
|
10
|
+
tail: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const TAIL_LINES = 40;
|
|
14
|
+
const TAIL_CHARS = 4000;
|
|
15
|
+
const KEEP_CHARS = 64 * 1024;
|
|
16
|
+
/** How long output may still arrive after the command exited; a descendant that left the group can hold the pipes. */
|
|
17
|
+
const DRAIN_MS = 500;
|
|
18
|
+
|
|
19
|
+
/** Runs one check command in the project root, in its own process group so a timeout stops what it started. */
|
|
20
|
+
export function runCheck(command: string, cwd: string, timeoutMs: number, running?: Set<() => void>): Promise<CheckResult> {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
let out = "";
|
|
23
|
+
let timedOut = false;
|
|
24
|
+
let interrupted = false;
|
|
25
|
+
let settled = false;
|
|
26
|
+
let exited = false;
|
|
27
|
+
const child = spawn("sh", ["-c", command], { cwd, env: childEnv(process.env), stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
28
|
+
const killGroup = () => {
|
|
29
|
+
try {
|
|
30
|
+
if (child.pid) process.kill(-child.pid, "SIGKILL");
|
|
31
|
+
} catch {
|
|
32
|
+
// already gone
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const stop = () => {
|
|
36
|
+
if (exited) return; // finished before the hub stopped: its own result stands
|
|
37
|
+
interrupted = true;
|
|
38
|
+
killGroup();
|
|
39
|
+
};
|
|
40
|
+
running?.add(stop);
|
|
41
|
+
const keep = (chunk: string) => {
|
|
42
|
+
out += chunk;
|
|
43
|
+
if (out.length > KEEP_CHARS) out = out.slice(-KEEP_CHARS);
|
|
44
|
+
};
|
|
45
|
+
for (const s of [child.stdout, child.stderr]) {
|
|
46
|
+
s?.setEncoding("utf8"); // decodes across chunk edges, so Korean output is not cut into U+FFFD
|
|
47
|
+
s?.on("data", keep);
|
|
48
|
+
}
|
|
49
|
+
const timer = setTimeout(() => {
|
|
50
|
+
timedOut = true;
|
|
51
|
+
killGroup();
|
|
52
|
+
}, timeoutMs);
|
|
53
|
+
const done = (code: number | null) => {
|
|
54
|
+
if (settled) return;
|
|
55
|
+
settled = true;
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
running?.delete(stop);
|
|
58
|
+
child.stdout?.destroy();
|
|
59
|
+
child.stderr?.destroy();
|
|
60
|
+
const tail = out.replace(/\s+$/, "").split("\n").slice(-TAIL_LINES).join("\n").slice(-TAIL_CHARS);
|
|
61
|
+
resolve({ code: timedOut || interrupted ? null : code, timedOut, ...(interrupted ? { interrupted } : {}), tail });
|
|
62
|
+
};
|
|
63
|
+
child.on("error", (e) => {
|
|
64
|
+
out += `\n${e.message}`;
|
|
65
|
+
done(null);
|
|
66
|
+
});
|
|
67
|
+
child.on("exit", (code) => {
|
|
68
|
+
exited = true;
|
|
69
|
+
clearTimeout(timer); // it finished in time; only its output may still be on the way
|
|
70
|
+
killGroup(); // a background job it left behind (sh has no job control, so it is still in the group)
|
|
71
|
+
setTimeout(() => done(code), DRAIN_MS);
|
|
72
|
+
});
|
|
73
|
+
child.on("close", (code) => done(code));
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { childEnv } from "./child-process.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Fields of the project config that choose what the hub runs, which files it sends as credentials, where task text
|
|
8
|
+
* goes, or how far the local worker's sandbox reaches (issue #17). Only a file git confirms nobody committed may set
|
|
9
|
+
* them; a cloned repository would otherwise choose them for the owner's machine.
|
|
10
|
+
*/
|
|
11
|
+
export const MACHINE_LOCAL = [
|
|
12
|
+
"kimi_cmd",
|
|
13
|
+
"codex_bin",
|
|
14
|
+
"pi.cmd",
|
|
15
|
+
"checks",
|
|
16
|
+
"mlx.bin",
|
|
17
|
+
"mlx.runtimeDir",
|
|
18
|
+
"mlx.modelPath", // the legacy runtime runs code a model directory names (mlx-lm's model_file)
|
|
19
|
+
"omniroute.urls",
|
|
20
|
+
"omniroute.access_hosts",
|
|
21
|
+
"omniroute.api_key_file",
|
|
22
|
+
"omniroute.cf_client_id_file",
|
|
23
|
+
"omniroute.cf_client_secret_file",
|
|
24
|
+
"memory.worker_url",
|
|
25
|
+
"local.read_allow",
|
|
26
|
+
"local.bash_network",
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Why `.agenthub/<name>` must not set machine-local fields, or undefined when git confirms nobody committed it.
|
|
31
|
+
* Unknown is not untracked: no repository, no git, or a git error all refuse. The directory itself counts too (a
|
|
32
|
+
* committed symlink or submodule at `.agenthub`). A tracked file is matched by identity, not by name: macOS opens
|
|
33
|
+
* spellings git keeps apart (letter case, and folds such as "ſ" for "s").
|
|
34
|
+
*/
|
|
35
|
+
export function configRefusal(cwd: string, name: string): string | undefined {
|
|
36
|
+
const r = spawnSync("git", ["-C", cwd, "ls-files", "-s", "-z", "--", ":(icase).agenthub"], { encoding: "utf8", env: childEnv(process.env) });
|
|
37
|
+
if (r.status !== 0) return `git could not confirm that .agenthub/${name} is untracked`;
|
|
38
|
+
let opened: { dev: number; ino: number } | undefined;
|
|
39
|
+
try {
|
|
40
|
+
opened = statSync(join(cwd, ".agenthub", name));
|
|
41
|
+
} catch {
|
|
42
|
+
// not there: only the directory entry can still matter
|
|
43
|
+
}
|
|
44
|
+
const tracked = r.stdout.split("\0").filter(Boolean).some((row) => {
|
|
45
|
+
const path = row.slice(row.indexOf("\t") + 1);
|
|
46
|
+
if (path.toLowerCase() === ".agenthub") return true;
|
|
47
|
+
try {
|
|
48
|
+
const s = statSync(join(cwd, path));
|
|
49
|
+
return !!opened && s.dev === opened.dev && s.ino === opened.ino;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
return tracked ? `.agenthub/${name} is committed to git` : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const valueAt = (o: unknown, path: string): unknown => path.split(".").reduce<unknown>((v, k) => (v && typeof v === "object" ? (v as Record<string, unknown>)[k] : undefined), o);
|
|
58
|
+
/** An empty value chooses nothing (the template writes `""` for the key files), so it is never reported. */
|
|
59
|
+
const chooses = (v: unknown) => v !== undefined && v !== null && v !== "" && !(Array.isArray(v) && v.length === 0);
|
|
60
|
+
|
|
61
|
+
const drop = (file: Record<string, unknown>, path: string) => {
|
|
62
|
+
const [head, key] = path.split(".") as [string, string?];
|
|
63
|
+
const block = file[head];
|
|
64
|
+
if (!key) delete file[head];
|
|
65
|
+
else if (block && typeof block === "object") delete (block as Record<string, unknown>)[key];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Drops the machine-local fields `file` sets to anything but the default unless git vouches for it, and says which
|
|
70
|
+
* went and why. An empty value always goes, trusted or not: it means the default, never "here" (an empty runtimeDir
|
|
71
|
+
* once resolved to the project root). A committed copy of the template sets them to defaults and asks git nothing.
|
|
72
|
+
*/
|
|
73
|
+
export function stripUntrusted(file: Record<string, unknown>, defaults: object, cwd: string, name: string): string | undefined {
|
|
74
|
+
for (const p of MACHINE_LOCAL) if (valueAt(file, p) !== undefined && !chooses(valueAt(file, p))) drop(file, p);
|
|
75
|
+
const set = MACHINE_LOCAL.filter((p) => {
|
|
76
|
+
const v = valueAt(file, p);
|
|
77
|
+
return v !== undefined && JSON.stringify(v) !== JSON.stringify(valueAt(defaults, p));
|
|
78
|
+
});
|
|
79
|
+
if (!set.length) return undefined;
|
|
80
|
+
const why = configRefusal(cwd, name);
|
|
81
|
+
if (!why) return undefined;
|
|
82
|
+
for (const p of set) drop(file, p);
|
|
83
|
+
return `${set.join(", ")} in .agenthub/${name} ignored: ${why}`;
|
|
84
|
+
}
|
package/src/hub/daemon.ts
CHANGED
|
@@ -2,6 +2,10 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { childEnv } from "./child-process.ts";
|
|
7
|
+
import { runCheck } from "./checks.ts";
|
|
8
|
+
import { stripUntrusted } from "./config-trust.ts";
|
|
5
9
|
import type { ServerWebSocket } from "bun";
|
|
6
10
|
import { AcpPeer, type PermissionRequest } from "../adapters/acp.ts";
|
|
7
11
|
import { CodexPeer } from "../adapters/codex-appserver.ts";
|
|
@@ -16,7 +20,7 @@ import { Capture, skipTools } from "../memory/capture.ts";
|
|
|
16
20
|
import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
|
|
17
21
|
import { Sidecar } from "../switchyard/sidecar.ts";
|
|
18
22
|
import { Briefs } from "../memory/brief.ts";
|
|
19
|
-
import { Board, type TaskClass } from "./board.ts";
|
|
23
|
+
import { Board, CLASSES, type TaskClass } from "./board.ts";
|
|
20
24
|
import { Budget, claudeWindows, codexWindows, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
|
|
21
25
|
import { HUB } from "./envelope.ts";
|
|
22
26
|
import { trimToTokens } from "../memory/recall.ts";
|
|
@@ -53,6 +57,14 @@ export interface HubConfig {
|
|
|
53
57
|
pi: { enabled: boolean; auto_start: boolean; cmd: string[]; backend: "auto" | "dgx" | "mlx"; dgx_coding: string; dgx_fast: string; max_steps: number };
|
|
54
58
|
mlx: Pick<MlxOptions, "provider" | "host" | "runtimeDir" | "modelPath" | "port" | "model" | "sourceModel" | "contextWindow" | "maxInputTokens" | "maxTokens" | "maxConcurrency">;
|
|
55
59
|
local: { deny: string[]; bash_network: boolean; max_steps: number; read_allow: string[] };
|
|
60
|
+
/** Pending permission requests: how long they wait, and whether the desktop is told (issue #5). */
|
|
61
|
+
approvals: { timeout_s: number; notify: boolean };
|
|
62
|
+
/** An owner offline this long loses its open tasks back to routing; 0 turns it off (issue #6). */
|
|
63
|
+
tasks: { release_after_min: number };
|
|
64
|
+
/** A command per task class run when the owner marks the task done, and its timeout (issue #7). */
|
|
65
|
+
checks: { timeout_s: number; [cls: string]: string | number };
|
|
66
|
+
/** Machine-local fields a config file set but git could not vouch for, and why (issue #17). */
|
|
67
|
+
ignored?: string[];
|
|
56
68
|
}
|
|
57
69
|
export const DEFAULT_CONFIG: HubConfig = {
|
|
58
70
|
watchdog_ms: DEFAULT_WATCHDOG_MS,
|
|
@@ -69,6 +81,10 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
69
81
|
pi: { enabled: false, auto_start: false, cmd: ["pi"], backend: "auto", dgx_coding: "coding", dgx_fast: "fast", max_steps: 30 },
|
|
70
82
|
mlx: { provider: "ollama", model: "agenthub-fast-mlx:4b-8k", sourceModel: "qwen3.5:4b-mlx", contextWindow: 8192, maxInputTokens: 6000, maxTokens: 2048, maxConcurrency: 1 },
|
|
71
83
|
local: { deny: [], bash_network: false, max_steps: 30, read_allow: [] },
|
|
84
|
+
// Off here, so tests and a hub without a config file stay silent; a project's config defaults it on for macOS.
|
|
85
|
+
approvals: { timeout_s: 120, notify: false },
|
|
86
|
+
tasks: { release_after_min: 30 },
|
|
87
|
+
checks: { timeout_s: 600 },
|
|
72
88
|
};
|
|
73
89
|
|
|
74
90
|
export { stateDirFor };
|
|
@@ -77,33 +93,54 @@ export { stateDirFor };
|
|
|
77
93
|
const RESERVED_IDS = new Set([USER, "codex", "kimi", "local", "pi", "hub", DIGEST]);
|
|
78
94
|
const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
79
95
|
|
|
96
|
+
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
97
|
+
const CONFIG_FILES = ["config.json", "config.local.json"] as const;
|
|
98
|
+
const CONFIG_BLOCKS = ["memory", "roles", "budget", "inference", "omniroute", "local", "pi", "approvals", "tasks", "checks", "mlx"];
|
|
99
|
+
|
|
80
100
|
export function loadConfig(cwd: string): HubConfig {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
101
|
+
const ignored: string[] = [];
|
|
102
|
+
const files = CONFIG_FILES.flatMap((name) => {
|
|
103
|
+
let file: Record<string, any>; // parsed JSON, checked field by field below
|
|
104
|
+
try {
|
|
105
|
+
file = JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8"));
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
108
|
+
throw error;
|
|
87
109
|
}
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
local: { ...DEFAULT_CONFIG.local, ...file.local },
|
|
100
|
-
pi: { ...DEFAULT_CONFIG.pi, ...file.pi },
|
|
101
|
-
mlx,
|
|
102
|
-
};
|
|
103
|
-
} catch (error) {
|
|
104
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return DEFAULT_CONFIG;
|
|
105
|
-
throw error;
|
|
110
|
+
const why = stripUntrusted(file, DEFAULT_CONFIG, cwd, name);
|
|
111
|
+
if (why) ignored.push(why);
|
|
112
|
+
return [file];
|
|
113
|
+
});
|
|
114
|
+
if (!files.length) return DEFAULT_CONFIG;
|
|
115
|
+
const file = files.reduce((a, b) => ({ ...a, ...b, ...Object.fromEntries(CONFIG_BLOCKS.filter((k) => a[k] !== undefined && b[k] !== undefined).map((k) => [k, { ...a[k], ...b[k] }])) }));
|
|
116
|
+
delete file.ignored; // the hub's own record, never a file's
|
|
117
|
+
if (file.mlx != null && (typeof file.mlx !== "object" || Array.isArray(file.mlx))) throw new Error("mlx configuration must be an object");
|
|
118
|
+
if (file.mlx?.provider !== undefined && !["ollama", "legacy"].includes(file.mlx.provider)) throw new Error("mlx.provider must be ollama or legacy");
|
|
119
|
+
if (file.mlx?.provider === undefined && (file.mlx?.modelPath || file.mlx?.runtimeDir || file.mlx?.bin || file.mlx?.port)) {
|
|
120
|
+
throw new Error("legacy MLX configuration requires explicit mlx.provider=legacy; migrate to provider=ollama to avoid Python serving");
|
|
106
121
|
}
|
|
122
|
+
const mlx = { ...(file.mlx?.provider === "legacy" ? { provider: "legacy" as const, maxInputTokens: 16_000, maxTokens: 2048 } : DEFAULT_CONFIG.mlx), ...file.mlx };
|
|
123
|
+
if (typeof mlx.runtimeDir === "string" && mlx.runtimeDir) mlx.runtimeDir = resolve(cwd, mlx.runtimeDir);
|
|
124
|
+
if (typeof mlx.modelPath === "string" && mlx.modelPath) mlx.modelPath = resolve(cwd, mlx.modelPath);
|
|
125
|
+
return {
|
|
126
|
+
...DEFAULT_CONFIG,
|
|
127
|
+
...file,
|
|
128
|
+
memory: { ...DEFAULT_CONFIG.memory, ...file.memory },
|
|
129
|
+
roles: { ...DEFAULT_CONFIG.roles, ...file.roles },
|
|
130
|
+
budget: { ...DEFAULT_CONFIG.budget, ...file.budget },
|
|
131
|
+
inference: { ...DEFAULT_CONFIG.inference, ...file.inference },
|
|
132
|
+
omniroute: { ...DEFAULT_CONFIG.omniroute, ...file.omniroute },
|
|
133
|
+
local: { ...DEFAULT_CONFIG.local, ...file.local },
|
|
134
|
+
pi: { ...DEFAULT_CONFIG.pi, ...file.pi },
|
|
135
|
+
approvals: {
|
|
136
|
+
timeout_s: file.approvals?.timeout_s ?? DEFAULT_CONFIG.approvals.timeout_s,
|
|
137
|
+
notify: typeof file.approvals?.notify === "boolean" ? file.approvals.notify : process.platform === "darwin",
|
|
138
|
+
},
|
|
139
|
+
tasks: { ...DEFAULT_CONFIG.tasks, ...file.tasks },
|
|
140
|
+
checks: { ...DEFAULT_CONFIG.checks, ...file.checks },
|
|
141
|
+
mlx,
|
|
142
|
+
...(ignored.length ? { ignored } : {}),
|
|
143
|
+
};
|
|
107
144
|
}
|
|
108
145
|
|
|
109
146
|
export interface DaemonOptions {
|
|
@@ -121,6 +158,8 @@ export interface DaemonOptions {
|
|
|
121
158
|
/** Auto-approve ACP permission requests with the agent's allow_once option. */
|
|
122
159
|
unattended?: boolean;
|
|
123
160
|
permissionTimeoutMs?: number;
|
|
161
|
+
/** Tests replace the desktop notifier; the default posts a macOS notification when approvals.notify is on. */
|
|
162
|
+
notifier?: (title: string, body: string) => void;
|
|
124
163
|
/** Called once when shutdown begins, however it was triggered; the lifecycle uses it to arm a hard deadline. */
|
|
125
164
|
onShutdownStart?: () => void;
|
|
126
165
|
/** How often the daemon checks that its project root and state dir still exist. Tests shrink this. */
|
|
@@ -301,6 +340,17 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
301
340
|
};
|
|
302
341
|
const board = new Board(join(opts.stateDir, "hub.db"));
|
|
303
342
|
startupCleanup.push(() => board.close());
|
|
343
|
+
// Completion checks (issue #7): only from a config file git does not track, one at a time, killed on stop.
|
|
344
|
+
const checkCommands = Object.fromEntries(Object.entries(config.checks).filter(([k, v]) => (CLASSES as readonly string[]).includes(k) && typeof v === "string" && v.trim())) as Record<string, string>;
|
|
345
|
+
const strayChecks = Object.keys(config.checks).filter((k) => k !== "timeout_s" && !(k in checkCommands));
|
|
346
|
+
if (strayChecks.length) log(`checks ignored for ${strayChecks.join(", ")}: not a task class with a command (classes: ${CLASSES.join(", ")})`);
|
|
347
|
+
// Commands and the other machine-local fields came only from a file git vouched for (loadConfig, issue #17).
|
|
348
|
+
for (const line of config.ignored ?? []) log(`${line}; only a config file nobody committed may set it`);
|
|
349
|
+
const checksAllowed = Object.keys(checkCommands).length > 0;
|
|
350
|
+
const checkTimeoutS = typeof config.checks.timeout_s === "number" && config.checks.timeout_s >= 1 ? Math.min(config.checks.timeout_s, 3600) : 600;
|
|
351
|
+
const runningChecks = new Set<() => void>();
|
|
352
|
+
let checksClosed = false; // set on stop: a check still queued then must not start and outlive the hub
|
|
353
|
+
const interrupted = { code: null, timedOut: false, interrupted: true, tail: "" };
|
|
304
354
|
const tasks = new Tasks({
|
|
305
355
|
board,
|
|
306
356
|
bus,
|
|
@@ -309,6 +359,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
309
359
|
project: chain.at(-1)!,
|
|
310
360
|
...(config.memory.enabled ? { memory, briefs: new Briefs(memory, chain.at(-1)!, config.memory.brief_items) } : {}),
|
|
311
361
|
notify,
|
|
362
|
+
...(checksAllowed ? { check: (cls: string) => checkCommands[cls], runCheck: async (command: string) => (checksClosed ? interrupted : runCheck(command, opts.cwd, checkTimeoutS * 1000, runningChecks)) } : {}),
|
|
312
363
|
// What a peer learned reaches the peers working now, on their next delivery, instead of at their next session (#68).
|
|
313
364
|
// Fail-open: the note is already saved, so a bus that cannot persist costs the sharing, not the tool call.
|
|
314
365
|
share: (by, line) => {
|
|
@@ -318,6 +369,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
318
369
|
log(`note from ${by} not shared: ${(e as Error).message}`);
|
|
319
370
|
}
|
|
320
371
|
},
|
|
372
|
+
tell: (peer, line) => {
|
|
373
|
+
try {
|
|
374
|
+
bus.note(peer, line);
|
|
375
|
+
} catch (e) {
|
|
376
|
+
log(`note for ${peer} not kept: ${(e as Error).message}`);
|
|
377
|
+
}
|
|
378
|
+
},
|
|
321
379
|
triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
|
|
322
380
|
});
|
|
323
381
|
// ---- budget relay -------------------------------------------------------------------------------------------
|
|
@@ -435,8 +493,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
435
493
|
return line(tasks.accept(by, a.id));
|
|
436
494
|
case "hub_task_decline":
|
|
437
495
|
return line(await tasks.decline(by, a.id, a.reason));
|
|
438
|
-
case "hub_task_done":
|
|
439
|
-
|
|
496
|
+
case "hub_task_done": {
|
|
497
|
+
const t = await tasks.done(by, a.id, a.summary, a.refs);
|
|
498
|
+
return tasks.isChecking(t.id) ? `${line(t)}; its check is queued or running, and the result comes as a task message` : line(t);
|
|
499
|
+
}
|
|
440
500
|
case "hub_review":
|
|
441
501
|
return line(await tasks.review(by, a.id, a.verdict, a.note));
|
|
442
502
|
case "hub_remember":
|
|
@@ -481,8 +541,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
481
541
|
const r = budget.record(id); // one read per peer: status.json is rewritten on every bus event
|
|
482
542
|
return r ? { paused: `budget: ${r.reason}, resets ${new Date(r.resetsAt).toLocaleTimeString()}` } : manualPaused.has(id) && bus.stateOf(id) === "offline" ? { paused: "manual" } : {};
|
|
483
543
|
};
|
|
544
|
+
let releasing = false; // gone-owner release (#6): one run at a time, and a recovery commit waits for it
|
|
484
545
|
const recoveryReady = () => {
|
|
485
|
-
if (!recoveryActive() || (piReceipts?.inFlight ?? 0) !== 0 || permissions.size !== 0 || starting.size !== 0 || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (peer instanceof PiPeer && !peer.recoveryReady))) return false;
|
|
546
|
+
if (!recoveryActive() || releasing || (piReceipts?.inFlight ?? 0) !== 0 || permissions.size !== 0 || starting.size !== 0 || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (peer instanceof PiPeer && !peer.recoveryReady))) return false;
|
|
486
547
|
if (!recoveryPeerSnapshot) return true;
|
|
487
548
|
const current = recoveryPeers();
|
|
488
549
|
return recoveryPeerSnapshot.every((saved) => {
|
|
@@ -577,17 +638,70 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
577
638
|
// message and keeps a phantom `queued N` after the queue drains (measured on a live 0.6.3 hub).
|
|
578
639
|
bus.onQueues = () => { if (!stopping) writeStatus(); };
|
|
579
640
|
|
|
641
|
+
// When each peer went offline; a peer never seen attached counts from hub start (issue #6).
|
|
642
|
+
const offlineSince = new Map<PeerId, number>();
|
|
643
|
+
const hubStartedAt = Date.now();
|
|
644
|
+
const releaseGoneOwners = async () => {
|
|
645
|
+
const limit = config.tasks.release_after_min;
|
|
646
|
+
if (!(limit > 0) || releasing || stopping || recoveryActive()) return;
|
|
647
|
+
releasing = true;
|
|
648
|
+
try {
|
|
649
|
+
await releaseOwners(limit);
|
|
650
|
+
} finally {
|
|
651
|
+
releasing = false;
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
const releaseOwners = async (limit: number) => {
|
|
655
|
+
const owners = new Set(board.list().filter((t) => t.owner && ["proposed", "in_progress", "changes_requested"].includes(t.state)).map((t) => t.owner!));
|
|
656
|
+
for (const owner of owners) {
|
|
657
|
+
if (stopping || recoveryActive()) return;
|
|
658
|
+
if (owner === USER || owner === "hub" || bus.isPaused(owner) || bus.stateOf(owner) !== "offline") continue;
|
|
659
|
+
const since = offlineSince.get(owner) ?? hubStartedAt;
|
|
660
|
+
if (Date.now() - since < limit * 60_000) continue;
|
|
661
|
+
const moved = await tasks.releaseFromGone(owner, Math.round((Date.now() - since) / 60_000)).catch((e) => (log(`releasing tasks of ${owner} failed: ${(e as Error).message}`), []));
|
|
662
|
+
for (const m of moved) notify(`task ${m.title} moved from ${owner} (offline) to ${m.to ?? "nobody"}`);
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
const releaseTimer = setInterval(() => void releaseGoneOwners(), 60_000);
|
|
666
|
+
releaseTimer.unref?.();
|
|
667
|
+
intervals.push(releaseTimer);
|
|
668
|
+
|
|
580
669
|
bus.tap((e) => {
|
|
581
670
|
e = redact(e);
|
|
582
671
|
uiEvents.push({ seq: ++uiSequence, event: e });
|
|
583
672
|
if (uiEvents.length > 200) uiEvents.shift();
|
|
584
|
-
if (e.t === "state")
|
|
673
|
+
if (e.t === "state") {
|
|
674
|
+
log(`state ${e.peer} -> ${e.state}`);
|
|
675
|
+
if (e.state === "offline") offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now());
|
|
676
|
+
else offlineSince.delete(e.peer);
|
|
677
|
+
}
|
|
585
678
|
else if (e.t === "undeliverable") log(`UNDELIVERABLE to ${e.peer} after retries: ${e.env.id} from ${e.env.from}`);
|
|
586
679
|
else if (e.t === "overflow") log(`OVERFLOW ${e.peer}: dropped ${e.env.id} from ${e.env.from}`);
|
|
587
680
|
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)}`);
|
|
588
681
|
if (!stopping) writeStatus();
|
|
589
682
|
});
|
|
590
683
|
|
|
684
|
+
const approvalTimeoutS = (() => {
|
|
685
|
+
const s = config.approvals.timeout_s;
|
|
686
|
+
if (typeof s === "number" && s >= 30 && s <= 3600) return s;
|
|
687
|
+
log(`approvals.timeout_s ${JSON.stringify(s)} is outside 30-3600 seconds; using 120`);
|
|
688
|
+
return 120;
|
|
689
|
+
})();
|
|
690
|
+
let notifierBroken = false;
|
|
691
|
+
// Peer and tool name only: a title can quote what a PII turn is about to write (issue #5).
|
|
692
|
+
const desktop = opts.notifier ?? ((title: string, body: string) => {
|
|
693
|
+
const quote = (t: string) => `"${t.replace(/[\\"]/g, "\\$&")}"`;
|
|
694
|
+
const child = spawn("osascript", ["-e", `display notification ${quote(body)} with title ${quote(title)}`], { stdio: "ignore", env: childEnv(process.env) });
|
|
695
|
+
const kill = setTimeout(() => child.kill(), 5000);
|
|
696
|
+
kill.unref?.();
|
|
697
|
+
const failed = (why: string) => {
|
|
698
|
+
if (!notifierBroken) log(`desktop notification failed (${why}); later ones are not reported`);
|
|
699
|
+
notifierBroken = true;
|
|
700
|
+
};
|
|
701
|
+
child.on("error", (e: Error) => (clearTimeout(kill), failed(e.message)));
|
|
702
|
+
child.on("exit", (code: number | null, signal: string | null) => (clearTimeout(kill), code || signal ? failed(signal ? `osascript stopped by ${signal}` : `osascript exit ${code}`) : undefined));
|
|
703
|
+
});
|
|
704
|
+
|
|
591
705
|
async function onPermission(req: PermissionRequest): Promise<string | undefined> {
|
|
592
706
|
if (stopping) return undefined;
|
|
593
707
|
if (opts.unattended) return req.options.find((o) => o.kind === "allow_once")?.optionId;
|
|
@@ -597,11 +711,24 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
597
711
|
const title = req.title.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
|
|
598
712
|
// The title can quote what a PII turn is about to write. It is for the person approving, on the console; the log
|
|
599
713
|
// (which `ahub ask` reads as evidence) only records that a request was made.
|
|
600
|
-
|
|
601
|
-
|
|
714
|
+
const timeoutMs = opts.permissionTimeoutMs ?? approvalTimeoutS * 1000;
|
|
715
|
+
log(`permission ${id} requested by ${req.peer} (${title.length} chars, shown on the console; cancelled after ${timeoutMs / 1000}s)`);
|
|
716
|
+
// The tool name is for the desktop notice only: the console push keeps its shape.
|
|
717
|
+
const { tool: _tool, ...shown } = req;
|
|
718
|
+
const push = JSON.stringify({ t: "permission", id, ...shown, title });
|
|
602
719
|
for (const c of consoles) if (c.data.tail) c.send(push);
|
|
720
|
+
if (config.approvals.notify) {
|
|
721
|
+
try {
|
|
722
|
+
desktop("agent-hub", `${req.peer} asks for approval${req.tool ? `: ${req.tool}` : ""} (ahub tail)`);
|
|
723
|
+
} catch {
|
|
724
|
+
// never let a notifier break the request itself
|
|
725
|
+
}
|
|
726
|
+
}
|
|
603
727
|
return new Promise((resolve) => {
|
|
604
|
-
const timer = setTimeout(() =>
|
|
728
|
+
const timer = setTimeout(() => {
|
|
729
|
+
notify(`permission ${id} from ${req.peer} was not answered within ${Math.round(timeoutMs / 1000)}s and was cancelled`);
|
|
730
|
+
done(undefined);
|
|
731
|
+
}, timeoutMs);
|
|
605
732
|
const done = (optionId: string | undefined) => {
|
|
606
733
|
clearTimeout(timer);
|
|
607
734
|
permissions.delete(id);
|
|
@@ -1278,6 +1405,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1278
1405
|
}
|
|
1279
1406
|
async function stopOnce(): Promise<void> {
|
|
1280
1407
|
stopping = true;
|
|
1408
|
+
checksClosed = true;
|
|
1409
|
+
for (const kill of runningChecks) kill();
|
|
1281
1410
|
log("hub stopping");
|
|
1282
1411
|
writeStatus();
|
|
1283
1412
|
dashboard?.stop();
|