@staix/agent-hub 0.12.22 → 0.12.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +16 -8
  3. package/docs/agent-notes/adapters.md +10 -5
  4. package/docs/agent-notes/benchmarks.md +3 -0
  5. package/docs/agent-notes/budget.md +4 -0
  6. package/docs/agent-notes/daemon.md +8 -1
  7. package/docs/agent-notes/local-worker.md +3 -2
  8. package/docs/agent-notes/tasks.md +2 -1
  9. package/docs/agent-notes/tests.md +2 -0
  10. package/docs/events.md +7 -3
  11. package/docs/operations.md +232 -26
  12. package/docs/quickstart.md +9 -4
  13. package/docs/security.md +15 -6
  14. package/docs/smoke.md +72 -5
  15. package/docs/specs/2026-09-19-agent-hub-design.md +77 -10
  16. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  17. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  18. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  19. package/docs/verified.json +25 -16
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +14 -3
  23. package/src/adapters/acp-quota.ts +13 -0
  24. package/src/adapters/acp.ts +95 -13
  25. package/src/adapters/codex-appserver.ts +43 -13
  26. package/src/cli/bench.ts +2 -1
  27. package/src/cli/console-state.ts +28 -22
  28. package/src/cli/console.ts +20 -10
  29. package/src/cli/help.ts +19 -14
  30. package/src/cli/identity.ts +2 -2
  31. package/src/cli/init.ts +1 -1
  32. package/src/cli/main.ts +272 -159
  33. package/src/cli/output.ts +363 -0
  34. package/src/cli/preview.ts +2 -1
  35. package/src/cli/reset.ts +3 -0
  36. package/src/cli/status-lines.ts +0 -28
  37. package/src/cli/terminal-recovery.ts +27 -1
  38. package/src/cli/upgrade-runtime.ts +24 -7
  39. package/src/cli/upgrade.ts +23 -5
  40. package/src/hub/board.ts +2 -0
  41. package/src/hub/budget.ts +39 -7
  42. package/src/hub/bus.ts +10 -11
  43. package/src/hub/child-process.ts +11 -0
  44. package/src/hub/conductor.ts +23 -11
  45. package/src/hub/config-trust.ts +16 -5
  46. package/src/hub/conflicts.ts +2 -0
  47. package/src/hub/context-window.ts +8 -1
  48. package/src/hub/crash.ts +2 -1
  49. package/src/hub/daemon.ts +338 -23
  50. package/src/hub/db-timeout.ts +3 -0
  51. package/src/hub/delivery-journal.ts +2 -1
  52. package/src/hub/envelope.ts +1 -1
  53. package/src/hub/events.ts +2 -0
  54. package/src/hub/execution-budget.ts +2 -0
  55. package/src/hub/facts.ts +5 -4
  56. package/src/hub/git.ts +32 -0
  57. package/src/hub/hub-tools.ts +1 -1
  58. package/src/hub/lifecycle.ts +10 -4
  59. package/src/hub/manager.ts +18 -5
  60. package/src/hub/peers.ts +5 -3
  61. package/src/hub/permission-mode.ts +14 -0
  62. package/src/hub/project.ts +2 -1
  63. package/src/hub/recovery-error.ts +31 -0
  64. package/src/hub/recovery-store.ts +6 -5
  65. package/src/hub/registry.ts +2 -1
  66. package/src/hub/routing.ts +114 -13
  67. package/src/hub/settings.ts +392 -0
  68. package/src/hub/snapshots.ts +4 -2
  69. package/src/hub/start-mode.ts +48 -0
  70. package/src/hub/tasks.ts +129 -31
  71. package/src/hub/terminal-open.ts +64 -0
  72. package/src/hub/ui.ts +25 -13
  73. package/src/local/deny.ts +29 -7
  74. package/src/local/sandbox.ts +44 -6
  75. package/src/memory/recall.ts +2 -2
  76. package/src/models/mlx.ts +2 -1
  77. package/src/pi/tool-receipts.ts +2 -1
  78. package/src/ui/index.html +157 -10
@@ -1,4 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
2
3
 
3
4
  export type ExecutionUnit = "model_calls" | "tool_calls" | "elapsed_ms" | "tokens";
4
5
  export type ExecutionBudgetLimits = Partial<Record<ExecutionUnit, number>>;
