@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +27 -12
  3. package/docs/agent-notes/adapters.md +15 -7
  4. package/docs/agent-notes/benchmarks.md +4 -1
  5. package/docs/agent-notes/budget.md +4 -0
  6. package/docs/agent-notes/bus.md +2 -1
  7. package/docs/agent-notes/daemon.md +12 -1
  8. package/docs/agent-notes/local-worker.md +4 -1
  9. package/docs/agent-notes/tasks.md +2 -1
  10. package/docs/agent-notes/tests.md +4 -0
  11. package/docs/events.md +17 -4
  12. package/docs/operations.md +478 -41
  13. package/docs/quickstart.md +14 -6
  14. package/docs/security.md +23 -11
  15. package/docs/smoke.md +133 -7
  16. package/docs/specs/2026-09-19-agent-hub-design.md +115 -15
  17. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
  18. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  19. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  20. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  21. package/docs/verified.json +32 -17
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +14 -3
  25. package/src/adapters/acp-quota.ts +13 -0
  26. package/src/adapters/acp.ts +105 -17
  27. package/src/adapters/codex-appserver.ts +70 -35
  28. package/src/adapters/local-worker.ts +44 -10
  29. package/src/adapters/pi.ts +158 -19
  30. package/src/cli/bench.ts +2 -1
  31. package/src/cli/console-state.ts +28 -22
  32. package/src/cli/console.ts +20 -10
  33. package/src/cli/help.ts +25 -19
  34. package/src/cli/identity.ts +3 -3
  35. package/src/cli/init.ts +1 -1
  36. package/src/cli/launch.ts +182 -7
  37. package/src/cli/main.ts +409 -177
  38. package/src/cli/output.ts +363 -0
  39. package/src/cli/preview.ts +2 -1
  40. package/src/cli/recovery-package.ts +17 -0
  41. package/src/cli/reset.ts +3 -0
  42. package/src/cli/status-lines.ts +0 -28
  43. package/src/cli/terminal-recovery.ts +27 -1
  44. package/src/cli/upgrade-interactive.ts +297 -0
  45. package/src/cli/upgrade-runtime.ts +80 -9
  46. package/src/cli/upgrade.ts +50 -15
  47. package/src/hub/board.ts +2 -0
  48. package/src/hub/budget.ts +39 -7
  49. package/src/hub/bus.ts +15 -14
  50. package/src/hub/child-process.ts +11 -0
  51. package/src/hub/conductor.ts +23 -11
  52. package/src/hub/config-trust.ts +16 -5
  53. package/src/hub/conflicts.ts +2 -0
  54. package/src/hub/context-window.ts +8 -1
  55. package/src/hub/crash.ts +2 -1
  56. package/src/hub/daemon.ts +483 -83
  57. package/src/hub/db-timeout.ts +3 -0
  58. package/src/hub/delivery-journal.ts +2 -1
  59. package/src/hub/envelope.ts +1 -1
  60. package/src/hub/events.ts +4 -1
  61. package/src/hub/execution-budget.ts +2 -0
  62. package/src/hub/facts.ts +5 -4
  63. package/src/hub/git.ts +32 -0
  64. package/src/hub/hub-tools.ts +1 -1
  65. package/src/hub/lifecycle.ts +10 -4
  66. package/src/hub/manager.ts +18 -5
  67. package/src/hub/peers.ts +7 -5
  68. package/src/hub/permission-mode.ts +14 -0
  69. package/src/hub/project.ts +2 -1
  70. package/src/hub/recovery-error.ts +31 -0
  71. package/src/hub/recovery-store.ts +43 -4
  72. package/src/hub/registry.ts +2 -1
  73. package/src/hub/routing.ts +114 -13
  74. package/src/hub/settings.ts +392 -0
  75. package/src/hub/snapshots.ts +4 -2
  76. package/src/hub/start-mode.ts +48 -0
  77. package/src/hub/tasks.ts +158 -43
  78. package/src/hub/terminal-open.ts +64 -0
  79. package/src/hub/ui.ts +25 -13
  80. package/src/local/deny.ts +71 -2
  81. package/src/local/sandbox.ts +47 -6
  82. package/src/local/tools.ts +25 -6
  83. package/src/memory/recall.ts +2 -2
  84. package/src/models/mlx.ts +2 -1
  85. package/src/pi/extension.ts +43 -20
  86. package/src/pi/tool-receipts.ts +19 -2
  87. package/src/ui/index.html +172 -21
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";
@@ -19,12 +20,12 @@ import { CodexPeer } from "../adapters/codex-appserver.ts";
19
20
  import { PiPeer } from "../adapters/pi.ts";
20
21
  import { startModelRelay, type ModelRelay } from "../models/relay.ts";
21
22
  import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
22
- import { PiToolReceipts } from "../pi/tool-receipts.ts";
23
+ import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
23
24
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
24
25
  import { processSignature } from "../pi/process-signature.ts";
25
26
  import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
26
27
  import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
27
- import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolContext } from "../local/tools.ts";
28
+ import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
28
29
  import { LocalPeer } from "../adapters/local-worker.ts";
29
30
  import { Capture, skipTools } from "../memory/capture.ts";
30
31
  import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.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;
