@staix/agent-hub 0.11.0 → 0.12.1
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 +3 -3
- package/docs/operations.md +66 -21
- package/docs/quickstart.md +1 -1
- package/docs/security.md +1 -1
- package/docs/smoke.md +114 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +16 -2
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +8 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3 -3
- package/src/adapters/local-worker.ts +2 -2
- package/src/adapters/pi.ts +9 -1
- package/src/cli/main.ts +12 -0
- package/src/cli/status-lines.ts +2 -1
- package/src/cli/upgrade-runtime.ts +2 -2
- package/src/hub/bus.ts +26 -9
- package/src/hub/config-trust.ts +0 -1
- package/src/hub/conflicts.ts +1 -1
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +145 -21
- package/src/hub/events.ts +1 -1
- package/src/hub/manager.ts +3 -3
- package/src/hub/report.ts +1 -1
- package/src/hub/routing.ts +14 -1
- package/src/hub/snapshots.ts +11 -0
- package/src/hub/tasks.ts +44 -4
- package/src/local/proxy.ts +30 -3
- package/src/local/sandbox.ts +22 -33
- package/src/omniroute/client.ts +23 -0
- package/src/pi/extension.ts +9 -0
- package/src/pi/usage.ts +14 -0
- package/templates/config.json +1 -1
package/src/hub/tasks.ts
CHANGED
|
@@ -29,6 +29,12 @@ export interface TasksDeps {
|
|
|
29
29
|
quota?: () => Record<PeerId, { headroom: number; resetsAt?: number }>;
|
|
30
30
|
/** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
|
|
31
31
|
review?: { adaptive: boolean; min_reviews: number };
|
|
32
|
+
/** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
|
|
33
|
+
failing?: () => Record<PeerId, string>;
|
|
34
|
+
/** Peers whose queue a needs_review delivery holds, with the hold detail (issue #90); routing explains the hold, never rejects. */
|
|
35
|
+
held?: () => Record<PeerId, string>;
|
|
36
|
+
/** Roles from `.agenthub/config.json` (issue #92): peers with the reviewer role join the reviewer candidates. */
|
|
37
|
+
roles?: Record<string, string[]>;
|
|
32
38
|
/** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
|
|
33
39
|
triage?: { classify: (title: string, detail: string) => Promise<TaskClass | undefined>; onCampus: () => Promise<boolean> };
|
|
34
40
|
}
|
|
@@ -111,6 +117,17 @@ export class Tasks {
|
|
|
111
117
|
return this.isPii(task) ? { ...rest, refs: {}, plan: {}, title: "[pii]", detail: "[pii]" } : { ...rest, title, detail, history: history.slice(-5) };
|
|
112
118
|
}
|
|
113
119
|
|
|
120
|
+
/** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
|
|
121
|
+
resultLine(task: Task): string {
|
|
122
|
+
const base = `task #${task.id}: ${task.state}, owner ${task.owner ?? "none"}, reviewer ${task.reviewer ?? "none"}`;
|
|
123
|
+
const noReview = task.class !== "review" && !task.reviewer && OPEN.includes(task.state) && !this.waitsFor(task).length
|
|
124
|
+
? this.noReviewer(assign(task, this.states(), this.d.routing(), { candidates: task.owner ? [task.owner] : [], notReviewer: task.owner ?? undefined, ...this.weights(task.class) }))
|
|
125
|
+
: undefined;
|
|
126
|
+
const held = task.owner ? this.d.held?.()[task.owner] : undefined;
|
|
127
|
+
const holdText = held ? `${task.owner}'s queue is held (${held}); it receives the task once the hold is resolved` : undefined;
|
|
128
|
+
return [base, noReview, holdText].filter(Boolean).join("; ");
|
|
129
|
+
}
|
|
130
|
+
|
|
114
131
|
private states(): Record<PeerId, PeerState> {
|
|
115
132
|
return Object.fromEntries([...this.d.bus.peers.keys()].map((id) => [id, this.d.bus.stateOf(id)]));
|
|
116
133
|
}
|
|
@@ -164,11 +181,18 @@ export class Tasks {
|
|
|
164
181
|
return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested").map((h) => h.by));
|
|
165
182
|
}
|
|
166
183
|
|
|
184
|
+
/** Peer health and roles the daemon feeds routing (issues #89, #90, #92): failing peers are skipped, held queues explained. */
|
|
185
|
+
private health() {
|
|
186
|
+
const failing = this.d.failing?.();
|
|
187
|
+
const held = this.d.held?.();
|
|
188
|
+
return { ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
|
|
189
|
+
}
|
|
190
|
+
|
|
167
191
|
/** What assignment weighs besides states and policy: quota, demotion and the review record. */
|
|
168
192
|
private weights(cls: TaskClass) {
|
|
169
193
|
const now = Date.now();
|
|
170
194
|
const quota = this.d.quota?.();
|
|
171
|
-
return { now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
|
|
195
|
+
return { ...this.health(), now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
|
|
172
196
|
}
|
|
173
197
|
|
|
174
198
|
/**
|
|
@@ -334,7 +358,7 @@ export class Tasks {
|
|
|
334
358
|
// Awaits run between tasks (briefs, memory): re-read, and stop for anything that changed meanwhile.
|
|
335
359
|
const task = this.d.board.get(id);
|
|
336
360
|
if (!task || task.owner !== peer || !OPEN.includes(task.state) || this.d.bus.stateOf(peer) !== "offline") continue;
|
|
337
|
-
if (!assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), peer] }).owner) continue;
|
|
361
|
+
if (!assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), peer], ...this.health() }).owner) continue;
|
|
338
362
|
try {
|
|
339
363
|
const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
|
|
340
364
|
const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", note: why });
|
|
@@ -365,6 +389,21 @@ export class Tasks {
|
|
|
365
389
|
|
|
366
390
|
private declined = (task: Task) => task.history.filter((h) => h.event === "declined").map((h) => h.by);
|
|
367
391
|
|
|
392
|
+
/** Why the task will approve directly at done: no reviewer could be picked, and who was skipped (issue #92). */
|
|
393
|
+
private noReviewer(a: Assignment): string {
|
|
394
|
+
const skipped = a.trace.flatMap((l) => {
|
|
395
|
+
const m = /^ {2}reviewer candidate (\S+): skipped, (.*)$/.exec(l);
|
|
396
|
+
return m ? [`${m[1]} (${m[2]})`] : [];
|
|
397
|
+
});
|
|
398
|
+
return `no reviewer: done will approve directly${skipped.length ? ` (skipped: ${skipped.join("; ")})` : ""}`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private announceRouting(task: Task, a: Assignment): void {
|
|
402
|
+
if (task.class !== "review" && !a.reviewer) this.d.notify(`task ${this.publicTitle(task)}: ${this.noReviewer(a)}`);
|
|
403
|
+
const hold = a.owner ? this.d.held?.()[a.owner] : undefined;
|
|
404
|
+
if (hold) this.d.notify(`task ${this.publicTitle(task)}: ${a.owner}'s queue is held (${hold}); the task arrives once the hold is resolved`);
|
|
405
|
+
}
|
|
406
|
+
|
|
368
407
|
private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; event?: string; note?: string; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; claim?: boolean } = {}): Promise<Task> {
|
|
369
408
|
const waits = this.waitsFor(task);
|
|
370
409
|
const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class) });
|
|
@@ -383,6 +422,7 @@ export class Tasks {
|
|
|
383
422
|
this.announceOverlap(next, hits);
|
|
384
423
|
this.tellEarlierOwners(next, hits);
|
|
385
424
|
}
|
|
425
|
+
this.announceRouting(next, a);
|
|
386
426
|
if (opts.claim && a.owner === by) {
|
|
387
427
|
const claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" });
|
|
388
428
|
this.d.notify(`task ${this.publicTitle(claimed)} claimed by ${by}`);
|
|
@@ -396,7 +436,7 @@ export class Tasks {
|
|
|
396
436
|
const pii = this.isPii(task);
|
|
397
437
|
const brief = pii ? undefined : await this.d.briefs?.forTask(task.owner!, task).catch(() => undefined);
|
|
398
438
|
const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", a.owner)}`);
|
|
399
|
-
const facts = [`class ${task.class}`, a.owner === PI ? `backend pi/${a.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", a.reviewer ? `reviewer ${a.reviewer}` : "no reviewer"].filter(Boolean).join("; ");
|
|
439
|
+
const facts = [`class ${task.class}`, a.owner === PI ? `backend pi/${a.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", a.reviewer ? `reviewer ${a.reviewer}` : task.class === "review" ? "no reviewer" : this.noReviewer(a)].filter(Boolean).join("; ");
|
|
400
440
|
const plan = planText(task.plan);
|
|
401
441
|
const body = [
|
|
402
442
|
`Task #${task.id} [${task.class}] ${task.title}`,
|
|
@@ -438,7 +478,7 @@ export class Tasks {
|
|
|
438
478
|
for (const t of this.d.board.list("proposed")) {
|
|
439
479
|
const last = t.history.at(-1)?.event;
|
|
440
480
|
if (!t.deps?.length || t.owner || this.offered.has(t.id) || (last !== "blocked" && last !== "ready") || this.waitsFor(t).length) continue;
|
|
441
|
-
if (!assign(t, this.states(), this.d.routing(), { exclude: this.declined(t) }).owner) continue; // nobody attached can take it yet
|
|
481
|
+
if (!assign(t, this.states(), this.d.routing(), { exclude: this.declined(t), ...this.health() }).owner) continue; // nobody attached can take it yet
|
|
442
482
|
await this.offerReady(t, "nothing left to wait for");
|
|
443
483
|
}
|
|
444
484
|
}
|
package/src/local/proxy.ts
CHANGED
|
@@ -47,11 +47,29 @@ export function allowed(list: string[], host: string, port: number): boolean {
|
|
|
47
47
|
const INTERNAL = new BlockList();
|
|
48
48
|
// IPv4 nets as leading octets: the package check refuses private address literals in published files.
|
|
49
49
|
for (const [a, b, bits] of [[0, 0, 8], [10, 0, 8], [100, 64, 10], [127, 0, 8], [169, 254, 16], [172, 16, 12], [192, 168, 16], [198, 18, 15], [224, 0, 3]] as const) INTERNAL.addSubnet(`${a}.${b}.0.0`, bits, "ipv4");
|
|
50
|
-
|
|
50
|
+
// RFC 8215's local-use NAT64 prefix puts the IPv4 address where the operator's prefix length says: refused outright.
|
|
51
|
+
for (const [net, bits] of [["::", 96], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], ["ff00::", 8], ["64:ff9b:1::", 48]] as const) INTERNAL.addSubnet(net, bits, "ipv6");
|
|
52
|
+
// The well-known NAT64 prefix (issue #82): with DNS64, a name whose only record is an internal IPv4 address comes back
|
|
53
|
+
// as 64:ff9b::<that address>, and the NAT64 gateway carries the connection there. Its IPv4 address is what counts.
|
|
54
|
+
const NAT64 = new BlockList();
|
|
55
|
+
NAT64.addSubnet("64:ff9b::", 96, "ipv6");
|
|
56
|
+
|
|
57
|
+
/** The IPv4 address in the last 32 bits of an IPv6 address, whatever its spelling. */
|
|
58
|
+
function lastIPv4(v6: string): string {
|
|
59
|
+
const [head = "", tail] = new URL(`http://[${v6}]`).hostname.slice(1, -1).split("::");
|
|
60
|
+
const h = head ? head.split(":") : [];
|
|
61
|
+
const t = tail ? tail.split(":") : [];
|
|
62
|
+
const all = tail === undefined ? h : [...h, ...Array<string>(8 - h.length - t.length).fill("0"), ...t];
|
|
63
|
+
const [a = 0, b = 0] = all.slice(6).map((g) => parseInt(g, 16));
|
|
64
|
+
return `${a >> 8}.${a & 255}.${b >> 8}.${b & 255}`;
|
|
65
|
+
}
|
|
51
66
|
|
|
52
67
|
/** Loopback, private, link-local, site-local, unspecified, carrier-grade NAT, benchmark, multicast or reserved. */
|
|
53
68
|
export function isInternal(address: string): boolean {
|
|
69
|
+
// Only a non-global address carries a zone (`fe80::1%en0`, `::1%lo0`), and BlockList cannot parse one: internal.
|
|
70
|
+
if (address.includes("%")) return true;
|
|
54
71
|
const family = isIP(address);
|
|
72
|
+
if (family === 6 && NAT64.check(address, "ipv6")) return isInternal(lastIPv4(address));
|
|
55
73
|
return family !== 0 && INTERNAL.check(address, family === 6 ? "ipv6" : "ipv4");
|
|
56
74
|
}
|
|
57
75
|
|
|
@@ -75,7 +93,8 @@ export function startEgressProxy(opts: { allow: string[]; log: (line: string) =>
|
|
|
75
93
|
head = Buffer.concat([head, chunk]);
|
|
76
94
|
const end = head.indexOf("\r\n\r\n");
|
|
77
95
|
if (end < 0) {
|
|
78
|
-
|
|
96
|
+
// Once: later chunks of the same request must not refuse (and log) again. Nothing of the header is logged.
|
|
97
|
+
if (head.length > 8192) client.off("data", onData), refuse(client, 431, "request header too large", "a request");
|
|
79
98
|
return;
|
|
80
99
|
}
|
|
81
100
|
client.off("data", onData);
|
|
@@ -114,14 +133,22 @@ export function startEgressProxy(opts: { allow: string[]; log: (line: string) =>
|
|
|
114
133
|
const upstream = connect({ host: address, port, allowHalfOpen: true });
|
|
115
134
|
sockets.add(upstream);
|
|
116
135
|
upstream.on("close", () => sockets.delete(upstream));
|
|
136
|
+
let open = false;
|
|
117
137
|
upstream.once("connect", () => {
|
|
138
|
+
open = true;
|
|
118
139
|
client.write(`${http} 200 Connection Established\r\n\r\n`);
|
|
119
140
|
if (rest.length) upstream.write(rest);
|
|
120
141
|
client.pipe(upstream);
|
|
121
142
|
upstream.pipe(client);
|
|
122
143
|
client.resume();
|
|
123
144
|
});
|
|
124
|
-
|
|
145
|
+
// Before the tunnel opens a failure is a refusal, logged with the error code only (never its text). After, the
|
|
146
|
+
// stream is the client's TLS: an HTTP answer would corrupt it, so the client is just closed.
|
|
147
|
+
upstream.on("error", (e: NodeJS.ErrnoException) => {
|
|
148
|
+
if (open || !client.writable || client.destroyed) return void client.destroy();
|
|
149
|
+
const code = /^E[A-Z]{2,20}$/.test(e.code ?? "") ? `, ${e.code}` : "";
|
|
150
|
+
refuse(client, 403, `${host}:${port} is unreachable${code}`, `${host}:${port}`);
|
|
151
|
+
});
|
|
125
152
|
client.on("close", () => upstream.destroy());
|
|
126
153
|
};
|
|
127
154
|
|
package/src/local/sandbox.ts
CHANGED
|
@@ -59,21 +59,20 @@ const NETWORK_MACH_SERVICES = ["com.apple.dnssd.service", "com.apple.trustd", "c
|
|
|
59
59
|
const PROXY_MACH_SERVICES = ["com.apple.trustd", "com.apple.trustd.agent"];
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
* Network for the commands: none, direct (`local.bash_network: "direct"`, everything,
|
|
62
|
+
* Network for the commands: none, direct (`local.bash_network: "direct"`, everything, until 0.13.0), or only
|
|
63
63
|
* the hub's egress proxy on a loopback port (#65), which opens allowlisted hosts.
|
|
64
64
|
*/
|
|
65
65
|
export type SandboxNetwork = boolean | { proxyPort: number };
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
68
|
+
* Starts from `(deny default)` (issue #39) and allows only what commands need: running and reading the system,
|
|
69
|
+
* toolchain and project directories, writing the project and a temp dir of their own. The allow-default profile of
|
|
70
|
+
* 0.9 and earlier was removed in 0.12.0 (issue #83).
|
|
71
71
|
*/
|
|
72
|
-
export function profile(cwd: string, network: SandboxNetwork, readAllow: string[] = [], deny: string[] = []
|
|
72
|
+
export function profile(cwd: string, network: SandboxNetwork, readAllow: string[] = [], deny: string[] = []): string {
|
|
73
73
|
const home = homedir();
|
|
74
74
|
const inHome = (p: string) => (p.startsWith("~/") ? join(home, p.slice(2)) : p);
|
|
75
75
|
const root = realPath(cwd);
|
|
76
|
-
const tmp = realPath(tmpdir());
|
|
77
76
|
const gitDirs = externalGitDirs(root);
|
|
78
77
|
const creds = [".ssh", ".aws", ".gnupg", ".config/gh", ".config/gcloud", ".kube", ".docker", ".netrc", ".npmrc", ".omniroute", ".claude", ".codex", ".kimi-code", "Library/Keychains"];
|
|
79
78
|
const dev = developerDir();
|
|
@@ -81,38 +80,28 @@ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[
|
|
|
81
80
|
const subpaths = (paths: string[]) => paths.map((p) => `(subpath ${q(p)})`).join(" ");
|
|
82
81
|
const globals = (names: string[]) => names.map((n) => `(global-name ${q(n)})`).join(" ");
|
|
83
82
|
const proxy = typeof network === "object" ? `(allow network-outbound (remote ip ${q(`localhost:${network.proxyPort}`)}))` : undefined;
|
|
84
|
-
const start =
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
]
|
|
101
|
-
: [
|
|
102
|
-
"(allow default)",
|
|
103
|
-
...(network === true ? [] : ["(deny network*)", ...(proxy ? [proxy] : [])]),
|
|
104
|
-
// Home is default-deny for reads: whatever a command reads can end up in the model's answer, and that answer
|
|
105
|
-
// is shared with agents that run on cloud subscriptions (~/.claude.json, app tokens, browser profiles ...).
|
|
106
|
-
`(deny file-read* (subpath ${q(home)}))`,
|
|
107
|
-
`(allow file-read-metadata (subpath ${q(home)}))`,
|
|
108
|
-
`(allow file-read* ${subpaths(readable)})`,
|
|
109
|
-
];
|
|
83
|
+
const start = [
|
|
84
|
+
"(deny default)",
|
|
85
|
+
"(allow process-fork)",
|
|
86
|
+
// Runs from the system, toolchain and project directories only; a script runs through an allowed interpreter.
|
|
87
|
+
// The user's temp dir and /private/tmp are shared with every other process: each command gets its own (#63).
|
|
88
|
+
`(allow process-exec ${subpaths([...SYSTEM_READABLE, ...readable])})`,
|
|
89
|
+
"(allow signal (target same-sandbox))",
|
|
90
|
+
"(allow process-info* (target same-sandbox))",
|
|
91
|
+
"(allow sysctl-read)",
|
|
92
|
+
`(allow mach-lookup ${globals([...MACH_SERVICES, ...(proxy ? PROXY_MACH_SERVICES : network ? NETWORK_MACH_SERVICES : [])])})`,
|
|
93
|
+
'(allow ipc-posix-shm-read-data ipc-posix-shm-read-metadata (ipc-posix-name "apple.shm.notification_center"))',
|
|
94
|
+
"(allow file-read-metadata)",
|
|
95
|
+
`(allow file-read* (literal "/") ${subpaths([...SYSTEM_READABLE, "/dev"])} ${subpaths(readable)})`,
|
|
96
|
+
'(allow file-ioctl (regex #"^/dev/"))',
|
|
97
|
+
...(proxy ? [proxy] : network ? ["(allow network*)"] : []),
|
|
98
|
+
];
|
|
110
99
|
const places = `${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")} ${denyRegexes(root, deny, false).join(" ")}`;
|
|
111
100
|
return [
|
|
112
101
|
"(version 1)",
|
|
113
102
|
...start,
|
|
114
103
|
"(deny file-write*)",
|
|
115
|
-
`(allow file-write* (subpath ${q(root)})
|
|
104
|
+
`(allow file-write* (subpath ${q(root)}) (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`,
|
|
116
105
|
// Inside cwd: nothing that runs later outside the sandbox, nothing that reconfigures the hub.
|
|
117
106
|
`(deny file-write* (subpath ${q(join(root, ".agenthub"))}) ${[join(root, ".git"), ...gitDirs].map((d) => `(subpath ${q(join(d, "hooks"))}) (literal ${q(join(d, "config"))})`).join(" ")})`,
|
|
118
107
|
`(deny file-read* file-write* ${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")})`,
|
package/src/omniroute/client.ts
CHANGED
|
@@ -124,6 +124,29 @@ export class OmniRoute {
|
|
|
124
124
|
return undefined;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/** Authenticated model inventory; upstream response bodies never enter diagnostics. */
|
|
128
|
+
async models(): Promise<string[]> {
|
|
129
|
+
const base = await this.base();
|
|
130
|
+
if (!base) throw new Error("no model gateway is configured or reachable (see ahub doctor)");
|
|
131
|
+
const key = this.apiKey();
|
|
132
|
+
if (!key) throw new Error("no OmniRoute API key (see ahub doctor)");
|
|
133
|
+
const response = await fetch(`${base}/models`, { headers: { authorization: `Bearer ${key}`, ...this.accessHeaders(base) }, signal: AbortSignal.timeout(4000) });
|
|
134
|
+
if (!response.ok) throw new Error(`model inventory failed: HTTP ${response.status}`);
|
|
135
|
+
const body = await response.json() as { data?: { id?: unknown }[] };
|
|
136
|
+
if (!Array.isArray(body.data)) throw new Error("model inventory returned no model list");
|
|
137
|
+
return body.data.flatMap((m) => typeof m?.id === "string" ? [m.id] : []);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A listed model can still lack provider credentials: prove it accepts a minimal call before attaching. */
|
|
141
|
+
async checkModel(model: string, inventory?: string[]): Promise<void> {
|
|
142
|
+
if (!(inventory ?? await this.models()).includes(model)) throw new Error(`model ${model} is not served by the gateway`);
|
|
143
|
+
try {
|
|
144
|
+
await this.chat({ model, messages: [{ role: "user", content: "agent-hub model availability check: reply OK" }], max_tokens: 1 }, { signal: AbortSignal.timeout(10_000) });
|
|
145
|
+
} catch {
|
|
146
|
+
throw new Error(`model ${model} is not usable (availability call failed; check gateway credentials and provider health)`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
127
150
|
async chat(body: { model: string; messages: ChatMessage[]; tools?: unknown[]; max_tokens?: number }, opts: ChatOptions = {}): Promise<ChatResult> {
|
|
128
151
|
const base = opts.via ?? (await this.base());
|
|
129
152
|
if (!base) throw new Error("no model gateway is configured or reachable (omniroute.urls in .agenthub/config.json; see ahub doctor)");
|
package/src/pi/extension.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assistantTokens } from "./usage.ts";
|
|
1
2
|
type ExtensionAPI = any;
|
|
2
3
|
import { execFileSync } from "node:child_process";
|
|
3
4
|
import { createHash } from "node:crypto";
|
|
@@ -15,6 +16,7 @@ const models = (() => {
|
|
|
15
16
|
let pollStarted = false;
|
|
16
17
|
let toolSteps = 0;
|
|
17
18
|
let lastActivity = 0;
|
|
19
|
+
let usageSeq = 0;
|
|
18
20
|
let forcedFailure = "";
|
|
19
21
|
const maxSteps = Number(process.env.AGENTHUB_PI_MAX_STEPS ?? 30);
|
|
20
22
|
let shutdown: (() => void) | undefined;
|
|
@@ -89,6 +91,13 @@ export default function(pi: ExtensionAPI): void {
|
|
|
89
91
|
const cancelled = !forcedFailure && message?.stopReason === "aborted";
|
|
90
92
|
await post("/event", { type: "agent_end", text: text ?? "", failed, cancelled, ...(failed ? { error: forcedFailure || message?.errorMessage || message?.stopReason } : {}) });
|
|
91
93
|
});
|
|
94
|
+
pi.on("message_end", async (event: any) => {
|
|
95
|
+
const tokens = assistantTokens(event.message);
|
|
96
|
+
if (tokens === undefined) return;
|
|
97
|
+
// Message-end is the sole usage source: agent_end carries those same messages again.
|
|
98
|
+
try { await post("/event", { type: "tokens", id: `usage-${++usageSeq}`, tokens }); }
|
|
99
|
+
catch { /* telemetry must not break a native turn */ }
|
|
100
|
+
});
|
|
92
101
|
pi.on("message_update", async () => {
|
|
93
102
|
const now = Date.now();
|
|
94
103
|
if (now - lastActivity < 1000) return;
|
package/src/pi/usage.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Pi 0.86 assistant usage, including cache reads/writes. Unknown shapes are not estimates. */
|
|
2
|
+
export function assistantTokens(message: unknown): number | undefined {
|
|
3
|
+
if (!message || typeof message !== "object") return undefined;
|
|
4
|
+
const m = message as { role?: unknown; usage?: unknown };
|
|
5
|
+
if (m.role !== "assistant" || !m.usage || typeof m.usage !== "object") return undefined;
|
|
6
|
+
const u = m.usage as Record<string, unknown>;
|
|
7
|
+
const count = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
8
|
+
if (count(u.totalTokens)) return u.totalTokens;
|
|
9
|
+
if (!count(u.input) || !count(u.output)) return undefined;
|
|
10
|
+
const parts = [u.input, u.output, u.cacheRead ?? 0, u.cacheWrite ?? 0];
|
|
11
|
+
if (!parts.every(count)) return undefined;
|
|
12
|
+
const total = parts.reduce((n, v) => n + v, 0);
|
|
13
|
+
return Number.isSafeInteger(total) ? total : undefined;
|
|
14
|
+
}
|
package/templates/config.json
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"cf_client_id_file": "",
|
|
24
24
|
"cf_client_secret_file": ""
|
|
25
25
|
},
|
|
26
|
-
"local": { "deny": [], "bash_network": false, "max_steps": 30, "read_allow": []
|
|
26
|
+
"local": { "deny": [], "bash_network": false, "max_steps": 30, "read_allow": [] },
|
|
27
27
|
"approvals": { "timeout_s": 120 },
|
|
28
28
|
"tasks": { "release_after_min": 30 },
|
|
29
29
|
"checks": { "timeout_s": 600 },
|