@staix/agent-hub 0.25.0 → 0.27.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 (87) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +12 -6
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +1 -1
  5. package/docs/agent-notes/daemon.md +7 -5
  6. package/docs/agent-notes/tasks.md +7 -6
  7. package/docs/agent-notes/tests.md +1 -0
  8. package/docs/dogfood.md +7 -1
  9. package/docs/operations.md +160 -17
  10. package/docs/profiles.md +1 -0
  11. package/docs/quickstart.md +18 -4
  12. package/docs/sdlc.md +3 -3
  13. package/docs/security.md +52 -9
  14. package/docs/specs/2026-09-19-agent-hub-design.md +1 -1
  15. package/docs/specs/2026-11-01-research-campaigns-design.md +21 -7
  16. package/docs/verification/2026-11-01-0.26.0.md +54 -0
  17. package/docs/verification/2026-11-01-0.27.0.md +49 -0
  18. package/docs/verified.json +28 -16
  19. package/package.json +1 -1
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +190 -101
  22. package/src/adapters/channel-peer.ts +4 -2
  23. package/src/adapters/definitions/builtin-metadata.ts +6 -6
  24. package/src/adapters/definitions/qwen.ts +2 -0
  25. package/src/adapters/registry-types.ts +2 -0
  26. package/src/adapters/registry.ts +1 -0
  27. package/src/cli/conductor-recovery.ts +3 -2
  28. package/src/cli/help.ts +9 -8
  29. package/src/cli/identity.ts +5 -5
  30. package/src/cli/main.ts +68 -24
  31. package/src/cli/recovery-package.ts +94 -4
  32. package/src/cli/reset.ts +14 -4
  33. package/src/cli/runtime-version-warning.ts +28 -0
  34. package/src/cli/start-agents-run.ts +125 -0
  35. package/src/cli/study-output.ts +56 -0
  36. package/src/cli/study.ts +26 -3
  37. package/src/cli/task-output.ts +5 -0
  38. package/src/cli/upgrade-interactive.ts +4 -4
  39. package/src/cli/upgrade-runtime.ts +105 -57
  40. package/src/cli/upgrade.ts +90 -28
  41. package/src/hub/action-policy.ts +5 -3
  42. package/src/hub/autonomy-defaults.ts +32 -7
  43. package/src/hub/board.ts +129 -13
  44. package/src/hub/budget.ts +3 -4
  45. package/src/hub/child-process.ts +4 -3
  46. package/src/hub/conductor.ts +3 -4
  47. package/src/hub/config-apply.ts +13 -2
  48. package/src/hub/config-edit-host.ts +1 -0
  49. package/src/hub/config-tier.ts +3 -0
  50. package/src/hub/config-trust.ts +10 -2
  51. package/src/hub/control-client.ts +30 -10
  52. package/src/hub/daemon.ts +103 -26
  53. package/src/hub/db-schema.ts +143 -0
  54. package/src/hub/delivery-journal.ts +3 -4
  55. package/src/hub/events.ts +2 -1
  56. package/src/hub/execution-budget.ts +3 -4
  57. package/src/hub/folder-trigger.ts +3 -4
  58. package/src/hub/hub-tools.ts +7 -5
  59. package/src/hub/invocations.ts +3 -4
  60. package/src/hub/lifecycle.ts +87 -29
  61. package/src/hub/manager.ts +49 -33
  62. package/src/hub/no-writes.ts +1 -1
  63. package/src/hub/orca-env.ts +19 -0
  64. package/src/hub/project-runtime-state.ts +164 -0
  65. package/src/hub/project-runtime.ts +62 -0
  66. package/src/hub/protocol-window.ts +19 -0
  67. package/src/hub/recovery-store.ts +102 -12
  68. package/src/hub/registry.ts +5 -0
  69. package/src/hub/research-experiments.ts +33 -4
  70. package/src/hub/research-runner.ts +172 -0
  71. package/src/hub/restart.ts +9 -0
  72. package/src/hub/runtime-capture-prune.ts +415 -0
  73. package/src/hub/runtime-prune-health.ts +112 -0
  74. package/src/hub/schedules.ts +3 -4
  75. package/src/hub/sdlc-merge.ts +7 -0
  76. package/src/hub/sdlc-queue.ts +13 -1
  77. package/src/hub/sdlc-release-authorization.ts +11 -5
  78. package/src/hub/session-identity.ts +1 -1
  79. package/src/hub/settings.ts +4 -0
  80. package/src/hub/snapshots.ts +3 -4
  81. package/src/hub/start-agents.ts +44 -0
  82. package/src/hub/task-approval.ts +23 -0
  83. package/src/hub/tasks.ts +133 -34
  84. package/src/hub/usage-rollup.ts +3 -4
  85. package/src/pi/tool-receipts.ts +3 -4
  86. package/templates/presets/confirmation-matrix.json +5 -5
  87. package/templates/release-guard.block.md +2 -1
@@ -6,7 +6,7 @@ import type { StartForm } from "./registry-types.ts";
6
6
  /** Only the control socket operations the peer owns; authentication remains in the daemon. */
7
7
  export interface ChannelSocket {
8
8
  readyState: number;
9
- data: { launchId?: string };
9
+ data: { launchId?: string; protocol?: number };
10
10
  send(data: string): unknown;
11
11
  close(code?: number, reason?: string): void;
12
12
  }
@@ -142,7 +142,9 @@ export class WsPeer extends BasePeer {
142
142
  }
143
143
 
144
144
  recoveryMetadata(): Record<string, unknown> {
145
- return { launch: definitionOf(this.id)?.attach?.recoveryLaunch?.(this.id) ?? { kind: "claude-channel", peer: this.id } };
145
+ const protocol = this.attached ? this.sock?.data.protocol : undefined;
146
+ return { launch: definitionOf(this.id)?.attach?.recoveryLaunch?.(this.id) ?? { kind: "claude-channel", peer: this.id },
147
+ ...(typeof protocol === "number" && Number.isSafeInteger(protocol) && protocol > 0 ? { controlProtocol: protocol } : {}) };
146
148
  }
147
149
  }
148
150
 
@@ -76,7 +76,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
76
76
  projection: { roleRank: 0, helpRank: 0, plannerEligible: true },
77
77
  api: 1, kind: "claude", displayName: "Claude Code", family: "claude", transport: "claude-channel",
