@staix/agent-hub 0.12.15 → 0.12.16
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 +9 -0
- package/README.md +16 -3
- package/docs/agent-notes/budget.md +5 -1
- package/docs/agent-notes/tasks.md +1 -1
- package/docs/agent-notes/tests.md +2 -1
- package/docs/operations.md +178 -9
- package/docs/quickstart.md +2 -2
- package/docs/security.md +2 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +104 -0
- package/docs/verified.json +159 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4 -4
- package/src/adapters/codex-appserver.ts +7 -0
- package/src/adapters/pi.ts +4 -14
- package/src/cli/init.ts +40 -30
- package/src/cli/launch.ts +18 -1
- package/src/cli/main.ts +34 -11
- package/src/cli/preview.ts +80 -0
- package/src/cli/status-lines.ts +8 -2
- package/src/cli/statusline-tee.ts +14 -0
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/board.ts +5 -2
- package/src/hub/context-window.ts +72 -0
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +126 -32
- package/src/hub/events.ts +1 -0
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/task-sweep.ts +59 -0
- package/src/hub/tasks.ts +57 -1
- package/src/models/relay.ts +6 -1
- package/src/pi/launch.ts +20 -0
- package/src/ui/index.html +6 -0
package/src/hub/tasks.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { assign, detectSignals, LOCAL, PI, predictSplit, type Assignment, type R
|
|
|
8
8
|
import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
|
|
9
9
|
import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
|
|
10
10
|
import { realPath } from "./project.ts";
|
|
11
|
+
import { nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
11
12
|
|
|
12
13
|
export interface TasksDeps {
|
|
13
14
|
board: Board;
|
|
@@ -68,6 +69,9 @@ export interface TasksDeps {
|
|
|
68
69
|
recordSplit?: (task: number, prediction: SplitPrediction, where: "routing" | "cohort") => void;
|
|
69
70
|
/** A cohort formed, changed or was lifted (issue #107), for the record: the benchmark's treatment check reads it. */
|
|
70
71
|
recordCohort?: (cohort: { id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: PeerId[] }) => void;
|
|
72
|
+
sweep?: TaskSweepConfig;
|
|
73
|
+
/** Shutdown or coordinator work holds task sweeps too. */
|
|
74
|
+
sweepHeld?: () => boolean;
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
const ESCALATE_AFTER = 2;
|
|
@@ -80,7 +84,7 @@ const DEMOTE_AT = 1.5;
|
|
|
80
84
|
const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
|
|
81
85
|
const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
82
86
|
/** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
|
|
83
|
-
const QUIET_EVENTS = new Set(["answer", "reviewer changed"]);
|
|
87
|
+
const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep"]);
|
|
84
88
|
|
|
85
89
|
/** A project path as one spelling (#67): no leading `./`, no repeated or trailing `/`; the root is `.`. */
|
|
86
90
|
export const normPath = (p: string) => p.replace(/^(\.\/)+/, "").replace(/\/{2,}/g, "/").replace(/\/+$/, "") || ".";
|
|
@@ -134,8 +138,11 @@ const planText = (plan: TaskPlan = {}) => PLAN_KEYS.filter((k) => plan[k]?.lengt
|
|
|
134
138
|
export class Tasks {
|
|
135
139
|
/** Turn-free cohorts (issue #107): who works without messages, and who integrates. */
|
|
136
140
|
readonly cohorts: Cohorts;
|
|
141
|
+
private readonly sweepConfig: TaskSweepConfig;
|
|
142
|
+
private sweeping = false;
|
|
137
143
|
|
|
138
144
|
constructor(private readonly d: TasksDeps) {
|
|
145
|
+
this.sweepConfig = taskSweepConfig(d.sweep);
|
|
139
146
|
this.cohorts = new Cohorts({
|
|
140
147
|
silence: (owners) => this.turnFree() && owners.every((p) => p !== USER && p !== HUB && (this.d.capable?.(p) ?? false)),
|
|
141
148
|
idle: (peer) => this.d.idle?.(peer) ?? false,
|
|
@@ -157,6 +164,55 @@ export class Tasks {
|
|
|
157
164
|
|
|
158
165
|
isPii = (task: Pick<Task, "signals">) => task.signals.includes("pii") && this.d.routing().constraints.pii === "local_only";
|
|
159
166
|
|
|
167
|
+
/** Task-owned, default-off between-turn ladder. Persist before sending so restart cannot repeat a step. */
|
|
168
|
+
async sweep(now = Date.now()): Promise<{ task: number; finding: SweepRecord }[]> {
|
|
169
|
+
const recorded: { task: number; finding: SweepRecord }[] = [];
|
|
170
|
+
if (!this.sweepConfig.enabled || this.sweeping || this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return recorded;
|
|
171
|
+
this.sweeping = true;
|
|
172
|
+
try {
|
|
173
|
+
for (const task of this.d.board.list()) {
|
|
174
|
+
if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) break;
|
|
175
|
+
const finding = nextSweep(task, this.sweepConfig, now);
|
|
176
|
+
if (!finding || this.waitsFor(task).length || this.isChecking(task.id) || this.silentFor(task.id)) continue;
|
|
177
|
+
const responsible = finding.kind === "review-pending" ? task.reviewer! : task.owner!;
|
|
178
|
+
if (responsible !== USER && !this.sweepAvailable(responsible)) continue;
|
|
179
|
+
const title = this.publicTitle(task);
|
|
180
|
+
const message = `Task ${title}: ${finding.kind}, idle escalation ${finding.step}/3.`;
|
|
181
|
+
// Only identifiers, kind and step are kept in the ladder event. A crash after this write and before publish
|
|
182
|
+
// leaves the notice unpublished or uncertain; the durable-delivery journal owns any delivery retries.
|
|
183
|
+
this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
|
|
184
|
+
recorded.push({ task: task.id, finding });
|
|
185
|
+
if (finding.step === 1) {
|
|
186
|
+
this.sweepNotice(task, responsible, `${message} Check its current state with hub_task_list and accept, complete or review your assigned work.`);
|
|
187
|
+
} else if (finding.step === 2) {
|
|
188
|
+
this.d.notify(message);
|
|
189
|
+
for (const [peer, roles] of Object.entries(this.d.roles ?? {})) {
|
|
190
|
+
if (peer !== responsible && roles.includes("planner") && this.sweepAvailable(peer)) this.sweepNotice(task, peer, `${message} The assigned ${finding.kind === "review-pending" ? "reviewer" : "owner"} has not recorded fresh task activity; check the board before coordinating.`);
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
const reviewer = finding.kind === "review-pending";
|
|
194
|
+
const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), responsible], waitsFor: this.waitsFor(task), ...(reviewer ? { candidates: [], notReviewer: task.owner ?? undefined } : {}), ...this.weights(task.class) });
|
|
195
|
+
const candidate = reviewer ? a.reviewer : a.owner;
|
|
196
|
+
this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
|
|
197
|
+
if (!reviewer && candidate && this.sweepConfig.auto_reassign && this.sweepAvailable(candidate)) {
|
|
198
|
+
await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [candidate], exclude: [responsible], event: "reassigned", note: `idle sweep: ${finding.kind}; from ${responsible}` });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} finally { this.sweeping = false; }
|
|
203
|
+
return recorded;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private sweepAvailable(peer: PeerId): boolean {
|
|
207
|
+
const bus = this.d.bus;
|
|
208
|
+
return bus.stateOf(peer) === "idle" && (this.d.idle?.(peer) ?? true) && !this.d.held?.()[peer] && !bus.queueSummary(peer).heldBy && !bus.queued(peer) && !bus.hasInFlight(peer);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private sweepNotice(task: Task, peer: PeerId, body: string): void {
|
|
212
|
+
if (peer === USER) this.d.notify(body);
|
|
213
|
+
else this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task", refs: { task: String(task.id) } }));
|
|
214
|
+
}
|
|
215
|
+
|
|
160
216
|
configureExecutionBudget = (config: ExecutionBudgetConfig): ExecutionBudgetStatus | undefined => {
|
|
161
217
|
if (!this.d.executionBudget) return undefined;
|
|
162
218
|
if (config.kind === "task" && (!config.taskId || !this.d.board.get(config.taskId))) throw new Error(`task #${config.taskId ?? "?"} does not exist`);
|
package/src/models/relay.ts
CHANGED
|
@@ -324,6 +324,11 @@ function sseResponse(response: Response, release: () => void, onModel?: (model:
|
|
|
324
324
|
|
|
325
325
|
const copyRecord = (record: RelayRequestRecord): RelayRequestRecord => ({ ...record, ...(record.requestUsage ? { requestUsage: { ...record.requestUsage } } : {}), ...(record.toolSurface ? { toolSurface: { ...record.toolSurface, names: [...record.toolSurface.names] } } : {}) });
|
|
326
326
|
|
|
327
|
+
/** Pure advertised alias list, also used by no-start launch previews. */
|
|
328
|
+
export function relayModelIds(options: Pick<ModelRelayOptions, "enableHubAuto" | "mlx" | "mlxAlias" | "allowedDGXmodels">): string[] {
|
|
329
|
+
return [...new Set([...(options.enableHubAuto ? ["hub/auto"] : []), ...(options.mlx ? [options.mlxAlias ?? "mlx/fast"] : []), ...Object.keys(options.allowedDGXmodels)])];
|
|
330
|
+
}
|
|
331
|
+
|
|
327
332
|
export async function startModelRelay(options: ModelRelayOptions): Promise<ModelRelay> {
|
|
328
333
|
const host = options.host ?? "127.0.0.1";
|
|
329
334
|
assertLoopback(host);
|
|
@@ -331,7 +336,7 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
|
|
|
331
336
|
const mlxAlias = options.mlxAlias ?? "mlx/fast";
|
|
332
337
|
const dgxMaxInputTokens = options.dgxMaxInputTokens ?? 262_144;
|
|
333
338
|
const defaultBackend = options.defaultBackend ?? (options.mlx ? { kind: "mlx", alias: mlxAlias } : { kind: "dgx", alias: "dgx/coding" });
|
|
334
|
-
const models =
|
|
339
|
+
const models = relayModelIds(options);
|
|
335
340
|
const autoRoute = options.enableHubAuto ? new AutoRouteSelector(options, defaultBackend, mlxAlias, estimateInputTokens) : undefined;
|
|
336
341
|
let mlx: MlxHandle | undefined;
|
|
337
342
|
let mlxStarting: Promise<MlxHandle> | undefined;
|
package/src/pi/launch.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { PiOptions, PiModelDescriptor, PiTuiLaunch } from "../adapters/pi.ts";
|
|
3
|
+
|
|
4
|
+
const modelFor = (backend: PiOptions["backend"], models: PiModelDescriptor[]): string => {
|
|
5
|
+
const ids = models.map((m) => m.id);
|
|
6
|
+
const find = (needle: string) => ids.find((id) => id === needle) ?? needle;
|
|
7
|
+
if (backend === "mlx") return find("mlx/fast");
|
|
8
|
+
if (backend === "dgx") return find("dgx/coding");
|
|
9
|
+
if (ids.includes("hub/auto")) return "hub/auto";
|
|
10
|
+
return find(ids.find((id) => id === "dgx/coding") ? "dgx/coding" : ids.find((id) => id === "mlx/fast") ?? "dgx/coding");
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Pure native launch; bridge/relay setup and session validation remain with the adapter. */
|
|
14
|
+
export function buildPiLaunch(opts: Pick<PiOptions, "stateDir" | "cmd" | "mode" | "backend" | "model" | "relay" | "tools" | "preamble" | "maxSteps" | "sessionFile" | "sessionId">, inherited: NodeJS.ProcessEnv, extension: string, bridgePort: number | string, bridgeToken: string): PiTuiLaunch {
|
|
15
|
+
const env: NodeJS.ProcessEnv = { ...inherited, PI_CODING_AGENT_DIR: join(opts.stateDir, "pi"), AGENTHUB_PI_BRIDGE_URL: `http://127.0.0.1:${bridgePort}`, AGENTHUB_PI_BRIDGE_TOKEN: bridgeToken, AGENTHUB_PI_OWNER_TOKEN: bridgeToken, AGENTHUB_PI_RELAY_URL: opts.relay.url, AGENTHUB_PI_RELAY_TOKEN: opts.relay.token, AGENTHUB_PI_MODELS: JSON.stringify(opts.relay.models), AGENTHUB_PI_TOOLS: JSON.stringify(opts.tools), AGENTHUB_PI_MAX_STEPS: String(opts.maxSteps ?? 30) };
|
|
16
|
+
const args = [ ...(opts.mode === "headless" ? ["--mode", "rpc"] : []), "--provider", "agent-hub-local", "--model", opts.model ?? modelFor(opts.backend, opts.relay.models), "--models", opts.relay.models.map((model) => `agent-hub-local/${model.id}`).join(","), "--no-builtin-tools", "--no-skills", "--no-prompt-templates", "--no-extensions", "--extension", extension, ...(opts.preamble ? ["--append-system-prompt", opts.preamble] : []), "--session-dir", join(opts.stateDir, "pi-sessions")];
|
|
17
|
+
if (opts.sessionFile) args.push("--session", opts.sessionFile); else if (opts.sessionId) args.push("--session-id", opts.sessionId);
|
|
18
|
+
const command = opts.cmd ?? ["pi"];
|
|
19
|
+
return { cmd: command[0]!, args: [...command.slice(1), ...args], env };
|
|
20
|
+
}
|
package/src/ui/index.html
CHANGED
|
@@ -205,6 +205,12 @@ function render(snapshot) {
|
|
|
205
205
|
if (!Object.keys(snapshot.budget).length) return empty(target, 'No usage readings yet. Usage appears when a peer reports it.');
|
|
206
206
|
for (const [peer, budget] of Object.entries(snapshot.budget)) {
|
|
207
207
|
const row = el('div', undefined, 'row'); row.append(el('div', peer, 'label'));
|
|
208
|
+
const context = budget.context;
|
|
209
|
+
if (context) {
|
|
210
|
+
const pct = context.used === null ? null : Math.round(context.used * 100);
|
|
211
|
+
row.append(el('p', 'Context: ' + (pct === null ? 'unknown' : pct + '% used') + ' (' + context.freshness + ')' + (context.source ? ' / ' + context.source : '') + (context.measuredAt !== null ? ' / measured ' + time(context.measuredAt) : ''), 'meta'));
|
|
212
|
+
if (pct !== null) { const bar = el('progress'); bar.max = 1; bar.value = context.used; bar.setAttribute('aria-label', peer + ' context usage'); row.append(bar); }
|
|
213
|
+
}
|
|
208
214
|
if (budget.paused) { const line = el('p', 'Paused: ' + budget.paused.reason + ' / resets ', 'meta'), stamp = el('span'); stamp.dataset.ts = budget.paused.resetsAt; stamp.title = time(budget.paused.resetsAt); stamp.textContent = rel(budget.paused.resetsAt); line.append(stamp); row.append(line); }
|
|
209
215
|
if (!budget.windows.length) row.append(el('p', 'No usage windows reported.', 'meta'));
|
|
210
216
|
for (const window of budget.windows) {
|