@staix/agent-hub 0.12.19 → 0.12.21

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 +38 -0
  2. package/README.md +7 -6
  3. package/docs/agent-notes/adapters.md +10 -2
  4. package/docs/agent-notes/bus.md +6 -2
  5. package/docs/agent-notes/daemon.md +10 -3
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/models.md +3 -0
  8. package/docs/agent-notes/tasks.md +6 -3
  9. package/docs/agent-notes/tests.md +3 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +6 -2
  12. package/docs/operations.md +655 -34
  13. package/docs/quickstart.md +12 -10
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +59 -17
  16. package/docs/smoke.md +137 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
  18. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  19. package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
  20. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  21. package/docs/verified.json +29 -18
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +65 -26
  25. package/src/adapters/acp.ts +67 -6
  26. package/src/adapters/claude-channel.ts +61 -20
  27. package/src/adapters/codex-appserver.ts +70 -2
  28. package/src/adapters/local-worker.ts +8 -3
  29. package/src/adapters/pi.ts +58 -4
  30. package/src/cli/bench.ts +287 -0
  31. package/src/cli/console-state.ts +531 -110
  32. package/src/cli/console.ts +59 -26
  33. package/src/cli/facts-hook.ts +50 -8
  34. package/src/cli/help.ts +130 -0
  35. package/src/cli/identity.ts +11 -5
  36. package/src/cli/launch.ts +98 -17
  37. package/src/cli/main.ts +325 -88
  38. package/src/cli/preview.ts +14 -3
  39. package/src/cli/reset.ts +194 -0
  40. package/src/cli/status-lines.ts +10 -5
  41. package/src/cli/terminal-recovery.ts +196 -40
  42. package/src/cli/upgrade-runtime.ts +327 -60
  43. package/src/cli/upgrade.ts +442 -45
  44. package/src/hub/ask.ts +1 -1
  45. package/src/hub/bench.ts +211 -0
  46. package/src/hub/board.ts +15 -7
  47. package/src/hub/bus.ts +79 -8
  48. package/src/hub/child-process.ts +1 -0
  49. package/src/hub/conductor.ts +54 -21
  50. package/src/hub/config-trust.ts +1 -0
  51. package/src/hub/control-client.ts +9 -4
  52. package/src/hub/daemon.ts +401 -48
  53. package/src/hub/delivery-journal.ts +6 -6
  54. package/src/hub/events.ts +9 -2
  55. package/src/hub/execution-budget.ts +15 -6
  56. package/src/hub/hub-tools.ts +4 -4
  57. package/src/hub/inference.ts +136 -1
  58. package/src/hub/lifecycle.ts +13 -12
  59. package/src/hub/manager.ts +16 -14
  60. package/src/hub/peers.ts +2 -0
  61. package/src/hub/permission-mode.ts +44 -0
  62. package/src/hub/recovery-store.ts +68 -8
  63. package/src/hub/registry.ts +36 -22
  64. package/src/hub/report.ts +43 -1
  65. package/src/hub/research.ts +255 -0
  66. package/src/hub/restart.ts +25 -0
  67. package/src/hub/routing.ts +48 -16
  68. package/src/hub/snapshots.ts +3 -2
  69. package/src/hub/tasks.ts +295 -115
  70. package/src/hub/tool-refusal.ts +7 -0
  71. package/src/hub/ui.ts +4 -1
  72. package/src/local/deny.ts +2 -1
  73. package/src/local/tools.ts +14 -10
  74. package/src/models/mlx.ts +11 -8
  75. package/src/models/ollama.ts +2 -2
  76. package/src/models/relay.ts +190 -42
  77. package/src/models/route/relay-selector.ts +43 -31
  78. package/src/models/route/runtime.ts +17 -7
  79. package/src/models/route/signals.ts +12 -1
  80. package/src/models/route/stage.ts +57 -3
  81. package/src/omniroute/client.ts +1 -1
  82. package/src/pi/process-signature.ts +23 -0
  83. package/src/pi/tool-receipts.ts +7 -1
  84. package/src/ui/index.html +84 -6
  85. package/src/ui/task-progress.ts +33 -0
  86. package/templates/AGENTS.block.md +1 -1
  87. package/templates/routing.toml +8 -0
package/src/cli/main.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { isPermissionMode } from "../hub/permission-mode.ts";
2
3
  import { currentRouting } from "../hub/routing.ts";
3
4
  import { spawn, spawnSync } from "node:child_process";
4
5
  import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -11,20 +12,21 @@ import { factsHook } from "./facts-hook.ts";
11
12
  import { projectContext, realPath } from "../hub/project.ts";
12
13
  import { Registry, type Project } from "../hub/registry.ts";
13
14
  import { inspectProject, startProject, stopProject, runProjectDaemon } from "../hub/lifecycle.ts";
15
+ import { processLiveness, processSignature } from "../pi/process-signature.ts";
14
16
  import { openManager, startManager, stopManager } from "../hub/manager.ts";
15
17
  import { OmniRoute } from "../omniroute/client.ts";
16
18
  import { MemoryClient } from "../memory/client.ts";
17
19
  import { init, planInit } from "./init.ts";
18
20
  import { launcherPreview } from "./preview.ts";
19
- import { buildLaunch, claudeObservationHooks, nativeLaunchEnv, UNATTENDED_WARNING } from "./launch.ts";
21
+ import { cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, claudeObservationHooks, nativeLaunchEnv, UNATTENDED_WARNING } from "./launch.ts";
20
22
  import { nextStep, parseList, pluginState, type InstalledPlugin, type Marketplace } from "./setup.ts";
