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 وكيل مفتوح المصدر يقرأ مشروعك ويعدّل الملفات ويشغّل الأوامر ويتحقق من عمله باستخدام نموذج مستضاف أو محلي تختاره. ابدأ بمهمة واحدة في الطرفية. وللأعمال الأكبر، وزّع أجزاء العمل على وكلاء بنماذج وأدوار مختلفة. -![Codewhale يعمل في طرفية](web/public/codewhale-tui-171acee.png) +![Codewhale يعمل في طرفية](web/public/codewhale-tui-d7a9a1c.png) -*معاينة للطرفية من بنية تطوير للإصدار 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. -![Codewhale executant-se en un terminal](web/public/codewhale-tui-171acee.png) +![Codewhale executant-se en un terminal](web/public/codewhale-tui-d7a9a1c.png) -*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. -![Codewhale in einem Terminal](web/public/codewhale-tui-171acee.png) +![Codewhale in einem Terminal](web/public/codewhale-tui-d7a9a1c.png) -*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. -![Codewhale ejecutándose en una terminal](web/public/codewhale-tui-171acee.png) +![Codewhale ejecutándose en una terminal](web/public/codewhale-tui-d7a9a1c.png) -*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. -![Codewhale en cours d’exécution dans un terminal](web/public/codewhale-tui-171acee.png) +![Codewhale en cours d’exécution dans un terminal](web/public/codewhale-tui-d7a9a1c.png) -*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 एक ओपन सोर्स एजेंट है जो आपकी पसंद के होस्ट किए गए या लोकल मॉडल से आपका प्रोजेक्ट पढ़ता है, फ़ाइलें संपादित करता है, कमांड चलाता है और अपने काम की जाँच करता है। टर्मिनल में एक काम से शुरुआत करें। बड़े काम के हिस्से अलग-अलग मॉडल और भूमिकाओं वाले एजेंटों को सौंपें। -![टर्मिनल में चलता Codewhale](web/public/codewhale-tui-171acee.png) +![टर्मिनल में चलता Codewhale](web/public/codewhale-tui-d7a9a1c.png) -*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. -![Codewhale berjalan di terminal](web/public/codewhale-tui-171acee.png) +![Codewhale berjalan di terminal](web/public/codewhale-tui-d7a9a1c.png) -*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. -![Codewhale in esecuzione in un terminale](web/public/codewhale-tui-171acee.png) +![Codewhale in esecuzione in un terminale](web/public/codewhale-tui-d7a9a1c.png) -*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 は、選んだホスト型またはローカルのモデルを使ってプロジェクトを読み、ファイルを編集し、コマンドを実行して、自分の作業結果を確認するオープンソースのエージェントです。まずはターミナルで一つのタスクから始めましょう。大きな仕事では、異なるモデルや役割を持つエージェントに作業の一部を分担させられます。 -![ターミナルで動作する Codewhale](web/public/codewhale-tui-171acee.png) +![ターミナルで動作する Codewhale](web/public/codewhale-tui-d7a9a1c.png) -*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은 사용자가 선택한 호스팅 모델이나 로컬 모델로 프로젝트를 읽고, 파일을 편집하고, 명령을 실행하며, 작업 결과를 확인하는 오픈 소스 에이전트입니다. 터미널에서 하나의 작업으로 시작하세요. 더 큰 작업은 서로 다른 모델과 역할을 가진 에이전트에게 나누어 맡길 수 있습니다. -![터미널에서 실행 중인 Codewhale](web/public/codewhale-tui-171acee.png) +![터미널에서 실행 중인 Codewhale](web/public/codewhale-tui-d7a9a1c.png) -*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. A Codewhale terminal session -*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. -![Codewhale działający w terminalu](web/public/codewhale-tui-171acee.png) +![Codewhale działający w terminalu](web/public/codewhale-tui-d7a9a1c.png) -*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. -![Codewhale em execução em um terminal](web/public/codewhale-tui-171acee.png) +![Codewhale em execução em um terminal](web/public/codewhale-tui-d7a9a1c.png) -*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 — агент с открытым исходным кодом, который читает ваш проект, редактирует файлы, выполняет команды и проверяет свою работу с помощью выбранной вами облачной или локальной модели. Начните с одной задачи в терминале. Для большой работы поручайте её части агентам с разными моделями и ролями. -![Codewhale работает в терминале](web/public/codewhale-tui-171acee.png) +![Codewhale работает в терминале](web/public/codewhale-tui-d7a9a1c.png) -*Предварительный вид терминала из сборки 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. -![Terminalde çalışan Codewhale](web/public/codewhale-tui-171acee.png) +![Terminalde çalışan Codewhale](web/public/codewhale-tui-d7a9a1c.png) -*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 — агент із відкритим кодом, який читає ваш проєкт, редагує файли, виконує команди й перевіряє свою роботу за допомогою обраної вами хмарної або локальної моделі. Почніть з одного завдання в терміналі. Для великої роботи доручайте її частини агентам із різними моделями й ролями. -![Codewhale працює в терміналі](web/public/codewhale-tui-171acee.png) +![Codewhale працює в терміналі](web/public/codewhale-tui-d7a9a1c.png) -*Попередній вигляд термінала зі збірки 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. -![Codewhale đang chạy trong terminal](web/public/codewhale-tui-171acee.png) +![Codewhale đang chạy trong terminal](web/public/codewhale-tui-d7a9a1c.png) -*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 是一款开源智能体,可使用你选择的托管模型或本地模型读取项目、编辑文件、运行命令并检查自己的工作。从终端中的一项任务开始。对于较大的工作,可以将其中的部分任务交给使用不同模型、承担不同角色的智能体。 -![Codewhale 在终端中运行](web/public/codewhale-tui-171acee.png) +![Codewhale 在终端中运行](web/public/codewhale-tui-d7a9a1c.png) -*终端预览截图来自 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 是一款開源代理,可使用你選擇的託管模型或本機模型讀取專案、編輯檔案、執行指令,並檢查自己的工作。從終端機中的一項任務開始。對於較大的工作,可以將部分任務交給使用不同模型、擔任不同角色的代理。 -![Codewhale 在終端機中執行](web/public/codewhale-tui-171acee.png) +![Codewhale 在終端機中執行](web/public/codewhale-tui-d7a9a1c.png) -*終端機預覽截圖來自 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 + 'a, +{ + Box::pin(make()) +} + +/// Drive a test future on a thread with libtest's default 2 MiB stack, +/// whatever `RUST_MIN_STACK` says (#6362). +/// +/// CI exports a 16 MiB `RUST_MIN_STACK` for every test thread, so a test +/// that only fits because of that export never learns it overflowed the +/// stack a contributor's plain `cargo test` gives it. The future is built on +/// the spawned thread (so it need not be `Send`) and pinned before +/// `block_on`, exactly as `#[tokio::test]` drives a current-thread runtime; +/// a panic inside propagates to the caller unchanged. An overflow still +/// aborts the process with "has overflowed its stack": that is the reported +/// symptom, not something this helper can turn into a panic. +pub(crate) fn block_on_default_test_stack(make: M) -> T +where + M: FnOnce() -> F + Send + 'static, + F: std::future::Future, + T: Send + 'static, +{ + const DEFAULT_TEST_THREAD_STACK: usize = 2 * 1024 * 1024; + std::thread::Builder::new() + .name("default-test-stack".into()) + .stack_size(DEFAULT_TEST_THREAD_STACK) + .spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread test runtime"); + let future = make(); + tokio::pin!(future); + runtime.block_on(future) + }) + .expect("spawn the default-stack test thread") + .join() + .unwrap_or_else(|panic| std::panic::resume_unwind(panic)) +} + /// Restore one environment variable when dropped. /// /// Callers that mutate process-global environment variables must hold diff --git a/crates/tui/src/tools/terminal_session.rs b/crates/tui/src/tools/terminal_session.rs index 6091e11ec3..b4a826a818 100644 --- a/crates/tui/src/tools/terminal_session.rs +++ b/crates/tui/src/tools/terminal_session.rs @@ -35,6 +35,11 @@ use super::spec::{optional_u64, required_str}; const BUFFER_LIMIT: usize = 512 * 1024; #[cfg(unix)] const OUTPUT_LIMIT: usize = 12 * 1024; +/// Ceiling for one [`OutputBuffer::read_since`] response. The ring is already +/// bounded at [`BUFFER_LIMIT`]; this bounds a single frame so a client cannot +/// ask for the whole window at once. +#[cfg(unix)] +pub(crate) const READ_LIMIT: usize = 64 * 1024; #[cfg(unix)] const DEFAULT_TIMEOUT_SECS: u64 = 120; #[cfg(unix)] @@ -45,8 +50,12 @@ const CANCEL_CONFIRM_TIMEOUT: Duration = Duration::from_secs(2); const CANCEL_SENTINEL_RETRY_INTERVAL: Duration = Duration::from_millis(50); #[cfg(unix)] -struct TerminalSession { +pub(crate) struct TerminalSession { writer: Arc>>, + /// The pty master is retained after the reader and writer clones are taken + /// so [`TerminalSession::resize`] can reach the kernel's window size. The + /// clones keep the pty alive; nothing else clones the master. + master: Box, child: Box, output: Arc>, read_cursor: u64, @@ -110,6 +119,33 @@ struct OutputBuffer { total: u64, } +/// One absolute-offset slice of a session's output. +/// +/// Offsets are absolute for the life of the live PTY: they count every byte +/// the reader thread ever appended, not the bytes still retained. That is what +/// lets a client resume from a cursor it stored earlier, and what lets this +/// type tell it the truth when the retained window has moved past it. +/// +/// Field names match the `/v1/terminal/{name}/output` wire so the payload is a +/// projection, not a translation (the same vocabulary the jobs byte stream +/// uses). +#[cfg(unix)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct OutputChunk { + pub(crate) bytes: Vec, + /// Absolute offset of `bytes[0]` in the session's lifetime output. + pub(crate) offset: u64, + /// Offset to pass as the next `cursor`. + pub(crate) next_cursor: u64, + /// Every byte the session has produced, retained or not. + pub(crate) total: u64, + /// Leading bytes the ring has permanently discarded. + pub(crate) dropped: u64, + /// True when the requested cursor predates the retained window. The bytes + /// in between cannot be recovered from this process — report, not repair. + pub(crate) gap: bool, +} + #[cfg(unix)] impl OutputBuffer { fn append(&mut self, data: &[u8]) { @@ -123,10 +159,37 @@ impl OutputBuffer { fn text(&self) -> String { String::from_utf8_lossy(&self.bytes.iter().copied().collect::>()).into_owned() } + + /// Absolute offset of the oldest retained byte. + fn oldest(&self) -> u64 { + self.total.saturating_sub(self.bytes.len() as u64) + } + + /// Read from an absolute cursor without consuming: the caller owns its own + /// position, so two readers can replay the same bytes and a repeated read + /// is idempotent. Does not advance `read_cursor` — the consuming + /// tool-result path is untouched. This is the cursor arithmetic only; + /// response-size policy belongs to the caller. + fn read_since(&self, cursor: u64, max_bytes: usize) -> OutputChunk { + let dropped = self.oldest(); + let gap = cursor < dropped; + let start = cursor.max(dropped); + let skip = usize::try_from(start - dropped).unwrap_or(usize::MAX); + let take = max_bytes.min(self.bytes.len().saturating_sub(skip)); + let bytes = self.bytes.iter().skip(skip).take(take).copied().collect(); + OutputChunk { + bytes, + offset: start, + next_cursor: start + take as u64, + total: self.total, + dropped, + gap, + } + } } #[cfg(unix)] -type SharedSession = Arc>; +pub(crate) type SharedSession = Arc>; #[cfg(unix)] #[derive(Clone, Debug, Hash, PartialEq, Eq)] @@ -341,6 +404,7 @@ fn create_session( Ok(Arc::new(Mutex::new(TerminalSession { writer: Arc::new(Mutex::new(writer)), + master: pair.master, child, output, read_cursor: 0, @@ -352,7 +416,7 @@ fn create_session( } #[cfg(unix)] -fn get_or_create( +pub(crate) fn get_or_create( name: &str, workspace: &std::path::Path, policy: crate::sandbox::SandboxPolicy, @@ -408,7 +472,7 @@ fn find(name: &str, workspace: &Path) -> Result { } #[cfg(unix)] -fn write_bytes(session: &TerminalSession, bytes: &[u8]) -> Result<(), String> { +pub(crate) fn write_bytes(session: &TerminalSession, bytes: &[u8]) -> Result<(), String> { let mut writer = session .writer .lock() @@ -433,12 +497,84 @@ fn take_output(session: &mut TerminalSession) -> String { let Ok(output) = session.output.lock() else { return String::new(); }; - let retained_start = output.total.saturating_sub(output.bytes.len() as u64); - let start = session.read_cursor.max(retained_start); - let skip = usize::try_from(start.saturating_sub(retained_start)).unwrap_or(usize::MAX); - let bytes = output.bytes.iter().skip(skip).copied().collect::>(); + let chunk = output.read_since(session.read_cursor, usize::MAX); session.read_cursor = output.total; - String::from_utf8_lossy(&bytes).into_owned() + String::from_utf8_lossy(&chunk.bytes).into_owned() +} + +/// Resize the live pty's window. The kernel updates its `winsize` and signals +/// the child, which is what makes an interactive app redraw at the new size. +#[cfg(unix)] +pub(crate) fn resize_session( + session: &TerminalSession, + rows: u16, + cols: u16, +) -> Result<(), String> { + session + .master + .resize(portable_pty::PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| format!("PTY resize failed: {e}")) +} + +/// Absolute-offset, non-consuming read. `max_bytes` is clamped to +/// [`READ_LIMIT`] so one caller cannot ask for the whole retained window. +/// +/// Known limitation: this reports a [`OutputChunk::gap`]; it does not repair +/// one. Bytes dropped by the ring are gone with the process, and nothing here +/// re-reads them from disk — the durable record is identity and lifecycle, +/// never output. +#[cfg(unix)] +pub(crate) fn read_session_since( + session: &TerminalSession, + cursor: u64, + max_bytes: usize, +) -> Result { + let output = session + .output + .lock() + .map_err(|_| "terminal output lock poisoned".to_string())?; + Ok(output.read_since(cursor, max_bytes.min(READ_LIMIT))) +} + +/// Poll the shell without blocking; `None` means it is still running. +#[cfg(unix)] +pub(crate) fn session_exit_status( + session: &mut TerminalSession, +) -> Result, String> { + session + .child + .try_wait() + .map_err(|e| format!("PTY wait failed: {e}")) +} + +/// Terminate the shell. The caller still observes the exit through +/// [`session_exit_status`]. +#[cfg(unix)] +pub(crate) fn kill_session(session: &mut TerminalSession) -> Result<(), String> { + session + .child + .kill() + .map_err(|e| format!("PTY kill failed: {e}")) +} + +/// Resolve a live session without creating one. +/// +/// `/v1/terminal` attaches to shells the Engine already owns (the agent's +/// terminal tools create them). A request for a name that has no live session +/// is a 404, never a new process: an HTTP client must not be able to conjure a +/// shell the Engine does not know about. +#[cfg(unix)] +pub(crate) fn lookup(name: &str, workspace: &Path) -> Option { + let key = session_key(name, workspace); + sessions() + .lock() + .ok() + .and_then(|registry| registry.get(&key).map(Arc::clone)) } #[cfg(unix)] @@ -1211,4 +1347,141 @@ mod tests { assert!(result.content.len() <= OUTPUT_LIMIT + 100); assert!(result.content.contains("output truncated")); } + + /// The cursor contract the Engine byte stream rides on: offsets are + /// absolute for the life of the PTY, reads never consume, and a cursor the + /// retained window has moved past is reported rather than silently + /// answered from the middle of the stream. + #[test] + #[cfg(unix)] + fn bounded_replay_is_absolute_non_consuming_and_reports_its_gap() { + let mut buffer = OutputBuffer::default(); + buffer.append(b"hello"); + let first = buffer.read_since(0, 4); + assert_eq!(first.bytes, b"hell"); + assert_eq!(first.offset, 0); + assert_eq!(first.next_cursor, 4); + assert_eq!(first.total, 5); + assert_eq!(first.dropped, 0); + assert!(!first.gap); + // A second read at the same cursor returns the same bytes: the caller + // owns the position, so replay is idempotent. + assert_eq!(buffer.read_since(0, 4), first); + let rest = buffer.read_since(first.next_cursor, 4); + assert_eq!(rest.bytes, b"o"); + assert_eq!(rest.offset, 4); + assert_eq!(rest.next_cursor, 5); + + // Past the ring: the first 32 bytes are gone and the chunk says so. + let mut wrapped = OutputBuffer::default(); + wrapped.append(&vec![b'x'; BUFFER_LIMIT + 32]); + let lost = wrapped.read_since(0, 8); + assert!(lost.gap, "a cursor below the retained window is a gap"); + assert_eq!(lost.dropped, 32); + assert_eq!( + lost.offset, 32, + "the chunk starts at the oldest retained byte" + ); + assert_eq!(lost.total, BUFFER_LIMIT as u64 + 32); + assert_eq!(lost.bytes, vec![b'x'; 8]); + assert_eq!(lost.next_cursor, 40); + + // A cursor at the head is not a gap and returns nothing new. + let current = wrapped.read_since(BUFFER_LIMIT as u64 + 32, 8); + assert!(!current.gap); + assert!(current.bytes.is_empty()); + assert_eq!(current.next_cursor, BUFFER_LIMIT as u64 + 32); + } + + /// The session-level entry point an Engine byte stream will call: absolute + /// cursor, non-consuming, clamped, and honest about a cursor ahead of the + /// stream. + #[test] + #[cfg(unix)] + fn session_read_since_is_non_consuming_and_clamped() { + let session = fresh("test-read-since"); + let _ = run( + &session, + "printf 'cw-replay-proof\\n'", + Duration::from_secs(3), + ); + // The tool-result path already consumed this output through its own + // cursor; an absolute cursor still reads it, which is the point. + let printed = read_session_since(&session.lock().unwrap(), 0, usize::MAX).unwrap(); + assert!( + String::from_utf8_lossy(&printed.bytes).contains("cw-replay-proof"), + "{}", + String::from_utf8_lossy(&printed.bytes) + ); + let again = read_session_since(&session.lock().unwrap(), 0, usize::MAX).unwrap(); + assert_eq!(again.bytes, printed.bytes, "a replay read must not consume"); + + // A cursor ahead of the stream is not a gap: nothing was lost, there + // is simply nothing there yet. + let ahead = + read_session_since(&session.lock().unwrap(), printed.next_cursor + 4096, 16).unwrap(); + assert!(!ahead.gap); + assert!(ahead.bytes.is_empty()); + assert_eq!(ahead.next_cursor, printed.next_cursor + 4096); + + // More than one response's worth of output proves the clamp. + let large = fresh("test-read-since-clamp"); + let _ = run(&large, "yes x | head -n 100000", Duration::from_secs(3)); + let chunk = read_session_since(&large.lock().unwrap(), 0, usize::MAX).unwrap(); + assert_eq!(chunk.bytes.len(), READ_LIMIT); + assert!(!chunk.gap); + assert_eq!(chunk.next_cursor, READ_LIMIT as u64); + } + + /// Resize has to reach the kernel, not just a field: `get_size` reads the + /// window back from the pty, and the shell reports it through `stty`. + #[test] + #[cfg(unix)] + fn resize_reaches_the_kernel_and_the_live_shell() { + let session = fresh("test-resize"); + { + let guard = session.lock().unwrap(); + assert_eq!(guard.master.get_size().unwrap().rows, 24); + } + resize_session(&session.lock().unwrap(), 40, 100).unwrap(); + let size = session.lock().unwrap().master.get_size().unwrap(); + assert_eq!((size.rows, size.cols), (40, 100)); + let result = run(&session, "stty size", Duration::from_secs(3)); + assert!( + result.content.contains("40 100"), + "the shell should see the new window: {}", + result.content + ); + } + + /// Exit is observable: a killed shell reports a status instead of looking + /// alive forever (the "pretending to reattach" failure the durable record + /// is written to avoid). + #[test] + #[cfg(unix)] + fn killed_shell_reports_an_exit_status() { + let session = fresh("test-exit-status"); + { + let mut guard = session.lock().unwrap(); + assert!( + session_exit_status(&mut guard).unwrap().is_none(), + "fresh shell is alive" + ); + kill_session(&mut guard).unwrap(); + } + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let exited = session_exit_status(&mut session.lock().unwrap()) + .unwrap() + .is_some(); + if exited { + break; + } + assert!( + Instant::now() < deadline, + "a killed shell must report its exit" + ); + std::thread::sleep(Duration::from_millis(20)); + } + } } diff --git a/crates/tui/src/tui/ambient_life.rs b/crates/tui/src/tui/ambient_life.rs index 8afbe5f683..346ebd53ee 100644 --- a/crates/tui/src/tui/ambient_life.rs +++ b/crates/tui/src/tui/ambient_life.rs @@ -1133,6 +1133,29 @@ fn fish_body(facing_right: bool, lead: bool) -> &'static str { } } +/// Count fish silhouettes in rendered text by facing: `(rightward, leftward)`. +/// +/// Recognizes the ASCII bodies and every native braille pose, so a render +/// test can assert the school without knowing which family painted it. The +/// native poses carry no eye (ad20493), so only the ASCII lead is +/// distinguishable from its followers. +#[cfg(test)] +pub(crate) fn fish_silhouette_counts(text: &str) -> (usize, usize) { + let native = |right: bool| { + let poses: std::collections::BTreeSet<&'static str> = (0..4) + .flat_map(|pose| (0..2).flat_map(move |dx| (0..2).map(move |dy| (pose, dx, dy)))) + .map(|(pose, dx, dy)| native_poses::fish(right, pose, dx, dy)) + .collect(); + poses + .into_iter() + .map(|pose| text.matches(pose).count()) + .sum::() + }; + let ascii_right = text.matches("><>").count() + text.matches(LEAD_FISH_RIGHT).count(); + let ascii_left = text.matches("<><").count() + text.matches(LEAD_FISH_LEFT).count(); + (ascii_right + native(true), ascii_left + native(false)) +} + /// Subtle caustic shimmer applied to empty water cells when the field would /// otherwise read as a static ramp. Cheap: one phase lookup per cell, only /// when `animated` and density allows. diff --git a/crates/tui/src/tui/pet_watch/mod.rs b/crates/tui/src/tui/pet_watch/mod.rs index 31992a3369..48aec117e9 100644 --- a/crates/tui/src/tui/pet_watch/mod.rs +++ b/crates/tui/src/tui/pet_watch/mod.rs @@ -736,4 +736,80 @@ mod tests { assert!(!call.contains("PRIVATE")); assert!(!thought.contains("PRIVATE")); } + + /// The pet's multi-agent count is derived on the JS side from + /// `agent:`-prefixed spans, keyed by the `id` this projection forwards + /// (app-side issue #12). The counting itself is proven in + /// `pet/tests/pet-engine.test.mjs`; the handoff is the half that fails + /// silently — a trimmed allowlist or a dropped id zeroes `parallel` with + /// no error and no log line, and nothing else here covers agent events. + /// Pin the wire shape the JS dispatches on, and keep child text off it. + #[test] + fn agent_events_forward_span_identity_without_child_text() { + use crate::core::events::AgentProgressEventMeta; + use crate::tools::subagent::{AgentWorkerStatus, SubAgentStatus}; + + let spawned = metadata(&Event::AgentSpawned { + owner_session_id: "session-a".into(), + id: "agent-1".into(), + prompt: "PRIVATE CHILD PROMPT".into(), + worker_status: Some(AgentWorkerStatus::Running), + parent_run_id: Some("run-9".into()), + spawn_depth: 2, + model: "PRIVATE CHILD MODEL".into(), + route_source: Some("task.model".into()), + }) + .expect("agent spawns are observed"); + assert_eq!( + serde_json::from_str::(&spawned).unwrap(), + json!({"event":"agent_spawned","id":"agent-1","worker_status":"running"}) + ); + + // The JS finishes a span when progress reports a terminal status. + let progress = metadata(&Event::AgentProgress { + owner_session_id: "session-a".into(), + id: "agent-1".into(), + status: "PRIVATE PROGRESS TEXT".into(), + activity: AgentProgressEventMeta { + worker_status: AgentWorkerStatus::Completed, + step: Some(3), + tool_name: Some("exec_command".into()), + routine_wait: false, + }, + parent_run_id: Some("run-9".into()), + spawn_depth: 2, + }) + .expect("agent progress is observed"); + assert_eq!( + serde_json::from_str::(&progress).unwrap(), + json!({"event":"agent_progress","id":"agent-1","worker_status":"completed"}) + ); + + let complete = metadata(&Event::AgentComplete { + owner_session_id: "session-a".into(), + id: "agent-1".into(), + result: "PRIVATE CHILD RESULT".into(), + outcome: Some(SubAgentStatus::Completed), + parent_run_id: Some("run-9".into()), + spawn_depth: Some(2), + continuable: Some(false), + usage: None, + }) + .expect("agent completions are observed"); + assert_eq!( + serde_json::from_str::(&complete).unwrap(), + json!({"event":"agent_complete","id":"agent-1","worker_status":"completed"}) + ); + + for (label, payload) in [ + ("spawned", &spawned), + ("progress", &progress), + ("complete", &complete), + ] { + assert!( + !payload.contains("PRIVATE"), + "{label} leaked child text: {payload}" + ); + } + } } diff --git a/crates/tui/src/tui/ui/event_loop.rs b/crates/tui/src/tui/ui/event_loop.rs index 8a06276949..e90d726698 100644 --- a/crates/tui/src/tui/ui/event_loop.rs +++ b/crates/tui/src/tui/ui/event_loop.rs @@ -636,6 +636,24 @@ pub async fn run_tui( require_interactive_terminal(io::stdin().is_terminal(), io::stdout().is_terminal())?; require_foreground_terminal_owner()?; + // The dispatcher resets SIGPIPE to SIG_DFL so `codewhale doctor | head` + // exits quietly (#4030). A full-screen session is the opposite case: it + // writes to pipes whose far end it does not own — stdio MCP servers, shell + // tools, hooks, LSP — and a peer that exits first must surface as an + // `EPIPE` error on that one write, not kill the whole TUI with the terminal + // left in raw mode and nothing in the runtime log. Reproduced with a stdio + // MCP server that exits before `initialize` is written: the process died + // of SIGPIPE before its first frame, and the PTY harness reported it as a + // plain exit 1. Children are unaffected: the standard library resets + // SIGPIPE to SIG_DFL before exec, so `| head` inside a shell tool still + // terminates the way a shell expects. Non-TUI subcommands keep SIG_DFL. + // SAFETY: a plain disposition change, no handler; it runs before this + // session spawns anything that writes to a pipe. + #[cfg(unix)] + unsafe { + libc::signal(libc::SIGPIPE, libc::SIG_IGN); + } + // #6169: install the suspend/resume handshake here — after the // foreground-ownership check (the termios snapshot needs the still-cooked // tty) and before raw mode, so every mode enabled below has a handler that @@ -4361,18 +4379,27 @@ pub(crate) async fn run_event_loop( active_cell_has_live_motion, translation_placeholder_has_live_motion, ); - let animation_interval_ms = animation_interval_ms( + // Content-driven cadence: atmosphere rate when only ocean life moves; + // full interactive rate while streaming, selecting, typing, or hovering. + // Read once here so the animation tick and the frame limiter below + // agree on the same tier for this frame. + let cadence_tier = crate::tui::display_refresh::cadence_tier_from_signals( + app.is_loading || has_running_agents, + app.viewport.transcript_selection.is_active(), + !app.input.is_empty(), + crate::tui::hover_layer::current_hover().is_some(), + ); + let underwater_motion = + underwater_ambient_motion || underwater_completion_motion || launch_motion; + let animation_active = status_motion || underwater_motion; + let animation_interval = Duration::from_millis(animation_interval_ms( app, status_motion, - underwater_ambient_motion || underwater_completion_motion || launch_motion, - ); + underwater_motion, + cadence_tier, + )); let motion_policy = app.motion_policy(); - if (status_motion - || underwater_ambient_motion - || underwater_completion_motion - || launch_motion) - && last_status_frame.elapsed() >= Duration::from_millis(animation_interval_ms) - { + if animation_active && last_status_frame.elapsed() >= animation_interval { let translation_animated = streaming_thinking::animate_pending_translation( app, pending_thinking_translations > 0, @@ -4402,6 +4429,20 @@ pub(crate) async fn run_event_loop( } last_status_frame = Instant::now(); } + if animation_active { + // Aim the poll at the next tick. Without a deadline the tick only + // ran when the idle/active poll happened to return, which + // quantized an 80 ms cadence to 96 ms and a 120 ms one to 144 ms. + frame_requester.request_at( + Instant::now(), + last_status_frame + animation_interval, + motion_policy, + ); + } else { + // Consume a deadline armed before motion stopped so an orphaned + // request cannot hold the poll timeout at zero. + let _ = frame_requester.take_due(Instant::now(), motion_policy); + } if event_broker.is_paused() { let grace_active = terminal_paused_at @@ -4517,21 +4558,15 @@ pub(crate) async fn run_event_loop( frame_rate_limiter.set_low_motion(motion_policy.uses_constrained_frame_rate()); stream_display_clock.set_allow_catch_up(motion_policy.allows_catch_up_bursts()); - // Content-driven cadence: atmosphere rate when only ocean life moves; - // full interactive rate while streaming, selecting, typing, or hovering. + // The draw limiter follows the same content-driven tier the + // animation tick above read for this frame. { use crate::tui::display_refresh::{ - cadence_tier_from_signals, content_driven_draw_interval, probe_display_refresh, + content_driven_draw_interval, probe_display_refresh, }; - let tier = cadence_tier_from_signals( - app.is_loading || has_running_agents, - app.viewport.transcript_selection.is_active(), - !app.input.is_empty(), - crate::tui::hover_layer::current_hover().is_some(), - ); let probe = probe_display_refresh(); frame_rate_limiter.set_adaptive_interval(Some(content_driven_draw_interval( - tier, + cadence_tier, probe.hz, motion_policy.uses_constrained_frame_rate(), ))); diff --git a/crates/tui/src/tui/ui/handlers.rs b/crates/tui/src/tui/ui/handlers.rs index 09d6373c02..295017b282 100644 --- a/crates/tui/src/tui/ui/handlers.rs +++ b/crates/tui/src/tui/ui/handlers.rs @@ -1302,6 +1302,15 @@ pub(crate) async fn handle_view_events( { return Ok(true); } + // A command review confirmed over the Extensions panel + // (the plugin trust digest) closes its pager and lands + // back on the list, which must show the state the + // confirmation just changed. + if app.view_stack.extensions_is_top() { + let snapshot = + crate::tui::views::extensions::ExtensionsSnapshot::from_app(app); + app.view_stack.refresh_extensions(snapshot); + } } crate::tui::views::CommandPaletteAction::InsertText { text } => { app.input = text; diff --git a/crates/tui/src/tui/ui/motion.rs b/crates/tui/src/tui/ui/motion.rs index 41a146f0af..fdcdbbe5d9 100644 --- a/crates/tui/src/tui/ui/motion.rs +++ b/crates/tui/src/tui/ui/motion.rs @@ -165,7 +165,17 @@ pub(crate) fn status_animation_interval_ms(app: &App) -> u64 { } } -pub(crate) fn underwater_animation_interval_ms(app: &App) -> u64 { +/// Tick interval for the water. `tier` is the draw cadence the frame limiter +/// enforces this frame: while only ambience moves, the tick lands exactly on +/// the atmosphere interval the limiter will draw at, so no wake asks for a +/// frame the limiter then holds. While a turn streams or the user types, the +/// limiter runs at the interactive cap and the authored ocean cadence rides +/// inside it unchanged. +pub(crate) fn underwater_animation_interval_ms( + app: &App, + tier: crate::tui::display_refresh::DrawCadenceTier, +) -> u64 { + use crate::tui::display_refresh::DrawCadenceTier; if app.effective_low_motion_for_status() || app.low_motion { crate::tui::display_refresh::adaptive_animation_interval_ms(true) } else if app.constrained_frame_rate { @@ -175,8 +185,11 @@ pub(crate) fn underwater_animation_interval_ms(app: &App) -> u64 { } else { // Measured display Hz can raise atmosphere cadence on high-Hz // panels; missing probe falls back to the ~8 fps floor. - crate::tui::display_refresh::adaptive_animation_interval_ms(false) - .min(UI_UNDERWATER_ANIMATION_MS) + let atmosphere = crate::tui::display_refresh::adaptive_animation_interval_ms(false); + match tier { + DrawCadenceTier::Atmosphere => atmosphere, + DrawCadenceTier::Interactive => atmosphere.min(UI_UNDERWATER_ANIMATION_MS), + } } } @@ -209,8 +222,9 @@ pub(crate) fn animation_interval_ms( app: &App, status_motion: bool, underwater_motion: bool, + tier: crate::tui::display_refresh::DrawCadenceTier, ) -> u64 { - let underwater = underwater_animation_interval_ms(app); + let underwater = underwater_animation_interval_ms(app, tier); match (status_motion, underwater_motion) { (true, true) => status_animation_interval_ms(app).min(underwater), (true, false) => status_animation_interval_ms(app), diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index e0929b940e..293921c562 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1094,19 +1094,39 @@ fn underwater_motion_keeps_its_smoother_cadence_during_live_status() { app.fancy_animations = true; app.constrained_frame_rate = false; + use crate::tui::display_refresh::DrawCadenceTier; assert_eq!( - animation_interval_ms(&app, true, false), + animation_interval_ms(&app, true, false, DrawCadenceTier::Interactive), UI_STATUS_ANIMATION_MS ); assert_eq!( - animation_interval_ms(&app, false, true), + animation_interval_ms(&app, false, true, DrawCadenceTier::Interactive), UI_UNDERWATER_ANIMATION_MS ); assert_eq!( - animation_interval_ms(&app, true, true), + animation_interval_ms(&app, true, true, DrawCadenceTier::Interactive), UI_UNDERWATER_ANIMATION_MS, "the slower status spinner must not throttle ambient fish" ); + // Only ambience moving: the tick lands on the interval the frame + // limiter draws at, instead of asking every 80 ms for a frame the + // limiter then holds until its 120 ms atmosphere cadence. + let atmosphere = crate::tui::display_refresh::adaptive_animation_interval_ms(false); + assert!(atmosphere >= UI_UNDERWATER_ANIMATION_MS); + assert_eq!( + animation_interval_ms(&app, false, true, DrawCadenceTier::Atmosphere), + atmosphere + ); + assert_eq!( + underwater_animation_interval_ms(&app, DrawCadenceTier::Atmosphere), + crate::tui::display_refresh::content_driven_draw_interval( + DrawCadenceTier::Atmosphere, + crate::tui::display_refresh::probe_display_refresh().hz, + false, + ) + .as_millis() as u64, + "the idle water ticks exactly when the limiter lets it draw" + ); // SAFETY: cleanup under the same lock. unsafe { match previous_program { @@ -1135,16 +1155,19 @@ fn ghostty_caps_underwater_motion_without_slowing_interaction() { app.fancy_animations = true; app.constrained_frame_rate = false; - assert_eq!( - underwater_animation_interval_ms(&app), - UI_GHOSTTY_UNDERWATER_ANIMATION_MS - ); + use crate::tui::display_refresh::DrawCadenceTier; + for tier in [DrawCadenceTier::Atmosphere, DrawCadenceTier::Interactive] { + assert_eq!( + underwater_animation_interval_ms(&app, tier), + UI_GHOSTTY_UNDERWATER_ANIMATION_MS + ); + } const { assert!(UI_GHOSTTY_UNDERWATER_ANIMATION_MS < UI_UNDERWATER_ANIMATION_MS); } app.constrained_frame_rate = true; assert_eq!( - underwater_animation_interval_ms(&app), + underwater_animation_interval_ms(&app, DrawCadenceTier::Interactive), UI_CONSTRAINED_UNDERWATER_ANIMATION_MS, "tmux/SSH compatibility must override Ghostty's native atmosphere lane" ); diff --git a/crates/tui/src/tui/ui/tests/runtime_store_binding.rs b/crates/tui/src/tui/ui/tests/runtime_store_binding.rs index 591dd8a274..6abb1c1540 100644 --- a/crates/tui/src/tui/ui/tests/runtime_store_binding.rs +++ b/crates/tui/src/tui/ui/tests/runtime_store_binding.rs @@ -131,172 +131,225 @@ async fn runtime_store_binding_exit_preserves_inflight_recovery() -> anyhow::Res Ok(()) } -#[tokio::test] -async fn runtime_store_binding_survives_launch_snapshot_and_resume() -> anyhow::Result<()> { - let _environment = crate::test_support::lock_test_env(); - let root = tempfile::tempdir()?; - let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path()); - let _runtime = crate::test_support::EnvVarGuard::remove("CODEWHALE_RUNTIME_DIR"); - let _legacy = crate::test_support::EnvVarGuard::remove("DEEPSEEK_RUNTIME_DIR"); - let config = fixture_config(); - let mut app = Box::new(create_test_app()); - app.workspace = root.path().into(); - let initial_id = super::super::event_loop::ensure_runtime_session_id(&mut app); - let task_config = TaskManagerConfig::from_runtime(&config, root.path().into(), None, Some(1)); - let tasks = TaskManager::start( - task_config.clone(), - config.clone(), - app.plugin_registry.clone(), - &initial_id, - None, - ) - .await?; - app.runtime_services.task_manager = Some(tasks.clone()); - let sessions = SessionManager::default_location()?; - // A saved initial conversation may later be deleted while another launch - // still refers to its Runtime store. - let initial = build_session_snapshot(&mut app, &sessions).map_err(anyhow::Error::msg)?; - sessions.save_session(&initial)?; - let launch = begin_launch_session(&mut app, None); - assert!(!launch.is_error, "{:?}", launch.message); - assert_ne!(app.current_session_id.as_deref(), Some(initial_id.as_str())); - let saved = build_session_snapshot(&mut app, &sessions).map_err(anyhow::Error::msg)?; - let binding = saved - .metadata - .runtime_store - .clone() - .expect("attached host binding"); - assert_eq!(binding.execution_scope, tasks.execution_scope()); - sessions.save_session(&saved)?; - let mut automations = AutomationManager::open(root.path().join("automations"))?; - automations.bind_task_manager(&tasks)?; - let automation = automations.create_automation(CreateAutomationRequest { - name: "resumed ownership fixture".into(), - prompt: "local fixture only".into(), - rrule: "FREQ=HOURLY;INTERVAL=1".into(), - cwds: vec![root.path().into()], - model: None, - model_provider: None, - model_provider_id: None, - mode: None, - allow_shell: Some(false), - trust_mode: Some(false), - auto_approve: Some(false), - delivery_mode: None, - status: Some(AutomationStatus::Paused), - })?; - tasks.shutdown_and_wait().await?; - drop(app); - drop(tasks); - drop(automations); - sessions.delete_session(&initial_id)?; - assert!( - binding.data_dir.is_dir(), - "transcript deletion cannot erase Runtime authority" - ); - let loaded = sessions.load_session(&saved.metadata.id)?; - assert_eq!(loaded.metadata.runtime_store.as_ref(), Some(&binding)); - let mut resumed = Box::new(create_test_app()); - let mut resumed_config = config.clone(); - apply_loaded_session_with_goal(&mut resumed, &mut resumed_config, &loaded, None) - .map_err(anyhow::Error::msg)?; - let tasks = TaskManager::start( - task_config.clone(), - config.clone(), - resumed.plugin_registry.clone(), - &loaded.metadata.id, - loaded.metadata.runtime_store.as_ref(), - ) - .await?; - assert_eq!( - tasks.execution_scope(), - automation.execution_scope.as_deref().unwrap() - ); - resumed.runtime_services.task_manager = Some(tasks.clone()); - let automations = Arc::new(tokio::sync::Mutex::new(AutomationManager::open( - root.path().join("automations"), - )?)); - // The real Run-now admission must now create its durable receipt. The - // configured endpoint is closed loopback and no shell/tool is authorized. - let run = run_now_shared(&automations, &automation.id, &tasks).await?; - assert!(run.task_id.is_some(), "{run:?}"); - assert_eq!( - automations - .lock() - .await - .list_runs(&automation.id, None)? - .len(), - 1 - ); - assert_eq!( - automations - .lock() - .await - .get_automation(&automation.id)? - .execution_scope, - automation.execution_scope - ); - tasks.shutdown_and_wait().await?; - drop(resumed); - drop(tasks); - // Reproduce the old resume path: deriving a store from the saved - // conversation id without its binding opens a foreign scope and cannot run. - let foreign = TaskManager::start( - task_config, - config, - Arc::new(crate::plugins::PluginRegistry::empty(root.path())), - &loaded.metadata.id, - None, - ) - .await?; - let foreign_automations = Arc::new(tokio::sync::Mutex::new(AutomationManager::open( - root.path().join("automations"), - )?)); - let definition_path = root - .path() - .join("automations/automations") - .join(format!("{}.json", automation.id)); - let before_foreign_run = std::fs::read(&definition_path)?; - let error = run_now_shared(&foreign_automations, &automation.id, &foreign) - .await - .unwrap_err(); - assert!( - error - .to_string() - .contains("another Runtime execution scope"), - "{error:#}" - ); - assert_eq!( - foreign_automations - .lock() - .await - .list_runs(&automation.id, None)? - .len(), - 1 - ); - assert_eq!(std::fs::read(definition_path)?, before_foreign_run); - let mut other_app = Box::new(create_test_app()); - other_app.runtime_services.task_manager = Some(foreign.clone()); - other_app.input = "preserve pending input".into(); - let old_id = other_app.current_session_id.clone(); - let error = apply_loaded_session_with_goal(&mut other_app, &mut resumed_config, &loaded, None) - .unwrap_err(); - // The refusal must name the route that actually works. "Resume it in a new - // Codewhale process" was true but unactionable: starting a new process and - // then picking the session from `/resume` returns here, because that is - // this same switch path (#6207, #6225). - assert!( - error.contains("codewhale resume"), - "the refusal must point at the direct-open path: {error}" - ); - assert!( - error.contains(&loaded.metadata.id), - "the refusal must name the session to open: {error}" - ); - assert_eq!(other_app.current_session_id, old_id); - assert_eq!(other_app.input, "preserve pending input"); - foreign.shutdown_and_wait().await?; - Ok(()) +/// #6362: this test used to be one async body. Every debug-build temporary +/// of that body — the boxed `App` returns, the cloned `Config`s, two session +/// snapshots, the task-manager futures — got its own slot in a single poll +/// frame, which alone measured 1.1 MiB on the 2 MiB stack libtest gives a +/// test thread (gdb frame attribution, 2026-09-20). The phases below are +/// built and boxed through `boxed_phase`, so each phase's temporaries die +/// with its own poll frame and the outer body holds pointers; inline +/// `async {}` phases measured 806 KiB of never-reused slots on the outer +/// frame and still overflowed. The test pins the default budget explicitly +/// instead of inheriting CI's 16 MiB `RUST_MIN_STACK`, which is what masked +/// the overflow. +#[test] +fn runtime_store_binding_survives_launch_snapshot_and_resume() -> anyhow::Result<()> { + use crate::test_support::boxed_phase; + + crate::test_support::block_on_default_test_stack(|| async { + let _environment = crate::test_support::lock_test_env(); + let root = tempfile::tempdir()?; + let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path()); + let _runtime = crate::test_support::EnvVarGuard::remove("CODEWHALE_RUNTIME_DIR"); + let _legacy = crate::test_support::EnvVarGuard::remove("DEEPSEEK_RUNTIME_DIR"); + let config = fixture_config(); + let sessions = SessionManager::default_location()?; + let task_config = + TaskManagerConfig::from_runtime(&config, root.path().into(), None, Some(1)); + let (root, config, task_config, sessions) = (&root, &config, &task_config, &sessions); + + // Phase 1: launch over a saved conversation and bind its Runtime store. + let (initial_id, saved_id, binding, automation) = boxed_phase(move || async move { + let mut app = Box::new(create_test_app()); + app.workspace = root.path().into(); + let initial_id = super::super::event_loop::ensure_runtime_session_id(&mut app); + let tasks = TaskManager::start( + task_config.clone(), + config.clone(), + app.plugin_registry.clone(), + &initial_id, + None, + ) + .await?; + app.runtime_services.task_manager = Some(tasks.clone()); + // A saved initial conversation may later be deleted while another + // launch still refers to its Runtime store. + let initial = build_session_snapshot(&mut app, sessions).map_err(anyhow::Error::msg)?; + sessions.save_session(&initial)?; + let launch = begin_launch_session(&mut app, None); + assert!(!launch.is_error, "{:?}", launch.message); + assert_ne!(app.current_session_id.as_deref(), Some(initial_id.as_str())); + let saved = build_session_snapshot(&mut app, sessions).map_err(anyhow::Error::msg)?; + let binding = saved + .metadata + .runtime_store + .clone() + .expect("attached host binding"); + assert_eq!(binding.execution_scope, tasks.execution_scope()); + sessions.save_session(&saved)?; + let mut automations = AutomationManager::open(root.path().join("automations"))?; + automations.bind_task_manager(&tasks)?; + let automation = automations.create_automation(CreateAutomationRequest { + name: "resumed ownership fixture".into(), + prompt: "local fixture only".into(), + rrule: "FREQ=HOURLY;INTERVAL=1".into(), + cwds: vec![root.path().into()], + model: None, + model_provider: None, + model_provider_id: None, + mode: None, + allow_shell: Some(false), + trust_mode: Some(false), + auto_approve: Some(false), + delivery_mode: None, + status: Some(AutomationStatus::Paused), + })?; + tasks.shutdown_and_wait().await?; + drop(app); + drop(tasks); + drop(automations); + Ok::<_, anyhow::Error>((initial_id, saved.metadata.id.clone(), binding, automation)) + }) + .await?; + sessions.delete_session(&initial_id)?; + assert!( + binding.data_dir.is_dir(), + "transcript deletion cannot erase Runtime authority" + ); + let loaded = sessions.load_session(&saved_id)?; + assert_eq!(loaded.metadata.runtime_store.as_ref(), Some(&binding)); + let mut resumed_config = config.clone(); + let (loaded, automation) = (&loaded, &automation); + + // Phase 2: resume with the binding and run the automation for real. + { + let resumed_config = &mut resumed_config; + boxed_phase(move || async move { + let mut resumed = Box::new(create_test_app()); + apply_loaded_session_with_goal(&mut resumed, resumed_config, loaded, None) + .map_err(anyhow::Error::msg)?; + let tasks = TaskManager::start( + task_config.clone(), + config.clone(), + resumed.plugin_registry.clone(), + &loaded.metadata.id, + loaded.metadata.runtime_store.as_ref(), + ) + .await?; + assert_eq!( + tasks.execution_scope(), + automation.execution_scope.as_deref().unwrap() + ); + resumed.runtime_services.task_manager = Some(tasks.clone()); + let automations = Arc::new(tokio::sync::Mutex::new(AutomationManager::open( + root.path().join("automations"), + )?)); + // The real Run-now admission must now create its durable + // receipt. The configured endpoint is closed loopback and no + // shell/tool is authorized. + let run = run_now_shared(&automations, &automation.id, &tasks).await?; + assert!(run.task_id.is_some(), "{run:?}"); + assert_eq!( + automations + .lock() + .await + .list_runs(&automation.id, None)? + .len(), + 1 + ); + assert_eq!( + automations + .lock() + .await + .get_automation(&automation.id)? + .execution_scope, + automation.execution_scope + ); + tasks.shutdown_and_wait().await?; + drop(resumed); + drop(tasks); + Ok::<_, anyhow::Error>(()) + }) + .await?; + } + + // Phase 3: reproduce the old resume path — deriving a store from the + // saved conversation id without its binding opens a foreign scope and + // cannot run. + let foreign = TaskManager::start( + task_config.clone(), + config.clone(), + Arc::new(crate::plugins::PluginRegistry::empty(root.path())), + &loaded.metadata.id, + None, + ) + .await?; + let foreign = &foreign; + boxed_phase(move || async move { + let foreign_automations = Arc::new(tokio::sync::Mutex::new(AutomationManager::open( + root.path().join("automations"), + )?)); + let definition_path = root + .path() + .join("automations/automations") + .join(format!("{}.json", automation.id)); + let before_foreign_run = std::fs::read(&definition_path)?; + let error = run_now_shared(&foreign_automations, &automation.id, foreign) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("another Runtime execution scope"), + "{error:#}" + ); + assert_eq!( + foreign_automations + .lock() + .await + .list_runs(&automation.id, None)? + .len(), + 1 + ); + assert_eq!(std::fs::read(definition_path)?, before_foreign_run); + Ok::<_, anyhow::Error>(()) + }) + .await?; + + // Phase 4: a host that already owns a foreign scope refuses the switch + // and keeps its pending input. + { + let resumed_config = &mut resumed_config; + boxed_phase(move || async move { + let mut other_app = Box::new(create_test_app()); + other_app.runtime_services.task_manager = Some(foreign.clone()); + other_app.input = "preserve pending input".into(); + let old_id = other_app.current_session_id.clone(); + let error = + apply_loaded_session_with_goal(&mut other_app, resumed_config, loaded, None) + .unwrap_err(); + // The refusal must name the route that actually works. "Resume + // it in a new Codewhale process" was true but unactionable: + // starting a new process and then picking the session from + // `/resume` returns here, because that is this same switch + // path (#6207, #6225). + assert!( + error.contains("codewhale resume"), + "the refusal must point at the direct-open path: {error}" + ); + assert!( + error.contains(&loaded.metadata.id), + "the refusal must name the session to open: {error}" + ); + assert_eq!(other_app.current_session_id, old_id); + assert_eq!(other_app.input, "preserve pending input"); + Ok::<_, anyhow::Error>(()) + }) + .await?; + } + foreign.shutdown_and_wait().await?; + Ok(()) + }) } #[cfg(unix)] diff --git a/crates/tui/src/tui/views/extensions.rs b/crates/tui/src/tui/views/extensions.rs index 6f78f639a1..4744c784c5 100644 --- a/crates/tui/src/tui/views/extensions.rs +++ b/crates/tui/src/tui/views/extensions.rs @@ -825,11 +825,14 @@ fn plugin_row_action( } } else { // The command opens the exact-content review with its confirmation - // control, so this panel yields to that review. + // control stacked on this panel. Confirming the digest runs the + // trust mutation and the host re-reads the inventory, so the row the + // person just reviewed reports its new state instead of the stale + // "not reviewed" it left with. ExtensionAction::Command { label: tr(locale, MessageId::AutomationActionInspect).into_owned(), command: format!("/plugin trust {}", plugin.name()), - disposition: RowActionDisposition::LeavePanel, + disposition: RowActionDisposition::InPlace, } } } @@ -2412,8 +2415,10 @@ mod tests { row.state, tr(Locale::En, MessageId::ExtensionsStateFirstParty) ); + // The exact-content review stacks on the panel so the confirmed + // digest lands on a row that then re-reads its trust state. assert!( - matches!(&row.action, Some(ExtensionAction::Command { command, disposition: RowActionDisposition::LeavePanel, .. }) if command == "/plugin trust computer-use") + matches!(&row.action, Some(ExtensionAction::Command { command, disposition: RowActionDisposition::InPlace, .. }) if command == "/plugin trust computer-use") ); assert!(!builtin.trusted()); assert!(!builtin.enabled); diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index eb69f860f5..7843b9fa2e 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -8511,6 +8511,7 @@ base_url = "https://api.xiaomimimo.com/v1" "mcp_reconnect", "mcp_diagnose", "plugins_open", + "sandbox_details", "mcp_config_path", "approval_mode", "permission_posture", diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 42b88ab46f..6bda4cedda 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -7452,10 +7452,11 @@ mod tests { assert_ne!(buf[(0, 0)].bg, buf[(0, 19)].bg); let rendered = buffer_text(&buf, area); - // One loose wedge school: an eyed lead plus plain members, all - // facing the same way (facing equals travel by construction). - let rightward = rendered.matches("><>").count() + rendered.matches(">").count(); - let leftward = rendered.matches("<><").count() + rendered.matches("<").count(); + // One loose wedge school, every member facing the same way (facing + // equals travel by construction). The counter knows both silhouette + // families: the native braille poses this terminal paints and the + // ASCII bodies of `CODEWHALE_ASCII_SAFE=1`. + let (rightward, leftward) = crate::tui::ambient_life::fish_silhouette_counts(&rendered); assert!( rightward == 0 || leftward == 0, "one school shares one direction:\n{rendered}" @@ -7465,8 +7466,6 @@ mod tests { (4..=7).contains(&fish_count), "wide idle water should show one cohesive wedge school (got {fish_count}):\n{rendered}" ); - let leads = rendered.matches(">").count() + rendered.matches("<").count(); - assert_eq!(leads, 1, "exactly one eyed lead fish:\n{rendered}"); let context_x = ((100usize - UnicodeWidthStr::width(context.as_str())) / 2) as u16; let context_cell = (0..area.height) @@ -7500,8 +7499,9 @@ mod tests { assert_eq!(buf[(0, 0)].bg, base); assert_eq!(buf[(0, 19)].bg, base, "flat keeps the plain theme surface"); let rendered = buffer_text(&buf, area); - assert!( - !rendered.contains("><>") && !rendered.contains("<><"), + assert_eq!( + crate::tui::ambient_life::fish_silhouette_counts(&rendered), + (0, 0), "terminal-owned themes must keep a normal shell without decorative fish:\n{rendered}" ); } @@ -7532,8 +7532,9 @@ mod tests { "Solarized Light must keep canonical Base3 through the viewport" ); let rendered = buffer_text(&buf, area); - assert!( - !rendered.contains("><>") && !rendered.contains("<><"), + assert_eq!( + crate::tui::ambient_life::fish_silhouette_counts(&rendered), + (0, 0), "a theme with no painted field earns no ambient life:\n{rendered}" ); } @@ -7575,8 +7576,9 @@ mod tests { "the Terminal treatment must never paint a background" ); let rendered = buffer_text(&buf, area); - assert!( - !rendered.contains("><>") && !rendered.contains("<><"), + assert_eq!( + crate::tui::ambient_life::fish_silhouette_counts(&rendered), + (0, 0), "Terminal must remain a quiet host-owned shell without the selected Deepsea scene:\n{rendered}" ); } @@ -7842,8 +7844,9 @@ mod tests { // entire width. Browsing still holds the school in the clear water. let rows = history_field_rows(4); let rendered = rows.join("\n"); + let (rightward, leftward) = crate::tui::ambient_life::fish_silhouette_counts(&rendered); assert!( - rendered.contains("><>") || rendered.contains("<><"), + rightward + leftward > 0, "open water below the transcript should hold fish:\n{rendered}" ); for index in 0..4 { @@ -7879,8 +7882,9 @@ mod tests { let mut buf = Buffer::empty(area); widget.render(area, &mut buf); let rendered = buffer_text(&buf, area); + let (rightward, leftward) = crate::tui::ambient_life::fish_silhouette_counts(&rendered); assert!( - rendered.contains("><") || rendered.contains(" 0, "submitting a message must not empty the ocean:\n{rendered}" ); assert!(rendered.contains("release check 17"), "{rendered}"); @@ -7903,8 +7907,9 @@ mod tests { let mut buf = Buffer::empty(area); widget.render(area, &mut buf); let rendered = buffer_text(&buf, area); + let (rightward, leftward) = crate::tui::ambient_life::fish_silhouette_counts(&rendered); assert!( - rendered.contains("><") || rendered.contains(" 0, "the completion settle must not snap the ocean empty:\n{rendered}" ); assert!(rendered.contains("release receipt"), "{rendered}"); @@ -7929,8 +7934,9 @@ mod tests { let mut buf = Buffer::empty(area); widget.render(area, &mut buf); let rendered = buffer_text(&buf, area); - assert!( - !rendered.contains("><>") && !rendered.contains("<><"), + assert_eq!( + crate::tui::ambient_life::fish_silhouette_counts(&rendered), + (0, 0), "a full transcript is not an aquarium:\n{rendered}" ); } diff --git a/crates/tui/tests/support/qa_harness/harness.rs b/crates/tui/tests/support/qa_harness/harness.rs index 10d6e9678b..14193022e1 100644 --- a/crates/tui/tests/support/qa_harness/harness.rs +++ b/crates/tui/tests/support/qa_harness/harness.rs @@ -329,6 +329,7 @@ impl Harness { let transcript = self.pty.transcript(); let pid = self.pty.pid(); let exit = self.pty.wait_until(Instant::now()); + let signal = self.pty.signal().map(str::to_owned); let program = self.program.clone(); let diagnostic_root = self.diagnostic_root.clone(); let sealed_home = self.sealed_home.clone(); @@ -348,7 +349,7 @@ impl Harness { let destination = diagnostic_root .join(format!("{}-{nonce}", pid.unwrap_or_default())); let mut report = format!( - "program={:?} host={}/{} pid={pid:?} observed_exit={exit:?} wait_budget={budget:?} parent_CI={} {}\nPTY bytes={}\n", + "program={:?} host={}/{} pid={pid:?} observed_exit={exit:?} signal={signal:?} wait_budget={budget:?} parent_CI={} {}\nPTY bytes={}\n", program, std::env::consts::OS, std::env::consts::ARCH, diff --git a/crates/tui/tests/support/qa_harness/pty.rs b/crates/tui/tests/support/qa_harness/pty.rs index b284443fb5..793af1ed93 100644 --- a/crates/tui/tests/support/qa_harness/pty.rs +++ b/crates/tui/tests/support/qa_harness/pty.rs @@ -29,6 +29,10 @@ pub struct PtySession { /// no trace on the rendered screen at all. transcript: Arc>>, reader_handle: Option>, + /// The signal that killed the child, when `wait_until` reaped one. + /// `portable_pty` folds a signal death into exit code 1, which reads as a + /// deliberate `exit(1)`; a SIGPIPE death spent a debugging session that way. + signal: Option, } pub struct PtySessionBuilder<'a> { @@ -158,6 +162,7 @@ impl<'a> PtySessionBuilder<'a> { buffer, transcript, reader_handle: Some(reader_handle), + signal: None, }) } } @@ -171,6 +176,11 @@ impl PtySession { self.child.process_id() } + /// The signal that killed the child, once it has been reaped. + pub fn signal(&self) -> Option<&str> { + self.signal.as_deref() + } + pub fn write_bytes(&mut self, bytes: &[u8]) -> Result<()> { self.writer.write_all(bytes).context("pty write")?; self.writer.flush().context("pty flush")?; @@ -210,7 +220,10 @@ impl PtySession { pub fn wait_until(&mut self, deadline: Instant) -> Option { loop { match self.child.try_wait() { - Ok(Some(status)) => return Some(status.exit_code() as i32), + Ok(Some(status)) => { + self.signal = status.signal().map(str::to_owned); + return Some(status.exit_code() as i32); + } Ok(None) => {} Err(_) => return None, } diff --git a/docs/ENVIRONMENTS.md b/docs/ENVIRONMENTS.md index a2566c7167..77db45e23e 100644 --- a/docs/ENVIRONMENTS.md +++ b/docs/ENVIRONMENTS.md @@ -37,6 +37,27 @@ CODEWHALE_PROVIDER=vllm VLLM_BASE_URL=http://127.0.0.1:8000/v1 VLLM_MODEL= \ `codewhale exec` (add `--auto` for tool use) is the non-interactive path to exercise the full agent loop. +## Keeping the host awake during a turn + +While an interactive TUI turn is in flight, Codewhale holds the platform's +idle-sleep assertion, so an unattended machine does not idle into sleep +mid-turn and lose the work: + +- macOS: `caffeinate -i` +- Linux: `systemd-inhibit --what=idle --why="Codewhale turn in flight" --mode=block sleep infinity` + +The assertion is released the moment the turn ends, and it covers *idle* sleep +only: an explicit `sleep` / `pmset sleepnow`, a closed lid, or a low battery +still suspends the machine. Headless hosts — `exec`, app-server, CI — never +hold it, so a shared runner's power policy is untouched. Windows is not +implemented: `SetThreadExecutionState` is thread-affine and needs a holder that +pins the thread, so the gap is deliberate rather than silent. + +If a turn is suspended anyway, the engine notices on wake — wall-clock elapsed +diverging from monotonic elapsed by more than the suspend threshold — reports +`System sleep detected; connection lost — retrying request`, and re-issues the +request instead of failing the turn (#2990). + ## Consolidated runtime commands The current `codewhale` binary runs the TUI in-process. Release installers copy diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index 745c2cf58c..85d801084d 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -1215,6 +1215,39 @@ manager, the durable thread store, the workspace confinement layer, the config's credential plumbing — and add no second runtime, session store, scheduler, or credential store. +**Terminal sessions** (the persistent Engine-owned shell) + +The jobs family above runs one command per job. A terminal pane needs the +*other* authority: the stateful PTY-backed shell the agent's own terminal +tools drive, which keeps cwd and environment across inputs. These routes +attach to that session and never create one — a name with no live session is +`404`, because conjuring a shell from an HTTP request would give the client a +terminal the Engine does not know about. Input is attributable by route: +`input` is the client's writer, `terminal_send` is the agent's. + +- `GET /v1/terminal/{name}/output?cursor=&max_bytes=<1-64KiB>&format= + ` — the resumable byte stream. `{name, offset, next_cursor, + total, dropped, encoding, data, running, exit_code}`: pass `next_cursor` + back to continue; reads never consume, so several clients may hold + independent cursors; `dropped` reports bytes the 512 KiB ring discarded +- `POST /v1/terminal/{name}/input` — `{ "data", "encoding"? }`, UTF-8 text by + default or `base64` for exact bytes → `{ "name", "written" }` +- `POST /v1/terminal/{name}/resize` — `{ "rows", "cols" }` → the kernel + window the child draws for +- `POST /v1/terminal/{name}/kill` — end the shell; observe the exit through + `output` (`running` / `exit_code`) rather than the acknowledgement + +`GET /v1/runtime/info` advertises `terminal_stream`, `terminal_input`, +`terminal_resize` and `terminal_kill`. All four are `false` on Windows builds +today: the owner is Unix-only, the Windows routes answer `501`, and a client +should gate its terminal controls on these flags rather than discovering it +from a failed request. Known limitations, stated because a reader would +otherwise assume them: there is no `wait_ms` long poll (poll the cursor), +scrollback dropped by the ring is gone with the process, a restarted Engine +reports no session rather than pretending to reattach, and the +`@codewhale/runtime-sdk` package has no terminal client wrapper yet — the raw +routes are the contract for now. + **Jobs** (operator-scoped shell jobs; the terminal surface) - `GET /v1/jobs` — every live and known-stale job across all threads - `GET /v1/threads/{id}/jobs` — jobs owned by one thread's manager: diff --git a/scripts/dead-code-budget.json b/scripts/dead-code-budget.json index 17f45bade8..29797dbbad 100644 --- a/scripts/dead-code-budget.json +++ b/scripts/dead-code-budget.json @@ -1,7 +1,7 @@ { "_comment": "Ceiling for `#[allow(dead_code)]` across crates/. This number may go down freely; raising it needs a reviewer to say why in the PR. Regenerate with: python3 scripts/check-dead-code-budget.py --update", "_issue": "https://github.com/Hmbown/CodeWhale/issues/4785", - "total": 185, + "total": 174, "per_crate": { "app-server": 1, "core": 5, @@ -9,6 +9,6 @@ "models": 5, "palette": 20, "tools": 2, - "tui": 150 + "tui": 139 } } diff --git a/web/lib/changelog.generated.ts b/web/lib/changelog.generated.ts index 97eca879d4..8e34a510f9 100644 --- a/web/lib/changelog.generated.ts +++ b/web/lib/changelog.generated.ts @@ -77,6 +77,8 @@ export const CHANGELOG: ChangelogRelease[] = [ { "heading": "Changed", "items": [ + "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 read correctly for a single message.", "Model and provider settings use quieter selection surfaces, one focused cursor, clearer missing-key warnings, compact output limits, and less repetitive credential detail. Plugin actions use plain labels.", "The terminal opens on Shoreline, the same palette the GPUI client already uses: warm charcoal field #211F23, a raised plate for panels and the composer, one blue for action and selection #90B9FF, and the whale's ivory #F2ECE5 for body text, with 4.5:1 floors on every muted step. The old saturated navy gradient is not gone — underwater is a named theme now rather than the ground the product opens on. Existing installs keep whatever theme they have saved; /theme switches…", @@ -86,14 +88,14 @@ export const CHANGELOG: ChangelogRelease[] = [ "MCP protocol negotiation: every surface advertised the original 2024-11-05 revision and the stdio client required an exact match, so newer servers could not connect. The server and both clients now advertise 2025-06-18 and negotiate over the supported set (2025-06-18, 2025-03-26, 2024-11-05) — the server echoes the client's revision when it is supported and answers with the latest otherwise, the stdio client accepts any supported revision, and streamable HTTP sends the…", "Configured MCP servers now connect lazily instead of all at session boot. The pool owns a connecting set marked at spawn and cleared on resolution or abort, so \"connecting\" is no longer inferred as enabled-minus-connected. The boot pass scopes to the eager set — required servers plus those covered by tools.always_load / allowed_tools — and a turn naming an unstarted server spawns its connects alongside, under the existing five-second deadline. A configured-but-unstarted…", "The launch card's MCP problems row runs its own remedy. It already printed /mcp login or /mcp; it now joins the shared paint/click/keyboard ordering, so Up/Down lands on it and Enter or a click types the printed command into the composer for you to send. Typing beats copying: no clipboard dependency over SSH, and you see the command before a second Enter runs it (#6085).", - "Computer Use is the only computer-use product in Extensions and /mcp recommendations. Cua is no longer suggested as a parallel desktop-control MCP; enable the first-party computer-use plugin instead. The bundled plugin is 0.4.0: Return/Enter from type, filtered and paginated get_app_state, focus/get_value, and strategy:\"app\" window-scoped clicks. Shared-desktop pointer gestures stay gated.", - "The bundled first-party catalog pins marketplace revision ca6be22, so installing Computer Use from the Extensions listing fetches the same 0.4.0 source and the published notarized 0.4.0 Mac app." + "Computer Use is the only computer-use product in Extensions and /mcp recommendations. Cua is no longer suggested as a parallel desktop-control MCP; enable the first-party computer-use plugin instead. The bundled plugin is 0.4.0: Return/Enter from type, filtered and paginated get_app_state, focus/get_value, and strategy:\"app\" window-scoped clicks. Shared-desktop pointer gestures stay gated." ], - "itemCount": 11 + "itemCount": 13 }, { "heading": "Fixed", "items": [ + "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).", "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 when verbose mode and the default-expansion preference are both enabled.", "Branch navigation preserves sibling histories, stable entry IDs and timestamps through autosave, resume and forks, and synchronizes the selected branch into the live engine. Thanks to @7jrxt42BxFZo4iAnN4CX for the report (#6367).", @@ -104,10 +106,9 @@ export const CHANGELOG: ChangelogRelease[] = [ "Clicking a path:line in tool output no longer spawns $EDITOR detached while the TUI still owns the terminal, and no longer spawns one editor per matching line. The launch goes through the single terminal-handoff path, and a click is one request to open one file (#6235).", "A write-scope contention refusal now names a remedy that works. The agent tool's description claimed release was \"the remediation a write-scope contention refusal names\"; the refusal did not name it, and pointing back at it would have been worse, because release only clears claims whose owner is no longer running while a contention refusal names a live one. The refusal itself now says to wait for that owner to settle or cancel it (#6272).", "The session picker no longer refuses a saved session whose Runtime store exists but holds nothing. A force-quit leaves the store on disk, ownerless and empty, and the switch path refused it because recovery only covered a *missing* store. A switch now also adopts a store that is provably empty (every work directory, plus the event sequence that remembers pruned appends) *and* provably unheld (the process-owner lock, which a live manager holds from open to close), with no…", - "Double-tap Enter now sends every queued follow-up into the running turn, oldest first. The second Enter used to steer only the most recent message and leave older ones queued; a failed steer restores the failed message plus everything unattempted in original order, so nothing is lost or reordered.", - "Only the most recently sent prompt carries the elevated-surface background now; every older prompt renders on the bare ground. The fill used to sit behind every user row (striping), then behind none; newest-only keeps the eye on the turn in play. Sending a new prompt moves the highlight and un-highlights its predecessor." + "Double-tap Enter now sends every queued follow-up into the running turn, oldest first. The second Enter used to steer only the most recent message and leave older ones queued; a failed steer restores the failed message plus everything unattempted in original order, so nothing is lost or reordered." ], - "itemCount": 26 + "itemCount": 27 } ] },