@staix/agent-hub 0.12.19 → 0.12.20

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 (66) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +3 -3
  3. package/docs/agent-notes/adapters.md +1 -0
  4. package/docs/agent-notes/bus.md +1 -0
  5. package/docs/agent-notes/daemon.md +2 -1
  6. package/docs/agent-notes/models.md +3 -0
  7. package/docs/agent-notes/tasks.md +3 -2
  8. package/docs/events.md +4 -1
  9. package/docs/operations.md +413 -17
  10. package/docs/quickstart.md +1 -1
  11. package/docs/security.md +17 -10
  12. package/docs/smoke.md +58 -0
  13. package/docs/specs/2026-09-19-agent-hub-design.md +399 -3
  14. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  15. package/docs/specs/2026-09-20-upgrade-recovery-design.md +287 -0
  16. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  17. package/docs/verified.json +13 -13
  18. package/package.json +1 -1
  19. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  20. package/plugins/agent-hub/server.js +63 -25
  21. package/src/adapters/claude-channel.ts +61 -20
  22. package/src/adapters/codex-appserver.ts +3 -0
  23. package/src/adapters/local-worker.ts +4 -0
  24. package/src/cli/console-state.ts +325 -102
  25. package/src/cli/console.ts +44 -23
  26. package/src/cli/help.ts +120 -0
  27. package/src/cli/identity.ts +3 -3
  28. package/src/cli/launch.ts +2 -0
  29. package/src/cli/main.ts +171 -75
  30. package/src/cli/preview.ts +1 -0
  31. package/src/cli/reset.ts +194 -0
  32. package/src/cli/status-lines.ts +9 -5
  33. package/src/cli/terminal-recovery.ts +140 -36
  34. package/src/cli/upgrade-runtime.ts +274 -56
  35. package/src/cli/upgrade.ts +366 -41
  36. package/src/hub/ask.ts +1 -1
  37. package/src/hub/board.ts +15 -7
  38. package/src/hub/bus.ts +60 -4
  39. package/src/hub/child-process.ts +1 -0
  40. package/src/hub/conductor.ts +38 -6
  41. package/src/hub/control-client.ts +5 -3
  42. package/src/hub/daemon.ts +56 -9
  43. package/src/hub/events.ts +7 -1
  44. package/src/hub/execution-budget.ts +15 -6
  45. package/src/hub/hub-tools.ts +4 -4
  46. package/src/hub/inference.ts +136 -1
  47. package/src/hub/lifecycle.ts +7 -1
  48. package/src/hub/peers.ts +2 -0
  49. package/src/hub/recovery-store.ts +51 -3
  50. package/src/hub/report.ts +43 -1
  51. package/src/hub/restart.ts +25 -0
  52. package/src/hub/routing.ts +48 -16
  53. package/src/hub/snapshots.ts +3 -2
  54. package/src/hub/tasks.ts +236 -83
  55. package/src/local/deny.ts +2 -1
  56. package/src/models/mlx.ts +11 -8
  57. package/src/models/ollama.ts +2 -2
  58. package/src/models/relay.ts +190 -42
  59. package/src/models/route/relay-selector.ts +43 -31
  60. package/src/models/route/runtime.ts +17 -7
  61. package/src/models/route/signals.ts +12 -1
  62. package/src/models/route/stage.ts +57 -3
  63. package/src/omniroute/client.ts +1 -1
  64. package/src/ui/index.html +1 -0
  65. package/templates/AGENTS.block.md +1 -1
  66. package/templates/routing.toml +8 -0
package/src/cli/main.ts CHANGED
@@ -10,7 +10,7 @@ import { loadConfig } from "../hub/daemon.ts";
10
10
  import { factsHook } from "./facts-hook.ts";
11
11
  import { projectContext, realPath } from "../hub/project.ts";
12
12
  import { Registry, type Project } from "../hub/registry.ts";
13
- import { inspectProject, startProject, stopProject, runProjectDaemon } from "../hub/lifecycle.ts";
13
+ import { inspectProject, processAlive, startProject, stopProject, runProjectDaemon } from "../hub/lifecycle.ts";
14
14
  import { openManager, startManager, stopManager } from "../hub/manager.ts";
15
15
  import { OmniRoute } from "../omniroute/client.ts";
16
16
  import { MemoryClient } from "../memory/client.ts";
@@ -22,9 +22,9 @@ import { CLASSES } from "../hub/board.ts";
22
22
  import { VERSION } from "../version.ts";
23
23
  import { freeText } from "./free-text.ts";
24
24
  import { createInterface } from "node:readline/promises";
25
- import { assertLifecycleAvailable, readOperation } from "../hub/recovery-store.ts";
25
+ import { activeOperation, assertLifecycleAvailable, readOperation, recoveryLock, recoveryRunner } from "../hub/recovery-store.ts";
26
26
  import { childEnv } from "../hub/child-process.ts";