@@ -418,7 +442,9 @@ export async function startDaemon(opts: DaemonOptions) {
418
442
  const logFile = join(opts.stateDir, "hub.log");
419
443
  // The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
420
444
  const log = (line: string) => { try { appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`); } catch { /* the state dir is gone; the watchdog is stopping the hub */ } };
421
- const event = eventLog(join(opts.stateDir, "events.jsonl"));
445
+ const event = eventLog(join(opts.stateDir, "events.jsonl"), (entry) => {
446
+ if (entry.type === "permission") log(`permission event recording failed at ${entry.event} for ${entry.peer}`);
447
+ });
422
448
  /**
423
449
  * #247: the approved task's research record, built from events.jsonl with the function `ahub research backfill` uses.
424
450
  * Never blocks or fails the task flow: a write error is one hub.log line per hub run.
@@ -472,6 +498,12 @@ export async function startDaemon(opts: DaemonOptions) {
472
498
  parsed = JSON.parse(String(data));
473
499
  onMessage(sock, parsed);
474
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
+ }
475
507
  log("control operation failed; inspect delivery storage and recovery status");
476
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" }));
477
509
  }
@@ -587,7 +619,8 @@ export async function startDaemon(opts: DaemonOptions) {
587
619
  let relevantNotice: (peer: PeerId, env: Envelope) => boolean = () => true;
588
620
  const staleOff = config.experiments?.stale_notices === "deliver";
589
621
  if (staleOff) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
590
- const bus = new Bus({ journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => env.refs?.supervision ? relevantNotice(peer, env) : staleOff || relevantNotice(peer, env), silence });
622
+ const requestedPeerStops = new Set<string>();
623
+ const bus = new Bus({ deliveryHeld: peer => requestedPeerStops.has(peer), journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => env.refs?.supervision ? relevantNotice(peer, env) : staleOff || relevantNotice(peer, env), silence });
591
624
  startupCleanup.push(() => bus.closeJournal());
592
625
  const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
593
626
  const conductorHolds = new ConductorHolds(join(opts.stateDir, "hub.db"));
@@ -1017,9 +1050,25 @@ export async function startDaemon(opts: DaemonOptions) {
1017
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() } : {}) }),
1018
1051
  handoff: (peer, context, urgentOnly) => tasks.reassignForPause(peer, context, urgentOnly),
1019
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()];
1020
1067
  if (record.peer === "kimi") kimiTokens.length = 0; // a new window: the old counts would pause it again at once
1021
- 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.";
1022
- 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);
1023
1072
  },
1024
1073
  notify: (line) => { notify(line); feedBudgetPauses(); },
1025
1074
  });
@@ -1121,7 +1170,9 @@ export async function startDaemon(opts: DaemonOptions) {
1121
1170
  for (const peer of bus.knownPeers()) {
1122
1171
  contexts.retry(peer, contextSession(peer));
1123
1172
  const reading = contexts.view(peer, contextSession(peer), bus.peers.has(peer) && ["idle", "busy", "paused"].includes(bus.stateOf(peer)));
1124
- 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}`;
1125
1176
  if (contextTailSeen.get(peer) !== stamp) {
1126
1177
  contextTailSeen.set(peer, stamp);
1127
1178
  for (const c of consoles) if (c.data.tail) c.send(JSON.stringify({ t: "context", peer, reading }));
@@ -1371,7 +1422,7 @@ export async function startDaemon(opts: DaemonOptions) {
1371
1422
  };
1372
1423
  let releasing = false; // gone-owner release (#6) and the ready sweep (#34): one run at a time, and a recovery commit waits for it
1373
1424
  const recoveryReady = () => {
1374
- if (!recoveryActive() || releasing || taskOpsInFlight !== 0 || tasks.checksPending() !== 0 || (piReceipts?.inFlight ?? 0) !== 0 || permissions.size !== 0 || starting.size !== 0 || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (peer instanceof PiPeer && !peer.recoveryReady))) return false;
1425
+ if (!recoveryActive() || releasing || taskOpsInFlight !== 0 || tasks.checksPending() !== 0 || (piReceipts?.inFlight ?? 0) !== 0 || permissions.size !== 0 || starting.size !== 0 || requestedPeerStops.size !== 0 || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (peer instanceof PiPeer && !peer.recoveryReady))) return false;
1375
1426
  if (!recoveryPeerSnapshot) return true;
1376
1427
  const current = recoveryPeers();
1377
1428
  return recoveryPeerSnapshot.every((saved) => {
@@ -1736,6 +1787,8 @@ export async function startDaemon(opts: DaemonOptions) {
1736
1787
  if (r.ok) { report(`pi resumed (pi.auto_start): ${step.how}`); continue; }
1737
1788
  report(`pi not resumed (${String(r.error)}); ${step.how}`);
1738
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; }
1739
1792
  const fresh = await start("pi", { ...(step.fresh as Parameters<typeof startPeer>[1]), fresh: true });
1740
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` : "";
1741
1794
  report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`);
@@ -1832,7 +1885,11 @@ export async function startDaemon(opts: DaemonOptions) {
1832
1885
  if (task?.owner === e.peer && ["proposed", "in_progress", "changes_requested"].includes(task.state)) {
1833
1886
  const reason = tasks.isPii(task) ? "private delivery retries exhausted" : sanitize(e.reason ?? "delivery retries exhausted").replace(/\s+/g, " ").slice(0, 300);
1834
1887
  notify(`task ${tasks.publicTitle(task)}: undeliverable to ${e.peer}: ${reason}; escalating`);
1835
- 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
+ });
1836
1893
  }
1837
1894
  }
1838
1895
  } else {
@@ -1864,9 +1921,21 @@ export async function startDaemon(opts: DaemonOptions) {
1864
1921
  child.on("exit", (code: number | null, signal: string | null) => (clearTimeout(kill), code || signal ? failed(signal ? `osascript stopped by ${signal}` : `osascript exit ${code}`) : undefined));
1865
1922
  });
1866
1923
 
1924
+ const permissionAnswerEpochs = new Map<string, number>();
1925
+ const permissionAnswerEpoch = (peer: string) => permissionAnswerEpochs.get(peer) ?? 0;
1926
+ type PermissionResult = { outcome: "answered" | "expired" | "cancelled"; optionId?: string };
1867
1927
  async function onPermission(req: PermissionRequest): Promise<string | undefined> {
1868
- if (stopping) return undefined;
1869
- if (opts.unattended) return req.options.find((o) => o.kind === "allow_once")?.optionId;
1928
+ const result = await requestPermission(req);
1929
+ return result.outcome === "answered" ? result.optionId : undefined;
1930
+ }
1931
+ async function requestPermission(req: PermissionRequest, signal?: AbortSignal, observe?: ApprovalObserver): Promise<PermissionResult> {
1932
+ const requestEpoch = permissionAnswerEpoch(req.peer);
1933
+ const observed = (source: "person" | "automatic" | "expired" | "aborted") => {
1934
+ try { observe?.({ source, answerEpoch: permissionAnswerEpoch(req.peer), ...(source === "expired" ? { eligibleExpiry: requestEpoch === permissionAnswerEpoch(req.peer) } : {}) }); }
1935
+ catch { try { log(`permission observer failed for ${req.peer}`); } catch { /* settlement is authoritative */ } }
1936
+ };
1937
+ if (stopping || signal?.aborted) { observed("aborted"); return { outcome: "cancelled" }; }
1938
+ if (opts.unattended) { observed("automatic"); return { outcome: "answered", optionId: req.options.find((o) => o.kind === "allow_once")?.optionId }; }
1870
1939
  const id = randomUUID().slice(0, 8);
1871
1940
  // The title is written by an agent and read by the person approving it: escape sequences and carriage returns
1872
1941
  // could repaint the terminal line, so everything but newline and tab is made visible.
@@ -1881,36 +1950,39 @@ export async function startDaemon(opts: DaemonOptions) {
1881
1950
  // The tool name is for the desktop notice only: the console push keeps its shape.
1882
1951
  const { tool: _tool, ...shown } = req;
1883
1952
  const push = JSON.stringify({ t: "permission", id, ...shown, title, createdAt, expiresAt });
1884
- event({ type: "permission", id, peer: req.peer, event: "requested" });
1885
- for (const c of consoles) if (c.data.tail) c.send(push);
1886
- if (config.approvals.notify) {
1887
- try {
1888
- desktop("agent-hub", `${req.peer} asks for approval${req.tool ? `: ${req.tool}` : ""} (ahub tail)`);
1889
- } catch {
1890
- // never let a notifier break the request itself
1891
- }
1892
- }
1893
1953
  return new Promise((resolve) => {
1894
- const timer = setTimeout(() => {
1895
- notify(`permission ${id} from ${req.peer} was not answered within ${Math.round(timeoutMs / 1000)}s and was cancelled`);
1896
- done(undefined, undefined, "expired");
1897
- }, timeoutMs);
1954
+ let timer: ReturnType<typeof setTimeout>;
1955
+ const aborted = () => done(undefined, undefined, "cancelled");
1898
1956
  const done = (optionId: string | undefined, surface?: "console" | "dashboard" | "terminal", reason: "expired" | "cancelled" = "cancelled"): boolean => {
1899
1957
  if (!permissions.has(id)) return false;
1958
+ if (signal?.aborted && optionId !== undefined) { aborted(); return false; }
1900
1959
  const option = optionId === undefined ? undefined : req.options.find(o => o.optionId === optionId);
1901
- if (optionId !== undefined && (!option || Date.now() >= expiresAt)) return false;
1960
+ if (optionId !== undefined && (!option || Date.now() >= expiresAt)) {
1961
+ if (Date.now() >= expiresAt) done(undefined, undefined, "expired");
1962
+ return false;
1963
+ }
1902
1964
  clearTimeout(timer);
1903
- permissions.delete(id);
1965
+ signal?.removeEventListener("abort", aborted);
1966
+ permissions.delete(id); // withdrawal happens before any observer, resolution or late grant
1904
1967
  const outcome = option ? "answered" : reason;
1968
+ if (surface) permissionAnswerEpochs.set(req.peer, permissionAnswerEpoch(req.peer) + 1);
1969
+ observed(surface ? "person" : outcome === "expired" ? "expired" : "aborted");
1970
+ if (outcome === "expired") { try { notify(`permission ${id} from ${req.peer} was not answered within ${Math.round(timeoutMs / 1000)}s and was cancelled`); } catch { /* registry withdrawal already completed */ } }
1905
1971
  const latencyMs = Math.max(0, Date.now() - createdAt);
1906
1972
  const optionKind = option?.kind === "allow_once" || option?.kind === "allow_always" || option?.kind === "reject_once" || option?.kind === "reject_always" ? option.kind : undefined;
1907
- event({ type: "permission", id, peer: req.peer, event: outcome, latencyMs, ...(surface ? { surface } : {}), ...(optionKind ? { option: optionKind } : {}) });
1908
- notify(`permission ${id} from ${req.peer} ${outcome} option ${optionKind ?? "none"} by ${surface ?? "hub"} (${latencyMs}ms)`);
1909
- for (const c of consoles) if (c.data.tail) c.send(JSON.stringify({ t: "permission_closed", id, peer: req.peer, outcome: outcome === "expired" ? "cancelled" : outcome, ...(outcome === "expired" ? { reason: "expired" } : {}), latencyMs }));
1910
- resolve(optionId);
1973
+ try { event({ type: "permission", id, peer: req.peer, event: outcome, latencyMs, ...(surface ? { surface } : {}), ...(optionKind ? { option: optionKind } : {}) }); } catch { try { log(`permission event recording failed at settlement for ${req.peer}`); } catch { /* settlement remains authoritative */ } }
1974
+ try { notify(`permission ${id} from ${req.peer} ${outcome} option ${optionKind ?? "none"} by ${surface ?? "hub"} (${latencyMs}ms)`); } catch { /* no reporting failure strands the caller */ }
1975
+ for (const c of consoles) if (c.data.tail) { try { c.send(JSON.stringify({ t: "permission_closed", id, peer: req.peer, outcome: outcome === "expired" ? "cancelled" : outcome, ...(outcome === "expired" ? { reason: "expired" } : {}), latencyMs })); } catch { /* another surface can still observe the closure */ } }
1976
+ resolve({ outcome, ...(option ? { optionId: option.optionId } : {}) });
1911
1977
  return true;
1912
1978
  };
1913
1979
  permissions.set(id, { push, done, peer: req.peer, ...(publicTool ? { tool: publicTool } : {}), createdAt, expiresAt });
1980
+ timer = setTimeout(() => done(undefined, undefined, "expired"), timeoutMs);
1981
+ signal?.addEventListener("abort", aborted, { once: true });
1982
+ if (signal?.aborted) { aborted(); return; }
1983
+ try { event({ type: "permission", id, peer: req.peer, event: "requested" }); } catch { /* optional observer */ }
1984
+ for (const c of consoles) if (c.data.tail) { try { c.send(push); } catch { /* next tail gets the registered request */ } }
1985
+ if (config.approvals.notify) { try { desktop("agent-hub", `${req.peer} asks for approval${req.tool ? `: ${req.tool}` : ""} (ahub tail)`); } catch { /* optional desktop */ } }
1914
1986
  });
1915
1987
  }
1916
1988
 
@@ -1919,12 +1991,17 @@ export async function startDaemon(opts: DaemonOptions) {
1919
1991
  let piAutoRestartAt = -Infinity;
1920
1992
  let piAutoRestartPending = false;
1921
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}`));
1922
1998
  async function stopPiForReplacement(pi: PiPeer): Promise<void> {
1923
1999
  replacingPi.add(pi);
1924
2000
  try { await pi.stop(); } finally { replacingPi.delete(pi); }
1925
2001
  }
1926
2002
  function startPeer(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; fresh?: boolean; unattended?: boolean }): Promise<Record<string, unknown>> {
1927
2003
  if (stopping) return Promise.resolve({ ok: false, error: "hub is stopping" });
2004
+ if (requestedPeerStops.has(peer)) return Promise.resolve({ ok: false, error: "peer stop is in progress; wait for ahub status to show offline before starting it" });
1928
2005
  if (peer === "pi" && starting.has(peer)) return Promise.resolve({ ok: false, error: "Pi start is in progress; inspect status before retrying" });
1929
2006
  const joined = starting.get(peer);
1930
2007
  if (joined) {
@@ -1945,6 +2022,90 @@ export async function startDaemon(opts: DaemonOptions) {
1945
2022
  return running;
1946
2023
  }
1947
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
+
2075
+ async function stopPeer(peer: unknown): Promise<Record<string, unknown>> {
2076
+ if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding peer stops; finish the operation before retrying" };
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" };
2078
+ if (peer === "claude" || peer === "codex") return { ok: false, error: "this peer belongs to a native terminal; end it in its terminal" };
2079
+ const owner = bus.peers.get(peer);
2080
+ if (!(owner instanceof AcpPeer || owner instanceof PiPeer || owner instanceof LocalPeer)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
2081
+ if (owner instanceof PiPeer && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
2082
+ if (owner.state === "offline") return { ok: false, error: `peer is already offline; start it with ahub ${peer} before stopping it` };
2083
+ if (starting.has(peer) || requestedPeerStops.has(peer) || permissionChanges.has(peer)) return { ok: false, error: "peer lifecycle or permission change is in progress; wait for ahub status before retrying" };
2084
+ requestedPeerStops.add(peer); // fence delivery, starts and recovery before the first asynchronous stop step
2085
+ try {
2086
+ return await permissionChange(peer, async () => {
2087
+ if (stopping || recoveryActive() || bus.peers.get(peer) !== owner) return { ok: false, error: "peer or recovery changed; refresh ahub status before retrying" };
2088
+ const reason = `requested stop: ahub stop ${peer}`; // ids only, never task or message text
2089
+ // Pi persists a session only after its first message: take live empty-session proof before stopping.
2090
+ if (owner instanceof PiPeer && owner.state === "idle") {
2091
+ try { await owner.captureResume(); }
2092
+ catch { log(`requested stop ${peer}: resume evidence unavailable; later start requires verified persisted history`); }
2093
+ }
2094
+ for (const pending of permissions.values()) if (pending.peer === peer) pending.done(undefined);
2095
+ await owner.stop(reason);
2096
+ writeStatus();
2097
+ const state = bus.stateOf(peer);
2098
+ if (state !== "offline" || bus.peers.get(peer) !== owner) return { ok: false, error: "peer stop did not reach offline on its owner; inspect ahub status and its terminal before retrying" };
2099
+ try { notify(`${peer} stopped by the console; start it again with ahub ${peer}`); } catch { /* owner stop and readback remain authoritative */ }
2100
+ return { ok: true, state };
2101
+ });
2102
+ } catch {
2103
+ writeStatus();
2104
+ log(`requested peer stop failed for ${peer}; inspect owner state before retrying`);
2105
+ return { ok: false, error: "peer stop did not finish; inspect ahub status and logs before retrying" };
2106
+ } finally { requestedPeerStops.delete(peer); }
2107
+ }
2108
+
1948
2109
  /**
1949
2110
  * Hide a replaced adapter's stop from the console (issue #42): its `offline` would flash into status.json and
1950
2111
  * the dashboard between the two adapters. Returns the undo, which the caller runs on every exit: if no
@@ -2036,6 +2197,25 @@ export async function startDaemon(opts: DaemonOptions) {
2036
2197
  autoApprove: (title) => !stopping && HUB_TOOL_TITLES.has(title),
2037
2198
  log,
2038
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
+ },
2039
2219
  onTurnFailure: () => { supervisionTurns.delete("kimi"); },
2040
2220
  mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv("kimi")).map(([name, value]) => ({ name, value })) }],
2041
2221
  preamble: roleContract("kimi", config.roles),
@@ -2139,17 +2319,23 @@ export async function startDaemon(opts: DaemonOptions) {
2139
2319
  let piReply: Envelope | undefined;
2140
2320
  // Tools the person allowed "always" for this Pi start; a new start gets a new set (#209).
2141
2321
  const piAlways = new Set<string>();
2142
- const piPermit = async (title: string, tool: string, signal?: AbortSignal, path?: string): Promise<boolean> => {
2143
- if (signal?.aborted) return false;
2322
+ const piPermit = async (title: string, tool: string, signal?: AbortSignal, path?: string, valid: () => boolean = () => true, observe?: ApprovalObserver): Promise<ToolApproval> => {
2323
+ if (stopping || signal?.aborted || !valid() || !pi.acceptingTools || bus.peers.get("pi") !== pi) return "aborted";
2144
2324
  const selected = permissionMode("pi");
2145
- if (selected === "never-ask" || (selected === "ask-when-needed" && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(opts.cwd), path))) return pi.acceptingTools && bus.peers.get("pi") === pi;
2146
- if (piAlways.has(tool)) log(`permission auto-allowed for pi: ${tool} (granted until Pi restarts)`); // the name only, never the arguments
2325
+ if (selected === "never-ask" || (selected === "ask-when-needed" && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(opts.cwd), path))) {
2326
+ observe?.({ source: "automatic", answerEpoch: permissionAnswerEpoch("pi") });
2327
+ return true;
2328
+ }
2329
+ if (piAlways.has(tool)) { log(`permission auto-allowed for pi: ${tool} (granted until Pi restarts)`); observe?.({ source: "automatic", answerEpoch: permissionAnswerEpoch("pi") }); }
2147
2330
  else {
2148
- const picked = await onPermission({ peer: "pi", title, options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "always", name: `Always allow ${tool} until Pi restarts`, kind: "allow_always" }, { optionId: "deny", name: "Deny", kind: "reject_once" }] });
2149
- if (picked === "always") piAlways.add(tool);
2150
- else if (picked !== "allow") return false;
2331
+ const picked = await requestPermission({ peer: "pi", title, tool, options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "always", name: `Always allow ${tool} until Pi restarts`, kind: "allow_always" }, { optionId: "deny", name: "Deny", kind: "reject_once" }] }, signal, observe);
2332
+ if (picked.outcome === "expired") return "expired";
2333
+ if (stopping || signal?.aborted || !valid() || !pi.acceptingTools || bus.peers.get("pi") !== pi) return "aborted";
2334
+ if (picked.outcome === "cancelled") return false; // a current person's no-option answer is a denial, not native abort
2335
+ if (picked.optionId === "always") piAlways.add(tool);
2336
+ else if (picked.optionId !== "allow") return false;
2151
2337
  }
2152
- return pi.acceptingTools && bus.peers.get("pi") === pi;
2338
+ return signal?.aborted || !valid() ? "aborted" : pi.acceptingTools && bus.peers.get("pi") === pi;
2153
2339
  };
2154
2340
  const ctx: ToolContext = {
2155
2341
  cwd: opts.cwd, deny: config.local.deny,
@@ -2170,27 +2356,33 @@ export async function startDaemon(opts: DaemonOptions) {
2170
2356
  admitBudget: async (envs, unit) => unit === "model_calls" ? [] : tasks.admitExecutionEnvelopes(envs, "pi", unit),
2171
2357
  relay: { url: modelRelay.url, token: modelRelay.token, models: modelRelay.models.map((id) => ({ id, contextWindow: id.startsWith("mlx/") ? Math.min(routing.pi.mlx_max_context_tokens, config.mlx.provider === "ollama" ? (config.mlx.contextWindow ?? 8192) : routing.pi.mlx_max_context_tokens) : routing.pi.dgx_max_context_tokens, maxTokens: id === "hub/auto" ? (config.mlx.enabled === false ? 8192 : Math.min(config.mlx.maxTokens ?? 2048, 8192)) : id.startsWith("mlx/") ? (config.mlx.maxTokens ?? 2048) : 8192 })) },
2172
2358
  tools: [...TOOL_SCHEMAS.map((t) => t.function), ...[...TASK_TOOLS, ...CONDUCTOR_TOOLS].map((t) => ({ name: t.name, description: t.description, parameters: t.inputSchema }))],
2173
- executeTool: async (name, raw, callId, sessionId, signal) => {
2359
+ executeTool: async (name, raw, callId, sessionId, signal, context) => {
2174
2360
  if (signal?.aborted) return "error: turn cancelled before tool effects";
2175
2361
  if (stopping || (recoveryActive() && recoveryPhase !== "preparing")) return "error: recovery is holding tool effects";
2176
- return piReceipts!.execute(sessionId ?? "", callId, name, raw, async () => {
2362
+ const humanBash = context?.userBash === true && name === "bash";
2363
+ let exitCode: number | null = null;
2364
+ const result = await piReceipts!.execute(sessionId ?? "", callId, name, humanBash ? { origin: "verified-user-bash", args: raw } : raw, async () => {
2177
2365
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "error: invalid tool arguments";
2178
2366
  const nativeTurn = pi.observationTurn;
2179
- const output = TASK_TOOLS.some((t) => t.name === name) || CONDUCTOR_TOOL_NAMES.has(name) ? await taskOp("pi", name, raw as Record<string, unknown>, true) : await runTool(name, JSON.stringify(raw), { ...ctx, signal, permit: async (title, _tool, _signal, path) => {
2180
- if (!signal) return piPermit(title, name, undefined, path);
2181
- if (signal.aborted) return false;
2182
- return new Promise<boolean>((resolve, reject) => {
2183
- const finish = (allowed: boolean) => { signal.removeEventListener("abort", aborted); resolve(allowed && !signal.aborted); };
2184
- const aborted = () => finish(false); signal.addEventListener("abort", aborted, { once: true });
2185
- piPermit(title, name, signal, path).then(finish, error => { signal.removeEventListener("abort", aborted); reject(error); });
2186
- });
2187
- } });
2367
+ const output = TASK_TOOLS.some((t) => t.name === name) || CONDUCTOR_TOOL_NAMES.has(name) ? await taskOp("pi", name, raw as Record<string, unknown>, true) : await runTool(name, JSON.stringify(raw), { ...ctx, signal,
2368
+ ...(humanBash ? { onProcessExit: (code: number | null) => { exitCode = code; } } : {}),
2369
+ permit: async (title, tool, toolSignal, path) => {
2370
+ const picked = humanBash ? (toolSignal?.aborted || context?.isCurrent() !== true ? "aborted" : true) : await piPermit(title, tool ?? name, toolSignal, path, context?.isCurrent, context?.onApproval);
2371
+ return picked; // never turn a nonempty expiry/abort string into authorization
2372
+ },
2373
+ });
2188
2374
  if (!signal?.aborted && bus.peers.get("pi") === pi) {
2189
2375
  const taskId = pi.budgetEnvelopes.find(env => env.refs?.task)?.refs?.task;
2190
2376
  observeProgress("pi", { name, ...(typeof (raw as any)?.command === "string" ? { command: (raw as any).command } : {}), resultText: output, isError: toolResultFailed(name, output), source: "pi", ...(nativeTurn ? { turn: nativeTurn } : {}) }, taskId);
2191
2377
  }
2192
- return output;
2378
+ return humanBash ? packPiShellResult(output, exitCode) : output;
2193
2379
  });
2380
+ if (!humanBash) return result;
2381
+ try {
2382
+ const cached = JSON.parse(result);
2383
+ if (cached.kind === "user-bash-result" && typeof cached.text === "string" && (cached.exitCode === null || Number.isInteger(cached.exitCode))) return { text: cached.text, exitCode: cached.exitCode };
2384
+ } catch { /* a ledger refusal has no observed process exit */ }
2385
+ return { text: result, exitCode: null };
2194
2386
  },
2195
2387
  selectModel: async (envs) => {
2196
2388
  piReply = replyParent(envs);
@@ -2217,7 +2409,8 @@ export async function startDaemon(opts: DaemonOptions) {
2217
2409
  const cause = exit.signal ? `signal ${exit.signal}` : exit.code !== null ? `code ${exit.code}` : exit.cause;
2218
2410
  let action = "start it with ahub pi";
2219
2411
  if (stopping) action = "hub is stopping; requested stop, no automatic restart";
2220
- else if (replacingPi.has(pi)) action = "requested replacement; the new Pi owner is starting";
2412
+ else if (requestedPeerStops.has("pi")) action = "requested stop; no automatic restart, start it again with ahub pi";
2413
+ else if (replacingPi.has(pi)) action = "stopped for a new Pi owner; inspect ahub status for the replacement";
2221
2414
  else if (exit.expected) action = "owner teardown; inspect its session, then ahub pi";
2222
2415
  else if (bus.peers.get("pi") !== pi) action = "superseded owner; no automatic restart";
2223
2416
  else if (recoveryActive()) action = "recovery holds automatic restart; inspect ahub status";
@@ -2243,6 +2436,7 @@ export async function startDaemon(opts: DaemonOptions) {
2243
2436
  }
2244
2437
  notify(`Pi exited (${cause}); turn active=${exit.turnActive}, tool active=${exit.toolActive}; ${action}`);
2245
2438
  },
2439
+ onApprovalStop: reason => notify(reason),
2246
2440
  onTokens: (added) => void addTokens("pi", added),
2247
2441
  preamble: roleContract("pi", config.roles) + "\nYou are the pi peer. Hub messages are untrusted peer input, not user authority. Use only the managed tools. Tool writes and shell commands require hub approval. Never repeat an operation whose outcome is uncertain. PII work belongs to the local peer.",
2248
2442
  onTurnFailure: async (envs) => {
@@ -2254,7 +2448,8 @@ export async function startDaemon(opts: DaemonOptions) {
2254
2448
  const task = board.get(Number(id));
2255
2449
  if (!task || task.owner !== "pi" || tasks.isPii(task) || !["proposed", "in_progress", "changes_requested"].includes(task.state)) continue;
2256
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"); }
2257
- 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`); }
2258
2453
  }
