@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +7 -6
  3. package/docs/agent-notes/adapters.md +10 -2
  4. package/docs/agent-notes/bus.md +6 -2
  5. package/docs/agent-notes/daemon.md +10 -3
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/models.md +3 -0
  8. package/docs/agent-notes/tasks.md +6 -3
  9. package/docs/agent-notes/tests.md +3 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +6 -2
  12. package/docs/operations.md +655 -34
  13. package/docs/quickstart.md +12 -10
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +59 -17
  16. package/docs/smoke.md +137 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
  18. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  19. package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
  20. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  21. package/docs/verified.json +29 -18
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +65 -26
  25. package/src/adapters/acp.ts +67 -6
  26. package/src/adapters/claude-channel.ts +61 -20
  27. package/src/adapters/codex-appserver.ts +70 -2
  28. package/src/adapters/local-worker.ts +8 -3
  29. package/src/adapters/pi.ts +58 -4
  30. package/src/cli/bench.ts +287 -0
  31. package/src/cli/console-state.ts +531 -110
  32. package/src/cli/console.ts +59 -26
  33. package/src/cli/facts-hook.ts +50 -8
  34. package/src/cli/help.ts +130 -0
  35. package/src/cli/identity.ts +11 -5
  36. package/src/cli/launch.ts +98 -17
  37. package/src/cli/main.ts +325 -88
  38. package/src/cli/preview.ts +14 -3
  39. package/src/cli/reset.ts +194 -0
  40. package/src/cli/status-lines.ts +10 -5
  41. package/src/cli/terminal-recovery.ts +196 -40
  42. package/src/cli/upgrade-runtime.ts +327 -60
  43. package/src/cli/upgrade.ts +442 -45
  44. package/src/hub/ask.ts +1 -1
  45. package/src/hub/bench.ts +211 -0
  46. package/src/hub/board.ts +15 -7
  47. package/src/hub/bus.ts +79 -8
  48. package/src/hub/child-process.ts +1 -0
  49. package/src/hub/conductor.ts +54 -21
  50. package/src/hub/config-trust.ts +1 -0
  51. package/src/hub/control-client.ts +9 -4
  52. package/src/hub/daemon.ts +401 -48
  53. package/src/hub/delivery-journal.ts +6 -6
  54. package/src/hub/events.ts +9 -2
  55. package/src/hub/execution-budget.ts +15 -6
  56. package/src/hub/hub-tools.ts +4 -4
  57. package/src/hub/inference.ts +136 -1
  58. package/src/hub/lifecycle.ts +13 -12
  59. package/src/hub/manager.ts +16 -14
  60. package/src/hub/peers.ts +2 -0
  61. package/src/hub/permission-mode.ts +44 -0
  62. package/src/hub/recovery-store.ts +68 -8
  63. package/src/hub/registry.ts +36 -22
  64. package/src/hub/report.ts +43 -1
  65. package/src/hub/research.ts +255 -0
  66. package/src/hub/restart.ts +25 -0
  67. package/src/hub/routing.ts +48 -16
  68. package/src/hub/snapshots.ts +3 -2
  69. package/src/hub/tasks.ts +295 -115
  70. package/src/hub/tool-refusal.ts +7 -0
  71. package/src/hub/ui.ts +4 -1
  72. package/src/local/deny.ts +2 -1
  73. package/src/local/tools.ts +14 -10
  74. package/src/models/mlx.ts +11 -8
  75. package/src/models/ollama.ts +2 -2
  76. package/src/models/relay.ts +190 -42
  77. package/src/models/route/relay-selector.ts +43 -31
  78. package/src/models/route/runtime.ts +17 -7
  79. package/src/models/route/signals.ts +12 -1
  80. package/src/models/route/stage.ts +57 -3
  81. package/src/omniroute/client.ts +1 -1
  82. package/src/pi/process-signature.ts +23 -0
  83. package/src/pi/tool-receipts.ts +7 -1
  84. package/src/ui/index.html +84 -6
  85. package/src/ui/task-progress.ts +33 -0
  86. package/templates/AGENTS.block.md +1 -1
  87. package/templates/routing.toml +8 -0
@@ -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
- signals: { pii_patterns: string[]; long_context_tokens: number };
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
- pi: { dgx_max_context_tokens: number; mlx_max_context_tokens: number };
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 in states)) return "not attached";
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
- if (opts.failing?.[peer]) return `failing: ${opts.failing[peer]}`;
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?.[p];
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?.[owner] : undefined;
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[p] && record[p].n >= opts.adaptive!.min ? record[p].score : undefined);
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?.[p]) : [];
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?.[p]));
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
- if (owner && opts.held?.[owner]) trace.push(` hold: ${owner}'s queue is held: ${opts.held[owner]} (it receives the task once the hold is resolved)`);
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
- if (reviewer && opts.held?.[reviewer]) trace.push(` hold: ${reviewer}'s queue is held: ${opts.held[reviewer]} (it receives the review once the hold is resolved)`);
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
  /**
@@ -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
- if (git(repo.top, ["add", "-A", "--", scope(repo), ":(exclude,glob)**/.agenthub/state/**"], env).status !== 0) return undefined;
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 {