27
- import { abortRecovery, createOperation, publicOperation, registeredProjects, runRecovery, type RecoveryOperation } from "./upgrade.ts";
27
+ import { abortRecovery, createOperation, disposeRecovery, liveProjects, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation } from "./upgrade.ts";
28
28
  import { makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
29
29
  import { recordTerminalLaunch } from "./terminal-recovery.ts";
30
30
  import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
@@ -40,7 +40,9 @@ import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, peerCommandRef
40
40
  import { recordCliAudit } from "./identity-audit.ts";
41
41
  import { runConsole } from "./console.ts";
42
42
  import { resolveColor } from "./console-state.ts";
43
+ import { renderHelp } from "./help.ts";
43
44
  import { renderTailEvent } from "./tail-render.ts";
45
+ import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
44
46
 
45
47
  /** `--since 7d|24h|<iso>` for export and report; everything when absent. */
46
48
  const since = (): number => {
@@ -51,62 +53,6 @@ const since = (): number => {
51
53
  return t;
52
54
  };
53
55
 
54
- const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one project directory
55
-
56
- ahub --project <path|id> <command> select a repository or worktree explicitly
57
- ahub projects [--json] list registered projects and live status
58
- ahub projects remove <id> forget a stopped registration (keeps project files)
59
- ahub status --all show every registered project
60
- ahub ui --all [--no-open] open the unified project dashboard
61
- ahub ui --all --stop stop only the dashboard manager
62
- ahub setup [--yes] install or update the Claude Code channel plugin from this package, then run doctor
63
- ahub init [--dry-run --json] preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)
64
- ahub up [--unattended] [--no-console] start the daemon; interactive terminals enter console
65
- ahub console [--panels] [--color=auto|always|never] enter the human console, leaving the daemon running on exit
66
- ahub upgrade --to <version> [--dry-run] [--yes] review and upgrade running projects
67
- ahub restart [--dry-run] [--yes] recover this project's runtime
68
- ahub recovery status|resume|abort <operation-id> inspect, resume or cancel a preflight
69
- ahub claude [--print-command] [args...] launch Claude Code with the hub channel [--unattended]
70
- ahub codex [--print-command] [args...] start the Codex adapter and attach the TUI [--unattended]
71
- ahub kimi [--print-command] [--model <alias>] start Kimi headless under ACP
72
- ahub pi [--print-command] [--mode headless|tui] [--backend auto|dgx|mlx] [--session-id <id>] [--session-file <path>] start Pi
73
- ahub models setup|status|start|stop prepare or inspect local Ollama MLX (legacy stop is explicit)
74
- ahub local [--route <id> | --model <id>]
75
- start the hub-native worker on the self-hosted models (routing.toml)
76
- ahub say [@peer ...] <text> send as the console user (no @peer = broadcast); delivered at once,
77
- start the text with [STATUS] to let it batch or [FYI] for the record only
78
- ahub pause|resume <peer> hold a peer's deliveries in its queue / release them
79
- ahub budget execution configure <config.json> | status [id] | disable <id>
80
- ahub budget quota windows per peer, and who is paused until when
81
- ahub budget set <peer> <0..1> [--resets-in 30m] [--window 5h|week] feed a reading by hand (also: test the relay)
82
- ahub budget resume <peer> override a budget pause; readings are ignored for that peer until the window resets
83
- ahub board [state | --ready] the task board; --ready: proposed tasks with nothing left to wait for
84
- ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--after <id>]... [--urgent] [--detail <text>]
85
- ahub task show|escalate <id> full task with history (PII text included) / hand it to the next peer in escalate_to
86
- ahub task assign <id> <peer> give a task to a peer yourself
87
- ahub review <id> approved|changes_requested [note...] [--unmet <item>]...
88
- ahub remember <text...> save a note to the memory all agents share
89
- ahub ask [--remember] <question...> answer from the task board, shared memory and this run's log, with the ids it rests on
90
- ahub route explain <id> why a task went where it went
91
- ahub route explain --class <c> <title...> what would happen to such a task now
92
- ahub ui [--no-open] open the local dashboard (or print its one-time link)
93
- ahub tail live stream of messages, states and permission requests
94
- ahub permit <id> <option> answer a permission request shown by tail ("deny" cancels)
95
- ahub queue list [--peer <id>] [--json] inspect durable deliveries
96
- ahub queue show <delivery-id> inspect one delivery and revision
97
- ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text>
98
- ahub status | logs [-f] | doctor | kill
99
- ahub export [--since 7d|<iso>] structured events (events.jsonl) as JSON lines; never message bodies
100
- ahub report [--since 7d|<iso>] [--by task] [--json] turns, tokens, messages, overlaps and task events per period
101
- ahub check-path <file> [--peer <id>] other owners' open tasks that claim or changed a file
102
- ahub check-path --hook the same as a Claude Code PreToolUse hook (templates/claude-hooks.json); never blocks
103
- ahub facts --hook turn-free facts as a Claude Code PreToolUse, PostToolUse and Stop hook (issue #108); never blocks
104
- ahub turns [peer] [--limit N] recent turns and the files each changed (a git work tree only)
105
- ahub undo <turn> [--yes] [--context] put back the files a turn changed; refuses files changed since.
106
- Without --yes it only lists them; --context also drops a Codex turn from its conversation
107
- ahub doctor --orphans [--kill] list registrations whose project root is gone; --kill stops their
108
- daemons (SIGTERM, then SIGKILL) only after the process identity checks out`;
109
-
110
56
  const argv = process.argv.slice(2);
111
57
  let selector: string | undefined;
112
58
  if (argv[0] === "--project") {
@@ -114,7 +60,12 @@ if (argv[0] === "--project") {
114
60
  selector = argv.shift();
115
61
  if (!selector || selector.startsWith("--")) fail("--project needs a path or project ID");
116
62
  }
117
- const [cmd = "help", ...args] = argv;
63
+ const [given = "help", ...rest] = argv;
64
+ // `ahub <command> -h` alone asks for that command's help instead of running it. Elsewhere `-h` may be message text
65
+ // (`ahub say try ls -h`), and claude and codex pass their arguments on to the agent.
66
+ const asksHelp = (a: string | undefined) => a === "--help" || a === "-h";
67
+ const cmd = asksHelp(given) || (given !== "claude" && given !== "codex" && rest.length === 1 && asksHelp(rest[0])) ? "help" : given;
68
+ const args = cmd === "help" && given !== "help" && !asksHelp(given) ? [given] : rest;
118
69
  let selected: { root: string; stateDir: string };
119
70
  try {
120
71
  if (selector) {
@@ -157,6 +108,30 @@ function registeredProject(): Project {
157
108
  finally { registry.close(); }
158
109
  }
159
110
 
111
+ /** The registration of exactly this root and state directory, the only one kill and reset act on; never registers. */
112
+ function matchingProject(): Project | undefined {
113
+ const registry = new Registry();
114
+ try { return registry.list().find((p) => p.root === cwd && p.stateDir === stateDir); }
115
+ finally { registry.close(); }
116
+ }
117
+ const hubManifest = () => !!readControl(stateDir) || existsSync(join(stateDir, "hub.pid"));
118
+ /** A reset is never part of an upgrade or recovery: any operation holding the machine's lock refuses it, whatever
119
+ * AGENTHUB_RECOVERY_OPERATION says (assertLifecycleAvailable lets that operation's own processes through). */
120
+ function resetLockFree(): void {
121
+ const owner = recoveryLock();
122
+ if (owner) throw new Error(`${activeOperation(owner)}; nothing was changed`);
123
+ }
124
+ /** A manifest whose daemon is alive or uncertain, by inspectProject's rule; a crashed daemon's leftovers do not count. */
125
+ function liveManifest(): boolean {
126
+ const control = readControl(stateDir);
127
+ if (control && processAlive(control.pid) !== false) return true;
128
+ let text: string;
129
+ try { text = readFileSync(join(stateDir, "hub.pid"), "utf8").trim(); } catch { return false; }
130
+ if (/^\d+$/.test(text)) return processAlive(Number(text)) !== false;
131
+ // Empty or garbled: beside a dead daemon's status it is that daemon's leftover too; alone it proves nothing.
132
+ return !control;
133
+ }
134
+
160
135
  async function healthy(): Promise<boolean> {
161
136
  let hub: ControlClient | undefined;
162
137
  try { hub = await connect(); const reply = await hub.request({ t: "status" }, 3000); return reply.status?.cwd === cwd; }
@@ -353,9 +328,10 @@ function spawnRecovery(operation: RecoveryOperation): void {
353
328
  const child = spawn(process.execPath, [join(operation.sourceRoot, "src/cli/main.js"), "recovery-run", operation.id], {
354
329
  cwd, detached: true, stdio: "ignore", env: { ...process.env, AGENTHUB_RECOVERY_OPERATION: operation.id },
355
330
  });
356
- child.on("error", () => console.error(`runner launch failed; use ahub recovery resume ${operation.id}`));
331
+ child.on("error", () => console.error(`runner launch failed; use ${recoveryCommand(operation, "resume")}`));
357
332
  child.unref();
358
- console.log(`Recovery operation ${operation.id} scheduled.\nahub recovery status ${operation.id}`);
333
+ // #215: the operation's own coordinator, which has every recovery command; the global ahub may be older mid-upgrade.
334
+ console.log(`Recovery operation ${operation.id} scheduled.\n${recoveryCommand(operation, "status")}`);
359
335
  }
360
336
 
361
337
  async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
@@ -365,8 +341,13 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
365
341
  if (kind === "upgrade" && selector) fail("upgrade changes the shared package/plugin; omit --project to review all affected running projects");
366
342
  const plan = await makeUpgradePlan(kind, one["--to"] ?? VERSION, kind === "restart" ? cwd : undefined);
367
343
  console.log(JSON.stringify(plan, null, 2));
344
+ // #206: name every blocker and reconnect-only session on stderr, not only inside the JSON above.
345
+ for (const p of plan.projects) for (const peer of p.reconnectOnly ?? []) console.error(`ahub: ${p.project.id}: ${peer} is reconnect-only (unmanaged session): its plugin reattaches to the new hub; no terminal is closed or relaunched`);
346
+ for (const p of plan.projects) for (const peer of p.freshStart ?? []) console.error(`ahub: ${p.project.id}: ${peer} restarts as a new session: its thread has no rollout and the hub recorded no turn on it, so nothing is lost`);
347
+ const blockers = [...plan.blockers, ...plan.projects.flatMap((p) => p.blockers.map((b) => `${p.project.id}: ${b}`))];
348
+ for (const blocker of blockers) console.error(`ahub: blocker: ${blocker}`);
368
349
  if (args.includes("--dry-run")) return;
369
- if (plan.blockers.length || plan.projects.some((p) => p.blockers.length)) fail("plan has blockers; no runtime was changed");
350
+ if (blockers.length) fail("plan has blockers; no runtime was changed");
370
351
  assertLifecycleAvailable();
371
352
  if (!args.includes("--yes")) {
372
353
  if (!process.stdin.isTTY) fail("review --dry-run and use --yes in non-interactive sessions");
@@ -386,12 +367,33 @@ const commands: Record<string, () => Promise<void> | void> = {
386
367
  upgrade: () => upgrade("upgrade"),
387
368
  restart: () => upgrade("restart"),
388
369
  recovery: async () => {
389
- const [action, id] = args;
390
- if (args.length !== 2 || !id || !["status", "resume", "abort"].includes(action ?? "")) fail("usage: ahub recovery status|resume|abort <operation-id>");
370
+ const [action, id, ...rest] = args;
371
+ const usage = "usage: ahub recovery status|resume|abort <operation-id> | ahub recovery dispose <operation-id> --fresh-session <peer>|--stop-and-archive --reason <text>";
372
+ if (!id || !["status", "resume", "abort", "dispose"].includes(action ?? "") || (action !== "dispose" && rest.length)) fail(usage);
391
373
  const operation = readOperation<RecoveryOperation>(id);
392
- if (action === "status") console.log(JSON.stringify(publicOperation(operation), null, 2));
374
+ const runner = recoveryRunner(id);
375
+ if (action === "status") {
376
+ // #215: abort and resume are offered by what the open sources show, read as the runner's errors read them.
377
+ const live = runner ? {} : await liveProjects(operation, makeRecoveryDriver().inspect);
378
+ console.log(JSON.stringify(publicOperation(operation, runner, live), null, 2));
379
+ }
393
380
  else if (action === "abort") { await abortRecovery(id, makeRecoveryDriver()); console.log("preflight cancelled; no committed transition was rolled back"); }
381
+ else if (action === "dispose") {
382
+ // #215: human-only (the identity gate refuses agent shells); the reason is kept in the operation's audit.
383
+ const { one, rest: flags } = takeFlags(rest, ["--fresh-session", "--reason"], []);
384
+ const stop = flags.length === 1 && flags[0] === "--stop-and-archive";
385
+ const reason = one["--reason"]?.trim() ?? "";
386
+ if (!reason || reason.length > 500 || (flags.length && !stop) || stop === !!one["--fresh-session"]) fail(usage);
387
+ const result = await disposeRecovery(id, stop ? { stop: true } : { fresh: one["--fresh-session"]! }, reason, makeRecoveryDriver());
388
+ if (!stop) { console.log(`${one["--fresh-session"]}: a fresh session is accepted and the lost one is recorded; resuming`); return spawnRecovery(result); }
389
+ console.log(JSON.stringify(publicOperation(result), null, 2));
390
+ console.log(`${result.plan.kind} abandoned, not completed; the recovery lock is released. A project whose target ran starts again with that version's CLI.`);
391
+ }
394
392
  else if (["completed", "cancelled"].includes(operation.phase)) console.log(`recovery is already ${operation.phase}`);
