@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
package/src/hub/daemon.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
1
2
  import { buildKimiLaunch } from "../cli/launch.ts";
2
3
  import { ProgressObserver, normalizeCodexObservation, normalizeClaudeObservation } from "./progress.ts";
3
4
  import type { ToolObservation } from "../models/route/signals.ts";
@@ -36,6 +37,7 @@ import { HUB } from "./envelope.ts";
36
37
  import { trimToTokens } from "../memory/recall.ts";
37
38
  import { closeSync, constants as fsConstants, openSync, readSync, statSync } from "node:fs";
38
39
  import { basename, isAbsolute } from "node:path";
40
+ import { fileURLToPath } from "node:url";
39
41
  import { realPath } from "./project.ts";
40
42
  import type { BusEvent } from "./bus.ts";
41
43
  import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOLS } from "./hub-tools.ts";
@@ -43,14 +45,15 @@ import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, public
43
45
  import { SupervisionFeed } from "./supervision.ts";
44
46
  import { drainCliAudits } from "../cli/identity-audit.ts";
45
47
  import { launcherPreview } from "../cli/preview.ts";
46
- import { Tasks } from "./tasks.ts";
48
+ import { AssignmentUndeliveredError, Tasks } from "./tasks.ts";
47
49
  import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
48
50
  import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
49
- import { currentRouting, detectSignals } from "./routing.ts";
51
+ import { currentRouting, detectSignals, OVERLAY_FILE, parseRouting, routingText, type Routing } from "./routing.ts";
52
+ import { checkSettingValue, ignoredLine, pendingUndo, readOverlay, restoredValue, routingCandidate, storedLocally, settingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue } from "./settings.ts";
50
53
  import { Bus } from "./bus.ts";
51
54
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
52
55
  import { DeliveryJournal } from "./delivery-journal.ts";
53
- import { startDashboard } from "./ui.ts";
56
+ import { startDashboard, type DashboardSession } from "./ui.ts";
54
57
  import { PROTOCOL, stateDirFor } from "./control-client.ts";