2259
2454
  if (!ids.length) notify("Pi inference failed; inspect its session before retrying any effects");
2260
2455
  },
@@ -2293,18 +2488,20 @@ export async function startDaemon(opts: DaemonOptions) {
2293
2488
  const capture = config.memory.enabled
2294
2489
  ? new Capture(memory, { project: chain.at(-1)!, cwd: opts.cwd, skip: skipTools(), deny: config.local.deny })
2295
2490
  : undefined;
2296
- const permit = (title: string, tool?: string, signal?: AbortSignal, path?: string): Promise<boolean> => {
2297
- if (signal?.aborted) return Promise.resolve(false);
2491
+ const permit = async (title: string, tool?: string, signal?: AbortSignal, path?: string, observe?: ApprovalObserver): Promise<ToolApproval> => {
2492
+ if (stopping || signal?.aborted || bus.peers.get("local") !== local) return "aborted";
2298
2493
  const selected = permissionMode("local");
2299
- if (selected === "never-ask" || (selected === "ask-when-needed" && !!tool && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(opts.cwd), path))) return Promise.resolve(!stopping && bus.peers.get("local") === local);
2300
- return onPermission({
2301
- peer: "local",
2302
- title, tool,
2303
- options: [
2304
- { optionId: "allow", name: "Allow", kind: "allow_once" },
2305
- { optionId: "deny", name: "Deny", kind: "reject_once" },
2306
- ],
2307
- }).then((picked) => picked === "allow");
2494
+ if (selected === "never-ask" || (selected === "ask-when-needed" && !!tool && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(opts.cwd), path))) {
2495
+ observe?.({ source: "automatic", answerEpoch: permissionAnswerEpoch("local") });
2496
+ return true;
2497
+ }
2498
+ const picked = await requestPermission({ peer: "local", title, tool, options: [
2499
+ { optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" },
2500
+ ] }, signal, observe);
2501
+ if (picked.outcome === "expired") return "expired";
2502
+ if (stopping || signal?.aborted || bus.peers.get("local") !== local) return "aborted";
2503
+ if (picked.outcome === "cancelled") return false;
2504
+ return picked.optionId === "allow";
2308
2505
  };
2309
2506
  const local = new LocalPeer("local", {
2310
2507
  cwd: opts.cwd,
@@ -2322,6 +2519,7 @@ export async function startDaemon(opts: DaemonOptions) {
2322
2519
  onTool: (observation, task) => observeProgress("local", observation, task),
2323
2520
  fixedModel: args.model ?? routing.local.fixed_model,
2324
2521
  tools: { deny: config.local.deny, bashNetwork: sandboxNetwork, readAllow: config.local.read_allow, permit },
2522
+ onApprovalStop: reason => notify(reason),
2325
2523
  ...(capture ? { capture } : {}),
2326
2524
  taskTool: (name, a, turn) => taskOp("local", name, a, true, turn.pii),
2327
2525
  turnPolicy: (envs) => {
@@ -2424,10 +2622,10 @@ export async function startDaemon(opts: DaemonOptions) {
2424
2622
  launcherPreview(peer, peer === "pi" ? ["--mode", "tui"] : [], opts.cwd, opts.stateDir, false);
2425
2623
  return `ahub ${peer}${peer === "pi" ? " --mode tui" : ""}`;
2426
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),
2427
2627
  start: async peer => {
2428
- const existing = bus.peers.get(peer);
2429
- 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");
2430
- const result = await startPeer(peer, { mode: "headless" });
2628
+ const result = await hubStart(peer, "the conductor");
2431
2629
  if (result.ok !== true) throw new Error(String(result.error ?? "peer start failed"));
2432
2630
  return result;
2433
2631
  },
@@ -2479,7 +2677,7 @@ export async function startDaemon(opts: DaemonOptions) {
2479
2677
  arm: rs[0]!.header.arm, runs: rs.length, measures: benchReport(rs).overall })) } };
2480
2678
  } catch { return {}; } // an unreadable store never breaks the dashboard