78
78
  roles: Object.freeze(["planner", "reviewer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
79
- start: { forms: Object.freeze(["tui"] as const), defaultForm: "tui", launch: claudeNativeLaunch, version: claudeObservedVersion, ...CLAUDE_LAUNCH_POLICY, conductorStartable: true,
79
+ start: { forms: Object.freeze(["tui"] as const), defaultForm: "tui", launch: claudeNativeLaunch, version: claudeObservedVersion, ...CLAUDE_LAUNCH_POLICY, conductorStartable: true, upStart: { allowed: true, default: true },
80
80
  unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--dangerously-load-development-channels", "--dangerously-skip-permissions"]) },
81
81
  identity: { mcp: "channel-plugin" }, instances: { mode: "external", roles: ["planner", "implementer", "verifier", "reviewer"] }, attach: { externalHello: true, recoveryLaunch: claudeRecoveryLaunch },
82
82
  // Approval enforcement belongs to the verified native transport and host permission gate.
@@ -93,7 +93,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
93
93
  projection: { roleRank: 1, startRank: 1, helpRank: 1, plannerEligible: true },
94
94
  api: 1, kind: "codex", displayName: "Codex", family: "codex", transport: "codex-appserver",
95
95
  roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
96
- start: { create: createCodex, daemonPort: "codex-appserver", forms: dualForms, defaultForm: "tui", launch: codexNativeLaunch, version: codexObservedVersion, ...CODEX_LAUNCH_POLICY, conductorStartable: true,
96
+ start: { create: createCodex, daemonPort: "codex-appserver", forms: dualForms, defaultForm: "tui", launch: codexNativeLaunch, version: codexObservedVersion, ...CODEX_LAUNCH_POLICY, conductorStartable: true, upStart: { allowed: true, default: true },
97
97
  unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--remote", "--remote-auth-token-env", "--yolo", "--dangerously-bypass-approvals-and-sandbox"]) },
98
98
  identity: { mcp: "env" }, instances: { mode: "headless" },
99
99
  permission: { trackUnattended: true, grants: { ask: "the session's own approval policy applies again", "ask-when-needed": "approval policy on-request on every turn", "never-ask": "approval policy never on every turn: nothing asks" } },
@@ -106,7 +106,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
106
106
  projection: { roleRank: 2, startRank: 2, helpRank: 2, plannerEligible: true },
107
107
  api: 1, kind: "kimi", displayName: "Kimi Code", family: "kimi", transport: "acp",
108
108
  roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
109
- start: { create: createKimi, acpCommand: kimiAcpCommand, explicitHeadlessMode: true, planCommand: () => "ahub kimi", daemonPort: "acp", forms: dualForms, defaultForm: "tui", conductorStartable: true, hubSwitches: [], ownedFlags: [] },
109
+ start: { create: createKimi, acpCommand: kimiAcpCommand, explicitHeadlessMode: true, planCommand: () => "ahub kimi", daemonPort: "acp", forms: dualForms, defaultForm: "tui", conductorStartable: true, upStart: { allowed: true }, hubSwitches: [], ownedFlags: [] },
110
110
  // ACP sessions carry headless identity; the existing CLI alone owns the TUI's project MCP entry.
111
111
  identity: { mcp: "acp-session" }, instances: { mode: "headless" }, attach: { pullOnlyForms: Object.freeze(["tui"] as const), recoveryLaunch: kimiRecoveryLaunch,
112
112
  pullOnlyNotice: "pull-only: messages wait for hub_inbox; no push, steer, watchdog or automatic quota handoff",
@@ -121,7 +121,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
121
121
  projection: { roleRank: 4, startRank: 0, helpRank: 6, plannerEligible: false },
122
122
  api: 1, kind: "pi", enabledSwitchStartOnly: true, displayName: "Pi", family: "pi", transport: "hub-native",
123
123
  roles: implementerRoles, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: true },
124
- start: { create: createPi, exclusiveStartRefusal: "Pi start is in progress; inspect status before retrying", refreshTools: true, explicitHeadlessMode: true, daemonPort: "pi-rpc", forms: dualForms, defaultForm: "tui", conductorStartable: true, autoStart: "pi.auto_start", conductorStartArgs: piConductorStartArgs, hubSwitches: [], ownedFlags: [] },
124
+ start: { create: createPi, exclusiveStartRefusal: "Pi start is in progress; inspect status before retrying", refreshTools: true, explicitHeadlessMode: true, daemonPort: "pi-rpc", forms: dualForms, defaultForm: "tui", conductorStartable: true, upStart: { allowed: true }, autoStart: "pi.auto_start", conductorStartArgs: piConductorStartArgs, hubSwitches: [], ownedFlags: [] },
125
125
  identity: { mcp: "env" }, instances: { mode: "headless" },
126
126
  // Approval enforcement belongs to the verified native transport and host permission gate.
127
127
  permission: { grants: { ...hubGrants } },
@@ -134,7 +134,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
134
134
  projection: { roleRank: 3, helpRank: 7, plannerEligible: false },
135
135
  api: 1, kind: "local", displayName: "Local worker", family: "local", transport: "hub-native",
136
136
  roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: true, hubSandboxed: true },
137
- start: { create: createLocal, reconfigureWhileAttached: true, refreshTools: true, daemonPort: "local-worker", forms: headlessForms, defaultForm: "headless", conductorStartable: true, hubSwitches: [], ownedFlags: [] },
137
+ start: { create: createLocal, reconfigureWhileAttached: true, refreshTools: true, daemonPort: "local-worker", forms: headlessForms, defaultForm: "headless", conductorStartable: true, upStart: { allowed: true }, hubSwitches: [], ownedFlags: [] },
138
138
  identity: { mcp: "env" }, instances: { mode: "headless" },
139
139
  // Approval enforcement belongs to the verified native transport and host permission gate.
140
140
  permission: { grants: { ...hubGrants } },
@@ -147,7 +147,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
147
147
  projection: { roleRank: 6, startRank: 3, helpRank: 3, plannerEligible: true },
148
148
  api: 1, kind: "jcode", displayName: "Jcode", family: "jcode", transport: "acp",
149
149
  roles: implementerRoles, optional: true, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: false },
150
- start: { create: createJcode, preflight: jcodeStartPreflight, daemonPort: "owned-acp", forms: dualForms, defaultForm: "tui", explicitHeadlessMode: true, conductorStartable: true, hubSwitches: [], ownedFlags: [] },
150
+ start: { create: createJcode, preflight: jcodeStartPreflight, daemonPort: "owned-acp", forms: dualForms, defaultForm: "tui", explicitHeadlessMode: true, conductorStartable: true, upStart: { allowed: true }, hubSwitches: [], ownedFlags: [] },
151
151
  // #685: an extra Jcode is a headless instance with its own native home, socket and state (instances.<id>.jcode_home).
152
152
  identity: { mcp: "project-file" }, instances: { mode: "headless", nativeHome: true },
153
153
  permission: { unmanaged: "Jcode native policy is unmanaged; no hub mode mapping", grants: {
@@ -26,6 +26,8 @@ export const QWEN_DEFINITION: AdapterDefinition = {
26
26
  forms: ["headless"],
27
27
  defaultForm: "headless",
28
28
  conductorStartable: true,
29
+ // #608: the TUI is explicit only, so `ahub up` never starts Qwen from the start set; the text names why.
30
+ upStart: { allowed: false, refusal: "the hub never opens its TUI, and a Qwen is started only by ahub qwen" },
29
31
  hubSwitches: [],
30
32
  ownedFlags: [],
31
33
  // The TUI (#286 Part 3) is explicit only: forms stays headless, so Qwen never becomes a start-mode peer and the hub never opens it by itself.
@@ -188,6 +188,8 @@ export interface AdapterDefinition {
188
188
  preflight?(ctx: StartRequest): string | undefined;
189
189
  version?(ctx: VersionContext): string | undefined;
190
190
  conductorStartable: boolean;
191
+ /** #608: whether `ahub up` may start this kind from the `start.agents` set, and whether it is in the default set. `allowed: false` carries the reason a listing is refused. Absent: not startable by `ahub up`, refused with the generic reason. Fixed builtins only. */
192
+ upStart?: { allowed: true; default?: true } | { allowed: false; refusal: string };
191
193
  passthrough?: boolean;
192
194
  autoStart?: "pi.auto_start";
193
195
  exclusiveStartRefusal?: string;
@@ -247,6 +247,7 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
247
247
  ["start.daemonPort pi-rpc", definition.start.daemonPort === "pi-rpc"],
248
248
  ["start.daemonPort qwen-dual", definition.start.daemonPort === "qwen-dual"],
249
249
  ["start.createTui", declared(definition.start, "createTui")],
250
+ ["start.upStart", declared(definition.start, "upStart")], // #608: which kinds `ahub up` may start is a fixed-builtin decision, never a declared one
250
251
  ["routing", definition.routing !== undefined],
251
252
  ["projection", definition.projection !== undefined],
252
253
  ["instances.nativeHome", declared(definition.instances, "nativeHome")], // #685: a fixed-builtin assumption, one native data home per declared instance
@@ -9,14 +9,15 @@ import { readRestartSnapshot } from "../hub/restart.ts";
9
9
  import { processLiveness } from "../pi/process-signature.ts";
10
10
  import { packageDigest, recoveryTargetDigest } from "./recovery-package.ts";
11
11
  import type { Project } from "../hub/registry.ts";
12
- import { planFingerprint, registeredProjects, type RecoveryOperation, type UpgradePlan } from "./upgrade.ts";
12
+ import { planFingerprint, recoveryScope, registeredProjects, type RecoveryOperation, type UpgradePlan } from "./upgrade.ts";
13
13
  import { realPath } from "../hub/project.ts";
14
14
 
15
15
  function unavailable(): never { throw new RecoveryAuthorityError(); }
16
16
  function validateConductorOperation(op: RecoveryOperation): void {
17
17
  try {
18
18
  const { fingerprint, ...body } = op.plan;
19
- if (op.schema !== 1 || !["restart", "upgrade"].includes(op.plan.kind) || planFingerprint(body) !== fingerprint || !op.plan.projects.length
19
+ const scope = recoveryScope(op.plan);
20
+ if (!(op.schema === 1 || op.schema === 3 && scope !== undefined) || !["restart", "upgrade"].includes(op.plan.kind) || planFingerprint(body) !== fingerprint || !op.plan.projects.length
20
21
  || op.plan.projects.length > 128 || new Set(op.plan.projects.map(row => row.project.id)).size !== op.plan.projects.length) unavailable();
21
22
  } catch { unavailable(); }
22
23
  }
package/src/cli/help.ts CHANGED
@@ -10,15 +10,15 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
10
10
  ["ahub --project <path|id> <command>", "select a repository or worktree explicitly"],
11
11
  ["ahub projects [--json] [--full] [--color=auto|always|never]", "list registered projects and live status"],
12
12
  ["ahub projects remove <id>", "forget a stopped registration (keeps project files)"],
13
- ["ahub setup [--yes]", "install or update the channel plugin and project-neutral hook in Claude configuration homes in use, preserving unrelated profile settings, register the acon console plugin (console/plugins/ahub.json in the hub home, read at acon's next start), then run doctor"],
14
- ["ahub init [--profile dev|research|writing|general|<pack>] [--shadow] [--release-guard] [--dry-run --json]", "preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block); a pack is a shipped one (writing-pack, analysis, ops) or a project file .agenthub/roles/<name>.pack.json; a new project inherits a trusted machine autonomy default into config.local.json"],
13
+ ["ahub setup [--yes] [--agents <a,b>]", "install or update the channel plugin and project-neutral hook in Claude configuration homes in use, preserving unrelated profile settings, ask once which agents ahub up starts (--agents answers it, --yes keeps the current choice or claude,codex, no terminal keeps the default), register the acon console plugin (console/plugins/ahub.json in the hub home, read at acon's next start), then run doctor"],
14
+ ["ahub init [--profile dev|research|writing|general|<pack>] [--shadow] [--release-guard] [--dry-run --json]", "preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block); a pack is a shipped one (writing-pack, analysis, ops) or a project file .agenthub/roles/<name>.pack.json; a new project inherits a trusted machine autonomy default and the agent set chosen at ahub setup (start.agents) into config.local.json"],
15
15
  ["ahub --live-test [--project <path|id>] <command> [args...]", "user-authorized operational console delegation; ordinary calls retain peer identity; person-authored content refuses"],
16
16
  ["ahub help [command]", "this help, or one command's entries; also --help and -h"],
17
17
  ["ahub version | --version", "print the installed version"],
18
18
  ["ahub completion zsh|bash|fish", "print a shell completion script; its candidates come from these help entries"],
19
19
  ]],
20
20
  ["Daemon and runtime", [
21
- ["ahub up [--unattended] [--no-console]", "start the daemon; interactive terminals enter console"],
21
+ ["ahub up [--unattended] [--no-console] [--no-agents]", "start the daemon, then ask it to start this project's default agents (start.agents, claude and codex unless chosen at setup; one line each, a refusal names the manual command; --no-agents skips them), then enter console in an interactive terminal"],
22
22
  ["ahub console [--panels] [--color=auto|always|never]", "enter the human console; P cycles built-in pet characters/off; exiting leaves the daemon running"],
23
23
  ["ahub console --no-pet", "turn off attention for this session; combines with --panels and --color"],
24
24
  ["ahub pet --window [--close]", "start or close the macOS floating window, from a person's terminal"],
@@ -27,7 +27,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
27
27
  ["ahub sdlc status [task] | explain <task> [--json] [--color=auto|always|never] | export <task> | record <task> <receipt-json>", "read head-bound lifecycle gates and the next actor/action; status and explain are open to any agent as a public view (private tasks stay stubs), export and record are person only; export stays JSON"],
28
28
  ["ahub sdlc queue <owner/repo> | release <owner/repo> [live-issue...] [--json] [--color=auto|always|never]", "read-only merge and release evidence; open to any agent as public readback of the checkout's repositories, never an authorization"],
29
29
  ["ahub sdlc merge <task> [--dry-run] [--json] [--color=auto|always|never]", "guarded squash merge of an approved task at its approved head after every gate is re-read; follows sdlc.toml auto_merge (off, dry-run, on); --dry-run only reports (person or conductor)"],
30
- ["ahub sdlc authorize-release <version> [--json] [--color=auto|always|never]", "approve this daemon checkout commit, version and fresh docs (person only; restart revokes)"],
30
+ ["ahub sdlc authorize-release <version> [--json] [--color=auto|always|never]", "approve this daemon checkout commit, version and fresh docs (person or unique configured conductor on the person's explicit release instruction naming the release in its own session; hub/task/review/file/peer relays never qualify; restart revokes)"],
31
31
  ["ahub sdlc release-guard -- <literal git push|gh release create|npm publish argv>", "read-only authorization preflight; init --release-guard installs the tag pre-push hook"],
32
32
  ["ahub sdlc release-guard-hook <remote-name> <remote-url>", "internal pre-push readback; validates version tags and forwards exact stdin on success"],
33
33
  ["ahub sdlc accept <task> --issue <n> [--repo owner/repo]", "accept the task's spec issue at its current body; build entry needs it in enforce (person only)"],
@@ -41,7 +41,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
41
41
  ["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
42
42
  ["ahub status --all [--json] [--color=auto|always|never]", "show every registered project"],
43
43
  ["ahub logs [-f]", "person: last 100 hub.log lines (-f starts at 10); conductor: bounded public lines and follow while authority remains"],
44
- ["ahub doctor [--json] [--full] [--color=auto|always|never]", "check the tools, daemon, plugin, gateway, models, memory library and memory capture setup (read from `amem setup <tool> --json`, a preview) this project uses"],
44
+ ["ahub doctor [--json] [--full] [--color=auto|always|never]", "check the tools, daemon, plugin, gateway, models, memory library and memory capture setup (read from `amem setup <tool> --json`, a preview) this project uses, and the runtime capture pruning ledger; warn when the CLI or plugin is older than its running daemon"],
45
45
  ["ahub doctor --stale-anchors [--json]", "read-only bounded report of public memory symbol anchors at current HEAD; private or unavailable evidence stays unknown"],
46
46
  ["ahub doctor --orphans [--kill] [--json] [--color=auto|always|never]", "person: inspect missing-root registrations; conductor --kill: selected project only; signed hub ownership is required before any stop"],
47
47
  ["ahub agents [--json]", "show each gated agent CLI's installed version and verdict (verified, in-line, trusted, refused or unknown)"],
@@ -50,8 +50,8 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
50
50
  ["ahub down", "stop this project's hub, the counterpart of ahub up (same as ahub kill)"],
51
51
  ["ahub reset [--all] [--yes]", "list, then with --yes stop the hub and discard every queued and needs_review delivery, clear holds and pauses and drop session pointers; --all moves the state directory to .agenthub/archive instead"],
52
52
  ["ahub restart [--dry-run] [--yes] [--json] [--verbose] [--color=auto|always|never]", "recover this project's runtime; a configured conductor's --yes requires a same-project, source-issued continuation proof, otherwise run it as the person"],
53
- ["ahub upgrade [--to <version>] [--dry-run] [--yes] [--json] [--verbose] [--color=auto|always|never]", "review and upgrade running projects; --to defaults to latest for interactive review or dry-run, while --yes requires --to <exact-version>; conductor apply needs the same project's supported proof, and a newer-coordinator handoff is person-only"],
54
- ["ahub recovery [status|resume|abort] [<operation-id>] [--json] [--color=auto|always|never]", "inspect a preflight; without an id, resume/abort select the operation holding the lock; a bare terminal opens the person's screen; only its initiating conductor may continue its own supported restart/upgrade with fresh proof"],
53
+ ["ahub upgrade [--project <path|id>] [--to <version>] [--dry-run] [--yes] [--json] [--verbose] [--color=auto|always|never]", "review and upgrade this selected project (default: current directory; --project selects another); global CLI/plugin updates are separate; --to defaults to latest for interactive review or dry-run, while --yes requires --to <exact-version>; conductor apply needs the same project's supported proof, and a newer-coordinator handoff is person-only"],
54
+ ["ahub recovery [status|resume|abort] [<operation-id>] [--json] [--color=auto|always|never]", "inspect a preflight; without an id, resume/abort select this project's hold or a retained legacy machine hold; a bare terminal opens the person's screen; only its initiating conductor may continue its own supported restart/upgrade with fresh proof"],
55
55
  ["ahub recovery reattach <operation-id> codex|claude|pi", "person only: repair that blocked peer with its recorded session; leaves the rest for recovery resume"],
56
56
  ["ahub recovery dispose [<operation-id>] --fresh-session codex|claude --reason <text>", "end a partial operation by starting that peer as a new session, recording the lost conversation"],
57
57
  ["ahub recovery dispose [<operation-id>] --stop-and-archive --reason <text>", "end a partial operation: stop its own targets, archive its snapshot and release the lock; the upgrade is abandoned, not completed"],
@@ -77,7 +77,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
77
77
  ["ahub autonomy on [--all-projects] [--yes] | off [--all-projects] | status [--json]", "person only: apply the autonomy preset and machine default with one confirmation, optionally to every registered project; off removes fixed preset keys (manual edits included); status reports modes, floor enforcement and Claude homes; settings undo restores a live-hub project write"],
78
78
  ["ahub settings [list] [--json] | get <key> [--json]", "the settings the dashboard can change, each with its value, the file it comes from and when it applies"],
79
79
  ["ahub settings set <key> <value|inherit> [--yes] [--preview] [--now] | undo | reload", "validated settings: conductor set is limited to exact live operational keys; undo/reload and other keys are person only; --preview saves nothing; conductor writes keep the person undo record"],
80
- ["ahub settings edit <config.local.json|sdlc.toml> (set <path> <json> | remove <path>)... [--reason <text>] [--preview] [--yes] | backups [--json]", "human only: structural config (instances, review, snapshots, approvals, context.gate, sdlc.toml mode and required_checks) validated by the hub's own loaders, previewed as a diff and written with a backup; a key that runs code or locates secrets needs its name typed, never --yes"],
80
+ ["ahub settings edit <config.local.json|sdlc.toml> (set <path> <json> | remove <path>)... [--reason <text>] [--preview] [--yes] | backups [--json]", "human only: structural config (instances, review, snapshots, approvals, context.gate, research.runners, sdlc.toml mode and required_checks) validated by the hub's own loaders, previewed as a diff and written with a backup; a key that runs code or locates secrets needs its name typed, never --yes"],
81
81
  ["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
82
82
  ["ahub queue list [--peer <id>] [--json] [--full] [--color=auto|always|never]", "inspect durable deliveries; open to any agent as public queue metadata, held bodies stay with the person or conductor"],
83
83
  ["ahub queue show <delivery-id> [--json] [--color=auto|always|never]", "person or configured conductor: inspect one delivery and revision; agent output uses the public view"],
@@ -108,6 +108,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
108
108
  ["ahub learn list [--all] | accept <id> | reject <id> [--json] [--color=auto|always|never]", "person only, never a conductor or an agent shell: review a recurring-unmet-criterion candidate the hub filed (#514a); accept writes its one review checklist line, added to your current checklist, to .agenthub/config.local.json through the settings writer (this machine only, never a tracked file, ahub settings undo puts it back, applies after a restart) and closes the card; reject only closes it"],
109
109
  ["ahub study draft <parent> <contract.json> | revise <id> <revision> <contract.json> | approve <id> <revision> | close <id> <revision> --reason <text> | approve-protocol <hash> [--json] [--color=auto|always|never]", "person only: store a research campaign contract as a draft, revise it, approve it (the only step that creates the unit tasks and lets work start) or close it; approve-protocol binds approval to the exact protocol and evaluator hashes; a planner drafts through hub_study_draft and hub_study_revise and can never approve"],
110
110
  ["ahub study show [<id> | --after <id>] [--json] [--color=auto|always|never]", "read-only for anyone: list this project's research campaigns, 50 per page (the page names the --after cursor that continues it), or one campaign's coverage per unit, handoff state, budget support and why dispatch is held; a private campaign, task or source shows ids, counts and states only"],
111
+ ["ahub study experiments (--campaign <id> [--after <n>] | --attempt <ra-id> [--events-after <n>] | --plan <hash> --baseline <ra-id> --variant <ra-id>) [--json] [--color=auto|always|never]", "read-only for anyone, the same read as hub_experiment_show: a campaign's recorded attempts (50 a page, --after continues), one attempt with its events, or one comparison under a recorded plan; each value names its provenance and a declared value is never shown as observed"],
111
112
  ["ahub deliverables [--task <id>] [--approved] [--check] [--json] [--color=auto|always|never]", "what each task delivered and a reviewer approved, by id, hash and size (a path only where the PII screen allows); --check measures the files now and marks one changed since review; an agent sees only the tasks hub_task_show shows it"],
112
113
  ["ahub task show <id> [--json] [--full] [--color=auto|always|never] | escalate <id>", "full task with history (the person sees PII text, an agent the public view) / hand it to the next peer in escalate_to (person or configured conductor)"],
113
114
  ["ahub task role <id> <role|none>", "the person, or an agent on a task it owns: change bounded catalog guidance on an open task; routing and authority are unchanged"],
@@ -23,7 +23,7 @@ export function liveTestRefusal(command: string, args: readonly string[], contex
23
23
  || (command === "backlog" && sub !== "list") || (command === "release" && !["list", "show"].includes(sub ?? ""))
24
24
  || (command === "memory" && sub !== "graph")
25
25
  // #601: a campaign's draft, revise, approve and close are the person's verdicts; only `show` is a read. Without this an agent shell could run them as the person through `--live-test`.
26
- || (command === "study" && sub !== "show")
26
+ || (command === "study" && sub !== "show" && sub !== "experiments")
27
27
  || (command === "recovery" && (sub === "reattach" || sub === "dispose" && context.repairedFresh === true))
28
28
  || (command === "board" && args[0] === "import") || (command === "sdlc" && (args[0] === "record" || args[0] === "accept" || args[0] === "accept-departure"))
29
29
  || (command === "bench" && args.find(arg => arg !== "--json") === "run") || command === "pet"
@@ -85,7 +85,7 @@ const peerReadForms: Readonly<Record<string, Readonly<Record<string, PeerReadFor
85
85
  sdlc: { status: { positional: 1, displayFlags: sdlcReadDisplayFlags }, explain: { positional: 1, displayFlags: sdlcReadDisplayFlags }, queue: { positional: 1, minimum: 1, displayFlags: sdlcReadDisplayFlags }, release: { positional: 33, minimum: 1, displayFlags: sdlcReadDisplayFlags } },
86
86
  release: { show: { positional: 1, minimum: 1, displayFlags: [] } },
87
87
  // #601: reading a campaign is harmless for any agent (the hub stubs a private one); draft, revise, approve and close fall to `console`.
88
- study: { show: { positional: 1, valueFlags: ["--after"], displayFlags: sdlcReadDisplayFlags } },
88
+ study: { show: { positional: 1, valueFlags: ["--after"], displayFlags: sdlcReadDisplayFlags }, experiments: { valueFlags: ["--campaign", "--attempt", "--after", "--events-after", "--plan", "--baseline", "--variant"], displayFlags: sdlcReadDisplayFlags } },
89
89
  projects: { "": {} },
90
90
  models: { status: {} },
91
91
  recovery: { status: { positional: 1 } },
@@ -138,8 +138,8 @@ export function classifyPeerCommand(command: string, args: readonly string[], ca
138
138
  if (conductorOperation(command,args)) return "conductor";
139
139
  if (command === "stop") return "console";
140
140
  if (command === "sdlc" && ["release-guard", "release-guard-hook"].includes(args[0] ?? "")) return "allowed";
141
- // #786: the guarded merge is the person's or the configured conductor's; the daemon refuses any other peer by role.
142
- if (command === "sdlc" && args[0] === "merge") return "conductor";
141
+ // #786, #1063: guarded merge and recording explicitly instructed release consent admit the person or unique configured conductor; the daemon checks the role.
142
+ if (command === "sdlc" && ["merge", "authorize-release"].includes(args[0] ?? "")) return "conductor";
143
143
  if (command === "role") return ["list", "show"].includes(args[0] ?? "") ? "allowed" : "console";
144
144
  if (command === "task") {
145
145
  // #933: cancel, label and role act on the caller's own task only; the daemon refuses another peer's task by role (Tasks.cancel, task_catalog_role, task_label_check).
@@ -196,7 +196,7 @@ export function cliCommandLabel(command: string, args: readonly string[], catalo
196
196
  if (command === "learn") return ["list", "accept", "reject"].includes(args.find(arg => arg !== "--json") ?? "") ? `learn ${args.find(arg => arg !== "--json")}` : "learn";
197
197
  if (command === "source") return ["add", "list"].includes(args.find(arg => arg !== "--json") ?? "") ? `source ${args.find(arg => arg !== "--json")}` : "source";
198
198
  // #601: a campaign id and a contract path are text the person chose, so the audit names the subcommand only.
199
- if (command === "study") return ["draft", "revise", "approve", "close", "show"].includes(args[0] ?? "") ? `study ${args[0]}` : "study";
199
+ if (command === "study") return ["draft", "revise", "approve", "close", "show", "experiments"].includes(args[0] ?? "") ? `study ${args[0]}` : "study";
200
200
  if (command === "release") return ["new", "list", "show", "plan", "activate", "freeze", "ship", "drop"].includes(args[0] ?? "") ? `release ${args[0]}` : "release";
201
201
  if (!(catalog?.definitionOf(command) ?? definitionOf(command)) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "down", "reset", "doctor", "agents", "source", "broker", "schedule", "intake", "learn", "study"].includes(command)) return "unknown";
202
202
  if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench", "agents", "roster", "settings"].includes(command)) {
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";
@@ -29,7 +30,7 @@ import { renderIntake, renderIntakeChange } from "./intake-output.ts";
29
30
  import { renderLearnChange, renderLearnList } from "./learn-output.ts";
30
31
  import { renderCapabilities } from "./capabilities-output.ts";
31
32
  import { parseStudyArgs } from "./study.ts";
32
- import { renderStudyList, renderStudyReceipt, renderStudyShow } from "./study-output.ts";
33
+ import { renderExperiments, renderStudyList, renderStudyReceipt, renderStudyShow } from "./study-output.ts";
33
34
  import { renderHandoffReceipt, renderHandoffs } from "./handoff-output.ts";
34
35
  import { parseHandoffArgs } from "./handoff-args.ts";
35
36
  import { terminalText } from "./console-state.ts";
@@ -84,11 +85,13 @@ import { childEnv, personEnv } from "../hub/child-process.ts";
84
85
  import { abortRecovery, createOperation, disposeRecovery, recordRecoveryDelegation, repairedFreshSession, reattachRecovery, liveProjects, nextChoices, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation, type Inspection } from "./upgrade.ts";
85
86
  import { endPlannedPeer, makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
86
87
  import { exactVersion, latestRelease, newerVersion } from "./recovery-package.ts";
88
+ import { runtimeVersionWarnings } from "./runtime-version-warning.ts";
87
89
  import { operationScreen, planScreen, type ScreenIO, type UpgradeHost } from "./upgrade-interactive.ts";
88
90
  import { readRecoveryPresentationFlags } from "./upgrade-presentation-flags.ts";
89
91
  import { renderOperation, renderBlocked, renderSummary, type RecoveryPresentationOptions } from "./upgrade-presentation.ts";
90
92
  import { renderBudgetResult, renderRecoveryStatus, renderUpgradePlan } from "./upgrade-output.ts";
91
93
  import { recordTerminalLaunch, prepareTerminalLaunch, registerTerminalLaunch, type TerminalPeer } from "./terminal-recovery.ts";
94
+ import { effectiveStartAgents, inheritMachineStart, setupStartAgents, startProjectAgents } from "./start-agents-run.ts";
92
95
  import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
93
96
  import { setupOllamaModel } from "./models-setup.ts";
94
97
 
@@ -142,6 +145,16 @@ while (argv[0] === "--project" || argv[0] === "--live-test") {
142
145
  if (!selector || selector.startsWith("--")) fail("--project needs a path or project ID");
143
146
  }
144
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
+ }
145
158
  const [given = "help", ...rest] = argv;
146
159
  let selected: { root: string; stateDir: string };
147
160
  try {
@@ -385,10 +398,10 @@ function matchingProject(): Project | undefined {
385
398
  finally { registry.close(); }
386
399
  }
387
400
  const hubManifest = () => !!readControl(stateDir) || existsSync(join(stateDir, "hub.pid"));
388
- /** 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
389
402
  * AGENTHUB_RECOVERY_OPERATION says (assertLifecycleAvailable lets that operation's own processes through). */
390
403
  function resetLockFree(): void {
391
- const owner = recoveryLock();
404
+ const owner = recoveryLock(undefined, cwd);
392
405
  if (owner) throw new Error(`${activeOperation(owner)}; nothing was changed`);
393
406
  }
394
407
  /** A manifest whose daemon is alive or uncertain, by inspectProject's rule; a crashed daemon's leftovers do not count. */
@@ -680,11 +693,11 @@ async function onTerminal(screens: (io: ScreenIO) => Promise<void>, presentation
680
693
  }
681
694
 
682
695
  function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost {
683
- const scope = kind === "restart" ? cwd : undefined;
696
+ const scope = cwd;
684
697
  return {
685
698
  plan: () => makeUpgradePlan(kind, version, scope),
686
699
  apply: async (plan, interrupted) => {
687
- assertLifecycleAvailable();
700
+ assertLifecycleAvailable(cwd);
688
701
  const current = await makeUpgradePlan(kind, plan.version, scope);
689
702
  if (current.fingerprint !== plan.fingerprint) throw new Error("the plan changed during review; review it again");
690
703
  // The check above takes seconds: a Ctrl+C in that time is still a no.
@@ -693,7 +706,7 @@ function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost
693
706
  spawnRecovery(operation, true);
694
707
  return operation;
695
708
  },
696
- lock: recoveryLock,
709
+ lock: (projectRoot = cwd) => recoveryLock(undefined, projectRoot),
697
710
  read: (id) => readOperation<RecoveryOperation>(id),
698
711
  runner: recoveryRunner,
699
712
  live: (op) => liveProjects(op, makeRecoveryDriver(undefined, delegatedPeer, undefined, conductorPeer).inspect),
@@ -709,8 +722,7 @@ function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost
709
722
  async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
710
723
  const display = recoveryDisplay();
711
724
  const { one, rest } = takeFlags(display.args, ["--to"], []);
712
- 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]");
713
- 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]");
714
726
  const yes = rest.includes("--yes"), dry = rest.includes("--dry-run") || (display.json && !yes);
715
727
  // #272: on a terminal, with neither flag, the plan is reviewed, applied and followed in one session.
716
728
  const interactive = !dry && !yes && humanRecovery(display.json);
@@ -725,7 +737,7 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
725
737
  // An older CLI must not coordinate a newer target (docs/operations.md): the target's own coordinator takes over.
726
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");
727
739
  if (kind === "upgrade" && newerVersion(to!, VERSION) && process.env.AGENTHUB_UPGRADE_HANDOVER !== "1") {
728
- 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"] : [])];
729
741
  console.error(`ahub: ${to} is newer than this CLI (${VERSION}), so its own coordinator runs the upgrade: bun ${argv.join(" ")}`);
730
742
  if (interactive) {
731
743
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -743,7 +755,7 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
743
755
  }
744
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");
745
757
  if (interactive) return onTerminal((io) => planScreen(upgradeHost(kind, to ?? VERSION), io), display.options);
746
- const plan = await makeUpgradePlan(kind, to ?? VERSION, kind === "restart" ? cwd : undefined, undefined, conductorPeer);
758
+ const plan = await makeUpgradePlan(kind, to ?? VERSION, cwd, undefined, conductorPeer);
747
759
  if (conductorPeer) await preflightConductorRecovery(plan, conductorPeer);
748
760
  const jsonApply = display.json && yes && !dry;
749
761
  // #814: a person reading a pipe or a dry run gets the plan screen's rows, with every blocker, reconnect-only session and
@@ -757,7 +769,7 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
757
769
  if (display.json) for (const blocker of blockers) console.error(`ahub: blocker: ${blocker}`);
758
770
  if (dry) return;
759
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"); }
760
- assertLifecycleAvailable();
772
+ assertLifecycleAvailable(cwd);
761
773
  if (!yes) {
762
774
  if (!process.stdin.isTTY) fail("review --dry-run and use --yes in non-interactive sessions");
763
775
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -765,14 +777,14 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
765
777
  if (!/^(y|yes)$/i.test((await rl.question("Apply this project list and restore its sessions? [y/N] ")).trim())) return;
766
778
  } finally { rl.close(); }
767
779
  }
768
- const current = await makeUpgradePlan(kind, plan.version, kind === "restart" ? cwd : undefined, undefined, conductorPeer);
780
+ const current = await makeUpgradePlan(kind, plan.version, cwd, undefined, conductorPeer);
769
781
  if (current.fingerprint !== plan.fingerprint) fail("plan changed during review; run the command again");
770
782
  const routingDefaults = conductorPeer ? await preflightConductorRecovery(plan, conductorPeer) : undefined;
771
783
  const sourceRoot = preserveSource(plan);
772
784
  let operation: RecoveryOperation;
773
785
  if (conductorPeer) {
774
786
  const id = randomUUID();
775
- 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: {} })) };
776
788
  const target = await makeRecoveryDriver().stage(pending);
777
789
  pending.targetRoot = target.root; pending.targetDigest = target.digest; pending.targetFeatures = target.targetFeatures;
778
790
  await issueConductorOperation(pending, conductorPeer);
@@ -868,9 +880,9 @@ const commands: Record<string, () => Promise<void> | void> = {
868
880
  printRecovery(renderBlocked(result, nextChoices(result, undefined, live), display.options));
869
881
  return;
870
882
  }
871
- // #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.
872
884
  const named = !!given && operationIdPattern.test(given);
873
- const id = named ? given : recoveryLock();
885
+ const id = named ? given : recoveryLock(undefined, cwd);
874
886
  const rest = named || given === undefined ? more : [given, ...more];
875
887
  if (action === undefined && humanRecovery(display.json)) {
876
888
  if (!id) return console.log("no recovery operation is open");
@@ -1009,26 +1021,38 @@ const commands: Record<string, () => Promise<void> | void> = {
1009
1021
  },
1010
1022
 
1011
1023
  manager: async () => {
1012
- 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
+ } });
1013
1029
  for (const signal of ["SIGINT", "SIGTERM"] as const) process.on(signal, () => void manager.stop());
1014
1030
  await manager.stopped;
1015
1031
  },
1016
1032
 
1017
1033
  setup: async () => {
1018
1034
  recoveryLauncherCurrent?.();
1019
- assertLifecycleAvailable();
1035
+ assertLifecycleAvailable(cwd);
1020
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>]");
1021
1039
  const registry = new Registry(), rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : undefined;
1022
1040
  try {
1023
1041
  const found = recoveryLauncherCurrent || delegatedPeer ? await discoverClaudeHomes({ home: homedir(), env: process.env }) : await usedClaudeHomes(registry, homedir(), process.env);
1024
1042
  for (const notice of found.notices) console.log(terminalText(notice));
1025
1043
  await setupClaudeHomes({ homes: found.homes, env: process.env, packageRoot: root, yes: args.includes("--yes"),
1026
1044
  interactive: !!rl,
1027
- current: () => { recoveryLauncherCurrent?.(); assertLifecycleAvailable(); },
1045
+ current: () => { recoveryLauncherCurrent?.(); assertLifecycleAvailable(cwd); },
1028
1046
  print: line => console.log(terminalText(line)), ask: async prompt => rl ? rl.question(prompt) : null,
1029
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 ?? "" }; },
1030
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; },
1031
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
+ }
1032
1056
  } finally { registry.close(); rl?.close(); }
1033
1057
  // #935: register the acon console plugin (acon reads it at its next start; no acon install is needed for this).
1034
1058
  const acon = registerAconPlugin(hubHome(), join(root, "src", "ui", "plugin.ts"));
@@ -1050,7 +1074,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1050
1074
  if (defaults.notice) console.error(defaults.notice);
1051
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");
1052
1076
  }
1053
- assertLifecycleAvailable();
1077
+ assertLifecycleAvailable(cwd);
1054
1078
  if (newProject && delegatedPeer && readAutonomyDefaults(hubHome(), [cwd]).enabled) fail("machine autonomy inheritance is person-only; delegated live tests cannot apply it");
1055
1079
  const changed = init(cwd, requestedProfile, args.includes("--shadow"), args.includes("--release-guard"));
1056
1080
  const project = registeredProject();
@@ -1059,6 +1083,8 @@ const commands: Record<string, () => Promise<void> | void> = {
1059
1083
  const registry = new Registry();
1060
1084
  try { for (const line of await inheritMachineAutonomy(project, hubHome(), autonomyEnvironment(process.env, !!process.stdin.isTTY), registry)) console.log(terminalText(line)); }
1061
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)); }
1062
1088
  }
1063
1089
  const config = projectConfig(), examples = resolveProfile(config.profile, config.profile_overrides).example_checks;
1064
1090
  if (examples.length) console.log(terminalText("Example checks (advisory):\n" + examples.map(example => "- " + example).join("\n")));
@@ -1086,12 +1112,20 @@ const commands: Record<string, () => Promise<void> | void> = {
1086
1112
  if (recoveryLauncherCurrent) { recoveryLauncherCurrent(); return; }
1087
1113
  try { if (loadPetConfig(cwd).window) console.log(await startPetWindow(stateDir, cwd)); }
1088
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
+ }
1089
1123
  if (process.stdin.isTTY && process.stdout.isTTY && !args.includes("--no-console")) await runConsole({ client: await connect(), cwd, stateDir, liveTest });
1090
1124
  },
1091
1125
 
1092
1126
  ...Object.fromEntries(BUILTIN_CLI_ENTRIES.map(entry => [entry.command, async () => {
1093
1127
  // Preserve the original guard before native effects; previews remain read-only and precede this guard.
1094
- 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);
1095
1129
  const nativeConnect = async () => {
1096
1130
  const hub = await connectLaunch();
1097
1131
  try { if (selectedInstance) { requirePeerInstances(hub.features); requireActiveInstance((await hub.request({ t: "status" })).status, selectedInstance); } return hub; }
@@ -1491,6 +1525,11 @@ const commands: Record<string, () => Promise<void> | void> = {
1491
1525
  catch { return fail("cannot read the contract file"); }
1492
1526
  try { return JSON.parse(text); } catch { return fail("the contract file is not valid JSON"); }
1493
1527
  };
1528
+ if (parsed.op === "experiments") {
1529
+ // A read of the records Tasks keeps (hub_experiment_show): it runs no command and reads no file, and the hub blanks a private record itself.
1530
+ const args = parsed.attempt ? { attemptId: parsed.attempt, ...(parsed.eventsAfter === undefined ? {} : { eventAfter: parsed.eventsAfter }) } : parsed.plan ? { planHash: parsed.plan, baselineAttemptId: parsed.baseline, variantAttemptId: parsed.variant } : { campaignId: parsed.campaign, ...(parsed.after === undefined ? {} : { after: parsed.after }) };
1531
+ return showToolText(data => renderExperiments(data, present.columns), await taskOp("hub_experiment_show", args), present);
1532
+ }
1494
1533
  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);
1495
1534
  const request = parsed.op === "draft" ? { parentTaskId: parsed.parentTaskId, contract: contract(parsed.file) }
1496
1535
  : parsed.op === "revise" ? { id: parsed.id, revision: parsed.revision, contract: contract(parsed.file) }
@@ -1959,7 +1998,8 @@ const commands: Record<string, () => Promise<void> | void> = {
1959
1998
  try {
1960
1999
  const environment = autonomyEnvironment(process.env, !!process.stdin.isTTY), home = hubHome();
1961
2000
  if (args.includes("--all-projects")) {
1962
- assertLifecycleAvailable();
2001
+ assertLifecycleAvailable(cwd);
2002
+ for (const project of registry.list()) assertLifecycleAvailable(project.root);
1963
2003
  await runAllProjectAutonomy(args, { home, environment, projects: registry.list(),
1964
2004
  prepare: (project, enabled) => prepareAutonomyProject(project, environment, enabled, registry),
1965
2005
  ask: async prompt => rl ? rl.question(prompt) : null, print: line => console.log(terminalText(line)),
@@ -1994,7 +2034,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1994
2034
  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."],
1995
2035
  confirmPrompt: "Apply this project's preset and this machine's autonomy default? [y/N] ",
1996
2036
  beforeApply: action => {
1997
- assertLifecycleAvailable();
2037
+ assertLifecycleAvailable(cwd);
1998
2038
  if (action === "off" && defaults.fingerprint === "untrusted") console.log("Unsafe machine defaults are already ignored; their file was left unchanged.");
1999
2039
  else writeAutonomyDefaults(home, action === "on", defaults.fingerprint, roots);
2000
2040
  console.log(`Machine autonomy default: ${action}.`);
@@ -2550,7 +2590,7 @@ const commands: Record<string, () => Promise<void> | void> = {
2550
2590
  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");
2551
2591
  }
2552
2592
  if (liveManifest()) throw new Error("a hub started after the stop; nothing was reset, run ahub reset again");
2553
- // An upgrade or recovery that took the machine's lock after the first check must not run beside the reset.
2593
+ // A recovery that took this project's hold after the first check must not run beside its reset.
2554
2594
  resetLockFree();
2555
2595
  if (all) {
2556
2596
  let archived: string;
@@ -2635,6 +2675,7 @@ const commands: Record<string, () => Promise<void> | void> = {
2635
2675
  const row = (ok: boolean | undefined, name: string, detail: string) => { checks.push(nativeDoctorCheck(ok, name, detail)); };
2636
2676
 
2637
2677
  try {
2678
+ for (const check of runtimePruneHealth()) row(check.ok, check.name, check.detail);
2638
2679
  const catalog = loadRoleCatalog(cwd);
2639
2680
  for (const error of catalog.errors) row(false, "catalog role", `${error.id}: ${error.reason}`);
2640
2681
  try {
@@ -2646,11 +2687,13 @@ const commands: Record<string, () => Promise<void> | void> = {
2646
2687
  row(!!v, bin, v ?? "not found on PATH");
2647
2688
  }
2648
2689
  const up = await healthy();
2690
+ let daemonVersion: unknown;
2649
2691
  row(up, "ahub daemon", up ? readControl(stateDir)!.url : "not running (ahub up)");
2650
2692
  if (up) {
2651
2693
  const hub = await connect();
2652
2694
  try {
2653
2695
  const { status } = await hub.request({ t: "status" });
2696
+ daemonVersion = status.version; // Authenticated reply, never the unauthenticated disk manifest.
2654
2697
  const pending = Object.values(status.peers as Record<string, PeerRow>).reduce((n, peer) => n + (peer.needsReview ?? 0), 0);
2655
2698
  row(!status.deliveryError && pending === 0, "delivery recovery", status.deliveryError ?? (pending ? `${pending} deliveries need review; run ahub queue list` : "journal healthy; no deliveries need review"));
2656
2699
  // #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.
@@ -2666,6 +2709,7 @@ const commands: Record<string, () => Promise<void> | void> = {
2666
2709
  }
2667
2710
  const plugin = pluginState(parseList<InstalledPlugin>(spawnSync(DOCTOR_PLUGIN_PROBE.command, [...DOCTOR_PLUGIN_PROBE.argv], { encoding: "utf8" }).stdout ?? ""), join(import.meta.dir, "..", ".."));
2668
2711
  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`);
2712
+ if (up) checks.push(...runtimeVersionWarnings(daemonVersion, VERSION, plugin.state === "missing" ? undefined : plugin.version));
2669
2713
 
2670
2714
  const config = loadConfig(cwd);
2671
2715
  row(!config.ignored, "config", config.ignored ? `${config.ignored.join("; ")} (move them to .agenthub/config.local.json)` : "no machine-local field ignored");
@@ -2862,7 +2906,7 @@ async function runConductorCommand(): Promise<void> {
2862
2906
  }
2863
2907
  const runConfigured = configuredCommand ? async () => {
2864
2908
  const selection = parseConfiguredAcpArguments(cmd, args, projectCatalog, declaredInstances, peerConfig.declaredExternalInstances);
2865
- if (!selection.preview) assertLifecycleAvailable();
2909
+ if (!selection.preview) assertLifecycleAvailable(cwd);
2866
2910
  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 });
2867
2911
  } : undefined;
2868
2912
  const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? runConfigured ?? (() => fail(`unknown command "${cmd}"; run ahub help`));