@staix/agent-hub 0.25.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 (72) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +10 -5
  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 +6 -4
  6. package/docs/agent-notes/tasks.md +5 -4
  7. package/docs/agent-notes/tests.md +1 -0
  8. package/docs/dogfood.md +4 -0
  9. package/docs/operations.md +131 -14
  10. package/docs/quickstart.md +14 -4
  11. package/docs/security.md +51 -8
  12. package/docs/specs/2026-11-01-research-campaigns-design.md +9 -6
  13. package/docs/verification/2026-11-01-0.26.0.md +54 -0
  14. package/docs/verified.json +28 -16
  15. package/package.json +1 -1
  16. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  17. package/plugins/agent-hub/server.js +182 -95
  18. package/src/adapters/channel-peer.ts +4 -2
  19. package/src/adapters/definitions/builtin-metadata.ts +6 -6
  20. package/src/adapters/definitions/qwen.ts +2 -0
  21. package/src/adapters/registry-types.ts +2 -0
  22. package/src/adapters/registry.ts +1 -0
  23. package/src/cli/conductor-recovery.ts +3 -2
  24. package/src/cli/help.ts +7 -7
  25. package/src/cli/main.ts +62 -23
  26. package/src/cli/recovery-package.ts +94 -4
  27. package/src/cli/reset.ts +14 -4
  28. package/src/cli/runtime-version-warning.ts +28 -0
  29. package/src/cli/start-agents-run.ts +125 -0
  30. package/src/cli/task-output.ts +5 -0
  31. package/src/cli/upgrade-interactive.ts +4 -4
  32. package/src/cli/upgrade-runtime.ts +105 -57
  33. package/src/cli/upgrade.ts +90 -28
  34. package/src/hub/autonomy-defaults.ts +32 -7
  35. package/src/hub/board.ts +129 -13
  36. package/src/hub/budget.ts +3 -4
  37. package/src/hub/child-process.ts +4 -3
  38. package/src/hub/conductor.ts +3 -4
  39. package/src/hub/config-apply.ts +13 -2
  40. package/src/hub/config-edit-host.ts +1 -0
  41. package/src/hub/config-tier.ts +3 -0
  42. package/src/hub/config-trust.ts +10 -2
  43. package/src/hub/control-client.ts +30 -10
  44. package/src/hub/daemon.ts +82 -17
  45. package/src/hub/db-schema.ts +143 -0
  46. package/src/hub/delivery-journal.ts +3 -4
  47. package/src/hub/execution-budget.ts +3 -4
  48. package/src/hub/folder-trigger.ts +3 -4
  49. package/src/hub/invocations.ts +3 -4
  50. package/src/hub/lifecycle.ts +87 -29
  51. package/src/hub/manager.ts +49 -33
  52. package/src/hub/orca-env.ts +19 -0
  53. package/src/hub/project-runtime-state.ts +139 -0
  54. package/src/hub/project-runtime.ts +62 -0
  55. package/src/hub/protocol-window.ts +19 -0
  56. package/src/hub/recovery-store.ts +102 -12
  57. package/src/hub/registry.ts +5 -0
  58. package/src/hub/research-experiments.ts +33 -4
  59. package/src/hub/research-runner.ts +172 -0
  60. package/src/hub/restart.ts +9 -0
  61. package/src/hub/runtime-capture-prune.ts +415 -0
  62. package/src/hub/runtime-prune-health.ts +112 -0
  63. package/src/hub/schedules.ts +3 -4
  64. package/src/hub/sdlc-merge.ts +7 -0
  65. package/src/hub/sdlc-queue.ts +13 -1
  66. package/src/hub/settings.ts +4 -0
  67. package/src/hub/snapshots.ts +3 -4
  68. package/src/hub/start-agents.ts +44 -0
  69. package/src/hub/task-approval.ts +23 -0
  70. package/src/hub/tasks.ts +133 -34
  71. package/src/hub/usage-rollup.ts +3 -4
  72. package/src/pi/tool-receipts.ts +3 -4
