openmerit 0.4.1 → 0.4.2

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.
Files changed (70) hide show
  1. package/README.md +239 -58
  2. package/data/catalogue.json +6778 -0
  3. package/dist/catalogue/catalogue.d.ts +41 -0
  4. package/dist/catalogue/catalogue.js +33 -0
  5. package/dist/cli/prompt.d.ts +26 -0
  6. package/dist/cli/prompt.js +114 -0
  7. package/dist/cli/render.d.ts +12 -0
  8. package/dist/cli/render.js +104 -0
  9. package/dist/cli.js +245 -158
  10. package/dist/connect.d.ts +34 -0
  11. package/dist/connect.js +158 -0
  12. package/dist/connectors/langfuse/index.d.ts +37 -110
  13. package/dist/connectors/langfuse/index.js +95 -246
  14. package/dist/daemon/daemon.d.ts +81 -0
  15. package/dist/daemon/daemon.js +408 -0
  16. package/dist/daemon/registry.d.ts +111 -0
  17. package/dist/daemon/registry.js +109 -0
  18. package/dist/eval/proxy.d.ts +54 -0
  19. package/dist/eval/proxy.js +174 -0
  20. package/dist/eval/runner.d.ts +29 -0
  21. package/dist/eval/runner.js +80 -0
  22. package/dist/harness/claude-bridge.d.ts +21 -0
  23. package/dist/harness/claude-bridge.js +65 -0
  24. package/dist/harness/harness.d.ts +60 -0
  25. package/dist/harness/harness.js +221 -0
  26. package/dist/index.d.ts +9 -10
  27. package/dist/index.js +9 -10
  28. package/dist/local.d.ts +12 -0
  29. package/dist/local.js +40 -0
  30. package/dist/mcp.d.ts +1 -0
  31. package/dist/mcp.js +155 -0
  32. package/dist/scout/pool.d.ts +17 -0
  33. package/dist/scout/pool.js +56 -0
  34. package/dist/scout/scan.d.ts +42 -0
  35. package/dist/scout/scan.js +169 -0
  36. package/dist/scout/search.d.ts +25 -98
  37. package/dist/scout/search.js +56 -156
  38. package/dist/scout/workflow.d.ts +221 -0
  39. package/dist/scout/workflow.js +941 -0
  40. package/dist/setup.d.ts +44 -0
  41. package/dist/setup.js +211 -0
  42. package/dist/state/config.d.ts +116 -0
  43. package/dist/state/config.js +60 -0
  44. package/dist/state/db.d.ts +159 -0
  45. package/dist/state/db.js +260 -0
  46. package/dist/state/paths.d.ts +12 -0
  47. package/dist/state/paths.js +23 -0
  48. package/dist/update.d.ts +22 -0
  49. package/dist/update.js +58 -0
  50. package/package.json +12 -17
  51. package/dist/connectors/langfuse/otlp.d.ts +0 -60
  52. package/dist/connectors/langfuse/otlp.js +0 -40
  53. package/dist/pi/extension.d.ts +0 -13
  54. package/dist/pi/extension.js +0 -148
  55. package/dist/pi/harness.d.ts +0 -20
  56. package/dist/pi/harness.js +0 -49
  57. package/dist/pi/instructions.d.ts +0 -2
  58. package/dist/pi/instructions.js +0 -12
  59. package/dist/scout/evaluators/module.d.ts +0 -24
  60. package/dist/scout/evaluators/module.js +0 -19
  61. package/dist/scout/evaluators/pi.d.ts +0 -53
  62. package/dist/scout/evaluators/pi.js +0 -117
  63. package/dist/scout/pipeline.d.ts +0 -111
  64. package/dist/scout/pipeline.js +0 -157
  65. package/dist/scout/report.d.ts +0 -3
  66. package/dist/scout/report.js +0 -42
  67. package/dist/scout/service.d.ts +0 -148
  68. package/dist/scout/service.js +0 -143
  69. package/examples/health-declaration-codes/dataset.jsonl +0 -14
  70. package/examples/sahra-enrollment/evaluator.mts +0 -152
