@staix/agent-hub 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +16 -6
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +17 -3
  5. package/docs/agent-notes/budget.md +4 -4
  6. package/docs/agent-notes/bus.md +1 -0
  7. package/docs/agent-notes/daemon.md +14 -9
  8. package/docs/agent-notes/tasks.md +9 -4
  9. package/docs/agent-notes/tests.md +3 -3
  10. package/docs/dogfood.md +9 -4
  11. package/docs/events.md +21 -7
  12. package/docs/operations.md +90 -18
  13. package/docs/profiles.md +1 -0
  14. package/docs/quickstart.md +9 -3
  15. package/docs/sdlc.md +17 -0
  16. package/docs/security.md +37 -10
  17. package/docs/specs/2026-09-19-agent-hub-design.md +74 -10
  18. package/docs/verification/2026-10-23-0.15.0.md +80 -0
  19. package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
  20. package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
  21. package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
  22. package/docs/verified.json +29 -17
  23. package/package.json +1 -1
  24. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  25. package/plugins/agent-hub/server.js +992 -312
  26. package/src/adapters/acp.ts +43 -7
  27. package/src/adapters/claude-channel.ts +26 -4
  28. package/src/adapters/claude-implementer.ts +243 -0
  29. package/src/adapters/claude-print-child.ts +90 -0
  30. package/src/adapters/claude-task-sessions.ts +80 -0
  31. package/src/adapters/compat.ts +4 -0
  32. package/src/adapters/conformance/enrollment.ts +2 -1
  33. package/src/adapters/conformance/fakes/acp-server.ts +3 -1
  34. package/src/adapters/definitions/builtin-metadata.ts +14 -2
  35. package/src/adapters/definitions/native-channel-state.ts +1 -1
  36. package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
  37. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  38. package/src/adapters/definitions/native-factories.ts +8 -0
  39. package/src/adapters/kimi-wire-usage.ts +303 -0
  40. package/src/adapters/registry-types.ts +7 -3
  41. package/src/adapters/registry.ts +14 -2
  42. package/src/adapters/reviewer-print.ts +30 -53
  43. package/src/cli/console-config.ts +123 -0
  44. package/src/cli/console-state.ts +28 -1
  45. package/src/cli/console.ts +25 -1
  46. package/src/cli/facts-hook.ts +9 -5
  47. package/src/cli/help.ts +4 -3
  48. package/src/cli/launch.ts +10 -4
  49. package/src/cli/main.ts +47 -9
  50. package/src/cli/output.ts +28 -0
  51. package/src/cli/reset.ts +1 -1
  52. package/src/cli/settings-edit.ts +89 -0
  53. package/src/cli/terminal-recovery.ts +1 -6
  54. package/src/hub/action-enforce.ts +82 -0
  55. package/src/hub/action-policy.ts +22 -10
  56. package/src/hub/board.ts +26 -2
  57. package/src/hub/budget.ts +5 -0
  58. package/src/hub/bus.ts +5 -5
  59. package/src/hub/claude-launch-proof.ts +33 -0
  60. package/src/hub/config-apply.ts +5 -0
  61. package/src/hub/config-edit-host.ts +173 -0
  62. package/src/hub/config-edit.ts +282 -0
  63. package/src/hub/config-tier.ts +89 -0
  64. package/src/hub/control-client.ts +24 -3
  65. package/src/hub/daemon.ts +368 -59
  66. package/src/hub/deliverable-files.ts +131 -0
  67. package/src/hub/deliverables.ts +125 -0
  68. package/src/hub/delivery-journal.ts +18 -2
  69. package/src/hub/events.ts +3 -1
  70. package/src/hub/hub-tools.ts +19 -3
  71. package/src/hub/instances.ts +7 -4
  72. package/src/hub/kimi-usage.ts +144 -0
  73. package/src/hub/manager.ts +1 -1
  74. package/src/hub/peers.ts +7 -0
  75. package/src/hub/permission-payload.ts +15 -0
  76. package/src/hub/routing.ts +118 -3
  77. package/src/hub/settings.ts +1 -1
  78. package/src/hub/tasks.ts +202 -15
  79. package/src/hub/token-window.ts +33 -0
  80. package/src/hub/usage-rollup.ts +16 -6
  81. package/src/hub/workflow.ts +16 -0
  82. package/src/ui/index.html +115 -1
