@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
@@ -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
+ }
@@ -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
- spawnSync("git", ["-C", top, ...args], {
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,
@@ -0,0 +1,48 @@
1
+ /**
2
+ * #269: how a peer comes up when the hub starts it: its own TUI in a terminal, or headless. A person who types
3
+ * `ahub <peer>` gets the same default and can override it with a flag.
4
+ */
5
+ /** Shared terminal-start guard and dashboard retry hold after an unconfirmed response. */
6
+ export const PEER_START_RETRY_MS = 30_000;
7
+ export const START_MODES = ["tui", "headless"] as const;
8
+ export type StartMode = typeof START_MODES[number];
9
+ /** The peers with both forms. Claude has only a TUI; Kimi (ACP) and the local worker have none the hub attaches to. */
10
+ export const START_MODE_PEERS = ["pi", "codex"] as const;
11
+ /** Every peer the hub can start itself. */
12
+ export const STARTABLE_PEERS = ["claude", "codex", "kimi", "pi", "local"] as const;
13
+ export type StartablePeer = typeof STARTABLE_PEERS[number];
14
+ export const isStartablePeer = (value: unknown): value is StartablePeer => typeof value === "string" && (STARTABLE_PEERS as readonly string[]).includes(value);
15
+
16
+ export type PeerStartConfig = Record<string, { start_mode?: StartMode }>;
17
+ /** The checked `peers` block of the config; throws on anything but `{ pi|codex: { start_mode: "tui"|"headless" } }`. */
18
+ export function peerStartConfig(value: unknown): PeerStartConfig {
19
+ if (value === undefined) return {};
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("peers must be an object of per-peer settings");
21
+ const result: PeerStartConfig = {};
22
+ for (const [peer, block] of Object.entries(value)) {
23
+ if (!(START_MODE_PEERS as readonly string[]).includes(peer)) throw new Error(`peers.${peer}: only ${START_MODE_PEERS.join(" and ")} have a start mode`);
24
+ if (!block || typeof block !== "object" || Array.isArray(block)) throw new Error(`peers.${peer} must be an object`);
25
+ const mode = (block as { start_mode?: unknown }).start_mode;
26
+ if (mode !== undefined && !(START_MODES as readonly unknown[]).includes(mode)) throw new Error(`peers.${peer}.start_mode must be tui or headless`);
27
+ result[peer] = mode === undefined ? {} : { start_mode: mode as StartMode };
28
+ }
29
+ return result;
30
+ }
31
+
32
+ /** The mode a start of `peer` takes when nothing more specific was asked: `tui` wherever the peer has one. */
33
+ export function startModeOf(peers: PeerStartConfig | undefined, peer: string): StartMode {
34
+ if (peer === "claude") return "tui";
35
+ if ((START_MODE_PEERS as readonly string[]).includes(peer)) return peers?.[peer]?.start_mode ?? "tui";
36
+ return "headless";
37
+ }
38
+
39
+ /**
40
+ * The checked `terminal.open` template: an argv in which exactly one element contains `{command}` (replaced by the
41
+ * shell-quoted command the hub assembled); `{title}` and `{cwd}` are replaced too. Empty means no template.
42
+ */
43
+ export function terminalTemplate(value: unknown): string[] {
44
+ if (value === undefined || value === null) return [];
45
+ if (!Array.isArray(value) || value.some((part) => typeof part !== "string" || !part)) throw new Error("terminal.open must be a list of non-empty strings (an argv with a {command} placeholder)");
46
+ if (value.length && value.filter((part: string) => part.includes("{command}")).length !== 1) throw new Error("terminal.open needs {command} in exactly one element");
47
+ return [...value];
48
+ }