@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.
- package/CHANGELOG.md +17 -0
- package/README.md +16 -6
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +17 -3
- package/docs/agent-notes/budget.md +4 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +14 -9
- package/docs/agent-notes/tasks.md +9 -4
- package/docs/agent-notes/tests.md +3 -3
- package/docs/dogfood.md +9 -4
- package/docs/events.md +21 -7
- package/docs/operations.md +90 -18
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +9 -3
- package/docs/sdlc.md +17 -0
- package/docs/security.md +37 -10
- package/docs/specs/2026-09-19-agent-hub-design.md +74 -10
- package/docs/verification/2026-10-23-0.15.0.md +80 -0
- package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
- package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
- package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
- package/docs/verified.json +29 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +992 -312
- package/src/adapters/acp.ts +43 -7
- package/src/adapters/claude-channel.ts +26 -4
- package/src/adapters/claude-implementer.ts +243 -0
- package/src/adapters/claude-print-child.ts +90 -0
- package/src/adapters/claude-task-sessions.ts +80 -0
- package/src/adapters/compat.ts +4 -0
- package/src/adapters/conformance/enrollment.ts +2 -1
- package/src/adapters/conformance/fakes/acp-server.ts +3 -1
- package/src/adapters/definitions/builtin-metadata.ts +14 -2
- package/src/adapters/definitions/native-channel-state.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/kimi-wire-usage.ts +303 -0
- package/src/adapters/registry-types.ts +7 -3
- package/src/adapters/registry.ts +14 -2
- package/src/adapters/reviewer-print.ts +30 -53
- package/src/cli/console-config.ts +123 -0
- package/src/cli/console-state.ts +28 -1
- package/src/cli/console.ts +25 -1
- package/src/cli/facts-hook.ts +9 -5
- package/src/cli/help.ts +4 -3
- package/src/cli/launch.ts +10 -4
- package/src/cli/main.ts +47 -9
- package/src/cli/output.ts +28 -0
- package/src/cli/reset.ts +1 -1
- package/src/cli/settings-edit.ts +89 -0
- package/src/cli/terminal-recovery.ts +1 -6
- package/src/hub/action-enforce.ts +82 -0
- package/src/hub/action-policy.ts +22 -10
- package/src/hub/board.ts +26 -2
- package/src/hub/budget.ts +5 -0
- package/src/hub/bus.ts +5 -5
- package/src/hub/claude-launch-proof.ts +33 -0
- package/src/hub/config-apply.ts +5 -0
- package/src/hub/config-edit-host.ts +173 -0
- package/src/hub/config-edit.ts +282 -0
- package/src/hub/config-tier.ts +89 -0
- package/src/hub/control-client.ts +24 -3
- package/src/hub/daemon.ts +368 -59
- package/src/hub/deliverable-files.ts +131 -0
- package/src/hub/deliverables.ts +125 -0
- package/src/hub/delivery-journal.ts +18 -2
- package/src/hub/events.ts +3 -1
- package/src/hub/hub-tools.ts +19 -3
- package/src/hub/instances.ts +7 -4
- package/src/hub/kimi-usage.ts +144 -0
- package/src/hub/manager.ts +1 -1
- package/src/hub/peers.ts +7 -0
- package/src/hub/permission-payload.ts +15 -0
- package/src/hub/routing.ts +118 -3
- package/src/hub/settings.ts +1 -1
- package/src/hub/tasks.ts +202 -15
- package/src/hub/token-window.ts +33 -0
- package/src/hub/usage-rollup.ts +16 -6
- package/src/hub/workflow.ts +16 -0
- package/src/ui/index.html +115 -1
package/src/hub/routing.ts
CHANGED
|
@@ -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"
|
|
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" &&
|
|
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
|
/**
|
package/src/hub/settings.ts
CHANGED
|
@@ -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: "
|
|
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") {
|