@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,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
|
+
});
|