@@ -0,0 +1,54 @@
1
+ import { type CatalogueModel } from "../catalogue/catalogue.js";
2
+ import type { State } from "../state/db.js";
3
+ export interface ProxyUnit {
4
+ runId: string;
5
+ unitId: string;
6
+ endpointId: string;
7
+ baseUrl: string;
8
+ apiKey: string;
9
+ /** The candidate (or baseline) the unit evaluates, as the endpoint names it. */
10
+ model: string;
11
+ /** Endpoint model ID → catalogue model, for every model the pool allows on this endpoint. */
12
+ allowed: (endpointModel: string) => CatalogueModel | null | undefined;
13
+ }
14
+ export interface UnitTally {
15
+ requests: number;
16
+ refused: number;
17
+ budgetHit: boolean;
18
+ poolViolations: string[];
19
+ upstreamErrors: number[];
20
+ }
21
+ /** Assumed output tokens when a request sets no maximum; reasoning models can use many. */
22
+ export declare const DEFAULT_OUTPUT_TOKENS = 4096;
23
+ /** Reservation for a request whose price is unknown. */
24
+ export declare const UNKNOWN_PRICE_RESERVATION_USD = 0.05;
25
+ export declare function expectedCost(model: CatalogueModel | null | undefined, body: Record<string, unknown>, bodyBytes: number): {
26
+ usd: number;
27
+ priced: boolean;
28
+ };
29
+ /** Token counts and provider cost from an OpenAI-style (or OpenRouter) response body or SSE stream. */
30
+ export declare function readUsage(text: string): {
31
+ input: number;
32
+ output: number;
33
+ costUsd: number | null;
34
+ } | null;
35
+ /** How often a failed model call is sent again before the eval sees the failure. */
36
+ export declare const MODEL_CALL_RETRIES = 3;
37
+ /** Failures that may pass: timeouts, rate limits and server errors. Rejections such as 400 or 404 get the same answer again. */
38
+ export declare const retryable: (status: number) => boolean;
39
+ export declare class EvalProxy {
40
+ private server;
41
+ private readonly units;
42
+ private readonly state;
43
+ private readonly fetcher;
44
+ constructor(state: State, fetcher?: typeof fetch);
45
+ start(): Promise<string>;
46
+ stop(): Promise<void>;
47
+ /** A token for one eval unit; requests with it are metered against the unit's run. */
48
+ open(unit: ProxyUnit): {
49
+ token: string;
50
+ tally: UnitTally;
51
+ };
52
+ close(token: string): void;
53
+ private handle;
54
+ }
@@ -0,0 +1,174 @@
1
+ // The eval metering proxy: an OpenAI-compatible HTTP endpoint on 127.0.0.1 that exists only while
2
+ // Scout runs approved evals. Each eval unit gets its own bearer token. For every request it:
3
+ // 1. refuses models outside the repository's allowed pool for that endpoint;
4
+ // 2. reserves the request's expected cost (prompt size + max output at list price) in SQLite and
5
+ // refuses it, before anything is sent, when that would pass the run's budget;
6
+ // 3. forwards it with the endpoint's key, then settles the reservation with the provider-reported
7
+ // cost (OpenRouter) or a list-price estimate from the reported tokens (OpenAI-style endpoints).
8
+ import { randomBytes } from "node:crypto";
9
+ import { createServer } from "node:http";
10
+ import { setTimeout as sleep } from "node:timers/promises";
11
+ import { requestPrice } from "../catalogue/catalogue.js";
12
+ /** Assumed output tokens when a request sets no maximum; reasoning models can use many. */
13
+ export const DEFAULT_OUTPUT_TOKENS = 4096;
14
+ /** Reservation for a request whose price is unknown. */
15
+ export const UNKNOWN_PRICE_RESERVATION_USD = 0.05;
16
+ export function expectedCost(model, body, bodyBytes) {
17
+ const output = [body.max_completion_tokens, body.max_tokens, body.max_output_tokens].find(value => typeof value === "number" && value > 0);
18
+ const price = requestPrice(model ?? null, Math.ceil(bodyBytes / 3), output ?? DEFAULT_OUTPUT_TOKENS);
19
+ return price === null ? { usd: UNKNOWN_PRICE_RESERVATION_USD, priced: false } : { usd: price, priced: true };
20
+ }
21
+ /** Token counts and provider cost from an OpenAI-style (or OpenRouter) response body or SSE stream. */
22
+ export function readUsage(text) {
23
+ const candidates = text.trimStart().startsWith("{") ? [text] : text.split("\n").filter(line => line.startsWith("data:")).map(line => line.slice(5).trim()).filter(line => line.startsWith("{"));
24
+ let found = null;
25
+ for (const chunk of candidates) {
26
+ try {
27
+ const value = JSON.parse(chunk);
28
+ const usage = value.usage ?? value.response?.usage;
29
+ if (!usage)
30
+ continue;
31
+ const input = Number(usage.prompt_tokens ?? usage.input_tokens ?? 0), output = Number(usage.completion_tokens ?? usage.output_tokens ?? 0);
32
+ found = { input, output, costUsd: typeof usage.cost === "number" ? usage.cost : null };
33
+ }
34
+ catch { /* not JSON */ }
35
+ }
36
+ return found;
37
+ }
38
+ const send = (res, status, message, type) => {
39
+ res.writeHead(status, { "content-type": "application/json" });
40
+ res.end(JSON.stringify({ error: { message, type, code: status } }));
41
+ };
42
+ async function body(req) {
43
+ const chunks = [];
44
+ let size = 0;
45
+ for await (const chunk of req) {
46
+ size += chunk.length;
47
+ if (size > 50_000_000)
48
+ throw new Error("request too large");
49
+ chunks.push(chunk);
50
+ }
51
+ return Buffer.concat(chunks);
52
+ }
53
+ /** How often a failed model call is sent again before the eval sees the failure. */
54
+ export const MODEL_CALL_RETRIES = 3;
55
+ /** Failures that may pass: timeouts, rate limits and server errors. Rejections such as 400 or 404 get the same answer again. */
56
+ export const retryable = (status) => status === 408 || status === 429 || status >= 500;
57
+ /** Retry-After when the endpoint sends one (at most 30 s), else 2, 4 and 8 s. */
58
+ const retryDelayMs = (retryAfter, attempt) => {
59
+ const seconds = Number(retryAfter);
60
+ return retryAfter !== null && Number.isFinite(seconds) && seconds >= 0 ? Math.min(seconds, 30) * 1000 : 2000 * 2 ** attempt;
61
+ };
62
+ export class EvalProxy {
63
+ server = null;
64
+ units = new Map();
65
+ state;
66
+ fetcher;
67
+ constructor(state, fetcher = fetch) { this.state = state; this.fetcher = fetcher; }
68
+ async start() {
69
+ this.server = createServer((req, res) => { this.handle(req, res).catch(error => { if (!res.headersSent)
70
+ send(res, 500, `OpenMerit proxy: ${error instanceof Error ? error.message : String(error)}`, "openmerit_proxy");
71
+ else
72
+ res.end(); }); });
73
+ await new Promise(resolve => this.server.listen(0, "127.0.0.1", resolve));
74
+ return `http://127.0.0.1:${this.server.address().port}/v1`;
75
+ }
76
+ async stop() {
77
+ const server = this.server;
78
+ this.server = null;
79
+ if (!server)
80
+ return;
81
+ const closed = new Promise(resolve => server.close(() => resolve()));
82
+ server.closeAllConnections();
83
+ await closed;
84
+ }
85
+ /** A token for one eval unit; requests with it are metered against the unit's run. */
86
+ open(unit) {
87
+ const token = `om-eval-${randomBytes(18).toString("hex")}`;
88
+ const tally = { requests: 0, refused: 0, budgetHit: false, poolViolations: [], upstreamErrors: [] };
89
+ this.units.set(token, { unit, tally });
90
+ return { token, tally };
91
+ }
92
+ close(token) { this.units.delete(token); }
93
+ async handle(req, res) {
94
+ const token = /^Bearer\s+(\S+)/i.exec(req.headers.authorization ?? "")?.[1] ?? req.headers["x-api-key"];
95
+ const entry = token ? this.units.get(token) : undefined;
96
+ if (!entry)
97
+ return send(res, 401, "OpenMerit proxy: unknown or closed eval token.", "openmerit_auth");
98
+ const { unit, tally } = entry;
99
+ const path = (req.url ?? "/").replace(/^\/v1/, "");
100
+ if (req.method === "GET" && path.startsWith("/models")) {
101
+ res.writeHead(200, { "content-type": "application/json" });
102
+ return res.end(JSON.stringify({ object: "list", data: [{ id: unit.model, object: "model" }] }));
103
+ }
104
+ if (req.method !== "POST")
105
+ return send(res, 405, "OpenMerit proxy: only POST model requests are forwarded.", "openmerit_method");
106
+ const raw = await body(req);
107
+ let parsed;
108
+ try {
109
+ parsed = JSON.parse(raw.toString("utf8"));
110
+ }
111
+ catch {
112
+ return send(res, 400, "OpenMerit proxy: the request body is not JSON.", "openmerit_request");
113
+ }
114
+ const model = typeof parsed.model === "string" ? parsed.model : unit.model;
115
+ const catalogue = unit.allowed(model);
116
+ if (catalogue === undefined) {
117
+ tally.poolViolations.push(model);
118
+ return send(res, 403, `OpenMerit proxy: ${model} is not in this repository's allowed models for endpoint ${unit.endpointId}.`, "openmerit_pool");
119
+ }
120
+ if (parsed.stream === true)
121
+ parsed.stream_options = { ...parsed.stream_options, include_usage: true };
122
+ if (unit.baseUrl.includes("openrouter.ai"))
123
+ parsed.usage = { include: true };
124
+ const forwarded = Buffer.from(JSON.stringify(parsed));
125
+ const expected = expectedCost(catalogue, parsed, forwarded.length);
126
+ const reservation = this.state.reserve({ runId: unit.runId, unitId: unit.unitId, model, endpoint: unit.endpointId, expectedUsd: expected.usd });
127
+ if (!reservation.ok) {
128
+ tally.refused++;
129
+ tally.budgetHit = true;
130
+ return send(res, 402, `OpenMerit eval budget reached: $${reservation.spend.totalUsd.toFixed(4)} used or reserved of $${reservation.budgetUsd.toFixed(2)}; this request needs about $${expected.usd.toFixed(4)}. Paid evaluation is paused until the budget is raised.`, "openmerit_budget");
131
+ }
132
+ tally.requests++;
133
+ const started = Date.now();
134
+ const forward = () => this.fetcher(`${unit.baseUrl.replace(/\/$/, "")}${path}`, {
135
+ method: "POST", body: forwarded, redirect: "manual", signal: AbortSignal.timeout(600_000),
136
+ headers: { authorization: `Bearer ${unit.apiKey}`, "content-type": "application/json", accept: req.headers.accept ?? "application/json", ...(unit.baseUrl.includes("openrouter.ai") ? { "x-title": "OpenMerit Scout eval" } : {}) },
137
+ }).catch((error) => (error instanceof Error ? error : new Error(String(error))));
138
+ // A failed call that may pass is sent again, so the eval only sees failures that last; failed calls are not charged.
139
+ let upstream = await forward();
140
+ for (let attempt = 0; attempt < MODEL_CALL_RETRIES && (upstream instanceof Error || retryable(upstream.status)); attempt++) {
141
+ if (!(upstream instanceof Error))
142
+ await upstream.body?.cancel();
143
+ await sleep(retryDelayMs(upstream instanceof Error ? null : upstream.headers.get("retry-after"), attempt));
144
+ upstream = await forward();
145
+ }
146
+ if (upstream instanceof Error) {
147
+ tally.upstreamErrors.push(502);
148
+ this.state.settle(reservation.id, { actualUsd: 0, estimatedUsd: null, latencyMs: Date.now() - started, failed: true });
149
+ return send(res, 502, `OpenMerit proxy: ${unit.endpointId} did not answer (${upstream.message}).`, "openmerit_upstream");
150
+ }
151
+ res.writeHead(upstream.status, { "content-type": upstream.headers.get("content-type") ?? "application/json" });
152
+ let text = "";
153
+ const decoder = new TextDecoder();
154
+ const reader = upstream.body?.getReader();
155
+ for (;;) {
156
+ if (!reader)
157
+ break;
158
+ const { done, value } = await reader.read();
159
+ if (done)
160
+ break;
161
+ res.write(value);
162
+ text += decoder.decode(value, { stream: true });
163
+ }
164
+ res.end();
165
+ const latencyMs = Date.now() - started;
166
+ const usage = readUsage(text);
167
+ if (!upstream.ok)
168
+ tally.upstreamErrors.push(upstream.status);
169
+ if (!upstream.ok)
170
+ this.state.settle(reservation.id, { actualUsd: usage?.costUsd ?? 0, estimatedUsd: null, latencyMs, failed: true });
171
+ else
172
+ this.state.settle(reservation.id, { actualUsd: usage?.costUsd ?? null, estimatedUsd: usage ? requestPrice(catalogue ?? null, usage.input, usage.output) : null, latencyMs });
173
+ }
174
+ }
@@ -0,0 +1,29 @@
1
+ export interface EvalCommand {
2
+ command: string;
3
+ cwd?: string;
4
+ env?: Record<string, string>;
5
+ timeoutMs?: number;
6
+ }
7
+ export interface EvalOutcome {
8
+ ok: boolean;
9
+ exitCode: number;
10
+ durationMs: number;
11
+ /** Per-item results the eval reported itself, or null when it reported none. */
12
+ items: {
13
+ id: string;
14
+ pass: boolean;
15
+ }[] | null;
16
+ /** Last lines of output, redacted, kept only when the eval failed. */
17
+ log?: string;
18
+ error?: string;
19
+ }
20
+ export declare function runEval(spec: EvalCommand, input: {
21
+ root: string;
22
+ model: string;
23
+ baseUrl: string;
24
+ token: string;
25
+ items: readonly string[] | null;
26
+ experiment: string;
27
+ env?: Record<string, string | undefined>;
28
+ signal?: AbortSignal;
29
+ }): Promise<EvalOutcome>;
@@ -0,0 +1,80 @@
1
+ // Runs one eval unit: the task's existing eval command for one model and item set, routed through
2
+ // the metering proxy. The eval contract (README "Eval contract"):
3
+ // - OpenMerit sets OPENMERIT_MODEL (the model as the endpoint names it), OPENMERIT_BASE_URL and
4
+ // OPENMERIT_API_KEY (the proxy), the same values as OPENAI_BASE_URL and OPENAI_API_KEY,
5
+ // OPENMERIT_ITEMS (comma-separated dataset item IDs; empty for every item), OPENMERIT_EXPERIMENT
6
+ // (the experiment name to record under) and OPENMERIT_RESULT (a file path). `{{model}}`,
7
+ // `{{items}}` and `{{experiment}}` in the command or in `env` values become the same values.
8
+ // - Per-item results come from the Langfuse experiment the eval records (read back by the caller),
9
+ // or from {"items": [{"id", "pass"}]} written to $OPENMERIT_RESULT or printed as stdout's last JSON line.
10
+ import { spawn } from "node:child_process";
11
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join, resolve } from "node:path";
14
+ import { redact } from "../scout/scan.js";
15
+ function lastJson(stdout) {
16
+ for (const line of stdout.trim().split("\n").reverse()) {
17
+ const text = line.trim();
18
+ if (!text.startsWith("{"))
19
+ continue;
20
+ try {
21
+ const value = JSON.parse(text);
22
+ if (value && typeof value === "object" && !Array.isArray(value))
23
+ return value;
24
+ }
25
+ catch { /* keep looking */ }
26
+ }
27
+ return null;
28
+ }
29
+ function reportedItems(result) {
30
+ if (!result || !Array.isArray(result.items))
31
+ return null;
32
+ const items = result.items.flatMap(raw => {
33
+ const item = raw;
34
+ const pass = typeof item.pass === "boolean" ? item.pass : item.passed;
35
+ return (typeof item.id === "string" || typeof item.id === "number") && typeof pass === "boolean" ? [{ id: String(item.id), pass }] : [];
36
+ });
37
+ return items.length ? items : null;
38
+ }
39
+ export async function runEval(spec, input) {
40
+ const dir = mkdtempSync(join(tmpdir(), "openmerit-eval-"));
41
+ const resultPath = join(dir, "result.json");
42
+ const items = (input.items ?? []).join(",");
43
+ const fill = (text) => text.replaceAll("{{model}}", input.model).replaceAll("{{items}}", items).replaceAll("{{experiment}}", input.experiment);
44
+ const base = input.env ?? process.env;
45
+ // Model calls go through the proxy with its one-unit token, never with the endpoint's own key.
46
+ // Other credentials (the app's Langfuse keys, for example) stay, so the eval records as it always does.
47
+ const env = Object.fromEntries(Object.entries(base).filter(([key]) => !/^(OPENAI|OPENROUTER)_API_KEY$/.test(key)));
48
+ Object.assign(env, {
49
+ OPENMERIT_MODEL: input.model, OPENMERIT_BASE_URL: input.baseUrl, OPENMERIT_API_KEY: input.token, OPENMERIT_RESULT: resultPath,
50
+ OPENMERIT_ITEMS: items, OPENMERIT_EXPERIMENT: input.experiment, OPENAI_BASE_URL: input.baseUrl, OPENAI_API_KEY: input.token,
51
+ }, Object.fromEntries(Object.entries(spec.env ?? {}).map(([key, value]) => [key, fill(value)])));
52
+ const started = Date.now();
53
+ try {
54
+ const { code, stdout, stderr } = await new Promise((done, fail) => {
55
+ const child = spawn("sh", ["-c", fill(spec.command)], { cwd: resolve(input.root, spec.cwd ?? "."), env, stdio: ["ignore", "pipe", "pipe"], ...(input.signal ? { signal: input.signal } : {}) });
56
+ let out = "", err = "";
57
+ const timer = setTimeout(() => child.kill("SIGTERM"), spec.timeoutMs ?? 1_800_000);
58
+ child.stdout.on("data", chunk => { out += chunk; if (out.length > 2_000_000)
59
+ out = out.slice(-1_000_000); });
60
+ child.stderr.on("data", chunk => { err += chunk; if (err.length > 200_000)
61
+ err = err.slice(-100_000); });
62
+ child.on("error", fail);
63
+ child.on("close", status => { clearTimeout(timer); done({ code: status ?? 1, stdout: out, stderr: err }); });
64
+ });
65
+ let result = null;
66
+ try {
67
+ result = JSON.parse(readFileSync(resultPath, "utf8"));
68
+ }
69
+ catch {
70
+ result = lastJson(stdout);
71
+ }
72
+ const outcome = { exitCode: code, durationMs: Date.now() - started, items: reportedItems(result) };
73
+ if (code !== 0)
74
+ return { ok: false, ...outcome, log: redact(`${stderr}\n${stdout}`.trim().split("\n").slice(-12).join("\n")).slice(-2000), error: `eval exited with ${code}` };
75
+ return { ok: true, ...outcome };
76
+ }
77
+ finally {
78
+ rmSync(dir, { recursive: true, force: true });
79
+ }
80
+ }
@@ -0,0 +1,21 @@
1
+ import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
2
+ type ProviderConfigInput = Parameters<ModelRuntime["registerProvider"]>[1];
3
+ type Handler = (event: unknown, ctx: unknown) => unknown;
4
+ export interface ClaudeBridge {
5
+ provider: ProviderConfigInput;
6
+ handlers: Map<string, Handler[]>;
7
+ }
8
+ export declare const CLAUDE_BRIDGE_PROVIDER = "claude-bridge";
9
+ export declare const DEFAULT_CLAUDE_MODEL = "claude-sonnet-5-5";
10
+ /** Loads the bridge once per process. Its config and log live in pi's agent directory (PI_CODING_AGENT_DIR). */
11
+ export declare function loadClaudeBridge(): Promise<ClaudeBridge>;
12
+ /** Runs `request` as one bridge session in `cwd`, so Claude Code starts there and accepts `systemPrompt`. */
13
+ export declare function inBridgeSession<T>(bridge: ClaudeBridge, input: {
14
+ sessionId: string;
15
+ cwd: string;
16
+ systemPrompt: string;
17
+ tools: string[];
18
+ }, request: () => Promise<T>): Promise<T>;
19
+ /** The Claude Code executable for login checks: the one the Claude Agent SDK bundles, which the bridge runs. */
20
+ export declare function claudeBinary(env?: Record<string, string | undefined>): string;
21
+ export {};
@@ -0,0 +1,65 @@
1
+ // Claude Code through pi-claude-bridge (https://pi.dev/packages/pi-claude-bridge): a pi extension that
2
+ // drives Claude Code with the Claude Agent SDK and the machine's Claude Code login. OpenMerit runs it
3
+ // outside a pi coding-agent session, so this module gives it the two extension calls it uses
4
+ // (registerProvider, on) and fires the session events the bridge relies on around each request:
5
+ // session_start names the request's working directory, before_agent_start records its system prompt
6
+ // (the bridge refuses a prompt it has not seen), and session_shutdown closes its Claude Code query.
7
+ import { existsSync } from "node:fs";
8
+ import { createRequire } from "node:module";
9
+ import { dirname, join } from "node:path";
10
+ import { createJiti } from "jiti";
11
+ export const CLAUDE_BRIDGE_PROVIDER = "claude-bridge";
12
+ export const DEFAULT_CLAUDE_MODEL = "claude-sonnet-5-5";
13
+ let loaded;
14
+ /** Loads the bridge once per process. Its config and log live in pi's agent directory (PI_CODING_AGENT_DIR). */
15
+ export function loadClaudeBridge() {
16
+ return loaded ??= (async () => {
17
+ const handlers = new Map();
18
+ let provider;
19
+ const shim = {
20
+ registerProvider: (id, config) => { if (id === CLAUDE_BRIDGE_PROVIDER)
21
+ provider = config; },
22
+ on: (event, handler) => { handlers.set(event, [...(handlers.get(event) ?? []), handler]); },
23
+ };
24
+ // The bridge ships TypeScript, which Node does not strip under node_modules; pi loads extensions with jiti too.
25
+ const module = await createJiti(import.meta.url).import("pi-claude-bridge/src/index.ts");
26
+ module.default(shim);
27
+ if (!provider)
28
+ throw new Error("pi-claude-bridge did not register its provider");
29
+ return { provider, handlers };
30
+ })().catch(error => { loaded = undefined; throw error; });
31
+ }
32
+ /** Runs `request` as one bridge session in `cwd`, so Claude Code starts there and accepts `systemPrompt`. */
33
+ export async function inBridgeSession(bridge, input, request) {
34
+ const ctx = { cwd: input.cwd, ui: undefined, sessionManager: { getSessionId: () => input.sessionId }, modelRegistry: { getProvider: () => bridge.provider }, getSystemPrompt: () => input.systemPrompt };
35
+ const fire = async (name, event) => { for (const handler of bridge.handlers.get(name) ?? [])
36
+ await handler(event, ctx); };
37
+ await fire("session_start", { type: "session_start", reason: "startup" });
38
+ try {
39
+ await fire("before_agent_start", { type: "before_agent_start", systemPrompt: input.systemPrompt, systemPromptOptions: { customPrompt: input.systemPrompt, contextFiles: [], skills: [], selectedTools: input.tools } });
40
+ return await request();
41
+ }
42
+ finally {
43
+ await fire("session_shutdown", { type: "session_shutdown" });
44
+ }
45
+ }
46
+ /** The Claude Code executable for login checks: the one the Claude Agent SDK bundles, which the bridge runs. */
47
+ export function claudeBinary(env = process.env) {
48
+ if (env.OPENMERIT_CLAUDE_BIN)
49
+ return env.OPENMERIT_CLAUDE_BIN;
50
+ try {
51
+ const fromBridge = createRequire(createRequire(import.meta.url).resolve("pi-claude-bridge/package.json"));
52
+ // The SDK's exports do not include its package.json, so resolve its entry point.
53
+ const fromSdk = createRequire(fromBridge.resolve("@anthropic-ai/claude-agent-sdk"));
54
+ for (const variant of ["", "-musl"]) {
55
+ try {
56
+ const path = join(dirname(fromSdk.resolve(`@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}${variant}/package.json`)), process.platform === "win32" ? "claude.exe" : "claude");
57
+ if (existsSync(path))
58
+ return path;
59
+ }
60
+ catch { /* not installed for this libc */ }
61
+ }
62
+ }
63
+ catch { /* fall back to PATH */ }
64
+ return "claude";
65
+ }
@@ -0,0 +1,60 @@
1
+ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
2
+ import { type HarnessSettings } from "../state/config.js";
3
+ type Env = Record<string, string | undefined>;
4
+ /** Pi's provider for ChatGPT Plus/Pro sign-ins, which serves Codex models. */
5
+ export declare const CODEX_PROVIDER = "openai-codex";
6
+ /** Used only when saved settings name no model; `openmerit connect codex` saves the best one the plan accepts. */
7
+ export declare const DEFAULT_CODEX_MODEL = "gpt-5.6-terra";
8
+ export interface HarnessUsage {
9
+ calls: number;
10
+ costUsd: number;
11
+ unknownCost: number;
12
+ inputTokens: number;
13
+ outputTokens: number;
14
+ }
15
+ export interface JsonRequest {
16
+ system: string;
17
+ prompt: string;
18
+ /** JSON Schema of the reply; the reply is parsed and must be an object. */
19
+ schema: Record<string, unknown>;
20
+ /** Let the harness read (never write) files under `cwd` with pi's read-only tools. */
21
+ cwd?: string;
22
+ signal?: AbortSignal;
23
+ }
24
+ export interface Harness {
25
+ readonly kind: HarnessSettings["kind"];
26
+ /** The settings it runs on, as history names them. */
27
+ readonly label: string;
28
+ readonly usage: HarnessUsage;
29
+ json<T>(request: JsonRequest): Promise<T>;
30
+ }
31
+ /** Pi's model runtime on OpenMerit's agent directory, ~/.openmerit/agent: auth.json (the ChatGPT sign-in), models.json and the bridge's config. */
32
+ export declare function harnessRuntime(env?: Env): Promise<ModelRuntime>;
33
+ /** The first JSON object in a reply, with code fences removed. With `required`, the object must have those keys. */
34
+ export declare function parseJsonObject(reply: string, required?: string[]): Record<string, unknown>;
35
+ /** Builds the harness from saved settings. Fails clearly, without falling back, when it cannot. */
36
+ export declare function createHarness(settings: HarnessSettings, env?: Env): Promise<Harness>;
37
+ /** One round trip that proves the configured connection powers the harness. */
38
+ export declare function verifyHarness(harness: Harness, cwd: string): Promise<{
39
+ ok: true;
40
+ detail: string;
41
+ } | {
42
+ ok: false;
43
+ detail: string;
44
+ }>;
45
+ /** Runs the Claude Code executable the bridge uses, such as `auth status` or `auth login`. */
46
+ export declare function runClaude(args: string[], options?: {
47
+ env?: Env;
48
+ interactive?: boolean;
49
+ signal?: AbortSignal;
50
+ }): Promise<{
51
+ code: number;
52
+ stdout: string;
53
+ }>;
54
+ /** Whether Claude Code reports a login (its own status command), and whether OpenMerit has a ChatGPT sign-in. */
55
+ export declare function detectAgents(env?: Env): Promise<{
56
+ kind: "claude-code" | "codex";
57
+ loggedIn: boolean;
58
+ detail: string;
59
+ }[]>;
60
+ export {};