55
58
  import { newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
56
59
  import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
@@ -67,8 +70,10 @@ import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./li
67
70
  import { changedPaths, repoOf, snapshot, Turns, type TurnRecord } from "./snapshots.ts";
68
71
  import { archiveRestartSnapshot, readRecoveryWaivers, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
69
72
 
70
- import { grantablePath, isPermissionMode, permissionDefaults, PI_EDIT_TOOLS, type PermissionMode } from "./permission-mode.ts";
71
- import { ContextWindows, DEFAULT_CONTEXT, claudeContext, type ContextConfig } from "./context-window.ts";
73
+ import { isStartablePeer, peerStartConfig, PEER_START_RETRY_MS, STARTABLE_PEERS, startModeOf, terminalTemplate, type PeerStartConfig, type StartMode } from "./start-mode.ts";
74
+ import type { TerminalOpener } from "./terminal-open.ts";
75
+ import { grantablePath, isPermissionMode, permissionBoundary, permissionDefaults, permissionGrant, PI_EDIT_TOOLS, type PermissionMode } from "./permission-mode.ts";
76
+ import { ContextWindows, DEFAULT_CONTEXT, acpContext, claudeContext, type ContextConfig } from "./context-window.ts";
72
77
 
73
78
  export interface HubConfig {
74
79
  watchdog_ms: number;
@@ -105,6 +110,10 @@ export interface HubConfig {
105
110
  research: { enabled: boolean };
106
111
  /** #251: a project kept for benchmarks: `ahub bench run` resets its work tree between attempts. */
107
112
  bench: { enabled: boolean };
113
+ /** #269: per peer, how a start comes up when nothing more specific was asked: `tui` (the default) or `headless`. Pi and Codex only. */
114
+ peers: PeerStartConfig;
115
+ /** #269: a machine-local argv that opens a terminal for a hub-made TUI start; `{command}` in one element is the command to run. */
116
+ terminal: { open: string[] };
108
117
  /** Per-sender rate limits and repeat suppression for what agents send (issue #38). */
109
118
  limits: LimitsConfig;
110
119
  /** Reviewer choice from recorded review outcomes, once a reviewer has `min_reviews` of an implementer (issue #35). */
@@ -152,6 +161,8 @@ export const DEFAULT_CONFIG: HubConfig = {
152
161
  snapshots: { enabled: false, keep: 20 },
153
162
  research: { enabled: false },
154
163
  bench: { enabled: false },
164
+ peers: {},
165
+ terminal: { open: [] },
155
166
  limits: DEFAULT_LIMITS,
156
167
  review: { adaptive: false, min_reviews: 5 },
157
168
  recovery: { auto_resume_after_crash: false },
@@ -167,7 +178,7 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
167
178
 
168
179
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
169
180
  const CONFIG_FILES = ["config.json", "config.local.json"] as const;
170
- const CONFIG_BLOCKS = ["memory", "roles", "conductor", "budget", "context", "inference", "omniroute", "local", "pi", "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes"];
181
+ const CONFIG_BLOCKS = ["memory", "roles", "conductor", "budget", "context", "inference", "omniroute", "local", "pi", "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal"];
171
182
 
172
183
  /** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
173
184
  export function loadConductorPolicy(cwd: string): { roles: Record<string, string[]>; conductor: HubConfig["conductor"] } {
@@ -207,6 +218,9 @@ export function loadConfig(cwd: string): HubConfig {
207
218
  throw error;
208
219
  }
209
220
  permissionDefaults(file.permission_modes); // malformed declarations fail even when trust would drop them
221
+ peerStartConfig(file.peers);
222
+ if (file.terminal != null && (typeof file.terminal !== "object" || Array.isArray(file.terminal))) throw new Error("terminal must be an object");
223
+ terminalTemplate(file.terminal?.open);
210
224
  delete file.permission_default_sources; // provenance is loader-owned, never a file claim
211
225
  const why = stripUntrusted(file, DEFAULT_CONFIG, cwd, name);
212
226
  if (why) ignored.push(why);
@@ -261,6 +275,8 @@ export function loadConfig(cwd: string): HubConfig {
261
275
  snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
262
276
  research: { enabled: file.research?.enabled === true }, // opt-in: anything but true is off
263
277
  bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
278
+ peers: peerStartConfig(file.peers),
279
+ terminal: { open: terminalTemplate(file.terminal?.open) },
264
280
  limits: { ...PROJECT_LIMITS, ...file.limits }, // on with any project config (issue #38)
265
281
  review: { ...DEFAULT_CONFIG.review, ...file.review },
266
282
  recovery: { ...DEFAULT_CONFIG.recovery, ...file.recovery },
@@ -280,9 +296,15 @@ export function mlxLaunchProblem(config: HubConfig, args: { backend?: unknown; m
280
296
 
281
297
  /** Shipped defaults remain capability-aware; operator-written MLX pins require an explicit migration. */
282
298
  function disabledMlxPolicyProblem(config: HubConfig, cwd: string): string | undefined {
283
- if (config.mlx.enabled !== false || !existsSync(join(cwd, ".agenthub", "routing.toml"))) return undefined;
284
- const conflict = Object.entries(currentRouting(cwd).classes).find(([, policy]) => policy?.pi_backend === "mlx");
285
- return conflict ? `routing.toml: [classes.${conflict[0]}] pi_backend=mlx conflicts with mlx.enabled=false; remove the pin for hub/auto or select dgx` : undefined;
299
+ if (config.mlx.enabled !== false) return undefined;
300
+ const project = existsSync(join(cwd, ".agenthub", "routing.toml"));
301
+ if (!project && !existsSync(join(cwd, ".agenthub", OVERLAY_FILE))) return undefined;
302
+ return mlxPinProblem(currentRouting(cwd), project);
303
+ }
304
+ /** A pin a person wrote, in routing.toml or the machine-local overlay (#269); the shipped template's own pins are not theirs to migrate. */
305
+ function mlxPinProblem(routing: Routing, project: boolean): string | undefined {
306
+ const conflict = Object.entries(routing.classes).find(([name, policy]) => policy?.pi_backend === "mlx" && (project || routing.sources?.[`classes.${name}.pi_backend`]));
307
+ return conflict ? `${routing.sources?.[`classes.${conflict[0]}.pi_backend`] ?? "routing.toml"}: [classes.${conflict[0]}] pi_backend=mlx conflicts with mlx.enabled=false; remove the pin for hub/auto or select dgx` : undefined;
286
308
  }
287
309
 
288
310
  export interface DaemonOptions {
@@ -297,6 +319,8 @@ export interface DaemonOptions {
297
319
  switchyardPort?: number;
298
320
  switchyardBin?: string;
299
321
  config?: HubConfig;
322
+ /** #269: the terminal providers for a TUI start the hub makes itself, built from `terminal.open`. Absent: none, and such a start is refused. */
323
+ terminal?: (template: string[]) => TerminalOpener;
300
324
  /** Auto-approve ACP permission requests with the agent's allow_once option. */
301
325
  unattended?: boolean;
302
326
  permissionTimeoutMs?: number;
@@ -474,6 +498,12 @@ export async function startDaemon(opts: DaemonOptions) {
474
498
  parsed = JSON.parse(String(data));
475
499
  onMessage(sock, parsed);
476
500
  } catch (e) {
501
+ if (isDatabaseBusy(e) && sock.data.authed && Number.isSafeInteger(parsed?.rid)) {
502
+ const busy = new RecoveryDatabaseBusy();
503
+ try { log(`control ${sanitize(String(parsed.t))} database contention: ${databaseBusySite(e).join("\n")}`); } catch { /* the reply still settles its rid */ }
504
+ sock.send(JSON.stringify({ rid: parsed.rid, ok: false, error: busy.message, code: busy.code, retryable: busy.retryable }));
505
+ return;
506
+ }
477
507
  log("control operation failed; inspect delivery storage and recovery status");
478
508
  if (sock.data.authed && Number.isSafeInteger(parsed?.rid)) sock.send(JSON.stringify({ rid: parsed.rid, ok: false, error: "operation failed; inspect delivery storage and recovery status before retrying" }));
479
509
  }
@@ -1020,9 +1050,25 @@ export async function startDaemon(opts: DaemonOptions) {
1020
1050
  reading: (peer, windows, hard, at) => event({ type: "quota", peer, windows: windows.map((w) => ({ id: w.id, used: w.used, ...(w.resetsAt ? { resetsAt: w.resetsAt } : {}) })), hard, ...(Math.abs(Date.now() - at) > 1000 ? { measuredAt: new Date(at).toISOString() } : {}) }),
1021
1051
  handoff: (peer, context, urgentOnly) => tasks.reassignForPause(peer, context, urgentOnly),
1022
1052
  resumed: (record) => {
1053
+ // Coalesce unread notices without forgetting the public moves they carried.
1054
+ const moves = new Map<string, typeof record.moved[number]>();
1055
+ const earlier: string[] = [];
1056
+ const superseded: string[] = [];
1057
+ for (const env of bus.snapshot(false).queues[record.peer] ?? []) {
1058
+ if (env.from !== HUB || env.kind !== "budget" || env.refs?.budgetResume !== record.peer) continue;
1059
+ for (const move of env.refs.budgetResumeMoves ?? []) moves.set(JSON.stringify([move.id, move.role, move.to, move.title]), move);
1060
+ earlier.push(...(env.refs.budgetResumeEarlier ?? []));
1061
+ // A notice written by an older hub has no structured moves: retain its whole public text.
1062
+ if (!env.refs.budgetResumeMoves) earlier.push(env.body);
1063
+ superseded.push(env.id);
1064
+ }
1065
+ for (const move of record.moved) moves.set(JSON.stringify([move.id, move.role, move.to, move.title]), move);
1066
+ const unreadMoves = [...moves.values()];
1023
1067
  if (record.peer === "kimi") kimiTokens.length = 0; // a new window: the old counts would pause it again at once
1024
- const moved = record.moved.length ? `While you were paused these moved: ${record.moved.map((m) => `${m.title} (${m.role} -> ${m.to ?? "nobody"})`).join("; ")}. They stay where they are; ask the user if you should take one back.` : "Nothing was moved while you were paused.";
1025
- bus.publish(newEnvelope(HUB, `Your quota window has reset and the hub has resumed you (paused since ${new Date(record.since).toLocaleTimeString()}, ${record.reason}). ${moved} Messages queued for you follow.`, { to: [record.peer], kind: "budget", priority: "important" }));
1068
+ const moved = unreadMoves.length ? `While you were paused these moved: ${unreadMoves.map((m) => `${m.title} (${m.role} -> ${m.to ?? "nobody"})`).join("; ")}. They stay where they are; ask the user if you should take one back.` : earlier.length ? "Moves from earlier pauses are preserved in the unread notices below." : "Nothing was moved while you were paused.";
1069
+ bus.publish(newEnvelope(HUB, `Your quota window has reset and the hub has resumed you (paused since ${new Date(record.since).toLocaleTimeString()}, ${record.reason}). ${moved}${earlier.length ? ` Earlier unread resume notices: ${earlier.join("\n")}` : ""} Messages queued for you follow.`, { to: [record.peer], kind: "budget", priority: "important", refs: { budgetResume: record.peer, budgetResumeMoves: unreadMoves, ...(earlier.length ? { budgetResumeEarlier: earlier } : {}) } }));
1070
+ // Publish the complete replacement before removing its evidence. A failed write keeps the old notices.
1071
+ for (const id of superseded) bus.withdraw(id);
1026
1072
  },
1027
1073
  notify: (line) => { notify(line); feedBudgetPauses(); },
1028
1074
  });
@@ -1124,7 +1170,9 @@ export async function startDaemon(opts: DaemonOptions) {
1124
1170
  for (const peer of bus.knownPeers()) {
1125
1171
  contexts.retry(peer, contextSession(peer));
1126
1172
  const reading = contexts.view(peer, contextSession(peer), bus.peers.has(peer) && ["idle", "busy", "paused"].includes(bus.stateOf(peer)));
1127
- const stamp = JSON.stringify(reading);
1173
+ // An unchanged reading is not repeated (#309): the stamp is what the printed line says apart from its
1174
+ // time — the percentage as printed (or unknown), the freshness and the source — never measuredAt or tokens.
1175
+ const stamp = `${reading.used === null ? "unknown" : Math.round(reading.used * 100)}|${reading.freshness}|${reading.source}`;
1128
1176
  if (contextTailSeen.get(peer) !== stamp) {
1129
1177
  contextTailSeen.set(peer, stamp);
1130
1178
  for (const c of consoles) if (c.data.tail) c.send(JSON.stringify({ t: "context", peer, reading }));
@@ -1739,6 +1787,8 @@ export async function startDaemon(opts: DaemonOptions) {
1739
1787
  if (r.ok) { report(`pi resumed (pi.auto_start): ${step.how}`); continue; }
1740
1788
  report(`pi not resumed (${String(r.error)}); ${step.how}`);
1741
1789
  } else report(step.how);
1790
+ // #269: a Pi that ran in a terminal is not replaced by a headless one unless headless is its start mode.
1791
+ if (step.tui && startMode("pi") === "tui") { report("pi.auto_start starts no headless Pi in place of a terminal one: run the command above, or set peers.pi.start_mode to headless"); continue; }
1742
1792
  const fresh = await start("pi", { ...(step.fresh as Parameters<typeof startPeer>[1]), fresh: true });
1743
1793
  const back = step.tui ? `; to go back to the recorded session, run ahub kill, start the hub without pi.auto_start, then the command above` : "";
1744
1794
  report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`);
@@ -1835,7 +1885,11 @@ export async function startDaemon(opts: DaemonOptions) {
1835
1885
  if (task?.owner === e.peer && ["proposed", "in_progress", "changes_requested"].includes(task.state)) {
1836
1886
  const reason = tasks.isPii(task) ? "private delivery retries exhausted" : sanitize(e.reason ?? "delivery retries exhausted").replace(/\s+/g, " ").slice(0, 300);
1837
1887
  notify(`task ${tasks.publicTitle(task)}: undeliverable to ${e.peer}: ${reason}; escalating`);
1838
- void tasks.escalate(HUB, task.id, `Undeliverable to ${e.peer}: ${reason}`, "delivery_failed").catch(() => notify(`task ${tasks.publicTitle(task)} could not be escalated; inspect with ahub task show ${task.id}`));
1888
+ void tasks.escalate(HUB, task.id, `Undeliverable to ${e.peer}: ${reason}`, "delivery_failed").catch((err) => {
1889
+ // A saved move whose delivery failed was already noticed where the error was thrown; anything else is
1890
+ // an escalation that did not happen (#297).
1891
+ if (!(err instanceof AssignmentUndeliveredError)) notify(`task ${tasks.publicTitle(task)} could not be escalated; inspect with ahub task show ${task.id}`);
1892
+ });
1839
1893
  }
1840
1894
  }
1841
1895
  } else {
@@ -1937,6 +1991,10 @@ export async function startDaemon(opts: DaemonOptions) {
1937
1991
  let piAutoRestartAt = -Infinity;
1938
1992
  let piAutoRestartPending = false;
1939
1993
  const replacingPi = new Set<PiPeer>();
1994
+ /** pi.auto_start at hub start (#269): in Pi's start mode, and said out loud when it could not be done. */
1995
+ const autoStartPi = () => void hubStart("pi", "pi.auto_start").then(
1996
+ (result) => { if (result.ok !== true) notify(`pi.auto_start did not start Pi: ${String(result.error)}`); },
1997
+ (error) => log(`Pi auto-start failed: ${(error as Error).message}`));
1940
1998
  async function stopPiForReplacement(pi: PiPeer): Promise<void> {
1941
1999
  replacingPi.add(pi);
1942
2000
  try { await pi.stop(); } finally { replacingPi.delete(pi); }
@@ -1964,6 +2022,56 @@ export async function startDaemon(opts: DaemonOptions) {
1964
2022
  return running;
1965
2023
  }
1966
2024
 
2025
+ // #269: start modes. A start the hub makes itself (the dashboard's Start, `peer_start` from a console child, the
2026
+ // conductor, pi.auto_start) follows the peer's start mode. A TUI needs a terminal: a provider opens one running the
2027
+ // fixed `ahub <peer>` command, and with no provider the start is refused with that command. It is never downgraded.
2028
+ const opener: TerminalOpener = opts.terminal?.(config.terminal.open) ?? { available: () => ({ ok: false, why: "this hub was started without a terminal provider" }), open: async () => ({ ok: false, why: "this hub was started without a terminal provider" }) };
2029
+ const CLI_ENTRY = fileURLToPath(new URL("../cli/main.ts", import.meta.url));
2030
+ /** `config.peers` follows a settings write (`settingsSet`), so a changed mode applies at the peer's next start. */
2031
+ const startMode = (peer: string): StartMode => startModeOf(config.peers, peer);
2032
+ const startWords = (peer: string): string[] => peer === "pi" ? ["pi", "--mode", "tui"] : [peer];
2033
+ const startable = (peer: string) => peer !== "pi" || config.pi.enabled;
2034
+ // ponytail: one terminal per peer per 30 s, by the clock. The hub cannot see whether the terminal it opened still
2035
+ // exists, so a peer that never attaches can be opened again after that; track the provider's handle to do better.
2036
+ const opening = new Map<string, number>();
2037
+ /** For the dashboard's Start control: what a start of each peer would do now, and for a TUI where its terminal comes from. */
2038
+ // ponytail: asks the provider on every dashboard poll (a launch-record read and a PATH lookup for Orca); cache it for a few seconds if a page feels it.
2039
+ const startPlans = () => STARTABLE_PEERS.filter(startable).map((peer) => {
2040
+ const mode = startMode(peer), owner = bus.peers.get(peer), can = mode === "tui" ? opener.available() : undefined;
2041
+ return { peer, mode, attached: !!owner && owner.state !== "offline", command: `ahub ${startWords(peer).join(" ")}`, ...(can ? (can.ok ? { via: can.via } : { why: can.why }) : {}) };
2042
+ });
2043
+ async function hubStart(peer: unknown, by: string): Promise<Record<string, unknown>> {
2044
+ if (stopping) return { ok: false, error: "hub is stopping" };
2045
+ if (!isStartablePeer(peer) || !startable(peer)) return { ok: false, error: "unknown peer; the hub starts claude, codex, kimi, pi (when enabled) and local" };
2046
+ if (recoveryActive()) return { ok: false, error: "recovery is holding mutations" };
2047
+ const mode = startMode(peer);
2048
+ const owner = bus.peers.get(peer);
2049
+ if (mode === "headless") {
2050
+ // A Pi a person runs in a terminal is theirs: a start made for them never takes it over as a headless one.
2051
+ // ponytail: an attached terminal Pi only. A terminal launch that the TUI has not claimed yet reads as offline,
2052
+ // like one whose terminal was closed, so a headless start in that window replaces it; record when a launch was
2053
+ // issued to tell the two apart.
2054
+ if (owner instanceof PiPeer && owner.state !== "offline" && owner.mode === "tui") return { ok: false, error: "Pi runs in a terminal; end it there before starting a headless one", command: "ahub pi --headless" };
2055
+ return startPeer(peer, peer === "pi" ? { mode: "headless" } : {});
2056
+ }
2057
+ if (owner && owner.state !== "offline") return { ok: true, already: true, mode };
2058
+ const command = `ahub ${startWords(peer).join(" ")}`;
2059
+ const since = Date.now() - (opening.get(peer) ?? 0);
2060
+ if (since < PEER_START_RETRY_MS) return { ok: false, error: `a terminal for ${peer} was opened ${Math.round(since / 1000)}s ago; wait for its TUI, or run ${command} yourself`, command };
2061
+ // Reserved before the provider is awaited, so starts that arrive together open one terminal, not one each.
2062
+ opening.set(peer, Date.now());
2063
+ // Every word is the hub's own: its runtime, its entry point, this project and a peer from the closed list.
2064
+ const opened = await opener.open(`${peer} (agent-hub)`, [process.execPath, CLI_ENTRY, "--project", opts.cwd, ...startWords(peer)]).catch((error: Error) => ({ ok: false as const, why: "the terminal provider failed", detail: error.message }));
2065
+ if (!opened.ok) {
2066
+ opening.delete(peer); // nothing was opened: the next request may try again at once
2067
+ if (opened.detail) log(`terminal provider for ${peer}: ${opened.detail}`);
2068
+ const headless = peer === "claude" ? "" : `, or set its start mode to headless (ahub settings set peers.${peer}.start_mode headless)`;
2069
+ return { ok: false, error: `${peer} starts in its TUI and the hub could not open a terminal for it (${opened.why}). Run ${command} in a terminal${headless}`, command };
2070
+ }
2071
+ try { notify(`${peer}: ${by} opened a terminal running ${command} (${opened.via}); it attaches when its TUI is ready`); } catch { /* the terminal is open either way */ }
2072
+ return { ok: true, mode, opened: opened.via, command };
2073
+ }
2074
+
1967
2075
  async function stopPeer(peer: unknown): Promise<Record<string, unknown>> {
1968
2076
  if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding peer stops; finish the operation before retrying" };
1969
2077
  if (typeof peer !== "string" || !PEER_ID.test(peer)) return { ok: false, error: "unknown peer; run ahub status and choose a hub-owned headless peer" };
@@ -2089,6 +2197,25 @@ export async function startDaemon(opts: DaemonOptions) {
2089
2197
  autoApprove: (title) => !stopping && HUB_TOOL_TITLES.has(title),
2090
2198
  log,
2091
2199
  onTokens: onKimiTokens,
2200
+ // Pause synchronously before the adapter requeues a provider-refused prompt (#306).
2201
+ onQuotaRefusal: (window) => {
2202
+ budget.report("kimi", [window], true);
2203
+ return !!budget.record("kimi") && bus.isPaused("kimi");
2204
+ },
2205
+ // Kimi's usage_update is context occupancy ({used, size}), not consumption (#167): it becomes the peer's
2206
+ // context reading (#285 phase 1), never a tokens event or a budget window. The reading is bound to the
2207
+ // adapter's own session id; report() drops it when that is not the daemon's current session. An invalid
2208
+ // shape reports a null reading: unknown, not the previous value (#285).
2209
+ onUsageDiagnostic: (observation, sessionId) => {
2210
+ if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
2211
+ const current = contextSession("kimi");
2212
+ if (!current) return;
2213
+ contexts.report("kimi",
2214
+ observation.contextUsed !== undefined && observation.contextCapacity !== undefined
2215
+ ? acpContext({ used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now())
2216
+ : { source: "acp_usage_update", sessionId, measuredAt: Date.now(), tokens: null, window: null, used: null },
2217
+ current);
2218
+ },
2092
2219
  onTurnFailure: () => { supervisionTurns.delete("kimi"); },
2093
2220
  mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv("kimi")).map(([name, value]) => ({ name, value })) }],
2094
2221
  preamble: roleContract("kimi", config.roles),
@@ -2321,7 +2448,8 @@ export async function startDaemon(opts: DaemonOptions) {
2321
2448
  const task = board.get(Number(id));
2322
2449
  if (!task || task.owner !== "pi" || tasks.isPii(task) || !["proposed", "in_progress", "changes_requested"].includes(task.state)) continue;
2323
2450
  try { await tasks.escalate(HUB, task.id, "Pi inference failed after accepting the turn. Prior tool effects may be partial or uncertain. Inspect the working tree and Pi session before continuing; do not blindly repeat writes or commands.", "inference_failed"); }
2324
- catch { notify(`Pi task #${task.id} could not be escalated; inspect it with ahub task show`); }
2451
+ // A saved move whose delivery failed was already noticed where the error was thrown (#297).
2452
+ catch (err) { if (!(err instanceof AssignmentUndeliveredError)) notify(`Pi task #${task.id} could not be escalated; inspect it with ahub task show`); }
2325
2453
  }
2326
2454
  if (!ids.length) notify("Pi inference failed; inspect its session before retrying any effects");
2327
2455
  },
@@ -2494,10 +2622,10 @@ export async function startDaemon(opts: DaemonOptions) {
2494
2622
  launcherPreview(peer, peer === "pi" ? ["--mode", "tui"] : [], opts.cwd, opts.stateDir, false);
2495
2623
  return `ahub ${peer}${peer === "pi" ? " --mode tui" : ""}`;
2496
2624
  },
2625
+ // #269: the conductor's start is the hub's own: the peer's start mode decides, never the caller.
2626
+ startMode: peer => startMode(peer),
2497
2627
  start: async peer => {
2498
- const existing = bus.peers.get(peer);
2499
- if (existing instanceof PiPeer && (existing.recoveryMetadata().launch as { mode?: string })?.mode === "tui") throw new Error("Pi owns a TUI; ask the person to change its mode");
2500
- const result = await startPeer(peer, { mode: "headless" });
2628
+ const result = await hubStart(peer, "the conductor");
2501
2629
  if (result.ok !== true) throw new Error(String(result.error ?? "peer start failed"));
2502
2630
  return result;
2503
2631
  },
@@ -2549,7 +2677,7 @@ export async function startDaemon(opts: DaemonOptions) {
2549
2677
  arm: rs[0]!.header.arm, runs: rs.length, measures: benchReport(rs).overall })) } };
2550
2678
  } catch { return {}; } // an unreadable store never breaks the dashboard
2551
2679
  }
2552
- function uiSnapshot(after: number) {
2680
+ function uiSnapshot(after: number, _input?: Record<string, unknown>, session?: DashboardSession) {
2553
2681
  const quota = budget.status();
2554
2682
  const peers = [...new Set([...Object.keys(quota), ...bus.knownPeers()])];
2555
2683
  return {
@@ -2573,12 +2701,157 @@ export async function startDaemon(opts: DaemonOptions) {
2573
2701
  }),
2574
2702
  events: uiEvents.filter((e) => e.seq > after),
2575
2703
  cursor: uiSequence,
2704
+ ...settingsSnapshot(session),
2705
+ starts: startPlans(),
2576
2706
  ...benchView(),
2577
2707
  };
2578
2708
  }
2579
2709
 
2580
- async function uiAction(a: Record<string, unknown>): Promise<unknown> {
2710
+ // #269: settings. One registry (src/hub/settings.ts) serves the dashboard and `ahub settings`; nothing outside it is written.
2711
+ let storedConfigCache: { stamp: string; config: HubConfig | undefined } | undefined;
2712
+ /** What the config files hold now, as the loader reads them. Re-read only when a file changed: the dashboard polls. */
2713
+ const storedConfig = (): HubConfig | undefined => {
2714
+ const stamp = CONFIG_FILES.map((name) => { try { const s = statSync(join(opts.cwd, ".agenthub", name)); return `${s.mtimeMs}:${s.size}`; } catch { return "-"; } }).join("|");
2715
+ if (storedConfigCache?.stamp !== stamp) {
2716
+ let loaded: HubConfig | undefined;
2717
+ try { loaded = loadConfig(opts.cwd); } catch { /* the rows say that the files do not load */ }
2718
+ storedConfigCache = { stamp, config: loaded };
2719
+ }
2720
+ return storedConfigCache.config;
2721
+ };
2722
+ /** Start modes are the one stored setting a running hub takes up: after a settings write, as the files now read. */
2723
+ const applyStartModes = () => { const stored = storedConfig(); if (stored) config.peers = stored.peers; };
2724
+ const settingScalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" ? value : Array.isArray(value) ? value.map(String) : null;
2725
+ const routingValue = (routing: ReturnType<typeof currentRouting>, def: SettingDef): SettingValue =>
2726
+ settingScalar(def.path[0] === "stay_switch" ? routing.stay_switch : (routing.classes[def.path[1] as TaskClass] as Record<string, unknown> | undefined)?.[def.path[2]!]);
2727
+ /** For the dashboard: the rows, and until when this session may raise. A project whose files do not read still gets its dashboard. */
2728
+ // ponytail: reads the two config files and the undo record and parses the routing files on each dashboard poll; cache by mtime if a page ever feels it.
2729
+ function settingsSnapshot(session?: DashboardSession): { settings?: Record<string, unknown> } {
2730
+ try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}) } }; }
2731
+ catch { return {}; }
2732
+ }
2733
+ function settingsView(): { rows: SettingRow[]; undo?: string } {
2734
+ const stored = storedConfig();
2735
+ // What the loader would say about the files as they are: an overlay that does not parse, or one that no longer
2736
+ // merges (it names a route routing.toml dropped), leaves the last good policy in force, and the rows say why.
2737
+ let routingProblem: string | undefined;
2738
+ try { parseRouting(routingText(opts.cwd), readOverlay(opts.cwd)); } catch (error) { routingProblem = (error as Error).message; }
2739
+ const rows = settingRows({
2740
+ cwd: opts.cwd, routing: currentRouting(opts.cwd, log), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
2741
+ running: (path) => valueAt(config, path),
2742
+ next: stored ? (path) => valueAt(stored, path) : undefined,
2743
+ mode: (peer) => {
2744
+ const owner = bus.peers.get(peer), attached = !!owner && owner.state !== "offline";
2745
+ const note = pendingPermissionDefaults.has(peer) ? "a never-ask default waits for y in ahub console" : attached ? undefined : "not attached: only ask can be set";
2746
+ return { value: attached ? permissionDisplay(peer, owner) : permissionMode(peer), ...(note ? { note } : {}) };
2747
+ },
2748
+ });
2749
+ const undo = pendingUndo(opts.stateDir);
2750
+ return { rows, ...(undo ? { undo } : {}) };
2751
+ }
2752
+ /** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
2753
+ const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false) => {
2754
+ event({ type: "settings", key, from, to, source, ...(undo ? { undo: true } : {}) });
2755
+ try { notify(`setting ${key}: ${settingText(from)} -> ${settingText(to)} (${undo ? "undo, " : ""}${source})`); } catch { /* the write stands */ }
2756
+ };
2757
+ const settingSource = (authority: SettingAuthority) => authority === "terminal" ? "terminal" as const : "dashboard" as const;
2758
+ /** A checked definition and value, or the refusal. Every settings write and preview starts here. */
2759
+ function settingInput(key: unknown, raw: unknown): { def: SettingDef; value: SettingValue } | { error: string } {
2760
+ const def = settingDef(key);
2761
+ if (typeof def === "string") return { error: def };
2762
+ const routing = currentRouting(opts.cwd, log);
2763
+ try { return { def, value: checkSettingValue(def, raw, [...Object.keys(routing.routes), ...Object.keys(routing.hub_routes ?? {})]) }; }
2764
+ catch (error) { return { error: (error as Error).message }; }
2765
+ }
2766
+ async function settingsSet(key: unknown, raw: unknown, authority: SettingAuthority, confirm?: unknown): Promise<Record<string, unknown>> {
2767
+ const input = settingInput(key, raw);
2768
+ if ("error" in input) return { ok: false, error: input.error };
2769
+ const { def, value } = input;
2770
+ const refusal = settingRefusal(def, value, authority);
2771
+ if (refusal) return { ok: false, error: refusal };
2772
+ const source = settingSource(authority);
2773
+ const peer = def.group === "Permissions" ? def.path.at(-1)! : undefined;
2774
+ // The console asks for y before never-ask; here the person types the peer it is for.
2775
+ if (value === "never-ask" && confirm !== peer) return { ok: false, error: `never-ask for ${peer} needs its peer id as the confirmation; nothing was changed` };
2776
+ if (def.store === "runtime") {
2777
+ // The console's own request (#242): the same refusals, start reconciliation and events.
2778
+ if (requestedPeerStops.has(peer!)) return { ok: false, error: "peer stop is in progress; wait for ahub status before changing its mode" };
2779
+ const from = permissionMode(peer!);
2780
+ const result = await permissionChange(peer!, () => changePermission(peer!, value, value === "never-ask"));
2781
+ if (result.ok !== true) return result;
2782
+ recordSetting(def.key, from, permissionMode(peer!), source);
2783
+ return { ok: true, text: `${def.key}: ${result.permissionMode}`, ...(typeof result.note === "string" ? { note: result.note } : {}) };
2784
+ }
2785
+ try {
2786
+ if (def.store === "routing") {
2787
+ const from = routingValue(currentRouting(opts.cwd, log), def);
2788
+ const next = writeRoutingSetting(opts.cwd, opts.stateDir, def, value, routingCheck);
2789
+ if (!next) return { ok: true, text: `${def.key}: ${OVERLAY_FILE} already ${value === null ? "leaves it unset" : `holds ${settingText(value)}`}; nothing was written` };
2790
+ recordSetting(def.key, from, routingValue(next, def), source);
2791
+ return { ok: true, text: `${def.key}: ${settingText(routingValue(next, def))} (in force now)` };
2792
+ }
2793
+ // Removing the machine-local value uncovers the shared file's: never-ask there needs the same confirmation.
2794
+ if (peer && value === null && storedLocally(opts.cwd, def) && restoredValue(opts.cwd, opts.stateDir, def, true) === "never-ask" && confirm !== peer) return { ok: false, error: `removing the machine-local value leaves never-ask from config.json for ${peer}: it needs the peer id as the confirmation; nothing was changed` };
2795
+ const from = settingScalar(valueAt(storedConfig(), def.path));
2796
+ if (!writeConfigSetting(opts.cwd, opts.stateDir, def, value, (scratch) => loadConfig(scratch))) return { ok: true, text: `${def.key}: config.local.json already ${value === null ? "leaves it unset" : `holds ${settingText(value)}`}; nothing was written` };
2797
+ const to = settingScalar(valueAt(storedConfig(), def.path));
2798
+ applyStartModes();
2799
+ recordSetting(def.key, from, to, source);
2800
+ // Config trust (#17) can drop a machine-local field at load: the write happened, and the person is told it will not count.
2801
+ const dropped = ignoredLine(storedConfig()?.ignored, def);
2802
+ return { ok: true, text: dropped ? `${def.key}: written, but the hub does not read it (${dropped})` : `${def.key}: ${settingText(to)} (${def.applies === "peer start" ? "applies at the peer's next start" : "applies at the next hub start"})` };
2803
+ } catch (error) {
2804
+ return { ok: false, error: (error as Error).message };
2805
+ }
2806
+ }
2807
+ /** A pin the hub could not start with: refused before a write or an undo puts it on disk. */
2808
+ // As at hub start: with no project routing.toml only a pin the overlay sets counts, never the shipped template's own.
2809
+ const routingCheck = (routing: Routing) => { const problem = config.mlx.enabled === false ? mlxPinProblem(routing, existsSync(join(opts.cwd, ".agenthub", "routing.toml"))) : undefined; if (problem) throw new Error(problem); };
2810
+ function settingsUndo(authority: SettingAuthority): Record<string, unknown> {
2811
+ // The previous version can hold a wider value than the one in force, so undo is never an ordinary session's.
2812
+ if (authority === "ordinary") return { ok: false, error: "undo can put back a value that widens what agents do without asking: open a settings session with ahub ui --settings, or use ahub settings undo in a terminal" };
2813
+ const def = settingDef(pendingUndo(opts.stateDir));
2814
+ if (typeof def === "string") return { ok: false, error: "nothing to undo" };
2815
+ const read = (): SettingValue => def.store === "routing" ? routingValue(currentRouting(opts.cwd, log), def) : settingScalar(valueAt(storedConfig(), def.path));
2816
+ // never-ask is set with its confirmation, never put back by an undo that carries none.
2817
+ if (def.group === "Permissions" && restoredValue(opts.cwd, opts.stateDir, def) === "never-ask") return { ok: false, error: `undo would put never-ask back for ${def.path.at(-1)} without its confirmation: set ${def.key} again (never-ask, or inherit where the shared file holds it) with the confirmation instead; nothing was undone` };
2818
+ try {
2819
+ const from = read();
2820
+ undoSetting(opts.cwd, opts.stateDir, (scratch) => loadConfig(scratch), routingCheck);
2821
+ applyStartModes();
2822
+ recordSetting(def.key, from, read(), settingSource(authority), true);
2823
+ return { ok: true, text: `${def.key}: put back to ${settingText(read())}` };
2824
+ } catch (error) {
2825
+ return { ok: false, error: (error as Error).message };
2826
+ }
2827
+ }
2828
+ /** What a change would do, before it is saved: what the mode grants, or `route explain` for the open tasks before and after. */
2829
+ function settingsPreview(key: unknown, raw: unknown): Record<string, unknown> {
2830
+ const input = settingInput(key, raw);
2831
+ if ("error" in input) return { ok: false, error: input.error };
2832
+ const { def, value } = input;
2833
+ if (def.group === "Permissions") {
2834
+ const peer = def.path.at(-1)!;
2835
+ return { ok: true, lines: [permissionBoundary(peer), ...(isPermissionMode(value) ? [`${value}: ${permissionGrant(peer, value)}`] : ["inherit: the shared file's default, or ask"])] };
2836
+ }
2837
+ if (def.store !== "routing") return { ok: true, lines: [`${def.key}: ${settingText(value)}; ${def.applies === "peer start" ? "applies at the peer's next start" : "read at the next hub start"}`] };
2838
+ try {
2839
+ const after = parseRouting(routingText(opts.cwd), routingCandidate(readOverlay(opts.cwd), def, value));
2840
+ const open = board.list().filter((task) => task.state !== "approved");
2841
+ // A PII task's routing is fixed by its constraint and its text is private: it is counted, not shown.
2842
+ const shown = open.filter((task) => !tasks.isPii(task)).slice(0, 20);
2843
+ return { ok: true, lines: [`${def.key}: ${settingText(routingValue(currentRouting(opts.cwd, log), def))} -> ${settingText(routingValue(after, def))}`, ...(open.length ? [] : ["no open task: nothing is routed differently now"])],
2844
+ tasks: shown.map((task) => ({ id: task.id, before: tasks.publicExplain(task.id), after: tasks.publicExplain(task.id, after) })),
2845
+ ...(open.length > shown.length ? { hidden: open.length - shown.length } : {}) };
2846
+ } catch (error) {
2847
+ return { ok: false, error: (error as Error).message };
2848
+ }
2849
+ }
2850
+
2851
+ async function uiAction(a: Record<string, unknown>, session?: DashboardSession): Promise<unknown> {
2581
2852
  const bad = { ok: false, error: "invalid or unavailable dashboard action" };
2853
+ // #269: a session opened by `ahub ui --settings` may raise authority while its window lasts; any other may not.
2854
+ const authority: SettingAuthority = session?.settings ? "settings" : "ordinary";
2582
2855
  const text = (key: string, max: number) => typeof a[key] === "string" && (a[key] as string).length <= max;
2583
2856
  const peer = () => text("peer", 32) && PEER_ID.test(a.peer as string);
2584
2857
  const taskId = () => typeof a.id === "number" && Number.isSafeInteger(a.id) && a.id > 0;
@@ -2610,6 +2883,22 @@ export async function startDaemon(opts: DaemonOptions) {
2610
2883
  }
2611
2884
  case "assign":
2612
2885
  return taskId() && peer() ? { ok: true, text: await taskOp(USER, "task_assign", { id: a.id, peer: a.peer }) } : bad;
2886
+ case "start_peer": {
2887
+ // #269: the hub's own start of a peer from the closed list, in the mode a person set. No argument reaches a command.
2888
+ if (!peer()) return bad;
2889
+ const result = await hubStart(a.peer, "the dashboard");
2890
+ if (result.ok === true) return { ok: true, text: result.already ? `${a.peer} is already attached.` : result.opened ? `Opened a terminal running ${result.command}; ${a.peer} attaches when its TUI is ready.` : `${a.peer} started (headless).` };
2891
+ // A refusal the hub worded itself carries the command to run by hand; an adapter's own failure text stays in the log.
2892
+ if (typeof result.command === "string") return { ok: false, error: String(result.error) };
2893
+ log(`dashboard start ${a.peer} failed: ${String(result.error)}`);
2894
+ return { ok: false, error: `${a.peer} did not start; inspect ahub status and hub.log` };
2895
+ }
2896
+ case "setting":
2897
+ return settingsSet(a.key, a.value, authority, a.confirm);
2898
+ case "setting_undo":
2899
+ return settingsUndo(authority);
2900
+ case "setting_preview":
2901
+ return settingsPreview(a.key, a.value);
2613
2902
  default:
2614
2903
  return bad;
2615
2904
  }