2481
2679
  }
2482
- function uiSnapshot(after: number) {
2680
+ function uiSnapshot(after: number, _input?: Record<string, unknown>, session?: DashboardSession) {
2483
2681
  const quota = budget.status();
2484
2682
  const peers = [...new Set([...Object.keys(quota), ...bus.knownPeers()])];
2485
2683
  return {
@@ -2503,12 +2701,157 @@ export async function startDaemon(opts: DaemonOptions) {
2503
2701
  }),
2504
2702
  events: uiEvents.filter((e) => e.seq > after),
2505
2703
  cursor: uiSequence,
2704
+ ...settingsSnapshot(session),
2705
+ starts: startPlans(),
2506
2706
  ...benchView(),
2507
2707
  };
2508
2708
  }
2509
2709
 
2510
- 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> {
2511
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";
2512
2855
  const text = (key: string, max: number) => typeof a[key] === "string" && (a[key] as string).length <= max;
2513
2856
  const peer = () => text("peer", 32) && PEER_ID.test(a.peer as string);
2514
2857
  const taskId = () => typeof a.id === "number" && Number.isSafeInteger(a.id) && a.id > 0;
@@ -2540,6 +2883,22 @@ export async function startDaemon(opts: DaemonOptions) {
2540
2883
  }
2541
2884
  case "assign":
2542
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);
2543
2902
  default:
