@staix/agent-hub 0.12.30 → 0.12.32

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 (102) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +29 -9
  3. package/docs/agent-notes/adapters.md +9 -5
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +5 -0
  6. package/docs/agent-notes/daemon.md +14 -8
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +7 -1
  9. package/docs/agent-notes/tests.md +6 -2
  10. package/docs/cooperbench.md +1 -1
  11. package/docs/gsd-collaboration-analysis.md +1 -1
  12. package/docs/memory.md +2 -2
  13. package/docs/operations.md +236 -37
  14. package/docs/quickstart.md +24 -5
  15. package/docs/security.md +42 -10
  16. package/docs/smoke.md +34 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +241 -3
  18. package/docs/specs/2026-09-20-upgrade-recovery-design.md +275 -10
  19. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  20. package/docs/verification/2026-10-17-418-probes.md +21 -0
  21. package/docs/verification/2026-10-18-0.12.32.md +54 -0
  22. package/docs/verified.json +20 -15
  23. package/package.json +2 -2
  24. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  25. package/plugins/agent-hub/server.js +1361 -44
  26. package/src/adapters/acp.ts +5 -6
  27. package/src/adapters/claude-channel.ts +29 -11
  28. package/src/adapters/definitions/builtin-metadata.ts +121 -0
  29. package/src/adapters/definitions/kimi-native.ts +4 -0
  30. package/src/adapters/definitions/qwen-native.ts +24 -0
  31. package/src/adapters/definitions/qwen.ts +71 -0
  32. package/src/adapters/local-worker.ts +2 -2
  33. package/src/adapters/pi.ts +11 -2
  34. package/src/adapters/registry-types.ts +174 -0
  35. package/src/adapters/registry.ts +36 -0
  36. package/src/cli/attention.ts +5 -1
  37. package/src/cli/console-state.ts +130 -17
  38. package/src/cli/console.ts +54 -8
  39. package/src/cli/help.ts +3 -1
  40. package/src/cli/identity.ts +16 -5
  41. package/src/cli/jcode-mcp.ts +1 -1
  42. package/src/cli/launch.ts +1 -6
  43. package/src/cli/main.ts +208 -40
  44. package/src/cli/output.ts +15 -2
  45. package/src/cli/preview.ts +8 -5
  46. package/src/cli/recovery-guidance.ts +137 -0
  47. package/src/cli/recovery-presentation-options.ts +8 -0
  48. package/src/cli/recovery-session-file.ts +18 -0
  49. package/src/cli/terminal-recovery.ts +48 -0
  50. package/src/cli/upgrade-interactive.ts +129 -74
  51. package/src/cli/upgrade-labels.ts +30 -0
  52. package/src/cli/upgrade-presentation-data.ts +41 -0
  53. package/src/cli/upgrade-presentation-flags.ts +31 -0
  54. package/src/cli/upgrade-presentation.ts +217 -0
  55. package/src/cli/upgrade-restore-state.ts +137 -0
  56. package/src/cli/upgrade-runtime.ts +430 -175
  57. package/src/cli/upgrade.ts +325 -49
  58. package/src/hub/board.ts +82 -24
  59. package/src/hub/bus.ts +89 -29
  60. package/src/hub/check-runner.ts +514 -0
  61. package/src/hub/child-process.ts +1 -0
  62. package/src/hub/cohorts.ts +2 -2
  63. package/src/hub/config-apply.ts +242 -0
  64. package/src/hub/config-reload.ts +120 -0
  65. package/src/hub/config-trust.ts +11 -2
  66. package/src/hub/control-client.ts +5 -1
  67. package/src/hub/crash.ts +8 -6
  68. package/src/hub/daemon.ts +931 -209
  69. package/src/hub/delivery-hints.ts +30 -0
  70. package/src/hub/delivery-journal.ts +185 -12
  71. package/src/hub/delivery-superseded.ts +52 -0
  72. package/src/hub/envelope.ts +1 -1
  73. package/src/hub/events.ts +2 -2
  74. package/src/hub/host-executable.ts +11 -0
  75. package/src/hub/hub-tools.ts +9 -1
  76. package/src/hub/manager.ts +1 -1
  77. package/src/hub/peer-catalog.ts +13 -13
  78. package/src/hub/peers.ts +12 -2
  79. package/src/hub/permission-mode.ts +5 -3
  80. package/src/hub/recovery-continuity.ts +152 -0
  81. package/src/hub/recovery-store.ts +10 -4
  82. package/src/hub/routing.ts +35 -16
  83. package/src/hub/sdlc.ts +5 -1
  84. package/src/hub/settings.ts +52 -37
  85. package/src/hub/settled-roles.ts +35 -0
  86. package/src/hub/spec-read.ts +120 -0
  87. package/src/hub/supervision.ts +24 -8
  88. package/src/hub/task-sweep.ts +7 -6
  89. package/src/hub/tasks.ts +199 -40
  90. package/src/hub/ui.ts +1 -1
  91. package/src/memory/brief.ts +6 -5
  92. package/src/memory/capture.ts +2 -1
  93. package/src/pi/session.ts +23 -4
  94. package/src/ui/index.html +35 -16
  95. package/src/ui/needs.ts +7 -5
  96. package/src/ui/office-model.ts +4 -1
  97. package/src/ui/task-board.ts +1 -1
  98. package/src/ui/task-progress.ts +6 -2
  99. package/templates/AGENTS.block.md +1 -1
  100. package/templates/profiles/general.json +1 -1
  101. package/templates/profiles/research.json +1 -1
  102. package/templates/profiles/writing.json +1 -1
package/src/cli/launch.ts CHANGED
@@ -393,12 +393,7 @@ export function buildKimiLaunch(command: string[], model?: string): Launch {
393
393
  return { cmd: cmd!, args: model ? ["--model", model, ...args] : args };
394
394
  }
395
395
 
396
- /** Qwen flags follow the configured executable argv, including wrapper/script arguments. */
397
- export function buildQwenLaunch(command: string[], model?: string): Launch {
398
- const [cmd, ...args] = command;
399
- if (!cmd) throw new Error("qwen_cmd must name an executable");
400
- return { cmd, args: [...args, ...(model ? ["--model", model] : [])] };
401
- }
396
+ export { buildQwenLaunch } from "../adapters/definitions/qwen-native.ts";
402
397
 
403
398
  /** The configured ACP argv has a TUI form only when its exact final word is the ACP subcommand. */
