@staix/agent-hub 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +35 -8
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +2 -1
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +18 -3
- package/docs/agent-notes/tasks.md +12 -4
- package/docs/agent-notes/tests.md +3 -1
- package/docs/dogfood.md +16 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +264 -14
- package/docs/profiles.md +2 -0
- package/docs/quickstart.md +23 -6
- package/docs/security.md +198 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +25 -1
- package/docs/specs/2026-09-19-multi-project-design.md +12 -1
- package/docs/specs/2026-11-01-research-campaigns-design.md +354 -0
- package/docs/verification/2026-11-01-0.25.0.md +91 -0
- package/docs/verification/2026-11-01-0.26.0.md +54 -0
- package/docs/verified.json +45 -17
- package/package.json +4 -3
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5294 -4797
- package/src/adapters/channel-peer.ts +4 -2
- package/src/adapters/claude-channel.ts +29 -6
- package/src/adapters/definitions/builtin-metadata.ts +6 -6
- package/src/adapters/definitions/native-cli-doctor.ts +30 -0
- package/src/adapters/definitions/qwen.ts +2 -0
- package/src/adapters/jcode.ts +3 -0
- package/src/adapters/registry-types.ts +2 -0
- package/src/adapters/registry.ts +2 -1
- package/src/cli/acon-register.ts +29 -0
- package/src/cli/conductor-recovery.ts +3 -2
- package/src/cli/handoff-args.ts +27 -0
- package/src/cli/handoff-output.ts +25 -0
- package/src/cli/help.ts +11 -8
- package/src/cli/identity.ts +7 -1
- package/src/cli/main.ts +138 -30
- package/src/cli/output.ts +8 -5
- 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/schedule-output.ts +8 -4
- package/src/cli/start-agents-run.ts +125 -0
- package/src/cli/study-output.ts +71 -0
- package/src/cli/study.ts +58 -0
- package/src/cli/task-output.ts +6 -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 +993 -8
- package/src/hub/budget.ts +3 -4
- package/src/hub/channel-project.ts +61 -0
- 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 +39 -10
- package/src/hub/daemon.ts +326 -25
- package/src/hub/db-schema.ts +143 -0
- package/src/hub/delivery-journal.ts +3 -4
- package/src/hub/envelope.ts +2 -0
- package/src/hub/events.ts +4 -1
- package/src/hub/execution-budget.ts +15 -4
- package/src/hub/folder-trigger.ts +3 -4
- package/src/hub/handoff.ts +126 -0
- package/src/hub/hub-tools.ts +12 -2
- package/src/hub/invocations.ts +3 -4
- package/src/hub/lifecycle.ts +87 -29
- package/src/hub/manager.ts +110 -87
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/orca-env.ts +19 -0
- package/src/hub/path-policy.ts +12 -8
- package/src/hub/project-runtime-state.ts +139 -0
- package/src/hub/project-runtime.ts +62 -0
- package/src/hub/project.ts +14 -5
- package/src/hub/protocol-window.ts +19 -0
- package/src/hub/provenance.ts +30 -0
- package/src/hub/recovery-store.ts +102 -12
- package/src/hub/registry.ts +5 -0
- package/src/hub/research-campaign.ts +265 -0
- package/src/hub/research-experiments.ts +474 -0
- package/src/hub/research-runner.ts +172 -0
- package/src/hub/restart.ts +35 -0
- package/src/hub/runtime-capture-prune.ts +415 -0
- package/src/hub/runtime-prune-health.ts +112 -0
- package/src/hub/schedules.ts +213 -13
- package/src/hub/sdlc-merge.ts +7 -0
- package/src/hub/sdlc-queue.ts +13 -1
- package/src/hub/session-identity.ts +49 -0
- 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 +1005 -34
- package/src/hub/ui.ts +15 -6
- package/src/hub/usage-rollup.ts +3 -4
- package/src/memory/capture.ts +2 -2
- package/src/pi/tool-receipts.ts +3 -4
- package/src/ui/index.html +5 -3
- package/src/ui/office-space.ts +118 -0
- package/src/ui/plugin.ts +293 -0
- package/templates/AGENTS.block.md +1 -0
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +4 -4
- package/templates/profiles/writing.json +1 -1
- package/templates/roles/research/analyst.md +1 -0
- package/templates/roles/research/fact-checker.md +1 -0
- package/templates/roles/research/research-lead.md +1 -0
- package/templates/roles/research/researcher.md +1 -0
- package/templates/roles/research/statistician.md +1 -0
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; }
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Which project and state directory the Claude channel plugin joins (#604 PR B, agent-hub#1030 item 10). It applies the same decision the CLI makes at startup: `projectContext` for an
|
|
2
|
+
// ordinary process, plus the one exception the CLI has for a declared extra instance, whose cwd is its own managed worktree and whose state directory is the main project's by design.
|
|
3
|
+
// Without this the plugin trusted an inherited AGENTHUB_STATE_DIR as is, so a Claude process started in `<repo>/.worktrees/<name>` that inherited the main checkout's variables joined the
|
|
4
|
+
// main repository's hub.
|
|
5
|
+
//
|
|
6
|
+
// Limit, stated plainly: this stops accidental and inherited-environment joins. It is not a defence against a same-user process that sets the variables and the launch evidence itself.
|
|
7
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
8
|
+
import { readInstanceConfig } from "./instance-config.ts";
|
|
9
|
+
import { effectiveInstanceRoles } from "./instances.ts";
|
|
10
|
+
import { resolveInstanceExecutionCwd } from "./instance-execution.ts";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { canonicalPath, inheritedStateDir, projectContext, projectRoot, realPath } from "./project.ts";
|
|
14
|
+
|
|
15
|
+
export interface ChannelProject {
|
|
16
|
+
root: string; stateDir: string;
|
|
17
|
+
/** True when the state directory came from the inherited environment, proven. */
|
|
18
|
+
inherited: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Set when this process has no project of its own and an inherited state directory that records another project (agent-hub#1036): it joins that hub as a distinct session, never as the
|
|
21
|
+
* peer id it names. `label` is the origin part of the id the hub mints (`<peer>~<label>`), or empty to let the hub choose a short one.
|
|
22
|
+
*/
|
|
23
|
+
session?: { label: string };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `cwd` is the plugin process's working directory, `env` its environment, `policyRoles(root)` the configured roles of a project (the same merge the plugin and the CLI use).
|
|
28
|
+
* Returns `undefined` only when there is nothing to join at all: no project of its own and no inherited state directory. Everything else joins something (agent-hub#1036: nothing that
|
|
29
|
+
* works today is refused): the proven project, the cwd's own project, or, when the process cannot prove it belongs to the hub its environment names, that hub as a distinct session.
|
|
30
|
+
*/
|
|
31
|
+
export function channelProject(cwd: string, env: NodeJS.ProcessEnv, policyRoles: (root: string) => Record<string, string[]>): ChannelProject | undefined {
|
|
32
|
+
const override = env.AGENTHUB_STATE_DIR?.trim(), declared = env.AGENTHUB_PROJECT_DIR?.trim();
|
|
33
|
+
// A tools-mode helper (`AGENTHUB_MODE=tools`) is started by the hub or by an agent the hub started, with the state dir and project dir set for exactly that purpose: the Claude implementer child and
|
|
34
|
+
// an external-module instance run in their own managed worktree and must reach the owning hub, as they always did. The daemon checks their launch id itself at the hello. The root check below is
|
|
35
|
+
// for a channel session, a person's `claude` that may merely have inherited a pair (review thread on #1037; agent-hub#1036 AC4).
|
|
36
|
+
if (env.AGENTHUB_MODE === "tools" && override && declared) return { root: canonicalPath(declared), stateDir: canonicalPath(override), inherited: true };
|
|
37
|
+
// The origin label is left empty on purpose: the hub mints a short id, so no directory name reaches a header another agent reads.
|
|
38
|
+
const asSession = (): ChannelProject | undefined => override
|
|
39
|
+
? { root: declared ? canonicalPath(declared) : "", stateDir: canonicalPath(override), inherited: false, session: { label: "" } } : undefined;
|
|
40
|
+
let own: ReturnType<typeof projectContext>;
|
|
41
|
+
try { own = projectContext(cwd, {}); } catch { return asSession(); }
|
|
42
|
+
const peer = env.AGENTHUB_PEER_ID;
|
|
43
|
+
if (peer && declared && !definitionOf(peer)) {
|
|
44
|
+
// A declared extra instance: its workspace is the exact managed worktree the project declares for it, and its hub is the project that declares it.
|
|
45
|
+
try {
|
|
46
|
+
const root = realPath(declared), config = readInstanceConfig(root);
|
|
47
|
+
if (config.instances && Object.hasOwn(config.instances, peer)) {
|
|
48
|
+
const workspace = resolveInstanceExecutionCwd(root, peer, config.instances, effectiveInstanceRoles(peer, config.instances, policyRoles(root), config.catalog), config.catalog);
|
|
49
|
+
if (realPath(projectRoot(cwd)) !== realPath(workspace)) return asSession();
|
|
50
|
+
const stateDir = inheritedStateDir(root, env);
|
|
51
|
+
return stateDir ? { root, stateDir, inherited: true } : asSession();
|
|
52
|
+
}
|
|
53
|
+
} catch { return asSession(); }
|
|
54
|
+
}
|
|
55
|
+
const inherited = inheritedStateDir(own.root, env);
|
|
56
|
+
if (inherited) return { root: own.root, stateDir: inherited, inherited: true };
|
|
57
|
+
// No project of its own (no .agenthub/config.json up to the git boundary) and an inherited state directory that is not proven to belong here: it joins that hub as a distinct session.
|
|
58
|
+
// With a project of its own (a linked worktree, say) it keeps that project's own hub, as before.
|
|
59
|
+
if (override && !existsSync(join(own.root, ".agenthub", "config.json"))) return asSession();
|
|
60
|
+
return { root: own.root, stateDir: own.stateDir, inherited: false };
|
|
61
|
+
}
|
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. */
|
|
@@ -42,6 +53,12 @@ export interface Hello {
|
|
|
42
53
|
launchId?: string;
|
|
43
54
|
/** Optional canonical native execution cwd; ownership requires independent host proof. */
|
|
44
55
|
cwd?: string;
|
|
56
|
+
/**
|
|
57
|
+
* agent-hub#1036: the plugin has no project of its own and its inherited state directory belongs to another project. It asks to join as a distinct session; the value is the origin label
|
|
58
|
+
* (possibly empty). The daemon mints `<peer>~<label>` for it and never binds the peer id it names. A daemon without the `session_identity` feature ignores the field, so a client checks
|
|
59
|
+
* `features` in `status.json` before sending it.
|
|
60
|
+
*/
|
|
61
|
+
session?: string;
|
|
45
62
|
/** Reclaim after replacement, never an intentional initial takeover. */
|
|
46
63
|
standby?: boolean;
|
|
47
64
|
projectId?: string;
|
|
@@ -56,6 +73,8 @@ export interface ControlDescriptor {
|
|
|
56
73
|
instanceId?: string;
|
|
57
74
|
cwd?: string;
|
|
58
75
|
protocol?: number;
|
|
76
|
+
protocolMin?: number;
|
|
77
|
+
protocolMax?: number;
|
|
59
78
|
pid?: number;
|
|
60
79
|
features?: readonly string[];
|
|
61
80
|
/** The daemon's accepted native start modes, used by launchers when the file value is retained. */
|
|
@@ -75,7 +94,7 @@ export function readControl(stateDir: string): ControlDescriptor | undefined {
|
|
|
75
94
|
? Object.fromEntries(Object.entries(status.startModes).filter((entry): entry is [string, "tui" | "headless"] => entry[1] === "tui" || entry[1] === "headless"))
|
|
76
95
|
: undefined;
|
|
77
96
|
return { url: `ws://127.0.0.1:${status.controlPort}`, token, projectId: status.projectId,
|
|
78
|
-
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") : [],
|
|
79
98
|
...(startModes ? { startModes } : {}),
|
|
80
99
|
...(status.externalAdapters !== undefined ? { externalAdapters: externalAdapterMetadata(status.externalAdapters) } : {}),
|
|
81
100
|
...(typeof status.pidSignature === "string" && status.pidSignature ? { pidSignature: status.pidSignature } : {}) };
|
|
@@ -97,6 +116,10 @@ export function controlRequestTimeout(msg: Record<string, unknown>, requested =
|
|
|
97
116
|
/** One authenticated control connection. `request` correlates replies by `rid`; everything else goes to `onPush`. */
|
|
98
117
|
export class ControlClient {
|
|
99
118
|
features: readonly string[] = [];
|
|
119
|
+
/** The id the hub minted for a distinct session (agent-hub#1036); absent for every ordinary connection. */
|
|
120
|
+
sessionId?: string;
|
|
121
|
+
/** Selected wire version, checked against the welcome when that legacy-optional field is present. */
|
|
122
|
+
protocol?: number;
|
|
100
123
|
private welcomeInstanceId?: string;
|
|
101
124
|
/** Identity from the authenticated welcome, never the unauthenticated descriptor alone. */
|
|
102
125
|
get instanceId(): string | undefined { return this.welcomeInstanceId; }
|
|
@@ -120,17 +143,20 @@ export class ControlClient {
|
|
|
120
143
|
}
|
|
121
144
|
|
|
122
145
|
/** `protocol` is used only by the upgrade coordinator for an authenticated, explicitly supported source contract. */
|
|
123
|
-
static connect(stateDir: string, hello: Hello, timeoutMs = 3000, protocol
|
|
124
|
-
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}`));
|
|
125
148
|
const control = readControl(stateDir);
|
|
126
149
|
if (!control) return Promise.reject(new Error(`no hub running for ${stateDir} (run: ahub up)`));
|
|
127
150
|
const pin = instancePins.get(resolve(stateDir));
|
|
128
151
|
if (pin && (control.instanceId !== pin.instanceId || hello.instanceId !== undefined && hello.instanceId !== pin.instanceId)) {
|
|
129
152
|
return Promise.reject(new Error("delegated audit consumer changed; refusing a connection to its replacement"));
|
|
130
153
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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());
|
|
134
160
|
if ((hello.projectId && hello.projectId !== control.projectId) ||
|
|
135
161
|
(hello.instanceId && hello.instanceId !== control.instanceId) ||
|
|
136
162
|
(hello.projectRoot && hello.projectRoot !== control.cwd)) {
|
|
@@ -161,15 +187,18 @@ export class ControlClient {
|
|
|
161
187
|
client.pending.delete(msg.rid);
|
|
162
188
|
done(msg);
|
|
163
189
|
};
|
|
164
|
-
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) => {
|
|
165
191
|
if (reply.t !== "welcome" || reply.ok === false) return refuse(new Error(reply.error ?? "hub refused handshake"));
|
|
166
192
|
if ((expected.projectId && reply.projectId !== expected.projectId) ||
|
|
167
193
|
(expected.instanceId && reply.instanceId !== expected.instanceId) ||
|
|
168
194
|
(expected.projectRoot && reply.cwd !== expected.projectRoot)) {
|
|
169
195
|
return refuse(Object.assign(new Error("connected hub has a different project or instance"), { code: 4404 }));
|
|
170
196
|
}
|
|
197
|
+
if (reply.protocol !== undefined && reply.protocol !== selected) return refuse(mismatch());
|
|
198
|
+
client.protocol = selected;
|
|
171
199
|
client.features = Object.freeze(Array.isArray(reply.features) ? reply.features.filter((feature: unknown): feature is string => typeof feature === "string") : []);
|
|
172
200
|
client.welcomeInstanceId = typeof reply.instanceId === "string" && reply.instanceId ? reply.instanceId : undefined;
|
|
201
|
+
client.sessionId = typeof reply.session === "string" && reply.session.length <= 64 ? reply.session : undefined;
|
|
173
202
|
clearTimeout(timer);
|
|
174
203
|
resolve(client);
|
|
175
204
|
});
|