From 5da415f9e34fe04429c7ee5d413fea1fa51f4da1 Mon Sep 17 00:00:00 2001 From: osherove Date: Wed, 20 May 2026 13:08:56 +0000 Subject: [PATCH 1/5] fix(kiro): update ACP adapter for kiro-cli 2.x protocol kiro-cli 2.x moved ACP to a top-level subcommand and changed the JSON-RPC protocol in several breaking ways. This updates the adapter to match the current protocol: - Spawn `kiro-cli acp --agent ` instead of `kiro-cli chat --agent --acp` - Send `protocolVersion` as integer `1` (not string "1.0") - Pass required `cwd` and `mcpServers` params to `session/new` - Use `prompt` field (content block array) for `session/prompt` - Handle `session/update` notifications with `update.sessionUpdate` discriminator instead of separate method-name events - Derive turn completion from the `session/prompt` RPC response Co-Authored-By: Claude Sonnet 4.5 --- src/agents/kiro/acp/process.ts | 2 +- src/agents/kiro/kiro-adapter.ts | 133 ++++++++++++++------------------ 2 files changed, 58 insertions(+), 77 deletions(-) diff --git a/src/agents/kiro/acp/process.ts b/src/agents/kiro/acp/process.ts index 0515923..a612459 100644 --- a/src/agents/kiro/acp/process.ts +++ b/src/agents/kiro/acp/process.ts @@ -31,7 +31,7 @@ export interface AcpProcess { export function spawnKiroCli(opts: SpawnOptions): AcpProcess { const { agentName, cwd, env, maxStderrBytes = 1_048_576 } = opts; - const proc = spawn("kiro-cli", ["chat", "--agent", agentName, "--acp"], { + const proc = spawn("kiro-cli", ["acp", "--agent", agentName], { cwd, env: { ...process.env, ...env }, stdio: ["pipe", "pipe", "pipe"], diff --git a/src/agents/kiro/kiro-adapter.ts b/src/agents/kiro/kiro-adapter.ts index 478c556..fa08612 100644 --- a/src/agents/kiro/kiro-adapter.ts +++ b/src/agents/kiro/kiro-adapter.ts @@ -192,7 +192,8 @@ class KiroAdapter extends BaseAdapter { this.mainProcess = spawnKiroCli({ agentName: this.config.agentName, cwd: this.config.cwd }); await this.mainProcess.client.call("initialize", { - protocolVersion: "1.0", + protocolVersion: 1, + clientCapabilities: { terminal: true }, clientInfo: { name: "roundhouse", version: ROUNDHOUSE_VERSION }, }); @@ -230,7 +231,7 @@ class KiroAdapter extends BaseAdapter { } } - const result = await proc.client.call("session/new", {}); + const result = await proc.client.call("session/new", { cwd: this.config.cwd, mcpServers: [] }); const entry: SessionEntry = { sessionId: result.sessionId, threadId, @@ -255,44 +256,40 @@ class KiroAdapter extends BaseAdapter { const proc = this.mainProcess!; const text = this.formatMessage(message); - const responsePromise = proc.client.call("session/prompt", { - sessionId: session.sessionId, - text, - }); - let fullText = ""; - const onTextChunk = (params: any) => { - if (params?.sessionId === session.sessionId) { - fullText += params.text ?? ""; - } - }; - - const onPermission = (params: any) => { - if (params?.sessionId === session.sessionId) { - proc.client.notify("permission/response", { - tool_call_id: params.tool_call_id, - decision: "approved", - }); + const onSessionUpdate = (params: any) => { + if (params?.sessionId !== session.sessionId) return; + const update = params.update; + if (!update) return; + if (update.sessionUpdate === "agent_message_chunk" && update.content?.text) { + fullText += update.content.text; } }; - const onSessionUpdate = (params: any) => { - if (params?.sessionId === session.sessionId) { - this.store.updateContext(threadId, params.context_tokens ?? null, params.context_window ?? null, params.model); + const onMetadata = (params: any) => { + if (params?.sessionId !== session.sessionId) return; + const pct = params.contextUsagePercentage; + if (pct != null) { + const window = 200000; + const tokens = Math.round((pct / 100) * window); + this.store.updateContext(threadId, tokens, window, params.model ?? null); } }; - proc.client.on("text_chunk", onTextChunk); - proc.client.on("permission_request", onPermission); proc.client.on("session/update", onSessionUpdate); + proc.client.on("_kiro.dev/session/update", onSessionUpdate); + proc.client.on("_kiro.dev/metadata", onMetadata); try { - await responsePromise; + await proc.client.call("session/prompt", { + sessionId: session.sessionId, + prompt: [{ type: "text", text }], + }); } finally { - proc.client.off("text_chunk", onTextChunk); - proc.client.off("permission_request", onPermission); proc.client.off("session/update", onSessionUpdate); + proc.client.off("_kiro.dev/session/update", onSessionUpdate); + proc.client.off("_kiro.dev/metadata", onMetadata); } return { text: fullText }; @@ -319,59 +316,46 @@ class KiroAdapter extends BaseAdapter { resolveWait?.(); } - const onTextChunk = (params: any) => { - if (params?.sessionId === session.sessionId) { - push({ type: "text_delta", text: params.text ?? "" }); - } - }; - - const onToolCall = (params: any) => { - if (params?.sessionId === session.sessionId) { - push({ type: "tool_start", toolName: normalizeToolName(params.title ?? params.tool_name ?? ""), toolCallId: params.tool_call_id }); - } - }; - - const onToolResult = (params: any) => { - if (params?.sessionId === session.sessionId) { - push({ type: "tool_end", toolName: normalizeToolName(params.tool_name ?? ""), toolCallId: params.tool_call_id, isError: (params.exit_code ?? 0) !== 0 }); - } - }; - - const onPermission = (params: any) => { - if (params?.sessionId === session.sessionId) { - proc.client.notify("permission/response", { - tool_call_id: params.tool_call_id, - decision: "approved", - }); - } - }; - - const onComplete = (params: any) => { - if (params?.sessionId === session.sessionId) { - if (params.stop_reason === "end_turn") { - push({ type: "turn_end" }); - } - push({ type: "agent_end" }); - done = true; - resolveWait?.(); + const onSessionUpdate = (params: any) => { + if (params?.sessionId !== session.sessionId) return; + const update = params.update; + if (!update) return; + switch (update.sessionUpdate) { + case "agent_message_chunk": + if (update.content?.text) { + push({ type: "text_delta", text: update.content.text }); + } + break; + case "tool_call": + push({ type: "tool_start", toolName: normalizeToolName(update.title ?? ""), toolCallId: update.toolCallId ?? "" }); + break; + case "tool_call_update": + push({ type: "tool_end", toolName: normalizeToolName(update.title ?? ""), toolCallId: update.toolCallId ?? "", isError: false }); + break; } }; - const onSessionUpdate = (params: any) => { - if (params?.sessionId === session.sessionId) { - this.store.updateContext(threadId, params.context_tokens ?? null, params.context_window ?? null, params.model); + const onMetadata = (params: any) => { + if (params?.sessionId !== session.sessionId) return; + const pct = params.contextUsagePercentage; + if (pct != null) { + const window = 200000; + const tokens = Math.round((pct / 100) * window); + this.store.updateContext(threadId, tokens, window, params.model ?? null); } }; - proc.client.on("text_chunk", onTextChunk); - proc.client.on("tool_call", onToolCall); - proc.client.on("tool_result", onToolResult); - proc.client.on("permission_request", onPermission); - proc.client.on("complete", onComplete); proc.client.on("session/update", onSessionUpdate); + proc.client.on("_kiro.dev/session/update", onSessionUpdate); + proc.client.on("_kiro.dev/metadata", onMetadata); // Fire the prompt (don't await — events stream in) - proc.client.call("session/prompt", { sessionId: session.sessionId, text }).catch((err) => { + proc.client.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text }] }).then(() => { + push({ type: "turn_end" }); + push({ type: "agent_end" }); + done = true; + resolveWait?.(); + }).catch((err) => { push({ type: "agent_end" }); done = true; promptError = err; @@ -389,12 +373,9 @@ class KiroAdapter extends BaseAdapter { } if (promptError) throw promptError; } finally { - proc.client.off("text_chunk", onTextChunk); - proc.client.off("tool_call", onToolCall); - proc.client.off("tool_result", onToolResult); - proc.client.off("permission_request", onPermission); - proc.client.off("complete", onComplete); proc.client.off("session/update", onSessionUpdate); + proc.client.off("_kiro.dev/session/update", onSessionUpdate); + proc.client.off("_kiro.dev/metadata", onMetadata); } } finally { this.store.markInFlight(threadId, false); From d5e09d07bfe0568691e1c90fa2f3cca08c58dd05 Mon Sep 17 00:00:00 2001 From: osherove Date: Wed, 20 May 2026 13:15:14 +0000 Subject: [PATCH 2/5] fix(kiro): disable timeout for session/prompt RPC calls Agent turns can run for minutes (tool use, reading files, etc.). The 60s default timeout on the ACP client would kill the call mid-turn. Pass timeout=0 to disable the timer for session/prompt while keeping the default 60s timeout for short-lived calls like initialize and session/new. Co-Authored-By: Claude Sonnet 4.5 --- src/agents/kiro/acp/client.ts | 15 ++++++++------- src/agents/kiro/kiro-adapter.ts | 4 ++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/agents/kiro/acp/client.ts b/src/agents/kiro/acp/client.ts index ea3f0a9..a5d3ba7 100644 --- a/src/agents/kiro/acp/client.ts +++ b/src/agents/kiro/acp/client.ts @@ -11,7 +11,7 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; export interface PendingRequest { resolve: (value: unknown) => void; reject: (error: unknown) => void; - timer: ReturnType; + timer: ReturnType | null; } export class AcpClient extends EventEmitter { @@ -31,18 +31,19 @@ export class AcpClient extends EventEmitter { } /** Send a JSON-RPC request and await its response. */ - async call(method: string, params?: unknown): Promise { + async call(method: string, params?: unknown, timeoutMs?: number): Promise { if (this.closed) throw new Error("ACP client is closed"); const id = this.nextId++; const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? {} }); this.proc.stdin.write(payload + "\n"); + const timeout = timeoutMs ?? this.requestTimeoutMs; return new Promise((resolve, reject) => { - const timer = setTimeout(() => { + const timer = timeout > 0 ? setTimeout(() => { this.pending.delete(id); - reject(new Error(`ACP call "${method}" timed out after ${this.requestTimeoutMs}ms`)); - }, this.requestTimeoutMs); + reject(new Error(`ACP call "${method}" timed out after ${timeout}ms`)); + }, timeout) : null; this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer }); }); @@ -90,7 +91,7 @@ export class AcpClient extends EventEmitter { // Response to a pending request if ("id" in msg && typeof msg.id === "number" && this.pending.has(msg.id)) { const { resolve, reject, timer } = this.pending.get(msg.id)!; - clearTimeout(timer); + if (timer) clearTimeout(timer); this.pending.delete(msg.id); if (msg.error) reject(msg.error); else resolve(msg.result); @@ -109,7 +110,7 @@ export class AcpClient extends EventEmitter { private rejectAll(error: Error): void { for (const [id, { reject, timer }] of this.pending) { - clearTimeout(timer); + if (timer) clearTimeout(timer); reject(error); } this.pending.clear(); diff --git a/src/agents/kiro/kiro-adapter.ts b/src/agents/kiro/kiro-adapter.ts index fa08612..f69454f 100644 --- a/src/agents/kiro/kiro-adapter.ts +++ b/src/agents/kiro/kiro-adapter.ts @@ -285,7 +285,7 @@ class KiroAdapter extends BaseAdapter { await proc.client.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text }], - }); + }, 0); } finally { proc.client.off("session/update", onSessionUpdate); proc.client.off("_kiro.dev/session/update", onSessionUpdate); @@ -350,7 +350,7 @@ class KiroAdapter extends BaseAdapter { proc.client.on("_kiro.dev/metadata", onMetadata); // Fire the prompt (don't await — events stream in) - proc.client.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text }] }).then(() => { + proc.client.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text }] }, 0).then(() => { push({ type: "turn_end" }); push({ type: "agent_end" }); done = true; From 20aad27a291085650e82b4c5064f276f115bfac5 Mon Sep 17 00:00:00 2001 From: osherove Date: Wed, 20 May 2026 18:41:07 +0000 Subject: [PATCH 3/5] fix(kiro): recover from stale persisted session id and pass --trust-all-tools When a thread had a persisted sessionId from an earlier run, the adapter called session/load on a fresh kiro-cli process. kiro-cli responds to an unknown sessionId by exiting cleanly (code 0) instead of returning a JSON-RPC error, which closed the AcpClient. The catch around session/load swallowed the rejection, but the subsequent fallback to session/new then ran on a dead process and failed with "ACP client is closed". Recovery now: - Clears the stale persisted id via SessionStore.clearPersistedSessionId - Detects that the AcpClient closed and respawns kiro-cli before falling through to session/new - Logs stderr on unexpected kiro-cli exits to surface protocol errors instead of failing silently Also pass --trust-all-tools to the spawn so tool-use prompts don't block waiting for permission responses we don't implement. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/agents/kiro/acp/process.ts | 2 +- src/agents/kiro/kiro-adapter.ts | 18 ++++++++++++++++-- src/agents/kiro/session.ts | 8 +++++++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/agents/kiro/acp/process.ts b/src/agents/kiro/acp/process.ts index a612459..c7ea1d1 100644 --- a/src/agents/kiro/acp/process.ts +++ b/src/agents/kiro/acp/process.ts @@ -31,7 +31,7 @@ export interface AcpProcess { export function spawnKiroCli(opts: SpawnOptions): AcpProcess { const { agentName, cwd, env, maxStderrBytes = 1_048_576 } = opts; - const proc = spawn("kiro-cli", ["acp", "--agent", agentName], { + const proc = spawn("kiro-cli", ["acp", "--agent", agentName, "--trust-all-tools"], { cwd, env: { ...process.env, ...env }, stdio: ["pipe", "pipe", "pipe"], diff --git a/src/agents/kiro/kiro-adapter.ts b/src/agents/kiro/kiro-adapter.ts index f69454f..97648c0 100644 --- a/src/agents/kiro/kiro-adapter.ts +++ b/src/agents/kiro/kiro-adapter.ts @@ -191,6 +191,12 @@ class KiroAdapter extends BaseAdapter { this.mainProcess = spawnKiroCli({ agentName: this.config.agentName, cwd: this.config.cwd }); + this.mainProcess.proc.on("exit", (code, signal) => { + if (this.mainProcess?.stderr.length) { + console.error(`[kiro] process exited code=${code} signal=${signal}; stderr:\n${this.mainProcess.stderr.join("")}`); + } + }); + await this.mainProcess.client.call("initialize", { protocolVersion: 1, clientCapabilities: { terminal: true }, @@ -208,7 +214,7 @@ class KiroAdapter extends BaseAdapter { const existing = this.store.get(threadId); if (existing) return existing; - const proc = await this.ensureProcess(); + let proc = await this.ensureProcess(); const persistedId = this.store.loadPersistedSessionId(threadId); if (persistedId) { @@ -227,7 +233,15 @@ class KiroAdapter extends BaseAdapter { this.store.set(threadId, entry); return entry; } catch { - // Session no longer valid — create new + // Session no longer valid. kiro-cli sometimes responds to an unknown + // sessionId by exiting (rather than returning a JSON-RPC error), which + // closes the AcpClient. Clear the stale persisted id and respawn before + // falling through to session/new. + this.store.clearPersistedSessionId(threadId); + if (this.mainProcess?.client.isClosed) { + this.mainProcess = null; + proc = await this.ensureProcess(); + } } } diff --git a/src/agents/kiro/session.ts b/src/agents/kiro/session.ts index 464b7eb..6513457 100644 --- a/src/agents/kiro/session.ts +++ b/src/agents/kiro/session.ts @@ -6,7 +6,7 @@ */ import { resolve } from "node:path"; -import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from "node:fs"; +import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, unlinkSync } from "node:fs"; import { randomBytes } from "node:crypto"; export interface SessionEntry { @@ -97,6 +97,12 @@ export class SessionStore { } } + /** Remove the persisted session id file so the next ensureSession starts fresh. */ + clearPersistedSessionId(threadId: string): void { + const filePath = this.sessionFilePath(threadId); + try { unlinkSync(filePath); } catch {} + } + // ── Private ────────────────────────────────────────── private persistSession(threadId: string, entry: SessionEntry): void { From 1abf19b3ef171ddf5527ad4a8e97fbe9d1378522 Mon Sep 17 00:00:00 2001 From: osherove Date: Wed, 20 May 2026 18:52:32 +0000 Subject: [PATCH 4/5] refactor(kiro): extract ACP method/event constants and fix exit-listener closure capture Two changes from a code review pass: 1. Extracted JSON-RPC method names, notification names, and session/update discriminator values into src/agents/kiro/acp/methods.ts as AcpMethod / AcpEvent / SessionUpdateKind. Adapter call sites and subscribe/unsubscribe pairs now reference the constants. A protocol rev now touches one file instead of every call site, and typos at call sites become compile errors. 2. The exit listener in ensureProcess() referenced this.mainProcess from inside its closure. If the process exited *after* a respawn replaced this.mainProcess, the listener would read the new process's stderr instead of the dead one's. The listener now captures the AcpProcess in a local so it always reports stderr for the process it was registered on. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/agents/kiro/acp/index.ts | 1 + src/agents/kiro/acp/methods.ts | 31 +++++++++++++++++ src/agents/kiro/kiro-adapter.ts | 59 +++++++++++++++++---------------- 3 files changed, 63 insertions(+), 28 deletions(-) create mode 100644 src/agents/kiro/acp/methods.ts diff --git a/src/agents/kiro/acp/index.ts b/src/agents/kiro/acp/index.ts index c2b6339..f7b02b1 100644 --- a/src/agents/kiro/acp/index.ts +++ b/src/agents/kiro/acp/index.ts @@ -6,3 +6,4 @@ export { AcpClient } from "./client.js"; export { spawnKiroCli, shutdownProcess, getKiroCliVersion } from "./process.js"; export type { AcpProcess, SpawnOptions } from "./process.js"; export type * from "./types.js"; +export { AcpMethod, AcpEvent, SessionUpdateKind } from "./methods.js"; diff --git a/src/agents/kiro/acp/methods.ts b/src/agents/kiro/acp/methods.ts new file mode 100644 index 0000000..08b1838 --- /dev/null +++ b/src/agents/kiro/acp/methods.ts @@ -0,0 +1,31 @@ +/** + * acp/methods.ts — JSON-RPC method names and event discriminators used by + * kiro-cli's ACP protocol. Centralized here so a protocol rev only touches + * this file. + */ + +/** Methods the client calls on the agent. */ +export const AcpMethod = { + Initialize: "initialize", + SessionNew: "session/new", + SessionLoad: "session/load", + SessionPrompt: "session/prompt", + SessionCancel: "session/cancel", + /** Internal kiro extension — e.g. running `/compact`. */ + KiroCommandsExecute: "_kiro.dev/commands/execute", +} as const; + +/** Notifications the agent emits to the client. */ +export const AcpEvent = { + SessionUpdate: "session/update", + /** Internal kiro variant emitted alongside `session/update` for some payloads. */ + KiroSessionUpdate: "_kiro.dev/session/update", + KiroMetadata: "_kiro.dev/metadata", +} as const; + +/** Discriminator values inside a `session/update` notification's `update.sessionUpdate`. */ +export const SessionUpdateKind = { + AgentMessageChunk: "agent_message_chunk", + ToolCall: "tool_call", + ToolCallUpdate: "tool_call_update", +} as const; diff --git a/src/agents/kiro/kiro-adapter.ts b/src/agents/kiro/kiro-adapter.ts index 97648c0..2a8efd7 100644 --- a/src/agents/kiro/kiro-adapter.ts +++ b/src/agents/kiro/kiro-adapter.ts @@ -15,7 +15,7 @@ import { resolve } from "node:path"; import type { AgentAdapterFactory, AgentMessage, AgentResponse, AgentStreamEvent, AdapterInfo, MessageContext } from "../../types.js"; import { ROUNDHOUSE_VERSION } from "../../config.js"; import { BaseAdapter } from "../base-adapter.js"; -import { spawnKiroCli, shutdownProcess, getKiroCliVersion, type AcpProcess, type InitializeResult, type SessionNewResult } from "./acp/index.js"; +import { spawnKiroCli, shutdownProcess, getKiroCliVersion, AcpMethod, AcpEvent, SessionUpdateKind, type AcpProcess, type InitializeResult, type SessionNewResult } from "./acp/index.js"; import { SessionStore, type SessionEntry } from "./session.js"; import { normalizeToolName } from "./tool-names.js"; @@ -141,7 +141,7 @@ class KiroAdapter extends BaseAdapter { async abort(threadId: string): Promise { const session = this.store.get(threadId); if (!session || !this.mainProcess) return; - await this.mainProcess.client.call("session/cancel", { sessionId: session.sessionId }).catch(() => {}); + await this.mainProcess.client.call(AcpMethod.SessionCancel, { sessionId: session.sessionId }).catch(() => {}); } async restart(threadId: string): Promise { @@ -154,7 +154,7 @@ class KiroAdapter extends BaseAdapter { if (!session || !this.mainProcess) return null; const before = session.contextTokens ?? 0; - await this.mainProcess.client.call("_kiro.dev/commands/execute", { + await this.mainProcess.client.call(AcpMethod.KiroCommandsExecute, { sessionId: session.sessionId, command: "/compact", }); @@ -189,15 +189,18 @@ class KiroAdapter extends BaseAdapter { private async ensureProcess(): Promise { if (this.mainProcess && !this.mainProcess.client.isClosed) return this.mainProcess; - this.mainProcess = spawnKiroCli({ agentName: this.config.agentName, cwd: this.config.cwd }); + const acpProc = spawnKiroCli({ agentName: this.config.agentName, cwd: this.config.cwd }); + this.mainProcess = acpProc; - this.mainProcess.proc.on("exit", (code, signal) => { - if (this.mainProcess?.stderr.length) { - console.error(`[kiro] process exited code=${code} signal=${signal}; stderr:\n${this.mainProcess.stderr.join("")}`); + // Capture acpProc in the closure so the listener always reports stderr + // for *this* process even if `this.mainProcess` is reassigned later. + acpProc.proc.on("exit", (code, signal) => { + if (acpProc.stderr.length) { + console.error(`[kiro] process exited code=${code} signal=${signal}; stderr:\n${acpProc.stderr.join("")}`); } }); - await this.mainProcess.client.call("initialize", { + await acpProc.client.call(AcpMethod.Initialize, { protocolVersion: 1, clientCapabilities: { terminal: true }, clientInfo: { name: "roundhouse", version: ROUNDHOUSE_VERSION }, @@ -219,7 +222,7 @@ class KiroAdapter extends BaseAdapter { const persistedId = this.store.loadPersistedSessionId(threadId); if (persistedId) { try { - await proc.client.call("session/load", { sessionId: persistedId }); + await proc.client.call(AcpMethod.SessionLoad, { sessionId: persistedId }); const entry: SessionEntry = { sessionId: persistedId, threadId, @@ -245,7 +248,7 @@ class KiroAdapter extends BaseAdapter { } } - const result = await proc.client.call("session/new", { cwd: this.config.cwd, mcpServers: [] }); + const result = await proc.client.call(AcpMethod.SessionNew, { cwd: this.config.cwd, mcpServers: [] }); const entry: SessionEntry = { sessionId: result.sessionId, threadId, @@ -276,7 +279,7 @@ class KiroAdapter extends BaseAdapter { if (params?.sessionId !== session.sessionId) return; const update = params.update; if (!update) return; - if (update.sessionUpdate === "agent_message_chunk" && update.content?.text) { + if (update.sessionUpdate === SessionUpdateKind.AgentMessageChunk && update.content?.text) { fullText += update.content.text; } }; @@ -291,19 +294,19 @@ class KiroAdapter extends BaseAdapter { } }; - proc.client.on("session/update", onSessionUpdate); - proc.client.on("_kiro.dev/session/update", onSessionUpdate); - proc.client.on("_kiro.dev/metadata", onMetadata); + proc.client.on(AcpEvent.SessionUpdate, onSessionUpdate); + proc.client.on(AcpEvent.KiroSessionUpdate, onSessionUpdate); + proc.client.on(AcpEvent.KiroMetadata, onMetadata); try { - await proc.client.call("session/prompt", { + await proc.client.call(AcpMethod.SessionPrompt, { sessionId: session.sessionId, prompt: [{ type: "text", text }], }, 0); } finally { - proc.client.off("session/update", onSessionUpdate); - proc.client.off("_kiro.dev/session/update", onSessionUpdate); - proc.client.off("_kiro.dev/metadata", onMetadata); + proc.client.off(AcpEvent.SessionUpdate, onSessionUpdate); + proc.client.off(AcpEvent.KiroSessionUpdate, onSessionUpdate); + proc.client.off(AcpEvent.KiroMetadata, onMetadata); } return { text: fullText }; @@ -335,15 +338,15 @@ class KiroAdapter extends BaseAdapter { const update = params.update; if (!update) return; switch (update.sessionUpdate) { - case "agent_message_chunk": + case SessionUpdateKind.AgentMessageChunk: if (update.content?.text) { push({ type: "text_delta", text: update.content.text }); } break; - case "tool_call": + case SessionUpdateKind.ToolCall: push({ type: "tool_start", toolName: normalizeToolName(update.title ?? ""), toolCallId: update.toolCallId ?? "" }); break; - case "tool_call_update": + case SessionUpdateKind.ToolCallUpdate: push({ type: "tool_end", toolName: normalizeToolName(update.title ?? ""), toolCallId: update.toolCallId ?? "", isError: false }); break; } @@ -359,12 +362,12 @@ class KiroAdapter extends BaseAdapter { } }; - proc.client.on("session/update", onSessionUpdate); - proc.client.on("_kiro.dev/session/update", onSessionUpdate); - proc.client.on("_kiro.dev/metadata", onMetadata); + proc.client.on(AcpEvent.SessionUpdate, onSessionUpdate); + proc.client.on(AcpEvent.KiroSessionUpdate, onSessionUpdate); + proc.client.on(AcpEvent.KiroMetadata, onMetadata); // Fire the prompt (don't await — events stream in) - proc.client.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text }] }, 0).then(() => { + proc.client.call(AcpMethod.SessionPrompt, { sessionId: session.sessionId, prompt: [{ type: "text", text }] }, 0).then(() => { push({ type: "turn_end" }); push({ type: "agent_end" }); done = true; @@ -387,9 +390,9 @@ class KiroAdapter extends BaseAdapter { } if (promptError) throw promptError; } finally { - proc.client.off("session/update", onSessionUpdate); - proc.client.off("_kiro.dev/session/update", onSessionUpdate); - proc.client.off("_kiro.dev/metadata", onMetadata); + proc.client.off(AcpEvent.SessionUpdate, onSessionUpdate); + proc.client.off(AcpEvent.KiroSessionUpdate, onSessionUpdate); + proc.client.off(AcpEvent.KiroMetadata, onMetadata); } } finally { this.store.markInFlight(threadId, false); From 80333193f7b577b25af01cb958890145a7cac201 Mon Sep 17 00:00:00 2001 From: osherove Date: Wed, 20 May 2026 19:15:56 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix(kiro):=20address=20Codex=20review=20?= =?UTF-8?q?=E2=80=94=20session/load=20shape,=20tool=5Fcall=5Fupdate=20life?= =?UTF-8?q?cycle,=20dual=20subscribe,=20hardcoded=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings from the automated PR review: 1. session/load was missing required `cwd` and `mcpServers` params per the ACP v1 spec. With the wrong shape, kiro-cli rejects the request, our catch clears the persisted id, and we silently fall through to session/new — defeating thread resumption. Pass the correct shape. Note: kiro-cli sessions are per-process and don't survive a kiro-cli restart, so session/load fails legitimately when the original kiro-cli process is gone — recovery still handles that path correctly. 2. tool_call_update arrives multiple times per tool call (in-progress updates plus a terminal completed/failed transition). Emitting tool_end on every one prematurely closed the gateway's tool lifecycle and masked failures. Gate tool_end on terminal status and derive isError from `status === "failed"`. 3. The adapter subscribed the same handler to both `session/update` and `_kiro.dev/session/update`. The two are not mirrors — they multiplex disjoint sets of update kinds. Subscribing to both either no-ops (today, since we don't handle tool_call_chunk) or duplicates events (the moment we do). Subscribe only to the canonical session/update. 4. The 200k context window magic number is now a documented module-level constant `KIRO_DEFAULT_CONTEXT_WINDOW` with an explanation of why we approximate (kiro emits percent only; pressure classifier short- circuits if window is null) and what to replace it with when kiro exposes a real window field. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/agents/kiro/acp/methods.ts | 6 ++++- src/agents/kiro/kiro-adapter.ts | 44 +++++++++++++++++++++++---------- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/src/agents/kiro/acp/methods.ts b/src/agents/kiro/acp/methods.ts index 08b1838..257e9f1 100644 --- a/src/agents/kiro/acp/methods.ts +++ b/src/agents/kiro/acp/methods.ts @@ -18,7 +18,11 @@ export const AcpMethod = { /** Notifications the agent emits to the client. */ export const AcpEvent = { SessionUpdate: "session/update", - /** Internal kiro variant emitted alongside `session/update` for some payloads. */ + /** + * Internal kiro variant. Multiplexes a different set of update kinds + * (e.g. `tool_call_chunk`) than `session/update`, NOT a mirror — subscribing + * to both would route the same handler over disjoint payloads. + */ KiroSessionUpdate: "_kiro.dev/session/update", KiroMetadata: "_kiro.dev/metadata", } as const; diff --git a/src/agents/kiro/kiro-adapter.ts b/src/agents/kiro/kiro-adapter.ts index 2a8efd7..6298169 100644 --- a/src/agents/kiro/kiro-adapter.ts +++ b/src/agents/kiro/kiro-adapter.ts @@ -19,6 +19,15 @@ import { spawnKiroCli, shutdownProcess, getKiroCliVersion, AcpMethod, AcpEvent, import { SessionStore, type SessionEntry } from "./session.js"; import { normalizeToolName } from "./tool-names.js"; +// kiro emits context usage as a percentage and does not advertise the +// active model's window size in the ACP protocol. We approximate the window +// at 200k so SessionEntry.contextTokens/contextWindow stay populated and the +// memory pressure classifier (which short-circuits when either is null) can +// still trigger compaction. This is correct for 200k-window models and a +// best-effort approximation for others — when kiro adds a window field, +// thread it through here instead. +const KIRO_DEFAULT_CONTEXT_WINDOW = 200_000; + // ── Types ──────────────────────────────────────────── interface KiroAdapterConfig { @@ -222,7 +231,7 @@ class KiroAdapter extends BaseAdapter { const persistedId = this.store.loadPersistedSessionId(threadId); if (persistedId) { try { - await proc.client.call(AcpMethod.SessionLoad, { sessionId: persistedId }); + await proc.client.call(AcpMethod.SessionLoad, { sessionId: persistedId, cwd: this.config.cwd, mcpServers: [] }); const entry: SessionEntry = { sessionId: persistedId, threadId, @@ -288,14 +297,15 @@ class KiroAdapter extends BaseAdapter { if (params?.sessionId !== session.sessionId) return; const pct = params.contextUsagePercentage; if (pct != null) { - const window = 200000; - const tokens = Math.round((pct / 100) * window); - this.store.updateContext(threadId, tokens, window, params.model ?? null); + const tokens = Math.round((pct / 100) * KIRO_DEFAULT_CONTEXT_WINDOW); + this.store.updateContext(threadId, tokens, KIRO_DEFAULT_CONTEXT_WINDOW, params.model ?? null); } }; + // session/update is the canonical channel; _kiro.dev/session/update + // multiplexes a different set of update kinds (e.g. tool_call_chunk) + // we don't currently consume — don't subscribe twice. proc.client.on(AcpEvent.SessionUpdate, onSessionUpdate); - proc.client.on(AcpEvent.KiroSessionUpdate, onSessionUpdate); proc.client.on(AcpEvent.KiroMetadata, onMetadata); try { @@ -305,7 +315,6 @@ class KiroAdapter extends BaseAdapter { }, 0); } finally { proc.client.off(AcpEvent.SessionUpdate, onSessionUpdate); - proc.client.off(AcpEvent.KiroSessionUpdate, onSessionUpdate); proc.client.off(AcpEvent.KiroMetadata, onMetadata); } @@ -346,9 +355,21 @@ class KiroAdapter extends BaseAdapter { case SessionUpdateKind.ToolCall: push({ type: "tool_start", toolName: normalizeToolName(update.title ?? ""), toolCallId: update.toolCallId ?? "" }); break; - case SessionUpdateKind.ToolCallUpdate: - push({ type: "tool_end", toolName: normalizeToolName(update.title ?? ""), toolCallId: update.toolCallId ?? "", isError: false }); + case SessionUpdateKind.ToolCallUpdate: { + // tool_call_update arrives multiple times per call — for in-progress + // status changes and for the terminal completed/failed transition. + // Only the terminal one closes the tool lifecycle in our gateway. + const status = update.status; + if (status === "completed" || status === "failed") { + push({ + type: "tool_end", + toolName: normalizeToolName(update.title ?? ""), + toolCallId: update.toolCallId ?? "", + isError: status === "failed", + }); + } break; + } } }; @@ -356,14 +377,12 @@ class KiroAdapter extends BaseAdapter { if (params?.sessionId !== session.sessionId) return; const pct = params.contextUsagePercentage; if (pct != null) { - const window = 200000; - const tokens = Math.round((pct / 100) * window); - this.store.updateContext(threadId, tokens, window, params.model ?? null); + const tokens = Math.round((pct / 100) * KIRO_DEFAULT_CONTEXT_WINDOW); + this.store.updateContext(threadId, tokens, KIRO_DEFAULT_CONTEXT_WINDOW, params.model ?? null); } }; proc.client.on(AcpEvent.SessionUpdate, onSessionUpdate); - proc.client.on(AcpEvent.KiroSessionUpdate, onSessionUpdate); proc.client.on(AcpEvent.KiroMetadata, onMetadata); // Fire the prompt (don't await — events stream in) @@ -391,7 +410,6 @@ class KiroAdapter extends BaseAdapter { if (promptError) throw promptError; } finally { proc.client.off(AcpEvent.SessionUpdate, onSessionUpdate); - proc.client.off(AcpEvent.KiroSessionUpdate, onSessionUpdate); proc.client.off(AcpEvent.KiroMetadata, onMetadata); } } finally {