@staix/agent-hub 0.24.0 → 0.26.0

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 (114) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +35 -8
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +2 -1
  5. package/docs/agent-notes/bus.md +1 -0
  6. package/docs/agent-notes/daemon.md +18 -3
  7. package/docs/agent-notes/tasks.md +12 -4
  8. package/docs/agent-notes/tests.md +3 -1
  9. package/docs/dogfood.md +16 -2
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +264 -14
  12. package/docs/profiles.md +2 -0
  13. package/docs/quickstart.md +23 -6
  14. package/docs/security.md +198 -3
  15. package/docs/specs/2026-09-19-agent-hub-design.md +25 -1
  16. package/docs/specs/2026-09-19-multi-project-design.md +12 -1
  17. package/docs/specs/2026-11-01-research-campaigns-design.md +354 -0
  18. package/docs/verification/2026-11-01-0.25.0.md +91 -0
  19. package/docs/verification/2026-11-01-0.26.0.md +54 -0
  20. package/docs/verified.json +45 -17
  21. package/package.json +4 -3
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +5294 -4797
  24. package/src/adapters/channel-peer.ts +4 -2
  25. package/src/adapters/claude-channel.ts +29 -6
  26. package/src/adapters/definitions/builtin-metadata.ts +6 -6
  27. package/src/adapters/definitions/native-cli-doctor.ts +30 -0
  28. package/src/adapters/definitions/qwen.ts +2 -0
  29. package/src/adapters/jcode.ts +3 -0
  30. package/src/adapters/registry-types.ts +2 -0
  31. package/src/adapters/registry.ts +2 -1
  32. package/src/cli/acon-register.ts +29 -0
  33. package/src/cli/conductor-recovery.ts +3 -2
  34. package/src/cli/handoff-args.ts +27 -0
  35. package/src/cli/handoff-output.ts +25 -0
  36. package/src/cli/help.ts +11 -8
  37. package/src/cli/identity.ts +7 -1
  38. package/src/cli/main.ts +138 -30
  39. package/src/cli/output.ts +8 -5
  40. package/src/cli/recovery-package.ts +94 -4
  41. package/src/cli/reset.ts +14 -4
  42. package/src/cli/runtime-version-warning.ts +28 -0
  43. package/src/cli/schedule-output.ts +8 -4
  44. package/src/cli/start-agents-run.ts +125 -0
  45. package/src/cli/study-output.ts +71 -0
  46. package/src/cli/study.ts +58 -0
  47. package/src/cli/task-output.ts +6 -0
  48. package/src/cli/upgrade-interactive.ts +4 -4
  49. package/src/cli/upgrade-runtime.ts +105 -57
  50. package/src/cli/upgrade.ts +90 -28
  51. package/src/hub/autonomy-defaults.ts +32 -7
  52. package/src/hub/board.ts +993 -8
  53. package/src/hub/budget.ts +3 -4
  54. package/src/hub/channel-project.ts +61 -0
  55. package/src/hub/child-process.ts +4 -3
  56. package/src/hub/conductor.ts +3 -4
  57. package/src/hub/config-apply.ts +13 -2
  58. package/src/hub/config-edit-host.ts +1 -0
  59. package/src/hub/config-tier.ts +3 -0
  60. package/src/hub/config-trust.ts +10 -2
  61. package/src/hub/control-client.ts +39 -10
  62. package/src/hub/daemon.ts +326 -25
  63. package/src/hub/db-schema.ts +143 -0
  64. package/src/hub/delivery-journal.ts +3 -4
  65. package/src/hub/envelope.ts +2 -0
  66. package/src/hub/events.ts +4 -1
  67. package/src/hub/execution-budget.ts +15 -4
  68. package/src/hub/folder-trigger.ts +3 -4
  69. package/src/hub/handoff.ts +126 -0
  70. package/src/hub/hub-tools.ts +12 -2
  71. package/src/hub/invocations.ts +3 -4
  72. package/src/hub/lifecycle.ts +87 -29
  73. package/src/hub/manager.ts +110 -87
  74. package/src/hub/no-writes.ts +1 -1
  75. package/src/hub/orca-env.ts +19 -0
  76. package/src/hub/path-policy.ts +12 -8
  77. package/src/hub/project-runtime-state.ts +139 -0
  78. package/src/hub/project-runtime.ts +62 -0
  79. package/src/hub/project.ts +14 -5
  80. package/src/hub/protocol-window.ts +19 -0
  81. package/src/hub/provenance.ts +30 -0
  82. package/src/hub/recovery-store.ts +102 -12
  83. package/src/hub/registry.ts +5 -0
  84. package/src/hub/research-campaign.ts +265 -0
  85. package/src/hub/research-experiments.ts +474 -0
  86. package/src/hub/research-runner.ts +172 -0
  87. package/src/hub/restart.ts +35 -0
  88. package/src/hub/runtime-capture-prune.ts +415 -0
  89. package/src/hub/runtime-prune-health.ts +112 -0
  90. package/src/hub/schedules.ts +213 -13
  91. package/src/hub/sdlc-merge.ts +7 -0
  92. package/src/hub/sdlc-queue.ts +13 -1
  93. package/src/hub/session-identity.ts +49 -0
  94. package/src/hub/settings.ts +4 -0
  95. package/src/hub/snapshots.ts +3 -4
  96. package/src/hub/start-agents.ts +44 -0
  97. package/src/hub/task-approval.ts +23 -0
  98. package/src/hub/tasks.ts +1005 -34
  99. package/src/hub/ui.ts +15 -6
  100. package/src/hub/usage-rollup.ts +3 -4
  101. package/src/memory/capture.ts +2 -2
  102. package/src/pi/tool-receipts.ts +3 -4
  103. package/src/ui/index.html +5 -3
  104. package/src/ui/office-space.ts +118 -0
  105. package/src/ui/plugin.ts +293 -0
  106. package/templates/AGENTS.block.md +1 -0
  107. package/templates/profiles/general.json +1 -1
  108. package/templates/profiles/research.json +4 -4
  109. package/templates/profiles/writing.json +1 -1
  110. package/templates/roles/research/analyst.md +1 -0
  111. package/templates/roles/research/fact-checker.md +1 -0
  112. package/templates/roles/research/research-lead.md +1 -0
  113. package/templates/roles/research/researcher.md +1 -0
  114. package/templates/roles/research/statistician.md +1 -0
package/src/cli/main.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { assertConductorOperationLocal, authorizeConductorOperation, authorizeConductorLaunch, issueConductorOperation, preflightConductorRecovery, retireConductorOperation } from "./conductor-recovery.ts";
3
+ import { runtimePruneHealth } from "../hub/runtime-prune-health.ts";
3
4
  import { conductorLocalOperation, conductorOperation, OPERATIONAL_PERSON_GUIDANCE } from "../hub/operational-authority.ts";
