@staix/agent-hub 0.12.21 → 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.
- package/CHANGELOG.md +29 -0
- package/README.md +27 -12
- package/docs/agent-notes/adapters.md +15 -7
- package/docs/agent-notes/benchmarks.md +4 -1
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +12 -1
- package/docs/agent-notes/local-worker.md +4 -1
- package/docs/agent-notes/tasks.md +2 -1
- package/docs/agent-notes/tests.md +4 -0
- package/docs/events.md +17 -4
- package/docs/operations.md +478 -41
- package/docs/quickstart.md +14 -6
- package/docs/security.md +23 -11
- package/docs/smoke.md +133 -7
- package/docs/specs/2026-09-19-agent-hub-design.md +115 -15
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
- package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -0
- package/docs/verified.json +32 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +14 -3
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +105 -17
- package/src/adapters/codex-appserver.ts +70 -35
- package/src/adapters/local-worker.ts +44 -10
- package/src/adapters/pi.ts +158 -19
- package/src/cli/bench.ts +2 -1
- package/src/cli/console-state.ts +28 -22
- package/src/cli/console.ts +20 -10
- package/src/cli/help.ts +25 -19
- package/src/cli/identity.ts +3 -3
- package/src/cli/init.ts +1 -1
- package/src/cli/launch.ts +182 -7
- package/src/cli/main.ts +409 -177
- package/src/cli/output.ts +363 -0
- package/src/cli/preview.ts +2 -1
- package/src/cli/recovery-package.ts +17 -0
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +0 -28
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-interactive.ts +297 -0
- package/src/cli/upgrade-runtime.ts +80 -9
- package/src/cli/upgrade.ts +50 -15
- package/src/hub/board.ts +2 -0
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +15 -14
- package/src/hub/child-process.ts +11 -0
- package/src/hub/conductor.ts +23 -11
- package/src/hub/config-trust.ts +16 -5
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +2 -1
- package/src/hub/daemon.ts +483 -83
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +2 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +4 -1
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +5 -4
- package/src/hub/git.ts +32 -0
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/manager.ts +18 -5
- package/src/hub/peers.ts +7 -5
- package/src/hub/permission-mode.ts +14 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +43 -4
- package/src/hub/registry.ts +2 -1
- package/src/hub/routing.ts +114 -13
- package/src/hub/settings.ts +392 -0
- package/src/hub/snapshots.ts +4 -2
- package/src/hub/start-mode.ts +48 -0
- package/src/hub/tasks.ts +158 -43
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/ui.ts +25 -13
- package/src/local/deny.ts +71 -2
- package/src/local/sandbox.ts +47 -6
- package/src/local/tools.ts +25 -6
- package/src/memory/recall.ts +2 -2
- package/src/models/mlx.ts +2 -1
- package/src/pi/extension.ts +43 -20
- package/src/pi/tool-receipts.ts +19 -2
- package/src/ui/index.html +172 -21
package/src/hub/routing.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
|
-
import type
|
|
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
|
-
/**
|
|
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
|
-
|
|
113
|
+
const text = routingText(cwd);
|
|
114
|
+
let overlay: string | undefined;
|
|
48
115
|
try {
|
|
49
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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.
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { CLASSES, type TaskClass } from "./board.ts";
|
|
6
|
+
import { configTracked } from "./config-trust.ts";
|
|
7
|
+
import { PERMISSION_MODES, permissionBoundary } from "./permission-mode.ts";
|
|
8
|
+
import { START_MODE_PEERS, START_MODES } from "./start-mode.ts";
|
|
9
|
+
import { OVERLAY_FILE, overlayToml, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "./routing.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* #269: the settings a person may change from the dashboard or `ahub settings`. A key that is not in SETTINGS cannot be
|
|
13
|
+
* written by either. Writes go only to the machine-local files (`.agenthub/config.local.json`,
|
|
14
|
+
* `.agenthub/routing.local.toml`), never to a tracked one.
|
|
15
|
+
*/
|
|
16
|
+
export const SETTING_PEERS = ["claude", "codex", "kimi", "pi", "local"] as const;
|
|
17
|
+
/** `null` removes the machine-local value, so the shared file's or the default applies again. */
|
|
18
|
+
export type SettingValue = string | boolean | string[] | null;
|
|
19
|
+
export interface SettingDef {
|
|
20
|
+
key: string;
|
|
21
|
+
group: "Permissions" | "Start" | "Routing" | "Switches";
|
|
22
|
+
label: string;
|
|
23
|
+
type: "enum" | "boolean" | "peers";
|
|
24
|
+
/** The closed values of an enum; a route setting takes the project's own route ids instead. */
|
|
25
|
+
values?: readonly string[];
|
|
26
|
+
/** `runtime` lives in the running hub only; the others are the machine-local file that holds the value. */
|
|
27
|
+
store: "runtime" | "config" | "routing";
|
|
28
|
+
/** `peer start`: stored, and taken up by the running hub at that peer's next start. */
|
|
29
|
+
applies: "live" | "peer start" | "hub start";
|
|
30
|
+
/** `raises`: only a settings session or a person's terminal may set it, except to `floor`. */
|
|
31
|
+
risk: "safe" | "raises";
|
|
32
|
+
floor?: string | boolean;
|
|
33
|
+
/** Where the value sits inside its file (or, for `runtime`, the peer). */
|
|
34
|
+
path: readonly string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const PEER = /^[a-z][a-z0-9-]{0,31}$/;
|
|
38
|
+
const PEERS_MAX = 8;
|
|
39
|
+
const perClass = (name: TaskClass): SettingDef[] => [
|
|
40
|
+
{ key: `routing.classes.${name}.peers`, group: "Routing", label: `${name}: owner preference order`, type: "peers", store: "routing", applies: "live", risk: "raises", path: ["classes", name, "peers"] },
|
|
41
|
+
{ key: `routing.classes.${name}.escalate_to`, group: "Routing", label: `${name}: escalation order`, type: "peers", store: "routing", applies: "live", risk: "raises", path: ["classes", name, "escalate_to"] },
|
|
42
|
+
{ key: `routing.classes.${name}.route`, group: "Routing", label: `${name}: local worker route`, type: "enum", store: "routing", applies: "live", risk: "raises", path: ["classes", name, "route"] },
|
|
43
|
+
{ key: `routing.classes.${name}.pi_backend`, group: "Routing", label: `${name}: Pi backend`, type: "enum", values: ["dgx", "mlx"], store: "routing", applies: "live", risk: "raises", path: ["classes", name, "pi_backend"] },
|
|
44
|
+
];
|
|
45
|
+
export const SETTINGS: readonly SettingDef[] = [
|
|
46
|
+
...SETTING_PEERS.map((peer): SettingDef => ({ key: `permission.${peer}`, group: "Permissions", label: `${peer}: mode now`, type: "enum", values: PERMISSION_MODES, store: "runtime", applies: "live", risk: "raises", floor: "ask", path: [peer] })),
|
|
47
|
+
...SETTING_PEERS.map((peer): SettingDef => ({ key: `permission_modes.${peer}`, group: "Permissions", label: `${peer}: mode at hub start`, type: "enum", values: PERMISSION_MODES, store: "config", applies: "hub start", risk: "raises", floor: "ask", path: ["permission_modes", peer] })),
|
|
48
|
+
...START_MODE_PEERS.map((peer): SettingDef => ({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (tui unless set)`, type: "enum", values: START_MODES, store: "config", applies: "peer start", risk: "raises", floor: "tui", path: ["peers", peer, "start_mode"] })),
|
|
49
|
+
{ key: "pi.auto_start", group: "Start", label: "Start Pi with the hub", type: "boolean", store: "config", applies: "hub start", risk: "raises", floor: false, path: ["pi", "auto_start"] },
|
|
50
|
+
{ key: "routing.stay_switch", group: "Routing", label: "Stay or switch (hub/auto and stage routes)", type: "enum", values: ["off", "shadow", "enforce"], store: "routing", applies: "live", risk: "raises", path: ["stay_switch"] },
|
|
51
|
+
...CLASSES.flatMap(perClass),
|
|
52
|
+
{ key: "research.enabled", group: "Switches", label: "Research records", type: "boolean", store: "config", applies: "hub start", risk: "safe", path: ["research", "enabled"] },
|
|
53
|
+
{ key: "approvals.notify", group: "Switches", label: "Desktop notice for a waiting approval", type: "boolean", store: "config", applies: "hub start", risk: "safe", path: ["approvals", "notify"] },
|
|
54
|
+
{ key: "snapshots.enabled", group: "Switches", label: "Per-turn snapshots", type: "boolean", store: "config", applies: "hub start", risk: "safe", path: ["snapshots", "enabled"] },
|
|
55
|
+
{ key: "coordination", group: "Switches", label: "Coordination of overlapping owners", type: "enum", values: ["advisory", "turn-free"], store: "config", applies: "hub start", risk: "safe", path: ["coordination"] },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Never editable from settings, whatever the session: these choose what the hub executes or where secrets and task
|
|
60
|
+
* text go. A key equal to an entry, or below it, is refused by name.
|
|
61
|
+
*/
|
|
62
|
+
export const NEVER_EDITABLE = ["kimi_cmd", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal"] as const;
|
|
63
|
+
|
|
64
|
+
/** The definition of a key, or why there is none. */
|
|
65
|
+
export function settingDef(key: unknown): SettingDef | string {
|
|
66
|
+
if (typeof key !== "string" || key.length > 80) return "unknown setting";
|
|
67
|
+
const found = SETTINGS.find((s) => s.key === key);
|
|
68
|
+
if (found) return found;
|
|
69
|
+
if (NEVER_EDITABLE.some((never) => key === never || key.startsWith(`${never}.`))) return `${key} chooses what the hub runs or where secrets and task text go: it is never editable from settings; edit .agenthub/config.local.json in your editor`;
|
|
70
|
+
return "unknown setting; run ahub settings list";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The checked value, or a thrown reason. `routes`: the route ids the project's routing defines. */
|
|
74
|
+
export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonly string[] = []): SettingValue {
|
|
75
|
+
if (raw === null) {
|
|
76
|
+
if (def.store === "runtime") throw new Error(`${def.key} has no stored value to remove`);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
if (def.type === "boolean") {
|
|
80
|
+
if (typeof raw !== "boolean") throw new Error(`${def.key} takes true or false`);
|
|
81
|
+
return raw;
|
|
82
|
+
}
|
|
83
|
+
if (def.type === "peers") {
|
|
84
|
+
if (!Array.isArray(raw) || raw.length > PEERS_MAX || raw.some((p) => typeof p !== "string" || !PEER.test(p)) || new Set(raw).size !== raw.length) throw new Error(`${def.key} takes up to ${PEERS_MAX} distinct peer ids`);
|
|
85
|
+
return [...(raw as string[])];
|
|
86
|
+
}
|
|
87
|
+
const values = def.values ?? routes;
|
|
88
|
+
if (typeof raw !== "string" || !values.includes(raw)) throw new Error(`${def.key} takes one of: ${values.join(", ") || "(the project defines no route)"}`);
|
|
89
|
+
return raw;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A terminal's text for a value: `inherit` removes the machine-local value, a peers setting takes a comma list. */
|
|
93
|
+
export function parseSettingText(def: SettingDef, text: string): unknown {
|
|
94
|
+
if (text === "inherit") return null;
|
|
95
|
+
if (def.type === "boolean") return text === "true" ? true : text === "false" ? false : text;
|
|
96
|
+
if (def.type === "peers") return text.split(",").map((p) => p.trim()).filter(Boolean);
|
|
97
|
+
return text;
|
|
98
|
+
}
|
|
99
|
+
export const settingText = (value: unknown): string => value === null || value === undefined ? "(not set)" : Array.isArray(value) ? value.join(",") || "(none)" : String(value);
|
|
100
|
+
|
|
101
|
+
/** `ordinary`: a dashboard session from `ahub ui`. `settings`: one from `ahub ui --settings`. `terminal`: `ahub settings`. */
|
|
102
|
+
export type SettingAuthority = "ordinary" | "settings" | "terminal";
|
|
103
|
+
/** Why this authority may not set this value, or undefined. Removing a stored value can raise, so it counts as one. */
|
|
104
|
+
export function settingRefusal(def: SettingDef, value: SettingValue, authority: SettingAuthority): string | undefined {
|
|
105
|
+
if (def.risk === "safe" || authority !== "ordinary") return undefined;
|
|
106
|
+
if (value !== null && value === def.floor) return undefined;
|
|
107
|
+
return `${def.key} can widen what agents do without asking: open a settings session with ahub ui --settings, or use ahub settings in a terminal`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const CONFIG_LOCAL = "config.local.json";
|
|
111
|
+
type StoredFile = typeof CONFIG_LOCAL | typeof OVERLAY_FILE;
|
|
112
|
+
interface UndoRecord { file: StoredFile; key: string; previous: string | null; written: string | null; at: number }
|
|
113
|
+
const undoFile = (stateDir: string): string => join(stateDir, "settings-undo.json");
|
|
114
|
+
const digest = (text: string | null): string | null => text === null ? null : createHash("sha256").update(text).digest("hex");
|
|
115
|
+
/** The file's text, or null when it is not there. Any other failure is named by its code: its own text holds absolute paths. */
|
|
116
|
+
const readText = (file: string): string | null => {
|
|
117
|
+
try { return readFileSync(file, "utf8"); } catch (error) {
|
|
118
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
119
|
+
throw new Error(`.agenthub/${basename(file)} could not be read (${(error as NodeJS.ErrnoException).code ?? "unknown error"})`);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const isObject = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
123
|
+
function parseObject(text: string, name: string): Record<string, unknown> {
|
|
124
|
+
let doc: unknown;
|
|
125
|
+
try { doc = JSON.parse(text); } catch { throw new Error(`.agenthub/${name} is not valid JSON; fix it in your editor first`); }
|
|
126
|
+
if (!isObject(doc)) throw new Error(`.agenthub/${name} must hold a JSON object`);
|
|
127
|
+
return doc;
|
|
128
|
+
}
|
|
129
|
+
/** Temp file and rename in the same directory: a reader sees the old file or the new one, never half of it. */
|
|
130
|
+
function atomicWrite(file: string, text: string): void {
|
|
131
|
+
let mode = 0o600;
|
|
132
|
+
try { mode = statSync(file).mode & 0o777; } catch { /* a new file is the owner's alone */ }
|
|
133
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
134
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
135
|
+
try {
|
|
136
|
+
writeFileSync(tmp, text, { mode, flag: "wx" });
|
|
137
|
+
renameSync(tmp, file);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
try { unlinkSync(tmp); } catch { /* nothing was written */ }
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** A settings write never changes a file git tracks: the machine-local files are meant to stay out of the repository. */
|
|
144
|
+
function refuseTracked(cwd: string, file: StoredFile): void {
|
|
145
|
+
const tracked = configTracked(cwd, file);
|
|
146
|
+
if (tracked) throw new Error(`.agenthub/${file} is committed to git; settings write only machine-local files. Remove it from the repository (git rm --cached) and ignore it`);
|
|
147
|
+
// Unknown is not untracked where there is a repository to ask; only a project outside git has nothing tracked.
|
|
148
|
+
if (tracked === undefined && insideRepository(cwd)) throw new Error(`git could not confirm that .agenthub/${file} is untracked; nothing was written`);
|
|
149
|
+
}
|
|
150
|
+
/** Whether this directory or one above it holds a `.git`: a repository git should have been able to answer for. */
|
|
151
|
+
function insideRepository(dir: string): boolean {
|
|
152
|
+
for (let at = dir; ; at = dirname(at)) {
|
|
153
|
+
if (existsSync(join(at, ".git"))) return true;
|
|
154
|
+
if (dirname(at) === at) return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** Keeps the one previous version, then replaces (or removes) the file. */
|
|
158
|
+
function store(cwd: string, stateDir: string, file: StoredFile, key: string, previous: string | null, next: string | null): void {
|
|
159
|
+
refuseTracked(cwd, file);
|
|
160
|
+
replaceFile(join(cwd, ".agenthub", file), file, next);
|
|
161
|
+
// The record follows the file: a write that failed leaves the earlier record valid, because the file is as it left it.
|
|
162
|
+
const record: UndoRecord = { file, key, previous, written: digest(next), at: Date.now() };
|
|
163
|
+
try {
|
|
164
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
165
|
+
rmSync(undoFile(stateDir), { force: true });
|
|
166
|
+
writeFileSync(undoFile(stateDir), JSON.stringify(record), { mode: 0o600 });
|
|
167
|
+
} catch {
|
|
168
|
+
rmSync(undoFile(stateDir), { force: true }); // this write has no undo, and an older record must not offer one
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Replaces or removes the file. A file-system failure is reported by its code only: its text names absolute paths, and this reaches the dashboard. */
|
|
172
|
+
function replaceFile(path: string, file: StoredFile, text: string | null): void {
|
|
173
|
+
try {
|
|
174
|
+
if (text === null) rmSync(path, { force: true });
|
|
175
|
+
else atomicWrite(path, text);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
throw new Error(`.agenthub/${file} could not be written (${(error as NodeJS.ErrnoException).code ?? "unknown error"}); nothing was changed`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The value a config file holds at a path, or undefined. */
|
|
182
|
+
export function valueAt(doc: unknown, path: readonly string[]): unknown {
|
|
183
|
+
let node = doc;
|
|
184
|
+
for (const part of path) {
|
|
185
|
+
if (!isObject(node) || !Object.hasOwn(node, part)) return undefined;
|
|
186
|
+
node = node[part];
|
|
187
|
+
}
|
|
188
|
+
return node;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Sets (or with null removes) one key of `.agenthub/config.local.json`. `validate` gets a scratch project that holds
|
|
193
|
+
* the shared config and the candidate, and throws when the hub's own loader would refuse it: nothing is written then.
|
|
194
|
+
*/
|
|
195
|
+
export function writeConfigSetting(cwd: string, stateDir: string, def: SettingDef, value: SettingValue, validate: (scratch: string) => unknown): boolean {
|
|
196
|
+
const file = join(cwd, ".agenthub", CONFIG_LOCAL);
|
|
197
|
+
const before = readText(file);
|
|
198
|
+
const doc = before === null ? {} : parseObject(before, CONFIG_LOCAL);
|
|
199
|
+
// Nothing to change: no file is created or rewritten, and the undo record keeps the last real write.
|
|
200
|
+
const held = valueAt(doc, def.path);
|
|
201
|
+
if (value === null ? held === undefined : same(held, value)) return false;
|
|
202
|
+
// The loader gives a project that has any config file its project defaults (snapshots, limits, the budget relay,
|
|
203
|
+
// the approval notice). A settings write must not be what silently makes the first one.
|
|
204
|
+
if (before === null && !existsSync(join(cwd, ".agenthub", "config.json"))) throw new Error("this project has no .agenthub/config.json: run ahub init first. A first config file also turns on the project defaults (snapshots, limits, the budget relay), which a settings write does not do for you");
|
|
205
|
+
let node = doc;
|
|
206
|
+
const trail: [Record<string, unknown>, string][] = [];
|
|
207
|
+
for (const part of def.path.slice(0, -1)) {
|
|
208
|
+
if (node[part] === undefined) {
|
|
209
|
+
if (value === null) { node = {}; break; } // nothing to remove below a block that is not there
|
|
210
|
+
node[part] = {};
|
|
211
|
+
}
|
|
212
|
+
const next = node[part];
|
|
213
|
+
if (!isObject(next)) throw new Error(`.agenthub/${CONFIG_LOCAL}: ${part} is not an object; fix it in your editor first`);
|
|
214
|
+
trail.push([node, part]);
|
|
215
|
+
node = next;
|
|
216
|
+
}
|
|
217
|
+
const leaf = def.path.at(-1)!;
|
|
218
|
+
if (value === null) {
|
|
219
|
+
delete node[leaf];
|
|
220
|
+
// A block the removal emptied goes with it, so setting a value and removing it again leaves the file as it was.
|
|
221
|
+
for (const [parent, part] of trail.reverse()) {
|
|
222
|
+
if (Object.keys(parent[part] as object).length) break;
|
|
223
|
+
delete parent[part];
|
|
224
|
+
}
|
|
225
|
+
} else node[leaf] = value;
|
|
226
|
+
const text = `${JSON.stringify(doc, null, 2)}\n`;
|
|
227
|
+
checkConfigText(cwd, text, validate);
|
|
228
|
+
store(cwd, stateDir, CONFIG_LOCAL, def.key, before, text);
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
/** Runs `validate` on a scratch project holding the shared config and this candidate for the machine-local one. */
|
|
232
|
+
function checkConfigText(cwd: string, text: string | null, validate: (scratch: string) => unknown): void {
|
|
233
|
+
const scratch = mkdtempSync(join(tmpdir(), "ahub-settings-"));
|
|
234
|
+
try {
|
|
235
|
+
mkdirSync(join(scratch, ".agenthub"));
|
|
236
|
+
if (existsSync(join(cwd, ".agenthub", "config.json"))) copyFileSync(join(cwd, ".agenthub", "config.json"), join(scratch, ".agenthub", "config.json"));
|
|
237
|
+
if (text !== null) writeFileSync(join(scratch, ".agenthub", CONFIG_LOCAL), text);
|
|
238
|
+
validate(scratch);
|
|
239
|
+
} finally {
|
|
240
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Sets (or with null removes) one key of `.agenthub/routing.local.toml`; the real parser reads the result first, and
|
|
246
|
+
* `check` may refuse the policy it yields. Undefined when the overlay already holds that value: nothing is written.
|
|
247
|
+
*/
|
|
248
|
+
export function writeRoutingSetting(cwd: string, stateDir: string, def: SettingDef, value: SettingValue, check?: (routing: Routing) => void): Routing | undefined {
|
|
249
|
+
const file = join(cwd, ".agenthub", OVERLAY_FILE);
|
|
250
|
+
const before = readText(file);
|
|
251
|
+
const current = before === null ? {} : parseOverlay(before);
|
|
252
|
+
const held = def.path[0] === "stay_switch" ? current.stay_switch : (current.classes?.[def.path[1] as TaskClass] as Record<string, unknown> | undefined)?.[def.path[2]!];
|
|
253
|
+
if (value === null ? held === undefined : same(held, value)) return undefined;
|
|
254
|
+
const overlay = routingCandidate(current, def, value);
|
|
255
|
+
const routing = parseRouting(routingText(cwd), overlay);
|
|
256
|
+
check?.(routing);
|
|
257
|
+
const empty = !overlay.stay_switch && !Object.keys(overlay.classes ?? {}).length;
|
|
258
|
+
store(cwd, stateDir, OVERLAY_FILE, def.key, before, empty ? null : overlayToml(overlay));
|
|
259
|
+
return routing;
|
|
260
|
+
}
|
|
261
|
+
/** An overlay with one key set or removed: the candidate a write stores and a preview only reads. */
|
|
262
|
+
export function routingCandidate(current: RoutingOverlay, def: SettingDef, value: SettingValue): RoutingOverlay {
|
|
263
|
+
const overlay: RoutingOverlay = { ...(current.stay_switch ? { stay_switch: current.stay_switch } : {}), classes: Object.fromEntries(Object.entries(current.classes ?? {}).map(([name, entry]) => [name, { ...entry }])) };
|
|
264
|
+
if (def.path[0] === "stay_switch") {
|
|
265
|
+
if (value === null) delete overlay.stay_switch;
|
|
266
|
+
else overlay.stay_switch = value as RoutingOverlay["stay_switch"];
|
|
267
|
+
return overlay;
|
|
268
|
+
}
|
|
269
|
+
const [, name, field] = def.path as [string, TaskClass, "peers" | "escalate_to" | "route" | "pi_backend"];
|
|
270
|
+
const entry: Record<string, unknown> = { ...overlay.classes![name] };
|
|
271
|
+
if (value === null) delete entry[field];
|
|
272
|
+
else entry[field] = value;
|
|
273
|
+
if (Object.keys(entry).length) overlay.classes![name] = entry;
|
|
274
|
+
else delete overlay.classes![name];
|
|
275
|
+
return overlay;
|
|
276
|
+
}
|
|
277
|
+
/** The overlay on disk, or an empty one. */
|
|
278
|
+
export function readOverlay(cwd: string): RoutingOverlay {
|
|
279
|
+
const text = readText(join(cwd, ".agenthub", OVERLAY_FILE));
|
|
280
|
+
return text === null ? {} : parseOverlay(text);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Puts back the one previous version of the file the last settings write changed. Refused when the file changed since
|
|
285
|
+
* that write (a hand edit would be lost), when the loaders no longer accept that version, or when there is nothing to undo.
|
|
286
|
+
*/
|
|
287
|
+
export function undoSetting(cwd: string, stateDir: string, validate: (scratch: string) => unknown, checkRouting?: (routing: Routing) => void): { key: string; file: StoredFile } {
|
|
288
|
+
const raw = readText(undoFile(stateDir));
|
|
289
|
+
if (raw === null) throw new Error("nothing to undo");
|
|
290
|
+
let record: UndoRecord;
|
|
291
|
+
try { record = JSON.parse(raw) as UndoRecord; } catch { throw new Error("nothing to undo"); }
|
|
292
|
+
if (record.file !== CONFIG_LOCAL && record.file !== OVERLAY_FILE) throw new Error("nothing to undo");
|
|
293
|
+
const path = join(cwd, ".agenthub", record.file);
|
|
294
|
+
if (digest(readText(path)) !== record.written) throw new Error(`.agenthub/${record.file} changed since the last settings write; nothing was undone`);
|
|
295
|
+
if (record.previous !== null && typeof record.previous !== "string") throw new Error("nothing to undo");
|
|
296
|
+
refuseTracked(cwd, record.file);
|
|
297
|
+
if (record.file === OVERLAY_FILE) {
|
|
298
|
+
// The parser always reads what is put back; the caller's check is on top of it.
|
|
299
|
+
const restored = parseRouting(routingText(cwd), record.previous === null ? undefined : parseOverlay(record.previous));
|
|
300
|
+
checkRouting?.(restored);
|
|
301
|
+
}
|
|
302
|
+
else checkConfigText(cwd, record.previous, validate);
|
|
303
|
+
replaceFile(path, record.file, record.previous);
|
|
304
|
+
rmSync(undoFile(stateDir), { force: true });
|
|
305
|
+
return { key: String(record.key), file: record.file };
|
|
306
|
+
}
|
|
307
|
+
/** Whether the machine-local config file holds a value for this setting now. */
|
|
308
|
+
export function storedLocally(cwd: string, def: SettingDef): boolean {
|
|
309
|
+
try { return valueAt(JSON.parse(readFileSync(join(cwd, ".agenthub", CONFIG_LOCAL), "utf8")), def.path) !== undefined; } catch { return false; }
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* For a config setting: the value the config files would hold for it after the pending undo (the previous version of
|
|
313
|
+
* the machine-local file, else the shared file), or, with `inherit`, after the machine-local value is removed (the
|
|
314
|
+
* shared file's). Undefined when neither sets it or nothing reads.
|
|
315
|
+
*/
|
|
316
|
+
export function restoredValue(cwd: string, stateDir: string, def: SettingDef, inherit = false): unknown {
|
|
317
|
+
const parse = (text: string | null): unknown => { try { return text === null ? {} : JSON.parse(text); } catch { return {}; } };
|
|
318
|
+
let local: unknown = {};
|
|
319
|
+
if (!inherit) {
|
|
320
|
+
try { const record = JSON.parse(readFileSync(undoFile(stateDir), "utf8")) as UndoRecord; if (record.file !== CONFIG_LOCAL) return undefined; local = parse(record.previous); } catch { return undefined; }
|
|
321
|
+
}
|
|
322
|
+
return valueAt(local, def.path) ?? valueAt(parse(readText(join(cwd, ".agenthub", "config.json"))), def.path);
|
|
323
|
+
}
|
|
324
|
+
/** The key the next undo would put back, if any. */
|
|
325
|
+
export function pendingUndo(stateDir: string): string | undefined {
|
|
326
|
+
try { const record = JSON.parse(readFileSync(undoFile(stateDir), "utf8")) as UndoRecord; return typeof record.key === "string" ? record.key : undefined; } catch { return undefined; }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The loader's line saying it ignored this setting's field in the machine-local file, if it did (config trust, #17). */
|
|
330
|
+
export function ignoredLine(ignored: readonly string[] | undefined, def: SettingDef): string | undefined {
|
|
331
|
+
return ignored?.find((line) => {
|
|
332
|
+
const at = line.indexOf(` in .agenthub/${CONFIG_LOCAL} ignored:`);
|
|
333
|
+
return at > 0 && line.slice(0, at).split(", ").some((field) => field === def.path[0] || field === def.path.slice(0, 2).join("."));
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
export interface SettingRow {
|
|
337
|
+
key: string; group: SettingDef["group"]; label: string; type: SettingDef["type"]; values?: readonly string[];
|
|
338
|
+
/** The file a write goes to, or "this hub run". */
|
|
339
|
+
file: string;
|
|
340
|
+
applies: SettingDef["applies"]; risk: SettingDef["risk"]; floor?: string | boolean;
|
|
341
|
+
/** What is in force now; null when nothing sets it. */
|
|
342
|
+
value: SettingValue;
|
|
343
|
+
/** Which file the value in force comes from. */
|
|
344
|
+
source: string;
|
|
345
|
+
/** The stored value the next hub start reads, when it differs from the one in force. */
|
|
346
|
+
pending?: SettingValue;
|
|
347
|
+
/** Why the row cannot be changed now. */
|
|
348
|
+
note?: string;
|
|
349
|
+
/** For a permission row: the bounds the peer's tools run in, whatever the mode. */
|
|
350
|
+
hint?: string;
|
|
351
|
+
}
|
|
352
|
+
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* One row per setting, for the dashboard and `ahub settings list`. `running` is the configuration the hub started
|
|
356
|
+
* with, `next` what its loader reads from the files now (undefined when they do not load), `mode` a peer's mode now.
|
|
357
|
+
*/
|
|
358
|
+
export function settingRows(input: {
|
|
359
|
+
cwd: string; routing: Routing;
|
|
360
|
+
running: (path: readonly string[]) => unknown;
|
|
361
|
+
next: ((path: readonly string[]) => unknown) | undefined;
|
|
362
|
+
mode: (peer: string) => { value?: string; note?: string };
|
|
363
|
+
/** Why the overlay does not load, when it does not: the rows then show the last policy that did. */
|
|
364
|
+
routingProblem?: string;
|
|
365
|
+
/** The loader's own lines about machine-local fields it ignored (`HubConfig.ignored`). */
|
|
366
|
+
ignored?: string[];
|
|
367
|
+
}): SettingRow[] {
|
|
368
|
+
const raw = (name: string): unknown => { try { return JSON.parse(readFileSync(join(input.cwd, ".agenthub", name), "utf8")); } catch { return undefined; } };
|
|
369
|
+
const local = raw(CONFIG_LOCAL), shared = raw("config.json");
|
|
370
|
+
const projectRouting = existsSync(join(input.cwd, ".agenthub", "routing.toml"));
|
|
371
|
+
const routes = [...Object.keys(input.routing.routes), ...Object.keys(input.routing.hub_routes ?? {})];
|
|
372
|
+
const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" ? value : Array.isArray(value) ? value.map(String) : null;
|
|
373
|
+
return SETTINGS.map((def): SettingRow => {
|
|
374
|
+
const base = { key: def.key, group: def.group, label: def.label, type: def.type, applies: def.applies, risk: def.risk, ...(def.floor === undefined ? {} : { floor: def.floor }),
|
|
375
|
+
...(def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!) } : {}) };
|
|
376
|
+
if (def.store === "runtime") {
|
|
377
|
+
const mode = input.mode(def.path[0]!);
|
|
378
|
+
return { ...base, values: def.values!, file: "this hub run", value: mode.value ?? null, source: "this hub run", ...(mode.note ? { note: mode.note } : {}) };
|
|
379
|
+
}
|
|
380
|
+
if (def.store === "routing") {
|
|
381
|
+
const value = scalar(def.path[0] === "stay_switch" ? input.routing.stay_switch : (input.routing.classes[def.path[1] as TaskClass] as Record<string, unknown> | undefined)?.[def.path[2]!]);
|
|
382
|
+
return { ...base, ...(def.type === "enum" ? { values: def.values ?? routes } : {}), file: OVERLAY_FILE, value, source: input.routing.sources?.[def.key.slice("routing.".length)] ?? (value === null ? "default" : projectRouting ? "routing.toml" : "default"),
|
|
383
|
+
...(input.routingProblem ? { note: `the routing files do not load, so the last policy that did stays in force: ${input.routingProblem}` } : {}) };
|
|
384
|
+
}
|
|
385
|
+
const value = scalar(input.running(def.path));
|
|
386
|
+
const stored = input.next ? scalar(input.next(def.path)) : value;
|
|
387
|
+
const source = valueAt(local, def.path) !== undefined ? CONFIG_LOCAL : valueAt(shared, def.path) !== undefined ? "config.json" : "default";
|
|
388
|
+
const dropped = ignoredLine(input.ignored, def);
|
|
389
|
+
return { ...base, ...(def.values ? { values: def.values } : {}), file: CONFIG_LOCAL, value, source, ...(same(value, stored) ? {} : { pending: stored }),
|
|
390
|
+
...(!input.next ? { note: "the config files do not load; fix them in your editor" } : dropped ? { note: `the hub does not read this value: ${dropped}` } : {}) };
|
|
391
|
+
});
|
|
392
|
+
}
|
package/src/hub/snapshots.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
2
|
import { copyFileSync, existsSync, mkdtempSync, rmSync, statSync, utimesSync } from "node:fs";
|
|
4
3
|
import { tmpdir } from "node:os";
|
|
5
4
|
import { join } from "node:path";
|
|
6
5
|
import { childEnv } from "./child-process.ts";
|
|
7
6
|
import type { Touch } from "./conflicts.ts";
|
|
7
|
+
import { hubGitSync } from "./git.ts";
|
|
8
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Per-turn workspace snapshots (issue #33): git tree objects written through a copy of the index, so the user's index
|
|
@@ -15,7 +16,7 @@ import type { Touch } from "./conflicts.ts";
|
|
|
15
16
|
* of an older turn says so. Refs under refs/agenthub/ would keep them, at the cost of showing up in the user's repo.
|
|
16
17
|
*/
|
|
17
18
|
const git = (top: string, args: string[], env: Record<string, string> = {}) =>
|
|
18
|
-
|
|
19
|
+
hubGitSync(["-C", top, ...args], {
|
|
19
20
|
encoding: "utf8",
|
|
20
21
|
env: { ...childEnv(process.env), ...env },
|
|
21
22
|
// These run inside the bus's state change: a hung git (a huge new directory, a stuck filter) must not freeze the hub.
|
|
@@ -144,6 +145,7 @@ export class Turns {
|
|
|
144
145
|
private readonly db: Database;
|
|
145
146
|
constructor(path: string, readonly?: boolean) {
|
|
146
147
|
this.db = new Database(path, readonly ? { readonly: true } : { create: true });
|
|
148
|
+
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
147
149
|
if (readonly) return;
|
|
148
150
|
this.db.run("PRAGMA journal_mode = WAL");
|
|
149
151
|
this.db.run(`CREATE TABLE IF NOT EXISTS turns (id TEXT PRIMARY KEY, peer TEXT NOT NULL, started INTEGER NOT NULL, ended INTEGER,
|