@@ -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"],
@@ -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"],
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";
@@ -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; }
@@ -1959,7 +1993,8 @@ const commands: Record<string, () => Promise<void> | void> = {
1959
1993
  try {
1960
1994
  const environment = autonomyEnvironment(process.env, !!process.stdin.isTTY), home = hubHome();
1961
1995
  if (args.includes("--all-projects")) {
1962
- assertLifecycleAvailable();
1996
+ assertLifecycleAvailable(cwd);
1997
+ for (const project of registry.list()) assertLifecycleAvailable(project.root);
1963
1998
  await runAllProjectAutonomy(args, { home, environment, projects: registry.list(),
1964
1999
  prepare: (project, enabled) => prepareAutonomyProject(project, environment, enabled, registry),
1965
2000
  ask: async prompt => rl ? rl.question(prompt) : null, print: line => console.log(terminalText(line)),
@@ -1994,7 +2029,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1994
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."],
1995
2030
  confirmPrompt: "Apply this project's preset and this machine's autonomy default? [y/N] ",
1996
2031
  beforeApply: action => {
1997
- assertLifecycleAvailable();
2032
+ assertLifecycleAvailable(cwd);
1998
2033
  if (action === "off" && defaults.fingerprint === "untrusted") console.log("Unsafe machine defaults are already ignored; their file was left unchanged.");
1999
2034
  else writeAutonomyDefaults(home, action === "on", defaults.fingerprint, roots);
2000
2035
  console.log(`Machine autonomy default: ${action}.`);
@@ -2550,7 +2585,7 @@ const commands: Record<string, () => Promise<void> | void> = {
2550
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");
2551
2586
  }
2552
2587
  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.
2588
+ // A recovery that took this project's hold after the first check must not run beside its reset.
2554
2589
  resetLockFree();
2555
2590
  if (all) {
2556
2591
  let archived: string;
@@ -2635,6 +2670,7 @@ const commands: Record<string, () => Promise<void> | void> = {
2635
2670
  const row = (ok: boolean | undefined, name: string, detail: string) => { checks.push(nativeDoctorCheck(ok, name, detail)); };
2636
2671
 
2637
2672
  try {
2673
+ for (const check of runtimePruneHealth()) row(check.ok, check.name, check.detail);
2638
2674
  const catalog = loadRoleCatalog(cwd);
2639
2675
  for (const error of catalog.errors) row(false, "catalog role", `${error.id}: ${error.reason}`);
2640
2676
  try {
@@ -2646,11 +2682,13 @@ const commands: Record<string, () => Promise<void> | void> = {
2646
2682
  row(!!v, bin, v ?? "not found on PATH");
2647
2683
  }
2648
2684
  const up = await healthy();
2685
+ let daemonVersion: unknown;
2649
2686
  row(up, "ahub daemon", up ? readControl(stateDir)!.url : "not running (ahub up)");
2650
2687
  if (up) {
2651
2688
  const hub = await connect();
2652
2689
  try {
2653
2690
  const { status } = await hub.request({ t: "status" });
2691
+ daemonVersion = status.version; // Authenticated reply, never the unauthenticated disk manifest.
2654
2692
  const pending = Object.values(status.peers as Record<string, PeerRow>).reduce((n, peer) => n + (peer.needsReview ?? 0), 0);
2655
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"));
2656
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.
@@ -2666,6 +2704,7 @@ const commands: Record<string, () => Promise<void> | void> = {
2666
2704
  }
2667
2705
  const plugin = pluginState(parseList<InstalledPlugin>(spawnSync(DOCTOR_PLUGIN_PROBE.command, [...DOCTOR_PLUGIN_PROBE.argv], { encoding: "utf8" }).stdout ?? ""), join(import.meta.dir, "..", ".."));
2668
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));
2669
2708
 
2670
2709
  const config = loadConfig(cwd);
2671
2710
  row(!config.ignored, "config", config.ignored ? `${config.ignored.join("; ")} (move them to .agenthub/config.local.json)` : "no machine-local field ignored");
@@ -2862,7 +2901,7 @@ async function runConductorCommand(): Promise<void> {
2862
2901
  }
2863
2902
  const runConfigured = configuredCommand ? async () => {
2864
2903
  const selection = parseConfiguredAcpArguments(cmd, args, projectCatalog, declaredInstances, peerConfig.declaredExternalInstances);
2865
- if (!selection.preview) assertLifecycleAvailable();
2904
+ if (!selection.preview) assertLifecycleAvailable(cwd);
2866
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 });
2867
2906
  } : undefined;
2868
2907
  const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? runConfigured ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
@@ -1,9 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
- import { dirname, join, resolve } from "node:path";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
5
  import { createRequire } from "node:module";
6
6
  import { hubHome, realPath } from "../hub/project.ts";
7
+ import { Registry } from "../hub/registry.ts";
7
8
 
8
9
  export type CommandResult = { code: number; stdout: string; stderr: string };
9
10
  export type RunCommand = (args: string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }) => Promise<CommandResult>;
@@ -109,7 +110,11 @@ export function verifyPackage(root: string, version: string, options: { ownDepen
109
110
  export function recoveryPackageOptions(kind: "restart" | "upgrade", root: string): { ownDependencies?: boolean; dependencyRoot?: string } {
110
111
  if (kind === "restart") return { ownDependencies: true };
111
112
  if (kind !== "upgrade") throw new Error("unsupported recovery package kind");
112
- const canonical = realPath(root), dependencyRoot = resolve(canonical, "../../..");
113
+ const canonical = realPath(root);
114
+ // Project captures own the exact dependency and keep the registry install separate below this version.
115
+ if (canonical === root && /^source-[a-f0-9]{64}$/.test(basename(canonical)) && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(basename(dirname(canonical)))
116
+ && basename(dirname(dirname(canonical))) === "releases") return { ownDependencies: true };
117
+ const dependencyRoot = resolve(canonical, "../../..");
113
118
  if (canonical !== root || canonical !== join(dependencyRoot, "node_modules", "@staix", "agent-hub")) throw new Error("invalid staged recovery package path");
114
119
  return { dependencyRoot };
115
120
  }
@@ -122,6 +127,82 @@ export function verifyRecoveryTarget(kind: "restart" | "upgrade", root: string,
122
127
  return verifyPackage(root, version, recoveryPackageOptions(kind, root));
123
128
  }
124
129
 
130
+ const RUNTIME_ASSETS = ["src", "plugins", "templates", ".claude-plugin", "package.json", "README.md", "LICENSE", "CHANGELOG.md"] as const;
131
+ function runtimeDirectory(directory: string): void {
132
+ let info;
133
+ try { info = lstatSync(directory); }
134
+ catch (error) {
135
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
136
+ try { mkdirSync(directory, { mode: 0o700 }); }
137
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
138
+ info = lstatSync(directory);
139
+ }
140
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error("runtime cache must contain real directories");
141
+ }
142
+
143
+ /** Preserve the exact bounded asset set; legacy coordinators need not have a plugin identity manifest. */
144
+ function preserveRuntimeAssets(sourceRoot: string, destination: string, expectedDigest: string, version?: string): { root: string; digest: string } {
145
+ const digest = (root: string, ownDependencies = false) => version === undefined
146
+ ? packageDigest(root, { ownDependencies }) : verifyPackage(root, version, { ownDependencies });
147
+ if (digest(sourceRoot) !== expectedDigest) throw new Error("source package changed before preservation");
148
+ runtimeDirectory(dirname(destination));
149
+ const cached = () => {
150
+ let info;
151
+ try { info = lstatSync(destination); }
152
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
153
+ if (info.isSymbolicLink() || !info.isDirectory() || digest(destination, true) !== expectedDigest)
154
+ throw new Error(version === undefined ? "preserved source package changed" : "preserved runtime changed; refusing it");
155
+ return { root: realPath(destination), digest: expectedDigest };
156
+ };
157
+ const previous = cached(); if (previous) return previous;
158
+ const temporary = join(dirname(destination), `.source-${randomUUID()}`);
159
+ mkdirSync(temporary, { mode: 0o700 });
160
+ try {
161
+ for (const name of RUNTIME_ASSETS) cpSync(join(sourceRoot, name), join(temporary, name), { recursive: true, dereference: false });
162
+ preserveRuntimeDependencies(sourceRoot, temporary);
163
+ if (digest(temporary, true) !== expectedDigest || digest(sourceRoot) !== expectedDigest)
164
+ throw new Error("source package changed while being preserved");
165
+ try { renameSync(temporary, destination); }
166
+ catch (error) {
167
+ if (!["EEXIST", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
168
+ // A concurrent publisher may have won. Validate its complete bytes; never remove its directory.
169
+ const winner = cached(); if (winner) return winner;
170
+ throw error;
171
+ }
172
+ const published = cached();
173
+ if (!published) throw new Error("preserved runtime vanished after publication");
174
+ return published;
175
+ } finally { rmSync(temporary, { recursive: true, force: true }); }
176
+ }
177
+
178
+ /** Capture a verified package identity and its exact first-party dependency, never a node_modules tree. */
179
+ export function preserveRuntimePackage(sourceRoot: string, destination: string, expectedDigest: string): { root: string; digest: string; version: string } {
180
+ const version = exactVersion(JSON.parse(readFileSync(join(sourceRoot, "package.json"), "utf8")).version);
181
+ return { ...preserveRuntimeAssets(sourceRoot, destination, expectedDigest, version), version };
182
+ }
183
+
184
+ /** Retained legacy coordinators keep their digest-only contract and still require their own exact dependency. */
185
+ export function preserveRuntimeSource(sourceRoot: string, destination: string, expectedDigest: string): string {
186
+ preserveRuntimeAssets(sourceRoot, destination, expectedDigest);
187
+ return destination; // Legacy receipts retain the path spelling their coordinator already bound.
188
+ }
189
+
190
+ /** Ordinary startup preserves the invoking package, including unpublished builds, below its version namespace. */
191
+ export function stageCurrentRuntime(sourceRoot: string, version: string, home = hubHome(), registry?: Registry): { root: string; digest: string; version: string } {
192
+ exactVersion(version);
193
+ const digest = verifyPackage(sourceRoot, version);
194
+ const owner = registry ?? new Registry(join(home, "registry.db"));
195
+ try {
196
+ if (realPath(owner.path) !== realPath(join(home, "registry.db"))) throw new Error("runtime capture registry belongs to another home");
197
+ return owner.runtimeCaptureLocked(() => {
198
+ mkdirSync(home, { recursive: true, mode: 0o700 });
199
+ const releases = join(realPath(home), "releases"); runtimeDirectory(releases);
200
+ const versionRoot = join(releases, version); runtimeDirectory(versionRoot);
201
+ return preserveRuntimePackage(sourceRoot, join(versionRoot, `source-${digest}`), digest);
202
+ });
203
+ } finally { if (!registry) owner.close(); }
204
+ }
205
+
125
206
  export async function registryRelease(version: string, run: RunCommand = runCommand): Promise<{ version: string; integrity: string }> {
126
207
  exactVersion(version);
127
208
  const result = await run(["npm", "view", `@staix/agent-hub@${version}`, "version", "dist.integrity", "--json"], { timeoutMs: 30_000 });
@@ -151,10 +232,18 @@ export function newerVersion(a: string, b: string): boolean {
151
232
  }
152
233
 
153
234
  /** Stage outside the mutable global install; retain successful versions for recovery. */
154
- export async function stageRelease(version: string, integrity: string, run: RunCommand = runCommand, home = hubHome()): Promise<{ root: string; digest: string }> {
235
+ export async function stageRelease(version: string, integrity: string, run: RunCommand = runCommand, home = hubHome(), project = false): Promise<{ root: string; digest: string }> {
155
236
  exactVersion(version);
156
237
  const base = join(home, "releases");
157
- const destination = join(base, version);
238
+ const versionRoot = join(base, version);
239
+ // Ordinary startup can create this parent before an unstaged legacy receipt resumes.
240
+ // An actual legacy install (even a damaged one) must retain its original path and validation.
241
+ if (!project && existsSync(versionRoot) && !existsSync(join(versionRoot, "verified.json")) && !existsSync(join(versionRoot, "node_modules"))) {
242
+ runtimeDirectory(versionRoot);
243
+ if (!readdirSync(versionRoot).every(name => name === "registry" || /^source-[a-f0-9]{64}$/.test(name))) throw new Error("cached release state is unknown; refusing it");
244
+ project = true;
245
+ }
246
+ const destination = project ? join(versionRoot, "registry") : versionRoot;
158
247
  const root = join(destination, "node_modules/@staix/agent-hub");
159
248
  if (existsSync(destination)) {
160
249
  const saved = JSON.parse(readFileSync(join(destination, "verified.json"), "utf8"));
@@ -163,6 +252,7 @@ export async function stageRelease(version: string, integrity: string, run: RunC
163
252
  return { root, digest };
164
253
  }
165
254
  mkdirSync(base, { recursive: true, mode: 0o700 });
255
+ if (project) runtimeDirectory(join(base, version));
166
256
  const temporary = join(base, `.stage-${randomUUID()}`);
167
257
  mkdirSync(temporary, { mode: 0o700 });
168
258
  try {