From fb6431936c436beead0f997811f6378d66690c74 Mon Sep 17 00:00:00 2001 From: keh4l <2461454684@qq.com> Date: Mon, 28 Sep 2026 03:17:28 +0800 Subject: [PATCH 1/3] feat(kiro): add Kiro CLI as a built-in agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kiro CLI (`kiro-cli acp`) becomes the sixteenth built-in, under wire name `kiro` and registry id `kiro-cli`. Install: Kiro is distributed only through its own installer and keeps itself current, so codeg launches the user's installed `kiro-cli` rather than downloading a copy. That is a new `AgentDistribution::System { cmd, args, env, install_url, update_args }`: preflight resolves the command on PATH and, when it is missing, fails with the vendor's install page as its one fix; download, prepare and uninstall refuse it as user-managed. The settings page labels it "official installer" and offers Upgrade, which runs `kiro-cli update --non-interactive` (`acp_update_system_agent`, Tauri and web) with its output streamed to the install log and a 15-minute timeout, then reports the version before and after. Session config: Kiro sends no `configOptions`. The model selector is built from the `models` block of `session/new` / `session/load` — the load is sent untyped for Kiro so that field survives — and switched with `session/set_model`. Modes come from the ACP modes and follow `_kiro.dev/agent/switched`. Reasoning effort and the thinking switch come from `_kiro.dev/metadata.reasoning` and are set through `_kiro.dev/commands/execute` (`effort`, `reasoning`); the effort list is shown whenever Kiro lists levels, because GPT models report `support: unavailable` yet accept them, and the thinking switch only where Kiro calls it toggleable. The order is mode, model, thinking, effort. Slash commands come from `_kiro.dev/commands/available`, minus the TUI-only ones. Permissions: the settings panel offers ask (default) or trust all, stored as KIRO_TRUST_ALL_TOOLS and passed as `--trust-all-tools`, the same way Cursor's CURSOR_FORCE works, plus an optional KIRO_API_KEY. Tool calls: Kiro's tools take arguments no card reads. `write` is one tool whose `command` (create / strReplace / insert) picks the operation, which the input-shape classifier read as a shell command; `read` takes a list of `operations` with no top-level path; results arrive as `{"items":[{"Text"|"Json": …}]}`. `parsers::kiro::canonical_tool` maps the built-in tools onto the arguments the cards take, for the history parser and the live stream alike, and records the canonical name in `_meta.kiro.canonicalToolName` so the frontend can tell glob from grep and a directory listing from a file read. Results are flattened the same way on both paths: a shell result becomes stdout/stderr plus a non-zero exit status, an MCP result its text, and a read past line 1 is numbered from its offset. A permission request carries no `_meta`, so its tool is told from its arguments. Transcripts: `/sessions/cli/.jsonl` holds the log and `.json` the metadata, rewritten every turn; only prompts carry a timestamp, so turn ends come from the metadata, and the summary cache keys on the log with the metadata as a companion. Registered for import and backup, with insta snapshots. MCP and skills: codeg manages `/settings/mcp.json`, which Kiro loads itself, so Kiro is on the ACP forward-skip list. SSE servers are left out (Kiro takes stdio and HTTP only), and Kiro-only fields in an entry are kept across rewrites. Skills live in `~/.kiro/skills` and `.kiro/skills`. Migration: before this, the only way to run Kiro was as a custom agent with the registry id `kiro-cli` — the id the built-in now claims, so those conversations would turn into an unregistered `custom:kiro-cli`. `m20260927_000001_kiro_builtin_agent` re-types every place an agent type is stored (conversations, tabs, folder defaults, automations, work-task configs, agent settings with their launch env); both point at the same session on disk, so the parser reads their history. Collisions resolve toward the built-in without losing anything (a duplicate conversation hands its tabs over and is soft-deleted). The custom definition is removed; `down` is a no-op. README (10 languages) lists sixteen agents. --- README.md | 4 +- docs/readme/README.ar.md | 4 +- docs/readme/README.de.md | 4 +- docs/readme/README.es.md | 4 +- docs/readme/README.fr.md | 4 +- docs/readme/README.ja.md | 4 +- docs/readme/README.ko.md | 4 +- docs/readme/README.pt.md | 4 +- docs/readme/README.zh-CN.md | 4 +- docs/readme/README.zh-TW.md | 4 +- src-tauri/src/acp/connection.rs | 2138 ++++++++++++++--- src-tauri/src/acp/custom_registry.rs | 8 +- src-tauri/src/acp/delegation/companion.rs | 17 +- src-tauri/src/acp/delegation/tool_schema.json | 3 +- src-tauri/src/acp/file_system_runtime.rs | 12 +- src-tauri/src/acp/preflight.rs | 85 + src-tauri/src/acp/registry.rs | 133 +- src-tauri/src/acp/session_state.rs | 9 + src-tauri/src/acp/types.rs | 29 + src-tauri/src/commands/acp.rs | 341 ++- src-tauri/src/commands/experts.rs | 1 + src-tauri/src/commands/mcp.rs | 343 ++- src-tauri/src/commands/office_tools.rs | 1 + src-tauri/src/commands/science.rs | 5 + .../m20260927_000001_kiro_builtin_agent.rs | 375 +++ src-tauri/src/db/migration/mod.rs | 2 + .../src/db/service/agent_setting_service.rs | 16 + src-tauri/src/db/service/import_service.rs | 3 +- src-tauri/src/lib.rs | 1 + src-tauri/src/models/agent.rs | 15 +- src-tauri/src/parsers/kiro.rs | 1454 +++++++++++ src-tauri/src/parsers/mod.rs | 14 + src-tauri/src/parsers/summary_cache.rs | 96 +- src-tauri/src/web/handlers/acp.rs | 23 + src-tauri/src/web/router.rs | 4 + src-tauri/tests/parsers_snapshot.rs | 80 +- .../parsers_snapshot__kiro_detail.snap | 123 + .../parsers_snapshot__kiro_list.snap | 18 + src/components/agent-icon.tsx | 27 + .../settings/acp-agent-settings.test.tsx | 71 + .../settings/acp-agent-settings.tsx | 177 +- .../settings/delegation-agent-defaults.tsx | 1 + .../settings/kiro-config-panel.test.ts | 42 + src/components/settings/kiro-config-panel.tsx | 207 ++ src/components/settings/mcp-settings.tsx | 2 + src/i18n/messages/ar.json | 25 +- src/i18n/messages/de.json | 25 +- src/i18n/messages/en.json | 25 +- src/i18n/messages/es.json | 25 +- src/i18n/messages/fr.json | 25 +- src/i18n/messages/ja.json | 25 +- src/i18n/messages/ko.json | 25 +- src/i18n/messages/pt.json | 25 +- src/i18n/messages/zh-CN.json | 25 +- src/i18n/messages/zh-TW.json | 25 +- src/lib/api.ts | 15 + src/lib/tool-call-normalization.test.ts | 104 + src/lib/tool-call-normalization.ts | 28 + src/lib/types.ts | 17 +- 59 files changed, 5990 insertions(+), 340 deletions(-) create mode 100644 src-tauri/src/db/migration/m20260927_000001_kiro_builtin_agent.rs create mode 100644 src-tauri/src/parsers/kiro.rs create mode 100644 src-tauri/tests/snapshots/parsers_snapshot__kiro_detail.snap create mode 100644 src-tauri/tests/snapshots/parsers_snapshot__kiro_list.snap create mode 100644 src/components/settings/kiro-config-panel.test.ts create mode 100644 src/components/settings/kiro-config-panel.tsx diff --git a/README.md b/README.md index a79a03e595..86ce83b48d 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Codeg (Code Generation) is a multi-agent coding workspace: run every AI coding agent in one place — and let them work together. -It aggregates your sessions from every supported agent CLI into one searchable workspace, and lets a main agent delegate to sub-agents of other types within a single task. Work you'd rather not sit through goes on a to-do board instead — each task in its own branch, running unattended, waiting for your review before it lands. Codeg runs as a desktop app, a standalone server, or a Docker container, with native iOS and Android clients for when you're away from your desk; fifteen agents come built in, and you can register any other ACP-compatible agent yourself. +It aggregates your sessions from every supported agent CLI into one searchable workspace, and lets a main agent delegate to sub-agents of other types within a single task. Work you'd rather not sit through goes on a to-do board instead — each task in its own branch, running unattended, waiting for your review before it lands. Codeg runs as a desktop app, a standalone server, or a Docker container, with native iOS and Android clients for when you're away from your desk; sixteen agents come built in, and you can register any other ACP-compatible agent yourself. ![workspace](./docs/images/workspace-light.png#gh-light-mode-only) ![workspace](./docs/images/workspace-dark.png#gh-dark-mode-only) @@ -90,7 +90,7 @@ It aggregates your sessions from every supported agent CLI into one searchable w ## 🤖 Supported Agents -Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder · Google Antigravity +Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder · Google Antigravity · Kiro CLI Codeg installs, pins, and updates most of them for you. See [Supported Agents](https://docs.codeg.app/guide/supported-agents) for the full roster, each agent's runtime requirements, and where it keeps its sessions on disk. diff --git a/docs/readme/README.ar.md b/docs/readme/README.ar.md index f860913d85..3e56a7405e 100644 --- a/docs/readme/README.ar.md +++ b/docs/readme/README.ar.md @@ -19,7 +19,7 @@ Codeg (Code Generation) هو مساحة عمل برمجية متعددة الوكلاء: شغّل كل وكلاء البرمجة بالذكاء الاصطناعي في مكان واحد — ودعهم يعملون معًا. -يجمع جلساتك من كل واجهات الوكلاء المدعومة في مساحة عمل واحدة قابلة للبحث، ويتيح للوكيل الرئيسي أن يفوّض إلى وكلاء فرعيين من أنواع أخرى داخل المهمة نفسها. أما العمل الذي لا تريد الجلوس لمتابعته فيذهب إلى لوحة المهام قيد الانتظار: كل مهمة في فرعها الخاص، تعمل دون إشراف، وتنتظر مراجعتك قبل أن تُدمج. ويعمل Codeg كتطبيق سطح مكتب أو خادم مستقل أو حاوية Docker، إضافةً إلى تطبيقَي iOS وAndroid الأصليين لمواصلة العمل بعيدًا عن مكتبك؛ يأتي بخمسة عشر وكيلاً مدمجًا، ويمكنك تسجيل أي وكيل آخر متوافق مع ACP بنفسك. +يجمع جلساتك من كل واجهات الوكلاء المدعومة في مساحة عمل واحدة قابلة للبحث، ويتيح للوكيل الرئيسي أن يفوّض إلى وكلاء فرعيين من أنواع أخرى داخل المهمة نفسها. أما العمل الذي لا تريد الجلوس لمتابعته فيذهب إلى لوحة المهام قيد الانتظار: كل مهمة في فرعها الخاص، تعمل دون إشراف، وتنتظر مراجعتك قبل أن تُدمج. ويعمل Codeg كتطبيق سطح مكتب أو خادم مستقل أو حاوية Docker، إضافةً إلى تطبيقَي iOS وAndroid الأصليين لمواصلة العمل بعيدًا عن مكتبك؛ يأتي بستة عشر وكيلاً مدمجًا، ويمكنك تسجيل أي وكيل آخر متوافق مع ACP بنفسك. ![مساحة العمل](../images/workspace-light.png#gh-light-mode-only) ![مساحة العمل](../images/workspace-dark.png#gh-dark-mode-only) @@ -90,7 +90,7 @@ Codeg (Code Generation) هو مساحة عمل برمجية متعددة الو ## 🤖 الوكلاء المدعومون -Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder · Google Antigravity +Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder · Google Antigravity · Kiro CLI يتولّى Codeg تثبيت معظمهم وتثبيت إصداراتهم وتحديثهم نيابةً عنك. راجع [الوكلاء المدعومون](https://docs.codeg.app/guide/supported-agents) للقائمة الكاملة، ومتطلبات تشغيل كل وكيل، وموضع حفظ جلساته على القرص. diff --git a/docs/readme/README.de.md b/docs/readme/README.de.md index ece6d29839..4e553ef24f 100644 --- a/docs/readme/README.de.md +++ b/docs/readme/README.de.md @@ -19,7 +19,7 @@ Codeg (Code Generation) ist ein Multi-Agent-Coding-Workspace: Führe jeden KI-Coding-Agenten an einem Ort aus — und lass sie zusammenarbeiten. -Codeg bündelt die Sitzungen aller unterstützten Agenten-CLIs in einem durchsuchbaren Workspace und lässt einen Haupt-Agenten innerhalb einer Aufgabe an Sub-Agenten anderer Typen delegieren. Arbeit, bei der du nicht danebensitzen willst, kommt stattdessen aufs To-do-Board — jede Aufgabe in ihrem eigenen Branch, unbeaufsichtigt laufend und wartend auf deine Freigabe, bevor sie landet. Codeg läuft als Desktop-App, eigenständiger Server oder Docker-Container, dazu native iOS- und Android-Clients für die Zeit fernab vom Schreibtisch; fünfzehn Agenten sind eingebaut, und jeden weiteren ACP-kompatiblen Agenten kannst du selbst registrieren. +Codeg bündelt die Sitzungen aller unterstützten Agenten-CLIs in einem durchsuchbaren Workspace und lässt einen Haupt-Agenten innerhalb einer Aufgabe an Sub-Agenten anderer Typen delegieren. Arbeit, bei der du nicht danebensitzen willst, kommt stattdessen aufs To-do-Board — jede Aufgabe in ihrem eigenen Branch, unbeaufsichtigt laufend und wartend auf deine Freigabe, bevor sie landet. Codeg läuft als Desktop-App, eigenständiger Server oder Docker-Container, dazu native iOS- und Android-Clients für die Zeit fernab vom Schreibtisch; sechzehn Agenten sind eingebaut, und jeden weiteren ACP-kompatiblen Agenten kannst du selbst registrieren. ![Workspace](../images/workspace-light.png#gh-light-mode-only) ![Workspace](../images/workspace-dark.png#gh-dark-mode-only) @@ -90,7 +90,7 @@ Codeg bündelt die Sitzungen aller unterstützten Agenten-CLIs in einem durchsuc ## 🤖 Unterstützte Agenten -Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder · Google Antigravity +Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder · Google Antigravity · Kiro CLI Die meisten davon installiert, fixiert und aktualisiert Codeg für dich. Die vollständige Liste, die Laufzeit-Anforderungen jedes Agenten und den Ablageort seiner Sitzungen findest du unter [Unterstützte Agenten](https://docs.codeg.app/guide/supported-agents). diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 3050953d28..d82d3b2f7e 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -19,7 +19,7 @@ Codeg (Code Generation) es un espacio de trabajo de programación multiagente: ejecuta todos tus agentes de IA en un mismo lugar y deja que trabajen juntos. -Reúne las sesiones de todas las CLI de agentes compatibles en un único espacio de trabajo con búsqueda y permite que un agente principal delegue en subagentes de otros tipos dentro de una misma tarea. El trabajo que prefieres no vigilar va al tablero de tareas pendientes: cada tarea en su propia rama, ejecutándose sin supervisión, esperando tu revisión antes de aterrizar. Codeg funciona como aplicación de escritorio, servidor independiente o contenedor Docker, con clientes nativos de iOS y Android para cuando estás lejos del escritorio; trae quince agentes integrados y puedes registrar tú mismo cualquier otro agente compatible con ACP. +Reúne las sesiones de todas las CLI de agentes compatibles en un único espacio de trabajo con búsqueda y permite que un agente principal delegue en subagentes de otros tipos dentro de una misma tarea. El trabajo que prefieres no vigilar va al tablero de tareas pendientes: cada tarea en su propia rama, ejecutándose sin supervisión, esperando tu revisión antes de aterrizar. Codeg funciona como aplicación de escritorio, servidor independiente o contenedor Docker, con clientes nativos de iOS y Android para cuando estás lejos del escritorio; trae dieciséis agentes integrados y puedes registrar tú mismo cualquier otro agente compatible con ACP. ![Espacio de trabajo](../images/workspace-light.png#gh-light-mode-only) ![Espacio de trabajo](../images/workspace-dark.png#gh-dark-mode-only) @@ -90,7 +90,7 @@ Reúne las sesiones de todas las CLI de agentes compatibles en un único espacio ## 🤖 Agentes compatibles -Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder · Google Antigravity +Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder · Google Antigravity · Kiro CLI Codeg instala, fija la versión y actualiza la mayoría de ellos por ti. Consulta [Agentes compatibles](https://docs.codeg.app/guide/supported-agents) para ver la lista completa, los requisitos de ejecución de cada uno y dónde guarda sus sesiones en disco. diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index 10c6abdfcd..6cfbaf5337 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -19,7 +19,7 @@ Codeg (Code Generation) est un espace de travail de programmation multi-agents : faites tourner tous vos agents de codage IA au même endroit — et laissez-les travailler ensemble. -Il regroupe les sessions de toutes les CLI d'agents supportées dans un espace de travail unique et consultable, et permet à un agent principal de déléguer à des sous-agents d'autres types au sein d'une même tâche. Le travail que vous préférez ne pas surveiller part sur un tableau de tâches à faire : chaque tâche sur sa propre branche, exécutée sans surveillance, en attente de votre relecture avant d'atterrir. Codeg fonctionne en application de bureau, en serveur autonome ou en conteneur Docker, avec des clients natifs iOS et Android pour les moments où vous n'êtes pas à votre bureau ; quinze agents sont intégrés, et vous pouvez enregistrer vous-même n'importe quel autre agent compatible ACP. +Il regroupe les sessions de toutes les CLI d'agents supportées dans un espace de travail unique et consultable, et permet à un agent principal de déléguer à des sous-agents d'autres types au sein d'une même tâche. Le travail que vous préférez ne pas surveiller part sur un tableau de tâches à faire : chaque tâche sur sa propre branche, exécutée sans surveillance, en attente de votre relecture avant d'atterrir. Codeg fonctionne en application de bureau, en serveur autonome ou en conteneur Docker, avec des clients natifs iOS et Android pour les moments où vous n'êtes pas à votre bureau ; seize agents sont intégrés, et vous pouvez enregistrer vous-même n'importe quel autre agent compatible ACP. ![Espace de travail](../images/workspace-light.png#gh-light-mode-only) ![Espace de travail](../images/workspace-dark.png#gh-dark-mode-only) @@ -90,7 +90,7 @@ Il regroupe les sessions de toutes les CLI d'agents supportées dans un espace d ## 🤖 Agents supportés -Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder · Google Antigravity +Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder · Google Antigravity · Kiro CLI Codeg installe, épingle et met à jour la plupart d'entre eux pour vous. Voir [Agents supportés](https://docs.codeg.app/guide/supported-agents) pour la liste complète, les prérequis d'exécution de chacun et l'emplacement de ses sessions sur le disque. diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index b4cb30329f..67dfb07a8e 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -19,7 +19,7 @@ Codeg(Code Generation)はマルチエージェント・コーディングワークスペースです。あらゆる AI コーディングエージェントをひとつの場所で動かし、そして協働させます。 -対応するすべてのエージェント CLI のセッションを検索可能なワークスペースへ集約し、ひとつのタスクの中でメインエージェントが別種類のサブエージェントへ委譲できます。付きっきりで見ていたくない作業は ToDo タスクに書いておけば、それぞれが専用のブランチで無人のまま進み、あなたのレビューを待ってから取り込まれます。Codeg はデスクトップアプリ・スタンドアロンサーバー・Docker コンテナのいずれとしても動作し、ネイティブの iOS / Android クライアントもあるのでデスクを離れても作業を続けられます。エージェントは 15 種を内蔵し、ACP 互換の任意のエージェントを自分で登録することもできます。 +対応するすべてのエージェント CLI のセッションを検索可能なワークスペースへ集約し、ひとつのタスクの中でメインエージェントが別種類のサブエージェントへ委譲できます。付きっきりで見ていたくない作業は ToDo タスクに書いておけば、それぞれが専用のブランチで無人のまま進み、あなたのレビューを待ってから取り込まれます。Codeg はデスクトップアプリ・スタンドアロンサーバー・Docker コンテナのいずれとしても動作し、ネイティブの iOS / Android クライアントもあるのでデスクを離れても作業を続けられます。エージェントは 16 種を内蔵し、ACP 互換の任意のエージェントを自分で登録することもできます。 ![ワークスペース](../images/workspace-light.png#gh-light-mode-only) ![ワークスペース](../images/workspace-dark.png#gh-dark-mode-only) @@ -90,7 +90,7 @@ Codeg(Code Generation)はマルチエージェント・コーディングワ ## 🤖 対応エージェント -Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder · Google Antigravity +Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder · Google Antigravity · Kiro CLI その多くは Codeg がインストール・バージョン固定・更新まで面倒を見ます。全リスト、各エージェントの実行環境要件、セッションの保存場所は [対応エージェント](https://docs.codeg.app/guide/supported-agents) を参照してください。 diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index f96a1df537..2a8a63c487 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -19,7 +19,7 @@ Codeg(Code Generation)는 멀티 에이전트 코딩 워크스페이스입니다. 모든 AI 코딩 에이전트를 한곳에서 실행하고, 서로 협업하게 만듭니다. -지원되는 모든 에이전트 CLI의 세션을 검색 가능한 하나의 워크스페이스로 모으고, 하나의 작업 안에서 메인 에이전트가 다른 종류의 서브 에이전트에게 위임할 수 있습니다. 지켜보고 앉아 있기 아까운 일은 할 일 보드에 적어 두세요. 각 작업이 자기 브랜치에서 무인으로 돌아가고, 반영되기 전에 당신의 검토를 기다립니다. Codeg는 데스크톱 앱·독립 서버·Docker 컨테이너 어느 형태로든 실행되고, 네이티브 iOS·Android 클라이언트가 있어 자리를 비운 사이에도 작업을 이어갈 수 있습니다. 열다섯 개의 에이전트가 기본 내장되며, ACP를 지원하는 다른 에이전트를 직접 등록할 수도 있습니다. +지원되는 모든 에이전트 CLI의 세션을 검색 가능한 하나의 워크스페이스로 모으고, 하나의 작업 안에서 메인 에이전트가 다른 종류의 서브 에이전트에게 위임할 수 있습니다. 지켜보고 앉아 있기 아까운 일은 할 일 보드에 적어 두세요. 각 작업이 자기 브랜치에서 무인으로 돌아가고, 반영되기 전에 당신의 검토를 기다립니다. Codeg는 데스크톱 앱·독립 서버·Docker 컨테이너 어느 형태로든 실행되고, 네이티브 iOS·Android 클라이언트가 있어 자리를 비운 사이에도 작업을 이어갈 수 있습니다. 열여섯 개의 에이전트가 기본 내장되며, ACP를 지원하는 다른 에이전트를 직접 등록할 수도 있습니다. ![워크스페이스](../images/workspace-light.png#gh-light-mode-only) ![워크스페이스](../images/workspace-dark.png#gh-dark-mode-only) @@ -90,7 +90,7 @@ Codeg(Code Generation)는 멀티 에이전트 코딩 워크스페이스입니다 ## 🤖 지원 에이전트 -Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder · Google Antigravity +Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder · Google Antigravity · Kiro CLI 이 중 대부분은 Codeg가 대신 설치하고, 버전을 고정하고, 업데이트합니다. 전체 목록과 각 에이전트의 실행 환경 요구 사항, 세션이 디스크에 저장되는 위치는 [지원 에이전트](https://docs.codeg.app/guide/supported-agents)를 참고하세요. diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index d155d48763..a23b47e243 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -19,7 +19,7 @@ O Codeg (Code Generation) é um espaço de trabalho de programação multiagente: rode todos os seus agentes de IA em um só lugar — e deixe que trabalhem juntos. -Ele agrega as sessões de todas as CLIs de agentes suportadas em um único espaço de trabalho pesquisável e permite que um agente principal delegue a subagentes de outros tipos dentro de uma mesma tarefa. O trabalho que você prefere não acompanhar vai para o quadro de tarefas a fazer: cada tarefa no próprio branch, rodando sem supervisão, esperando a sua revisão antes de entrar. O Codeg roda como aplicativo de desktop, servidor independente ou contêiner Docker, com clientes nativos de iOS e Android para quando você está longe da mesa; quinze agentes já vêm integrados e você pode registrar por conta própria qualquer outro agente compatível com ACP. +Ele agrega as sessões de todas as CLIs de agentes suportadas em um único espaço de trabalho pesquisável e permite que um agente principal delegue a subagentes de outros tipos dentro de uma mesma tarefa. O trabalho que você prefere não acompanhar vai para o quadro de tarefas a fazer: cada tarefa no próprio branch, rodando sem supervisão, esperando a sua revisão antes de entrar. O Codeg roda como aplicativo de desktop, servidor independente ou contêiner Docker, com clientes nativos de iOS e Android para quando você está longe da mesa; dezesseis agentes já vêm integrados e você pode registrar por conta própria qualquer outro agente compatível com ACP. ![Espaço de trabalho](../images/workspace-light.png#gh-light-mode-only) ![Espaço de trabalho](../images/workspace-dark.png#gh-dark-mode-only) @@ -90,7 +90,7 @@ Ele agrega as sessões de todas as CLIs de agentes suportadas em um único espa ## 🤖 Agentes suportados -Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder · Google Antigravity +Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder · Google Antigravity · Kiro CLI O Codeg instala, fixa a versão e atualiza a maioria deles por você. Veja [Agentes suportados](https://docs.codeg.app/guide/supported-agents) para a lista completa, os requisitos de execução de cada um e onde ele guarda as sessões em disco. diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index ba72b45e08..6911ae6cf9 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -19,7 +19,7 @@ Codeg(Code Generation)是一个多智能体编码工作台:把所有 AI 编码智能体收进同一个地方 —— 并让它们协同工作。 -它将所有受支持智能体 CLI 的会话聚合进一个可搜索的工作区,让主智能体在同一个任务内委派给其它类型的子智能体。不想守着做完的活,可以写进待办任务:每个任务待在自己的分支上无人值守地跑,做完了等你验收才落地。Codeg 可作为桌面应用、独立服务器或 Docker 容器运行,还有原生 iOS 与 Android 客户端,让你离开电脑后也能接手正在跑的任务;内置十五个智能体,你也可以自行注册任何其它兼容 ACP 的智能体。 +它将所有受支持智能体 CLI 的会话聚合进一个可搜索的工作区,让主智能体在同一个任务内委派给其它类型的子智能体。不想守着做完的活,可以写进待办任务:每个任务待在自己的分支上无人值守地跑,做完了等你验收才落地。Codeg 可作为桌面应用、独立服务器或 Docker 容器运行,还有原生 iOS 与 Android 客户端,让你离开电脑后也能接手正在跑的任务;内置十六个智能体,你也可以自行注册任何其它兼容 ACP 的智能体。 ![工作区](../images/workspace-light.png#gh-light-mode-only) ![工作区](../images/workspace-dark.png#gh-dark-mode-only) @@ -90,7 +90,7 @@ Codeg(Code Generation)是一个多智能体编码工作台:把所有 AI ## 🤖 支持的 Agent -Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder · Google Antigravity +Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder · Google Antigravity · Kiro CLI 其中大部分 Codeg 都能替你安装、锁定版本并更新。完整名单、各自的运行环境要求以及会话在磁盘上的存放位置,见 [支持的智能体](https://docs.codeg.app/zh/guide/supported-agents)。 diff --git a/docs/readme/README.zh-TW.md b/docs/readme/README.zh-TW.md index 080d9b135c..afd1b835c1 100644 --- a/docs/readme/README.zh-TW.md +++ b/docs/readme/README.zh-TW.md @@ -19,7 +19,7 @@ Codeg(Code Generation)是一個多智慧體編碼工作台:把所有 AI 編碼智慧體收進同一個地方 —— 並讓它們協同工作。 -它將所有支援的智慧體 CLI 的工作階段聚合進一個可搜尋的工作區,讓主智慧體在同一個任務內委派給其它類型的子智慧體。不想守著做完的活,可以寫進待辦任務:每個任務待在自己的分支上無人看管地跑,做完了等你驗收才落地。Codeg 可作為桌面應用、獨立伺服器或 Docker 容器執行,還有原生 iOS 與 Android 用戶端,讓你離開電腦後也能接手正在跑的任務;內建十五個智慧體,你也可以自行註冊任何其它相容 ACP 的智慧體。 +它將所有支援的智慧體 CLI 的工作階段聚合進一個可搜尋的工作區,讓主智慧體在同一個任務內委派給其它類型的子智慧體。不想守著做完的活,可以寫進待辦任務:每個任務待在自己的分支上無人看管地跑,做完了等你驗收才落地。Codeg 可作為桌面應用、獨立伺服器或 Docker 容器執行,還有原生 iOS 與 Android 用戶端,讓你離開電腦後也能接手正在跑的任務;內建十六個智慧體,你也可以自行註冊任何其它相容 ACP 的智慧體。 ![工作區](../images/workspace-light.png#gh-light-mode-only) ![工作區](../images/workspace-dark.png#gh-dark-mode-only) @@ -90,7 +90,7 @@ Codeg(Code Generation)是一個多智慧體編碼工作台:把所有 AI ## 🤖 支援的 Agent -Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder · Google Antigravity +Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder · Google Antigravity · Kiro CLI 其中大部分 Codeg 都能替你安裝、鎖定版本並更新。完整名單、各自的執行環境需求以及工作階段在磁碟上的存放位置,見 [支援的智慧體](https://docs.codeg.app/zh/guide/supported-agents)。 diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 156fa242ec..79f2e5ef4a 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -25,7 +25,8 @@ use agent_client_protocol::schema::v1::{ WaitForTerminalExitResponse, WriteTextFileRequest, WriteTextFileResponse, }; use agent_client_protocol::schema::v1::{ - HttpHeader, McpServer, McpServerHttp, McpServerSse, McpServerStdio, AGENT_METHOD_NAMES, + HttpHeader, LoadSessionResponse, McpServer, McpServerHttp, McpServerSse, McpServerStdio, + AGENT_METHOD_NAMES, }; use agent_client_protocol::schema::ProtocolVersion; use agent_client_protocol::util::MatchDispatch; @@ -57,7 +58,7 @@ use crate::acp::terminal_runtime::{ }; use crate::acp::types::{ AcpEvent, AsyncTaskDelta, AsyncTaskUsage, AvailableCommandInfo, ConnectionInfo, - ConnectionStatus, GrokModelSpec, + ConnectionStatus, GrokModelSpec, KiroReasoning, PermissionOptionInfo, PlanEntryInfo, PromptCapabilitiesInfo, PromptInputBlock, SessionConfigBooleanInfo, SessionConfigKindInfo, SessionConfigOptionInfo, SessionConfigSelectGroupInfo, SessionConfigSelectInfo, SessionConfigSelectOptionInfo, @@ -248,6 +249,16 @@ fn merge_agent_env_with_color( /// silently loses its confirmation prompts; `--force` also turns cursor's own /// sandbox off (`approvalMode: unrestricted` → `insecure_none`), which is not /// something to switch on for someone who never asked. +/// The Kiro panel's permission-mode knob (see the `System` launch branch). +pub(crate) const KIRO_TRUST_ALL_TOOLS_ENV: &str = "KIRO_TRUST_ALL_TOOLS"; + +/// Whether the knob asks for `--trust-all-tools`. Same reading as +/// [`cursor_force_enabled`]: `"1"` / `"true"` turn it on; unset, `"0"` and +/// anything unrecognized leave the CLI asking — the safe side. +pub(crate) fn kiro_trust_all_tools_enabled(value: Option<&str>) -> bool { + cursor_force_enabled(value) +} + pub(crate) fn cursor_force_enabled(value: Option<&str>) -> bool { let Some(value) = value.map(str::trim) else { return false; @@ -2169,6 +2180,76 @@ async fn build_agent( }) .map_err(|e| AcpError::SpawnFailed(e.to_string())) } + AgentDistribution::System { cmd, args, env, .. } => { + // Nothing is ever cached for a system agent, so the user's own + // install is the only candidate — resolved the same way the Binary + // branch's fallback resolves one (PATH, `~/.local/bin`, the + // vendor's installer dirs), because a desktop app launched from + // Finder or the Dock rarely inherits the PATH line the vendor's + // installer appended to the shell rc. + // + // INVARIANT: the substring "is not installed" is matched verbatim + // by the frontend catch block in + // `src/contexts/acp-connections-context.tsx` to surface a + // localized install prompt. Do not change the wording. + let binary_path = + crate::commands::acp::resolve_system_agent_binary_for(agent_type, cmd) + .ok_or_else(|| { + AcpError::SdkNotInstalled(format!( + "{} is not installed. Please install it in Agent Settings.", + meta.name + )) + })?; + let binary_str = binary_path.to_string_lossy().to_string(); + let mut cmd_args: Vec = args.iter().map(|a| (*a).to_string()).collect(); + // Kiro's permission mode: `acp --trust-all-tools` auto-approves + // every tool call instead of sending each one to the permission + // card. Sourced from the Kiro panel's permission-mode control + // (env_json key KIRO_TRUST_ALL_TOOLS — a codeg-side knob; the CLI + // reads no such env var). Unset means ask, the CLI's own default. + if agent_type == AgentType::Kiro + && kiro_trust_all_tools_enabled( + runtime_env.get(KIRO_TRUST_ALL_TOOLS_ENV).map(String::as_str), + ) + { + cmd_args.push("--trust-all-tools".to_string()); + } + let mut server = McpServerStdio::new(meta.name, &binary_str); + if !cmd_args.is_empty() { + server = server.args(cmd_args.clone()); + } + let mut merged_env = merge_agent_env(env, runtime_env, scratch); + prepend_agent_install_dirs_path(&mut merged_env, agent_type); + let env_key_list: Vec<&str> = merged_env.iter().map(|(k, _)| k.as_str()).collect(); + // Same spawn-time dump as the Binary branch: which binary, which + // args, which env KEYS (never values — they may hold API keys). + tracing::info!( + "[ACP][{}] system binary_path={} args={:?} env_keys={:?}", + meta.name, + binary_str, + cmd_args, + env_key_list + ); + if !merged_env.is_empty() { + let env_vars: Vec = merged_env + .iter() + .map(|(k, v)| agent_client_protocol::schema::v1::EnvVariable::new(k, v)) + .collect(); + server = server.env(env_vars); + } + // stdin/stdout carry user content; dumped only under + // CODEG_ACP_DEBUG, exactly like the Binary branch. + let stdio_debug_enabled = std::env::var("CODEG_ACP_DEBUG") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + let agent_name = meta.name.to_string(); + let tail = Arc::clone(stderr_tail); + Ok( + AcpAgent::new(agent_client_protocol::schema::v1::McpServer::Stdio(server)).with_debug( + agent_debug_callback(agent_name, tail, stdio_debug_enabled), + ), + ) + } }?; // Run the agent subprocess in the session's working directory rather than @@ -3837,123 +3918,693 @@ async fn set_grok_model( Ok(()) } -/// Build the `session/set_model` params. A reasoning-effort override rides in -/// `_meta.reasoningEffort` (the exact key grok's sampling layer reads — verified -/// against 0.2.99); `None` omits `_meta` for a pure model switch. -fn build_grok_set_model_params( - session_id: &str, - model_id: &str, - reasoning_effort: Option<&str>, -) -> serde_json::Value { - let mut params = serde_json::json!({ - "sessionId": session_id, - "modelId": model_id, - }); - if let Some(effort) = reasoning_effort { - params["_meta"] = serde_json::json!({ "reasoningEffort": effort }); - } - params +/// Kiro's model selector id — the shared `model` id, so the composer's saved +/// per-agent preference and `order_preferred_config_values` treat it like every +/// other agent's model selector. +const KIRO_MODEL_OPTION_ID: &str = MODEL_CONFIG_OPTION_ID; + +/// Kiro CLI publishes its models only in the session reply's top-level +/// `models` — the pre-config-options ACP session-model API: +/// `{"currentModelId": "auto", "availableModels": [{"modelId", "name", +/// "description"}, …]}` (verified against 2.24.1 on `session/new` and +/// `session/load`; it sends no `configOptions` at all). That field is behind the +/// `unstable_session_model` feature codeg keeps off, so the typed response drops +/// it and the composer showed no model picker. Fold it into the same +/// `SessionConfigOptionInfo` every other agent's model selector flows through. +/// +/// `None` when the reply names no usable model; an unknown `currentModelId` +/// falls back to the first model rather than naming one the list lacks. +fn synthesize_kiro_model_option( + models: Option<&serde_json::Value>, +) -> Option { + let models = models?; + let mut seen = HashSet::new(); + let options: Vec = models + .get("availableModels")? + .as_array()? + .iter() + .filter_map(|model| { + let id = model.get("modelId")?.as_str()?.trim(); + if id.is_empty() || !seen.insert(id.to_string()) { + return None; + } + let name = model + .get("name") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or(id); + Some(SessionConfigSelectOptionInfo { + value: id.to_string(), + name: name.to_string(), + description: model + .get("description") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + }) + }) + .collect(); + let first = options.first()?.value.clone(); + let current_value = models + .get("currentModelId") + .and_then(serde_json::Value::as_str) + .filter(|current| options.iter().any(|o| o.value == *current)) + .map(str::to_string) + .unwrap_or(first); + Some(SessionConfigOptionInfo { + id: KIRO_MODEL_OPTION_ID.to_string(), + name: "Model".to_string(), + description: None, + category: Some("model".to_string()), + kind: SessionConfigKindInfo::Select(SessionConfigSelectInfo { + current_value, + options, + groups: Vec::new(), + }), + recommended_value: None, + }) } -/// Send `_session/steering` (the ACP steering extension) to inject a message -/// into the RUNNING turn. Untyped because it is an extension method the schema -/// has no typed request for. Always opts into the 0.64.0 -/// `promptRequired` idle contract; codeg only enables native steering for -/// adapters proven to honor it AND to keep the owning prompt in flight across -/// the steered work (claude-agent-acp 0.65.0 / #958 — see -/// [`synthesize_native_steering`] and `registry::steering_prompt_required_min_version`), -/// but the caller still handles every outcome in case the proof was wrong. -async fn send_steer_request( +/// Switch Kiro's model with `session/set_model {sessionId, modelId}` — listed in +/// Kiro's own ACP method table and answered `{}` by 2.24.1. Untyped for the same +/// reason as [`set_grok_model`]: the stable schema no longer carries the method. +async fn set_kiro_model( cx: &ConnectionTo, session_id: &SessionId, - blocks: &[PromptInputBlock], -) -> Result { - let params = build_steer_params(session_id.0.as_ref(), blocks); - let untyped_req = UntypedMessage::new("_session/steering", params).map_err(|e| { - AcpError::protocol(format!("Failed to build steering request: {e}")) + model_id: &str, +) -> Result<(), agent_client_protocol::Error> { + let params = serde_json::json!({ + "sessionId": session_id.0.as_ref(), + "modelId": model_id, + }); + let untyped_req = UntypedMessage::new("session/set_model", params).map_err(|e| { + agent_client_protocol::util::internal_error(format!("Failed to build set_model request: {e}")) })?; - let raw = cx - .send_request_to(Agent, untyped_req) - .block_task() - .await - .map_err(|e| AcpError::protocol(format!("Steering request failed: {e}")))?; - parse_steer_outcome(&raw) + cx.send_request_to(Agent, untyped_req).block_task().await?; + Ok(()) } -/// Build the `_session/steering` params. The prompt carries the caller's -/// blocks through [`map_prompt_blocks`] — the SAME conversion a -/// `session/prompt` uses — so a steered draft's image attachments reach the -/// adapter in the exact encoding its prompt path already accepts (a plain -/// note is still a single text block, as before). `_meta.steering -/// .idleBehavior = "promptRequired"` opts into the turn-end-race contract: a -/// turn that settled first yields `{outcome:"promptRequired"}` WITHOUT -/// consuming the content, so the host resubmits it through a normal -/// `session/prompt`. -fn build_steer_params(session_id: &str, blocks: &[PromptInputBlock]) -> serde_json::Value { - serde_json::json!({ - "sessionId": session_id, - "prompt": map_prompt_blocks(blocks.to_vec()), - "_meta": { "steering": { "idleBehavior": "promptRequired" } }, +/// Kiro's mode selector id: the id the composer and `order_preferred_config_values` +/// already treat as the session mode. +const KIRO_MODE_OPTION_ID: &str = "mode"; + +/// Kiro's reasoning-effort selector id — Grok's id for the same control, so a +/// saved effort means the same thing across agents. +const KIRO_EFFORT_OPTION_ID: &str = GROK_EFFORT_OPTION_ID; + +/// Kiro's thinking on/off selector id. +const KIRO_THINKING_OPTION_ID: &str = "thinking"; + +/// What a Kiro reasoning selector shows before anything was set in the +/// session: the model runs at its own default, which Kiro does not name — +/// its own `/model` panel says `default` in the same spot. +const KIRO_DEFAULT_VALUE: &str = "default"; + +/// The selectors codeg builds for Kiro itself; none is a Kiro config option. +const KIRO_SYNTHESIZED_OPTION_IDS: &[&str] = &[ + KIRO_MODEL_OPTION_ID, + KIRO_MODE_OPTION_ID, + KIRO_THINKING_OPTION_ID, + KIRO_EFFORT_OPTION_ID, +]; + +/// Kiro's ACP modes (its agent configs: `kiro_default`, `kiro_planner`, +/// `kiro_guide`, plus the user's own under `~/.kiro/agents`) as a selector. +fn kiro_mode_option(modes: &SessionModeState) -> Option { + let options: Vec = modes + .available_modes + .iter() + .map(|mode| SessionConfigSelectOptionInfo { + value: mode.id.to_string(), + name: mode.name.clone(), + description: mode.description.clone(), + }) + .collect(); + if options.is_empty() { + return None; + } + let current = modes.current_mode_id.to_string(); + let current_value = if options.iter().any(|o| o.value == current) { + current + } else { + options[0].value.clone() + }; + Some(SessionConfigOptionInfo { + id: KIRO_MODE_OPTION_ID.to_string(), + name: "Agent".to_string(), + description: None, + category: Some("mode".to_string()), + kind: SessionConfigKindInfo::Select(SessionConfigSelectInfo { + current_value, + options, + groups: Vec::new(), + }), + recommended_value: None, }) } -/// Send `_session/async_task/stop` (the AIR async-task control) to stop one -/// background task. Untyped for the same reason as `_session/steering`: an -/// extension method the schema has no typed request for. +/// Read `_kiro.dev/metadata.reasoning`. /// -/// Returns the adapter's `stopped` flag. A missing/non-boolean field reads as -/// `false` rather than raising: the adapter answers `{stopped:false}` for a task -/// it declines to stop, so "no clear yes" and "no" call for the same handling, -/// and a transport-level failure is already an `Err` on the path above. -async fn send_stop_async_task_request( +/// `effortLevels` alone decides whether the model takes an effort. `support` +/// is about the model's THINKING, not its effort: Kiro 2.24.1 reports the GPT +/// models as `support: "unavailable"` while listing six levels +/// (`none` … `max`) that `/effort` accepts and then reports back as `effort`. +/// A model with no effort at all (`auto`) sends an empty list. +fn parse_kiro_reasoning(reasoning: &serde_json::Value) -> KiroReasoning { + let levels: Vec = reasoning + .get("effortLevels") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|level| !level.is_empty()) + .map(str::to_string) + .collect(); + let current = reasoning + .get("effort") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|effort| !effort.is_empty()) + .map(str::to_string); + KiroReasoning { + levels, + current, + thinking_toggleable: reasoning.get("support").and_then(serde_json::Value::as_str) + == Some("toggleable"), + thinking: reasoning + .get("thinkingEnabled") + .and_then(serde_json::Value::as_bool), + } +} + +/// The thinking on/off selector, for a model whose thinking can be toggled. +/// Until the session (or the model's saved default) sets it, Kiro reports no +/// state, and the selector says `default` rather than guessing. +fn kiro_thinking_option(reasoning: &KiroReasoning) -> Option { + if !reasoning.thinking_toggleable { + return None; + } + let current_value = match reasoning.thinking { + Some(true) => "on", + Some(false) => "off", + None => KIRO_DEFAULT_VALUE, + }; + let choice = |value: &str| SessionConfigSelectOptionInfo { + value: value.to_string(), + name: value.to_string(), + description: None, + }; + Some(SessionConfigOptionInfo { + id: KIRO_THINKING_OPTION_ID.to_string(), + name: "Thinking".to_string(), + description: Some("Shows reasoning before answering".to_string()), + category: Some("thought_level".to_string()), + kind: SessionConfigKindInfo::Select(SessionConfigSelectInfo { + current_value: current_value.to_string(), + options: vec![choice("on"), choice("off")], + groups: Vec::new(), + }), + recommended_value: None, + }) +} + +/// The effort selector for the current model, or `None` when it takes none. +/// +/// Kiro names the running level only once one has been set — in the session, +/// or as the model's saved default; until then the selector says `default`, +/// as Kiro's own `/model` panel does, rather than naming a level the model may +/// not be running at. +fn kiro_effort_option(reasoning: &KiroReasoning) -> Option { + if reasoning.levels.is_empty() { + return None; + } + let current_value = reasoning + .current + .clone() + .filter(|c| reasoning.levels.contains(c)) + .unwrap_or_else(|| KIRO_DEFAULT_VALUE.to_string()); + Some(SessionConfigOptionInfo { + id: KIRO_EFFORT_OPTION_ID.to_string(), + name: "Reasoning effort".to_string(), + description: None, + category: Some("thought_level".to_string()), + kind: SessionConfigKindInfo::Select(SessionConfigSelectInfo { + current_value, + options: reasoning + .levels + .iter() + .map(|level| SessionConfigSelectOptionInfo { + value: level.clone(), + name: level.clone(), + description: None, + }) + .collect(), + groups: Vec::new(), + }), + recommended_value: None, + }) +} + +/// The selector value for a thinking state, back to the state. +fn kiro_thinking_value(value: &str) -> Option { + match value { + "on" => Some(true), + "off" => Some(false), + _ => None, + } +} + +/// Switch thinking on or off for the session — Kiro's `/reasoning` command, +/// whose args take `thinkingEnabled` (verified against 2.24.1: the next +/// `_kiro.dev/metadata` reports the new `thinkingEnabled`). +async fn set_kiro_thinking( cx: &ConnectionTo, session_id: &SessionId, - task_id: &str, -) -> Result { + enabled: bool, +) -> Result<(), agent_client_protocol::Error> { + execute_kiro_command( + cx, + session_id, + "reasoning", + serde_json::json!({ "thinkingEnabled": enabled }), + ) + .await +} + +/// Run one of Kiro's commands through `_kiro.dev/commands/execute`, whose +/// `command` is an adjacently tagged `{command, args}`. A reply with +/// `success: false` is an error. +async fn execute_kiro_command( + cx: &ConnectionTo, + session_id: &SessionId, + command: &str, + args: serde_json::Value, +) -> Result<(), agent_client_protocol::Error> { let params = serde_json::json!({ "sessionId": session_id.0.as_ref(), - "asyncTaskId": task_id, + "command": {"command": command, "args": args}, }); - let untyped_req = UntypedMessage::new("_session/async_task/stop", params).map_err(|e| { - AcpError::protocol(format!("Failed to build async task stop request: {e}")) + let untyped_req = UntypedMessage::new("_kiro.dev/commands/execute", params).map_err(|e| { + agent_client_protocol::util::internal_error(format!("Failed to build /{command} request: {e}")) })?; - let raw = cx - .send_request_to(Agent, untyped_req) - .block_task() - .await - .map_err(|e| AcpError::protocol(format!("Async task stop request failed: {e}")))?; - Ok(raw - .get("stopped") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false)) + let reply = cx.send_request_to(Agent, untyped_req).block_task().await?; + if reply.get("success").and_then(serde_json::Value::as_bool) == Some(false) { + let message = reply + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("Kiro rejected the command"); + return Err(agent_client_protocol::util::internal_error(message.to_string())); + } + Ok(()) } -/// Parse a `_session/steering` response's top-level `outcome`. Strict on -/// unknowns: a missing or unrecognized outcome is a protocol error, NOT a -/// silent success — the caller must know whether the content was consumed -/// before it decides between "record delivered" and "safe to resend". -fn parse_steer_outcome(raw: &serde_json::Value) -> Result { - match raw.get("outcome").and_then(serde_json::Value::as_str) { - Some("injected") => Ok(SteerOutcome::Injected), - Some("promptRequired") => Ok(SteerOutcome::PromptRequired), - Some("startedNewTurn") => Ok(SteerOutcome::StartedNewTurn), - other => Err(AcpError::protocol(format!( - "unexpected _session/steering outcome: {other:?}" - ))), - } +/// Switch Kiro's agent config with the standard `session/set_mode`. +async fn set_kiro_mode( + cx: &ConnectionTo, + session_id: &SessionId, + mode_id: &str, +) -> Result<(), agent_client_protocol::Error> { + let req = SetSessionModeRequest::new(session_id.clone(), mode_id.to_string()); + cx.send_request_to(Agent, req).block_task().await?; + Ok(()) } -/// On reconnect, re-apply the user's last-picked Grok model AND reasoning effort -/// (both saved per agent by the frontend and shipped back as preferred config -/// values), reflecting each in its selector's `current_value`. Model is applied -/// first (a pure switch, effort untouched); effort is then re-applied on top of -/// the now-current model via `set_model`'s `_meta.reasoningEffort`. -async fn apply_grok_preferred_options( +/// Set Kiro's reasoning effort. There is no ACP method for it: Kiro takes it +/// as its `/effort` command through `_kiro.dev/commands/execute`, whose +/// `command` is an adjacently tagged `{command, args}` — `args.level` for this +/// one (verified against 2.24.1: `{"success":true,"message":"Effort set to +/// high"}`, followed by a `_kiro.dev/metadata` carrying the new `effort`). +/// An unknown level still answers `success`, with the list of valid ones — the +/// following metadata, not the reply, is what says which level is in force. +async fn set_kiro_effort( cx: &ConnectionTo, session_id: &SessionId, - opts: &mut Vec, - preferred_config_values: &BTreeMap, - specs: &HashMap, + level: &str, +) -> Result<(), agent_client_protocol::Error> { + execute_kiro_command(cx, session_id, "effort", serde_json::json!({ "level": level })).await +} + +/// Point one of the selectors in `opts` at `value` (a no-op when absent). +fn set_select_current(opts: &mut [SessionConfigOptionInfo], id: &str, value: &str) { + if let Some(SessionConfigKindInfo::Select(sel)) = + opts.iter_mut().find(|o| o.id == id).map(|o| &mut o.kind) + { + sel.current_value = value.to_string(); + } +} + +/// Update one selector of the session's emitted options and re-emit them. +/// Clones out of the read guard first — the emit helpers take the same +/// state's write lock (see `emit_grok_incompatible_agent_switch`). +async fn update_emitted_option( + state: &Arc>, + emitter: &EventEmitter, + id: &str, + value: &str, +) { + let current = state.read().await.config_options.clone(); + if let Some(mut opts) = current { + if opts.iter().any(|o| o.id == id) { + set_select_current(&mut opts, id, value); + emit_session_config_options_info(state, emitter, opts).await; + } + } +} + +/// On connect, re-apply the agent, model, thinking and effort the user last +/// picked for Kiro (saved per agent by the composer), each only when it +/// differs from what the session already runs and is still offered. Model +/// before the reasoning settings: which of those exist depends on it. +async fn apply_kiro_preferred_options( + cx: &ConnectionTo, + session_id: &SessionId, + state: &Arc>, + emitter: &EventEmitter, + opts: &mut [SessionConfigOptionInfo], + preferred_config_values: &BTreeMap, + preferred_mode: Option<&str>, +) { + let wanted = |id: &str, pref: &str, opts: &[SessionConfigOptionInfo]| { + opts.iter().find(|o| o.id == id).is_some_and(|o| { + let SessionConfigKindInfo::Select(sel) = &o.kind else { + return false; + }; + sel.current_value != pref && sel.options.iter().any(|x| x.value == pref) + }) + }; + if let Some(pref) = preferred_config_values.get(KIRO_MODEL_OPTION_ID) { + if wanted(KIRO_MODEL_OPTION_ID, pref, opts) { + match set_kiro_model(cx, session_id, pref).await { + Ok(()) => set_select_current(opts, KIRO_MODEL_OPTION_ID, pref), + Err(e) => tracing::error!("[ACP] failed to apply preferred kiro model '{pref}': {e}"), + } + } + } + if let Some(pref) = preferred_mode { + if wanted(KIRO_MODE_OPTION_ID, pref, opts) { + match set_kiro_mode(cx, session_id, pref).await { + Ok(()) => { + set_select_current(opts, KIRO_MODE_OPTION_ID, pref); + emit_with_state( + state, + emitter, + AcpEvent::ModeChanged { + mode_id: pref.to_string(), + }, + ) + .await; + } + Err(e) => tracing::error!("[ACP] failed to apply preferred kiro agent '{pref}': {e}"), + } + } + } + // The reasoning state arrives in a notification that may not have landed + // yet, so a saved value is sent whenever the known state does not already + // rule it out; Kiro's next metadata is the authority either way. + if let Some(enabled) = preferred_config_values + .get(KIRO_THINKING_OPTION_ID) + .and_then(|pref| kiro_thinking_value(pref)) + { + let known = state.read().await.kiro_reasoning.clone(); + let applicable = known + .as_ref() + .is_none_or(|r| r.thinking_toggleable && r.thinking != Some(enabled)); + if applicable { + match set_kiro_thinking(cx, session_id, enabled).await { + Ok(()) => { + if let Some(r) = state.write().await.kiro_reasoning.as_mut() { + r.thinking = Some(enabled); + } + } + Err(e) => tracing::error!("[ACP] failed to apply preferred kiro thinking: {e}"), + } + } + } + if let Some(pref) = preferred_config_values.get(KIRO_EFFORT_OPTION_ID) { + let known = state.read().await.kiro_reasoning.clone(); + let applicable = known.as_ref().is_none_or(|r| { + r.levels.contains(pref) && r.current.as_deref() != Some(pref.as_str()) + }); + if applicable { + match set_kiro_effort(cx, session_id, pref).await { + Ok(()) => { + if let Some(r) = state.write().await.kiro_reasoning.as_mut() { + r.current = Some(pref.clone()); + } + } + Err(e) => tracing::error!("[ACP] failed to apply preferred kiro effort '{pref}': {e}"), + } + } + } +} + +/// Fold a `_kiro.dev/metadata` into the session: remember the reasoning state, +/// and — once the selectors exist — rebuild the effort selector for it (a +/// model switch changes which levels exist, or whether any do). +async fn apply_kiro_metadata( + state: &Arc>, + emitter: &EventEmitter, + reasoning: KiroReasoning, +) { + { + let mut guard = state.write().await; + if guard.kiro_reasoning.as_ref() == Some(&reasoning) { + return; + } + guard.kiro_reasoning = Some(reasoning.clone()); + } + rebuild_kiro_reasoning_selectors(state, emitter, &reasoning).await; +} + +/// Put the reasoning selectors in their slot — right after the model +/// selector, thinking then effort (the order of Kiro's own `/model` panel) — +/// replacing any already there. The ones `reasoning` does not offer are +/// removed: which exist depends on the model. +fn place_kiro_reasoning_selectors(opts: &mut Vec, reasoning: &KiroReasoning) { + opts.retain(|o| o.id != KIRO_THINKING_OPTION_ID && o.id != KIRO_EFFORT_OPTION_ID); + let mut at = opts + .iter() + .position(|o| o.id == KIRO_MODEL_OPTION_ID) + .map_or(opts.len(), |pos| pos + 1); + for option in [kiro_thinking_option(reasoning), kiro_effort_option(reasoning)] + .into_iter() + .flatten() + { + opts.insert(at, option); + at += 1; + } +} + +/// Rebuild the reasoning selectors in the emitted options from `reasoning`. +/// A no-op until codeg has emitted Kiro's own selectors. +async fn rebuild_kiro_reasoning_selectors( + state: &Arc>, + emitter: &EventEmitter, + reasoning: &KiroReasoning, +) { + let current = state.read().await.config_options.clone(); + let Some(mut opts) = current else { + return; + }; + // Only a session whose selectors codeg built itself has an effort slot. + if !opts.iter().any(|o| o.id == KIRO_MODEL_OPTION_ID) { + return; + } + place_kiro_reasoning_selectors(&mut opts, reasoning); + emit_session_config_options_info(state, emitter, opts).await; +} + +/// Apply a composer selector change, on whichever channel this agent really +/// takes it. The ONE dispatcher both the in-turn and the idle loop call — +/// they used to carry a copy each, and an agent routed in one and not the +/// other silently ignored every pick made between turns. +async fn set_config_option_for_agent( + agent_type: AgentType, + cx: &ConnectionTo, + session_id: &SessionId, + state: &Arc>, + emitter: &EventEmitter, + config_id: String, + value_id: String, +) -> Result<(), agent_client_protocol::Error> { + match agent_type { + AgentType::Grok => { + set_grok_config_option(cx, session_id, state, emitter, config_id, value_id).await + } + AgentType::Kiro => { + set_kiro_config_option(cx, session_id, state, emitter, config_id, value_id).await + } + _ => set_session_config_option(cx, session_id, state, emitter, config_id, value_id).await, + } +} + +/// Apply a composer selector change for Kiro. Each synthesized selector goes +/// out on the channel Kiro really takes it on; anything else is a standard +/// config option (none today) and takes the shared path. +async fn set_kiro_config_option( + cx: &ConnectionTo, + session_id: &SessionId, + state: &Arc>, + emitter: &EventEmitter, + config_id: String, + value_id: String, +) -> Result<(), agent_client_protocol::Error> { + match config_id.as_str() { + KIRO_MODEL_OPTION_ID => set_kiro_model(cx, session_id, &value_id).await?, + KIRO_MODE_OPTION_ID => { + set_kiro_mode(cx, session_id, &value_id).await?; + emit_with_state( + state, + emitter, + AcpEvent::ModeChanged { + mode_id: value_id.clone(), + }, + ) + .await; + } + KIRO_EFFORT_OPTION_ID => { + set_kiro_effort(cx, session_id, &value_id).await?; + if let Some(r) = state.write().await.kiro_reasoning.as_mut() { + r.current = Some(value_id.clone()); + } + } + KIRO_THINKING_OPTION_ID => { + // `default` is what an unset toggle shows; it is not a setting. + let Some(enabled) = kiro_thinking_value(&value_id) else { + return Ok(()); + }; + set_kiro_thinking(cx, session_id, enabled).await?; + if let Some(r) = state.write().await.kiro_reasoning.as_mut() { + r.thinking = Some(enabled); + } + } + _ => { + return set_session_config_option(cx, session_id, state, emitter, config_id, value_id) + .await + } + } + update_emitted_option(state, emitter, &config_id, &value_id).await; + Ok(()) +} + +/// Build the `session/set_model` params. A reasoning-effort override rides in +/// `_meta.reasoningEffort` (the exact key grok's sampling layer reads — verified +/// against 0.2.99); `None` omits `_meta` for a pure model switch. +fn build_grok_set_model_params( + session_id: &str, + model_id: &str, + reasoning_effort: Option<&str>, +) -> serde_json::Value { + let mut params = serde_json::json!({ + "sessionId": session_id, + "modelId": model_id, + }); + if let Some(effort) = reasoning_effort { + params["_meta"] = serde_json::json!({ "reasoningEffort": effort }); + } + params +} + +/// Send `_session/steering` (the ACP steering extension) to inject a message +/// into the RUNNING turn. Untyped because it is an extension method the schema +/// has no typed request for. Always opts into the 0.64.0 +/// `promptRequired` idle contract; codeg only enables native steering for +/// adapters proven to honor it AND to keep the owning prompt in flight across +/// the steered work (claude-agent-acp 0.65.0 / #958 — see +/// [`synthesize_native_steering`] and `registry::steering_prompt_required_min_version`), +/// but the caller still handles every outcome in case the proof was wrong. +async fn send_steer_request( + cx: &ConnectionTo, + session_id: &SessionId, + blocks: &[PromptInputBlock], +) -> Result { + let params = build_steer_params(session_id.0.as_ref(), blocks); + let untyped_req = UntypedMessage::new("_session/steering", params).map_err(|e| { + AcpError::protocol(format!("Failed to build steering request: {e}")) + })?; + let raw = cx + .send_request_to(Agent, untyped_req) + .block_task() + .await + .map_err(|e| AcpError::protocol(format!("Steering request failed: {e}")))?; + parse_steer_outcome(&raw) +} + +/// Build the `_session/steering` params. The prompt carries the caller's +/// blocks through [`map_prompt_blocks`] — the SAME conversion a +/// `session/prompt` uses — so a steered draft's image attachments reach the +/// adapter in the exact encoding its prompt path already accepts (a plain +/// note is still a single text block, as before). `_meta.steering +/// .idleBehavior = "promptRequired"` opts into the turn-end-race contract: a +/// turn that settled first yields `{outcome:"promptRequired"}` WITHOUT +/// consuming the content, so the host resubmits it through a normal +/// `session/prompt`. +fn build_steer_params(session_id: &str, blocks: &[PromptInputBlock]) -> serde_json::Value { + serde_json::json!({ + "sessionId": session_id, + "prompt": map_prompt_blocks(blocks.to_vec()), + "_meta": { "steering": { "idleBehavior": "promptRequired" } }, + }) +} + +/// Send `_session/async_task/stop` (the AIR async-task control) to stop one +/// background task. Untyped for the same reason as `_session/steering`: an +/// extension method the schema has no typed request for. +/// +/// Returns the adapter's `stopped` flag. A missing/non-boolean field reads as +/// `false` rather than raising: the adapter answers `{stopped:false}` for a task +/// it declines to stop, so "no clear yes" and "no" call for the same handling, +/// and a transport-level failure is already an `Err` on the path above. +async fn send_stop_async_task_request( + cx: &ConnectionTo, + session_id: &SessionId, + task_id: &str, +) -> Result { + let params = serde_json::json!({ + "sessionId": session_id.0.as_ref(), + "asyncTaskId": task_id, + }); + let untyped_req = UntypedMessage::new("_session/async_task/stop", params).map_err(|e| { + AcpError::protocol(format!("Failed to build async task stop request: {e}")) + })?; + let raw = cx + .send_request_to(Agent, untyped_req) + .block_task() + .await + .map_err(|e| AcpError::protocol(format!("Async task stop request failed: {e}")))?; + Ok(raw + .get("stopped") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false)) +} + +/// Parse a `_session/steering` response's top-level `outcome`. Strict on +/// unknowns: a missing or unrecognized outcome is a protocol error, NOT a +/// silent success — the caller must know whether the content was consumed +/// before it decides between "record delivered" and "safe to resend". +fn parse_steer_outcome(raw: &serde_json::Value) -> Result { + match raw.get("outcome").and_then(serde_json::Value::as_str) { + Some("injected") => Ok(SteerOutcome::Injected), + Some("promptRequired") => Ok(SteerOutcome::PromptRequired), + Some("startedNewTurn") => Ok(SteerOutcome::StartedNewTurn), + other => Err(AcpError::protocol(format!( + "unexpected _session/steering outcome: {other:?}" + ))), + } +} + +/// On reconnect, re-apply the user's last-picked Grok model AND reasoning effort +/// (both saved per agent by the frontend and shipped back as preferred config +/// values), reflecting each in its selector's `current_value`. Model is applied +/// first (a pure switch, effort untouched); effort is then re-applied on top of +/// the now-current model via `set_model`'s `_meta.reasoningEffort`. +async fn apply_grok_preferred_options( + cx: &ConnectionTo, + session_id: &SessionId, + opts: &mut Vec, + preferred_config_values: &BTreeMap, + specs: &HashMap, ) { // Model preference — a pure `set_model` (no effort override). On success we // also re-point the effort selector at the newly-preferred model (grok ships @@ -4264,6 +4915,9 @@ async fn apply_and_emit_session_config_options( agent_type: AgentType, grok_meta: Option<&serde_json::Map>, grok_model_specs: Option<&HashMap>, + // The reply's raw top-level `models`, when the send captured one. Only + // Kiro reads it here (Grok's per-model data arrives pre-parsed above). + session_models_raw: Option<&serde_json::Value>, preferred_mode_id: Option<&str>, preferred_config_values: &BTreeMap, initial_config_options: Vec, @@ -4297,20 +4951,88 @@ async fn apply_and_emit_session_config_options( // No x.ai/sessionConfig (unexpected): fall through to the standard path, // which for Grok emits an empty list (no selectors) — same as before. } - let updated = apply_preferred_session_options( - cx, - session, - state, - emitter, - preferred_mode_id, - preferred_config_values, - initial_config_options, - ) - .await; - emit_session_config_options_values(state, emitter, updated).await; -} - -/// Grok's initialize still advertises `image: false` — the coding model + if agent_type == AgentType::Kiro { + if let Some(model) = synthesize_kiro_model_option(session_models_raw) { + // The composer shows the legacy mode selector only when an agent + // has NO config options, so once Kiro has a model selector its + // modes (agent configs) and reasoning effort have to be config + // options too, or they vanish. None of the three is a Kiro config + // option on the wire, so their saved values are restored here on + // their own channels rather than by the shared pipeline — which + // would send each as a `session/set_config_option` Kiro rejects. + let standard_prefs: BTreeMap = preferred_config_values + .iter() + .filter(|(id, _)| !KIRO_SYNTHESIZED_OPTION_IDS.contains(&id.as_str())) + .map(|(id, value)| (id.clone(), value.clone())) + .collect(); + let updated = apply_preferred_session_options( + cx, + session, + state, + emitter, + None, + &standard_prefs, + initial_config_options, + ) + .await; + let session_id = session.session_id().clone(); + // Composer order is this list's order: agent, model, then the + // reasoning selectors — which of those exist depends on the model. + let mut synthesized = Vec::with_capacity(3); + if let Some(mode) = session.modes().as_ref().and_then(kiro_mode_option) { + synthesized.push(mode); + } + synthesized.push(model); + let preferred_mode = preferred_config_values + .get(KIRO_MODE_OPTION_ID) + .map(String::as_str) + .or(preferred_mode_id); + apply_kiro_preferred_options( + cx, + &session_id, + state, + emitter, + &mut synthesized, + preferred_config_values, + preferred_mode, + ) + .await; + let reasoning = state.read().await.kiro_reasoning.clone(); + if let Some(reasoning) = reasoning.as_ref() { + place_kiro_reasoning_selectors(&mut synthesized, reasoning); + } + let pinned = state.read().await.env_pinned_config_option_ids.clone(); + let mut infos = map_session_config_options(&visible_config_options(&pinned, updated)); + infos.retain(|o| !KIRO_SYNTHESIZED_OPTION_IDS.contains(&o.id.as_str())); + synthesized.extend(infos); + emit_session_config_options_info(state, emitter, synthesized).await; + // A metadata that landed between the read above and this emit found + // no selectors to update and was only stored; apply it now. + let latest = state.read().await.kiro_reasoning.clone(); + if latest != reasoning { + if let Some(latest) = latest { + rebuild_kiro_reasoning_selectors(state, emitter, &latest).await; + } + } + return; + } + // No `models` in the reply: the standard path, which is what an older + // or future Kiro that speaks config options would want anyway. + } + let updated = apply_preferred_session_options( + cx, + session, + state, + emitter, + preferred_mode_id, + preferred_config_values, + initial_config_options, + ) + .await; + emit_session_config_options_values(state, emitter, updated).await; +} + +/// Grok's initialize still advertises `image: false` — the coding model /// cannot see pixels. Native ACP `image` blocks nevertheless run its /// image-describe sidecar. An image-mime `resource` blob does not: grok dumps /// it as `` and the model only gets a path. @@ -4894,6 +5616,12 @@ fn load_mcp_servers_for_agent(agent_type: AgentType) -> Vec { // winning — so forwarding would not actually double-mount. It is skipped // anyway because defining one server through two channels is noise, and // because the `codeg-mcp` companion is injected separately regardless. + // + // Kiro joins it: `kiro-cli acp` loads `/settings/mcp.json` + // (default `~/.kiro/settings/mcp.json`) for its default agent — verified + // against 2.24.1, a stdio server declared only there is initialized and + // listed (`_kiro.dev/mcp/server_initialized`) on a `session/new` that + // carries `mcpServers: []`. codeg's MCP settings UI manages that file. if matches!( agent_type, AgentType::Hermes @@ -4902,6 +5630,7 @@ fn load_mcp_servers_for_agent(agent_type: AgentType) -> Vec { | AgentType::Cursor | AgentType::Qoder | AgentType::Antigravity + | AgentType::Kiro ) { return Vec::new(); } @@ -5938,6 +6667,76 @@ async fn run_connection( }, on_receive_notification!(), ) + .on_receive_notification( + { + // Kiro's slash-command list. Claimed here, in the builder + // chain, because Kiro sends it right after `session/new` + // answers — before the session router exists. + let state_inner = Arc::clone(&state); + let emitter_inner = emitter_clone.clone(); + async move |notif: KiroCommandsAvailableNotification, _cx: ConnectionTo| { + let commands = kiro_available_commands(¬if); + emit_with_state( + &state_inner, + &emitter_inner, + AcpEvent::AvailableCommands { commands }, + ) + .await; + Ok(()) + } + }, + on_receive_notification!(), + ) + .on_receive_notification( + { + // Kiro's agent configs are its ACP modes, and `/plan`, `/guide` + // and `/agent swap` switch them from inside a prompt. It reports + // that here rather than as a `current_mode_update`, so without + // this the composer's mode selector would keep the old one. + let state_inner = Arc::clone(&state); + let emitter_inner = emitter_clone.clone(); + async move |notif: KiroAgentSwitchedNotification, _cx: ConnectionTo| { + if let Some(mode_id) = notif.agent_name.filter(|name| !name.trim().is_empty()) { + update_emitted_option( + &state_inner, + &emitter_inner, + KIRO_MODE_OPTION_ID, + &mode_id, + ) + .await; + emit_with_state( + &state_inner, + &emitter_inner, + AcpEvent::ModeChanged { mode_id }, + ) + .await; + } + Ok(()) + } + }, + on_receive_notification!(), + ) + .on_receive_notification( + { + // Kiro's per-session status: reasoning-effort support and the + // running level. Claimed here for the same reason as the + // command list — the first one races the `session/new` reply. + let state_inner = Arc::clone(&state); + let emitter_inner = emitter_clone.clone(); + async move |notif: KiroMetadataNotification, _cx: ConnectionTo| { + if let Some(reasoning) = notif.reasoning.as_ref() { + apply_kiro_metadata( + &state_inner, + &emitter_inner, + parse_kiro_reasoning(reasoning), + ) + .await; + } + Ok(()) + } + }, + on_receive_notification!(), + ) .connect_with(agent, async move |cx| -> Result<(), agent_client_protocol::Error> { let state = state_outer; let agent_name_for_log = registry::get_agent_meta(agent_type).name; @@ -6264,6 +7063,7 @@ async fn run_connection( agent_type, grok_meta.as_ref(), grok_model_specs.as_ref(), + grok_models_raw.as_ref(), preferred_mode_id.as_deref(), &preferred_config_values, initial_config_options.unwrap_or_default(), @@ -6349,6 +7149,11 @@ async fn run_connection( // "Method not found" are real, so the whole error ladder below // stays exactly as it was. let attempted_load = init_resp.agent_capabilities.load_session; + // Kiro reports its model catalog only in the reply's top-level + // `models` (see `synthesize_kiro_model_option`), which the typed + // response drops — so its load goes out untyped and keeps it. + // Every other agent keeps the plain typed send. + let mut load_models_raw: Option = None; let load_result = if attempted_load { let load_req = build_load_session_request( agent_type, @@ -6356,7 +7161,20 @@ async fn run_connection( &cwd, mcp_servers.clone(), ); - cx.send_request_to(Agent, load_req).block_task().await + if agent_type == AgentType::Kiro { + send_capturing_models::( + &cx, + AGENT_METHOD_NAMES.session_load, + load_req, + ) + .await + .map(|(resp, models)| { + load_models_raw = models; + resp + }) + } else { + cx.send_request_to(Agent, load_req).block_task().await + } } else { Err(agent_client_protocol::Error::method_not_found() .data("agent does not advertise the loadSession capability")) @@ -6523,6 +7341,7 @@ async fn run_connection( // `session/load` is a typed send with no raw `models` // capture, so effort stays on the flat fallback. None, + load_models_raw.as_ref(), preferred_mode_id.as_deref(), &preferred_config_values, initial_config_options.unwrap_or_default(), @@ -6710,6 +7529,7 @@ async fn run_connection( agent_type, grok_meta.as_ref(), grok_model_specs.as_ref(), + grok_models_raw.as_ref(), preferred_mode_id.as_deref(), &preferred_config_values, initial_config_options.unwrap_or_default(), @@ -6796,6 +7616,7 @@ async fn run_connection( agent_type, grok_meta.as_ref(), grok_model_specs.as_ref(), + grok_models_raw.as_ref(), preferred_mode_id.as_deref(), &preferred_config_values, initial_config_options.unwrap_or_default(), @@ -7818,6 +8639,20 @@ async fn handle_permission_request( let mut tool_call_value = serde_json::to_value(&req.tool_call).unwrap_or_default(); + // Kiro's permission `toolCall` names no tool (no `_meta`), and in its raw + // shape a `write` reads as the shell command `create` / `strReplace` and + // shows no diff. Rewrite it like the live frames (see `kiro_live_tool`), + // telling the tool by its arguments. + if agent_type == AgentType::Kiro { + if let Some(raw_input) = tool_call_value.get_mut("rawInput") { + let canonical = crate::parsers::kiro::tool_name_from_input(raw_input) + .and_then(|name| crate::parsers::kiro::canonical_tool(name, raw_input)); + if let Some(tool) = canonical { + *raw_input = tool.input; + } + } + } + // Resolve line numbers in rawInput for edit tool permission requests if let Some(obj) = tool_call_value.as_object_mut() { let key = ["rawInput", "raw_input"] @@ -9399,6 +10234,7 @@ async fn handle_fork_or_exit( agent_type, grok_meta.as_ref(), grok_model_specs.as_ref(), + models_raw.as_ref(), inherited_mode_id.as_deref(), &inherited_config_values, initial_config_options.unwrap_or_default(), @@ -10378,6 +11214,7 @@ async fn run_conversation_loop( // out-of-turn pump and its calls settle on the update that // immediately follows, before any turn starts. cb_state.hosted_terminal_calls.clear(); + cb_state.kiro_calls.clear(); // Grok's context ring needs the active model's window paired // with the cumulative token count riding each update. Resolve it // once here (the model can't change mid-turn) so the per-update @@ -10853,17 +11690,11 @@ async fn run_conversation_loop( config_id, value_id, }) => { - let set_result = if agent_type == AgentType::Grok { - set_grok_config_option( - &cx, &sid, state, emitter, config_id, value_id, - ) - .await - } else { - set_session_config_option( - &cx, &sid, state, emitter, config_id, value_id, - ) - .await - }; + let set_result = set_config_option_for_agent( + agent_type, &cx, &sid, state, emitter, config_id, + value_id, + ) + .await; if let Err(e) = set_result { emit_with_state( state, @@ -11155,11 +11986,10 @@ async fn run_conversation_loop( }) => { let cx = session.connection(); let sid = session.session_id().clone(); - let set_result = if agent_type == AgentType::Grok { - set_grok_config_option(&cx, &sid, state, emitter, config_id, value_id).await - } else { - set_session_config_option(&cx, &sid, state, emitter, config_id, value_id).await - }; + let set_result = set_config_option_for_agent( + agent_type, &cx, &sid, state, emitter, config_id, value_id, + ) + .await; if let Err(e) = set_result { emit_with_state( state, @@ -13502,6 +14332,103 @@ fn tool_call_raw_input_text( json_value_to_text(raw_input) } +/// The canonical form of a live Kiro tool call (see +/// [`crate::parsers::kiro::canonical_tool`]), shared by every frame of it. +/// +/// Kiro names the tool in `_meta.kiro.toolName` on the opening `tool_call` +/// only, while its updates re-send `rawInput` in Kiro's own shape — so the name +/// is remembered by id, and each frame that carries input is rewritten from +/// it. A frame without input gets the last canonical form back (its output may +/// still need it), and emits no input of its own: the reducer keeps the prior +/// one. `None` for every other agent, for MCP tools, and for tools with no +/// canonical form. +fn kiro_live_tool( + agent_type: AgentType, + calls: &mut HashMap, + tool_call_id: &str, + meta: Option<&serde_json::Map>, + raw_input: Option<&serde_json::Value>, +) -> Option { + if agent_type != AgentType::Kiro { + return None; + } + let call = calls.entry(tool_call_id.to_string()).or_default(); + if let Some(kiro) = meta.and_then(|m| m.get(KIRO_META_KEY)) { + if let Some(name) = kiro.get("toolName").and_then(serde_json::Value::as_str) { + call.name = Some(name.to_string()); + } + call.mcp |= kiro.get("mcpServerName").is_some(); + } + if call.mcp { + return None; + } + if let Some(input) = raw_input.filter(|input| !input.is_null()) { + let name = call + .name + .as_deref() + .or_else(|| crate::parsers::kiro::tool_name_from_input(input)); + call.tool = name.and_then(|name| crate::parsers::kiro::canonical_tool(name, input)); + } + call.tool.clone() +} + +/// `_meta` namespace Kiro stamps on its tool calls (`{"toolName", "mcpServerName"?}`). +const KIRO_META_KEY: &str = "kiro"; + +/// Add `canonicalToolName` to a Kiro opening frame's `_meta.kiro`: the name the +/// frontend classifies the call by (`extractKiroToolName`), ahead of its +/// input-shape rules — which cannot tell `glob`'s `{pattern}` from `grep`'s, or +/// a directory listing's `{path}` from a file read. An MCP call gets the +/// `mcp____` form the frontend already resolves (codeg-mcp's +/// companions included), since its title — `Running: @/` — names +/// no tool any rule can recover. +fn stamp_kiro_tool_name( + agent_type: AgentType, + tool: Option<&crate::parsers::kiro::CanonicalTool>, + meta: Option>, +) -> Option> { + if agent_type != AgentType::Kiro { + return meta; + } + let mcp_name = || { + let kiro = meta.as_ref()?.get(KIRO_META_KEY)?; + let server = kiro.get("mcpServerName")?.as_str()?; + let tool = kiro.get("toolName")?.as_str()?; + Some(format!("mcp__{server}__{tool}")) + }; + let Some(name) = tool.map(|t| t.name.to_string()).or_else(mcp_name) else { + return meta; + }; + let mut meta = meta.unwrap_or_default(); + let kiro = meta + .entry(KIRO_META_KEY) + .or_insert_with(|| serde_json::json!({})); + if let Some(kiro) = kiro.as_object_mut() { + kiro.insert("canonicalToolName".to_string(), name.into()); + } + Some(meta) +} + +/// Resolve the live `raw_output` string for a Kiro tool call. +/// +/// Kiro wraps every result as `{"items":[{"Text":…} | {"Json":…}]}`, which no +/// card reads — a finished `read` showed that envelope as its body. Unwrap it +/// into the same text the history parser gives the result, and number a read +/// that started past line 1. An empty result emits nothing, so a shell's +/// streamed `content` is not replaced by a blank; anything else stringifies as +/// before. +fn kiro_live_tool_output( + raw_output: &Option, + tool: Option<&crate::parsers::kiro::CanonicalTool>, +) -> Option { + let raw = raw_output.as_ref()?; + match crate::parsers::kiro::live_tool_output_text(raw) { + Some(text) if text.trim().is_empty() => None, + Some(text) => Some(crate::parsers::kiro::structure_tool_output(tool, text)), + None => json_value_to_text(raw_output).map(|text| structurize_live_output(&text)), + } +} + /// Antigravity 1.2's live MCP `rawInput`, folded back into the shape its /// history takes. /// @@ -13730,6 +14657,23 @@ struct CodeBuddyLiveState { /// turn start — a shell call whose turn was canceled never sees a final /// status, and its lifecycle cannot span turns anyway. hosted_terminal_calls: HashMap, + /// Kiro: tool_call_id → which tool the call is (see [`kiro_live_tool`]). + /// Only the opening frame names it, and every later frame re-sends the + /// raw arguments, which must be rewritten the same way. Dropped at a final + /// status and cleared at turn start, like `hosted_terminal_calls`. + kiro_calls: HashMap, +} + +/// What [`kiro_live_tool`] remembers about one live Kiro tool call. +#[derive(Debug, Default)] +struct KiroLiveCall { + /// `_meta.kiro.toolName` from the opening frame. + name: Option, + /// Set by `_meta.kiro.mcpServerName`: an MCP tool is never one of Kiro's + /// own, whatever it is called. + mcp: bool, + /// The last frame's canonical form, for a frame that carries no input. + tool: Option, } /// One announced-but-unpaired Grok `spawn_subagent` call. `description` / @@ -14432,150 +15376,771 @@ fn grok_ext_notification_skipped_on_replay(dispatch: &Dispatch, agent_type: Agen Dispatch::Notification(notification) => { map_grok_ext_notification(notification, agent_type).is_some() } - _ => false, + _ => false, + } +} + +/// The `Error` that accompanies a FAILED compaction card from +/// [`map_grok_ext_notification`] (see `auto_compact_failed` there), or `None`. +/// +/// The card is where the failure is shown in the app. This coded, non-terminal +/// `Error` is for everything that reads errors off the event stream rather +/// than cards — the chat-channel bridges (a remote user sees no transcript), +/// `SessionState::last_error`, the pet's error state — exactly as before the +/// card existed. The frontend knows the code and draws nothing for it (the +/// card already says it), so the app still shows the failure once. +fn compaction_failure_error(event: &AcpEvent, agent_type: AgentType) -> Option { + let AcpEvent::ToolCall { status, meta, .. } = event else { + return None; + }; + if status != "failed" { + return None; + } + let compaction = meta.as_ref()?.get("contextCompaction")?; + let reason = compaction.get("error").and_then(|v| v.as_str()); + Some(AcpEvent::Error { + message: match reason { + Some(reason) => format!("Context compaction failed: {reason}"), + None => "Context compaction failed".to_string(), + }, + agent_type: agent_type.to_string(), + code: Some(COMPACTION_FAILED_ERROR_CODE.to_string()), + details: None, + // Recoverable: the turn goes on uncompacted. + terminal: false, + }) +} + +/// Claims an agent message whose `sessionId` is present but `null`, before the +/// ACP runtime's session routing can park it: a notification is dropped, a +/// request is answered with `invalid_params`. +/// +/// The runtime decides a message is session-bound by FIELD PRESENCE alone +/// (`Dispatch::has_session_id` → `params.get("sessionId").is_some()`), and parks +/// every session-bound message nobody claims in a retry queue that is replayed +/// into each newly added dynamic handler. A `"sessionId": null` therefore looks +/// session-bound and gets parked. Under `sacp` 11 the replay went into the +/// runtime's own session handler, whose `get_session_id` fails on the null with +/// `invalid type: null, expected a string` — and a handler `Err` brought the +/// whole connection down, so the user saw `ACP protocol error: Invalid params: +/// "invalid type: null, expected a string"` instead of a session (issue #794). +/// The 2.x runtime logs a handler error instead of dying on it, and codeg's own +/// `AgentSession` router simply matches no session on a null id — but nothing +/// would ever claim the frame either, so without this guard it would sit in the +/// retry queue for the connection's lifetime and be replayed into every handler +/// registered after it. +/// +/// grok 1.0.40 is what made this reachable: it narrates `session/new` progress +/// on `_x.ai/session/setup`, and its first five phases (`auth`, +/// `resolve_workspace`, `folder_trust`, `plugin_registry`, `mcp_merge`) run +/// BEFORE the session id exists, so they carry `null`. 1.0.34 (the previous +/// pin) sent no such notification at all. The guard is deliberately NOT gated +/// on grok: no ACP notification legitimately carries a null session id, and +/// any agent that sent one would hit the same path. +/// +/// This has to sit in the BUILDER chain, not among the session handlers: the +/// static chain is the only thing that runs before a message can be parked for +/// retry, and a parked message is replayed straight into the newly added +/// dynamic handler without passing through this chain again. +/// +/// A null session id means "not about a session yet", and nothing codeg renders +/// rides on such a notification, so it is dropped. A REQUEST shaped this way is +/// the same protocol violation, and it would be parked just the same — the +/// runtime's `method_not_found` fallback only answers requests with no +/// `sessionId` field at all — leaving the agent blocked on a reply that never +/// comes. It is answered `invalid_params` instead, which is also the truth. +/// Being first in the chain, the guard answers it before codeg's own request +/// handlers see it: the typed ones would reject the null anyway, and no agent +/// codeg drives sends a null to the raw-params bridges (grok's ask and plan +/// exit, the elicitation bridge codex and DeepSeek use, cursor's extension +/// methods — which carry no `sessionId` at all). +struct ClaimNullSessionIds; + +impl HandleDispatchFrom for ClaimNullSessionIds { + async fn handle_dispatch_from( + &mut self, + message: Dispatch, + _connection: ConnectionTo, + ) -> Result, agent_client_protocol::Error> { + match message { + Dispatch::Notification(notification) if has_null_session_id(¬ification) => { + tracing::debug!( + method = %notification.method(), + "[ACP] dropping notification with a null sessionId" + ); + Ok(Handled::Yes) + } + Dispatch::Request(request, responder) if has_null_session_id(&request) => { + tracing::warn!( + method = %request.method(), + "[ACP] rejecting request with a null sessionId" + ); + if let Err(e) = responder.respond_with_error( + agent_client_protocol::Error::invalid_params() + .data("sessionId must be a session id, not null"), + ) { + tracing::debug!("[ACP] could not answer a null-sessionId request: {e}"); + } + Ok(Handled::Yes) + } + message => Ok(Handled::No { + message, + retry: false, + }), + } + } + + fn describe_chain(&self) -> impl std::fmt::Debug { + "ClaimNullSessionIds" + } +} + +/// True when `params.sessionId` exists and is JSON `null` — the one shape +/// [`ClaimNullSessionIds`] exists for. A MISSING `sessionId` is a +/// perfectly ordinary connection-level notification (`_auth/status_update`, +/// grok's `_x.ai/settings/update`) and must keep flowing. +fn has_null_session_id(message: &UntypedMessage) -> bool { + message + .params() + .get("sessionId") + .is_some_and(serde_json::Value::is_null) +} + +/// `_auth/status_update` — the agent reporting which identity IT is logged in +/// with. Introduced by codex-acp 1.9.0; claude-agent-acp 0.75.0 adopted the +/// same method with its own vocabulary. +/// +/// Connection-level: unlike every other agent push codeg reads, the params carry +/// NO `sessionId`, which is exactly why it needs a handler of its own (see +/// [`handle_auth_status_update`]). Only `authStatus` is modelled; the payload is +/// kept as a raw value so a new `kind` or an added field can never turn a +/// well-formed push into a deserialization failure. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, agent_client_protocol::JsonRpcNotification)] +#[notification(method = "_auth/status_update")] +#[serde(rename_all = "camelCase")] +struct AuthStatusUpdateNotification { + auth_status: serde_json::Value, +} + +/// Kiro CLI's slash-command list: `_kiro.dev/commands/available +/// {sessionId, commands: [{name: "/compact", description, meta?}, …]}`, sent in +/// place of the standard `available_commands_update` (verified against 2.24.1). +/// Kiro runs a command it receives as prompt TEXT (`/usage`, `/model `, +/// `/compact`, … each answer with an `agent_message_chunk` and `end_turn`), so +/// listing them is all the composer's `/` menu needs. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, agent_client_protocol::JsonRpcNotification)] +#[notification(method = "_kiro.dev/commands/available")] +#[serde(rename_all = "camelCase")] +struct KiroCommandsAvailableNotification { + #[serde(default)] + session_id: Option, + #[serde(default)] + commands: Vec, +} + +/// `_kiro.dev/metadata {sessionId, contextUsagePercentage, reasoning}` — sent +/// after `session/new` and after every model or effort change. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, agent_client_protocol::JsonRpcNotification)] +#[notification(method = "_kiro.dev/metadata")] +#[serde(rename_all = "camelCase")] +struct KiroMetadataNotification { + #[serde(default)] + session_id: Option, + #[serde(default)] + reasoning: Option, +} + +/// `_kiro.dev/agent/switched {sessionId, agentName, previousAgentName, …}` — +/// Kiro's report that the session's agent config (its ACP mode) changed. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, agent_client_protocol::JsonRpcNotification)] +#[notification(method = "_kiro.dev/agent/switched")] +#[serde(rename_all = "camelCase")] +struct KiroAgentSwitchedNotification { + #[serde(default)] + session_id: Option, + #[serde(default)] + agent_name: Option, +} + +/// Commands the composer should offer for Kiro, named WITHOUT the leading `/` +/// (the standard ACP spelling the `/` menu prefixes itself). +/// +/// Left out: the ones only Kiro's own terminal UI can carry out — `local` +/// commands (`/chat`, `/quit`: they act on the TUI process), the few that open +/// an editor, the clipboard or the microphone of the machine Kiro runs on, and +/// the two that answer a prompt with nothing (checked against 2.24.1): +/// `/prompts` needs the TUI's selection menu, and `/rewind` forks into a new +/// Kiro session codeg would never see. Everything else answers in the +/// conversation. +/// +/// The hint is Kiro's own when it has one, else its subcommand list — the +/// commands that take one (`/code`, `/knowledge`, …) print nothing bare, so the +/// menu should say what to type after them. +fn kiro_available_commands(notif: &KiroCommandsAvailableNotification) -> Vec { + const TUI_ONLY: &[&str] = &["reply", "paste", "voice", "quit", "prompts", "rewind"]; + let mut seen = HashSet::new(); + notif + .commands + .iter() + .filter_map(|command| { + let raw = command.get("name")?.as_str()?.trim(); + let name = raw.strip_prefix('/').unwrap_or(raw).trim(); + let meta = command.get("meta"); + let local = meta + .and_then(|m| m.get("local")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + if name.is_empty() || local || TUI_ONLY.contains(&name) || !seen.insert(name.to_string()) { + return None; + } + let input_hint = meta + .and_then(|m| m.get("hint")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|hint| !hint.is_empty()) + .map(str::to_string) + .or_else(|| { + let subcommands: Vec<&str> = meta? + .get("subcommands")? + .as_array()? + .iter() + .filter_map(serde_json::Value::as_str) + .collect(); + (!subcommands.is_empty()).then(|| subcommands.join(" | ")) + }); + Some(AvailableCommandInfo { + name: name.to_string(), + description: command + .get("description") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(), + input_hint, + }) + }) + .collect() +} + +#[cfg(test)] +mod kiro_ext_tests { + use super::*; + + // Shape of `session/new`'s `models` from kiro-cli 2.24.1 (trimmed). + fn kiro_models() -> serde_json::Value { + serde_json::json!({ + "currentModelId": "claude-opus-5.5", + "availableModels": [ + {"modelId": "auto", "name": "auto", "description": "Models chosen by task"}, + {"modelId": "claude-opus-5.5", "name": "claude-opus-5.5", "description": "1M context"}, + {"modelId": "claude-sonnet-5", "name": "claude-sonnet-5"}, + {"modelId": "claude-sonnet-5", "name": "duplicate"}, + {"modelId": "", "name": "blank"} + ] + }) + } + + #[test] + fn kiro_models_become_the_model_selector() { + let option = synthesize_kiro_model_option(Some(&kiro_models())).expect("selector"); + assert_eq!(option.id, "model"); + assert_eq!(option.category.as_deref(), Some("model")); + let SessionConfigKindInfo::Select(sel) = option.kind else { + panic!("select expected"); + }; + assert_eq!(sel.current_value, "claude-opus-5.5"); + let values: Vec<_> = sel.options.iter().map(|o| o.value.as_str()).collect(); + assert_eq!(values, ["auto", "claude-opus-5.5", "claude-sonnet-5"]); + assert_eq!(sel.options[0].description.as_deref(), Some("Models chosen by task")); + } + + #[test] + fn an_unknown_current_model_falls_back_to_the_first() { + let mut models = kiro_models(); + models["currentModelId"] = serde_json::json!("retired-model"); + let option = synthesize_kiro_model_option(Some(&models)).unwrap(); + let SessionConfigKindInfo::Select(sel) = option.kind else { + panic!("select expected"); + }; + assert_eq!(sel.current_value, "auto"); + } + + #[test] + fn no_usable_models_means_no_selector() { + assert!(synthesize_kiro_model_option(None).is_none()); + assert!(synthesize_kiro_model_option(Some(&serde_json::json!({}))).is_none()); + assert!( + synthesize_kiro_model_option(Some(&serde_json::json!({"availableModels": []}))) + .is_none() + ); + } + + #[test] + fn kiro_commands_map_to_the_slash_menu() { + let notif: KiroCommandsAvailableNotification = serde_json::from_value(serde_json::json!({ + "sessionId": "s1", + "commands": [ + {"name": "/compact", "description": "Compact conversation history"}, + {"name": "/context", "description": "Manage context files", + "meta": {"inputType": "panel", "hint": "add , remove , clear"}}, + {"name": "/model", "description": "Select a model", "meta": {"hint": ""}}, + {"name": "/code", "description": "Code intelligence", + "meta": {"inputType": "panel", "subcommands": ["status", "init"]}}, + {"name": "/chat", "description": "Load a session", "meta": {"local": true}}, + {"name": "/paste", "description": "Paste image from clipboard"}, + {"name": "/rewind", "description": "Rewind (forks into a new session)"}, + {"name": "/compact", "description": "duplicate"}, + {"description": "nameless"} + ] + })) + .unwrap(); + let commands = kiro_available_commands(¬if); + let names: Vec<_> = commands.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, ["compact", "context", "model", "code"]); + assert_eq!(commands[0].description, "Compact conversation history"); + assert_eq!( + commands[1].input_hint.as_deref(), + Some("add , remove , clear") + ); + assert_eq!(commands[2].input_hint, None, "an empty hint is no hint"); + assert_eq!(commands[3].input_hint.as_deref(), Some("status | init")); + } + + #[test] + fn kiro_modes_become_the_agent_selector() { + let modes: SessionModeState = serde_json::from_value(serde_json::json!({ + "currentModeId": "kiro_default", + "availableModes": [ + {"id": "kiro_default", "name": "kiro_default", "description": "The default agent"}, + {"id": "kiro_planner", "name": "kiro_planner"} + ] + })) + .unwrap(); + let option = kiro_mode_option(&modes).expect("selector"); + assert_eq!(option.id, "mode"); + assert_eq!(option.category.as_deref(), Some("mode")); + let SessionConfigKindInfo::Select(sel) = option.kind else { + panic!("select expected"); + }; + assert_eq!(sel.current_value, "kiro_default"); + assert_eq!(sel.options.len(), 2); + assert_eq!(sel.options[0].description.as_deref(), Some("The default agent")); + } + + // The three shapes kiro-cli 2.24.1 sends: a model without effort, one with + // levels but none set yet, and one after `/effort` set a level. + #[test] + fn kiro_reasoning_reads_every_support_state() { + let unavailable = parse_kiro_reasoning(&serde_json::json!({ + "support": "unavailable", "effortLevels": [] + })); + assert_eq!(unavailable, KiroReasoning::default()); + assert!(kiro_effort_option(&unavailable).is_none()); + + let unset = parse_kiro_reasoning(&serde_json::json!({ + "support": "alwaysOn", "effortLevels": ["low", "medium", "high", "xhigh", "max"] + })); + assert_eq!(unset.current, None); + let option = kiro_effort_option(&unset).expect("selector"); + assert_eq!(option.id, "reasoning_effort"); + assert_eq!(option.category.as_deref(), Some("thought_level")); + let SessionConfigKindInfo::Select(sel) = option.kind else { + panic!("select expected"); + }; + assert_eq!(sel.current_value, "default", "nothing set yet: Kiro's own label"); + assert_eq!(sel.options.len(), 5); + + let set = parse_kiro_reasoning(&serde_json::json!({ + "support": "toggleable", "effort": "xhigh", + "effortLevels": ["low", "medium", "high", "xhigh", "max"] + })); + let SessionConfigKindInfo::Select(sel) = kiro_effort_option(&set).unwrap().kind else { + panic!("select expected"); + }; + assert_eq!(sel.current_value, "xhigh"); + + // GPT models: no thinking support, but a real effort control. + let gpt = parse_kiro_reasoning(&serde_json::json!({ + "support": "unavailable", "effort": "high", + "effortLevels": ["none", "low", "medium", "high", "xhigh", "max"] + })); + let SessionConfigKindInfo::Select(sel) = kiro_effort_option(&gpt).unwrap().kind else { + panic!("select expected"); + }; + assert_eq!(sel.current_value, "high"); + assert_eq!(sel.options.len(), 6); + } + + // The composer renders selectors in list order, which for Kiro is agent, + // model, effort — also after a model switch rebuilds the effort selector. + #[test] + fn kiro_effort_goes_right_after_the_model() { + let levels = KiroReasoning { + levels: vec!["low".into(), "high".into()], + current: Some("high".into()), + ..KiroReasoning::default() + }; + let modes: SessionModeState = serde_json::from_value(serde_json::json!({ + "currentModeId": "kiro_default", + "availableModes": [{"id": "kiro_default", "name": "kiro_default"}] + })) + .unwrap(); + let mut opts = vec![ + kiro_mode_option(&modes).unwrap(), + synthesize_kiro_model_option(Some(&kiro_models())).unwrap(), + ]; + let ids = |o: &[SessionConfigOptionInfo]| o.iter().map(|x| x.id.clone()).collect::>(); + + place_kiro_reasoning_selectors(&mut opts, &levels); + assert_eq!(ids(&opts), ["mode", "model", "reasoning_effort"]); + // A toggleable model adds thinking ahead of effort — rebuilt, not + // duplicated. + let toggleable = KiroReasoning { + thinking_toggleable: true, + ..levels.clone() + }; + place_kiro_reasoning_selectors(&mut opts, &toggleable); + assert_eq!(ids(&opts), ["mode", "model", "thinking", "reasoning_effort"]); + place_kiro_reasoning_selectors(&mut opts, &toggleable); + assert_eq!(ids(&opts), ["mode", "model", "thinking", "reasoning_effort"]); + // A model with neither drops both; a later one puts them back in place. + place_kiro_reasoning_selectors(&mut opts, &KiroReasoning::default()); + assert_eq!(ids(&opts), ["mode", "model"]); + place_kiro_reasoning_selectors(&mut opts, &toggleable); + assert_eq!(ids(&opts), ["mode", "model", "thinking", "reasoning_effort"]); + } + + // `toggleable` (claude-opus-5 / sonnet-5 on 2.24.1) is the only support + // level with a switch; `thinkingEnabled` appears once it has been set. + #[test] + fn kiro_thinking_is_a_switch_only_where_kiro_allows_one() { + let unset = parse_kiro_reasoning(&serde_json::json!({ + "support": "toggleable", "effortLevels": ["low", "high"] + })); + let SessionConfigKindInfo::Select(sel) = kiro_thinking_option(&unset).unwrap().kind else { + panic!("select expected"); + }; + assert_eq!(sel.current_value, "default"); + assert_eq!(sel.options.iter().map(|o| o.value.as_str()).collect::>(), ["on", "off"]); + + let off = parse_kiro_reasoning(&serde_json::json!({ + "support": "toggleable", "thinkingEnabled": false, "effortLevels": [] + })); + let SessionConfigKindInfo::Select(sel) = kiro_thinking_option(&off).unwrap().kind else { + panic!("select expected"); + }; + assert_eq!(sel.current_value, "off"); + + for support in ["alwaysOn", "unavailable"] { + let fixed = parse_kiro_reasoning(&serde_json::json!({ + "support": support, "effortLevels": ["low"] + })); + assert!(kiro_thinking_option(&fixed).is_none(), "{support} has no switch"); + } + assert_eq!(kiro_thinking_value("on"), Some(true)); + assert_eq!(kiro_thinking_value("off"), Some(false)); + assert_eq!(kiro_thinking_value("default"), None); + } + + #[test] + fn kiro_trusts_all_tools_only_when_asked() { + assert!(kiro_trust_all_tools_enabled(Some("1"))); + assert!(kiro_trust_all_tools_enabled(Some(" true "))); + for value in [None, Some("0"), Some(""), Some("yes please")] { + assert!(!kiro_trust_all_tools_enabled(value), "{value:?}"); + } + } + + #[test] + fn a_kiro_agent_switch_names_the_new_mode() { + let notif: KiroAgentSwitchedNotification = serde_json::from_value(serde_json::json!({ + "sessionId": "s1", + "agentName": "kiro_planner", + "previousAgentName": "kiro_default", + "welcomeMessage": "hi" + })) + .unwrap(); + assert_eq!(notif.agent_name.as_deref(), Some("kiro_planner")); + } + + /// Run Kiro wire frames through the live emit path, in order, and return + /// every emitted tool-call event as `(kind, raw_input, raw_output, content, + /// meta)`. + async fn kiro_emit( + frames: Vec, + ) -> Vec<( + &'static str, + Option, + Option, + Option, + Option, + )> { + let st = SessionState::new( + "conn-kiro".to_string(), + AgentType::Kiro, + None, + "win".to_string(), + None, + ); + let state = Arc::new(RwLock::new(st)); + let mut cache = ToolCallOutputCache::default(); + let mut cb = CodeBuddyLiveState::default(); + for frame in frames { + let update: SessionUpdate = serde_json::from_value(frame).expect("valid wire shape"); + emit_conversation_update( + &state, + &EventEmitter::Noop, + AgentType::Kiro, + update, + None, + &mut cache, + &mut cb, + ) + .await; + } + let parse = |text: &Option| { + text.as_deref() + .map(|t| serde_json::from_str(t).expect("raw_input is JSON")) + }; + let guard = state.read().await; + guard + .recent_events_after(0) + .expect("events recorded") + .iter() + .filter_map(|e| match &e.payload { + AcpEvent::ToolCall { + raw_input, + raw_output, + content, + meta, + .. + } => Some(( + "call", + parse(raw_input), + raw_output.clone(), + content.clone(), + meta.clone(), + )), + AcpEvent::ToolCallUpdate { + raw_input, + raw_output, + content, + meta, + .. + } => Some(( + "update", + parse(raw_input), + raw_output.clone(), + content.clone(), + meta.clone(), + )), + _ => None, + }) + .collect() } -} -/// The `Error` that accompanies a FAILED compaction card from -/// [`map_grok_ext_notification`] (see `auto_compact_failed` there), or `None`. -/// -/// The card is where the failure is shown in the app. This coded, non-terminal -/// `Error` is for everything that reads errors off the event stream rather -/// than cards — the chat-channel bridges (a remote user sees no transcript), -/// `SessionState::last_error`, the pet's error state — exactly as before the -/// card existed. The frontend knows the code and draws nothing for it (the -/// card already says it), so the app still shows the failure once. -fn compaction_failure_error(event: &AcpEvent, agent_type: AgentType) -> Option { - let AcpEvent::ToolCall { status, meta, .. } = event else { - return None; - }; - if status != "failed" { - return None; + // Frames below are kiro-cli 2.24.1's, captured over real ACP. + + #[tokio::test] + async fn a_kiro_str_replace_becomes_an_edit_not_a_shell_command() { + let events = kiro_emit(vec![ + serde_json::json!({ + "sessionUpdate": "tool_call", + "toolCallId": "t1", + "title": "Editing a.txt", + "kind": "edit", + "content": [{"type": "diff", "path": "/w/a.txt", "oldText": "line two", "newText": "line 2"}], + "locations": [{"path": "/w/a.txt", "line": 2}], + "rawInput": {"__tool_use_purpose": "Fix it", "command": "strReplace", "path": "/w/a.txt", "oldStr": "line two", "newStr": "line 2"}, + "_meta": {"kiro": {"toolName": "write"}} + }), + serde_json::json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": "t1", + "kind": "edit", + "status": "completed", + "rawInput": {"__tool_use_purpose": "Fix it", "command": "strReplace", "path": "/w/a.txt", "oldStr": "line two", "newStr": "line 2"}, + "rawOutput": {"items": [{"Text": "Successfully replaced 1 occurrence(s) in /w/a.txt."}]} + }), + ]) + .await; + let canonical = serde_json::json!({ + "file_path": "/w/a.txt", + "old_string": "line two", + "new_string": "line 2", + "description": "Fix it" + }); + let (_, input, _, content, meta) = &events[0]; + assert_eq!(input.as_ref(), Some(&canonical)); + // The Diff is the same edit again; it must not double up in `content`. + assert_eq!(content, &None); + assert_eq!( + meta.as_ref().and_then(|m| m.pointer("/kiro/canonicalToolName")), + Some(&serde_json::json!("edit")) + ); + // Kiro's own key is left alone. + assert_eq!( + meta.as_ref().and_then(|m| m.pointer("/kiro/toolName")), + Some(&serde_json::json!("write")) + ); + // The update names no tool, but re-sends the raw input: rewritten too, + // or it would replace the canonical one in the reducer. + let (_, input, output, _, _) = &events[1]; + assert_eq!(input.as_ref(), Some(&canonical)); + assert_eq!( + output.as_deref(), + Some("Successfully replaced 1 occurrence(s) in /w/a.txt.") + ); } - let compaction = meta.as_ref()?.get("contextCompaction")?; - let reason = compaction.get("error").and_then(|v| v.as_str()); - Some(AcpEvent::Error { - message: match reason { - Some(reason) => format!("Context compaction failed: {reason}"), - None => "Context compaction failed".to_string(), - }, - agent_type: agent_type.to_string(), - code: Some(COMPACTION_FAILED_ERROR_CODE.to_string()), - details: None, - // Recoverable: the turn goes on uncompacted. - terminal: false, - }) -} -/// Claims an agent message whose `sessionId` is present but `null`, before the -/// ACP runtime's session routing can park it: a notification is dropped, a -/// request is answered with `invalid_params`. -/// -/// The runtime decides a message is session-bound by FIELD PRESENCE alone -/// (`Dispatch::has_session_id` → `params.get("sessionId").is_some()`), and parks -/// every session-bound message nobody claims in a retry queue that is replayed -/// into each newly added dynamic handler. A `"sessionId": null` therefore looks -/// session-bound and gets parked. Under `sacp` 11 the replay went into the -/// runtime's own session handler, whose `get_session_id` fails on the null with -/// `invalid type: null, expected a string` — and a handler `Err` brought the -/// whole connection down, so the user saw `ACP protocol error: Invalid params: -/// "invalid type: null, expected a string"` instead of a session (issue #794). -/// The 2.x runtime logs a handler error instead of dying on it, and codeg's own -/// `AgentSession` router simply matches no session on a null id — but nothing -/// would ever claim the frame either, so without this guard it would sit in the -/// retry queue for the connection's lifetime and be replayed into every handler -/// registered after it. -/// -/// grok 1.0.40 is what made this reachable: it narrates `session/new` progress -/// on `_x.ai/session/setup`, and its first five phases (`auth`, -/// `resolve_workspace`, `folder_trust`, `plugin_registry`, `mcp_merge`) run -/// BEFORE the session id exists, so they carry `null`. 1.0.34 (the previous -/// pin) sent no such notification at all. The guard is deliberately NOT gated -/// on grok: no ACP notification legitimately carries a null session id, and -/// any agent that sent one would hit the same path. -/// -/// This has to sit in the BUILDER chain, not among the session handlers: the -/// static chain is the only thing that runs before a message can be parked for -/// retry, and a parked message is replayed straight into the newly added -/// dynamic handler without passing through this chain again. -/// -/// A null session id means "not about a session yet", and nothing codeg renders -/// rides on such a notification, so it is dropped. A REQUEST shaped this way is -/// the same protocol violation, and it would be parked just the same — the -/// runtime's `method_not_found` fallback only answers requests with no -/// `sessionId` field at all — leaving the agent blocked on a reply that never -/// comes. It is answered `invalid_params` instead, which is also the truth. -/// Being first in the chain, the guard answers it before codeg's own request -/// handlers see it: the typed ones would reject the null anyway, and no agent -/// codeg drives sends a null to the raw-params bridges (grok's ask and plan -/// exit, the elicitation bridge codex and DeepSeek use, cursor's extension -/// methods — which carry no `sessionId` at all). -struct ClaimNullSessionIds; + #[tokio::test] + async fn a_kiro_read_past_line_one_is_numbered_from_its_offset() { + let raw_input = serde_json::json!({ + "__tool_use_purpose": "Read the block", + "operations": [{"mode": "Line", "path": "/w/c.rs", "offset": 6396, "limit": 30}] + }); + let events = kiro_emit(vec![ + serde_json::json!({ + "sessionUpdate": "tool_call", + "toolCallId": "t2", + "title": "Reading c.rs:6397-6426", + "kind": "read", + "rawInput": raw_input, + "_meta": {"kiro": {"toolName": "read"}} + }), + serde_json::json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": "t2", + "status": "completed", + "rawInput": raw_input, + "rawOutput": {"items": [{"Text": "fn a() {}\nfn b() {}"}]} + }), + ]) + .await; + assert_eq!( + events[0].1, + Some(serde_json::json!({ + "file_path": "/w/c.rs", + "offset": 6397, + "limit": 30, + "description": "Read the block" + })) + ); + let output: serde_json::Value = + serde_json::from_str(events[1].2.as_deref().expect("output")).unwrap(); + assert_eq!( + output, + serde_json::json!({"start_line": 6397, "content": "fn a() {}\nfn b() {}"}) + ); + } -impl HandleDispatchFrom for ClaimNullSessionIds { - async fn handle_dispatch_from( - &mut self, - message: Dispatch, - _connection: ConnectionTo, - ) -> Result, agent_client_protocol::Error> { - match message { - Dispatch::Notification(notification) if has_null_session_id(¬ification) => { - tracing::debug!( - method = %notification.method(), - "[ACP] dropping notification with a null sessionId" - ); - Ok(Handled::Yes) - } - Dispatch::Request(request, responder) if has_null_session_id(&request) => { - tracing::warn!( - method = %request.method(), - "[ACP] rejecting request with a null sessionId" - ); - if let Err(e) = responder.respond_with_error( - agent_client_protocol::Error::invalid_params() - .data("sessionId must be a session id, not null"), - ) { - tracing::debug!("[ACP] could not answer a null-sessionId request: {e}"); - } - Ok(Handled::Yes) - } - message => Ok(Handled::No { - message, - retry: false, + #[tokio::test] + async fn a_kiro_shell_result_is_what_the_terminal_printed() { + let raw_input = + serde_json::json!({"__tool_use_purpose": "Fail loudly", "command": "echo hi >&2; exit 3"}); + let events = kiro_emit(vec![ + serde_json::json!({ + "sessionUpdate": "tool_call", + "toolCallId": "t3", + "title": "Running: echo hi >&2; exit 3", + "kind": "execute", + "rawInput": raw_input, + "_meta": {"kiro": {"toolName": "shell"}} }), - } + serde_json::json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": "t3", + "content": [{"type": "content", "content": {"type": "text", "text": "hi\n"}}] + }), + serde_json::json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": "t3", + "kind": "execute", + "status": "completed", + "rawInput": raw_input, + "rawOutput": {"items": [{"Json": {"exit_status": "exit status: 3", "stdout": "", "stderr": "hi\n"}}]} + }), + ]) + .await; + assert_eq!( + events[0].1, + Some(serde_json::json!({"command": "echo hi >&2; exit 3", "description": "Fail loudly"})) + ); + // The streamed chunk carries no input: the reducer keeps the prior one. + assert_eq!(events[1].1, None); + assert_eq!(events[1].3.as_deref(), Some("hi\n")); + assert_eq!(events[2].2.as_deref(), Some("hi\nexit status: 3")); } - fn describe_chain(&self) -> impl std::fmt::Debug { - "ClaimNullSessionIds" + #[tokio::test] + async fn a_kiro_glob_and_an_mcp_call_are_named_for_the_frontend() { + let events = kiro_emit(vec![ + serde_json::json!({ + "sessionUpdate": "tool_call", + "toolCallId": "g1", + "title": "Finding *.txt in ws", + "kind": "search", + "rawInput": {"__tool_use_purpose": "Find", "pattern": "*.txt", "path": "/w"}, + "_meta": {"kiro": {"toolName": "glob"}} + }), + serde_json::json!({ + "sessionUpdate": "tool_call", + "toolCallId": "m1", + "title": "Running: @codeg-mcp/get_session_info", + "rawInput": {"__tool_use_purpose": "Look up", "session_id": 7}, + "_meta": {"kiro": {"toolName": "get_session_info", "mcpServerName": "codeg-mcp"}} + }), + serde_json::json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": "m1", + "status": "completed", + "rawInput": {"__tool_use_purpose": "Look up", "session_id": 7}, + "rawOutput": {"items": [{"Json": {"content": [{"type": "text", "text": "{\"found\":false}"}]}}]} + }), + ]) + .await; + assert_eq!( + events[0].4.as_ref().and_then(|m| m.pointer("/kiro/canonicalToolName")), + Some(&serde_json::json!("glob")) + ); + // An MCP tool's arguments are its own: passed through untouched. + assert_eq!( + events[1].1, + Some(serde_json::json!({"__tool_use_purpose": "Look up", "session_id": 7})) + ); + assert_eq!( + events[1].4.as_ref().and_then(|m| m.pointer("/kiro/canonicalToolName")), + Some(&serde_json::json!("mcp__codeg-mcp__get_session_info")) + ); + // The MCP text result, not its `{content:[…]}` envelope — the + // companion cards parse it. + assert_eq!(events[2].2.as_deref(), Some(r#"{"found":false}"#)); } -} -/// True when `params.sessionId` exists and is JSON `null` — the one shape -/// [`ClaimNullSessionIds`] exists for. A MISSING `sessionId` is a -/// perfectly ordinary connection-level notification (`_auth/status_update`, -/// grok's `_x.ai/settings/update`) and must keep flowing. -fn has_null_session_id(message: &UntypedMessage) -> bool { - message - .params() - .get("sessionId") - .is_some_and(serde_json::Value::is_null) -} + #[test] + fn an_mcp_tool_named_like_a_builtin_is_not_rewritten() { + let mut calls = HashMap::new(); + let meta = serde_json::json!({"kiro": {"toolName": "read", "mcpServerName": "fs"}}); + let tool = kiro_live_tool( + AgentType::Kiro, + &mut calls, + "x", + meta.as_object(), + Some(&serde_json::json!({"operations": [{"mode": "Line", "path": "/a"}]})), + ); + assert_eq!(tool, None); + } -/// `_auth/status_update` — the agent reporting which identity IT is logged in -/// with. Introduced by codex-acp 1.9.0; claude-agent-acp 0.75.0 adopted the -/// same method with its own vocabulary. -/// -/// Connection-level: unlike every other agent push codeg reads, the params carry -/// NO `sessionId`, which is exactly why it needs a handler of its own (see -/// [`handle_auth_status_update`]). Only `authStatus` is modelled; the payload is -/// kept as a raw value so a new `kind` or an added field can never turn a -/// well-formed push into a deserialization failure. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, agent_client_protocol::JsonRpcNotification)] -#[notification(method = "_auth/status_update")] -#[serde(rename_all = "camelCase")] -struct AuthStatusUpdateNotification { - auth_status: serde_json::Value, + #[test] + fn only_kiro_calls_are_rewritten() { + let mut calls = HashMap::new(); + let meta = serde_json::json!({"kiro": {"toolName": "write"}}); + let input = serde_json::json!({"command": "create", "path": "/a", "content": "x"}); + assert_eq!( + kiro_live_tool(AgentType::Cursor, &mut calls, "x", meta.as_object(), Some(&input)), + None + ); + assert!(calls.is_empty()); + } } /// Claim `_auth/status_update` and drop it, loudly enough to be greppable. @@ -15179,6 +16744,13 @@ async fn emit_conversation_update( CodexSubagentActivity::Other => return, }; let tool_call_id = tc.tool_call_id.to_string(); + let kiro_tool = kiro_live_tool( + agent_type, + &mut cb_state.kiro_calls, + &tool_call_id, + tc.meta.as_ref(), + tc.raw_input.as_ref(), + ); // Remember which capsule owns this child, so its eventual // `completed` / `interrupted` (announced under a synthetic id of its // own) can be routed back here. @@ -15235,8 +16807,11 @@ async fn emit_conversation_update( Some((_, inner)) => { json_value_to_text(&Some(inner.clone())).filter(|t| !t.trim().is_empty()) } - None => tool_call_raw_input_text(agent_type, tc.meta.as_ref(), &tc.raw_input) - .filter(|t| !t.trim().is_empty()), + None => match &kiro_tool { + Some(tool) => Some(tool.input.to_string()), + None => tool_call_raw_input_text(agent_type, tc.meta.as_ref(), &tc.raw_input), + } + .filter(|t| !t.trim().is_empty()), }; let synthesized_edit = if own_raw_input.is_none() { synthesize_edit_input_from_diffs(content_blocks) @@ -15269,8 +16844,13 @@ async fn emit_conversation_update( } else { None }; + // Kiro's `write` ships the edit as a `Diff` too (a create's with + // old == new); the canonical input already carries it. let content = - serialize_tool_call_content(content_blocks, synthesized_edit.is_none()) + serialize_tool_call_content( + content_blocks, + synthesized_edit.is_none() && kiro_tool.is_none(), + ) .map(|c| unwrap_codebuddy_deferred_output(agent_type, &c).unwrap_or(c)) // pi announces a command with an empty result, which pi-acp // renders as JSON source (see fn doc). @@ -15300,6 +16880,8 @@ async fn emit_conversation_update( // around the SAME text `content` already carries; shipping it // shadows the clean text (see opencode_live_tool_output). opencode_live_tool_output(&content, &tc.raw_output) + } else if matches!(agent_type, AgentType::Kiro) { + kiro_live_tool_output(&tc.raw_output, kiro_tool.as_ref()) } else { json_value_to_text(&tc.raw_output) .map(|text| unwrap_codebuddy_deferred_output(agent_type, &text).unwrap_or(text)) @@ -15336,7 +16918,8 @@ async fn emit_conversation_update( &tc.title, tc.meta, ); - let meta = stamp_codex_search_action(agent_type, &tc.kind, hosted_shell, meta) + let meta = stamp_codex_search_action(agent_type, &tc.kind, hosted_shell, meta); + let meta = stamp_kiro_tool_name(agent_type, kiro_tool.as_ref(), meta) .map(serde_json::Value::Object); raw_output_cache.remove_if_final(&tool_call_id, Some(status.as_str())); // Track Grok's spawn_subagent lifecycle for the subagent-notification @@ -15434,6 +17017,13 @@ async fn emit_conversation_update( CodexSubagentActivity::Other => return, }; let tool_call_id = tcu.tool_call_id.to_string(); + let kiro_tool = kiro_live_tool( + agent_type, + &mut cb_state.kiro_calls, + &tool_call_id, + tcu.meta.as_ref(), + tcu.fields.raw_input.as_ref(), + ); if let (Some(thread_id), Some(input)) = (codex_subagent_thread, codex_subagent.as_ref()) { cb_state @@ -15484,10 +17074,16 @@ async fn emit_conversation_update( Some((_, inner)) => { json_value_to_text(&Some(inner.clone())).filter(|t| !t.trim().is_empty()) } - None => { - tool_call_raw_input_text(agent_type, tcu.meta.as_ref(), &tcu.fields.raw_input) - .filter(|t| !t.trim().is_empty()) + // A Kiro frame without input keeps the reducer's prior one. + None => match (&kiro_tool, &tcu.fields.raw_input) { + (Some(tool), Some(_)) => Some(tool.input.to_string()), + _ => tool_call_raw_input_text( + agent_type, + tcu.meta.as_ref(), + &tcu.fields.raw_input, + ), } + .filter(|t| !t.trim().is_empty()), }; let synthesized_edit = if own_raw_input.is_none() { content_blocks.and_then(synthesize_edit_input_from_diffs) @@ -15534,7 +17130,9 @@ async fn emit_conversation_update( None }; let content = content_blocks - .and_then(|c| serialize_tool_call_content(c, synthesized_edit.is_none())) + .and_then(|c| { + serialize_tool_call_content(c, synthesized_edit.is_none() && kiro_tool.is_none()) + }) .map(|c| unwrap_codebuddy_deferred_output(agent_type, &c).unwrap_or(c)) // Symmetric with the ToolCall arm — and the arm that matters: // pi's empty opening frame is a `tool_call_update`. @@ -15582,6 +17180,9 @@ async fn emit_conversation_update( // OpenCode delivers every result on the completion update (see // opencode_live_tool_output). opencode_live_tool_output(&content, &tcu.fields.raw_output) + } else if matches!(agent_type, AgentType::Kiro) { + // Kiro delivers every result on the completion update. + kiro_live_tool_output(&tcu.fields.raw_output, kiro_tool.as_ref()) } else { json_value_to_text(&tcu.fields.raw_output) .map(|text| unwrap_codebuddy_deferred_output(agent_type, &text).unwrap_or(text)) @@ -15636,6 +17237,7 @@ async fn emit_conversation_update( Some("completed" | "failed" | "cancelled" | "error") ) { cb_state.hosted_terminal_calls.remove(&tool_call_id); + cb_state.kiro_calls.remove(&tool_call_id); } // Symmetric with the ToolCall arm: an update may carry the terminal // status (and, on grok, usually re-carries the `x.ai/tool` meta). diff --git a/src-tauri/src/acp/custom_registry.rs b/src-tauri/src/acp/custom_registry.rs index a0567f3d3b..cca5bd2081 100644 --- a/src-tauri/src/acp/custom_registry.rs +++ b/src-tauri/src/acp/custom_registry.rs @@ -973,14 +973,18 @@ mod tests { // A custom definition must not claim a BUILT-IN registry id: it would be // shadowed by `from_registry_id`'s built-in arms and double-listed by // `all_acp_agents`. This is also the hydration path that retires a - // pre-integration `deepseek-acp` / `qoder-cli` / `antigravity-acp` custom - // entry (kept in the DB, never published) once the id became a built-in. + // pre-integration `deepseek-acp` / `qoder-cli` / `antigravity-acp` / + // `kiro-cli` custom entry (kept in the DB, never published) once the id + // became a built-in. (Kiro's is also migrated away by + // `m20260927_000001_kiro_builtin_agent`, so this is the backstop for a + // database that re-grows one.) #[test] fn rejects_builtin_registry_id_collisions() { for id in [ "deepseek-acp", "qoder-cli", "antigravity-acp", + "kiro-cli", "claude-acp", "cursor", ] { diff --git a/src-tauri/src/acp/delegation/companion.rs b/src-tauri/src/acp/delegation/companion.rs index 64416b3ac6..531d3f1946 100644 --- a/src-tauri/src/acp/delegation/companion.rs +++ b/src-tauri/src/acp/delegation/companion.rs @@ -2658,7 +2658,7 @@ mod tests { let agents = delegate["inputSchema"]["properties"]["agent_type"]["enum"] .as_array() .unwrap(); - assert_eq!(agents.len(), 15); + assert_eq!(agents.len(), 16); assert!(agents.iter().any(|a| a == "hermes")); assert!(agents.iter().any(|a| a == "code_buddy")); assert!(agents.iter().any(|a| a == "kimi_code")); @@ -2668,6 +2668,7 @@ mod tests { assert!(agents.iter().any(|a| a == "deepseek")); assert!(agents.iter().any(|a| a == "qoder")); assert!(agents.iter().any(|a| a == "antigravity")); + assert!(agents.iter().any(|a| a == "kiro")); // get_delegation_status takes a single id param — task_ids (required) — // plus wait_ms. The legacy single `task_id` param is gone. let status = tools @@ -2707,11 +2708,11 @@ mod tests { .as_array() .unwrap() .clone(); - assert_eq!(agents.len(), 17, "15 builtins + 2 distinct customs"); + assert_eq!(agents.len(), 18, "16 builtins + 2 distinct customs"); // Builtins keep the embedded order and come first. assert_eq!(agents[0], "claude_code"); - assert_eq!(agents[15], "custom:goose"); - assert_eq!(agents[16], "custom:amp"); + assert_eq!(agents[16], "custom:goose"); + assert_eq!(agents[17], "custom:amp"); // The other delegation tools carry no agent_type and are untouched. let status = tools .as_array() @@ -2746,13 +2747,13 @@ mod tests { .as_array() .unwrap() .clone(); - assert_eq!(agents.len(), 14, "15 builtins - 2 disabled + 1 custom"); + assert_eq!(agents.len(), 15, "16 builtins - 2 disabled + 1 custom"); assert!(!agents.contains(&serde_json::json!("codex"))); assert!(!agents.contains(&serde_json::json!("grok"))); // Survivors keep the embedded order, customs still come last. assert_eq!(agents[0], "claude_code"); assert_eq!(agents[1], "open_code"); - assert_eq!(agents[13], "custom:goose"); + assert_eq!(agents[14], "custom:goose"); } // An empty disabled list (the parent omitted `--disabled-agents`) leaves @@ -2774,9 +2775,9 @@ mod tests { .as_array() .unwrap() .clone(); - assert_eq!(agents.len(), 15); + assert_eq!(agents.len(), 16); assert_eq!(agents[0], "claude_code"); - assert_eq!(agents[14], "antigravity"); + assert_eq!(agents[15], "kiro"); } #[tokio::test] diff --git a/src-tauri/src/acp/delegation/tool_schema.json b/src-tauri/src/acp/delegation/tool_schema.json index a14f46c605..3b467160dc 100644 --- a/src-tauri/src/acp/delegation/tool_schema.json +++ b/src-tauri/src/acp/delegation/tool_schema.json @@ -26,7 +26,8 @@ "cursor", "deepseek", "qoder", - "antigravity" + "antigravity", + "kiro" ], "description": "Which local agent runs the sub-task. Pick the one best suited to the work — or, when the user's message names an agent via `@AgentName` / `codeg://agent/`, use that exact `` slug (e.g. `codeg://agent/claude_code` means `claude_code`)." }, diff --git a/src-tauri/src/acp/file_system_runtime.rs b/src-tauri/src/acp/file_system_runtime.rs index 96a80b2f71..f772580d7f 100644 --- a/src-tauri/src/acp/file_system_runtime.rs +++ b/src-tauri/src/acp/file_system_runtime.rs @@ -599,6 +599,15 @@ fn agent_root_slots(agent_type: AgentType) -> &'static [RootSlot] { trims: false, default_rel: &[".gemini"], }], + // `KIRO_HOME` names the `.kiro` directory ITSELF (agents, prompts, + // skills, steering, settings and sessions all hang off it), the same + // shape `GEMINI_HOME` has for Antigravity — see + // `parsers::kiro::resolve_kiro_home_dir`. + AgentType::Kiro => &[RootSlot { + candidates: &[("KIRO_HOME", "", VERBATIM)], + trims: false, + default_rel: &[".kiro"], + }], AgentType::ClaudeCode => &[RootSlot { candidates: &[("CLAUDE_CONFIG_DIR", "", VERBATIM)], trims: false, @@ -2114,8 +2123,9 @@ mod tests { fn root_slots_match_parser_resolvers() { use crate::parsers; - let expected: [(AgentType, PathBuf); 12] = [ + let expected: [(AgentType, PathBuf); 13] = [ (AgentType::Grok, parsers::grok::resolve_grok_home_dir()), + (AgentType::Kiro, parsers::kiro::resolve_kiro_home_dir()), ( AgentType::ClaudeCode, parsers::claude::resolve_claude_config_dir(), diff --git a/src-tauri/src/acp/preflight.rs b/src-tauri/src/acp/preflight.rs index a35172468d..5dd1ab17ad 100644 --- a/src-tauri/src/acp/preflight.rs +++ b/src-tauri/src/acp/preflight.rs @@ -100,6 +100,13 @@ pub async fn run_preflight(agent_type: AgentType) -> PreflightResult { system_cmd, .. } => check_uv_environment(*uv_required, *system_cmd).await, + AgentDistribution::System { + cmd, install_url, .. + } => vec![build_system_cli_check( + cmd, + install_url, + crate::commands::acp::resolve_system_agent_binary_for(agent_type, cmd).as_deref(), + )], }; let passed = checks @@ -446,6 +453,42 @@ async fn check_uv_environment( }] } +/// Preflight for a [`AgentDistribution::System`] agent: the user's own install +/// is the only one there is, so the single question is whether it resolves. +/// Pure over the resolution result so both outcomes are unit-tested without +/// touching PATH. +/// +/// A miss is a `Fail` with the vendor's install page as the fix — there is no +/// codeg-side Install to offer, and a `Warn` would read as "connect anyway". +fn build_system_cli_check( + cmd: &str, + install_url: &str, + resolved: Option<&std::path::Path>, +) -> CheckItem { + match resolved { + Some(path) => CheckItem { + check_id: "system_cli".into(), + label: cmd.to_string(), + status: CheckStatus::Pass, + message: format!("Using the system-installed {cmd} at {}", path.display()), + fixes: vec![], + }, + None => CheckItem { + check_id: "system_cli".into(), + label: cmd.to_string(), + status: CheckStatus::Fail, + message: format!( + "{cmd} was not found on PATH or in ~/.local/bin. Install it with the official installer, then refresh." + ), + fixes: vec![FixAction { + label: "Install guide".into(), + kind: FixActionKind::OpenUrl, + payload: install_url.to_string(), + }], + }, + } +} + /// Run ` --version` and extract the version token (output looks like /// "uvx 0.8.10 (hash date)"). async fn run_uv_version(uvx_path: &std::path::Path) -> Option { @@ -832,3 +875,45 @@ mod adapter_tests { } } } + +#[cfg(test)] +mod system_cli_tests { + use super::*; + + #[test] + fn a_resolved_system_cli_passes_and_names_its_path() { + let check = build_system_cli_check( + "kiro-cli", + "https://kiro.dev/docs/getting-started/installation/", + Some(std::path::Path::new("/Users/me/.local/bin/kiro-cli")), + ); + assert_eq!(check.check_id, "system_cli"); + assert!(matches!(check.status, CheckStatus::Pass)); + assert!(check.message.contains("/Users/me/.local/bin/kiro-cli")); + assert!(check.fixes.is_empty()); + } + + // There is no codeg-side install for a system agent, so a miss must FAIL + // (a warn would let the user connect into a spawn error) and hand over the + // vendor's own install page as the one fix. + #[test] + fn a_missing_system_cli_fails_with_the_install_guide() { + let url = "https://kiro.dev/docs/getting-started/installation/"; + let check = build_system_cli_check("kiro-cli", url, None); + assert!(matches!(check.status, CheckStatus::Fail)); + assert!(check.message.contains("kiro-cli")); + assert_eq!(check.fixes.len(), 1); + assert!(matches!(check.fixes[0].kind, FixActionKind::OpenUrl)); + assert_eq!(check.fixes[0].payload, url); + } + + // Whatever the machine has installed, a system agent's preflight is ONE + // check: nothing is cached, pinned or downloadable to report on. + #[tokio::test] + async fn kiro_preflight_is_the_single_system_check() { + let result = run_preflight(AgentType::Kiro).await; + assert_eq!(result.checks.len(), 1); + assert_eq!(result.checks[0].check_id, "system_cli"); + assert!(result.adapter.is_none()); + } +} diff --git a/src-tauri/src/acp/registry.rs b/src-tauri/src/acp/registry.rs index 355d565631..f902c54bca 100644 --- a/src-tauri/src/acp/registry.rs +++ b/src-tauri/src/acp/registry.rs @@ -53,6 +53,29 @@ pub enum AgentDistribution { /// install`, an official installer) launch it without `uv`. system_cmd: Option<(&'static str, &'static [&'static str])>, }, + /// An agent codeg never installs or pins: the vendor ships it only through + /// an installer of its own (a signed `.dmg`, an `.msi`, a shell script that + /// also wires up shell integrations), which the binary cache cannot + /// reproduce. The one supported install is the user's, found on PATH the + /// way [`AgentDistribution::Binary`]'s system fallback finds a CLI — PATH, + /// then `~/.local/bin`, then [`binary_system_dirs`] — and the Settings page + /// points at `install_url` instead of offering an Install button. Its + /// version is whatever ` --version` reports. + /// + /// UPGRADES go through the vendor's own updater when it has one + /// (`update_args`), so the copy the user's terminal runs and the copy codeg + /// launches stay one and the same install. + System { + /// Command name on PATH. + cmd: &'static str, + args: &'static [&'static str], + env: &'static [(&'static str, &'static str)], + /// The vendor's own install instructions. + install_url: &'static str, + /// Arguments that make `cmd` update ITSELF in place without prompting, + /// or `None` when the CLI has no such command (no Upgrade button then). + update_args: Option<&'static [&'static str]>, + }, } #[derive(Debug, Clone)] @@ -143,6 +166,8 @@ impl AcpAgentMeta { AgentDistribution::Npx { version, .. } | AgentDistribution::Binary { version, .. } | AgentDistribution::Uvx { version, .. } => Some(*version), + // Nothing is pinned: the user's own install is the only version. + AgentDistribution::System { .. } => None, } } @@ -172,7 +197,7 @@ impl AcpAgentMeta { pub fn supports_custom_version(&self) -> bool { match &self.distribution { AgentDistribution::Npx { .. } => true, - AgentDistribution::Uvx { .. } => false, + AgentDistribution::Uvx { .. } | AgentDistribution::System { .. } => false, AgentDistribution::Binary { version, platforms, .. } => platforms @@ -197,6 +222,10 @@ const ANTIGRAVITY_LAUNCH_ARGS: &[&str] = if cfg!(target_os = "linux") { &[] }; +/// Where the Settings page sends a user who has no `kiro-cli` yet. The CLI's +/// own `/docs/cli/installation/` redirects here. +const KIRO_INSTALL_URL: &str = "https://kiro.dev/docs/getting-started/installation/"; + pub fn current_platform() -> &'static str { #[cfg(all(target_os = "macos", target_arch = "aarch64"))] { @@ -224,7 +253,7 @@ pub fn current_platform() -> &'static str { } } -/// The fifteen built-in agents. Excludes user-registered custom agents — use +/// The sixteen built-in agents. Excludes user-registered custom agents — use /// [`all_acp_agents`] for the live set. pub fn builtin_acp_agents() -> Vec { vec![ @@ -243,10 +272,11 @@ pub fn builtin_acp_agents() -> Vec { AgentType::DeepSeek, AgentType::Qoder, AgentType::Antigravity, + AgentType::Kiro, ] } -/// Every agent codeg can currently drive: the fifteen built-ins followed by +/// Every agent codeg can currently drive: the sixteen built-ins followed by /// the user's registered custom ACP agents (sorted by id). pub fn all_acp_agents() -> Vec { let mut agents = builtin_acp_agents(); @@ -271,6 +301,7 @@ pub fn registry_id_for(agent_type: AgentType) -> &'static str { AgentType::DeepSeek => "deepseek-acp", AgentType::Qoder => "qoder-cli", AgentType::Antigravity => "antigravity-acp", + AgentType::Kiro => "kiro-cli", // A custom agent's registry id IS its identity. AgentType::Custom(id) => id, } @@ -293,6 +324,7 @@ pub fn from_registry_id(id: &str) -> Option { "deepseek-acp" => Some(AgentType::DeepSeek), "qoder-cli" => Some(AgentType::Qoder), "antigravity-acp" => Some(AgentType::Antigravity), + "kiro-cli" => Some(AgentType::Kiro), // Only ids the user has actually registered resolve. An unregistered // id must stay `None` so the ACP-registry picker still offers it as // "addable" rather than treating it as already supported. @@ -458,9 +490,9 @@ pub fn uses_cursor_acp_backend(agent_type: AgentType) -> bool { fn distribution_uses_cursor_acp(distribution: &AgentDistribution) -> bool { match distribution { - AgentDistribution::Npx { cmd, args, .. } | AgentDistribution::Binary { cmd, args, .. } => { - launch_spec_uses_cursor_acp(cmd, args) - } + AgentDistribution::Npx { cmd, args, .. } + | AgentDistribution::Binary { cmd, args, .. } + | AgentDistribution::System { cmd, args, .. } => launch_spec_uses_cursor_acp(cmd, args), AgentDistribution::Uvx { cmd, args, @@ -3020,6 +3052,55 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { }), }, }, + AgentType::Kiro => AcpAgentMeta { + agent_type, + supports_mcp: true, + name: "Kiro CLI", + description: "Amazon's Kiro agent in the terminal (native ACP via kiro-cli acp)", + // `kiro-cli acp` is the CLI's OWN ACP server. Verified handshake + // against 2.24.1: `agentInfo.name = "Kiro CLI Agent"`, + // `loadSession: true`, empty `sessionCapabilities`, image prompts + // (no embeddedContext), MCP http but NOT sse, and no + // `authMethods` — sign-in is the CLI's own `kiro-cli login`, which + // the ACP server reuses. `session/new` answers with standard + // `modes` (the agent configs: kiro_default / kiro_planner / + // kiro_guide, plus any user agent under `~/.kiro/agents`) and + // `models`, so the composer selectors need no per-agent code. + // + // SYSTEM, NOT BINARY. Kiro ships only through its own installers: + // a universal `Kiro CLI.dmg` on macOS (an app bundle that links + // its binaries into `~/.local/bin` on first launch), an `.msi` on + // Windows, and Linux archives whose bundled `install.sh` places + // them in `~/.local/bin`. The binary cache can do none of the + // first two, and `kiro-cli` is a dispatcher that execs a sibling + // `kiro-cli-chat` (the ACP server itself), so it is not a + // single-file install either. A codeg-managed copy would also + // diverge from the one the user's terminal runs, while both share + // `~/.kiro` (sessions, settings, agents, sign-in) and the CLI + // updates itself in place (`kiro-cli update`). So codeg launches + // the user's install and never pins one; `~/.local/bin` is probed + // by the system resolver even when a Finder-launched app's PATH + // lacks it. + // + // Sessions land in `~/.kiro/sessions/cli/.json` (metadata) + + // `.jsonl` (the event log) for every entrypoint, ACP included, + // which `parsers::kiro` reads for history. MCP servers come from + // `~/.kiro/settings/mcp.json`, which the CLI loads natively, so + // codeg manages that file and does not forward the same servers + // over `session/new` (see `load_mcp_servers_for_agent`). + distribution: AgentDistribution::System { + cmd: "kiro-cli", + args: &["acp"], + env: &[], + install_url: KIRO_INSTALL_URL, + // `kiro-cli update --non-interactive` checks Kiro's release + // channel and installs a newer build without asking (2.24.1: + // "No updates available, 2.24.1 is the latest version." when + // current, exit 0) — the same updater `kiro-cli update` runs + // in the terminal, so the install stays the vendor's. + update_args: Some(&["update", "--non-interactive"]), + }, + }, // Handled by the early return above; kept so the match stays // exhaustive without a catch-all that could swallow a new built-in. AgentType::Custom(_) => unreachable!("custom agents resolve via custom_registry"), @@ -3253,6 +3334,46 @@ mod tests { } } + // Kiro is the one SYSTEM agent: its installers (a .dmg, an .msi, a Linux + // install.sh) are nothing the binary cache can replay, so there is no pin, + // no custom version, and the launch is the user's own `kiro-cli acp` — + // upgraded in place by the CLI's own updater. + #[test] + fn kiro_launches_the_system_cli_without_a_pin() { + let meta = get_agent_meta(AgentType::Kiro); + assert_eq!(registry_id_for(AgentType::Kiro), "kiro-cli"); + assert_eq!(from_registry_id("kiro-cli"), Some(AgentType::Kiro)); + assert_eq!(meta.registry_version(), None); + assert!(!meta.supports_custom_version()); + assert!(!uses_cursor_acp_backend(AgentType::Kiro)); + match meta.distribution { + AgentDistribution::System { + cmd, + args, + env, + install_url, + update_args, + } => { + assert_eq!(cmd, "kiro-cli"); + assert_eq!(args, &["acp"]); + assert!(env.is_empty()); + assert!(install_url.starts_with("https://kiro.dev/")); + assert_eq!(update_args, Some(&["update", "--non-interactive"][..])); + } + other => panic!("expected system distribution for Kiro, got {other:?}"), + } + // No other built-in rides the system distribution: every one of them + // has a pinned version codeg installs itself. + for agent_type in builtin_acp_agents() { + if agent_type != AgentType::Kiro { + assert!( + get_agent_meta(agent_type).registry_version().is_some(), + "{agent_type:?} lost its pin" + ); + } + } + } + #[test] fn steering_prompt_required_min_version_gates_claude_only() { // The native-steering policy bit: only an adapter that honors the diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index 91edbaaa0f..15e0645072 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -335,6 +335,14 @@ pub struct SessionState { /// Backend-internal — not serialized. pub grok_model_specs: Option>, + /// Kiro only: the reasoning-effort state from its latest + /// `_kiro.dev/metadata` (which arrives on its own schedule — after + /// `session/new`, and again after every model or effort change). Kept here + /// so the effort selector can be built whether that notification lands + /// before or after the session's selectors are first emitted. `None` = + /// Kiro has said nothing yet. Backend-internal — not serialized. + pub kiro_reasoning: Option, + /// pi only: the session prelude pi-acp reports as `_meta.piAcp.startupInfo` /// on `session/new`, held until the matching `agent_message_chunk` arrives /// so that chunk can be recognized and dropped instead of rendering as the @@ -680,6 +688,7 @@ impl SessionState { current_mode: None, config_options: None, grok_model_specs: None, + kiro_reasoning: None, pi_startup_banner: None, asserted_config_values: BTreeMap::new(), env_pinned_config_option_ids: Vec::new(), diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index 07871ecee3..7992d8cb36 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -1349,6 +1349,25 @@ pub struct GrokModelSpec { pub context_window: Option, } +/// Kiro CLI's reasoning-effort state, from `_kiro.dev/metadata.reasoning`: +/// `{"support": "alwaysOn" | "toggleable" | "unavailable", "effort"?: "high", +/// "effortLevels": ["low", …, "max"]}` (verified against 2.24.1). `support` +/// describes thinking, not effort, so only `effortLevels` is read; `effort` is +/// absent until one is set in the session. Backend-internal. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct KiroReasoning { + /// Levels the CURRENT model accepts; empty when it takes none. + pub levels: Vec, + /// The level the session runs at, when Kiro reports one. + pub current: Option, + /// Whether the current model's thinking can be switched on and off + /// (`support: "toggleable"`; `alwaysOn` and `unavailable` models cannot). + pub thinking_toggleable: bool, + /// Whether thinking is on (`thinkingEnabled`), when Kiro reports it — only + /// once it has been set, in the session or as the model's saved default. + pub thinking: Option, +} + /// Read-only snapshot of the modes + config_options an agent advertises /// when it opens a new session. Used by `ConnectionManager::probe_agent_options` /// to give the delegation settings UI an authoritative view of what an @@ -1444,6 +1463,16 @@ pub struct AcpAgentInfo { /// "the adapter isn't installed" instead of "the agent isn't", without /// hardcoding a second copy of the agent list frontend-side. pub is_acp_adapter: bool, + /// The vendor's install page for an agent codeg does not install itself + /// (`AgentDistribution::System`, i.e. `distribution_type == "system"`): + /// the Settings page links it where other agents get an Install button. + /// `None` for every agent codeg installs. + #[serde(skip_serializing_if = "Option::is_none")] + pub install_url: Option, + /// Whether that install can be upgraded from the Settings page through the + /// vendor's own updater (`acp_update_system_agent`). Always `false` for + /// agents codeg installs itself — they upgrade through their own actions. + pub supports_self_update: bool, /// For custom agents, where the definition came from (`registry` | /// `manual`); `None` for built-ins. A manual definition's /// `registry_version` is user-typed, so the version-status display shows diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 8b2ea06c6a..38bb92d1ae 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -581,6 +581,18 @@ fn is_npm_command_candidate(path: &Path) -> bool { .unwrap_or(false) } +/// The refusal every install / upgrade / uninstall entry point returns for a +/// [`registry::AgentDistribution::System`] agent. The Settings page never +/// offers those actions for one (it has no version card to hang them on), so +/// this is the backstop for a direct API call — and it names where the user +/// CAN manage the install instead of a bare "unsupported". +fn system_agent_is_user_managed(name: &str, install_url: &str) -> AcpError { + AcpError::protocol(format!( + "{name} is installed and updated by its own installer ({install_url}); \ + codeg launches that install and does not install, upgrade or remove it" + )) +} + /// Verify that the agent SDK / binary is installed and usable. /// /// This is the pre-spawn guard used by the session-page connect path: @@ -646,6 +658,19 @@ pub(crate) async fn verify_agent_installed(agent_type: AgentType) -> Result<(), ))) } } + registry::AgentDistribution::System { cmd, .. } => { + // The same resolution `build_agent` launches with; there is no + // cache to consult. INVARIANT: see note above — "is not installed" + // is a stable substring the frontend matches against. + if resolve_system_agent_binary_for(agent_type, cmd).is_some() { + Ok(()) + } else { + Err(AcpError::SdkNotInstalled(format!( + "{} is not installed. Please install it in Agent Settings.", + meta.name + ))) + } + } } } @@ -745,6 +770,10 @@ async fn detect_local_version(agent_type: AgentType) -> Option { registry::AgentDistribution::Uvx { cmd, system_cmd, .. } => uvx_displayed_version(agent_type, cmd, system_cmd).await, + registry::AgentDistribution::System { cmd, .. } => { + let bin = resolve_system_agent_binary_for(agent_type, cmd)?; + system_probed_version(agent_type, &bin, None).await + } } } @@ -1116,6 +1145,14 @@ async fn collect_agent_diag( .unwrap_or_else(|| format!("{cmd} (system CLI on PATH)")) }); } + registry::AgentDistribution::System { cmd, .. } => { + diag.cmd = cmd.to_string(); + diag.distribution = "system"; + // Mirrors `verify_agent_installed`: the system resolution is the + // whole gate. + diag.launchable = resolve_system_agent_binary_for(agent_type, cmd) + .map(|p| p.to_string_lossy().to_string()); + } } diag @@ -1433,6 +1470,7 @@ fn build_report( let launch_label = match a.distribution { "npx" => format!("{} (resolve_npx_command)", a.cmd), "binary" => format!("{} (cached / system binary)", a.cmd), + "system" => format!("{} (system CLI: PATH / ~/.local/bin)", a.cmd), _ => format!("{} (uvx launchable)", a.cmd), }; let mut checks = vec![diag_check( @@ -8545,6 +8583,15 @@ pub(crate) fn skill_storage_spec(agent_type: AgentType) -> Option/skills/*/SKILL.md` (global, `~/.kiro` by + // default) — bundles only, and no shared `.agents/skills` store. + AgentType::Kiro => Some(SkillStorageSpec { + kind: SkillStorageKind::SkillDirectoryOnly, + global_dirs: vec![crate::parsers::kiro::resolve_kiro_home_dir().join("skills")], + project_rel_dirs: vec![".kiro/skills"], + }), // codeg cannot detect where an arbitrary ACP agent loads skills from, // so custom agents are gated on the user's own declaration: that the // agent reads the shared `.agents/skills` store (the cross-agent @@ -9741,6 +9788,13 @@ fn agent_env_keys(agent_type: AgentType) -> (&'static str, &'static str, &'stati // keeps the generic cascade off the `OPENAI_*` keys. The LLM endpoint // travels in `providers.json` instead (`persist_cline_local_config`). AgentType::Cline => ("CLINE_BASE_URL", "CLINE_API_KEY", "CLINE_MODEL"), + // Kiro's non-interactive credential is `KIRO_API_KEY` — the one the + // CLI's headless mode documents, and the only sign-in a server/Docker + // install without a browser can use. There is no endpoint or model + // env: the model is chosen per session over ACP (`session/new` + // answers with `models`). The two placeholders keep the generic + // cascade off the OPENAI_* keys, like `QODER_BASE_URL` above. + AgentType::Kiro => ("KIRO_BASE_URL", "KIRO_API_KEY", "KIRO_MODEL"), _ => ("OPENAI_BASE_URL", "OPENAI_API_KEY", "OPENAI_MODEL"), } } @@ -10275,6 +10329,11 @@ fn cascade_update_agent_config( // METHOD, never a credential, so there is nothing here to // reconcile either. } + AgentType::Kiro => { + // Kiro signs in to its own service (`kiro-cli login`, or + // `KIRO_API_KEY` in the agent's env) and accepts no custom base + // URL, so it stays off the model-provider credential cascade. + } AgentType::Custom(_) => { // Custom agents are deliberately configuration-free: codeg writes // no config file for them and they are excluded from the @@ -11054,6 +11113,16 @@ pub(crate) async fn acp_get_agent_status_core( let version = uvx_displayed_version(agent_type, cmd, *system_cmd).await; (uvx_agent_launchable(*system_cmd), version) } + // A system agent runs on every platform its vendor ships for; whether + // THIS machine has it is `installed_version`, probed from the one + // install there can be. + registry::AgentDistribution::System { cmd, .. } => { + let version = match resolve_system_agent_binary_for(agent_type, cmd) { + Some(bin) => system_probed_version(agent_type, &bin, None).await, + None => None, + }; + (true, version) + } }; Ok(crate::acp::types::AcpAgentStatus { @@ -11153,6 +11222,14 @@ pub(crate) async fn acp_list_agents_core(db: &AppDatabase) -> Result { + let version = match resolve_system_agent_binary_for(agent_type, cmd) { + Some(bin) => system_probed_version(agent_type, &bin, None).await, + None => None, + }; + (true, "system", version) + } }; let mut env = setting @@ -11184,8 +11261,10 @@ pub(crate) async fn acp_list_agents_core(db: &AppDatabase) -> Result Result { + Some((*install_url).to_string()) + } + _ => None, + }, + supports_self_update: matches!( + meta.distribution, + registry::AgentDistribution::System { + update_args: Some(_), + .. + } + ), custom_source: agent_type .custom_id() .and_then(crate::acp::custom_registry::source_of) @@ -12492,6 +12584,9 @@ pub(crate) async fn acp_download_agent_binary_core( registry::AgentDistribution::Uvx { .. } => Err(AcpError::protocol( "download is only supported for binary agents", )), + registry::AgentDistribution::System { install_url, .. } => { + Err(system_agent_is_user_managed(meta.name, install_url)) + } }; match &result { @@ -12527,6 +12622,173 @@ pub async fn acp_download_agent_binary( acp_download_agent_binary_core(agent_type, version, task_id, &emitter).await } +/// Upper bound on a vendor updater run. Generous — it downloads a full build +/// (Kiro's are 150–360 MB) — but bounded, so a wedged updater cannot hold the +/// Settings page's package lock forever. +const SYSTEM_UPDATE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15 * 60); + +/// Run `program args…`, streaming every stdout/stderr line as an install-log +/// event. Returns whether it exited successfully, plus its stderr for the error +/// message. Killed (and reported as a failure) past `timeout`. +async fn run_streaming_command( + program: &std::path::Path, + args: &[&str], + task_id: &str, + emitter: &EventEmitter, + timeout: std::time::Duration, +) -> Result<(bool, String), AcpError> { + use tokio::io::BufReader; + + let mut cmd = crate::process::tokio_command(program); + cmd.args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + let mut child = cmd + .spawn() + .map_err(|e| AcpError::protocol(format!("failed to spawn {}: {e}", program.display())))?; + + let pump = |reader: Option>| { + let emitter = emitter.clone(); + let task_id = task_id.to_string(); + tokio::spawn(async move { + match reader { + Some(reader) => { + crate::process::collect_lines_lossy(BufReader::new(reader), |line| { + emit_agent_install_event(&emitter, &task_id, AgentInstallEventKind::Log, line); + }) + .await + } + None => String::new(), + } + }) + }; + let stdout = pump(child.stdout.take().map(|s| Box::new(s) as _)); + let stderr = pump(child.stderr.take().map(|s| Box::new(s) as _)); + + let run = async { + let (_, stderr) = tokio::join!(stdout, stderr); + let status = child.wait().await; + (status, stderr.unwrap_or_default()) + }; + match tokio::time::timeout(timeout, run).await { + Ok((Ok(status), stderr)) => Ok((status.success(), stderr)), + Ok((Err(e), _)) => Err(AcpError::protocol(format!( + "failed to wait for {}: {e}", + program.display() + ))), + Err(_) => Err(AcpError::protocol(format!( + "{} did not finish within {} minutes", + program.display(), + timeout.as_secs() / 60 + ))), + } +} + +/// Upgrade a [`registry::AgentDistribution::System`] agent in place through +/// the vendor's own updater (`update_args` — `kiro-cli update +/// --non-interactive` for Kiro), streaming its output on the shared +/// agent-install topic like every other install action. The install stays the +/// user's own: the same copy their terminal runs is the one updated, and codeg +/// still launches whatever `` resolves to afterwards. +/// +/// An updater that finds nothing newer also succeeds; the re-probed version is +/// what tells the caller whether anything changed. Sessions already running +/// keep the build they started with until they reconnect. +pub(crate) async fn acp_update_system_agent_core( + agent_type: AgentType, + task_id: String, + db: &AppDatabase, + emitter: &EventEmitter, +) -> Result, AcpError> { + emit_agent_install_event(emitter, &task_id, AgentInstallEventKind::Started, ""); + let meta = registry::get_agent_meta(agent_type); + + let result: Result, AcpError> = async { + let registry::AgentDistribution::System { + cmd, + update_args, + install_url, + .. + } = meta.distribution + else { + return Err(AcpError::protocol(format!( + "{} is not updated through its own CLI", + meta.name + ))); + }; + let Some(update_args) = update_args else { + return Err(system_agent_is_user_managed(meta.name, install_url)); + }; + // INVARIANT: "is not installed" is the stable substring the frontend + // matches to surface its install prompt (see `verify_agent_installed`). + let bin = resolve_system_agent_binary_for(agent_type, cmd).ok_or_else(|| { + AcpError::SdkNotInstalled(format!( + "{} is not installed. Please install it in Agent Settings.", + meta.name + )) + })?; + emit_agent_install_event( + emitter, + &task_id, + AgentInstallEventKind::Log, + format!("$ {} {}", bin.display(), update_args.join(" ")), + ); + let (success, stderr) = + run_streaming_command(&bin, update_args, &task_id, emitter, SYSTEM_UPDATE_TIMEOUT) + .await?; + if !success { + let detail = stderr.trim(); + return Err(AcpError::protocol(if detail.is_empty() { + format!("{} update failed", meta.name) + } else { + format!("{} update failed: {detail}", meta.name) + })); + } + // The probe cache is keyed by the binary's mtime, so an update that + // replaced it is re-probed rather than served the old answer. + let version = system_probed_version(agent_type, &bin, None).await; + agent_setting_service::set_installed_version(&db.conn, agent_type, version.clone()) + .await + .map_err(|e| AcpError::protocol(e.to_string()))?; + emit_acp_agents_updated(emitter, "system_agent_updated", Some(agent_type)); + Ok(version) + } + .await; + + match &result { + Ok(version) => emit_agent_install_event( + emitter, + &task_id, + AgentInstallEventKind::Completed, + match version { + Some(v) => format!("{} {v}", meta.name), + None => meta.name.to_string(), + }, + ), + Err(e) => emit_agent_install_event( + emitter, + &task_id, + AgentInstallEventKind::Failed, + e.to_string(), + ), + } + result +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn acp_update_system_agent( + agent_type: AgentType, + task_id: String, + db: State<'_, AppDatabase>, + app: tauri::AppHandle, +) -> Result, AcpError> { + let emitter = EventEmitter::Tauri(app); + acp_update_system_agent_core(agent_type, task_id, &db, &emitter).await +} + /// Provision ONLY the uv toolchain (uvx) into codeg's cache — independent of /// installing any `Uvx` agent's package. Streams progress over the shared /// agent-install event stream so the Settings page shows a live log. Backs the @@ -12625,10 +12887,11 @@ pub(crate) async fn acp_detect_agent_local_version_core( // phantom that can no longer be launched. The returned value does NOT depend // on the mirror write below, so a swallowed write cannot reintroduce the // phantom. (NPX detection runs `npm list`, which can fail transiently, so - // for npx we keep the DB value as a best-effort fallback.) + // for npx we keep the DB value as a best-effort fallback.) A system agent + // is the same case: its one install either resolves or does not exist. if matches!( registry::get_agent_meta(agent_type).distribution, - registry::AgentDistribution::Binary { .. } + registry::AgentDistribution::Binary { .. } | registry::AgentDistribution::System { .. } ) { let _ = agent_setting_service::set_installed_version(conn, agent_type, None).await; // Mirror the heal in the clearing direction: a binary that vanished from @@ -12844,6 +13107,9 @@ pub(crate) async fn acp_prepare_npx_agent_core( registry::AgentDistribution::Binary { .. } => Err(AcpError::protocol( "prepare is only supported for npx agents", )), + registry::AgentDistribution::System { install_url, .. } => { + Err(system_agent_is_user_managed(meta.name, install_url)) + } registry::AgentDistribution::Uvx { package, cmd, @@ -12968,6 +13234,13 @@ pub(crate) async fn acp_uninstall_agent_core( registry::AgentDistribution::Uvx { .. } => { binary_cache::clear_uvx_agent_prepared(agent_type)?; } + // Removing the user's own install is their installer's job; codeg + // owns nothing on disk for this agent. Refusing (rather than + // "succeeding" at nothing) also keeps the DB version below intact, + // so the agent does not flip to "not installed" while it still is. + registry::AgentDistribution::System { install_url, .. } => { + return Err(system_agent_is_user_managed(meta.name, install_url)); + } } agent_setting_service::set_installed_version(&db.conn, agent_type, None) @@ -13721,6 +13994,66 @@ mod tests { bin } + #[cfg(unix)] + #[tokio::test] + async fn streaming_command_reports_exit_status_and_stderr() { + let sh = std::path::Path::new("/bin/sh"); + let (ok, stderr) = run_streaming_command( + sh, + &["-c", "echo out; echo boom 1>&2; exit 3"], + "t", + &EventEmitter::Noop, + std::time::Duration::from_secs(10), + ) + .await + .expect("spawned"); + assert!(!ok); + assert!(stderr.contains("boom"), "{stderr:?}"); + + let (ok, _) = run_streaming_command( + sh, + &["-c", "true"], + "t", + &EventEmitter::Noop, + std::time::Duration::from_secs(10), + ) + .await + .expect("spawned"); + assert!(ok); + } + + // A wedged updater must not hold the Settings page's package lock forever. + #[cfg(unix)] + #[tokio::test] + async fn streaming_command_gives_up_past_its_timeout() { + let err = run_streaming_command( + std::path::Path::new("/bin/sh"), + &["-c", "sleep 5"], + "t", + &EventEmitter::Noop, + std::time::Duration::from_millis(200), + ) + .await + .expect_err("timed out"); + assert!(err.to_string().contains("did not finish"), "{err}"); + } + + // Only a system agent with a self-updater can be upgraded this way; every + // agent codeg installs itself upgrades through its own actions instead. + #[tokio::test] + async fn only_self_updating_system_agents_take_the_system_update() { + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let err = acp_update_system_agent_core( + AgentType::Codex, + "t".to_string(), + &db, + &EventEmitter::Noop, + ) + .await + .expect_err("codex is not a system agent"); + assert!(err.to_string().contains("not updated through its own CLI"), "{err}"); + } + #[cfg(unix)] #[tokio::test] async fn system_probed_version_reads_a_system_cli_via_the_version_convention() { diff --git a/src-tauri/src/commands/experts.rs b/src-tauri/src/commands/experts.rs index 5d322de0aa..e5114e9519 100644 --- a/src-tauri/src/commands/experts.rs +++ b/src-tauri/src/commands/experts.rs @@ -806,6 +806,7 @@ fn supported_agents() -> Vec { AgentType::DeepSeek, AgentType::Qoder, AgentType::Antigravity, + AgentType::Kiro, ]; // Custom agents that declared the shared skills store join the built-in // set — the same `skill_storage_spec` gate every skills surface uses, so diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 37c933cd55..5451605605 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -71,6 +71,8 @@ pub enum McpAppType { Qoder, /// Serializes as `antigravity`, matching `AgentType::as_wire`. Antigravity, + /// Serializes as `kiro`, matching `AgentType::as_wire`. + Kiro, /// Serializes as `pi`, matching `AgentType::as_wire`. Scan-only: codeg /// reads and round-trips the pi MCP EXTENSION's config, but pi is not an /// assignable target and gets no MCP over the ACP wire. See the pi section @@ -88,7 +90,7 @@ pub enum McpAppType { /// server come back on the next refresh. Both used to keep their own hand-typed /// copy of this list; one shared constant plus [`tests::all_mcp_apps_is_exhaustive`] /// (which fails to compile when a variant is added) is what keeps them honest. -const ALL_MCP_APPS: [McpAppType; 15] = [ +const ALL_MCP_APPS: [McpAppType; 16] = [ McpAppType::ClaudeCode, McpAppType::Codex, McpAppType::Gemini, @@ -103,6 +105,7 @@ const ALL_MCP_APPS: [McpAppType; 15] = [ McpAppType::DeepSeek, McpAppType::Qoder, McpAppType::Antigravity, + McpAppType::Kiro, McpAppType::Pi, ]; @@ -575,11 +578,13 @@ fn normalize_apps(apps: Vec) -> Vec { /// config.toml has only stdio and streamable-HTTP transports, so it cannot host an /// SSE server — writing one would persist a url-only entry that Codex loads as HTTP /// and codeg then reads back as `http`, silently reclassifying the shared canonical -/// spec. Write paths preflight-exclude such (app, spec) pairs instead of writing a -/// misrepresented entry or aborting the whole multi-agent operation. See issue #325. +/// spec. Kiro is the same case (its config knows `stdio`/`http`/`registry` and its +/// client speaks only streamable HTTP). Write paths preflight-exclude such (app, +/// spec) pairs instead of writing a misrepresented entry or aborting the whole +/// multi-agent operation. See issue #325. fn app_can_host_spec(app: McpAppType, canonical_spec: &Value) -> bool { let is_sse = canonical_spec.get("type").and_then(Value::as_str) == Some("sse"); - !(matches!(app, McpAppType::Codex | McpAppType::DeepSeek) && is_sse) + !(matches!(app, McpAppType::Codex | McpAppType::DeepSeek | McpAppType::Kiro) && is_sse) } #[derive(Debug, Clone)] @@ -3074,7 +3079,7 @@ impl LocalMcpReader { } } -fn local_mcp_readers() -> [LocalMcpReader; 15] { +fn local_mcp_readers() -> [LocalMcpReader; 16] { [ LocalMcpReader::new("Claude Code", McpAppType::ClaudeCode, read_claude_servers), LocalMcpReader::new("Codex", McpAppType::Codex, read_codex_servers), @@ -3094,6 +3099,7 @@ fn local_mcp_readers() -> [LocalMcpReader; 15] { read_antigravity_servers, ), LocalMcpReader::new("Qoder", McpAppType::Qoder, read_qoder_servers), + LocalMcpReader::new("Kiro CLI", McpAppType::Kiro, read_kiro_servers), LocalMcpReader::new("pi", McpAppType::Pi, read_pi_servers), ] } @@ -3169,6 +3175,11 @@ fn scan_local_servers_from_readers(readers: &[LocalMcpReader]) -> LocalMcpScan { let owner_values = openclaw_declares.remove(&id).unwrap_or_default(); merge_kimi_extension_fields(&mut entry.0, &spec, &owner_values); } + if reader.app == McpAppType::Kiro { + // Same first-writer-wins hazard for Kiro's own fields + // (`disabled`, `autoApprove`, …). See `merge_kiro_extension_fields`. + merge_kiro_extension_fields(&mut entry.0, &spec, &entry.1); + } entry.1.insert(reader.app); } } @@ -3238,6 +3249,7 @@ fn upsert_server_for_app(app: McpAppType, id: &str, spec: &Value) -> Result<(), McpAppType::DeepSeek => upsert_deepseek_server(id, spec), McpAppType::Qoder => upsert_qoder_server(id, spec), McpAppType::Antigravity => upsert_antigravity_server(id, spec), + McpAppType::Kiro => upsert_kiro_server(id, spec), McpAppType::Pi => upsert_pi_server(id, spec), } } @@ -3276,6 +3288,9 @@ pub fn read_servers_for_agent_type( // itself at session setup — see the Antigravity section above for why // it rides the forward skip list rather than the wire. AgentType::Antigravity => read_antigravity_servers(), + // Kiro loads `/settings/mcp.json` natively — see the Kiro + // section below for why it rides the forward skip list. + AgentType::Kiro => read_kiro_servers(), // Custom agents get MCP purely over the ACP wire (`session/new`'s // `mcpServers`); codeg deliberately knows nothing about their native // config files, so there is no per-agent store to read back here. @@ -4073,6 +4088,230 @@ fn remove_cursor_server_at(path: &Path, id: &str) -> Result/settings/mcp.json → top-level `mcpServers`) +// +// Kiro reads its user-global MCP config from `/settings/mcp.json` +// (default `~/.kiro/settings/mcp.json`; a workspace `.kiro/settings/mcp.json` +// and the agent config's own `mcpServers` layer over it by name). Entries are +// Claude-shaped — `command`/`args`/`env` for stdio, `url`/`headers` for remote +// — with an optional `type` that the 2.24.1 schema documents as `"stdio"`, +// `"http"` or `"registry"`. Its client is rmcp's streamable-HTTP one: there +// is no SSE transport to write, so an SSE server is preflight-excluded like it +// is for Codex (`app_can_host_spec`), and a `registry` entry — a name looked up +// in an MCP registry, not a server spec — is skipped by the reader because +// codeg cannot represent it. +// +// Kiro also models per-server fields no canonical spec carries: `disabled`, +// `autoApprove`, `disabledTools`, `timeout` (ms), and for remote servers +// `oauth` / `oauthScopes`. The writer emits the canonical fields plus those +// (validated to the shape Kiro expects), and the scan folds Kiro's own copies +// back into a server another agent claimed first +// (`merge_kiro_extension_fields`), so editing a server in codeg never silently +// re-enables it or drops its approvals. +// +// Because Kiro loads this file natively at session start, `Kiro` is on the +// ACP forward skip list in `connection.rs`; the built-in `codeg-mcp` companion +// is injected separately by `inject_codeg_mcp`, so it still reaches Kiro. +// --------------------------------------------------------------------------- + +fn kiro_mcp_json_path() -> PathBuf { + crate::parsers::kiro::resolve_kiro_home_dir() + .join("settings") + .join("mcp.json") +} + +fn read_kiro_servers() -> Result, AppCommandError> { + read_kiro_servers_at(&kiro_mcp_json_path()) +} + +fn read_kiro_servers_at(path: &Path) -> Result, AppCommandError> { + let root = read_json_file(path)?; + let mut out = BTreeMap::new(); + + let Some(servers) = root.get("mcpServers").and_then(Value::as_object) else { + return Ok(out); + }; + + for (id, spec) in servers { + match canonicalize_spec(spec, &format!("Kiro config '{id}'")) { + Ok(normalized) => { + out.insert(id.to_string(), normalized); + } + Err(err) => { + tracing::warn!("[MCP] skip invalid Kiro MCP entry id={id}: {err}"); + } + } + } + + Ok(out) +} + +/// A Kiro-only field, validated to the shape Kiro's `CustomToolConfig` takes. +/// +/// A wrong-typed value is dropped rather than written: Kiro deserializes the +/// whole file, so one malformed entry would cost the user every server in it. +/// `oauth` / `oauthScopes` exist only on the remote branch. +fn is_kiro_extension_field(key: &str, value: &Value, stdio: bool) -> bool { + let is_string_array = + |v: &Value| v.as_array().is_some_and(|items| items.iter().all(Value::is_string)); + match key { + "disabled" => value.is_boolean(), + "autoApprove" | "disabledTools" => is_string_array(value), + "timeout" => value.as_u64().is_some(), + "oauth" => !stdio && value.is_object(), + "oauthScopes" => !stdio && is_string_array(value), + _ => false, + } +} + +/// The per-server fields Kiro models beyond the canonical spec. +const KIRO_EXTENSION_KEYS: &[&str] = &[ + "disabled", + "autoApprove", + "disabledTools", + "timeout", + "oauth", + "oauthScopes", +]; + +/// Whether an agent OTHER than Kiro reads `key` from its own MCP config, so +/// its copy is a genuine setting rather than an echo of Kiro's that codeg +/// once wrote there (canonical writers pass unknown keys through). Cline's +/// `mcpServers` entries carry `disabled` / `autoApprove` / `timeout`, Gemini's +/// carry `timeout`, and Kimi's carry `disabledTools`. +fn kiro_key_native_elsewhere(key: &str, holders: &BTreeSet) -> bool { + match key { + "disabled" | "autoApprove" => holders.contains(&McpAppType::Cline), + "timeout" => { + holders.contains(&McpAppType::Cline) || holders.contains(&McpAppType::Gemini) + } + "disabledTools" => holders.contains(&McpAppType::KimiCode), + _ => false, + } +} + +/// Resolve Kiro's extension fields on the spec the scan resolved a server to. +/// +/// The scan is first-writer-wins, so a server another agent also holds +/// surfaces as THAT agent's copy. Its value for a Kiro field is either the +/// other agent's own setting — when that agent reads the field natively (see +/// [`kiro_key_native_elsewhere`]), in which case it stands — or a stale echo +/// of an earlier save, in which case Kiro's copy is authoritative, its +/// ABSENCE included. Without that, a change the user made in Kiro's own file +/// would be masked by the echo and written back over on the next save. +fn merge_kiro_extension_fields( + resolved: &mut Value, + kiro_spec: &Value, + earlier_holders: &BTreeSet, +) { + let stdio = resolved.get("type").and_then(Value::as_str) == Some("stdio"); + let Some(target) = resolved.as_object_mut() else { + return; + }; + let source = kiro_spec.as_object(); + for key in KIRO_EXTENSION_KEYS { + let kiro_value = source + .and_then(|obj| obj.get(*key)) + .filter(|value| is_kiro_extension_field(key, value, stdio)); + if kiro_key_native_elsewhere(key, earlier_holders) { + if let Some(value) = kiro_value { + target + .entry((*key).to_string()) + .or_insert_with(|| value.clone()); + } + continue; + } + match kiro_value { + Some(value) => { + target.insert((*key).to_string(), value.clone()); + } + None => { + target.remove(*key); + } + } + } +} + +/// Convert codeg's canonical spec into a Kiro `mcpServers` entry: the shape +/// Kiro's docs write (no `type`; Kiro infers stdio from `command` and http from +/// `url`), plus the Kiro fields that pass [`is_kiro_extension_field`]. +/// Anything else — `cwd`, which Kiro does not model, and whatever foreign keys +/// canonicalize passed through from another agent's file — stays off disk. +fn canonical_to_kiro_entry(spec: &Value) -> Result { + let canonical = canonicalize_spec(spec, "Kiro write")?; + let Some(obj) = canonical.as_object() else { + return Ok(canonical); + }; + let stdio = obj.get("type").and_then(Value::as_str) == Some("stdio"); + let mut out = Map::new(); + for (key, value) in obj { + let base = if stdio { + matches!(key.as_str(), "command" | "args" | "env") + } else { + matches!(key.as_str(), "url" | "headers") + }; + if base || is_kiro_extension_field(key, value, stdio) { + out.insert(key.clone(), value.clone()); + } + } + Ok(Value::Object(out)) +} + +fn upsert_kiro_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { + upsert_kiro_server_at(&kiro_mcp_json_path(), id, spec) +} + +fn upsert_kiro_server_at(path: &Path, id: &str, spec: &Value) -> Result<(), AppCommandError> { + let mut root = read_json_file(path)?; + if !root.is_object() { + root = json!({}); + } + + let entry = canonical_to_kiro_entry(spec)?; + + let obj = root.as_object_mut().ok_or_else(|| { + mcp_configuration_invalid(format!("invalid JSON root in {}", path.display())) + })?; + if !obj.get("mcpServers").map(Value::is_object).unwrap_or(false) { + obj.insert("mcpServers".to_string(), Value::Object(Map::new())); + } + + let map = obj + .get_mut("mcpServers") + .and_then(Value::as_object_mut) + .ok_or_else(|| { + mcp_configuration_invalid(format!("invalid mcpServers in {}", path.display())) + })?; + map.insert(id.to_string(), entry); + + write_json_file(path, &root) +} + +fn remove_kiro_server(id: &str) -> Result { + remove_kiro_server_at(&kiro_mcp_json_path(), id) +} + +fn remove_kiro_server_at(path: &Path, id: &str) -> Result { + if !path.exists() { + return Ok(false); + } + + let mut root = read_json_file(path)?; + let Some(obj) = root.as_object_mut() else { + return Ok(false); + }; + let Some(servers) = obj.get_mut("mcpServers").and_then(Value::as_object_mut) else { + return Ok(false); + }; + + let removed = servers.remove(id).is_some(); + if removed { + write_json_file(path, &root)?; + } + Ok(removed) +} + // --------------------------------------------------------------------------- // Hermes Agent (~/.hermes/config.yaml → mcp_servers) // @@ -4342,6 +4581,7 @@ fn remove_server_for_app(app: McpAppType, id: &str) -> Result remove_deepseek_server(id), McpAppType::Qoder => remove_qoder_server(id), McpAppType::Antigravity => remove_antigravity_server(id), + McpAppType::Kiro => remove_kiro_server(id), McpAppType::Pi => remove_pi_server(id), } } @@ -7148,6 +7388,97 @@ mod tests { assert_eq!(root.pointer("/model/name"), Some(&json!("qmodel_38max"))); } + // Kiro's `settings/mcp.json`: Claude-shaped entries without a `type`, + // plus Kiro-only fields. A round trip through codeg must keep the user's + // `disabled` / `autoApprove` / `timeout`, drop what Kiro does not model + // (`cwd`), and leave the file's other servers alone. + #[test] + fn kiro_mcp_json_round_trips_its_own_fields() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings").join("mcp.json"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + json!({"mcpServers": { + "fetch": { + "command": "uvx", + "args": ["mcp-server-fetch"], + "env": {"LOG": "1"}, + "disabled": true, + "autoApprove": ["fetch"], + "timeout": 120000 + }, + "remote": {"url": "https://h/mcp", "headers": {"A": "b"}, "oauthScopes": ["s"]}, + "reg": {"type": "registry"} + }}) + .to_string(), + ) + .unwrap(); + + let servers = read_kiro_servers_at(&path).unwrap(); + // A registry reference names no server codeg could represent. + assert_eq!(servers.keys().collect::>(), ["fetch", "remote"]); + assert_eq!(servers["fetch"]["type"], json!("stdio")); + assert_eq!(servers["remote"]["type"], json!("http")); + + // Re-save `fetch` with a foreign field and a wrong-typed Kiro one. + let mut edited = servers["fetch"].clone(); + edited["cwd"] = json!("/somewhere"); + edited["timeout"] = json!("soon"); + upsert_kiro_server_at(&path, "fetch", &edited).unwrap(); + let root: Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + let fetch = &root["mcpServers"]["fetch"]; + assert_eq!(fetch["command"], json!("uvx")); + assert_eq!(fetch["disabled"], json!(true)); + assert_eq!(fetch["autoApprove"], json!(["fetch"])); + assert!(fetch.get("type").is_none(), "Kiro infers the transport"); + assert!(fetch.get("cwd").is_none(), "Kiro does not model cwd"); + assert!(fetch.get("timeout").is_none(), "wrong-typed field dropped"); + // Untouched neighbours, including the one codeg cannot read. + assert_eq!(root["mcpServers"]["reg"], json!({"type": "registry"})); + assert_eq!(root["mcpServers"]["remote"]["oauthScopes"], json!(["s"])); + + assert!(remove_kiro_server_at(&path, "fetch").unwrap()); + assert!(!remove_kiro_server_at(&path, "fetch").unwrap()); + } + + // Kiro's client speaks streamable HTTP only, so an SSE server is excluded + // from it the same way it is from Codex. + #[test] + fn kiro_cannot_host_sse() { + let sse = json!({"type": "sse", "url": "https://h/sse"}); + let http = json!({"type": "http", "url": "https://h/mcp"}); + assert!(!app_can_host_spec(McpAppType::Kiro, &sse)); + assert!(app_can_host_spec(McpAppType::Kiro, &http)); + } + + // First-writer-wins must not strip Kiro's own fields from a server that + // another agent also holds, nor let a stale echo in that agent's file + // mask what Kiro's own file says — unless that agent reads the field + // itself, in which case its value is a real setting and stands. + #[test] + fn kiro_extension_fields_resolve_by_ownership() { + let claude_only: BTreeSet = [McpAppType::ClaudeCode].into(); + let with_cline: BTreeSet = [McpAppType::Cline].into(); + let kiro = json!({"type": "stdio", "command": "x", "disabled": false, "autoApprove": ["t"], "oauth": {}}); + + // Claude does not read `disabled`: its `true` is an echo; Kiro wins, + // and a field Kiro no longer has (`timeout`) goes too. + let mut resolved = json!({"type": "stdio", "command": "x", "disabled": true, "timeout": 5}); + merge_kiro_extension_fields(&mut resolved, &kiro, &claude_only); + assert_eq!(resolved["disabled"], json!(false)); + assert_eq!(resolved["autoApprove"], json!(["t"])); + assert!(resolved.get("timeout").is_none()); + assert!(resolved.get("oauth").is_none(), "oauth is remote-only"); + + // Cline reads `disabled` natively: its value stands; Kiro only fills gaps. + let mut resolved = json!({"type": "stdio", "command": "x", "disabled": true}); + merge_kiro_extension_fields(&mut resolved, &kiro, &with_cline); + assert_eq!(resolved["disabled"], json!(true)); + assert_eq!(resolved["autoApprove"], json!(["t"])); + } + #[test] fn all_mcp_apps_is_exhaustive() { // `ALL_MCP_APPS` drives BOTH write paths' "and no others" semantics: @@ -7178,6 +7509,7 @@ mod tests { | McpAppType::DeepSeek | McpAppType::Qoder | McpAppType::Antigravity + | McpAppType::Kiro | McpAppType::Pi => {} } } @@ -7232,6 +7564,7 @@ mod tests { (McpAppType::DeepSeek, AgentType::DeepSeek), (McpAppType::Qoder, AgentType::Qoder), (McpAppType::Antigravity, AgentType::Antigravity), + (McpAppType::Kiro, AgentType::Kiro), (McpAppType::Pi, AgentType::Pi), ] { let wire = serde_json::to_value(app).expect("serialize app type"); diff --git a/src-tauri/src/commands/office_tools.rs b/src-tauri/src/commands/office_tools.rs index fdcd08b5ed..0fac7bd5db 100644 --- a/src-tauri/src/commands/office_tools.rs +++ b/src-tauri/src/commands/office_tools.rs @@ -1063,6 +1063,7 @@ fn supported_agents() -> Vec { AgentType::DeepSeek, AgentType::Qoder, AgentType::Antigravity, + AgentType::Kiro, ]; // Custom agents that declared the shared skills store join the built-in // set — the same `skill_storage_spec` gate every skills surface uses, so diff --git a/src-tauri/src/commands/science.rs b/src-tauri/src/commands/science.rs index 5d76e12e97..8115fa8ec7 100644 --- a/src-tauri/src/commands/science.rs +++ b/src-tauri/src/commands/science.rs @@ -630,6 +630,7 @@ fn supported_agents() -> Vec { AgentType::DeepSeek, AgentType::Qoder, AgentType::Antigravity, + AgentType::Kiro, ]; // Custom agents that declared the shared skills store join the built-in // set — the same `skill_storage_spec` gate every skills surface uses, so @@ -1013,6 +1014,10 @@ mod tests { expected.contains(&AgentType::Antigravity), "Antigravity declares a skill store, so it is one of the columns" ); + assert!( + expected.contains(&AgentType::Kiro), + "Kiro declares a skill store, so it is one of the columns" + ); let skill_id = bundled_metadata() .first() diff --git a/src-tauri/src/db/migration/m20260927_000001_kiro_builtin_agent.rs b/src-tauri/src/db/migration/m20260927_000001_kiro_builtin_agent.rs new file mode 100644 index 0000000000..5c5dc5a8d2 --- /dev/null +++ b/src-tauri/src/db/migration/m20260927_000001_kiro_builtin_agent.rs @@ -0,0 +1,375 @@ +//! Retire a pre-integration `kiro-cli` CUSTOM agent into the built-in Kiro. +//! +//! Before Kiro CLI became a built-in, the only way to drive it was to register +//! it as a custom ACP agent — and the ACP registry's id for it, `kiro-cli`, is +//! exactly the registry id the built-in now claims. Custom-registry hydration +//! already refuses a definition that collides with a built-in id, so without +//! this migration such a user would see their custom agent vanish and every +//! conversation they had with it turn into an unregistered `custom:kiro-cli` +//! that can neither be reopened nor resumed. +//! +//! Both kinds of conversation point at the SAME session on disk: a custom +//! agent's `conversation.external_id` is the ACP session id, and `kiro-cli acp` +//! names its `~/.kiro/sessions/cli/.jsonl` after that id — so re-typing the +//! rows is all it takes for `parsers::kiro` to read their history. +//! +//! Every place an agent type is persisted is rewritten: +//! +//! * plain wire strings — `conversation.agent_type`, `opened_tab.agent_type`, +//! `folder.default_agent_type`, `automation.agent_type`, +//! `chat_channel_sender_context.current_agent_type`, `canvas_node.agent_type`; +//! * `agent_setting.agent_type`, which stores the JSON-QUOTED form +//! (`"custom:kiro-cli"`) — the enabled flag, order and launch env carry over; +//! * JSON configs that embed an agent type as a string value — +//! `work_task.config`, `work_task_settings.config`, +//! `work_task_template.config`, `automation.config`. +//! +//! The custom definition itself is then deleted. codeg's own ACP transcripts +//! of those sessions (`/acp-transcripts/kiro-cli/`) are left on disk: the +//! built-in reads Kiro's native log instead, and deleting user data is not a +//! migration's call. +//! +//! Collisions (possible only on a database that already holds built-in `kiro` +//! rows, i.e. one a pre-release build wrote to) resolve toward the built-in +//! without losing anything: a `custom:kiro-cli` conversation whose session is +//! already imported as `kiro` hands its tabs to that row and is soft-deleted +//! (`deleted_at`), and an existing `kiro` settings row keeps its own state but +//! inherits the custom row's launch env and model provider where it has none. +//! +//! Irreversible by design — `down` is a no-op: once a user has new built-in +//! Kiro conversations, nothing can tell them apart from migrated ones. + +use sea_orm::ConnectionTrait; +use sea_orm_migration::prelude::*; + +const CUSTOM_WIRE: &str = "custom:kiro-cli"; +const BUILTIN_WIRE: &str = "kiro"; +const REGISTRY_ID: &str = "kiro-cli"; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + for statement in statements() { + db.execute_unprepared(&statement).await?; + } + Ok(()) + } + + async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + Ok(()) + } +} + +/// The whole migration as SQL, in order. Every value is a compile-time +/// constant, so nothing here is user input. +fn statements() -> Vec { + let custom = CUSTOM_WIRE; + let builtin = BUILTIN_WIRE; + let quoted_custom = format!("\"{CUSTOM_WIRE}\""); + let quoted_builtin = format!("\"{BUILTIN_WIRE}\""); + // A custom conversation whose session already exists as a `kiro` row — + // re-typing it would trip the `(external_id, agent_type)` unique index. + let duplicate = format!( + "conversation.agent_type = '{custom}' AND conversation.external_id IS NOT NULL \ + AND EXISTS (SELECT 1 FROM conversation AS existing \ + WHERE existing.agent_type = '{builtin}' \ + AND existing.external_id = conversation.external_id)" + ); + let mut out = vec![ + // Its tabs move to the built-in row that holds the same session… + format!( + "UPDATE opened_tab SET conversation_id = ( \ + SELECT existing.id FROM conversation AS existing \ + JOIN conversation AS dup ON dup.external_id = existing.external_id \ + WHERE existing.agent_type = '{builtin}' AND dup.id = opened_tab.conversation_id) \ + WHERE conversation_id IN (SELECT id FROM conversation WHERE {duplicate})" + ), + // …and the duplicate is hidden rather than deleted. + format!( + "UPDATE conversation SET deleted_at = COALESCE(deleted_at, CURRENT_TIMESTAMP) \ + WHERE {duplicate}" + ), + // Every other custom conversation becomes a built-in one. + format!( + "UPDATE conversation SET agent_type = '{builtin}' \ + WHERE agent_type = '{custom}' \ + AND NOT EXISTS ( \ + SELECT 1 FROM conversation AS existing \ + WHERE existing.agent_type = '{builtin}' \ + AND existing.external_id IS conversation.external_id \ + AND conversation.external_id IS NOT NULL)" + ), + // Settings row (JSON-quoted key, unique): take over the custom row + // unless the built-in already has one. The probed version is cleared — + // the custom agent's `version_probe` output ("kiro-cli 2.24.1") is not + // what the built-in's probe writes, and the next list re-probes anyway. + format!( + "UPDATE agent_setting \ + SET agent_type = '{quoted_builtin}', installed_version = NULL \ + WHERE agent_type = '{quoted_custom}' \ + AND NOT EXISTS (SELECT 1 FROM agent_setting WHERE agent_type = '{quoted_builtin}')" + ), + // When a `kiro` row already existed it keeps its own enabled flag and + // order, but takes over the custom row's env and provider where it + // has none — those are what a user typed and cannot re-derive. + format!( + "UPDATE agent_setting SET \ + env_json = COALESCE(env_json, \ + (SELECT env_json FROM agent_setting WHERE agent_type = '{quoted_custom}')), \ + model_provider_id = COALESCE(model_provider_id, \ + (SELECT model_provider_id FROM agent_setting WHERE agent_type = '{quoted_custom}')) \ + WHERE agent_type = '{quoted_builtin}'" + ), + format!("DELETE FROM agent_setting WHERE agent_type = '{quoted_custom}'"), + ]; + for (table, column) in [ + ("opened_tab", "agent_type"), + ("folder", "default_agent_type"), + ("automation", "agent_type"), + ("chat_channel_sender_context", "current_agent_type"), + ("canvas_node", "agent_type"), + ] { + out.push(format!( + "UPDATE {table} SET {column} = '{builtin}' WHERE {column} = '{custom}'" + )); + } + // The quoted token is exact: it cannot match a longer id like + // `custom:kiro-cli-beta`, and JSON never escapes `:` or `-`. + for table in [ + "work_task", + "work_task_settings", + "work_task_template", + "automation", + ] { + out.push(format!( + "UPDATE {table} SET config = replace(config, '{quoted_custom}', '{quoted_builtin}') \ + WHERE instr(config, '{quoted_custom}') > 0" + )); + } + out.push(format!( + "DELETE FROM custom_agent WHERE registry_id = '{REGISTRY_ID}'" + )); + out +} + +#[cfg(test)] +mod tests { + use sea_orm::{ConnectionTrait, Database, DatabaseConnection, DbBackend, Statement}; + use sea_orm_migration::MigratorTrait; + + use crate::db::migration::Migrator; + + fn sql(s: &str) -> Statement { + Statement::from_string(DbBackend::Sqlite, s.to_owned()) + } + + async fn exec(conn: &DatabaseConnection, s: &str) { + conn.execute(sql(s)) + .await + .unwrap_or_else(|e| panic!("{s}: {e}")); + } + + async fn strings(conn: &DatabaseConnection, query: &str) -> Vec> { + conn.query_all(sql(query)) + .await + .expect("query") + .into_iter() + .map(|row| row.try_get_by_index::>(0).expect("column")) + .collect() + } + + /// Apply every migration before this one, seed the shapes a user who + /// registered `kiro-cli` as a custom agent really has (taken from such a + /// database), then run the rest. + async fn migrated_db(seed: &[&str]) -> DatabaseConnection { + let conn = Database::connect("sqlite::memory:").await.expect("db"); + let migrations = ::migrations(); + let idx = migrations + .iter() + .position(|m| m.name().contains("kiro_builtin_agent")) + .expect("kiro migration is registered"); + Migrator::up(&conn, Some(idx as u32)) + .await + .expect("earlier migrations"); + exec( + &conn, + "INSERT INTO folder (id, name, path, last_opened_at, created_at, updated_at, \ + is_open, sort_order, color, kind, default_agent_type) VALUES \ + (1, 'w', '/w', '2026-01-01', '2026-01-01', '2026-01-01', 1, 1, 'inherit', \ + 'regular', 'custom:kiro-cli')", + ) + .await; + for statement in seed { + exec(&conn, statement).await; + } + Migrator::up(&conn, None).await.expect("kiro migration"); + conn + } + + const CONV: &str = "INSERT INTO conversation (id, folder_id, agent_type, status, \ + message_count, title_locked, created_at, updated_at, external_id) VALUES "; + const SETTING: &str = "INSERT INTO agent_setting (agent_type, registry_id, enabled, \ + sort_order, installed_version, env_json, created_at, updated_at) VALUES "; + const CUSTOM: &str = "INSERT INTO custom_agent (registry_id, name, description, version, \ + distribution_kind, spec_json, created_at, updated_at) VALUES \ + ('kiro-cli', 'Kiro', '', '2.24.1', 'binary', '{}', '2026-01-01', '2026-01-01'), \ + ('goose', 'Goose', '', '1.0.0', 'binary', '{}', '2026-01-01', '2026-01-01')"; + + #[tokio::test] + async fn custom_kiro_becomes_the_builtin_everywhere() { + let conn = migrated_db(&[ + &format!("{CONV}(1, 1, 'custom:kiro-cli', 'completed', 2, 0, '2026-01-01', '2026-01-01', 's1')"), + &format!("{CONV}(2, 1, 'custom:goose', 'completed', 2, 0, '2026-01-01', '2026-01-01', 's2')"), + &format!("{SETTING}('\"custom:kiro-cli\"', 'kiro-cli', 1, 17, 'kiro-cli 2.24.1', '{{\"K\":\"v\"}}', '2026-01-01', '2026-01-01')"), + CUSTOM, + "INSERT INTO opened_tab (folder_id, conversation_id, agent_type, position, is_active, \ + is_pinned, created_at, updated_at) VALUES (1, 1, 'custom:kiro-cli', 0, 1, 0, \ + '2026-01-01', '2026-01-01')", + "INSERT INTO work_task_settings (folder_id, config, created_at, updated_at) VALUES \ + (1, '{\"default_agent_type\":\"custom:kiro-cli\",\"other\":\"custom:kiro-cli-beta\"}', \ + '2026-01-01', '2026-01-01')", + ]) + .await; + + assert_eq!( + strings(&conn, "SELECT agent_type FROM conversation ORDER BY id").await, + [Some("kiro".into()), Some("custom:goose".into())] + ); + // The settings row carries over (order, env) with the stale probe cleared. + let rows = conn + .query_all(sql( + "SELECT agent_type, sort_order, installed_version, env_json FROM agent_setting", + )) + .await + .unwrap(); + assert_eq!(rows.len(), 1); + let row = &rows[0]; + assert_eq!(row.try_get::("", "agent_type").unwrap(), "\"kiro\""); + assert_eq!(row.try_get::("", "sort_order").unwrap(), 17); + assert_eq!( + row.try_get::>("", "installed_version") + .unwrap(), + None + ); + assert_eq!( + row.try_get::>("", "env_json") + .unwrap() + .as_deref(), + Some("{\"K\":\"v\"}") + ); + assert_eq!( + strings(&conn, "SELECT registry_id FROM custom_agent").await, + [Some("goose".into())] + ); + assert_eq!( + strings(&conn, "SELECT agent_type FROM opened_tab").await, + [Some("kiro".into())] + ); + assert_eq!( + strings(&conn, "SELECT default_agent_type FROM folder").await, + [Some("kiro".into())] + ); + // Only the exact quoted id is rewritten inside JSON. + assert_eq!( + strings(&conn, "SELECT config FROM work_task_settings").await, + [Some( + "{\"default_agent_type\":\"kiro\",\"other\":\"custom:kiro-cli-beta\"}".into() + )] + ); + } + + // Rows the built-in already has win, without losing anything: the + // duplicate conversation is hidden and hands its tab to the built-in row, + // and the built-in settings row inherits the custom env it lacked. + #[tokio::test] + async fn existing_builtin_rows_win_without_losing_data() { + let conn = migrated_db(&[ + &format!("{CONV}(1, 1, 'custom:kiro-cli', 'completed', 2, 0, '2026-01-01', '2026-01-01', 'dup')"), + &format!("{CONV}(2, 1, 'kiro', 'completed', 2, 0, '2026-01-01', '2026-01-01', 'dup')"), + &format!("{CONV}(3, 1, 'custom:kiro-cli', 'completed', 2, 0, '2026-01-01', '2026-01-01', NULL)"), + &format!("{SETTING}('\"custom:kiro-cli\"', 'kiro-cli', 0, 17, NULL, '{{\"KIRO_API_KEY\":\"k\"}}', '2026-01-01', '2026-01-01')"), + &format!("{SETTING}('\"kiro\"', 'kiro-cli', 1, 3, NULL, NULL, '2026-01-01', '2026-01-01')"), + "INSERT INTO opened_tab (folder_id, conversation_id, agent_type, position, is_active, \ + is_pinned, created_at, updated_at) VALUES (1, 1, 'custom:kiro-cli', 0, 1, 0, \ + '2026-01-01', '2026-01-01')", + ]) + .await; + + assert_eq!( + strings(&conn, "SELECT agent_type FROM conversation ORDER BY id").await, + [ + Some("custom:kiro-cli".into()), + Some("kiro".into()), + Some("kiro".into()) + ] + ); + assert_eq!( + strings( + &conn, + "SELECT CASE WHEN deleted_at IS NULL THEN 'live' ELSE 'hidden' END \ + FROM conversation ORDER BY id" + ) + .await, + [ + Some("hidden".into()), + Some("live".into()), + Some("live".into()) + ] + ); + assert_eq!( + strings( + &conn, + "SELECT conversation_id || ':' || agent_type FROM opened_tab" + ) + .await, + [Some("2:kiro".into())] + ); + let rows = conn + .query_all(sql( + "SELECT agent_type, enabled, sort_order, env_json FROM agent_setting", + )) + .await + .unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0].try_get::("", "agent_type").unwrap(), + "\"kiro\"" + ); + assert!( + rows[0].try_get::("", "enabled").unwrap(), + "built-in state kept" + ); + assert_eq!(rows[0].try_get::("", "sort_order").unwrap(), 3); + assert_eq!( + rows[0] + .try_get::>("", "env_json") + .unwrap() + .as_deref(), + Some("{\"KIRO_API_KEY\":\"k\"}"), + "custom env inherited" + ); + } + + // Only Kiro moves: other built-ins, other custom agents and their + // definitions are left exactly as they were. + #[tokio::test] + async fn other_agents_are_untouched() { + let conn = migrated_db(&[ + &format!("{CONV}(1, 1, 'codex', 'completed', 2, 0, '2026-01-01', '2026-01-01', 's1')"), + CUSTOM, + ]) + .await; + assert_eq!( + strings(&conn, "SELECT agent_type FROM conversation").await, + [Some("codex".into())] + ); + assert_eq!( + strings(&conn, "SELECT registry_id FROM custom_agent").await, + [Some("goose".into())] + ); + } +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 54e642be74..04f51c7635 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -45,6 +45,7 @@ mod m20260829_000001_folder_group; mod m20260830_000001_canvas_node; mod m20260831_000001_canvas_node_group_grid; mod m20260907_000001_canvas_node_path; +mod m20260927_000001_kiro_builtin_agent; pub struct Migrator; #[async_trait::async_trait] @@ -96,6 +97,7 @@ impl MigratorTrait for Migrator { Box::new(m20260830_000001_canvas_node::Migration), Box::new(m20260831_000001_canvas_node_group_grid::Migration), Box::new(m20260907_000001_canvas_node_path::Migration), + Box::new(m20260927_000001_kiro_builtin_agent::Migration), ] } } diff --git a/src-tauri/src/db/service/agent_setting_service.rs b/src-tauri/src/db/service/agent_setting_service.rs index cd98684acf..6f34ce4632 100644 --- a/src-tauri/src/db/service/agent_setting_service.rs +++ b/src-tauri/src/db/service/agent_setting_service.rs @@ -43,6 +43,7 @@ fn default_enabled(agent_type: AgentType) -> bool { | AgentType::DeepSeek | AgentType::Qoder | AgentType::Antigravity + | AgentType::Kiro // A user who just registered a custom agent wants to use it. | AgentType::Custom(_) ) @@ -231,3 +232,18 @@ fn is_sqlite_full_error(err: &DbError) -> bool { let message = err.to_string(); message.contains("database or disk is full") || message.contains("(code: 13)") } + +#[cfg(test)] +mod tests { + use super::*; + + // Every built-in starts enabled. The match above has no catch-all arm for + // "the rest", so a newly added agent missing from it silently ships + // disabled — which is how Kiro would have arrived. + #[test] + fn every_builtin_defaults_to_enabled() { + for agent in crate::models::agent::BUILTIN_AGENT_TYPES { + assert!(default_enabled(*agent), "{agent:?} defaults to disabled"); + } + } +} diff --git a/src-tauri/src/db/service/import_service.rs b/src-tauri/src/db/service/import_service.rs index 3a04e60a50..d48ae4adc2 100644 --- a/src-tauri/src/db/service/import_service.rs +++ b/src-tauri/src/db/service/import_service.rs @@ -10,7 +10,7 @@ use crate::models::{AgentType, ConversationSummary, ImportResult}; use crate::parsers::{build_agent_parser, path_eq_for_matching, AgentParser}; /// Every locally-parsable agent, in the canonical parser order. -const ALL_PARSER_AGENTS: [AgentType; 15] = [ +const ALL_PARSER_AGENTS: [AgentType; 16] = [ AgentType::ClaudeCode, AgentType::Codex, AgentType::OpenCode, @@ -26,6 +26,7 @@ const ALL_PARSER_AGENTS: [AgentType; 15] = [ AgentType::DeepSeek, AgentType::Qoder, AgentType::Antigravity, + AgentType::Kiro, ]; fn build_parser(agent_type: AgentType) -> Box { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 89a412a58d..c54819f490 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1791,6 +1791,7 @@ mod tauri_app { acp_commands::acp_scan_leaked_temp, acp_commands::acp_reclaim_leaked_temp, acp_commands::acp_download_agent_binary, + acp_commands::acp_update_system_agent, acp_commands::acp_install_uv_tool, acp_commands::acp_detect_agent_local_version, acp_commands::acp_prepare_npx_agent, diff --git a/src-tauri/src/models/agent.rs b/src-tauri/src/models/agent.rs index bc38571361..c9b8517b48 100644 --- a/src-tauri/src/models/agent.rs +++ b/src-tauri/src/models/agent.rs @@ -8,7 +8,7 @@ pub const CUSTOM_AGENT_WIRE_PREFIX: &str = "custom:"; /// Which agent backs a conversation. /// -/// The fifteen named variants are compile-time built-ins with hand-written +/// The sixteen named variants are compile-time built-ins with hand-written /// launch metadata (`acp::registry`) and a dedicated transcript parser /// (`parsers::*`). [`AgentType::Custom`] is the open end: a user-registered /// ACP agent whose launch metadata lives in the database @@ -36,12 +36,13 @@ pub enum AgentType { DeepSeek, Qoder, Antigravity, + Kiro, /// A user-registered ACP agent, identified by its ACP-registry id /// (interned). Ordered last so built-ins keep their relative order. Custom(&'static str), } -/// The fifteen compile-time agents, in declaration order. Does NOT include +/// The sixteen compile-time agents, in declaration order. Does NOT include /// custom agents — use [`crate::acp::registry::all_acp_agents`] for the live /// set that includes them. pub const BUILTIN_AGENT_TYPES: &[AgentType] = &[ @@ -60,6 +61,7 @@ pub const BUILTIN_AGENT_TYPES: &[AgentType] = &[ AgentType::DeepSeek, AgentType::Qoder, AgentType::Antigravity, + AgentType::Kiro, ]; impl AgentType { @@ -107,6 +109,7 @@ impl AgentType { AgentType::DeepSeek => Cow::Borrowed("deepseek"), AgentType::Qoder => Cow::Borrowed("qoder"), AgentType::Antigravity => Cow::Borrowed("antigravity"), + AgentType::Kiro => Cow::Borrowed("kiro"), AgentType::Custom(id) => Cow::Owned(format!("{CUSTOM_AGENT_WIRE_PREFIX}{id}")), } } @@ -130,6 +133,7 @@ impl AgentType { "deepseek" => Some(AgentType::DeepSeek), "qoder" => Some(AgentType::Qoder), "antigravity" => Some(AgentType::Antigravity), + "kiro" => Some(AgentType::Kiro), other => other .strip_prefix(CUSTOM_AGENT_WIRE_PREFIX) .and_then(AgentType::custom), @@ -172,6 +176,7 @@ pub fn is_valid_custom_agent_id(id: &str) -> bool { | "deepseek" | "qoder" | "antigravity" + | "kiro" ) } @@ -207,6 +212,7 @@ impl fmt::Display for AgentType { AgentType::DeepSeek => write!(f, "DeepSeek Harness"), AgentType::Qoder => write!(f, "Qoder"), AgentType::Antigravity => write!(f, "Google Antigravity"), + AgentType::Kiro => write!(f, "Kiro CLI"), // Prefer the registered display name; fall back to the raw id when // the registry has not been hydrated (or the agent was deleted // while conversations still reference it). @@ -242,6 +248,7 @@ mod tests { (AgentType::DeepSeek, "deepseek"), (AgentType::Qoder, "qoder"), (AgentType::Antigravity, "antigravity"), + (AgentType::Kiro, "kiro"), ]; for (agent, wire) in expected { assert_eq!(agent.as_wire(), wire); @@ -312,6 +319,7 @@ mod tests { "deepseek", "qoder", "antigravity", + "kiro", ] { assert!( !is_valid_custom_agent_id(bad), @@ -330,11 +338,12 @@ mod tests { #[test] fn ordering_places_custom_after_builtins() { - assert!(AgentType::Antigravity < AgentType::custom("goose").unwrap()); + assert!(AgentType::Kiro < AgentType::custom("goose").unwrap()); assert!(AgentType::ClaudeCode < AgentType::Cursor); assert!(AgentType::Cursor < AgentType::DeepSeek); assert!(AgentType::DeepSeek < AgentType::Qoder); assert!(AgentType::Qoder < AgentType::Antigravity); + assert!(AgentType::Antigravity < AgentType::Kiro); // Custom agents order lexicographically among themselves. assert!(AgentType::custom("aaa").unwrap() < AgentType::custom("bbb").unwrap()); } diff --git a/src-tauri/src/parsers/kiro.rs b/src-tauri/src/parsers/kiro.rs new file mode 100644 index 0000000000..1cb1f882c3 --- /dev/null +++ b/src-tauri/src/parsers/kiro.rs @@ -0,0 +1,1454 @@ +//! Kiro CLI session history. +//! +//! Every Kiro session — interactive, headless, and the `kiro-cli acp` ones +//! codeg drives — is two sibling files under `/sessions/cli/` +//! (default `~/.kiro/sessions/cli/`): +//! +//! * `.jsonl` — the append-only event log. One `{"version":"v1","kind":…, +//! "data":…}` record per line. The kinds 2.24.1 writes are `Prompt`, +//! `AssistantMessage`, `ToolResults`, `Compaction`, `Clear`, `ResetTo` and +//! `CancelledPrompt`. +//! * `.json` — session metadata, REWRITTEN at the end of every turn: +//! `cwd`, `title`, `created_at` / `updated_at`, and a `session_state` whose +//! `conversation_metadata.user_turn_metadatas[]` lists each user turn's +//! `message_ids` with its `end_timestamp`, and whose `rts_model_state` names +//! the model and its context window. +//! +//! Neither file is sufficient alone: only `Prompt` records carry a timestamp +//! (epoch seconds), so the time an answer finished comes from the metadata; +//! and the metadata holds no content. The summary cache is therefore keyed on +//! the log with the metadata as a companion +//! ([`super::summary_cache::get_or_parse_with_companions`]). +//! +//! Also in that directory, and deliberately ignored: `.lock` (the pid of a +//! live process), `.history` (the prompt-line history of the TUI) and +//! `/` (the session's task list). + +use std::collections::{HashMap, HashSet}; +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; + +use base64::Engine; +use chrono::{DateTime, TimeZone, Utc}; +use serde_json::Value; + +use crate::models::{ + AgentType, ContentBlock, ConversationDetail, ConversationSummary, ImageData, MessageRole, + UnifiedMessage, +}; +use crate::parsers::claude::group_into_turns; +use crate::parsers::{ + backfill_turn_durations, compute_session_stats, folder_name_from_path, + infer_context_window_max_tokens, is_safe_subagent_id, merge_context_window_stats, + relocate_orphaned_tool_results, title_from_user_text, with_reported_context_percent, + AgentParser, ParseError, +}; + +/// `KIRO_HOME` if set (and non-empty), else `~/.kiro`. +/// +/// `KIRO_HOME` names the `.kiro` directory ITSELF — Kiro's docs: "Overrides the +/// `~/.kiro` directory used for global agents, prompts, skills, steering, +/// settings, and sessions". Verified against 2.24.1: with +/// `KIRO_HOME=/tmp/kh`, `kiro-cli acp` writes `/tmp/kh/sessions/cli/.json` +/// and `/tmp/kh/settings/cli.json`. The value is taken verbatim (no `~` +/// expansion), which is what the file-system runtime's root slot mirrors. +pub(crate) fn resolve_kiro_home_dir() -> PathBuf { + resolve_kiro_home_dir_from(std::env::var_os("KIRO_HOME"), dirs::home_dir()) +} + +fn resolve_kiro_home_dir_from(kiro_home: Option, home: Option) -> PathBuf { + if let Some(dir) = kiro_home.filter(|value| !value.is_empty()) { + return PathBuf::from(dir); + } + home.unwrap_or_default().join(".kiro") +} + +/// `/sessions` — the whole sessions tree (only `cli/` today). +pub(crate) fn resolve_kiro_sessions_root() -> PathBuf { + resolve_kiro_home_dir().join("sessions") +} + +pub struct KiroParser { + sessions_dir: PathBuf, +} + +impl KiroParser { + pub fn new() -> Self { + Self { + sessions_dir: resolve_kiro_sessions_root().join("cli"), + } + } + + /// Construct a parser pointed at an explicit `sessions/cli` directory (test + /// fixtures). + #[cfg(any(test, feature = "test-utils"))] + pub fn with_base_dir(sessions_dir: PathBuf) -> Self { + Self { sessions_dir } + } +} + +impl Default for KiroParser { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Metadata (`.json`) +// --------------------------------------------------------------------------- + +#[derive(Debug, Default)] +struct SessionMeta { + cwd: Option, + title: Option, + created_at: Option>, + updated_at: Option>, + model: Option, + context_window: Option, + context_percent: Option, + /// The LAST message of each user turn → that turn's `end_timestamp`. + /// + /// Only the last one: an assistant message in the middle of a turn (the one + /// that asked for a tool) did not finish when the turn did, and stamping it + /// with the turn's end would hand it the whole turn's duration. + turn_ends: HashMap>, +} + +fn parse_rfc3339(value: Option<&Value>) -> Option> { + value + .and_then(Value::as_str) + .and_then(|raw| DateTime::parse_from_rfc3339(raw).ok()) + .map(|t| t.with_timezone(&Utc)) +} + +fn non_empty_str(value: Option<&Value>) -> Option { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn parse_meta(bytes: Option<&[u8]>) -> SessionMeta { + let Some(root) = bytes.and_then(|b| serde_json::from_slice::(b).ok()) else { + return SessionMeta::default(); + }; + let state = root.get("session_state"); + let conversation = state.and_then(|s| s.get("conversation_metadata")); + let rts = state.and_then(|s| s.get("rts_model_state")); + let model_info = rts.and_then(|r| r.get("model_info")); + let last_usage = conversation.and_then(|c| c.get("last_context_usage")); + + let mut turn_ends = HashMap::new(); + let mut last_turn_model = None; + if let Some(turns) = conversation + .and_then(|c| c.get("user_turn_metadatas")) + .and_then(Value::as_array) + { + for turn in turns { + if let Some(model) = non_empty_str(turn.get("model")) { + last_turn_model = Some(model); + } + let Some(end) = parse_rfc3339(turn.get("end_timestamp")) else { + continue; + }; + // A cancelled turn records `[, null]`: the answer it + // was waiting for never got an id. The end then belongs to no + // message on disk — stamping it on the PROMPT would date the user + // turn to when the cancel landed — so it is dropped. + let last_id = turn + .get("message_ids") + .and_then(Value::as_array) + .and_then(|ids| ids.last()) + .and_then(Value::as_str); + if let Some(id) = last_id { + // A later turn can repeat an earlier turn's ids (a `/compact` + // records the turn it summarized); the first end stands. + turn_ends.entry(id.to_string()).or_insert(end); + } + } + } + + SessionMeta { + cwd: non_empty_str(root.get("cwd")), + title: non_empty_str(root.get("title")), + created_at: parse_rfc3339(root.get("created_at")), + updated_at: parse_rfc3339(root.get("updated_at")), + model: non_empty_str(model_info.and_then(|m| m.get("model_id"))) + .or_else(|| non_empty_str(last_usage.and_then(|u| u.get("model_id")))) + .or(last_turn_model), + context_window: model_info + .and_then(|m| m.get("context_window_tokens")) + .and_then(Value::as_u64) + .filter(|w| *w > 0), + context_percent: last_usage + .and_then(|u| u.get("percentage")) + .and_then(Value::as_f64) + .or_else(|| { + rts.and_then(|r| r.get("context_usage_percentage")) + .and_then(Value::as_f64) + }) + .filter(|p| p.is_finite() && *p >= 0.0), + turn_ends, + } +} + +// --------------------------------------------------------------------------- +// Event log (`.jsonl`) +// --------------------------------------------------------------------------- + +struct Transcript { + messages: Vec, + first_prompt_text: Option, + first_ts: Option>, + last_ts: Option>, + /// The model named by the newest thinking block, when the metadata names + /// none. + thinking_model: Option, +} + +impl Transcript { + /// What the conversation renders: prompts, answers and markers — the tool + /// results fold into the answer that asked for them. + fn message_count(&self) -> u32 { + self.messages + .iter() + .filter(|m| { + !(matches!(m.role, MessageRole::User) + && m.content + .iter() + .all(|b| matches!(b, ContentBlock::ToolResult { .. }))) + }) + .count() as u32 + } +} + +/// `Prompt.data.meta.timestamp`: epoch SECONDS (integer in 2.24.1; a float is +/// tolerated). +fn prompt_timestamp(data: &Value) -> Option> { + let raw = data.pointer("/meta/timestamp")?; + let millis = raw + .as_i64() + .map(|s| s.saturating_mul(1000)) + .or_else(|| raw.as_f64().map(|s| (s * 1000.0) as i64))?; + Utc.timestamp_millis_opt(millis).single() +} + +/// An image block's bytes, as Kiro stores them: `{"format":"png","source": +/// {"kind":"bytes","data":[137,80,78,…]}}`. A base64 string source is accepted +/// too, in case a writer stores one. +fn image_data(data: &Value) -> Option { + let format = data + .get("format") + .and_then(Value::as_str) + .unwrap_or("png") + .to_ascii_lowercase(); + let source = data.get("source")?; + let encoded = match source.get("data")? { + Value::Array(bytes) => { + let raw: Option> = bytes + .iter() + .map(|b| b.as_u64().and_then(|n| u8::try_from(n).ok())) + .collect(); + base64::engine::general_purpose::STANDARD.encode(raw?) + } + Value::String(b64) if !b64.is_empty() => b64.clone(), + _ => return None, + }; + let subtype = if format == "jpg" { "jpeg" } else { &format }; + Some(ImageData { + data: encoded, + mime_type: format!("image/{subtype}"), + uri: None, + }) +} + +/// Blocks of a `Prompt` (user text and pasted images). +fn prompt_blocks(data: &Value) -> Vec { + let mut blocks = Vec::new(); + for item in data + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + match item.get("kind").and_then(Value::as_str) { + Some("text") => { + if let Some(text) = item.get("data").and_then(Value::as_str) { + if !text.trim().is_empty() { + blocks.push(ContentBlock::Text { + text: text.to_string(), + }); + } + } + } + Some("image") => { + if let Some(image) = item.get("data").and_then(image_data) { + blocks.push(ContentBlock::Image { + data: image.data, + mime_type: image.mime_type, + uri: image.uri, + }); + } + } + _ => {} + } + } + blocks +} + +/// Blocks of an `AssistantMessage`, plus the model its thinking block names. +/// +/// Empty thinking is dropped: for models whose reasoning is withheld Kiro +/// writes `{"text":"","redactedContent":[…]}`, which has nothing to show. So is +/// the empty `text` block Kiro writes in front of every tool call. +/// +/// A built-in tool is renamed and its input rewritten ([`canonical_tool`]); +/// `tools` remembers each one by id for the result that answers it. +fn assistant_blocks( + data: &Value, + tools: &mut HashMap, +) -> (Vec, Option) { + let mut blocks = Vec::new(); + let mut model = None; + for item in data + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let body = item.get("data"); + match item.get("kind").and_then(Value::as_str) { + Some("thinking") => { + if let Some(id) = non_empty_str(body.and_then(|b| b.get("modelId"))) { + model = Some(id); + } + if let Some(text) = body.and_then(|b| b.get("text")).and_then(Value::as_str) { + if !text.trim().is_empty() { + blocks.push(ContentBlock::Thinking { + text: text.to_string(), + }); + } + } + } + Some("text") => { + if let Some(text) = body.and_then(Value::as_str) { + if !text.trim().is_empty() { + blocks.push(ContentBlock::Text { + text: text.to_string(), + }); + } + } + } + Some("toolUse") => { + let Some(body) = body else { continue }; + let Some(name) = non_empty_str(body.get("name")) else { + continue; + }; + // MCP tools arrive under their bare tool name (the server is + // named only in the paired result's `results` map), which is + // also how the frontend's alias table already knows codeg-mcp's + // own tools (`delegate_to_agent`, …). + let tool_use_id = non_empty_str(body.get("toolUseId")); + let input = body.get("input"); + let canonical = input.and_then(|input| canonical_tool(&name, input)); + let input = canonical.as_ref().map(|tool| &tool.input).or(input); + let input_preview = input.map(|input| { + serde_json::to_string_pretty(input).unwrap_or_else(|_| input.to_string()) + }); + let tool_name = canonical + .as_ref() + .map_or(name, |tool| tool.name.to_string()); + if let (Some(id), Some(tool)) = (&tool_use_id, canonical) { + tools.insert(id.clone(), tool); + } + blocks.push(ContentBlock::ToolUse { + tool_use_id, + tool_name, + input_preview, + status: None, + meta: None, + }); + } + _ => {} + } + } + (blocks, model) +} + +/// Blocks of a `ToolResults` record: one `ToolResult` per `toolResult` item. +/// +/// A result's `content` is a list of `text` / `json` / `image` items; texts +/// and JSON values join into the preview (see [`result_json_text`]), images +/// ride alongside. `status: "error"` covers both a failed tool and one the +/// user denied (Kiro records the denial as the text "User denied tool +/// execution"). `tools` is what [`assistant_blocks`] canonicalized, by id. +fn tool_result_blocks(data: &Value, tools: &HashMap) -> Vec { + let mut blocks = Vec::new(); + for item in data + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + if item.get("kind").and_then(Value::as_str) != Some("toolResult") { + continue; + } + let Some(body) = item.get("data") else { + continue; + }; + let mut texts: Vec = Vec::new(); + let mut images = Vec::new(); + for part in body + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let part_data = part.get("data"); + match part.get("kind").and_then(Value::as_str) { + Some("text") => { + if let Some(text) = part_data.and_then(Value::as_str) { + texts.push(text.to_string()); + } + } + Some("json") => { + if let Some(value) = part_data { + texts.push(result_json_text(value)); + } + } + Some("image") => { + if let Some(image) = part_data.and_then(image_data) { + images.push(image); + } + } + _ => {} + } + } + let tool_use_id = non_empty_str(body.get("toolUseId")); + let canonical = tool_use_id.as_ref().and_then(|id| tools.get(id)); + blocks.push(ContentBlock::ToolResult { + output_preview: (!texts.is_empty()) + .then(|| structure_tool_output(canonical, texts.join("\n"))), + tool_use_id, + is_error: body.get("status").and_then(Value::as_str) == Some("error"), + agent_stats: None, + images, + }); + } + blocks +} + +// ── Tool calls ─────────────────────────────────────────────────────────── + +/// Codeg's own name and argument shape for a call to one of Kiro's built-in +/// tools. +/// +/// Kiro's tools take arguments no card reads. `write` is ONE tool whose +/// `command` (`create` / `strReplace` / `insert`) picks the operation, `read` +/// takes a list of `operations`, and every call carries the model's +/// `__tool_use_purpose`. Left like that, the input-shape classifier reads +/// `write`'s `command` as a shell command — a `$ strReplace` terminal card — +/// and finds no path to read in `read`. This maps them onto the arguments the +/// dedicated cards take (Claude Code's), for the history parser and the live +/// stream alike (`acp::connection`), so a card renders the same while it runs +/// and after a reload. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct CanonicalTool { + pub name: &'static str, + pub input: Value, +} + +const TOOL_PURPOSE_KEY: &str = "__tool_use_purpose"; + +/// See [`CanonicalTool`]. `None` leaves the call as Kiro sent it: a tool with +/// no dedicated card (`code`, `use_aws`, `todo_list`, …), an MCP tool, a +/// `read` of several targets at once, or a shape this doesn't recognize. +/// +/// The purpose becomes `description`, which the terminal card shows as the +/// command's caption (and the tool header as its title), exactly like Claude +/// Code's own `Bash` description. +pub(crate) fn canonical_tool(name: &str, input: &Value) -> Option { + let args = input.as_object()?; + let text = |key: &str| args.get(key).and_then(Value::as_str); + let mut out = serde_json::Map::new(); + let name: &'static str = match name { + "shell" => { + out.insert("command".into(), text("command")?.into()); + if let Some(dir) = text("working_dir") { + out.insert("cwd".into(), dir.into()); + } + "bash" + } + "write" => { + out.insert("file_path".into(), text("path")?.into()); + match text("command")? { + // A created file renders as new (the write card's + // `--- /dev/null` diff), not as an edit of an empty one. + "create" => { + out.insert("content".into(), text("content").unwrap_or("").into()); + "write" + } + "strReplace" => { + out.insert("old_string".into(), text("oldStr").unwrap_or("").into()); + out.insert("new_string".into(), text("newStr").unwrap_or("").into()); + if args.get("replaceAll") == Some(&Value::Bool(true)) { + out.insert("replace_all".into(), true.into()); + } + "edit" + } + // Lines added at `insertLine`, nothing removed. The live frame + // also carries a whole-file diff, but the log does not, and + // both surfaces must read the same arguments. + "insert" => { + out.insert("old_string".into(), "".into()); + out.insert("new_string".into(), text("content").unwrap_or("").into()); + // 0-based "insert at"; the edit card's `_start_line` is the + // 1-based line the new text starts on. + if let Some(line) = args.get("insertLine").and_then(Value::as_u64) { + out.insert("_start_line".into(), (line + 1).into()); + } + "edit" + } + _ => return None, + } + } + "read" => { + let [op] = args.get("operations")?.as_array()?.as_slice() else { + return None; + }; + let op_text = |key: &str| op.get(key).and_then(Value::as_str); + match op_text("mode").unwrap_or("Line") { + "Line" => { + out.insert("file_path".into(), op_text("path")?.into()); + // Kiro's `offset` is how many lines to skip (its own title + // for offset 6396 reads `:6397-…`); the read card's + // `offset` is the first line shown. A negative one (from + // the end) has no such line, so it passes through. + if let Some(offset) = op.get("offset") { + match offset.as_i64() { + Some(0) => {} + Some(skip) if skip > 0 => { + out.insert("offset".into(), (skip + 1).into()); + } + _ => { + out.insert("offset".into(), offset.clone()); + } + } + } + if let Some(limit) = op.get("limit") { + out.insert("limit".into(), limit.clone()); + } + "read" + } + "Directory" => { + out.insert("path".into(), op_text("path")?.into()); + if let Some(depth) = op.get("depth") { + out.insert("depth".into(), depth.clone()); + } + "ls" + } + "Image" => { + let [path] = op.get("image_paths")?.as_array()?.as_slice() else { + return None; + }; + out.insert("file_path".into(), path.as_str()?.into()); + "read" + } + _ => return None, + } + } + // Already the arguments the search and web cards read; only the name + // (and the purpose) differ. + "grep" | "glob" | "web_fetch" | "web_search" => { + for (key, value) in args { + if key != TOOL_PURPOSE_KEY { + out.insert(key.clone(), value.clone()); + } + } + match name { + "grep" => "grep", + "glob" => "glob", + "web_fetch" => "webfetch", + _ => "websearch", + } + } + _ => return None, + }; + if let Some(purpose) = text(TOOL_PURPOSE_KEY) + .map(str::trim) + .filter(|p| !p.is_empty()) + { + out.entry("description") + .or_insert_with(|| purpose.to_string().into()); + } + Some(CanonicalTool { + name, + input: Value::Object(out), + }) +} + +/// Which built-in tool a call is, from its arguments alone — for the one +/// frame that names none: a permission request's `toolCall` carries no +/// `_meta`. Only the tools whose raw arguments render WRONG are recognized +/// (`write`'s `command` reads as a shell command, `read` has no path); a +/// `command` with no `path` beside it is `shell`, which Kiro spells no other +/// way. +pub(crate) fn tool_name_from_input(input: &Value) -> Option<&'static str> { + let args = input.as_object()?; + if args.get("operations").is_some_and(Value::is_array) { + return Some("read"); + } + let command = args.get("command")?.as_str()?; + if args.get("path").is_some_and(Value::is_string) { + return matches!(command, "create" | "strReplace" | "insert").then_some("write"); + } + Some("shell") +} + +/// The text one part of a tool result contributes. `json` parts are +/// flattened where a card expects text: a shell result (`{exit_status, +/// stdout, stderr}`) becomes what the terminal printed, an MCP result (`{content: +/// [{type:"text",text}]}`) its text — which the codeg-mcp companion cards +/// parse. Any other JSON (`grep`'s match list, `glob`'s file list, …) is shown +/// pretty-printed. +fn result_json_text(value: &Value) -> String { + if let Some(obj) = value.as_object() { + let stdout = obj.get("stdout").and_then(Value::as_str); + let stderr = obj.get("stderr").and_then(Value::as_str); + if stdout.is_some() || stderr.is_some() { + let mut out = String::new(); + for stream in [stdout, stderr].into_iter().flatten() { + if stream.is_empty() { + continue; + } + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + out.push_str(stream); + } + // "exit status: 3" — Kiro's own wording; a success says nothing. + if let Some(status) = obj.get("exit_status").and_then(Value::as_str) { + if !status.trim_end().ends_with(": 0") { + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + out.push_str(status); + } + } + return out; + } + if let Some(parts) = obj.get("content").and_then(Value::as_array) { + let texts: Vec<&str> = parts + .iter() + .filter(|p| p.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|p| p.get("text").and_then(Value::as_str)) + .collect(); + if !texts.is_empty() { + return texts.join("\n"); + } + } + } + serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()) +} + +/// A live `rawOutput` — `{"items":[{"Text":"…"} | {"Json":{…}} | {"Image":…}]}` +/// — as the text the same result gets from the history parser. `None` when it +/// is not that envelope, so the caller keeps its generic handling. +pub(crate) fn live_tool_output_text(raw_output: &Value) -> Option { + let items = raw_output.get("items")?.as_array()?; + let texts: Vec = items + .iter() + .filter_map(|item| { + if let Some(text) = item.get("Text").and_then(Value::as_str) { + return Some(text.to_string()); + } + item.get("Json").map(result_json_text) + }) + .collect(); + Some(texts.join("\n")) +} + +/// A `read` result that starts past line 1, as the `{start_line, content}` +/// the read card numbers its lines from (Kiro returns the bare lines). Every +/// other result is returned unchanged. +pub(crate) fn structure_tool_output(canonical: Option<&CanonicalTool>, text: String) -> String { + let start_line = canonical + .filter(|tool| tool.name == "read") + .and_then(|tool| tool.input.get("offset")) + .and_then(Value::as_u64) + .filter(|line| *line > 1); + match start_line { + Some(start_line) => { + serde_json::json!({ "start_line": start_line, "content": text }).to_string() + } + None => text, + } +} + +fn parse_transcript(log: &[u8], meta: &SessionMeta) -> Transcript { + let mut messages: Vec = Vec::new(); + let mut seen_ids: HashSet = HashSet::new(); + let mut first_prompt_text = None; + let mut thinking_model = None; + let mut tools: HashMap = HashMap::new(); + // The newest prompt's time: every record after it happened no earlier. + let mut cursor = meta.created_at; + + for line in log.split(|b| *b == b'\n') { + let Ok(line) = std::str::from_utf8(line) else { + continue; + }; + if line.trim().is_empty() { + continue; + } + // A trailing line can be half-written while a turn streams. + let Ok(record) = serde_json::from_str::(line) else { + continue; + }; + let Some(data) = record.get("data") else { + continue; + }; + let kind = record.get("kind").and_then(Value::as_str).unwrap_or(""); + let message_id = non_empty_str(data.get("message_id")); + // `CancelledPrompt` (never observed in a 2.24.1 log — a cancel mid-reply + // leaves the `Prompt` and an "interrupted" `AssistantMessage`) is read + // like a `Prompt`, so a record that repeats one must not render twice. + if let Some(id) = &message_id { + if !seen_ids.insert(id.clone()) { + continue; + } + } + + let (role, content, model) = match kind { + "Prompt" | "CancelledPrompt" => { + if let Some(ts) = prompt_timestamp(data) { + cursor = Some(cursor.map_or(ts, |c| c.max(ts))); + } + let blocks = prompt_blocks(data); + if first_prompt_text.is_none() { + first_prompt_text = blocks.iter().find_map(|b| match b { + ContentBlock::Text { text } => Some(text.clone()), + _ => None, + }); + } + (MessageRole::User, blocks, None) + } + "AssistantMessage" => { + let (blocks, model) = assistant_blocks(data, &mut tools); + if model.is_some() { + thinking_model.clone_from(&model); + } + (MessageRole::Assistant, blocks, model) + } + // Folded into the answer that asked for them by `group_into_turns`. + "ToolResults" => (MessageRole::User, tool_result_blocks(data, &tools), None), + // `/compact`: the summary is what the conversation carries on with. + // The records it summarized stay in the log, and keep rendering. + "Compaction" => { + let blocks = non_empty_str(data.get("summary")) + .map(|text| vec![ContentBlock::Text { text }]) + .unwrap_or_default(); + (MessageRole::System, blocks, None) + } + // `/clear` writes no `Prompt`, only this marker. Rendered as the + // command the user typed; the history before it stays visible. + "Clear" => ( + MessageRole::User, + vec![ContentBlock::Text { + text: "/clear".to_string(), + }], + None, + ), + // `ResetTo` (`/rewind`, which forks into a NEW session) and any + // kind a later release adds: rendering nothing is safer than + // guessing at a shape — and truncating on a guessed index could + // hide messages the user really sent. + _ => continue, + }; + if content.is_empty() { + continue; + } + let Some(timestamp) = cursor else { + continue; + }; + let completed_at = message_id + .as_ref() + .and_then(|id| meta.turn_ends.get(id).copied()) + .filter(|end| *end >= timestamp); + messages.push(UnifiedMessage { + id: message_id.unwrap_or_else(|| format!("kiro-{}", messages.len())), + role, + content, + timestamp, + usage: None, + duration_ms: None, + model, + completed_at, + agent_message_id: None, + }); + } + + let first_ts = messages.first().map(|m| m.timestamp); + let last_ts = messages + .iter() + .map(|m| m.completed_at.unwrap_or(m.timestamp)) + .max(); + Transcript { + messages, + first_prompt_text, + first_ts, + last_ts, + thinking_model, + } +} + +/// Everything both the summary and the detail path need, read ONCE so the two +/// can never disagree (a title that differed between them would make the +/// auto-title backfill oscillate). +struct Session { + meta: SessionMeta, + transcript: Transcript, +} + +impl Session { + fn read(log_path: &Path) -> std::io::Result<(Self, u64)> { + let log = fs::read(log_path)?; + let meta_bytes = fs::read(log_path.with_extension("json")).ok(); + let meta = parse_meta(meta_bytes.as_deref()); + let transcript = parse_transcript(&log, &meta); + Ok((Self { meta, transcript }, log.len() as u64)) + } + + fn title(&self) -> Option { + self.meta.title.clone().or_else(|| { + self.transcript + .first_prompt_text + .as_deref() + .map(title_from_user_text) + }) + } + + fn model(&self) -> Option { + self.meta + .model + .clone() + .or_else(|| self.transcript.thinking_model.clone()) + } + + fn summary(&self, id: String) -> Option { + let started_at = self.transcript.first_ts?; + Some(ConversationSummary { + id, + agent_type: AgentType::Kiro, + folder_name: self.meta.cwd.as_deref().map(folder_name_from_path), + folder_path: self.meta.cwd.clone(), + title: self.title(), + started_at, + // Whichever is later: a turn end the log could not be tied to + // (metadata from a newer writer, a cancelled turn) still moves + // `updated_at`. + ended_at: match (self.transcript.last_ts, self.meta.updated_at) { + (Some(a), Some(b)) => Some(a.max(b)), + (a, b) => a.or(b), + }, + message_count: self.transcript.message_count(), + model: self.model(), + git_branch: None, + parent_id: None, + parent_tool_use_id: None, + delegation_call_id: None, + }) + } +} + +fn parse_summary(log_path: &Path) -> Option { + // The FILE STEM is the id: it is what `get_conversation` is handed back, + // and Kiro itself names both files after the session id. + let id = log_path + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .filter(|stem| !stem.is_empty())?; + let (session, _) = Session::read(log_path).ok()?; + session.summary(id) +} + +impl AgentParser for KiroParser { + fn list_conversations(&self) -> Result, ParseError> { + let mut conversations = Vec::new(); + let Ok(entries) = fs::read_dir(&self.sessions_dir) else { + return Ok(conversations); + }; + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("jsonl") + || !entry.file_type().map(|t| t.is_file()).unwrap_or(false) + { + continue; + } + let meta_path = path.with_extension("json"); + if let Ok(Some(summary)) = super::summary_cache::get_or_parse_with_companions( + AgentType::Kiro, + &path, + &[meta_path.as_path()], + || Ok(parse_summary(&path)), + ) { + conversations.push(summary); + } + } + conversations.sort_by_key(|c| std::cmp::Reverse(c.started_at)); + Ok(conversations) + } + + fn get_conversation(&self, conversation_id: &str) -> Result { + if !is_safe_subagent_id(conversation_id) { + return Err(ParseError::ConversationNotFound( + conversation_id.to_string(), + )); + } + let log_path = self.sessions_dir.join(format!("{conversation_id}.jsonl")); + if !log_path.is_file() { + return Err(ParseError::ConversationNotFound( + conversation_id.to_string(), + )); + } + // The watermark is EXACTLY the byte length this parse consumed — see + // the same contract in `parsers::claude`. + let (session, transcript_watermark) = Session::read(&log_path)?; + let summary = session + .summary(conversation_id.to_string()) + .unwrap_or_else(|| ConversationSummary { + id: conversation_id.to_string(), + agent_type: AgentType::Kiro, + folder_name: session.meta.cwd.as_deref().map(folder_name_from_path), + folder_path: session.meta.cwd.clone(), + title: session.title(), + started_at: session.meta.created_at.unwrap_or_else(Utc::now), + ended_at: session.meta.updated_at, + message_count: 0, + model: session.model(), + git_branch: None, + parent_id: None, + parent_tool_use_id: None, + delegation_call_id: None, + }); + + let Session { meta, transcript } = session; + let mut turns = group_into_turns(transcript.messages); + relocate_orphaned_tool_results(&mut turns); + backfill_turn_durations(&mut turns, &[]); + + // Kiro reports every token counter as 0 (its turn metadata carries + // `input_token_count: 0` even for long sessions) but states occupancy + // directly as a percentage, and the model's window beside it. The + // used count is derived from those two so the gauge's used/max pair + // agrees with the percentage it shows. + let max_tokens = meta + .context_window + .or_else(|| infer_context_window_max_tokens(summary.model.as_deref())); + let used_tokens = match (meta.context_percent, max_tokens) { + (Some(percent), Some(max)) => Some(((percent / 100.0) * max as f64).round() as u64), + _ => None, + }; + let session_stats = with_reported_context_percent( + merge_context_window_stats(compute_session_stats(&turns), used_tokens, max_tokens), + meta.context_percent, + ); + + Ok(ConversationDetail { + summary, + turns, + session_stats, + transcript_watermark: Some(transcript_watermark), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::TurnRole; + + const SID: &str = "7787f985-7083-420e-b99d-a74ccce5c00f"; + + // Records captured from a real `kiro-cli acp` 2.24.1 session (signatures + // and digests trimmed; ids kept). + const PROMPT_1: &str = r#"{"version":"v1","kind":"Prompt","data":{"message_id":"p1","content":[{"kind":"text","data":"Run the shell command `echo kiro-probe` and then reply DONE."}],"meta":{"timestamp":1790509942}}}"#; + const ASK_TOOL: &str = r#"{"version":"v1","kind":"AssistantMessage","data":{"message_id":"a1","content":[{"kind":"thinking","data":{"text":"","redactedContent":[46,75],"modelId":"auto","toolsDigest":"f1"}},{"kind":"text","data":""},{"kind":"toolUse","data":{"toolUseId":"tooluse_1","name":"shell","input":{"__tool_use_purpose":"Run the requested echo command.","command":"echo kiro-probe"}}}]}}"#; + const DENIED: &str = r#"{"version":"v1","kind":"ToolResults","data":{"message_id":"r1","content":[{"kind":"toolResult","data":{"toolUseId":"tooluse_1","content":[{"kind":"text","data":"User denied tool execution"}],"status":"error"}}],"results":{}}}"#; + const DONE: &str = r#"{"version":"v1","kind":"AssistantMessage","data":{"message_id":"a2","content":[{"kind":"thinking","data":{"text":"","redactedContent":[46],"modelId":"auto","toolsDigest":"f1"}},{"kind":"text","data":"DONE"}]}}"#; + const PROMPT_2: &str = r#"{"version":"v1","kind":"Prompt","data":{"message_id":"p2","content":[{"kind":"text","data":"Reply with exactly: OK2"}],"meta":{"timestamp":1790510069}}}"#; + const OK2: &str = r#"{"version":"v1","kind":"AssistantMessage","data":{"message_id":"a3","content":[{"kind":"thinking","data":{"text":"Short answer.","signature":"sig","redactedContent":[],"modelId":"claude-opus-5.5","toolsDigest":"f1"}},{"kind":"text","data":"OK2"}]}}"#; + const CLEAR: &str = r#"{"version":"v1","kind":"Clear","data":{}}"#; + const COMPACTION: &str = r###"{"version":"v1","kind":"Compaction","data":{"summary":"## OBJECTIVE\nEcho a probe string.","strategy":{"message_pairs_to_exclude":2},"messages_snapshot":[]}}"###; + + fn meta_json(title: Option<&str>) -> String { + serde_json::json!({ + "session_id": SID, + "cwd": "/tmp/kprobe/cwd", + "created_at": "2026-09-27T11:52:15.670458Z", + "updated_at": "2026-09-27T11:54:29.957575Z", + "title": title, + "session_created_reason": "subagent", + "session_state": { + "version": "v1", + "conversation_metadata": { + "user_turn_metadatas": [ + { + "message_ids": ["p1", "a1", "r1", "a2"], + "end_reason": "UserTurnEnd", + "end_timestamp": "2026-09-27T11:52:26.709231Z", + "input_token_count": 0, + "model": "auto" + }, + { + "message_ids": ["p2", "a3"], + "end_reason": "UserTurnEnd", + "end_timestamp": "2026-09-27T11:54:34Z", + "model": "claude-opus-5.5" + } + ], + "last_context_usage": {"percentage": 10.0, "model_id": "claude-opus-5.5"} + }, + "rts_model_state": { + "model_info": {"model_id": "claude-opus-5.5", "context_window_tokens": 1000000}, + "context_usage_percentage": 10.0 + } + } + }) + .to_string() + } + + fn write_session(dir: &Path, id: &str, lines: &[&str], meta: Option<&str>) { + std::fs::write(dir.join(format!("{id}.jsonl")), lines.join("\n") + "\n").unwrap(); + if let Some(meta) = meta { + std::fs::write(dir.join(format!("{id}.json")), meta).unwrap(); + } + } + + fn texts(turn: &crate::models::MessageTurn) -> Vec { + turn.blocks + .iter() + .filter_map(|b| match b { + ContentBlock::Text { text } => Some(text.clone()), + _ => None, + }) + .collect() + } + + #[test] + fn home_dir_honours_kiro_home_verbatim() { + assert_eq!( + resolve_kiro_home_dir_from(Some("/opt/kiro".into()), Some("/home/u".into())), + PathBuf::from("/opt/kiro") + ); + assert_eq!( + resolve_kiro_home_dir_from(Some("".into()), Some("/home/u".into())), + PathBuf::from("/home/u/.kiro") + ); + assert_eq!( + resolve_kiro_home_dir_from(None, Some("/home/u".into())), + PathBuf::from("/home/u/.kiro") + ); + } + + #[test] + fn summary_reads_metadata_and_log() { + let tmp = tempfile::tempdir().unwrap(); + let meta = meta_json(Some("Run the shell command")); + write_session( + tmp.path(), + SID, + &[PROMPT_1, ASK_TOOL, DENIED, DONE, PROMPT_2, OK2], + Some(&meta), + ); + + let list = KiroParser::with_base_dir(tmp.path().to_path_buf()) + .list_conversations() + .unwrap(); + assert_eq!(list.len(), 1); + let s = &list[0]; + assert_eq!(s.id, SID); + assert_eq!(s.agent_type, AgentType::Kiro); + assert_eq!(s.title.as_deref(), Some("Run the shell command")); + assert_eq!(s.folder_path.as_deref(), Some("/tmp/kprobe/cwd")); + assert_eq!(s.folder_name.as_deref(), Some("cwd")); + assert_eq!(s.model.as_deref(), Some("claude-opus-5.5")); + // 2 prompts + 3 answers; the tool result folds into its answer. + assert_eq!(s.message_count, 5); + assert_eq!(s.started_at.timestamp(), 1_790_509_942); + assert_eq!( + s.ended_at.map(|t| t.to_rfc3339()), + Some("2026-09-27T11:54:34+00:00".to_string()) + ); + } + + #[test] + fn title_falls_back_to_the_first_prompt() { + let tmp = tempfile::tempdir().unwrap(); + write_session(tmp.path(), SID, &[PROMPT_2, OK2], Some(&meta_json(None))); + let list = KiroParser::with_base_dir(tmp.path().to_path_buf()) + .list_conversations() + .unwrap(); + assert_eq!(list[0].title.as_deref(), Some("Reply with exactly: OK2")); + } + + // `kiro-cli acp` writes both files the moment a session opens; one that + // never got a prompt has an empty log and must not become a row. + #[test] + fn sessions_without_content_stay_unlisted() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("empty.jsonl"), "").unwrap(); + std::fs::write(tmp.path().join("empty.json"), meta_json(None)).unwrap(); + // Metadata alone (no log) is not a session either. + std::fs::write(tmp.path().join("orphan.json"), meta_json(Some("x"))).unwrap(); + let list = KiroParser::with_base_dir(tmp.path().to_path_buf()) + .list_conversations() + .unwrap(); + assert!(list.is_empty()); + } + + #[test] + fn detail_pairs_tool_results_and_times_turns() { + let tmp = tempfile::tempdir().unwrap(); + let meta = meta_json(None); + write_session( + tmp.path(), + SID, + &[PROMPT_1, ASK_TOOL, DENIED, DONE, PROMPT_2, OK2], + Some(&meta), + ); + let detail = KiroParser::with_base_dir(tmp.path().to_path_buf()) + .get_conversation(SID) + .unwrap(); + assert_eq!( + detail.transcript_watermark, + Some( + std::fs::metadata(tmp.path().join(format!("{SID}.jsonl"))) + .unwrap() + .len() + ) + ); + + let roles: Vec<_> = detail + .turns + .iter() + .map(|t| match t.role { + TurnRole::User => "user", + TurnRole::Assistant => "assistant", + TurnRole::System => "system", + }) + .collect(); + assert_eq!( + roles, + ["user", "assistant", "assistant", "user", "assistant"] + ); + + // The tool call and its (denied) result land in the same turn, and the + // empty text + redacted thinking in front of the call are dropped. + let ask = &detail.turns[1]; + assert_eq!(ask.blocks.len(), 2, "{:?}", ask.blocks); + match &ask.blocks[0] { + ContentBlock::ToolUse { + tool_use_id, + tool_name, + input_preview, + .. + } => { + assert_eq!(tool_use_id.as_deref(), Some("tooluse_1")); + // Kiro's `shell`, in the terminal card's name and shape. + assert_eq!(tool_name, "bash"); + let input: Value = serde_json::from_str(input_preview.as_deref().unwrap()).unwrap(); + assert_eq!( + input, + serde_json::json!({ + "command": "echo kiro-probe", + "description": "Run the requested echo command." + }) + ); + } + other => panic!("expected tool use, got {other:?}"), + } + match &ask.blocks[1] { + ContentBlock::ToolResult { + tool_use_id, + output_preview, + is_error, + .. + } => { + assert_eq!(tool_use_id.as_deref(), Some("tooluse_1")); + assert_eq!( + output_preview.as_deref(), + Some("User denied tool execution") + ); + assert!(is_error); + } + other => panic!("expected tool result, got {other:?}"), + } + assert_eq!(texts(&detail.turns[2]), ["DONE"]); + // The turn's END is the metadata's `end_timestamp`, placed on the + // turn's last message only, so the whole span lands on the answer. + // Prompt at 11:52:22 (epoch 1790509942), turn end 11:52:26.709231. + assert_eq!(detail.turns[2].duration_ms, Some(4_709)); + assert!(detail.turns[1].duration_ms.is_none()); + + // Real thinking text survives, and names the model. + let answer = &detail.turns[4]; + assert!(answer + .blocks + .iter() + .any(|b| matches!(b, ContentBlock::Thinking { text } if text == "Short answer."))); + assert_eq!(answer.model.as_deref(), Some("claude-opus-5.5")); + + let stats = detail.session_stats.expect("stats"); + assert_eq!(stats.context_window_max_tokens, Some(1_000_000)); + assert_eq!(stats.context_window_used_tokens, Some(100_000)); + assert_eq!(stats.context_window_usage_percent, Some(10.0)); + } + + /// Tool uses and results as kiro-cli 2.24.1 logs them (trimmed from a + /// real session), one per built-in tool shape the cards care about. + #[test] + fn builtin_tools_take_the_names_and_shapes_their_cards_read() { + let uses = r#"{"version":"v1","kind":"AssistantMessage","data":{"message_id":"a1","content":[ + {"kind":"toolUse","data":{"toolUseId":"w1","name":"write","input":{"__tool_use_purpose":"Fix","command":"strReplace","path":"/w/a.txt","oldStr":"line two","newStr":"line 2"}}}, + {"kind":"toolUse","data":{"toolUseId":"w2","name":"write","input":{"__tool_use_purpose":"New","command":"create","path":"/w/b.txt","content":"hello"}}}, + {"kind":"toolUse","data":{"toolUseId":"w3","name":"write","input":{"__tool_use_purpose":"Top","command":"insert","path":"/w/a.txt","content":"line zero","insertLine":0}}}, + {"kind":"toolUse","data":{"toolUseId":"r1","name":"read","input":{"__tool_use_purpose":"Look","operations":[{"mode":"Line","path":"/w/a.txt","offset":1}]}}}, + {"kind":"toolUse","data":{"toolUseId":"s1","name":"shell","input":{"__tool_use_purpose":"Fail","command":"echo hi >&2; exit 3"}}}, + {"kind":"toolUse","data":{"toolUseId":"m1","name":"get_session_info","input":{"__tool_use_purpose":"Look up","session_id":7}}} + ]}}"#.replace('\n', ""); + let results = r#"{"version":"v1","kind":"ToolResults","data":{"message_id":"r1","content":[ + {"kind":"toolResult","data":{"toolUseId":"r1","content":[{"kind":"text","data":"line 2"}],"status":"success"}}, + {"kind":"toolResult","data":{"toolUseId":"s1","content":[{"kind":"json","data":{"exit_status":"exit status: 3","stdout":"","stderr":"hi\n"}}],"status":"success"}}, + {"kind":"toolResult","data":{"toolUseId":"m1","content":[{"kind":"json","data":{"content":[{"type":"text","text":"{\"found\":false}"}]}}],"status":"success"}} + ],"results":{}}}"#.replace('\n', ""); + let tmp = tempfile::tempdir().unwrap(); + write_session( + tmp.path(), + SID, + &[PROMPT_1, &uses, &results], + Some(&meta_json(None)), + ); + let detail = KiroParser::with_base_dir(tmp.path().to_path_buf()) + .get_conversation(SID) + .unwrap(); + let blocks: Vec<&ContentBlock> = detail.turns.iter().flat_map(|t| &t.blocks).collect(); + let uses: Vec<(&str, Value)> = blocks + .iter() + .filter_map(|b| match b { + ContentBlock::ToolUse { + tool_name, + input_preview, + .. + } => Some(( + tool_name.as_str(), + serde_json::from_str(input_preview.as_deref().unwrap()).unwrap(), + )), + _ => None, + }) + .collect(); + assert_eq!( + uses, + [ + ( + "edit", + serde_json::json!({"file_path": "/w/a.txt", "old_string": "line two", "new_string": "line 2", "description": "Fix"}) + ), + ( + "write", + serde_json::json!({"file_path": "/w/b.txt", "content": "hello", "description": "New"}) + ), + ( + "edit", + serde_json::json!({"file_path": "/w/a.txt", "old_string": "", "new_string": "line zero", "_start_line": 1, "description": "Top"}) + ), + ( + "read", + serde_json::json!({"file_path": "/w/a.txt", "offset": 2, "description": "Look"}) + ), + ( + "bash", + serde_json::json!({"command": "echo hi >&2; exit 3", "description": "Fail"}) + ), + // An MCP tool keeps its bare name and its own arguments. + ( + "get_session_info", + serde_json::json!({"__tool_use_purpose": "Look up", "session_id": 7}) + ), + ] + ); + let outputs: Vec<&str> = blocks + .iter() + .filter_map(|b| match b { + ContentBlock::ToolResult { output_preview, .. } => output_preview.as_deref(), + _ => None, + }) + .collect(); + assert_eq!( + outputs, + [ + // Numbered from line 2, where the read started. + r#"{"start_line":2,"content":"line 2"}"#, + "hi\nexit status: 3", + r#"{"found":false}"#, + ] + ); + } + + #[test] + fn canonical_tool_leaves_what_it_does_not_know_alone() { + // Several targets in one read: no single card shows them. + let multi = serde_json::json!({"operations": [ + {"mode": "Line", "path": "/a"}, {"mode": "Line", "path": "/b"} + ]}); + assert_eq!(canonical_tool("read", &multi), None); + assert_eq!( + canonical_tool( + "write", + &serde_json::json!({"command": "append", "path": "/a"}) + ), + None + ); + assert_eq!( + canonical_tool("use_aws", &serde_json::json!({"service_name": "s3"})), + None + ); + let dir = + serde_json::json!({"operations": [{"mode": "Directory", "path": "/w", "depth": 1}]}); + assert_eq!( + canonical_tool("read", &dir).map(|t| (t.name, t.input)), + Some(("ls", serde_json::json!({"path": "/w", "depth": 1}))) + ); + let glob = + serde_json::json!({"__tool_use_purpose": "Find", "pattern": "*.txt", "path": "/w"}); + assert_eq!( + canonical_tool("glob", &glob).map(|t| (t.name, t.input)), + Some(( + "glob", + serde_json::json!({"pattern": "*.txt", "path": "/w", "description": "Find"}) + )) + ); + } + + #[test] + fn a_tool_is_told_from_its_arguments_where_no_name_rides_along() { + let name = |v: Value| tool_name_from_input(&v); + assert_eq!( + name(serde_json::json!({"command": "create", "path": "/a", "content": "x"})), + Some("write") + ); + assert_eq!( + name(serde_json::json!({"command": "strReplace", "path": "/a"})), + Some("write") + ); + assert_eq!(name(serde_json::json!({"operations": []})), Some("read")); + assert_eq!(name(serde_json::json!({"command": "ls"})), Some("shell")); + // A `path` beside an unknown command is not a shell call either. + assert_eq!( + name(serde_json::json!({"command": "ls", "path": "/a"})), + None + ); + assert_eq!(name(serde_json::json!({"query": "x"})), None); + } + + #[test] + fn clear_and_compaction_render_as_markers() { + let tmp = tempfile::tempdir().unwrap(); + write_session( + tmp.path(), + SID, + &[PROMPT_1, DONE, COMPACTION, CLEAR, PROMPT_2, OK2], + Some(&meta_json(None)), + ); + let detail = KiroParser::with_base_dir(tmp.path().to_path_buf()) + .get_conversation(SID) + .unwrap(); + let system = detail + .turns + .iter() + .find(|t| matches!(t.role, TurnRole::System)) + .expect("compaction summary turn"); + assert!(texts(system)[0].contains("OBJECTIVE")); + assert!(detail + .turns + .iter() + .any(|t| matches!(t.role, TurnRole::User) && texts(t) == ["/clear"])); + // Nothing before the clear disappears. + assert!(detail.turns.iter().any(|t| texts(t) == ["DONE"])); + } + + #[test] + fn a_cancelled_turn_keeps_its_interruption_notice() { + let tmp = tempfile::tempdir().unwrap(); + let interrupted = r#"{"version":"v1","kind":"AssistantMessage","data":{"message_id":"ax","content":[{"kind":"text","data":"Response was interrupted by the user"}]}}"#; + let meta = serde_json::json!({ + "cwd": "/w", + "created_at": "2026-09-27T11:52:00Z", + "session_state": {"conversation_metadata": {"user_turn_metadatas": [ + {"message_ids": ["p1", null], "end_reason": "Cancelled", "end_timestamp": "2026-09-27T11:52:30Z"} + ]}} + }) + .to_string(); + write_session(tmp.path(), SID, &[PROMPT_1, interrupted], Some(&meta)); + let detail = KiroParser::with_base_dir(tmp.path().to_path_buf()) + .get_conversation(SID) + .unwrap(); + assert_eq!(detail.turns.len(), 2); + assert_eq!( + texts(&detail.turns[1]), + ["Response was interrupted by the user"] + ); + } + + #[test] + fn images_decode_from_byte_arrays() { + let image = image_data(&serde_json::json!({ + "format": "png", + "source": {"kind": "bytes", "data": [137, 80, 78, 71]} + })) + .unwrap(); + assert_eq!(image.mime_type, "image/png"); + assert_eq!(image.data, "iVBORw=="); + let jpg = image_data(&serde_json::json!({ + "format": "jpg", + "source": {"kind": "bytes", "data": [255]} + })) + .unwrap(); + assert_eq!(jpg.mime_type, "image/jpeg"); + // A byte out of range is corrupt data, not an image. + assert!(image_data(&serde_json::json!({ + "format": "png", + "source": {"kind": "bytes", "data": [256]} + })) + .is_none()); + } + + #[test] + fn a_half_written_tail_and_unknown_kinds_are_skipped() { + let tmp = tempfile::tempdir().unwrap(); + let reset = r#"{"version":"v1","kind":"ResetTo","data":{"target_index":1}}"#; + std::fs::write( + tmp.path().join(format!("{SID}.jsonl")), + format!("{PROMPT_2}\n{reset}\n{OK2}\n{{\"version\":\"v1\",\"kind\":\"Assis"), + ) + .unwrap(); + let detail = KiroParser::with_base_dir(tmp.path().to_path_buf()) + .get_conversation(SID) + .unwrap(); + assert_eq!(detail.turns.len(), 2); + } + + #[test] + fn unsafe_ids_are_not_found() { + let tmp = tempfile::tempdir().unwrap(); + let parser = KiroParser::with_base_dir(tmp.path().to_path_buf()); + for id in ["../escape", "a/b", ""] { + assert!(matches!( + parser.get_conversation(id), + Err(ParseError::ConversationNotFound(_)) + )); + } + } +} diff --git a/src-tauri/src/parsers/mod.rs b/src-tauri/src/parsers/mod.rs index 4614f0b31a..42fcc7456d 100644 --- a/src-tauri/src/parsers/mod.rs +++ b/src-tauri/src/parsers/mod.rs @@ -11,6 +11,7 @@ pub mod gemini; pub mod grok; pub mod hermes; pub mod kimi_code; +pub mod kiro; pub mod openclaw; pub mod opencode; pub mod pi; @@ -252,6 +253,18 @@ pub fn external_transcript_sources() -> Vec { sqlite: true, include_top: None, }, + ExternalSource { + // Kiro keeps each session as `.jsonl` (event log) + `.json` + // (metadata) under `/sessions/cli/` (default + // `~/.kiro/...`). The root is the `sessions/` subtree, never + // `` itself, whose `settings/`, `agents/` and MCP config + // are the user's configuration, not conversation content. + agent: "kiro", + root: kiro::resolve_kiro_sessions_root(), + is_file: false, + sqlite: false, + include_top: None, + }, ]; if let Some(home) = dirs::home_dir() { sources.push(ExternalSource { @@ -316,6 +329,7 @@ pub fn build_agent_parser(agent_type: AgentType) -> Box { AgentType::DeepSeek => Box::new(deepseek::DeepSeekParser::new()), AgentType::Qoder => Box::new(qoder::QoderParser::new()), AgentType::Antigravity => Box::new(antigravity::AntigravityParser::new()), + AgentType::Kiro => Box::new(kiro::KiroParser::new()), // Custom ACP agents have no native store to reverse-engineer; their // history is codeg's own ACP transcript. AgentType::Custom(_) => Box::new(acp_native::AcpNativeParser::new(agent_type)), diff --git a/src-tauri/src/parsers/summary_cache.rs b/src-tauri/src/parsers/summary_cache.rs index 858516541c..088d787268 100644 --- a/src-tauri/src/parsers/summary_cache.rs +++ b/src-tauri/src/parsers/summary_cache.rs @@ -23,6 +23,11 @@ //! cache returns, on the returned clone — it's deterministic given the file's //! location and never mutates the cached entry, so it doesn't affect validity. //! +//! A summary that is a pure function of a FIXED set of sibling files may route +//! through [`get_or_parse_with_companions`], which folds every file's token +//! into the fingerprint: Kiro's `.jsonl` event log plus its `.json` +//! metadata, which are written at different moments of a turn. +//! //! The key is namespaced by `AgentType`, not path alone: the cache is shared by //! every parser, and a value depends on WHICH parser produced it. If two agents' //! roots are configured to overlap (distinct env overrides normally keep them @@ -61,10 +66,18 @@ use std::time::SystemTime; use super::ParseError; use crate::models::{AgentType, ConversationSummary}; -/// File-content fingerprint: `(mtime, size)`. `mtime` is `Option` because a +/// One file's content token: `(mtime, size)`. `mtime` is `Option` because a /// platform may not report it; a `None`/`None` comparison then falls back to /// size-only. -type Fingerprint = (Option, u64); +type FileToken = (Option, u64); + +/// The keyed file's token plus one per companion file (`None` = the companion +/// does not exist, which is itself a state a later write can change). +#[derive(Debug, Clone, PartialEq, Eq)] +struct Fingerprint { + primary: FileToken, + companions: Vec>, +} struct CacheEntry { fingerprint: Fingerprint, @@ -82,13 +95,21 @@ fn cache() -> &'static Mutex { CACHE.get_or_init(|| Mutex::new(HashMap::new())) } -/// Stat the file for its `(mtime, size)` fingerprint. Returns `None` (⇒ bypass -/// the cache) when the file can't be stat'd. -fn fingerprint(path: &Path) -> Option { +fn file_token(path: &Path) -> Option { let meta = std::fs::metadata(path).ok()?; Some((meta.modified().ok(), meta.len())) } +/// Stat the keyed file (and its companions) for the fingerprint. Returns `None` +/// (⇒ bypass the cache) when the KEYED file can't be stat'd; a missing +/// companion is recorded, not fatal. +fn fingerprint(path: &Path, companions: &[&Path]) -> Option { + Some(Fingerprint { + primary: file_token(path)?, + companions: companions.iter().map(|c| file_token(c)).collect(), + }) +} + /// Return the cached summary for `(agent_type, path)` if the file is unchanged /// since it was last parsed; otherwise run `parse`, cache a positive result, and /// return it. @@ -118,12 +139,32 @@ pub(crate) fn get_or_parse( path: &Path, parse: F, ) -> Result, ParseError> +where + F: FnOnce() -> Result, ParseError>, +{ + get_or_parse_with_companions(agent_type, path, &[], parse) +} + +/// [`get_or_parse`] for a summary that is a pure function of a FIXED SET of +/// files rather than one: the entry is keyed by `path` and invalidated when +/// `path` or any of `companions` changes (or appears, or disappears). +/// +/// For agents that split one session across sibling files — Kiro writes the +/// event log to `.jsonl` as a turn streams and rewrites the metadata in +/// `.json` (title, working directory, model) when it ends — so neither file +/// alone decides the summary. +pub(crate) fn get_or_parse_with_companions( + agent_type: AgentType, + path: &Path, + companions: &[&Path], + parse: F, +) -> Result, ParseError> where F: FnOnce() -> Result, ParseError>, { // Can't fingerprint (file vanished, permission error, …) ⇒ bypass the cache: // parse fresh and store nothing, since there's no token to validate against. - let Some(fp_before) = fingerprint(path) else { + let Some(fp_before) = fingerprint(path, companions) else { return parse(); }; @@ -145,7 +186,7 @@ where // concurrent write raced the read (or this is the streaming file); leave it // uncached so the next list re-parses the settled content. if let Some(summary) = &parsed { - if fingerprint(path) == Some(fp_before) { + if fingerprint(path, companions).as_ref() == Some(&fp_before) { if let Ok(mut map) = cache().lock() { map.entry(agent_type).or_default().insert( path.to_path_buf(), @@ -382,4 +423,45 @@ mod tests { assert_eq!(cb2.id, "cb"); // served from cache, closure not run assert_eq!(cb_calls.get(), 0); } + + // A companion is part of the key: rewriting it (Kiro's metadata file at + // the end of a turn), creating it, or deleting it must each invalidate the + // entry even though the keyed file never changed. + #[test] + fn a_companion_change_invalidates_the_entry() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("s.jsonl"); + let meta = dir.path().join("s.json"); + write(&log, b"event\n"); + + let calls = Cell::new(0); + let run = || { + get_or_parse_with_companions(AgentType::Kiro, &log, &[&meta], || { + calls.set(calls.get() + 1); + Ok(Some(dummy_agent(AgentType::Kiro, "k"))) + }) + .unwrap() + }; + + assert!(run().is_some()); + assert!(run().is_some()); + assert_eq!(calls.get(), 1, "unchanged pair is a hit"); + + // Companion appears. + write(&meta, b"{}"); + assert!(run().is_some()); + assert_eq!(calls.get(), 2); + assert!(run().is_some()); + assert_eq!(calls.get(), 2); + + // Companion rewritten with a different size. + write(&meta, b"{\"title\":\"renamed\"}"); + assert!(run().is_some()); + assert_eq!(calls.get(), 3); + + // Companion removed. + std::fs::remove_file(&meta).unwrap(); + assert!(run().is_some()); + assert_eq!(calls.get(), 4); + } } diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index ecd701e3ac..9645c96f69 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -1135,6 +1135,29 @@ pub async fn acp_download_agent_binary( Ok(Json(())) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpUpdateSystemAgentParams { + pub agent_type: AgentType, + pub task_id: String, +} + +pub async fn acp_update_system_agent( + Extension(state): Extension>, + Json(params): Json, +) -> Result>, AppCommandError> { + let emitter = state.emitter.clone(); + let version = acp_commands::acp_update_system_agent_core( + params.agent_type, + params.task_id, + &state.db, + &emitter, + ) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + Ok(Json(version)) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct AcpInstallUvToolParams { diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index bdcd82a8a2..74d6622350 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -979,6 +979,10 @@ pub fn build_router( "/acp_download_agent_binary", post(handlers::acp::acp_download_agent_binary), ) + .route( + "/acp_update_system_agent", + post(handlers::acp::acp_update_system_agent), + ) .route( "/acp_install_uv_tool", post(handlers::acp::acp_install_uv_tool), diff --git a/src-tauri/tests/parsers_snapshot.rs b/src-tauri/tests/parsers_snapshot.rs index b032589217..6778a994bb 100644 --- a/src-tauri/tests/parsers_snapshot.rs +++ b/src-tauri/tests/parsers_snapshot.rs @@ -15,7 +15,7 @@ use std::path::Path; use codeg_lib::parsers::{ claude::ClaudeParser, cline::ClineParser, codex::CodexParser, gemini::GeminiParser, - hermes::HermesParser, kimi_code::KimiCodeParser, openclaw::OpenClawParser, + hermes::HermesParser, kimi_code::KimiCodeParser, kiro::KiroParser, openclaw::OpenClawParser, opencode::OpenCodeParser, AgentParser, }; use insta::assert_json_snapshot; @@ -1490,3 +1490,81 @@ fn kimi_code_minimal_session_snapshot() { ".**.completed_at" => "[ts]", }); } + +// ──────────────────────────────────────────────────────────────────────────── +// Kiro CLI +// ──────────────────────────────────────────────────────────────────────────── + +#[test] +fn kiro_minimal_session_snapshot() { + let temp = tempfile::tempdir().expect("create tempdir"); + // `/sessions/cli/.jsonl` (event log) + `.json` (metadata). + let base = temp.path().join("sessions").join("cli"); + let session_id = "0b6c1f7e-4d2a-4c5e-9a55-3f1e2d7c8b90"; + + // The event log, in the shapes kiro-cli 2.24.1 writes: prompt → thinking + + // tool call → tool result (text + json parts) → answer; then a /compact + // summary, a /clear marker, and one more prompt/answer. + let log = [ + json!({"version":"v1","kind":"Prompt","data":{"message_id":"m1","content":[{"kind":"text","data":"list the repo"}],"meta":{"timestamp":1772359201}}}), + json!({"version":"v1","kind":"AssistantMessage","data":{"message_id":"m2","content":[ + {"kind":"thinking","data":{"text":"Look at the tree first.","signature":"sig","redactedContent":[],"modelId":"claude-opus-5.5","toolsDigest":"d"}}, + {"kind":"text","data":""}, + {"kind":"toolUse","data":{"toolUseId":"tooluse_1","name":"read","input":{"operations":[{"mode":"Directory","path":"/tmp/demo"}]}}} + ]}}), + json!({"version":"v1","kind":"ToolResults","data":{"message_id":"m3","content":[ + {"kind":"toolResult","data":{"toolUseId":"tooluse_1","content":[{"kind":"text","data":"src\nREADME.md"},{"kind":"json","data":{"entries":2}}],"status":"success"}} + ],"results":{}}}), + json!({"version":"v1","kind":"AssistantMessage","data":{"message_id":"m4","content":[{"kind":"text","data":"Two entries: src and README.md."}]}}), + json!({"version":"v1","kind":"Compaction","data":{"summary":"The user listed the repo.","strategy":{"message_pairs_to_exclude":2},"messages_snapshot":[]}}), + json!({"version":"v1","kind":"Clear","data":{}}), + json!({"version":"v1","kind":"Prompt","data":{"message_id":"m5","content":[{"kind":"text","data":"thanks"}],"meta":{"timestamp":1772359300}}}), + json!({"version":"v1","kind":"AssistantMessage","data":{"message_id":"m6","content":[{"kind":"text","data":"Anytime."}]}}), + ]; + let log_text = log + .iter() + .map(|v| v.to_string()) + .collect::>() + .join("\n"); + write( + &base.join(format!("{session_id}.jsonl")), + &format!("{log_text}\n"), + ); + write( + &base.join(format!("{session_id}.json")), + &json!({ + "session_id": session_id, + "cwd": "/tmp/demo", + "created_at": "2026-03-01T10:00:00Z", + "updated_at": "2026-03-01T10:01:45Z", + "title": "list the repo", + "session_state": { + "version": "v1", + "conversation_metadata": { + "user_turn_metadatas": [ + {"message_ids": ["m1", "m2", "m3", "m4"], "end_reason": "UserTurnEnd", "end_timestamp": "2026-03-01T10:00:09Z"}, + {"message_ids": ["m5", "m6"], "end_reason": "UserTurnEnd", "end_timestamp": "2026-03-01T10:01:44Z"} + ], + "last_context_usage": {"percentage": 2.5, "model_id": "claude-opus-5.5"} + }, + "rts_model_state": {"model_info": {"model_id": "claude-opus-5.5", "context_window_tokens": 200000}} + } + }) + .to_string(), + ); + + let parser = KiroParser::with_base_dir(base); + let summaries = parser.list_conversations().expect("list kiro"); + let detail = parser.get_conversation(session_id).expect("detail kiro"); + + assert_json_snapshot!("kiro_list", summaries, { + ".**.started_at" => "[ts]", + ".**.ended_at" => "[ts]", + }); + assert_json_snapshot!("kiro_detail", detail, { + ".**.started_at" => "[ts]", + ".**.ended_at" => "[ts]", + ".**.timestamp" => "[ts]", + ".**.completed_at" => "[ts]", + }); +} diff --git a/src-tauri/tests/snapshots/parsers_snapshot__kiro_detail.snap b/src-tauri/tests/snapshots/parsers_snapshot__kiro_detail.snap new file mode 100644 index 0000000000..6a2b2dd845 --- /dev/null +++ b/src-tauri/tests/snapshots/parsers_snapshot__kiro_detail.snap @@ -0,0 +1,123 @@ +--- +source: tests/parsers_snapshot.rs +assertion_line: 1561 +expression: detail +--- +{ + "summary": { + "id": "0b6c1f7e-4d2a-4c5e-9a55-3f1e2d7c8b90", + "agent_type": "kiro", + "folder_path": "/tmp/demo", + "folder_name": "demo", + "title": "list the repo", + "started_at": "[ts]", + "ended_at": "[ts]", + "message_count": 7, + "model": "claude-opus-5.5", + "git_branch": null + }, + "turns": [ + { + "id": "turn-0", + "role": "user", + "blocks": [ + { + "type": "text", + "text": "list the repo" + } + ], + "timestamp": "[ts]" + }, + { + "id": "turn-1", + "role": "assistant", + "blocks": [ + { + "type": "thinking", + "text": "Look at the tree first." + }, + { + "type": "tool_use", + "tool_use_id": "tooluse_1", + "tool_name": "ls", + "input_preview": "{\n \"path\": \"/tmp/demo\"\n}" + }, + { + "type": "tool_result", + "tool_use_id": "tooluse_1", + "output_preview": "src\nREADME.md\n{\n \"entries\": 2\n}", + "is_error": false + } + ], + "timestamp": "[ts]", + "model": "claude-opus-5.5" + }, + { + "id": "turn-2", + "role": "assistant", + "blocks": [ + { + "type": "text", + "text": "Two entries: src and README.md." + } + ], + "timestamp": "[ts]", + "duration_ms": 8000, + "completed_at": "[ts]" + }, + { + "id": "turn-3", + "role": "system", + "blocks": [ + { + "type": "text", + "text": "The user listed the repo." + } + ], + "timestamp": "[ts]" + }, + { + "id": "turn-4", + "role": "user", + "blocks": [ + { + "type": "text", + "text": "/clear" + } + ], + "timestamp": "[ts]" + }, + { + "id": "turn-5", + "role": "user", + "blocks": [ + { + "type": "text", + "text": "thanks" + } + ], + "timestamp": "[ts]" + }, + { + "id": "turn-6", + "role": "assistant", + "blocks": [ + { + "type": "text", + "text": "Anytime." + } + ], + "timestamp": "[ts]", + "duration_ms": 4000, + "completed_at": "[ts]" + } + ], + "session_stats": { + "total_usage": null, + "total_duration_ms": 12000, + "context_window_used_tokens": 5000, + "context_window_max_tokens": 200000, + "context_window_usage_percent": 2.5 + }, + "transcript_watermark": 1364 +} diff --git a/src-tauri/tests/snapshots/parsers_snapshot__kiro_list.snap b/src-tauri/tests/snapshots/parsers_snapshot__kiro_list.snap new file mode 100644 index 0000000000..92450c154f --- /dev/null +++ b/src-tauri/tests/snapshots/parsers_snapshot__kiro_list.snap @@ -0,0 +1,18 @@ +--- +source: tests/parsers_snapshot.rs +expression: summaries +--- +[ + { + "id": "0b6c1f7e-4d2a-4c5e-9a55-3f1e2d7c8b90", + "agent_type": "kiro", + "folder_path": "/tmp/demo", + "folder_name": "demo", + "title": "list the repo", + "started_at": "[ts]", + "ended_at": "[ts]", + "message_count": 7, + "model": "claude-opus-5.5", + "git_branch": null + } +] diff --git a/src/components/agent-icon.tsx b/src/components/agent-icon.tsx index bb471e8aa2..89c02adfab 100644 --- a/src/components/agent-icon.tsx +++ b/src/components/agent-icon.tsx @@ -461,6 +461,32 @@ const CursorMonoIcon = memo(function CursorMonoIcon({ ) }) +const KiroColorIcon = memo(function KiroColorIcon({ size = "1em" }: IconProps) { + // Kiro's ghost mark from the official app icon (kiro.dev/icon.svg), drawn + // bare in the brand purple (#9046FF) with its two eyes cut out, so it sits + // at the same weight as the other frameless marks instead of carrying the + // icon's rounded plate. The paths are the icon's own, translated so the + // glyph's 655×796 mask box starts at the origin. + return ( + + Kiro CLI + + + + + ) +}) + // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyIcon = React.ComponentType @@ -472,6 +498,7 @@ const COLOR_ICONS: Partial> = { kimi_code: KimiCodeColorIcon, pi: PiColorIcon, deepseek: DeepSeekColorIcon, + kiro: KiroColorIcon, } const MONO_ICONS: Partial> = { diff --git a/src/components/settings/acp-agent-settings.test.tsx b/src/components/settings/acp-agent-settings.test.tsx index 4532f2ec38..6532f752ed 100644 --- a/src/components/settings/acp-agent-settings.test.tsx +++ b/src/components/settings/acp-agent-settings.test.tsx @@ -900,6 +900,57 @@ describe("buildVersionCheck", () => { false ) }) + + // A system agent (Kiro CLI) is installed and updated by its own installer: + // the card reports what was found and never offers install/upgrade/ + // uninstall, which the backend would refuse. + it("reports a found system agent without any install action", () => { + const check = buildVersionCheck( + makeAgent({ + agent_type: "kiro" as AgentType, + distribution_type: "system", + registry_version: null, + installed_version: "2.24.1", + install_url: "https://kiro.dev/docs/getting-started/installation/", + }) + ) + expect(check?.status).toBe("pass") + expect(check?.message).toContain("own installer") + expect(check?.fixes).toEqual([]) + }) + + // With a vendor updater, the found install gets an Upgrade that runs it — + // still no install, uninstall or custom version. + it("offers the vendor updater as Upgrade when the agent has one", () => { + const check = buildVersionCheck( + makeAgent({ + agent_type: "kiro" as AgentType, + distribution_type: "system", + registry_version: null, + installed_version: "2.24.1", + supports_self_update: true, + }) + ) + expect(check?.status).toBe("pass") + expect(check?.message).toContain("its own updater") + expect(check?.fixes.map((fix) => fix.kind)).toEqual(["upgrade_system"]) + }) + + it("points a missing system agent at its install guide", () => { + const url = "https://kiro.dev/docs/getting-started/installation/" + const check = buildVersionCheck( + makeAgent({ + agent_type: "kiro" as AgentType, + distribution_type: "system", + registry_version: null, + installed_version: null, + install_url: url, + }) + ) + expect(check?.status).toBe("fail") + expect(check?.fixes).toHaveLength(1) + expect(check?.fixes[0]).toMatchObject({ kind: "open_url", payload: url }) + }) }) describe("getAgentChecks uv gating", () => { @@ -2087,6 +2138,26 @@ describe("important env keys", () => { }) }) +describe("Kiro env keys", () => { + // KIRO_API_KEY is the only env Kiro CLI reads for sign-in; there is no + // endpoint or model variable, so only the key field is offered. + it("offers only the API key, under the name Kiro reads", () => { + expect(importantEnvKeysByAgent("kiro" as AgentType)).toEqual({ + apiBaseUrl: [], + apiKey: ["KIRO_API_KEY"], + model: [], + }) + expect(importantFieldsFor("kiro" as AgentType)).toEqual({ + apiBaseUrl: false, + apiKey: true, + model: false, + }) + expect( + patchEnvByImportantKey("kiro" as AgentType, "", "apiKey", "ksk_1") + ).toBe("KIRO_API_KEY=ksk_1") + }) +}) + describe("codex ACP preset disclosures", () => { // Every claim the panel makes about the seeded approval preset is only true // when a preset is actually seeded. `default_permissions` shadowing means diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index 02c5815dc0..37b6dfe87e 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -92,6 +92,7 @@ import { acpClearBinaryCache, acpDetectAgentLocalVersion, acpDownloadAgentBinary, + acpUpdateSystemAgent, acpInstallUvTool, acpGetAgentStatus, acpListAgents, @@ -157,6 +158,7 @@ import { ANTIGRAVITY_ENV_KEYS, AntigravityConfigPanel, } from "./antigravity-config-panel" +import { KiroConfigPanel } from "./kiro-config-panel" import { CodeBuddyConfigPanel } from "./codebuddy-config-panel" import { CursorConfigPanel } from "./cursor-config-panel" import { @@ -288,6 +290,7 @@ type RunningActionKind = | "redownload_binary" | "custom_install" | "install_uv" + | "upgrade_system" type UiFixAction = | FixAction @@ -302,6 +305,7 @@ type UiFixAction = | "uninstall_npx" | "install_opencode_plugins" | "custom_install" + | "upgrade_system" payload: string // When true, the fix renders as a greyed-out button (e.g. the uvx // agent-install action while the uv runtime isn't ready yet). @@ -333,6 +337,7 @@ const PACKAGE_ACTION_FIX_KINDS: ReadonlyArray = [ "install_opencode_plugins", "custom_install", "install_uv", + "upgrade_system", ] type AcpTranslator = ( @@ -368,6 +373,18 @@ function publishAgentDisplay(list: AcpAgentInfo[]): void { ) } +/** + * The distribution badge. `npx`, `binary` and `uvx` are the channels' own + * names and stay as they are; `system` is codeg's internal word for "the + * vendor's installer put it there", which reads as noise next to them. + */ +function distributionLabel(distributionType: string): string { + if (distributionType === "system") { + return acpText("distribution.system", "official installer") + } + return distributionType +} + function statusTone(status: CheckStatus): string { if (status === "pass") return "text-green-500" if (status === "warn") return "text-yellow-500" @@ -863,6 +880,18 @@ export function importantEnvKeysByAgent( model: ["QODER_MODEL"], } } + if (agentType === "kiro") { + // `KIRO_API_KEY` is Kiro CLI's non-interactive sign-in (its headless mode + // requires it; an interactive `kiro-cli login` covers the desktop). There + // is no endpoint or model env — the model is picked per session over ACP — + // so both lists stay empty and the panel shows only the key. Mirrors the + // backend `agent_env_keys(Kiro)`. + return { + apiBaseUrl: [], + apiKey: ["KIRO_API_KEY"], + model: [], + } + } return { apiBaseUrl: ["OPENAI_BASE_URL", "API_BASE_URL"], apiKey: ["OPENAI_API_KEY", "API_KEY"], @@ -3945,6 +3974,61 @@ export function buildVersionCheck( agent: AcpAgentInfo, uvReady: boolean = true ): UiCheckItem | null { + // An agent codeg launches but never installs (Kiro CLI): no pin to compare + // against, and installing or removing it is its own installer's job. The + // card says whether the user's install was found — where to get it when it + // was not — and upgrades it in place through the vendor's own updater, so + // the copy the terminal runs and the one Codeg launches stay the same. + if (agent.distribution_type === "system") { + const installed = agent.installed_version + if (installed) { + return { + check_id: "version_status", + label: acpText("version.statusLabel", "Version Status"), + status: "pass", + message: acpText( + agent.supports_self_update + ? "version.systemInstalledUpdatable" + : "version.systemInstalled", + agent.supports_self_update + ? "{versionText}. Installed with the official installer; Upgrade runs its own updater." + : "{versionText}. Installed and updated by its own installer; Codeg launches it as-is.", + { + versionText: acpText("version.localOnly", "Local: {localVersion}", { + localVersion: installed, + }), + } + ), + fixes: agent.supports_self_update + ? [ + { + label: acpText("actions.upgrade", "Upgrade"), + kind: "upgrade_system", + payload: agent.agent_type, + }, + ] + : [], + } + } + return { + check_id: "version_status", + label: acpText("version.statusLabel", "Version Status"), + status: "fail", + message: acpText( + "version.systemMissing", + "Not found on this machine. Install it with the official installer, then refresh." + ), + fixes: agent.install_url + ? [ + { + label: acpText("actions.installGuide", "Install guide"), + kind: "open_url", + payload: agent.install_url, + }, + ] + : [], + } + } if ( agent.distribution_type !== "binary" && agent.distribution_type !== "npx" && @@ -5266,6 +5350,77 @@ export function AcpAgentSettings() { [runPreflight, t, installStream.start] ) + // Upgrade an agent Codeg does not install (Kiro CLI) through its own + // updater. The updater succeeds whether or not a newer build existed, so the + // toast compares the version before and after instead of always claiming an + // upgrade. + const runSystemUpdate = useCallback( + async (agent: AcpAgentInfo) => { + if (busyActionRef.current.has(agent.agent_type)) return + busyActionRef.current.add(agent.agent_type) + setBusyBinaryAction((prev) => ({ ...prev, [agent.agent_type]: true })) + setRunningActionKind((prev) => ({ + ...prev, + [agent.agent_type]: "upgrade_system", + })) + const actionLabel = t("actions.upgrade") + const previous = agent.installed_version + const taskId = randomUUID() + setStreamAgentType(agent.agent_type) + await installStream.start(taskId) + try { + const version = await acpUpdateSystemAgent(agent.agent_type, taskId) + setAgents((prev) => + prev.map((item) => + item.agent_type === agent.agent_type + ? { ...item, installed_version: version } + : item + ) + ) + await runPreflight(agent.agent_type) + if (version && version === previous) { + toast.success( + t("toasts.systemAgentAlreadyLatest", { + name: agent.name, + version, + }) + ) + } else { + toast.success( + t("toasts.agentActionCompleted", { + name: agent.name, + action: actionLabel, + }), + { + description: version + ? t("toasts.localVersion", { version }) + : t("toasts.installCompletedVersionLater"), + } + ) + } + } catch (err) { + const message = toErrorMessage(err) + toast.error( + t("toasts.agentActionFailed", { + name: agent.name, + action: actionLabel, + }), + { description: message } + ) + throw err + } finally { + busyActionRef.current.delete(agent.agent_type) + setBusyBinaryAction((prev) => ({ ...prev, [agent.agent_type]: false })) + setRunningActionKind((prev) => ({ + ...prev, + [agent.agent_type]: undefined, + })) + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [runPreflight, t, installStream.start] + ) + const handleFixAction = async (agent: AcpAgentInfo, action: UiFixAction) => { if ( busyBinaryAction[agent.agent_type] || @@ -5310,6 +5465,10 @@ export function AcpAgentSettings() { await runUvInstall(agent) return } + if (action.kind === "upgrade_system") { + await runSystemUpdate(agent) + return + } if (action.kind === "custom_install") { setCustomVersionInput("") setCustomInstallAgent(agent) @@ -5478,7 +5637,8 @@ export function AcpAgentSettings() { ) : fix.kind === "upgrade_binary" || fix.kind === "upgrade_npx" || - fix.kind === "redownload_binary" ? ( + fix.kind === "redownload_binary" || + fix.kind === "upgrade_system" ? ( ) : fix.kind === "uninstall_binary" || fix.kind === "uninstall_npx" ? ( @@ -7872,7 +8032,7 @@ export function AcpAgentSettings() { {selectedAgent.name} - {selectedAgent.distribution_type} + {distributionLabel(selectedAgent.distribution_type)} {/* Names the thing codeg actually installs, right next to the vendor's name — so the split is visible even before @@ -10743,6 +10903,19 @@ supports_websockets = true`} ) } /> + ) : selectedAgent.agent_type === "kiro" ? ( + + persistEnv( + selectedAgent.agent_type, + enabled, + envMapToText(env), + selectedAgent.model_provider_id + ) + } + /> ) : selectedAgent.agent_type === "kimi_code" ? ( { + // Mirrors the launch side (`kiro_trust_all_tools_enabled`): only an explicit + // "1"/"true" trusts every tool, so an unset or unknown value keeps asking. + it("reads the launch knob the way the backend does", () => { + expect(kiroPermissionModeFromEnv({})).toBe("ask") + expect(kiroPermissionModeFromEnv({ KIRO_TRUST_ALL_TOOLS: "0" })).toBe("ask") + expect(kiroPermissionModeFromEnv({ KIRO_TRUST_ALL_TOOLS: "yes" })).toBe( + "ask" + ) + expect(kiroPermissionModeFromEnv({ KIRO_TRUST_ALL_TOOLS: "1" })).toBe( + "trust_all" + ) + expect(kiroPermissionModeFromEnv({ KIRO_TRUST_ALL_TOOLS: " TRUE " })).toBe( + "trust_all" + ) + }) +}) + +describe("buildKiroEnv", () => { + it("writes both permission states explicitly and keeps unrelated keys", () => { + const prev = { OTHER: "x", KIRO_API_KEY: "old" } + expect(buildKiroEnv(prev, "old", "trust_all")).toEqual({ + OTHER: "x", + KIRO_API_KEY: "old", + KIRO_TRUST_ALL_TOOLS: "1", + }) + expect(buildKiroEnv(prev, "old", "ask").KIRO_TRUST_ALL_TOOLS).toBe("0") + // The input is not mutated. + expect(prev).toEqual({ OTHER: "x", KIRO_API_KEY: "old" }) + }) + + it("trims the API key and deletes it when empty", () => { + expect(buildKiroEnv({}, " ksk_1 ", "ask").KIRO_API_KEY).toBe("ksk_1") + expect( + buildKiroEnv({ KIRO_API_KEY: "old" }, " ", "ask") + ).not.toHaveProperty("KIRO_API_KEY") + }) +}) diff --git a/src/components/settings/kiro-config-panel.tsx b/src/components/settings/kiro-config-panel.tsx new file mode 100644 index 0000000000..05f3fd966a --- /dev/null +++ b/src/components/settings/kiro-config-panel.tsx @@ -0,0 +1,207 @@ +"use client" + +import { useCallback, useState } from "react" +import { useTranslations } from "next-intl" +import { Eye, EyeOff, Loader2, Save, ShieldCheck } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import type { AcpAgentInfo } from "@/lib/types" + +/** Kiro CLI's non-interactive sign-in (what its headless mode requires). */ +const KIRO_API_KEY_ENV = "KIRO_API_KEY" +/** codeg-side launch knob: "1" launches `kiro-cli acp --trust-all-tools`, "0" + * leaves the CLI asking before each tool call. Kiro reads no such env var — + * the launch path (`kiro_trust_all_tools_enabled`) turns it into the flag. */ +const KIRO_TRUST_ALL_TOOLS_ENV = "KIRO_TRUST_ALL_TOOLS" + +export type KiroPermissionMode = "ask" | "trust_all" + +/** The permission mode the next launch will use. Mirrors the backend reading: + * only "1" / "true" trust every tool; unset and anything else asks. */ +export function kiroPermissionModeFromEnv( + env: Record +): KiroPermissionMode { + const value = (env[KIRO_TRUST_ALL_TOOLS_ENV] ?? "").trim().toLowerCase() + return value === "1" || value === "true" ? "trust_all" : "ask" +} + +/** + * Build the env map to persist for Kiro. The permission knob is written for + * BOTH states ("1" / "0"), never deleted, so the saved choice is always + * explicit — the same rule as Cursor's `CURSOR_FORCE`. An empty API key + * deletes `KIRO_API_KEY` (leaving the CLI's own `kiro-cli login` in charge). + * Unrelated keys are preserved untouched. + */ +export function buildKiroEnv( + prevEnv: Record, + apiKey: string, + mode: KiroPermissionMode +): Record { + const env: Record = { ...prevEnv } + const trimmedKey = apiKey.trim() + if (trimmedKey) { + env[KIRO_API_KEY_ENV] = trimmedKey + } else { + delete env[KIRO_API_KEY_ENV] + } + env[KIRO_TRUST_ALL_TOOLS_ENV] = mode === "trust_all" ? "1" : "0" + return env +} + +/** + * Settings panel for Kiro CLI. Everything here rides the generic per-agent env + * path (`persistEnv`): the permission mode becomes the `--trust-all-tools` + * launch flag, and the API key is passed to the CLI as `KIRO_API_KEY`. Model, + * agent, thinking and effort are not here — Kiro reports them over ACP, so the + * composer's own selectors own them per session. Local state resets on remount + * when a different agent is selected. + */ +export function KiroConfigPanel({ + agent, + saving, + onSave, +}: { + agent: AcpAgentInfo + saving: boolean + onSave: (env: Record, enabled: boolean) => Promise +}) { + const t = useTranslations("AcpAgentSettings") + const [mode, setMode] = useState(() => + kiroPermissionModeFromEnv(agent.env) + ) + const [apiKey, setApiKey] = useState(() => agent.env[KIRO_API_KEY_ENV] ?? "") + const [showKey, setShowKey] = useState(false) + + const handleSave = useCallback(async () => { + try { + await onSave(buildKiroEnv(agent.env, apiKey, mode), agent.enabled) + toast.success(t("toasts.kiroSaved")) + } catch (error) { + console.error("[Kiro] save config failed", error) + toast.error(t("toasts.saveKiroFailed")) + } + }, [agent.env, agent.enabled, apiKey, mode, onSave, t]) + + return ( +
+
+ +

+ {t("kiro.configDescription")} +

+
+ +
+
+ + + {t("kiro.permissionsTitle")} + +
+
+ + +
+ {/* Say what the selected mode actually does, including that it only + reaches sessions launched after the save. */} +

+ {mode === "trust_all" + ? t("kiro.permissionModeTrustAllHint") + : t("kiro.permissionModeAskHint")} +

+
+ +
+ +
+ setApiKey(event.target.value)} + autoComplete="off" + disabled={saving} + /> + +
+

{t("kiro.apiKeyHint")}

+
+ +
+ +
+
+ ) +} diff --git a/src/components/settings/mcp-settings.tsx b/src/components/settings/mcp-settings.tsx index dee9915382..45a1a3b590 100644 --- a/src/components/settings/mcp-settings.tsx +++ b/src/components/settings/mcp-settings.tsx @@ -128,6 +128,7 @@ const APP_OPTIONS: { value: McpAppType; label: string }[] = [ { value: "deepseek", label: "DeepSeek Harness" }, { value: "qoder", label: "Qoder" }, { value: "antigravity", label: "Google Antigravity" }, + { value: "kiro", label: "Kiro CLI" }, // pi 同理不作为可分配目标:读写的 ~/.pi/agent/mcp.json 属于第三方 pi 扩展, // pi 自身没有 MCP,pi-acp 也不转发线缆上的 mcpServers。给没装该扩展的用户 // 写这个文件只会造出一个没人读的配置。存量 "pi" 条目照样能改能删—— @@ -318,6 +319,7 @@ function appsToDraft(apps: McpAppType[]): Record { deepseek: appSet.has("deepseek"), qoder: appSet.has("qoder"), antigravity: appSet.has("antigravity"), + kiro: appSet.has("kiro"), pi: appSet.has("pi"), } } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 226042ce7a..85cf9ec373 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -773,6 +773,7 @@ "install": "تثبيت", "upgrade": "ترقية", "uninstall": "إزالة التثبيت", + "installGuide": "دليل التثبيت", "uninstalling": "جارٍ إزالة التثبيت...", "saveEnvVars": "حفظ متغيرات البيئة", "saving": "جارٍ الحفظ...", @@ -998,6 +999,19 @@ "tempReclaimConfirmAction": "حذف", "tempReclaimCancel": "إلغاء" }, + "kiro": { + "configManagement": "إدارة الإعدادات", + "configDescription": "يحفظ Kiro CLI تسجيل الدخول والوكلاء وخوادم MCP في ‎~/.kiro. النموذج والوكيل والتفكير ومستوى الجهد ليست هنا: يُبلغ Kiro عنها عبر ACP، لذا تديرها محددات مربع الإدخال لكل جلسة.", + "permissionsTitle": "الأذونات", + "permissionModeLabel": "وضع الأذونات", + "permissionModeAsk": "السؤال قبل تشغيل الأدوات (الافتراضي)", + "permissionModeTrustAll": "الوثوق بكل الأدوات (‎--trust-all-tools)", + "permissionModeAskHint": "يعرض Kiro عليك أولاً كل استدعاء أداة يحتاج إلى موافقة (تشغيل الأوامر، كتابة الملفات، …). أدوات القراءة فقط تعمل دون سؤال.", + "permissionModeTrustAllHint": "تبدأ الجلسات مع ‎--trust-all-tools: يعمل كل استدعاء أداة دون تأكيد، بما في ذلك الأوامر وكتابة الملفات. يسري على الجلسات الجديدة.", + "apiKeyLabel": "مفتاح API (اختياري)", + "apiKeyHint": "يُمرَّر إلى CLI باسم KIRO_API_KEY. اتركه فارغاً لاستخدام الحساب الذي سجلت الدخول به عبر kiro-cli login؛ لا يلزم إلا حيث يتعذر تسجيل الدخول بالمتصفح (الخوادم، Docker).", + "saveConfig": "حفظ إعدادات Kiro" + }, "deepseek": { "configManagement": "إعدادات DeepSeek Harness", "configDescription": "نقطة النهاية والمفتاح متغيّرا بيئة يقرأهما deepseek-acp عند الإقلاع. أما النموذج ومستوى الاستدلال فمحدِّدان على مستوى الجلسة، ويُختاران من حقل الإدخال.", @@ -1487,7 +1501,13 @@ "saveQoderConfigFailed": "تعذّر حفظ إعدادات Qoder", "antigravitySaved": "Antigravity config saved", "antigravitySyncSkipped": "Saved, but Antigravity's settings.json was left alone", - "saveAntigravityConfigFailed": "Failed to save Antigravity config" + "saveAntigravityConfigFailed": "Failed to save Antigravity config", + "kiroSaved": "تم حفظ إعدادات Kiro", + "saveKiroFailed": "تعذّر حفظ إعدادات Kiro", + "systemAgentAlreadyLatest": "{name} محدَّث بالفعل ({version})" + }, + "distribution": { + "system": "المثبّت الرسمي" }, "version": { "statusLabel": "حالة الإصدار", @@ -1495,6 +1515,9 @@ "remoteLocal": "البعيد: {remoteVersion} · المحلي: {localVersion}", "localOnly": "المحلي: {localVersion}", "localInstalled": "{versionText}. مثبت.", + "systemInstalled": "{versionText}. يثبّته ويحدّثه مثبّته الرسمي، ويشغّله Codeg كما هو.", + "systemInstalledUpdatable": "{versionText}. مثبَّت بالمثبّت الرسمي؛ يشغّل «ترقية» أداة التحديث الخاصة به.", + "systemMissing": "غير موجود على هذا الجهاز. ثبّته باستخدام المثبّت الرسمي ثم حدّث الصفحة.", "platformUnsupported": "{versionText}. المنصة الحالية لا تدعم هذا الوكيل.", "uvxNotReady": "{versionText}. وقت تشغيل uv غير مثبّت — ثبّته من فحص uv بالأسفل لاستخدام هذا الوكيل.", "clickInstall": "{versionText}. انقر تثبيت في الجهة اليمنى.", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index ec0a36da86..95765c449f 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -773,6 +773,7 @@ "install": "Installieren", "upgrade": "Aktualisieren", "uninstall": "Deinstallieren", + "installGuide": "Installationsanleitung", "uninstalling": "Wird deinstalliert...", "saveEnvVars": "Umgebungsvariablen speichern", "saving": "Speichern...", @@ -998,6 +999,19 @@ "tempReclaimConfirmAction": "Löschen", "tempReclaimCancel": "Abbrechen" }, + "kiro": { + "configManagement": "Konfiguration", + "configDescription": "Kiro CLI speichert Anmeldung, Agenten und MCP-Server in ~/.kiro. Modell, Agent, Thinking und Aufwand stehen nicht hier: Kiro meldet sie über ACP, daher verwalten die Auswahlfelder des Eingabebereichs sie pro Sitzung.", + "permissionsTitle": "Berechtigungen", + "permissionModeLabel": "Berechtigungsmodus", + "permissionModeAsk": "Vor dem Ausführen von Tools fragen (Standard)", + "permissionModeTrustAll": "Allen Tools vertrauen (--trust-all-tools)", + "permissionModeAskHint": "Kiro legt dir jeden genehmigungspflichtigen Tool-Aufruf (Befehle, Dateischreibzugriffe, …) zuerst vor. Nur-Lese-Tools laufen ohne Nachfrage.", + "permissionModeTrustAllHint": "Sitzungen starten mit --trust-all-tools: Jeder Tool-Aufruf läuft ohne Bestätigung, auch Befehle und Dateischreibzugriffe. Gilt für neue Sitzungen.", + "apiKeyLabel": "API-Schlüssel (optional)", + "apiKeyHint": "Wird der CLI als KIRO_API_KEY übergeben. Leer lassen, um das per kiro-cli login angemeldete Konto zu nutzen; nur nötig, wo keine Browser-Anmeldung möglich ist (Server, Docker).", + "saveConfig": "Kiro-Konfiguration speichern" + }, "deepseek": { "configManagement": "DeepSeek-Harness-Konfiguration", "configDescription": "Endpunkt und Schlüssel sind Umgebungsvariablen, die deepseek-acp beim Start liest. Modell und Denkstufe sind Auswahlfelder pro Sitzung — sie werden im Eingabefeld gewählt.", @@ -1487,7 +1501,13 @@ "saveQoderConfigFailed": "Qoder-Konfiguration konnte nicht gespeichert werden", "antigravitySaved": "Antigravity config saved", "antigravitySyncSkipped": "Saved, but Antigravity's settings.json was left alone", - "saveAntigravityConfigFailed": "Failed to save Antigravity config" + "saveAntigravityConfigFailed": "Failed to save Antigravity config", + "kiroSaved": "Kiro-Konfiguration gespeichert", + "saveKiroFailed": "Kiro-Konfiguration konnte nicht gespeichert werden", + "systemAgentAlreadyLatest": "{name} ist bereits aktuell ({version})" + }, + "distribution": { + "system": "offizieller Installer" }, "version": { "statusLabel": "Versionsstatus", @@ -1495,6 +1515,9 @@ "remoteLocal": "Remote: {remoteVersion} · Lokal: {localVersion}", "localOnly": "Lokal: {localVersion}", "localInstalled": "{versionText}. Installiert.", + "systemInstalled": "{versionText}. Wird von seinem eigenen Installer installiert und aktualisiert; Codeg startet es unverändert.", + "systemInstalledUpdatable": "{versionText}. Mit dem offiziellen Installer installiert; Aktualisieren führt dessen eigenen Updater aus.", + "systemMissing": "Auf diesem Rechner nicht gefunden. Mit dem offiziellen Installer installieren und dann aktualisieren.", "platformUnsupported": "{versionText}. Aktuelle Plattform unterstützt diesen Agenten nicht.", "uvxNotReady": "{versionText}. Die uv-Laufzeit ist nicht installiert – installieren Sie sie über die uv-Prüfung unten, um diesen Agenten zu verwenden.", "clickInstall": "{versionText}. Rechts auf Installieren klicken.", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 80600cc306..185457178d 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -773,6 +773,7 @@ "install": "Install", "upgrade": "Upgrade", "uninstall": "Uninstall", + "installGuide": "Install guide", "uninstalling": "Uninstalling...", "saveEnvVars": "Save environment variables", "saving": "Saving...", @@ -998,6 +999,19 @@ "tempReclaimConfirmAction": "Delete", "tempReclaimCancel": "Cancel" }, + "kiro": { + "configManagement": "Configuration", + "configDescription": "Kiro CLI keeps its sign-in, agents and MCP servers in ~/.kiro. Model, agent, thinking and effort are not here: Kiro reports them over ACP, so the composer's own selectors own them per session.", + "permissionsTitle": "Permissions", + "permissionModeLabel": "Permission mode", + "permissionModeAsk": "Ask before running tools (default)", + "permissionModeTrustAll": "Trust all tools (--trust-all-tools)", + "permissionModeAskHint": "Kiro sends each tool call that needs approval (shell commands, file writes, …) to you first. Read-only tools run without asking.", + "permissionModeTrustAllHint": "Sessions launch with --trust-all-tools: every tool call runs without a confirmation prompt, including shell commands and file writes. Takes effect for new sessions.", + "apiKeyLabel": "API key (optional)", + "apiKeyHint": "Passed to the CLI as KIRO_API_KEY. Leave empty to use the account you signed in with via kiro-cli login; an API key is only needed where no browser sign-in is possible (servers, Docker).", + "saveConfig": "Save Kiro Config" + }, "deepseek": { "configManagement": "DeepSeek Harness Configuration", "configDescription": "The endpoint and key are environment variables deepseek-acp reads at launch. Model and reasoning effort are per-session selectors — pick them in the composer.", @@ -1487,7 +1501,13 @@ "saveQoderConfigFailed": "Failed to save Qoder config", "antigravitySaved": "Antigravity config saved", "antigravitySyncSkipped": "Saved, but Antigravity's settings.json was left alone", - "saveAntigravityConfigFailed": "Failed to save Antigravity config" + "saveAntigravityConfigFailed": "Failed to save Antigravity config", + "kiroSaved": "Kiro config saved", + "saveKiroFailed": "Failed to save Kiro config", + "systemAgentAlreadyLatest": "{name} is already up to date ({version})" + }, + "distribution": { + "system": "official installer" }, "version": { "statusLabel": "Version Status", @@ -1495,6 +1515,9 @@ "remoteLocal": "Remote: {remoteVersion} · Local: {localVersion}", "localOnly": "Local: {localVersion}", "localInstalled": "{versionText}. Installed.", + "systemInstalled": "{versionText}. Installed and updated by its own installer; Codeg launches it as-is.", + "systemInstalledUpdatable": "{versionText}. Installed with the official installer; Upgrade runs its own updater.", + "systemMissing": "Not found on this machine. Install it with the official installer, then refresh.", "platformUnsupported": "{versionText}. Current platform does not support this agent.", "uvxNotReady": "{versionText}. The uv runtime isn't installed — install it from the uv check below to use this agent.", "clickInstall": "{versionText}. Click Install on the right.", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index d34bd1d8ef..d414aa6d66 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -773,6 +773,7 @@ "install": "Instalar", "upgrade": "Actualizar", "uninstall": "Desinstalar", + "installGuide": "Guía de instalación", "uninstalling": "Desinstalando...", "saveEnvVars": "Guardar variables de entorno", "saving": "Guardando...", @@ -998,6 +999,19 @@ "tempReclaimConfirmAction": "Eliminar", "tempReclaimCancel": "Cancelar" }, + "kiro": { + "configManagement": "Configuración", + "configDescription": "Kiro CLI guarda su inicio de sesión, sus agentes y sus servidores MCP en ~/.kiro. El modelo, el agente, el razonamiento y el esfuerzo no están aquí: Kiro los informa por ACP, así que los selectores del compositor los gestionan por sesión.", + "permissionsTitle": "Permisos", + "permissionModeLabel": "Modo de permisos", + "permissionModeAsk": "Preguntar antes de ejecutar herramientas (predeterminado)", + "permissionModeTrustAll": "Confiar en todas las herramientas (--trust-all-tools)", + "permissionModeAskHint": "Kiro te envía primero cada llamada que requiere aprobación (comandos, escritura de archivos, …). Las herramientas de solo lectura se ejecutan sin preguntar.", + "permissionModeTrustAllHint": "Las sesiones se inician con --trust-all-tools: todas las llamadas se ejecutan sin confirmación, incluidos comandos y escritura de archivos. Se aplica a las sesiones nuevas.", + "apiKeyLabel": "Clave de API (opcional)", + "apiKeyHint": "Se pasa a la CLI como KIRO_API_KEY. Déjala vacía para usar la cuenta con la que iniciaste sesión mediante kiro-cli login; solo hace falta donde no se puede iniciar sesión con el navegador (servidores, Docker).", + "saveConfig": "Guardar configuración de Kiro" + }, "deepseek": { "configManagement": "Configuración de DeepSeek Harness", "configDescription": "El endpoint y la clave son variables de entorno que deepseek-acp lee al arrancar. El modelo y el esfuerzo de razonamiento son selectores por sesión: se eligen en el campo de mensaje.", @@ -1487,7 +1501,13 @@ "saveQoderConfigFailed": "No se pudo guardar la configuración de Qoder", "antigravitySaved": "Antigravity config saved", "antigravitySyncSkipped": "Saved, but Antigravity's settings.json was left alone", - "saveAntigravityConfigFailed": "Failed to save Antigravity config" + "saveAntigravityConfigFailed": "Failed to save Antigravity config", + "kiroSaved": "Configuración de Kiro guardada", + "saveKiroFailed": "No se pudo guardar la configuración de Kiro", + "systemAgentAlreadyLatest": "{name} ya está al día ({version})" + }, + "distribution": { + "system": "instalador oficial" }, "version": { "statusLabel": "Estado de versión", @@ -1495,6 +1515,9 @@ "remoteLocal": "Remota: {remoteVersion} · Local: {localVersion}", "localOnly": "Local: {localVersion}", "localInstalled": "{versionText}. Instalado.", + "systemInstalled": "{versionText}. Lo instala y actualiza su propio instalador; Codeg lo ejecuta tal cual.", + "systemInstalledUpdatable": "{versionText}. Instalado con el instalador oficial; Actualizar ejecuta su propio actualizador.", + "systemMissing": "No se encontró en este equipo. Instálalo con el instalador oficial y luego actualiza.", "platformUnsupported": "{versionText}. La plataforma actual no soporta este agente.", "uvxNotReady": "{versionText}. El runtime de uv no está instalado: instálalo desde la comprobación de uv de abajo para usar este agente.", "clickInstall": "{versionText}. Haz clic en Instalar a la derecha.", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index b4a8264f7a..c67ad2adc4 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -773,6 +773,7 @@ "install": "Installer", "upgrade": "Mettre à niveau", "uninstall": "Désinstaller", + "installGuide": "Guide d'installation", "uninstalling": "Désinstallation...", "saveEnvVars": "Enregistrer les variables d’environnement", "saving": "Enregistrement...", @@ -998,6 +999,19 @@ "tempReclaimConfirmAction": "Supprimer", "tempReclaimCancel": "Annuler" }, + "kiro": { + "configManagement": "Configuration", + "configDescription": "Kiro CLI conserve sa connexion, ses agents et ses serveurs MCP dans ~/.kiro. Le modèle, l'agent, la réflexion et l'effort ne sont pas ici : Kiro les signale via ACP, donc les sélecteurs de la zone de saisie les gèrent par session.", + "permissionsTitle": "Autorisations", + "permissionModeLabel": "Mode d'autorisation", + "permissionModeAsk": "Demander avant d'exécuter les outils (par défaut)", + "permissionModeTrustAll": "Faire confiance à tous les outils (--trust-all-tools)", + "permissionModeAskHint": "Kiro vous soumet d'abord chaque appel qui demande une approbation (commandes, écriture de fichiers, …). Les outils en lecture seule s'exécutent sans demander.", + "permissionModeTrustAllHint": "Les sessions démarrent avec --trust-all-tools : chaque appel d'outil s'exécute sans confirmation, y compris les commandes et l'écriture de fichiers. S'applique aux nouvelles sessions.", + "apiKeyLabel": "Clé d'API (facultative)", + "apiKeyHint": "Transmise à la CLI en tant que KIRO_API_KEY. Laissez vide pour utiliser le compte connecté via kiro-cli login ; nécessaire seulement là où la connexion par navigateur est impossible (serveurs, Docker).", + "saveConfig": "Enregistrer la configuration Kiro" + }, "deepseek": { "configManagement": "Configuration de DeepSeek Harness", "configDescription": "Le point de terminaison et la clé sont des variables d'environnement que deepseek-acp lit au démarrage. Le modèle et le niveau de raisonnement sont des sélecteurs par session : ils se choisissent dans le champ de saisie.", @@ -1487,7 +1501,13 @@ "saveQoderConfigFailed": "Échec de l'enregistrement de la configuration Qoder", "antigravitySaved": "Antigravity config saved", "antigravitySyncSkipped": "Saved, but Antigravity's settings.json was left alone", - "saveAntigravityConfigFailed": "Failed to save Antigravity config" + "saveAntigravityConfigFailed": "Failed to save Antigravity config", + "kiroSaved": "Configuration Kiro enregistrée", + "saveKiroFailed": "Impossible d'enregistrer la configuration Kiro", + "systemAgentAlreadyLatest": "{name} est déjà à jour ({version})" + }, + "distribution": { + "system": "installateur officiel" }, "version": { "statusLabel": "Statut de version", @@ -1495,6 +1515,9 @@ "remoteLocal": "Distant : {remoteVersion} · Local : {localVersion}", "localOnly": "Local : {localVersion}", "localInstalled": "{versionText}. Installé.", + "systemInstalled": "{versionText}. Installé et mis à jour par son propre programme d'installation ; Codeg le lance tel quel.", + "systemInstalledUpdatable": "{versionText}. Installé avec le programme d'installation officiel ; Mettre à niveau lance son propre outil de mise à jour.", + "systemMissing": "Introuvable sur cette machine. Installez-le avec le programme d'installation officiel, puis actualisez.", "platformUnsupported": "{versionText}. La plateforme actuelle ne prend pas en charge cet agent.", "uvxNotReady": "{versionText}. Le runtime uv n'est pas installé — installez-le depuis la vérification uv ci-dessous pour utiliser cet agent.", "clickInstall": "{versionText}. Cliquez sur Installer à droite.", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 9c79975034..83d7f03119 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -773,6 +773,7 @@ "install": "インストール", "upgrade": "アップグレード", "uninstall": "アンインストール", + "installGuide": "インストールガイド", "uninstalling": "アンインストール中...", "saveEnvVars": "環境変数を保存", "saving": "保存中...", @@ -998,6 +999,19 @@ "tempReclaimConfirmAction": "削除", "tempReclaimCancel": "キャンセル" }, + "kiro": { + "configManagement": "設定管理", + "configDescription": "Kiro CLI のサインイン状態、agent、MCP サーバーは ~/.kiro に保存されます。モデル、agent、thinking、思考レベルはここにはありません。Kiro が ACP で報告するため、入力欄のセレクターがセッションごとに管理します。", + "permissionsTitle": "権限", + "permissionModeLabel": "権限モード", + "permissionModeAsk": "ツール実行前に確認(既定)", + "permissionModeTrustAll": "すべてのツールを信頼(--trust-all-tools)", + "permissionModeAskHint": "承認が必要なツール呼び出し(コマンド実行、ファイル書き込みなど)は先に確認を求めます。読み取り専用ツールは確認なしで実行されます。", + "permissionModeTrustAllHint": "--trust-all-tools でセッションを起動します。コマンド実行やファイル書き込みを含め、すべてのツール呼び出しが確認なしで実行されます。新しいセッションから適用されます。", + "apiKeyLabel": "API キー(任意)", + "apiKeyHint": "KIRO_API_KEY として CLI に渡されます。空欄なら kiro-cli login でサインインしたアカウントを使います。ブラウザでサインインできない環境(サーバー、Docker)でのみ必要です。", + "saveConfig": "Kiro 設定を保存" + }, "deepseek": { "configManagement": "DeepSeek Harness の設定", "configDescription": "エンドポイントと API キーは deepseek-acp が起動時に読み込む環境変数です。モデルと推論レベルはセッションごとのセレクターなので、入力欄で切り替えます。", @@ -1487,7 +1501,13 @@ "saveQoderConfigFailed": "Qoder の設定の保存に失敗しました", "antigravitySaved": "Antigravity config saved", "antigravitySyncSkipped": "Saved, but Antigravity's settings.json was left alone", - "saveAntigravityConfigFailed": "Failed to save Antigravity config" + "saveAntigravityConfigFailed": "Failed to save Antigravity config", + "kiroSaved": "Kiro 設定を保存しました", + "saveKiroFailed": "Kiro 設定の保存に失敗しました", + "systemAgentAlreadyLatest": "{name} は最新です({version})" + }, + "distribution": { + "system": "公式インストーラー" }, "version": { "statusLabel": "バージョン状態", @@ -1495,6 +1515,9 @@ "remoteLocal": "リモート: {remoteVersion} · ローカル: {localVersion}", "localOnly": "ローカル:{localVersion}", "localInstalled": "{versionText}。インストール済みです。", + "systemInstalled": "{versionText}。公式インストーラーでインストール・更新され、Codeg はそのまま起動します。", + "systemInstalledUpdatable": "{versionText}。公式インストーラーでインストール済み。「アップグレード」で同梱のアップデーターを実行します。", + "systemMissing": "このマシンに見つかりません。公式インストーラーでインストールしてから更新してください。", "platformUnsupported": "{versionText}。現在のプラットフォームではこのエージェントをサポートしていません。", "uvxNotReady": "{versionText}。uv ランタイムが未インストールです。下の uv チェックからインストールするとこのエージェントを使用できます。", "clickInstall": "{versionText}。右側の「インストール」をクリックしてください。", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index adfbbf8a2d..a4716c591a 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -773,6 +773,7 @@ "install": "설치", "upgrade": "업그레이드", "uninstall": "제거", + "installGuide": "설치 가이드", "uninstalling": "제거 중...", "saveEnvVars": "환경 변수 저장", "saving": "저장 중...", @@ -998,6 +999,19 @@ "tempReclaimConfirmAction": "삭제", "tempReclaimCancel": "취소" }, + "kiro": { + "configManagement": "구성 관리", + "configDescription": "Kiro CLI의 로그인 상태, agent, MCP 서버는 ~/.kiro에 저장됩니다. 모델, agent, thinking, 추론 수준은 여기에 없습니다. Kiro가 ACP로 알려 주므로 입력창의 선택기가 세션별로 관리합니다.", + "permissionsTitle": "권한", + "permissionModeLabel": "권한 모드", + "permissionModeAsk": "도구 실행 전에 확인(기본값)", + "permissionModeTrustAll": "모든 도구 신뢰(--trust-all-tools)", + "permissionModeAskHint": "승인이 필요한 도구 호출(명령 실행, 파일 쓰기 등)은 먼저 확인을 요청합니다. 읽기 전용 도구는 확인 없이 실행됩니다.", + "permissionModeTrustAllHint": "--trust-all-tools로 세션을 시작합니다. 명령 실행과 파일 쓰기를 포함한 모든 도구 호출이 확인 없이 실행됩니다. 새 세션부터 적용됩니다.", + "apiKeyLabel": "API 키(선택)", + "apiKeyHint": "KIRO_API_KEY로 CLI에 전달됩니다. 비워 두면 kiro-cli login으로 로그인한 계정을 사용합니다. 브라우저 로그인이 불가능한 환경(서버, Docker)에서만 필요합니다.", + "saveConfig": "Kiro 구성 저장" + }, "deepseek": { "configManagement": "DeepSeek Harness 구성", "configDescription": "엔드포인트와 API 키는 deepseek-acp가 시작할 때 읽는 환경 변수입니다. 모델과 추론 강도는 세션별 선택기이므로 입력창에서 바꿉니다.", @@ -1487,7 +1501,13 @@ "saveQoderConfigFailed": "Qoder 설정 저장에 실패했습니다", "antigravitySaved": "Antigravity config saved", "antigravitySyncSkipped": "Saved, but Antigravity's settings.json was left alone", - "saveAntigravityConfigFailed": "Failed to save Antigravity config" + "saveAntigravityConfigFailed": "Failed to save Antigravity config", + "kiroSaved": "Kiro 구성을 저장했습니다", + "saveKiroFailed": "Kiro 구성을 저장하지 못했습니다", + "systemAgentAlreadyLatest": "{name}은(는) 이미 최신 버전입니다({version})" + }, + "distribution": { + "system": "공식 설치 프로그램" }, "version": { "statusLabel": "버전 상태", @@ -1495,6 +1515,9 @@ "remoteLocal": "원격: {remoteVersion} · 로컬: {localVersion}", "localOnly": "로컬: {localVersion}", "localInstalled": "{versionText}. 설치되어 있습니다.", + "systemInstalled": "{versionText}. 공식 설치 프로그램으로 설치·업데이트되며 Codeg는 그대로 실행합니다.", + "systemInstalledUpdatable": "{versionText}. 공식 설치 프로그램으로 설치됨. 「업그레이드」는 자체 업데이트 프로그램을 실행합니다.", + "systemMissing": "이 컴퓨터에서 찾을 수 없습니다. 공식 설치 프로그램으로 설치한 뒤 새로고침하세요.", "platformUnsupported": "{versionText}. 현재 플랫폼에서는 이 에이전트를 지원하지 않습니다.", "uvxNotReady": "{versionText}. uv 런타임이 설치되지 않았습니다. 아래 uv 검사에서 설치하면 이 에이전트를 사용할 수 있습니다.", "clickInstall": "{versionText}. 오른쪽의 설치를 클릭하세요.", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index e8551e9bc6..34abfe060e 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -773,6 +773,7 @@ "install": "Instalar", "upgrade": "Atualizar", "uninstall": "Desinstalar", + "installGuide": "Guia de instalação", "uninstalling": "Desinstalando...", "saveEnvVars": "Salvar variáveis de ambiente", "saving": "Salvando...", @@ -998,6 +999,19 @@ "tempReclaimConfirmAction": "Excluir", "tempReclaimCancel": "Cancelar" }, + "kiro": { + "configManagement": "Configuração", + "configDescription": "O Kiro CLI guarda o login, os agentes e os servidores MCP em ~/.kiro. Modelo, agente, raciocínio e esforço não ficam aqui: o Kiro os informa via ACP, então os seletores do compositor os gerenciam por sessão.", + "permissionsTitle": "Permissões", + "permissionModeLabel": "Modo de permissão", + "permissionModeAsk": "Perguntar antes de executar ferramentas (padrão)", + "permissionModeTrustAll": "Confiar em todas as ferramentas (--trust-all-tools)", + "permissionModeAskHint": "O Kiro envia a você primeiro cada chamada que precisa de aprovação (comandos, gravação de arquivos, …). Ferramentas somente leitura rodam sem perguntar.", + "permissionModeTrustAllHint": "As sessões iniciam com --trust-all-tools: toda chamada de ferramenta roda sem confirmação, inclusive comandos e gravação de arquivos. Vale para novas sessões.", + "apiKeyLabel": "Chave de API (opcional)", + "apiKeyHint": "Passada à CLI como KIRO_API_KEY. Deixe vazia para usar a conta conectada via kiro-cli login; só é necessária onde não há login pelo navegador (servidores, Docker).", + "saveConfig": "Salvar configuração do Kiro" + }, "deepseek": { "configManagement": "Configuração do DeepSeek Harness", "configDescription": "O endpoint e a chave são variáveis de ambiente que o deepseek-acp lê ao iniciar. O modelo e o nível de raciocínio são seletores por sessão — escolha-os no campo de mensagem.", @@ -1487,7 +1501,13 @@ "saveQoderConfigFailed": "Falha ao salvar a configuração do Qoder", "antigravitySaved": "Antigravity config saved", "antigravitySyncSkipped": "Saved, but Antigravity's settings.json was left alone", - "saveAntigravityConfigFailed": "Failed to save Antigravity config" + "saveAntigravityConfigFailed": "Failed to save Antigravity config", + "kiroSaved": "Configuração do Kiro salva", + "saveKiroFailed": "Falha ao salvar a configuração do Kiro", + "systemAgentAlreadyLatest": "{name} já está atualizado ({version})" + }, + "distribution": { + "system": "instalador oficial" }, "version": { "statusLabel": "Status da versão", @@ -1495,6 +1515,9 @@ "remoteLocal": "Remoto: {remoteVersion} · Local: {localVersion}", "localOnly": "Local: {localVersion}", "localInstalled": "{versionText}. Instalado.", + "systemInstalled": "{versionText}. Instalado e atualizado pelo próprio instalador; o Codeg o executa como está.", + "systemInstalledUpdatable": "{versionText}. Instalado com o instalador oficial; Atualizar executa o próprio atualizador.", + "systemMissing": "Não encontrado nesta máquina. Instale com o instalador oficial e depois atualize.", "platformUnsupported": "{versionText}. A plataforma atual não suporta este agente.", "uvxNotReady": "{versionText}. O runtime do uv não está instalado — instale-o na verificação do uv abaixo para usar este agente.", "clickInstall": "{versionText}. Clique em Instalar à direita.", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 31ecfb9fbc..a5835d683a 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -773,6 +773,7 @@ "install": "安装", "upgrade": "升级", "uninstall": "卸载", + "installGuide": "安装指南", "uninstalling": "卸载中...", "saveEnvVars": "保存环境变量", "saving": "保存中...", @@ -998,6 +999,19 @@ "tempReclaimConfirmAction": "删除", "tempReclaimCancel": "取消" }, + "kiro": { + "configManagement": "配置管理", + "configDescription": "Kiro CLI 的登录状态、agent 与 MCP 服务器都保存在 ~/.kiro。模型、agent、thinking 与思考等级不在这里:Kiro 通过 ACP 上报它们,由输入框的选择器按会话管理。", + "permissionsTitle": "权限", + "permissionModeLabel": "权限模式", + "permissionModeAsk": "运行工具前询问(默认)", + "permissionModeTrustAll": "信任所有工具(--trust-all-tools)", + "permissionModeAskHint": "需要确认的工具调用(执行命令、写文件等)会先交给你确认;只读工具直接运行。", + "permissionModeTrustAllHint": "以 --trust-all-tools 启动会话:所有工具调用都不再弹确认,包括执行命令和写文件。对新会话生效。", + "apiKeyLabel": "API Key(可选)", + "apiKeyHint": "以 KIRO_API_KEY 传给 CLI。留空则使用 kiro-cli login 登录的账号;只有无法用浏览器登录的环境(服务器、Docker)才需要 API Key。", + "saveConfig": "保存 Kiro 配置" + }, "deepseek": { "configManagement": "DeepSeek Harness 配置", "configDescription": "端点与密钥是 deepseek-acp 启动时读取的环境变量。模型和推理档位是会话级选择器,在输入框里切换。", @@ -1487,7 +1501,13 @@ "saveQoderConfigFailed": "保存 Qoder 配置失败", "antigravitySaved": "已保存 Antigravity 配置", "antigravitySyncSkipped": "已保存,但没有改动 Antigravity 的 settings.json", - "saveAntigravityConfigFailed": "保存 Antigravity 配置失败" + "saveAntigravityConfigFailed": "保存 Antigravity 配置失败", + "kiroSaved": "Kiro 配置已保存", + "saveKiroFailed": "保存 Kiro 配置失败", + "systemAgentAlreadyLatest": "{name} 已是最新版本({version})" + }, + "distribution": { + "system": "官方安装" }, "version": { "statusLabel": "版本状态", @@ -1495,6 +1515,9 @@ "remoteLocal": "远程:{remoteVersion} · 本地:{localVersion}", "localOnly": "本地:{localVersion}", "localInstalled": "{versionText}。已安装。", + "systemInstalled": "{versionText}。由其官方安装程序安装和更新,Codeg 直接启动这份安装。", + "systemInstalledUpdatable": "{versionText}。通过官方安装程序安装;点击「升级」会运行它自带的更新程序。", + "systemMissing": "本机未找到。请用官方安装程序安装后再刷新。", "platformUnsupported": "{versionText}。当前平台不支持该 Agent。", "uvxNotReady": "{versionText}。uv 运行时未安装——请在下方 uv 预检项中安装后再使用该 Agent。", "clickInstall": "{versionText}。请点击右侧安装。", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 6009646f2f..b92182551b 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -773,6 +773,7 @@ "install": "安裝", "upgrade": "升級", "uninstall": "卸載", + "installGuide": "安裝指南", "uninstalling": "卸載中...", "saveEnvVars": "儲存環境變數", "saving": "儲存中...", @@ -998,6 +999,19 @@ "tempReclaimConfirmAction": "刪除", "tempReclaimCancel": "取消" }, + "kiro": { + "configManagement": "設定管理", + "configDescription": "Kiro CLI 的登入狀態、agent 與 MCP 伺服器都保存在 ~/.kiro。模型、agent、thinking 與思考等級不在這裡:Kiro 透過 ACP 回報它們,由輸入框的選擇器按工作階段管理。", + "permissionsTitle": "權限", + "permissionModeLabel": "權限模式", + "permissionModeAsk": "執行工具前詢問(預設)", + "permissionModeTrustAll": "信任所有工具(--trust-all-tools)", + "permissionModeAskHint": "需要確認的工具呼叫(執行指令、寫入檔案等)會先交給你確認;唯讀工具直接執行。", + "permissionModeTrustAllHint": "以 --trust-all-tools 啟動工作階段:所有工具呼叫都不再跳出確認,包括執行指令與寫入檔案。對新工作階段生效。", + "apiKeyLabel": "API Key(選填)", + "apiKeyHint": "以 KIRO_API_KEY 傳給 CLI。留空則使用 kiro-cli login 登入的帳號;只有無法用瀏覽器登入的環境(伺服器、Docker)才需要 API Key。", + "saveConfig": "儲存 Kiro 設定" + }, "deepseek": { "configManagement": "DeepSeek Harness 設定", "configDescription": "端點與金鑰是 deepseek-acp 啟動時讀取的環境變數。模型與推理等級是工作階段層級的選擇器,在輸入框裡切換。", @@ -1487,7 +1501,13 @@ "saveQoderConfigFailed": "儲存 Qoder 設定失敗", "antigravitySaved": "已保存 Antigravity 配置", "antigravitySyncSkipped": "Saved, but Antigravity's settings.json was left alone", - "saveAntigravityConfigFailed": "保存 Antigravity 配置失败" + "saveAntigravityConfigFailed": "保存 Antigravity 配置失败", + "kiroSaved": "Kiro 設定已儲存", + "saveKiroFailed": "儲存 Kiro 設定失敗", + "systemAgentAlreadyLatest": "{name} 已是最新版本({version})" + }, + "distribution": { + "system": "官方安裝" }, "version": { "statusLabel": "版本狀態", @@ -1495,6 +1515,9 @@ "remoteLocal": "遠端:{remoteVersion} · 本地:{localVersion}", "localOnly": "本機:{localVersion}", "localInstalled": "{versionText}。已安裝。", + "systemInstalled": "{versionText}。由其官方安裝程式安裝與更新,Codeg 直接啟動這份安裝。", + "systemInstalledUpdatable": "{versionText}。透過官方安裝程式安裝;點擊「升級」會執行它內建的更新程式。", + "systemMissing": "本機未找到。請用官方安裝程式安裝後再重新整理。", "platformUnsupported": "{versionText}。目前平台不支援該 Agent。", "uvxNotReady": "{versionText}。uv 執行階段未安裝——請在下方 uv 預檢項中安裝後再使用該 Agent。", "clickInstall": "{versionText}。請點擊右側安裝。", diff --git a/src/lib/api.ts b/src/lib/api.ts index c0afe26d54..28ffdc0182 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -535,6 +535,21 @@ export async function acpDownloadAgentBinary( }) } +/** + * Upgrade an agent codeg launches but does not install (Kiro CLI) through the + * vendor's own updater, streaming its output on the install topic. Resolves to + * the version found afterwards — the same one when nothing newer existed. + */ +export async function acpUpdateSystemAgent( + agentType: AgentType, + taskId: string +): Promise { + return getTransport().call("acp_update_system_agent", { + agentType, + taskId, + }) +} + export async function acpInstallUvTool(taskId: string): Promise { // uv install downloads + extracts the toolchain from GitHub; allow well // beyond the default 60s web-call timeout so slow networks don't surface a diff --git a/src/lib/tool-call-normalization.test.ts b/src/lib/tool-call-normalization.test.ts index 6a32f2c79b..e967494ef1 100644 --- a/src/lib/tool-call-normalization.test.ts +++ b/src/lib/tool-call-normalization.test.ts @@ -1469,3 +1469,107 @@ describe("inferLiveToolName meta.opencode.toolName override", () => { ) }) }) + +describe("inferLiveToolName meta.kiro.canonicalToolName override", () => { + // Opening frames captured from kiro-cli 2.24.1 over real ACP, after the + // backend's rewrite: `rawInput` is the canonical shape and + // `_meta.kiro.canonicalToolName` sits beside Kiro's own `toolName` + // (`stamp_kiro_tool_name`). + const call = ( + kiro: Record, + title: string, + kind: string | null, + rawInput: unknown + ) => + inferLiveToolName({ + title, + kind, + rawInput: JSON.stringify(rawInput), + meta: { kiro }, + }) + + it("names the calls the input shape gets wrong", () => { + // {pattern, path} alone reads as grep, {path} alone as a file read. + expect( + call( + { toolName: "glob", canonicalToolName: "glob" }, + "Finding *.txt in ws", + "search", + { pattern: "*.txt", path: "/w" } + ) + ).toBe("glob") + expect( + call( + { toolName: "read", canonicalToolName: "ls" }, + "Reading listing ws", + "read", + { path: "/w" } + ) + ).toBe("ls") + // {query} is only a web search when the title or kind says so; Kiro's + // kind is the generic "search". + expect( + call( + { toolName: "web_search", canonicalToolName: "websearch" }, + "Searching the web", + "search", + { query: "agent client protocol" } + ) + ).toBe("websearch") + }) + + it("keeps the edit/write split the canonical input encodes", () => { + expect( + call( + { toolName: "write", canonicalToolName: "edit" }, + "Editing a.txt", + "edit", + { file_path: "/w/a.txt", old_string: "two", new_string: "2" } + ) + ).toBe("edit") + expect( + call( + { toolName: "write", canonicalToolName: "write" }, + "Creating b.txt", + "edit", + { file_path: "/w/b.txt", content: "hello" } + ) + ).toBe("write") + expect( + call( + { toolName: "shell", canonicalToolName: "bash" }, + "Running: ls /w", + "execute", + { command: "ls /w", description: "List it" } + ) + ).toBe("bash") + }) + + it("resolves an MCP call to its companion tool", () => { + // Title `Running: @/`, no kind: nothing else names it. + expect( + call( + { + toolName: "get_session_info", + mcpServerName: "codeg-mcp", + canonicalToolName: "mcp__codeg-mcp__get_session_info", + }, + "Running: @codeg-mcp/get_session_info", + null, + { __tool_use_purpose: "Look it up", session_id: 7 } + ) + ).toBe("get_session_info") + }) + + it("ignores Kiro's own toolName without the backend's stamp", () => { + // Kiro's bare `write` is not a canonical name — without the stamp the + // input shape decides, as before. + expect( + call({ toolName: "write" }, "Editing a.txt", "edit", { + file_path: "/w/a.txt", + old_string: "a", + new_string: "b", + }) + ).toBe("edit") + }) +}) diff --git a/src/lib/tool-call-normalization.ts b/src/lib/tool-call-normalization.ts index af292d75b5..fa97487df3 100644 --- a/src/lib/tool-call-normalization.ts +++ b/src/lib/tool-call-normalization.ts @@ -744,6 +744,16 @@ export function inferLiveToolName(params: { const openCodeToolName = extractOpenCodeToolName(params.meta) if (openCodeToolName) return normalizeToolName(openCodeToolName) + // Kiro: the backend rewrites a built-in tool's arguments into the canonical + // shape and records the canonical name beside Kiro's own + // (`stamp_kiro_tool_name`) — the only way to tell `glob` ({pattern}) from + // `grep`, or a directory listing ({path}) from a file read, both of which the + // history parser names correctly. An MCP call gets its `mcp____` + // name, which its `Running: @/` title never yields. Same + // placement, and reason, as OpenCode's above. + const kiroToolName = extractKiroToolName(params.meta) + if (kiroToolName) return normalizeToolName(kiroToolName) + // Input-shape detection runs FIRST so cross-agent heuristics (Claude Code // `Task` tool routed via `subagent_type`, OpenCode sub-agent calls, etc.) // keep priority. The meta-tool-name override below only kicks in when the @@ -880,6 +890,24 @@ function extractOpenCodeToolName( return trimmed.length > 0 ? trimmed : null } +/** + * The canonical tool name the backend stamps into Kiro's own + * `_meta.kiro` namespace (`canonicalToolName`, beside Kiro's `toolName`). Only + * the opening frame carries it; the reducer keeps a block's `meta` across + * updates that omit it. + */ +function extractKiroToolName( + meta: Record | null | undefined +): string | null { + if (!meta || typeof meta !== "object") return null + const kiro = (meta as Record).kiro + if (!kiro || typeof kiro !== "object") return null + const name = (kiro as Record).canonicalToolName + if (typeof name !== "string") return null + const trimmed = name.trim() + return trimmed.length > 0 ? trimmed : null +} + /** * claude-agent-acp ≥0.63 marks Agent/Task tool calls with * `_meta.claudeCode.subagent: true` — the namespaced subagent-launch marker diff --git a/src/lib/types.ts b/src/lib/types.ts index b7c77f4712..03e7df55be 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,4 +1,4 @@ -/** The fifteen agents codeg ships hand-written support for. */ +/** The sixteen agents codeg ships hand-written support for. */ export type BuiltinAgentType = | "claude_code" | "codex" @@ -15,6 +15,7 @@ export type BuiltinAgentType = | "deepseek" | "qoder" | "antigravity" + | "kiro" /** * Which agent backs a conversation. @@ -935,6 +936,7 @@ export const AGENT_DISPLAY_ORDER: BuiltinAgentType[] = [ "deepseek", "qoder", "antigravity", + "kiro", ] const AGENT_DISPLAY_ORDER_INDEX = new Map( @@ -969,6 +971,7 @@ export const ALL_AGENT_TYPES: BuiltinAgentType[] = [ "deepseek", "qoder", "antigravity", + "kiro", ] export const MODEL_PROVIDER_AGENT_TYPES: BuiltinAgentType[] = [ @@ -1280,6 +1283,7 @@ export const AGENT_LABELS: Record = { deepseek: "DeepSeek Harness", qoder: "Qoder", antigravity: "Google Antigravity", + kiro: "Kiro CLI", } export const AGENT_COLORS: Record = { @@ -1298,6 +1302,7 @@ export const AGENT_COLORS: Record = { deepseek: "bg-[#4D6BFE]", qoder: "bg-[#6C4CF1]", antigravity: "bg-[#1A73E8]", + kiro: "bg-[#9046FF]", } // ACP connection status (matches Rust ConnectionStatus) @@ -3268,6 +3273,15 @@ export interface AcpAgentInfo { * most-reported confusion. */ is_acp_adapter: boolean + /** + * The vendor's install page for an agent codeg launches but never installs + * (`distribution_type === "system"`, e.g. Kiro CLI). Absent for every agent + * codeg installs itself. + */ + install_url?: string | null + /** Whether that install can be upgraded from Settings through the vendor's + * own updater (`acpUpdateSystemAgent`). */ + supports_self_update?: boolean /** * For custom agents, where the definition came from ("registry" | "manual"); * null for built-ins. A manual definition's registry_version is user-typed, @@ -3969,6 +3983,7 @@ export type McpAppType = | "deepseek" | "qoder" | "antigravity" + | "kiro" | "pi" export interface LocalMcpServer { From fc4952d46143657972984479aff20a17b9f76e31 Mon Sep 17 00:00:00 2001 From: keh4l <2461454684@qq.com> Date: Mon, 28 Sep 2026 03:54:07 +0800 Subject: [PATCH 2/3] fix(kiro): insert the reasoning selectors with a splice Clippy 1.98 (CI's stable) flags the hand-counted insert loop as explicit_counter_loop; 1.93 did not. --- src-tauri/src/acp/connection.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 79f2e5ef4a..21e73a1991 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -4383,17 +4383,16 @@ async fn apply_kiro_metadata( /// removed: which exist depends on the model. fn place_kiro_reasoning_selectors(opts: &mut Vec, reasoning: &KiroReasoning) { opts.retain(|o| o.id != KIRO_THINKING_OPTION_ID && o.id != KIRO_EFFORT_OPTION_ID); - let mut at = opts + let at = opts .iter() .position(|o| o.id == KIRO_MODEL_OPTION_ID) .map_or(opts.len(), |pos| pos + 1); - for option in [kiro_thinking_option(reasoning), kiro_effort_option(reasoning)] - .into_iter() - .flatten() - { - opts.insert(at, option); - at += 1; - } + opts.splice( + at..at, + [kiro_thinking_option(reasoning), kiro_effort_option(reasoning)] + .into_iter() + .flatten(), + ); } /// Rebuild the reasoning selectors in the emitted options from `reasoning`. From d4654f78459e436f620e1356aee1f73b8d26001a Mon Sep 17 00:00:00 2001 From: keh4l <2461454684@qq.com> Date: Thu, 1 Oct 2026 18:06:45 +0800 Subject: [PATCH 3/3] fix(kiro): date the built-in migration after upstream's newest Upstream added m20260929_000001_canvas_board and m20260930_000001_agent_setting_drop_adapter_channel, and released builds have already applied them. A migration dated before them would run out of order on those databases, so it moves to m20261001. --- src-tauri/src/acp/custom_registry.rs | 2 +- ...uiltin_agent.rs => m20261001_000001_kiro_builtin_agent.rs} | 0 src-tauri/src/db/migration/mod.rs | 4 ++-- 3 files changed, 3 insertions(+), 3 deletions(-) rename src-tauri/src/db/migration/{m20260927_000001_kiro_builtin_agent.rs => m20261001_000001_kiro_builtin_agent.rs} (100%) diff --git a/src-tauri/src/acp/custom_registry.rs b/src-tauri/src/acp/custom_registry.rs index cca5bd2081..4c80e6f70c 100644 --- a/src-tauri/src/acp/custom_registry.rs +++ b/src-tauri/src/acp/custom_registry.rs @@ -976,7 +976,7 @@ mod tests { // pre-integration `deepseek-acp` / `qoder-cli` / `antigravity-acp` / // `kiro-cli` custom entry (kept in the DB, never published) once the id // became a built-in. (Kiro's is also migrated away by - // `m20260927_000001_kiro_builtin_agent`, so this is the backstop for a + // `m20261001_000001_kiro_builtin_agent`, so this is the backstop for a // database that re-grows one.) #[test] fn rejects_builtin_registry_id_collisions() { diff --git a/src-tauri/src/db/migration/m20260927_000001_kiro_builtin_agent.rs b/src-tauri/src/db/migration/m20261001_000001_kiro_builtin_agent.rs similarity index 100% rename from src-tauri/src/db/migration/m20260927_000001_kiro_builtin_agent.rs rename to src-tauri/src/db/migration/m20261001_000001_kiro_builtin_agent.rs diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index da0b829d86..0e884f8db8 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -45,9 +45,9 @@ mod m20260829_000001_folder_group; mod m20260830_000001_canvas_node; mod m20260831_000001_canvas_node_group_grid; mod m20260907_000001_canvas_node_path; -mod m20260927_000001_kiro_builtin_agent; mod m20260929_000001_canvas_board; mod m20260930_000001_agent_setting_drop_adapter_channel; +mod m20261001_000001_kiro_builtin_agent; pub struct Migrator; #[async_trait::async_trait] @@ -99,9 +99,9 @@ impl MigratorTrait for Migrator { Box::new(m20260830_000001_canvas_node::Migration), Box::new(m20260831_000001_canvas_node_group_grid::Migration), Box::new(m20260907_000001_canvas_node_path::Migration), - Box::new(m20260927_000001_kiro_builtin_agent::Migration), Box::new(m20260929_000001_canvas_board::Migration), Box::new(m20260930_000001_agent_setting_drop_adapter_channel::Migration), + Box::new(m20261001_000001_kiro_builtin_agent::Migration), ] } }