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