@@ -309,6 +309,9 @@ export interface Assignment {
309
309
  refusals?: RoutingRefusal[];
310
310
  owner?: PeerId;
311
311
  reviewer?: PeerId;
312
+ /** #525-3: the task's planner, from the planner walk. Read-only information until 525-2 records it; absent for a PII task or when nobody holds the role. */
313
+ planner?: PeerId;
314
+ plannerReason?: PlannerReason;
312
315
  /** What local should ask for on this task: a hub route or retired non-hub selection, with its fixed fallback. */
313
316
  route?: string;
314
317
  fixedModel?: string;
@@ -318,6 +321,79 @@ export interface Assignment {
318
321
  trace: string[];
319
322
  }
320
323
 
324
+ /**
325
+ * The role a class's owner works in (#735): `plan` is the planner's, `review` the reviewer's, and every other class (implement, bulk_edit,
326
+ * test, summarize, triage, and a profile class that maps onto one) is done by an implementer, which needs write access. Derived, never a
327
+ * list of the write classes, so a class added later is a write class unless it says otherwise here.
328
+ */
329
+ export function ownerRoleOfClass(cls: TaskClass): "planner" | "implementer" | "reviewer" {
330
+ return cls === "review" ? "reviewer" : cls === "plan" ? "planner" : "implementer";
331
+ }
332
+
333
+ /** Why a planner was chosen: the person named it, rotation reached it, the task's recorded planner is kept, or there is none (PII, no holder). */
334
+ export type PlannerReason = "rotation" | "named" | "kept" | "none";
335
+
336
+ /**
337
+ * I-2 (#525-3): the peers that implemented a task, read from its own history: any peer with an `accepted` or `done` entry. A reviewer is
338
+ * never one of them, so a multi-role holder (implementer and reviewer) never reviews a change it made, even after the task changed hands.
339
+ * Pure: history in, set out. A `done` by the person is not a peer's implementation and is left out.
340
+ */
341
+ export function implementers(history: readonly { by: PeerId; event: string }[] | undefined): Set<PeerId> {
342
+ return new Set((history ?? []).filter(entry => (entry.event === "accepted" || entry.event === "done") && entry.by !== "user").map(entry => entry.by));
343
+ }
344
+
345
+ export interface PlannerWalkInput {
346
+ /** The settled planner holders, sorted by id. The walk sorts again, so an unsorted input cannot change the result. */
347
+ planners: readonly PeerId[];
348
+ /** The rotation seed: the task id. */
349
+ seed: number;
350
+ /** A person-named planner: exact, never replaced by rotation. */
351
+ named?: PeerId;
352
+ /** The planner already recorded on the task: kept while it is still a holder. */
353
+ kept?: PeerId;
354
+ /** Why a holder cannot take planning right now; undefined when it can. */
355
+ blocked(peer: PeerId): string | undefined;
356
+ /** A PII task has no planner: its planning stays with the person. */
357
+ pii: boolean;
358
+ }
359
+ export interface PlannerWalk { planner?: PeerId; reason: PlannerReason; walk: { peer: PeerId; note: string }[]; line: string }
360
+
361
+ /**
362
+ * The planner walk (#525 R3, "Choice"): pure, so assignment and `ahub route explain` print the same lines.
363
+ * - a PII task has no planner;
364
+ * - a named planner is exact: a name that is not a holder or cannot plan is refused in the trace, never replaced by rotation;
365
+ * - otherwise the recorded planner is kept while it is still a holder;
366
+ * - otherwise rotation: holders sorted by id, starting at `seed mod n`, walking forward past unavailable holders. The denominator is every
367
+ * holder, availability never renumbers it (the #519 seed rule). When all are skipped, the seed holder is taken, "rotation (none available)".
368
+ */
369
+ export function plannerWalk(input: PlannerWalkInput): PlannerWalk {
370
+ const holders = [...new Set(input.planners)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
371
+ const walk: { peer: PeerId; note: string }[] = [];
372
+ const finish = (reason: PlannerReason, planner: PeerId | undefined, detail: string): PlannerWalk => ({ ...(planner ? { planner } : {}), reason, walk, line: planner ? `planner: ${planner} (${detail})` : `planner: none (${detail})` });
373
+ if (input.pii) return finish("none", undefined, "pii: planning stays with the person");
374
+ if (!holders.length) return finish("none", undefined, "no planner holder");
375
+ if (input.named !== undefined) {
376
+ if (!holders.includes(input.named)) { walk.push({ peer: input.named, note: "skipped, not a planner holder" }); return finish("none", undefined, `named ${input.named} is not a planner holder`); }
377
+ const why = input.blocked(input.named);
378
+ walk.push({ peer: input.named, note: why ? `named, ${why}` : "named" });
379
+ return finish("named", input.named, why ? `named; ${why}` : "named");
380
+ }
381
+ if (input.kept !== undefined && holders.includes(input.kept)) {
382
+ walk.push({ peer: input.kept, note: "kept: already the task's planner" });
383
+ return finish("kept", input.kept, "kept");
384
+ }
385
+ const seed = Number.isSafeInteger(input.seed) ? input.seed : 0;
386
+ const start = ((seed % holders.length) + holders.length) % holders.length;
387
+ for (let step = 0; step < holders.length; step++) {
388
+ const peer = holders[(start + step) % holders.length]!;
389
+ const why = input.blocked(peer);
390
+ walk.push({ peer, note: why ? `skipped, ${why}` : "available" });
391
+ if (!why) return finish("rotation", peer, step === 0 ? "rotation" : "rotation, skipped unavailable holders");
392
+ }
393
+ const seeded = holders[start]!;
394
+ return finish("rotation", seeded, "rotation (none available)");
395
+ }
396
+
321
397
  /**
322
398
  * L1. A pure function of policy, task and peer states, so `ahub route explain` runs exactly what assignment runs and
323
399
  * prints its trace. `states`: effective bus states of attached peers (a peer that is not in the map is not attached).
@@ -338,6 +414,11 @@ export function assign(
338
414
  pullOnly?: PeerId[];
339
415
  namedOwner?: PeerId;
340
416
  namedReviewer?: PeerId;
417
+ /** #525-3: the settled planner holders (sorted ids) and a person-named planner. Absent `planners`: no planner walk, as before. */
418
+ planners?: readonly PeerId[];
419
+ namedPlanner?: PeerId;
420
+ /** The planner already recorded on the task (525-2): kept while it is still a holder. */
421
+ keptPlanner?: PeerId;
341
422
  reviewPool?: { pool: readonly string[]; rerotate?: boolean; failed?: readonly string[]; required?: boolean };
342
423
  candidates?: PeerId[];
343
424
  notReviewer?: PeerId;
@@ -376,11 +457,14 @@ export function assign(
376
457
  if (!pii) try { needsSpec = specRefs({ title: task.title ?? "", detail: task.detail ?? "", refs: task.refs ?? {}, external: task.external }, opts.repository ?? "", { bareTitle: isGitHubRepository(opts.repository) }).length > 0; }
377
458
  catch { needsSpec = true; } // an invalid/foreign reference never grants a sandboxed peer spec access
378
459
  const needsTests = ["implement", "test", "bulk_edit"].includes(task.class);
460
+ // #735: the owner needs write access whenever its role for this class is the implementer's, not only for the three classes that need tests.
461
+ const ownerWrites = ownerRoleOfClass(task.class) === "implementer";
379
462
  const refusals = new Map<string, RoutingRefusal>();
463
+ const former = implementers(task.history);
380
464
  const blocked = (peer: PeerId, role: "owner" | "reviewer"): string | undefined => {
381
465
  const deny = (code: RoutingRefusalCode, reason: string): string => { refusals.set(`${role}:${peer}`, { peer, role, code, reason }); return reason; };
382
466
  const ceiling = (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.routing?.allowedRoles;
383
- const requiredRole = role === "reviewer" || task.class === "review" ? "reviewer" : task.class === "plan" ? "planner" : "implementer";
467
+ const requiredRole = role === "reviewer" ? "reviewer" : ownerRoleOfClass(task.class);
384
468
  if (ceiling && !ceiling.includes(requiredRole)) return deny("policy", "adapter role ceiling excludes this assignment");
385
469
  const unavailable = role === "reviewer" ? (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.reviewer?.unavailable : undefined;
386
470
  if (unavailable) return deny("policy", unavailable);
@@ -397,7 +481,7 @@ export function assign(
397
481
  if (role === "owner" && opts.excludeOwners?.includes(peer)) return deny("owner-admission", "owner admission: WIP or delivery hold");
398
482
  const adapter = (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances);
399
483
  if (role === "owner" && adapter?.routing?.reviewOnly) return deny("owner-admission", "owner admission: a review-only instance owns no task");
400
- if (role === "owner" && needsTests && adapter && adapter.kind !== peer) {
484
+ if (role === "owner" && ownerWrites && adapter && adapter.kind !== peer) {
401
485
  if (!peerEntry(opts.roles, peer)?.includes("implementer")) return deny("owner-admission", "owner admission: declared write owner requires the current implementer role");
402
486
  if (peerEntry(opts.ownerIsolation, peer) !== true) return deny("owner-admission", "owner admission: declared write owner requires a verified isolated execution root");
403
487
  }
@@ -414,6 +498,10 @@ export function assign(
414
498
  if (routing.signals.long_context_tokens > limit) return deny("policy", `${nativeRouting.backendLabel} ${backend} capability limit ${limit} tokens`);
415
499
  }
416
500
  if (nativeRouting?.longContext === "skip-owner" && role === "owner" && task.signals.includes("long_context") && routing.constraints.long_context === "skip_local") return deny("policy", "long_context: skip local");
501
+ // I-2 (#525-3): a peer that implemented this task never reviews it, whoever holds it now, a named reviewer included. It is the LAST check,
502
+ // so it only ever removes a candidate that was otherwise eligible: a former implementer that is offline, paused, failing or off roster is
503
+ // refused for that real reason and leaves the outcome exactly as it was before I-2 (a task with no other reviewer still approves directly).
504
+ if (role === "reviewer" && former.has(peer)) return deny("policy", "implemented this task");
417
505
  return undefined;
418
506
  };
419
507
  /** 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). */
@@ -479,6 +567,27 @@ export function assign(
479
567
  const ownerHold = owner ? peerEntry(opts.held, owner) : undefined;
480
568
  if (ownerHold) trace.push(` hold: ${owner}'s queue is held: ${ownerHold} (it receives the task once the hold is resolved)`);
481
569
 
570
+ // #525-3: the planner is chosen by its own walk over the settled planner holders, independent of the owner and the reviewer (planner and
571
+ // owner, or planner and reviewer, may be the same peer). Information only until 525-2 records it: nothing here moves the task.
572
+ let planner: PeerId | undefined, plannerReason: PlannerReason | undefined;
573
+ if (opts.planners !== undefined) {
574
+ const plannerBlocked = (peer: PeerId): string | undefined => {
575
+ if ((opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.routing?.allowedRoles?.includes("planner") === false) return "adapter role ceiling excludes planning";
576
+ if (opts.offRoster?.includes(peer)) return "off roster";
577
+ if (!Object.hasOwn(states, peer)) return "not attached";
578
+ if (states[peer] === "offline") return "offline";
579
+ if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return "paused";
580
+ const failing = peerEntry(opts.failing, peer);
581
+ return failing ? `failing: ${failing}` : undefined;
582
+ };
583
+ const walked = plannerWalk({ planners: opts.planners, seed: task.id ?? 0, ...(opts.namedPlanner !== undefined ? { named: opts.namedPlanner } : {}),
584
+ ...(opts.keptPlanner !== undefined ? { kept: opts.keptPlanner } : {}), blocked: plannerBlocked, pii });
585
+ for (const row of walked.walk) trace.push(` planner candidate ${row.peer}: ${row.note}`);
586
+ trace.push(walked.line);
587
+ planner = walked.planner;
588
+ plannerReason = walked.reason;
589
+ }
590
+
482
591
  let reviewer: PeerId | undefined;
483
592
  let sameFamilyFallback = false;
484
593
  let plannerFallback = false;
@@ -548,6 +657,12 @@ export function assign(
548
657
  sameFamilyFallback = true;
549
658
  trace.push(` same-family fallback: ${reviewer} and ${reviewOwner} share ${ownerFamily}; no eligible other family`);
550
659
  }
660
+ // I-2 (#525-3): when the peers that implemented this task were the only candidates left, the person reviews. Never a fallback to an implementer.
661
+ if (!reviewer && [...refusals.values()].some(row => row.role === "reviewer" && row.code === "policy" && row.reason === "implemented this task")) {
662
+ reviewer = "user";
663
+ reviewBlocked = true;
664
+ trace.push(" every eligible reviewer implemented this task: the person reviews");
665
+ }
551
666
  trace.push(reviewer ? `reviewer: ${reviewer}` : "reviewer: none, done will approve directly");
552
667
  const reviewerHold = reviewer ? peerEntry(opts.held, reviewer) : undefined;
553
668
  if (reviewerHold) trace.push(` hold: ${reviewer}'s queue is held: ${reviewerHold} (it receives the review once the hold is resolved)`);
@@ -558,7 +673,7 @@ export function assign(
558
673
  const piBackend = policy?.pi_backend;
559
674
  if (owner && (opts.adapterCatalog?.definitionOf ?? definitionOf)(owner, opts.instances)?.routing?.trace === "route") trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
560
675
  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}` : ""}`);
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 };
676
+ return { ...(reviewSelection ? { reviewSelection } : {}), ...(reviewBlocked ? { reviewBlocked: true as const } : {}), ...(sameFamilyFallback ? { sameFamilyFallback: true as const } : {}), ...(plannerFallback ? { plannerFallback: true as const } : {}), ...(owner ? { owner } : {}), ...(planner ? { planner } : {}), ...(plannerReason ? { plannerReason } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), ...(refusals.size ? { refusals: [...refusals.values()] } : {}), trace };
562
677
  }
563
678
 
564
679
  /**
@@ -473,7 +473,7 @@ export function settingRows(input: {
473
473
  const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
474
474
  return projectSettings(input.instances, input.adapterCatalog).map((def): SettingRow => {
475
475
  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 }),
476
- ...(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.key.startsWith("action_policy.") ? { hint: "not enforced yet: adapter integration pending" } : def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!, input.instances, input.adapterCatalog) } : {}) };
476
+ ...(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.key.startsWith("action_policy.") ? { hint: "enforced for Claude (PreToolUse hook) and hub-owned ACP peers in ask mode; ahub status lists each peer that is not enforceable" } : def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!, input.instances, input.adapterCatalog) } : {}) };
477
477
  if (def.locked) return { ...base, values: def.values, file: def.store === "runtime" ? "this hub run" : CONFIG_LOCAL,
478
478
  source: "instance declaration", value: "read-only", fileValue: "read-only", locked: def.locked, note: def.locked };
479
479
  if (def.store === "runtime") {