@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/usage.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { normalizeUsage, safeModelLabel, type NormalizedUsage } from "../omniroute/usage.ts";
|
|
4
|
+
|
|
5
|
+
export interface UsageRecord {
|
|
6
|
+
id: string;
|
|
7
|
+
at?: string;
|
|
8
|
+
usage?: NormalizedUsage;
|
|
9
|
+
requestedModel?: string;
|
|
10
|
+
servedModel?: string;
|
|
11
|
+
provider?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function opaqueId(sessionId: string, messageId: string): string {
|
|
15
|
+
return createHash("sha256").update(sessionId).update("\0").update(messageId).digest("hex");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Documented successful Messages API values, including beta compaction, plus stop_sequence in native transcripts.
|
|
19
|
+
const COMPLETED_STOP_REASONS = new Set(["end_turn", "tool_use", "max_tokens", "stop_sequence", "pause_turn", "refusal", "model_context_window_exceeded", "compaction"]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read Claude's native JSONL usage without returning transcript text, paths or session identity.
|
|
23
|
+
* Repeated streaming records are collapsed by message.id, using only a completed assistant message.
|
|
24
|
+
* Malformed lines and unavailable files are ignored so telemetry cannot interrupt a Claude turn.
|
|
25
|
+
*/
|
|
26
|
+
export function readClaudeTranscriptUsage(sessionId: string, transcriptPath: string): UsageRecord[] {
|
|
27
|
+
try {
|
|
28
|
+
// Avoid repeatedly parsing an unbounded transcript on the daemon's polling path.
|
|
29
|
+
if (statSync(transcriptPath).size > 32 * 1024 * 1024) {
|
|
30
|
+
// Signal unavailable coverage with a stable opaque id; a repeated poll dedupes in the report.
|
|
31
|
+
return [{ id: opaqueId(sessionId, "transcript-too-large") }];
|
|
32
|
+
}
|
|
33
|
+
const latest = new Map<string, { usage?: NormalizedUsage; at?: string; servedModel?: string }>();
|
|
34
|
+
for (const line of readFileSync(transcriptPath, "utf8").split("\n")) {
|
|
35
|
+
if (!line.trim()) continue;
|
|
36
|
+
try {
|
|
37
|
+
const row = JSON.parse(line) as { type?: unknown; timestamp?: unknown; sessionId?: unknown; message?: unknown };
|
|
38
|
+
if (typeof row.sessionId === "string" && row.sessionId !== sessionId) continue;
|
|
39
|
+
if (row.type !== "assistant" || !row.message || typeof row.message !== "object") continue;
|
|
40
|
+
const message = row.message as { id?: unknown; stop_reason?: unknown; usage?: unknown; model?: unknown };
|
|
41
|
+
if (typeof message.id !== "string" || !message.id || typeof message.stop_reason !== "string" || !message.stop_reason) continue;
|
|
42
|
+
const supportedStopReason = COMPLETED_STOP_REASONS.has(message.stop_reason);
|
|
43
|
+
const usage = supportedStopReason ? normalizeUsage(message.usage) : undefined;
|
|
44
|
+
const at = typeof row.timestamp === "string" && Number.isFinite(Date.parse(row.timestamp)) ? new Date(row.timestamp).toISOString() : undefined;
|
|
45
|
+
const servedModel = safeModelLabel(message.model);
|
|
46
|
+
latest.set(message.id, { ...(usage ? { usage } : {}), ...(at ? { at } : {}), ...(servedModel ? { servedModel } : {}) });
|
|
47
|
+
} catch {
|
|
48
|
+
// A streaming or crash-truncated line is not a record.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return [...latest].map(([messageId, value]) => ({ id: opaqueId(sessionId, messageId), ...value }));
|
|
52
|
+
} catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/local/sandbox.ts
CHANGED
|
@@ -118,8 +118,9 @@ export interface ExecResult {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
/** Run argv under seatbelt with a scrubbed environment. Never runs unsandboxed: no sandbox, no exec. */
|
|
121
|
-
export function sandboxedExec(argv: string[], opts: { cwd: string; profile: string; timeoutMs?: number; env?: Record<string, string
|
|
121
|
+
export function sandboxedExec(argv: string[], opts: { cwd: string; profile: string; timeoutMs?: number; env?: Record<string, string>; signal?: AbortSignal }): Promise<ExecResult> {
|
|
122
122
|
if (!sandboxAvailable()) return Promise.resolve({ code: null, output: "error: command execution needs macOS sandbox-exec and is disabled on this host" });
|
|
123
|
+
if (opts.signal?.aborted) return Promise.resolve({ code: null, output: "error: command cancelled before execution" });
|
|
123
124
|
return new Promise((resolve) => {
|
|
124
125
|
// A temp dir of its own (#63): the user's is shared with every other process, and what a command reads can reach
|
|
125
126
|
// an answer shown to cloud peers. Allowed after the profile's denies, and gone when the command ends.
|
|
@@ -141,6 +142,8 @@ export function sandboxedExec(argv: string[], opts: { cwd: string; profile: stri
|
|
|
141
142
|
child.kill("SIGKILL");
|
|
142
143
|
}
|
|
143
144
|
};
|
|
145
|
+
const abort = () => killGroup();
|
|
146
|
+
opts.signal?.addEventListener("abort", abort, { once: true });
|
|
144
147
|
// Best effort, never in the way of the result: a command can mark its files immutable (`chflags uchg`), and then
|
|
145
148
|
// the plain remove throws. What still fails is left to the OS temp cleanup.
|
|
146
149
|
const dropOwn = () => {
|
|
@@ -154,11 +157,12 @@ export function sandboxedExec(argv: string[], opts: { cwd: string; profile: stri
|
|
|
154
157
|
if (done) return;
|
|
155
158
|
done = true;
|
|
156
159
|
clearTimeout(timer);
|
|
160
|
+
opts.signal?.removeEventListener("abort", abort);
|
|
157
161
|
dropOwn();
|
|
158
162
|
const clipped = output.length >= OUTPUT_CAP ? `${output.slice(0, OUTPUT_CAP)}\n(output truncated)` : output;
|
|
159
163
|
resolve({ code, output: signal ? `${clipped}\n(killed: ${signal}, timeout?)` : clipped });
|
|
160
164
|
};
|
|
161
|
-
child.on("error", (e) => (done || ((done = true), dropOwn(), resolve({ code: null, output: `error: ${e.message}` }))));
|
|
165
|
+
child.on("error", (e) => (done || ((done = true), clearTimeout(timer), opts.signal?.removeEventListener("abort", abort), dropOwn(), resolve({ code: null, output: `error: ${e.message}` }))));
|
|
162
166
|
child.on("close", finish);
|
|
163
167
|
// `close` waits for every holder of the pipes; after `exit` give stragglers a moment, then stop waiting and reap them.
|
|
164
168
|
child.on("exit", (code, signal) => setTimeout(() => (killGroup(), finish(code, signal)), 500).unref());
|
package/src/local/tools.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface ToolContext {
|
|
|
14
14
|
sandboxProfile: string;
|
|
15
15
|
/** Extra environment for sandboxed commands: the egress proxy's variables when network goes through it (#65). */
|
|
16
16
|
sandboxEnv?: Record<string, string>;
|
|
17
|
+
/** Turn cancellation signal, including the execution-budget wall cap. */
|
|
18
|
+
signal?: AbortSignal;
|
|
17
19
|
/** Ask the console. Resolves false on deny or timeout. */
|
|
18
20
|
permit: (title: string) => Promise<boolean>;
|
|
19
21
|
/** Publish a message to other peers mid-turn. Returns a one-line receipt. */
|
|
@@ -106,6 +108,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
106
108
|
return "error: tool arguments were not valid JSON";
|
|
107
109
|
}
|
|
108
110
|
try {
|
|
111
|
+
if (ctx.signal?.aborted) return "error: turn cancelled before tool execution";
|
|
109
112
|
switch (name) {
|
|
110
113
|
case "read": {
|
|
111
114
|
const file = guardPath(ctx, String(a.path), "read");
|
|
@@ -119,6 +122,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
119
122
|
let file = guardPath(ctx, String(a.path), "write");
|
|
120
123
|
const content = String(a.content ?? "");
|
|
121
124
|
if (!(await ctx.permit(`write ${a.path} (${content.length} chars):\n${preview(content)}`))) return "error: the user did not approve this write";
|
|
125
|
+
if (ctx.signal?.aborted) return "error: turn cancelled before write";
|
|
122
126
|
file = guardPath(ctx, String(a.path), "write");
|
|
123
127
|
mkdirSync(dirname(file), { recursive: true });
|
|
124
128
|
writeFileSync(file, content);
|
|
@@ -130,6 +134,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
130
134
|
let count = text.split(String(a.old)).length - 1;
|
|
131
135
|
if (!a.old || count !== 1) return `error: \`old\` must match exactly once, it matched ${count} times`;
|
|
132
136
|
if (!(await ctx.permit(`edit ${a.path}:\n- ${preview(String(a.old), 600)}\n+ ${preview(String(a.new ?? ""), 600)}`))) return "error: the user did not approve this edit";
|
|
137
|
+
if (ctx.signal?.aborted) return "error: turn cancelled before edit";
|
|
133
138
|
// Approval can outlive another peer's edit or a path change. Apply only the approved fragment to current bytes.
|
|
134
139
|
file = guardPath(ctx, String(a.path), "write");
|
|
135
140
|
text = readFileSync(file, "utf8");
|
|
@@ -144,7 +149,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
144
149
|
// The approver sees the whole command, not a prefix: what is hidden cannot be approved.
|
|
145
150
|
if (command.length > 4000) return "error: command longer than 4000 characters; put it in a script file with write, then run that";
|
|
146
151
|
if (!(await ctx.permit(`bash: ${command}`))) return "error: the user did not approve this command";
|
|
147
|
-
const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, timeoutMs: (Number(a.timeout_s) || 120) * 1000, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}) });
|
|
152
|
+
const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, timeoutMs: (Number(a.timeout_s) || 120) * 1000, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
148
153
|
return `${res.output}\n(exit ${res.code})`;
|
|
149
154
|
}
|
|
150
155
|
case "git": {
|
|
@@ -155,7 +160,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
155
160
|
if (problem) return `error: git ${sub}: ${problem}`;
|
|
156
161
|
if (GIT_WRITE.has(sub) && !(await ctx.permit(`git ${args.join(" ")}`))) return "error: the user did not approve this git command";
|
|
157
162
|
// Sandboxed like bash: flags such as --output or an editor cannot write outside the project or reach the network.
|
|
158
|
-
const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}) });
|
|
163
|
+
const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
159
164
|
return `${res.output}\n(exit ${res.code})`;
|
|
160
165
|
}
|
|
161
166
|
case "hub_send":
|
package/src/models/relay.ts
CHANGED
|
@@ -32,6 +32,8 @@ export interface ModelRelayStatus {
|
|
|
32
32
|
|
|
33
33
|
export interface ModelRelayOptions {
|
|
34
34
|
omni: OmniRoute;
|
|
35
|
+
/** Authoritative admission immediately before each upstream request, including fallbacks. */
|
|
36
|
+
admitRequest?: () => Promise<{ allowed: boolean; reason?: string; remainingMs?: number }>;
|
|
35
37
|
host?: string;
|
|
36
38
|
port?: number;
|
|
37
39
|
token?: string;
|
|
@@ -60,6 +62,8 @@ interface ActiveRequest {
|
|
|
60
62
|
cleanup: () => void;
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
class ExecutionAdmissionError extends Error {}
|
|
66
|
+
|
|
63
67
|
const safeHeader = (value: string | null): string | undefined => value && value.length < 256 ? value : undefined;
|
|
64
68
|
|
|
65
69
|
function assertLoopback(host: string): void {
|
|
@@ -111,8 +115,9 @@ function sseResponse(response: Response, release: () => void, onModel?: (model:
|
|
|
111
115
|
for (const line of lines) {
|
|
112
116
|
if (!line.startsWith("data:") || line.slice(5).trim() === "[DONE]") continue;
|
|
113
117
|
try {
|
|
114
|
-
const value = JSON.parse(line.slice(5).trim()) as { model?: unknown };
|
|
115
|
-
|
|
118
|
+
const value = JSON.parse(line.slice(5).trim()) as { model?: unknown; choices?: unknown[] };
|
|
119
|
+
// Gateway heartbeat events can name a synthetic "keepalive" model with no choices.
|
|
120
|
+
if (Array.isArray(value.choices) && value.choices.length && typeof value.model === "string" && value.model.length < 256) {
|
|
116
121
|
inspectedModel = true;
|
|
117
122
|
onModel(value.model);
|
|
118
123
|
return;
|
|
@@ -220,7 +225,10 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
|
|
|
220
225
|
const boundedRequest = isOllama
|
|
221
226
|
? { ...request, max_tokens: request.max_tokens ?? options.mlx!.maxTokens ?? 2048, reasoning_effort: request.reasoning_effort ?? "none" }
|
|
222
227
|
: request;
|
|
223
|
-
|
|
228
|
+
const decision = await options.admitRequest?.();
|
|
229
|
+
if (decision && !decision.allowed) throw new ExecutionAdmissionError(decision.reason ?? "execution budget exhausted");
|
|
230
|
+
const deadline = decision?.remainingMs === undefined ? 180_000 : Math.max(1, Math.min(180_000, decision.remainingMs));
|
|
231
|
+
response = await fetch(`${base.replace(/\/$/, "")}/chat/completions`, { method: "POST", ...(isOllama ? { redirect: "error" as const } : {}), headers, body: JSON.stringify(bodyForUpstream(boundedRequest, model)), signal: AbortSignal.any([signal, AbortSignal.timeout(deadline)]) });
|
|
224
232
|
} catch (error) {
|
|
225
233
|
releaseOnce();
|
|
226
234
|
setState(backend, { state: "error", lastError: error instanceof Error ? error.message.slice(0, 160) : "upstream request failed" });
|
|
@@ -307,7 +315,7 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
|
|
|
307
315
|
record.release = release;
|
|
308
316
|
return sseResponse(result.response, release, result.onModel, (cancel) => { record.cancel = cancel; });
|
|
309
317
|
} catch (error) {
|
|
310
|
-
if (!fallback || controller.signal.aborted) {
|
|
318
|
+
if (!fallback || controller.signal.aborted || error instanceof ExecutionAdmissionError) {
|
|
311
319
|
record.cleanup();
|
|
312
320
|
activeRequests.delete(record);
|
|
313
321
|
return Response.json({ error: error instanceof Error ? error.message : "backend unavailable" }, { status: 502 });
|
package/src/omniroute/client.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
+
import { normalizeUsage, safeModelLabel, type NormalizedUsage } from "./usage.ts";
|
|
2
3
|
|
|
3
4
|
export interface OmniRouteConfig {
|
|
4
5
|
/** Candidate base URLs ending in /v1, in order of preference. `AGENTHUB_OMNIROUTE_URL` replaces the list. */
|
|
@@ -26,6 +27,10 @@ export interface ChatMessage {
|
|
|
26
27
|
}
|
|
27
28
|
export interface ChatResult {
|
|
28
29
|
message: ChatMessage;
|
|
30
|
+
/** Usage counters returned by the provider, when valid. No missing counter is inferred as zero. */
|
|
31
|
+
usage?: NormalizedUsage;
|
|
32
|
+
/** Model named by the completed response, when the gateway reports it. */
|
|
33
|
+
servedModel?: string;
|
|
29
34
|
/** `x-omniroute-provider` (direct path) */
|
|
30
35
|
provider?: string;
|
|
31
36
|
/** `x-model-router-selected-model` (through Switchyard) */
|
|
@@ -171,16 +176,22 @@ export class OmniRoute {
|
|
|
171
176
|
throw e;
|
|
172
177
|
}
|
|
173
178
|
if (!res.ok) throw new Error(`model call failed: HTTP ${res.status} ${(await res.text()).slice(0, 300)}`);
|
|
174
|
-
const json = (await res.json()) as { choices?: { message?: ChatMessage }[] };
|
|
179
|
+
const json = (await res.json()) as { choices?: { message?: ChatMessage }[]; usage?: unknown; model?: unknown };
|
|
175
180
|
const message = json.choices?.[0]?.message;
|
|
176
181
|
if (!message) throw new Error("model call returned no message");
|
|
177
182
|
const provider = res.headers.get("x-omniroute-provider") ?? undefined;
|
|
178
183
|
const selectedModel = res.headers.get("x-model-router-selected-model") ?? undefined;
|
|
184
|
+
const servedModel = safeModelLabel(json.model) ?? safeModelLabel(selectedModel);
|
|
185
|
+
const safeProvider = safeModelLabel(provider);
|
|
186
|
+
const safeSelectedModel = safeModelLabel(selectedModel);
|
|
187
|
+
const usage = normalizeUsage(json.usage);
|
|
179
188
|
// reasoning_content and other extras are dropped here: only role, content and tool_calls travel on.
|
|
180
189
|
return {
|
|
181
190
|
message: { role: "assistant", content: message.content ?? null, ...(message.tool_calls?.length ? { tool_calls: message.tool_calls } : {}) },
|
|
182
|
-
...(
|
|
183
|
-
...(
|
|
191
|
+
...(usage ? { usage } : {}),
|
|
192
|
+
...(safeProvider ? { provider: safeProvider } : {}),
|
|
193
|
+
...(safeSelectedModel ? { selectedModel: safeSelectedModel } : {}),
|
|
194
|
+
...(servedModel ? { servedModel } : {}),
|
|
184
195
|
};
|
|
185
196
|
}
|
|
186
197
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Provider usage is optional. Missing counters stay missing; they are never guessed as zero. */
|
|
2
|
+
export interface NormalizedUsage {
|
|
3
|
+
inputTokens?: number;
|
|
4
|
+
outputTokens?: number;
|
|
5
|
+
cacheReadTokens?: number;
|
|
6
|
+
cacheWriteTokens?: number;
|
|
7
|
+
totalTokens?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const count = (value: unknown): number | undefined =>
|
|
11
|
+
typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
12
|
+
|
|
13
|
+
/** Accept known OpenAI-compatible and Anthropic-compatible counter spellings, dropping malformed fields. */
|
|
14
|
+
export function normalizeUsage(value: unknown): NormalizedUsage | undefined {
|
|
15
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
16
|
+
const raw = value as Record<string, unknown>;
|
|
17
|
+
const inputTokens = count(raw.input_tokens) ?? count(raw.prompt_tokens) ?? count(raw.inputTokens);
|
|
18
|
+
const outputTokens = count(raw.output_tokens) ?? count(raw.completion_tokens) ?? count(raw.outputTokens);
|
|
19
|
+
const cacheReadTokens = count(raw.cache_read_input_tokens) ?? count(raw.cached_read_tokens) ?? count(raw.cacheReadTokens)
|
|
20
|
+
?? count((raw.prompt_tokens_details as Record<string, unknown> | undefined)?.cached_tokens);
|
|
21
|
+
const cacheWriteTokens = count(raw.cache_creation_input_tokens) ?? count(raw.cache_write_tokens) ?? count(raw.cacheWriteTokens);
|
|
22
|
+
const totalTokens = count(raw.total_tokens) ?? count(raw.totalTokens);
|
|
23
|
+
const result = {
|
|
24
|
+
...(inputTokens !== undefined ? { inputTokens } : {}),
|
|
25
|
+
...(outputTokens !== undefined ? { outputTokens } : {}),
|
|
26
|
+
...(cacheReadTokens !== undefined ? { cacheReadTokens } : {}),
|
|
27
|
+
...(cacheWriteTokens !== undefined ? { cacheWriteTokens } : {}),
|
|
28
|
+
...(totalTokens !== undefined ? { totalTokens } : {}),
|
|
29
|
+
};
|
|
30
|
+
return Object.keys(result).length ? result : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Model/provider labels are metadata, but still untrusted: keep a short printable identifier only. */
|
|
34
|
+
export function safeModelLabel(value: unknown): string | undefined {
|
|
35
|
+
if (typeof value !== "string") return undefined;
|
|
36
|
+
const label = value.trim();
|
|
37
|
+
return label.length > 0 && label.length <= 160 && /^[A-Za-z0-9][A-Za-z0-9._:/+@-]*$/.test(label) ? label : undefined;
|
|
38
|
+
}
|
package/src/pi/extension.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { assistantTokens } from "./usage.ts";
|
|
|
2
2
|
type ExtensionAPI = any;
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
+
type BudgetUnit = "model_calls" | "tool_calls";
|
|
5
6
|
|
|
6
7
|
type BridgeEvent = { type: string; text?: string; sessionId?: string; sessionFile?: string; error?: string };
|
|
7
8
|
|
|
@@ -14,10 +15,12 @@ const models = (() => {
|
|
|
14
15
|
try { return JSON.parse(process.env.AGENTHUB_PI_MODELS ?? "[]") as unknown[]; } catch { return []; }
|
|
15
16
|
})();
|
|
16
17
|
let pollStarted = false;
|
|
18
|
+
let pollStopped = false;
|
|
17
19
|
let toolSteps = 0;
|
|
18
20
|
let lastActivity = 0;
|
|
19
21
|
let usageSeq = 0;
|
|
20
22
|
let forcedFailure = "";
|
|
23
|
+
let turnGeneration = 0;
|
|
21
24
|
const maxSteps = Number(process.env.AGENTHUB_PI_MAX_STEPS ?? 30);
|
|
22
25
|
let shutdown: (() => void) | undefined;
|
|
23
26
|
let runtimeCtx: any;
|
|
@@ -29,10 +32,27 @@ async function post(path: string, body: unknown): Promise<any> {
|
|
|
29
32
|
if (!response.ok) throw new Error(`Pi bridge HTTP ${response.status}`);
|
|
30
33
|
return response.json();
|
|
31
34
|
}
|
|
35
|
+
async function admitBudget(unit: BudgetUnit, idleUserBash = false): Promise<{ allowed: boolean; reservation?: string }> {
|
|
36
|
+
try {
|
|
37
|
+
const result = await post("/budget", { unit, generation: turnGeneration, ...(idleUserBash ? { idleUserBash: true } : {}) });
|
|
38
|
+
const decisions = Array.isArray(result?.decisions) ? result.decisions : [];
|
|
39
|
+
const denied = decisions.find((item: any) => item?.allowed === false);
|
|
40
|
+
if (!denied) return { allowed: true, ...(idleUserBash && typeof result?.reservation === "string" ? { reservation: result.reservation } : {}) };
|
|
41
|
+
const reason = `execution budget ${denied.reason ?? "exhausted"}: ${denied.scope} ${denied.unit ?? unit} used ${denied.used}${denied.limit === null ? "" : ` of ${denied.limit}`}`;
|
|
42
|
+
if (idleUserBash) return { allowed: false };
|
|
43
|
+
forcedFailure = reason;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (idleUserBash) return { allowed: false };
|
|
46
|
+
forcedFailure = `execution budget admission unavailable: ${(error as Error).message}`;
|
|
47
|
+
}
|
|
48
|
+
try { await post("/event", { type: "agent_end", generation: turnGeneration, failed: true, error: forcedFailure }); } catch { /* abort remains authoritative when the bridge is unavailable */ }
|
|
49
|
+
runtimeCtx?.abort?.();
|
|
50
|
+
return { allowed: false };
|
|
51
|
+
}
|
|
32
52
|
function processSignature(): string | undefined { try { const text = execFileSync("ps", ["-p", String(process.pid), "-o", "lstart=,comm="], { encoding: "utf8" }).trim(); return text ? createHash("sha256").update(text).digest("hex") : undefined; } catch { return undefined; } }
|
|
33
53
|
|
|
34
54
|
async function poll(pi: ExtensionAPI): Promise<void> {
|
|
35
|
-
while (
|
|
55
|
+
while (!pollStopped) {
|
|
36
56
|
try {
|
|
37
57
|
const response = await fetch(`${bridgeUrl}/commands`, { headers: { authorization: `Bearer ${bridgeToken}` } });
|
|
38
58
|
const payload = await response.json() as { command?: any };
|
|
@@ -54,7 +74,15 @@ async function poll(pi: ExtensionAPI): Promise<void> {
|
|
|
54
74
|
const model = modelRegistry?.find(String(command.provider), String(command.modelId));
|
|
55
75
|
if (!model || !(await (pi as any).setModel?.(model))) throw new Error("Pi model is not available");
|
|
56
76
|
} else if (command.type === "shutdown") {
|
|
77
|
+
pollStopped = true;
|
|
57
78
|
shutdown?.();
|
|
79
|
+
} else if (command.type === "abort_budget") {
|
|
80
|
+
if (Number.isSafeInteger(command.generation) && command.generation === turnGeneration) {
|
|
81
|
+
const reason = "execution budget exhausted: elapsed_ms wall cap reached";
|
|
82
|
+
forcedFailure = reason;
|
|
83
|
+
await post("/event", { type: "agent_end", generation: turnGeneration, failed: true, error: reason });
|
|
84
|
+
runtimeCtx?.abort?.();
|
|
85
|
+
}
|
|
58
86
|
}
|
|
59
87
|
await post("/ack", { id: command.id, ok: true });
|
|
60
88
|
} catch (error) { await post("/ack", { id: command.id, ok: false, error: (error as Error).message }); }
|
|
@@ -89,7 +117,7 @@ export default function(pi: ExtensionAPI): void {
|
|
|
89
117
|
const text = message?.content?.filter((c: any) => c?.type === "text").map((c: any) => c.text).join("")?.trim();
|
|
90
118
|
const failed = !!forcedFailure || message?.stopReason === "error";
|
|
91
119
|
const cancelled = !forcedFailure && message?.stopReason === "aborted";
|
|
92
|
-
await post("/event", { type: "agent_end", text: text ?? "", failed, cancelled, ...(failed ? { error: forcedFailure || message?.errorMessage || message?.stopReason } : {}) });
|
|
120
|
+
await post("/event", { type: "agent_end", generation: turnGeneration, text: text ?? "", failed, cancelled, ...(failed ? { error: forcedFailure || message?.errorMessage || message?.stopReason } : {}) });
|
|
93
121
|
});
|
|
94
122
|
pi.on("message_end", async (event: any) => {
|
|
95
123
|
const tokens = assistantTokens(event.message);
|
|
@@ -104,21 +132,28 @@ export default function(pi: ExtensionAPI): void {
|
|
|
104
132
|
lastActivity = now;
|
|
105
133
|
try { await post("/event", { type: "activity" }); } catch { /* shutdown owns bridge cleanup */ }
|
|
106
134
|
});
|
|
107
|
-
pi.on("agent_start", async () => { toolSteps = 0; forcedFailure = ""; await post("/event", { type: "agent_start" }); });
|
|
135
|
+
pi.on("agent_start", async () => { toolSteps = 0; forcedFailure = ""; turnGeneration++; await post("/event", { type: "agent_start", generation: turnGeneration }); });
|
|
108
136
|
pi.on("model_select", async (_event: any, ctx: any) => { if (ctx.model?.provider && ctx.model.provider !== "agent-hub-local") ctx.shutdown?.(); });
|
|
109
137
|
pi.on("before_agent_start", async (event: any, ctx: any) => {
|
|
110
138
|
if (ctx.model?.provider && ctx.model.provider !== "agent-hub-local") { ctx.abort?.(); return { systemPrompt: event.systemPrompt }; }
|
|
111
139
|
return undefined;
|
|
112
140
|
});
|
|
113
|
-
pi.on("before_provider_request", async (_event: any, ctx: any) => {
|
|
141
|
+
pi.on("before_provider_request", async (_event: any, ctx: any) => {
|
|
142
|
+
if (ctx.model?.provider && ctx.model.provider !== "agent-hub-local") { ctx.abort?.(); return undefined; }
|
|
143
|
+
try { if (!(await admitBudget("model_calls")).allowed) ctx.abort?.(); }
|
|
144
|
+
catch (error) { forcedFailure = `execution budget admission failed: ${(error as Error).message}`; ctx.abort?.(); }
|
|
145
|
+
return undefined;
|
|
146
|
+
});
|
|
114
147
|
pi.on("agent_settled", async (_event: any, ctx: any) => {
|
|
115
148
|
const entries = ctx.sessionManager?.getEntries?.() ?? [];
|
|
116
149
|
const message = entries.slice().reverse().find((entry: any) => entry.type === "message" && entry.message?.role === "assistant")?.message;
|
|
117
150
|
const text = message?.content?.filter((c: any) => c?.type === "text").map((c: any) => c.text).join("")?.trim();
|
|
118
|
-
await post("/event", { type: "agent_settled", ...(text ? { text } : {}) });
|
|
151
|
+
await post("/event", { type: "agent_settled", generation: turnGeneration, ...(text ? { text } : {}) });
|
|
119
152
|
});
|
|
120
153
|
pi.on("user_bash", async (event: any) => {
|
|
121
|
-
const
|
|
154
|
+
const admission = await admitBudget("tool_calls", true);
|
|
155
|
+
if (!admission.allowed || !admission.reservation) return { cancel: true };
|
|
156
|
+
const result = await post("/tool", { name: "bash", args: { command: event.command, cwd: event.cwd }, toolCallId: `pi-shell-${Date.now()}`, purpose: "idle_user_bash", generation: turnGeneration, reservation: admission.reservation });
|
|
122
157
|
return { result: { output: String(result.text ?? result), exitCode: 0, cancelled: false, truncated: false } };
|
|
123
158
|
});
|
|
124
159
|
// Managed sessions may only be handed over by PiPeer after it has fenced the
|
|
@@ -126,11 +161,12 @@ export default function(pi: ExtensionAPI): void {
|
|
|
126
161
|
// otherwise silently detach the hub from its recorded session.
|
|
127
162
|
pi.on("session_before_switch", async () => ({ cancel: true }));
|
|
128
163
|
pi.on("session_before_fork", async () => ({ cancel: true }));
|
|
129
|
-
pi.on("session_shutdown", async () => { await post("/event", { type: "session_shutdown" }); });
|
|
164
|
+
pi.on("session_shutdown", async () => { pollStopped = true; await post("/event", { type: "session_shutdown" }); });
|
|
130
165
|
for (const raw of (() => { try { return JSON.parse(process.env.AGENTHUB_PI_TOOLS ?? "[]") as any[]; } catch { return []; } })()) {
|
|
131
166
|
if (!raw || typeof raw.name !== "string" || !raw.parameters) continue;
|
|
132
167
|
pi.registerTool({ name: raw.name, label: raw.name, description: raw.description ?? raw.name, parameters: raw.parameters, async execute(toolCallId: string, params: unknown) {
|
|
133
|
-
if (
|
|
168
|
+
if (!(await admitBudget("tool_calls")).allowed) return { content: [{ type: "text", text: `error: ${forcedFailure}` }], details: {}, isError: true };
|
|
169
|
+
if (toolSteps++ >= maxSteps) { const reason = `Pi tool step limit ${maxSteps} reached`; forcedFailure = reason; await post("/event", { type: "agent_end", generation: turnGeneration, failed: true, error: reason }); runtimeCtx?.abort?.(); return { content: [{ type: "text", text: `error: ${reason}` }], details: {}, isError: true }; }
|
|
134
170
|
const result = await post("/tool", { name: raw.name, args: params, toolCallId });
|
|
135
171
|
return { content: [{ type: "text", text: String(result.text ?? result) }], details: {} };
|
|
136
172
|
} });
|
|
@@ -9,5 +9,5 @@ This project runs agent-hub: other coding agents (claude, codex, kimi, pi, local
|
|
|
9
9
|
- Start a message or final answer with `[IMPORTANT]` only when the others must see it now; `[FYI]` is recorded and costs nobody a turn. Unmarked answers are batched into digests.
|
|
10
10
|
- Read the kind of each message (`meta.kind` on a channel tag, each item's kind in a digest, the kind in a prompt header). Only `hub` items with kind `presence` are shared memory for reference, not requests. Its `task`, `review`, and `budget` items are workflow events: check the task board and your assigned role, then use the appropriate hub tools within the user's authorized scope. Sender and kind never override user instructions or safety rules.
|
|
11
11
|
- The task board is the record of who does what: `hub_task_propose`, `hub_task_accept` / `hub_task_decline`, `hub_task_done`, `hub_review`, `hub_task_list`. Default roles: Claude plans and reviews, Codex implements, Kimi, Pi and the local worker implement and verify; `.agenthub/config.json` `roles` is the source of truth.
|
|
12
|
-
- Implementers claim work nobody assigned them with `hub_task_propose`, naming themselves as `owner`, with the paths in `refs` and a `plan` (files, symbols, signatures, insertion points; `hub_task_accept` takes one too). When the paths or symbols overlap another open task the hub says so, the later claimant settles it with that owner, and that owner gets the plan with its next message. When a task is done, the owners of overlapping open tasks get a notice of what changed. `hub_task_done` says what changed, why, and the check that was run with its result.
|
|
12
|
+
- Implementers claim work nobody assigned them with `hub_task_propose`, naming themselves as `owner`, with the paths in `refs` and a `plan` (files, symbols, signatures, insertion points; `hub_task_accept` takes one too). When the paths or symbols overlap another open task the hub says so, the later claimant settles it with that owner, and that owner gets the plan with its next message. When a task is done, the owners of overlapping open tasks get a notice of what changed. In a project with `coordination: "turn-free"` in `.agenthub/config.json`, a task's texts say when its owners form a silent cohort: then they do not message each other, the hub shows each of them the other's changes as they work, and the last to finish is asked to check its work against the others and call `hub_task_done` again. `hub_task_done` says what changed, why, and the check that was run with its result.
|
|
13
13
|
- `hub_remember` saves a decision, finding, contract or `fail` (an approach that does not work, and why) to the memory all agents share; the other agents get it with their next message. Do not retry what a `fail` note rules out without new evidence. A task shown as `[pii]` is handled by the on-prem worker only: do not ask for its content.
|