@@ -42,6 +43,7 @@ export class ExecutionBudget {
42
43
  private readonly db: Database;
43
44
  constructor(path: string) {
44
45
  this.db = new Database(path, { create: true });
46
+ this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
45
47
  this.db.run("PRAGMA journal_mode = WAL");
46
48
  this.db.run(`CREATE TABLE IF NOT EXISTS execution_budgets (
47
49
  id TEXT PRIMARY KEY, kind TEXT NOT NULL, task_id INTEGER, peers TEXT NOT NULL,
package/src/hub/facts.ts CHANGED
@@ -3,6 +3,7 @@ import { closeSync, constants, fstatSync, mkdirSync, openSync, readSync, rmSync,
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { sanitize, type PeerId } from "./envelope.ts";
5
5
  import { isDenied } from "../local/deny.ts";
6
+ import { hubGitArgv, hubGitEnv } from "./git.ts";
6
7
  import { realPath } from "./project.ts";
7
8
 
8
9
  /**
@@ -275,7 +276,7 @@ export class Facts {
275
276
  // One path per line: a newline in a name would shift the answers, and git drops a final carriage return (another file).
276
277
  files = files.filter((f) => !/[\r\n]/.test(f));
277
278
  if (!files.length) return out;
278
- const r = Bun.spawnSync(["git", "cat-file", "--batch-check=%(objectname) %(objecttype)"], { cwd: this.root, stdin: new TextEncoder().encode(files.map((f) => `HEAD:./${f}`).join("\n") + "\n"), stdout: "pipe", stderr: "pipe", env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } });
279
+ const r = Bun.spawnSync(hubGitArgv(["cat-file", "--batch-check=%(objectname) %(objecttype)"]), { cwd: this.root, stdin: new TextEncoder().encode(files.map((f) => `HEAD:./${f}`).join("\n") + "\n"), stdout: "pipe", stderr: "pipe", env: hubGitEnv({ ...process.env, GIT_OPTIONAL_LOCKS: "0" }) });
279
280
  if (r.exitCode !== 0) return out;
280
281
  r.stdout.toString().split("\n").forEach((line, i) => {
281
282
  const [oid, type] = line.split(" ");
@@ -414,7 +415,7 @@ export class Facts {
414
415
  if (!files) {
415
416
  // Plumbing only, with optional locks off: an agent's own git commands must never meet a lock the hub holds.
416
417
  const git = (...args: string[]) => {
417
- const r = Bun.spawnSync(["git", ...args, "--", p], { cwd: this.root, stdout: "pipe", stderr: "pipe", env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } });
418
+ const r = Bun.spawnSync(hubGitArgv([...args, "--", p]), { cwd: this.root, stdout: "pipe", stderr: "pipe", env: hubGitEnv({ ...process.env, GIT_OPTIONAL_LOCKS: "0" }) });
418
419
  return r.exitCode === 0 ? r.stdout.toString().split("\0").filter(Boolean) : undefined;
419
420
  };
420
421
  // Against HEAD, so a staged change counts (a staged move by both names); a repository without a commit yet has
@@ -693,7 +694,7 @@ export class Facts {
693
694
  tree(paths: string[], windows: { peer: PeerId; since: number; until?: number }[] = []): string {
694
695
  this.boundary();
695
696
  const h = createHash("sha1");
696
- const head = Bun.spawnSync(["git", "rev-parse", "-q", "--verify", "HEAD"], { cwd: this.root, stdout: "pipe", stderr: "pipe" });
697
+ const head = Bun.spawnSync(hubGitArgv(["rev-parse", "-q", "--verify", "HEAD"]), { cwd: this.root, stdout: "pipe", stderr: "pipe", env: hubGitEnv() });
697
698
  h.update(head.exitCode === 0 ? head.stdout.toString().trim() : "no-head").update("\0");
698
699
  // The files the members wrote while at work in the cohort count too: a symbol-only overlap names no path, and a
699
700
  // tool's write outside the named paths still moves the work the integration checked. What they only read never
@@ -780,7 +781,7 @@ export class Facts {
780
781
  try {
781
782
  writeFileSync(a, before, { mode: 0o600 });
782
783
  writeFileSync(b, after, { mode: 0o600 });
783
- const r = Bun.spawnSync(["git", "-c", "core.quotepath=off", "diff", "--no-index", "--no-color", "--no-ext-diff", "--unified=2", "--", a, b], { stdout: "pipe", stderr: "pipe" });
784
+ const r = Bun.spawnSync(hubGitArgv(["-c", "core.quotepath=off", "diff", "--no-index", "--no-color", "--no-ext-diff", "--no-textconv", "--unified=2", "--", a, b]), { stdout: "pipe", stderr: "pipe", env: hubGitEnv() });
784
785
  const lines = r.stdout.toString().split("\n");
785
786
  const start = lines.findIndex((l) => l.startsWith("@@"));
786
787
  if (start === -1) return [];
package/src/hub/git.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { spawnSync, type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns } from "node:child_process";
2
+
3
+ /**
4
+ * The one helper for every git call the hub makes outside the sandbox (#281): the repository's own configuration
5
+ * must not name a program the hub then runs with its own rights (defense in depth behind the seatbelt profile,
6
+ * which refuses a sandboxed command the writes that would plant one). Every call gets:
7
+ * - core.fsmonitor=false: the file-system monitor is an arbitrary command read from the repo config.
8
+ * - core.hooksPath=/dev/null: hooks come from a non-directory, so every hook lookup fails closed.
9
+ * - diff.external= (empty): no external diff driver.
10
+ * - core.pager=cat and --no-pager: no pager, whatever the config says (stdout is a pipe everywhere anyway).
11
+ * - credential.helper= (empty resets the list) and core.askpass= (empty fails closed), with GIT_TERMINAL_PROMPT=0:
12
+ * no hub command authenticates, so any prompt is a bug and must fail instead of running a configured helper.
13
+ * git has no global switch for filter drivers or textconv: both are per-driver config keys. Of the hub's commands
14
+ * only the snapshot's `add`/`restore` would run a filter driver, and only a content diff would run textconv — the
15
+ * hub's one content diff is `diff --no-index`, which DOES read the repository's config (verified: a planted
16
+ * diff.external runs for it), so that call passes `--no-ext-diff` and `--no-textconv` on top of the config here,
17
+ * and the driver command of a filter lives in the `.git/config` the seatbelt profile refuses to a sandboxed
18
+ * command. GIT_CONFIG_NOSYSTEM/global are left alone: those files are the user's own.
19
+ */
20
+ const SAFE_CONFIG = ["core.fsmonitor=false", "core.hooksPath=/dev/null", "diff.external=", "core.pager=cat", "credential.helper=", "core.askpass="];
21
+
22
+ /** `["git", ...safe options, ...args]`: use instead of a literal "git" argv anywhere under src/. */
23
+ export const hubGitArgv = (args: string[]): string[] => ["git", "--no-pager", ...SAFE_CONFIG.flatMap((c) => ["-c", c]), ...args];
24
+
25
+ /** The caller's env plus the no-prompt switch; existing keys (childEnv, GIT_OPTIONAL_LOCKS=0) pass through. */
26
+ export const hubGitEnv = (env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv => ({ ...env, GIT_TERMINAL_PROMPT: "0" });
27
+
28
+ /** spawnSync form of the same call: the env option is merged with hubGitEnv, everything else passes through. */
29
+ export function hubGitSync(args: string[], options: SpawnSyncOptionsWithStringEncoding): SpawnSyncReturns<string> {
30
+ const argv = hubGitArgv(args);
31
+ return spawnSync(argv[0]!, argv.slice(1), { ...options, env: hubGitEnv(options.env ?? process.env) });
32
+ }
@@ -53,7 +53,7 @@ export const CONDUCTOR_TOOLS: HubTool[] = [
53
53
  tool("hub_task_show", "Read a task's public view and history, its done summary and check result included. PII tasks remain stubs. Only the conductor and the task's current owner and reviewer may use this tool.", { id }, ["id"]),
54
54
  tool("hub_task_assign", "Move a task to another peer, as the conductor, or redirect a task you proposed while it is proposed and nobody accepted it. A task that still waits gets the peer as its reserved owner. When you have an explicit capabilities list, the conductor needs assign capability for any assign, the proposer only to hand the task to another peer.", { id, peer: str }, ["id", "peer"]),
55
55
  tool("hub_task_escalate", "Escalate a task through the normal task flow, as the conductor. Requires assign capability when explicitly listed.", { id }, ["id"]),
56
- tool("hub_peer_start", "Start local, kimi or headless pi. Claude, Codex and Pi TUI requests return a command for the person to run, without launching a terminal.", { peer: { type: "string", enum: ["local", "kimi", "pi", "claude", "codex"] }, mode: { type: "string", enum: ["headless", "tui"] } }, ["peer"]),
56
+ tool("hub_peer_start", "Start local, kimi, pi, claude or codex in the start mode a person set for it; you cannot choose the mode. A headless peer starts at once. A TUI peer (claude, and pi and codex by default) opens in a terminal with a fixed command; when the hub cannot open a terminal the call fails with the command for the person to run.", { peer: { type: "string", enum: ["local", "kimi", "pi", "claude", "codex"] } }, ["peer"]),
57
57
  tool("hub_peer_hold", "Hold a peer's deliveries as the conductor. This hold is separate from the person's hold and budget pauses.", { peer: str }, ["peer"]),
58
58
  tool("hub_peer_release", "Release only the conductor hold you placed. Never lifts a person's hold or a budget pause.", { peer: str }, ["peer"]),
59
59
  ];
@@ -1,3 +1,4 @@
1
+ import { isDatabaseBusyReply, RecoveryDatabaseBusy } from "./recovery-error.ts";
1
2
  import { spawn } from "node:child_process";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -5,19 +6,21 @@ import { join } from "node:path";
5
6
  import { createServer, type Server } from "node:net";
6
7
  import { ControlClient, PROTOCOL, readControl } from "./control-client.ts";
7
8
  import { startDaemon } from "./daemon.ts";
9
+ import { terminalOpener } from "./terminal-open.ts";
8
10
  import { Registry, type Project } from "./registry.ts";
9
11
  import { CODEX_APP, CODEX_PROXY, CONTROL, SWITCHYARD } from "./ports.ts";
10
12
  import { assertLifecycleAvailable, recoveryLock } from "./recovery-store.ts";
11
13
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
12
14
 
13
15
  export type ProjectInspection = {
16
+ failure?: { code: "database_busy" };
14
17
  state: "running" | "stopped" | "stopping" | "unavailable" | "incompatible" | "missing" | "starting";
15
18
  status?: any;
16
19
  error?: string;
17
20
  };
18
21
 
19
22
  /** A port or PID alone is never evidence that the selected project's hub is alive. */
20
- export async function inspectProject(project: Project): Promise<ProjectInspection> {
23
+ export async function inspectProject(project: Project, timeoutMs = 3000): Promise<ProjectInspection> {
21
24
  if (!existsSync(project.root)) return { state: "missing", error: "project directory is missing" };
22
25
  const control = readControl(project.stateDir);
23
26
  if (control) {
@@ -32,8 +35,9 @@ export async function inspectProject(project: Project): Promise<ProjectInspectio
32
35
  try {
33
36
  // Older manifests omit identity. Authenticate first, then require the full current contract.
34
37
  client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: project.root,
35
- ...(control.projectId ? { projectId: project.id } : {}), ...(control.instanceId ? { instanceId: control.instanceId } : {}) });
36
- const response = await client.request({ t: "status" }, 3000);
38
+ ...(control.projectId ? { projectId: project.id } : {}), ...(control.instanceId ? { instanceId: control.instanceId } : {}) }, timeoutMs);
39
+ const response = await client.request({ t: "status" }, timeoutMs);
40
+ if (isDatabaseBusyReply(response)) return { state: "unavailable", error: new RecoveryDatabaseBusy().message, failure: { code: "database_busy" } };
37
41
  if (!response.status || response.status.projectId !== project.id || !response.status.instanceId || response.status.cwd !== project.root) {
38
42
  return { state: "unavailable", error: "authenticated hub identity does not match registration" };
39
43
  }
@@ -112,7 +116,9 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
112
116
  try {
113
117
  daemon = await startDaemon({ cwd: project.root, stateDir: project.stateDir, projectId: project.id, instanceId,
114
118
  controlPort: base + CONTROL, codexAppPort: base + CODEX_APP, codexProxyPort: base + CODEX_PROXY,
115
- switchyardPort: base + SWITCHYARD, unattended, onShutdownStart });
119
+ switchyardPort: base + SWITCHYARD, unattended, onShutdownStart,
120
+ // #269: the real terminal providers belong to the running product; a daemon built without them opens nothing.
121
+ terminal: (template) => terminalOpener({ template, cwd: project.root, stateDir: project.stateDir }) });
116
122
  break;
117
123
  } catch (error) {
118
124
  if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error;
@@ -9,15 +9,19 @@ import { hubHome } from "./project.ts";
9
9
  import { startDashboard } from "./ui.ts";
10
10
  import { projectChain } from "../memory/recall.ts";
11
11
  import type { ProjectInspection } from "./lifecycle.ts";
12
+ import { MAX_COMMAND_MS } from "../cli/terminal-recovery.ts";
12
13
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
13
14
 
15
+ const MANAGER_OWNER_DB_BUSY_TIMEOUT_MS = 5000;
14
16
  export type ProjectRecord = Project;
15
17
  export type Lifecycle = {
16
18
  inspectProject(project: Project): Promise<ProjectInspection>;
17
19
  startProject(project: Project, options?: { unattended?: boolean; env?: NodeJS.ProcessEnv }): Promise<any>;
18
20
  stopProject(project: Project, expectedInstance?: string): Promise<void>;
19
21
  };
20
- export type ManagerOptions = { registry?: Registry; lifecycle?: Lifecycle; home?: string; cli?: string; orphanWatchMs?: number };
22
+ export type ManagerOptions = { registry?: Registry; lifecycle?: Lifecycle; home?: string; cli?: string; orphanWatchMs?: number;
23
+ /** Test clock seam: maps nominal forwarding deadlines, never comes from dashboard input. */
24
+ forwardTimeoutMs?: (action: string, nominalMs: number) => number };
21
25
  type ManagerHandle = { stop(): Promise<void>; stopped: Promise<void> };
22
26
  type Manifest = { port: number; protocol: number; instanceId: string; pid: number; pidSignature?: string };
23
27
  const active = new Map<string, { handle: ManagerHandle; issue(): string }>();
@@ -45,7 +49,7 @@ async function bounded<T>(promise: Promise<T>, ms: number): Promise<T> {
45
49
  function claimOwner(path: string, instanceId: string, signature: string | undefined): Database {
46
50
  const db = new Database(path, { create: true });
47
51
  try {
48
- db.run("PRAGMA busy_timeout = 5000");
52
+ db.run(`PRAGMA busy_timeout = ${MANAGER_OWNER_DB_BUSY_TIMEOUT_MS}`);
49
53
  db.run("CREATE TABLE IF NOT EXISTS owner (slot INTEGER PRIMARY KEY CHECK (slot = 1), instance_id TEXT NOT NULL, pid INTEGER NOT NULL, signature TEXT)");
50
54
  if (!(db.query("PRAGMA table_info(owner)").all() as { name: string }[]).some((c) => c.name === "signature")) {
51
55
  try { db.run("ALTER TABLE owner ADD COLUMN signature TEXT"); }
@@ -148,11 +152,20 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
148
152
  await bounded(lifecycle.stopProject(project, input.instanceId), 20_000);
149
153
  return { ok: true };
150
154
  }
151
- if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
155
+ // The settings actions (#269) reach the project hub as an ordinary session's: this dashboard opens no settings session.
156
+ if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "setting", "setting_undo", "setting_preview", "start_peer"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
152
157
  client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId: input.instanceId });
153
- return await client.request(input.action === "snapshot"
158
+ const nominalMs = input.action === "start_peer" ? MAX_COMMAND_MS + 5_000 : 10_000;
159
+ const timeoutMs = options.forwardTimeoutMs?.(String(input.action), nominalMs) ?? nominalMs;
160
+ const result = await client.request(input.action === "snapshot"
154
161
  ? { t: "ui_snapshot", after: input.after ?? 0 }
155
- : { t: "ui_action", action: input, instanceId: input.instanceId }, 10_000);
162
+ : { t: "ui_action", action: input, instanceId: input.instanceId }, timeoutMs);
163
+ // ControlClient resolves its deadline as this exact sentinel; a hub refusal stays a known failure.
164
+ if (input.action === "start_peer" && result.ok === false && result.error === `no answer from the hub within ${Math.round(timeoutMs / 1000)} s`) {
165
+ const text = "Start result is unconfirmed. Check the Peers panel before retrying; the hub may still be starting the peer.";
166
+ return { ok: false, unconfirmed: true, text, error: text };
167
+ }
168
+ return result;
156
169
  } catch (error) { return { ok: false, error: safeError(error) }; }
157
170
  finally { client?.close(); }
158
171
  };
package/src/hub/peers.ts CHANGED
@@ -4,6 +4,8 @@ export interface DeliveryReceipt {
4
4
  id: string;
5
5
  state: "accepted" | "completed" | "failed_safe" | "needs_review";
6
6
  reason?: string;
7
+ /** A provider refused before acceptance for quota; requeue without spending poison retries while paused. */
8
+ quota?: true;
7
9
  }
8
10
 
9
11
  export interface PeerAdapter {
@@ -24,7 +26,7 @@ export interface PeerAdapter {
24
26
  /** Set by the bus. */
25
27
  onState?: (state: PeerState) => void;
26
28
  /** Set by the bus. A delivery that had resolved turned out not to reach the agent: put it back. */
27
- onFailed?: (envs: Envelope[]) => void;
29
+ onFailed?: (envs: Envelope[], failure?: { quota?: true }) => void;
28
30
  /** Correlated durable-delivery lifecycle, emitted only when the bus supplied an id. */
29
31
  onDelivery?: (receipt: DeliveryReceipt) => void;
30
32
  /** Safe restart metadata only: ids and launch parameters, never prompt/message text. */
@@ -38,7 +40,7 @@ export abstract class BasePeer implements PeerAdapter {
38
40
  /** Returns a reason (a string) when the hub refused the message (issue #38). */
39
41
  onMessage?: (body: string, opts?: EnvelopeOpts) => unknown;
40
42
  onState?: (state: PeerState) => void;
41
- onFailed?: (envs: Envelope[]) => void;
43
+ onFailed?: (envs: Envelope[], failure?: { quota?: true }) => void;
42
44
  onDelivery?: (receipt: DeliveryReceipt) => void;
43
45
  private _state: PeerState = "offline";
44
46
  private timer: ReturnType<typeof setTimeout> | undefined;
@@ -77,7 +79,7 @@ export abstract class BasePeer implements PeerAdapter {
77
79
  this.onDelivery?.(receipt);
78
80
  }
79
81
 
80
- private clearWatchdog(): void {
82
+ protected clearWatchdog(): void {
81
83
  if (this.timer) clearTimeout(this.timer);
82
84
  this.timer = undefined;
83
85
  }
@@ -17,6 +17,20 @@ export function permissionDefaults(value: unknown): Record<string, PermissionMod
17
17
  }
18
18
  return result;
19
19
  }
20
+ /** Approval frequency does not create a common sandbox across native peers. */
21
+ export function permissionBoundary(peer: string): string {
22
+ if (peer === "pi" || peer === "local") return `${peer}: inside hub sandbox, path guard and denylist`;
23
+ if (peer === "claude" || peer === "codex") return `${peer}: native vendor bounds; no hub sandbox`;
24
+ return `${peer}: runs its own tools; NO hub sandbox`;
25
+ }
26
+ /** What a mode lets a peer do without asking, in one line a person reads before choosing it (#269). */
27
+ export function permissionGrant(peer: string, mode: PermissionMode): string {
28
+ if (mode === "ask") return peer === "codex" ? "the session's own approval policy applies again" : peer === "kimi" ? "Kimi asks as it does on its own" : peer === "claude" ? "Claude's own permission rules decide" : "every write, edit and shell command asks";
29
+ if (peer === "kimi") return mode === "never-ask" ? "Kimi's auto mode: nothing asks" : "Kimi's yolo mode: it asks only where it judges it must";
30
+ if (peer === "codex") return mode === "never-ask" ? "approval policy never on every turn: nothing asks" : "approval policy on-request on every turn";
31
+ if (peer === "claude") return mode === "never-ask" ? "every tool runs without asking" : "file tools on project files run without asking, outside .agenthub, .git and agent configuration; other tools keep Claude's own rules";
32
+ return mode === "never-ask" ? "every tool runs without asking, inside the hub sandbox and path guard" : "write and edit inside the project run without asking; shell and git writes still ask";
33
+ }
20
34
  export const PI_EDIT_TOOLS = new Set(["edit", "write"]);
21
35
  /** Native agent policy files are never included in scoped automatic file grants. */
22
36
  export const AGENT_CONFIG_SEGMENTS: ReadonlySet<string> = new Set([".claude", ".codex", ".qwen", ".kimi", ".pi", ".mcp.json"]);
@@ -2,6 +2,7 @@ import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
+ import { hubGitSync } from "./git.ts";
5
6
 
6
7
  const canonical = (path: string): string => {
7
8
  const absolute = resolve(path);
@@ -39,7 +40,7 @@ export function realPath(path: string): string {
39
40
  }
40
41
 
41
42
  function gitRoot(dir: string): string | undefined {
42
- const result = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
43
+ const result = hubGitSync(["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
43
44
  if (result.status !== 0) return undefined;
44
45
  const root = result.stdout.trim();
45
46
  return root ? canonical(root) : undefined;
@@ -0,0 +1,31 @@
1
+ import { sanitize } from "./envelope.ts";
2
+
3
+ /** Additive recovery outcome: older clients still receive ok:false and a useful error. */
4
+ export class RecoveryDatabaseBusy extends Error {
5
+ readonly code = "database_busy";
6
+ readonly retryable = true;
7
+ constructor() {
8
+ super("database_busy: a database is busy; wait for the other connection to settle before retrying the request");
9
+ }
10
+ }
11
+
12
+ export function isDatabaseBusy(error: unknown): boolean {
13
+ if (error instanceof RecoveryDatabaseBusy) return true;
14
+ if (!error || typeof error !== "object") return false;
15
+ const value = error as { code?: unknown; errno?: unknown };
16
+ if (typeof value.code === "string") return /^(SQLITE_BUSY|SQLITE_LOCKED)(_|$)/.test(value.code);
17
+ return error instanceof Error && error.name === "SQLiteError"
18
+ && typeof value.errno === "number" && value.errno > 0 && [5, 6].includes(value.errno & 255);
19
+ }
20
+
21
+ /** Only exact legacy SQLite refusals are inferred from an authenticated hub reply. */
22
+ export function isDatabaseBusyReply(reply: any): boolean {
23
+ return reply?.ok === false && (reply.code === "database_busy"
24
+ || reply.error === "database is locked" || reply.error === "database table is locked");
25
+ }
26
+
27
+ /** Frames only: retain the throw site without carrying a native error's message. */
28
+ export function databaseBusySite(error: unknown): string[] {
29
+ const stack = error instanceof Error ? error.stack : undefined;
30
+ return (stack ?? "").split("\n").filter(line => /^\s*at /.test(line)).slice(0, 8).map(line => sanitize(line.trim()).slice(0, 400));
31
+ }
@@ -5,6 +5,7 @@ import { Database } from "bun:sqlite";
5
5
  import { hubHome } from "./project.ts";
6
6
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
7
7
 
8
+ const RUNNER_DB_BUSY_TIMEOUT_MS = 3000;
8
9
  export const operationIdPattern = /^[a-f0-9-]{36}$/;
9
10
  export function operationPath(id: string, home = hubHome()): string {
10
11
  if (!operationIdPattern.test(id)) throw new Error("invalid recovery operation ID");
@@ -111,7 +112,7 @@ export function recoveryRunner(id: string, home = hubHome()): number | "unknown"
111
112
  let db: Database | undefined;
112
113
  try {
113
114
  db = new Database(path, { readonly: true });
114
- db.run("PRAGMA busy_timeout = 3000"); // a claim in progress must not read as no runner
115
+ db.run(`PRAGMA busy_timeout = ${RUNNER_DB_BUSY_TIMEOUT_MS}`); // a claim in progress must not read as no runner
115
116
  // A crash between creating the file and its table leaves no runner; claimRunner creates the table and claims.
116
117
  if (!db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'runner'").get()) return undefined;
117
118
  const row = db.query(`SELECT pid${signed(db) ? ", signature" : ""} FROM runner WHERE slot = 1`).get() as { pid: number; signature?: string | null } | null;
@@ -133,7 +134,7 @@ export function signedRunner(id: string, home = hubHome()): number | undefined {
133
134
  let db: Database | undefined;
134
135
  try {
135
136
  db = new Database(path, { readonly: true });
136
- db.run("PRAGMA busy_timeout = 3000");
137
+ db.run(`PRAGMA busy_timeout = ${RUNNER_DB_BUSY_TIMEOUT_MS}`);
137
138
  if (!signed(db)) return undefined;
138
139
  const row = db.query("SELECT pid, signature FROM runner WHERE slot = 1").get() as { pid: number; signature: string | null } | null;
139
140
  if (!row?.signature || !Number.isSafeInteger(row.pid) || row.pid < 1) return undefined;
@@ -157,11 +158,11 @@ export async function stopSignedRunner(id: string, home = hubHome(), waitMs = 50
157
158
  /** An exclusive runner claim. Never steal a live or uncertain owner on resume. */
158
159
  export function claimRunner(id: string, home = hubHome()): () => void {
159
160
  const path = `${operationPath(id, home)}.runner.db`;
160
- const db = new Database(path, { create: true });
161
- chmodSync(path, 0o600);
162
161
  const nonce = randomUUID();
162
+ const db = new Database(path, { create: true });
163
163
  try {
164
- db.run("PRAGMA busy_timeout = 3000");
164
+ db.run(`PRAGMA busy_timeout = ${RUNNER_DB_BUSY_TIMEOUT_MS}`);
165
+ chmodSync(path, 0o600);
165
166
  db.run("CREATE TABLE IF NOT EXISTS runner (slot INTEGER PRIMARY KEY, pid INTEGER NOT NULL, nonce TEXT NOT NULL, signature TEXT)");
166
167
  if (!signed(db)) {
167
168
  try { db.run("ALTER TABLE runner ADD COLUMN signature TEXT"); }
@@ -7,6 +7,7 @@ import { processLiveness } from "../pi/process-signature.ts";
7
7
 
8
8
  const BASE_PORT = 4600;
9
9
  const STRIDE = 10;
10
+ export const REGISTRY_DB_BUSY_TIMEOUT_MS = 5000;
10
11
  const MAX_PORT = 65535 - 3;
11
12
  const LEGACY_DONE = "legacy_ports_imported";
12
13
  type Tx = { immediate(): void };
@@ -50,7 +51,7 @@ export class Registry {
50
51
  this.path = path;
51
52
  mkdirSync(dirname(path), { recursive: true });
52
53
  this.db = new Database(path, { create: true });
53
- this.db.run("PRAGMA busy_timeout = 5000");
54
+ this.db.run(`PRAGMA busy_timeout = ${REGISTRY_DB_BUSY_TIMEOUT_MS}`);
54
55
  try {
55
56
  this.db.run("PRAGMA journal_mode = WAL");
56
57
  } catch (error) {
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
- import type { Task, TaskClass } from "./board.ts";
3
+ import { CLASSES, type Task, type TaskClass } from "./board.ts";
4
4
  import type { PeerId, PeerState } from "./envelope.ts";
5
5
 
6
6
  import { parseHubRoutes, type HubRoute } from "../models/route/config.ts";
@@ -36,21 +36,115 @@ export interface Routing {
36
36
  constraints: { pii: "local_only" | "off"; long_context: "skip_local" | "off"; budget_paused: "skip_peer" | "off" };
37
37
  /** `efficient_wait_ms`, opt-in (#199): how long a hub/auto request waits for a busy MLX slot before it moves to dgx/fast; absent, nothing moves for load. */
38
38
  pi: { dgx_max_context_tokens: number; mlx_max_context_tokens: number; efficient_wait_ms?: number };
39
+ /** The keys `.agenthub/routing.local.toml` set (#269), such as `classes.implement.peers`; every other value is routing.toml's. */
40
+ sources?: Record<string, "routing.local.toml">;
41
+ }
42
+
43
+ /**
44
+ * What `.agenthub/routing.local.toml` may hold (#269): a person's machine-local override of these keys and nothing
45
+ * else, so a settings write can never reach targets, routes, signals or the PII constraint.
46
+ */
47
+ export interface RoutingOverlay {
48
+ stay_switch?: StaySwitchMode;
49
+ classes?: Partial<Record<TaskClass, { peers?: PeerId[]; escalate_to?: PeerId[]; route?: string; pi_backend?: "dgx" | "mlx" }>>;
50
+ }
51
+ export const OVERLAY_FILE = "routing.local.toml";
52
+ const OVERLAY_PEER = /^[a-z][a-z0-9-]{0,31}$/;
53
+ const OVERLAY_ROUTE = /^[A-Za-z0-9][A-Za-z0-9_./-]{0,63}$/;
54
+ const OVERLAY_PEERS_MAX = 8;
55
+
56
+ /** A checked copy of an overlay, in a fixed key order; throws on anything outside the keys above. */
57
+ export function checkOverlay(raw: unknown): RoutingOverlay {
58
+ const bad = (what: string): never => { throw new Error(`${OVERLAY_FILE}: ${what}`); };
59
+ const table = (value: unknown, where: string): Record<string, unknown> => (!value || typeof value !== "object" || Array.isArray(value) ? bad(`${where} must be a table`) : value as Record<string, unknown>);
60
+ const peers = (value: unknown, where: string): PeerId[] => {
61
+ if (!Array.isArray(value) || value.length > OVERLAY_PEERS_MAX || value.some((p) => typeof p !== "string" || !OVERLAY_PEER.test(p)) || new Set(value).size !== value.length) bad(`${where} must list up to ${OVERLAY_PEERS_MAX} distinct peer ids`);
62
+ return [...(value as PeerId[])];
63
+ };
64
+ const top = table(raw, "the file");
65
+ const out: RoutingOverlay = {};
66
+ for (const [key, value] of Object.entries(top)) {
67
+ if (key === "stay_switch") {
68
+ if (value !== "off" && value !== "shadow" && value !== "enforce") bad('stay_switch must be "off", "shadow" or "enforce"');
69
+ out.stay_switch = value as StaySwitchMode;
70
+ } else if (key === "classes") {
71
+ for (const [name, policy] of Object.entries(table(value, "classes"))) {
72
+ if (!(CLASSES as readonly string[]).includes(name)) bad(`[classes.${name}] is not a task class`);
73
+ const entry: NonNullable<RoutingOverlay["classes"]>[TaskClass] = {};
74
+ for (const [field, v] of Object.entries(table(policy, `[classes.${name}]`))) {
75
+ if (field === "peers" || field === "escalate_to") entry[field] = peers(v, `[classes.${name}] ${field}`);
76
+ else if (field === "route") entry.route = typeof v === "string" && OVERLAY_ROUTE.test(v) ? v : bad(`[classes.${name}] route must be a route id`);
77
+ else if (field === "pi_backend") entry.pi_backend = v === "dgx" || v === "mlx" ? v : bad(`[classes.${name}] pi_backend must be "dgx" or "mlx"`);
78
+ else bad(`[classes.${name}] ${field} is not a setting this file holds; edit routing.toml`);
79
+ }
80
+ if (Object.keys(entry).length) (out.classes ??= {})[name as TaskClass] = entry;
81
+ }
82
+ } else bad(`${key} is not a setting this file holds; edit routing.toml`);
83
+ }
84
+ return out;
85
+ }
86
+ export const parseOverlay = (text: string): RoutingOverlay => checkOverlay(Bun.TOML.parse(text));
87
+
88
+ /** The overlay as TOML. Every value is a checked id or a closed word, so a JSON string is a valid TOML basic string. */
89
+ export function overlayToml(overlay: RoutingOverlay): string {
90
+ const checked = checkOverlay(overlay);
91
+ const lines = ["# Written by ahub settings and the dashboard (machine-local). It overrides routing.toml key by key."];
92
+ if (checked.stay_switch) lines.push(`stay_switch = ${JSON.stringify(checked.stay_switch)}`);
93
+ for (const name of CLASSES) {
94
+ const entry = checked.classes?.[name];
95
+ if (!entry) continue;
96
+ lines.push("", `[classes.${name}]`);
97
+ for (const field of ["peers", "escalate_to"] as const) if (entry[field]) lines.push(`${field} = [${entry[field]!.map((p) => JSON.stringify(p)).join(", ")}]`);
98
+ if (entry.route) lines.push(`route = ${JSON.stringify(entry.route)}`);
99
+ if (entry.pi_backend) lines.push(`pi_backend = ${JSON.stringify(entry.pi_backend)}`);
100
+ }
101
+ return `${lines.join("\n")}\n`;
39
102
  }
40
103
 
41
104
  const TEMPLATE = join(import.meta.dir, "..", "..", "templates", "routing.toml");
42
105
  export const LOCAL: PeerId = "local";
43
106
  export const PI: PeerId = "pi";
44
107
 
45
- /** `.agenthub/routing.toml`, or the shipped default when the project has none. Throws on a file that does not parse or lacks a fixed model. */
108
+ /**
109
+ * `.agenthub/routing.toml`, or the shipped default when the project has none, with `.agenthub/routing.local.toml`
110
+ * merged over it key by key (#269). Throws on a file that does not parse or lacks a fixed model.
111
+ */
46
112
  export function loadRouting(cwd: string): Routing {
47
- let text: string;
113
+ const text = routingText(cwd);
114
+ let overlay: string | undefined;
48
115
  try {
49
- text = readFileSync(join(cwd, ".agenthub", "routing.toml"), "utf8");
116
+ overlay = readFileSync(join(cwd, ".agenthub", OVERLAY_FILE), "utf8");
117
+ } catch (error) {
118
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
119
+ }
120
+ return parseRouting(text, overlay === undefined ? undefined : parseOverlay(overlay));
121
+ }
122
+
123
+ /** The project's routing.toml as text, or the shipped default when it has none. */
124
+ export function routingText(cwd: string): string {
125
+ try {
126
+ return readFileSync(join(cwd, ".agenthub", "routing.toml"), "utf8");
50
127
  } catch {
51
- text = readFileSync(TEMPLATE, "utf8");
128
+ return readFileSync(TEMPLATE, "utf8");
52
129
  }
130
+ }
131
+
132
+ /** The policy of a routing.toml text with an overlay merged over it: what `loadRouting` reads, without the disk. */
133
+ export function parseRouting(text: string, overlay?: RoutingOverlay): Routing {
53
134
  const raw = Bun.TOML.parse(text) as Partial<Routing>;
135
+ const sources: NonNullable<Routing["sources"]> = {};
136
+ if (overlay) {
137
+ const checked = checkOverlay(overlay);
138
+ if (checked.stay_switch) { (raw as Record<string, unknown>).stay_switch = checked.stay_switch; sources.stay_switch = OVERLAY_FILE; }
139
+ const known = new Set([...Object.keys(raw.routes ?? {}), ...Object.keys(raw.hub_routes ?? {})]);
140
+ for (const [name, entry] of Object.entries(checked.classes ?? {})) {
141
+ if (entry.route && !known.has(entry.route)) throw new Error(`${OVERLAY_FILE}: [classes.${name}] route ${entry.route} names no route in routing.toml`);
142
+ const classes = (raw.classes ??= {}) as Record<string, ClassPolicy>;
143
+ // A class the shared file does not have starts from an empty peer list: the overlay may add its own.
144
+ classes[name] = { ...(classes[name] ?? { peers: [] }), ...entry };
145
+ for (const field of Object.keys(entry)) sources[`classes.${name}.${field}`] = OVERLAY_FILE;
146
+ }
147
+ }
54
148
  if (!raw.local?.fixed_model) throw new Error("routing.toml: [local] fixed_model is required (the path that works without Switchyard)");
55
149
  const signals = { pii_patterns: [], long_context_tokens: 120_000, pii_screen: "off" as const, ...raw.signals };
56
150
  for (const p of signals.pii_patterns) new RegExp(p); // a bad pattern fails here, at load, not in the middle of an assignment
@@ -82,22 +176,26 @@ export function loadRouting(cwd: string): Routing {
82
176
  signals,
83
177
  constraints: { pii: "local_only", long_context: "skip_local", budget_paused: "skip_peer", ...raw.constraints },
84
178
  pi,
179
+ ...(Object.keys(sources).length ? { sources } : {}),
85
180
  };
86
181
  }
87
182
 
88
- const cache = new Map<string, { mtime: number; routing: Routing }>();
183
+ const cache = new Map<string, { mtime: string; routing: Routing }>();
89
184
 
90
185
  /**
91
186
  * loadRouting behind an mtime check. routing.toml is edited while the hub runs and read on every task operation;
92
187
  * a half-saved file must not abort an operation midway, so the last good parse stays in force until the file parses again.
93
188
  */
94
189
  export function currentRouting(cwd: string, log: (line: string) => void = () => {}): Routing {
95
- let mtime = 0;
96
- try {
97
- mtime = statSync(join(cwd, ".agenthub", "routing.toml")).mtimeMs;
98
- } catch {
99
- // no project file: the shipped template
100
- }
190
+ // Both files count: a settings write changes only the overlay.
191
+ const mtime = ["routing.toml", OVERLAY_FILE].map((name) => {
192
+ try {
193
+ const stat = statSync(join(cwd, ".agenthub", name));
194
+ return `${stat.mtimeMs}/${stat.size}`;
195
+ } catch {
196
+ return 0; // no project file: the shipped template, or no overlay
197
+ }
198
+ }).join(":");
101
199
  const hit = cache.get(cwd);
102
200
  if (hit && hit.mtime === mtime) return hit.routing;
103
201
  try {
@@ -106,7 +204,7 @@ export function currentRouting(cwd: string, log: (line: string) => void = () =>
106
204
  return routing;
107
205
  } catch (e) {
108
206
  if (!hit) throw e;
109
- log(`routing.toml does not load, keeping the previous policy: ${(e as Error).message}`);
207
+ log(`routing does not load, keeping the previous policy: ${(e as Error).message}`);
110
208
  cache.set(cwd, { mtime, routing: hit.routing });
111
209
  return hit.routing;
112
210
  }
@@ -176,6 +274,9 @@ export function assign(
176
274
  ): Assignment {
177
275
  const policy = routing.classes[task.class];
178
276
  const trace: string[] = [`class ${task.class}${policy ? "" : " (no [classes] entry: only an explicit owner can take it)"}`, `signals: ${task.signals.join(", ") || "none"}`];
277
+ // Which file each of this class's values comes from (#269), only when the machine-local overlay sets one.
278
+ const local = Object.keys(routing.sources ?? {}).filter((key) => key.startsWith(`classes.${task.class}.`)).map((key) => key.slice(`classes.${task.class}.`.length));
279
+ if (local.length) trace.push(`policy source: ${local.join(", ")} from ${OVERLAY_FILE}; the rest from routing.toml`);
179
280
  // A reserved owner (issue #207) is offered the task first. Named candidates (an assign, an escalation, a relay) replace it.
180
281
  const reserved = opts.candidates ? undefined : task.reserved ?? undefined;
181
282
  // Readiness is an input like peer states (issue #34): a task that waits for others goes to nobody yet.