@staix/agent-hub 0.12.20 → 0.12.22

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 (67) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +17 -9
  3. package/docs/agent-notes/adapters.md +13 -3
  4. package/docs/agent-notes/benchmarks.md +1 -1
  5. package/docs/agent-notes/bus.md +7 -3
  6. package/docs/agent-notes/daemon.md +12 -2
  7. package/docs/agent-notes/local-worker.md +5 -1
  8. package/docs/agent-notes/tasks.md +4 -2
  9. package/docs/agent-notes/tests.md +5 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +15 -5
  12. package/docs/operations.md +510 -54
  13. package/docs/quickstart.md +16 -11
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +49 -11
  16. package/docs/smoke.md +138 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +227 -20
  18. package/docs/specs/2026-09-20-upgrade-recovery-design.md +152 -18
  19. package/docs/verified.json +34 -17
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +3 -2
  23. package/src/adapters/acp.ts +77 -10
  24. package/src/adapters/codex-appserver.ts +72 -2
  25. package/src/adapters/local-worker.ts +46 -11
  26. package/src/adapters/pi.ts +215 -22
  27. package/src/cli/bench.ts +287 -0
  28. package/src/cli/console-state.ts +226 -28
  29. package/src/cli/console.ts +16 -4
  30. package/src/cli/facts-hook.ts +50 -8
  31. package/src/cli/help.ts +16 -5
  32. package/src/cli/identity.ts +10 -4
  33. package/src/cli/launch.ts +272 -18
  34. package/src/cli/main.ts +290 -30
  35. package/src/cli/preview.ts +13 -3
  36. package/src/cli/recovery-package.ts +17 -0
  37. package/src/cli/status-lines.ts +2 -1
  38. package/src/cli/terminal-recovery.ts +83 -31
  39. package/src/cli/upgrade-interactive.ts +297 -0
  40. package/src/cli/upgrade-runtime.ts +123 -20
  41. package/src/cli/upgrade.ts +108 -19
  42. package/src/hub/bench.ts +211 -0
  43. package/src/hub/bus.ts +24 -7
  44. package/src/hub/conductor.ts +22 -21
  45. package/src/hub/config-trust.ts +1 -0
  46. package/src/hub/control-client.ts +4 -1
  47. package/src/hub/daemon.ts +477 -86
  48. package/src/hub/delivery-journal.ts +6 -6
  49. package/src/hub/events.ts +4 -2
  50. package/src/hub/lifecycle.ts +8 -13
  51. package/src/hub/manager.ts +16 -14
  52. package/src/hub/peers.ts +2 -2
  53. package/src/hub/permission-mode.ts +44 -0
  54. package/src/hub/recovery-store.ts +59 -9
  55. package/src/hub/registry.ts +36 -22
  56. package/src/hub/research.ts +255 -0
  57. package/src/hub/tasks.ts +99 -55
  58. package/src/hub/tool-refusal.ts +7 -0
  59. package/src/hub/ui.ts +4 -1
  60. package/src/local/deny.ts +48 -1
  61. package/src/local/sandbox.ts +6 -3
  62. package/src/local/tools.ts +34 -11
  63. package/src/pi/extension.ts +43 -20
  64. package/src/pi/process-signature.ts +23 -0
  65. package/src/pi/tool-receipts.ts +24 -2
  66. package/src/ui/index.html +98 -17
  67. package/src/ui/task-progress.ts +33 -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";
@@ -10,22 +11,26 @@ import { loadConfig } from "../hub/daemon.ts";
10
11
  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
- import { inspectProject, processAlive, startProject, stopProject, runProjectDaemon } from "../hub/lifecycle.ts";
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, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, 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 { activeOperation, assertLifecycleAvailable, readOperation, recoveryLock, recoveryRunner } from "../hub/recovery-store.ts";
27
+ import { createInterface as lineReader } from "node:readline";
28
+ import { activeOperation, assertLifecycleAvailable, operationIdPattern, readOperation, recoveryLock, recoveryRunner, signedRunner, stopSignedRunner } from "../hub/recovery-store.ts";
26
29
  import { childEnv } from "../hub/child-process.ts";
27
30
  import { abortRecovery, createOperation, disposeRecovery, liveProjects, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation } from "./upgrade.ts";
