@staix/agent-hub 0.12.19 → 0.12.21
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 +38 -0
- package/README.md +7 -6
- package/docs/agent-notes/adapters.md +10 -2
- package/docs/agent-notes/bus.md +6 -2
- package/docs/agent-notes/daemon.md +10 -3
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/models.md +3 -0
- package/docs/agent-notes/tasks.md +6 -3
- package/docs/agent-notes/tests.md +3 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +6 -2
- package/docs/operations.md +655 -34
- package/docs/quickstart.md +12 -10
- package/docs/research.md +92 -0
- package/docs/security.md +59 -17
- package/docs/smoke.md +137 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
- package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
- package/docs/verified.json +29 -18
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +65 -26
- package/src/adapters/acp.ts +67 -6
- package/src/adapters/claude-channel.ts +61 -20
- package/src/adapters/codex-appserver.ts +70 -2
- package/src/adapters/local-worker.ts +8 -3
- package/src/adapters/pi.ts +58 -4
- package/src/cli/bench.ts +287 -0
- package/src/cli/console-state.ts +531 -110
- package/src/cli/console.ts +59 -26
- package/src/cli/facts-hook.ts +50 -8
- package/src/cli/help.ts +130 -0
- package/src/cli/identity.ts +11 -5
- package/src/cli/launch.ts +98 -17
- package/src/cli/main.ts +325 -88
- package/src/cli/preview.ts +14 -3
- package/src/cli/reset.ts +194 -0
- package/src/cli/status-lines.ts +10 -5
- package/src/cli/terminal-recovery.ts +196 -40
- package/src/cli/upgrade-runtime.ts +327 -60
- package/src/cli/upgrade.ts +442 -45
- package/src/hub/ask.ts +1 -1
- package/src/hub/bench.ts +211 -0
- package/src/hub/board.ts +15 -7
- package/src/hub/bus.ts +79 -8
- package/src/hub/child-process.ts +1 -0
- package/src/hub/conductor.ts +54 -21
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +9 -4
- package/src/hub/daemon.ts +401 -48
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +9 -2
- package/src/hub/execution-budget.ts +15 -6
- package/src/hub/hub-tools.ts +4 -4
- package/src/hub/inference.ts +136 -1
- package/src/hub/lifecycle.ts +13 -12
- package/src/hub/manager.ts +16 -14
- package/src/hub/peers.ts +2 -0
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +68 -8
- package/src/hub/registry.ts +36 -22
- package/src/hub/report.ts +43 -1
- package/src/hub/research.ts +255 -0
- package/src/hub/restart.ts +25 -0
- package/src/hub/routing.ts +48 -16
- package/src/hub/snapshots.ts +3 -2
- package/src/hub/tasks.ts +295 -115
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/deny.ts +2 -1
- package/src/local/tools.ts +14 -10
- package/src/models/mlx.ts +11 -8
- package/src/models/ollama.ts +2 -2
- package/src/models/relay.ts +190 -42
- package/src/models/route/relay-selector.ts +43 -31
- package/src/models/route/runtime.ts +17 -7
- package/src/models/route/signals.ts +12 -1
- package/src/models/route/stage.ts +57 -3
- package/src/omniroute/client.ts +1 -1
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +7 -1
- package/src/ui/index.html +84 -6
- package/src/ui/task-progress.ts +33 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/routing.toml +8 -0
package/src/hub/routing.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { Task, TaskClass } from "./board.ts";
|
|
|
4
4
|
import type { PeerId, PeerState } from "./envelope.ts";
|
|
5
5
|
|
|
6
6
|
import { parseHubRoutes, type HubRoute } from "../models/route/config.ts";
|
|
7
|
+
import { DEFAULT_STAY_SWITCH, type StaySwitchMode } from "../models/route/stage.ts";
|
|
7
8
|
|
|
8
9
|
type Table = Record<string, unknown>;
|
|
9
10
|
|
|
@@ -26,10 +27,15 @@ export interface Routing {
|
|
|
26
27
|
targets: Record<string, Table & { id: string }>;
|
|
27
28
|
routes: Record<string, Table & { type: string }>;
|
|
28
29
|
hub_routes?: Record<string, HubRoute>;
|
|
30
|
+
/** Session-aware stay/switch for `hub/auto` and hub stage routes (#197). */
|
|
31
|
+
stay_switch: StaySwitchMode;
|
|
32
|
+
max_switch_prefill_tokens: number;
|
|
29
33
|
classes: Partial<Record<TaskClass, ClassPolicy>>;
|
|
30
|
-
|
|
34
|
+
/** `pii_screen` (issue #198): `local` adds the on-device or on-campus model screen to the patterns. */
|
|
35
|
+
signals: { pii_patterns: string[]; long_context_tokens: number; pii_screen: "off" | "local" };
|
|
31
36
|
constraints: { pii: "local_only" | "off"; long_context: "skip_local" | "off"; budget_paused: "skip_peer" | "off" };
|
|
32
|
-
|
|
37
|
+
/** `efficient_wait_ms`, opt-in (#199): how long a hub/auto request waits for a busy MLX slot before it moves to dgx/fast; absent, nothing moves for load. */
|
|
38
|
+
pi: { dgx_max_context_tokens: number; mlx_max_context_tokens: number; efficient_wait_ms?: number };
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
const TEMPLATE = join(import.meta.dir, "..", "..", "templates", "routing.toml");
|
|
@@ -46,19 +52,32 @@ export function loadRouting(cwd: string): Routing {
|
|
|
46
52
|
}
|
|
47
53
|
const raw = Bun.TOML.parse(text) as Partial<Routing>;
|
|
48
54
|
if (!raw.local?.fixed_model) throw new Error("routing.toml: [local] fixed_model is required (the path that works without Switchyard)");
|
|
49
|
-
const signals = { pii_patterns: [], long_context_tokens: 120_000, ...raw.signals };
|
|
55
|
+
const signals = { pii_patterns: [], long_context_tokens: 120_000, pii_screen: "off" as const, ...raw.signals };
|
|
50
56
|
for (const p of signals.pii_patterns) new RegExp(p); // a bad pattern fails here, at load, not in the middle of an assignment
|
|
57
|
+
if (signals.pii_screen !== "off" && signals.pii_screen !== "local") throw new Error('routing.toml: [signals] pii_screen must be "off" or "local"');
|
|
51
58
|
const classes = raw.classes ?? {};
|
|
52
59
|
for (const [name, policy] of Object.entries(classes)) {
|
|
53
60
|
if (policy?.pi_backend !== undefined && policy.pi_backend !== "dgx" && policy.pi_backend !== "mlx") throw new Error(`routing.toml: [classes.${name}] pi_backend must be "dgx" or "mlx"`);
|
|
54
61
|
}
|
|
55
62
|
const pi = { dgx_max_context_tokens: 262_144, mlx_max_context_tokens: 16_000, ...(raw as any).pi };
|
|
56
63
|
if (!(Number.isSafeInteger(pi.dgx_max_context_tokens) && pi.dgx_max_context_tokens > 0) || !(Number.isSafeInteger(pi.mlx_max_context_tokens) && pi.mlx_max_context_tokens > 0)) throw new Error("routing.toml: [pi] context limits must be positive integers");
|
|
64
|
+
// A dispatch gives up on a busy MLX slot after 120 s; a longer load wait would never move anything.
|
|
65
|
+
if (pi.efficient_wait_ms !== undefined && !(Number.isSafeInteger(pi.efficient_wait_ms) && pi.efficient_wait_ms >= 0 && pi.efficient_wait_ms < 120_000)) throw new Error("routing.toml: [pi] efficient_wait_ms must be an integer from 0 to 119999");
|
|
66
|
+
const { stay_switch, max_switch_prefill_tokens } = { ...DEFAULT_STAY_SWITCH, ...(raw as any) };
|
|
67
|
+
// Written after a table header, a top-level key lands in that table and would be ignored without a word.
|
|
68
|
+
const misplaced = (table: unknown, path: string): string | undefined => !table || typeof table !== "object" || Array.isArray(table) ? undefined
|
|
69
|
+
: Object.entries(table).map(([key, value]) => Object.hasOwn(DEFAULT_STAY_SWITCH, key) ? `${path}${key}` : misplaced(value, `${path}${key}.`)).find(Boolean);
|
|
70
|
+
const nested = Object.entries(raw).map(([key, value]) => misplaced(value, `${key}.`)).find(Boolean);
|
|
71
|
+
if (nested) throw new Error(`routing.toml: ${nested} is inside a table; stay_switch and max_switch_prefill_tokens are top-level keys, before any table`);
|
|
72
|
+
if (!["off", "shadow", "enforce"].includes(stay_switch)) throw new Error('routing.toml: stay_switch must be "off", "shadow" or "enforce" (a top-level key, before any table)');
|
|
73
|
+
if (!(Number.isSafeInteger(max_switch_prefill_tokens) && max_switch_prefill_tokens > 0)) throw new Error("routing.toml: max_switch_prefill_tokens must be a positive integer");
|
|
57
74
|
return {
|
|
58
75
|
local: raw.local,
|
|
59
76
|
targets: raw.targets ?? {},
|
|
60
77
|
routes: raw.routes ?? {},
|
|
61
78
|
hub_routes: parseHubRoutes(raw.hub_routes),
|
|
79
|
+
stay_switch,
|
|
80
|
+
max_switch_prefill_tokens,
|
|
62
81
|
classes,
|
|
63
82
|
signals,
|
|
64
83
|
constraints: { pii: "local_only", long_context: "skip_local", budget_paused: "skip_peer", ...raw.constraints },
|
|
@@ -111,6 +130,9 @@ export function detectSignals(task: Pick<Task, "title" | "detail" | "refs">, rou
|
|
|
111
130
|
return out;
|
|
112
131
|
}
|
|
113
132
|
|
|
133
|
+
/** A peer's entry in a peer-indexed map: own keys only, so a peer named `constructor` is never an inherited Object member. */
|
|
134
|
+
export const peerEntry = <T>(map: Readonly<Record<string, T>> | undefined, peer: string): T | undefined => (map && Object.hasOwn(map, peer) ? map[peer] : undefined);
|
|
135
|
+
|
|
114
136
|
export interface Assignment {
|
|
115
137
|
owner?: PeerId;
|
|
116
138
|
reviewer?: PeerId;
|
|
@@ -118,6 +140,8 @@ export interface Assignment {
|
|
|
118
140
|
route?: string;
|
|
119
141
|
fixedModel?: string;
|
|
120
142
|
piBackend?: "dgx" | "mlx";
|
|
143
|
+
/** Why the task's reserved owner (issue #207) was passed over; absent when it was honored or there is none. */
|
|
144
|
+
unreserved?: string;
|
|
121
145
|
trace: string[];
|
|
122
146
|
}
|
|
123
147
|
|
|
@@ -126,7 +150,7 @@ export interface Assignment {
|
|
|
126
150
|
* prints its trace. `states`: effective bus states of attached peers (a peer that is not in the map is not attached).
|
|
127
151
|
*/
|
|
128
152
|
export function assign(
|
|
129
|
-
task: Pick<Task, "class" | "signals">,
|
|
153
|
+
task: Pick<Task, "class" | "signals" | "reserved">,
|
|
130
154
|
states: Record<PeerId, PeerState>,
|
|
131
155
|
routing: Routing,
|
|
132
156
|
opts: {
|
|
@@ -152,16 +176,19 @@ export function assign(
|
|
|
152
176
|
): Assignment {
|
|
153
177
|
const policy = routing.classes[task.class];
|
|
154
178
|
const trace: string[] = [`class ${task.class}${policy ? "" : " (no [classes] entry: only an explicit owner can take it)"}`, `signals: ${task.signals.join(", ") || "none"}`];
|
|
179
|
+
// A reserved owner (issue #207) is offered the task first. Named candidates (an assign, an escalation, a relay) replace it.
|
|
180
|
+
const reserved = opts.candidates ? undefined : task.reserved ?? undefined;
|
|
155
181
|
// Readiness is an input like peer states (issue #34): a task that waits for others goes to nobody yet.
|
|
156
|
-
if (opts.waitsFor?.length) return { trace: [...trace, `blocked: waits for ${opts.waitsFor.map((id) => `#${id}`).join(", ")} (not approved)`] };
|
|
182
|
+
if (opts.waitsFor?.length) return { trace: [...trace, `blocked: waits for ${opts.waitsFor.map((id) => `#${id}`).join(", ")} (not approved)`, ...(reserved ? [`reserved owner ${reserved}: offered first once it is ready`] : [])] };
|
|
157
183
|
const pii = task.signals.includes("pii") && routing.constraints.pii === "local_only";
|
|
158
184
|
|
|
159
185
|
const blocked = (peer: PeerId, role: "owner" | "reviewer"): string | undefined => {
|
|
160
|
-
if (!(peer
|
|
186
|
+
if (!Object.hasOwn(states, peer)) return "not attached";
|
|
161
187
|
if (opts.exclude?.includes(peer)) return "excluded (declined or replaced)";
|
|
162
188
|
if (states[peer] === "offline") return "offline";
|
|
163
189
|
if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return "paused";
|
|
164
|
-
|
|
190
|
+
const failing = peerEntry(opts.failing, peer);
|
|
191
|
+
if (failing) return `failing: ${failing}`;
|
|
165
192
|
if (pii && peer !== LOCAL) return "pii: on-prem peers only";
|
|
166
193
|
if (peer === LOCAL && policy?.local_allowed === false) return "local_allowed = false for this class";
|
|
167
194
|
if (peer === PI && policy?.local_allowed === false) return "local_allowed = false also excludes pi for this class";
|
|
@@ -176,7 +203,7 @@ export function assign(
|
|
|
176
203
|
|
|
177
204
|
/** Quota that resets soonest gets used first: headroom per hour left in the window, at least 15 min (a task assigned that close to a reset mostly runs after it). */
|
|
178
205
|
const drain = (p: PeerId): number | undefined => {
|
|
179
|
-
const q = opts.quota
|
|
206
|
+
const q = peerEntry(opts.quota, p);
|
|
180
207
|
return q?.resetsAt === undefined ? undefined : q.headroom / Math.max((q.resetsAt - (opts.now ?? 0)) / 3_600_000, 0.25);
|
|
181
208
|
};
|
|
182
209
|
/** Peers with readings swap places among themselves by drain rate; peers without (local, pi) keep theirs. */
|
|
@@ -193,11 +220,11 @@ export function assign(
|
|
|
193
220
|
/** Demoted peers go behind the rest for this class (owners only); each group is then ordered by quota. */
|
|
194
221
|
/** Reviewers with enough recorded reviews of this owner's work swap places by how those reviews held up. */
|
|
195
222
|
const byRecord = (list: PeerId[], owner: PeerId | undefined): PeerId[] => {
|
|
196
|
-
const record = owner ? opts.reviews
|
|
223
|
+
const record = owner ? peerEntry(opts.reviews, owner) : undefined;
|
|
197
224
|
if (!record || !Object.keys(record).length) return list;
|
|
198
225
|
trace.push(` review record with ${owner} in ${task.class}: ${Object.entries(record).map(([r, s]) => `${r} ${s.n} reviews, ${Math.round(s.score * 100)}% held`).join("; ")}${opts.adaptive ? "" : " (review.adaptive is off)"}`);
|
|
199
226
|
if (!opts.adaptive) return list;
|
|
200
|
-
const known = (p: PeerId) => (record
|
|
227
|
+
const known = (p: PeerId) => { const r = peerEntry(record, p); return r && r.n >= opts.adaptive!.min ? r.score : undefined; };
|
|
201
228
|
const slots = list.flatMap((p, i) => (known(p) === undefined ? [] : [i]));
|
|
202
229
|
const sorted = slots.map((i) => list[i]!).sort((a, b) => known(b)! - known(a)!);
|
|
203
230
|
const out = [...list];
|
|
@@ -205,7 +232,7 @@ export function assign(
|
|
|
205
232
|
return out;
|
|
206
233
|
};
|
|
207
234
|
const rank = (ok: PeerId[], role: "owner" | "reviewer", owner?: PeerId): PeerId[] => {
|
|
208
|
-
const down = role === "owner" ? ok.filter((p) => opts.demoted
|
|
235
|
+
const down = role === "owner" ? ok.filter((p) => peerEntry(opts.demoted, p)) : [];
|
|
209
236
|
if (down.length) trace.push(` demoted for ${task.class}: ${down.map((p) => `${p} (${opts.demoted![p]!.toFixed(1)} recent failures)`).join(", ")}`);
|
|
210
237
|
const ranked = [...byDrain(ok.filter((p) => !down.includes(p))), ...byDrain(down)];
|
|
211
238
|
// The review record comes after quota, so it decides among reviewers that have one: idle, then record, then quota.
|
|
@@ -221,16 +248,20 @@ export function assign(
|
|
|
221
248
|
}
|
|
222
249
|
const ranked = rank(ok, role, not);
|
|
223
250
|
// Demotion applies to local and Pi too: a demoted one loses its place ahead of the cloud peers.
|
|
224
|
-
const localTier = ranked.filter((p) => (p === LOCAL || p === PI) && !(role === "owner" && opts.demoted
|
|
251
|
+
const localTier = ranked.filter((p) => (p === LOCAL || p === PI) && !(role === "owner" && peerEntry(opts.demoted, p)));
|
|
225
252
|
if (localTier.length) return localTier.find((p) => states[p] === "idle") ?? localTier[0]; // local/Pi stays ahead of an idle cloud peer
|
|
226
253
|
return ranked.find((p) => states[p] === "idle") ?? ranked[0];
|
|
227
254
|
};
|
|
228
255
|
|
|
256
|
+
// The PII constraint, capability limits, exclusions and peer states still apply: when they pass it over, routing proceeds.
|
|
257
|
+
const unreserved = reserved ? blocked(reserved, "owner") : undefined;
|
|
258
|
+
if (reserved) trace.push(`reserved owner ${reserved}: ${unreserved ? `not honored, ${unreserved}; routing proceeds` : "honored"}`);
|
|
229
259
|
// Never the task's current owner by default: a decline or an escalation has to reach the next peer in the list.
|
|
230
|
-
const wanted = opts.candidates ?? policy?.peers ?? [];
|
|
260
|
+
const wanted = reserved && !unreserved ? [reserved] : opts.candidates ?? policy?.peers ?? [];
|
|
231
261
|
const owner = pick(wanted, "owner");
|
|
232
262
|
trace.push(owner ? `owner: ${owner}` : "owner: none available, task stays proposed (ahub task assign <id> <peer>)");
|
|
233
|
-
|
|
263
|
+
const ownerHold = owner ? peerEntry(opts.held, owner) : undefined;
|
|
264
|
+
if (ownerHold) trace.push(` hold: ${owner}'s queue is held: ${ownerHold} (it receives the task once the hold is resolved)`);
|
|
234
265
|
|
|
235
266
|
let reviewer: PeerId | undefined;
|
|
236
267
|
if (task.class !== "review") {
|
|
@@ -242,7 +273,8 @@ export function assign(
|
|
|
242
273
|
trace.push(` reviewer candidates: [classes.review] peers ${classPeers.join(", ") || "none"}${rolePeers.length ? `; reviewer role ${rolePeers.join(", ")}` : ""}`);
|
|
243
274
|
reviewer = pick([...new Set([...classPeers, ...rolePeers])], "reviewer", owner ?? opts.notReviewer);
|
|
244
275
|
trace.push(reviewer ? `reviewer: ${reviewer}` : "reviewer: none, done will approve directly");
|
|
245
|
-
|
|
276
|
+
const reviewerHold = reviewer ? peerEntry(opts.held, reviewer) : undefined;
|
|
277
|
+
if (reviewerHold) trace.push(` hold: ${reviewer}'s queue is held: ${reviewerHold} (it receives the review once the hold is resolved)`);
|
|
246
278
|
}
|
|
247
279
|
}
|
|
248
280
|
const route = policy?.route ?? routing.local.route;
|
|
@@ -250,7 +282,7 @@ export function assign(
|
|
|
250
282
|
const piBackend = policy?.pi_backend;
|
|
251
283
|
if (owner === LOCAL) trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
|
|
252
284
|
if (owner === PI) trace.push(`pi 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}` : ""}`);
|
|
253
|
-
return { ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), trace };
|
|
285
|
+
return { ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), trace };
|
|
254
286
|
}
|
|
255
287
|
|
|
256
288
|
/**
|
package/src/hub/snapshots.ts
CHANGED
|
@@ -60,11 +60,12 @@ export function snapshot(repo: Repo): string | undefined {
|
|
|
60
60
|
copyFileSync(index, env.GIT_INDEX_FILE);
|
|
61
61
|
utimesSync(env.GIT_INDEX_FILE, atime, mtime);
|
|
62
62
|
}
|
|
63
|
-
|
|
63
|
+
// `ahub reset --all` archives hub.db (task text, PII included) under .agenthub/archive: never copy it either.
|
|
64
|
+
if (git(repo.top, ["add", "-A", "--", scope(repo), ":(exclude,glob)**/.agenthub/state/**", ":(exclude,glob)**/.agenthub/archive/**"], env).status !== 0) return undefined;
|
|
64
65
|
// The copy starts from the user's index: hub state somebody tracked or staged is still in it, and the exclude above
|
|
65
66
|
// only keeps `add` from touching it. Take it out explicitly; -f because staged state the hub has rewritten since
|
|
66
67
|
// matches neither HEAD nor the file, which `rm --cached` otherwise refuses. Only the copy changes.
|
|
67
|
-
if (git(repo.top, ["rm", "-r", "-f", "--cached", "--quiet", "--ignore-unmatch", "--", ":(glob)**/.agenthub/state/**"], env).status !== 0) return undefined;
|
|
68
|
+
if (git(repo.top, ["rm", "-r", "-f", "--cached", "--quiet", "--ignore-unmatch", "--", ":(glob)**/.agenthub/state/**", ":(glob)**/.agenthub/archive/**"], env).status !== 0) return undefined;
|
|
68
69
|
const r = git(repo.top, ["write-tree"], env);
|
|
69
70
|
return r.status === 0 ? r.stdout.trim() : undefined;
|
|
70
71
|
} finally {
|