@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.
- package/CHANGELOG.md +30 -0
- package/README.md +5 -4
- package/docs/adding-an-ai-tool.md +140 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +11 -4
- package/docs/agent-notes/budget.md +2 -2
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/daemon.md +15 -6
- package/docs/agent-notes/tasks.md +7 -2
- package/docs/agent-notes/tests.md +6 -2
- package/docs/operations.md +159 -26
- package/docs/quickstart.md +11 -3
- package/docs/sdlc.md +1 -1
- package/docs/security.md +7 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +209 -13
- package/docs/verification/2026-10-21-0.12.43.md +50 -0
- package/docs/verification/2026-10-21-0.12.44.md +54 -0
- package/docs/verification/2026-10-21-agy-1.4.3-t0.md +65 -0
- package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
- package/docs/verification/2026-10-21-pii-screen.md +14 -0
- package/docs/verified.json +34 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +808 -255
- package/src/adapters/acp-owned-group.ts +124 -0
- package/src/adapters/acp.ts +92 -25
- package/src/adapters/agy/definition.ts +1 -3
- package/src/adapters/agy/entry.ts +4 -1
- package/src/adapters/agy/hooks.ts +7 -2
- package/src/adapters/channel-peer.ts +143 -0
- package/src/adapters/claude-channel.ts +7 -7
- package/src/adapters/codex-appserver.ts +16 -2
- package/src/adapters/configured-acp.ts +124 -0
- package/src/adapters/definitions/builtin-metadata.ts +37 -33
- package/src/adapters/definitions/native-channel-state.ts +41 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -1
- package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
- package/src/adapters/definitions/native-identity-defaults.ts +14 -0
- package/src/adapters/definitions/qwen.ts +3 -8
- package/src/adapters/definitions/reviewer-variants.ts +3 -7
- package/src/adapters/jcode.ts +4 -0
- package/src/adapters/local-worker.ts +4 -0
- package/src/adapters/pi.ts +50 -16
- package/src/adapters/registry-types.ts +18 -16
- package/src/adapters/registry.ts +68 -6
- package/src/adapters/reviewer-print.ts +4 -0
- package/src/cli/agy-mcp.ts +3 -2
- package/src/cli/completion.ts +173 -0
- package/src/cli/configured-acp-entry.ts +58 -0
- package/src/cli/console-state.ts +116 -24
- package/src/cli/console.ts +9 -6
- package/src/cli/facts-hook.ts +2 -1
- package/src/cli/help.ts +8 -1
- package/src/cli/identity.ts +8 -7
- package/src/cli/launch.ts +5 -4
- package/src/cli/main.js +5 -1
- package/src/cli/main.ts +84 -27
- package/src/cli/memory.ts +13 -10
- package/src/cli/output.ts +1 -0
- package/src/cli/preview.ts +9 -0
- package/src/cli/recovery-guidance.ts +1 -1
- package/src/cli/terminal-recovery.ts +33 -21
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +21 -6
- package/src/cli/upgrade.ts +3 -2
- package/src/hub/board.ts +9 -6
- package/src/hub/budget.ts +9 -2
- package/src/hub/bus.ts +6 -2
- package/src/hub/claude-state.ts +2 -37
- package/src/hub/conductor.ts +8 -6
- package/src/hub/config-apply.ts +26 -14
- package/src/hub/config-reload.ts +39 -11
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +6 -0
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +471 -338
- package/src/hub/hub-tools.ts +37 -1
- package/src/hub/instance-config.ts +20 -11
- package/src/hub/instance-execution.ts +4 -4
- package/src/hub/instances.ts +45 -21
- package/src/hub/lifecycle-schema.ts +5 -3
- package/src/hub/main-checkout.ts +2 -2
- package/src/hub/manager.ts +10 -2
- package/src/hub/memory-profile.ts +16 -3
- package/src/hub/no-writes.ts +50 -0
- package/src/hub/permission-mode.ts +9 -7
- package/src/hub/roster.ts +7 -4
- package/src/hub/routing.ts +21 -20
- package/src/hub/settings.ts +51 -17
- package/src/hub/snapshots.ts +276 -17
- package/src/hub/start-mode.ts +10 -8
- package/src/hub/task-sweep.ts +8 -2
- package/src/hub/tasks.ts +101 -19
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +5 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
package/src/hub/routing.ts
CHANGED
|
@@ -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
|
|
package/src/hub/settings.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
type: "enum", values: Object.freeze(["
|
|
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 } : {}) };
|