21
23
  import { CLASSES } from "../hub/board.ts";
22
24
  import { VERSION } from "../version.ts";
23
25
  import { freeText } from "./free-text.ts";
24
26
  import { createInterface } from "node:readline/promises";
25
- import { assertLifecycleAvailable, readOperation } from "../hub/recovery-store.ts";
27
+ import { activeOperation, assertLifecycleAvailable, readOperation, recoveryLock, recoveryRunner } from "../hub/recovery-store.ts";
26
28
  import { childEnv } from "../hub/child-process.ts";
27
- import { abortRecovery, createOperation, publicOperation, registeredProjects, runRecovery, type RecoveryOperation } from "./upgrade.ts";
29
+ import { abortRecovery, createOperation, disposeRecovery, liveProjects, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation } from "./upgrade.ts";
28
30
  import { makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
29
31
  import { recordTerminalLaunch } from "./terminal-recovery.ts";
30
32
  import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
@@ -33,14 +35,19 @@ import { setupOllamaModel } from "./models-setup.ts";
33
35
  import { unknownContext } from "../hub/context-window.ts";
34
36
  import { backendLine, contextLine, peerLine, type BackendRow, type PeerRow } from "./status-lines.ts";
35
37
  import { parseSince, readEvents } from "../hub/events.ts";
38
+ import { armRuns, benchCompare, benchCsv, benchReport, formatCompare, formatReport as formatBenchReport, formatRuns, readRuns, runSummary, sameSuite } from "../hub/bench.ts";
39
+ import { benchPreflight, defaultIo, runBench } from "./bench.ts";
40
+ import { appendRecords, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
36
41
  import { formatReport, summarize, formatTaskReport, summarizeByTask } from "../hub/report.ts";
37
42
  import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
38
43
  import { pathWarnings } from "../hub/conflicts.ts";
39
44
  import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, peerCommandRefusal } from "./identity.ts";
40
45
  import { recordCliAudit } from "./identity-audit.ts";
41
46
  import { runConsole } from "./console.ts";
42
- import { resolveColor } from "./console-state.ts";
47
+ import { permissionBoundary, resolveColor } from "./console-state.ts";
48
+ import { renderHelp } from "./help.ts";
43
49
  import { renderTailEvent } from "./tail-render.ts";
50
+ import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
44
51
 
45
52
  /** `--since 7d|24h|<iso>` for export and report; everything when absent. */
46
53
  const since = (): number => {
@@ -51,62 +58,6 @@ const since = (): number => {
51
58
  return t;
52
59
  };
53
60
 
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
61
  const argv = process.argv.slice(2);
111
62
  let selector: string | undefined;
112
63
  if (argv[0] === "--project") {
@@ -114,7 +65,12 @@ if (argv[0] === "--project") {
114
65
  selector = argv.shift();
115
66
  if (!selector || selector.startsWith("--")) fail("--project needs a path or project ID");
116
67
  }
117
- const [cmd = "help", ...args] = argv;
68
+ const [given = "help", ...rest] = argv;
69
+ // `ahub <command> -h` alone asks for that command's help instead of running it. Elsewhere `-h` may be message text
70
+ // (`ahub say try ls -h`), and claude and codex pass their arguments on to the agent.
71
+ const asksHelp = (a: string | undefined) => a === "--help" || a === "-h";
72
+ const cmd = asksHelp(given) || (given !== "claude" && given !== "codex" && rest.length === 1 && asksHelp(rest[0])) ? "help" : given;
73
+ const args = cmd === "help" && given !== "help" && !asksHelp(given) ? [given] : rest;
118
74
  let selected: { root: string; stateDir: string };
119
75
  try {
120
76
  if (selector) {
@@ -146,6 +102,7 @@ function audit(outcome: "run" | "refused" | "invalid"): void {
146
102
  if (identity.role === "invalid") { audit("invalid"); fail(`${identity.reason}; use ahub console or a terminal with no agent markers`); }
147
103
  if (identity.role === "tools" && commandAccess === "console") { audit("refused"); fail(peerCommandRefusal(identity.peer, commandLabel)); }
148
104
  if (identity.role === "tools") audit("run");
105
+ const invokedFrom = process.cwd(); // relative paths a person types (a bench suite) are theirs, not the project's
149
106
  try { process.chdir(cwd); } catch { fail(`project directory is unavailable: ${cwd}`); }
150
107
  const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
151
108
  const lifecycle = { inspectProject, startProject, stopProject };
@@ -157,6 +114,40 @@ function registeredProject(): Project {
157
114
  finally { registry.close(); }
158
115
  }
159
116
 
117
+ /** #247: this project's registration for its research records; writing needs `research.enabled` in its config. */
118
+ function researchProject(writes = true): Project {
119
+ const project = matchingProject() ?? fail("this project has no hub registration; run ahub up here once first");
120
+ if (writes && !loadConfig(cwd).research.enabled) fail('research records are off for this project; set "research": { "enabled": true } in .agenthub/config.json first');
121
+ return project;
122
+ }
123
+
124
+ /** The registration of exactly this root and state directory, the only one kill and reset act on; never registers. */
125
+ function matchingProject(): Project | undefined {
126
+ const registry = new Registry();
127
+ try { return registry.list().find((p) => p.root === cwd && p.stateDir === stateDir); }
128
+ finally { registry.close(); }
129
+ }
130
+ const hubManifest = () => !!readControl(stateDir) || existsSync(join(stateDir, "hub.pid"));
131
+ /** A reset is never part of an upgrade or recovery: any operation holding the machine's lock refuses it, whatever
132
+ * AGENTHUB_RECOVERY_OPERATION says (assertLifecycleAvailable lets that operation's own processes through). */
133
+ function resetLockFree(): void {
134
+ const owner = recoveryLock();
135
+ if (owner) throw new Error(`${activeOperation(owner)}; nothing was changed`);
136
+ }
137
+ /** A manifest whose daemon is alive or uncertain, by inspectProject's rule; a crashed daemon's leftovers do not count. */
138
+ function liveManifest(): boolean {
139
+ const control = readControl(stateDir);
140
+ if (control && processLiveness(control.pid, control.pidSignature) !== "gone") return true;
141
+ // A signed manifest is this release's daemon, which writes no hub.pid: one beside it is an older run's leftover.
142
+ if (control?.pidSignature) return false;
143
+ let text: string;
144
+ // Only daemons up to 0.12.20 wrote hub.pid (#226): an unsigned legacy record.
145
+ try { text = readFileSync(join(stateDir, "hub.pid"), "utf8").trim(); } catch { return false; }
146
+ if (/^\d+$/.test(text)) return processLiveness(Number(text)) !== "gone";
147
+ // Empty or garbled: beside a dead daemon's status it is that daemon's leftover too; alone it proves nothing.
148
+ return !control;
149
+ }
150
+
160
151
  async function healthy(): Promise<boolean> {
161
152
  let hub: ControlClient | undefined;
162
153
  try { hub = await connect(); const reply = await hub.request({ t: "status" }, 3000); return reply.status?.cwd === cwd; }
@@ -242,17 +233,16 @@ function hubDaemonCommand(pid: number, root: string): string | undefined {
242
233
 
243
234
  /** Live PID candidates for a registration whose project root is gone: the claim, the manifest, the legacy pid file. */
244
235
  function orphanPids(project: Project): number[] {
236
+ // #226: a recorded pid counts only while its signature (when one was recorded) still names its process.
245
237
  const candidates = new Set<number>();
246
- if (typeof project.pid === "number") candidates.add(project.pid);
238
+ if (typeof project.pid === "number" && processLiveness(project.pid, project.pidSignature) === "live") candidates.add(project.pid);
247
239
  const control = readControl(project.stateDir);
248
- if (control && Number.isSafeInteger(control.pid)) candidates.add(control.pid!);
240
+ if (control && Number.isSafeInteger(control.pid) && processLiveness(control.pid, control.pidSignature) === "live") candidates.add(control.pid!);
249
241
  try {
250
242
  const pid = Number(readFileSync(join(project.stateDir, "hub.pid"), "utf8").trim());
251
243
  if (Number.isSafeInteger(pid) && pid > 0) candidates.add(pid);
252
244
  } catch { /* no legacy pid file */ }
253
- return [...candidates].filter((pid) => {
254
- try { process.kill(pid, 0); return true; } catch { return false; }
255
- });
245
+ return [...candidates].filter((pid) => processLiveness(pid) === "live");
256
246
  }
257
247
 
258
248
  const processGone = (pid: number): boolean => !processCommandLine(pid);
@@ -353,9 +343,10 @@ function spawnRecovery(operation: RecoveryOperation): void {
353
343
  const child = spawn(process.execPath, [join(operation.sourceRoot, "src/cli/main.js"), "recovery-run", operation.id], {
354
344
  cwd, detached: true, stdio: "ignore", env: { ...process.env, AGENTHUB_RECOVERY_OPERATION: operation.id },
355
345
  });
356
- child.on("error", () => console.error(`runner launch failed; use ahub recovery resume ${operation.id}`));
346
+ child.on("error", () => console.error(`runner launch failed; use ${recoveryCommand(operation, "resume")}`));
357
347
  child.unref();
358
- console.log(`Recovery operation ${operation.id} scheduled.\nahub recovery status ${operation.id}`);
348
+ // #215: the operation's own coordinator, which has every recovery command; the global ahub may be older mid-upgrade.
349
+ console.log(`Recovery operation ${operation.id} scheduled.\n${recoveryCommand(operation, "status")}`);
359
350
  }
360
351
 
361
352
  async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
@@ -365,8 +356,13 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
365
356
  if (kind === "upgrade" && selector) fail("upgrade changes the shared package/plugin; omit --project to review all affected running projects");
366
357
  const plan = await makeUpgradePlan(kind, one["--to"] ?? VERSION, kind === "restart" ? cwd : undefined);
367
358
  console.log(JSON.stringify(plan, null, 2));
359
+ // #206: name every blocker and reconnect-only session on stderr, not only inside the JSON above.
360
+ 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`);
361
+ 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`);
362
+ const blockers = [...plan.blockers, ...plan.projects.flatMap((p) => p.blockers.map((b) => `${p.project.id}: ${b}`))];
363
+ for (const blocker of blockers) console.error(`ahub: blocker: ${blocker}`);
368
364
  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");
365
+ if (blockers.length) fail("plan has blockers; no runtime was changed");
370
366
  assertLifecycleAvailable();
371
367
  if (!args.includes("--yes")) {
372
368
  if (!process.stdin.isTTY) fail("review --dry-run and use --yes in non-interactive sessions");
@@ -386,12 +382,33 @@ const commands: Record<string, () => Promise<void> | void> = {
386
382
  upgrade: () => upgrade("upgrade"),
387
383
  restart: () => upgrade("restart"),
388
384
  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>");
385
+ const [action, id, ...rest] = args;
386
+ const usage = "usage: ahub recovery status|resume|abort <operation-id> | ahub recovery dispose <operation-id> --fresh-session <peer>|--stop-and-archive --reason <text>";
387
+ if (!id || !["status", "resume", "abort", "dispose"].includes(action ?? "") || (action !== "dispose" && rest.length)) fail(usage);
391
388
  const operation = readOperation<RecoveryOperation>(id);
392
- if (action === "status") console.log(JSON.stringify(publicOperation(operation), null, 2));
389
+ const runner = recoveryRunner(id);
390
+ if (action === "status") {
391
+ // #215: abort and resume are offered by what the open sources show, read as the runner's errors read them.
392
+ const live = runner ? {} : await liveProjects(operation, makeRecoveryDriver().inspect);
393
+ console.log(JSON.stringify(publicOperation(operation, runner, live), null, 2));
394
+ }
393
395
  else if (action === "abort") { await abortRecovery(id, makeRecoveryDriver()); console.log("preflight cancelled; no committed transition was rolled back"); }
396
+ else if (action === "dispose") {
397
+ // #215: human-only (the identity gate refuses agent shells); the reason is kept in the operation's audit.
398
+ const { one, rest: flags } = takeFlags(rest, ["--fresh-session", "--reason"], []);
399
+ const stop = flags.length === 1 && flags[0] === "--stop-and-archive";
400
+ const reason = one["--reason"]?.trim() ?? "";
401
+ if (!reason || reason.length > 500 || (flags.length && !stop) || stop === !!one["--fresh-session"]) fail(usage);
402
+ const result = await disposeRecovery(id, stop ? { stop: true } : { fresh: one["--fresh-session"]! }, reason, makeRecoveryDriver());
403
+ if (!stop) { console.log(`${one["--fresh-session"]}: a fresh session is accepted and the lost one is recorded; resuming`); return spawnRecovery(result); }
404
+ console.log(JSON.stringify(publicOperation(result), null, 2));
405
+ 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.`);
406
+ }
394
407
  else if (["completed", "cancelled"].includes(operation.phase)) console.log(`recovery is already ${operation.phase}`);
408
+ // #215: an operation being abandoned is never resumed, whichever coordinator started it (an older one would).
409
+ else if (operation.disposition) fail(`a stop-and-archive of this operation is partway; nothing was resumed; ${nextActionsText(operation, await liveProjects(operation, makeRecoveryDriver().inspect))}`);
410
+ else if (runner === "unknown") console.log(`whether a runner holds this operation could not be read; nothing was started; ${recoveryCommand(operation, "status")}`);
411
+ else if (runner) console.log(`runner ${runner} is still working on this operation; ${recoveryCommand(operation, "status")}`);
395
412
  else spawnRecovery(operation);
396
413
  },
397
414
  "recovery-run": async () => {
@@ -399,7 +416,13 @@ const commands: Record<string, () => Promise<void> | void> = {
399
416
  const result = await runRecovery(args[0]!, makeRecoveryDriver());
400
417
  if (result.phase !== "completed") process.exitCode = 1;
401
418
  },
402
- help: () => console.log(USAGE),
419
+ help: () => {
420
+ const color = resolveColor(undefined, { isTTY: !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR }) === true;
421
+ const topic = asksHelp(args[0]) ? undefined : args[0];
422
+ const text = renderHelp(process.stdout.columns ?? 80, color, topic);
423
+ if (!text) fail(`unknown command "${topic}"; run ahub help`);
424
+ console.log(text);
425
+ },
403
426
  "--version": () => console.log(VERSION),
404
427
  version: () => console.log(VERSION),
405
428
 
@@ -517,10 +540,6 @@ const commands: Record<string, () => Promise<void> | void> = {
517
540
  process.env.AGENTHUB_INSTANCE_ID = control.instanceId;
518
541
  const terminal = await recordTerminalLaunch("claude", cwd, stateDir, control.instanceId);
519
542
  if (!terminal) process.env.AGENTHUB_LAUNCH_ID = randomUUID();
520
- // Native hook identity exists in an ordinary terminal too; this is not an Orca recovery record.
521
- const file = join(stateDir, "claude-launch.json");
522
- writeFileSync(`${file}.tmp`, JSON.stringify({ instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID }), { mode: 0o600 });
523
- chmodSync(`${file}.tmp`, 0o600); renameSync(`${file}.tmp`, file);
524
543
  }
525
544
  // `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
526
545
  // project local, then project, then user.
@@ -534,9 +553,26 @@ const commands: Record<string, () => Promise<void> | void> = {
534
553
  }
535
554
  // Turn-free facts and opted-in task sweeps share native PreToolUse/PostToolUse/Stop observations.
536
555
  const facts = claudeObservationHooks(projectConfig(), { script: join(import.meta.dir, "facts-hook.ts"), stateDir });
556
+ let previousLaunch: unknown;
557
+ try { previousLaunch = JSON.parse(readFileSync(join(stateDir, "claude-launch.json"), "utf8")); } catch { /* no previous managed launch */ }
537
558
  const launch = buildLaunch("claude", args, { unattended: unattendedEnv, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original } : {}) }, ...(facts ? { facts } : {}) });
538
- if (launch.warning) console.error(launch.warning);
539
- exec(launch.cmd, launch.args, "claude");
559
+ let result: ReturnType<typeof spawnSync>;
560
+ try {
561
+ if (control?.instanceId) {
562
+ // Publish only the final launch's installed hook and effective permission flags.
563
+ const file = join(stateDir, "claude-launch.json");
564
+ writeFileSync(`${file}.tmp`, JSON.stringify({ instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID, permissionHook: launch.permissionHook === true, hookPurpose: launch.hookPurpose, settingsFile: launch.settingsFile, launcherPid: process.pid, launcherSignature: processSignature(process.pid), unattended: launch.unattended === true }), { mode: 0o600 });
565
+ chmodSync(`${file}.tmp`, 0o600); renameSync(`${file}.tmp`, file);
566
+ cleanupStaleClaudeSettings(stateDir, previousLaunch);
567
+ }
568
+ if (launch.warning) console.error(launch.warning);
569
+ const env = nativeLaunchEnv("claude", childEnv());
570
+ result = spawnSync(launch.cmd, launch.args, { cwd, stdio: "inherit", env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
571
+ } finally {
572
+ cleanupClaudeSettings(stateDir, launch.settingsFile);
573
+ }
574
+ if (result.error) fail(`cannot run ${launch.cmd}: ${result.error.message}`);
575
+ process.exit(result.status ?? 1);
540
576
  },
541
577
 
542
578
  codex: async () => {
@@ -546,12 +582,18 @@ const commands: Record<string, () => Promise<void> | void> = {
546
582
  }
547
583
  assertLifecycleAvailable();
548
584
  const launch0 = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: "pending" }); // refuse bad flags before starting anything
585
+ // #215: a launch by a recovery operation records itself before the hub round trip, so the coordinator never takes
586
+ // a launcher still starting for one that never ran. An ordinary launch records only once the hub accepted it: a
587
+ // refused start must not replace the record of a Codex already running here (one row per peer and instance).
588
+ const recovering = !!process.env.AGENTHUB_RECOVERY_OPERATION && process.env.AGENTHUB_RECOVERY_OPERATION === recoveryLock(); // not an id a restored session inherited
589
+ const before = readControl(stateDir);
590
+ if (recovering && before?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, before.instanceId);
549
591
  const hub = await connect();
550
- const res = await hub.request({ t: "start", peer: "codex", operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
592
+ const res = await hub.request({ t: "start", peer: "codex", args: { unattended: launch0.unattended === true }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
551
593
  hub.close();
552
594
  if (!res.ok) fail(res.error);
553
595
  const control = readControl(stateDir);
554
- if (control?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, control.instanceId);
596
+ if (!recovering && control?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, control.instanceId);
555
597
  const launch = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: res.proxyUrl, codexBin: projectConfig().codex_bin });
556
598
  if (launch0.warning) console.error(launch0.warning);
557
599
  exec(launch.cmd, launch.args, "codex");
@@ -577,6 +619,11 @@ const commands: Record<string, () => Promise<void> | void> = {
577
619
  catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
578
620
  }
579
621
  const options = piFlags();
622
+ // #215: as for Codex, a TUI launch by the recovery operation holding the lock records itself before the hub round
623
+ // trip, so a resume in that window never reads it as gone; an ordinary launch records only after the hub accepted it.
624
+ const recovering = options.mode === "tui" && !!process.env.AGENTHUB_RECOVERY_OPERATION && process.env.AGENTHUB_RECOVERY_OPERATION === recoveryLock();
625
+ const before = readControl(stateDir);
626
+ if (recovering && before?.instanceId) await recordTerminalLaunch("pi", cwd, stateDir, before.instanceId);
580
627
  const hub = await connect();
581
628
  const res = await hub.request({ t: "start", peer: "pi", args: options, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
582
629
  hub.close();
@@ -588,7 +635,7 @@ const commands: Record<string, () => Promise<void> | void> = {
588
635
  delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
589
636
  delete launchEnv.AGENTHUB_UNATTENDED;
590
637
  const control = readControl(stateDir);
591
- if (control?.instanceId) await recordTerminalLaunch("pi", cwd, stateDir, control.instanceId);
638
+ if (!recovering && control?.instanceId) await recordTerminalLaunch("pi", cwd, stateDir, control.instanceId);
592
639
  return execWithEnv(launch.cmd, launch.args, launchEnv);
593
640
  }
594
641
  console.log(res.already ? "pi is already attached" : 'pi attached (headless). Talk to it with: ahub say @pi "..."');
@@ -747,6 +794,17 @@ const commands: Record<string, () => Promise<void> | void> = {
747
794
  if (sub === "show") return console.log(await taskOp("task_show", { id: rest[0] }));
748
795
  if (sub === "escalate") return console.log(await taskOp("task_escalate", { id: rest[0] }));
749
796
  if (sub === "assign") return console.log(await taskOp("task_assign", { id: rest[0], peer: rest[1] }));
797
+ if (sub === "label") {
798
+ // #247: a person's later verdict on an approved task (the identity gate keeps it human-only), for research records.
799
+ const { one, rest: positional } = takeFlags(rest, ["--created"], []);
800
+ const id = Number(positional[0]), label = positional[1] as Label;
801
+ if (positional.length !== 2 || !/^[1-9]\d*$/.test(positional[0]!) || !Number.isSafeInteger(id) || !(LABELS as readonly string[]).includes(label)) fail(`usage: ahub task label <id> ${LABELS.join("|")} [--created <time from ahub research export>]`);
802
+ const project = researchProject();
803
+ let record: TaskRecord;
804
+ try { record = labelTarget(readStores(project.id), readEvents(join(stateDir, "events.jsonl")), id, one["--created"]); } catch (error) { fail((error as Error).message); }
805
+ appendRecords(project.id, [{ schema: RESEARCH_SCHEMA, kind: "label", project: projectKey(project.id), task: id, createdAt: record!.createdAt!, label, at: new Date().toISOString() }]);
806
+ return console.log(`task #${id} (approved ${record!.approvedAt}) labelled ${label} in the research records`);
807
+ }
750
808
  if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | escalate <id>");
751
809
  // `--class` is the explicit form. A first word that is a class name is still taken as the class (the documented
752
810
  // short form), but said out loud: "review the auth module" would otherwise be filed as class review, silently.
@@ -795,6 +853,24 @@ const commands: Record<string, () => Promise<void> | void> = {
795
853
  pause: () => hold("pause"),
796
854
  resume: () => hold("resume"),
797
855
 
856
+ permission: async () => {
857
+ const positional = args.filter(arg => arg !== "--yes");
858
+ const [peer, mode] = positional;
859
+ if (positional.length > 2 || positional.some(arg => arg.startsWith("--")) || (mode !== undefined && !isPermissionMode(mode))) fail("usage: ahub permission [<peer> [ask|ask-when-needed|never-ask]] [--yes]");
860
+ if (mode === "never-ask" && !args.includes("--yes")) fail("never-ask requires --yes; nothing was changed");
861
+ if (mode === "never-ask" && peer) console.error(`never-ask: ${permissionBoundary(peer)}; approval prompts are disabled`);
862
+ const hub = await connect();
863
+ try {
864
+ const reply = await hub.request({ t: "permission", ...(peer ? { peer } : {}), ...(mode ? { mode, confirmed: args.includes("--yes") } : {}) }, 35_000);
865
+ if (reply.ok === false) {
866
+ const error = String(reply.error ?? "permission request refused");
867
+ fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "permission"/i.test(error) ? `${error}; upgrade the running hub to use ahub permission` : error);
868
+ }
869
+ if (reply.peers) for (const [id, value] of Object.entries(reply.peers)) console.log(`${id}: ${value}`);
870
+ else console.log(`${peer}: ${reply.permissionMode}`);
871
+ } finally { hub.close(); }
872
+ },
873
+
798
874
  permit: async () => {
799
875
  const [id, option] = args;
800
876
  if (!id || !option) fail("usage: ahub permit <id> <option|deny>");
@@ -812,6 +888,7 @@ const commands: Record<string, () => Promise<void> | void> = {
812
888
  if (args.includes("--json")) return console.log(JSON.stringify(status, null, 2));
813
889
  console.log(`hub pid ${status.pid}, control 127.0.0.1:${status.controlPort}, ${status.cwd}`);
814
890
  if (status.deliveryError) console.log(` delivery storage: ${status.deliveryError}; dispatch is stopped`);
891
+ for (const pending of status.permissionDefaults ?? []) console.log(` permission default ${pending.peer}: never-ask from ${pending.source}, stays ask until a person confirms in ahub console`);
815
892
  for (const line of (status as { crash?: string[] }).crash ?? []) console.log(` crash recovery: ${line}`);
816
893
  const peers = Object.entries(status.peers as Record<string, PeerRow>);
817
894
  for (const [id, p] of peers) console.log(peerLine(id, { ...p, context: p.context ?? unknownContext() }));
@@ -827,6 +904,89 @@ const commands: Record<string, () => Promise<void> | void> = {
827
904
  export: () => {
828
905
  for (const e of readEvents(join(stateDir, "events.jsonl"), since())) console.log(JSON.stringify(e));
829
906
  },
907
+ bench: async () => {
908
+ // #251: benchmark suites run against the peers attached here, and their comparison across arms.
909
+ const json = args.includes("--json");
910
+ const [sub, ...rest] = args.filter((a) => a !== "--json");
911
+ if (sub === "run") {
912
+ const { one, rest: positional } = takeFlags(rest, ["--arm", "--repeat", "--tasks"], []);
913
+ const suite = positional[0];
914
+ const repeat = one["--repeat"] === undefined ? 1 : Number(one["--repeat"]);
915
+ if (!suite || positional.length !== 1 || !one["--arm"]?.trim() || !Number.isSafeInteger(repeat) || repeat < 1 || repeat > 100) {
916
+ fail("usage: ahub bench run <suite.json> --arm <label> [--repeat 1..100] [--tasks a,b]");
917
+ }
918
+ await benchPreflight(cwd, loadConfig(cwd).bench.enabled); // the suite's refs are checked once it is read
919
+ let interrupted = false;
920
+ const stop = () => { interrupted = true; console.log("stopping at the next check: the attempt in progress is recorded as interrupted (a task still open is named)"); };
921
+ process.on("SIGINT", stop);
922
+ try {
923
+ const run = await runBench({ cwd, stateDir, suitePath: resolve(invokedFrom, suite!), arm: one["--arm"]!.trim(), repeat, only: one["--tasks"]?.split(",").map((t) => t.trim()).filter(Boolean), stopped: () => interrupted }, defaultIo(stateDir, cwd));
924
+ return console.log(formatBenchReport(benchReport(readRuns().filter((r) => r.header.run === run))).join("\n"));
925
+ } finally { process.off("SIGINT", stop); }
926
+ }
927
+ const runs = readRuns();
928
+ if (sub === undefined || sub === "list") return console.log(json ? JSON.stringify(runs.map(runSummary), null, 2) : formatRuns(runs).join("\n"));
929
+ if (sub === "status") {
930
+ const live = runs.filter((r) => r.state === "running" || r.state === "unknown");
931
+ return console.log(json ? JSON.stringify(live.map(runSummary), null, 2) : live.length ? formatRuns(live).join("\n") : "no benchmark run is running");
932
+ }
933
+ if (sub === "report") {
934
+ const run = runs.find((r) => r.header.run === rest[0]) ?? fail(`usage: ahub bench report <run> (ahub bench list shows the runs)`);
935
+ const r = benchReport([run]);
936
+ return console.log(json ? JSON.stringify(r, null, 2) : formatBenchReport(r).join("\n"));
937
+ }
938
+ if (sub === "compare") {
939
+ const mixed = rest.includes("--mixed");
940
+ const { one, rest: picks } = takeFlags(rest.filter((a) => a !== "--mixed"), ["--suite"], []);
941
+ const scoped = one["--suite"] ? runs.filter((r) => r.header.suite === one["--suite"]) : runs;
942
+ const groups = picks.map((pick) => {
943
+ const byRun = scoped.filter((r) => r.header.run === pick);
944
+ const chosen = byRun.length ? byRun : armRuns(scoped, pick);
945
+ if (!chosen.length) fail(`no run, or arm with a run that is over, named ${pick}${one["--suite"] ? ` in suite ${one["--suite"]}` : ""}`);
946
+ return { arm: byRun.length ? `${pick} [${byRun[0]!.header.arm}]` : pick, runs: chosen };
947
+ });
948
+ if (groups.length < 2) fail("usage: ahub bench compare <run|arm> <run|arm>... [--suite <name>] [--mixed] [--json]");
949
+ const differs = sameSuite(groups);
950
+ if (differs && !mixed) fail(`${differs}; compare runs of one suite file (name runs by id, or --suite when the names differ), or pass --mixed to compare them anyway`);
951
+ const c = benchCompare(groups);
952
+ return console.log(json ? JSON.stringify(c, null, 2) : formatCompare(c).join("\n"));
953
+ }
954
+ if (sub === "export") {
955
+ const format = rest.includes("--format") ? rest[rest.indexOf("--format") + 1] : "jsonl";
956
+ if (format !== "jsonl" && format !== "csv") fail("--format takes jsonl or csv");
957
+ if (format === "csv") return void process.stdout.write(benchCsv(runs));
958
+ for (const r of runs) for (const record of [r.header, ...r.attempts, ...(r.end ? [r.end] : [])]) console.log(JSON.stringify(record));
959
+ return;
960
+ }
961
+ fail("usage: ahub bench run|list|status|report|compare|export (ahub help bench)");
962
+ },
963
+ research: () => {
964
+ // #247: measures from the opt-in research records; `export` writes them out, `backfill` builds them from events.jsonl.
965
+ const [sub] = args;
966
+ // Another project is `ahub --project <dir> research`; anything unknown is refused rather than ignored.
967
+ const flags = new Set(["--all", "--json", "--since", "--format"]), valued = new Set(["--since", "--format"]);
968
+ for (let i = sub === "export" || sub === "backfill" ? 1 : 0; i < args.length; i++) {
969
+ if (!flags.has(args[i]!)) fail(`ahub research: unknown argument ${args[i]} (another project: ahub --project <dir> research)`);
970
+ if (valued.has(args[i]!)) i++;
971
+ }
972
+ if (sub === "backfill") {
973
+ const project = researchProject();
974
+ const written = appendRecords(project.id, taskRecords(readEvents(join(stateDir, "events.jsonl")), project.id, { version: VERSION, source: "backfill" }));
975
+ return console.log(`${written} research record${written === 1 ? "" : "s"} written from this project's events`);
976
+ }
977
+ const all = args.includes("--all");
978
+ const records = readStores(all ? undefined : researchProject(false).id);
979
+ const from = since();
980
+ if (sub === "export") {
981
+ const format = args[args.indexOf("--format") + 1] ?? "jsonl";
982
+ if (args.includes("--format") && !["jsonl", "csv"].includes(format)) fail("--format takes jsonl or csv");
983
+ const kept = records.filter((r) => r.kind === "label" || Date.parse(r.approvedAt) >= from);
984
+ return void process.stdout.write(args.includes("--format") && format === "csv" ? toCsv(kept) : kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
985
+ }
986
+ if (sub !== undefined && !sub.startsWith("--")) fail("usage: ahub research [--since 30d] [--all] [--json] | export [--format jsonl|csv] [--since] [--all] | backfill");
987
+ const r = researchReport(records, from);
988
+ console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatResearch(r).join("\n"));
989
+ },
830
990
  report: () => {
831
991
  const by = args.indexOf("--by");
832
992
  if (by >= 0 && args[by + 1] !== "task") return fail("--by takes task");
@@ -943,18 +1103,95 @@ const commands: Record<string, () => Promise<void> | void> = {
943
1103
  },
944
1104
 
945
1105
  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(); }
1106
+ const project = matchingProject();
950
1107
  if (!project) {
951
- if (!readControl(stateDir) && !existsSync(join(stateDir, "hub.pid"))) return console.log("hub is not running");
1108
+ if (!hubManifest()) return console.log("hub is not running");
952
1109
  fail("hub has no matching registration; use its matching CLI to stop it before upgrading");
953
1110
  }
954
1111
  await stopProject(project);
955
1112
  console.log("hub stopped");
956
1113
  },
957
1114
 
1115
+ reset: async () => {
1116
+ if (args.some((a) => !["--all", "--yes"].includes(a))) fail("usage: ahub reset [--all] [--yes]");
1117
+ const all = args.includes("--all");
1118
+ resetLockFree();
1119
+ // Registered with another state directory: `ahub up` here would register the default one and orphan that state.
1120
+ const elsewhere = registeredProjects().find((p) => p.root === cwd);
1121
+ const project = matchingProject() ?? fail(hubManifest() ? "hub has no matching registration; use its matching CLI to stop it; nothing was changed"
1122
+ : elsewhere ? `this project is registered with the state directory ${elsewhere.stateDir}; run ahub --project ${elsewhere.id} reset; nothing was changed`
1123
+ : "no registration matches this project and state directory; nothing was changed (ahub up registers it)");
1124
+ if (!existsSync(stateDir)) return console.log("no state directory; nothing to reset");
1125
+ // A state directory elsewhere (AGENTHUB_STATE_DIR, or a symlink) would cross file systems or pull outside state
1126
+ // into the project tree.
1127
+ 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`);
1128
+ const unsafe = all ? archiveProblem(cwd, stateDir) : undefined;
1129
+ if (unsafe) fail(`${unsafe}; nothing was changed`);
1130
+ const live = await inspectProject(project);
1131
+ if (!["stopped", "running", "stopping"].includes(live.state)) fail(`${live.error ?? `hub is ${live.state}`}; nothing was changed`);
1132
+ const recovery = live.status?.recovery as { operationId?: string; phase?: string } | undefined;
1133
+ if (recovery?.operationId && recovery.phase !== "released") fail(`recovery operation ${recovery.operationId} is open in this hub; nothing was changed`);
1134
+ const kept = "kept: the board, logs and audit, recovery records, execution budgets, configuration and pi-sessions/";
1135
+ const memory = "claude-mem is not touched: notes saved with hub_remember stay in shared memory";
1136
+ if (!args.includes("--yes")) {
1137
+ // Planned only for the dry run: `--all --yes` archives a state directory it cannot read as it is.
1138
+ let plan: ResetPlan | undefined, unread: string | undefined;
1139
+ try { plan = planReset(stateDir, project.id); }
1140
+ catch (error) {
1141
+ if (!all) fail(`cannot read the state (${failureText(error)}); nothing was changed${damagedState(error) ? "; ahub reset --all --yes archives it as it is" : ""}`);
1142
+ unread = failureText(error);
1143
+ }
1144
+ console.log(`ahub reset${all ? " --all" : ""}, dry run: the hub is ${live.state}${live.state === "stopped" ? "" : "; --yes stops it first, as ahub kill does"}`);
1145
+ if (all) {
1146
+ 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`);
1147
+ console.log(unread ? ` could not read the state (${unread}); it is archived as it is` : " archived as they are:");
1148
+ } else console.log(' settle as discard with reason "reset", clear and drop:');
1149
+ for (const line of plan ? resetLines(plan) : []) console.log(` ${line}`);
1150
+ const stale = live.state === "stopped" && !all ? MANIFEST.filter((name) => existsSync(join(stateDir, name))) : [];
1151
+ if (stale.length) console.log(` remove the manifest a hub left when it did not stop cleanly: ${stale.join(", ")}`);
1152
+ console.log(` ${all ? ".agenthub/config.json, config.local.json and routing.toml are outside the state directory and stay" : kept}`);
1153
+ console.log(` ${memory}`);
1154
+ return console.log("nothing was changed; add --yes to apply");
1155
+ }
1156
+ await stopProject(project);
1157
+ if (live.state !== "stopped") console.log("hub stopped");
1158
+ // Hold the claim a daemon takes to run: a hub started after the stop holds it (refused here), and none can start
1159
+ // while the reset acts. A reset that dies leaves a claim with a dead pid, which the next start takes over.
1160
+ const registry = new Registry();
1161
+ // The prefix tells a concurrent reset's claim from a daemon's.
1162
+ const claim = `reset-${randomUUID()}`;
1163
+ try {
1164
+ if (!registry.claim(project.id, claim, process.pid, processSignature(process.pid))) {
1165
+ const holder = registry.get(project.id);
1166
+ 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");
1167
+ }
1168
+ if (liveManifest()) throw new Error("a hub started after the stop; nothing was reset, run ahub reset again");
1169
+ // An upgrade or recovery that took the machine's lock after the first check must not run beside the reset.
1170
+ resetLockFree();
1171
+ if (all) {
1172
+ let archived: string;
1173
+ try { archived = archiveState(cwd, stateDir); }
1174
+ catch (error) { throw new Error(`the hub is stopped; nothing was moved: ${failureText(error)}`); }
1175
+ console.log(`moved the state directory to ${archived}`);
1176
+ try { startState(stateDir, archived); }
1177
+ 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`); }
1178
+ 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})`);
1179
+ } else {
1180
+ let result: ReturnType<typeof resetRuntime>;
1181
+ try { result = resetRuntime(stateDir, project); }
1182
+ catch (error) {
1183
+ 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"}`);
1184
+ }
1185
+ console.log(`settled ${result.settled.length} deliveries as discard with reason "reset"`);
1186
+ for (const line of resetLines(result.plan).slice(1)) console.log(`cleared ${line}`);
1187
+ if (result.stale.length) console.log(`removed the manifest a hub left when it did not stop cleanly: ${result.stale.join(", ")}`);
1188
+ console.log(kept);
1189
+ }
1190
+ } finally { registry.release(project.id, claim); registry.close(); }
1191
+ console.log(memory);
1192
+ console.log("Claude Code sessions attached to this hub lost their hub session: relaunch them with ahub claude");
1193
+ },
1194
+
958
1195
  doctor: async () => {
959
1196
  if (args.includes("--orphans")) return orphanDoctor(args.includes("--kill"));
960
1197
  const version = (bin: string) => {
@@ -1040,7 +1277,7 @@ async function runConductorCommand(): Promise<void> {
1040
1277
  }
1041
1278
  console.log(await taskOp(op, input));
1042
1279
  }
1043
- const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : commands[cmd] ?? (() => fail(`unknown command "${cmd}"\n\n${USAGE}`));
1280
+ const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
1044
1281
  try {
1045
1282
  await run();
1046
1283
  } catch (e) {