@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.
- package/README.md +45 -0
- package/bin/kai-bridge.mjs +275 -0
- package/package.json +47 -0
- package/runner/acp-runner.mjs +671 -0
- package/runner/lib/acp/harnesses.mjs +342 -0
- package/runner/lib/acp/mapper.mjs +575 -0
- package/runner/lib/acp/transcripts.mjs +238 -0
- package/runner/lib/contract.mjs +1122 -0
- package/runner/lib/wire-proxy.mjs +200 -0
- package/runner/personas/claude/kai-asker.md +69 -0
- package/runner/personas/claude/kai-doc-explorer.md +130 -0
- package/runner/personas/claude/kai-documentarian.md +205 -0
- package/runner/personas/claude/kai-researcher.md +68 -0
- package/runner/personas/claude/kai-resolution-analyst.md +164 -0
- package/runner/personas/codex/kai-asker.md +68 -0
- package/runner/personas/codex/kai-doc-explorer.md +130 -0
- package/runner/personas/codex/kai-documentarian.md +211 -0
- package/runner/personas/codex/kai-researcher.md +67 -0
- package/runner/personas/codex/kai-resolution-analyst.md +164 -0
- package/runner/tools/ask-user-mcp.mjs +130 -0
- package/runner/tools/todo-mcp.mjs +116 -0
- package/scripts/postinstall.mjs +24 -0
- package/src/api.mjs +141 -0
- package/src/config.mjs +76 -0
- package/src/daemon.mjs +904 -0
- package/src/executor.mjs +156 -0
- package/src/harnesses.mjs +252 -0
- package/src/preview.mjs +337 -0
- package/src/profiles.mjs +250 -0
- package/src/repos.mjs +182 -0
- package/src/service.mjs +162 -0
- package/src/setup.mjs +261 -0
- package/src/workspace.mjs +175 -0
|
@@ -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
|
+
}
|