@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,671 @@
1
+ #!/usr/bin/env node
2
+ // Kai Code ACP runner — ONE driver for every harness that speaks the
3
+ // Agent Client Protocol (https://agentclientprotocol.com).
4
+ //
5
+ // Same contract argv as the engine-specific runners (parseRunnerArgs)
6
+ // plus `--harness claude|codex`; same JSONL event stream out. The
7
+ // harness adapter (claude-agent-acp, codex-acp, …) runs as a child over
8
+ // stdio; we are the ACP *client*: open a session in the workspace with
9
+ // the persona + MCP servers, send the prompt, translate `session/update`
10
+ // into contract events (lib/acp/mapper.mjs), answer permission requests
11
+ // (build = allow; question / plan hand-off = end the turn), and close
12
+ // with a `result` whose token split comes from the harness's own
13
+ // transcript (lib/acp/transcripts.mjs) — the only place the per-model
14
+ // cache buckets exist.
15
+ //
16
+ // This is the cloud runner AND the local bridge's executor: nothing in
17
+ // here assumes E2B beyond the default runner dir.
18
+
19
+ import { spawn } from "node:child_process";
20
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { dirname, join } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import { Readable, Writable } from "node:stream";
25
+
26
+ import { ClientSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
27
+
28
+ import {
29
+ KAI_RESOLUTION_ANALYST_AGENT_NAME,
30
+ captureRepoBaselines,
31
+ createUsageTracker,
32
+ debugLog,
33
+ emit,
34
+ emitSync,
35
+ isArtifactWriterAgent,
36
+ parseRunnerArgs,
37
+ revertRepoMutations,
38
+ setTracePrefix,
39
+ startHeartbeat,
40
+ traceLog,
41
+ } from "./lib/contract.mjs";
42
+ import { deriveEngineSlug, getHarness, isNativeAnthropic, pickSessionMode, resolveHarnessId } from "./lib/acp/harnesses.mjs";
43
+ import { createAcpMapper, permissionPolicy } from "./lib/acp/mapper.mjs";
44
+ import { aggregateUsageRows, lastRootContextSnapshot } from "./lib/acp/transcripts.mjs";
45
+ import { needsWireProxy, startWireProxy } from "./lib/wire-proxy.mjs";
46
+
47
+ setTracePrefix("acp");
48
+
49
+ const ARGS = parseRunnerArgs(process.argv.slice(2));
50
+ const {
51
+ agent: AGENT,
52
+ isPlanMode: IS_PLAN_MODE,
53
+ subagentModel: SUBAGENT_MODEL,
54
+ workDir: WORK_DIR,
55
+ maxSteps: MAX_STEPS,
56
+ effort: EFFORT,
57
+ sessionId: SESSION_ID,
58
+ maxBudgetUsd: MAX_BUDGET_USD,
59
+ task: TASK,
60
+ feedback: FEEDBACK,
61
+ questionAnswers: QUESTION_ANSWERS,
62
+ attachments: ATTACHMENTS,
63
+ customSystemPrompt: CUSTOM_SYSTEM_PROMPT,
64
+ mcpServers: MCP_SERVERS,
65
+ modelPricing: MODEL_PRICING,
66
+ maxContextTokens: MAX_CONTEXT_TOKENS,
67
+ maxOutputTokens: MAX_OUTPUT_TOKENS,
68
+ backgroundModel: BACKGROUND_MODEL,
69
+ providerOrders: PROVIDER_ORDERS,
70
+ } = ARGS;
71
+ const MODEL = String(ARGS.model || "anthropic/claude-sonnet-4-6");
72
+ const ENGINE_MODEL = ARGS.engineModel;
73
+ const HARNESS_ID = resolveHarnessId(ARGS.argv.harness, MODEL);
74
+ const HARNESS = getHarness(HARNESS_ID);
75
+ const IS_ARTIFACT_WRITER = isArtifactWriterAgent(AGENT);
76
+
77
+ if (!TASK) {
78
+ emitSync({ type: "error", message: "runner: missing --task-b64 argument" });
79
+ process.exit(1);
80
+ }
81
+
82
+ const RUNNER_DIR = dirname(fileURLToPath(import.meta.url));
83
+ const PERSONA_DIR = process.env.KAI_PERSONA_DIR || "/opt/gleap-runners/personas";
84
+ const PERSONA_AGENTS = new Set(["kai-documentarian", "kai-asker", "kai-researcher", KAI_RESOLUTION_ANALYST_AGENT_NAME]);
85
+ const SCRATCH_DIR = join(tmpdir(), `kai-acp-${process.pid}`);
86
+ mkdirSync(SCRATCH_DIR, { recursive: true });
87
+ // Harness config dir lives OUTSIDE the repo tree and survives across
88
+ // turns of the same session (resume + transcript) — keyed by session.
89
+ const STATE_ROOT = process.env.KAI_ACP_STATE_DIR || join(tmpdir(), "kai-acp-state");
90
+
91
+ const GIT_HANDOFF_PROMPT =
92
+ "Never run `git add`, `git commit`, or `git push` — the Gleap host " +
93
+ "creates commits and pull requests from your working tree after the " +
94
+ "turn ends. Leave all changes uncommitted.";
95
+ const GIT_HANDOFF_DISALLOWED = [
96
+ "Bash(git add:*)",
97
+ "Bash(git commit:*)",
98
+ "Bash(git push:*)",
99
+ ];
100
+ // Gateway wire (OpenRouter skin): the CLI's native AskUserQuestion /
101
+ // ExitPlanMode tools don't exist for unrecognised model ids, so the
102
+ // ask_user MCP bridge stands in (carried over from the retired claude-runner.mjs).
103
+ const IS_GATEWAY_CLAUDE = HARNESS_ID === "claude" && !isNativeAnthropic(MODEL);
104
+ // Codex has no native question tool either (elicitation only covers
105
+ // approvals), so it gets the same bridge — that is literally what
106
+ // ask-user-mcp.mjs was written for. Without it a Codex turn that is
107
+ // told to ask the user something just skips the ask.
108
+ const NEEDS_ASK_USER_MCP = IS_GATEWAY_CLAUDE || HARNESS_ID === "codex";
109
+ const ASK_USER_SERVER_KEY = "kai_user";
110
+ const ASK_USER_MCP_PATH = process.env.KAI_ASK_USER_MCP_PATH || join(RUNNER_DIR, "tools", "ask-user-mcp.mjs");
111
+ // Todo bridge for EVERY harness: Claude's own task tools (TaskCreate/…)
112
+ // vanish from resumed BYO sessions (the CLI's deferred-tool
113
+ // reconciliation removes them on resume when the user's ambient
114
+ // claude.ai connector set is loaded — upstream bug), and Codex has no
115
+ // todo tool at all. `todo_write` calls are folded onto the canonical
116
+ // TodoWrite path by the mapper, which emits the dashboard `todos`
117
+ // events either way.
118
+ const TODO_SERVER_KEY = "kai_todos";
119
+ const TODO_MCP_PATH = process.env.KAI_TODO_MCP_PATH || join(RUNNER_DIR, "tools", "todo-mcp.mjs");
120
+ const TODO_NOTE =
121
+ "Track multi-step work as a live todo list with the `todo_write` tool " +
122
+ `from the \`${TODO_SERVER_KEY}\` MCP server: pass the FULL updated list ` +
123
+ "(todos: [{content, status: pending|in_progress|completed}]) whenever a " +
124
+ "step starts or finishes — the user's dashboard renders it live. Use it " +
125
+ "INSTEAD of TodoWrite / TaskCreate / TaskUpdate, which may be " +
126
+ "unavailable in this environment.";
127
+ // Harnesses prefix MCP tool names differently (Claude: `mcp__kai_user__ask_user`,
128
+ // Codex: server-scoped) — the note names the tool + server, and the
129
+ // mapper's `ask_user` suffix match catches every spelling.
130
+ const ASK_USER_TOOL_REF = `\`ask_user\` tool from the \`${ASK_USER_SERVER_KEY}\` MCP server`;
131
+ const GATEWAY_QUESTION_NOTE =
132
+ "The AskUserQuestion tool is NOT available in this environment — " +
133
+ `where instructions mention it, use the ${ASK_USER_TOOL_REF} ` +
134
+ "instead. To ask the user clarifying questions, call that " +
135
+ "tool with your structured questions and END YOUR TURN — the answers " +
136
+ "arrive in the next user message. Never ask questions as plain text.";
137
+ const GATEWAY_PLAN_GUARD =
138
+ "The ExitPlanMode tool is NOT available in this environment either. " +
139
+ "To finalise a plan: end your turn with the complete plan as your " +
140
+ "final message — do NOT attempt to write plan files anywhere (file " +
141
+ "writes are declined in plan mode; the platform captures the plan " +
142
+ "from your final message). Your final message is shown to the user " +
143
+ "verbatim as the plan: never mention declined tool calls, permission " +
144
+ "errors, or where you tried to save it. " +
145
+ `If you asked questions via the ${ASK_USER_TOOL_REF}, ` +
146
+ "do NOT also finalise the plan in the same turn — wait for " +
147
+ "the answers first.";
148
+ // Tool allow-lists (Claude permission rules). Plan mode and artifact
149
+ // writers (kai-asker / researcher / documentarian: `.kai/` outputs only,
150
+ // never repo edits) run under the CLI's own gating with these rules;
151
+ // build mode bypasses permissions — the worktree IS the boundary.
152
+ // AskUserQuestion / ExitPlanMode deliberately NOT here: a bare allow-list
153
+ // entry bypasses the SDK's canUseTool, and claude-agent-acp delivers both
154
+ // through canUseTool (AskUserQuestion → form elicitation, ExitPlanMode →
155
+ // permission request). The question tool is offered because we advertise
156
+ // `elicitation.form` on initialize.
157
+ const NONWRITE_TOOLS_ALLOWED = ["Read", "Grep", "Glob", "Task", "TodoWrite", "WebFetch", "WebSearch", "ToolSearch"];
158
+ const READONLY_BASH_ALLOWED = [
159
+ "cat", "head", "tail", "wc", "stat", "ls", "find", "grep", "rg", "git log", "git diff", "git show", "git status",
160
+ "git ls-files", "git grep", "git rev-parse", "git branch", "sed -n", "awk", "jq", "sort", "uniq", "cut", "tr",
161
+ "diff", "nl", "basename", "dirname", "realpath", "file",
162
+ ].map((c) => `Bash(${c}:*)`);
163
+ const absRule = (p) => `//${String(p).replace(/^\/+/, "")}`;
164
+ const KAI_WRITE_ALLOWED = [
165
+ `Write(${absRule(WORK_DIR)}/.kai/**)`,
166
+ `Edit(${absRule(WORK_DIR)}/.kai/**)`,
167
+ `MultiEdit(${absRule(WORK_DIR)}/.kai/**)`,
168
+ "Write(.kai/**)",
169
+ "Edit(.kai/**)",
170
+ "MultiEdit(.kai/**)",
171
+ ];
172
+ const DOC_EXPLORER_AGENT_NAME = "kai-doc-explorer";
173
+ const DOC_EXPLORER_DESCRIPTION =
174
+ "Help-center research subagent. Investigates ONE user-facing feature " +
175
+ "area across the cloned repositories (read-only) and returns a " +
176
+ "structured findings dossier with file citations and quoted strings.";
177
+
178
+ const PLAN_QUESTION_GUARD =
179
+ "When you call AskUserQuestion in plan mode, do NOT also call " +
180
+ "ExitPlanMode or write a plan file in the same turn. End the turn " +
181
+ "with the AskUserQuestion call and wait for the user's answer; " +
182
+ "finalise the plan only on a subsequent turn after questions are answered.";
183
+
184
+ // ── Prompt + persona ──────────────────────────────────────────────────
185
+ function renderAnswersBlock() {
186
+ if (!Array.isArray(QUESTION_ANSWERS) || QUESTION_ANSWERS.length === 0) return "";
187
+ const lines = QUESTION_ANSWERS.map((a, i) => `${i + 1}. ${Array.isArray(a) ? a.join(", ") : String(a)}`);
188
+ return `Answers to your questions:\n${lines.join("\n")}`;
189
+ }
190
+
191
+ /** Download attachments into scratch so the agent can Read them. */
192
+ async function downloadAttachments() {
193
+ const saved = [];
194
+ if (!Array.isArray(ATTACHMENTS) || ATTACHMENTS.length === 0) return saved;
195
+ const dir = join(SCRATCH_DIR, "attachments");
196
+ mkdirSync(dir, { recursive: true });
197
+ for (const [i, att] of ATTACHMENTS.entries()) {
198
+ if (!att?.url) continue;
199
+ const safeName = String(att.name || `attachment-${i}`).replace(/[^a-zA-Z0-9._-]/g, "_");
200
+ const target = join(dir, `${i}-${safeName}`);
201
+ try {
202
+ const res = await fetch(att.url, { signal: AbortSignal.timeout(60_000) });
203
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
204
+ writeFileSync(target, Buffer.from(await res.arrayBuffer()));
205
+ saved.push({ path: target, type: att.type, name: att.name });
206
+ traceLog("attachment.saved", { name: att.name });
207
+ } catch (err) {
208
+ traceLog("attachment.failed", { name: att.name, error: err?.message ?? String(err) });
209
+ }
210
+ }
211
+ return saved;
212
+ }
213
+
214
+ function buildPrompt(savedAttachments = []) {
215
+ const sections = [];
216
+ const answers = renderAnswersBlock();
217
+ if (FEEDBACK) {
218
+ sections.push(FEEDBACK);
219
+ if (answers) sections.push(answers);
220
+ } else if (answers) {
221
+ sections.push(answers);
222
+ sections.push("Continue the task using these answers.");
223
+ } else {
224
+ sections.push(TASK);
225
+ }
226
+ if (savedAttachments.length > 0) {
227
+ const list = savedAttachments.map((a) => `- ${a.path} (${a.type})`).join("\n");
228
+ sections.push(`The user attached the following files — read them with the Read tool:\n${list}`);
229
+ }
230
+ return sections.join("\n\n");
231
+ }
232
+
233
+ function buildAppendSystemPrompt() {
234
+ const sections = [];
235
+ if (PERSONA_AGENTS.has(AGENT)) {
236
+ // Codex has its own compiled personas; Claude + Cursor share the Claude set.
237
+ const personaPath = join(PERSONA_DIR, HARNESS_ID === "codex" ? "codex" : "claude", `${AGENT}.md`);
238
+ if (existsSync(personaPath)) {
239
+ const body = readFileSync(personaPath, "utf8").trim();
240
+ if (body) sections.push(body);
241
+ } else {
242
+ traceLog("persona.missing", { agent: AGENT, path: personaPath });
243
+ }
244
+ }
245
+ if (NEEDS_ASK_USER_MCP && !IS_ARTIFACT_WRITER) sections.push(GATEWAY_QUESTION_NOTE);
246
+ if (IS_PLAN_MODE) sections.push(NEEDS_ASK_USER_MCP ? GATEWAY_PLAN_GUARD : PLAN_QUESTION_GUARD);
247
+ if (!IS_PLAN_MODE) sections.push(GIT_HANDOFF_PROMPT);
248
+ // After the safety guards (their leading position is load-bearing for
249
+ // cursor's prompt-prefix mode) but before project instructions.
250
+ if (!IS_ARTIFACT_WRITER) sections.push(TODO_NOTE);
251
+ // Project instructions ALWAYS last so the cached prefix stays stable.
252
+ if (CUSTOM_SYSTEM_PROMPT && CUSTOM_SYSTEM_PROMPT.trim()) sections.push(CUSTOM_SYSTEM_PROMPT.trim());
253
+ return sections.join("\n\n");
254
+ }
255
+
256
+ /** Host MCP servers → ACP `mcpServers` (stdio + http both supported). */
257
+ function buildAcpMcpServers() {
258
+ const out = [];
259
+ if (NEEDS_ASK_USER_MCP && existsSync(ASK_USER_MCP_PATH)) {
260
+ out.push({ name: ASK_USER_SERVER_KEY, command: process.execPath, args: [ASK_USER_MCP_PATH], env: [] });
261
+ }
262
+ if (!IS_ARTIFACT_WRITER && existsSync(TODO_MCP_PATH)) {
263
+ out.push({ name: TODO_SERVER_KEY, command: process.execPath, args: [TODO_MCP_PATH], env: [] });
264
+ }
265
+ for (const server of MCP_SERVERS || []) {
266
+ if (!server || typeof server !== "object") continue;
267
+ const name = String(server.name || server.id || "").replace(/[^a-zA-Z0-9_-]/g, "_");
268
+ if (!name) continue;
269
+ if (server.transport === "http" && server.url) {
270
+ out.push({
271
+ type: "http",
272
+ name,
273
+ url: String(server.url),
274
+ headers: Object.entries(server.headers || {}).map(([k, v]) => ({ name: k, value: String(v) })),
275
+ });
276
+ } else if (server.command) {
277
+ out.push({
278
+ name,
279
+ command: String(server.command),
280
+ args: Array.isArray(server.args) ? server.args.map(String) : [],
281
+ env: Object.entries(server.env || {}).map(([k, v]) => ({ name: k, value: String(v) })),
282
+ });
283
+ }
284
+ }
285
+ return out;
286
+ }
287
+
288
+ function buildAllowedTools(mcpAllowed) {
289
+ if (IS_PLAN_MODE) return [...NONWRITE_TOOLS_ALLOWED, ...READONLY_BASH_ALLOWED, ...mcpAllowed];
290
+ if (IS_ARTIFACT_WRITER) return [...NONWRITE_TOOLS_ALLOWED, ...READONLY_BASH_ALLOWED, ...KAI_WRITE_ALLOWED, ...mcpAllowed];
291
+ return undefined;
292
+ }
293
+
294
+ /** Documentarian fan-out: the read-only research subagent, by name. */
295
+ function buildAgents() {
296
+ if (AGENT !== "kai-documentarian" || HARNESS_ID !== "claude") return undefined;
297
+ const explorerPath = join(PERSONA_DIR, "claude", `${DOC_EXPLORER_AGENT_NAME}.md`);
298
+ if (!existsSync(explorerPath)) {
299
+ traceLog("persona.missing", { agent: DOC_EXPLORER_AGENT_NAME, path: explorerPath });
300
+ return undefined;
301
+ }
302
+ const def = {
303
+ description: DOC_EXPLORER_DESCRIPTION,
304
+ prompt: readFileSync(explorerPath, "utf8"),
305
+ tools: ["Read", "Grep", "Glob", "Bash", "WebFetch", "WebSearch"],
306
+ };
307
+ if (SUBAGENT_MODEL && isNativeAnthropic(SUBAGENT_MODEL) === isNativeAnthropic(MODEL)) {
308
+ def.model = deriveEngineSlug(SUBAGENT_MODEL);
309
+ }
310
+ return { [DOC_EXPLORER_AGENT_NAME]: def };
311
+ }
312
+
313
+ function buildDisallowedTools() {
314
+ const list = IS_PLAN_MODE || IS_ARTIFACT_WRITER ? [] : [...GIT_HANDOFF_DISALLOWED];
315
+ for (const server of MCP_SERVERS || []) {
316
+ const key = String(server?.name || server?.id || "").replace(/[^a-zA-Z0-9_-]/g, "_");
317
+ for (const tool of [...(server?.disabledTools ?? []), ...(server?.gatedTools ?? [])]) {
318
+ list.push(`mcp__${key}__${String(tool).replace(/[^a-zA-Z0-9_-]/g, "_")}`);
319
+ }
320
+ }
321
+ return list;
322
+ }
323
+
324
+ // ── Main ──────────────────────────────────────────────────────────────
325
+ async function main() {
326
+ // One config dir per harness (NOT per session): the agent mints the
327
+ // session id on turn 1, and the resume turn must find the same
328
+ // transcript + settings under it.
329
+ // `KAI_ACP_CONFIG_DIR` = the local bridge pointing at a BYO profile
330
+ // (the user's own `claude` / `codex` login dir).
331
+ const configDir = process.env.KAI_ACP_CONFIG_DIR || join(STATE_ROOT, HARNESS_ID);
332
+ mkdirSync(configDir, { recursive: true });
333
+ // `KAI_ACP_CONFIG_DIR_AMBIENT=1` = configDir is the CLI's own default
334
+ // dir (the user's ambient login). Claude must then NOT get an explicit
335
+ // CLAUDE_CONFIG_DIR: on macOS the CLI keeps OAuth in the keychain only
336
+ // while the env var is unset — exporting it (even set to the default
337
+ // path) flips credential lookup to `.credentials.json` and a real
338
+ // login probes as signed-out. Transcripts still read from configDir.
339
+ const configDirAmbient = process.env.KAI_ACP_CONFIG_DIR_AMBIENT === "1";
340
+ const appendSystemPrompt = buildAppendSystemPrompt();
341
+ const savedAttachments = await downloadAttachments();
342
+ let instructionsPath;
343
+ if (HARNESS_ID === "codex" && appendSystemPrompt) {
344
+ instructionsPath = join(SCRATCH_DIR, "instructions.md");
345
+ writeFileSync(instructionsPath, appendSystemPrompt);
346
+ }
347
+
348
+ const ctx = {
349
+ runnerDir: RUNNER_DIR,
350
+ configDir,
351
+ configDirAmbient,
352
+ workDir: WORK_DIR,
353
+ model: MODEL,
354
+ engineModel: ENGINE_MODEL,
355
+ subagentModel: SUBAGENT_MODEL,
356
+ backgroundModel: BACKGROUND_MODEL,
357
+ maxContextTokens: MAX_CONTEXT_TOKENS,
358
+ maxOutputTokens: MAX_OUTPUT_TOKENS,
359
+ effort: EFFORT,
360
+ maxSteps: MAX_STEPS,
361
+ isPlanMode: IS_PLAN_MODE,
362
+ isArtifactWriter: IS_ARTIFACT_WRITER,
363
+ appendSystemPrompt,
364
+ instructionsPath,
365
+ disallowedTools: buildDisallowedTools(),
366
+ allowedTools: buildAllowedTools([
367
+ // The injected todo bridge must survive plan mode's allow-list.
368
+ `mcp__${TODO_SERVER_KEY}`,
369
+ ...(MCP_SERVERS || []).map((srv) => `mcp__${String(srv?.name || srv?.id || "").replace(/[^a-zA-Z0-9_-]/g, "_")}`).filter((k) => k !== "mcp__"),
370
+ ]),
371
+ agents: buildAgents(),
372
+ // Codex takes MCP servers from ctx via config.toml, not from
373
+ // session/new (sessionMcpServers is [] there) — so the ask_user
374
+ // bridge has to ride in here for it. Claude picks the same server
375
+ // up from buildAcpMcpServers() via session/new instead.
376
+ mcpServers:
377
+ HARNESS_ID === "codex"
378
+ ? [
379
+ ...(NEEDS_ASK_USER_MCP && existsSync(ASK_USER_MCP_PATH)
380
+ ? [{ name: ASK_USER_SERVER_KEY, command: process.execPath, args: [ASK_USER_MCP_PATH] }]
381
+ : []),
382
+ ...(!IS_ARTIFACT_WRITER && existsSync(TODO_MCP_PATH)
383
+ ? [{ name: TODO_SERVER_KEY, command: process.execPath, args: [TODO_MCP_PATH] }]
384
+ : []),
385
+ ...(MCP_SERVERS || []),
386
+ ]
387
+ : MCP_SERVERS || [],
388
+ maxBudgetUsd: MAX_BUDGET_USD,
389
+ additionalDirectories: savedAttachments.length > 0 ? [join(SCRATCH_DIR, "attachments")] : [],
390
+ resumeSessionId: SESSION_ID,
391
+ sessionId: SESSION_ID,
392
+ turnStartedAt: new Date().toISOString(),
393
+ };
394
+
395
+ // BYO login (bridge): the harness authenticates from its own config
396
+ // dir; no Gleap key is required or present.
397
+ const byoLogin = process.env.KAI_ACP_BYO_LOGIN === "1";
398
+ ctx.byoLogin = byoLogin;
399
+ const required = byoLogin ? null : HARNESS.requiredEnv(ctx);
400
+ if (required && !process.env[required]) {
401
+ emitSync({ type: "error", message: `acp-runner: ${required} is not set for harness ${HARNESS_ID}` });
402
+ process.exit(1);
403
+ }
404
+
405
+ // Gateway wire: interpose the local proxy when this model needs
406
+ // provider pinning / a compat shim / byte capture (KAI_WIRE_DEBUG=1).
407
+ let wire = null;
408
+ const wireDebug = process.env.KAI_WIRE_DEBUG === "1";
409
+ const engineSlug = ENGINE_MODEL || MODEL;
410
+ if (IS_GATEWAY_CLAUDE && needsWireProxy({ engineModel: engineSlug, providerOrders: PROVIDER_ORDERS, debug: wireDebug })) {
411
+ wire = await startWireProxy({
412
+ engineModel: engineSlug,
413
+ providerOrders: PROVIDER_ORDERS,
414
+ debug: wireDebug,
415
+ captureDir: join(SCRATCH_DIR, "wire"),
416
+ log: (event, data) => traceLog(event, data),
417
+ });
418
+ ctx.wireBaseUrl = wire.baseUrl;
419
+ }
420
+
421
+ const { cmd, args } = HARNESS.command(ctx);
422
+ const env = HARNESS.env(ctx);
423
+ traceLog("spawn", { harness: HARNESS_ID, cmd, model: MODEL, engineModel: ENGINE_MODEL, plan: IS_PLAN_MODE });
424
+ const child = spawn(cmd, args, { cwd: WORK_DIR, env, stdio: ["pipe", "pipe", "pipe"] });
425
+ child.stderr.on("data", (d) => traceLog("agent.stderr", { line: String(d).trim().slice(0, 2000) }));
426
+ const stream = ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
427
+
428
+ const tracker = createUsageTracker(MODEL_PRICING);
429
+ let inFlight = true;
430
+ const stopHeartbeat = startHeartbeat(() => inFlight);
431
+ let conn;
432
+ let acpSessionId = null;
433
+ let cancelRequested = null;
434
+
435
+ const mcpServerIds = {};
436
+ for (const server of MCP_SERVERS || []) {
437
+ const key = String(server?.name || server?.id || "").replace(/[^a-zA-Z0-9_-]/g, "_");
438
+ if (key) mcpServerIds[key] = String(server.id || server.name || key);
439
+ }
440
+ // Plan files: pin the CLI's plansDirectory to scratch so the hand-off can
441
+ // read the newest plan even when ExitPlanMode only references it by path.
442
+ const plansDir = join(SCRATCH_DIR, "plans");
443
+ mkdirSync(plansDir, { recursive: true });
444
+ ctx.plansDir = plansDir;
445
+ // The CLI honours plansDirectory from settings.json but not reliably from
446
+ // the programmatic settings tier (2.1.241 wrote to <CLAUDE_CONFIG_DIR>/plans),
447
+ // so scan both and take the newest plan written during THIS turn.
448
+ const turnStartedMs = Date.now();
449
+ const readNewestPlan = () => {
450
+ let best = null;
451
+ for (const dir of [plansDir, join(configDir, "plans")]) {
452
+ try {
453
+ for (const f of readdirSync(dir)) {
454
+ if (!f.endsWith(".md")) continue;
455
+ const m = statSync(join(dir, f)).mtimeMs;
456
+ if (m >= turnStartedMs - 1000 && (!best || m > best.m)) best = { p: join(dir, f), m };
457
+ }
458
+ } catch {
459
+ /* dir absent */
460
+ }
461
+ }
462
+ try {
463
+ return best ? readFileSync(best.p, "utf8") : "";
464
+ } catch {
465
+ return "";
466
+ }
467
+ };
468
+ const mapper = createAcpMapper({
469
+ emit,
470
+ isPlanMode: IS_PLAN_MODE,
471
+ mcpServerIds,
472
+ readPlanFile: readNewestPlan,
473
+ allowTool: permissionPolicy({ isPlanMode: IS_PLAN_MODE, isArtifactWriter: IS_ARTIFACT_WRITER, workDir: WORK_DIR }),
474
+ onTurnShouldEnd: (reason) => {
475
+ cancelRequested = reason;
476
+ if (conn && acpSessionId) conn.cancel({ sessionId: acpSessionId }).catch(() => {});
477
+ },
478
+ onContextSnapshot: (model, tokens, window) => {
479
+ tracker.noteContextSnapshot(model ?? MODEL, tokens, window);
480
+ },
481
+ });
482
+
483
+ conn = new ClientSideConnection(
484
+ () => ({
485
+ async requestPermission(params) {
486
+ const optionId = mapper.handlePermission(params);
487
+ if (optionId == null) return { outcome: { outcome: "cancelled" } };
488
+ // No reject option offered but the policy says no → cancel this
489
+ // tool use only (the adapter treats cancelled as "not allowed").
490
+ if (optionId === "__reject__") return { outcome: { outcome: "cancelled" } };
491
+ return { outcome: { outcome: "selected", optionId } };
492
+ },
493
+ async sessionUpdate(params) {
494
+ mapper.handleUpdate(params.update);
495
+ },
496
+ // AskUserQuestion arrives as a form elicitation (claude-agent-acp only
497
+ // offers the tool when the client advertises `elicitation.form`). We
498
+ // never answer inline: the question ends the turn and the dashboard's
499
+ // answers come back as the next user message (same as today).
500
+ async createElicitation(params) {
501
+ mapper.handleElicitation(params);
502
+ return { action: "cancel" };
503
+ },
504
+ }),
505
+ stream,
506
+ );
507
+
508
+ const init = await conn.initialize({
509
+ protocolVersion: 1,
510
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false, elicitation: { form: {} } },
511
+ clientInfo: { name: "kai-acp-runner", version: "0.1.0" },
512
+ });
513
+ traceLog("initialized", { agent: init.agentInfo, auth: (init.authMethods || []).map((m) => m.id) });
514
+
515
+ // API-key auth where the adapter asks for it (codex-acp advertises
516
+ // `api-key`; claude-agent-acp needs nothing with the key in env).
517
+ const apiKeyMethod = byoLogin ? null : (init.authMethods || []).find((m) => /api[-_]?key/i.test(String(m.id)));
518
+ if (apiKeyMethod) {
519
+ try {
520
+ await conn.authenticate({ methodId: apiKeyMethod.id });
521
+ } catch (err) {
522
+ traceLog("authenticate.failed", { method: apiKeyMethod.id, error: String(err?.message ?? err) });
523
+ }
524
+ }
525
+
526
+ const mcpServers = HARNESS.sessionMcpServers(ctx, buildAcpMcpServers());
527
+ // Resume only when the harness can actually find the prior session on
528
+ // disk — the FIRST turn of a session ships a session id too, and a
529
+ // resume against nothing fails after seconds of adapter retries.
530
+ const canResume =
531
+ !!SESSION_ID &&
532
+ !!init.agentCapabilities?.sessionCapabilities?.resume &&
533
+ (HARNESS.hasResumableSession ? HARNESS.hasResumableSession(ctx) : true);
534
+ let sessionResponse;
535
+ let resumed = false;
536
+ if (canResume) {
537
+ try {
538
+ sessionResponse = await conn.resumeSession({ sessionId: SESSION_ID, cwd: WORK_DIR, mcpServers, _meta: HARNESS.sessionMeta(ctx) });
539
+ resumed = !!sessionResponse;
540
+ } catch (err) {
541
+ traceLog("resume.failed", { error: String(err?.message ?? err) });
542
+ }
543
+ }
544
+ if (!sessionResponse) {
545
+ // The fallback must be a CLEAN start: `_meta` built with the resume id
546
+ // still in it makes claude-agent-acp re-run the query against the
547
+ // missing transcript and hang the session/new call indefinitely.
548
+ sessionResponse = await conn.newSession({
549
+ cwd: WORK_DIR,
550
+ mcpServers,
551
+ ...(ctx.additionalDirectories.length > 0 ? { additionalDirectories: ctx.additionalDirectories } : {}),
552
+ _meta: HARNESS.sessionMeta({ ...ctx, resumeSessionId: null }),
553
+ });
554
+ }
555
+ // codex-acp's `session/load` response is an empty object per the ACP
556
+ // spec (the id is the one we asked for) — reading `.sessionId` off it
557
+ // left every later call with `sessionId: undefined` → "Invalid params"
558
+ // and the whole resumed turn failed.
559
+ acpSessionId = sessionResponse.sessionId ?? (resumed ? SESSION_ID : null);
560
+ ctx.sessionId = acpSessionId;
561
+ traceLog("session", { sessionId: acpSessionId, resumed });
562
+
563
+ // Permission mode via ACP (`session/set_mode`). claude-agent-acp ignores
564
+ // `options.permissionMode` (it re-applies its own default after our
565
+ // options), so this is the call that actually puts the agent in plan /
566
+ // dontAsk / bypassPermissions. Verified on 0.70.0 + CLI 2.1.241.
567
+ const desiredMode = pickSessionMode(HARNESS.sessionModePreference?.(ctx) ?? [], sessionResponse.modes?.availableModes);
568
+ if (desiredMode) {
569
+ try {
570
+ await conn.setSessionMode({ sessionId: acpSessionId, modeId: desiredMode });
571
+ traceLog("session.mode", { modeId: desiredMode });
572
+ } catch (err) {
573
+ traceLog("session.mode.failed", { modeId: desiredMode, error: String(err?.message ?? err) });
574
+ }
575
+ }
576
+
577
+ // Artifact writers must leave the repo untouched: snapshot before,
578
+ // revert anything outside `.kai/` after (belt-and-braces under the
579
+ // permission rules — shell redirections can still write).
580
+ const baselines = IS_ARTIFACT_WRITER ? captureRepoBaselines(WORK_DIR) : null;
581
+ // Harnesses without a system-prompt channel (Cursor) get the persona as
582
+ // a prefix of the first prompt.
583
+ const promptPrefix = HARNESS.promptPrefix?.(ctx) || "";
584
+ const prompt = (promptPrefix ? `${promptPrefix}\n\n---\n\n` : "") + buildPrompt(savedAttachments);
585
+ let stopReason = "end_turn";
586
+ let promptError = null;
587
+ try {
588
+ const res = await conn.prompt({ sessionId: acpSessionId, prompt: [{ type: "text", text: prompt }] });
589
+ stopReason = res?.stopReason ?? "end_turn";
590
+ } catch (err) {
591
+ promptError = err;
592
+ }
593
+ inFlight = false;
594
+ stopHeartbeat?.();
595
+
596
+ const finished = mapper.finish();
597
+ if (IS_ARTIFACT_WRITER) {
598
+ try {
599
+ const reverted = revertRepoMutations(WORK_DIR, baselines);
600
+ if (reverted.length > 0) debugLog("artifact.revert", { repos: reverted });
601
+ } catch (err) {
602
+ traceLog("artifact.revert.failed", { error: err?.message ?? String(err) });
603
+ }
604
+ }
605
+
606
+ // Billing-grade usage from the harness transcript.
607
+ let usageRows = [];
608
+ let contextWindow = null;
609
+ try {
610
+ const collected = HARNESS.collectTurnUsage(ctx);
611
+ usageRows = collected.rows || [];
612
+ contextWindow = collected.contextWindow ?? null;
613
+ traceLog("usage.transcript", { path: collected.path, rows: usageRows.length });
614
+ } catch (err) {
615
+ traceLog("usage.transcript.failed", { error: String(err?.message ?? err) });
616
+ }
617
+ for (const row of aggregateUsageRows(usageRows)) {
618
+ tracker.record(
619
+ HARNESS.canonicalModel(ctx, row.model),
620
+ {
621
+ // Tracker convention: `inputTokens` = FRESH (uncached) input; it
622
+ // reports the combined figure itself.
623
+ inputTokens: row.inputTokens,
624
+ cachedInputTokens: row.cachedInputTokens,
625
+ cacheWriteInputTokens: row.cacheWriteInputTokens,
626
+ outputTokens: row.outputTokens,
627
+ },
628
+ { countTurn: true, aggregate: true },
629
+ );
630
+ }
631
+ const snap = lastRootContextSnapshot(usageRows);
632
+ if (snap) {
633
+ tracker.noteContextSnapshot(
634
+ HARNESS.canonicalModel(ctx, snap.model),
635
+ snap.tokens,
636
+ finished.usage?.size ?? contextWindow ?? MAX_CONTEXT_TOKENS ?? undefined,
637
+ );
638
+ }
639
+ // The adapter's USD is the CLI's own figure — authoritative on the
640
+ // native Anthropic wire only; gateway turns are priced from tokens.
641
+ if (typeof finished.usage?.costUsd === "number" && HARNESS_ID === "claude" && isNativeAnthropic(MODEL)) {
642
+ tracker.setProviderCostUsd(finished.usage.costUsd);
643
+ }
644
+
645
+ try {
646
+ child.kill("SIGTERM");
647
+ } catch {
648
+ /* already gone */
649
+ }
650
+
651
+ if (promptError && !cancelRequested) {
652
+ const diag = wire?.diagnostics();
653
+ if (diag) traceLog("wire.failure", diag);
654
+ emitSync({ type: "error", message: `acp-runner: ${String(promptError?.message ?? promptError)}` });
655
+ emitSync(tracker.buildResultEvent({ sessionId: acpSessionId }));
656
+ process.exit(1);
657
+ }
658
+ if (stopReason === "refusal") {
659
+ emit({ type: "error", message: "The model declined to continue (refusal)." });
660
+ }
661
+ const resultMessage = IS_PLAN_MODE && !finished.planEmitted && !finished.questionAsked ? finished.lastText : "";
662
+ if (IS_PLAN_MODE && resultMessage) emit({ type: "plan", message: resultMessage });
663
+ emitSync(tracker.buildResultEvent({ message: resultMessage, sessionId: acpSessionId }));
664
+ debugLog("done", { stopReason, cancelRequested, steps: usageRows.length });
665
+ process.exit(0);
666
+ }
667
+
668
+ main().catch((err) => {
669
+ emitSync({ type: "error", message: `acp-runner: ${String(err?.stack ?? err)}` });
670
+ process.exit(1);
671
+ });