404
399
  export function buildKimiTuiLaunch(command: string[], model?: string): Launch {
package/src/cli/main.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { definitionOf } from "../adapters/registry.ts";
2
3
  import { supportsBoardReleaseFilter, supportsBoardColumnMetadata } from "./board-read.ts";
3
4
  import { matchesPolicy } from "../hub/path-policy.ts";
4
5
  import { projectedDiff } from "../hub/projected-diff.ts";
@@ -43,13 +44,16 @@ import { VERSION } from "../version.ts";
43
44
  import { freeText } from "./free-text.ts";
44
45
  import { createInterface } from "node:readline/promises";
45
46
  import { createInterface as lineReader } from "node:readline";
46
- import { activeOperation, assertLifecycleAvailable, operationIdPattern, readOperation, recoveryLock, recoveryRunner, signedRunner, stopSignedRunner } from "../hub/recovery-store.ts";
47
+ import { activeOperation, assertLifecycleAvailable, operationIdPattern, readOperation, recoveryLock, recoveryRunner, signedRunner, stopSignedRunner, supportsAction } from "../hub/recovery-store.ts";
47
48
  import { childEnv } from "../hub/child-process.ts";
48
- import { abortRecovery, createOperation, disposeRecovery, liveProjects, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation } from "./upgrade.ts";
49
+ import { abortRecovery, createOperation, disposeRecovery, repairedFreshSession, reattachRecovery, liveProjects, nextChoices, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation, type Inspection } from "./upgrade.ts";
49
50
  import { endPlannedPeer, makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
50
51
  import { exactVersion, latestRelease, newerVersion } from "./recovery-package.ts";
51
52
  import { operationScreen, planScreen, type ScreenIO, type UpgradeHost } from "./upgrade-interactive.ts";
52
- import { recordTerminalLaunch } from "./terminal-recovery.ts";
53
+ import { readRecoveryPresentationFlags } from "./upgrade-presentation-flags.ts";
54
+ import { renderOperation, renderBlocked, renderSummary, type RecoveryPresentationOptions } from "./upgrade-presentation.ts";
55
+ import { recordTerminalLaunch, type TerminalPeer } from "./terminal-recovery.ts";
56
+ import { stopAndArchiveSummary } from "./recovery-guidance.ts";
53
57
  import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
54
58
  import { setupOllamaModel } from "./models-setup.ts";
55
59
 
@@ -65,7 +69,7 @@ import { addonReport, appendRecords, formatAddons, formatResearch, LABELS, label
65
69
  import { summarize, summarizeByTask } from "../hub/report.ts";
66
70
  import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
67
71
  import { pathWarnings } from "../hub/conflicts.ts";
68
- import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, liveTestRefusal, peerCommandRefusal } from "./identity.ts";
72
+ import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, LIVE_TEST_PERSON_REFUSAL, liveTestRefusal, peerCommandRefusal } from "./identity.ts";
69
73
  import { recordCliAudit } from "./identity-audit.ts";
70
74
  import { runConsole } from "./console.ts";
71
75
  import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
@@ -138,7 +142,7 @@ function audit(outcome: "run" | "refused" | "invalid"): void {
138
142
  }
139
143
  }
140
144
  if (identity.role === "invalid") { audit("invalid"); fail(`${identity.reason}; use ahub console or a terminal with no agent markers`); }
141
- if (delegationRefused) { audit("refused"); fail("live-test cannot write person-attributed content; use peer tools or a person's terminal"); }
145
+ if (delegationRefused) { audit("refused"); fail(LIVE_TEST_PERSON_REFUSAL); }
142
146
  if (identity.role === "tools" && commandAccess === "console") { audit("refused"); fail(peerCommandRefusal(identity.peer, commandLabel)); }
143
147
  if (identity.role === "tools" || delegatedPeer) audit("run");
144
148
  // Forward only to ahub's own continuation commands, never to native agent argv or an environment flag.
