@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +35 -8
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +2 -1
  5. package/docs/agent-notes/bus.md +1 -0
  6. package/docs/agent-notes/daemon.md +18 -3
  7. package/docs/agent-notes/tasks.md +12 -4
  8. package/docs/agent-notes/tests.md +3 -1
  9. package/docs/dogfood.md +16 -2
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +264 -14
  12. package/docs/profiles.md +2 -0
  13. package/docs/quickstart.md +23 -6
  14. package/docs/security.md +198 -3
  15. package/docs/specs/2026-09-19-agent-hub-design.md +25 -1
  16. package/docs/specs/2026-09-19-multi-project-design.md +12 -1
  17. package/docs/specs/2026-11-01-research-campaigns-design.md +354 -0
  18. package/docs/verification/2026-11-01-0.25.0.md +91 -0
  19. package/docs/verification/2026-11-01-0.26.0.md +54 -0
  20. package/docs/verified.json +45 -17
  21. package/package.json +4 -3
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +5294 -4797
  24. package/src/adapters/channel-peer.ts +4 -2
  25. package/src/adapters/claude-channel.ts +29 -6
  26. package/src/adapters/definitions/builtin-metadata.ts +6 -6
  27. package/src/adapters/definitions/native-cli-doctor.ts +30 -0
  28. package/src/adapters/definitions/qwen.ts +2 -0
  29. package/src/adapters/jcode.ts +3 -0
  30. package/src/adapters/registry-types.ts +2 -0
  31. package/src/adapters/registry.ts +2 -1
  32. package/src/cli/acon-register.ts +29 -0
  33. package/src/cli/conductor-recovery.ts +3 -2
  34. package/src/cli/handoff-args.ts +27 -0
  35. package/src/cli/handoff-output.ts +25 -0
  36. package/src/cli/help.ts +11 -8
  37. package/src/cli/identity.ts +7 -1
  38. package/src/cli/main.ts +138 -30
  39. package/src/cli/output.ts +8 -5
  40. package/src/cli/recovery-package.ts +94 -4
  41. package/src/cli/reset.ts +14 -4
  42. package/src/cli/runtime-version-warning.ts +28 -0
  43. package/src/cli/schedule-output.ts +8 -4
  44. package/src/cli/start-agents-run.ts +125 -0
  45. package/src/cli/study-output.ts +71 -0
  46. package/src/cli/study.ts +58 -0
  47. package/src/cli/task-output.ts +6 -0
  48. package/src/cli/upgrade-interactive.ts +4 -4
  49. package/src/cli/upgrade-runtime.ts +105 -57
  50. package/src/cli/upgrade.ts +90 -28
  51. package/src/hub/autonomy-defaults.ts +32 -7
  52. package/src/hub/board.ts +993 -8
  53. package/src/hub/budget.ts +3 -4
  54. package/src/hub/channel-project.ts +61 -0
  55. package/src/hub/child-process.ts +4 -3
  56. package/src/hub/conductor.ts +3 -4
  57. package/src/hub/config-apply.ts +13 -2
  58. package/src/hub/config-edit-host.ts +1 -0
  59. package/src/hub/config-tier.ts +3 -0
  60. package/src/hub/config-trust.ts +10 -2
  61. package/src/hub/control-client.ts +39 -10
  62. package/src/hub/daemon.ts +326 -25
  63. package/src/hub/db-schema.ts +143 -0
  64. package/src/hub/delivery-journal.ts +3 -4
  65. package/src/hub/envelope.ts +2 -0
  66. package/src/hub/events.ts +4 -1
  67. package/src/hub/execution-budget.ts +15 -4
  68. package/src/hub/folder-trigger.ts +3 -4
  69. package/src/hub/handoff.ts +126 -0
  70. package/src/hub/hub-tools.ts +12 -2
  71. package/src/hub/invocations.ts +3 -4
  72. package/src/hub/lifecycle.ts +87 -29
  73. package/src/hub/manager.ts +110 -87
  74. package/src/hub/no-writes.ts +1 -1
  75. package/src/hub/orca-env.ts +19 -0
  76. package/src/hub/path-policy.ts +12 -8
  77. package/src/hub/project-runtime-state.ts +139 -0
  78. package/src/hub/project-runtime.ts +62 -0
  79. package/src/hub/project.ts +14 -5
  80. package/src/hub/protocol-window.ts +19 -0
  81. package/src/hub/provenance.ts +30 -0
  82. package/src/hub/recovery-store.ts +102 -12
  83. package/src/hub/registry.ts +5 -0
  84. package/src/hub/research-campaign.ts +265 -0
  85. package/src/hub/research-experiments.ts +474 -0
  86. package/src/hub/research-runner.ts +172 -0
  87. package/src/hub/restart.ts +35 -0
  88. package/src/hub/runtime-capture-prune.ts +415 -0
  89. package/src/hub/runtime-prune-health.ts +112 -0
  90. package/src/hub/schedules.ts +213 -13
  91. package/src/hub/sdlc-merge.ts +7 -0
  92. package/src/hub/sdlc-queue.ts +13 -1
  93. package/src/hub/session-identity.ts +49 -0
  94. package/src/hub/settings.ts +4 -0
  95. package/src/hub/snapshots.ts +3 -4
  96. package/src/hub/start-agents.ts +44 -0
  97. package/src/hub/task-approval.ts +23 -0
  98. package/src/hub/tasks.ts +1005 -34
  99. package/src/hub/ui.ts +15 -6
  100. package/src/hub/usage-rollup.ts +3 -4
  101. package/src/memory/capture.ts +2 -2
  102. package/src/pi/tool-receipts.ts +3 -4
  103. package/src/ui/index.html +5 -3
  104. package/src/ui/office-space.ts +118 -0
  105. package/src/ui/plugin.ts +293 -0
  106. package/templates/AGENTS.block.md +1 -0
  107. package/templates/profiles/general.json +1 -1
  108. package/templates/profiles/research.json +4 -4
  109. package/templates/profiles/writing.json +1 -1
  110. package/templates/roles/research/analyst.md +1 -0
  111. package/templates/roles/research/fact-checker.md +1 -0
  112. package/templates/roles/research/research-lead.md +1 -0
  113. package/templates/roles/research/researcher.md +1 -0
  114. 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 "bun:sqlite";
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 = new Database(dbPath, { create: true });
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
+ }
@@ -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
- const env = childEnv(source);
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.
24
- for (const name of ["CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "AGENTHUB_CHANNEL", "AGENTHUB_PEER_ID", "AGENTHUB_UNATTENDED", "ORCA_TERMINAL_HANDLE", "ORCA_WORKTREE_ID"]) delete env[name];
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
 
@@ -1,5 +1,6 @@
1
1
  import { definitionOf, type KindMap, type ProjectAdapterCatalog } from "../adapters/registry.ts";
2
- import { Database } from "bun:sqlite";
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 = new Database(dbPath, { create: true });
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
  }
@@ -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 : {};
@@ -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`);
@@ -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 = 16;
12
- 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"] as const;
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 = PROTOCOL): Promise<ControlClient> {
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
- if (control.protocol !== undefined && control.protocol !== protocol) {
132
- return Promise.reject(Object.assign(new Error(`wire version mismatch: hub speaks ${control.protocol}, CLI speaks ${protocol}; stop it with its matching CLI, then upgrade and restart`), { code: 4426 }));
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", v: protocol, token: control.token, ...hello, ...expected }, timeoutMs).then((reply) => {
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
  });