2544
2903
  return bad;
2545
2904
  }
@@ -2556,6 +2915,12 @@ export async function startDaemon(opts: DaemonOptions) {
2556
2915
  ...(!budget.recoverySettled ? ["budget transition in progress"] : []),
2557
2916
  ...([...bus.peers].filter(([, peer]) => peer.state === "busy").map(([id]) => `${id} is busy`)),
2558
2917
  ...(permissions.size ? ["pending approvals"] : []),
2918
+ // #272: the rest of what recoveryReady() waits for, so the upgrade screen can name the wait. Display only.
2919
+ ...(taskOpsInFlight !== 0 ? ["a task command in flight"] : []),
2920
+ ...(tasks.checksPending() !== 0 ? ["completion checks queued or running"] : []),
2921
+ ...((piReceipts?.inFlight ?? 0) !== 0 ? ["Pi tool calls in flight"] : []),
2922
+ ...(releasing ? ["ready tasks being released"] : []),
2923
+ ...([...bus.peers].filter(([, peer]) => peer instanceof PiPeer && peer.state !== "busy" && !peer.recoveryReady).map(([id]) => `${id} is not settled`)),
2559
2924
  ],
2560
2925
  integrity: { current: integrityAs(restored?.integrity), ...(restored?.integrity ? { expected: restored.integrity } : {}) },