393
+ // #215: an operation being abandoned is never resumed, whichever coordinator started it (an older one would).
394
+ else if (operation.disposition) fail(`a stop-and-archive of this operation is partway; nothing was resumed; ${nextActionsText(operation, await liveProjects(operation, makeRecoveryDriver().inspect))}`);
395
+ else if (runner === "unknown") console.log(`whether a runner holds this operation could not be read; nothing was started; ${recoveryCommand(operation, "status")}`);
396
+ else if (runner) console.log(`runner ${runner} is still working on this operation; ${recoveryCommand(operation, "status")}`);
395
397
  else spawnRecovery(operation);
396
398
  },
397
399
  "recovery-run": async () => {
@@ -399,7 +401,13 @@ const commands: Record<string, () => Promise<void> | void> = {
399
401
  const result = await runRecovery(args[0]!, makeRecoveryDriver());
400
402
  if (result.phase !== "completed") process.exitCode = 1;
401
403
  },
402
- help: () => console.log(USAGE),
404
+ help: () => {
405
+ const color = resolveColor(undefined, { isTTY: !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR }) === true;
406
+ const topic = asksHelp(args[0]) ? undefined : args[0];
407
+ const text = renderHelp(process.stdout.columns ?? 80, color, topic);
408
+ if (!text) fail(`unknown command "${topic}"; run ahub help`);
409
+ console.log(text);
410
+ },
403
411
  "--version": () => console.log(VERSION),
404
412
  version: () => console.log(VERSION),
405
413
 
@@ -546,12 +554,18 @@ const commands: Record<string, () => Promise<void> | void> = {
546
554
  }
547
555
  assertLifecycleAvailable();
548
556
  const launch0 = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: "pending" }); // refuse bad flags before starting anything
