diff --git a/README.md b/README.md index 9ee96d1101..ab45c7572f 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ - + @@ -101,7 +101,7 @@ ### One Interface for Every Agent -Fifteen agents come built in, and any other ACP agent can join from the public registry or its distribution JSON. Codeg speaks the Agent Client Protocol to every one of them, so they all get the same rich conversation — tool cards, live diffs, plans, and permission prompts — instead of a terminal to squint at. Enable UI Render for an agent and it can answer with interactive cards, tables, and charts. +Sixteen agents come built in, and any other ACP agent can join from the public registry or its distribution JSON. Codeg speaks the Agent Client Protocol to every one of them, so they all get the same rich conversation — tool cards, live diffs, plans, and permission prompts — instead of a terminal to squint at. Enable UI Render for an agent and it can answer with interactive cards, tables, and charts. [Docs →](https://docs.codeg.app/guide/supported-agents) · [Custom agents →](https://docs.codeg.app/guide/custom-agents) · [UI Render →](https://docs.codeg.app/guide/skills#cards-tables-and-charts-in-replies) @@ -267,7 +267,7 @@ An editor, live and side-by-side diffs, a full git client with a three-pane merg ## 🤖 Supported Agents -Codeg talks to every agent over the [Agent Client Protocol](https://agentclientprotocol.com), so each one gets the same structured interface. Fifteen come built in, and Codeg installs, pins, and updates most of them for you: +Codeg talks to every agent over the [Agent Client Protocol](https://agentclientprotocol.com), so each one gets the same structured interface. Sixteen come built in, and Codeg installs, pins, and updates most of them for you:

Claude Code   @@ -285,6 +285,7 @@ Codeg talks to every agent over the [Agent Client Protocol](https://agentclientp DeepSeek Harness   Qoder   Google Antigravity   + Kiro CLI   + any ACP agent

diff --git a/docs/images/agents/kiro.svg b/docs/images/agents/kiro.svg new file mode 100644 index 0000000000..1e5374ce25 --- /dev/null +++ b/docs/images/agents/kiro.svg @@ -0,0 +1 @@ +Kiro CLI diff --git a/docs/readme/README.ar.md b/docs/readme/README.ar.md index 4434459160..4295bad5d7 100644 --- a/docs/readme/README.ar.md +++ b/docs/readme/README.ar.md @@ -31,7 +31,7 @@
🧩 One interface for every agent
Fifteen agents built in, and any ACP agent can join — all rendered as the same structured conversation, not a terminal.
🧩 One interface for every agent
Sixteen agents built in, and any ACP agent can join — all rendered as the same structured conversation, not a terminal.
🔎 Sessions that travel
Import, search, and resume the history every agent keeps on disk, then hand it to a different agent.
- + @@ -101,7 +101,7 @@ ### واجهة واحدة لكل الوكلاء -يضم Codeg خمسة عشر وكيلاً مدمجًا، ويمكن لأي وكيل آخر متوافق مع ACP الانضمام من السجل العام أو عبر ملف distribution JSON الخاص به. ويتواصل Codeg مع كلٍّ منهم عبر Agent Client Protocol، فيحصلون جميعًا على المحادثة الغنية نفسها — بطاقات الأدوات، والفروقات الحيّة، والخطط، وطلبات الأذونات — بدلاً من طرفية تُضطر إلى التحديق فيها. فعّل «عرض الواجهة» لوكيلٍ ما، فيصبح قادرًا على الرد ببطاقات وجداول ورسوم بيانية تفاعلية. +يضم Codeg ستة عشر وكيلاً مدمجًا، ويمكن لأي وكيل آخر متوافق مع ACP الانضمام من السجل العام أو عبر ملف distribution JSON الخاص به. ويتواصل Codeg مع كلٍّ منهم عبر Agent Client Protocol، فيحصلون جميعًا على المحادثة الغنية نفسها — بطاقات الأدوات، والفروقات الحيّة، والخطط، وطلبات الأذونات — بدلاً من طرفية تُضطر إلى التحديق فيها. فعّل «عرض الواجهة» لوكيلٍ ما، فيصبح قادرًا على الرد ببطاقات وجداول ورسوم بيانية تفاعلية. [التوثيق ←](https://docs.codeg.app/guide/supported-agents) · [الوكلاء المخصّصون ←](https://docs.codeg.app/guide/custom-agents) · [عرض الواجهة ←](https://docs.codeg.app/guide/skills#cards-tables-and-charts-in-replies) @@ -267,7 +267,7 @@ ## 🤖 الوكلاء المدعومون -يتواصل Codeg مع كل وكيل عبر [Agent Client Protocol](https://agentclientprotocol.com)، فيحصل كلٌّ منهم على الواجهة المنظَّمة نفسها. ويأتي بخمسة عشر وكيلاً مدمجًا، يتولّى تثبيت معظمهم وتثبيت إصداراتهم وتحديثهم نيابةً عنك: +يتواصل Codeg مع كل وكيل عبر [Agent Client Protocol](https://agentclientprotocol.com)، فيحصل كلٌّ منهم على الواجهة المنظَّمة نفسها. ويأتي بستة عشر وكيلاً مدمجًا، يتولّى تثبيت معظمهم وتثبيت إصداراتهم وتحديثهم نيابةً عنك:

Claude Code   @@ -285,6 +285,7 @@ DeepSeek Harness   Qoder   Google Antigravity   + Kiro CLI   + أي وكيل ACP

diff --git a/docs/readme/README.de.md b/docs/readme/README.de.md index abad12486c..1f010ef76f 100644 --- a/docs/readme/README.de.md +++ b/docs/readme/README.de.md @@ -31,7 +31,7 @@
🧩 واجهة واحدة لكل الوكلاء
خمسة عشر وكيلاً مدمجًا، ويمكن لأي وكيل متوافق مع ACP الانضمام — ويُعرض عملهم جميعًا كمحادثة منظَّمة موحّدة، لا كطرفية.
🧩 واجهة واحدة لكل الوكلاء
ستة عشر وكيلاً مدمجًا، ويمكن لأي وكيل متوافق مع ACP الانضمام — ويُعرض عملهم جميعًا كمحادثة منظَّمة موحّدة، لا كطرفية.
🔎 جلسات تنتقل معك
استورد السجل الذي يحفظه كل وكيل على القرص، وابحث فيه، واستأنفه، ثم سلّمه إلى وكيل آخر.
- + @@ -101,7 +101,7 @@ ### Eine Oberfläche für alle Agenten -Fünfzehn Agenten sind eingebaut, und jeder weitere ACP-Agent kann aus der öffentlichen Registry oder über sein Distribution-JSON dazukommen. Codeg spricht mit jedem von ihnen über das Agent Client Protocol, sodass alle dieselbe reichhaltige Unterhaltung bekommen — Tool-Karten, Live-Diffs, Pläne und Berechtigungsanfragen — statt eines Terminals, vor dem du die Augen zusammenkneifst. Aktiviere UI-Rendering für einen Agenten, und er kann mit interaktiven Karten, Tabellen und Diagrammen antworten. +Sechzehn Agenten sind eingebaut, und jeder weitere ACP-Agent kann aus der öffentlichen Registry oder über sein Distribution-JSON dazukommen. Codeg spricht mit jedem von ihnen über das Agent Client Protocol, sodass alle dieselbe reichhaltige Unterhaltung bekommen — Tool-Karten, Live-Diffs, Pläne und Berechtigungsanfragen — statt eines Terminals, vor dem du die Augen zusammenkneifst. Aktiviere UI-Rendering für einen Agenten, und er kann mit interaktiven Karten, Tabellen und Diagrammen antworten. [Doku →](https://docs.codeg.app/guide/supported-agents) · [Eigene Agenten →](https://docs.codeg.app/guide/custom-agents) · [UI-Rendering →](https://docs.codeg.app/guide/skills#cards-tables-and-charts-in-replies) @@ -267,7 +267,7 @@ Ein Editor, Live- und Zwei-Spalten-Diffs, ein vollwertiger Git-Client mit dreisp ## 🤖 Unterstützte Agenten -Codeg spricht mit jedem Agenten über das [Agent Client Protocol](https://agentclientprotocol.com), sodass jeder dieselbe strukturierte Oberfläche bekommt. Fünfzehn sind eingebaut, und die meisten davon installiert, fixiert und aktualisiert Codeg für dich: +Codeg spricht mit jedem Agenten über das [Agent Client Protocol](https://agentclientprotocol.com), sodass jeder dieselbe strukturierte Oberfläche bekommt. Sechzehn sind eingebaut, und die meisten davon installiert, fixiert und aktualisiert Codeg für dich:

Claude Code   @@ -285,6 +285,7 @@ Codeg spricht mit jedem Agenten über das [Agent Client Protocol](https://agentc DeepSeek Harness   Qoder   Google Antigravity   + Kiro CLI   + jeder ACP-Agent

diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 617fcac63c..01277df003 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -31,7 +31,7 @@
🧩 Eine Oberfläche für alle Agenten
Fünfzehn Agenten sind eingebaut, und jeder ACP-Agent kann dazukommen — alle dargestellt als dieselbe strukturierte Unterhaltung, nicht als Terminal.
🧩 Eine Oberfläche für alle Agenten
Sechzehn Agenten sind eingebaut, und jeder ACP-Agent kann dazukommen — alle dargestellt als dieselbe strukturierte Unterhaltung, nicht als Terminal.
🔎 Sitzungen zum Mitnehmen
Importiere, durchsuche und setze die Historie fort, die jeder Agent auf der Festplatte ablegt, und übergib sie dann an einen anderen Agenten.
- + @@ -101,7 +101,7 @@ ### Una interfaz para todos los agentes -Quince agentes vienen integrados, y cualquier otro agente compatible con ACP puede sumarse desde el registro público o con su JSON de distribución. Codeg se comunica con cada uno de ellos mediante el Agent Client Protocol, así que todos reciben la misma conversación enriquecida —tarjetas de herramientas, diffs en vivo, planes y solicitudes de permiso— en lugar de una terminal en la que dejarse la vista. Activa Renderizado de UI para un agente y podrá responder con tarjetas, tablas y gráficos interactivos. +Dieciséis agentes vienen integrados, y cualquier otro agente compatible con ACP puede sumarse desde el registro público o con su JSON de distribución. Codeg se comunica con cada uno de ellos mediante el Agent Client Protocol, así que todos reciben la misma conversación enriquecida —tarjetas de herramientas, diffs en vivo, planes y solicitudes de permiso— en lugar de una terminal en la que dejarse la vista. Activa Renderizado de UI para un agente y podrá responder con tarjetas, tablas y gráficos interactivos. [Documentación →](https://docs.codeg.app/guide/supported-agents) · [Agentes personalizados →](https://docs.codeg.app/guide/custom-agents) · [Renderizado de UI →](https://docs.codeg.app/guide/skills#cards-tables-and-charts-in-replies) @@ -267,7 +267,7 @@ Un editor, diffs en vivo y lado a lado, un cliente de git completo con un editor ## 🤖 Agentes compatibles -Codeg se comunica con cada agente mediante el [Agent Client Protocol](https://agentclientprotocol.com), así que todos reciben la misma interfaz estructurada. Quince vienen integrados, y Codeg instala, fija la versión y actualiza la mayoría de ellos por ti: +Codeg se comunica con cada agente mediante el [Agent Client Protocol](https://agentclientprotocol.com), así que todos reciben la misma interfaz estructurada. Dieciséis vienen integrados, y Codeg instala, fija la versión y actualiza la mayoría de ellos por ti:

Claude Code   @@ -285,6 +285,7 @@ Codeg se comunica con cada agente mediante el [Agent Client Protocol](https://ag DeepSeek Harness   Qoder   Google Antigravity   + Kiro CLI   + cualquier agente ACP

diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index a0ff46a088..6575d65685 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -31,7 +31,7 @@
🧩 Una interfaz para todos los agentes
Quince agentes integrados, y cualquier agente compatible con ACP puede sumarse: todos se presentan como la misma conversación estructurada, no como una terminal.
🧩 Una interfaz para todos los agentes
Dieciséis agentes integrados, y cualquier agente compatible con ACP puede sumarse: todos se presentan como la misma conversación estructurada, no como una terminal.
🔎 Sesiones que viajan
Importa, busca y retoma el historial que cada agente guarda en disco, y luego pásaselo a otro agente.
- + @@ -101,7 +101,7 @@ ### Une interface pour tous les agents -Quinze agents sont intégrés, et tout autre agent ACP peut les rejoindre depuis le registre public ou via son JSON de distribution. Codeg parle l'Agent Client Protocol avec chacun d'eux : tous bénéficient donc de la même conversation riche — cartes d'outils, diffs en direct, plans et demandes d'autorisation — au lieu d'un terminal à déchiffrer en plissant les yeux. Activez le Rendu d'interface pour un agent et il pourra répondre avec des cartes, des tableaux et des graphiques interactifs. +Seize agents sont intégrés, et tout autre agent ACP peut les rejoindre depuis le registre public ou via son JSON de distribution. Codeg parle l'Agent Client Protocol avec chacun d'eux : tous bénéficient donc de la même conversation riche — cartes d'outils, diffs en direct, plans et demandes d'autorisation — au lieu d'un terminal à déchiffrer en plissant les yeux. Activez le Rendu d'interface pour un agent et il pourra répondre avec des cartes, des tableaux et des graphiques interactifs. [Documentation →](https://docs.codeg.app/guide/supported-agents) · [Agents personnalisés →](https://docs.codeg.app/guide/custom-agents) · [Rendu d'interface →](https://docs.codeg.app/guide/skills#cards-tables-and-charts-in-replies) @@ -267,7 +267,7 @@ Un éditeur, des diffs en direct et côte à côte, un client git complet avec u ## 🤖 Agents pris en charge -Codeg communique avec chaque agent via l'[Agent Client Protocol](https://agentclientprotocol.com) : chacun bénéficie ainsi de la même interface structurée. Quinze sont intégrés, et Codeg installe, épingle et met à jour la plupart d'entre eux pour vous : +Codeg communique avec chaque agent via l'[Agent Client Protocol](https://agentclientprotocol.com) : chacun bénéficie ainsi de la même interface structurée. Seize sont intégrés, et Codeg installe, épingle et met à jour la plupart d'entre eux pour vous :

Claude Code   @@ -285,6 +285,7 @@ Codeg communique avec chaque agent via l'[Agent Client Protocol](https://agentcl DeepSeek Harness   Qoder   Google Antigravity   + Kiro CLI   + tout agent ACP

diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index 73832b32cb..30faad3b5d 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -31,7 +31,7 @@
🧩 Une interface pour tous les agents
Quinze agents intégrés, et tout agent ACP peut s'y ajouter — tous affichés sous la même forme de conversation structurée, pas dans un terminal.
🧩 Une interface pour tous les agents
Seize agents intégrés, et tout agent ACP peut s'y ajouter — tous affichés sous la même forme de conversation structurée, pas dans un terminal.
🔎 Des sessions qui voyagent
Importez, fouillez et reprenez l'historique que chaque agent conserve sur le disque, puis confiez-le à un autre agent.
- + @@ -101,7 +101,7 @@ ### すべてのエージェントを、ひとつのインターフェースで -15 種のエージェントを内蔵し、ほかの ACP エージェントも公開レジストリまたはその distribution JSON から加えられます。Codeg はそのすべてと Agent Client Protocol で通信するので、どれも目を凝らして読むターミナルではなく、同じリッチな会話 — ツールカード、ライブ diff、計画、権限の確認 — として表示されます。エージェントで UI レンダリングを有効にすると、インタラクティブなカードや表、グラフで答えられるようになります。 +16 種のエージェントを内蔵し、ほかの ACP エージェントも公開レジストリまたはその distribution JSON から加えられます。Codeg はそのすべてと Agent Client Protocol で通信するので、どれも目を凝らして読むターミナルではなく、同じリッチな会話 — ツールカード、ライブ diff、計画、権限の確認 — として表示されます。エージェントで UI レンダリングを有効にすると、インタラクティブなカードや表、グラフで答えられるようになります。 [ドキュメント →](https://docs.codeg.app/guide/supported-agents) · [カスタムエージェント →](https://docs.codeg.app/guide/custom-agents) · [UI レンダリング →](https://docs.codeg.app/guide/skills#cards-tables-and-charts-in-replies) @@ -267,7 +267,7 @@ Codeg はデスクトップアプリとしても、任意のブラウザで開 ## 🤖 対応エージェント -Codeg はすべてのエージェントと [Agent Client Protocol](https://agentclientprotocol.com) で通信するため、どのエージェントにも同じ構造化されたインターフェースが用意されます。15 種を内蔵し、その多くは Codeg がインストール・バージョン固定・更新まで面倒を見ます: +Codeg はすべてのエージェントと [Agent Client Protocol](https://agentclientprotocol.com) で通信するため、どのエージェントにも同じ構造化されたインターフェースが用意されます。16 種を内蔵し、その多くは Codeg がインストール・バージョン固定・更新まで面倒を見ます:

Claude Code   @@ -285,6 +285,7 @@ Codeg はすべてのエージェントと [Agent Client Protocol](https://agent DeepSeek Harness   Qoder   Google Antigravity   + Kiro CLI   + 任意の ACP エージェント

diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 56d716ed05..83c727bb8b 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -31,7 +31,7 @@
🧩 すべてのエージェントを、ひとつのインターフェースで
15 種のエージェントを内蔵し、ACP エージェントならどれでも加えられます — いずれもターミナルではなく、同じ構造化された会話として表示されます。
🧩 すべてのエージェントを、ひとつのインターフェースで
16 種のエージェントを内蔵し、ACP エージェントならどれでも加えられます — いずれもターミナルではなく、同じ構造化された会話として表示されます。
🔎 引き継げるセッション
各エージェントがディスクに残す履歴をインポート・検索・再開でき、さらに別のエージェントへ引き継げます。
- + @@ -101,7 +101,7 @@ ### 모든 에이전트를 하나의 인터페이스로 -열다섯 개의 에이전트가 기본 내장되어 있고, 그 밖의 ACP 에이전트도 공개 레지스트리나 자체 distribution JSON으로 합류할 수 있습니다. Codeg는 이들 모두와 Agent Client Protocol로 통신하므로, 눈을 가늘게 뜨고 들여다봐야 하는 터미널 대신 모두 같은 풍부한 대화 화면을 갖게 됩니다 — 도구 카드, 실시간 diff, 계획, 권한 요청까지. 에이전트에 UI 렌더링을 켜면 대화형 카드, 표, 차트로도 답할 수 있습니다. +열여섯 개의 에이전트가 기본 내장되어 있고, 그 밖의 ACP 에이전트도 공개 레지스트리나 자체 distribution JSON으로 합류할 수 있습니다. Codeg는 이들 모두와 Agent Client Protocol로 통신하므로, 눈을 가늘게 뜨고 들여다봐야 하는 터미널 대신 모두 같은 풍부한 대화 화면을 갖게 됩니다 — 도구 카드, 실시간 diff, 계획, 권한 요청까지. 에이전트에 UI 렌더링을 켜면 대화형 카드, 표, 차트로도 답할 수 있습니다. [문서 →](https://docs.codeg.app/guide/supported-agents) · [커스텀 에이전트 →](https://docs.codeg.app/guide/custom-agents) · [UI 렌더링 →](https://docs.codeg.app/guide/skills#cards-tables-and-charts-in-replies) @@ -267,7 +267,7 @@ Codeg를 데스크톱 앱으로, 어떤 브라우저에서든 여는 자체 호 ## 🤖 지원 에이전트 -Codeg는 모든 에이전트와 [Agent Client Protocol](https://agentclientprotocol.com)로 통신하므로, 에이전트마다 같은 구조화된 인터페이스를 갖습니다. 열다섯 개가 기본 내장되어 있으며, 그중 대부분은 Codeg가 대신 설치하고, 버전을 고정하고, 업데이트합니다: +Codeg는 모든 에이전트와 [Agent Client Protocol](https://agentclientprotocol.com)로 통신하므로, 에이전트마다 같은 구조화된 인터페이스를 갖습니다. 열여섯 개가 기본 내장되어 있으며, 그중 대부분은 Codeg가 대신 설치하고, 버전을 고정하고, 업데이트합니다:

Claude Code   @@ -285,6 +285,7 @@ Codeg는 모든 에이전트와 [Agent Client Protocol](https://agentclientproto DeepSeek Harness   Qoder   Google Antigravity   + Kiro CLI   + 모든 ACP 에이전트

diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index 1ca2f1c356..23aa5ae033 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -31,7 +31,7 @@
🧩 모든 에이전트를 하나의 인터페이스로
열다섯 개의 에이전트가 기본 내장되어 있고, ACP 에이전트라면 무엇이든 합류할 수 있습니다 — 모두 터미널이 아닌, 같은 구조화된 대화로 표시됩니다.
🧩 모든 에이전트를 하나의 인터페이스로
열여섯 개의 에이전트가 기본 내장되어 있고, ACP 에이전트라면 무엇이든 합류할 수 있습니다 — 모두 터미널이 아닌, 같은 구조화된 대화로 표시됩니다.
🔎 에이전트를 넘나드는 세션
에이전트마다 디스크에 남겨 두는 기록을 가져와 검색하고 이어서 진행한 다음, 다른 에이전트에게 넘겨주세요.
- + @@ -101,7 +101,7 @@ ### Uma interface para todos os agentes -Quinze agentes já vêm integrados, e qualquer outro agente compatível com ACP pode se juntar a eles pelo registro público ou pelo JSON de distribuição dele. O Codeg fala com cada um deles pelo Agent Client Protocol, então todos ganham a mesma conversa rica — cartões de ferramentas, diffs ao vivo, planos e pedidos de permissão — em vez de um terminal para você forçar a vista. Ative a Renderização de UI para um agente e ele poderá responder com cartões, tabelas e gráficos interativos. +Dezesseis agentes já vêm integrados, e qualquer outro agente compatível com ACP pode se juntar a eles pelo registro público ou pelo JSON de distribuição dele. O Codeg fala com cada um deles pelo Agent Client Protocol, então todos ganham a mesma conversa rica — cartões de ferramentas, diffs ao vivo, planos e pedidos de permissão — em vez de um terminal para você forçar a vista. Ative a Renderização de UI para um agente e ele poderá responder com cartões, tabelas e gráficos interativos. [Documentação →](https://docs.codeg.app/guide/supported-agents) · [Agentes personalizados →](https://docs.codeg.app/guide/custom-agents) · [Renderização de UI →](https://docs.codeg.app/guide/skills#cards-tables-and-charts-in-replies) @@ -267,7 +267,7 @@ Um editor, diffs ao vivo e lado a lado, um cliente git completo com um editor de ## 🤖 Agentes suportados -O Codeg conversa com todos os agentes pelo [Agent Client Protocol](https://agentclientprotocol.com), então cada um ganha a mesma interface estruturada. Quinze já vêm integrados, e o Codeg instala, fixa a versão e atualiza a maioria deles por você: +O Codeg conversa com todos os agentes pelo [Agent Client Protocol](https://agentclientprotocol.com), então cada um ganha a mesma interface estruturada. Dezesseis já vêm integrados, e o Codeg instala, fixa a versão e atualiza a maioria deles por você:

Claude Code   @@ -285,6 +285,7 @@ O Codeg conversa com todos os agentes pelo [Agent Client Protocol](https://agent DeepSeek Harness   Qoder   Google Antigravity   + Kiro CLI   + qualquer agente ACP

diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index d3d5e88513..c8e5161eee 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -31,7 +31,7 @@
🧩 Uma interface para todos os agentes
Quinze agentes integrados, e qualquer agente compatível com ACP pode entrar — todos exibidos como a mesma conversa estruturada, e não como um terminal.
🧩 Uma interface para todos os agentes
Dezesseis agentes integrados, e qualquer agente compatível com ACP pode entrar — todos exibidos como a mesma conversa estruturada, e não como um terminal.
🔎 Sessões que viajam
Importe, pesquise e retome o histórico que cada agente guarda em disco e depois entregue-o a outro agente.
- + @@ -101,7 +101,7 @@ ### 一个界面,容纳所有智能体 -内置十五个智能体,其它兼容 ACP 的智能体也能从公开注册表或它的 distribution JSON 加入。Codeg 与每一个智能体都用 Agent Client Protocol 对话,所以它们拿到的是同一套丰富的对话界面——工具卡片、实时 diff、计划与权限确认——而不是一个得眯着眼看的终端。为智能体启用「界面渲染」后,它还能用可交互的卡片、表格和图表来回复。 +内置十六个智能体,其它兼容 ACP 的智能体也能从公开注册表或它的 distribution JSON 加入。Codeg 与每一个智能体都用 Agent Client Protocol 对话,所以它们拿到的是同一套丰富的对话界面——工具卡片、实时 diff、计划与权限确认——而不是一个得眯着眼看的终端。为智能体启用「界面渲染」后,它还能用可交互的卡片、表格和图表来回复。 [文档 →](https://docs.codeg.app/zh/guide/supported-agents) · [自定义智能体 →](https://docs.codeg.app/zh/guide/custom-agents) · [界面渲染 →](https://docs.codeg.app/zh/guide/skills#cards-tables-and-charts-in-replies) @@ -267,7 +267,7 @@ Codeg 可以作为桌面应用运行,也可以作为用任意浏览器访问 ## 🤖 支持的智能体 -Codeg 通过 [Agent Client Protocol](https://agentclientprotocol.com) 与每个智能体对话,所以它们拿到的都是同一套结构化界面。内置十五个,其中大部分 Codeg 都能替你安装、锁定版本并更新: +Codeg 通过 [Agent Client Protocol](https://agentclientprotocol.com) 与每个智能体对话,所以它们拿到的都是同一套结构化界面。内置十六个,其中大部分 Codeg 都能替你安装、锁定版本并更新:

Claude Code   @@ -285,6 +285,7 @@ Codeg 通过 [Agent Client Protocol](https://agentclientprotocol.com) 与每个 DeepSeek Harness   Qoder   Google Antigravity   + Kiro CLI   + 任意 ACP 智能体

diff --git a/docs/readme/README.zh-TW.md b/docs/readme/README.zh-TW.md index 5c6d91e9b9..620f716bc9 100644 --- a/docs/readme/README.zh-TW.md +++ b/docs/readme/README.zh-TW.md @@ -31,7 +31,7 @@
🧩 一个界面,容纳所有智能体
内置十五个智能体,任何兼容 ACP 的智能体都能加入——全部呈现为同一套结构化对话,而不是一个终端。
🧩 一个界面,容纳所有智能体
内置十六个智能体,任何兼容 ACP 的智能体都能加入——全部呈现为同一套结构化对话,而不是一个终端。
🔎 会话随你流转
导入、搜索、续接每个智能体留在磁盘上的历史,还能把它交给另一个智能体接着做。
- + @@ -101,7 +101,7 @@ ### 一個介面,容納所有智慧體 -內建十五個智慧體,任何其他相容 ACP 的智慧體也能從公開註冊表或它的 distribution JSON 加入。Codeg 透過 Agent Client Protocol 與每一個智慧體溝通,所以它們拿到的都是同一套豐富的對話介面——工具卡片、即時 diff、計畫與權限請求——而不是一個得瞇著眼看的終端機。為智慧體啟用「介面渲染」後,它還能用可互動的卡片、表格與圖表來回覆。 +內建十六個智慧體,任何其他相容 ACP 的智慧體也能從公開註冊表或它的 distribution JSON 加入。Codeg 透過 Agent Client Protocol 與每一個智慧體溝通,所以它們拿到的都是同一套豐富的對話介面——工具卡片、即時 diff、計畫與權限請求——而不是一個得瞇著眼看的終端機。為智慧體啟用「介面渲染」後,它還能用可互動的卡片、表格與圖表來回覆。 [文件 →](https://docs.codeg.app/zh/guide/supported-agents) · [自訂智慧體 →](https://docs.codeg.app/zh/guide/custom-agents) · [介面渲染 →](https://docs.codeg.app/zh/guide/skills#cards-tables-and-charts-in-replies) @@ -267,7 +267,7 @@ Codeg 可以作為桌面應用、可用任何瀏覽器開啟的自架伺服器 ## 🤖 支援的智慧體 -Codeg 透過 [Agent Client Protocol](https://agentclientprotocol.com) 與每個智慧體溝通,所以每一個都能用上同一套結構化介面。內建十五個,其中大部分 Codeg 都能替你安裝、鎖定版本並更新: +Codeg 透過 [Agent Client Protocol](https://agentclientprotocol.com) 與每個智慧體溝通,所以每一個都能用上同一套結構化介面。內建十六個,其中大部分 Codeg 都能替你安裝、鎖定版本並更新:

Claude Code   @@ -285,6 +285,7 @@ Codeg 透過 [Agent Client Protocol](https://agentclientprotocol.com) 與每個 DeepSeek Harness   Qoder   Google Antigravity   + Kiro CLI   + 任何 ACP 智慧體

diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index d98f812446..88e5d33bea 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; @@ -58,11 +59,12 @@ use crate::acp::terminal_runtime::{ }; use crate::acp::types::{ AcpEvent, AsyncTaskDelta, AsyncTaskUsage, AvailableCommandInfo, ConnectionInfo, - ConnectionStatus, GrokModelCatalog, GrokModelSpec, PermissionOptionInfo, PlanEntryInfo, - PluginLoadFailure, PromptCapabilitiesInfo, PromptInputBlock, SessionConfigBooleanInfo, - SessionConfigKindInfo, SessionConfigOptionInfo, SessionConfigSelectGroupInfo, - SessionConfigSelectInfo, SessionConfigSelectOptionInfo, SessionFailureRecord, SessionModeInfo, - SessionModeStateInfo, SessionNotice, ToolCallImageInfo, UserMessageBlock, + ConnectionStatus, GrokModelCatalog, GrokModelSpec, KiroReasoning, PermissionOptionInfo, + PlanEntryInfo, PluginLoadFailure, PromptCapabilitiesInfo, PromptInputBlock, + SessionConfigBooleanInfo, SessionConfigKindInfo, SessionConfigOptionInfo, + SessionConfigSelectGroupInfo, SessionConfigSelectInfo, SessionConfigSelectOptionInfo, + SessionFailureRecord, SessionModeInfo, SessionModeStateInfo, SessionNotice, ToolCallImageInfo, + UserMessageBlock, }; use crate::logging::throttle::LeadingEdgeThrottle; use crate::models::agent::AgentType; @@ -258,6 +260,16 @@ pub(crate) fn cursor_force_enabled(value: Option<&str>) -> bool { value == "1" || value.eq_ignore_ascii_case("true") } +/// 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) +} + /// Cursor subscription-mode launch policy. When the user picked the official /// subscription (browser login), guarantee the launched CLI sees NONE of the /// custom-endpoint credentials — not even a stale `CURSOR_API_KEY` / @@ -2296,6 +2308,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 @@ -4168,599 +4250,1317 @@ 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(); + } +} + +/// Edit the session's emitted selectors and re-emit them, as ONE step against +/// every other writer of that list. +/// +/// Kiro's selectors have two writers that run concurrently: the conversation +/// loop answering a pick (`set_kiro_config_option`) and the builder-chain +/// handler for `_kiro.dev/metadata`, which Kiro sends right around the replies +/// the loop waits on. Each edits a copy — the emit helpers take the state's +/// write lock themselves (see `emit_grok_incompatible_agent_switch`), so the +/// copy is made first — and a copy made before the other writer's emit could +/// otherwise be emitted after it: a model switch reverting in its selector, +/// or an effort selector Kiro just withdrew coming back. The emit is gated on +/// the list and the reasoning state still being the ones the copy was made +/// from; when either moved, the edit is redone on the newer ones. `edit` +/// returns whether there is anything to emit. +async fn edit_emitted_options( + state: &Arc>, + emitter: &EventEmitter, + mut edit: impl FnMut(&mut Vec, Option<&KiroReasoning>) -> bool, ) { - // 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 - // per-model effort only at birth, never on set_model). - if let Some(pref) = preferred_config_values.get(GROK_MODEL_OPTION_ID).cloned() { - // Split the eligibility read (immutable) from the rebuild (mutable) so we - // never hold a `&mut opts` borrow across `set_grok_effort_selector_for_model`. - let eligible = opts - .iter() - .find(|o| o.id == GROK_MODEL_OPTION_ID) - .is_some_and(|o| { - // Grok's selectors are synthesized as selects; any other kind - // has no model value to compare against. - let SessionConfigKindInfo::Select(sel) = &o.kind else { - return false; - }; - // Skip if already current, or the saved model is no longer offered. - sel.current_value != pref && sel.options.iter().any(|x| x.value == pref) - }); - if eligible { - match set_grok_model(cx, session_id, pref.clone(), None).await { + loop { + let (base, reasoning) = { + let guard = state.read().await; + (guard.config_options.clone(), guard.kiro_reasoning.clone()) + }; + let Some(base) = base else { + return; + }; + let mut next = base.clone(); + if !edit(&mut next, reasoning.as_ref()) { + return; + } + let emitted = emit_with_state_gated( + state, + emitter, + AcpEvent::SessionConfigOptions { + config_options: next, + }, + |s| s.config_options.as_ref() == Some(&base) && s.kiro_reasoning == reasoning, + ) + .await; + if emitted { + return; + } + } +} + +/// Update one selector of the session's emitted options and re-emit them. +async fn update_emitted_option( + state: &Arc>, + emitter: &EventEmitter, + id: &str, + value: &str, +) { + edit_emitted_options(state, emitter, |opts, _| { + if !opts.iter().any(|o| o.id == id) { + return false; + } + set_select_current(opts, id, value); + true + }) + .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) + }) + }; + // Whether the saved model replaced the one the session started on. The + // reasoning state known after that still describes the OLD model — Kiro + // reports the new one's in a later `_kiro.dev/metadata`, and nothing in + // that notification says which model it is about — so it cannot rule a + // saved value out: checked against it, an effort the old model lacks (the + // default `auto` takes none) would be skipped for good, since no later + // metadata re-applies a saved value. + let mut model_switched = false; + 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(()) => { - if let Some(SessionConfigKindInfo::Select(sel)) = opts - .iter_mut() - .find(|o| o.id == GROK_MODEL_OPTION_ID) - .map(|o| &mut o.kind) - { - sel.current_value = pref.clone(); - } - if !specs.is_empty() { - set_grok_effort_selector_for_model(opts, &pref, specs); + set_select_current(opts, KIRO_MODEL_OPTION_ID, pref); + model_switched = true; + } + 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. After a + // model switch nothing known rules anything out (see `model_switched`). + if let Some(enabled) = preferred_config_values + .get(KIRO_THINKING_OPTION_ID) + .and_then(|pref| kiro_thinking_value(pref)) + { + let known = if model_switched { + None + } else { + 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 grok model '{pref}' on connect: {e}" - ), + Err(e) => tracing::error!("[ACP] failed to apply preferred kiro thinking: {e}"), } } } - // Effort preference — re-applied on top of the (possibly just-switched) - // current model. The effort selector was rebuilt above for that model, so an - // unsupported model (no selector) or an unoffered value is skipped here. - if let Some(pref) = preferred_config_values.get(GROK_EFFORT_OPTION_ID) { - let model_id = current_grok_model_id_from_opts(opts); - if let Some(SessionConfigKindInfo::Select(sel)) = opts - .iter_mut() - .find(|o| o.id == GROK_EFFORT_OPTION_ID) - .map(|o| &mut o.kind) - { - if &sel.current_value != pref && sel.options.iter().any(|o| &o.value == pref) { - if let Some(model_id) = model_id { - match set_grok_model(cx, session_id, model_id, Some(pref.clone())).await { - Ok(()) => sel.current_value = pref.clone(), - Err(e) => tracing::error!( - "[ACP] failed to apply preferred grok effort '{pref}' on connect: {e}" - ), + if let Some(pref) = preferred_config_values.get(KIRO_EFFORT_OPTION_ID) { + let known = if model_switched { + None + } else { + 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}"), } } } } -/// The Grok model selector's current value, read from an in-memory options list. -fn current_grok_model_id_from_opts(opts: &[SessionConfigOptionInfo]) -> Option { - opts.iter() - .find(|o| o.id == GROK_MODEL_OPTION_ID) - .and_then(|o| { - let SessionConfigKindInfo::Select(sel) = &o.kind else { - return None; - }; - Some(sel.current_value.clone()) - }) +/// 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); + } + rebuild_kiro_reasoning_selectors(state, emitter).await; } -/// The Grok model selector's current value, read from the authoritative -/// `SessionState.config_options` snapshot — needed to carry a reasoning-effort -/// override on `session/set_model` (effort is applied relative to a model). -async fn current_grok_model_id(state: &Arc>) -> Option { - let opts = state.read().await.config_options.clone()?; - current_grok_model_id_from_opts(&opts) +/// 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 at = opts + .iter() + .position(|o| o.id == KIRO_MODEL_OPTION_ID) + .map_or(opts.len(), |pos| pos + 1); + opts.splice( + at..at, + [kiro_thinking_option(reasoning), kiro_effort_option(reasoning)] + .into_iter() + .flatten(), + ); } -/// Context window for the session's CURRENT Grok model. One state read, done at -/// turn start — the answer only changes on a model switch, which cannot happen -/// mid-turn. -/// -/// Resolution order mirrors `parsers::grok::build_detail` so the live ring and -/// the re-parsed history never disagree about the denominator: what Grok -/// reported for the model at session establishment, then its on-disk catalog -/// (which is where a BYO endpoint's declared window lives — that one is often -/// absent from the wire), then the id-shaped heuristic. -/// -/// `None` is a real outcome, not just "no model selector": a BYO endpoint is -/// keyed by whatever id the user typed (`[model.]` holds the upstream -/// model's name), so an id like `deepseek-chat` can miss the wire, the catalog, -/// and every heuristic family at once. [`grok_window_change_usage`] is what -/// keeps that from stranding a previous model's window on screen. -async fn grok_current_model_context_window(state: &Arc>) -> Option { - let (model, reported) = { - let st = state.read().await; - let model = current_grok_model_id_from_opts(st.config_options.as_deref()?)?; - let reported = st - .grok_model_specs - .as_ref() - .and_then(|specs| specs.get(&model)) - .and_then(|spec| spec.context_window); - (model, reported) - }; - // Lock released: the catalog lookup below touches the filesystem. - reported.or_else(|| { - grok_offline_context_window(&model, &crate::parsers::grok::resolve_grok_home_dir()) +/// Rebuild the reasoning selectors in the emitted options from the session's +/// latest reasoning state. A no-op until codeg has emitted Kiro's own +/// selectors, and while Kiro has reported no reasoning state. +async fn rebuild_kiro_reasoning_selectors( + state: &Arc>, + emitter: &EventEmitter, +) { + edit_emitted_options(state, emitter, |opts, reasoning| { + let Some(reasoning) = reasoning else { + return false; + }; + // Only a session whose selectors codeg built itself has an effort slot. + if !opts.iter().any(|o| o.id == KIRO_MODEL_OPTION_ID) { + return false; + } + place_kiro_reasoning_selectors(opts, reasoning); + true }) + .await; } -/// The window for `model` when Grok didn't report one on the wire: its on-disk -/// catalog first, then the id-shaped heuristic. Split out of -/// [`grok_current_model_context_window`] so the resolution order is testable -/// against a fixture home instead of the host's real `~/.grok`. -fn grok_offline_context_window(model: &str, grok_home: &Path) -> Option { - crate::parsers::grok::grok_catalog_context_window(grok_home, model) - .or_else(|| crate::parsers::infer_context_window_max_tokens(Some(model))) +/// 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, + } } -/// The live pair to re-emit at turn start because the DENOMINATOR moved — the -/// user switched model between turns — or `None` when nothing needs re-keying. -/// -/// Two cases, and the second is why this exists at all: -/// * the new model has a window → carry the cumulative count forward onto it, -/// so the ring doesn't keep dividing by the previous model's window until -/// this turn's first token-bearing update; -/// * the new model has NO resolvable window (a BYO id that the wire, the -/// catalog and the heuristic all decline to size) → hand the denominator -/// back with `size: 0`, the frontend's own "unknown window" sentinel -/// (`rawLiveSize > 0 ? … : null`), which drops the ring to the re-parsed -/// session stats. This is what stops the previous model's window from -/// surviving indefinitely on a turn that never reports a token count. -/// -/// Note `size: 0` and not `used: 0` — the frontend's `USAGE_UPDATE` reducer -/// *drops* a zero-`used` update whenever it already holds a positive one (a rule -/// that exists for Claude Code's synthetic `/context` responses), so clearing -/// via zeros would strand every attached client on the stale value while the -/// backend snapshot moved on. -fn grok_window_change_usage(window: Option, last: Option<(u64, u64)>) -> Option<(u64, u64)> { - let (used, last_size) = last?; - let size = window.unwrap_or(0); - (size != last_size).then_some((used, size)) +/// 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(()) } -/// The `(used, size)` a Grok update moves the context ring to, or `None` when -/// there is nothing new to report. -/// -/// Grok emits no ACP `usage_update` at all; it reports a CUMULATIVE token count -/// for the session in the OUTER `params._meta.totalTokens` of ordinary -/// `session/update` notifications (the same number -/// `parsers::grok::GrokTurnMeta` folds into each turn's `usage.input_tokens`). -/// That count IS the context "used", so pairing it with the model's window fills -/// the composer ring live — previously it only moved once a turn had been -/// written to disk and re-parsed. -/// -/// `last` suppresses the repeats: the count rides nearly every chunk, but steps -/// only a handful of times per turn. It keys on the WINDOW too, so a model -/// switch still re-emits while the count sits still. -/// -/// An unresolvable window reports `size: 0` rather than going silent — the -/// frontend reads that as "unknown window" and sizes the ring from the re-parsed -/// session stats, while the live count still tracks. Going silent instead would -/// freeze whatever pair was last emitted (see [`grok_window_change_usage`]). -fn grok_live_usage_step( - dispatch: &Dispatch, - agent_type: AgentType, - context_window: Option, - last: Option<(u64, u64)>, -) -> Option<(u64, u64)> { - if agent_type != AgentType::Grok { - return None; +/// 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 }); } - let size = context_window.unwrap_or(0); - let Dispatch::Notification(notification) = dispatch else { - return None; - }; - let used = notification - .params - .get("_meta")? - .get("totalTokens")? - .as_u64() - .filter(|used| *used > 0)?; - (Some((used, size)) != last).then_some((used, size)) + params } -/// Route a composer config-option change for Grok. Both live selectors go -/// through `session/set_model`: the model selector switches the model, and the -/// reasoning-effort selector re-sends the current model with an -/// `_meta.reasoningEffort` override (the `~/.grok/config.toml` -/// `default_reasoning_effort` stays the at-birth global default). Re-emits the -/// options with the new `current_value` so the backend snapshot stays authoritative. -/// -/// A cross-agent-type switch rejected on an established conversation -/// (`is_grok_incompatible_agent_switch`) is handled in-band: re-emit the -/// authoritative options to revert the composer's optimistic pick and surface a -/// friendly, recoverable `AcpEvent::Error` (localized by the frontend via -/// `GROK_INCOMPATIBLE_AGENT_ERROR_CODE`), returning `Ok` so the caller does not -/// also emit the raw JSON-RPC error. The saved model preference is left intact, -/// so the suggested "start a new session" actually lands on the picked model -/// (a fresh session applies the preference pre-turn, where the switch succeeds). -async fn set_grok_config_option( +/// 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, - state: &Arc>, - emitter: &EventEmitter, - config_id: String, - value_id: String, -) -> Result<(), agent_client_protocol::Error> { - // Resolve the `set_model` args for whichever selector changed. A model pick - // is the model itself (no effort override); an effort pick re-sends the - // current model carrying the new `_meta.reasoningEffort`. Any other id is a - // no-op (defensive — the composer only offers these two). - let (model_id, effort) = if config_id == GROK_MODEL_OPTION_ID { - (value_id.clone(), None) - } else if config_id == GROK_EFFORT_OPTION_ID { - match current_grok_model_id(state).await { - Some(model_id) => (model_id, Some(value_id.clone())), - // No model known yet — nothing to carry the effort override on. - None => return Ok(()), - } - } else { - return Ok(()); - }; - match set_grok_model(cx, session_id, model_id, effort).await { - Ok(()) => { - // Built under the emit's own lock, from the picker as it is now: a - // model catalog broadcast refreshes it from the connection's - // dispatch task, and re-emitting a list read before that would - // undo the refresh (see `GrokModelCatalogBroadcasts`). - emit_with_state_built(state, emitter, |s| { - let mut opts = s.config_options.clone()?; - if let Some(SessionConfigKindInfo::Select(sel)) = opts - .iter_mut() - .find(|o| o.id == config_id) - .map(|o| &mut o.kind) - { - sel.current_value = value_id.clone(); - } - // A MODEL switch must re-point the effort selector at the new - // model — grok never re-sends per-model effort data on - // set_model. An EFFORT change leaves the list shape alone; no - // specs ⇒ leave as-is (flat-fallback session). - if config_id == GROK_MODEL_OPTION_ID { - if let Some(specs) = &s.grok_model_specs { - set_grok_effort_selector_for_model(&mut opts, &value_id, specs); - } - } - Some(AcpEvent::SessionConfigOptions { - config_options: opts, - }) - }) - .await; - Ok(()) - } - Err(e) if is_grok_incompatible_agent_switch(&e) => { - emit_grok_incompatible_agent_switch(state, emitter).await; - Ok(()) - } - Err(e) => Err(e), - } + 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) } -/// Recover from a Grok cross-agent-type model-switch rejection: revert the -/// composer's optimistic selection by re-emitting the authoritative (unchanged) -/// options, then surface a friendly, recoverable error the frontend localizes -/// via `GROK_INCOMPATIBLE_AGENT_ERROR_CODE`. Split out of `set_grok_config_option` -/// so it can be unit-tested without a live ACP connection. -async fn emit_grok_incompatible_agent_switch( - state: &Arc>, - emitter: &EventEmitter, -) { - // Re-emit the options as they stand, read under the emit's own write lock: - // a separate read first would race a model catalog broadcast refreshing the - // picker in between, and re-emit the list from before it. `current_value` - // is unchanged because the switch never took effect. - emit_with_state_built(state, emitter, |s| { - s.config_options - .clone() - .map(|config_options| AcpEvent::SessionConfigOptions { config_options }) +/// 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" } }, }) - .await; - emit_with_state( - state, - emitter, - AcpEvent::Error { - message: "Cannot switch to that model in an existing conversation. \ - Start a new session to use it." - .to_string(), - agent_type: AgentType::Grok.to_string(), - code: Some(GROK_INCOMPATIBLE_AGENT_ERROR_CODE.to_string()), - details: None, - // Recoverable: the conversation continues on its current model. - terminal: false, - }, - ) - .await; } -/// Emit the composer's session config-option selectors. For Grok this reads the -/// synthesized `x.ai/sessionConfig` (parity path); for every other agent it runs -/// the standard preference-application + schema-mapping pipeline unchanged. +/// 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. /// -/// `session_models` is the establishing answer's raw top-level `models`, `None` -/// where none was captured; the standard pipeline reads it for -/// [`session_model_list_offers`]. -#[allow(clippy::too_many_arguments)] -async fn apply_and_emit_session_config_options( +/// 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: &mut AgentSession, - state: &Arc>, - emitter: &EventEmitter, - agent_type: AgentType, - grok_meta: Option<&serde_json::Map>, - grok_model_specs: Option<&HashMap>, - session_models: Option<&serde_json::Value>, - preferred_mode_id: Option<&str>, - preferred_config_values: &BTreeMap, - initial_config_options: Vec, -) { - // Every establishment starts from an empty ledger. `SessionState` outlives a - // fork transition, so without this the child would inherit — and defend — - // whatever the parent asserted, on paths that never write it back: Grok's - // dedicated branch below, and the empty-preferences early return inside - // `apply_preferred_session_options`. See `SessionState::asserted_config_values`. - state.write().await.asserted_config_values.clear(); - if agent_type == AgentType::Grok { - let specs = grok_model_specs.cloned().unwrap_or_default(); - if let Some(mut opts) = synthesize_grok_config_options(grok_meta, &specs) { - // Cache the per-model effort map so a later model switch can rebuild - // the effort selector for the target model (grok ships it only at - // session birth, and on a catalog broadcast). `None` when empty - // keeps the switch path on the flat-fallback branch. - state.write().await.grok_model_specs = (!specs.is_empty()).then(|| specs.clone()); - // Grok's picker comes from its handshake, not a config-option list: - // its values are what the agent picked on its own, read before the - // replay below rewrites them (see - // `SessionState::agent_chosen_config_values`). - state.write().await.agent_chosen_config_values = current_config_option_values(&opts); - let session_id = session.session_id().clone(); - apply_grok_preferred_options( - cx, - &session_id, - &mut opts, - preferred_config_values, - &specs, - ) - .await; - emit_grok_established_picker(state, emitter, opts).await; - return; - } - // No x.ai/sessionConfig (unexpected): fall through to the standard path, - // which for Grok emits an empty list (no selectors) — same as before. - } - // What the agent picked on its own, read before the replay below rewrites - // any of it (see `SessionState::agent_chosen_config_values`). - state.write().await.agent_chosen_config_values = - current_config_option_values(&map_session_config_options(&initial_config_options)); - let updated = apply_preferred_session_options( - cx, - session, - state, - emitter, - preferred_mode_id, - preferred_config_values, - session_models, - initial_config_options, - ) - .await; - emit_session_config_options_values(state, emitter, updated).await; + 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)) } -/// 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. -/// Advertise `image: true` so the composer sends Image blocks. -/// -/// Measured live against 0.2.112, 1.0.0 and 1.0.3: every one of them still -/// advertises `image: false`, accepts a native `image` block anyway, and answers -/// correctly about the pixels — while the same bytes as a resource blob make the -/// model invent an answer. The advertisement has simply been wrong for as long -/// as codeg has supported grok, across every version tested, so this is -/// deliberately NOT version-gated (and stays correct if grok ever starts -/// advertising the truth: `image` is already true then). Deliberately no pinned -/// version named here either — `registry.rs` moves on its own schedule, and a -/// claim about "the pinned version" rots at the next bump. -/// -/// This bit says only that grok takes image blocks AT ALL — it decodes just some -/// formats, which is a per-mime question a single capability cannot answer, so -/// [`normalize_grok_image_blocks`] settles that separately at dispatch. -fn effective_prompt_capabilities( - agent_type: AgentType, - capabilities: &agent_client_protocol::schema::v1::PromptCapabilities, -) -> PromptCapabilitiesInfo { - PromptCapabilitiesInfo { - image: capabilities.image || agent_type == AgentType::Grok, - audio: capabilities.audio, - embedded_context: capabilities.embedded_context, +/// 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:?}" + ))), } } -async fn emit_prompt_capabilities( - state: &Arc>, - emitter: &EventEmitter, - capabilities: &agent_client_protocol::schema::v1::PromptCapabilities, - agent_type: AgentType, +/// 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, ) { - emit_with_state( - state, - emitter, - AcpEvent::PromptCapabilities { - prompt_capabilities: effective_prompt_capabilities(agent_type, capabilities), - }, - ) - .await; -} - -fn resolve_working_dir(working_dir: Option<&str>) -> PathBuf { - match working_dir { - Some(dir) => { - let path = PathBuf::from(dir); - if path.is_absolute() { - path - } else { - std::env::current_dir().unwrap_or_default().join(path) + // 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 + // per-model effort only at birth, never on set_model). + if let Some(pref) = preferred_config_values.get(GROK_MODEL_OPTION_ID).cloned() { + // Split the eligibility read (immutable) from the rebuild (mutable) so we + // never hold a `&mut opts` borrow across `set_grok_effort_selector_for_model`. + let eligible = opts + .iter() + .find(|o| o.id == GROK_MODEL_OPTION_ID) + .is_some_and(|o| { + // Grok's selectors are synthesized as selects; any other kind + // has no model value to compare against. + let SessionConfigKindInfo::Select(sel) = &o.kind else { + return false; + }; + // Skip if already current, or the saved model is no longer offered. + sel.current_value != pref && sel.options.iter().any(|x| x.value == pref) + }); + if eligible { + match set_grok_model(cx, session_id, pref.clone(), None).await { + Ok(()) => { + if let Some(SessionConfigKindInfo::Select(sel)) = opts + .iter_mut() + .find(|o| o.id == GROK_MODEL_OPTION_ID) + .map(|o| &mut o.kind) + { + sel.current_value = pref.clone(); + } + if !specs.is_empty() { + set_grok_effort_selector_for_model(opts, &pref, specs); + } + } + Err(e) => tracing::error!( + "[ACP] failed to apply preferred grok model '{pref}' on connect: {e}" + ), + } + } + } + // Effort preference — re-applied on top of the (possibly just-switched) + // current model. The effort selector was rebuilt above for that model, so an + // unsupported model (no selector) or an unoffered value is skipped here. + if let Some(pref) = preferred_config_values.get(GROK_EFFORT_OPTION_ID) { + let model_id = current_grok_model_id_from_opts(opts); + if let Some(SessionConfigKindInfo::Select(sel)) = opts + .iter_mut() + .find(|o| o.id == GROK_EFFORT_OPTION_ID) + .map(|o| &mut o.kind) + { + if &sel.current_value != pref && sel.options.iter().any(|o| &o.value == pref) { + if let Some(model_id) = model_id { + match set_grok_model(cx, session_id, model_id, Some(pref.clone())).await { + Ok(()) => sel.current_value = pref.clone(), + Err(e) => tracing::error!( + "[ACP] failed to apply preferred grok effort '{pref}' on connect: {e}" + ), + } + } } } - None => std::env::current_dir() - .unwrap_or_else(|_| dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))), } } -pub(crate) fn claude_raw_sdk_session_meta( - agent_type: AgentType, -) -> Option> { - if agent_type != AgentType::ClaudeCode { - return None; - } +/// The Grok model selector's current value, read from an in-memory options list. +fn current_grok_model_id_from_opts(opts: &[SessionConfigOptionInfo]) -> Option { + opts.iter() + .find(|o| o.id == GROK_MODEL_OPTION_ID) + .and_then(|o| { + let SessionConfigKindInfo::Select(sel) = &o.kind else { + return None; + }; + Some(sel.current_value.clone()) + }) +} - let mut claude_code = serde_json::Map::new(); - claude_code.insert( - "emitRawSDKMessages".to_string(), - serde_json::Value::Bool(true), - ); +/// The Grok model selector's current value, read from the authoritative +/// `SessionState.config_options` snapshot — needed to carry a reasoning-effort +/// override on `session/set_model` (effort is applied relative to a model). +async fn current_grok_model_id(state: &Arc>) -> Option { + let opts = state.read().await.config_options.clone()?; + current_grok_model_id_from_opts(&opts) +} - let mut meta = serde_json::Map::new(); - meta.insert( - "claudeCode".to_string(), - serde_json::Value::Object(claude_code), - ); - Some(meta) +/// Context window for the session's CURRENT Grok model. One state read, done at +/// turn start — the answer only changes on a model switch, which cannot happen +/// mid-turn. +/// +/// Resolution order mirrors `parsers::grok::build_detail` so the live ring and +/// the re-parsed history never disagree about the denominator: what Grok +/// reported for the model at session establishment, then its on-disk catalog +/// (which is where a BYO endpoint's declared window lives — that one is often +/// absent from the wire), then the id-shaped heuristic. +/// +/// `None` is a real outcome, not just "no model selector": a BYO endpoint is +/// keyed by whatever id the user typed (`[model.]` holds the upstream +/// model's name), so an id like `deepseek-chat` can miss the wire, the catalog, +/// and every heuristic family at once. [`grok_window_change_usage`] is what +/// keeps that from stranding a previous model's window on screen. +async fn grok_current_model_context_window(state: &Arc>) -> Option { + let (model, reported) = { + let st = state.read().await; + let model = current_grok_model_id_from_opts(st.config_options.as_deref()?)?; + let reported = st + .grok_model_specs + .as_ref() + .and_then(|specs| specs.get(&model)) + .and_then(|spec| spec.context_window); + (model, reported) + }; + // Lock released: the catalog lookup below touches the filesystem. + reported.or_else(|| { + grok_offline_context_window(&model, &crate::parsers::grok::resolve_grok_home_dir()) + }) } -/// The client capabilities codeg advertises on Initialize, with per-agent -/// gates. Extracted for testability — each gate is a documented product -/// decision: +/// The window for `model` when Grok didn't report one on the wire: its on-disk +/// catalog first, then the id-shaped heuristic. Split out of +/// [`grok_current_model_context_window`] so the resolution order is testable +/// against a fixture home instead of the host's real `~/.grok`. +fn grok_offline_context_window(model: &str, grok_home: &Path) -> Option { + crate::parsers::grok::grok_catalog_context_window(grok_home, model) + .or_else(|| crate::parsers::infer_context_window_max_tokens(Some(model))) +} + +/// The live pair to re-emit at turn start because the DENOMINATOR moved — the +/// user switched model between turns — or `None` when nothing needs re-keying. /// -/// - Everyone, unless `host_tools` says otherwise: filesystem read/write + -/// terminal, for ACP tool execution. Under -/// [`HostToolsPolicy::Agent`] BOTH are withheld, which is not a narrowing of -/// what the agent may do — it moves the doing back inside the agent's own -/// process, where the agent's own OS sandbox and permission rules already -/// apply (#436). Withholding one without the other buys nothing: the agent -/// just reaches the same file through the channel it kept. -/// - Codex, DeepSeek and Qoder only: form elicitation, so their native -/// questions (codex's Plan-mode `request_user_input`, DeepSeek's -/// `ask_user_question` and plan review, Qoder's `AskUserQuestion`) are -/// delivered as `elicitation/create` (handled by -/// `handle_elicitation_request`). Without it codex silently answers `{}`, -/// DeepSeek falls back to permission buttons, and Qoder withholds the tool -/// from the model. +/// Two cases, and the second is why this exists at all: +/// * the new model has a window → carry the cumulative count forward onto it, +/// so the ring doesn't keep dividing by the previous model's window until +/// this turn's first token-bearing update; +/// * the new model has NO resolvable window (a BYO id that the wire, the +/// catalog and the heuristic all decline to size) → hand the denominator +/// back with `size: 0`, the frontend's own "unknown window" sentinel +/// (`rawLiveSize > 0 ? … : null`), which drops the ring to the re-parsed +/// session stats. This is what stops the previous model's window from +/// surviving indefinitely on a turn that never reports a token count. +/// +/// Note `size: 0` and not `used: 0` — the frontend's `USAGE_UPDATE` reducer +/// *drops* a zero-`used` update whenever it already holds a positive one (a rule +/// that exists for Claude Code's synthetic `/context` responses), so clearing +/// via zeros would strand every attached client on the stale value while the +/// backend snapshot moved on. +fn grok_window_change_usage(window: Option, last: Option<(u64, u64)>) -> Option<(u64, u64)> { + let (used, last_size) = last?; + let size = window.unwrap_or(0); + (size != last_size).then_some((used, size)) +} + +/// The `(used, size)` a Grok update moves the context ring to, or `None` when +/// there is nothing new to report. +/// +/// Grok emits no ACP `usage_update` at all; it reports a CUMULATIVE token count +/// for the session in the OUTER `params._meta.totalTokens` of ordinary +/// `session/update` notifications (the same number +/// `parsers::grok::GrokTurnMeta` folds into each turn's `usage.input_tokens`). +/// That count IS the context "used", so pairing it with the model's window fills +/// the composer ring live — previously it only moved once a turn had been +/// written to disk and re-parsed. +/// +/// `last` suppresses the repeats: the count rides nearly every chunk, but steps +/// only a handful of times per turn. It keys on the WINDOW too, so a model +/// switch still re-emits while the count sits still. +/// +/// An unresolvable window reports `size: 0` rather than going silent — the +/// frontend reads that as "unknown window" and sizes the ring from the re-parsed +/// session stats, while the live count still tracks. Going silent instead would +/// freeze whatever pair was last emitted (see [`grok_window_change_usage`]). +fn grok_live_usage_step( + dispatch: &Dispatch, + agent_type: AgentType, + context_window: Option, + last: Option<(u64, u64)>, +) -> Option<(u64, u64)> { + if agent_type != AgentType::Grok { + return None; + } + let size = context_window.unwrap_or(0); + let Dispatch::Notification(notification) = dispatch else { + return None; + }; + let used = notification + .params + .get("_meta")? + .get("totalTokens")? + .as_u64() + .filter(|used| *used > 0)?; + (Some((used, size)) != last).then_some((used, size)) +} + +/// Route a composer config-option change for Grok. Both live selectors go +/// through `session/set_model`: the model selector switches the model, and the +/// reasoning-effort selector re-sends the current model with an +/// `_meta.reasoningEffort` override (the `~/.grok/config.toml` +/// `default_reasoning_effort` stays the at-birth global default). Re-emits the +/// options with the new `current_value` so the backend snapshot stays authoritative. +/// +/// A cross-agent-type switch rejected on an established conversation +/// (`is_grok_incompatible_agent_switch`) is handled in-band: re-emit the +/// authoritative options to revert the composer's optimistic pick and surface a +/// friendly, recoverable `AcpEvent::Error` (localized by the frontend via +/// `GROK_INCOMPATIBLE_AGENT_ERROR_CODE`), returning `Ok` so the caller does not +/// also emit the raw JSON-RPC error. The saved model preference is left intact, +/// so the suggested "start a new session" actually lands on the picked model +/// (a fresh session applies the preference pre-turn, where the switch succeeds). +async fn set_grok_config_option( + cx: &ConnectionTo, + session_id: &SessionId, + state: &Arc>, + emitter: &EventEmitter, + config_id: String, + value_id: String, +) -> Result<(), agent_client_protocol::Error> { + // Resolve the `set_model` args for whichever selector changed. A model pick + // is the model itself (no effort override); an effort pick re-sends the + // current model carrying the new `_meta.reasoningEffort`. Any other id is a + // no-op (defensive — the composer only offers these two). + let (model_id, effort) = if config_id == GROK_MODEL_OPTION_ID { + (value_id.clone(), None) + } else if config_id == GROK_EFFORT_OPTION_ID { + match current_grok_model_id(state).await { + Some(model_id) => (model_id, Some(value_id.clone())), + // No model known yet — nothing to carry the effort override on. + None => return Ok(()), + } + } else { + return Ok(()); + }; + match set_grok_model(cx, session_id, model_id, effort).await { + Ok(()) => { + // Built under the emit's own lock, from the picker as it is now: a + // model catalog broadcast refreshes it from the connection's + // dispatch task, and re-emitting a list read before that would + // undo the refresh (see `GrokModelCatalogBroadcasts`). + emit_with_state_built(state, emitter, |s| { + let mut opts = s.config_options.clone()?; + if let Some(SessionConfigKindInfo::Select(sel)) = opts + .iter_mut() + .find(|o| o.id == config_id) + .map(|o| &mut o.kind) + { + sel.current_value = value_id.clone(); + } + // A MODEL switch must re-point the effort selector at the new + // model — grok never re-sends per-model effort data on + // set_model. An EFFORT change leaves the list shape alone; no + // specs ⇒ leave as-is (flat-fallback session). + if config_id == GROK_MODEL_OPTION_ID { + if let Some(specs) = &s.grok_model_specs { + set_grok_effort_selector_for_model(&mut opts, &value_id, specs); + } + } + Some(AcpEvent::SessionConfigOptions { + config_options: opts, + }) + }) + .await; + Ok(()) + } + Err(e) if is_grok_incompatible_agent_switch(&e) => { + emit_grok_incompatible_agent_switch(state, emitter).await; + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Recover from a Grok cross-agent-type model-switch rejection: revert the +/// composer's optimistic selection by re-emitting the authoritative (unchanged) +/// options, then surface a friendly, recoverable error the frontend localizes +/// via `GROK_INCOMPATIBLE_AGENT_ERROR_CODE`. Split out of `set_grok_config_option` +/// so it can be unit-tested without a live ACP connection. +async fn emit_grok_incompatible_agent_switch( + state: &Arc>, + emitter: &EventEmitter, +) { + // Re-emit the options as they stand, read under the emit's own write lock: + // a separate read first would race a model catalog broadcast refreshing the + // picker in between, and re-emit the list from before it. `current_value` + // is unchanged because the switch never took effect. + emit_with_state_built(state, emitter, |s| { + s.config_options + .clone() + .map(|config_options| AcpEvent::SessionConfigOptions { config_options }) + }) + .await; + emit_with_state( + state, + emitter, + AcpEvent::Error { + message: "Cannot switch to that model in an existing conversation. \ + Start a new session to use it." + .to_string(), + agent_type: AgentType::Grok.to_string(), + code: Some(GROK_INCOMPATIBLE_AGENT_ERROR_CODE.to_string()), + details: None, + // Recoverable: the conversation continues on its current model. + terminal: false, + }, + ) + .await; +} + +/// Emit the composer's session config-option selectors. For Grok this reads the +/// synthesized `x.ai/sessionConfig` (parity path); for every other agent it runs +/// the standard preference-application + schema-mapping pipeline unchanged. +/// +/// `session_models` is the establishing answer's raw top-level `models`, `None` +/// where none was captured; the standard pipeline reads it for +/// [`session_model_list_offers`]. +#[allow(clippy::too_many_arguments)] +async fn apply_and_emit_session_config_options( + cx: &ConnectionTo, + session: &mut AgentSession, + state: &Arc>, + emitter: &EventEmitter, + agent_type: AgentType, + grok_meta: Option<&serde_json::Map>, + grok_model_specs: Option<&HashMap>, + session_models: Option<&serde_json::Value>, + preferred_mode_id: Option<&str>, + preferred_config_values: &BTreeMap, + initial_config_options: Vec, +) { + // Every establishment starts from an empty ledger. `SessionState` outlives a + // fork transition, so without this the child would inherit — and defend — + // whatever the parent asserted, on paths that never write it back: Grok's + // dedicated branch below, and the empty-preferences early return inside + // `apply_preferred_session_options`. See `SessionState::asserted_config_values`. + state.write().await.asserted_config_values.clear(); + if agent_type == AgentType::Grok { + let specs = grok_model_specs.cloned().unwrap_or_default(); + if let Some(mut opts) = synthesize_grok_config_options(grok_meta, &specs) { + // Cache the per-model effort map so a later model switch can rebuild + // the effort selector for the target model (grok ships it only at + // session birth, and on a catalog broadcast). `None` when empty + // keeps the switch path on the flat-fallback branch. + state.write().await.grok_model_specs = (!specs.is_empty()).then(|| specs.clone()); + // Grok's picker comes from its handshake, not a config-option list: + // its values are what the agent picked on its own, read before the + // replay below rewrites them (see + // `SessionState::agent_chosen_config_values`). + state.write().await.agent_chosen_config_values = current_config_option_values(&opts); + let session_id = session.session_id().clone(); + apply_grok_preferred_options( + cx, + &session_id, + &mut opts, + preferred_config_values, + &specs, + ) + .await; + emit_grok_established_picker(state, emitter, opts).await; + return; + } + // No x.ai/sessionConfig (unexpected): fall through to the standard path, + // which for Grok emits an empty list (no selectors) — same as before. + } + if agent_type == AgentType::Kiro { + if let Some(model) = synthesize_kiro_model_option(session_models) { + // 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(); + // What the agent picked on its own, read before either replay + // below rewrites it (see `SessionState::agent_chosen_config_values`): + // the config options it advertised, and the agent and model + // selectors built from its handshake. + let mut agent_chosen = + current_config_option_values(&map_session_config_options(&initial_config_options)); + let updated = apply_preferred_session_options( + cx, + session, + state, + emitter, + None, + &standard_prefs, + session_models, + 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); + agent_chosen.extend(current_config_option_values(&synthesized)); + state.write().await.agent_chosen_config_values = agent_chosen; + 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_with_state( + state, + emitter, + AcpEvent::SessionConfigOptions { + config_options: 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 { + rebuild_kiro_reasoning_selectors(state, emitter).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. + } + // What the agent picked on its own, read before the replay below rewrites + // any of it (see `SessionState::agent_chosen_config_values`). + state.write().await.agent_chosen_config_values = + current_config_option_values(&map_session_config_options(&initial_config_options)); + let updated = apply_preferred_session_options( + cx, + session, + state, + emitter, + preferred_mode_id, + preferred_config_values, + session_models, + 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. +/// Advertise `image: true` so the composer sends Image blocks. +/// +/// Measured live against 0.2.112, 1.0.0 and 1.0.3: every one of them still +/// advertises `image: false`, accepts a native `image` block anyway, and answers +/// correctly about the pixels — while the same bytes as a resource blob make the +/// model invent an answer. The advertisement has simply been wrong for as long +/// as codeg has supported grok, across every version tested, so this is +/// deliberately NOT version-gated (and stays correct if grok ever starts +/// advertising the truth: `image` is already true then). Deliberately no pinned +/// version named here either — `registry.rs` moves on its own schedule, and a +/// claim about "the pinned version" rots at the next bump. +/// +/// This bit says only that grok takes image blocks AT ALL — it decodes just some +/// formats, which is a per-mime question a single capability cannot answer, so +/// [`normalize_grok_image_blocks`] settles that separately at dispatch. +fn effective_prompt_capabilities( + agent_type: AgentType, + capabilities: &agent_client_protocol::schema::v1::PromptCapabilities, +) -> PromptCapabilitiesInfo { + PromptCapabilitiesInfo { + image: capabilities.image || agent_type == AgentType::Grok, + audio: capabilities.audio, + embedded_context: capabilities.embedded_context, + } +} + +async fn emit_prompt_capabilities( + state: &Arc>, + emitter: &EventEmitter, + capabilities: &agent_client_protocol::schema::v1::PromptCapabilities, + agent_type: AgentType, +) { + emit_with_state( + state, + emitter, + AcpEvent::PromptCapabilities { + prompt_capabilities: effective_prompt_capabilities(agent_type, capabilities), + }, + ) + .await; +} + +fn resolve_working_dir(working_dir: Option<&str>) -> PathBuf { + match working_dir { + Some(dir) => { + let path = PathBuf::from(dir); + if path.is_absolute() { + path + } else { + std::env::current_dir().unwrap_or_default().join(path) + } + } + None => std::env::current_dir() + .unwrap_or_else(|_| dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))), + } +} + +pub(crate) fn claude_raw_sdk_session_meta( + agent_type: AgentType, +) -> Option> { + if agent_type != AgentType::ClaudeCode { + return None; + } + + let mut claude_code = serde_json::Map::new(); + claude_code.insert( + "emitRawSDKMessages".to_string(), + serde_json::Value::Bool(true), + ); + + let mut meta = serde_json::Map::new(); + meta.insert( + "claudeCode".to_string(), + serde_json::Value::Object(claude_code), + ); + Some(meta) +} + +/// The client capabilities codeg advertises on Initialize, with per-agent +/// gates. Extracted for testability — each gate is a documented product +/// decision: +/// +/// - Everyone, unless `host_tools` says otherwise: filesystem read/write + +/// terminal, for ACP tool execution. Under +/// [`HostToolsPolicy::Agent`] BOTH are withheld, which is not a narrowing of +/// what the agent may do — it moves the doing back inside the agent's own +/// process, where the agent's own OS sandbox and permission rules already +/// apply (#436). Withholding one without the other buys nothing: the agent +/// just reaches the same file through the channel it kept. +/// - Codex, DeepSeek and Qoder only: form elicitation, so their native +/// questions (codex's Plan-mode `request_user_input`, DeepSeek's +/// `ask_user_question` and plan review, Qoder's `AskUserQuestion`) are +/// delivered as `elicitation/create` (handled by +/// `handle_elicitation_request`). Without it codex silently answers `{}`, +/// DeepSeek falls back to permission buttons, and Qoder withholds the tool +/// from the model. /// NOTE this reroutes codex's WHOLE form-elicitation surface — MCP /// tool-call approvals and MCP-server forms included — so the handler must /// cover every shape (`classify_elicitation`). URL elicitation is @@ -5214,20 +6014,22 @@ pub(crate) fn build_resume_session_request( /// typed response, handing back the raw top-level `models` alongside it. /// /// That field is the one reason these requests are untyped: grok puts its -/// per-model reasoning-effort data there, and the typed responses have no field -/// for it (the stable schema carries no session-model API), so it has to be read -/// off the raw JSON before the typed parse drops it. Everything else is exactly -/// the typed exchange — `UntypedMessage::new` serializes the very same request, -/// the reply goes through the same serde impl (whose `configOptions` already -/// skips an option kind this build does not know, one entry at a time), and a -/// JSON-RPC error comes back as the same `Err(agent_client_protocol::Error)` -/// with `.code` / `.to_string()` intact. `session/load` needs none of this and is -/// a plain typed send. +/// per-model reasoning-effort data there, Kiro its whole model catalog, and the +/// typed responses have no field for it (the stable schema carries no +/// session-model API), so it has to be read off the raw JSON before the typed +/// parse drops it. Everything else is exactly the typed exchange — +/// `UntypedMessage::new` serializes the very same request, the reply goes +/// through the same serde impl (whose `configOptions` already skips an option +/// kind this build does not know, one entry at a time), and a JSON-RPC error +/// comes back as the same `Err(agent_client_protocol::Error)` with `.code` / +/// `.to_string()` intact. `session/load` goes through here for Kiro only; every +/// other agent's load is a plain typed send. /// /// `models` is `None` when the reply carries none. Other agents can send one too -/// (codex-acp reports its model state there), but only two readers use it, and -/// both check the agent first: grok's effort data, and Antigravity's flat model -/// list (`session_model_list_offers`). +/// (codex-acp reports its model state there), but only three readers use it, +/// and each checks the agent first: grok's effort data, Kiro's model catalog +/// (`synthesize_kiro_model_option`), and Antigravity's flat model list +/// (`session_model_list_offers`). pub(crate) async fn send_capturing_models( cx: &ConnectionTo, method: &str, @@ -5308,6 +6110,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 @@ -5316,6 +6124,7 @@ fn load_mcp_servers_for_agent(agent_type: AgentType) -> Vec { | AgentType::Cursor | AgentType::Qoder | AgentType::Antigravity + | AgentType::Kiro ) { return Vec::new(); } @@ -6401,6 +7210,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; @@ -6834,6 +7713,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, @@ -6841,8 +7725,24 @@ async fn run_connection( &cwd, mcp_servers.clone(), ); - unless_cancelled(&handshake, cx.send_request_to(Agent, load_req).block_task()) + if agent_type == AgentType::Kiro { + unless_cancelled( + &handshake, + send_capturing_models::( + &cx, + AGENT_METHOD_NAMES.session_load, + load_req, + ), + ) .await? + .map(|(resp, models)| { + load_models_raw = models; + resp + }) + } else { + unless_cancelled(&handshake, 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")) @@ -7027,7 +7927,7 @@ async fn run_connection( // `session/load` is a typed send with no raw `models` // capture, so effort stays on the flat fallback. None, - None, + load_models_raw.as_ref(), preferred_mode_id.as_deref(), &preferred_config_values, initial_config_options.unwrap_or_default(), @@ -8405,6 +9305,22 @@ 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 — only when they are all the + // built-in's own, so an MCP call that shares an argument name keeps every + // argument on the card (see `tool_name_from_input`). + 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"] @@ -11566,6 +12482,7 @@ async fn run_conversation_loop( // once — an under-read, where resetting on Cancel instead would // admit a searched report landing after the cancel. cb_state.codex_context_readings.turn_started(); + 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 @@ -12098,17 +13015,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, @@ -12437,11 +13348,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, @@ -13210,3425 +14120,4620 @@ fn inject_start_line(value: &mut serde_json::Value, cwd: Option<&str>) -> bool { Some(o) => o, None => return false, }; - let fp = obj - .get("file_path") - .or_else(|| obj.get("filePath")) - .or_else(|| obj.get("path")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let old_str = obj - .get("old_string") - .or_else(|| obj.get("oldString")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - if let (Some(fp), Some(old_str)) = (fp, old_str) { - if let Some(sl) = find_string_start_line(&fp, &old_str, cwd) { - obj.insert("_start_line".to_string(), serde_json::json!(sl)); - return true; - } + let fp = obj + .get("file_path") + .or_else(|| obj.get("filePath")) + .or_else(|| obj.get("path")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let old_str = obj + .get("old_string") + .or_else(|| obj.get("oldString")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + if let (Some(fp), Some(old_str)) = (fp, old_str) { + if let Some(sl) = find_string_start_line(&fp, &old_str, cwd) { + obj.insert("_start_line".to_string(), serde_json::json!(sl)); + return true; + } + } + false +} + +/// Find the 1-based start line of `needle` in the file at `path`. +fn find_string_start_line(path: &str, needle: &str, cwd: Option<&str>) -> Option { + if needle.is_empty() { + return None; + } + let file_lines = crate::parsers::load_file_lines(path, cwd)?; + let file_content = file_lines.join("\n"); + let byte_offset = file_content.find(needle)?; + Some(file_content[..byte_offset].matches('\n').count() as u64 + 1) +} + +pub(crate) fn json_value_to_text(val: &Option) -> Option { + match val { + Some(serde_json::Value::String(text)) => Some(text.clone()), + Some(v) if !v.is_null() => Some(v.to_string()), + _ => None, + } +} + +/// Resolve the live `raw_output` string for a Grok tool call. +/// +/// Grok reports terminal output in the standard `content[]` channel (clean, +/// human-readable text) AND in a structured `rawOutput` object whose readable +/// text lives only in the string `output_for_prompt` (its `output` field is a +/// raw byte array, and the remaining keys — `command`, `exit_code`, … — are +/// metadata). Feeding that object through `json_value_to_text` stringifies the +/// whole thing into a JSON blob that (a) shadows the clean `content` — the live +/// renderer's `raw_output_chunks` win over `content` +/// (`conversation-runtime-store.ts`) — and (b) is then dropped by the terminal +/// renderer as a metadata-only "command envelope" +/// (`commandOutputFromJsonString` returns `""`), so a finished command shows no +/// result during live streaming even though the history parser renders it fine. +/// +/// Mirror the history parser (`parsers/grok.rs::update_tool_output`): prefer the +/// already-serialized `content`, and only fall back — when `content` is empty — +/// to the object's string `output_for_prompt` (Bash/terminal), a background-task +/// `TaskOutput` envelope (see `parsers::grok::grok_task_output_envelope`, the +/// one exception to "never emit the object blob": the frontend parses it into a +/// background-task card), or, for an MCP `rawOutput`, the text under `output` +/// (see grok_mcp_output_text). Returning `None` lets the frontend render +/// `content`. Non-object / absent / unrecognized `rawOutput` → `None`. +/// +/// Note: `content` here is `serialize_tool_call_content`, which for a Grok +/// terminal call is the plain text block (verified against real `~/.grok` +/// data). It could in principle also serialize `Diff`/`Terminal` blocks, in +/// which case a Grok tool carrying ONLY such a block plus `output_for_prompt` +/// would render the serialized block instead of the prompt text — but Grok's +/// `run_terminal_command` emits `content:text`, so this stays parity with +/// history for the shapes Grok actually produces. +fn grok_live_tool_output( + content: &Option, + raw_output: &Option, +) -> Option { + if content.as_deref().is_some_and(|c| !c.trim().is_empty()) { + return None; + } + let raw = raw_output.as_ref()?; + // Bash / terminal calls: the readable text lives only in `output_for_prompt`. + if let Some(text) = raw + .get("output_for_prompt") + .and_then(serde_json::Value::as_str) + .filter(|s| !s.is_empty()) + { + return Some(text.to_string()); + } + // Background-task polls (`get_command_or_subagent_output`): the command, + // exit code and shell text all live under the `TaskOutput` envelope, which + // matches none of the paths around it — without this the card streams empty. + // Shared with the history parser so both hand the frontend the same string. + if let Some(envelope) = crate::parsers::grok::grok_task_output_envelope(raw) { + return Some(envelope); + } + // MCP calls (Grok's `use_tool` envelope): the result text lives under + // `output.<*Output>` instead (see grok_mcp_output_text). Without this a + // finished MCP call — e.g. the `delegate_to_agent` ack carrying + // `task_id=…` — would surface no output at all. + grok_mcp_output_text(raw) +} + +/// Resolve the live `raw_output` string for a pi tool call. +/// +/// pi-acp hands ACP the pi tool result VERBATIM as `rawOutput` — the MCP-shaped +/// envelope `{"content":[{"type":"text","text":…}]}` — while putting that very +/// same text, already flattened, on the `content[]` channel +/// (`toolResultToText`; both `tool_execution_update` and `tool_execution_end` +/// emit the pair). Stringifying the envelope shadows the clean text, because the +/// live renderer's `raw_output_chunks` win over `content` +/// (`conversation-runtime-store.ts`), and nothing downstream unwraps that shape +/// (`commandOutputFromJsonString` bails on a `content` array) — so a finished +/// `bash` painted its terminal body with the JSON source string. +/// +/// Same parity rule as Grok (see `grok_live_tool_output`): whenever `content` +/// carries anything, it IS pi-acp's own flattening of this very result — emit +/// `None` and let it render (that also covers the `details.diff` / `stdout` / +/// `output` shapes `toolResultToText` flattens, which the envelope alone does +/// not reach). Only with no `content` is the envelope unwrapped, through the +/// SAME flattener the history parser uses so both surfaces show one string. +/// +/// pi's empty opening announcement is excluded up front — emitting anything for +/// it would strand a placeholder chunk over the whole call (see +/// `pi_result_is_empty_announcement`). Every OTHER result that carries something +/// but whose block array holds no text still stringifies exactly as before: +/// `toolResultToText` reaches `details.diff`, `stdout`/`stderr` and `output` that +/// the block array alone does not, so dropping those would lose a real result. +/// +/// (pi ≥0.0.33 routes `bash` through `_meta.terminal_*` and sends no `rawOutput` +/// for it at all — see `hosted_terminal_chunk`. This is the path every OTHER pi +/// tool takes, and the one `bash` itself takes on earlier pi-acp builds.) +fn pi_live_tool_output( + content: &Option, + raw_output: &Option, +) -> Option { + let raw = raw_output.as_ref()?; + if pi_result_is_empty_announcement(raw) { + return None; + } + if content.as_deref().is_some_and(|c| !c.trim().is_empty()) { + return None; + } + raw.get("content") + .and_then(crate::parsers::pi::tool_result_content_text) + .or_else(|| json_value_to_text(raw_output)) + .map(|text| structurize_live_output(&text)) +} + +/// Is this `rawOutput` pi's "I have started, and have nothing yet" announcement — +/// literally `{"content": []}`? +/// +/// pi's bash tool fires `onUpdate({content: [], details: undefined})` before the +/// process writes a byte (`bash.js`), so this is the opening frame of EVERY pi +/// command. Both channels then carry noise: `rawOutput` is the empty envelope, +/// and pi-acp — with no text, diff, stdout or stderr to flatten — falls back to +/// `JSON.stringify(result, null, 2)` for `content[]` (`toolResultToText`), which +/// puts a literal `{\n "content": []\n}` in the terminal card until the first +/// line of real output arrives. Worse, stringifying the envelope into +/// `raw_output` seeds it as the call's chunk, and since every later frame prefers +/// `content` and emits `None` — which the reducer treats as "keep the previous +/// chunks" (`acp-connections-context.tsx`) — that placeholder would outrank the +/// real output for the rest of the call. +/// +/// So both channels are suppressed for this frame, and ONLY for this frame: the +/// predicate is deliberately the narrowest thing that identifies it — an empty +/// `content` array and no other non-null member. Any richer result (a diff, +/// `stdout`/`stderr`, an exit code, a truncation record) is something +/// `toolResultToText` may have flattened into real text, so it is left alone even +/// when its block array is empty. Keyed off the STRUCTURED payload, never off the +/// rendered string, so a command whose own output looks like an empty envelope is +/// untouched. +fn pi_result_is_empty_announcement(raw_output: &serde_json::Value) -> bool { + let Some(obj) = raw_output.as_object() else { + return false; + }; + obj.get("content") + .and_then(serde_json::Value::as_array) + .is_some_and(|blocks| blocks.is_empty()) + && obj + .iter() + .all(|(key, value)| key == "content" || value.is_null()) +} + +/// pi-gated wrapper for the `content` channel: see `pi_result_is_empty_announcement`. +fn pi_result_content_is_stringify_noise( + agent_type: AgentType, + raw_output: &Option, +) -> bool { + matches!(agent_type, AgentType::Pi) + && raw_output + .as_ref() + .is_some_and(pi_result_is_empty_announcement) +} + +/// `_meta` key carrying OpenCode's authoritative tool name (see +/// [`stamp_opencode_tool_name`]). Namespaced like every other agent's marker +/// (`claudeCode`, `qoder`, `x.ai/tool`) so it cannot collide with a payload the +/// adapter itself publishes. +const OPENCODE_META_KEY: &str = "opencode"; + +/// Record the raw tool name from an OpenCode `tool_call`'s opening frame as +/// `_meta.opencode.toolName`, so the frontend classifier has the same identity +/// the history parser reads out of `part.tool`. +/// +/// OpenCode's ACP adapter states the tool's name EXACTLY ONCE, and only on this +/// frame: `pendingToolCall` titles a not-yet-running call with the bare tool id +/// (`toolTitle` falls through to `toolName` because `ToolStatePending` carries +/// no title), the `in_progress` update repeats it, and then the COMPLETION frame +/// replaces `title` with a display label and drops `kind`, `locations` and +/// `rawInput` entirely — verified against opencode 1.18.30 driven over real ACP: +/// tool_call title="glob" kind="search" rawInput={} +/// tool_call_update title="glob" kind="search" rawInput={"pattern":"*.txt"} +/// tool_call_update (no title, no kind, no rawInput) content=[…] +/// (`read`→"notes.txt", `todowrite`→"3 todos", `grep`→"third", `bash` keeps the +/// command.) So from the second frame on, the only signal left is the input +/// shape — and OpenCode has several tools that are indistinguishable that way: +/// `glob` (`{pattern, path}`) classified as **grep**, `lsp_*` (`{path}`) as +/// **read**, and an MCP tool taking `{query}` as **websearch**, each of which the +/// history parser names correctly. This closes that live/history split at the +/// source instead of adding more input-shape heuristics. +/// +/// Gated on `pending` + an empty `rawInput` because `loadSession`/`forkSession` +/// REPLAY finished tool parts through the same `pendingToolCall` builder: a +/// replayed frame is also `status: "pending"`, but it is built from the +/// COMPLETED state, so its title is the display label and its input is fully +/// populated. Requiring the empty input keeps the marker off those. (codeg +/// prefers `session/resume`, which replays nothing, so this is belt-and-braces.) +/// A `pending` frame that did arrive with partial input simply goes unstamped — +/// today's behavior, never a wrong name. +/// +/// Merges into whatever `_meta` the adapter sent and never overwrites an +/// existing `opencode` key. +fn stamp_opencode_tool_name( + agent_type: AgentType, + status: &str, + raw_input: &Option, + title: &str, + meta: Option>, +) -> Option> { + if agent_type != AgentType::OpenCode || status != "pending" { + return meta; + } + let input_is_empty = match raw_input { + None | Some(serde_json::Value::Null) => true, + Some(serde_json::Value::Object(map)) => map.is_empty(), + _ => false, + }; + let name = title.trim(); + if !input_is_empty || name.is_empty() { + return meta; } - false -} - -/// Find the 1-based start line of `needle` in the file at `path`. -fn find_string_start_line(path: &str, needle: &str, cwd: Option<&str>) -> Option { - if needle.is_empty() { - return None; + let mut meta = meta.unwrap_or_default(); + if meta.contains_key(OPENCODE_META_KEY) { + return Some(meta); } - let file_lines = crate::parsers::load_file_lines(path, cwd)?; - let file_content = file_lines.join("\n"); - let byte_offset = file_content.find(needle)?; - Some(file_content[..byte_offset].matches('\n').count() as u64 + 1) + meta.insert( + OPENCODE_META_KEY.to_string(), + serde_json::json!({ "toolName": name }), + ); + Some(meta) } -pub(crate) fn json_value_to_text(val: &Option) -> Option { - match val { - Some(serde_json::Value::String(text)) => Some(text.clone()), - Some(v) if !v.is_null() => Some(v.to_string()), - _ => None, +/// `_meta` key marking a tool call as one of codex's `search` command actions +/// (see [`stamp_codex_search_action`]). Frontend twin: +/// `CODEX_SEARCH_ACTION_META_KEY` in `src/lib/codex-command-action.ts`. +const CODEX_SEARCH_ACTION_META_KEY: &str = "codeg.codexSearchAction"; + +/// Stamp codex's `search` command actions on their opening frame, so the +/// frontend can tell them from every other agent's grep. +/// +/// It needs to because codeg advertises `_meta.terminal_output_delta`, and with +/// that capability codex-acp completes a command that printed nothing as a +/// bare `failed` status — no `rawOutput` envelope, so no exit code (see +/// `build_client_capabilities`). For a search that is almost always rg's exit +/// 1, "no matches", and `isGrepNoMatchResult` presents it that way — but a +/// bare `failed` with no output is also what an interrupted grep from another +/// adapter can look like, so the rule must know the call is codex's. Only the +/// backend knows the agent at frame level, hence the marker. +/// +/// Keyed on the opening frame's `kind: "search"` with no `terminal_info`, which +/// is exactly `createCommandActionEvent`'s search arm (listFiles and read go out +/// as `kind: "read"`). Later frames carry no `_meta` for such a call unless it +/// streamed output, and the reducer keeps the opening `_meta` until one does — +/// by which point the call has output and the rule no longer applies anyway. +fn stamp_codex_search_action( + agent_type: AgentType, + kind: &ToolKind, + hosted_shell: bool, + meta: Option>, +) -> Option> { + if agent_type != AgentType::Codex || hosted_shell || !matches!(kind, ToolKind::Search) { + return meta; } + let mut meta = meta.unwrap_or_default(); + meta.insert( + CODEX_SEARCH_ACTION_META_KEY.to_string(), + serde_json::Value::Bool(true), + ); + Some(meta) } -/// Resolve the live `raw_output` string for a Grok tool call. +/// Resolve the live `raw_output` string for an OpenCode tool call. /// -/// Grok reports terminal output in the standard `content[]` channel (clean, -/// human-readable text) AND in a structured `rawOutput` object whose readable -/// text lives only in the string `output_for_prompt` (its `output` field is a -/// raw byte array, and the remaining keys — `command`, `exit_code`, … — are -/// metadata). Feeding that object through `json_value_to_text` stringifies the -/// whole thing into a JSON blob that (a) shadows the clean `content` — the live -/// renderer's `raw_output_chunks` win over `content` -/// (`conversation-runtime-store.ts`) — and (b) is then dropped by the terminal -/// renderer as a metadata-only "command envelope" -/// (`commandOutputFromJsonString` returns `""`), so a finished command shows no -/// result during live streaming even though the history parser renders it fine. +/// OpenCode's ACP adapter reports a finished tool on BOTH channels: the clean +/// result text on `content[]`, and the envelope `{output, metadata?, +/// attachments?}` — wrapping that very same string — on `rawOutput` (a failure +/// sends `{error, metadata?}` beside the error text). Stringifying the envelope +/// shadows the clean text, because the live renderer's `raw_output_chunks` win +/// over `content` (`conversation-runtime-store.ts`), so every card that parses +/// the result ITSELF is handed JSON source instead of the result. /// -/// Mirror the history parser (`parsers/grok.rs::update_tool_output`): prefer the -/// already-serialized `content`, and only fall back — when `content` is empty — -/// to the object's string `output_for_prompt` (Bash/terminal), a background-task -/// `TaskOutput` envelope (see `parsers::grok::grok_task_output_envelope`, the -/// one exception to "never emit the object blob": the frontend parses it into a -/// background-task card), or, for an MCP `rawOutput`, the text under `output` -/// (see grok_mcp_output_text). Returning `None` lets the frontend render -/// `content`. Non-object / absent / unrecognized `rawOutput` → `None`. +/// Verified against opencode 1.18.23 (driven over real ACP with a stub MCP +/// server): a codeg-mcp `ask_user_question` completes as +/// content: [{"type":"content","content":{"type":"text","text":"The user +/// answered your question(s):\n1. [框架] …\n → 选项 A\n"}}] +/// rawOutput: {"output":"","metadata":{"truncated":false}} +/// OpenCode drops the MCP `structuredContent` entirely, so the human-readable +/// lines ARE the whole record — and `AskQuestionResultCard`, seeing only the +/// one-line JSON blob, matched neither the structured envelope nor the text +/// fallback and rendered an answered question as "no selection", while the +/// history parser (which reads the same `state.output` bare) rendered it fine. /// -/// Note: `content` here is `serialize_tool_call_content`, which for a Grok -/// terminal call is the plain text block (verified against real `~/.grok` -/// data). It could in principle also serialize `Diff`/`Terminal` blocks, in -/// which case a Grok tool carrying ONLY such a block plus `output_for_prompt` -/// would render the serialized block instead of the prompt text — but Grok's -/// `run_terminal_command` emits `content:text`, so this stays parity with -/// history for the shapes Grok actually produces. -fn grok_live_tool_output( +/// Same parity rule as Grok and pi (see [`grok_live_tool_output`]): whenever +/// `content` carries anything, it IS OpenCode's own rendering of this result — +/// return `None` and let it render. Only with no `content` is the envelope +/// unwrapped, mirroring `parsers/opencode.rs`: `output`, else the failure +/// `error`, else `metadata.output` (a command writing only to stderr leaves +/// `state.output` empty while the combined stream stays in the metadata). An +/// unrecognized payload still stringifies as before, so no result is ever lost. +fn opencode_live_tool_output( content: &Option, raw_output: &Option, ) -> Option { + // `read` is the one tool whose `content` is LOSSY rather than merely + // redundant: OpenCode hands the client the file body with the line numbers + // stripped, while `metadata.display` still carries `lineStart`. The history + // parser rebuilds `{start_line, content}` from it, so without this the same + // finished `read` renders numbered after a reload and unnumbered while + // live. `metadata.display` is unique to `read`, so every other tool keeps + // the parity rule below. + if let Some(structured) = raw_output + .as_ref() + .and_then(|raw| crate::parsers::opencode::structure_read_output(raw.get("metadata"))) + { + return Some(structured); + } if content.as_deref().is_some_and(|c| !c.trim().is_empty()) { return None; } let raw = raw_output.as_ref()?; - // Bash / terminal calls: the readable text lives only in `output_for_prompt`. - if let Some(text) = raw - .get("output_for_prompt") - .and_then(serde_json::Value::as_str) - .filter(|s| !s.is_empty()) - { - return Some(text.to_string()); - } - // Background-task polls (`get_command_or_subagent_output`): the command, - // exit code and shell text all live under the `TaskOutput` envelope, which - // matches none of the paths around it — without this the card streams empty. - // Shared with the history parser so both hand the frontend the same string. - if let Some(envelope) = crate::parsers::grok::grok_task_output_envelope(raw) { - return Some(envelope); + fn text(value: Option<&serde_json::Value>) -> Option<&str> { + value + .and_then(serde_json::Value::as_str) + .filter(|s| !s.trim().is_empty()) } - // MCP calls (Grok's `use_tool` envelope): the result text lives under - // `output.<*Output>` instead (see grok_mcp_output_text). Without this a - // finished MCP call — e.g. the `delegate_to_agent` ack carrying - // `task_id=…` — would surface no output at all. - grok_mcp_output_text(raw) + text(raw.get("output")) + .or_else(|| text(raw.get("error"))) + .or_else(|| text(raw.pointer("/metadata/output"))) + .map(structurize_live_output) + .or_else(|| json_value_to_text(raw_output).map(|t| structurize_live_output(&t))) } -/// Resolve the live `raw_output` string for a pi tool call. +/// What a pi `agent_message_chunk` actually IS (issue #525). /// -/// pi-acp hands ACP the pi tool result VERBATIM as `rawOutput` — the MCP-shaped -/// envelope `{"content":[{"type":"text","text":…}]}` — while putting that very -/// same text, already flattened, on the `content[]` channel -/// (`toolResultToText`; both `tool_execution_update` and `tool_execution_end` -/// emit the pair). Stringifying the envelope shadows the clean text, because the -/// live renderer's `raw_output_chunks` win over `content` -/// (`conversation-runtime-store.ts`), and nothing downstream unwraps that shape -/// (`commandOutputFromJsonString` bails on a `content` array) — so a finished -/// `bash` painted its terminal body with the JSON source string. +/// pi-acp puts the assistant's prose AND its own lifecycle announcements on the +/// same `agent_message_chunk` channel, so a caffeinate extension's notify lands +/// spliced into the reply: `你好。有什么需要我帮你处理?Released pi-caffeinate +/// (agent finished).` See [`pi_message_chunk_route`] for the full inventory. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PiChunkRoute { + /// The agent's own words — pi's `message_update` / `text_delta`. Renders as + /// today's `ContentDelta`. This is what EVERY other agent's chunk resolves + /// to, since the classifier is pi-gated. + Prose, + /// A pi-acp lifecycle announcement with nothing downstream needs it. + Drop, + /// pi is auto-retrying the model call. Routed to the shared retry banner + /// (`AcpEvent::TurnRetrying`) instead of the prose stream. The numbers are + /// pi's own, recovered from the sentence it formats them into; all three are + /// `None` for pi-acp's shapeless `"Retrying..."` fallback. + Retrying { + attempt: Option, + max: Option, + delay_ms: Option, + }, +} + +/// Tell a pi `agent_message_chunk` that is the ASSISTANT SPEAKING from one that +/// is pi-acp ANNOUNCING SOMETHING (issue #525). /// -/// Same parity rule as Grok (see `grok_live_tool_output`): whenever `content` -/// carries anything, it IS pi-acp's own flattening of this very result — emit -/// `None` and let it render (that also covers the `details.diff` / `stdout` / -/// `output` shapes `toolResultToText` flattens, which the envelope alone does -/// not reach). Only with no `content` is the envelope unwrapped, through the -/// SAME flattener the history parser uses so both surfaces show one string. +/// Real prose reaches this channel from exactly one place: pi-acp's +/// `message_update` arm, for `assistantMessageEvent.type === "text_delta"`. +/// Everything below is pi-acp synthesizing a sentence out of a pi RPC lifecycle +/// event and emitting it on that SAME channel, where the live store appends it +/// into whatever message is open — which is the whole bug: /// -/// pi's empty opening announcement is excluded up front — emitting anything for -/// it would strand a placeholder chunk over the whole call (see -/// `pi_result_is_empty_announcement`). Every OTHER result that carries something -/// but whose block array holds no text still stringifies exactly as before: -/// `toolResultToText` reaches `details.diff`, `stdout`/`stderr` and `output` that -/// the block array alone does not, so dropping those would lose a real result. +/// | pi-acp `dist/index.js` | text | marker | +/// |---|---|---| +/// | L1265 `extension_ui_request` / `notify` | the extension's own message | `_meta.piAcp.notify` | +/// | L1169 `auto_retry_start` | `Retrying (attempt N/M, waiting Ss)...` | — | +/// | L1176 `auto_retry_end` | `Retry finished, resuming.` | — | +/// | L1183 `auto_compaction_start` | `Context nearing limit, running automatic compaction...` | — | +/// | L1193 `auto_compaction_end` | `Automatic compaction finished; …` | — | +/// | L834 `prompt()` queue | `Queued message (position N).` | — | +/// | L1222 `agent_settled` queue | `Starting queued message. (N remaining)` | — | +/// | L860 `cancel()` queue | `Cleared queued prompts.` | — | +/// +/// The notify marker is read FIRST and wins outright, because that text is +/// arbitrary extension content — a pi extension can notify anything, including +/// something that reads exactly like prose, so the structured marker is the only +/// trustworthy handle on it. The other seven carry no marker at all, and pi-acp +/// offers no alternative channel for them, so they are matched as LITERALS. +/// Three things keep that honest: the whole classifier is gated on +/// `AgentType::Pi`; every rule matches the WHOLE trimmed chunk, never a +/// substring; and the adapter version is pinned in `registry.rs`, so a bump is +/// the natural place to re-check these strings. A false positive would need the +/// model to emit one of these exact sentences as an entire standalone delta, and +/// would cost one dropped delta — not a corrupted message. +/// +/// Two families deliberately stay `Prose`, and must: /// -/// (pi ≥0.0.33 routes `bash` through `_meta.terminal_*` and sends no `rawOutput` -/// for it at all — see `hosted_terminal_chunk`. This is the path every OTHER pi -/// tool takes, and the one `bash` itself takes on earlier pi-acp builds.) -fn pi_live_tool_output( - content: &Option, - raw_output: &Option, -) -> Option { - let raw = raw_output.as_ref()?; - if pi_result_is_empty_announcement(raw) { - return None; +/// - **Slash-command replies** (pi-acp L2080+: `/compact`, `/session`, `/name`, +/// `/export`, `/follow-up`, `/steering`, `/changelog`). The user ASKED for +/// those; they ride the same channel and match no rule here, which is exactly +/// the point of matching whole literals rather than sniffing for +/// "status-looking" text. (The startup prelude — `sendStartupInfoIfPending` — +/// is NOT in this family: it is dropped, but by +/// [`pi_take_startup_banner`] against the text pi-acp itself reported, never +/// by a rule here.) +/// - **`Pi UI request is not supported in ACP yet; cancelling it.`** +/// (L1257). pi asked the user for input and pi-acp auto-cancelled it — a rare, +/// actionable failure with no better home today. Dropping it would hide the +/// reason a turn went sideways, which is worse than the noise this fixes. +/// +/// Used by BOTH the renderer (`emit_conversation_update`) and the empty-turn +/// probe (`is_agent_output_update`), so the two can never disagree about whether +/// a chunk was output — a status-only turn must not render blank AND report +/// success. +fn pi_message_chunk_route( + agent_type: AgentType, + text: &str, + meta: Option<&serde_json::Map>, +) -> PiChunkRoute { + if agent_type != AgentType::Pi { + return PiChunkRoute::Prose; } - if content.as_deref().is_some_and(|c| !c.trim().is_empty()) { - return None; + // A pi extension's `ui.notify()`. pi's own semantics for it are a transient + // toast (`rpc-mode.js`: "Fire and forget - no response needed"), never + // conversation — so it is dropped at every level (`info` / `warning` / + // `error`). Keyed off the marker alone: the message is the extension's, and + // matching it as text would be matching arbitrary user content. + if meta.is_some_and(|meta| { + meta.get("piAcp") + .and_then(|pi| pi.get("notify")) + .is_some_and(serde_json::Value::is_object) + }) { + return PiChunkRoute::Drop; + } + let text = text.trim(); + match text { + // `auto_retry_end`. No event of its own: the banner clears at the next + // tool call / plan update / turn boundary, exactly as codex's does. + "Retry finished, resuming." => PiChunkRoute::Drop, + // `formatAutoRetryMessage`'s fallback, when pi's event carried no usable + // attempt / maxAttempts / delayMs. + "Retrying..." => PiChunkRoute::Retrying { + attempt: None, + max: None, + delay_ms: None, + }, + // Compaction. Dropped rather than rendered as the shared + // `_meta.contextCompaction` card: a tool call synthesized at the END of a + // turn would land after the reply, and `SessionState`'s "final assistant + // text" is the text FOLLOWING the last tool call — so the card would + // silently blank the delegation result / work-task summary. Restoring the + // card is a follow-up, gated on teaching that extraction to skip + // compaction refs (which codex and grok need too). + "Context nearing limit, running automatic compaction..." + | "Automatic compaction finished; context was summarized to continue the session." => { + PiChunkRoute::Drop + } + // pi-acp's own prompt queue. codeg's turn gate normally makes this + // unreachable (`manager.rs` rejects a concurrent prompt), so all three + // are handled defensively and together — one of them showing up alone + // would be the odd one out. + "Cleared queued prompts." => PiChunkRoute::Drop, + _ => { + if let Some((attempt, max, delay_ms)) = pi_parse_retry_announcement(text) { + PiChunkRoute::Retrying { + attempt: Some(attempt), + max: Some(max), + delay_ms: Some(delay_ms), + } + } else if pi_is_queue_announcement(text) { + PiChunkRoute::Drop + } else { + PiChunkRoute::Prose + } + } } - raw.get("content") - .and_then(crate::parsers::pi::tool_result_content_text) - .or_else(|| json_value_to_text(raw_output)) - .map(|text| structurize_live_output(&text)) } -/// Is this `rawOutput` pi's "I have started, and have nothing yet" announcement — -/// literally `{"content": []}`? +/// Recover `(attempt, max, delay_ms)` from `Retrying (attempt 1/3, waiting 2s)...` +/// — pi-acp's `formatAutoRetryMessage`, which is the only place codeg can reach +/// these numbers: pi sends them structured to pi-acp, which formats them into a +/// sentence and forwards nothing else. /// -/// pi's bash tool fires `onUpdate({content: [], details: undefined})` before the -/// process writes a byte (`bash.js`), so this is the opening frame of EVERY pi -/// command. Both channels then carry noise: `rawOutput` is the empty envelope, -/// and pi-acp — with no text, diff, stdout or stderr to flatten — falls back to -/// `JSON.stringify(result, null, 2)` for `content[]` (`toolResultToText`), which -/// puts a literal `{\n "content": []\n}` in the terminal card until the first -/// line of real output arrives. Worse, stringifying the envelope into -/// `raw_output` seeds it as the call's chunk, and since every later frame prefers -/// `content` and emits `None` — which the reducer treats as "keep the previous -/// chunks" (`acp-connections-context.tsx`) — that placeholder would outrank the -/// real output for the rest of the call. +/// Worth recovering rather than shipping the sentence as the banner's message, +/// because the banner already has localized slots for exactly this data +/// (`claudeApiRetry.retryingWithMax` / `nextRetryIn`) — so a zh-CN user reads +/// `正在重试 1/3,2.0 秒后重试` instead of an English sentence with a Chinese +/// suffix bolted on. /// -/// So both channels are suppressed for this frame, and ONLY for this frame: the -/// predicate is deliberately the narrowest thing that identifies it — an empty -/// `content` array and no other non-null member. Any richer result (a diff, -/// `stdout`/`stderr`, an exit code, a truncation record) is something -/// `toolResultToText` may have flattened into real text, so it is left alone even -/// when its block array is empty. Keyed off the STRUCTURED payload, never off the -/// rendered string, so a command whose own output looks like an empty envelope is -/// untouched. -fn pi_result_is_empty_announcement(raw_output: &serde_json::Value) -> bool { - let Some(obj) = raw_output.as_object() else { - return false; - }; - obj.get("content") - .and_then(serde_json::Value::as_array) - .is_some_and(|blocks| blocks.is_empty()) - && obj - .iter() - .all(|(key, value)| key == "content" || value.is_null()) +/// Parsed by hand rather than by regex: the shape is fixed, and every field is +/// re-validated (`{n}/{n}`, `{n}s`, nothing left over), so a sentence that merely +/// starts the same way falls through to `Prose` instead of half-matching. +fn pi_parse_retry_announcement(text: &str) -> Option<(u32, u32, u64)> { + let body = text + .strip_prefix("Retrying (attempt ")? + .strip_suffix("s)...")?; + let (attempts, delay_seconds) = body.split_once(", waiting ")?; + let (attempt, max) = attempts.split_once('/')?; + Some(( + attempt.parse().ok()?, + max.parse().ok()?, + delay_seconds.parse::().ok()?.checked_mul(1000)?, + )) } -/// pi-gated wrapper for the `content` channel: see `pi_result_is_empty_announcement`. -fn pi_result_content_is_stringify_noise( - agent_type: AgentType, - raw_output: &Option, -) -> bool { - matches!(agent_type, AgentType::Pi) - && raw_output - .as_ref() - .is_some_and(pi_result_is_empty_announcement) +/// `Queued message (position N).` / `Starting queued message. (N remaining)` — +/// the two pi-acp queue announcements that carry an interpolated count (the +/// third, `Cleared queued prompts.`, is a plain literal handled by the caller). +fn pi_is_queue_announcement(text: &str) -> bool { + let counted = text + .strip_prefix("Queued message (position ") + .and_then(|rest| rest.strip_suffix(").")) + .or_else(|| { + text.strip_prefix("Starting queued message. (") + .and_then(|rest| rest.strip_suffix(" remaining)")) + }); + counted.is_some_and(|count| !count.is_empty() && count.bytes().all(|b| b.is_ascii_digit())) } -/// `_meta` key carrying OpenCode's authoritative tool name (see -/// [`stamp_opencode_tool_name`]). Namespaced like every other agent's marker -/// (`claudeCode`, `qoder`, `x.ai/tool`) so it cannot collide with a payload the -/// adapter itself publishes. -const OPENCODE_META_KEY: &str = "opencode"; - -/// Record the raw tool name from an OpenCode `tool_call`'s opening frame as -/// `_meta.opencode.toolName`, so the frontend classifier has the same identity -/// the history parser reads out of `part.tool`. +/// The session prelude pi-acp reports on `session/new`, if this response is +/// carrying one. /// -/// OpenCode's ACP adapter states the tool's name EXACTLY ONCE, and only on this -/// frame: `pendingToolCall` titles a not-yet-running call with the bare tool id -/// (`toolTitle` falls through to `toolName` because `ToolStatePending` carries -/// no title), the `in_progress` update repeats it, and then the COMPLETION frame -/// replaces `title` with a display label and drops `kind`, `locations` and -/// `rawInput` entirely — verified against opencode 1.18.30 driven over real ACP: -/// tool_call title="glob" kind="search" rawInput={} -/// tool_call_update title="glob" kind="search" rawInput={"pattern":"*.txt"} -/// tool_call_update (no title, no kind, no rawInput) content=[…] -/// (`read`→"notes.txt", `todowrite`→"3 todos", `grep`→"third", `bash` keeps the -/// command.) So from the second frame on, the only signal left is the input -/// shape — and OpenCode has several tools that are indistinguishable that way: -/// `glob` (`{pattern, path}`) classified as **grep**, `lsp_*` (`{path}`) as -/// **read**, and an MCP tool taking `{query}` as **websearch**, each of which the -/// history parser names correctly. This closes that live/history split at the -/// source instead of adding more input-shape heuristics. +/// pi-acp builds a markdown banner (`buildStartupInfo`: pi's version, the +/// project `AGENTS.md`, every discovered skill file, prompts, extensions, an +/// update notice) and pushes it down the ORDINARY `agent_message_chunk` channel +/// with no marker of any kind — so codeg rendered it as the assistant's opening +/// words, before the user had said anything, complete with the absolute paths of +/// every skill and extension on the machine. /// -/// Gated on `pending` + an empty `rawInput` because `loadSession`/`forkSession` -/// REPLAY finished tool parts through the same `pendingToolCall` builder: a -/// replayed frame is also `status: "pending"`, but it is built from the -/// COMPLETED state, so its title is the display label and its input is fully -/// populated. Requiring the empty input keeps the marker off those. (codeg -/// prefers `session/resume`, which replays nothing, so this is belt-and-braces.) -/// A `pending` frame that did arrive with partial input simply goes unstamped — -/// today's behavior, never a wrong name. +/// The trustworthy handle is on the `session/new` RESPONSE instead: +/// `_meta.piAcp.startupInfo` holds the very same string (verified byte-for-byte +/// against pi-acp 0.0.33 driven over real stdio ACP — `startupInfo === chunk`). +/// Capturing it there and matching the chunk against it is what lets the drop be +/// exact rather than a guess about what "looks like a banner" — the same +/// preference for a structured handle over text that makes the `notify` marker +/// win outright in [`pi_message_chunk_route`]. A pi with `quietStartup` set +/// sends neither, and every other agent has no `piAcp` meta at all. /// -/// Merges into whatever `_meta` the adapter sent and never overwrites an -/// existing `opencode` key. -fn stamp_opencode_tool_name( +/// Gated on `AgentType::Pi` for the same reason the terminal-meta bridge is: +/// `piAcp` is pi-acp's own namespace and must not be read off arbitrary agents. +fn pi_startup_banner( agent_type: AgentType, - status: &str, - raw_input: &Option, - title: &str, - meta: Option>, -) -> Option> { - if agent_type != AgentType::OpenCode || status != "pending" { - return meta; - } - let input_is_empty = match raw_input { - None | Some(serde_json::Value::Null) => true, - Some(serde_json::Value::Object(map)) => map.is_empty(), - _ => false, - }; - let name = title.trim(); - if !input_is_empty || name.is_empty() { - return meta; - } - let mut meta = meta.unwrap_or_default(); - if meta.contains_key(OPENCODE_META_KEY) { - return Some(meta); + meta: Option<&serde_json::Map>, +) -> Option { + if agent_type != AgentType::Pi { + return None; } - meta.insert( - OPENCODE_META_KEY.to_string(), - serde_json::json!({ "toolName": name }), - ); - Some(meta) + meta? + .get("piAcp")? + .get("startupInfo")? + .as_str() + .map(str::trim) + .filter(|banner| !banner.is_empty()) + .map(str::to_string) } -/// `_meta` key marking a tool call as one of codex's `search` command actions -/// (see [`stamp_codex_search_action`]). Frontend twin: -/// `CODEX_SEARCH_ACTION_META_KEY` in `src/lib/codex-command-action.ts`. -const CODEX_SEARCH_ACTION_META_KEY: &str = "codeg.codexSearchAction"; - -/// Stamp codex's `search` command actions on their opening frame, so the -/// frontend can tell them from every other agent's grep. +/// Whether this `agent_message_chunk` IS the pending startup banner — and, if so, +/// consume it so only the first match is dropped. /// -/// It needs to because codeg advertises `_meta.terminal_output_delta`, and with -/// that capability codex-acp completes a command that printed nothing as a -/// bare `failed` status — no `rawOutput` envelope, so no exit code (see -/// `build_client_capabilities`). For a search that is almost always rg's exit -/// 1, "no matches", and `isGrepNoMatchResult` presents it that way — but a -/// bare `failed` with no output is also what an interrupted grep from another -/// adapter can look like, so the rule must know the call is codex's. Only the -/// backend knows the agent at frame level, hence the marker. +/// Taking rather than peeking is what keeps this from being a permanent text +/// filter: pi-acp guards its own emit with `startupInfoSent`, so exactly one +/// chunk can be the prelude, and a later message that happens to repeat the text +/// (the user pasting it back, say) is prose and renders. /// -/// Keyed on the opening frame's `kind: "search"` with no `terminal_info`, which -/// is exactly `createCommandActionEvent`'s search arm (listFiles and read go out -/// as `kind: "read"`). Later frames carry no `_meta` for such a call unless it -/// streamed output, and the reducer keeps the opening `_meta` until one does — -/// by which point the call has output and the rule no longer applies anyway. -fn stamp_codex_search_action( +/// NOT consulted by [`is_agent_output_update`], which has no session state to +/// read — and does not need it: pi-acp queues the prelude in a `setTimeout(…, 0)` +/// fired as `session/new` returns, so it lands on the idle loop, before any +/// prompt. Were it ever to arrive mid-turn, the probe would count it as output — +/// the safe direction, since a turn wrongly called "empty" is the failure that +/// predicate exists to prevent. +/// +/// Cheap for everyone else: non-pi agents return before touching the lock, and a +/// pi session whose banner was already taken pays one read lock — the same lock +/// the emit on this path acquires anyway. +async fn pi_take_startup_banner( agent_type: AgentType, - kind: &ToolKind, - hosted_shell: bool, - meta: Option>, -) -> Option> { - if agent_type != AgentType::Codex || hosted_shell || !matches!(kind, ToolKind::Search) { - return meta; + state: &Arc>, + text: &str, +) -> bool { + if agent_type != AgentType::Pi { + return false; + } + if state.read().await.pi_startup_banner.is_none() { + return false; + } + let mut guard = state.write().await; + match guard.pi_startup_banner.as_deref() { + Some(banner) if banner == text.trim() => { + guard.pi_startup_banner = None; + true + } + _ => false, } - let mut meta = meta.unwrap_or_default(); - meta.insert( - CODEX_SEARCH_ACTION_META_KEY.to_string(), - serde_json::Value::Bool(true), - ); - Some(meta) } -/// Resolve the live `raw_output` string for an OpenCode tool call. +/// The mode id carried by a Gemini `[MODE_UPDATE] ` chunk, if this chunk +/// IS one. /// -/// OpenCode's ACP adapter reports a finished tool on BOTH channels: the clean -/// result text on `content[]`, and the envelope `{output, metadata?, -/// attachments?}` — wrapping that very same string — on `rawOutput` (a failure -/// sends `{error, metadata?}` beside the error text). Stringifying the envelope -/// shadows the clean text, because the live renderer's `raw_output_chunks` win -/// over `content` (`conversation-runtime-store.ts`), so every card that parses -/// the result ITSELF is handed JSON source instead of the result. +/// gemini-cli announces approval-mode changes as PROSE rather than as the +/// `current_mode_update` the protocol has for it: `handleApprovalModeChanged` +/// pushes an `agent_message_chunk` whose entire content is +/// `[MODE_UPDATE] ${payload.mode}` (packages/cli/src/acp/acpSession.ts, 0.60.0). +/// Rendered as-is that is a literal assistant bubble, and codeg's own mode +/// selector never follows the switch the user just made in the agent. /// -/// Verified against opencode 1.18.23 (driven over real ACP with a stub MCP -/// server): a codeg-mcp `ask_user_question` completes as -/// content: [{"type":"content","content":{"type":"text","text":"The user -/// answered your question(s):\n1. [框架] …\n → 选项 A\n"}}] -/// rawOutput: {"output":"","metadata":{"truncated":false}} -/// OpenCode drops the MCP `structuredContent` entirely, so the human-readable -/// lines ARE the whole record — and `AskQuestionResultCard`, seeing only the -/// one-line JSON blob, matched neither the structured envelope nor the text -/// fallback and rendered an answered question as "no selection", while the -/// history parser (which reads the same `state.output` bare) rendered it fine. +/// Two guards keep this from eating real prose: /// -/// Same parity rule as Grok and pi (see [`grok_live_tool_output`]): whenever -/// `content` carries anything, it IS OpenCode's own rendering of this result — -/// return `None` and let it render. Only with no `content` is the envelope -/// unwrapped, mirroring `parsers/opencode.rs`: `output`, else the failure -/// `error`, else `metadata.output` (a command writing only to stderr leaves -/// `state.output` empty while the combined stream stays in the metadata). An -/// unrecognized payload still stringifies as before, so no result is ever lost. -fn opencode_live_tool_output( - content: &Option, - raw_output: &Option, +/// - the marker is matched against the WHOLE trimmed chunk, never as a +/// substring, so an assistant paragraph that happens to quote the marker +/// mid-sentence still renders; +/// - the id must be one the agent ADVERTISED for this session, not a mode name +/// we hardcoded. That is both tighter and self-maintaining: `plan` only +/// exists when `isPlanEnabled()` (`buildAvailableModes`), so at a session +/// without it a user typing `[MODE_UPDATE] plan` gets their own text back +/// instead of a silent swallow, and a mode gemini adds later needs no change +/// here. Before the modes handshake lands there is nothing to match against +/// and this returns `None` — prose, which is the safe direction: showing one +/// ugly literal beats eating a reply. +/// +/// Residual risk, accepted knowingly: gemini emits one chunk per model stream +/// event with boundaries it chooses, so a reply that DISCUSSES the marker could +/// in principle split so that one whole chunk is exactly `[MODE_UPDATE] yolo`. +/// That would both swallow the line and desync codeg's mode selector (the event +/// is latched into `modes.current_mode_id`) until the next real switch. There +/// is no signal on the wire that separates that chunk from a genuine one, and +/// the alternative — rendering the literal on every real mode switch — is the +/// common case rather than the pathological one. +/// +/// NOT consulted by [`is_agent_output_update`], which is sync and has no +/// session state to read the advertised modes from. Same call as the pi banner +/// makes, for the same reason: counting one of these as output is the safe +/// direction, and the turn it would have to mislabel — one carrying a mode +/// update and nothing else — does not occur, since the mid-turn switch gemini +/// makes (`exit_plan_mode`) always lands alongside its own tool call. +/// +/// Cheap for everyone else: non-Gemini agents return before touching the lock, +/// and a Gemini chunk that is ordinary prose returns at the prefix test. +async fn gemini_mode_update_chunk( + agent_type: AgentType, + state: &Arc>, + text: &str, ) -> Option { - // `read` is the one tool whose `content` is LOSSY rather than merely - // redundant: OpenCode hands the client the file body with the line numbers - // stripped, while `metadata.display` still carries `lineStart`. The history - // parser rebuilds `{start_line, content}` from it, so without this the same - // finished `read` renders numbered after a reload and unnumbered while - // live. `metadata.display` is unique to `read`, so every other tool keeps - // the parity rule below. - if let Some(structured) = raw_output - .as_ref() - .and_then(|raw| crate::parsers::opencode::structure_read_output(raw.get("metadata"))) - { - return Some(structured); - } - if content.as_deref().is_some_and(|c| !c.trim().is_empty()) { + if agent_type != AgentType::Gemini { return None; } - let raw = raw_output.as_ref()?; - fn text(value: Option<&serde_json::Value>) -> Option<&str> { - value - .and_then(serde_json::Value::as_str) - .filter(|s| !s.trim().is_empty()) + let mode_id = text.trim().strip_prefix("[MODE_UPDATE] ")?.trim(); + if mode_id.is_empty() { + return None; } - text(raw.get("output")) - .or_else(|| text(raw.get("error"))) - .or_else(|| text(raw.pointer("/metadata/output"))) - .map(structurize_live_output) - .or_else(|| json_value_to_text(raw_output).map(|t| structurize_live_output(&t))) + state + .read() + .await + .modes + .as_ref()? + .available_modes + .iter() + .any(|mode| mode.id == mode_id) + .then(|| mode_id.to_string()) } -/// What a pi `agent_message_chunk` actually IS (issue #525). +/// Grok wraps every MCP tool invocation in a generic `use_tool` envelope whose +/// `raw_input` is `{"tool_name": "__", "tool_input": {..real args..}}`. +/// Peel it so the call is correlated (delegation `lifecycle.rs`), classified, and +/// parsed as a direct MCP call — identical to how hosts like Claude Code surface +/// MCP tools. Without this, Grok's `delegate_to_agent` (and the other codeg-mcp +/// companion tools) never resolve to their dedicated cards, and the delegation +/// broker can't correlate the parent tool call to bind the sub-session. /// -/// pi-acp puts the assistant's prose AND its own lifecycle announcements on the -/// same `agent_message_chunk` channel, so a caffeinate extension's notify lands -/// spliced into the reply: `你好。有什么需要我帮你处理?Released pi-caffeinate -/// (agent finished).` See [`pi_message_chunk_route`] for the full inventory. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PiChunkRoute { - /// The agent's own words — pi's `message_update` / `text_delta`. Renders as - /// today's `ContentDelta`. This is what EVERY other agent's chunk resolves - /// to, since the classifier is pi-gated. - Prose, - /// A pi-acp lifecycle announcement with nothing downstream needs it. - Drop, - /// pi is auto-retrying the model call. Routed to the shared retry banner - /// (`AcpEvent::TurnRetrying`) instead of the prose stream. The numbers are - /// pi's own, recovered from the sentence it formats them into; all three are - /// `None` for pi-acp's shapeless `"Retrying..."` fallback. - Retrying { - attempt: Option, - max: Option, - delay_ms: Option, - }, +/// Returns `(inner_tool_name, inner_tool_input)` only for the envelope shape — +/// a non-empty string `tool_name` plus a `tool_input` value — so Grok's native +/// tools (`run_terminal_command`, `search_tool`, `spawn_subagent`, …), which +/// carry their args directly, pass through untouched. +fn unwrap_grok_use_tool( + raw_input: Option<&serde_json::Value>, +) -> Option<(String, serde_json::Value)> { + let obj = raw_input?.as_object()?; + let tool_name = obj + .get("tool_name") + .and_then(serde_json::Value::as_str) + .filter(|s| !s.is_empty())?; + let tool_input = obj.get("tool_input")?; + Some((tool_name.to_string(), tool_input.clone())) } -/// Tell a pi `agent_message_chunk` that is the ASSISTANT SPEAKING from one that -/// is pi-acp ANNOUNCING SOMETHING (issue #525). -/// -/// Real prose reaches this channel from exactly one place: pi-acp's -/// `message_update` arm, for `assistantMessageEvent.type === "text_delta"`. -/// Everything below is pi-acp synthesizing a sentence out of a pi RPC lifecycle -/// event and emitting it on that SAME channel, where the live store appends it -/// into whatever message is open — which is the whole bug: -/// -/// | pi-acp `dist/index.js` | text | marker | -/// |---|---|---| -/// | L1265 `extension_ui_request` / `notify` | the extension's own message | `_meta.piAcp.notify` | -/// | L1169 `auto_retry_start` | `Retrying (attempt N/M, waiting Ss)...` | — | -/// | L1176 `auto_retry_end` | `Retry finished, resuming.` | — | -/// | L1183 `auto_compaction_start` | `Context nearing limit, running automatic compaction...` | — | -/// | L1193 `auto_compaction_end` | `Automatic compaction finished; …` | — | -/// | L834 `prompt()` queue | `Queued message (position N).` | — | -/// | L1222 `agent_settled` queue | `Starting queued message. (N remaining)` | — | -/// | L860 `cancel()` queue | `Cleared queued prompts.` | — | +/// Extract the human-readable text from a Grok MCP `rawOutput` +/// (`{"type":"MCP","output":{"OkayOutput":"…"}}`, or an `*Output` error variant). +/// The MCP result is the first string value under `output` (`output` may itself +/// be a bare string on some tools). Returns `None` for a non-MCP `rawOutput` so +/// the caller can fall through to the Bash/`output_for_prompt` path. +fn grok_mcp_output_text(raw_output: &serde_json::Value) -> Option { + if raw_output.get("type").and_then(serde_json::Value::as_str) != Some("MCP") { + return None; + } + let output = raw_output.get("output")?; + if let Some(text) = output.as_str() { + return (!text.is_empty()).then(|| text.to_string()); + } + // First NON-EMPTY string value (the singleton `*Output` variant). Filtering + // inside `find_map` — not after — so an earlier empty-string sibling can't + // shadow a later populated one. + output + .as_object()? + .values() + .find_map(|v| v.as_str().filter(|s| !s.is_empty())) + .map(str::to_string) +} + +/// Recover a codeg-mcp companion tool's identity from its RESULT text, for +/// Cursor sessions only. /// -/// The notify marker is read FIRST and wins outright, because that text is -/// arbitrary extension content — a pi extension can notify anything, including -/// something that reads exactly like prose, so the structured marker is the only -/// trustworthy handle on it. The other seven carry no marker at all, and pi-acp -/// offers no alternative channel for them, so they are matched as LITERALS. -/// Three things keep that honest: the whole classifier is gated on -/// `AgentType::Pi`; every rule matches the WHOLE trimmed chunk, never a -/// substring; and the adapter version is pinned in `registry.rs`, so a bump is -/// the natural place to re-check these strings. A false positive would need the -/// model to emit one of these exact sentences as an entire standalone delta, and -/// would cost one dropped delta — not a corrupted message. +/// Cursor's ACP layer announces every MCP call from the first streaming +/// partial — before `McpArgs` exists — so the announcement is the literal +/// title "MCP: tool" with an empty `raw_input`, and `sendToolCallUpdate` +/// (bundle-verified) never forwards `title`/`raw_input` again. The ONLY +/// wire signal that ever identifies the call is the MCP result text arriving +/// on the completion update, and for the codeg-mcp companion tools that text +/// is a codeg-owned contract: /// -/// Two families deliberately stay `Prose`, and must: +/// * a `delegate_to_agent` ack opens with +/// `"Delegation successful. task_id="` (`broker.rs::running_ack`); +/// * `get_delegation_status` renders the compact `{"tasks":[..]}` JSON +/// (`companion.rs::render_batch_report`), whose items carry `task_id` + +/// a `status` from the fixed report vocabulary. /// -/// - **Slash-command replies** (pi-acp L2080+: `/compact`, `/session`, `/name`, -/// `/export`, `/follow-up`, `/steering`, `/changelog`). The user ASKED for -/// those; they ride the same channel and match no rule here, which is exactly -/// the point of matching whole literals rather than sniffing for -/// "status-looking" text. (The startup prelude — `sendStartupInfoIfPending` — -/// is NOT in this family: it is dropped, but by -/// [`pi_take_startup_banner`] against the text pi-acp itself reported, never -/// by a rule here.) -/// - **`Pi UI request is not supported in ACP yet; cancelling it.`** -/// (L1257). pi asked the user for input and pi-acp auto-cancelled it — a rare, -/// actionable failure with no better home today. Dropping it would hide the -/// reason a turn went sideways, which is worse than the noise this fixes. +/// (`cancel_delegation` results are free-form report messages with no stable +/// prefix, so a canceled call keeps the generic title — a rare op, accepted.) /// -/// Used by BOTH the renderer (`emit_conversation_update`) and the empty-turn -/// probe (`is_agent_output_update`), so the two can never disagree about whether -/// a chunk was output — a status-only turn must not render blank AND report -/// success. -fn pi_message_chunk_route( - agent_type: AgentType, - text: &str, - meta: Option<&serde_json::Map>, -) -> PiChunkRoute { - if agent_type != AgentType::Pi { - return PiChunkRoute::Prose; +/// Matching those shapes lets the completion update rewrite the title to the +/// canonical `__` form (the exact name the history parser +/// derives from `McpArgs`), so the frontend resolves the dedicated delegation +/// cards instead of a generic "MCP: tool" call. Returns `None` for everything +/// else — an unrecognized result keeps the wire title untouched. Callers gate +/// the sniff to calls ANNOUNCED with the identity-less "MCP: tool" title +/// (`CodeBuddyLiveState::cursor_generic_mcp_ids`), so a native tool whose +/// output merely echoes these shapes is never re-titled. +fn cursor_companion_title_from_content(content: Option<&str>) -> Option<&'static str> { + let text = content?.trim_start(); + if text.starts_with("Delegation successful. task_id=") { + return Some(crate::acp::delegation::DELEGATE_TOOL_REWRITE_TITLE); } - // A pi extension's `ui.notify()`. pi's own semantics for it are a transient - // toast (`rpc-mode.js`: "Fire and forget - no response needed"), never - // conversation — so it is dropped at every level (`info` / `warning` / - // `error`). Keyed off the marker alone: the message is the extension's, and - // matching it as text would be matching arbitrary user content. - if meta.is_some_and(|meta| { - meta.get("piAcp") - .and_then(|pi| pi.get("notify")) - .is_some_and(serde_json::Value::is_object) - }) { - return PiChunkRoute::Drop; + // Cheap guards before the full JSON parse: the status report is a JSON + // object whose first key is `tasks`. + if !text.starts_with('{') || !text.contains("\"tasks\"") { + return None; } - let text = text.trim(); - match text { - // `auto_retry_end`. No event of its own: the banner clears at the next - // tool call / plan update / turn boundary, exactly as codex's does. - "Retry finished, resuming." => PiChunkRoute::Drop, - // `formatAutoRetryMessage`'s fallback, when pi's event carried no usable - // attempt / maxAttempts / delayMs. - "Retrying..." => PiChunkRoute::Retrying { - attempt: None, - max: None, - delay_ms: None, - }, - // Compaction. Dropped rather than rendered as the shared - // `_meta.contextCompaction` card: a tool call synthesized at the END of a - // turn would land after the reply, and `SessionState`'s "final assistant - // text" is the text FOLLOWING the last tool call — so the card would - // silently blank the delegation result / work-task summary. Restoring the - // card is a follow-up, gated on teaching that extraction to skip - // compaction refs (which codex and grok need too). - "Context nearing limit, running automatic compaction..." - | "Automatic compaction finished; context was summarized to continue the session." => { - PiChunkRoute::Drop - } - // pi-acp's own prompt queue. codeg's turn gate normally makes this - // unreachable (`manager.rs` rejects a concurrent prompt), so all three - // are handled defensively and together — one of them showing up alone - // would be the odd one out. - "Cleared queued prompts." => PiChunkRoute::Drop, - _ => { - if let Some((attempt, max, delay_ms)) = pi_parse_retry_announcement(text) { - PiChunkRoute::Retrying { - attempt: Some(attempt), - max: Some(max), - delay_ms: Some(delay_ms), - } - } else if pi_is_queue_announcement(text) { - PiChunkRoute::Drop - } else { - PiChunkRoute::Prose - } - } + let v: serde_json::Value = serde_json::from_str(text).ok()?; + let tasks = v.get("tasks")?.as_array()?; + let is_report_item = |t: &serde_json::Value| { + t.get("task_id").and_then(|x| x.as_str()).is_some() + && t.get("status").and_then(|x| x.as_str()).is_some_and(|s| { + matches!(s, "running" | "completed" | "failed" | "canceled" | "unknown") + }) + }; + if !tasks.is_empty() && tasks.iter().all(is_report_item) { + return Some(crate::acp::delegation::STATUS_TOOL_REWRITE_TITLE); } + None } -/// Recover `(attempt, max, delay_ms)` from `Retrying (attempt 1/3, waiting 2s)...` -/// — pi-acp's `formatAutoRetryMessage`, which is the only place codeg can reach -/// these numbers: pi sends them structured to pi-acp, which formats them into a -/// sentence and forwards nothing else. -/// -/// Worth recovering rather than shipping the sentence as the banner's message, -/// because the banner already has localized slots for exactly this data -/// (`claudeApiRetry.retryingWithMax` / `nextRetryIn`) — so a zh-CN user reads -/// `正在重试 1/3,2.0 秒后重试` instead of an English sentence with a Chinese -/// suffix bolted on. -/// -/// Parsed by hand rather than by regex: the shape is fixed, and every field is -/// re-validated (`{n}/{n}`, `{n}s`, nothing left over), so a sentence that merely -/// starts the same way falls through to `Prose` instead of half-matching. -fn pi_parse_retry_announcement(text: &str) -> Option<(u32, u32, u64)> { - let body = text - .strip_prefix("Retrying (attempt ")? - .strip_suffix("s)...")?; - let (attempts, delay_seconds) = body.split_once(", waiting ")?; - let (attempt, max) = attempts.split_once('/')?; - Some(( - attempt.parse().ok()?, - max.parse().ok()?, - delay_seconds.parse::().ok()?.checked_mul(1000)?, - )) +/// Mirrors `parsers/opencode.rs:425-429` (and `parsers/codebuddy.rs`'s +/// `subagent_type → "Agent"` rewrite) so streaming and reload-from-DB render the +/// same Agent card. The SQLite-side condition is +/// `tool == "task" && state.input.subagent_type IS NOT NULL`, where `tool` is the +/// agent's **internal** tool name. ACP only exposes a user-facing `title` (e.g. +/// "Explore project structure") rather than the internal tool name, so we cannot +/// replicate the `tool == "task"` half of the AND here. We instead anchor on a +/// known sub-agent-capable `agent_type` (OpenCode and CodeBuddy — both surface a +/// description-style title and the standard `{…, subagent_type}` input, and never +/// emit a bare top-level `subagent_type` for anything but a sub-agent) plus the +/// non-empty `subagent_type` string in `raw_input` — together these uniquely +/// identify a sub-agent invocation in practice. Other agents stay excluded to +/// avoid any cross-agent collision a generic `subagent_type` field could cause. +fn is_subagent_invocation(agent_type: AgentType, raw_input: &Option) -> bool { + if !matches!(agent_type, AgentType::OpenCode | AgentType::CodeBuddy) { + return false; + } + let Some(text) = raw_input.as_deref() else { + return false; + }; + // Cheap substring guard avoids parsing large `raw_input` payloads + // (e.g. prompts with many KB of context) when the field is absent. + if !text.contains("subagent_type") { + return false; + } + let Ok(value) = serde_json::from_str::(text) else { + return false; + }; + value + .get("subagent_type") + .and_then(|v| v.as_str()) + .map(|s| !s.is_empty()) + .unwrap_or(false) } -/// `Queued message (position N).` / `Starting queued message. (N remaining)` — -/// the two pi-acp queue announcements that carry an interpolated count (the -/// third, `Cleared queued prompts.`, is a plain literal handled by the caller). -fn pi_is_queue_announcement(text: &str) -> bool { - let counted = text - .strip_prefix("Queued message (position ") - .and_then(|rest| rest.strip_suffix(").")) - .or_else(|| { - text.strip_prefix("Starting queued message. (") - .and_then(|rest| rest.strip_suffix(" remaining)")) - }); - counted.is_some_and(|count| !count.is_empty() && count.bytes().all(|b| b.is_ascii_digit())) +/// CodeBuddy routes MCP tools through its `DeferExecuteTool` virtualization +/// layer, which surfaces over ACP as a tool call whose `raw_input` wraps the real +/// call as `{ "toolName": "mcp__…", "params": { … } }`. Return that inner +/// `toolName` so the caller can rewrite the live `title` to it — making the +/// frontend resolve the dedicated card (delegation / question / …), mirroring the +/// historical unwrap in `parsers/codebuddy.rs`. `raw_input` is left untouched +/// (the cards peel `params` themselves, and that keeps `inferFromInput` from +/// misclassifying `cancel_delegation`'s `{task_id}` as a generic task). +fn codebuddy_deferred_tool_name(agent_type: AgentType, raw_input: &Option) -> Option { + if agent_type != AgentType::CodeBuddy { + return None; + } + let text = raw_input.as_deref()?; + // Cheap substring guard before parsing a potentially large payload. + if !text.contains("toolName") { + return None; + } + let value = serde_json::from_str::(text).ok()?; + crate::parsers::codebuddy::deferred_tool_name(&value).map(|s| s.to_string()) +} + +/// CodeBuddy ships a deferred MCP tool's RESULT as a single re-serialized +/// `{ "type": "text", "text": }` content part (the OpenAI-Agents content +/// shape), where `` is the MCP `CallToolResult` content text — for the +/// delegation companion, the compact report / `{ "tasks": [...] }` JSON. The +/// dedicated cards (`parseStatusReport` / `parseToolOutput`) expect that bare +/// inner payload (the content-only host shape they already handle for Claude +/// Code), NOT this wrapper, so a live `get_delegation_status` / `cancel_delegation` +/// poll otherwise renders as raw JSON text. Peel the wrapper to its inner `text`, +/// mirroring the historical `deferred_result_envelope` normalization in +/// `parsers/codebuddy.rs`. +/// +/// Gated on CodeBuddy + the exact wrapper shape (`type == "text"` with a string +/// `text`): a non-deferred result (Bash/Read/ToolSearch/…) is never a lone +/// `{type,text}` object, and no delegation report carries a top-level `type`, so +/// those pass through untouched. Unlike the title rewrite, this needs no +/// `raw_input`, so it also normalizes a result-only `ToolCallUpdate` that omits it. +fn unwrap_codebuddy_deferred_output(agent_type: AgentType, text: &str) -> Option { + if agent_type != AgentType::CodeBuddy { + return None; + } + // Cheap substring guard before parsing a potentially large payload. + if !text.contains("\"type\"") { + return None; + } + let value = serde_json::from_str::(text).ok()?; + let obj = value.as_object()?; + if obj.get("type").and_then(|t| t.as_str()) != Some("text") { + return None; + } + obj.get("text").and_then(|t| t.as_str()).map(str::to_string) } -/// The session prelude pi-acp reports on `session/new`, if this response is -/// carrying one. -/// -/// pi-acp builds a markdown banner (`buildStartupInfo`: pi's version, the -/// project `AGENTS.md`, every discovered skill file, prompts, extensions, an -/// update notice) and pushes it down the ORDINARY `agent_message_chunk` channel -/// with no marker of any kind — so codeg rendered it as the assistant's opening -/// words, before the user had said anything, complete with the absolute paths of -/// every skill and extension on the machine. -/// -/// The trustworthy handle is on the `session/new` RESPONSE instead: -/// `_meta.piAcp.startupInfo` holds the very same string (verified byte-for-byte -/// against pi-acp 0.0.33 driven over real stdio ACP — `startupInfo === chunk`). -/// Capturing it there and matching the chunk against it is what lets the drop be -/// exact rather than a guess about what "looks like a banner" — the same -/// preference for a structured handle over text that makes the `notify` marker -/// win outright in [`pi_message_chunk_route`]. A pi with `quietStartup` set -/// sends neither, and every other agent has no `piAcp` meta at all. -/// -/// Gated on `AgentType::Pi` for the same reason the terminal-meta bridge is: -/// `piAcp` is pi-acp's own namespace and must not be read off arbitrary agents. -fn pi_startup_banner( +/// True when a CodeBuddy tool call's ACP `_meta` identifies it as a native +/// sub-agent (`Agent`) invocation. CodeBuddy tags this in `_meta` from the FIRST +/// frame (`codebuddy.ai/toolName == "Agent"`) and later adds +/// `codebuddy.ai/isSubagent` / `subagentType` — whereas the `subagent_type` +/// field in `raw_input` (see `is_subagent_invocation`) only streams in dozens of +/// frames later. Reading the meta lets the title rewrite fire on frame 1, so the +/// Agent pill never spends an opening window classified as a generic tool (and +/// its child tool calls, which carry `codebuddy.ai/parentToolCallId` every frame, +/// nest from the start). Gated on CodeBuddy so the generic `codebuddy.ai/*` keys +/// can never affect another agent. +fn codebuddy_meta_marks_subagent( agent_type: AgentType, meta: Option<&serde_json::Map>, -) -> Option { - if agent_type != AgentType::Pi { - return None; +) -> bool { + if agent_type != AgentType::CodeBuddy { + return false; } - meta? - .get("piAcp")? - .get("startupInfo")? - .as_str() - .map(str::trim) - .filter(|banner| !banner.is_empty()) - .map(str::to_string) + let Some(meta) = meta else { + return false; + }; + if meta.get("codebuddy.ai/toolName").and_then(|v| v.as_str()) == Some("Agent") { + return true; + } + if meta.get("codebuddy.ai/isSubagent").and_then(|v| v.as_bool()) == Some(true) { + return true; + } + meta.get("codebuddy.ai/subagentType") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()) } -/// Whether this `agent_message_chunk` IS the pending startup banner — and, if so, -/// consume it so only the first match is dropped. +/// Two adapters report a shell tool call as an ACP `Terminal` content block +/// whose `terminalId` is its OWN tool-call id, then stream the command's output +/// over a bespoke `_meta` channel instead of the ACP terminal channel. Neither +/// ever calls `terminal/create`, so the id they name can never resolve against +/// `TerminalRuntime` — which only ever mints `term_` ids. Codeg renders +/// the resulting placeholder and then polls a terminal that does not exist, so +/// the card stays at `[Terminal: ]` with no command output, ever (#519). /// -/// Taking rather than peeking is what keeps this from being a permanent text -/// filter: pi-acp guards its own emit with `startupInfoSent`, so exactly one -/// chunk can be the prelude, and a later message that happens to repeat the text -/// (the user pasting it back, say) is prose and renders. +/// **pi-acp** says it outright in its README: "No ACP filesystem delegation +/// (`fs/*`) and no ACP terminal delegation (`terminal/*`). pi reads/writes and +/// executes locally." Its wire, per 0.0.33 (`emitBashToolCall` / +/// `emitBashOutputUpdate`): +/// - `tool_call`: `title` = the command, `kind: execute`, the `Terminal` block, +/// `_meta.terminal_info = {terminal_id, cwd}`, and NO `rawInput`. +/// - `tool_call_update` ×N: `_meta.terminal_output = {terminal_id, data}` where +/// `data` is an incremental delta, plus `_meta.terminal_exit = +/// {terminal_id, exit_code, signal}` on the final frame. No `content`, no +/// `rawOutput` — this `_meta` is the only channel carrying the output. /// -/// NOT consulted by [`is_agent_output_update`], which has no session state to -/// read — and does not need it: pi-acp queues the prelude in a `setTimeout(…, 0)` -/// fired as `session/new` returns, so it lands on the idle loop, before any -/// prompt. Were it ever to arrive mid-turn, the probe would count it as output — -/// the safe direction, since a turn wrongly called "empty" is the failure that -/// predicate exists to prevent. +/// **codex-acp** has the SAME shape and codeg never noticed, because unlike pi +/// it also repeats the whole output as `rawOutput` at the end, so the card +/// filled in — just not until the command had finished. `createTerminalCommandEvent` +/// builds `content: [{type: "terminal", terminalId: item.id}]` + +/// `_meta.terminal_info` for every command `commandExecutionUsesTerminalOutput` +/// accepts (one with no single recognized `commandAction` — i.e. a real shell +/// command rather than a `search`/`listFiles` it renders as its own card), and +/// `createCommandOutputDeltaEvent` streams the output as +/// `_meta.terminal_output_delta = {terminal_id, data}` deltas throughout. That +/// key is what `resolveTerminalOutputMode` returns **by default**, with no +/// client capability involved, so this has been on the wire all along. The +/// completion frame then carries `rawOutput = {formatted_output: , exit_code}` plus `_meta.terminal_exit`. Unlike pi, +/// codex DOES send `rawInput = {command, cwd}` on the opening frame, so the +/// card classifies on its own and only the output channel needs bridging. /// -/// Cheap for everyone else: non-pi agents return before touching the lock, and a -/// pi session whose banner was already taken pays one read lock — the same lock -/// the emit on this path acquires anyway. -async fn pi_take_startup_banner( +/// These readers bridge that channel into the same `raw_output` stream the +/// host-terminal poller produces, so both agents' shell cards read exactly like +/// every other agent's — and stream while the command runs instead of appearing +/// all at once when it ends. +/// +/// GATED ON AN AGENT SET ON PURPOSE: both adapters' keys are unnamespaced +/// (`terminal_output`, not `pi/terminalOutput`), so an ungated reader would be a +/// collision waiting to happen. Known limitation: the agent types resolve from +/// the built-in registry ids, so a user who registers either adapter under a +/// CUSTOM agent id gets `AgentType::Custom` and keeps the old behaviour. That is +/// the right trade — an unnamespaced-meta bridge must not apply to arbitrary +/// agents. +/// +/// Deliberately keyed off the adapter's own `terminal_info` marker rather than +/// the agent type wholesale, so a future release that DOES delegate `terminal/*` +/// goes back to being polled normally. +fn hosted_terminal_meta_marks_shell( agent_type: AgentType, - state: &Arc>, - text: &str, + meta: Option<&serde_json::Map>, ) -> bool { - if agent_type != AgentType::Pi { - return false; - } - if state.read().await.pi_startup_banner.is_none() { + if hosted_terminal_output_key(agent_type).is_none() { return false; } - let mut guard = state.write().await; - match guard.pi_startup_banner.as_deref() { - Some(banner) if banner == text.trim() => { - guard.pi_startup_banner = None; - true - } - _ => false, + meta.is_some_and(|meta| meta.get("terminal_info").is_some_and(|v| v.is_object())) +} + +/// The `_meta` key an adapter streams its self-hosted terminal output under, +/// for the agents whose unnamespaced keys codeg is willing to read. +/// +/// The two spell it differently for a reason: pi predates the ACP delta +/// convention and reuses `terminal_output`, while codex-acp picks between +/// `terminal_output` and `terminal_output_delta` in `resolveTerminalOutputMode` +/// and lands on the delta key unless the client asks for the other one — +/// which codeg does not: it advertises the delta key itself, for the duplicate +/// `rawOutput` that drops (see `build_client_capabilities`). Both carry the +/// same `{terminal_id, data}` payload with incremental `data`, so only the key +/// differs. +fn hosted_terminal_output_key(agent_type: AgentType) -> Option<&'static str> { + match agent_type { + AgentType::Pi => Some("terminal_output"), + AgentType::Codex => Some("terminal_output_delta"), + _ => None, } } -/// The mode id carried by a Gemini `[MODE_UPDATE] ` chunk, if this chunk -/// IS one. +/// The incremental output chunk from the agent's output key, if any. /// -/// gemini-cli announces approval-mode changes as PROSE rather than as the -/// `current_mode_update` the protocol has for it: `handleApprovalModeChanged` -/// pushes an `agent_message_chunk` whose entire content is -/// `[MODE_UPDATE] ${payload.mode}` (packages/cli/src/acp/acpSession.ts, 0.60.0). -/// Rendered as-is that is a literal assistant bubble, and codeg's own mode -/// selector never follows the switch the user just made in the agent. +/// pi computes this delta itself as `next.startsWith(prev) ? next.slice(prev.len) +/// : next`, so in the degenerate case where its cumulative text stops being a +/// prefix extension (stdout still growing AFTER stderr was first folded in) it +/// re-sends the WHOLE text as a "delta" and we append it, duplicating. Codeg +/// cannot detect that without holding the full snapshot, which +/// `ToolCallOutputCache` deliberately does not do (8 KB tail only). Appending is +/// the correct reading of the wire contract; the duplication is an upstream +/// residual. codex has no such degenerate case: its deltas come straight from +/// the exec stream (`item/commandExecution/outputDelta`). /// -/// Two guards keep this from eating real prose: +/// Text codex wrote to a running command's stdin rides the same stream. Through +/// 1.13.x codex-acp sent it as an output delta of its own, `\n\n`; +/// 2.0.0 sends an AIR client a separate `_meta.terminal_input = {terminal_id, +/// data}` instead ("It is not output"). Rendered the way the older adapter — +/// and 2.0.0 for every other client — renders it, so a card that answered a +/// prompt still shows the answer where it was typed. +fn hosted_terminal_output_delta( + agent_type: AgentType, + meta: Option<&serde_json::Map>, +) -> Option { + let key = hosted_terminal_output_key(agent_type)?; + let meta = meta?; + let data = |key: &str| { + meta.get(key)? + .get("data")? + .as_str() + .filter(|data| !data.is_empty()) + }; + let output = data(key).map(str::to_string); + let stdin = (agent_type == AgentType::Codex) + .then(|| data("terminal_input")) + .flatten() + .map(|input| format!("\n{input}\n")); + match (output, stdin) { + (Some(output), Some(stdin)) => Some(output + &stdin), + (output, stdin) => output.or(stdin), + } +} + +/// The `[terminal exited: …]` line for `_meta.terminal_exit`, if present. /// -/// - the marker is matched against the WHOLE trimmed chunk, never as a -/// substring, so an assistant paragraph that happens to quote the marker -/// mid-sentence still renders; -/// - the id must be one the agent ADVERTISED for this session, not a mode name -/// we hardcoded. That is both tighter and self-maintaining: `plan` only -/// exists when `isPlanEnabled()` (`buildAvailableModes`), so at a session -/// without it a user typing `[MODE_UPDATE] plan` gets their own text back -/// instead of a silent swallow, and a mode gemini adds later needs no change -/// here. Before the modes handshake lands there is nothing to match against -/// and this returns `None` — prose, which is the safe direction: showing one -/// ugly literal beats eating a reply. +/// The values are read key by key and fed to `format_terminal_exit_status`, so +/// the wording is byte-for-byte what a host-owned terminal produces and can +/// never drift. Do NOT be tempted to `serde_json::from_value::` +/// the object instead: pi writes SNAKE_case (`exit_code`) while the schema type +/// is `rename_all = "camelCase"`, and unknown fields are ignored — so it +/// deserializes CLEANLY into an all-`None` status and silently prints +/// "[terminal exited: finished]", dropping the exit code the report explicitly +/// asks for. codex-acp writes the same snake_case shape (`{exit_code, signal, +/// terminal_id}`), so the identical reasoning applies to it. +fn hosted_terminal_exit_line( + agent_type: AgentType, + meta: Option<&serde_json::Map>, +) -> Option { + hosted_terminal_output_key(agent_type)?; + let exit = meta?.get("terminal_exit")?.as_object()?; + let code = exit.get("exit_code").and_then(serde_json::Value::as_i64); + let signal = exit + .get("signal") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + // `TerminalExitStatus::exit_code` is `u32`, but pi's is a JS number and some + // runtimes report a signal death as a negative. Format those directly rather + // than dropping the only failure evidence a killed command leaves behind. + let formatted = match code.map(u32::try_from) { + Some(Err(_)) => { + let mut parts = vec![format!("exit code: {}", code.unwrap_or_default())]; + if let Some(signal) = &signal { + parts.push(format!("signal: {signal}")); + } + parts.join(", ") + } + narrowed => format_terminal_exit_status( + &TerminalExitStatus::new() + .exit_code(narrowed.and_then(Result::ok)) + .signal(signal), + ), + }; + Some(format!("[terminal exited: {formatted}]")) +} + +/// Bridge the agent's `_meta` terminal channel onto the `raw_output` stream, +/// returning the `(payload, append)` pair to emit — or `None` when this frame +/// carries no terminal data (which is every frame of every agent outside +/// `hosted_terminal_output_key`). /// -/// Residual risk, accepted knowingly: gemini emits one chunk per model stream -/// event with boundaries it chooses, so a reply that DISCUSSES the marker could -/// in principle split so that one whole chunk is exactly `[MODE_UPDATE] yolo`. -/// That would both swallow the line and desync codeg's mode selector (the event -/// is latched into `modes.current_mode_id`) until the next real switch. There -/// is no signal on the wire that separates that chunk from a genuine one, and -/// the alternative — rendering the literal on every real mode switch — is the -/// common case rather than the pathological one. +/// `append` is false for a call's FIRST chunk, so it REPLACES whatever the +/// opening frame left on the card, and true for every chunk after — the same +/// rule `poll_terminal_tool_call_output` applies via +/// `TrackedTerminalToolCall::has_emitted_output`. The payload goes through +/// `build_emit_payload` for the pipeline-wide ANSI-safe single-event cap. /// -/// NOT consulted by [`is_agent_output_update`], which is sync and has no -/// session state to read the advertised modes from. Same call as the pi banner -/// makes, for the same reason: counting one of these as output is the safe -/// direction, and the turn it would have to mislabel — one carrying a mode -/// update and nothing else — does not occur, since the mid-turn switch gemini -/// makes (`exit_plan_mode`) always lands alongside its own tool call. +/// The exit line is appended on EVERY exit, `exit code: 0` included: for a +/// command that printed nothing it is the only thing that supersedes the +/// placeholder, and it is what a host-owned terminal shows. /// -/// Cheap for everyone else: non-Gemini agents return before touching the lock, -/// and a Gemini chunk that is ordinary prose returns at the prefix test. -async fn gemini_mode_update_chunk( +/// The entry is created here rather than required up front, so a client that +/// attached mid-turn (and so never saw the opening `terminal_info` frame) still +/// gets the output instead of silently dropping it. +fn hosted_terminal_chunk( agent_type: AgentType, - state: &Arc>, - text: &str, -) -> Option { - if agent_type != AgentType::Gemini { - return None; + meta: Option<&serde_json::Map>, + tool_call_id: &str, + tracked: &mut HashMap, +) -> Option<(String, bool)> { + let mut chunk = hosted_terminal_output_delta(agent_type, meta).unwrap_or_default(); + if let Some(exit_line) = hosted_terminal_exit_line(agent_type, meta) { + if !chunk.is_empty() && !chunk.ends_with('\n') { + chunk.push('\n'); + } + chunk.push_str(&exit_line); } - let mode_id = text.trim().strip_prefix("[MODE_UPDATE] ")?.trim(); - if mode_id.is_empty() { + if chunk.is_empty() { return None; } - state - .read() - .await - .modes - .as_ref()? - .available_modes - .iter() - .any(|mode| mode.id == mode_id) - .then(|| mode_id.to_string()) + let has_emitted = tracked.entry(tool_call_id.to_string()).or_insert(false); + let append = *has_emitted; + *has_emitted = true; + Some(build_emit_payload(&chunk, append)) } -/// Grok wraps every MCP tool invocation in a generic `use_tool` envelope whose -/// `raw_input` is `{"tool_name": "__", "tool_input": {..real args..}}`. -/// Peel it so the call is correlated (delegation `lifecycle.rs`), classified, and -/// parsed as a direct MCP call — identical to how hosts like Claude Code surface -/// MCP tools. Without this, Grok's `delegate_to_agent` (and the other codeg-mcp -/// companion tools) never resolve to their dedicated cards, and the delegation -/// broker can't correlate the parent tool call to bind the sub-session. +/// pi-acp titles a bash tool call `bashCommand(args) ?? toolName`. On the first +/// `toolcall_start` frame the arguments are still partial JSON, so the title is +/// the bare tool name `"bash"` and the real command only lands on a later frame. /// -/// Returns `(inner_tool_name, inner_tool_input)` only for the envelope shape — -/// a non-empty string `tool_name` plus a `tool_input` value — so Grok's native -/// tools (`run_terminal_command`, `search_tool`, `spawn_subagent`, …), which -/// carry their args directly, pass through untouched. -fn unwrap_grok_use_tool( - raw_input: Option<&serde_json::Value>, -) -> Option<(String, serde_json::Value)> { - let obj = raw_input?.as_object()?; - let tool_name = obj - .get("tool_name") - .and_then(serde_json::Value::as_str) - .filter(|s| !s.is_empty())?; - let tool_input = obj.get("tool_input")?; - Some((tool_name.to_string(), tool_input.clone())) -} - -/// Extract the human-readable text from a Grok MCP `rawOutput` -/// (`{"type":"MCP","output":{"OkayOutput":"…"}}`, or an `*Output` error variant). -/// The MCP result is the first string value under `output` (`output` may itself -/// be a bare string on some tools). Returns `None` for a non-MCP `rawOutput` so -/// the caller can fall through to the Bash/`output_for_prompt` path. -fn grok_mcp_output_text(raw_output: &serde_json::Value) -> Option { - if raw_output.get("type").and_then(serde_json::Value::as_str) != Some("MCP") { +/// Synthesize a canonical `{"command": …}` `raw_input` from a title that IS the +/// command, so `inferLiveToolName` classifies the call as `bash` and it renders +/// through the Bash card (`$ `) — the same trick +/// `synthesize_edit_input_from_diffs` plays for codex's input-less diffs. Without +/// it the input shape is silent and the title fallback makes the card a generic +/// tool literally NAMED after the command (`node --version`), which is also how +/// it diverged from pi's own history parser (`parsers/pi.rs`, which builds a real +/// `bash` call with `{"command": …}`). +/// +/// Returns `None` for the bare `"bash"` title: `normalizeToolName("bash")` +/// already resolves that frame to the Bash card, and synthesizing there would +/// flash `$ bash` for one frame before the real command arrives. +fn pi_bash_input_from_title(title: Option<&str>) -> Option { + let command = title?.trim(); + if command.is_empty() || command.eq_ignore_ascii_case("bash") { return None; } - let output = raw_output.get("output")?; - if let Some(text) = output.as_str() { - return (!text.is_empty()).then(|| text.to_string()); - } - // First NON-EMPTY string value (the singleton `*Output` variant). Filtering - // inside `find_map` — not after — so an earlier empty-string sibling can't - // shadow a later populated one. - output - .as_object()? - .values() - .find_map(|v| v.as_str().filter(|s| !s.is_empty())) - .map(str::to_string) + Some(serde_json::json!({ "command": command }).to_string()) } -/// Recover a codeg-mcp companion tool's identity from its RESULT text, for -/// Cursor sessions only. +/// Rebuild a Gemini tool call's `raw_input` from its title and locations. /// -/// Cursor's ACP layer announces every MCP call from the first streaming -/// partial — before `McpArgs` exists — so the announcement is the literal -/// title "MCP: tool" with an empty `raw_input`, and `sendToolCallUpdate` -/// (bundle-verified) never forwards `title`/`raw_input` again. The ONLY -/// wire signal that ever identifies the call is the MCP result text arriving -/// on the completion update, and for the codeg-mcp companion tools that text -/// is a codeg-owned contract: +/// gemini-cli puts NO `rawInput` on the ACP wire at all: a call arrives as +/// title + kind + locations + content and nothing else (`acpSession.ts`, +/// 0.60.0). codeg classifies a live call by the SHAPE of its input, so with +/// none of it every gemini tool lands on the generic card named after its own +/// title — no Bash card, no file card, no diff header. /// -/// * a `delegate_to_agent` ack opens with -/// `"Delegation successful. task_id="` (`broker.rs::running_ack`); -/// * `get_delegation_status` renders the compact `{"tasks":[..]}` JSON -/// (`companion.rs::render_batch_report`), whose items carry `task_id` + -/// a `status` from the fixed report vocabulary. +/// This is a whitelist of exact title formats and NOT a match on `kind`, +/// because `kind` cannot identify the tool: `search` covers glob, grep AND web +/// search, and every MCP tool is hardcoded to `other` (`DiscoveredMCPTool`). +/// Synthesizing off `kind` alone would actively mislabel calls — codeg reads a +/// bare `pattern` as grep and a bare `query` as web search, so a guessed +/// `{"pattern": title}` would turn every glob into a grep. Not synthesizing +/// costs a generic card; guessing wrong invents a card that states something +/// untrue, so anything unrecognized returns `None` on purpose. Two formats are +/// deliberately left out for exactly that reason: /// -/// (`cancel_delegation` results are free-form report messages with no stable -/// prefix, so a canceled call keeps the generic title — a rare op, accepted.) +/// - glob and grep can both title themselves a bare `''`. Note it is +/// `grep.ts` that collides with `glob.ts`, NOT the `ripGrep.ts` that normally +/// serves this kind: ripgrep appends ` within …` unconditionally (its +/// `dir_path` defaults to `"."`), while glob and the plain-grep fallback both +/// append it only when `dir_path` is set. So the collision only happens on +/// machines without ripgrep — which is exactly the kind of "usually fine" +/// that makes guessing here a bad trade. +/// - web-fetch's prompt variant carries a `prompt`, not a `url`, so there is no +/// honest shape to emit; above 100 chars it is truncated to 97 + `...` as +/// well. /// -/// Matching those shapes lets the completion update rewrite the title to the -/// canonical `__` form (the exact name the history parser -/// derives from `McpArgs`), so the frontend resolves the dedicated delegation -/// cards instead of a generic "MCP: tool" call. Returns `None` for everything -/// else — an unrecognized result keeps the wire title untouched. Callers gate -/// the sniff to calls ANNOUNCED with the identity-less "MCP: tool" title -/// (`CodeBuddyLiveState::cursor_generic_mcp_ids`), so a native tool whose -/// output merely echoes these shapes is never re-titled. -fn cursor_companion_title_from_content(content: Option<&str>) -> Option<&'static str> { - let text = content?.trim_start(); - if text.starts_with("Delegation successful. task_id=") { - return Some(crate::acp::delegation::DELEGATE_TOOL_REWRITE_TITLE); - } - // Cheap guards before the full JSON parse: the status report is a JSON - // object whose first key is `tasks`. - if !text.starts_with('{') || !text.contains("\"tasks\"") { - return None; - } - let v: serde_json::Value = serde_json::from_str(text).ok()?; - let tasks = v.get("tasks")?.as_array()?; - let is_report_item = |t: &serde_json::Value| { - t.get("task_id").and_then(|x| x.as_str()).is_some() - && t.get("status").and_then(|x| x.as_str()).is_some_and(|s| { - matches!(s, "running" | "completed" | "failed" | "canceled" | "unknown") - }) - }; - if !tasks.is_empty() && tasks.iter().all(is_report_item) { - return Some(crate::acp::delegation::STATUS_TOOL_REWRITE_TITLE); - } - None -} - -/// Mirrors `parsers/opencode.rs:425-429` (and `parsers/codebuddy.rs`'s -/// `subagent_type → "Agent"` rewrite) so streaming and reload-from-DB render the -/// same Agent card. The SQLite-side condition is -/// `tool == "task" && state.input.subagent_type IS NOT NULL`, where `tool` is the -/// agent's **internal** tool name. ACP only exposes a user-facing `title` (e.g. -/// "Explore project structure") rather than the internal tool name, so we cannot -/// replicate the `tool == "task"` half of the AND here. We instead anchor on a -/// known sub-agent-capable `agent_type` (OpenCode and CodeBuddy — both surface a -/// description-style title and the standard `{…, subagent_type}` input, and never -/// emit a bare top-level `subagent_type` for anything but a sub-agent) plus the -/// non-empty `subagent_type` string in `raw_input` — together these uniquely -/// identify a sub-agent invocation in practice. Other agents stay excluded to -/// avoid any cross-agent collision a generic `subagent_type` field could cause. -fn is_subagent_invocation(agent_type: AgentType, raw_input: &Option) -> bool { - if !matches!(agent_type, AgentType::OpenCode | AgentType::CodeBuddy) { - return false; - } - let Some(text) = raw_input.as_deref() else { - return false; - }; - // Cheap substring guard avoids parsing large `raw_input` payloads - // (e.g. prompts with many KB of context) when the field is absent. - if !text.contains("subagent_type") { - return false; - } - let Ok(value) = serde_json::from_str::(text) else { - return false; - }; - value - .get("subagent_type") - .and_then(|v| v.as_str()) - .map(|s| !s.is_empty()) - .unwrap_or(false) -} - -/// CodeBuddy routes MCP tools through its `DeferExecuteTool` virtualization -/// layer, which surfaces over ACP as a tool call whose `raw_input` wraps the real -/// call as `{ "toolName": "mcp__…", "params": { … } }`. Return that inner -/// `toolName` so the caller can rewrite the live `title` to it — making the -/// frontend resolve the dedicated card (delegation / question / …), mirroring the -/// historical unwrap in `parsers/codebuddy.rs`. `raw_input` is left untouched -/// (the cards peel `params` themselves, and that keeps `inferFromInput` from -/// misclassifying `cancel_delegation`'s `{task_id}` as a generic task). -fn codebuddy_deferred_tool_name(agent_type: AgentType, raw_input: &Option) -> Option { - if agent_type != AgentType::CodeBuddy { - return None; - } - let text = raw_input.as_deref()?; - // Cheap substring guard before parsing a potentially large payload. - if !text.contains("toolName") { +/// Every format below is the tool's own `getDisplayTitle()`, which the ACP +/// layer prefers over `getDescription()`. That preference is what makes the +/// `execute` rule exact: shell overrides `getDisplayTitle()` to return +/// `params.command` verbatim, so the title IS the command. (`getDescription()` +/// is the one that swaps in the model's prose description for commands over +/// 150 chars — it feeds the CLI's own TUI and never reaches the wire. Reading +/// that function instead would have made this rule render a Bash card whose +/// `$ …` line was an English sentence.) +/// +/// Only consulted when the agent sent no `raw_input` of its own, and after +/// `synthesize_edit_input_from_diffs`, which reconstructs a better input for +/// the edits that do carry a diff. +fn gemini_synthesize_tool_input( + agent_type: AgentType, + kind: &ToolKind, + title: &str, + locations: &[ToolCallLocation], +) -> Option { + if agent_type != AgentType::Gemini { return None; } - let value = serde_json::from_str::(text).ok()?; - crate::parsers::codebuddy::deferred_tool_name(&value).map(|s| s.to_string()) + let title = title.trim(); + let input = match kind { + ToolKind::Execute if !title.is_empty() => serde_json::json!({ "command": title }), + // NOTE: no `edit`/write-file rule on purpose. gemini ships the edit as a + // `diff` content block on both the permission and completion frames + // (`acpSession.ts`), so `synthesize_edit_input_from_diffs` — which runs + // ahead of this in the `.or()` chain — already rebuilds a real + // `{old_string, new_string}` for it. A `{file_path}` here could only + // ever fire on a frame that carries NO diff, and there it would make + // things worse, not better: `inferFromInput` sees a path plus + // `kind: edit` and returns "edit", which renders through EditToolInput + // and shows BLANK because the old/new strings it reads are absent. A + // generic card beats an empty edit card. + // + // read-file reports exactly one location, carrying the resolved + // absolute path and `params.start_line` (`read-file.ts + // toolLocations()`). One location is a sound discriminator because + // `toolLocations()` is overridden by only three tools in the whole + // tree — read-file, write-file and edit — and everything else inherits + // `tools.ts`'s `return []`. So every OTHER Read-kind tool + // (read_many_files, read-mcp-resource, the shell-background and + // tracker tools) reports ZERO, not several. + // + // The line becomes `offset`, which is the key codeg's file card reads + // (`content-parts-renderer.tsx`, `FileToolInput`); `start_line` is not + // in its vocabulary and would render nothing. + ToolKind::Read if locations.len() == 1 => { + let location = &locations[0]; + match location.line { + Some(line) => serde_json::json!({ "file_path": location.path, "offset": line }), + None => serde_json::json!({ "file_path": location.path }), + } + } + ToolKind::Search => serde_json::json!({ + "query": title + .strip_prefix("Searching the web for: \"")? + .strip_suffix('"')?, + }), + ToolKind::Fetch => serde_json::json!({ + "url": title.strip_prefix("Fetching content from: ")?, + }), + _ => return None, + }; + Some(input.to_string()) } -/// CodeBuddy ships a deferred MCP tool's RESULT as a single re-serialized -/// `{ "type": "text", "text": }` content part (the OpenAI-Agents content -/// shape), where `` is the MCP `CallToolResult` content text — for the -/// delegation companion, the compact report / `{ "tasks": [...] }` JSON. The -/// dedicated cards (`parseStatusReport` / `parseToolOutput`) expect that bare -/// inner payload (the content-only host shape they already handle for Claude -/// Code), NOT this wrapper, so a live `get_delegation_status` / `cancel_delegation` -/// poll otherwise renders as raw JSON text. Peel the wrapper to its inner `text`, -/// mirroring the historical `deferred_result_envelope` normalization in -/// `parsers/codebuddy.rs`. +/// Name used when a codex sub-agent's `path` carries no usable segment. Matches +/// the fallback codex-acp itself uses when building the activity title. +const CODEX_SUBAGENT_FALLBACK_NAME: &str = "subagent"; + +/// How a Codex live `subAgentActivity` (codex-acp #304) should be handled. /// -/// Gated on CodeBuddy + the exact wrapper shape (`type == "text"` with a string -/// `text`): a non-deferred result (Bash/Read/ToolSearch/…) is never a lone -/// `{type,text}` object, and no delegation report carries a top-level `type`, so -/// those pass through untouched. Unlike the title rewrite, this needs no -/// `raw_input`, so it also normalizes a result-only `ToolCallUpdate` that omits it. -fn unwrap_codebuddy_deferred_output(agent_type: AgentType, text: &str) -> Option { - if agent_type != AgentType::CodeBuddy { - return None; - } - // Cheap substring guard before parsing a potentially large payload. - if !text.contains("\"type\"") { - return None; - } - let value = serde_json::from_str::(text).ok()?; - let obj = value.as_object()?; - if obj.get("type").and_then(|t| t.as_str()) != Some("text") { - return None; - } - obj.get("text").and_then(|t| t.as_str()).map(str::to_string) +/// codex 0.147's native team-of-agents runs sub-agents entirely inside the codex +/// process. Its orchestration calls (`spawn_agent` / `wait_agent`, the +/// `collaboration` namespace) never reach the ACP wire, and the inter-agent +/// message is an opaque encrypted envelope even in the on-disk rollout. The one +/// thing codex-acp forwards is `subAgentActivity`, as a `tool_call(kind:other)` +/// carrying `_meta.codex.subagent = {threadId, path, activity}`. +/// +/// codeg used to DROP every one of these, on the premise that the +/// `collabAgentToolCall` capsule already showed the same thing. That premise +/// died with the team-of-agents rewrite: codex raises no `collabAgentToolCall` +/// for a spawn any more, so dropping this left a codex sub-agent completely +/// invisible while it ran — nothing appeared in the timeline until the session +/// was reopened and the rollout re-parsed. +#[derive(Debug)] +enum CodexSubagentActivity { + /// Not a codex sub-agent activity — handle the call normally. + None, + /// A sub-agent was launched. Carries the Agent-card input to render it with. + Started { + thread_id: Option, + input: String, + }, + /// The child reached a terminal state (`completed` / `interrupted`). Carries + /// no card of its own — its `toolCallId` is a synthetic id codeg has never + /// seen (`subagent-completed-`), so rendering it would open a SECOND + /// capsule with the same name and no way to tell it from the launch. It is + /// forwarded onto the LAUNCH capsule instead, keyed by `threadId`. + Terminal { thread_id: String, kind: String }, + /// A mid-life marker (`interacted`). Still dropped: it carries no content of + /// its own and says nothing the launch capsule does not already say. + Other, } -/// True when a CodeBuddy tool call's ACP `_meta` identifies it as a native -/// sub-agent (`Agent`) invocation. CodeBuddy tags this in `_meta` from the FIRST -/// frame (`codebuddy.ai/toolName == "Agent"`) and later adds -/// `codebuddy.ai/isSubagent` / `subagentType` — whereas the `subagent_type` -/// field in `raw_input` (see `is_subagent_invocation`) only streams in dozens of -/// frames later. Reading the meta lets the title rewrite fire on frame 1, so the -/// Agent pill never spends an opening window classified as a generic tool (and -/// its child tool calls, which carry `codebuddy.ai/parentToolCallId` every frame, -/// nest from the start). Gated on CodeBuddy so the generic `codebuddy.ai/*` keys -/// can never affect another agent. -fn codebuddy_meta_marks_subagent( +/// The `_meta` to classify a codex tool call's sub-agent activity from, when the +/// frame's own does not say: codex-acp 2.0.0 sends `_meta.codex.subagent` to no +/// client, and an activity item is then recognised by its `rawInput` +/// (`air_contract::codex_activity_meta_from_raw_input`). `None` means "the +/// frame's own `_meta` is the one to read" — it already carries the legacy +/// marker, or this is not codex, or the input is not an activity. +fn codex_activity_classification_meta( agent_type: AgentType, meta: Option<&serde_json::Map>, -) -> bool { - if agent_type != AgentType::CodeBuddy { - return false; + raw_input: Option<&serde_json::Value>, +) -> Option> { + if agent_type != AgentType::Codex { + return None; } - let Some(meta) = meta else { - return false; - }; - if meta.get("codebuddy.ai/toolName").and_then(|v| v.as_str()) == Some("Agent") { - return true; + if meta + .and_then(|m| m.get("codex")) + .and_then(|codex| codex.get("subagent")) + .is_some() + { + return None; } - if meta.get("codebuddy.ai/isSubagent").and_then(|v| v.as_bool()) == Some(true) { - return true; + let derived = crate::acp::air_contract::codex_activity_meta_from_raw_input(raw_input)?; + let mut merged = meta.cloned().unwrap_or_default(); + if let Some(derived) = derived.as_object() { + for (key, value) in derived { + merged.insert(key.clone(), value.clone()); + } } - meta.get("codebuddy.ai/subagentType") - .and_then(|v| v.as_str()) - .is_some_and(|s| !s.is_empty()) + Some(merged) } -/// Two adapters report a shell tool call as an ACP `Terminal` content block -/// whose `terminalId` is its OWN tool-call id, then stream the command's output -/// over a bespoke `_meta` channel instead of the ACP terminal channel. Neither -/// ever calls `terminal/create`, so the id they name can never resolve against -/// `TerminalRuntime` — which only ever mints `term_` ids. Codeg renders -/// the resulting placeholder and then polls a terminal that does not exist, so -/// the card stays at `[Terminal: ]` with no command output, ever (#519). -/// -/// **pi-acp** says it outright in its README: "No ACP filesystem delegation -/// (`fs/*`) and no ACP terminal delegation (`terminal/*`). pi reads/writes and -/// executes locally." Its wire, per 0.0.33 (`emitBashToolCall` / -/// `emitBashOutputUpdate`): -/// - `tool_call`: `title` = the command, `kind: execute`, the `Terminal` block, -/// `_meta.terminal_info = {terminal_id, cwd}`, and NO `rawInput`. -/// - `tool_call_update` ×N: `_meta.terminal_output = {terminal_id, data}` where -/// `data` is an incremental delta, plus `_meta.terminal_exit = -/// {terminal_id, exit_code, signal}` on the final frame. No `content`, no -/// `rawOutput` — this `_meta` is the only channel carrying the output. -/// -/// **codex-acp** has the SAME shape and codeg never noticed, because unlike pi -/// it also repeats the whole output as `rawOutput` at the end, so the card -/// filled in — just not until the command had finished. `createTerminalCommandEvent` -/// builds `content: [{type: "terminal", terminalId: item.id}]` + -/// `_meta.terminal_info` for every command `commandExecutionUsesTerminalOutput` -/// accepts (one with no single recognized `commandAction` — i.e. a real shell -/// command rather than a `search`/`listFiles` it renders as its own card), and -/// `createCommandOutputDeltaEvent` streams the output as -/// `_meta.terminal_output_delta = {terminal_id, data}` deltas throughout. That -/// key is what `resolveTerminalOutputMode` returns **by default**, with no -/// client capability involved, so this has been on the wire all along. The -/// completion frame then carries `rawOutput = {formatted_output: , exit_code}` plus `_meta.terminal_exit`. Unlike pi, -/// codex DOES send `rawInput = {command, cwd}` on the opening frame, so the -/// card classifies on its own and only the output channel needs bridging. -/// -/// These readers bridge that channel into the same `raw_output` stream the -/// host-terminal poller produces, so both agents' shell cards read exactly like -/// every other agent's — and stream while the command runs instead of appearing -/// all at once when it ends. -/// -/// GATED ON AN AGENT SET ON PURPOSE: both adapters' keys are unnamespaced -/// (`terminal_output`, not `pi/terminalOutput`), so an ungated reader would be a -/// collision waiting to happen. Known limitation: the agent types resolve from -/// the built-in registry ids, so a user who registers either adapter under a -/// CUSTOM agent id gets `AgentType::Custom` and keeps the old behaviour. That is -/// the right trade — an unnamespaced-meta bridge must not apply to arbitrary -/// agents. +/// The stashed viewed-file result a claude completion should carry, if any +/// (see `CodeBuddyLiveState::claude_viewed_results`). /// -/// Deliberately keyed off the adapter's own `terminal_info` marker rather than -/// the agent type wholesale, so a future release that DOES delegate `terminal/*` -/// goes back to being polled normally. -fn hosted_terminal_meta_marks_shell( +/// The stash entry is spent at the call's terminal status either way; it is +/// only RETURNED for a completion that carried no result of its own — the +/// case claude-agent-acp 0.82.0 produces for an AIR client's read or search. +/// A failed call is not filled: the adapter sends a failure's text itself. +fn claude_viewed_result_for_completion( agent_type: AgentType, - meta: Option<&serde_json::Map>, -) -> bool { - if hosted_terminal_output_key(agent_type).is_none() { - return false; - } - meta.is_some_and(|meta| meta.get("terminal_info").is_some_and(|v| v.is_object())) -} - -/// The `_meta` key an adapter streams its self-hosted terminal output under, -/// for the agents whose unnamespaced keys codeg is willing to read. -/// -/// The two spell it differently for a reason: pi predates the ACP delta -/// convention and reuses `terminal_output`, while codex-acp picks between -/// `terminal_output` and `terminal_output_delta` in `resolveTerminalOutputMode` -/// and lands on the delta key unless the client asks for the other one — -/// which codeg does not: it advertises the delta key itself, for the duplicate -/// `rawOutput` that drops (see `build_client_capabilities`). Both carry the -/// same `{terminal_id, data}` payload with incremental `data`, so only the key -/// differs. -fn hosted_terminal_output_key(agent_type: AgentType) -> Option<&'static str> { - match agent_type { - AgentType::Pi => Some("terminal_output"), - AgentType::Codex => Some("terminal_output_delta"), - _ => None, + stash: &mut HashMap, + tool_call_id: &str, + status: Option<&agent_client_protocol::schema::v1::ToolCallStatus>, + carries_result: bool, +) -> Option { + if agent_type != AgentType::ClaudeCode { + return None; + } + let status = status?; + let completed = matches!(status, agent_client_protocol::schema::v1::ToolCallStatus::Completed); + let failed = matches!(status, agent_client_protocol::schema::v1::ToolCallStatus::Failed); + if !completed && !failed { + return None; } + let stashed = stash.remove(tool_call_id)?; + (completed && !carries_result).then_some(stashed) } -/// The incremental output chunk from the agent's output key, if any. -/// -/// pi computes this delta itself as `next.startsWith(prev) ? next.slice(prev.len) -/// : next`, so in the degenerate case where its cumulative text stops being a -/// prefix extension (stdout still growing AFTER stderr was first folded in) it -/// re-sends the WHOLE text as a "delta" and we append it, duplicating. Codeg -/// cannot detect that without holding the full snapshot, which -/// `ToolCallOutputCache` deliberately does not do (8 KB tail only). Appending is -/// the correct reading of the wire contract; the duplication is an upstream -/// residual. codex has no such degenerate case: its deltas come straight from -/// the exec stream (`item/commandExecution/outputDelta`). +/// Classify a live tool call's `_meta`, building the Agent-card input for a +/// launch. The three fields are the ones `parsers/codex.rs` writes on reload, so +/// live and history render the same capsule: the sub-agent's name is the last +/// segment of its `path` (`/root/pnpm_build` → `pnpm_build`) and `agent_id` is +/// its codex thread id, which the card renders as a short badge. No `prompt` — +/// the task text is encrypted on this wire. /// -/// Text codex wrote to a running command's stdin rides the same stream. Through -/// 1.13.x codex-acp sent it as an output delta of its own, `\n\n`; -/// 2.0.0 sends an AIR client a separate `_meta.terminal_input = {terminal_id, -/// data}` instead ("It is not output"). Rendered the way the older adapter — -/// and 2.0.0 for every other client — renders it, so a card that answered a -/// prompt still shows the answer where it was typed. -fn hosted_terminal_output_delta( +/// The capsule settles as soon as codex acknowledges the launch, NOT when the +/// child finishes: the activity item's own lifecycle is the spawn's. codex DOES +/// say so later, with a `completed` / `interrupted` activity of its own — see +/// [`CodexSubagentActivity::Terminal`], which is routed back onto the launch +/// capsule rather than rendered. +fn classify_codex_subagent_activity( agent_type: AgentType, meta: Option<&serde_json::Map>, -) -> Option { - let key = hosted_terminal_output_key(agent_type)?; - let meta = meta?; - let data = |key: &str| { - meta.get(key)? - .get("data")? - .as_str() - .filter(|data| !data.is_empty()) +) -> CodexSubagentActivity { + if agent_type != AgentType::Codex { + return CodexSubagentActivity::None; + } + let Some(subagent) = meta + .and_then(|m| m.get("codex")) + .and_then(|codex| codex.get("subagent")) + else { + return CodexSubagentActivity::None; }; - let output = data(key).map(str::to_string); - let stdin = (agent_type == AgentType::Codex) - .then(|| data("terminal_input")) - .flatten() - .map(|input| format!("\n{input}\n")); - match (output, stdin) { - (Some(output), Some(stdin)) => Some(output + &stdin), - (output, stdin) => output.or(stdin), + let thread_id = subagent + .get("threadId") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()); + // A status-only follow-up carries the same meta with the same `activity`, + // so this classification is stable across the call's whole lifetime. + match subagent.get("activity").and_then(|v| v.as_str()) { + Some("started") => {} + // Without a thread id there is no capsule to attribute the outcome to, + // so it degrades to the old drop rather than opening a stray card. + Some(kind @ ("completed" | "interrupted")) => { + return match thread_id { + Some(thread_id) => CodexSubagentActivity::Terminal { + thread_id: thread_id.to_string(), + kind: kind.to_string(), + }, + None => CodexSubagentActivity::Other, + }; + } + _ => return CodexSubagentActivity::Other, + } + let name = subagent + .get("path") + .and_then(|v| v.as_str()) + .unwrap_or("") + .rsplit('/') + .find(|segment| !segment.is_empty()) + .unwrap_or(CODEX_SUBAGENT_FALLBACK_NAME); + let mut input = serde_json::Map::new(); + input.insert( + "subagent_type".to_string(), + serde_json::Value::String(name.to_string()), + ); + if let Some(thread_id) = thread_id { + input.insert( + "agent_id".to_string(), + serde_json::Value::String(thread_id.to_string()), + ); + } + // Say that this card stands for the LAUNCH, so its "completed" is not read + // as "the sub-agent finished" (see the constant's doc). + input.insert( + crate::parsers::codex::CODEX_SUBAGENT_LAUNCH_KEY.to_string(), + serde_json::Value::Bool(true), + ); + CodexSubagentActivity::Started { + thread_id: thread_id.map(str::to_string), + input: serde_json::Value::Object(input).to_string(), } } -/// The `[terminal exited: …]` line for `_meta.terminal_exit`, if present. +/// Re-stamp a launch capsule's input with the child's terminal state. /// -/// The values are read key by key and fed to `format_terminal_exit_status`, so -/// the wording is byte-for-byte what a host-owned terminal produces and can -/// never drift. Do NOT be tempted to `serde_json::from_value::` -/// the object instead: pi writes SNAKE_case (`exit_code`) while the schema type -/// is `rename_all = "camelCase"`, and unknown fields are ignored — so it -/// deserializes CLEANLY into an all-`None` status and silently prints -/// "[terminal exited: finished]", dropping the exit code the report explicitly -/// asks for. codex-acp writes the same snake_case shape (`{exit_code, signal, -/// terminal_id}`), so the identical reasoning applies to it. -fn hosted_terminal_exit_line( - agent_type: AgentType, - meta: Option<&serde_json::Map>, -) -> Option { - hosted_terminal_output_key(agent_type)?; - let exit = meta?.get("terminal_exit")?.as_object()?; - let code = exit.get("exit_code").and_then(serde_json::Value::as_i64); - let signal = exit - .get("signal") - .and_then(serde_json::Value::as_str) - .map(str::to_string); - // `TerminalExitStatus::exit_code` is `u32`, but pi's is a JS number and some - // runtimes report a signal death as a negative. Format those directly rather - // than dropping the only failure evidence a killed command leaves behind. - let formatted = match code.map(u32::try_from) { - Some(Err(_)) => { - let mut parts = vec![format!("exit code: {}", code.unwrap_or_default())]; - if let Some(signal) = &signal { - parts.push(format!("signal: {signal}")); - } - parts.join(", ") - } - narrowed => format_terminal_exit_status( - &TerminalExitStatus::new() - .exit_code(narrowed.and_then(Result::ok)) - .signal(signal), - ), - }; - Some(format!("[terminal exited: {formatted}]")) +/// The card's whole meaning lives in its `rawInput` — that is where both the +/// live path and the rollout parser put `agent_id` and the launch marker — so +/// the outcome is delivered the same way, by re-sending the input it already +/// has plus one key. Deliberately NOT `_meta`: that field is replace-on-update +/// (`SessionState::upsert_tool_call`), so a meta-only patch would drop whatever +/// codex-acp had put there, whereas `rawInput` is parsed and swapped in whole. +fn codex_subagent_terminal_input(launch_input: &str, kind: &str) -> String { + let mut obj = serde_json::from_str::(launch_input) + .ok() + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default(); + obj.insert( + crate::parsers::codex::CODEX_SUBAGENT_STATE_KEY.to_string(), + serde_json::Value::String(kind.to_string()), + ); + serde_json::Value::Object(obj).to_string() } -/// Bridge the agent's `_meta` terminal channel onto the `raw_output` stream, -/// returning the `(payload, append)` pair to emit — or `None` when this frame -/// carries no terminal data (which is every frame of every agent outside -/// `hosted_terminal_output_key`). +/// Deliver a codex sub-agent's terminal state onto the capsule that launched it. /// -/// `append` is false for a call's FIRST chunk, so it REPLACES whatever the -/// opening frame left on the card, and true for every chunk after — the same -/// rule `poll_terminal_tool_call_output` applies via -/// `TrackedTerminalToolCall::has_emitted_output`. The payload goes through -/// `build_emit_payload` for the pipeline-wide ANSI-safe single-event cap. +/// GATED on that capsule still being live, and the gate must be a gate: a child +/// can outlive the turn that spawned it, and once `TurnComplete` has cleared +/// `active_tool_calls` an update naming the launch id would be materialized from +/// nothing by `upsert_tool_call`'s insert-on-miss — a card that exists only in +/// memory, disappears on refresh, and says nothing. Probing with a read lock +/// first would not help: the turn can complete in the window before the write +/// lock is taken, which is why `emit_with_state_gated` evaluates the predicate +/// under the same lock as the apply. /// -/// The exit line is appended on EVERY exit, `exit code: 0` included: for a -/// command that printed nothing it is the only thing that supersedes the -/// placeholder, and it is what a host-owned terminal shows. +/// Nothing is lost when the gate refuses: the rollout carries the same +/// `SubAgentActivity`, and the parser stamps the same key on reload. +async fn settle_codex_subagent_launch( + state: &Arc>, + emitter: &EventEmitter, + cb_state: &CodeBuddyLiveState, + thread_id: &str, + kind: &str, +) { + let Some((launch_id, launch_input)) = cb_state.codex_subagent_launches.get(thread_id) else { + return; + }; + let launch_id = launch_id.clone(); + emit_with_state_gated( + state, + emitter, + AcpEvent::ToolCallUpdate { + tool_call_id: launch_id.clone(), + title: None, + status: None, + content: None, + raw_input: Some(codex_subagent_terminal_input(launch_input, kind)), + raw_output: None, + raw_output_append: None, + locations: None, + meta: None, + images: None, + }, + |s| s.active_tool_calls.contains_key(&launch_id), + ) + .await; +} + +/// The `_meta` to seed codex's Plan-mode review card with, when a +/// `session/request_permission` is that gate — `None` for every other request. /// -/// The entry is created here rather than required up front, so a client that -/// attached mid-turn (and so never saw the opening `terminal_info` frame) still -/// gets the output instead of silently dropping it. -fn hosted_terminal_chunk( - agent_type: AgentType, - meta: Option<&serde_json::Map>, - tool_call_id: &str, - tracked: &mut HashMap, -) -> Option<(String, bool)> { - let mut chunk = hosted_terminal_output_delta(agent_type, meta).unwrap_or_default(); - if let Some(exit_line) = hosted_terminal_exit_line(agent_type, meta) { - if !chunk.is_empty() && !chunk.ends_with('\n') { - chunk.push('\n'); - } - chunk.push_str(&exit_line); - } - if chunk.is_empty() { +/// codex-acp raises this once a plan item settles while `collaboration_mode` is +/// `plan`, asking whether to implement the plan (`implement_plan`) or stay in +/// plan mode (`revise_plan`). Its `toolCall` (`plan-review:`) is NEVER +/// announced as a `tool_call` — the only follow-up on the wire is a +/// `tool_call_update` carrying just a status and `rawOutput`. Without seeding a +/// tool call from this request that update lands on an unknown id and renders as +/// an untitled generic tool card, so `handle_permission_request` emits one. +/// Gated on Codex, mirroring [`classify_codex_subagent_activity`]. +/// +/// Two ways it is recognised, one per adapter generation. 1.1.8–1.13.x mark the +/// REQUEST (sibling of `options`) `_meta.codex = {kind: "plan_review", +/// planItemId}`, and that meta is what the frontend names the card by +/// (`codexMarksPlanReview`). 2.0.0 sends that marker to no client; the gate is +/// then recognised by its id, which every version shares, and the legacy +/// marker is written onto the seeded card so the frontend's reading holds. +fn codex_plan_review_meta( + agent_type: AgentType, + tool_call_id: &str, + switch_mode: bool, + meta: Option<&serde_json::Map>, +) -> Option> { + if agent_type != AgentType::Codex { return None; } - let has_emitted = tracked.entry(tool_call_id.to_string()).or_insert(false); - let append = *has_emitted; - *has_emitted = true; - Some(build_emit_payload(&chunk, append)) -} - -/// pi-acp titles a bash tool call `bashCommand(args) ?? toolName`. On the first -/// `toolcall_start` frame the arguments are still partial JSON, so the title is -/// the bare tool name `"bash"` and the real command only lands on a later frame. -/// -/// Synthesize a canonical `{"command": …}` `raw_input` from a title that IS the -/// command, so `inferLiveToolName` classifies the call as `bash` and it renders -/// through the Bash card (`$ `) — the same trick -/// `synthesize_edit_input_from_diffs` plays for codex's input-less diffs. Without -/// it the input shape is silent and the title fallback makes the card a generic -/// tool literally NAMED after the command (`node --version`), which is also how -/// it diverged from pi's own history parser (`parsers/pi.rs`, which builds a real -/// `bash` call with `{"command": …}`). -/// -/// Returns `None` for the bare `"bash"` title: `normalizeToolName("bash")` -/// already resolves that frame to the Bash card, and synthesizing there would -/// flash `$ bash` for one frame before the real command arrives. -fn pi_bash_input_from_title(title: Option<&str>) -> Option { - let command = title?.trim(); - if command.is_empty() || command.eq_ignore_ascii_case("bash") { + let marked = meta + .and_then(|m| m.get("codex")) + .and_then(|codex| codex.get("kind")) + .and_then(serde_json::Value::as_str) + == Some("plan_review"); + if marked { + return meta.cloned(); + } + // The id alone is a naming convention; the review request is also the one + // codex sends as `kind: switch_mode` (`PlanReviewReporter`, every client). + if !switch_mode { return None; } - Some(serde_json::json!({ "command": command }).to_string()) + let plan_item_id = crate::acp::air_contract::codex_plan_review_item_id(tool_call_id)?; + let mut seeded = meta.cloned().unwrap_or_default(); + seeded.insert( + "codex".to_string(), + serde_json::json!({ "kind": "plan_review", "planItemId": plan_item_id }), + ); + Some(seeded) } -/// Rebuild a Gemini tool call's `raw_input` from its title and locations. +/// Copy a permission request's REQUEST-level `_meta.permission` onto the card's +/// tool call, where the dialog can reach it. /// -/// gemini-cli puts NO `rawInput` on the ACP wire at all: a call arrives as -/// title + kind + locations + content and nothing else (`acpSession.ts`, -/// 0.60.0). codeg classifies a live call by the SHAPE of its input, so with -/// none of it every gemini tool lands on the generic card named after its own -/// title — no Bash card, no file card, no diff header. +/// `session/request_permission` carries presentation data at two levels: on each +/// option (`PermissionOptionInfo.meta`, already forwarded verbatim) and on the +/// request itself. Only the tool call and the options reach the frontend — +/// `AcpEvent::PermissionRequest` has no request-meta field — so without this the +/// request level is dropped on the floor. /// -/// This is a whitelist of exact title formats and NOT a match on `kind`, -/// because `kind` cannot identify the tool: `search` covers glob, grep AND web -/// search, and every MCP tool is hardcoded to `other` (`DiscoveredMCPTool`). -/// Synthesizing off `kind` alone would actively mislabel calls — codeg reads a -/// bare `pattern` as grep and a bare `query` as web search, so a guessed -/// `{"pattern": title}` would turn every glob into a grep. Not synthesizing -/// costs a generic card; guessing wrong invents a card that states something -/// untrue, so anything unrecognized returns `None` on purpose. Two formats are -/// deliberately left out for exactly that reason: +/// That became load-bearing in codex-acp 1.7.0, which moved Codex's own reason +/// for asking out of `toolCall.title` (1.4.0 sent +/// `params.reason ?? "Permissions Request"`) into +/// `_meta.permission = {version: 1, title, description?}`. The title is now one +/// of four fixed strings and the reason lives only in `description`, so a card +/// built from the tool call alone would read "Edit files" where it used to +/// explain WHY the edit needs approval. /// -/// - glob and grep can both title themselves a bare `''`. Note it is -/// `grep.ts` that collides with `glob.ts`, NOT the `ripGrep.ts` that normally -/// serves this kind: ripgrep appends ` within …` unconditionally (its -/// `dir_path` defaults to `"."`), while glob and the plain-grep fallback both -/// append it only when `dir_path` is set. So the collision only happens on -/// machines without ripgrep — which is exactly the kind of "usually fine" -/// that makes guessing here a bad trade. -/// - web-fetch's prompt variant carries a `prompt`, not a `url`, so there is no -/// honest shape to emit; above 100 chars it is truncated to 97 + `...` as -/// well. +/// claude-agent-acp 0.73.0 made the same move, which is why this must stay +/// UNGATED. There it is not merely load-bearing but the ONLY source of a +/// heading: its rebuilt `permissions/` layer emits exactly one `_meta` in the +/// whole subtree — this request-level block, whose `description` is +/// `Reason: ` — and the permission tool call it pairs with is +/// built from `toolInfoFromToolUse`, which returns `{title, kind, content}` and +/// no `_meta.claudeCode` at all. So the dialog's preferred +/// `_meta.claudeCode.title` is absent on 0.73.0 cards and falls through to what +/// this hoists. /// -/// Every format below is the tool's own `getDisplayTitle()`, which the ACP -/// layer prefers over `getDescription()`. That preference is what makes the -/// `execute` rule exact: shell overrides `getDisplayTitle()` to return -/// `params.command` verbatim, so the title IS the command. (`getDescription()` -/// is the one that swaps in the model's prose description for commands over -/// 150 chars — it feeds the CLI's own TUI and never reaches the wire. Reading -/// that function instead would have made this rule render a Bash card whose -/// `$ …` line was an English sentence.) +/// claude-agent-acp 0.82.0 and codex-acp 2.0.0 moved the record to +/// `_meta.jetbrains.air.permission` and send the old key to no client +/// ([`crate::acp::air_contract::permission_record`] reads both). The card keeps +/// reading `_meta.permission`: this is where the new spelling becomes it. /// -/// Only consulted when the agent sent no `raw_input` of its own, and after -/// `synthesize_edit_input_from_diffs`, which reconstructs a better input for -/// the edits that do carry a diff. -fn gemini_synthesize_tool_input( - agent_type: AgentType, - kind: &ToolKind, - title: &str, - locations: &[ToolCallLocation], -) -> Option { - if agent_type != AgentType::Gemini { +/// Hoisting rather than adding an event field is deliberate: the tool call is +/// already the card's payload end-to-end (`PendingPermissionState.tool_call`, +/// the snapshot, the WebSocket envelope, `parsePermissionToolCall`), so the +/// reason survives a reconnect and a snapshot restore for free. `_meta` is +/// namespaced by producer, and `permission` is unclaimed at tool-call level — +/// codex's permission tool calls carry no `_meta` at all, and claude's carries +/// only `claudeCode` (nothing at all since 0.73.0). An existing +/// `_meta.permission` is therefore never +/// overwritten: the insert is skipped if the key is already present. +fn hoist_request_permission_meta( + tool_call: &mut serde_json::Value, + request_meta: Option<&serde_json::Map>, +) { + let Some(permission) = crate::acp::air_contract::permission_record(request_meta) else { + return; + }; + let Some(obj) = tool_call.as_object_mut() else { + return; + }; + let meta = obj + .entry("_meta") + .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new())); + let Some(meta) = meta.as_object_mut() else { + // A non-object `_meta` is malformed; leave it exactly as the agent sent + // it rather than replacing content the card may still be parsing. + return; + }; + meta.entry("permission").or_insert_with(|| permission.clone()); +} + +/// True when an `initialize` response advertises the ACP steering extension — +/// the TOP-LEVEL `_meta.steering.supported` flag, a sibling of +/// `agentCapabilities` (NOT `agentCapabilities._meta`, which belongs to other +/// conventions such as the ACP runtime's symposium capability ext). Both claude-agent-acp +/// (0.61+) and codex-acp (1.1.6+) advertise here; whether codeg actually +/// steers natively additionally requires the +/// `registry::steering_prompt_required_min_version` policy plus the runtime +/// version proof (see the synthesis in `run_connection`). +fn init_advertises_steering(meta: Option<&serde_json::Map>) -> bool { + meta.and_then(|m| m.get("steering")) + .and_then(|s| s.get("supported")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) +} + +/// Whether the `initialize` response advertises the provider-neutral goal +/// extension: top-level `_meta.goal = {version: = 1>, controlMethod, +/// actions}` — or, from claude-agent-acp 0.82.0 / codex-acp 2.0.0, the same +/// object at `_meta.jetbrains.air.goal` (the AIR-only home those releases moved +/// it to; the top-level key then goes to no client). Advertised ⇒ goal state +/// arrives as `session_info_update._meta.goal` (or +/// `_meta.jetbrains.air.goal`) snapshots and the legacy +/// `_meta.codex.goal` key is ignored for the WHOLE connection — codex-acp +/// switched to the neutral key silently in 1.2.0 (legacy key gone), and +/// claude-agent-acp speaks only the neutral form (0.66.0+). Pinning the +/// channel here, at initialize, makes the selection independent of update +/// arrival order, so a transitional adapter double-publishing one goal +/// transition through both namespaces can never produce two goal cards. +/// Non-integer or sub-1 versions fail closed onto the legacy channel. +fn init_advertises_goal(meta: Option<&serde_json::Map>) -> bool { + crate::acp::air_contract::legacy_or_air(meta, "goal") + .and_then(|g| g.get("version")) + .and_then(serde_json::Value::as_i64) + .is_some_and(|version| version >= 1) +} + +/// The goal-control surface an `initialize` response advertises: +/// `(_meta.goal.controlMethod, _meta.goal.actions)` — claude 0.66+ offers +/// `("_session/goal", ["set","clear"])`, codex 1.2+ the same method with all +/// four actions. `None` when the neutral goal extension isn't advertised +/// (see [`init_advertises_goal`]) or carries no usable method string — the +/// session then keeps the legacy codex method + actions +/// (`codex_goal::LEGACY_GOAL_CONTROL_METHOD` / `LEGACY_GOAL_ACTIONS`). An +/// advertised-but-empty actions array is honored as "no controls": the goal +/// card gates its buttons on this list, so only affordances the adapter +/// actually implements are offered. +fn goal_advertised_control( + meta: Option<&serde_json::Map>, +) -> Option<(String, Vec)> { + if !init_advertises_goal(meta) { return None; } - let title = title.trim(); - let input = match kind { - ToolKind::Execute if !title.is_empty() => serde_json::json!({ "command": title }), - // NOTE: no `edit`/write-file rule on purpose. gemini ships the edit as a - // `diff` content block on both the permission and completion frames - // (`acpSession.ts`), so `synthesize_edit_input_from_diffs` — which runs - // ahead of this in the `.or()` chain — already rebuilds a real - // `{old_string, new_string}` for it. A `{file_path}` here could only - // ever fire on a frame that carries NO diff, and there it would make - // things worse, not better: `inferFromInput` sees a path plus - // `kind: edit` and returns "edit", which renders through EditToolInput - // and shows BLANK because the old/new strings it reads are absent. A - // generic card beats an empty edit card. - // - // read-file reports exactly one location, carrying the resolved - // absolute path and `params.start_line` (`read-file.ts - // toolLocations()`). One location is a sound discriminator because - // `toolLocations()` is overridden by only three tools in the whole - // tree — read-file, write-file and edit — and everything else inherits - // `tools.ts`'s `return []`. So every OTHER Read-kind tool - // (read_many_files, read-mcp-resource, the shell-background and - // tracker tools) reports ZERO, not several. - // - // The line becomes `offset`, which is the key codeg's file card reads - // (`content-parts-renderer.tsx`, `FileToolInput`); `start_line` is not - // in its vocabulary and would render nothing. - ToolKind::Read if locations.len() == 1 => { - let location = &locations[0]; - match location.line { - Some(line) => serde_json::json!({ "file_path": location.path, "offset": line }), - None => serde_json::json!({ "file_path": location.path }), - } - } - ToolKind::Search => serde_json::json!({ - "query": title - .strip_prefix("Searching the web for: \"")? - .strip_suffix('"')?, - }), - ToolKind::Fetch => serde_json::json!({ - "url": title.strip_prefix("Fetching content from: ")?, - }), - _ => return None, - }; - Some(input.to_string()) + let goal = crate::acp::air_contract::legacy_or_air(meta, "goal")?; + let method = goal + .get("controlMethod") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|m| !m.is_empty())? + .to_string(); + let actions = goal + .get("actions") + .and_then(serde_json::Value::as_array) + .map(|entries| { + entries + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + Some((method, actions)) } -/// Name used when a codex sub-agent's `path` carries no usable segment. Matches -/// the fallback codex-acp itself uses when building the activity title. -const CODEX_SUBAGENT_FALLBACK_NAME: &str = "subagent"; - -/// How a Codex live `subAgentActivity` (codex-acp #304) should be handled. -/// -/// codex 0.147's native team-of-agents runs sub-agents entirely inside the codex -/// process. Its orchestration calls (`spawn_agent` / `wait_agent`, the -/// `collaboration` namespace) never reach the ACP wire, and the inter-agent -/// message is an opaque encrypted envelope even in the on-disk rollout. The one -/// thing codex-acp forwards is `subAgentActivity`, as a `tool_call(kind:other)` -/// carrying `_meta.codex.subagent = {threadId, path, activity}`. +/// Resolve [`goal_advertised_control`]'s answer into what the session state +/// stores: an OPTIONAL method override (absent ⇒ keep the legacy codex method +/// the state was built with) and the CONCRETE action vocabulary. /// -/// codeg used to DROP every one of these, on the premise that the -/// `collabAgentToolCall` capsule already showed the same thing. That premise -/// died with the team-of-agents rewrite: codex raises no `collabAgentToolCall` -/// for a spawn any more, so dropping this left a codex sub-agent completely -/// invisible while it ran — nothing appeared in the timeline until the session -/// was reopened and the rollout re-parsed. -#[derive(Debug)] -enum CodexSubagentActivity { - /// Not a codex sub-agent activity — handle the call normally. - None, - /// A sub-agent was launched. Carries the Agent-card input to render it with. - Started { - thread_id: Option, - input: String, - }, - /// The child reached a terminal state (`completed` / `interrupted`). Carries - /// no card of its own — its `toolCallId` is a synthetic id codeg has never - /// seen (`subagent-completed-`), so rendering it would open a SECOND - /// capsule with the same name and no way to tell it from the launch. It is - /// forwarded onto the LAUNCH capsule instead, keyed by `threadId`. - Terminal { thread_id: String, kind: String }, - /// A mid-life marker (`interacted`). Still dropped: it carries no content of - /// its own and says nothing the launch capsule does not already say. - Other, +/// The legacy pair belongs here, at initialize, and not in `SessionState::new`. +/// A client can read the snapshot during the handshake — `spawn_agent` returns +/// with `initialize` still in flight — and it latches whatever it finds, so a +/// construction-time legacy default hands a claude session a Pause its adapter +/// answers with `Invalid params: goal action must be "set" or "clear"`. Keeping +/// the field `None` until this runs is what makes "unknown" tellable from +/// "legacy" on the wire. +fn resolve_goal_control( + advertised: Option<(String, Vec)>, +) -> (Option, Vec) { + match advertised { + Some((method, actions)) => (Some(method), actions), + None => ( + None, + crate::acp::codex_goal::LEGACY_GOAL_ACTIONS + .iter() + .map(|a| (*a).to_string()) + .collect(), + ), + } } -/// The `_meta` to classify a codex tool call's sub-agent activity from, when the -/// frame's own does not say: codex-acp 2.0.0 sends `_meta.codex.subagent` to no -/// client, and an activity item is then recognised by its `rawInput` -/// (`air_contract::codex_activity_meta_from_raw_input`). `None` means "the -/// frame's own `_meta` is the one to read" — it already carries the legacy -/// marker, or this is not codex, or the input is not an activity. -fn codex_activity_classification_meta( - agent_type: AgentType, +/// Pick the goal payload out of a `session_info_update`'s `_meta` according to +/// the channel pinned at initialize (see [`init_advertises_goal`]): the +/// neutral `_meta.goal` (or its AIR home `_meta.jetbrains.air.goal`) for +/// advertising connections, the legacy `_meta.codex.goal` otherwise — never +/// both. Pure so the either/or contract +/// is unit-tested without the connection machinery. +fn session_info_goal_value( + neutral_goal_channel: bool, meta: Option<&serde_json::Map>, - raw_input: Option<&serde_json::Value>, -) -> Option> { - if agent_type != AgentType::Codex { - return None; +) -> Option<&serde_json::Value> { + if neutral_goal_channel { + crate::acp::air_contract::legacy_or_air(meta, "goal") + } else { + meta?.get("codex").and_then(|codex| codex.get("goal")) } - if meta - .and_then(|m| m.get("codex")) - .and_then(|codex| codex.get("subagent")) - .is_some() - { +} + +/// The raw `sessionFailure` value out of a `_meta.jetbrains.air` envelope — +/// present only when the envelope itself is well-formed (integer +/// `version >= 1`, mirroring the advertisement check the adapters run on +/// codeg's `clientCapabilities._meta.jetbrains.air`). A malformed or +/// future-incompatible envelope yields `None` and the carrier is treated as +/// holding no failure. Records ride TWO carriers with this same envelope: the +/// per-attempt upserts on `session_info_update._meta`, and a turn's terminal +/// failure on the prompt RESPONSE `_meta` (see [`response_session_failure`]). +fn air_session_failure( + meta: Option<&serde_json::Map>, +) -> Option<&serde_json::Value> { + let air = meta?.get("jetbrains")?.get("air")?; + let version = air.get("version").and_then(serde_json::Value::as_i64)?; + if version < 1 { return None; } - let derived = crate::acp::air_contract::codex_activity_meta_from_raw_input(raw_input)?; - let mut merged = meta.cloned().unwrap_or_default(); - if let Some(derived) = derived.as_object() { - for (key, value) in derived { - merged.insert(key.clone(), value.clone()); - } - } - Some(merged) + air.get("sessionFailure") } -/// The stashed viewed-file result a claude completion should carry, if any -/// (see `CodeBuddyLiveState::claude_viewed_results`). +/// Validate one AIR failure upsert into a [`SessionFailureRecord`]. /// -/// The stash entry is spent at the call's terminal status either way; it is -/// only RETURNED for a completion that carried no result of its own — the -/// case claude-agent-acp 0.82.0 produces for an AIR client's read or search. -/// A failed call is not filled: the adapter sends a failure's text itself. -fn claude_viewed_result_for_completion( - agent_type: AgentType, - stash: &mut HashMap, - tool_call_id: &str, - status: Option<&agent_client_protocol::schema::v1::ToolCallStatus>, - carries_result: bool, -) -> Option { - if agent_type != AgentType::ClaudeCode { +/// `id` (non-blank string) and `revision` (integer >= 1) are HARD +/// requirements — without identity there is nothing to merge +/// deterministically, so a record missing either is dropped (the caller logs +/// it at debug). Everything else is lenient: `category`/`severity` default to +/// `"unknown"`/`"error"` and pass through unrecognized values as plain +/// strings (the frontend falls back per field), `title` may be blank, +/// non-string `actions` entries are skipped. `resolved` starts `false`; the +/// stores flip it (see the type docs). +fn parse_session_failure_record(value: &serde_json::Value) -> Option { + let id = value.get("id")?.as_str()?.trim(); + if id.is_empty() { return None; } - let status = status?; - let completed = matches!(status, agent_client_protocol::schema::v1::ToolCallStatus::Completed); - let failed = matches!(status, agent_client_protocol::schema::v1::ToolCallStatus::Failed); - if !completed && !failed { + let revision = value.get("revision")?.as_u64()?; + if revision < 1 { return None; } - let stashed = stash.remove(tool_call_id)?; - (completed && !carries_result).then_some(stashed) -} - -/// Classify a live tool call's `_meta`, building the Agent-card input for a -/// launch. The three fields are the ones `parsers/codex.rs` writes on reload, so -/// live and history render the same capsule: the sub-agent's name is the last -/// segment of its `path` (`/root/pnpm_build` → `pnpm_build`) and `agent_id` is -/// its codex thread id, which the card renders as a short badge. No `prompt` — -/// the task text is encrypted on this wire. -/// -/// The capsule settles as soon as codex acknowledges the launch, NOT when the -/// child finishes: the activity item's own lifecycle is the spawn's. codex DOES -/// say so later, with a `completed` / `interrupted` activity of its own — see -/// [`CodexSubagentActivity::Terminal`], which is routed back onto the launch -/// capsule rather than rendered. -fn classify_codex_subagent_activity( - agent_type: AgentType, - meta: Option<&serde_json::Map>, -) -> CodexSubagentActivity { - if agent_type != AgentType::Codex { - return CodexSubagentActivity::None; - } - let Some(subagent) = meta - .and_then(|m| m.get("codex")) - .and_then(|codex| codex.get("subagent")) - else { - return CodexSubagentActivity::None; + let text = |key: &str, default: &str| -> String { + value + .get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(default) + .to_string() }; - let thread_id = subagent - .get("threadId") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()); - // A status-only follow-up carries the same meta with the same `activity`, - // so this classification is stable across the call's whole lifetime. - match subagent.get("activity").and_then(|v| v.as_str()) { - Some("started") => {} - // Without a thread id there is no capsule to attribute the outcome to, - // so it degrades to the old drop rather than opening a stray card. - Some(kind @ ("completed" | "interrupted")) => { - return match thread_id { - Some(thread_id) => CodexSubagentActivity::Terminal { - thread_id: thread_id.to_string(), - kind: kind.to_string(), - }, - None => CodexSubagentActivity::Other, - }; - } - _ => return CodexSubagentActivity::Other, - } - let name = subagent - .get("path") - .and_then(|v| v.as_str()) - .unwrap_or("") - .rsplit('/') - .find(|segment| !segment.is_empty()) - .unwrap_or(CODEX_SUBAGENT_FALLBACK_NAME); - let mut input = serde_json::Map::new(); - input.insert( - "subagent_type".to_string(), - serde_json::Value::String(name.to_string()), - ); - if let Some(thread_id) = thread_id { - input.insert( - "agent_id".to_string(), - serde_json::Value::String(thread_id.to_string()), - ); - } - // Say that this card stands for the LAUNCH, so its "completed" is not read - // as "the sub-agent finished" (see the constant's doc). - input.insert( - crate::parsers::codex::CODEX_SUBAGENT_LAUNCH_KEY.to_string(), - serde_json::Value::Bool(true), - ); - CodexSubagentActivity::Started { - thread_id: thread_id.map(str::to_string), - input: serde_json::Value::Object(input).to_string(), - } + Some(SessionFailureRecord { + id: id.to_string(), + revision, + category: text("category", "unknown"), + severity: text("severity", "error"), + title: text("title", ""), + details: value + .get("details") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string), + actions: value + .get("actions") + .and_then(serde_json::Value::as_array) + .map(|entries| { + entries + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(), + resolved: false, + }) } -/// Re-stamp a launch capsule's input with the child's terminal state. +/// The terminal AIR failure riding on a prompt RESPONSE's `_meta`, if any. /// -/// The card's whole meaning lives in its `rawInput` — that is where both the -/// live path and the rollout parser put `agent_id` and the launch marker — so -/// the outcome is delivered the same way, by re-sending the input it already -/// has plus one key. Deliberately NOT `_meta`: that field is replace-on-update -/// (`SessionState::upsert_tool_call`), so a meta-only patch would drop whatever -/// codex-acp had put there, whereas `rawInput` is parsed and swapped in whole. -fn codex_subagent_terminal_input(launch_input: &str, kind: &str) -> String { - let mut obj = serde_json::from_str::(launch_input) - .ok() - .and_then(|v| v.as_object().cloned()) - .unwrap_or_default(); - obj.insert( - crate::parsers::codex::CODEX_SUBAGENT_STATE_KEY.to_string(), - serde_json::Value::String(kind.to_string()), - ); - serde_json::Value::Object(obj).to_string() +/// With `jetbrains.air` negotiated, BOTH adapters deliver a turn's terminal +/// failure ON the prompt response rather than as another +/// `session_info_update`: claude-agent-acp's `failActiveWithSessionFailure` +/// settles the turn with a disguised `end_turn` stop reason and attaches the +/// record here (its own comment calls the response "the canonical AIR +/// carrier" — the update channel only ever carries the per-attempt retry +/// warnings), and codex-acp's `terminalFailurePromptResponse` mirrors the +/// same shape as the catch-up for a record whose update was missed (the +/// strict revision merge de-duplicates when both arrive). Field report +/// 2026-08-15: a mid-turn network drop on claude 0.68.0 published +/// "Reconnecting to Claude, attempt N of 5" warnings via updates, then the +/// `transport_lost` error escalation ONLY here — ignoring this carrier lost +/// the terminal record entirely, so the turn-boundary settle painted the +/// still-dead connection as a recovered warning. +fn response_session_failure( + meta: Option<&serde_json::Map>, +) -> Option { + let raw = air_session_failure(meta)?; + let record = parse_session_failure_record(raw); + if record.is_none() { + tracing::debug!( + "[ACP] dropped prompt-response AIR sessionFailure without usable id/revision: {raw:?}" + ); + } + record } -/// Deliver a codex sub-agent's terminal state onto the capsule that launched it. -/// -/// GATED on that capsule still being live, and the gate must be a gate: a child -/// can outlive the turn that spawned it, and once `TurnComplete` has cleared -/// `active_tool_calls` an update naming the launch id would be materialized from -/// nothing by `upsert_tool_call`'s insert-on-miss — a card that exists only in -/// memory, disappears on refresh, and says nothing. Probing with a read lock -/// first would not help: the turn can complete in the window before the write -/// lock is taken, which is why `emit_with_state_gated` evaluates the predicate -/// under the same lock as the apply. -/// -/// Nothing is lost when the gate refuses: the rollout carries the same -/// `SubAgentActivity`, and the parser stamps the same key on reload. -async fn settle_codex_subagent_launch( - state: &Arc>, - emitter: &EventEmitter, - cb_state: &CodeBuddyLiveState, - thread_id: &str, - kind: &str, -) { - let Some((launch_id, launch_input)) = cb_state.codex_subagent_launches.get(thread_id) else { - return; +/// Strict SemVer floor check: true when `version >= min` by SemVer +/// PRECEDENCE. Prerelease ordering matters here — `0.64.0-rc1` precedes +/// `0.64.0` and may predate the very commit that shipped the +/// `promptRequired` guarantee, so it must NOT satisfy a `0.64.0` floor +/// (`0.64.1-beta.2` still does: its numeric core is above the floor). Build +/// metadata (`+sha`) is precedence-ignored per spec. Fail closed: anything +/// `semver` can't parse (missing segments, `v` prefixes, garbage suffixes) +/// routes live feedback to the MCP pull path — today's behavior. +fn version_at_least(version: &str, min: &str) -> bool { + let (Ok(actual), Ok(floor)) = ( + semver::Version::parse(version.trim()), + semver::Version::parse(min), + ) else { + return false; }; - let launch_id = launch_id.clone(); - emit_with_state_gated( - state, - emitter, - AcpEvent::ToolCallUpdate { - tool_call_id: launch_id.clone(), - title: None, - status: None, - content: None, - raw_input: Some(codex_subagent_terminal_input(launch_input, kind)), - raw_output: None, - raw_output_append: None, - locations: None, - meta: None, - images: None, - }, - |s| s.active_tool_calls.contains_key(&launch_id), - ) - .await; + actual.cmp_precedence(&floor) != std::cmp::Ordering::Less } -/// The `_meta` to seed codex's Plan-mode review card with, when a -/// `session/request_permission` is that gate — `None` for every other request. -/// -/// codex-acp raises this once a plan item settles while `collaboration_mode` is -/// `plan`, asking whether to implement the plan (`implement_plan`) or stay in -/// plan mode (`revise_plan`). Its `toolCall` (`plan-review:`) is NEVER -/// announced as a `tool_call` — the only follow-up on the wire is a -/// `tool_call_update` carrying just a status and `rawOutput`. Without seeding a -/// tool call from this request that update lands on an unknown id and renders as -/// an untitled generic tool card, so `handle_permission_request` emits one. -/// Gated on Codex, mirroring [`classify_codex_subagent_activity`]. -/// -/// Two ways it is recognised, one per adapter generation. 1.1.8–1.13.x mark the -/// REQUEST (sibling of `options`) `_meta.codex = {kind: "plan_review", -/// planItemId}`, and that meta is what the frontend names the card by -/// (`codexMarksPlanReview`). 2.0.0 sends that marker to no client; the gate is -/// then recognised by its id, which every version shares, and the legacy -/// marker is written onto the seeded card so the frontend's reading holds. -fn codex_plan_review_meta( +/// Runtime half of the native-steering gate: does the adapter binary that is +/// ACTUALLY running — which launch may have resolved from PATH rather than +/// the pinned npx package (see `commands::acp::acp_get_agent_status_core`) — +/// report an `agent_info.version` at or above the registry minimum? Fail +/// closed on a missing `agent_info` or an unparseable version. +fn steering_version_ok(agent_info: Option<&agent_client_protocol::schema::v1::Implementation>, min: &str) -> bool { + agent_info.is_some_and(|info| version_at_least(&info.version, min)) +} + +/// Synthesize `SessionState.native_steering_available` from an `initialize` +/// response: extension advertised (top-level `_meta`) AND registry policy says +/// this agent honors `promptRequired` AND the running binary's +/// `agent_info.version` proves it. Pure so the full gate matrix is unit-tested; +/// `run_connection` calls it once and everything downstream reads the stored +/// bool. +fn synthesize_native_steering( agent_type: AgentType, - tool_call_id: &str, - switch_mode: bool, meta: Option<&serde_json::Map>, -) -> Option> { - if agent_type != AgentType::Codex { - return None; - } - let marked = meta - .and_then(|m| m.get("codex")) - .and_then(|codex| codex.get("kind")) - .and_then(serde_json::Value::as_str) - == Some("plan_review"); - if marked { - return meta.cloned(); - } - // The id alone is a naming convention; the review request is also the one - // codex sends as `kind: switch_mode` (`PlanReviewReporter`, every client). - if !switch_mode { - return None; - } - let plan_item_id = crate::acp::air_contract::codex_plan_review_item_id(tool_call_id)?; - let mut seeded = meta.cloned().unwrap_or_default(); - seeded.insert( - "codex".to_string(), - serde_json::json!({ "kind": "plan_review", "planItemId": plan_item_id }), - ); - Some(seeded) + agent_info: Option<&agent_client_protocol::schema::v1::Implementation>, +) -> bool { + init_advertises_steering(meta) + && registry::steering_prompt_required_min_version(steering_policy_agent( + agent_type, agent_info, + )) + .is_some_and(|min| steering_version_ok(agent_info, min)) } -/// Copy a permission request's REQUEST-level `_meta.permission` onto the card's -/// tool call, where the dialog can reach it. -/// -/// `session/request_permission` carries presentation data at two levels: on each -/// option (`PermissionOptionInfo.meta`, already forwarded verbatim) and on the -/// request itself. Only the tool call and the options reach the frontend — -/// `AcpEvent::PermissionRequest` has no request-meta field — so without this the -/// request level is dropped on the floor. -/// -/// That became load-bearing in codex-acp 1.7.0, which moved Codex's own reason -/// for asking out of `toolCall.title` (1.4.0 sent -/// `params.reason ?? "Permissions Request"`) into -/// `_meta.permission = {version: 1, title, description?}`. The title is now one -/// of four fixed strings and the reason lives only in `description`, so a card -/// built from the tool call alone would read "Edit files" where it used to -/// explain WHY the edit needs approval. -/// -/// claude-agent-acp 0.73.0 made the same move, which is why this must stay -/// UNGATED. There it is not merely load-bearing but the ONLY source of a -/// heading: its rebuilt `permissions/` layer emits exactly one `_meta` in the -/// whole subtree — this request-level block, whose `description` is -/// `Reason: ` — and the permission tool call it pairs with is -/// built from `toolInfoFromToolUse`, which returns `{title, kind, content}` and -/// no `_meta.claudeCode` at all. So the dialog's preferred -/// `_meta.claudeCode.title` is absent on 0.73.0 cards and falls through to what -/// this hoists. -/// -/// claude-agent-acp 0.82.0 and codex-acp 2.0.0 moved the record to -/// `_meta.jetbrains.air.permission` and send the old key to no client -/// ([`crate::acp::air_contract::permission_record`] reads both). The card keeps -/// reading `_meta.permission`: this is where the new spelling becomes it. +/// Whose steering policy a connection follows. A custom agent whose running +/// adapter reports itself as claude-agent-acp (a second Claude Code account +/// registered as a custom npx agent, say) speaks exactly the protocol the +/// built-in Claude Code policy was written for, so it follows that policy; the +/// advertisement and the version floor still gate it as they gate the built-in +/// agent. The name must match exactly — a fork or a look-alike has no policy +/// and keeps the pull channel — and a built-in type is never remapped. /// -/// Hoisting rather than adding an event field is deliberate: the tool call is -/// already the card's payload end-to-end (`PendingPermissionState.tool_call`, -/// the snapshot, the WebSocket envelope, `parsePermissionToolCall`), so the -/// reason survives a reconnect and a snapshot restore for free. `_meta` is -/// namespaced by producer, and `permission` is unclaimed at tool-call level — -/// codex's permission tool calls carry no `_meta` at all, and claude's carries -/// only `claudeCode` (nothing at all since 0.73.0). An existing -/// `_meta.permission` is therefore never -/// overwritten: the insert is skipped if the key is already present. -fn hoist_request_permission_meta( - tool_call: &mut serde_json::Value, - request_meta: Option<&serde_json::Map>, -) { - let Some(permission) = crate::acp::air_contract::permission_record(request_meta) else { - return; - }; - let Some(obj) = tool_call.as_object_mut() else { - return; - }; - let meta = obj - .entry("_meta") - .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new())); - let Some(meta) = meta.as_object_mut() else { - // A non-object `_meta` is malformed; leave it exactly as the agent sent - // it rather than replacing content the card may still be parsing. - return; - }; - meta.entry("permission").or_insert_with(|| permission.clone()); +/// Only the steering channel changes for such an agent: its history is still +/// the transcript codeg records, which is why an injected steer is recorded +/// there (see the `Steer` arm). +fn steering_policy_agent( + agent_type: AgentType, + agent_info: Option<&agent_client_protocol::schema::v1::Implementation>, +) -> AgentType { + match agent_type { + AgentType::Custom(_) + if agent_info.is_some_and(|info| info.name == registry::CLAUDE_AGENT_ACP_PACKAGE) => + { + AgentType::ClaudeCode + } + other => other, + } } -/// True when an `initialize` response advertises the ACP steering extension — -/// the TOP-LEVEL `_meta.steering.supported` flag, a sibling of -/// `agentCapabilities` (NOT `agentCapabilities._meta`, which belongs to other -/// conventions such as the ACP runtime's symposium capability ext). Both claude-agent-acp -/// (0.61+) and codex-acp (1.1.6+) advertise here; whether codeg actually -/// steers natively additionally requires the -/// `registry::steering_prompt_required_min_version` policy plus the runtime -/// version proof (see the synthesis in `run_connection`). -fn init_advertises_steering(meta: Option<&serde_json::Map>) -> bool { - meta.and_then(|m| m.get("steering")) - .and_then(|s| s.get("supported")) - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) +/// codex-acp 1.12.0 swapped the question and the short tab header between a +/// `request_user_input` property's `title` and `description`. Both generations +/// emit both strings, so the FORM cannot be dated — but the adapter that built +/// it can, from the `agentInfo.version` it reports at `initialize`. +/// +/// Pinned once into `SessionState::codex_user_input_shape` because the running +/// adapter is not necessarily the pinned one: launch prefers a PATH-resolved +/// install, and a custom pinned version is a supported configuration +/// (`registry::supports_custom_version`). `None` for every other agent, and for +/// a codex adapter that reports no parseable version — the elicitation parser +/// then falls back to the form's own markers (see +/// [`crate::acp::question::CodexUserInputShape`]). +fn codex_user_input_shape( + agent_type: AgentType, + agent_info: Option<&agent_client_protocol::schema::v1::Implementation>, +) -> Option { + use crate::acp::question::CodexUserInputShape; + if agent_type != AgentType::Codex { + return None; + } + let version = agent_info.map(|info| info.version.trim())?; + // Fail closed on an unparseable version rather than guessing a generation: + // `None` lets the parser use the form's markers, which is strictly better + // information than a coin flip. + if semver::Version::parse(version).is_err() { + return None; + } + Some(if version_at_least(version, "1.12.0") { + CodexUserInputShape::QuestionInTitle + } else { + CodexUserInputShape::QuestionInDescription + }) } -/// Whether the `initialize` response advertises the provider-neutral goal -/// extension: top-level `_meta.goal = {version: = 1>, controlMethod, -/// actions}` — or, from claude-agent-acp 0.82.0 / codex-acp 2.0.0, the same -/// object at `_meta.jetbrains.air.goal` (the AIR-only home those releases moved -/// it to; the top-level key then goes to no client). Advertised ⇒ goal state -/// arrives as `session_info_update._meta.goal` (or -/// `_meta.jetbrains.air.goal`) snapshots and the legacy -/// `_meta.codex.goal` key is ignored for the WHOLE connection — codex-acp -/// switched to the neutral key silently in 1.2.0 (legacy key gone), and -/// claude-agent-acp speaks only the neutral form (0.66.0+). Pinning the -/// channel here, at initialize, makes the selection independent of update -/// arrival order, so a transitional adapter double-publishing one goal -/// transition through both namespaces can never produce two goal cards. -/// Non-integer or sub-1 versions fail closed onto the legacy channel. -fn init_advertises_goal(meta: Option<&serde_json::Map>) -> bool { - crate::acp::air_contract::legacy_or_air(meta, "goal") - .and_then(|g| g.get("version")) - .and_then(serde_json::Value::as_i64) - .is_some_and(|version| version >= 1) +/// Extract a retryable-turn-error indicator from a Codex `session_info_update`'s +/// `_meta` (codex-acp #289, v1.1.3+). codex ships a transient, auto-retried +/// error as `_meta.codex.error = {message, codexErrorInfo, additionalDetails, +/// turnId, willRetry}` and keeps the prompt alive; it emits this only when +/// `willRetry == true`. Returns `(message, http_status)` when a non-empty +/// message is present. `codexErrorInfo` may be a bare string enum, an object +/// variant carrying an inner `httpStatusCode`, or absent — only the object form +/// yields a status. Defensively refuses a `willRetry == false` payload so a +/// terminal error can never render as "retrying". +fn codex_retry_indicator( + meta: Option<&serde_json::Map>, +) -> Option<(String, Option)> { + let err = meta?.get("codex")?.get("error")?; + if err.get("willRetry").and_then(|v| v.as_bool()) == Some(false) { + return None; + } + let message = err.get("message").and_then(|v| v.as_str())?.trim(); + if message.is_empty() { + return None; + } + let http_status = err + .get("codexErrorInfo") + .and_then(|info| info.as_object()) + .and_then(|obj| obj.values().next()) + .and_then(|inner| inner.get("httpStatusCode")) + .and_then(|v| v.as_i64()); + Some((message.to_string(), http_status)) } -/// The goal-control surface an `initialize` response advertises: -/// `(_meta.goal.controlMethod, _meta.goal.actions)` — claude 0.66+ offers -/// `("_session/goal", ["set","clear"])`, codex 1.2+ the same method with all -/// four actions. `None` when the neutral goal extension isn't advertised -/// (see [`init_advertises_goal`]) or carries no usable method string — the -/// session then keeps the legacy codex method + actions -/// (`codex_goal::LEGACY_GOAL_CONTROL_METHOD` / `LEGACY_GOAL_ACTIONS`). An -/// advertised-but-empty actions array is honored as "no controls": the goal -/// card gates its buttons on this list, so only affordances the adapter -/// actually implements are offered. -fn goal_advertised_control( +/// True when an available command is really a config-option state toggle rather +/// than an invokable slash command (codex-acp #293, v1.1.4). codex advertises +/// e.g. `/plan` as an `AvailableCommand` tagged +/// `_meta.commandAction = {kind:"setConfigOption", configId:"collaboration_mode", +/// value:"plan", resetValue:"default", presentation:"state"}` — codex's signal +/// that the client should represent it as STATE. codeg already surfaces that +/// state as the `collaboration_mode` config-option selector (the generic +/// `SessionConfigOption` path), so also listing `/plan` as a slash command is +/// redundant and its static "Turn plan mode on" description is wrong once plan +/// mode is already on. Suppress these from the command list. Commands with any +/// other action kind (e.g. `/goal`'s `prefixPrompt`, which takes an objective +/// argument) are real commands and kept. Gated on Codex — `commandAction` is a +/// codex-private `_meta` extension (the ACP schema has no such type). +fn is_config_option_state_command( + agent_type: AgentType, meta: Option<&serde_json::Map>, -) -> Option<(String, Vec)> { - if !init_advertises_goal(meta) { - return None; +) -> bool { + if agent_type != AgentType::Codex { + return false; } - let goal = crate::acp::air_contract::legacy_or_air(meta, "goal")?; - let method = goal - .get("controlMethod") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|m| !m.is_empty())? - .to_string(); - let actions = goal - .get("actions") - .and_then(serde_json::Value::as_array) - .map(|entries| { - entries - .iter() - .filter_map(serde_json::Value::as_str) - .map(str::to_string) - .collect() - }) - .unwrap_or_default(); - Some((method, actions)) + // codex-acp 2.0.0 moved the action to `_meta.jetbrains.air.commandAction` + // (an AIR-only key; the top-level one then goes to no client). + crate::acp::air_contract::legacy_or_air(meta, "commandAction") + .and_then(|action| action.get("kind")) + .and_then(|kind| kind.as_str()) + == Some("setConfigOption") } -/// Resolve [`goal_advertised_control`]'s answer into what the session state -/// stores: an OPTIONAL method override (absent ⇒ keep the legacy codex method -/// the state was built with) and the CONCRETE action vocabulary. -/// -/// The legacy pair belongs here, at initialize, and not in `SessionState::new`. -/// A client can read the snapshot during the handshake — `spawn_agent` returns -/// with `initialize` still in flight — and it latches whatever it finds, so a -/// construction-time legacy default hands a claude session a Pause its adapter -/// answers with `Invalid params: goal action must be "set" or "clear"`. Keeping -/// the field `None` until this runs is what makes "unknown" tellable from -/// "legacy" on the wire. -fn resolve_goal_control( - advertised: Option<(String, Vec)>, -) -> (Option, Vec) { - match advertised { - Some((method, actions)) => (Some(method), actions), - None => ( - None, - crate::acp::codex_goal::LEGACY_GOAL_ACTIONS - .iter() - .map(|a| (*a).to_string()) - .collect(), - ), +/// True when a CodeBuddy sub-agent tool call's `_meta` marks it as a BACKGROUND +/// sub-agent (`codebuddy.ai/isBackground == true`). A background sub-agent runs +/// concurrently with the main agent, so the suppression-window invariant (parent +/// blocked → only sub-agent chunks in the window) does NOT hold for it — see +/// `track_subagent_window`, which excludes it from the window. Gated on CodeBuddy. +fn codebuddy_meta_marks_background( + agent_type: AgentType, + meta: Option<&serde_json::Map>, +) -> bool { + if agent_type != AgentType::CodeBuddy { + return false; } + meta.and_then(|m| m.get("codebuddy.ai/isBackground")) + .and_then(|v| v.as_bool()) + == Some(true) } -/// Pick the goal payload out of a `session_info_update`'s `_meta` according to -/// the channel pinned at initialize (see [`init_advertises_goal`]): the -/// neutral `_meta.goal` (or its AIR home `_meta.jetbrains.air.goal`) for -/// advertising connections, the legacy `_meta.codex.goal` otherwise — never -/// both. Pure so the either/or contract -/// is unit-tested without the connection machinery. -fn session_info_goal_value( - neutral_goal_channel: bool, +/// True when a CodeBuddy thought/message `ContentChunk`'s own `_meta` marks the +/// chunk as sub-agent output (`codebuddy.ai/isSubagent`, or a +/// `codebuddy.ai/parentToolCallId` link to the Agent call). This is a precision +/// supplement to the open-sub-agent window — CodeBuddy is not confirmed to +/// populate chunk `_meta`, so suppression never relies on it alone. Gated on +/// CodeBuddy. +fn codebuddy_chunk_marks_subagent( + agent_type: AgentType, meta: Option<&serde_json::Map>, -) -> Option<&serde_json::Value> { - if neutral_goal_channel { - crate::acp::air_contract::legacy_or_air(meta, "goal") - } else { - meta?.get("codex").and_then(|codex| codex.get("goal")) +) -> bool { + if agent_type != AgentType::CodeBuddy { + return false; + } + let Some(meta) = meta else { + return false; + }; + if meta.get("codebuddy.ai/isSubagent").and_then(|v| v.as_bool()) == Some(true) { + return true; } + meta.get("codebuddy.ai/parentToolCallId") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()) } -/// The raw `sessionFailure` value out of a `_meta.jetbrains.air` envelope — -/// present only when the envelope itself is well-formed (integer -/// `version >= 1`, mirroring the advertisement check the adapters run on -/// codeg's `clientCapabilities._meta.jetbrains.air`). A malformed or -/// future-incompatible envelope yields `None` and the carrier is treated as -/// holding no failure. Records ride TWO carriers with this same envelope: the -/// per-attempt upserts on `session_info_update._meta`, and a turn's terminal -/// failure on the prompt RESPONSE `_meta` (see [`response_session_failure`]). -fn air_session_failure( +/// Whether a live thought/message chunk should be dropped from the top-level +/// stream because it belongs to a CodeBuddy sub-agent (whose work is already +/// represented by the Agent pill + its nested tool calls). +/// +/// Claude Code is NOT handled here: its sub-agent chunks (claude-agent-acp +/// ≥0.63 with the `subagent-transcript` capability) arrive with a precise +/// per-chunk `_meta.claudeCode.parentToolUseId` and are forwarded WITH that +/// attribution instead of suppressed — see `claude_chunk_parent_tool_use_id`. +/// +/// Suppress while we're inside an open sub-agent window OR when the chunk's own +/// meta marks it. The window safety rests on a structural invariant: the window +/// only ever holds FOREGROUND (blocking) sub-agents — a synchronous `Agent` tool +/// call suspends the parent model until the tool returns its result, so between +/// that call's open frame and its terminal frame the main session carries ONLY +/// the sub-agent's chunks, never main-agent output. BACKGROUND sub-agents (which +/// run concurrently and could interleave main-agent output) are deliberately +/// excluded from the window by `track_subagent_window`, so `window_open` can +/// never cause a main-agent chunk to be dropped. Gated on CodeBuddy; every other +/// agent always emits. +fn should_suppress_subagent_chunk( + agent_type: AgentType, + window_open: bool, + chunk_meta: Option<&serde_json::Map>, +) -> bool { + if agent_type != AgentType::CodeBuddy { + return false; + } + window_open || codebuddy_chunk_marks_subagent(agent_type, chunk_meta) +} + +/// Extract the update-level `_meta.claudeCode.parentToolUseId` of a live +/// text/thought chunk — set by claude-agent-acp ≥0.63 on a subagent's +/// forwarded chunks once the client advertises the `subagent-transcript` +/// capability (see `build_client_capabilities`). The chunk is emitted WITH +/// this attribution (never suppressed): the frontend routes parented chunks +/// into the live Agent capsule instead of the main thread. Gated on +/// ClaudeCode so no other agent's namespaced meta can alias into parented +/// routing; empty strings are treated as absent. +fn claude_chunk_parent_tool_use_id( + agent_type: AgentType, meta: Option<&serde_json::Map>, -) -> Option<&serde_json::Value> { - let air = meta?.get("jetbrains")?.get("air")?; - let version = air.get("version").and_then(serde_json::Value::as_i64)?; - if version < 1 { +) -> Option { + if agent_type != AgentType::ClaudeCode { return None; } - air.get("sessionFailure") + meta? + .get("claudeCode")? + .get("parentToolUseId")? + .as_str() + .filter(|s| !s.is_empty()) + .map(str::to_owned) } -/// Validate one AIR failure upsert into a [`SessionFailureRecord`]. +/// `json_value_to_text` for a tool call's `rawInput`, with Claude Code's +/// tool argument aliases (Write, Edit, Grep, Bash) settled first, and +/// Antigravity's MCP input folded back into its history shape +/// ([`fold_antigravity_mcp_raw_input`]). /// -/// `id` (non-blank string) and `revision` (integer >= 1) are HARD -/// requirements — without identity there is nothing to merge -/// deterministically, so a record missing either is dropped (the caller logs -/// it at debug). Everything else is lenient: `category`/`severity` default to -/// `"unknown"`/`"error"` and pass through unrecognized values as plain -/// strings (the frontend falls back per field), `title` may be blank, -/// non-string `actions` entries are skipped. `resolved` starts `false`; the -/// stores flip it (see the type docs). -fn parse_session_failure_record(value: &serde_json::Value) -> Option { - let id = value.get("id")?.as_str()?.trim(); - if id.is_empty() { - return None; +/// claude-agent-acp forwards the model's `tool_use.input` verbatim as +/// `rawInput`, so a Write the model spelled `{path, file_text}` — which CLI +/// 2.1.280 accepts and renames for itself — reaches the card, the live line +/// stats and the file tally without the `file_path` / `content` every one of +/// them reads. 0.81.1 (#1161) fixed only the adapter's own title and diff for +/// it. This settles the input itself, with the renames the history parser +/// applies to the same calls (`parsers::claude::canonical_file_tool_input`), +/// so a live card and its history twin read the same arguments. +/// +/// The tool is named by `_meta.claudeCode.toolName`, which rode every claude +/// tool frame that carries input through 0.81.x — the opening `tool_call`, the +/// refining `tool_call_update` and the streamed-input refinements alike (the +/// top-level ACP `name` is only on the first). From 0.82.0 an AIR client gets it +/// on the opening frame only, and the `_meta` ledger +/// (`crate::acp::air_contract::ToolCallMetaLedger`) hands it to every later one. +/// A permission request needs nothing: the CLI coerces the input before it +/// asks, so it already carries the canonical names. +fn tool_call_raw_input_text( + agent_type: AgentType, + meta: Option<&serde_json::Map>, + raw_input: &Option, +) -> Option { + if agent_type == AgentType::ClaudeCode { + let tool_name = meta + .and_then(|m| m.get("claudeCode")) + .and_then(|c| c.get("toolName")) + .and_then(serde_json::Value::as_str); + let canonical = tool_name.zip(raw_input.as_ref()).and_then(|(name, input)| { + crate::parsers::claude::canonical_file_tool_input(name, input) + }); + if canonical.is_some() { + return json_value_to_text(&canonical); + } } - let revision = value.get("revision")?.as_u64()?; - if revision < 1 { - return None; + if agent_type == AgentType::Antigravity { + let folded = fold_antigravity_mcp_raw_input(meta, raw_input.as_ref()); + if folded.is_some() { + return json_value_to_text(&folded); + } } - let text = |key: &str, default: &str| -> String { - value - .get(key) - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|s| !s.is_empty()) - .unwrap_or(default) - .to_string() - }; - Some(SessionFailureRecord { - id: id.to_string(), - revision, - category: text("category", "unknown"), - severity: text("severity", "error"), - title: text("title", ""), - details: value - .get("details") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string), - actions: value - .get("actions") - .and_then(serde_json::Value::as_array) - .map(|entries| { - entries - .iter() - .filter_map(serde_json::Value::as_str) - .map(str::to_string) - .collect() - }) - .unwrap_or_default(), - resolved: false, - }) + json_value_to_text(raw_input) } -/// The terminal AIR failure riding on a prompt RESPONSE's `_meta`, if any. +/// The canonical form of a live Kiro tool call (see +/// [`crate::parsers::kiro::canonical_tool`]), shared by every frame of it. /// -/// With `jetbrains.air` negotiated, BOTH adapters deliver a turn's terminal -/// failure ON the prompt response rather than as another -/// `session_info_update`: claude-agent-acp's `failActiveWithSessionFailure` -/// settles the turn with a disguised `end_turn` stop reason and attaches the -/// record here (its own comment calls the response "the canonical AIR -/// carrier" — the update channel only ever carries the per-attempt retry -/// warnings), and codex-acp's `terminalFailurePromptResponse` mirrors the -/// same shape as the catch-up for a record whose update was missed (the -/// strict revision merge de-duplicates when both arrive). Field report -/// 2026-08-15: a mid-turn network drop on claude 0.68.0 published -/// "Reconnecting to Claude, attempt N of 5" warnings via updates, then the -/// `transport_lost` error escalation ONLY here — ignoring this carrier lost -/// the terminal record entirely, so the turn-boundary settle painted the -/// still-dead connection as a recovered warning. -fn response_session_failure( +/// 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>, -) -> Option { - let raw = air_session_failure(meta)?; - let record = parse_session_failure_record(raw); - if record.is_none() { - tracing::debug!( - "[ACP] dropped prompt-response AIR sessionFailure without usable id/revision: {raw:?}" - ); + raw_input: Option<&serde_json::Value>, +) -> Option { + if agent_type != AgentType::Kiro { + return None; } - record + 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() } -/// Strict SemVer floor check: true when `version >= min` by SemVer -/// PRECEDENCE. Prerelease ordering matters here — `0.64.0-rc1` precedes -/// `0.64.0` and may predate the very commit that shipped the -/// `promptRequired` guarantee, so it must NOT satisfy a `0.64.0` floor -/// (`0.64.1-beta.2` still does: its numeric core is above the floor). Build -/// metadata (`+sha`) is precedence-ignored per spec. Fail closed: anything -/// `semver` can't parse (missing segments, `v` prefixes, garbage suffixes) -/// routes live feedback to the MCP pull path — today's behavior. -fn version_at_least(version: &str, min: &str) -> bool { - let (Ok(actual), Ok(floor)) = ( - semver::Version::parse(version.trim()), - semver::Version::parse(min), - ) else { - return false; - }; - actual.cmp_precedence(&floor) != std::cmp::Ordering::Less -} +/// `_meta` namespace Kiro stamps on its tool calls (`{"toolName", "mcpServerName"?}`). +const KIRO_META_KEY: &str = "kiro"; -/// Runtime half of the native-steering gate: does the adapter binary that is -/// ACTUALLY running — which launch may have resolved from PATH rather than -/// the pinned npx package (see `commands::acp::acp_get_agent_status_core`) — -/// report an `agent_info.version` at or above the registry minimum? Fail -/// closed on a missing `agent_info` or an unparseable version. -fn steering_version_ok(agent_info: Option<&agent_client_protocol::schema::v1::Implementation>, min: &str) -> bool { - agent_info.is_some_and(|info| version_at_least(&info.version, min)) +/// 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) } -/// Synthesize `SessionState.native_steering_available` from an `initialize` -/// response: extension advertised (top-level `_meta`) AND registry policy says -/// this agent honors `promptRequired` AND the running binary's -/// `agent_info.version` proves it. Pure so the full gate matrix is unit-tested; -/// `run_connection` calls it once and everything downstream reads the stored -/// bool. -fn synthesize_native_steering( - agent_type: AgentType, - meta: Option<&serde_json::Map>, - agent_info: Option<&agent_client_protocol::schema::v1::Implementation>, -) -> bool { - init_advertises_steering(meta) - && registry::steering_prompt_required_min_version(steering_policy_agent( - agent_type, agent_info, - )) - .is_some_and(|min| steering_version_ok(agent_info, min)) +/// 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)), + } } -/// Whose steering policy a connection follows. A custom agent whose running -/// adapter reports itself as claude-agent-acp (a second Claude Code account -/// registered as a custom npx agent, say) speaks exactly the protocol the -/// built-in Claude Code policy was written for, so it follows that policy; the -/// advertisement and the version floor still gate it as they gate the built-in -/// agent. The name must match exactly — a fork or a look-alike has no policy -/// and keeps the pull channel — and a built-in type is never remapped. +/// Antigravity 1.2's live MCP `rawInput`, folded back into the shape its +/// history takes. /// -/// Only the steering channel changes for such an agent: its history is still -/// the transcript codeg records, which is why an injected steer is recorded -/// there (see the `Steer` arm). -fn steering_policy_agent( - agent_type: AgentType, - agent_info: Option<&agent_client_protocol::schema::v1::Implementation>, -) -> AgentType { - match agent_type { - AgentType::Custom(_) - if agent_info.is_some_and(|info| info.name == registry::CLAUDE_AGENT_ACP_PACKAGE) => - { - AgentType::ClaudeCode - } - other => other, +/// The server rewrites every MCP dispatch before a client sees it +/// (`tools.py::unwrap_mcp_tool_call`). Through 1.1 that produced +/// `{"arguments": {…}, "prompt": ""}`, and `parsers::antigravity` +/// emits exactly that for history. 1.2 ALSO copies each argument onto the top +/// level — for clients that read `rawInput` without knowing about MCP — and +/// moves the sentence into `_meta.prompt`. The dedicated cards peel +/// `arguments` either way, but the generic card lists every top-level key, so +/// each argument would render twice, once flat and once in the `arguments` +/// tree, and the live card would stop matching its history twin. History +/// cannot take the new shape instead: the copy doubles every input against the +/// parser's `TOOL_INPUT_CAP`, and an input cut there is one no card can parse. +/// +/// Folds only an EXACT copy — every key beside `arguments` equal to the same +/// key inside it, none missing — on a call whose `_meta` marks it as MCP. +/// Anything else passes through: a 1.1 input is already in this shape, and a +/// tool whose own argument is named `arguments` overwrites the wrapper +/// upstream, leaving no nested copy to fold into. +fn fold_antigravity_mcp_raw_input( + meta: Option<&serde_json::Map>, + raw_input: Option<&serde_json::Value>, +) -> Option { + let meta = meta?; + if meta.get("is_mcp_tool_call") != Some(&serde_json::Value::Bool(true)) { + return None; + } + let input = raw_input?.as_object()?; + let arguments = input.get("arguments")?.as_object()?; + let flattened_copy = input.len() - 1 == arguments.len() + && arguments + .iter() + .all(|(key, value)| input.get(key) == Some(value)); + if !flattened_copy { + return None; + } + let mut folded = serde_json::Map::new(); + folded.insert( + "arguments".to_string(), + serde_json::Value::Object(arguments.clone()), + ); + let prompt = meta + .get("prompt") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|prompt| !prompt.is_empty()); + if let Some(prompt) = prompt { + folded.insert( + "prompt".to_string(), + serde_json::Value::String(prompt.to_string()), + ); } + Some(serde_json::Value::Object(folded)) } -/// codex-acp 1.12.0 swapped the question and the short tab header between a -/// `request_user_input` property's `title` and `description`. Both generations -/// emit both strings, so the FORM cannot be dated — but the adapter that built -/// it can, from the `agentInfo.version` it reports at `initialize`. +/// Maintain the set of OPEN CodeBuddy sub-agent tool calls (`open`). `is_agent` +/// is true once `resolve_rewritten_title` classified this `tool_call_id` as a +/// native sub-agent (`"agent"`). A non-final status opens the window; a final +/// status (`completed` / `failed`) closes it and records the id in `closed`, so a +/// stray late non-final frame can't re-open an already-finished sub-agent. /// -/// Pinned once into `SessionState::codex_user_input_shape` because the running -/// adapter is not necessarily the pinned one: launch prefers a PATH-resolved -/// install, and a custom pinned version is a supported configuration -/// (`registry::supports_custom_version`). `None` for every other agent, and for -/// a codex adapter that reports no parseable version — the elicitation parser -/// then falls back to the form's own markers (see -/// [`crate::acp::question::CodexUserInputShape`]). -fn codex_user_input_shape( +/// `is_background` (from `codebuddy_meta_marks_background`) EXCLUDES a sub-agent +/// from the window: a background sub-agent runs concurrently with the main agent, +/// so the "window holds only sub-agent chunks" invariant that makes +/// `should_suppress_subagent_chunk` safe would not hold. We treat a background +/// marker exactly like a terminal frame (remove + record closed) so it can never +/// suppress interleaved main-agent output. (`isBackground` can stream in a frame +/// or two after the call opens, so a background sub-agent's earliest chunks may be +/// briefly suppressed before the marker arrives — an accepted, rare imperfection; +/// the user-reported case is foreground, where the marker is `false`.) +/// +/// Gated on CodeBuddy so a single-agent-type connection of any other agent stays +/// inert. +fn track_subagent_window( agent_type: AgentType, - agent_info: Option<&agent_client_protocol::schema::v1::Implementation>, -) -> Option { - use crate::acp::question::CodexUserInputShape; - if agent_type != AgentType::Codex { - return None; + is_agent: bool, + is_background: bool, + status: Option<&str>, + tool_call_id: &str, + open: &mut HashSet, + closed: &mut HashSet, +) { + if agent_type != AgentType::CodeBuddy || !is_agent { + return; } - let version = agent_info.map(|info| info.version.trim())?; - // Fail closed on an unparseable version rather than guessing a generation: - // `None` lets the parser use the form's markers, which is strictly better - // information than a coin flip. - if semver::Version::parse(version).is_err() { - return None; + let is_final = matches!(status, Some("completed") | Some("failed")); + if is_final || is_background { + open.remove(tool_call_id); + closed.insert(tool_call_id.to_string()); + } else if !closed.contains(tool_call_id) { + open.insert(tool_call_id.to_string()); } - Some(if version_at_least(version, "1.12.0") { - CodexUserInputShape::QuestionInTitle - } else { - CodexUserInputShape::QuestionInDescription - }) } -/// Extract a retryable-turn-error indicator from a Codex `session_info_update`'s -/// `_meta` (codex-acp #289, v1.1.3+). codex ships a transient, auto-retried -/// error as `_meta.codex.error = {message, codexErrorInfo, additionalDetails, -/// turnId, willRetry}` and keeps the prompt alive; it emits this only when -/// `willRetry == true`. Returns `(message, http_status)` when a non-empty -/// message is present. `codexErrorInfo` may be a bare string enum, an object -/// variant carrying an inner `httpStatusCode`, or absent — only the object form -/// yields a status. Defensively refuses a `willRetry == false` payload so a -/// terminal error can never render as "retrying". -fn codex_retry_indicator( - meta: Option<&serde_json::Map>, -) -> Option<(String, Option)> { - let err = meta?.get("codex")?.get("error")?; - if err.get("willRetry").and_then(|v| v.as_bool()) == Some(false) { - return None; - } - let message = err.get("message").and_then(|v| v.as_str())?.trim(); - if message.is_empty() { - return None; - } - let http_status = err - .get("codexErrorInfo") - .and_then(|info| info.as_object()) - .and_then(|obj| obj.values().next()) - .and_then(|inner| inner.get("httpStatusCode")) - .and_then(|v| v.as_i64()); - Some((message.to_string(), http_status)) +/// Per-session CodeBuddy live-stream state threaded through +/// `emit_conversation_update`. Consolidates the authoritative title rewrites and +/// the open-sub-agent suppression window so CodeBuddy's sparse, multi-frame +/// sub-agent stream resolves to a stable Agent pill (whose children nest) with +/// its interleaved thought/message chunks suppressed. Created per connection, +/// shared across the idle and active-turn loops; the historical-replay path uses +/// a throwaway instance. Mirrors `ToolCallOutputCache`'s lifetime. +#[derive(Default)] +struct CodeBuddyLiveState { + /// tool_call_id → authoritative title: `"agent"` for a native sub-agent, or + /// the inner `mcp__…` name for a `DeferExecuteTool` MCP call. Re-asserted on + /// every later frame so a status-only update can't downgrade the card. + title_overrides: HashMap, + /// Sub-agent tool calls currently OPEN (classified, not yet completed/failed). + /// While non-empty, interleaved thought/message chunks belong to a sub-agent + /// and are suppressed from the top-level stream (matching Claude Code). + open_subagents: HashSet, + /// Sub-agent tool calls that already reached a final status — guards against a + /// stray late non-final frame re-opening a finished sub-agent. + closed_subagents: HashSet, + /// Objective of the Codex `/goal` run currently open on this connection (set + /// by the latest `active` `session_info_update` goal, cleared on any terminal + /// status). Lets a later `goal:null` close the run by objective — and be a + /// no-op when no run is open. See `crate::acp::codex_goal::next_goal_marker`. + /// + /// This lives here (not in `SessionState`) because `CodeBuddyLiveState` and + /// `SessionState` share one lifetime: a browser refresh / reconnect re-attaches + /// to the *running* connection (`find_connection_for_reuse`), keeping both; a + /// brand-new connection resets both together (empty live blocks + fresh state). + /// So this state never resets while goal blocks it would close still exist. + codex_open_goal: Option, + /// Monotonic per-connection counter for synthetic goal tool-call ids. Occurrence + /// (not content) addressing keeps two runs that share an objective from + /// colliding in the reducer's id-keyed live block list. + codex_goal_seq: u64, + /// Cursor tool calls announced with the identity-less "MCP: tool" title. + /// Only these are eligible for the completion-time result sniff + /// (`cursor_companion_title_from_content`) — a `shell`/`read` call whose + /// OUTPUT merely echoes a delegation ack must never be re-titled. Entries + /// are dropped once the call reaches a terminal status (the sniff, if any, + /// has recorded its override by then), so the set tracks only in-flight + /// calls. + cursor_generic_mcp_ids: HashSet, + /// Grok tool_call ids whose interactive question already renders via the + /// `_x.ai/ask_user_question` ext bridge (`handle_grok_ask_user_question`). The + /// redundant native `tool_call` / `tool_call_update` stream for these is + /// dropped so the card doesn't double-render; tracked by id because a later + /// status-only update may drop the `x.ai/tool` meta that first identified it. + grok_ask_tool_ids: HashSet, + /// Qoder `AskUserQuestion` calls in flight → the questions they were opened + /// with. Qoder names the tool only on the opening `tool_call` + /// (`_meta.qoder.toolName`), and its completion carries the answers only as + /// the sentence handed to the model; these questions are what both the + /// reply codeg sent (keyed `q`) and that sentence are read against (see + /// `qoder_ask_live_output`). Dropped at a final status, and cleared at turn + /// start: an ask blocks its turn, so a canceled turn's entry is never + /// completed. + qoder_ask_questions: HashMap>, + /// Codex sub-agent thread id → the launch capsule that spawned it, paired + /// with the `rawInput` that capsule was announced with. + /// + /// codex reports a child's completion as a `subAgentActivity` of its own, + /// under a synthetic tool-call id (`subagent-completed-`) that shares + /// nothing with the launch except the thread id — so this is the only way + /// back to the card that should carry the outcome. Keeping the input too + /// avoids reconstructing it: the outcome is delivered by re-sending it with + /// one key added (`codex_subagent_terminal_input`). + /// + /// NOT cleared per turn, for the same reason the grok sub-agent map is not: + /// a child legitimately outlives the turn that launched it. + codex_subagent_launches: HashMap, + /// Grok `spawn_subagent` tool_call ids ever announced on this connection + /// (dedupe for the pending queue + status tracking on meta-less updates). + grok_spawn_seen: HashSet, + /// Announced spawn calls not yet paired with a `subagent_spawned` + /// notification. Grok's ext notifications carry a `subagent_id` but no + /// `tool_call_id`, so pairing is by matching `(description, subagent_type)` + /// captured from the launch `rawInput` against the notification's own + /// fields, first match in stream order — FIFO in the common in-order case + /// (mirroring the history parser's pairing), while a DELAYED prior-turn + /// `subagent_spawned` fails the match against a new turn's differently- + /// described entry instead of stealing its slot. A spawn that FAILS before + /// pairing is removed so later pairs can't shift; the queue is cleared at + /// every turn start (staleness bound). + grok_pending_spawn_ids: VecDeque, + /// subagent_id → its launching spawn tool_call id, from `subagent_spawned`. + /// Routes `subagent_progress` ticks (live meta on the Agent card) and the + /// `subagent_finished` settle; the entry is dropped at finish. + grok_subagent_to_call: HashMap, + /// spawn tool_call id → the child's OWN session id (`child_session_id`, or + /// the subagent id it always equals in practice). Grok runs each sub-agent + /// as a standalone session that writes its transcript to disk while it + /// works, so this is what lets the live Agent card offer "open the child's + /// session" — the only way to watch a blocking child, whose launch call + /// carries no output until it finishes. Kept in state (not just emitted + /// once) because `upsert_tool_call` REPLACES a block's meta: every later + /// progress tick must re-send it or the id would be dropped. Entry dropped + /// at finish, alongside `grok_subagent_to_call`. + grok_call_child_session: HashMap, + /// Spawn calls that reached a terminal wire status. A `subagent_finished` + /// for one of these is a BACKGROUND child settling after its launch ack — + /// the case whose result would otherwise never reach the card live; a + /// blocking spawn's own completion frame carries the output instead. + grok_settled_spawn_ids: HashSet, + /// Spawn calls announced in the CURRENT turn — the only ones whose + /// `subagent_progress` ticks may emit a live `ToolCallUpdate`. Cleared at + /// every turn start (with the pending queue): a tick for a PRIOR turn's + /// background child would otherwise pass the Prompting gate during a later + /// turn and get appended into that turn's live message as a ghost card + /// (its real card lives in the promoted history). The settle path + /// (`subagent_finished` → BackgroundActivity) is unaffected — it targets + /// promoted turns by design. + grok_progress_eligible: HashSet, + /// Context window of the model this Grok turn runs on, resolved ONCE at turn + /// start (`grok_current_model_context_window`) so the per-update usage peek + /// never takes the state lock on the streaming hot path. `None` for non-Grok + /// agents, and for a Grok model whose spec carried no `totalContextTokens` + /// — the ring then keeps falling back to the parsed per-turn stats. + grok_turn_context_window: Option, + /// Last `(used, size)` emitted as a live `UsageUpdate` on this connection. + /// Grok repeats its cumulative `totalTokens` on nearly every update (169 of + /// 189 in a real capture), so re-emitting each one would put a broadcast on + /// a per-chunk hot path for a number that changes a handful of times a turn. + /// The WINDOW is part of the key so a between-turn model switch re-emits + /// even when the token count hasn't moved yet — otherwise the ring would + /// keep dividing by the previous model's window. + grok_last_usage: Option<(u64, u64)>, + /// The prompt id of the turn Grok is running on its own that the idle loop + /// is rendering (see [`grok_agent_turn_opener`]); `None` when none is open. + /// Paired with `SessionState::agent_initiated_turn`, which keeps that + /// turn's end off the prompt gate. + grok_agent_turn: Option, + /// The prompt id of every Grok turn this session has seen END — ones codeg + /// started (read off their frames and responses), and agent-initiated ones + /// it closed. Frames Grok still delivers for them afterwards must neither + /// open a turn nor end one. Every one, not the latest: a prompt that + /// answers at once (a slash command) can end while an earlier turn's + /// frames are still queued, and nothing marks when those have drained. One + /// short id per turn is nothing to keep for a session's life. + grok_ended_prompt_ids: HashSet, + /// Which of codex's `usage_update`s read the context window: the one for a + /// request that ran a hosted web search covers the provider's whole search + /// loop, not the context (see `crate::acp::codex_context`). + codex_context_readings: crate::acp::codex_context::ContextReadings, + /// Shell tool calls whose terminal the AGENT hosts itself (pi bash, + /// codex command execution) → whether any output has already been emitted + /// for that call. + /// + /// Registered from the opening frame's `_meta.terminal_info` + /// (see `hosted_terminal_meta_marks_shell`), because the frames that actually + /// CARRY the output name only the tool-call id — `terminal_info` never + /// repeats. The flag is what makes the first bridged chunk a replacement and + /// every later one an append, the same rule + /// `TrackedTerminalToolCall::has_emitted_output` applies on the host-terminal + /// path. Entries are dropped at a final status, alongside + /// `ToolCallOutputCache::remove_if_final`, and the whole map is cleared at + /// 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, + /// The merged `_meta` of every claude / codex tool call on this connection + /// — the AIR contract (claude-agent-acp 0.82.0, codex-acp 2.0.0) sends each + /// `_meta` key once and expects the client to merge. See + /// [`crate::acp::air_contract`]. + air_tool_call_meta: crate::acp::air_contract::ToolCallMetaLedger, + /// codex `subAgentActivity` tool calls classified on their opening frame: + /// `Some(input)` for a launch capsule (the Agent-card input it was announced + /// with), `None` for an activity that is dropped. codex-acp 2.0.0 sends the + /// follow-up of such a call as a bare status — no `rawInput`, no marker — so + /// the id is the only thing left to classify it by (1.4.0–1.13.x repeated + /// `_meta.codex.subagent` on every frame). Kept for the connection, like + /// `codex_subagent_launches`: a child outlives its turn. + codex_activity_calls: HashMap>, + /// claude `Read` / `Grep` / `Glob` results taken off the raw SDK stream + /// (`_claude/sdkMessage`, a `user` message's `tool_result`), keyed by the + /// tool-use id, waiting for the call's completion frame. + /// + /// claude-agent-acp 0.82.0 withholds the text of those results from an AIR + /// client ("AIR shows a read or a search that names a path as the list of + /// viewed files … the text of the result stays out"), so the completion + /// arrives as a bare status and the card would show the path and nothing + /// else — while the same call reloaded from the transcript shows the text. + /// codeg already subscribes to the raw SDK stream (`emitRawSDKMessages`), + /// and the adapter forwards each raw message BEFORE it reports it, so the + /// result is here first. It only ever fills a completion that carries no + /// result of its own (see the `ToolCallUpdate` arm), and the map is cleared + /// at turn start like `hosted_terminal_calls`. + claude_viewed_results: HashMap, + /// The plugin-failure list last forwarded as + /// [`AcpEvent::PluginLoadFailures`] (see `take_new_claude_plugin_failures`). + /// Claude Code repeats `plugin_errors` on every turn's `system/init`, so + /// without this each turn would re-raise the same warning. Kept across + /// turns — only a CHANGED list is news — and cleared by an init that lists + /// none, so a failure that went away and came back is reported again. + claude_reported_plugin_failures: Option>, + /// 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, } -/// True when an available command is really a config-option state toggle rather -/// than an invokable slash command (codex-acp #293, v1.1.4). codex advertises -/// e.g. `/plan` as an `AvailableCommand` tagged -/// `_meta.commandAction = {kind:"setConfigOption", configId:"collaboration_mode", -/// value:"plan", resetValue:"default", presentation:"state"}` — codex's signal -/// that the client should represent it as STATE. codeg already surfaces that -/// state as the `collaboration_mode` config-option selector (the generic -/// `SessionConfigOption` path), so also listing `/plan` as a slash command is -/// redundant and its static "Turn plan mode on" description is wrong once plan -/// mode is already on. Suppress these from the command list. Commands with any -/// other action kind (e.g. `/goal`'s `prefixPrompt`, which takes an objective -/// argument) are real commands and kept. Gated on Codex — `commandAction` is a -/// codex-private `_meta` extension (the ACP schema has no such type). -fn is_config_option_state_command( - agent_type: AgentType, - meta: Option<&serde_json::Map>, -) -> bool { - if agent_type != AgentType::Codex { - return false; - } - // codex-acp 2.0.0 moved the action to `_meta.jetbrains.air.commandAction` - // (an AIR-only key; the top-level one then goes to no client). - crate::acp::air_contract::legacy_or_air(meta, "commandAction") - .and_then(|action| action.get("kind")) - .and_then(|kind| kind.as_str()) - == Some("setConfigOption") +/// One announced-but-unpaired Grok `spawn_subagent` call. `description` / +/// `subagent_type` come from the launch `rawInput` and validate the pairing +/// against the `subagent_spawned` notification's own fields (`None` = wildcard, +/// tolerant of older wire shapes). +#[derive(Debug)] +struct GrokPendingSpawn { + call_id: String, + description: Option, + subagent_type: Option, } -/// True when a CodeBuddy sub-agent tool call's `_meta` marks it as a BACKGROUND -/// sub-agent (`codebuddy.ai/isBackground == true`). A background sub-agent runs -/// concurrently with the main agent, so the suppression-window invariant (parent -/// blocked → only sub-agent chunks in the window) does NOT hold for it — see -/// `track_subagent_window`, which excludes it from the window. Gated on CodeBuddy. -fn codebuddy_meta_marks_background( +/// True when a Grok tool call's ACP `_meta` identifies it as the native +/// `spawn_subagent` launcher (`_meta["x.ai/tool"].name`). Present on the very +/// first frame (verified against real captures), unlike `subagent_type` which +/// rides `rawInput`. Gated on Grok so the namespaced key can't affect others. +fn grok_meta_marks_spawn_subagent( agent_type: AgentType, meta: Option<&serde_json::Map>, ) -> bool { - if agent_type != AgentType::CodeBuddy { - return false; - } - meta.and_then(|m| m.get("codebuddy.ai/isBackground")) - .and_then(|v| v.as_bool()) - == Some(true) + matches!(agent_type, AgentType::Grok) + && meta + .and_then(|m| m.get("x.ai/tool")) + .and_then(|t| t.get("name")) + .and_then(|n| n.as_str()) + == Some("spawn_subagent") } -/// True when a CodeBuddy thought/message `ContentChunk`'s own `_meta` marks the -/// chunk as sub-agent output (`codebuddy.ai/isSubagent`, or a -/// `codebuddy.ai/parentToolCallId` link to the Agent call). This is a precision -/// supplement to the open-sub-agent window — CodeBuddy is not confirmed to -/// populate chunk `_meta`, so suppression never relies on it alone. Gated on -/// CodeBuddy. -fn codebuddy_chunk_marks_subagent( - agent_type: AgentType, - meta: Option<&serde_json::Map>, -) -> bool { - if agent_type != AgentType::CodeBuddy { - return false; +/// Track a Grok `spawn_subagent` call's lifecycle for the subagent-notification +/// pairing (see the `grok_*` fields on [`CodeBuddyLiveState`]). `is_spawn` is +/// the precomputed meta marker; a status-only update that lost the meta still +/// tracks via the seen-set. `raw_input` (the launch input, when this frame +/// carries it) supplies the `(description, subagent_type)` the pairing +/// validates against. No-op for other agents/tools by construction +/// (`is_spawn` false + id never seen). +fn track_grok_spawn_call( + cb_state: &mut CodeBuddyLiveState, + is_spawn: bool, + status: Option<&str>, + tool_call_id: &str, + raw_input: &Option, +) { + if is_spawn && cb_state.grok_spawn_seen.insert(tool_call_id.to_string()) { + let (description, subagent_type) = raw_input + .as_deref() + .and_then(|text| serde_json::from_str::(text).ok()) + .map(|input| { + let field = |key: &str| { + input + .get(key) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + }; + (field("description"), field("subagent_type")) + }) + .unwrap_or((None, None)); + cb_state.grok_pending_spawn_ids.push_back(GrokPendingSpawn { + call_id: tool_call_id.to_string(), + description, + subagent_type, + }); + // Announced in the current turn → its progress ticks may render live. + cb_state + .grok_progress_eligible + .insert(tool_call_id.to_string()); } - let Some(meta) = meta else { - return false; - }; - if meta.get("codebuddy.ai/isSubagent").and_then(|v| v.as_bool()) == Some(true) { - return true; + if !cb_state.grok_spawn_seen.contains(tool_call_id) { + return; + } + match status { + Some("completed") => { + cb_state + .grok_settled_spawn_ids + .insert(tool_call_id.to_string()); + } + Some("failed") => { + cb_state + .grok_settled_spawn_ids + .insert(tool_call_id.to_string()); + // Never spawned a child (depth-limit / config error): drop it from + // the pairing queue so the next spawn doesn't inherit its slot. + cb_state + .grok_pending_spawn_ids + .retain(|pending| pending.call_id != tool_call_id); + } + _ => {} } - meta.get("codebuddy.ai/parentToolCallId") - .and_then(|v| v.as_str()) - .is_some_and(|s| !s.is_empty()) } -/// Whether a live thought/message chunk should be dropped from the top-level -/// stream because it belongs to a CodeBuddy sub-agent (whose work is already -/// represented by the Agent pill + its nested tool calls). -/// -/// Claude Code is NOT handled here: its sub-agent chunks (claude-agent-acp -/// ≥0.63 with the `subagent-transcript` capability) arrive with a precise -/// per-chunk `_meta.claudeCode.parentToolUseId` and are forwarded WITH that -/// attribution instead of suppressed — see `claude_chunk_parent_tool_use_id`. -/// -/// Suppress while we're inside an open sub-agent window OR when the chunk's own -/// meta marks it. The window safety rests on a structural invariant: the window -/// only ever holds FOREGROUND (blocking) sub-agents — a synchronous `Agent` tool -/// call suspends the parent model until the tool returns its result, so between -/// that call's open frame and its terminal frame the main session carries ONLY -/// the sub-agent's chunks, never main-agent output. BACKGROUND sub-agents (which -/// run concurrently and could interleave main-agent output) are deliberately -/// excluded from the window by `track_subagent_window`, so `window_open` can -/// never cause a main-agent chunk to be dropped. Gated on CodeBuddy; every other -/// agent always emits. -fn should_suppress_subagent_chunk( +/// True when a tool call's ACP `_meta` marks it as grok's native +/// `ask_user_question` (`x.ai/tool.kind == "ask_user"`). Codeg answers grok's +/// blocking `_x.ai/ask_user_question` ext request by rendering the interactive +/// `AskQuestionCard` (see `handle_grok_ask_user_question`), so the parallel +/// `tool_call` stream grok emits for the same call is redundant — it is dropped +/// live so the question doesn't render twice (once answerable, once inert). +fn grok_meta_marks_ask_user( agent_type: AgentType, - window_open: bool, - chunk_meta: Option<&serde_json::Map>, + meta: Option<&serde_json::Map>, ) -> bool { - if agent_type != AgentType::CodeBuddy { - return false; - } - window_open || codebuddy_chunk_marks_subagent(agent_type, chunk_meta) + matches!(agent_type, AgentType::Grok) + && meta + .and_then(|m| m.get("x.ai/tool")) + .and_then(|t| t.get("kind")) + .and_then(|k| k.as_str()) + == Some("ask_user") } -/// Extract the update-level `_meta.claudeCode.parentToolUseId` of a live -/// text/thought chunk — set by claude-agent-acp ≥0.63 on a subagent's -/// forwarded chunks once the client advertises the `subagent-transcript` -/// capability (see `build_client_capabilities`). The chunk is emitted WITH -/// this attribution (never suppressed): the frontend routes parented chunks -/// into the live Agent capsule instead of the main thread. Gated on -/// ClaudeCode so no other agent's namespaced meta can alias into parented -/// routing; empty strings are treated as absent. -fn claude_chunk_parent_tool_use_id( - agent_type: AgentType, - meta: Option<&serde_json::Map>, -) -> Option { - if agent_type != AgentType::ClaudeCode { - return None; - } - meta? - .get("claudeCode")? - .get("parentToolUseId")? - .as_str() - .filter(|s| !s.is_empty()) - .map(str::to_owned) +/// The questions of the Qoder `AskUserQuestion` call this opening `tool_call` +/// announces, or `None` when it announces anything else. Qoder states the +/// tool's name only here (`_meta.qoder.toolName`); the questions may be empty +/// if `rawInput` has not arrived yet, and a later frame that carries it fills +/// them in. +fn qoder_ask_opened( + agent_type: AgentType, + meta: Option<&serde_json::Map>, + raw_input: Option<&serde_json::Value>, +) -> Option> { + let names_ask = agent_type == AgentType::Qoder + && meta + .and_then(|m| m.get("qoder")) + .and_then(|q| q.get("toolName")) + .and_then(serde_json::Value::as_str) + == Some(crate::parsers::qoder::QODER_ASK_USER_QUESTION_TOOL); + names_ask.then(|| { + crate::parsers::qoder::QoderAskQuestion::list_from( + raw_input.and_then(|input| input.get("questions")), + ) + .unwrap_or_default() + }) } -/// `json_value_to_text` for a tool call's `rawInput`, with Claude Code's -/// tool argument aliases (Write, Edit, Grep, Bash) settled first, and -/// Antigravity's MCP input folded back into its history shape -/// ([`fold_antigravity_mcp_raw_input`]). +/// The `raw_output` a Qoder `AskUserQuestion` frame should carry: its answers, +/// as the envelope the question card reads, when the frame is the answered +/// result. `None` leaves the frame's own output alone (not an ask, a +/// rejection, anything that is not an answered shape). /// -/// claude-agent-acp forwards the model's `tool_use.input` verbatim as -/// `rawInput`, so a Write the model spelled `{path, file_text}` — which CLI -/// 2.1.280 accepts and renames for itself — reaches the card, the live line -/// stats and the file tally without the `file_path` / `content` every one of -/// them reads. 0.81.1 (#1161) fixed only the adapter's own title and diff for -/// it. This settles the input itself, with the renames the history parser -/// applies to the same calls (`parsers::claude::canonical_file_tool_input`), -/// so a live card and its history twin read the same arguments. +/// Qoder answers the ask over `elicitation/create` but announces and completes +/// it as its own tool call, and nothing it streams for that call is something +/// the card can read: /// -/// The tool is named by `_meta.claudeCode.toolName`, which rode every claude -/// tool frame that carries input through 0.81.x — the opening `tool_call`, the -/// refining `tool_call_update` and the streamed-input refinements alike (the -/// top-level ACP `name` is only on the first). From 0.82.0 an AIR client gets it -/// on the opening frame only, and the `_meta` ledger -/// (`crate::acp::air_contract::ToolCallMetaLedger`) hands it to every later one. -/// A permission request needs nothing: the CLI coerces the input before it -/// asks, so it already carries the canonical names. -fn tool_call_raw_input_text( - agent_type: AgentType, - meta: Option<&serde_json::Map>, - raw_input: &Option, +/// - a live call's completion carries the sentence handed to the model — in +/// `rawOutput`, and again as `content`. When codeg answered the form itself, +/// `reply` is what it sent (`SessionState::qoder_ask_replies`), which is +/// exact and wins; otherwise the sentence is read back against the questions +/// the call opened with (`parsers::qoder::qoder_ask_envelope_from_result_text`); +/// - a `session/load` replay sends the call once, already completed, with the +/// recorded `{questions, answers}` object as `rawOutput`, read the way the +/// history parser reads the transcript's copy of that object +/// (`parsers::qoder::qoder_ask_envelope_from_recorded`). +/// +/// Without this, a question the user just answered shows nothing selected. +fn qoder_ask_live_output( + questions: Option<&Vec>, + reply: Option<&serde_json::Map>, + raw_output: &Option, + content: Option<&str>, ) -> Option { - if agent_type == AgentType::ClaudeCode { - let tool_name = meta - .and_then(|m| m.get("claudeCode")) - .and_then(|c| c.get("toolName")) - .and_then(serde_json::Value::as_str); - let canonical = tool_name.zip(raw_input.as_ref()).and_then(|(name, input)| { - crate::parsers::claude::canonical_file_tool_input(name, input) - }); - if canonical.is_some() { - return json_value_to_text(&canonical); - } + let questions = questions?; + if let Some(answers) = reply + .and_then(|reply| crate::parsers::qoder::qoder_ask_envelope_from_reply(questions, reply)) + { + return Some(answers); } - if agent_type == AgentType::Antigravity { - let folded = fold_antigravity_mcp_raw_input(meta, raw_input.as_ref()); - if folded.is_some() { - return json_value_to_text(&folded); + match raw_output { + Some(recorded @ serde_json::Value::Object(_)) => { + crate::parsers::qoder::qoder_ask_envelope_from_recorded(recorded) + } + raw_output => { + let text = raw_output + .as_ref() + .and_then(serde_json::Value::as_str) + .or(content)?; + crate::parsers::qoder::qoder_ask_envelope_from_result_text(questions, text) } } - json_value_to_text(raw_input) } -/// Antigravity 1.2's live MCP `rawInput`, folded back into the shape its -/// history takes. -/// -/// The server rewrites every MCP dispatch before a client sees it -/// (`tools.py::unwrap_mcp_tool_call`). Through 1.1 that produced -/// `{"arguments": {…}, "prompt": ""}`, and `parsers::antigravity` -/// emits exactly that for history. 1.2 ALSO copies each argument onto the top -/// level — for clients that read `rawInput` without knowing about MCP — and -/// moves the sentence into `_meta.prompt`. The dedicated cards peel -/// `arguments` either way, but the generic card lists every top-level key, so -/// each argument would render twice, once flat and once in the `arguments` -/// tree, and the live card would stop matching its history twin. History -/// cannot take the new shape instead: the copy doubles every input against the -/// parser's `TOOL_INPUT_CAP`, and an input cut there is one no card can parse. -/// -/// Folds only an EXACT copy — every key beside `arguments` equal to the same -/// key inside it, none missing — on a call whose `_meta` marks it as MCP. -/// Anything else passes through: a 1.1 input is already in this shape, and a -/// tool whose own argument is named `arguments` overwrites the wrapper -/// upstream, leaving no nested copy to fold into. -fn fold_antigravity_mcp_raw_input( - meta: Option<&serde_json::Map>, - raw_input: Option<&serde_json::Value>, -) -> Option { - let meta = meta?; - if meta.get("is_mcp_tool_call") != Some(&serde_json::Value::Bool(true)) { - return None; - } - let input = raw_input?.as_object()?; - let arguments = input.get("arguments")?.as_object()?; - let flattened_copy = input.len() - 1 == arguments.len() - && arguments - .iter() - .all(|(key, value)| input.get(key) == Some(value)); - if !flattened_copy { - return None; - } - let mut folded = serde_json::Map::new(); - folded.insert( - "arguments".to_string(), - serde_json::Value::Object(arguments.clone()), - ); - let prompt = meta - .get("prompt") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|prompt| !prompt.is_empty()); - if let Some(prompt) = prompt { - folded.insert( - "prompt".to_string(), - serde_json::Value::String(prompt.to_string()), - ); +/// Keep the reply codeg sends back for a Qoder `AskUserQuestion` call, for the +/// call's own completion to render (see `SessionState::qoder_ask_replies`). +/// Only an accepted reply carries answers; anything else keeps nothing. +async fn record_qoder_ask_reply( + state: &Arc>, + tool_call_id: &str, + reply: serde_json::Value, +) { + let serde_json::Value::Object(mut reply) = reply else { + return; + }; + if reply.get("action").and_then(serde_json::Value::as_str) != Some("accept") { + return; } - Some(serde_json::Value::Object(folded)) + let Some(serde_json::Value::Object(content)) = reply.remove("content") else { + return; + }; + state + .write() + .await + .qoder_ask_replies + .insert(tool_call_id.to_string(), content); } -/// Maintain the set of OPEN CodeBuddy sub-agent tool calls (`open`). `is_agent` -/// is true once `resolve_rewritten_title` classified this `tool_call_id` as a -/// native sub-agent (`"agent"`). A non-final status opens the window; a final -/// status (`completed` / `failed`) closes it and records the id in `closed`, so a -/// stray late non-final frame can't re-open an already-finished sub-agent. +/// Resolve a tool call's title, honoring an authoritative rewrite recorded for +/// the session in `overrides` (tool_call_id → resolved title). /// -/// `is_background` (from `codebuddy_meta_marks_background`) EXCLUDES a sub-agent -/// from the window: a background sub-agent runs concurrently with the main agent, -/// so the "window holds only sub-agent chunks" invariant that makes -/// `should_suppress_subagent_chunk` safe would not hold. We treat a background -/// marker exactly like a terminal frame (remove + record closed) so it can never -/// suppress interleaved main-agent output. (`isBackground` can stream in a frame -/// or two after the call opens, so a background sub-agent's earliest chunks may be -/// briefly suppressed before the marker arrives — an accepted, rare imperfection; -/// the user-reported case is foreground, where the marker is `false`.) +/// Returns `Some(name)` when this event identifies a CodeBuddy `DeferExecuteTool` +/// (the inner `mcp__…` name, from `raw_input`) or a sub-agent invocation +/// (`"agent"`) — recording it — OR when a PRIOR event already classified this +/// `tool_call_id` and this event lost the marker (the override is re-asserted). +/// Returns `None` only when the call was never classified, so the caller falls +/// back to the event's own title. /// -/// Gated on CodeBuddy so a single-agent-type connection of any other agent stays -/// inert. -fn track_subagent_window( +/// Sub-agent detection fires on EITHER `raw_input.subagent_type` +/// (`is_subagent_invocation`) OR `meta_marks_subagent` — the precomputed +/// `codebuddy_meta_marks_subagent` result. The meta signal is what makes the pill +/// stable: CodeBuddy carries `codebuddy.ai/toolName == "Agent"` from the very +/// first frame, whereas `subagent_type` only reaches `raw_input` dozens of frames +/// later, so meta-first detection records the override immediately and every +/// later (sparse) frame re-asserts it. +/// +/// The re-assertion is the fix for CodeBuddy's status-only `ToolCallUpdate`s: +/// they arrive without the original `subagent_type`/`toolName` payload but WITH +/// the agent's raw (non-agent) title. Without it the frontend +/// (`inferLiveToolName` → `getToolName`) downgrades the Agent / delegation card +/// back to a generic tool call mid-stream — which also un-nests its children. +/// `on_update` only tunes the (PII-safe, id-only) trace wording. +fn resolve_rewritten_title( agent_type: AgentType, - is_agent: bool, - is_background: bool, - status: Option<&str>, + raw_input: &Option, tool_call_id: &str, - open: &mut HashSet, - closed: &mut HashSet, -) { - if agent_type != AgentType::CodeBuddy || !is_agent { - return; + on_update: bool, + meta_marks_subagent: bool, + overrides: &mut HashMap, +) -> Option { + if let Some(inner) = codebuddy_deferred_tool_name(agent_type, raw_input) { + tracing::info!( + "[ACP][{agent_type}] unwrapped DeferExecuteTool to its real MCP tool (tool_call_id={tool_call_id}, on_update={on_update})" + ); + overrides.insert(tool_call_id.to_string(), inner.clone()); + return Some(inner); } - let is_final = matches!(status, Some("completed") | Some("failed")); - if is_final || is_background { - open.remove(tool_call_id); - closed.insert(tool_call_id.to_string()); - } else if !closed.contains(tool_call_id) { - open.insert(tool_call_id.to_string()); + if is_subagent_invocation(agent_type, raw_input) || meta_marks_subagent { + tracing::info!( + "[ACP][{agent_type}] subagent detected, rewrote tool title to 'agent' (tool_call_id={tool_call_id}, on_update={on_update})" + ); + overrides.insert(tool_call_id.to_string(), "agent".to_string()); + return Some("agent".to_string()); + } + overrides.get(tool_call_id).cloned() +} + +fn map_plan_priority(priority: &PlanEntryPriority) -> String { + match priority { + PlanEntryPriority::High => "high", + PlanEntryPriority::Medium => "medium", + PlanEntryPriority::Low => "low", + _ => "unknown", + } + .to_string() +} + +fn map_plan_status(status: &PlanEntryStatus) -> String { + match status { + PlanEntryStatus::Pending => "pending", + PlanEntryStatus::InProgress => "in_progress", + PlanEntryStatus::Completed => "completed", + _ => "unknown", } + .to_string() } -/// Per-session CodeBuddy live-stream state threaded through -/// `emit_conversation_update`. Consolidates the authoritative title rewrites and -/// the open-sub-agent suppression window so CodeBuddy's sparse, multi-frame -/// sub-agent stream resolves to a stable Agent pill (whose children nest) with -/// its interleaved thought/message chunks suppressed. Created per connection, -/// shared across the idle and active-turn loops; the historical-replay path uses -/// a throwaway instance. Mirrors `ToolCallOutputCache`'s lifetime. -#[derive(Default)] -struct CodeBuddyLiveState { - /// tool_call_id → authoritative title: `"agent"` for a native sub-agent, or - /// the inner `mcp__…` name for a `DeferExecuteTool` MCP call. Re-asserted on - /// every later frame so a status-only update can't downgrade the card. - title_overrides: HashMap, - /// Sub-agent tool calls currently OPEN (classified, not yet completed/failed). - /// While non-empty, interleaved thought/message chunks belong to a sub-agent - /// and are suppressed from the top-level stream (matching Claude Code). - open_subagents: HashSet, - /// Sub-agent tool calls that already reached a final status — guards against a - /// stray late non-final frame re-opening a finished sub-agent. - closed_subagents: HashSet, - /// Objective of the Codex `/goal` run currently open on this connection (set - /// by the latest `active` `session_info_update` goal, cleared on any terminal - /// status). Lets a later `goal:null` close the run by objective — and be a - /// no-op when no run is open. See `crate::acp::codex_goal::next_goal_marker`. - /// - /// This lives here (not in `SessionState`) because `CodeBuddyLiveState` and - /// `SessionState` share one lifetime: a browser refresh / reconnect re-attaches - /// to the *running* connection (`find_connection_for_reuse`), keeping both; a - /// brand-new connection resets both together (empty live blocks + fresh state). - /// So this state never resets while goal blocks it would close still exist. - codex_open_goal: Option, - /// Monotonic per-connection counter for synthetic goal tool-call ids. Occurrence - /// (not content) addressing keeps two runs that share an objective from - /// colliding in the reducer's id-keyed live block list. - codex_goal_seq: u64, - /// Cursor tool calls announced with the identity-less "MCP: tool" title. - /// Only these are eligible for the completion-time result sniff - /// (`cursor_companion_title_from_content`) — a `shell`/`read` call whose - /// OUTPUT merely echoes a delegation ack must never be re-titled. Entries - /// are dropped once the call reaches a terminal status (the sniff, if any, - /// has recorded its override by then), so the set tracks only in-flight - /// calls. - cursor_generic_mcp_ids: HashSet, - /// Grok tool_call ids whose interactive question already renders via the - /// `_x.ai/ask_user_question` ext bridge (`handle_grok_ask_user_question`). The - /// redundant native `tool_call` / `tool_call_update` stream for these is - /// dropped so the card doesn't double-render; tracked by id because a later - /// status-only update may drop the `x.ai/tool` meta that first identified it. - grok_ask_tool_ids: HashSet, - /// Qoder `AskUserQuestion` calls in flight → the questions they were opened - /// with. Qoder names the tool only on the opening `tool_call` - /// (`_meta.qoder.toolName`), and its completion carries the answers only as - /// the sentence handed to the model; these questions are what both the - /// reply codeg sent (keyed `q`) and that sentence are read against (see - /// `qoder_ask_live_output`). Dropped at a final status, and cleared at turn - /// start: an ask blocks its turn, so a canceled turn's entry is never - /// completed. - qoder_ask_questions: HashMap>, - /// Codex sub-agent thread id → the launch capsule that spawned it, paired - /// with the `rawInput` that capsule was announced with. - /// - /// codex reports a child's completion as a `subAgentActivity` of its own, - /// under a synthetic tool-call id (`subagent-completed-`) that shares - /// nothing with the launch except the thread id — so this is the only way - /// back to the card that should carry the outcome. Keeping the input too - /// avoids reconstructing it: the outcome is delivered by re-sending it with - /// one key added (`codex_subagent_terminal_input`). - /// - /// NOT cleared per turn, for the same reason the grok sub-agent map is not: - /// a child legitimately outlives the turn that launched it. - codex_subagent_launches: HashMap, - /// Grok `spawn_subagent` tool_call ids ever announced on this connection - /// (dedupe for the pending queue + status tracking on meta-less updates). - grok_spawn_seen: HashSet, - /// Announced spawn calls not yet paired with a `subagent_spawned` - /// notification. Grok's ext notifications carry a `subagent_id` but no - /// `tool_call_id`, so pairing is by matching `(description, subagent_type)` - /// captured from the launch `rawInput` against the notification's own - /// fields, first match in stream order — FIFO in the common in-order case - /// (mirroring the history parser's pairing), while a DELAYED prior-turn - /// `subagent_spawned` fails the match against a new turn's differently- - /// described entry instead of stealing its slot. A spawn that FAILS before - /// pairing is removed so later pairs can't shift; the queue is cleared at - /// every turn start (staleness bound). - grok_pending_spawn_ids: VecDeque, - /// subagent_id → its launching spawn tool_call id, from `subagent_spawned`. - /// Routes `subagent_progress` ticks (live meta on the Agent card) and the - /// `subagent_finished` settle; the entry is dropped at finish. - grok_subagent_to_call: HashMap, - /// spawn tool_call id → the child's OWN session id (`child_session_id`, or - /// the subagent id it always equals in practice). Grok runs each sub-agent - /// as a standalone session that writes its transcript to disk while it - /// works, so this is what lets the live Agent card offer "open the child's - /// session" — the only way to watch a blocking child, whose launch call - /// carries no output until it finishes. Kept in state (not just emitted - /// once) because `upsert_tool_call` REPLACES a block's meta: every later - /// progress tick must re-send it or the id would be dropped. Entry dropped - /// at finish, alongside `grok_subagent_to_call`. - grok_call_child_session: HashMap, - /// Spawn calls that reached a terminal wire status. A `subagent_finished` - /// for one of these is a BACKGROUND child settling after its launch ack — - /// the case whose result would otherwise never reach the card live; a - /// blocking spawn's own completion frame carries the output instead. - grok_settled_spawn_ids: HashSet, - /// Spawn calls announced in the CURRENT turn — the only ones whose - /// `subagent_progress` ticks may emit a live `ToolCallUpdate`. Cleared at - /// every turn start (with the pending queue): a tick for a PRIOR turn's - /// background child would otherwise pass the Prompting gate during a later - /// turn and get appended into that turn's live message as a ghost card - /// (its real card lives in the promoted history). The settle path - /// (`subagent_finished` → BackgroundActivity) is unaffected — it targets - /// promoted turns by design. - grok_progress_eligible: HashSet, - /// Context window of the model this Grok turn runs on, resolved ONCE at turn - /// start (`grok_current_model_context_window`) so the per-update usage peek - /// never takes the state lock on the streaming hot path. `None` for non-Grok - /// agents, and for a Grok model whose spec carried no `totalContextTokens` - /// — the ring then keeps falling back to the parsed per-turn stats. - grok_turn_context_window: Option, - /// Last `(used, size)` emitted as a live `UsageUpdate` on this connection. - /// Grok repeats its cumulative `totalTokens` on nearly every update (169 of - /// 189 in a real capture), so re-emitting each one would put a broadcast on - /// a per-chunk hot path for a number that changes a handful of times a turn. - /// The WINDOW is part of the key so a between-turn model switch re-emits - /// even when the token count hasn't moved yet — otherwise the ring would - /// keep dividing by the previous model's window. - grok_last_usage: Option<(u64, u64)>, - /// The prompt id of the turn Grok is running on its own that the idle loop - /// is rendering (see [`grok_agent_turn_opener`]); `None` when none is open. - /// Paired with `SessionState::agent_initiated_turn`, which keeps that - /// turn's end off the prompt gate. - grok_agent_turn: Option, - /// The prompt id of every Grok turn this session has seen END — ones codeg - /// started (read off their frames and responses), and agent-initiated ones - /// it closed. Frames Grok still delivers for them afterwards must neither - /// open a turn nor end one. Every one, not the latest: a prompt that - /// answers at once (a slash command) can end while an earlier turn's - /// frames are still queued, and nothing marks when those have drained. One - /// short id per turn is nothing to keep for a session's life. - grok_ended_prompt_ids: HashSet, - /// Which of codex's `usage_update`s read the context window: the one for a - /// request that ran a hosted web search covers the provider's whole search - /// loop, not the context (see `crate::acp::codex_context`). - codex_context_readings: crate::acp::codex_context::ContextReadings, - /// Shell tool calls whose terminal the AGENT hosts itself (pi bash, - /// codex command execution) → whether any output has already been emitted - /// for that call. - /// - /// Registered from the opening frame's `_meta.terminal_info` - /// (see `hosted_terminal_meta_marks_shell`), because the frames that actually - /// CARRY the output name only the tool-call id — `terminal_info` never - /// repeats. The flag is what makes the first bridged chunk a replacement and - /// every later one an append, the same rule - /// `TrackedTerminalToolCall::has_emitted_output` applies on the host-terminal - /// path. Entries are dropped at a final status, alongside - /// `ToolCallOutputCache::remove_if_final`, and the whole map is cleared at - /// 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, - /// The merged `_meta` of every claude / codex tool call on this connection - /// — the AIR contract (claude-agent-acp 0.82.0, codex-acp 2.0.0) sends each - /// `_meta` key once and expects the client to merge. See - /// [`crate::acp::air_contract`]. - air_tool_call_meta: crate::acp::air_contract::ToolCallMetaLedger, - /// codex `subAgentActivity` tool calls classified on their opening frame: - /// `Some(input)` for a launch capsule (the Agent-card input it was announced - /// with), `None` for an activity that is dropped. codex-acp 2.0.0 sends the - /// follow-up of such a call as a bare status — no `rawInput`, no marker — so - /// the id is the only thing left to classify it by (1.4.0–1.13.x repeated - /// `_meta.codex.subagent` on every frame). Kept for the connection, like - /// `codex_subagent_launches`: a child outlives its turn. - codex_activity_calls: HashMap>, - /// claude `Read` / `Grep` / `Glob` results taken off the raw SDK stream - /// (`_claude/sdkMessage`, a `user` message's `tool_result`), keyed by the - /// tool-use id, waiting for the call's completion frame. - /// - /// claude-agent-acp 0.82.0 withholds the text of those results from an AIR - /// client ("AIR shows a read or a search that names a path as the list of - /// viewed files … the text of the result stays out"), so the completion - /// arrives as a bare status and the card would show the path and nothing - /// else — while the same call reloaded from the transcript shows the text. - /// codeg already subscribes to the raw SDK stream (`emitRawSDKMessages`), - /// and the adapter forwards each raw message BEFORE it reports it, so the - /// result is here first. It only ever fills a completion that carries no - /// result of its own (see the `ToolCallUpdate` arm), and the map is cleared - /// at turn start like `hosted_terminal_calls`. - claude_viewed_results: HashMap, - /// The plugin-failure list last forwarded as - /// [`AcpEvent::PluginLoadFailures`] (see `take_new_claude_plugin_failures`). - /// Claude Code repeats `plugin_errors` on every turn's `system/init`, so - /// without this each turn would re-raise the same warning. Kept across - /// turns — only a CHANGED list is news — and cleared by an init that lists - /// none, so a failure that went away and came back is reported again. - claude_reported_plugin_failures: Option>, +fn map_plan_entries(plan: &Plan) -> Vec { + plan.entries + .iter() + .map(|entry| PlanEntryInfo { + content: entry.content.clone(), + priority: map_plan_priority(&entry.priority), + status: map_plan_status(&entry.status), + }) + .collect() } -/// One announced-but-unpaired Grok `spawn_subagent` call. `description` / -/// `subagent_type` come from the launch `rawInput` and validate the pairing -/// against the `subagent_spawned` notification's own fields (`None` = wildcard, -/// tolerant of older wire shapes). -#[derive(Debug)] -struct GrokPendingSpawn { - call_id: String, - description: Option, - subagent_type: Option, +fn parse_claude_sdk_message_params( + params: &serde_json::Value, +) -> Option<(String, serde_json::Value)> { + let obj = params.as_object()?; + let session_id = obj.get("sessionId")?.as_str()?.to_string(); + let message = obj.get("message")?.clone(); + Some((session_id, message)) +} + +fn is_claude_api_retry_message(message: &serde_json::Value) -> bool { + let obj = match message.as_object() { + Some(obj) => obj, + None => return false, + }; + let message_type = obj.get("type").and_then(|v| v.as_str()); + let message_subtype = obj.get("subtype").and_then(|v| v.as_str()); + matches!(message_type, Some("system")) && matches!(message_subtype, Some("api_retry")) +} + +/// The JSON-RPC method claude-agent-acp uses to mirror raw SDK messages. Named +/// so `is_known_ext_method` and the mapper can't drift apart. +const CLAUDE_SDK_EXT_METHOD: &str = "_claude/sdkMessage"; + +fn map_claude_sdk_ext_notification(notification: &UntypedMessage) -> Option { + if notification.method() != CLAUDE_SDK_EXT_METHOD { + return None; + } + + let (session_id, message) = parse_claude_sdk_message_params(notification.params())?; + if !is_claude_api_retry_message(&message) { + return None; + } + Some(AcpEvent::ClaudeSdkMessage { + session_id, + message, + }) } -/// True when a Grok tool call's ACP `_meta` identifies it as the native -/// `spawn_subagent` launcher (`_meta["x.ai/tool"].name`). Present on the very -/// first frame (verified against real captures), unlike `subagent_type` which -/// rides `rawInput`. Gated on Grok so the namespaced key can't affect others. -fn grok_meta_marks_spawn_subagent( - agent_type: AgentType, - meta: Option<&serde_json::Map>, -) -> bool { - matches!(agent_type, AgentType::Grok) - && meta - .and_then(|m| m.get("x.ai/tool")) - .and_then(|t| t.get("name")) - .and_then(|n| n.as_str()) - == Some("spawn_subagent") +/// The JSON-RPC methods grok uses for its private, namespaced session updates. +/// Both share the standard `session/update` envelope (`params.update. +/// sessionUpdate` + fields, verified live against grok 0.2.111) but carry +/// variants the typed ACP pipeline can't deserialize, so codeg drops them. +const GROK_EXT_UPDATE_METHODS: [&str; 2] = + ["_x.ai/session_notification", "_x.ai/session/update"]; + +/// A stable id for a synthetic event derived from a grok ext notification — +/// grok stamps `params._meta.eventId`; fall back to a fresh uuid. +fn grok_ext_event_id(params: &serde_json::Value) -> String { + params + .get("_meta") + .and_then(|m| m.get("eventId")) + .and_then(|v| v.as_str()) + .map(str::to_string) + .unwrap_or_else(|| format!("grok-ext-{}", uuid::Uuid::new_v4().simple())) } -/// Track a Grok `spawn_subagent` call's lifecycle for the subagent-notification -/// pairing (see the `grok_*` fields on [`CodeBuddyLiveState`]). `is_spawn` is -/// the precomputed meta marker; a status-only update that lost the meta still -/// tracks via the seen-set. `raw_input` (the launch input, when this frame -/// carries it) supplies the `(description, subagent_type)` the pairing -/// validates against. No-op for other agents/tools by construction -/// (`is_spawn` false + id never seen). -fn track_grok_spawn_call( - cb_state: &mut CodeBuddyLiveState, - is_spawn: bool, - status: Option<&str>, - tool_call_id: &str, - raw_input: &Option, -) { - if is_spawn && cb_state.grok_spawn_seen.insert(tool_call_id.to_string()) { - let (description, subagent_type) = raw_input - .as_deref() - .and_then(|text| serde_json::from_str::(text).ok()) - .map(|input| { - let field = |key: &str| { - input - .get(key) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(str::to_string) - }; - (field("description"), field("subagent_type")) - }) - .unwrap_or((None, None)); - cb_state.grok_pending_spawn_ids.push_back(GrokPendingSpawn { - call_id: tool_call_id.to_string(), - description, - subagent_type, - }); - // Announced in the current turn → its progress ticks may render live. - cb_state - .grok_progress_eligible - .insert(tool_call_id.to_string()); +/// Map grok's private ext notifications — context compaction and dropped +/// prompt images — into `AcpEvent`s. +/// +/// grok reports `/compact` (and auto-compaction) results, and the fate of an +/// image it refused to send, on +/// `_x.ai/session_notification` / `_x.ai/session/update` rather than as normal +/// `agent_message_chunk`s. Those methods never match the typed `session/update` +/// pipeline, so without this the whole turn is blank and `/compact` looks like +/// it failed. Only grok emits these, so gate on the agent. Turn-level failures +/// are intentionally NOT handled here — the `session/prompt` response path +/// (`turn_failure_error_event`) already surfaces those, and duplicating them +/// would double-report. (The one turn with no response, a turn Grok starts on +/// its own, is ended by the idle loop — see [`close_grok_agent_turn`].) +fn map_grok_ext_notification( + notification: &UntypedMessage, + agent_type: AgentType, +) -> Option { + if !matches!(agent_type, AgentType::Grok) { + return None; } - if !cb_state.grok_spawn_seen.contains(tool_call_id) { - return; + if !GROK_EXT_UPDATE_METHODS.contains(¬ification.method()) { + return None; } - match status { - Some("completed") => { - cb_state - .grok_settled_spawn_ids - .insert(tool_call_id.to_string()); + let params = notification.params(); + let update = params.get("update")?; + match update.get("sessionUpdate").and_then(|v| v.as_str())? { + // grok always emits `completed` (even a no-op where before == after). + // Render the shared context-compaction card (recognized frontend-side by + // `_meta.contextCompaction`, same as codex) carrying the token delta. + "auto_compact_completed" => { + let mut meta = serde_json::Map::new(); + meta.insert( + "contextCompaction".to_string(), + serde_json::Value::Bool(true), + ); + if let Some(before) = update.get("tokens_before").and_then(|v| v.as_u64()) { + meta.insert("tokensBefore".to_string(), before.into()); + } + if let Some(after) = update.get("tokens_after").and_then(|v| v.as_u64()) { + meta.insert("tokensAfter".to_string(), after.into()); + } + Some(AcpEvent::ToolCall { + tool_call_id: grok_ext_event_id(params), + title: "Context compaction".to_string(), + kind: "other".to_string(), + status: "completed".to_string(), + content: None, + raw_input: None, + raw_output: None, + locations: None, + meta: Some(serde_json::Value::Object(meta)), + images: None, + }) } - Some("failed") => { - cb_state - .grok_settled_spawn_ids - .insert(tool_call_id.to_string()); - // Never spawned a child (depth-limit / config error): drop it from - // the pairing queue so the next spawn doesn't inherit its slot. - cb_state - .grok_pending_spawn_ids - .retain(|pending| pending.call_id != tool_call_id); + // A prompt image was accepted on the wire but dropped before the + // describe sidecar (too small, oversize, decode failure). Surface it + // so the user isn't left wondering why Grok "can't see" the shot. + "image_dropped" => { + // `notes` is grok's own user-facing sentence, one per dropped image + // ("Image 1 was dropped before send: too small (1×1); images must be + // at least 8×8 pixels."). It already names the subject, so it is + // shown verbatim — prefixing it would read "Image dropped: Image 1 + // was dropped before send: …". Only the shapeless fallbacks get a + // prefix, because on their own they say nothing about images. + let message = update + .get("notes") + .and_then(|v| v.as_array()) + .map(|notes| { + notes + .iter() + .filter_map(|n| n.as_str()) + .map(str::trim) + .filter(|n| !n.is_empty()) + .collect::>() + .join(" ") + }) + .filter(|s| !s.is_empty()) + .or_else(|| { + update + .get("reason") + .or_else(|| update.get("message")) + .and_then(|v| v.as_str()) + .map(str::trim) + // A blank reason would render as a bare "Image dropped: " + // — worse than the generic sentence below. + .filter(|d| !d.is_empty()) + .map(|d| format!("Image dropped: {d}")) + }) + .unwrap_or_else(|| "An image was dropped before send.".to_string()); + Some(AcpEvent::Error { + message, + agent_type: agent_type.to_string(), + code: Some(IMAGE_DROPPED_ERROR_CODE.to_string()), + details: None, + terminal: false, + }) } - _ => {} + // Compaction itself blew up (e.g. the summarizer model call failed) while + // the turn still ended cleanly. It lands where every other compaction + // outcome does — the shared context-compaction card, here in its FAILED + // state with the reason as the card's hover text — rather than as a + // session error under the composer. That is also how a failed + // compaction reads on codex and claude (`session_compaction_event`), and + // the history parser synthesizes the same card from the log. The coded + // `Error` readers other than the app still need is emitted beside it by + // the caller (`compaction_failure_error`), keeping this mapper 1:1. + "auto_compact_failed" => Some(AcpEvent::ToolCall { + tool_call_id: grok_ext_event_id(params), + title: "Context compaction".to_string(), + kind: "other".to_string(), + status: "failed".to_string(), + content: None, + raw_input: None, + raw_output: None, + locations: None, + meta: Some(grok_failed_compaction_meta(update)), + images: None, + }), + _ => None, } } -/// True when a tool call's ACP `_meta` marks it as grok's native -/// `ask_user_question` (`x.ai/tool.kind == "ask_user"`). Codeg answers grok's -/// blocking `_x.ai/ask_user_question` ext request by rendering the interactive -/// `AskQuestionCard` (see `handle_grok_ask_user_question`), so the parallel -/// `tool_call` stream grok emits for the same call is redundant — it is dropped -/// live so the question doesn't render twice (once answerable, once inert). -fn grok_meta_marks_ask_user( - agent_type: AgentType, - meta: Option<&serde_json::Map>, -) -> bool { - matches!(agent_type, AgentType::Grok) - && meta - .and_then(|m| m.get("x.ai/tool")) - .and_then(|t| t.get("kind")) - .and_then(|k| k.as_str()) - == Some("ask_user") +/// `_meta` for a failed grok auto-compaction: the versioned +/// `contextCompaction` payload (`{version: 1, error?}`), whose `error` the +/// compaction card shows as the reason. Shared by the live mapper and the +/// history parser so a reopened conversation renders the same card. +pub(crate) fn grok_failed_compaction_meta(update: &serde_json::Value) -> serde_json::Value { + let mut compaction = serde_json::Map::new(); + compaction.insert("version".to_string(), 1.into()); + if let Some(reason) = update + .get("reason") + .or_else(|| update.get("message")) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|reason| !reason.is_empty()) + { + compaction.insert("error".to_string(), reason.into()); + } + serde_json::json!({ "contextCompaction": compaction }) } -/// The questions of the Qoder `AskUserQuestion` call this opening `tool_call` -/// announces, or `None` when it announces anything else. Qoder states the -/// tool's name only here (`_meta.qoder.toolName`); the questions may be empty -/// if `rawInput` has not arrived yet, and a later frame that carries it fills -/// them in. -fn qoder_ask_opened( +/// Map grok's sub-agent lifecycle notifications (`_x.ai/session/update` with +/// `sessionUpdate: subagent_spawned | subagent_progress | subagent_finished`) +/// onto live events for the launching `spawn_subagent` Agent card. STATEFUL — +/// kept out of the pure [`map_grok_ext_notification`] (which doubles as the +/// turn-output predicate and must stay re-invocable). +/// +/// Grok never forwards a child's chunks or tool calls over ACP (unlike +/// claude-agent-acp ≥0.63) — these three notifications are the ONLY live +/// signal a subagent emits, so: +/// +/// * `subagent_spawned` pairs `subagent_id` → the oldest unpaired spawn call +/// (FIFO; the notifications carry no `tool_call_id` — same pairing as the +/// history parser). +/// * `subagent_progress` becomes a meta-only `ToolCallUpdate` +/// (`meta.grokSubagentProgress`) so the running Agent card can show a live +/// "N tools · M turns · ctx%" line. Replacing the block's meta is safe for +/// this call: its classification rides `rawInput.subagent_type`, not meta. +/// GATED on `turn_active`: after `TurnComplete` the frontend diverts wire +/// tool updates out of the transcript anyway, while the backend +/// `SessionState` apply arm would lazily RECREATE `live_message` for the +/// update — resurrecting a ghost pending card into every later +/// snapshot/attach. An out-of-turn tick is therefore dropped whole (the +/// `subagent_finished` settle below is the out-of-turn-safe channel). +/// * `subagent_finished` for a spawn whose CALL already settled (= a +/// BACKGROUND child; the launch ack was its final wire output) settles via +/// the same `BackgroundActivity` channel Claude's async sub-agents use: the +/// frontend flips the launch card's `[[codeg-background-task]]` marker +/// in-memory (works out-of-turn too), raises the OS notification, and +/// mirrors `outstanding` for the idle-sweep exemption. A BLOCKING spawn is +/// skipped — its own completion frame delivers the output. +fn map_grok_subagent_notification( + notification: &UntypedMessage, agent_type: AgentType, - meta: Option<&serde_json::Map>, - raw_input: Option<&serde_json::Value>, -) -> Option> { - let names_ask = agent_type == AgentType::Qoder - && meta - .and_then(|m| m.get("qoder")) - .and_then(|q| q.get("toolName")) - .and_then(serde_json::Value::as_str) - == Some(crate::parsers::qoder::QODER_ASK_USER_QUESTION_TOOL); - names_ask.then(|| { - crate::parsers::qoder::QoderAskQuestion::list_from( - raw_input.and_then(|input| input.get("questions")), - ) + turn_active: bool, + cb_state: &mut CodeBuddyLiveState, +) -> Vec { + map_grok_subagent_notification_inner(notification, agent_type, turn_active, cb_state) .unwrap_or_default() - }) } -/// The `raw_output` a Qoder `AskUserQuestion` frame should carry: its answers, -/// as the envelope the question card reads, when the frame is the answered -/// result. `None` leaves the frame's own output alone (not an ask, a -/// rejection, anything that is not an answered shape). -/// -/// Qoder answers the ask over `elicitation/create` but announces and completes -/// it as its own tool call, and nothing it streams for that call is something -/// the card can read: -/// -/// - a live call's completion carries the sentence handed to the model — in -/// `rawOutput`, and again as `content`. When codeg answered the form itself, -/// `reply` is what it sent (`SessionState::qoder_ask_replies`), which is -/// exact and wins; otherwise the sentence is read back against the questions -/// the call opened with (`parsers::qoder::qoder_ask_envelope_from_result_text`); -/// - a `session/load` replay sends the call once, already completed, with the -/// recorded `{questions, answers}` object as `rawOutput`, read the way the -/// history parser reads the transcript's copy of that object -/// (`parsers::qoder::qoder_ask_envelope_from_recorded`). -/// -/// Without this, a question the user just answered shows nothing selected. -fn qoder_ask_live_output( - questions: Option<&Vec>, - reply: Option<&serde_json::Map>, - raw_output: &Option, - content: Option<&str>, -) -> Option { - let questions = questions?; - if let Some(answers) = reply - .and_then(|reply| crate::parsers::qoder::qoder_ask_envelope_from_reply(questions, reply)) - { - return Some(answers); +/// The body of [`map_grok_subagent_notification`], written with `?` on the many +/// "this notification isn't one of ours / is malformed" checks. A spawn can +/// produce TWO events (the card's session stamp plus the background-activity +/// report), hence the list. +fn map_grok_subagent_notification_inner( + notification: &UntypedMessage, + agent_type: AgentType, + turn_active: bool, + cb_state: &mut CodeBuddyLiveState, +) -> Option> { + if !matches!(agent_type, AgentType::Grok) { + return None; } - match raw_output { - Some(recorded @ serde_json::Value::Object(_)) => { - crate::parsers::qoder::qoder_ask_envelope_from_recorded(recorded) + if !GROK_EXT_UPDATE_METHODS.contains(¬ification.method()) { + return None; + } + let params = notification.params(); + let update = params.get("update")?; + let subagent_id = update.get("subagent_id").and_then(|v| v.as_str())?; + // `outstanding` = paired subagents still running whose launch call already + // settled — i.e. background children codeg would otherwise sweep as idle. + let outstanding = |cb_state: &CodeBuddyLiveState| { + cb_state + .grok_subagent_to_call + .values() + .filter(|call_id| cb_state.grok_settled_spawn_ids.contains(*call_id)) + .count() as u32 + }; + match update.get("sessionUpdate").and_then(|v| v.as_str())? { + "subagent_spawned" => { + // First pending entry whose captured launch `(description, + // subagent_type)` is consistent with the notification's own + // fields. ASYMMETRIC rule: a value captured from the launch input + // must be matched by an EQUAL value on the notification — a + // notification that omits the field does NOT wildcard past it + // (that shape would let a delayed, description-less prior-turn + // notification steal a described new-turn entry). Only a pending + // side that captured nothing (unparseable/absent input field — + // then there is nothing to validate against) is a wildcard. Real + // captures always carry both fields on both sides, so in-order + // streams match at the head (plain FIFO); the fail direction for + // an unmatched notification is pairing nothing (live progress/ + // settle degrade gracefully; history re-parse is unaffected). + let event_field = |key: &str| { + update + .get(key) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + }; + let matches = |pending: &Option, event: Option<&str>| match (pending, event) { + (Some(p), Some(e)) => p == e, + (Some(_), None) => false, + (None, _) => true, + }; + let idx = cb_state.grok_pending_spawn_ids.iter().position(|p| { + matches(&p.description, event_field("description")) + && matches(&p.subagent_type, event_field("subagent_type")) + })?; + let call_id = cb_state.grok_pending_spawn_ids.remove(idx)?.call_id; + cb_state + .grok_subagent_to_call + .insert(subagent_id.to_string(), call_id.clone()); + // The child's own session id — `child_session_id` when the wire + // carries it, else the subagent id (they are the same value in + // every capture). Remembered so later progress ticks can re-send it + // (`upsert_tool_call` replaces meta wholesale, so every tick must + // carry it again). + // + // Gated by the same `is_safe_subagent_id` the history parser applies + // to this field: the value is handed to the frontend, which asks + // `get_conversation` to resolve a session directory by it, so both + // paths have to reject a traversal-shaped id — not just the one that + // reads from disk itself. Failing the gate simply leaves the card + // without a session to open. + let child_session_id = update + .get("child_session_id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or(subagent_id); + let child_session_id = crate::parsers::is_safe_subagent_id(child_session_id) + .then(|| child_session_id.to_string()); + if let Some(child) = &child_session_id { + cb_state + .grok_call_child_session + .insert(call_id.clone(), child.clone()); + } + + let mut events = Vec::new(); + // Stamp the child's session onto the launching Agent card so it can + // offer to open the child's transcript WHILE it runs — for a + // blocking spawn that is the only live window into the child, since + // its call produces no output until the very end. Gated exactly like + // a progress tick: out of turn, the `SessionState` apply arm would + // lazily recreate `live_message` and strand a ghost card in every + // later snapshot. + if turn_active && cb_state.grok_progress_eligible.contains(&call_id) { + events.push(AcpEvent::ToolCallUpdate { + tool_call_id: call_id.clone(), + title: None, + status: None, + content: None, + raw_input: None, + raw_output: None, + raw_output_append: None, + locations: None, + meta: Some(grok_subagent_meta( + subagent_id, + child_session_id.as_deref(), + None, + )), + images: None, + }); + } + // A background launch's call settles before/around the pairing; + // surface the outstanding count so the connection is exempt from + // idle sweeps while the child works. Nothing to add for a + // blocking spawn (its turn is open — nothing can sweep it). + if cb_state.grok_settled_spawn_ids.contains(&call_id) { + let session_id = params.get("sessionId").and_then(|v| v.as_str())?; + events.push(AcpEvent::BackgroundActivity { + session_id: session_id.to_string(), + turns: Vec::new(), + outstanding: outstanding(cb_state), + settled: Vec::new(), + watermark: 0, + }); + } + Some(events) } - raw_output => { - let text = raw_output - .as_ref() - .and_then(serde_json::Value::as_str) - .or(content)?; - crate::parsers::qoder::qoder_ask_envelope_from_result_text(questions, text) + "subagent_progress" => { + if !turn_active { + return None; + } + let call_id = cb_state.grok_subagent_to_call.get(subagent_id)?.clone(); + // Launch-turn-specific gate on top of Prompting: a tick for a + // PRIOR turn's background child must not be appended into the + // CURRENT turn's live message (see `grok_progress_eligible`). + if !cb_state.grok_progress_eligible.contains(&call_id) { + return None; + } + let mut progress = serde_json::Map::new(); + for (wire, out) in [ + ("duration_ms", "durationMs"), + ("turn_count", "turnCount"), + ("tool_call_count", "toolCallCount"), + ("context_usage_pct", "contextUsagePct"), + ("error_count", "errorCount"), + ] { + if let Some(v) = update.get(wire).filter(|v| v.is_number()) { + progress.insert(out.to_string(), v.clone()); + } + } + if progress.is_empty() { + return None; + } + // Carry the session stamp along: `upsert_tool_call` replaces the + // block's meta wholesale, so a progress-only payload would drop the + // "open the child's session" affordance mid-run. + let child_session_id = cb_state.grok_call_child_session.get(&call_id).cloned(); + Some(vec![AcpEvent::ToolCallUpdate { + tool_call_id: call_id, + title: None, + status: None, + content: None, + raw_input: None, + raw_output: None, + raw_output_append: None, + locations: None, + meta: Some(grok_subagent_meta( + subagent_id, + child_session_id.as_deref(), + Some(serde_json::Value::Object(progress)), + )), + images: None, + }]) + } + "subagent_finished" => { + let call_id = cb_state.grok_subagent_to_call.remove(subagent_id)?; + cb_state.grok_call_child_session.remove(&call_id); + if !cb_state.grok_settled_spawn_ids.contains(&call_id) { + // Blocking spawn: the child's output arrives on the call's own + // completion frame; the settle channel would double-render it. + return None; + } + let session_id = params.get("sessionId").and_then(|v| v.as_str())?; + let status = update + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("completed"); + let result = update + .get("output") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| crate::parsers::truncate_str(s, crate::parsers::claude::BACKGROUND_RESULT_MAX_CHARS)); + Some(vec![AcpEvent::BackgroundActivity { + session_id: session_id.to_string(), + turns: Vec::new(), + outstanding: outstanding(cb_state), + settled: vec![crate::acp::types::BackgroundSettledInfo { + task_id: subagent_id.to_string(), + status: status.to_string(), + summary: None, + tool_use_id: Some(call_id), + result, + }], + watermark: 0, + }]) } + _ => None, } } -/// Keep the reply codeg sends back for a Qoder `AskUserQuestion` call, for the -/// call's own completion to render (see `SessionState::qoder_ask_replies`). -/// Only an accepted reply carries answers; anything else keeps nothing. -async fn record_qoder_ask_reply( - state: &Arc>, - tool_call_id: &str, - reply: serde_json::Value, -) { - let serde_json::Value::Object(mut reply) = reply else { - return; - }; - if reply.get("action").and_then(serde_json::Value::as_str) != Some("accept") { - return; - } - let Some(serde_json::Value::Object(content)) = reply.remove("content") else { - return; - }; - state - .write() - .await - .qoder_ask_replies - .insert(tool_call_id.to_string(), content); -} - -/// Resolve a tool call's title, honoring an authoritative rewrite recorded for -/// the session in `overrides` (tool_call_id → resolved title). -/// -/// Returns `Some(name)` when this event identifies a CodeBuddy `DeferExecuteTool` -/// (the inner `mcp__…` name, from `raw_input`) or a sub-agent invocation -/// (`"agent"`) — recording it — OR when a PRIOR event already classified this -/// `tool_call_id` and this event lost the marker (the override is re-asserted). -/// Returns `None` only when the call was never classified, so the caller falls -/// back to the event's own title. -/// -/// Sub-agent detection fires on EITHER `raw_input.subagent_type` -/// (`is_subagent_invocation`) OR `meta_marks_subagent` — the precomputed -/// `codebuddy_meta_marks_subagent` result. The meta signal is what makes the pill -/// stable: CodeBuddy carries `codebuddy.ai/toolName == "Agent"` from the very -/// first frame, whereas `subagent_type` only reaches `raw_input` dozens of frames -/// later, so meta-first detection records the override immediately and every -/// later (sparse) frame re-asserts it. -/// -/// The re-assertion is the fix for CodeBuddy's status-only `ToolCallUpdate`s: -/// they arrive without the original `subagent_type`/`toolName` payload but WITH -/// the agent's raw (non-agent) title. Without it the frontend -/// (`inferLiveToolName` → `getToolName`) downgrades the Agent / delegation card -/// back to a generic tool call mid-stream — which also un-nests its children. -/// `on_update` only tunes the (PII-safe, id-only) trace wording. -fn resolve_rewritten_title( - agent_type: AgentType, - raw_input: &Option, - tool_call_id: &str, - on_update: bool, - meta_marks_subagent: bool, - overrides: &mut HashMap, -) -> Option { - if let Some(inner) = codebuddy_deferred_tool_name(agent_type, raw_input) { - tracing::info!( - "[ACP][{agent_type}] unwrapped DeferExecuteTool to its real MCP tool (tool_call_id={tool_call_id}, on_update={on_update})" - ); - overrides.insert(tool_call_id.to_string(), inner.clone()); - return Some(inner); - } - if is_subagent_invocation(agent_type, raw_input) || meta_marks_subagent { - tracing::info!( - "[ACP][{agent_type}] subagent detected, rewrote tool title to 'agent' (tool_call_id={tool_call_id}, on_update={on_update})" - ); - overrides.insert(tool_call_id.to_string(), "agent".to_string()); - return Some("agent".to_string()); +/// The `meta` payload a Grok sub-agent's launching Agent card carries: +/// `grokSubagentSession` (ids — lets the card open the child's transcript) and, +/// while it runs, `grokSubagentProgress` (the live "N tools · M turns · ctx%" +/// line). Built in one place because `upsert_tool_call` REPLACES a block's +/// meta: every emission has to carry the whole picture, not just its own half. +fn grok_subagent_meta( + subagent_id: &str, + child_session_id: Option<&str>, + progress: Option, +) -> serde_json::Value { + let mut session = serde_json::Map::new(); + session.insert("subagentId".to_string(), subagent_id.into()); + if let Some(child) = child_session_id { + session.insert("childSessionId".to_string(), child.into()); } - overrides.get(tool_call_id).cloned() -} - -fn map_plan_priority(priority: &PlanEntryPriority) -> String { - match priority { - PlanEntryPriority::High => "high", - PlanEntryPriority::Medium => "medium", - PlanEntryPriority::Low => "low", - _ => "unknown", + let mut meta = serde_json::Map::new(); + meta.insert( + "grokSubagentSession".to_string(), + serde_json::Value::Object(session), + ); + if let Some(progress) = progress { + meta.insert("grokSubagentProgress".to_string(), progress); } - .to_string() + serde_json::Value::Object(meta) } -fn map_plan_status(status: &PlanEntryStatus) -> String { - match status { - PlanEntryStatus::Pending => "pending", - PlanEntryStatus::InProgress => "in_progress", - PlanEntryStatus::Completed => "completed", - _ => "unknown", +/// Whether a dispatch is a grok ext notification that +/// `map_grok_ext_notification` renders as visible turn output (a compaction +/// card, a compaction error, or a dropped-image error). The active-turn loop +/// consults this BEFORE the typed +/// pipeline to mark the turn as non-empty: a `/compact` turn emits only these +/// ext notifications and no standard `agent_message_chunk`, so without this its +/// `end_turn` is reclassified as `"empty"` and re-surfaced as a spurious error — +/// the exact symptom this change removes. Reuses `map_grok_ext_notification` so +/// the handled-variant set can never drift from what actually emits. A turn +/// whose only output was a dropped image therefore reports THAT, rather than +/// the generic empty-turn failure it used to. +fn grok_ext_notification_is_turn_output(dispatch: &Dispatch, agent_type: AgentType) -> bool { + match dispatch { + Dispatch::Notification(notification) => { + map_grok_ext_notification(notification, agent_type).is_some() + } + _ => false, } - .to_string() -} - -fn map_plan_entries(plan: &Plan) -> Vec { - plan.entries - .iter() - .map(|entry| PlanEntryInfo { - content: entry.content.clone(), - priority: map_plan_priority(&entry.priority), - status: map_plan_status(&entry.status), - }) - .collect() -} - -fn parse_claude_sdk_message_params( - params: &serde_json::Value, -) -> Option<(String, serde_json::Value)> { - let obj = params.as_object()?; - let session_id = obj.get("sessionId")?.as_str()?.to_string(); - let message = obj.get("message")?.clone(); - Some((session_id, message)) -} - -fn is_claude_api_retry_message(message: &serde_json::Value) -> bool { - let obj = match message.as_object() { - Some(obj) => obj, - None => return false, - }; - let message_type = obj.get("type").and_then(|v| v.as_str()); - let message_subtype = obj.get("subtype").and_then(|v| v.as_str()); - matches!(message_type, Some("system")) && matches!(message_subtype, Some("api_retry")) } -/// The JSON-RPC method claude-agent-acp uses to mirror raw SDK messages. Named -/// so `is_known_ext_method` and the mapper can't drift apart. -const CLAUDE_SDK_EXT_METHOD: &str = "_claude/sdkMessage"; - -fn map_claude_sdk_ext_notification(notification: &UntypedMessage) -> Option { - if notification.method() != CLAUDE_SDK_EXT_METHOD { - return None; - } - - let (session_id, message) = parse_claude_sdk_message_params(notification.params())?; - if !is_claude_api_retry_message(&message) { - return None; +/// Whether the historical `session/load` replay drops a grok ext notification: +/// every variant [`map_grok_ext_notification`] handles, so none of them reach +/// the live `SessionState` or the clients. +/// +/// Those notifications describe a PAST session. An alert (a dropped image) +/// would report something as happening now, for a session the user is merely +/// opening. A compaction card, completed or failed, would anchor into +/// `live_message` — the replay runs outside any turn, and no `TurnComplete` +/// follows to clear it — so every later snapshot would carry a historical card +/// as in-flight content. Nothing is lost by dropping them: grok's transcript, +/// compaction cards included, comes from its own session log +/// (`parsers::grok`), not from this replay. Reuses the mapper for the same +/// reason [`grok_ext_notification_is_turn_output`] does — the set cannot drift +/// away from what actually emits. +fn grok_ext_notification_skipped_on_replay(dispatch: &Dispatch, agent_type: AgentType) -> bool { + match dispatch { + Dispatch::Notification(notification) => { + map_grok_ext_notification(notification, agent_type).is_some() + } + _ => false, } - Some(AcpEvent::ClaudeSdkMessage { - session_id, - message, - }) -} - -/// The JSON-RPC methods grok uses for its private, namespaced session updates. -/// Both share the standard `session/update` envelope (`params.update. -/// sessionUpdate` + fields, verified live against grok 0.2.111) but carry -/// variants the typed ACP pipeline can't deserialize, so codeg drops them. -const GROK_EXT_UPDATE_METHODS: [&str; 2] = - ["_x.ai/session_notification", "_x.ai/session/update"]; - -/// A stable id for a synthetic event derived from a grok ext notification — -/// grok stamps `params._meta.eventId`; fall back to a fresh uuid. -fn grok_ext_event_id(params: &serde_json::Value) -> String { - params - .get("_meta") - .and_then(|m| m.get("eventId")) - .and_then(|v| v.as_str()) - .map(str::to_string) - .unwrap_or_else(|| format!("grok-ext-{}", uuid::Uuid::new_v4().simple())) } -/// Map grok's private ext notifications — context compaction and dropped -/// prompt images — into `AcpEvent`s. +/// The `Error` that accompanies a FAILED compaction card from +/// [`map_grok_ext_notification`] (see `auto_compact_failed` there), or `None`. /// -/// grok reports `/compact` (and auto-compaction) results, and the fate of an -/// image it refused to send, on -/// `_x.ai/session_notification` / `_x.ai/session/update` rather than as normal -/// `agent_message_chunk`s. Those methods never match the typed `session/update` -/// pipeline, so without this the whole turn is blank and `/compact` looks like -/// it failed. Only grok emits these, so gate on the agent. Turn-level failures -/// are intentionally NOT handled here — the `session/prompt` response path -/// (`turn_failure_error_event`) already surfaces those, and duplicating them -/// would double-report. (The one turn with no response, a turn Grok starts on -/// its own, is ended by the idle loop — see [`close_grok_agent_turn`].) -fn map_grok_ext_notification( - notification: &UntypedMessage, - agent_type: AgentType, -) -> Option { - if !matches!(agent_type, AgentType::Grok) { +/// 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 !GROK_EXT_UPDATE_METHODS.contains(¬ification.method()) { + }; + if status != "failed" { return None; } - let params = notification.params(); - let update = params.get("update")?; - match update.get("sessionUpdate").and_then(|v| v.as_str())? { - // grok always emits `completed` (even a no-op where before == after). - // Render the shared context-compaction card (recognized frontend-side by - // `_meta.contextCompaction`, same as codex) carrying the token delta. - "auto_compact_completed" => { - let mut meta = serde_json::Map::new(); - meta.insert( - "contextCompaction".to_string(), - serde_json::Value::Bool(true), - ); - if let Some(before) = update.get("tokens_before").and_then(|v| v.as_u64()) { - meta.insert("tokensBefore".to_string(), before.into()); + 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) } - if let Some(after) = update.get("tokens_after").and_then(|v| v.as_u64()) { - meta.insert("tokensAfter".to_string(), after.into()); + 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) } - Some(AcpEvent::ToolCall { - tool_call_id: grok_ext_event_id(params), - title: "Context compaction".to_string(), - kind: "other".to_string(), - status: "completed".to_string(), - content: None, - raw_input: None, - raw_output: None, - locations: None, - meta: Some(serde_json::Value::Object(meta)), - images: None, - }) - } - // A prompt image was accepted on the wire but dropped before the - // describe sidecar (too small, oversize, decode failure). Surface it - // so the user isn't left wondering why Grok "can't see" the shot. - "image_dropped" => { - // `notes` is grok's own user-facing sentence, one per dropped image - // ("Image 1 was dropped before send: too small (1×1); images must be - // at least 8×8 pixels."). It already names the subject, so it is - // shown verbatim — prefixing it would read "Image dropped: Image 1 - // was dropped before send: …". Only the shapeless fallbacks get a - // prefix, because on their own they say nothing about images. - let message = update - .get("notes") - .and_then(|v| v.as_array()) - .map(|notes| { - notes - .iter() - .filter_map(|n| n.as_str()) - .map(str::trim) - .filter(|n| !n.is_empty()) - .collect::>() - .join(" ") - }) - .filter(|s| !s.is_empty()) - .or_else(|| { - update - .get("reason") - .or_else(|| update.get("message")) - .and_then(|v| v.as_str()) - .map(str::trim) - // A blank reason would render as a bare "Image dropped: " - // — worse than the generic sentence below. - .filter(|d| !d.is_empty()) - .map(|d| format!("Image dropped: {d}")) - }) - .unwrap_or_else(|| "An image was dropped before send.".to_string()); - Some(AcpEvent::Error { + message => Ok(Handled::No { message, - agent_type: agent_type.to_string(), - code: Some(IMAGE_DROPPED_ERROR_CODE.to_string()), - details: None, - terminal: false, - }) + retry: false, + }), } - // Compaction itself blew up (e.g. the summarizer model call failed) while - // the turn still ended cleanly. It lands where every other compaction - // outcome does — the shared context-compaction card, here in its FAILED - // state with the reason as the card's hover text — rather than as a - // session error under the composer. That is also how a failed - // compaction reads on codex and claude (`session_compaction_event`), and - // the history parser synthesizes the same card from the log. The coded - // `Error` readers other than the app still need is emitted beside it by - // the caller (`compaction_failure_error`), keeping this mapper 1:1. - "auto_compact_failed" => Some(AcpEvent::ToolCall { - tool_call_id: grok_ext_event_id(params), - title: "Context compaction".to_string(), - kind: "other".to_string(), - status: "failed".to_string(), - content: None, - raw_input: None, - raw_output: None, - locations: None, - meta: Some(grok_failed_compaction_meta(update)), - images: None, - }), - _ => None, } -} -/// `_meta` for a failed grok auto-compaction: the versioned -/// `contextCompaction` payload (`{version: 1, error?}`), whose `error` the -/// compaction card shows as the reason. Shared by the live mapper and the -/// history parser so a reopened conversation renders the same card. -pub(crate) fn grok_failed_compaction_meta(update: &serde_json::Value) -> serde_json::Value { - let mut compaction = serde_json::Map::new(); - compaction.insert("version".to_string(), 1.into()); - if let Some(reason) = update - .get("reason") - .or_else(|| update.get("message")) - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|reason| !reason.is_empty()) - { - compaction.insert("error".to_string(), reason.into()); + fn describe_chain(&self) -> impl std::fmt::Debug { + "ClaimNullSessionIds" } - serde_json::json!({ "contextCompaction": compaction }) } -/// Map grok's sub-agent lifecycle notifications (`_x.ai/session/update` with -/// `sessionUpdate: subagent_spawned | subagent_progress | subagent_finished`) -/// onto live events for the launching `spawn_subagent` Agent card. STATEFUL — -/// kept out of the pure [`map_grok_ext_notification`] (which doubles as the -/// turn-output predicate and must stay re-invocable). -/// -/// Grok never forwards a child's chunks or tool calls over ACP (unlike -/// claude-agent-acp ≥0.63) — these three notifications are the ONLY live -/// signal a subagent emits, so: +/// 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) +} + +/// Claims Grok's model catalog broadcast, `_x.ai/models/update`, and folds it +/// into the connection's model picker (issue #876). /// -/// * `subagent_spawned` pairs `subagent_id` → the oldest unpaired spawn call -/// (FIFO; the notifications carry no `tool_call_id` — same pairing as the -/// history parser). -/// * `subagent_progress` becomes a meta-only `ToolCallUpdate` -/// (`meta.grokSubagentProgress`) so the running Agent card can show a live -/// "N tools · M turns · ctx%" line. Replacing the block's meta is safe for -/// this call: its classification rides `rawInput.subagent_type`, not meta. -/// GATED on `turn_active`: after `TurnComplete` the frontend diverts wire -/// tool updates out of the transcript anyway, while the backend -/// `SessionState` apply arm would lazily RECREATE `live_message` for the -/// update — resurrecting a ghost pending card into every later -/// snapshot/attach. An out-of-turn tick is therefore dropped whole (the -/// `subagent_finished` settle below is the out-of-turn-safe channel). -/// * `subagent_finished` for a spawn whose CALL already settled (= a -/// BACKGROUND child; the launch ack was its final wire output) settles via -/// the same `BackgroundActivity` channel Claude's async sub-agents use: the -/// frontend flips the launch card's `[[codeg-background-task]]` marker -/// in-memory (works out-of-turn too), raises the OS notification, and -/// mirrors `outstanding` for the idle-sweep exemption. A BLOCKING spawn is -/// skipped — its own completion frame delivers the output. -fn map_grok_subagent_notification( - notification: &UntypedMessage, +/// Grok fetches its catalog after startup, and again when its login changes — +/// after refreshing an expired token, for one — and announces every fetch on +/// this method, typically several identical frames within milliseconds. A +/// session established before the fetch landed was handed whatever catalog grok +/// had then, which can be only its bundled models: loaded on an expired token, +/// a session showed Grok 4.6 and 4.5 while the catalog that arrived 0.7s later +/// also had both 4.7 models. +/// +/// The broadcast names no session. The per-session router matches on +/// `params.sessionId`, so it never claimed this frame, nothing else did either, +/// and the runtime dropped it unhandled: the picker kept the handshake's list +/// for the life of the connection. It has to be claimed here in the builder +/// chain — a message without a `sessionId` is never parked for a router added +/// later. A connection drives one grok process and the catalog is that +/// process's, so the broadcast belongs to whatever session the connection holds +/// or is establishing; for the latter it waits in +/// `SessionState::grok_catalog_broadcast`. +/// +/// Handled inline on the dispatch loop rather than spawned: in arrival order, +/// each frame folds against the state the one before it left, which is what +/// makes a repeat a no-op. It takes nothing but the session state's lock, which +/// the permission handler on this same loop already takes inline +/// (`handle_permission_request`). +struct GrokModelCatalogBroadcasts { agent_type: AgentType, - turn_active: bool, - cb_state: &mut CodeBuddyLiveState, -) -> Vec { - map_grok_subagent_notification_inner(notification, agent_type, turn_active, cb_state) - .unwrap_or_default() + state: Arc>, + emitter: EventEmitter, } -/// The body of [`map_grok_subagent_notification`], written with `?` on the many -/// "this notification isn't one of ours / is malformed" checks. A spawn can -/// produce TWO events (the card's session stamp plus the background-activity -/// report), hence the list. -fn map_grok_subagent_notification_inner( - notification: &UntypedMessage, - agent_type: AgentType, - turn_active: bool, - cb_state: &mut CodeBuddyLiveState, -) -> Option> { - if !matches!(agent_type, AgentType::Grok) { - return None; +impl HandleDispatchFrom for GrokModelCatalogBroadcasts { + async fn handle_dispatch_from( + &mut self, + message: Dispatch, + _connection: ConnectionTo, + ) -> Result, agent_client_protocol::Error> { + match message { + Dispatch::Notification(notification) + if self.agent_type == AgentType::Grok + && notification.method() == GROK_MODELS_UPDATE_METHOD => + { + apply_grok_model_catalog(&self.state, &self.emitter, notification.params()).await; + Ok(Handled::Yes) + } + message => Ok(Handled::No { + message, + retry: false, + }), + } } - if !GROK_EXT_UPDATE_METHODS.contains(¬ification.method()) { - return None; + + fn describe_chain(&self) -> impl std::fmt::Debug { + "GrokModelCatalogBroadcasts" } - let params = notification.params(); - let update = params.get("update")?; - let subagent_id = update.get("subagent_id").and_then(|v| v.as_str())?; - // `outstanding` = paired subagents still running whose launch call already - // settled — i.e. background children codeg would otherwise sweep as idle. - let outstanding = |cb_state: &CodeBuddyLiveState| { - cb_state - .grok_subagent_to_call - .values() - .filter(|call_id| cb_state.grok_settled_spawn_ids.contains(*call_id)) - .count() as u32 +} + +/// Fold one `_x.ai/models/update` into the connection's state: always into the +/// session's model specs and the establishment slot, and into the picker on +/// screen if there is one — emitted only when that changed it, so a repeated +/// broadcast is a no-op. +async fn apply_grok_model_catalog( + state: &Arc>, + emitter: &EventEmitter, + params: &serde_json::Value, +) { + let Some(catalog) = parse_grok_model_catalog(params) else { + tracing::debug!("[ACP][grok] model catalog broadcast lists no usable model; ignored"); + return; }; - match update.get("sessionUpdate").and_then(|v| v.as_str())? { - "subagent_spawned" => { - // First pending entry whose captured launch `(description, - // subagent_type)` is consistent with the notification's own - // fields. ASYMMETRIC rule: a value captured from the launch input - // must be matched by an EQUAL value on the notification — a - // notification that omits the field does NOT wildcard past it - // (that shape would let a delayed, description-less prior-turn - // notification steal a described new-turn entry). Only a pending - // side that captured nothing (unparseable/absent input field — - // then there is nothing to validate against) is a wildcard. Real - // captures always carry both fields on both sides, so in-order - // streams match at the head (plain FIFO); the fail direction for - // an unmatched notification is pairing nothing (live progress/ - // settle degrade gracefully; history re-parse is unaffected). - let event_field = |key: &str| { - update - .get(key) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - }; - let matches = |pending: &Option, event: Option<&str>| match (pending, event) { - (Some(p), Some(e)) => p == e, - (Some(_), None) => false, - (None, _) => true, - }; - let idx = cb_state.grok_pending_spawn_ids.iter().position(|p| { - matches(&p.description, event_field("description")) - && matches(&p.subagent_type, event_field("subagent_type")) - })?; - let call_id = cb_state.grok_pending_spawn_ids.remove(idx)?.call_id; - cb_state - .grok_subagent_to_call - .insert(subagent_id.to_string(), call_id.clone()); - // The child's own session id — `child_session_id` when the wire - // carries it, else the subagent id (they are the same value in - // every capture). Remembered so later progress ticks can re-send it - // (`upsert_tool_call` replaces meta wholesale, so every tick must - // carry it again). - // - // Gated by the same `is_safe_subagent_id` the history parser applies - // to this field: the value is handed to the frontend, which asks - // `get_conversation` to resolve a session directory by it, so both - // paths have to reject a traversal-shaped id — not just the one that - // reads from disk itself. Failing the gate simply leaves the card - // without a session to open. - let child_session_id = update - .get("child_session_id") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .unwrap_or(subagent_id); - let child_session_id = crate::parsers::is_safe_subagent_id(child_session_id) - .then(|| child_session_id.to_string()); - if let Some(child) = &child_session_id { - cb_state - .grok_call_child_session - .insert(call_id.clone(), child.clone()); - } + let models = catalog.models.len(); + let refreshed = emit_with_state_built(state, emitter, move |s| { + let specs = s.grok_model_specs.get_or_insert_with(HashMap::new); + fold_grok_catalog_specs(specs, &catalog.specs); + let picker = s.config_options.as_ref().and_then(|shown| { + let mut next = shown.clone(); + fold_grok_catalog_into_picker(&mut next, &catalog, specs); + (next != *shown).then_some(next) + }); + s.grok_catalog_broadcast = Some(catalog); + picker.map(|config_options| AcpEvent::SessionConfigOptions { config_options }) + }) + .await; + if refreshed { + tracing::info!( + models, + "[ACP][grok] model catalog broadcast refreshed the model picker" + ); + } else { + tracing::debug!( + models, + "[ACP][grok] model catalog broadcast left the model picker as it was" + ); + } +} - let mut events = Vec::new(); - // Stamp the child's session onto the launching Agent card so it can - // offer to open the child's transcript WHILE it runs — for a - // blocking spawn that is the only live window into the child, since - // its call produces no output until the very end. Gated exactly like - // a progress tick: out of turn, the `SessionState` apply arm would - // lazily recreate `live_message` and strand a ghost card in every - // later snapshot. - if turn_active && cb_state.grok_progress_eligible.contains(&call_id) { - events.push(AcpEvent::ToolCallUpdate { - tool_call_id: call_id.clone(), - title: None, - status: None, - content: None, - raw_input: None, - raw_output: None, - raw_output_append: None, - locations: None, - meta: Some(grok_subagent_meta( - subagent_id, - child_session_id.as_deref(), - None, - )), - images: None, - }); - } - // A background launch's call settles before/around the pairing; - // surface the outstanding count so the connection is exempt from - // idle sweeps while the child works. Nothing to add for a - // blocking spawn (its turn is open — nothing can sweep it). - if cb_state.grok_settled_spawn_ids.contains(&call_id) { - let session_id = params.get("sessionId").and_then(|v| v.as_str())?; - events.push(AcpEvent::BackgroundActivity { - session_id: session_id.to_string(), - turns: Vec::new(), - outstanding: outstanding(cb_state), - settled: Vec::new(), - watermark: 0, - }); - } - Some(events) - } - "subagent_progress" => { - if !turn_active { - return None; - } - let call_id = cb_state.grok_subagent_to_call.get(subagent_id)?.clone(); - // Launch-turn-specific gate on top of Prompting: a tick for a - // PRIOR turn's background child must not be appended into the - // CURRENT turn's live message (see `grok_progress_eligible`). - if !cb_state.grok_progress_eligible.contains(&call_id) { - return None; - } - let mut progress = serde_json::Map::new(); - for (wire, out) in [ - ("duration_ms", "durationMs"), - ("turn_count", "turnCount"), - ("tool_call_count", "toolCallCount"), - ("context_usage_pct", "contextUsagePct"), - ("error_count", "errorCount"), - ] { - if let Some(v) = update.get(wire).filter(|v| v.is_number()) { - progress.insert(out.to_string(), v.clone()); - } - } - if progress.is_empty() { +/// `_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, + /// Everything else a `/name` runs: skills (`serverName: "skill:…"`), file + /// prompts from `.kiro/prompts` (`"local"`), and MCP prompts (the server's + /// name), each `{name, description, arguments[], serverName}`. + #[serde(default)] + prompts: 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. So is any `$`-named entry, which the +/// composer can only send as `$name`. 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(); + let mut available: Vec = 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); + // Every built-in's name is reserved, offered here or not: a prompt + // by that name never runs, because Kiro runs the command. + if name.is_empty() || !seen.insert(name.to_string()) || local || TUI_ONLY.contains(&name) { return None; } - // Carry the session stamp along: `upsert_tool_call` replaces the - // block's meta wholesale, so a progress-only payload would drop the - // "open the child's session" affordance mid-run. - let child_session_id = cb_state.grok_call_child_session.get(&call_id).cloned(); - Some(vec![AcpEvent::ToolCallUpdate { - tool_call_id: call_id, - title: None, - status: None, - content: None, - raw_input: None, - raw_output: None, - raw_output_append: None, - locations: None, - meta: Some(grok_subagent_meta( - subagent_id, - child_session_id.as_deref(), - Some(serde_json::Value::Object(progress)), - )), - images: None, - }]) - } - "subagent_finished" => { - let call_id = cb_state.grok_subagent_to_call.remove(subagent_id)?; - cb_state.grok_call_child_session.remove(&call_id); - if !cb_state.grok_settled_spawn_ids.contains(&call_id) { - // Blocking spawn: the child's output arrives on the call's own - // completion frame; the settle channel would double-render it. + 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(); + // Skills, `.kiro/prompts` files and MCP prompts. Kiro resolves a prompt + // text of `/name args` against the same list, local files first, then + // skills, then MCP (verified against kiro-cli 2.24.1: each kind answers + // when sent as plain prompt text), so they are offered as commands. A + // name a built-in command already took is skipped, as Kiro itself would + // run the command. + available.extend(notif.prompts.iter().filter_map(|prompt| { + let name = prompt.get("name")?.as_str()?.trim(); + let name = name.strip_prefix('/').unwrap_or(name).trim(); + if name.is_empty() || !seen.insert(name.to_string()) { + return None; + } + let description = prompt + .get("description") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|d| !d.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| kiro_prompt_source_label(prompt)); + Some(AvailableCommandInfo { + name: name.to_string(), + description, + input_hint: kiro_prompt_input_hint(prompt), + }) + })); + // The composer sends a command named `$x` as `$x`, the token codex-acp + // gives its skills (`commandInvocationToken` in invocation-reference.ts), + // and Kiro runs only `/x`: picked from the menu, such an entry never runs. + available.retain(|command| !command.name.starts_with('$')); + available +} + +/// What a prompt with no description of its own says in the slash menu: where +/// it comes from. +fn kiro_prompt_source_label(prompt: &serde_json::Value) -> String { + match prompt.get("serverName").and_then(serde_json::Value::as_str) { + Some(server) if server.starts_with("skill:") => "Skill".to_string(), + Some("local") | None => "Prompt".to_string(), + Some(server) => format!("MCP prompt ({server})"), + } +} + +/// The argument hint for a prompt: the names of its declared arguments. A skill +/// or file prompt declares only Kiro's catch-all `args` ("All arguments"), +/// which says nothing a user doesn't already know, so it gets none. +fn kiro_prompt_input_hint(prompt: &serde_json::Value) -> Option { + let names: Vec = prompt + .get("arguments")? + .as_array()? + .iter() + .filter_map(|arg| { + let name = arg.get("name")?.as_str()?.trim(); + if name.is_empty() { return None; } - let session_id = params.get("sessionId").and_then(|v| v.as_str())?; - let status = update - .get("status") - .and_then(|v| v.as_str()) - .unwrap_or("completed"); - let result = update - .get("output") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(|s| crate::parsers::truncate_str(s, crate::parsers::claude::BACKGROUND_RESULT_MAX_CHARS)); - Some(vec![AcpEvent::BackgroundActivity { - session_id: session_id.to_string(), - turns: Vec::new(), - outstanding: outstanding(cb_state), - settled: vec![crate::acp::types::BackgroundSettledInfo { - task_id: subagent_id.to_string(), - status: status.to_string(), - summary: None, - tool_use_id: Some(call_id), - result, - }], - watermark: 0, - }]) + let required = arg.get("required").and_then(serde_json::Value::as_bool) == Some(true); + Some(if required { format!("<{name}>") } else { format!("[{name}]") }) + }) + .collect(); + match names.as_slice() { + [] => None, + [only] if only == "[args]" => None, + _ => Some(names.join(" ")), + } +} + +#[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")); + } + + /// `prompts` as kiro-cli 2.24.1 sends it: an MCP prompt, a `.kiro/prompts` + /// file and a skill, each answered by Kiro when sent as `/name args`. + #[test] + fn kiro_skills_and_prompts_join_the_slash_menu() { + let notif: KiroCommandsAvailableNotification = serde_json::from_value(serde_json::json!({ + "sessionId": "s1", + "commands": [{"name": "/compact", "description": "Compact conversation history"}], + "prompts": [ + {"name": "mcp_probe", "description": "MCP probe prompt", + "arguments": [{"name": "topic", "description": null, "required": false}], + "serverName": "stub"}, + {"name": "probe-prompt", "description": null, + "arguments": [{"name": "args", "description": "All arguments", "required": false}], + "serverName": "local"}, + {"name": "verify-wechat-links", "description": "帮我上班作业手册", + "arguments": [{"name": "args", "description": "All arguments", "required": false}], + "serverName": "skill:config"}, + {"name": "release", "description": "Cut a release", + "arguments": [{"name": "version", "required": true}, {"name": "notes"}], + "serverName": "deploy"}, + {"name": "compact", "description": "shadowed by the built-in", "serverName": "local"}, + {"name": "verify-wechat-links", "description": "duplicate", "serverName": "skill:config"}, + {"description": "nameless", "serverName": "local"} + ] + })) + .unwrap(); + let commands = kiro_available_commands(¬if); + let rows: Vec<_> = commands + .iter() + .map(|c| (c.name.as_str(), c.description.as_str(), c.input_hint.as_deref())) + .collect(); + assert_eq!( + rows, + [ + ("compact", "Compact conversation history", None), + ("mcp_probe", "MCP probe prompt", Some("[topic]")), + ("probe-prompt", "Prompt", None), + ("verify-wechat-links", "帮我上班作业手册", None), + ("release", "Cut a release", Some(" [notes]")), + ] + ); + } + + /// A prompt that shares its name with a built-in Kiro keeps out of the + /// menu (the TUI-only and client-local ones included), so picking it can + /// never run the command instead. + #[test] + fn a_prompt_named_like_a_hidden_builtin_stays_out_of_the_slash_menu() { + let notif: KiroCommandsAvailableNotification = serde_json::from_value(serde_json::json!({ + "sessionId": "s1", + "commands": [ + {"name": "/compact", "description": "Compact conversation history"}, + {"name": "/quit", "description": "Quit the application"}, + {"name": "/clear", "description": "Clear the screen", "meta": {"local": true}} + ], + "prompts": [ + {"name": "quit", "description": "A file prompt", "serverName": "local"}, + {"name": "/clear", "description": "A skill", "serverName": "skill:config"}, + {"name": "review", "description": "Review the diff", "serverName": "local"} + ] + })) + .unwrap(); + let names: Vec<_> = kiro_available_commands(¬if) + .into_iter() + .map(|c| c.name) + .collect(); + assert_eq!(names, ["compact", "review"]); + } + + /// The composer sends a `$`-named command as `$name` (codex-acp's skill + /// token), which Kiro reads as plain text, so the entry could never run. + #[test] + fn a_dollar_named_prompt_stays_out_of_the_slash_menu() { + let notif: KiroCommandsAvailableNotification = serde_json::from_value(serde_json::json!({ + "sessionId": "s1", + "commands": [ + {"name": "/compact", "description": "Compact conversation history"} + ], + "prompts": [ + {"name": "$report", "description": "Weekly report", "serverName": "reports"}, + {"name": "review", "description": "Review the diff", "serverName": "local"} + ] + })) + .unwrap(); + let names: Vec<_> = kiro_available_commands(¬if) + .into_iter() + .map(|c| c.name) + .collect(); + assert_eq!(names, ["compact", "review"]); + } + + #[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"); } - _ => None, + 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() + } + + // 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.") + ); } -} -/// The `meta` payload a Grok sub-agent's launching Agent card carries: -/// `grokSubagentSession` (ids — lets the card open the child's transcript) and, -/// while it runs, `grokSubagentProgress` (the live "N tools · M turns · ctx%" -/// line). Built in one place because `upsert_tool_call` REPLACES a block's -/// meta: every emission has to carry the whole picture, not just its own half. -fn grok_subagent_meta( - subagent_id: &str, - child_session_id: Option<&str>, - progress: Option, -) -> serde_json::Value { - let mut session = serde_json::Map::new(); - session.insert("subagentId".to_string(), subagent_id.into()); - if let Some(child) = child_session_id { - session.insert("childSessionId".to_string(), child.into()); + #[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() {}"}) + ); } - let mut meta = serde_json::Map::new(); - meta.insert( - "grokSubagentSession".to_string(), - serde_json::Value::Object(session), - ); - if let Some(progress) = progress { - meta.insert("grokSubagentProgress".to_string(), progress); + + #[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")); } - serde_json::Value::Object(meta) -} -/// Whether a dispatch is a grok ext notification that -/// `map_grok_ext_notification` renders as visible turn output (a compaction -/// card, a compaction error, or a dropped-image error). The active-turn loop -/// consults this BEFORE the typed -/// pipeline to mark the turn as non-empty: a `/compact` turn emits only these -/// ext notifications and no standard `agent_message_chunk`, so without this its -/// `end_turn` is reclassified as `"empty"` and re-surfaced as a spurious error — -/// the exact symptom this change removes. Reuses `map_grok_ext_notification` so -/// the handled-variant set can never drift from what actually emits. A turn -/// whose only output was a dropped image therefore reports THAT, rather than -/// the generic empty-turn failure it used to. -fn grok_ext_notification_is_turn_output(dispatch: &Dispatch, agent_type: AgentType) -> bool { - match dispatch { - Dispatch::Notification(notification) => { - map_grok_ext_notification(notification, agent_type).is_some() - } - _ => false, + #[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}"#)); } -} -/// Whether the historical `session/load` replay drops a grok ext notification: -/// every variant [`map_grok_ext_notification`] handles, so none of them reach -/// the live `SessionState` or the clients. -/// -/// Those notifications describe a PAST session. An alert (a dropped image) -/// would report something as happening now, for a session the user is merely -/// opening. A compaction card, completed or failed, would anchor into -/// `live_message` — the replay runs outside any turn, and no `TurnComplete` -/// follows to clear it — so every later snapshot would carry a historical card -/// as in-flight content. Nothing is lost by dropping them: grok's transcript, -/// compaction cards included, comes from its own session log -/// (`parsers::grok`), not from this replay. Reuses the mapper for the same -/// reason [`grok_ext_notification_is_turn_output`] does — the set cannot drift -/// away from what actually emits. -fn grok_ext_notification_skipped_on_replay(dispatch: &Dispatch, agent_type: AgentType) -> bool { - match dispatch { - Dispatch::Notification(notification) => { - map_grok_ext_notification(notification, agent_type).is_some() - } - _ => false, + #[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); } -} -/// 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; + #[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()); } - 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; + /// The two requests a Kiro selector goes out on, as the agent side of a + /// test pipe receives them. + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonRpcRequest)] + #[request(method = "session/set_model", response = serde_json::Value)] + #[serde(transparent)] + struct FakeKiroSetModel(serde_json::Value); -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, - }), - } + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonRpcRequest)] + #[request(method = "_kiro.dev/commands/execute", response = serde_json::Value)] + #[serde(transparent)] + struct FakeKiroExecute(serde_json::Value); + + /// Replay `prefs` onto a session showing `opts` over a pipe to a fake Kiro + /// that accepts everything, and return what it was sent (in order) + /// together with the selectors afterwards. + async fn replay_against_fake_kiro( + state: &Arc>, + opts: Vec, + prefs: BTreeMap, + ) -> (Vec, Vec) { + use agent_client_protocol::Channel; + + let sent: Arc>> = Arc::default(); + let (client_end, agent_end) = Channel::duplex(); + let agent = tokio::spawn({ + let model_log = Arc::clone(&sent); + let command_log = Arc::clone(&sent); + Agent + .builder() + .on_receive_request( + async move |req: FakeKiroSetModel, + responder: Responder, + _cx: ConnectionTo| { + model_log.lock().unwrap().push(req.0); + responder.respond(serde_json::json!({})) + }, + on_receive_request!(), + ) + .on_receive_request( + async move |req: FakeKiroExecute, + responder: Responder, + _cx: ConnectionTo| { + command_log.lock().unwrap().push(req.0); + responder.respond(serde_json::json!({"success": true, "message": "ok"})) + }, + on_receive_request!(), + ) + .connect_with(agent_end, async |_cx: ConnectionTo| { + std::future::pending::>().await + }) + }); + let state = Arc::clone(state); + let opts = Client + .builder() + .connect_with(client_end, async move |cx: ConnectionTo| { + let mut opts = opts; + apply_kiro_preferred_options( + &cx, + &SessionId::new("s1"), + &state, + &EventEmitter::Noop, + &mut opts, + &prefs, + None, + ) + .await; + Ok(opts) + }) + .await + .expect("client connection"); + agent.abort(); + let sent = sent.lock().unwrap().clone(); + (sent, opts) } - fn describe_chain(&self) -> impl std::fmt::Debug { - "ClaimNullSessionIds" + fn kiro_state() -> Arc> { + Arc::new(RwLock::new(SessionState::new( + "conn-kiro".to_string(), + AgentType::Kiro, + None, + "win".to_string(), + None, + ))) } -} -/// 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) -} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonRpcRequest)] + #[request(method = "session/set_mode", response = serde_json::Value)] + #[serde(transparent)] + struct FakeKiroSetMode(serde_json::Value); -/// Claims Grok's model catalog broadcast, `_x.ai/models/update`, and folds it -/// into the connection's model picker (issue #876). -/// -/// Grok fetches its catalog after startup, and again when its login changes — -/// after refreshing an expired token, for one — and announces every fetch on -/// this method, typically several identical frames within milliseconds. A -/// session established before the fetch landed was handed whatever catalog grok -/// had then, which can be only its bundled models: loaded on an expired token, -/// a session showed Grok 4.6 and 4.5 while the catalog that arrived 0.7s later -/// also had both 4.7 models. -/// -/// The broadcast names no session. The per-session router matches on -/// `params.sessionId`, so it never claimed this frame, nothing else did either, -/// and the runtime dropped it unhandled: the picker kept the handshake's list -/// for the life of the connection. It has to be claimed here in the builder -/// chain — a message without a `sessionId` is never parked for a router added -/// later. A connection drives one grok process and the catalog is that -/// process's, so the broadcast belongs to whatever session the connection holds -/// or is establishing; for the latter it waits in -/// `SessionState::grok_catalog_broadcast`. -/// -/// Handled inline on the dispatch loop rather than spawned: in arrival order, -/// each frame folds against the state the one before it left, which is what -/// makes a repeat a no-op. It takes nothing but the session state's lock, which -/// the permission handler on this same loop already takes inline -/// (`handle_permission_request`). -struct GrokModelCatalogBroadcasts { - agent_type: AgentType, - state: Arc>, - emitter: EventEmitter, -} + /// The options probe reports, for an option it applied, what the agent runs + /// when that option is left unset (`SessionState::agent_chosen_config_values`). + /// Kiro's agent and model selectors are built from its handshake rather + /// than listed as config options, so its establishment records them itself: + /// as Kiro opened the session, before the saved picks replace them. + #[tokio::test] + async fn kiro_establishment_records_the_agents_own_picks() { + use agent_client_protocol::Channel; -impl HandleDispatchFrom for GrokModelCatalogBroadcasts { - async fn handle_dispatch_from( - &mut self, - message: Dispatch, - _connection: ConnectionTo, - ) -> Result, agent_client_protocol::Error> { - match message { - Dispatch::Notification(notification) - if self.agent_type == AgentType::Grok - && notification.method() == GROK_MODELS_UPDATE_METHOD => - { - apply_grok_model_catalog(&self.state, &self.emitter, notification.params()).await; - Ok(Handled::Yes) - } - message => Ok(Handled::No { - message, - retry: false, - }), - } + let (client_end, agent_end) = Channel::duplex(); + let agent = tokio::spawn( + Agent + .builder() + .on_receive_request( + async |_req: FakeKiroSetModel, + responder: Responder, + _cx: ConnectionTo| { + responder.respond(serde_json::json!({})) + }, + on_receive_request!(), + ) + .on_receive_request( + async |_req: FakeKiroSetMode, + responder: Responder, + _cx: ConnectionTo| { + responder.respond(serde_json::json!({})) + }, + on_receive_request!(), + ) + .connect_with(agent_end, async |_cx: ConnectionTo| { + std::future::pending::>().await + }), + ); + let state = kiro_state(); + let establishment_state = Arc::clone(&state); + Client + .builder() + .connect_with(client_end, async move |cx: ConnectionTo| { + let modes: SessionModeState = serde_json::from_value(serde_json::json!({ + "currentModeId": "kiro_default", + "availableModes": [ + {"id": "kiro_default", "name": "kiro_default"}, + {"id": "kiro_planner", "name": "kiro_planner"} + ] + })) + .expect("modes"); + let response = NewSessionResponse::new(SessionId::new("s1")).modes(modes); + let mut session = AgentSession::attach(&cx, response)?; + let models = kiro_models(); + let prefs = BTreeMap::from([ + ("model".to_string(), "claude-sonnet-5".to_string()), + ("mode".to_string(), "kiro_planner".to_string()), + ]); + apply_and_emit_session_config_options( + &cx, + &mut session, + &establishment_state, + &EventEmitter::Noop, + AgentType::Kiro, + None, + None, + Some(&models), + None, + &prefs, + Vec::new(), + ) + .await; + Ok(()) + }) + .await + .expect("the establishment runs"); + agent.abort(); + + let state = state.read().await; + assert_eq!( + state.agent_chosen_config_values, + BTreeMap::from([ + ("mode".to_string(), "kiro_default".to_string()), + ("model".to_string(), "claude-opus-5.5".to_string()), + ]), + "Kiro's own picks, from before the replay" + ); + // …while the session itself runs the saved picks. + let current = + current_config_option_values(state.config_options.as_deref().unwrap_or_default()); + assert_eq!( + current.get("model").map(String::as_str), + Some("claude-sonnet-5") + ); + assert_eq!( + current.get("mode").map(String::as_str), + Some("kiro_planner") + ); } - fn describe_chain(&self) -> impl std::fmt::Debug { - "GrokModelCatalogBroadcasts" + // The session opens on Kiro's default `auto`, whose metadata (no effort, + // no thinking switch) lands before the saved model is restored. That state + // describes `auto`, not the restored model, so it must not veto the saved + // thinking and effort — no later metadata would ever re-apply them. + #[tokio::test] + async fn a_restored_model_gets_its_saved_reasoning_despite_the_old_models_state() { + let state = kiro_state(); + state.write().await.kiro_reasoning = Some(KiroReasoning::default()); + let mut models = kiro_models(); + models["currentModelId"] = serde_json::json!("auto"); + let opts = vec![synthesize_kiro_model_option(Some(&models)).unwrap()]; + let prefs = BTreeMap::from([ + ("model".to_string(), "claude-opus-5.5".to_string()), + ("thinking".to_string(), "on".to_string()), + ("reasoning_effort".to_string(), "high".to_string()), + ]); + + let (sent, opts) = replay_against_fake_kiro(&state, opts, prefs).await; + + assert_eq!( + sent, + [ + serde_json::json!({"sessionId": "s1", "modelId": "claude-opus-5.5"}), + serde_json::json!({"sessionId": "s1", "command": { + "command": "reasoning", "args": {"thinkingEnabled": true} + }}), + serde_json::json!({"sessionId": "s1", "command": { + "command": "effort", "args": {"level": "high"} + }}), + ] + ); + let SessionConfigKindInfo::Select(sel) = &opts[0].kind else { + panic!("select expected"); + }; + assert_eq!(sel.current_value, "claude-opus-5.5"); } -} -/// Fold one `_x.ai/models/update` into the connection's state: always into the -/// session's model specs and the establishment slot, and into the picker on -/// screen if there is one — emitted only when that changed it, so a repeated -/// broadcast is a no-op. -async fn apply_grok_model_catalog( - state: &Arc>, - emitter: &EventEmitter, - params: &serde_json::Value, -) { - let Some(catalog) = parse_grok_model_catalog(params) else { - tracing::debug!("[ACP][grok] model catalog broadcast lists no usable model; ignored"); - return; - }; - let models = catalog.models.len(); - let refreshed = emit_with_state_built(state, emitter, move |s| { - let specs = s.grok_model_specs.get_or_insert_with(HashMap::new); - fold_grok_catalog_specs(specs, &catalog.specs); - let picker = s.config_options.as_ref().and_then(|shown| { - let mut next = shown.clone(); - fold_grok_catalog_into_picker(&mut next, &catalog, specs); - (next != *shown).then_some(next) + // Without a model switch the known state still rules out what the model + // cannot take, or already runs at. + #[tokio::test] + async fn without_a_model_switch_the_known_reasoning_still_rules_values_out() { + let state = kiro_state(); + state.write().await.kiro_reasoning = Some(KiroReasoning { + levels: vec!["low".into(), "high".into()], + current: Some("high".into()), + thinking_toggleable: false, + thinking: None, }); - s.grok_catalog_broadcast = Some(catalog); - picker.map(|config_options| AcpEvent::SessionConfigOptions { config_options }) - }) - .await; - if refreshed { - tracing::info!( - models, - "[ACP][grok] model catalog broadcast refreshed the model picker" - ); - } else { - tracing::debug!( - models, - "[ACP][grok] model catalog broadcast left the model picker as it was" - ); + let opts = vec![synthesize_kiro_model_option(Some(&kiro_models())).unwrap()]; + let prefs = BTreeMap::from([ + ("model".to_string(), "claude-opus-5.5".to_string()), + ("thinking".to_string(), "on".to_string()), + ("reasoning_effort".to_string(), "high".to_string()), + ]); + + let (sent, _) = replay_against_fake_kiro(&state, opts, prefs).await; + + assert!(sent.is_empty(), "{sent:?}"); } -} -/// `_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, + // The set path and the `_kiro.dev/metadata` handler both rewrite the + // emitted selectors from a copy. A copy taken before the other writer's + // emit must not be emitted after it: here the metadata handler withdraws + // the effort selector between the model pick's copy and its emit, and the + // pick has to land on the list without it. + #[tokio::test] + async fn a_selector_edit_is_redone_on_a_list_another_writer_emitted_first() { + let state = kiro_state(); + let reasoning = KiroReasoning { + levels: vec!["low".into(), "high".into()], + current: Some("high".into()), + ..KiroReasoning::default() + }; + let mut opts = vec![synthesize_kiro_model_option(Some(&kiro_models())).unwrap()]; + place_kiro_reasoning_selectors(&mut opts, &reasoning); + state.write().await.kiro_reasoning = Some(reasoning); + emit_with_state( + &state, + &EventEmitter::Noop, + AcpEvent::SessionConfigOptions { + config_options: opts, + }, + ) + .await; + + let mut edits = 0; + edit_emitted_options(&state, &EventEmitter::Noop, |opts, _| { + edits += 1; + if edits == 1 { + // The metadata for `auto` arrives mid-edit: stored and + // emitted without an effort selector. + let mut other = state.try_write().expect("no lock is held while editing"); + other.kiro_reasoning = Some(KiroReasoning::default()); + let mut without_effort = other.config_options.clone().unwrap(); + without_effort.retain(|o| o.id != KIRO_EFFORT_OPTION_ID); + other.config_options = Some(without_effort); + } + set_select_current(opts, KIRO_MODEL_OPTION_ID, "auto"); + true + }) + .await; + + assert_eq!(edits, 2, "the stale copy is recomputed, not emitted"); + let opts = state.read().await.config_options.clone().unwrap(); + let ids: Vec<_> = opts.iter().map(|o| o.id.as_str()).collect(); + assert_eq!(ids, ["model"]); + let SessionConfigKindInfo::Select(sel) = &opts[0].kind else { + panic!("select expected"); + }; + assert_eq!(sel.current_value, "auto"); + } } /// Claim `_auth/status_update` and drop it, loudly enough to be greppable. @@ -17699,6 +19804,13 @@ async fn emit_conversation_update( return; } }; + 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. @@ -17759,8 +19871,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) @@ -17798,8 +19913,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). @@ -17829,6 +19949,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 if let Some(answers) = qoder_ask_live_output(qoder_ask.as_ref(), None, &tc.raw_output, content.as_deref()) { @@ -17890,6 +20012,7 @@ async fn emit_conversation_update( tc.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); // Later frames hand the frontend the MERGED `_meta`, which it takes // whole — so codeg's own stamps must be part of the record, or the // first update carrying any `_meta` would wipe them. @@ -18026,6 +20149,13 @@ async fn emit_conversation_update( } CodexSubagentActivity::Other => return, }; + 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 @@ -18101,10 +20231,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) @@ -18158,7 +20294,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`. @@ -18216,6 +20354,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 if let Some(answers) = qoder_ask_live_output( cb_state.qoder_ask_questions.get(&tool_call_id), qoder_ask_reply.as_ref(), @@ -18284,6 +20425,7 @@ async fn emit_conversation_update( ) { cb_state.hosted_terminal_calls.remove(&tool_call_id); cb_state.qoder_ask_questions.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 ef2d8800d7..bb92294f88 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 + // `m20261015_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 1be8ae5e00..fb0f270bb9 100644 --- a/src-tauri/src/acp/delegation/companion.rs +++ b/src-tauri/src/acp/delegation/companion.rs @@ -4170,7 +4170,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")); @@ -4180,6 +4180,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 @@ -4219,11 +4220,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() @@ -4258,13 +4259,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 @@ -4286,9 +4287,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 7ca131a2a7..c0570255db 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 8e534126bb..8cc999dda4 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(), @@ -2193,7 +2203,7 @@ mod tests { type Resolver = fn(&AgentEnv) -> PathBuf; // `(agent, resolver its history parser is built from, slot index, // where under that slot's root the resolver points)`. - let cases: [(AgentType, Resolver, usize, &[&str]); 16] = [ + let cases: [(AgentType, Resolver, usize, &[&str]); 17] = [ ( AgentType::Grok, parsers::grok::resolve_grok_home_dir_for_env, @@ -2212,6 +2222,12 @@ mod tests { 0, &["antigravity-acp", "conversations"], ), + ( + AgentType::Kiro, + parsers::kiro::resolve_kiro_home_dir_for_env, + 0, + &[], + ), ( AgentType::ClaudeCode, parsers::claude::resolve_claude_config_dir_for_env, diff --git a/src-tauri/src/acp/latest_release.rs b/src-tauri/src/acp/latest_release.rs index f5d7e706b7..81a351c1be 100644 --- a/src-tauri/src/acp/latest_release.rs +++ b/src-tauri/src/acp/latest_release.rs @@ -18,6 +18,8 @@ //! that no longer fits the pinned template (upstream renamed its archives) //! offers nothing, rather than an install that would 404. //! - uvx: nothing, since Custom install does not support it. +//! - system (the user's own install, e.g. Kiro CLI): nothing, since its own +//! updater keeps it current. //! //! An offered version is always one Custom install accepts, and installing it //! goes through exactly that path. @@ -106,6 +108,10 @@ fn lookup_for(meta: &AcpAgentMeta, custom_source: Option) -> pinned_url: p.url, }), AgentDistribution::Uvx { .. } => None, + // The user's own install, kept current by its vendor's updater (the + // Upgrade action); codeg pins no version of it. Already ruled out by + // `supports_custom_version` above. + AgentDistribution::System { .. } => None, } } @@ -595,5 +601,8 @@ mod tests { ), ); assert_eq!(lookup_for(&opaque, None), None); + // A system-installed agent updates itself; codeg has no pin for it. + let kiro = registry::get_agent_meta(AgentType::Kiro); + assert_eq!(lookup_for(&kiro, None), None); } } diff --git a/src-tauri/src/acp/preflight.rs b/src-tauri/src/acp/preflight.rs index 6f928e76fb..b0c0c60342 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 @@ -447,6 +454,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 { @@ -833,3 +876,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 8060f45753..145cda0d7c 100644 --- a/src-tauri/src/acp/registry.rs +++ b/src-tauri/src/acp/registry.rs @@ -56,6 +56,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)] @@ -146,6 +169,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, } } @@ -175,7 +200,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 @@ -200,6 +225,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"))] { @@ -227,7 +256,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![ @@ -246,10 +275,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(); @@ -274,6 +304,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, } @@ -296,6 +327,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. @@ -483,9 +515,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, @@ -4485,6 +4517,58 @@ 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 the + // pre-config-options `models`, but no `configOptions`; reasoning + // effort and thinking arrive on `_kiro.dev/metadata`. The composer + // selectors are therefore built per agent in `acp::connection` + // (see `synthesize_kiro_model_option`). + // + // 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"), @@ -4718,6 +4802,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/remote_registry.rs b/src-tauri/src/acp/remote_registry.rs index d23ec51c67..1fd62db543 100644 --- a/src-tauri/src/acp/remote_registry.rs +++ b/src-tauri/src/acp/remote_registry.rs @@ -263,12 +263,14 @@ pub async fn fetch_catalog( /// than codeg's own. Without this the picker would offer a second, redundant /// integration of an agent the user already has — verified against the live /// registry, where Kimi is published as `kimi` while codeg's built-in registry -/// id is `kimi-code`, and Qoder as `qoder` against codeg's `qoder-cli`. +/// id is `kimi-code`, and Qoder as `qoder` against codeg's `qoder-cli`. Kiro is +/// not in the registry yet, but its vendor's pending entry +/// (agentclientprotocol/registry#561) is `kiro`, against codeg's `kiro-cli`. /// /// Missing an alias is not merely redundant, it is a DEAD entry: the picker /// lists the agent as addable, and the add then fails validation because the /// id collides with a built-in wire name (`is_valid_custom_agent_id`). -const BUILTIN_REGISTRY_ALIASES: &[&str] = &["kimi", "qoder"]; +const BUILTIN_REGISTRY_ALIASES: &[&str] = &["kimi", "qoder", "kiro"]; /// Whether an id is one of codeg's hand-written built-in agents. Deliberately /// does NOT consult the custom registry (unlike `registry::from_registry_id`, @@ -483,6 +485,10 @@ mod tests { // duplicate — see the assertion below. assert!(is_builtin_registry_id("qoder")); assert!(is_builtin_registry_id("qoder-cli")); + // And for Kiro (`kiro` in its vendor's pending registry entry, + // `kiro-cli` here). + assert!(is_builtin_registry_id("kiro")); + assert!(is_builtin_registry_id("kiro-cli")); // Antigravity needs NO alias: codeg's registry id is byte-identical // to the one the ACP registry publishes, so the picker resolves it // through the built-in table and never offers a duplicate entry. @@ -491,19 +497,21 @@ mod tests { assert!(!is_builtin_registry_id("qwen-code")); } - // Why a missing alias is a BUG and not just noise. The two entries in the - // table fail differently, and Qoder is the worse of the two: + // Why a missing alias is a BUG and not just noise. The entries in the + // table fail differently, and Qoder and Kiro are the worse kind: // // * `kimi` IS a legal custom-agent id (codeg's wire name is `kimi_code`), // so without the alias the picker would let the user add a SECOND, // redundant Kimi alongside the built-in. - // * `qoder` is NOT (it is codeg's wire name, blocked outright), so without - // the alias the picker lists an entry whose "Add" button can only ever - // error — a dead end with no way for the user to understand it. + // * `qoder` and `kiro` are NOT (they are codeg's wire names, blocked + // outright), so without the alias the picker lists an entry whose "Add" + // button can only ever error — a dead end with no way for the user to + // understand it. #[test] fn aliases_that_are_addable_would_duplicate_and_the_rest_would_dead_end() { assert!(crate::models::agent::is_valid_custom_agent_id("kimi")); assert!(!crate::models::agent::is_valid_custom_agent_id("qoder")); + assert!(!crate::models::agent::is_valid_custom_agent_id("kiro")); for alias in BUILTIN_REGISTRY_ALIASES { assert!( is_builtin_registry_id(alias), diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index 146894188f..0d7cb6d80c 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -361,6 +361,14 @@ pub struct SessionState { /// Backend-internal — not serialized. pub grok_catalog_broadcast: 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 @@ -743,6 +751,7 @@ impl SessionState { config_options: None, grok_model_specs: None, grok_catalog_broadcast: None, + kiro_reasoning: None, pi_startup_banner: None, asserted_config_values: BTreeMap::new(), agent_chosen_config_values: BTreeMap::new(), diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index 1e37892856..f1b9451d4d 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -1431,6 +1431,25 @@ pub struct GrokModelCatalog { pub specs: std::collections::HashMap, } +/// 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 @@ -1531,6 +1550,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 4662c77535..a1fb361c37 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -540,6 +540,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: @@ -605,6 +617,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 + ))) + } + } } } @@ -704,6 +729,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 + } } } @@ -1093,6 +1122,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 @@ -1418,6 +1455,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( @@ -9154,6 +9192,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 @@ -10350,6 +10397,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"), } } @@ -10888,6 +10942,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 @@ -11697,6 +11756,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 { @@ -11796,6 +11865,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 @@ -11827,8 +11904,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) @@ -13151,6 +13243,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 { @@ -13186,6 +13281,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 @@ -13284,10 +13546,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 @@ -13477,6 +13740,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, @@ -13601,6 +13867,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) @@ -14346,6 +14619,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 d8f0bbea2d..2ff0addd1f 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, ]; @@ -440,11 +443,21 @@ pub async fn mcp_install_from_marketplace( }) } +/// Save `spec` for `server_id` to exactly the agents in `apps`. +/// +/// `shown_spec` is the spec the editor loaded for this server before the user +/// edited it: Kiro keeps its own value for every field the save left as shown +/// (see `upsert_kiro_server_at`). It comes from the editor because a scan +/// taken now could already reflect a change another agent made while the +/// editor was open, and then misread which fields the user touched. A client +/// that sends none (a new server, or an older build) gets the scan's spec for +/// the id as the closest stand-in, and a new server has none. #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn mcp_upsert_local_server( server_id: String, spec: Value, apps: Vec, + shown_spec: Option, ) -> Result { let canonical_spec = canonicalize_spec(&spec, "local MCP save")?; let target_apps = normalize_apps(apps); @@ -473,11 +486,26 @@ pub async fn mcp_upsert_local_server( // Nothing below is reversible, and the walk REMOVES the server from every // non-target agent, so a target whose config cannot take it has to be // caught before the first write rather than halfway through. - require_complete_scan(&scan_local_servers())?; + let scan = scan_local_servers(); + require_complete_scan(&scan)?; + // Canonicalized like the saved spec, so a field left alone compares equal. + let shown = match shown_spec { + Some(shown) => canonicalize_spec(&shown, "local MCP save").ok(), + None => scan + .servers + .iter() + .find(|server| server.id == server_id) + .map(|server| server.spec.clone()), + }; with_upsert_preflight(&target_set, || { for app in ALL_MCP_APPS { if target_set.contains(&app) { - upsert_server_for_app(app, &server_id, &canonical_spec)?; + match (app, shown.as_ref()) { + (McpAppType::Kiro, Some(shown)) => { + upsert_kiro_server_edited(&server_id, &canonical_spec, shown)? + } + _ => upsert_server_for_app(app, &server_id, &canonical_spec)?, + } } else { let _ = remove_server_for_app(app, &server_id)?; } @@ -575,11 +603,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 +3104,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 +3124,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 +3200,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 +3274,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 +3313,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. @@ -4082,6 +4122,288 @@ 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, None) +} + +/// [`upsert_kiro_server`] for a save from the MCP editor, which knows the spec +/// the editor showed for the server before the user edited it. +fn upsert_kiro_server_edited(id: &str, spec: &Value, shown: &Value) -> Result<(), AppCommandError> { + upsert_kiro_server_at(&kiro_mcp_json_path(), id, spec, Some(shown)) +} + +/// Write `spec` as Kiro's entry for `id`. +/// +/// With `shown` (the spec the editor showed before the edit), a Kiro field the +/// save left exactly as shown keeps the value Kiro's own entry has — its +/// absence included. The editor shows ONE spec per server, and for a server +/// another agent also holds, a field both agents model is the other agent's +/// (see [`merge_kiro_extension_fields`]): writing it back would silently +/// re-enable a server the user disabled only in Kiro, or hand Kiro the tool +/// approvals the user gave only to Cline. A field the user did change is +/// theirs, and is written. Only an entry the Kiro reader lists counts as +/// Kiro's own: one it skips (no command or url, a `registry` reference) was +/// never shown, so nothing in it is carried over. +fn upsert_kiro_server_at( + path: &Path, + id: &str, + spec: &Value, + shown: Option<&Value>, +) -> Result<(), AppCommandError> { + let mut root = read_json_file(path)?; + if !root.is_object() { + root = json!({}); + } + + let mut entry = canonical_to_kiro_entry(spec)?; + let existing = root + .get("mcpServers") + .and_then(|servers| servers.get(id)) + .filter(|existing| canonicalize_spec(existing, "Kiro config").is_ok()) + .and_then(Value::as_object); + if let (Some(shown), Some(existing), Some(entry)) = (shown, existing, entry.as_object_mut()) { + let stdio = spec.get("type").and_then(Value::as_str) == Some("stdio"); + for key in KIRO_EXTENSION_KEYS { + if spec.get(*key) != shown.get(*key) { + continue; + } + match existing + .get(*key) + .filter(|value| is_kiro_extension_field(key, value, stdio)) + { + Some(value) => { + entry.insert((*key).to_string(), value.clone()); + } + None => { + entry.remove(*key); + } + } + } + } + + 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); + }; + + // An entry the reader skips — a `registry` reference, or anything else + // `canonicalize_spec` rejects — was never listed as Kiro's, so no edit made + // in codeg can be about it. Saving a same-named server for other agents + // still walks Kiro to remove it from every agent it was not assigned to, + // and that walk must leave such an entry alone. + let representable = servers + .get(id) + .is_some_and(|spec| canonicalize_spec(spec, "Kiro config").is_ok()); + if !representable { + 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) // @@ -4351,6 +4673,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), } } @@ -7157,6 +7480,186 @@ 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, None).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()); + + // The registry reference was never listed as Kiro's, so the remove + // walk a same-named save for other agents runs leaves it in place. + assert!(!remove_kiro_server_at(&path, "reg").unwrap()); + let root: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(root["mcpServers"]["reg"], json!({"type": "registry"})); + } + + // 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"])); + } + + // The editor shows one spec per server, so for a server Cline also holds + // it shows Cline's `disabled` and `autoApprove`. Saving an unrelated edit + // must not write those into Kiro: a server disabled only in Kiro stays + // disabled, and Kiro gets no approval the user gave only to Cline. A field + // the user did change is written. + #[test] + fn a_save_keeps_kiros_own_value_for_every_field_it_left_alone() { + 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": { + "fs": {"command": "npx", "args": ["fs-v1"], "disabled": true, "timeout": 5000} + }}) + .to_string(), + ) + .unwrap(); + let read = |path: &Path| -> Value { + serde_json::from_str::(&std::fs::read_to_string(path).unwrap()).unwrap() + ["mcpServers"]["fs"] + .clone() + }; + // What the editor showed: Cline's projection, which won the scan. + let shown = json!({ + "type": "stdio", "command": "npx", "args": ["fs-v1"], + "disabled": false, "autoApprove": ["write_file"], "timeout": 5000 + }); + + // Only `args` changed. + let mut edited = shown.clone(); + edited["args"] = json!(["fs-v2"]); + upsert_kiro_server_at(&path, "fs", &edited, Some(&shown)).unwrap(); + let fs = read(&path); + assert_eq!(fs["args"], json!(["fs-v2"])); + assert_eq!(fs["disabled"], json!(true), "still disabled in Kiro"); + assert!(fs.get("autoApprove").is_none(), "no approval Kiro never had"); + assert_eq!(fs["timeout"], json!(5000)); + + // The user turns `disabled` on and sets a timeout: both are written. + let before = json!({ + "type": "stdio", "command": "npx", "args": ["fs-v2"], + "disabled": false, "timeout": 5000 + }); + let mut edited = before.clone(); + edited["disabled"] = json!(true); + edited["timeout"] = json!(9000); + upsert_kiro_server_at(&path, "fs", &edited, Some(&before)).unwrap(); + let fs = read(&path); + assert_eq!(fs["disabled"], json!(true)); + assert_eq!(fs["timeout"], json!(9000)); + + // Without a shown spec (an install, a newly added agent) the spec is + // written as given. + upsert_kiro_server_at(&path, "fs", &shown, None).unwrap(); + assert_eq!(read(&path)["autoApprove"], json!(["write_file"])); + } + + // Only an entry the Kiro reader lists is Kiro's own. One it skips (here: + // no command or url) was never shown, so a save that puts the server on + // Kiro must not revive anything it holds — a hidden approval included. + #[test] + fn a_kiro_entry_the_reader_skips_lends_nothing_to_a_save() { + 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": {"fs": {"autoApprove": ["write_file"], "disabled": true}}}) + .to_string(), + ) + .unwrap(); + assert!(read_kiro_servers_at(&path).unwrap().is_empty()); + + let shown = json!({"type": "stdio", "command": "npx", "args": ["fs"]}); + upsert_kiro_server_at(&path, "fs", &shown, Some(&shown)).unwrap(); + let root: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!( + root["mcpServers"]["fs"], + json!({"command": "npx", "args": ["fs"]}) + ); + } + #[test] fn all_mcp_apps_is_exhaustive() { // `ALL_MCP_APPS` drives BOTH write paths' "and no others" semantics: @@ -7187,6 +7690,7 @@ mod tests { | McpAppType::DeepSeek | McpAppType::Qoder | McpAppType::Antigravity + | McpAppType::Kiro | McpAppType::Pi => {} } } @@ -7241,6 +7745,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 b670df0386..7ddca110ce 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/m20261015_000001_kiro_builtin_agent.rs b/src-tauri/src/db/migration/m20261015_000001_kiro_builtin_agent.rs new file mode 100644 index 0000000000..31e883760b --- /dev/null +++ b/src-tauri/src/db/migration/m20261015_000001_kiro_builtin_agent.rs @@ -0,0 +1,482 @@ +//! 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 delegation defaults (`app_metadata` key `delegation.agent_defaults`), +//! one JSON object keyed by agent type — the custom agent's entry moves to +//! `kiro`. +//! +//! 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, adds its tags to the +//! ones that row has, and is soft-deleted (`deleted_at`); an existing `kiro` +//! settings row keeps its own state but inherits the custom row's launch env +//! and model provider where it has none; and existing `kiro` delegation +//! defaults are kept over the custom agent's. +//! +//! 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"; +/// `commands::delegation::KEY_DELEGATION_AGENT_DEFAULTS`, frozen here as this +/// migration found it. +const DELEGATION_DEFAULTS_KEY: &str = "delegation.agent_defaults"; + +#[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})" + ), + // …its tags join the ones that row already has… + format!( + "INSERT OR IGNORE INTO conversation_tag_link (conversation_id, tag_id, created_at) \ + SELECT existing.id, link.tag_id, link.created_at \ + FROM conversation_tag_link AS link \ + JOIN conversation AS dup ON dup.id = link.conversation_id \ + JOIN conversation AS existing ON existing.external_id = dup.external_id \ + WHERE existing.agent_type = '{builtin}' \ + AND link.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" + )); + } + // The delegation defaults, keyed by wire name: the custom agent's entry + // becomes the built-in's unless the built-in already has its own, which + // wins as its settings row does. `->` keeps the entry a JSON object. A + // value that is not JSON is left alone rather than failing the migration + // (the CASE makes sure nothing parses it first). + let custom_path = format!("$.\"{CUSTOM_WIRE}\""); + let builtin_path = format!("$.\"{BUILTIN_WIRE}\""); + out.push(format!( + "UPDATE app_metadata SET value = json_remove( \ + CASE WHEN json_type(value, '{builtin_path}') IS NULL \ + THEN json_set(value, '{builtin_path}', value -> '{custom_path}') \ + ELSE value END, \ + '{custom_path}') \ + WHERE key = '{DELEGATION_DEFAULTS_KEY}' \ + AND CASE WHEN json_valid(value) THEN json_type(value, '{custom_path}') END IS NOT NULL" + )); + 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 DELEGATION: &str = "INSERT INTO app_metadata (key, value, created_at, updated_at) VALUES "; + + /// The delegation defaults after the migration, parsed. + async fn delegation_defaults(conn: &DatabaseConnection) -> serde_json::Value { + let raw = strings( + conn, + "SELECT value FROM app_metadata WHERE key = 'delegation.agent_defaults'", + ) + .await; + let [Some(raw)] = raw.as_slice() else { + panic!("one delegation defaults row expected: {raw:?}"); + }; + serde_json::from_str(raw).expect("still JSON") + } + + 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')", + &format!( + "{DELEGATION}('delegation.agent_defaults', \ + '{{\"codex\":{{\"mode\":\"auto\"}},\"custom:kiro-cli\":{{\"model\":\"claude-opus-5.5\"}}}}', \ + '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() + )] + ); + // The delegation defaults move with the agent, as an object. + assert_eq!( + delegation_defaults(&conn).await, + serde_json::json!({ + "codex": {"mode": "auto"}, + "kiro": {"model": "claude-opus-5.5"}, + }) + ); + } + + // 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')", + "INSERT INTO conversation_tag (id, folder_id, name, color, sort_order, created_at, \ + updated_at) VALUES (1, NULL, 'a', 'red', 0, '2026-01-01', '2026-01-01'), \ + (2, NULL, 'b', 'blue', 1, '2026-01-01', '2026-01-01')", + // The duplicate has tags 1 and 2, the built-in row tag 2 already. + "INSERT INTO conversation_tag_link (conversation_id, tag_id, created_at) VALUES \ + (1, 1, '2026-01-01'), (1, 2, '2026-01-01'), (2, 2, '2026-01-01')", + &format!( + "{DELEGATION}('delegation.agent_defaults', \ + '{{\"custom:kiro-cli\":{{\"model\":\"custom\"}},\"kiro\":{{\"model\":\"own\"}}}}', \ + '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 group_concat(tag_id) FROM (SELECT tag_id FROM conversation_tag_link \ + WHERE conversation_id = 2 ORDER BY tag_id)" + ) + .await, + [Some("1,2".into())], + "the duplicate's tags join the built-in row's, once each" + ); + assert_eq!( + delegation_defaults(&conn).await, + serde_json::json!({"kiro": {"model": "own"}}), + "the built-in's own delegation defaults win" + ); + 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" + ); + } + + // Delegation defaults that are not JSON stay as they are; the migration + // does not fail on them. + #[tokio::test] + async fn delegation_defaults_that_are_not_json_are_left_alone() { + let conn = migrated_db(&[&format!( + "{DELEGATION}('delegation.agent_defaults', 'custom:kiro-cli', \ + '2026-01-01', '2026-01-01')" + )]) + .await; + assert_eq!( + strings( + &conn, + "SELECT value FROM app_metadata WHERE key = 'delegation.agent_defaults'" + ) + .await, + [Some("custom:kiro-cli".into())] + ); + } + + // 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 ab5161c547..9dbe342ec7 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -48,6 +48,7 @@ mod m20260907_000001_canvas_node_path; mod m20260929_000001_canvas_board; mod m20260930_000001_agent_setting_drop_adapter_channel; mod m20261006_000001_conversation_tag; +mod m20261015_000001_kiro_builtin_agent; pub struct Migrator; #[async_trait::async_trait] @@ -102,6 +103,7 @@ impl MigratorTrait for Migrator { Box::new(m20260929_000001_canvas_board::Migration), Box::new(m20260930_000001_agent_setting_drop_adapter_channel::Migration), Box::new(m20261006_000001_conversation_tag::Migration), + Box::new(m20261015_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 0f21a026d9..ce5be56746 100644 --- a/src-tauri/src/db/service/agent_setting_service.rs +++ b/src-tauri/src/db/service/agent_setting_service.rs @@ -44,6 +44,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(_) ) @@ -276,3 +277,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 f1f92e28cd..9e75bf1a0c 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, AgentEnv, AgentEnvs, 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, env: &AgentEnv) -> Box { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a8de249fd5..330784b47f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1994,6 +1994,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_fetch_agent_latest_release, 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..bae4a1cb92 --- /dev/null +++ b/src-tauri/src/parsers/kiro.rs @@ -0,0 +1,1557 @@ +//! 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, + AgentEnv, 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_for_env(&AgentEnv::inherited()) +} + +/// The `.kiro` directory of the Kiro launched with `env`. +pub(crate) fn resolve_kiro_home_dir_for_env(env: &AgentEnv) -> PathBuf { + resolve_kiro_home_dir_from(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::for_env(&AgentEnv::inherited()) + } + + /// The history of the Kiro launched with `env`. + pub fn for_env(env: &AgentEnv) -> Self { + Self { + sessions_dir: resolve_kiro_home_dir_for_env(env) + .join("sessions") + .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. +/// +/// The caller REPLACES the approval card's input with [`canonical_tool`]'s, +/// so a tool is recognized only when its input holds that tool's own +/// arguments and nothing else — every one of which the canonical form +/// carries. An MCP tool that merely shares an argument name (`command`, +/// `operations`) is left alone, or its other arguments would vanish from +/// what the user is approving. +pub(crate) fn tool_name_from_input(input: &Value) -> Option<&'static str> { + let args = input.as_object()?; + if let Some(operations) = args.get("operations").and_then(Value::as_array) { + let known = operations.iter().all(|op| { + op.as_object().is_some_and(|op| { + let own: &[&str] = match op.get("mode").and_then(Value::as_str).unwrap_or("Line") { + "Line" => &["mode", "path", "offset", "limit"], + "Directory" => &["mode", "path", "depth"], + "Image" => &["mode", "image_paths"], + _ => return false, + }; + only_own_arguments(op, own) + }) + }); + return (known && only_own_arguments(args, &["operations"])).then_some("read"); + } + let command = args.get("command")?.as_str()?; + if args.get("path").is_some_and(Value::is_string) { + let own: &[&str] = match command { + "create" => &["command", "path", "content"], + "strReplace" => &["command", "path", "oldStr", "newStr", "replaceAll"], + "insert" => &["command", "path", "content", "insertLine"], + _ => return None, + }; + return only_own_arguments(args, own).then_some("write"); + } + only_own_arguments(args, &["command", "working_dir"]).then_some("shell") +} + +/// Whether every key of `args` is one of `own` or the purpose every Kiro tool +/// call carries. +fn only_own_arguments(args: &serde_json::Map, own: &[&str]) -> bool { + args.keys() + .all(|key| key == TOOL_PURPOSE_KEY || own.contains(&key.as_str())) +} + +/// 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") + ); + } + + /// Built with an agent's launch env, the parser reads where THAT env puts + /// Kiro's sessions, not where codeg's own environment does. + #[test] + fn for_env_reads_the_home_of_the_launch_env() { + let env = AgentEnv::with_overrides(&[("KIRO_HOME", "/relocated/kiro")]); + assert_eq!( + KiroParser::for_env(&env).sessions_dir, + PathBuf::from("/relocated/kiro").join("sessions").join("cli") + ); + } + + #[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); + // Every argument of the built-in, including the purpose, still counts. + assert_eq!( + name(serde_json::json!({ + "__tool_use_purpose": "List it", "command": "ls", "working_dir": "/w" + })), + Some("shell") + ); + assert_eq!( + name(serde_json::json!({"operations": [ + {"mode": "Directory", "path": "/w", "depth": 1} + ]})), + Some("read") + ); + } + + // The permission path replaces the approval card's input with the + // canonical one, so an input carrying anything the canonical form would + // drop is left as sent. These are MCP calls that merely share an argument + // name with a built-in. + #[test] + fn an_input_with_foreign_arguments_is_not_taken_for_a_builtin() { + let name = |v: Value| tool_name_from_input(&v); + // `operations` would make it a read, and the command would vanish. + assert_eq!( + name(serde_json::json!({ + "command": "rm -rf /repo", + "operations": [{"mode": "Line", "path": "/repo/README.md"}] + })), + None + ); + // A shell-looking call whose target would vanish. + assert_eq!( + name(serde_json::json!({"command": "deploy", "target": "prod"})), + None + ); + // A write with an argument the canonical write does not carry. + assert_eq!( + name(serde_json::json!({ + "command": "create", "path": "/a", "content": "x", "mode": "0755" + })), + None + ); + // A read operation with a field the canonical read does not carry. + assert_eq!( + name(serde_json::json!({"operations": [ + {"mode": "Line", "path": "/a", "offset": 1, "host": "prod"} + ]})), + 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 4ad5cec772..17d2896c52 100644 --- a/src-tauri/src/parsers/mod.rs +++ b/src-tauri/src/parsers/mod.rs @@ -13,6 +13,7 @@ pub mod gemini; pub mod grok; pub mod hermes; pub mod kimi_code; +pub mod kiro; pub mod openclaw; pub mod opencode; mod opencode_context_window; @@ -328,6 +329,19 @@ pub fn external_transcript_sources() -> Vec { include_top: None, indexes: &[], }, + 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, + indexes: &[], + }, ]; if let Some(home) = dirs::home_dir() { sources.push(ExternalSource { @@ -403,6 +417,7 @@ pub fn build_agent_parser(agent_type: AgentType, env: &AgentEnv) -> Box Box::new(deepseek::DeepSeekParser::for_env(env)), AgentType::Qoder => Box::new(qoder::QoderParser::for_env(env)), AgentType::Antigravity => Box::new(antigravity::AntigravityParser::for_env(env)), + AgentType::Kiro => Box::new(kiro::KiroParser::for_env(env)), // 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 93c61c5c2f..79f3779446 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -1152,6 +1152,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/handlers/mcp.rs b/src-tauri/src/web/handlers/mcp.rs index bc3438aef3..7a4f751032 100644 --- a/src-tauri/src/web/handlers/mcp.rs +++ b/src-tauri/src/web/handlers/mcp.rs @@ -46,6 +46,9 @@ pub struct UpsertLocalServerParams { pub server_id: String, pub spec: Value, pub apps: Vec, + /// The spec the editor loaded before the edit; absent for a new server. + #[serde(default)] + pub shown_spec: Option, } #[derive(Deserialize)] @@ -112,8 +115,13 @@ pub async fn mcp_install_from_marketplace( pub async fn mcp_upsert_local_server( Json(params): Json, ) -> Result, AppCommandError> { - let result = - mcp_commands::mcp_upsert_local_server(params.server_id, params.spec, params.apps).await?; + let result = mcp_commands::mcp_upsert_local_server( + params.server_id, + params.spec, + params.apps, + params.shown_spec, + ) + .await?; Ok(Json(result)) } diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index c747312b77..8c81f5f34f 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -1126,6 +1126,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 51bd0bcd6d..3ec06e3a0b 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; @@ -1767,3 +1767,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.kiro.test.tsx b/src/components/settings/acp-agent-settings.kiro.test.tsx new file mode 100644 index 0000000000..f4619c721c --- /dev/null +++ b/src/components/settings/acp-agent-settings.kiro.test.tsx @@ -0,0 +1,115 @@ +/** + * The Kiro panel writes its env keys through `persistEnv`, while the enable + * switch persists the raw editor's draft WHOLESALE. The panel's save has to + * fold its keys into that draft, or flipping the switch afterwards silently + * brings back whatever the draft held before — here `--trust-all-tools`, after + * the user chose to be asked again. + */ +import { render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { NextIntlClientProvider } from "next-intl" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import enMessages from "@/i18n/messages/en.json" +import { acpListAgents, acpUpdateAgentEnv, listModelProviders } from "@/lib/api" +import type { AcpAgentInfo } from "@/lib/types" + +import { AcpAgentSettings } from "./acp-agent-settings" + +vi.mock("@/lib/api", async (importOriginal) => { + const actual = await importOriginal>() + // Every function is stubbed so nothing here can reach the real transport; + // the calls the panel makes on mount that these tests don't need (preflight, + // catalogs) simply never settle. + return Object.fromEntries( + Object.entries(actual).map(([name, value]) => [ + name, + typeof value === "function" ? vi.fn(() => new Promise(() => {})) : value, + ]) + ) +}) +vi.mock("next/navigation", () => { + const params = new URLSearchParams() + return { useSearchParams: () => params } +}) + +function kiroAgent(overrides: Partial = {}): AcpAgentInfo { + return { + agent_type: "kiro", + skills_capable: true, + registry_id: "kiro-cli", + registry_version: null, + supports_custom_version: false, + name: "Kiro CLI", + description: "", + available: true, + distribution_type: "system", + is_acp_adapter: false, + install_url: "https://kiro.dev/docs/getting-started/installation/", + supports_self_update: true, + custom_source: null, + enabled: true, + sort_order: 0, + installed_version: "2.24.1", + host_tools_agent_mode: false, + env: {}, + config_json: null, + config_file_path: null, + opencode_auth_json: null, + codex_auth_json: null, + cline_secrets_json: null, + codex_config_toml: null, + codex_model_catalog: null, + codex_sandbox_settings: null, + grok_config_toml: null, + grok_settings: null, + hermes_config_yaml: null, + cursor_cli_config_json: null, + cursor_settings: null, + model_provider_id: null, + icon_url: null, + ...overrides, + } +} + +describe("AcpAgentSettings — the Kiro panel and the enable switch", () => { + beforeEach(() => { + vi.mocked(listModelProviders).mockResolvedValue([]) + vi.mocked(acpUpdateAgentEnv).mockResolvedValue(0) + }) + + it("keeps a switch back to asking when the agent is toggled afterwards", async () => { + const user = userEvent.setup() + vi.mocked(acpListAgents).mockResolvedValue([ + kiroAgent({ env: { KIRO_TRUST_ALL_TOOLS: "1", OTHER: "keep" } }), + ]) + render( + + + + ) + + await user.click( + await screen.findByRole("combobox", { name: "Permission mode" }) + ) + await user.click( + await screen.findByRole("option", { + name: "Ask before running tools (default)", + }) + ) + await user.click(screen.getByRole("button", { name: "Save Kiro Config" })) + await waitFor(() => expect(acpUpdateAgentEnv).toHaveBeenCalledTimes(1)) + expect(vi.mocked(acpUpdateAgentEnv).mock.calls[0][1].env).toEqual({ + KIRO_TRUST_ALL_TOOLS: "0", + OTHER: "keep", + }) + + await user.click( + screen.getByRole("switch", { name: "Kiro CLI enable switch" }) + ) + await waitFor(() => expect(acpUpdateAgentEnv).toHaveBeenCalledTimes(2)) + const [, toggled] = vi.mocked(acpUpdateAgentEnv).mock.calls[1] + expect(toggled.enabled).toBe(false) + expect(toggled.env).toEqual({ KIRO_TRUST_ALL_TOOLS: "0", OTHER: "keep" }) + }) +}) diff --git a/src/components/settings/acp-agent-settings.test.tsx b/src/components/settings/acp-agent-settings.test.tsx index 7fe263c27c..faa593b8b3 100644 --- a/src/components/settings/acp-agent-settings.test.tsx +++ b/src/components/settings/acp-agent-settings.test.tsx @@ -1160,6 +1160,118 @@ describe("unreviewed latest release in Version Status", () => { "custom_install", ]) }) + + // 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 for a system agent", () => { + const url = "https://kiro.dev/docs/getting-started/installation/" + const kiro = (installed_version: string | null) => + makeAgent({ + agent_type: "kiro" as AgentType, + distribution_type: "system", + registry_version: null, + installed_version, + install_url: url, + supports_self_update: true, + }) + const preflight = (status: "pass" | "fail"): { result: PreflightResult } => ({ + result: { + agent_type: "kiro" as AgentType, + agent_name: "Kiro CLI", + passed: status === "pass", + checks: [ + { + check_id: "system_cli", + label: "kiro-cli", + status, + message: "", + fixes: [], + }, + ], + adapter: null, + }, + }) + const versionCard = (checks: ReturnType) => + checks.find((c) => c.check_id === "version_status") + + // The preflight resolves the binary a launch runs; `--version` coming back + // empty (a probe that timed out right after an update) does not make the + // CLI missing, and Upgrade stays on offer. + it("keeps a found CLI installed when its version could not be read", () => { + const card = versionCard(getAgentChecks(kiro(null), preflight("pass"))) + expect(card?.status).toBe("pass") + expect(card?.message).toContain("its own updater") + expect(card?.fixes.map((fix) => fix.kind)).toEqual(["upgrade_system"]) + }) + + // A version left over from before the CLI was removed must not offer an + // Upgrade the backend can only refuse. + it("reports a CLI the preflight could not find as missing", () => { + const card = versionCard(getAgentChecks(kiro("2.24.1"), preflight("fail"))) + expect(card?.status).toBe("fail") + expect(card?.fixes).toEqual([ + expect.objectContaining({ kind: "open_url", payload: url }), + ]) + }) + + it("goes by the version until the preflight has answered", () => { + expect(versionCard(getAgentChecks(kiro("2.24.1"), undefined))?.status).toBe( + "pass" + ) + expect(versionCard(getAgentChecks(kiro(null), undefined))?.status).toBe( + "fail" + ) + }) }) describe("getAgentChecks uv gating", () => { @@ -2323,6 +2435,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 3521cb30d7..a6761c212d 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -93,6 +93,7 @@ import { acpDetectAgentLocalVersion, acpDownloadAgentBinary, acpFetchAgentLatestRelease, + acpUpdateSystemAgent, acpInstallUvTool, acpGetAgentStatus, acpListAgents, @@ -159,6 +160,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 { @@ -292,6 +294,7 @@ type RunningActionKind = | "custom_install" | "upgrade_latest" | "install_uv" + | "upgrade_system" type UiFixAction = | FixAction @@ -306,6 +309,7 @@ type UiFixAction = | "uninstall_npx" | "install_opencode_plugins" | "custom_install" + | "upgrade_system" // Payload is the version to install, not the agent type. | "upgrade_latest" payload: string @@ -343,6 +347,7 @@ const PACKAGE_ACTION_FIX_KINDS: ReadonlyArray = [ "custom_install", "upgrade_latest", "install_uv", + "upgrade_system", ] type AcpTranslator = ( @@ -378,6 +383,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" @@ -824,6 +841,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"], @@ -4066,8 +4095,78 @@ export function providerToRebindTo( export function buildVersionCheck( agent: AcpAgentInfo, uvReady: boolean = true, - latestRelease: AgentLatestRelease | null = null + latestRelease: AgentLatestRelease | null = null, + // For a `system` agent: whether the preflight found the user's install + // (its `system_cli` check), or `undefined` before a preflight has answered. + systemCliFound?: boolean ): 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 + // Whether the install is there is the preflight's call: it resolves the + // very binary a launch would run. The version is only what `--version` + // printed — it can come back empty for a CLI that is present (a probe + // that timed out, say right after an update) and stay set after the CLI + // was removed (a refresh keeps the last version it saw). Without a + // preflight answer yet, a known version is the evidence there is. + const found = systemCliFound ?? Boolean(installed) + if (found) { + 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: installed + ? acpText("version.localOnly", "Local: {localVersion}", { + localVersion: installed, + }) + : acpText( + "version.localUndetected", + "Local: version not detected" + ), + } + ), + 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" && @@ -4352,7 +4451,15 @@ export function getAgentChecks( ) const uvReady = agent.distribution_type !== "uvx" || !uvCheck || uvCheck.status === "pass" - const versionCheck = buildVersionCheck(agent, uvReady, latestRelease ?? null) + const systemCliCheck = current?.result?.checks?.find( + (check) => check.check_id === "system_cli" + ) + const versionCheck = buildVersionCheck( + agent, + uvReady, + latestRelease ?? null, + systemCliCheck ? systemCliCheck.status === "pass" : undefined + ) const remoteChecks: UiCheckItem[] = (current?.result?.checks ?? []).map( (check) => ({ ...check, @@ -5435,6 +5542,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] || @@ -5479,6 +5657,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) @@ -5631,7 +5813,8 @@ export function AcpAgentSettings() { ) : fix.kind === "upgrade_binary" || fix.kind === "upgrade_npx" || fix.kind === "redownload_binary" || - fix.kind === "upgrade_latest" ? ( + fix.kind === "upgrade_latest" || + fix.kind === "upgrade_system" ? ( ) : fix.kind === "uninstall_binary" || fix.kind === "uninstall_npx" ? ( @@ -8151,7 +8334,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 @@ -10970,6 +11153,29 @@ supports_websockets = true`} ) } /> + ) : selectedAgent.agent_type === "kiro" ? ( + + persistEnv( + selectedAgent.agent_type, + enabled, + envMapToText(env), + selectedAgent.model_provider_id, + // Both keys this panel owns, folded into the raw + // editor's draft. The enable switch and the generic env + // Save persist that draft WHOLESALE, so without this a + // switch from trust-all back to ask would be silently + // undone the next time either one fires. `undefined` + // (the key field was cleared) deletes the line. + { + KIRO_API_KEY: env.KIRO_API_KEY, + KIRO_TRUST_ALL_TOOLS: env.KIRO_TRUST_ALL_TOOLS, + } + ) + } + /> ) : 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..5335624c6b 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"), } } @@ -693,6 +695,10 @@ export function McpSettings() { serverId: selectedLocal.id, spec: parsedSpec, apps, + // What the editor was loaded with — it is reset from this spec + // whenever the selection or the scan changes — so the backend can + // tell the fields the user edited from the ones they left alone. + shownSpec: selectedLocal.spec, }) const next = await refreshLocalServers() toast.success(t("toasts.saveSuccess")) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 59b2dc0ba2..88b016aa64 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -780,6 +780,7 @@ "install": "تثبيت", "upgrade": "ترقية", "uninstall": "إزالة التثبيت", + "installGuide": "دليل التثبيت", "uninstalling": "جارٍ إزالة التثبيت...", "saveEnvVars": "حفظ متغيرات البيئة", "saving": "جارٍ الحفظ...", @@ -1006,6 +1007,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 عند الإقلاع. أما النموذج ومستوى الاستدلال فمحدِّدان على مستوى الجلسة، ويُختاران من حقل الإدخال.", @@ -1498,14 +1512,24 @@ "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": "حالة الإصدار", "notInstalled": "غير مثبت", "recommendedLocal": "الموصى به: {recommendedVersion} · المحلي: {localVersion}", "localOnly": "المحلي: {localVersion}", + "localUndetected": "المحلي: لم يتم اكتشاف الإصدار", "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 81e23b12f4..135272025c 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -780,6 +780,7 @@ "install": "Installieren", "upgrade": "Aktualisieren", "uninstall": "Deinstallieren", + "installGuide": "Installationsanleitung", "uninstalling": "Wird deinstalliert...", "saveEnvVars": "Umgebungsvariablen speichern", "saving": "Speichern...", @@ -1006,6 +1007,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.", @@ -1498,14 +1512,24 @@ "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", "notInstalled": "Nicht installiert", "recommendedLocal": "Empfohlen: {recommendedVersion} · Lokal: {localVersion}", "localOnly": "Lokal: {localVersion}", + "localUndetected": "Lokal: Version nicht erkannt", "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 7d8062fe1e..cf18fc0d8b 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -780,6 +780,7 @@ "install": "Install", "upgrade": "Upgrade", "uninstall": "Uninstall", + "installGuide": "Install guide", "uninstalling": "Uninstalling...", "saveEnvVars": "Save environment variables", "saving": "Saving...", @@ -1006,6 +1007,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.", @@ -1498,14 +1512,24 @@ "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", "notInstalled": "Not installed", "recommendedLocal": "Recommended: {recommendedVersion} · Local: {localVersion}", "localOnly": "Local: {localVersion}", + "localUndetected": "Local: version not detected", "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 4da8aefba6..7c8c29f983 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -780,6 +780,7 @@ "install": "Instalar", "upgrade": "Actualizar", "uninstall": "Desinstalar", + "installGuide": "Guía de instalación", "uninstalling": "Desinstalando...", "saveEnvVars": "Guardar variables de entorno", "saving": "Guardando...", @@ -1006,6 +1007,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.", @@ -1498,14 +1512,24 @@ "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", "notInstalled": "No instalado", "recommendedLocal": "Recomendada: {recommendedVersion} · Local: {localVersion}", "localOnly": "Local: {localVersion}", + "localUndetected": "Local: versión no detectada", "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 00b6e0924a..2d73bee834 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -780,6 +780,7 @@ "install": "Installer", "upgrade": "Mettre à niveau", "uninstall": "Désinstaller", + "installGuide": "Guide d'installation", "uninstalling": "Désinstallation...", "saveEnvVars": "Enregistrer les variables d’environnement", "saving": "Enregistrement...", @@ -1006,6 +1007,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.", @@ -1498,14 +1512,24 @@ "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", "notInstalled": "Non installé", "recommendedLocal": "Recommandée : {recommendedVersion} · Local : {localVersion}", "localOnly": "Local : {localVersion}", + "localUndetected": "Local : version non détectée", "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 b8068a09b1..84da2f250b 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -780,6 +780,7 @@ "install": "インストール", "upgrade": "アップグレード", "uninstall": "アンインストール", + "installGuide": "インストールガイド", "uninstalling": "アンインストール中...", "saveEnvVars": "環境変数を保存", "saving": "保存中...", @@ -1006,6 +1007,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 が起動時に読み込む環境変数です。モデルと推論レベルはセッションごとのセレクターなので、入力欄で切り替えます。", @@ -1498,14 +1512,24 @@ "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": "バージョン状態", "notInstalled": "未インストール", "recommendedLocal": "推奨: {recommendedVersion} · ローカル: {localVersion}", "localOnly": "ローカル:{localVersion}", + "localUndetected": "ローカル:バージョンを検出できません", "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 6302116447..f1f1364a90 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -780,6 +780,7 @@ "install": "설치", "upgrade": "업그레이드", "uninstall": "제거", + "installGuide": "설치 가이드", "uninstalling": "제거 중...", "saveEnvVars": "환경 변수 저장", "saving": "저장 중...", @@ -1006,6 +1007,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가 시작할 때 읽는 환경 변수입니다. 모델과 추론 강도는 세션별 선택기이므로 입력창에서 바꿉니다.", @@ -1498,14 +1512,24 @@ "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": "버전 상태", "notInstalled": "설치되지 않음", "recommendedLocal": "권장: {recommendedVersion} · 로컬: {localVersion}", "localOnly": "로컬: {localVersion}", + "localUndetected": "로컬: 버전을 확인할 수 없음", "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 c1c06bae91..9fbe80c23a 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -780,6 +780,7 @@ "install": "Instalar", "upgrade": "Atualizar", "uninstall": "Desinstalar", + "installGuide": "Guia de instalação", "uninstalling": "Desinstalando...", "saveEnvVars": "Salvar variáveis de ambiente", "saving": "Salvando...", @@ -1006,6 +1007,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.", @@ -1498,14 +1512,24 @@ "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", "notInstalled": "Não instalado", "recommendedLocal": "Recomendada: {recommendedVersion} · Local: {localVersion}", "localOnly": "Local: {localVersion}", + "localUndetected": "Local: versão não detectada", "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 88a42eb3bf..94b09e42e5 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -780,6 +780,7 @@ "install": "安装", "upgrade": "升级", "uninstall": "卸载", + "installGuide": "安装指南", "uninstalling": "卸载中...", "saveEnvVars": "保存环境变量", "saving": "保存中...", @@ -1006,6 +1007,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 启动时读取的环境变量。模型和推理档位是会话级选择器,在输入框里切换。", @@ -1498,14 +1512,24 @@ "saveQoderConfigFailed": "保存 Qoder 配置失败", "antigravitySaved": "已保存 Antigravity 配置", "antigravitySyncSkipped": "已保存,但没有改动 Antigravity 的 settings.json", - "saveAntigravityConfigFailed": "保存 Antigravity 配置失败" + "saveAntigravityConfigFailed": "保存 Antigravity 配置失败", + "kiroSaved": "Kiro 配置已保存", + "saveKiroFailed": "保存 Kiro 配置失败", + "systemAgentAlreadyLatest": "{name} 已是最新版本({version})" + }, + "distribution": { + "system": "官方安装" }, "version": { "statusLabel": "版本状态", "notInstalled": "未安装", "recommendedLocal": "推荐:{recommendedVersion} · 本地:{localVersion}", "localOnly": "本地:{localVersion}", + "localUndetected": "本地:未检测到版本", "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 b75e45b6fb..ca346db22b 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -780,6 +780,7 @@ "install": "安裝", "upgrade": "升級", "uninstall": "卸載", + "installGuide": "安裝指南", "uninstalling": "卸載中...", "saveEnvVars": "儲存環境變數", "saving": "儲存中...", @@ -1006,6 +1007,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 啟動時讀取的環境變數。模型與推理等級是工作階段層級的選擇器,在輸入框裡切換。", @@ -1498,14 +1512,24 @@ "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": "版本狀態", "notInstalled": "未安裝", "recommendedLocal": "建議:{recommendedVersion} · 本地:{localVersion}", "localOnly": "本機:{localVersion}", + "localUndetected": "本機:未偵測到版本", "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 015790480f..8249f70d9a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -543,6 +543,27 @@ 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 { + // The updater downloads a whole build (Kiro's are 150–360 MB), and the + // backend gives it up to 15 minutes (`SYSTEM_UPDATE_TIMEOUT`). Outlast that, + // so a slow update ends with the backend's own answer instead of the default + // 60 s web-call timeout — which would report a failure and free the Upgrade + // button while the updater is still running. + return getTransport().call( + "acp_update_system_agent", + { agentType, taskId }, + { timeoutMs: 16 * 60_000 } + ) +} + 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 @@ -2174,11 +2195,16 @@ export async function mcpUpsertLocalServer(params: { serverId: string spec: Record apps: McpAppType[] + /** The spec the editor loaded before the edit (omit for a new server): an + * agent that keeps per-server settings of its own (Kiro) keeps them for + * every field the edit left alone. */ + shownSpec?: Record | null }): Promise { return getTransport().call("mcp_upsert_local_server", { serverId: params.serverId, spec: params.spec, apps: params.apps, + shownSpec: params.shownSpec ?? null, }) } diff --git a/src/lib/selector-prefs-storage.test.ts b/src/lib/selector-prefs-storage.test.ts index bda1b1fa95..f078179182 100644 --- a/src/lib/selector-prefs-storage.test.ts +++ b/src/lib/selector-prefs-storage.test.ts @@ -149,4 +149,43 @@ describe("selector-prefs-storage", () => { model: "claude-opus-5", }) }) + + it("starts the built-in Kiro from the retired custom agent's picks", () => { + // The migration that turned `custom:kiro-cli` into `kiro` never sees this + // device-local store, so a user's chosen Kiro agent would silently reset. + seed({ "custom:kiro-cli": { modeId: "reviewer" } }) + expect(getSavedModeId("kiro")).toBe("reviewer") + expect(getSavedPrefsForConnect("kiro")).toEqual({ + modeId: "reviewer", + configValues: null, + }) + + // The built-in's first pick keeps the inherited one beside it… + saveConfigPreference("kiro", "model", "claude-sonnet-5") + expect(getSavedPrefsForConnect("kiro")).toEqual({ + modeId: "reviewer", + configValues: { model: "claude-sonnet-5" }, + }) + // …and from then on its own entry is the one that counts. + saveModePreference("kiro", { + current_mode_id: "kiro_planner", + available_modes: [], + }) + expect(getSavedModeId("kiro")).toBe("kiro_planner") + // The custom agent's entry is never rewritten. + expect( + JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}")["custom:kiro-cli"] + ).toEqual({ modeId: "reviewer" }) + }) + + it("prefers the built-in Kiro's own picks over the retired agent's", () => { + seed({ + "custom:kiro-cli": { modeId: "reviewer" }, + kiro: { configValues: { model: "claude-sonnet-5" } }, + }) + expect(getSavedPrefsForConnect("kiro")).toEqual({ + modeId: null, + configValues: { model: "claude-sonnet-5" }, + }) + }) }) diff --git a/src/lib/selector-prefs-storage.ts b/src/lib/selector-prefs-storage.ts index 64790dc979..8fd9b11cf1 100644 --- a/src/lib/selector-prefs-storage.ts +++ b/src/lib/selector-prefs-storage.ts @@ -47,12 +47,29 @@ function writeAll(all: AllPrefs) { } } +/** + * Built-in agent types that took over a custom agent, mapped to the custom + * agent's id. The database migration that retired it + * (`m20261015_000001_kiro_builtin_agent`) cannot reach this device-local + * store, so until the built-in has picks of its own it starts from the custom + * agent's: both ran the same `kiro-cli acp`, so its saved agent (the mode) is + * one the built-in offers too. + */ +const RETIRED_CUSTOM_AGENTS = new Map([["kiro", "custom:kiro-cli"]]) + +function prefsOf(all: AllPrefs, agentType: string): SelectorPrefs | undefined { + const own = all[agentType] + if (own) return own + const retired = RETIRED_CUSTOM_AGENTS.get(agentType) + return retired ? all[retired] : undefined +} + function updatePrefs( agentType: string, fn: (prefs: SelectorPrefs) => SelectorPrefs ) { const all = readAll() - const existing = all[agentType] + const existing = prefsOf(all, agentType) // Re-project onto the current schema so legacy fields (`modesHash` / // `configHash` from before the backend took ownership of preference // application) don't survive across writes. Without this an upgrade @@ -127,8 +144,7 @@ function healLegacyValues( /** Read saved mode id for an agent (no validation, just the raw value). */ export function getSavedModeId(agentType: string): string | null { - const all = readAll() - return all[agentType]?.modeId ?? null + return prefsOf(readAll(), agentType)?.modeId ?? null } /** @@ -145,8 +161,7 @@ export function getSavedPrefsForConnect(agentType: string): { modeId: string | null configValues: Record | null } { - const all = readAll() - const prefs = all[agentType] + const prefs = prefsOf(readAll(), agentType) if (!prefs) return { modeId: null, configValues: null } const configValues = prefs.configValues && Object.keys(prefs.configValues).length > 0 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 2ea81f5983..b997a6762d 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. @@ -1099,6 +1100,7 @@ export const AGENT_DISPLAY_ORDER: BuiltinAgentType[] = [ "deepseek", "qoder", "antigravity", + "kiro", ] const AGENT_DISPLAY_ORDER_INDEX = new Map( @@ -1133,6 +1135,7 @@ export const ALL_AGENT_TYPES: BuiltinAgentType[] = [ "deepseek", "qoder", "antigravity", + "kiro", ] export const MODEL_PROVIDER_AGENT_TYPES: BuiltinAgentType[] = [ @@ -1444,6 +1447,7 @@ export const AGENT_LABELS: Record = { deepseek: "DeepSeek Harness", qoder: "Qoder", antigravity: "Google Antigravity", + kiro: "Kiro CLI", } export const AGENT_COLORS: Record = { @@ -1462,6 +1466,7 @@ export const AGENT_COLORS: Record = { deepseek: "bg-[#4D6BFE]", qoder: "bg-[#6C4CF1]", antigravity: "bg-[#1A73E8]", + kiro: "bg-[#9046FF]", } // ACP connection status (matches Rust ConnectionStatus) @@ -3572,6 +3577,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, @@ -4280,6 +4294,7 @@ export type McpAppType = | "deepseek" | "qoder" | "antigravity" + | "kiro" | "pi" export interface LocalMcpServer {
🧩 一個介面,容納所有智慧體
內建十五個智慧體,任何相容 ACP 的智慧體也都能加入——全部呈現為同一套結構化對話,而不是一個終端機。
🧩 一個介面,容納所有智慧體
內建十六個智慧體,任何相容 ACP 的智慧體也都能加入——全部呈現為同一套結構化對話,而不是一個終端機。
🔎 工作階段自由流轉
匯入、搜尋、接續每個智慧體留在磁碟上的歷史,還能把它交給另一個智慧體接手。