@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,575 @@
|
|
|
1
|
+
// ACP → runner-contract mapper.
|
|
2
|
+
//
|
|
3
|
+
// Turns Agent Client Protocol `session/update` notifications (and the
|
|
4
|
+
// permission requests an agent raises mid-turn) into the exact JSONL
|
|
5
|
+
// events the analyzer host already consumes (`parseContractOutputLine`
|
|
6
|
+
// in src/coder/agents/contract.ts): thinking / text / tool_status /
|
|
7
|
+
// todos / question / plan / result / error. Harness-agnostic by design:
|
|
8
|
+
// anything that speaks ACP (claude-agent-acp, codex-acp, gemini, opencode,
|
|
9
|
+
// …) produces the same timeline. Harness-specific details — the real
|
|
10
|
+
// tool name behind a call, subagent parentage — are read from the
|
|
11
|
+
// namespaced `_meta` the adapters attach, with graceful fallbacks to
|
|
12
|
+
// ACP's generic `kind`/`title`.
|
|
13
|
+
//
|
|
14
|
+
// Pure: no I/O, no process state. `emit` is injected so tests can
|
|
15
|
+
// capture the stream.
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
canonicalToolName,
|
|
19
|
+
sanitizeToolValue,
|
|
20
|
+
summarizeTool,
|
|
21
|
+
} from "../contract.mjs";
|
|
22
|
+
|
|
23
|
+
/** Tool names (canonical) whose calls END the turn instead of running. */
|
|
24
|
+
const QUESTION_TOOL = "Question";
|
|
25
|
+
const EXIT_PLAN_TOOL = "ExitPlanMode";
|
|
26
|
+
|
|
27
|
+
/** Generic ACP `kind` → a readable tool label when no meta name exists. */
|
|
28
|
+
const KIND_LABEL = {
|
|
29
|
+
read: "Read",
|
|
30
|
+
edit: "Edit",
|
|
31
|
+
delete: "Delete",
|
|
32
|
+
move: "Move",
|
|
33
|
+
search: "Search",
|
|
34
|
+
execute: "Bash",
|
|
35
|
+
think: "Think",
|
|
36
|
+
fetch: "WebFetch",
|
|
37
|
+
switch_mode: "SwitchMode",
|
|
38
|
+
other: "Tool",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Best-effort tool name from an ACP tool_call (+ harness meta). */
|
|
42
|
+
export function toolNameFromUpdate(update) {
|
|
43
|
+
const meta = update?._meta ?? {};
|
|
44
|
+
const fromMeta =
|
|
45
|
+
meta.claudeCode?.toolName ??
|
|
46
|
+
meta.codex?.toolName ??
|
|
47
|
+
meta.codex?.tool ??
|
|
48
|
+
meta.toolName;
|
|
49
|
+
if (typeof fromMeta === "string" && fromMeta) return fromMeta;
|
|
50
|
+
// codex-acp encodes MCP tool calls as `rawInput: {server, tool,
|
|
51
|
+
// arguments}` under kind "execute" — without this they'd render as a
|
|
52
|
+
// Bash row, and the ask_user question bridge would never be detected.
|
|
53
|
+
if (typeof update?.rawInput?.server === "string" && typeof update?.rawInput?.tool === "string") {
|
|
54
|
+
return `mcp__${update.rawInput.server}__${update.rawInput.tool}`;
|
|
55
|
+
}
|
|
56
|
+
// Generic adapters (codex-acp) describe shell runs by `rawInput.command`
|
|
57
|
+
// whatever `kind` they classify them as (`ls` → read) — the dashboard
|
|
58
|
+
// wants the Bash row + command summary.
|
|
59
|
+
if (typeof update?.rawInput?.command === "string" || Array.isArray(update?.rawInput?.command)) return "Bash";
|
|
60
|
+
// The kai_todos bridge's announce can arrive before `_meta` names the
|
|
61
|
+
// call — the adapter's human title still says todo_write. Without this
|
|
62
|
+
// the announce leaks a bare "Tool (running)" row that the suppressed
|
|
63
|
+
// completion never clears.
|
|
64
|
+
if (typeof update?.title === "string" && /todo_write/.test(update.title)) return "TodoWrite";
|
|
65
|
+
if (update?.kind && KIND_LABEL[update.kind]) return KIND_LABEL[update.kind];
|
|
66
|
+
return "Tool";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Normalise adapter-specific raw inputs into the shapes summarizeTool knows. */
|
|
70
|
+
export function normalizeToolInput(name, update) {
|
|
71
|
+
const raw = update?.rawInput;
|
|
72
|
+
// MCP calls (codex-acp): the useful input is the nested `arguments`.
|
|
73
|
+
if (name.startsWith("mcp__") && raw && typeof raw.arguments === "object" && raw.arguments !== null) return raw.arguments;
|
|
74
|
+
if (name === "Bash" && raw && Array.isArray(raw.command)) return { ...raw, command: raw.command.join(" ") };
|
|
75
|
+
if (raw && typeof raw === "object" && Object.keys(raw).length > 0) return raw;
|
|
76
|
+
// Edits arrive as diff content blocks with no rawInput (claude-agent-acp
|
|
77
|
+
// and codex-acp both do this). Preserve the actual edit — oldText/newText —
|
|
78
|
+
// as Claude-Code-shaped fields so the dashboard can render the diff view
|
|
79
|
+
// and the "+N -N" line counts; dropping them here is why bridge sessions
|
|
80
|
+
// used to show "(no diff captured)".
|
|
81
|
+
if (Array.isArray(update?.content)) {
|
|
82
|
+
const diffs = update.content.filter((c) => c?.type === "diff" && c.path);
|
|
83
|
+
if (diffs.length === 1) {
|
|
84
|
+
const d = diffs[0];
|
|
85
|
+
const base = { file_path: d.path };
|
|
86
|
+
if (typeof d.newText === "string") {
|
|
87
|
+
if (typeof d.oldText === "string" && d.oldText.length > 0) {
|
|
88
|
+
base.old_string = d.oldText;
|
|
89
|
+
base.new_string = d.newText;
|
|
90
|
+
} else {
|
|
91
|
+
// No prior content → a Write (new file / full overwrite).
|
|
92
|
+
base.content = d.newText;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return base;
|
|
96
|
+
}
|
|
97
|
+
if (diffs.length > 1) {
|
|
98
|
+
return {
|
|
99
|
+
file_path: diffs[0].path,
|
|
100
|
+
files: diffs.map((d) => d.path),
|
|
101
|
+
edits: diffs.map((d) => ({
|
|
102
|
+
old_string: typeof d.oldText === "string" ? d.oldText : "",
|
|
103
|
+
new_string: typeof d.newText === "string" ? d.newText : "",
|
|
104
|
+
})),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Read/search actions carry `locations` instead of an input.
|
|
109
|
+
if (Array.isArray(update?.locations) && update.locations[0]?.path) return { file_path: update.locations[0].path };
|
|
110
|
+
return raw ?? null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parentFromUpdate(update) {
|
|
114
|
+
const meta = update?._meta ?? {};
|
|
115
|
+
const parent = meta.claudeCode?.parentToolUseId ?? meta.codex?.parentToolCallId;
|
|
116
|
+
return typeof parent === "string" && parent ? parent : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** ACP plan entries → the TodoWrite `todos` shape the dashboard renders. */
|
|
120
|
+
export function planEntriesToTodos(entries) {
|
|
121
|
+
return (Array.isArray(entries) ? entries : [])
|
|
122
|
+
.map((e) => ({
|
|
123
|
+
content: String(e?.content ?? ""),
|
|
124
|
+
status: e?.status === "completed" ? "completed" : e?.status === "in_progress" ? "in_progress" : "pending",
|
|
125
|
+
priority: typeof e?.priority === "string" ? e.priority : "medium",
|
|
126
|
+
}))
|
|
127
|
+
.filter((t) => t.content.length > 0);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** AskUserQuestion-style input → contract `questions[]`. */
|
|
131
|
+
export function normalizeQuestions(input) {
|
|
132
|
+
const raw = Array.isArray(input?.questions) ? input.questions : [];
|
|
133
|
+
return raw
|
|
134
|
+
.map((q) => ({
|
|
135
|
+
question: String(q?.question || ""),
|
|
136
|
+
header: String(q?.header || ""),
|
|
137
|
+
options: Array.isArray(q?.options)
|
|
138
|
+
? q.options.map((o) => ({
|
|
139
|
+
label: String(o?.label || ""),
|
|
140
|
+
description: String(o?.description || ""),
|
|
141
|
+
}))
|
|
142
|
+
: [],
|
|
143
|
+
multiSelect: q?.multiSelect === true || q?.multiple === true,
|
|
144
|
+
}))
|
|
145
|
+
.filter((q) => q.question.length > 0);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const hasInput = (v) => v != null && (typeof v !== "object" || Array.isArray(v) || Object.keys(v).length > 0);
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* ACP form elicitation (claude-agent-acp's AskUserQuestion bridge) →
|
|
152
|
+
* contract `questions[]`. The adapter encodes each question as
|
|
153
|
+
* `question_<n>` (`oneOf` = single choice, `items.anyOf` = multi) plus a
|
|
154
|
+
* free-text `question_<n>_custom`; a single question's text rides on
|
|
155
|
+
* `message`, multiple questions carry theirs in `description`.
|
|
156
|
+
*/
|
|
157
|
+
export function questionsFromElicitation(request) {
|
|
158
|
+
const props = request?.requestedSchema?.properties;
|
|
159
|
+
if (!props || typeof props !== "object") return [];
|
|
160
|
+
const keys = Object.keys(props)
|
|
161
|
+
.filter((k) => /^question_\d+$/.test(k))
|
|
162
|
+
.sort((a, b) => Number(a.slice(9)) - Number(b.slice(9)));
|
|
163
|
+
const single = keys.length === 1;
|
|
164
|
+
return keys
|
|
165
|
+
.map((k) => {
|
|
166
|
+
const field = props[k] || {};
|
|
167
|
+
const multiSelect = field.type === "array";
|
|
168
|
+
const opts = multiSelect ? field.items?.anyOf : field.oneOf;
|
|
169
|
+
return {
|
|
170
|
+
question: String((single ? request.message : field.description) || field.description || request.message || ""),
|
|
171
|
+
header: String(field.title || ""),
|
|
172
|
+
options: (Array.isArray(opts) ? opts : []).map((o) => ({
|
|
173
|
+
label: String(o?.const ?? o?.title ?? ""),
|
|
174
|
+
description: String(o?.description ?? ""),
|
|
175
|
+
})),
|
|
176
|
+
multiSelect,
|
|
177
|
+
};
|
|
178
|
+
})
|
|
179
|
+
.filter((q) => q.question.length > 0);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Flatten ACP content blocks / raw tool output into something emit-able. */
|
|
183
|
+
function contentToValue(content) {
|
|
184
|
+
if (content == null) return undefined;
|
|
185
|
+
if (typeof content === "string") return content;
|
|
186
|
+
if (typeof content === "object" && !Array.isArray(content) && typeof content.formatted_output === "string") {
|
|
187
|
+
const exit = content.exit_code;
|
|
188
|
+
return exit != null && exit !== 0 ? `${content.formatted_output}\n[exit ${exit}]` : content.formatted_output;
|
|
189
|
+
}
|
|
190
|
+
if (Array.isArray(content)) {
|
|
191
|
+
const parts = [];
|
|
192
|
+
for (const c of content) {
|
|
193
|
+
if (!c || typeof c !== "object") continue;
|
|
194
|
+
if (c.type === "content" && c.content?.type === "text") parts.push(String(c.content.text ?? ""));
|
|
195
|
+
else if (c.type === "text") parts.push(String(c.text ?? ""));
|
|
196
|
+
else if (c.type === "diff") parts.push(`diff ${c.path ?? ""}`);
|
|
197
|
+
else if (c.type === "terminal") parts.push(`[terminal ${c.terminalId ?? ""}]`);
|
|
198
|
+
else parts.push(JSON.stringify(c));
|
|
199
|
+
}
|
|
200
|
+
return parts.join("\n");
|
|
201
|
+
}
|
|
202
|
+
return content;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* @param {object} opts
|
|
207
|
+
* @param {(event: object) => void} opts.emit contract line sink
|
|
208
|
+
* @param {boolean} opts.isPlanMode plan agents hold prose for the result
|
|
209
|
+
* @param {(reason: string) => void} opts.onTurnShouldEnd question/plan asked → caller cancels the ACP turn
|
|
210
|
+
* @param {(model: string, tokens: number, window?: number) => void} [opts.onContextSnapshot]
|
|
211
|
+
*/
|
|
212
|
+
/**
|
|
213
|
+
* Permission policy for `request_permission`: build mode allows everything
|
|
214
|
+
* (the worktree/sandbox is the boundary); plan mode and artifact writers
|
|
215
|
+
* deny repo mutations — artifact writers may still write under `.kai/`.
|
|
216
|
+
* Belt-and-braces next to the session mode (`plan` / `dontAsk`) set on
|
|
217
|
+
* the agent: a mode the adapter fails to apply must not become a free
|
|
218
|
+
* pass for edits.
|
|
219
|
+
*/
|
|
220
|
+
export function permissionPolicy({ isPlanMode = false, isArtifactWriter = false, workDir = "" } = {}) {
|
|
221
|
+
const WRITE_TOOLS = /^(Write|Edit|MultiEdit|NotebookEdit)$/;
|
|
222
|
+
const READONLY_BASH = /^\s*(cat|head|tail|wc|stat|ls|find|grep|rg|git (log|diff|show|status|ls-files|grep|rev-parse|branch)|sed -n|awk|jq|sort|uniq|cut|tr|diff|nl|basename|dirname|realpath|file|echo|pwd|which)\b/;
|
|
223
|
+
const kaiDir = workDir ? `${workDir.replace(/\/+$/, "")}/.kai/` : "/.kai/";
|
|
224
|
+
return (name, input) => {
|
|
225
|
+
if (!isPlanMode && !isArtifactWriter) return true;
|
|
226
|
+
const canonical = canonicalToolName(name);
|
|
227
|
+
if (WRITE_TOOLS.test(canonical)) {
|
|
228
|
+
if (!isArtifactWriter) return false;
|
|
229
|
+
const path = String(input?.file_path ?? input?.filePath ?? input?.notebook_path ?? "");
|
|
230
|
+
return path.startsWith(kaiDir) || path.startsWith(".kai/");
|
|
231
|
+
}
|
|
232
|
+
if (canonical === "Bash") {
|
|
233
|
+
const cmd = String(input?.command ?? "");
|
|
234
|
+
// Allow plain read-only commands; anything with redirection or
|
|
235
|
+
// mutation verbs is denied (the post-turn revert catches leaks).
|
|
236
|
+
return READONLY_BASH.test(cmd) && !/[>|]|\b(rm|mv|cp|touch|mkdir|chmod|chown|git (add|commit|push|checkout|reset|rebase|merge|stash)|npm|pnpm|yarn|pip|make)\b/.test(cmd);
|
|
237
|
+
}
|
|
238
|
+
return true;
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function createAcpMapper({ emit, isPlanMode = false, onTurnShouldEnd, onContextSnapshot, mcpServerIds = {}, readPlanFile = () => "", allowTool = () => true }) {
|
|
243
|
+
/** toolCallId → { name, input, parent, emitted } */
|
|
244
|
+
const tools = new Map();
|
|
245
|
+
/** MCP server keys whose `connected` status already went out. */
|
|
246
|
+
const mcpConnectedEmitted = new Set();
|
|
247
|
+
|
|
248
|
+
// ACP carries no MCP boot/health signal. A tool call `mcp__<key>__…`
|
|
249
|
+
// that completes WITHOUT error is proof the server is connected — emit
|
|
250
|
+
// `mcp_status ok:true` once per server so the host's sticky-success
|
|
251
|
+
// rollup gets the signal it waits for (same inference as the claude
|
|
252
|
+
// runner's noteMcpTrafficSuccess).
|
|
253
|
+
const noteMcpTrafficSuccess = (rawName) => {
|
|
254
|
+
const m = /^mcp__([^_].*?)__/.exec(String(rawName || ""));
|
|
255
|
+
if (!m) return;
|
|
256
|
+
const key = m[1];
|
|
257
|
+
if (mcpConnectedEmitted.has(key)) return;
|
|
258
|
+
mcpConnectedEmitted.add(key);
|
|
259
|
+
emit({
|
|
260
|
+
type: "mcp_status",
|
|
261
|
+
message: `MCP ${key} connected`,
|
|
262
|
+
mcpStatus: { serverId: mcpServerIds[key] ?? key, ok: true },
|
|
263
|
+
});
|
|
264
|
+
};
|
|
265
|
+
let textBuffer = "";
|
|
266
|
+
let thoughtBuffer = "";
|
|
267
|
+
let lastText = "";
|
|
268
|
+
let questionAsked = false;
|
|
269
|
+
let planEmitted = false;
|
|
270
|
+
let lastPlanMarkdown = "";
|
|
271
|
+
let lastUsage = null; // { used, size, costUsd }
|
|
272
|
+
|
|
273
|
+
const flushThought = () => {
|
|
274
|
+
const t = thoughtBuffer.trim();
|
|
275
|
+
thoughtBuffer = "";
|
|
276
|
+
if (t) emit({ type: "thinking", message: t });
|
|
277
|
+
};
|
|
278
|
+
const flushText = () => {
|
|
279
|
+
const t = textBuffer.trim();
|
|
280
|
+
textBuffer = "";
|
|
281
|
+
if (!t) return;
|
|
282
|
+
lastText = t;
|
|
283
|
+
// Plan agents keep prose for the result-time fallback (same as the
|
|
284
|
+
// claude/opencode runners) — the plan itself arrives via ExitPlanMode.
|
|
285
|
+
if (!isPlanMode) emit({ type: "text", message: t });
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const emitToolStatus = (id, status, { output, error } = {}) => {
|
|
289
|
+
const meta = tools.get(id);
|
|
290
|
+
if (!meta) return;
|
|
291
|
+
const name = canonicalToolName(meta.name);
|
|
292
|
+
if (name === "TodoWrite") {
|
|
293
|
+
// Emit only when the input actually carries the list — an
|
|
294
|
+
// input-less announce would otherwise wipe the dashboard's todo
|
|
295
|
+
// stage with `[]` for a beat before the real update lands.
|
|
296
|
+
if (Array.isArray(meta.input?.todos)) {
|
|
297
|
+
emit({ type: "todos", message: JSON.stringify(meta.input.todos) });
|
|
298
|
+
}
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
// Adapter-provided titles ("List files in 'src'") stand in when the
|
|
302
|
+
// input carries nothing summarizeTool understands.
|
|
303
|
+
const summary = summarizeTool(name, meta.input || {}) || meta.title || "";
|
|
304
|
+
emit({
|
|
305
|
+
type: "tool_status",
|
|
306
|
+
message: summary ? `${name} ${summary}` : name,
|
|
307
|
+
toolName: name,
|
|
308
|
+
toolSummary: summary,
|
|
309
|
+
toolInput: meta.input != null ? sanitizeToolValue(meta.input, 4000) : undefined,
|
|
310
|
+
toolOutput: output != null ? sanitizeToolValue(output, 4000) : undefined,
|
|
311
|
+
toolError: error != null ? sanitizeToolValue(error, 1000) : undefined,
|
|
312
|
+
toolStatus: status,
|
|
313
|
+
toolPartId: id,
|
|
314
|
+
...(meta.parent ? { parentToolPartId: meta.parent } : {}),
|
|
315
|
+
});
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const isTurnEndingTool = (name) => {
|
|
319
|
+
const canonical = canonicalToolName(name);
|
|
320
|
+
return canonical === QUESTION_TOOL || canonical === EXIT_PLAN_TOOL || /(^|__|\.)ask_user$/.test(String(name));
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
/** Remember plan-file writes so a question / hand-off can surface the plan. */
|
|
324
|
+
const notePlanWrite = (name, input) => {
|
|
325
|
+
if (!isPlanMode || !/^(Write|Edit|MultiEdit)$/.test(canonicalToolName(name))) return;
|
|
326
|
+
const body = typeof input?.content === "string" ? input.content : "";
|
|
327
|
+
const path = String(input?.file_path ?? input?.filePath ?? "");
|
|
328
|
+
if (body && /plan/i.test(path)) lastPlanMarkdown = body.trim();
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/** A turn-ending tool surfaced (question or plan hand-off). */
|
|
332
|
+
const handleTurnEndingTool = (name, input) => {
|
|
333
|
+
const canonical = canonicalToolName(name);
|
|
334
|
+
if (canonical === QUESTION_TOOL || /(^|__|\.)ask_user$/.test(String(name))) {
|
|
335
|
+
const questions = normalizeQuestions(input);
|
|
336
|
+
if (questions.length === 0 || questionAsked) return false;
|
|
337
|
+
questionAsked = true;
|
|
338
|
+
flushThought();
|
|
339
|
+
flushText();
|
|
340
|
+
emit({
|
|
341
|
+
type: "question",
|
|
342
|
+
message: questions.length === 1 ? "Asking a question…" : `Asking ${questions.length} questions…`,
|
|
343
|
+
questions,
|
|
344
|
+
});
|
|
345
|
+
if (isPlanMode && lastPlanMarkdown) emit({ type: "plan", message: lastPlanMarkdown });
|
|
346
|
+
onTurnShouldEnd?.("question");
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
if (canonical === EXIT_PLAN_TOOL) {
|
|
350
|
+
if (questionAsked || planEmitted) return true;
|
|
351
|
+
// ExitPlanMode's own `plan` input, else the last plan-file write we
|
|
352
|
+
// saw, else the newest file in the plans directory (the CLI writes
|
|
353
|
+
// there itself on 2.1.24x and only references it by path).
|
|
354
|
+
const markdown = (typeof input?.plan === "string" && input.plan.trim()) || lastPlanMarkdown || String(readPlanFile() || "").trim();
|
|
355
|
+
if (!markdown) return false;
|
|
356
|
+
planEmitted = true;
|
|
357
|
+
flushThought();
|
|
358
|
+
flushText();
|
|
359
|
+
emit({ type: "plan", message: markdown });
|
|
360
|
+
onTurnShouldEnd?.("plan");
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
return false;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
/** Feed one `session/update` notification's `update` payload. */
|
|
368
|
+
handleUpdate(update) {
|
|
369
|
+
if (!update || typeof update !== "object") return;
|
|
370
|
+
switch (update.sessionUpdate) {
|
|
371
|
+
case "agent_thought_chunk": {
|
|
372
|
+
flushText();
|
|
373
|
+
if (update.content?.type === "text") thoughtBuffer += update.content.text ?? "";
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
case "agent_message_chunk": {
|
|
377
|
+
flushThought();
|
|
378
|
+
if (update.content?.type === "text") textBuffer += update.content.text ?? "";
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
case "tool_call": {
|
|
382
|
+
flushThought();
|
|
383
|
+
flushText();
|
|
384
|
+
const id = String(update.toolCallId ?? "");
|
|
385
|
+
if (!id) return;
|
|
386
|
+
const name = toolNameFromUpdate(update);
|
|
387
|
+
const input = normalizeToolInput(name, update);
|
|
388
|
+
notePlanWrite(name, input);
|
|
389
|
+
if (handleTurnEndingTool(name, input)) return;
|
|
390
|
+
if (isTurnEndingTool(name)) return; // input still streaming — the elicitation/permission carries it
|
|
391
|
+
const existing = tools.get(id);
|
|
392
|
+
const meta = {
|
|
393
|
+
name,
|
|
394
|
+
input: hasInput(input) ? input : existing?.input ?? null,
|
|
395
|
+
title: typeof update.title === "string" && update.title !== name ? update.title : existing?.title ?? "",
|
|
396
|
+
parent: parentFromUpdate(update),
|
|
397
|
+
emitted: existing?.emitted ?? false,
|
|
398
|
+
};
|
|
399
|
+
tools.set(id, meta);
|
|
400
|
+
// Adapters announce the call before the streamed input is
|
|
401
|
+
// complete (`rawInput: {}`); hold the "running" row until the
|
|
402
|
+
// input lands so the dashboard summary isn't blank.
|
|
403
|
+
if (!meta.emitted && (hasInput(meta.input) || (meta.title && update.status === "in_progress"))) {
|
|
404
|
+
meta.emitted = true;
|
|
405
|
+
emitToolStatus(id, "running");
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
case "tool_call_update": {
|
|
410
|
+
const id = String(update.toolCallId ?? "");
|
|
411
|
+
const meta = tools.get(id);
|
|
412
|
+
// Turn-ending tools (question / plan hand-off) are rendered as their
|
|
413
|
+
// own cards by handleTurnEndingTool / handleElicitation — never as
|
|
414
|
+
// a tool row, even when their input arrives late.
|
|
415
|
+
if (meta && isTurnEndingTool(meta.name)) {
|
|
416
|
+
tools.delete(id);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (!meta) {
|
|
420
|
+
// Update for a call we never saw start (adapter cache miss or
|
|
421
|
+
// turn-ending tool) — nothing to pair it with.
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
// Late-arriving input: adapters often attach the real payload
|
|
425
|
+
// (rawInput, or diff content blocks) only on the completion
|
|
426
|
+
// update — and claude-agent-acp's rawInput can be PARTIAL
|
|
427
|
+
// (old_string without new_string). Merge instead of replace:
|
|
428
|
+
// the earliest value wins per key, later updates only fill
|
|
429
|
+
// holes, so a path-only or half-truncated stub still ends up
|
|
430
|
+
// carrying the full edit for the dashboard's diff view.
|
|
431
|
+
{
|
|
432
|
+
const richer = normalizeToolInput(meta.name, update);
|
|
433
|
+
if (hasInput(richer) && typeof richer === "object" && !Array.isArray(richer)) {
|
|
434
|
+
if (!hasInput(meta.input) || typeof meta.input !== "object" || Array.isArray(meta.input)) {
|
|
435
|
+
meta.input = richer;
|
|
436
|
+
notePlanWrite(meta.name, meta.input);
|
|
437
|
+
} else {
|
|
438
|
+
let filled = false;
|
|
439
|
+
for (const [key, value] of Object.entries(richer)) {
|
|
440
|
+
if (meta.input[key] == null) {
|
|
441
|
+
meta.input[key] = value;
|
|
442
|
+
filled = true;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (filled) notePlanWrite(meta.name, meta.input);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (!meta.emitted && (hasInput(meta.input) || update.status === "completed" || update.status === "failed")) {
|
|
450
|
+
meta.emitted = true;
|
|
451
|
+
emitToolStatus(id, "running");
|
|
452
|
+
}
|
|
453
|
+
if (update.status === "completed" || update.status === "failed") {
|
|
454
|
+
if (update.status === "completed") noteMcpTrafficSuccess(meta.name);
|
|
455
|
+
const output = contentToValue(update.rawOutput ?? update.content);
|
|
456
|
+
emitToolStatus(
|
|
457
|
+
id,
|
|
458
|
+
update.status === "failed" ? "failed" : "completed",
|
|
459
|
+
update.status === "failed" ? { error: output } : { output },
|
|
460
|
+
);
|
|
461
|
+
tools.delete(id);
|
|
462
|
+
}
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
case "plan": {
|
|
466
|
+
// ACP "plan" = the agent's task list (TodoWrite equivalent), NOT
|
|
467
|
+
// the planner's markdown hand-off.
|
|
468
|
+
emit({ type: "todos", message: JSON.stringify(planEntriesToTodos(update.entries)) });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
case "usage_update": {
|
|
472
|
+
const used = Number(update.used);
|
|
473
|
+
const size = Number(update.size);
|
|
474
|
+
const costUsd = Number(update.cost?.amount);
|
|
475
|
+
lastUsage = {
|
|
476
|
+
used: Number.isFinite(used) ? used : null,
|
|
477
|
+
size: Number.isFinite(size) && size > 0 ? size : null,
|
|
478
|
+
costUsd: Number.isFinite(costUsd) ? costUsd : null,
|
|
479
|
+
};
|
|
480
|
+
if (lastUsage.used != null && lastUsage.used > 0) {
|
|
481
|
+
onContextSnapshot?.(undefined, lastUsage.used, lastUsage.size ?? undefined);
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
case "compaction_update":
|
|
486
|
+
case "compaction_summary_chunk":
|
|
487
|
+
case "current_mode_update":
|
|
488
|
+
case "config_option_update":
|
|
489
|
+
case "available_commands_update":
|
|
490
|
+
case "session_info_update":
|
|
491
|
+
case "user_message_chunk":
|
|
492
|
+
case "plan_update":
|
|
493
|
+
case "plan_removed":
|
|
494
|
+
default:
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* A `session/request_permission` arrived. Returns the option id to
|
|
501
|
+
* answer with, or `null` when the turn should be cancelled instead
|
|
502
|
+
* (question / plan hand-off). Build mode allows everything — the
|
|
503
|
+
* sandbox/worktree IS the permission boundary, same as today's
|
|
504
|
+
* `--dangerously-skip-permissions`.
|
|
505
|
+
*/
|
|
506
|
+
handlePermission(params) {
|
|
507
|
+
const toolCall = params?.toolCall ?? {};
|
|
508
|
+
const name = toolNameFromUpdate(toolCall);
|
|
509
|
+
const input = toolCall.rawInput ?? null;
|
|
510
|
+
if (handleTurnEndingTool(name, input)) return null;
|
|
511
|
+
const options = Array.isArray(params?.options) ? params.options : [];
|
|
512
|
+
const pick = (kind) => options.find((o) => o?.kind === kind)?.optionId;
|
|
513
|
+
if (!allowTool(name, input)) {
|
|
514
|
+
emit({
|
|
515
|
+
type: "tool_status",
|
|
516
|
+
message: `${canonicalToolName(name)} blocked (read-only mode)`,
|
|
517
|
+
toolName: canonicalToolName(name),
|
|
518
|
+
toolSummary: "blocked (read-only mode)",
|
|
519
|
+
toolInput: input != null ? sanitizeToolValue(input, 1000) : undefined,
|
|
520
|
+
toolStatus: "failed",
|
|
521
|
+
toolError: "Not allowed in this mode.",
|
|
522
|
+
toolPartId: String(toolCall.toolCallId ?? `deny-${Date.now()}`),
|
|
523
|
+
});
|
|
524
|
+
return pick("reject_once") ?? pick("reject_always") ?? "__reject__";
|
|
525
|
+
}
|
|
526
|
+
return pick("allow_always") ?? pick("allow_once") ?? options[0]?.optionId ?? null;
|
|
527
|
+
},
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* A form elicitation arrived (AskUserQuestion via claude-agent-acp).
|
|
531
|
+
* Emits the question event and asks the runner to end the turn; the
|
|
532
|
+
* answers arrive as the next user turn. Returns true when handled.
|
|
533
|
+
*/
|
|
534
|
+
handleElicitation(request) {
|
|
535
|
+
if (request?.mode && request.mode !== "form") return false;
|
|
536
|
+
const questions = questionsFromElicitation(request);
|
|
537
|
+
if (questions.length === 0 || questionAsked) return false;
|
|
538
|
+
questionAsked = true;
|
|
539
|
+
flushThought();
|
|
540
|
+
flushText();
|
|
541
|
+
emit({
|
|
542
|
+
type: "question",
|
|
543
|
+
message: questions.length === 1 ? "Asking a question…" : `Asking ${questions.length} questions…`,
|
|
544
|
+
questions,
|
|
545
|
+
});
|
|
546
|
+
if (isPlanMode && lastPlanMarkdown) emit({ type: "plan", message: lastPlanMarkdown });
|
|
547
|
+
onTurnShouldEnd?.("question");
|
|
548
|
+
return true;
|
|
549
|
+
},
|
|
550
|
+
|
|
551
|
+
/** End-of-turn bookkeeping; returns what the runner needs for `result`. */
|
|
552
|
+
finish() {
|
|
553
|
+
flushThought();
|
|
554
|
+
flushText();
|
|
555
|
+
for (const id of [...tools.keys()]) {
|
|
556
|
+
// Calls still open when the agent stopped — resolve so the
|
|
557
|
+
// dashboard row never spins forever.
|
|
558
|
+
const meta = tools.get(id);
|
|
559
|
+
if (!meta.emitted) {
|
|
560
|
+
meta.emitted = true;
|
|
561
|
+
emitToolStatus(id, "running");
|
|
562
|
+
}
|
|
563
|
+
emitToolStatus(id, "completed", {});
|
|
564
|
+
tools.delete(id);
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
lastText,
|
|
568
|
+
questionAsked,
|
|
569
|
+
planEmitted,
|
|
570
|
+
lastPlanMarkdown,
|
|
571
|
+
usage: lastUsage,
|
|
572
|
+
};
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
}
|