557
+ // #215: a launch by a recovery operation records itself before the hub round trip, so the coordinator never takes
558
+ // a launcher still starting for one that never ran. An ordinary launch records only once the hub accepted it: a
559
+ // refused start must not replace the record of a Codex already running here (one row per peer and instance).
560
+ const recovering = !!process.env.AGENTHUB_RECOVERY_OPERATION && process.env.AGENTHUB_RECOVERY_OPERATION === recoveryLock(); // not an id a restored session inherited
561
+ const before = readControl(stateDir);
562
+ if (recovering && before?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, before.instanceId);
549
563
  const hub = await connect();
550
564
  const res = await hub.request({ t: "start", peer: "codex", operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
551
565
  hub.close();
552
566
  if (!res.ok) fail(res.error);
553
567
  const control = readControl(stateDir);
554
- if (control?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, control.instanceId);
568
+ if (!recovering && control?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, control.instanceId);
555
569
  const launch = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: res.proxyUrl, codexBin: projectConfig().codex_bin });
556
570
  if (launch0.warning) console.error(launch0.warning);
557
571
  exec(launch.cmd, launch.args, "codex");
@@ -577,6 +591,11 @@ const commands: Record<string, () => Promise<void> | void> = {
577
591
  catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
578
592
  }
579
593
  const options = piFlags();
594
+ // #215: as for Codex, a TUI launch by the recovery operation holding the lock records itself before the hub round
595
+ // trip, so a resume in that window never reads it as gone; an ordinary launch records only after the hub accepted it.
596
+ const recovering = options.mode === "tui" && !!process.env.AGENTHUB_RECOVERY_OPERATION && process.env.AGENTHUB_RECOVERY_OPERATION === recoveryLock();
597
+ const before = readControl(stateDir);
598
+ if (recovering && before?.instanceId) await recordTerminalLaunch("pi", cwd, stateDir, before.instanceId);
580
599
  const hub = await connect();
