@staix/agent-hub 0.5.0 → 0.6.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.
@@ -0,0 +1,334 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { isIP } from "node:net";
3
+ import type { OmniRoute, ChatMessage } from "../omniroute/client.ts";
4
+ import { ensureMlx, type MlxHandle, type MlxOptions, type MlxStatus } from "./mlx.ts";
5
+
6
+ export type ModelBackend = { kind: "mlx"; alias?: string } | { kind: "dgx"; alias: string };
7
+
8
+ export interface RelayRequest {
9
+ model?: string;
10
+ messages: ChatMessage[];
11
+ tools?: unknown[];
12
+ max_tokens?: number;
13
+ [key: string]: unknown;
14
+ }
15
+
16
+ export interface RelayBackendStatus {
17
+ alias: string;
18
+ kind: ModelBackend["kind"];
19
+ state: "idle" | "starting" | "ready" | "error";
20
+ requestedModel?: string;
21
+ actualModel?: string;
22
+ provider?: string;
23
+ active: number;
24
+ lastError?: string;
25
+ }
26
+
27
+ export interface ModelRelayStatus {
28
+ url: string;
29
+ models: string[];
30
+ backends: RelayBackendStatus[];
31
+ }
32
+
33
+ export interface ModelRelayOptions {
34
+ omni: OmniRoute;
35
+ host?: string;
36
+ port?: number;
37
+ token?: string;
38
+ defaultBackend?: ModelBackend;
39
+ selectBackend?: (request: RelayRequest) => ModelBackend | Promise<ModelBackend>;
40
+ allowedDGXmodels: Record<string, string>;
41
+ dgxMaxInputTokens?: number;
42
+ mlx?: MlxOptions;
43
+ mlxAlias?: string;
44
+ mlxModel?: string;
45
+ fallbackDGXAlias?: string;
46
+ }
47
+
48
+ export interface ModelRelay {
49
+ readonly url: string;
50
+ readonly token: string;
51
+ readonly models: string[];
52
+ readonly status: () => ModelRelayStatus;
53
+ readonly close: () => Promise<void>;
54
+ }
55
+
56
+ interface ActiveRequest {
57
+ controller: AbortController;
58
+ release?: () => void;
59
+ cancel?: (reason?: unknown) => Promise<void>;
60
+ cleanup: () => void;
61
+ }
62
+
63
+ const safeHeader = (value: string | null): string | undefined => value && value.length < 256 ? value : undefined;
64
+
65
+ function assertLoopback(host: string): void {
66
+ const value = host.toLowerCase();
67
+ if (value !== "localhost" && value !== "::1" && !(isIP(value) === 4 && value.startsWith("127."))) throw new Error("model relay host must be loopback");
68
+ }
69
+
70
+ function estimateInputTokens(messages: ChatMessage[], tools?: unknown[]): number {
71
+ return Math.ceil(JSON.stringify({ messages, tools }).length / 4);
72
+ }
73
+
74
+ function bearer(request: Request): string | undefined {
75
+ const value = request.headers.get("authorization");
76
+ return value?.startsWith("Bearer ") ? value.slice("Bearer ".length) : undefined;
77
+ }
78
+
79
+ function aliasOf(backend: ModelBackend, mlxAlias: string): string {
80
+ return backend.kind === "mlx" ? backend.alias ?? mlxAlias : backend.alias;
81
+ }
82
+
83
+ function bodyForUpstream(body: RelayRequest, model: string): Record<string, unknown> {
84
+ const allowed = ["temperature", "top_p", "max_tokens", "stop", "tools", "tool_choice", "response_format"];
85
+ return {
86
+ model,
87
+ messages: body.messages,
88
+ stream: true,
89
+ ...Object.fromEntries(allowed.filter((key) => body[key] !== undefined).map((key) => [key, body[key]])),
90
+ };
91
+ }
92
+
93
+ function sseResponse(response: Response, release: () => void, onModel?: (model: string) => void, registerCancel?: (cancel: (reason?: unknown) => Promise<void>) => void): Response {
94
+ if (!response.body) {
95
+ release();
96
+ return new Response("upstream returned no stream", { status: 502 });
97
+ }
98
+ const reader = response.body.getReader();
99
+ const cancel = async (reason?: unknown) => {
100
+ try { await reader.cancel(reason); } catch { /* the upstream may already be closed */ }
101
+ };
102
+ registerCancel?.(cancel);
103
+ let inspectBuffer = "";
104
+ let inspectedModel = false;
105
+ const inspect = (chunk: Uint8Array) => {
106
+ if (!onModel || inspectedModel) return;
107
+ inspectBuffer += new TextDecoder().decode(chunk);
108
+ if (inspectBuffer.length > 64_000) inspectBuffer = inspectBuffer.slice(-64_000);
109
+ const lines = inspectBuffer.split(/\r?\n/);
110
+ inspectBuffer = lines.pop() ?? "";
111
+ for (const line of lines) {
112
+ if (!line.startsWith("data:") || line.slice(5).trim() === "[DONE]") continue;
113
+ try {
114
+ const value = JSON.parse(line.slice(5).trim()) as { model?: unknown };
115
+ if (typeof value.model === "string" && value.model.length < 256) {
116
+ inspectedModel = true;
117
+ onModel(value.model);
118
+ return;
119
+ }
120
+ } catch { /* incomplete or non-JSON SSE data */ }
121
+ }
122
+ };
123
+ const stream = new ReadableStream<Uint8Array>({
124
+ async pull(controller) {
125
+ try {
126
+ const next = await reader.read();
127
+ if (next.done) {
128
+ release();
129
+ controller.close();
130
+ } else { inspect(next.value); controller.enqueue(next.value); }
131
+ } catch (error) {
132
+ release();
133
+ controller.error(error);
134
+ }
135
+ },
136
+ async cancel(reason) {
137
+ release();
138
+ await cancel(reason);
139
+ },
140
+ });
141
+ return new Response(stream, {
142
+ status: response.status,
143
+ headers: { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" },
144
+ });
145
+ }
146
+
147
+ export async function startModelRelay(options: ModelRelayOptions): Promise<ModelRelay> {
148
+ const host = options.host ?? "127.0.0.1";
149
+ assertLoopback(host);
150
+ const token = options.token ?? randomUUID();
151
+ const mlxAlias = options.mlxAlias ?? "mlx/fast";
152
+ const dgxMaxInputTokens = options.dgxMaxInputTokens ?? 262_144;
153
+ const defaultBackend = options.defaultBackend ?? (options.mlx ? { kind: "mlx", alias: mlxAlias } : { kind: "dgx", alias: "dgx/coding" });
154
+ const models = [...new Set([...(options.mlx ? [mlxAlias] : []), ...Object.keys(options.allowedDGXmodels)])];
155
+ let mlx: MlxHandle | undefined;
156
+ let mlxStarting: Promise<MlxHandle> | undefined;
157
+ const states = new Map<string, RelayBackendStatus>();
158
+ const activeRequests = new Set<ActiveRequest>();
159
+ const activeByAlias = new Map<string, number>();
160
+
161
+ const ensureMlxHandle = async (): Promise<MlxHandle> => (mlx ??= await (mlxStarting ??= ensureMlx(options.mlx).finally(() => { mlxStarting = undefined; })));
162
+ const state = (backend: ModelBackend): RelayBackendStatus => states.get(aliasOf(backend, mlxAlias)) ?? {
163
+ alias: aliasOf(backend, mlxAlias), kind: backend.kind, state: "idle", active: 0,
164
+ };
165
+ const setState = (backend: ModelBackend, patch: Partial<RelayBackendStatus>) => {
166
+ const alias = aliasOf(backend, mlxAlias);
167
+ states.set(alias, { ...state(backend), ...patch, alias, kind: backend.kind });
168
+ };
169
+
170
+ const resolve = async (body: RelayRequest): Promise<ModelBackend> => {
171
+ const requested = typeof body.model === "string" ? body.model : undefined;
172
+ if (requested && !models.includes(requested)) throw new Error("model alias is not allowed");
173
+ if (requested === mlxAlias && options.mlx) return { kind: "mlx", alias: mlxAlias };
174
+ if (requested && requested in options.allowedDGXmodels) return { kind: "dgx", alias: requested };
175
+ const selected = options.selectBackend ? await options.selectBackend(body) : defaultBackend;
176
+ if (selected.kind === "mlx" && !options.mlx) throw new Error("MLX backend is not configured");
177
+ if (selected.kind === "dgx" && !(selected.alias in options.allowedDGXmodels)) throw new Error("DGX model alias is not allowed");
178
+ return selected;
179
+ };
180
+
181
+ const upstream = async (request: RelayRequest, backend: ModelBackend, signal: AbortSignal): Promise<{ response: Response; release: () => void; onModel?: (model: string) => void }> => {
182
+ const alias = aliasOf(backend, mlxAlias);
183
+ let base: string;
184
+ let model: string;
185
+ let release = () => {};
186
+ let released = false;
187
+ const count = (delta: number) => activeByAlias.set(alias, Math.max(0, (activeByAlias.get(alias) ?? 0) + delta));
188
+ if (backend.kind === "mlx") {
189
+ setState(backend, { state: "starting", requestedModel: request.model });
190
+ let handle: MlxHandle;
191
+ try {
192
+ handle = await ensureMlxHandle();
193
+ } catch (error) {
194
+ setState(backend, { state: "error", lastError: error instanceof Error ? error.message.slice(0, 160) : "MLX startup failed" });
195
+ throw error;
196
+ }
197
+ base = handle.url;
198
+ model = options.mlxModel ?? handle.model;
199
+ if (signal.aborted) throw new Error("request was cancelled before MLX generation started");
200
+ release = await handle.acquire(signal);
201
+ } else {
202
+ base = (await options.omni.base()) ?? (() => { throw new Error("DGX gateway is unavailable"); })();
203
+ model = options.allowedDGXmodels[backend.alias]!;
204
+ }
205
+ const key = backend.kind === "dgx" ? options.omni.apiKey() : "";
206
+ if (backend.kind === "dgx" && !key) throw new Error("DGX gateway key is unavailable");
207
+ count(1);
208
+ const releaseOnce = () => {
209
+ if (released) return;
210
+ released = true;
211
+ release();
212
+ count(-1);
213
+ };
214
+ const headers: Record<string, string> = { "content-type": "application/json" };
215
+ if (key) Object.assign(headers, { authorization: `Bearer ${key}` }, options.omni.accessHeaders(base));
216
+ let response: Response;
217
+ try {
218
+ response = await fetch(`${base.replace(/\/$/, "")}/chat/completions`, { method: "POST", headers, body: JSON.stringify(bodyForUpstream(request, model)), signal: AbortSignal.any([signal, AbortSignal.timeout(180_000)]) });
219
+ } catch (error) {
220
+ releaseOnce();
221
+ setState(backend, { state: "error", lastError: error instanceof Error ? error.message.slice(0, 160) : "upstream request failed" });
222
+ throw error;
223
+ }
224
+ if (!response.ok) {
225
+ releaseOnce();
226
+ const message = `backend returned HTTP ${response.status}`;
227
+ setState(backend, { state: "error", lastError: message });
228
+ throw new Error(message);
229
+ }
230
+ const provider = safeHeader(response.headers.get("x-omniroute-provider"));
231
+ const actualModel = safeHeader(response.headers.get("x-model-router-selected-model"));
232
+ setState(backend, { state: "ready", requestedModel: request.model, active: activeByAlias.get(alias) ?? 0,
233
+ provider: provider ?? undefined, actualModel: actualModel ?? (backend.kind === "mlx" ? model : undefined) });
234
+ const releaseWithStatus = () => {
235
+ releaseOnce();
236
+ setState(backend, { active: activeByAlias.get(alias) ?? 0 });
237
+ };
238
+ return { response, release: releaseWithStatus, ...(actualModel ? {} : { onModel: (value: string) => setState(backend, { actualModel: value }) }) };
239
+ };
240
+
241
+ const server = Bun.serve({
242
+ hostname: host,
243
+ port: options.port ?? 0,
244
+ idleTimeout: 255,
245
+ async fetch(request) {
246
+ if (bearer(request) !== token) return new Response("unauthorized", { status: 401 });
247
+ if (request.headers.has("origin")) return new Response("origin header is not accepted", { status: 403 });
248
+ const path = new URL(request.url).pathname;
249
+ if (request.method === "GET" && path === "/health") return Response.json({ ok: true, status: status() });
250
+ if (request.method === "GET" && path === "/v1/models") return Response.json({ object: "list", data: models.map((id) => ({ id, object: "model", owned_by: id.startsWith("mlx/") ? "mlx" : "dgx" })) });
251
+ if (request.method !== "POST" || path !== "/v1/chat/completions") return new Response("not found", { status: 404 });
252
+ if (Number(request.headers.get("content-length") ?? 0) > 2_000_000) return new Response("request too large", { status: 413 });
253
+ const controller = new AbortController();
254
+ const onAbort = () => controller.abort(request.signal.reason);
255
+ request.signal.addEventListener("abort", onAbort, { once: true });
256
+ const record: ActiveRequest = { controller, cleanup: () => request.signal.removeEventListener("abort", onAbort) };
257
+ activeRequests.add(record);
258
+ let body: RelayRequest;
259
+ try {
260
+ const raw = await request.text();
261
+ if (new TextEncoder().encode(raw).byteLength > 2_000_000) throw new Error("request too large");
262
+ body = JSON.parse(raw) as RelayRequest;
263
+ if (!Array.isArray(body.messages) || body.messages.length === 0) throw new Error("messages is required");
264
+ } catch (error) {
265
+ record.cleanup();
266
+ activeRequests.delete(record);
267
+ return Response.json({ error: error instanceof Error ? error.message : "invalid request" }, { status: 400 });
268
+ }
269
+ let backend: ModelBackend;
270
+ try { backend = await resolve(body); } catch (error) {
271
+ record.cleanup();
272
+ activeRequests.delete(record);
273
+ return Response.json({ error: error instanceof Error ? error.message : "backend unavailable" }, { status: 400 });
274
+ }
275
+ const inputBudget = backend.kind === "mlx" ? (options.mlx?.maxInputTokens ?? 16_000) : dgxMaxInputTokens;
276
+ if (estimateInputTokens(body.messages, body.tools) > inputBudget) {
277
+ record.cleanup();
278
+ activeRequests.delete(record);
279
+ return Response.json({ error: "input exceeds the model context budget" }, { status: 400 });
280
+ }
281
+ const fallback = backend.kind === "mlx" && options.fallbackDGXAlias ? { kind: "dgx", alias: options.fallbackDGXAlias } as ModelBackend : undefined;
282
+ try {
283
+ const result = await upstream(body, backend, controller.signal);
284
+ let released = false;
285
+ const release = () => {
286
+ if (released) return;
287
+ released = true;
288
+ result.release();
289
+ record.cleanup();
290
+ activeRequests.delete(record);
291
+ };
292
+ record.release = release;
293
+ return sseResponse(result.response, release, result.onModel, (cancel) => { record.cancel = cancel; });
294
+ } catch (error) {
295
+ if (!fallback || controller.signal.aborted) {
296
+ record.cleanup();
297
+ activeRequests.delete(record);
298
+ return Response.json({ error: error instanceof Error ? error.message : "backend unavailable" }, { status: 502 });
299
+ }
300
+ try {
301
+ const result = await upstream({ ...body, model: fallback.alias }, fallback, controller.signal);
302
+ let released = false;
303
+ const release = () => {
304
+ if (released) return;
305
+ released = true;
306
+ result.release();
307
+ record.cleanup();
308
+ activeRequests.delete(record);
309
+ };
310
+ record.release = release;
311
+ return sseResponse(result.response, release, result.onModel, (cancel) => { record.cancel = cancel; });
312
+ } catch (fallbackError) {
313
+ record.cleanup();
314
+ activeRequests.delete(record);
315
+ return Response.json({ error: fallbackError instanceof Error ? fallbackError.message : "fallback unavailable" }, { status: 502 });
316
+ }
317
+ }
318
+ },
319
+ });
320
+ const url = `http://${host}:${server.port}/v1`;
321
+ const status = (): ModelRelayStatus => ({ url, models, backends: [...states.values()].map((value) => ({ ...value })) });
322
+ return { url, token, models, status, close: async () => {
323
+ const closing = [...activeRequests].map(async (request) => {
324
+ request.controller.abort(new Error("model relay closed"));
325
+ await request.cancel?.(new Error("model relay closed"));
326
+ request.release?.();
327
+ request.cleanup();
328
+ });
329
+ await Promise.allSettled(closing);
330
+ activeRequests.clear();
331
+ server.stop(false);
332
+ await mlx?.close();
333
+ } };
334
+ }
@@ -0,0 +1,120 @@
1
+ type ExtensionAPI = any;
2
+ import { execFileSync } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+
5
+ type BridgeEvent = { type: string; text?: string; sessionId?: string; sessionFile?: string; error?: string };
6
+
7
+ const bridgeUrl = process.env.AGENTHUB_PI_BRIDGE_URL ?? "";
8
+ const bridgeToken = process.env.AGENTHUB_PI_BRIDGE_TOKEN ?? "";
9
+ const ownerToken = process.env.AGENTHUB_PI_OWNER_TOKEN ?? "";
10
+ const relayUrl = process.env.AGENTHUB_PI_RELAY_URL ?? "";
11
+ const relayToken = process.env.AGENTHUB_PI_RELAY_TOKEN ?? "";
12
+ const models = (() => {
13
+ try { return JSON.parse(process.env.AGENTHUB_PI_MODELS ?? "[]") as unknown[]; } catch { return []; }
14
+ })();
15
+ let pollStarted = false;
16
+ let toolSteps = 0;
17
+ let lastActivity = 0;
18
+ let forcedFailure = "";
19
+ const maxSteps = Number(process.env.AGENTHUB_PI_MAX_STEPS ?? 30);
20
+ let shutdown: (() => void) | undefined;
21
+ let runtimeCtx: any;
22
+ let modelRegistry: any;
23
+
24
+ async function post(path: string, body: unknown): Promise<any> {
25
+ if (!bridgeUrl || !bridgeToken) throw new Error("Pi bridge is not configured");
26
+ const response = await fetch(`${bridgeUrl}${path}`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${bridgeToken}` }, body: JSON.stringify(body) });
27
+ if (!response.ok) throw new Error(`Pi bridge HTTP ${response.status}`);
28
+ return response.json();
29
+ }
30
+ 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; } }
31
+
32
+ async function poll(pi: ExtensionAPI): Promise<void> {
33
+ while (true) {
34
+ try {
35
+ const response = await fetch(`${bridgeUrl}/commands`, { headers: { authorization: `Bearer ${bridgeToken}` } });
36
+ const payload = await response.json() as { command?: any };
37
+ const command = payload.command;
38
+ if (!command) continue;
39
+ try {
40
+ if (command.type === "prompt" || command.type === "steer") {
41
+ pi.sendMessage({ customType: "agent-hub", content: String(command.message ?? ""), display: true }, { triggerTurn: command.type === "prompt", deliverAs: command.type === "steer" ? "steer" : "followUp" });
42
+ } else if (command.type === "set_model") {
43
+ const model = modelRegistry?.find(String(command.provider), String(command.modelId));
44
+ if (!model || !(await (pi as any).setModel?.(model))) throw new Error("Pi model is not available");
45
+ } else if (command.type === "shutdown") {
46
+ shutdown?.();
47
+ }
48
+ await post("/ack", { id: command.id, ok: true });
49
+ } catch (error) { await post("/ack", { id: command.id, ok: false, error: (error as Error).message }); }
50
+ } catch { await new Promise((resolve) => setTimeout(resolve, 250)); }
51
+ }
52
+ }
53
+
54
+ export default function(pi: ExtensionAPI): void {
55
+ if (relayUrl) {
56
+ pi.registerProvider("agent-hub-local", {
57
+ baseUrl: relayUrl,
58
+ api: "openai-completions",
59
+ apiKey: relayToken || "agent-hub-local",
60
+ models: models.filter((m): m is any => !!m && typeof m === "object" && typeof (m as any).id === "string").map((m: any) => ({
61
+ id: m.id, name: m.name ?? m.id, reasoning: !!m.reasoning, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: m.contextWindow ?? 131072, maxTokens: m.maxTokens ?? 8192,
62
+ })),
63
+ });
64
+ }
65
+ pi.on("session_start", async (_event: any, ctx: any) => {
66
+ runtimeCtx = ctx;
67
+ modelRegistry = ctx.modelRegistry;
68
+ shutdown = ctx.shutdown;
69
+ const state = ctx.sessionManager.getHeader();
70
+ try {
71
+ const claimed = await post("/event", { type: "session_start", ownerToken, pid: process.pid, signature: processSignature(), sessionId: state?.id, sessionFile: ctx.sessionManager.getSessionFile() });
72
+ if (claimed?.ok === false) { ctx.shutdown?.(); return; }
73
+ } catch (error) { ctx.shutdown?.(); throw error; }
74
+ if (!pollStarted) { pollStarted = true; void poll(pi); }
75
+ });
76
+ pi.on("agent_end", async (event: any) => {
77
+ const message = (event as any).messages?.slice().reverse().find((m: any) => m?.role === "assistant");
78
+ const text = message?.content?.filter((c: any) => c?.type === "text").map((c: any) => c.text).join("")?.trim();
79
+ const failed = !!forcedFailure || message?.stopReason === "error";
80
+ const cancelled = !forcedFailure && message?.stopReason === "aborted";
81
+ await post("/event", { type: "agent_end", text: text ?? "", failed, cancelled, ...(failed ? { error: forcedFailure || message?.errorMessage || message?.stopReason } : {}) });
82
+ });
83
+ pi.on("message_update", async () => {
84
+ const now = Date.now();
85
+ if (now - lastActivity < 1000) return;
86
+ lastActivity = now;
87
+ try { await post("/event", { type: "activity" }); } catch { /* shutdown owns bridge cleanup */ }
88
+ });
89
+ pi.on("agent_start", async () => { toolSteps = 0; forcedFailure = ""; await post("/event", { type: "agent_start" }); });
90
+ pi.on("model_select", async (_event: any, ctx: any) => { if (ctx.model?.provider && ctx.model.provider !== "agent-hub-local") ctx.shutdown?.(); });
91
+ pi.on("before_agent_start", async (event: any, ctx: any) => {
92
+ if (ctx.model?.provider && ctx.model.provider !== "agent-hub-local") { ctx.abort?.(); return { systemPrompt: event.systemPrompt }; }
93
+ return undefined;
94
+ });
95
+ pi.on("before_provider_request", async (_event: any, ctx: any) => { if (ctx.model?.provider && ctx.model.provider !== "agent-hub-local") ctx.abort?.(); return undefined; });
96
+ pi.on("agent_settled", async (_event: any, ctx: any) => {
97
+ const entries = ctx.sessionManager?.getEntries?.() ?? [];
98
+ const message = entries.slice().reverse().find((entry: any) => entry.type === "message" && entry.message?.role === "assistant")?.message;
99
+ const text = message?.content?.filter((c: any) => c?.type === "text").map((c: any) => c.text).join("")?.trim();
100
+ await post("/event", { type: "agent_settled", ...(text ? { text } : {}) });
101
+ });
102
+ pi.on("user_bash", async (event: any) => {
103
+ const result = await post("/tool", { name: "bash", args: { command: event.command, cwd: event.cwd }, toolCallId: `pi-shell-${Date.now()}` });
104
+ return { result: { output: String(result.text ?? result), exitCode: 0, cancelled: false, truncated: false } };
105
+ });
106
+ // Managed sessions may only be handed over by PiPeer after it has fenced the
107
+ // owner and verified the replacement identity. User /fork and /resume would
108
+ // otherwise silently detach the hub from its recorded session.
109
+ pi.on("session_before_switch", async () => ({ cancel: true }));
110
+ pi.on("session_before_fork", async () => ({ cancel: true }));
111
+ pi.on("session_shutdown", async () => { await post("/event", { type: "session_shutdown" }); });
112
+ for (const raw of (() => { try { return JSON.parse(process.env.AGENTHUB_PI_TOOLS ?? "[]") as any[]; } catch { return []; } })()) {
113
+ if (!raw || typeof raw.name !== "string" || !raw.parameters) continue;
114
+ pi.registerTool({ name: raw.name, label: raw.name, description: raw.description ?? raw.name, parameters: raw.parameters, async execute(toolCallId: string, params: unknown) {
115
+ if (toolSteps++ >= maxSteps) { const reason = `Pi tool step limit ${maxSteps} reached`; forcedFailure = reason; await post("/event", { type: "agent_end", failed: true, error: reason }); runtimeCtx?.abort?.(); return { content: [{ type: "text", text: `error: ${reason}` }], details: {}, isError: true }; }
116
+ const result = await post("/tool", { name: raw.name, args: params, toolCallId });
117
+ return { content: [{ type: "text", text: String(result.text ?? result) }], details: {} };
118
+ } });
119
+ }
120
+ }
@@ -0,0 +1,52 @@
1
+ import { chmodSync } from "node:fs";
2
+ import { Database } from "bun:sqlite";
3
+ import { createHash } from "node:crypto";
4
+
5
+ /** Private, session-scoped dedupe. An interrupted effect is never automatically repeated. */
6
+ export class PiToolReceipts {
7
+ private readonly db: Database;
8
+ private closing = false;
9
+ private closed = false;
10
+ private readonly pending = new Map<string, Promise<string>>();
11
+ constructor(file: string) {
12
+ this.db = new Database(file, { create: true });
13
+ chmodSync(file, 0o600);
14
+ this.db.run("PRAGMA busy_timeout = 5000");
15
+ this.db.run(`CREATE TABLE IF NOT EXISTS pi_tool_receipts (
16
+ session TEXT NOT NULL, call_id TEXT NOT NULL, fingerprint TEXT NOT NULL,
17
+ state TEXT NOT NULL, result TEXT, PRIMARY KEY(session, call_id))`);
18
+ }
19
+
20
+ async execute(session: string, callId: string, name: string, args: unknown, run: () => Promise<string>): Promise<string> {
21
+ if (this.closing) return "error: Pi tool ledger is shutting down; no new operation was executed";
22
+ if (!session || !callId || session.length > 512 || callId.length > 512) return "error: invalid Pi session/tool call identity";
23
+ const fingerprint = createHash("sha256").update(JSON.stringify({ name, args })).digest("hex");
24
+ const key = JSON.stringify([session, callId]);
25
+ const row = this.db.query("SELECT fingerprint,state,result FROM pi_tool_receipts WHERE session=? AND call_id=?").get(session, callId) as { fingerprint: string; state: string; result: string | null } | null;
26
+ if (row) {
27
+ if (row.fingerprint !== fingerprint) return "error: Pi tool call ID was reused with different arguments";
28
+ if (row.state === "done") return row.result ?? "";
29
+ const active = this.pending.get(key);
30
+ return active ?? "error: previous tool outcome is uncertain; stop and reconcile it, do not repeat the effect";
31
+ }
32
+ const claim = this.db.query("INSERT OR IGNORE INTO pi_tool_receipts(session,call_id,fingerprint,state) VALUES(?,?,?,'pending')").run(session, callId, fingerprint);
33
+ if (!claim.changes) return this.execute(session, callId, name, args, run);
34
+ const work = Promise.resolve().then(run).then((result) => {
35
+ const text = result.slice(0, 100_000);
36
+ this.db.query("UPDATE pi_tool_receipts SET state='done',result=? WHERE session=? AND call_id=?").run(text, session, callId);
37
+ return text;
38
+ }).catch(() => "error: tool outcome is uncertain; stop and reconcile it, do not repeat the effect")
39
+ .finally(() => this.pending.delete(key));
40
+ this.pending.set(key, work);
41
+ return work;
42
+ }
43
+
44
+ get inFlight(): number { return this.pending.size; }
45
+ async drain(): Promise<void> { await Promise.allSettled(this.pending.values()); }
46
+
47
+ async close(): Promise<void> {
48
+ this.closing = true;
49
+ await this.drain();
50
+ if (!this.closed) { this.closed = true; this.db.close(); }
51
+ }
52
+ }
package/src/ui/index.html CHANGED
@@ -138,6 +138,10 @@ function render(snapshot) {
138
138
  head.append(el('span', id, 'label'), el('span', peer.state, 'badge' + (peer.state === 'paused' ? ' warn' : '')));
139
139
  row.append(head, el('p', peer.queued + ' queued', 'meta'));
140
140
  if (peer.paused) row.append(el('p', peer.paused, 'meta'));
141
+ if (peer.requestedModel) row.append(el('p', 'Requested: ' + peer.requestedModel, 'meta'));
142
+ for (const backend of peer.backends || []) {
143
+ if (backend.requestedModel) row.append(el('p', backend.kind.toUpperCase() + ': ' + (backend.actualModel || 'model not reported') + ' (' + backend.state + ')', 'meta'));
144
+ }
141
145
  const paused = peer.state === 'paused' || peer.paused;
142
146
  row.append(button(paused ? 'Resume' : 'Pause', {action:paused ? 'resume' : 'pause', peer:id})); target.append(row);
143
147
  }
@@ -2,6 +2,7 @@
2
2
  "watchdog_ms": 300000,
3
3
  "kimi_cmd": ["kimi", "acp"],
4
4
  "codex_bin": "codex",
5
+ "pi": { "enabled": false, "auto_start": false, "cmd": ["pi"], "backend": "auto", "dgx_coding": "coding", "dgx_fast": "fast", "max_steps": 30 },
5
6
  "batch_max": 3,
6
7
  "batch_ms": 15000,
7
8
  "queue_cap": 200,
@@ -10,7 +11,8 @@
10
11
  "claude": ["planner", "reviewer"],
11
12
  "codex": ["implementer"],
12
13
  "kimi": ["implementer", "verifier"],
13
- "local": ["implementer", "verifier"]
14
+ "local": ["implementer", "verifier"],
15
+ "pi": ["implementer", "verifier"]
14
16
  },
15
17
  "budget": { "gate": 0.9, "stale_min": 30, "poll_min": 10, "checkpoint_timeout_s": 90, "kimi_tokens_5h": 0 },
16
18
  "inference": { "enabled": true, "digest_min_items": 5, "digest_min_chars": 8000, "triage": true },
@@ -52,33 +52,44 @@ pii = "local_only" # hard: a PII task is owned by `local` or nobody,
52
52
  long_context = "skip_local"
53
53
  budget_paused = "skip_peer"
54
54
 
55
+ [pi]
56
+ dgx_max_context_tokens = 262144
57
+ mlx_max_context_tokens = 16000
58
+
55
59
  [classes.plan]
56
60
  peers = ["claude", "codex"]
57
61
  local_allowed = false
58
62
 
59
63
  [classes.implement]
60
- peers = ["codex", "kimi", "local"]
64
+ peers = ["pi", "local", "codex", "kimi"]
61
65
  route = "sy/coding"
62
- escalate_to = ["kimi", "claude"]
66
+ pi_backend = "dgx"
67
+ escalate_to = ["codex", "kimi", "claude"]
63
68
 
64
69
  [classes.bulk_edit]
65
- peers = ["local", "kimi"]
70
+ peers = ["pi", "local", "kimi"]
66
71
  route = "sy/fast"
67
- escalate_to = ["kimi", "codex"]
72
+ pi_backend = "dgx"
73
+ escalate_to = ["codex", "kimi"]
68
74
 
69
75
  [classes.test]
70
- peers = ["kimi", "local", "codex"]
76
+ peers = ["pi", "local", "kimi", "codex"]
71
77
  route = "sy/fast"
72
- escalate_to = ["codex"]
78
+ pi_backend = "dgx"
79
+ escalate_to = ["codex", "kimi"]
73
80
 
74
81
  [classes.review]
75
82
  peers = ["claude", "codex"]
76
83
  local_allowed = false # judgment classes never go to local
77
84
 
78
85
  [classes.summarize]
79
- peers = ["local", "kimi"]
86
+ peers = ["pi", "local", "kimi"]
80
87
  route = "sy/fast"
88
+ pi_backend = "mlx"
89
+ escalate_to = ["codex", "kimi"]
81
90
 
82
91
  [classes.triage]
83
- peers = ["local", "kimi"]
92
+ peers = ["pi", "local", "kimi"]
84
93
  route = "sy/fast"
94
+ pi_backend = "mlx"
95
+ escalate_to = ["codex", "kimi"]