@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.
@@ -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
- // ponytail: the reply is addressed to the highest-hop sender only (newEnvelope's default). A digest that
391
- // mixed senders answers the last of them; track the audience alongside `injected` if that starts to matter.
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
- const lines = existsSync(ignore) ? readFileSync(ignore, "utf8") : "";
63
- if (!lines.split("\n").includes(".agenthub/state/")) write(ignore, `${lines}${lines && !lines.endsWith("\n") ? "\n" : ""}.agenthub/state/\n`);
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: loadConfig(cwd).codex_bin });
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 = loadConfig(cwd).mlx;
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 = loadConfig(cwd).mlx;
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, originals) } : {}),
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
- try {
82
- const file = JSON.parse(readFileSync(join(cwd, ".agenthub", "config.json"), "utf8"));
83
- if (file.mlx != null && (typeof file.mlx !== "object" || Array.isArray(file.mlx))) throw new Error("mlx configuration must be an object");
84
- if (file.mlx?.provider !== undefined && !["ollama", "legacy"].includes(file.mlx.provider)) throw new Error("mlx.provider must be ollama or legacy");
85
- if (file.mlx?.provider === undefined && (file.mlx?.modelPath || file.mlx?.runtimeDir || file.mlx?.bin || file.mlx?.port)) {
86
- throw new Error("legacy MLX configuration requires explicit mlx.provider=legacy; migrate to provider=ollama to avoid Python serving");
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 mlx = { ...(file.mlx?.provider === "legacy" ? { provider: "legacy" as const, maxInputTokens: 16_000, maxTokens: 2048 } : DEFAULT_CONFIG.mlx), ...file.mlx };
89
- if (typeof mlx.runtimeDir === "string") mlx.runtimeDir = resolve(cwd, mlx.runtimeDir);
90
- if (typeof mlx.modelPath === "string") mlx.modelPath = resolve(cwd, mlx.modelPath);
91
- return {
92
- ...DEFAULT_CONFIG,
93
- ...file,
94
- memory: { ...DEFAULT_CONFIG.memory, ...file.memory },
95
- roles: { ...DEFAULT_CONFIG.roles, ...file.roles },
96
- budget: { ...DEFAULT_CONFIG.budget, ...file.budget },
97
- inference: { ...DEFAULT_CONFIG.inference, ...file.inference },
98
- omniroute: { ...DEFAULT_CONFIG.omniroute, ...file.omniroute },
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
- return line(await tasks.done(by, a.id, a.summary, a.refs));
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") log(`state ${e.peer} -> ${e.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
- log(`permission ${id} requested by ${req.peer} (${title.length} chars, shown on the console)`);
601
- const push = JSON.stringify({ t: "permission", id, ...req, title });
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(() => done(undefined), opts.permissionTimeoutMs ?? 120_000);
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();