581
600
  const res = await hub.request({ t: "start", peer: "pi", args: options, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
582
601
  hub.close();
@@ -588,7 +607,7 @@ const commands: Record<string, () => Promise<void> | void> = {
588
607
  delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
589
608
  delete launchEnv.AGENTHUB_UNATTENDED;
590
609
  const control = readControl(stateDir);
591
- if (control?.instanceId) await recordTerminalLaunch("pi", cwd, stateDir, control.instanceId);
610
+ if (!recovering && control?.instanceId) await recordTerminalLaunch("pi", cwd, stateDir, control.instanceId);
592
611
  return execWithEnv(launch.cmd, launch.args, launchEnv);
593
612
  }
594
613
  console.log(res.already ? "pi is already attached" : 'pi attached (headless). Talk to it with: ahub say @pi "..."');
@@ -943,18 +962,95 @@ const commands: Record<string, () => Promise<void> | void> = {
943
962
  },
944
963
 
945
964
  kill: async () => {
946
- const registry = new Registry();
947
- let project: Project | undefined;
948
- try { project = registry.list().find((p) => p.root === cwd && p.stateDir === stateDir); }
949
- finally { registry.close(); }
965
+ const project = matchingProject();
950
966
  if (!project) {
951
- if (!readControl(stateDir) && !existsSync(join(stateDir, "hub.pid"))) return console.log("hub is not running");
967
+ if (!hubManifest()) return console.log("hub is not running");
952
968
  fail("hub has no matching registration; use its matching CLI to stop it before upgrading");
953
969
  }
954
970
  await stopProject(project);
955
971
  console.log("hub stopped");
956
972
  },
957
973
 
974
+ reset: async () => {
975
+ if (args.some((a) => !["--all", "--yes"].includes(a))) fail("usage: ahub reset [--all] [--yes]");
976
+ const all = args.includes("--all");
977
+ resetLockFree();
978
+ // Registered with another state directory: `ahub up` here would register the default one and orphan that state.
979
+ const elsewhere = registeredProjects().find((p) => p.root === cwd);
980
+ const project = matchingProject() ?? fail(hubManifest() ? "hub has no matching registration; use its matching CLI to stop it; nothing was changed"
981
+ : elsewhere ? `this project is registered with the state directory ${elsewhere.stateDir}; run ahub --project ${elsewhere.id} reset; nothing was changed`
982
+ : "no registration matches this project and state directory; nothing was changed (ahub up registers it)");
983
+ if (!existsSync(stateDir)) return console.log("no state directory; nothing to reset");
984
+ // A state directory elsewhere (AGENTHUB_STATE_DIR, or a symlink) would cross file systems or pull outside state
985
+ // into the project tree.
986
+ if (all && stateDir !== join(cwd, ".agenthub", "state")) fail(`--all moves only ${join(cwd, ".agenthub", "state")}; this project's state is in ${stateDir}: stop the hub and archive it by hand; nothing was changed`);
987
+ const unsafe = all ? archiveProblem(cwd, stateDir) : undefined;
988
+ if (unsafe) fail(`${unsafe}; nothing was changed`);
989
+ const live = await inspectProject(project);
990
+ if (!["stopped", "running", "stopping"].includes(live.state)) fail(`${live.error ?? `hub is ${live.state}`}; nothing was changed`);
991
+ const recovery = live.status?.recovery as { operationId?: string; phase?: string } | undefined;
992
+ if (recovery?.operationId && recovery.phase !== "released") fail(`recovery operation ${recovery.operationId} is open in this hub; nothing was changed`);
993
+ const kept = "kept: the board, logs and audit, recovery records, execution budgets, configuration and pi-sessions/";
994
+ const memory = "claude-mem is not touched: notes saved with hub_remember stay in shared memory";
995
+ if (!args.includes("--yes")) {
996
+ // Planned only for the dry run: `--all --yes` archives a state directory it cannot read as it is.
997
+ let plan: ResetPlan | undefined, unread: string | undefined;
998
+ try { plan = planReset(stateDir, project.id); }
999
+ catch (error) {
1000
+ if (!all) fail(`cannot read the state (${failureText(error)}); nothing was changed${damagedState(error) ? "; ahub reset --all --yes archives it as it is" : ""}`);
1001
+ unread = failureText(error);
1002
+ }
1003
+ console.log(`ahub reset${all ? " --all" : ""}, dry run: the hub is ${live.state}${live.state === "stopped" ? "" : "; --yes stops it first, as ahub kill does"}`);
1004
+ if (all) {
1005
+ console.log(` move ${stateDir}${plan ? ` (${plan.entries} entries)` : ""} to ${join(cwd, ".agenthub", "archive")}/state-<UTC time>/ (0700), then start an empty state directory holding only project.json; project ${project.id} keeps its id and registration`);
1006
+ console.log(unread ? ` could not read the state (${unread}); it is archived as it is` : " archived as they are:");
1007
+ } else console.log(' settle as discard with reason "reset", clear and drop:');
1008
+ for (const line of plan ? resetLines(plan) : []) console.log(` ${line}`);
1009
+ const stale = live.state === "stopped" && !all ? MANIFEST.filter((name) => existsSync(join(stateDir, name))) : [];
1010
+ if (stale.length) console.log(` remove the manifest a hub left when it did not stop cleanly: ${stale.join(", ")}`);
1011
+ console.log(` ${all ? ".agenthub/config.json, config.local.json and routing.toml are outside the state directory and stay" : kept}`);
1012
+ console.log(` ${memory}`);
1013
+ return console.log("nothing was changed; add --yes to apply");
1014
+ }
1015
+ await stopProject(project);
1016
+ if (live.state !== "stopped") console.log("hub stopped");
1017
+ // Hold the claim a daemon takes to run: a hub started after the stop holds it (refused here), and none can start
1018
+ // while the reset acts. A reset that dies leaves a claim with a dead pid, which the next start takes over.
1019
+ const registry = new Registry();
1020
+ // The prefix tells a concurrent reset's claim from a daemon's.
1021
+ const claim = `reset-${randomUUID()}`;
1022
+ try {
1023
+ if (!registry.claim(project.id, claim, process.pid)) {
1024
+ const holder = registry.get(project.id);
1025
+ throw new Error(holder?.instanceId?.startsWith("reset-") ? `another ahub reset of this project is running (pid ${holder.pid}); nothing was reset` : "a hub started after the stop; nothing was reset, run ahub reset again");
1026
+ }
1027
+ if (liveManifest()) throw new Error("a hub started after the stop; nothing was reset, run ahub reset again");
1028
+ // An upgrade or recovery that took the machine's lock after the first check must not run beside the reset.
1029
+ resetLockFree();
1030
+ if (all) {
1031
+ let archived: string;
1032
+ try { archived = archiveState(cwd, stateDir); }
1033
+ catch (error) { throw new Error(`the hub is stopped; nothing was moved: ${failureText(error)}`); }
1034
+ console.log(`moved the state directory to ${archived}`);
1035
+ try { startState(stateDir, archived); }
1036
+ catch (error) { throw new Error(`${(error as Error).message}; the state directory is archived at ${archived}: create ${stateDir} (0700) and copy project.json from the archive`); }
1037
+ console.log(`the new state directory holds only project.json (to restore: ahub kill, move ${stateDir} into ${join(cwd, ".agenthub", "archive")}/, then move the archive back to ${stateDir})`);
1038
+ } else {
1039
+ let result: ReturnType<typeof resetRuntime>;
1040
+ try { result = resetRuntime(stateDir, project); }
1041
+ catch (error) {
1042
+ throw new Error(`${failureText(error)}; the hub is stopped and the reset is incomplete: ${damagedState(error) ? "the state cannot be read, so a rerun fails the same way; ahub reset --all --yes archives it as it is" : "run ahub reset --yes again to finish it"}`);
1043
+ }
1044
+ console.log(`settled ${result.settled.length} deliveries as discard with reason "reset"`);
1045
+ for (const line of resetLines(result.plan).slice(1)) console.log(`cleared ${line}`);
1046
+ if (result.stale.length) console.log(`removed the manifest a hub left when it did not stop cleanly: ${result.stale.join(", ")}`);
1047
+ console.log(kept);
1048
+ }
1049
+ } finally { registry.release(project.id, claim); registry.close(); }
1050
+ console.log(memory);
1051
+ console.log("Claude Code sessions attached to this hub lost their hub session: relaunch them with ahub claude");
1052
+ },
1053
+
958
1054
  doctor: async () => {
959
1055
  if (args.includes("--orphans")) return orphanDoctor(args.includes("--kill"));
960
1056
  const version = (bin: string) => {
@@ -1040,7 +1136,7 @@ async function runConductorCommand(): Promise<void> {
1040
1136
  }
1041
1137
  console.log(await taskOp(op, input));
1042
1138
  }
1043
- const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : commands[cmd] ?? (() => fail(`unknown command "${cmd}"\n\n${USAGE}`));
1139
+ const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
1044
1140
  try {
1045
1141
  await run();
1046
1142
  } catch (e) {
@@ -71,6 +71,7 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: s
71
71
  unresolved.push({ field: "bridge/relay endpoints and owner credentials", reason: "assigned at runtime; preview does not bind a server or start a model" });
72
72
  if (!value("--session-id") && !value("--session-file")) unresolved.push({ field: "sessionId", reason: "new native session identity is not allocated by preview" });
73
73
  }
74
+ if (tool === "claude") envNames.push("AGENTHUB_CHANNEL");
74
75
  if (tool === "claude" || tool === "codex") {
75
76
  envNames.push("AGENTHUB_INSTANCE_ID", "AGENTHUB_LAUNCH_ID");
76
77
  unresolved.push({ field: "env.AGENTHUB_INSTANCE_ID", reason: tool === "claude" ? "injected when an existing daemon instance is read at launch; preview does not read runtime state" : "injected when a verified Orca terminal launch is recorded against the daemon; preview does not record it" });
@@ -0,0 +1,194 @@
1
+ import { constants, Database } from "bun:sqlite";
2
+ import { randomUUID } from "node:crypto";
3
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { Bus } from "../hub/bus.ts";
6
+ import { DeliveryJournal } from "../hub/delivery-journal.ts";
7
+ import type { Project } from "../hub/registry.ts";
8
+
9
+ /** Agent session resume pointers. `pi-sessions/` holds Pi transcripts, history rather than pointers, and stays. */
10
+ export const SESSION_POINTERS = ["sessions.json", "claude-session.json", "claude-context.json"];
11
+ /** What a running daemon publishes; a daemon that did not stop cleanly (crash, SIGKILL, forced exit) leaves it. */
12
+ export const MANIFEST = ["status.json", "control-token", "hub.pid"];
13
+
14
+ /** A state the reset cannot read the same way twice: rerunning fails alike, while `--all` archives it as it is. */
15
+ export function damagedState(error: unknown): boolean {
16
+ return error instanceof SyntaxError || /invalid delivery journal|not a database|malformed|corrupt/i.test((error as Error)?.message ?? "");
17
+ }
18
+
19
+ /**
20
+ * What may be printed about a failure: a parser's message can quote the data it choked on (envelope bodies, task text),
21
+ * so JSON and SQLite errors are named by class and code only. Every other message is written by the hub or the system.
22
+ */
23
+ export function failureText(error: unknown): string {
24
+ const e = error as NodeJS.ErrnoException;
25
+ if (e instanceof SyntaxError || e?.name === "SQLiteError") return `${[e.name, e.code].filter(Boolean).join(" ")} reading hub.db`;
26
+ return e?.message ?? String(error);
27
+ }
28
+
29
+ /** Ids and peer names only: a reset never prints task or message text (#214). */
30
+ export interface ResetPlan {
31
+ queued: string[];
32
+ needsReview: string[];
33
+ /** Dispatching or accepted: the journal holds them as needs_review once the hub has stopped. */
34
+ inFlight: string[];
35
+ manualHolds: string[];
36
+ budgetPauses: string[];
37
+ conductorHolds: string[];
38
+ sessionPointers: string[];
39
+ entries: number;
40
+ }
41
+
42
+ const isList = (value: unknown, item: (v: unknown) => boolean) => Array.isArray(value) && value.every(item);
43
+ const isText = (v: unknown) => typeof v === "string";
44
+ const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
45
+ const isEnvelope = (v: unknown) => isObject(v) && typeof v.id === "string";
46
+ /** Valid JSON of the wrong shape is damage as well: the journal refuses it on every open, so the message says so. */
47
+ const shaped = (ok: boolean, what: string) => { if (!ok) throw new Error(`invalid delivery journal: ${what} has the wrong shape`); };
48
+
49
+ /** What a reset would act on, read from the state directory without changing it (hub.db opened read-only). */
50
+ export function planReset(stateDir: string, projectId: string): ResetPlan {
51
+ const plan: ResetPlan = { queued: [], needsReview: [], inFlight: [], manualHolds: [], budgetPauses: [], conductorHolds: [],
52
+ sessionPointers: SESSION_POINTERS.filter((name) => existsSync(join(stateDir, name))), entries: existsSync(stateDir) ? readdirSync(stateDir).length : 0 };
53
+ const file = join(stateDir, "hub.db");
54
+ if (!existsSync(file)) return plan;
55
+ // A read-only open of a WAL database creates hub.db-wal and -shm when the last writer's close removed them (Linux),
56
+ // and fails without them on macOS. Without them no writer is mid-transaction, so `immutable` reads hub.db alone and
57
+ // writes nothing. ponytail: a -wal left without its -shm (deleted by hand) is ignored then, and commits still in
58
+ // it go unlisted; open plainly in that corner if it ever matters. SQLITE_OPEN_URI is passed because Linux builds do
59
+ // not parse `file:` names by default (the name then fails as a plain path).
60
+ const quiet = !existsSync(`${file}-wal`) || !existsSync(`${file}-shm`);
61
+ const db = quiet ? new Database(`file:${file.replace(/[%?#]/g, (c) => `%${c.charCodeAt(0).toString(16)}`)}?immutable=1`, constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI) : new Database(file, { readonly: true });
62
+ try {
63
+ const has = (table: string) => !!db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
64
+ const peers = (table: string) => has(table) ? (db.query(`SELECT peer FROM ${table} ORDER BY peer`).all() as { peer: string }[]).map((row) => row.peer) : [];
65
+ plan.budgetPauses = peers("budget_pauses");
66
+ plan.conductorHolds = peers("conductor_holds");
67
+ if (!has("deliveries")) return plan;
68
+ const rows = db.query("SELECT id, peer, state, originals FROM deliveries WHERE project_id = ? AND state IN ('queued', 'dispatching', 'accepted', 'needs_review') ORDER BY created_at, id").all(projectId) as { id: string; peer: string; state: string; originals: string }[];
69
+ for (const row of rows) (row.state === "queued" ? plan.queued : row.state === "needs_review" ? plan.needsReview : plan.inFlight).push(row.id);
70
+ const meta = db.query("SELECT bus_snapshot, manual_paused FROM delivery_meta WHERE project_id = ?").get(projectId) as { bus_snapshot: string; manual_paused: string } | null;
71
+ const bus: unknown = JSON.parse(meta?.bus_snapshot || "{}"), manual: unknown = JSON.parse(meta?.manual_paused || "[]");
72
+ shaped(isList(manual, isText), "manual_paused");
73
+ shaped(isObject(bus) && (bus.manualPaused === undefined || isList(bus.manualPaused, isText)) && (bus.queues === undefined || (isObject(bus.queues) && Object.values(bus.queues).every((queue) => isList(queue, isEnvelope)))), "bus_snapshot");
74
+ const { queues = {}, manualPaused = [] } = bus as { queues?: Record<string, { id: string }[]>; manualPaused?: string[] };
75
+ plan.manualHolds = [...new Set([...manual as string[], ...manualPaused])].sort();
76
+ // As Bus.queueList: a queued envelope that no open row stands for is listed as q:<peer>:<envelope id>.
77
+ // ponytail: the rule is copied so the dry run never opens the journal for writing; the reset tests compare the
78
+ // listed ids with the settled ones. Share one pure helper with queueList if that rule changes.
79
+ const recorded = new Set(rows.flatMap((row) => {
80
+ const originals: unknown = JSON.parse(row.originals);
81
+ shaped(isList(originals, isEnvelope), `delivery ${row.id} originals`);
82
+ return (originals as { id: string }[]).map((env) => `${row.peer}:${env.id}`);
83
+ }));
84
+ for (const [peer, queue] of Object.entries(queues)) for (const env of queue) if (!recorded.has(`${peer}:${env.id}`)) plan.queued.push(`q:${peer}:${env.id}`);
85
+ } finally { db.close(); }
86
+ return plan;
87
+ }
88
+
89
+ const named = (label: string, items: string[]) => `${label} ${items.length}${items.length ? `: ${items.join(", ")}` : ""}`;
90
+ /** The runtime scope, ids and counts: the deliveries first, then the holds and pointers. */
91
+ export function resetLines(plan: ResetPlan): string[] {
92
+ return [
93
+ `deliveries: ${named("queued", plan.queued)}; ${named("needs_review", plan.needsReview)}; ${named("in flight", plan.inFlight)}`,
94
+ named("manual holds", plan.manualHolds),
95
+ named("budget pauses", plan.budgetPauses),
96
+ named("conductor holds", plan.conductorHolds),
97
+ named("session pointers", plan.sessionPointers),
98
+ ];
99
+ }
100
+
101
+ /**
102
+ * Runtime reset of a stopped hub: every queued and needs_review delivery is discarded with reason `reset` through
103
+ * the journal (one resolution_history entry each), manual, budget and conductor holds are cleared and the session
104
+ * pointers dropped, with a crashed run's manifest. The board, logs, audit, recovery records and execution budgets stay.
105
+ */
106
+ export function resetRuntime(stateDir: string, project: Project): { settled: string[]; plan: ResetPlan; stale: string[] } {
107
+ const file = join(stateDir, "hub.db");
108
+ const plan = planReset(stateDir, project.id);
109
+ const settled: string[] = [];
110
+ if (existsSync(file)) {
111
+ // Opening the journal as a new instance turns dispatching and accepted rows of the stopped run into needs_review.
112
+ const journal = new DeliveryJournal({ file, projectRoot: project.root, projectId: project.id, instanceId: randomUUID() });
113
+ const bus = new Bus({ journal });
114
+ try {
115
+ for (const { id } of bus.queueList().filter((row) => row.state === "queued" || row.state === "needs_review")) {
116
+ // Read again each time: a queue entry's revision is the journal's, and every resolution moves it on.
117
+ bus.resolveDelivery(id, bus.queueShow(id)!.revision, "discard", "reset");
118
+ settled.push(id);
119
+ }
120
+ bus.setManualPaused([]);
121
+ } finally { bus.closeJournal(); }
122
+ const db = new Database(file);
123
+ try {
124
+ db.transaction(() => {
125
+ for (const table of ["budget_pauses", "conductor_holds"]) {
126
+ if (db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table)) db.run(`DELETE FROM ${table}`);
127
+ }
128
+ })();
129
+ } finally { db.close(); }
130
+ }
131
+ for (const name of SESSION_POINTERS) rmSync(join(stateDir, name), { force: true });
132
+ // The caller has checked that no daemon behind these is alive or uncertain: they are a crashed run's leftovers.
133
+ const stale = MANIFEST.filter((name) => existsSync(join(stateDir, name)));
134
+ for (const name of stale) rmSync(join(stateDir, name), { force: true });
135
+ return { settled, plan, stale };
136
+ }
137
+
138
+ /**
139
+ * Why `--all` must not move this state directory into `<root>/.agenthub/archive`, or undefined. An agent with write
140
+ * access to the project, or a repository that commits `.agenthub/`, could otherwise turn the archive into a symlink and
141
+ * have hub.db (PII) moved out of the project, or the `.gitignore` into one that truncates any file it names.
142
+ */
143
+ export function archiveProblem(root: string, stateDir: string): string | undefined {
144
+ const uid = process.getuid?.();
145
+ const own = (path: string) => { const st = lstatSync(path); return st.isDirectory() && (uid === undefined || st.uid === uid); };
146
+ for (const dir of [join(root, ".agenthub"), stateDir]) if (!own(dir)) return `${dir} is not a directory of this user`;
147
+ const archive = join(root, ".agenthub", "archive");
148
+ if (lstatSync(archive, { throwIfNoEntry: false }) && !own(archive)) return `${archive} is not a directory of this user (a symlink, or another owner's)`;
149
+ const ignore = lstatSync(join(archive, ".gitignore"), { throwIfNoEntry: false });
150
+ if (ignore && !ignore.isFile()) return `${join(archive, ".gitignore")} is not a regular file`;
151
+ return undefined;
152
+ }
153
+
154
+ /**
155
+ * Full reset, first step: the state directory moves to `.agenthub/archive/state-<UTC time>/` (0700). Nothing is
156
+ * deleted; moving the archive back restores it. Only the default `<root>/.agenthub/state` qualifies (the caller checks).
157
+ * The rename is the last step: when this throws, nothing was moved.
158
+ */
159
+ export function archiveState(root: string, stateDir: string, now = new Date()): string {
160
+ const problem = archiveProblem(root, stateDir);
161
+ if (problem) throw new Error(problem);
162
+ const archive = join(root, ".agenthub", "archive");
163
+ if (!lstatSync(archive, { throwIfNoEntry: false })) mkdirSync(archive, { mode: 0o700 });
164
+ // hub.db holds task text, PII included: keep the archive out of git whatever the project's .gitignore says.
165
+ // The local worker's denylist (src/local/deny.ts) keeps it out of its tools, sandbox and memory capture.
166
+ // Verified, not only created: an edited, emptied or hard-linked one is replaced by a rename, never written through.
167
+ // Turn snapshots exclude the archive as well.
168
+ const ignore = join(archive, ".gitignore");
169
+ const current = lstatSync(ignore, { throwIfNoEntry: false });
170
+ if (!current || current.nlink > 1 || readFileSync(ignore, "utf8") !== "*\n") {
171
+ const temporary = join(archive, `.gitignore.${randomUUID()}.tmp`);
172
+ try { writeFileSync(temporary, "*\n", { flag: "wx", mode: 0o600 }); renameSync(temporary, ignore); }
173
+ finally { rmSync(temporary, { force: true }); }
174
+ }
175
+ const target = join(archive, `state-${now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z")}`);
176
+ if (lstatSync(target, { throwIfNoEntry: false })) throw new Error(`${target} already exists`);
177
+ chmodSync(stateDir, 0o700);
178
+ // ponytail: checked, then renamed by path; a symlink swapped in for .agenthub/archive in the microseconds between
179
+ // still wins. Rename relative to a held directory descriptor (renameat) if Bun ever exposes one.
180
+ renameSync(stateDir, target);
181
+ return target;
182
+ }
183
+
184
+ /**
185
+ * Full reset, second step: a new state directory with only the archive's `project.json`, so the project id and
186
+ * registration stay. Something else may have created the directory meanwhile (the status line tee, 0755).
187
+ */
188
+ export function startState(stateDir: string, archived: string): void {
189
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
190
+ const st = lstatSync(stateDir);
191
+ if (!st.isDirectory() || st.uid !== (process.getuid?.() ?? st.uid)) throw new Error(`${stateDir} was re-created as something other than a directory of this user`);
192
+ chmodSync(stateDir, 0o700);
193
+ if (existsSync(join(archived, "project.json"))) copyFileSync(join(archived, "project.json"), join(stateDir, "project.json"));
194
+ }
@@ -1,8 +1,8 @@
1
1
  import type { ContextView } from "../hub/context-window.ts";
2
2
  // What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
3
3
 
4
- export interface PeerRow { context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; paused?: string; servedBy?: string; requestedModel?: string }
5
- export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string }
4
+ export interface PeerRow { context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; toolsOnly?: string; paused?: string; servedBy?: string; requestedModel?: string }
5
+ export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string; coolingUntil?: string; failures?: number; failingUntil?: string }
6
6
 
7
7
  /** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
8
8
  export function backendLabel(backend: BackendRow): string {
@@ -11,8 +11,9 @@ export function backendLabel(backend: BackendRow): string {
11
11
  return alias.startsWith(`${kind}/`) ? alias : `${kind}/${alias}`;
12
12
  }
13
13
 
14
- export function contextLine(reading: ContextView): string {
15
- return `context ${reading.used === null ? "unknown" : `${Math.round(reading.used * 100)}%`} (${reading.freshness}${reading.source ? `, ${reading.source}` : ""}${reading.measuredAt !== null ? `, measured ${new Date(reading.measuredAt).toISOString()}` : ""})`;
14
+ /** `measured` reads as an ISO time unless `time` says otherwise: the console uses local time, as its event headers do. */
15
+ export function contextLine(reading: ContextView, time = (at: number) => new Date(at).toISOString()): string {
16
+ return `context ${reading.used === null ? "unknown" : `${Math.round(reading.used * 100)}%`} (${reading.freshness}${reading.source ? `, ${reading.source}` : ""}${reading.measuredAt !== null ? `, measured ${time(reading.measuredAt)}` : ""})`;
16
17
  }
17
18
 
18
19
  export function peerLine(id: string, p: PeerRow): string {
@@ -24,6 +25,7 @@ export function peerLine(id: string, p: PeerRow): string {
24
25
  (p.heldBy ? ` ${p.holdNote ?? `held by needs_review ${p.heldBy}; ahub queue resolve ${p.heldBy} --action completed|retry|discard --reason <text>`}` : "") +
25
26
  (p.oldestQueuedAt !== undefined ? ` oldest ${Math.max(0, Math.floor((Date.now() - p.oldestQueuedAt) / 1000))}s` : "") +
26
27
  (p.attached === false ? " disconnected" : "") +
28
+ (p.toolsOnly ? ` ${p.toolsOnly}` : "") +
27
29
  (p.paused ? ` (${p.paused})` : "") +
28
30
  (p.servedBy ? ` last call: ${p.servedBy}` : "") +
29
31
  // Which backend the peer asked for on its last turn: without it, telling a Pi DGX turn from an MLX one
@@ -36,5 +38,7 @@ export function backendLine(backend: BackendRow): string {
36
38
  return ` model ${backendLabel(backend)} ${backend.state ?? "unknown"} active ${backend.active ?? 0}` +
37
39
  (backend.requestedModel ? ` requested ${backend.requestedModel}` : "") +
38
40
  (backend.actualModel ? ` actual ${backend.actualModel}` : "") +
39
- (backend.provider ? ` provider ${backend.provider}` : "");
41
+ (backend.provider ? ` provider ${backend.provider}` : "") +
42
+ (backend.coolingUntil ? ` cooling down until ${backend.coolingUntil} after ${backend.failures ?? "?"} failures` : "") +
43
+ (backend.failingUntil ? ` last dispatch failed, no load moves until ${backend.failingUntil}` : "");
40
44
  }