diff --git a/package.json b/package.json
index 2220c0f943..cc9b9a4dfb 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"server:dev": "tsx --tsconfig server/tsconfig.json server/index.js",
"server:dev-watch": "tsx watch --tsconfig server/tsconfig.json server/index.js",
"client": "vite",
+ "test:server": "tsx --tsconfig server/tsconfig.json --test \"server/**/*.test.ts\"",
"build": "npm run build:client && npm run build:server",
"build:client": "vite build",
"prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"",
diff --git a/public/api-docs.html b/public/api-docs.html
index 03dbb9b890..06323648d5 100644
--- a/public/api-docs.html
+++ b/public/api-docs.html
@@ -834,6 +834,7 @@
Branch & PR Response
{ id: 'gemini', name: 'Google' },
{ id: 'cursor', name: 'Cursor' },
{ id: 'opencode', name: 'OpenCode' },
+ { id: 'kiro', name: 'AWS Kiro' },
];
async function populateModels() {
diff --git a/public/icons/kiro-white.svg b/public/icons/kiro-white.svg
new file mode 100644
index 0000000000..b42c8eac00
--- /dev/null
+++ b/public/icons/kiro-white.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/icons/kiro.svg b/public/icons/kiro.svg
new file mode 100644
index 0000000000..ce0b07dec5
--- /dev/null
+++ b/public/icons/kiro.svg
@@ -0,0 +1,4 @@
+
diff --git a/server/index.js b/server/index.js
index 9ebe74d600..e4115f11dc 100755
--- a/server/index.js
+++ b/server/index.js
@@ -41,6 +41,10 @@ import {
spawnOpenCode,
abortOpenCodeSession,
} from './opencode-cli.js';
+import {
+ spawnKiro,
+ abortKiroSession,
+} from './kiro-cli.js';
import sessionManager from './sessionManager.js';
import {
stripAnsiSequences,
@@ -100,6 +104,7 @@ const wss = createWebSocketServer(server, {
codex: queryCodex,
gemini: spawnGemini,
opencode: spawnOpenCode,
+ kiro: spawnKiro,
},
abortFns: {
claude: abortClaudeSDKSession,
@@ -107,6 +112,7 @@ const wss = createWebSocketServer(server, {
codex: abortCodexSession,
gemini: abortGeminiSession,
opencode: abortOpenCodeSession,
+ kiro: abortKiroSession,
},
resolveToolApproval,
getPendingApprovalsForSession,
@@ -1224,6 +1230,20 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
});
}
+ // Kiro sessions report `context_usage_percentage` per turn but not a
+ // running total/used pair compatible with this endpoint. Defer accurate
+ // accounting to a follow-up; the UI will treat Kiro as "no budget" for
+ // now (parity with Cursor/Gemini).
+ if (provider === 'kiro') {
+ return res.json({
+ used: 0,
+ total: 0,
+ breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
+ unsupported: true,
+ message: 'Token usage tracking not available for Kiro sessions'
+ });
+ }
+
if (provider === 'opencode') {
const dbPath = getOpenCodeDatabasePath();
if (!fs.existsSync(dbPath)) {
diff --git a/server/kiro-cli.js b/server/kiro-cli.js
new file mode 100644
index 0000000000..ba6c75f92e
--- /dev/null
+++ b/server/kiro-cli.js
@@ -0,0 +1,367 @@
+import { spawn } from 'node:child_process';
+import crypto from 'node:crypto';
+
+import crossSpawn from 'cross-spawn';
+
+import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
+import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
+import { createNormalizedMessage } from './shared/utils.js';
+import { StdioJsonRpcClient } from './modules/providers/list/kiro/stdio-jsonrpc-client.js';
+
+const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
+const KIRO_BIN = process.env.KIRO_PATH ?? 'kiro-cli';
+
+// Tracks active Kiro processes by session id (or a temporary process key
+// before the ACP `session/new` reply assigns a real sessionId).
+const activeKiroProcesses = new Map();
+
+const PROVIDER = 'kiro';
+
+/**
+ * Kiro speaks ACP (Agent Client Protocol) — JSON-RPC 2.0 over stdio. Each
+ * `kiro-cli acp` invocation hosts ONE chat session that we drive with
+ * `initialize` → `session/new` (or `session/load` for resume) → `session/prompt`.
+ *
+ * Streamed agent events arrive as `session/update` notifications and are
+ * normalized into NormalizedMessage shapes the rest of the app already
+ * understands (text, tool_use, tool_result, complete, error).
+ */
+
+/**
+ * Maps an ACP `session/update` notification into NormalizedMessage chunks.
+ *
+ * Event shapes (verified against kiro-cli 2.3.0):
+ * sessionUpdate: 'agent_message_chunk' → {content: {type, text}}
+ * sessionUpdate: 'tool_call' → {toolCallId, title, kind, locations[], rawInput}
+ * sessionUpdate: 'tool_call_chunk' → {toolCallId, ...} (progressive args)
+ * sessionUpdate: 'tool_call_update' → {toolCallId, status: 'completed'|'failed', ...}
+ */
+function normalizeAcpUpdate(params, sessionId) {
+ if (!params || typeof params !== 'object') {
+ return [];
+ }
+
+ const update = params.update;
+ if (!update || typeof update !== 'object') {
+ return [];
+ }
+
+ const kind = update.sessionUpdate;
+ const ts = new Date().toISOString();
+
+ if (kind === 'agent_message_chunk') {
+ const content = update.content;
+ const text = content && typeof content === 'object' && typeof content.text === 'string'
+ ? content.text
+ : '';
+ if (!text) {
+ return [];
+ }
+ return [createNormalizedMessage({
+ sessionId,
+ timestamp: ts,
+ provider: PROVIDER,
+ kind: 'text',
+ role: 'assistant',
+ content: text,
+ })];
+ }
+
+ if (kind === 'tool_call') {
+ const toolId = typeof update.toolCallId === 'string' ? update.toolCallId : '';
+ return [createNormalizedMessage({
+ id: toolId || undefined,
+ sessionId,
+ timestamp: ts,
+ provider: PROVIDER,
+ kind: 'tool_use',
+ toolName: typeof update.title === 'string' ? update.title : (typeof update.kind === 'string' ? update.kind : 'tool'),
+ toolId,
+ toolInput: update.rawInput,
+ input: update.locations,
+ })];
+ }
+
+ if (kind === 'tool_call_update') {
+ const toolId = typeof update.toolCallId === 'string' ? update.toolCallId : '';
+ const status = typeof update.status === 'string' ? update.status : 'completed';
+ if (status !== 'completed' && status !== 'failed') {
+ // Intermediate (e.g. 'in_progress') updates are not surfaced to keep the
+ // history reader and the live stream byte-identical.
+ return [];
+ }
+ const isError = status === 'failed';
+ let content = '';
+ if (typeof update.output === 'string') {
+ content = update.output;
+ } else if (update.output && typeof update.output === 'object') {
+ try { content = JSON.stringify(update.output); } catch { content = ''; }
+ }
+ return [createNormalizedMessage({
+ sessionId,
+ timestamp: ts,
+ provider: PROVIDER,
+ kind: 'tool_result',
+ toolId,
+ content,
+ isError,
+ })];
+ }
+
+ // tool_call_chunk and other progressive variants are intentionally dropped:
+ // the final tool_call carries the complete rawInput, so duplicating the
+ // streamed args would inflate the wire transcript without UI value.
+ return [];
+}
+
+async function spawnKiro(command, options = {}, ws) {
+ const { sessionId, projectPath, cwd, model, agent, sessionSummary } = options;
+ const workingDir = cwd || projectPath || process.cwd();
+
+ // Process key starts as the existing sessionId (resume) or a placeholder
+ // we'll replace once `session/new` returns the real id. We expose the
+ // placeholder via `ws.setSessionId` so the frontend can target an abort
+ // BEFORE the real id arrives (the `session/new` round-trip can take 30+s
+ // while MCP servers boot).
+ const placeholderKey = sessionId || `pending-${crypto.randomUUID()}`;
+ let activeKey = placeholderKey;
+ let capturedSessionId = sessionId || null;
+ let sessionCreatedSent = false;
+ let terminalNotificationSent = false;
+
+ if (!sessionId && typeof ws.setSessionId === 'function') {
+ // Frontend uses this id to send `abort-session` while we're still in the
+ // ACP handshake. Once `session/new` returns, the id flips and the swap
+ // below rekeys the active map.
+ ws.setSessionId(placeholderKey);
+ }
+
+ // ACP `--model` and `--agent` are CLI flags applied to the FIRST session in
+ // the process. They must be passed at spawn time, not in `session/prompt`
+ // params (Kiro silently ignores `model`/`agent` on prompt). Resume mode
+ // reuses the existing session's model, so we only set them for new sessions.
+ const acpArgs = ['acp', '--trust-all-tools'];
+ if (!sessionId && model) {
+ acpArgs.push('--model', model);
+ }
+ if (!sessionId && agent) {
+ acpArgs.push('--agent', agent);
+ }
+
+ const kiroProcess = spawnFunction(KIRO_BIN, acpArgs, {
+ cwd: workingDir,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ env: { ...process.env },
+ });
+ activeKiroProcesses.set(activeKey, kiroProcess);
+
+ console.log('Spawning Kiro CLI:', KIRO_BIN, acpArgs.join(' '));
+ console.log('Working directory:', workingDir);
+ console.log('Session info - Input sessionId:', sessionId);
+
+ const client = new StdioJsonRpcClient(kiroProcess, {
+ onStderr: (line) => console.error('Kiro CLI stderr:', line),
+ onParseError: (rawLine) => console.warn('Kiro ACP non-JSON line:', rawLine.slice(0, 200)),
+ });
+
+ const notifyTerminalState = ({ code = null, error = null } = {}) => {
+ if (terminalNotificationSent) {
+ return;
+ }
+ terminalNotificationSent = true;
+
+ const finalSessionId = capturedSessionId || sessionId || activeKey;
+ if (code === 0 && !error) {
+ notifyRunStopped({
+ userId: ws?.userId || null,
+ provider: PROVIDER,
+ sessionId: finalSessionId,
+ sessionName: sessionSummary,
+ stopReason: 'completed',
+ });
+ return;
+ }
+
+ notifyRunFailed({
+ userId: ws?.userId || null,
+ provider: PROVIDER,
+ sessionId: finalSessionId,
+ sessionName: sessionSummary,
+ error: error || `Kiro CLI exited with code ${code}`,
+ });
+ };
+
+ // Stream session/update notifications onto the websocket.
+ client.onNotification('session/update', (params) => {
+ const targetSessionId = capturedSessionId || sessionId || null;
+ const messages = normalizeAcpUpdate(params, targetSessionId);
+ for (const msg of messages) {
+ ws.send(msg);
+ }
+ });
+
+ // Kiro extension namespace: log for debugging, but do not surface to the UI
+ // in v1. Future enhancements (live MCP server status, credit usage) can hook
+ // here without touching the core normalization path.
+ client.onNotificationPrefix('_kiro.dev/', () => {});
+
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ const settleOnce = (callback) => {
+ if (settled) return;
+ settled = true;
+ callback();
+ };
+
+ kiroProcess.on('close', async (code) => {
+ activeKiroProcesses.delete(activeKey);
+ ws.send(createNormalizedMessage({
+ kind: 'complete',
+ exitCode: code,
+ isNewSession: !sessionId && !!command,
+ sessionId: capturedSessionId || sessionId || activeKey,
+ provider: PROVIDER,
+ }));
+ notifyTerminalState({ code });
+ if (code === 0) {
+ settleOnce(() => resolve());
+ } else {
+ settleOnce(() => reject(new Error(`Kiro CLI exited with code ${code}`)));
+ }
+ });
+
+ kiroProcess.on('error', async (error) => {
+ console.error('Kiro CLI process error:', error);
+ activeKiroProcesses.delete(activeKey);
+
+ const installed = await providerAuthService.isProviderInstalled(PROVIDER);
+ const errorContent = !installed
+ ? 'Kiro CLI is not installed. Install with: curl -fsSL https://cli.kiro.dev/install | bash'
+ : error.message;
+
+ ws.send(createNormalizedMessage({
+ kind: 'error',
+ content: errorContent,
+ sessionId: capturedSessionId || sessionId || null,
+ provider: PROVIDER,
+ }));
+ notifyTerminalState({ error });
+ settleOnce(() => reject(error));
+ });
+
+ // Drive the ACP handshake → new/load → prompt sequence.
+ (async () => {
+ try {
+ await client.request('initialize', {
+ protocolVersion: 1,
+ clientCapabilities: {
+ fs: { readTextFile: false, writeTextFile: false },
+ },
+ });
+
+ if (sessionId) {
+ // Resume an existing chat-store or ACP-store session by id.
+ await client.request('session/load', { sessionId, cwd: workingDir, mcpServers: [] });
+ capturedSessionId = sessionId;
+ } else {
+ const newResult = await client.request('session/new', {
+ cwd: workingDir,
+ mcpServers: [],
+ });
+ const result = newResult && typeof newResult === 'object' ? newResult : {};
+ if (typeof result.sessionId === 'string' && result.sessionId) {
+ capturedSessionId = result.sessionId;
+ const previousKey = activeKey;
+ activeKey = capturedSessionId;
+ if (previousKey !== capturedSessionId) {
+ activeKiroProcesses.delete(previousKey);
+ activeKiroProcesses.set(activeKey, kiroProcess);
+ }
+ if (typeof ws.setSessionId === 'function') {
+ ws.setSessionId(capturedSessionId);
+ }
+ if (!sessionCreatedSent) {
+ sessionCreatedSent = true;
+ ws.send(createNormalizedMessage({
+ kind: 'session_created',
+ newSessionId: capturedSessionId,
+ cwd: workingDir,
+ sessionId: capturedSessionId,
+ provider: PROVIDER,
+ }));
+ }
+ }
+ }
+
+ if (command && command.trim()) {
+ // ACP `session/prompt` only accepts {sessionId, prompt}. Model and
+ // agent are baked into the spawn-time CLI flags above; resume mode
+ // inherits whatever the original session was created with.
+ const promptResult = await client.request('session/prompt', {
+ sessionId: capturedSessionId,
+ prompt: [{ type: 'text', text: command }],
+ });
+ const stopReason = promptResult && typeof promptResult === 'object'
+ ? promptResult.stopReason
+ : null;
+ if (stopReason && stopReason !== 'end_turn') {
+ ws.send(createNormalizedMessage({
+ kind: 'status',
+ status: stopReason,
+ sessionId: capturedSessionId,
+ provider: PROVIDER,
+ }));
+ }
+ }
+
+ // Close stdin to let the child terminate naturally; the 'close' event
+ // handler above resolves the outer promise.
+ kiroProcess.stdin.end();
+ } catch (rpcError) {
+ ws.send(createNormalizedMessage({
+ kind: 'error',
+ content: rpcError instanceof Error ? rpcError.message : String(rpcError),
+ sessionId: capturedSessionId || sessionId || null,
+ provider: PROVIDER,
+ }));
+ // Half-close stdin so the child can drain pending writes, then SIGTERM.
+ // If the child ignores SIGTERM (running tool, blocked syscall), force
+ // SIGKILL after a grace window so the outer Promise resolves via the
+ // 'close' event handler instead of hanging forever.
+ try { kiroProcess.stdin.end(); } catch { /* already closed */ }
+ try { kiroProcess.kill('SIGTERM'); } catch { /* already gone */ }
+ setTimeout(() => {
+ if (!kiroProcess.killed) {
+ try { kiroProcess.kill('SIGKILL'); } catch { /* already gone */ }
+ }
+ }, 5000).unref();
+ }
+ })();
+ });
+}
+
+function abortKiroSession(sessionId) {
+ const process = activeKiroProcesses.get(sessionId);
+ if (process) {
+ console.log(`Aborting Kiro session: ${sessionId}`);
+ process.kill('SIGTERM');
+ activeKiroProcesses.delete(sessionId);
+ return true;
+ }
+ return false;
+}
+
+function isKiroSessionActive(sessionId) {
+ return activeKiroProcesses.has(sessionId);
+}
+
+function getActiveKiroSessions() {
+ return Array.from(activeKiroProcesses.keys());
+}
+
+export {
+ spawnKiro,
+ abortKiroSession,
+ isKiroSessionActive,
+ getActiveKiroSessions,
+};
diff --git a/server/modules/providers/list/kiro/__tests__/fixtures/error-result.jsonl b/server/modules/providers/list/kiro/__tests__/fixtures/error-result.jsonl
new file mode 100644
index 0000000000..601bedcf06
--- /dev/null
+++ b/server/modules/providers/list/kiro/__tests__/fixtures/error-result.jsonl
@@ -0,0 +1,3 @@
+{"version":"v1","kind":"Prompt","data":{"message_id":"p1","content":[{"kind":"text","data":"please fail"}],"meta":{"timestamp":1778546781}}}
+{"version":"v1","kind":"AssistantMessage","data":{"message_id":"a1","content":[{"kind":"toolUse","data":{"toolUseId":"tooluse_failing","name":"execute_bash","input":{"command":"false"}}}]}}
+{"version":"v1","kind":"ToolResults","data":{"message_id":"r1","content":[{"kind":"toolResult","data":{"toolUseId":"tooluse_failing","content":[{"kind":"text","data":"command exited 1"}]}}],"status":"error"}}
diff --git a/server/modules/providers/list/kiro/__tests__/fixtures/sample-session.json b/server/modules/providers/list/kiro/__tests__/fixtures/sample-session.json
new file mode 100644
index 0000000000..106d8010c0
--- /dev/null
+++ b/server/modules/providers/list/kiro/__tests__/fixtures/sample-session.json
@@ -0,0 +1,7 @@
+{
+ "session_id": "9999aaaa-bbbb-cccc-dddd-000000000001",
+ "cwd": "/tmp",
+ "created_at": "2026-05-12T00:46:21.000Z",
+ "updated_at": "2026-05-12T00:46:30.000Z",
+ "title": "list the files in the current directory using fs_read, then in one sentence say what you saw"
+}
diff --git a/server/modules/providers/list/kiro/__tests__/fixtures/sample-session.jsonl b/server/modules/providers/list/kiro/__tests__/fixtures/sample-session.jsonl
new file mode 100644
index 0000000000..2f4cd58d4d
--- /dev/null
+++ b/server/modules/providers/list/kiro/__tests__/fixtures/sample-session.jsonl
@@ -0,0 +1,4 @@
+{"version":"v1","kind":"Prompt","data":{"message_id":"a11875bb-ed17-4d3e-b2ba-71690a48a30a","content":[{"kind":"text","data":"list the files in the current directory using fs_read, then in one sentence say what you saw"}],"meta":{"timestamp":1778546781}}}
+{"version":"v1","kind":"AssistantMessage","data":{"message_id":"605d7eb6-893b-496e-aa5b-74fc47526a46","content":[{"kind":"toolUse","data":{"toolUseId":"tooluse_hfZQ1jSPv5KV2JTruRda1F","name":"fs_read","input":{"operations":[{"mode":"Directory","path":"/tmp"}]}}}]}}
+{"version":"v1","kind":"ToolResults","data":{"message_id":"599b78a0-161f-4227-9d0f-0ce96dafec19","content":[{"kind":"toolResult","data":{"toolUseId":"tooluse_hfZQ1jSPv5KV2JTruRda1F","content":[{"kind":"text","data":"User id: 1000\n-rw-r--r-- 1 1000 1000 100 May 12 /tmp/file"}]}}],"status":"success"}}
+{"version":"v1","kind":"AssistantMessage","data":{"message_id":"02345062-d813-4d20-9cbf-02a3955036dc","content":[{"kind":"text","data":"The /tmp directory contains a single file."}]}}
diff --git a/server/modules/providers/list/kiro/__tests__/kiro-auth.provider.test.ts b/server/modules/providers/list/kiro/__tests__/kiro-auth.provider.test.ts
new file mode 100644
index 0000000000..c0ab29a858
--- /dev/null
+++ b/server/modules/providers/list/kiro/__tests__/kiro-auth.provider.test.ts
@@ -0,0 +1,129 @@
+/**
+ * Auth-detection tests for KiroProviderAuth.
+ *
+ * `kiro-cli whoami` is the source of truth for login state. These tests stub
+ * the CLI via a fake executable pointed to by KIRO_PATH and override $HOME so
+ * the legacy SSO token file (`~/.aws/sso/cache/kiro-auth-token.json`) can be
+ * present, absent, or expired independently of whoami.
+ *
+ * Regression: kiro-cli >= 2.7.0 no longer writes that token file, so keying
+ * auth off the file alone reported a logged-in user as expired. whoami must
+ * win, and a missing token file must not force "not authenticated".
+ */
+import { describe, it, before, after, beforeEach } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+const ORIGINAL_HOME = process.env.HOME;
+const ORIGINAL_USERPROFILE = process.env.USERPROFILE;
+const ORIGINAL_KIRO_PATH = process.env.KIRO_PATH;
+
+const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'kiro-auth-test-'));
+const FAKE_BIN_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'kiro-auth-bin-'));
+const FAKE_KIRO = path.join(FAKE_BIN_DIR, 'fake-kiro-cli.sh');
+const WHOAMI_OUTPUT_FILE = path.join(FAKE_BIN_DIR, 'whoami-output.txt');
+const TOKEN_PATH = path.join(TMP_HOME, '.aws', 'sso', 'cache', 'kiro-auth-token.json');
+
+process.env.HOME = TMP_HOME;
+process.env.USERPROFILE = TMP_HOME;
+process.env.KIRO_PATH = FAKE_KIRO;
+
+// A tiny shell stub standing in for kiro-cli: `--version` exits 0; `whoami`
+// prints whatever the current scenario wrote to WHOAMI_OUTPUT_FILE.
+const FAKE_SCRIPT = `#!/bin/bash
+case "$1" in
+ --version) echo "kiro-cli 9.9.9"; exit 0 ;;
+ whoami) cat "${WHOAMI_OUTPUT_FILE}" 2>/dev/null; exit 0 ;;
+ *) exit 0 ;;
+esac
+`;
+
+const setWhoami = (output: string): void => {
+ fs.writeFileSync(WHOAMI_OUTPUT_FILE, output);
+};
+
+const writeToken = (expiresAt: string, authMethod = 'IdC'): void => {
+ fs.mkdirSync(path.dirname(TOKEN_PATH), { recursive: true });
+ fs.writeFileSync(TOKEN_PATH, JSON.stringify({ expiresAt, authMethod }));
+};
+
+const clearToken = (): void => {
+ fs.rmSync(TOKEN_PATH, { force: true });
+};
+
+describe('KiroProviderAuth.getStatus', () => {
+ let KiroProviderAuth: typeof import('../kiro-auth.provider.js').KiroProviderAuth;
+
+ before(async () => {
+ fs.writeFileSync(FAKE_KIRO, FAKE_SCRIPT, { mode: 0o755 });
+ ({ KiroProviderAuth } = await import('../kiro-auth.provider.js'));
+ });
+
+ beforeEach(() => {
+ clearToken();
+ setWhoami('');
+ });
+
+ after(() => {
+ if (ORIGINAL_HOME === undefined) delete process.env.HOME; else process.env.HOME = ORIGINAL_HOME;
+ if (ORIGINAL_USERPROFILE === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = ORIGINAL_USERPROFILE;
+ if (ORIGINAL_KIRO_PATH === undefined) delete process.env.KIRO_PATH; else process.env.KIRO_PATH = ORIGINAL_KIRO_PATH;
+ fs.rmSync(TMP_HOME, { recursive: true, force: true });
+ fs.rmSync(FAKE_BIN_DIR, { recursive: true, force: true });
+ });
+
+ it('authenticates from whoami alone when the legacy token file is absent (kiro-cli >= 2.7.0 regression)', async () => {
+ setWhoami('Logged in with IAM Identity Center (https://example.awsapps.com/start/)\nEmail: user@example.com\n');
+ // No token file on disk — newer CLIs do not write kiro-auth-token.json.
+
+ const status = await new KiroProviderAuth().getStatus();
+
+ assert.equal(status.authenticated, true);
+ assert.equal(status.email, 'user@example.com');
+ assert.equal(status.method, 'IdC');
+ assert.equal(status.error, undefined);
+ });
+
+ it('authenticates from whoami even when the legacy token file is expired', async () => {
+ setWhoami('Logged in with IAM Identity Center (https://example.awsapps.com/start/)\nEmail: user@example.com\n');
+ writeToken('2000-01-01T00:00:00Z'); // long expired — must NOT override whoami
+
+ const status = await new KiroProviderAuth().getStatus();
+
+ assert.equal(status.authenticated, true);
+ assert.equal(status.email, 'user@example.com');
+ assert.equal(status.method, 'IdC');
+ });
+
+ it('detects Builder ID method from whoami output', async () => {
+ setWhoami('Logged in with Builder ID\nEmail: builder@example.com\n');
+
+ const status = await new KiroProviderAuth().getStatus();
+
+ assert.equal(status.authenticated, true);
+ assert.equal(status.method, 'BuilderId');
+ assert.equal(status.email, 'builder@example.com');
+ });
+
+ it('reports not authenticated when whoami says not logged in', async () => {
+ setWhoami('Not logged in\n');
+
+ const status = await new KiroProviderAuth().getStatus();
+
+ assert.equal(status.authenticated, false);
+ assert.equal(status.email, null);
+ });
+
+ it('surfaces the expired-token error when logged out and a stale token explains why', async () => {
+ setWhoami('Not logged in\n');
+ writeToken('2000-01-01T00:00:00Z', 'IdC');
+
+ const status = await new KiroProviderAuth().getStatus();
+
+ assert.equal(status.authenticated, false);
+ assert.equal(status.error, 'OAuth token has expired');
+ assert.equal(status.method, 'IdC');
+ });
+});
diff --git a/server/modules/providers/list/kiro/__tests__/kiro-mcp.provider.test.ts b/server/modules/providers/list/kiro/__tests__/kiro-mcp.provider.test.ts
new file mode 100644
index 0000000000..13cce37585
--- /dev/null
+++ b/server/modules/providers/list/kiro/__tests__/kiro-mcp.provider.test.ts
@@ -0,0 +1,210 @@
+/**
+ * Real end-to-end MCP CRUD tests for KiroMcpProvider.
+ *
+ * Writes to a real on-disk JSON file under a tmp-dir override of $HOME and
+ * asserts the round-trip through `upsertServer` -> `listServersForScope`.
+ * Specifically validates the issue uncovered in code review:
+ * - `disabled` and `autoApprove` Kiro-specific fields are PRESERVED across
+ * upserts (they were being silently wiped).
+ * - HTTP transport with bearerTokenEnvVar is written and read back.
+ */
+import { describe, it, before, after } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+// We intercept HOME to point at a fresh tmp dir so the provider writes there.
+// Capture the original env vars (not os.homedir()) so teardown restores HOME
+// and USERPROFILE independently — they are not guaranteed to match.
+const ORIGINAL_HOME = process.env.HOME;
+const ORIGINAL_USERPROFILE = process.env.USERPROFILE;
+const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'kiro-mcp-test-'));
+
+// homedir() reads from the env on Linux, so override it before importing the
+// provider (which captures path joins lazily inside instance methods).
+process.env.HOME = TMP_HOME;
+process.env.USERPROFILE = TMP_HOME;
+
+const { KiroMcpProvider } = await import('@/modules/providers/list/kiro/kiro-mcp.provider.js');
+
+const SETTINGS_FILE = path.join(TMP_HOME, '.kiro', 'settings', 'mcp.json');
+
+function writeSettings(content: Record): void {
+ fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(content, null, 2));
+}
+
+function readSettings(): Record {
+ return JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
+}
+
+describe('KiroMcpProvider', () => {
+ before(() => {
+ fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });
+ });
+
+ after(() => {
+ if (ORIGINAL_HOME !== undefined) {
+ process.env.HOME = ORIGINAL_HOME;
+ } else {
+ delete process.env.HOME;
+ }
+ if (ORIGINAL_USERPROFILE !== undefined) {
+ process.env.USERPROFILE = ORIGINAL_USERPROFILE;
+ } else {
+ delete process.env.USERPROFILE;
+ }
+ fs.rmSync(TMP_HOME, { recursive: true, force: true });
+ });
+
+ it('lists existing user-scope stdio servers', async () => {
+ writeSettings({
+ mcpServers: {
+ fetch: {
+ command: 'uvx',
+ args: ['mcp-server-fetch'],
+ env: { FOO: 'bar' },
+ disabled: true,
+ autoApprove: ['*'],
+ },
+ },
+ });
+
+ const provider = new KiroMcpProvider();
+ const list = await provider.listServersForScope('user');
+ assert.equal(list.length, 1);
+ assert.equal(list[0].provider, 'kiro');
+ assert.equal(list[0].name, 'fetch');
+ assert.equal(list[0].transport, 'stdio');
+ assert.equal(list[0].command, 'uvx');
+ assert.deepEqual(list[0].args, ['mcp-server-fetch']);
+ assert.deepEqual(list[0].env, { FOO: 'bar' });
+ });
+
+ it('preserves disabled=true on upsert (regression: user disable was being wiped)', async () => {
+ writeSettings({
+ mcpServers: {
+ broken: {
+ command: 'old-bin',
+ args: [],
+ env: {},
+ disabled: true,
+ autoApprove: ['risky_tool'],
+ },
+ },
+ });
+
+ const provider = new KiroMcpProvider();
+ await provider.upsertServer({
+ name: 'broken',
+ scope: 'user',
+ transport: 'stdio',
+ command: 'new-bin', // user is updating the binary path
+ args: ['--flag'],
+ env: { NEW: '1' },
+ });
+
+ const after = readSettings();
+ const broken = (after.mcpServers as Record>).broken;
+
+ // New canonical fields took effect
+ assert.equal(broken.command, 'new-bin');
+ assert.deepEqual(broken.args, ['--flag']);
+ assert.deepEqual(broken.env, { NEW: '1' });
+ // CRITICAL: Kiro-only fields were preserved
+ assert.equal(broken.disabled, true);
+ assert.deepEqual(broken.autoApprove, ['risky_tool']);
+ });
+
+ it('preserves autoApprove without disabled', async () => {
+ writeSettings({
+ mcpServers: {
+ partial: {
+ command: 'a',
+ args: [],
+ env: {},
+ autoApprove: ['t1', 't2'],
+ },
+ },
+ });
+
+ const provider = new KiroMcpProvider();
+ await provider.upsertServer({
+ name: 'partial',
+ scope: 'user',
+ transport: 'stdio',
+ command: 'b',
+ args: [],
+ env: {},
+ });
+
+ const after = readSettings();
+ const partial = (after.mcpServers as Record>).partial;
+ assert.deepEqual(partial.autoApprove, ['t1', 't2']);
+ assert.equal(partial.disabled, undefined, 'must not invent a disabled field');
+ });
+
+ it('writes bearer_token_env_var for HTTP transports', async () => {
+ writeSettings({ mcpServers: {} });
+
+ const provider = new KiroMcpProvider();
+ await provider.upsertServer({
+ name: 'token-protected',
+ scope: 'user',
+ transport: 'http',
+ url: 'https://example.com/mcp',
+ headers: { 'X-Custom': 'value' },
+ bearerTokenEnvVar: 'MY_TOKEN',
+ });
+
+ const after = readSettings();
+ const entry = (after.mcpServers as Record>)['token-protected'];
+ assert.equal(entry.url, 'https://example.com/mcp');
+ assert.deepEqual(entry.headers, { 'X-Custom': 'value' });
+ assert.equal(entry.bearer_token_env_var, 'MY_TOKEN');
+ });
+
+ it('listServersForScope reads bearer_token_env_var back as bearerTokenEnvVar', async () => {
+ writeSettings({
+ mcpServers: {
+ api: {
+ url: 'https://api.example.com/mcp',
+ headers: {},
+ bearer_token_env_var: 'API_TOKEN',
+ },
+ },
+ });
+
+ const provider = new KiroMcpProvider();
+ const list = await provider.listServersForScope('user');
+ assert.equal(list.length, 1);
+ assert.equal(list[0].transport, 'http');
+ assert.equal(list[0].url, 'https://api.example.com/mcp');
+ assert.equal(list[0].bearerTokenEnvVar, 'API_TOKEN');
+ });
+
+ it('removes a server', async () => {
+ writeSettings({
+ mcpServers: {
+ keep: { command: 'a', args: [] },
+ drop: { command: 'b', args: [] },
+ },
+ });
+
+ const provider = new KiroMcpProvider();
+ const result = await provider.removeServer({ name: 'drop', scope: 'user' });
+ assert.equal(result.removed, true);
+
+ const after = readSettings();
+ assert.deepEqual(Object.keys(after.mcpServers as object), ['keep']);
+ });
+
+ it('rejects unsupported scope (e.g. "local")', async () => {
+ const provider = new KiroMcpProvider();
+ // KiroMcpProvider was constructed with ['user', 'project'] only; 'local'
+ // is a valid `McpScope` in the type system but unsupported at runtime.
+ const invalidInput = { name: 'x', scope: 'local', transport: 'stdio', command: 'whatever' } as Parameters[0];
+ await assert.rejects(provider.upsertServer(invalidInput), (err: Error) => err.message.length > 0);
+ });
+});
diff --git a/server/modules/providers/list/kiro/__tests__/kiro-session-synchronizer.test.ts b/server/modules/providers/list/kiro/__tests__/kiro-session-synchronizer.test.ts
new file mode 100644
index 0000000000..e577adac0f
--- /dev/null
+++ b/server/modules/providers/list/kiro/__tests__/kiro-session-synchronizer.test.ts
@@ -0,0 +1,142 @@
+/**
+ * Real integration test for KiroSessionSynchronizer.
+ *
+ * Spins up an isolated SQLite DB + tmp ~/.kiro/sessions/cli/ tree, drops
+ * fixture {.json,.jsonl} pairs onto disk, runs `synchronizeFile`, and asserts
+ * the regression fix from code review:
+ *
+ * - When the existing DB row has a user-set `custom_name`, the next
+ * synchronizeFile pass MUST NOT overwrite it with the sidecar `title`.
+ * (Bug regression scenario: user renamed a session via the UI; next
+ * synchronization round-trip wiped the rename.)
+ */
+import { describe, it, before, after } from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+
+const ORIGINAL_HOME = process.env.HOME;
+const ORIGINAL_USERPROFILE = process.env.USERPROFILE;
+const ORIGINAL_DB = process.env.DATABASE_PATH;
+
+let tempHome: string;
+let tempDbDir: string;
+
+before(async () => {
+ tempHome = await mkdtemp(path.join(tmpdir(), 'kiro-sync-home-'));
+ tempDbDir = await mkdtemp(path.join(tmpdir(), 'kiro-sync-db-'));
+ process.env.HOME = tempHome;
+ process.env.USERPROFILE = tempHome;
+ process.env.DATABASE_PATH = path.join(tempDbDir, 'auth.db');
+
+ // mkdir the kiro session tree the synchronizer expects
+ await mkdir(path.join(tempHome, '.kiro', 'sessions', 'cli'), { recursive: true });
+});
+
+after(async () => {
+ // Best-effort restore + cleanup. HOME and USERPROFILE are restored
+ // independently — they are not guaranteed to match (e.g. on Windows).
+ if (ORIGINAL_HOME !== undefined) {
+ process.env.HOME = ORIGINAL_HOME;
+ } else {
+ delete process.env.HOME;
+ }
+ if (ORIGINAL_USERPROFILE !== undefined) {
+ process.env.USERPROFILE = ORIGINAL_USERPROFILE;
+ } else {
+ delete process.env.USERPROFILE;
+ }
+ if (ORIGINAL_DB !== undefined) {
+ process.env.DATABASE_PATH = ORIGINAL_DB;
+ } else {
+ delete process.env.DATABASE_PATH;
+ }
+ if (tempHome) await rm(tempHome, { recursive: true, force: true });
+ if (tempDbDir) await rm(tempDbDir, { recursive: true, force: true });
+});
+
+describe('KiroSessionSynchronizer', () => {
+ it('preserves a user-set custom_name across re-synchronization (regression)', async () => {
+ // Lazy-import so the env overrides above are in effect when modules
+ // capture homedir() / DATABASE_PATH.
+ const { closeConnection, initializeDatabase, sessionsDb } = await import('@/modules/database/index.js');
+ const { KiroSessionSynchronizer } = await import('@/modules/providers/list/kiro/kiro-session-synchronizer.provider.js');
+
+ closeConnection();
+ await initializeDatabase();
+
+ const sessionId = 'aaaa1111-bbbb-cccc-dddd-000000000001';
+ const sidecarPath = path.join(tempHome, '.kiro', 'sessions', 'cli', `${sessionId}.json`);
+ const jsonlPath = path.join(tempHome, '.kiro', 'sessions', 'cli', `${sessionId}.jsonl`);
+
+ await writeFile(
+ sidecarPath,
+ JSON.stringify({
+ session_id: sessionId,
+ cwd: '/tmp',
+ created_at: '2026-05-12T00:00:00.000Z',
+ updated_at: '2026-05-12T00:00:30.000Z',
+ title: 'original kiro-derived title',
+ }),
+ );
+ await writeFile(jsonlPath, '{"version":"v1","kind":"Prompt","data":{"message_id":"p1","content":[{"kind":"text","data":"hi"}]}}\n');
+
+ const sync = new KiroSessionSynchronizer();
+
+ // First sync: no DB row exists → adopt the sidecar title.
+ await sync.synchronizeFile(jsonlPath);
+ let row = sessionsDb.getSessionById(sessionId);
+ assert.ok(row, 'session should be indexed after first sync');
+ assert.equal(row.custom_name, 'original kiro-derived title');
+ assert.equal(row.provider, 'kiro');
+ assert.equal(row.project_path, '/tmp');
+
+ // User renames the session via the UI.
+ sessionsDb.updateSessionCustomName(sessionId, 'My Important Refactor');
+ row = sessionsDb.getSessionById(sessionId);
+ assert.equal(row?.custom_name, 'My Important Refactor');
+
+ // Second sync (e.g. a watcher event after the user typed another message).
+ // The sidecar `title` is still "original kiro-derived title", but the user's
+ // custom name MUST survive.
+ await sync.synchronizeFile(jsonlPath);
+ row = sessionsDb.getSessionById(sessionId);
+ assert.equal(
+ row?.custom_name,
+ 'My Important Refactor',
+ 'user-set custom_name must not be wiped by re-synchronization',
+ );
+
+ closeConnection();
+ });
+
+ it('skips files whose sidecar JSON is missing', async () => {
+ const { closeConnection, initializeDatabase, sessionsDb } = await import('@/modules/database/index.js');
+ const { KiroSessionSynchronizer } = await import('@/modules/providers/list/kiro/kiro-session-synchronizer.provider.js');
+
+ closeConnection();
+ await initializeDatabase();
+
+ const sessionId = 'no-sidecar-' + Date.now();
+ const jsonlPath = path.join(tempHome, '.kiro', 'sessions', 'cli', `${sessionId}.jsonl`);
+ // jsonl exists, sidecar does NOT (race window when ACP creates jsonl first)
+ await writeFile(jsonlPath, '{"version":"v1","kind":"Prompt","data":{"content":[]}}\n');
+
+ const sync = new KiroSessionSynchronizer();
+ const result = await sync.synchronizeFile(jsonlPath);
+
+ assert.equal(result, null, 'must return null when sidecar is missing');
+ const row = sessionsDb.getSessionById(sessionId);
+ assert.ok(row == null, 'must not insert a row without project_path');
+
+ closeConnection();
+ });
+
+ it('returns null for non-jsonl paths', async () => {
+ const { KiroSessionSynchronizer } = await import('@/modules/providers/list/kiro/kiro-session-synchronizer.provider.js');
+ const sync = new KiroSessionSynchronizer();
+ const result = await sync.synchronizeFile('/some/path/foo.json');
+ assert.equal(result, null);
+ });
+});
diff --git a/server/modules/providers/list/kiro/__tests__/kiro-sessions.provider.test.ts b/server/modules/providers/list/kiro/__tests__/kiro-sessions.provider.test.ts
new file mode 100644
index 0000000000..53c644220e
--- /dev/null
+++ b/server/modules/providers/list/kiro/__tests__/kiro-sessions.provider.test.ts
@@ -0,0 +1,242 @@
+/**
+ * Real tests for KiroSessionsProvider.
+ *
+ * Exercises normalizeMessage against verified Kiro JSONL event shapes (Prompt,
+ * AssistantMessage with text + toolUse mix, ToolResults with status:'success'
+ * vs status:'error'). Fixture data was captured from a real `kiro-cli acp`
+ * session against `/tmp` on 2026-05-12.
+ */
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+
+import { KiroSessionsProvider } from '@/modules/providers/list/kiro/kiro-sessions.provider.js';
+
+const PROVIDER = 'kiro';
+
+describe('KiroSessionsProvider.normalizeMessage', () => {
+ const provider = new KiroSessionsProvider();
+
+ it('normalizes a Prompt entry to a single user text message', () => {
+ const entry = {
+ version: 'v1',
+ kind: 'Prompt',
+ data: {
+ message_id: 'p1',
+ content: [{ kind: 'text', data: 'hello world' }],
+ meta: { timestamp: 1778546781 },
+ },
+ };
+
+ const messages = provider.normalizeMessage(entry, 'session-1');
+ assert.equal(messages.length, 1);
+ assert.equal(messages[0].kind, 'text');
+ assert.equal(messages[0].role, 'user');
+ assert.equal(messages[0].content, 'hello world');
+ assert.equal(messages[0].provider, PROVIDER);
+ assert.equal(messages[0].sessionId, 'session-1');
+ // meta.timestamp is unix seconds; we should produce ISO 8601
+ assert.match(messages[0].timestamp, /^2026-/);
+ });
+
+ it('normalizes an AssistantMessage with text + toolUse content to two messages', () => {
+ const entry = {
+ version: 'v1',
+ kind: 'AssistantMessage',
+ data: {
+ message_id: 'a1',
+ content: [
+ { kind: 'text', data: 'I will call a tool now.' },
+ {
+ kind: 'toolUse',
+ data: {
+ toolUseId: 'tool-call-1',
+ name: 'fs_read',
+ input: { operations: [{ mode: 'Directory', path: '/tmp' }] },
+ },
+ },
+ ],
+ },
+ };
+
+ const messages = provider.normalizeMessage(entry, 'session-1');
+ assert.equal(messages.length, 2);
+
+ assert.equal(messages[0].kind, 'text');
+ assert.equal(messages[0].role, 'assistant');
+ assert.equal(messages[0].content, 'I will call a tool now.');
+
+ assert.equal(messages[1].kind, 'tool_use');
+ assert.equal(messages[1].toolName, 'fs_read');
+ assert.equal(messages[1].toolId, 'tool-call-1');
+ assert.deepEqual(messages[1].toolInput, { operations: [{ mode: 'Directory', path: '/tmp' }] });
+ });
+
+ it('marks ToolResults with status="error" as isError=true', () => {
+ const entry = {
+ version: 'v1',
+ kind: 'ToolResults',
+ data: {
+ message_id: 'r1',
+ status: 'error',
+ content: [
+ {
+ kind: 'toolResult',
+ data: {
+ toolUseId: 'tool-call-1',
+ content: [{ kind: 'text', data: 'command exited 1' }],
+ },
+ },
+ ],
+ },
+ };
+
+ const messages = provider.normalizeMessage(entry, 'session-1');
+ assert.equal(messages.length, 1);
+ assert.equal(messages[0].kind, 'tool_result');
+ assert.equal(messages[0].isError, true);
+ assert.equal(messages[0].content, 'command exited 1');
+ assert.equal(messages[0].toolId, 'tool-call-1');
+ });
+
+ it('marks ToolResults with status="success" as isError=false', () => {
+ const entry = {
+ version: 'v1',
+ kind: 'ToolResults',
+ data: {
+ message_id: 'r2',
+ status: 'success',
+ content: [
+ {
+ kind: 'toolResult',
+ data: {
+ toolUseId: 'tool-call-2',
+ content: [{ kind: 'text', data: 'ok' }],
+ },
+ },
+ ],
+ },
+ };
+
+ const messages = provider.normalizeMessage(entry, 'session-1');
+ assert.equal(messages.length, 1);
+ assert.equal(messages[0].isError, false);
+ });
+
+ it('falls back to per-content error kinds when status is missing', () => {
+ const entry = {
+ version: 'v1',
+ kind: 'ToolResults',
+ data: {
+ message_id: 'r3',
+ // no status field — exercise the content-part fallback
+ content: [
+ {
+ kind: 'toolResult',
+ data: {
+ toolUseId: 'tool-call-3',
+ content: [{ kind: 'errorText', data: 'something went wrong' }],
+ },
+ },
+ ],
+ },
+ };
+
+ const messages = provider.normalizeMessage(entry, 'session-1');
+ assert.equal(messages.length, 1);
+ assert.equal(messages[0].isError, true);
+ assert.equal(messages[0].content, 'something went wrong');
+ });
+
+ it('drops Prompt entries with empty/whitespace content', () => {
+ const entry = {
+ version: 'v1',
+ kind: 'Prompt',
+ data: {
+ message_id: 'p2',
+ content: [{ kind: 'text', data: ' \n\t' }],
+ meta: { timestamp: 1778546781 },
+ },
+ };
+
+ const messages = provider.normalizeMessage(entry, 'session-1');
+ assert.equal(messages.length, 0);
+ });
+
+ it('returns [] for unknown kind', () => {
+ const entry = {
+ version: 'v1',
+ kind: 'UnknownEventType',
+ data: { message_id: 'x' },
+ };
+
+ assert.equal(provider.normalizeMessage(entry, 'session-1').length, 0);
+ });
+
+ it('returns [] for non-object input', () => {
+ assert.equal(provider.normalizeMessage(null, 'session-1').length, 0);
+ assert.equal(provider.normalizeMessage('string', 'session-1').length, 0);
+ assert.equal(provider.normalizeMessage(42, 'session-1').length, 0);
+ });
+
+ it('produces unique ids when an entry has multiple text or tool parts', () => {
+ // Regression: when CodeRabbit flagged this, an AssistantMessage with two
+ // text parts produced two messages with the same `id` (both `${baseId}_text`),
+ // breaking React keyed rendering and message-association lookups.
+ const entry = {
+ version: 'v1',
+ kind: 'AssistantMessage',
+ data: {
+ message_id: 'a-multi',
+ content: [
+ { kind: 'text', data: 'first chunk' },
+ { kind: 'text', data: 'second chunk' },
+ {
+ kind: 'toolUse',
+ data: { toolUseId: 'tu1', name: 'fs_read', input: { path: '/a' } },
+ },
+ {
+ kind: 'toolUse',
+ data: { toolUseId: 'tu2', name: 'fs_read', input: { path: '/b' } },
+ },
+ ],
+ },
+ };
+
+ const messages = provider.normalizeMessage(entry, 'session-1');
+ const ids = messages.map((m) => m.id);
+ assert.equal(messages.length, 4);
+ assert.equal(new Set(ids).size, 4, `all ids must be unique; got: ${ids.join(', ')}`);
+ });
+
+ it('produces unique ids when ToolResults has multiple toolResult parts', () => {
+ const entry = {
+ version: 'v1',
+ kind: 'ToolResults',
+ data: {
+ message_id: 'r-multi',
+ status: 'success',
+ content: [
+ {
+ kind: 'toolResult',
+ data: {
+ toolUseId: 'shared-tool-id',
+ content: [{ kind: 'text', data: 'first result' }],
+ },
+ },
+ {
+ kind: 'toolResult',
+ data: {
+ toolUseId: 'shared-tool-id',
+ content: [{ kind: 'text', data: 'second result' }],
+ },
+ },
+ ],
+ },
+ };
+
+ const messages = provider.normalizeMessage(entry, 'session-1');
+ const ids = messages.map((m) => m.id);
+ assert.equal(messages.length, 2);
+ assert.equal(new Set(ids).size, 2, `tool_result ids must differ even when toolUseId collides; got: ${ids.join(', ')}`);
+ });
+});
diff --git a/server/modules/providers/list/kiro/__tests__/stdio-jsonrpc-client.test.ts b/server/modules/providers/list/kiro/__tests__/stdio-jsonrpc-client.test.ts
new file mode 100644
index 0000000000..82b2daf976
--- /dev/null
+++ b/server/modules/providers/list/kiro/__tests__/stdio-jsonrpc-client.test.ts
@@ -0,0 +1,310 @@
+/**
+ * Real unit tests for StdioJsonRpcClient.
+ *
+ * Backs the JSON-RPC 2.0 wire protocol against an in-process child substitute:
+ * a Node Duplex acting as the child's stdio pair so we can drive byte streams
+ * exactly like the real ChildProcessWithoutNullStreams. No mocks, no spies —
+ * the assertions exercise the real transport state machine.
+ *
+ * Run with `node --test --import tsx`.
+ */
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { EventEmitter } from 'node:events';
+import { PassThrough } from 'node:stream';
+import type { ChildProcessWithoutNullStreams } from 'node:child_process';
+
+import { StdioJsonRpcClient } from '@/modules/providers/list/kiro/stdio-jsonrpc-client.js';
+
+// Build a minimal stand-in matching the surface area StdioJsonRpcClient uses:
+// stdin (writable), stdout (readable), stderr (readable), and the EE for
+// 'close' / 'error'. The class never touches any other ChildProcess fields.
+function makeFakeChild(): {
+ child: ChildProcessWithoutNullStreams;
+ emitStdout: (chunk: string) => void;
+ emitStderr: (chunk: string) => void;
+ emitClose: (code: number | null) => void;
+ emitError: (err: Error) => void;
+ stdinWrites: string[];
+} {
+ const stdout = new PassThrough();
+ const stderr = new PassThrough();
+ const stdinWrites: string[] = [];
+ const stdin = {
+ write(chunk: string, callback?: (err?: Error | null) => void): boolean {
+ stdinWrites.push(chunk);
+ callback?.(null);
+ return true;
+ },
+ end(): void {},
+ };
+
+ const ee = new EventEmitter();
+ const fake = Object.assign(ee, {
+ stdin,
+ stdout,
+ stderr,
+ killed: false,
+ });
+
+ return {
+ child: fake as unknown as ChildProcessWithoutNullStreams,
+ emitStdout: (chunk: string) => stdout.write(chunk),
+ emitStderr: (chunk: string) => stderr.write(chunk),
+ emitClose: (code: number | null) => ee.emit('close', code),
+ emitError: (err: Error) => ee.emit('error', err),
+ stdinWrites,
+ };
+}
+
+describe('StdioJsonRpcClient', () => {
+ it('correlates request id to response result', async () => {
+ const fake = makeFakeChild();
+ const client = new StdioJsonRpcClient(fake.child);
+
+ const promise = client.request<{ value: number }>('test/method', { foo: 1 });
+
+ // Verify the wire format we produced
+ assert.equal(fake.stdinWrites.length, 1);
+ const sent = JSON.parse(fake.stdinWrites[0].trim());
+ assert.equal(sent.jsonrpc, '2.0');
+ assert.equal(sent.method, 'test/method');
+ assert.deepEqual(sent.params, { foo: 1 });
+ assert.equal(typeof sent.id, 'number');
+
+ // Send back a matching response
+ fake.emitStdout(`${JSON.stringify({ jsonrpc: '2.0', id: sent.id, result: { value: 42 } })}\n`);
+
+ const result = await promise;
+ assert.deepEqual(result, { value: 42 });
+ });
+
+ it('rejects on JSON-RPC error frame with the error message', async () => {
+ const fake = makeFakeChild();
+ const client = new StdioJsonRpcClient(fake.child);
+
+ const promise = client.request('failing/method');
+ const sent = JSON.parse(fake.stdinWrites[0].trim());
+
+ fake.emitStdout(
+ `${JSON.stringify({
+ jsonrpc: '2.0',
+ id: sent.id,
+ error: { code: -32603, message: 'Internal error', data: { detail: 'kiro broke' } },
+ })}\n`,
+ );
+
+ await assert.rejects(promise, (err: Error) => {
+ assert.match(err.message, /Internal error/);
+ assert.match(err.message, /failing\/method/);
+ return true;
+ });
+ });
+
+ it('handles a frame split across multiple stdout chunks', async () => {
+ const fake = makeFakeChild();
+ const client = new StdioJsonRpcClient(fake.child);
+
+ const promise = client.request('split/test');
+ const sent = JSON.parse(fake.stdinWrites[0].trim());
+ const fullFrame = JSON.stringify({ jsonrpc: '2.0', id: sent.id, result: 'ok' }) + '\n';
+
+ // Split mid-string across 4 chunks
+ fake.emitStdout(fullFrame.slice(0, 5));
+ fake.emitStdout(fullFrame.slice(5, 20));
+ fake.emitStdout(fullFrame.slice(20, 35));
+ fake.emitStdout(fullFrame.slice(35));
+
+ assert.equal(await promise, 'ok');
+ });
+
+ it('handles multiple frames in one chunk', async () => {
+ const fake = makeFakeChild();
+ const client = new StdioJsonRpcClient(fake.child);
+
+ const p1 = client.request('a');
+ const p2 = client.request('b');
+ const id1 = JSON.parse(fake.stdinWrites[0]).id;
+ const id2 = JSON.parse(fake.stdinWrites[1]).id;
+
+ const combined =
+ JSON.stringify({ jsonrpc: '2.0', id: id1, result: 'first' }) + '\n' +
+ JSON.stringify({ jsonrpc: '2.0', id: id2, result: 'second' }) + '\n';
+ fake.emitStdout(combined);
+
+ assert.equal(await p1, 'first');
+ assert.equal(await p2, 'second');
+ });
+
+ it('skips empty lines and CRLF without crashing', async () => {
+ const fake = makeFakeChild();
+ const client = new StdioJsonRpcClient(fake.child);
+
+ const p = client.request('crlf/test');
+ const sent = JSON.parse(fake.stdinWrites[0]);
+
+ fake.emitStdout('\n\r\n \n');
+ fake.emitStdout(`${JSON.stringify({ jsonrpc: '2.0', id: sent.id, result: 'ok' })}\r\n`);
+
+ assert.equal(await p, 'ok');
+ });
+
+ it('routes notifications to exact-method handlers', async () => {
+ const fake = makeFakeChild();
+ const client = new StdioJsonRpcClient(fake.child);
+
+ let received: unknown = null;
+ client.onNotification('session/update', (params) => {
+ received = params;
+ });
+
+ fake.emitStdout(`${JSON.stringify({
+ jsonrpc: '2.0',
+ method: 'session/update',
+ params: { sessionId: 'abc', update: { sessionUpdate: 'agent_message_chunk' } },
+ })}\n`);
+
+ // Allow microtask queue to flush
+ await new Promise((r) => setImmediate(r));
+
+ assert.deepEqual(received, { sessionId: 'abc', update: { sessionUpdate: 'agent_message_chunk' } });
+ });
+
+ it('routes prefixed notifications to wildcard handlers', async () => {
+ const fake = makeFakeChild();
+ const client = new StdioJsonRpcClient(fake.child);
+
+ const seen: Array<{ method: string; params: unknown }> = [];
+ client.onNotificationPrefix('_kiro.dev/', (params) => {
+ seen.push({ method: 'prefix-match', params });
+ });
+
+ fake.emitStdout(`${JSON.stringify({ jsonrpc: '2.0', method: '_kiro.dev/metadata', params: { x: 1 } })}\n`);
+ fake.emitStdout(`${JSON.stringify({ jsonrpc: '2.0', method: '_kiro.dev/mcp/server_initialized', params: { y: 2 } })}\n`);
+ fake.emitStdout(`${JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: { z: 3 } })}\n`);
+
+ await new Promise((r) => setImmediate(r));
+
+ // Only the two _kiro.dev/* notifications hit the prefix handler
+ assert.equal(seen.length, 2);
+ assert.deepEqual(seen.map((s) => s.params), [{ x: 1 }, { y: 2 }]);
+ });
+
+ it('rejects all pending requests on child close', async () => {
+ const fake = makeFakeChild();
+ const client = new StdioJsonRpcClient(fake.child);
+
+ const p1 = client.request('a').catch((e: Error) => e);
+ const p2 = client.request('b').catch((e: Error) => e);
+
+ fake.emitClose(1);
+
+ const [e1, e2] = await Promise.all([p1, p2]);
+ assert.match((e1 as Error).message, /closed/);
+ assert.match((e2 as Error).message, /closed/);
+ });
+
+ it('rejects new requests after close synchronously', async () => {
+ const fake = makeFakeChild();
+ const client = new StdioJsonRpcClient(fake.child);
+ fake.emitClose(0);
+
+ await assert.rejects(client.request('after-close'), (err: Error) => {
+ assert.match(err.message, /closed/);
+ return true;
+ });
+ });
+
+ it('survives non-JSON stdout lines without crashing', async () => {
+ const fake = makeFakeChild();
+ let parseErrors = 0;
+ const client = new StdioJsonRpcClient(fake.child, {
+ onParseError: () => { parseErrors += 1; },
+ });
+
+ const p = client.request('survive/test');
+ const sent = JSON.parse(fake.stdinWrites[0]);
+
+ // First a stderr leak that landed on stdout (not JSON), then a valid frame
+ fake.emitStdout('warning: something is up on stderr but emitted on stdout\n');
+ fake.emitStdout(`${JSON.stringify({ jsonrpc: '2.0', id: sent.id, result: 'still works' })}\n`);
+
+ assert.equal(await p, 'still works');
+ assert.equal(parseErrors, 1);
+ });
+
+ it('forwards stderr lines to onStderr callback', async () => {
+ const fake = makeFakeChild();
+ const stderrLines: string[] = [];
+ new StdioJsonRpcClient(fake.child, { onStderr: (l) => stderrLines.push(l) });
+
+ fake.emitStderr('first error\nsecond error\n');
+ await new Promise((r) => setImmediate(r));
+
+ assert.deepEqual(stderrLines, ['first error', 'second error']);
+ });
+
+ it('rejects request that times out', async () => {
+ const fake = makeFakeChild();
+ const client = new StdioJsonRpcClient(fake.child, { requestTimeoutMs: 50 });
+
+ await assert.rejects(client.request('slow/method'), (err: Error) => {
+ assert.match(err.message, /timed out/);
+ assert.match(err.message, /slow\/method/);
+ return true;
+ });
+ });
+
+ it('notify() does not expect a response and never resolves a promise', () => {
+ const fake = makeFakeChild();
+ const client = new StdioJsonRpcClient(fake.child);
+
+ client.notify('session/cancel', { sessionId: 'abc' });
+
+ assert.equal(fake.stdinWrites.length, 1);
+ const sent = JSON.parse(fake.stdinWrites[0].trim());
+ assert.equal(sent.jsonrpc, '2.0');
+ assert.equal(sent.method, 'session/cancel');
+ assert.equal('id' in sent, false, 'notification frames must have no id');
+ });
+
+ it('continues processing frames when a notification handler throws', async () => {
+ const fake = makeFakeChild();
+ const client = new StdioJsonRpcClient(fake.child);
+
+ let goodCalls = 0;
+ client.onNotification('throws', () => {
+ throw new Error('handler boom');
+ });
+ client.onNotification('survives', () => {
+ goodCalls += 1;
+ });
+
+ // Suppress the expected console.error so it doesn't pollute test output;
+ // we still want to assert the stream itself kept flowing.
+ const originalConsoleError = console.error;
+ const errorCalls: unknown[][] = [];
+ console.error = (...args) => {
+ errorCalls.push(args);
+ };
+
+ try {
+ // Three frames: throwing handler, recoverable handler, then a request
+ // response that proves the dispatch loop wasn't broken.
+ const promise = client.request('after-throw');
+ const sent = JSON.parse(fake.stdinWrites[0].trim());
+
+ fake.emitStdout(`${JSON.stringify({ jsonrpc: '2.0', method: 'throws', params: {} })}\n`);
+ fake.emitStdout(`${JSON.stringify({ jsonrpc: '2.0', method: 'survives', params: {} })}\n`);
+ fake.emitStdout(`${JSON.stringify({ jsonrpc: '2.0', id: sent.id, result: 'still works' })}\n`);
+
+ assert.equal(await promise, 'still works');
+ // Allow the synchronous notification handlers to finish
+ await new Promise((r) => setImmediate(r));
+ assert.equal(goodCalls, 1);
+ assert.ok(errorCalls.length >= 1, 'handler errors should be logged');
+ } finally {
+ console.error = originalConsoleError;
+ }
+ });
+});
diff --git a/server/modules/providers/list/kiro/kiro-auth.provider.ts b/server/modules/providers/list/kiro/kiro-auth.provider.ts
new file mode 100644
index 0000000000..77ccc500d8
--- /dev/null
+++ b/server/modules/providers/list/kiro/kiro-auth.provider.ts
@@ -0,0 +1,205 @@
+import { readFile } from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+
+import spawn from 'cross-spawn';
+
+import type { IProviderAuth } from '@/shared/interfaces.js';
+import type { ProviderAuthStatus } from '@/shared/types.js';
+import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
+
+type KiroCredentialsStatus = {
+ authenticated: boolean;
+ email: string | null;
+ method: string | null;
+ error?: string;
+};
+
+const KIRO_BIN = process.env.KIRO_PATH ?? 'kiro-cli';
+
+export class KiroProviderAuth implements IProviderAuth {
+ /**
+ * Checks whether the Kiro CLI is installed and on PATH.
+ *
+ * Spawns asynchronously (rather than spawn.sync) so a slow or hung CLI cannot
+ * block the Node event loop while `getStatus()` runs; the process is killed if
+ * `--version` does not return within the timeout.
+ */
+ private checkInstalled(): Promise {
+ return new Promise((resolve) => {
+ let settled = false;
+ let childProcess: ReturnType | undefined;
+
+ const finish = (value: boolean) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ clearTimeout(timeout);
+ resolve(value);
+ };
+
+ const timeout = setTimeout(() => {
+ childProcess?.kill();
+ finish(false);
+ }, 5000);
+
+ try {
+ childProcess = spawn(KIRO_BIN, ['--version'], { stdio: 'ignore' });
+ } catch {
+ finish(false);
+ return;
+ }
+
+ childProcess.on('close', (code) => finish(code === 0));
+ childProcess.on('error', () => finish(false));
+ });
+ }
+
+ /**
+ * Returns Kiro CLI installation and IdC/BuilderId login status.
+ */
+ async getStatus(): Promise {
+ const installed = await this.checkInstalled();
+
+ if (!installed) {
+ return {
+ installed,
+ provider: 'kiro',
+ authenticated: false,
+ email: null,
+ method: null,
+ error: 'Kiro CLI is not installed',
+ };
+ }
+
+ const credentials = await this.checkCredentials();
+
+ return {
+ installed,
+ provider: 'kiro',
+ authenticated: credentials.authenticated,
+ email: credentials.email,
+ method: credentials.method,
+ error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
+ };
+ }
+
+ /**
+ * Resolves login state from `kiro-cli whoami`, which is the source of truth
+ * across CLI versions.
+ *
+ * `whoami` is authoritative because the on-disk token location is not stable:
+ * kiro-cli <= 2.3.0 wrote `~/.aws/sso/cache/kiro-auth-token.json`, but later
+ * versions (verified on 2.7.0) write hashed-filename token files instead, so
+ * keying auth off that single path reports a logged-in user as expired. The
+ * token file is now only a best-effort hint used to enrich the method label
+ * and surface an explicit "expired" error; it never overrides whoami.
+ */
+ private async checkCredentials(): Promise {
+ const whoami = await this.readWhoami();
+ const tokenHint = await this.readTokenHint();
+
+ if (whoami.loggedIn) {
+ return {
+ authenticated: true,
+ email: whoami.email ?? 'Authenticated',
+ method: whoami.method ?? tokenHint.method ?? 'sso',
+ };
+ }
+
+ // whoami says not logged in: prefer a precise "expired" error when the
+ // stale token file still explains why, otherwise a generic message.
+ return {
+ authenticated: false,
+ email: null,
+ method: whoami.method ?? tokenHint.method,
+ error: tokenHint.expired ? 'OAuth token has expired' : 'Not authenticated',
+ };
+ }
+
+ /**
+ * Best-effort read of the legacy SSO cache token for method/expiry hints.
+ *
+ * Returns empty hints when the file is absent (expected on newer CLIs) so a
+ * missing file never forces a not-authenticated result on its own.
+ */
+ private async readTokenHint(): Promise<{ method: string | null; expired: boolean }> {
+ try {
+ const tokenPath = path.join(os.homedir(), '.aws', 'sso', 'cache', 'kiro-auth-token.json');
+ const content = await readFile(tokenPath, 'utf8');
+ const token = readObjectRecord(JSON.parse(content)) ?? {};
+ const method = readOptionalString(token.authMethod) ?? null;
+ const expiresAt = readOptionalString(token.expiresAt);
+ const expiryMs = expiresAt ? Date.parse(expiresAt) : NaN;
+ const expired = Number.isFinite(expiryMs) ? expiryMs <= Date.now() : false;
+ return { method, expired };
+ } catch {
+ return { method: null, expired: false };
+ }
+ }
+
+ /**
+ * Runs `kiro-cli whoami` and parses login state, email, and auth method.
+ *
+ * Uses the plain (default) output format for cross-version compatibility
+ * (the `--format json` flag does not exist on older CLIs). Stdout shapes:
+ * Logged in with IAM Identity Center (https://...) -> method 'IdC'
+ * Logged in with Builder ID -> method 'BuilderId'
+ * Email: someone@example.com
+ * Not logged in -> loggedIn false
+ *
+ * `whoami` exits 0 in both states, so login is determined from the text, not
+ * the exit code.
+ */
+ private readWhoami(): Promise<{ loggedIn: boolean; email: string | null; method: string | null }> {
+ return new Promise((resolve) => {
+ let processCompleted = false;
+ let childProcess: ReturnType | undefined;
+
+ const finish = (value: { loggedIn: boolean; email: string | null; method: string | null }) => {
+ if (processCompleted) {
+ return;
+ }
+ processCompleted = true;
+ clearTimeout(timeout);
+ resolve(value);
+ };
+
+ const timeout = setTimeout(() => {
+ childProcess?.kill();
+ finish({ loggedIn: false, email: null, method: null });
+ }, 5000);
+
+ try {
+ childProcess = spawn(KIRO_BIN, ['whoami']);
+ } catch {
+ finish({ loggedIn: false, email: null, method: null });
+ return;
+ }
+
+ let stdout = '';
+ childProcess.stdout?.on('data', (data: Buffer) => {
+ stdout += data.toString();
+ });
+
+ childProcess.on('close', () => {
+ const emailMatch = stdout.match(/Email:\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
+ const loggedIn = /logged in with/i.test(stdout) || emailMatch !== null;
+ let method: string | null = null;
+ if (/identity center/i.test(stdout)) {
+ method = 'IdC';
+ } else if (/builder\s*id/i.test(stdout)) {
+ method = 'BuilderId';
+ } else if (loggedIn) {
+ method = 'sso';
+ }
+ finish({ loggedIn, email: emailMatch?.[1] ?? null, method });
+ });
+
+ childProcess.on('error', () => {
+ finish({ loggedIn: false, email: null, method: null });
+ });
+ });
+ }
+}
diff --git a/server/modules/providers/list/kiro/kiro-mcp.provider.ts b/server/modules/providers/list/kiro/kiro-mcp.provider.ts
new file mode 100644
index 0000000000..69f0b12881
--- /dev/null
+++ b/server/modules/providers/list/kiro/kiro-mcp.provider.ts
@@ -0,0 +1,163 @@
+import os from 'node:os';
+import path from 'node:path';
+
+import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
+import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
+import {
+ AppError,
+ readJsonConfig,
+ readObjectRecord,
+ readOptionalString,
+ readStringArray,
+ readStringRecord,
+ writeJsonConfig,
+} from '@/shared/utils.js';
+
+const SETTINGS_DIR = path.join('.kiro', 'settings');
+
+export class KiroMcpProvider extends McpProvider {
+ constructor() {
+ super('kiro', ['user', 'project'], ['stdio', 'http']);
+ }
+
+ protected async readScopedServers(scope: McpScope, workspacePath: string): Promise> {
+ const filePath = scope === 'user'
+ ? path.join(os.homedir(), SETTINGS_DIR, 'mcp.json')
+ : path.join(workspacePath, SETTINGS_DIR, 'mcp.json');
+ const config = await readJsonConfig(filePath);
+ return readObjectRecord(config.mcpServers) ?? {};
+ }
+
+ protected async writeScopedServers(
+ scope: McpScope,
+ workspacePath: string,
+ servers: Record,
+ ): Promise {
+ const filePath = scope === 'user'
+ ? path.join(os.homedir(), SETTINGS_DIR, 'mcp.json')
+ : path.join(workspacePath, SETTINGS_DIR, 'mcp.json');
+ const config = await readJsonConfig(filePath);
+ config.mcpServers = servers;
+ await writeJsonConfig(filePath, config);
+ }
+
+ protected buildServerConfig(input: UpsertProviderMcpServerInput): Record {
+ if (input.transport === 'stdio') {
+ if (!input.command?.trim()) {
+ throw new AppError('command is required for stdio MCP servers.', {
+ code: 'MCP_COMMAND_REQUIRED',
+ statusCode: 400,
+ });
+ }
+
+ return {
+ command: input.command,
+ args: input.args ?? [],
+ env: input.env ?? {},
+ };
+ }
+
+ if (!input.url?.trim()) {
+ throw new AppError('url is required for http MCP servers.', {
+ code: 'MCP_URL_REQUIRED',
+ statusCode: 400,
+ });
+ }
+
+ const httpConfig: Record = {
+ url: input.url,
+ headers: input.headers ?? {},
+ };
+ if (input.bearerTokenEnvVar) {
+ httpConfig.bearer_token_env_var = input.bearerTokenEnvVar;
+ }
+ return httpConfig;
+ }
+
+ protected normalizeServerConfig(
+ scope: McpScope,
+ name: string,
+ rawConfig: unknown,
+ ): ProviderMcpServer | null {
+ if (!rawConfig || typeof rawConfig !== 'object') {
+ return null;
+ }
+
+ const config = rawConfig as Record;
+ if (typeof config.command === 'string') {
+ return {
+ provider: 'kiro',
+ name,
+ scope,
+ transport: 'stdio',
+ command: config.command,
+ args: readStringArray(config.args),
+ env: readStringRecord(config.env),
+ };
+ }
+
+ if (typeof config.url === 'string') {
+ return {
+ provider: 'kiro',
+ name,
+ scope,
+ transport: 'http',
+ url: config.url,
+ headers: readStringRecord(config.headers),
+ bearerTokenEnvVar: readOptionalString(config.bearer_token_env_var),
+ };
+ }
+
+ return null;
+ }
+
+ /**
+ * Override the base `upsertServer` to preserve Kiro-specific fields that the
+ * provider-neutral `UpsertProviderMcpServerInput` does not carry: `disabled`
+ * (per-server enabled/disabled toggle) and `autoApprove` (whitelist of tool
+ * names the user has pre-trusted). Without this override, every UI edit
+ * silently re-enables disabled servers and wipes auto-approve lists.
+ *
+ * Strategy: read the existing entry BEFORE `super.upsertServer` rewrites the
+ * file, then re-apply the preserved Kiro-only keys with a follow-up write.
+ */
+ async upsertServer(input: UpsertProviderMcpServerInput): Promise {
+ const scope = input.scope ?? 'project';
+ const workspacePath = scope === 'user'
+ ? os.homedir()
+ : (input.workspacePath ?? '');
+ const filePath = scope === 'user'
+ ? path.join(os.homedir(), SETTINGS_DIR, 'mcp.json')
+ : path.join(workspacePath, SETTINGS_DIR, 'mcp.json');
+
+ // Capture Kiro-only fields BEFORE the base class wipes them on rewrite.
+ const preWriteConfig = await readJsonConfig(filePath);
+ const preWriteServers = readObjectRecord(preWriteConfig.mcpServers) ?? {};
+ const preWriteEntry = readObjectRecord(preWriteServers[input.name]);
+ const preservedDisabled = preWriteEntry
+ ? (preWriteEntry as Record).disabled
+ : undefined;
+ const preservedAutoApprove = preWriteEntry
+ ? (preWriteEntry as Record).autoApprove
+ : undefined;
+
+ const result = await super.upsertServer(input);
+
+ if (preservedDisabled !== undefined || preservedAutoApprove !== undefined) {
+ const config = await readJsonConfig(filePath);
+ const servers = readObjectRecord(config.mcpServers) ?? {};
+ const updated = readObjectRecord(servers[input.name]) ?? {};
+ if (preservedDisabled !== undefined) {
+ updated.disabled = preservedDisabled;
+ }
+ if (preservedAutoApprove !== undefined) {
+ updated.autoApprove = preservedAutoApprove;
+ }
+ servers[input.name] = updated;
+ config.mcpServers = servers;
+ await writeJsonConfig(filePath, config);
+ }
+
+ return result;
+ }
+}
diff --git a/server/modules/providers/list/kiro/kiro-models.provider.ts b/server/modules/providers/list/kiro/kiro-models.provider.ts
new file mode 100644
index 0000000000..cfb51d72a5
--- /dev/null
+++ b/server/modules/providers/list/kiro/kiro-models.provider.ts
@@ -0,0 +1,51 @@
+import type { IProviderModels } from '@/shared/interfaces.js';
+import type {
+ ProviderChangeActiveModelInput,
+ ProviderCurrentActiveModel,
+ ProviderModelsDefinition,
+ ProviderSessionActiveModelChange,
+} from '@/shared/types.js';
+import {
+ buildDefaultProviderCurrentActiveModel,
+ writeProviderSessionActiveModelChange,
+} from '@/shared/utils.js';
+
+/**
+ * Kiro (AWS) model catalog.
+ *
+ * Verified against `kiro-cli chat --list-models -f json` (Kiro CLI 2.3.0). The
+ * full catalog can be fetched dynamically, but the static list below is a
+ * conservative Claude-only subset suitable for v1. The `auto` router default
+ * lets Kiro pick the best available model for the request.
+ */
+export const KIRO_FALLBACK_MODELS: ProviderModelsDefinition = {
+ OPTIONS: [
+ { value: 'auto', label: 'Auto (router)' },
+ { value: 'claude-opus-4.7', label: 'Claude Opus 4.7' },
+ { value: 'claude-opus-4.6', label: 'Claude Opus 4.6' },
+ { value: 'claude-sonnet-4.6', label: 'Claude Sonnet 4.6' },
+ { value: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5' },
+ { value: 'claude-haiku-4.5', label: 'Claude Haiku 4.5' },
+ ],
+ DEFAULT: 'auto',
+};
+
+export class KiroProviderModels implements IProviderModels {
+ async getSupportedModels(): Promise {
+ return KIRO_FALLBACK_MODELS;
+ }
+
+ async getCurrentActiveModel(): Promise {
+ // Kiro resolves the effective model at spawn time via the `--model` CLI
+ // flag (ACP ignores model on `session/prompt`). There is no persisted
+ // per-session model on disk to read back, so fall back to the catalog
+ // default.
+ return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
+ }
+
+ async changeActiveModel(
+ input: ProviderChangeActiveModelInput,
+ ): Promise {
+ return writeProviderSessionActiveModelChange('kiro', input);
+ }
+}
diff --git a/server/modules/providers/list/kiro/kiro-session-synchronizer.provider.ts b/server/modules/providers/list/kiro/kiro-session-synchronizer.provider.ts
new file mode 100644
index 0000000000..dae07dadbb
--- /dev/null
+++ b/server/modules/providers/list/kiro/kiro-session-synchronizer.provider.ts
@@ -0,0 +1,133 @@
+import os from 'node:os';
+import path from 'node:path';
+import fs from 'node:fs';
+import { readFile } from 'node:fs/promises';
+
+import { sessionsDb } from '@/modules/database/index.js';
+import {
+ findFilesRecursivelyCreatedAfter,
+ normalizeSessionName,
+ readFileTimestamps,
+ readObjectRecord,
+ readOptionalString,
+} from '@/shared/utils.js';
+import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
+
+type ParsedSession = {
+ sessionId: string;
+ projectPath: string;
+ sessionName?: string;
+};
+
+const UNTITLED = 'Untitled Kiro Session';
+
+/**
+ * Session indexer for Kiro CLI ACP transcripts.
+ *
+ * Kiro persists each ACP session as a pair under `~/.kiro/sessions/cli/`:
+ * .jsonl — append-only event log (Prompt/AssistantMessage/ToolResults)
+ * .json — sidecar with `{session_id, cwd, created_at, updated_at, title, ...}`
+ *
+ * The sidecar is the cheaper, more reliable source for project path and
+ * display title; the JSONL is parsed by `KiroSessionsProvider.fetchHistory`.
+ */
+export class KiroSessionSynchronizer implements IProviderSessionSynchronizer {
+ private readonly provider = 'kiro' as const;
+ private readonly sessionsRoot = path.join(os.homedir(), '.kiro', 'sessions', 'cli');
+
+ /**
+ * Scans `~/.kiro/sessions/cli/*.jsonl` and upserts discovered sessions.
+ */
+ async synchronize(since?: Date): Promise {
+ if (!fs.existsSync(this.sessionsRoot)) {
+ return 0;
+ }
+
+ const files = await findFilesRecursivelyCreatedAfter(
+ this.sessionsRoot,
+ '.jsonl',
+ since ?? null,
+ );
+
+ let processed = 0;
+ for (const filePath of files) {
+ const upserted = await this.synchronizeFile(filePath);
+ if (upserted) {
+ processed += 1;
+ }
+ }
+
+ return processed;
+ }
+
+ /**
+ * Parses one JSONL session file and upserts it via the sidecar `.json`.
+ */
+ async synchronizeFile(filePath: string): Promise {
+ if (!filePath.endsWith('.jsonl')) {
+ return null;
+ }
+
+ const parsed = await this.parseSessionFromSidecar(filePath);
+ if (!parsed) {
+ return null;
+ }
+
+ // Honor any user-set custom_name. `sessionsDb.createSession` upserts
+ // with `COALESCE(excluded.custom_name, sessions.custom_name)`, so any
+ // non-null name we pass replaces the stored one. The pattern (mirrors
+ // codex-session-synchronizer.provider.ts:123-131): when the existing
+ // session has a non-default name, re-pass that name so the COALESCE
+ // is a no-op; only adopt the sidecar title when no name has been set
+ // (or when the placeholder is still the default "Untitled" string).
+ const existing = sessionsDb.getSessionById(parsed.sessionId);
+ let nameToPersist = parsed.sessionName;
+ if (existing?.custom_name && existing.custom_name !== UNTITLED) {
+ nameToPersist = existing.custom_name;
+ }
+
+ const timestamps = await readFileTimestamps(filePath);
+ return sessionsDb.createSession(
+ parsed.sessionId,
+ this.provider,
+ parsed.projectPath,
+ nameToPersist,
+ timestamps.createdAt,
+ timestamps.updatedAt,
+ filePath,
+ );
+ }
+
+ /**
+ * Reads the sidecar `.json` to extract `cwd`, `session_id`, and
+ * `title`. Falls back to deriving the session id from the filename when the
+ * sidecar is missing or malformed.
+ */
+ private async parseSessionFromSidecar(jsonlPath: string): Promise {
+ const sessionIdFromName = path.basename(jsonlPath, '.jsonl');
+ const sidecarPath = jsonlPath.replace(/\.jsonl$/, '.json');
+
+ let sidecar: Record | null = null;
+ try {
+ const content = await readFile(sidecarPath, 'utf8');
+ sidecar = readObjectRecord(JSON.parse(content));
+ } catch {
+ // Missing sidecar — without a cwd we can't index the session.
+ return null;
+ }
+
+ const sessionId = readOptionalString(sidecar?.session_id) ?? sessionIdFromName;
+ const projectPath = readOptionalString(sidecar?.cwd);
+ if (!projectPath) {
+ return null;
+ }
+
+ const title = readOptionalString(sidecar?.title);
+
+ return {
+ sessionId,
+ projectPath,
+ sessionName: normalizeSessionName(title, UNTITLED),
+ };
+ }
+}
diff --git a/server/modules/providers/list/kiro/kiro-sessions.provider.ts b/server/modules/providers/list/kiro/kiro-sessions.provider.ts
new file mode 100644
index 0000000000..23878629b6
--- /dev/null
+++ b/server/modules/providers/list/kiro/kiro-sessions.provider.ts
@@ -0,0 +1,315 @@
+import fsSync from 'node:fs';
+import readline from 'node:readline';
+
+import { sessionsDb } from '@/modules/database/index.js';
+import type { IProviderSessions } from '@/shared/interfaces.js';
+import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
+import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
+
+const PROVIDER = 'kiro';
+
+/**
+ * Kiro persists ACP sessions as JSONL at `~/.kiro/sessions/cli/.jsonl`.
+ * Each line is `{version, kind, data}` where kind is one of:
+ * - "Prompt" : user input
+ * - "AssistantMessage": assistant text and/or tool_use content parts
+ * - "ToolResults" : tool_result content parts
+ *
+ * Each `data.content[]` entry is `{kind: "text"|"toolUse"|"toolResult", data}`.
+ */
+
+type KiroContentPart = {
+ kind: 'text' | 'toolUse' | 'toolResult' | string;
+ data: unknown;
+};
+
+type KiroJsonlEntry = {
+ version?: string;
+ kind?: 'Prompt' | 'AssistantMessage' | 'ToolResults' | string;
+ data?: {
+ message_id?: string;
+ content?: KiroContentPart[];
+ meta?: { timestamp?: number };
+ status?: string;
+ };
+};
+
+function isContentPart(value: unknown): value is KiroContentPart {
+ if (!value || typeof value !== 'object') {
+ return false;
+ }
+ const record = value as AnyRecord;
+ return typeof record.kind === 'string';
+}
+
+function timestampForEntry(entry: KiroJsonlEntry): string {
+ const epochSeconds = entry.data?.meta?.timestamp;
+ if (typeof epochSeconds === 'number' && Number.isFinite(epochSeconds)) {
+ return new Date(epochSeconds * 1000).toISOString();
+ }
+ return new Date().toISOString();
+}
+
+function extractText(part: KiroContentPart): string {
+ if (part.kind !== 'text') {
+ return '';
+ }
+ return typeof part.data === 'string' ? part.data : '';
+}
+
+function flattenToolResultContent(content: unknown): { text: string; isError: boolean } {
+ if (!Array.isArray(content)) {
+ return { text: typeof content === 'string' ? content : '', isError: false };
+ }
+
+ let isError = false;
+ const text = content
+ .map((part) => {
+ if (!isContentPart(part)) {
+ return '';
+ }
+ if (part.kind === 'text') {
+ return typeof part.data === 'string' ? part.data : '';
+ }
+ if (part.kind === 'error' || part.kind === 'errorText') {
+ isError = true;
+ return typeof part.data === 'string' ? part.data : JSON.stringify(part.data);
+ }
+ return '';
+ })
+ .filter(Boolean)
+ .join('\n');
+
+ return { text, isError };
+}
+
+async function readKiroJsonl(filePath: string): Promise {
+ const entries: KiroJsonlEntry[] = [];
+ const fileStream = fsSync.createReadStream(filePath);
+ const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
+
+ for await (const line of rl) {
+ if (!line.trim()) {
+ continue;
+ }
+ try {
+ entries.push(JSON.parse(line) as KiroJsonlEntry);
+ } catch {
+ // Skip malformed lines defensively — Kiro's writer is line-buffered and a
+ // crashed turn can leave a partial trailing line.
+ }
+ }
+
+ return entries;
+}
+
+export class KiroSessionsProvider implements IProviderSessions {
+ /**
+ * Normalizes a Kiro JSONL entry into the shared NormalizedMessage shape.
+ *
+ * One entry can yield multiple messages (an AssistantMessage with both
+ * text and toolUse parts emits text + tool_use, which is why this returns
+ * an array).
+ */
+ private normalizeJsonlEntry(entry: KiroJsonlEntry, sessionId: string | null): NormalizedMessage[] {
+ const ts = timestampForEntry(entry);
+ const baseId = entry.data?.message_id ?? generateMessageId('kiro');
+ const parts = Array.isArray(entry.data?.content) ? entry.data!.content! : [];
+
+ if (entry.kind === 'Prompt') {
+ const text = parts.map(extractText).filter(Boolean).join('\n').trim();
+ if (!text) {
+ return [];
+ }
+ return [createNormalizedMessage({
+ id: baseId,
+ sessionId,
+ timestamp: ts,
+ provider: PROVIDER,
+ kind: 'text',
+ role: 'user',
+ content: text,
+ })];
+ }
+
+ if (entry.kind === 'AssistantMessage') {
+ const messages: NormalizedMessage[] = [];
+
+ // An entry can carry multiple parts of the same kind (e.g. two text
+ // chunks, or text + toolUse + text). The part index disambiguates so the
+ // generated id is unique even when toolUseId or baseId would otherwise
+ // collide across parts.
+ for (const [partIndex, part] of parts.entries()) {
+ if (!isContentPart(part)) {
+ continue;
+ }
+
+ if (part.kind === 'text') {
+ const text = typeof part.data === 'string' ? part.data : '';
+ if (text.trim()) {
+ messages.push(createNormalizedMessage({
+ id: `${baseId}_text_${partIndex}`,
+ sessionId,
+ timestamp: ts,
+ provider: PROVIDER,
+ kind: 'text',
+ role: 'assistant',
+ content: text,
+ }));
+ }
+ continue;
+ }
+
+ if (part.kind === 'toolUse') {
+ const data = readObjectRecord(part.data) ?? {};
+ const toolUseId = typeof data.toolUseId === 'string' ? data.toolUseId : `${baseId}_tool_${partIndex}`;
+ const toolName = typeof data.name === 'string' ? data.name : 'Unknown';
+ messages.push(createNormalizedMessage({
+ id: toolUseId,
+ sessionId,
+ timestamp: ts,
+ provider: PROVIDER,
+ kind: 'tool_use',
+ toolName,
+ toolInput: data.input,
+ toolId: toolUseId,
+ }));
+ }
+ }
+
+ return messages;
+ }
+
+ if (entry.kind === 'ToolResults') {
+ const messages: NormalizedMessage[] = [];
+ // Kiro sets `data.status` to "success" or "error" at the entry level;
+ // the per-content-part `error`/`errorText` kinds (handled in
+ // `flattenToolResultContent`) are a fallback for older event versions.
+ const entryStatus = typeof entry.data?.status === 'string' ? entry.data!.status : null;
+ const entryIsError = entryStatus !== null && entryStatus !== 'success';
+
+ for (const [partIndex, part] of parts.entries()) {
+ if (!isContentPart(part) || part.kind !== 'toolResult') {
+ continue;
+ }
+ const data = readObjectRecord(part.data) ?? {};
+ const toolUseId = typeof data.toolUseId === 'string' ? data.toolUseId : '';
+ const { text, isError: contentIsError } = flattenToolResultContent(data.content);
+ // Two tool_result parts for the same tool would otherwise share an id.
+ // Use the part index as the disambiguator to keep keyed React render
+ // and message-association lookups stable.
+ messages.push(createNormalizedMessage({
+ id: `${toolUseId || baseId}_result_${partIndex}`,
+ sessionId,
+ timestamp: ts,
+ provider: PROVIDER,
+ kind: 'tool_result',
+ toolId: toolUseId,
+ content: text,
+ isError: entryIsError || contentIsError,
+ }));
+ }
+
+ return messages;
+ }
+
+ return [];
+ }
+
+ /**
+ * Normalizes either a Kiro JSONL history entry or a transformed live ACP
+ * `session/update` notification (forwarded by `server/kiro-cli.js`).
+ */
+ normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
+ const raw = readObjectRecord(rawMessage);
+ if (!raw) {
+ return [];
+ }
+
+ if (typeof raw.kind === 'string' && raw.data && typeof raw.data === 'object') {
+ return this.normalizeJsonlEntry(raw as KiroJsonlEntry, sessionId);
+ }
+
+ // Live ACP `session/update` notifications are pre-normalized by the runtime
+ // module (`server/kiro-cli.js`). Anything that reaches this method without
+ // the `{kind, data}` JSONL shape is treated as already-normalized.
+ if (typeof raw.kind === 'string' && typeof raw.provider === 'string') {
+ return [raw as NormalizedMessage];
+ }
+
+ return [];
+ }
+
+ /**
+ * Loads Kiro JSONL session history off disk.
+ */
+ async fetchHistory(
+ sessionId: string,
+ options: FetchHistoryOptions = {},
+ ): Promise {
+ const { limit = null, offset = 0 } = options;
+
+ let entries: KiroJsonlEntry[];
+ try {
+ const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
+ if (!sessionFilePath) {
+ return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
+ }
+ entries = await readKiroJsonl(sessionFilePath);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.warn(`[KiroProvider] Failed to load session ${sessionId}:`, message);
+ return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
+ }
+
+ const normalized: NormalizedMessage[] = [];
+ for (const entry of entries) {
+ normalized.push(...this.normalizeJsonlEntry(entry, sessionId));
+ }
+
+ // Backfill tool_result content onto matching tool_use messages so the UI
+ // can render the request/response pair as one card. Same shape as Codex's
+ // pairing pass (codex-sessions.provider.ts:540-553).
+ const toolResultMap = new Map();
+ for (const msg of normalized) {
+ if (msg.kind === 'tool_result' && msg.toolId) {
+ toolResultMap.set(msg.toolId, msg);
+ }
+ }
+ for (const msg of normalized) {
+ if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
+ const toolResult = toolResultMap.get(msg.toolId);
+ if (toolResult) {
+ msg.toolResult = { content: toolResult.content, isError: toolResult.isError };
+ }
+ }
+ }
+
+ const totalNormalized = normalized.length;
+ let total = 0;
+ for (const msg of normalized) {
+ if (msg.kind !== 'tool_result') {
+ total += 1;
+ }
+ }
+ const normalizedOffset = Math.max(0, offset);
+ const normalizedLimit = limit === null ? null : Math.max(0, limit);
+ const messages = normalizedLimit === null
+ ? normalized
+ : normalized.slice(
+ Math.max(0, totalNormalized - normalizedOffset - normalizedLimit),
+ Math.max(0, totalNormalized - normalizedOffset),
+ );
+ const hasMore = normalizedLimit === null
+ ? false
+ : Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0;
+
+ return {
+ messages,
+ total,
+ hasMore,
+ offset: normalizedOffset,
+ limit: normalizedLimit,
+ };
+ }
+}
diff --git a/server/modules/providers/list/kiro/kiro-skills.provider.ts b/server/modules/providers/list/kiro/kiro-skills.provider.ts
new file mode 100644
index 0000000000..68c90b9c50
--- /dev/null
+++ b/server/modules/providers/list/kiro/kiro-skills.provider.ts
@@ -0,0 +1,36 @@
+import os from 'node:os';
+import path from 'node:path';
+
+import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
+import type { ProviderSkillSource } from '@/shared/types.js';
+
+export class KiroSkillsProvider extends SkillsProvider {
+ constructor() {
+ super('kiro');
+ }
+
+ protected async getSkillSources(workspacePath: string): Promise {
+ return [
+ {
+ scope: 'user',
+ rootDir: path.join(os.homedir(), '.kiro', 'skills'),
+ commandPrefix: '/',
+ },
+ {
+ scope: 'user',
+ rootDir: path.join(os.homedir(), '.agents', 'skills'),
+ commandPrefix: '/',
+ },
+ {
+ scope: 'project',
+ rootDir: path.join(workspacePath, '.kiro', 'skills'),
+ commandPrefix: '/',
+ },
+ {
+ scope: 'project',
+ rootDir: path.join(workspacePath, '.agents', 'skills'),
+ commandPrefix: '/',
+ },
+ ];
+ }
+}
diff --git a/server/modules/providers/list/kiro/kiro.provider.ts b/server/modules/providers/list/kiro/kiro.provider.ts
new file mode 100644
index 0000000000..fd3c98fe0e
--- /dev/null
+++ b/server/modules/providers/list/kiro/kiro.provider.ts
@@ -0,0 +1,27 @@
+import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
+import { KiroProviderAuth } from '@/modules/providers/list/kiro/kiro-auth.provider.js';
+import { KiroProviderModels } from '@/modules/providers/list/kiro/kiro-models.provider.js';
+import { KiroMcpProvider } from '@/modules/providers/list/kiro/kiro-mcp.provider.js';
+import { KiroSessionSynchronizer } from '@/modules/providers/list/kiro/kiro-session-synchronizer.provider.js';
+import { KiroSessionsProvider } from '@/modules/providers/list/kiro/kiro-sessions.provider.js';
+import { KiroSkillsProvider } from '@/modules/providers/list/kiro/kiro-skills.provider.js';
+import type {
+ IProviderAuth,
+ IProviderModels,
+ IProviderSessionSynchronizer,
+ IProviderSessions,
+ IProviderSkills,
+} from '@/shared/interfaces.js';
+
+export class KiroProvider extends AbstractProvider {
+ readonly models: IProviderModels = new KiroProviderModels();
+ readonly mcp = new KiroMcpProvider();
+ readonly auth: IProviderAuth = new KiroProviderAuth();
+ readonly skills: IProviderSkills = new KiroSkillsProvider();
+ readonly sessions: IProviderSessions = new KiroSessionsProvider();
+ readonly sessionSynchronizer: IProviderSessionSynchronizer = new KiroSessionSynchronizer();
+
+ constructor() {
+ super('kiro');
+ }
+}
diff --git a/server/modules/providers/list/kiro/stdio-jsonrpc-client.ts b/server/modules/providers/list/kiro/stdio-jsonrpc-client.ts
new file mode 100644
index 0000000000..3c88dd4759
--- /dev/null
+++ b/server/modules/providers/list/kiro/stdio-jsonrpc-client.ts
@@ -0,0 +1,240 @@
+import type { ChildProcessWithoutNullStreams } from 'node:child_process';
+
+/**
+ * Minimal JSON-RPC 2.0 client over a child process's stdio.
+ *
+ * Designed for Kiro CLI's ACP (Agent Client Protocol) endpoint, but agnostic
+ * of the method names used. Handles:
+ * - line-buffered stdout (one JSON-RPC frame per line)
+ * - request/response correlation by `id`
+ * - notification dispatch by method name (with prefix-matching support so
+ * Kiro's `_kiro.dev/*` extension namespace can register a wildcard handler)
+ * - graceful close that rejects all in-flight requests with the close reason
+ */
+
+type Handler = (params: unknown) => void;
+
+type PendingRequest = {
+ resolve: (value: unknown) => void;
+ reject: (reason: unknown) => void;
+ method: string;
+};
+
+export type StdioJsonRpcClientOptions = {
+ /** Maximum time to wait for a response, in ms. Default 120_000 (2 minutes). */
+ requestTimeoutMs?: number;
+ /** Optional callback for stderr lines from the child. */
+ onStderr?: (line: string) => void;
+ /** Optional callback for parse failures. */
+ onParseError?: (rawLine: string, error: unknown) => void;
+};
+
+/**
+ * Wraps a spawned child process and exposes JSON-RPC request/notify/onNotification.
+ *
+ * Caller owns the child process lifecycle (spawning and killing). This client
+ * only attaches stdout/stderr listeners and writes to stdin.
+ */
+export class StdioJsonRpcClient {
+ private nextId = 1;
+ private readonly pending = new Map();
+ private readonly handlers = new Map();
+ private readonly prefixHandlers = new Map();
+ private readonly options: Required> &
+ Pick;
+ private stdoutBuffer = '';
+ private closed = false;
+
+ constructor(
+ private readonly child: ChildProcessWithoutNullStreams,
+ options: StdioJsonRpcClientOptions = {},
+ ) {
+ this.options = {
+ requestTimeoutMs: options.requestTimeoutMs ?? 120_000,
+ onStderr: options.onStderr,
+ onParseError: options.onParseError,
+ };
+
+ this.child.stdout.setEncoding('utf8');
+ this.child.stdout.on('data', this.handleStdoutChunk);
+ if (this.child.stderr) {
+ this.child.stderr.setEncoding('utf8');
+ this.child.stderr.on('data', this.handleStderrChunk);
+ }
+ this.child.on('close', this.handleClose);
+ this.child.on('error', (error) => this.handleClose(null, null, error));
+ }
+
+ /**
+ * Sends a JSON-RPC request and resolves with the typed result.
+ */
+ request(method: string, params?: unknown): Promise {
+ if (this.closed) {
+ return Promise.reject(new Error(`JSON-RPC client is closed (request: ${method})`));
+ }
+
+ const id = this.nextId;
+ this.nextId += 1;
+ const frame = JSON.stringify({ jsonrpc: '2.0', id, method, params });
+
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ this.pending.delete(id);
+ reject(new Error(`JSON-RPC request '${method}' timed out after ${this.options.requestTimeoutMs}ms`));
+ }, this.options.requestTimeoutMs);
+
+ this.pending.set(id, {
+ resolve: (value) => {
+ clearTimeout(timer);
+ resolve(value as TResult);
+ },
+ reject: (reason) => {
+ clearTimeout(timer);
+ reject(reason);
+ },
+ method,
+ });
+
+ this.child.stdin.write(`${frame}\n`, (error) => {
+ if (error) {
+ clearTimeout(timer);
+ this.pending.delete(id);
+ reject(error);
+ }
+ });
+ });
+ }
+
+ /**
+ * Sends a JSON-RPC notification (no response expected).
+ */
+ notify(method: string, params?: unknown): void {
+ if (this.closed) {
+ return;
+ }
+ const frame = JSON.stringify({ jsonrpc: '2.0', method, params });
+ this.child.stdin.write(`${frame}\n`);
+ }
+
+ /**
+ * Registers a notification handler for a specific method name.
+ *
+ * Returns a disposer that removes the handler.
+ */
+ onNotification(method: string, handler: Handler): () => void {
+ this.handlers.set(method, handler);
+ return () => this.handlers.delete(method);
+ }
+
+ /**
+ * Registers a notification handler for any method whose name starts with the
+ * given prefix (e.g. `_kiro.dev/`). Useful for protocol extension namespaces.
+ */
+ onNotificationPrefix(prefix: string, handler: Handler): () => void {
+ this.prefixHandlers.set(prefix, handler);
+ return () => this.prefixHandlers.delete(prefix);
+ }
+
+ /**
+ * True after the child process has exited or errored.
+ */
+ isClosed(): boolean {
+ return this.closed;
+ }
+
+ private handleStdoutChunk = (chunk: string): void => {
+ this.stdoutBuffer += chunk;
+
+ let newlineIndex: number;
+ while ((newlineIndex = this.stdoutBuffer.indexOf('\n')) >= 0) {
+ const rawLine = this.stdoutBuffer.slice(0, newlineIndex).trim();
+ this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
+ if (!rawLine) {
+ continue;
+ }
+
+ let frame: Record;
+ try {
+ const parsed = JSON.parse(rawLine);
+ if (!parsed || typeof parsed !== 'object') {
+ continue;
+ }
+ frame = parsed as Record;
+ } catch (error) {
+ this.options.onParseError?.(rawLine, error);
+ continue;
+ }
+
+ this.dispatchFrame(frame);
+ }
+ };
+
+ private handleStderrChunk = (chunk: string): void => {
+ if (!this.options.onStderr) {
+ return;
+ }
+ for (const line of chunk.split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (trimmed) {
+ this.options.onStderr(trimmed);
+ }
+ }
+ };
+
+ private handleClose = (code?: number | null, _signal?: NodeJS.Signals | null, error?: Error): void => {
+ if (this.closed) {
+ return;
+ }
+ this.closed = true;
+ const reason = error
+ ? error
+ : new Error(code === 0 ? 'JSON-RPC stream closed' : `JSON-RPC stream closed with code ${code ?? 'unknown'}`);
+ for (const pending of this.pending.values()) {
+ pending.reject(reason);
+ }
+ this.pending.clear();
+ };
+
+ private dispatchFrame(frame: Record): void {
+ const id = typeof frame.id === 'number' ? frame.id : null;
+ const method = typeof frame.method === 'string' ? frame.method : null;
+
+ if (id !== null && this.pending.has(id)) {
+ const pending = this.pending.get(id)!;
+ this.pending.delete(id);
+ if (frame.error && typeof frame.error === 'object') {
+ const err = frame.error as Record;
+ const message = typeof err.message === 'string' ? err.message : 'JSON-RPC error';
+ const rpcError = new Error(`${message} (method: ${pending.method})`);
+ (rpcError as Error & { data?: unknown }).data = err.data;
+ pending.reject(rpcError);
+ return;
+ }
+ pending.resolve(frame.result);
+ return;
+ }
+
+ if (method) {
+ const params = frame.params;
+ const exact = this.handlers.get(method);
+ if (exact) {
+ try {
+ exact(params);
+ } catch (handlerError) {
+ // Handler errors must not break the JSON-RPC stream — but they ARE
+ // logged so silent regressions don't slip past the stderr console.
+ console.error(`[StdioJsonRpcClient] notification handler for "${method}" threw:`, handlerError);
+ }
+ }
+ for (const [prefix, handler] of this.prefixHandlers) {
+ if (method.startsWith(prefix)) {
+ try {
+ handler(params);
+ } catch (handlerError) {
+ console.error(`[StdioJsonRpcClient] prefix-"${prefix}" handler threw on "${method}":`, handlerError);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/server/modules/providers/provider.registry.ts b/server/modules/providers/provider.registry.ts
index a9f0d26b1a..109eecf564 100644
--- a/server/modules/providers/provider.registry.ts
+++ b/server/modules/providers/provider.registry.ts
@@ -2,6 +2,7 @@ import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js';
import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js';
+import { KiroProvider } from '@/modules/providers/list/kiro/kiro.provider.js';
import { OpenCodeProvider } from '@/modules/providers/list/opencode/opencode.provider.js';
import type { IProvider } from '@/shared/interfaces.js';
import type { LLMProvider } from '@/shared/types.js';
@@ -13,6 +14,7 @@ const providers: Record = {
cursor: new CursorProvider(),
gemini: new GeminiProvider(),
opencode: new OpenCodeProvider(),
+ kiro: new KiroProvider(),
};
/**
diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts
index ec76a7db11..8d8ef9274f 100644
--- a/server/modules/providers/provider.routes.ts
+++ b/server/modules/providers/provider.routes.ts
@@ -187,6 +187,7 @@ const parseProvider = (value: unknown): LLMProvider => {
|| normalized === 'cursor'
|| normalized === 'gemini'
|| normalized === 'opencode'
+ || normalized === 'kiro'
) {
return normalized;
}
diff --git a/server/modules/providers/services/provider-capabilities.service.ts b/server/modules/providers/services/provider-capabilities.service.ts
index 1b7cbbb3f1..8a164a7116 100644
--- a/server/modules/providers/services/provider-capabilities.service.ts
+++ b/server/modules/providers/services/provider-capabilities.service.ts
@@ -75,6 +75,18 @@ const PROVIDER_CAPABILITIES: Record = {
supportsPermissionRequests: false,
supportsTokenUsage: true,
},
+ kiro: {
+ provider: 'kiro',
+ // Kiro runs ACP with `--trust-all-tools`, so it has no interactive
+ // permission ladder and no tool-approval prompts. Token/credit usage is
+ // not yet surfaced by the runtime (see kiro-cli.js), so it stays off.
+ permissionModes: ['default'],
+ defaultPermissionMode: 'default',
+ supportsImages: false,
+ supportsAbort: true,
+ supportsPermissionRequests: false,
+ supportsTokenUsage: false,
+ },
};
/**
diff --git a/server/modules/providers/services/session-synchronizer.service.ts b/server/modules/providers/services/session-synchronizer.service.ts
index 55b41f9e28..9c588734f0 100644
--- a/server/modules/providers/services/session-synchronizer.service.ts
+++ b/server/modules/providers/services/session-synchronizer.service.ts
@@ -23,6 +23,7 @@ export const sessionSynchronizerService = {
cursor: 0,
gemini: 0,
opencode: 0,
+ kiro: 0,
};
const failures: string[] = [];
diff --git a/server/modules/providers/services/sessions-watcher.service.ts b/server/modules/providers/services/sessions-watcher.service.ts
index cfbdb88756..06ab0aadd9 100644
--- a/server/modules/providers/services/sessions-watcher.service.ts
+++ b/server/modules/providers/services/sessions-watcher.service.ts
@@ -39,6 +39,10 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> =
provider: 'opencode',
rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'),
},
+ {
+ provider: 'kiro',
+ rootPath: path.join(os.homedir(), '.kiro', 'sessions', 'cli'),
+ },
];
const WATCHER_IGNORED_PATTERNS = [
diff --git a/server/modules/providers/tests/mcp.test.ts b/server/modules/providers/tests/mcp.test.ts
index f10b135489..693a0f82ee 100644
--- a/server/modules/providers/tests/mcp.test.ts
+++ b/server/modules/providers/tests/mcp.test.ts
@@ -341,7 +341,8 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
workspacePath,
});
- assert.equal(globalResult.length, 5);
+ // Six providers (claude, codex, cursor, gemini, opencode, kiro).
+ assert.equal(globalResult.length, 6);
assert.ok(globalResult.every((entry) => entry.created === true));
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
@@ -359,6 +360,9 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
assert.ok((cursorProject.mcpServers as Record)['global-http']);
+ const kiroProject = await readJson(path.join(workspacePath, '.kiro', 'settings', 'mcp.json'));
+ assert.ok((kiroProject.mcpServers as Record)['global-http']);
+
await assert.rejects(
providerMcpService.addMcpServerToAllProviders({
name: 'global-sse',
diff --git a/server/modules/websocket/services/shell-websocket.service.ts b/server/modules/websocket/services/shell-websocket.service.ts
index d41b781f7a..bb75fee725 100644
--- a/server/modules/websocket/services/shell-websocket.service.ts
+++ b/server/modules/websocket/services/shell-websocket.service.ts
@@ -161,6 +161,16 @@ function buildShellCommand(
return initialCommand || 'opencode';
}
+ if (provider === 'kiro') {
+ // The Shell tab drives the interactive `kiro-cli chat` REPL (the binary is
+ // `kiro-cli`, not `kiro`); the ACP/`--trust-all-tools` invocation is for
+ // the headless chat gateway only. Resume targets a specific conversation.
+ if (resumeSessionId) {
+ return `kiro-cli chat --resume-id "${resumeSessionId}"`;
+ }
+ return initialCommand || 'kiro-cli chat';
+ }
+
const command = initialCommand || 'claude';
if (resumeSessionId) {
if (os.platform() === 'win32') {
@@ -423,6 +433,8 @@ export function handleShellConnection(
? 'Gemini'
: provider === 'opencode'
? 'OpenCode'
+ : provider === 'kiro'
+ ? 'Kiro'
: 'Claude';
welcomeMsg = hasSession && resumeSessionId
? `\x1b[36mResuming ${providerName} session ${resumeSessionId} in: ${projectPath}\x1b[0m\r\n`
diff --git a/server/routes/agent.js b/server/routes/agent.js
index f2273181fe..f6593a7b24 100644
--- a/server/routes/agent.js
+++ b/server/routes/agent.js
@@ -10,6 +10,7 @@ import { spawnCursor } from '../cursor-cli.js';
import { queryCodex } from '../openai-codex.js';
import { spawnGemini } from '../gemini-cli.js';
import { spawnOpenCode } from '../opencode-cli.js';
+import { spawnKiro } from '../kiro-cli.js';
import { Octokit } from '@octokit/rest';
import { providerModelsService } from '../modules/providers/services/provider-models.service.js';
import { IS_PLATFORM } from '../constants/config.js';
@@ -636,7 +637,7 @@ class ResponseCollector {
* - Source for auto-generated branch names (if createBranch=true and no branchName)
* - Fallback for PR title if no commits are made
*
- * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode'
+ * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode' | 'kiro'
* Default: 'claude'
*
* @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
@@ -754,7 +755,7 @@ class ResponseCollector {
* Input Validations (400 Bad Request):
* - Either githubUrl OR projectPath must be provided (not neither)
* - message must be non-empty string
- * - provider must be 'claude', 'cursor', 'codex', 'gemini', or 'opencode'
+ * - provider must be 'claude', 'cursor', 'codex', 'gemini', 'opencode', or 'kiro'
* - createBranch/createPR requires githubUrl OR projectPath (not neither)
* - branchName must pass Git naming rules (if provided)
*
@@ -862,8 +863,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
return res.status(400).json({ error: 'message is required' });
}
- if (!['claude', 'cursor', 'codex', 'gemini', 'opencode'].includes(provider)) {
- return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "gemini", or "opencode"' });
+ if (!['claude', 'cursor', 'codex', 'gemini', 'opencode', 'kiro'].includes(provider)) {
+ return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "gemini", "opencode", or "kiro"' });
}
// Validate GitHub branch/PR creation requirements
@@ -996,6 +997,15 @@ router.post('/', validateExternalApiKey, async (req, res) => {
sessionId: sessionId || null,
model: model || opencodeModels.DEFAULT
}, writer);
+ } else if (provider === 'kiro') {
+ console.log('☁️ Starting Kiro CLI session');
+
+ await spawnKiro(message.trim(), {
+ projectPath: finalProjectPath,
+ cwd: finalProjectPath,
+ sessionId: sessionId || null,
+ model: model || undefined,
+ }, writer);
}
// Handle GitHub branch and PR creation after successful agent completion
diff --git a/server/routes/commands.js b/server/routes/commands.js
index ea223f1238..dbc548c3ff 100644
--- a/server/routes/commands.js
+++ b/server/routes/commands.js
@@ -15,7 +15,7 @@ const APP_ROOT = findAppRoot(__dirname);
const router = express.Router();
-const MODEL_PROVIDERS = ["claude", "cursor", "codex", "gemini", "opencode"];
+const MODEL_PROVIDERS = ["claude", "cursor", "codex", "gemini", "opencode", "kiro"];
const MODEL_PROVIDER_LABELS = {
claude: "Claude",
@@ -23,6 +23,7 @@ const MODEL_PROVIDER_LABELS = {
codex: "Codex",
gemini: "Gemini",
opencode: "OpenCode",
+ kiro: "Kiro",
};
const readModelProvider = (value) => {
@@ -531,16 +532,31 @@ router.post("/execute", async (req, res) => {
}
// Load command content
- // Security: validate commandPath is within allowed directories
+ // Security: validate commandPath is within allowed directories.
+ // Resolve to canonical paths (realpath) first so a symlink inside
+ // .claude/commands cannot point at a file outside the allowed roots and
+ // still pass the containment check. realpath throws if the target does not
+ // exist, which we treat as access denied.
{
- const resolvedPath = path.resolve(commandPath);
- const userBase = path.resolve(
+ const canonicalize = async (target) => {
+ try {
+ return await fs.realpath(target);
+ } catch {
+ return null;
+ }
+ };
+
+ const resolvedPath = await canonicalize(commandPath);
+ const userBase = await canonicalize(
path.join(os.homedir(), ".claude", "commands"),
);
const projectBase = context?.projectPath
- ? path.resolve(path.join(context.projectPath, ".claude", "commands"))
+ ? await canonicalize(path.join(context.projectPath, ".claude", "commands"))
: null;
const isUnder = (base) => {
+ if (!base || !resolvedPath) {
+ return false;
+ }
const rel = path.relative(base, resolvedPath);
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
};
diff --git a/server/shared/types.ts b/server/shared/types.ts
index 91c477a609..ad503b0a13 100644
--- a/server/shared/types.ts
+++ b/server/shared/types.ts
@@ -65,7 +65,7 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & {
* Use this as the source of truth whenever a function or payload needs to identify
* a specific LLM integration.
*/
-export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode';
+export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'kiro';
/**
* One selectable model row in a provider model catalog.
diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts
index c1f86f2db9..5991723dec 100644
--- a/src/components/chat/hooks/useChatComposerState.ts
+++ b/src/components/chat/hooks/useChatComposerState.ts
@@ -39,6 +39,7 @@ interface UseChatComposerStateArgs {
codexModel: string;
geminiModel: string;
opencodeModel: string;
+ kiroModel: string;
isLoading: boolean;
canAbortSession: boolean;
tokenBudget: Record | null;
@@ -173,6 +174,7 @@ export function useChatComposerState({
codexModel,
geminiModel,
opencodeModel,
+ kiroModel,
isLoading,
canAbortSession,
tokenBudget,
@@ -334,7 +336,9 @@ export function useChatComposerState({
? geminiModel
: provider === 'opencode'
? opencodeModel
- : claudeModel,
+ : provider === 'kiro'
+ ? kiroModel
+ : claudeModel,
tokenUsage: tokenBudget,
};
@@ -389,6 +393,7 @@ export function useChatComposerState({
cursorModel,
geminiModel,
opencodeModel,
+ kiroModel,
handleBuiltInCommand,
handleCustomCommand,
input,
@@ -685,6 +690,8 @@ export function useChatComposerState({
? 'gemini-settings'
: provider === 'opencode'
? 'opencode-settings'
+ : provider === 'kiro'
+ ? 'kiro-settings'
: 'claude-settings';
const savedSettings = safeLocalStorage.getItem(settingsKey);
if (savedSettings) {
@@ -711,7 +718,9 @@ export function useChatComposerState({
? geminiModel
: provider === 'opencode'
? opencodeModel
- : claudeModel;
+ : provider === 'kiro'
+ ? kiroModel
+ : claudeModel;
// One message shape for every provider. The backend resolves the
// provider, project path, and provider-native resume id from the
@@ -756,6 +765,7 @@ export function useChatComposerState({
executeCommand,
geminiModel,
opencodeModel,
+ kiroModel,
isLoading,
onSessionProcessing,
onSessionEstablished,
diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts
index a2910b0df4..a4685fd8a0 100644
--- a/src/components/chat/hooks/useChatProviderState.ts
+++ b/src/components/chat/hooks/useChatProviderState.ts
@@ -15,6 +15,7 @@ const FALLBACK_DEFAULT_MODEL: Record = {
codex: 'gpt-5.4',
gemini: 'gemini-3.1-pro-preview',
opencode: 'anthropic/claude-sonnet-4-5',
+ kiro: 'auto',
};
/**
@@ -29,6 +30,7 @@ const FALLBACK_PERMISSION_MODES: Record = {
codex: ['default', 'acceptEdits', 'bypassPermissions'],
gemini: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
opencode: ['default'],
+ kiro: ['default'],
};
type ProviderCapabilities = {
@@ -93,6 +95,9 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const [opencodeModel, setOpenCodeModel] = useState(() => {
return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode;
});
+ const [kiroModel, setKiroModel] = useState(() => {
+ return localStorage.getItem('kiro-model') || FALLBACK_DEFAULT_MODEL.kiro;
+ });
/**
* Backend-owned capability matrix keyed by provider. Drives the permission
@@ -142,12 +147,18 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
return;
}
+ if (targetProvider === 'kiro') {
+ setKiroModel(model);
+ localStorage.setItem('kiro-model', model);
+ return;
+ }
+
setOpenCodeModel(model);
localStorage.setItem('opencode-model', model);
}, []);
const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => {
- const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
+ const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'kiro'];
const requestId = providerModelsRequestIdRef.current + 1;
providerModelsRequestIdRef.current = requestId;
const isHardRefresh = options.bypassCache === true;
@@ -325,6 +336,19 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
}
}, [providerModelCatalog.opencode, opencodeModel]);
+ useEffect(() => {
+ const kiro = providerModelCatalog.kiro;
+ if (kiro) {
+ const next = pickStoredOrCurrent('kiro-model', kiroModel, kiro);
+ if (next !== kiroModel) {
+ setKiroModel(next);
+ }
+ if (localStorage.getItem('kiro-model') !== next) {
+ localStorage.setItem('kiro-model', next);
+ }
+ }
+ }, [providerModelCatalog.kiro, kiroModel]);
+
useEffect(() => {
if (!selectedSession?.id) {
return;
@@ -441,6 +465,8 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
setGeminiModel,
opencodeModel,
setOpenCodeModel,
+ kiroModel,
+ setKiroModel,
permissionMode,
setPermissionMode,
pendingPermissionRequests,
diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx
index 5efe6af487..f2f92eda51 100644
--- a/src/components/chat/view/ChatInterface.tsx
+++ b/src/components/chat/view/ChatInterface.tsx
@@ -75,6 +75,8 @@ function ChatInterface({
setGeminiModel,
opencodeModel,
setOpenCodeModel,
+ kiroModel,
+ setKiroModel,
permissionMode,
pendingPermissionRequests,
setPendingPermissionRequests,
@@ -200,6 +202,7 @@ function ChatInterface({
codexModel,
geminiModel,
opencodeModel,
+ kiroModel,
isLoading: isProcessing,
canAbortSession,
tokenBudget,
@@ -291,6 +294,8 @@ function ChatInterface({
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
+ : provider === 'kiro'
+ ? t('messageTypes.kiro', { defaultValue: 'Kiro' })
: t('messageTypes.claude');
return (
@@ -334,6 +339,8 @@ function ChatInterface({
setOpenCodeModel={setOpenCodeModel}
providerModelCatalog={providerModelCatalog}
providerModelsLoading={providerModelsLoading}
+ kiroModel={kiroModel}
+ setKiroModel={setKiroModel}
tasksEnabled={tasksEnabled}
isTaskMasterInstalled={isTaskMasterInstalled}
onShowAllTasks={onShowAllTasks}
@@ -422,6 +429,8 @@ function ChatInterface({
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
+ : provider === 'kiro'
+ ? t('messageTypes.kiro', { defaultValue: 'Kiro' })
: t('messageTypes.claude'),
})}
isTextareaExpanded={isTextareaExpanded}
diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx
index 5573b31fa1..801a0065c9 100644
--- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx
+++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx
@@ -39,6 +39,8 @@ interface ChatMessagesPaneProps {
setOpenCodeModel: (model: string) => void;
providerModelCatalog: Partial>;
providerModelsLoading: boolean;
+ kiroModel: string;
+ setKiroModel: (model: string) => void;
tasksEnabled: boolean;
isTaskMasterInstalled: boolean | null;
onShowAllTasks?: (() => void) | null;
@@ -89,6 +91,8 @@ export default function ChatMessagesPane({
setOpenCodeModel,
providerModelCatalog,
providerModelsLoading,
+ kiroModel,
+ setKiroModel,
tasksEnabled,
isTaskMasterInstalled,
onShowAllTasks,
@@ -176,6 +180,8 @@ export default function ChatMessagesPane({
setOpenCodeModel={setOpenCodeModel}
providerModelCatalog={providerModelCatalog}
providerModelsLoading={providerModelsLoading}
+ kiroModel={kiroModel}
+ setKiroModel={setKiroModel}
tasksEnabled={tasksEnabled}
isTaskMasterInstalled={isTaskMasterInstalled}
onShowAllTasks={onShowAllTasks}
diff --git a/src/components/chat/view/subcomponents/CommandResultModal.tsx b/src/components/chat/view/subcomponents/CommandResultModal.tsx
index 2e391ebe8c..20580a9f0b 100644
--- a/src/components/chat/view/subcomponents/CommandResultModal.tsx
+++ b/src/components/chat/view/subcomponents/CommandResultModal.tsx
@@ -78,6 +78,7 @@ const PROVIDER_LABELS: Record = {
codex: 'Codex',
gemini: 'Gemini',
opencode: 'OpenCode',
+ kiro: 'Kiro',
};
const FALLBACK_COMMANDS: CommandEntry[] = [
diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx
index ba69bf90e9..d02691b783 100644
--- a/src/components/chat/view/subcomponents/MessageComponent.tsx
+++ b/src/components/chat/view/subcomponents/MessageComponent.tsx
@@ -182,7 +182,9 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
- : t('messageTypes.claude'))}
+ : provider === 'kiro'
+ ? t('messageTypes.kiro', { defaultValue: 'Kiro' })
+ : t('messageTypes.claude'))}
)}
diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
index 6d97ca886d..44141e519a 100644
--- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
+++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
@@ -29,6 +29,7 @@ const PROVIDER_META: { id: LLMProvider; name: string }[] = [
{ id: "gemini", name: "Google" },
{ id: "cursor", name: "Cursor" },
{ id: "opencode", name: "OpenCode" },
+ { id: "kiro", name: "AWS Kiro" },
];
const MOD_KEY =
@@ -50,6 +51,8 @@ type ProviderSelectionEmptyStateProps = {
setGeminiModel: (model: string) => void;
opencodeModel: string;
setOpenCodeModel: (model: string) => void;
+ kiroModel: string;
+ setKiroModel: (model: string) => void;
providerModelCatalog: Partial>;
providerModelsLoading: boolean;
tasksEnabled: boolean;
@@ -79,11 +82,13 @@ function getCurrentModel(
co: string,
g: string,
o: string,
+ k: string,
) {
if (p === "claude") return c;
if (p === "codex") return co;
if (p === "gemini") return g;
if (p === "opencode") return o;
+ if (p === "kiro") return k;
return cu;
}
@@ -92,6 +97,7 @@ function getProviderDisplayName(p: LLMProvider) {
if (p === "cursor") return "Cursor";
if (p === "codex") return "Codex";
if (p === "opencode") return "OpenCode";
+ if (p === "kiro") return "Kiro";
return "Gemini";
}
@@ -111,6 +117,8 @@ export default function ProviderSelectionEmptyState({
setGeminiModel,
opencodeModel,
setOpenCodeModel,
+ kiroModel,
+ setKiroModel,
providerModelCatalog,
providerModelsLoading,
tasksEnabled,
@@ -140,6 +148,7 @@ export default function ProviderSelectionEmptyState({
codexModel,
geminiModel,
opencodeModel,
+ kiroModel,
);
const currentModelLabel = useMemo(() => {
@@ -164,12 +173,15 @@ export default function ProviderSelectionEmptyState({
} else if (providerId === "opencode") {
setOpenCodeModel(modelValue);
localStorage.setItem("opencode-model", modelValue);
+ } else if (providerId === "kiro") {
+ setKiroModel(modelValue);
+ localStorage.setItem("kiro-model", modelValue);
} else {
setCursorModel(modelValue);
localStorage.setItem("cursor-model", modelValue);
}
},
- [setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, setOpenCodeModel],
+ [setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, setOpenCodeModel, setKiroModel],
);
const handleModelSelect = useCallback(
@@ -230,9 +242,13 @@ export default function ProviderSelectionEmptyState({
- Model Selector
+
+ {t("providerSelection.modelSelectorTitle", { defaultValue: "Model Selector" })}
+
-
Choose a model
+
+ {t("providerSelection.chooseModel", { defaultValue: "Choose a model" })}
+
diff --git a/src/components/llm-logo-provider/KiroLogo.tsx b/src/components/llm-logo-provider/KiroLogo.tsx
new file mode 100644
index 0000000000..117bbe2227
--- /dev/null
+++ b/src/components/llm-logo-provider/KiroLogo.tsx
@@ -0,0 +1,19 @@
+import { useTheme } from '../../contexts/ThemeContext';
+
+type KiroLogoProps = {
+ className?: string;
+};
+
+const KiroLogo = ({ className = 'w-5 h-5' }: KiroLogoProps) => {
+ const { isDarkMode } = useTheme();
+
+ return (
+
+ );
+};
+
+export default KiroLogo;
diff --git a/src/components/llm-logo-provider/SessionProviderLogo.tsx b/src/components/llm-logo-provider/SessionProviderLogo.tsx
index e29ecd6d14..2d7d4820f3 100644
--- a/src/components/llm-logo-provider/SessionProviderLogo.tsx
+++ b/src/components/llm-logo-provider/SessionProviderLogo.tsx
@@ -4,6 +4,7 @@ import CodexLogo from './CodexLogo';
import CursorLogo from './CursorLogo';
import GeminiLogo from './GeminiLogo';
import OpenCodeLogo from './OpenCodeLogo';
+import KiroLogo from './KiroLogo';
type SessionProviderLogoProps = {
provider?: LLMProvider | string | null;
@@ -30,5 +31,9 @@ export default function SessionProviderLogo({
return ;
}
+ if (provider === 'kiro') {
+ return ;
+ }
+
return ;
}
diff --git a/src/components/mcp/constants.ts b/src/components/mcp/constants.ts
index ab6396ff72..08997c770b 100644
--- a/src/components/mcp/constants.ts
+++ b/src/components/mcp/constants.ts
@@ -6,6 +6,7 @@ export const MCP_PROVIDER_NAMES: Record = {
codex: 'Codex',
gemini: 'Gemini',
opencode: 'OpenCode',
+ kiro: 'Kiro',
};
export const MCP_SUPPORTED_SCOPES: Record = {
@@ -14,6 +15,7 @@ export const MCP_SUPPORTED_SCOPES: Record = {
codex: ['user', 'project'],
gemini: ['user', 'project'],
opencode: ['user', 'project'],
+ kiro: ['user', 'project'],
};
export const MCP_SUPPORTED_TRANSPORTS: Record = {
@@ -22,6 +24,7 @@ export const MCP_SUPPORTED_TRANSPORTS: Record = {
codex: ['stdio', 'http'],
gemini: ['stdio', 'http', 'sse'],
opencode: ['stdio', 'http'],
+ kiro: ['stdio', 'http'],
};
export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project'];
@@ -34,6 +37,7 @@ export const MCP_PROVIDER_BUTTON_CLASSES: Record = {
codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
gemini: 'bg-blue-600 text-white hover:bg-blue-700',
opencode: 'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-700 dark:hover:bg-zinc-600',
+ kiro: 'bg-slate-800 text-white hover:bg-slate-900 dark:bg-slate-700 dark:hover:bg-slate-600',
};
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record = {
@@ -42,6 +46,7 @@ export const MCP_SUPPORTS_WORKING_DIRECTORY: Record = {
codex: true,
gemini: true,
opencode: false,
+ kiro: false,
};
export const DEFAULT_MCP_FORM: McpFormState = {
diff --git a/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx b/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx
index 5c66997a1b..da7c75b640 100644
--- a/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx
+++ b/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx
@@ -44,6 +44,13 @@ const providerCards = [
iconContainerClassName: 'bg-zinc-100 dark:bg-zinc-800',
loginButtonClassName: 'bg-zinc-800 hover:bg-zinc-900 dark:bg-zinc-700 dark:hover:bg-zinc-600',
},
+ {
+ provider: 'kiro' as const,
+ title: 'AWS Kiro',
+ connectedClassName: 'bg-slate-50 dark:bg-slate-900/20 border-slate-200 dark:border-slate-800',
+ iconContainerClassName: 'bg-slate-100 dark:bg-slate-900/30',
+ loginButtonClassName: 'bg-slate-700 hover:bg-slate-800 dark:bg-slate-700 dark:hover:bg-slate-600',
+ },
];
export default function AgentConnectionsStep({
diff --git a/src/components/provider-auth/types.ts b/src/components/provider-auth/types.ts
index afa0809411..890096c627 100644
--- a/src/components/provider-auth/types.ts
+++ b/src/components/provider-auth/types.ts
@@ -10,7 +10,7 @@ export type ProviderAuthStatus = {
export type ProviderAuthStatusMap = Record;
-export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
+export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'kiro'];
export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record = {
claude: '/api/providers/claude/auth/status',
@@ -18,6 +18,7 @@ export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record = {
codex: '/api/providers/codex/auth/status',
gemini: '/api/providers/gemini/auth/status',
opencode: '/api/providers/opencode/auth/status',
+ kiro: '/api/providers/kiro/auth/status',
};
export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({
@@ -26,4 +27,5 @@ export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuth
codex: { authenticated: false, email: null, method: null, error: null, loading },
gemini: { authenticated: false, email: null, method: null, error: null, loading },
opencode: { authenticated: false, email: null, method: null, error: null, loading },
+ kiro: { authenticated: false, email: null, method: null, error: null, loading },
});
diff --git a/src/components/provider-auth/view/ProviderLoginModal.tsx b/src/components/provider-auth/view/ProviderLoginModal.tsx
index 9de1d227b0..07e355a75e 100644
--- a/src/components/provider-auth/view/ProviderLoginModal.tsx
+++ b/src/components/provider-auth/view/ProviderLoginModal.tsx
@@ -41,6 +41,10 @@ const getProviderCommand = ({
return 'opencode auth login';
}
+ if (provider === 'kiro') {
+ return 'kiro-cli login';
+ }
+
return 'gemini status';
};
@@ -49,6 +53,7 @@ const getProviderTitle = (provider: LLMProvider) => {
if (provider === 'cursor') return 'Cursor CLI Login';
if (provider === 'codex') return 'Codex CLI Login';
if (provider === 'opencode') return 'OpenCode CLI Login';
+ if (provider === 'kiro') return 'Kiro CLI Login';
return 'Gemini CLI Configuration';
};
diff --git a/src/components/settings/constants/constants.ts b/src/components/settings/constants/constants.ts
index 7d4c353df1..3a0d0ea295 100644
--- a/src/components/settings/constants/constants.ts
+++ b/src/components/settings/constants/constants.ts
@@ -37,7 +37,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTabMeta[] = [
{ id: 'about', label: 'About', keywords: 'about version info', icon: Info },
];
-export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
+export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'kiro'];
export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';
diff --git a/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx
index b23784ee67..55b77fc40c 100644
--- a/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx
+++ b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx
@@ -12,7 +12,7 @@ type AgentListItemProps = {
type AgentConfig = {
name: string;
- color: 'blue' | 'purple' | 'gray' | 'indigo' | 'zinc';
+ color: 'blue' | 'purple' | 'gray' | 'indigo' | 'zinc' | 'slate';
};
const agentConfig: Record = {
@@ -36,6 +36,10 @@ const agentConfig: Record = {
name: 'OpenCode',
color: 'zinc',
},
+ kiro: {
+ name: 'Kiro',
+ color: 'slate',
+ },
};
const colorClasses = {
@@ -54,6 +58,9 @@ const colorClasses = {
zinc: {
dot: 'bg-zinc-500',
},
+ slate: {
+ dot: 'bg-slate-500',
+ },
} as const;
export default function AgentListItem({
diff --git a/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx
index ee24239cff..bcb6703d39 100644
--- a/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx
+++ b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx
@@ -24,7 +24,7 @@ export default function AgentsSettingsTab({
const [selectedCategory, setSelectedCategory] = useState('account');
const visibleAgents = useMemo(() => {
- return ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
+ return ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'kiro'];
}, []);
const agentContextById = useMemo>(() => ({
@@ -48,6 +48,10 @@ export default function AgentsSettingsTab({
authStatus: providerAuthStatus.opencode,
onLogin: () => onProviderLogin('opencode'),
},
+ kiro: {
+ authStatus: providerAuthStatus.kiro,
+ onLogin: () => onProviderLogin('kiro'),
+ },
}), [
onProviderLogin,
providerAuthStatus.claude,
@@ -55,6 +59,7 @@ export default function AgentsSettingsTab({
providerAuthStatus.cursor,
providerAuthStatus.gemini,
providerAuthStatus.opencode,
+ providerAuthStatus.kiro,
]);
return (
diff --git a/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx b/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx
index a6d017fa3c..502151bd0a 100644
--- a/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx
+++ b/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx
@@ -9,6 +9,7 @@ const AGENT_NAMES: Record = {
codex: 'Codex',
gemini: 'Gemini',
opencode: 'OpenCode',
+ kiro: 'Kiro',
};
export default function AgentSelectorSection({
@@ -25,7 +26,8 @@ export default function AgentSelectorSection({
agent === 'claude' ? 'bg-blue-500' :
agent === 'cursor' ? 'bg-purple-500' :
agent === 'gemini' ? 'bg-indigo-500' :
- agent === 'opencode' ? 'bg-zinc-500' : 'bg-foreground/60';
+ agent === 'opencode' ? 'bg-zinc-500' :
+ agent === 'kiro' ? 'bg-slate-500' : 'bg-foreground/60';
return (
= {
subtextClass: 'text-zinc-700 dark:text-zinc-300',
buttonClass: 'bg-zinc-900 hover:bg-zinc-800 active:bg-zinc-950 dark:bg-zinc-700 dark:hover:bg-zinc-600',
},
+ kiro: {
+ name: 'Kiro',
+ description: 'AWS Kiro agentic IDE',
+ bgClass: 'bg-slate-50 dark:bg-slate-900/20',
+ borderClass: 'border-slate-200 dark:border-slate-800',
+ textClass: 'text-slate-900 dark:text-slate-100',
+ subtextClass: 'text-slate-700 dark:text-slate-300',
+ buttonClass: 'bg-slate-700 hover:bg-slate-800 active:bg-slate-900',
+ },
};
export default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) {
diff --git a/src/i18n/locales/de/chat.json b/src/i18n/locales/de/chat.json
index f0b10d7a85..58d16b0a32 100644
--- a/src/i18n/locales/de/chat.json
+++ b/src/i18n/locales/de/chat.json
@@ -18,7 +18,8 @@
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex",
- "gemini": "Gemini"
+ "gemini": "Gemini",
+ "kiro": "Kiro"
},
"tools": {
"settings": "Werkzeugeinstellungen",
@@ -153,7 +154,8 @@
"cursor": "Bereit, Cursor mit {{model}} zu verwenden. Gib unten deine Nachricht ein.",
"codex": "Bereit, Codex mit {{model}} zu verwenden. Gib unten deine Nachricht ein.",
"gemini": "Bereit, Gemini mit {{model}} zu verwenden. Gib unten deine Nachricht ein.",
- "default": "Wähl oben einen Anbieter, um zu beginnen"
+ "default": "Wähl oben einen Anbieter, um zu beginnen",
+ "kiro": "Bereit zur Nutzung von Kiro mit {{model}}. Beginnen Sie, Ihre Nachricht unten einzugeben."
},
"pressToSearch": "Drücke {{shortcut}}, um Sitzungen, Dateien und Commits zu durchsuchen"
},
diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json
index 2c75fad080..81d9181dcc 100644
--- a/src/i18n/locales/en/chat.json
+++ b/src/i18n/locales/en/chat.json
@@ -19,7 +19,8 @@
"cursor": "Cursor",
"codex": "Codex",
"gemini": "Gemini",
- "opencode": "OpenCode"
+ "opencode": "OpenCode",
+ "kiro": "Kiro"
},
"tools": {
"settings": "Tool Settings",
@@ -155,6 +156,7 @@
"codex": "Ready to use Codex with {{model}}. Start typing your message below.",
"gemini": "Ready to use Gemini with {{model}}. Start typing your message below.",
"opencode": "Ready to use OpenCode with {{model}}. Start typing your message below.",
+ "kiro": "Ready to use Kiro with {{model}}. Start typing your message below.",
"default": "Select a provider above to begin"
},
"pressToSearch": "Press {{shortcut}} to search sessions, files, and commits"
diff --git a/src/i18n/locales/it/chat.json b/src/i18n/locales/it/chat.json
index ae845d9a18..2cbe370e55 100644
--- a/src/i18n/locales/it/chat.json
+++ b/src/i18n/locales/it/chat.json
@@ -18,7 +18,8 @@
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex",
- "gemini": "Gemini"
+ "gemini": "Gemini",
+ "kiro": "Kiro"
},
"tools": {
"settings": "Impostazioni strumento",
@@ -153,7 +154,8 @@
"cursor": "Pronto a usare Cursor con {{model}}. Inizia a digitare il tuo messaggio qui sotto.",
"codex": "Pronto a usare Codex con {{model}}. Inizia a digitare il tuo messaggio qui sotto.",
"gemini": "Pronto a usare Gemini con {{model}}. Inizia a digitare il tuo messaggio qui sotto.",
- "default": "Seleziona un provider sopra per iniziare"
+ "default": "Seleziona un provider sopra per iniziare",
+ "kiro": "Pronto per usare Kiro con {{model}}. Inizia a digitare il tuo messaggio qui sotto."
},
"pressToSearch": "Premi {{shortcut}} per cercare sessioni, file e commit"
},
diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json
index cd20292e8f..13cadc406f 100644
--- a/src/i18n/locales/ja/chat.json
+++ b/src/i18n/locales/ja/chat.json
@@ -17,7 +17,8 @@
"tool": "ツール",
"claude": "Claude",
"cursor": "Cursor",
- "codex": "Codex"
+ "codex": "Codex",
+ "kiro": "Kiro"
},
"tools": {
"settings": "ツール設定",
@@ -130,7 +131,8 @@
"claude": "{{model}}でClaudeを使用する準備ができました。下にメッセージを入力してください。",
"cursor": "{{model}}でCursorを使用する準備ができました。下にメッセージを入力してください。",
"codex": "{{model}}でCodexを使用する準備ができました。下にメッセージを入力してください。",
- "default": "上からプロバイダーを選択して開始してください"
+ "default": "上からプロバイダーを選択して開始してください",
+ "kiro": "Kiro を {{model}} で使用する準備ができました。下にメッセージを入力してください。"
},
"pressToSearch": "{{shortcut}} を押してセッション、ファイル、コミットを検索"
},
diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json
index c9df5c2ec9..4cc19f61ba 100644
--- a/src/i18n/locales/ko/chat.json
+++ b/src/i18n/locales/ko/chat.json
@@ -18,7 +18,8 @@
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex",
- "gemini": "Gemini"
+ "gemini": "Gemini",
+ "kiro": "Kiro"
},
"tools": {
"settings": "도구 설정",
@@ -135,7 +136,8 @@
"cursor": "{{model}} 모델로 Cursor를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
"codex": "{{model}} 모델로 Codex를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
"gemini": "{{model}} 모델로 Gemini를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
- "default": "시작하려면 위에서 제공자를 선택하세요"
+ "default": "시작하려면 위에서 제공자를 선택하세요",
+ "kiro": "Kiro를 {{model}}와 함께 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요."
},
"pressToSearch": "{{shortcut}}를 눌러 세션, 파일 및 커밋을 검색하세요"
},
diff --git a/src/i18n/locales/ru/chat.json b/src/i18n/locales/ru/chat.json
index 8d3e9e093c..7aa856783e 100644
--- a/src/i18n/locales/ru/chat.json
+++ b/src/i18n/locales/ru/chat.json
@@ -18,7 +18,8 @@
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex",
- "gemini": "Gemini"
+ "gemini": "Gemini",
+ "kiro": "Kiro"
},
"tools": {
"settings": "Настройки инструмента",
@@ -153,7 +154,8 @@
"cursor": "Готов использовать Cursor с {{model}}. Начните вводить сообщение ниже.",
"codex": "Готов использовать Codex с {{model}}. Начните вводить сообщение ниже.",
"gemini": "Готов использовать Gemini с {{model}}. Начните вводить сообщение ниже.",
- "default": "Выберите провайдера выше для начала"
+ "default": "Выберите провайдера выше для начала",
+ "kiro": "Kiro готов к работе с {{model}}. Начните вводить сообщение ниже."
},
"pressToSearch": "Нажмите {{shortcut}}, чтобы искать сессии, файлы и коммиты"
},
diff --git a/src/i18n/locales/tr/chat.json b/src/i18n/locales/tr/chat.json
index 74ce954807..c092290516 100644
--- a/src/i18n/locales/tr/chat.json
+++ b/src/i18n/locales/tr/chat.json
@@ -18,7 +18,8 @@
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex",
- "gemini": "Gemini"
+ "gemini": "Gemini",
+ "kiro": "Kiro"
},
"tools": {
"settings": "Araç Ayarları",
@@ -153,7 +154,8 @@
"cursor": "Cursor'ı {{model}} ile kullanmaya hazır. Mesajını aşağıya yazmaya başla.",
"codex": "Codex'i {{model}} ile kullanmaya hazır. Mesajını aşağıya yazmaya başla.",
"gemini": "Gemini'yi {{model}} ile kullanmaya hazır. Mesajını aşağıya yazmaya başla.",
- "default": "Başlamak için yukarıdan bir sağlayıcı seç"
+ "default": "Başlamak için yukarıdan bir sağlayıcı seç",
+ "kiro": "Kiro {{model}} ile kullanıma hazır. Mesajınızı aşağıya yazmaya başlayın."
},
"pressToSearch": "Oturumlarda, dosyalarda ve commit'lerde arama yapmak için {{shortcut}} tuşlarına bas"
},
diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json
index 8ebe68d14a..5cb7bd4e66 100644
--- a/src/i18n/locales/zh-CN/chat.json
+++ b/src/i18n/locales/zh-CN/chat.json
@@ -18,7 +18,8 @@
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex",
- "gemini": "Gemini"
+ "gemini": "Gemini",
+ "kiro": "Kiro"
},
"tools": {
"settings": "工具设置",
@@ -135,7 +136,8 @@
"cursor": "准备好使用带有 {{model}} 的 Cursor。请在下方开始输入您的消息。",
"codex": "准备好使用带有 {{model}} 的 Codex。请在下方开始输入您的消息。",
"gemini": "准备好使用带有 {{model}} 的 Gemini。请在下方开始输入您的消息。",
- "default": "请在上方选择一个提供者以开始"
+ "default": "请在上方选择一个提供者以开始",
+ "kiro": "已准备好使用 Kiro 和 {{model}}。在下方输入您的消息。"
},
"pressToSearch": "按 {{shortcut}} 搜索会话、文件和提交"
},
diff --git a/src/types/app.ts b/src/types/app.ts
index 42b9d28b14..3aa3249314 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -1,4 +1,4 @@
-export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode';
+export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode' | 'kiro';
export type ProviderModelOption = {
value: string;