diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml
index d5935c2275..01a7ae28be 100644
--- a/.github/workflows/release-candidate.yml
+++ b/.github/workflows/release-candidate.yml
@@ -88,6 +88,13 @@ jobs:
# resolve already proved expected_sha equals GITHUB_SHA. Do not
# interpolate that SHA into checkout or the npm cache key.
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
+ with:
+ # `check:facts` derives each model's `addedAt` from the commit date
+ # on which its id first appeared in the model declaration paths
+ # (web/scripts/facts-lib.mjs). A shallow checkout collapses every
+ # date to the tip commit and the committed facts always read as
+ # stale; web.yml and ci.yml pin depth 0 for the same reason.
+ fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2ae3eff089..3a0ee9036b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -149,6 +149,17 @@ tag, packages, checksums and release assets exist.
### Changed
+- Extensions keeps the exact-content plugin review on the panel: confirming
+ a bundle's digest re-reads the inventory, so the row you just reviewed
+ reports its new trust state and offers Enable instead of leaving you in
+ the transcript with a stale "not reviewed" row.
+- Underwater motion ticks at the cadence the frame limiter actually draws
+ (the atmosphere interval while only the water moves, the authored 80 ms
+ ocean cadence inside the interactive cap while a turn streams), and the
+ event loop wakes exactly for the next tick instead of on the next idle
+ poll. Idle water no longer requests frames it cannot draw or quantizes its
+ cadence to the poll interval; reduced motion, Ghostty, tmux and the
+ six-second idle settle are unchanged.
- The launcher keeps the Codewhale mark while balancing its layout above the
composer. A single cursor identifies the selected action; MCP faults retain
their warning color even in compact terminals. Recent-session counts now
@@ -234,6 +245,12 @@ tag, packages, checksums and release assets exist.
### Fixed
+- Configuration parsing keeps the parsed base config boxed, so loading a
+ profile no longer carries the full `Config` by value through the
+ deserializer and overflows a default 2 MiB test-thread stack; the
+ runtime-store binding test that also overflowed is split into phases and
+ pinned to that budget so CI's larger stack cannot mask a regression
+ ([#6362](https://github.com/Hmbown/Codewhale/issues/6362)).
- Stopping a turn revokes its pending approvals. A late approval cannot resume
the cancelled action or save an automatic approval for later turns.
- Expanding and collapsing selected reasoning now matches its rendered state
diff --git a/README.ar.md b/README.ar.md
index a9ebd74fc2..d6691f68a7 100644
--- a/README.ar.md
+++ b/README.ar.md
@@ -1,11 +1,11 @@
-
+
# Codewhale
Codewhale وكيل مفتوح المصدر يقرأ مشروعك ويعدّل الملفات ويشغّل الأوامر ويتحقق من عمله باستخدام نموذج مستضاف أو محلي تختاره. ابدأ بمهمة واحدة في الطرفية. وللأعمال الأكبر، وزّع أجزاء العمل على وكلاء بنماذج وأدوار مختلفة.
-
+
-*معاينة للطرفية من بنية تطوير للإصدار v0.9.12.*
+*معاينة للطرفية من بنية تطوير للإصدار v0.10.0.*
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [Català](README.ca.md)
diff --git a/README.ca.md b/README.ca.md
index 3603e1497a..478fe0aac4 100644
--- a/README.ca.md
+++ b/README.ca.md
@@ -1,11 +1,11 @@
-
+
# Codewhale
Codewhale és un agent de codi obert que llegeix el teu projecte, edita fitxers, executa ordres i comprova la seva feina amb un model allotjat o local que tu tries. Comença amb una tasca al terminal. Per a una feina més gran, assigna parts de la feina a agents amb models i rols diferents.
-
+
-*Previsualització del terminal d’una compilació de desenvolupament de la v0.9.12.*
+*Previsualització del terminal d’una compilació de desenvolupament de la v0.10.0.*
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md)
diff --git a/README.de.md b/README.de.md
index c32de36c08..10d2635c0d 100644
--- a/README.de.md
+++ b/README.de.md
@@ -1,11 +1,11 @@
-
+
# Codewhale
Codewhale ist ein Open-Source-Agent, der dein Projekt liest, Dateien bearbeitet, Befehle ausführt und seine Arbeit mit einem gehosteten oder lokalen Modell deiner Wahl prüft. Starte mit einer Aufgabe im Terminal. Teile eine größere Aufgabe auf Agenten mit verschiedenen Modellen und Rollen auf.
-
+
-*Terminalvorschau aus einem Entwicklungsbuild von v0.9.12.*
+*Terminalvorschau aus einem Entwicklungsbuild von v0.10.0.*
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
diff --git a/README.es-419.md b/README.es-419.md
index 53203b38db..87732bdaee 100644
--- a/README.es-419.md
+++ b/README.es-419.md
@@ -1,11 +1,11 @@
-
+
# Codewhale
Codewhale es un agente de código abierto que lee tu proyecto, edita archivos, ejecuta comandos y comprueba su trabajo con un modelo alojado o local que tú eliges. Empieza con una tarea en la terminal. Para un trabajo más grande, asigna partes del trabajo a agentes con distintos modelos y roles.
-
+
-*Vista previa de la terminal de una compilación de desarrollo de v0.9.12.*
+*Vista previa de la terminal de una compilación de desarrollo de v0.10.0.*
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
diff --git a/README.fr.md b/README.fr.md
index 86be4b1170..ffad2a132e 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -1,11 +1,11 @@
-
+
# Codewhale
Codewhale est un agent open source qui lit votre projet, modifie des fichiers, exécute des commandes et vérifie son travail avec un modèle hébergé ou local de votre choix. Commencez par une tâche dans votre terminal. Pour un travail plus important, confiez-en des parties à des agents utilisant différents modèles et rôles.
-
+
-*Aperçu du terminal dans une version de développement de la v0.9.12.*
+*Aperçu du terminal dans une version de développement de la v0.10.0.*
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
diff --git a/README.hi.md b/README.hi.md
index a9385b6abb..6cf000fa83 100644
--- a/README.hi.md
+++ b/README.hi.md
@@ -1,11 +1,11 @@
-
+
# Codewhale
Codewhale एक ओपन सोर्स एजेंट है जो आपकी पसंद के होस्ट किए गए या लोकल मॉडल से आपका प्रोजेक्ट पढ़ता है, फ़ाइलें संपादित करता है, कमांड चलाता है और अपने काम की जाँच करता है। टर्मिनल में एक काम से शुरुआत करें। बड़े काम के हिस्से अलग-अलग मॉडल और भूमिकाओं वाले एजेंटों को सौंपें।
-
+
-*v0.9.12 के विकासाधीन बिल्ड से टर्मिनल का पूर्वावलोकन।*
+*v0.10.0 के विकासाधीन बिल्ड से टर्मिनल का पूर्वावलोकन।*
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
diff --git a/README.id.md b/README.id.md
index 8054f7f196..6b00ecb2f2 100644
--- a/README.id.md
+++ b/README.id.md
@@ -1,11 +1,11 @@
-
+
# Codewhale
Codewhale adalah agen sumber terbuka yang membaca proyek, mengedit berkas, menjalankan perintah, dan memeriksa hasil kerjanya dengan model yang dihosting atau model lokal pilihan Anda. Mulailah dengan satu tugas di terminal. Untuk pekerjaan yang lebih besar, bagikan sebagian pekerjaan kepada agen dengan model dan peran yang berbeda.
-
+
-*Pratinjau terminal dari build pengembangan v0.9.12.*
+*Pratinjau terminal dari build pengembangan v0.10.0.*
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
diff --git a/README.it.md b/README.it.md
index 9d4d84d7b4..d9010b5069 100644
--- a/README.it.md
+++ b/README.it.md
@@ -1,11 +1,11 @@
-
+
# Codewhale
Codewhale è un agente open source che legge il tuo progetto, modifica file, esegue comandi e verifica il proprio lavoro usando un modello ospitato o locale a tua scelta. Parti da un’attività nel terminale. Per un lavoro più grande, assegna parti del lavoro ad agenti con modelli e ruoli diversi.
-
+
-*Anteprima del terminale da una build di sviluppo della v0.9.12.*
+*Anteprima del terminale da una build di sviluppo della v0.10.0.*
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
diff --git a/README.ja-JP.md b/README.ja-JP.md
index a0f8b80b5a..ae43e22cd8 100644
--- a/README.ja-JP.md
+++ b/README.ja-JP.md
@@ -1,11 +1,11 @@
-
+
# Codewhale
Codewhale は、選んだホスト型またはローカルのモデルを使ってプロジェクトを読み、ファイルを編集し、コマンドを実行して、自分の作業結果を確認するオープンソースのエージェントです。まずはターミナルで一つのタスクから始めましょう。大きな仕事では、異なるモデルや役割を持つエージェントに作業の一部を分担させられます。
-
+
-*v0.9.12 の開発ビルドによるターミナルのプレビュー。*
+*v0.10.0 の開発ビルドによるターミナルのプレビュー。*
[English](README.md) · [简体中文](README.zh-CN.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
diff --git a/README.ko-KR.md b/README.ko-KR.md
index f35321c4f5..1f444d2097 100644
--- a/README.ko-KR.md
+++ b/README.ko-KR.md
@@ -1,11 +1,11 @@
-
+
# Codewhale
Codewhale은 사용자가 선택한 호스팅 모델이나 로컬 모델로 프로젝트를 읽고, 파일을 편집하고, 명령을 실행하며, 작업 결과를 확인하는 오픈 소스 에이전트입니다. 터미널에서 하나의 작업으로 시작하세요. 더 큰 작업은 서로 다른 모델과 역할을 가진 에이전트에게 나누어 맡길 수 있습니다.
-
+
-*v0.9.12 개발 빌드의 터미널 미리보기입니다.*
+*v0.10.0 개발 빌드의 터미널 미리보기입니다.*
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
diff --git a/README.md b/README.md
index a2959c28eb..e405c5c6bf 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ agents with different models and roles.
-*Terminal preview from a v0.9.12 development build.*
+*Terminal preview from a v0.10.0 development build.*
## Install
diff --git a/README.pl.md b/README.pl.md
index 4012a9810c..0222c3d6e2 100644
--- a/README.pl.md
+++ b/README.pl.md
@@ -1,11 +1,11 @@
-
+
# Codewhale
Codewhale to agent o otwartym kodzie źródłowym, który czyta Twój projekt, edytuje pliki, wykonuje polecenia i sprawdza swoją pracę przy użyciu wybranego przez Ciebie modelu hostowanego lub lokalnego. Zacznij od jednego zadania w terminalu. Przy większej pracy powierz jej części agentom korzystającym z różnych modeli i pełniącym różne role.
-
+
-*Podgląd terminala z rozwojowej kompilacji v0.9.12.*
+*Podgląd terminala z rozwojowej kompilacji v0.10.0.*
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [العربية](README.ar.md) · [Català](README.ca.md)
diff --git a/README.pt-BR.md b/README.pt-BR.md
index 8b5df1d1b7..b9d0482e3d 100644
--- a/README.pt-BR.md
+++ b/README.pt-BR.md
@@ -1,11 +1,11 @@
-
+
# Codewhale
Codewhale é um agente de código aberto que lê seu projeto, edita arquivos, executa comandos e verifica o próprio trabalho usando um modelo hospedado ou local à sua escolha. Comece com uma tarefa no terminal. Para um trabalho maior, distribua partes do trabalho entre agentes com diferentes modelos e funções.
-
+
-*Prévia do terminal em uma build de desenvolvimento da v0.9.12.*
+*Prévia do terminal em uma build de desenvolvimento da v0.10.0.*
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
diff --git a/README.ru.md b/README.ru.md
index 391eddc85c..0db0e7c00f 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -1,11 +1,11 @@
-
+
# Codewhale
Codewhale — агент с открытым исходным кодом, который читает ваш проект, редактирует файлы, выполняет команды и проверяет свою работу с помощью выбранной вами облачной или локальной модели. Начните с одной задачи в терминале. Для большой работы поручайте её части агентам с разными моделями и ролями.
-
+
-*Предварительный вид терминала из сборки v0.9.12, находившейся в разработке.*
+*Предварительный вид терминала из сборки v0.10.0, находившейся в разработке.*
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
diff --git a/README.tr.md b/README.tr.md
index d4460275fc..11685cfa37 100644
--- a/README.tr.md
+++ b/README.tr.md
@@ -1,11 +1,11 @@
-
+
# Codewhale
Codewhale, seçtiğiniz barındırılan veya yerel bir modeli kullanarak projenizi okuyan, dosyaları düzenleyen, komutları çalıştıran ve yaptığı işi kontrol eden açık kaynaklı bir ajandır. Terminalde tek bir görevle başlayın. Daha büyük bir işte, işin bölümlerini farklı model ve rollere sahip ajanlara verin.
-
+
-*v0.9.12 geliştirme derlemesinden terminal önizlemesi.*
+*v0.10.0 geliştirme derlemesinden terminal önizlemesi.*
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
diff --git a/README.uk.md b/README.uk.md
index 99e99bb6d1..ef4618bcbe 100644
--- a/README.uk.md
+++ b/README.uk.md
@@ -1,11 +1,11 @@
-
+
# Codewhale
Codewhale — агент із відкритим кодом, який читає ваш проєкт, редагує файли, виконує команди й перевіряє свою роботу за допомогою обраної вами хмарної або локальної моделі. Почніть з одного завдання в терміналі. Для великої роботи доручайте її частини агентам із різними моделями й ролями.
-
+
-*Попередній вигляд термінала зі збірки v0.9.12, що перебувала в розробці.*
+*Попередній вигляд термінала зі збірки v0.10.0, що перебувала в розробці.*
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
diff --git a/README.vi.md b/README.vi.md
index 9947a3a367..987ec25e7a 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -1,11 +1,11 @@
-
+
# Codewhale
Codewhale là tác nhân mã nguồn mở có thể đọc dự án, chỉnh sửa tệp, chạy lệnh và kiểm tra công việc của mình bằng mô hình do nhà cung cấp lưu trữ hoặc mô hình cục bộ mà bạn chọn. Hãy bắt đầu với một tác vụ trong terminal. Với công việc lớn hơn, bạn có thể giao từng phần cho các tác nhân dùng mô hình và đảm nhiệm vai trò khác nhau.
-
+
-*Hình xem trước terminal từ bản dựng phát triển v0.9.12.*
+*Hình xem trước terminal từ bản dựng phát triển v0.10.0.*
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 2ace516476..ad7b20caca 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -1,11 +1,11 @@
-
+
# Codewhale
Codewhale 是一款开源智能体,可使用你选择的托管模型或本地模型读取项目、编辑文件、运行命令并检查自己的工作。从终端中的一项任务开始。对于较大的工作,可以将其中的部分任务交给使用不同模型、承担不同角色的智能体。
-
+
-*终端预览截图来自 v0.9.12 的开发构建。*
+*终端预览截图来自 v0.10.0 的开发构建。*
[English](README.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
diff --git a/README.zh-TW.md b/README.zh-TW.md
index a40866392c..cca298d3e7 100644
--- a/README.zh-TW.md
+++ b/README.zh-TW.md
@@ -1,11 +1,11 @@
-
+
# Codewhale
Codewhale 是一款開源代理,可使用你選擇的託管模型或本機模型讀取專案、編輯檔案、執行指令,並檢查自己的工作。從終端機中的一項任務開始。對於較大的工作,可以將部分任務交給使用不同模型、擔任不同角色的代理。
-
+
-*終端機預覽截圖來自 v0.9.12 的開發建置版本。*
+*終端機預覽截圖來自 v0.10.0 的開發建置版本。*
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
diff --git a/crates/protocol/src/runtime/mod.rs b/crates/protocol/src/runtime/mod.rs
index 280afaabd9..896117cc23 100644
--- a/crates/protocol/src/runtime/mod.rs
+++ b/crates/protocol/src/runtime/mod.rs
@@ -118,6 +118,24 @@ pub struct RuntimeCapabilities {
/// Durable, workspace-scoped cross-task Agent Mail endpoints and events.
#[serde(default)]
pub agent_mail: bool,
+ /// `GET /v1/terminal/{name}/output` — the resumable byte stream over a
+ /// persistent Engine-owned terminal session, with absolute cursors.
+ #[serde(default)]
+ pub terminal_stream: bool,
+ /// `POST /v1/terminal/{name}/input` — bytes into the live session.
+ #[serde(default)]
+ pub terminal_input: bool,
+ /// `POST /v1/terminal/{name}/resize` — the window the child draws for.
+ #[serde(default)]
+ pub terminal_resize: bool,
+ /// `POST /v1/terminal/{name}/kill` — end the live session.
+ #[serde(default)]
+ pub terminal_kill: bool,
+ /// `GET /v1/threads/{id}/events` puts the durable `seq` on every journal
+ /// frame as the SSE `id:` and resumes from a `Last-Event-ID` header, so a
+ /// browser `EventSource` reconnects without a cursor in the query string.
+ #[serde(default)]
+ pub event_stream_resume: bool,
}
/// Experimental opt-in flags advertised by `GET /v1/runtime/info`.
@@ -425,6 +443,11 @@ mod tests {
skill_lifecycle: false,
plugin_management: false,
agent_mail: true,
+ terminal_stream: false,
+ terminal_input: false,
+ terminal_resize: false,
+ terminal_kill: false,
+ event_stream_resume: true,
};
let value = serde_json::to_value(&caps).unwrap();
let obj = value.as_object().unwrap();
diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md
index 04648830c7..0f70d07dea 100644
--- a/crates/tui/CHANGELOG.md
+++ b/crates/tui/CHANGELOG.md
@@ -149,6 +149,17 @@ tag, packages, checksums and release assets exist.
### Changed
+- Extensions keeps the exact-content plugin review on the panel: confirming
+ a bundle's digest re-reads the inventory, so the row you just reviewed
+ reports its new trust state and offers Enable instead of leaving you in
+ the transcript with a stale "not reviewed" row.
+- Underwater motion ticks at the cadence the frame limiter actually draws
+ (the atmosphere interval while only the water moves, the authored 80 ms
+ ocean cadence inside the interactive cap while a turn streams), and the
+ event loop wakes exactly for the next tick instead of on the next idle
+ poll. Idle water no longer requests frames it cannot draw or quantizes its
+ cadence to the poll interval; reduced motion, Ghostty, tmux and the
+ six-second idle settle are unchanged.
- The launcher keeps the Codewhale mark while balancing its layout above the
composer. A single cursor identifies the selected action; MCP faults retain
their warning color even in compact terminals. Recent-session counts now
@@ -234,6 +245,12 @@ tag, packages, checksums and release assets exist.
### Fixed
+- Configuration parsing keeps the parsed base config boxed, so loading a
+ profile no longer carries the full `Config` by value through the
+ deserializer and overflows a default 2 MiB test-thread stack; the
+ runtime-store binding test that also overflowed is split into phases and
+ pinned to that budget so CI's larger stack cannot mask a regression
+ ([#6362](https://github.com/Hmbown/Codewhale/issues/6362)).
- Stopping a turn revokes its pending approvals. A late approval cannot resume
the cancelled action or save an automatic approval for later turns.
- Expanding and collapsing selected reasoning now matches its rendered state
diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs
index 770d5ca2cc..2ffee10322 100644
--- a/crates/tui/src/config.rs
+++ b/crates/tui/src/config.rs
@@ -4096,8 +4096,12 @@ fn validate_model_context_windows(
#[derive(Debug, Clone, Deserialize, Default)]
struct ConfigFile {
+ /// Boxed so the parsed document never carries the multi-kilobyte
+ /// `Config` by value through `toml::de` and `apply_profile` frames. A
+ /// `#[tokio::test]` runs those frames on libtest's default 2 MiB stack,
+ /// which the by-value copies overflowed (#6362).
#[serde(flatten)]
- base: Config,
+ base: Box,
profiles: Option>,
}
@@ -11133,7 +11137,7 @@ fn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {
let profiles = config.profiles.as_ref();
match profiles.and_then(|profiles| profiles.get(profile_name)) {
Some(override_cfg) => {
- let mut merged = merge_config(config.base, override_cfg.clone());
+ let mut merged = merge_config(*config.base, override_cfg.clone());
apply_layer_root_model(&mut merged, override_cfg);
Ok(merged)
}
@@ -11153,7 +11157,7 @@ fn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {
}
}
} else {
- Ok(config.base)
+ Ok(*config.base)
}
}
@@ -11541,7 +11545,7 @@ fn load_single_config_file(path: &Path) -> Result {
codewhale_config::quote_os_path(path)
)
})?;
- Ok(parsed.base)
+ Ok(*parsed.base)
}
/// Build a one-line warning when top-level-only keys are nested under a section
diff --git a/crates/tui/src/config/tests.rs b/crates/tui/src/config/tests.rs
index 8de2d2d6c6..72fe7f8bdd 100644
--- a/crates/tui/src/config/tests.rs
+++ b/crates/tui/src/config/tests.rs
@@ -1398,14 +1398,14 @@ fn profile_hotbar_override_replaces_entire_user_list() {
},
);
let config = ConfigFile {
- base: Config {
+ base: Box::new(Config {
hotbar: Some(vec![codewhale_config::HotbarBindingToml {
slot: 1,
action: "mode.plan".to_string(),
label: Some("Plan".to_string()),
}]),
..Config::default()
- },
+ }),
profiles: Some(profiles),
};
@@ -1429,14 +1429,14 @@ fn profile_without_scenario() {
let mut profiles = HashMap::new();
profiles.insert("work".to_string(), Config::default());
let config = ConfigFile {
- base: Config {
+ base: Box::new(Config {
hotbar: Some(vec![codewhale_config::HotbarBindingToml {
slot: 1,
action: "mode.plan".to_string(),
label: None,
}]),
..Config::default()
- },
+ }),
profiles: Some(profiles),
};
@@ -1456,13 +1456,13 @@ fn profile_without_scenario() {
let mut profiles = HashMap::new();
profiles.insert("work".to_string(), Config::default());
let config = ConfigFile {
- base: Config {
+ base: Box::new(Config {
context: ContextConfig {
enabled: Some(true),
..Default::default()
},
..Default::default()
- },
+ }),
profiles: Some(profiles),
};
@@ -6913,7 +6913,7 @@ fn test_nonexistent_profile_error() {
let mut profiles = HashMap::new();
profiles.insert("work".to_string(), Config::default());
let config = ConfigFile {
- base: Config::default(),
+ base: Box::default(),
profiles: Some(profiles),
};
@@ -6924,10 +6924,47 @@ fn test_nonexistent_profile_error() {
assert!(message.contains("work"));
}
+/// #6362: `ConfigFile` keeps its base `Config` boxed. Parsing a document
+/// through the profile path used to carry the multi-kilobyte struct by value
+/// through the `toml::de` and `apply_profile` frames, which overflowed the
+/// 2 MiB stack libtest gives every test thread in debug builds and aborted
+/// the whole lib suite. Pin that budget explicitly: CI exports a larger
+/// `RUST_MIN_STACK`, so without this thread the regression would be masked.
+/// A regression here aborts the process with "has overflowed its stack",
+/// which is the reported symptom, not a panic.
+#[test]
+fn profile_document_parses_within_the_default_test_thread_stack() {
+ const DEFAULT_TEST_THREAD_STACK: usize = 2 * 1024 * 1024;
+ let document = r#"
+provider = "deepseek"
+approval_policy = "on-request"
+
+[tui]
+theme = "underwater"
+
+[profiles.work]
+approval_policy = "never"
+"#;
+ let handle = std::thread::Builder::new()
+ .name("config-default-test-stack".into())
+ .stack_size(DEFAULT_TEST_THREAD_STACK)
+ .spawn(move || {
+ let config =
+ Config::from_saved_document(document, Some("work")).expect("profile parses");
+ assert_eq!(config.approval_policy.as_deref(), Some("never"));
+ let base = Config::from_saved_document(document, None).expect("base parses");
+ assert_eq!(base.approval_policy.as_deref(), Some("on-request"));
+ })
+ .expect("spawn a 2 MiB test thread");
+ handle
+ .join()
+ .expect("config parsing must fit the default test thread stack");
+}
+
#[test]
fn test_profile_with_no_profiles_section() {
let config = ConfigFile {
- base: Config::default(),
+ base: Box::default(),
profiles: None,
};
@@ -7974,14 +8011,14 @@ fn profile_skills_config_merges_individual_fields() {
},
);
let config = ConfigFile {
- base: Config {
+ base: Box::new(Config {
skills: Some(SkillsConfig {
registry_url: Some("https://registry.example/skills.json".to_string()),
max_install_size_bytes: Some(1234),
..Default::default()
}),
..Default::default()
- },
+ }),
profiles: Some(profiles),
};
diff --git a/crates/tui/src/core/engine/approval.rs b/crates/tui/src/core/engine/approval.rs
index ed78ecf038..9abc15bdc4 100644
--- a/crates/tui/src/core/engine/approval.rs
+++ b/crates/tui/src/core/engine/approval.rs
@@ -14,6 +14,27 @@ use crate::tools::user_input::{UserInputRequest, UserInputResponse};
const USER_INPUT_TIMEOUT: Duration = Duration::from_secs(300);
+/// How often a parked wait says it is still parked.
+///
+/// A wait with no deadline and no periodic line is indistinguishable from a
+/// freeze (#6184): the approval card may never expire (only a top-of-stack view
+/// ticks), the turn wall clock is paused across this wait, and nothing else
+/// reports. This is the line that gives a stall a name. Tests drive it at a
+/// tiny interval so the real path can be observed without waiting a minute.
+#[cfg(not(test))]
+const WAIT_HEARTBEAT: Duration = Duration::from_secs(60);
+#[cfg(test)]
+const WAIT_HEARTBEAT: Duration = Duration::from_millis(50);
+
+/// The announcement a parked wait makes, in one place so the log line and the
+/// status event cannot drift apart.
+fn wait_announcement(what: &str, tool_id: &str, waited: Duration) -> String {
+ format!(
+ "Still waiting for {what} on `{tool_id}` after {}s — the turn is parked here until it is answered",
+ waited.as_secs()
+ )
+}
+
use super::Engine;
#[derive(Debug, Clone)]
@@ -166,8 +187,26 @@ impl Engine {
&mut self,
tool_id: &str,
) -> Result {
+ let started = std::time::Instant::now();
+ let mut heartbeat = tokio::time::interval(WAIT_HEARTBEAT);
+ heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
+ // The first tick completes immediately; consume it so the first
+ // announcement is a heartbeat later, not at the gate itself.
+ heartbeat.tick().await;
+ let mut announced = false;
loop {
tokio::select! {
+ _ = heartbeat.tick() => {
+ let waited = started.elapsed();
+ let message = wait_announcement("tool approval", tool_id, waited);
+ // Log every heartbeat; tell the user once, so a long park
+ // leaves a trail without filling the transcript.
+ tracing::warn!(tool_id, waited_secs = waited.as_secs(), "{message}");
+ if !announced {
+ announced = true;
+ let _ = self.tx_event.send(Event::Status { message }).await;
+ }
+ }
_ = self.cancel_token.cancelled() => {
let suffix = self.cancel_reason_suffix();
self.commit_approval_outcome(tool_id, ApprovalOutcome::Cancelled).await?;
@@ -233,8 +272,24 @@ impl Engine {
// #6003: `[tools] user_input_timeout_seconds` — absent uses the
// built-in default; an explicit 0 waits indefinitely.
let wait = self.config.user_input_timeout.unwrap_or(USER_INPUT_TIMEOUT);
+ let started = std::time::Instant::now();
+ let mut heartbeat = tokio::time::interval(WAIT_HEARTBEAT);
+ heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
+ heartbeat.tick().await;
+ let mut announced = false;
loop {
tokio::select! {
+ _ = heartbeat.tick() => {
+ // An indefinite wait (`user_input_timeout_seconds = 0`) is
+ // the case that needs this most: nothing else bounds it.
+ let waited = started.elapsed();
+ let message = wait_announcement("user input", tool_id, waited);
+ tracing::warn!(tool_id, waited_secs = waited.as_secs(), "{message}");
+ if !announced {
+ announced = true;
+ let _ = self.tx_event.send(Event::Status { message }).await;
+ }
+ }
_ = self.cancel_token.cancelled() => {
let suffix = self.cancel_reason_suffix();
return Err(ToolError::cancelled(
@@ -424,6 +479,87 @@ mod tests {
.expect("required approval event deadline")
}
+ /// #6184: a turn parked on an approval must say so. Before this the wait
+ /// had no engine-side deadline, no periodic line and no event, so a stalled
+ /// turn was indistinguishable from a working one until the user gave up.
+ #[tokio::test]
+ async fn a_parked_approval_announces_the_wait_instead_of_hanging_silently() {
+ let tmp = tempfile::tempdir().expect("fixture directory");
+ let mock = Arc::new(MockLlmClient::new(vec![counter_request(
+ false,
+ CURRENT_CALL,
+ )]));
+ let (mut engine, handle) = Engine::new_with_model_client(
+ EngineConfig {
+ workspace: tmp.path().to_path_buf(),
+ snapshots_enabled: false,
+ subagents_enabled: false,
+ terminal_chrome_enabled: false,
+ ..EngineConfig::default()
+ },
+ &Config::default(),
+ mock.clone(),
+ );
+ engine.session.approval_mode = ApprovalMode::Suggest;
+ engine.session.add_message(Message {
+ role: Role::User,
+ content: vec![ContentBlock::Text {
+ text: "Park on the approval gate.".into(),
+ cache_control: None,
+ }],
+ });
+ let mut registry = crate::tools::ToolRegistry::new(ToolContext::new(tmp.path()));
+ registry.register(Arc::new(ApprovalFixtureTool {
+ executions: Arc::new(AtomicUsize::new(0)),
+ claim_only: false,
+ }));
+ let catalog = registry.to_api_tools_with_cache(true);
+ let surface = ToolSurfacePolicy::new(
+ registry,
+ Some(catalog),
+ AppMode::Agent,
+ &engine.config.tools_always_load,
+ &[],
+ false,
+ None,
+ None,
+ Some(4),
+ engine.session.approval_mode,
+ crate::core::engine::tool_catalog::ToolMode::Direct,
+ );
+
+ let events = handle.rx_event.clone();
+ let task = tokio::spawn(async move {
+ engine
+ .run_turn(&mut TurnContext::new(8), surface, None, None)
+ .await
+ });
+
+ // Reach the gate and answer nothing: this is the park.
+ let _ = wait_for_fixture_approval(&events, CURRENT_CALL).await;
+
+ let announced = tokio::time::timeout(Duration::from_secs(5), async {
+ let mut rx = events.write().await;
+ while let Some(event) = rx.recv().await {
+ if let Event::Status { message } = &event
+ && message.contains("Still waiting for tool approval")
+ && message.contains(CURRENT_CALL)
+ {
+ return true;
+ }
+ }
+ false
+ })
+ .await
+ .expect("a parked approval must announce itself before anything else happens");
+ assert!(
+ announced,
+ "the announcement must name the wait and the tool it waits on"
+ );
+
+ task.abort();
+ }
+
async fn assert_required_fixture(source: ClaimSource, action: HostAction) {
let tmp = tempfile::tempdir().expect("fixture directory");
let full_access = matches!(action, HostAction::FullAccess);
diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs
index 643b1b7046..a400da2759 100644
--- a/crates/tui/src/core/engine/turn_loop.rs
+++ b/crates/tui/src/core/engine/turn_loop.rs
@@ -688,6 +688,15 @@ impl Engine {
// Only interactive TUI hosts own terminal chrome. Headless exec,
// app-server, and stream-json stdout must remain byte-clean.
+ //
+ // The sleep guard rides the same gate: a turn that outlives the host's
+ // idle timer is lost work, and an interactive host is the only one
+ // that owns a human's machine. Bound to this function, so it releases
+ // on every return path. See `crate::sleep_guard` for its limits.
+ let _sleep_guard = self
+ .config
+ .terminal_chrome_enabled
+ .then(crate::sleep_guard::SleepGuard::hold);
if self.config.terminal_chrome_enabled {
crate::tui::notifications::set_taskbar_progress_busy();
crate::tui::notifications::start_title_animation("codewhale");
diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs
index a14ff45df1..977a99b28e 100644
--- a/crates/tui/src/lib.rs
+++ b/crates/tui/src/lib.rs
@@ -132,6 +132,7 @@ mod settings;
mod shell_dispatcher;
mod skill_state;
mod skills;
+mod sleep_guard;
mod snapshot;
mod startup_trace;
mod task_manager;
diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs
index 6bd44ef624..fbcc151ad4 100644
--- a/crates/tui/src/runtime_api.rs
+++ b/crates/tui/src/runtime_api.rs
@@ -12,7 +12,7 @@ use anyhow::{Context, Result, anyhow, bail};
use async_stream::stream;
use axum::extract::{ConnectInfo, DefaultBodyLimit, Path, Query, Request, State};
use axum::http::header;
-use axum::http::{HeaderName, HeaderValue, Method, StatusCode};
+use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
use axum::middleware;
use axum::response::Html;
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
@@ -109,6 +109,7 @@ mod plugins;
mod secrets;
mod sessions;
mod targets;
+mod terminal;
mod voice;
mod web;
mod workspace;
@@ -589,6 +590,16 @@ fn default_runtime_capabilities() -> RuntimeCapabilities {
skill_lifecycle: true,
plugin_management: true,
agent_mail: true,
+ // SSE journal frames carry their durable `seq` as the event id, and the
+ // thread event stream resumes from `Last-Event-ID`.
+ event_stream_resume: true,
+ // The terminal family is Unix-only in this build: the owner is
+ // `#[cfg(unix)]` end to end and the Windows routes answer 501. A
+ // client must be able to feature-detect that before it offers a pane.
+ terminal_stream: cfg!(unix),
+ terminal_input: cfg!(unix),
+ terminal_resize: cfg!(unix),
+ terminal_kill: cfg!(unix),
}
}
@@ -872,6 +883,16 @@ struct FleetEventsQuery {
struct StartTurnResponse {
thread: ThreadRecord,
turn: TurnRecord,
+ /// Present only when the durable `operation_key` made this submission a
+ /// replay of one already accepted: the turn is the original and nothing
+ /// new was admitted. Omitted otherwise so every existing response stays
+ /// byte-identical — a client that never sends a key sees no change.
+ #[serde(skip_serializing_if = "replay_flag_is_absent")]
+ idempotent_replay: bool,
+}
+
+fn replay_flag_is_absent(replayed: &bool) -> bool {
+ !*replayed
}
fn install_runtime_server_workshop_budgets(
@@ -1153,6 +1174,15 @@ pub fn build_router(state: RuntimeApiState) -> Router {
get(read_session_artifact),
)
.route("/v1/workspace/status", get(workspace_status))
+ // The Engine's terminal byte stream (#34). Auth is the route layer's,
+ // not this module's; these never create a session — see terminal.rs.
+ .route("/v1/terminal/{name}/output", get(terminal::terminal_output))
+ .route("/v1/terminal/{name}/input", post(terminal::terminal_input))
+ .route(
+ "/v1/terminal/{name}/resize",
+ post(terminal::terminal_resize),
+ )
+ .route("/v1/terminal/{name}/kill", post(terminal::terminal_kill))
.route("/v1/workspace/files/search", get(workspace_file_search))
.route(
"/v1/workspace/files",
@@ -5676,9 +5706,9 @@ async fn start_thread_turn(
Path(id): Path,
Json(req): Json,
) -> Result<(StatusCode, Json), ApiError> {
- let turn = state
+ let (turn, replayed) = state
.runtime_threads
- .start_turn(&id, req)
+ .start_turn_reporting_replay(&id, req)
.await
.map_err(map_thread_err)?;
let thread = state
@@ -5686,9 +5716,22 @@ async fn start_thread_turn(
.get_thread(&id)
.await
.map_err(map_thread_err)?;
+ // A replay acknowledges work already accepted rather than admitting new
+ // work: 200 tells the client "this is the turn I already started", which
+ // is what lets an ambiguous submit resolve without duplicate messages or
+ // tools. A fresh admission stays 201.
+ let status = if replayed {
+ StatusCode::OK
+ } else {
+ StatusCode::CREATED
+ };
Ok((
- StatusCode::CREATED,
- Json(StartTurnResponse { thread, turn }),
+ status,
+ Json(StartTurnResponse {
+ thread,
+ turn,
+ idempotent_replay: replayed,
+ }),
))
}
@@ -5871,7 +5914,11 @@ async fn compact_thread(
.map_err(map_thread_err)?;
Ok((
StatusCode::ACCEPTED,
- Json(StartTurnResponse { thread, turn }),
+ Json(StartTurnResponse {
+ thread,
+ turn,
+ idempotent_replay: false,
+ }),
))
}
@@ -6152,6 +6199,7 @@ async fn stream_thread_events(
State(state): State,
Path(id): Path,
Query(query): Query,
+ headers: HeaderMap,
) -> Result {
let _ = state
.runtime_threads
@@ -6159,6 +6207,14 @@ async fn stream_thread_events(
.await
.map_err(map_thread_err)?;
+ // Two clients, two cursors. A browser `EventSource` can only replay through
+ // the `Last-Event-ID` header it sets on reconnect (the ids now ride the
+ // journal frames below); every other client passes `since_seq`. An explicit
+ // query cursor wins over the header, so a deliberate replay-from-zero is
+ // never silently overridden by a stale header — the header is the fallback
+ // when no cursor was asked for.
+ let since_seq = query.since_seq.or_else(|| last_event_id(&headers));
+
// Subscribe before reading durable history. An event emitted while replay
// is loaded is then present in both places (and deduped below) or queued
// live, never in an uncovered handoff window.
@@ -6173,7 +6229,7 @@ async fn stream_thread_events(
}
let replay = state
.runtime_threads
- .replay_events(&id, query.since_seq, query.replay_limit)
+ .replay_events(&id, since_seq, query.replay_limit)
.await
.map_err(|e| ApiError::internal(e.to_string()))?;
@@ -6246,7 +6302,8 @@ fn replay_live_thread_events(
yield Ok(sse_json(
&event_name,
runtime_event_payload_with_previous(event, previous_seq),
- ));
+ )
+ .id(last_seq.to_string()));
}
}
@@ -6280,7 +6337,8 @@ fn replay_live_thread_events(
yield Ok(sse_json(
&event_name,
runtime_event_payload_with_previous(event, previous_seq),
- ));
+ )
+ .id(last_seq.to_string()));
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
if progress {
@@ -6330,7 +6388,8 @@ fn replay_live_thread_events(
yield Ok(sse_json(
&event_name,
runtime_event_payload_with_previous(event, previous_seq),
- ));
+ )
+ .id(last_seq.to_string()));
}
}
}
@@ -6866,6 +6925,19 @@ fn sse_json(event: &str, payload: serde_json::Value) -> SseEvent {
SseEvent::default().event(event).data(data)
}
+/// Read a `Last-Event-ID` cursor off the request.
+///
+/// Only a decimal sequence number is ours. Anything else is ignored rather
+/// than rejected: an opaque id from a proxy or an older client should start
+/// the stream from the durable head, not fail to open it — a refused stream
+/// looks like an outage to a reconnecting client.
+fn last_event_id(headers: &HeaderMap) -> Option {
+ headers
+ .get("last-event-id")
+ .and_then(|value| value.to_str().ok())
+ .and_then(|value| value.trim().parse::().ok())
+}
+
fn truncate_text(text: &str, max_chars: usize) -> String {
let char_count = text.chars().count();
if char_count <= max_chars {
diff --git a/crates/tui/src/runtime_api/terminal.rs b/crates/tui/src/runtime_api/terminal.rs
new file mode 100644
index 0000000000..9be0f4a805
--- /dev/null
+++ b/crates/tui/src/runtime_api/terminal.rs
@@ -0,0 +1,404 @@
+//! `/v1/terminal/{name}` — the Engine's terminal byte stream.
+//!
+//! The owner is [`crate::tools::terminal_session`]: the same PTY-backed shell
+//! the agent's terminal tools drive, so a client attaching here sees the
+//! session the model is already working in rather than a second shell beside
+//! it. These routes never create a session — a name with no live session is a
+//! 404, because conjuring a shell from an HTTP request would give the app a
+//! terminal the Engine does not know about.
+//!
+//! Authentication is the `/v1` route layer's bearer token (see
+//! [`super::auth`]); nothing here re-implements or bypasses it.
+//!
+//! Wire shape deliberately follows `/v1/threads/{id}/jobs/{job_id}/output`:
+//! `cursor` / `max_bytes` / `format` in, `offset` / `next_cursor` / `total` /
+//! `dropped` out. Two byte streams in one product should not speak two
+//! dialects.
+//!
+//! Known limitations, recorded because a reader will otherwise assume them:
+//!
+//! - **No long poll.** `wait_ms` is not accepted; a client polls the cursor.
+//! The jobs route can block because a job owns a notification; a terminal
+//! session's ring has no wake-up channel yet, and inventing one here would
+//! be a second mechanism rather than a reuse.
+//! - **No scrollback recovery.** `dropped` reports what the 512 KiB ring
+//! discarded; those bytes are gone with the process, not on disk.
+//! - **Live sessions only.** Persistence is identity and lifecycle, never
+//! output, so a restarted Engine reports no session rather than pretending to
+//! reattach (#34 acceptance: "Restart truthfully reports lost live PTYs").
+//! - **Unix only.** The owner is `#[cfg(all(unix, not(target_env = "ohos")))]` end to end; on Windows these
+//! routes do not exist yet. ConPTY qualification is its own slice.
+
+use axum::Json;
+use axum::extract::{Path, Query, State};
+#[cfg(all(unix, not(target_env = "ohos")))]
+use base64::Engine as _;
+use serde::{Deserialize, Serialize};
+
+// The owner does not exist on ohos (`tools/mod.rs`), so neither does any
+// handler that drives it; the stubs below answer there instead.
+#[cfg(all(unix, not(target_env = "ohos")))]
+use crate::tools::terminal_session;
+
+use super::{ApiError, RuntimeApiState};
+
+/// Default per-response ceiling; the owner clamps to its own `READ_LIMIT`.
+#[cfg(all(unix, not(target_env = "ohos")))]
+const TERMINAL_CHUNK_DEFAULT: usize = 64 * 1024;
+/// Session names come from the agent's tools; this only bounds the echo.
+#[cfg(all(unix, not(target_env = "ohos")))]
+const TERMINAL_NAME_MAX_BYTES: usize = 128;
+/// One input frame. Interactive typing is bytes, not uploads.
+#[cfg(all(unix, not(target_env = "ohos")))]
+const TERMINAL_INPUT_MAX_BYTES: usize = 64 * 1024;
+#[cfg(all(unix, not(target_env = "ohos")))]
+const TERMINAL_DIMENSION_MAX: u16 = 1000;
+
+// The request shape is the contract on every platform; only the Unix
+// handlers read it, so the 501 builds expect the fields to stay unread.
+#[cfg_attr(any(not(unix), target_env = "ohos"), expect(dead_code))]
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(super) struct TerminalOutputQuery {
+ /// Absolute byte offset into the session's lifetime output.
+ #[serde(default)]
+ cursor: Option,
+ /// Per-response byte ceiling, default 64 KiB, clamped by the owner.
+ #[serde(default)]
+ max_bytes: Option,
+ /// `base64` (default, exact bytes) or `text` (lossy UTF-8).
+ #[serde(default)]
+ format: Option,
+}
+
+#[derive(Debug, Serialize)]
+pub(super) struct TerminalOutputResponse {
+ name: String,
+ /// Absolute offset of `data[0]`; exceeds `cursor` when the ring already
+ /// discarded that prefix (`dropped` reports the cutoff).
+ offset: u64,
+ /// Pass back as `cursor` to continue.
+ next_cursor: u64,
+ /// Everything the session has produced, including discarded bytes.
+ total: u64,
+ /// Leading bytes the bounded ring permanently discarded.
+ dropped: u64,
+ encoding: &'static str,
+ data: String,
+ /// False once the shell has exited and no bytes remain past `next_cursor`.
+ running: bool,
+ exit_code: Option,
+}
+
+#[cfg_attr(any(not(unix), target_env = "ohos"), expect(dead_code))]
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(super) struct TerminalInputRequest {
+ data: String,
+ /// `base64` (default, exact bytes) or `text`.
+ #[serde(default)]
+ encoding: Option,
+}
+
+#[derive(Debug, Serialize)]
+pub(super) struct TerminalWriteResponse {
+ name: String,
+ written: usize,
+}
+
+#[cfg_attr(any(not(unix), target_env = "ohos"), expect(dead_code))]
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(super) struct TerminalResizeRequest {
+ rows: u16,
+ cols: u16,
+}
+
+#[derive(Debug, Serialize)]
+pub(super) struct TerminalResizeResponse {
+ name: String,
+ rows: u16,
+ cols: u16,
+}
+
+#[derive(Debug, Serialize)]
+pub(super) struct TerminalKillResponse {
+ name: String,
+ killed: bool,
+}
+
+/// `base64` keeps bytes exact; `text` is the lossy convenience form.
+#[cfg(all(unix, not(target_env = "ohos")))]
+fn chunk_encoding(format: &str) -> Result<&'static str, ApiError> {
+ match format {
+ "base64" => Ok("base64"),
+ "text" => Ok("text"),
+ _ => Err(ApiError::bad_request("format must be base64 or text")),
+ }
+}
+
+#[cfg(all(unix, not(target_env = "ohos")))]
+fn encode_bytes(bytes: &[u8], encoding: &str) -> String {
+ if encoding == "base64" {
+ base64::engine::general_purpose::STANDARD.encode(bytes)
+ } else {
+ String::from_utf8_lossy(bytes).into_owned()
+ }
+}
+
+#[cfg(all(unix, not(target_env = "ohos")))]
+fn decode_bytes(data: &str, encoding: &str) -> Result, ApiError> {
+ let bytes = match encoding {
+ "base64" => base64::engine::general_purpose::STANDARD
+ .decode(data)
+ .map_err(|_| ApiError::bad_request("data is not valid base64"))?,
+ "text" => data.as_bytes().to_vec(),
+ _ => return Err(ApiError::bad_request("encoding must be base64 or text")),
+ };
+ if bytes.len() > TERMINAL_INPUT_MAX_BYTES {
+ return Err(ApiError::bad_request(format!(
+ "input exceeds {TERMINAL_INPUT_MAX_BYTES} bytes"
+ )));
+ }
+ Ok(bytes)
+}
+
+#[cfg(all(unix, not(target_env = "ohos")))]
+fn bounded_max_bytes(requested: Option) -> Result {
+ let max_bytes = requested.unwrap_or(TERMINAL_CHUNK_DEFAULT);
+ if !(1..=terminal_session::READ_LIMIT).contains(&max_bytes) {
+ return Err(ApiError::bad_request(format!(
+ "max_bytes must be between 1 and {}",
+ terminal_session::READ_LIMIT
+ )));
+ }
+ Ok(max_bytes)
+}
+
+#[cfg(all(unix, not(target_env = "ohos")))]
+fn bounded_dimension(value: u16, field: &str) -> Result {
+ if !(1..=TERMINAL_DIMENSION_MAX).contains(&value) {
+ return Err(ApiError::bad_request(format!(
+ "{field} must be between 1 and {TERMINAL_DIMENSION_MAX}"
+ )));
+ }
+ Ok(value)
+}
+
+/// Resolve a live session or 404. Never creates one — see the module docs.
+#[cfg(all(unix, not(target_env = "ohos")))]
+fn open_session(
+ state: &RuntimeApiState,
+ name: &str,
+) -> Result {
+ if name.is_empty() || name.len() > TERMINAL_NAME_MAX_BYTES {
+ return Err(ApiError::not_found("terminal session not found"));
+ }
+ terminal_session::lookup(name, &state.workspace)
+ .ok_or_else(|| ApiError::not_found(format!("no live terminal session named '{name}'")))
+}
+
+#[cfg(all(unix, not(target_env = "ohos")))]
+fn lock_session(
+ session: &terminal_session::SharedSession,
+) -> Result, ApiError> {
+ session
+ .lock()
+ .map_err(|_| ApiError::internal("terminal session lock poisoned"))
+}
+
+/// `GET /v1/terminal/{name}/output` — the resumable byte stream.
+///
+/// Reads are non-consuming: several clients may hold independent cursors, and
+/// polling here never steals output from the agent's own consuming read.
+#[cfg(all(unix, not(target_env = "ohos")))]
+pub(super) async fn terminal_output(
+ State(state): State,
+ Path(name): Path,
+ Query(query): Query,
+) -> Result, ApiError> {
+ let session = open_session(&state, &name)?;
+ let encoding = chunk_encoding(query.format.as_deref().unwrap_or("base64"))?;
+ let max_bytes = bounded_max_bytes(query.max_bytes)?;
+ let cursor = query.cursor.unwrap_or(0);
+ let mut guard = lock_session(&session)?;
+ let chunk = terminal_session::read_session_since(&guard, cursor, max_bytes)
+ .map_err(ApiError::internal)?;
+ let exit = terminal_session::session_exit_status(&mut guard).map_err(ApiError::internal)?;
+ let running = exit.is_none();
+ Ok(Json(TerminalOutputResponse {
+ name,
+ offset: chunk.offset,
+ next_cursor: chunk.next_cursor,
+ total: chunk.total,
+ dropped: chunk.dropped,
+ encoding,
+ data: encode_bytes(&chunk.bytes, encoding),
+ // A gap means bytes were lost; `running` alone must not imply there is
+ // nothing behind us, so drain state is reported independently.
+ running,
+ exit_code: exit.map(|status| i64::from(status.exit_code())),
+ }))
+}
+
+/// `POST /v1/terminal/{name}/input` — bytes into the live shell.
+///
+/// Input attribution is the caller's: this route is the client's writer, and
+/// the agent's writer is `terminal_send`. Nothing here re-labels one as the
+/// other.
+#[cfg(all(unix, not(target_env = "ohos")))]
+pub(super) async fn terminal_input(
+ State(state): State,
+ Path(name): Path,
+ Json(request): Json,
+) -> Result, ApiError> {
+ let session = open_session(&state, &name)?;
+ let bytes = decode_bytes(
+ &request.data,
+ request.encoding.as_deref().unwrap_or("base64"),
+ )?;
+ let guard = lock_session(&session)?;
+ terminal_session::write_bytes(&guard, &bytes).map_err(ApiError::internal)?;
+ Ok(Json(TerminalWriteResponse {
+ name,
+ written: bytes.len(),
+ }))
+}
+
+/// `POST /v1/terminal/{name}/resize` — the window the child should draw for.
+#[cfg(all(unix, not(target_env = "ohos")))]
+pub(super) async fn terminal_resize(
+ State(state): State,
+ Path(name): Path,
+ Json(request): Json,
+) -> Result, ApiError> {
+ let session = open_session(&state, &name)?;
+ let rows = bounded_dimension(request.rows, "rows")?;
+ let cols = bounded_dimension(request.cols, "cols")?;
+ let guard = lock_session(&session)?;
+ terminal_session::resize_session(&guard, rows, cols).map_err(ApiError::internal)?;
+ Ok(Json(TerminalResizeResponse { name, rows, cols }))
+}
+
+/// `POST /v1/terminal/{name}/kill` — end the shell.
+///
+/// The exit itself is observed through `output` (`running` / `exit_code`),
+/// so a client that kills and then polls learns the truth instead of an
+/// optimistic acknowledgement.
+#[cfg(all(unix, not(target_env = "ohos")))]
+pub(super) async fn terminal_kill(
+ State(state): State,
+ Path(name): Path,
+) -> Result, ApiError> {
+ let session = open_session(&state, &name)?;
+ let mut guard = lock_session(&session)?;
+ terminal_session::kill_session(&mut guard).map_err(ApiError::internal)?;
+ Ok(Json(TerminalKillResponse { name, killed: true }))
+}
+
+/// Windows build: the owner is `#[cfg(all(unix, not(target_env = "ohos")))]` end to end, so the contract
+/// exists but cannot be served. These answer 501 rather than 404 so a client
+/// can tell "this Engine build cannot do terminals" apart from "that session
+/// is gone" — and so the ConPTY slice has one place to replace.
+#[cfg(any(not(unix), target_env = "ohos"))]
+mod platform {
+ use super::*;
+
+ fn unsupported() -> ApiError {
+ ApiError::not_implemented(
+ "terminal sessions are Unix-only in this build; native Windows PTY support is not implemented yet",
+ )
+ }
+
+ pub(crate) async fn terminal_output(
+ State(_): State,
+ Path(_): Path,
+ Query(_): Query,
+ ) -> Result, ApiError> {
+ Err(unsupported())
+ }
+
+ pub(crate) async fn terminal_input(
+ State(_): State,
+ Path(_): Path,
+ Json(_): Json,
+ ) -> Result, ApiError> {
+ Err(unsupported())
+ }
+
+ pub(crate) async fn terminal_resize(
+ State(_): State,
+ Path(_): Path,
+ Json(_): Json,
+ ) -> Result, ApiError> {
+ Err(unsupported())
+ }
+
+ pub(crate) async fn terminal_kill(
+ State(_): State,
+ Path(_): Path,
+ ) -> Result, ApiError> {
+ Err(unsupported())
+ }
+}
+
+#[cfg(any(not(unix), target_env = "ohos"))]
+pub(super) use platform::{terminal_input, terminal_kill, terminal_output, terminal_resize};
+
+#[cfg(all(test, unix, not(target_env = "ohos")))]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn encodings_round_trip_exact_bytes_and_stay_lossy_only_on_request() {
+ // Non-UTF-8 bytes survive base64 and are the reason it is the default.
+ let raw = [0xf0, 0x9f, 0x90, 0x8b, 0x00, 0xff];
+ let encoded = encode_bytes(&raw, "base64");
+ assert_eq!(decode_bytes(&encoded, "base64").unwrap(), raw);
+ // The lossy form is explicit and cannot be mistaken for fidelity.
+ let text = encode_bytes(&raw, "text");
+ assert!(text.contains('\u{fffd}'));
+ assert_eq!(decode_bytes(&text, "text").unwrap(), text.as_bytes());
+ }
+
+ #[test]
+ fn encoding_names_are_closed_sets() {
+ for good in ["base64", "text"] {
+ assert_eq!(chunk_encoding(good).unwrap(), good);
+ }
+ for bad in ["utf8", "raw", "Base64", ""] {
+ assert!(chunk_encoding(bad).is_err(), "{bad} must not be accepted");
+ assert!(decode_bytes("", bad).is_err(), "{bad} must not decode");
+ }
+ // A base64 decoder that ignores padding would accept junk bytes.
+ assert!(decode_bytes("not base64!!", "base64").is_err());
+ }
+
+ #[test]
+ fn chunk_and_dimension_bounds_reject_the_edges() {
+ assert_eq!(bounded_max_bytes(None).unwrap(), TERMINAL_CHUNK_DEFAULT);
+ assert_eq!(
+ bounded_max_bytes(Some(terminal_session::READ_LIMIT)).unwrap(),
+ terminal_session::READ_LIMIT
+ );
+ assert!(bounded_max_bytes(Some(0)).is_err());
+ assert!(bounded_max_bytes(Some(terminal_session::READ_LIMIT + 1)).is_err());
+ assert_eq!(bounded_dimension(24, "rows").unwrap(), 24);
+ assert!(bounded_dimension(0, "rows").is_err());
+ assert!(bounded_dimension(TERMINAL_DIMENSION_MAX + 1, "cols").is_err());
+ }
+
+ #[test]
+ fn input_is_bounded_before_it_reaches_the_pty() {
+ let too_much =
+ base64::engine::general_purpose::STANDARD
+ .encode(vec![b'a'; TERMINAL_INPUT_MAX_BYTES + 1]);
+ assert!(decode_bytes(&too_much, "base64").is_err());
+ let at_limit =
+ base64::engine::general_purpose::STANDARD.encode(vec![b'a'; TERMINAL_INPUT_MAX_BYTES]);
+ assert_eq!(
+ decode_bytes(&at_limit, "base64").unwrap().len(),
+ TERMINAL_INPUT_MAX_BYTES
+ );
+ }
+}
diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs
index 9c7aafd41e..7de24bada7 100644
--- a/crates/tui/src/runtime_api/tests.rs
+++ b/crates/tui/src/runtime_api/tests.rs
@@ -3766,9 +3766,19 @@ async fn turn_endpoint_operation_key_returns_original_and_conflicts_on_mismatch(
assert!(!serde_json::to_string(&first)?.contains("cwc-http-operation-1"));
let replay_response = client.post(&url).json(&request).send().await?;
- assert_eq!(replay_response.status(), StatusCode::CREATED);
+ // A replay acknowledges work already accepted rather than admitting new
+ // work: 200 plus an explicit flag, so a client that retried an ambiguous
+ // submit can tell it is looking at the turn it already started (#76).
+ assert_eq!(replay_response.status(), StatusCode::OK);
let replay: serde_json::Value = replay_response.json().await?;
assert_eq!(replay["turn"]["id"], first_turn_id);
+ assert_eq!(replay["idempotent_replay"], true);
+ // A fresh admission carries no flag, so the response every existing client
+ // already parses is byte-identical to before.
+ assert!(
+ first.get("idempotent_replay").is_none(),
+ "only a replay is marked as one: {first}"
+ );
let mismatch = client
.post(&url)
@@ -4151,6 +4161,114 @@ async fn events_endpoint_respects_since_seq_cursor() -> Result<()> {
Ok(())
}
+/// The SSE `id:` a browser `EventSource` resumes from, and the `Last-Event-ID`
+/// header it replays with, against the same durable cursor `since_seq` uses.
+#[tokio::test]
+async fn thread_event_frames_carry_the_id_a_reconnect_resumes_from() -> Result<()> {
+ let Some((addr, runtime_threads, handle)) = spawn_test_server().await? else {
+ return Ok(());
+ };
+ let client = crate::tls::reqwest_client();
+ let thread = runtime_threads
+ .create_thread(CreateThreadRequest::default())
+ .await?;
+
+ // Every journal frame carries its durable seq as the SSE id.
+ let first = client
+ .get(format!(
+ "http://{addr}/v1/threads/{}/events?since_seq=0",
+ thread.id
+ ))
+ .send()
+ .await?
+ .error_for_status()?;
+ let frame = read_first_sse_frame(first).await?;
+ let (_event, payload) = parse_sse_frame(&frame)?;
+ let first_seq = payload
+ .get("seq")
+ .and_then(Value::as_u64)
+ .context("missing seq in first frame")?;
+ let id = frame
+ .lines()
+ .find_map(|line| line.strip_prefix("id:"))
+ .map(str::trim)
+ .context("SSE frames must carry an id, or nothing can resume")?
+ .to_string();
+ assert_eq!(
+ id.parse::()?,
+ first_seq,
+ "the id is the durable seq, not a frame counter"
+ );
+
+ // A second durable event, so a resume has somewhere to land.
+ let second_seq = runtime_threads
+ .emit_event_for_test(
+ &thread.id,
+ None,
+ "approval.required",
+ json!({"approval_id": "resume-proof", "tool_name": "exec_command"}),
+ )
+ .await?
+ .seq;
+ assert!(second_seq > first_seq, "the second event is later");
+
+ // The header alone is enough: a browser cannot set a query cursor.
+ let resumed = client
+ .get(format!("http://{addr}/v1/threads/{}/events", thread.id))
+ .header("Last-Event-ID", &id)
+ .send()
+ .await?
+ .error_for_status()?;
+ let frame = read_first_sse_frame(resumed).await?;
+ let (_event, payload) = parse_sse_frame(&frame)?;
+ assert_eq!(
+ payload.get("seq").and_then(Value::as_u64),
+ Some(second_seq),
+ "Last-Event-ID must resume past the acknowledged frame"
+ );
+
+ // An explicit `since_seq` outranks the header, so a deliberate
+ // replay-from-zero is never silently overridden by a stale id.
+ let explicit = client
+ .get(format!(
+ "http://{addr}/v1/threads/{}/events?since_seq=0",
+ thread.id
+ ))
+ .header("Last-Event-ID", &id)
+ .send()
+ .await?
+ .error_for_status()?;
+ let frame = read_first_sse_frame(explicit).await?;
+ let (_event, payload) = parse_sse_frame(&frame)?;
+ assert_eq!(
+ payload.get("seq").and_then(Value::as_u64),
+ Some(first_seq),
+ "the query cursor wins over the header"
+ );
+
+ handle.abort();
+ Ok(())
+}
+
+#[test]
+fn last_event_id_accepts_only_decimal_cursors() {
+ use super::last_event_id;
+
+ let mut headers = axum::http::HeaderMap::new();
+ assert_eq!(last_event_id(&headers), None, "absent header is no cursor");
+
+ headers.insert("last-event-id", "42".parse().unwrap());
+ assert_eq!(last_event_id(&headers), Some(42));
+
+ headers.insert("last-event-id", " 7 ".parse().unwrap());
+ assert_eq!(last_event_id(&headers), Some(7), "whitespace is trimmed");
+
+ // An opaque id from a proxy or an older client opens the stream from the
+ // durable head instead of refusing to open it at all.
+ headers.insert("last-event-id", "fev1_abcdef".parse().unwrap());
+ assert_eq!(last_event_id(&headers), None);
+}
+
#[tokio::test]
async fn event_handoff_replays_and_dedupes_interaction_prompts_without_a_gap() -> Result<()> {
let Some((_addr, runtime_threads, handle)) = spawn_test_server().await? else {
@@ -13764,6 +13882,209 @@ async fn runtime_info_advertises_plugin_management_capability() -> Result<()> {
Ok(())
}
+#[tokio::test]
+async fn runtime_info_advertises_terminal_capabilities() -> Result<()> {
+ let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
+ return Ok(());
+ };
+ let client = crate::tls::reqwest_client();
+
+ let info: serde_json::Value = client
+ .get(format!("http://{addr}/v1/runtime/info"))
+ .send()
+ .await?
+ .error_for_status()?
+ .json()
+ .await?;
+ // A GPUI client gates its terminal pane on these. They are true where the
+ // routes serve bytes and false where the owner is Unix-only — the flag
+ // must not claim a capability the build cannot serve, so assert the
+ // platform's truth rather than `true`.
+ let expected = cfg!(unix);
+ for capability in [
+ "terminal_stream",
+ "terminal_input",
+ "terminal_resize",
+ "terminal_kill",
+ ] {
+ assert_eq!(
+ info["capabilities"][capability], expected,
+ "runtime/info must advertise {capability}={expected}"
+ );
+ }
+
+ handle.abort();
+ Ok(())
+}
+
+#[tokio::test]
+#[cfg(unix)]
+async fn terminal_routes_serve_a_live_engine_session_over_http() -> Result<()> {
+ let tmp = tempfile::tempdir()?;
+ let root = tmp.path().join("runtime");
+ let workspace = tmp.path().join("ws");
+ fs::create_dir_all(&root)?;
+ fs::create_dir_all(&workspace)?;
+ let Some((addr, _runtime_threads, handle)) =
+ spawn_test_server_with_root_token_mobile_workspace(
+ root.clone(),
+ root.join("sessions"),
+ None,
+ false,
+ workspace.clone(),
+ )
+ .await?
+ else {
+ return Ok(());
+ };
+ let client = crate::tls::reqwest_client();
+ let base = format!("http://{addr}/v1/terminal/pane");
+
+ // The agent's terminal tools own session creation; stand in for that
+ // producer on the same workspace the server was started with, which is
+ // what makes this the Engine's own shell rather than a second one.
+ let _session = crate::tools::terminal_session::get_or_create(
+ "pane",
+ &workspace,
+ crate::sandbox::SandboxPolicy::DangerFullAccess,
+ )
+ .map_err(anyhow::Error::msg)?;
+
+ // Input through the route, then the shell's own echo back through the
+ // route. Bytes in, bytes out, no direct access to the session object.
+ let write: serde_json::Value = client
+ .post(format!("{base}/input"))
+ .json(&serde_json::json!({
+ "data": "printf 'terminal-route-proof\\n'\n",
+ "encoding": "text"
+ }))
+ .send()
+ .await?
+ .error_for_status()?
+ .json()
+ .await?;
+ assert!(write["written"].as_u64().unwrap_or_default() > 0);
+
+ let read_chunk = |base: String, client: reqwest::Client| async move {
+ let chunk: serde_json::Value = client
+ .get(format!("{base}/output?cursor=0&format=text"))
+ .send()
+ .await
+ .ok()?
+ .error_for_status()
+ .ok()?
+ .json()
+ .await
+ .ok()?;
+ Some(chunk)
+ };
+
+ let deadline = std::time::Instant::now() + ci_scaled(Duration::from_secs(10));
+ loop {
+ let chunk = read_chunk(base.clone(), client.clone())
+ .await
+ .expect("terminal output route answers");
+ let data = chunk["data"].as_str().unwrap_or_default();
+ if data.contains("terminal-route-proof") {
+ // Reads are non-consuming: the same cursor returns the same bytes.
+ let again = read_chunk(base.clone(), client.clone())
+ .await
+ .expect("terminal output route answers");
+ assert_eq!(again["data"], chunk["data"]);
+ break;
+ }
+ assert!(
+ std::time::Instant::now() < deadline,
+ "route never delivered the shell's output: {data}"
+ );
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ }
+
+ // Resize is checked through the shell, not the handler: `stty size` reads
+ // the kernel's window, so a handler that only stored the numbers fails.
+ client
+ .post(format!("{base}/resize"))
+ .json(&serde_json::json!({"rows": 40, "cols": 100}))
+ .send()
+ .await?
+ .error_for_status()?;
+ client
+ .post(format!("{base}/input"))
+ .json(&serde_json::json!({"data": "stty size\n", "encoding": "text"}))
+ .send()
+ .await?
+ .error_for_status()?;
+ let deadline = std::time::Instant::now() + ci_scaled(Duration::from_secs(10));
+ loop {
+ let chunk = read_chunk(base.clone(), client.clone())
+ .await
+ .expect("terminal output route answers");
+ let data = chunk["data"].as_str().unwrap_or_default();
+ if data.contains("40 100") {
+ break;
+ }
+ assert!(
+ std::time::Instant::now() < deadline,
+ "resize never reached the shell: {data}"
+ );
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ }
+
+ // Kill, then learn the truth from the stream rather than the ack.
+ client
+ .post(format!("{base}/kill"))
+ .send()
+ .await?
+ .error_for_status()?;
+ let deadline = std::time::Instant::now() + ci_scaled(Duration::from_secs(10));
+ loop {
+ let chunk = read_chunk(base.clone(), client.clone())
+ .await
+ .expect("terminal output route answers");
+ if chunk["running"] == serde_json::json!(false) {
+ break;
+ }
+ assert!(
+ std::time::Instant::now() < deadline,
+ "killed session still reports running: {chunk}"
+ );
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ }
+
+ handle.abort();
+ Ok(())
+}
+
+#[tokio::test]
+#[cfg(unix)]
+async fn terminal_output_for_an_unknown_session_is_not_found_and_creates_nothing() -> Result<()> {
+ let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
+ return Ok(());
+ };
+ let client = crate::tls::reqwest_client();
+ let base = format!("http://{addr}/v1/terminal");
+
+ // The route exists and answers for a name that has no live session: the
+ // Engine attaches to shells it owns, it does not conjure one per request.
+ let missing = client
+ .get(format!("{base}/no-such-session/output"))
+ .send()
+ .await?;
+ assert_eq!(missing.status(), reqwest::StatusCode::NOT_FOUND);
+
+ // An over-long name is rejected as a miss too, so the registry is never
+ // asked to allocate for it.
+ let long_name = "n".repeat(200);
+ let oversized = client
+ .get(format!("{base}/{long_name}/output"))
+ .send()
+ .await?;
+ assert_eq!(oversized.status(), reqwest::StatusCode::NOT_FOUND);
+
+ handle.abort();
+ Ok(())
+}
+
#[tokio::test]
async fn plugin_lifecycle_over_http_installs_reviews_enables_and_uninstalls() -> Result<()> {
let tmp = tempfile::tempdir()?;
@@ -17540,14 +17861,30 @@ async fn threads_running_lists_active_turns_and_clears_on_settle() -> Result<()>
sleep(Duration::from_millis(20)).await;
}
- let settled: serde_json::Value = client
- .get(format!("{base}/v1/threads/running"))
- .send()
- .await?
- .error_for_status()?
- .json()
- .await?;
- assert_eq!(settled, serde_json::json!([]));
+ // The listing above is read from the durable store, and the engine still
+ // owns this record: it can persist its own status after the write above,
+ // which puts the turn back in flight and made a single read flaky on a
+ // loaded macOS runner. That is not a defect — the engine is entitled to
+ // finish its turn. What must hold is that a settled turn stops being
+ // listed, so poll for that instead of assuming the first read is final.
+ let deadline = std::time::Instant::now() + ci_scaled(Duration::from_secs(5));
+ loop {
+ let settled: serde_json::Value = client
+ .get(format!("{base}/v1/threads/running"))
+ .send()
+ .await?
+ .error_for_status()?
+ .json()
+ .await?;
+ if settled == serde_json::json!([]) {
+ break;
+ }
+ assert!(
+ std::time::Instant::now() < deadline,
+ "a settled turn must leave the running list: {settled}"
+ );
+ sleep(Duration::from_millis(50)).await;
+ }
handle.abort();
Ok(())
diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs
index 2ed4b1348f..1b4702cc57 100644
--- a/crates/tui/src/runtime_threads.rs
+++ b/crates/tui/src/runtime_threads.rs
@@ -5983,6 +5983,7 @@ impl RuntimeThreadManager {
false,
)
.await
+ .map(|(turn, _replayed)| turn)
}
/// Terminal goal settlement for one finished turn.
@@ -6506,7 +6507,7 @@ impl RuntimeThreadManager {
.await;
match turn_result {
- Ok(turn) => {
+ Ok((turn, _replayed)) => {
let delivered = {
let _mail_mutation = self.store.mail_mutation.lock();
let mut envelope = self.store.load_agent_mail(message_id)?;
@@ -9433,6 +9434,22 @@ impl RuntimeThreadManager {
self.start_turn_inner(thread_id, req, None).await
}
+ /// Start a turn and report whether the durable `operation_key` made it a
+ /// replay of an already-accepted submission.
+ ///
+ /// The distinction belongs to admission, not to the route: a client that
+ /// retried an ambiguous submit needs to be told "this is the turn you
+ /// already started" so it does not render a duplicate, and only the
+ /// admission path knows that for certain.
+ pub async fn start_turn_reporting_replay(
+ &self,
+ thread_id: &str,
+ req: StartTurnRequest,
+ ) -> Result<(TurnRecord, bool)> {
+ self.start_turn_inner_reporting_replay(thread_id, req, None)
+ .await
+ }
+
pub(crate) async fn start_turn_with_reserved_id(
&self,
thread_id: &str,
@@ -9455,6 +9472,17 @@ impl RuntimeThreadManager {
req: StartTurnRequest,
reserved_turn_id: Option<&str>,
) -> Result {
+ self.start_turn_inner_reporting_replay(thread_id, req, reserved_turn_id)
+ .await
+ .map(|(turn, _replayed)| turn)
+ }
+
+ async fn start_turn_inner_reporting_replay(
+ &self,
+ thread_id: &str,
+ req: StartTurnRequest,
+ reserved_turn_id: Option<&str>,
+ ) -> Result<(TurnRecord, bool)> {
if reserved_turn_id.is_some() && req.operation_key.is_none() {
bail!("a reserved turn id requires an operation key");
}
@@ -9484,8 +9512,11 @@ impl RuntimeThreadManager {
true,
)
.await
+ .map(|(turn, _replayed)| turn)
}
+ /// Returns the turn and whether the durable operation key made this a
+ /// replay of an already-accepted submission rather than a new admission.
async fn start_turn_with_source(
&self,
thread_id: &str,
@@ -9493,7 +9524,7 @@ impl RuntimeThreadManager {
input_source: RuntimeTurnInputSource,
reserved_turn_id: Option<&str>,
stored_image_bytes: bool,
- ) -> Result {
+ ) -> Result<(TurnRecord, bool)> {
// Heap-allocate the turn-start state machine. Its future holds two full
// Config clones plus ThreadRecord/EngineHandle/TurnRecord/TurnItemRecord
// and the Op::SendMessage, and inlines the large ensure_engine_loaded
@@ -9605,7 +9636,7 @@ impl RuntimeThreadManager {
if let Some(operation) = operation.as_ref()
&& let Some(original_turn) = self.replay_turn_for_operation(operation)?
{
- return Ok(original_turn);
+ return Ok((original_turn, true));
}
if !image_blocks.is_empty() || req.max_output_tokens.is_some() {
let identity = self.provider_identity_for_thread(&cfg_snapshot, &thread)?;
@@ -9927,7 +9958,7 @@ impl RuntimeThreadManager {
if let Some(operation) = operation.as_ref()
&& let Some(original_turn) = self.replay_turn_for_operation(operation)?
{
- return Ok(original_turn);
+ return Ok((original_turn, true));
}
let Some(state) = active.engines.get_mut(thread_id) else {
bail!("Thread engine not loaded");
@@ -10020,10 +10051,11 @@ impl RuntimeThreadManager {
)
};
- acceptance_rx
+ let turn = acceptance_rx
.await
.map_err(|_| anyhow!("Turn lifecycle task ended before acknowledgement"))?
- .map_err(anyhow::Error::msg)
+ .map_err(anyhow::Error::msg)?;
+ Ok((turn, false))
})
.await
}
diff --git a/crates/tui/src/sleep_guard.rs b/crates/tui/src/sleep_guard.rs
new file mode 100644
index 0000000000..f3979f663e
--- /dev/null
+++ b/crates/tui/src/sleep_guard.rs
@@ -0,0 +1,174 @@
+//! Keep the host from idling into sleep while a turn is in flight.
+//!
+//! A suspended host cannot run the engine, so nothing here survives a real
+//! suspend — `core::engine::streaming::sleep_gap_detected` already reports that
+//! case and the engine re-issues the request (issue #2990). What this module
+//! prevents is the avoidable one: an unattended machine idling into sleep in
+//! the middle of a turn, which is how a long turn gets lost with no error at
+//! all.
+//!
+//! Scope, stated so nobody expects more than it does:
+//!
+//! - It holds the platform's *idle-sleep* assertion only. An explicit `sleep`/
+//! `pmset sleepnow`, a closed lid, or a low battery still wins — refusing
+//! those is the machine owner's call, not a running task's.
+//! - It is held for the duration of a turn and released on drop, so the host's
+//! power behaviour outside a turn is untouched.
+//! - It follows the same gate as the rest of the host-facing chrome
+//! (`EngineConfig::terminal_chrome_enabled`): an interactive TUI turn holds
+//! it, while headless hosts — `exec`, app-server, CI — never do. A dedicated
+//! `[tui]` opt-out key is not implemented yet; the headless gate is the
+//! escape hatch today.
+//! - Windows is not implemented. `SetThreadExecutionState` is thread-affine —
+//! the release has to happen on the thread that set it, which a guard that
+//! travels with a turn cannot promise. Rather than ship an untested holder
+//! that might silently never release, this is a no-op there for now.
+//!
+//! Release is `Drop` and never cached: a leaked inhibitor would keep a laptop
+//! awake forever, which is worse than the problem this solves.
+
+#[cfg(unix)]
+use std::process::Child;
+// Only the macOS and Linux inhibitors spawn anything; every other Unix
+// (Android, the BSDs, illumos) is a no-op and would see these as dead.
+#[cfg(any(target_os = "macos", target_os = "linux"))]
+use std::process::{Command, Stdio};
+
+/// An idle-sleep assertion held for as long as this value lives.
+pub struct SleepGuard {
+ /// The platform inhibitor process, when one was started. `None` means the
+ /// platform has no implementation, or the process could not be started —
+ /// keeping the host awake is best-effort and must never fail a turn.
+ #[cfg(unix)]
+ child: Option,
+}
+
+impl SleepGuard {
+ /// Hold the host awake until the returned guard drops.
+ #[must_use]
+ pub fn hold() -> Self {
+ #[cfg(unix)]
+ {
+ Self {
+ child: start_inhibitor(),
+ }
+ }
+ #[cfg(not(unix))]
+ {
+ Self {}
+ }
+ }
+
+ /// The inhibitor's process id, for diagnostics and tests. Absent when the
+ /// platform is a no-op or the process did not start.
+ #[cfg(all(test, unix))]
+ pub(crate) fn inhibitor_pid(&self) -> Option {
+ self.child.as_ref().map(Child::id)
+ }
+}
+
+#[cfg(unix)]
+impl Drop for SleepGuard {
+ fn drop(&mut self) {
+ let Some(child) = self.child.as_mut() else {
+ return;
+ };
+ // Killing the inhibitor is what releases the assertion; reaping it
+ // keeps a zombie out of the process table.
+ let _ = child.kill();
+ let _ = child.wait();
+ }
+}
+
+/// `-i` prevents idle sleep. Without `-t` caffeinate runs until it is killed,
+/// which is what `Drop` does; macOS releases the assertion with the process.
+#[cfg(target_os = "macos")]
+fn start_inhibitor() -> Option {
+ spawn("caffeinate", &["-i"])
+}
+
+/// `--what=idle` only: an explicit suspend or a closed lid is still honoured.
+/// `sleep infinity` is the command whose lifetime holds the block open.
+#[cfg(target_os = "linux")]
+fn start_inhibitor() -> Option {
+ spawn(
+ "systemd-inhibit",
+ &[
+ "--what=idle",
+ "--why=Codewhale turn in flight",
+ "--mode=block",
+ "sleep",
+ "infinity",
+ ],
+ )
+}
+
+/// Everything else Unix (BSD, illumos, …) has no inhibitor this module knows.
+#[cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))]
+fn start_inhibitor() -> Option {
+ None
+}
+
+#[cfg(any(target_os = "macos", target_os = "linux"))]
+fn spawn(program: &str, args: &[&str]) -> Option {
+ Command::new(program)
+ .args(args)
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .spawn()
+ .ok()
+}
+
+#[cfg(all(test, unix))]
+mod tests {
+ use super::*;
+
+ /// Whether the process is still there. `kill(pid, 0)` asks the kernel
+ /// without touching the process, so this cannot perturb the guard.
+ fn alive(pid: u32) -> bool {
+ // SAFETY: signal 0 performs the permission/existence check only.
+ unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
+ }
+
+ #[test]
+ #[cfg(any(target_os = "macos", target_os = "linux"))]
+ fn the_inhibitor_lives_exactly_as_long_as_the_guard() {
+ let guard = SleepGuard::hold();
+ let pid = guard
+ .inhibitor_pid()
+ .expect("this platform starts an inhibitor");
+ assert!(alive(pid), "the inhibitor must be running while held");
+
+ drop(guard);
+
+ // Reaping is synchronous in `Drop`, so the pid is gone immediately —
+ // and if it were reused by a new process in this window the test would
+ // be racing itself, which is why we assert on the guard's own child.
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ while alive(pid) {
+ assert!(
+ std::time::Instant::now() < deadline,
+ "a released guard must not leave an inhibitor keeping the host awake"
+ );
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+ }
+
+ #[test]
+ #[cfg(any(target_os = "macos", target_os = "linux"))]
+ fn holding_twice_holds_two_independent_inhibitors() {
+ // Turns are serialized, but nothing here should assume it: two guards
+ // must not share one process, or the first drop would release both.
+ let first = SleepGuard::hold();
+ let second = SleepGuard::hold();
+ let (a, b) = (
+ first.inhibitor_pid().expect("first inhibitor"),
+ second.inhibitor_pid().expect("second inhibitor"),
+ );
+ assert_ne!(a, b, "each guard owns its own inhibitor process");
+ drop(first);
+ assert!(!alive(a), "the first guard released only its own");
+ assert!(alive(b), "the second guard still holds the host awake");
+ }
+}
diff --git a/crates/tui/src/test_support.rs b/crates/tui/src/test_support.rs
index c0e04dcd18..4105dbdfe7 100644
--- a/crates/tui/src/test_support.rs
+++ b/crates/tui/src/test_support.rs
@@ -110,6 +110,60 @@ pub(crate) fn with_test_state_io_lock(operation: impl FnOnce() -> T) -> T {
operation()
}
+/// Build a test phase's future inside this call and box it (#6362).
+///
+/// In debug builds every inline `async {}` value gets a stack slot in the
+/// enclosing poll frame the size of that future's whole state machine, and
+/// the slots are never reused, so a body that awaits four phases inline
+/// carries all four state machines on its own frame at once (measured at
+/// 806 KiB for the runtime-store binding test). Constructing the phase here
+/// leaves the caller holding a pointer, and the phase's own temporaries die
+/// with its poll frame.
+pub(crate) fn boxed_phase<'a, T, M, F>(
+ make: M,
+) -> std::pin::Pin + 'a>>
+where
+ M: FnOnce() -> F,
+ F: std::future::Future