@@ -353,6 +357,7 @@ function fail(message: string): never {
353
357
  async function taskOp(op: string, a: Record<string, unknown>, throwOnError = false, connection?: ControlClient): Promise<string> {
354
358
  const hub = connection ?? await connect();
355
359
  try {
360
+ if (op === "hub_task_cancel" && !hub.features.includes("task_cancel")) fail("this daemon does not support task cancellation; upgrade it first");
356
361
  const res = await hub.request({ t: "task", op, args: a }, op === "github_import" ? 60_000 : 30_000);
357
362
  if (!res.ok) { if (throwOnError) throw new Error(res.error); audit("refused"); fail(res.error); }
358
363
  return res.text;
@@ -398,21 +403,50 @@ async function hold(t: "pause" | "resume"): Promise<void> {
398
403
  console.log(`${args[0]} is ${res.state}`);
399
404
  }
400
405
 
401
- function spawnRecovery(operation: RecoveryOperation, quiet = false): void {
406
+ function recoveryDisplay() {
407
+ return readRecoveryPresentationFlags(args, { isTTY: !!process.stdout.isTTY, columns: process.stdout.columns ?? 80, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR });
408
+ }
409
+ const humanRecovery = (json: boolean) => !json && !!process.stdin.isTTY && !!process.stdout.isTTY;
410
+ const printRecovery = (lines: readonly string[]) => { for (const line of lines) console.log(line); };
411
+ function recoveryFormatting(entry: string, presentation: RecoveryPresentationOptions): string[] {
412
+ // ponytail: old retained coordinators have no presentation parser; capability metadata can replace this display-only module-presence check.
413
+ return existsSync(join(dirname(entry), "upgrade-presentation-flags.ts"))
414
+ ? [`--color=${presentation.color ? "always" : "never"}`, ...(presentation.verbose ? ["--verbose"] : [])] : [];
415
+ }
416
+ function printRecoveryStatus(op: RecoveryOperation, message: string, presentation: RecoveryPresentationOptions): void {
417
+ printRecovery(wrap(message, presentation.columns));
418
+ printRecovery(wrap(`ahub recovery status ${op.id}`, presentation.columns));
419
+ if (presentation.verbose) {
420
+ printRecovery(wrap("Retained bun coordinator (main.js), wrapped command:", presentation.columns));
421
+ printRecovery(wrap(recoveryCommand(op, "status"), presentation.columns));
422
+ }
423
+ }
424
+ async function recoveryEndInspection(op: RecoveryOperation): Promise<Record<string, Inspection | undefined>> {
425
+ const driver = makeRecoveryDriver(undefined, delegatedPeer);
426
+ return Object.fromEntries(await Promise.all(op.plan.projects.map(async planned => [planned.project.id, await driver.inspect(planned.project).catch(() => undefined)] as const)));
427
+ }
428
+
429
+ function spawnRecovery(operation: RecoveryOperation, quiet = false, presentation?: RecoveryPresentationOptions): void {
402
430
  const child = spawn(process.execPath, [join(operation.sourceRoot, "src/cli/main.js"), ...liveTestArgs, "recovery-run", operation.id], {
403
431
  cwd, detached: true, stdio: "ignore", env: { ...process.env, AGENTHUB_RECOVERY_OPERATION: operation.id },
404
432
  });
405
433
  child.on("error", () => console.error(`runner launch failed; use ${recoveryCommand(operation, "resume")}`));
406
434
  child.unref();
407
435
  // #215: the operation's own coordinator, which has every recovery command; the global ahub may be older mid-upgrade.
408
- if (!quiet) console.log(`Recovery operation ${operation.id} scheduled.\n${recoveryCommand(operation, "status")}`);
436
+ if (!quiet) {
437
+ if (presentation) {
438
+ printRecovery(wrap(`Recovery operation ${operation.id} scheduled.`, presentation.columns));
439
+ printRecovery(wrap(`ahub recovery status ${operation.id}`, presentation.columns));
440
+ if (presentation.verbose) printRecovery(wrap(recoveryCommand(operation, "status"), presentation.columns));
441
+ } else console.log(`Recovery operation ${operation.id} scheduled.\n${recoveryCommand(operation, "status")}`);
442
+ }
409
443
  }
410
444
 
411
445
  /** #272: a terminal session for the interactive screens. One line reader for the whole session, in the terminal's own
412
446
  * line mode, so Ctrl+C stays a signal: from then on every question answers null and the screens leave. The terminal
413
447
  * 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
414
448
  * runner is ever stopped by it. */
415
- async function onTerminal(screens: (io: ScreenIO) => Promise<void>): Promise<void> {
449
+ async function onTerminal(screens: (io: ScreenIO) => Promise<void>, presentation?: RecoveryPresentationOptions): Promise<void> {
416
450
  const rl = lineReader({ input: process.stdin, terminal: false });
417
451
  const lines: string[] = [], waiters: ((line: string | null) => void)[] = [];
418
452
  let ended = false, interrupted = false;
@@ -422,6 +456,7 @@ async function onTerminal(screens: (io: ScreenIO) => Promise<void>): Promise<voi
422
456
  process.on("SIGINT", onInterrupt);
423
457
  try {
424
458
  await screens({
459
+ ...(presentation ? { presentation: { ...presentation, get columns() { return process.stdout.columns ?? presentation.columns; } } } : {}),
425
460
  ask: (question) => {
426
461
  if (interrupted) return Promise.resolve(null);
427
462
  process.stdout.write(question);
@@ -433,7 +468,13 @@ async function onTerminal(screens: (io: ScreenIO) => Promise<void>): Promise<voi
433
468
  // The command owns the terminal while it runs; a reader whose input already ended has nothing to pause.
434
469
  if (!ended) rl.pause();
435
470
  const done = (code: number) => { if (!ended) rl.resume(); resolve(code); };
436
- const child = spawn(process.execPath, [argv[0]!, ...liveTestArgs, ...argv.slice(1)], { cwd, stdio: "inherit" });
471
+ const formatting = presentation && argv[1] === "recovery" ? recoveryFormatting(argv[0]!, presentation) : [];
472
+ // A capable screen child updates the receipt; its parent owns the human view after the action.
473
+ // Older coordinators keep their existing terminal contract and receive no unsupported JSON flag.
474
+ const quiet = formatting.length > 0;
475
+ const child = spawn(process.execPath, [argv[0]!, ...liveTestArgs, ...argv.slice(1), ...formatting, ...(quiet ? ["--json"] : [])], {
476
+ cwd, stdio: quiet ? ["inherit", "ignore", "inherit"] : "inherit",
477
+ });
437
478
  child.on("error", () => done(1));
438
479
  child.on("close", (code) => done(code ?? 1));
439
480
  }),
@@ -463,6 +504,7 @@ function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost
463
504
  read: (id) => readOperation<RecoveryOperation>(id),
464
505
  runner: recoveryRunner,
465
506
  live: (op) => liveProjects(op, makeRecoveryDriver(undefined, delegatedPeer).inspect),
507
+ endInspection: recoveryEndInspection,
466
508
  endPeer: (planned, peer) => endPlannedPeer(planned, peer),
467
509
  runnerStoppable: (id) => signedRunner(id) !== undefined,
468
510
  stopRunner: (id) => stopSignedRunner(id),
@@ -472,12 +514,13 @@ function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost
472
514
  }
473
515
 
474
516
  async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
475
- const { one, rest } = takeFlags(args, ["--to"], []);
517
+ const display = recoveryDisplay();
518
+ const { one, rest } = takeFlags(display.args, ["--to"], []);
476
519
  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]");
477
520
  if (kind === "upgrade" && selector) fail("upgrade changes the shared package/plugin; omit --project to review all affected running projects");
478
- const dry = rest.includes("--dry-run"), yes = rest.includes("--yes");
521
+ const yes = rest.includes("--yes"), dry = rest.includes("--dry-run") || (display.json && !yes);
479
522
  // #272: on a terminal, with neither flag, the plan is reviewed, applied and followed in one session.
480
- const interactive = !dry && !yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
523
+ const interactive = !dry && !yes && humanRecovery(display.json);
481
524
  let to = one["--to"];
482
525
  if (kind === "upgrade" && !to) {
483
526
  if (yes || (!dry && !interactive)) fail("upgrade requires --to <exact-version>");
@@ -488,7 +531,7 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
488
531
  if (to !== undefined) { try { exactVersion(to); } catch (error) { fail((error as Error).message); } }
489
532
  // An older CLI must not coordinate a newer target (docs/operations.md): the target's own coordinator takes over.
490
533
  if (kind === "upgrade" && newerVersion(to!, VERSION) && process.env.AGENTHUB_UPGRADE_HANDOVER !== "1") {
491
- const argv = ["x", "--package", `@staix/agent-hub@${to}`, "ahub", ...liveTestArgs, "upgrade", "--to", to!, ...rest];
534
+ const argv = ["x", "--package", `@staix/agent-hub@${to}`, "ahub", ...liveTestArgs, "upgrade", "--to", to!, ...rest, ...display.flags, ...(dry && !rest.includes("--dry-run") ? ["--dry-run"] : [])];
492
535
  console.error(`ahub: ${to} is newer than this CLI (${VERSION}), so its own coordinator runs the upgrade: bun ${argv.join(" ")}`);
493
536
  if (interactive) {
494
537
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -505,16 +548,17 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
505
548
  process.exit(code);
506
549
  }
507
550
  if (kind === "upgrade") console.error("ahub: Kimi now defaults to its own TUI (pull-only) in projects without peers.kimi.start_mode. To keep ACP: ahub settings set peers.kimi.start_mode headless");
508
- if (interactive) return onTerminal((io) => planScreen(upgradeHost(kind, to ?? VERSION), io));
551
+ if (interactive) return onTerminal((io) => planScreen(upgradeHost(kind, to ?? VERSION), io), display.options);
509
552
  const plan = await makeUpgradePlan(kind, to ?? VERSION, kind === "restart" ? cwd : undefined);
510
- console.log(JSON.stringify(plan, null, 2));
553
+ const jsonApply = display.json && yes && !dry;
554
+ if (!jsonApply) console.log(JSON.stringify(plan, null, 2));
511
555
  // #206: name every blocker and reconnect-only session on stderr, not only inside the JSON above.
512
556
  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`);
513
557
  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`);
514
558
  const blockers = [...plan.blockers, ...plan.projects.flatMap((p) => p.blockers.map((b) => `${p.project.id}: ${b}`))];
515
559
  for (const blocker of blockers) console.error(`ahub: blocker: ${blocker}`);
516
560
  if (dry) return;
517
- if (blockers.length) fail("plan has blockers; no runtime was changed");
561
+ if (blockers.length) { if (jsonApply) console.log(JSON.stringify(plan, null, 2)); fail("plan has blockers; no runtime was changed"); }
518
562
  assertLifecycleAvailable();
519
563
  if (!yes) {
520
564
  if (!process.stdin.isTTY) fail("review --dry-run and use --yes in non-interactive sessions");
@@ -527,50 +571,128 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
527
571
  if (current.fingerprint !== plan.fingerprint) fail("plan changed during review; run the command again");
528
572
  const sourceRoot = preserveSource(plan);
529
573
  const operation = createOperation(plan, sourceRoot);
530
- spawnRecovery(operation);
574
+ if (display.json) { spawnRecovery(operation, true); console.log(JSON.stringify(publicOperation(operation), null, 2)); }
575
+ else spawnRecovery(operation, false, humanRecovery(false) ? display.options : undefined);
576
+ }
577
+
578
+ /** Strict person command: recorded identity only, no session or terminal overrides. */
579
+ function parseRecoveryReattach(argv: readonly string[]): { id: string; peer: TerminalPeer } | undefined {
580
+ if (argv.length !== 3 || argv[0] !== "reattach" || !operationIdPattern.test(argv[1]!) || !["codex", "claude", "pi"].includes(argv[2]!)) return undefined;
581
+ return { id: argv[1]!, peer: argv[2] as TerminalPeer };
531
582
  }
532
583
 
533
584
  const commands: Record<string, () => Promise<void> | void> = {
534
585
  upgrade: () => upgrade("upgrade"),
535
586
  restart: () => upgrade("restart"),
536
587
  recovery: async () => {
537
- const [action, given, ...more] = args;
588
+ const display = recoveryDisplay();
589
+ const [action, given, ...more] = display.args;
538
590
  const usage = "usage: ahub recovery [status|resume|abort] [<operation-id>] | ahub recovery dispose [<operation-id>] --fresh-session <peer>|--stop-and-archive --reason <text>";
591
+ if (action === "reattach") {
592
+ const selected = parseRecoveryReattach(display.args);
593
+ if (!selected) fail("usage: ahub recovery reattach <operation-id> codex|claude|pi");
594
+ let operation: RecoveryOperation;
595
+ try { operation = readOperation<RecoveryOperation>(selected.id); }
596
+ catch { fail(`recovery receipt ${selected.id} cannot be read; inspect the retained operation with ahub recovery status ${selected.id}; no session was started`); }
597
+ if (typeof operation.sourceRoot !== "string" || !operation.sourceRoot.trim()) fail(`recovery receipt ${selected.id} has no retained coordinator path; inspect it with ahub recovery status ${selected.id}; no session was started`);
598
+ const coordinator = join(operation.sourceRoot, "src/cli/main.js");
599
+ if (!existsSync(coordinator)) fail(`the retained coordinator for ${selected.id} is unavailable; restore its retained package and inspect ahub recovery status ${selected.id} before retrying; no session was started`);
600
+ let coordinatorIsCurrent: boolean;
601
+ try { coordinatorIsCurrent = realPath(coordinator) === realPath(join(import.meta.dir, "main.js")); }
602
+ catch { fail(`the retained coordinator for ${selected.id} cannot be resolved; inspect ahub recovery status ${selected.id} before retrying; no session was started`); }
603
+ if (supportsAction(operation.sourceRoot, "reattach") && !coordinatorIsCurrent) {
604
+ if (humanRecovery(display.json)) {
605
+ for (const line of wrap("ahub: using the operation's retained coordinator for reattachment", display.options.columns)) console.error(line);
606
+ if (display.options.verbose) for (const line of wrap(recoveryCommand(operation, "reattach", selected.peer), display.options.columns)) console.error(line);
607
+ } else console.error(`ahub: using the operation's coordinator: ${recoveryCommand(operation, "reattach", selected.peer)}`);
608
+ const formatting = recoveryFormatting(coordinator, display.options);
609
+ const code = await new Promise<number>(resolve => {
610
+ const child = spawn(process.execPath, [coordinator, "recovery", "reattach", selected.id, selected.peer, ...formatting], { cwd, stdio: display.json ? ["inherit", "ignore", "inherit"] : "inherit", env: process.env });
611
+ child.on("error", () => { console.error(`ahub: coordinator could not start; ${recoveryCommand(operation, "status")}`); resolve(1); });
612
+ child.on("close", status => resolve(status ?? 1));
613
+ });
614
+ process.exitCode = code;
615
+ if (display.json && code === 0) console.log(JSON.stringify(publicOperation(readOperation<RecoveryOperation>(selected.id)), null, 2));
616
+ return;
617
+ }
618
+ const result = await reattachRecovery(selected.id, selected.peer, makeRecoveryDriver());
619
+ if (humanRecovery(display.json)) {
620
+ printRecovery(renderOperation(result, undefined, display.options));
621
+ const live = await liveProjects(result, makeRecoveryDriver(undefined, delegatedPeer).inspect);
622
+ printRecovery(renderBlocked(result, nextChoices(result, undefined, live), display.options));
623
+ } else {
624
+ console.log(JSON.stringify(publicOperation(result), null, 2));
625
+ if (!display.json) console.log(nextActionsText(result));
626
+ }
627
+ return;
628
+ }
539
629
  // #272: without an id, the operation that holds the machine's lock; bare on a terminal, its screen.
540
630
  const named = !!given && operationIdPattern.test(given);
541
631
  const id = named ? given : recoveryLock();
542
632
  const rest = named || given === undefined ? more : [given, ...more];
543
- if (action === undefined && process.stdin.isTTY && process.stdout.isTTY) {
633
+ if (action === undefined && humanRecovery(display.json)) {
544
634
  if (!id) return console.log("no recovery operation is open");
545
- return onTerminal(async (io) => { await operationScreen(upgradeHost("restart", VERSION), io, id); });
635
+ return onTerminal(async (io) => { await operationScreen(upgradeHost("restart", VERSION), io, id); }, display.options);
546
636
  }
547
637
  if (!["status", "resume", "abort", "dispose"].includes(action ?? "") || (action !== "dispose" && rest.length)) fail(usage);
548
638
  if (!id) fail("no recovery operation holds the lock; name one by its id");
549
- const operation = readOperation<RecoveryOperation>(id);
639
+ const delegatedFresh = delegatedPeer && action === "dispose" ? takeFlags(rest, ["--fresh-session", "--reason"], []).one["--fresh-session"] : undefined;
640
+ let operation: RecoveryOperation;
641
+ try { operation = readOperation<RecoveryOperation>(id); }
642
+ catch (error) {
643
+ if (delegatedFresh !== undefined) { audit("refused"); fail(LIVE_TEST_PERSON_REFUSAL); }
644
+ throw error;
645
+ }
646
+ if (delegatedFresh !== undefined) {
647
+ const repairedFresh = repairedFreshSession(operation, delegatedFresh);
648
+ if (repairedFresh === undefined || liveTestRefusal("recovery", display.args, { repairedFresh })) {
649
+ audit("refused"); fail(LIVE_TEST_PERSON_REFUSAL);
650
+ }
651
+ }
550
652
  const runner = recoveryRunner(id);
551
653
  if (action === "status") {
552
654
  // #215: abort and resume are offered by what the open sources show, read as the runner's errors read them.
553
655
  const live = runner ? {} : await liveProjects(operation, makeRecoveryDriver(undefined, delegatedPeer).inspect);
554
656
  console.log(JSON.stringify(publicOperation(operation, runner, live), null, 2));
555
657
  }
556
- else if (action === "abort") { await abortRecovery(id, makeRecoveryDriver(undefined, delegatedPeer)); console.log("preflight cancelled; no committed transition was rolled back"); }
658
+ else if (action === "abort") {
659
+ await abortRecovery(id, makeRecoveryDriver(undefined, delegatedPeer));
660
+ if (display.json) console.log(JSON.stringify(publicOperation(readOperation<RecoveryOperation>(id)), null, 2));
661
+ else console.log("preflight cancelled; no committed transition was rolled back");
662
+ }
557
663
  else if (action === "dispose") {
558
- // Console authority (including explicit --live-test delegation); the reason is kept in the operation's audit.
664
+ // Console authority; #408 live-test delegation remains for ordinary fresh choices, never repaired loss.
559
665
  const { one, rest: flags } = takeFlags(rest, ["--fresh-session", "--reason"], []);
560
666
  const stop = flags.length === 1 && flags[0] === "--stop-and-archive";
561
667
  const reason = one["--reason"]?.trim() ?? "";
562
668
  if (!reason || reason.length > 500 || (flags.length && !stop) || stop === !!one["--fresh-session"]) fail(usage);
563
669
  const result = await disposeRecovery(id, stop ? { stop: true } : { fresh: one["--fresh-session"]! }, reason, makeRecoveryDriver(undefined, delegatedPeer));
564
- if (!stop) { console.log(`${one["--fresh-session"]}: a fresh session is accepted and the lost one is recorded; resuming`); return spawnRecovery(result); }
565
- console.log(JSON.stringify(publicOperation(result), null, 2));
566
- 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.`);
670
+ if (!stop) {
671
+ if (display.json) { console.log(JSON.stringify(publicOperation(result), null, 2)); return spawnRecovery(result, true); }
672
+ console.log(`${one["--fresh-session"]}: a fresh session is accepted and the lost one is recorded; resuming`);
673
+ return spawnRecovery(result, false, humanRecovery(false) ? display.options : undefined);
674
+ }
675
+ if (humanRecovery(display.json)) printRecovery(renderSummary(result, await recoveryEndInspection(result), display.options));
676
+ else {
677
+ console.log(JSON.stringify(publicOperation(result), null, 2));
678
+ if (!display.json) for (const line of stopAndArchiveSummary(result)) console.log(line);
679
+ }
567
680
  }
568
- else if (["completed", "cancelled"].includes(operation.phase)) console.log(`recovery is already ${operation.phase}`);
681
+ else if (["completed", "cancelled"].includes(operation.phase)) console.log(display.json ? JSON.stringify(publicOperation(operation, runner), null, 2) : `recovery is already ${operation.phase}`);
569
682
  // #215: an operation being abandoned is never resumed, whichever coordinator started it (an older one would).
570
683
  else if (operation.disposition) fail(`a stop-and-archive of this operation is partway; nothing was resumed; ${nextActionsText(operation, await liveProjects(operation, makeRecoveryDriver(undefined, delegatedPeer).inspect))}`);
571
- else if (runner === "unknown") console.log(`whether a runner holds this operation could not be read; nothing was started; ${recoveryCommand(operation, "status")}`);
572
- else if (runner) console.log(`runner ${runner} is still working on this operation; ${recoveryCommand(operation, "status")}`);
573
- else spawnRecovery(operation);
684
+ else if (runner === "unknown") {
685
+ if (display.json) console.log(JSON.stringify(publicOperation(operation, runner), null, 2));
686
+ else if (humanRecovery(false)) printRecoveryStatus(operation, "Runner ownership is unknown; nothing was started.", display.options);
687
+ else console.log(`whether a runner holds this operation could not be read; nothing was started; ${recoveryCommand(operation, "status")}`);
688
+ }
689
+ else if (runner) {
690
+ if (display.json) console.log(JSON.stringify(publicOperation(operation, runner), null, 2));
691
+ else if (humanRecovery(false)) printRecoveryStatus(operation, `runner ${runner} is still working on this operation`, display.options);
692
+ else console.log(`runner ${runner} is still working on this operation; ${recoveryCommand(operation, "status")}`);
693
+ }
694
+ else if (display.json) { spawnRecovery(operation, true); console.log(JSON.stringify(publicOperation(operation), null, 2)); }
695
+ else spawnRecovery(operation, false, humanRecovery(false) ? display.options : undefined);
574
696
  },
575
697
  "recovery-run": async () => {
576
698
  if (args.length !== 1 || process.env.AGENTHUB_RECOVERY_OPERATION !== args[0]) fail("recovery-run is an internal command");
@@ -803,8 +925,9 @@ const commands: Record<string, () => Promise<void> | void> = {
803
925
  }
804
926
  assertLifecycleAvailable();
805
927
  const { one, rest } = takeFlags(args, ["--mode", "--model", "--session-id"], []);
806
- if (one["--session-id"] !== undefined && !qwenSessionId(one["--session-id"])) fail("invalid Qwen session identity");
807
- if (rest.length || (one["--mode"] !== undefined && one["--mode"] !== "headless")) fail("Qwen TUI is not offered before verification; usage: ahub qwen [--mode headless] [--model <alias>] [--session-id <id>]");
928
+ const adapter = definitionOf("qwen")!;
929
+ if (one["--session-id"] !== undefined && !adapter.session.crashIdentity?.({ sessionId: one["--session-id"] }).sessionId) fail("invalid Qwen session identity");
930
+ if (rest.length || (one["--mode"] !== undefined && !(adapter.start.forms as readonly string[]).includes(one["--mode"]))) fail("Qwen TUI is not offered before verification; usage: ahub qwen [--mode headless] [--model <alias>] [--session-id <id>]");
808
931
  const hub = await connect();
809
932
  try {
810
933
  const reply = await hub.request({ t: "start", peer: "qwen", args: { mode: "headless", ...(one["--model"] ? { model: one["--model"] } : {}), ...(one["--session-id"] ? { sessionId: one["--session-id"] } : {}) }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 35_000);
@@ -1002,7 +1125,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1002
1125
 
1003
1126
  watch: async () => {
1004
1127
  const peer = args[0];
1005
- if (peer !== "kimi" && peer !== "qwen") fail("usage: ahub watch kimi|qwen [--no-thoughts] [--color=auto|always|never]");
1128
+ if (typeof peer !== "string" || !definitionOf(peer)?.capabilities?.watch) fail("usage: ahub watch kimi|qwen [--no-thoughts] [--color=auto|always|never]");
1006
1129
  if (args.slice(1).some(arg => arg !== "--no-thoughts" && !/^--color=(auto|always|never)$/.test(arg))) fail("unsupported watch option");
1007
1130
  const color = resolveColor(args.find(arg => arg.startsWith("--color="))?.slice(8), { isTTY: !!process.stdout.isTTY, ...process.env });
1008
1131
  if (color instanceof Error) fail(color.message);
@@ -1189,6 +1312,12 @@ const commands: Record<string, () => Promise<void> | void> = {
1189
1312
  finally { hub.close(); }
1190
1313
  return;
1191
1314
  }
1315
+ if (sub === "cancel") {
1316
+ const { one, rest: positional } = takeFlags(rest, ["--reason", "--superseded-by", "--revision"], []);
1317
+ const revision = one["--revision"] === undefined ? undefined : Number(one["--revision"]);
1318
+ if (positional.length !== 1 || !/^[1-9]\d*$/.test(positional[0]!) || !one["--reason"]?.trim() || (revision !== undefined && (!/^\d+$/.test(one["--revision"]!) || !Number.isSafeInteger(revision)))) fail("usage: ahub task cancel <id> --reason <text> [--superseded-by <ref>] [--revision N]");
1319
+ return console.log(await taskOp("hub_task_cancel", { id: Number(positional[0]), reason: one["--reason"], ...(one["--superseded-by"] === undefined ? {} : { superseded_by: one["--superseded-by"] }), ...(revision === undefined ? {} : { revision }) }));
1320
+ }
1192
1321
  if (sub === "show") return console.log(await taskOp("task_show", { id: rest[0] }));
1193
1322
  if (sub === "escalate") return console.log(await taskOp("task_escalate", { id: rest[0] }));
1194
1323
  if (sub === "assign") {
@@ -1214,7 +1343,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1214
1343
  appendRecords(project.id, [{ schema: RESEARCH_SCHEMA, kind: "label", project: projectKey(project.id), task: id, createdAt: record!.createdAt!, label, at: new Date().toISOString() }]);
1215
1344
  return console.log(`task #${id} (approved ${record!.approvedAt}) labelled ${label} in the research records`);
1216
1345
  }
1217
- if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | unassign <id> | escalate <id>");
1346
+ if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | unassign <id> | cancel <id> --reason <text> | escalate <id>");
1218
1347
  // `--class` is the explicit form. A first word that is a class name is still taken as the class (the documented
1219
1348
  // short form), but said out loud: "review the auth module" would otherwise be filed as class review, silently.
1220
1349
  const urgent = rest.includes("--urgent"), backlog = rest.includes("--backlog");
@@ -1324,11 +1453,11 @@ const commands: Record<string, () => Promise<void> | void> = {
1324
1453
  } finally { hub.close(); }
1325
1454
  },
1326
1455
  settings: async () => {
1327
- const usage = "usage: ahub settings [list] [--json] | get <key> [--json] | set <key> <value|inherit> [--yes] [--preview] | undo";
1456
+ const usage = "usage: ahub settings [list] [--json] | get <key> [--json] | set <key> <value|inherit> [--yes] [--preview] | undo | reload";
1328
1457
  const flags = args.filter((arg) => arg.startsWith("--")), [sub = "list", key, text, ...extra] = args.filter((arg) => !arg.startsWith("--"));
1329
- const allowed: Record<string, string[]> = { list: ["--json"], get: ["--json"], set: ["--yes", "--preview", "--now"], undo: [] };
1458
+ const allowed: Record<string, string[]> = { list: ["--json"], get: ["--json"], set: ["--yes", "--preview", "--now"], undo: [], reload: [] };
1330
1459
  if (!Object.hasOwn(allowed, sub) || flags.some((flag) => !allowed[sub]!.includes(flag)) || extra.length) fail(usage);
1331
- if ((sub === "get" && (!key || text !== undefined)) || (sub === "set" && (!key || text === undefined)) || ((sub === "list" || sub === "undo") && key !== undefined)) fail(usage);
1460
+ if ((sub === "get" && (!key || text !== undefined)) || (sub === "set" && (!key || text === undefined)) || ((sub === "list" || sub === "undo" || sub === "reload") && key !== undefined)) fail(usage);
1332
1461
  const def = key === undefined ? undefined : settingDef(key);
1333
1462
  if (typeof def === "string") fail(`${key}: ${def}`);
1334
1463
  if (flags.includes("--now") && (typeof def !== "object" || def.group !== "Roster")) fail(usage);
@@ -1348,6 +1477,43 @@ const commands: Record<string, () => Promise<void> | void> = {
1348
1477
  }
1349
1478
  return reply;
1350
1479
  };
1480
+ if (sub === "reload") {
1481
+ let result = await request({ t: "settings_reload" });
1482
+ const confirmation = result.confirmation;
1483
+ if (Array.isArray(result.rejected)) for (const row of result.rejected.slice(0, 100)) console.log(terminalText(`${String(row.key).slice(0, 160)}: ${String(row.reason).slice(0, 1000)}`));
1484
+ if (confirmation !== undefined) {
1485
+ if (!confirmation || typeof confirmation.token !== "string" || !confirmation.token || confirmation.token.length > 256 || !Array.isArray(confirmation.keys) || !confirmation.keys.length || confirmation.keys.length > 200 || confirmation.keys.some((key: unknown) => typeof key !== "string" || key.length > 160)) fail("invalid reload confirmation; guarded settings were not applied");
1486
+ const fullValue = (value: unknown): boolean => {
1487
+ if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > 16_384) return false;
1488
+ if (value === "(unset)") return true;
1489
+ try { JSON.parse(value); return true; } catch { return false; }
1490
+ };
1491
+ if (Buffer.byteLength(JSON.stringify(result), "utf8") > 131_072 || new Set(confirmation.keys).size !== confirmation.keys.length || !Array.isArray(confirmation.changes) || confirmation.changes.length !== confirmation.keys.length || confirmation.changes.some((change: unknown, index: number) => {
1492
+ if (!change || typeof change !== "object" || Array.isArray(change)) return true;
1493
+ const row = change as Record<string, unknown>;
1494
+ return row.key !== confirmation.keys[index] || !fullValue(row.before) || !fullValue(row.after) || (row.effect !== undefined && (typeof row.effect !== "string" || Buffer.byteLength(row.effect, "utf8") > 2048));
1495
+ })) fail("reload preview is incomplete or unavailable; upgrade the running hub and run ahub settings reload again to review every full value; guarded settings were not applied");
1496
+ const token = confirmation.token;
1497
+ console.log("Guarded settings awaiting confirmation:");
1498
+ for (const change of confirmation.changes) {
1499
+ console.log(` ${terminalText(change.key)}: ${terminalText(change.before)} -> ${terminalText(change.after)}`);
1500
+ if (change.effect !== undefined) console.log(` ${terminalText(change.effect)}`);
1501
+ }
1502
+ if (!process.stdin.isTTY || !process.stdout.isTTY) fail("guarded settings need an interactive person's terminal; run ahub settings reload there; guarded settings were not applied");
1503
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false });
1504
+ const ended = new AbortController();
1505
+ rl.once("close", () => ended.abort());
1506
+ let accepted = false;
1507
+ try { accepted = (await rl.question("Apply guarded settings? [y/N] ", { signal: AbortSignal.any([AbortSignal.timeout(30_000), ended.signal]) })).trim().toLowerCase() === "y"; }
1508
+ catch { /* EOF, cancellation and a missed confirmation deadline grant nothing. */ }
1509
+ finally { rl.close(); }
1510
+ if (!accepted) return console.log("guarded settings were not applied");
1511
+ result = await request({ t: "settings_reload", confirm: token });
1512
+ if (result.confirmation !== undefined) fail("reload candidate changed; run ahub settings reload again to review it; guarded settings were not applied");
1513
+ if (Array.isArray(result.rejected)) for (const row of result.rejected.slice(0, 100)) console.log(terminalText(`${String(row.key).slice(0, 160)}: ${String(row.reason).slice(0, 1000)}`));
1514
+ }
1515
+ return console.log(paint([{ text: result.text }], false));
1516
+ }
1351
1517
  if (sub === "undo") {
1352
1518
  const view = await request({ t: "settings_get" }), undoDef = settingDef(view.undo);
1353
1519
  const rosterPeer = typeof undoDef !== "string" && undoDef.group === "Roster" ? undoDef.path[1] : undefined;
@@ -1375,15 +1541,16 @@ const commands: Record<string, () => Promise<void> | void> = {
1375
1541
  }
1376
1542
  const view = await request({ t: "settings_get" });
1377
1543
  const rows = (view.rows as SettingRow[]).filter((row) => sub === "list" || row.key === key);
1378
- if (flags.includes("--json")) return console.log(JSON.stringify(sub === "get" ? rows[0] : { rows, ...(view.undo ? { undo: view.undo } : {}) }, null, 2));
1544
+ if (flags.includes("--json")) return console.log(JSON.stringify(sub === "get" ? rows[0] : { rows, pendingRestart: view.pendingRestart ?? [], ...(view.undo ? { undo: view.undo } : {}) }, null, 2));
1379
1545
  const width = Math.max(...rows.map((row) => row.key.length)), valueWidth = Math.max(...rows.map((row) => settingText(row.value).length));
1380
1546
  let group = "";
1381
1547
  for (const row of rows) {
1382
1548
  if (sub === "list" && row.group !== group) console.log(`${group ? "\n" : ""}${group = row.group}`);
1383
- const facts = [`from ${row.source}`, row.applies === "live" ? "applies at once" : row.applies === "peer start" ? "applies at the peer's next start" : "read at hub start", ...(row.pending !== undefined ? [`${settingText(row.pending)} at the next ${row.applies === "peer start" ? "peer" : "hub"} start`] : []), ...(row.note ? [row.note] : [])];
1549
+ const facts = [`from ${row.source}`, `file: ${settingText(row.fileValue === undefined ? row.pending ?? row.value : row.fileValue)}`, row.applies === "live" ? "applies at once" : row.applies === "next use" ? "applies at next use" : "pending restart", ...(row.restartReason ? [row.restartReason] : []), ...(row.note ? [row.note] : [])];
1384
1550
  console.log(` ${row.key.padEnd(width)} ${settingText(row.value).padEnd(valueWidth)} ${facts.join("; ")}`);
1385
- if (sub === "get") console.log(` ${row.label}; written to ${row.file}; takes ${row.type === "boolean" ? "true, false" : row.type === "peers" ? "a comma list of peer ids" : row.type === "roles" ? "up to two comma-separated catalog ids" : (row.values ?? []).join(", ")}${row.file === "this hub run" ? "" : ", or inherit to remove the machine-local value"}${row.risk === "raises" ? "; a dashboard needs a settings session (ahub ui --settings) to raise it" : ""}`);
1551
+ if (sub === "get") console.log(` ${row.label}; written to ${row.file}; takes ${row.type === "boolean" ? "true, false" : row.type === "integer" || row.type === "number" ? `a number from ${row.min ?? 0} to ${row.max ?? "the configured bound"}` : row.type === "peers" ? "a comma list of peer ids" : row.type === "roles" ? "up to two comma-separated catalog ids" : (row.values ?? []).join(", ")}${row.file === "this hub run" ? "" : ", or inherit to remove the machine-local value"}${row.risk === "raises" ? "; a dashboard needs a settings session (ahub ui --settings) to raise it" : ""}`);
1386
1552
  }
1553
+ if (sub === "list" && view.pendingRestart?.length) console.log(`\npending restart: ${view.pendingRestart.map((entry: { key: string }) => entry.key).join(", ")}`);
1387
1554
  if (sub === "list" && view.undo) console.log(`\nahub settings undo puts back the last write (${view.undo})`);
1388
1555
  } finally { hub.close(); }
1389
1556
  },
@@ -1835,6 +2002,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1835
2002
  row(verifiedJcodeCommand(config.jcode_cmd, cwd), "jcode", "CLI/source identity only; provider, project MCP and native teardown require separate receipts; native policy unmanaged");
1836
2003
  }
1837
2004
  if (config.roster.qwen === true) {
2005
+ // ponytail: keep this bounded CLI-version probe and 0.25.0 pin in the A1 guard baseline; A2 moves every version probe together when VersionContext gains its observed input.
1838
2006
  const v = version(config.qwen_cmd[0]!);
1839
2007
  row(v === "0.25.0", "qwen", v === "0.25.0" ? "CLI reports 0.25.0; native ACP identity/modes and provider access require a separate live check" : "Qwen Code 0.25.0 executable not confirmed; provider and ACP readiness unknown");
1840
2008
  } // issue #83
package/src/cli/output.ts CHANGED
@@ -7,9 +7,13 @@ import { formatReport, formatTaskReport, type Report, type TaskReport } from "..
7
7
 
8
8
  export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
9
9
  export interface StatusOutput {
10
+ pendingRestart?: readonly { key: string; reason: string }[];
11
+ taskStalls?: readonly { task: number; cause: { kind: string; peer?: string; dependency?: number; code: string } }[];
12
+ pendingTaskOffers?: readonly { task: number; peer: string }[];
13
+ isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
10
14
  memoryProfile?: { profile?: string; generation?: string; state?: string; agentMemVersion?: string; skippedRows?: number; error?: string };
11
15
  taskWip?: Record<string, { wip: number; wipLimit: number }>;
12
- pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; budget?: Record<string, any>;
16
+ pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; budget?: Record<string, any>; planner?: { peers?: string[]; source?: string };
13
17
  tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[]; switchyard?: string;
14
18
  }
15
19
  export interface BudgetOutput { budget: Record<string, any>; gate?: number }
@@ -185,14 +189,21 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
185
189
  if (data.roleInfo) data = { ...data, peers: Object.fromEntries(Object.entries(data.peers).map(([peer, value]) => [peer, { ...value, ...data.roleInfo?.[peer] }])) };
186
190
  const out = lines(`agent-hub${data.version ? ` ${data.version}` : ""} pid ${data.pid ?? "unknown"} control 127.0.0.1:${data.controlPort ?? "unknown"}`, columns, "info");
187
191
  if (data.cwd) out.push(...lines(data.cwd, columns));
192
+ for (const pending of data.pendingRestart ?? []) out.push(...detail("pending restart", `${pending.key}: ${pending.reason}`, columns, "attention"));
193
+ for (const stall of data.taskStalls ?? []) out.push(...detail(`task #${stall.task} stalled`, `${stall.cause.kind}${stall.cause.peer ? ` ${stall.cause.peer}` : ""}${stall.cause.dependency ? ` #${stall.cause.dependency}` : ""}: ${stall.cause.code}; configure the peer, ahub task assign ${stall.task} <peer>, or ahub task cancel ${stall.task} --reason <text>`, columns, "attention"));
194
+ for (const offer of data.pendingTaskOffers ?? []) out.push(...detail("task offer", `open ${offer.peer}: task #${offer.task} is waiting for it`, columns, "attention"));
188
195
  if (data.memoryProfile) {
189
196
  const memory = data.memoryProfile;
190
197
  const profile = memory.profile === "generation" ? `generation ${memory.generation?.slice(0, 8) ?? "unknown"}` : memory.profile ?? "unbound";
191
198
  out.push(...detail("memory", `${profile}; ${memory.state ?? "unknown"}; agent-mem ${memory.agentMemVersion ?? "unknown"}${memory.skippedRows ? `; malformed legacy rows skipped: ${memory.skippedRows}` : ""}${memory.error ? `; ${memory.error}` : ""}`, columns, memory.state === "unavailable" ? "attention" : undefined));
192
199
  }
200
+ if (data.planner) out.push(...detail("planner", `${data.planner.peers?.join(", ") || "nobody"} (${data.planner.source ?? "unknown"})${data.planner.source === "automatic" || data.planner.source === "none" ? "; pin it with ahub settings set planner <peer>" : ""}`, columns, data.planner.source === "none" ? "attention" : undefined));
193
201
  if (data.deliveryError) out.push(...detail("delivery storage", `${data.deliveryError}; dispatch is stopped`, columns, "failure"));
194
202
  for (const pending of data.permissionDefaults ?? []) out.push(...detail("permission default", `${pending.peer}: never-ask from ${pending.source}, stays ask until a person confirms in ahub console`, columns, "attention"));
195
203
  for (const crash of data.crash ?? []) out.push(...detail("crash recovery", crash, columns, "attention"));
204
+ for (const warning of data.isolatedChecks?.orphans ?? []) out.push(...detail("isolated check warning", warning, columns, "attention"));
205
+ for (const id of data.isolatedChecks?.unknownReceipts ?? []) out.push(...detail("check receipt", `${id}: unknown; receipt storage unavailable`, columns, "attention"));
206
+ if (data.isolatedChecks?.maintenanceUnavailable) out.push(...detail("check maintenance", "unavailable; saved run outcomes are unchanged", columns, "attention"));
196
207
  const s = initialConsoleState(); s.panel = 1; s.peers = data.peers; s.budget = data.budget ?? {};
197
208
  const head = Object.values(data.peers).some(p => p.permissionMode && p.permissionMode !== "ask") ? ["PEER", "STATE", "MODE", ...TABLES[1]!.slice(2)] : [...TABLES[1]!];
198
209
  head.push("CONTEXT");
@@ -206,7 +217,9 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
206
217
  if (p.flowRoles) out.push(...detail(`${id} flow roles`, p.flowRoles.join(", ") || "none", columns));
207
218
  if (p.catalogRoles) { out.push(...detail(`${id} catalog roles`, Array.isArray(p.catalogRoles.value) ? p.catalogRoles.value.join(", ") || "none" : "unconfirmed", columns)); if (p.catalogRoles.pending !== undefined) out.push(...detail(`${id} pending roles`, p.catalogRoles.pending.join(", ") || "none", columns, "attention")); }
208
219
  if (p.liveAccepted?.length) { settling = true; out.push(...detail("settling", p.liveAccepted.map((v: string) => shortId(v, columns, full)).join(", "), columns, "attention")); }
209
- if (p.heldBy) {
220
+ if (p.heldRows?.length) {
221
+ for (const row of p.heldRows) out.push(...detail("held", `${id}: needs_review ${shortId(row.id, columns, full)}; from ${row.senders.join(",")}; kind ${row.kinds.join(",")}; age ${duration(Math.max(0, now - row.createdAt))}${row.context ? `; ${row.context}` : ""}; ahub queue show ${row.id}`, columns, "failure"));
222
+ } else if (p.heldBy) {
210
223
  const note = flat(p.holdNote ?? "");
211
224
  const command = `ahub queue resolve ${p.heldBy} --action completed|retry|discard --reason <text>`;
212
225
  const normalized = note.startsWith(`held by needs_review ${p.heldBy}`)
@@ -1,3 +1,4 @@
1
+ import { definitionOf, kindOf } from "../adapters/registry.ts";
1
2
  import { jcodeCommand } from "../adapters/jcode.ts";
2
3
  import { qwenSessionId } from "../hub/crash.ts";
3
4
  import { readFileSync } from "node:fs";
@@ -7,7 +8,7 @@ import { childEnv } from "../hub/child-process.ts";
7
8
  import { relayModelIds } from "../models/relay.ts";
8
9
  import { buildPiLaunch } from "../pi/launch.ts";
9
10
  import { startModeOf } from "../hub/start-mode.ts";
10
- import { buildLaunch, buildKimiLaunch, buildQwenLaunch, buildKimiTuiLaunch, nativeLaunchEnv, claudeObservationHooks, type Launch } from "./launch.ts";
11
+ import { buildLaunch, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, claudeObservationHooks, type Launch } from "./launch.ts";
11
12
  import { kimiMcpPlan } from "./kimi-mcp.ts";
12
13
 
13
14
  const REDACTED = "[redacted]";
@@ -71,14 +72,16 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi" | "qwen
71
72
  launch = { cmd: cmd[0]!, args: cmd.slice(1) };
72
73
  unresolved.push({ field: "ownedDaemon", reason: "private daemon/socket and source-pinned native identity verified at start; preview starts nothing" });
73
74
  unresolved.push({ field: "hubTools", reason: "project MCP bridge required; native tool-call receipt remains unverified" });
74
- } else if (tool === "qwen") {
75
+ } else if (kindOf(tool) === "qwen") {
75
76
  const mode = value("--mode") ?? "headless", model = value("--model");
76
- if (mode !== "headless") throw new Error("Qwen TUI is not offered before verification");
77
+ const adapter = definitionOf(tool)!;
78
+ if (!(adapter.start.forms as readonly string[]).includes(mode)) throw new Error("Qwen TUI is not offered before verification");
77
79
  for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode", "--session-id"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Qwen option");
78
80
  const sessionId = value("--session-id");
79
- if (sessionId !== undefined && !qwenSessionId(sessionId)) throw new Error("invalid Qwen session identity");
81
+ if (sessionId !== undefined && !adapter.session.crashIdentity?.({ sessionId }).sessionId) throw new Error("invalid Qwen session identity");
80
82
  if (sessionId) unresolved.push({ field: "sessionId", reason: "requested through ACP session/load at start; preview does not load or expose it" });
81
- launch = buildQwenLaunch(nativeCommand(config.qwen_cmd, ["qwen", "--acp"]), model ? REDACTED : undefined);
83
+ launch = adapter.start.launch({ instance: tool, form: "headless", projectRoot: cwd, stateDir, entrypoint: join(import.meta.dir, "main.js"),
84
+ userArgs: [], unattended: false, env: {}, preview: true, nativeCommand: nativeCommand(config.qwen_cmd, ["qwen", "--acp"]), model: model ? REDACTED : undefined });
82
85
  unresolved.push({ field: "nativeIdentity", reason: "Qwen Code 0.25.0 identity and permission modes are checked at ACP initialization; preview starts nothing" });
83
86
  } else if (tool === "kimi") {
84
87
  const model = value("--model");