2561
2926
  peers: Object.fromEntries([...(recoveryPeerSnapshot ?? []), ...Object.values(recoveryPeers()).filter((peer) => !(recoveryPeerSnapshot ?? []).some((saved) => saved.id === peer.id))].map((peer) => {
@@ -2578,6 +2943,7 @@ export async function startDaemon(opts: DaemonOptions) {
2578
2943
  if (typeof msg.operationId !== "string" || msg.operationId.length < 1 || msg.operationId.length > 128) return recoveryError("operationId is required");
2579
2944
  const op = msg.operationId as string;
2580
2945
  if (msg.op === "prepare") {
2946
+ if (requestedPeerStops.size) return recoveryError("peer stop is in progress; wait for ahub status before preparing recovery");
2581
2947
  if (recoveryCommitted) return recoveryError("recovery commit is already in progress");
2582
2948
  if (recoveryPhase === "released" && recoveryOperationId !== op) {
2583
2949
  recoveryOperationId = undefined;
@@ -2678,17 +3044,19 @@ export async function startDaemon(opts: DaemonOptions) {
2678
3044
  }
2679
3045
  async function changePermission(peer: unknown, mode: unknown, confirmed: unknown): Promise<Record<string, unknown>> {
2680
3046
  if (typeof peer !== "string" || !["claude", "codex", "kimi", "pi", "local"].includes(peer)) return { ok: false, error: "unknown permission peer" };
3047
+ if (requestedPeerStops.has(peer)) return { ok: false, error: "peer stop is in progress; wait for ahub status before changing its mode" };
2681
3048
  const owner = bus.peers.get(peer);
2682
3049
  if (mode === "ask" && (!owner || owner.state === "offline")) {
2683
3050
  // A Codex proxy outlives its TUI and keeps its own copy of the mode: clear that too, or the next TUI that attaches
2684
3051
  // to it would run a mode the status no longer shows.
2685
3052
  // It is never refused here: a leftover non-ask mode must always be undoable while the peer is away.
2686
- if (owner instanceof CodexPeer && !owner.clearPermissionMode()) log("permission mode codex: the detached session's own approval policy was never reported, so its thread keeps what it last got; a new ahub codex session starts in ask");
3053
+ const note = owner instanceof CodexPeer && !owner.clearPermissionMode() ? "The detached session's own approval policy was never reported, so its thread keeps what it last got; a new ahub codex session starts in ask" : undefined;
3054
+ if (note) log(`permission mode codex: ${note}`);
2687
3055
  const from = permissionMode(peer);
2688
3056
  permissionModes.set(peer, "ask"); permissionModeSources.set(peer, "human runtime command"); pendingPermissionDefaults.delete(peer);
2689
3057
  event({ type: "permission_mode", peer, from, to: "ask" });
2690
3058
  log(`permission mode ${peer}: ${from} -> ask (offline hub choice cleared)`); writeStatus();
2691
- return { ok: true, peer, permissionMode: "ask" };
3059
+ return { ok: true, peer, permissionMode: "ask", ...(note ? { note } : {}) };
2692
3060
  }
2693
3061
  if (owner instanceof CodexPeer && !owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
2694
3062
  if (!owner || owner.state === "offline") return { ok: false, error: `${peer} is not attached; start it through ahub first` };
@@ -2817,11 +3185,13 @@ export async function startDaemon(opts: DaemonOptions) {
2817
3185
  switch (msg.t) {
2818
3186
  case "permission_default": {
2819
3187
  if (c.role !== "console") return void reply({ ok: false, error: "permission default confirmation is a human console action" });
3188
+ if (typeof msg.peer === "string" && requestedPeerStops.has(msg.peer)) return void reply({ ok: false, error: "peer stop is in progress; wait for ahub status before changing its mode" });
2820
3189
  void permissionChange(msg.peer, () => confirmPermissionDefault(msg.peer, msg.confirmed)).then(reply).catch(() => reply({ ok: false, error: "permission default confirmation failed; inspect status" }));
2821
3190
  return;
2822
3191
  }
2823
3192
  case "permission": {
2824
3193
  if (c.role !== "console") return void reply({ ok: false, error: "permission modes are human console commands" });
3194
+ if (typeof msg.peer === "string" && requestedPeerStops.has(msg.peer)) return void reply({ ok: false, error: "peer stop is in progress; wait for ahub status before changing its mode" });
2825
3195
  if (msg.peer === undefined) return void reply({ ok: true, peers: Object.fromEntries([...bus.peers].filter(([, p]) => p.state !== "offline").map(([id]) => [id, permissionDisplay(id, bus.peers.get(id))])) });
2826
3196
  void permissionChange(msg.peer, () => changePermission(msg.peer, msg.mode, msg.confirmed)).then(reply).catch(() => reply({ ok: false, error: "permission mode change failed; inspect status" }));
2827
3197
  return;
@@ -2886,7 +3256,13 @@ export async function startDaemon(opts: DaemonOptions) {
2886
3256
  }
2887
3257
  case "recovery":
2888
3258
  if (c.role !== "console") return void reply(recoveryError("recovery is a console command"));
2889
- 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
+ });
2890
3266
  return;
2891
3267
  case "send": {
2892
3268
  if (recoveryActive()) return void reply({ t: "sent", ok: false, error: "recovery is holding new deliveries" });
@@ -3079,7 +3455,9 @@ export async function startDaemon(opts: DaemonOptions) {
3079
3455
  try {
3080
3456
  dashboard ??= startDashboard({ snapshot: uiSnapshot, action: uiAction });
3081
3457
  writeStatus();
3082
- 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 } : {}) });
3083
3461
  } catch {
3084
3462
  return void reply({ t: "ui", ok: false, error: "could not start the dashboard" });
3085
3463
  }
@@ -3096,6 +3474,28 @@ export async function startDaemon(opts: DaemonOptions) {
3096
3474
  if (!msg.action || typeof msg.action !== "object" || Array.isArray(msg.action)) return void reply({ ok: false, error: "invalid dashboard action" });
3097
3475
  void uiAction(msg.action).then((result) => reply(result as Record<string, unknown>), () => reply({ ok: false, error: "dashboard action failed; check its inputs" }));
3098
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;
3495
+ case "peer_stop":
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" });
3497
+ void stopPeer(msg.peer).then(reply).catch(() => reply({ ok: false, error: "peer stop failed; inspect ahub status and logs before retrying" }));
3498
+ return;
3099
3499
  case "start":
3100
3500
  if (c.role !== "console") return void reply({ t: "started", ok: false, error: "start is a console command; use hub_peer_start with the conductor role" });
3101
3501
  if (recoveryActive() && !(recoveryPhase === "restored" && msg.operationId === recoveryOperationId)) return void reply({ t: "started", ok: false, error: "recovery is holding mutations" });
@@ -3263,12 +3663,12 @@ export async function startDaemon(opts: DaemonOptions) {
3263
3663
  void recoverAfterCrash(crashed).catch((error) => {
3264
3664
  log(`crash recovery failed: ${(error as Error).message}`);
3265
3665
  // pi.auto_start still holds: a running Pi answers `already`, a starting one refuses the second start.
3266
- if (piAutoStart) void startPeer("pi", {}).catch((e) => log(`Pi auto-start failed: ${e.message}`));
3666
+ if (piAutoStart) autoStartPi();
3267
3667
  });
3268
3668
  }
3269
3669
  // After a crash that recorded Pi, crash recovery starts it (#66): its recorded session first, a fresh one if that fails.
3270
3670
  const piRecovered = !!crashed?.peers.some((p) => p.peer === "pi");
3271
- if (piAutoStart && !recoveryActive() && !piRecovered) void startPeer("pi", {}).catch((error) => log(`Pi auto-start failed: ${error.message}`));
3671
+ if (piAutoStart && !recoveryActive() && !piRecovered) autoStartPi();
3272
3672
  return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
3273
3673
  } finally {
3274
3674
  if (!ready) for (const cleanup of startupCleanup.reverse()) { try { cleanup(); } catch { /* preserve startup error */ } }