28
- import { makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
31
+ import { endPlannedPeer, makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
32
+ import { exactVersion, latestRelease, newerVersion } from "./recovery-package.ts";
33
+ import { operationScreen, planScreen, type ScreenIO, type UpgradeHost } from "./upgrade-interactive.ts";
29
34
  import { recordTerminalLaunch } from "./terminal-recovery.ts";
30
35
  import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
31
36
  import { setupOllamaModel } from "./models-setup.ts";
@@ -33,13 +38,16 @@ import { setupOllamaModel } from "./models-setup.ts";
33
38
  import { unknownContext } from "../hub/context-window.ts";
34
39
  import { backendLine, contextLine, peerLine, type BackendRow, type PeerRow } from "./status-lines.ts";
35
40
  import { parseSince, readEvents } from "../hub/events.ts";
41
+ import { armRuns, benchCompare, benchCsv, benchReport, formatCompare, formatReport as formatBenchReport, formatRuns, readRuns, runSummary, sameSuite } from "../hub/bench.ts";
42
+ import { benchPreflight, defaultIo, runBench } from "./bench.ts";
43
+ import { appendRecords, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
36
44
  import { formatReport, summarize, formatTaskReport, summarizeByTask } from "../hub/report.ts";
37
45
  import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
38
46
  import { pathWarnings } from "../hub/conflicts.ts";
39
47
  import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, peerCommandRefusal } from "./identity.ts";
40
48
  import { recordCliAudit } from "./identity-audit.ts";
41
49
  import { runConsole } from "./console.ts";
42
- import { resolveColor } from "./console-state.ts";
50
+ import { permissionBoundary, resolveColor } from "./console-state.ts";
43
51
  import { renderHelp } from "./help.ts";
44
52
  import { renderTailEvent } from "./tail-render.ts";
45
53
  import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
@@ -97,6 +105,7 @@ function audit(outcome: "run" | "refused" | "invalid"): void {
97
105
  if (identity.role === "invalid") { audit("invalid"); fail(`${identity.reason}; use ahub console or a terminal with no agent markers`); }
98
106
  if (identity.role === "tools" && commandAccess === "console") { audit("refused"); fail(peerCommandRefusal(identity.peer, commandLabel)); }
99
107
  if (identity.role === "tools") audit("run");
108
+ const invokedFrom = process.cwd(); // relative paths a person types (a bench suite) are theirs, not the project's
100
109
  try { process.chdir(cwd); } catch { fail(`project directory is unavailable: ${cwd}`); }
101
110
  const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
102
111
  const lifecycle = { inspectProject, startProject, stopProject };
@@ -108,6 +117,13 @@ function registeredProject(): Project {
108
117
  finally { registry.close(); }
109
118
  }
110
119
 
120
+ /** #247: this project's registration for its research records; writing needs `research.enabled` in its config. */
121
+ function researchProject(writes = true): Project {
122
+ const project = matchingProject() ?? fail("this project has no hub registration; run ahub up here once first");
123
+ if (writes && !loadConfig(cwd).research.enabled) fail('research records are off for this project; set "research": { "enabled": true } in .agenthub/config.json first');
124
+ return project;
125
+ }
126
+
111
127
  /** The registration of exactly this root and state directory, the only one kill and reset act on; never registers. */
112
128
  function matchingProject(): Project | undefined {
113
129
  const registry = new Registry();
@@ -124,10 +140,13 @@ function resetLockFree(): void {
124
140
  /** A manifest whose daemon is alive or uncertain, by inspectProject's rule; a crashed daemon's leftovers do not count. */
125
141
  function liveManifest(): boolean {
126
142
  const control = readControl(stateDir);
127
- if (control && processAlive(control.pid) !== false) return true;
143
+ if (control && processLiveness(control.pid, control.pidSignature) !== "gone") return true;
144
+ // A signed manifest is this release's daemon, which writes no hub.pid: one beside it is an older run's leftover.
145
+ if (control?.pidSignature) return false;
128
146
  let text: string;
147
+ // Only daemons up to 0.12.20 wrote hub.pid (#226): an unsigned legacy record.
129
148
  try { text = readFileSync(join(stateDir, "hub.pid"), "utf8").trim(); } catch { return false; }
130
- if (/^\d+$/.test(text)) return processAlive(Number(text)) !== false;
149
+ if (/^\d+$/.test(text)) return processLiveness(Number(text)) !== "gone";
131
150
  // Empty or garbled: beside a dead daemon's status it is that daemon's leftover too; alone it proves nothing.
132
151
  return !control;
133
152
  }
@@ -217,17 +236,16 @@ function hubDaemonCommand(pid: number, root: string): string | undefined {
217
236
 
218
237
  /** Live PID candidates for a registration whose project root is gone: the claim, the manifest, the legacy pid file. */
219
238
  function orphanPids(project: Project): number[] {
239
+ // #226: a recorded pid counts only while its signature (when one was recorded) still names its process.
220
240
  const candidates = new Set<number>();
221
- if (typeof project.pid === "number") candidates.add(project.pid);
241
+ if (typeof project.pid === "number" && processLiveness(project.pid, project.pidSignature) === "live") candidates.add(project.pid);
222
242
  const control = readControl(project.stateDir);
223
- if (control && Number.isSafeInteger(control.pid)) candidates.add(control.pid!);
243
+ if (control && Number.isSafeInteger(control.pid) && processLiveness(control.pid, control.pidSignature) === "live") candidates.add(control.pid!);
224
244
  try {
225
245
  const pid = Number(readFileSync(join(project.stateDir, "hub.pid"), "utf8").trim());
226
246
  if (Number.isSafeInteger(pid) && pid > 0) candidates.add(pid);
227
247
  } catch { /* no legacy pid file */ }
228
- return [...candidates].filter((pid) => {
229
- try { process.kill(pid, 0); return true; } catch { return false; }
230
- });
248
+ return [...candidates].filter((pid) => processLiveness(pid) === "live");
231
249
  }
232
250
 
233
251
  const processGone = (pid: number): boolean => !processCommandLine(pid);
@@ -324,32 +342,124 @@ async function hold(t: "pause" | "resume"): Promise<void> {
324
342
  console.log(`${args[0]} is ${res.state}`);
325
343
  }
326
344
 
327
- function spawnRecovery(operation: RecoveryOperation): void {
345
+ function spawnRecovery(operation: RecoveryOperation, quiet = false): void {
328
346
  const child = spawn(process.execPath, [join(operation.sourceRoot, "src/cli/main.js"), "recovery-run", operation.id], {
329
347
  cwd, detached: true, stdio: "ignore", env: { ...process.env, AGENTHUB_RECOVERY_OPERATION: operation.id },
330
348
  });
331
349
  child.on("error", () => console.error(`runner launch failed; use ${recoveryCommand(operation, "resume")}`));
332
350
  child.unref();
333
351
  // #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")}`);
352
+ if (!quiet) console.log(`Recovery operation ${operation.id} scheduled.\n${recoveryCommand(operation, "status")}`);
353
+ }
354
+
355
+ /** #272: a terminal session for the interactive screens. One line reader for the whole session, in the terminal's own
356
+ * line mode, so Ctrl+C stays a signal: from then on every question answers null and the screens leave. The terminal
357
+ * is not read again after it (a parent that died on the same Ctrl+C may have handed it back to the shell), and no
358
+ * runner is ever stopped by it. */
359
+ async function onTerminal(screens: (io: ScreenIO) => Promise<void>): Promise<void> {
360
+ const rl = lineReader({ input: process.stdin, terminal: false });
361
+ const lines: string[] = [], waiters: ((line: string | null) => void)[] = [];
362
+ let ended = false, interrupted = false;
363
+ rl.on("line", (line) => { const next = waiters.shift(); if (next) next(line.trim()); else lines.push(line.trim()); });
364
+ rl.on("close", () => { ended = true; for (const waiter of waiters.splice(0)) waiter(null); });
365
+ const onInterrupt = () => { interrupted = true; for (const waiter of waiters.splice(0)) waiter(null); };
366
+ process.on("SIGINT", onInterrupt);
367
+ try {
368
+ await screens({
369
+ ask: (question) => {
370
+ if (interrupted) return Promise.resolve(null);
371
+ process.stdout.write(question);
372
+ const typed = lines.shift();
373
+ return typed !== undefined ? Promise.resolve(typed) : ended ? Promise.resolve(null) : new Promise((resolve) => waiters.push(resolve));
374
+ },
375
+ out: (line) => console.log(line),
376
+ run: (argv) => new Promise((resolve) => {
377
+ // The command owns the terminal while it runs; a reader whose input already ended has nothing to pause.
378
+ if (!ended) rl.pause();
379
+ const done = (code: number) => { if (!ended) rl.resume(); resolve(code); };
380
+ const child = spawn(process.execPath, argv, { cwd, stdio: "inherit" });
381
+ child.on("error", () => done(1));
382
+ child.on("close", (code) => done(code ?? 1));
383
+ }),
384
+ sleep: (ms) => Bun.sleep(ms),
385
+ interrupted: () => interrupted,
386
+ // Scripted input (a pipe) is answers typed ahead for the next question; only a terminal's line is a key press.
387
+ typed: () => !ended && lines.splice(0).length > 0,
388
+ });
389
+ } finally { process.off("SIGINT", onInterrupt); rl.close(); }
390
+ }
391
+
392
+ function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost {
393
+ const scope = kind === "restart" ? cwd : undefined;
394
+ return {
395
+ plan: () => makeUpgradePlan(kind, version, scope),
396
+ apply: async (plan, interrupted) => {
397
+ assertLifecycleAvailable();
398
+ const current = await makeUpgradePlan(kind, plan.version, scope);
399
+ if (current.fingerprint !== plan.fingerprint) throw new Error("the plan changed during review; review it again");
400
+ // The check above takes seconds: a Ctrl+C in that time is still a no.
401
+ if (interrupted()) throw new Error("interrupted; nothing was started");
402
+ const operation = createOperation(plan, preserveSource(plan));
403
+ spawnRecovery(operation, true);
404
+ return operation;
405
+ },
406
+ lock: recoveryLock,
407
+ read: (id) => readOperation<RecoveryOperation>(id),
408
+ runner: recoveryRunner,
409
+ live: (op) => liveProjects(op, makeRecoveryDriver().inspect),
410
+ endPeer: (planned, peer) => endPlannedPeer(planned, peer),
411
+ runnerStoppable: (id) => signedRunner(id) !== undefined,
412
+ stopRunner: (id) => stopSignedRunner(id),
413
+ entry: join(import.meta.dir, "main.js"),
414
+ version: VERSION,
415
+ };
335
416
  }
336
417
 
337
418
  async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
338
419
  const { one, rest } = takeFlags(args, ["--to"], []);
339
- if (rest.some((a) => !["--dry-run", "--yes"].includes(a)) || (kind === "restart" && one["--to"])) fail("usage: ahub upgrade --to <version> [--dry-run] [--yes] | ahub restart [--dry-run] [--yes]");
340
- if (kind === "upgrade" && !one["--to"]) fail("upgrade requires --to <exact-version>");
420
+ if (rest.some((a) => !["--dry-run", "--yes"].includes(a)) || (kind === "restart" && one["--to"])) fail("usage: ahub upgrade [--to <version>] [--dry-run] [--yes] | ahub restart [--dry-run] [--yes]");
341
421
  if (kind === "upgrade" && selector) fail("upgrade changes the shared package/plugin; omit --project to review all affected running projects");
342
- const plan = await makeUpgradePlan(kind, one["--to"] ?? VERSION, kind === "restart" ? cwd : undefined);
422
+ const dry = rest.includes("--dry-run"), yes = rest.includes("--yes");
423
+ // #272: on a terminal, with neither flag, the plan is reviewed, applied and followed in one session.
424
+ const interactive = !dry && !yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
425
+ let to = one["--to"];
426
+ if (kind === "upgrade" && !to) {
427
+ if (yes || (!dry && !interactive)) fail("upgrade requires --to <exact-version>");
428
+ to = await latestRelease();
429
+ console.error(`ahub: the latest release is ${to}`);
430
+ }
431
+ // Before the value reaches a package spec or the registry: a range such as 0.13 would resolve to whatever matches.
432
+ if (to !== undefined) { try { exactVersion(to); } catch (error) { fail((error as Error).message); } }
433
+ // An older CLI must not coordinate a newer target (docs/operations.md): the target's own coordinator takes over.
434
+ if (kind === "upgrade" && newerVersion(to!, VERSION) && process.env.AGENTHUB_UPGRADE_HANDOVER !== "1") {
435
+ const argv = ["x", "--package", `@staix/agent-hub@${to}`, "ahub", "upgrade", "--to", to!, ...rest];
436
+ console.error(`ahub: ${to} is newer than this CLI (${VERSION}), so its own coordinator runs the upgrade: bun ${argv.join(" ")}`);
437
+ if (interactive) {
438
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
439
+ try { if (!/^(|y|yes)$/i.test((await rl.question("Continue? [Y/n] ")).trim())) return; } finally { rl.close(); }
440
+ }
441
+ // The coordinator owns the terminal from here. This process only waits, and a Ctrl+C meant for that session must not
442
+ // end it first and hand the terminal back to the shell under it.
443
+ process.on("SIGINT", () => {});
444
+ const code = await new Promise<number>((resolve) => {
445
+ const child = spawn(process.execPath, argv, { cwd, stdio: "inherit", env: { ...process.env, AGENTHUB_UPGRADE_HANDOVER: "1" } });
446
+ child.on("error", (error) => { console.error(`ahub: cannot run bun x: ${error.message}`); resolve(1); });
447
+ child.on("close", (status) => resolve(status ?? 1));
448
+ });
449
+ process.exit(code);
450
+ }
451
+ if (interactive) return onTerminal((io) => planScreen(upgradeHost(kind, to ?? VERSION), io));
452
+ const plan = await makeUpgradePlan(kind, to ?? VERSION, kind === "restart" ? cwd : undefined);
343
453
  console.log(JSON.stringify(plan, null, 2));
344
454
  // #206: name every blocker and reconnect-only session on stderr, not only inside the JSON above.
345
455
  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
456
  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
457
  const blockers = [...plan.blockers, ...plan.projects.flatMap((p) => p.blockers.map((b) => `${p.project.id}: ${b}`))];
348
458
  for (const blocker of blockers) console.error(`ahub: blocker: ${blocker}`);
349
- if (args.includes("--dry-run")) return;
459
+ if (dry) return;
350
460
  if (blockers.length) fail("plan has blockers; no runtime was changed");
351
461
  assertLifecycleAvailable();
352
- if (!args.includes("--yes")) {
462
+ if (!yes) {
353
463
  if (!process.stdin.isTTY) fail("review --dry-run and use --yes in non-interactive sessions");
354
464
  const rl = createInterface({ input: process.stdin, output: process.stdout });
355
465
  try {
@@ -367,9 +477,18 @@ const commands: Record<string, () => Promise<void> | void> = {
367
477
  upgrade: () => upgrade("upgrade"),
368
478
  restart: () => upgrade("restart"),
369
479
  recovery: async () => {
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);
480
+ const [action, given, ...more] = args;
481
+ const usage = "usage: ahub recovery [status|resume|abort] [<operation-id>] | ahub recovery dispose [<operation-id>] --fresh-session <peer>|--stop-and-archive --reason <text>";
482
+ // #272: without an id, the operation that holds the machine's lock; bare on a terminal, its screen.
483
+ const named = !!given && operationIdPattern.test(given);
484
+ const id = named ? given : recoveryLock();
485
+ const rest = named || given === undefined ? more : [given, ...more];
486
+ if (action === undefined && process.stdin.isTTY && process.stdout.isTTY) {
487
+ if (!id) return console.log("no recovery operation is open");
488
+ return onTerminal(async (io) => { await operationScreen(upgradeHost("restart", VERSION), io, id); });
489
+ }
490
+ if (!["status", "resume", "abort", "dispose"].includes(action ?? "") || (action !== "dispose" && rest.length)) fail(usage);
491
+ if (!id) fail("no recovery operation holds the lock; name one by its id");
373
492
  const operation = readOperation<RecoveryOperation>(id);
374
493
  const runner = recoveryRunner(id);
375
494
  if (action === "status") {
@@ -525,10 +644,6 @@ const commands: Record<string, () => Promise<void> | void> = {
525
644
  process.env.AGENTHUB_INSTANCE_ID = control.instanceId;
526
645
  const terminal = await recordTerminalLaunch("claude", cwd, stateDir, control.instanceId);
527
646
  if (!terminal) process.env.AGENTHUB_LAUNCH_ID = randomUUID();
528
- // Native hook identity exists in an ordinary terminal too; this is not an Orca recovery record.
529
- const file = join(stateDir, "claude-launch.json");
530
- writeFileSync(`${file}.tmp`, JSON.stringify({ instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID }), { mode: 0o600 });
531
- chmodSync(`${file}.tmp`, 0o600); renameSync(`${file}.tmp`, file);
532
647
  }
533
648
  // `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
534
649
  // project local, then project, then user.
@@ -542,9 +657,26 @@ const commands: Record<string, () => Promise<void> | void> = {
542
657
  }
543
658
  // Turn-free facts and opted-in task sweeps share native PreToolUse/PostToolUse/Stop observations.
544
659
  const facts = claudeObservationHooks(projectConfig(), { script: join(import.meta.dir, "facts-hook.ts"), stateDir });
660
+ let previousLaunch: unknown;
661
+ try { previousLaunch = JSON.parse(readFileSync(join(stateDir, "claude-launch.json"), "utf8")); } catch { /* no previous managed launch */ }
545
662
  const launch = buildLaunch("claude", args, { unattended: unattendedEnv, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original } : {}) }, ...(facts ? { facts } : {}) });
546
- if (launch.warning) console.error(launch.warning);
547
- exec(launch.cmd, launch.args, "claude");
663
+ let result: Awaited<ReturnType<typeof runClaudeLaunch>>;
664
+ try {
665
+ let launchRecord: ClaudeLaunchRecord | undefined;
666
+ if (control?.instanceId) {
667
+ // Publish only the final launch's installed hook and effective permission flags.
668
+ launchRecord = { 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 };
669
+ recordClaudeLaunch(stateDir, launchRecord);
670
+ cleanupStaleClaudeSettings(stateDir, previousLaunch);
671
+ }
672
+ if (launch.warning) console.error(launch.warning);
673
+ const env = nativeLaunchEnv("claude", childEnv());
674
+ result = await runClaudeLaunch(launch, { cwd, stateDir, env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd }, ...(launchRecord ? { record: launchRecord } : {}) });
675
+ } finally {
676
+ cleanupClaudeSettings(stateDir, launch.settingsFile);
677
+ }
678
+ if (result.error) fail(`cannot run ${launch.cmd}: ${result.error.message}`);
679
+ process.exit(result.status ?? 1);
548
680
  },
549
681
 
550
682
  codex: async () => {
@@ -561,7 +693,7 @@ const commands: Record<string, () => Promise<void> | void> = {
561
693
  const before = readControl(stateDir);
562
694
  if (recovering && before?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, before.instanceId);
563
695
  const hub = await connect();
564
- const res = await hub.request({ t: "start", peer: "codex", operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
696
+ const res = await hub.request({ t: "start", peer: "codex", args: { unattended: launch0.unattended === true }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
565
697
  hub.close();
566
698
  if (!res.ok) fail(res.error);
567
699
  const control = readControl(stateDir);
@@ -766,6 +898,17 @@ const commands: Record<string, () => Promise<void> | void> = {
766
898
  if (sub === "show") return console.log(await taskOp("task_show", { id: rest[0] }));
767
899
  if (sub === "escalate") return console.log(await taskOp("task_escalate", { id: rest[0] }));
768
900
  if (sub === "assign") return console.log(await taskOp("task_assign", { id: rest[0], peer: rest[1] }));
901
+ if (sub === "label") {
902
+ // #247: a person's later verdict on an approved task (the identity gate keeps it human-only), for research records.
903
+ const { one, rest: positional } = takeFlags(rest, ["--created"], []);
904
+ const id = Number(positional[0]), label = positional[1] as Label;
905
+ 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>]`);
906
+ const project = researchProject();
907
+ let record: TaskRecord;
908
+ try { record = labelTarget(readStores(project.id), readEvents(join(stateDir, "events.jsonl")), id, one["--created"]); } catch (error) { fail((error as Error).message); }
909
+ appendRecords(project.id, [{ schema: RESEARCH_SCHEMA, kind: "label", project: projectKey(project.id), task: id, createdAt: record!.createdAt!, label, at: new Date().toISOString() }]);
910
+ return console.log(`task #${id} (approved ${record!.approvedAt}) labelled ${label} in the research records`);
911
+ }
769
912
  if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | escalate <id>");
770
913
  // `--class` is the explicit form. A first word that is a class name is still taken as the class (the documented
771
914
  // short form), but said out loud: "review the auth module" would otherwise be filed as class review, silently.
@@ -814,6 +957,39 @@ const commands: Record<string, () => Promise<void> | void> = {
814
957
  pause: () => hold("pause"),
815
958
  resume: () => hold("resume"),
816
959
 
960
+ stop: async () => {
961
+ const peer = args[0];
962
+ if (args.length !== 1 || !peer || !/^[a-z][a-z0-9-]{0,31}$/.test(peer)) fail("usage: ahub stop <peer>");
963
+ const hub = await connect();
964
+ try {
965
+ const reply = await hub.request({ t: "peer_stop", peer }, 35_000);
966
+ if (reply.ok !== true) {
967
+ const error = String(reply.error ?? "peer stop request refused");
968
+ fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "peer_stop"/i.test(error) ? `${error}; upgrade the running hub to use ahub stop` : error);
969
+ }
970
+ console.log(`${peer}: ${reply.state}`);
971
+ } finally { hub.close(); }
972
+ },
973
+
974
+ permission: async () => {
975
+ const positional = args.filter(arg => arg !== "--yes");
976
+ const [peer, mode] = positional;
977
+ 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]");
978
+ if (mode === "never-ask" && !args.includes("--yes")) fail("never-ask requires --yes; nothing was changed");
979
+ if (mode === "never-ask" && peer) console.error(`never-ask: ${permissionBoundary(peer)}; approval prompts are disabled`);
980
+ const hub = await connect();
981
+ try {
982
+ const reply = await hub.request({ t: "permission", ...(peer ? { peer } : {}), ...(mode ? { mode, confirmed: args.includes("--yes") } : {}) }, 35_000);
983
+ if (reply.ok === false) {
984
+ const error = String(reply.error ?? "permission request refused");
985
+ 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);
986
+ }
987
+ if (reply.peers) for (const [id, value] of Object.entries(reply.peers)) console.log(`${id}: ${value}`);
988
+ else console.log(`${peer}: ${reply.permissionMode}`);
989
+ if (typeof reply.note === "string" && reply.note) console.log(reply.note);
990
+ } finally { hub.close(); }
991
+ },
992
+
817
993
  permit: async () => {
818
994
  const [id, option] = args;
819
995
  if (!id || !option) fail("usage: ahub permit <id> <option|deny>");
@@ -831,6 +1007,7 @@ const commands: Record<string, () => Promise<void> | void> = {
831
1007
  if (args.includes("--json")) return console.log(JSON.stringify(status, null, 2));
832
1008
  console.log(`hub pid ${status.pid}, control 127.0.0.1:${status.controlPort}, ${status.cwd}`);
833
1009
  if (status.deliveryError) console.log(` delivery storage: ${status.deliveryError}; dispatch is stopped`);
1010
+ 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`);
834
1011
  for (const line of (status as { crash?: string[] }).crash ?? []) console.log(` crash recovery: ${line}`);
835
1012
  const peers = Object.entries(status.peers as Record<string, PeerRow>);
836
1013
  for (const [id, p] of peers) console.log(peerLine(id, { ...p, context: p.context ?? unknownContext() }));
@@ -846,6 +1023,89 @@ const commands: Record<string, () => Promise<void> | void> = {
846
1023
  export: () => {
847
1024
  for (const e of readEvents(join(stateDir, "events.jsonl"), since())) console.log(JSON.stringify(e));
848
1025
  },
1026
+ bench: async () => {
1027
+ // #251: benchmark suites run against the peers attached here, and their comparison across arms.
1028
+ const json = args.includes("--json");
1029
+ const [sub, ...rest] = args.filter((a) => a !== "--json");
1030
+ if (sub === "run") {
1031
+ const { one, rest: positional } = takeFlags(rest, ["--arm", "--repeat", "--tasks"], []);
1032
+ const suite = positional[0];
1033
+ const repeat = one["--repeat"] === undefined ? 1 : Number(one["--repeat"]);
1034
+ if (!suite || positional.length !== 1 || !one["--arm"]?.trim() || !Number.isSafeInteger(repeat) || repeat < 1 || repeat > 100) {
1035
+ fail("usage: ahub bench run <suite.json> --arm <label> [--repeat 1..100] [--tasks a,b]");
1036
+ }
1037
+ await benchPreflight(cwd, loadConfig(cwd).bench.enabled); // the suite's refs are checked once it is read
1038
+ let interrupted = false;
1039
+ 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)"); };
1040
+ process.on("SIGINT", stop);
1041
+ try {
1042
+ 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));
1043
+ return console.log(formatBenchReport(benchReport(readRuns().filter((r) => r.header.run === run))).join("\n"));
1044
+ } finally { process.off("SIGINT", stop); }
1045
+ }
1046
+ const runs = readRuns();
1047
+ if (sub === undefined || sub === "list") return console.log(json ? JSON.stringify(runs.map(runSummary), null, 2) : formatRuns(runs).join("\n"));
1048
+ if (sub === "status") {
1049
+ const live = runs.filter((r) => r.state === "running" || r.state === "unknown");
1050
+ return console.log(json ? JSON.stringify(live.map(runSummary), null, 2) : live.length ? formatRuns(live).join("\n") : "no benchmark run is running");
1051
+ }
1052
+ if (sub === "report") {
1053
+ const run = runs.find((r) => r.header.run === rest[0]) ?? fail(`usage: ahub bench report <run> (ahub bench list shows the runs)`);
1054
+ const r = benchReport([run]);
1055
+ return console.log(json ? JSON.stringify(r, null, 2) : formatBenchReport(r).join("\n"));
1056
+ }
1057
+ if (sub === "compare") {
1058
+ const mixed = rest.includes("--mixed");
1059
+ const { one, rest: picks } = takeFlags(rest.filter((a) => a !== "--mixed"), ["--suite"], []);
1060
+ const scoped = one["--suite"] ? runs.filter((r) => r.header.suite === one["--suite"]) : runs;
1061
+ const groups = picks.map((pick) => {
1062
+ const byRun = scoped.filter((r) => r.header.run === pick);
1063
+ const chosen = byRun.length ? byRun : armRuns(scoped, pick);
1064
+ if (!chosen.length) fail(`no run, or arm with a run that is over, named ${pick}${one["--suite"] ? ` in suite ${one["--suite"]}` : ""}`);
1065
+ return { arm: byRun.length ? `${pick} [${byRun[0]!.header.arm}]` : pick, runs: chosen };
1066
+ });
1067
+ if (groups.length < 2) fail("usage: ahub bench compare <run|arm> <run|arm>... [--suite <name>] [--mixed] [--json]");
1068
+ const differs = sameSuite(groups);
1069
+ 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`);
1070
+ const c = benchCompare(groups);
1071
+ return console.log(json ? JSON.stringify(c, null, 2) : formatCompare(c).join("\n"));
1072
+ }
1073
+ if (sub === "export") {
1074
+ const format = rest.includes("--format") ? rest[rest.indexOf("--format") + 1] : "jsonl";
1075
+ if (format !== "jsonl" && format !== "csv") fail("--format takes jsonl or csv");
1076
+ if (format === "csv") return void process.stdout.write(benchCsv(runs));
1077
+ for (const r of runs) for (const record of [r.header, ...r.attempts, ...(r.end ? [r.end] : [])]) console.log(JSON.stringify(record));
1078
+ return;
1079
+ }
1080
+ fail("usage: ahub bench run|list|status|report|compare|export (ahub help bench)");
1081
+ },
1082
+ research: () => {
1083
+ // #247: measures from the opt-in research records; `export` writes them out, `backfill` builds them from events.jsonl.
1084
+ const [sub] = args;
1085
+ // Another project is `ahub --project <dir> research`; anything unknown is refused rather than ignored.
1086
+ const flags = new Set(["--all", "--json", "--since", "--format"]), valued = new Set(["--since", "--format"]);
1087
+ for (let i = sub === "export" || sub === "backfill" ? 1 : 0; i < args.length; i++) {
1088
+ if (!flags.has(args[i]!)) fail(`ahub research: unknown argument ${args[i]} (another project: ahub --project <dir> research)`);
1089
+ if (valued.has(args[i]!)) i++;
1090
+ }
1091
+ if (sub === "backfill") {
1092
+ const project = researchProject();
1093
+ const written = appendRecords(project.id, taskRecords(readEvents(join(stateDir, "events.jsonl")), project.id, { version: VERSION, source: "backfill" }));
1094
+ return console.log(`${written} research record${written === 1 ? "" : "s"} written from this project's events`);
1095
+ }
1096
+ const all = args.includes("--all");
1097
+ const records = readStores(all ? undefined : researchProject(false).id);
1098
+ const from = since();
1099
+ if (sub === "export") {
1100
+ const format = args[args.indexOf("--format") + 1] ?? "jsonl";
1101
+ if (args.includes("--format") && !["jsonl", "csv"].includes(format)) fail("--format takes jsonl or csv");
1102
+ const kept = records.filter((r) => r.kind === "label" || Date.parse(r.approvedAt) >= from);
1103
+ return void process.stdout.write(args.includes("--format") && format === "csv" ? toCsv(kept) : kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
1104
+ }
1105
+ if (sub !== undefined && !sub.startsWith("--")) fail("usage: ahub research [--since 30d] [--all] [--json] | export [--format jsonl|csv] [--since] [--all] | backfill");
1106
+ const r = researchReport(records, from);
1107
+ console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatResearch(r).join("\n"));
1108
+ },
849
1109
  report: () => {
850
1110
  const by = args.indexOf("--by");
851
1111
  if (by >= 0 && args[by + 1] !== "task") return fail("--by takes task");
@@ -1020,7 +1280,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1020
1280
  // The prefix tells a concurrent reset's claim from a daemon's.
1021
1281
  const claim = `reset-${randomUUID()}`;
1022
1282
  try {
1023
- if (!registry.claim(project.id, claim, process.pid)) {
1283
+ if (!registry.claim(project.id, claim, process.pid, processSignature(process.pid))) {
1024
1284
  const holder = registry.get(project.id);
1025
1285
  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
1286
  }
@@ -38,9 +38,19 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: s
38
38
  }
39
39
  // Build with original inputs so channel selection and owned-flag validation match normal launch.
40
40
  const facts = claudeObservationHooks(config, { script: join(import.meta.dir, "facts-hook.ts"), stateDir });
41
- launch = buildLaunch(tool, args, { unattended, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original: { command: original.command ? REDACTED : "", ...(typeof original.refreshInterval === "number" && Number.isFinite(original.refreshInterval) ? { refreshInterval: original.refreshInterval } : {}), ...(typeof original.padding === "number" && Number.isFinite(original.padding) ? { padding: original.padding } : {}) } } : {}) }, ...(facts ? { facts } : {}) });
42
- const passthrough = args.filter(arg => !["--unattended", "--safe", "--new"].includes(arg));
43
- launch.args = launch.args.slice(0, launch.args.length - passthrough.length).concat(passthrough.map(publicArg));
41
+ launch = buildLaunch(tool, args, { preview: true, unattended, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original: { command: original.command ? REDACTED : "", ...(typeof original.refreshInterval === "number" && Number.isFinite(original.refreshInterval) ? { refreshInterval: original.refreshInterval } : {}), ...(typeof original.padding === "number" && Number.isFinite(original.padding) ? { padding: original.padding } : {}) } } : {}) }, ...(facts ? { facts } : {}) });
42
+ // Merged caller settings change the native argument count. Rebuild a redacted preview,
43
+ // retaining the managed hooks but never serializing caller settings or their file path.
44
+ const safeArgs = args.map((arg, i) => {
45
+ if (args[i - 1] === "--settings") return "{}";
46
+ if (arg.startsWith("--settings=")) return "--settings={}";
47
+ if (["--unattended", "--safe", "--new"].includes(arg)) return arg;
48
+ return publicArg(arg);
49
+ });
50
+ const publicLaunch = buildLaunch(tool, safeArgs, { preview: true, unattended, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir,
51
+ ...(original ? { original: { command: original.command ? REDACTED : "", ...(typeof original.refreshInterval === "number" && Number.isFinite(original.refreshInterval) ? { refreshInterval: original.refreshInterval } : {}), ...(typeof original.padding === "number" && Number.isFinite(original.padding) ? { padding: original.padding } : {}) } } : {}) }, facts });
52
+ publicLaunch.args.splice(0, 2, ...launch.args.slice(0, 2)); // channel selection was verified against the original MCP config
53
+ launch = publicLaunch;
44
54
  } else if (tool === "codex") {
45
55
  // A fresh native proxy is assigned by start, never guessed from an old status file.
46
56
  launch = buildLaunch(tool, args, { unattended, proxyUrl: UNRESOLVED, codexBin: config.codex_bin === "codex" ? "codex" : REDACTED });
@@ -67,6 +67,23 @@ export async function registryRelease(version: string, run: RunCommand = runComm
67
67
  return { version, integrity: data["dist.integrity"] };
68
68
  }
69
69
 
70
+ /** #272: the registry's latest release, the target when `--to` is left out. */
71
+ export async function latestRelease(run: RunCommand = runCommand): Promise<string> {
72
+ const result = await run(["npm", "view", "@staix/agent-hub", "version", "--json"], { timeoutMs: 30_000 });
73
+ if (result.code !== 0) throw new Error("registry metadata unavailable for @staix/agent-hub; name the release with --to <version>");
74
+ let version: unknown;
75
+ try { version = JSON.parse(result.stdout); } catch { /* reported below */ }
76
+ if (typeof version !== "string") throw new Error("registry returned an unexpected latest release; name the release with --to <version>");
77
+ return exactVersion(version);
78
+ }
79
+
80
+ /** ponytail: compares the numeric triple only, so a pre-release never counts as newer than its own release; a semver compare if pre-releases are ever published. */
81
+ export function newerVersion(a: string, b: string): boolean {
82
+ const [x, y] = [a, b].map((v) => v.split("-")[0]!.split(".").map(Number));
83
+ for (let i = 0; i < 3; i++) if (x![i] !== y![i]) return (x![i] ?? 0) > (y![i] ?? 0);
84
+ return false;
85
+ }
86
+
70
87
  /** Stage outside the mutable global install; retain successful versions for recovery. */
71
88
  export async function stageRelease(version: string, integrity: string, run: RunCommand = runCommand, home = hubHome()): Promise<{ root: string; digest: string }> {
72
89
  exactVersion(version);
@@ -1,7 +1,7 @@
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; toolsOnly?: string; paused?: string; servedBy?: string; requestedModel?: string }
4
+ export interface PeerRow { permissionMode?: string; 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
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. */
@@ -26,6 +26,7 @@ export function peerLine(id: string, p: PeerRow): string {
26
26
  (p.oldestQueuedAt !== undefined ? ` oldest ${Math.max(0, Math.floor((Date.now() - p.oldestQueuedAt) / 1000))}s` : "") +
27
27
  (p.attached === false ? " disconnected" : "") +
28
28
  (p.toolsOnly ? ` ${p.toolsOnly}` : "") +
29
+ (p.permissionMode && p.permissionMode !== "ask" ? ` permission: ${p.permissionMode}` : "") +
29
30
  (p.paused ? ` (${p.paused})` : "") +
30
31
  (p.servedBy ? ` last call: ${p.servedBy}` : "") +
31
32
  // Which backend the peer asked for on its last turn: without it, telling a Pi DGX turn from an MLX one