4
5
  import { sayRecipients } from "./say-input.ts";
5
6
  import type { DeliverableRow } from "../hub/tasks.ts";
@@ -16,7 +17,7 @@ import { readInstanceRoleConfig, readInstanceConfig, type InstanceRoleConfig } f
16
17
  import { staleInstanceKey } from "../hub/roster.ts";
17
18
  import { parseConfiguredAcpArguments, runConfiguredAcpEntry, requireActiveConfiguredAcp } from "./configured-acp-entry.ts";
18
19
  import type { NativeCliEntryContext } from "../adapters/definitions/native-cli-entry-types.ts";
19
- import { BUILTIN_CLI_ENTRIES, createProjectAdapterCatalog, definitionOf, DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "../adapters/registry.ts";
20
+ import { BUILTIN_CLI_ENTRIES, createProjectAdapterCatalog, definitionOf, DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeMemoryCaptureRow, type MemorySetupReading, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "../adapters/registry.ts";
20
21
  import { parsePiFlags } from "../adapters/definitions/native-cli-pi.ts";
21
22
  import { supportsBoardReleaseFilter, supportsBoardColumnMetadata } from "./board-read.ts";
22
23
  import { matchesPolicy } from "../hub/path-policy.ts";
@@ -28,6 +29,10 @@ import { renderScheduleChange, renderScheduleList, renderScheduleRun } from "./s
28
29
  import { renderIntake, renderIntakeChange } from "./intake-output.ts";
29
30
  import { renderLearnChange, renderLearnList } from "./learn-output.ts";
30
31
  import { renderCapabilities } from "./capabilities-output.ts";
32
+ import { parseStudyArgs } from "./study.ts";
33
+ import { renderStudyList, renderStudyReceipt, renderStudyShow } from "./study-output.ts";
34
+ import { renderHandoffReceipt, renderHandoffs } from "./handoff-output.ts";
35
+ import { parseHandoffArgs } from "./handoff-args.ts";
31
36
  import { terminalText } from "./console-state.ts";
32
37
  import { parseSettingsEditArgs, runSettingsEdit, SETTINGS_EDIT_USAGE } from "./settings-edit.ts";
33
38
  import { autonomyEnvironment, runAutonomy } from "./autonomy.ts";
@@ -37,6 +42,7 @@ import { offlineAutonomyStatus } from "./autonomy-offline.ts";
37
42
  import { autonomyHomeStatus, usedClaudeHomes } from "./autonomy-homes.ts";
38
43
  import { discoverClaudeHomes } from "./claude-homes.ts";
39
44
  import { setupClaudeHomes } from "./setup-homes.ts";
45
+ import { registerAconPlugin } from "./acon-register.ts";
40
46
  import { petView, readPet, renderPet } from "./pet.ts";
41
47
  import { startPetWindow, closePetWindow } from "../pet/window.ts";
42
48
  import { loadPetConfig, savePetPalette } from "./pet-config.ts";
@@ -51,7 +57,7 @@ import { parseSettingText, settingDef, settingText, undoableDef, type SettingRow
51
57
  import { startModeOf } from "../hub/start-mode.ts";
52
58
  import { currentRouting, OVERLAY_FILE, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "../hub/routing.ts";
53
59
  import { spawn, spawnSync } from "node:child_process";
54
- import { chmodSync, closeSync, constants, existsSync, fstatSync, openSync, readFileSync, readSync, renameSync, writeFileSync } from "node:fs";
60
+ import { chmodSync, closeSync, constants, existsSync, fstatSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync } from "node:fs";
55
61
  import { randomUUID } from "node:crypto";
56
62
  import { homedir } from "node:os";
57
63
  import { basename, dirname, join, relative, resolve } from "node:path";
@@ -79,11 +85,13 @@ import { childEnv, personEnv } from "../hub/child-process.ts";
79
85
  import { abortRecovery, createOperation, disposeRecovery, recordRecoveryDelegation, repairedFreshSession, reattachRecovery, liveProjects, nextChoices, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation, type Inspection } from "./upgrade.ts";
80
86
  import { endPlannedPeer, makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
81
87
  import { exactVersion, latestRelease, newerVersion } from "./recovery-package.ts";
88
+ import { runtimeVersionWarnings } from "./runtime-version-warning.ts";
82
89
  import { operationScreen, planScreen, type ScreenIO, type UpgradeHost } from "./upgrade-interactive.ts";
83
90
  import { readRecoveryPresentationFlags } from "./upgrade-presentation-flags.ts";
84
91
  import { renderOperation, renderBlocked, renderSummary, type RecoveryPresentationOptions } from "./upgrade-presentation.ts";
85
92
  import { renderBudgetResult, renderRecoveryStatus, renderUpgradePlan } from "./upgrade-output.ts";
86
93
  import { recordTerminalLaunch, prepareTerminalLaunch, registerTerminalLaunch, type TerminalPeer } from "./terminal-recovery.ts";
94
+ import { effectiveStartAgents, inheritMachineStart, setupStartAgents, startProjectAgents } from "./start-agents-run.ts";
87
95
  import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
88
96
  import { setupOllamaModel } from "./models-setup.ts";
89
97
 
@@ -137,6 +145,16 @@ while (argv[0] === "--project" || argv[0] === "--live-test") {
137
145
  if (!selector || selector.startsWith("--")) fail("--project needs a path or project ID");
138
146
  }
139
147
  }
148
+ // Upgrade accepts its documented command-local selector as well as the leading global form.
149
+ if (argv[0] === "upgrade") {
150
+ const at = argv.indexOf("--project", 1);
151
+ if (at >= 0) {
152
+ if (selector !== undefined || argv.indexOf("--project", at + 1) >= 0) fail("--project may appear only once");
153
+ selector = argv[at + 1];
154
+ if (!selector || selector.startsWith("--")) fail("--project needs a path or project ID");
155
+ argv.splice(at, 2);
156
+ }
157
+ }
140
158
  const [given = "help", ...rest] = argv;
141
159
  let selected: { root: string; stateDir: string };
142
160
  try {
@@ -380,10 +398,10 @@ function matchingProject(): Project | undefined {
380
398
  finally { registry.close(); }
381
399
  }
382
400
  const hubManifest = () => !!readControl(stateDir) || existsSync(join(stateDir, "hub.pid"));
383
- /** A reset is never part of an upgrade or recovery: any operation holding the machine's lock refuses it, whatever
401
+ /** A reset is never part of an upgrade or recovery: this project's or a legacy machine hold refuses it, whatever
384
402
  * AGENTHUB_RECOVERY_OPERATION says (assertLifecycleAvailable lets that operation's own processes through). */
385
403
  function resetLockFree(): void {
386
- const owner = recoveryLock();
404
+ const owner = recoveryLock(undefined, cwd);
387
405
  if (owner) throw new Error(`${activeOperation(owner)}; nothing was changed`);
388
406
  }
389
407
  /** A manifest whose daemon is alive or uncertain, by inspectProject's rule; a crashed daemon's leftovers do not count. */
@@ -675,11 +693,11 @@ async function onTerminal(screens: (io: ScreenIO) => Promise<void>, presentation
675
693
  }
676
694
 
677
695
  function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost {
678
- const scope = kind === "restart" ? cwd : undefined;
696
+ const scope = cwd;
679
697
  return {
680
698
  plan: () => makeUpgradePlan(kind, version, scope),
681
699
  apply: async (plan, interrupted) => {
682
- assertLifecycleAvailable();
700
+ assertLifecycleAvailable(cwd);
683
701
  const current = await makeUpgradePlan(kind, plan.version, scope);
684
702
  if (current.fingerprint !== plan.fingerprint) throw new Error("the plan changed during review; review it again");
685
703
  // The check above takes seconds: a Ctrl+C in that time is still a no.
@@ -688,7 +706,7 @@ function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost
688
706
  spawnRecovery(operation, true);
689
707
  return operation;
690
708
  },
691
- lock: recoveryLock,
709
+ lock: (projectRoot = cwd) => recoveryLock(undefined, projectRoot),
692
710
  read: (id) => readOperation<RecoveryOperation>(id),
693
711
  runner: recoveryRunner,
694
712
  live: (op) => liveProjects(op, makeRecoveryDriver(undefined, delegatedPeer, undefined, conductorPeer).inspect),
@@ -704,8 +722,7 @@ function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost
704
722
  async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
705
723
  const display = recoveryDisplay();
706
724
  const { one, rest } = takeFlags(display.args, ["--to"], []);
707
- 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]");
708
- if (kind === "upgrade" && selector) fail("upgrade changes the shared package/plugin; omit --project to review all affected running projects");
725
+ if (rest.some((a) => !["--dry-run", "--yes"].includes(a)) || (kind === "restart" && one["--to"])) fail("usage: ahub upgrade [--project <path|id>] [--to <version>] [--dry-run] [--yes] | ahub restart [--dry-run] [--yes]");
709
726
  const yes = rest.includes("--yes"), dry = rest.includes("--dry-run") || (display.json && !yes);
710
727
  // #272: on a terminal, with neither flag, the plan is reviewed, applied and followed in one session.
711
728
  const interactive = !dry && !yes && humanRecovery(display.json);
@@ -720,7 +737,7 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
720
737
  // An older CLI must not coordinate a newer target (docs/operations.md): the target's own coordinator takes over.
721
738
  if (conductorPeer && kind === "upgrade" && newerVersion(to!, VERSION)) fail("a supporting target coordinator is required; the person runs it in ahub console or a terminal");
722
739
  if (kind === "upgrade" && newerVersion(to!, VERSION) && process.env.AGENTHUB_UPGRADE_HANDOVER !== "1") {
723
- const argv = ["x", "--package", `@staix/agent-hub@${to}`, "ahub", ...liveTestArgs, "upgrade", "--to", to!, ...rest, ...display.flags, ...(dry && !rest.includes("--dry-run") ? ["--dry-run"] : [])];
740
+ const argv = ["x", "--package", `@staix/agent-hub@${to}`, "ahub", "--project", cwd, ...liveTestArgs, "upgrade", "--to", to!, ...rest, ...display.flags, ...(dry && !rest.includes("--dry-run") ? ["--dry-run"] : [])];
724
741
  console.error(`ahub: ${to} is newer than this CLI (${VERSION}), so its own coordinator runs the upgrade: bun ${argv.join(" ")}`);
725
742
  if (interactive) {
726
743
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -738,7 +755,7 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
738
755
  }
739
756
  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");
740
757
  if (interactive) return onTerminal((io) => planScreen(upgradeHost(kind, to ?? VERSION), io), display.options);
741
- const plan = await makeUpgradePlan(kind, to ?? VERSION, kind === "restart" ? cwd : undefined, undefined, conductorPeer);
758
+ const plan = await makeUpgradePlan(kind, to ?? VERSION, cwd, undefined, conductorPeer);
742
759
  if (conductorPeer) await preflightConductorRecovery(plan, conductorPeer);
743
760
  const jsonApply = display.json && yes && !dry;
744
761
  // #814: a person reading a pipe or a dry run gets the plan screen's rows, with every blocker, reconnect-only session and
@@ -752,7 +769,7 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
752
769
  if (display.json) for (const blocker of blockers) console.error(`ahub: blocker: ${blocker}`);
753
770
  if (dry) return;
754
771
  if (blockers.length) { if (jsonApply) console.log(JSON.stringify(plan, null, 2)); fail(conductorPeer ? "conductor recovery cannot prove this plan; no runtime was changed; the person runs it in ahub console or a terminal" : "plan has blockers; no runtime was changed"); }
755
- assertLifecycleAvailable();
772
+ assertLifecycleAvailable(cwd);
756
773
  if (!yes) {
757
774
  if (!process.stdin.isTTY) fail("review --dry-run and use --yes in non-interactive sessions");
758
775
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -760,14 +777,14 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
760
777
  if (!/^(y|yes)$/i.test((await rl.question("Apply this project list and restore its sessions? [y/N] ")).trim())) return;
761
778
  } finally { rl.close(); }
762
779
  }
763
- const current = await makeUpgradePlan(kind, plan.version, kind === "restart" ? cwd : undefined, undefined, conductorPeer);
780
+ const current = await makeUpgradePlan(kind, plan.version, cwd, undefined, conductorPeer);
764
781
  if (current.fingerprint !== plan.fingerprint) fail("plan changed during review; run the command again");
765
782
  const routingDefaults = conductorPeer ? await preflightConductorRecovery(plan, conductorPeer) : undefined;
766
783
  const sourceRoot = preserveSource(plan);
767
784
  let operation: RecoveryOperation;
768
785
  if (conductorPeer) {
769
786
  const id = randomUUID();
770
- const pending: RecoveryOperation = { schema: 1, id, plan, sourceRoot, createdAt: Date.now(), updatedAt: Date.now(), phase: "pending", step: "stage", ...(routingDefaults ? { conductorRoutingDefaults: routingDefaults } : {}), projects: plan.projects.map(row => ({ id: row.project.id, phase: "pending", terminals: {} })) };
787
+ const pending: RecoveryOperation = { schema: 3, id, plan, sourceRoot, createdAt: Date.now(), updatedAt: Date.now(), phase: "pending", step: "stage", ...(routingDefaults ? { conductorRoutingDefaults: routingDefaults } : {}), projects: plan.projects.map(row => ({ id: row.project.id, phase: "pending", terminals: {} })) };
771
788
  const target = await makeRecoveryDriver().stage(pending);
772
789
  pending.targetRoot = target.root; pending.targetDigest = target.digest; pending.targetFeatures = target.targetFeatures;
773
790
  await issueConductorOperation(pending, conductorPeer);
@@ -863,9 +880,9 @@ const commands: Record<string, () => Promise<void> | void> = {
863
880
  printRecovery(renderBlocked(result, nextChoices(result, undefined, live), display.options));
864
881
  return;
865
882
  }
866
- // #272: without an id, the operation that holds the machine's lock; bare on a terminal, its screen.
883
+ // #272: without an id, this project's hold (or a retained legacy machine hold); bare on a terminal, its screen.
867
884
  const named = !!given && operationIdPattern.test(given);
868
- const id = named ? given : recoveryLock();
885
+ const id = named ? given : recoveryLock(undefined, cwd);
869
886
  const rest = named || given === undefined ? more : [given, ...more];
870
887
  if (action === undefined && humanRecovery(display.json)) {
871
888
  if (!id) return console.log("no recovery operation is open");
@@ -1004,27 +1021,42 @@ const commands: Record<string, () => Promise<void> | void> = {
1004
1021
  },
1005
1022
 
1006
1023
  manager: async () => {
1007
- const manager = await startManager({ lifecycle });
1024
+ // #608: the dashboard's "start" counts as the person's `ahub up`: the manager starts the project's default agent set after the hub is ready.
1025
+ const manager = await startManager({ lifecycle, afterStart: async (project) => {
1026
+ const chosen = effectiveStartAgents(project.root, loadConfig(project.root).start.agents, hubHome(), [project.root]);
1027
+ return startProjectAgents(project, chosen.agents);
1028
+ } });
1008
1029
  for (const signal of ["SIGINT", "SIGTERM"] as const) process.on(signal, () => void manager.stop());
1009
1030
  await manager.stopped;
1010
1031
  },
1011
1032
 
1012
1033
  setup: async () => {
1013
1034
  recoveryLauncherCurrent?.();
1014
- assertLifecycleAvailable();
1035
+ assertLifecycleAvailable(cwd);
1015
1036
  const root = join(import.meta.dir, "..", "..");
1037
+ const setupFlags = takeFlags(args.filter(arg => arg !== "--yes"), ["--agents"], []);
1038
+ if (setupFlags.rest.length) fail("usage: ahub setup [--yes] [--agents <a,b>]");
1016
1039
  const registry = new Registry(), rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : undefined;
1017
1040
  try {
1018
1041
  const found = recoveryLauncherCurrent || delegatedPeer ? await discoverClaudeHomes({ home: homedir(), env: process.env }) : await usedClaudeHomes(registry, homedir(), process.env);
1019
1042
  for (const notice of found.notices) console.log(terminalText(notice));
1020
1043
  await setupClaudeHomes({ homes: found.homes, env: process.env, packageRoot: root, yes: args.includes("--yes"),
1021
1044
  interactive: !!rl,
1022
- current: () => { recoveryLauncherCurrent?.(); assertLifecycleAvailable(); },
1045
+ current: () => { recoveryLauncherCurrent?.(); assertLifecycleAvailable(cwd); },
1023
1046
  print: line => console.log(terminalText(line)), ask: async prompt => rl ? rl.question(prompt) : null,
1024
1047
  probe: (argv, options) => { recoveryLauncherCurrent?.(); const result = spawnSync(argv[0]!, argv.slice(1), { encoding: "utf8", env: options.env, timeout: options.timeoutMs, maxBuffer: 1024 * 1024 }); recoveryLauncherCurrent?.(); return { status: result.status ?? -1, stdout: result.stdout ?? "" }; },
1025
1048
  run: async (argv, env) => { recoveryLauncherCurrent?.(); const result = spawnSync(argv[0]!, argv.slice(1), { stdio: "inherit", env, timeout: 30_000 }); recoveryLauncherCurrent?.(); return result.status ?? -1; },
1026
1049
  });
1050
+ // #608: the one question about the default agent set, after the Claude homes and before doctor.
1051
+ if (!recoveryLauncherCurrent && !delegatedPeer) {
1052
+ const refused = await setupStartAgents({ home: hubHome(), roots: [...registry.list().map(row => row.root), cwd], ...(setupFlags.one["--agents"] !== undefined ? { flag: setupFlags.one["--agents"] } : {}), yes: args.includes("--yes"),
1053
+ ...(rl ? { ask: async (prompt: string) => { try { return await rl.question(prompt); } catch { return null; } } } : {}), print: line => console.log(terminalText(line)) });
1054
+ if (refused) fail(refused);
1055
+ }
1027
1056
  } finally { registry.close(); rl?.close(); }
1057
+ // #935: register the acon console plugin (acon reads it at its next start; no acon install is needed for this).
1058
+ const acon = registerAconPlugin(hubHome(), join(root, "src", "ui", "plugin.ts"));
1059
+ console.log("error" in acon ? `acon plugin not registered: ${acon.error}` : acon.changed ? `acon plugin registered: ${acon.path}` : `acon plugin up to date: ${acon.path}`);
1028
1060
  if (recoveryLauncherCurrent) recoveryLauncherCurrent();
1029
1061
  else await commands.doctor!();
1030
1062
  },
@@ -1042,7 +1074,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1042
1074
  if (defaults.notice) console.error(defaults.notice);
1043
1075
  return console.log(args.includes("--json") ? JSON.stringify(plan, null, 2) : plan.map(change => `${change.action} ${change.path}: ${change.reason}${change.managedBlock ? ` (managed block: ${change.managedBlock})` : ""}`).join("\n") || "already up to date");
1044
1076
  }
1045
- assertLifecycleAvailable();
1077
+ assertLifecycleAvailable(cwd);
1046
1078
  if (newProject && delegatedPeer && readAutonomyDefaults(hubHome(), [cwd]).enabled) fail("machine autonomy inheritance is person-only; delegated live tests cannot apply it");
1047
1079
  const changed = init(cwd, requestedProfile, args.includes("--shadow"), args.includes("--release-guard"));
1048
1080
  const project = registeredProject();
@@ -1051,6 +1083,8 @@ const commands: Record<string, () => Promise<void> | void> = {
1051
1083
  const registry = new Registry();
1052
1084
  try { for (const line of await inheritMachineAutonomy(project, hubHome(), autonomyEnvironment(process.env, !!process.stdin.isTTY), registry)) console.log(terminalText(line)); }
1053
1085
  finally { registry.close(); }
1086
+ // #608: the machine's default agent set, copied the same way (person-only: a delegated live test never inherits it).
1087
+ if (!delegatedPeer) { const roots = (() => { const r = new Registry(); try { return r.list().map(row => row.root); } finally { r.close(); } })(); for (const line of inheritMachineStart(cwd, hubHome(), [...roots, cwd])) console.log(terminalText(line)); }
1054
1088
  }
1055
1089
  const config = projectConfig(), examples = resolveProfile(config.profile, config.profile_overrides).example_checks;
1056
1090
  if (examples.length) console.log(terminalText("Example checks (advisory):\n" + examples.map(example => "- " + example).join("\n")));
@@ -1078,12 +1112,20 @@ const commands: Record<string, () => Promise<void> | void> = {
1078
1112
  if (recoveryLauncherCurrent) { recoveryLauncherCurrent(); return; }
1079
1113
  try { if (loadPetConfig(cwd).window) console.log(await startPetWindow(stateDir, cwd)); }
1080
1114
  catch { console.error("Pet window was not started; run ahub pet --window to inspect it."); }
1115
+ // #608: the default agent set, only for a person's own `ahub up` (never an agent's, a live test's, or an upgrade or recovery relaunch, which set AGENTHUB_RECOVERY_OPERATION).
1116
+ if (!args.includes("--no-agents") && !liveTest && caller.role === "console" && identity.role === "console" && !process.env.AGENTHUB_RECOVERY_OPERATION) {
1117
+ try {
1118
+ const chosen = effectiveStartAgents(cwd, loadConfig(cwd).start.agents, hubHome(), [cwd]);
1119
+ if (chosen.notice) console.error(chosen.notice);
1120
+ for (const line of await startProjectAgents({ root: cwd, stateDir }, chosen.agents)) console.log(terminalText(line));
1121
+ } catch (error) { console.error(`default agents were not started: ${terminalText((error as Error).message)}`); }
1122
+ }
1081
1123
  if (process.stdin.isTTY && process.stdout.isTTY && !args.includes("--no-console")) await runConsole({ client: await connect(), cwd, stateDir, liveTest });
1082
1124
  },
1083
1125
 
1084
1126
  ...Object.fromEntries(BUILTIN_CLI_ENTRIES.map(entry => [entry.command, async () => {
1085
1127
  // Preserve the original guard before native effects; previews remain read-only and precede this guard.
1086
- if (entry.lifecycleAdmission && !args.includes("--print-command") && !args.includes("--dry-run")) assertLifecycleAvailable();
1128
+ if (entry.lifecycleAdmission && !args.includes("--print-command") && !args.includes("--dry-run")) assertLifecycleAvailable(cwd);
1087
1129
  const nativeConnect = async () => {
1088
1130
  const hub = await connectLaunch();
1089
1131
  try { if (selectedInstance) { requirePeerInstances(hub.features); requireActiveInstance((await hub.request({ t: "status" })).status, selectedInstance); } return hub; }
@@ -1457,6 +1499,40 @@ const commands: Record<string, () => Promise<void> | void> = {
1457
1499
  return showToolText(data => renderScheduleChange(sub as "pause" | "resume", data, present.columns, Date.now()), await taskOp(sub === "pause" ? "schedule_pause" : "schedule_resume", { id }), present);
1458
1500
  },
1459
1501
 
1502
+ study: async () => {
1503
+ // #601: research campaigns. The hub keeps every campaign and decides every rule (Tasks.study*); this process parses argv, reads the contract file the person names and asks.
1504
+ // Person only for draft, revise, approve and close (a console control op the daemon refuses on any other connection, and `study` has no agent form in classifyPeerCommand besides the
1505
+ // read-only `show`); an agent drafts through the hub_study_* tools. `show` is a read: for a private campaign, task or source the hub sends ids, counts and states only.
1506
+ const split = splitPresentation(args, { edges: true });
1507
+ const present = outputOptions(split.flags); // before any request: an invalid --color must refuse before a mutation is sent
1508
+ const parsed = parseStudyArgs(split.operands);
1509
+ if ("usage" in parsed) return fail(parsed.usage);
1510
+ const contract = (file: string): unknown => {
1511
+ // One descriptor decides everything: opened non-blocking and without following a final symlink (a FIFO would otherwise block the open), checked to be a regular file with fstat, and
1512
+ // read for at most the cap plus one byte, so a file that grows or is replaced after the check, or a device that reports size zero, cannot exhaust memory or hang this process.
1513
+ const cap = 64 * 1024;
1514
+ let text: string;
1515
+ try {
1516
+ const fd = openSync(resolve(invokedFrom, file), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
1517
+ try {
1518
+ if (!fstatSync(fd).isFile()) return fail("the contract file is not a regular file");
1519
+ const bytes = Buffer.alloc(cap + 1); let got = 0;
1520
+ while (got < bytes.length) { const n = readSync(fd, bytes, got, bytes.length - got, null); if (!n) break; got += n; }
1521
+ if (got > cap) return fail("the contract file is over 64 KiB");
1522
+ text = bytes.subarray(0, got).toString("utf8");
1523
+ } finally { closeSync(fd); }
1524
+ }
1525
+ catch { return fail("cannot read the contract file"); }
1526
+ try { return JSON.parse(text); } catch { return fail("the contract file is not valid JSON"); }
1527
+ };
1528
+ if (parsed.op === "show") return showToolText(data => parsed.id ? renderStudyShow(data, present.columns) : renderStudyList(data, present.columns), await taskOp("hub_study_show", parsed.id ? { id: parsed.id } : parsed.after ? { after: parsed.after } : {}), present);
1529
+ const request = parsed.op === "draft" ? { parentTaskId: parsed.parentTaskId, contract: contract(parsed.file) }
1530
+ : parsed.op === "revise" ? { id: parsed.id, revision: parsed.revision, contract: contract(parsed.file) }
1531
+ : parsed.op === "approve_protocol" ? { hash: parsed.hash }
1532
+ : parsed.op === "approve" ? { id: parsed.id, revision: parsed.revision } : { id: parsed.id, revision: parsed.revision, reason: parsed.reason };
1533
+ return showToolText(data => renderStudyReceipt(parsed.op, data, present.columns), await taskOp(`study_${parsed.op}`, request, false, undefined, parsed.op === "approve" ? 90_000 : undefined), present);
1534
+ },
1535
+
1460
1536
  capabilities: async () => {
1461
1537
  // #445 C1: what each attached peer offers and which of it you share. Person only, like `learn`: `capabilities` has no entry in classifyPeerCommand, so an agent shell is refused before this runs and the daemon refuses any caller but the person again. It lists; it never calls anything.
1462
1538
  const split = splitPresentation(args);
@@ -1530,6 +1606,11 @@ const commands: Record<string, () => Promise<void> | void> = {
1530
1606
  if (!selection.label) fail("GitHub import requires --label");
1531
1607
  return console.log(await taskOp("github_import", selection));
1532
1608
  }
1609
+ if (args[0] === "--handoffs" || args.includes("--handoffs")) {
1610
+ // #604: what this project handed to other projects and the last state each reported (read-only).
1611
+ const options = outputOptions();
1612
+ return showToolText(data => renderHandoffs(data, options.columns), await taskOp("hub_task_handoffs", {}), options);
1613
+ }
1533
1614
  const options = outputOptions();
1534
1615
  const { one, rest } = takeFlags(args, ["--release", "--release-id"], []);
1535
1616
  if (one["--release"] !== undefined && !one["--release"].trim() || one["--release"] !== undefined && one["--release-id"] !== undefined || one["--release-id"] !== undefined && !/^[1-9]\d*$/.test(one["--release-id"])) fail("choose --release <nonempty name> or --release-id <positive id>");
@@ -1773,7 +1854,16 @@ const commands: Record<string, () => Promise<void> | void> = {
1773
1854
  appendRecords(project.id, [{ schema: RESEARCH_SCHEMA, kind: "label", project: projectKey(project.id), task: id, createdAt: record!.createdAt!, label, at: new Date().toISOString(), ...(identity.role === "tools" ? { by: identity.peer } : {}) }]);
1774
1855
  return console.log(`task #${id} (approved ${record!.approvedAt}) labelled ${label} in the research records`);
1775
1856
  }
1776
- 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>");
1857
+ if (sub === "handoff") {
1858
+ // #604: work that belongs to ANOTHER project that runs agent-hub goes onto that project's board. The person's form (a console command: the classifier gives an agent `console` for it, and an
1859
+ // agent uses hub_task_handoff); the hub resolves the target, asks its daemon and records the answer. Nothing is queued: a target that is not running is named with the action.
1860
+ const present = outputOptions(splitPresentation(rest, { valueFlags: ["--project", "--path", "--issue", "--task", "--detail"], edges: true }).flags);
1861
+ const parsed = parseHandoffArgs(splitPresentation(rest, { valueFlags: ["--project", "--path", "--issue", "--task", "--detail"], edges: true }).operands);
1862
+ if ("usage" in parsed) return fail(parsed.usage);
1863
+ const text = await taskOp("hub_task_handoff", { ...parsed });
1864
+ return showToolText(data => renderHandoffReceipt(data, present.columns), text, present);
1865
+ }
1866
+ 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> | handoff --project <root|id> <title...>");
1777
1867
  // `--class` is the explicit form. A first word that is a class name is still taken as the class (the documented
1778
1868
  // short form), but said out loud: "review the auth module" would otherwise be filed as class review, silently.
1779
1869
  const urgent = rest.includes("--urgent"), backlog = rest.includes("--backlog");
@@ -1903,7 +1993,8 @@ const commands: Record<string, () => Promise<void> | void> = {
1903
1993
  try {
1904
1994
  const environment = autonomyEnvironment(process.env, !!process.stdin.isTTY), home = hubHome();
1905
1995
  if (args.includes("--all-projects")) {
1906
- assertLifecycleAvailable();
1996
+ assertLifecycleAvailable(cwd);
1997
+ for (const project of registry.list()) assertLifecycleAvailable(project.root);
1907
1998
  await runAllProjectAutonomy(args, { home, environment, projects: registry.list(),
1908
1999
  prepare: (project, enabled) => prepareAutonomyProject(project, environment, enabled, registry),
1909
2000
  ask: async prompt => rl ? rl.question(prompt) : null, print: line => console.log(terminalText(line)),
@@ -1938,7 +2029,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1938
2029
  contextLines: [...(defaults.notice ? [defaults.notice] : []), "On/off also changes this machine's autonomy default for new projects; --all-projects applies the change to every registered project."],
1939
2030
  confirmPrompt: "Apply this project's preset and this machine's autonomy default? [y/N] ",
1940
2031
  beforeApply: action => {
1941
- assertLifecycleAvailable();
2032
+ assertLifecycleAvailable(cwd);
1942
2033
  if (action === "off" && defaults.fingerprint === "untrusted") console.log("Unsafe machine defaults are already ignored; their file was left unchanged.");
1943
2034
  else writeAutonomyDefaults(home, action === "on", defaults.fingerprint, roots);
1944
2035
  console.log(`Machine autonomy default: ${action}.`);
@@ -2494,7 +2585,7 @@ const commands: Record<string, () => Promise<void> | void> = {
2494
2585
  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");
2495
2586
  }
2496
2587
  if (liveManifest()) throw new Error("a hub started after the stop; nothing was reset, run ahub reset again");
2497
- // An upgrade or recovery that took the machine's lock after the first check must not run beside the reset.
2588
+ // A recovery that took this project's hold after the first check must not run beside its reset.
2498
2589
  resetLockFree();
2499
2590
  if (all) {
2500
2591
  let archived: string;
@@ -2579,6 +2670,7 @@ const commands: Record<string, () => Promise<void> | void> = {
2579
2670
  const row = (ok: boolean | undefined, name: string, detail: string) => { checks.push(nativeDoctorCheck(ok, name, detail)); };
2580
2671
 
2581
2672
  try {
2673
+ for (const check of runtimePruneHealth()) row(check.ok, check.name, check.detail);
2582
2674
  const catalog = loadRoleCatalog(cwd);
2583
2675
  for (const error of catalog.errors) row(false, "catalog role", `${error.id}: ${error.reason}`);
2584
2676
  try {
@@ -2590,11 +2682,13 @@ const commands: Record<string, () => Promise<void> | void> = {
2590
2682
  row(!!v, bin, v ?? "not found on PATH");
2591
2683
  }
2592
2684
  const up = await healthy();
2685
+ let daemonVersion: unknown;
2593
2686
  row(up, "ahub daemon", up ? readControl(stateDir)!.url : "not running (ahub up)");
2594
2687
  if (up) {
2595
2688
  const hub = await connect();
2596
2689
  try {
2597
2690
  const { status } = await hub.request({ t: "status" });
2691
+ daemonVersion = status.version; // Authenticated reply, never the unauthenticated disk manifest.
2598
2692
  const pending = Object.values(status.peers as Record<string, PeerRow>).reduce((n, peer) => n + (peer.needsReview ?? 0), 0);
2599
2693
  row(!status.deliveryError && pending === 0, "delivery recovery", status.deliveryError ?? (pending ? `${pending} deliveries need review; run ahub queue list` : "journal healthy; no deliveries need review"));
2600
2694
  // #285 AC10: where Kimi's number comes from, and whether the hub can trust it right now. An older hub answers without `kimiSource`: no row, not a failure.
@@ -2610,6 +2704,7 @@ const commands: Record<string, () => Promise<void> | void> = {
2610
2704
  }
2611
2705
  const plugin = pluginState(parseList<InstalledPlugin>(spawnSync(DOCTOR_PLUGIN_PROBE.command, [...DOCTOR_PLUGIN_PROBE.argv], { encoding: "utf8" }).stdout ?? ""), join(import.meta.dir, "..", ".."));
2612
2706
  row(plugin.state === "current", DOCTOR_PLUGIN_PROBE.name, plugin.state === "missing" ? "missing: run ahub setup" : plugin.state === "current" ? `agent-hub@agent-hub ${plugin.version}` : `agent-hub@agent-hub is stale (${plugin.why}): run ahub setup`);
2707
+ if (up) checks.push(...runtimeVersionWarnings(daemonVersion, VERSION, plugin.state === "missing" ? undefined : plugin.version));
2613
2708
 
2614
2709
  const config = loadConfig(cwd);
2615
2710
  row(!config.ignored, "config", config.ignored ? `${config.ignored.join("; ")} (move them to .agenthub/config.local.json)` : "no machine-local field ignored");
@@ -2658,11 +2753,11 @@ const commands: Record<string, () => Promise<void> | void> = {
2658
2753
  let memoryLibraryReady = false;
2659
2754
  try { assertAgentMemVersion({ name: "@staix/agent-mem", version: AGENT_MEM_VERSION }); memoryLibraryReady = true; } catch { /* report the failed exact pin without opening a store */ }
2660
2755
  row(memoryLibraryReady, "memory library", memoryLibraryReady ? `amem ${AGENT_MEM_VERSION}; in-process, no claude-mem worker` : "installed amem does not match the exact dependency pin");
2661
- const bridge = spawnSync("dot", ["ai", "memory", "status"], { encoding: "utf8" });
2662
- const out = bridge.error ? "" : bridge.stdout.replace(/\x1b\[[0-9;]*m/g, "");
2756
+ // #1008: memory capture is agent-mem's own setup state, read as structured data (`amem setup <tool> --json`, a preview that writes nothing), never dotfiles text. The caller's
2757
+ // identity is NOT scrubbed: agent-mem screens a plan for an agent, and a refusal reads as unknown here instead of being laundered into a person's answer.
2663
2758
  for (const tool of DOCTOR_MEMORY_CAPTURE_PEERS) {
2664
- const ready = new RegExp(`✓\\s+${tool} ready`).test(out);
2665
- row(bridge.error ? undefined : ready, `memory capture: ${tool}`, bridge.error ? "unknown (`dot ai memory status` not available)" : `${ready ? "ready" : "not ready"}, per ` + "`dot ai memory status`");
2759
+ const found = nativeMemoryCaptureRow(tool, readMemorySetup(tool));
2760
+ row(found.ok, `memory capture: ${tool}`, found.detail);
2666
2761
  }
2667
2762
  } finally {
2668
2763
  // Preserve findings already collected if a later probe (for example config parsing) throws.
@@ -2672,6 +2767,19 @@ const commands: Record<string, () => Promise<void> | void> = {
2672
2767
  },
2673
2768
  };
2674
2769
 
2770
+ /**
2771
+ * One bounded read of agent-mem's setup state for a tool: `amem setup <tool> --json` with no mode flag is a preview and writes nothing. No shell, a 10 s timeout, a 256 KiB output cap,
2772
+ * and the answer is parsed as JSON here and judged by `nativeMemoryCaptureRow`; anything that does not parse, exits non-zero or times out is "unavailable", never a guess.
2773
+ * ponytail: runs the `amem` found on PATH (the hub pins @staix/agent-mem exactly, but the person's own `amem` may be another version); the upgrade is to call `planSetup` from the pinned
2774
+ * library in-process once it is exported from its public entry.
2775
+ */
2776
+ function readMemorySetup(tool: string): MemorySetupReading {
2777
+ const found = spawnSync("amem", ["setup", tool, "--json"], { encoding: "utf8", timeout: 10_000, maxBuffer: 256 * 1024, stdio: ["ignore", "pipe", "pipe"] });
2778
+ if (found.error) return { kind: "unavailable", reason: (found.error as NodeJS.ErrnoException).code === "ENOENT" ? "`amem` is not on PATH" : "`amem setup` could not be run" };
2779
+ if (found.status !== 0 || typeof found.stdout !== "string") return { kind: "unavailable", reason: found.status === null ? "`amem setup` timed out" : `\`amem setup ${tool}\` refused or failed` };
2780
+ try { return { kind: "read", plan: JSON.parse(found.stdout) }; } catch { return { kind: "unavailable", reason: "`amem setup --json` did not print JSON" }; }
2781
+ }
2782
+
2675
2783
  function sanitizeRoutingParseError(file: "routing.toml" | "routing.local.toml", error: unknown): string {
2676
2784
  const raw = ((error as Error)?.message || "invalid TOML").split("\n")[0]!.trim();
2677
2785
  const cleaned = raw
@@ -2793,7 +2901,7 @@ async function runConductorCommand(): Promise<void> {
2793
2901
  }
2794
2902
  const runConfigured = configuredCommand ? async () => {
2795
2903
  const selection = parseConfiguredAcpArguments(cmd, args, projectCatalog, declaredInstances, peerConfig.declaredExternalInstances);
2796
- if (!selection.preview) assertLifecycleAvailable();
2904
+ if (!selection.preview) assertLifecycleAvailable(cwd);
2797
2905
  await runConfiguredAcpEntry({ kind: cmd, raw: args, catalog: projectCatalog, instances: declaredInstances, declaredExternalInstances: peerConfig.declaredExternalInstances, projectRoot: cwd, connect: connectLaunch, print: text => console.log(text), operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
2798
2906
  } : undefined;
2799
2907
  const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? runConfigured ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
package/src/cli/output.ts CHANGED
@@ -7,6 +7,7 @@ import { cells, TABLES, table, fitLine, wrap, duration, relative, plural, termin
7
7
  import { taskProgress } from "../ui/task-progress.ts";
8
8
  import { backendLabel } from "./status-lines.ts";
9
9
  import { ownerIsolationText } from "./role-holders.ts";
10
+ import { schedulePeriodText, scheduleWhen } from "../hub/schedules.ts";
10
11
  import { formatReport, formatTaskReport, type Report, type TaskReport } from "../hub/report.ts";
11
12
  import type { Counter, LimitsResult, TotalResult, UsageResult } from "../hub/usage-rollup.ts";
12
13
 
@@ -20,7 +21,7 @@ export interface StatusOutput {
20
21
  /** #843 AC3: installed agent CLIs not on a verified version; ids, version, state and a receipt path only. */
21
22
  agentDrift?: readonly { id: string; version: string; state: string; receipt: string }[];
22
23
  /** #512a: one row per machine-local schedule; ids, times and a state only, never task text. */
23
- schedules?: readonly { id: string; every: string; class: string; queue: string; next: number; last?: number; task?: number; state: string; coalesced?: number; error?: string }[];
24
+ schedules?: readonly { id: string; every?: string; cron?: string; zone?: string; class: string; queue: string; next: number; last?: number; task?: number; state: string; coalesced?: number; error?: string }[];
24
25
  taskStalls?: readonly { task: number; cause: { kind: string; peer?: string; dependency?: number; code: string } }[];
25
26
  pendingTaskOffers?: readonly { task: number; peer: string }[];
26
27
  isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
@@ -218,8 +219,8 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
218
219
  for (const pending of data.retainedConfig) out.push(...detail("retained reason", `${pending.key}: ${pending.reason}`, columns, "attention"));
219
220
  }
220
221
  for (const row of data.schedules ?? []) {
221
- const when = (at: number) => new Date(at).toISOString().replace("T", " ").slice(0, 16) + "Z";
222
- const text = `${row.state === "paused" ? `paused (ahub schedule resume ${row.id}); would be next ${when(row.next)}` : row.state === "scheduled" ? `next ${when(row.next)}` : row.state === "due" ? "due now" : row.state === "waiting" ? `due, waiting for task #${row.task} to close` : "due, held (the hub is stopping or recovering)"}; every ${row.every}; ${row.class} to ${row.queue}${row.last ? `; last ${when(row.last)}${row.task ? ` as task #${row.task}` : ""}` : "; never run"}${row.coalesced ? `; ${row.coalesced} skipped run${row.coalesced === 1 ? "" : "s"} coalesced` : ""}${row.error ? `; last error: ${row.error}` : ""}`;
222
+ const when = (at: number) => row.zone ? scheduleWhen(row, at) : new Date(at).toISOString().replace("T", " ").slice(0, 16) + "Z";
223
+ const text = `${row.state === "paused" ? `paused (ahub schedule resume ${row.id}); would be next ${when(row.next)}` : row.state === "scheduled" ? `next ${when(row.next)}` : row.state === "due" ? "due now" : row.state === "waiting" ? `due, waiting for task #${row.task} to close` : "due, held (the hub is stopping or recovering)"}; ${schedulePeriodText(row)}; ${row.class} to ${row.queue}${row.last ? `; last ${when(row.last)}${row.task ? ` as task #${row.task}` : ""}` : "; never run"}${row.coalesced ? `; ${row.coalesced} skipped run${row.coalesced === 1 ? "" : "s"} coalesced` : ""}${row.error ? `; last error: ${row.error}` : ""}`;
223
224
  out.push(...detail(`schedule ${row.id}`, text, columns, row.error ? "attention" : undefined));
224
225
  }
225
226
  if (data.queueBackpressure) {
@@ -347,6 +348,8 @@ export function renderBacklog(cards: any[], columns?: number, now = Date.now(),
347
348
  return [...rows(names, table, columns), [], ...lines(summary, columns)];
348
349
  }
349
350
  /** #724: one line per task. TITLE goes last and is cut to the room left; at a narrow width REVIEWER, CLASS and AGE give way first. */
351
+ /** #604: a task another project handed over says so on the board: the project id and the peer, both ids and nothing a handoff wrote. */
352
+ const handoffMark = (task: any): string => task?.handoffOrigin ? ` (handed off from ${String(task.handoffOrigin.projectId).slice(0, 24)} by ${String(task.handoffOrigin.peer).slice(0, 32)})` : "";
350
353
  function compactBoard(tasks: any[], data: OutputCell[][], columns: number, staged: boolean): Span[][] {
351
354
  const head = ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "AFTER", ...(staged ? ["STAGE"] : []), "TITLE"];
352
355
  let table = tasks.map((task, i) => {
@@ -355,7 +358,7 @@ function compactBoard(tasks: any[], data: OutputCell[][], columns: number, stage
355
358
  const meter = /^\[[^\]]*\](?: cancelled)?/.exec(row[7]?.text ?? "")?.[0] ?? "[----]";
356
359
  const stage = task.lifecycle ? `${meter} ${task.lifecycle.stage}: ${stageCode(String(task.lifecycle.next ?? ""))}` : meter;
357
360
  const short = task.lifecycle ? `${meter} ${stageCode(String(task.lifecycle.next ?? ""))}` : meter;
358
- return [row[0]!, row[1]!, row[2]!, row[3]!, row[4]!, row[5]!, span(after, undefined, true), ...(staged ? [{ ...span(stage, row[7]!.tone, true), short }] : []), span(flat(task.title))];
361
+ return [row[0]!, row[1]!, row[2]!, row[3]!, row[4]!, row[5]!, span(after, undefined, true), ...(staged ? [{ ...span(stage, row[7]!.tone, true), short }] : []), span(`${flat(task.title)}${handoffMark(task)}`)];
359
362
  });
360
363
  let names = [...head];
361
364
  const drop = (name: string) => { const at = names.indexOf(name); if (at < 0) return; names = names.filter((_, i) => i !== at); table = table.map(row => row.filter((_, i) => i !== at)); };
@@ -389,7 +392,7 @@ export function renderBoard(tasks: any[], columns?: number, now = Date.now(), fu
389
392
  const row = cells(s, task, false, now, stages);
390
393
  row[2] = span(row[2]!.text, peerTone(task.owner ?? ""));
391
394
  row[3] = span(row[3]!.text, peerTone(task.reviewer ?? ""));
392
- row[6] = span(`${task.title}${task.deps?.length ? ` after ${task.deps.map((id: number) => `#${id}`).join(", ")}` : ""}${task.signals?.includes("pii") ? ` (ahub task show ${task.id})` : ""}`);
395
+ row[6] = span(`${task.title}${handoffMark(task)}${task.deps?.length ? ` after ${task.deps.map((id: number) => `#${id}`).join(", ")}` : ""}${task.signals?.includes("pii") ? ` (ahub task show ${task.id})` : ""}`);
393
396
  if (allTasks === null) row.pop();
394
397
  return row;
395
398
  });