@gleapai/kai-bridge 0.2.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,342 @@
1
+ // Harness registry for the ACP runner.
2
+ //
3
+ // One entry per coding harness that speaks the Agent Client Protocol.
4
+ // Each entry knows how to (1) spawn its ACP adapter, (2) build the env
5
+ // that routes the model (native key, OpenRouter skin, limits), (3) shape
6
+ // the `session/new` `_meta` that carries persona / permissions / resume,
7
+ // and (4) read its own transcript for billing-grade usage after a turn.
8
+ // Adding a harness = adding an entry here; the runner and the mapper
9
+ // never branch on harness id.
10
+
11
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ import { findClaudeTranscript, findCodexRollout, readClaudeTurnUsage, readCodexTurnUsage } from "./transcripts.mjs";
15
+
16
+ export const HARNESS_IDS = ["claude", "codex", "cursor"];
17
+
18
+ /** Strip the registry namespace down to the engine's own model slug. */
19
+ export function deriveEngineSlug(canonical) {
20
+ const id = String(canonical || "");
21
+ if (id.startsWith("anthropic/")) return id.slice("anthropic/".length);
22
+ if (id.startsWith("openai/")) return id.slice("openai/".length);
23
+ if (id.startsWith("openrouter/")) return id.slice("openrouter/".length);
24
+ if (id.startsWith("xai/")) return `x-ai/${id.slice("xai/".length)}`;
25
+ return id;
26
+ }
27
+
28
+ export function isNativeAnthropic(model) {
29
+ const id = String(model || "");
30
+ return id.startsWith("anthropic/") || !id.includes("/");
31
+ }
32
+
33
+ /**
34
+ * Pick the harness for a model when the host didn't say: `openai/*` →
35
+ * codex, everything else → claude (native Anthropic + the OpenRouter long
36
+ * tail, exactly today's `getCloudEngine` rule).
37
+ */
38
+ export function resolveHarnessId(explicit, model) {
39
+ if (explicit && HARNESS_IDS.includes(explicit)) return explicit;
40
+ const id = String(model || "");
41
+ if (id.startsWith("openai/")) return "codex";
42
+ if (id.startsWith("cursor/")) return "cursor";
43
+ return "claude";
44
+ }
45
+
46
+ /**
47
+ * Pick the ACP session mode: the first of `preferred` the agent actually
48
+ * advertises (`session/new` → `modes.availableModes`); null when the
49
+ * agent advertises no modes (then `session/set_mode` is skipped).
50
+ */
51
+ export function pickSessionMode(preferred, available) {
52
+ const ids = new Set((Array.isArray(available) ? available : []).map((m) => String(m?.id ?? m)));
53
+ if (ids.size === 0) return preferred[0] ?? null;
54
+ return preferred.find((id) => ids.has(id)) ?? null;
55
+ }
56
+
57
+ const OPENROUTER_BASE_URL = "https://openrouter.ai/api";
58
+
59
+ /**
60
+ * Resolve the adapter binary: explicit override (`KAI_ACP_AGENT_CMD`,
61
+ * JSON `{cmd, args}` — tests and the local bridge's per-profile
62
+ * binaries), else the npm bin baked next to the runner, else PATH.
63
+ */
64
+ function resolveAgentCommand(runnerDir, name, extraArgs = []) {
65
+ const override = process.env.KAI_ACP_AGENT_CMD;
66
+ if (override) {
67
+ try {
68
+ const parsed = JSON.parse(override);
69
+ if (parsed?.cmd) return { cmd: String(parsed.cmd), args: Array.isArray(parsed.args) ? parsed.args.map(String) : [] };
70
+ } catch {
71
+ return { cmd: override, args: [] };
72
+ }
73
+ }
74
+ // Baked next to the runner (/opt/gleap-runners/node_modules) or one
75
+ // level up (the bridge package's own node_modules); else PATH.
76
+ for (const dir of [join(runnerDir, "node_modules", ".bin"), join(runnerDir, "..", "node_modules", ".bin")]) {
77
+ const p = join(dir, name);
78
+ if (existsSync(p)) return { cmd: p, args: [...extraArgs] };
79
+ }
80
+ return { cmd: name, args: [...extraArgs] };
81
+ }
82
+
83
+ const tomlString = (v) => JSON.stringify(String(v ?? ""));
84
+ const sanitizeMcpKey = (raw) => String(raw || "").replace(/[^a-zA-Z0-9_-]/g, "_");
85
+
86
+ /**
87
+ * Codex: MCP servers + tool kill-switches live in `$CODEX_HOME/config.toml`,
88
+ * NOT in `session/new.mcpServers` — codex-acp replaces the `mcp_servers`
89
+ * config layer wholesale with the session list, and that list has no
90
+ * per-tool `disabled_tools`. The TOML mirrors the retired codex-runner.mjs
91
+ * (`default_tools_approval_mode`, startup/tool timeouts, engine-enforced
92
+ * `disabled_tools` for disabled + gated tools).
93
+ */
94
+ export function buildCodexConfigToml(mcpServers) {
95
+ const lines = [
96
+ "# generated by acp-runner — do not edit",
97
+ "show_raw_agent_reasoning = true",
98
+ "[features]",
99
+ "collaboration_modes = true",
100
+ ];
101
+ for (const server of mcpServers || []) {
102
+ if (!server || typeof server !== "object") continue;
103
+ const key = sanitizeMcpKey(server.name || server.id);
104
+ if (!key) continue;
105
+ lines.push("", `[mcp_servers.${key}]`);
106
+ if (server.transport === "http" && server.url) {
107
+ lines.push(`url = ${tomlString(server.url)}`);
108
+ const headers = server.headers || {};
109
+ if (Object.keys(headers).length > 0) {
110
+ lines.push(`http_headers = { ${Object.entries(headers).map(([k, v]) => `${tomlString(k)} = ${tomlString(v)}`).join(", ")} }`);
111
+ }
112
+ } else if (server.command) {
113
+ lines.push(`command = ${tomlString(server.command)}`);
114
+ lines.push(`args = [${(Array.isArray(server.args) ? server.args : []).map(tomlString).join(", ")}]`);
115
+ const env = server.env || {};
116
+ if (Object.keys(env).length > 0) {
117
+ lines.push(`env = { ${Object.entries(env).map(([k, v]) => `${tomlString(k)} = ${tomlString(v)}`).join(", ")} }`);
118
+ }
119
+ } else {
120
+ lines.pop();
121
+ lines.pop();
122
+ continue;
123
+ }
124
+ lines.push("startup_timeout_sec = 60", "tool_timeout_sec = 60", 'default_tools_approval_mode = "approve"');
125
+ const disabled = [
126
+ ...(Array.isArray(server.disabledTools) ? server.disabledTools : []),
127
+ ...(Array.isArray(server.gatedTools) ? server.gatedTools : []),
128
+ ];
129
+ if (disabled.length > 0) lines.push(`disabled_tools = [${disabled.map(tomlString).join(", ")}]`);
130
+ }
131
+ return lines.join("\n") + "\n";
132
+ }
133
+
134
+ export const HARNESSES = {
135
+ claude: {
136
+ id: "claude",
137
+ /** Which env var the adapter needs to be able to run at all. */
138
+ requiredEnv: (ctx) => (isNativeAnthropic(ctx.model) ? "ANTHROPIC_API_KEY" : "OPENROUTER_API_KEY"),
139
+ command: (ctx) => resolveAgentCommand(ctx.runnerDir, "claude-agent-acp"),
140
+ env: (ctx) => {
141
+ const env = { ...process.env };
142
+ const native = isNativeAnthropic(ctx.model);
143
+ const engineModel = ctx.engineModel || deriveEngineSlug(ctx.model);
144
+ // Isolated config dir per run: keeps the sandbox's ambient ~/.claude
145
+ // (settings, other sessions) out of the picture and gives the
146
+ // transcript a known location for usage collection. EXCEPT for an
147
+ // ambient BYO profile: macOS `claude` reads its OAuth from the
148
+ // keychain only while CLAUDE_CONFIG_DIR is unset, so the user's own
149
+ // login dir must be inherited, never exported (transcripts are still
150
+ // read from ctx.configDir — it IS the CLI's default dir then).
151
+ if (ctx.configDirAmbient) delete env.CLAUDE_CONFIG_DIR;
152
+ else env.CLAUDE_CONFIG_DIR = ctx.configDir;
153
+ env.ANTHROPIC_MODEL = engineModel;
154
+ if (native) {
155
+ delete env.ANTHROPIC_BASE_URL;
156
+ delete env.ANTHROPIC_AUTH_TOKEN;
157
+ // BYO login: the CLI's own OAuth in CLAUDE_CONFIG_DIR authenticates.
158
+ if (ctx.byoLogin) delete env.ANTHROPIC_API_KEY;
159
+ } else {
160
+ // OpenRouter's Anthropic-Messages skin. Same wiring as
161
+ // the retired claude-runner.mjs buildCliEnv (limits, background + subagent
162
+ // pins), minus the local capture proxy — see PARITY.md.
163
+ // Through the local wire proxy when the runner started one
164
+ // (provider pins / MiniMax / xAI shims / capture), else direct.
165
+ env.ANTHROPIC_BASE_URL = ctx.wireBaseUrl || OPENROUTER_BASE_URL;
166
+ env.ANTHROPIC_AUTH_TOKEN = env.OPENROUTER_API_KEY;
167
+ delete env.ANTHROPIC_API_KEY;
168
+ if (ctx.maxContextTokens) env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(ctx.maxContextTokens);
169
+ if (ctx.maxOutputTokens) env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = String(ctx.maxOutputTokens);
170
+ env.API_TIMEOUT_MS = env.API_TIMEOUT_MS || "1200000";
171
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL = ctx.backgroundModel
172
+ ? deriveEngineSlug(ctx.backgroundModel)
173
+ : engineModel;
174
+ }
175
+ const subagentWireMatches =
176
+ ctx.subagentModel && isNativeAnthropic(ctx.subagentModel) === native;
177
+ if (subagentWireMatches) env.CLAUDE_CODE_SUBAGENT_MODEL = deriveEngineSlug(ctx.subagentModel);
178
+ else if (!native) env.CLAUDE_CODE_SUBAGENT_MODEL = engineModel;
179
+ // The adapter never opens a browser in a sandbox; IS_SANDBOX lets it
180
+ // offer bypassPermissions even when the process happens to be root.
181
+ env.NO_BROWSER = "1";
182
+ env.IS_SANDBOX = env.IS_SANDBOX || "1";
183
+ return env;
184
+ },
185
+ /** Which MCP servers ride `session/new` (all of them for Claude). */
186
+ sessionMcpServers: (ctx, acpServers) => acpServers,
187
+ /** `session/new` `_meta` — persona + SDK options. */
188
+ sessionMeta: (ctx) => ({
189
+ systemPrompt: {
190
+ append: ctx.appendSystemPrompt,
191
+ // Drops the CLI's per-run dynamic sections (cwd listing, date …)
192
+ // so the cached prefix stays byte-stable across turns.
193
+ excludeDynamicSections: true,
194
+ },
195
+ claudeCode: {
196
+ options: {
197
+ model: ctx.engineModel || deriveEngineSlug(ctx.model),
198
+ // BYO inherits the user's OWN MCP world by design (their
199
+ // user-scope servers + claude.ai connectors, alongside the
200
+ // project's injected ones): it's their machine and only they
201
+ // can dispatch sessions to it — product call, Lukas 2026-08-25.
202
+ // Cloud sandboxes have no user scope, so cloud stays clean.
203
+ ...(ctx.effort ? { effort: ctx.effort } : {}),
204
+ ...(ctx.resumeSessionId ? { resume: ctx.resumeSessionId } : {}),
205
+ // plan → CLI plan gating; artifact writers → dontAsk + the
206
+ // `.kai/`-scoped allow rules (any other write auto-denied);
207
+ // build → bypass (the worktree is the boundary).
208
+ permissionMode: ctx.isPlanMode ? "plan" : ctx.isArtifactWriter ? "dontAsk" : "bypassPermissions",
209
+ ...(ctx.allowedTools?.length ? { allowedTools: ctx.allowedTools } : {}),
210
+ ...(ctx.disallowedTools?.length ? { disallowedTools: ctx.disallowedTools } : {}),
211
+ ...(ctx.maxSteps ? { maxTurns: ctx.maxSteps } : {}),
212
+ ...(ctx.maxBudgetUsd ? { maxBudgetUsd: ctx.maxBudgetUsd } : {}),
213
+ ...(ctx.additionalDirectories?.length ? { additionalDirectories: ctx.additionalDirectories } : {}),
214
+ settings: {
215
+ outputStyle: "Concise",
216
+ ...(ctx.plansDir ? { plansDirectory: ctx.plansDir } : {}),
217
+ ...(ctx.settings || {}),
218
+ },
219
+ ...(ctx.agents ? { agents: ctx.agents } : {}),
220
+ },
221
+ },
222
+ }),
223
+ /** ACP session modes to try, in order (`session/set_mode`) — the adapter's own ids. */
224
+ sessionModePreference: (ctx) => (ctx.isPlanMode ? ["plan"] : ctx.isArtifactWriter ? ["dontAsk", "plan"] : ["bypassPermissions", "acceptEdits", "default"]),
225
+ /** A prior turn's transcript on disk is what makes `resume` viable. */
226
+ hasResumableSession: (ctx) => !!findClaudeTranscript(ctx.configDir, ctx.workDir, ctx.resumeSessionId),
227
+ /** Billing-grade usage for the turn from the CLI's own transcript. */
228
+ collectTurnUsage: (ctx) =>
229
+ readClaudeTurnUsage({
230
+ configDir: ctx.configDir,
231
+ cwd: ctx.workDir,
232
+ sessionId: ctx.sessionId,
233
+ sinceTs: ctx.turnStartedAt,
234
+ }),
235
+ /** Canonical registry id for a transcript model string. */
236
+ canonicalModel: (ctx, raw) => {
237
+ const r = String(raw || "");
238
+ if (!r) return ctx.model;
239
+ const engine = ctx.engineModel || deriveEngineSlug(ctx.model);
240
+ // Transcripts echo the API's own id — the slug we sent, or its
241
+ // dated form (`claude-haiku-4-5-20251001`). Both are the model the
242
+ // host priced under `ctx.model`; anything else (a subagent on a
243
+ // different model) keeps its own canonical id.
244
+ if (r === engine || r.startsWith(`${engine}-`)) return ctx.model;
245
+ if (isNativeAnthropic(ctx.model)) return r.startsWith("anthropic/") ? r : `anthropic/${r}`;
246
+ return r;
247
+ },
248
+ },
249
+
250
+ codex: {
251
+ id: "codex",
252
+ requiredEnv: () => "OPENAI_API_KEY",
253
+ command: (ctx) => resolveAgentCommand(ctx.runnerDir, "codex-acp"),
254
+ env: (ctx) => {
255
+ const env = { ...process.env };
256
+ env.CODEX_HOME = ctx.configDir;
257
+ // Codex reads the key from auth.json, not the env (verified in
258
+ // the retired codex-runner.mjs ensureCodexHome); write the same file the
259
+ // `codex login --with-api-key` path produces. A BYO ChatGPT
260
+ // auth.json dropped in here is the bridge's subscription path.
261
+ mkdirSync(ctx.configDir, { recursive: true });
262
+ const authPath = join(ctx.configDir, "auth.json");
263
+ if (!ctx.byoLogin && !existsSync(authPath) && env.OPENAI_API_KEY) {
264
+ writeFileSync(authPath, JSON.stringify({ OPENAI_API_KEY: env.OPENAI_API_KEY }), { mode: 0o600 });
265
+ }
266
+ if (!ctx.byoLogin) env.CODEX_API_KEY = env.OPENAI_API_KEY;
267
+ // MCP servers + disabled/gated tools via config.toml (see
268
+ // buildCodexConfigToml) — never via session/new for codex.
269
+ writeFileSync(join(ctx.configDir, "config.toml"), buildCodexConfigToml(ctx.mcpServers));
270
+ env.NO_BROWSER = "1";
271
+ env.INITIAL_AGENT_MODE = ctx.isPlanMode ? "read-only" : "agent-full-access";
272
+ // Session config merged by the adapter: model + effort + our
273
+ // developer instructions (persona) via the instructions file.
274
+ const config = {
275
+ model: ctx.engineModel || deriveEngineSlug(ctx.model),
276
+ ...(ctx.effort ? { model_reasoning_effort: ctx.effort } : {}),
277
+ ...(ctx.instructionsPath ? { model_instructions_file: ctx.instructionsPath } : {}),
278
+ ...(ctx.maxContextTokens ? { model_context_window: ctx.maxContextTokens } : {}),
279
+ };
280
+ env.CODEX_CONFIG = JSON.stringify(config);
281
+ return env;
282
+ },
283
+ sessionMcpServers: () => [],
284
+ sessionMeta: () => ({}),
285
+ sessionModePreference: (ctx) => (ctx.isPlanMode || ctx.isArtifactWriter ? ["read-only"] : ["agent-full-access", "agent"]),
286
+ hasResumableSession: (ctx) => !!findCodexRollout(ctx.configDir, ctx.resumeSessionId),
287
+ collectTurnUsage: (ctx) =>
288
+ readCodexTurnUsage({
289
+ codexHome: ctx.configDir,
290
+ threadId: ctx.sessionId,
291
+ sinceTs: ctx.turnStartedAt,
292
+ model: ctx.engineModel || deriveEngineSlug(ctx.model),
293
+ }),
294
+ canonicalModel: (ctx, raw) => {
295
+ const r = String(raw || "");
296
+ const engine = ctx.engineModel || deriveEngineSlug(ctx.model);
297
+ if (!r || r === engine) return ctx.model;
298
+ return r.startsWith("openai/") ? r : `openai/${r}`;
299
+ },
300
+ },
301
+ };
302
+
303
+ HARNESSES.cursor = {
304
+ id: "cursor",
305
+ /**
306
+ * Cursor Agent CLI (`agent acp`, https://cursor.com/docs/cli). LOCAL ONLY:
307
+ * it runs on the user's Cursor login (or CURSOR_API_KEY); Gleap has no
308
+ * Cursor credentials, so the cloud never selects it. ACP v1 with
309
+ * loadSession + MCP over http/sse on session/new (probed 2026-08-23,
310
+ * build 2026.08.11). Binary is `cursor-agent` (legacy alias `agent`,
311
+ * which collides with other vendors' CLIs — resolve by explicit path).
312
+ */
313
+ requiredEnv: (ctx) => (ctx.byoLogin ? null : "CURSOR_API_KEY"),
314
+ command: (ctx) => resolveAgentCommand(ctx.runnerDir, "cursor-agent", ["acp"]),
315
+ env: () => {
316
+ const env = { ...process.env };
317
+ env.NO_OPEN_BROWSER = "1";
318
+ return env;
319
+ },
320
+ sessionMcpServers: (ctx, acpServers) => acpServers,
321
+ sessionMeta: () => ({}),
322
+ // Mode ids come from the session/new response; first match wins.
323
+ sessionModePreference: (ctx) => (ctx.isPlanMode || ctx.isArtifactWriter ? ["plan", "ask", "read-only"] : ["agent", "default"]),
324
+ /**
325
+ * No system-prompt channel over ACP and no per-profile config dir:
326
+ * the persona travels as a prefix of the first prompt instead.
327
+ */
328
+ promptPrefix: (ctx) => (ctx.resumeSessionId ? "" : ctx.appendSystemPrompt || ""),
329
+ /** No transcript with a per-request split — context comes from usage_update; BYO bills nothing. */
330
+ collectTurnUsage: () => ({ path: null, rows: [], contextWindow: null }),
331
+ canonicalModel: (ctx, raw) => {
332
+ const r = String(raw || "");
333
+ if (!r) return ctx.model;
334
+ return r.startsWith("cursor/") ? r : `cursor/${r}`;
335
+ },
336
+ };
337
+
338
+ export function getHarness(id) {
339
+ const h = HARNESSES[id];
340
+ if (!h) throw new Error(`acp-runner: unknown harness "${id}" (known: ${HARNESS_IDS.join(", ")})`);
341
+ return h;
342
+ }