@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.
- package/CHANGELOG.md +10 -0
- package/README.md +10 -5
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +1 -1
- package/docs/agent-notes/daemon.md +6 -4
- package/docs/agent-notes/tasks.md +5 -4
- package/docs/agent-notes/tests.md +1 -0
- package/docs/dogfood.md +4 -0
- package/docs/operations.md +131 -14
- package/docs/quickstart.md +14 -4
- package/docs/security.md +51 -8
- package/docs/specs/2026-11-01-research-campaigns-design.md +9 -6
- package/docs/verification/2026-11-01-0.26.0.md +54 -0
- package/docs/verified.json +28 -16
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +182 -95
- package/src/adapters/channel-peer.ts +4 -2
- package/src/adapters/definitions/builtin-metadata.ts +6 -6
- package/src/adapters/definitions/qwen.ts +2 -0
- package/src/adapters/registry-types.ts +2 -0
- package/src/adapters/registry.ts +1 -0
- package/src/cli/conductor-recovery.ts +3 -2
- package/src/cli/help.ts +7 -7
- package/src/cli/main.ts +62 -23
- package/src/cli/recovery-package.ts +94 -4
- package/src/cli/reset.ts +14 -4
- package/src/cli/runtime-version-warning.ts +28 -0
- package/src/cli/start-agents-run.ts +125 -0
- package/src/cli/task-output.ts +5 -0
- package/src/cli/upgrade-interactive.ts +4 -4
- package/src/cli/upgrade-runtime.ts +105 -57
- package/src/cli/upgrade.ts +90 -28
- package/src/hub/autonomy-defaults.ts +32 -7
- package/src/hub/board.ts +129 -13
- package/src/hub/budget.ts +3 -4
- package/src/hub/child-process.ts +4 -3
- package/src/hub/conductor.ts +3 -4
- package/src/hub/config-apply.ts +13 -2
- package/src/hub/config-edit-host.ts +1 -0
- package/src/hub/config-tier.ts +3 -0
- package/src/hub/config-trust.ts +10 -2
- package/src/hub/control-client.ts +30 -10
- package/src/hub/daemon.ts +82 -17
- package/src/hub/db-schema.ts +143 -0
- package/src/hub/delivery-journal.ts +3 -4
- package/src/hub/execution-budget.ts +3 -4
- package/src/hub/folder-trigger.ts +3 -4
- package/src/hub/invocations.ts +3 -4
- package/src/hub/lifecycle.ts +87 -29
- package/src/hub/manager.ts +49 -33
- package/src/hub/orca-env.ts +19 -0
- package/src/hub/project-runtime-state.ts +139 -0
- package/src/hub/project-runtime.ts +62 -0
- package/src/hub/protocol-window.ts +19 -0
- package/src/hub/recovery-store.ts +102 -12
- package/src/hub/registry.ts +5 -0
- package/src/hub/research-experiments.ts +33 -4
- package/src/hub/research-runner.ts +172 -0
- package/src/hub/restart.ts +9 -0
- package/src/hub/runtime-capture-prune.ts +415 -0
- package/src/hub/runtime-prune-health.ts +112 -0
- package/src/hub/schedules.ts +3 -4
- package/src/hub/sdlc-merge.ts +7 -0
- package/src/hub/sdlc-queue.ts +13 -1
- package/src/hub/settings.ts +4 -0
- package/src/hub/snapshots.ts +3 -4
- package/src/hub/start-agents.ts +44 -0
- package/src/hub/task-approval.ts +23 -0
- package/src/hub/tasks.ts +133 -34
- package/src/hub/usage-rollup.ts +3 -4
- 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;
|
package/src/adapters/registry.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
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
|
|
54
|
-
["ahub recovery [status|resume|abort] [<operation-id>] [--json] [--color=auto|always|never]", "inspect a preflight; without an id, resume/abort select
|
|
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:
|
|
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 =
|
|
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,
|
|
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,
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|
-
//
|
|
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)
|
|
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
|
|
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 {
|