@staix/agent-hub 0.12.42 → 0.12.44

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 (100) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +5 -4
  3. package/docs/adding-an-ai-tool.md +140 -0
  4. package/docs/agent-notes/README.md +5 -0
  5. package/docs/agent-notes/adapters.md +11 -4
  6. package/docs/agent-notes/budget.md +2 -2
  7. package/docs/agent-notes/bus.md +2 -0
  8. package/docs/agent-notes/daemon.md +15 -6
  9. package/docs/agent-notes/tasks.md +7 -2
  10. package/docs/agent-notes/tests.md +6 -2
  11. package/docs/operations.md +159 -26
  12. package/docs/quickstart.md +11 -3
  13. package/docs/sdlc.md +1 -1
  14. package/docs/security.md +7 -5
  15. package/docs/specs/2026-09-19-agent-hub-design.md +209 -13
  16. package/docs/verification/2026-10-21-0.12.43.md +50 -0
  17. package/docs/verification/2026-10-21-0.12.44.md +54 -0
  18. package/docs/verification/2026-10-21-agy-1.4.3-t0.md +65 -0
  19. package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
  20. package/docs/verification/2026-10-21-pii-screen.md +14 -0
  21. package/docs/verified.json +34 -19
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +808 -255
  25. package/src/adapters/acp-owned-group.ts +124 -0
  26. package/src/adapters/acp.ts +92 -25
  27. package/src/adapters/agy/definition.ts +1 -3
  28. package/src/adapters/agy/entry.ts +4 -1
  29. package/src/adapters/agy/hooks.ts +7 -2
  30. package/src/adapters/channel-peer.ts +143 -0
  31. package/src/adapters/claude-channel.ts +7 -7
  32. package/src/adapters/codex-appserver.ts +16 -2
  33. package/src/adapters/configured-acp.ts +124 -0
  34. package/src/adapters/definitions/builtin-metadata.ts +37 -33
  35. package/src/adapters/definitions/native-channel-state.ts +41 -0
  36. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -1
  37. package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
  38. package/src/adapters/definitions/native-identity-defaults.ts +14 -0
  39. package/src/adapters/definitions/qwen.ts +3 -8
  40. package/src/adapters/definitions/reviewer-variants.ts +3 -7
  41. package/src/adapters/jcode.ts +4 -0
  42. package/src/adapters/local-worker.ts +4 -0
  43. package/src/adapters/pi.ts +50 -16
  44. package/src/adapters/registry-types.ts +18 -16
  45. package/src/adapters/registry.ts +68 -6
  46. package/src/adapters/reviewer-print.ts +4 -0
  47. package/src/cli/agy-mcp.ts +3 -2
  48. package/src/cli/completion.ts +173 -0
  49. package/src/cli/configured-acp-entry.ts +58 -0
  50. package/src/cli/console-state.ts +116 -24
  51. package/src/cli/console.ts +9 -6
  52. package/src/cli/facts-hook.ts +2 -1
  53. package/src/cli/help.ts +8 -1
  54. package/src/cli/identity.ts +8 -7
  55. package/src/cli/launch.ts +5 -4
  56. package/src/cli/main.js +5 -1
  57. package/src/cli/main.ts +84 -27
  58. package/src/cli/memory.ts +13 -10
  59. package/src/cli/output.ts +1 -0
  60. package/src/cli/preview.ts +9 -0
  61. package/src/cli/recovery-guidance.ts +1 -1
  62. package/src/cli/terminal-recovery.ts +33 -21
  63. package/src/cli/upgrade-restore-state.ts +3 -2
  64. package/src/cli/upgrade-runtime.ts +21 -6
  65. package/src/cli/upgrade.ts +3 -2
  66. package/src/hub/board.ts +9 -6
  67. package/src/hub/budget.ts +9 -2
  68. package/src/hub/bus.ts +6 -2
  69. package/src/hub/claude-state.ts +2 -37
  70. package/src/hub/conductor.ts +8 -6
  71. package/src/hub/config-apply.ts +26 -14
  72. package/src/hub/config-reload.ts +39 -11
  73. package/src/hub/config-trust.ts +1 -1
  74. package/src/hub/context-window.ts +6 -0
  75. package/src/hub/control-client.ts +1 -1
  76. package/src/hub/crash.ts +8 -6
  77. package/src/hub/daemon.ts +471 -338
  78. package/src/hub/hub-tools.ts +37 -1
  79. package/src/hub/instance-config.ts +20 -11
  80. package/src/hub/instance-execution.ts +4 -4
  81. package/src/hub/instances.ts +45 -21
  82. package/src/hub/lifecycle-schema.ts +5 -3
  83. package/src/hub/main-checkout.ts +2 -2
  84. package/src/hub/manager.ts +10 -2
  85. package/src/hub/memory-profile.ts +16 -3
  86. package/src/hub/no-writes.ts +50 -0
  87. package/src/hub/permission-mode.ts +9 -7
  88. package/src/hub/roster.ts +7 -4
  89. package/src/hub/routing.ts +21 -20
  90. package/src/hub/settings.ts +51 -17
  91. package/src/hub/snapshots.ts +276 -17
  92. package/src/hub/start-mode.ts +10 -8
  93. package/src/hub/task-sweep.ts +8 -2
  94. package/src/hub/tasks.ts +101 -19
  95. package/src/memory/capture.ts +2 -1
  96. package/src/ui/index.html +5 -0
  97. package/templates/AGENTS.block.md +1 -1
  98. package/templates/profiles/general.json +1 -1
  99. package/templates/profiles/research.json +1 -1
  100. package/templates/profiles/writing.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { selectReviewerPool, type ReviewerPoolSelection } from "./reviewer-pool.ts";
