@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
package/src/hub/budget.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { definitionOf, type AdapterDefinition } from "../adapters/registry.ts";
|
|
2
|
-
import { Database } from "
|
|
2
|
+
import type { Database } from "./db-schema.ts";
|
|
3
|
+
import { openHubDatabase } from "./db-schema.ts";
|
|
3
4
|
import type { PeerId } from "./envelope.ts";
|
|
4
|
-
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
5
5
|
import { windowKind, type WindowKind } from "./window-kind.ts";
|
|
6
6
|
export { windowKind, type WindowKind };
|
|
7
7
|
|
|
@@ -136,8 +136,7 @@ export class Budget {
|
|
|
136
136
|
private readonly hooks: BudgetHooks,
|
|
137
137
|
private readonly now: () => number = Date.now,
|
|
138
138
|
) {
|
|
139
|
-
this.db =
|
|
140
|
-
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
139
|
+
this.db = openHubDatabase(dbPath, { create: true });
|
|
141
140
|
this.db.run(`CREATE TABLE IF NOT EXISTS budget_pauses (peer TEXT PRIMARY KEY, since INTEGER NOT NULL, resets_at INTEGER NOT NULL,
|
|
142
141
|
reason TEXT NOT NULL, summary TEXT, handed_off INTEGER NOT NULL DEFAULT 0, moved TEXT NOT NULL DEFAULT '[]')`);
|
|
143
142
|
try { this.normalizeStoredPauses(); } catch (error) { this.db.close(); throw error; }
|
package/src/hub/child-process.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
import { withoutOrcaEnv } from "./orca-env.ts";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Recovery authority belongs to the short-lived ahub wrapper/launcher. Native
|
|
@@ -19,9 +20,9 @@ export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proces
|
|
|
19
20
|
* typed or started there has the console's identity, whatever started the hub.
|
|
20
21
|
*/
|
|
21
22
|
export function personEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
for (const name of ["CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "AGENTHUB_CHANNEL", "AGENTHUB_PEER_ID", "AGENTHUB_UNATTENDED"
|
|
23
|
+
// The Orca terminal the hub itself was started in is not this new terminal: a launch there must not record it as its own, so every ORCA_ variable goes (#613).
|
|
24
|
+
const env = withoutOrcaEnv(childEnv(source));
|
|
25
|
+
for (const name of ["CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "AGENTHUB_CHANNEL", "AGENTHUB_PEER_ID", "AGENTHUB_UNATTENDED"]) delete env[name];
|
|
25
26
|
return env;
|
|
26
27
|
}
|
|
27
28
|
|
package/src/hub/conductor.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { definitionOf, type KindMap, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
2
|
-
import { Database } from "
|
|
2
|
+
import type { Database } from "./db-schema.ts";
|
|
3
|
+
import { openHubDatabase } from "./db-schema.ts";
|
|
3
4
|
import type { Task } from "./board.ts";
|
|
4
5
|
import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
|
|
5
6
|
import { HUB, sanitize, USER } from "./envelope.ts";
|
|
@@ -11,7 +12,6 @@ import type { SupervisionFeed } from "./supervision.ts";
|
|
|
11
12
|
import { DOING_STUB, type PeerDoing } from "./peer-doing.ts";
|
|
12
13
|
import type { Budget } from "./budget.ts";
|
|
13
14
|
import { windowKind, type WindowKind } from "./window-kind.ts";
|
|
14
|
-
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
15
15
|
import { supportsHeadlessInstances } from "./instances.ts";
|
|
16
16
|
|
|
17
17
|
/** The production ProgressObserver sink forwards structured verdicts, never its observations/reasoning. */
|
|
@@ -72,8 +72,7 @@ export interface ConductorHold { peer: string; actor: string; since: number }
|
|
|
72
72
|
export class ConductorHolds {
|
|
73
73
|
private readonly db: Database;
|
|
74
74
|
constructor(dbPath: string, private readonly now: () => number = Date.now) {
|
|
75
|
-
this.db =
|
|
76
|
-
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
75
|
+
this.db = openHubDatabase(dbPath, { create: true });
|
|
77
76
|
this.db.run("CREATE TABLE IF NOT EXISTS conductor_holds (peer TEXT PRIMARY KEY, actor TEXT NOT NULL, since INTEGER NOT NULL)");
|
|
78
77
|
this.db.run("CREATE TABLE IF NOT EXISTS supervision_rounds (peer TEXT PRIMARY KEY, signature TEXT NOT NULL)");
|
|
79
78
|
}
|
package/src/hub/config-apply.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { operationalNumber, OPERATIONAL_NUMBERS } from "./operational-settings.ts";
|
|
2
|
+
import { parseResearchRunners } from "./research-runner.ts";
|
|
2
3
|
import { QUEUE_BACKPRESSURE_LIMITS } from "./queue-backpressure.ts";
|
|
3
4
|
import { operationalSettingKey } from "./operational-authority.ts";
|
|
4
5
|
import { remoteRunnerConfig } from "./check-runner.ts";
|
|
@@ -14,6 +15,7 @@ import { createProjectAdapterCatalog, type ProjectAdapterCatalog, ADAPTERS, DEFA
|
|
|
14
15
|
import { CLASSES } from "./board.ts";
|
|
15
16
|
import { permissionDefaults } from "./permission-mode.ts";
|
|
16
17
|
import { peerStartConfig, terminalTemplate } from "./start-mode.ts";
|
|
18
|
+
import { checkStartAgents } from "./start-agents.ts";
|
|
17
19
|
import { catalogAssignments } from "./roles.ts";
|
|
18
20
|
import { resolveProfile } from "./profiles.ts";
|
|
19
21
|
import { foldersConfig, schedulesConfig } from "./schedules.ts";
|
|
@@ -62,6 +64,12 @@ export interface ConfigAuthorizationDecision { key: string; allowed: boolean; gu
|
|
|
62
64
|
export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuthorizationDecision {
|
|
63
65
|
const { key, before, after } = input;
|
|
64
66
|
const result = (allowed: boolean, guarded: boolean, reason: string): ConfigAuthorizationDecision => ({ key, allowed, guarded, reason });
|
|
67
|
+
const runners = key === "research.runners" || key.startsWith("research.runners.") || key === "research"
|
|
68
|
+
&& [before, after].some(value => !!value && typeof value === "object" && Object.hasOwn(value, "runners"));
|
|
69
|
+
if (runners) {
|
|
70
|
+
if (input.source !== "config" || input.sourceFile !== "config.local.json" || input.tracked !== false) return result(false, true, "research.runners requires untracked config.local.json");
|
|
71
|
+
if (input.authority === "conductor") return result(false, true, "runner registration is person only");
|
|
72
|
+
}
|
|
65
73
|
if (key === "action_policy" || key.startsWith("action_policy.")) {
|
|
66
74
|
if (input.sourceFile !== "config.local.json") return result(false, true, "action policy requires .agenthub/config.local.json");
|
|
67
75
|
if (input.tracked !== false) return result(false, true, "action policy requires an untracked machine-local source");
|
|
@@ -175,6 +183,8 @@ export const validatePetConfig: Check = (v, k) => {
|
|
|
175
183
|
};
|
|
176
184
|
shapes.set(validatePetConfig, { fields: petFields });
|
|
177
185
|
const nativeValidators = { bool, text, strings, argv, number, words, block };
|
|
186
|
+
const researchRunners: Check = value => { parseResearchRunners(value); };
|
|
187
|
+
shapes.set(researchRunners, { item: block({ publicKey: text, projects: strings, revoked: bool }) });
|
|
178
188
|
const schema: Record<string, Check> = {
|
|
179
189
|
_autonomy: v => { autonomyOwnership(v); },
|
|
180
190
|
...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.schema?.(nativeValidators) ?? {})),
|
|
@@ -214,9 +224,10 @@ const schema: Record<string, Check> = {
|
|
|
214
224
|
else if (key === "remote") remoteRunnerConfig(value);
|
|
215
225
|
else if (/^[a-z][a-z0-9_]{0,47}$/.test(key)) text(value, `${k}.${key}`); else fail(`${k}.${key}`, "unknown key");
|
|
216
226
|
} },
|
|
217
|
-
snapshots: block({ enabled: bool, keep: number(1, 1_000_000, true) }), research: block({ enabled: bool }), bench: block({ enabled: bool }),
|
|
227
|
+
snapshots: block({ enabled: bool, keep: number(1, 1_000_000, true) }), research: block({ enabled: bool, runners: researchRunners }), bench: block({ enabled: bool }),
|
|
218
228
|
peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), START_MODE_PEERS)(v, k); peerStartConfig(v); },
|
|
219
229
|
terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
|
|
230
|
+
start: (v, k) => { block({ agents: strings })(v, k); if (object(v, k).agents !== undefined) checkStartAgents(object(v, k).agents, `${k}.agents`); },
|
|
220
231
|
limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
|
|
221
232
|
review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true), pool: v => { reviewerPoolConfig({ pool: v }, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS); }, rerotate: bool, same_family: words(["fallback", "never"]) }), recovery: block({ auto_resume_after_crash: bool, reconnect_wait_s: operationalNumberCheck("recovery.reconnect_wait_s"), unmanaged_reconnect_wait_s: operationalNumberCheck("recovery.unmanaged_reconnect_wait_s"), control_timeout_s: operationalNumberCheck("recovery.control_timeout_s"), shutdown_wait_s: operationalNumberCheck("recovery.shutdown_wait_s"), terminal_idle_wait_s: operationalNumberCheck("recovery.terminal_idle_wait_s") }),
|
|
222
233
|
capabilities: map(strings), invocation: block({ share: map(map(() => {})), timeout_s: number(10, 86_400, true) }), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
|
|
@@ -269,7 +280,7 @@ export function configApply(key: string, scopes: readonly ConfigIdentityScope[]
|
|
|
269
280
|
if (top !== "routing" && top !== "permission" && key.includes(".")) {
|
|
270
281
|
const special: Record<string, readonly string[]> = {
|
|
271
282
|
task_sweep: ["enabled", "auto_reassign", "no_writes_turns", "interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"],
|
|
272
|
-
checks: ["timeout_s", "runner", "remote", ...CLASSES], terminal: ["open"],
|
|
283
|
+
checks: ["timeout_s", "runner", "remote", ...CLASSES], terminal: ["open"], start: ["agents"],
|
|
273
284
|
};
|
|
274
285
|
let check = schema[top];
|
|
275
286
|
for (const [index, part] of key.split(".").slice(1).entries()) {
|
|
@@ -160,6 +160,7 @@ export function createConfigEditor(host: ConfigEditHost) {
|
|
|
160
160
|
function leafKeys(plan: Extract<ConfigEditPlan, { ok: true }>): string[] {
|
|
161
161
|
const out = new Set<string>();
|
|
162
162
|
const walk = (prefix: string, before: unknown, after: unknown): void => {
|
|
163
|
+
if (prefix === "research.runners") { out.add(prefix); return; }
|
|
163
164
|
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
164
165
|
if (object(before) || object(after)) {
|
|
165
166
|
const old = object(before) ? before : {}, next = object(after) ? after : {};
|
package/src/hub/config-tier.ts
CHANGED
|
@@ -41,6 +41,9 @@ export function configTier(file: ConfigEditFile, path: readonly string[], value?
|
|
|
41
41
|
const key = path.join(".");
|
|
42
42
|
if (file === "sdlc.toml") return path.length === 1 && (SDLC_KEYS as readonly string[]).includes(path[0]!) ? { tier: 2 } : refuse(`sdlc.${key} is not editable here: only mode and required_checks are; edit .agenthub/sdlc.toml in your editor`);
|
|
43
43
|
if (registryKey(key)) return { refused: `${key} is already editable with ahub settings set ${key}; nothing was written` };
|
|
44
|
+
if (path[0] === "research" && path[1] === "runners") {
|
|
45
|
+
return path.length === 2 ? { tier: 2 } : refuse("research.runners: edit the whole registration block");
|
|
46
|
+
}
|
|
44
47
|
if (path[0] === "instances") {
|
|
45
48
|
if (path.length === 1) return refuse("instances: name one instance (instances.<id>)");
|
|
46
49
|
if (!PEER.test(path[1]!)) return refuse(`instances.${path[1]}: an instance id is lower-case letters, digits and hyphens`);
|
package/src/hub/config-trust.ts
CHANGED
|
@@ -36,7 +36,9 @@ export const MACHINE_LOCAL = [
|
|
|
36
36
|
"invocation", // #445 C1: what another agent may be offered from this machine's agents (a digest-pinned allowlist); a committed config may not share a person's agents' commands with someone else's peers
|
|
37
37
|
] as const;
|
|
38
38
|
/** Legacy exported field list stays compatible; new policies use the complete list below. */
|
|
39
|
-
export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "rooms", "tasks.queue_backpressure", "context.max_assignment_used", "action_policy", "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "adapters", "adapter_modules", "text_projection", "instances"] as const;
|
|
39
|
+
export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "research.runners", "rooms", "tasks.queue_backpressure", "context.max_assignment_used", "action_policy", "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "start.agents", "adapters", "adapter_modules", "text_projection", "instances"] as const;
|
|
40
|
+
/** An empty list of these is a value (`start.agents` empty starts no agent), not "the default", so the empty-value drop below skips them. */
|
|
41
|
+
const EMPTY_IS_VALUE: readonly string[] = ["start.agents"];
|
|
40
42
|
|
|
41
43
|
/** A known repository, including an unreadable marker, must never be mistaken for a plain directory. */
|
|
42
44
|
export function configRepositoryPresent(cwd: string): boolean {
|
|
@@ -102,11 +104,17 @@ const drop = (file: Record<string, unknown>, path: string) => {
|
|
|
102
104
|
* once resolved to the project root). A committed copy of the template sets them to defaults and asks git nothing.
|
|
103
105
|
*/
|
|
104
106
|
export function stripUntrusted(file: Record<string, unknown>, defaults: object, cwd: string, name: string): string | undefined {
|
|
107
|
+
const research = file.research;
|
|
108
|
+
if (research && typeof research === "object" && Object.hasOwn(research, "runners")) {
|
|
109
|
+
if (name !== "config.local.json") throw new Error("research.runners requires untracked config.local.json");
|
|
110
|
+
const why = configRefusal(cwd, name);
|
|
111
|
+
if (why) throw new Error(`research.runners requires untracked config.local.json: ${why}`);
|
|
112
|
+
}
|
|
105
113
|
if (name !== "config.local.json") delete file.rooms;
|
|
106
114
|
const localOnly = name !== "config.local.json" && file.action_policy !== undefined;
|
|
107
115
|
if (localOnly) delete file.action_policy;
|
|
108
116
|
const originNotice = localOnly ? "action_policy ignored: only .agenthub/config.local.json may set it" : undefined;
|
|
109
|
-
for (const p of ALL_MACHINE_LOCAL) if (valueAt(file, p) !== undefined && !chooses(valueAt(file, p))) drop(file, p);
|
|
117
|
+
for (const p of ALL_MACHINE_LOCAL) if (!EMPTY_IS_VALUE.includes(p) && valueAt(file, p) !== undefined && !chooses(valueAt(file, p))) drop(file, p);
|
|
110
118
|
const set = ALL_MACHINE_LOCAL.filter((p) => {
|
|
111
119
|
const v = valueAt(file, p);
|
|
112
120
|
return v !== undefined && JSON.stringify(v) !== JSON.stringify(valueAt(defaults, p));
|
|
@@ -2,16 +2,27 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { projectContext } from "./project.ts";
|
|
4
4
|
import { externalAdapterMetadata, externalInstanceCatalog, type ExternalInstanceCatalog, type ExternalAdapterMetadata } from "../adapters/external-metadata.ts";
|
|
5
|
+
import { negotiateProtocol, protocolWindow, type ProtocolWindow } from "./protocol-window.ts";
|
|
5
6
|
|
|
6
7
|
export function stateDirFor(cwd: string): string {
|
|
7
8
|
return projectContext(cwd).stateDir;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
|
|
11
|
-
export const PROTOCOL =
|
|
12
|
-
|
|
12
|
+
export const PROTOCOL = 17;
|
|
13
|
+
/** The descriptor's legacy protocol field remains this floor for previous published clients. */
|
|
14
|
+
export const PROTOCOL_MIN = 16;
|
|
15
|
+
export const DAEMON_FEATURES = ["project_autonomy", "conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "invocation", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated", "action_policy_floor", "calendar_schedules", "research_records", "research_dispatch", "research_dropped_offers", "research_experiments", "research_comparisons", "research_runners", "task_handoff", "session_identity", "schema_guard"] as const;
|
|
13
16
|
/** Protocols a current coordinator may authenticate while upgrading a running source. */
|
|
14
|
-
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
|
|
17
|
+
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, 16, PROTOCOL] as const;
|
|
18
|
+
|
|
19
|
+
/** Modern descriptors advertise their maximum separately; old descriptors represent singleton windows. */
|
|
20
|
+
export function controlProtocolWindow(descriptor: { protocol?: unknown; protocolMin?: unknown; protocolMax?: unknown }): ProtocolWindow | undefined {
|
|
21
|
+
const legacy = protocolWindow(descriptor.protocol);
|
|
22
|
+
const window = protocolWindow(descriptor.protocolMax ?? descriptor.protocol, descriptor.protocolMin ?? descriptor.protocol);
|
|
23
|
+
if (!legacy || !window || legacy.v < window.vMin || legacy.v > window.v) return undefined;
|
|
24
|
+
return window;
|
|
25
|
+
}
|
|
15
26
|
|
|
16
27
|
/** Only a launch retries transient transport failures. Refusals and identity mismatches
|
|
17
28
|
* remain terminal; every attempt rereads the descriptor and applies the instance pin. */
|
|
@@ -62,6 +73,8 @@ export interface ControlDescriptor {
|
|
|
62
73
|
instanceId?: string;
|
|
63
74
|
cwd?: string;
|
|
64
75
|
protocol?: number;
|
|
76
|
+
protocolMin?: number;
|
|
77
|
+
protocolMax?: number;
|
|
65
78
|
pid?: number;
|
|
66
79
|
features?: readonly string[];
|
|
67
80
|
/** The daemon's accepted native start modes, used by launchers when the file value is retained. */
|
|
@@ -81,7 +94,7 @@ export function readControl(stateDir: string): ControlDescriptor | undefined {
|
|
|
81
94
|
? Object.fromEntries(Object.entries(status.startModes).filter((entry): entry is [string, "tui" | "headless"] => entry[1] === "tui" || entry[1] === "headless"))
|
|
82
95
|
: undefined;
|
|
83
96
|
return { url: `ws://127.0.0.1:${status.controlPort}`, token, projectId: status.projectId,
|
|
84
|
-
instanceId: status.instanceId, cwd: status.cwd, protocol: status.protocol, pid: status.pid, features: Array.isArray(status.features) ? status.features.filter((feature: unknown): feature is string => typeof feature === "string") : [],
|
|
97
|
+
instanceId: status.instanceId, cwd: status.cwd, protocol: status.protocol, protocolMin: status.protocolMin, protocolMax: status.protocolMax, pid: status.pid, features: Array.isArray(status.features) ? status.features.filter((feature: unknown): feature is string => typeof feature === "string") : [],
|
|
85
98
|
...(startModes ? { startModes } : {}),
|
|
86
99
|
...(status.externalAdapters !== undefined ? { externalAdapters: externalAdapterMetadata(status.externalAdapters) } : {}),
|
|
87
100
|
...(typeof status.pidSignature === "string" && status.pidSignature ? { pidSignature: status.pidSignature } : {}) };
|
|
@@ -105,6 +118,8 @@ export class ControlClient {
|
|
|
105
118
|
features: readonly string[] = [];
|
|
106
119
|
/** The id the hub minted for a distinct session (agent-hub#1036); absent for every ordinary connection. */
|
|
107
120
|
sessionId?: string;
|
|
121
|
+
/** Selected wire version, checked against the welcome when that legacy-optional field is present. */
|
|
122
|
+
protocol?: number;
|
|
108
123
|
private welcomeInstanceId?: string;
|
|
109
124
|
/** Identity from the authenticated welcome, never the unauthenticated descriptor alone. */
|
|
110
125
|
get instanceId(): string | undefined { return this.welcomeInstanceId; }
|
|
@@ -128,17 +143,20 @@ export class ControlClient {
|
|
|
128
143
|
}
|
|
129
144
|
|
|
130
145
|
/** `protocol` is used only by the upgrade coordinator for an authenticated, explicitly supported source contract. */
|
|
131
|
-
static connect(stateDir: string, hello: Hello, timeoutMs = 3000, protocol
|
|
132
|
-
if (!RECOVERY_SOURCE_PROTOCOLS.includes(protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number])) return Promise.reject(new Error(`unsupported recovery source protocol ${protocol}`));
|
|
146
|
+
static connect(stateDir: string, hello: Hello, timeoutMs = 3000, protocol?: number): Promise<ControlClient> {
|
|
147
|
+
if (protocol !== undefined && !RECOVERY_SOURCE_PROTOCOLS.includes(protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number])) return Promise.reject(new Error(`unsupported recovery source protocol ${protocol}`));
|
|
133
148
|
const control = readControl(stateDir);
|
|
134
149
|
if (!control) return Promise.reject(new Error(`no hub running for ${stateDir} (run: ahub up)`));
|
|
135
150
|
const pin = instancePins.get(resolve(stateDir));
|
|
136
151
|
if (pin && (control.instanceId !== pin.instanceId || hello.instanceId !== undefined && hello.instanceId !== pin.instanceId)) {
|
|
137
152
|
return Promise.reject(new Error("delegated audit consumer changed; refusing a connection to its replacement"));
|
|
138
153
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
154
|
+
const local = protocolWindow(protocol ?? PROTOCOL, protocol ?? PROTOCOL_MIN)!;
|
|
155
|
+
const versioned = control.protocol !== undefined || control.protocolMin !== undefined || control.protocolMax !== undefined;
|
|
156
|
+
const remote = versioned ? controlProtocolWindow(control) : undefined;
|
|
157
|
+
const selected = versioned ? remote && negotiateProtocol(local, remote) : protocol ?? PROTOCOL_MIN;
|
|
158
|
+
const mismatch = () => Object.assign(new Error(`wire version mismatch: hub speaks ${control.protocolMax ?? control.protocol}, CLI speaks ${protocol ?? PROTOCOL}; stop it with its matching CLI, then upgrade and restart`), { code: 4426 });
|
|
159
|
+
if (selected === undefined) return Promise.reject(mismatch());
|
|
142
160
|
if ((hello.projectId && hello.projectId !== control.projectId) ||
|
|
143
161
|
(hello.instanceId && hello.instanceId !== control.instanceId) ||
|
|
144
162
|
(hello.projectRoot && hello.projectRoot !== control.cwd)) {
|
|
@@ -169,13 +187,15 @@ export class ControlClient {
|
|
|
169
187
|
client.pending.delete(msg.rid);
|
|
170
188
|
done(msg);
|
|
171
189
|
};
|
|
172
|
-
ws.onopen = () => void client.request({ t: "hello",
|
|
190
|
+
ws.onopen = () => void client.request({ t: "hello", token: control.token, ...hello, ...expected, v: selected, vMin: remote?.vMin === undefined ? selected : Math.max(local.vMin, remote.vMin) }, timeoutMs).then((reply) => {
|
|
173
191
|
if (reply.t !== "welcome" || reply.ok === false) return refuse(new Error(reply.error ?? "hub refused handshake"));
|
|
174
192
|
if ((expected.projectId && reply.projectId !== expected.projectId) ||
|
|
175
193
|
(expected.instanceId && reply.instanceId !== expected.instanceId) ||
|
|
176
194
|
(expected.projectRoot && reply.cwd !== expected.projectRoot)) {
|
|
177
195
|
return refuse(Object.assign(new Error("connected hub has a different project or instance"), { code: 4404 }));
|
|
178
196
|
}
|
|
197
|
+
if (reply.protocol !== undefined && reply.protocol !== selected) return refuse(mismatch());
|
|
198
|
+
client.protocol = selected;
|
|
179
199
|
client.features = Object.freeze(Array.isArray(reply.features) ? reply.features.filter((feature: unknown): feature is string => typeof feature === "string") : []);
|
|
180
200
|
client.welcomeInstanceId = typeof reply.instanceId === "string" && reply.instanceId ? reply.instanceId : undefined;
|
|
181
201
|
client.sessionId = typeof reply.session === "string" && reply.session.length <= 64 ? reply.session : undefined;
|
package/src/hub/daemon.ts
CHANGED
|
@@ -174,9 +174,11 @@ import { githubOriginRepository, githubRemoteRepository } from "./task-repositor
|
|
|
174
174
|
import { Bus } from "./bus.ts";
|
|
175
175
|
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
176
176
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
177
|
+
import { assertHubDbCompatible } from "./db-schema.ts";
|
|
177
178
|
import { startDashboard, dashboardView, type DashboardSession } from "./ui.ts";
|
|
178
179
|
import { codeGroup } from "./task-code-group.ts";
|
|
179
|
-
import { ControlClient, DAEMON_FEATURES, PROTOCOL, readControl, stateDirFor } from "./control-client.ts";
|
|
180
|
+
import { ControlClient, DAEMON_FEATURES, PROTOCOL, PROTOCOL_MIN, readControl, stateDirFor } from "./control-client.ts";
|
|
181
|
+
import { negotiateProtocol, protocolWindow } from "./protocol-window.ts";
|
|
180
182
|
import { attachEvent, originOf, type Origin } from "./provenance.ts";
|
|
181
183
|
import { mintSessionId, sessionGate } from "./session-identity.ts";
|
|
182
184
|
import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
|
|
@@ -185,6 +187,7 @@ import { ClaudeTodoSource, DoingTracker, readAiTitle, type DoingKind, type PeerD
|
|
|
185
187
|
import { MemoryClient } from "../memory/client.ts";
|
|
186
188
|
import { VERSION } from "../version.ts";
|
|
187
189
|
import { appendRecords, taskRecords } from "./research.ts";
|
|
190
|
+
import { parseResearchRunners, type RegisteredRunnerV1 } from "./research-runner.ts";
|
|
188
191
|
import { benchReport, MIN_ATTEMPTS, readRuns, runSummary, type Measures, type RunSummary } from "./bench.ts";
|
|
189
192
|
import { projectChain } from "@staix/agent-mem";
|
|
190
193
|
import { recallFor } from "../memory/recall.ts";
|
|
@@ -199,6 +202,7 @@ import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContin
|
|
|
199
202
|
import { shellQuote, publishTerminalLaunch, terminalLaunchCandidate, launcherOf } from "../cli/terminal-recovery.ts";
|
|
200
203
|
import { TerminalLaunches, terminalOwnerReads } from "./terminal-launch.ts";
|
|
201
204
|
|
|
205
|
+
import { START_AGENTS_DEFAULT, checkStartAgents } from "./start-agents.ts";
|
|
202
206
|
import { peerStartConfig, PEER_START_RETRY_MS, startModeOf, terminalTemplate, type PeerStartConfig, type StartMode } from "./start-mode.ts";
|
|
203
207
|
import { hasKimiHubMcp } from "../cli/kimi-mcp.ts";
|
|
204
208
|
import type { TerminalOpener } from "./terminal-open.ts";
|
|
@@ -281,13 +285,15 @@ export interface HubConfig {
|
|
|
281
285
|
/** A git tree at each turn boundary for `ahub turns` and `ahub undo`, the last `keep` per peer (issue #33). */
|
|
282
286
|
snapshots: { enabled: boolean; keep: number };
|
|
283
287
|
/** #247: opt-in research records of each approved task in ~/.agenthub/research (ids, counts and tokens only). */
|
|
284
|
-
research: { enabled: boolean };
|
|
288
|
+
research: { enabled: boolean; runners?: Record<string, RegisteredRunnerV1> };
|
|
285
289
|
/** #251: a project kept for benchmarks: `ahub bench run` resets its work tree between attempts. */
|
|
286
290
|
bench: { enabled: boolean };
|
|
287
291
|
/** #269: per peer, how a start comes up when nothing more specific was asked: `tui` (the default) or `headless`. Pi and Codex only. */
|
|
288
292
|
peers: PeerStartConfig;
|
|
289
293
|
/** #269: a machine-local argv that opens a terminal for a hub-made TUI start; `{command}` in one element is the command to run. */
|
|
290
294
|
terminal: { open: string[] };
|
|
295
|
+
/** #608: the peers `ahub up` asks the hub to start, machine-local. Read by the CLI at a person's `ahub up` and by the dashboard manager's start; the daemon itself never starts one from it. */
|
|
296
|
+
start: { agents: string[] };
|
|
291
297
|
/** Per-sender rate limits and repeat suppression for what agents send (issue #38). */
|
|
292
298
|
limits: LimitsConfig;
|
|
293
299
|
/** Reviewer choice from recorded review outcomes, once a reviewer has `min_reviews` of an implementer (issue #35). */
|
|
@@ -353,6 +359,7 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
353
359
|
bench: { enabled: false },
|
|
354
360
|
peers: {},
|
|
355
361
|
terminal: { open: [] },
|
|
362
|
+
start: { agents: [...START_AGENTS_DEFAULT] },
|
|
356
363
|
limits: DEFAULT_LIMITS,
|
|
357
364
|
review: { adaptive: false, min_reviews: 5 },
|
|
358
365
|
recovery: { auto_resume_after_crash: false, reconnect_wait_s: RECOVERY_RECONNECT_WAIT_SETTING.defaultValue, unmanaged_reconnect_wait_s: RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING.defaultValue, control_timeout_s: RECOVERY_CONTROL_TIMEOUT_SETTING.defaultValue, shutdown_wait_s: RECOVERY_SHUTDOWN_WAIT_SETTING.defaultValue, terminal_idle_wait_s: RECOVERY_TERMINAL_IDLE_WAIT_SETTING.defaultValue },
|
|
@@ -370,7 +377,22 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
|
370
377
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
371
378
|
const CONFIG_FILES = INSTANCE_CONFIG_FILES;
|
|
372
379
|
export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
|
|
373
|
-
const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "adapter_timing", "model_relay", "console", "corpus", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
|
|
380
|
+
const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "adapter_timing", "model_relay", "console", "corpus", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "start", "pet"];
|
|
381
|
+
|
|
382
|
+
/** Older targets parse raw keys before placement/trust checks, so even an empty or refused declaration needs the floor. */
|
|
383
|
+
function rawResearchRunnersPresent(cwd: string): boolean {
|
|
384
|
+
const texts = configSourceTexts(cwd);
|
|
385
|
+
let present = false;
|
|
386
|
+
for (const name of CONFIG_FILES) {
|
|
387
|
+
if (texts[name] === null) continue;
|
|
388
|
+
const raw: unknown = JSON.parse(texts[name]!);
|
|
389
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid configuration");
|
|
390
|
+
const research = (raw as Record<string, unknown>).research;
|
|
391
|
+
if (research !== undefined && (!research || typeof research !== "object" || Array.isArray(research))) throw new Error("invalid research configuration");
|
|
392
|
+
present ||= research !== undefined && Object.hasOwn(research, "runners");
|
|
393
|
+
}
|
|
394
|
+
return present;
|
|
395
|
+
}
|
|
374
396
|
|
|
375
397
|
/** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
|
|
376
398
|
export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[], scope: Pick<ReturnType<typeof readInstanceConfig>, "instances" | "catalog"> = readInstanceConfig(cwd, layers)): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string | string[]; notices?: string[] } {
|
|
@@ -478,6 +500,8 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
478
500
|
peerStartConfig(file.peers, instances, adapterCatalog);
|
|
479
501
|
if (file.terminal != null && (typeof file.terminal !== "object" || Array.isArray(file.terminal))) throw new Error("terminal must be an object");
|
|
480
502
|
terminalTemplate(file.terminal?.open);
|
|
503
|
+
if (file.start != null && (typeof file.start !== "object" || Array.isArray(file.start) || Object.keys(file.start).some((key: string) => key !== "agents"))) throw new Error("start must be an object with an agents list");
|
|
504
|
+
if (file.start?.agents !== undefined) checkStartAgents(file.start.agents);
|
|
481
505
|
if (file.pet !== undefined) validatePetConfig(file.pet, "config.pet");
|
|
482
506
|
if (file.pet && typeof file.pet === "object" && !Object.keys(file.pet).length) delete file.pet;
|
|
483
507
|
delete file.declaredExternalInstances; // host-derived refusal rows, never a file claim
|
|
@@ -487,8 +511,11 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
487
511
|
if (name !== "config.local.json" || configTracked(cwd, name) !== false) throw new Error("remote checks require untracked config.local.json");
|
|
488
512
|
if (file.checks.remote !== undefined) remoteRunnerConfig(file.checks.remote);
|
|
489
513
|
}
|
|
514
|
+
if (file.research !== undefined && (!file.research || typeof file.research !== "object" || Array.isArray(file.research))) throw new Error("research must be an object");
|
|
515
|
+
const hasRunners = file.research && Object.hasOwn(file.research, "runners"), runnerDeclarations: unknown = hasRunners ? file.research.runners : undefined;
|
|
490
516
|
const why = stripUntrusted(file, DEFAULT_CONFIG, cwd, name);
|
|
491
517
|
if (why) ignored.push(why);
|
|
518
|
+
if (hasRunners) file.research.runners = parseResearchRunners(runnerDeclarations);
|
|
492
519
|
for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes, instances, adapterCatalog))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
|
|
493
520
|
return [file];
|
|
494
521
|
});
|
|
@@ -575,10 +602,11 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
575
602
|
checks: { ...DEFAULT_CONFIG.checks, ...file.checks, runner: file.checks?.runner === "remote" ? "remote" : file.checks?.runner === "container" ? "container" : undefined,
|
|
576
603
|
...(file.checks?.remote !== undefined || file.checks?.runner === "remote" ? { remote: remoteRunnerConfig(file.checks?.remote) } : {}) },
|
|
577
604
|
snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
|
|
578
|
-
research: { enabled: file.research?.enabled === true }, // opt-in: anything but true is off
|
|
605
|
+
research: { enabled: file.research?.enabled === true, ...(file.research?.runners !== undefined ? { runners: parseResearchRunners(file.research.runners) } : {}) }, // opt-in: anything but true is off
|
|
579
606
|
bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
|
|
580
607
|
peers: { ...instanceModes, ...peerStartConfig(file.peers, instances, adapterCatalog) },
|
|
581
608
|
terminal: { open: terminalTemplate(file.terminal?.open) },
|
|
609
|
+
start: { agents: file.start?.agents === undefined ? [...START_AGENTS_DEFAULT] : checkStartAgents(file.start.agents) },
|
|
582
610
|
limits: { ...PROJECT_LIMITS, ...file.limits }, // on with any project config (issue #38)
|
|
583
611
|
review: { ...DEFAULT_CONFIG.review, ...file.review },
|
|
584
612
|
recovery: { ...DEFAULT_CONFIG.recovery, ...file.recovery },
|
|
@@ -698,6 +726,7 @@ export interface DaemonOptions {
|
|
|
698
726
|
|
|
699
727
|
interface Client {
|
|
700
728
|
authed: boolean;
|
|
729
|
+
protocol?: number;
|
|
701
730
|
role?: "peer" | "console" | "tools";
|
|
702
731
|
peer?: PeerId;
|
|
703
732
|
/** agent-hub#1036: this connection is a distinct session; `peer` is the id the daemon minted for it (`<peer>~<origin>`), never a bus peer. */
|
|
@@ -747,6 +776,8 @@ function capturedRecoverySourceDigest(): string | undefined {
|
|
|
747
776
|
}
|
|
748
777
|
|
|
749
778
|
export async function startDaemon(opts: DaemonOptions) {
|
|
779
|
+
// Refuse unknown formats before logs, session cleanup, module factories or any writable store is opened.
|
|
780
|
+
assertHubDbCompatible(join(opts.stateDir, "hub.db"));
|
|
750
781
|
const resolveExecutionCwd = (peer: string, currentDeclarations: InstancesConfig | undefined, currentRoles: readonly string[], catalog: ReturnType<typeof createProjectAdapterCatalog>): string =>
|
|
751
782
|
resolveInstanceExecutionCwd(opts.cwd, peer, currentDeclarations, currentRoles, catalog, undefined, undefined, opts.instanceExecutionGit);
|
|
752
783
|
const projectId = opts.projectId ?? randomUUID();
|
|
@@ -2048,6 +2079,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2048
2079
|
};
|
|
2049
2080
|
const tasks: Tasks = new Tasks({
|
|
2050
2081
|
releaseWindow,
|
|
2082
|
+
researchRunners: () => {
|
|
2083
|
+
if (config.research.runners === undefined) return {};
|
|
2084
|
+
const why = configRefusal(opts.cwd, "config.local.json");
|
|
2085
|
+
if (why || configSourceTexts(opts.cwd)["config.local.json"] === null) throw new Error(`research.runners source trust unavailable${why ? `: ${why}` : ""}`);
|
|
2086
|
+
return parseResearchRunners(config.research.runners);
|
|
2087
|
+
},
|
|
2051
2088
|
capabilities: peerCapabilities, repository: memoryRepo,
|
|
2052
2089
|
contextAdmission: () => ({
|
|
2053
2090
|
policy: { max_used: config.context.max_assignment_used ?? 0, stale_min: config.context.stale_min },
|
|
@@ -4400,6 +4437,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4400
4437
|
const metadata = peer.recoveryMetadata?.() ?? {};
|
|
4401
4438
|
const row: RestartPeerSnapshot = { id, state: peer.state, queueIds: bus.queueIds(id), ...(metadata.launch ? { launch: metadata.launch as Record<string, unknown> } : {}) };
|
|
4402
4439
|
if (typeof metadata.threadId === "string") row.threadId = metadata.threadId;
|
|
4440
|
+
if (typeof metadata.controlProtocol === "number" && Number.isSafeInteger(metadata.controlProtocol) && metadata.controlProtocol > 0) row.controlProtocol = metadata.controlProtocol;
|
|
4403
4441
|
const sessionId = definitionOf(id)?.transport === "claude-channel" ? claudeSession(id).sessionId : metadata.sessionId;
|
|
4404
4442
|
if (typeof sessionId === "string" && sessionId) row.sessionId = sessionId;
|
|
4405
4443
|
return [id, row];
|
|
@@ -4422,7 +4460,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4422
4460
|
const researchDispatch = board.researchDispatchIntegrity();
|
|
4423
4461
|
const researchExperiments = board.researchExperimentsIntegrity();
|
|
4424
4462
|
const researchComparisons = board.researchComparisonIntegrity();
|
|
4425
|
-
|
|
4463
|
+
const researchRunners = board.runnerReceiptIntegrity();
|
|
4464
|
+
return { queues, manualPaused: [...manualPaused].sort(), boardDigest: digest(boardState), budgetDigest: digest(budgetState), ...(research.campaigns || research.units ? { research } : {}), ...(researchDispatch.activations || researchDispatch.handoffs ? { researchDispatch } : {}), ...(researchExperiments.protocols || researchExperiments.attempts || researchExperiments.events || researchExperiments.observations ? { researchExperiments } : {}), ...(researchComparisons.plans || researchComparisons.approvals || researchComparisons.contexts ? { researchComparisons } : {}), ...(researchRunners.count ? { researchRunners } : {}), ...(allExternalInstanceIds().length ? { externalBindingsDigest: digest(allExternalInstanceIds().sort().map(id => [id, (externalRefusedRows.has(id) ? externalBindings.get(id) : retainedExternalBinding(id)) ?? null])) } : {}) };
|
|
4426
4465
|
};
|
|
4427
4466
|
/** The integrity in the shape `expected` was recorded in: the current one unless only an older shape matches. */
|
|
4428
4467
|
const integrityAs = (expected: unknown) => {
|
|
@@ -4512,7 +4551,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4512
4551
|
...(lifecycleConfig.mode !== "off" ? { lifecycleCriteria: true } : {}),
|
|
4513
4552
|
// Every supported peer's membership, for launchers that run outside the console role (#484).
|
|
4514
4553
|
roster: [...KNOWN_PEERS, ...OPTIONAL_PEERS].map(peer => ({ peer, on: onRoster(peer) })),
|
|
4515
|
-
protocol: PROTOCOL,
|
|
4554
|
+
protocol: PROTOCOL_MIN, protocolMin: PROTOCOL_MIN, protocolMax: PROTOCOL,
|
|
4516
4555
|
features: DAEMON_FEATURES,
|
|
4517
4556
|
configuredAdapters: configuredIds,
|
|
4518
4557
|
...(externalAdapters.length ? { externalAdapters } : {}),
|
|
@@ -6708,6 +6747,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6708
6747
|
// Retired dictionaries never enter active publication; hub ids remain opaque keys.
|
|
6709
6748
|
if (prefix === "routing.targets" || prefix === "routing.routes") return [];
|
|
6710
6749
|
if (prefix === "routing.hub_routes") return [prefix];
|
|
6750
|
+
if (prefix === "research.runners") return [prefix];
|
|
6711
6751
|
if (value && typeof value === "object" && !Array.isArray(value)) return Object.entries(value).flatMap(([key, child]) => declaredPaths(child, prefix ? `${prefix}.${key}` : key));
|
|
6712
6752
|
return prefix ? [prefix] : [];
|
|
6713
6753
|
}
|
|
@@ -7586,6 +7626,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7586
7626
|
// Raw presence matters even for a disabled or ignored shared declaration: an older target parses the unknown key first.
|
|
7587
7627
|
if (namesRunners) return "target lacks research_records; the project's research.runners cannot be loaded by that daemon";
|
|
7588
7628
|
}
|
|
7629
|
+
if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_runners")) {
|
|
7630
|
+
if (board.hasRunnerReceipts()) return "target lacks research_runners; persisted runner attestations require a supporting daemon";
|
|
7631
|
+
try {
|
|
7632
|
+
if (config.research.runners !== undefined || rawResearchRunnersPresent(opts.cwd)) return "target lacks research_runners; the project's research.runners requires a supporting daemon";
|
|
7633
|
+
} catch { return "research_runners compatibility is unknown; configuration could not be read"; }
|
|
7634
|
+
}
|
|
7589
7635
|
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_dispatch")) && board.hasResearchDispatchState())
|
|
7590
7636
|
return "target lacks research_dispatch; persisted campaign stop policies and handoffs require a supporting daemon";
|
|
7591
7637
|
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_dropped_offers")) && board.hasResearchDroppedOffers())
|
|
@@ -7637,8 +7683,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7637
7683
|
} catch { return "calendar_schedules compatibility is unknown; configuration could not be read"; }
|
|
7638
7684
|
if (namesCalendar) return "target lacks calendar_schedules; the project's schedules use cron or tz, which that daemon cannot load";
|
|
7639
7685
|
}
|
|
7686
|
+
if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("schema_guard"))
|
|
7687
|
+
return "target lacks schema_guard; controlled recovery requires a guard-aware runtime";
|
|
7640
7688
|
return undefined;
|
|
7641
7689
|
};
|
|
7690
|
+
// Preserve lifecycle fences before capability probes; the prepare branch rechecks them after resume capture awaits.
|
|
7691
|
+
if (msg.op === "prepare") {
|
|
7692
|
+
if (rosterChanging) return recoveryError("roster change is in progress; wait for ahub status before preparing recovery");
|
|
7693
|
+
if (requestedPeerStops.size) return recoveryError("peer stop is in progress; wait for ahub status before preparing recovery");
|
|
7694
|
+
if (recoveryCommitted) return recoveryError("recovery commit is already in progress");
|
|
7695
|
+
}
|
|
7642
7696
|
const checkTarget = msg.op === "prepare" || msg.op === "commit" || msg.op === "inspect" && msg.targetFeatures !== undefined;
|
|
7643
7697
|
if (checkTarget) {
|
|
7644
7698
|
const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error);
|
|
@@ -7906,7 +7960,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7906
7960
|
if (!c.authed) {
|
|
7907
7961
|
if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
|
|
7908
7962
|
if (typeof msg.peer === "string" && msg.peer.includes("@")) return sock.close(4403, "remote identities have no control connection");
|
|
7909
|
-
|
|
7963
|
+
const offered = protocolWindow(msg.v, msg.vMin), selectedProtocol = offered && negotiateProtocol({ v: PROTOCOL, vMin: PROTOCOL_MIN }, offered);
|
|
7964
|
+
if (selectedProtocol === undefined) {
|
|
7910
7965
|
// An outdated plugin would drop every digest without a trace. Refuse it loudly instead.
|
|
7911
7966
|
log(`refused ${msg.role} ${msg.peer ?? ""}: wire version ${msg.v ?? 1}, hub speaks ${PROTOCOL} (claude plugin update agent-hub@agent-hub)`);
|
|
7912
7967
|
return sock.close(4426, `wire version mismatch: hub speaks ${PROTOCOL}; update the agent-hub plugin`);
|
|
@@ -7917,6 +7972,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7917
7972
|
try { refreshConductorPolicy(); } catch { return sock.close(4403, "invalid conductor role/feed configuration"); }
|
|
7918
7973
|
if (stopping && msg.role !== "console") return sock.close(1013, "hub is stopping");
|
|
7919
7974
|
c.authed = true;
|
|
7975
|
+
c.protocol = selectedProtocol;
|
|
7920
7976
|
c.role = msg.role;
|
|
7921
7977
|
if (c.role === "console") consoles.add(sock);
|
|
7922
7978
|
else if (c.role === "tools" && sessionHello(msg)) {
|
|
@@ -8003,7 +8059,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8003
8059
|
}
|
|
8004
8060
|
// #604 PR B: one attach event per admitted peer or tools hello, with the same fields an envelope's `origin` carries. A refused hello returned above and writes none.
|
|
8005
8061
|
if (c.role === "peer" || c.role === "tools") event(attachEvent({ role: c.role, peer: c.peer, launchId: c.launchId, ...(c.session ? { session: true } : {}) }));
|
|
8006
|
-
return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL, features: DAEMON_FEATURES, ...(c.session ? { session: c.peer } : {}) });
|
|
8062
|
+
return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: c.protocol, protocolMin: PROTOCOL_MIN, protocolMax: PROTOCOL, features: DAEMON_FEATURES, ...(c.session ? { session: c.peer } : {}) });
|
|
8007
8063
|
}
|
|
8008
8064
|
const committedSourceProof = c.role === "tools" && msg.t === "recovery_authority" && msg.op === "verify" && recoveryCommitted
|
|
8009
8065
|
&& msg.binding?.operationId === recoveryOperationId && msg.binding?.sourceInstanceId === instanceId;
|
|
@@ -8272,11 +8328,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8272
8328
|
const plan = msg.plan as UpgradePlan;
|
|
8273
8329
|
const { fingerprint, ...body } = plan ?? {} as UpgradePlan;
|
|
8274
8330
|
if (!plan || !["restart", "upgrade"].includes(plan.kind) || plan.projects.some(row => row.terminals.length > 0) || planFingerprint(body) !== fingerprint || fingerprint !== raw.planFingerprint || plan.kind !== raw.kind || existsSync(operationPath(raw.operationId))
|
|
8275
|
-
|| raw.sourceInstanceId !== instanceId || recoveryActive() || recoveryLock() || !plan.projects.some(row => row.project.id === projectId && row.project.root === opts.cwd && row.project.stateDir === opts.stateDir && row.source.instanceId === instanceId)) throw new RecoveryAuthorityError();
|
|
8331
|
+
|| raw.sourceInstanceId !== instanceId || recoveryActive() || recoveryLock(undefined, opts.cwd) || !plan.projects.some(row => row.project.id === projectId && row.project.root === opts.cwd && row.project.stateDir === opts.stateDir && row.source.instanceId === instanceId)) throw new RecoveryAuthorityError();
|
|
8332
|
+
const { recoveryScope } = await import("../cli/upgrade.ts"); current();
|
|
8333
|
+
const scope = recoveryScope(plan);
|
|
8334
|
+
if (scope !== undefined && scope !== opts.cwd) throw new RecoveryAuthorityError();
|
|
8276
8335
|
if (plan.kind === "restart" && plan.projects.length !== 1) throw new RecoveryAuthorityError();
|
|
8277
8336
|
if (plan.kind === "upgrade") {
|
|
8278
|
-
//
|
|
8337
|
+
// Scoped upgrades bind the source's one registered project; retained shared upgrades still cover every affected project.
|
|
8279
8338
|
const affected = registeredProjects().filter(project => {
|
|
8339
|
+
if (scope !== undefined) return project.root === scope;
|
|
8280
8340
|
const control = readControl(project.stateDir);
|
|
8281
8341
|
return control ? processLiveness(control.pid, control.pidSignature) !== "gone" : project.pid !== null && processLiveness(project.pid, project.pidSignature) !== "gone";
|
|
8282
8342
|
});
|
|
@@ -8289,23 +8349,26 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8289
8349
|
if (!Array.isArray(selected.reconnectOnly) || new Set(selected.reconnectOnly).size !== selected.reconnectOnly.length) throw new RecoveryAuthorityError();
|
|
8290
8350
|
for (const id of selected.reconnectOnly) {
|
|
8291
8351
|
const row = selected.source.peers.find(peer => peer.id === id), owner = bus.peers.get(id), definition = definitionOf(id);
|
|
8292
|
-
if (!row || selected.freshStart?.includes(id) || Object.keys(row).some(key => !["id", "state", "observation"].includes(key)) || !row.observation
|
|
8352
|
+
if (!row || selected.freshStart?.includes(id) || Object.keys(row).some(key => !["id", "state", "observation", "controlProtocol"].includes(key)) || !row.observation
|
|
8293
8353
|
|| Object.keys(row.observation).some(key => !["kind", "attached", "sessionDigest"].includes(key))
|
|
8294
8354
|
|| !isWsPeer(owner) || definition?.transport !== "claude-channel" || !owner.attached || owner.managedLaunchId || claudeLaunchProof.verified(id)
|
|
8355
|
+
|| !protocolWindow(row.controlProtocol) || owner.recoveryMetadata().controlProtocol !== row.controlProtocol
|
|
8295
8356
|
|| row.observation.kind !== definition.kind || row.observation.attached !== owner.attached || row.observation.sessionDigest !== recoverySessionDigest(claudeSession(id).sessionId)) throw new RecoveryAuthorityError();
|
|
8296
8357
|
const generation = owner.sessionGeneration;
|
|
8297
8358
|
const launcher = await launcherOf(id, opts.cwd, { stateDir: opts.stateDir, instanceId }); current();
|
|
8298
|
-
if (launcher && launcher.state !== "gone" || bus.peers.get(id) !== owner || owner.sessionGeneration !== generation || !owner.attached || owner.managedLaunchId || claudeLaunchProof.verified(id)
|
|
8359
|
+
if (launcher && launcher.state !== "gone" || bus.peers.get(id) !== owner || owner.sessionGeneration !== generation || !owner.attached || owner.managedLaunchId || claudeLaunchProof.verified(id)
|
|
8360
|
+
|| owner.recoveryMetadata().controlProtocol !== row.controlProtocol) throw new RecoveryAuthorityError();
|
|
8299
8361
|
reconnectCurrent.push(() => bus.peers.get(id) === owner && owner.sessionGeneration === generation && owner.attached && !owner.managedLaunchId && !claudeLaunchProof.verified(id)
|
|
8362
|
+
&& owner.recoveryMetadata().controlProtocol === row.controlProtocol
|
|
8300
8363
|
&& definitionOf(id)?.kind === row.observation!.kind && owner.attached === row.observation!.attached && recoverySessionDigest(claudeSession(id).sessionId) === row.observation!.sessionDigest);
|
|
8301
8364
|
reconnect.set(id, row.observation);
|
|
8302
8365
|
}
|
|
8303
8366
|
}
|
|
8304
8367
|
if (reconnectCurrent.some(check => !check())) throw new RecoveryAuthorityError();
|
|
8305
|
-
const normalize = (row: { id: string; state: string; observation?: { kind: string; attached: boolean; sessionDigest?: string }; threadId?: string; sessionId?: string; sessionFile?: string; args?: Record<string, string>; launch?: Record<string, unknown> }) => {
|
|
8368
|
+
const normalize = (row: { id: string; state: string; controlProtocol?: number; observation?: { kind: string; attached: boolean; sessionDigest?: string }; threadId?: string; sessionId?: string; sessionFile?: string; args?: Record<string, string>; launch?: Record<string, unknown> }) => {
|
|
8306
8369
|
const launch = row.args ?? row.launch ?? {};
|
|
8307
8370
|
const observed = reconnect.get(row.id);
|
|
8308
|
-
return { id: row.id, active: row.state !== "offline", threadId: row.threadId, sessionId: observed ? undefined : row.sessionId,
|
|
8371
|
+
return { id: row.id, active: row.state !== "offline", controlProtocol: row.controlProtocol, threadId: row.threadId, sessionId: observed ? undefined : row.sessionId,
|
|
8309
8372
|
...(observed ? { sessionDigest: row.observation?.sessionDigest ?? recoverySessionDigest(row.sessionId) } : {}),
|
|
8310
8373
|
...(observed ? { kind: row.observation?.kind ?? definitionOf(row.id)?.kind, attached: row.observation?.attached ?? (isWsPeer(bus.peers.get(row.id)) && (bus.peers.get(row.id) as import("../adapters/channel-peer.ts").WsPeer).attached) } : {}),
|
|
8311
8374
|
sessionFile: row.sessionFile ?? (typeof launch.sessionFile === "string" ? launch.sessionFile : undefined),
|
|
@@ -8319,9 +8382,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8319
8382
|
if (raw.kind === "restart") {
|
|
8320
8383
|
if (!sourcePackageDigest || raw.targetDigest !== sourcePackageDigest) throw new RecoveryAuthorityError();
|
|
8321
8384
|
} else {
|
|
8322
|
-
const staged = join(hubHome(), "releases", plan.version);
|
|
8323
|
-
|
|
8324
|
-
|
|
8385
|
+
const staged = join(scope === undefined ? hubHome() : realPath(hubHome()), "releases", plan.version);
|
|
8386
|
+
const expectedTarget = scope === undefined ? join(staged, "node_modules/@staix/agent-hub") : join(staged, `source-${raw.targetDigest}`);
|
|
8387
|
+
if (raw.targetRoot !== expectedTarget) throw new RecoveryAuthorityError();
|
|
8388
|
+
// A new/expired grant still needs registry proof; verification of an already issued grant below uses only retained target bytes.
|
|
8389
|
+
const verified = JSON.parse(readFileSync(join(scope === undefined ? staged : join(staged, "registry"), "verified.json"), "utf8"));
|
|
8325
8390
|
if (verified.digest !== raw.targetDigest || verified.integrity !== plan.integrity) throw new RecoveryAuthorityError();
|
|
8326
8391
|
}
|
|
8327
8392
|
if (!sourcePackageDigest || plan.sourceDigest !== sourcePackageDigest && (plan.kind !== "upgrade" || plan.sourceDigest !== raw.targetDigest)) throw new RecoveryAuthorityError();
|