@staix/agent-hub 0.12.2 → 0.12.4
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 +16 -0
- package/docs/cooperbench.md +109 -0
- package/docs/events.md +14 -3
- package/docs/operations.md +158 -5
- package/docs/smoke.md +16 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +66 -0
- package/docs/verification/2026-10-02-0.12.3.md +44 -0
- package/docs/verification/2026-10-02-0.12.4.md +82 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +23 -8
- package/src/adapters/claude-channel.ts +19 -5
- package/src/adapters/codex-appserver.ts +38 -0
- package/src/adapters/local-worker.ts +109 -16
- package/src/adapters/pi.ts +106 -21
- package/src/cli/facts-hook.ts +40 -0
- package/src/cli/launch.ts +45 -4
- package/src/cli/main.ts +44 -2
- package/src/cli/status-lines.ts +2 -1
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/bus.ts +92 -6
- package/src/hub/cohorts.ts +308 -0
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +425 -21
- package/src/hub/events.ts +12 -1
- package/src/hub/execution-budget.ts +138 -0
- package/src/hub/facts.ts +754 -0
- package/src/hub/report.ts +88 -1
- package/src/hub/routing.ts +85 -0
- package/src/hub/tasks.ts +402 -19
- package/src/hub/usage.ts +55 -0
- package/src/local/sandbox.ts +6 -2
- package/src/local/tools.ts +7 -2
- package/src/models/relay.ts +12 -4
- package/src/omniroute/client.ts +14 -3
- package/src/omniroute/usage.ts +38 -0
- package/src/pi/extension.ts +44 -8
- package/templates/AGENTS.block.md +1 -1
package/src/hub/events.ts
CHANGED
|
@@ -10,10 +10,20 @@ export const EVENTS_SCHEMA = 1;
|
|
|
10
10
|
export type HubEvent =
|
|
11
11
|
| { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string }
|
|
12
12
|
| { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
|
|
13
|
+
| { type: "stale"; id: string; from: string; peer: string; task?: string }
|
|
14
|
+
| { type: "quiet"; id: string; from: string; peers: string[] }
|
|
15
|
+
| { type: "fact"; peer: string; id: string; files: number; plans: number; unknown: number; bytes: number; via: "hook" | "steer" | "done"; ms?: number; hookMs?: number; rttMs?: number; accepted?: boolean; unanswered?: boolean; probe?: boolean; coverage?: boolean }
|
|
16
|
+
| { type: "fact_ack"; peer: string; id: string; via: string; ms: number }
|
|
17
|
+
| { type: "capability"; peer: string; state: "verified" | "lost"; via?: string }
|
|
18
|
+
| { type: "split"; task: number; verdict: "split" | "single" | "unknown"; single?: string; splitS?: number; singleS?: number; reason?: string; trace?: string[] }
|
|
13
19
|
| { type: "state"; peer: string; state: string }
|
|
14
20
|
| { type: "turn_start"; peer: string; turn: string }
|
|
15
21
|
| { type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number }
|
|
22
|
+
| { type: "native_turn_end"; peer: string }
|
|
23
|
+
| { type: "hook_stats"; peer: string; n: number; startupMs: number; hubMs: number; maxStartupMs: number }
|
|
24
|
+
| { type: "cohort"; id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: string[] }
|
|
16
25
|
| { type: "tokens"; peer: string; n: number }
|
|
26
|
+
| { type: "usage"; peer: string; source: "omniroute" | "claude_transcript"; id: string; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number }
|
|
17
27
|
| { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; pii: boolean }
|
|
18
28
|
| { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
|
|
19
29
|
| { type: "conflict"; peer: string; task?: number; other: number; owner: string; paths: string[]; concurrent: boolean; turns?: [string, string] }
|
|
@@ -75,7 +85,8 @@ export function readEvents(file: string, since = 0): StampedEvent[] {
|
|
|
75
85
|
if (!line.trim()) return [];
|
|
76
86
|
try {
|
|
77
87
|
const e = JSON.parse(line) as StampedEvent;
|
|
78
|
-
|
|
88
|
+
const measuredAt = e.type === "usage" && typeof e.measuredAt === "string" ? e.measuredAt : e.at;
|
|
89
|
+
return Date.parse(measuredAt) >= since ? [e] : [];
|
|
79
90
|
} catch {
|
|
80
91
|
return [];
|
|
81
92
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
export type ExecutionUnit = "model_calls" | "tool_calls" | "elapsed_ms" | "tokens";
|
|
4
|
+
export type ExecutionBudgetLimits = Partial<Record<ExecutionUnit, number>>;
|
|
5
|
+
export interface ExecutionBudgetConfig {
|
|
6
|
+
id: string;
|
|
7
|
+
kind: "task" | "run";
|
|
8
|
+
taskId?: number;
|
|
9
|
+
peers: string[];
|
|
10
|
+
limits: ExecutionBudgetLimits;
|
|
11
|
+
}
|
|
12
|
+
export interface ExecutionBudgetStatus extends ExecutionBudgetConfig {
|
|
13
|
+
used: Partial<Record<ExecutionUnit, number>>;
|
|
14
|
+
units: Partial<Record<ExecutionUnit, { used: number; limit: number; remaining: number; reason?: "exhausted" | "unknown_usage" }>>;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
updatedAt: number;
|
|
17
|
+
}
|
|
18
|
+
export interface ExecutionBudgetDecision {
|
|
19
|
+
allowed: boolean;
|
|
20
|
+
scope: string;
|
|
21
|
+
unit: ExecutionUnit;
|
|
22
|
+
used: number;
|
|
23
|
+
limit: number | null;
|
|
24
|
+
remaining: number | null;
|
|
25
|
+
reason?: "exhausted" | "unknown_usage" | "not_eligible";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const UNITS = new Set<ExecutionUnit>(["model_calls", "tool_calls", "elapsed_ms", "tokens"]);
|
|
29
|
+
const validLimits = (raw: ExecutionBudgetLimits): ExecutionBudgetLimits => {
|
|
30
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("execution budget limits must be an object");
|
|
31
|
+
const out: ExecutionBudgetLimits = {};
|
|
32
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
33
|
+
if (!UNITS.has(key as ExecutionUnit)) throw new Error(`unsupported execution budget unit: ${key}`);
|
|
34
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`execution budget ${key} limit must be a nonnegative integer`);
|
|
35
|
+
out[key as ExecutionUnit] = value;
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Persistent, cross-turn execution meter. Each admission and increment is one SQLite transaction. */
|
|
41
|
+
export class ExecutionBudget {
|
|
42
|
+
private readonly db: Database;
|
|
43
|
+
constructor(path: string) {
|
|
44
|
+
this.db = new Database(path, { create: true });
|
|
45
|
+
this.db.run("PRAGMA journal_mode = WAL");
|
|
46
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS execution_budgets (
|
|
47
|
+
id TEXT PRIMARY KEY, kind TEXT NOT NULL, task_id INTEGER, peers TEXT NOT NULL,
|
|
48
|
+
limits TEXT NOT NULL, used TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
configure(input: ExecutionBudgetConfig): ExecutionBudgetStatus {
|
|
52
|
+
if (!input.id || input.id.length > 200) throw new Error("execution budget id is invalid");
|
|
53
|
+
if (input.kind !== "task" && input.kind !== "run") throw new Error("execution budget kind must be task or run");
|
|
54
|
+
if (input.kind === "task" && (!Number.isSafeInteger(input.taskId) || input.taskId! < 1)) throw new Error("task budget requires a positive task id");
|
|
55
|
+
const peers = [...new Set(input.peers.filter((p) => typeof p === "string" && p.length > 0))].sort();
|
|
56
|
+
if (!peers.length) throw new Error("execution budget requires at least one peer");
|
|
57
|
+
const unsupported = peers.filter((peer) => peer !== "pi" && peer !== "local");
|
|
58
|
+
if (unsupported.length) throw new Error(`execution budgets require instrumented peers (pi, local); unsupported: ${unsupported.join(", ")}`);
|
|
59
|
+
const limits = validLimits(input.limits ?? {});
|
|
60
|
+
if (!Object.keys(limits).length) throw new Error("execution budget requires at least one supported limit");
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
const existing = this.db.query("SELECT kind,task_id FROM execution_budgets WHERE id=?").get(input.id) as { kind: string; task_id: number | null } | undefined;
|
|
63
|
+
if (existing && (existing.kind !== input.kind || existing.task_id !== (input.taskId ?? null))) throw new Error("execution budget id is already bound to another scope");
|
|
64
|
+
this.db.query(`INSERT INTO execution_budgets (id,kind,task_id,peers,limits,used,created_at,updated_at)
|
|
65
|
+
VALUES (?,?,?,?,?,'{}',?,?) ON CONFLICT(id) DO UPDATE SET kind=excluded.kind,task_id=excluded.task_id,
|
|
66
|
+
peers=excluded.peers,limits=excluded.limits,updated_at=excluded.updated_at`).run(
|
|
67
|
+
input.id, input.kind, input.taskId ?? null, JSON.stringify(peers), JSON.stringify(limits), now, now,
|
|
68
|
+
);
|
|
69
|
+
return this.status(input.id) as ExecutionBudgetStatus;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
disable(id: string): boolean { return this.db.query("DELETE FROM execution_budgets WHERE id=?").run(id).changes > 0; }
|
|
73
|
+
|
|
74
|
+
status(id?: string): ExecutionBudgetStatus | ExecutionBudgetStatus[] | undefined {
|
|
75
|
+
const rows = (id
|
|
76
|
+
? this.db.query("SELECT * FROM execution_budgets WHERE id=?").all(id)
|
|
77
|
+
: this.db.query("SELECT * FROM execution_budgets ORDER BY created_at,id").all()) as BudgetRow[];
|
|
78
|
+
const values = rows.map(fromRow);
|
|
79
|
+
return id ? values[0] : values;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
admitTask(taskId: number | undefined, peer: string, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] {
|
|
83
|
+
return this.admitTasks(taskId === undefined ? [] : [taskId], peer, unit, amount);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
admitTasks(taskIds: number[], peer: string, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] {
|
|
87
|
+
if (!UNITS.has(unit) || !Number.isSafeInteger(amount) || amount <= 0) throw new Error("execution budget admission is invalid");
|
|
88
|
+
const transaction = this.db.transaction(() => {
|
|
89
|
+
// Read scopes only after BEGIN IMMEDIATE so a concurrent configure/admission cannot evade a scope or overdraw it.
|
|
90
|
+
const ids = [...new Set(taskIds.filter((id) => Number.isSafeInteger(id) && id > 0))];
|
|
91
|
+
const taskRows = ids.length
|
|
92
|
+
? this.db.query(`SELECT * FROM execution_budgets WHERE kind='task' AND task_id IN (${ids.map(() => "?").join(",")}) AND EXISTS (SELECT 1 FROM json_each(peers) WHERE value=?)`).all(...ids, peer) as BudgetRow[]
|
|
93
|
+
: [];
|
|
94
|
+
const runRows = this.db.query("SELECT * FROM execution_budgets WHERE kind='run' AND EXISTS (SELECT 1 FROM json_each(peers) WHERE value=?)").all(peer) as BudgetRow[];
|
|
95
|
+
const rows = [...taskRows, ...runRows].sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id));
|
|
96
|
+
const pending: { cfg: ExecutionBudgetStatus; updates: Partial<Record<ExecutionUnit, number>>; decisions: ExecutionBudgetDecision[] }[] = rows.map((row) => {
|
|
97
|
+
const cfg = fromRow(row), updates: Partial<Record<ExecutionUnit, number>> = {}, decisions: ExecutionBudgetDecision[] = [];
|
|
98
|
+
const now = Date.now(), elapsedLimit = cfg.limits.elapsed_ms;
|
|
99
|
+
if (elapsedLimit !== undefined) {
|
|
100
|
+
const elapsed = Math.max(cfg.used.elapsed_ms ?? 0, now - cfg.createdAt);
|
|
101
|
+
const decision: ExecutionBudgetDecision = { allowed: elapsed < elapsedLimit, scope: cfg.id, unit: "elapsed_ms", used: elapsed, limit: elapsedLimit, remaining: Math.max(0, elapsedLimit - elapsed), ...(elapsed >= elapsedLimit ? { reason: "exhausted" } : {}) };
|
|
102
|
+
decisions.push(decision); updates.elapsed_ms = elapsed;
|
|
103
|
+
}
|
|
104
|
+
const limit = cfg.limits[unit], current = cfg.used[unit] ?? 0;
|
|
105
|
+
if (unit === "tokens" || (unit === "model_calls" && cfg.limits.tokens !== undefined)) {
|
|
106
|
+
decisions.push({ allowed: false, scope: cfg.id, unit: "tokens", used: cfg.used.tokens ?? 0, limit: cfg.limits.tokens ?? null, remaining: cfg.limits.tokens === undefined ? null : Math.max(0, cfg.limits.tokens - (cfg.used.tokens ?? 0)), reason: "unknown_usage" });
|
|
107
|
+
} else if (limit === undefined) decisions.push({ allowed: true, scope: cfg.id, unit, used: current, limit: null, remaining: null });
|
|
108
|
+
else if (current + amount > limit) decisions.push({ allowed: false, scope: cfg.id, unit, used: current, limit, remaining: Math.max(0, limit - current), reason: "exhausted" });
|
|
109
|
+
else {
|
|
110
|
+
updates[unit] = current + amount;
|
|
111
|
+
decisions.push({ allowed: true, scope: cfg.id, unit, used: current + amount, limit, remaining: Math.max(0, limit - current - amount) });
|
|
112
|
+
}
|
|
113
|
+
return { cfg, updates, decisions };
|
|
114
|
+
});
|
|
115
|
+
if (pending.some((item) => item.decisions.some((decision) => !decision.allowed))) return pending.flatMap((item) => item.decisions);
|
|
116
|
+
for (const item of pending) if (Object.keys(item.updates).length) {
|
|
117
|
+
item.cfg.used = { ...item.cfg.used, ...item.updates };
|
|
118
|
+
this.db.query("UPDATE execution_budgets SET used=?,updated_at=? WHERE id=?").run(JSON.stringify(item.cfg.used), Date.now(), item.cfg.id);
|
|
119
|
+
}
|
|
120
|
+
return pending.flatMap((item) => item.decisions);
|
|
121
|
+
});
|
|
122
|
+
return transaction.immediate();
|
|
123
|
+
}
|
|
124
|
+
close(): void { this.db.close(); }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface BudgetRow { id: string; kind: "task"|"run"; task_id: number | null; peers: string; limits: string; used: string; created_at: number; updated_at: number }
|
|
128
|
+
function fromRow(row: BudgetRow): ExecutionBudgetStatus {
|
|
129
|
+
const limits = JSON.parse(row.limits) as ExecutionBudgetLimits;
|
|
130
|
+
const used = JSON.parse(row.used) as Partial<Record<ExecutionUnit, number>>;
|
|
131
|
+
if (limits.elapsed_ms !== undefined) used.elapsed_ms = Math.max(used.elapsed_ms ?? 0, Date.now() - row.created_at);
|
|
132
|
+
const units: ExecutionBudgetStatus["units"] = {};
|
|
133
|
+
for (const [unit, limit] of Object.entries(limits) as [ExecutionUnit, number][]) {
|
|
134
|
+
const current = used[unit] ?? 0, remaining = Math.max(0, limit - current);
|
|
135
|
+
units[unit] = { used: current, limit, remaining, ...((unit === "tokens" && remaining > 0) ? { reason: "unknown_usage" as const } : (remaining === 0 ? { reason: "exhausted" as const } : {})) };
|
|
136
|
+
}
|
|
137
|
+
return { id: row.id, kind: row.kind, ...(row.task_id === null ? {} : { taskId: row.task_id }), peers: JSON.parse(row.peers), limits, used, units, createdAt: row.created_at, updatedAt: row.updated_at };
|
|
138
|
+
}
|