2
- import { ADAPTERS, definitionOf, kindOf, familyOf } from "../adapters/registry.ts";
2
+ import { ADAPTERS, definitionOf, kindOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
3
3
  import { readFileSync, statSync } from "node:fs";
4
4
  import { join, resolve } from "node:path";
5
5
  import { CLASSES, type Task, type TaskClass } from "./board.ts";
@@ -115,6 +115,7 @@ export const PI: PeerId = "pi";
115
115
 
116
116
  /** Startup-bound project data, never inferred from a suffix or read from configuration here. */
117
117
  export interface InstanceRoutingOptions {
118
+ adapterCatalog?: ProjectAdapterCatalog;
118
119
  instances?: Readonly<Record<string, { kind: string }>>;
119
120
  wip?: Record<string, number>;
120
121
  load?: Record<string, number>;
@@ -133,8 +134,8 @@ function siblingOrder(states: Readonly<Record<string, PeerState>>, opts: Instanc
133
134
  function expandClassPeers(peers: readonly PeerId[], states: Readonly<Record<string, PeerState>>, opts: InstanceRoutingOptions): PeerId[] {
134
135
  if (!hasInstances(opts)) return [...peers];
135
136
  return [...new Set(peers.flatMap(peer => {
136
- const kind = kindOf(peer, opts.instances);
137
- return kind && kind === peer ? [peer, ...Object.keys(opts.instances!).filter(id => kindOf(id, opts.instances) === kind)].sort(siblingOrder(states, opts)) : [peer];
137
+ const kind = (opts.adapterCatalog?.kindOf ?? kindOf)(peer, opts.instances);
138
+ return kind && kind === peer ? [peer, ...Object.keys(opts.instances!).filter(id => (opts.adapterCatalog?.kindOf ?? kindOf)(id, opts.instances) === kind)].sort(siblingOrder(states, opts)) : [peer];
138
139
  }))];
139
140
  }
140
141
  /** Quota/adaptive policy retains kind slots; sibling workload chooses only within that kind's slots. */
@@ -142,7 +143,7 @@ function orderSiblingSlots(peers: PeerId[], states: Readonly<Record<string, Peer
142
143
  if (!hasInstances(opts)) return peers;
143
144
  const groups = new Map<string, number[]>(), out = [...peers];
144
145
  for (const [index, peer] of peers.entries()) {
145
- const kind = kindOf(peer, opts.instances);
146
+ const kind = (opts.adapterCatalog?.kindOf ?? kindOf)(peer, opts.instances);
146
147
  if (kind) { const slots = groups.get(kind) ?? []; slots.push(index); groups.set(kind, slots); }
147
148
  }
148
149
  for (const slots of groups.values()) {
@@ -154,11 +155,11 @@ function orderSiblingSlots(peers: PeerId[], states: Readonly<Record<string, Peer
154
155
 
155
156
  /** The quota/roster relay keeps its native preference order, then the class's original tail. */
156
157
  export function relayCandidates(classPeers: readonly PeerId[], pii: boolean, opts: InstanceRoutingOptions & { states?: Readonly<Record<string, PeerState>> } = {}): PeerId[] {
157
- const preferred = ADAPTERS.filter(adapter => adapter.routing?.handoffPriority !== undefined)
158
+ const preferred = (opts.adapterCatalog?.definitions ?? ADAPTERS).filter(adapter => adapter.routing?.handoffPriority !== undefined)
158
159
  .sort((a, b) => a.routing!.handoffPriority! - b.routing!.handoffPriority!);
159
160
  const candidates = [
160
161
  ...preferred.filter(adapter => !pii || adapter.trust.onPrem).map(adapter => adapter.kind),
161
- ...classPeers.filter(peer => definitionOf(peer, opts.instances)?.routing?.handoffPriority === undefined),
162
+ ...classPeers.filter(peer => (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.routing?.handoffPriority === undefined),
162
163
  ].filter((peer): peer is PeerId => !!peer);
163
164
  return expandClassPeers(candidates, opts.states ?? {}, opts);
164
165
  }
@@ -378,10 +379,10 @@ export function assign(
378
379
  const refusals = new Map<string, RoutingRefusal>();
379
380
  const blocked = (peer: PeerId, role: "owner" | "reviewer"): string | undefined => {
380
381
  const deny = (code: RoutingRefusalCode, reason: string): string => { refusals.set(`${role}:${peer}`, { peer, role, code, reason }); return reason; };
381
- const ceiling = definitionOf(peer, opts.instances)?.routing?.allowedRoles;
382
+ const ceiling = (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.routing?.allowedRoles;
382
383
  const requiredRole = role === "reviewer" || task.class === "review" ? "reviewer" : task.class === "plan" ? "planner" : "implementer";
383
384
  if (ceiling && !ceiling.includes(requiredRole)) return deny("policy", "adapter role ceiling excludes this assignment");
384
- const unavailable = role === "reviewer" ? definitionOf(peer, opts.instances)?.reviewer?.unavailable : undefined;
385
+ const unavailable = role === "reviewer" ? (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.reviewer?.unavailable : undefined;
385
386
  if (unavailable) return deny("policy", unavailable);
386
387
  if (role === "owner" && opts.capabilities && !pii) {
387
388
  const caps = peerEntry(opts.capabilities, peer);
@@ -394,7 +395,7 @@ export function assign(
394
395
  if (!Object.hasOwn(states, peer)) return deny("not-attached", "not attached");
395
396
  if (opts.exclude?.includes(peer)) return deny("excluded", "excluded (declined or replaced)");
396
397
  if (role === "owner" && opts.excludeOwners?.includes(peer)) return deny("owner-admission", "owner admission: WIP or delivery hold");
397
- const adapter = definitionOf(peer, opts.instances);
398
+ const adapter = (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances);
398
399
  if (role === "owner" && adapter?.routing?.reviewOnly) return deny("owner-admission", "owner admission: a review-only instance owns no task");
399
400
  if (role === "owner" && needsTests && adapter && adapter.kind !== peer) {
400
401
  if (!peerEntry(opts.roles, peer)?.includes("implementer")) return deny("owner-admission", "owner admission: declared write owner requires the current implementer role");
@@ -404,8 +405,8 @@ export function assign(
404
405
  if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return deny("paused", "paused");
405
406
  const failing = peerEntry(opts.failing, peer);
406
407
  if (failing) return deny("failing", `failing: ${failing}`);
407
- if (pii && definitionOf(peer, opts.instances)?.trust.onPrem !== true) return deny("pii", "pii: on-prem peers only");
408
- const nativeRouting = definitionOf(peer, opts.instances)?.routing;
408
+ if (pii && (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.trust.onPrem !== true) return deny("pii", "pii: on-prem peers only");
409
+ const nativeRouting = (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.routing;
409
410
  if (nativeRouting?.localAllowedRefusal && policy?.local_allowed === false) return deny("policy", nativeRouting.localAllowedRefusal);
410
411
  if (nativeRouting?.longContext === "backend-limit" && task.signals.includes("long_context")) {
411
412
  const backend = policy?.pi_backend ?? "dgx";
@@ -462,7 +463,7 @@ export function assign(
462
463
  }
463
464
  const ranked = sortSiblings ? orderSiblingSlots(rank(ok, role, not), states, opts) : rank(ok, role, not);
464
465
  // Demotion applies to local and Pi too: a demoted one loses its place ahead of the cloud peers.
465
- const localTier = ranked.filter((p) => definitionOf(p, opts.instances)?.routing?.preferredTier === true && !(role === "owner" && peerEntry(opts.demoted, p)));
466
+ const localTier = ranked.filter((p) => (opts.adapterCatalog?.definitionOf ?? definitionOf)(p, opts.instances)?.routing?.preferredTier === true && !(role === "owner" && peerEntry(opts.demoted, p)));
466
467
  if (localTier.length) return localTier.find((p) => states[p] === "idle") ?? localTier[0]; // local/Pi stays ahead of an idle cloud peer
467
468
  return ranked.find((p) => states[p] === "idle") ?? ranked[0];
468
469
  };
@@ -492,13 +493,13 @@ export function assign(
492
493
  trace.push(opts.namedReviewer === undefined ? ` reviewer candidates: [classes.review] peers ${classPeers.join(", ") || "none"}${rolePeers.length ? `; reviewer role ${rolePeers.join(", ")}` : ""}` : ` reviewer candidate explicitly named: ${opts.namedReviewer}`);
493
494
  const reviewOwner = owner ?? opts.notReviewer;
494
495
  const automatic = [...new Set([...expandClassPeers(classPeers, states, opts), ...rolePeers])];
495
- const ownerFamily = instanceMode && reviewOwner ? familyOf(reviewOwner, opts.instances) : undefined;
496
- const sameFamily = (peer: PeerId) => ownerFamily !== undefined && familyOf(peer, opts.instances) === ownerFamily;
496
+ const ownerFamily = instanceMode && reviewOwner ? (opts.adapterCatalog?.familyOf ?? familyOf)(reviewOwner, opts.instances) : undefined;
497
+ const sameFamily = (peer: PeerId) => ownerFamily !== undefined && (opts.adapterCatalog?.familyOf ?? familyOf)(peer, opts.instances) === ownerFamily;
497
498
  const independent = ownerFamily === undefined ? [] : automatic.filter(peer => peer !== reviewOwner && !blocked(peer, "reviewer") && !sameFamily(peer));
498
499
  const named = opts.namedReviewer;
499
500
  const declaredPool = opts.reviewPool && named === undefined ? Object.keys(opts.instances ?? {}).flatMap(id => {
500
- const roles = peerEntry(opts.roles, id), definition = definitionOf(id, opts.instances);
501
- const kind = kindOf(id, opts.instances), family = familyOf(id, opts.instances);
501
+ const roles = peerEntry(opts.roles, id), definition = (opts.adapterCatalog?.definitionOf ?? definitionOf)(id, opts.instances);
502
+ const kind = (opts.adapterCatalog?.kindOf ?? kindOf)(id, opts.instances), family = (opts.adapterCatalog?.familyOf ?? familyOf)(id, opts.instances);
502
503
  if (roles?.length !== 1 || roles[0] !== "reviewer" || !definition?.routing?.reviewOnly || !kind || !family) return [];
503
504
  const reason = id === reviewOwner ? "is the owner" : definition.reviewer?.unavailable ?? blocked(id, "reviewer")
504
505
  ?? (states[id] === "paused" ? "paused" : undefined)
@@ -509,7 +510,7 @@ export function assign(
509
510
  if (declaredPool.length || opts.reviewPool?.required && named === undefined) {
510
511
  for (const candidate of declaredPool) if (!opts.reviewPool!.pool.includes(candidate.kind)) trace.push(` reviewer pool candidate ${candidate.id} (${candidate.family}): unavailable skip: ${candidate.blocked ?? "kind outside configured pool"}`);
511
512
  const last = task.history?.findLast(entry => entry.event === "changes_requested");
512
- const continuityFamily = last && "reviewerFamily" in last && typeof last.reviewerFamily === "string" ? last.reviewerFamily : last ? familyOf(last.by, opts.instances) : undefined;
513
+ const continuityFamily = last && "reviewerFamily" in last && typeof last.reviewerFamily === "string" ? last.reviewerFamily : last ? (opts.adapterCatalog?.familyOf ?? familyOf)(last.by, opts.instances) : undefined;
513
514
  const pr = task.sdlc?.pr;
514
515
  const seed = typeof pr === "number" && Number.isSafeInteger(pr) && pr > 0 ? pr : task.id ?? 0;
515
516
  reviewSelection = selectReviewerPool({ pool: opts.reviewPool!.pool, candidates: declaredPool, seed, implementerFamily: ownerFamily, continuityFamily, rerotate: opts.reviewPool!.rerotate, failed: opts.reviewPool!.failed });
@@ -518,7 +519,7 @@ export function assign(
518
519
  if (reviewSelection.blocked) {
519
520
  trace.push("blocked: no reviewer can run");
520
521
  // Optional defaults retain the legacy planner escape when no fresh reviewer can run.
521
- const planners = opts.reviewPool?.required ? [] : automatic.filter(peer => peer !== reviewOwner && peerEntry(opts.roles, peer)?.includes("planner") && !definitionOf(peer, opts.instances)?.routing?.reviewOnly && !blocked(peer, "reviewer"));
522
+ const planners = opts.reviewPool?.required ? [] : automatic.filter(peer => peer !== reviewOwner && peerEntry(opts.roles, peer)?.includes("planner") && !(opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.routing?.reviewOnly && !blocked(peer, "reviewer"));
522
523
  const otherFamily = planners.filter(peer => !sameFamily(peer));
523
524
  const planner = pick(otherFamily.length ? otherFamily : planners, "reviewer", reviewOwner);
524
525
  if (planner) {
@@ -555,8 +556,8 @@ export function assign(
555
556
  const route = effectiveLocalRoute(routing.local.route, policy?.route, policy?.fixed_model);
556
557
  const fixedModel = policy?.fixed_model ?? routing.local.fixed_model;
557
558
  const piBackend = policy?.pi_backend;
558
- if (owner && definitionOf(owner, opts.instances)?.routing?.trace === "route") trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
559
- if (owner && definitionOf(owner, opts.instances)?.routing?.trace === "backend") trace.push(`${definitionOf(owner, opts.instances)!.routing!.backendLabel} decision: backend ${piBackend ?? "dgx"}${task.signals.includes("long_context") ? `, context limit ${piBackend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens}` : ""}`);
559
+ if (owner && (opts.adapterCatalog?.definitionOf ?? definitionOf)(owner, opts.instances)?.routing?.trace === "route") trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
560
+ if (owner && (opts.adapterCatalog?.definitionOf ?? definitionOf)(owner, opts.instances)?.routing?.trace === "backend") trace.push(`${(opts.adapterCatalog?.definitionOf ?? definitionOf)(owner, opts.instances)!.routing!.backendLabel} decision: backend ${piBackend ?? "dgx"}${task.signals.includes("long_context") ? `, context limit ${piBackend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens}` : ""}`);
560
561
  return { ...(reviewSelection ? { reviewSelection } : {}), ...(reviewBlocked ? { reviewBlocked: true as const } : {}), ...(sameFamilyFallback ? { sameFamilyFallback: true as const } : {}), ...(plannerFallback ? { plannerFallback: true as const } : {}), ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), ...(refusals.size ? { refusals: [...refusals.values()] } : {}), trace };
561
562
  }
562
563
 
@@ -1,4 +1,4 @@
1
- import { definitionOf, type KindMap, ADAPTERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
1
+ import { definitionOf, type KindMap, type ProjectAdapterCatalog, ADAPTERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
2
2
  import { createHash } from "node:crypto";
3
3
  import { types } from "node:util";
4
4
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
@@ -19,6 +19,8 @@ export const SETTING_PEERS = SUPPORTED_PEERS;
19
19
  /** `null` removes the machine-local value, so the shared file's or the default applies again. */
20
20
  export type SettingValue = string | boolean | number | string[] | null;
21
21
  export interface SettingDef {
22
+ /** Fixed host policy; no authority can edit or undo this row. */
23
+ locked?: string;
22
24
  key: string;
23
25
  group: "Permissions" | "Start" | "Routing" | "Switches" | "Roster" | "Roles";
24
26
  label: string;
@@ -38,6 +40,7 @@ export interface SettingDef {
38
40
  path: readonly string[];
39
41
  }
40
42
 
43
+ const READ_ONLY_LOCK = "read-only instance: permission mode is locked by its declaration";
41
44
  const PEER = /^[a-z][a-z0-9-]{0,31}$/;
42
45
  const PEERS_MAX = 8;
43
46
  const perClass = (name: TaskClass): SettingDef[] => [
@@ -48,6 +51,7 @@ const perClass = (name: TaskClass): SettingDef[] => [
48
51
  ];
49
52
  export const SETTINGS: readonly SettingDef[] = [
50
53
  ...["enabled", "auto_reassign"].map((key): SettingDef => ({ key: `task_sweep.${key}`, group: "Switches", label: `Task sweep: ${key}`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["task_sweep", key] })),
54
+ { key: "task_sweep.no_writes_turns", group: "Switches", label: "Consecutive owner turns without writes (0 disables)", type: "integer", min: 0, max: Number.MAX_SAFE_INTEGER, store: "config", applies: "live", risk: "raises", path: ["task_sweep", "no_writes_turns"] },
51
55
  ...["interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"].map((key): SettingDef => ({ key: `task_sweep.${key}`, group: "Switches", label: `Task sweep: ${key}`, type: "number", min: 1, max: 2_147_483_647 / (key === "interval_s" ? 1000 : 60_000), store: "config", applies: "live", risk: "raises", path: ["task_sweep", key] })),
52
56
  ...SETTING_PEERS.map((peer): SettingDef => ({ key: `tasks.wip_limits.${peer}`, group: "Routing", label: `${peer}: planner WIP limit`, type: "integer", min: 0, max: 32, store: "config", applies: "live", risk: "raises", floor: 0, path: ["tasks", "wip_limits", peer] })),
53
57
  { key: "tasks.escalate_after", group: "Routing", label: "Escalate after N consecutive changes_requested", type: "integer", min: 1, max: 10, store: "config", applies: "live", risk: "raises", path: ["tasks", "escalate_after"] },
@@ -71,37 +75,61 @@ export const SETTINGS: readonly SettingDef[] = [
71
75
  * Never editable from settings, whatever the session: these choose what the hub executes or where secrets and task
72
76
  * text go. A key equal to an entry, or below it, is refused by name.
73
77
  */
74
- export const NEVER_EDITABLE = ["kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal", "instances"] as const;
78
+ export const NEVER_EDITABLE = ["kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal", "instances", "adapters"] as const;
75
79
 
76
80
  /** Derive project-bound rows from the same builtin templates used by lookup, without registering aliases globally. */
77
- export function projectSettings(instances?: KindMap): readonly SettingDef[] {
78
- if (!instances || typeof instances !== "object" || types.isProxy(instances) || Array.isArray(instances)
79
- || ![Object.prototype, null].includes(Object.getPrototypeOf(instances)) || Object.getOwnPropertySymbols(instances).length) return SETTINGS;
81
+ export function projectSettings(instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): readonly SettingDef[] {
82
+ const resolve = adapterCatalog?.definitionOf ?? definitionOf;
80
83
  const extra: SettingDef[] = [];
81
- for (const peer of Object.keys(instances)) {
82
- if (definitionOf(peer)) continue; // An explicit declaration never changes a builtin's setting definition.
83
- const adapter = definitionOf(peer, instances);
84
+ const headless = (peer: string): SettingDef => Object.freeze({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (headless only)`,
85
+ type: "enum", values: Object.freeze(["headless"]), store: "config", applies: "next use", risk: "raises", floor: "headless", path: Object.freeze(["peers", peer, "start_mode"]) });
86
+ for (const adapter of adapterCatalog?.definitions ?? []) {
87
+ if (definitionOf(adapter.kind)) continue;
88
+ const peer = adapter.kind;
89
+ extra.push(
90
+ { key: `roster.${peer}`, group: "Roster", label: `${adapter.displayName} (${peer}): participates`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", peer] },
91
+ { ...SETTINGS.find(def => def.key.startsWith("tasks.wip_limits."))!, key: `tasks.wip_limits.${peer}`, label: `${peer}: planner WIP limit`, path: ["tasks", "wip_limits", peer] },
92
+ headless(peer),
93
+ );
94
+ const values = Object.keys(adapter.permission.genericAcp?.modes ?? {});
95
+ if (!adapter.permission.unmanaged && values.length) for (const runtime of [true, false]) extra.push({
96
+ key: `${runtime ? "permission" : "permission_modes"}.${peer}`, group: "Permissions", label: `${peer}: mode ${runtime ? "now" : "at next peer start"}`,
97
+ type: "enum", values, store: runtime ? "runtime" : "config", applies: runtime ? "live" : "next use", risk: "raises", ...(values.includes("ask") ? { floor: "ask" } : {}), path: runtime ? [peer] : ["permission_modes", peer],
98
+ });
99
+ }
100
+ const baseSettings = [...SETTINGS, ...extra];
101
+ const validInstances = instances && typeof instances === "object" && !types.isProxy(instances) && !Array.isArray(instances)
102
+ && [Object.prototype, null].includes(Object.getPrototypeOf(instances)) && !Object.getOwnPropertySymbols(instances).length;
103
+ for (const peer of validInstances ? Object.keys(instances!) : []) {
104
+ if (resolve(peer)) continue; // An explicit declaration never changes a canonical setting definition.
105
+ const adapter = resolve(peer, instances);
84
106
  if (!adapter) continue;
107
+ const entry = Object.getOwnPropertyDescriptor(instances!, peer)?.value;
108
+ const readOnly = entry && typeof entry === "object" && !types.isProxy(entry) && Object.getOwnPropertyDescriptor(entry, "readOnly")?.value === true && !!adapter.permission.genericAcp?.readOnly;
85
109
  const copy = (base: SettingDef, key: string): SettingDef => Object.freeze({ ...base, key,
86
110
  label: base.label.replace(`${adapter.kind}:`, `${peer}:`),
87
111
  path: Object.freeze(base.path.map(part => part === adapter.kind ? peer : part)),
88
112
  ...(base.values ? { values: Object.freeze([...base.values]) } : {}),
89
113
  });
90
- for (const prefix of ["roster", "permission", "permission_modes"]) {
91
- const base = SETTINGS.find(def => def.key === `${prefix}.${adapter.kind}`);
114
+ for (const prefix of ["roster", "permission", "permission_modes", "tasks.wip_limits"]) {
115
+ if (readOnly && (prefix === "permission" || prefix === "permission_modes")) continue;
116
+ const base = baseSettings.find(def => def.key === `${prefix}.${adapter.kind}`);
92
117
  if (base) extra.push(copy(base, `${prefix}.${peer}`));
93
118
  }
94
- // I2 declarations select the headless engine; settings cannot opt an extra into a native TUI.
95
- extra.push(Object.freeze({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (headless only)`,
96
- type: "enum", values: Object.freeze(["headless"]), store: "config", applies: "next use", risk: "raises", floor: "headless", path: Object.freeze(["peers", peer, "start_mode"]) } satisfies SettingDef));
119
+ if (readOnly) for (const runtime of [true, false]) extra.push(Object.freeze({
120
+ key: `${runtime ? "permission" : "permission_modes"}.${peer}`, group: "Permissions", label: `${peer}: read-only (locked)`,
121
+ type: "enum", values: Object.freeze(["read-only"]), store: runtime ? "runtime" : "config", applies: runtime ? "live" : "next use", risk: "safe",
122
+ path: Object.freeze(runtime ? [peer] : ["permission_modes", peer]), locked: READ_ONLY_LOCK,
123
+ } satisfies SettingDef));
124
+ extra.push(headless(peer));
97
125
  }
98
126
  return extra.length ? Object.freeze([...SETTINGS, ...extra]) : SETTINGS;
99
127
  }
100
128
 
101
129
  /** The definition of a key, or why there is none. */
102
- export function settingDef(key: unknown, instances?: KindMap): SettingDef | string {
130
+ export function settingDef(key: unknown, instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): SettingDef | string {
103
131
  if (typeof key !== "string" || key.length > 80) return "unknown setting";
104
- const found = projectSettings(instances).find((s) => s.key === key);
132
+ const found = projectSettings(instances, adapterCatalog).find((s) => s.key === key);
105
133
  if (found) return found;
106
134
  if (NEVER_EDITABLE.some((never) => key === never || key.startsWith(`${never}.`))) return `${key} chooses what the hub runs or where secrets and task text go: it is never editable from settings; edit .agenthub/config.local.json in your editor`;
107
135
  return "unknown setting; run ahub settings list";
@@ -109,6 +137,7 @@ export function settingDef(key: unknown, instances?: KindMap): SettingDef | stri
109
137
 
110
138
  /** The checked value, or a thrown reason. `routes`: the route ids the project's routing defines. */
111
139
  export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonly string[] = []): SettingValue {
140
+ if (def.locked) throw new Error(def.locked);
112
141
  if (raw === null) {
113
142
  if (def.store === "runtime") throw new Error(`${def.key} has no stored value to remove`);
114
143
  return null;
@@ -151,6 +180,7 @@ export type SettingAuthority = "ordinary" | "settings" | "terminal";
151
180
  /** Why this authority may not set this value, or undefined. Removing a stored value can raise, so it counts as one. */
152
181
  export function settingRefusal(def: SettingDef | string, value: SettingValue, authority: SettingAuthority): string | undefined {
153
182
  if (typeof def === "string") return def || "invalid setting definition";
183
+ if (def.locked) return def.locked;
154
184
  if (def.risk === "safe" || authority !== "ordinary") return undefined;
155
185
  if (value !== null && value === def.floor) return undefined;
156
186
  return `${def.key} can widen what agents do without asking: open a settings session with ahub ui --settings, or use ahub settings in a terminal`;
@@ -390,6 +420,7 @@ export function ignoredLine(ignored: readonly string[] | undefined, def: Setting
390
420
  });
391
421
  }
392
422
  export interface SettingRow {
423
+ locked?: string;
393
424
  key: string; group: SettingDef["group"]; label: string; type: SettingDef["type"]; values?: readonly string[]; min?: number; max?: number;
394
425
  /** The file a write goes to, or "this hub run". */
395
426
  file: string;
@@ -418,6 +449,7 @@ export function settingRows(input: {
418
449
  cwd: string; routing: Routing;
419
450
  /** Captured trusted declarations for this daemon/project, never taken from a settings request. */
420
451
  instances?: KindMap;
452
+ adapterCatalog: ProjectAdapterCatalog;
421
453
  running: (path: readonly string[]) => unknown;
422
454
  next: ((path: readonly string[]) => unknown) | undefined;
423
455
  mode: (peer: string) => { value?: string; note?: string };
@@ -432,9 +464,11 @@ export function settingRows(input: {
432
464
  const projectRouting = existsSync(join(input.cwd, ".agenthub", "routing.toml"));
433
465
  const routes = Object.keys(input.routing.hub_routes ?? {});
434
466
  const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
435
- return projectSettings(input.instances).map((def): SettingRow => {
467
+ return projectSettings(input.instances, input.adapterCatalog).map((def): SettingRow => {
436
468
  const base = { key: def.key, group: def.group, label: def.label, type: def.type, ...(["integer", "number"].includes(def.type) ? { min: def.min, max: def.max } : {}), applies: def.applies, ...(def.restartReason ? { restartReason: def.restartReason } : {}), risk: def.risk, ...(def.floor === undefined ? {} : { floor: def.floor }),
437
- ...(def.key === "scoped_grants.protect_instructions" ? { hint: "AGENTS.md, CLAUDE.md and QWEN.md remain grantable by default; enabling this asks before scoped writes and edits" } : def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!, input.instances) } : {}) };
469
+ ...(def.key === "scoped_grants.protect_instructions" ? { hint: "AGENTS.md, CLAUDE.md and QWEN.md remain grantable by default; enabling this asks before scoped writes and edits" } : def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!, input.instances, input.adapterCatalog) } : {}) };
470
+ if (def.locked) return { ...base, values: def.values, file: def.store === "runtime" ? "this hub run" : CONFIG_LOCAL,
471
+ source: "instance declaration", value: "read-only", fileValue: "read-only", locked: def.locked, note: def.locked };
438
472
  if (def.store === "runtime") {
439
473
  const mode = input.mode(def.path[0]!);
440
474
  return { ...base, values: def.values!, file: "this hub run", value: mode.value ?? null, fileValue: mode.value ?? null, source: "this hub run", ...(mode.note ? { note: mode.note } : {}) };