@@ -2967,7 +3256,13 @@ export async function startDaemon(opts: DaemonOptions) {
2967
3256
  }
2968
3257
  case "recovery":
2969
3258
  if (c.role !== "console") return void reply(recoveryError("recovery is a console command"));
2970
- void recoveryOp(msg).then(reply, (error) => reply(recoveryError((error as Error)?.message ?? "recovery operation failed")));
3259
+ void recoveryOp(msg).then(reply, (error) => {
3260
+ if (isDatabaseBusy(error)) {
3261
+ try { log(`recovery ${sanitize(String(msg.op))} database contention: ${databaseBusySite(error).join("\n")}`); } catch { /* the reply still settles its rid */ }
3262
+ const busy = new RecoveryDatabaseBusy();
3263
+ reply({ ...recoveryError(busy.message), code: busy.code, retryable: busy.retryable });
3264
+ } else reply(recoveryError((error as Error)?.message ?? "recovery operation failed"));
3265
+ });
2971
3266
  return;
2972
3267
  case "send": {
2973
3268
  if (recoveryActive()) return void reply({ t: "sent", ok: false, error: "recovery is holding new deliveries" });
@@ -3160,7 +3455,9 @@ export async function startDaemon(opts: DaemonOptions) {
3160
3455
  try {
3161
3456
  dashboard ??= startDashboard({ snapshot: uiSnapshot, action: uiAction });
3162
3457
  writeStatus();
3163
- return void reply({ t: "ui", ok: true, url: dashboard.issue() });
3458
+ // #269: `ahub ui --settings` is the same person's command; its ticket marks the session it opens.
3459
+ if (msg.settings === true) notify("settings session opened from ahub ui --settings: for 15 minutes that dashboard session may change permission modes, routing and start settings");
3460
+ return void reply({ t: "ui", ok: true, url: dashboard.issue(msg.settings === true), ...(msg.settings === true ? { settings: true } : {}) });
3164
3461
  } catch {
3165
3462
  return void reply({ t: "ui", ok: false, error: "could not start the dashboard" });
3166
3463
  }
@@ -3177,6 +3474,24 @@ export async function startDaemon(opts: DaemonOptions) {
3177
3474
  if (!msg.action || typeof msg.action !== "object" || Array.isArray(msg.action)) return void reply({ ok: false, error: "invalid dashboard action" });
3178
3475
  void uiAction(msg.action).then((result) => reply(result as Record<string, unknown>), () => reply({ ok: false, error: "dashboard action failed; check its inputs" }));
3179
3476
  return;
3477
+ case "settings_get":
3478
+ if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
3479
+ return void reply({ ok: true, ...settingsView() });
3480
+ case "settings_preview":
3481
+ if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
3482
+ return void reply(settingsPreview(msg.key, msg.value));
3483
+ case "settings_set":
3484
+ if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
3485
+ void settingsSet(msg.key, msg.value, "terminal", msg.confirm).then(reply, () => reply({ ok: false, error: "the setting could not be changed; inspect hub.log" }));
3486
+ return;
3487
+ case "settings_undo":
3488
+ if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
3489
+ return void reply(settingsUndo("terminal"));
3490
+ case "peer_start":
3491
+ // #269: a start the hub makes in the peer's start mode, for a console child with no terminal of its own.
3492
+ if (c.role !== "console") return void reply({ ok: false, error: "peer_start is a console command; use hub_peer_start with the conductor role" });
3493
+ void hubStart(msg.peer, "the console").then(reply, (error) => { log(`peer_start ${String(msg.peer)} failed: ${(error as Error).message}`); reply({ ok: false, error: "peer start failed; inspect ahub status and hub.log" }); });
3494
+ return;
3180
3495
  case "peer_stop":
3181
3496
  if (c.role !== "console") return void reply({ ok: false, error: "peer_stop is a console command; the person runs ahub stop in a terminal" });
3182
3497
  void stopPeer(msg.peer).then(reply).catch(() => reply({ ok: false, error: "peer stop failed; inspect ahub status and logs before retrying" }));
@@ -3348,12 +3663,12 @@ export async function startDaemon(opts: DaemonOptions) {
3348
3663
  void recoverAfterCrash(crashed).catch((error) => {
3349
3664
  log(`crash recovery failed: ${(error as Error).message}`);
3350
3665
  // pi.auto_start still holds: a running Pi answers `already`, a starting one refuses the second start.
3351
- if (piAutoStart) void startPeer("pi", {}).catch((e) => log(`Pi auto-start failed: ${e.message}`));
3666
+ if (piAutoStart) autoStartPi();
3352
3667
  });
3353
3668
  }
3354
3669
  // After a crash that recorded Pi, crash recovery starts it (#66): its recorded session first, a fresh one if that fails.
3355
3670
  const piRecovered = !!crashed?.peers.some((p) => p.peer === "pi");
3356
- if (piAutoStart && !recoveryActive() && !piRecovered) void startPeer("pi", {}).catch((error) => log(`Pi auto-start failed: ${error.message}`));
3671
+ if (piAutoStart && !recoveryActive() && !piRecovered) autoStartPi();
3357
3672
  return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
3358
3673
  } finally {
3359
3674
  if (!ready) for (const cleanup of startupCleanup.reverse()) { try { cleanup(); } catch { /* preserve startup error */ } }
@@ -0,0 +1,3 @@
1
+ // hub.db is shared by the board, journal, budgets, turns and Pi receipts.
2
+ // Bound each SQLite contention wait; callers must not multiply it with retries.
3
+ export const HUB_DB_BUSY_TIMEOUT_MS = 3000;
@@ -4,6 +4,7 @@ import { Database } from "bun:sqlite";
4
4
  import type { BusSnapshot } from "./bus.ts";
5
5
  import type { Envelope } from "./envelope.ts";
6
6
  import { processLiveness } from "../pi/process-signature.ts";
7
+ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
7
8
 
8
9
  export type JournalDeliveryState =
9
10
  | "queued"
@@ -239,10 +240,10 @@ export class DeliveryJournal {
239
240
  this.operationId = options.operationId;
240
241
  mkdirSync(dirname(options.file), { recursive: true, mode: 0o700 });
241
242
  this.db = new Database(options.file, { create: true });
243
+ this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
242
244
  this.harden();
243
245
  this.db.run("PRAGMA journal_mode = WAL");
244
246
  this.db.run("PRAGMA synchronous = FULL");
245
- this.db.run("PRAGMA busy_timeout = 3000");
246
247
  this.createSchema();
247
248
  const meta = this.readMeta();
248
249
  if (!meta)
@@ -14,7 +14,7 @@ export interface Envelope {
14
14
  kind: Kind;
15
15
  priority: Priority;
16
16
  body: string; // agent conclusions only, never tool noise
17
- refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; supervision?: boolean; supervisionKey?: string };
17
+ refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; supervision?: boolean; supervisionKey?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
18
18
  /** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
19
19
  private?: boolean;
20
20
  ts: number;
package/src/hub/events.ts CHANGED
@@ -12,6 +12,8 @@ export const EVENTS_SCHEMA = 1;
12
12
  */
13
13
  export type HubEvent =
14
14
  | { type: "permission_mode"; peer: string; from: import("./permission-mode.ts").PermissionMode; to: import("./permission-mode.ts").PermissionMode }
15
+ /** A settings change (#269): the registry key, the value in force before and after (closed words, ids or booleans, never free text) and where it came from. */
16
+ | { type: "settings"; key: string; from: import("./settings.ts").SettingValue; to: import("./settings.ts").SettingValue; source: "dashboard" | "terminal"; undo?: boolean }
15
17
  | { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string }
16
18
  | { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
17
19
  | { type: "stale"; id: string; from: string; peer: string; task?: string }