diff --git a/.github/AUTHOR_MAP b/.github/AUTHOR_MAP index 43b545cac4..04c6e1d406 100644 --- a/.github/AUTHOR_MAP +++ b/.github/AUTHOR_MAP @@ -9,6 +9,10 @@ # or local machine email seen in older harvested history. hmbown = Hmbown <101357273+Hmbown@users.noreply.github.com> +hmbown@gmail.com = Hmbown <101357273+Hmbown@users.noreply.github.com> +fleitz = Fred Leitz <345780+fleitz@users.noreply.github.com> +fred.leitz@gmail.com = Fred Leitz <345780+fleitz@users.noreply.github.com> +fleitzo@gmail.com = Fred Leitz <345780+fleitz@users.noreply.github.com> Angel-Hair = Lu Shihan <38780063+Angel-Hair@users.noreply.github.com> Angel_Hair@protonmail.com = Lu Shihan <38780063+Angel-Hair@users.noreply.github.com> dmitri-0 = dmitri-0 <141411224+dmitri-0@users.noreply.github.com> @@ -190,3 +194,7 @@ knqiufan@foxmail.com = knqiufan <34114995+knqiufan@users.noreply.github.com> # v0.9.1 Windows TUI reports harvested into maintainer fixes. SparkofSpike = Sh1Zuku <125943630+SparkofSpike@users.noreply.github.com> Sh1Zuku = Sh1Zuku <125943630+SparkofSpike@users.noreply.github.com> + +# v0.9.1 provider harvests. +baendlorel = baendlorel <50111870+baendlorel@users.noreply.github.com> +futami16237@gmail.com = baendlorel <50111870+baendlorel@users.noreply.github.com> diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml index 77970e9c10..451cd8e095 100644 --- a/.github/workflows/auto-tag.yml +++ b/.github/workflows/auto-tag.yml @@ -99,7 +99,7 @@ jobs: - name: Verify version consistency if: steps.check.outputs.exists == 'false' run: | - ./scripts/release/check-versions.sh || { + ./scripts/release/check-versions.sh --require-dated-release || { echo "::error::Version consistency check failed. Aborting tag creation." >&2 exit 1 } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 89d76fb902..c0b3bfc91d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,7 +99,7 @@ jobs: exit 1 fi done - ./scripts/release/check-versions.sh + ./scripts/release/check-versions.sh --require-dated-release - name: Require release source on main run: ./scripts/release/ensure-release-on-main.sh "${{ steps.release.outputs.sha }}" - name: Refuse an existing public asset set diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index a8dac92ae1..c15a5c2447 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -72,9 +72,17 @@ jobs: cache-dependency-path: web/package-lock.json - name: Install dependencies run: npm ci + - name: Record deployed/source drift + # Read-only and credential-free. A mismatch is expected before a + # maintainer-approved deployment, so this step reports without gating. + run: npm run compare:deployed-facts -- --expected-revision "$GITHUB_SHA" - name: Check Cloudflare deploy environment run: npm run check:deploy-env - name: Build OpenNext bundle run: npm run build && npx opennextjs-cloudflare build - name: Deploy run: npm run deploy + - name: Verify exact deployed revision + # The public /api/facts receipt must identify this workflow's exact + # checkout before the manual deployment run can finish green. + run: npm run check:deployed-facts -- --expected-revision "$GITHUB_SHA" --attempts 10 --retry-delay-ms 3000 diff --git a/CHANGELOG.md b/CHANGELOG.md index e3562e8c33..dddf15615b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.9.1] - 2026-07-20 +## [0.9.1] - Unreleased candidate -Codewhale v0.9.1 ships a first-class local web client over the Runtime API, -first-class OpenCode Go and restored xAI device login on the provider surface, +The Codewhale v0.9.1 source candidate includes a first-class local web client over the Runtime API, +first-class OpenCode Go and TelecomJS TokenHub providers and restored xAI device login, calendar-correct hourly automations, a buildable OpenHarmony workflow-js target, and hardening for Auto routing, remote-terminal clipboard transport, -restart recovery, and the community site's content boundaries. +restart recovery, and a coherent TUI, Work, evidence, and public release +surface. ### Added @@ -34,7 +35,14 @@ restart recovery, and the community site's content boundaries. of this narrow route until Codewhale supports per-model wire selection (#1481 by @seanthefuturegorilla; implementation harvested from PR #773 by @zhangweiii and PR #1050 by @sternelee). -- Publish native Windows ARM64 `codewhale`, `codew`, and `codewhale-tui` +- Add TelecomJS TokenHub as a first-class Chat Completions provider with + `[providers.telecomjs]`, `TELECOMJS_API_KEY`, and a key-scoped live + `/v1/models` refresh. Models.dev and provider-specific catalogs remain in + separate source partitions so either refresh order preserves both; refreshes + do not delete the other source's rows, matching model ids from unrelated + providers do not fabricate metadata, and chat requests omit unsupported + reasoning fields (PR #4370 by @baendlorel; harvested with co-authorship). +- Prepare native Windows ARM64 `codewhale`, `codew`, and `codewhale-tui` binaries, npm selection, updater support, and standard/portable release archives. Build and smoke them on GitHub's native Windows 11 ARM runner, and move Linux ARM64 release builds to the native Ubuntu ARM runner to @@ -43,6 +51,20 @@ restart recovery, and the community site's content boundaries. role vocabulary with alias parser and `SubAgentType` mapping (#3934). - `load_skill` tool now supports listing: omit `name` or pass `"list"` to see all available skills without loading one (#4651). +- Add a unified `/skills` manager with one precedence-aware root catalog, + bounded duplicate/shadow/conflict auditing, package provenance, and + validated install, update, remove, and trust mutations (PR #4679 by + @SamhandsomeLee). +- Add a safe Agent Details view and bounded, structured `current_activity` to + the single Work projection, sourced from worker events instead of renderer + string inference. Rows stay compact, exact evidence is opt-in, and raw child + output never enters the parent transcript (#2889 and #4636; design direction + by @aboimpinto, preserved from #2694). +- Make exact results and delegated coordination durable: non-inline tool output + becomes immutable session-owned evidence behind bounded receipts; File + mutations add configurable success-only diffs; and decisions and write + contention survive restart with typed neutral-fan-in records (#4619, #4636, + #4647). - Runtime API provider registry and atomic provider-switch endpoints (`GET /v1/providers`, `GET /v1/providers/{id}/models`, `POST /v1/providers/{id}/switch`) so the web GUI renders a dynamic @@ -56,6 +78,18 @@ restart recovery, and the community site's content boundaries. ### Changed +- Simplify the model-facing runtime around stable action tools (`File`, `Git`, + `Run`, deferred `Web`, and durable task and automation families), with legacy + spellings hidden for replay. Fresh sessions no longer reserve a Work surface + before real work exists (PR #4675). +- Give the terminal shell one deliberate visual language: cool Plan → Act → + Operate and warm Ask → Auto-Review → Full Access ramps match between header + and split composer edges; transcript rhythm groups related activity; a + refined whale keeps the empty state calm; and one-cell live motion with + truthful labels distinguishes reasoning, reading, tool use, and verification + without exposing private reasoning text. Reduced-motion and animation-off + settings freeze it, while ASCII-safe terminals retain the signal (#4676, + #4677). - Unified shell tool: the model now sees a single `Bash` tool with an `action` parameter (run/wait/interact/cancel). Legacy `exec_shell*` names remain as hidden compat aliases for transcript replay, and the tool-search catalog @@ -86,6 +120,11 @@ restart recovery, and the community site's content boundaries. ### Fixed +- Default canonical `Bash` runs with no explicit `cwd` to the active + `ToolContext.workspace`, including an isolated sub-agent worktree, instead of + falling through to the shared shell manager's parent workspace. The regression + test detects the selected workspace through marker files so it remains + meaningful across PowerShell path spellings (#4674, PR #4673 by @fleitz). - Generate QuickJS bindings for `aarch64-unknown-linux-ohos` with the native SDK's libclang and sysroot, carry the OHOS target and sysroot through final linking, and keep unsupported persistent PTY dependencies out of the target @@ -133,6 +172,9 @@ restart recovery, and the community site's content boundaries. instead of tinting it green-grey through the default underwater Ombre treatment, while retaining foreground ambient life (#4457 by @AiurArtanis; PR #4471 by @nightt5879). +- Register `/slop` and `/canzha` as compatibility aliases of `/debt`, while + keeping user-command ownership truthful across dispatch, help, slash + completion, alias copy, and typo suggestions (PR #4680 by @nightt5879). - Fail closed on legacy Kimi CLI credential imports: remove Codewhale's hard-coded first-party-client impersonation and refresh request, never auto-enable or rewrite imported credentials, and label the compatibility @@ -234,9 +276,21 @@ Thank you to the contributors whose code, reports, and reviews shaped v0.9.1: and idle-timeout progress telemetry (PR #4657). - [@gaord](https://github.com/gaord) — Runtime API provider registry and atomic provider-switch endpoints (PR #4658). -- [@SamhandsomeLee](https://github.com/SamhandsomeLee) — Enter-send lag - diagnosis and fix direction for #4605 (PR #4654; landed via the release - lane async-dispatch split). +- [@SamhandsomeLee](https://github.com/SamhandsomeLee) — the unified `/skills` + root catalog, audit/provenance model, validated mutations, manager UI, and + acceptance coverage (PR #4679), plus Enter-send lag diagnosis and fix + direction for #4605 (PR #4654; landed via the release-lane async-dispatch + split). +- [@aboimpinto](https://github.com/aboimpinto) — the Layer 5.1 user-command + registry boundary from PR #3278; the exact authored evidence commit from PR + #4046, preserved intact in the integration graph; and the #2870 follow-up + audit whose metadata and malformed-sibling gaps shaped the final corrections. + Paulo also provided the structured, redacted Agent Details and + `current_activity` direction preserved from #2694/#2889 and the real-PTY + lifecycle acceptance direction from #2886. +- [@baendlorel](https://github.com/baendlorel) — TelecomJS TokenHub provider + support and key-scoped live-catalog direction from PR #4370, harvested into + the current provider architecture with co-authorship preserved. - [@zhangweiii](https://github.com/zhangweiii) and [@sternelee](https://github.com/sternelee) — the original first-class OpenCode Go implementations (PRs #773 and #1050), harvested into the @@ -244,8 +298,9 @@ Thank you to the contributors whose code, reports, and reviews shaped v0.9.1: - [@seanthefuturegorilla](https://github.com/seanthefuturegorilla) — the canonical OpenCode Go/Zen provider request and acceptance direction (#1481). -- [@nightt5879](https://github.com/nightt5879) — the Solarized Light - background preservation fix (PR #4471). +- [@nightt5879](https://github.com/nightt5879) — `/debt` compatibility aliases + with dispatch-consistent user-command shadowing across discovery surfaces + (PR #4680), plus the Solarized Light background preservation fix (PR #4471). - [@AiurArtanis](https://github.com/AiurArtanis) — the Solarized Light regression report and reproduction (#4457). - [@shenjackyuanjie](https://github.com/shenjackyuanjie) — the HarmonyOS @@ -265,6 +320,9 @@ Thank you to the contributors whose code, reports, and reviews shaped v0.9.1: contract (PRs #4475 and #4476). - [@dmitri-0](https://github.com/dmitri-0) — configurable cache-hit visibility in the phase strip (PR #4474). +- [@fleitz](https://github.com/fleitz) — the canonical `Bash` no-`cwd` + workspace fix and regression test that keep isolated sub-agent commands in + their own worktree (PR #4673, closing #4674). - [@SparkofSpike](https://github.com/SparkofSpike) — the Windows Ctrl+O reproduction that exposed pre-pager result truncation and conflicting composer shortcut routing (#4482), and the exact Vim-space regression reproduction @@ -3900,8 +3958,8 @@ overflow report and `/theme` picker edge-wrapping patch in #1814. Older releases (v0.8.39 and earlier) are archived in [docs/CHANGELOG_ARCHIVE.md](docs/CHANGELOG_ARCHIVE.md). -[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.9.1...HEAD -[0.9.1]: https://github.com/Hmbown/CodeWhale/compare/v0.9.0...v0.9.1 +[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.9.0...HEAD +[0.9.1]: https://github.com/Hmbown/CodeWhale/compare/v0.9.0...main [0.8.68]: https://github.com/Hmbown/CodeWhale/compare/v0.8.67...v0.8.68 [0.8.67]: https://github.com/Hmbown/CodeWhale/compare/v0.8.66...v0.8.67 [0.8.66]: https://github.com/Hmbown/CodeWhale/compare/v0.8.65...v0.8.66 diff --git a/Cargo.lock b/Cargo.lock index e46fa6a902..a8dff46f88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1108,6 +1108,7 @@ dependencies = [ "tower-http 0.7.0", "tracing", "tracing-subscriber", + "unicode-normalization", "unicode-segmentation", "unicode-width 0.2.2", "urlencoding", diff --git a/README.es-419.md b/README.es-419.md index 94f92614e4..69cf785d56 100644 --- a/README.es-419.md +++ b/README.es-419.md @@ -1,10 +1,10 @@ - + # Codewhale -**Un runtime. Todos los modelos. Tu máquina.** +**Un runtime. Modelos alojados y locales compatibles. Tu máquina.** -Codewhale es un agente de código para tu terminal. Funciona con cualquier -modelo; los modelos abiertos primero. Le das un proveedor, un modelo y una +Codewhale es un agente de código para tu terminal. Funciona con modelos +alojados y locales compatibles; los modelos abiertos primero. Le das un proveedor, un modelo y una tarea: lee tu código, edita archivos, ejecuta comandos, verifica su trabajo y se detiene cuando la tarea queda lista o te necesita. Cambia de modelo a mitad de tarea con `/model`. Usa la TUI para el trabajo interactivo y @@ -17,10 +17,12 @@ mitad de tarea con `/model`. Usa la TUI para el trabajo interactivo y vienen de la ruta real. Un precio desconocido se muestra como desconocido, nunca como $0. - **Seguro por construcción.** El modo Plan es de solo lectura. Las - aprobaciones controlan cada llamada riesgosa. El sandbox del sistema - operativo resiste — Seatbelt, Landlock, seccomp, bwrap. El - `constitution.json` de un repo se compila en bloqueos de escritura que ni - siquiera Full Access puede saltarse. + aprobaciones controlan cada llamada riesgosa. Codewhale solo informa un + sandbox de comandos del sistema operativo cuando realmente envuelve el + comando: Seatbelt en macOS cuando está disponible y bubblewrap opcional en + Linux cuando está instalado. Windows actualmente informa que no hay + sandbox. El `constitution.json` de un repo se compila en bloqueos de + escritura que ni siquiera Full Access puede saltarse. - **Trabajo que sobrevive.** Los fleets registran cada paso en un libro mayor de solo agregado; `fleet resume` retoma donde te detuviste. Cada turno deja un recibo que puedes inspeccionar. @@ -57,10 +59,10 @@ codewhale web # local browser client on 127.0.0.1 ``` En la TUI: `/model` cambia proveedor y modelo juntos, `/fleet` ejecuta un -equipo de workers, `/restore` deshace un turno, `Tab` cicla entre -Plan / Act / Operate, `Shift+Tab` cicla la postura de aprobación -Ask / Auto-Review / Full Access, y `!` ejecuta un comando de shell por la ruta -normal de aprobación. +equipo de workers y `/restore` deshace un turno. Cuando el compositor está +inactivo, `Tab` cicla entre Plan / Act / Operate y `Shift+Tab` cicla la postura +de permiso Ask / Auto-Review / Full Access. `!` ejecuta un comando de shell por +la ruta normal de aprobación. ## Para saber más diff --git a/README.ja-JP.md b/README.ja-JP.md index af9028212b..d3740394a8 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -1,10 +1,10 @@ - + # Codewhale -**ひとつのランタイム。あらゆるモデル。あなたのマシン。** +**ひとつのランタイム。対応するホスト型・ローカルモデル。あなたのマシン。** -Codewhale はターミナルで動くコーディングエージェントです。あらゆるモデルで -動作し、オープンモデルを最優先します。プロバイダ、モデル、タスクを渡すと、 +Codewhale はターミナルで動くコーディングエージェントです。対応するホスト型・ +ローカルモデルで動作し、オープンモデルを最優先します。プロバイダ、モデル、タスクを渡すと、 コードを読み、ファイルを編集し、コマンドを実行し、自分の作業を確認して、 タスクが完了するかあなたの手が必要になった時点で止まります。タスクの途中でも `/model` でモデルを切り替えられます。対話的な作業には TUI を、スクリプトと @@ -18,9 +18,12 @@ CI には `codewhale exec` を。Rust 製、MIT ライセンス、あなたの 実際のルートに由来します。不明な価格は不明と表示され、$0 とは決して 表示されません。 - **構造として安全。** Plan モードは読み取り専用。リスクのある呼び出しは - すべて承認でゲートされます。OS サンドボックスが守ります — Seatbelt、 - Landlock、seccomp、bwrap。リポジトリの `constitution.json` は書き込み - ホールドへとコンパイルされ、Full Access でもスキップできません。 + すべて承認でゲートされます。Codewhale が OS コマンドサンドボックスを + 表示するのは、実際にコマンドをラップするときだけです。macOS では利用可能な + Seatbelt、Linux ではインストール済みで明示的に有効化した bubblewrap を使い、 + Windows は現在サンドボックスなしと表示します。リポジトリの + `constitution.json` は書き込みホールドへとコンパイルされ、Full Access でも + スキップできません。 - **消えない作業。** Fleet はすべてのステップを追記専用の台帳に記録し、 `fleet resume` で止めたところから再開できます。どのターンも検証できる レシートを残します。 @@ -53,7 +56,7 @@ codewhale exec "fix the failing test" # headless codewhale web # local browser client on 127.0.0.1 ``` -TUI では、`/model` がプロバイダとモデルをまとめて切り替え、`/fleet` がワーカーのチームを走らせ、`/restore` がターンを取り消します。`Tab` は Plan / Act / Operate を順に切り替え、`Shift+Tab` は Ask / Auto-Review / Full Access の承認スタンスを順に切り替え、`!` は Shell コマンドを通常の承認経路で実行します。 +TUI では、`/model` がプロバイダとモデルをまとめて切り替え、`/fleet` がワーカーのチームを走らせ、`/restore` がターンを取り消します。入力欄がアイドル状態のとき、`Tab` は Plan / Act / Operate を順に切り替え、`Shift+Tab` は Ask / Auto-Review / Full Access の権限スタンスを順に切り替えます。`!` は Shell コマンドを通常の承認経路で実行します。 ## さらに詳しく diff --git a/README.ko-KR.md b/README.ko-KR.md index 6eafe14d00..0c82285249 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -1,10 +1,10 @@ - + # Codewhale -**하나의 런타임. 모든 모델. 당신의 컴퓨터.** +**하나의 런타임. 지원되는 호스팅 및 로컬 모델. 당신의 컴퓨터.** -Codewhale은 터미널에서 쓰는 코딩 에이전트입니다. 어떤 모델과도 동작하며, -오픈 모델을 우선합니다. 프로바이더, 모델, 작업을 지정하면 코드를 읽고, +Codewhale은 터미널에서 쓰는 코딩 에이전트입니다. 지원되는 호스팅 및 로컬 +모델과 함께 동작하며, 오픈 모델을 우선합니다. 프로바이더, 모델, 작업을 지정하면 코드를 읽고, 파일을 편집하고, 명령을 실행하고, 스스로 작업을 확인하며, 작업이 끝나거나 사용자의 판단이 필요해지면 멈춥니다. 작업 도중에도 `/model`로 모델을 바꿀 수 있습니다. 대화형 작업에는 TUI를, 스크립트와 CI에는 `codewhale exec`를 @@ -17,9 +17,11 @@ Codewhale은 터미널에서 쓰는 코딩 에이전트입니다. 어떤 모델 알 수 없는 가격은 알 수 없음으로 표시됩니다 — 절대 $0으로 표시되지 않습니다. - **구조적으로 안전.** Plan 모드는 읽기 전용입니다. 위험한 호출은 모두 - 승인을 거칩니다. OS 샌드박스가 지켜냅니다 — Seatbelt, Landlock, seccomp, - bwrap. 저장소의 `constitution.json`은 Full Access조차 건너뛸 수 없는 쓰기 - 홀드로 컴파일됩니다. + 승인을 거칩니다. Codewhale은 명령이 실제로 래핑될 때만 OS 명령 샌드박스를 + 표시합니다. macOS에서는 사용 가능한 Seatbelt를, Linux에서는 설치되어 있고 + 명시적으로 활성화한 bubblewrap을 사용하며, Windows는 현재 샌드박스 없음으로 + 표시합니다. 저장소의 `constitution.json`은 Full Access조차 건너뛸 수 없는 + 쓰기 홀드로 컴파일됩니다. - **사라지지 않는 작업.** Fleet은 모든 단계를 추가 전용 원장에 기록하고, `fleet resume`은 멈춘 지점부터 이어갑니다. 매 턴마다 확인할 수 있는 영수증이 남습니다. @@ -57,9 +59,9 @@ codewhale web # local browser client on 127.0.0.1 ``` TUI 안에서: `/model`은 프로바이더와 모델을 함께 전환하고, `/fleet`은 -워커 팀을 실행하며, `/restore`는 한 턴을 되돌립니다. `Tab`은 -Plan / Act / Operate 모드를 순환하고, `Shift+Tab`은 -Ask / Auto-Review / Full Access 승인 태세를 순환하며, `!`는 일반 승인 +워커 팀을 실행하며, `/restore`는 한 턴을 되돌립니다. 입력창이 유휴 상태일 때 +`Tab`은 Plan / Act / Operate 모드를 순환하고, `Shift+Tab`은 +Ask / Auto-Review / Full Access 권한 태세를 순환합니다. `!`는 일반 승인 경로를 거쳐 셸 명령을 실행합니다. ## 더 알아보기 diff --git a/README.md b/README.md index 94581434a3..6a652b6471 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Codewhale -**One runtime. Every model. Your machine.** +**One runtime. Supported hosted and local models. Your machine.** -Codewhale is a coding agent for your terminal. It works with any model; -open models first. Give it a provider, a model, and a task: it reads your +Codewhale is a coding agent for your terminal. It works with supported hosted +and local models; open models first. Give it a provider, a model, and a task: it reads your code, edits files, runs commands, checks its work, and stops when the job is done or it needs you. Switch models mid-task with `/model`. Use the TUI for interactive work, `codewhale exec` for scripts and CI. Rust, MIT, @@ -15,9 +15,11 @@ runs on your machine. and one toolset. Context budgets and prices come from the real route. An unknown price shows as unknown, never as $0. - **Safe by construction.** Plan mode is read-only. Approvals gate every - risky call. The OS sandbox holds — Seatbelt, Landlock, seccomp, bwrap. - A repo's `constitution.json` compiles into write holds that even Full - Access cannot skip. + risky call. Codewhale reports an OS command sandbox only when it actually + wraps the command: Seatbelt on macOS when available, and opt-in bubblewrap + on Linux when installed. Windows currently reports none. A repo's + `constitution.json` compiles into write holds that even Full Access cannot + skip. - **Work that survives.** Fleets record every step in an append-only ledger; `fleet resume` picks up where you stopped. Every turn leaves a receipt you can inspect. @@ -54,9 +56,10 @@ codewhale web # local browser client on 127.0.0.1 ``` In the TUI: `/model` switches provider and model together, `/fleet` runs a -team of workers, `/restore` undoes a turn, `Tab` cycles Plan / Act / Operate, -`Shift+Tab` cycles the Ask / Auto-Review / Full Access approval posture, and -`!` runs a shell command through the normal approval path. +team of workers, and `/restore` undoes a turn. When the composer is idle, `Tab` +cycles Plan / Act / Operate and `Shift+Tab` cycles the Ask / Auto-Review / Full +Access permission posture. `!` runs a shell command through the normal approval +path. ## Learn more diff --git a/README.pt-BR.md b/README.pt-BR.md index cdd40144bd..abf45e423d 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -1,10 +1,10 @@ - + # Codewhale -**Um runtime. Todos os modelos. Sua máquina.** +**Um runtime. Modelos hospedados e locais compatíveis. Sua máquina.** -O Codewhale é um agente de código para o seu terminal. Funciona com qualquer -modelo; modelos abertos em primeiro lugar. Você informa um provedor, um +O Codewhale é um agente de código para o seu terminal. Funciona com modelos +hospedados e locais compatíveis; modelos abertos em primeiro lugar. Você informa um provedor, um modelo e uma tarefa: ele lê seu código, edita arquivos, executa comandos, verifica o próprio trabalho e para quando a tarefa termina ou quando precisa de você. Troque de modelo no meio da tarefa com `/model`. Use a TUI para @@ -17,9 +17,12 @@ sua máquina. um único conjunto de ferramentas. Orçamentos de contexto e preços vêm da rota real. Um preço desconhecido aparece como desconhecido, nunca como $0. - **Seguro por construção.** O modo Plan é somente leitura. Aprovações - controlam cada chamada arriscada. O sandbox do sistema operacional segura — - Seatbelt, Landlock, seccomp, bwrap. O `constitution.json` de um repositório - é compilado em bloqueios de escrita que nem o Full Access consegue pular. + controlam cada chamada arriscada. O Codewhale só informa um sandbox de + comandos do sistema operacional quando ele realmente envolve o comando: + Seatbelt no macOS quando disponível e bubblewrap opcional no Linux quando + instalado. O Windows atualmente informa que não há sandbox. O + `constitution.json` de um repositório é compilado em bloqueios de escrita + que nem o Full Access consegue pular. - **Trabalho que sobrevive.** Fleets registram cada passo em um livro-razão de apenas inclusão; `fleet resume` retoma de onde você parou. Cada turno deixa um recibo que você pode inspecionar. @@ -56,9 +59,10 @@ codewhale web # local browser client on 127.0.0.1 ``` Na TUI: `/model` troca provedor e modelo juntos, `/fleet` executa uma equipe -de workers, `/restore` desfaz um turno, `Tab` cicla entre Plan / Act / Operate, -`Shift+Tab` cicla a postura de permissão Ask / Auto-Review / Full Access, e -`!` executa um comando de shell pelo caminho normal de aprovação. +de workers e `/restore` desfaz um turno. Quando o compositor está ocioso, `Tab` +cicla entre Plan / Act / Operate e `Shift+Tab` cicla a postura de permissão Ask +/ Auto-Review / Full Access. `!` executa um comando de shell pelo caminho normal +de aprovação. ## Saiba mais diff --git a/README.vi.md b/README.vi.md index 54d4fa8dd2..8d50962d7b 100644 --- a/README.vi.md +++ b/README.vi.md @@ -1,10 +1,10 @@ - + # Codewhale -**Một runtime. Mọi model. Máy của bạn.** +**Một runtime. Các model hosted và local được hỗ trợ. Máy của bạn.** -Codewhale là một coding agent cho terminal của bạn. Hoạt động với mọi model; -ưu tiên model mở. Đưa cho nó một provider, một model và một nhiệm vụ: nó đọc +Codewhale là một coding agent cho terminal của bạn. Hoạt động với các model +hosted và local được hỗ trợ; ưu tiên model mở. Đưa cho nó một provider, một model và một nhiệm vụ: nó đọc code của bạn, sửa file, chạy lệnh, kiểm tra công việc của mình, và dừng lại khi nhiệm vụ hoàn thành hoặc cần đến bạn. Đổi model giữa chừng bằng `/model`. Dùng TUI cho công việc tương tác, `codewhale exec` cho script và CI. Viết @@ -16,9 +16,11 @@ bằng Rust, giấy phép MIT, chạy trên máy của bạn. runtime và một bộ công cụ. Ngân sách ngữ cảnh và giá lấy từ route thật; giá chưa rõ hiển thị là chưa rõ, không bao giờ là $0. - **An toàn từ thiết kế.** Chế độ Plan chỉ đọc. Mọi lệnh gọi rủi ro đều qua - phê duyệt. Sandbox của hệ điều hành giữ vững — Seatbelt, Landlock, seccomp, - bwrap. `constitution.json` của repo được biên dịch thành các chốt chặn ghi - mà ngay cả Full Access cũng không thể bỏ qua. + phê duyệt. Codewhale chỉ báo sandbox lệnh của hệ điều hành khi lệnh thực sự + được bọc: Seatbelt trên macOS khi khả dụng và bubblewrap trên Linux khi đã + cài đặt và bật rõ ràng. Windows hiện báo không có sandbox. `constitution.json` + của repo được biên dịch thành các chốt chặn ghi mà ngay cả Full Access cũng + không thể bỏ qua. - **Công việc không mất.** Fleet ghi lại từng bước vào sổ cái chỉ ghi thêm; `fleet resume` tiếp tục từ chỗ bạn dừng. Mỗi lượt đều để lại một biên nhận bạn có thể kiểm tra. @@ -55,9 +57,10 @@ codewhale web # local browser client on 127.0.0.1 ``` Trong TUI: `/model` đổi provider và model cùng lúc, `/fleet` chạy một đội -worker, `/restore` hoàn tác một lượt, `Tab` chuyển vòng qua Plan / Act / -Operate, `Shift+Tab` chuyển vòng qua mức phê duyệt Ask / Auto-Review / Full -Access, và `!` chạy một lệnh shell qua đường phê duyệt bình thường. +worker, và `/restore` hoàn tác một lượt. Khi vùng soạn thảo đang rảnh, `Tab` +chuyển vòng qua Plan / Act / Operate và `Shift+Tab` chuyển vòng qua tư thế +quyền Ask / Auto-Review / Full Access. `!` chạy một lệnh shell qua đường phê +duyệt bình thường. ## Tìm hiểu thêm diff --git a/README.zh-CN.md b/README.zh-CN.md index 887ccb839f..7ef24584e3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,13 +1,13 @@ - + # Codewhale -**一个运行时。所有模型。你的机器。** +**一个运行时。支持的托管与本地模型。你的机器。** -Codewhale 是运行在终端里的编程智能体。适配任意模型;开放模型优先。给它一个 provider、一个模型和一个任务:它会读你的代码、改文件、跑命令、检查自己的工作,在任务完成或需要你介入时停下。任务中途用 `/model` 切换模型。交互式工作用 TUI,脚本和 CI 用 `codewhale exec`。Rust 编写,MIT 许可,运行在你自己的机器上。 +Codewhale 是运行在终端里的编程智能体。适配受支持的托管与本地模型;开放模型优先。给它一个 provider、一个模型和一个任务:它会读你的代码、改文件、跑命令、检查自己的工作,在任务完成或需要你介入时停下。任务中途用 `/model` 切换模型。交互式工作用 TUI,脚本和 CI 用 `codewhale exec`。Rust 编写,MIT 许可,运行在你自己的机器上。 **为什么选 Codewhale:** - **不被锁定。** DeepSeek、Claude、GPT、Kimi、GLM 等 30 多家 provider,以及你自己的 vLLM、SGLang、Ollama——无需 key——都跑在同一套运行时和同一套工具之上。上下文预算与价格取自真实路由;价格未知时显示未知,绝不显示 $0。 -- **安全靠构造。** Plan 模式只读。审批把关每一次高风险调用。操作系统级沙箱守住底线——Seatbelt、Landlock、seccomp、bwrap。仓库的 `constitution.json` 会编译成写入拦截,连 Full Access 也无法跳过。 +- **安全靠构造。** Plan 模式只读。审批把关每一次高风险调用。Codewhale 只有在命令实际经由 OS 沙箱包装器运行时才报告沙箱:macOS 上是可用时启用的 Seatbelt,Linux 上是已安装且显式启用的 bubblewrap;Windows 当前报告无 OS 沙箱。仓库的 `constitution.json` 会编译成写入拦截,连 Full Access 也无法跳过。 - **工作不丢失。** Fleet 把每一步记录在只追加的账本里;`fleet resume` 从你停下的地方继续。每一轮都留下可查验的回执。 它诞生于 `deepseek-tui`。社区需要更多 provider,于是我们造了一个把模型当组件、而不是当产品的运行时。 @@ -37,7 +37,7 @@ codewhale exec "fix the failing test" # headless codewhale web # local browser client on 127.0.0.1 ``` -在 TUI 中:`/model` 同时切换 provider 和模型,`/fleet` 运行一组 worker,`/restore` 撤销某一轮,`Tab` 在 Plan / Act / Operate 之间循环切换,`Shift+Tab` 在 Ask / Auto-Review / Full Access 审批姿态之间循环切换,`!` 让 shell 命令经由正常的审批路径运行。 +在 TUI 中:`/model` 同时切换 provider 和模型,`/fleet` 运行一组 worker,`/restore` 撤销某一轮。输入区空闲时,`Tab` 在 Plan / Act / Operate 之间循环切换,`Shift+Tab` 在 Ask / Auto-Review / Full Access 权限姿态之间循环切换。`!` 让 shell 命令经由正常的审批路径运行。 ## 了解更多 diff --git a/SECURITY.md b/SECURITY.md index 4d88cc002e..82096881ca 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -46,7 +46,7 @@ You will receive status updates at each phase. If the timeline slips, we will co ### In scope (what counts) - Remote code execution through crafted prompts or model responses -- Sandbox escape — breaking out of the YOLO-mode workspace boundary or shell `cwd` confinement +- Sandbox escape — breaking out of an active Seatbelt/bubblewrap wrapper or a declared workspace boundary - Credential leak — exfiltration of API keys, tokens, or environment secrets - Arbitrary file read/write outside the intended workspace (`PathEscape` bypass) - SSRF via `fetch_url` or `web_search` against internal network endpoints diff --git a/assets/screenshot.png b/assets/screenshot.png index c72d148175..fcf1c54945 100644 Binary files a/assets/screenshot.png and b/assets/screenshot.png differ diff --git a/crates/app-server/src/chat_completions.rs b/crates/app-server/src/chat_completions.rs index f8862c7f49..ce383bbb24 100644 --- a/crates/app-server/src/chat_completions.rs +++ b/crates/app-server/src/chat_completions.rs @@ -223,8 +223,10 @@ fn endpoint_preserves_raw_model_ids(provider: ProviderKind, base_url: &str) -> b ) || provider_preserves_custom_base_url_model(provider, base_url) } -/// Build the upstream URL. -fn upstream_url(endpoint: &ResolvedModelEndpoint) -> String { +/// Build the upstream URL. DeepSeek strict function calls are a beta feature, +/// so only requests that actually carry `function.strict = true` preserve the +/// configured `/beta` route. Ordinary requests continue to use `/v1`. +fn upstream_url(endpoint: &ResolvedModelEndpoint, body: &Value) -> String { let base = endpoint.base_url.trim_end_matches('/'); match endpoint.path_suffix.as_deref() { Some(suffix) if !suffix.trim().is_empty() => format!( @@ -234,10 +236,18 @@ fn upstream_url(endpoint: &ResolvedModelEndpoint) -> String { ), _ => { let mut versioned = versioned_base_url(base); - if versioned - .rsplit('/') - .next() - .is_some_and(|segment| segment.eq_ignore_ascii_case("beta")) + let deepseek_strict_beta = endpoint.provider == ProviderKind::Deepseek + && provider_base_url_is_official(endpoint.provider, base) + && versioned + .rsplit('/') + .next() + .is_some_and(|segment| segment.eq_ignore_ascii_case("beta")) + && body_uses_strict_tools(body); + if !deepseek_strict_beta + && versioned + .rsplit('/') + .next() + .is_some_and(|segment| segment.eq_ignore_ascii_case("beta")) { versioned = format!("{}/v1", unversioned_base_url(base)); } @@ -246,6 +256,16 @@ fn upstream_url(endpoint: &ResolvedModelEndpoint) -> String { } } +fn body_uses_strict_tools(body: &Value) -> bool { + body.get("tools") + .and_then(Value::as_array) + .is_some_and(|tools| { + tools + .iter() + .any(|tool| tool.pointer("/function/strict").and_then(Value::as_bool) == Some(true)) + }) +} + fn versioned_base_url(base_url: &str) -> String { let trimmed = base_url.trim_end_matches('/'); if base_url_has_version_suffix(trimmed) { @@ -347,7 +367,7 @@ pub(crate) async fn chat_completions_handler( // provider wire ids before forwarding. body["model"] = serde_json::Value::String(endpoint.model.clone()); - let url = upstream_url(&endpoint); + let url = upstream_url(&endpoint, &body); if endpoint.insecure_skip_tls_verify { return ( @@ -1390,7 +1410,7 @@ api_key = {provider_api_key:?} wire_format: WireFormat::ChatCompletions, }; assert_eq!( - upstream_url(&endpoint), + upstream_url(&endpoint, &serde_json::json!({})), "https://api.arcee.ai/v1/chat/completions" ); } @@ -1409,7 +1429,7 @@ api_key = {provider_api_key:?} wire_format: WireFormat::ChatCompletions, }; assert_eq!( - upstream_url(&endpoint), + upstream_url(&endpoint, &serde_json::json!({})), "https://api.arcee.ai/api/v1/chat/completions" ); } @@ -1428,13 +1448,13 @@ api_key = {provider_api_key:?} wire_format: WireFormat::ChatCompletions, }; assert_eq!( - upstream_url(&endpoint), + upstream_url(&endpoint, &serde_json::json!({})), "https://openrouter.ai/api/chat/completions" ); } #[test] - fn upstream_url_beta_base_uses_standard_v1_chat_completions() { + fn upstream_url_beta_base_uses_v1_for_ordinary_chat_completions() { let endpoint = ResolvedModelEndpoint { provider: ProviderKind::Deepseek, base_url: "https://api.deepseek.com/beta".to_string(), @@ -1447,11 +1467,41 @@ api_key = {provider_api_key:?} wire_format: WireFormat::ChatCompletions, }; assert_eq!( - upstream_url(&endpoint), + upstream_url(&endpoint, &serde_json::json!({})), "https://api.deepseek.com/v1/chat/completions" ); } + #[test] + fn upstream_url_beta_base_preserves_strict_chat_completions() { + let endpoint = ResolvedModelEndpoint { + provider: ProviderKind::Deepseek, + base_url: "https://api.deepseek.com/beta".to_string(), + model: "deepseek-v4-pro".to_string(), + api_key: None, + auth_disabled: false, + http_headers: BTreeMap::new(), + path_suffix: None, + insecure_skip_tls_verify: false, + wire_format: WireFormat::ChatCompletions, + }; + let body = serde_json::json!({ + "tools": [{ + "type": "function", + "function": { + "name": "lookup", + "strict": true, + "parameters": {"type": "object"} + } + }] + }); + + assert_eq!( + upstream_url(&endpoint, &body), + "https://api.deepseek.com/beta/chat/completions" + ); + } + #[test] fn upstream_url_strips_trailing_slash() { let endpoint = ResolvedModelEndpoint { @@ -1466,7 +1516,7 @@ api_key = {provider_api_key:?} wire_format: WireFormat::ChatCompletions, }; assert_eq!( - upstream_url(&endpoint), + upstream_url(&endpoint, &serde_json::json!({})), "https://api.deepseek.com/v1/chat/completions" ); } diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 34c25d893f..4f51d30f3c 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -5284,7 +5284,9 @@ mod tests { } #[test] - fn raw_provider_dispatch_defers_dynamic_config_to_the_tui() { + fn persisted_custom_provider_crosses_config_and_root_tui_launch_boundary() { + let _lock = env_lock(); + let (_tui_dir, _tui_bin) = install_fake_tui_binary(); let dir = tempfile::TempDir::new().expect("tempdir"); let config_path = dir.path().join("config.toml"); std::fs::write( @@ -5298,12 +5300,34 @@ model = "qwen-2.5-7b" "#, ) .expect("custom provider config fixture"); + let store = ConfigStore::load(Some(config_path.clone())) + .expect("a TUI-persisted custom provider must cross the dispatcher parser"); + assert_eq!(store.config.provider, ProviderKind::Custom); + assert_eq!(store.config.provider_id(), "lm-studio"); + + let resolved = store + .config + .resolve_runtime_options(&CliRuntimeOverrides::default()); + assert_eq!(resolved.provider, ProviderKind::Custom); + assert_eq!(resolved.base_url, "http://127.0.0.1:1234/v1"); + assert_eq!(resolved.model, "qwen-2.5-7b"); + + let config = config_path.to_string_lossy().into_owned(); + let root_cli = parse_ok(&["codewhale", "--config", &config]); + let root_command = build_tui_command(&root_cli, &resolved, Vec::new()) + .expect("root launch should reach the TUI command boundary"); + let root_args = root_command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); assert!( - ConfigStore::load(Some(config_path.clone())).is_err(), - "the enum-backed dispatcher store must not be the owner of dynamic provider config" + root_args + .windows(2) + .any(|args| args == ["--config", &config]) ); + assert_eq!(command_env(&root_command, "CODEWHALE_PROVIDER"), None); + assert_eq!(command_env(&root_command, "DEEPSEEK_PROVIDER"), None); - let config = config_path.to_string_lossy().into_owned(); let cli = parse_ok(&[ "codewhale", "--config", diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 7b12ff60ff..99c3f07b6c 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -334,6 +334,16 @@ pub struct ProvidersToml { alias = "grok" )] pub xai: ProviderConfigToml, + /// Jiangsu Telecom TokenHub — OpenAI-compatible AI gateway. + #[serde( + default, + skip_serializing_if = "ProviderConfigToml::is_empty", + alias = "telecom-js", + alias = "telecom_js", + alias = "telecomjs-cn", + alias = "tokenhub" + )] + pub telecomjs: ProviderConfigToml, /// Catch-all table for the dynamic OpenAI-compatible custom provider /// identity (#1519). Arbitrary `[providers.]` tables are handled by /// the tui-side flatten map; this named slot keeps the canonical @@ -449,6 +459,7 @@ impl ProvidersToml { ProviderKind::OpencodeGo => &self.opencode_go, ProviderKind::Meta => &self.meta, ProviderKind::Xai => &self.xai, + ProviderKind::Telecomjs => &self.telecomjs, ProviderKind::Custom => &self.custom, } } @@ -489,11 +500,21 @@ impl ProvidersToml { ProviderKind::OpencodeGo => &mut self.opencode_go, ProviderKind::Meta => &mut self.meta, ProviderKind::Xai => &mut self.xai, + ProviderKind::Telecomjs => &mut self.telecomjs, ProviderKind::Custom => &mut self.custom, } } } +fn deserialize_root_provider<'de, D>(deserializer: D) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + let value = String::deserialize(deserializer)?; + let strict = serde::de::value::StringDeserializer::::new(value); + Ok(ProviderKind::deserialize(strict).unwrap_or(ProviderKind::Custom)) +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ConfigToml { /// TUI-compatible DeepSeek API key. Kept at the root so both `deepseek` @@ -506,8 +527,17 @@ pub struct ConfigToml { pub http_headers: BTreeMap, /// TUI-compatible default DeepSeek model. pub default_text_model: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_root_provider")] pub provider: ProviderKind, + /// Exact id for a dynamically named root provider. + /// + /// This is runtime parse state rather than a second on-disk key. The + /// serialized `provider` value is restored by [`ConfigStore`] so a typed + /// dispatcher read/write cannot collapse `[providers.]` back to the + /// legacy literal `custom` route. + #[doc(hidden)] + #[serde(skip)] + pub selected_provider_id: Option, pub model: Option, pub auth_mode: Option, pub output_mode: Option, @@ -1945,6 +1975,73 @@ pub struct LspConfigToml { } impl ConfigToml { + /// Exact configured provider id, including a dynamically named custom + /// provider selected by the TUI. + #[must_use] + pub fn provider_id(&self) -> &str { + self.named_custom_provider_id() + .unwrap_or_else(|| self.provider.as_str()) + } + + /// Return the exact id only when the root selection names a dynamic custom + /// provider rather than the legacy literal `custom` route. + #[must_use] + pub fn named_custom_provider_id(&self) -> Option<&str> { + (self.provider == ProviderKind::Custom) + .then_some(self.selected_provider_id.as_deref()) + .flatten() + } + + fn named_custom_provider_table(&self, provider_id: &str) -> Result<&toml::value::Table> { + let table = self + .providers + .extras + .get(provider_id) + .and_then(toml::Value::as_table) + .with_context(|| { + format!( + "custom provider '{provider_id}' requires a matching [providers.{provider_id}] table" + ) + })?; + let compatible = table + .get("kind") + .and_then(toml::Value::as_str) + .is_some_and(|kind| { + kind.trim() + .to_ascii_lowercase() + .replace('_', "-") + .eq("openai-compatible") + }); + if !compatible { + bail!( + "custom provider '{provider_id}' must set [providers.{provider_id}].kind = \"openai-compatible\"" + ); + } + Ok(table) + } + + fn named_custom_provider_config(&self) -> Option { + let provider_id = self.named_custom_provider_id()?; + self.named_custom_provider_table(provider_id).ok()?; + self.providers + .extras + .get(provider_id) + .cloned()? + .try_into() + .ok() + } + + fn bind_persisted_provider_id(&mut self, provider_id: &str) -> Result<()> { + self.selected_provider_id = None; + if self.provider != ProviderKind::Custom || provider_id == ProviderKind::Custom.as_str() { + return Ok(()); + } + + self.named_custom_provider_table(provider_id)?; + self.selected_provider_id = Some(provider_id.to_string()); + Ok(()) + } + /// Merge safe project-level overrides from `$WORKSPACE/.codewhale/config.toml` /// or legacy `$WORKSPACE/.deepseek/config.toml`. /// @@ -1997,7 +2094,7 @@ impl ConfigToml { } match key { - "provider" => Some(self.provider.as_str().to_string()), + "provider" => Some(self.provider_id().to_string()), "stream_chunk_timeout_secs" | "tui.stream_chunk_timeout_secs" => { Some(self.stream_chunk_timeout_secs().to_string()) } @@ -2079,12 +2176,21 @@ impl ConfigToml { match key { "provider" => { - self.provider = ProviderKind::parse(value).with_context(|| { - format!( - "unknown provider '{value}': expected {}", - ProviderKind::names_hint() - ) - })?; + if let Some(provider) = ProviderKind::parse(value) { + self.provider = provider; + self.selected_provider_id = None; + } else { + let provider_id = value.trim(); + self.named_custom_provider_table(provider_id) + .with_context(|| { + format!( + "unknown provider '{value}': expected {} or a configured custom provider", + ProviderKind::names_hint() + ) + })?; + self.provider = ProviderKind::Custom; + self.selected_provider_id = Some(provider_id.to_string()); + } } "api_key" => self.api_key = Some(value.to_string()), "base_url" => self.base_url = Some(value.to_string()), @@ -2120,7 +2226,10 @@ impl ConfigToml { } match key { - "provider" => self.provider = ProviderKind::Deepseek, + "provider" => { + self.provider = ProviderKind::Deepseek; + self.selected_provider_id = None; + } "api_key" => self.api_key = None, "base_url" => self.base_url = None, "http_headers" => self.http_headers.clear(), @@ -2148,7 +2257,7 @@ impl ConfigToml { #[must_use] pub fn list_values(&self) -> BTreeMap { let mut out = BTreeMap::new(); - out.insert("provider".to_string(), self.provider.as_str().to_string()); + out.insert("provider".to_string(), self.provider_id().to_string()); if let Some(v) = self.api_key.as_ref() { out.insert("api_key".to_string(), redact_secret(v)); @@ -2246,7 +2355,14 @@ impl ConfigToml { (self.provider, ProviderSource::Config) }; - let mut provider_cfg = self.providers.for_provider(provider).clone(); + let mut provider_cfg = if provider == ProviderKind::Custom + && matches!(provider_source, ProviderSource::Config) + { + self.named_custom_provider_config() + .unwrap_or_else(|| self.providers.for_provider(provider).clone()) + } else { + self.providers.for_provider(provider).clone() + }; if provider == ProviderKind::SiliconflowCN { let fb = &self.providers.siliconflow; if provider_cfg.api_key.is_none() { @@ -2352,6 +2468,7 @@ impl ConfigToml { ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL.to_string(), ProviderKind::Meta => DEFAULT_META_BASE_URL.to_string(), ProviderKind::Xai => DEFAULT_XAI_BASE_URL.to_string(), + ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL.to_string(), // The custom provider has no built-in endpoint; fall back to its // descriptor placeholder so the lookup is total. Real custom // routes always supply a configured base_url before this point. @@ -2582,8 +2699,23 @@ pub fn load_project_config(workspace: &Path) -> Option { return None; } }; - match toml::from_str(&raw) { - Ok(config) => return Some(config), + match toml::from_str::(&raw) { + Ok(config) => { + let raw_provider = toml::from_str::(&raw) + .ok() + .and_then(|document| document.get("provider").cloned()) + .and_then(|provider| provider.as_str().map(str::to_string)); + if config.provider == ProviderKind::Custom + && raw_provider.as_deref() != Some(ProviderKind::Custom.as_str()) + { + tracing::warn!( + "Failed to parse project config {}; file contents were omitted", + quote_os_path(&path) + ); + return None; + } + return Some(config); + } Err(_) => { tracing::warn!( "Failed to parse project config {}; file contents were omitted", @@ -3021,6 +3153,7 @@ fn default_model_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_MODEL, ProviderKind::Meta => DEFAULT_META_MODEL, ProviderKind::Xai => DEFAULT_XAI_MODEL, + ProviderKind::Telecomjs => DEFAULT_TELECOMJS_MODEL, // No built-in default model; the registry placeholder keeps this total. ProviderKind::Custom => provider.provider().default_model(), } @@ -3062,6 +3195,7 @@ fn default_base_url_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL, ProviderKind::Meta => DEFAULT_META_BASE_URL, ProviderKind::Xai => DEFAULT_XAI_BASE_URL, + ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL, // No built-in default base URL; the registry placeholder keeps this total. ProviderKind::Custom => provider.provider().default_base_url(), } @@ -3442,12 +3576,25 @@ impl ConfigStore { let path = resolve_config_path(path)?; let (config, original_raw) = if checked_path_exists(&path)? { let raw = read_checked_config_file(&path)?; - let parsed: ConfigToml = toml::from_str(&raw).map_err(|_| { + let mut parsed: ConfigToml = toml::from_str(&raw).map_err(|_| { anyhow::anyhow!( "failed to parse config at {}; file contents were omitted", quote_os_path(&path) ) })?; + let raw_document: toml::Value = toml::from_str(&raw).map_err(|_| { + anyhow::anyhow!( + "failed to parse config at {}; file contents were omitted", + quote_os_path(&path) + ) + })?; + if let Some(provider_id) = raw_document.get("provider").and_then(toml::Value::as_str) { + parsed + .bind_persisted_provider_id(provider_id) + .with_context(|| { + format!("failed to parse config at {}", quote_os_path(&path)) + })?; + } (parsed, Some(raw)) } else { (ConfigToml::default(), None) @@ -3468,8 +3615,15 @@ impl ConfigStore { /// [`persistence::SetupTransaction`] alongside sibling files and keep the /// comment-preserving write atomic with the rest of the transaction. pub fn rendered_body(&self) -> Result { - let serialized = + let mut serialized = toml::to_string_pretty(&self.config).context("failed to serialize config")?; + if let Some(provider_id) = self.config.named_custom_provider_id() { + let mut document = serialized + .parse::() + .context("failed to edit serialized config")?; + document["provider"] = toml_edit::value(provider_id); + serialized = document.to_string(); + } if let Some(ref original_raw) = self.original_raw { merge_and_preserve_comments(&serialized, original_raw).with_context(|| { format!( @@ -4719,6 +4873,8 @@ struct EnvRuntimeOverrides { meta_model: Option, xai_base_url: Option, xai_model: Option, + telecomjs_base_url: Option, + telecomjs_model: Option, } impl EnvRuntimeOverrides { @@ -5007,6 +5163,12 @@ impl EnvRuntimeOverrides { xai_model: std::env::var("XAI_MODEL") .ok() .filter(|v| !v.trim().is_empty()), + telecomjs_base_url: std::env::var("TELECOMJS_BASE_URL") + .ok() + .filter(|v| !v.trim().is_empty()), + telecomjs_model: std::env::var("TELECOMJS_MODEL") + .ok() + .filter(|v| !v.trim().is_empty()), } } @@ -5063,6 +5225,7 @@ impl EnvRuntimeOverrides { ProviderKind::OpencodeGo => self.opencode_go_base_url.clone(), ProviderKind::Meta => self.meta_base_url.clone(), ProviderKind::Xai => self.xai_base_url.clone(), + ProviderKind::Telecomjs => self.telecomjs_base_url.clone(), // No dedicated CODEWHALE_CUSTOM_BASE_URL env override: a custom // provider's base URL comes from its `[providers.]` table. ProviderKind::Custom => None, @@ -5097,6 +5260,7 @@ impl EnvRuntimeOverrides { ProviderKind::OpencodeGo => self.opencode_go_model.clone(), ProviderKind::Meta => self.meta_model.clone(), ProviderKind::Xai => self.xai_model.clone(), + ProviderKind::Telecomjs => self.telecomjs_model.clone(), _ => None, }?; diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index 72e31c7f50..9de5223879 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -21,11 +21,12 @@ use super::{ DEFAULT_QIANFAN_BASE_URL, DEFAULT_QIANFAN_MODEL, DEFAULT_SAKANA_BASE_URL, DEFAULT_SAKANA_MODEL, DEFAULT_SGLANG_BASE_URL, DEFAULT_SGLANG_MODEL, DEFAULT_SILICONFLOW_BASE_URL, DEFAULT_SILICONFLOW_CN_BASE_URL, DEFAULT_SILICONFLOW_MODEL, DEFAULT_STEPFUN_BASE_URL, - DEFAULT_STEPFUN_MODEL, DEFAULT_TOGETHER_BASE_URL, DEFAULT_TOGETHER_MODEL, - DEFAULT_VLLM_BASE_URL, DEFAULT_VLLM_MODEL, DEFAULT_VOLCENGINE_BASE_URL, - DEFAULT_VOLCENGINE_MODEL, DEFAULT_WANJIE_ARK_BASE_URL, DEFAULT_WANJIE_ARK_MODEL, - DEFAULT_XAI_BASE_URL, DEFAULT_XAI_MODEL, DEFAULT_XIAOMI_MIMO_BASE_URL, - DEFAULT_XIAOMI_MIMO_MODEL, DEFAULT_ZAI_BASE_URL, DEFAULT_ZAI_MODEL, ProviderKind, + DEFAULT_STEPFUN_MODEL, DEFAULT_TELECOMJS_BASE_URL, DEFAULT_TELECOMJS_MODEL, + DEFAULT_TOGETHER_BASE_URL, DEFAULT_TOGETHER_MODEL, DEFAULT_VLLM_BASE_URL, DEFAULT_VLLM_MODEL, + DEFAULT_VOLCENGINE_BASE_URL, DEFAULT_VOLCENGINE_MODEL, DEFAULT_WANJIE_ARK_BASE_URL, + DEFAULT_WANJIE_ARK_MODEL, DEFAULT_XAI_BASE_URL, DEFAULT_XAI_MODEL, + DEFAULT_XIAOMI_MIMO_BASE_URL, DEFAULT_XIAOMI_MIMO_MODEL, DEFAULT_ZAI_BASE_URL, + DEFAULT_ZAI_MODEL, ProviderKind, }; /// Wire protocol spoken by a provider. @@ -344,6 +345,12 @@ pub const fn credential_help(kind: ProviderKind) -> CredentialHelp { docs_url: None, guidance: "Use an xAI Console API key or Codewhale's native device login. Reading an existing Grok CLI file requires explicit provider-scoped read-only consent.", }, + ProviderKind::Telecomjs => CredentialHelp { + acquisition: ApiKey, + credential_url: Some("https://aigw.telecomjs.com/"), + docs_url: None, + guidance: "Create a TelecomJS TokenHub API key, then use the provider's live model catalog to discover the models available to that key.", + }, ProviderKind::Custom => CredentialHelp { acquisition: Configuration, credential_url: None, @@ -1045,6 +1052,18 @@ provider!( aliases: ["x-ai", "x_ai", "grok"] ); +provider!( + Telecomjs, + Telecomjs, + "telecomjs", + "TelecomJS TokenHub", + DEFAULT_TELECOMJS_BASE_URL, + DEFAULT_TELECOMJS_MODEL, + ["TELECOMJS_API_KEY"], + "telecomjs", + aliases: ["telecom-js", "telecom_js", "telecomjs-cn", "tokenhub"] +); + /// User-defined OpenAI-compatible endpoint (#1519). /// /// A single dynamic provider identity for arbitrary `[providers.] @@ -1132,9 +1151,10 @@ static LONGCAT: LongCat = LongCat; static OPENCODE_GO: OpencodeGo = OpencodeGo; static META: Meta = Meta; static XAI: Xai = Xai; +static TELECOMJS: Telecomjs = Telecomjs; static CUSTOM: Custom = Custom; -static PROVIDER_REGISTRY: [&dyn Provider; 35] = [ +static PROVIDER_REGISTRY: [&dyn Provider; 36] = [ &DEEPSEEK, &DEEPSEEK_ANTHROPIC, &NVIDIA_NIM, @@ -1169,6 +1189,7 @@ static PROVIDER_REGISTRY: [&dyn Provider; 35] = [ &OPENCODE_GO, &META, &XAI, + &TELECOMJS, &CUSTOM, ]; diff --git a/crates/config/src/provider_defaults.rs b/crates/config/src/provider_defaults.rs index 879d0c4cab..36a1641c63 100644 --- a/crates/config/src/provider_defaults.rs +++ b/crates/config/src/provider_defaults.rs @@ -155,3 +155,6 @@ pub(crate) const DEFAULT_META_BASE_URL: &str = "https://api.meta.ai/v1"; // xAI / Grok API-key route defaults pub(crate) const DEFAULT_XAI_MODEL: &str = "grok-4.5"; pub(crate) const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1"; +// TelecomJS (Jiangsu Telecom TokenHub) defaults +pub(crate) const DEFAULT_TELECOMJS_MODEL: &str = "deepseek-v4-pro"; +pub(crate) const DEFAULT_TELECOMJS_BASE_URL: &str = "https://aigw.telecomjs.com/v1"; diff --git a/crates/config/src/provider_kind.rs b/crates/config/src/provider_kind.rs index 07d02d307e..232487f7c6 100644 --- a/crates/config/src/provider_kind.rs +++ b/crates/config/src/provider_kind.rs @@ -124,6 +124,18 @@ pub enum ProviderKind { Meta, #[serde(alias = "x-ai", alias = "x_ai", alias = "grok")] Xai, + /// Jiangsu Telecom TokenHub (OpenAI-compatible). + /// + /// An AI gateway operated by Jiangsu Telecom that speaks the OpenAI Chat + /// Completions wire protocol and serves a broad model catalog; each API key + /// may access a different subset of models. + #[serde( + alias = "telecom-js", + alias = "telecom_js", + alias = "telecomjs-cn", + alias = "tokenhub" + )] + Telecomjs, /// User-defined OpenAI-compatible endpoint (#1519). /// /// A single dynamic identity for arbitrary `[providers.] @@ -135,7 +147,7 @@ pub enum ProviderKind { } impl ProviderKind { - pub const ALL: [Self; 35] = [ + pub const ALL: [Self; 36] = [ Self::Deepseek, Self::DeepseekAnthropic, Self::NvidiaNim, @@ -170,6 +182,7 @@ impl ProviderKind { Self::OpencodeGo, Self::Meta, Self::Xai, + Self::Telecomjs, Self::Custom, ]; diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 183986dc27..f5081f1896 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -871,6 +871,9 @@ struct EnvGuard { xai_api_key: Option, xai_base_url: Option, xai_model: Option, + telecomjs_api_key: Option, + telecomjs_base_url: Option, + telecomjs_model: Option, opencode_go_api_key: Option, opencode_go_base_url: Option, opencode_go_model: Option, @@ -900,6 +903,9 @@ impl EnvGuard { xai_api_key: env::var_os("XAI_API_KEY"), xai_base_url: env::var_os("XAI_BASE_URL"), xai_model: env::var_os("XAI_MODEL"), + telecomjs_api_key: env::var_os("TELECOMJS_API_KEY"), + telecomjs_base_url: env::var_os("TELECOMJS_BASE_URL"), + telecomjs_model: env::var_os("TELECOMJS_MODEL"), opencode_go_api_key: env::var_os("OPENCODE_GO_API_KEY"), opencode_go_base_url: env::var_os("OPENCODE_GO_BASE_URL"), opencode_go_model: env::var_os("OPENCODE_GO_MODEL"), @@ -1022,6 +1028,9 @@ impl EnvGuard { env::remove_var("XAI_API_KEY"); env::remove_var("XAI_BASE_URL"); env::remove_var("XAI_MODEL"); + env::remove_var("TELECOMJS_API_KEY"); + env::remove_var("TELECOMJS_BASE_URL"); + env::remove_var("TELECOMJS_MODEL"); env::remove_var("OPENCODE_GO_API_KEY"); env::remove_var("OPENCODE_GO_BASE_URL"); env::remove_var("OPENCODE_GO_MODEL"); @@ -1167,6 +1176,9 @@ impl Drop for EnvGuard { Self::restore_var("XAI_API_KEY", self.xai_api_key.take()); Self::restore_var("XAI_BASE_URL", self.xai_base_url.take()); Self::restore_var("XAI_MODEL", self.xai_model.take()); + Self::restore_var("TELECOMJS_API_KEY", self.telecomjs_api_key.take()); + Self::restore_var("TELECOMJS_BASE_URL", self.telecomjs_base_url.take()); + Self::restore_var("TELECOMJS_MODEL", self.telecomjs_model.take()); Self::restore_var("OPENCODE_GO_API_KEY", self.opencode_go_api_key.take()); Self::restore_var("OPENCODE_GO_BASE_URL", self.opencode_go_base_url.take()); Self::restore_var("OPENCODE_GO_MODEL", self.opencode_go_model.take()); @@ -2937,6 +2949,29 @@ fn load_project_config_rejects_symlinked_primary_config() { ); } +#[test] +fn load_project_config_keeps_unknown_provider_names_strict() { + let workspace = tempfile::tempdir().expect("workspace tempdir"); + let config_dir = workspace.path().join(CODEWHALE_APP_DIR); + fs::create_dir_all(&config_dir).expect("mkdir project config"); + fs::write( + config_dir.join(CONFIG_FILE_NAME), + r#"provider = "opencode_zen" +model = "must-not-apply" + +[providers.opencode_zen] +kind = "openai-compatible" +base_url = "https://opencode.example/v1" +"#, + ) + .expect("write project config"); + + assert!( + load_project_config(workspace.path()).is_none(), + "project overlays must not gain named-provider authority" + ); +} + #[cfg(unix)] #[test] fn load_sibling_permissions_rejects_symlink_file() { @@ -3652,6 +3687,90 @@ fn unknown_provider_error_lists_huggingface() { assert!(message.contains("huggingface")); } +#[test] +fn config_store_preserves_named_custom_provider_identity_across_typed_dispatch_reads() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("config.toml"); + fs::write( + &path, + r#"# written by the TUI custom-provider flow +provider = "opencode_zen" + +[providers.opencode_zen] +kind = "openai-compatible" +base_url = "https://opencode.example/v1" +model = "deepseek-v4-flash-free" +api_key_env = "OPENCODE_ZEN_API_KEY" +"#, + ) + .expect("custom provider fixture"); + + let mut store = ConfigStore::load(Some(path.clone())).expect("dispatcher config should load"); + assert_eq!(store.config.provider, ProviderKind::Custom); + assert_eq!(store.config.provider_id(), "opencode_zen"); + assert_eq!( + store.config.get_value("provider").as_deref(), + Some("opencode_zen") + ); + assert_eq!( + store + .config + .list_values() + .get("provider") + .map(String::as_str), + Some("opencode_zen") + ); + + let resolved = store + .config + .resolve_runtime_options(&CliRuntimeOverrides::default()); + assert_eq!(resolved.provider, ProviderKind::Custom); + assert_eq!(resolved.provider_source, ProviderSource::Config); + assert_eq!(resolved.base_url, "https://opencode.example/v1"); + assert_eq!(resolved.model, "deepseek-v4-flash-free"); + + store + .config + .set_value("telemetry", "false") + .expect("unrelated typed mutation"); + let rendered = store + .rendered_body() + .expect("render custom provider config"); + assert!( + rendered.contains("provider = \"opencode_zen\""), + "{rendered}" + ); + assert!(!rendered.contains("provider = \"custom\""), "{rendered}"); + assert!(!rendered.contains("[providers.custom]"), "{rendered}"); + + store.save().expect("save custom provider config"); + let reloaded = ConfigStore::load(Some(path)).expect("reload custom provider config"); + assert_eq!(reloaded.config.provider_id(), "opencode_zen"); +} + +#[test] +fn named_custom_root_provider_requires_a_matching_openai_compatible_table() { + for body in [ + "provider = \"opencode_zen\"\n", + r#"provider = "opencode_zen" + +[providers.opencode_zen] +kind = "anthropic-messages" +base_url = "https://opencode.example/v1" +"#, + ] { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("config.toml"); + fs::write(&path, body).expect("invalid custom provider fixture"); + let err = ConfigStore::load(Some(path)).expect_err("invalid custom route should fail"); + let message = format!("{err:#}"); + assert!(message.contains("opencode_zen"), "{message}"); + assert!(message.contains("openai-compatible") || message.contains("matching")); + } +} + #[test] fn provider_kind_accepts_legacy_deepseek_cn_aliases() { for alias in [ @@ -3874,6 +3993,74 @@ model = "opencode-go/glm-5.2" assert_eq!(resolved.api_key, None); } +#[test] +fn telecomjs_resolves_key_scoped_chat_completions_route() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + + for alias in [ + "telecomjs", + "telecom-js", + "telecom_js", + "telecomjs-cn", + "tokenhub", + ] { + assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Telecomjs)); + + let parsed: ConfigToml = + toml::from_str(&format!("provider = \"{alias}\"")).expect("TelecomJS alias"); + assert_eq!(parsed.provider, ProviderKind::Telecomjs); + } + + let metadata = provider::resolve_provider("tokenhub").expect("provider metadata"); + assert_eq!(metadata.id(), "telecomjs"); + assert_eq!(metadata.display_name(), "TelecomJS TokenHub"); + assert_eq!(metadata.provider_config_key(), "telecomjs"); + assert_eq!(metadata.default_base_url(), DEFAULT_TELECOMJS_BASE_URL); + assert_eq!(metadata.default_model(), DEFAULT_TELECOMJS_MODEL); + assert_eq!(metadata.env_vars(), &["TELECOMJS_API_KEY"]); + assert_eq!(metadata.wire(), provider::WireFormat::ChatCompletions); + + let config: ConfigToml = toml::from_str( + r#" +provider = "telecomjs" + +[providers.telecomjs] +api_key = "telecom-config-key" +model = "glm-5.2" +"#, + ) + .expect("TelecomJS provider table"); + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + assert_eq!(resolved.provider, ProviderKind::Telecomjs); + assert_eq!(resolved.base_url, DEFAULT_TELECOMJS_BASE_URL); + assert_eq!(resolved.model, "glm-5.2"); + assert_eq!(resolved.api_key.as_deref(), Some("telecom-config-key")); + assert_eq!( + resolved.api_key_source, + Some(RuntimeApiKeySource::ConfigFile) + ); + + unsafe { + std::env::set_var("TELECOMJS_API_KEY", "telecom-env-key"); + std::env::set_var("TELECOMJS_MODEL", "kimi-k2.5"); + } + assert_eq!( + codewhale_secrets::env_for("tokenhub").as_deref(), + Some("telecom-env-key") + ); + + let env_config = ConfigToml { + provider: ProviderKind::Telecomjs, + ..ConfigToml::default() + }; + let resolved = env_config.resolve_runtime_options(&CliRuntimeOverrides::default()); + assert_eq!(resolved.base_url, DEFAULT_TELECOMJS_BASE_URL); + assert_eq!(resolved.model, "kimi-k2.5"); + assert_eq!(resolved.api_key.as_deref(), Some("telecom-env-key")); + assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env)); +} + #[test] fn meta_model_api_scopes_both_documented_key_names_to_official_endpoint() { let _lock = env_lock(); diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs index feaf8a7b99..091c1e22ee 100644 --- a/crates/secrets/src/lib.rs +++ b/crates/secrets/src/lib.rs @@ -991,6 +991,7 @@ impl Secrets { /// | `wanjie` / `wanjie-ark` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` | /// | `meta` / `muse-spark` | `META_MODEL_API_KEY`, `MODEL_API_KEY` | /// | `xai` / `grok` | `XAI_API_KEY` | +/// | `telecomjs` / `tokenhub` | `TELECOMJS_API_KEY` | /// /// Returns `None` if the provider is not recognised or none of its /// candidate environment variables are set to a non-empty value. @@ -1040,6 +1041,9 @@ pub fn env_for(name: &str) -> Option { "meta" | "meta-ai" | "meta_ai" | "meta-model-api" | "meta_model_api" | "muse" | "muse-spark" => &["META_MODEL_API_KEY", "MODEL_API_KEY"], "xai" | "x-ai" | "x_ai" | "grok" => &["XAI_API_KEY"], + "telecomjs" | "telecom-js" | "telecom_js" | "telecomjs-cn" | "tokenhub" => { + &["TELECOMJS_API_KEY"] + } _ => return None, }; for var in candidates { @@ -1098,6 +1102,7 @@ mod tests { "META_MODEL_API_KEY", "MODEL_API_KEY", "XAI_API_KEY", + "TELECOMJS_API_KEY", SECRET_BACKEND_ENV, LEGACY_SECRET_BACKEND_ENV, ] { @@ -1567,6 +1572,25 @@ mod tests { clear_known_envs(); } + #[test] + fn telecomjs_env_aliases_resolve() { + let _guard = env_lock(); + clear_known_envs(); + unsafe { std::env::set_var("TELECOMJS_API_KEY", "telecom-key") }; + + for alias in [ + "telecomjs", + "telecom-js", + "telecom_js", + "telecomjs-cn", + "tokenhub", + ] { + assert_eq!(env_for(alias).as_deref(), Some("telecom-key"), "{alias}"); + } + + clear_known_envs(); + } + #[test] fn opencode_go_env_aliases_resolve() { let _guard = env_lock(); diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index da58c09577..1cefc867e6 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,13 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.9.1] - 2026-07-20 +## [0.9.1] - Unreleased candidate -Codewhale v0.9.1 ships a first-class local web client over the Runtime API, -first-class OpenCode Go and restored xAI device login on the provider surface, +The Codewhale v0.9.1 source candidate includes a first-class local web client over the Runtime API, +first-class OpenCode Go and TelecomJS TokenHub providers and restored xAI device login, calendar-correct hourly automations, a buildable OpenHarmony workflow-js target, and hardening for Auto routing, remote-terminal clipboard transport, -restart recovery, and the community site's content boundaries. +restart recovery, and a coherent TUI, Work, evidence, and public release +surface. ### Added @@ -34,7 +35,14 @@ restart recovery, and the community site's content boundaries. of this narrow route until Codewhale supports per-model wire selection (#1481 by @seanthefuturegorilla; implementation harvested from PR #773 by @zhangweiii and PR #1050 by @sternelee). -- Publish native Windows ARM64 `codewhale`, `codew`, and `codewhale-tui` +- Add TelecomJS TokenHub as a first-class Chat Completions provider with + `[providers.telecomjs]`, `TELECOMJS_API_KEY`, and a key-scoped live + `/v1/models` refresh. Models.dev and provider-specific catalogs remain in + separate source partitions so either refresh order preserves both; refreshes + do not delete the other source's rows, matching model ids from unrelated + providers do not fabricate metadata, and chat requests omit unsupported + reasoning fields (PR #4370 by @baendlorel; harvested with co-authorship). +- Prepare native Windows ARM64 `codewhale`, `codew`, and `codewhale-tui` binaries, npm selection, updater support, and standard/portable release archives. Build and smoke them on GitHub's native Windows 11 ARM runner, and move Linux ARM64 release builds to the native Ubuntu ARM runner to @@ -43,6 +51,20 @@ restart recovery, and the community site's content boundaries. role vocabulary with alias parser and `SubAgentType` mapping (#3934). - `load_skill` tool now supports listing: omit `name` or pass `"list"` to see all available skills without loading one (#4651). +- Add a unified `/skills` manager with one precedence-aware root catalog, + bounded duplicate/shadow/conflict auditing, package provenance, and + validated install, update, remove, and trust mutations (PR #4679 by + @SamhandsomeLee). +- Add a safe Agent Details view and bounded, structured `current_activity` to + the single Work projection, sourced from worker events instead of renderer + string inference. Rows stay compact, exact evidence is opt-in, and raw child + output never enters the parent transcript (#2889 and #4636; design direction + by @aboimpinto, preserved from #2694). +- Make exact results and delegated coordination durable: non-inline tool output + becomes immutable session-owned evidence behind bounded receipts; File + mutations add configurable success-only diffs; and decisions and write + contention survive restart with typed neutral-fan-in records (#4619, #4636, + #4647). - Runtime API provider registry and atomic provider-switch endpoints (`GET /v1/providers`, `GET /v1/providers/{id}/models`, `POST /v1/providers/{id}/switch`) so the web GUI renders a dynamic @@ -56,6 +78,18 @@ restart recovery, and the community site's content boundaries. ### Changed +- Simplify the model-facing runtime around stable action tools (`File`, `Git`, + `Run`, deferred `Web`, and durable task and automation families), with legacy + spellings hidden for replay. Fresh sessions no longer reserve a Work surface + before real work exists (PR #4675). +- Give the terminal shell one deliberate visual language: cool Plan → Act → + Operate and warm Ask → Auto-Review → Full Access ramps match between header + and split composer edges; transcript rhythm groups related activity; a + refined whale keeps the empty state calm; and one-cell live motion with + truthful labels distinguishes reasoning, reading, tool use, and verification + without exposing private reasoning text. Reduced-motion and animation-off + settings freeze it, while ASCII-safe terminals retain the signal (#4676, + #4677). - Unified shell tool: the model now sees a single `Bash` tool with an `action` parameter (run/wait/interact/cancel). Legacy `exec_shell*` names remain as hidden compat aliases for transcript replay, and the tool-search catalog @@ -86,6 +120,11 @@ restart recovery, and the community site's content boundaries. ### Fixed +- Default canonical `Bash` runs with no explicit `cwd` to the active + `ToolContext.workspace`, including an isolated sub-agent worktree, instead of + falling through to the shared shell manager's parent workspace. The regression + test detects the selected workspace through marker files so it remains + meaningful across PowerShell path spellings (#4674, PR #4673 by @fleitz). - Generate QuickJS bindings for `aarch64-unknown-linux-ohos` with the native SDK's libclang and sysroot, carry the OHOS target and sysroot through final linking, and keep unsupported persistent PTY dependencies out of the target @@ -133,6 +172,9 @@ restart recovery, and the community site's content boundaries. instead of tinting it green-grey through the default underwater Ombre treatment, while retaining foreground ambient life (#4457 by @AiurArtanis; PR #4471 by @nightt5879). +- Register `/slop` and `/canzha` as compatibility aliases of `/debt`, while + keeping user-command ownership truthful across dispatch, help, slash + completion, alias copy, and typo suggestions (PR #4680 by @nightt5879). - Fail closed on legacy Kimi CLI credential imports: remove Codewhale's hard-coded first-party-client impersonation and refresh request, never auto-enable or rewrite imported credentials, and label the compatibility @@ -234,9 +276,21 @@ Thank you to the contributors whose code, reports, and reviews shaped v0.9.1: and idle-timeout progress telemetry (PR #4657). - [@gaord](https://github.com/gaord) — Runtime API provider registry and atomic provider-switch endpoints (PR #4658). -- [@SamhandsomeLee](https://github.com/SamhandsomeLee) — Enter-send lag - diagnosis and fix direction for #4605 (PR #4654; landed via the release - lane async-dispatch split). +- [@SamhandsomeLee](https://github.com/SamhandsomeLee) — the unified `/skills` + root catalog, audit/provenance model, validated mutations, manager UI, and + acceptance coverage (PR #4679), plus Enter-send lag diagnosis and fix + direction for #4605 (PR #4654; landed via the release-lane async-dispatch + split). +- [@aboimpinto](https://github.com/aboimpinto) — the Layer 5.1 user-command + registry boundary from PR #3278; the exact authored evidence commit from PR + #4046, preserved intact in the integration graph; and the #2870 follow-up + audit whose metadata and malformed-sibling gaps shaped the final corrections. + Paulo also provided the structured, redacted Agent Details and + `current_activity` direction preserved from #2694/#2889 and the real-PTY + lifecycle acceptance direction from #2886. +- [@baendlorel](https://github.com/baendlorel) — TelecomJS TokenHub provider + support and key-scoped live-catalog direction from PR #4370, harvested into + the current provider architecture with co-authorship preserved. - [@zhangweiii](https://github.com/zhangweiii) and [@sternelee](https://github.com/sternelee) — the original first-class OpenCode Go implementations (PRs #773 and #1050), harvested into the @@ -244,8 +298,9 @@ Thank you to the contributors whose code, reports, and reviews shaped v0.9.1: - [@seanthefuturegorilla](https://github.com/seanthefuturegorilla) — the canonical OpenCode Go/Zen provider request and acceptance direction (#1481). -- [@nightt5879](https://github.com/nightt5879) — the Solarized Light - background preservation fix (PR #4471). +- [@nightt5879](https://github.com/nightt5879) — `/debt` compatibility aliases + with dispatch-consistent user-command shadowing across discovery surfaces + (PR #4680), plus the Solarized Light background preservation fix (PR #4471). - [@AiurArtanis](https://github.com/AiurArtanis) — the Solarized Light regression report and reproduction (#4457). - [@shenjackyuanjie](https://github.com/shenjackyuanjie) — the HarmonyOS @@ -265,6 +320,9 @@ Thank you to the contributors whose code, reports, and reviews shaped v0.9.1: contract (PRs #4475 and #4476). - [@dmitri-0](https://github.com/dmitri-0) — configurable cache-hit visibility in the phase strip (PR #4474). +- [@fleitz](https://github.com/fleitz) — the canonical `Bash` no-`cwd` + workspace fix and regression test that keep isolated sub-agent commands in + their own worktree (PR #4673, closing #4674). - [@SparkofSpike](https://github.com/SparkofSpike) — the Windows Ctrl+O reproduction that exposed pre-pager result truncation and conflicting composer shortcut routing (#4482), and the exact Vim-space regression reproduction diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 508ba99a31..48f49586c2 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -64,6 +64,7 @@ tokio.workspace = true tokio-util = { version = "0.7.16", features = ["io"] } unicode-width = "0.2" unicode-segmentation = "1.12" +unicode-normalization = "0.1" urlencoding = "2.1" uuid.workspace = true chrono.workspace = true diff --git a/crates/tui/locales/en.json b/crates/tui/locales/en.json index 0afc385a96..bf5afef849 100644 --- a/crates/tui/locales/en.json +++ b/crates/tui/locales/en.json @@ -200,7 +200,7 @@ "CmdSidebarDescription": "Toggle or focus the right sidebar", "CmdSkillDescription": "Activate a skill, or install/update/uninstall/trust a community skill", "CmdSkillsDescription": "List local skills (filter by `/skills `; --remote browses the curated registry)", - "CmdSlopDescription": "Inspect or export the debt ledger", + "CmdDebtDescription": "Inspect or export the debt ledger", "CmdStashDescription": "Park or restore a composer draft (Ctrl+S sends queued follow-up first; otherwise stash, /stash list/pop)", "CmdStatusDescription": "Show runtime session status", "CmdStatuslineDescription": "Configure which items appear in the footer", @@ -830,6 +830,9 @@ "PhaseIdle": "idle", "PhaseDraft": "draft", "PhaseWorking": "working", + "PhaseReasoning": "reasoning", + "PhaseReading": "reading", + "PhaseUsingTool": "using tool", "PhaseVerifying": "verifying", "PhaseWaitingOnYou": "waiting on you", "PhaseDone": "done", @@ -1003,6 +1006,45 @@ "SidebarStopControl": "stop", "SidebarDestructiveArmed": "Destructive action armed — press Enter to confirm or clear the draft to cancel", "WorkSurfaceStopConfirmHint": "confirm stop · Esc cancels", + "CoordinationWorkTitle": "Coordination Work", + "CoordinationSummaryDecisions": "{count} decisions", + "CoordinationSummaryContentions": "{count} contentions", + "CoordinationSummaryReconciled": "{count} reconciled", + "CoordinationSchema": "Schema {value}", + "CoordinationSequence": "sequence {value}", + "CoordinationPerSectionLimit": "up to {limit} per section", + "CoordinationDecisionsHeading": "Decisions", + "CoordinationNone": "None", + "CoordinationNoneValue": "none", + "CoordinationStatus": "status {status}", + "CoordinationOwner": "owner {owner}", + "CoordinationVersion": "version {version}", + "CoordinationWriteClaimsHeading": "Write claims", + "CoordinationIsolated": "isolated", + "CoordinationSharedWorkspace": "shared workspace", + "CoordinationPaths": "paths {paths}", + "CoordinationContracts": "contracts {contracts}", + "CoordinationContentionsHeading": "Contentions", + "CoordinationClaimant": "claimant {claimant}", + "CoordinationDisposition": "disposition {disposition}", + "CoordinationNeutralReconciliationHeading": "Neutral reconciliation", + "CoordinationCandidates": "{count} candidates", + "CoordinationRetry": "retry {count}/{limit}", + "CoordinationReviewer": "reviewer {reviewer}", + "CoordinationVerifier": "verifier {verifier}", + "CoordinationVerification": "verification {verification}", + "CoordinationContextProjectionsHeading": "Context projections", + "CoordinationContextDecisions": "decisions {decisions}", + "CoordinationBytes": "{count} bytes", + "CoordinationDeduplicated": "{count} deduplicated", + "CoordinationOmitted": "{count} omitted", + "CoordinationActiveHotPathsHeading": "Active hot paths", + "CoordinationActiveClaims": "{count} active claims", + "CoordinationMetricsNoteHeading": "Metrics note", + "CoordinationMetricsNoAuthoritativeSource": "growth and route/cost stay null when the coordination ledger has no authoritative source", + "CoordinationStatusProposed": "proposed", + "CoordinationStatusAccepted": "accepted", + "CoordinationStatusSuperseded": "superseded", "ComposerSlashMenuHint": " enter:run · tab:complete · ↑↓:select · esc:keep typing ", "ApprovalRepoLawBadge": "REPO LAW", "ApprovalRepoLawTitle": "Repository constitution", diff --git a/crates/tui/locales/es-419.json b/crates/tui/locales/es-419.json index 24c243cda8..eff24e513d 100644 --- a/crates/tui/locales/es-419.json +++ b/crates/tui/locales/es-419.json @@ -200,7 +200,7 @@ "CmdSidebarDescription": "Alternar o enfocar la barra lateral derecha", "CmdSkillDescription": "Activar una skill, o instalar/actualizar/desinstalar/confiar en una skill de la comunidad", "CmdSkillsDescription": "Listar skills locales (filtra con `/skills `; --remote navega el registro curado)", - "CmdSlopDescription": "Inspeccionar o exportar el libro de deuda técnica", + "CmdDebtDescription": "Inspeccionar o exportar el libro de deuda técnica", "CmdStashDescription": "Estacionar o restaurar borrador del compositor (Ctrl+S estaciona, /stash list|pop)", "CmdStatusDescription": "Mostrar el estado de la sesión en ejecución", "CmdStatuslineDescription": "Configurar qué elementos aparecen en el pie de página", @@ -830,6 +830,9 @@ "PhaseIdle": "inactivo", "PhaseDraft": "borrador", "PhaseWorking": "trabajando", + "PhaseReasoning": "razonando", + "PhaseReading": "leyendo", + "PhaseUsingTool": "usando herramienta", "PhaseVerifying": "verificando", "PhaseWaitingOnYou": "esperándote", "PhaseDone": "listo", @@ -1003,6 +1006,45 @@ "SidebarStopControl": "detener", "SidebarDestructiveArmed": "Acción destructiva armada — presiona Enter para confirmar o limpia el borrador para cancelar", "WorkSurfaceStopConfirmHint": "confirmar detención · Esc cancela", + "CoordinationWorkTitle": "Trabajo de coordinación", + "CoordinationSummaryDecisions": "{count} decisiones", + "CoordinationSummaryContentions": "{count} contenciones", + "CoordinationSummaryReconciled": "{count} reconciliaciones", + "CoordinationSchema": "Esquema {value}", + "CoordinationSequence": "secuencia {value}", + "CoordinationPerSectionLimit": "hasta {limit} por sección", + "CoordinationDecisionsHeading": "Decisiones", + "CoordinationNone": "Ninguna", + "CoordinationNoneValue": "ninguno", + "CoordinationStatus": "estado {status}", + "CoordinationOwner": "responsable {owner}", + "CoordinationVersion": "versión {version}", + "CoordinationWriteClaimsHeading": "Reclamos de escritura", + "CoordinationIsolated": "aislado", + "CoordinationSharedWorkspace": "espacio de trabajo compartido", + "CoordinationPaths": "rutas {paths}", + "CoordinationContracts": "contratos {contracts}", + "CoordinationContentionsHeading": "Contenciones", + "CoordinationClaimant": "solicitante {claimant}", + "CoordinationDisposition": "disposición {disposition}", + "CoordinationNeutralReconciliationHeading": "Reconciliación neutral", + "CoordinationCandidates": "{count} candidatos", + "CoordinationRetry": "reintento {count}/{limit}", + "CoordinationReviewer": "revisor {reviewer}", + "CoordinationVerifier": "verificador {verifier}", + "CoordinationVerification": "verificación {verification}", + "CoordinationContextProjectionsHeading": "Proyecciones de contexto", + "CoordinationContextDecisions": "decisiones {decisions}", + "CoordinationBytes": "{count} bytes", + "CoordinationDeduplicated": "{count} sin duplicados", + "CoordinationOmitted": "{count} omitidos", + "CoordinationActiveHotPathsHeading": "Rutas críticas activas", + "CoordinationActiveClaims": "{count} reclamos activos", + "CoordinationMetricsNoteHeading": "Nota de métricas", + "CoordinationMetricsNoAuthoritativeSource": "el crecimiento y la ruta/el costo permanecen nulos cuando el registro de coordinación no tiene una fuente autorizada", + "CoordinationStatusProposed": "propuesto", + "CoordinationStatusAccepted": "aceptado", + "CoordinationStatusSuperseded": "reemplazado", "ComposerSlashMenuHint": " enter:ejecutar · tab:completar · ↑↓:seleccionar · esc:seguir escribiendo ", "ApprovalRepoLawBadge": "LEY DEL REPO", "ApprovalRepoLawTitle": "Constitución del repositorio", diff --git a/crates/tui/locales/ja.json b/crates/tui/locales/ja.json index 90bbc98120..c412c0e25d 100644 --- a/crates/tui/locales/ja.json +++ b/crates/tui/locales/ja.json @@ -200,7 +200,7 @@ "CmdSidebarDescription": "右サイドバーの表示/フォーカスを切り替え", "CmdSkillDescription": "スキルを有効化、またはコミュニティスキルをインストール/更新/アンインストール/信頼", "CmdSkillsDescription": "ローカルスキルを一覧表示(`/skills ` で絞り込み、--remote で精選レジストリを参照)", - "CmdSlopDescription": "負債台帳を確認またはエクスポート", + "CmdDebtDescription": "負債台帳を確認またはエクスポート", "CmdStashDescription": "コンポーザーの下書きを退避/復元(Ctrl+S で退避、/stash list|pop)", "CmdStatusDescription": "実行中のセッション状態を表示", "CmdStatuslineDescription": "フッターに表示する項目を設定", @@ -830,6 +830,9 @@ "PhaseIdle": "待機", "PhaseDraft": "下書き", "PhaseWorking": "処理中", + "PhaseReasoning": "思考中", + "PhaseReading": "読込中", + "PhaseUsingTool": "ツール実行中", "PhaseVerifying": "検証中", "PhaseWaitingOnYou": "応答待ち", "PhaseDone": "完了", @@ -1003,6 +1006,45 @@ "SidebarStopControl": "停止", "SidebarDestructiveArmed": "破壊的操作が実行待ちです — Enter で確定、取り消すには下書きをクリアしてください", "WorkSurfaceStopConfirmHint": "停止を確認 · Esc でキャンセル", + "CoordinationWorkTitle": "調整作業", + "CoordinationSummaryDecisions": "{count} 件の決定", + "CoordinationSummaryContentions": "{count} 件の競合", + "CoordinationSummaryReconciled": "{count} 件を調整済み", + "CoordinationSchema": "スキーマ {value}", + "CoordinationSequence": "シーケンス {value}", + "CoordinationPerSectionLimit": "各セクション最大 {limit} 件", + "CoordinationDecisionsHeading": "決定", + "CoordinationNone": "なし", + "CoordinationNoneValue": "なし", + "CoordinationStatus": "状態 {status}", + "CoordinationOwner": "所有者 {owner}", + "CoordinationVersion": "バージョン {version}", + "CoordinationWriteClaimsHeading": "書き込み要求", + "CoordinationIsolated": "分離済み", + "CoordinationSharedWorkspace": "共有ワークスペース", + "CoordinationPaths": "パス {paths}", + "CoordinationContracts": "契約 {contracts}", + "CoordinationContentionsHeading": "競合", + "CoordinationClaimant": "要求者 {claimant}", + "CoordinationDisposition": "処置 {disposition}", + "CoordinationNeutralReconciliationHeading": "中立的な調整", + "CoordinationCandidates": "{count} 件の候補", + "CoordinationRetry": "再試行 {count}/{limit}", + "CoordinationReviewer": "レビュー担当 {reviewer}", + "CoordinationVerifier": "検証担当 {verifier}", + "CoordinationVerification": "検証 {verification}", + "CoordinationContextProjectionsHeading": "コンテキスト投影", + "CoordinationContextDecisions": "決定 {decisions}", + "CoordinationBytes": "{count} バイト", + "CoordinationDeduplicated": "{count} 件重複排除", + "CoordinationOmitted": "{count} 件省略", + "CoordinationActiveHotPathsHeading": "アクティブなホットパス", + "CoordinationActiveClaims": "アクティブな要求 {count} 件", + "CoordinationMetricsNoteHeading": "メトリクス注記", + "CoordinationMetricsNoAuthoritativeSource": "調整台帳に信頼できる情報源がないため、増加量とルート/コストは null のままです", + "CoordinationStatusProposed": "提案中", + "CoordinationStatusAccepted": "承認済み", + "CoordinationStatusSuperseded": "置き換え済み", "ComposerSlashMenuHint": " enter:実行 · tab:補完 · ↑↓:選択 · esc:入力を継続 ", "ApprovalRepoLawBadge": "リポジトリ憲法", "ApprovalRepoLawTitle": "リポジトリ憲法", diff --git a/crates/tui/locales/ko.json b/crates/tui/locales/ko.json index 046a013bae..0ed087ebc3 100644 --- a/crates/tui/locales/ko.json +++ b/crates/tui/locales/ko.json @@ -200,7 +200,7 @@ "CmdSidebarDescription": "오른쪽 사이드바를 전환하거나 포커스를 옮깁니다", "CmdSkillDescription": "스킬을 활성화하거나 커뮤니티 스킬을 설치/업데이트/제거/신뢰합니다", "CmdSkillsDescription": "로컬 스킬 목록을 표시합니다 (`/skills <접두사>`로 필터링, --remote는 큐레이션된 레지스트리 조회)", - "CmdSlopDescription": "부채 원장을 확인하거나 내보냅니다", + "CmdDebtDescription": "부채 원장을 확인하거나 내보냅니다", "CmdStashDescription": "입력창 초안을 보관하거나 복원합니다 (Ctrl+S는 대기 중인 후속 메시지를 먼저 전송, 그 외에는 보관, /stash list/pop)", "CmdStatusDescription": "런타임 세션 상태를 표시합니다", "CmdStatuslineDescription": "하단 표시줄에 표시할 항목을 설정합니다", @@ -830,6 +830,9 @@ "PhaseIdle": "유휴", "PhaseDraft": "초안", "PhaseWorking": "작업 중", + "PhaseReasoning": "추론 중", + "PhaseReading": "읽는 중", + "PhaseUsingTool": "도구 사용 중", "PhaseVerifying": "검증 중", "PhaseWaitingOnYou": "응답 대기", "PhaseDone": "완료", @@ -1003,6 +1006,45 @@ "SidebarStopControl": "중지", "SidebarDestructiveArmed": "파괴적 작업이 준비되었습니다 — Enter를 누르면 확인, 초안을 지우면 취소됩니다", "WorkSurfaceStopConfirmHint": "중지 확인 · Esc로 취소", + "CoordinationWorkTitle": "조정 작업", + "CoordinationSummaryDecisions": "결정 {count}개", + "CoordinationSummaryContentions": "경합 {count}개", + "CoordinationSummaryReconciled": "조정 완료 {count}개", + "CoordinationSchema": "스키마 {value}", + "CoordinationSequence": "시퀀스 {value}", + "CoordinationPerSectionLimit": "섹션당 최대 {limit}개", + "CoordinationDecisionsHeading": "결정", + "CoordinationNone": "없음", + "CoordinationNoneValue": "없음", + "CoordinationStatus": "상태 {status}", + "CoordinationOwner": "소유자 {owner}", + "CoordinationVersion": "버전 {version}", + "CoordinationWriteClaimsHeading": "쓰기 요청", + "CoordinationIsolated": "격리됨", + "CoordinationSharedWorkspace": "공유 작업 공간", + "CoordinationPaths": "경로 {paths}", + "CoordinationContracts": "계약 {contracts}", + "CoordinationContentionsHeading": "경합", + "CoordinationClaimant": "요청자 {claimant}", + "CoordinationDisposition": "처리 {disposition}", + "CoordinationNeutralReconciliationHeading": "중립 조정", + "CoordinationCandidates": "후보 {count}개", + "CoordinationRetry": "재시도 {count}/{limit}", + "CoordinationReviewer": "검토자 {reviewer}", + "CoordinationVerifier": "검증자 {verifier}", + "CoordinationVerification": "검증 {verification}", + "CoordinationContextProjectionsHeading": "컨텍스트 투영", + "CoordinationContextDecisions": "결정 {decisions}", + "CoordinationBytes": "{count}바이트", + "CoordinationDeduplicated": "중복 제거 {count}개", + "CoordinationOmitted": "생략 {count}개", + "CoordinationActiveHotPathsHeading": "활성 핫 경로", + "CoordinationActiveClaims": "활성 요청 {count}개", + "CoordinationMetricsNoteHeading": "메트릭 참고", + "CoordinationMetricsNoAuthoritativeSource": "조정 원장에 신뢰할 수 있는 소스가 없으면 증가량과 경로/비용은 null로 유지됩니다", + "CoordinationStatusProposed": "제안됨", + "CoordinationStatusAccepted": "승인됨", + "CoordinationStatusSuperseded": "대체됨", "ComposerSlashMenuHint": " enter:실행 · tab:완성 · ↑↓:선택 · esc:계속 입력 ", "ApprovalRepoLawBadge": "저장소 규칙", "ApprovalRepoLawTitle": "저장소 헌법", diff --git a/crates/tui/locales/pt-BR.json b/crates/tui/locales/pt-BR.json index f5e7bd9618..4e9a368237 100644 --- a/crates/tui/locales/pt-BR.json +++ b/crates/tui/locales/pt-BR.json @@ -200,7 +200,7 @@ "CmdSidebarDescription": "Alternar ou focar a barra lateral direita", "CmdSkillDescription": "Ativar uma skill, ou instalar/atualizar/desinstalar/confiar em uma skill da comunidade", "CmdSkillsDescription": "Listar skills locais (filtre com `/skills `; --remote navega pelo registro curado)", - "CmdSlopDescription": "Inspecionar ou exportar o livro de dívida técnica", + "CmdDebtDescription": "Inspecionar ou exportar o livro de dívida técnica", "CmdStashDescription": "Estacionar ou restaurar rascunho do compositor (Ctrl+S estaciona, /stash list|pop)", "CmdStatusDescription": "Exibir o status da sessão em execução", "CmdStatuslineDescription": "Configurar quais itens aparecem no rodapé", @@ -830,6 +830,9 @@ "PhaseIdle": "ocioso", "PhaseDraft": "rascunho", "PhaseWorking": "trabalhando", + "PhaseReasoning": "raciocinando", + "PhaseReading": "lendo", + "PhaseUsingTool": "usando ferramenta", "PhaseVerifying": "verificando", "PhaseWaitingOnYou": "aguardando você", "PhaseDone": "concluído", @@ -1003,6 +1006,45 @@ "SidebarStopControl": "parar", "SidebarDestructiveArmed": "Ação destrutiva armada — pressione Enter para confirmar ou limpe o rascunho para cancelar", "WorkSurfaceStopConfirmHint": "confirmar parada · Esc cancela", + "CoordinationWorkTitle": "Trabalho de coordenação", + "CoordinationSummaryDecisions": "{count} decisões", + "CoordinationSummaryContentions": "{count} contestações", + "CoordinationSummaryReconciled": "{count} reconciliações", + "CoordinationSchema": "Esquema {value}", + "CoordinationSequence": "sequência {value}", + "CoordinationPerSectionLimit": "até {limit} por seção", + "CoordinationDecisionsHeading": "Decisões", + "CoordinationNone": "Nenhuma", + "CoordinationNoneValue": "nenhum", + "CoordinationStatus": "status {status}", + "CoordinationOwner": "responsável {owner}", + "CoordinationVersion": "versão {version}", + "CoordinationWriteClaimsHeading": "Reivindicações de escrita", + "CoordinationIsolated": "isolado", + "CoordinationSharedWorkspace": "espaço de trabalho compartilhado", + "CoordinationPaths": "caminhos {paths}", + "CoordinationContracts": "contratos {contracts}", + "CoordinationContentionsHeading": "Contestações", + "CoordinationClaimant": "requerente {claimant}", + "CoordinationDisposition": "disposição {disposition}", + "CoordinationNeutralReconciliationHeading": "Reconciliação neutra", + "CoordinationCandidates": "{count} candidatos", + "CoordinationRetry": "nova tentativa {count}/{limit}", + "CoordinationReviewer": "revisor {reviewer}", + "CoordinationVerifier": "verificador {verifier}", + "CoordinationVerification": "verificação {verification}", + "CoordinationContextProjectionsHeading": "Projeções de contexto", + "CoordinationContextDecisions": "decisões {decisions}", + "CoordinationBytes": "{count} bytes", + "CoordinationDeduplicated": "{count} desduplicados", + "CoordinationOmitted": "{count} omitidos", + "CoordinationActiveHotPathsHeading": "Caminhos críticos ativos", + "CoordinationActiveClaims": "{count} reivindicações ativas", + "CoordinationMetricsNoteHeading": "Nota das métricas", + "CoordinationMetricsNoAuthoritativeSource": "crescimento e rota/custo permanecem nulos quando o registro de coordenação não tem uma fonte autoritativa", + "CoordinationStatusProposed": "proposto", + "CoordinationStatusAccepted": "aceito", + "CoordinationStatusSuperseded": "substituído", "ComposerSlashMenuHint": " enter:executar · tab:completar · ↑↓:selecionar · esc:continuar digitando ", "ApprovalRepoLawBadge": "LEI DO REPO", "ApprovalRepoLawTitle": "Constitution do repositório", diff --git a/crates/tui/locales/vi.json b/crates/tui/locales/vi.json index 604bcfef7f..a2d350c58e 100644 --- a/crates/tui/locales/vi.json +++ b/crates/tui/locales/vi.json @@ -200,7 +200,7 @@ "CmdSidebarDescription": "Bật/tắt hoặc lấy nét thanh bên phải", "CmdSkillDescription": "Kích hoạt một kỹ năng, hoặc cài đặt/cập nhật/gỡ bỏ/tin cậy một kỹ năng cộng đồng", "CmdSkillsDescription": "Liệt kê các kỹ năng cục bộ (lọc bằng `/skills `; --remote để duyệt kho lưu trữ được kiểm duyệt)", - "CmdSlopDescription": "Kiểm tra hoặc xuất sổ nợ kỹ thuật", + "CmdDebtDescription": "Kiểm tra hoặc xuất sổ nợ kỹ thuật", "CmdStashDescription": "Tạm cất hoặc khôi phục bản nháp (Ctrl+S để cất, /stash list/pop để xem/lấy ra)", "CmdStatusDescription": "Hiển thị trạng thái thời gian chạy của phiên", "CmdStatuslineDescription": "Cấu hình các mục hiển thị ở thanh trạng thái dưới cùng", @@ -830,6 +830,9 @@ "PhaseIdle": "rảnh", "PhaseDraft": "nháp", "PhaseWorking": "đang xử lý", + "PhaseReasoning": "đang suy luận", + "PhaseReading": "đang đọc", + "PhaseUsingTool": "đang dùng công cụ", "PhaseVerifying": "đang kiểm chứng", "PhaseWaitingOnYou": "chờ bạn", "PhaseDone": "xong", @@ -1003,6 +1006,45 @@ "SidebarStopControl": "dừng", "SidebarDestructiveArmed": "Thao tác phá hủy đã kích hoạt — nhấn Enter để xác nhận hoặc xóa bản nháp để hủy", "WorkSurfaceStopConfirmHint": "xác nhận dừng · Esc để hủy", + "CoordinationWorkTitle": "Công việc điều phối", + "CoordinationSummaryDecisions": "{count} quyết định", + "CoordinationSummaryContentions": "{count} tranh chấp", + "CoordinationSummaryReconciled": "{count} mục đã hòa giải", + "CoordinationSchema": "Lược đồ {value}", + "CoordinationSequence": "trình tự {value}", + "CoordinationPerSectionLimit": "tối đa {limit} mục mỗi phần", + "CoordinationDecisionsHeading": "Các quyết định", + "CoordinationNone": "Không có", + "CoordinationNoneValue": "không có", + "CoordinationStatus": "trạng thái {status}", + "CoordinationOwner": "chủ sở hữu {owner}", + "CoordinationVersion": "phiên bản {version}", + "CoordinationWriteClaimsHeading": "Yêu cầu ghi", + "CoordinationIsolated": "đã tách biệt", + "CoordinationSharedWorkspace": "không gian làm việc dùng chung", + "CoordinationPaths": "đường dẫn {paths}", + "CoordinationContracts": "hợp đồng {contracts}", + "CoordinationContentionsHeading": "Tranh chấp", + "CoordinationClaimant": "bên yêu cầu {claimant}", + "CoordinationDisposition": "hướng xử lý {disposition}", + "CoordinationNeutralReconciliationHeading": "Hòa giải trung lập", + "CoordinationCandidates": "{count} ứng viên", + "CoordinationRetry": "thử lại {count}/{limit}", + "CoordinationReviewer": "người đánh giá {reviewer}", + "CoordinationVerifier": "người xác minh {verifier}", + "CoordinationVerification": "xác minh {verification}", + "CoordinationContextProjectionsHeading": "Phép chiếu ngữ cảnh", + "CoordinationContextDecisions": "quyết định {decisions}", + "CoordinationBytes": "{count} byte", + "CoordinationDeduplicated": "{count} mục đã loại trùng", + "CoordinationOmitted": "{count} mục đã bỏ qua", + "CoordinationActiveHotPathsHeading": "Đường dẫn nóng đang hoạt động", + "CoordinationActiveClaims": "{count} yêu cầu đang hoạt động", + "CoordinationMetricsNoteHeading": "Ghi chú chỉ số", + "CoordinationMetricsNoAuthoritativeSource": "tăng trưởng và tuyến/chi phí giữ giá trị null khi sổ cái điều phối không có nguồn có thẩm quyền", + "CoordinationStatusProposed": "được đề xuất", + "CoordinationStatusAccepted": "đã chấp nhận", + "CoordinationStatusSuperseded": "đã thay thế", "ComposerSlashMenuHint": " enter:chạy · tab:hoàn thành · ↑↓:chọn · esc:gõ tiếp ", "ApprovalRepoLawBadge": "LUẬT REPO", "ApprovalRepoLawTitle": "Constitution của repo", diff --git a/crates/tui/locales/zh-Hans.json b/crates/tui/locales/zh-Hans.json index e7d086688c..16589670b4 100644 --- a/crates/tui/locales/zh-Hans.json +++ b/crates/tui/locales/zh-Hans.json @@ -200,7 +200,7 @@ "CmdSidebarDescription": "切换或聚焦右侧边栏", "CmdSkillDescription": "激活技能,或安装/更新/卸载/信任社区技能", "CmdSkillsDescription": "列出本地技能(用 `/skills ` 按名称前缀过滤,--remote 浏览精选注册表)", - "CmdSlopDescription": "检查或导出债务账本", + "CmdDebtDescription": "检查或导出债务账本", "CmdStashDescription": "暂存或恢复输入草稿(Ctrl+S 暂存,/stash list|pop)", "CmdStatusDescription": "显示当前运行状态", "CmdStatuslineDescription": "配置底栏要显示哪些条目", @@ -830,6 +830,9 @@ "PhaseIdle": "空闲", "PhaseDraft": "草稿", "PhaseWorking": "工作中", + "PhaseReasoning": "推理中", + "PhaseReading": "读取中", + "PhaseUsingTool": "使用工具中", "PhaseVerifying": "校验中", "PhaseWaitingOnYou": "等你处理", "PhaseDone": "完成", @@ -1003,6 +1006,45 @@ "SidebarStopControl": "停止", "SidebarDestructiveArmed": "破坏性操作已就绪 — 按 Enter 确认,或清空草稿以取消", "WorkSurfaceStopConfirmHint": "确认停止 · Esc 取消", + "CoordinationWorkTitle": "协调工作", + "CoordinationSummaryDecisions": "{count} 项决策", + "CoordinationSummaryContentions": "{count} 项争用", + "CoordinationSummaryReconciled": "{count} 项已调和", + "CoordinationSchema": "架构 {value}", + "CoordinationSequence": "序列 {value}", + "CoordinationPerSectionLimit": "每个部分最多 {limit} 条", + "CoordinationDecisionsHeading": "决策", + "CoordinationNone": "无", + "CoordinationNoneValue": "无", + "CoordinationStatus": "状态 {status}", + "CoordinationOwner": "所有者 {owner}", + "CoordinationVersion": "版本 {version}", + "CoordinationWriteClaimsHeading": "写入声明", + "CoordinationIsolated": "已隔离", + "CoordinationSharedWorkspace": "共享工作区", + "CoordinationPaths": "路径 {paths}", + "CoordinationContracts": "契约 {contracts}", + "CoordinationContentionsHeading": "争用", + "CoordinationClaimant": "声明方 {claimant}", + "CoordinationDisposition": "处置 {disposition}", + "CoordinationNeutralReconciliationHeading": "中立调和", + "CoordinationCandidates": "{count} 个候选项", + "CoordinationRetry": "重试 {count}/{limit}", + "CoordinationReviewer": "审核者 {reviewer}", + "CoordinationVerifier": "验证者 {verifier}", + "CoordinationVerification": "验证 {verification}", + "CoordinationContextProjectionsHeading": "上下文投影", + "CoordinationContextDecisions": "决策 {decisions}", + "CoordinationBytes": "{count} 字节", + "CoordinationDeduplicated": "已去重 {count} 项", + "CoordinationOmitted": "已省略 {count} 项", + "CoordinationActiveHotPathsHeading": "活动热点路径", + "CoordinationActiveClaims": "{count} 个活跃声明", + "CoordinationMetricsNoteHeading": "指标说明", + "CoordinationMetricsNoAuthoritativeSource": "协调账本没有权威来源时,增长和路由/成本保持为 null", + "CoordinationStatusProposed": "已提议", + "CoordinationStatusAccepted": "已接受", + "CoordinationStatusSuperseded": "已取代", "ComposerSlashMenuHint": " enter:运行 · tab:补全 · ↑↓:选择 · esc:继续输入 ", "ApprovalRepoLawBadge": "仓库规则", "ApprovalRepoLawTitle": "仓库规则", diff --git a/crates/tui/locales/zh-Hant.json b/crates/tui/locales/zh-Hant.json index afa2edcf0c..d5839b2a83 100644 --- a/crates/tui/locales/zh-Hant.json +++ b/crates/tui/locales/zh-Hant.json @@ -473,5 +473,8 @@ "LinksCommunity": "社群與貢獻:", "LinksGitHub": "GitHub:", "LinksManagedApp": "託管應用程式預覽:", - "LinksManagedAppNote": "託管應用程式使用獨立登入,不會連接目前的本機工作階段。" + "LinksManagedAppNote": "託管應用程式使用獨立登入,不會連接目前的本機工作階段。", + "PhaseReasoning": "推理中", + "PhaseReading": "讀取中", + "PhaseUsingTool": "使用工具中" } diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index f87513b218..09b1c66c0a 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -775,6 +775,51 @@ pub(super) fn api_url_with_suffix(base_url: &str, path: &str, path_suffix: Optio format!("{}/{}", versioned.trim_end_matches('/'), path) } +/// Route strict DeepSeek tool requests through the beta Chat Completions +/// surface while keeping every ordinary request on the canonical `/v1` path. +/// +/// DeepSeek requires its `/beta` base URL when a function opts into +/// `strict: true`. The configured route URL remains semantic here because +/// unit tests may replace only the transport origin with a local capture +/// server. +/// +/// Source: (verified 2026-07-22). +fn chat_completions_url( + transport_base_url: &str, + route_base_url: &str, + provider: ApiProvider, + path_suffix: Option<&str>, + body: &Value, +) -> String { + let uses_deepseek_beta = matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) + && is_official_deepseek_beta_base_url(route_base_url) + && body_uses_strict_tools(body) + && path_suffix.is_none(); + let path = if uses_deepseek_beta { + "beta/chat/completions" + } else { + "chat/completions" + }; + api_url_with_suffix(transport_base_url, path, path_suffix) +} + +fn is_official_deepseek_beta_base_url(base_url: &str) -> bool { + matches!( + base_url.trim_end_matches('/').to_ascii_lowercase().as_str(), + "https://api.deepseek.com/beta" | "https://api.deepseeki.com/beta" + ) +} + +fn body_uses_strict_tools(body: &Value) -> bool { + body.get("tools") + .and_then(Value::as_array) + .is_some_and(|tools| { + tools + .iter() + .any(|tool| tool.pointer("/function/strict").and_then(Value::as_bool) == Some(true)) + }) +} + fn normalize_audio_format(format: &str) -> String { let normalized = format.trim().to_ascii_lowercase(); if normalized.is_empty() { @@ -1302,8 +1347,23 @@ pub async fn verify_provider_api_key( .map_err(|err| format!("request failed: {err:#}"))?; let status = response.status(); if status.is_success() { - // Consume the body so the connection returns to the pool. - let _ = response.text().await; + // TelecomJS verification already returns the key-scoped model roster. + // Publish it before returning so the guided model picker can render the + // live choices in this session instead of requiring a restart. A valid + // 2xx response remains sufficient to verify the key even if the body is + // malformed; in that case failure-preserving catalog semantics keep the + // existing/static rows. + let body = response.text().await.unwrap_or_default(); + if provider == ApiProvider::Telecomjs + && let Ok(offerings) = telecomjs_catalog_offerings_from_body( + &body, + provider.as_str(), + &base_url_fingerprint(base_url), + now_unix(), + ) + { + crate::provider_lake::merge_live_offerings(offerings); + } Ok(()) } else { let body = response.text().await.unwrap_or_default(); @@ -1591,6 +1651,8 @@ impl DeepSeekClient { openrouter_to_catalog_offering(item, &provider, &fingerprint, fetched_at) }) .collect() + } else if provider == "telecomjs" { + telecomjs_catalog_offerings_from_body(&body, &provider, &fingerprint, fetched_at)? } else { let models = apply_provider_model_cutline( self.api_provider, @@ -1659,6 +1721,60 @@ impl DeepSeekClient { } } + /// Best-effort background refresh of the active provider's own `/v1/models` + /// catalog, merging results into the provider lake (#3385). + /// + /// Unlike [`models_dev_live::spawn_background_refresh`] (which fetches the + /// cross-provider Models.dev catalog), this calls the provider's own + /// `/v1/models` endpoint and merges the results into the existing live + /// snapshot via [`provider_lake::merge_live_offerings`], preserving rows + /// from other sources. + /// + /// Currently activated for providers whose model list is not covered by the + /// Models.dev catalog (e.g. TelecomJS TokenHub). The refresh is non-fatal: + /// on failure, existing/bundled rows remain available. + pub fn spawn_active_provider_catalog_refresh(config: &Config) { + let provider = config.api_provider(); + // Only refresh for providers that serve their own model list and are + // not already covered by the Models.dev catalog. + if !matches!(provider, ApiProvider::Telecomjs) { + return; + } + + let client = match DeepSeekClient::new(config) { + Ok(client) => client, + Err(err) => { + tracing::debug!( + target: "provider_catalog", + error = %err, + "skipping provider catalog refresh: client creation failed" + ); + return; + } + }; + + tokio::spawn(async move { + match client.fetch_catalog_delta().await { + Ok(delta) => { + let count = delta.offerings.len(); + crate::provider_lake::merge_live_offerings(delta.offerings); + tracing::debug!( + target: "provider_catalog", + offering_count = count, + "provider catalog refresh merged {count} offerings into provider lake" + ); + } + Err(err) => { + tracing::debug!( + target: "provider_catalog", + error = ?err, + "provider catalog refresh failed; keeping existing rows" + ); + } + } + }); + } + /// Generate speech with Xiaomi MiMo TTS models. /// /// The spoken text is placed in an `assistant` message because Xiaomi @@ -2115,6 +2231,80 @@ fn apply_provider_model_cutline( models } +/// Convert TelecomJS's bare `/models` response into truthful provider-scoped +/// catalog rows. Matching model ids on other providers prove no capabilities, +/// limits, or prices; only an explicit same-provider bundled row may enrich a +/// live offering. +fn telecomjs_catalog_offerings_from_body( + body: &str, + provider: &str, + fingerprint: &str, + fetched_at: u64, +) -> Result, CatalogRefreshError> { + let models = parse_models_response(body).map_err(|_| CatalogRefreshError::InvalidResponse)?; + if models.is_empty() { + return Err(CatalogRefreshError::EmptyList); + } + + let bundled = codewhale_config::catalog::bundled_catalog_offerings(); + let default_model_id = codewhale_config::ProviderKind::Telecomjs + .provider() + .default_model(); + Ok(models + .into_iter() + .map(|model| { + let is_default = model.id.eq_ignore_ascii_case(default_model_id); + let same_provider_match = bundled.iter().find(|offering| { + offering.provider.eq_ignore_ascii_case(provider) + && offering.wire_model_id.eq_ignore_ascii_case(&model.id) + }); + if let Some(matched) = same_provider_match { + CatalogOffering { + provider: provider.to_string(), + wire_model_id: model.id, + canonical_model: matched.canonical_model.clone(), + endpoint_key: "chat".to_string(), + default_for_provider: is_default, + family: matched.family.clone(), + limit: matched.limit.clone(), + cost: matched.cost.clone(), + modalities: matched.modalities.clone(), + attachment: matched.attachment, + reasoning: matched.reasoning, + tool_call: matched.tool_call, + structured_output: matched.structured_output, + reasoning_options: matched.reasoning_options.clone(), + source: CatalogSource::Live { + base_url_fingerprint: fingerprint.to_string(), + fetched_at, + }, + } + } else { + CatalogOffering { + provider: provider.to_string(), + wire_model_id: model.id, + canonical_model: None, + endpoint_key: "chat".to_string(), + default_for_provider: is_default, + family: None, + limit: None, + cost: None, + modalities: None, + attachment: None, + reasoning: None, + tool_call: None, + structured_output: None, + reasoning_options: Vec::new(), + source: CatalogSource::Live { + base_url_fingerprint: fingerprint.to_string(), + fetched_at, + }, + } + } + }) + .collect()) +} + /// Parse an OpenRouter `/models` response, preserving server-side ordering and /// capturing full capability metadata (#3385). fn parse_openrouter_models_response( @@ -2133,13 +2323,15 @@ fn parse_openrouter_models_response( fn publish_provider_lake_snapshot(cache: &ProviderCatalogCache) { // Publish fresh *and* stale/prior rows so pickers keep live catalog coverage - // after TTL expiry or a failed refresh (#4139). Empty caches clear the live - // layer and fall back to the bundled snapshot. + // after TTL expiry or a failed refresh (#4139). An empty cache publishes + // nothing: it must not erase a provider-scoped layer populated by another + // refresh path. let offerings = cache.all_visible_offerings(now_unix()); - if offerings.is_empty() { - crate::provider_lake::clear_live_snapshot(); - } else { - crate::provider_lake::set_live_snapshot(CatalogSnapshot { offerings }); + if !offerings.is_empty() { + crate::provider_lake::set_live_snapshot( + CatalogSnapshot { offerings }, + crate::provider_lake::LiveSource::PerProvider, + ); } } @@ -2301,6 +2493,17 @@ pub(super) fn apply_reasoning_effort( | ApiProvider::Zai => { body["thinking"] = json!({ "type": "disabled" }); } + // TelecomJS TokenHub: the gateway's OpenAI Chat Completions API + // (POST /v1/chat/completions) does not document `reasoning_effort` + // or `thinking` as supported parameters. The `thinking` field is + // only available on the Anthropic Messages API (POST /v1/messages) + // with a different shape ({"type":"enabled","budget_tokens":N}). + // Since CodeWhale routes TelecomJS through the Chat Completions + // path, we must NOT inject these fields — the gateway may silently + // ignore them or reject the request, and not every gateway model + // (qwen-max, deepseek-chat, gpt-4o, claude, etc.) accepts the same + // reasoning dialect (#4188 review: verify against actual behavior). + ApiProvider::Telecomjs => {} ApiProvider::OpenaiCodex => { // OpenAI Codex uses Responses API — thinking handled differently } @@ -2368,6 +2571,9 @@ pub(super) fn apply_reasoning_effort( body["reasoning_effort"] = json!("high"); body["thinking"] = json!({ "type": "enabled" }); } + // TelecomJS: see comment in the "off" branch above — the gateway's + // Chat Completions API does not support reasoning_effort or thinking. + ApiProvider::Telecomjs => {} // OpenRouter/Novita/Together: pass through the actual user-chosen value. // OpenRouter's unified scale is none/minimal/low/medium/high/xhigh; // DeepSeek models hosted there accept those directly. @@ -2462,6 +2668,9 @@ pub(super) fn apply_reasoning_effort( body["reasoning_effort"] = json!("max"); body["thinking"] = json!({ "type": "enabled" }); } + // TelecomJS: see comment in the "off" branch above — the gateway's + // Chat Completions API does not support reasoning_effort or thinking. + ApiProvider::Telecomjs => {} ApiProvider::Openrouter | ApiProvider::Novita | ApiProvider::Together => { body["reasoning_effort"] = json!("xhigh"); body["thinking"] = json!({ "type": "enabled" }); @@ -2694,7 +2903,7 @@ mod tests { tool_to_chat_for_base_url, }; use crate::client::responses::build_responses_body; - use crate::config::{ProviderConfig, ProvidersConfig}; + use crate::config::{DEFAULT_TELECOMJS_MODEL, ProviderConfig, ProvidersConfig}; use crate::models::{ ContentBlock, ContentBlockStart, Delta, Message, MessageRequest, StreamEvent, Tool, }; @@ -2814,6 +3023,136 @@ mod tests { client } + fn deepseek_request_boundary_client( + route_base_url: &str, + transport_base_url: String, + ) -> DeepSeekClient { + let mut client = DeepSeekClient::new(&Config { + provider: Some("deepseek".to_string()), + api_key: Some("deepseek-request-boundary-key".to_string()), + base_url: Some(route_base_url.to_string()), + default_text_model: Some("deepseek-v4-pro".to_string()), + ..Config::default() + }) + .expect("DeepSeek request-boundary client"); + client.test_chat_transport_base_url = Some(transport_base_url); + client + } + + async fn capture_deepseek_chat_request( + route_base_url: &str, + strict: bool, + streaming: bool, + ) -> (String, Value) { + let server = MockServer::start().await; + let response = if streaming { + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string("data: [DONE]\n\n") + } else { + ResponseTemplate::new(200).set_body_json(json!({ + "id": "chatcmpl-deepseek-request-boundary", + "object": "chat.completion", + "model": "deepseek-v4-pro", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2 + } + })) + }; + Mock::given(method("POST")) + .respond_with(response) + .expect(1) + .mount(&server) + .await; + + let mut tool = test_tool("lookup"); + if !strict { + tool.strict = None; + } + let request = MessageRequest { + model: "deepseek-v4-pro".to_string(), + messages: vec![Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "provider-free DeepSeek route fixture".to_string(), + cache_control: None, + }], + }], + max_tokens: 64, + system: None, + tools: Some(vec![tool]), + tool_choice: Some(json!(if strict { "required" } else { "auto" })), + metadata: None, + thinking: None, + reasoning_effort: Some("off".to_string()), + stream: Some(streaming), + temperature: None, + top_p: None, + }; + let client = deepseek_request_boundary_client(route_base_url, server.uri()); + + if streaming { + let mut stream = client + .create_message_stream(request) + .await + .expect("streaming request succeeds"); + while let Some(event) = stream.next().await { + event.expect("captured SSE response remains valid"); + } + } else { + client + .create_message(request) + .await + .expect("non-streaming request succeeds"); + } + + let requests = server.received_requests().await.expect("recorded request"); + assert_eq!(requests.len(), 1); + let path = requests[0].url.path().to_string(); + let body = serde_json::from_slice(&requests[0].body).expect("captured request JSON"); + (path, body) + } + + async fn assert_deepseek_strict_request_route_boundary(streaming: bool) { + for (route_base_url, strict, expected_path, expected_wire_strict) in [ + ( + "https://api.deepseek.com/beta", + false, + "/v1/chat/completions", + None, + ), + ( + "https://api.deepseek.com/beta", + true, + "/beta/chat/completions", + Some(true), + ), + ( + "https://api.deepseek.com/v1", + true, + "/v1/chat/completions", + None, + ), + ] { + let (captured_path, body) = + capture_deepseek_chat_request(route_base_url, strict, streaming).await; + assert_eq!(captured_path, expected_path, "{route_base_url} {body}"); + assert_eq!( + body.pointer("/tools/0/function/strict") + .and_then(Value::as_bool), + expected_wire_strict, + "{route_base_url} {body}" + ); + } + } + fn k3_request_fixture(model: &str, effort: Option<&str>, stream: bool) -> MessageRequest { MessageRequest { model: model.to_string(), @@ -3281,6 +3620,16 @@ mod tests { assert_k3_request_json_route_boundaries(true).await; } + #[tokio::test] + async fn create_message_routes_only_strict_deepseek_tools_to_beta() { + assert_deepseek_strict_request_route_boundary(false).await; + } + + #[tokio::test] + async fn create_message_stream_routes_only_strict_deepseek_tools_to_beta() { + assert_deepseek_strict_request_route_boundary(true).await; + } + #[tokio::test] async fn create_message_request_replays_kimi_code_history_for_raw_off() { assert_kimi_code_raw_off_replays_tool_history(false).await; @@ -3704,6 +4053,12 @@ mod tests { let mut spilled = crate::tools::spec::ToolResult::success(raw.clone()); let path = crate::tools::truncate::apply_spillover(&mut spilled, "call-local-secret") .expect("turn-loop spillover"); + crate::tools::truncate::publish_legacy_spillover_ownership( + &path, + "workspace", + raw.as_bytes(), + ) + .expect("publish compatibility ownership proof"); assert_eq!(path.parent(), Some(spillover_root.as_path())); assert!( std::fs::read_to_string(&path) @@ -3740,7 +4095,7 @@ mod tests { } #[test] - fn sha_spillover_persists_only_sanitized_tool_result() { + fn wire_adapter_does_not_persist_sessionless_sha_spillover() { let _guard = crate::tools::truncate::TEST_SPILLOVER_GUARD .lock() .unwrap_or_else(|err| err.into_inner()); @@ -3763,7 +4118,7 @@ mod tests { CONFIG_SECRET_SENTINELS[6], "tail ".repeat(80) ); - assert!(raw.len() > 1024, "fixture must enter SHA persistence path"); + assert!(raw.len() > 1024, "fixture must enter wire dedup size class"); let raw_sha = crate::hashing::sha256_hex(raw.as_bytes()); let prepared = client.prepare_model_bound_request(request_with_tool_result(raw)); let sanitized = tool_result_content(&prepared).to_string(); @@ -3775,11 +4130,10 @@ mod tests { let sanitized_path = crate::tools::truncate::sha_spillover_path(&sanitized_sha) .expect("sanitized spillover path"); - let persisted = std::fs::read_to_string(&sanitized_path) - .expect("sanitized tool output should be persisted"); - assert_eq!(persisted, sanitized); - assert!(!persisted.contains(CONFIG_SECRET_SENTINELS[6])); - assert!(persisted.contains(codewhale_config::persistence::REDACTED)); + assert!( + !sanitized_path.exists(), + "sessionless wire fallback must not create an ownerless SHA artifact" + ); let raw_path = crate::tools::truncate::sha_spillover_path(&raw_sha).expect("raw spillover path"); @@ -3787,6 +4141,7 @@ mod tests { !raw_path.exists(), "unsanitized tool output must never be persisted by the wire adapter" ); + assert!(!serialized.contains("retrieve_tool_result ref=sha:")); } fn deepseek_anthropic_client(server: &MockServer) -> DeepSeekClient { @@ -5381,6 +5736,31 @@ mod tests { assert_eq!(body, json!({ "thinking": { "type": "disabled" } })); } + /// TelecomJS TokenHub: the gateway's OpenAI Chat Completions API does NOT + /// support `reasoning_effort` or `thinking` fields (#4188 review). Verify + /// that no reasoning fields are injected for any effort level, since not + /// every gateway model (qwen-max, deepseek-chat, gpt-4o, claude, etc.) + /// accepts the same reasoning dialect. + #[test] + fn reasoning_effort_telecomjs_does_not_inject_reasoning_fields() { + for effort in &["off", "low", "medium", "high", "max", "xhigh"] { + let mut body = json!({}); + apply_reasoning_effort(&mut body, Some(effort), ApiProvider::Telecomjs); + assert!( + body.get("reasoning_effort").is_none(), + "TelecomJS must not inject reasoning_effort for effort={effort}: {body}" + ); + assert!( + body.get("thinking").is_none(), + "TelecomJS must not inject thinking for effort={effort}: {body}" + ); + assert!( + body.get("think").is_none(), + "TelecomJS must not inject think for effort={effort}: {body}" + ); + } + } + #[test] fn moonshot_uses_codewhale_user_agent_not_kimi_cli_identity() { let user_agent = client_user_agent(ApiProvider::Moonshot); @@ -6222,6 +6602,23 @@ mod tests { .expect("OpenCode Go client") } + fn telecomjs_client_for(server: &MockServer) -> DeepSeekClient { + let _ = rustls::crypto::ring::default_provider().install_default(); + DeepSeekClient::new(&Config { + provider: Some("telecomjs".to_string()), + providers: Some(ProvidersConfig { + telecomjs: ProviderConfig { + api_key: Some("test-key".to_string()), + base_url: Some(server.uri()), + ..ProviderConfig::default() + }, + ..ProvidersConfig::default() + }), + ..Config::default() + }) + .expect("TelecomJS client") + } + async fn mount_models_json(server: &MockServer, status: u16, body: serde_json::Value) { Mock::given(method("GET")) .and(path("/v1/models")) @@ -6335,6 +6732,71 @@ mod tests { ); } + #[tokio::test] + async fn telecomjs_live_catalog_keeps_cross_provider_metadata_unknown() { + let server = MockServer::start().await; + let ambiguous_id = codewhale_config::catalog::bundled_catalog_offerings() + .into_iter() + .find(|offering| { + !offering.provider.eq_ignore_ascii_case("telecomjs") + && !offering + .wire_model_id + .eq_ignore_ascii_case(DEFAULT_TELECOMJS_MODEL) + && (offering.canonical_model.is_some() + || offering.family.is_some() + || offering.limit.is_some() + || offering.cost.is_some() + || offering.reasoning.is_some() + || offering.tool_call.is_some()) + }) + .expect("bundled catalog should contain a metadata-bearing non-TelecomJS row") + .wire_model_id; + Mock::given(method("GET")) + .and(path("/v1/models")) + .and(header("authorization", "Bearer test-key")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": [ + {"id": ambiguous_id.clone()}, + {"id": DEFAULT_TELECOMJS_MODEL} + ] + }))) + .mount(&server) + .await; + + let delta = telecomjs_client_for(&server) + .fetch_catalog_delta() + .await + .expect("TelecomJS catalog delta"); + assert_eq!(delta.provider, "telecomjs"); + assert_eq!(delta.offerings.len(), 2); + + let ambiguous = delta + .offerings + .iter() + .find(|offering| offering.wire_model_id == ambiguous_id) + .expect("ambiguous cross-provider id"); + assert!(!ambiguous.default_for_provider); + assert_eq!(ambiguous.endpoint_key, "chat"); + assert_eq!(ambiguous.canonical_model, None); + assert_eq!(ambiguous.family, None); + assert_eq!(ambiguous.limit, None); + assert_eq!(ambiguous.cost, None); + assert_eq!(ambiguous.modalities, None); + assert_eq!(ambiguous.attachment, None); + assert_eq!(ambiguous.reasoning, None); + assert_eq!(ambiguous.tool_call, None); + assert_eq!(ambiguous.structured_output, None); + assert!(ambiguous.reasoning_options.is_empty()); + assert!(matches!(ambiguous.source, CatalogSource::Live { .. })); + + let default = delta + .offerings + .iter() + .find(|offering| offering.wire_model_id == DEFAULT_TELECOMJS_MODEL) + .expect("TelecomJS default row"); + assert!(default.default_for_provider); + } + #[tokio::test] async fn fetch_catalog_delta_success_builds_scoped_secret_free_live_delta() { let server = MockServer::start().await; diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 7ad82cd133..4eb20967b0 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -76,9 +76,9 @@ use crate::models::{ use super::{ DeepSeekClient, ERROR_BODY_MAX_BYTES, SSE_BACKPRESSURE_HIGH_WATERMARK, - SSE_BACKPRESSURE_SLEEP_MS, SSE_MAX_LINES_PER_CHUNK, acquire_stream_buffer, api_url_with_suffix, - apply_reasoning_effort, bounded_error_text, from_api_tool_name, parse_usage, - release_stream_buffer, system_to_instructions, to_api_tool_name, + SSE_BACKPRESSURE_SLEEP_MS, SSE_MAX_LINES_PER_CHUNK, acquire_stream_buffer, + apply_reasoning_effort, bounded_error_text, chat_completions_url, from_api_tool_name, + parse_usage, release_stream_buffer, system_to_instructions, to_api_tool_name, }; fn apply_provider_token_limit( @@ -463,10 +463,12 @@ impl DeepSeekClient { None }; - let url = api_url_with_suffix( + let url = chat_completions_url( self.chat_transport_base_url(), - "chat/completions", + &self.base_url, + self.api_provider, self.path_suffix.as_deref(), + &body, ); let open_timeout = stream_open_timeout(); let response = match tokio_timeout(open_timeout, self.send_json_with_retry(&url, &body)) @@ -619,10 +621,12 @@ impl DeepSeekClient { ); mirror_minimax_reasoning_details_for_body(&mut body, self.api_provider); - let url = api_url_with_suffix( + let url = chat_completions_url( self.chat_transport_base_url(), - "chat/completions", + &self.base_url, + self.api_provider, self.path_suffix.as_deref(), + &body, ); let response = self.send_json_with_retry(&url, &body).await?; @@ -1147,15 +1151,9 @@ const TOOL_RESULT_SENT_CHAR_BUDGET: usize = 12_000; const TOOL_RESULT_HEAD_CHARS: usize = 4_000; const TOOL_RESULT_TAIL_CHARS: usize = 4_000; /// Tool results shorter than this stay inline even when repeated. The -/// extra prompt bytes are cheaper than forcing the model through an -/// unnecessary retrieval hop for tiny command outputs. +/// extra prompt bytes are cheaper than adding an earlier-message reference +/// for tiny command outputs. const TOOL_RESULT_DEDUP_MIN_CHARS: usize = 1_024; -/// Tool results shorter than this are also exempt from disk persistence — -/// no SHA file is written. The wire-dedup path won't fire for them -/// anyway (see `TOOL_RESULT_DEDUP_MIN_CHARS`), so there's no retrieval -/// burden to satisfy. Keeps `~/.deepseek/tool_outputs/` from filling -/// up with tiny `gh auth status` and `cat package.json` files. -const TOOL_RESULT_SHA_PERSIST_MIN_CHARS: usize = 1_024; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub(crate) struct PromptInspection { @@ -1556,34 +1554,6 @@ fn sha256_hex(bytes: &[u8]) -> String { crate::hashing::sha256_hex(bytes) } -/// Persist a SHA-addressed copy of `content` to -/// `~/.deepseek/tool_outputs/sha_.txt` so the model can retrieve -/// the original bytes after the wire-dedup compactor has replaced -/// later occurrences with a `` block. -/// -/// Returns `true` when the persist succeeded (or the content is -/// below `TOOL_RESULT_SHA_PERSIST_MIN_CHARS` — there's no retrieval -/// need to satisfy). Returns `false` when the write failed and the -/// caller MUST skip dedup, because emitting a SHA ref the model -/// can't retrieve is worse than inlining the content twice. The -/// no-home-dir edge case (InvalidInput) is treated as a real -/// failure: we can't promise retrieval works without a writable -/// store. -fn persist_tool_result_for_sha(sha: &str, content: &str) -> bool { - if content.chars().count() < TOOL_RESULT_SHA_PERSIST_MIN_CHARS { - return true; - } - match crate::tools::truncate::write_sha_spillover(sha, content) { - Ok(_) => true, - Err(err) => { - logging::warn(format!( - "tool-result SHA spillover write failed for sha={sha}: {err} — dedup skipped" - )); - false - } - } -} - #[derive(Clone)] struct PendingToolCallInfo { tool_name: String, @@ -1671,8 +1641,10 @@ fn turn_meta_budget_json(turn_meta: &TurnMetaBudget) -> Value { /// their full confirmation inline: collapsing the later one to a /// `` makes the model lose the write-success /// context and behave as if the file is missing (issue #1695). Read-style -/// tools (`read_file`, `grep_files`, `exec_shell`, …) are unaffected and -/// still dedup normally. +/// tools (`read_file`, `grep_files`, `exec_shell`, …) may deduplicate medium +/// outputs by pointing at an earlier full message in the same request. They +/// never advertise a process-wide SHA as retrievable: that store cannot prove +/// session ownership. fn is_mutation_tool(tool_name: &str) -> bool { matches!(tool_name, "write_file" | "edit_file" | "apply_patch") } @@ -1687,43 +1659,17 @@ fn compact_tool_result_for_wire( let original_chars = content.chars().count(); let sha = sha256_hex(content.as_bytes()); - // Two independent size-and-kind predicates, deliberately decoupled: - // - // * `persist_eligible` — size only. Any large result (including a - // mutation tool's big diff) is written to the SHA-addressed store - // so that, if it gets truncated below, the elided middle stays - // retrievable via `retrieve_tool_result`. Mutation tools must NOT - // be excluded here: a >12k-char `write_file` diff that we truncate - // without persisting would leave the model unable to recover it. - // * `dedup_eligible` — size AND non-mutation. Only this predicate - // gates collapsing a later identical result to a - // ``. Mutation-tool results are write - // *confirmations*, never dedup-eligible (#1695): two identical - // large `write_file` calls must each keep their full confirmation - // inline. - // - // Below the threshold, repeating the content is safer than asking - // the model to chase a reference, and there's no retrieval burden to - // satisfy, so both predicates are false. - let persist_eligible = original_chars >= TOOL_RESULT_DEDUP_MIN_CHARS; - let dedup_eligible = persist_eligible && !is_mutation_tool(tool_name); + // Only medium, non-mutation results can point back to a full earlier + // message in this one request. Oversized results are already excerpts, so + // a back-reference would falsely imply the exact bytes remain available. + let dedup_eligible = (TOOL_RESULT_DEDUP_MIN_CHARS..=TOOL_RESULT_SENT_CHAR_BUDGET) + .contains(&original_chars) + && !is_mutation_tool(tool_name); if dedup_eligible && let Some(previous) = seen_tool_results.get(&sha) { - // Re-check persistence before emitting a ref. If the file is - // already present this is a cheap no-op; if the write now fails, - // inline the content rather than producing an orphan reference. - if !persist_tool_result_for_sha(&sha, content) { - return WireToolResult { - content: content.to_string(), - original_chars, - sent_chars: original_chars, - truncated: false, - deduplicated: false, - }; - } let content = format!( "\n\ - retrieve: retrieve_tool_result ref=sha:{sha}\n\ + source: full content appears in {label} earlier in this request\n\ ", label = previous.message_label, chars = previous.original_chars, @@ -1737,25 +1683,14 @@ fn compact_tool_result_for_wire( }; } - if persist_eligible { - // Persist any large result so a later truncation below stays - // retrievable by SHA — this includes mutation tools, whose big - // diffs are NOT dedup-eligible but still must be recoverable - // when elided. Only register the SHA as dedup-able (eligible to - // be replaced by a back-reference later) when `dedup_eligible`: - // if the write fails, skip registration so later occurrences - // stay inline instead of pointing at a file that was never - // created. - let persisted = persist_tool_result_for_sha(&sha, content); - if persisted && dedup_eligible { - seen_tool_results.insert( - sha.clone(), - SeenToolResult { - message_label: message_label.to_string(), - original_chars, - }, - ); - } + if dedup_eligible { + seen_tool_results.insert( + sha.clone(), + SeenToolResult { + message_label: message_label.to_string(), + original_chars, + }, + ); } if original_chars <= TOOL_RESULT_SENT_CHAR_BUDGET { @@ -1779,7 +1714,7 @@ fn compact_tool_result_for_wire( exit_status: {}\n\ original_chars: {original_chars}\n\ sha256: {sha}\n\ - retrieve: retrieve_tool_result ref=sha:{sha}\n\ + exact_detail: unavailable; no session-owned artifact was recorded\n\ first_chars:\n\ {head}\n\n\ [... truncated {omitted} chars from middle ...]\n\n\ @@ -4549,9 +4484,10 @@ mod stream_decoder_tests { assert!(sent.contains("original_chars: 14000"), "got: {sent}"); assert!(sent.contains("sha256:"), "got: {sent}"); assert!( - sent.contains("retrieve: retrieve_tool_result ref=sha:"), + sent.contains("exact_detail: unavailable; no session-owned artifact was recorded"), "got: {sent}" ); + assert!(!sent.contains("retrieve_tool_result"), "got: {sent}"); assert!(sent.contains(&"A".repeat(4_000)), "got: {sent}"); assert!(sent.contains(&"Z".repeat(4_000)), "got: {sent}"); assert!( @@ -4562,7 +4498,7 @@ mod stream_decoder_tests { } #[test] - fn request_builder_keeps_extreme_tool_output_bounded_and_retrievable() { + fn request_builder_keeps_unowned_extreme_tool_output_bounded_without_false_hint() { with_tool_result_sha_spillover_root(|| { let huge_output = format!( "{}{}{}", @@ -4583,10 +4519,8 @@ mod stream_decoder_tests { assert!(sent.contains("tool_name: exec_shell"), "got: {sent}"); assert!(sent.contains("command_or_query: git diff"), "got: {sent}"); assert!(sent.contains(&format!("sha256: {sha}")), "got: {sent}"); - assert!( - sent.contains(&format!("retrieve: retrieve_tool_result ref=sha:{sha}")), - "got: {sent}" - ); + assert!(sent.contains("exact_detail: unavailable"), "got: {sent}"); + assert!(!sent.contains("retrieve_tool_result"), "got: {sent}"); assert!( sent.chars().count() <= TOOL_RESULT_SENT_CHAR_BUDGET, "truncated result should stay bounded, sent {} chars", @@ -4620,7 +4554,7 @@ mod stream_decoder_tests { } #[test] - fn request_builder_deduplicates_medium_identical_tool_results_with_retrieval_hint() { + fn request_builder_deduplicates_medium_identical_tool_results_to_earlier_message() { with_tool_result_sha_spillover_root(|| { // 2,000 chars is intentionally above TOOL_RESULT_DEDUP_MIN_CHARS // (1,024) but below TOOL_RESULT_SENT_CHAR_BUDGET (12,000). This @@ -4650,9 +4584,11 @@ mod stream_decoder_tests { ); assert!(second.contains("chars=\"2000\""), "got: {second}"); assert!( - second.contains("retrieve: retrieve_tool_result ref=sha:"), + second + .contains("source: full content appears in Message #1 earlier in this request"), "got: {second}" ); + assert!(!second.contains("retrieve_tool_result"), "got: {second}"); }); } @@ -4680,8 +4616,8 @@ mod stream_decoder_tests { assert_eq!(second, output); assert!(!second.contains("` (mutation confirmations stay inline) yet - // (b) still be persisted to the SHA store so the content elided - // by truncation remains retrievable via `retrieve_tool_result`. - // Before the fix, folding `!is_mutation_tool` into the single - // `dedup_eligible` gate also disabled persistence, so a >12k - // mutation diff was truncated AND unrecoverable. - let _guard = crate::tools::truncate::TEST_SPILLOVER_GUARD - .lock() - .unwrap_or_else(|err| err.into_inner()); - let tmp = tempfile::tempdir().expect("tempdir"); - let prior = crate::tools::truncate::set_test_spillover_root(Some( - tmp.path().join(".deepseek").join("tool_outputs"), - )); - struct Restore(Option); - impl Drop for Restore { - fn drop(&mut self) { - crate::tools::truncate::set_test_spillover_root(self.0.take()); - } - } - let _restore = Restore(prior); - - // > TOOL_RESULT_SENT_CHAR_BUDGET (12_000) so the wire path - // truncates and would need a SHA to recover the middle. + fn large_unowned_results_stay_bounded_without_false_retrieval_handles() { + // The adaptive router normally replaces a large result with a + // session-owned artifact receipt before this provider-wire fallback. + // If legacy/raw history reaches here, it may be excerpted but must not + // advertise the process-wide SHA store as retrievable. let big_diff = "D".repeat(20_000); let sha = sha256_hex(big_diff.as_bytes()); @@ -4739,7 +4656,7 @@ mod stream_decoder_tests { let first = tool_message_content(&built, 0); let second = tool_message_content(&built, 1); - // (a) Both confirmations stay inline — truncated, never a ref. + // Mutation confirmations are independently excerpted, never deduped. assert!( first.contains("[TOOL_RESULT_TRUNCATED]"), "first should be truncated, got: {first}" @@ -4758,31 +4675,14 @@ mod stream_decoder_tests { ); assert!( first.contains(&format!("sha256: {sha}")), - "truncation block should advertise the recovery SHA, got: {first}" - ); - assert!( - first.contains(&format!("retrieve: retrieve_tool_result ref=sha:{sha}")), - "truncation block should advertise the recovery command, got: {first}" - ); - - // (b) The full content was persisted to the SHA store and is - // retrievable — the seam `persist_tool_result_for_sha` writes - // to and `retrieve_tool_result ref=sha:` reads back from. - let path = crate::tools::truncate::sha_spillover_path(&sha) - .expect("sha spillover path resolvable under test root"); - assert!( - path.exists(), - "large write_file output not persisted: {path:?}" - ); - let persisted = std::fs::read_to_string(&path).expect("read persisted spillover"); - assert_eq!( - persisted, big_diff, - "persisted content must match the original write_file result verbatim" + "truncation block should retain an integrity digest, got: {first}" ); + assert!(first.contains("exact_detail: unavailable")); + assert!(!first.contains("retrieve_tool_result")); - // Sanity: a large NON-mutation result still dedups (back-ref on - // the second sighting) — decoupling didn't regress #1695's - // preserved read-path behavior. + // A huge non-mutation result cannot refer to an earlier *full* message, + // because both wire messages are excerpts. It therefore stays a + // truthful bounded excerpt too. let read_messages = vec![ tool_use_message("r-1", "read_file", json!({"path": "huge.rs"})), tool_result_message("r-1", &big_diff), @@ -4791,10 +4691,9 @@ mod stream_decoder_tests { ]; let read_built = build_chat_messages(None, &read_messages, "deepseek-v4-flash"); let read_second = tool_message_content(&read_built, 1); - assert!( - read_second.starts_with(" = inspection - .layers - .iter() - .filter_map(|layer| layer.tool_result.as_ref()) - .collect(); + let inspection = inspect_prompt_for_request(&request); + let tool_layers: Vec<_> = inspection + .layers + .iter() + .filter_map(|layer| layer.tool_result.as_ref()) + .collect(); - assert_eq!(tool_layers.len(), 2); - assert_eq!(tool_layers[0].original_chars, 14_000); - assert!(tool_layers[0].sent_chars < tool_layers[0].original_chars); - assert!(tool_layers[0].truncated); - assert!(!tool_layers[0].deduplicated); - assert_eq!(tool_layers[1].original_chars, 14_000); - // Keep the reference far smaller than the original 14K output - // even with a copyable retrieval hint included. - assert!( - tool_layers[1].sent_chars < 300, - "deduplicated ref grew unexpectedly large: {}", - tool_layers[1].sent_chars - ); - assert!(!tool_layers[1].truncated); - assert!(tool_layers[1].deduplicated); - }); + assert_eq!(tool_layers.len(), 2); + for layer in tool_layers { + assert_eq!(layer.original_chars, 14_000); + assert!(layer.sent_chars < layer.original_chars); + assert!(layer.truncated); + assert!(!layer.deduplicated); + } } } diff --git a/crates/tui/src/commands/groups/config/config.rs b/crates/tui/src/commands/groups/config/config.rs index b48b944d78..ef7ff3c34e 100644 --- a/crates/tui/src/commands/groups/config/config.rs +++ b/crates/tui/src/commands/groups/config/config.rs @@ -297,6 +297,9 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { } .to_string(), ), + "inline_diffs" | "inline_diff" | "diffs" => { + Some(app.inline_diff_mode.as_setting().to_string()) + } "mode" | "default_mode" => Some(app.mode.as_setting().to_string()), "max_history" | "history" => Some(app.max_input_history.to_string()), "sidebar_width" | "sidebar" => Some(app.sidebar_width_percent.to_string()), @@ -1843,6 +1846,11 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> app.show_tool_details = settings.show_tool_details; app.mark_history_updated(); } + "inline_diffs" | "inline_diff" | "diffs" => { + app.inline_diff_mode = crate::settings::InlineDiffMode::parse(&settings.inline_diffs); + app.mark_history_updated(); + app.needs_redraw = true; + } "locale" | "language" => { app.ui_locale = resolve_locale(&settings.locale); app.mark_history_updated(); diff --git a/crates/tui/src/commands/groups/config/mod.rs b/crates/tui/src/commands/groups/config/mod.rs index 8bdef7f694..ce00aa2311 100644 --- a/crates/tui/src/commands/groups/config/mod.rs +++ b/crates/tui/src/commands/groups/config/mod.rs @@ -103,9 +103,9 @@ static LOGOUT_INFO: CommandInfo = CommandInfo { }; static DEBT_INFO: CommandInfo = CommandInfo { name: "debt", - aliases: &["cleanup"], + aliases: &["cleanup", "slop", "canzha"], usage: "/debt [query|export]", - description_id: MessageId::CmdSlopDescription, + description_id: MessageId::CmdDebtDescription, }; fn run_registered(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult { diff --git a/crates/tui/src/commands/groups/core/core.rs b/crates/tui/src/commands/groups/core/core.rs index 0660083c3e..815198fcbd 100644 --- a/crates/tui/src/commands/groups/core/core.rs +++ b/crates/tui/src/commands/groups/core/core.rs @@ -17,6 +17,14 @@ use super::CommandResult; /// Show help information pub fn help(app: &mut App, topic: Option<&str>) -> CommandResult { if let Some(topic) = topic { + let user_commands = crate::commands::user_registry::with_registry_for_workspace( + Some(&app.workspace), + Clone::clone, + ); + if let Some(command) = user_commands.get(topic) { + return CommandResult::message(user_command_help(app.ui_locale, command)); + } + // Show help for specific command if let Some(cmd) = crate::commands::get_command_info(topic) { let mut help = format!( @@ -26,12 +34,18 @@ pub fn help(app: &mut App, topic: Option<&str>) -> CommandResult { tr(app.ui_locale, MessageId::HelpUsageLabel), cmd.usage ); - if !cmd.aliases.is_empty() { + let visible_aliases = cmd + .aliases + .iter() + .filter(|alias| user_commands.get(alias).is_none()) + .copied() + .collect::>(); + if !visible_aliases.is_empty() { let _ = write!( help, "\n {} {}", tr(app.ui_locale, MessageId::HelpAliasesLabel), - cmd.aliases.join(", ") + visible_aliases.join(", ") ); } return CommandResult::message(help); @@ -43,11 +57,46 @@ pub fn help(app: &mut App, topic: Option<&str>) -> CommandResult { // Show help overlay if app.view_stack.top_kind() != Some(ModalKind::Help) { - app.view_stack.push(HelpView::new_for_locale(app.ui_locale)); + let help = HelpView::new_for_workspace(app.ui_locale, &app.workspace); + app.view_stack.push(help); } CommandResult::ok() } +fn user_command_help( + locale: Locale, + command: &crate::commands::user_registry::UserCommandMetadata, +) -> String { + let mut help = command.name.clone(); + if let Some(description) = command + .description + .as_deref() + .filter(|description| !description.trim().is_empty()) + { + let _ = write!(help, "\n\n {description}"); + } + + let usage = command + .display_usage() + .map(str::to_owned) + .unwrap_or_else(|| format!("/{}", command.name)); + let _ = write!( + help, + "\n\n {} {}", + tr(locale, MessageId::HelpUsageLabel), + usage + ); + if !command.aliases.is_empty() { + let _ = write!( + help, + "\n {} {}", + tr(locale, MessageId::HelpAliasesLabel), + command.aliases.join(", ") + ); + } + help +} + /// Clear conversation history pub fn clear(app: &mut App) -> CommandResult { if app.session_transition_blocked() { diff --git a/crates/tui/src/commands/groups/debug/tests.rs b/crates/tui/src/commands/groups/debug/tests.rs index 58df21b86e..3a78132094 100644 --- a/crates/tui/src/commands/groups/debug/tests.rs +++ b/crates/tui/src/commands/groups/debug/tests.rs @@ -396,29 +396,6 @@ fn cache_inspect_reports_divergence_from_previous_request() { #[test] fn cache_inspect_displays_tool_result_budget_metadata() { - // Wire dedup persists to the process-global SHA spillover root. - // Serialize through the same guard other tests use to override - // that root, so a parallel test pointing it at a temp dir can't - // make this test's second-sighting dedup silently fail. - let _spill_guard = crate::tools::truncate::TEST_SPILLOVER_GUARD - .lock() - .unwrap_or_else(|err| err.into_inner()); - // Set a temporary spillover root so wire-dedup can persist - // SHA-addressed tool-result files without depending on a - // writable $HOME (nix sandboxes have a read-only home tree). - let tmp = tempfile::tempdir().expect("tempdir"); - let _restore = { - let prior = crate::tools::truncate::set_test_spillover_root(Some( - tmp.path().join(".deepseek").join("tool_outputs"), - )); - struct Restore(Option); - impl Drop for Restore { - fn drop(&mut self) { - crate::tools::truncate::set_test_spillover_root(self.0.take()); - } - } - Restore(prior) - }; let mut app = create_test_app(); let long_output = format!("{}{}", "A".repeat(7_000), "Z".repeat(7_000)); app.api_messages.push(Message { @@ -467,19 +444,11 @@ fn cache_inspect_displays_tool_result_budget_metadata() { .collect(); assert_eq!(tool_budget_lines.len(), 2, "got: {msg}"); - let first_sighting = tool_budget_lines - .iter() - .find(|line| line.contains("deduplicated=false")) - .expect("first tool-result sighting should report non-dedup metadata"); - assert!(first_sighting.contains("sent_chars="), "got: {msg}"); - assert!(first_sighting.contains("truncated=true"), "got: {msg}"); - - let repeat_sighting = tool_budget_lines - .iter() - .find(|line| line.contains("deduplicated=true")) - .expect("repeat tool-result sighting should report dedup metadata"); - assert!(repeat_sighting.contains("sent_chars="), "got: {msg}"); - assert!(repeat_sighting.contains("truncated=false"), "got: {msg}"); + for sighting in tool_budget_lines { + assert!(sighting.contains("sent_chars="), "got: {msg}"); + assert!(sighting.contains("truncated=true"), "got: {msg}"); + assert!(sighting.contains("deduplicated=false"), "got: {msg}"); + } } #[test] diff --git a/crates/tui/src/commands/groups/skills/skills.rs b/crates/tui/src/commands/groups/skills/skills.rs index 0c9b498b84..46894ece00 100644 --- a/crates/tui/src/commands/groups/skills/skills.rs +++ b/crates/tui/src/commands/groups/skills/skills.rs @@ -4,8 +4,8 @@ use std::fmt::Write; use crate::network_policy::NetworkPolicy; use crate::skills::install::{ - self, DEFAULT_MAX_SIZE_BYTES, DEFAULT_REGISTRY_URL, InstallOutcome, InstallSource, - RegistryFetchResult, SkillSyncOutcome, SyncResult, UpdateResult, + self, DEFAULT_MAX_SIZE_BYTES, DEFAULT_REGISTRY_URL, InstallSource, RegistryFetchResult, + SkillSyncOutcome, SyncResult, }; use crate::skills::{SkillRegistry, SkillSource}; use crate::tui::app::{App, AppAction}; @@ -180,6 +180,9 @@ fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult { } prefix = Some(trimmed.to_ascii_lowercase()); } + } else { + // Bare `/skills` opens the unified manager (owned-only, zero network). + return CommandResult::action(AppAction::OpenSkillsManager); } let skills_dir = app.skills_dir.clone(); let registry = discover_visible_skills(app); @@ -323,7 +326,7 @@ fn run_skill(app: &mut App, name: Option<&str>) -> CommandResult { Some(n) => n.trim(), None => { return CommandResult::error( - "Usage: /skill \n\nSubcommands:\n /skill install >\n /skill update \n /skill uninstall \n /skill trust ", + "Usage: /skill \n\nSubcommands:\n /skill install [--project|--global] >\n /skill update [--project|--global] \n /skill uninstall [--project|--global] \n /skill trust [--project|--global] ", ); } }; @@ -345,6 +348,67 @@ fn run_skill(app: &mut App, name: Option<&str>) -> CommandResult { activate_skill_with_task(app, head, task) } +/// Parse optional `--project` / `--global` scope prefix from a skill subcommand. +fn parse_scope_args( + args: &str, +) -> Result<(Option, &str), String> { + use crate::skills::mutation::SkillTargetScope; + let mut scope = None; + let mut rest = args.trim(); + loop { + if let Some(next) = rest.strip_prefix("--project") { + if scope.is_some() { + return Err("specify at most one of --project / --global".into()); + } + scope = Some(SkillTargetScope::Project); + rest = next.trim_start(); + continue; + } + if let Some(next) = rest.strip_prefix("--global") { + if scope.is_some() { + return Err("specify at most one of --project / --global".into()); + } + scope = Some(SkillTargetScope::Global); + rest = next.trim_start(); + continue; + } + break; + } + Ok((scope, rest.trim())) +} + +fn format_mutation_receipt(receipt: &crate::skills::mutation::SkillMutationReceipt) -> String { + use crate::skills::mutation::SkillMutationOutcome; + match &receipt.outcome { + SkillMutationOutcome::Installed => format!( + "Installed skill '{}'.\nLocation: {}\n\nManage skills with /skills.", + receipt.name, receipt.safe_target_path + ), + SkillMutationOutcome::Updated => format!( + "Skill '{}' updated.\nLocation: {}", + receipt.name, receipt.safe_target_path + ), + SkillMutationOutcome::NoChange => { + format!("Skill '{}': no upstream change.", receipt.name) + } + SkillMutationOutcome::Removed => format!("Removed skill '{}'.", receipt.name), + SkillMutationOutcome::Trusted => format!( + "Marked skill '{}' as trusted. The .trusted marker is advisory and digest-bound; it records your review intent but does not sandbox or auto-authorize scripts.", + receipt.name + ), + SkillMutationOutcome::Imported => format!( + "Imported skill '{}'.\nLocation: {}", + receipt.name, receipt.safe_target_path + ), + SkillMutationOutcome::AlreadyPresent => format!( + "Skill '{}' is already present at {} (exact duplicate).", + receipt.name, receipt.safe_target_path + ), + SkillMutationOutcome::NeedsApproval(host) => needs_approval_message(host), + SkillMutationOutcome::NetworkDenied(host) => network_denied_message(host), + } +} + /// Activate a skill and, when the invocation includes a task, send that task /// immediately. `AppAction::SendMessage` is converted into a `QueuedMessage` /// by the UI, where `app.active_skill` is consumed and attached to this turn. @@ -414,45 +478,62 @@ fn activate_skill(app: &mut App, name: &str) -> CommandResult { // ─── /skill install ──────────────────────────────────────────────────────── -fn install_skill(app: &mut App, spec: &str) -> CommandResult { +fn install_skill(app: &mut App, args: &str) -> CommandResult { + use crate::skills::mutation::{MutationContext, SkillMutationRequest, SkillTargetScope}; + + let (scope, spec) = match parse_scope_args(args) { + Ok(v) => v, + Err(err) => return CommandResult::error(err), + }; if spec.is_empty() { return CommandResult::error( - "Usage: /skill install >", + "Usage: /skill install [--project|--global] >", ); } let source = match InstallSource::parse(spec) { Ok(s) => s, Err(err) => return CommandResult::error(format!("Invalid install source: {err}")), }; - let skills_dir = app.skills_dir.clone(); + // Legacy no-scope install maps to the CodeWhale global owned root. + let target = scope.unwrap_or(SkillTargetScope::Global); + let workspace = app.workspace.clone(); + let home = dirs::home_dir(); let (network, max_size, registry_url) = installer_settings(app); let outcome = run_async(async move { - install::install_with_registry( - source, - &skills_dir, + let ctx = MutationContext { + workspace: &workspace, + home: home.as_deref(), + configured_skills_dir: None, + network: &network, max_size, - &network, - false, - ®istry_url, + registry_url: ®istry_url, + }; + crate::skills::mutation::execute( + SkillMutationRequest::InstallRemote { source, target }, + &ctx, ) .await }); match outcome { - Ok(InstallOutcome::Installed(installed)) => { - app.refresh_skill_cache(); - let path_str = path_or_default(&installed.path); - CommandResult::message(format!( - "Installed skill '{}' from {}.\nLocation: {}\n\nRun /skills to see it in the list.", - installed.name, spec, path_str - )) - } - Ok(InstallOutcome::NeedsApproval(host)) => { - CommandResult::error(needs_approval_message(&host)) - } - Ok(InstallOutcome::NetworkDenied(host)) => { - CommandResult::error(network_denied_message(&host)) + Ok(receipt) => { + if matches!( + receipt.outcome, + crate::skills::mutation::SkillMutationOutcome::Installed + ) { + app.refresh_skill_cache(); + } + let message = format_mutation_receipt(&receipt); + if matches!( + receipt.outcome, + crate::skills::mutation::SkillMutationOutcome::NeedsApproval(_) + | crate::skills::mutation::SkillMutationOutcome::NetworkDenied(_) + ) { + CommandResult::error(message) + } else { + CommandResult::message(message) + } } Err(err) => CommandResult::error(format!("Install failed: {err:#}")), } @@ -460,32 +541,59 @@ fn install_skill(app: &mut App, spec: &str) -> CommandResult { // ─── /skill update ───────────────────────────────────────────────────────── -fn update_skill(app: &mut App, name: &str) -> CommandResult { +fn update_skill(app: &mut App, args: &str) -> CommandResult { + use crate::skills::mutation::{MutationContext, SkillMutationRequest}; + + let (scope, name) = match parse_scope_args(args) { + Ok(v) => v, + Err(err) => return CommandResult::error(err), + }; if name.is_empty() { - return CommandResult::error("Usage: /skill update "); + return CommandResult::error("Usage: /skill update [--project|--global] "); } - let skills_dir = app.skills_dir.clone(); + let workspace = app.workspace.clone(); + let home = dirs::home_dir(); let (network, max_size, registry_url) = installer_settings(app); let owned_name = name.to_string(); + let outcome = run_async(async move { - install::update_with_registry(&owned_name, &skills_dir, max_size, &network, ®istry_url) - .await + let ctx = MutationContext { + workspace: &workspace, + home: home.as_deref(), + configured_skills_dir: None, + network: &network, + max_size, + registry_url: ®istry_url, + }; + crate::skills::mutation::execute( + SkillMutationRequest::UpdateByName { + name: owned_name, + scope, + expected_digest: None, + }, + &ctx, + ) + .await }); match outcome { - Ok(UpdateResult::NoChange) => { - CommandResult::message(format!("Skill '{name}': no upstream change.")) - } - Ok(UpdateResult::Updated(installed)) => CommandResult::message(format!( - "Skill '{}' updated. Location: {}", - installed.name, - path_or_default(&installed.path) - )), - Ok(UpdateResult::NeedsApproval(host)) => { - CommandResult::error(needs_approval_message(&host)) - } - Ok(UpdateResult::NetworkDenied(host)) => { - CommandResult::error(network_denied_message(&host)) + Ok(receipt) => { + if matches!( + receipt.outcome, + crate::skills::mutation::SkillMutationOutcome::Updated + ) { + app.refresh_skill_cache(); + } + let message = format_mutation_receipt(&receipt); + if matches!( + receipt.outcome, + crate::skills::mutation::SkillMutationOutcome::NeedsApproval(_) + | crate::skills::mutation::SkillMutationOutcome::NetworkDenied(_) + ) { + CommandResult::error(message) + } else { + CommandResult::message(message) + } } Err(err) => CommandResult::error(format!("Update failed: {err:#}")), } @@ -493,14 +601,38 @@ fn update_skill(app: &mut App, name: &str) -> CommandResult { // ─── /skill uninstall ────────────────────────────────────────────────────── -fn uninstall_skill(app: &mut App, name: &str) -> CommandResult { +fn uninstall_skill(app: &mut App, args: &str) -> CommandResult { + use crate::skills::mutation::{MutationContext, SkillMutationRequest}; + + let (scope, name) = match parse_scope_args(args) { + Ok(v) => v, + Err(err) => return CommandResult::error(err), + }; if name.is_empty() { - return CommandResult::error("Usage: /skill uninstall "); + return CommandResult::error("Usage: /skill uninstall [--project|--global] "); } - match install::uninstall(name, &app.skills_dir) { - Ok(()) => { + let home = dirs::home_dir(); + let (network, max_size, registry_url) = installer_settings(app); + let ctx = MutationContext { + workspace: &app.workspace, + home: home.as_deref(), + configured_skills_dir: None, + network: &network, + max_size, + registry_url: ®istry_url, + }; + + match crate::skills::mutation::execute_sync( + SkillMutationRequest::RemoveByName { + name: name.to_string(), + scope, + expected_digest: None, + }, + &ctx, + ) { + Ok(receipt) => { app.refresh_skill_cache(); - CommandResult::message(format!("Removed skill '{name}'.")) + CommandResult::message(format_mutation_receipt(&receipt)) } Err(err) => CommandResult::error(format!("Uninstall failed: {err:#}")), } @@ -508,14 +640,36 @@ fn uninstall_skill(app: &mut App, name: &str) -> CommandResult { // ─── /skill trust ────────────────────────────────────────────────────────── -fn trust_skill(app: &mut App, name: &str) -> CommandResult { +fn trust_skill(app: &mut App, args: &str) -> CommandResult { + use crate::skills::mutation::{MutationContext, SkillMutationRequest}; + + let (scope, name) = match parse_scope_args(args) { + Ok(v) => v, + Err(err) => return CommandResult::error(err), + }; if name.is_empty() { - return CommandResult::error("Usage: /skill trust "); + return CommandResult::error("Usage: /skill trust [--project|--global] "); } - match install::trust(name, &app.skills_dir) { - Ok(()) => CommandResult::message(format!( - "Marked skill '{name}' as trusted. The .trusted marker is advisory; it records your intent but does not sandbox or auto-authorize scripts." - )), + let home = dirs::home_dir(); + let (network, max_size, registry_url) = installer_settings(app); + let ctx = MutationContext { + workspace: &app.workspace, + home: home.as_deref(), + configured_skills_dir: None, + network: &network, + max_size, + registry_url: ®istry_url, + }; + + match crate::skills::mutation::execute_sync( + SkillMutationRequest::TrustByName { + name: name.to_string(), + scope, + expected_digest: None, + }, + &ctx, + ) { + Ok(receipt) => CommandResult::message(format_mutation_receipt(&receipt)), Err(err) => CommandResult::error(format!("Trust failed: {err:#}")), } } @@ -656,6 +810,7 @@ where tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future)) } +#[allow(dead_code)] // retained for sync/remote listing helpers fn path_or_default(path: &std::path::Path) -> String { path.file_name() .map(|n| { @@ -749,7 +904,7 @@ pub(in crate::commands) const SKILLS_INFO: crate::commands::traits::CommandInfo crate::commands::traits::CommandInfo { name: "skills", aliases: &["jinengliebiao"], - usage: "/skills [--remote|sync|inspect|]", + usage: "/skills [--remote|sync|inspect|] (bare opens manager)", description_id: crate::localization::MessageId::CmdSkillsDescription, }; @@ -947,11 +1102,21 @@ mod tests { } #[test] - fn test_list_skills_empty_directory() { + fn test_bare_skills_opens_manager() { let tmpdir = TempDir::new().unwrap(); let _home = IsolatedHome::new(&tmpdir); let mut app = create_test_app_with_tmpdir(&tmpdir); let result = list_skills(&mut app, None); + assert!(matches!(result.action, Some(AppAction::OpenSkillsManager))); + } + + #[test] + fn test_list_skills_empty_directory() { + let tmpdir = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmpdir); + let mut app = create_test_app_with_tmpdir(&tmpdir); + // Empty arg still uses the legacy text inventory (prefix path). + let result = list_skills(&mut app, Some("")); assert!(result.message.is_some()); let msg = result.message.unwrap(); assert!(msg.contains("No skills found")); @@ -972,7 +1137,7 @@ mod tests { "---\nname: test-skill\ndescription: A test skill\n---\nDo something", ); let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = list_skills(&mut app, None); + let result = list_skills(&mut app, Some("")); assert!(result.message.is_some()); let msg = result.message.unwrap(); assert!(msg.contains("Available skills")); @@ -1093,7 +1258,7 @@ mod tests { ); let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = list_skills(&mut app, None); + let result = list_skills(&mut app, Some("")); let msg = result.message.unwrap(); // User-created skills must appear in their own section so they @@ -1135,7 +1300,7 @@ mod tests { ); let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = list_skills(&mut app, None); + let result = list_skills(&mut app, Some("")); let msg = result.message.unwrap(); assert!(msg.contains("/workspace-skill"), "got: {msg}"); @@ -1212,7 +1377,7 @@ mod tests { let mut app = create_test_app_with_tmpdir(&tmpdir); app.skills_dir = tmpdir.path().join(".codewhale").join("skills"); app.skills_scan_codewhale_only = true; - let result = list_skills(&mut app, None); + let result = list_skills(&mut app, Some("")); let msg = result.message.unwrap(); assert!(msg.contains("/codewhale-skill"), "got: {msg}"); @@ -1280,32 +1445,105 @@ mod tests { let mut app = create_test_app_with_tmpdir(&tmpdir); let result = run_skill(&mut app, Some("uninstall absent-skill")); let msg = result.message.unwrap(); - assert!(msg.contains("not installed"), "got: {msg}"); + assert!( + msg.contains("not found") || msg.contains("not installed"), + "got: {msg}" + ); } #[test] fn test_skill_trust_message_marks_marker_advisory() { let tmpdir = TempDir::new().unwrap(); let _home = IsolatedHome::new(&tmpdir); - create_skill_dir( - &tmpdir, - "trusted-skill", - "---\nname: trusted-skill\ndescription: Trust copy\n---\nbody", - ); - let marker = tmpdir + // Mutations only touch CodeWhale-owned roots; place under project scope. + let skill_dir = tmpdir .path() + .join(".codewhale") .join("skills") - .join("trusted-skill") - .join(install::INSTALLED_FROM_MARKER); - std::fs::write(marker, "github:owner/repo\n").expect("installed marker"); + .join("trusted-skill"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: trusted-skill\ndescription: Trust copy\n---\nbody", + ) + .unwrap(); + install::write_installed_from_v2( + &skill_dir, + "github:owner/repo", + None, + "src", + "placeholder", + "trusted-skill", + ) + .unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = run_skill(&mut app, Some("trust trusted-skill")); + let result = run_skill(&mut app, Some("trust --project trusted-skill")); + assert!(!result.is_error, "got: {:?}", result.message); let msg = result.message.expect("trust result"); assert!(msg.contains("advisory"), "got: {msg}"); assert!(!msg.contains("may now invoke"), "got: {msg}"); } + #[test] + fn parse_scope_args_and_default_install_target_is_global() { + use crate::skills::mutation::SkillTargetScope; + + let (scope, rest) = parse_scope_args("github:o/r").unwrap(); + assert_eq!(scope, None); + assert_eq!(rest, "github:o/r"); + // Bare install (no --project/--global) maps to the CodeWhale global root. + assert_eq!( + scope.unwrap_or(SkillTargetScope::Global), + SkillTargetScope::Global + ); + + let (scope, rest) = parse_scope_args("--project my-skill").unwrap(); + assert_eq!(scope, Some(SkillTargetScope::Project)); + assert_eq!(rest, "my-skill"); + + let (scope, rest) = parse_scope_args("--global my-skill").unwrap(); + assert_eq!(scope, Some(SkillTargetScope::Global)); + assert_eq!(rest, "my-skill"); + + assert!(parse_scope_args("--project --global x").is_err()); + } + + #[test] + fn uninstall_external_only_skill_refuses_write() { + let tmpdir = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmpdir); + let ext = tmpdir + .path() + .join(".claude") + .join("skills") + .join("ext-only"); + std::fs::create_dir_all(&ext).unwrap(); + std::fs::write( + ext.join("SKILL.md"), + "---\nname: ext-only\ndescription: d\n---\nbody\n", + ) + .unwrap(); + let sentinel = tmpdir + .path() + .join(".claude") + .join("skills") + .join("SENTINEL"); + std::fs::write(&sentinel, "keep").unwrap(); + + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.workspace = tmpdir.path().to_path_buf(); + let result = run_skill(&mut app, Some("uninstall ext-only")); + assert!(result.is_error, "got: {:?}", result.message); + let msg = result.message.unwrap_or_default(); + assert!( + msg.contains("compatible external") || msg.contains("not found"), + "got: {msg}" + ); + assert_eq!(std::fs::read_to_string(&sentinel).unwrap(), "keep"); + assert!(ext.join("SKILL.md").is_file()); + } + #[test] fn test_run_skill_without_name() { let tmpdir = TempDir::new().unwrap(); diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index f53f70b6df..d72cd177be 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -149,8 +149,10 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { return result; } - // Permanent backward-compatible aliases. They predate the group-owned - // registry and remain documented in docs/architecture/command-dispatch.md. + // Permanent backward-compatible mode aliases. They select a fixed mode + // rather than the canonical `/mode` behavior, so they still dispatch + // before registry lookup. Ordinary compatibility aliases belong in their + // command's `CommandInfo` metadata. match command.as_str() { "jihua" => { return groups::config::dispatch(app, "jihua", arg).unwrap_or_else(|| { @@ -162,11 +164,6 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { CommandResult::error("The /zidong alias could not be dispatched.") }); } - "slop" | "canzha" => { - return groups::config::dispatch(app, "debt", arg).unwrap_or_else(|| { - CommandResult::error("The /debt command could not be dispatched.") - }); - } _ => {} } @@ -193,7 +190,10 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { if let Some(result) = groups::skills::run_skill_by_name(app, command.as_str(), arg) { return result; } - let suggestions = suggest_command_names(command.as_str(), 3); + let suggestions = + user_registry::with_registry_for_workspace(Some(&app.workspace), |user_commands| { + suggest_command_names(command.as_str(), 3, user_commands) + }); if suggestions.is_empty() { CommandResult::error(format!( "Unknown command: /{command}. Type /help for available commands." @@ -251,7 +251,42 @@ fn edit_distance(a: &str, b: &str) -> usize { previous[b_chars.len()] } -fn suggest_command_names(input: &str, limit: usize) -> Vec { +fn best_suggestion_score<'a>( + query: &str, + candidates: impl IntoIterator, +) -> Option<(u8, usize)> { + let mut best: Option<(u8, usize)> = None; + for candidate in candidates { + let prefix_match = candidate.starts_with(query) || query.starts_with(candidate); + let contains_match = candidate.contains(query) || query.contains(candidate); + let distance = edit_distance(candidate, query); + let close_typo = distance <= 2; + if !(prefix_match || contains_match || close_typo) { + continue; + } + + let rank = if prefix_match { + 0 + } else if contains_match { + 1 + } else { + 2 + }; + + match best { + Some((best_rank, best_distance)) + if rank > best_rank || (rank == best_rank && distance >= best_distance) => {} + _ => best = Some((rank, distance)), + } + } + best +} + +fn suggest_command_names( + input: &str, + limit: usize, + user_commands: &user_registry::UserCommandRegistry, +) -> Vec { let query = input.trim().to_ascii_lowercase(); if query.is_empty() || limit == 0 { return Vec::new(); @@ -259,36 +294,37 @@ fn suggest_command_names(input: &str, limit: usize) -> Vec { let mut scored: Vec<(u8, usize, String)> = Vec::new(); for command in registry().infos() { - let mut best: Option<(u8, usize)> = None; - for candidate in std::iter::once(command.name).chain(command.aliases.iter().copied()) { - let prefix_match = candidate.starts_with(&query) || query.starts_with(candidate); - let contains_match = candidate.contains(&query) || query.contains(candidate); - let distance = edit_distance(candidate, &query); - let close_typo = distance <= 2; - if !(prefix_match || contains_match || close_typo) { - continue; - } - - let rank = if prefix_match { - 0 - } else if contains_match { - 1 - } else { - 2 - }; - - match best { - Some((best_rank, best_distance)) - if rank > best_rank || (rank == best_rank && distance >= best_distance) => {} - _ => best = Some((rank, distance)), - } + // A user command can shadow a built-in canonical name or just one of + // its aliases. Score only the built-in spellings that still dispatch + // to the built-in so suggestions never advertise different behavior. + if user_commands.get(command.name).is_some() { + continue; } - - if let Some((rank, distance)) = best { + let candidates = std::iter::once(command.name).chain( + command + .aliases + .iter() + .copied() + .filter(|alias| user_commands.get(alias).is_none()), + ); + if let Some((rank, distance)) = best_suggestion_score(&query, candidates) { scored.push((rank, distance, command.name.to_string())); } } + for command in user_commands.iter().filter(|command| !command.hidden) { + let candidates = std::iter::once(command.name.as_str()).chain( + command.aliases.iter().map(String::as_str).filter(|alias| { + user_commands + .get(alias) + .is_some_and(|resolved| resolved.name == command.name) + }), + ); + if let Some((rank, distance)) = best_suggestion_score(&query, candidates) { + scored.push((rank, distance, command.name.clone())); + } + } + scored.sort_by(|a, b| { a.0.cmp(&b.0) .then_with(|| a.1.cmp(&b.1)) @@ -433,6 +469,143 @@ mod tests { assert_eq!(links.aliases, &["dashboard", "api", "lianjie"]); } + #[test] + fn debt_compat_aliases_use_registry_discovery_and_help() { + let debt = get_command_info("debt").expect("debt command should be registered"); + assert_eq!(debt.aliases, &["cleanup", "slop", "canzha"]); + assert_eq!(debt.description_id, MessageId::CmdDebtDescription); + + for alias in ["slop", "canzha"] { + let resolved = get_command_info(alias) + .unwrap_or_else(|| panic!("/{alias} should resolve through the registry")); + assert_eq!(resolved.name, "debt"); + + let mut app = create_test_app(); + let result = execute(&format!("/help {alias}"), &mut app); + assert!(!result.is_error, "/help {alias} returned {result:?}"); + let message = result + .message + .unwrap_or_else(|| panic!("/help {alias} should return text")); + assert!( + message.starts_with("debt\n"), + "unexpected help: {message:?}" + ); + assert!( + message.contains("cleanup, slop, canzha"), + "help should list every debt alias: {message:?}" + ); + } + + let user_commands = user_registry::UserCommandRegistry::new(); + assert!( + suggest_command_names("slpo", 3, &user_commands) + .iter() + .any(|name| name == "debt"), + "typo suggestions should consider the /slop alias" + ); + } + + #[test] + fn debt_alias_help_and_suggestions_respect_user_command_shadows() { + let temp = tempdir().unwrap(); + let commands_dir = temp.path().join(".codewhale").join("commands"); + std::fs::create_dir_all(&commands_dir).unwrap(); + std::fs::write( + commands_dir.join("slop.md"), + "---\ndescription: User slop workflow\nusage: /slop \n---\ncustom slop $ARGUMENTS", + ) + .unwrap(); + std::fs::write( + commands_dir.join("review.md"), + "---\ndescription: User canzha workflow\nusage: /review \nalias: canzha\n---\ncustom canzha $ARGUMENTS", + ) + .unwrap(); + std::fs::write( + commands_dir.join("alpha.md"), + "---\nalias: beta\n---\nalpha body", + ) + .unwrap(); + std::fs::write(commands_dir.join("beta.md"), "beta body").unwrap(); + + let mut app = create_test_app(); + app.workspace = temp.path().to_path_buf(); + user_registry::reload(Some(temp.path())); + + for (input, expected) in [ + ("/slop now", "custom slop now"), + ("/canzha later", "custom canzha later"), + ] { + let result = execute(input, &mut app); + assert!(!result.is_error, "{input} returned {result:?}"); + assert!(matches!( + result.action, + Some(AppAction::SendMessage(ref message)) if message == expected + )); + } + + for (topic, canonical, description, usage) in [ + ("slop", "slop", "User slop workflow", "/slop "), + ("canzha", "review", "User canzha workflow", "/review "), + ] { + let result = execute(&format!("/help {topic}"), &mut app); + assert!(!result.is_error, "/help {topic} returned {result:?}"); + let message = result.message.expect("user command help text"); + assert!( + message.starts_with(&format!("{canonical}\n")), + "{message:?}" + ); + assert!(message.contains(description), "{message:?}"); + assert!(message.contains(usage), "{message:?}"); + assert!(!message.starts_with("debt\n"), "{message:?}"); + } + + for (typo, expected) in [("/slpo", "/slop"), ("/canzh", "/review")] { + let result = execute(typo, &mut app); + assert!(result.is_error, "{typo} should remain unknown"); + let message = result.message.expect("unknown-command suggestion"); + assert!(message.contains(expected), "{message:?}"); + assert!(!message.contains("/debt"), "{message:?}"); + } + + let debt_help = execute("/help debt", &mut app); + assert!(!debt_help.is_error); + let debt_message = debt_help + .message + .expect("canonical debt help should render"); + assert!(debt_message.contains("cleanup"), "{debt_message:?}"); + assert!(!debt_message.contains("slop"), "{debt_message:?}"); + assert!(!debt_message.contains("canzha"), "{debt_message:?}"); + + let slop_typo = execute("/slpo", &mut app); + let slop_typo_message = slop_typo.message.expect("typo should return guidance"); + assert!(!slop_typo_message.contains("/debt"), "{slop_typo_message}"); + + let canzha_typo = execute("/canzhaa", &mut app); + let canzha_typo_message = canzha_typo.message.expect("typo should return guidance"); + assert!( + !canzha_typo_message.contains("/debt"), + "{canzha_typo_message}" + ); + + let debt_typo = execute("/detb", &mut app); + let debt_typo_message = debt_typo.message.expect("typo should return guidance"); + assert!(debt_typo_message.contains("/debt"), "{debt_typo_message}"); + + let alpha_help = execute("/help alpha", &mut app); + assert!(!alpha_help.is_error); + let alpha_message = alpha_help.message.expect("alpha help text"); + assert!(!alpha_message.contains("beta"), "{alpha_message}"); + + let beta_help = execute("/help beta", &mut app); + assert!(!beta_help.is_error); + assert!( + beta_help + .message + .expect("beta help text") + .starts_with("beta\n") + ); + } + #[test] fn transcript_command_is_discoverable_and_opens_live_overlay() { let transcript = command_infos() @@ -1261,10 +1434,8 @@ mod tests { assert_eq!(app.hunt.token_budget, Some(100)); let (mut app, _tmpdir, _guard) = create_isolated_test_app(); - let skills = execute("/skills", &mut app) - .message - .expect("/skills should return text"); - assert!(skills.contains("Skills location:")); + let result = execute("/skills", &mut app); + assert!(matches!(result.action, Some(AppAction::OpenSkillsManager))); let mut app = create_test_app(); let result = execute("/task list", &mut app); diff --git a/crates/tui/src/commands/user_commands.rs b/crates/tui/src/commands/user_commands.rs index 652bc0206a..758e34b9ef 100644 --- a/crates/tui/src/commands/user_commands.rs +++ b/crates/tui/src/commands/user_commands.rs @@ -2,11 +2,13 @@ //! workspace-local `/.codewhale/commands/.md`. //! //! Users drop `.md` files into a commands directory and the filename -//! (without `.md` extension) becomes a slash command. When invoked via -//! `/name`, the file contents are sent as a user message. +//! (without `.md` extension) becomes the default slash-command name. A +//! frontmatter `name` may replace it. When invoked, the file contents are sent +//! as a user message. //! //! Files may include optional YAML-like frontmatter between `---` markers. -//! Supported fields are `description`, `argument-hint`, `allowed-tools`, and `pausable`. +//! Supported fields are `name`, `description`, `usage`, `arguments`, +//! `argument-hint`, `allowed-tools`, `pausable`, `alias`/`aliases`, and `hidden`. //! Frontmatter is stripped before the command body is sent to the model. //! //! ## Precedence @@ -795,4 +797,22 @@ mod tests { ))); assert!(metadata.contains(&("argument-hint".to_string(), "".to_string()))); } + + #[test] + fn parser_preserves_layer_5_1_frontmatter_fields() { + let (metadata, body) = parse_frontmatter( + "---\nname: inspect\ndescription: Inspect a target\nusage: /inspect \narguments: \nhidden: false\nallowed-tools: Read_File, Grep_Files\n---\ninspect $ARGUMENTS", + ); + + assert!(metadata.contains(&("name".to_string(), "inspect".to_string()))); + assert!(metadata.contains(&("description".to_string(), "Inspect a target".to_string()))); + assert!(metadata.contains(&("usage".to_string(), "/inspect ".to_string()))); + assert!(metadata.contains(&("arguments".to_string(), "".to_string()))); + assert!(metadata.contains(&("hidden".to_string(), "false".to_string()))); + assert!(metadata.contains(&( + "allowed-tools".to_string(), + "Read_File, Grep_Files".to_string() + ))); + assert_eq!(body, "inspect $ARGUMENTS"); + } } diff --git a/crates/tui/src/commands/user_registry.rs b/crates/tui/src/commands/user_registry.rs index 376706bec7..dd972ae7ce 100644 --- a/crates/tui/src/commands/user_registry.rs +++ b/crates/tui/src/commands/user_registry.rs @@ -43,6 +43,8 @@ pub struct UserCommandMetadata { pub name: String, pub body: String, pub description: Option, + pub usage: Option, + pub arguments: Option, pub argument_hint: Option, pub allowed_tools: Option>, pub pausable: bool, @@ -50,6 +52,36 @@ pub struct UserCommandMetadata { pub hidden: bool, } +impl UserCommandMetadata { + /// User-facing invocation syntax. `argument-hint` remains the legacy + /// fallback for existing command files; `arguments` is the final fallback + /// when no complete `usage` string is supplied. + pub(crate) fn display_usage(&self) -> Option<&str> { + [&self.usage, &self.argument_hint, &self.arguments] + .into_iter() + .filter_map(Option::as_deref) + .find(|value| !value.trim().is_empty()) + .map(str::trim) + } + + /// Whether selecting this command should leave the composer open for + /// arguments. These fields describe presentation only; dispatch keeps the + /// existing permissive `$ARGUMENTS`/`$1` template semantics. + pub(crate) fn takes_arguments(&self) -> bool { + self.arguments + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + // Preserve the legacy contract exactly: the presence of + // `argument-hint`, including an explicitly empty value, made the + // palette insert rather than immediately execute the command. + || self.argument_hint.is_some() + || self + .usage + .as_deref() + .is_some_and(|usage| usage_describes_arguments(&self.name, usage)) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct LoadError { pub path: PathBuf, @@ -82,7 +114,9 @@ impl UserCommandRegistry { let mut registry = Self::new(); for dir in paths { - for (name, content) in user_commands::load_commands_from_dir(dir) { + let mut directory_commands = user_commands::load_commands_from_dir(dir); + directory_commands.sort_by(|a, b| a.0.cmp(&b.0)); + for (name, content) in directory_commands { let canonical = normalize_name(&name); if seen.insert(canonical.clone()) { loaded.push((name, content, dir.join(format!("{canonical}.md")))); @@ -96,7 +130,6 @@ impl UserCommandRegistry { } } } - loaded.sort_by(|a, b| a.0.cmp(&b.0)); registry.load_from_entries(loaded); registry } @@ -116,18 +149,21 @@ impl UserCommandRegistry { } fn load_from_entries(&mut self, commands: Vec<(String, String, PathBuf)>) { - let canonical_names = commands + let parsed_commands = commands + .into_iter() + .map(|(name, content, path)| { + let (metadata, errors) = parse_metadata(name, &content, &path); + (metadata, errors, path) + }) + .collect::>(); + let canonical_names = parsed_commands .iter() - .map(|(name, _, _)| normalize_name(name)) + .map(|(metadata, _, _)| metadata.name.clone()) .collect::>(); - for (name, content, path) in commands { - let (metadata, errors) = parse_metadata(name, &content, &path); - for error in errors { + for (mut metadata, errors, path) in parsed_commands { + for error in &errors { self.record_load_error(error.path.clone(), error.message.clone()); - self.invalid_commands - .entry(metadata.name.clone()) - .or_insert(error.message); } if self.commands.contains_key(&metadata.name) { @@ -141,6 +177,16 @@ impl UserCommandRegistry { continue; } + // A malformed losing duplicate must not poison the valid command + // that already won precedence. Only the selected definition owns + // the dispatch-time error for its canonical name and aliases. + for error in errors { + self.invalid_commands + .entry(metadata.name.clone()) + .or_insert(error.message); + } + + let mut accepted_aliases = Vec::with_capacity(metadata.aliases.len()); for alias in &metadata.aliases { let alias = alias.to_ascii_lowercase(); if canonical_names.contains(&alias) { @@ -163,8 +209,13 @@ impl UserCommandRegistry { ); continue; } - self.aliases.insert(alias, metadata.name.clone()); + self.aliases.insert(alias.clone(), metadata.name.clone()); + accepted_aliases.push(alias); } + // Discovery surfaces consume metadata directly. Keep it aligned + // with the dispatch map so a rejected alias is never advertised + // by help, command palettes, or slash completion. + metadata.aliases = accepted_aliases; self.commands.insert(metadata.name.clone(), metadata); } @@ -228,23 +279,28 @@ fn parse_metadata( content: &str, path: &Path, ) -> (UserCommandMetadata, Vec) { - let canonical = normalize_name(&name); + let filename_name = normalize_name(&name); let (metadata, body) = user_commands::parse_frontmatter(content); - let errors = validate_command_content(&canonical, content, path); let mut command = UserCommandMetadata { - name: canonical, + name: filename_name.clone(), body: body.to_string(), description: None, + usage: None, + arguments: None, argument_hint: None, allowed_tools: None, pausable: false, aliases: Vec::new(), hidden: false, }; + let mut configured_name = None; for (key, value) in metadata { match key.as_str() { + "name" => configured_name = Some(value), "description" => command.description = Some(value), + "usage" => command.usage = Some(value), + "arguments" => command.arguments = Some(value), "argument-hint" => command.argument_hint = Some(value), "allowed-tools" => { command.allowed_tools = Some(user_commands::parse_allowed_tools(&value)); @@ -262,6 +318,21 @@ fn parse_metadata( } } + let mut errors = Vec::new(); + if let Some(configured_name) = configured_name { + if let Some(normalized) = normalize_configured_name(&configured_name) { + command.name = normalized; + } else { + errors.push(LoadError { + path: path.to_path_buf(), + message: format!( + "User command '/{filename_name}' has invalid frontmatter name {configured_name:?}; expected one slash-command token" + ), + }); + } + } + errors.extend(validate_command_content(&command.name, content, path)); + (command, errors) } @@ -333,6 +404,22 @@ fn normalize_name(name: &str) -> String { name.trim().trim_start_matches('/').to_ascii_lowercase() } +fn normalize_configured_name(name: &str) -> Option { + let name = name.trim(); + let name = name.strip_prefix('/').unwrap_or(name); + (!name.is_empty() && !name.contains('/') && !name.contains(char::is_whitespace)) + .then(|| name.to_ascii_lowercase()) +} + +fn usage_describes_arguments(name: &str, usage: &str) -> bool { + let usage = usage.trim(); + if usage.is_empty() { + return false; + } + let bare_usage = usage.trim_start_matches('/'); + !bare_usage.eq_ignore_ascii_case(name) +} + fn normalize_workspace(workspace: Option<&Path>) -> Option { workspace.map(Path::to_path_buf) } @@ -518,12 +605,16 @@ mod tests { fn registry_loads_markdown_metadata() { let registry = UserCommandRegistry::from_loaded(vec![( "review".to_string(), - "---\ndescription: Review code\nargument-hint: \nallowed-tools: read, grep\npausable: true\n---\nReview $ARGUMENTS".to_string(), + "---\ndescription: Review code\nusage: /review \narguments: \nargument-hint: \nallowed-tools: read, grep\npausable: true\n---\nReview $ARGUMENTS".to_string(), )]); let command = registry.get("review").expect("command loaded"); assert_eq!(command.description.as_deref(), Some("Review code")); - assert_eq!(command.argument_hint.as_deref(), Some("")); + assert_eq!(command.usage.as_deref(), Some("/review ")); + assert_eq!(command.arguments.as_deref(), Some("")); + assert_eq!(command.argument_hint.as_deref(), Some("")); + assert_eq!(command.display_usage(), Some("/review ")); + assert!(command.takes_arguments()); assert_eq!( command.allowed_tools, Some(vec!["read".to_string(), "grep".to_string()]) @@ -532,6 +623,37 @@ mod tests { assert_eq!(command.body, "Review $ARGUMENTS"); } + #[test] + fn frontmatter_name_replaces_filename_canonical_name() { + let registry = UserCommandRegistry::from_loaded(vec![( + "workflow-file".to_string(), + "---\nname: /Review-Target\ndescription: Review target\n---\nreview $ARGUMENTS" + .to_string(), + )]); + + let command = registry.get("review-target").expect("renamed command"); + assert_eq!(command.name, "review-target"); + assert_eq!(command.body, "review $ARGUMENTS"); + assert!( + registry.get("workflow-file").is_none(), + "the filename is only a default; retaining it requires an explicit alias" + ); + } + + #[test] + fn filename_remains_the_default_name_without_frontmatter_override() { + let registry = UserCommandRegistry::from_loaded(vec![( + "Filename-Default".to_string(), + "plain body".to_string(), + )]); + + assert_eq!(registry.names(), vec!["filename-default"]); + assert_eq!( + registry.get("/filename-default").unwrap().body, + "plain body" + ); + } + #[test] fn registry_names_are_sorted() { let registry = UserCommandRegistry::from_loaded(vec![ @@ -556,6 +678,33 @@ mod tests { assert_eq!(registry.get("shadow").unwrap().body, "first"); } + #[test] + fn frontmatter_name_collision_uses_directory_then_filename_precedence() { + let first = TempDir::new().unwrap(); + let second = TempDir::new().unwrap(); + std::fs::write( + first.path().join("z-workspace.md"), + "---\nname: shared\n---\nworkspace body", + ) + .unwrap(); + std::fs::write( + second.path().join("a-global.md"), + "---\nname: shared\n---\nglobal body", + ) + .unwrap(); + + let registry = UserCommandRegistry::load_from_paths(&[ + first.path().to_path_buf(), + second.path().to_path_buf(), + ]); + + assert_eq!(registry.get("shared").unwrap().body, "workspace body"); + assert!(registry.load_errors().iter().any(|error| { + error.message.contains("User command '/shared'") + && error.message.contains("defined more than once") + })); + } + #[test] fn alias_lookup_uses_metadata_aliases() { let registry = UserCommandRegistry::from_loaded(vec![( @@ -644,8 +793,8 @@ mod tests { let tmp = TempDir::new().unwrap(); write_workspace_command( tmp.path(), - "secret", - "---\nhidden: true\ndescription: Internal workflow\n---\nsecret $ARGUMENTS", + "internal-workflow", + "---\nname: secret\nhidden: true\ndescription: Internal workflow\n---\nsecret $ARGUMENTS", ); let mut app = test_app(tmp.path().to_path_buf()); @@ -656,6 +805,30 @@ mod tests { assert_eq!(app.hunt.quarry.as_deref(), Some("Internal workflow")); } + #[test] + fn dispatch_uses_frontmatter_name_arguments_and_allowed_tools() { + let tmp = TempDir::new().unwrap(); + write_workspace_command( + tmp.path(), + "deploy-workflow", + "---\nname: ship\nusage: /ship \narguments: \nallowed-tools: Read_File, Grep_Files\n---\nship $1 with $ARGUMENTS", + ); + let mut app = test_app(tmp.path().to_path_buf()); + + let result = crate::commands::execute("/ship moon base", &mut app); + + assert!(!result.is_error, "{:?}", result.message); + assert_eq!(sent_message(result), "ship moon with moon base"); + assert_eq!( + app.active_allowed_tools, + Some(vec!["read_file".to_string(), "grep_files".to_string()]) + ); + assert!( + try_dispatch(&mut app, "/deploy-workflow").is_none(), + "the source filename must not remain an implicit dispatch alias" + ); + } + #[test] fn empty_allowed_tools_frontmatter_blocks_all_tools() { let tmp = TempDir::new().unwrap(); @@ -753,6 +926,11 @@ mod tests { let command = registry.get("shared").expect("alias resolves"); assert_eq!(command.name, "first"); assert_eq!(command.body, "first body"); + assert_eq!(command.aliases, ["shared"]); + assert!( + registry.get("second").unwrap().aliases.is_empty(), + "the losing command must not advertise an alias it does not own" + ); assert!( registry.load_errors().iter().any(|error| error .message @@ -770,12 +948,19 @@ mod tests { "alpha".to_string(), "---\nalias: beta\n---\nalpha body".to_string(), ), - ("beta".to_string(), "beta body".to_string()), + ( + "renamed-beta".to_string(), + "---\nname: beta\n---\nbeta body".to_string(), + ), ]); let command = registry.get("beta").expect("canonical command resolves"); assert_eq!(command.name, "beta"); assert_eq!(command.body, "beta body"); + assert!( + registry.get("alpha").unwrap().aliases.is_empty(), + "a canonical-name collision must be absent from alias metadata" + ); assert!( registry.load_errors().iter().any(|error| error .message @@ -807,6 +992,30 @@ mod tests { ); } + #[test] + fn malformed_losing_name_override_does_not_poison_valid_winner() { + let registry = UserCommandRegistry::from_loaded(vec![ + ( + "first-file".to_string(), + "---\nname: shared\n---\nfirst body".to_string(), + ), + ( + "second-file".to_string(), + "---\nname: shared\nnot valid frontmatter\n---\nsecond body".to_string(), + ), + ]); + + assert_eq!(registry.get("shared").unwrap().body, "first body"); + assert_eq!(registry.dispatch_error("shared"), None); + assert!(registry.load_errors().iter().any(|error| { + error.message.contains("invalid frontmatter") && error.path.ends_with("second-file.md") + })); + assert!(registry.load_errors().iter().any(|error| { + error.message.contains("defined more than once") + && error.path.ends_with("second-file.md") + })); + } + #[test] fn invalid_frontmatter_dispatch_returns_user_command_error_without_builtin_fallback() { let tmp = TempDir::new().unwrap(); @@ -825,6 +1034,51 @@ mod tests { assert!(message.contains("invalid frontmatter"), "{message}"); } + #[test] + fn malformed_file_is_recoverable_and_valid_sibling_still_dispatches() { + let tmp = TempDir::new().unwrap(); + write_workspace_command( + tmp.path(), + "broken", + "---\ndescription: Broken\nnot valid frontmatter\n---\nbroken body", + ); + write_workspace_command( + tmp.path(), + "healthy", + "---\ndescription: Healthy\n---\nhealthy $ARGUMENTS", + ); + let mut app = test_app(tmp.path().to_path_buf()); + + let healthy = crate::commands::execute("/healthy now", &mut app); + assert!(!healthy.is_error, "{:?}", healthy.message); + assert_eq!(sent_message(healthy), "healthy now"); + + let broken = crate::commands::execute("/broken", &mut app); + assert!(broken.is_error); + assert!( + broken + .message + .as_deref() + .is_some_and(|message| message.contains("invalid frontmatter")) + ); + } + + #[test] + fn invalid_frontmatter_name_is_recoverable_under_filename_default() { + let registry = UserCommandRegistry::from_loaded(vec![( + "recoverable".to_string(), + "---\nname: two words\n---\nbody".to_string(), + )]); + + assert!(registry.get("recoverable").is_some()); + assert!(registry.dispatch_error("recoverable").is_some()); + assert!(registry.load_errors().iter().any(|error| { + error + .message + .contains("invalid frontmatter name \"two words\"") + })); + } + #[test] fn frontmatter_line_with_empty_key_is_invalid() { let registry = UserCommandRegistry::from_loaded(vec![( diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index f2a5d3828c..d4e253f508 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -72,6 +72,8 @@ pub enum ApiProvider { OpencodeGo, Meta, Xai, + /// Jiangsu Telecom TokenHub — OpenAI-compatible AI gateway. + Telecomjs, /// User-defined OpenAI-compatible endpoint (#1519). /// /// Selected when `provider = ""` names a `[providers.] @@ -227,7 +229,7 @@ impl ApiProvider { /// `ApiProvider` discriminant → `ProviderKind` lookup. /// Index 1 is `None` for the legacy `DeepseekCN` variant. - const KIND_LOOKUP: [Option; 36] = [ + const KIND_LOOKUP: [Option; 37] = [ Some(codewhale_config::ProviderKind::Deepseek), None, // DeepseekCN Some(codewhale_config::ProviderKind::DeepseekAnthropic), @@ -263,11 +265,12 @@ impl ApiProvider { Some(codewhale_config::ProviderKind::OpencodeGo), Some(codewhale_config::ProviderKind::Meta), Some(codewhale_config::ProviderKind::Xai), + Some(codewhale_config::ProviderKind::Telecomjs), Some(codewhale_config::ProviderKind::Custom), ]; /// `ProviderKind` discriminant → `ApiProvider` lookup. - const FROM_KIND_LOOKUP: [Self; 35] = [ + const FROM_KIND_LOOKUP: [Self; 36] = [ Self::Deepseek, Self::DeepseekAnthropic, Self::NvidiaNim, @@ -302,6 +305,7 @@ impl ApiProvider { Self::OpencodeGo, Self::Meta, Self::Xai, + Self::Telecomjs, Self::Custom, ]; @@ -1313,6 +1317,19 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati XAI_GROK_4_20_0309_REASONING_MODEL, XAI_GROK_4_20_0309_NON_REASONING_MODEL, ], + ApiProvider::Telecomjs => vec![ + DEFAULT_TELECOMJS_MODEL, + "deepseek-v4-flash", + "DeepSeek-R1", + "qwen3.7-plus", + "qwen3-max", + "glm-5.2", + "glm-5.1", + "GLM-5.0", + "Minimax-M2.5", + "kimi-k2.7-code", + "Doubao-Seed-2.0-Pro", + ], // Custom endpoints expose no built-in completion names; the user // supplies their own model id (#1519). ApiProvider::Custom => Vec::new(), @@ -2106,8 +2123,8 @@ pub struct Config { /// Optional API key for the external sandbox backend (sent as Bearer token). #[serde(alias = "sandboxApiKey")] pub sandbox_api_key: Option, - /// When true and `/usr/bin/bwrap` is present on Linux, route exec_shell - /// through bubblewrap instead of relying solely on Landlock (#2184). + /// When true and `/usr/bin/bwrap` is executable on Linux, route exec_shell + /// through bubblewrap (#2184). /// Defaults to false. Requires the `bubblewrap` package to be installed /// separately — we do NOT vendor bwrap. #[serde(alias = "preferBwrap")] @@ -2846,6 +2863,14 @@ pub struct ProvidersConfig { pub meta: ProviderConfig, #[serde(default, alias = "x-ai", alias = "x_ai", alias = "grok")] pub xai: ProviderConfig, + #[serde( + default, + alias = "telecom-js", + alias = "telecom_js", + alias = "telecomjs-cn", + alias = "tokenhub" + )] + pub telecomjs: ProviderConfig, /// Arbitrary user-named custom providers (#1519). /// /// Captures every `[providers.]` table whose key is not one of the @@ -4154,6 +4179,7 @@ impl Config { ApiProvider::OpencodeGo => &providers.opencode_go, ApiProvider::Meta => &providers.meta, ApiProvider::Xai => &providers.xai, + ApiProvider::Telecomjs => &providers.telecomjs, // Handled by the name-keyed early return above (#1519). ApiProvider::Custom => unreachable!("custom provider resolved by name above"), }) @@ -4219,6 +4245,7 @@ impl Config { ApiProvider::OpencodeGo => &mut providers.opencode_go, ApiProvider::Meta => &mut providers.meta, ApiProvider::Xai => &mut providers.xai, + ApiProvider::Telecomjs => &mut providers.telecomjs, // Handled by the name-keyed early return above (#1519). ApiProvider::Custom => unreachable!("custom provider resolved by name above"), } @@ -4552,6 +4579,7 @@ impl Config { ApiProvider::OpencodeGo => DEFAULT_OPENCODE_GO_MODEL, ApiProvider::Meta => DEFAULT_META_MODEL, ApiProvider::Xai => DEFAULT_XAI_MODEL, + ApiProvider::Telecomjs => DEFAULT_TELECOMJS_MODEL, // Custom endpoints have no built-in default model; pass through the // descriptor placeholder when nothing is configured (#1519). ApiProvider::Custom => codewhale_config::ProviderKind::Custom @@ -4609,7 +4637,8 @@ impl Config { | ApiProvider::LongCat | ApiProvider::OpencodeGo | ApiProvider::Meta - | ApiProvider::Xai => None, + | ApiProvider::Xai + | ApiProvider::Telecomjs => None, ApiProvider::Custom if self.uses_legacy_literal_custom_route() => self.base_url.clone(), // Named custom routes read their base URL from `provider_base`. ApiProvider::Custom => None, @@ -4675,6 +4704,7 @@ impl Config { ApiProvider::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL, ApiProvider::Meta => DEFAULT_META_BASE_URL, ApiProvider::Xai => DEFAULT_XAI_BASE_URL, + ApiProvider::Telecomjs => DEFAULT_TELECOMJS_BASE_URL, // No built-in endpoint; descriptor placeholder keeps the // fallback total. A real custom route configures // `[providers.] base_url` which wins above (#1519). @@ -5986,6 +6016,7 @@ fn provider_env_base_url_override(provider: ApiProvider) -> Option { ApiProvider::Huggingface => &["HUGGINGFACE_BASE_URL", "HF_BASE_URL"], ApiProvider::Meta => &["META_MODEL_API_BASE_URL", "MODEL_API_BASE_URL"], ApiProvider::Xai => &["XAI_BASE_URL"], + ApiProvider::Telecomjs => &["TELECOMJS_BASE_URL"], ApiProvider::OpencodeGo => &["OPENCODE_GO_BASE_URL"], ApiProvider::Deepseek | ApiProvider::DeepseekCN @@ -6267,6 +6298,13 @@ fn apply_env_overrides(config: &mut Config) { .xai .base_url = Some(value); } + ApiProvider::Telecomjs => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .telecomjs + .base_url = Some(value); + } // Custom resolves to the named `[providers.]` table; route the // override through the exact route while retaining the released // root-literal custom storage shape (#1519, #4334). @@ -6466,6 +6504,16 @@ fn apply_env_overrides(config: &mut Config) { .xai .base_url = Some(value); } + if matches!(config.api_provider(), ApiProvider::Telecomjs) + && let Ok(value) = std::env::var("TELECOMJS_BASE_URL") + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .telecomjs + .base_url = Some(value); + } if let Ok(value) = std::env::var("CODEWHALE_HTTP_HEADERS").or_else(|_| std::env::var("DEEPSEEK_HTTP_HEADERS")) && let Ok(headers) = parse_http_headers(&value) @@ -6527,6 +6575,7 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::OpencodeGo => &mut providers.opencode_go, ApiProvider::Meta => &mut providers.meta, ApiProvider::Xai => &mut providers.xai, + ApiProvider::Telecomjs => &mut providers.telecomjs, ApiProvider::Custom => providers .custom .entry(custom_key.expect("custom key captured for custom provider")) @@ -6711,6 +6760,16 @@ fn apply_env_overrides(config: &mut Config) { .opencode_go .model = Some(value); } + if matches!(config.api_provider(), ApiProvider::Telecomjs) + && let Ok(value) = std::env::var("TELECOMJS_MODEL") + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .telecomjs + .model = Some(value); + } if let Some(value) = codewhale_env_var("CODEWHALE_MODEL", "DEEPSEEK_MODEL") .ok() .or_else(|| { @@ -6788,6 +6847,7 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::OpencodeGo => &mut providers.opencode_go, ApiProvider::Meta => &mut providers.meta, ApiProvider::Xai => &mut providers.xai, + ApiProvider::Telecomjs => &mut providers.telecomjs, }; entry.model = Some(value); } @@ -7070,6 +7130,7 @@ pub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool { | ApiProvider::Huggingface | ApiProvider::Meta | ApiProvider::Xai + | ApiProvider::Telecomjs // Custom OpenAI-compatible endpoints preserve user-supplied model // ids verbatim (#1519); never normalize/rewrite them. | ApiProvider::Custom @@ -7770,6 +7831,7 @@ fn merge_providers( opencode_go: merge_provider_config(base.opencode_go, override_cfg.opencode_go), meta: merge_provider_config(base.meta, override_cfg.meta), xai: merge_provider_config(base.xai, override_cfg.xai), + telecomjs: merge_provider_config(base.telecomjs, override_cfg.telecomjs), custom: merge_custom_providers(base.custom, override_cfg.custom), }), } diff --git a/crates/tui/src/config/models.rs b/crates/tui/src/config/models.rs index 9de02a43a1..58c307347e 100644 --- a/crates/tui/src/config/models.rs +++ b/crates/tui/src/config/models.rs @@ -197,3 +197,5 @@ pub const XAI_GROK_COMPOSER_2_5_FAST_MODEL: &str = "grok-composer-2.5-fast"; pub const XAI_GROK_4_20_0309_REASONING_MODEL: &str = "grok-4.20-0309-reasoning"; pub const XAI_GROK_4_20_0309_NON_REASONING_MODEL: &str = "grok-4.20-0309-non-reasoning"; pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1"; +pub const DEFAULT_TELECOMJS_MODEL: &str = "deepseek-v4-pro"; +pub const DEFAULT_TELECOMJS_BASE_URL: &str = "https://aigw.telecomjs.com/v1"; diff --git a/crates/tui/src/config_persistence.rs b/crates/tui/src/config_persistence.rs index a319ee5379..8656ea7c02 100644 --- a/crates/tui/src/config_persistence.rs +++ b/crates/tui/src/config_persistence.rs @@ -303,6 +303,7 @@ fn provider_base_url_table_key(provider: ApiProvider) -> anyhow::Result<&'static ApiProvider::OpencodeGo => Ok("opencode_go"), ApiProvider::Meta => Ok("meta"), ApiProvider::Xai => Ok("xai"), + ApiProvider::Telecomjs => Ok("telecomjs"), // Custom providers live under a user-chosen `[providers.]` table, // not a fixed key. Persisting base_url through this static-key path is // out of scope for the #1519 constrained slice; users edit the named @@ -816,6 +817,14 @@ mod tests { ); assert_eq!(entry.model.as_deref(), Some("acme/code-1")); assert_eq!(entry.api_key_env.as_deref(), Some("ACME_API_KEY")); + + let dispatcher = codewhale_config::ConfigStore::load(Some(written)) + .expect("the dispatcher must parse the exact config written by the TUI"); + assert_eq!( + dispatcher.config.provider, + codewhale_config::ProviderKind::Custom + ); + assert_eq!(dispatcher.config.provider_id(), "acme_ai"); } #[test] diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index bb8774e911..116a858bdc 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -66,6 +66,7 @@ pub struct SettingsSection { pub paste_burst_detection: bool, pub show_thinking: bool, pub show_tool_details: bool, + pub inline_diffs: InlineDiffValue, pub locale: UiLocale, pub theme: UiThemeValue, #[schemars( @@ -249,6 +250,14 @@ pub enum TranscriptSpacingValue { Spacious, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum InlineDiffValue { + Full, + Summary, + Off, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum WorkSurfacePlacementValue { @@ -371,6 +380,7 @@ pub fn build_document(app: &App, config: &Config) -> Result { paste_burst_detection: settings.paste_burst_detection, show_thinking: settings.show_thinking, show_tool_details: settings.show_tool_details, + inline_diffs: settings.inline_diffs.as_str().into(), locale: UiLocale::from_setting(&settings.locale)?, theme: UiThemeValue::from_setting(&settings.theme)?, background_color: settings.background_color.clone(), @@ -558,6 +568,7 @@ pub fn apply_document( "show_tool_details", bool_str(doc.settings.show_tool_details), ), + ("inline_diffs", doc.settings.inline_diffs.as_setting()), ("locale", doc.settings.locale.as_setting()), ("theme", doc.settings.theme.as_setting()), ( @@ -980,6 +991,26 @@ impl TranscriptSpacingValue { } } +impl InlineDiffValue { + fn as_setting(self) -> &'static str { + match self { + Self::Full => "full", + Self::Summary => "summary", + Self::Off => "off", + } + } +} + +impl From<&str> for InlineDiffValue { + fn from(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "summary" => Self::Summary, + "off" => Self::Off, + _ => Self::Full, + } + } +} + impl WorkSurfacePlacementValue { fn as_setting(self) -> &'static str { match self { @@ -1411,6 +1442,8 @@ background_color = "#1A1B26" ); let default_mode = &schema["$defs"]["DefaultModeValue"]["enum"]; assert_eq!(default_mode, &serde_json::json!(["agent", "plan"])); + let inline_diffs = &schema["$defs"]["InlineDiffValue"]["enum"]; + assert_eq!(inline_diffs, &serde_json::json!(["full", "summary", "off"])); let locale = &schema["$defs"]["UiLocale"]["enum"]; assert_eq!( locale, diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 452028a323..6638840a68 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -58,8 +58,8 @@ use crate::tools::spec::{ }; use crate::tools::subagent::{ Mailbox, MailboxMessage, SharedSubAgentManager, SubAgentCompletion, SubAgentForkContext, - SubAgentResult, SubAgentRuntime, SubAgentStatus, SubAgentThinking, SubAgentType, - agent_worker_owner_snapshot, ensure_subagent_model_for_provider, + SubAgentManager, SubAgentResult, SubAgentRuntime, SubAgentStatus, SubAgentThinking, + SubAgentType, agent_worker_owner_snapshot, ensure_subagent_model_for_provider, new_shared_subagent_manager_with_timeout, resolve_subagent_assignment_route, }; use crate::tools::todo::{SharedTodoList, TodoListSnapshot, new_shared_todo_list}; @@ -84,6 +84,13 @@ use super::turn::{TurnContext, post_turn_snapshot, pre_turn_snapshot}; const ENGINE_OP_CHANNEL_CAPACITY: usize = 32; const GOAL_CONTINUATION_FAILURE_DETAIL_MAX_BYTES: usize = 512; +fn agent_list_event(manager: &SubAgentManager) -> Event { + Event::AgentList { + agents: manager.list(), + coordination: manager.coordination_detail_projection(None, 24), + } +} + /// Snapshot of parent state that can be passed to forked sub-agents without /// rewriting the parent transcript. #[derive(Debug, Clone, Default)] @@ -439,9 +446,8 @@ pub struct EngineConfig { /// Native tools that should stay in the model-visible catalog even when /// they are outside the small default core surface (#2076). pub tools_always_load: HashSet, - /// When true and `/usr/bin/bwrap` is present on Linux, route exec_shell - /// through bubblewrap instead of relying solely on Landlock (#2184). - #[allow(dead_code)] // Wired through ShellManager in follow-up PR + /// When true and `/usr/bin/bwrap` is executable on Linux, route exec_shell + /// through bubblewrap (#2184). pub prefer_bwrap: bool, /// Tool override and plugin configuration (`[tools]` table in config.toml). /// Applied to the per-turn tool registry after built-in tools are registered. @@ -1165,6 +1171,10 @@ impl Engine { .shell_manager .clone() .unwrap_or_else(|| new_shared_shell_manager(config.workspace.clone())); + match shell_manager.lock() { + Ok(mut manager) => manager.set_prefer_bwrap(config.prefer_bwrap), + Err(poisoned) => poisoned.into_inner().set_prefer_bwrap(config.prefer_bwrap), + } let file_read_tracker = new_shared_file_read_tracker(); // Create Flash seam manager for layered context (#159). v0.7.5 keeps // this opt-in until the prefix-cache audit proves when seam production @@ -2069,17 +2079,18 @@ impl Engine { crate::tools::subagent::SUBAGENT_LIST_CLEANUP_MIN_INTERVAL, ) }; - let agents = if due { + let event = if due { let mut manager = self.subagent_manager.write().await; manager.cleanup(Duration::from_secs(60 * 60)); - manager.list() + agent_list_event(&manager) } else { - self.subagent_manager.read().await.list() + let manager = self.subagent_manager.read().await; + agent_list_event(&manager) }; // #3802: use non-blocking send — this is a refresh event // that can safely be dropped when the channel is full. // The next drain cycle will re-request the list. - if let Err(_e) = self.tx_event.try_send(Event::AgentList { agents }) { + if let Err(_e) = self.tx_event.try_send(event) { tracing::debug!( "Event channel full; dropping ListSubAgents refresh (will retry next drain)" ); @@ -2089,14 +2100,13 @@ impl Engine { let result = { let mut manager = self.subagent_manager.write().await; match manager.cancel_agent(&agent_id) { - Ok(_) => Ok(manager.list()), + Ok(_) => Ok(agent_list_event(&manager)), Err(err) => Err(err), } }; match result { - Ok(agents) => { - if let Err(_e) = self.tx_event.try_send(Event::AgentList { agents }) - { + Ok(event) => { + if let Err(_e) = self.tx_event.try_send(event) { tracing::debug!( "Event channel full; dropping CancelSubAgent refresh" ); @@ -4762,11 +4772,20 @@ pub(super) fn exec_shell_ask_rule_decision( workspace: &Path, approval_mode: crate::tui::approval::ApprovalMode, ) -> Option { - if tool_name != "exec_shell" { + let policy_tool_name = + crate::tools::canonical_action::canonical_action_alias(tool_name, tool_input); + if policy_tool_name != "exec_shell" { return None; } let command = tool_input.get("command").and_then(Value::as_str)?; - tool_ask_rule_decision_for_context(config, tool_name, command, None, workspace, approval_mode) + tool_ask_rule_decision_for_context( + config, + policy_tool_name, + command, + None, + workspace, + approval_mode, + ) } pub(super) fn file_tool_ask_rule_decision( @@ -4776,11 +4795,13 @@ pub(super) fn file_tool_ask_rule_decision( workspace: &Path, approval_mode: crate::tui::approval::ApprovalMode, ) -> Option { - let paths = file_tool_permission_paths(tool_name, tool_input)?; + let policy_tool_name = + crate::tools::canonical_action::canonical_action_alias(tool_name, tool_input); + let paths = file_tool_permission_paths(policy_tool_name, tool_input)?; if paths.is_empty() { return tool_ask_rule_decision_for_context( config, - tool_name, + policy_tool_name, "", None, workspace, @@ -4792,7 +4813,7 @@ pub(super) fn file_tool_ask_rule_decision( for path in paths { match tool_ask_rule_decision_for_context( config, - tool_name, + policy_tool_name, "", Some(&path), workspace, diff --git a/crates/tui/src/core/engine/context.rs b/crates/tui/src/core/engine/context.rs index c18a7fbb08..7d29c9419c 100644 --- a/crates/tui/src/core/engine/context.rs +++ b/crates/tui/src/core/engine/context.rs @@ -198,7 +198,7 @@ fn compact_subagent_tool_result_for_context(tool_name: &str, raw: &str) -> Optio let mut out = String::from("[sub-agent result summarized for parent context]\n"); out.push_str( - "Child results are self-reports; verify side effects with tools like read_file or list_dir before claiming success.\n", + "Child results are self-reports; verify side effects with `File` actions like `read` or `list` before claiming success.\n", ); out.push_str("Use `handle_read` on `transcript_handle` for bounded transcript slices when the returned summary is not enough.\n"); for (idx, snapshot) in snapshots.iter().enumerate() { diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 7157d4a740..93460420d2 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -7,6 +7,10 @@ use super::turn_loop::{ }; use crate::config::ApiProvider; use crate::models::{SystemBlock, Usage}; +use crate::prompts::{ + PromptSessionContext, system_prompt_flat_text, + system_prompt_for_mode_with_context_skills_and_session, +}; use crate::test_support::{EnvVarGuard, lock_test_env}; use crate::tools::plan::{PlanItemArg, PlanSnapshot, StepStatus}; use crate::tools::spec::ToolCapability; @@ -3991,6 +3995,29 @@ fn exec_shell_ask_rule_decision_prompts_for_matching_auto_command() { ); } +#[test] +fn canonical_bash_run_honors_legacy_typed_ask_rules() { + let config = EngineConfig { + exec_policy_engine: ask_rule_engine("cargo test"), + ..EngineConfig::default() + }; + + let decision = exec_shell_ask_rule_decision( + &config, + "Bash", + &json!({"action": "run", "command": "cargo test --workspace"}), + Path::new("/repo"), + crate::tui::approval::ApprovalMode::Auto, + ); + + assert_eq!( + decision, + Some(ToolAskRuleDecision::Prompt( + "Typed ask rule 'tool=exec_shell command=cargo test' requires approval.".to_string() + )) + ); +} + #[test] fn exec_shell_ask_rule_decision_blocks_matching_never_command() { let config = EngineConfig { @@ -4056,6 +4083,29 @@ fn file_ask_rule_decision_prompts_for_matching_read_path() { ); } +#[test] +fn canonical_file_action_honors_legacy_path_ask_rules() { + let config = EngineConfig { + exec_policy_engine: file_ask_rule_engine("write_file", "src/lib.rs"), + ..EngineConfig::default() + }; + + let decision = file_tool_ask_rule_decision( + &config, + "File", + &json!({"action": "write", "path": "src/lib.rs", "content": "new\n"}), + Path::new("/repo"), + crate::tui::approval::ApprovalMode::Auto, + ); + + assert_eq!( + decision, + Some(ToolAskRuleDecision::Prompt( + "Typed ask rule 'tool=write_file path=src/lib.rs' requires approval.".to_string() + )) + ); +} + #[test] fn file_ask_rule_decision_prompts_for_absolute_workspace_path() { let config = EngineConfig { @@ -4887,39 +4937,79 @@ fn core_native_tools_stay_loaded_in_yolo_mode() { // #4625: `Bash` is the model-facing shell tool; legacy exec_shell names // are hidden compat aliases whose defer state no longer matters. assert!(!should_default_defer_tool("Bash", &always_load)); - // git_blame remains deferred (read-only git history beyond log/show/diff). + for canonical in ["File", "Git", "Run"] { + assert!(!should_default_defer_tool(canonical, &always_load)); + } + // Legacy spellings remain registered for replay but are no longer eager. assert!(should_default_defer_tool("git_blame", &always_load)); } +#[test] +fn default_active_contract_keeps_remember_and_synthetic_tool_search_eager() { + const EXPECTED_NATIVE: [&str; 9] = [ + "Bash", + "File", + "Git", + "Run", + "agent", + "remember", + "tasks", + "update_plan", + "work_update", + ]; + assert_eq!( + default_active_native_tool_names(), + EXPECTED_NATIVE.as_slice() + ); + + let always_load = HashSet::new(); + let mut catalog = build_model_tool_catalog( + EXPECTED_NATIVE.into_iter().map(api_tool).collect(), + Vec::new(), + AppMode::Agent, + &always_load, + ); + ensure_advanced_tooling(&mut catalog, AppMode::Agent, &always_load); + let active = initial_active_tools(&catalog); + let expected = EXPECTED_NATIVE + .into_iter() + .chain([TOOL_SEARCH_NAME]) + .map(str::to_string) + .collect::>(); + + assert_eq!(active, expected); + assert_eq!( + catalog + .iter() + .find(|tool| tool.name == TOOL_SEARCH_NAME) + .and_then(|tool| tool.defer_loading), + Some(false) + ); +} + #[test] fn non_yolo_mode_retains_default_defer_policy() { let always_load = HashSet::new(); - assert!(!should_default_defer_tool("Bash", &always_load)); - assert!(!should_default_defer_tool("edit_file", &always_load)); - assert!(!should_default_defer_tool("apply_patch", &always_load)); - assert!(!should_default_defer_tool("fetch_url", &always_load)); - assert!(!should_default_defer_tool("git_diff", &always_load)); - // #2654: read-only git history joins the active set. - assert!(!should_default_defer_tool("git_log", &always_load)); - assert!(!should_default_defer_tool("git_show", &always_load)); - assert!(!should_default_defer_tool("git_status", &always_load)); - assert!(!should_default_defer_tool("run_tests", &always_load)); + for canonical in ["Bash", "File", "Git", "Run"] { + assert!(!should_default_defer_tool(canonical, &always_load)); + } assert!(!should_default_defer_tool("agent", &always_load)); - assert!(!should_default_defer_tool("read_file", &always_load)); assert!(!should_default_defer_tool("remember", &always_load)); - assert!(!should_default_defer_tool( - "wait_for_dev_server", - &always_load - )); - assert!(!should_default_defer_tool("web_search", &always_load)); - assert!(!should_default_defer_tool("write_file", &always_load)); assert!(should_default_defer_tool( REQUEST_USER_INPUT_NAME, &always_load )); - assert!(should_default_defer_tool("task_shell_start", &always_load)); - assert!(should_default_defer_tool("task_shell_wait", &always_load)); - assert!(should_default_defer_tool("git_blame", &always_load)); + for legacy in [ + "read_file", + "edit_file", + "apply_patch", + "git_status", + "git_blame", + "run_tests", + "web_search", + ] { + assert!(should_default_defer_tool(legacy, &always_load)); + } } #[test] @@ -4962,10 +5052,10 @@ fn model_tool_catalog_applies_native_and_mcp_deferral() { let always_load = HashSet::new(); let catalog = build_model_tool_catalog( vec![ - api_tool("read_file"), - api_tool("write_file"), + api_tool("File"), api_tool("Bash"), - api_tool("edit_file"), + api_tool("Git"), + api_tool("Run"), api_tool("remember"), api_tool("project_map"), ], @@ -4981,10 +5071,10 @@ fn model_tool_catalog_applies_native_and_mcp_deferral() { .and_then(|tool| tool.defer_loading) }; - assert_eq!(defer_loading("read_file"), Some(false)); - assert_eq!(defer_loading("write_file"), Some(false)); + assert_eq!(defer_loading("File"), Some(false)); assert_eq!(defer_loading("Bash"), Some(false)); - assert_eq!(defer_loading("edit_file"), Some(false)); + assert_eq!(defer_loading("Git"), Some(false)); + assert_eq!(defer_loading("Run"), Some(false)); assert_eq!(defer_loading("remember"), Some(false)); assert_eq!(defer_loading("project_map"), Some(true)); assert_eq!(defer_loading("list_mcp_resources"), Some(false)); @@ -4997,13 +5087,12 @@ fn capability_compact_surface_defers_nonessential_core_tools() { let catalog = build_model_tool_catalog_with_surface( vec![ api_tool("agent"), - api_tool("grep_files"), - api_tool("read_file"), - api_tool("run_tests"), + api_tool("File"), + api_tool("Git"), + api_tool("Run"), api_tool(TOOL_SEARCH_NAME), api_tool("update_plan"), - api_tool("web_search"), - api_tool("write_file"), + api_tool("Web"), ], vec![api_tool("list_mcp_resources"), api_tool("mcp_server_write")], AppMode::Agent, @@ -5018,15 +5107,14 @@ fn capability_compact_surface_defers_nonessential_core_tools() { .and_then(|tool| tool.defer_loading) }; - assert_eq!(defer_loading("read_file"), Some(false)); - assert_eq!(defer_loading("grep_files"), Some(false)); + assert_eq!(defer_loading("File"), Some(false)); + assert_eq!(defer_loading("Git"), Some(false)); assert_eq!(defer_loading("update_plan"), Some(false)); - assert_eq!(defer_loading("write_file"), Some(false)); assert_eq!(defer_loading(TOOL_SEARCH_NAME), Some(false)); assert_eq!(defer_loading("list_mcp_resources"), Some(false)); assert_eq!(defer_loading("agent"), Some(true)); - assert_eq!(defer_loading("run_tests"), Some(true)); - assert_eq!(defer_loading("web_search"), Some(true)); + assert_eq!(defer_loading("Run"), Some(true)); + assert_eq!(defer_loading("Web"), Some(true)); assert_eq!(defer_loading("mcp_server_write"), Some(true)); } @@ -5034,18 +5122,14 @@ fn capability_compact_surface_defers_nonessential_core_tools() { fn capability_full_surface_preserves_default_core_tools() { let always_load = HashSet::new(); let catalog = build_model_tool_catalog_with_surface( - vec![ - api_tool("agent"), - api_tool("read_file"), - api_tool("run_tests"), - ], + vec![api_tool("agent"), api_tool("File"), api_tool("Run")], Vec::new(), AppMode::Agent, &always_load, crate::model_profile::ToolSurfaceBudget::Full, ); - for name in ["agent", "read_file", "run_tests"] { + for name in ["agent", "File", "Run"] { assert_eq!( catalog .iter() @@ -5061,7 +5145,7 @@ fn capability_full_surface_preserves_default_core_tools() { fn plugin_or_benchmark_tools_marked_loaded_stay_active() { let always_load = HashSet::new(); let mut catalog = build_model_tool_catalog( - vec![api_tool("KB_search"), api_tool("read_file")], + vec![api_tool("KB_search"), api_tool("File")], Vec::new(), AppMode::Agent, &always_load, @@ -5082,11 +5166,11 @@ fn plugin_or_benchmark_tools_marked_loaded_stay_active() { active.contains("KB_search"), "plugin/benchmark tools marked loaded must be callable on turn 1" ); - assert!(active.contains("read_file")); + assert!(active.contains("File")); } #[test] -fn agent_catalog_keeps_edit_file_loaded_when_fuzz_is_omitted() { +fn agent_catalog_keeps_canonical_file_tool_loaded() { let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); let registry = engine .build_turn_tool_registry_builder( @@ -5102,39 +5186,34 @@ fn agent_catalog_keeps_edit_file_loaded_when_fuzz_is_omitted() { AppMode::Agent, &always_load, ); - let edit = catalog + let file = catalog .iter() - .find(|tool| tool.name == "edit_file") - .expect("edit_file registered"); + .find(|tool| tool.name == "File") + .expect("File registered"); - assert_eq!(edit.defer_loading, Some(false)); - let required = edit.input_schema["required"] + assert_eq!(file.defer_loading, Some(false)); + let required = file.input_schema["required"] .as_array() - .expect("edit_file schema should include required fields"); - assert!(required.iter().any(|field| field.as_str() == Some("path"))); - assert!( - required - .iter() - .any(|field| field.as_str() == Some("search")) - ); + .expect("File schema should include required fields"); assert!( required .iter() - .any(|field| field.as_str() == Some("replace")) + .any(|field| field.as_str() == Some("action")) ); assert!(!required.iter().any(|field| field.as_str() == Some("fuzz"))); assert_eq!( - edit.input_schema["properties"]["fuzz"]["type"].as_str(), - Some("boolean") + file.input_schema["properties"]["fuzz"]["oneOf"][0]["type"].as_str(), + Some("boolean"), ); let active_at_batch_start = initial_active_tools(&catalog); - assert!(active_at_batch_start.contains("edit_file")); + assert!(active_at_batch_start.contains("File")); let mut hydrated_this_batch = HashSet::new(); assert!( maybe_hydrate_requested_deferred_tool( - "edit_file", + "File", &json!({ + "action": "edit", "path": "src/foo.rs", "search": "before", "replace": "after" @@ -5144,7 +5223,7 @@ fn agent_catalog_keeps_edit_file_loaded_when_fuzz_is_omitted() { &mut hydrated_this_batch, ) .is_none(), - "loaded edit_file calls without fuzz should execute instead of hydrating the schema" + "loaded File calls without fuzz should execute instead of hydrating the schema" ); assert!(hydrated_this_batch.is_empty()); } @@ -5178,9 +5257,7 @@ fn agent_catalog_advertises_and_searches_core_action_tools() { .iter() .map(|tool| tool.name.as_str()) .collect::>(); - // #4625: the model-facing shell tool is `Bash`; `exec_shell` remains a - // hidden compat alias and must NOT appear in the advertised catalog. - for tool_name in ["Bash", "write_file", "edit_file", "apply_patch"] { + for tool_name in ["Bash", "File", "Git", "Run"] { assert!( names.contains(tool_name), "{tool_name} must be advertised in Agent mode" @@ -5238,34 +5315,50 @@ fn catalog_consistency_self_check_flags_registered_core_tool_missing_from_catalo ); } -#[test] -fn tool_search_reports_known_core_action_tool_when_current_catalog_omits_it() { +fn assert_exec_shell_is_not_discoverable(match_kind: &str) { let catalog = vec![api_tool("read_file")]; let mut active = initial_active_tools(&catalog); let result = execute_tool_search( TOOL_SEARCH_NAME, - &json!({ "query": "exec_shell" }), + &json!({ "query": "exec_shell", "match": match_kind }), &catalog, &mut active, ) .expect("tool search succeeds"); assert!(!active.contains("exec_shell")); - let unavailable = result.metadata.as_ref().unwrap()["unavailable_tool_references"] + let metadata = result.metadata.as_ref().expect("search metadata"); + let references = metadata["tool_references"] + .as_array() + .expect("tool references are an array"); + assert!( + references + .iter() + .all(|reference| reference.as_str() != Some("exec_shell")), + "legacy shell alias must not surface via {match_kind}: {references:?}" + ); + let unavailable = metadata["unavailable_tool_references"] .as_array() .expect("unavailable references are an array"); assert!( - unavailable.iter().any(|reference| { - reference["tool_name"].as_str() == Some("exec_shell") - && reference["reason"] - .as_str() - .is_some_and(|reason| reason.contains("allow_shell = true")) - }), - "known-but-omitted core action tool should surface with a reason: {unavailable:?}" + unavailable + .iter() + .all(|reference| reference["tool_name"].as_str() != Some("exec_shell")), + "legacy shell alias must not surface as an unavailable fallback via {match_kind}: {unavailable:?}" ); } +#[test] +fn regex_tool_search_does_not_discover_hidden_exec_shell_alias() { + assert_exec_shell_is_not_discoverable("regex"); +} + +#[test] +fn bm25_tool_search_does_not_discover_hidden_exec_shell_alias() { + assert_exec_shell_is_not_discoverable("bm25"); +} + #[test] fn tools_always_load_overrides_mcp_deferral() { let always_load = HashSet::from(["mcp_server_write".to_string()]); @@ -5310,6 +5403,9 @@ fn print_agent_tool_catalog_metrics() { ); let registry = crate::tools::ToolRegistryBuilder::new() .with_agent_tools(true) + // Exercise the complete default-active policy. Production registers + // this opt-in tool only when user memory is enabled. + .with_remember_tool() .with_todo_tool(new_shared_todo_list()) .with_plan_tool(new_shared_plan_state()) .with_review_tool(None, DEFAULT_TEXT_MODEL.to_string()) @@ -5353,6 +5449,44 @@ fn print_agent_tool_catalog_metrics() { ); } +#[test] +#[ignore = "one-shot metric for scripts/measure-runtime-contract.py"] +#[allow(clippy::print_stdout)] +fn print_agent_runtime_contract_metrics() { + let tmp = tempdir().expect("tempdir"); + let workspace = tmp.path().to_path_buf(); + let session_context = PromptSessionContext::default(); + let prompt = system_prompt_for_mode_with_context_skills_and_session( + &workspace, + None, + None, + None, + session_context, + ); + let flat_prompt = system_prompt_flat_text(&prompt); + let prompt_bytes = flat_prompt.len(); + let prompt_tokens_est = prompt_bytes.div_ceil(4); + + // Mode runtime instructions are delivered as per-turn metadata; measure + // them separately so prompt-size work does not forget the volatile surface. + let mode_bytes = crate::prompts::AGENT_MODE.len(); + let mode_tokens_est = mode_bytes.div_ceil(4); + + println!( + "RUNTIME_CONTRACT_METRICS {}", + serde_json::json!({ + "system_prompt_bytes": prompt_bytes, + "system_prompt_tokens_est": prompt_tokens_est, + "system_prompt_blocks": match prompt { + crate::models::SystemPrompt::Blocks(blocks) => blocks.len(), + _ => 1, + }, + "agent_mode_instructions_bytes": mode_bytes, + "agent_mode_instructions_tokens_est": mode_tokens_est, + }) + ); +} + #[test] fn deferred_tool_hydration_activates_without_guard_result_for_same_turn_retry() { let mut edit = api_tool("edit_file"); @@ -5471,7 +5605,7 @@ fn deferred_edit_file_first_use_hydrates_schema_without_execution() { fn model_tool_catalog_defers_non_core_native_tools_in_yolo_mode() { let always_load = HashSet::new(); let catalog = build_model_tool_catalog( - vec![api_tool("read_file"), api_tool("project_map")], + vec![api_tool("File"), api_tool("project_map")], vec![api_tool("mcp_server_write")], AppMode::Yolo, &always_load, @@ -5484,7 +5618,7 @@ fn model_tool_catalog_defers_non_core_native_tools_in_yolo_mode() { .and_then(|tool| tool.defer_loading) }; - assert_eq!(defer_loading("read_file"), Some(false)); + assert_eq!(defer_loading("File"), Some(false)); assert_eq!(defer_loading("project_map"), Some(true)); assert_eq!(defer_loading("mcp_server_write"), Some(false)); } @@ -5629,7 +5763,7 @@ fn active_tool_list_pushes_deferred_activations_to_the_tail() { } #[test] -fn deferred_tool_preflight_loads_edit_schema_without_executing_bad_aliases() { +fn hidden_edit_alias_bypasses_deferred_model_catalog_preflight() { let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); let registry = engine .build_turn_tool_registry_builder( @@ -5647,11 +5781,11 @@ fn deferred_tool_preflight_loads_edit_schema_without_executing_bad_aliases() { ); catalog .iter_mut() - .find(|tool| tool.name == "edit_file") - .expect("edit_file registered") + .find(|tool| tool.name == "File") + .expect("File registered") .defer_loading = Some(true); let mut active = initial_active_tools(&catalog); - assert!(!active.contains("edit_file")); + assert!(!active.contains("File")); let result = preflight_requested_deferred_tool( "edit_file", @@ -5662,22 +5796,10 @@ fn deferred_tool_preflight_loads_edit_schema_without_executing_bad_aliases() { }), &catalog, &mut active, - ) - .expect("deferred edit_file should preflight"); - - assert!(active.contains("edit_file")); - assert!(result.success); - assert!(result.content.contains("Tool `edit_file` was deferred")); - assert!(result.content.contains("The tool was not executed")); - assert!(result.content.contains("path: string required")); - assert!(result.content.contains("search: string required")); - assert!(result.content.contains("replace: string required")); - assert!(result.content.contains("old_string -> search")); - assert!(result.content.contains("new_string -> replace")); - assert_eq!( - result.metadata.as_ref().unwrap()["deferred_tool_loaded"], - json!(true) ); + + assert!(result.is_none()); + assert!(!active.contains("File")); } #[test] @@ -7442,6 +7564,7 @@ fn turn_tool_registry_builder_keeps_plan_mode_read_only_for_files() { assert!(registry.contains("read_file")); assert!(registry.contains("list_dir")); + assert!(registry.contains("File")); assert!(!registry.contains("write_file")); assert!(!registry.contains("edit_file")); assert!(!registry.contains("exec_shell")); @@ -9115,7 +9238,8 @@ fn subagent_results_are_summarized_before_parent_context_insertion() { assert!(context.len() < output.content.len()); assert!(context.contains("self-report")); assert!(context.contains("verify side effects")); - assert!(context.contains("read_file") && context.contains("list_dir")); + assert!(context.contains("`File` actions like `read` or `list`")); + assert!(!context.contains("read_file") && !context.contains("list_dir")); assert!(context.contains("handle_read")); } @@ -9808,11 +9932,7 @@ fn turn_metadata_includes_plan_mode_policy() { ); assert!(text.contains("##### Mode: Plan"), "got: {text}"); assert!( - text.contains("All writes and patches are blocked"), - "got: {text}" - ); - assert!( - text.contains("Shell and code execution are unavailable"), + text.contains("All writes, patches, shell commands, and code execution"), "got: {text}" ); } @@ -11663,6 +11783,40 @@ async fn post_edit_hook_skips_unknown_tool_names() { // ── #3802: non-blocking send for ListSubAgents refresh events ───────────── +#[test] +fn agent_list_event_carries_the_typed_coordination_projection() { + use crate::tools::subagent::coord::{DecisionRecord, DecisionStatus}; + + let mut manager = SubAgentManager::new(PathBuf::from("."), 1); + manager + .record_coordination_decision(DecisionRecord { + decision_id: "decision-event".to_string(), + subject: "typed event".to_string(), + status: DecisionStatus::Accepted, + owner: "root".to_string(), + scope: Vec::new(), + constraints: Vec::new(), + evidence_handles: Vec::new(), + version: 1, + sequence: 0, + }) + .expect("record decision"); + + let Event::AgentList { + agents, + coordination, + } = agent_list_event(&manager) + else { + panic!("expected AgentList event"); + }; + assert!(agents.is_empty()); + assert_eq!(coordination.decisions.len(), 1); + assert_eq!(coordination.decisions[0].decision_id, "decision-event"); + assert_eq!(coordination.decisions[0].status, DecisionStatus::Accepted); + assert!(coordination.bounded); + assert_eq!(coordination.limit, 24); +} + #[test] fn engine_handle_try_send_does_not_block_when_op_channel_is_full() { use tokio::sync::mpsc; @@ -11768,7 +11922,11 @@ async fn list_subagents_event_try_send_does_not_block_when_event_channel_full() // Reproduce the handler pattern: try_send an AgentList event. // This must return Err immediately — the handler should never hang. let agents = vec![]; - let result = tx_event.try_send(Event::AgentList { agents }); + let result = tx_event.try_send(Event::AgentList { + agents, + coordination: crate::tools::subagent::SubAgentManager::new(PathBuf::from("."), 1) + .coordination_detail_projection(None, 24), + }); assert!( result.is_err(), "try_send should fail when event channel is full (backpressure avoided)" diff --git a/crates/tui/src/core/engine/tool_catalog.rs b/crates/tui/src/core/engine/tool_catalog.rs index efb7b03282..5b6fb78bd8 100644 --- a/crates/tui/src/core/engine/tool_catalog.rs +++ b/crates/tui/src/core/engine/tool_catalog.rs @@ -43,30 +43,17 @@ pub(super) const DEFAULT_ACTIVE_NATIVE_TOOLS: &[&str] = &[ // #4625: the model-facing shell tool is `Bash`; legacy `exec_shell*` // names are hidden compat aliases and must not be default-active. "Bash", + "File", + "Git", + "Run", "agent", - "apply_patch", - "edit_file", - "fetch_url", - "file_search", - "git_diff", - "git_log", - "git_show", - "git_status", - "grep_files", - "list_dir", - "read_file", "remember", - "run_tests", - "run_verifiers", // Piagent phase B: the model-facing durable-task tool is `tasks`; the // legacy `task_create`/`task_list`/`task_read` names it replaces are // hidden compat aliases and must not be default-active. "tasks", "update_plan", - "wait_for_dev_server", - "web_search", "work_update", - "write_file", ]; const CORE_ACTION_TOOL_FALLBACKS: &[CoreActionToolFallback] = &[ @@ -76,24 +63,9 @@ const CORE_ACTION_TOOL_FALLBACKS: &[CoreActionToolFallback] = &[ unavailable_reason: "Not present in the current model-visible catalog. Interactive Agent sessions expose shell by default unless allow_shell = false; noninteractive and durable profiles require allow_shell = true. Plan mode hides shell, and command tool allow/deny gates can also block it.", }, CoreActionToolFallback { - name: "exec_shell", - description: "Run shell commands in the workspace.", - unavailable_reason: "Not present in the current model-visible catalog. Interactive Agent sessions expose shell by default unless allow_shell = false; noninteractive and durable profiles require allow_shell = true. Plan mode hides shell, and command tool allow/deny gates can also block it.", - }, - CoreActionToolFallback { - name: "write_file", - description: "Create or overwrite files in the workspace.", - unavailable_reason: "Not present in the current model-visible catalog. File writes require Act mode and no command tool allow/deny gate blocking write_file.", - }, - CoreActionToolFallback { - name: "edit_file", - description: "Edit existing files by replacing text.", - unavailable_reason: "Not present in the current model-visible catalog. File edits require Act mode and no command tool allow/deny gate blocking edit_file.", - }, - CoreActionToolFallback { - name: "apply_patch", - description: "Apply a patch to one or more workspace files.", - unavailable_reason: "Not present in the current model-visible catalog. Patches require Act mode, the apply_patch feature, and no command tool allow/deny gate blocking apply_patch.", + name: "File", + description: "Read, search, and modify workspace files.", + unavailable_reason: "Not present in the current model-visible catalog. File reads are available in Plan and Agent modes; write and edit actions require an executable mode, while patch also requires the apply_patch feature.", }, ]; @@ -253,10 +225,7 @@ fn apply_tool_surface_budget( if always_load.contains(&tool.name) { continue; } - if matches!( - tool.name.as_str(), - "agent" | "run_tests" | "run_verifiers" | "tasks" | "web_search" - ) { + if matches!(tool.name.as_str(), "agent" | "Run" | "tasks" | "Web") { tool.defer_loading = Some(true); } } diff --git a/crates/tui/src/core/engine/tool_execution.rs b/crates/tui/src/core/engine/tool_execution.rs index 6bcc2075be..ee293f8da8 100644 --- a/crates/tui/src/core/engine/tool_execution.rs +++ b/crates/tui/src/core/engine/tool_execution.rs @@ -403,6 +403,30 @@ impl Engine { // cancelled interactive tool). let _terminal = InteractiveTerminalGuard::engage(tx_event, interactive).await; + let tool_authority = context_override + .as_ref() + .and_then(|context| context.tool_authority.as_ref()) + .or_else(|| registry.and_then(|registry| registry.context().tool_authority.as_ref())); + if let Some(authority) = tool_authority { + if McpPool::is_mcp_tool(&tool_name) + && !super::dispatch::mcp_tool_is_read_only(&tool_name) + { + return Err(ToolError::permission_denied(format!( + "worker '{}' cannot run mutating MCP tool {tool_name}: it has no authorized file target", + authority.owner + ))); + } + if matches!( + tool_name.as_str(), + CODE_EXECUTION_TOOL_NAME | JS_EXECUTION_TOOL_NAME + ) { + return Err(ToolError::permission_denied(format!( + "worker '{}' cannot run {tool_name}: arbitrary code execution is outside its machine-readable authority envelope", + authority.owner + ))); + } + } + let outcome = if McpPool::is_mcp_tool(&tool_name) { if let Some(pool) = mcp_pool { Engine::execute_mcp_tool_with_pool(pool, &tool_name, tool_input).await diff --git a/crates/tui/src/core/engine/tool_preparation.rs b/crates/tui/src/core/engine/tool_preparation.rs index af374013e2..d59887a4bc 100644 --- a/crates/tui/src/core/engine/tool_preparation.rs +++ b/crates/tui/src/core/engine/tool_preparation.rs @@ -33,6 +33,15 @@ pub(super) fn prepare_tool_call( ) -> Result { if McpPool::is_mcp_tool(name) { let read_only = mcp_tool_is_read_only(name); + if !read_only + && let Some(authority) = + registry.and_then(|registry| registry.context().tool_authority.as_ref()) + { + return Err(ToolError::permission_denied(format!( + "worker '{}' cannot run mutating MCP tool {name}: it has no bounded file target under the machine-readable authority envelope", + authority.owner + ))); + } return Ok(PreparedToolPolicy { call: PreparedToolCall { name: name.to_string(), @@ -64,6 +73,7 @@ pub(super) fn prepare_tool_call( } if name == CODE_EXECUTION_TOOL_NAME { + reject_unbounded_execution_under_authority(name, registry)?; return Ok(conservative_execution_policy( name, input, @@ -73,6 +83,7 @@ pub(super) fn prepare_tool_call( } if name == JS_EXECUTION_TOOL_NAME { + reject_unbounded_execution_under_authority(name, registry)?; return Ok(conservative_execution_policy( name, input, @@ -102,6 +113,20 @@ pub(super) fn prepare_tool_call( ))) } +fn reject_unbounded_execution_under_authority( + name: &str, + registry: Option<&ToolRegistry>, +) -> Result<(), ToolError> { + let Some(authority) = registry.and_then(|registry| registry.context().tool_authority.as_ref()) + else { + return Ok(()); + }; + Err(ToolError::permission_denied(format!( + "worker '{}' cannot run {name}: arbitrary code execution cannot prove a bounded file target under the machine-readable authority envelope", + authority.owner + ))) +} + /// Re-run preparation from the rewritten input rather than patching any /// previously derived field. pub(super) fn reprepare_tool_call_after_hook( diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 0b3f4b3bf3..404c114966 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -3519,8 +3519,9 @@ fn resolve_tool_definition<'a>( .iter() .find(|def| def.name.as_str() == tool_name.as_str()); - // Resolve hallucinated tool names before policy gates run, so aliases like - // ReadFile are checked against the canonical registered tool name. + // Resolve hallucinated tool names before policy gates run. Hidden legacy + // handlers keep their executable name, while policy uses the canonical + // model-facing family definition. if tool_def.is_none() && let Some(registry) = tool_registry && let Some(canonical) = registry.resolve(tool_name.as_str()) @@ -3528,7 +3529,15 @@ fn resolve_tool_definition<'a>( crate::logging::info(format!( "Resolved hallucinated tool name '{tool_name}' -> '{canonical}'" )); - tool_def = tool_catalog.iter().find(|d| d.name == canonical); + let catalog_name = match canonical { + "read_file" | "write_file" | "edit_file" | "list_dir" | "grep_files" + | "file_search" | "apply_patch" => "File", + "git_status" | "git_diff" | "git_log" | "git_show" | "git_blame" => "Git", + "run_tests" | "run_verifiers" => "Run", + "web_search" | "fetch_url" | "wait_for_dev_server" => "Web", + _ => canonical, + }; + tool_def = tool_catalog.iter().find(|d| d.name == catalog_name); if tool_def.is_some() { *tool_name = canonical.to_string(); } diff --git a/crates/tui/src/core/events.rs b/crates/tui/src/core/events.rs index cde4789c33..84ba5fb022 100644 --- a/crates/tui/src/core/events.rs +++ b/crates/tui/src/core/events.rs @@ -13,7 +13,7 @@ use crate::error_taxonomy::ErrorEnvelope; use crate::models::{Message, SystemPrompt, Tool, Usage}; use crate::tools::goal::GoalSnapshot; use crate::tools::spec::{ToolError, ToolResult}; -use crate::tools::subagent::SubAgentResult; +use crate::tools::subagent::{AgentWorkerStatus, CoordinationDetailProjection, SubAgentResult}; use crate::tools::user_input::UserInputRequest; /// Final status for a turn. @@ -39,6 +39,43 @@ pub struct TurnRoute { pub auto_model: bool, } +/// Structured lifecycle metadata paired with a human-readable +/// [`Event::AgentProgress`] message. +/// +/// Producers own this classification. UI consumers may bound the display +/// message, but must never recover lifecycle state by parsing it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentProgressEventMeta { + pub worker_status: AgentWorkerStatus, + pub step: Option, + /// Canonical action/tool name. Presentation aliases are applied by the UI + /// when it creates the bounded current-activity projection. + pub tool_name: Option, +} + +impl AgentProgressEventMeta { + #[must_use] + pub const fn new(worker_status: AgentWorkerStatus) -> Self { + Self { + worker_status, + step: None, + tool_name: None, + } + } + + #[must_use] + pub const fn with_step(mut self, step: u32) -> Self { + self.step = Some(step); + self + } + + #[must_use] + pub fn with_tool(mut self, tool_name: impl Into) -> Self { + self.tool_name = Some(tool_name.into()); + self + } +} + /// Events emitted by the engine to update the UI. #[derive(Debug, Clone)] pub enum Event { @@ -195,6 +232,7 @@ pub enum Event { AgentProgress { id: String, status: String, + activity: AgentProgressEventMeta, parent_run_id: Option, spawn_depth: u32, }, @@ -202,8 +240,12 @@ pub enum Event { /// Sub-agent completed AgentComplete { id: String, result: String }, - /// Sub-agent listing - AgentList { agents: Vec }, + /// Sub-agent listing plus the same bounded typed coordination projection + /// used by machine-readable `agents/coordinate inspect`. + AgentList { + agents: Vec, + coordination: CoordinationDetailProjection, + }, /// Structured sub-agent mailbox envelope (issue #128). Carries the /// monotonic seq + the typed `MailboxMessage` so the UI can route each diff --git a/crates/tui/src/fleet/executor.rs b/crates/tui/src/fleet/executor.rs index 182e786a7f..c679051ae7 100644 --- a/crates/tui/src/fleet/executor.rs +++ b/crates/tui/src/fleet/executor.rs @@ -30,6 +30,8 @@ use super::worker_runtime::{ fleet_task_prompt, fleet_task_prompt_with_profiles, fleet_worker_launch_reasoning_effort, fleet_worker_launch_route, }; +use crate::tools::spec::{ToolAuthorityEnvelope, ToolMutationAuthority}; +use crate::tools::subagent::AgentWorkerSpec; /// Resolve the executable used for Fleet worker subprocesses. /// @@ -48,8 +50,10 @@ pub fn configured_codewhale_binary() -> String { /// `--auto` is always passed: a headless worker has no human to approve tool /// calls, so it runs with full (policy-gated) tool access. `--output-format /// stream-json` makes the worker emit the NDJSON event stream this module -/// parses. Fleet recursion depth is inherited from the worker's own config -/// (`[fleet.exec] max_spawn_depth`, default [`codewhale_config::DEFAULT_SPAWN_DEPTH`]). +/// parses. A worker launched with the v0.9.1 machine-readable outer authority +/// cap is a truthful leaf (`max_spawn_depth = 0`): the nested-agent surface is +/// disabled until authority scopes can be intersected across +/// process/workspace boundaries. /// /// Secrets are NEVER placed on the argv: provider credentials are resolved by /// the worker process from its own config/keyring exactly like an interactive @@ -71,6 +75,7 @@ pub fn build_worker_exec_command( model, None, None, + None, ) } @@ -101,9 +106,75 @@ pub fn build_worker_exec_command_with_profiles( Some(worker_model.as_str()), worker_provider.as_deref(), worker_reasoning_effort.as_deref(), + None, )) } +/// Build the exact Fleet subprocess command from the coordination-registered +/// worker spec. Unlike the compatibility helpers above, production dispatch +/// uses the projected objective and carries a machine-readable outer authority +/// envelope into the child process. +pub fn build_worker_exec_command_with_launch_spec( + codewhale_binary: &str, + task_spec: &FleetTaskSpec, + launch_spec: &AgentWorkerSpec, + exec_config: &FleetExecConfig, + model: Option<&str>, + agent_profiles: &[AgentProfile], +) -> Result { + let (worker_model, worker_provider) = + fleet_worker_launch_route(task_spec, agent_profiles, model.unwrap_or_default()); + let worker_reasoning_effort = fleet_worker_launch_reasoning_effort(task_spec, agent_profiles); + let authority = authority_envelope_for_worker(launch_spec, task_spec)?; + Ok(build_worker_exec_command_from_prompt( + codewhale_binary, + launch_spec.objective.clone(), + exec_config, + Some(worker_model.as_str()), + worker_provider.as_deref(), + worker_reasoning_effort.as_deref(), + Some(&authority), + )) +} + +pub(crate) fn authority_envelope_for_worker( + spec: &AgentWorkerSpec, + task_spec: &FleetTaskSpec, +) -> Result { + let (authority, writable_roots, writable_files, coordination_contracts) = + if spec.runtime_profile.permissions.write { + let manifest = spec.launch_manifest.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "write-capable Fleet worker '{}' has no launch manifest", + spec.worker_id + ) + })?; + ( + ToolMutationAuthority::ScopedWrite, + super::worker_runtime::fleet_runtime_write_roots(task_spec)?, + manifest.writable_files.clone(), + manifest.coordination_contracts.clone(), + ) + } else { + ( + ToolMutationAuthority::ReadOnly, + Vec::new(), + Vec::new(), + Vec::new(), + ) + }; + ToolAuthorityEnvelope { + schema_version: 1, + owner: spec.worker_id.clone(), + authority, + writable_roots, + writable_files, + coordination_contracts, + } + .normalized() + .map_err(anyhow::Error::msg) +} + fn build_worker_exec_command_from_prompt( codewhale_binary: &str, task_prompt: String, @@ -111,6 +182,7 @@ fn build_worker_exec_command_from_prompt( model: Option<&str>, provider: Option<&str>, reasoning_effort: Option<&str>, + authority: Option<&ToolAuthorityEnvelope>, ) -> FleetWorkerCommand { let mut args: Vec = Vec::new(); @@ -164,6 +236,14 @@ fn build_worker_exec_command_from_prompt( args.push(exec_config.append_system_prompt.clone()); } + if let Some(authority) = authority { + args.push("--tool-authority-json".to_string()); + args.push( + serde_json::to_string(authority) + .expect("validated Fleet tool authority envelope must serialize"), + ); + } + // The composed task prompt is the final positional argument. args.push(task_prompt); @@ -667,8 +747,12 @@ mod tests { FleetDelegationHints, FleetLoadout, FleetProfile, FleetProfilePermissions, FleetRole, FleetSlot, }; - use codewhale_protocol::fleet::{FleetTaskSpec, FleetTaskWorkerProfile}; + use codewhale_protocol::fleet::{ + FleetHostSpec, FleetTaskSpec, FleetTaskWorkerProfile, FleetWorkerSpec, + FleetWorkspaceRequirements, + }; use std::collections::BTreeMap; + use tempfile::TempDir; fn task(instructions: &str) -> FleetTaskSpec { FleetTaskSpec { @@ -725,6 +809,29 @@ mod tests { } } + fn launch_spec(task: &FleetTaskSpec, workspace: &std::path::Path) -> AgentWorkerSpec { + let worker = FleetWorkerSpec { + id: "worker-1".to_string(), + name: "Worker 1".to_string(), + host: FleetHostSpec::Local, + trust_level: None, + labels: BTreeMap::new(), + capabilities: Vec::new(), + max_concurrent_tasks: Some(1), + }; + crate::fleet::worker_runtime::fleet_task_to_worker_spec_with_profiles( + "worker-1", + "run-1", + task, + &worker, + "auto", + workspace, + &[], + None, + ) + .unwrap() + } + fn track_test_stream( executor: &mut FleetExecutor, worker_id: &str, @@ -824,6 +931,72 @@ mod tests { assert!(prompt.contains("Focus on defects, regressions, and missing tests.")); } + #[test] + fn launch_spec_command_uses_projected_prompt_and_read_only_authority() { + let tmp = TempDir::new().unwrap(); + let task = task("inspect the release candidate"); + let launch_spec = launch_spec(&task, tmp.path()); + + let cmd = build_worker_exec_command_with_launch_spec( + "codewhale", + &task, + &launch_spec, + &FleetExecConfig::default(), + None, + &[], + ) + .unwrap(); + + assert_eq!(cmd.args.last(), Some(&launch_spec.objective)); + let authority_index = cmd + .args + .iter() + .position(|arg| arg == "--tool-authority-json") + .expect("launch command must carry machine-readable authority"); + let authority = ToolAuthorityEnvelope::from_json(&cmd.args[authority_index + 1]).unwrap(); + assert_eq!(authority.owner, "worker-1"); + assert_eq!(authority.authority, ToolMutationAuthority::ReadOnly); + assert!(authority.writable_roots.is_empty()); + assert!(authority.writable_files.is_empty()); + assert!(authority.coordination_contracts.is_empty()); + } + + #[test] + fn launch_spec_command_preserves_exact_write_scope() { + let tmp = TempDir::new().unwrap(); + let mut task = task("edit the bounded source tree"); + let worker = task.worker.as_mut().unwrap(); + worker.role = Some("implementer".to_string()); + worker.tool_profile = None; + task.workspace = Some(FleetWorkspaceRequirements { + writable_paths: vec![std::path::PathBuf::from("src")], + ..FleetWorkspaceRequirements::default() + }); + let launch_spec = launch_spec(&task, tmp.path()); + + let cmd = build_worker_exec_command_with_launch_spec( + "codewhale", + &task, + &launch_spec, + &FleetExecConfig::default(), + None, + &[], + ) + .unwrap(); + let authority_index = cmd + .args + .iter() + .position(|arg| arg == "--tool-authority-json") + .expect("launch command must carry machine-readable authority"); + let authority = ToolAuthorityEnvelope::from_json(&cmd.args[authority_index + 1]).unwrap(); + + assert_eq!(authority.authority, ToolMutationAuthority::ScopedWrite); + assert_eq!(authority.writable_roots, ["src"]); + assert!(authority.writable_files.is_empty()); + assert!(authority.coordination_contracts.is_empty()); + assert_eq!(cmd.args.last(), Some(&launch_spec.objective)); + } + /// #4093 AC #4 at the LAUNCH boundary (not just the receipt): a worker whose /// profile pins a DIFFERENT provider+model than the parent session must /// actually launch on the profile's route and saved reasoning tier. The diff --git a/crates/tui/src/fleet/ledger.rs b/crates/tui/src/fleet/ledger.rs index a429dbbf98..8befacd1d5 100644 --- a/crates/tui/src/fleet/ledger.rs +++ b/crates/tui/src/fleet/ledger.rs @@ -200,6 +200,10 @@ pub struct FleetLedger { /// The ledger itself is replaced during compaction, so locking /// `fleet.jsonl` would leave appenders holding a lock on the old inode. lock_path: PathBuf, + #[cfg(test)] + fail_start_append_after_callback: std::sync::atomic::AtomicBool, + #[cfg(test)] + fail_restart_append_after_callback: std::sync::atomic::AtomicBool, } impl FleetLedger { @@ -225,6 +229,10 @@ impl FleetLedger { Ok(Self { ledger_path, lock_path, + #[cfg(test)] + fail_start_append_after_callback: std::sync::atomic::AtomicBool::new(false), + #[cfg(test)] + fail_restart_append_after_callback: std::sync::atomic::AtomicBool::new(false), }) } @@ -232,6 +240,18 @@ impl FleetLedger { &self.ledger_path } + #[cfg(test)] + pub(crate) fn fail_next_start_append_after_callback(&self) { + self.fail_start_append_after_callback + .store(true, std::sync::atomic::Ordering::SeqCst); + } + + #[cfg(test)] + pub(crate) fn fail_next_restart_append_after_callback(&self) { + self.fail_restart_append_after_callback + .store(true, std::sync::atomic::Ordering::SeqCst); + } + fn open_lock_file(&self) -> Result { OpenOptions::new() .create(true) @@ -396,16 +416,16 @@ impl FleetLedger { max_active_for_run, Vec::new(), false, - || {}, + || Ok(()), ) } /// Atomically claim a queued task and publish its initial lifecycle. /// - /// The callback runs after the durable transaction while the same ledger - /// lock is still held. Fleet uses it for its in-memory runtime projection, - /// so cancellation cannot land between the durable claim and projection - /// registration and leave a worker that was never actually started. + /// The callback runs before the durable transaction while the same ledger + /// lock is held. Fleet uses it to synchronously persist the exact launch + /// projection; a callback failure leaves the task Enqueued. Callers must + /// compensate the projection if the subsequent ledger append fails. #[allow(clippy::too_many_arguments)] pub fn start_task_if_enqueued( &self, @@ -416,7 +436,7 @@ impl FleetLedger { lease_expires_at: Option<&str>, max_active_for_run: Option, initial_events: Vec, - on_started: impl FnOnce(), + on_started: impl FnOnce() -> Result<()>, ) -> Result { self.lease_task_if_enqueued_with_events( run_id, @@ -442,7 +462,7 @@ impl FleetLedger { max_active_for_run: Option, initial_events: Vec, record_heartbeat: bool, - on_started: impl FnOnce(), + on_started: impl FnOnce() -> Result<()>, ) -> Result { self.with_write_lock(move || { let state = self.rebuild_state_unlocked()?; @@ -504,8 +524,18 @@ impl FleetLedger { memory_mb: None, }); } + // Persist the exact launch/coordination projection before making + // the lease visible. A failed callback leaves the task Enqueued + // with attempt 0 rather than a Running lease that cannot launch. + on_started()?; + #[cfg(test)] + if self + .fail_start_append_after_callback + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + bail!("forced Fleet ledger append failure after coordination callback"); + } self.append_records_unlocked(&records)?; - on_started(); Ok(true) }) } @@ -871,7 +901,42 @@ impl FleetLedger { lease_expires_at: Option<&str>, restart_count: u32, ) -> Result { - self.with_write_lock(|| { + self.restart_task_if_unchanged_with_callback( + run_id, + task_id, + worker_id, + expected_status, + expected_attempts, + expected_latest_seq, + expected_heartbeat_at, + leased_at, + lease_expires_at, + restart_count, + || Ok(()), + ) + } + + /// Restart the observed attempt while persisting its replacement launch + /// identity before the new lease becomes visible. This mirrors + /// `start_task_if_enqueued`: a failed callback leaves the old attempt + /// untouched, so no leased generation can exist without a matching + /// durable launch manifest. + #[allow(clippy::too_many_arguments)] + pub fn restart_task_if_unchanged_with_callback( + &self, + run_id: &FleetRunId, + task_id: &str, + worker_id: &str, + expected_status: FleetTaskLedgerStatus, + expected_attempts: u32, + expected_latest_seq: u64, + expected_heartbeat_at: Option<&str>, + leased_at: &str, + lease_expires_at: Option<&str>, + restart_count: u32, + on_restarted: impl FnOnce() -> Result<()>, + ) -> Result { + self.with_write_lock(move || { let state = self.rebuild_state_unlocked()?; let key = task_key(&run_id.0, task_id); let Some(task) = state.tasks.get(&key) else { @@ -917,6 +982,14 @@ impl FleetLedger { payload: FleetWorkerEventPayload::Running, extra: BTreeMap::new(), }; + on_restarted()?; + #[cfg(test)] + if self + .fail_restart_append_after_callback + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + bail!("forced Fleet restart ledger append failure after coordination callback"); + } self.append_records_unlocked(&[ FleetLedgerRecord::TaskLeased { run_id: run_id.clone(), @@ -1822,6 +1895,7 @@ fn sanitize_run_for_ledger(run: &FleetRun) -> FleetRun { #[cfg(test)] mod tests { use super::*; + use anyhow::anyhow; use std::sync::{ Arc, Barrier, atomic::{AtomicBool, Ordering}, @@ -2035,7 +2109,7 @@ mod tests { }), FleetWorkerEventPayload::Running, ], - || {}, + || Ok(()), ) .unwrap() }); @@ -2128,13 +2202,47 @@ mod tests { None, Some(1), vec![FleetWorkerEventPayload::Running], - || callback_ran.store(true, Ordering::SeqCst), + || { + callback_ran.store(true, Ordering::SeqCst); + Ok(()) + }, ) .unwrap() ); assert!(!callback_ran.load(Ordering::SeqCst)); } + #[test] + fn failed_start_projection_leaves_task_unleased_and_without_events() { + let tmp = TempDir::new().unwrap(); + let ledger = FleetLedger::open(tmp.path()).unwrap(); + ledger.create_run(&sample_run("run-1")).unwrap(); + ledger.enqueue(sample_entry("run-1", "task-a")).unwrap(); + + let error = ledger + .start_task_if_enqueued( + &FleetRunId::from("run-1"), + "task-a", + "worker-1", + "2026-06-12T17:02:00Z", + None, + Some(1), + vec![FleetWorkerEventPayload::Running], + || Err(anyhow!("forced coordination persistence failure")), + ) + .expect_err("projection failure must abort the lease"); + assert!(error.to_string().contains("forced coordination")); + + let state = ledger.rebuild_state().unwrap(); + let task = &state.tasks["run-1:task-a"]; + assert_eq!(task.status, FleetTaskLedgerStatus::Enqueued); + assert_eq!(task.entry.attempts, 0); + assert!(task.leased_to.is_none()); + assert!(state.latest_events.is_empty()); + assert!(state.latest_seq.is_empty()); + assert!(!state.heartbeats.contains_key("worker-1")); + } + #[test] fn concurrent_restarts_compare_and_set_one_new_attempt() { let tmp = TempDir::new().unwrap(); @@ -2151,7 +2259,7 @@ mod tests { None, Some(1), vec![FleetWorkerEventPayload::Running], - || {}, + || Ok(()), ) .unwrap() ); @@ -2213,7 +2321,7 @@ mod tests { None, Some(1), vec![FleetWorkerEventPayload::Running], - || {}, + || Ok(()), ) .unwrap() ); @@ -2288,7 +2396,7 @@ mod tests { None, Some(1), vec![FleetWorkerEventPayload::Running], - || {}, + || Ok(()), ) .unwrap() ); @@ -2441,7 +2549,7 @@ mod tests { None, Some(1), vec![FleetWorkerEventPayload::Running], - || {}, + || Ok(()), ) .unwrap() ); @@ -2499,7 +2607,7 @@ mod tests { None, Some(1), vec![FleetWorkerEventPayload::Running], - || {}, + || Ok(()), ) .unwrap() ); @@ -2903,7 +3011,7 @@ mod tests { None, Some(1), vec![FleetWorkerEventPayload::Running], - || {}, + || Ok(()), ) .unwrap() ); @@ -2976,7 +3084,7 @@ mod tests { None, Some(1), vec![FleetWorkerEventPayload::Running], - || {}, + || Ok(()), ) .unwrap() ); diff --git a/crates/tui/src/fleet/manager.rs b/crates/tui/src/fleet/manager.rs index 482f1cc59a..b70bdfc66e 100644 --- a/crates/tui/src/fleet/manager.rs +++ b/crates/tui/src/fleet/manager.rs @@ -20,7 +20,7 @@ use uuid::Uuid; use super::executor::{ FleetExecutor, FleetExecutorAttempt, FleetWorkerReportedRoute, FleetWorkerTerminalEvent, - build_worker_exec_command_with_profiles, + authority_envelope_for_worker, build_worker_exec_command_with_launch_spec, }; use super::host::FleetHostErrorKind; use super::ledger::{FleetLedger, FleetLedgerState, FleetTaskLedgerStatus, FleetTaskState}; @@ -31,7 +31,7 @@ use super::task_spec::{ }; use super::worker_runtime; use crate::config::Config; -use crate::tools::subagent::SharedSubAgentManager; +use crate::tools::subagent::{AgentWorkerSpec, SharedSubAgentManager, SubAgentManager}; const DEFAULT_STALE_AFTER_SECONDS: u64 = 300; @@ -362,6 +362,7 @@ impl FleetManager { max_workers: usize, unavailable_workers: &BTreeSet, ) -> Result { + self.reconcile_coordination_worker_statuses()?; let max_workers = max_workers.clamp(1, 128); let mut report = FleetTickReport::default(); let state = self.ledger.rebuild_state()?; @@ -408,6 +409,57 @@ impl FleetManager { Ok(report) } + fn reconcile_coordination_worker_statuses(&self) -> Result<()> { + let Some(manager) = self.sub_agent_manager.as_ref() else { + return Ok(()); + }; + let Ok(mut guard) = manager.try_write() else { + // The next scheduler tick retries before leasing more work. + return Ok(()); + }; + let state = self.ledger.rebuild_state()?; + for record in guard.list_worker_records() { + let current = state + .tasks + .values() + .filter(|task| task.entry.run_id.0 == record.spec.run_id) + .filter(|task| task.leased_to.as_deref() == Some(record.spec.worker_id.as_str())) + .max_by_key(|task| task.lifecycle_seq); + let Some(current) = current else { + continue; + }; + let (status, status_label) = match current.status { + FleetTaskLedgerStatus::Enqueued => { + (crate::tools::subagent::AgentWorkerStatus::Queued, "queued") + } + FleetTaskLedgerStatus::Leased => ( + crate::tools::subagent::AgentWorkerStatus::Running, + "running", + ), + FleetTaskLedgerStatus::Completed => ( + crate::tools::subagent::AgentWorkerStatus::Completed, + "completed", + ), + FleetTaskLedgerStatus::Failed => { + (crate::tools::subagent::AgentWorkerStatus::Failed, "failed") + } + FleetTaskLedgerStatus::Cancelled => ( + crate::tools::subagent::AgentWorkerStatus::Cancelled, + "cancelled", + ), + }; + guard.project_external_worker_status( + &record.spec.worker_id, + status, + Some(format!( + "Fleet task {} is {}", + current.entry.task_id, status_label + )), + ); + } + Ok(()) + } + pub fn status(&self) -> Result { let state = self.ledger.rebuild_state()?; Ok(self.status_from_state(None, &state)) @@ -454,7 +506,29 @@ impl FleetManager { }; let mut scheduler = FleetScheduler::open(&self.workspace, policy)?; scheduler.set_now(now); - let report = scheduler.resume_run(run_id)?; + // Keep the lock order coordination -> ledger. A restart generation is + // durably prepared by the callback before the scheduler publishes the + // replacement lease, and an exact one-generation-ahead preparation is + // safe to consume after a process crash. + let mut coordination_guard = match &self.sub_agent_manager { + Some(manager) => Some( + manager + .try_write() + .map_err(|_| anyhow!("Fleet coordination state is busy; retry resume"))?, + ), + None => None, + }; + let report = scheduler.resume_run_with_restart_callback( + run_id, + |state, task, task_spec, worker_id| { + if let Some(guard) = coordination_guard.as_mut() { + self.prepare_registered_restart_generation( + guard, state, task, task_spec, worker_id, + )?; + } + Ok(()) + }, + )?; let status = self.run_status(run_id)?; Ok(FleetResumeReport { run_id: run_id.clone(), @@ -775,6 +849,15 @@ impl FleetManager { .max_workers .unwrap_or_else(|| run.worker_specs.len().max(1)) .clamp(1, 128); + let mut coordination_guard = match &self.sub_agent_manager { + Some(manager) => { + let Ok(guard) = manager.try_write() else { + bail!("Fleet worker {worker_id} coordination state is busy; retry restart"); + }; + Some(guard) + } + None => None, + }; let now = timestamp(); let latest_seq = state .latest_seq @@ -789,7 +872,7 @@ impl FleetManager { .heartbeats .get(worker_id) .map(|heartbeat| heartbeat.timestamp.as_str()); - if !self.ledger.restart_task_if_unchanged( + let restarted = self.ledger.restart_task_if_unchanged_with_callback( &task.entry.run_id, &task.entry.task_id, worker_id, @@ -800,7 +883,23 @@ impl FleetManager { &now, None, task.entry.attempts, - )? { + || { + if let Some(guard) = coordination_guard.as_mut() { + let task_spec = run + .task_specs + .iter() + .find(|spec| spec.id == task.entry.task_id) + .ok_or_else(|| { + anyhow!("fleet task {} does not exist", task.entry.task_id) + })?; + self.prepare_registered_restart_generation( + guard, &state, task, task_spec, worker_id, + )?; + } + Ok(()) + }, + )?; + if !restarted { bail!("worker {worker_id} task changed before it could be restarted"); } self.ledger @@ -812,6 +911,89 @@ impl FleetManager { }) } + /// Prepare or consume the exact durable launch generation for one Fleet + /// retry. The persisted one-generation-ahead record is the prepare marker: + /// it is not launchable while the ledger remains on the old attempt, but a + /// retry after a crash may validate and consume it idempotently. + fn prepare_registered_restart_generation( + &self, + coordination: &mut SubAgentManager, + state: &FleetLedgerState, + task: &FleetTaskState, + task_spec: &FleetTaskSpec, + worker_id: &str, + ) -> Result<()> { + let run = state + .runs + .get(&task.entry.run_id.0) + .ok_or_else(|| anyhow!("fleet run {} does not exist", task.entry.run_id.0))?; + let worker_spec = run + .worker_specs + .iter() + .find(|worker| worker.id == worker_id) + .cloned() + .unwrap_or_else(|| default_local_worker(worker_id)); + let cwd = resolve_task_cwd(&self.workspace, task_spec)?; + validate_task_cwd_for_host(&self.workspace, &worker_spec.host, &cwd)?; + let roster = self.agent_roster(); + let expected_current = bind_fleet_launch_attempt( + worker_runtime::apply_exec_hardening( + worker_runtime::fleet_task_to_worker_spec_with_profiles( + worker_id, + &task.entry.run_id.0, + task_spec, + &worker_spec, + self.run_model(), + &cwd, + roster.members(), + None, + )?, + &self.exec_config, + ), + task.entry.attempts, + ); + let record = coordination + .get_worker_record(worker_id) + .ok_or_else(|| anyhow!("Fleet worker {worker_id} has no registered launch spec"))?; + let current_generation = task.entry.attempts.max(1); + let next_generation = current_generation + .checked_add(1) + .ok_or_else(|| anyhow!("Fleet worker {worker_id} exhausted launch generations"))?; + let registered_generation = record + .spec + .launch_manifest + .as_ref() + .map(|manifest| manifest.generation) + .ok_or_else(|| anyhow!("Fleet worker {worker_id} has no persisted launch manifest"))?; + + match registered_generation { + generation if generation == current_generation => { + validate_registered_launch_spec(&record.spec, &expected_current)?; + coordination + .advance_registered_worker_generation(bind_fleet_launch_attempt( + record.spec, + next_generation, + )) + .map_err(anyhow::Error::msg)?; + } + generation if generation == next_generation => { + let mut normalized = record.spec; + normalized + .launch_manifest + .as_mut() + .expect("prepared launch manifest checked above") + .generation = current_generation; + validate_registered_launch_spec(&normalized, &expected_current)?; + } + generation => { + bail!( + "Fleet worker {worker_id} persisted launch generation {generation} does not match ledger attempt {current_generation} or its prepared retry {next_generation}" + ); + } + } + Ok(()) + } + pub fn stop_all(&self) -> Result { let state = self.ledger.rebuild_state()?; let now = timestamp(); @@ -889,45 +1071,63 @@ impl FleetManager { task_spec: &FleetTaskSpec, max_active_for_run: Option, ) -> Result { - let sub_agent_worker = if self.sub_agent_manager.is_some() { - let run = self - .ledger - .rebuild_state() - .ok() - .and_then(|state| state.runs.get(&entry.run_id.0).cloned()); - let worker_spec = run - .as_ref() - .and_then(|r| r.worker_specs.iter().find(|w| w.id == worker_id).cloned()) - .unwrap_or_else(|| FleetWorkerSpec { - id: worker_id.to_string(), - name: worker_id.to_string(), - host: FleetHostSpec::Local, - trust_level: Some(FleetTrustLevel::Local), - labels: BTreeMap::new(), - capabilities: vec![], - max_concurrent_tasks: Some(1), - }); - let roster = self.agent_roster(); - let worker = worker_runtime::fleet_task_to_worker_spec_with_profiles( - worker_id, - &entry.run_id.0, - task_spec, - &worker_spec, - self.run_model(), - &self.workspace, - roster.members(), - None, - )?; - Some(worker_runtime::apply_exec_hardening( - worker, + let run = self + .ledger + .rebuild_state()? + .runs + .get(&entry.run_id.0) + .cloned() + .ok_or_else(|| anyhow!("fleet run {} does not exist", entry.run_id.0))?; + let worker_spec = run + .worker_specs + .iter() + .find(|worker| worker.id == worker_id) + .cloned() + .unwrap_or_else(|| default_local_worker(worker_id)); + let worker_workspace = resolve_task_cwd(&self.workspace, task_spec)?; + validate_task_cwd_for_host(&self.workspace, &worker_spec.host, &worker_workspace)?; + let roster = self.agent_roster(); + let sub_agent_worker = bind_fleet_launch_attempt( + worker_runtime::apply_exec_hardening( + worker_runtime::fleet_task_to_worker_spec_with_profiles( + worker_id, + &entry.run_id.0, + task_spec, + &worker_spec, + self.run_model(), + &worker_workspace, + roster.members(), + None, + )?, &self.exec_config, - )) - } else { - None - }; + ), + entry.attempts.saturating_add(1), + ); + authority_envelope_for_worker(&sub_agent_worker, task_spec)?; let log_artifact = self.write_log_artifact(&entry.run_id, worker_id, task_spec)?; + // Hold the coordination manager from pure preflight through the + // ledger's pre-commit projection callback and any append compensation. This keeps a concurrent + // agent/Fleet registration from invalidating the overlap decision in + // between, while a busy manager simply leaves the task queued for the + // next scheduler tick. + let mut coordination_guard = match &self.sub_agent_manager { + Some(manager) => { + let Ok(mut guard) = manager.try_write() else { + return Ok(false); + }; + guard + .preflight_worker_coordination(&sub_agent_worker) + .map_err(anyhow::Error::msg)?; + Some(guard) + } + None => None, + }; + let registration_snapshot = coordination_guard + .as_ref() + .map(|guard| guard.coordination_registration_snapshot()); + let mut registration_succeeded = false; let now = timestamp(); - if !self.ledger.start_task_if_enqueued( + let start_result = self.ledger.start_task_if_enqueued( &entry.run_id, &entry.task_id, worker_id, @@ -945,14 +1145,30 @@ impl FleetManager { || { // Registration shares the durable transition lock, so a // cancellation cannot win between claim and projection setup. - if let Some(ref mgr) = self.sub_agent_manager - && let Some(worker) = sub_agent_worker - && let Ok(mut guard) = mgr.try_write() - { - guard.register_worker(worker); + if let Some(guard) = coordination_guard.as_mut() { + guard + .register_worker_with_coordination(sub_agent_worker) + .map_err(anyhow::Error::msg)?; + registration_succeeded = true; } + Ok(()) }, - )? { + ); + let started = match start_result { + Ok(started) => started, + Err(start_error) => { + if registration_succeeded + && let (Some(guard), Some(snapshot)) = + (coordination_guard.as_mut(), registration_snapshot) + && let Err(rollback_error) = + guard.restore_coordination_registration_snapshot(snapshot) + { + return Err(anyhow!("{start_error:#}; additionally {rollback_error}")); + } + return Err(start_error); + } + }; + if !started { return Ok(false); } @@ -995,19 +1211,80 @@ impl FleetManager { .find(|worker| worker.id == worker_id) .cloned() .unwrap_or_else(|| default_local_worker(worker_id)); - let command = build_worker_exec_command_with_profiles( - codewhale_binary, - &task_spec, - &self.exec_config, - model, - roster.members(), - )?; - let cwd = resolve_task_cwd(&self.workspace, &task_spec); + let coordination_record = if let Some(manager) = self.sub_agent_manager.as_ref() { + let Ok(guard) = manager.try_read() else { + continue; + }; + Some(guard.get_worker_record(worker_id)) + } else { + None + }; + let preparation = (|| -> Result<_> { + let cwd = resolve_task_cwd(&self.workspace, &task_spec)?; + validate_task_cwd_for_host(&self.workspace, &worker_spec.host, &cwd)?; + let expected_launch_spec = bind_fleet_launch_attempt( + worker_runtime::apply_exec_hardening( + worker_runtime::fleet_task_to_worker_spec_with_profiles( + worker_id, + &run_id.0, + &task_spec, + &worker_spec, + self.run_model(), + &cwd, + roster.members(), + None, + )?, + &self.exec_config, + ), + task.entry.attempts, + ); + let launch_spec = match coordination_record { + Some(Some(record)) => { + validate_registered_launch_spec(&record.spec, &expected_launch_spec)?; + record.spec + } + Some(None) => { + bail!("Fleet worker {worker_id} has no coordination-registered launch spec") + } + None => expected_launch_spec, + }; + let command = build_worker_exec_command_with_launch_spec( + codewhale_binary, + &task_spec, + &launch_spec, + &self.exec_config, + model, + roster.members(), + )?; + Ok((cwd, command)) + })(); let attempt = FleetExecutorAttempt { run_id: task.entry.run_id.clone(), task_id: task.entry.task_id.clone(), attempt: task.entry.attempts, }; + let (cwd, command) = match preparation { + Ok(prepared) => prepared, + Err(err) => { + let task = FleetExecutorTaskContext { + entry: task.entry.clone(), + task_spec, + worker_id: worker_id.to_string(), + }; + let terminal = FleetWorkerTerminalEvent { + payload: FleetWorkerEventPayload::Failed { + reason: format!("worker launch preparation failed: {err:#}"), + recoverable: false, + }, + exit_code: None, + tail_payloads: Vec::new(), + reported_route: None, + requires_reported_route: false, + }; + let _ = self.record_task_outcome(&task, terminal)?; + continue; + } + }; match executor.start_worker_attempt_on_host( worker_id, &worker_spec.host, @@ -1333,7 +1610,10 @@ impl FleetManager { None, ) .ok()?; - let worker = worker_runtime::apply_exec_hardening(worker, &self.exec_config); + let worker = bind_fleet_launch_attempt( + worker_runtime::apply_exec_hardening(worker, &self.exec_config), + task.entry.attempts, + ); Some(worker_runtime::fleet_effective_permissions_for_task( &task.task_spec, roster.members(), @@ -1785,19 +2065,122 @@ fn task_priority(task: &FleetTaskSpec) -> i32 { .unwrap_or(0) } -fn resolve_task_cwd(workspace: &Path, task: &FleetTaskSpec) -> PathBuf { +fn resolve_task_cwd(workspace: &Path, task: &FleetTaskSpec) -> Result { let Some(root) = task .workspace .as_ref() .and_then(|workspace| workspace.root.as_ref()) else { - return workspace.to_path_buf(); + return crate::tools::spec::resolve_strict_authority_path( + &crate::tools::ToolContext::new(workspace.to_path_buf()), + ".", + ) + .map_err(anyhow::Error::new); + }; + crate::tools::spec::resolve_strict_authority_path( + &crate::tools::ToolContext::new(workspace.to_path_buf()), + &root.to_string_lossy(), + ) + .map_err(anyhow::Error::new) +} + +fn bind_fleet_launch_attempt(mut spec: AgentWorkerSpec, attempt: u32) -> AgentWorkerSpec { + // The outer machine-readable cap is workspace-relative and cannot yet be + // intersected into a grandchild's narrower launch context. Fleet workers + // are therefore truthful leaves in v0.9.1: the nested-agent surface is + // disabled for the authority-bound subprocess. + spec.max_spawn_depth = 0; + spec.runtime_profile.max_spawn_depth = 0; + if let Some(manifest) = spec.launch_manifest.as_mut() { + manifest.generation = attempt.max(1); + manifest.profile.max_spawn_depth = 0; + } + spec +} + +fn validate_registered_launch_spec( + registered: &AgentWorkerSpec, + expected: &AgentWorkerSpec, +) -> Result<()> { + let Some(registered_manifest) = registered.launch_manifest.as_ref() else { + bail!( + "Fleet worker {} has no persisted launch manifest", + registered.worker_id + ); + }; + if registered_manifest.prompt != registered.objective + || !registered_prompt_matches_expected(®istered.objective, &expected.objective) + { + bail!( + "Fleet worker {} has an inconsistent persisted prompt", + registered.worker_id + ); + } + + // Coordination may append a bounded decision projection to the prompt. + // Every identity, route, permission, workspace, scope, and attempt field + // must otherwise match a fresh derivation from this exact leased task. + let mut registered_identity = registered.clone(); + let mut expected_identity = expected.clone(); + registered_identity.objective.clear(); + expected_identity.objective.clear(); + if let Some(manifest) = registered_identity.launch_manifest.as_mut() { + manifest.prompt.clear(); + } + if let Some(manifest) = expected_identity.launch_manifest.as_mut() { + manifest.prompt.clear(); + } + if registered_identity != expected_identity { + bail!( + "Fleet worker {} persisted launch spec does not match the exact task lease and attempt", + registered.worker_id + ); + } + Ok(()) +} + +fn registered_prompt_matches_expected(registered: &str, expected: &str) -> bool { + const HEADER: &str = "Accepted coordination decisions relevant to this child (bounded):\n"; + if registered == expected { + return true; + } + let Some(projection) = registered + .strip_prefix(expected) + .and_then(|suffix| suffix.strip_prefix("\n\n")) + else { + return false; }; - if root.is_absolute() { - root.clone() - } else { - workspace.join(root) + let Some(lines) = projection.strip_prefix(HEADER) else { + return false; + }; + !lines.is_empty() + && projection.len() <= 4096 + && lines.lines().count() <= 8 + && lines + .lines() + .all(|line| line.starts_with("- ") && line.len() <= 512) +} + +fn validate_task_cwd_for_host( + workspace: &Path, + host: &FleetHostSpec, + task_cwd: &Path, +) -> Result<()> { + if !matches!(host, FleetHostSpec::Ssh { .. }) { + return Ok(()); + } + let workspace_root = crate::tools::spec::resolve_strict_authority_path( + &crate::tools::ToolContext::new(workspace.to_path_buf()), + ".", + ) + .map_err(anyhow::Error::new)?; + if task_cwd != workspace_root { + bail!( + "SSH Fleet workers do not yet support nested workspace.root values; task cwd '{}' cannot be mapped safely beneath the remote working_directory", + task_cwd.display() + ); } + Ok(()) } fn task_receipt_outcome( @@ -1874,7 +2257,16 @@ mod tests { description: None, objective: Some(format!("Complete {id}")), instructions: format!("do {id}"), - worker: None, + worker: Some(FleetTaskWorkerProfile { + agent_profile: None, + role: Some("reviewer".to_string()), + loadout: None, + model_class: None, + model: None, + tool_profile: Some("read-only".to_string()), + tools: Vec::new(), + capabilities: Vec::new(), + }), workspace: None, input_files: Vec::new(), context: Vec::new(), @@ -1889,6 +2281,303 @@ mod tests { } } + #[test] + fn ssh_workers_fail_closed_for_nested_task_roots() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir(tmp.path().join("nested")).unwrap(); + let mut nested = task("nested"); + nested.workspace = Some(FleetWorkspaceRequirements { + root: Some(PathBuf::from("nested")), + ..FleetWorkspaceRequirements::default() + }); + let nested_cwd = resolve_task_cwd(tmp.path(), &nested).unwrap(); + let ssh = FleetHostSpec::Ssh { + host: "builder.example.test".to_string(), + port: None, + user: None, + identity: None, + known_hosts: None, + host_key_fingerprint: None, + working_directory: Some(PathBuf::from("/srv/codewhale")), + env_allowlist: Vec::new(), + codewhale_binary: Some("/usr/local/bin/codewhale".to_string()), + }; + + let error = validate_task_cwd_for_host(tmp.path(), &ssh, &nested_cwd) + .expect_err("nested SSH task roots must fail closed"); + assert!(error.to_string().contains("cannot be mapped safely")); + + let root_cwd = resolve_task_cwd(tmp.path(), &task("root")).unwrap(); + validate_task_cwd_for_host(tmp.path(), &ssh, &root_cwd).unwrap(); + validate_task_cwd_for_host(tmp.path(), &FleetHostSpec::Local, &nested_cwd).unwrap(); + } + + #[test] + fn ssh_nested_root_failure_commits_neither_lease_nor_coordination_record() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir(tmp.path().join("nested")).unwrap(); + let coordination = + crate::tools::subagent::new_shared_subagent_manager(tmp.path().to_path_buf(), 4); + let manager = FleetManager::open(tmp.path()) + .unwrap() + .with_sub_agent_manager(coordination.clone()); + let mut nested = task("nested"); + nested.worker = Some(FleetTaskWorkerProfile { + agent_profile: None, + role: Some("reviewer".to_string()), + loadout: None, + model_class: None, + model: None, + tool_profile: Some("read-only".to_string()), + tools: Vec::new(), + capabilities: Vec::new(), + }); + nested.workspace = Some(FleetWorkspaceRequirements { + root: Some(PathBuf::from("nested")), + ..FleetWorkspaceRequirements::default() + }); + let worker = FleetWorkerSpec { + id: "ssh-worker".to_string(), + name: "SSH worker".to_string(), + host: FleetHostSpec::Ssh { + host: "builder.example.test".to_string(), + port: None, + user: None, + identity: None, + known_hosts: None, + host_key_fingerprint: None, + working_directory: Some(PathBuf::from("/srv/codewhale")), + env_allowlist: Vec::new(), + codewhale_binary: Some("/usr/local/bin/codewhale".to_string()), + }, + trust_level: None, + labels: BTreeMap::new(), + capabilities: Vec::new(), + max_concurrent_tasks: Some(1), + }; + let error = manager + .create_run( + FleetTaskSpecDocument { + name: Some("nested SSH".to_string()), + labels: BTreeMap::new(), + security_policy: None, + workers: vec![worker], + tasks: vec![nested], + }, + 1, + ) + .expect_err("nested SSH root must fail before leasing"); + assert!(error.to_string().contains("cannot be mapped safely")); + + let state = manager.rebuild_state().unwrap(); + let task = state.tasks.values().next().expect("queued task remains"); + assert_eq!(task.status, FleetTaskLedgerStatus::Enqueued); + assert_eq!(task.entry.attempts, 0); + assert!(task.leased_to.is_none()); + assert!( + coordination + .try_read() + .unwrap() + .list_worker_records() + .is_empty() + ); + } + + #[test] + fn ledger_append_failure_rolls_back_coordination_registration() { + let tmp = TempDir::new().unwrap(); + let coordination = + crate::tools::subagent::new_shared_subagent_manager(tmp.path().to_path_buf(), 4); + let manager = FleetManager::open(tmp.path()) + .unwrap() + .with_sub_agent_manager(coordination.clone()); + let mut spec = task("read-only"); + spec.worker = Some(FleetTaskWorkerProfile { + agent_profile: None, + role: Some("reviewer".to_string()), + loadout: None, + model_class: None, + model: None, + tool_profile: Some("read-only".to_string()), + tools: Vec::new(), + capabilities: Vec::new(), + }); + manager.ledger.fail_next_start_append_after_callback(); + + let error = manager + .create_run( + FleetTaskSpecDocument { + name: Some("forced rollback".to_string()), + labels: BTreeMap::new(), + security_policy: None, + workers: Vec::new(), + tasks: vec![spec], + }, + 1, + ) + .expect_err("forced append failure"); + assert!(error.to_string().contains("forced Fleet ledger append")); + + let state = manager.rebuild_state().unwrap(); + let task = state.tasks.values().next().expect("queued task remains"); + assert_eq!(task.status, FleetTaskLedgerStatus::Enqueued); + assert_eq!(task.entry.attempts, 0); + assert!(task.leased_to.is_none()); + let guard = coordination.try_read().unwrap(); + assert!(guard.list_worker_records().is_empty()); + assert!(guard.coordination_snapshot().write_claims.is_empty()); + } + + #[test] + fn invalid_worker_identity_is_rejected_before_run_journal_creation() { + let tmp = TempDir::new().unwrap(); + let manager = FleetManager::open(tmp.path()).unwrap(); + let error = manager + .create_run( + FleetTaskSpecDocument { + name: Some("invalid identity".to_string()), + labels: BTreeMap::new(), + security_policy: None, + workers: vec![resume_worker_spec("worker\r\nforged")], + tasks: vec![task("task-a")], + }, + 1, + ) + .expect_err("multiline worker identity must fail before journaling"); + assert!( + error + .to_string() + .contains("worker id must be a simple ASCII token") + ); + + let state = manager.rebuild_state().unwrap(); + assert!(state.runs.is_empty()); + assert!(state.tasks.is_empty()); + } + + #[test] + fn restored_lease_without_launch_record_fails_durably_instead_of_poisoning_ticks() { + let tmp = TempDir::new().unwrap(); + let coordination = + crate::tools::subagent::new_shared_subagent_manager(tmp.path().to_path_buf(), 4); + let empty_snapshot = coordination + .try_read() + .unwrap() + .coordination_registration_snapshot(); + let manager = FleetManager::open(tmp.path()) + .unwrap() + .with_sub_agent_manager(coordination.clone()); + let mut spec = task("task-a"); + spec.worker = Some(FleetTaskWorkerProfile { + agent_profile: None, + role: Some("reviewer".to_string()), + loadout: None, + model_class: None, + model: None, + tool_profile: Some("read-only".to_string()), + tools: Vec::new(), + capabilities: Vec::new(), + }); + let report = manager + .create_run( + FleetTaskSpecDocument { + name: Some("restored lease".to_string()), + labels: BTreeMap::new(), + security_policy: None, + workers: Vec::new(), + tasks: vec![spec], + }, + 1, + ) + .unwrap(); + coordination + .try_write() + .unwrap() + .restore_coordination_registration_snapshot(empty_snapshot) + .unwrap(); + + let mut executor = FleetExecutor::new(tmp.path()); + manager + .drive_executor_tick(&report.run_id, &mut executor, "unused-codewhale", None) + .expect("missing restored launch state must become a durable task failure"); + manager + .drive_executor_tick(&report.run_id, &mut executor, "unused-codewhale", None) + .expect("the next scheduler tick must not remain poisoned"); + + let state = manager.rebuild_state().unwrap(); + let key = task_key(&report.run_id.0, "task-a"); + assert_eq!(state.tasks[&key].status, FleetTaskLedgerStatus::Failed); + assert_eq!(state.receipts[&key].result, FleetTaskResult::Fail); + assert!( + latest_error_for_worker(&state, &report.worker_ids[0]) + .is_some_and(|error| error.contains("no coordination-registered launch spec")) + ); + } + + fn read_only_launch_spec(workspace: &Path, task_id: &str, attempt: u32) -> AgentWorkerSpec { + let mut spec = task(task_id); + spec.worker = Some(FleetTaskWorkerProfile { + agent_profile: None, + role: Some("reviewer".to_string()), + loadout: None, + model_class: None, + model: None, + tool_profile: Some("read-only".to_string()), + tools: Vec::new(), + capabilities: Vec::new(), + }); + bind_fleet_launch_attempt( + worker_runtime::fleet_task_to_worker_spec_with_profiles( + "worker-1", + "run-1", + &spec, + &default_local_worker("worker-1"), + "auto", + workspace, + &[], + None, + ) + .unwrap(), + attempt, + ) + } + + #[test] + fn persisted_launch_identity_is_bound_to_task_and_attempt() { + let tmp = TempDir::new().unwrap(); + let task_a = read_only_launch_spec(tmp.path(), "task-a", 1); + let task_b = read_only_launch_spec(tmp.path(), "task-b", 1); + let task_b_retry = read_only_launch_spec(tmp.path(), "task-b", 2); + + assert_eq!(task_b.max_spawn_depth, 0); + assert_eq!(task_b.runtime_profile.max_spawn_depth, 0); + assert_eq!( + task_b + .launch_manifest + .as_ref() + .unwrap() + .profile + .max_spawn_depth, + 0 + ); + validate_registered_launch_spec(&task_b, &task_b).unwrap(); + assert!(validate_registered_launch_spec(&task_a, &task_b).is_err()); + assert!(validate_registered_launch_spec(&task_b, &task_b_retry).is_err()); + + let mut projected = task_b.clone(); + projected.objective.push_str( + "\n\nAccepted coordination decisions relevant to this child (bounded):\n- api v1 [decision-1] owner=planner: keep scope bounded", + ); + projected.launch_manifest.as_mut().unwrap().prompt = projected.objective.clone(); + validate_registered_launch_spec(&projected, &task_b) + .expect("a bounded coordination prompt projection preserves launch identity"); + + let mut corrupt = task_b.clone(); + corrupt.objective.push_str("\n\narbitrary stale prompt"); + corrupt.launch_manifest.as_mut().unwrap().prompt = corrupt.objective.clone(); + assert!(validate_registered_launch_spec(&corrupt, &task_b).is_err()); + } + #[test] fn with_session_model_adopts_route_and_ignores_auto_or_empty() { let tmp = TempDir::new().unwrap(); @@ -2556,7 +3245,11 @@ while :; do sleep 1; done #[test] fn standalone_restart_drives_replacement_attempt_to_terminal_receipt() { let tmp = TempDir::new().unwrap(); - let manager = FleetManager::open(tmp.path()).unwrap(); + let coordination = + crate::tools::subagent::new_shared_subagent_manager(tmp.path().to_path_buf(), 2); + let manager = FleetManager::open(tmp.path()) + .unwrap() + .with_sub_agent_manager(coordination.clone()); let path = task_spec_file(&tmp, vec![task("task-a")]); let marker = tmp.path().join("replacement-attempt-ran"); let fake = fake_codewhale( @@ -2608,6 +3301,208 @@ exit 0 assert_eq!(state.tasks[&key].status, FleetTaskLedgerStatus::Completed); assert_eq!(state.receipts[&key].attempt, Some(2)); assert!(state.receipts[&key].terminal_seq.is_some()); + let generation = coordination + .try_read() + .unwrap() + .get_worker_record(worker_id) + .and_then(|record| record.spec.launch_manifest) + .map(|manifest| manifest.generation); + assert_eq!(generation, Some(2)); + } + + #[test] + fn prepared_restart_survives_reload_and_commits_once() { + let tmp = TempDir::new().unwrap(); + let coordination = + crate::tools::subagent::new_shared_subagent_manager(tmp.path().to_path_buf(), 2); + let manager = FleetManager::open(tmp.path()) + .unwrap() + .with_sub_agent_manager(coordination.clone()); + let path = task_spec_file(&tmp, vec![task("task-a")]); + let report = manager.create_run_from_task_spec_path(&path, 1).unwrap(); + let worker_id = report.worker_ids[0].clone(); + + manager.ledger.fail_next_restart_append_after_callback(); + let error = manager + .restart_worker(&worker_id) + .expect_err("restart append failpoint must leave a durable preparation"); + assert!( + error + .to_string() + .contains("forced Fleet restart ledger append failure"), + "{error:#}" + ); + assert_eq!( + manager.rebuild_state().unwrap().tasks[&task_key(&report.run_id.0, "task-a")] + .entry + .attempts, + 1, + "the replacement lease was not published" + ); + let prepared_spec = coordination + .try_read() + .unwrap() + .get_worker_record(&worker_id) + .unwrap() + .spec; + assert_eq!( + prepared_spec.launch_manifest.as_ref().unwrap().generation, + 2, + "generation two is the durable prepare marker" + ); + + drop(manager); + drop(coordination); + + let reloaded_coordination = + crate::tools::subagent::new_shared_subagent_manager(tmp.path().to_path_buf(), 2); + let reloaded = FleetManager::open(tmp.path()) + .unwrap() + .with_sub_agent_manager(reloaded_coordination.clone()); + reloaded + .restart_worker(&worker_id) + .expect("reload must idempotently consume the prepared generation"); + + let state = reloaded.rebuild_state().unwrap(); + assert_eq!( + state.tasks[&task_key(&report.run_id.0, "task-a")] + .entry + .attempts, + 2 + ); + let committed_spec = reloaded_coordination + .try_read() + .unwrap() + .get_worker_record(&worker_id) + .unwrap() + .spec; + assert_eq!(committed_spec, prepared_spec, "only generation may change"); + let ledger_text = std::fs::read_to_string(reloaded.ledger_path()).unwrap(); + assert_eq!( + ledger_text.matches("\"state\":\"restarted\"").count(), + 1, + "the prepared retry commits exactly one restart event" + ); + } + + #[test] + fn prepared_restart_with_corrupt_depth_fails_closed_after_reload() { + let tmp = TempDir::new().unwrap(); + let coordination = + crate::tools::subagent::new_shared_subagent_manager(tmp.path().to_path_buf(), 2); + let manager = FleetManager::open(tmp.path()) + .unwrap() + .with_sub_agent_manager(coordination.clone()); + let path = task_spec_file(&tmp, vec![task("task-a")]); + let report = manager.create_run_from_task_spec_path(&path, 1).unwrap(); + let worker_id = report.worker_ids[0].clone(); + + manager.ledger.fail_next_restart_append_after_callback(); + manager + .restart_worker(&worker_id) + .expect_err("failpoint leaves generation two prepared"); + { + let mut guard = coordination.try_write().unwrap(); + let mut corrupt = guard.get_worker_record(&worker_id).unwrap().spec; + corrupt.max_spawn_depth = corrupt.max_spawn_depth.saturating_add(1); + guard + .replace_registered_worker_spec_for_test(corrupt) + .unwrap(); + } + drop(manager); + drop(coordination); + + let reloaded_coordination = + crate::tools::subagent::new_shared_subagent_manager(tmp.path().to_path_buf(), 2); + let reloaded = FleetManager::open(tmp.path()) + .unwrap() + .with_sub_agent_manager(reloaded_coordination); + let error = reloaded + .restart_worker(&worker_id) + .expect_err("prepared authority corruption must fail before ledger commit"); + assert!( + error + .to_string() + .contains("does not match the exact task lease") + ); + let state = reloaded.rebuild_state().unwrap(); + assert_eq!( + state.tasks[&task_key(&report.run_id.0, "task-a")] + .entry + .attempts, + 1 + ); + assert!(state.restarted_events.is_empty()); + } + + #[cfg(unix)] + #[test] + fn resume_stale_registered_worker_advances_generation_and_launches() { + let tmp = TempDir::new().unwrap(); + let coordination = + crate::tools::subagent::new_shared_subagent_manager(tmp.path().to_path_buf(), 2); + let manager = FleetManager::open(tmp.path()) + .unwrap() + .with_stale_after(Duration::from_secs(5)) + .with_sub_agent_manager(coordination.clone()); + let path = task_spec_file(&tmp, vec![role_task_with_retry("task-a", "reviewer", 3)]); + let report = manager.create_run_from_task_spec_path(&path, 1).unwrap(); + let worker_id = report.worker_ids[0].clone(); + let marker = tmp.path().join("resumed-attempt-ran"); + let fake = fake_codewhale( + &tmp, + &format!( + r#"#!/bin/sh +touch '{}' +printf '{{"type":"content","content":"resumed attempt"}}\n' +exit 0 +"#, + marker.display() + ), + ); + + drop(manager); + drop(coordination); + + let reloaded_coordination = + crate::tools::subagent::new_shared_subagent_manager(tmp.path().to_path_buf(), 2); + let reloaded = FleetManager::open(tmp.path()) + .unwrap() + .with_stale_after(Duration::from_secs(5)) + .with_sub_agent_manager(reloaded_coordination.clone()); + let resumed = reloaded + .resume_run_at(&report.run_id, Utc::now() + chrono::Duration::minutes(10)) + .unwrap(); + assert_eq!(resumed.reclaimed_stale, 1); + assert_eq!(resumed.restarted, 1); + assert_eq!( + reloaded.rebuild_state().unwrap().tasks[&task_key(&report.run_id.0, "task-a")] + .entry + .attempts, + 2 + ); + assert_eq!( + reloaded_coordination + .try_read() + .unwrap() + .get_worker_record(&worker_id) + .unwrap() + .spec + .launch_manifest + .unwrap() + .generation, + 2 + ); + + let status = complete_with_fake_codewhale(&reloaded, &report.run_id, 1, &fake); + assert!(marker.is_file(), "the recovered replacement never launched"); + assert_eq!(status.completed, 1); + assert_eq!(status.restarted, 1); + let state = reloaded.rebuild_state().unwrap(); + assert_eq!( + state.receipts[&task_key(&report.run_id.0, "task-a")].attempt, + Some(2) + ); } #[cfg(unix)] @@ -3082,6 +3977,12 @@ esac tools: tools.into_iter().map(str::to_string).collect(), capabilities: vec!["local-smoke".to_string()], }); + if role == "builder" { + task.workspace = Some(FleetWorkspaceRequirements { + writable_paths: vec![PathBuf::from(".codewhale/fleet")], + ..FleetWorkspaceRequirements::default() + }); + } task.expected_artifacts = vec![FleetArtifactKind::Log, FleetArtifactKind::Receipt]; task.scorer = Some(FleetScorerSpec::ExitCode); task.retry_policy = Some(FleetRetryPolicy { @@ -3370,7 +4271,7 @@ esac contextual.objective = Some("Review the release ledger".to_string()); contextual.worker = Some(FleetTaskWorkerProfile { agent_profile: None, - role: Some("release-reviewer".to_string()), + role: Some("reviewer".to_string()), loadout: None, model_class: None, model: None, @@ -3399,7 +4300,7 @@ esac inspection.objective.as_deref(), Some("Review the release ledger") ); - assert_eq!(inspection.role.as_deref(), Some("release-reviewer")); + assert_eq!(inspection.role.as_deref(), Some("reviewer")); assert_eq!(inspection.host.as_deref(), Some("local")); assert_eq!( inspection.alert_state.as_deref(), @@ -3543,6 +4444,7 @@ esac "id": "task-a", "name": "task-a", "instructions": "report ok", + "worker": {"role": "reviewer", "tool_profile": "read-only"}, "expected_artifacts": ["log"] }], "security_policy": { diff --git a/crates/tui/src/fleet/scheduler.rs b/crates/tui/src/fleet/scheduler.rs index 1ca16ec5db..d2a5ea0cce 100644 --- a/crates/tui/src/fleet/scheduler.rs +++ b/crates/tui/src/fleet/scheduler.rs @@ -65,9 +65,10 @@ impl FleetScheduler { self.now = now; } + #[cfg(test)] pub fn tick_run(&self, run_id: &FleetRunId) -> Result { let mut report = FleetSchedulerReport::default(); - self.recover_unhealthy_work(run_id, &mut report)?; + self.recover_unhealthy_work(run_id, &mut report, &mut |_, _, _, _| Ok(()))?; self.launch_queued_work(run_id, &mut report)?; self.refresh_run_status(run_id)?; Ok(report) @@ -82,9 +83,23 @@ impl FleetScheduler { /// and idempotent to call on a fresh process: a task re-leased by an /// earlier resume is no longer stale at the same instant, and a terminally /// failed task is never failed or escalated twice. + #[cfg(test)] pub fn resume_run(&self, run_id: &FleetRunId) -> Result { + self.resume_run_with_restart_callback(run_id, |_, _, _, _| Ok(())) + } + + pub(crate) fn resume_run_with_restart_callback( + &self, + run_id: &FleetRunId, + mut on_restarting: impl FnMut( + &FleetLedgerState, + &FleetTaskState, + &FleetTaskSpec, + &str, + ) -> Result<()>, + ) -> Result { let mut report = FleetSchedulerReport::default(); - self.reconcile_stale_leases(run_id, &mut report)?; + self.reconcile_stale_leases(run_id, &mut report, &mut on_restarting)?; self.refresh_run_status(run_id)?; Ok(report) } @@ -123,6 +138,12 @@ impl FleetScheduler { &self, run_id: &FleetRunId, report: &mut FleetSchedulerReport, + on_restarting: &mut dyn FnMut( + &FleetLedgerState, + &FleetTaskState, + &FleetTaskSpec, + &str, + ) -> Result<()>, ) -> Result<()> { let state = self.ledger.rebuild_state()?; for task in state @@ -168,12 +189,14 @@ impl FleetScheduler { }; report.marked_stale += 1; self.retry_or_fail( + &state, task, &task_spec, &worker_id, stale_event.seq, heartbeat_at, report, + on_restarting, ) .with_context(|| format!("recovering stale task {}", task.entry.task_id))?; } @@ -195,12 +218,14 @@ impl FleetScheduler { .get(&worker_id) .map(|heartbeat| heartbeat.timestamp.as_str()); self.retry_or_fail( + &state, task, &task_spec, &worker_id, latest_seq, heartbeat_at, report, + on_restarting, ) .with_context(|| format!("recovering failed task {}", task.entry.task_id))?; } @@ -219,6 +244,12 @@ impl FleetScheduler { &self, run_id: &FleetRunId, report: &mut FleetSchedulerReport, + on_restarting: &mut dyn FnMut( + &FleetLedgerState, + &FleetTaskState, + &FleetTaskSpec, + &str, + ) -> Result<()>, ) -> Result<()> { let state = self.ledger.rebuild_state()?; for task in state @@ -267,12 +298,14 @@ impl FleetScheduler { }; report.marked_stale += 1; self.retry_or_fail( + &state, task, &task_spec, &worker_id, stale_event.seq, heartbeat_at, report, + on_restarting, ) .with_context(|| format!("resuming stale task {}", task.entry.task_id))?; } @@ -281,17 +314,24 @@ impl FleetScheduler { fn retry_or_fail( &self, + state: &FleetLedgerState, task: &FleetTaskState, task_spec: &FleetTaskSpec, worker_id: &str, expected_latest_seq: u64, expected_heartbeat_at: Option<&str>, report: &mut FleetSchedulerReport, + on_restarting: &mut dyn FnMut( + &FleetLedgerState, + &FleetTaskState, + &FleetTaskSpec, + &str, + ) -> Result<()>, ) -> Result<()> { let retry_policy = task_spec.retry_policy.clone().unwrap_or_default(); if task.entry.attempts < retry_policy.max_attempts { let lease_expires_at = self.lease_expires_at(); - if !self.ledger.restart_task_if_unchanged( + if !self.ledger.restart_task_if_unchanged_with_callback( &task.entry.run_id, &task.entry.task_id, worker_id, @@ -302,6 +342,7 @@ impl FleetScheduler { &self.timestamp(), Some(&lease_expires_at), task.entry.attempts, + || on_restarting(state, task, task_spec, worker_id), )? { return Ok(()); } @@ -386,7 +427,7 @@ impl FleetScheduler { FleetWorkerEventPayload::Starting, FleetWorkerEventPayload::Running, ], - || {}, + || Ok(()), )? { continue; } diff --git a/crates/tui/src/fleet/task_spec.rs b/crates/tui/src/fleet/task_spec.rs index ef290be6e4..3435aa2942 100644 --- a/crates/tui/src/fleet/task_spec.rs +++ b/crates/tui/src/fleet/task_spec.rs @@ -15,6 +15,8 @@ use serde_json::{Value, json}; use super::ledger::FleetLedger; const MAX_SCORER_READ_BYTES: u64 = 1_000_000; +const MAX_FLEET_ID_BYTES: usize = 128; +const MAX_FLEET_NAME_BYTES: usize = 256; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FleetTaskSpecDocument { @@ -115,15 +117,11 @@ pub fn validate_task_spec_document(doc: &FleetTaskSpecDocument) -> Result<()> { } let mut ids = BTreeSet::new(); for task in &doc.tasks { - if task.id.trim().is_empty() { - bail!("fleet task id cannot be empty"); - } + validate_fleet_identity("task id", &task.id)?; if !ids.insert(task.id.clone()) { bail!("duplicate fleet task id {}", task.id); } - if task.name.trim().is_empty() { - bail!("fleet task {} name cannot be empty", task.id); - } + validate_fleet_name(&format!("task {} name", task.id), &task.name)?; if task.instructions.trim().is_empty() { bail!("fleet task {} instructions cannot be empty", task.id); } @@ -136,6 +134,38 @@ pub fn validate_task_spec_document(doc: &FleetTaskSpecDocument) -> Result<()> { validate_tags(&task.id, &task.tags)?; validate_workspace_requirements(task)?; } + let mut worker_ids = BTreeSet::new(); + for worker in &doc.workers { + validate_fleet_identity("worker id", &worker.id)?; + if !worker_ids.insert(worker.id.clone()) { + bail!("duplicate fleet worker id {}", worker.id); + } + validate_fleet_name(&format!("worker {} name", worker.id), &worker.name)?; + } + Ok(()) +} + +fn validate_fleet_identity(field: &str, value: &str) -> Result<()> { + if value.is_empty() { + bail!("fleet {field} cannot be empty"); + } + if value.len() > MAX_FLEET_ID_BYTES || !value.chars().all(is_worker_token_char) { + bail!( + "fleet {field} must be a simple ASCII token no longer than {MAX_FLEET_ID_BYTES} bytes" + ); + } + Ok(()) +} + +fn validate_fleet_name(field: &str, value: &str) -> Result<()> { + if value.trim().is_empty() { + bail!("fleet {field} cannot be empty"); + } + if value.len() > MAX_FLEET_NAME_BYTES || value.chars().any(char::is_control) { + bail!( + "fleet {field} must be one printable line no longer than {MAX_FLEET_NAME_BYTES} bytes" + ); + } Ok(()) } @@ -789,6 +819,47 @@ mod tests { ); } + #[test] + fn fleet_task_spec_rejects_unbounded_or_multiline_task_and_worker_identities() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("unsafe-identities.json"); + let doc = json!({ + "workers": [{ + "id": "worker\r\nforged", + "name": "forged worker", + "host": {"kind": "local"} + }], + "tasks": [{ + "id": "review", + "name": "review", + "instructions": "review the patch" + }] + }); + std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap()).unwrap(); + + let err = load_task_spec_document(&path).unwrap_err().to_string(); + assert!( + err.contains("worker id must be a simple ASCII token"), + "unexpected error: {err}" + ); + + let mut doc = task("review", None); + doc.id = "a".repeat(MAX_FLEET_ID_BYTES + 1); + let err = validate_task_spec_document(&FleetTaskSpecDocument { + name: None, + labels: BTreeMap::new(), + security_policy: None, + workers: Vec::new(), + tasks: vec![doc], + }) + .unwrap_err() + .to_string(); + assert!( + err.contains("task id must be a simple ASCII token"), + "unexpected error: {err}" + ); + } + #[test] fn fleet_task_spec_artifact_refs_are_bounded_paths() { let tmp = TempDir::new().unwrap(); diff --git a/crates/tui/src/fleet/worker_runtime.rs b/crates/tui/src/fleet/worker_runtime.rs index 5a08cf0535..4daa2a9e66 100644 --- a/crates/tui/src/fleet/worker_runtime.rs +++ b/crates/tui/src/fleet/worker_runtime.rs @@ -23,7 +23,7 @@ use super::profile::AgentProfile; use crate::config::{ApiProvider, Config}; use crate::route_runtime::{resolve_route_candidate, resolve_runtime_route}; use crate::tools::subagent::{AgentWorkerSpec, AgentWorkerToolProfile, SubAgentType}; -use crate::worker_profile::{ModelRoute, ToolScope, WorkerRuntimeProfile}; +use crate::worker_profile::{ChildLaunchManifest, ModelRoute, ToolScope, WorkerRuntimeProfile}; /// Validate that every task referencing a workspace agent profile can resolve it. /// @@ -85,12 +85,42 @@ pub fn fleet_task_to_worker_spec_with_profiles( let runtime_profile = parent_runtime_profile .map(|parent| parent.derive_child(&requested_runtime)) .unwrap_or(requested_runtime); + let writable_roots = fleet_write_roots(task_spec)?; + let coordination_contracts = fleet_coordination_contracts(task_spec)?; + if runtime_profile.permissions.write + && writable_roots.is_empty() + && coordination_contracts.is_empty() + { + bail!( + "fleet task '{}' is write-capable but declares no workspace.writable_paths or metadata.coordination_contracts", + task_spec.id + ); + } + let session_name = format!("fleet-{}-{}", worker_id, task_spec.id); + let launch_manifest = ChildLaunchManifest { + owner_session: run_id.to_string(), + child_id: worker_id.to_string(), + profile: runtime_profile.clone(), + prompt: objective.clone(), + cwd: Some(workspace.display().to_string()), + worktree: false, + writable_roots, + writable_files: Vec::new(), + coordination_contracts, + expected_artifact: None, + token_budget: task_spec + .budget + .as_ref() + .and_then(|budget| budget.max_tokens), + resume_identity: Some(session_name.clone()), + generation: 1, + }; Ok(AgentWorkerSpec { worker_id: worker_id.to_string(), run_id: run_id.to_string(), parent_run_id: None, - session_name: Some(format!("fleet-{}-{}", worker_id, task_spec.id)), + session_name: Some(session_name), objective, role, agent_type, @@ -108,10 +138,133 @@ pub fn fleet_task_to_worker_spec_with_profiles( .unwrap_or(u32::MAX), spawn_depth: 0, max_spawn_depth: runtime_profile.max_spawn_depth, - launch_manifest: None, + launch_manifest: Some(launch_manifest), + }) +} + +fn fleet_write_roots(task_spec: &FleetTaskSpec) -> Result> { + let task_root = normalize_fleet_relative_path( + task_spec + .workspace + .as_ref() + .and_then(|workspace| workspace.root.as_deref()) + .unwrap_or_else(|| std::path::Path::new(".")), + &task_spec.id, + "workspace.root", + )?; + let mut roots = Vec::new(); + for runtime_root in fleet_runtime_write_roots(task_spec)? { + let claim_root = match (task_root.as_str(), runtime_root.as_str()) { + (".", path) | (path, ".") => path.to_string(), + (root, path) => format!("{root}/{path}"), + }; + if !roots.contains(&claim_root) { + roots.push(claim_root); + } + } + Ok(roots) +} + +pub(crate) fn fleet_runtime_write_roots(task_spec: &FleetTaskSpec) -> Result> { + let mut roots = Vec::new(); + for path in task_spec + .workspace + .as_ref() + .into_iter() + .flat_map(|workspace| &workspace.writable_paths) + { + let normalized = + normalize_fleet_relative_path(path, &task_spec.id, "workspace.writable_paths")?; + if !roots.contains(&normalized) { + roots.push(normalized); + } + } + Ok(roots) +} + +fn normalize_fleet_relative_path( + path: &std::path::Path, + task_id: &str, + field: &str, +) -> Result { + let raw = path.to_string_lossy().replace('\\', "/"); + if raw.chars().any(|ch| matches!(ch, '\0' | '\r' | '\n')) + || path.is_absolute() + || path.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) + ) + }) + { + bail!( + "fleet task '{task_id}' {field} path '{}' must be one repo-relative line and cannot escape the workspace", + path.display() + ); + } + let mut segments = Vec::new(); + for segment in raw.split('/') { + match segment { + "" | "." => {} + ".." => { + bail!( + "fleet task '{task_id}' {field} path '{}' cannot contain parent traversal", + path.display() + ); + } + value => segments.push(value), + } + } + Ok(if segments.is_empty() { + ".".to_string() + } else { + segments.join("/") }) } +fn fleet_coordination_contracts(task_spec: &FleetTaskSpec) -> Result> { + let Some(value) = task_spec.metadata.get("coordination_contracts") else { + return Ok(Vec::new()); + }; + let Some(values) = value.as_array() else { + bail!( + "fleet task '{}' metadata.coordination_contracts must be an array of strings", + task_spec.id + ); + }; + if values.len() > 16 { + bail!( + "fleet task '{}' metadata.coordination_contracts accepts at most 16 entries", + task_spec.id + ); + } + let mut contracts = Vec::new(); + for value in values { + let Some(value) = value.as_str() else { + bail!( + "fleet task '{}' metadata.coordination_contracts must contain only strings", + task_spec.id + ); + }; + let value = value.trim(); + if value.is_empty() + || value.chars().count() > 128 + || value.chars().any(|ch| matches!(ch, '\0' | '\r' | '\n')) + { + bail!( + "fleet task '{}' coordination contracts must be one non-empty line of at most 128 characters", + task_spec.id + ); + } + if !contracts.iter().any(|contract| contract == value) { + contracts.push(value.to_string()); + } + } + Ok(contracts) +} + /// Mint a [`FleetResolvedRoute`] snapshot for a fleet task (#3154). /// /// This calls the existing hermetic resolver bridge @@ -918,7 +1071,8 @@ fn filter_tool_profile( #[cfg(test)] mod tests { use super::*; - use codewhale_protocol::fleet::FleetHostSpec; + use codewhale_protocol::fleet::{FleetHostSpec, FleetWorkspaceRequirements}; + use std::path::{Path, PathBuf}; fn fleet_task(id: &str, worker: Option) -> FleetTaskSpec { FleetTaskSpec { @@ -928,7 +1082,12 @@ mod tests { objective: Some(format!("Complete {id}")), instructions: format!("do {id}"), worker, - workspace: None, + workspace: Some(FleetWorkspaceRequirements { + root: Some(PathBuf::from(".")), + required_files: Vec::new(), + writable_paths: vec![PathBuf::from(".")], + environment: None, + }), input_files: Vec::new(), context: Vec::new(), budget: None, @@ -942,6 +1101,157 @@ mod tests { } } + #[test] + fn write_capable_fleet_worker_requires_and_persists_a_bounded_claim() { + let worker = FleetWorkerSpec { + id: "worker-1".to_string(), + name: "Worker".to_string(), + host: FleetHostSpec::Local, + trust_level: None, + labels: Default::default(), + capabilities: vec![], + max_concurrent_tasks: None, + }; + let mut unscoped = fleet_task("write", None); + unscoped.workspace = None; + let error = fleet_task_to_worker_spec_with_profiles( + "worker-1", + "run-1", + &unscoped, + &worker, + "auto", + Path::new("/tmp"), + &[], + None, + ) + .expect_err("unscoped Fleet writer must fail before registration"); + assert!(error.to_string().contains("declares no"), "{error:#}"); + + let scoped = fleet_task_to_worker_spec_with_profiles( + "worker-1", + "run-1", + &fleet_task("write", None), + &worker, + "auto", + Path::new("/tmp"), + &[], + None, + ) + .expect("bounded Fleet writer"); + let manifest = scoped.launch_manifest.expect("launch manifest"); + assert_eq!(manifest.child_id, "worker-1"); + assert_eq!(manifest.writable_roots, ["."]); + assert_eq!(manifest.prompt, scoped.objective); + } + + #[test] + fn fleet_claim_roots_share_one_manager_workspace_namespace() { + let worker = FleetWorkerSpec { + id: "worker-1".to_string(), + name: "Worker".to_string(), + host: FleetHostSpec::Local, + trust_level: None, + labels: Default::default(), + capabilities: vec![], + max_concurrent_tasks: None, + }; + let mut nested = fleet_task("nested", None); + nested.workspace = Some(FleetWorkspaceRequirements { + root: Some(PathBuf::from("pkg-a")), + writable_paths: vec![PathBuf::from("src")], + ..FleetWorkspaceRequirements::default() + }); + let mut root = fleet_task("root", None); + root.workspace = Some(FleetWorkspaceRequirements { + root: Some(PathBuf::from(".")), + writable_paths: vec![PathBuf::from("pkg-a/src")], + ..FleetWorkspaceRequirements::default() + }); + + let nested_spec = fleet_task_to_worker_spec_with_profiles( + "worker-1", + "run-1", + &nested, + &worker, + "auto", + Path::new("/repo/pkg-a"), + &[], + None, + ) + .unwrap(); + let root_spec = fleet_task_to_worker_spec_with_profiles( + "worker-2", + "run-1", + &root, + &worker, + "auto", + Path::new("/repo"), + &[], + None, + ) + .unwrap(); + assert_eq!( + nested_spec.launch_manifest.unwrap().writable_roots, + ["pkg-a/src"] + ); + assert_eq!( + root_spec.launch_manifest.unwrap().writable_roots, + ["pkg-a/src"] + ); + assert_eq!(fleet_runtime_write_roots(&nested).unwrap(), ["src"]); + } + + #[test] + fn fleet_manifest_rejects_control_characters_before_lease() { + let worker = FleetWorkerSpec { + id: "worker-1".to_string(), + name: "Worker".to_string(), + host: FleetHostSpec::Local, + trust_level: None, + labels: Default::default(), + capabilities: vec![], + max_concurrent_tasks: None, + }; + let mut bad_contract = fleet_task("bad-contract", None); + bad_contract.metadata.insert( + "coordination_contracts".to_string(), + serde_json::json!(["api\ncontract"]), + ); + assert!( + fleet_task_to_worker_spec_with_profiles( + "worker-1", + "run-1", + &bad_contract, + &worker, + "auto", + Path::new("/repo"), + &[], + None, + ) + .unwrap_err() + .to_string() + .contains("one non-empty line") + ); + + let mut bad_path = fleet_task("bad-path", None); + bad_path.workspace.as_mut().unwrap().writable_paths = vec![PathBuf::from("src\nother")]; + assert!( + fleet_task_to_worker_spec_with_profiles( + "worker-1", + "run-1", + &bad_path, + &worker, + "auto", + Path::new("/repo"), + &[], + None, + ) + .unwrap_err() + .to_string() + .contains("one repo-relative line") + ); + } + fn worker_profile( agent_profile: Option<&str>, role: Option<&str>, @@ -2228,7 +2538,16 @@ mod tests { description: None, objective: None, instructions: "Do the task.".to_string(), - worker: None, + worker: Some(FleetTaskWorkerProfile { + agent_profile: None, + role: Some("reviewer".to_string()), + loadout: None, + model_class: None, + model: None, + tool_profile: Some("read-only".to_string()), + tools: Vec::new(), + capabilities: Vec::new(), + }), workspace: None, input_files: vec![], context: vec![], @@ -2352,7 +2671,14 @@ mod tests { }, capabilities: vec![], }), - workspace: None, + workspace: matches!(&expected_type, SubAgentType::Implementer).then(|| { + FleetWorkspaceRequirements { + root: Some(PathBuf::from(".")), + required_files: Vec::new(), + writable_paths: vec![PathBuf::from(".")], + environment: None, + } + }), input_files: vec![], context: vec![], budget: None, diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index f791e39217..533bd21085 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -291,7 +291,7 @@ pub enum MessageId { CmdSidebarDescription, CmdSkillDescription, CmdSkillsDescription, - CmdSlopDescription, + CmdDebtDescription, CmdStashDescription, CmdStatusDescription, CmdStatuslineDescription, @@ -951,6 +951,9 @@ pub enum MessageId { PhaseIdle, PhaseDraft, PhaseWorking, + PhaseReasoning, + PhaseReading, + PhaseUsingTool, /// Metered verification pass (tests/checks) — distinct from `working` /// so checking reads differently from searching (ocean state model). PhaseVerifying, @@ -1140,6 +1143,45 @@ pub enum MessageId { SidebarDestructiveArmed, WorkSurfaceTodoProgress, WorkSurfaceStopConfirmHint, + CoordinationWorkTitle, + CoordinationSummaryDecisions, + CoordinationSummaryContentions, + CoordinationSummaryReconciled, + CoordinationSchema, + CoordinationSequence, + CoordinationPerSectionLimit, + CoordinationDecisionsHeading, + CoordinationNone, + CoordinationNoneValue, + CoordinationStatus, + CoordinationOwner, + CoordinationVersion, + CoordinationWriteClaimsHeading, + CoordinationIsolated, + CoordinationSharedWorkspace, + CoordinationPaths, + CoordinationContracts, + CoordinationContentionsHeading, + CoordinationClaimant, + CoordinationDisposition, + CoordinationNeutralReconciliationHeading, + CoordinationCandidates, + CoordinationRetry, + CoordinationReviewer, + CoordinationVerifier, + CoordinationVerification, + CoordinationContextProjectionsHeading, + CoordinationContextDecisions, + CoordinationBytes, + CoordinationDeduplicated, + CoordinationOmitted, + CoordinationActiveHotPathsHeading, + CoordinationActiveClaims, + CoordinationMetricsNoteHeading, + CoordinationMetricsNoAuthoritativeSource, + CoordinationStatusProposed, + CoordinationStatusAccepted, + CoordinationStatusSuperseded, // Composer slash menu. ComposerSlashMenuHint, // Approval modal — repository law band. @@ -1360,7 +1402,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CmdSidebarDescription, MessageId::CmdSkillDescription, MessageId::CmdSkillsDescription, - MessageId::CmdSlopDescription, + MessageId::CmdDebtDescription, MessageId::CmdStashDescription, MessageId::CmdStatusDescription, MessageId::CmdStatuslineDescription, @@ -1992,6 +2034,9 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::PhaseIdle, MessageId::PhaseDraft, MessageId::PhaseWorking, + MessageId::PhaseReasoning, + MessageId::PhaseReading, + MessageId::PhaseUsingTool, MessageId::PhaseVerifying, MessageId::PhaseWaitingOnYou, MessageId::PhaseDone, @@ -2165,6 +2210,45 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::SidebarDestructiveArmed, MessageId::WorkSurfaceTodoProgress, MessageId::WorkSurfaceStopConfirmHint, + MessageId::CoordinationWorkTitle, + MessageId::CoordinationSummaryDecisions, + MessageId::CoordinationSummaryContentions, + MessageId::CoordinationSummaryReconciled, + MessageId::CoordinationSchema, + MessageId::CoordinationSequence, + MessageId::CoordinationPerSectionLimit, + MessageId::CoordinationDecisionsHeading, + MessageId::CoordinationNone, + MessageId::CoordinationNoneValue, + MessageId::CoordinationStatus, + MessageId::CoordinationOwner, + MessageId::CoordinationVersion, + MessageId::CoordinationWriteClaimsHeading, + MessageId::CoordinationIsolated, + MessageId::CoordinationSharedWorkspace, + MessageId::CoordinationPaths, + MessageId::CoordinationContracts, + MessageId::CoordinationContentionsHeading, + MessageId::CoordinationClaimant, + MessageId::CoordinationDisposition, + MessageId::CoordinationNeutralReconciliationHeading, + MessageId::CoordinationCandidates, + MessageId::CoordinationRetry, + MessageId::CoordinationReviewer, + MessageId::CoordinationVerifier, + MessageId::CoordinationVerification, + MessageId::CoordinationContextProjectionsHeading, + MessageId::CoordinationContextDecisions, + MessageId::CoordinationBytes, + MessageId::CoordinationDeduplicated, + MessageId::CoordinationOmitted, + MessageId::CoordinationActiveHotPathsHeading, + MessageId::CoordinationActiveClaims, + MessageId::CoordinationMetricsNoteHeading, + MessageId::CoordinationMetricsNoAuthoritativeSource, + MessageId::CoordinationStatusProposed, + MessageId::CoordinationStatusAccepted, + MessageId::CoordinationStatusSuperseded, MessageId::ComposerSlashMenuHint, MessageId::ApprovalRepoLawBadge, MessageId::ApprovalRepoLawTitle, @@ -2456,14 +2540,77 @@ mod tests { } } - fn raw_locale_keys(locale: Locale) -> std::collections::BTreeSet { + #[test] + fn coordination_work_chrome_is_explicitly_localized() { + for locale in Locale::shipped_complete() { + if *locale == Locale::En { + continue; + } + assert_ne!( + tr(*locale, MessageId::CoordinationWorkTitle), + tr(Locale::En, MessageId::CoordinationWorkTitle), + "{} fell back to the English Coordination Work title", + locale.tag() + ); + assert_ne!( + tr(*locale, MessageId::CoordinationMetricsNoAuthoritativeSource), + tr( + Locale::En, + MessageId::CoordinationMetricsNoAuthoritativeSource + ), + "{} fell back to the English coordination metrics note", + locale.tag() + ); + } + } + + fn raw_locale_messages(locale: Locale) -> serde_json::Map { serde_json::from_str::>(locale_json_source( locale, )) .unwrap_or_else(|err| panic!("{} locale json should parse: {err}", locale.tag())) - .keys() - .cloned() - .collect() + } + + fn raw_locale_keys(locale: Locale) -> std::collections::BTreeSet { + raw_locale_messages(locale).keys().cloned().collect() + } + + fn message_placeholders(value: &str) -> std::collections::BTreeSet { + value + .split('{') + .skip(1) + .filter_map(|suffix| suffix.split_once('}').map(|(name, _)| name.to_string())) + .collect() + } + + #[test] + fn coordination_complete_packs_have_raw_key_and_placeholder_parity() { + let english = raw_locale_messages(Locale::En); + let coordination_keys = english + .keys() + .filter(|key| key.starts_with("Coordination")) + .collect::>(); + assert_eq!(coordination_keys.len(), 39); + + for locale in Locale::shipped_complete() { + let pack = raw_locale_messages(*locale); + for key in &coordination_keys { + let english_value = english + .get(*key) + .and_then(serde_json::Value::as_str) + .unwrap_or_else(|| panic!("English {key} must be a string")); + let translated = pack + .get(*key) + .and_then(serde_json::Value::as_str) + .unwrap_or_else(|| panic!("{} is missing raw key {key}", locale.tag())); + assert_eq!( + message_placeholders(translated), + message_placeholders(english_value), + "{} changed placeholders for {key}", + locale.tag() + ); + } + } } /// `missing_message_ids` is blind to keys that exist in en but not in a diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index ca2444c411..b5fe1adbd3 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -412,6 +412,9 @@ struct ExecArgs { /// Extra text appended to the system prompt for this run. #[arg(long)] append_system_prompt: Option, + /// Internal Fleet worker authority envelope. Non-secret, versioned JSON. + #[arg(long, value_name = "JSON", hide = true)] + tool_authority_json: Option, /// Prompt to send to the model #[arg( value_name = "PROMPT", @@ -1541,6 +1544,10 @@ async fn run_async_main( } let prompt = join_prompt_parts(&args.prompt); let resume_session_id = resolve_exec_resume_session_id(&args, &workspace)?; + validate_exec_tool_authority_resume( + args.tool_authority_json.as_deref(), + resume_session_id.is_some(), + )?; let resume_session = resume_session_id .as_deref() .map(load_exec_resume_session) @@ -1578,6 +1585,7 @@ async fn run_async_main( || args.allowed_tools.is_some() || args.disallowed_tools.is_some() || args.append_system_prompt.is_some() + || args.tool_authority_json.is_some() || args.sandbox.is_some() || args.allow_sandbox_elevation || env_tool_surface.is_some(); @@ -1613,6 +1621,7 @@ async fn run_async_main( allowed_tools, disallowed_tools, args.append_system_prompt.clone(), + args.tool_authority_json.clone(), std::sync::Arc::clone(&plugin_registry), ) .await @@ -2520,11 +2529,24 @@ async fn run_fleet_command(workspace: &Path, config: &Config, args: FleetArgs) - } let fleet_config = config.fleet_config(); + let provider = config.api_provider(); + let max_subagents = config.max_subagents_for_provider(provider); + let coordination_manager = crate::tools::subagent::new_shared_subagent_manager_with_timeout( + workspace.to_path_buf(), + max_subagents, + config + .max_admitted_subagents_for_provider(provider) + .max(max_subagents), + Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(provider)), + config.launch_concurrency_for_provider(provider), + config.subagent_token_budget_for_provider(provider), + ); // The configured route is the operator: fleet workers without a // task/profile model pin inherit the session's active model. let manager = FleetManager::open(workspace)? .with_exec_config(fleet_config.exec.clone()) .with_fleet_config(fleet_config) + .with_sub_agent_manager(coordination_manager) .with_session_model(config.default_model()) .with_route_config(config.clone()); match args.command { @@ -3123,7 +3145,9 @@ fn run_setup( println!(" Next: run `/plugin validate`, review `example`, then trust and enable it."); } - let sandbox = crate::sandbox::get_platform_sandbox(); + let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference( + config.prefer_bwrap.unwrap_or(false), + ); if let Some(kind) = sandbox { println!(" ✓ Sandbox available: {kind}"); } else { @@ -3439,7 +3463,9 @@ fn run_setup_status( crate::utils::display_path(&plugins_dir) ); - let sandbox = crate::sandbox::get_platform_sandbox(); + let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference( + config.prefer_bwrap.unwrap_or(false), + ); match sandbox { Some(kind) => println!( " {} sandbox: {kind}", @@ -4497,7 +4523,9 @@ async fn run_doctor( println!(" OS: {}", std::env::consts::OS); println!(" Arch: {}", std::env::consts::ARCH); - let sandbox = crate::sandbox::get_platform_sandbox(); + let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference( + config.prefer_bwrap.unwrap_or(false), + ); if let Some(kind) = sandbox { println!( " {} sandbox available: {}", @@ -6054,7 +6082,9 @@ fn run_doctor_json( "error": stash.error, }, }, - "sandbox": match crate::sandbox::get_platform_sandbox() { + "sandbox": match crate::sandbox::get_platform_sandbox_with_bwrap_preference( + config.prefer_bwrap.unwrap_or(false), + ) { Some(kind) => json!({"available": true, "kind": kind.to_string()}), None => json!({"available": false, "kind": null}), }, @@ -8921,6 +8951,11 @@ async fn run_interactive( // Failures are quiet: bundled catalog rows always remain available. crate::models_dev_live::maybe_load_persisted_cache(); crate::models_dev_live::spawn_background_refresh(); + // Best-effort per-provider catalog refresh: fetches the active provider's + // own /v1/models endpoint and merges live rows into the provider lake + // alongside the Models.dev snapshot. Currently active for TelecomJS, whose + // model list is not covered by the Models.dev catalog. + crate::client::DeepSeekClient::spawn_active_provider_catalog_refresh(config); // Boot janitors — snapshot prune (7-day default), spillover prune // (#422), and managed-session cleanup (v0.8.44) — are best-effort disk @@ -10167,6 +10202,18 @@ struct ExecSummary { error: Option, } +fn validate_exec_tool_authority_resume( + tool_authority_json: Option<&str>, + resuming: bool, +) -> Result<()> { + if tool_authority_json.is_some() && resuming { + bail!( + "Fleet tool authority cannot be combined with exec --resume, --session-id, or --continue" + ); + } + Ok(()) +} + #[allow(clippy::too_many_arguments)] async fn run_exec_agent( config: &Config, @@ -10186,6 +10233,7 @@ async fn run_exec_agent( allowed_tools: Option>, disallowed_tools: Option>, append_system_prompt: Option, + tool_authority_json: Option, plugin_registry: std::sync::Arc, ) -> Result<()> { use crate::compaction::CompactionConfig; @@ -10196,6 +10244,15 @@ async fn run_exec_agent( use crate::tools::todo::new_shared_todo_list; use crate::tui::app::AppMode; + validate_exec_tool_authority_resume(tool_authority_json.as_deref(), resume_session.is_some())?; + let fleet_authority_active = tool_authority_json.is_some(); + + if let Some(raw) = tool_authority_json.as_deref() { + let envelope = crate::tools::spec::ToolAuthorityEnvelope::from_json(raw) + .map_err(anyhow::Error::msg)?; + crate::tools::spec::install_process_tool_authority(envelope).map_err(anyhow::Error::msg)?; + } + let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?; let execution_config = config_for_cli_route(config, &route); let auto_model = route.auto_model; @@ -10269,16 +10326,32 @@ async fn run_exec_agent( crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime()) }); - let lsp_config = execution_config - .lsp - .clone() - .map(crate::config::LspConfigToml::into_runtime); + let lsp_config = (!fleet_authority_active) + .then(|| { + execution_config + .lsp + .clone() + .map(crate::config::LspConfigToml::into_runtime) + }) + .flatten(); + let mut engine_features = execution_config.features(); + if fleet_authority_active { + engine_features + .disable(crate::features::Feature::ShellTool) + .disable(crate::features::Feature::Subagents) + .disable(crate::features::Feature::Mcp); + } + let engine_plugin_registry = if fleet_authority_active { + std::sync::Arc::new(crate::plugins::PluginRegistry::empty(&workspace)) + } else { + plugin_registry + }; let engine_config = EngineConfig { model: effective_model.clone(), active_route_limits, workspace: workspace.clone(), - plugin_registry: Some(plugin_registry), - allow_shell: auto_approve || execution_config.allow_shell(), + plugin_registry: Some(engine_plugin_registry), + allow_shell: !fleet_authority_active && (auto_approve || execution_config.allow_shell()), trust_mode, notes_path: execution_config.notes_path(), mcp_config_path: execution_config.mcp_config_path(), @@ -10307,18 +10380,23 @@ async fn run_exec_agent( .max_admitted_subagents_for_provider(effective_provider) .max(max_subagents), launch_concurrency: execution_config.launch_concurrency_for_provider(effective_provider), - subagents_enabled: execution_config.subagents_enabled_for_provider(effective_provider), - features: execution_config.features(), + subagents_enabled: !fleet_authority_active + && execution_config.subagents_enabled_for_provider(effective_provider), + features: engine_features, auto_review_policy: execution_config.auto_review_policy(), compaction: compaction.clone(), todos: new_shared_todo_list(), plan_state: new_shared_plan_state(), goal_state: crate::tools::goal::new_shared_goal_state(), - max_spawn_depth: execution_config.subagent_max_spawn_depth_for_provider(effective_provider), + max_spawn_depth: if fleet_authority_active { + 0 + } else { + execution_config.subagent_max_spawn_depth_for_provider(effective_provider) + }, subagent_token_budget: execution_config .subagent_token_budget_for_provider(effective_provider), network_policy, - snapshots_enabled: execution_config.snapshots_config().enabled, + snapshots_enabled: !fleet_authority_active && execution_config.snapshots_config().enabled, snapshots_max_workspace_bytes: execution_config .snapshots_config() .max_workspace_gb @@ -10365,8 +10443,16 @@ async fn run_exec_agent( .search .as_ref() .and_then(|s| s.base_url.clone()), - tools_always_load: execution_config.tools_always_load(), - tools: execution_config.tools.clone(), + tools_always_load: if fleet_authority_active { + std::collections::HashSet::new() + } else { + execution_config.tools_always_load() + }, + tools: if fleet_authority_active { + None + } else { + execution_config.tools.clone() + }, verbosity: execution_config.verbosity.clone(), workspace_follow_symlinks: settings.workspace_follow_symlinks, exec_policy_engine: execution_config.exec_policy_engine.clone(), @@ -13488,6 +13574,7 @@ mod terminal_mode_tests { #[test] fn exec_parses_tool_gate_and_hardening_flags() { + let envelope = r#"{"schema_version":1,"owner":"fleet-worker-1","authority":"read_only"}"#; let cli = parse_cli(&[ "codewhale", "exec", @@ -13499,6 +13586,8 @@ mod terminal_mode_tests { "7", "--append-system-prompt", "extra rules", + "--tool-authority-json", + envelope, "do the thing", ]); let Some(Commands::Exec(args)) = cli.command else { @@ -13515,9 +13604,20 @@ mod terminal_mode_tests { ); assert_eq!(args.max_turns, Some(7)); assert_eq!(args.append_system_prompt.as_deref(), Some("extra rules")); + assert_eq!(args.tool_authority_json.as_deref(), Some(envelope)); assert_eq!(args.prompt, vec!["do the thing"]); } + #[test] + fn fleet_tool_authority_cannot_cross_an_exec_resume_boundary() { + assert!(validate_exec_tool_authority_resume(None, true).is_ok()); + assert!(validate_exec_tool_authority_resume(Some("{}"), false).is_ok()); + let error = validate_exec_tool_authority_resume(Some("{}"), true) + .expect_err("authority must remain bound to its fresh Fleet launch") + .to_string(); + assert!(error.contains("cannot be combined with exec --resume")); + } + #[test] fn exec_auto_does_not_authorize_sandbox_elevation() { let cli = parse_cli(&["codewhale", "exec", "--auto", "run it"]); diff --git a/crates/tui/src/models_dev_live.rs b/crates/tui/src/models_dev_live.rs index aa6f980ec7..dc8aedea56 100644 --- a/crates/tui/src/models_dev_live.rs +++ b/crates/tui/src/models_dev_live.rs @@ -361,7 +361,10 @@ fn publish_from_body( return Err(err); } let count = offerings.len(); - crate::provider_lake::set_live_snapshot(CatalogSnapshot { offerings }); + crate::provider_lake::set_live_snapshot( + CatalogSnapshot { offerings }, + crate::provider_lake::LiveSource::ModelsDev, + ); set_status(ModelsDevStatus { freshness, offering_count: count, diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 92b7ee7b30..d734a46eb3 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -938,9 +938,9 @@ fn apply_model_template( prompt.replace("{model_id}", model_id) } -const TOOL_TAXONOMY_DISCOVERY: &[&str] = &["grep_files", "file_search"]; -const TOOL_TAXONOMY_GIT: &[&str] = &["git_status", "git_diff"]; -const TOOL_TAXONOMY_VERIFICATION: &[&str] = &["run_tests", "run_verifiers"]; +const TOOL_TAXONOMY_DISCOVERY: &[&str] = &["File"]; +const TOOL_TAXONOMY_GIT: &[&str] = &["Git"]; +const TOOL_TAXONOMY_VERIFICATION: &[&str] = &["Run"]; /// Return the core tool taxonomy body **without** a markdown heading. /// Suitable for embedding under a mode-specific sub-heading in the @@ -958,9 +958,9 @@ pub(crate) fn render_core_tool_taxonomy_body(mode: AppMode) -> String { if let Some(verification) = render_core_tool_group(TOOL_TAXONOMY_VERIFICATION, &core_tools) { sentences.push(format!("Use {verification} for verification.")); } - if core_tools.contains(&"run_verifiers") { + if core_tools.contains(&"Run") { sentences.push( - "For long build/test/lint verifier suites, call `run_verifiers` with `background: true` or use `task_shell_start`, then poll while continuing independent inspection." + "For long build/test/lint suites, call `Run` with `action: \"verifiers\"` and `background: true`, then continue independent inspection." .to_string(), ); } @@ -977,7 +977,7 @@ fn core_taxonomy_tools_for_mode(mode: AppMode) -> Vec<&'static str> { core_tools .iter() .copied() - .filter(|tool| mode != AppMode::Plan || !matches!(*tool, "run_tests" | "run_verifiers")) + .filter(|tool| mode != AppMode::Plan || *tool != "Run") .collect() } @@ -1628,11 +1628,10 @@ mod tests { #[test] fn agent_mode_carries_execution_discipline_block() { for phrase in [ - "Execution Discipline", - "Do not end with \"I'll check\"", - "After spawning a background shell or sub-agent", - "", - "verify load-bearing claims", + "Execute the user's task autonomously", + "Keep `work_update` current", + "verify load-bearing child", + "never manufacture completion sentinels", ] { assert!( AGENT_MODE.contains(phrase), @@ -2039,16 +2038,8 @@ mod tests { #[test] fn execution_discipline_lives_in_agent_mode_after_core_constitution() { - let discipline_at = AGENT_MODE - .find("###### Execution Discipline") - .expect("Execution Discipline anchor present"); - let longevity_at = AGENT_MODE - .find("###### Session Longevity") - .expect("Session Longevity anchor present"); - assert!( - longevity_at < discipline_at, - "agent-mode execution discipline should follow the shorter mode/longevity delta" - ); + assert!(AGENT_MODE.contains("Execute the user's task autonomously")); + assert!(AGENT_MODE.contains("verify load-bearing child")); assert!( !BASE_PROMPT.contains("Execution Discipline") && !BASE_PROMPT.contains(""), @@ -2063,7 +2054,7 @@ mod tests { "Plan mode must tell the model to finish plans through update_plan" ); assert!( - PLAN_MODE.contains("accept / revise / exit prompt"), + PLAN_MODE.contains("accept, revise, or exit decision"), "Plan mode must explain why update_plan is the UI handoff signal" ); } @@ -2897,18 +2888,15 @@ mod tests { fn agent_mode_prompt_keeps_safety_invariants_after_compression() { let prompt = AGENT_MODE.replace("\r\n", "\n").replace('\r', "\n"); for must in [ - "Agent mode", + "autonomously", + "`File`", + "`Git`", + "`Run`", + "`Bash`", "work_update", "update_plan", - "workflow", - "request_user_input", - "agent(action=\"wait\")", - "type: \"explore\"", - "fork_context", - "rlm_open", - "Bare `/workflow` means orchestrate current work without re-asking", - "stop and synthesize", - "Do NOT explain, announce, or mention to the user that you are running in Agent mode", + "Delegate independent work", + "Do not announce the mode", ] { assert!( prompt.contains(must), @@ -2937,14 +2925,10 @@ mod tests { let word_count = normalized.split_whitespace().count(); let estimated_tokens = crate::compaction::estimate_text_tokens_conservative(&normalized); - // 2026-07-20: agent mode compressed (661 -> ~560 words) while - // preserving every tested approval, orchestration, subagent-brief, - // sentinel, workflow, and fork-context invariant. Keep the budget - // tight so procedural detail stays out of the system prefix. - let max_words = if name == "agent" { 580 } else { 350 }; - // The auto-fork contract sentence (#4599-adjacent cache work) - // costs a handful of tokens over the original compression target. - let max_tokens = if name == "agent" { 1360 } else { 700 }; + // 2026-07-21: mode deltas contain permissions and durable behavior + // only. Action recipes belong to the canonical tool schemas. + let max_words = 120; + let max_tokens = 320; assert!( word_count <= max_words, @@ -2990,7 +2974,7 @@ mod tests { } assert!( - PLAN_MODE.contains("All writes and patches are blocked"), + PLAN_MODE.contains("All writes, patches, shell commands"), "Plan may summarize the user-facing mode delta" ); assert!( @@ -3109,8 +3093,12 @@ mod tests { fn agent_mode_tool_guidance_avoids_defensive_tool_suppression() { let prompt = compose_prompt(Personality::Calm); assert!(!prompt.contains("Tool Selection Guide")); - assert!(AGENT_MODE.contains("Delegate only independent, fire-and-forget work")); - assert!(AGENT_MODE.contains("Use `rlm_open`")); + for tool in ["`File`", "`Git`", "`Run`", "`Bash`"] { + assert!(AGENT_MODE.contains(tool)); + } + for legacy in ["read_file", "git_status", "run_tests", "exec_shell"] { + assert!(!AGENT_MODE.contains(legacy)); + } assert!( !AGENT_MODE.contains("When NOT to use certain tools"), "agent mode should steer tool choice without training the model to avoid available tools" @@ -3228,19 +3216,7 @@ mod tests { #[test] fn rlm_specialty_tool_guidance_present() { - assert!(AGENT_MODE.contains("Large Context Tools")); - for tool in [ - "rlm_open", - "rlm_eval", - "rlm_configure", - "rlm_close", - "handle_read", - ] { - assert!( - AGENT_MODE.contains(tool), - "AGENT_MODE should mention `{tool}`" - ); - } + assert!(!AGENT_MODE.contains("Large Context Tools")); let descriptions = [ RlmTool::alias("rlm_open", "open", None) @@ -3301,17 +3277,8 @@ mod tests { #[test] fn prompt_uses_single_agent_and_rlm_surface() { - for tool in [ - "rlm_open", - "rlm_eval", - "rlm_configure", - "rlm_close", - "handle_read", - ] { - assert!( - AGENT_MODE.contains(tool), - "AGENT_MODE should mention tool `{tool}`" - ); + for tool in ["rlm_open", "rlm_eval", "rlm_configure", "rlm_close"] { + assert!(!AGENT_MODE.contains(tool)); } assert!(AGENT_MODE.contains("sub-agent")); } @@ -3319,104 +3286,60 @@ mod tests { #[test] fn prompt_documents_fork_context_prefix_cache_contract() { let source = include_str!("tools/subagent/mod.rs"); - for haystack in [AGENT_MODE, source] { - assert!(haystack.contains("fork_context")); - assert!(haystack.contains("byte-identical")); - assert!(haystack.contains("DeepSeek prefix-cache reuse")); - } - assert!(AGENT_MODE.contains("`fork_context` is auto-chosen")); - assert!(AGENT_MODE.contains("write-capable, isolated, or re-routed children start fresh")); + assert!(source.contains("fork_context")); + assert!(!AGENT_MODE.contains("fork_context")); } #[test] fn prompt_documents_explicit_subagent_model_strength() { - let prompt = AGENT_MODE; - assert!(prompt.contains("model_strength: \"same\"")); - assert!(prompt.contains("model_strength: \"faster\"")); - assert!(prompt.contains("type: \"explore\"")); - assert!(include_str!("tools/subagent/mod.rs").contains("Overrides model_strength")); - assert!(prompt.contains("defaults to `model_strength: \"faster\"`")); - assert!(prompt.contains("2-4 `type: \"explore\"` sub-agents")); - assert!(prompt.contains("self-reports")); + let source = include_str!("tools/subagent/mod.rs"); + assert!(source.contains("model_strength")); + assert!(!AGENT_MODE.contains("model_strength")); } #[test] fn prompt_documents_structured_subagent_briefs() { - let prompt = AGENT_MODE; - for field in [ - "Subagent Brief", - "QUESTION", - "SCOPE", - "ALREADY_KNOWN", - "EFFORT", - "STOP_CONDITION", - "VERDICT", - "EVIDENCE", - "GAPS", - "NEXT", + assert!(!AGENT_MODE.contains("Subagent Brief")); + for heading in [ + "### SUMMARY", + "### EVIDENCE", + "### CHANGES", + "### RISKS", + "### BLOCKERS", ] { - assert!( - prompt.contains(field), - "main prompt should include Subagent Brief field `{field}`" - ); + assert!(text::SUBAGENT_OUTPUT_FORMAT.contains(heading)); } - assert!(prompt.contains("Brief sub-agents with a compact Subagent Brief")); } #[test] fn prompt_bounds_explore_without_tiny_cap_for_implementers() { - let prompt = AGENT_MODE; - assert!(prompt.contains("Explore briefs default to `quick`")); - assert!(prompt.contains("read-only")); - assert!(prompt.contains("3-5 tool calls")); - assert!(prompt.contains("Review/verifier children stop after decisive evidence")); - assert!(prompt.contains("No fan-out without a fan-in owner")); + assert!(AGENT_MODE.contains("Delegate independent work")); + assert!(!AGENT_MODE.contains("3-5 tool calls")); + assert!(!AGENT_MODE.contains("No fan-out without a fan-in owner")); } #[test] fn agent_mode_prompt_teaches_automatic_workflow_use() { - // #4125: parent decides Workflow without the user saying the word; - // indicates the shape and may ask setup questions before launch. - let prompt = AGENT_MODE; - for phrase in [ - "You decide when to use Workflow", - "need **not** say \"workflow\"", - "broad, independent, or staged", - "This looks set up for a Workflow", - "`request_user_input`", - "TUI question modal", - "Pass **paths**, not file contents", - "Prefer `responseSchema`", - "one compact summary", + for recipe in [ + "Workflow", + "responseSchema", + "request_user_input", + ".workflow.js", ] { - assert!( - prompt.contains(phrase), - "AGENT_MODE missing automatic-workflow phrase {phrase:?}" - ); + assert!(!AGENT_MODE.contains(recipe)); } - // Explicitly not the old opt-in-only framing. - assert!( - !prompt.contains("The `workflow` tool is opt-in"), - "AGENT_MODE must not describe Workflow as opt-in only" - ); } #[test] fn operate_mode_prompt_keeps_multitask_simple_and_async() { for phrase in [ - "ordinary user messages", - "Answer conversation", - "honor that request even for read-only work", - "silently collapse it into parent-local discovery", - "Use ordinary tools directly", - "same approval posture, sandbox, shell configuration", - "Prefer one or more `agent` workers", - "Delegation is not mandatory", - "background", - "Treat each queued user message as another task", - "Use `workflow` only when", - "scheduling emphasis, not tool authority", - "Keep internal mechanics internal", + "ordinary messages", + "small or tightly coupled tasks directly", + "dispatch workers", + "queued user messages", + "approval, sandbox, and repository policies", + "lifecycle claims exact", + "internal control-plane mechanics", ] { assert!( OPERATE_MODE.contains(phrase), @@ -3437,11 +3360,10 @@ mod tests { #[test] fn subagent_done_sentinel_section_present() { - assert!(AGENT_MODE.contains("")); - assert!(AGENT_MODE.contains("not user input")); - assert!(AGENT_MODE.contains("verify load-bearing claims")); - assert!(AGENT_MODE.contains("never generate fake sentinels")); - assert!(AGENT_MODE.contains("Do not tell the user they pasted sentinels")); + assert!(AGENT_MODE.contains("completion events as internal evidence")); + assert!(AGENT_MODE.contains("verify load-bearing child")); + assert!(AGENT_MODE.contains("never manufacture completion sentinels")); + assert!(!AGENT_MODE.contains("")); } #[test] @@ -3981,9 +3903,9 @@ mod tests { fn core_execution_profile_is_runtime_only() { for required in [ "repository instructions", - "smallest coherent change", - "relevant verification", - "typed outcome", + "inspect the narrow owner", + "verify it", + "Report changed files", ] { assert!(CORE_EXECUTION_PROFILE_PROMPT.contains(required)); } diff --git a/crates/tui/src/prompts/text.rs b/crates/tui/src/prompts/text.rs index 7f4cef0569..803634cea9 100644 --- a/crates/tui/src/prompts/text.rs +++ b/crates/tui/src/prompts/text.rs @@ -232,117 +232,42 @@ Your voice is warm, energetic, and playful. You're still precise — you just ha /// Agent mode (Act) delta. pub const AGENT_MODE: &str = r#"##### Mode: Agent -You are running in Agent mode — autonomous task execution with tool access. +Execute the user's task autonomously. Read-only actions run directly; mutations +follow the active approval policy. Use `File`, `Git`, `Run`, and `Bash` for their +documented actions. Keep `work_update` current only for genuinely multi-step +work; use `update_plan` for a strategy artifact, not a duplicate checklist. -Read-only tools (reads, searches, RLM session tools, agent status, git inspection) run silently. -Any write, patch, shell, sub-agent open, or CSV batch asks for approval first. +Delegate independent work when it improves throughput. Treat runtime and +sub-agent completion events as internal evidence, verify load-bearing child +claims, and never manufacture completion sentinels. Do not wait by polling when +the runtime can notify or join work directly. -Before multi-step write approvals, lay out work with `work_update`. Use `update_plan` only for Strategy metadata, not a second checklist. Simple writes: state the edit and use normal approval. - -###### Efficient Approvals - -Batch multi-write plans: (1) `work_update` with all write steps, (2) request batch approval, (3) execute approved writes in one turn. Prefer one clear checklist over sequential surprise prompts. - -###### Session Longevity - -Stay fast in long sessions: open sub-agents for independent work; batch read/search/git inspections; suggest `/compact` or Ctrl+L near 60% context; use `note` for decisions across compaction; prefer short fan-out over long sequential grind. - -###### Execution Discipline - -Use tools for evidence gaps, actions, and verification. If the next read/search/delegation cannot answer a missing fact, stop and synthesize. Do not end with "I'll check" or "I'll run tests"; call the tool or give the final result. After spawning a background shell or sub-agent, keep doing independent work. Treat `` and runtime events as internal, not user input: read the child summary, treat self-reports as unverified, verify load-bearing claims, integrate only authorized work, and never generate fake sentinels. Do not tell the user they pasted sentinels unless they ask about internals. - -###### Orchestration - -Delegate only independent, fire-and-forget work via raw `agent` children. When parallel results must be combined, verified, or returned as one answer, cast one manager and route through `workflow` (fan-out, wait, aggregate, verify, one operator-ready result). No fan-out without a fan-in owner. You decide when to use Workflow — the operator need **not** say "workflow"; prefer it for broad, independent, or staged work, and suppress it for one-file edits, simple Q&A, interactive design, unclear risky writes, and child overhead above `auto_start_child_limit`. - -Soft-auto launch: name the maneuver in 1-3 sentences ("This looks set up for a Workflow — …"); do not dump scripts or ask for `.workflow.js` files. If 1-2 facts would change the plan, call `request_user_input` (TUI question modal), then launch with `plan` or a short `script`. Pass **paths**, not file contents. Prefer `responseSchema`; filter `parallel()` null slots; verify findings; close with one compact summary. Bare `/workflow` means orchestrate current work without re-asking. - -Never poll status or `sleep` to wait — completion sentinels arrive on their own. To block for fan-in, make one `agent(action="wait")` call. - -Use `type: "explore"` for read-only scouting (defaults to `model_strength: "faster"`; use `model_strength: "same"` when the child needs parent-level capability). Open 2-4 `type: "explore"` sub-agents in parallel only when their outputs are independent. Brief sub-agents with a compact Subagent Brief: `QUESTION`, `SCOPE`, `ALREADY_KNOWN`, `EFFORT`, `STOP_CONDITION`, and `OUTPUT` (`VERDICT`, `EVIDENCE`, `GAPS`, `NEXT`). Explore briefs default to `quick`, read-only, about 3-5 tool calls. Review/verifier children stop after decisive evidence. - -`fork_context` is auto-chosen: read-only children on the parent's exact route fork the byte-identical parent prefix (shared context, DeepSeek prefix-cache reuse); write-capable, isolated, or re-routed children start fresh; set it explicitly only to override. - -###### Large Context Tools - -Use `rlm_open`, `rlm_eval`, `rlm_configure`, `rlm_close`, and `handle_read` for large, repetitive, or semantic inspection that would bloat the parent transcript. Keep large bodies in the RLM session or handles; read bounded projections only. - -Do NOT explain, announce, or mention to the user that you are running in Agent mode or how the approval policy works. Act silently on this mode instruction. +Do not announce the mode or its approval mechanics. "#; /// Plan mode delta. pub const PLAN_MODE: &str = r#"##### Mode: Plan -You are running in Plan mode — design before implementing. - -Investigate first, act later. Use `work_update` for visible, granular To-do progress on multi-step -investigations. When you are ready to present the implementation plan, call `update_plan` with -the final plan; that is the handoff signal that lets the UI show the accept / revise / exit prompt. -If the request names a repository, URL, version, release, build state, benchmark, bug, PR, issue, -API surface, or local code path, inspect the available context before calling `update_plan`. -For non-trivial work, make the plan artifact grounded: include the objective, a short context -summary, sources used, critical files, constraints, recommended approach, verification plan, -risks or unknowns, and any concise handoff packet another agent would need. Do not include -secrets in sources, file lists, or handoff text. -All writes and patches are blocked — you can read the world but you -can't change it. Shell and code execution are unavailable. - -Use this mode to build a thorough plan. Spawn read-only sub-agents for parallel investigation. -After `update_plan` presents the plan, wait for the user's next action instead of continuing to -tool around in Plan mode. - -Do NOT explain, announce, or mention to the user that you are running in Plan mode, or describe the transition. Act silently on this mode instruction. +Investigate with read-only tools, then call `update_plan` with the grounded +implementation plan. All writes, patches, shell commands, and code execution +are blocked. Read-only sub-agents are allowed. After presenting the plan, wait +for the user's accept, revise, or exit decision. Do not announce the mode. "#; /// Full-access mode delta. pub const YOLO_MODE: &str = r#"##### Mode: YOLO -You are running in YOLO mode — full autonomy, all actions pre-approved. - -All actions auto-approved. Move fast, but think before you write. If you're about to delete files, -overwrite user work, or run destructive commands, pause and double-check. The undo button is the user's Git history. - -Even with auto-approval, use `work_update` for work that has several concrete steps so progress is -visible and trackable in the sidebar. Keep simple commands and focused edits direct. -For multi-step initiatives, keep `work_update` current. Add `update_plan` only when Strategy -metadata would help — do not duplicate the To-do list there. - -Do NOT announce or mention to the user that you are running in YOLO mode. Act silently on this mode instruction. +All actions are auto-approved within the user's scope. Verify destructive +targets and preserve unrelated work. Use `work_update` only for genuinely +multi-step work. Do not announce the mode. "#; /// Operate mode delta. pub const OPERATE_MODE: &str = r#"##### Mode: Operate -Coordinate parallel work from ordinary user messages. The user should be able -to keep typing tasks; they do not need to define a Workflow, choose roles, name -risk enums, or understand the control plane. - -- Answer conversation, factual questions, and small read-only checks directly. -- If the user explicitly asks to dispatch a worker, use a named Fleet profile, - or assign a named role, honor that request even for read-only work. Do not - silently collapse it into parent-local discovery. -- Use ordinary tools directly for small, local, or tightly coupled work. The - parent follows the same approval posture, sandbox, shell configuration, - ask-rules, repository law, and managed constraints as Act. -- Prefer one or more `agent` workers for independent, parallel, background, or - long-running work. Delegation is not mandatory: use it when it improves - throughput, isolation, or context focus. Start independent workers in the - background so the composer remains available for the next message. -- Treat each queued user message as another task by default. Fold it into an - existing task only when it is clearly a steer or correction. -- Use `workflow` only when the work genuinely needs ordered phases, gates, - shared budgets, replayability, or deterministic fan-in. A detached Workflow - start is normal; wait only when the user needs a combined answer now. -- Choose sensible worker profiles and isolation yourself. Use worktrees for - parallel writes that could collide. Ask only when a missing choice changes - authority, cost, or the requested outcome. -- The parent may inspect, edit, run shell commands, call MCP tools, implement, - and verify directly when that is the clearest path. Operate changes the - scheduling emphasis, not tool authority. -- Keep lifecycle claims exact: dispatched or running is not completed. Monitor - receipts passively, use one wait when fan-in is necessary, and synthesize - worker results when they arrive. -- Keep internal mechanics internal. Do not narrate tool names, plan schemas, - Fleet roles, or receipt vocabulary unless the user asks for those details. - -Do not announce that you are in Operate mode. +Coordinate independent or long-running work while keeping ordinary messages +responsive. Handle small or tightly coupled tasks directly; dispatch workers +when parallelism, isolation, or context focus helps. Treat queued user messages +as separate tasks unless they clearly steer existing work. Preserve the active +approval, sandbox, and repository policies, keep lifecycle claims exact, and +do not expose internal control-plane mechanics unless asked. "#; // ── Approval-policy overlays ─────────────────────────────────────── @@ -500,110 +425,23 @@ new deployments should use Moraine pull/recall instead. /// tutorials remain outside the model-facing coding contract. pub const CORE_EXECUTION_PROFILE_PROMPT: &str = r#"## Core Execution -Use the smallest safe loop that can complete the request: - -1. Read applicable repository instructions before touching scoped files. -2. Inspect enough code and current behavior to identify the narrow owner. -3. Reproduce the problem when doing so is safe and proportionate. -4. Make the smallest coherent change; preserve unrelated work. -5. Run the most relevant verification, then inspect the resulting diff. -6. End with a typed outcome that names changed files, checks run, unresolved - risks, pending work, and the reason the run stopped. - -Do not create planning theater for a simple change. Use an active checklist -only when the work is genuinely multi-step. - -Never infer permission from urgency. Approval posture, sandbox policy, -repository law, network policy, and publication authority remain independent. +Read applicable repository instructions, inspect the narrow owner, make the smallest +coherent change, verify it, and inspect the diff. Preserve unrelated work. +Report changed files, checks, unresolved risks, and pending work. Never infer +permission from urgency; approval, sandbox, network, and publication authority +remain independent. "#; /// Sub-agent final-message output contract — injected into every sub-agent /// brief by the runner in `tools/subagent/mod.rs` so the parent's parser can /// rely on the summary line + `` sentinel. pub const SUBAGENT_OUTPUT_FORMAT: &str = r#"## Output contract (mandatory) -When you finish (success or blocked), your final assistant message MUST end with -the structured report below. Use these exact section headings as Markdown -H3s. Skip a section only when the rule under that heading explicitly allows -"omit" — never omit a heading without that escape, and never invent extra -sections. - -### SUMMARY -One paragraph. Plain prose. State what you did and the headline conclusion. No -hedging, no preamble. If you were blocked, say so on the first line. - -### EVIDENCE -Bullet list. Each bullet is one concrete artifact you observed: a file path -with a line range, a tool result key, a command + exit code, a search hit. Cite -only what you actually read or executed; do not paraphrase from memory. Format -file refs as `path/to/file.rs:120-145`. Omit this section only if the task was -purely generative and you observed nothing (rare). - -If you rely on a child sub-agent report, cite it as child-agent evidence: -include the child `agent_id` and the specific EVIDENCE line(s) the child -provided. Do not present child-agent findings as files or commands you -personally verified unless you directly read or ran them yourself. - -### CHANGES -Bullet list of every write you performed: files created, files edited, patches -applied, shell side effects (e.g. `cargo fmt --write`). Each bullet names the -path and one line about the edit. If you performed no writes, write the single -line "None." — do not delete the heading. - -### RISKS -Bullet list of correctness, security, performance, or scope risks you saw but -did not address (or addressed only partially). Each bullet: the risk, why it -matters, and one line on what would mitigate it. If you saw nothing -risk-worthy, write "None observed." — do not delete the heading. - -### BLOCKERS -Use this section only when you stopped without finishing the assigned task. -Each bullet: the blocker, the specific information or capability you would -need to proceed, and (if relevant) the most plausible 1–2 next steps the -parent could take. If you completed the task, write "None." — do not delete -the heading. - -## Stop condition - -Produce the structured report and stop. Do not propose follow-up tasks, do not -ask the parent what to do next, do not start a new line of investigation. The -parent will decide whether to spawn additional work based on your report. - -The single exception: if the assigned task is impossible to make progress on -without a clarification only the parent can provide, fill BLOCKERS with the -specific question and stop. - -## Tool-calling conventions - -The typed tool surface beats shell-outs every time — typed tools return -structured results, log cleanly in the parent's transcript, and respect the -workspace boundary. Reach for `exec_shell` only for things the typed tools do -not cover (build, test, format, lint, ad-hoc one-liners). - -- Read a file: `read_file` (NOT `exec_shell` with `cat`/`head`/`tail`). -- List a directory: `list_dir` (NOT `exec_shell` with `ls`). -- Search file contents: `grep_files` (NOT `exec_shell` with `rg`/`grep`). -- Find files by name: `file_search` (NOT `exec_shell` with `find`). -- Single search/replace edit in one file: `edit_file`. -- Multi-hunk or multi-file edits: `apply_patch` (NOT a sequence of - `edit_file` calls — patches are atomic and easier for the parent to audit). -- Brand-new file: `write_file` (NOT `apply_patch` against `/dev/null`). -- Inspect git state: `git_status` / `git_diff` / `git_log` / `git_show` / - `git_blame` (NOT `exec_shell` with `git`). -- Web lookup: `web_search` / `fetch_url` (NOT `exec_shell` with `curl`). -- Run tests / build / format / lint: `run_tests` when applicable, otherwise - `exec_shell` is correct. - -Always read a file with `read_file` before patching it. Patches written blind -almost always fail to apply. - -## Honesty rules - -- Use only the tools provided to you at runtime. If a tool you want is not - available, say so in BLOCKERS rather than working around it silently. -- Do not claim a write or a command you did not actually execute. The parent - audits the tool log against your CHANGES section. -- If a tool errored, surface the error in EVIDENCE; do not pretend it - succeeded. +End with these exact Markdown headings: `### SUMMARY`, `### EVIDENCE`, +`### CHANGES`, `### RISKS`, and `### BLOCKERS`. Keep each section compact. +Cite only files and commands you actually inspected, list every write, surface +tool errors, and distinguish child reports from evidence you verified. Write +`None.` where a section has no entries. If blocked, name the missing fact or +capability. Then stop. "#; // ── Legacy prompt constants (kept for backwards compatibility) ───── diff --git a/crates/tui/src/provider_lake.rs b/crates/tui/src/provider_lake.rs index 274dc0e881..c9dcc0e7a4 100644 --- a/crates/tui/src/provider_lake.rs +++ b/crates/tui/src/provider_lake.rs @@ -1,16 +1,17 @@ //! Configured provider/model lake facade (#3830, Wave 5b / #4188). //! //! Single seam over the Models.dev catalog layers and the configured-provider -//! predicate shared with `/provider`. Precedence is **live Models.dev > -//! bundled offline snapshot > legacy hardcoded fallback**. Pickers, hotbar -//! route slots, [`crate::model_inventory::ModelInventory`], slash completions, -//! and subagent validation should read model lists from here. +//! predicate shared with `/provider`. Precedence is **provider-scoped live > +//! live Models.dev > bundled offline snapshot > legacy hardcoded fallback**. +//! Pickers, hotbar route slots, [`crate::model_inventory::ModelInventory`], +//! slash completions, and subagent validation should read model lists from here. //! //! [`crate::config::model_completion_names_for_provider`] is retained only as a //! compatibility fallback for CodeWhale-only / local providers that Models.dev //! does not represent (and for unbundled gateways until the live catalog covers //! them). +use std::collections::BTreeMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, RwLock}; @@ -24,13 +25,87 @@ use crate::config::{ static BUNDLED_SNAPSHOT: std::sync::OnceLock = std::sync::OnceLock::new(); -/// Optional live Models.dev snapshot (#4187). When `None`, only the bundled -/// offline/stale fallback rows are visible. -static LIVE_SNAPSHOT: RwLock> = RwLock::new(None); +/// Source tag for live-catalog rows. Models.dev is a cross-provider catalog +/// that serves as the primary live layer; per-provider refreshes (e.g. +/// TelecomJS `/v1/models`) are a secondary layer that must coexist alongside +/// Models.dev rows without being wiped by a Models.dev refresh. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum LiveSource { + /// The cross-provider Models.dev catalog refresh. + ModelsDev, + /// A per-provider `/v1/models` catalog refresh (e.g. TelecomJS TokenHub). + PerProvider, +} + +/// Optional live catalog snapshot(s), source-scoped (#4188 race fix). +/// +/// Models.dev and every provider fetch maintain distinct partitions of live +/// rows. A Models.dev refresh replaces only Models.dev-sourced rows; a +/// per-provider merge adds/replaces only that provider's rows. This prevents a +/// later Models.dev `set_live_snapshot` from erasing TelecomJS rows and keeps +/// independent provider refreshes from erasing each other. +static LIVE_SNAPSHOT: RwLock = RwLock::new(LiveSnapshotPartitions { + models_dev: None, + per_provider: BTreeMap::new(), +}); + +/// Internal partition map: one Models.dev snapshot plus one snapshot per +/// provider-specific live fetch. +#[derive(Default)] +struct LiveSnapshotPartitions { + models_dev: Option, + per_provider: BTreeMap, +} + +impl LiveSnapshotPartitions { + /// Collect all live rows from every partition into a single flat snapshot. + fn flattened(&self) -> Option { + if self.models_dev.is_none() && self.per_provider.is_empty() { + return None; + } + + // Merge by (provider, wire_model_id); provider-scoped rows win on + // collision because they came from that gateway's own live endpoint. + let mut merged: BTreeMap<(String, String), CatalogOffering> = BTreeMap::new(); + if let Some(models_dev) = &self.models_dev { + for row in &models_dev.offerings { + merged.insert( + (row.provider.clone(), row.wire_model_id.clone()), + row.clone(), + ); + } + } + for provider_snapshot in self.per_provider.values() { + for row in &provider_snapshot.offerings { + merged.insert( + (row.provider.clone(), row.wire_model_id.clone()), + row.clone(), + ); + } + } + Some(CatalogSnapshot { + offerings: merged.into_values().collect(), + }) + } +} + +fn offerings_by_provider( + offerings: Vec, +) -> BTreeMap> { + let mut grouped = BTreeMap::new(); + for offering in offerings { + grouped + .entry(offering.provider.trim().to_ascii_lowercase()) + .or_insert_with(Vec::new) + .push(offering); + } + grouped +} /// Generation stamp for the live snapshot. Bumped (under the `LIVE_SNAPSHOT` -/// write lock) by [`set_live_snapshot`] / [`clear_live_snapshot`] so the -/// memoized merged snapshot below can detect staleness without re-merging. +/// write lock) by [`set_live_snapshot`], [`merge_live_offerings`], and +/// [`clear_live_snapshot`] so the memoized merged snapshot below can detect +/// staleness without re-merging. static LIVE_GENERATION: AtomicU64 = AtomicU64::new(0); /// Memoized result of [`merged_snapshot`], tagged with the `LIVE_GENERATION` @@ -68,34 +143,94 @@ fn apply_provider_model_cutlines(mut snapshot: CatalogSnapshot) -> CatalogSnapsh snapshot } -/// Set the live-catalog snapshot. Call this after a background refresh -/// succeeds; the lake merges live rows over bundled rows on the next read. -/// Stale or empty snapshots are harmless — a `None` just means "bundled only." -pub fn set_live_snapshot(snapshot: CatalogSnapshot) { +/// Set the live-catalog snapshot for a given source (#4188 race fix). +/// +/// Source-scoped: a Models.dev refresh replaces only Models.dev-sourced rows; +/// a per-provider refresh replaces only the layers for providers represented +/// in that snapshot. Other providers and sources are preserved. This +/// eliminates the race where a Models.dev `set_live_snapshot` would erase +/// TelecomJS rows merged earlier. +pub fn set_live_snapshot(snapshot: CatalogSnapshot, source: LiveSource) { if let Ok(mut guard) = LIVE_SNAPSHOT.write() { - *guard = Some(apply_provider_model_cutlines(snapshot)); + let snapshot = apply_provider_model_cutlines(snapshot); + let changed = match source { + LiveSource::ModelsDev => { + guard.models_dev = Some(snapshot); + true + } + LiveSource::PerProvider => { + let grouped = offerings_by_provider(snapshot.offerings); + let changed = !grouped.is_empty(); + for (provider, offerings) in grouped { + guard + .per_provider + .insert(provider, CatalogSnapshot { offerings }); + } + changed + } + }; // Invalidate the memoized merged snapshot while still holding the // write lock so no reader can cache the old merge against the new // generation. - LIVE_GENERATION.fetch_add(1, Ordering::SeqCst); + if changed { + LIVE_GENERATION.fetch_add(1, Ordering::SeqCst); + } } } -/// Clear the live snapshot (e.g. on cache eviction or shutdown). +/// Clear all live snapshots (both Models.dev and per-provider partitions). +/// Used by tests and shutdown paths that need a full reset. +#[allow(dead_code)] pub fn clear_live_snapshot() { if let Ok(mut guard) = LIVE_SNAPSHOT.write() { - *guard = None; + guard.models_dev = None; + guard.per_provider.clear(); + LIVE_GENERATION.fetch_add(1, Ordering::SeqCst); + } +} + +/// Merge additional live offerings into provider-scoped live partitions (#4188). +/// +/// Unlike [`set_live_snapshot`] for `LiveSource::PerProvider` (which replaces +/// each represented provider's partition), this merges new rows by +/// `(provider, wire_model_id)` identity within that provider's partition, +/// preserving every other provider and the Models.dev partition. This is used +/// by provider catalog refreshes (e.g. TelecomJS `/v1/models`) that need to +/// coexist with the cross-provider Models.dev live layer. +pub fn merge_live_offerings(new_offerings: Vec) { + if new_offerings.is_empty() { + return; + } + if let Ok(mut guard) = LIVE_SNAPSHOT.write() { + for (provider, new_rows) in offerings_by_provider(new_offerings) { + let existing = guard.per_provider.remove(&provider).unwrap_or_default(); + let mut merged: BTreeMap<(String, String), CatalogOffering> = BTreeMap::new(); + for row in existing.offerings { + merged.insert((row.provider.clone(), row.wire_model_id.clone()), row); + } + for row in new_rows { + merged.insert((row.provider.clone(), row.wire_model_id.clone()), row); + } + guard.per_provider.insert( + provider, + CatalogSnapshot { + offerings: merged.into_values().collect(), + }, + ); + } LIVE_GENERATION.fetch_add(1, Ordering::SeqCst); } } /// The merged catalog snapshot: live rows override bundled rows on /// `(provider, wire_model_id)` identity (#4188). When no live snapshot is -/// present, this is just the offline bundled snapshot. +/// present, this is just the offline bundled snapshot. Per-provider live rows +/// override Models.dev live rows on collision (gateway-specific wins over +/// cross-provider). /// -/// Memoized: the merge is recomputed only after [`set_live_snapshot`] / -/// [`clear_live_snapshot`] bump `LIVE_GENERATION`; every other call returns -/// the cached `Arc` (the picker calls this per row, so it must be cheap). +/// Memoized: the merge is recomputed only after a live-layer mutation bumps +/// `LIVE_GENERATION`; every other call returns the cached `Arc` (the picker +/// calls this per row, so it must be cheap). fn merged_snapshot() -> Arc { let generation = LIVE_GENERATION.load(Ordering::SeqCst); if let Ok(guard) = MERGED_CACHE.read() @@ -116,11 +251,13 @@ fn merged_snapshot() -> Arc { /// Uncached merge (see [`merged_snapshot`] for the caching seam). fn compute_merged_snapshot() -> CatalogSnapshot { - let live = LIVE_SNAPSHOT.read().ok().and_then(|guard| guard.clone()); + let live = LIVE_SNAPSHOT + .read() + .ok() + .and_then(|guard| guard.flattened()); let merged = match live { None => bundled_snapshot().clone(), Some(live) => { - use std::collections::BTreeMap; let mut merged: BTreeMap<(String, String), CatalogOffering> = BTreeMap::new(); for row in &bundled_snapshot().offerings { merged.insert( @@ -283,6 +420,7 @@ pub fn all_catalog_providers() -> Vec { mod tests { use super::*; use crate::config::{DEFAULT_TOGETHER_FLASH_MODEL, DEFAULT_TOGETHER_MODEL}; + use codewhale_config::catalog::CatalogSource; use std::sync::{Mutex, MutexGuard, OnceLock}; /// Serialize tests that mutate the process-wide live snapshot. @@ -468,7 +606,7 @@ mod tests { ..Default::default() }], }; - set_live_snapshot(live); + set_live_snapshot(live, LiveSource::ModelsDev); let merged = all_catalog_models_for_provider(ApiProvider::Deepseek); assert!(merged.contains(&"deepseek-v4-synthetic".to_string())); // The bundled model is still present. @@ -501,14 +639,17 @@ mod tests { "probe model must not pre-exist in the bundled snapshot" ); - set_live_snapshot(CatalogSnapshot { - offerings: vec![CatalogOffering { - provider: "deepseek".to_string(), - wire_model_id: probe.to_string(), - endpoint_key: "chat".to_string(), - ..Default::default() - }], - }); + set_live_snapshot( + CatalogSnapshot { + offerings: vec![CatalogOffering { + provider: "deepseek".to_string(), + wire_model_id: probe.to_string(), + endpoint_key: "chat".to_string(), + ..Default::default() + }], + }, + LiveSource::ModelsDev, + ); let with_live = merged_snapshot(); assert!( !Arc::ptr_eq(&bundled_only, &with_live), @@ -561,7 +702,7 @@ mod tests { endpoint_key: "messages".to_string(), ..Default::default() })); - set_live_snapshot(CatalogSnapshot { offerings }); + set_live_snapshot(CatalogSnapshot { offerings }, LiveSource::ModelsDev); let models: std::collections::BTreeSet<_> = all_catalog_models_for_provider(ApiProvider::OpencodeGo) @@ -624,7 +765,7 @@ mod tests { }, ], }; - set_live_snapshot(live); + set_live_snapshot(live, LiveSource::ModelsDev); let merged = merged_snapshot(); let moonshot_rows = merged.offerings_for_provider("moonshot"); @@ -723,9 +864,12 @@ mod tests { .map(|r| r.provider.as_str()) .collect::>() ); - set_live_snapshot(CatalogSnapshot { - offerings: live_rows, - }); + set_live_snapshot( + CatalogSnapshot { + offerings: live_rows, + }, + LiveSource::ModelsDev, + ); let models = all_catalog_models_for_provider(ApiProvider::Moonshot); let kimi_count = models.iter().filter(|m| m.as_str() == "kimi-k2.5").count(); @@ -740,4 +884,420 @@ mod tests { ); clear_live_snapshot(); } + + // ── Source-scoped partition tests (#4188 race fix) ────────────────────── + + #[test] + fn provider_live_snapshots_are_scoped_per_provider() { + let _live = lock_live_snapshot(); + clear_live_snapshot(); + + set_live_snapshot( + CatalogSnapshot { + offerings: vec![CatalogOffering { + provider: "telecomjs".to_string(), + wire_model_id: "deepseek-v4-pro".to_string(), + endpoint_key: "chat".to_string(), + ..Default::default() + }], + }, + LiveSource::PerProvider, + ); + let telecom_only = merged_snapshot(); + assert_eq!(telecom_only.offerings_for_provider("telecomjs").len(), 1); + + set_live_snapshot( + CatalogSnapshot { + offerings: vec![CatalogOffering { + provider: "another-gateway".to_string(), + wire_model_id: "another-model".to_string(), + endpoint_key: "chat".to_string(), + ..Default::default() + }], + }, + LiveSource::PerProvider, + ); + + let merged = merged_snapshot(); + assert_eq!(merged.offerings_for_provider("telecomjs").len(), 1); + assert_eq!(merged.offerings_for_provider("another-gateway").len(), 1); + assert!( + !Arc::ptr_eq(&telecom_only, &merged), + "publishing a second provider must invalidate the cached merge" + ); + + clear_live_snapshot(); + } + + /// Models.dev→TelecomJS completion order: Models.dev sets its snapshot first, + /// then TelecomJS merges per-provider rows. Both sets must be present in the + /// final merged view. + #[test] + fn models_dev_first_then_telecomjs_both_preserved() { + let _live = lock_live_snapshot(); + clear_live_snapshot(); + + // 1) Models.dev publishes its cross-provider snapshot. + let models_dev_rows = vec![ + CatalogOffering { + provider: "deepseek".to_string(), + wire_model_id: "deepseek-chat".to_string(), + endpoint_key: "chat".to_string(), + family: Some("deepseek".to_string()), + source: CatalogSource::Live { + base_url_fingerprint: "modelsdev-fp".to_string(), + fetched_at: 1000, + }, + ..Default::default() + }, + CatalogOffering { + provider: "zai".to_string(), + wire_model_id: "glm-4".to_string(), + endpoint_key: "chat".to_string(), + family: Some("glm".to_string()), + source: CatalogSource::Live { + base_url_fingerprint: "modelsdev-fp".to_string(), + fetched_at: 1000, + }, + ..Default::default() + }, + ]; + set_live_snapshot( + CatalogSnapshot { + offerings: models_dev_rows, + }, + LiveSource::ModelsDev, + ); + let before_provider_refresh = merged_snapshot(); + assert!( + before_provider_refresh + .offerings_for_provider("telecomjs") + .is_empty() + ); + + // 2) TelecomJS merges its per-provider rows (after Models.dev completes). + let telecomjs_rows = vec![ + CatalogOffering { + provider: "telecomjs".to_string(), + wire_model_id: "deepseek-chat".to_string(), + endpoint_key: "chat".to_string(), + family: Some("deepseek".to_string()), + source: CatalogSource::Live { + base_url_fingerprint: "telecomjs-fp".to_string(), + fetched_at: 2000, + }, + ..Default::default() + }, + CatalogOffering { + provider: "telecomjs".to_string(), + wire_model_id: "glm-4".to_string(), + endpoint_key: "chat".to_string(), + family: Some("glm".to_string()), + source: CatalogSource::Live { + base_url_fingerprint: "telecomjs-fp".to_string(), + fetched_at: 2000, + }, + ..Default::default() + }, + ]; + merge_live_offerings(telecomjs_rows); + assert_eq!( + merged_snapshot().offerings_for_provider("telecomjs").len(), + 2, + "provider refresh should invalidate the cached Models.dev-only view" + ); + + // 3) Both sources' rows are present in the merged snapshot. + let merged = merged_snapshot(); + let deepseek_rows = merged.offerings_for_provider("deepseek"); + assert!( + deepseek_rows + .iter() + .any(|r| r.wire_model_id == "deepseek-chat"), + "Models.dev deepseek row missing: {deepseek_rows:?}" + ); + let zai_rows = merged.offerings_for_provider("zai"); + assert!( + zai_rows.iter().any(|r| r.wire_model_id == "glm-4"), + "Models.dev zai row missing: {zai_rows:?}" + ); + let telecomjs_rows_merged = merged.offerings_for_provider("telecomjs"); + assert_eq!( + telecomjs_rows_merged.len(), + 2, + "TelecomJS rows missing: {telecomjs_rows_merged:?}" + ); + assert!( + telecomjs_rows_merged + .iter() + .any(|r| r.wire_model_id == "deepseek-chat"), + "TelecomJS deepseek-chat row missing" + ); + assert!( + telecomjs_rows_merged + .iter() + .any(|r| r.wire_model_id == "glm-4"), + "TelecomJS glm-4 row missing" + ); + + clear_live_snapshot(); + } + + /// TelecomJS→Models.dev completion order: TelecomJS merges first, then + /// Models.dev replaces the cross-provider snapshot. TelecomJS rows must + /// survive the Models.dev refresh (they live in a separate partition). + #[test] + fn telecomjs_first_then_models_dev_both_preserved() { + let _live = lock_live_snapshot(); + clear_live_snapshot(); + + // 1) TelecomJS merges its per-provider rows first. + let telecomjs_rows = vec![ + CatalogOffering { + provider: "telecomjs".to_string(), + wire_model_id: "deepseek-chat".to_string(), + endpoint_key: "chat".to_string(), + family: Some("deepseek".to_string()), + source: CatalogSource::Live { + base_url_fingerprint: "telecomjs-fp".to_string(), + fetched_at: 2000, + }, + ..Default::default() + }, + CatalogOffering { + provider: "telecomjs".to_string(), + wire_model_id: "glm-4".to_string(), + endpoint_key: "chat".to_string(), + family: Some("glm".to_string()), + source: CatalogSource::Live { + base_url_fingerprint: "telecomjs-fp".to_string(), + fetched_at: 2000, + }, + ..Default::default() + }, + ]; + merge_live_offerings(telecomjs_rows); + assert_eq!( + merged_snapshot().offerings_for_provider("telecomjs").len(), + 2, + "provider rows should be visible before Models.dev completes" + ); + + // 2) Models.dev refreshes and replaces its cross-provider snapshot. + // Before the source-scoped fix, this would have wiped TelecomJS rows. + let models_dev_rows = vec![CatalogOffering { + provider: "deepseek".to_string(), + wire_model_id: "deepseek-chat".to_string(), + endpoint_key: "chat".to_string(), + family: Some("deepseek".to_string()), + source: CatalogSource::Live { + base_url_fingerprint: "modelsdev-fp".to_string(), + fetched_at: 3000, + }, + ..Default::default() + }]; + set_live_snapshot( + CatalogSnapshot { + offerings: models_dev_rows, + }, + LiveSource::ModelsDev, + ); + + // 3) Both sources' rows are present — TelecomJS rows were NOT erased. + let merged = merged_snapshot(); + let telecomjs_rows_merged = merged.offerings_for_provider("telecomjs"); + assert_eq!( + telecomjs_rows_merged.len(), + 2, + "TelecomJS rows were erased by Models.dev refresh: {telecomjs_rows_merged:?}" + ); + assert!( + telecomjs_rows_merged + .iter() + .any(|r| r.wire_model_id == "deepseek-chat"), + "TelecomJS deepseek-chat row erased" + ); + assert!( + telecomjs_rows_merged + .iter() + .any(|r| r.wire_model_id == "glm-4"), + "TelecomJS glm-4 row erased" + ); + let deepseek_rows = merged.offerings_for_provider("deepseek"); + assert!( + deepseek_rows + .iter() + .any(|r| r.wire_model_id == "deepseek-chat"), + "Models.dev deepseek row missing: {deepseek_rows:?}" + ); + + clear_live_snapshot(); + } + + /// Catalog refresh never deletes previously published rows: a Models.dev + /// refresh that adds new rows must preserve existing per-provider rows, + /// and a per-provider merge must preserve existing Models.dev rows. + #[test] + fn catalog_refresh_never_deletes_previously_published_rows() { + let _live = lock_live_snapshot(); + clear_live_snapshot(); + + // 1) Initial state: Models.dev publishes rows for deepseek + zai. + let initial_models_dev = vec![ + CatalogOffering { + provider: "deepseek".to_string(), + wire_model_id: "deepseek-chat".to_string(), + endpoint_key: "chat".to_string(), + source: CatalogSource::Live { + base_url_fingerprint: "modelsdev-fp".to_string(), + fetched_at: 1000, + }, + ..Default::default() + }, + CatalogOffering { + provider: "zai".to_string(), + wire_model_id: "glm-4".to_string(), + endpoint_key: "chat".to_string(), + source: CatalogSource::Live { + base_url_fingerprint: "modelsdev-fp".to_string(), + fetched_at: 1000, + }, + ..Default::default() + }, + ]; + set_live_snapshot( + CatalogSnapshot { + offerings: initial_models_dev, + }, + LiveSource::ModelsDev, + ); + + // 2) TelecomJS merges its rows. + let telecomjs_rows = vec![CatalogOffering { + provider: "telecomjs".to_string(), + wire_model_id: "deepseek-chat".to_string(), + endpoint_key: "chat".to_string(), + source: CatalogSource::Live { + base_url_fingerprint: "telecomjs-fp".to_string(), + fetched_at: 2000, + }, + ..Default::default() + }]; + merge_live_offerings(telecomjs_rows); + + // Record what we have before the second refresh. + let before_refresh = merged_snapshot(); + let before_providers: std::collections::BTreeSet<_> = before_refresh + .offerings + .iter() + .map(|r| (r.provider.clone(), r.wire_model_id.clone())) + .collect(); + assert!( + before_providers.contains(&("deepseek".to_string(), "deepseek-chat".to_string())), + "deepseek row should exist before refresh" + ); + assert!( + before_providers.contains(&("telecomjs".to_string(), "deepseek-chat".to_string())), + "telecomjs row should exist before refresh" + ); + + // 3) Models.dev refreshes again with an updated snapshot (adds a new row). + let updated_models_dev = vec![ + CatalogOffering { + provider: "deepseek".to_string(), + wire_model_id: "deepseek-chat".to_string(), + endpoint_key: "chat".to_string(), + source: CatalogSource::Live { + base_url_fingerprint: "modelsdev-fp".to_string(), + fetched_at: 3000, + }, + ..Default::default() + }, + CatalogOffering { + provider: "zai".to_string(), + wire_model_id: "glm-4".to_string(), + endpoint_key: "chat".to_string(), + source: CatalogSource::Live { + base_url_fingerprint: "modelsdev-fp".to_string(), + fetched_at: 3000, + }, + ..Default::default() + }, + // New row added by the refresh. + CatalogOffering { + provider: "moonshot".to_string(), + wire_model_id: "kimi-k2.5".to_string(), + endpoint_key: "chat".to_string(), + source: CatalogSource::Live { + base_url_fingerprint: "modelsdev-fp".to_string(), + fetched_at: 3000, + }, + ..Default::default() + }, + ]; + set_live_snapshot( + CatalogSnapshot { + offerings: updated_models_dev, + }, + LiveSource::ModelsDev, + ); + + // 4) The TelecomJS row is STILL present — it was not deleted. + let after_refresh = merged_snapshot(); + let after_telecomjs: Vec<_> = after_refresh + .offerings_for_provider("telecomjs") + .iter() + .map(|r| r.wire_model_id.clone()) + .collect(); + assert!( + after_telecomjs.iter().any(|id| id == "deepseek-chat"), + "TelecomJS row was deleted by Models.dev refresh! Remaining: {after_telecomjs:?}" + ); + + // 5) New Models.dev row is also present. + let after_moonshot: Vec<_> = after_refresh + .offerings_for_provider("moonshot") + .iter() + .map(|r| r.wire_model_id.clone()) + .collect(); + assert!( + after_moonshot.iter().any(|id| id == "kimi-k2.5"), + "New Models.dev moonshot row missing: {after_moonshot:?}" + ); + + // 6) Also verify: a per-provider merge does not delete Models.dev rows. + let extra_telecomjs = vec![CatalogOffering { + provider: "telecomjs".to_string(), + wire_model_id: "glm-4".to_string(), + endpoint_key: "chat".to_string(), + source: CatalogSource::Live { + base_url_fingerprint: "telecomjs-fp".to_string(), + fetched_at: 4000, + }, + ..Default::default() + }]; + merge_live_offerings(extra_telecomjs); + + let final_merged = merged_snapshot(); + let final_deepseek: Vec<_> = final_merged + .offerings_for_provider("deepseek") + .iter() + .map(|r| r.wire_model_id.clone()) + .collect(); + assert!( + final_deepseek.iter().any(|id| id == "deepseek-chat"), + "Models.dev deepseek row was deleted by per-provider merge! Remaining: {final_deepseek:?}" + ); + let final_moonshot: Vec<_> = final_merged + .offerings_for_provider("moonshot") + .iter() + .map(|r| r.wire_model_id.clone()) + .collect(); + assert!( + final_moonshot.iter().any(|id| id == "kimi-k2.5"), + "Models.dev moonshot row was deleted by per-provider merge! Remaining: {final_moonshot:?}" + ); + + clear_live_snapshot(); + } } diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 09ecc39956..79cbc73ec9 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -3415,6 +3415,7 @@ struct GuiConfigResponse { subagents_max_depth: u32, show_thinking: bool, show_tool_details: bool, + inline_diffs: String, locale: String, max_history: usize, prefer_external_pdftotext: bool, @@ -3507,6 +3508,7 @@ async fn get_config( subagents_max_depth: config.subagent_max_spawn_depth(), show_thinking: settings.show_thinking, show_tool_details: settings.show_tool_details, + inline_diffs: settings.inline_diffs.clone(), locale: settings.locale.clone(), max_history: settings.max_input_history, prefer_external_pdftotext: settings.prefer_external_pdftotext, @@ -3614,6 +3616,7 @@ async fn set_config( | "auto_compact" | "show_thinking" | "show_tool_details" + | "inline_diffs" | "calm_mode" | "prefer_external_pdftotext" | "workspace_follow_symlinks" @@ -3720,7 +3723,7 @@ async fn set_config( } _ => { return Err(ApiError::bad_request(format!( - "Unknown config key '{key}'. Supported keys: model, default_model, reasoning_effort, approval_mode, base_url, provider, provider_url, cost_currency, default_mode, auto_compact, allow_shell, mcp_config_path, show_thinking, show_tool_details, locale, max_history, calm_mode, prefer_external_pdftotext, workspace_follow_symlinks, subagents_enabled, subagents_max_depth, sandbox_mode, strict_tool_mode, memory_enabled, search_provider, prompt_suggestion" + "Unknown config key '{key}'. Supported keys: model, default_model, reasoning_effort, approval_mode, base_url, provider, provider_url, cost_currency, default_mode, auto_compact, allow_shell, mcp_config_path, show_thinking, show_tool_details, inline_diffs, locale, max_history, calm_mode, prefer_external_pdftotext, workspace_follow_symlinks, subagents_enabled, subagents_max_depth, sandbox_mode, strict_tool_mode, memory_enabled, search_provider, prompt_suggestion" ))); } }; diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index 32472555df..c77fed6336 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -1611,13 +1611,13 @@ async fn workspace_and_automation_endpoints_work() -> Result<()> { #[tokio::test] async fn fleet_status_runtime_api_exposes_state_and_actions() -> Result<()> { - use crate::tools::subagent::{AgentWorkerSpec, AgentWorkerToolProfile, SubAgentType}; - use crate::worker_profile::WorkerRuntimeProfile; - let root = std::env::temp_dir().join(format!("codewhale-fleet-api-{}", Uuid::new_v4())); let workspace = root.join("workspace"); fs::create_dir_all(&workspace)?; - let manager = FleetManager::open(&workspace)?; + let sub_agent_manager = runtime_api_sub_agent_manager(&workspace, 2); + let manager = FleetManager::open(&workspace)? + .with_sub_agent_manager(sub_agent_manager.clone()) + .with_session_model(DEFAULT_TEXT_MODEL); let task = codewhale_protocol::fleet::FleetTaskSpec { id: "task-a".to_string(), name: "Task A".to_string(), @@ -1626,7 +1626,7 @@ async fn fleet_status_runtime_api_exposes_state_and_actions() -> Result<()> { instructions: "Stay running for inspection.".to_string(), worker: Some(codewhale_protocol::fleet::FleetTaskWorkerProfile { agent_profile: None, - role: Some("status-reviewer".to_string()), + role: Some("reviewer".to_string()), loadout: None, model_class: None, model: None, @@ -1660,30 +1660,6 @@ async fn fleet_status_runtime_api_exposes_state_and_actions() -> Result<()> { let fake_codewhale = write_fake_fleet_binary(&root, &restarted_marker)?; let worker_id = report.worker_ids[0].clone(); let sessions_dir = root.join("sessions"); - let sub_agent_manager = runtime_api_sub_agent_manager(&workspace, 2); - { - let mut guard = sub_agent_manager.write().await; - guard.register_worker(AgentWorkerSpec { - worker_id: worker_id.clone(), - run_id: report.run_id.0.clone(), - parent_run_id: None, - session_name: Some("runtime-api-fleet-worker".to_string()), - objective: "Inspect fleet status through Runtime API".to_string(), - role: Some("status-reviewer".to_string()), - agent_type: SubAgentType::Review, - model: "auto".to_string(), - workspace: workspace.clone(), - git_branch: None, - context_mode: "fresh".to_string(), - fork_context: false, - tool_profile: AgentWorkerToolProfile::Explicit(vec!["rg".to_string()]), - runtime_profile: WorkerRuntimeProfile::for_role(SubAgentType::Review), - max_steps: 8, - spawn_depth: 0, - max_spawn_depth: codewhale_config::DEFAULT_SPAWN_DEPTH, - launch_manifest: None, - }); - } let Some((addr, _runtime_threads, handle)) = spawn_test_server_with_root_token_mobile_workspace_and_subagents( root.clone(), @@ -1721,7 +1697,7 @@ async fn fleet_status_runtime_api_exposes_state_and_actions() -> Result<()> { worker["objective"], "Inspect fleet status through Runtime API" ); - assert_eq!(worker["role"], "status-reviewer"); + assert_eq!(worker["role"], "reviewer"); assert_eq!(worker["host"], "local"); assert_eq!(worker["artifacts"][0]["kind"], "log"); assert_eq!(worker["runtime_state"]["agent_status"], "starting"); diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index a2f5e3c0d1..8f93aee64b 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -5801,7 +5801,7 @@ impl RuntimeThreadManager { ) .await?; } - EngineEvent::AgentList { agents } => { + EngineEvent::AgentList { agents, .. } => { let running = agents .iter() .filter(|agent| matches!(agent.status, SubAgentStatus::Running)) diff --git a/crates/tui/src/sandbox/backend.rs b/crates/tui/src/sandbox/backend.rs index 96fec4cd63..6c09b4b1c3 100644 --- a/crates/tui/src/sandbox/backend.rs +++ b/crates/tui/src/sandbox/backend.rs @@ -2,7 +2,7 @@ //! //! External sandbox backends route shell command execution to a remote service //! (e.g. Alibaba OpenSandbox) instead of spawning a local process. This is -//! complementary to the OS-level sandbox module (Seatbelt / Landlock / Windows) +//! complementary to the OS-level sandbox module (Seatbelt / opt-in bubblewrap) //! — the external backend *replaces* local execution entirely when configured. use std::collections::HashMap; diff --git a/crates/tui/src/sandbox/bwrap.rs b/crates/tui/src/sandbox/bwrap.rs index 1db43b0e6c..89800305f8 100644 --- a/crates/tui/src/sandbox/bwrap.rs +++ b/crates/tui/src/sandbox/bwrap.rs @@ -6,21 +6,22 @@ //! //! # How it works //! -//! When `/usr/bin/bwrap` is present AND the config key `[sandbox] prefer_bwrap` -//! is set to `true`, exec_shell commands are routed through bwrap instead of -//! relying solely on Landlock. The bwrap invocation looks like: +//! When `/usr/bin/bwrap` is executable AND the top-level config key +//! `prefer_bwrap` is set to `true`, exec_shell commands are routed through +//! bwrap. The bwrap invocation looks like: //! //! ```text //! bwrap \ +//! --unshare-all \ //! --ro-bind / / \ -//! --bind \ +//! --bind \ //! --chdir \ -//! --unshare-all \ //! -- //! ``` //! //! This creates a read-only view of the entire filesystem with write access -//! limited to the working directory. +//! limited to the policy-derived writable roots. Policies that allow network +//! access add `--share-net` after `--unshare-all`. //! //! # Important //! @@ -30,7 +31,16 @@ //! - Fedora: `dnf install bubblewrap` //! - Arch: `pacman -S bubblewrap` //! -//! If bwrap is not installed, we fall back to Landlock. +//! If bwrap is not executable, Codewhale reports no Linux OS sandbox and runs +//! the command without an OS wrapper. It never labels that fallback as +//! sandboxed. + +#[cfg(target_os = "linux")] +use super::policy::WritableRoot; +#[cfg(target_os = "linux")] +use std::collections::BTreeSet; +#[cfg(target_os = "linux")] +use std::path::{Path, PathBuf}; /// Canonical path to the bubblewrap binary. #[cfg(target_os = "linux")] @@ -39,7 +49,15 @@ pub const BWRAP_PATH: &str = "/usr/bin/bwrap"; /// Check if bubblewrap is installed and executable. #[cfg(target_os = "linux")] pub fn is_available() -> bool { - std::path::Path::new(BWRAP_PATH).exists() + is_executable(std::path::Path::new(BWRAP_PATH)) +} + +#[cfg(target_os = "linux")] +fn is_executable(path: &std::path::Path) -> bool { + use std::os::unix::fs::PermissionsExt; + + std::fs::metadata(path) + .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) } #[cfg(not(target_os = "linux"))] @@ -52,41 +70,66 @@ pub fn is_available() -> bool { /// The returned command vector is suitable for use as `ExecEnv.command` — /// it replaces the normal program+args with a bwrap invocation that sets /// up a read-only root filesystem with write access only to the specified -/// working directory. +/// policy roots. /// /// # Arguments /// -/// - `cwd` — working directory that gets writable bind-mount +/// - `cwd` — working directory and sandbox chdir target /// - `program` — the program to run inside the container /// - `args` — arguments to pass to the program +/// - `writable_roots` — policy-derived directories to remount read-write +/// - `network_access` — whether to retain the caller's network namespace /// /// # Returns /// /// A `Vec` representing the full bwrap invocation. #[cfg(target_os = "linux")] -pub fn build_bwrap_command(cwd: &std::path::Path, program: &str, args: &[String]) -> Vec { - let mut cmd: Vec = Vec::with_capacity(10 + args.len()); +pub fn build_bwrap_command( + cwd: &std::path::Path, + program: &str, + args: &[String], + writable_roots: &[WritableRoot], + network_access: bool, +) -> Vec { + let (writable_mounts, read_only_mounts) = safe_mounts(writable_roots); + let mut cmd: Vec = + Vec::with_capacity(10 + args.len() + 3 * (writable_mounts.len() + read_only_mounts.len())); cmd.push(BWRAP_PATH.to_string()); + // Isolate every supported namespace by default. `--share-net` selectively + // retains only the network namespace when the resolved policy allows it. + cmd.push("--unshare-all".to_string()); + if network_access { + cmd.push("--share-net".to_string()); + } + // Read-only bind-mount the entire root filesystem. cmd.push("--ro-bind".to_string()); cmd.push("/".to_string()); cmd.push("/".to_string()); - // Bind-mount the working directory with read-write access. - let cwd_str = cwd.to_string_lossy().to_string(); - cmd.push("--bind".to_string()); - cmd.push(cwd_str.clone()); - cmd.push(cwd_str.clone()); + for root in writable_mounts { + let root = root.to_string_lossy().into_owned(); + cmd.push("--bind".to_string()); + cmd.push(root.clone()); + cmd.push(root); + } + + // Re-apply protected descendants after all writable parents so a broad + // writable root cannot make .codewhale/.deepseek exceptions writable. + for root in read_only_mounts { + let root = root.to_string_lossy().into_owned(); + cmd.push("--ro-bind".to_string()); + cmd.push(root.clone()); + cmd.push(root); + } // Change to the working directory inside the container. + let cwd_str = cwd.to_string_lossy().to_string(); cmd.push("--chdir".to_string()); cmd.push(cwd_str); - // Unshare all namespaces for maximum isolation. - cmd.push("--unshare-all".to_string()); - // Separator between bwrap args and the command to run. cmd.push("--".to_string()); @@ -97,6 +140,60 @@ pub fn build_bwrap_command(cwd: &std::path::Path, program: &str, args: &[String] cmd } +#[cfg(target_os = "linux")] +fn safe_mounts(writable_roots: &[WritableRoot]) -> (Vec, Vec) { + let mut writable = BTreeSet::new(); + let mut read_only = BTreeSet::new(); + + for root in writable_roots { + let Some(canonical_root) = safe_existing_directory(&root.root) else { + continue; + }; + writable.insert(canonical_root.clone()); + + for exception in &root.read_only_subpaths { + let Some(canonical_exception) = existing_directory(exception) else { + continue; + }; + if canonical_exception.starts_with(&canonical_root) { + read_only.insert(canonical_exception); + } + } + } + + ( + writable.into_iter().collect(), + read_only.into_iter().collect(), + ) +} + +#[cfg(target_os = "linux")] +fn safe_existing_directory(path: &Path) -> Option { + let canonical = existing_directory(path)?; + (canonical != Path::new("/")).then_some(canonical) +} + +#[cfg(target_os = "linux")] +fn existing_directory(path: &Path) -> Option { + let canonical = path.canonicalize().ok()?; + canonical.is_dir().then_some(canonical) +} + +/// Detect a failure attributable to the bubblewrap boundary. +#[cfg(target_os = "linux")] +pub fn detect_denial(exit_code: i32, stderr: &str) -> bool { + exit_code != 0 + && (stderr + .lines() + .any(|line| line.trim_start().starts_with("bwrap:")) + || stderr.contains("Read-only file system")) +} + +#[cfg(not(target_os = "linux"))] +pub fn detect_denial(_exit_code: i32, _stderr: &str) -> bool { + false +} + #[cfg(test)] mod tests { use super::*; @@ -109,8 +206,15 @@ mod tests { #[test] #[cfg(target_os = "linux")] fn test_build_bwrap_command_structure() { - let cwd = std::path::Path::new("/home/user/project"); - let cmd = build_bwrap_command(cwd, "sh", &["-c".to_string(), "echo hi".to_string()]); + let dir = tempfile::tempdir().expect("tempdir"); + let cwd = dir.path(); + let cmd = build_bwrap_command( + cwd, + "sh", + &["-c".to_string(), "echo hi".to_string()], + &[WritableRoot::new(cwd.to_path_buf())], + false, + ); // Should start with bwrap assert_eq!(cmd[0], "/usr/bin/bwrap"); @@ -121,9 +225,110 @@ mod tests { // Should have --chdir assert!(cmd.contains(&"--chdir".to_string())); + // Network stays isolated unless the policy explicitly allows it. + assert!(cmd.contains(&"--unshare-all".to_string())); + assert!(!cmd.contains(&"--share-net".to_string())); + // Should end with the command assert_eq!(cmd[cmd.len() - 1], "echo hi"); assert_eq!(cmd[cmd.len() - 2], "-c"); assert_eq!(cmd[cmd.len() - 3], "sh"); } + + #[test] + #[cfg(target_os = "linux")] + fn read_only_command_does_not_remount_the_working_directory_writable() { + let dir = tempfile::tempdir().expect("tempdir"); + let cwd = dir.path(); + let cmd = build_bwrap_command(cwd, "true", &[], &[], false); + + assert!(!cmd.iter().any(|arg| arg == "--bind")); + assert!(!cmd.iter().any(|arg| arg == "--share-net")); + assert!( + cmd.windows(2) + .any(|args| args[0] == "--chdir" && args[1] == cwd.to_string_lossy()) + ); + } + + #[test] + #[cfg(target_os = "linux")] + fn workspace_write_mounts_every_safe_root_and_protects_read_only_descendants() { + let dir = tempfile::tempdir().expect("tempdir"); + let workspace = dir.path().join("workspace"); + let extra = dir.path().join("extra"); + let protected = workspace.join(".codewhale"); + std::fs::create_dir_all(&protected).expect("protected directory"); + std::fs::create_dir_all(&extra).expect("extra directory"); + + let roots = vec![ + WritableRoot::with_exceptions(workspace.clone(), vec![protected.clone()]), + WritableRoot::new(extra.clone()), + WritableRoot::new(dir.path().join("missing")), + WritableRoot::new(PathBuf::from("/")), + ]; + let cmd = build_bwrap_command(&workspace, "true", &[], &roots, true); + + for root in [&workspace, &extra] { + let canonical = root.canonicalize().expect("canonical root"); + assert!(has_mount(&cmd, "--bind", &canonical)); + } + assert!(has_mount( + &cmd, + "--ro-bind", + &protected.canonicalize().expect("canonical protected path") + )); + assert!(!has_mount(&cmd, "--bind", Path::new("/"))); + assert!(!cmd.iter().any(|arg| arg.ends_with("/missing"))); + + let unshare = cmd + .iter() + .position(|arg| arg == "--unshare-all") + .expect("unshare all"); + let share = cmd + .iter() + .position(|arg| arg == "--share-net") + .expect("share net"); + assert!(share > unshare); + } + + #[cfg(target_os = "linux")] + fn has_mount(command: &[String], flag: &str, path: &Path) -> bool { + let path = path.to_string_lossy(); + command.windows(3).any(|args| { + args[0] == flag + && args[1].as_str() == path.as_ref() + && args[2].as_str() == path.as_ref() + }) + } + + #[test] + #[cfg(target_os = "linux")] + fn executable_probe_requires_a_regular_executable_file() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("bwrap"); + std::fs::write(&path, b"fixture").expect("write fixture"); + assert!(!is_executable(&path)); + + let mut permissions = std::fs::metadata(&path).expect("metadata").permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&path, permissions).expect("set executable bit"); + assert!(is_executable(&path)); + assert!(!is_executable(dir.path())); + } + + #[test] + fn denial_detection_requires_a_failed_sandbox_signal() { + assert!(!detect_denial(0, "bwrap: ignored on success")); + #[cfg(target_os = "linux")] + { + assert!(detect_denial(1, "bwrap: Creating new namespace failed")); + assert!(detect_denial(1, "Read-only file system")); + assert!(!detect_denial(1, "child output mentions bwrap: casually")); + assert!(!detect_denial(1, "Permission denied")); + assert!(!detect_denial(1, "Operation not permitted")); + assert!(!detect_denial(1, "ordinary command failure")); + } + } } diff --git a/crates/tui/src/sandbox/landlock.rs b/crates/tui/src/sandbox/landlock.rs index 4a083ea347..7db6808f3b 100644 --- a/crates/tui/src/sandbox/landlock.rs +++ b/crates/tui/src/sandbox/landlock.rs @@ -1,5 +1,8 @@ //! Linux Landlock sandbox implementation. //! +//! This module is dormant in v0.9.1: command execution does not call it. An +//! ABI probe succeeding therefore does not mean that a command is sandboxed. +//! //! Landlock is a security mechanism introduced in Linux kernel 5.13 that allows //! processes to restrict their own access rights. Unlike Seatbelt on macOS which //! uses an external sandbox-exec wrapper, Landlock applies restrictions directly diff --git a/crates/tui/src/sandbox/mod.rs b/crates/tui/src/sandbox/mod.rs index 6141af0c9f..0a6b680a7e 100644 --- a/crates/tui/src/sandbox/mod.rs +++ b/crates/tui/src/sandbox/mod.rs @@ -8,8 +8,10 @@ //! //! # Platform Support //! -//! - **macOS**: Uses Seatbelt (sandbox-exec) for mandatory access control -//! - **Linux**: Uses Landlock (kernel 5.13+) for filesystem access control +//! - **macOS**: Uses Seatbelt (`sandbox-exec`) when the runtime probe succeeds +//! - **Linux**: Uses bubblewrap only when the user opts in and `/usr/bin/bwrap` +//! is executable. Landlock and seccomp helpers are not wired into child +//! execution yet and therefore are not advertised. //! - **Windows**: No OS sandbox is advertised yet. The planned first helper //! contract is process-tree containment only via a Windows Job Object; it //! must not claim filesystem, network, registry, or AppContainer isolation. @@ -53,6 +55,15 @@ use std::time::Duration; pub use policy::SandboxPolicy; +/// Public OS-sandbox capability labels consumed by the website facts +/// generator. Keep this list limited to wrappers that the command execution +/// path can actually select and apply. +#[allow(dead_code)] // Parsed from source by web/scripts/facts-lib.mjs. +pub const PUBLIC_SANDBOX_BACKENDS: &[&str] = &[ + "seatbelt (macOS, when available)", + "bubblewrap (Linux, opt-in when installed)", +]; + /// Specification for a command to be executed, potentially within a sandbox. /// /// This struct captures all the information needed to execute a command: @@ -248,9 +259,9 @@ pub enum SandboxType { #[cfg(target_os = "macos")] MacosSeatbelt, - /// Linux Landlock sandboxing (kernel 5.13+). + /// Linux bubblewrap namespace sandboxing. #[cfg(all(target_os = "linux", not(target_env = "ohos")))] - LinuxLandlock, + LinuxBubblewrap, /// Windows process-containment helper. /// @@ -267,7 +278,7 @@ impl std::fmt::Display for SandboxType { #[cfg(target_os = "macos")] SandboxType::MacosSeatbelt => write!(f, "macos-seatbelt"), #[cfg(all(target_os = "linux", not(target_env = "ohos")))] - SandboxType::LinuxLandlock => write!(f, "linux-landlock"), + SandboxType::LinuxBubblewrap => write!(f, "linux-bwrap"), #[cfg(target_os = "windows")] SandboxType::Windows => write!(f, "windows-sandbox"), } @@ -324,6 +335,15 @@ impl ExecEnv { /// Detect what sandbox technology is available on the current platform. pub fn get_platform_sandbox() -> Option { + get_platform_sandbox_with_bwrap_preference(false) +} + +/// Detect the sandbox wrapper the configured command path can actually use. +/// +/// Linux bubblewrap is deliberately opt-in. A Landlock ABI probe alone does +/// not make commands sandboxed because Codewhale does not yet launch them +/// through a Landlock helper. +pub fn get_platform_sandbox_with_bwrap_preference(prefer_bwrap: bool) -> Option { #[cfg(target_os = "macos")] { if seatbelt::is_available() { @@ -333,11 +353,14 @@ pub fn get_platform_sandbox() -> Option { #[cfg(all(target_os = "linux", not(target_env = "ohos")))] { - if landlock::is_available() { - return Some(SandboxType::LinuxLandlock); + if prefer_bwrap && bwrap::is_available() { + return Some(SandboxType::LinuxBubblewrap); } } + #[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))] + let _ = prefer_bwrap; + #[cfg(target_os = "windows")] { if windows::is_available() { @@ -368,8 +391,8 @@ pub struct SandboxManager { #[allow(dead_code)] forced_sandbox: Option, - /// When true and bwrap is available on Linux, route commands through - /// bubblewrap instead of Landlock alone (#2184). + /// When true and bwrap is executable on Linux, route commands through + /// bubblewrap (#2184). prefer_bwrap: bool, } @@ -381,7 +404,7 @@ impl SandboxManager { /// Create a new `SandboxManager` with bwrap preference (#2184). /// - /// When `prefer_bwrap` is true and `/usr/bin/bwrap` is present on Linux, + /// When `prefer_bwrap` is true and `/usr/bin/bwrap` is executable on Linux, /// exec_shell commands will be routed through bubblewrap. pub fn with_bwrap_preference(prefer_bwrap: bool) -> Self { Self { @@ -393,6 +416,7 @@ impl SandboxManager { /// Set the bwrap preference (#2184). pub fn set_prefer_bwrap(&mut self, prefer: bool) { self.prefer_bwrap = prefer; + self.sandbox_available = None; } /// Check if sandboxing is available. @@ -401,11 +425,16 @@ impl SandboxManager { return available; } - let available = is_sandbox_available(); + let available = self.configured_sandbox().is_some(); self.sandbox_available = Some(available); available } + /// Return the wrapper this manager is configured and able to apply. + pub fn configured_sandbox(&self) -> Option { + get_platform_sandbox_with_bwrap_preference(self.prefer_bwrap) + } + /// Select the appropriate sandbox type for the given policy. pub fn select_sandbox(&self, policy: &SandboxPolicy) -> SandboxType { // If the policy doesn't want sandboxing, return None @@ -418,8 +447,7 @@ impl SandboxManager { return forced; } - // Use platform default - get_platform_sandbox().unwrap_or(SandboxType::None) + self.configured_sandbox().unwrap_or(SandboxType::None) } /// Transform a `CommandSpec` into a sandboxed `ExecEnv`. @@ -437,7 +465,7 @@ impl SandboxManager { SandboxType::MacosSeatbelt => Self::prepare_seatbelt(spec), #[cfg(all(target_os = "linux", not(target_env = "ohos")))] - SandboxType::LinuxLandlock => self.prepare_landlock(spec), + SandboxType::LinuxBubblewrap => Self::prepare_bwrap(spec), #[cfg(target_os = "windows")] SandboxType::Windows => Self::prepare_windows(spec), @@ -488,43 +516,27 @@ impl SandboxManager { } } - /// Prepare a Landlock-sandboxed execution environment (Linux). - /// - /// If `prefer_bwrap` is set and `/usr/bin/bwrap` is available, routes the - /// command through bubblewrap for stronger filesystem isolation (#2184). - /// Otherwise falls back to Landlock markers. + /// Prepare a bubblewrap-sandboxed execution environment (Linux). #[cfg(all(target_os = "linux", not(target_env = "ohos")))] - fn prepare_landlock(&self, spec: &CommandSpec) -> ExecEnv { - // Check if bwrap passthrough should be used (#2184). - if self.prefer_bwrap && bwrap::is_available() { - let command = bwrap::build_bwrap_command(&spec.cwd, &spec.program, &spec.args); - - let mut env = spec.env.clone(); - env.insert("DEEPSEEK_SANDBOX".to_string(), "bwrap".to_string()); - - return ExecEnv { - command, - cwd: spec.cwd.clone(), - env, - timeout: spec.timeout, - sandbox_type: SandboxType::LinuxLandlock, - policy: spec.sandbox_policy.clone(), - }; - } - - // Fall back to Landlock (marker only — full implementation needs a helper). - let mut command = vec![spec.program.clone()]; - command.extend(spec.args.clone()); + fn prepare_bwrap(spec: &CommandSpec) -> ExecEnv { + let writable_roots = spec.sandbox_policy.get_writable_roots(&spec.cwd); + let command = bwrap::build_bwrap_command( + &spec.cwd, + &spec.program, + &spec.args, + &writable_roots, + spec.sandbox_policy.has_network_access(), + ); let mut env = spec.env.clone(); - env.insert("DEEPSEEK_SANDBOX".to_string(), "landlock".to_string()); + env.insert("DEEPSEEK_SANDBOX".to_string(), "bwrap".to_string()); ExecEnv { command, cwd: spec.cwd.clone(), env, timeout: spec.timeout, - sandbox_type: SandboxType::LinuxLandlock, + sandbox_type: SandboxType::LinuxBubblewrap, policy: spec.sandbox_policy.clone(), } } @@ -578,7 +590,7 @@ impl SandboxManager { SandboxType::MacosSeatbelt => seatbelt::detect_denial(exit_code, stderr), #[cfg(all(target_os = "linux", not(target_env = "ohos")))] - SandboxType::LinuxLandlock => landlock::detect_denial(exit_code, stderr), + SandboxType::LinuxBubblewrap => bwrap::detect_denial(exit_code, stderr), #[cfg(target_os = "windows")] SandboxType::Windows => windows::detect_denial(exit_code, stderr), @@ -611,21 +623,18 @@ impl SandboxManager { } #[cfg(all(target_os = "linux", not(target_env = "ohos")))] - SandboxType::LinuxLandlock => { - // Seccomp patterns checked first because they are more specific (#2182). - if stderr.contains("Bad system call") - || stderr.contains("bad system call") - || stderr.contains("SIGSYS") - || stderr.contains("seccomp") + SandboxType::LinuxBubblewrap => { + if let Some(error) = stderr + .lines() + .map(str::trim_start) + .find(|line| line.starts_with("bwrap:")) { - "Seccomp blocked a disallowed system call (e.g., ptrace, mount, kexec)." - .to_string() - } else if stderr.contains("Permission denied") { - "Landlock blocked access. The command tried to access a restricted path." - .to_string() + format!("Bubblewrap could not create the sandbox: {}", error) + } else if stderr.contains("Read-only file system") { + "Bubblewrap blocked access outside the writable workspace view.".to_string() } else { format!( - "Landlock blocked operation: {}", + "Bubblewrap blocked operation: {}", stderr.lines().next().unwrap_or("unknown") ) } @@ -816,6 +825,9 @@ mod tests { #[cfg(target_os = "macos")] assert_eq!(format!("{}", SandboxType::MacosSeatbelt), "macos-seatbelt"); + + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] + assert_eq!(format!("{}", SandboxType::LinuxBubblewrap), "linux-bwrap"); } // ── Parity tests (#2187) ────────────────────────────────────────────── @@ -838,11 +850,20 @@ mod tests { #[test] #[cfg(all(target_os = "linux", not(target_env = "ohos")))] - fn test_parity_linux_landlock_detection_matches_availability() { - assert_eq!( - get_platform_sandbox(), - landlock::is_available().then_some(SandboxType::LinuxLandlock) - ); + fn linux_default_never_claims_marker_only_landlock() { + assert_eq!(get_platform_sandbox(), None); + assert_eq!(get_platform_sandbox_with_bwrap_preference(false), None); + } + + #[test] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] + fn linux_bwrap_selection_requires_opt_in_and_executable() { + let expected = bwrap::is_available().then_some(SandboxType::LinuxBubblewrap); + assert_eq!(get_platform_sandbox_with_bwrap_preference(true), expected); + + let manager = SandboxManager::with_bwrap_preference(true); + let selected = manager.select_sandbox(&SandboxPolicy::default()); + assert_eq!(selected, expected.unwrap_or(SandboxType::None)); } #[test] @@ -860,7 +881,7 @@ mod tests { )); #[cfg(all(target_os = "linux", not(target_env = "ohos")))] assert!(!SandboxManager::was_denied( - SandboxType::LinuxLandlock, + SandboxType::LinuxBubblewrap, 0, "" )); @@ -870,16 +891,16 @@ mod tests { #[test] #[cfg(all(target_os = "linux", not(target_env = "ohos")))] - fn test_parity_seccomp_sigsys_detected() { - assert!(SandboxManager::was_denied( - SandboxType::LinuxLandlock, - 31, - "" + fn bwrap_denial_is_not_inferred_from_seccomp_text() { + assert!(!SandboxManager::was_denied( + SandboxType::LinuxBubblewrap, + 1, + "Bad system call" )); assert!(SandboxManager::was_denied( - SandboxType::LinuxLandlock, + SandboxType::LinuxBubblewrap, 1, - "Bad system call" + "Read-only file system" )); } @@ -908,7 +929,8 @@ mod tests { #[cfg(all(target_os = "linux", not(target_env = "ohos")))] { let marker = env.env.get("DEEPSEEK_SANDBOX"); - assert!(marker.is_none_or(|v| v != "bwrap")); + assert!(marker.is_none()); + assert_eq!(env.sandbox_type, SandboxType::None); } let _ = env; } @@ -924,11 +946,90 @@ mod tests { if crate::sandbox::bwrap::is_available() { let marker = env.env.get("DEEPSEEK_SANDBOX"); assert_eq!(marker.map(String::as_str), Some("bwrap")); + assert_eq!(env.sandbox_type, SandboxType::LinuxBubblewrap); + assert_eq!(env.program(), bwrap::BWRAP_PATH); + } else { + assert_eq!(env.sandbox_type, SandboxType::None); + assert!(env.env.get("DEEPSEEK_SANDBOX").is_none()); } } let _ = env; } + #[test] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] + fn bwrap_read_only_policy_keeps_the_working_directory_read_only() { + let manager = SandboxManager { + forced_sandbox: Some(SandboxType::LinuxBubblewrap), + ..SandboxManager::default() + }; + let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5)) + .with_policy(SandboxPolicy::ReadOnly); + let env = manager.prepare(&spec); + + assert_eq!(env.sandbox_type, SandboxType::LinuxBubblewrap); + assert!(!env.command.iter().any(|arg| arg == "--bind")); + assert!(!env.command.iter().any(|arg| arg == "--share-net")); + } + + #[test] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] + fn bwrap_workspace_policy_maps_additional_roots_and_network_access() { + let dir = tempfile::tempdir().expect("tempdir"); + let workspace = dir.path().join("workspace"); + let extra = dir.path().join("extra"); + std::fs::create_dir_all(&workspace).expect("workspace"); + std::fs::create_dir_all(&extra).expect("extra"); + + let manager = SandboxManager { + forced_sandbox: Some(SandboxType::LinuxBubblewrap), + ..SandboxManager::default() + }; + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![extra.clone()], + network_access: true, + exclude_tmpdir: true, + exclude_slash_tmp: true, + }; + let spec = CommandSpec::shell("true", workspace.clone(), Duration::from_secs(5)) + .with_policy(policy); + let env = manager.prepare(&spec); + + for root in [workspace, extra] { + let root = root + .canonicalize() + .expect("writable root") + .to_string_lossy() + .into_owned(); + assert!(env.command.windows(3).any(|args| args[0] == "--bind" + && args[1].as_str() == root.as_str() + && args[2].as_str() == root.as_str())); + } + assert!(env.command.iter().any(|arg| arg == "--share-net")); + } + + #[test] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] + fn full_access_and_external_policies_bypass_forced_bwrap() { + let manager = SandboxManager { + forced_sandbox: Some(SandboxType::LinuxBubblewrap), + ..SandboxManager::default() + }; + + for policy in [ + SandboxPolicy::DangerFullAccess, + SandboxPolicy::ExternalSandbox { + network_access: false, + }, + ] { + let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5)) + .with_policy(policy); + let env = manager.prepare(&spec); + assert_eq!(env.sandbox_type, SandboxType::None); + assert_ne!(env.program(), bwrap::BWRAP_PATH); + } + } + #[test] fn test_parity_exec_env_for_all_policies() { let manager = SandboxManager::new(); diff --git a/crates/tui/src/sandbox/policy.rs b/crates/tui/src/sandbox/policy.rs index 5919221820..cf1fc30284 100644 --- a/crates/tui/src/sandbox/policy.rs +++ b/crates/tui/src/sandbox/policy.rs @@ -369,9 +369,9 @@ impl WritableRoot { /// Unified trait for platform-specific sandbox executors (#2186). /// -/// Each platform module (seatbelt, landlock, windows) provides an -/// implementation of this trait. The `SandboxManager` dispatches through -/// the trait instead of calling platform-specific functions directly. +/// Platform implementations can use this trait to convert a policy into +/// wrapper-specific rules. The current `SandboxManager` command path does not +/// dispatch through this trait yet. pub trait SandboxExecutor { /// Prepare a sandboxed execution environment from a command spec. /// diff --git a/crates/tui/src/sandbox/process_hardening.rs b/crates/tui/src/sandbox/process_hardening.rs index aa90dc078c..43f0fcbadf 100644 --- a/crates/tui/src/sandbox/process_hardening.rs +++ b/crates/tui/src/sandbox/process_hardening.rs @@ -1,9 +1,10 @@ //! Process hardening for Linux sandbox defense-in-depth (#2183). //! //! This module applies kernel-level restrictions to the codewhale-tui process -//! itself. Unlike Landlock/seccomp which restrict child processes spawned for -//! shell commands, these hardening measures protect the *parent* TUI process -//! from information leaks and privilege-escalation vectors. +//! itself. These hardening measures protect the *parent* TUI process and its +//! descendants from information leaks and privilege-escalation vectors; they +//! are not a filesystem or network sandbox for child commands. The Landlock +//! and seccomp source modules are not wired into child execution yet. //! //! # Ordering constraints //! @@ -42,8 +43,9 @@ /// # Panics /// /// Does NOT panic. Failures are logged via `tracing::warn` because the -/// hardening is defense-in-depth — the sandbox still protects child processes -/// even if these prctls fail (e.g., in a container where some are restricted). +/// hardening is defense-in-depth. A failure does not abort startup or change +/// whether a separately configured Seatbelt/bubblewrap command wrapper is +/// available. pub fn apply_process_hardening() { #[cfg(all(target_os = "linux", not(target_env = "ohos")))] { diff --git a/crates/tui/src/sandbox/seccomp.rs b/crates/tui/src/sandbox/seccomp.rs index b384ed8c80..7295cbd141 100644 --- a/crates/tui/src/sandbox/seccomp.rs +++ b/crates/tui/src/sandbox/seccomp.rs @@ -1,11 +1,14 @@ //! Linux seccomp (Secure Computing) filter layer (#2182). //! +//! This module is dormant in v0.9.1: command execution does not install this +//! filter. Kernel support therefore does not mean that a command is sandboxed. +//! //! Seccomp BPF (Berkeley Packet Filter) is a kernel facility that allows a //! process to restrict the system calls it (and its descendants) can make. -//! This module applies a seccomp filter on top of Landlock to provide a -//! second layer of defense — even if Landlock misbehaves or is configured -//! too permissively, the seccomp filter blocks entire *classes* of dangerous -//! syscalls like `ptrace`, `mount`, `kexec_load`, etc. +//! The implementation here is intended to apply a seccomp filter on top of +//! Landlock as a second layer of defense. Once it is wired, the filter can +//! block entire *classes* of dangerous syscalls like `ptrace`, `mount`, and +//! `kexec_load`. //! //! # Architecture //! diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index d42e58dd3a..7ef59b2a2e 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -18,6 +18,39 @@ use crate::tui::app::ReasoningEffort; const SETTINGS_FILE_NAME: &str = "settings.toml"; const TUI_PREFS_FILE_NAME: &str = "tui.toml"; +/// How successful structured file mutations are represented in the live +/// transcript. Exact evidence is retained for inspection in every mode. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum InlineDiffMode { + /// Show a bounded red/green unified diff plus semantic change statistics. + #[default] + Full, + /// Show only bounded semantic change statistics. + Summary, + /// Keep the calm File outcome row without any inline diff detail. + Off, +} + +impl InlineDiffMode { + #[must_use] + pub fn parse(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "summary" => Self::Summary, + "off" => Self::Off, + _ => Self::Full, + } + } + + #[must_use] + pub const fn as_setting(self) -> &'static str { + match self { + Self::Full => "full", + Self::Summary => "summary", + Self::Off => "off", + } + } +} + // ============================================================================ // TuiPrefs — ~/.codewhale/tui.toml // ============================================================================ @@ -257,6 +290,10 @@ pub struct Settings { pub show_thinking: bool, /// Show detailed tool output pub show_tool_details: bool, + /// Successful structured File mutation evidence: full, summary, or off. + /// This affects inline presentation only; exact evidence remains available + /// through the tool-details route in every mode. + pub inline_diffs: String, /// UI locale: auto, en, ja, zh-Hans, pt-BR, es-419 pub locale: String, /// Named UI theme. Accepts `"system"` (follow terminal background), @@ -411,6 +448,7 @@ impl Default for Settings { // never displace the actual conversation in the default TUI. show_thinking: false, show_tool_details: false, + inline_diffs: "full".to_string(), locale: "auto".to_string(), theme: "system".to_string(), background_color: None, @@ -474,6 +512,10 @@ fn normalize_work_surface_placement(value: &str) -> &'static str { } } +fn normalize_inline_diffs(value: &str) -> &'static str { + InlineDiffMode::parse(value).as_setting() +} + /// The `(key, value)` fields a named preset applies, or `None` for an unknown /// name. Single source of truth shared by [`Settings::apply_preset`] and the /// `/config preset` command so the bundle is never defined twice. @@ -609,6 +651,7 @@ impl Settings { s.ocean_treatment = normalize_ocean_treatment(&s.ocean_treatment).to_string(); s.work_surface_placement = normalize_work_surface_placement(&s.work_surface_placement).to_string(); + s.inline_diffs = normalize_inline_diffs(&s.inline_diffs).to_string(); s.synchronized_output = normalize_synchronized_output(&s.synchronized_output).to_string(); s.locale = normalize_configured_locale(&s.locale) @@ -857,6 +900,15 @@ impl Settings { "show_tool_details" | "tool_details" => { self.show_tool_details = parse_bool(value)?; } + "inline_diffs" | "inline_diff" | "diffs" => { + let normalized = value.trim().to_ascii_lowercase(); + if !matches!(normalized.as_str(), "full" | "summary" | "off") { + anyhow::bail!( + "Failed to update setting: invalid inline diff mode '{value}'. Expected: full, summary, or off." + ); + } + self.inline_diffs = normalized; + } "locale" | "language" => { let Some(locale) = normalize_configured_locale(value) else { anyhow::bail!( @@ -1104,6 +1156,7 @@ impl Settings { )); lines.push(format!(" show_thinking: {}", self.show_thinking)); lines.push(format!(" show_tool_details: {}", self.show_tool_details)); + lines.push(format!(" inline_diffs: {}", self.inline_diffs)); lines.push(format!(" locale: {}", self.locale)); lines.push(format!(" theme: {}", self.theme)); lines.push(format!( @@ -1234,6 +1287,10 @@ impl Settings { ), ("show_thinking", "Show model thinking: on/off"), ("show_tool_details", "Show detailed tool output: on/off"), + ( + "inline_diffs", + "Successful File mutation evidence: full/summary/off (exact detail is always retained)", + ), ( "base_url", "HTTP base URL for DeepSeek-compatible endpoints.", @@ -1786,6 +1843,30 @@ mod tests { assert_eq!(settings.work_surface_placement, "top"); } + #[test] + fn inline_diffs_default_full_and_persist_exactly_one_mode() { + let mut settings = Settings::default(); + assert_eq!(settings.inline_diffs, "full"); + assert_eq!( + InlineDiffMode::parse(&settings.inline_diffs), + InlineDiffMode::Full + ); + + for mode in ["summary", "off", "full"] { + settings.set("inline_diffs", mode).expect("valid mode"); + assert_eq!(settings.inline_diffs, mode); + let body = toml::to_string(&settings).expect("serialize settings"); + let restored: Settings = toml::from_str(&body).expect("restore settings"); + assert_eq!(restored.inline_diffs, mode); + } + + let error = settings + .set("inline_diffs", "compact") + .expect_err("unknown mode must not be guessed"); + assert!(error.to_string().contains("full, summary, or off")); + assert_eq!(settings.inline_diffs, "full"); + } + /// Explicit animated baseline for env-force tests (#4095 flipped defaults to calm). fn animated_settings() -> Settings { Settings { diff --git a/crates/tui/src/skills/audit.rs b/crates/tui/src/skills/audit.rs new file mode 100644 index 0000000000..5f811c7498 --- /dev/null +++ b/crates/tui/src/skills/audit.rs @@ -0,0 +1,1374 @@ +//! Bounded, read-only skill audit inventory. +//! +//! Separates "what is on disk" from runtime [`super::SkillRegistry`] merging. +//! Never executes skill bodies, never contacts the network, and never writes. + +use std::collections::{HashMap, HashSet}; +use std::fs::{self, File}; +use std::io::{Read, Take}; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use serde::Deserialize; + +use super::install::{INSTALLED_FROM_MARKER, TRUSTED_MARKER}; +use super::package_digest::{self, PackageDigestError}; +use super::roots::{ + SkillRootAccess, SkillRootCatalog, SkillRootDescriptor, SkillRootId, SkillRootKind, + safe_display_path, +}; +use super::system::is_exact_bundled_skill; +use super::{SkillRegistry, normalize_skill_name_for_lookup}; + +/// Max bytes of `SKILL.md` the auditor will read into memory. +pub const AUDIT_MAX_SKILL_MD_BYTES: u64 = 512 * 1024; +/// Max total package bytes considered for digest / integrity. +#[allow(dead_code)] // re-exported bound for callers / docs +pub const AUDIT_MAX_PACKAGE_BYTES: u64 = package_digest::PACKAGE_DIGEST_MAX_BYTES; +/// Max regular files included in a package digest walk. +#[allow(dead_code)] +pub const AUDIT_MAX_FILES: usize = package_digest::PACKAGE_DIGEST_MAX_FILES; +/// Max directory depth under a skill package (and under a root when locating packages). +pub const AUDIT_MAX_DEPTH: usize = package_digest::PACKAGE_DIGEST_MAX_DEPTH; + +/// Which roots the auditor visits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SkillAuditMode { + /// CodeWhale-owned project/global roots only. + OwnedOnly, + /// Owned + compatible roots (including `.codex/skills`). Does not change runtime. + Compatible, +} + +/// Stable identity for one on-disk skill copy. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AuditedSkillId { + pub root_id: SkillRootId, + pub relative_dir: PathBuf, + pub canonical_name: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SkillSourceKind { + CodeWhaleManaged, + CodeWhaleManual, + CompatibleExternal, + BuiltIn, + ReviewedPluginSnapshot, + RegistryCache, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DigestUnknownReason { + Unreadable, + SymlinkPresent, + EscapedRoot, + Cycle, + Oversized, + TooManyFiles, + TooDeep, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DigestState { + Known(String), + Unknown(DigestUnknownReason), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParserState { + Valid, + Warning(Vec), + Broken(String), + Oversized, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PrecedenceState { + Active, + ShadowedBy(AuditedSkillId), + InactiveSource, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IntegrityState { + Healthy, + LocalContentDrift, + BrokenManagedInstall, + LegacyMetadataUnknown, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] // NotApplicable reserved for non-filesystem rows in later stages +pub enum TrustState { + TrustedForDigest(String), + TrustStale, + LegacyAdvisory, + Untrusted, + NotApplicable, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] // Partial / NeedsSetup filled when #4407 readiness cache is wired +pub enum ReadinessState { + Ready, + Partial, + NeedsSetup, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] // Unknown reserved for unclassified logical sources +pub enum ProvenanceState { + Managed { + spec: Option, + safe_url: Option, + schema_version: Option, + }, + Manual, + External, + BuiltIn, + Plugin, + Cache, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SkillActionKind { + Install, + Import, + Update, + Remove, + Trust, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SkillAuditWarning { + Message(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuditedSkill { + pub id: AuditedSkillId, + pub name: String, + pub description: Option, + pub root: SkillRootDescriptor, + pub safe_display_path: String, + pub source_kind: SkillSourceKind, + pub parser: ParserState, + pub digest: DigestState, + pub provenance: ProvenanceState, + pub trust: TrustState, + pub readiness: ReadinessState, + pub precedence: PrecedenceState, + pub integrity: IntegrityState, + pub available_actions: Vec, + pub warnings: Vec, + /// Same canonical name + same digest as another copy. + pub exact_duplicate_of: Option, + /// Same canonical name + different digest. + pub conflicts_with: Vec, + /// External copy with no owned same-name skill — import candidate. + pub import_candidate: bool, + /// Package path left the declared skill root (symlink escape, etc.). + pub path_unsafe: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillAuditSnapshot { + pub scan_mode: SkillAuditMode, + pub roots: Vec, + pub skills: Vec, + pub generated_at: SystemTime, +} + +/// Optional readiness cache from Issue #4407. Missing → [`ReadinessState::Unknown`]. +pub trait SkillReadinessProvider { + fn readiness_for(&self, skill: &AuditedSkillId) -> Option; +} + +/// Scan skill roots into a full, unmerged inventory. +#[cfg(test)] +#[must_use] +pub fn scan( + workspace: &Path, + home: Option<&Path>, + mode: SkillAuditMode, + readiness: Option<&dyn SkillReadinessProvider>, +) -> SkillAuditSnapshot { + scan_with_configured(workspace, home, None, mode, readiness) +} + +/// Same as [`scan`] with an optional configured `skills_dir`. +#[must_use] +pub fn scan_with_configured( + workspace: &Path, + home: Option<&Path>, + configured_skills_dir: Option<&Path>, + mode: SkillAuditMode, + readiness: Option<&dyn SkillReadinessProvider>, +) -> SkillAuditSnapshot { + let catalog = SkillRootCatalog::build(workspace, home, configured_skills_dir); + let root_refs: Vec = match mode { + SkillAuditMode::OwnedOnly => catalog + .audit_owned_directories() + .into_iter() + .cloned() + .collect(), + SkillAuditMode::Compatible => catalog + .audit_compatible_directories() + .into_iter() + .cloned() + .collect(), + }; + + let mut skills = Vec::new(); + for root in &root_refs { + skills.extend(scan_root(root, workspace, home)); + } + + classify_cross_root(&mut skills); + for skill in &mut skills { + skill.readiness = readiness + .and_then(|p| p.readiness_for(&skill.id)) + .unwrap_or(ReadinessState::Unknown); + skill.available_actions = action_policy(skill); + } + + SkillAuditSnapshot { + scan_mode: mode, + roots: root_refs, + skills, + generated_at: SystemTime::now(), + } +} + +/// Compute available mutations for one audited row (UI and controller share this). +#[must_use] +pub fn action_policy(skill: &AuditedSkill) -> Vec { + if skill.path_unsafe { + return Vec::new(); + } + + match skill.source_kind { + SkillSourceKind::CodeWhaleManaged => { + let mut actions = Vec::new(); + if matches!(skill.parser, ParserState::Valid | ParserState::Warning(_)) + && !matches!(skill.integrity, IntegrityState::BrokenManagedInstall) + { + actions.push(SkillActionKind::Update); + } + actions.push(SkillActionKind::Remove); + if matches!( + skill.trust, + TrustState::Untrusted | TrustState::TrustStale | TrustState::LegacyAdvisory + ) && matches!(skill.digest, DigestState::Known(_)) + && matches!(skill.parser, ParserState::Valid | ParserState::Warning(_)) + { + actions.push(SkillActionKind::Trust); + } + actions + } + SkillSourceKind::CodeWhaleManual => Vec::new(), + SkillSourceKind::CompatibleExternal => { + // Import is offered for fresh candidates and for same-name owned + // peers (exact duplicate → AlreadyPresent, conflict → replace confirm). + // The mutation controller remains the authority on scope/conflict policy. + let importable = matches!(skill.parser, ParserState::Valid | ParserState::Warning(_)) + && matches!(skill.digest, DigestState::Known(_)) + && (skill.import_candidate + || skill.exact_duplicate_of.is_some() + || !skill.conflicts_with.is_empty()); + if importable { + vec![SkillActionKind::Import] + } else { + Vec::new() + } + } + SkillSourceKind::BuiltIn + | SkillSourceKind::ReviewedPluginSnapshot + | SkillSourceKind::RegistryCache => Vec::new(), + } +} + +// ── per-root scan ──────────────────────────────────────────────────────────── + +fn scan_root( + root: &SkillRootDescriptor, + workspace: &Path, + home: Option<&Path>, +) -> Vec { + let mut out = Vec::new(); + let Ok(canonical_root) = fs::canonicalize(&root.path) else { + return out; + }; + let mut visited = HashSet::new(); + let mut packages = Vec::new(); + find_skill_packages(&root.path, &canonical_root, 0, &mut visited, &mut packages); + + for package_dir in packages { + out.push(audit_package( + root, + &package_dir, + &canonical_root, + workspace, + home, + )); + } + out +} + +fn find_skill_packages( + dir: &Path, + canonical_root: &Path, + depth: usize, + visited: &mut HashSet, + out: &mut Vec, +) { + if depth > AUDIT_MAX_DEPTH { + return; + } + let Ok(meta) = fs::symlink_metadata(dir) else { + return; + }; + if meta.file_type().is_symlink() { + let Ok(canonical) = fs::canonicalize(dir) else { + return; + }; + if !canonical.starts_with(canonical_root) || !canonical.is_dir() { + return; + } + if !visited.insert(canonical) { + return; + } + } else if meta.is_dir() { + let Ok(canonical) = fs::canonicalize(dir) else { + return; + }; + if !visited.insert(canonical) { + return; + } + } else { + return; + } + + let skill_md = dir.join("SKILL.md"); + if skill_md.is_file() || fs::symlink_metadata(&skill_md).is_ok() { + out.push(dir.to_path_buf()); + return; // do not descend into a skill package + } + + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path + .file_name() + .and_then(|s| s.to_str()) + .is_some_and(|name| name.starts_with('.')) + { + continue; + } + let Ok(meta) = fs::symlink_metadata(&path) else { + continue; + }; + if meta.is_dir() || meta.file_type().is_symlink() { + find_skill_packages(&path, canonical_root, depth + 1, visited, out); + } + } +} + +fn audit_package( + root: &SkillRootDescriptor, + package_dir: &Path, + canonical_root: &Path, + workspace: &Path, + home: Option<&Path>, +) -> AuditedSkill { + let relative_dir = package_dir + .strip_prefix(&root.path) + .map(Path::to_path_buf) + .unwrap_or_else(|_| { + package_dir + .file_name() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) + }); + + let mut warnings = Vec::new(); + let skill_md = package_dir.join("SKILL.md"); + let (parser, name, description, skill_md_content) = parse_skill_md_bounded(&skill_md); + + let package = analyze_package(package_dir, canonical_root); + let path_unsafe = package.path_unsafe; + if path_unsafe { + warnings.push(SkillAuditWarning::Message( + "package contains a symlink that escapes the skill root or cycles".into(), + )); + } + for w in package.warnings { + warnings.push(SkillAuditWarning::Message(w)); + } + + let canonical_name = name + .as_deref() + .map(normalize_skill_name_for_lookup) + .unwrap_or_else(|| { + normalize_skill_name_for_lookup( + &relative_dir + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "skill".into()), + ) + }); + + let marker = read_installed_from(package_dir); + let trust = read_trust_state(package_dir, &package.digest); + let (source_kind, provenance, integrity) = classify_source( + root, + &canonical_name, + skill_md_content.as_deref(), + &marker, + &package.digest, + path_unsafe, + ); + + let display = format!( + "{}/{}", + safe_display_path(&root.path, Some(workspace), home), + relative_dir.display() + ) + .replace('\\', "/"); + + AuditedSkill { + id: AuditedSkillId { + root_id: root.id.clone(), + relative_dir, + canonical_name: canonical_name.clone(), + }, + name: canonical_name, + description, + root: root.clone(), + safe_display_path: display, + source_kind, + parser, + digest: package.digest, + provenance, + trust, + readiness: ReadinessState::Unknown, + precedence: if root.active_for_runtime { + PrecedenceState::Unknown // filled in classify_cross_root + } else { + PrecedenceState::InactiveSource + }, + integrity, + available_actions: Vec::new(), + warnings, + exact_duplicate_of: None, + conflicts_with: Vec::new(), + import_candidate: false, + path_unsafe, + } +} + +fn parse_skill_md_bounded( + path: &Path, +) -> (ParserState, Option, Option, Option) { + let meta = match fs::symlink_metadata(path) { + Ok(m) => m, + Err(err) => { + return ( + ParserState::Broken(format!("cannot stat SKILL.md: {err}")), + None, + None, + None, + ); + } + }; + if meta.file_type().is_symlink() { + return ( + ParserState::Broken("SKILL.md is a symlink".into()), + None, + None, + None, + ); + } + if meta.len() > AUDIT_MAX_SKILL_MD_BYTES { + return (ParserState::Oversized, None, None, None); + } + + let file = match File::open(path) { + Ok(f) => f, + Err(err) => { + return ( + ParserState::Broken(format!("cannot open SKILL.md: {err}")), + None, + None, + None, + ); + } + }; + let mut limited: Take = file.take(AUDIT_MAX_SKILL_MD_BYTES + 1); + let mut buf = Vec::new(); + if let Err(err) = limited.read_to_end(&mut buf) { + return ( + ParserState::Broken(format!("cannot read SKILL.md: {err}")), + None, + None, + None, + ); + } + if buf.len() as u64 > AUDIT_MAX_SKILL_MD_BYTES { + return (ParserState::Oversized, None, None, None); + } + let content = match String::from_utf8(buf) { + Ok(s) => s, + Err(_) => { + return ( + ParserState::Broken("SKILL.md is not valid UTF-8".into()), + None, + None, + None, + ); + } + }; + + match SkillRegistry::parse_skill(path, &content) { + Ok(skill) => { + let desc = if skill.description.is_empty() { + None + } else { + Some(truncate_desc(&skill.description)) + }; + let mut warnings = Vec::new(); + if skill.description.is_empty() { + warnings.push("missing description".into()); + } + let parser = if warnings.is_empty() { + ParserState::Valid + } else { + ParserState::Warning(warnings) + }; + (parser, Some(skill.name), desc, Some(content)) + } + Err(reason) => (ParserState::Broken(reason), None, None, Some(content)), + } +} + +fn truncate_desc(s: &str) -> String { + const MAX: usize = 280; + let count = s.chars().count(); + if count <= MAX { + s.to_string() + } else { + let truncated: String = s.chars().take(MAX.saturating_sub(1)).collect(); + format!("{truncated}…") + } +} + +struct PackageAnalysis { + digest: DigestState, + path_unsafe: bool, + warnings: Vec, +} + +/// Compute the bounded package content digest used by audit and mutation. +#[allow(dead_code)] // public wrapper; mutation uses package_digest directly +pub fn compute_package_digest(package_dir: &Path) -> Result { + package_digest::compute_package_digest(package_dir).map_err(digest_error_to_unknown) +} + +fn digest_error_to_unknown(err: PackageDigestError) -> DigestUnknownReason { + match err { + PackageDigestError::Unreadable => DigestUnknownReason::Unreadable, + PackageDigestError::SymlinkPresent => DigestUnknownReason::SymlinkPresent, + PackageDigestError::EscapedRoot => DigestUnknownReason::EscapedRoot, + PackageDigestError::Cycle => DigestUnknownReason::Cycle, + PackageDigestError::Oversized => DigestUnknownReason::Oversized, + PackageDigestError::TooManyFiles => DigestUnknownReason::TooManyFiles, + PackageDigestError::TooDeep => DigestUnknownReason::TooDeep, + } +} + +fn analyze_package(package_dir: &Path, _canonical_root: &Path) -> PackageAnalysis { + match package_digest::compute_package_digest(package_dir) { + Ok(digest) => PackageAnalysis { + digest: DigestState::Known(digest), + path_unsafe: false, + warnings: Vec::new(), + }, + Err(err) => { + let reason = digest_error_to_unknown(err.clone()); + let path_unsafe = matches!( + err, + PackageDigestError::SymlinkPresent + | PackageDigestError::EscapedRoot + | PackageDigestError::Cycle + ); + PackageAnalysis { + digest: DigestState::Unknown(reason), + path_unsafe, + warnings: vec![err.to_string()], + } + } + } +} + +// ── markers ────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] // optional v2 fields reserved for mutation receipts in #4651 stage 3 +struct InstalledFromFile { + #[serde(default)] + schema_version: Option, + #[serde(default)] + spec: Option, + #[serde(default)] + url: Option, + /// v1 field name. + #[serde(default)] + checksum: Option, + #[serde(default)] + source_checksum: Option, + #[serde(default)] + content_digest: Option, + #[serde(default)] + installed_name: Option, +} + +#[derive(Debug, Clone)] +enum MarkerParse { + Absent, + V1(InstalledFromFile), + V2(InstalledFromFile), + #[allow(dead_code)] // reason retained for future audit warning surfacing + Broken(String), +} + +fn read_installed_from(package_dir: &Path) -> MarkerParse { + let path = package_dir.join(INSTALLED_FROM_MARKER); + let meta = match fs::symlink_metadata(&path) { + Ok(m) => m, + Err(_) => return MarkerParse::Absent, + }; + if meta.file_type().is_symlink() { + return MarkerParse::Broken("symlink .installed-from".into()); + } + if !meta.is_file() { + return MarkerParse::Broken(".installed-from is not a regular file".into()); + } + let Ok(body) = fs::read_to_string(&path) else { + return MarkerParse::Broken("unreadable .installed-from".into()); + }; + let Ok(parsed) = serde_json::from_str::(&body) else { + return MarkerParse::Broken("malformed .installed-from".into()); + }; + match parsed.schema_version { + Some(v) if v >= 2 => MarkerParse::V2(parsed), + Some(_) | None => MarkerParse::V1(parsed), + } +} + +#[derive(Debug, Deserialize)] +struct TrustFileV2 { + #[serde(default)] + schema_version: Option, + #[serde(default)] + content_digest: Option, +} + +fn read_trust_state(package_dir: &Path, digest: &DigestState) -> TrustState { + let path = package_dir.join(TRUSTED_MARKER); + let meta = match fs::symlink_metadata(&path) { + Ok(m) => m, + Err(_) => return TrustState::Untrusted, + }; + if meta.file_type().is_symlink() { + // Do not follow symlink trust markers. + return TrustState::Unknown; + } + if !meta.is_file() { + return TrustState::Unknown; + } + let Ok(body) = fs::read_to_string(&path) else { + return TrustState::Unknown; + }; + if let Ok(parsed) = serde_json::from_str::(&body) + && parsed.schema_version == Some(2) + { + let Some(trusted_digest) = parsed.content_digest else { + return TrustState::Unknown; + }; + return match digest { + DigestState::Known(current) if current == &trusted_digest => { + TrustState::TrustedForDigest(trusted_digest) + } + DigestState::Known(_) => TrustState::TrustStale, + DigestState::Unknown(_) => TrustState::Unknown, + }; + } + TrustState::LegacyAdvisory +} + +fn sanitize_url_for_display(url: &str) -> String { + // Strip userinfo, query, and fragment before UI / receipts. + let without_fragment = url.split('#').next().unwrap_or(url); + let without_query = without_fragment + .split('?') + .next() + .unwrap_or(without_fragment); + if let Some(scheme_end) = without_query.find("://") { + let scheme = &without_query[..scheme_end]; + let rest = &without_query[scheme_end + 3..]; + if let Some(at) = rest.find('@') { + return format!("{scheme}://{}", &rest[at + 1..]); + } + } + without_query.to_string() +} + +fn classify_source( + root: &SkillRootDescriptor, + canonical_name: &str, + skill_md_content: Option<&str>, + marker: &MarkerParse, + digest: &DigestState, + _path_unsafe: bool, +) -> (SkillSourceKind, ProvenanceState, IntegrityState) { + if matches!(root.kind, SkillRootKind::RegistryCache) { + return ( + SkillSourceKind::RegistryCache, + ProvenanceState::Cache, + IntegrityState::Unknown, + ); + } + if matches!(root.kind, SkillRootKind::ReviewedPluginSnapshot) { + return ( + SkillSourceKind::ReviewedPluginSnapshot, + ProvenanceState::Plugin, + IntegrityState::Unknown, + ); + } + + if root.access != SkillRootAccess::WritableOwned { + return ( + SkillSourceKind::CompatibleExternal, + ProvenanceState::External, + IntegrityState::Unknown, + ); + } + + // Managed markers win over bundled-name heuristics so a registry install + // that reuses a bundled command name (e.g. `pdf`) stays Update/Remove/Trust + // capable. Exact shipped body without a marker is still BuiltIn. + match marker { + MarkerParse::V1(_) | MarkerParse::V2(_) | MarkerParse::Broken(_) => {} + MarkerParse::Absent => { + if let Some(content) = skill_md_content + && is_exact_bundled_skill(canonical_name, content) + { + return ( + SkillSourceKind::BuiltIn, + ProvenanceState::BuiltIn, + IntegrityState::Healthy, + ); + } + } + } + + match marker { + MarkerParse::Absent => ( + SkillSourceKind::CodeWhaleManual, + ProvenanceState::Manual, + IntegrityState::Unknown, + ), + MarkerParse::Broken(_) => ( + SkillSourceKind::CodeWhaleManaged, + ProvenanceState::Managed { + spec: None, + safe_url: None, + schema_version: None, + }, + IntegrityState::BrokenManagedInstall, + ), + MarkerParse::V1(m) => ( + SkillSourceKind::CodeWhaleManaged, + ProvenanceState::Managed { + spec: m.spec.clone(), + safe_url: m.url.as_deref().map(sanitize_url_for_display), + schema_version: m.schema_version.or(Some(1)), + }, + IntegrityState::LegacyMetadataUnknown, + ), + MarkerParse::V2(m) => { + let integrity = match (&m.content_digest, digest) { + (Some(expected), DigestState::Known(actual)) if expected == actual => { + IntegrityState::Healthy + } + (Some(_), DigestState::Known(_)) => IntegrityState::LocalContentDrift, + (None, _) => IntegrityState::Unknown, + (_, DigestState::Unknown(_)) => IntegrityState::Unknown, + }; + ( + SkillSourceKind::CodeWhaleManaged, + ProvenanceState::Managed { + spec: m.spec.clone(), + safe_url: m.url.as_deref().map(sanitize_url_for_display), + schema_version: m.schema_version, + }, + integrity, + ) + } + } +} + +// ── cross-root classification ──────────────────────────────────────────────── + +fn classify_cross_root(skills: &mut [AuditedSkill]) { + // Group by canonical name preserving first-seen order (catalog precedence). + let mut by_name: HashMap> = HashMap::new(); + for (idx, skill) in skills.iter().enumerate() { + by_name + .entry(skill.id.canonical_name.clone()) + .or_default() + .push(idx); + } + + let owned_names: HashSet = skills + .iter() + .filter(|s| s.root.is_writable_owned()) + .map(|s| s.id.canonical_name.clone()) + .collect(); + + for indices in by_name.values() { + if indices.is_empty() { + continue; + } + + // Runtime-active winners: among copies whose root is active_for_runtime, + // the earliest in catalog order wins. Audit-only roots stay InactiveSource. + let runtime_indices: Vec = indices + .iter() + .copied() + .filter(|&i| skills[i].root.active_for_runtime) + .collect(); + // Already in scan order which follows catalog precedence. + if let Some(&winner) = runtime_indices.first() { + let winner_id = skills[winner].id.clone(); + for &idx in &runtime_indices { + if idx == winner { + if !matches!(skills[idx].precedence, PrecedenceState::InactiveSource) { + skills[idx].precedence = PrecedenceState::Active; + } + } else { + skills[idx].precedence = PrecedenceState::ShadowedBy(winner_id.clone()); + } + } + } + + // Duplicate / conflict among all copies (including inactive). + let digests: Vec<(usize, Option)> = indices + .iter() + .map(|&i| { + let d = match &skills[i].digest { + DigestState::Known(s) => Some(s.clone()), + DigestState::Unknown(_) => None, + }; + (i, d) + }) + .collect(); + + for &(i, ref di) in &digests { + for &(j, ref dj) in &digests { + if i >= j { + continue; + } + match (di, dj) { + (Some(a), Some(b)) if a == b => { + let other = skills[j].id.clone(); + if skills[i].exact_duplicate_of.is_none() { + skills[i].exact_duplicate_of = Some(other); + } else { + let other = skills[i].id.clone(); + if skills[j].exact_duplicate_of.is_none() { + skills[j].exact_duplicate_of = Some(other); + } + } + } + (Some(_), Some(_)) => { + let id_j = skills[j].id.clone(); + let id_i = skills[i].id.clone(); + skills[i].conflicts_with.push(id_j); + skills[j].conflicts_with.push(id_i); + } + _ => {} + } + } + } + } + + for skill in skills.iter_mut() { + if skill.source_kind == SkillSourceKind::CompatibleExternal + && !owned_names.contains(&skill.id.canonical_name) + && matches!(skill.parser, ParserState::Valid | ParserState::Warning(_)) + && matches!(skill.digest, DigestState::Known(_)) + && !skill.path_unsafe + { + skill.import_candidate = true; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn write_skill(dir: &Path, name: &str, description: &str, body: &str) { + let skill_dir = dir.join(name); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + format!("---\nname: {name}\ndescription: {description}\n---\n{body}\n"), + ) + .unwrap(); + } + + #[test] + fn owned_only_skips_compatible_and_codex() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + write_skill( + &workspace.join(".codewhale").join("skills"), + "owned", + "owned skill", + "body", + ); + write_skill( + &workspace.join(".claude").join("skills"), + "claude", + "claude skill", + "body", + ); + write_skill( + &workspace.join(".codex").join("skills"), + "codex", + "codex skill", + "body", + ); + + let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None); + let names: Vec<_> = snap.skills.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, vec!["owned"]); + assert!(!snap.roots.iter().any(|r| matches!( + r.kind, + SkillRootKind::CompatibleProject(_) | SkillRootKind::CompatibleGlobal(_) + ))); + } + + #[test] + fn compatible_includes_codex_without_activating_runtime_precedence() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + write_skill( + &workspace.join(".codewhale").join("skills"), + "shared", + "owned", + "owned-body", + ); + write_skill( + &workspace.join(".codex").join("skills"), + "shared", + "codex", + "codex-body", + ); + + let snap = scan(&workspace, Some(&home), SkillAuditMode::Compatible, None); + assert_eq!(snap.skills.len(), 2); + let codex = snap + .skills + .iter() + .find(|s| { + matches!( + s.root.kind, + SkillRootKind::CompatibleProject(super::super::roots::CompatibleHarness::Codex) + ) + }) + .expect("codex copy"); + assert_eq!(codex.precedence, PrecedenceState::InactiveSource); + assert!(!codex.root.active_for_runtime); + + let owned = snap + .skills + .iter() + .find(|s| s.root.kind == SkillRootKind::CodeWhaleProject) + .expect("owned"); + assert_eq!(owned.precedence, PrecedenceState::Active); + } + + #[test] + fn detects_shadow_duplicate_and_conflict() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + + // Identical package content → exact duplicate (after shadowing). + let identical = "---\nname: shared\ndescription: same\n---\nbody\n"; + fs::create_dir_all(workspace.join(".agents").join("skills").join("shared")).unwrap(); + fs::write( + workspace + .join(".agents") + .join("skills") + .join("shared") + .join("SKILL.md"), + identical, + ) + .unwrap(); + fs::create_dir_all(workspace.join(".claude").join("skills").join("shared")).unwrap(); + fs::write( + workspace + .join(".claude") + .join("skills") + .join("shared") + .join("SKILL.md"), + identical, + ) + .unwrap(); + // Different content → conflict with the active copy. + write_skill( + &workspace.join(".cursor").join("skills"), + "shared", + "cursor conflict", + "different-body", + ); + + let snap = scan(&workspace, Some(&home), SkillAuditMode::Compatible, None); + let shared: Vec<_> = snap.skills.iter().filter(|s| s.name == "shared").collect(); + assert_eq!(shared.len(), 3); + assert!( + shared + .iter() + .any(|s| matches!(s.precedence, PrecedenceState::Active)) + ); + assert!( + shared + .iter() + .any(|s| matches!(s.precedence, PrecedenceState::ShadowedBy(_))) + ); + assert!(shared.iter().any(|s| s.exact_duplicate_of.is_some())); + assert!(shared.iter().any(|s| !s.conflicts_with.is_empty())); + } + + #[test] + fn external_without_owned_peer_is_import_candidate() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + fs::create_dir_all(workspace.join(".codewhale").join("skills")).unwrap(); + write_skill( + &workspace.join(".claude").join("skills"), + "from-claude", + "desc", + "body", + ); + + let snap = scan(&workspace, Some(&home), SkillAuditMode::Compatible, None); + let skill = snap + .skills + .iter() + .find(|s| s.name == "from-claude") + .expect("skill"); + assert!(skill.import_candidate); + assert_eq!(skill.available_actions, vec![SkillActionKind::Import]); + assert_eq!(skill.source_kind, SkillSourceKind::CompatibleExternal); + } + + #[test] + fn external_conflicting_with_owned_still_offers_import() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + write_skill( + &workspace.join(".codewhale").join("skills"), + "shared", + "desc", + "owned-body", + ); + write_skill( + &workspace.join(".claude").join("skills"), + "shared", + "desc", + "external-body", + ); + + let snap = scan(&workspace, Some(&home), SkillAuditMode::Compatible, None); + let external = snap + .skills + .iter() + .find(|s| s.name == "shared" && s.source_kind == SkillSourceKind::CompatibleExternal) + .expect("external"); + assert!(!external.import_candidate); + assert!(!external.conflicts_with.is_empty()); + assert_eq!(external.available_actions, vec![SkillActionKind::Import]); + } + + #[test] + fn v1_marker_is_legacy_integrity_and_managed_actions() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let root = workspace.join(".codewhale").join("skills"); + write_skill(&root, "managed", "desc", "body"); + fs::write( + root.join("managed").join(INSTALLED_FROM_MARKER), + r#"{"spec":"github:o/r","url":"https://user:pass@example.com/x?token=1#frag","checksum":"abc"}"#, + ) + .unwrap(); + + let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None); + let skill = &snap.skills[0]; + assert_eq!(skill.source_kind, SkillSourceKind::CodeWhaleManaged); + assert_eq!(skill.integrity, IntegrityState::LegacyMetadataUnknown); + assert!(skill.available_actions.contains(&SkillActionKind::Update)); + assert!(skill.available_actions.contains(&SkillActionKind::Remove)); + if let ProvenanceState::Managed { safe_url, .. } = &skill.provenance { + let url = safe_url.as_deref().unwrap(); + assert!(!url.contains("user:pass")); + assert!(!url.contains("token")); + assert!(!url.contains("frag")); + } else { + panic!("expected managed provenance"); + } + } + + #[test] + fn v2_marker_detects_healthy_and_drift() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let root = workspace.join(".codewhale").join("skills"); + write_skill(&root, "managed", "desc", "body"); + + // First scan to learn digest, then write matching v2 marker. + let preliminary = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None); + let DigestState::Known(digest) = &preliminary.skills[0].digest else { + panic!("expected known digest"); + }; + fs::write( + root.join("managed").join(INSTALLED_FROM_MARKER), + format!(r#"{{"schema_version":2,"spec":"github:o/r","content_digest":"{digest}"}}"#), + ) + .unwrap(); + let healthy = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None); + assert_eq!(healthy.skills[0].integrity, IntegrityState::Healthy); + + fs::write( + root.join("managed").join(INSTALLED_FROM_MARKER), + r#"{"schema_version":2,"spec":"github:o/r","content_digest":"deadbeef"}"#, + ) + .unwrap(); + let drift = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None); + assert_eq!(drift.skills[0].integrity, IntegrityState::LocalContentDrift); + } + + #[test] + fn legacy_trust_and_digest_bound_trust() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let root = workspace.join(".codewhale").join("skills"); + write_skill(&root, "managed", "desc", "body"); + fs::write( + root.join("managed").join(INSTALLED_FROM_MARKER), + r#"{"spec":"github:o/r","checksum":"x"}"#, + ) + .unwrap(); + fs::write(root.join("managed").join(TRUSTED_MARKER), "trusted\n").unwrap(); + + let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None); + assert_eq!(snap.skills[0].trust, TrustState::LegacyAdvisory); + + let DigestState::Known(digest) = &snap.skills[0].digest else { + panic!("digest"); + }; + fs::write( + root.join("managed").join(TRUSTED_MARKER), + format!(r#"{{"schema_version":2,"content_digest":"{digest}"}}"#), + ) + .unwrap(); + let trusted = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None); + assert!(matches!( + trusted.skills[0].trust, + TrustState::TrustedForDigest(_) + )); + + fs::write( + root.join("managed").join(TRUSTED_MARKER), + r#"{"schema_version":2,"content_digest":"stale"}"#, + ) + .unwrap(); + let stale = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None); + assert_eq!(stale.skills[0].trust, TrustState::TrustStale); + } + + #[test] + fn oversized_skill_md_is_fail_closed() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let skill_dir = workspace.join(".codewhale").join("skills").join("big"); + fs::create_dir_all(&skill_dir).unwrap(); + let huge = format!( + "---\nname: big\ndescription: x\n---\n{}", + "x".repeat(AUDIT_MAX_SKILL_MD_BYTES as usize + 64) + ); + fs::write(skill_dir.join("SKILL.md"), huge).unwrap(); + + let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None); + assert_eq!(snap.skills[0].parser, ParserState::Oversized); + assert!(snap.skills[0].available_actions.is_empty()); + } + + #[test] + fn readiness_missing_stays_unknown() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + write_skill(&workspace.join(".codewhale").join("skills"), "a", "d", "b"); + let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None); + assert_eq!(snap.skills[0].readiness, ReadinessState::Unknown); + } + + #[test] + fn bundled_name_alone_is_not_built_in() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + // `pdf` is a bundled name, but custom body must not classify as BuiltIn. + write_skill( + &workspace.join(".codewhale").join("skills"), + "pdf", + "user override", + "not-the-bundled-body", + ); + let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None); + assert_eq!(snap.skills[0].source_kind, SkillSourceKind::CodeWhaleManual); + assert!(snap.skills[0].available_actions.is_empty()); + } + + #[test] + fn managed_marker_wins_over_bundled_name() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let root = workspace.join(".codewhale").join("skills"); + // Bundled command name + different body + install marker → managed. + write_skill(&root, "pdf", "registry pdf", "community-body"); + fs::write( + root.join("pdf").join(INSTALLED_FROM_MARKER), + r#"{"spec":"github:o/pdf-skill","checksum":"abc"}"#, + ) + .unwrap(); + + let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None); + assert_eq!( + snap.skills[0].source_kind, + SkillSourceKind::CodeWhaleManaged + ); + assert!( + snap.skills[0] + .available_actions + .contains(&SkillActionKind::Update) + ); + assert!( + snap.skills[0] + .available_actions + .contains(&SkillActionKind::Remove) + ); + assert!( + snap.skills[0] + .available_actions + .contains(&SkillActionKind::Trust) + ); + } + + #[test] + fn exact_bundled_content_is_built_in() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let root = workspace.join(".codewhale").join("skills"); + fs::create_dir_all(root.join("pdf")).unwrap(); + fs::write( + root.join("pdf").join("SKILL.md"), + include_str!("../../assets/skills/pdf/SKILL.md"), + ) + .unwrap(); + + let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None); + assert_eq!(snap.skills[0].source_kind, SkillSourceKind::BuiltIn); + assert!(snap.skills[0].available_actions.is_empty()); + } + + #[cfg(unix)] + #[test] + fn symlink_installed_from_marker_is_fail_closed() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let root = workspace.join(".codewhale").join("skills"); + write_skill(&root, "managed", "desc", "body"); + let outside = tmp.path().join("outside-marker.json"); + fs::write(&outside, r#"{"spec":"github:o/r","checksum":"x"}"#).unwrap(); + std::os::unix::fs::symlink(&outside, root.join("managed").join(INSTALLED_FROM_MARKER)) + .unwrap(); + + let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None); + assert!(snap.skills[0].path_unsafe); + assert!(matches!( + snap.skills[0].digest, + DigestState::Unknown(DigestUnknownReason::SymlinkPresent) + )); + assert_eq!( + snap.skills[0].integrity, + IntegrityState::BrokenManagedInstall + ); + assert!(snap.skills[0].available_actions.is_empty()); + } + + #[test] + fn sanitize_url_strips_secrets() { + assert_eq!( + sanitize_url_for_display("https://user:pw@host/path?token=1#x"), + "https://host/path" + ); + } + + struct AlwaysReady; + impl SkillReadinessProvider for AlwaysReady { + fn readiness_for(&self, _: &AuditedSkillId) -> Option { + Some(ReadinessState::Ready) + } + } + + #[test] + fn readiness_provider_is_consulted_when_present() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + write_skill(&workspace.join(".codewhale").join("skills"), "a", "d", "b"); + let snap = scan( + &workspace, + Some(&home), + SkillAuditMode::OwnedOnly, + Some(&AlwaysReady), + ); + assert_eq!(snap.skills[0].readiness, ReadinessState::Ready); + } +} diff --git a/crates/tui/src/skills/install.rs b/crates/tui/src/skills/install.rs index 67d9f70fd5..7c5e1ff2e1 100644 --- a/crates/tui/src/skills/install.rs +++ b/crates/tui/src/skills/install.rs @@ -322,16 +322,17 @@ pub async fn install_with_registry( // Move the staged dir into its final location. If `update` is set and the // destination exists, replace it; otherwise reject. + // Keep any backup until content digest + marker write succeed so a failed + // finalize can restore the previous install. let final_path = skills_dir.join(&staged.skill_name); + let mut backup_path: Option = None; if final_path.exists() { if !update { // Clean up the staging dir before returning the error. let _ = fs::remove_dir_all(&staged.staged_path); return Err(InstallError::AlreadyInstalled(staged.skill_name).into()); } - // Best-effort backup-then-replace; on failure we restore the original. let backup = skills_dir.join(format!("{}.bak", staged.skill_name)); - // If a previous failed update left a stale `.bak/`, drop it. if backup.exists() { fs::remove_dir_all(&backup).ok(); } @@ -342,12 +343,10 @@ pub async fn install_with_registry( ) })?; if let Err(err) = fs::rename(&staged.staged_path, &final_path) { - // Roll back: restore the backup so the user isn't left with an - // empty skill directory. fs::rename(&backup, &final_path).ok(); return Err(err).context("failed to install staged skill"); } - fs::remove_dir_all(&backup).ok(); + backup_path = Some(backup); } else { if let Some(parent) = final_path.parent() { fs::create_dir_all(parent).with_context(|| { @@ -358,19 +357,37 @@ pub async fn install_with_registry( } // Write the marker last so a partial install never leaves a stale - // .installed-from on disk. - let marker_body = serde_json::json!({ - "spec": source_spec_string(&source), - "url": source_url, - "checksum": checksum, - }) - .to_string(); - fs::write(final_path.join(INSTALLED_FROM_MARKER), marker_body).with_context(|| { - format!( - "failed to write {} marker for skill {}", - INSTALLED_FROM_MARKER, staged.skill_name - ) - })?; + // .installed-from on disk. Prefer v2 with package content digest. + let spec = source_spec_string(&source); + let content_digest = match super::package_digest::compute_package_digest(&final_path) { + Ok(digest) => digest, + Err(err) => { + let _ = fs::remove_dir_all(&final_path); + if let Some(backup) = backup_path.take() { + let _ = fs::rename(&backup, &final_path); + } + return Err(anyhow::anyhow!( + "installed package failed content digest validation: {err}" + )); + } + }; + if let Err(err) = write_installed_from_v2( + &final_path, + &spec, + Some(&source_url), + &checksum, + &content_digest, + &staged.skill_name, + ) { + let _ = fs::remove_dir_all(&final_path); + if let Some(backup) = backup_path.take() { + let _ = fs::rename(&backup, &final_path); + } + return Err(err); + } + if let Some(backup) = backup_path { + fs::remove_dir_all(&backup).ok(); + } Ok(InstallOutcome::Installed(InstalledSkill { name: staged.skill_name, @@ -417,6 +434,13 @@ pub async fn update_with_registry( .with_context(|| format!("failed to read {}", marker_path.display()))?; let marker: InstalledFromMarker = serde_json::from_str(&marker_body) .with_context(|| format!("malformed {INSTALLED_FROM_MARKER} for {name}"))?; + if !is_registry_updatable_spec(&marker.spec) { + bail!( + "skill '{name}' was imported locally (spec '{}') and cannot be updated from a registry; \ + re-import or remove it first", + marker.spec + ); + } // Re-resolve the URL, taking the existing checksum as a short-circuit hint: // we still hit the network so the user gets a useful "no upstream change" @@ -434,14 +458,25 @@ pub async fn update_with_registry( }; let checksum = sha256_hex(&bytes); - if checksum == marker.checksum { + if checksum == marker.source_checksum() { return Ok(UpdateResult::NoChange); } // Bytes changed — fall back to the regular install path with `update = true` - // so we get the same atomic-replace semantics. + // so we get the same atomic-replace semantics. Content updates must not + // inherit a previous trust marker. + let trust_path = target.join(TRUSTED_MARKER); + let had_trust = trust_path.exists(); let outcome = install_with_registry(source, skills_dir, max_size, network, true, registry_url).await?; + match &outcome { + InstallOutcome::Installed(installed) => { + if had_trust { + let _ = fs::remove_file(installed.path.join(TRUSTED_MARKER)); + } + } + InstallOutcome::NeedsApproval(_) | InstallOutcome::NetworkDenied(_) => {} + } match outcome { InstallOutcome::Installed(installed) => Ok(UpdateResult::Updated(installed)), InstallOutcome::NeedsApproval(host) => Ok(UpdateResult::NeedsApproval(host)), @@ -467,12 +502,12 @@ pub fn uninstall(name: &str, skills_dir: &Path) -> Result<()> { Ok(()) } -/// Mark a community-installed skill as trusted. Currently a marker file only; -/// callers that wire tool execution against `/scripts/` consult the file -/// before invoking anything. No-op if already trusted. +/// Mark a community-installed skill as trusted, binding the marker to the +/// current package content digest (schema v2). /// /// Refuses to mark system skills (no `.installed-from`) so the bundled /// `skill-creator` doesn't accidentally inherit elevated tool privileges. +#[cfg(test)] pub fn trust(name: &str, skills_dir: &Path) -> Result<()> { let target = skill_target_path(name, skills_dir)?; if !target.exists() { @@ -482,14 +517,9 @@ pub fn trust(name: &str, skills_dir: &Path) -> Result<()> { if !target.join(INSTALLED_FROM_MARKER).exists() { return Err(InstallError::NotInstalledHere(name.to_string()).into()); } - let marker = target.join(TRUSTED_MARKER); - if !marker.exists() { - fs::write( - &marker, - "Skill scripts/ are user-trusted. Delete this file to revoke.\n", - ) - .with_context(|| format!("failed to write {}", marker.display()))?; - } + let content_digest = super::package_digest::compute_package_digest(&target) + .with_context(|| format!("cannot compute content digest for {}", target.display()))?; + write_trust_v2(&target, &content_digest)?; Ok(()) } @@ -836,8 +866,77 @@ async fn sync_one_skill( #[derive(Debug, Deserialize)] struct InstalledFromMarker { spec: String, + /// v1 download checksum field. #[serde(default)] checksum: String, + #[serde(default)] + source_checksum: Option, + #[serde(default)] + #[allow(dead_code)] + schema_version: Option, + #[serde(default)] + #[allow(dead_code)] + content_digest: Option, +} + +impl InstalledFromMarker { + fn source_checksum(&self) -> &str { + self.source_checksum + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or(self.checksum.as_str()) + } +} + +/// Remote/registry update is only valid for install specs that are not local imports. +#[must_use] +pub fn is_registry_updatable_spec(spec: &str) -> bool { + let spec = spec.trim(); + !spec.is_empty() && !spec.starts_with("import:") +} + +/// Write schema-v2 `.installed-from` metadata (last step of a successful install). +pub fn write_installed_from_v2( + skill_dir: &Path, + spec: &str, + url: Option<&str>, + source_checksum: &str, + content_digest: &str, + installed_name: &str, +) -> Result<()> { + let body = serde_json::json!({ + "schema_version": 2, + "spec": spec, + "url": url, + "source_checksum": source_checksum, + "content_digest": content_digest, + "installed_name": installed_name, + "registry_version": null, + }); + fs::write(skill_dir.join(INSTALLED_FROM_MARKER), body.to_string()).with_context(|| { + format!( + "failed to write {} for {}", + INSTALLED_FROM_MARKER, + skill_dir.display() + ) + })?; + Ok(()) +} + +/// Write schema-v2 `.trusted` bound to a package content digest. +pub fn write_trust_v2(skill_dir: &Path, content_digest: &str) -> Result<()> { + let body = serde_json::json!({ + "schema_version": 2, + "content_digest": content_digest, + }); + fs::write(skill_dir.join(TRUSTED_MARKER), body.to_string()).with_context(|| { + format!( + "failed to write {} for {}", + TRUSTED_MARKER, + skill_dir.display() + ) + })?; + Ok(()) } /// Curated-registry document. The shape is intentionally minimal so adding diff --git a/crates/tui/src/skills/mod.rs b/crates/tui/src/skills/mod.rs index 44c8dac9ad..e32b68627b 100644 --- a/crates/tui/src/skills/mod.rs +++ b/crates/tui/src/skills/mod.rs @@ -1,6 +1,10 @@ //! Skill discovery and registry for local SKILL.md files. +pub mod audit; pub mod install; +pub mod mutation; +mod package_digest; +pub mod roots; mod system; // Re-exports kept for documentation parity and downstream consumers; the // binary itself imports directly from `skills::install`. `#[allow(...)]` @@ -12,6 +16,13 @@ pub use install::{ InstallSource, InstalledSkill, RegistryDocument, RegistryEntry, RegistryFetchResult, SkillSyncOutcome, SyncResult, UpdateResult, default_cache_skills_dir, }; +#[allow(unused_imports)] +pub use roots::{ + CompatibleHarness, SkillRootAccess, SkillRootCatalog, SkillRootDescriptor, SkillRootId, + SkillRootKind, SkillScope, classify_configured_skills_dir, safe_display_path, +}; +#[allow(unused_imports)] +pub use system::{bundled_skill_body_sha256, is_exact_bundled_skill}; pub use system::{install_system_skills, is_bundled_skill_name}; use std::fs; @@ -619,7 +630,7 @@ fn is_valid_skill_name(name: &str) -> bool { .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') } -fn normalize_skill_name_for_lookup(name: &str) -> String { +pub(crate) fn normalize_skill_name_for_lookup(name: &str) -> String { if let Some((plugin, skill)) = name.trim().split_once(':') && !plugin.is_empty() && !skill.is_empty() @@ -673,7 +684,8 @@ fn normalize_skill_name_segment(name: &str) -> String { /// other AI-tool conventions installed in the same workspace /// (#432). /// -/// Precedence (first match wins on name conflicts): +/// Precedence is defined once in [`roots::SkillRootCatalog`] (first +/// match wins on name conflicts): /// /// 1. `/.agents/skills` — deepseek-native convention. /// 2. `/skills` — flat, project-local. @@ -686,6 +698,9 @@ fn normalize_skill_name_segment(name: &str) -> String { /// 9. `~/.codewhale/skills` — CodeWhale global, primary install target. /// 10. `~/.deepseek/skills` — legacy DeepSeek global fallback. /// +/// Compatible audit may also observe `.codex/skills`, but that root is +/// never activated for runtime discovery in this catalog. +/// /// Only directories that exist on disk are returned — callers don't /// need to filter further. Returns an empty vec when nothing is /// installed (the system-prompt skills block is then suppressed). @@ -706,58 +721,12 @@ fn skills_directories_with_home_and_mode( home_dir: Option<&Path>, mode: SkillDiscoveryMode, ) -> Vec { - let mut candidates = match mode { - SkillDiscoveryMode::Compatible => vec![ - workspace.join(".agents").join("skills"), - workspace.join("skills"), - workspace.join(".opencode").join("skills"), - workspace.join(".claude").join("skills"), - workspace.join(".cursor").join("skills"), - workspace.join(".codewhale").join("skills"), - ], - SkillDiscoveryMode::CodeWhaleOnly => codewhale_workspace_skills_dir(workspace) - .into_iter() - .collect(), - }; - if let Some(home) = home_dir { - match mode { - SkillDiscoveryMode::Compatible => { - candidates.push(home.join(".agents").join("skills")); - candidates.push(home.join(".claude").join("skills")); - candidates.push(home.join(".codewhale").join("skills")); - candidates.push(home.join(".deepseek").join("skills")); - } - SkillDiscoveryMode::CodeWhaleOnly => { - candidates.push(home.join(".codewhale").join("skills")); - } - } - } else { - candidates.push(PathBuf::from("/tmp/codewhale/skills")); - } - existing_skill_dirs(candidates) -} - -pub(crate) fn codewhale_workspace_skills_dir(workspace: &Path) -> Option { - let skills_dir = workspace.join(".codewhale").join("skills"); - let canonical_workspace = fs::canonicalize(workspace).ok()?; - let canonical_skills = fs::canonicalize(&skills_dir).ok()?; - (canonical_skills.is_dir() && canonical_skills.starts_with(canonical_workspace)) - .then_some(skills_dir) + roots::skills_directories_with_home_and_mode(workspace, home_dir, mode) } -fn existing_skill_dirs(candidates: impl IntoIterator) -> Vec { - let mut out = Vec::new(); - let mut seen = HashSet::new(); - for path in candidates { - let Ok(canonical_path) = fs::canonicalize(&path) else { - continue; - }; - if canonical_path.is_dir() && seen.insert(canonical_path) { - out.push(path); - } - } - out -} +pub(crate) use roots::codewhale_workspace_skills_dir; +#[cfg(test)] +pub(crate) use roots::existing_skill_dirs; /// Walk every candidate skills directory for a workspace and merge /// the discovered skills into a single registry. Name conflicts are @@ -832,7 +801,11 @@ pub fn skill_directories_for_workspace_and_dir( } fn insert_configured_skills_dir(dirs: &mut Vec, workspace: &Path, skills_dir: &Path) { - if !skills_dir.is_dir() || dirs.iter().any(|p| paths_refer_to_same_dir(p, skills_dir)) { + if !skills_dir.is_dir() + || dirs + .iter() + .any(|p| roots::paths_refer_to_same_dir(p, skills_dir)) + { return; } @@ -847,16 +820,6 @@ fn insert_configured_skills_dir(dirs: &mut Vec, workspace: &Path, skill dirs.insert(insert_at, skills_dir.to_path_buf()); } -fn paths_refer_to_same_dir(left: &Path, right: &Path) -> bool { - if left == right { - return true; - } - match (fs::canonicalize(left), fs::canonicalize(right)) { - (Ok(left), Ok(right)) => left == right, - _ => false, - } -} - #[allow(dead_code)] pub(crate) fn discover_from_directories(dirs: impl IntoIterator) -> SkillRegistry { discover_from_directories_with_plugins(dirs, None) @@ -1090,6 +1053,18 @@ fn sanitize_prompt_path_text(text: &str, workspace: &Path) -> String { { out = out.replace(home_str, "~"); } + // Environment variables are process-global, and concurrent embedders or + // tests may temporarily redirect HOME after discovery recorded a warning. + // Scrub conventional home roots by shape as a final privacy boundary. + for marker in ["/Users/", "/home/"] { + while let Some(start) = out.find(marker) { + let user_start = start + marker.len(); + let user_len = out[user_start..] + .find(|ch: char| ch == '/' || ch.is_whitespace()) + .unwrap_or(out.len() - user_start); + out.replace_range(start..user_start + user_len, "~"); + } + } out } @@ -1190,7 +1165,7 @@ reviewed plugin snapshots must be opened with `load_skill`.\n\n", out.push_str( "\n### How to use skills\n\ -- Native skill bodies live on disk at the listed paths. For a reviewed plugin snapshot, use `load_skill` and do not read its mutable source path directly.\n\ +- Use `load_skill` to open any skill body by name. This is required for reviewed plugin snapshots and is the preferred path for native skills, including global skills outside the workspace. Direct file reads retain the normal workspace/trust boundary.\n\ - Trigger rules: use a skill when the user names it (`$SkillName`, `/skill `, or plain text) or the task clearly matches its description. Do not carry skills across turns unless re-mentioned.\n\ - Missing/blocked: if a named skill is missing or cannot be read, say so briefly and continue with the best fallback.\n\ - Safety: do not execute scripts from a community skill unless the user explicitly asks or the skill has been trusted for script use.\n", @@ -1223,6 +1198,17 @@ mod tests { std::fs::write(skill_dir.join("SKILL.md"), skill_content).unwrap(); } + #[test] + fn prompt_warning_sanitizer_scrubs_stale_conventional_home_roots() { + let workspace = std::path::Path::new("/tmp/workspace"); + let warning = "Skill at /Users/private-name/.agents/skills/a/SKILL.md is shadowed by /home/other/.skills/a/SKILL.md"; + let sanitized = super::sanitize_prompt_path_text(warning, workspace); + assert_eq!( + sanitized, + "Skill at ~/.agents/skills/a/SKILL.md is shadowed by ~/.skills/a/SKILL.md" + ); + } + #[test] fn render_available_skills_context_lists_paths_and_usage() { let tmpdir = TempDir::new().unwrap(); @@ -1245,6 +1231,8 @@ mod tests { assert!(rendered.contains("## Skills")); assert!(rendered.contains("- test-skill: A test skill")); + assert!(rendered.contains("Use `load_skill` to open any skill body by name")); + assert!(rendered.contains("Direct file reads retain the normal workspace/trust boundary")); assert!( rendered.contains(&expected_path), "expected path {expected_path:?} not in rendered output" @@ -2220,7 +2208,7 @@ body"; "body", ); write_skill( - &home.join(".deepseek").join("skills"), + &home.join(".codewhale").join("skills"), "global-alpha", "Global alpha skill", "body", @@ -2237,7 +2225,7 @@ body"; ); assert!( names.contains(&"global-alpha"), - "global-alpha from ~/.deepseek/skills must be discovered: {names:?}", + "global-alpha from ~/.codewhale/skills must be discovered: {names:?}", ); } diff --git a/crates/tui/src/skills/mutation.rs b/crates/tui/src/skills/mutation.rs new file mode 100644 index 0000000000..c41a4aeb9c --- /dev/null +++ b/crates/tui/src/skills/mutation.rs @@ -0,0 +1,1826 @@ +//! Unique skill mutation controller for CodeWhale-owned roots. +//! +//! All install / import / update / remove / trust writes go through this +//! module. Compatible harness roots, built-ins, and plugin snapshots are +//! never mutated. + +use std::fs; +use std::io::ErrorKind; +use std::path::{Component, Path, PathBuf}; + +use anyhow::{Context, Result, bail}; + +use crate::network_policy::NetworkPolicy; + +use super::audit::{ + AuditedSkill, AuditedSkillId, DigestState, SkillActionKind, SkillAuditMode, SkillSourceKind, + scan_with_configured, +}; +use super::install::{ + self, InstallOutcome, InstallSource, UpdateResult, write_installed_from_v2, write_trust_v2, +}; +use super::normalize_skill_name_for_lookup; +use super::package_digest; +use super::roots::{ + SkillRootCatalog, SkillRootDescriptor, SkillRootKind, SkillScope, safe_display_path, +}; + +/// Project vs global CodeWhale-owned install target. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SkillTargetScope { + Project, + Global, +} + +impl SkillTargetScope { + #[must_use] + pub fn as_skill_scope(self) -> SkillScope { + match self { + Self::Project => SkillScope::Project, + Self::Global => SkillScope::Global, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConflictPolicy { + Reject, + ReplaceConfirmed, +} + +#[derive(Debug, Clone)] +pub enum SkillMutationRequest { + InstallRemote { + source: InstallSource, + target: SkillTargetScope, + }, + ImportExternal { + source_id: AuditedSkillId, + expected_digest: String, + target: SkillTargetScope, + conflict_policy: ConflictPolicy, + }, + Update { + skill_id: AuditedSkillId, + expected_digest: Option, + }, + /// Resolve by name inside owned roots (compatible `/skill update` path). + UpdateByName { + name: String, + scope: Option, + expected_digest: Option, + }, + Remove { + skill_id: AuditedSkillId, + expected_digest: Option, + }, + RemoveByName { + name: String, + scope: Option, + expected_digest: Option, + }, + Trust { + skill_id: AuditedSkillId, + expected_digest: String, + }, + TrustByName { + name: String, + scope: Option, + expected_digest: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SkillMutationOutcome { + Installed, + Updated, + NoChange, + Removed, + Trusted, + Imported, + AlreadyPresent, + NeedsApproval(String), + NetworkDenied(String), +} + +#[derive(Debug, Clone)] +pub struct SkillMutationReceipt { + #[allow(dead_code)] // surfaced by manager detail / future receipt toast + pub action: SkillActionKind, + pub name: String, + #[allow(dead_code)] // surfaced by manager detail / future receipt toast + pub scope: SkillScope, + pub safe_target_path: String, + #[allow(dead_code)] // reserved for digest-diff UI + pub before_digest: Option, + #[allow(dead_code)] // reserved for digest-diff UI + pub after_digest: Option, + pub outcome: SkillMutationOutcome, +} + +/// Inputs shared by mutation operations. +pub struct MutationContext<'a> { + pub workspace: &'a Path, + pub home: Option<&'a Path>, + pub configured_skills_dir: Option<&'a Path>, + pub network: &'a NetworkPolicy, + pub max_size: u64, + pub registry_url: &'a str, +} + +fn owned_anchor<'a>( + workspace: &'a Path, + home: Option<&'a Path>, + target: SkillTargetScope, +) -> Result<&'a Path> { + match target { + SkillTargetScope::Project => Ok(workspace), + SkillTargetScope::Global => home.context("global skill mutations require a home directory"), + } +} + +/// Return whether `path` is an existing real directory, rejecting links and +/// non-directory components. `symlink_metadata` is intentional: following a +/// link before checking it would turn a lexical CodeWhale-owned root into an +/// attacker-selected write target. +fn checked_real_directory(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => { + bail!( + "refusing to mutate symlinked CodeWhale skills path component {}", + path.display() + ) + } + Ok(meta) if !meta.is_dir() => bail!( + "refusing to mutate through non-directory CodeWhale skills path component {}", + path.display() + ), + Ok(_) => Ok(true), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(false), + Err(err) => Err(err).with_context(|| format!("failed to inspect {}", path.display())), + } +} + +/// Validate the complete owned-root chain without following a symlink in the +/// workspace/home anchor, `.codewhale`, or `skills` component. +fn validate_owned_target_chain( + anchor: &Path, + skills_dir: &Path, + require_existing: bool, +) -> Result<()> { + let expected = anchor.join(".codewhale").join("skills"); + if skills_dir != expected { + bail!( + "refusing to mutate non-canonical CodeWhale skills root {}", + skills_dir.display() + ); + } + + let anchor_exists = checked_real_directory(anchor)?; + if !anchor_exists { + if require_existing { + bail!("owned skill anchor {} does not exist", anchor.display()); + } + return Ok(()); + } + + let codewhale_dir = anchor.join(".codewhale"); + let codewhale_exists = checked_real_directory(&codewhale_dir)?; + if !codewhale_exists { + if require_existing { + bail!( + "owned skill parent {} does not exist", + codewhale_dir.display() + ); + } + return Ok(()); + } + + let skills_exists = checked_real_directory(skills_dir)?; + if !skills_exists { + if require_existing { + bail!("owned skill root {} does not exist", skills_dir.display()); + } + return Ok(()); + } + + let canonical_anchor = fs::canonicalize(anchor) + .with_context(|| format!("failed to resolve owned anchor {}", anchor.display()))?; + let canonical_skills = fs::canonicalize(skills_dir).with_context(|| { + format!( + "failed to resolve owned skill root {}", + skills_dir.display() + ) + })?; + if !canonical_skills.starts_with(&canonical_anchor) { + bail!( + "owned skill root {} escapes anchor {}", + skills_dir.display(), + anchor.display() + ); + } + Ok(()) +} + +fn create_owned_directory(path: &Path) -> Result<()> { + match fs::create_dir(path) { + Ok(()) => {} + Err(err) if err.kind() == ErrorKind::AlreadyExists => {} + Err(err) => { + return Err(err).with_context(|| format!("failed to create {}", path.display())); + } + } + if !checked_real_directory(path)? { + bail!("failed to create owned skill directory {}", path.display()); + } + Ok(()) +} + +fn prepare_owned_target( + workspace: &Path, + home: Option<&Path>, + target: SkillTargetScope, +) -> Result { + let skills_dir = resolve_owned_target(workspace, home, target)?; + let anchor = owned_anchor(workspace, home, target)?; + if !checked_real_directory(anchor)? { + bail!("owned skill anchor {} does not exist", anchor.display()); + } + + let codewhale_dir = anchor.join(".codewhale"); + if !checked_real_directory(&codewhale_dir)? { + create_owned_directory(&codewhale_dir)?; + } + validate_owned_target_chain(anchor, &skills_dir, false)?; + if !checked_real_directory(&skills_dir)? { + create_owned_directory(&skills_dir)?; + } + validate_owned_target_chain(anchor, &skills_dir, true)?; + Ok(skills_dir) +} + +fn target_scope_for_root(root: &SkillRootDescriptor) -> Result { + if !root.is_writable_owned() { + bail!("refusing to mutate non-owned root {}", root.path.display()); + } + match root.kind { + SkillRootKind::CodeWhaleProject => Ok(SkillTargetScope::Project), + SkillRootKind::CodeWhaleGlobal => Ok(SkillTargetScope::Global), + _ => bail!("refusing to mutate non-owned root {}", root.path.display()), + } +} + +fn validate_owned_root_descriptor( + ctx: &MutationContext<'_>, + root: &SkillRootDescriptor, +) -> Result { + let target = target_scope_for_root(root)?; + let expected = resolve_owned_target(ctx.workspace, ctx.home, target)?; + if root.path != expected { + bail!( + "audited owned root {} does not match mutation target {}", + root.path.display(), + expected.display() + ); + } + let anchor = owned_anchor(ctx.workspace, ctx.home, target)?; + validate_owned_target_chain(anchor, &expected, true)?; + Ok(expected) +} + +/// Validate a real direct child of an already validated owned skills root. +/// Returns false only when a missing child is permitted. +fn validate_owned_child(skills_dir: &Path, child: &Path, require_existing: bool) -> Result { + if child.parent() != Some(skills_dir) { + bail!( + "refusing to mutate path {} outside direct owned root {}", + child.display(), + skills_dir.display() + ); + } + let exists = checked_real_directory(child)?; + if !exists { + if require_existing { + bail!("owned skill path {} does not exist", child.display()); + } + return Ok(false); + } + + let canonical_root = fs::canonicalize(skills_dir).with_context(|| { + format!( + "failed to resolve owned skill root {}", + skills_dir.display() + ) + })?; + let canonical_child = fs::canonicalize(child) + .with_context(|| format!("failed to resolve owned skill path {}", child.display()))?; + if canonical_child.parent() != Some(canonical_root.as_path()) { + bail!( + "owned skill path {} escapes direct root {}", + child.display(), + skills_dir.display() + ); + } + Ok(true) +} + +fn validate_owned_skill_path( + ctx: &MutationContext<'_>, + skill: &AuditedSkill, + path: &Path, +) -> Result { + if skill.id.root_id != skill.root.id { + bail!("audited skill root identity changed; refusing mutation"); + } + let skills_dir = validate_owned_root_descriptor(ctx, &skill.root)?; + let package_name = on_disk_package_name(&skill.id)?; + let expected = skills_dir.join(package_name); + if path != expected { + bail!( + "audited skill path {} does not match owned package {}", + path.display(), + expected.display() + ); + } + validate_owned_child(&skills_dir, path, true)?; + Ok(skills_dir) +} + +/// Resolve the on-disk CodeWhale-owned skills directory for a target scope. +pub fn resolve_owned_target( + workspace: &Path, + home: Option<&Path>, + target: SkillTargetScope, +) -> Result { + let catalog = SkillRootCatalog::build(workspace, home, None); + let kind = match target { + SkillTargetScope::Project => SkillRootKind::CodeWhaleProject, + SkillTargetScope::Global => SkillRootKind::CodeWhaleGlobal, + }; + let root = catalog + .owned_writable_roots() + .into_iter() + .find(|r| r.kind == kind) + .with_context(|| format!("no owned root for {target:?}"))?; + if !root.is_writable_owned() { + bail!("refusing to mutate non-owned root {}", root.path.display()); + } + let anchor = owned_anchor(workspace, home, target)?; + validate_owned_target_chain(anchor, &root.path, false)?; + Ok(root.path.clone()) +} + +/// Execute a mutation request against CodeWhale-owned roots only. +pub async fn execute( + request: SkillMutationRequest, + ctx: &MutationContext<'_>, +) -> Result { + match request { + SkillMutationRequest::InstallRemote { source, target } => { + install_remote(source, target, ctx).await + } + SkillMutationRequest::Update { + skill_id, + expected_digest, + } => update_skill(skill_id, expected_digest, ctx).await, + SkillMutationRequest::UpdateByName { + name, + scope, + expected_digest, + } => { + let resolved = resolve_owned_skill_by_name(ctx, &name, scope)?; + update_skill(resolved.id, expected_digest.or(Some(resolved.digest)), ctx).await + } + sync => execute_sync(sync, ctx), + } +} + +/// Sync mutations (import / remove / trust) — safe to call without a Tokio runtime. +pub fn execute_sync( + request: SkillMutationRequest, + ctx: &MutationContext<'_>, +) -> Result { + match request { + SkillMutationRequest::ImportExternal { + source_id, + expected_digest, + target, + conflict_policy, + } => import_external(source_id, expected_digest, target, conflict_policy, ctx), + SkillMutationRequest::Remove { + skill_id, + expected_digest, + } => remove_skill(skill_id, expected_digest, ctx), + SkillMutationRequest::RemoveByName { + name, + scope, + expected_digest, + } => { + let resolved = resolve_owned_skill_by_name(ctx, &name, scope)?; + remove_skill(resolved.id, expected_digest.or(Some(resolved.digest)), ctx) + } + SkillMutationRequest::Trust { + skill_id, + expected_digest, + } => trust_skill(skill_id, expected_digest, ctx), + SkillMutationRequest::TrustByName { + name, + scope, + expected_digest, + } => { + let resolved = resolve_owned_skill_by_name(ctx, &name, scope)?; + let digest = expected_digest.unwrap_or(resolved.digest); + trust_skill(resolved.id, digest, ctx) + } + SkillMutationRequest::InstallRemote { .. } + | SkillMutationRequest::Update { .. } + | SkillMutationRequest::UpdateByName { .. } => { + bail!("this mutation requires async execute (network I/O)") + } + } +} + +#[derive(Debug)] +struct ResolvedOwnedSkill { + id: AuditedSkillId, + digest: String, +} + +fn resolve_owned_skill_by_name( + ctx: &MutationContext<'_>, + name: &str, + scope: Option, +) -> Result { + // Match SkillRegistry::get — users may pass frontmatter/dir forms + // (Hello_World) while audit stores the folded canonical key (hello-world). + let canonical = normalize_skill_name_for_lookup(name); + let snap = scan_with_configured( + ctx.workspace, + ctx.home, + ctx.configured_skills_dir, + SkillAuditMode::OwnedOnly, + None, + ); + let mut matches: Vec<&AuditedSkill> = snap + .skills + .iter() + .filter(|s| s.id.canonical_name == canonical) + .filter(|s| s.root.is_writable_owned()) + .collect(); + + if let Some(scope) = scope { + let want = match scope { + SkillTargetScope::Project => SkillRootKind::CodeWhaleProject, + SkillTargetScope::Global => SkillRootKind::CodeWhaleGlobal, + }; + matches.retain(|s| s.root.kind == want); + } + + match matches.as_slice() { + [] => { + // If the name only exists externally, tell the user to import. + let compatible = scan_with_configured( + ctx.workspace, + ctx.home, + ctx.configured_skills_dir, + SkillAuditMode::Compatible, + None, + ); + if compatible.skills.iter().any(|s| { + s.id.canonical_name == canonical + && s.source_kind == SkillSourceKind::CompatibleExternal + }) { + bail!( + "skill '{name}' exists only in a compatible external root; \ + import it with /skills (refusing to write external harness directories)" + ); + } + bail!("skill '{name}' not found in CodeWhale-owned project/global roots"); + } + [only] => { + let DigestState::Known(digest) = &only.digest else { + bail!("skill '{name}' has unknown package digest; refusing mutation"); + }; + Ok(ResolvedOwnedSkill { + id: only.id.clone(), + digest: digest.clone(), + }) + } + _ => bail!( + "skill '{name}' exists in both project and global CodeWhale roots; \ + specify --project or --global" + ), + } +} + +fn find_audited_skill( + ctx: &MutationContext<'_>, + skill_id: &AuditedSkillId, +) -> Result<(AuditedSkill, PathBuf)> { + let snap = scan_with_configured( + ctx.workspace, + ctx.home, + ctx.configured_skills_dir, + SkillAuditMode::Compatible, + None, + ); + let skill = snap + .skills + .into_iter() + .find(|s| &s.id == skill_id) + .with_context(|| format!("audited skill {} not found", skill_id.canonical_name))?; + let path = skill.root.path.join(&skill.id.relative_dir); + Ok((skill, path)) +} + +/// Directory segment under the skills root. Prefer this over `canonical_name` +/// when calling install helpers that join `skills_dir / name` — installs use +/// raw frontmatter names, while audit stores a normalized lookup key. +fn on_disk_package_name(skill_id: &AuditedSkillId) -> Result<&str> { + let mut components = skill_id.relative_dir.components(); + let name = match (components.next(), components.next()) { + (Some(Component::Normal(name)), None) => name.to_str(), + _ => None, + } + .filter(|name| !name.is_empty()) + .ok_or_else(|| { + anyhow::anyhow!( + "invalid on-disk package directory for skill '{}'", + skill_id.canonical_name + ) + })?; + Ok(name) +} + +fn verify_expected_digest(path: &Path, expected: Option<&str>) -> Result> { + let current = package_digest::compute_package_digest(path) + .with_context(|| format!("cannot digest {}", path.display()))?; + if let Some(expected) = expected + && expected != current + { + bail!( + "skill content changed since audit (expected {expected}, found {current}); \ + re-review before mutating" + ); + } + Ok(Some(current)) +} + +fn ensure_remote_updatable(skill_dir: &Path) -> Result<()> { + let marker_path = skill_dir.join(install::INSTALLED_FROM_MARKER); + let body = fs::read_to_string(&marker_path) + .with_context(|| format!("failed to read {}", marker_path.display()))?; + let value: serde_json::Value = serde_json::from_str(&body) + .with_context(|| format!("malformed {}", install::INSTALLED_FROM_MARKER))?; + let spec = value + .get("spec") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + if !install::is_registry_updatable_spec(spec) { + bail!( + "skill was imported locally (spec '{spec}') and cannot be updated from a registry; \ + re-import or remove it first" + ); + } + Ok(()) +} + +async fn install_remote( + source: InstallSource, + target: SkillTargetScope, + ctx: &MutationContext<'_>, +) -> Result { + let skills_dir = prepare_owned_target(ctx.workspace, ctx.home, target)?; + let anchor = owned_anchor(ctx.workspace, ctx.home, target)?; + + let outcome = install::install_with_registry( + source, + &skills_dir, + ctx.max_size, + ctx.network, + false, + ctx.registry_url, + ) + .await?; + validate_owned_target_chain(anchor, &skills_dir, true)?; + + match outcome { + InstallOutcome::Installed(installed) => { + validate_owned_child(&skills_dir, &installed.path, true)?; + let after = package_digest::compute_package_digest(&installed.path).ok(); + Ok(SkillMutationReceipt { + action: SkillActionKind::Install, + name: installed.name, + scope: target.as_skill_scope(), + safe_target_path: safe_display_path(&installed.path, Some(ctx.workspace), ctx.home), + before_digest: None, + after_digest: after, + outcome: SkillMutationOutcome::Installed, + }) + } + InstallOutcome::NeedsApproval(host) => Ok(SkillMutationReceipt { + action: SkillActionKind::Install, + name: String::new(), + scope: target.as_skill_scope(), + safe_target_path: safe_display_path(&skills_dir, Some(ctx.workspace), ctx.home), + before_digest: None, + after_digest: None, + outcome: SkillMutationOutcome::NeedsApproval(host), + }), + InstallOutcome::NetworkDenied(host) => Ok(SkillMutationReceipt { + action: SkillActionKind::Install, + name: String::new(), + scope: target.as_skill_scope(), + safe_target_path: safe_display_path(&skills_dir, Some(ctx.workspace), ctx.home), + before_digest: None, + after_digest: None, + outcome: SkillMutationOutcome::NetworkDenied(host), + }), + } +} + +fn import_external( + source_id: AuditedSkillId, + expected_digest: String, + target: SkillTargetScope, + conflict_policy: ConflictPolicy, + ctx: &MutationContext<'_>, +) -> Result { + let (source_skill, source_path) = find_audited_skill(ctx, &source_id)?; + if source_skill.source_kind != SkillSourceKind::CompatibleExternal { + bail!("import source must be a compatible external skill"); + } + if source_skill.path_unsafe { + bail!("refusing to import unsafe external skill package"); + } + + let owned_snap = scan_with_configured( + ctx.workspace, + ctx.home, + ctx.configured_skills_dir, + SkillAuditMode::OwnedOnly, + None, + ); + let owned_has_name = owned_snap + .skills + .iter() + .any(|s| s.id.canonical_name == source_id.canonical_name); + + if !owned_has_name && !source_skill.import_candidate { + bail!("skill is not an import candidate"); + } + + let before = verify_expected_digest(&source_path, Some(&expected_digest))?; + + let skills_dir = prepare_owned_target(ctx.workspace, ctx.home, target)?; + let anchor = owned_anchor(ctx.workspace, ctx.home, target)?; + + let want_kind = match target { + SkillTargetScope::Project => SkillRootKind::CodeWhaleProject, + SkillTargetScope::Global => SkillRootKind::CodeWhaleGlobal, + }; + let existing_in_target: Vec<&AuditedSkill> = owned_snap + .skills + .iter() + .filter(|s| s.id.canonical_name == source_id.canonical_name) + .filter(|s| s.root.kind == want_kind) + .collect(); + + // Prefer the on-disk directory of an existing same-scope package so we + // replace/reject against Hello_World rather than creating a parallel + // hello-world next to it. + let (dest, dest_segment) = match existing_in_target.as_slice() { + [] => { + let segment = source_id.canonical_name.clone(); + (skills_dir.join(&segment), segment) + } + [only] => { + let segment = on_disk_package_name(&only.id)?.to_string(); + (only.root.path.join(&only.id.relative_dir), segment) + } + _ => bail!( + "skill '{}' has multiple owned packages in the target scope; \ + remove or consolidate them before importing", + source_id.canonical_name + ), + }; + + let dest_exists = validate_owned_child(&skills_dir, &dest, false)?; + if dest_exists { + let existing = package_digest::compute_package_digest(&dest).ok(); + if existing.as_deref() == Some(expected_digest.as_str()) { + return Ok(SkillMutationReceipt { + action: SkillActionKind::Import, + name: source_id.canonical_name.clone(), + scope: target.as_skill_scope(), + safe_target_path: safe_display_path(&dest, Some(ctx.workspace), ctx.home), + before_digest: before, + after_digest: existing, + outcome: SkillMutationOutcome::AlreadyPresent, + }); + } + if conflict_policy == ConflictPolicy::Reject { + bail!( + "destination {} already exists with different content", + dest.display() + ); + } + } + + // Re-verify source digest immediately before copy (TOCTOU). + let _ = verify_expected_digest(&source_path, Some(&expected_digest))?; + validate_owned_target_chain(anchor, &skills_dir, true)?; + + let staging = skills_dir.join(format!("{dest_segment}.tmp")); + if validate_owned_child(&skills_dir, &staging, false)? { + fs::remove_dir_all(&staging) + .with_context(|| format!("failed to clean stale staging dir {}", staging.display()))?; + } + copy_skill_package(&source_path, &staging)?; + validate_owned_target_chain(anchor, &skills_dir, true)?; + validate_owned_child(&skills_dir, &staging, true)?; + package_digest::compute_package_digest(&staging) + .context("staged import package failed digest validation")?; + + let mut backup_path: Option = None; + if dest_exists { + validate_owned_child(&skills_dir, &dest, true)?; + let backup = skills_dir.join(format!("{dest_segment}.bak")); + if validate_owned_child(&skills_dir, &backup, false)? { + fs::remove_dir_all(&backup).with_context(|| { + format!("failed to clean stale backup dir {}", backup.display()) + })?; + } + fs::rename(&dest, &backup) + .with_context(|| format!("failed to backup {}", dest.display()))?; + if let Err(err) = fs::rename(&staging, &dest) { + let _ = fs::rename(&backup, &dest); + let _ = fs::remove_dir_all(&staging); + return Err(err).context("failed to replace destination with imported skill"); + } + backup_path = Some(backup); + } else if let Err(err) = fs::rename(&staging, &dest) { + let _ = fs::remove_dir_all(&staging); + return Err(err).context("failed to install imported skill"); + } + validate_owned_target_chain(anchor, &skills_dir, true)?; + validate_owned_child(&skills_dir, &dest, true)?; + + // Keep backup until digest + marker finalize succeed so a failed import + // can restore the previous owned skill. + let finalize = (|| -> Result { + let _ = verify_expected_digest(&source_path, Some(&expected_digest))?; + validate_owned_target_chain(anchor, &skills_dir, true)?; + validate_owned_child(&skills_dir, &dest, true)?; + let after = package_digest::compute_package_digest(&dest)?; + if after != expected_digest { + bail!("imported content digest mismatch after copy; aborted"); + } + write_installed_from_v2( + &dest, + &format!("import:{}", source_skill.safe_display_path), + None, + &expected_digest, + &after, + &source_id.canonical_name, + )?; + Ok(after) + })(); + + let after = match finalize { + Ok(after) => { + if let Some(backup) = backup_path.take() { + fs::remove_dir_all(&backup).ok(); + } + after + } + Err(err) => { + let _ = fs::remove_dir_all(&dest); + if let Some(backup) = backup_path.take() { + let _ = fs::rename(&backup, &dest); + } + return Err(err); + } + }; + + Ok(SkillMutationReceipt { + action: SkillActionKind::Import, + name: source_id.canonical_name, + scope: target.as_skill_scope(), + safe_target_path: safe_display_path(&dest, Some(ctx.workspace), ctx.home), + before_digest: before, + after_digest: Some(after), + outcome: SkillMutationOutcome::Imported, + }) +} + +fn copy_skill_package(src: &Path, dest: &Path) -> Result<()> { + // Fail closed: source must already pass package digest (no symlinks). + package_digest::compute_package_digest(src).context("source package is not safe to copy")?; + copy_dir_regular_files(src, dest)?; + Ok(()) +} + +fn copy_dir_regular_files(src: &Path, dest: &Path) -> Result<()> { + fs::create_dir_all(dest)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let path = entry.path(); + let meta = fs::symlink_metadata(&path)?; + if meta.file_type().is_symlink() { + bail!("refusing to copy symlink {}", path.display()); + } + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str == install::INSTALLED_FROM_MARKER + || name_str == install::TRUSTED_MARKER + || name_str == ".system-installed-version" + { + continue; + } + let target = dest.join(&name); + if meta.is_dir() { + if name_str.starts_with('.') { + continue; + } + copy_dir_regular_files(&path, &target)?; + } else if meta.is_file() { + if name_str.starts_with('.') { + continue; + } + fs::copy(&path, &target)?; + } + } + Ok(()) +} + +async fn update_skill( + skill_id: AuditedSkillId, + expected_digest: Option, + ctx: &MutationContext<'_>, +) -> Result { + let (skill, path) = find_audited_skill(ctx, &skill_id)?; + if !skill.root.is_writable_owned() { + bail!("refusing to update skill outside CodeWhale-owned roots"); + } + if skill.source_kind != SkillSourceKind::CodeWhaleManaged { + bail!("only CodeWhale managed skills can be updated"); + } + let skills_dir = validate_owned_skill_path(ctx, &skill, &path)?; + // Imported skills carry `import:…` provenance and must not hit the registry. + ensure_remote_updatable(&path)?; + let before = verify_expected_digest(&path, expected_digest.as_deref())?; + let scope = match skill.root.kind { + SkillRootKind::CodeWhaleProject => SkillScope::Project, + SkillRootKind::CodeWhaleGlobal => SkillScope::Global, + _ => SkillScope::Logical, + }; + + let package_name = on_disk_package_name(&skill_id)?; + validate_owned_skill_path(ctx, &skill, &path)?; + let outcome = install::update_with_registry( + package_name, + &skills_dir, + ctx.max_size, + ctx.network, + ctx.registry_url, + ) + .await?; + validate_owned_root_descriptor(ctx, &skill.root)?; + validate_owned_child(&skills_dir, &path, true)?; + + match outcome { + UpdateResult::NoChange => Ok(SkillMutationReceipt { + action: SkillActionKind::Update, + name: skill_id.canonical_name, + scope, + safe_target_path: safe_display_path(&path, Some(ctx.workspace), ctx.home), + before_digest: before.clone(), + after_digest: before, + outcome: SkillMutationOutcome::NoChange, + }), + UpdateResult::Updated(installed) => { + let after = package_digest::compute_package_digest(&installed.path).ok(); + Ok(SkillMutationReceipt { + action: SkillActionKind::Update, + name: installed.name, + scope, + safe_target_path: safe_display_path(&installed.path, Some(ctx.workspace), ctx.home), + before_digest: before, + after_digest: after, + outcome: SkillMutationOutcome::Updated, + }) + } + UpdateResult::NeedsApproval(host) => Ok(SkillMutationReceipt { + action: SkillActionKind::Update, + name: skill_id.canonical_name, + scope, + safe_target_path: safe_display_path(&path, Some(ctx.workspace), ctx.home), + before_digest: before, + after_digest: None, + outcome: SkillMutationOutcome::NeedsApproval(host), + }), + UpdateResult::NetworkDenied(host) => Ok(SkillMutationReceipt { + action: SkillActionKind::Update, + name: skill_id.canonical_name, + scope, + safe_target_path: safe_display_path(&path, Some(ctx.workspace), ctx.home), + before_digest: before, + after_digest: None, + outcome: SkillMutationOutcome::NetworkDenied(host), + }), + } +} + +fn remove_skill( + skill_id: AuditedSkillId, + expected_digest: Option, + ctx: &MutationContext<'_>, +) -> Result { + let (skill, path) = find_audited_skill(ctx, &skill_id)?; + if !skill.root.is_writable_owned() { + bail!("refusing to remove skill outside CodeWhale-owned roots"); + } + if skill.source_kind != SkillSourceKind::CodeWhaleManaged { + bail!("only CodeWhale managed skills can be removed"); + } + let skills_dir = validate_owned_skill_path(ctx, &skill, &path)?; + let before = verify_expected_digest(&path, expected_digest.as_deref())?; + let scope = match skill.root.kind { + SkillRootKind::CodeWhaleProject => SkillScope::Project, + SkillRootKind::CodeWhaleGlobal => SkillScope::Global, + _ => SkillScope::Logical, + }; + let package_name = on_disk_package_name(&skill_id)?; + validate_owned_skill_path(ctx, &skill, &path)?; + install::uninstall(package_name, &skills_dir)?; + Ok(SkillMutationReceipt { + action: SkillActionKind::Remove, + name: skill_id.canonical_name, + scope, + safe_target_path: safe_display_path(&path, Some(ctx.workspace), ctx.home), + before_digest: before, + after_digest: None, + outcome: SkillMutationOutcome::Removed, + }) +} + +fn trust_skill( + skill_id: AuditedSkillId, + expected_digest: String, + ctx: &MutationContext<'_>, +) -> Result { + let (skill, path) = find_audited_skill(ctx, &skill_id)?; + if !skill.root.is_writable_owned() { + bail!("refusing to trust skill outside CodeWhale-owned roots"); + } + if skill.source_kind != SkillSourceKind::CodeWhaleManaged { + bail!("only CodeWhale managed skills can be trusted"); + } + validate_owned_skill_path(ctx, &skill, &path)?; + let before = verify_expected_digest(&path, Some(&expected_digest))?; + validate_owned_skill_path(ctx, &skill, &path)?; + write_trust_v2(&path, &expected_digest)?; + let scope = match skill.root.kind { + SkillRootKind::CodeWhaleProject => SkillScope::Project, + SkillRootKind::CodeWhaleGlobal => SkillScope::Global, + _ => SkillScope::Logical, + }; + Ok(SkillMutationReceipt { + action: SkillActionKind::Trust, + name: skill_id.canonical_name, + scope, + safe_target_path: safe_display_path(&path, Some(ctx.workspace), ctx.home), + before_digest: before.clone(), + after_digest: before, + outcome: SkillMutationOutcome::Trusted, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::network_policy::NetworkPolicy; + use tempfile::TempDir; + + fn write_skill(dir: &Path, name: &str, body: &str) { + let skill_dir = dir.join(name); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + format!("---\nname: {name}\ndescription: d\n---\n{body}\n"), + ) + .unwrap(); + } + + #[cfg(unix)] + fn write_managed_skill(root: &Path, name: &str) -> String { + write_skill(root, name, "managed body"); + let skill_dir = root.join(name); + let digest = package_digest::compute_package_digest(&skill_dir).unwrap(); + write_installed_from_v2( + &skill_dir, + "github:owner/repo", + None, + "source-digest", + &digest, + name, + ) + .unwrap(); + digest + } + + fn ctx<'a>( + workspace: &'a Path, + home: &'a Path, + network: &'a NetworkPolicy, + ) -> MutationContext<'a> { + MutationContext { + workspace, + home: Some(home), + configured_skills_dir: None, + network, + max_size: install::DEFAULT_MAX_SIZE_BYTES, + registry_url: install::DEFAULT_REGISTRY_URL, + } + } + + #[test] + fn resolve_owned_target_project_and_global() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(&home).unwrap(); + + let project = + resolve_owned_target(&workspace, Some(&home), SkillTargetScope::Project).unwrap(); + let global = + resolve_owned_target(&workspace, Some(&home), SkillTargetScope::Global).unwrap(); + assert_eq!(project, workspace.join(".codewhale").join("skills")); + assert_eq!(global, home.join(".codewhale").join("skills")); + } + + #[cfg(unix)] + #[test] + fn owned_target_resolution_rejects_symlinked_parent_and_root_in_both_scopes() { + for target in [SkillTargetScope::Project, SkillTargetScope::Global] { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(&home).unwrap(); + let anchor = match target { + SkillTargetScope::Project => &workspace, + SkillTargetScope::Global => &home, + }; + + let outside_parent = tmp.path().join("outside-parent"); + fs::create_dir_all(&outside_parent).unwrap(); + std::os::unix::fs::symlink(&outside_parent, anchor.join(".codewhale")).unwrap(); + let err = resolve_owned_target(&workspace, Some(&home), target).unwrap_err(); + assert!(err.to_string().contains("symlinked"), "got: {err}"); + + fs::remove_file(anchor.join(".codewhale")).unwrap(); + fs::create_dir(anchor.join(".codewhale")).unwrap(); + let outside_root = tmp.path().join("outside-root"); + fs::create_dir_all(&outside_root).unwrap(); + std::os::unix::fs::symlink(&outside_root, anchor.join(".codewhale").join("skills")) + .unwrap(); + let err = resolve_owned_target(&workspace, Some(&home), target).unwrap_err(); + assert!(err.to_string().contains("symlinked"), "got: {err}"); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn install_rejects_symlinked_owned_root_without_external_writes() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let outside = tmp.path().join("outside"); + fs::create_dir_all(workspace.join(".codewhale")).unwrap(); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&outside).unwrap(); + fs::write(outside.join("SENTINEL"), "untouched").unwrap(); + std::os::unix::fs::symlink(&outside, workspace.join(".codewhale").join("skills")).unwrap(); + + let network = NetworkPolicy::default(); + let c = ctx(&workspace, &home, &network); + let err = execute( + SkillMutationRequest::InstallRemote { + source: InstallSource::GitHubRepo("owner/repo".into()), + target: SkillTargetScope::Project, + }, + &c, + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("symlinked"), "got: {err}"); + assert_eq!( + fs::read_to_string(outside.join("SENTINEL")).unwrap(), + "untouched" + ); + assert_eq!(fs::read_dir(&outside).unwrap().count(), 1); + } + + #[cfg(unix)] + #[test] + fn import_rejects_symlinked_owned_parent_without_external_writes() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let outside_parent = tmp.path().join("outside-parent"); + let outside_root = outside_parent.join("skills"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&outside_root).unwrap(); + fs::write(outside_root.join("SENTINEL"), "untouched").unwrap(); + write_skill( + &workspace.join(".claude").join("skills"), + "from-claude", + "import me", + ); + std::os::unix::fs::symlink(&outside_parent, workspace.join(".codewhale")).unwrap(); + + let network = NetworkPolicy::default(); + let c = ctx(&workspace, &home, &network); + let snap = scan_with_configured( + &workspace, + Some(&home), + None, + SkillAuditMode::Compatible, + None, + ); + let external = snap + .skills + .iter() + .find(|skill| skill.source_kind == SkillSourceKind::CompatibleExternal) + .unwrap(); + let DigestState::Known(digest) = &external.digest else { + panic!("digest"); + }; + let err = execute_sync( + SkillMutationRequest::ImportExternal { + source_id: external.id.clone(), + expected_digest: digest.clone(), + target: SkillTargetScope::Project, + conflict_policy: ConflictPolicy::Reject, + }, + &c, + ) + .unwrap_err(); + assert!(err.to_string().contains("symlinked"), "got: {err}"); + assert_eq!( + fs::read_to_string(outside_root.join("SENTINEL")).unwrap(), + "untouched" + ); + assert!(!outside_root.join("from-claude").exists()); + } + + #[cfg(unix)] + #[test] + fn remove_rejects_symlinked_owned_root_without_external_deletion() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let outside_root = tmp.path().join("outside-root"); + fs::create_dir_all(workspace.join(".codewhale")).unwrap(); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&outside_root).unwrap(); + let digest = write_managed_skill(&outside_root, "managed"); + std::os::unix::fs::symlink(&outside_root, workspace.join(".codewhale").join("skills")) + .unwrap(); + + let network = NetworkPolicy::default(); + let c = ctx(&workspace, &home, &network); + let snap = scan_with_configured( + &workspace, + Some(&home), + None, + SkillAuditMode::OwnedOnly, + None, + ); + let skill = snap.skills.first().expect("managed skill through symlink"); + let err = execute_sync( + SkillMutationRequest::Remove { + skill_id: skill.id.clone(), + expected_digest: Some(digest), + }, + &c, + ) + .unwrap_err(); + assert!(err.to_string().contains("symlinked"), "got: {err}"); + assert!(outside_root.join("managed").join("SKILL.md").is_file()); + } + + #[cfg(unix)] + #[test] + fn trust_rejects_symlinked_owned_parent_without_external_marker_write() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let outside_parent = tmp.path().join("outside-parent"); + let outside_root = outside_parent.join("skills"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&outside_root).unwrap(); + let digest = write_managed_skill(&outside_root, "managed"); + std::os::unix::fs::symlink(&outside_parent, workspace.join(".codewhale")).unwrap(); + + let network = NetworkPolicy::default(); + let c = ctx(&workspace, &home, &network); + let snap = scan_with_configured( + &workspace, + Some(&home), + None, + SkillAuditMode::OwnedOnly, + None, + ); + let skill = snap.skills.first().expect("managed skill through symlink"); + let err = execute_sync( + SkillMutationRequest::Trust { + skill_id: skill.id.clone(), + expected_digest: digest, + }, + &c, + ) + .unwrap_err(); + assert!(err.to_string().contains("symlinked"), "got: {err}"); + assert!( + !outside_root + .join("managed") + .join(install::TRUSTED_MARKER) + .exists() + ); + } + + #[test] + fn remove_and_trust_require_managed_owned() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let network = NetworkPolicy::default(); + let c = ctx(&workspace, &home, &network); + + write_skill( + &workspace.join(".codewhale").join("skills"), + "manual", + "body", + ); + let err = resolve_owned_skill_by_name(&c, "manual", Some(SkillTargetScope::Project)); + // Manual skill resolves, but remove/trust should fail source_kind check. + let resolved = err.unwrap(); + let remove = remove_skill(resolved.id.clone(), Some(resolved.digest.clone()), &c); + assert!(remove.is_err()); + + write_skill(&workspace.join(".claude").join("skills"), "ext", "body"); + // Place sentinel in external root. + let sentinel = workspace.join(".claude").join("skills").join("SENTINEL"); + fs::write(&sentinel, "do-not-touch").unwrap(); + + let err = resolve_owned_skill_by_name(&c, "ext", None); + assert!(err.unwrap_err().to_string().contains("compatible external")); + assert_eq!(fs::read_to_string(&sentinel).unwrap(), "do-not-touch"); + } + + #[test] + fn import_external_copies_into_project_owned() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let network = NetworkPolicy::default(); + let c = ctx(&workspace, &home, &network); + + fs::create_dir_all(workspace.join(".codewhale").join("skills")).unwrap(); + write_skill( + &workspace.join(".claude").join("skills"), + "from-claude", + "import-me", + ); + let sentinel = workspace.join(".claude").join("skills").join("SENTINEL"); + fs::write(&sentinel, "keep").unwrap(); + + let snap = scan_with_configured( + &workspace, + Some(&home), + None, + SkillAuditMode::Compatible, + None, + ); + let external = snap + .skills + .iter() + .find(|s| s.name == "from-claude") + .unwrap(); + let DigestState::Known(digest) = &external.digest else { + panic!("digest"); + }; + + let receipt = import_external( + external.id.clone(), + digest.clone(), + SkillTargetScope::Project, + ConflictPolicy::Reject, + &c, + ) + .unwrap(); + assert_eq!(receipt.outcome, SkillMutationOutcome::Imported); + assert!( + workspace + .join(".codewhale") + .join("skills") + .join("from-claude") + .join("SKILL.md") + .is_file() + ); + assert_eq!(fs::read_to_string(&sentinel).unwrap(), "keep"); + // Source untouched. + assert!( + workspace + .join(".claude") + .join("skills") + .join("from-claude") + .join("SKILL.md") + .is_file() + ); + } + + #[test] + fn import_exact_duplicate_is_already_present() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let network = NetworkPolicy::default(); + let c = ctx(&workspace, &home, &network); + + let content = "---\nname: shared\ndescription: d\n---\nbody\n"; + let owned = workspace.join(".codewhale").join("skills").join("shared"); + let external = workspace.join(".claude").join("skills").join("shared"); + fs::create_dir_all(&owned).unwrap(); + fs::create_dir_all(&external).unwrap(); + fs::write(owned.join("SKILL.md"), content).unwrap(); + fs::write(external.join("SKILL.md"), content).unwrap(); + + let snap = scan_with_configured( + &workspace, + Some(&home), + None, + SkillAuditMode::Compatible, + None, + ); + let external = snap + .skills + .iter() + .find(|s| s.name == "shared" && s.source_kind == SkillSourceKind::CompatibleExternal) + .unwrap(); + let DigestState::Known(digest) = &external.digest else { + panic!("digest"); + }; + + let receipt = import_external( + external.id.clone(), + digest.clone(), + SkillTargetScope::Project, + ConflictPolicy::Reject, + &c, + ) + .unwrap(); + assert_eq!(receipt.outcome, SkillMutationOutcome::AlreadyPresent); + } + + #[test] + fn trust_writes_v2_digest_binding() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let network = NetworkPolicy::default(); + let c = ctx(&workspace, &home, &network); + let root = workspace.join(".codewhale").join("skills"); + write_skill(&root, "managed", "body"); + let digest = package_digest::compute_package_digest(&root.join("managed")).unwrap(); + write_installed_from_v2( + &root.join("managed"), + "github:o/r", + None, + "src", + &digest, + "managed", + ) + .unwrap(); + + let snap = scan_with_configured( + &workspace, + Some(&home), + None, + SkillAuditMode::OwnedOnly, + None, + ); + let skill = &snap.skills[0]; + let receipt = trust_skill(skill.id.clone(), digest.clone(), &c).unwrap(); + assert_eq!(receipt.outcome, SkillMutationOutcome::Trusted); + let trust_body = + fs::read_to_string(root.join("managed").join(install::TRUSTED_MARKER)).unwrap(); + assert!(trust_body.contains("schema_version")); + assert!(trust_body.contains(&digest)); + } + + #[test] + fn remove_managed_skill() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let network = NetworkPolicy::default(); + let c = ctx(&workspace, &home, &network); + let root = workspace.join(".codewhale").join("skills"); + write_skill(&root, "managed", "body"); + let digest = package_digest::compute_package_digest(&root.join("managed")).unwrap(); + write_installed_from_v2( + &root.join("managed"), + "github:o/r", + None, + "src", + &digest, + "managed", + ) + .unwrap(); + + let snap = scan_with_configured( + &workspace, + Some(&home), + None, + SkillAuditMode::OwnedOnly, + None, + ); + let skill = &snap.skills[0]; + let receipt = remove_skill(skill.id.clone(), Some(digest), &c).unwrap(); + assert_eq!(receipt.outcome, SkillMutationOutcome::Removed); + assert!(!root.join("managed").exists()); + } + + #[test] + fn resolve_by_name_normalizes_like_registry() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let network = NetworkPolicy::default(); + let c = ctx(&workspace, &home, &network); + let root = workspace.join(".codewhale").join("skills"); + write_skill(&root, "Hello_World", "body"); + let digest = package_digest::compute_package_digest(&root.join("Hello_World")).unwrap(); + write_installed_from_v2( + &root.join("Hello_World"), + "github:o/r", + None, + "src", + &digest, + "Hello_World", + ) + .unwrap(); + + let resolved = + resolve_owned_skill_by_name(&c, "Hello_World", Some(SkillTargetScope::Project)) + .unwrap(); + assert_eq!(resolved.id.canonical_name, "hello-world"); + assert_eq!( + resolve_owned_skill_by_name(&c, "hello-world", Some(SkillTargetScope::Project)) + .unwrap() + .id, + resolved.id + ); + } + + #[test] + fn remove_uses_on_disk_dir_not_canonical_name() { + // Frontmatter/dir keep underscores+case; audit canonicalizes to dashes+lower. + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let network = NetworkPolicy::default(); + let c = ctx(&workspace, &home, &network); + let root = workspace.join(".codewhale").join("skills"); + let dir_name = "Hello_World"; + write_skill(&root, dir_name, "body"); + let digest = package_digest::compute_package_digest(&root.join(dir_name)).unwrap(); + write_installed_from_v2( + &root.join(dir_name), + "github:o/r", + None, + "src", + &digest, + dir_name, + ) + .unwrap(); + + let snap = scan_with_configured( + &workspace, + Some(&home), + None, + SkillAuditMode::OwnedOnly, + None, + ); + let skill = snap + .skills + .iter() + .find(|s| s.id.canonical_name == "hello-world") + .expect("canonical name should fold Hello_World → hello-world"); + assert_ne!(skill.id.canonical_name, dir_name); + assert_eq!( + skill.id.relative_dir.file_name().unwrap().to_str().unwrap(), + dir_name + ); + + let receipt = remove_skill(skill.id.clone(), Some(digest), &c).unwrap(); + assert_eq!(receipt.outcome, SkillMutationOutcome::Removed); + assert!(!root.join(dir_name).exists()); + } + + #[test] + fn imported_skill_rejects_registry_update() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let network = NetworkPolicy::default(); + let c = ctx(&workspace, &home, &network); + + fs::create_dir_all(workspace.join(".codewhale").join("skills")).unwrap(); + write_skill( + &workspace.join(".claude").join("skills"), + "from-claude", + "import-me", + ); + let snap = scan_with_configured( + &workspace, + Some(&home), + None, + SkillAuditMode::Compatible, + None, + ); + let external = snap + .skills + .iter() + .find(|s| s.name == "from-claude") + .unwrap(); + let DigestState::Known(digest) = &external.digest else { + panic!("digest"); + }; + import_external( + external.id.clone(), + digest.clone(), + SkillTargetScope::Project, + ConflictPolicy::Reject, + &c, + ) + .unwrap(); + + let owned = workspace + .join(".codewhale") + .join("skills") + .join("from-claude"); + assert!(ensure_remote_updatable(&owned).is_err()); + assert!(!install::is_registry_updatable_spec( + "import:/.claude/skills/from-claude" + )); + assert!(install::is_registry_updatable_spec("github:owner/repo")); + } + + #[test] + fn import_rejects_when_owned_dir_differs_from_canonical() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let network = NetworkPolicy::default(); + let c = ctx(&workspace, &home, &network); + + let owned_root = workspace.join(".codewhale").join("skills"); + write_skill(&owned_root, "Hello_World", "original-owned"); + let original_digest = + package_digest::compute_package_digest(&owned_root.join("Hello_World")).unwrap(); + write_installed_from_v2( + &owned_root.join("Hello_World"), + "github:o/r", + None, + "src", + &original_digest, + "Hello_World", + ) + .unwrap(); + + write_skill( + &workspace.join(".claude").join("skills"), + "Hello_World", + "external-different", + ); + let snap = scan_with_configured( + &workspace, + Some(&home), + None, + SkillAuditMode::Compatible, + None, + ); + let external = snap + .skills + .iter() + .find(|s| { + s.id.canonical_name == "hello-world" + && s.source_kind == SkillSourceKind::CompatibleExternal + }) + .unwrap(); + let DigestState::Known(ext_digest) = &external.digest else { + panic!("digest"); + }; + + let err = import_external( + external.id.clone(), + ext_digest.clone(), + SkillTargetScope::Project, + ConflictPolicy::Reject, + &c, + ); + assert!(err.unwrap_err().to_string().contains("already exists")); + assert!(owned_root.join("Hello_World").exists()); + assert!(!owned_root.join("hello-world").exists()); + let still = + package_digest::compute_package_digest(&owned_root.join("Hello_World")).unwrap(); + assert_eq!(still, original_digest); + + let receipt = import_external( + external.id.clone(), + ext_digest.clone(), + SkillTargetScope::Project, + ConflictPolicy::ReplaceConfirmed, + &c, + ) + .unwrap(); + assert_eq!(receipt.outcome, SkillMutationOutcome::Imported); + assert!(owned_root.join("Hello_World").exists()); + assert!(!owned_root.join("hello-world").exists()); + assert!(!owned_root.join("Hello_World.bak").exists()); + let after = + package_digest::compute_package_digest(&owned_root.join("Hello_World")).unwrap(); + assert_eq!(after, *ext_digest); + } + + #[test] + fn import_conflict_reject_keeps_owned_and_replace_cleans_backup() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let network = NetworkPolicy::default(); + let c = ctx(&workspace, &home, &network); + + let owned_root = workspace.join(".codewhale").join("skills"); + write_skill(&owned_root, "shared", "original-owned"); + let original_digest = + package_digest::compute_package_digest(&owned_root.join("shared")).unwrap(); + write_installed_from_v2( + &owned_root.join("shared"), + "github:o/r", + None, + "src", + &original_digest, + "shared", + ) + .unwrap(); + + write_skill( + &workspace.join(".claude").join("skills"), + "shared", + "external-different", + ); + let snap = scan_with_configured( + &workspace, + Some(&home), + None, + SkillAuditMode::Compatible, + None, + ); + let external = snap + .skills + .iter() + .find(|s| s.name == "shared" && s.source_kind == SkillSourceKind::CompatibleExternal) + .unwrap(); + let DigestState::Known(ext_digest) = &external.digest else { + panic!("digest"); + }; + + let err = import_external( + external.id.clone(), + ext_digest.clone(), + SkillTargetScope::Project, + ConflictPolicy::Reject, + &c, + ); + assert!(err.is_err()); + let still = package_digest::compute_package_digest(&owned_root.join("shared")).unwrap(); + assert_eq!(still, original_digest); + + let receipt = import_external( + external.id.clone(), + ext_digest.clone(), + SkillTargetScope::Project, + ConflictPolicy::ReplaceConfirmed, + &c, + ) + .unwrap(); + assert_eq!(receipt.outcome, SkillMutationOutcome::Imported); + let after = package_digest::compute_package_digest(&owned_root.join("shared")).unwrap(); + assert_eq!(after, *ext_digest); + assert!(!owned_root.join("shared.bak").exists()); + } + + #[test] + fn digest_mismatch_rejects_remove() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let network = NetworkPolicy::default(); + let c = ctx(&workspace, &home, &network); + let root = workspace.join(".codewhale").join("skills"); + write_skill(&root, "managed", "body"); + let digest = package_digest::compute_package_digest(&root.join("managed")).unwrap(); + write_installed_from_v2( + &root.join("managed"), + "github:o/r", + None, + "src", + &digest, + "managed", + ) + .unwrap(); + + let snap = scan_with_configured( + &workspace, + Some(&home), + None, + SkillAuditMode::OwnedOnly, + None, + ); + let err = remove_skill(snap.skills[0].id.clone(), Some("deadbeef".into()), &c); + assert!(err.unwrap_err().to_string().contains("changed since audit")); + assert!(root.join("managed").exists()); + } + + #[test] + fn dual_scope_same_name_requires_explicit_scope() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let network = NetworkPolicy::default(); + let c = ctx(&workspace, &home, &network); + + write_skill( + &workspace.join(".codewhale").join("skills"), + "dup", + "project", + ); + write_skill(&home.join(".codewhale").join("skills"), "dup", "global"); + + let err = resolve_owned_skill_by_name(&c, "dup", None).unwrap_err(); + assert!( + err.to_string().contains("--project") && err.to_string().contains("--global"), + "got: {err}" + ); + + let project = + resolve_owned_skill_by_name(&c, "dup", Some(SkillTargetScope::Project)).unwrap(); + let global = + resolve_owned_skill_by_name(&c, "dup", Some(SkillTargetScope::Global)).unwrap(); + assert_ne!(project.id.root_id, global.id.root_id); + assert_eq!(project.id.canonical_name, "dup"); + assert_eq!(global.id.canonical_name, "dup"); + } + + #[test] + fn uninstall_external_only_refuses_and_keeps_sentinel() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let network = NetworkPolicy::default(); + let c = ctx(&workspace, &home, &network); + + fs::create_dir_all(workspace.join(".codewhale").join("skills")).unwrap(); + write_skill( + &workspace.join(".claude").join("skills"), + "only-ext", + "body", + ); + let sentinel = workspace.join(".claude").join("skills").join("SENTINEL"); + fs::write(&sentinel, "untouched").unwrap(); + + let err = resolve_owned_skill_by_name(&c, "only-ext", None).unwrap_err(); + assert!( + err.to_string().contains("compatible external"), + "got: {err}" + ); + assert_eq!(fs::read_to_string(&sentinel).unwrap(), "untouched"); + assert!( + workspace + .join(".claude") + .join("skills") + .join("only-ext") + .join("SKILL.md") + .is_file() + ); + } +} diff --git a/crates/tui/src/skills/package_digest.rs b/crates/tui/src/skills/package_digest.rs new file mode 100644 index 0000000000..8651c0cc93 --- /dev/null +++ b/crates/tui/src/skills/package_digest.rs @@ -0,0 +1,158 @@ +//! Bounded package content digest shared by audit and mutation. +//! +//! Kept separate so `install` can write metadata v2 without depending on the +//! audit module (which itself depends on install marker constants). + +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +use super::install::{DEFAULT_MAX_SIZE_BYTES, INSTALLED_FROM_MARKER, TRUSTED_MARKER}; + +pub const PACKAGE_DIGEST_MAX_BYTES: u64 = DEFAULT_MAX_SIZE_BYTES; +pub const PACKAGE_DIGEST_MAX_FILES: usize = 256; +pub const PACKAGE_DIGEST_MAX_DEPTH: usize = 8; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PackageDigestError { + Unreadable, + SymlinkPresent, + EscapedRoot, + Cycle, + Oversized, + TooManyFiles, + TooDeep, +} + +impl std::fmt::Display for PackageDigestError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Unreadable => "unreadable package file", + Self::SymlinkPresent => "symlink present in package", + Self::EscapedRoot => "path escaped package root", + Self::Cycle => "symlink/directory cycle", + Self::Oversized => "package exceeded size limit", + Self::TooManyFiles => "package exceeded file limit", + Self::TooDeep => "package exceeded depth limit", + }) + } +} + +impl std::error::Error for PackageDigestError {} + +/// SHA-256 hex of the normalized package manifest (relative path + len + bytes). +pub fn compute_package_digest(package_dir: &Path) -> Result { + let canonical_package = + fs::canonicalize(package_dir).map_err(|_| PackageDigestError::Unreadable)?; + + let mut files: Vec<(String, Vec)> = Vec::new(); + let mut total_bytes: u64 = 0; + let mut visited = HashSet::new(); + + walk( + package_dir, + &canonical_package, + 0, + &mut visited, + &mut files, + &mut total_bytes, + )?; + + files.sort_by(|a, b| a.0.cmp(&b.0)); + let mut hasher = Sha256::new(); + for (rel, bytes) in &files { + hasher.update(rel.as_bytes()); + hasher.update(b"\0"); + hasher.update((bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); + } + Ok(hex_digest(hasher.finalize())) +} + +/// Whether the package tree is safe (no symlink / escape / cycle) under limits. +#[allow(dead_code)] // used by future import/validation paths +pub fn package_is_path_safe(package_dir: &Path) -> bool { + compute_package_digest(package_dir).is_ok() +} + +fn walk( + dir: &Path, + package_root: &Path, + depth: usize, + visited: &mut HashSet, + files: &mut Vec<(String, Vec)>, + total_bytes: &mut u64, +) -> Result<(), PackageDigestError> { + if depth > PACKAGE_DIGEST_MAX_DEPTH { + return Err(PackageDigestError::TooDeep); + } + let meta = fs::symlink_metadata(dir).map_err(|_| PackageDigestError::Unreadable)?; + if meta.file_type().is_symlink() { + return Err(PackageDigestError::SymlinkPresent); + } + let canonical = fs::canonicalize(dir).map_err(|_| PackageDigestError::Unreadable)?; + if !canonical.starts_with(package_root) { + return Err(PackageDigestError::EscapedRoot); + } + if !visited.insert(canonical) { + return Err(PackageDigestError::Cycle); + } + + let entries = fs::read_dir(dir).map_err(|_| PackageDigestError::Unreadable)?; + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + continue; + }; + + let meta = fs::symlink_metadata(&path).map_err(|_| PackageDigestError::Unreadable)?; + if meta.file_type().is_symlink() { + return Err(PackageDigestError::SymlinkPresent); + } + + if name == INSTALLED_FROM_MARKER + || name == TRUSTED_MARKER + || name == ".system-installed-version" + || name.ends_with(".bak") + || name.ends_with(".tmp") + || name.starts_with('.') + { + continue; + } + + if meta.is_dir() { + walk(&path, package_root, depth + 1, visited, files, total_bytes)?; + continue; + } + if !meta.is_file() { + continue; + } + if files.len() >= PACKAGE_DIGEST_MAX_FILES { + return Err(PackageDigestError::TooManyFiles); + } + let len = meta.len(); + if *total_bytes + len > PACKAGE_DIGEST_MAX_BYTES { + return Err(PackageDigestError::Oversized); + } + let bytes = fs::read(&path).map_err(|_| PackageDigestError::Unreadable)?; + *total_bytes += bytes.len() as u64; + let rel = path + .strip_prefix(package_root) + .map(|p| p.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|_| name.to_string()); + files.push((rel, bytes)); + } + Ok(()) +} + +fn hex_digest(bytes: impl AsRef<[u8]>) -> String { + let bytes = bytes.as_ref(); + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write as _; + let _ = write!(&mut out, "{byte:02x}"); + } + out +} diff --git a/crates/tui/src/skills/roots.rs b/crates/tui/src/skills/roots.rs new file mode 100644 index 0000000000..c45806e20b --- /dev/null +++ b/crates/tui/src/skills/roots.rs @@ -0,0 +1,873 @@ +//! Single source of truth for skill root enumeration, ownership, scope, and +//! runtime precedence. +//! +//! Runtime discovery and (later) audit/mutation share this catalog so +//! precedence cannot drift between modules. Discovery directories are not +//! write targets: only explicitly owned CodeWhale roots are writable. + +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Stable identifier for a skill root within a catalog snapshot. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SkillRootId(String); + +impl SkillRootId { + #[must_use] + #[allow(dead_code)] // consumed by audit/mutation in later #4651 stages + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for SkillRootId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// External harness layouts that CodeWhale can discover/audit but never owns. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CompatibleHarness { + Agents, + Claude, + Cursor, + OpenCode, + Codex, + DeepSeekLegacy, + /// Flat `/skills` layout. + FlatProjectSkills, +} + +impl CompatibleHarness { + #[must_use] + #[allow(dead_code)] // consumed by audit UI labels in later #4651 stages + pub fn label(self) -> &'static str { + match self { + Self::Agents => "agents", + Self::Claude => "claude", + Self::Cursor => "cursor", + Self::OpenCode => "opencode", + Self::Codex => "codex", + Self::DeepSeekLegacy => "deepseek", + Self::FlatProjectSkills => "flat-skills", + } + } +} + +/// Kind of skill root on disk (or logical source). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[allow(dead_code)] // BuiltIn / ReviewedPluginSnapshot used by later #4651 stages +pub enum SkillRootKind { + CodeWhaleProject, + CodeWhaleGlobal, + CompatibleProject(CompatibleHarness), + CompatibleGlobal(CompatibleHarness), + /// Explicitly configured `skills_dir` that is not one of the owned roots. + Configured, + BuiltIn, + ReviewedPluginSnapshot, + RegistryCache, +} + +/// Whether CodeWhale may mutate files under this root. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[allow(dead_code)] // Immutable used by later #4651 stages +pub enum SkillRootAccess { + /// CodeWhale-owned project/global install targets. + WritableOwned, + /// Compatible harness roots and unclassified configured dirs — read only. + ReadOnlyExternal, + /// Built-in / reviewed plugin snapshot content. + Immutable, + /// Registry download cache — not an active install target. + CacheOnly, +} + +/// Project vs global scope for owned and compatible roots. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SkillScope { + Project, + Global, + /// Logical / non-filesystem sources (built-in, plugin snapshot, cache). + Logical, +} + +/// One enumerated skill root with ownership and precedence metadata. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillRootDescriptor { + pub id: SkillRootId, + pub kind: SkillRootKind, + pub access: SkillRootAccess, + pub scope: SkillScope, + pub path: PathBuf, + pub canonical_path: Option, + /// Lower value = higher precedence for first-wins runtime merge. + pub precedence: Option, + /// When true, runtime skill discovery includes this root. + pub active_for_runtime: bool, + /// When true, owned-only / compatible audit may include this root. + pub active_for_audit: bool, +} + +impl SkillRootDescriptor { + #[must_use] + pub fn is_writable_owned(&self) -> bool { + self.access == SkillRootAccess::WritableOwned + } + + /// Home-relative or workspace-relative path for UI / receipts. + #[must_use] + #[allow(dead_code)] // consumed by manager receipts in later #4651 stages + pub fn safe_display_path(&self, workspace: Option<&Path>, home: Option<&Path>) -> String { + safe_display_path(&self.path, workspace, home) + } +} + +/// Catalog of skill roots for a workspace (+ optional HOME override for tests). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillRootCatalog { + roots: Vec, +} + +impl SkillRootCatalog { + /// Build the full catalog: owned + compatible (including Codex audit-only) + /// plus optional configured dir and logical sources. + #[must_use] + pub fn build( + workspace: &Path, + home_dir: Option<&Path>, + configured_skills_dir: Option<&Path>, + ) -> Self { + let mut roots = Vec::new(); + let mut precedence = 0usize; + + // Runtime-compatible workspace roots (existing order — do not reorder). + push_existing( + &mut roots, + &mut precedence, + SkillRootKind::CompatibleProject(CompatibleHarness::Agents), + SkillRootAccess::ReadOnlyExternal, + SkillScope::Project, + workspace.join(".agents").join("skills"), + true, + true, + "project-agents", + ); + push_existing( + &mut roots, + &mut precedence, + SkillRootKind::CompatibleProject(CompatibleHarness::FlatProjectSkills), + SkillRootAccess::ReadOnlyExternal, + SkillScope::Project, + workspace.join("skills"), + true, + true, + "project-flat-skills", + ); + push_existing( + &mut roots, + &mut precedence, + SkillRootKind::CompatibleProject(CompatibleHarness::OpenCode), + SkillRootAccess::ReadOnlyExternal, + SkillScope::Project, + workspace.join(".opencode").join("skills"), + true, + true, + "project-opencode", + ); + push_existing( + &mut roots, + &mut precedence, + SkillRootKind::CompatibleProject(CompatibleHarness::Claude), + SkillRootAccess::ReadOnlyExternal, + SkillScope::Project, + workspace.join(".claude").join("skills"), + true, + true, + "project-claude", + ); + push_existing( + &mut roots, + &mut precedence, + SkillRootKind::CompatibleProject(CompatibleHarness::Cursor), + SkillRootAccess::ReadOnlyExternal, + SkillScope::Project, + workspace.join(".cursor").join("skills"), + true, + true, + "project-cursor", + ); + + // CodeWhale project root — always listed for ownership; runtime + // CodeWhale-only mode additionally requires the path stay inside the + // workspace (symlink escape check happens in path selection helpers). + let project_owned = workspace.join(".codewhale").join("skills"); + push_descriptor( + &mut roots, + &mut precedence, + SkillRootKind::CodeWhaleProject, + SkillRootAccess::WritableOwned, + SkillScope::Project, + project_owned, + true, + true, + "project-codewhale", + true, // include even if missing — owned target may be created later + ); + + // Codex project: audit-compatible only; never active for runtime in #4651. + push_existing( + &mut roots, + &mut precedence, + SkillRootKind::CompatibleProject(CompatibleHarness::Codex), + SkillRootAccess::ReadOnlyExternal, + SkillScope::Project, + workspace.join(".codex").join("skills"), + false, + true, + "project-codex", + ); + + if let Some(home) = home_dir { + push_existing( + &mut roots, + &mut precedence, + SkillRootKind::CompatibleGlobal(CompatibleHarness::Agents), + SkillRootAccess::ReadOnlyExternal, + SkillScope::Global, + home.join(".agents").join("skills"), + true, + true, + "global-agents", + ); + push_existing( + &mut roots, + &mut precedence, + SkillRootKind::CompatibleGlobal(CompatibleHarness::Claude), + SkillRootAccess::ReadOnlyExternal, + SkillScope::Global, + home.join(".claude").join("skills"), + true, + true, + "global-claude", + ); + + let global_owned = home.join(".codewhale").join("skills"); + push_descriptor( + &mut roots, + &mut precedence, + SkillRootKind::CodeWhaleGlobal, + SkillRootAccess::WritableOwned, + SkillScope::Global, + global_owned, + true, + true, + "global-codewhale", + true, + ); + + push_existing( + &mut roots, + &mut precedence, + SkillRootKind::CompatibleGlobal(CompatibleHarness::DeepSeekLegacy), + SkillRootAccess::ReadOnlyExternal, + SkillScope::Global, + home.join(".deepseek").join("skills"), + true, + true, + "global-deepseek", + ); + + // Codex global: audit-compatible only. + push_existing( + &mut roots, + &mut precedence, + SkillRootKind::CompatibleGlobal(CompatibleHarness::Codex), + SkillRootAccess::ReadOnlyExternal, + SkillScope::Global, + home.join(".codex").join("skills"), + false, + true, + "global-codex", + ); + + // Registry cache is never an active skill root. + let cache = home.join(".codewhale").join("cache").join("skills"); + push_descriptor( + &mut roots, + &mut precedence, + SkillRootKind::RegistryCache, + SkillRootAccess::CacheOnly, + SkillScope::Logical, + cache, + false, + false, + "registry-cache", + false, + ); + } else { + // Match legacy fallback when HOME is unavailable. + push_descriptor( + &mut roots, + &mut precedence, + SkillRootKind::CodeWhaleGlobal, + SkillRootAccess::WritableOwned, + SkillScope::Global, + PathBuf::from("/tmp/codewhale/skills"), + true, + true, + "global-codewhale-fallback", + true, + ); + } + + if let Some(configured) = configured_skills_dir { + insert_configured_root(&mut roots, workspace, home_dir, configured, &mut precedence); + } + + Self { roots } + } + + #[must_use] + #[allow(dead_code)] // consumed by audit scanners in later #4651 stages + pub fn roots(&self) -> &[SkillRootDescriptor] { + &self.roots + } + + /// Paths used by runtime discovery for the given mode (existing dirs only, + /// first-wins order preserved). CodeWhale-only applies the workspace + /// containment check for the project owned root. + #[must_use] + pub fn runtime_directories( + &self, + workspace: &Path, + mode: super::SkillDiscoveryMode, + ) -> Vec { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + + for root in &self.roots { + if !root.active_for_runtime { + continue; + } + match mode { + super::SkillDiscoveryMode::Compatible => {} + super::SkillDiscoveryMode::CodeWhaleOnly => { + if !matches!( + root.kind, + SkillRootKind::CodeWhaleProject + | SkillRootKind::CodeWhaleGlobal + | SkillRootKind::Configured + ) { + continue; + } + if root.kind == SkillRootKind::CodeWhaleProject + && !codewhale_project_root_is_inside_workspace(workspace, &root.path) + { + continue; + } + } + } + + if !path_is_existing_dir(&root.path) { + continue; + } + let Ok(canonical) = fs::canonicalize(&root.path) else { + continue; + }; + if !canonical.is_dir() || !seen.insert(canonical) { + continue; + } + out.push(root.path.clone()); + } + out + } + + /// Owned CodeWhale project + global roots (may not exist yet). + #[must_use] + pub fn owned_writable_roots(&self) -> Vec<&SkillRootDescriptor> { + self.roots + .iter() + .filter(|r| r.is_writable_owned()) + .collect() + } + + /// Roots eligible for owned-only audit (writable owned roots that exist). + #[must_use] + #[allow(dead_code)] // consumed by owned audit mode in later #4651 stages + pub fn audit_owned_directories(&self) -> Vec<&SkillRootDescriptor> { + self.roots + .iter() + .filter(|r| { + r.is_writable_owned() && r.active_for_audit && path_is_existing_dir(&r.path) + }) + .collect() + } + + /// Owned + compatible roots for explicit `--compatible` audit, including + /// Codex. Does not change runtime activation. + #[must_use] + pub fn audit_compatible_directories(&self) -> Vec<&SkillRootDescriptor> { + self.roots + .iter() + .filter(|r| { + r.active_for_audit + && !matches!( + r.kind, + SkillRootKind::RegistryCache + | SkillRootKind::BuiltIn + | SkillRootKind::ReviewedPluginSnapshot + ) + && path_is_existing_dir(&r.path) + }) + .collect() + } +} + +/// Resolve candidate skill directories for runtime discovery (existing paths +/// only), preserving historical precedence. +#[must_use] +pub fn skills_directories_with_home_and_mode( + workspace: &Path, + home_dir: Option<&Path>, + mode: super::SkillDiscoveryMode, +) -> Vec { + SkillRootCatalog::build(workspace, home_dir, None).runtime_directories(workspace, mode) +} + +/// CodeWhale project skills dir when it exists and stays inside the workspace. +#[must_use] +pub fn codewhale_workspace_skills_dir(workspace: &Path) -> Option { + let skills_dir = workspace.join(".codewhale").join("skills"); + codewhale_project_root_is_inside_workspace(workspace, &skills_dir).then_some(skills_dir) +} + +/// Filter candidate paths to existing directories, preserving order and +/// de-duplicating by canonical path. +#[cfg(test)] +#[must_use] +pub fn existing_skill_dirs(candidates: impl IntoIterator) -> Vec { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for path in candidates { + let Ok(canonical_path) = fs::canonicalize(&path) else { + continue; + }; + if canonical_path.is_dir() && seen.insert(canonical_path) { + out.push(path); + } + } + out +} + +/// Classify a configured `skills_dir`: owned only when it is exactly a +/// CodeWhale project/global root; compatible harness paths stay read-only. +#[must_use] +pub fn classify_configured_skills_dir( + workspace: &Path, + home_dir: Option<&Path>, + skills_dir: &Path, +) -> (SkillRootKind, SkillRootAccess, SkillScope) { + let project_owned = workspace.join(".codewhale").join("skills"); + if paths_refer_to_same_dir(&project_owned, skills_dir) { + return ( + SkillRootKind::CodeWhaleProject, + SkillRootAccess::WritableOwned, + SkillScope::Project, + ); + } + if let Some(home) = home_dir { + let global_owned = home.join(".codewhale").join("skills"); + if paths_refer_to_same_dir(&global_owned, skills_dir) { + return ( + SkillRootKind::CodeWhaleGlobal, + SkillRootAccess::WritableOwned, + SkillScope::Global, + ); + } + } + + if let Some(harness) = match_compatible_project(workspace, skills_dir) { + return ( + SkillRootKind::CompatibleProject(harness), + SkillRootAccess::ReadOnlyExternal, + SkillScope::Project, + ); + } + if let Some(home) = home_dir + && let Some(harness) = match_compatible_global(home, skills_dir) + { + return ( + SkillRootKind::CompatibleGlobal(harness), + SkillRootAccess::ReadOnlyExternal, + SkillScope::Global, + ); + } + + // Unknown configured path: treat as external until an explicit owned-root + // marker exists (Issue #4651 first cut — do not guess writability). + let scope = fs::canonicalize(workspace) + .ok() + .map_or(SkillScope::Global, |root| { + fs::canonicalize(skills_dir) + .ok() + .filter(|p| p.starts_with(&root)) + .map_or(SkillScope::Global, |_| SkillScope::Project) + }); + ( + SkillRootKind::Configured, + SkillRootAccess::ReadOnlyExternal, + scope, + ) +} + +#[must_use] +pub fn safe_display_path(path: &Path, workspace: Option<&Path>, home: Option<&Path>) -> String { + // Prefer workspace when both apply so project roots stay distinct from + // `~/...` global paths that happen to live under the same home tree. + if let Some(workspace) = workspace + && let Ok(stripped) = path.strip_prefix(workspace) + { + return format!("/{}", stripped.display()).replace('\\', "/"); + } + if let Some(home) = home + && let Ok(stripped) = path.strip_prefix(home) + { + return format!("~/{}", stripped.display()).replace('\\', "/"); + } + // Last resort: basename chain without expanding unrelated absolute parents. + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()) +} + +#[must_use] +pub fn paths_refer_to_same_dir(left: &Path, right: &Path) -> bool { + if left == right { + return true; + } + match (fs::canonicalize(left), fs::canonicalize(right)) { + (Ok(left), Ok(right)) => left == right, + _ => false, + } +} + +fn codewhale_project_root_is_inside_workspace(workspace: &Path, skills_dir: &Path) -> bool { + let Ok(canonical_workspace) = fs::canonicalize(workspace) else { + return false; + }; + let Ok(canonical_skills) = fs::canonicalize(skills_dir) else { + return false; + }; + canonical_skills.is_dir() && canonical_skills.starts_with(canonical_workspace) +} + +fn path_is_existing_dir(path: &Path) -> bool { + match fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => { + fs::canonicalize(path).ok().is_some_and(|p| p.is_dir()) + } + Ok(meta) => meta.is_dir(), + Err(_) => false, + } +} + +fn match_compatible_project(workspace: &Path, skills_dir: &Path) -> Option { + let candidates = [ + ( + CompatibleHarness::Agents, + workspace.join(".agents").join("skills"), + ), + ( + CompatibleHarness::FlatProjectSkills, + workspace.join("skills"), + ), + ( + CompatibleHarness::OpenCode, + workspace.join(".opencode").join("skills"), + ), + ( + CompatibleHarness::Claude, + workspace.join(".claude").join("skills"), + ), + ( + CompatibleHarness::Cursor, + workspace.join(".cursor").join("skills"), + ), + ( + CompatibleHarness::Codex, + workspace.join(".codex").join("skills"), + ), + ]; + for (harness, candidate) in candidates { + if paths_refer_to_same_dir(&candidate, skills_dir) { + return Some(harness); + } + } + None +} + +fn match_compatible_global(home: &Path, skills_dir: &Path) -> Option { + let candidates = [ + ( + CompatibleHarness::Agents, + home.join(".agents").join("skills"), + ), + ( + CompatibleHarness::Claude, + home.join(".claude").join("skills"), + ), + ( + CompatibleHarness::DeepSeekLegacy, + home.join(".deepseek").join("skills"), + ), + (CompatibleHarness::Codex, home.join(".codex").join("skills")), + ]; + for (harness, candidate) in candidates { + if paths_refer_to_same_dir(&candidate, skills_dir) { + return Some(harness); + } + } + None +} + +#[allow(clippy::too_many_arguments)] // catalog rows keep ownership flags explicit at call sites +fn push_existing( + roots: &mut Vec, + precedence: &mut usize, + kind: SkillRootKind, + access: SkillRootAccess, + scope: SkillScope, + path: PathBuf, + active_for_runtime: bool, + active_for_audit: bool, + id: &str, +) { + push_descriptor( + roots, + precedence, + kind, + access, + scope, + path, + active_for_runtime, + active_for_audit, + id, + false, + ); +} + +#[allow(clippy::too_many_arguments)] // shared constructor for the explicit catalog table above +fn push_descriptor( + roots: &mut Vec, + precedence: &mut usize, + kind: SkillRootKind, + access: SkillRootAccess, + scope: SkillScope, + path: PathBuf, + active_for_runtime: bool, + active_for_audit: bool, + id: &str, + include_missing: bool, +) { + let exists = path_is_existing_dir(&path); + if !include_missing && !exists { + return; + } + let canonical_path = fs::canonicalize(&path).ok(); + let slot = *precedence; + *precedence += 1; + roots.push(SkillRootDescriptor { + id: SkillRootId(id.to_string()), + kind, + access, + scope, + path, + canonical_path, + precedence: Some(slot), + active_for_runtime, + active_for_audit, + }); +} + +fn insert_configured_root( + roots: &mut Vec, + workspace: &Path, + home_dir: Option<&Path>, + skills_dir: &Path, + precedence: &mut usize, +) { + if !path_is_existing_dir(skills_dir) { + return; + } + if roots + .iter() + .any(|root| paths_refer_to_same_dir(&root.path, skills_dir)) + { + return; + } + + let (kind, access, scope) = classify_configured_skills_dir(workspace, home_dir, skills_dir); + let workspace_root = fs::canonicalize(workspace).ok(); + let insert_at = workspace_root + .as_ref() + .and_then(|root| { + roots.iter().position(|dir| { + fs::canonicalize(&dir.path).map_or(true, |dir| !dir.starts_with(root)) + }) + }) + .unwrap_or(roots.len()); + + let canonical_path = fs::canonicalize(skills_dir).ok(); + let slot = *precedence; + *precedence += 1; + let descriptor = SkillRootDescriptor { + id: SkillRootId(format!("configured-{slot}")), + kind, + access, + scope, + path: skills_dir.to_path_buf(), + canonical_path, + precedence: Some(slot), + active_for_runtime: true, + active_for_audit: true, + }; + roots.insert(insert_at, descriptor); + // Re-number precedence after insertion so catalog order stays consistent. + for (idx, root) in roots.iter_mut().enumerate() { + root.precedence = Some(idx); + } + *precedence = roots.len(); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::skills::SkillDiscoveryMode; + use tempfile::TempDir; + + fn write_dir(path: &Path) { + std::fs::create_dir_all(path).unwrap(); + } + + #[test] + fn runtime_compatible_preserves_historical_workspace_order() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + write_dir(&workspace.join(".agents").join("skills")); + write_dir(&workspace.join("skills")); + write_dir(&workspace.join(".claude").join("skills")); + write_dir(&workspace.join(".cursor").join("skills")); + write_dir(&workspace.join(".codewhale").join("skills")); + write_dir(&workspace.join(".codex").join("skills")); + write_dir(&home.join(".codewhale").join("skills")); + + let catalog = SkillRootCatalog::build(&workspace, Some(&home), None); + let dirs = catalog.runtime_directories(&workspace, SkillDiscoveryMode::Compatible); + + assert_eq!( + dirs, + vec![ + workspace.join(".agents").join("skills"), + workspace.join("skills"), + workspace.join(".claude").join("skills"), + workspace.join(".cursor").join("skills"), + workspace.join(".codewhale").join("skills"), + home.join(".codewhale").join("skills"), + ] + ); + assert!( + !dirs + .iter() + .any(|p| p == &workspace.join(".codex").join("skills")), + "codex must not activate for runtime" + ); + } + + #[test] + fn audit_compatible_includes_codex_without_runtime_activation() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + write_dir(&workspace.join(".codewhale").join("skills")); + write_dir(&workspace.join(".codex").join("skills")); + write_dir(&home.join(".codewhale").join("skills")); + write_dir(&home.join(".codex").join("skills")); + + let catalog = SkillRootCatalog::build(&workspace, Some(&home), None); + let audit: Vec<_> = catalog + .audit_compatible_directories() + .into_iter() + .map(|r| r.path.clone()) + .collect(); + assert!(audit.contains(&workspace.join(".codex").join("skills"))); + assert!(audit.contains(&home.join(".codex").join("skills"))); + + let runtime = catalog.runtime_directories(&workspace, SkillDiscoveryMode::Compatible); + assert!(!runtime.contains(&workspace.join(".codex").join("skills"))); + assert!(!runtime.contains(&home.join(".codex").join("skills"))); + } + + #[test] + fn owned_roots_are_writable_and_codewhale_only() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + write_dir(&workspace.join(".agents").join("skills")); + write_dir(&workspace.join(".codewhale").join("skills")); + write_dir(&home.join(".codewhale").join("skills")); + write_dir(&home.join(".agents").join("skills")); + + let catalog = SkillRootCatalog::build(&workspace, Some(&home), None); + let owned = catalog.owned_writable_roots(); + assert_eq!(owned.len(), 2); + assert!(owned.iter().all(|r| r.is_writable_owned())); + + let runtime = catalog.runtime_directories(&workspace, SkillDiscoveryMode::CodeWhaleOnly); + assert_eq!( + runtime, + vec![ + workspace.join(".codewhale").join("skills"), + home.join(".codewhale").join("skills"), + ] + ); + } + + #[test] + fn configured_compatible_path_stays_read_only() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + let agents = workspace.join(".agents").join("skills"); + write_dir(&workspace); + write_dir(&agents); + + let (kind, access, scope) = + classify_configured_skills_dir(&workspace, Some(&home), &agents); + assert_eq!( + kind, + SkillRootKind::CompatibleProject(CompatibleHarness::Agents) + ); + assert_eq!(access, SkillRootAccess::ReadOnlyExternal); + assert_eq!(scope, SkillScope::Project); + } + + #[test] + fn safe_display_path_prefers_home_then_workspace() { + let home = PathBuf::from("/home/user"); + let workspace = home.join("proj"); + let path = home.join(".codewhale").join("skills"); + assert_eq!( + safe_display_path(&path, Some(&workspace), Some(&home)), + "~/.codewhale/skills" + ); + let project = workspace.join(".codewhale").join("skills"); + assert_eq!( + safe_display_path(&project, Some(&workspace), Some(&home)), + "/.codewhale/skills" + ); + } +} diff --git a/crates/tui/src/skills/system.rs b/crates/tui/src/skills/system.rs index a1ef12e063..6ae9f7598e 100644 --- a/crates/tui/src/skills/system.rs +++ b/crates/tui/src/skills/system.rs @@ -92,11 +92,42 @@ const BUNDLED_SKILLS: &[BundledSkill] = &[ /// Used by `/skills` to distinguish user-created skills (which should be /// surfaced prominently) from the always-installed bundle (which can be /// rendered compactly when many skills are present). +/// +/// Prefer [`is_exact_bundled_skill`] when classifying audit rows — name-only +/// matches can collide with user overrides of the same command name. #[must_use] pub fn is_bundled_skill_name(name: &str) -> bool { BUNDLED_SKILLS.iter().any(|s| s.name == name) } +/// True when `name` is a bundled skill **and** `skill_md_content` exactly +/// matches the shipped asset body (byte-for-byte). +/// +/// Used by the skill audit inventory so a user-edited copy of a bundled name +/// is not misclassified as built-in. +#[must_use] +pub fn is_exact_bundled_skill(name: &str, skill_md_content: &str) -> bool { + BUNDLED_SKILLS + .iter() + .any(|s| s.name == name && s.body == skill_md_content) +} + +/// SHA-256 (hex) of the shipped `SKILL.md` body for a bundled skill, if any. +#[must_use] +#[allow(dead_code)] // available for managers / docs that prefer digest over body compare +pub fn bundled_skill_body_sha256(name: &str) -> Option { + use sha2::{Digest, Sha256}; + BUNDLED_SKILLS.iter().find(|s| s.name == name).map(|s| { + let digest = Sha256::digest(s.body.as_bytes()); + let mut out = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(&mut out, "{byte:02x}"); + } + out + }) +} + /// Attempt to install a single bundled skill into `skills_dir`. /// /// Returns `true` if installation occurred (fresh install or version bump). diff --git a/crates/tui/src/tool_output_receipts.rs b/crates/tui/src/tool_output_receipts.rs index 1c72ac60a9..0cf5864576 100644 --- a/crates/tui/src/tool_output_receipts.rs +++ b/crates/tui/src/tool_output_receipts.rs @@ -5,7 +5,6 @@ use serde_json::Value; use crate::artifacts::{ArtifactKind, ArtifactRecord, format_artifact_relative_path}; use crate::fast_hash::FastHashMap; use crate::models::{ContentBlock, Message}; -use crate::tools::truncate; /// Match the provider-wire budget so persisted/resumed history does not keep a /// larger raw body than the model would receive on a fresh request. @@ -38,13 +37,14 @@ struct ToolUseInfo { #[derive(Debug, Clone)] enum DetailHandle { Artifact(ArtifactRecord), - Sha { sha: String, persisted: bool }, + Unavailable { sha256: String }, } /// Return a copy of `messages` with oversized raw tool-result bodies replaced /// by compact receipts. Full output is kept behind existing session artifacts -/// when available; otherwise a SHA-addressed spillover copy is written for -/// `retrieve_tool_result`. +/// when available. Raw legacy results without a session-owned artifact are +/// compacted truthfully, but never receive a retrieval handle: a process-wide +/// SHA store cannot prove which session owns the bytes. pub fn compact_messages_for_persistence( messages: &[Message], artifacts: &[ArtifactRecord], @@ -89,18 +89,12 @@ pub fn compact_messages_for_persistence( .get(tool_use_id.as_str()) .cloned() .map(|artifact| DetailHandle::Artifact((*artifact).clone())) - .unwrap_or_else(|| DetailHandle::Sha { - sha: sha256_hex(content.as_bytes()), - persisted: persist_sha_tool_result(content), + .unwrap_or_else(|| DetailHandle::Unavailable { + sha256: sha256_hex(content.as_bytes()), }); let source = match &handle { DetailHandle::Artifact(_) => ReceiptSource::Artifact, - DetailHandle::Sha { - persisted: true, .. - } => ReceiptSource::Sha, - DetailHandle::Sha { - persisted: false, .. - } => ReceiptSource::Unavailable, + DetailHandle::Unavailable { .. } => ReceiptSource::Unavailable, }; *content = render_tool_output_receipt( @@ -114,7 +108,6 @@ pub fn compact_messages_for_persistence( stats.original_chars = stats.original_chars.saturating_add(char_count); match source { ReceiptSource::Artifact => stats.artifact_receipts += 1, - ReceiptSource::Sha => stats.sha_receipts += 1, ReceiptSource::Unavailable => stats.unavailable_receipts += 1, } } @@ -193,7 +186,6 @@ fn artifacts_by_tool_call(artifacts: &[ArtifactRecord]) -> FastHashMap<&str, &Ar #[derive(Debug, Clone, Copy)] enum ReceiptSource { Artifact, - Sha, Unavailable, } @@ -231,19 +223,11 @@ fn render_tool_output_receipt( format!("retrieve_tool_result ref={}", record.id), format_artifact_relative_path(&record.storage_path), ), - DetailHandle::Sha { sha, persisted } => { - let handle = format!("sha:{sha}"); - let storage = if *persisted { - "content-addressed spillover".to_string() - } else { - "unavailable; spillover write failed".to_string() - }; - ( - handle.clone(), - format!("retrieve_tool_result ref={handle}"), - storage, - ) - } + DetailHandle::Unavailable { sha256 } => ( + format!("unavailable (sha256:{sha256})"), + "unavailable".to_string(), + "no session-owned artifact was recorded".to_string(), + ), }; format!( @@ -267,19 +251,6 @@ fn render_tool_output_receipt( ) } -fn persist_sha_tool_result(content: &str) -> bool { - let sha = sha256_hex(content.as_bytes()); - match truncate::write_sha_spillover(&sha, content) { - Ok(_) => true, - Err(err) => { - crate::logging::warn(format!( - "tool-output receipt SHA spillover write failed for sha={sha}: {err}" - )); - false - } - } -} - fn preview_for_receipt(handle: &DetailHandle, original_content: &str) -> String { let preview = match handle { DetailHandle::Artifact(record) if !record.preview.trim().is_empty() => { @@ -351,11 +322,9 @@ fn format_count(value: usize) -> String { mod tests { use std::path::{Path, PathBuf}; + use super::*; use chrono::Utc; use serde_json::json; - use tempfile::tempdir; - - use super::*; fn tool_use_message(id: &str, name: &str, input: Value) -> Message { Message { @@ -426,22 +395,7 @@ mod tests { } #[test] - fn compacts_large_tool_result_to_sha_receipt_when_no_artifact_exists() { - let _guard = crate::tools::truncate::TEST_SPILLOVER_GUARD - .lock() - .unwrap_or_else(|err| err.into_inner()); - let tmp = tempdir().expect("tempdir"); - let prior = crate::tools::truncate::set_test_spillover_root(Some( - tmp.path().join(".deepseek").join("tool_outputs"), - )); - struct Restore(Option); - impl Drop for Restore { - fn drop(&mut self) { - crate::tools::truncate::set_test_spillover_root(self.0.take()); - } - } - let _restore = Restore(prior); - + fn compacts_unowned_large_tool_result_without_false_retrieval_handle() { let raw = format!("{}\n{}", "H".repeat(320), "NO_ARTIFACT_RAW\n".repeat(2_000)); let sha = sha256_hex(raw.as_bytes()); let messages = vec![ @@ -455,12 +409,13 @@ mod tests { }; assert_eq!(stats.compacted_count, 1); - assert_eq!(stats.sha_receipts, 1); + assert_eq!(stats.sha_receipts, 0); + assert_eq!(stats.unavailable_receipts, 1); assert!(!content.contains("NO_ARTIFACT_RAW")); - assert!(content.contains(&format!("detail_handle: sha:{sha}"))); - assert!(content.contains(&format!("retrieve: retrieve_tool_result ref=sha:{sha}"))); - let path = crate::tools::truncate::sha_spillover_path(&sha).expect("sha path"); - assert_eq!(std::fs::read_to_string(path).expect("read sha"), raw); + assert!(content.contains(&format!("detail_handle: unavailable (sha256:{sha})"))); + assert!(content.contains("retrieve: unavailable")); + assert!(content.contains("storage: no session-owned artifact was recorded")); + assert!(!content.contains("retrieve_tool_result")); } #[test] diff --git a/crates/tui/src/tools/apply_patch.rs b/crates/tui/src/tools/apply_patch.rs index 3a82edde42..63aae817f6 100644 --- a/crates/tui/src/tools/apply_patch.rs +++ b/crates/tui/src/tools/apply_patch.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use thiserror::Error; +use super::diff_format::make_unified_diff; use super::spec::{ ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, lsp_diagnostics_for_paths, optional_bool, optional_str, optional_u64, @@ -278,6 +279,10 @@ impl ToolSpec for ApplyPatchTool { "apply_patch" } + fn model_visible(&self) -> bool { + false + } + fn description(&self) -> &'static str { "Apply a unified-diff patch (multi-hunk, multi-file). Use this instead of `git apply`, `patch`, or repeated `edit_file` calls in `exec_shell` — single transactional change with fuzzy matching and a rendered diff." } @@ -379,8 +384,11 @@ impl ToolSpec for ApplyPatchTool { }; let mut tool_result = ToolResult::json(&result) .map_err(|e| ToolError::execution_failed(e.to_string()))?; - tool_result = - tool_result.with_metadata(apply_patch_preflight_metadata(&preflight.summary)); + tool_result = tool_result.with_metadata(apply_patch_result_metadata( + &preflight.summary, + &pending, + &stats, + )); if !diag_block.is_empty() { tool_result.content.push('\n'); tool_result.content.push_str(&diag_block); @@ -425,7 +433,11 @@ impl ToolSpec for ApplyPatchTool { }; let mut tool_result = ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))?; - tool_result = tool_result.with_metadata(apply_patch_preflight_metadata(&preflight.summary)); + tool_result = tool_result.with_metadata(apply_patch_result_metadata( + &preflight.summary, + &pending, + &stats, + )); if !diag_block.is_empty() { tool_result.content.push('\n'); tool_result.content.push_str(&diag_block); @@ -568,15 +580,102 @@ fn preflight_replace( }) } -fn apply_patch_preflight_metadata(preflight: &ApplyPatchPreflight) -> Value { +fn apply_patch_result_metadata( + preflight: &ApplyPatchPreflight, + pending: &[PendingWrite], + stats: &PatchStatsExt, +) -> Value { let mut metadata = serde_json::to_value(preflight).expect("ApplyPatchPreflight should serialize"); if let Some(object) = metadata.as_object_mut() { object.insert("event".to_string(), json!("apply_patch.preflight")); + object.insert( + "mutation".to_string(), + build_mutation_metadata(pending, &stats.file_summaries), + ); } metadata } +/// Preserve the exact applied before/after diff independently from approval +/// presentation. The TUI consumes this success-only metadata for its calm +/// File receipt; the normal model-facing result remains compact JSON. +fn build_mutation_metadata(pending: &[PendingWrite], summaries: &[FileSummary]) -> Value { + let mut matched = HashSet::new(); + let mut renames = Vec::new(); + + for (delete_index, (deleted, delete_summary)) in pending.iter().zip(summaries).enumerate() { + if !delete_summary.deleted || matched.contains(&delete_index) { + continue; + } + let Some(old_content) = deleted.original.as_deref() else { + continue; + }; + let Some((create_index, (_, create_summary))) = pending + .iter() + .zip(summaries) + .enumerate() + .find(|(index, (created, summary))| { + !matched.contains(index) + && summary.created + && created.content.as_deref() == Some(old_content) + }) + else { + continue; + }; + matched.insert(delete_index); + matched.insert(create_index); + renames.push(json!({ + "from": delete_summary.path, + "to": create_summary.path, + })); + } + + let mut files = Vec::new(); + for (index, summary) in summaries.iter().enumerate() { + if matched.contains(&index) { + continue; + } + let outcome = if summary.created { + "created" + } else if summary.deleted { + "deleted" + } else { + "updated" + }; + files.push(json!({ "path": summary.path, "outcome": outcome })); + } + + let mut diff_parts = Vec::new(); + for rename in &renames { + let from = rename["from"].as_str().unwrap_or(""); + let to = rename["to"].as_str().unwrap_or(""); + diff_parts.push(format!( + "diff --git a/{from} b/{to}\nsimilarity index 100%\nrename from {from}\nrename to {to}\n" + )); + } + for (index, (write, summary)) in pending.iter().zip(summaries).enumerate() { + if matched.contains(&index) { + continue; + } + let old = write.original.as_deref().unwrap_or(""); + let new = write.content.as_deref().unwrap_or(""); + let diff = make_unified_diff(&summary.path, old, new); + if !diff.is_empty() { + diff_parts.push(format!( + "diff --git a/{path} b/{path}\n{diff}", + path = summary.path + )); + } + } + + json!({ + "diff": diff_parts.join("\n"), + "files": files, + "renames": renames, + }) +} + /// Parse a unified diff into hunks fn parse_unified_diff(patch: &str) -> Result, ToolError> { let mut hunks = Vec::new(); @@ -1662,6 +1761,17 @@ diff --git a/same.txt b/same.txt .get("path_override") .is_some() ); + let mutation = &result.metadata.as_ref().unwrap()["mutation"]; + assert_eq!( + mutation["files"], + json!([{ "path": "test.txt", "outcome": "updated" }]) + ); + assert!( + mutation["diff"] + .as_str() + .is_some_and(|diff| diff.contains("-line2") && diff.contains("+modified")), + "{mutation}" + ); let patch_result = parse_patch_result(result); assert_eq!(patch_result.touched_files, vec!["test.txt"]); assert_eq!(patch_result.hunks_applied, 1); @@ -1735,6 +1845,17 @@ diff --git a/same.txt b/same.txt .expect("execute"); assert!(result.success); + let mutation = &result.metadata.as_ref().expect("metadata")["mutation"]; + assert_eq!( + mutation["files"], + json!([{ "path": "test.txt", "outcome": "updated" }]) + ); + assert!( + mutation["diff"] + .as_str() + .is_some_and(|diff| diff.contains("+line2")), + "{mutation}" + ); let patch_result = parse_patch_result(result); assert_eq!(patch_result.touched_files, vec!["test.txt"]); @@ -1763,6 +1884,17 @@ diff --git a/same.txt b/same.txt .expect("execute"); assert!(result.success); + let mutation = &result.metadata.as_ref().expect("metadata")["mutation"]; + assert_eq!( + mutation["files"], + json!([{ "path": "new_file.txt", "outcome": "created" }]) + ); + assert!( + mutation["diff"] + .as_str() + .is_some_and(|diff| diff.contains("+line1")), + "{mutation}" + ); let patch_result = parse_patch_result(result); assert_eq!(patch_result.touched_files, vec!["new_file.txt"]); assert!(patch_result.file_summaries.first().unwrap().created); @@ -1797,6 +1929,20 @@ diff --git a/same.txt b/same.txt assert_eq!(metadata["files_total"], 2); assert_eq!(metadata["hunks_total"], 0); assert!(metadata.get("path_override").is_none()); + assert_eq!( + metadata["mutation"]["files"], + json!([ + { "path": "one.txt", "outcome": "updated" }, + { "path": "two.txt", "outcome": "created" } + ]) + ); + let mutation_diff = metadata["mutation"]["diff"] + .as_str() + .expect("mutation diff"); + assert!(mutation_diff.contains("diff --git a/one.txt b/one.txt")); + assert!(mutation_diff.contains("diff --git a/two.txt b/two.txt")); + assert!(mutation_diff.contains("--- a/one.txt"), "{mutation_diff}"); + assert!(mutation_diff.contains("+++ b/two.txt"), "{mutation_diff}"); let patch_result = parse_patch_result(result); let mut touched = patch_result.touched_files.clone(); touched.sort(); @@ -1964,6 +2110,79 @@ diff --git a/b.txt b/b.txt assert!(b.contains("beta2")); } + #[tokio::test] + async fn mutation_receipt_covers_delete_rename_and_multifile_outcomes() { + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + fs::write(tmp.path().join("old.txt"), "same\n").expect("old"); + fs::write(tmp.path().join("update.txt"), "before\n").expect("update"); + fs::write(tmp.path().join("delete.txt"), "gone\n").expect("delete"); + + let patch = r"diff --git a/old.txt b/old.txt +--- a/old.txt ++++ /dev/null +@@ -1 +0,0 @@ +-same +diff --git a/new.txt b/new.txt +--- /dev/null ++++ b/new.txt +@@ -0,0 +1 @@ ++same +diff --git a/update.txt b/update.txt +--- a/update.txt ++++ b/update.txt +@@ -1 +1 @@ +-before ++after +diff --git a/create.txt b/create.txt +--- /dev/null ++++ b/create.txt +@@ -0,0 +1 @@ ++fresh +diff --git a/delete.txt b/delete.txt +--- a/delete.txt ++++ /dev/null +@@ -1 +0,0 @@ +-gone +"; + + let result = ApplyPatchTool + .execute(json!({"patch": patch}), &ctx) + .await + .expect("execute"); + let mutation = &result.metadata.as_ref().expect("metadata")["mutation"]; + assert_eq!( + mutation["files"], + json!([ + { "path": "update.txt", "outcome": "updated" }, + { "path": "create.txt", "outcome": "created" }, + { "path": "delete.txt", "outcome": "deleted" } + ]) + ); + assert_eq!( + mutation["renames"], + json!([{ "from": "old.txt", "to": "new.txt" }]) + ); + let exact = mutation["diff"].as_str().expect("exact mutation diff"); + assert!(exact.contains("rename from old.txt"), "{exact}"); + assert!(exact.contains("rename to new.txt"), "{exact}"); + assert!(exact.contains("--- a/update.txt"), "{exact}"); + assert!(exact.contains("+++ b/create.txt"), "{exact}"); + assert!(exact.contains("--- a/delete.txt"), "{exact}"); + + assert!(!tmp.path().join("old.txt").exists()); + assert_eq!( + fs::read_to_string(tmp.path().join("new.txt")).expect("renamed target"), + "same\n" + ); + assert_eq!( + fs::read_to_string(tmp.path().join("update.txt")).expect("updated"), + "after\n" + ); + assert!(tmp.path().join("create.txt").exists()); + assert!(!tmp.path().join("delete.txt").exists()); + } + #[tokio::test] async fn test_apply_patch_requires_headers_without_path() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/tools/canonical_action.rs b/crates/tui/src/tools/canonical_action.rs new file mode 100644 index 0000000000..215aae4ea3 --- /dev/null +++ b/crates/tui/src/tools/canonical_action.rs @@ -0,0 +1,113 @@ +//! Semantic aliases for the model-facing action tools. +//! +//! `Bash`, `File`, `Git`, `Run`, and `Web` deliberately keep their canonical +//! names at the execution and audit boundaries. Presentation and policy +//! consumers, however, still understand the older per-action names. Resolve +//! that semantic name in one place so live calls and saved legacy transcripts +//! receive identical downstream behavior without rewriting the original call. + +use serde_json::Value; + +pub(crate) const CANONICAL_ACTION_ALIASES: &[(&str, &str, &str)] = &[ + ("Bash", "run", "exec_shell"), + ("Bash", "wait", "exec_shell_wait"), + ("Bash", "interact", "exec_shell_interact"), + ("Bash", "cancel", "exec_shell_cancel"), + ("File", "read", "read_file"), + ("File", "list", "list_dir"), + ("File", "search_name", "file_search"), + ("File", "search_content", "grep_files"), + ("File", "write", "write_file"), + ("File", "edit", "edit_file"), + ("File", "patch", "apply_patch"), + ("Git", "status", "git_status"), + ("Git", "diff", "git_diff"), + ("Git", "log", "git_log"), + ("Git", "show", "git_show"), + ("Git", "blame", "git_blame"), + ("Run", "tests", "run_tests"), + ("Run", "verifiers", "run_verifiers"), + ("Web", "search", "web_search"), + ("Web", "fetch", "fetch_url"), + ("Web", "wait", "wait_for_dev_server"), +]; + +/// Resolve a canonical action tool to the legacy name for that exact action. +/// +/// Missing actions follow each wrapper's execution default. Unknown actions +/// stay canonical so policy remains conservative and the eventual tool error +/// is attributed to the call the model actually made. +#[must_use] +pub(crate) fn canonical_action_alias<'a>(tool_name: &'a str, input: &Value) -> &'a str { + let default_action = match tool_name { + "Bash" => Some("run"), + "File" => Some("read"), + "Git" => Some("status"), + "Run" => Some("tests"), + "Web" => Some("search"), + _ => None, + }; + let Some(default_action) = default_action else { + return tool_name; + }; + let action = input + .get("action") + .and_then(Value::as_str) + .unwrap_or(default_action); + + CANONICAL_ACTION_ALIASES + .iter() + .find_map(|(family, candidate_action, alias)| { + (*family == tool_name && *candidate_action == action).then_some(*alias) + }) + .unwrap_or(tool_name) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn every_canonical_action_resolves_to_its_legacy_semantic_alias() { + for (family, action, alias) in CANONICAL_ACTION_ALIASES { + assert_eq!( + canonical_action_alias(family, &json!({"action": action})), + *alias, + "{family}.{action}" + ); + } + } + + #[test] + fn canonical_defaults_match_wrapper_execution_defaults() { + for (family, alias) in [ + ("Bash", "exec_shell"), + ("File", "read_file"), + ("Git", "git_status"), + ("Run", "run_tests"), + ("Web", "web_search"), + ] { + assert_eq!( + canonical_action_alias(family, &json!({})), + alias, + "{family}" + ); + } + } + + #[test] + fn legacy_unknown_and_invalid_calls_keep_their_original_names() { + for name in ["exec_shell", "read_file", "future_tool"] { + assert_eq!(canonical_action_alias(name, &json!({})), name); + } + assert_eq!( + canonical_action_alias("File", &json!({"action": "delete"})), + "File" + ); + assert_eq!( + canonical_action_alias("Bash", &json!({"action": 42})), + "exec_shell" + ); + } +} diff --git a/crates/tui/src/tools/dev_server_readiness.rs b/crates/tui/src/tools/dev_server_readiness.rs index bab7316a2b..8f3abc3e34 100644 --- a/crates/tui/src/tools/dev_server_readiness.rs +++ b/crates/tui/src/tools/dev_server_readiness.rs @@ -58,6 +58,10 @@ impl ToolSpec for WaitForDevServerTool { "wait_for_dev_server" } + fn model_visible(&self) -> bool { + false + } + fn description(&self) -> &'static str { "Wait for a local dev server to become ready. Polls a loopback TCP port, optionally then an HTTP(S) health URL on the same port, with bounded timeout and structured success/failure output." } diff --git a/crates/tui/src/tools/diagnostics.rs b/crates/tui/src/tools/diagnostics.rs index f4097d5d45..d0896eaa21 100644 --- a/crates/tui/src/tools/diagnostics.rs +++ b/crates/tui/src/tools/diagnostics.rs @@ -86,7 +86,13 @@ impl ToolSpec for DiagnosticsTool { }; let git = probe_git(&context.workspace); - let sandbox_type = crate::sandbox::get_platform_sandbox().map(|s| s.to_string()); + let sandbox_type = match context.shell_manager.lock() { + Ok(manager) => manager.configured_sandbox_type().map(|s| s.to_string()), + Err(poisoned) => poisoned + .into_inner() + .configured_sandbox_type() + .map(|s| s.to_string()), + }; let sandbox_available = sandbox_type.is_some(); // Bubblewrap availability (#2184). @@ -269,6 +275,42 @@ mod tests { let parsed: DiagnosticsOutput = serde_json::from_str(&result.content).expect("tool result should be json"); assert_eq!(parsed.workspace_root, tmp.path().display().to_string()); + let expected = ctx + .shell_manager + .lock() + .expect("shell manager") + .configured_sandbox_type() + .map(|kind| kind.to_string()); + assert_eq!(parsed.sandbox_available, expected.is_some()); + assert_eq!(parsed.sandbox_type, expected); + } + + #[tokio::test] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] + async fn diagnostics_only_reports_configured_executable_bwrap_on_linux() { + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path()); + ctx.shell_manager + .lock() + .expect("shell manager") + .set_prefer_bwrap(true); + + let result = DiagnosticsTool + .execute(json!({}), &ctx) + .await + .expect("execute"); + let parsed: DiagnosticsOutput = + serde_json::from_str(&result.content).expect("tool result should be json"); + + assert_eq!( + parsed.bwrap_available, + crate::sandbox::bwrap::is_available() + ); + assert_eq!(parsed.sandbox_available, parsed.bwrap_available); + assert_eq!( + parsed.sandbox_type.as_deref(), + parsed.bwrap_available.then_some("linux-bwrap") + ); } #[tokio::test] diff --git a/crates/tui/src/tools/fetch_url.rs b/crates/tui/src/tools/fetch_url.rs index 1be30484ac..e1ae7b9785 100644 --- a/crates/tui/src/tools/fetch_url.rs +++ b/crates/tui/src/tools/fetch_url.rs @@ -91,6 +91,10 @@ impl ToolSpec for FetchUrlTool { "fetch_url" } + fn model_visible(&self) -> bool { + false + } + fn description(&self) -> &'static str { "Fetch a known URL directly (HTTP GET) and return its content with a session-scoped citation ref_id. Use this instead of `curl` in `exec_shell` — sandboxed, network-policy aware, and properly decoded. Plain-text endpoints (`.md`, `.txt`, `.json`, `.yaml`, `raw.githubusercontent.com`, public APIs) prefer this over the browser/automation stack. For unknown queries, use `web_search` first. If a login or authorization wall is returned, treat the wall as the result; do not claim the protected page was read." } diff --git a/crates/tui/src/tools/file.rs b/crates/tui/src/tools/file.rs index 30074a5040..f002ffdaab 100644 --- a/crates/tui/src/tools/file.rs +++ b/crates/tui/src/tools/file.rs @@ -94,6 +94,10 @@ impl ToolSpec for ReadFileTool { "read_file" } + fn model_visible(&self) -> bool { + false + } + fn description(&self) -> &'static str { "Read a UTF-8 file from the workspace. Use this instead of `cat`, `head`, `tail`, or `sed -n '..p'` in `exec_shell` — it's faster, sandbox-aware, and skips the approval prompt. Plain text is returned as-is and records the file snapshot required before `edit_file` will make a narrow in-place edit. CodeWhale config files and file-backed credential stores cannot be read with this tool; use `codewhale config list` or `codewhale auth status` for safe inspection. PDFs are auto-extracted via the bundled pure-Rust extractor (no Poppler install required). Image screenshots are OCR-extracted when local OCR is available. Cannot read other non-PDF binaries.\n\nFor large files, use `start_line` and `max_lines` to read in chunks. By default, returns at most 200 lines (~16KB). If `truncated=\"true\"` in the response, use `next_start_line` to continue reading. For PDFs, use `pages` instead — `start_line`/`max_lines` only apply to text files." } @@ -675,6 +679,10 @@ impl ToolSpec for WriteFileTool { "write_file" } + fn model_visible(&self) -> bool { + false + } + fn description(&self) -> &'static str { "Write content to a UTF-8 file in the workspace. Use this instead of heredocs (`cat < file`) or `echo > file` in `exec_shell` — diffs render inline and approval is handled cleanly. Creates or overwrites; parent directories are auto-created." } @@ -760,7 +768,18 @@ impl ToolSpec for WriteFileTool { format!("{body}\n{diag_block}") }; - Ok(ToolResult::success(full_body)) + let outcome = if existed_before { "updated" } else { "created" }; + // Keep the execution-owned receipt workspace-relative even though the + // legacy model-facing output above retains its resolved-path wording. + let receipt_diff = make_unified_diff(path_str, &prior_contents, file_content); + Ok(ToolResult::success(full_body).with_metadata(json!({ + "event": "file.mutation", + "mutation": { + "diff": receipt_diff, + "files": [{ "path": path_str, "outcome": outcome }], + "renames": [] + } + }))) } } @@ -775,6 +794,10 @@ impl ToolSpec for EditFileTool { "edit_file" } + fn model_visible(&self) -> bool { + false + } + fn description(&self) -> &'static str { "Replace text in a single file via exact search/replace after the file has been read with `read_file` in this session. Use this instead of `sed -i` in `exec_shell` for one unambiguous in-place edit. `search` must match exactly one location by default; when no exact match is found the tool retries with leading-whitespace-tolerant fuzzy matching automatically. The optional `fuzz` parameter is accepted for backward compatibility and is no longer needed. Returns a compact unified diff, not the full file. For structural, multi-block, or cross-file changes, use `apply_patch` or `write_file` instead." } @@ -921,7 +944,17 @@ impl ToolSpec for EditFileTool { format!("{body}\n{diag_block}") }; - Ok(ToolResult::success(full_body)) + // The structured receipt uses the requested workspace path instead of + // the resolved host path retained by the legacy model-facing body. + let receipt_diff = make_unified_diff(path_str, &contents, &updated); + Ok(ToolResult::success(full_body).with_metadata(json!({ + "event": "file.mutation", + "mutation": { + "diff": receipt_diff, + "files": [{ "path": path_str, "outcome": "updated" }], + "renames": [] + } + }))) } } @@ -1076,6 +1109,10 @@ impl ToolSpec for ListDirTool { "list_dir" } + fn model_visible(&self) -> bool { + false + } + fn description(&self) -> &'static str { "List entries in a directory relative to the workspace. Use this instead of `ls`, `ls -la`, or `find . -maxdepth 1` in `exec_shell` for directory listings." } @@ -1903,6 +1940,24 @@ mod tests { "{}", result.content ); + let mutation = &result.metadata.as_ref().expect("metadata")["mutation"]; + assert_eq!( + mutation["files"], + json!([{ "path": "output.txt", "outcome": "created" }]) + ); + assert!( + mutation["diff"] + .as_str() + .is_some_and(|diff| diff.contains("--- a/output.txt")), + "{mutation}" + ); + assert!( + !mutation["diff"] + .as_str() + .unwrap_or_default() + .contains(&tmp.path().display().to_string()), + "receipt headers must not expose the resolved host path: {mutation}" + ); // Verify file was written let written = fs::read_to_string(tmp.path().join("output.txt")).expect("read"); @@ -2047,6 +2102,19 @@ mod tests { result.content ); assert!(result.content.contains("+hi world"), "{}", result.content); + let mutation = &result.metadata.as_ref().expect("metadata")["mutation"]; + assert_eq!( + mutation["files"], + json!([{ "path": "edit_me.txt", "outcome": "updated" }]) + ); + let receipt_diff = mutation["diff"].as_str().expect("receipt diff"); + assert!(receipt_diff.contains("--- a/edit_me.txt"), "{receipt_diff}"); + assert!(receipt_diff.contains("-hello world"), "{receipt_diff}"); + assert!(receipt_diff.contains("+hi world"), "{receipt_diff}"); + assert!( + !receipt_diff.contains(&tmp.path().display().to_string()), + "receipt headers must not expose the resolved host path: {receipt_diff}" + ); // Verify edit was applied let edited = fs::read_to_string(&test_file).expect("read"); diff --git a/crates/tui/src/tools/file_search.rs b/crates/tui/src/tools/file_search.rs index cd582aa805..66cf1c71c7 100644 --- a/crates/tui/src/tools/file_search.rs +++ b/crates/tui/src/tools/file_search.rs @@ -34,6 +34,10 @@ impl ToolSpec for FileSearchTool { "file_search" } + fn model_visible(&self) -> bool { + false + } + fn description(&self) -> &'static str { "Find files by name using fuzzy matching with score-based ranking. Use this instead of `find -name` or `fd` in `exec_shell` for filename search. Pass `extensions` to filter by suffix." } diff --git a/crates/tui/src/tools/file_tool.rs b/crates/tui/src/tools/file_tool.rs new file mode 100644 index 0000000000..4a858eb323 --- /dev/null +++ b/crates/tui/src/tools/file_tool.rs @@ -0,0 +1,297 @@ +//! Canonical action-based wrapper for file system tools. +//! +//! The model sees one tool: `File` with an `action` parameter +//! (read | list | search_name | search_content | write | edit | patch). +//! Legacy names (`read_file`, `write_file`, etc.) stay registered as hidden +//! compat aliases that force the action so saved transcripts replay correctly. + +use async_trait::async_trait; +use serde_json::{Value, json}; + +use super::apply_patch::ApplyPatchTool; +use super::file::{EditFileTool, ListDirTool, ReadFileTool, WriteFileTool}; +use super::file_search::FileSearchTool; +use super::search::GrepFilesTool; +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, +}; + +pub struct FileTool { + name: &'static str, + forced_action: Option<&'static str>, + allow_writes: bool, + allow_patch: bool, +} + +impl FileTool { + pub const fn new(name: &'static str) -> Self { + Self { + name, + forced_action: None, + allow_writes: true, + allow_patch: false, + } + } + + pub const fn with_patch(name: &'static str) -> Self { + Self { + name, + forced_action: None, + allow_writes: true, + allow_patch: true, + } + } + + pub const fn read_only(name: &'static str) -> Self { + Self { + name, + forced_action: None, + allow_writes: false, + allow_patch: false, + } + } + + pub const fn alias(name: &'static str, action: &'static str) -> Self { + Self { + name, + forced_action: Some(action), + allow_writes: true, + allow_patch: true, + } + } + + fn resolve_action<'a>(&self, input: &'a Value) -> &'a str { + self.forced_action.unwrap_or_else(|| { + input + .get("action") + .and_then(Value::as_str) + .unwrap_or("read") + }) + } + + fn strip_action(&self, input: Value) -> Result { + let mut input = input; + if let Some(obj) = input.as_object_mut() { + obj.remove("action"); + Ok(input) + } else { + Err(ToolError::invalid_input( + "File tool input must be an object", + )) + } + } +} + +#[async_trait] +impl ToolSpec for FileTool { + fn name(&self) -> &'static str { + self.name + } + + fn model_visible(&self) -> bool { + self.name == "File" + } + + fn description(&self) -> &'static str { + "Read, list, search, write, edit, or patch workspace files. Use read before edit; edit performs one exact replacement, while patch is best for multi-hunk or multi-file changes. Read/list/search actions are parallel-safe and do not require approval. Available actions depend on the active mode and feature policy." + } + + fn input_schema(&self) -> Value { + let mut actions = vec!["read", "list", "search_name", "search_content"]; + if self.allow_writes { + actions.extend(["write", "edit"]); + } + if self.allow_patch { + actions.push("patch"); + } + json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": actions, + "description": "Action to perform" + }, + "path": { + "type": "string", + "description": "File or directory path (read, list, search, write, edit, patch)" + }, + "start_line": { + "type": "integer", + "description": "Starting line for read (1-based, default 1)" + }, + "max_lines": { + "type": "integer", + "description": "Maximum lines to return for read or blame (default 200)" + }, + "pages": { + "type": "string", + "description": "PDF page range for read, e.g. '1-5'" + }, + "content": { + "type": "string", + "description": "Content to write for action=write" + }, + "search": { + "type": "string", + "description": "Exact text to search for action=edit" + }, + "replace": { + "oneOf": [ + { "type": "string", "description": "Replacement text for action=edit" }, + { "type": "array", "items": { "type": "object", "properties": { "path": { "type": "string" }, "content": { "type": "string" } }, "required": ["path", "content"] }, "description": "Full-file replacements for action=patch" } + ] + }, + "fuzz": { + "oneOf": [{ "type": "boolean" }, { "type": "integer" }], + "description": "Fuzzy matching flag for edit or max fuzz for patch" + }, + "query": { + "type": "string", + "description": "Search query for search_name or search_content" + }, + "pattern": { + "type": "string", + "description": "Regex pattern for search_content" + }, + "limit": { + "type": "integer", + "description": "Maximum results for search_name" + }, + "max_results": { + "type": "integer", + "description": "Maximum results for search_content or search_name" + }, + "extensions": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional extension filter for search_name" + }, + "include": { + "type": "array", + "items": { "type": "string" }, + "description": "Glob patterns to include for search_content" + }, + "exclude": { + "type": "array", + "items": { "type": "string" }, + "description": "Glob patterns to exclude for search_name or search_content" + }, + "context_lines": { + "type": "integer", + "description": "Context lines around each match for search_content" + }, + "case_insensitive": { + "type": "boolean", + "description": "Case-insensitive matching for search_content" + }, + "patch": { + "type": "string", + "description": "Unified diff patch content for action=patch" + }, + "changes": { + "type": "array", + "items": { "type": "object", "properties": { "path": { "type": "string" }, "content": { "type": "string" } }, "required": ["path", "content"] }, + "description": "Deprecated alias for replace in action=patch" + }, + "create_if_missing": { + "type": "boolean", + "description": "Create files if missing for action=patch" + } + }, + "required": ["action"] + }) + } + + fn capabilities(&self) -> Vec { + let mut capabilities = vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]; + let can_mutate = match self.forced_action { + Some("write" | "edit" | "patch") => true, + Some(_) => false, + None => self.allow_writes || self.allow_patch, + }; + if can_mutate { + capabilities.extend([ + ToolCapability::WritesFiles, + ToolCapability::RequiresApproval, + ]); + } + capabilities + } + + fn approval_requirement_for(&self, input: &Value) -> ApprovalRequirement { + match self.resolve_action(input) { + "read" | "list" | "search_name" | "search_content" => ApprovalRequirement::Auto, + "write" | "edit" | "patch" => ApprovalRequirement::Suggest, + _ => ApprovalRequirement::Auto, + } + } + + fn is_read_only_for(&self, input: &Value) -> bool { + matches!( + self.resolve_action(input), + "read" | "list" | "search_name" | "search_content" + ) + } + + fn supports_parallel_for(&self, input: &Value) -> bool { + matches!( + self.resolve_action(input), + "read" | "list" | "search_name" | "search_content" + ) + } + + fn starts_detached_for(&self, _input: &Value) -> bool { + false + } + + async fn execute(&self, input: Value, context: &ToolContext) -> Result { + let action = self.resolve_action(&input).to_string(); + if matches!(action.as_str(), "write" | "edit") && !self.allow_writes { + return Err(ToolError::not_available( + "File writes are unavailable in the current mode", + )); + } + if action == "patch" && !self.allow_patch { + return Err(ToolError::not_available( + "File.patch is unavailable because the patch feature is disabled", + )); + } + let mut input = self.strip_action(input)?; + + match action.as_str() { + "read" => ReadFileTool.execute(input, context).await, + "list" => ListDirTool.execute(input, context).await, + "search_name" => { + if let Some(obj) = input.as_object_mut() + && !obj.contains_key("limit") + && let Some(max) = obj.get("max_results").cloned() + { + obj.insert("limit".to_string(), max); + } + FileSearchTool.execute(input, context).await + } + "search_content" => { + if let Some(obj) = input.as_object_mut() { + if !obj.contains_key("pattern") + && let Some(query) = obj.get("query").cloned() + { + obj.insert("pattern".to_string(), query); + } + if !obj.contains_key("max_results") + && let Some(limit) = obj.get("limit").cloned() + { + obj.insert("max_results".to_string(), limit); + } + } + GrepFilesTool.execute(input, context).await + } + "write" => WriteFileTool.execute(input, context).await, + "edit" => EditFileTool.execute(input, context).await, + "patch" => ApplyPatchTool.execute(input, context).await, + other => Err(ToolError::invalid_input(format!( + "Unknown File action: {other}" + ))), + } + } +} diff --git a/crates/tui/src/tools/git.rs b/crates/tui/src/tools/git.rs index 178d9ae1f4..096bb595bc 100644 --- a/crates/tui/src/tools/git.rs +++ b/crates/tui/src/tools/git.rs @@ -31,6 +31,10 @@ impl ToolSpec for GitStatusTool { "git_status" } + fn model_visible(&self) -> bool { + false + } + fn description(&self) -> &'static str { "Run `git status --porcelain=v1 -b` in the workspace (optionally scoped to a path)." } @@ -112,6 +116,10 @@ impl ToolSpec for GitDiffTool { "git_diff" } + fn model_visible(&self) -> bool { + false + } + fn description(&self) -> &'static str { "Run `git diff` in the workspace with sensible defaults and safe truncation." } diff --git a/crates/tui/src/tools/git_history.rs b/crates/tui/src/tools/git_history.rs index a9210ec138..32581b9495 100644 --- a/crates/tui/src/tools/git_history.rs +++ b/crates/tui/src/tools/git_history.rs @@ -34,6 +34,10 @@ impl ToolSpec for GitLogTool { "git_log" } + fn model_visible(&self) -> bool { + false + } + fn description(&self) -> &'static str { "Run `git log` in the workspace with optional path and author/date filters." } @@ -151,6 +155,10 @@ impl ToolSpec for GitShowTool { "git_show" } + fn model_visible(&self) -> bool { + false + } + fn description(&self) -> &'static str { "Run `git show` for a specific revision with optional patch and stats." } @@ -269,6 +277,10 @@ impl ToolSpec for GitBlameTool { "git_blame" } + fn model_visible(&self) -> bool { + false + } + fn description(&self) -> &'static str { "Run `git blame` on a file with optional revision and line-range controls." } diff --git a/crates/tui/src/tools/git_tool.rs b/crates/tui/src/tools/git_tool.rs new file mode 100644 index 0000000000..b3b6bbbfca --- /dev/null +++ b/crates/tui/src/tools/git_tool.rs @@ -0,0 +1,172 @@ +//! Canonical action-based wrapper for git inspection tools. +//! +//! The model sees one tool: `Git` with an `action` parameter +//! (status | diff | log | show | blame). Legacy names (`git_status`, +//! `git_diff`, etc.) stay registered as hidden compat aliases that force the +//! action so saved transcripts replay correctly. + +use async_trait::async_trait; +use serde_json::{Value, json}; + +use super::git::{GitDiffTool, GitStatusTool}; +use super::git_history::{GitBlameTool, GitLogTool, GitShowTool}; +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, +}; + +pub struct GitTool { + name: &'static str, + forced_action: Option<&'static str>, +} + +impl GitTool { + pub const fn new(name: &'static str) -> Self { + Self { + name, + forced_action: None, + } + } + + pub const fn alias(name: &'static str, action: &'static str) -> Self { + Self { + name, + forced_action: Some(action), + } + } + + fn resolve_action<'a>(&self, input: &'a Value) -> &'a str { + self.forced_action.unwrap_or_else(|| { + input + .get("action") + .and_then(Value::as_str) + .unwrap_or("status") + }) + } + + fn strip_action(&self, input: Value) -> Result { + let mut input = input; + if let Some(obj) = input.as_object_mut() { + obj.remove("action"); + Ok(input) + } else { + Err(ToolError::invalid_input("Git tool input must be an object")) + } + } +} + +#[async_trait] +impl ToolSpec for GitTool { + fn name(&self) -> &'static str { + self.name + } + + fn model_visible(&self) -> bool { + self.name == "Git" + } + + fn description(&self) -> &'static str { + "Inspect repository state and history with status, diff, log, show, or blame. All actions are read-only and parallel-safe." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["status", "diff", "log", "show", "blame"], + "description": "Action to perform" + }, + "path": { + "type": "string", + "description": "Optional subdirectory or file path to scope the git command" + }, + "cached": { + "type": "boolean", + "description": "When true, diff staged changes (action=diff)" + }, + "unified": { + "type": "integer", + "description": "Number of context lines for diff or show output" + }, + "max_count": { + "type": "integer", + "description": "Maximum commits to return (action=log)" + }, + "author": { + "type": "string", + "description": "Author filter (action=log)" + }, + "since": { + "type": "string", + "description": "Lower date bound (action=log)" + }, + "until": { + "type": "string", + "description": "Upper date bound (action=log)" + }, + "rev": { + "type": "string", + "description": "Revision to show (action=show) or blame against (action=blame)" + }, + "patch": { + "type": "boolean", + "description": "Include patch hunks (action=show)" + }, + "stat": { + "type": "boolean", + "description": "Include stat summary (action=show)" + }, + "start_line": { + "type": "integer", + "description": "First line to include (action=blame)" + }, + "max_lines": { + "type": "integer", + "description": "Maximum lines to include (action=blame)" + }, + "porcelain": { + "type": "boolean", + "description": "Emit line-porcelain output (action=blame)" + } + }, + "required": ["action"] + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable] + } + + fn approval_requirement_for(&self, _input: &Value) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + fn is_read_only_for(&self, _input: &Value) -> bool { + true + } + + fn supports_parallel_for(&self, _input: &Value) -> bool { + true + } + + fn starts_detached_for(&self, _input: &Value) -> bool { + false + } + + async fn execute(&self, input: Value, context: &ToolContext) -> Result { + let action = self.resolve_action(&input).to_string(); + let input = self.strip_action(input)?; + + match action.as_str() { + "status" => GitStatusTool.execute(input, context).await, + "diff" => GitDiffTool.execute(input, context).await, + "log" => GitLogTool.execute(input, context).await, + "show" => GitShowTool.execute(input, context).await, + "blame" => GitBlameTool.execute(input, context).await, + other => Err(ToolError::invalid_input(format!( + "Unknown Git action: {other}" + ))), + } + } +} diff --git a/crates/tui/src/tools/mod.rs b/crates/tui/src/tools/mod.rs index 549ad6da22..8d33243541 100644 --- a/crates/tui/src/tools/mod.rs +++ b/crates/tui/src/tools/mod.rs @@ -12,6 +12,7 @@ pub mod apply_patch; pub mod approval_cache; pub mod arg_repair; pub mod automation; +pub mod canonical_action; pub mod cargo_failure_summary; pub mod dev_server_readiness; pub mod diagnostics; @@ -22,9 +23,11 @@ pub mod file_search; pub mod finance; pub mod fetch_url; +pub mod file_tool; pub mod fim; pub mod git; pub mod git_history; +pub mod git_tool; pub mod github; pub mod goal; pub mod handle; @@ -42,6 +45,7 @@ pub mod remember; pub mod revert_turn; pub mod review; pub mod rlm; +pub mod run_tool; pub mod runtime_mcp; pub mod schema_canonicalize; pub mod schema_sanitize; @@ -65,6 +69,7 @@ pub mod verifier; pub mod web; pub mod web_run; pub mod web_search; +pub mod web_tool; pub mod workflow; pub mod workflow_plan_approval; pub mod workflow_trigger; diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index c51d503ba4..0ecf1773cb 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -110,6 +110,7 @@ impl ToolRegistry { .get(name) .ok_or_else(|| ToolError::not_available(format!("tool '{name}' is not registered")))?; + enforce_tool_authority(name, &input, tool.as_ref(), &self.context)?; let result = tool.execute(input, &self.context).await?; Ok(result.content) } @@ -120,6 +121,7 @@ impl ToolRegistry { .get(name) .ok_or_else(|| ToolError::not_available(format!("tool '{name}' is not registered")))?; + enforce_tool_authority(name, &input, tool.as_ref(), &self.context)?; tool.execute(input, &self.context).await } @@ -138,6 +140,7 @@ impl ToolRegistry { .ok_or_else(|| ToolError::not_available(format!("tool '{name}' is not registered")))?; let ctx = context_override.unwrap_or(&self.context); + enforce_tool_authority(name, &input, tool.as_ref(), ctx)?; let mut result = tool.execute(input.clone(), ctx).await?; // Adaptive evidence routing (#4619) is storage-free here because this @@ -504,6 +507,93 @@ impl ToolRegistry { } } +fn enforce_tool_authority( + name: &str, + input: &Value, + tool: &dyn ToolSpec, + context: &ToolContext, +) -> Result<(), ToolError> { + let Some(authority) = context.tool_authority.as_ref() else { + return Ok(()); + }; + let capabilities = tool.capabilities(); + if matches!(name, "Bash" | "exec_shell" | "Run") { + return Err(ToolError::permission_denied(format!( + "worker '{}' cannot run {name}: arbitrary command execution is outside its machine-readable authority envelope", + authority.owner + ))); + } + if name == "Git" || name.starts_with("git_") || name == "review" { + return Err(ToolError::permission_denied(format!( + "worker '{}' cannot run {name}: repository-configured Git helpers cannot prove read-only execution under its machine-readable authority envelope", + authority.owner + ))); + } + if tool.is_read_only_for(input) { + return Ok(()); + } + if capabilities.contains(&ToolCapability::ExecutesCode) { + return Err(ToolError::permission_denied(format!( + "worker '{}' cannot run {name}: code or child execution is outside its machine-readable authority envelope", + authority.owner + ))); + } + if let Some(paths) = authority_mutation_paths(name, input)? { + if paths.is_empty() { + return Err(ToolError::permission_denied(format!( + "worker '{}' mutation through {name} did not expose a bounded file target", + authority.owner + ))); + } + for path in paths { + if !authority.permits_mutation_path(context, &path)? { + return Err(ToolError::permission_denied(format!( + "worker '{}' cannot mutate '{path}' outside its machine-readable authority envelope", + authority.owner + ))); + } + } + return Ok(()); + } + Err(ToolError::permission_denied(format!( + "worker '{}' cannot run mutating tool {name}: the call has no authorized file target", + authority.owner + ))) +} + +fn authority_mutation_paths(name: &str, input: &Value) -> Result>, ToolError> { + let is_patch = name == "apply_patch" + || (name == "File" && input.get("action").and_then(Value::as_str) == Some("patch")); + if is_patch { + let mut patch_input = input.clone(); + if let Some(object) = patch_input.as_object_mut() { + object.remove("action"); + } + let paths = crate::tools::apply_patch::preflight_apply_patch(&patch_input) + .map_err(|error| ToolError::invalid_input(error.to_string()))? + .touched_files; + return Ok(Some(paths)); + } + let path_bound = matches!(name, "write_file" | "edit_file" | "fim_edit") + || (name == "File" + && input + .get("action") + .and_then(Value::as_str) + .is_some_and(|action| matches!(action, "write" | "edit"))) + || (name == "pandoc_convert" && input.get("output_path").is_some()); + if !path_bound { + return Ok(None); + } + Ok(Some( + input + .get("path") + .or_else(|| input.get("output_path")) + .and_then(Value::as_str) + .map(|path| vec![path.to_string()]) + .unwrap_or_default(), + )) +} + /// Builder for constructing a `ToolRegistry` with common tools. pub struct ToolRegistryBuilder { tools: Vec>, @@ -567,19 +657,21 @@ impl ToolRegistryBuilder { /// Include file tools (read, write, edit, list). #[must_use] pub fn with_file_tools(self) -> Self { - use super::file::{EditFileTool, ListDirTool, ReadFileTool, WriteFileTool}; - self.with_tool(Arc::new(ReadFileTool)) - .with_tool(Arc::new(WriteFileTool)) - .with_tool(Arc::new(EditFileTool)) - .with_tool(Arc::new(ListDirTool)) + use super::file_tool::FileTool; + self.with_tool(Arc::new(FileTool::new("File"))) + .with_tool(Arc::new(FileTool::alias("read_file", "read"))) + .with_tool(Arc::new(FileTool::alias("write_file", "write"))) + .with_tool(Arc::new(FileTool::alias("edit_file", "edit"))) + .with_tool(Arc::new(FileTool::alias("list_dir", "list"))) } /// Include only read-only file tools (read, list). #[must_use] pub fn with_read_only_file_tools(self) -> Self { - use super::file::{ListDirTool, ReadFileTool}; - self.with_tool(Arc::new(ReadFileTool)) - .with_tool(Arc::new(ListDirTool)) + use super::file_tool::FileTool; + self.with_tool(Arc::new(FileTool::read_only("File"))) + .with_tool(Arc::new(FileTool::alias("read_file", "read"))) + .with_tool(Arc::new(FileTool::alias("list_dir", "list"))) .with_tool(Arc::new( super::tool_result_retrieval::RetrieveToolResultTool, )) @@ -629,27 +721,27 @@ impl ToolRegistryBuilder { /// Include search tools (`grep_files`). #[must_use] pub fn with_search_tools(self) -> Self { - use super::file_search::FileSearchTool; - use super::search::GrepFilesTool; - self.with_tool(Arc::new(GrepFilesTool)) - .with_tool(Arc::new(FileSearchTool)) + use super::file_tool::FileTool; + self.with_tool(Arc::new(FileTool::alias("grep_files", "search_content"))) + .with_tool(Arc::new(FileTool::alias("file_search", "search_name"))) } /// Include git inspection tools (`git_status`, `git_diff`). #[must_use] pub fn with_git_tools(self) -> Self { - use super::git::{GitDiffTool, GitStatusTool}; - self.with_tool(Arc::new(GitStatusTool)) - .with_tool(Arc::new(GitDiffTool)) + use super::git_tool::GitTool; + self.with_tool(Arc::new(GitTool::new("Git"))) + .with_tool(Arc::new(GitTool::alias("git_status", "status"))) + .with_tool(Arc::new(GitTool::alias("git_diff", "diff"))) } /// Include git history tools (`git_log`, `git_show`, `git_blame`). #[must_use] pub fn with_git_history_tools(self) -> Self { - use super::git_history::{GitBlameTool, GitLogTool, GitShowTool}; - self.with_tool(Arc::new(GitLogTool)) - .with_tool(Arc::new(GitShowTool)) - .with_tool(Arc::new(GitBlameTool)) + use super::git_tool::GitTool; + self.with_tool(Arc::new(GitTool::alias("git_log", "log"))) + .with_tool(Arc::new(GitTool::alias("git_show", "show"))) + .with_tool(Arc::new(GitTool::alias("git_blame", "blame"))) } /// Include workspace diagnostics tool. @@ -707,10 +799,10 @@ impl ToolRegistryBuilder { /// Include cargo test runner tool. #[must_use] pub fn with_test_runner_tool(self) -> Self { - use super::test_runner::RunTestsTool; - use super::verifier::RunVerifiersTool; - self.with_tool(Arc::new(RunTestsTool)) - .with_tool(Arc::new(RunVerifiersTool)) + use super::run_tool::RunTool; + self.with_tool(Arc::new(RunTool::new("Run"))) + .with_tool(Arc::new(RunTool::alias("run_tests", "tests"))) + .with_tool(Arc::new(RunTool::alias("run_verifiers", "verifiers"))) } /// Include structured data validation tool (`validate_data`). @@ -860,13 +952,12 @@ impl ToolRegistryBuilder { /// NOT gated behind the web-search feature. #[must_use] pub fn with_web_tools(self) -> Self { - use super::dev_server_readiness::WaitForDevServerTool; - use super::fetch_url::FetchUrlTool; use super::web_run::WebRunTool; - use super::web_search::WebSearchTool; - self.with_tool(Arc::new(WebSearchTool)) - .with_tool(Arc::new(FetchUrlTool)) - .with_tool(Arc::new(WaitForDevServerTool)) + use super::web_tool::WebTool; + self.with_tool(Arc::new(WebTool::new("Web"))) + .with_tool(Arc::new(WebTool::alias("web_search", "search"))) + .with_tool(Arc::new(WebTool::alias("fetch_url", "fetch"))) + .with_tool(Arc::new(WebTool::alias("wait_for_dev_server", "wait"))) .with_tool(Arc::new(WebRunTool)) } @@ -911,8 +1002,9 @@ impl ToolRegistryBuilder { /// Include patch tools (`apply_patch`). #[must_use] pub fn with_patch_tools(self) -> Self { - use super::apply_patch::ApplyPatchTool; - self.with_tool(Arc::new(ApplyPatchTool)) + use super::file_tool::FileTool; + self.with_tool(Arc::new(FileTool::with_patch("File"))) + .with_tool(Arc::new(FileTool::alias("apply_patch", "patch"))) } /// Include the `revert_turn` tool. Approval-gated since it mutates @@ -1439,8 +1531,8 @@ mod tests { use crate::config::ToolOverride; use crate::tools::ToolRegistryBuilder; use crate::tools::spec::{ - ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, - required_str, + ApprovalRequirement, ToolAuthorityEnvelope, ToolCapability, ToolContext, ToolError, + ToolMutationAuthority, ToolResult, ToolSpec, required_str, }; use super::{ToolRegistry, mcp_tool_adapter_for_test}; @@ -1920,6 +2012,230 @@ mod tests { assert!(result.is_err()); } + fn scoped_context(workspace: &std::path::Path) -> ToolContext { + ToolContext::new(workspace.to_path_buf()) + .with_tool_authority( + ToolAuthorityEnvelope { + schema_version: 1, + owner: "fleet-worker-1".to_string(), + authority: ToolMutationAuthority::ScopedWrite, + writable_roots: vec!["src".to_string()], + writable_files: Vec::new(), + coordination_contracts: Vec::new(), + } + .normalized() + .expect("test authority"), + ) + .expect("test context authority") + } + + #[tokio::test] + async fn fleet_authority_allows_scoped_file_writes_and_rejects_outside_paths() { + let tmp = tempdir().expect("tempdir"); + std::fs::create_dir(tmp.path().join("src")).expect("src"); + std::fs::create_dir(tmp.path().join("docs")).expect("docs"); + let registry = ToolRegistryBuilder::new() + .with_file_tools() + .with_patch_tools() + .build(scoped_context(tmp.path())); + + registry + .execute_full( + "File", + json!({"action": "write", "path": "src/ok.txt", "content": "ok\n"}), + ) + .await + .expect("scoped File write"); + assert_eq!( + std::fs::read_to_string(tmp.path().join("src/ok.txt")).expect("written file"), + "ok\n" + ); + + let error = registry + .execute_full( + "File", + json!({"action": "write", "path": "docs/no.txt", "content": "no\n"}), + ) + .await + .expect_err("out-of-scope File write") + .to_string(); + assert!(error.contains("outside its machine-readable"), "{error}"); + assert!(!tmp.path().join("docs/no.txt").exists()); + } + + #[tokio::test] + async fn fleet_authority_denies_bash_even_when_command_classifier_calls_it_read_only() { + let tmp = tempdir().expect("tempdir"); + std::fs::create_dir(tmp.path().join("src")).expect("src"); + let registry = ToolRegistryBuilder::new() + .with_shell_tools() + .build(scoped_context(tmp.path())); + + let error = registry + .execute_full("Bash", json!({"action": "run", "command": "git status"})) + .await + .expect_err("Bash remains unprovable under a file scope") + .to_string(); + assert!(error.contains("arbitrary command execution"), "{error}"); + } + + #[tokio::test] + async fn fleet_authority_denies_git_even_when_the_action_is_nominally_read_only() { + let tmp = tempdir().expect("tempdir"); + std::fs::create_dir(tmp.path().join("src")).expect("src"); + let registry = ToolRegistryBuilder::new() + .with_git_tools() + .with_git_history_tools() + .with_review_tool(None, "fixture-model".to_string()) + .build(scoped_context(tmp.path())); + + for (name, input) in [ + ("Git", json!({"action": "status"})), + ("git_diff", json!({})), + ("git_show", json!({"revision": "HEAD"})), + ("git_blame", json!({"path": "src/lib.rs"})), + ("review", json!({"target": "diff"})), + ] { + let error = registry + .execute_full(name, input) + .await + .expect_err("Git subprocesses remain unprovable under Fleet authority") + .to_string(); + assert!(error.contains("Git helpers"), "{name}: {error}"); + } + } + + #[tokio::test] + async fn fleet_authority_rejects_fim_edit_outside_its_write_scope() { + let tmp = tempdir().expect("tempdir"); + std::fs::create_dir(tmp.path().join("src")).expect("src"); + std::fs::create_dir(tmp.path().join("docs")).expect("docs"); + std::fs::write(tmp.path().join("docs/outside.txt"), "before\nafter\n").expect("fixture"); + let registry = ToolRegistryBuilder::new() + .with_fim_tool(None, "fixture-model".to_string()) + .build(scoped_context(tmp.path())); + + let error = registry + .execute_full( + "fim_edit", + json!({ + "path": "docs/outside.txt", + "prefix_anchor": "before\n", + "suffix_anchor": "after\n" + }), + ) + .await + .expect_err("FIM mutation must be checked before model execution") + .to_string(); + assert!(error.contains("outside its machine-readable"), "{error}"); + assert_eq!( + std::fs::read_to_string(tmp.path().join("docs/outside.txt")).unwrap(), + "before\nafter\n" + ); + } + + struct MixedExecutionTool; + + #[async_trait::async_trait] + impl ToolSpec for MixedExecutionTool { + fn name(&self) -> &str { + "mixed_execution" + } + + fn description(&self) -> &str { + "inspect or start a child" + } + + fn input_schema(&self) -> Value { + json!({"type": "object"}) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ExecutesCode] + } + + fn is_read_only_for(&self, input: &Value) -> bool { + input.get("action").and_then(Value::as_str) == Some("inspect") + } + + async fn execute( + &self, + _input: Value, + _context: &ToolContext, + ) -> Result { + Ok(ToolResult::success("observed")) + } + } + + #[tokio::test] + async fn fleet_authority_allows_read_only_actions_but_denies_mixed_family_starts() { + let tmp = tempdir().expect("tempdir"); + std::fs::create_dir(tmp.path().join("src")).expect("src"); + let registry = ToolRegistryBuilder::new() + .with_tool(Arc::new(MixedExecutionTool)) + .build(scoped_context(tmp.path())); + + registry + .execute_full("mixed_execution", json!({"action": "inspect"})) + .await + .expect("read-only status/inspect actions remain usable"); + let error = registry + .execute_full("mixed_execution", json!({"action": "start"})) + .await + .expect_err("child/code starts remain denied") + .to_string(); + assert!(error.contains("child execution"), "{error}"); + } + + struct UnscopedMutator; + + #[async_trait::async_trait] + impl ToolSpec for UnscopedMutator { + fn name(&self) -> &str { + "unscoped_mutator" + } + + fn description(&self) -> &str { + "mutates state without a file target" + } + + fn input_schema(&self) -> Value { + json!({"type": "object"}) + } + + fn capabilities(&self) -> Vec { + Vec::new() + } + + fn is_read_only_for(&self, _input: &Value) -> bool { + false + } + + async fn execute( + &self, + _input: Value, + _context: &ToolContext, + ) -> Result { + Ok(ToolResult::success("mutated")) + } + } + + #[tokio::test] + async fn fleet_authority_denies_every_unscoped_mutator_not_only_file_capabilities() { + let tmp = tempdir().expect("tempdir"); + std::fs::create_dir(tmp.path().join("src")).expect("src"); + let registry = ToolRegistryBuilder::new() + .with_tool(Arc::new(UnscopedMutator)) + .build(scoped_context(tmp.path())); + + let error = registry + .execute_full("unscoped_mutator", json!({})) + .await + .expect_err("unscoped mutation must fail closed") + .to_string(); + assert!(error.contains("mutating tool"), "{error}"); + } + #[test] fn test_builder_basic() { let tmp = tempdir().expect("tempdir"); @@ -1976,6 +2292,102 @@ mod tests { assert!(!registry.contains("finance")); } + #[test] + fn canonical_runtime_tools_hide_legacy_aliases() { + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + let registry = ToolRegistryBuilder::new() + .with_file_tools() + .with_search_tools() + .with_git_tools() + .with_git_history_tools() + .with_test_runner_tool() + .with_web_tools() + .with_patch_tools() + .build(ctx); + + let api_names = registry + .to_api_tools() + .into_iter() + .map(|tool| tool.name) + .collect::>(); + for canonical in ["File", "Git", "Run", "Web"] { + assert!(api_names.iter().any(|name| name == canonical)); + } + for alias in [ + "read_file", + "write_file", + "edit_file", + "list_dir", + "file_search", + "grep_files", + "apply_patch", + "git_status", + "git_diff", + "git_log", + "git_show", + "git_blame", + "run_tests", + "run_verifiers", + "web_search", + "fetch_url", + "wait_for_dev_server", + ] { + assert!(registry.contains(alias), "{alias} should remain callable"); + assert!( + api_names.iter().all(|name| name != alias), + "{alias} should be hidden" + ); + } + } + + #[tokio::test] + async fn legacy_file_aliases_replay_through_canonical_dispatch() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join("sample.txt"), "before\n").expect("fixture"); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + let registry = ToolRegistryBuilder::new().with_file_tools().build(ctx); + + registry + .execute("read_file", json!({"path": "sample.txt"})) + .await + .expect("legacy read should execute"); + registry + .execute( + "edit_file", + json!({"path": "sample.txt", "search": "before", "replace": "after"}), + ) + .await + .expect("legacy edit should execute after the replayed read"); + + assert_eq!( + std::fs::read_to_string(tmp.path().join("sample.txt")).expect("edited file"), + "after\n" + ); + } + + #[test] + fn read_only_file_surface_does_not_advertise_write_actions() { + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + let registry = ToolRegistryBuilder::new() + .with_read_only_file_tools() + .with_search_tools() + .build(ctx); + let file = registry + .to_api_tools() + .into_iter() + .find(|tool| tool.name == "File") + .expect("canonical File tool"); + let actions = file.input_schema["properties"]["action"]["enum"] + .as_array() + .expect("action enum"); + + for blocked in ["write", "edit", "patch"] { + assert!(actions.iter().all(|action| action != blocked)); + } + } + #[test] fn test_builder_with_finance_tool() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/tools/run_tool.rs b/crates/tui/src/tools/run_tool.rs new file mode 100644 index 0000000000..8b3f44785d --- /dev/null +++ b/crates/tui/src/tools/run_tool.rs @@ -0,0 +1,158 @@ +//! Canonical action-based wrapper for run/test/verifier tools. +//! +//! The model sees one tool: `Run` with an `action` parameter +//! (tests | verifiers). Legacy names (`run_tests`, `run_verifiers`) stay +//! registered as hidden compat aliases that force the action so saved +//! transcripts replay correctly. + +use async_trait::async_trait; +use serde_json::{Value, json}; + +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, +}; +use super::test_runner::RunTestsTool; +use super::verifier::RunVerifiersTool; + +pub struct RunTool { + name: &'static str, + forced_action: Option<&'static str>, +} + +impl RunTool { + pub const fn new(name: &'static str) -> Self { + Self { + name, + forced_action: None, + } + } + + pub const fn alias(name: &'static str, action: &'static str) -> Self { + Self { + name, + forced_action: Some(action), + } + } + + fn resolve_action<'a>(&self, input: &'a Value) -> &'a str { + self.forced_action.unwrap_or_else(|| { + input + .get("action") + .and_then(Value::as_str) + .unwrap_or("tests") + }) + } + + fn strip_action(&self, input: Value) -> Result { + let mut input = input; + if let Some(obj) = input.as_object_mut() { + obj.remove("action"); + Ok(input) + } else { + Err(ToolError::invalid_input("Run tool input must be an object")) + } + } +} + +#[async_trait] +impl ToolSpec for RunTool { + fn name(&self) -> &'static str { + self.name + } + + fn model_visible(&self) -> bool { + self.name == "Run" + } + + fn description(&self) -> &'static str { + "Run Cargo tests or repository verifier gates. Use tests for focused Rust test runs; use verifiers for cross-language build, test, lint, and syntax gates. Set background=true for verifier suites expected to take more than a few seconds." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["tests", "verifiers"], + "description": "Action to perform" + }, + "args": { + "type": "string", + "description": "Extra arguments for cargo test (action=tests)" + }, + "all_features": { + "type": "boolean", + "description": "Include --all-features for cargo test (action=tests)" + }, + "profile": { + "type": "string", + "enum": ["auto", "rust", "node", "python", "go"], + "description": "Verifier profile (action=verifiers)" + }, + "level": { + "type": "string", + "enum": ["quick", "full"], + "description": "Verifier level (action=verifiers)" + }, + "max_python_files": { + "type": "integer", + "description": "Maximum Python files for the verifier syntax gate (action=verifiers)" + }, + "commands": { + "type": "array", + "description": "Optional explicit verifier gates (action=verifiers)", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "program": { "type": "string" }, + "args": { "type": "array", "items": { "type": "string" } }, + "cwd": { "type": "string" } + }, + "required": ["name", "program"] + } + }, + "background": { + "type": "boolean", + "description": "Start verifier gates as background jobs (action=verifiers)" + } + }, + "required": ["action"] + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ExecutesCode, ToolCapability::Sandboxable] + } + + fn approval_requirement_for(&self, _input: &Value) -> ApprovalRequirement { + ApprovalRequirement::Required + } + + fn is_read_only_for(&self, _input: &Value) -> bool { + false + } + + fn supports_parallel_for(&self, _input: &Value) -> bool { + false + } + + fn starts_detached_for(&self, input: &Value) -> bool { + self.resolve_action(input) == "verifiers" + && input.get("background").and_then(Value::as_bool) == Some(true) + } + + async fn execute(&self, input: Value, context: &ToolContext) -> Result { + let action = self.resolve_action(&input).to_string(); + let input = self.strip_action(input)?; + + match action.as_str() { + "tests" => RunTestsTool.execute(input, context).await, + "verifiers" => RunVerifiersTool.execute(input, context).await, + other => Err(ToolError::invalid_input(format!( + "Unknown Run action: {other}" + ))), + } + } +} diff --git a/crates/tui/src/tools/search.rs b/crates/tui/src/tools/search.rs index 855aa65914..6300d738c6 100644 --- a/crates/tui/src/tools/search.rs +++ b/crates/tui/src/tools/search.rs @@ -48,6 +48,10 @@ impl ToolSpec for GrepFilesTool { "grep_files" } + fn model_visible(&self) -> bool { + false + } + fn description(&self) -> &'static str { "Search for a regex pattern in workspace files. Use this instead of `grep -r`, `rg`, or `find ... -exec grep` in `exec_shell` — pure-Rust, faster, and respects `.gitignore`. Returns matching lines with context (default: 2 lines before/after each match)." } diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index a7cd0bcf3e..d00d998d38 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -5,7 +5,7 @@ //! - Background process execution //! - Process output retrieval //! - Process termination -//! - Sandbox support (macOS Seatbelt) +//! - Sandbox support (macOS Seatbelt and opt-in Linux bubblewrap) //! - Streaming output (future) use anyhow::{Context, Result, anyhow}; @@ -1094,13 +1094,18 @@ impl ShellManager { /// Enable or disable bubblewrap passthrough (#2184). /// - /// When enabled and `/usr/bin/bwrap` is present on Linux, exec_shell + /// When enabled and `/usr/bin/bwrap` is executable on Linux, exec_shell /// commands are routed through bubblewrap for filesystem isolation. - #[allow(dead_code)] // Wired from EngineConfig in follow-up PR pub fn set_prefer_bwrap(&mut self, prefer: bool) { self.sandbox_manager.set_prefer_bwrap(prefer); } + /// Return the OS sandbox wrapper this shell manager is configured and able + /// to apply to commands. + pub fn configured_sandbox_type(&self) -> Option { + self.sandbox_manager.configured_sandbox() + } + /// Request that the active foreground shell wait detach and leave its /// process running in the background job table. pub fn request_foreground_background(&mut self) { @@ -2459,6 +2464,7 @@ fn exec_shell_input_starts_detached(input: &serde_json::Value) -> bool { async fn execute_foreground_via_background( context: &ToolContext, command: &str, + working_dir: Option, timeout_ms: u64, stdin_data: Option<&str>, tty: bool, @@ -2474,7 +2480,7 @@ async fn execute_foreground_via_background( manager.clear_foreground_background_request(); manager.execute_with_options_env_for_owner_and_work( command, - None, + working_dir.as_deref(), timeout_ms, true, stdin_data, @@ -2797,7 +2803,10 @@ impl ToolSpec for BashTool { let resolved = context.resolve_path(dir)?; Some(resolved.to_string_lossy().to_string()) } - None => None, + // Default to the tool context's workspace (which reflects the + // child agent's worktree when `worktree: true` was used), not the + // shared ShellManager's parent-workspace default_workspace. + None => Some(context.workspace.display().to_string()), }; // #456 — collect env from any configured `shell_env` hooks. Runs @@ -2977,6 +2986,7 @@ impl ToolSpec for BashTool { execute_foreground_via_background( context, command, + working_dir, timeout_ms, stdin_data.as_deref(), combined_output, diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 0237be548f..07d239805f 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -1871,3 +1871,47 @@ fn issue_1691_quoted_commit_message_round_trips() { .collect(); assert_eq!(got, spec.args); } + +/// When no `cwd` is provided, the shell should run in `context.workspace`, +/// not in the ShellManager's default_workspace. This ensures sub-agents in +/// worktrees run commands in the worktree directory rather than the parent. +/// +/// Without the `context.workspace` default (stashed): runs in sm_dir → FAILS +/// With the `context.workspace` default (unstashed): runs in ctx_dir → PASSES +#[tokio::test] +async fn default_cwd_uses_context_workspace_not_shell_manager_default() { + let ctx_dir = tempdir().expect("ctx tempdir"); + let sm_dir = tempdir().expect("sm tempdir"); + + // Create distinct dirs — write a marker in each so we can tell them apart. + std::fs::write(ctx_dir.path().join("I_AM_CTX_DIR"), "").unwrap(); + std::fs::write(sm_dir.path().join("I_AM_SM_DIR"), "").unwrap(); + + // ToolContext whose workspace is ctx_dir... + let ctx = ToolContext::new(ctx_dir.path()) + // ...but whose ShellManager's default_workspace is sm_dir. + .with_shell_manager(new_shared_shell_manager(sm_dir.path().to_path_buf())); + + // Assert directory identity through marker files instead of comparing the + // shell's printed path. PowerShell and `canonicalize` can spell the same + // Windows path differently (for example, with a verbatim-path prefix). + let command = if cfg!(windows) { + "if (Test-Path -LiteralPath 'I_AM_CTX_DIR') { Write-Output 'context-workspace' } elseif (Test-Path -LiteralPath 'I_AM_SM_DIR') { Write-Output 'manager-workspace' } else { Write-Output 'missing-workspace' }" + } else { + "if [ -f I_AM_CTX_DIR ]; then printf 'context-workspace'; elif [ -f I_AM_SM_DIR ]; then printf 'manager-workspace'; else printf 'missing-workspace'; fi" + }; + let result = BashTool::new("Bash") + .execute(json!({"command": command}), &ctx) + .await + .expect("shell execute"); + assert!(result.success, "command failed: {:?}", result.content); + + assert!( + result + .content + .lines() + .any(|line| line.trim() == "context-workspace"), + "expected context.workspace marker, but shell reported: {:?}", + result.content + ); +} diff --git a/crates/tui/src/tools/skill.rs b/crates/tui/src/tools/skill.rs index 2d0e99b623..0dd7855e63 100644 --- a/crates/tui/src/tools/skill.rs +++ b/crates/tui/src/tools/skill.rs @@ -10,19 +10,12 @@ //! would blow the prompt budget the moment a user has half a dozen //! skills installed. //! -//! Two paths exist for the model to actually read a native skill: -//! -//! 1. The existing progressive-disclosure pattern: model spots a -//! skill in the catalogue, calls `read_file ` from the -//! listing. -//! 2. (this tool) `load_skill name=` — single call, name-based -//! lookup, also enumerates the sibling files in the skill's -//! directory so the model sees the companion resources without -//! a separate `list_dir`. -//! -//! Both are valid for native skills. Reviewed plugin skills are exposed only -//! through this tool's content-bound in-memory snapshot; their mutable source -//! paths and companion files are deliberately not returned. +//! `load_skill name=` is the canonical progressive-disclosure path. It +//! performs a name-based host lookup, so native global skills work without +//! widening the model's workspace file authority, and it enumerates companion +//! files without a separate `list_dir`. Reviewed plugin skills are exposed +//! only through this tool's content-bound in-memory snapshot; their mutable +//! source paths and companion files are deliberately not returned. use async_trait::async_trait; use serde_json::{Value, json}; @@ -561,6 +554,46 @@ mod tests { ); } + #[tokio::test] + async fn execute_loads_configured_external_skill_without_workspace_trust() { + let tmp = tempdir().unwrap(); + let workspace = tmp.path().join("workspace"); + let home = tmp.path().join("home"); + let global_skills = home.join(".codewhale/skills"); + fs::create_dir_all(&workspace).unwrap(); + write_skill( + &global_skills, + "global-helper", + "Global helper", + "Global body marker.", + ); + + // Keep this test independent of the process-native home directory: + // `dirs::home_dir()` cannot be redirected reliably after process start + // on Windows. The injected-home discovery test in `skills::tests` + // separately proves that ~/.codewhale/skills enters the default catalog. + let context = ToolContext::new(&workspace).with_skills_config(global_skills.clone(), false); + assert!(!context.trust_mode); + assert!( + context + .resolve_path( + global_skills + .join("global-helper/SKILL.md") + .to_str() + .unwrap() + ) + .is_err(), + "ordinary file tools must retain the workspace boundary" + ); + + let result = LoadSkillTool + .execute(json!({"name": "global-helper"}), &context) + .await + .expect("load_skill host lookup should open a configured external skill root"); + assert!(result.success); + assert!(result.content.contains("Global body marker.")); + } + #[tokio::test] async fn execute_returns_helpful_error_for_unknown_skill() { let tmp = tempdir().unwrap(); diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index b8269a8447..e774a10f31 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -9,12 +9,14 @@ use std::collections::HashMap; use std::fs; use std::path::{Component, Path, PathBuf}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::SystemTime; use async_trait::async_trait; +use serde::{Deserialize, Serialize}; use serde_json::Value; use tokio_util::sync::CancellationToken; +use unicode_normalization::UnicodeNormalization; use crate::features::Features; use crate::lsp::LspManager; @@ -145,11 +147,289 @@ pub enum SandboxPolicy { None, } +/// Machine-readable mutation boundary for a headless worker process. +/// +/// Fleet serializes this envelope onto the exact `codewhale exec` argv. The +/// child installs it before constructing its engine, and every ToolContext in +/// that process inherits the same outer cap. Nested agents may narrow this +/// boundary, but cannot remove or expand it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ToolAuthorityEnvelope { + pub schema_version: u32, + pub owner: String, + pub authority: ToolMutationAuthority, + #[serde(default)] + pub writable_roots: Vec, + #[serde(default)] + pub writable_files: Vec, + #[serde(default)] + pub coordination_contracts: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolMutationAuthority { + ReadOnly, + ScopedWrite, +} + +static PROCESS_TOOL_AUTHORITY: OnceLock> = OnceLock::new(); + +impl ToolAuthorityEnvelope { + pub fn normalized(mut self) -> Result { + if self.schema_version != 1 { + return Err(format!( + "unsupported tool authority schema version {}", + self.schema_version + )); + } + self.owner = bounded_authority_value("owner", &self.owner, 128)?; + self.writable_roots = normalize_authority_paths(&self.writable_roots, "writable_roots")?; + self.writable_files = normalize_authority_paths(&self.writable_files, "writable_files")?; + self.coordination_contracts = normalize_authority_values( + &self.coordination_contracts, + "coordination_contracts", + 16, + 128, + )?; + if self.authority == ToolMutationAuthority::ScopedWrite + && self.writable_roots.is_empty() + && self.writable_files.is_empty() + && self.coordination_contracts.is_empty() + { + return Err( + "scoped_write authority requires a writable root, exact file, or coordination contract" + .to_string(), + ); + } + if self.authority == ToolMutationAuthority::ReadOnly + && (!self.writable_roots.is_empty() + || !self.writable_files.is_empty() + || !self.coordination_contracts.is_empty()) + { + return Err("read_only authority cannot carry mutation scope".to_string()); + } + Ok(self) + } + + pub fn from_json(raw: &str) -> Result { + serde_json::from_str::(raw) + .map_err(|error| format!("invalid tool authority envelope: {error}"))? + .normalized() + } + + #[cfg(test)] + fn is_within(&self, outer: &Self) -> bool { + if self.authority == ToolMutationAuthority::ReadOnly { + return true; + } + if outer.authority != ToolMutationAuthority::ScopedWrite { + return false; + } + self.writable_roots.iter().all(|path| { + outer + .writable_roots + .iter() + .any(|root| authority_path_is_within_root(path, root)) + }) && self.writable_files.iter().all(|path| { + outer.writable_files.contains(path) + || outer + .writable_roots + .iter() + .any(|root| authority_path_is_within_root(path, root)) + }) && self + .coordination_contracts + .iter() + .all(|contract| outer.coordination_contracts.contains(contract)) + } + + pub fn permits_mutation_path( + &self, + context: &ToolContext, + raw_path: &str, + ) -> Result { + if self.authority == ToolMutationAuthority::ReadOnly { + return Ok(false); + } + let target = resolve_strict_authority_path(context, raw_path)?; + for file in &self.writable_files { + if resolve_strict_authority_path(context, file)? == target { + return Ok(true); + } + } + for root in &self.writable_roots { + if target.starts_with(resolve_strict_authority_path(context, root)?) { + return Ok(true); + } + } + Ok(false) + } +} + +#[cfg(test)] +fn authority_path_is_within_root(path: &str, root: &str) -> bool { + root == "." + || path == root + || path + .strip_prefix(root) + .is_some_and(|suffix| suffix.starts_with('/')) +} + +pub fn install_process_tool_authority(envelope: ToolAuthorityEnvelope) -> Result<(), String> { + let envelope = Arc::new(envelope.normalized()?); + if let Some(existing) = PROCESS_TOOL_AUTHORITY.get() { + return if existing.as_ref() == envelope.as_ref() { + Ok(()) + } else { + Err("tool authority envelope was already installed for this process".to_string()) + }; + } + PROCESS_TOOL_AUTHORITY + .set(envelope) + .map_err(|_| "tool authority envelope was already installed for this process".to_string()) +} + +fn process_tool_authority() -> Option> { + PROCESS_TOOL_AUTHORITY.get().cloned() +} + +fn bounded_authority_value(field: &str, value: &str, max_chars: usize) -> Result { + let value = value.trim().nfc().collect::(); + if value.is_empty() + || value.chars().count() > max_chars + || value.chars().any(|ch| matches!(ch, '\0' | '\r' | '\n')) + { + return Err(format!( + "tool authority {field} must be one non-empty line of at most {max_chars} characters" + )); + } + Ok(value) +} + +fn normalize_authority_paths(values: &[String], field: &str) -> Result, String> { + if values.len() > 32 { + return Err(format!("tool authority {field} accepts at most 32 entries")); + } + let mut normalized = Vec::new(); + for raw in values { + let raw = bounded_authority_value(field, raw, 512)?.replace('\\', "/"); + let windows_drive = raw.as_bytes().get(1) == Some(&b':') + && raw.as_bytes().first().is_some_and(u8::is_ascii_alphabetic); + if raw.starts_with('/') || raw.starts_with("//") || windows_drive { + return Err(format!( + "tool authority {field} entries must be repo-relative" + )); + } + let mut segments = Vec::new(); + for segment in raw.split('/') { + match segment { + "" | "." => {} + ".." => { + return Err(format!( + "tool authority {field} cannot contain parent traversal" + )); + } + value => segments.push(value), + } + } + let path = if segments.is_empty() { + ".".to_string() + } else { + segments.join("/") + }; + if !normalized.contains(&path) { + normalized.push(path); + } + } + Ok(normalized) +} + +fn normalize_authority_values( + values: &[String], + field: &str, + max_entries: usize, + max_chars: usize, +) -> Result, String> { + if values.len() > max_entries { + return Err(format!( + "tool authority {field} accepts at most {max_entries} entries" + )); + } + let mut normalized = Vec::new(); + for value in values { + let value = bounded_authority_value(field, value, max_chars)?; + if !normalized.contains(&value) { + normalized.push(value); + } + } + Ok(normalized) +} + +pub(crate) fn resolve_strict_authority_path( + context: &ToolContext, + raw_path: &str, +) -> Result { + let normalized = normalize_authority_paths(&[raw_path.to_string()], "mutation_path") + .map_err(ToolError::permission_denied)? + .into_iter() + .next() + .ok_or_else(|| ToolError::permission_denied("mutation path cannot be empty"))?; + let workspace = context.workspace.canonicalize().map_err(|error| { + ToolError::execution_failed(format!( + "Failed to canonicalize authority workspace {}: {error}", + context.workspace.display() + )) + })?; + let mut current = workspace.clone(); + if normalized != "." { + for segment in normalized.split('/') { + current.push(segment); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(ToolError::permission_denied(format!( + "machine-readable authority paths must not traverse symlinks: {}", + current.display() + ))); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(ToolError::execution_failed(format!( + "Failed to inspect authority path {}: {error}", + current.display() + ))); + } + } + } + } + if !current.starts_with(&workspace) { + return Err(ToolError::permission_denied(format!( + "machine-readable authority path escapes workspace: {}", + current.display() + ))); + } + Ok(current) +} + /// Context passed to tools during execution. #[derive(Clone)] pub struct ToolContext { /// The workspace root directory pub workspace: PathBuf, + /// Per-turn policy and attached services. Kept behind one owned group so + /// cloning a context preserves the historical value semantics while the + /// top-level context remains small and stable as services evolve. + pub execution: Box, +} + +/// Policy and service state attached to one tool-execution context. +/// +/// `ToolContext` dereferences to this group for source compatibility with +/// existing tools. New code can use `context.execution` when the grouping is +/// useful, without growing the top-level context by another field per feature. +#[derive(Clone)] +pub struct ToolExecutionState { /// Shared shell manager for background tasks and streaming IO. pub shell_manager: SharedShellManager, /// Per-session snapshots for files successfully observed by `read_file`. @@ -161,6 +441,9 @@ pub struct ToolContext { /// jobs can be attributed in UI surfaces. pub owner_agent_id: Option, pub owner_agent_name: Option, + /// Outer process authority cap installed by Fleet/headless dispatch. + /// `None` for ordinary interactive/root sessions. + pub(crate) tool_authority: Option>, /// Whether to allow paths outside workspace pub trust_mode: bool, /// Current sandbox policy @@ -269,12 +552,25 @@ pub struct ToolContext { >, } +impl std::ops::Deref for ToolContext { + type Target = ToolExecutionState; + + fn deref(&self) -> &Self::Target { + &self.execution + } +} + +impl std::ops::DerefMut for ToolContext { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.execution + } +} + impl ToolContext { /// Create a new `ToolContext` with default settings. #[must_use] pub fn new(workspace: impl Into) -> Self { let workspace = workspace.into(); - let shell_manager = new_shared_shell_manager(workspace.clone()); // Prefer .codewhale, fall back to .deepseek for project-local state let notes_path = codewhale_config::resolve_project_state_dir(&workspace, "notes.md") .expect("hardcoded project notes state path is valid") @@ -282,44 +578,7 @@ impl ToolContext { let mcp_config_path = codewhale_config::resolve_project_state_dir(&workspace, "mcp.json") .expect("hardcoded project MCP state path is valid") .1; - Self { - workspace, - shell_manager, - file_read_tracker: new_shared_file_read_tracker(), - owner_agent_id: None, - owner_agent_name: None, - trust_mode: false, - sandbox_policy: SandboxPolicy::None, - notes_path, - mcp_config_path, - skills_dir: None, - skills_scan_codewhale_only: false, - plugin_registry: None, - elevated_sandbox_policy: None, - shell_network_denied_hint: None, - auto_approve: false, - review_plan_changes: false, - shell_policy: ShellPolicy::Full, - features: Features::with_defaults(), - state_namespace: "workspace".to_string(), - route_context_window: None, - trusted_external_paths: Vec::new(), - follow_symlinks: false, - network_policy: None, - runtime: RuntimeToolServices::default(), - session_objects: None, - cancel_token: None, - sandbox_backend: None, - memory_path: None, - lsp_manager: None, - large_output_router: None, - search_provider: crate::config::SearchProvider::default(), - search_api_key: None, - search_base_url: None, - provider_native_search: None, - route_capabilities: codewhale_config::route::RouteCapabilities::default(), - workshop_vars: None, - } + Self::with_options(workspace, false, notes_path, mcp_config_path) } /// Create a `ToolContext` with all settings specified. @@ -334,41 +593,44 @@ impl ToolContext { let shell_manager = new_shared_shell_manager(workspace.clone()); Self { workspace, - shell_manager, - file_read_tracker: new_shared_file_read_tracker(), - owner_agent_id: None, - owner_agent_name: None, - trust_mode, - sandbox_policy: SandboxPolicy::None, - notes_path: notes_path.into(), - mcp_config_path: mcp_config_path.into(), - skills_dir: None, - skills_scan_codewhale_only: false, - plugin_registry: None, - elevated_sandbox_policy: None, - shell_network_denied_hint: None, - auto_approve: false, - review_plan_changes: false, - shell_policy: ShellPolicy::Full, - features: Features::with_defaults(), - state_namespace: "workspace".to_string(), - route_context_window: None, - trusted_external_paths: Vec::new(), - follow_symlinks: false, - network_policy: None, - runtime: RuntimeToolServices::default(), - session_objects: None, - cancel_token: None, - sandbox_backend: None, - memory_path: None, - lsp_manager: None, - large_output_router: None, - search_provider: crate::config::SearchProvider::default(), - search_api_key: None, - search_base_url: None, - provider_native_search: None, - route_capabilities: codewhale_config::route::RouteCapabilities::default(), - workshop_vars: None, + execution: Box::new(ToolExecutionState { + shell_manager, + file_read_tracker: new_shared_file_read_tracker(), + owner_agent_id: None, + owner_agent_name: None, + tool_authority: process_tool_authority(), + trust_mode, + sandbox_policy: SandboxPolicy::None, + notes_path: notes_path.into(), + mcp_config_path: mcp_config_path.into(), + skills_dir: None, + skills_scan_codewhale_only: false, + plugin_registry: None, + elevated_sandbox_policy: None, + shell_network_denied_hint: None, + auto_approve: false, + review_plan_changes: false, + shell_policy: ShellPolicy::Full, + features: Features::with_defaults(), + state_namespace: "workspace".to_string(), + route_context_window: None, + trusted_external_paths: Vec::new(), + follow_symlinks: false, + network_policy: None, + runtime: RuntimeToolServices::default(), + session_objects: None, + cancel_token: None, + sandbox_backend: None, + memory_path: None, + lsp_manager: None, + large_output_router: None, + search_provider: crate::config::SearchProvider::default(), + search_api_key: None, + search_base_url: None, + provider_native_search: None, + route_capabilities: codewhale_config::route::RouteCapabilities::default(), + workshop_vars: None, + }), } } @@ -380,46 +642,9 @@ impl ToolContext { mcp_config_path: impl Into, auto_approve: bool, ) -> Self { - let workspace = workspace.into(); - let shell_manager = new_shared_shell_manager(workspace.clone()); - Self { - workspace, - shell_manager, - file_read_tracker: new_shared_file_read_tracker(), - owner_agent_id: None, - owner_agent_name: None, - trust_mode, - sandbox_policy: SandboxPolicy::None, - notes_path: notes_path.into(), - mcp_config_path: mcp_config_path.into(), - skills_dir: None, - skills_scan_codewhale_only: false, - plugin_registry: None, - elevated_sandbox_policy: None, - shell_network_denied_hint: None, - auto_approve, - review_plan_changes: false, - shell_policy: ShellPolicy::Full, - features: Features::with_defaults(), - state_namespace: "workspace".to_string(), - route_context_window: None, - trusted_external_paths: Vec::new(), - follow_symlinks: false, - network_policy: None, - runtime: RuntimeToolServices::default(), - session_objects: None, - cancel_token: None, - sandbox_backend: None, - memory_path: None, - lsp_manager: None, - large_output_router: None, - search_provider: crate::config::SearchProvider::default(), - search_api_key: None, - search_base_url: None, - provider_native_search: None, - route_capabilities: codewhale_config::route::RouteCapabilities::default(), - workshop_vars: None, - } + let mut context = Self::with_options(workspace, trust_mode, notes_path, mcp_config_path); + context.auto_approve = auto_approve; + context } /// Attach a per-domain network policy to this context (#135). @@ -458,6 +683,23 @@ impl ToolContext { self } + #[cfg(test)] + pub(crate) fn with_tool_authority( + mut self, + envelope: ToolAuthorityEnvelope, + ) -> Result { + let envelope = envelope.normalized()?; + if let Some(outer) = self.tool_authority.as_ref() + && !envelope.is_within(outer) + { + return Err( + "nested tool authority cannot expand its process authority cap".to_string(), + ); + } + self.tool_authority = Some(Arc::new(envelope)); + Ok(self) + } + /// Attach skill discovery settings for tools that need to resolve /// model-visible skills by name. #[must_use] @@ -1115,6 +1357,30 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn tool_context_keeps_execution_state_grouped_and_value_cloned() { + let mut context = ToolContext::new("."); + context.auto_approve = true; + context.state_namespace = "session-a".to_string(); + + assert!(context.execution.auto_approve); + assert_eq!(context.execution.state_namespace, "session-a"); + + let mut cloned = context.clone(); + cloned.state_namespace = "session-b".to_string(); + assert_eq!(context.state_namespace, "session-a"); + assert_eq!(cloned.execution.state_namespace, "session-b"); + } + + #[test] + fn tool_context_top_level_stays_slim_as_services_grow() { + assert!( + std::mem::size_of::() + <= std::mem::size_of::() + 2 * std::mem::size_of::(), + "ToolContext should contain only the workspace and boxed execution group" + ); + } + /// Issue #29: paths under a user-trusted external directory resolve /// successfully even though they fall outside the workspace, while /// untrusted external paths still error with `PathEscape`. @@ -1189,6 +1455,166 @@ mod tests { assert!(matches!(err, ToolError::PathEscape { .. })); } + fn scoped_authority(roots: &[&str], files: &[&str]) -> ToolAuthorityEnvelope { + ToolAuthorityEnvelope { + schema_version: 1, + owner: "fleet-worker-1".to_string(), + authority: ToolMutationAuthority::ScopedWrite, + writable_roots: roots.iter().map(|value| (*value).to_string()).collect(), + writable_files: files.iter().map(|value| (*value).to_string()).collect(), + coordination_contracts: Vec::new(), + } + .normalized() + .expect("valid test authority") + } + + #[test] + fn tool_authority_allows_normal_nonexistent_children_only_inside_scope() { + let tmp = tempdir().expect("tempdir"); + std::fs::create_dir(tmp.path().join("src")).expect("src"); + let context = ToolContext::new(tmp.path().to_path_buf()); + let authority = scoped_authority(&["src"], &[]); + + assert!( + authority + .permits_mutation_path(&context, "src/new/nested.rs") + .expect("normal nonexistent child") + ); + assert!( + !authority + .permits_mutation_path(&context, "docs/outside.md") + .expect("ordinary out-of-scope path") + ); + } + + #[cfg(unix)] + #[test] + fn tool_authority_rejects_exact_file_symlink_aliases() { + let tmp = tempdir().expect("tempdir"); + std::fs::create_dir(tmp.path().join("src")).expect("src"); + std::fs::create_dir(tmp.path().join("other")).expect("other"); + std::fs::write(tmp.path().join("other/target.rs"), "outside scope\n").expect("target"); + symlink("../other/target.rs", tmp.path().join("src/alias.rs")).expect("alias"); + let context = ToolContext::new(tmp.path().to_path_buf()); + let authority = scoped_authority(&[], &["src/alias.rs"]); + + let error = authority + .permits_mutation_path(&context, "src/alias.rs") + .expect_err("an exact-file claim must not authorize a symlink target") + .to_string(); + assert!(error.contains("must not traverse symlinks"), "{error}"); + } + + #[cfg(unix)] + #[test] + fn tool_authority_rejects_claimed_root_and_child_symlink_aliases() { + let tmp = tempdir().expect("tempdir"); + std::fs::create_dir(tmp.path().join("real")).expect("real"); + symlink("real", tmp.path().join("linked")).expect("linked root"); + let context = ToolContext::new(tmp.path().to_path_buf()); + let claimed_alias = scoped_authority(&["linked"], &[]); + let claimed_real = scoped_authority(&["real"], &[]); + + for (authority, path) in [ + (&claimed_alias, "linked/new.rs"), + (&claimed_real, "linked/new.rs"), + ] { + let error = authority + .permits_mutation_path(&context, path) + .expect_err("symlinked roots and mutation paths must fail closed") + .to_string(); + assert!(error.contains("must not traverse symlinks"), "{error}"); + } + } + + #[test] + fn nested_tool_authority_may_only_narrow_the_outer_cap() { + let tmp = tempdir().expect("tempdir"); + let outer = scoped_authority(&["src"], &["Cargo.toml"]); + let narrower = scoped_authority(&["src/parser"], &[]); + let expansion = scoped_authority(&["docs"], &[]); + ToolContext::new(tmp.path().to_path_buf()) + .with_tool_authority(outer.clone()) + .unwrap() + .with_tool_authority(narrower) + .expect("nested scope may narrow"); + let error = ToolContext::new(tmp.path().to_path_buf()) + .with_tool_authority(outer.clone()) + .unwrap() + .with_tool_authority(expansion) + .err() + .expect("nested scope expansion must fail closed"); + assert!(error.contains("cannot expand"), "{error}"); + + let read_only = ToolAuthorityEnvelope { + schema_version: 1, + owner: "read-only-child".to_string(), + authority: ToolMutationAuthority::ReadOnly, + writable_roots: Vec::new(), + writable_files: Vec::new(), + coordination_contracts: Vec::new(), + }; + ToolContext::new(tmp.path().to_path_buf()) + .with_tool_authority(outer) + .unwrap() + .with_tool_authority(read_only) + .expect("read-only always narrows a write cap"); + } + + #[test] + fn process_tool_authority_inherits_into_all_context_constructors() { + const CHILD_ENV: &str = "CODEWHALE_TEST_PROCESS_TOOL_AUTHORITY_CHILD"; + if std::env::var_os(CHILD_ENV).is_some() { + let tmp = tempdir().expect("tempdir"); + install_process_tool_authority(ToolAuthorityEnvelope { + schema_version: 1, + owner: "fleet-worker-child-process".to_string(), + authority: ToolMutationAuthority::ReadOnly, + writable_roots: Vec::new(), + writable_files: Vec::new(), + coordination_contracts: Vec::new(), + }) + .expect("install process authority once in isolated child"); + let notes = tmp.path().join("notes.md"); + let mcp = tmp.path().join("mcp.json"); + let contexts = [ + ToolContext::new(tmp.path().to_path_buf()), + ToolContext::with_options( + tmp.path().to_path_buf(), + false, + notes.clone(), + mcp.clone(), + ), + ToolContext::with_auto_approve(tmp.path().to_path_buf(), false, notes, mcp, true), + ]; + for context in contexts { + let authority = context + .tool_authority + .as_ref() + .expect("every constructor inherits process authority"); + assert_eq!(authority.owner, "fleet-worker-child-process"); + assert_eq!(authority.authority, ToolMutationAuthority::ReadOnly); + } + return; + } + + let output = std::process::Command::new(std::env::current_exe().expect("test binary")) + .arg("--exact") + .arg( + "tools::spec::tests::process_tool_authority_inherits_into_all_context_constructors", + ) + .arg("--nocapture") + .env(CHILD_ENV, "1") + .output() + .expect("spawn isolated authority test child"); + assert!( + output.status.success(), + "child failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + #[test] fn test_required_str() { let input = json!({"name": "test", "count": 42}); diff --git a/crates/tui/src/tools/subagent/coord.rs b/crates/tui/src/tools/subagent/coord.rs index 6b086e5c9d..f9bd828181 100644 --- a/crates/tui/src/tools/subagent/coord.rs +++ b/crates/tui/src/tools/subagent/coord.rs @@ -4,6 +4,7 @@ //! SubAgentManager / mailbox / checkpoint machinery without restoring the //! retired lifecycle theater (`agent_open` / `agent_eval` / …). +use std::collections::BTreeSet; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -28,6 +29,8 @@ const COORD_WAIT_CHECK_INTERVAL: Duration = Duration::from_millis(250); const RECENT_PROGRESS_LIMIT: usize = 8; pub(super) const COORDINATION_RECORD_LIMIT: usize = 128; const COORDINATION_INSPECT_LIMIT: usize = 24; +pub(super) const COORDINATION_PROJECTION_DECISION_LIMIT: usize = 8; +pub(super) const COORDINATION_PROJECTION_BYTE_LIMIT: usize = 4096; // ── agents/list ────────────────────────────────────────────────────────── @@ -123,12 +126,22 @@ impl ToolSpec for AgentsListTool { pub struct AgentsMessageTool { manager: SharedSubAgentManager, + caller_agent_id: Option, } impl AgentsMessageTool { #[must_use] pub fn new(manager: SharedSubAgentManager) -> Self { - Self { manager } + Self { + manager, + caller_agent_id: None, + } + } + + #[must_use] + pub fn with_caller(mut self, caller_agent_id: impl Into) -> Self { + self.caller_agent_id = Some(caller_agent_id.into()); + self } } @@ -181,6 +194,13 @@ impl ToolSpec for AgentsMessageTool { let receipt = { let mut manager = self.manager.write().await; + manager + .ensure_caller_controls_descendant( + &agent_ref, + self.caller_agent_id.as_deref(), + "agents/message", + ) + .map_err(|err| ToolError::invalid_input(err.to_string()))?; manager .queue_parent_message(&agent_ref, message, false) .map_err(|err| ToolError::invalid_input(err.to_string()))? @@ -211,12 +231,22 @@ impl ToolSpec for AgentsMessageTool { pub struct AgentsFollowupTool { manager: SharedSubAgentManager, + caller_agent_id: Option, } impl AgentsFollowupTool { #[must_use] pub fn new(manager: SharedSubAgentManager) -> Self { - Self { manager } + Self { + manager, + caller_agent_id: None, + } + } + + #[must_use] + pub fn with_caller(mut self, caller_agent_id: impl Into) -> Self { + self.caller_agent_id = Some(caller_agent_id.into()); + self } } @@ -269,6 +299,13 @@ impl ToolSpec for AgentsFollowupTool { let receipt = { let mut manager = self.manager.write().await; + manager + .ensure_caller_controls_descendant( + &agent_ref, + self.caller_agent_id.as_deref(), + "agents/followup", + ) + .map_err(|err| ToolError::invalid_input(err.to_string()))?; manager .followup_child(&agent_ref, message) .map_err(|err| ToolError::invalid_input(err.to_string()))? @@ -650,20 +687,27 @@ pub fn register_coordination_tools( // `runtime.parent_agent_id` is the identity of the agent this registry is // being built FOR: `runtime_for_nested_agent_tools` stamps the child's own // id there before `new_with_owner` registers tools, so anything that agent - // spawns records it as parent. Threading it as the interrupt caller makes - // the self-interrupt fail-closed guard live in production instead of only - // in tests (TUI-DOG-017). `None` means the root engine registry; the root - // is separately protected by the literal-root check in `interrupt_child`. - let interrupt = match runtime.parent_agent_id.as_deref() { - Some(caller) => AgentsInterruptTool::new(Arc::clone(&manager)).with_caller(caller), - None => AgentsInterruptTool::new(Arc::clone(&manager)), - }; - let coordinate = - AgentsCoordinateTool::new(Arc::clone(&manager), runtime.parent_agent_id.clone()); + // spawns records it as parent. Thread that identity through every mutating + // hierarchy tool: a child may control only its own descendants, while the + // root registry (`None`) may control any child (TUI-DOG-017). + let caller = runtime.parent_agent_id.clone(); + let message = caller.as_deref().map_or_else( + || AgentsMessageTool::new(Arc::clone(&manager)), + |caller| AgentsMessageTool::new(Arc::clone(&manager)).with_caller(caller), + ); + let followup = caller.as_deref().map_or_else( + || AgentsFollowupTool::new(Arc::clone(&manager)), + |caller| AgentsFollowupTool::new(Arc::clone(&manager)).with_caller(caller), + ); + let interrupt = caller.as_deref().map_or_else( + || AgentsInterruptTool::new(Arc::clone(&manager)), + |caller| AgentsInterruptTool::new(Arc::clone(&manager)).with_caller(caller), + ); + let coordinate = AgentsCoordinateTool::new(Arc::clone(&manager), caller); builder .with_tool(Arc::new(AgentsListTool::new(Arc::clone(&manager)))) - .with_tool(Arc::new(AgentsMessageTool::new(Arc::clone(&manager)))) - .with_tool(Arc::new(AgentsFollowupTool::new(Arc::clone(&manager)))) + .with_tool(Arc::new(message)) + .with_tool(Arc::new(followup)) .with_tool(Arc::new(interrupt)) .with_tool(Arc::new(coordinate)) .with_tool(Arc::new(AgentsWaitTool::new(manager))) @@ -688,6 +732,34 @@ mod tests { (manager, agent_id) } + async fn manager_with_agent_hierarchy( + workspace: &std::path::Path, + ) -> (SharedSubAgentManager, String, String, String) { + let manager = Arc::new(tokio::sync::RwLock::new( + super::super::SubAgentManager::new(workspace.to_path_buf(), 8), + )); + let (parent, child, sibling) = { + let mut guard = manager.write().await; + let parent = guard.insert_test_running_agent("hierarchy_parent", workspace); + let child = guard.insert_test_running_agent("hierarchy_child", workspace); + let sibling = guard.insert_test_running_agent("hierarchy_sibling", workspace); + for (agent_id, parent_id) in [ + (&parent, "root"), + (&child, parent.as_str()), + (&sibling, "root"), + ] { + let record = guard + .worker_records + .get_mut(agent_id) + .expect("hierarchy worker record"); + record.parent_run_id = Some(parent_id.to_string()); + record.spec.parent_run_id = Some(parent_id.to_string()); + } + (parent, child, sibling) + }; + (manager, parent, child, sibling) + } + #[tokio::test] async fn message_queues_without_waking() { let tmp = tempdir().unwrap(); @@ -711,6 +783,364 @@ mod tests { assert!(!guard.child_was_woken(&agent_id)); } + #[tokio::test] + async fn hierarchy_mutations_allow_own_descendants_and_deny_siblings_or_ancestors() { + let tmp = tempdir().unwrap(); + let (manager, parent, child, sibling) = manager_with_agent_hierarchy(tmp.path()).await; + let context = ToolContext::new(tmp.path()); + + AgentsMessageTool::new(Arc::clone(&manager)) + .with_caller(parent.clone()) + .execute( + json!({ "agent_id": child, "message": "bounded parent note" }), + &context, + ) + .await + .expect("parent may message its own child"); + AgentsFollowupTool::new(Arc::clone(&manager)) + .with_caller(parent.clone()) + .execute( + json!({ "agent_id": child, "message": "resume own child" }), + &context, + ) + .await + .expect("parent may follow up its own child"); + + let sibling_message = AgentsMessageTool::new(Arc::clone(&manager)) + .with_caller(parent.clone()) + .execute( + json!({ "agent_id": sibling, "message": "cross branch" }), + &context, + ) + .await + .expect_err("sibling message must fail closed") + .to_string(); + assert!( + sibling_message.contains("own descendants"), + "{sibling_message}" + ); + + let ancestor_followup = AgentsFollowupTool::new(Arc::clone(&manager)) + .with_caller(child.clone()) + .execute( + json!({ "agent_id": parent, "message": "wake ancestor" }), + &context, + ) + .await + .expect_err("ancestor followup must fail closed") + .to_string(); + assert!( + ancestor_followup.contains("own descendants"), + "{ancestor_followup}" + ); + + let sibling_interrupt = AgentsInterruptTool::new(Arc::clone(&manager)) + .with_caller(parent.clone()) + .execute(json!({ "agent_id": sibling }), &context) + .await + .expect_err("sibling interrupt must fail closed") + .to_string(); + assert!( + sibling_interrupt.contains("own descendants"), + "{sibling_interrupt}" + ); + + let interrupted = AgentsInterruptTool::new(Arc::clone(&manager)) + .with_caller(parent) + .execute(json!({ "agent_id": child }), &context) + .await + .expect("parent may interrupt its own child"); + let body: Value = serde_json::from_str(&interrupted.content).unwrap(); + assert_eq!(body["status"], json!("interrupted")); + } + + #[tokio::test] + async fn coordinate_inspect_is_side_effect_free_and_mutations_are_synchronously_durable() { + let tmp = tempdir().unwrap(); + let blocked_state_path = tmp.path().join("blocked-state"); + std::fs::create_dir(&blocked_state_path).unwrap(); + let blocked_manager = Arc::new(tokio::sync::RwLock::new( + super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4) + .with_state_path(blocked_state_path), + )); + let blocked_tool = AgentsCoordinateTool::new(Arc::clone(&blocked_manager), None); + + blocked_tool + .execute( + json!({ "action": "inspect" }), + &ToolContext::new(tmp.path()), + ) + .await + .expect("read-only inspect must not attempt persistence"); + let error = blocked_tool + .execute( + json!({ + "action": "propose", + "decision_id": "durable-decision", + "subject": "durability", + "constraints": ["persist before acknowledgement"] + }), + &ToolContext::new(tmp.path()), + ) + .await + .expect_err("mutation must fail when its receipt cannot persist") + .to_string(); + assert!(error.contains("failed to persist"), "{error}"); + assert!( + blocked_manager + .read() + .await + .coordination + .decisions + .is_empty(), + "failed persistence must roll the in-memory decision back" + ); + + let durable_workspace = tempdir().unwrap(); + let state_path = durable_workspace.path().join("subagents.v1.json"); + let manager = Arc::new(tokio::sync::RwLock::new( + super::super::SubAgentManager::new(durable_workspace.path().to_path_buf(), 4) + .with_state_path(state_path.clone()), + )); + AgentsCoordinateTool::new(Arc::clone(&manager), None) + .execute( + json!({ + "action": "propose", + "decision_id": "durable-decision", + "subject": "durability", + "constraints": ["persist before acknowledgement"] + }), + &ToolContext::new(durable_workspace.path()), + ) + .await + .expect("durable mutation"); + let mut replayed = + super::super::SubAgentManager::new(durable_workspace.path().to_path_buf(), 4) + .with_state_path(state_path); + replayed.load_state().expect("reload durable action"); + assert_eq!(replayed.coordination.decisions.len(), 1); + assert_eq!( + replayed.coordination.decisions[0].decision_id, + "durable-decision" + ); + } + + #[tokio::test] + async fn rejected_claim_contention_is_persisted_before_returning_the_error() { + let tmp = tempdir().unwrap(); + let state_path = tmp.path().join("subagents.v1.json"); + let manager = Arc::new(tokio::sync::RwLock::new( + super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4) + .with_state_path(state_path.clone()), + )); + let (claimant, owner) = { + let mut guard = manager.write().await; + let claimant = guard.insert_test_running_agent("claimant", tmp.path()); + let owner = guard.insert_test_running_agent("owner", tmp.path()); + let active = [claimant.clone(), owner.clone()] + .into_iter() + .collect::>(); + for claim in [ + WriteScopeClaim { + owner: claimant.clone(), + roots: vec!["src/claimant".into()], + exact_files: Vec::new(), + contracts: Vec::new(), + }, + WriteScopeClaim { + owner: owner.clone(), + roots: vec!["src/shared".into()], + exact_files: Vec::new(), + contracts: Vec::new(), + }, + ] { + guard + .coordination + .register_claim(claim, false, |candidate| active.contains(candidate)) + .expect("initial non-overlapping claim"); + } + (claimant, owner) + }; + + let error = AgentsCoordinateTool::new(Arc::clone(&manager), Some(claimant.clone())) + .execute( + json!({ "action": "claim", "roots": ["src/shared/nested"] }), + &ToolContext::new(tmp.path()), + ) + .await + .expect_err("overlap must block") + .to_string(); + assert!( + error.contains(&owner) && error.contains("contention"), + "{error}" + ); + + let mut replayed = super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4) + .with_state_path(state_path); + replayed.load_state().expect("reload contention receipt"); + assert_eq!(replayed.coordination.contentions.len(), 1); + assert_eq!(replayed.coordination.contentions[0].claimant, claimant); + assert_eq!( + replayed.coordination.contentions[0].conflicting_owner, + owner + ); + } + + #[tokio::test] + async fn coordination_resolution_survives_reload_and_resolving_claim_eviction() { + let tmp = tempdir().unwrap(); + let state_path = tmp.path().join("subagents.v1.json"); + let manager = Arc::new(tokio::sync::RwLock::new( + super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4) + .with_state_path(state_path.clone()), + )); + let claimant = { + let mut guard = manager.write().await; + let claimant = guard.insert_test_running_agent("claimant", tmp.path()); + let owner = guard.insert_test_running_agent("owner", tmp.path()); + let active = [claimant.clone(), owner.clone()] + .into_iter() + .collect::>(); + for claim in [ + WriteScopeClaim { + owner: claimant.clone(), + roots: vec!["src/claimant".into()], + exact_files: Vec::new(), + contracts: Vec::new(), + }, + WriteScopeClaim { + owner: owner.clone(), + roots: vec!["src/shared".into()], + exact_files: Vec::new(), + contracts: Vec::new(), + }, + ] { + guard + .coordination + .register_claim(claim, false, |candidate| active.contains(candidate)) + .expect("initial non-overlapping claim"); + } + claimant + }; + + AgentsCoordinateTool::new(Arc::clone(&manager), Some(claimant.clone())) + .execute( + json!({ "action": "claim", "roots": ["src/shared/nested"] }), + &ToolContext::new(tmp.path()), + ) + .await + .expect_err("overlap must block and persist its receipt"); + + let resolution_sequence = { + let mut guard = manager.write().await; + let record = guard + .coordination + .register_claim( + WriteScopeClaim { + owner: claimant.clone(), + roots: vec!["src/isolated".into()], + exact_files: Vec::new(), + contracts: Vec::new(), + }, + true, + |_| true, + ) + .expect("later isolated claim resolves contention"); + guard + .persist_state_synchronously() + .expect("persist resolved contention"); + record.sequence + }; + + let mut replayed = super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4) + .with_state_path(state_path.clone()); + replayed.load_state().expect("reload resolved contention"); + assert_eq!(replayed.coordination.contentions.len(), 1); + assert_eq!( + replayed.coordination.contentions[0].disposition, + WriteContentionDisposition::ResolvedBySuccessfulClaim + ); + assert_eq!( + replayed.coordination.contentions[0].resolution_sequence, + Some(resolution_sequence) + ); + + let slots = COORDINATION_RECORD_LIMIT - replayed.coordination.write_claims.len(); + for index in 0..slots { + replayed + .coordination + .register_claim( + WriteScopeClaim { + owner: format!("inactive-fill-{index:03}"), + roots: vec![format!("pkg/fill-{index:03}")], + exact_files: Vec::new(), + contracts: Vec::new(), + }, + true, + |_| false, + ) + .expect("fill inactive claim capacity"); + } + for index in 0..2 { + replayed + .coordination + .register_claim( + WriteScopeClaim { + owner: format!("inactive-overflow-{index}"), + roots: vec![format!("pkg/overflow-{index}")], + exact_files: Vec::new(), + contracts: Vec::new(), + }, + true, + |_| false, + ) + .expect("evict oldest inactive claim at capacity"); + } + assert!( + !replayed + .coordination + .write_claims + .iter() + .any(|claim| claim.claim.owner == claimant), + "the resolving claimant claim must be evicted for the durability regression" + ); + replayed + .persist_state_synchronously() + .expect("persist after inactive claim eviction"); + + let mut final_replay = super::super::SubAgentManager::new(tmp.path().to_path_buf(), 4) + .with_state_path(state_path); + final_replay + .load_state() + .expect("reload after resolving claim eviction"); + let projection = final_replay.coordination_detail_projection(None, 24); + assert!( + !projection + .write_claims + .iter() + .any(|claim| claim.claim.owner == claimant) + ); + assert_eq!(projection.contentions.len(), 1); + assert_eq!( + projection.contentions[0].disposition, + WriteContentionDisposition::ResolvedBySuccessfulClaim + ); + assert_eq!( + projection.contentions[0].resolution_sequence, + Some(resolution_sequence) + ); + assert!(!crate::tui::coordination_detail::needs_attention( + &projection + )); + let pager = + crate::tui::coordination_detail::format(crate::localization::Locale::En, &projection); + assert!( + pager.contains("disposition resolved_by_successful_claim"), + "{pager}" + ); + assert!(!pager.contains("disposition blocked_pending"), "{pager}"); + } + #[tokio::test] async fn interrupt_fails_closed_on_self() { let tmp = tempdir().unwrap(); @@ -850,6 +1280,16 @@ pub enum DecisionStatus { Superseded, } +/// Serialized coordination state schema. Increment only with an explicit +/// migration; restart/replay must never infer a newer contract from old data. +pub const COORDINATION_SCHEMA_VERSION: u32 = 1; + +const MAX_RECONCILIATION_RETRIES: u32 = 3; + +const fn coordination_schema_version() -> u32 { + COORDINATION_SCHEMA_VERSION +} + /// A bounded coordination decision record (#4647). /// /// Persisted with stable subject, concise constraints, one active owner, @@ -886,20 +1326,32 @@ impl WriteScopeClaim { pub fn overlaps(&self, other: &WriteScopeClaim) -> bool { for root_a in &self.roots { for root_b in &other.roots { - if path_contains(root_a, root_b) || path_contains(root_b, root_a) { + if paths_overlap_by_containment(root_a, root_b) + || paths_overlap_by_containment(root_b, root_a) + { return true; } } } for file_a in &self.exact_files { - if other.exact_files.iter().any(|f| f == file_a) - || other.roots.iter().any(|root| path_contains(root, file_a)) + if other + .exact_files + .iter() + .any(|file| paths_overlap_equal(file, file_a)) + || other + .roots + .iter() + .any(|root| paths_overlap_by_containment(root, file_a)) { return true; } } for file_b in &other.exact_files { - if self.roots.iter().any(|root| path_contains(root, file_b)) { + if self + .roots + .iter() + .any(|root| paths_overlap_by_containment(root, file_b)) + { return true; } } @@ -930,6 +1382,14 @@ fn path_contains(root: &str, candidate: &str) -> bool { .is_some_and(|suffix| suffix.starts_with('/')) } +fn paths_overlap_equal(left: &str, right: &str) -> bool { + left == right || left.to_lowercase() == right.to_lowercase() +} + +fn paths_overlap_by_containment(root: &str, candidate: &str) -> bool { + path_contains(root, candidate) || path_contains(&root.to_lowercase(), &candidate.to_lowercase()) +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PersistedWriteClaim { pub claim: WriteScopeClaim, @@ -938,7 +1398,7 @@ pub struct PersistedWriteClaim { pub isolated_worktree: bool, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ReconciliationReceipt { pub reconciliation_id: String, pub subject: String, @@ -946,12 +1406,119 @@ pub struct ReconciliationReceipt { pub input_decisions: Vec, pub outcome: String, pub evidence_handles: Vec, + /// Preserved candidate branches, patches, or artifact handles. A fan-in + /// receipt is not valid if either conflicting candidate was discarded. + #[serde(default)] + pub candidate_handles: Vec, + #[serde(default)] + pub retry_count: u32, + #[serde(default)] + pub retry_limit: u32, + #[serde(default)] + pub reviewer_evidence_handles: Vec, + #[serde(default)] + pub verifier_evidence_handles: Vec, + #[serde(default)] + pub verification_outcome: String, + pub sequence: u64, +} + +/// Durable receipt for the minimal accepted-decision context projected into a +/// child. It records counts and stable ids, never the child's transcript. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContextProjectionReceipt { + pub child_id: String, + pub decision_ids: Vec, + pub projected_bytes: usize, + /// Repeated constraint facts elided across otherwise distinct decisions. + /// Decision records themselves are never collapsed by this count. + pub deduplicated: usize, + /// Relevant unique decisions omitted solely because the hard count or + /// byte bound was reached. This must not be conflated with deduplication. + #[serde(default)] + pub omitted: usize, pub sequence: u64, } +/// Admission outcome persisted with a write-contention receipt. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WriteContentionDisposition { + BlockedPendingIsolationOrSerialization, + ResolvedBySuccessfulClaim, +} + +impl WriteContentionDisposition { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::BlockedPendingIsolationOrSerialization => { + "blocked_pending_isolation_or_serialization" + } + Self::ResolvedBySuccessfulClaim => "resolved_by_successful_claim", + } + } + + #[must_use] + pub const fn blocks_admission(self) -> bool { + matches!(self, Self::BlockedPendingIsolationOrSerialization) + } +} + +/// Durable non-secret receipt emitted when two active shared-workspace claims +/// collide. Rejected scope expansion remains visible after restart. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WriteContentionReceipt { + pub claimant: String, + pub conflicting_owner: String, + pub roots: Vec, + pub exact_files: Vec, + pub contracts: Vec, + pub disposition: WriteContentionDisposition, + /// Sequence of the later successful claim that resolved this receipt. + /// It intentionally references that claim's sequence instead of consuming + /// another ledger sequence. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolution_sequence: Option, + pub sequence: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CoordinationHotPath { + pub path: String, + pub active_claims: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CoordinationDetailMetrics { + pub hottest_paths: Vec, + pub package_or_module_growth: Option, + pub route_or_cost: Option, + pub note: String, +} + +/// One bounded typed projection shared by headless inspection and the TUI. +/// It contains durable coordination facts only, never raw reasoning or a +/// delegated transcript. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CoordinationDetailProjection { + pub schema_version: u32, + pub sequence: u64, + pub decisions: Vec, + pub write_claims: Vec, + pub reconciliations: Vec, + pub context_projections: Vec, + pub contentions: Vec, + pub metrics: CoordinationDetailMetrics, + pub bounded: bool, + pub limit: usize, +} + /// Durable, bounded coordination state owned by `SubAgentManager`. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct CoordinationLedger { + #[serde(default = "coordination_schema_version")] + pub schema_version: u32, #[serde(default)] pub sequence: u64, #[serde(default)] @@ -960,6 +1527,24 @@ pub struct CoordinationLedger { pub write_claims: Vec, #[serde(default)] pub reconciliations: Vec, + #[serde(default)] + pub projections: Vec, + #[serde(default)] + pub contentions: Vec, +} + +impl Default for CoordinationLedger { + fn default() -> Self { + Self { + schema_version: COORDINATION_SCHEMA_VERSION, + sequence: 0, + decisions: Vec::new(), + write_claims: Vec::new(), + reconciliations: Vec::new(), + projections: Vec::new(), + contentions: Vec::new(), + } + } } impl CoordinationLedger { @@ -972,23 +1557,83 @@ impl CoordinationLedger { &mut self, mut decision: DecisionRecord, ) -> Result { + self.validate_schema()?; + decision.decision_id = decision.decision_id.trim().to_string(); + if !decision.decision_id.is_empty() { + decision.decision_id = bounded_coordination_atom("decision id", &decision.decision_id)?; + } + decision.subject = bounded_coordination_atom("decision subject", &decision.subject)?; + decision.owner = bounded_coordination_atom("decision owner", &decision.owner)?; + decision.scope = normalize_coordination_values("decision scope", &decision.scope, 24)?; + decision.constraints = + normalize_coordination_values("decision constraints", &decision.constraints, 24)?; + decision.evidence_handles = normalize_coordination_values( + "decision evidence handles", + &decision.evidence_handles, + 24, + )?; + reject_sensitive_coordination_values(&decision.constraints)?; + reject_sensitive_coordination_values(&decision.evidence_handles)?; if decision.subject.trim().is_empty() || decision.owner.trim().is_empty() { return Err("decision subject and owner are required".to_string()); } + if !decision.decision_id.trim().is_empty() + && self + .decisions + .iter() + .any(|existing| existing.decision_id == decision.decision_id) + { + return Err(format!( + "decision id '{}' already exists", + decision.decision_id + )); + } + if decision.status == DecisionStatus::Accepted + && let Some(existing) = self.decisions.iter().find(|existing| { + existing.subject == decision.subject + && existing.status == DecisionStatus::Accepted + && existing.decision_id != decision.decision_id + }) + { + return Err(format!( + "subject '{}' already has accepted decision '{}' owned by '{}'; preserve both candidates and use neutral reconciliation", + decision.subject, existing.decision_id, existing.owner + )); + } + let next_version = self + .decisions + .iter() + .filter(|existing| existing.subject == decision.subject) + .map(|existing| existing.version) + .max() + .unwrap_or(0) + .saturating_add(1); + decision.version = decision.version.max(next_version); decision.sequence = self.next_sequence(); - decision.version = decision.version.max(1); if decision.decision_id.trim().is_empty() { decision.decision_id = format!("decision_{}", decision.sequence); } - if decision.status == DecisionStatus::Accepted { - for existing in self.decisions.iter_mut().filter(|existing| { - existing.subject == decision.subject && existing.status == DecisionStatus::Accepted + self.decisions.push(decision.clone()); + if self.decisions.len() > COORDINATION_RECORD_LIMIT { + let referenced = self + .reconciliations + .iter() + .flat_map(|receipt| receipt.input_decisions.iter()) + .cloned() + .collect::>(); + if let Some(index) = self.decisions.iter().position(|existing| { + existing.status != DecisionStatus::Accepted + && !referenced.contains(&existing.decision_id) }) { - existing.status = DecisionStatus::Superseded; + self.decisions.remove(index); + } else { + self.decisions.pop(); + return Err( + "coordination decision capacity is occupied by accepted or reconciled records" + .to_string(), + ); } } - self.decisions.push(decision.clone()); - trim_front(&mut self.decisions, COORDINATION_RECORD_LIMIT); Ok(decision) } @@ -996,8 +1641,10 @@ impl CoordinationLedger { &mut self, decision_id: &str, status: DecisionStatus, - _owner: &str, + owner: &str, + expected_version: u32, ) -> Result { + self.validate_schema()?; let Some(index) = self .decisions .iter() @@ -1005,15 +1652,35 @@ impl CoordinationLedger { else { return Err(format!("decision '{decision_id}' not found")); }; + if self.decisions[index].owner != owner { + return Err(format!( + "decision '{decision_id}' is owned by '{}'; caller '{owner}' cannot change it", + self.decisions[index].owner + )); + } + if self.decisions[index].version != expected_version { + return Err(format!( + "decision '{decision_id}' version changed: expected {expected_version}, current {}", + self.decisions[index].version + )); + } let subject = self.decisions[index].subject.clone(); if status == DecisionStatus::Accepted { - for (other_index, existing) in self.decisions.iter_mut().enumerate() { - if other_index != index - && existing.subject == subject - && existing.status == DecisionStatus::Accepted - { - existing.status = DecisionStatus::Superseded; - } + if let Some(existing) = + self.decisions + .iter() + .enumerate() + .find_map(|(other_index, existing)| { + (other_index != index + && existing.subject == subject + && existing.status == DecisionStatus::Accepted) + .then_some(existing) + }) + { + return Err(format!( + "subject '{subject}' already has accepted decision '{}' owned by '{}'; preserve both candidates and use neutral reconciliation", + existing.decision_id, existing.owner + )); } } let sequence = self.next_sequence(); @@ -1026,30 +1693,73 @@ impl CoordinationLedger { pub fn register_claim( &mut self, - claim: WriteScopeClaim, + mut claim: WriteScopeClaim, isolated_worktree: bool, mut owner_is_active: F, ) -> Result where F: FnMut(&str) -> bool, { - if claim.owner.trim().is_empty() - || (claim.roots.is_empty() - && claim.exact_files.is_empty() - && claim.contracts.is_empty()) - { + self.validate_schema()?; + claim.owner = bounded_coordination_atom("write claim owner", &claim.owner)?; + claim.roots = normalize_claim_paths(&claim.roots)?; + claim.exact_files = normalize_claim_paths(&claim.exact_files)?; + claim.contracts = normalize_claim_strings(&claim.contracts, 16, 128, "contracts")?; + if claim.roots.is_empty() && claim.exact_files.is_empty() && claim.contracts.is_empty() { return Err( "write claim requires an owner and at least one root, file, or contract" .to_string(), ); } + let replacing_existing_owner = self + .write_claims + .iter() + .any(|existing| existing.claim.owner == claim.owner); + if !replacing_existing_owner && self.write_claims.len() >= COORDINATION_RECORD_LIMIT { + let mut inactive = Vec::new(); + for existing in &self.write_claims { + if !owner_is_active(&existing.claim.owner) { + inactive.push((existing.sequence, existing.claim.owner.clone())); + } + } + inactive.sort_by_key(|(sequence, _)| *sequence); + for (_, owner) in inactive { + if self.write_claims.len() < COORDINATION_RECORD_LIMIT { + break; + } + self.write_claims + .retain(|existing| existing.claim.owner != owner); + } + if self.write_claims.len() >= COORDINATION_RECORD_LIMIT { + return Err(format!( + "write-claim capacity is {COORDINATION_RECORD_LIMIT} active owners; complete, serialize, or isolate existing work before admitting another writer" + )); + } + } if !isolated_worktree { - if let Some(existing) = self.write_claims.iter().find(|existing| { - !existing.isolated_worktree - && existing.claim.owner != claim.owner - && owner_is_active(&existing.claim.owner) - && existing.claim.overlaps(&claim) - }) { + if let Some(existing) = self + .write_claims + .iter() + .find(|existing| { + !existing.isolated_worktree + && existing.claim.owner != claim.owner + && owner_is_active(&existing.claim.owner) + && existing.claim.overlaps(&claim) + }) + .cloned() + { + let receipt = WriteContentionReceipt { + claimant: claim.owner.clone(), + conflicting_owner: existing.claim.owner.clone(), + roots: claim.roots.clone(), + exact_files: claim.exact_files.clone(), + contracts: claim.contracts.clone(), + disposition: WriteContentionDisposition::BlockedPendingIsolationOrSerialization, + resolution_sequence: None, + sequence: self.next_sequence(), + }; + self.contentions.push(receipt); + trim_front(&mut self.contentions, COORDINATION_RECORD_LIMIT); return Err(format!( "write-scope contention with {} (roots: {:?}, files: {:?}, contracts: {:?}); serialize the work, narrow the claim, or use worktree isolation", existing.claim.owner, @@ -1066,8 +1776,15 @@ impl CoordinationLedger { sequence: self.next_sequence(), isolated_worktree, }; + for contention in &mut self.contentions { + if contention.claimant == record.claim.owner + && contention.disposition.blocks_admission() + { + contention.disposition = WriteContentionDisposition::ResolvedBySuccessfulClaim; + contention.resolution_sequence = Some(record.sequence); + } + } self.write_claims.push(record.clone()); - trim_front(&mut self.write_claims, COORDINATION_RECORD_LIMIT); Ok(record) } @@ -1078,13 +1795,105 @@ impl CoordinationLedger { input_decisions: Vec, outcome: String, evidence_handles: Vec, + candidate_handles: Vec, + retry_count: u32, + retry_limit: u32, + reviewer_evidence_handles: Vec, + verifier_evidence_handles: Vec, + verification_outcome: String, ) -> Result { - if owner.trim().is_empty() || subject.trim().is_empty() || outcome.trim().is_empty() { - return Err("reconciliation owner, subject, and outcome are required".to_string()); - } + self.validate_schema()?; + let subject = bounded_coordination_atom("reconciliation subject", &subject)?; + let owner = bounded_coordination_atom("reconciliation owner", &owner)?; + let outcome = bounded_coordination_atom("reconciliation outcome", &outcome)?; + let verification_outcome = bounded_coordination_atom( + "reconciliation verification outcome", + &verification_outcome, + )?; if input_decisions.len() < 2 { return Err("neutral fan-in requires at least two input decisions".to_string()); } + if input_decisions.iter().collect::>().len() != input_decisions.len() { + return Err("neutral fan-in decision ids must be distinct".to_string()); + } + if candidate_handles.len() < 2 + || candidate_handles + .iter() + .any(|handle| handle.trim().is_empty()) + { + return Err( + "neutral fan-in must preserve at least two candidate branch, patch, or artifact handles" + .to_string(), + ); + } + if candidate_handles.iter().collect::>().len() != candidate_handles.len() { + return Err("neutral fan-in candidate handles must be distinct".to_string()); + } + let input_decisions = + normalize_coordination_values("input decision ids", &input_decisions, 24)?; + let evidence_handles = normalize_coordination_values( + "reconciliation evidence handles", + &evidence_handles, + 24, + )?; + let candidate_handles = + normalize_coordination_values("candidate handles", &candidate_handles, 24)?; + if input_decisions.len() < 2 { + return Err( + "neutral fan-in requires at least two distinct normalized input decisions" + .to_string(), + ); + } + if candidate_handles.len() < 2 { + return Err( + "neutral fan-in must preserve at least two distinct normalized candidate handles" + .to_string(), + ); + } + let reviewer_evidence_handles = normalize_coordination_values( + "Reviewer evidence handles", + &reviewer_evidence_handles, + 24, + )?; + let verifier_evidence_handles = normalize_coordination_values( + "Verifier evidence handles", + &verifier_evidence_handles, + 24, + )?; + reject_sensitive_coordination_values(&evidence_handles)?; + reject_sensitive_coordination_values(&candidate_handles)?; + reject_sensitive_coordination_values(&reviewer_evidence_handles)?; + reject_sensitive_coordination_values(&verifier_evidence_handles)?; + if retry_limit == 0 || retry_limit > MAX_RECONCILIATION_RETRIES { + return Err(format!( + "reconciliation retry_limit must be between 1 and {MAX_RECONCILIATION_RETRIES}" + )); + } + if retry_count > retry_limit { + return Err("reconciliation retry_count exceeds retry_limit".to_string()); + } + if reviewer_evidence_handles.is_empty() || verifier_evidence_handles.is_empty() { + return Err( + "neutral fan-in requires independent Reviewer and Verifier evidence handles" + .to_string(), + ); + } + if reviewer_evidence_handles.iter().any(|review| { + verifier_evidence_handles + .iter() + .any(|verify| verify == review) + }) { + return Err("Reviewer and Verifier evidence handles must be independent".to_string()); + } + if !matches!( + verification_outcome.as_str(), + "verified" | "failed" | "blocked" + ) { + return Err( + "neutral fan-in verification_outcome must be verified, failed, or blocked" + .to_string(), + ); + } if input_decisions.iter().any(|id| { !self .decisions @@ -1117,12 +1926,551 @@ impl CoordinationLedger { input_decisions, outcome, evidence_handles, + candidate_handles, + retry_count, + retry_limit, + reviewer_evidence_handles, + verifier_evidence_handles, + verification_outcome, sequence, }; self.reconciliations.push(receipt.clone()); trim_front(&mut self.reconciliations, COORDINATION_RECORD_LIMIT); Ok(receipt) } + + pub fn project_relevant_decisions( + &mut self, + child_id: &str, + claim: Option<&WriteScopeClaim>, + capabilities: &[String], + ) -> (String, ContextProjectionReceipt) { + const HEADER: &str = "Accepted coordination decisions relevant to this child (bounded):\n"; + let mut seen_constraint_facts = BTreeSet::new(); + let mut decision_ids = Vec::new(); + let mut lines = Vec::new(); + let mut projected_bytes = 0usize; + let mut deduplicated = 0usize; + let mut omitted = 0usize; + for decision in self + .decisions + .iter() + .rev() + .filter(|decision| decision.status == DecisionStatus::Accepted) + .filter(|decision| decision_is_relevant(decision, claim, capabilities)) + { + if decision_ids.len() >= COORDINATION_PROJECTION_DECISION_LIMIT { + omitted = omitted.saturating_add(1); + continue; + } + let constraints = decision + .constraints + .iter() + .filter_map(|value| { + let value = bounded_utf8(value, 192); + if seen_constraint_facts.insert(value.clone()) { + Some(value) + } else { + deduplicated = deduplicated.saturating_add(1); + None + } + }) + .take(8) + .collect::>() + .join("; "); + let mut line = format!( + "- {} v{} [{}] owner={}", + decision.subject, decision.version, decision.decision_id, decision.owner, + ); + if !constraints.is_empty() { + line.push_str(": "); + line.push_str(&constraints); + } + let line = bounded_utf8(&line, 512); + let added_bytes = line.len().saturating_add(1); + if HEADER + .len() + .saturating_add(projected_bytes) + .saturating_add(added_bytes) + > COORDINATION_PROJECTION_BYTE_LIMIT + { + omitted = omitted.saturating_add(1); + continue; + } + projected_bytes = projected_bytes.saturating_add(added_bytes); + decision_ids.push(decision.decision_id.clone()); + lines.push(line); + } + let projection = if lines.is_empty() { + String::new() + } else { + format!("{HEADER}{}", lines.join("\n")) + }; + let receipt = ContextProjectionReceipt { + child_id: child_id.to_string(), + decision_ids, + projected_bytes: projection.len(), + deduplicated, + omitted, + sequence: self.next_sequence(), + }; + self.projections.push(receipt.clone()); + trim_front(&mut self.projections, COORDINATION_RECORD_LIMIT); + (projection, receipt) + } + + pub(super) fn validate_replay(&mut self) -> Result<(), String> { + self.validate_schema()?; + if self.decisions.len() > COORDINATION_RECORD_LIMIT + || self.write_claims.len() > COORDINATION_RECORD_LIMIT + || self.reconciliations.len() > COORDINATION_RECORD_LIMIT + || self.projections.len() > COORDINATION_RECORD_LIMIT + || self.contentions.len() > COORDINATION_RECORD_LIMIT + { + return Err("coordination record count exceeds the durable bound".to_string()); + } + + let mut sequences = BTreeSet::new(); + let mut max_sequence = 0_u64; + let mut decision_ids = BTreeSet::new(); + let mut accepted_subjects = BTreeSet::new(); + for decision in &self.decisions { + bounded_coordination_atom("decision id", &decision.decision_id)?; + bounded_coordination_atom("decision subject", &decision.subject)?; + bounded_coordination_atom("decision owner", &decision.owner)?; + if decision.version == 0 { + return Err(format!( + "decision '{}' has zero version", + decision.decision_id + )); + } + validate_sequence( + decision.sequence, + "decision", + &mut sequences, + &mut max_sequence, + )?; + if !decision_ids.insert(decision.decision_id.clone()) { + return Err(format!("duplicate decision id '{}'", decision.decision_id)); + } + if decision.status == DecisionStatus::Accepted + && !accepted_subjects.insert(decision.subject.clone()) + { + return Err(format!( + "multiple accepted decisions own subject '{}'", + decision.subject + )); + } + validate_normalized_coordination_values("decision scope", &decision.scope, 24)?; + validate_normalized_coordination_values( + "decision constraints", + &decision.constraints, + 24, + )?; + validate_normalized_coordination_values( + "decision evidence handles", + &decision.evidence_handles, + 24, + )?; + reject_sensitive_coordination_values(&decision.constraints)?; + reject_sensitive_coordination_values(&decision.evidence_handles)?; + } + + let mut claim_owners = BTreeSet::new(); + for claim in &self.write_claims { + validate_sequence( + claim.sequence, + "write claim", + &mut sequences, + &mut max_sequence, + )?; + bounded_coordination_atom("write claim owner", &claim.claim.owner)?; + if !claim_owners.insert(claim.claim.owner.clone()) { + return Err(format!( + "duplicate write claim owner '{}'", + claim.claim.owner + )); + } + let roots = normalize_claim_paths(&claim.claim.roots)?; + let exact_files = normalize_claim_paths(&claim.claim.exact_files)?; + let contracts = normalize_claim_strings(&claim.claim.contracts, 16, 128, "contracts")?; + if roots != claim.claim.roots + || exact_files != claim.claim.exact_files + || contracts != claim.claim.contracts + || (roots.is_empty() && exact_files.is_empty() && contracts.is_empty()) + { + return Err(format!( + "write claim for '{}' is not normalized and bounded", + claim.claim.owner + )); + } + } + + for receipt in &self.reconciliations { + validate_sequence( + receipt.sequence, + "reconciliation", + &mut sequences, + &mut max_sequence, + )?; + validate_reconciliation_receipt(receipt, &self.decisions)?; + } + for projection in &self.projections { + validate_sequence( + projection.sequence, + "context projection", + &mut sequences, + &mut max_sequence, + )?; + bounded_coordination_atom("projection child", &projection.child_id)?; + if projection.decision_ids.len() > COORDINATION_PROJECTION_DECISION_LIMIT + || projection.projected_bytes > COORDINATION_PROJECTION_BYTE_LIMIT + || projection + .decision_ids + .iter() + .collect::>() + .len() + != projection.decision_ids.len() + { + return Err(format!( + "context projection for '{}' exceeds its bounds or duplicates decisions", + projection.child_id + )); + } + } + for contention in &self.contentions { + validate_sequence( + contention.sequence, + "contention", + &mut sequences, + &mut max_sequence, + )?; + bounded_coordination_atom("contention claimant", &contention.claimant)?; + bounded_coordination_atom( + "contention conflicting owner", + &contention.conflicting_owner, + )?; + match (contention.disposition, contention.resolution_sequence) { + (WriteContentionDisposition::BlockedPendingIsolationOrSerialization, None) => {} + (WriteContentionDisposition::ResolvedBySuccessfulClaim, Some(sequence)) + if sequence > contention.sequence && sequence <= self.sequence => {} + (WriteContentionDisposition::BlockedPendingIsolationOrSerialization, Some(_)) => { + return Err( + "blocked contention receipt cannot carry a resolution sequence".to_string(), + ); + } + (WriteContentionDisposition::ResolvedBySuccessfulClaim, _) => { + return Err( + "resolved contention receipt requires a later valid resolution sequence" + .to_string(), + ); + } + } + if normalize_claim_paths(&contention.roots)? != contention.roots + || normalize_claim_paths(&contention.exact_files)? != contention.exact_files + || normalize_claim_strings(&contention.contracts, 16, 128, "contracts")? + != contention.contracts + { + return Err("contention receipt paths/contracts are not normalized".to_string()); + } + } + if self.sequence < max_sequence { + return Err(format!( + "coordination sequence {} is behind record sequence {max_sequence}", + self.sequence + )); + } + Ok(()) + } + + fn validate_schema(&self) -> Result<(), String> { + if self.schema_version != COORDINATION_SCHEMA_VERSION { + return Err(format!( + "unsupported coordination schema {}; expected {}", + self.schema_version, COORDINATION_SCHEMA_VERSION + )); + } + Ok(()) + } +} + +fn normalize_claim_paths(paths: &[String]) -> Result, String> { + if paths.len() > 32 { + return Err("write claim paths accept at most 32 entries".to_string()); + } + let mut normalized = Vec::new(); + for path in paths { + let path = super::normalize_claim_path(path)?; + if !normalized.contains(&path) { + normalized.push(path); + } + } + Ok(normalized) +} + +fn normalize_claim_strings( + values: &[String], + count_limit: usize, + char_limit: usize, + field: &str, +) -> Result, String> { + if values.len() > count_limit { + return Err(format!( + "write claim {field} accepts at most {count_limit} entries" + )); + } + let mut normalized = Vec::new(); + for value in values { + let value = value.trim(); + if value.is_empty() + || value.chars().count() > char_limit + || value.chars().any(char::is_control) + { + return Err(format!( + "write claim {field} entries must be 1..={char_limit} characters" + )); + } + if !normalized.iter().any(|existing| existing == value) { + normalized.push(value.to_string()); + } + } + Ok(normalized) +} + +fn bounded_coordination_atom(field: &str, value: &str) -> Result { + let value = value.trim(); + if value.is_empty() + || value.chars().count() > 512 + || value.chars().any(|ch| matches!(ch, '\r' | '\n')) + { + return Err(format!( + "{field} must be one non-empty line of at most 512 characters" + )); + } + Ok(value.to_string()) +} + +fn normalize_coordination_values( + field: &str, + values: &[String], + limit: usize, +) -> Result, String> { + if values.len() > limit { + return Err(format!("{field} accepts at most {limit} entries")); + } + let mut normalized = Vec::new(); + for value in values { + let value = bounded_coordination_atom(field, value)?; + if !normalized.contains(&value) { + normalized.push(value); + } + } + Ok(normalized) +} + +fn validate_normalized_coordination_values( + field: &str, + values: &[String], + limit: usize, +) -> Result<(), String> { + if normalize_coordination_values(field, values, limit)? != values { + return Err(format!("{field} is not trimmed and deduplicated")); + } + Ok(()) +} + +fn reject_sensitive_coordination_values(values: &[String]) -> Result<(), String> { + const SENSITIVE_MARKERS: &[&str] = &[ + "secret", + "password", + "api_key", + "api-key", + "authorization:", + "bearer ", + "token=", + "sk-", + "ghp_", + "xoxb-", + ", + max_sequence: &mut u64, +) -> Result<(), String> { + if sequence == 0 || !sequences.insert(sequence) { + return Err(format!( + "{kind} has a zero or duplicate sequence {sequence}" + )); + } + *max_sequence = (*max_sequence).max(sequence); + Ok(()) +} + +fn validate_reconciliation_receipt( + receipt: &ReconciliationReceipt, + decisions: &[DecisionRecord], +) -> Result<(), String> { + bounded_coordination_atom("reconciliation id", &receipt.reconciliation_id)?; + bounded_coordination_atom("reconciliation subject", &receipt.subject)?; + bounded_coordination_atom("reconciliation owner", &receipt.owner)?; + bounded_coordination_atom("reconciliation outcome", &receipt.outcome)?; + if receipt.input_decisions.len() < 2 + || receipt + .input_decisions + .iter() + .collect::>() + .len() + != receipt.input_decisions.len() + { + return Err("reconciliation requires at least two distinct decision ids".to_string()); + } + let inputs = receipt + .input_decisions + .iter() + .map(|id| { + decisions + .iter() + .find(|decision| &decision.decision_id == id) + .ok_or_else(|| format!("reconciliation references unknown decision '{id}'")) + }) + .collect::, _>>()?; + if inputs + .iter() + .any(|decision| decision.subject != receipt.subject) + { + return Err("reconciliation inputs must share the requested subject".to_string()); + } + if inputs + .iter() + .any(|decision| decision.owner == receipt.owner) + { + return Err("neutral fan-in owner must differ from every candidate owner".to_string()); + } + if receipt.candidate_handles.len() < 2 + || receipt + .candidate_handles + .iter() + .collect::>() + .len() + != receipt.candidate_handles.len() + { + return Err("reconciliation requires at least two distinct candidate handles".to_string()); + } + validate_normalized_coordination_values("candidate handles", &receipt.candidate_handles, 24)?; + validate_normalized_coordination_values( + "reconciliation evidence handles", + &receipt.evidence_handles, + 24, + )?; + validate_normalized_coordination_values( + "Reviewer evidence handles", + &receipt.reviewer_evidence_handles, + 24, + )?; + validate_normalized_coordination_values( + "Verifier evidence handles", + &receipt.verifier_evidence_handles, + 24, + )?; + reject_sensitive_coordination_values(&receipt.candidate_handles)?; + reject_sensitive_coordination_values(&receipt.evidence_handles)?; + reject_sensitive_coordination_values(&receipt.reviewer_evidence_handles)?; + reject_sensitive_coordination_values(&receipt.verifier_evidence_handles)?; + if receipt.retry_limit == 0 + || receipt.retry_limit > MAX_RECONCILIATION_RETRIES + || receipt.retry_count > receipt.retry_limit + { + return Err("reconciliation retry count/limit is invalid".to_string()); + } + if receipt.reviewer_evidence_handles.is_empty() + || receipt.verifier_evidence_handles.is_empty() + || receipt.reviewer_evidence_handles.iter().any(|review| { + receipt + .verifier_evidence_handles + .iter() + .any(|verify| verify == review) + }) + { + return Err("Reviewer and Verifier evidence must be present and independent".to_string()); + } + if !matches!( + receipt.verification_outcome.as_str(), + "verified" | "failed" | "blocked" + ) { + return Err("reconciliation verification outcome is invalid".to_string()); + } + Ok(()) +} + +fn decision_is_relevant( + decision: &DecisionRecord, + claim: Option<&WriteScopeClaim>, + capabilities: &[String], +) -> bool { + if decision.scope.is_empty() { + return true; + } + decision.scope.iter().any(|raw| { + let value = raw.trim(); + let (kind, value) = value + .split_once(':') + .map_or(("", value), |(kind, value)| (kind.trim(), value.trim())); + match kind { + "capability" => capabilities.iter().any(|capability| capability == value), + "contract" => { + claim.is_some_and(|claim| claim.contracts.iter().any(|contract| contract == value)) + } + "path" => claim.is_some_and(|claim| claim_reaches_path(claim, value)), + _ => { + capabilities.iter().any(|capability| capability == value) + || claim.is_some_and(|claim| { + claim.contracts.iter().any(|contract| contract == value) + || claim_reaches_path(claim, value) + }) + } + } + }) +} + +fn claim_reaches_path(claim: &WriteScopeClaim, path: &str) -> bool { + let Ok(path) = super::normalize_claim_path(path) else { + return false; + }; + claim.contains_path(&path) + || claim.roots.iter().any(|root| path_contains(&path, root)) + || claim + .exact_files + .iter() + .any(|file| path_contains(&path, file)) +} + +fn bounded_utf8(value: &str, byte_limit: usize) -> String { + if value.len() <= byte_limit { + return value.to_string(); + } + let mut end = byte_limit; + while !value.is_char_boundary(end) { + end = end.saturating_sub(1); + } + value[..end].to_string() } fn trim_front(records: &mut Vec, limit: usize) { @@ -1160,7 +2508,7 @@ impl ToolSpec for AgentsCoordinateTool { "action": { "type": "string", "enum": ["inspect", "propose", "accept", "supersede", "claim", "reconcile"] }, "decision_id": { "type": "string" }, "subject": { "type": "string" }, - "owner": { "type": "string" }, + "expected_version": { "type": "integer", "minimum": 1 }, "scope": { "type": "array", "items": { "type": "string" } }, "constraints": { "type": "array", "items": { "type": "string" } }, "evidence_handles": { "type": "array", "items": { "type": "string" } }, @@ -1169,6 +2517,12 @@ impl ToolSpec for AgentsCoordinateTool { "contracts": { "type": "array", "items": { "type": "string" } }, "input_decisions": { "type": "array", "items": { "type": "string" } }, "outcome": { "type": "string" }, + "candidate_handles": { "type": "array", "items": { "type": "string" } }, + "retry_count": { "type": "integer", "minimum": 0, "maximum": 3 }, + "retry_limit": { "type": "integer", "minimum": 1, "maximum": 3 }, + "reviewer_evidence_handles": { "type": "array", "items": { "type": "string" } }, + "verifier_evidence_handles": { "type": "array", "items": { "type": "string" } }, + "verification_outcome": { "type": "string" }, "limit": { "type": "integer", "minimum": 1, "maximum": 24 } }, "required": ["action"] @@ -1196,11 +2550,9 @@ impl ToolSpec for AgentsCoordinateTool { .and_then(Value::as_str) .map(|value| value.chars().take(512).collect::()) }; - let owner = self - .caller - .clone() - .or_else(|| bounded_text("owner")) - .unwrap_or_else(|| "root".to_string()); + // Tool authority is the runtime caller identity. Root cannot supply an + // arbitrary child owner and mutate that child's decisions/claim. + let owner = self.caller.clone().unwrap_or_else(|| "root".to_string()); let strings = |key: &str| { input .get(key) @@ -1215,75 +2567,122 @@ impl ToolSpec for AgentsCoordinateTool { }) .unwrap_or_default() }; - let mut manager = self.manager.write().await; - let value = match action { - "inspect" => manager.inspect_coordination( + if action == "inspect" { + let manager = self.manager.read().await; + let value = manager.inspect_coordination( bounded_text("subject").as_deref(), input .get("limit") .and_then(Value::as_u64) .unwrap_or(COORDINATION_INSPECT_LIMIT as u64) as usize, - ), - "propose" => serde_json::to_value( - manager - .record_coordination_decision(DecisionRecord { - decision_id: bounded_text("decision_id").unwrap_or_default(), - subject: bounded_text("subject").unwrap_or_default(), - status: DecisionStatus::Proposed, - owner, - scope: strings("scope"), - constraints: strings("constraints"), - evidence_handles: strings("evidence_handles"), - version: 1, - sequence: 0, - }) - .map_err(ToolError::invalid_input)?, - ) - .map_err(|e| ToolError::execution_failed(e.to_string()))?, - "accept" | "supersede" => serde_json::to_value( - manager - .update_coordination_decision( - &bounded_text("decision_id").unwrap_or_default(), - if action == "accept" { - DecisionStatus::Accepted - } else { - DecisionStatus::Superseded - }, - &owner, - ) - .map_err(ToolError::invalid_input)?, - ) - .map_err(|e| ToolError::execution_failed(e.to_string()))?, - "claim" => serde_json::to_value( - manager - .expand_write_claim( - &owner, - strings("roots"), - strings("exact_files"), - strings("contracts"), - ) - .map_err(ToolError::invalid_input)?, - ) - .map_err(|e| ToolError::execution_failed(e.to_string()))?, - "reconcile" => serde_json::to_value( - manager - .reconcile_coordination( - bounded_text("subject").unwrap_or_default(), - owner, - strings("input_decisions"), - bounded_text("outcome").unwrap_or_default(), - strings("evidence_handles"), + ); + return ToolResult::json(&value) + .map_err(|e| ToolError::execution_failed(e.to_string())); + } + if !matches!( + action, + "propose" | "accept" | "supersede" | "claim" | "reconcile" + ) { + return Err(ToolError::invalid_input(format!( + "unknown coordination action '{action}'" + ))); + } + + let mut manager = self.manager.write().await; + let coordination_before = manager.coordination.clone(); + let mutation = match action { + "propose" => manager + .record_coordination_decision(DecisionRecord { + decision_id: bounded_text("decision_id").unwrap_or_default(), + subject: bounded_text("subject").unwrap_or_default(), + status: DecisionStatus::Proposed, + owner, + scope: strings("scope"), + constraints: strings("constraints"), + evidence_handles: strings("evidence_handles"), + version: 1, + sequence: 0, + }) + .map_err(ToolError::invalid_input) + .and_then(|record| { + serde_json::to_value(record) + .map_err(|e| ToolError::execution_failed(e.to_string())) + }), + "accept" | "supersede" => input + .get("expected_version") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| { + ToolError::invalid_input( + "accept/supersede requires expected_version".to_string(), ) - .map_err(ToolError::invalid_input)?, - ) - .map_err(|e| ToolError::execution_failed(e.to_string()))?, - other => { - return Err(ToolError::invalid_input(format!( - "unknown coordination action '{other}'" - ))); - } + }) + .and_then(|expected_version| { + manager + .update_coordination_decision( + &bounded_text("decision_id").unwrap_or_default(), + if action == "accept" { + DecisionStatus::Accepted + } else { + DecisionStatus::Superseded + }, + &owner, + expected_version, + ) + .map_err(ToolError::invalid_input) + }) + .and_then(|record| { + serde_json::to_value(record) + .map_err(|e| ToolError::execution_failed(e.to_string())) + }), + "claim" => manager + .expand_write_claim( + &owner, + strings("roots"), + strings("exact_files"), + strings("contracts"), + ) + .map_err(ToolError::invalid_input) + .and_then(|claim| { + serde_json::to_value(claim) + .map_err(|e| ToolError::execution_failed(e.to_string())) + }), + "reconcile" => manager + .reconcile_coordination( + bounded_text("subject").unwrap_or_default(), + owner, + strings("input_decisions"), + bounded_text("outcome").unwrap_or_default(), + strings("evidence_handles"), + strings("candidate_handles"), + input + .get("retry_count") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or_default(), + input + .get("retry_limit") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(MAX_RECONCILIATION_RETRIES), + strings("reviewer_evidence_handles"), + strings("verifier_evidence_handles"), + bounded_text("verification_outcome").unwrap_or_default(), + ) + .map_err(ToolError::invalid_input) + .and_then(|receipt| { + serde_json::to_value(receipt) + .map_err(|e| ToolError::execution_failed(e.to_string())) + }), + _ => unreachable!("coordination action validated above"), }; - manager.persist_state_best_effort(); + if let Err(error) = manager.persist_state_synchronously() { + manager.coordination = coordination_before; + return Err(ToolError::execution_failed(format!( + "failed to persist coordination action '{action}': {error}" + ))); + } + let value = mutation?; ToolResult::json(&value).map_err(|e| ToolError::execution_failed(e.to_string())) } } @@ -1367,6 +2766,27 @@ mod records_tests { assert!(root.overlaps(&child_file)); } + #[test] + fn legacy_blocked_contention_wire_defaults_to_unresolved() { + let receipt: WriteContentionReceipt = serde_json::from_value(json!({ + "claimant": "agent-b", + "conflicting_owner": "agent-a", + "roots": ["src"], + "exact_files": [], + "contracts": ["public-api"], + "disposition": "blocked_pending_isolation_or_serialization", + "sequence": 3 + })) + .expect("existing blocked wire receipt remains readable"); + + assert_eq!( + receipt.disposition, + WriteContentionDisposition::BlockedPendingIsolationOrSerialization + ); + assert_eq!(receipt.resolution_sequence, None); + assert!(receipt.disposition.blocks_admission()); + } + #[test] fn active_shared_claims_contend_but_isolated_claims_do_not() { let mut ledger = CoordinationLedger::default(); @@ -1390,15 +2810,71 @@ mod records_tests { err.contains("contention") && err.contains("agent-a"), "{err}" ); + assert_eq!(ledger.contentions.len(), 1); + assert_eq!(ledger.contentions[0].claimant, "agent-b"); + assert_eq!(ledger.contentions[0].conflicting_owner, "agent-a"); + assert_eq!( + ledger.contentions[0].disposition, + WriteContentionDisposition::BlockedPendingIsolationOrSerialization + ); + assert_eq!( + serde_json::to_value(&ledger.contentions[0]).unwrap()["disposition"], + json!("blocked_pending_isolation_or_serialization") + ); + let resolving_claim = ledger + .register_claim(second, true, |owner| owner == "agent-a") + .expect("isolated claim resolves the blocked admission"); + assert_eq!( + ledger.contentions[0].disposition, + WriteContentionDisposition::ResolvedBySuccessfulClaim + ); + assert_eq!( + ledger.contentions[0].resolution_sequence, + Some(resolving_claim.sequence) + ); + } + + #[test] + fn active_write_claims_are_never_evicted_by_receipt_retention() { + let mut ledger = CoordinationLedger::default(); + for index in 0..COORDINATION_RECORD_LIMIT { + ledger + .register_claim( + WriteScopeClaim { + owner: format!("agent-{index:03}"), + roots: vec![format!("pkg-{index:03}")], + exact_files: vec![], + contracts: vec![], + }, + false, + |_| true, + ) + .unwrap(); + } + let error = ledger + .register_claim( + WriteScopeClaim { + owner: "agent-over-cap".into(), + roots: vec!["new-package".into()], + exact_files: vec![], + contracts: vec![], + }, + false, + |_| true, + ) + .expect_err("all-active capacity must fail before evicting ownership"); + assert!(error.contains("active owners"), "{error}"); + assert_eq!(ledger.write_claims.len(), COORDINATION_RECORD_LIMIT); assert!( ledger - .register_claim(second, true, |owner| owner == "agent-a") - .is_ok() + .write_claims + .iter() + .any(|record| record.claim.owner == "agent-000") ); } #[test] - fn accepted_decision_supersedes_prior_acceptance_and_reconciles() { + fn accepted_decisions_require_owner_and_explicit_neutral_reconciliation() { let mut ledger = CoordinationLedger::default(); let make = |id: &str, owner: &str, status| DecisionRecord { decision_id: id.into(), @@ -1417,10 +2893,24 @@ mod records_tests { ledger .record_decision(make("b", "agent-b", DecisionStatus::Proposed)) .unwrap(); + let owner_error = ledger + .update_decision_status("b", DecisionStatus::Accepted, "root", 2) + .unwrap_err(); + assert!(owner_error.contains("owned by 'agent-b'"), "{owner_error}"); + let stale = ledger + .update_decision_status("b", DecisionStatus::Accepted, "agent-b", 1) + .unwrap_err(); + assert!(stale.contains("expected 1, current 2"), "{stale}"); + let conflict = ledger + .update_decision_status("b", DecisionStatus::Accepted, "agent-b", 2) + .unwrap_err(); + assert!(conflict.contains("neutral reconciliation"), "{conflict}"); + ledger + .update_decision_status("a", DecisionStatus::Superseded, "agent-a", 1) + .unwrap(); ledger - .update_decision_status("b", DecisionStatus::Accepted, "root") + .update_decision_status("b", DecisionStatus::Accepted, "agent-b", 2) .unwrap(); - assert_eq!(ledger.decisions[0].status, DecisionStatus::Superseded); let receipt = ledger .reconcile( "storage".into(), @@ -1428,9 +2918,112 @@ mod records_tests { vec!["a".into(), "b".into()], "use bounded origin-session artifacts".into(), vec!["test:coord".into()], + vec!["branch:agent-a".into(), "branch:agent-b".into()], + 1, + 3, + vec!["review:independent".into()], + vec!["verify:locked".into()], + "verified".into(), ) .unwrap(); assert_eq!(receipt.input_decisions.len(), 2); assert!(receipt.sequence > ledger.decisions[1].sequence); } + + #[test] + fn relevant_decision_projection_is_deduplicated_bounded_and_receipted() { + let mut ledger = CoordinationLedger::default(); + for (id, subject, scope) in [ + ("file", "file-contract", "path:src"), + ("docs", "docs-contract", "path:docs"), + ("api", "api-contract", "contract:public-api"), + ] { + ledger + .record_decision(DecisionRecord { + decision_id: id.into(), + subject: subject.into(), + status: DecisionStatus::Accepted, + owner: "planner".into(), + scope: vec![scope.into()], + constraints: vec!["bounded".into(), "bounded".into()], + evidence_handles: vec![format!("receipt:{id}")], + version: 1, + sequence: 0, + }) + .unwrap(); + } + let claim = WriteScopeClaim { + owner: "worker".into(), + roots: vec!["src/tui".into()], + exact_files: vec![], + contracts: vec!["public-api".into()], + }; + let (projection, receipt) = + ledger.project_relevant_decisions("worker", Some(&claim), &["File".into()]); + assert!(projection.contains("file-contract"), "{projection}"); + assert!(projection.contains("api-contract"), "{projection}"); + assert!(!projection.contains("docs-contract"), "{projection}"); + assert!(projection.len() <= COORDINATION_PROJECTION_BYTE_LIMIT); + assert_eq!(receipt.decision_ids, vec!["api", "file"]); + assert_eq!(receipt.deduplicated, 1); + assert_eq!(ledger.projections.last(), Some(&receipt)); + } + + #[test] + fn projection_receipt_distinguishes_unique_omissions_from_deduplication() { + let mut ledger = CoordinationLedger::default(); + for index in 0..(COORDINATION_PROJECTION_DECISION_LIMIT + 2) { + ledger + .record_decision(DecisionRecord { + decision_id: format!("decision-{index}"), + subject: format!("subject-{index}"), + status: DecisionStatus::Accepted, + owner: "planner".into(), + scope: vec!["path:src".into()], + constraints: vec![format!("constraint-{index}")], + evidence_handles: vec![format!("receipt:{index}")], + version: 1, + sequence: 0, + }) + .unwrap(); + } + let claim = WriteScopeClaim { + owner: "worker".into(), + roots: vec!["src".into()], + exact_files: vec![], + contracts: vec![], + }; + + let (projection, receipt) = ledger.project_relevant_decisions("worker", Some(&claim), &[]); + + assert!(projection.len() <= COORDINATION_PROJECTION_BYTE_LIMIT); + assert_eq!( + receipt.decision_ids.len(), + COORDINATION_PROJECTION_DECISION_LIMIT + ); + assert_eq!(receipt.deduplicated, 0); + assert_eq!(receipt.omitted, 2); + } + + #[test] + fn coordination_schema_drift_fails_closed_before_mutation() { + let mut ledger = CoordinationLedger { + schema_version: COORDINATION_SCHEMA_VERSION + 1, + ..CoordinationLedger::default() + }; + let error = ledger + .register_claim( + WriteScopeClaim { + owner: "worker".into(), + roots: vec!["src".into()], + exact_files: vec![], + contracts: vec![], + }, + false, + |_| false, + ) + .unwrap_err(); + assert!(error.contains("unsupported coordination schema"), "{error}"); + assert!(ledger.write_claims.is_empty()); + } } diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index b27e1a1e13..45a8b62a41 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -10,7 +10,7 @@ //! the retired lifecycle theater. Older manager helpers remain executable for //! persisted records and internal recovery. -use std::collections::{HashMap, VecDeque}; +use std::collections::{BTreeSet, HashMap, VecDeque}; use std::fs; use std::hash::{Hash, Hasher}; use std::io::{Read, Write}; @@ -25,17 +25,19 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tokio::{sync::mpsc, task::JoinHandle}; use tokio_util::sync::CancellationToken; +use unicode_normalization::UnicodeNormalization; use uuid::Uuid; use crate::client::DeepSeekClient; use crate::config::MAX_SUBAGENTS; -use crate::core::events::Event; +use crate::core::events::{AgentProgressEventMeta, Event}; use crate::dependencies::{ExternalTool, Git}; use crate::llm_client::{LlmClient, LlmError}; use crate::models::{ ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt, Tool, Usage, }; use crate::request_tuning::RequestTuning; +use crate::tools::canonical_action::{CANONICAL_ACTION_ALIASES, canonical_action_alias}; use crate::tools::handle::VarHandle; use crate::tools::plan::{PlanState, SharedPlanState}; use crate::tools::registry::{AgentToolSurfaceOptions, ToolRegistry, ToolRegistryBuilder}; @@ -56,8 +58,8 @@ use crate::worker_profile::{ ChildLaunchManifest, ModelRoute, ShellPolicy, ToolScope, WorkerRuntimeProfile, }; use coord::{ - CoordinationLedger, DecisionRecord, DecisionStatus, PersistedWriteClaim, ReconciliationReceipt, - WriteScopeClaim, + CoordinationDetailMetrics, CoordinationHotPath, CoordinationLedger, DecisionRecord, + DecisionStatus, PersistedWriteClaim, ReconciliationReceipt, WriteScopeClaim, }; pub mod coord; @@ -66,7 +68,7 @@ pub mod mailbox; #[allow(unused_imports)] // re-exported for hosts / tests; registration uses concrete types pub use coord::{ AgentsCoordinateTool, AgentsFollowupTool, AgentsInterruptTool, AgentsListTool, - AgentsMessageTool, AgentsWaitTool, register_coordination_tools, + AgentsMessageTool, AgentsWaitTool, CoordinationDetailProjection, register_coordination_tools, }; #[allow(unused_imports)] pub use mailbox::{Mailbox, MailboxEnvelope, MailboxMessage, MailboxReceiver}; @@ -79,6 +81,7 @@ pub use mailbox::{Mailbox, MailboxEnvelope, MailboxMessage, MailboxReceiver}; static RESIDENT_LEASES: std::sync::OnceLock< parking_lot::Mutex>, > = std::sync::OnceLock::new(); +const MAX_RESIDENT_CONTEXT_BYTES: u64 = 64 * 1024; /// Release all resident file leases held by `agent_id`. Called when an /// agent transitions to a terminal state (completed, failed, cancelled). @@ -89,6 +92,119 @@ fn release_resident_leases_for(agent_id: &str) { } } +fn reserve_resident_lease(lease_key: &str, display_path: &str) -> Result<(), ToolError> { + let leases = RESIDENT_LEASES.get_or_init(|| parking_lot::Mutex::new(HashMap::new())); + let mut guard = leases.lock(); + if let Some(owner) = guard.get(lease_key) { + return Err(ToolError::invalid_input(format!( + "resident_file '{display_path}' is already leased by agent {owner}" + ))); + } + guard.insert(lease_key.to_string(), "pending".to_string()); + Ok(()) +} + +fn rollback_pending_resident_lease(file_path: &str) { + if let Some(leases) = RESIDENT_LEASES.get() { + let mut guard = leases.lock(); + if guard.get(file_path).is_some_and(|owner| owner == "pending") { + guard.remove(file_path); + } + } +} + +fn commit_resident_lease(file_path: &str, agent_id: &str) { + if let Some(leases) = RESIDENT_LEASES.get() { + let mut guard = leases.lock(); + if let Some(owner) = guard.get_mut(file_path) + && owner == "pending" + { + *owner = agent_id.to_string(); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ResidentContext { + display_path: String, + lease_key: String, + contents: String, +} + +fn read_bounded_resident_context( + context: &ToolContext, + raw_path: &str, +) -> Result { + let path = crate::tools::spec::resolve_strict_authority_path(context, raw_path)?; + let metadata = std::fs::metadata(&path).map_err(|error| { + ToolError::invalid_input(format!( + "resident_file '{}' is not a readable workspace file: {error}", + raw_path + )) + })?; + if !metadata.is_file() { + return Err(ToolError::invalid_input(format!( + "resident_file '{}' must name one regular workspace file", + raw_path + ))); + } + if metadata.len() > MAX_RESIDENT_CONTEXT_BYTES { + return Err(ToolError::invalid_input(format!( + "resident_file '{}' is {} bytes; the bounded context limit is {} bytes", + raw_path, + metadata.len(), + MAX_RESIDENT_CONTEXT_BYTES + ))); + } + let mut bytes = Vec::new(); + std::fs::File::open(&path) + .and_then(|file| { + file.take(MAX_RESIDENT_CONTEXT_BYTES.saturating_add(1)) + .read_to_end(&mut bytes) + }) + .map_err(|error| { + ToolError::invalid_input(format!( + "resident_file '{}' could not be read: {error}", + raw_path + )) + })?; + if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_RESIDENT_CONTEXT_BYTES { + return Err(ToolError::invalid_input(format!( + "resident_file '{}' grew beyond the bounded {} byte context limit", + raw_path, MAX_RESIDENT_CONTEXT_BYTES + ))); + } + let contents = String::from_utf8(bytes).map_err(|_| { + ToolError::invalid_input(format!( + "resident_file '{}' must contain UTF-8 text", + raw_path + )) + })?; + let workspace = context.workspace.canonicalize().map_err(|error| { + ToolError::execution_failed(format!( + "Failed to canonicalize resident workspace {}: {error}", + context.workspace.display() + )) + })?; + let relative = path.strip_prefix(&workspace).map_err(|_| { + ToolError::permission_denied(format!( + "resident_file escapes workspace: {}", + path.display() + )) + })?; + let display_path = + normalize_claim_path(&relative.to_string_lossy()).map_err(ToolError::permission_denied)?; + Ok(ResidentContext { + display_path, + // The lease table is process-wide, so a repo-relative path alone + // would falsely collide across unrelated workspaces. The authority + // resolver returned a canonical in-workspace file; use that exact + // identity internally while keeping only the relative label visible. + lease_key: path.to_string_lossy().into_owned(), + contents, + }) +} + /// Child model-turn budgets are finite by role; explicit spawn values are /// clamped to the hard ceiling below. const MAX_SUBAGENT_STEPS: u32 = 2_000; @@ -163,10 +279,12 @@ const SUBAGENT_TRANSCRIPT_ARTIFACT_SCHEMA_VERSION: u32 = 1; const SUBAGENT_TRANSCRIPT_ARTIFACT_DIR: &str = "subagent-transcripts"; const SUBAGENT_STATE_SCHEMA_VERSION: u32 = 1; const SUBAGENT_STATE_FILE: &str = "subagents.v1.json"; +const SUBAGENT_STATE_LOCK_FILE: &str = "subagents.v1.lock"; const SUBAGENT_WORKTREE_ROOT_DIR: &str = ".codewhale-worktrees"; const SUBAGENT_RESTART_REASON: &str = "Interrupted by process restart"; -const SUBAGENT_QUEUED_LAUNCH_REASON: &str = "queued: waiting for a sub-agent launch slot"; +#[cfg(test)] const SUBAGENT_MODEL_WAIT_REASON: &str = "waiting for model response"; +const SUBAGENT_QUEUED_LAUNCH_REASON: &str = "queued: waiting for a sub-agent launch slot"; /// #freeze: minimum spacing between hot-path (per-step checkpoint) state /// persists. `update_checkpoint` fires on every step of every agent; at high /// fanout an unconditional full-fleet rewrite under the manager write lock @@ -990,6 +1108,12 @@ pub struct AgentWorkerRecord { pub events: VecDeque, } +#[derive(Clone)] +pub(crate) struct CoordinationRegistrationSnapshot { + worker_records: HashMap, + coordination: CoordinationLedger, +} + impl AgentWorkerRecord { fn new(spec: AgentWorkerSpec, now_ms: u64) -> Self { let run_id = agent_worker_run_id(&spec); @@ -1258,6 +1382,78 @@ fn normalize_worker_spec(mut spec: AgentWorkerSpec) -> AgentWorkerSpec { spec } +fn worker_coordination_claim( + spec: &AgentWorkerSpec, +) -> Result, String> { + if !spec.runtime_profile.permissions.write { + return Ok(None); + } + let manifest = spec.launch_manifest.as_ref().ok_or_else(|| { + format!( + "write-capable worker '{}' requires a persisted ChildLaunchManifest", + spec.worker_id + ) + })?; + if manifest.child_id != spec.worker_id { + return Err(format!( + "worker '{}' launch manifest belongs to '{}'", + spec.worker_id, manifest.child_id + )); + } + let normalize_paths = |values: &[String], field: &str| { + if values.len() > 32 { + return Err(format!( + "worker '{}' {field} accepts at most 32 entries", + spec.worker_id + )); + } + let mut normalized = Vec::new(); + for value in values { + let value = normalize_claim_path(value)?; + if !normalized.contains(&value) { + normalized.push(value); + } + } + Ok(normalized) + }; + let roots = normalize_paths(&manifest.writable_roots, "writable_roots")?; + let exact_files = normalize_paths(&manifest.writable_files, "writable_files")?; + if manifest.coordination_contracts.len() > 16 { + return Err(format!( + "worker '{}' coordination_contracts accepts at most 16 entries", + spec.worker_id + )); + } + let mut contracts = Vec::new(); + for contract in &manifest.coordination_contracts { + let contract = contract.trim(); + if contract.is_empty() || contract.chars().count() > 128 { + return Err(format!( + "worker '{}' coordination contracts must be 1..=128 characters", + spec.worker_id + )); + } + if !contracts.iter().any(|existing| existing == contract) { + contracts.push(contract.to_string()); + } + } + if roots.is_empty() && exact_files.is_empty() && contracts.is_empty() { + return Err(format!( + "write-capable worker '{}' requires a bounded root, exact file, or coordination contract", + spec.worker_id + )); + } + Ok(Some(( + WriteScopeClaim { + owner: spec.worker_id.clone(), + roots, + exact_files, + contracts, + }, + manifest.worktree, + ))) +} + fn worker_tool_scope(tool_profile: &AgentWorkerToolProfile) -> ToolScope { match tool_profile { AgentWorkerToolProfile::Inherited => ToolScope::Inherit, @@ -1281,8 +1477,16 @@ fn worker_profile_for_spawn( tool_profile: &AgentWorkerToolProfile, effective_model: &str, model_route: Option, + custom_write_authority: bool, ) -> WorkerRuntimeProfile { let mut requested = WorkerRuntimeProfile::for_role(agent_type.clone()); + // Custom starts locked down, but an explicit bounded write authority may + // deliberately open only the posture needed by its explicit tool list. + // Parent intersection below remains the hard ceiling. + if *agent_type == SubAgentType::Custom && custom_write_authority { + requested.permissions.write = true; + requested.shell = ShellPolicy::Full; + } requested.tools = worker_tool_scope(tool_profile); requested.model = model_route.unwrap_or_else(|| ModelRoute::Fixed(effective_model.to_string())); let provider = runtime.client.api_provider(); @@ -1484,6 +1688,11 @@ struct SubAgentInput { struct SpawnRequest { session_name: Option, prompt: String, + /// Explicit bounded prerequisites relevant to this child. Persisted in the + /// launch prompt; never populated from a parent transcript. + dependencies: Vec, + /// Explicit bounded acceptance checks relevant to this child. + acceptance: Vec, agent_type: SubAgentType, /// True when the caller supplied `type`/`agent_type` or `role` explicitly /// (vs the `General` default). A fleet `profile` only sets the agent type @@ -1516,8 +1725,8 @@ struct SpawnRequest { resident_file: Option, /// `Some(true)`: seed the child with the parent's system prompt and /// message prefix before appending the child task. `Some(false)`: force a - /// fresh isolated context. `None` (caller omitted the field): resolved by - /// [`auto_fork_context_default`] at spawn time. + /// fresh isolated context. `None` keeps the child fresh; transcript + /// inheritance is always an explicit caller decision. fork_context: Option, /// Legacy recursion budget for descendants. The model-facing child tool /// surface is leaf-only; this remains for persisted/internal records. @@ -1543,8 +1752,8 @@ struct SpawnRequest { /// Declared expected artifact. Surfaced to the child in its prompt so the /// contract the spawner declared is visible to the agent doing the work. expected_artifact: Option, - /// Expected shared-workspace mutation boundary. Empty on a write-capable - /// launch becomes a conservative whole-workspace claim. + /// Expected mutation boundary. Write-capable launches must declare at + /// least one bounded root, exact file, or named contract. write_roots: Vec, exact_files: Vec, coordination_contracts: Vec, @@ -1643,6 +1852,10 @@ struct PersistedSubAgent { #[derive(Debug, Clone, Serialize, Deserialize)] struct PersistedSubAgentState { schema_version: u32, + /// Monotonic in-process snapshot id. Concurrent background writers use it + /// to prevent an older clone from publishing after a newer one. + #[serde(default)] + snapshot_sequence: u64, agents: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] workers: Vec, @@ -1654,6 +1867,7 @@ impl Default for PersistedSubAgentState { fn default() -> Self { Self { schema_version: SUBAGENT_STATE_SCHEMA_VERSION, + snapshot_sequence: 0, agents: Vec::new(), workers: Vec::new(), coordination: CoordinationLedger::default(), @@ -2464,14 +2678,86 @@ impl SubAgent { } /// Manager for active sub-agents. +struct CoordinationProcessLock { + release: Option>, + thread: Option>, +} + +impl CoordinationProcessLock { + fn acquire(workspace: &Path) -> Result { + let lock_path = normalize_subagent_workspace(workspace) + .join(".codewhale") + .join("state") + .join(SUBAGENT_STATE_LOCK_FILE); + if let Some(parent) = lock_path.parent() { + fs::create_dir_all(parent)?; + } + reject_workspace_relative_symlinks(&normalize_subagent_workspace(workspace), &lock_path)?; + let lock_file = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&lock_path)?; + let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0); + let thread = std::thread::spawn(move || { + let mut lock = fd_lock::RwLock::new(lock_file); + match lock.try_write() { + Ok(_guard) => { + let _ = ready_tx.send(Ok::<(), String>(())); + let _ = release_rx.recv(); + } + Err(error) => { + let _ = ready_tx.send(Err(error.to_string())); + } + } + }); + match ready_rx.recv_timeout(Duration::from_secs(5)) { + Ok(Ok(())) => Ok(Self { + release: Some(release_tx), + thread: Some(thread), + }), + Ok(Err(error)) => { + let _ = thread.join(); + Err(anyhow!( + "another Codewhale process owns delegated coordination for {}: {error}", + workspace.display() + )) + } + Err(error) => { + drop(release_tx); + let _ = thread.join(); + Err(anyhow!( + "timed out acquiring delegated coordination lock for {}: {error}", + workspace.display() + )) + } + } + } +} + +impl Drop for CoordinationProcessLock { + fn drop(&mut self) { + self.release.take(); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + pub struct SubAgentManager { agents: HashMap, worker_records: HashMap, worker_event_seq: u64, + persist_sequence: std::sync::atomic::AtomicU64, coordination: CoordinationLedger, #[allow(dead_code)] // Stored for future workspace-scoped operations workspace: PathBuf, state_path: Option, + coordination_process_lock: Option, + coordination_process_lock_error: Option, + coordination_process_lock_required: bool, max_steps: Option, max_agents: usize, max_admitted_agents: usize, @@ -2518,9 +2804,13 @@ impl SubAgentManager { agents: HashMap::new(), worker_records: HashMap::new(), worker_event_seq: 0, + persist_sequence: std::sync::atomic::AtomicU64::new(0), coordination: CoordinationLedger::default(), workspace, state_path: None, + coordination_process_lock: None, + coordination_process_lock_error: None, + coordination_process_lock_required: false, max_steps: None, max_agents, max_admitted_agents: max_agents, @@ -2578,6 +2868,7 @@ impl SubAgentManager { &mut self, decision: DecisionRecord, ) -> Result { + self.ensure_coordination_process_lock()?; self.coordination.record_decision(decision) } @@ -2586,9 +2877,11 @@ impl SubAgentManager { decision_id: &str, status: DecisionStatus, owner: &str, + expected_version: u32, ) -> Result { + self.ensure_coordination_process_lock()?; self.coordination - .update_decision_status(decision_id, status, owner) + .update_decision_status(decision_id, status, owner, expected_version) } pub fn reconcile_coordination( @@ -2598,13 +2891,199 @@ impl SubAgentManager { input_decisions: Vec, outcome: String, evidence_handles: Vec, + candidate_handles: Vec, + retry_count: u32, + retry_limit: u32, + reviewer_evidence_handles: Vec, + verifier_evidence_handles: Vec, + verification_outcome: String, ) -> Result { - self.coordination - .reconcile(subject, owner, input_decisions, outcome, evidence_handles) + self.ensure_coordination_process_lock()?; + let expected_owner = self.nearest_common_fan_in_owner(&input_decisions)?; + if owner != expected_owner { + return Err(format!( + "neutral fan-in must be owned by nearest common Planner/release owner '{expected_owner}', not '{owner}'" + )); + } + let reviewer_ids = self.validate_reconciliation_role_evidence( + &reviewer_evidence_handles, + SubAgentType::Review, + "Reviewer", + )?; + let verifier_ids = self.validate_reconciliation_role_evidence( + &verifier_evidence_handles, + SubAgentType::Verifier, + "Verifier", + )?; + if !reviewer_ids.is_disjoint(&verifier_ids) { + return Err( + "Reviewer and Verifier evidence must come from distinct workers".to_string(), + ); + } + let candidate_owners = input_decisions + .iter() + .filter_map(|decision_id| { + self.coordination + .decisions + .iter() + .find(|decision| &decision.decision_id == decision_id) + .map(|decision| decision.owner.as_str()) + }) + .collect::>(); + if reviewer_ids + .iter() + .chain(verifier_ids.iter()) + .any(|worker| worker == &owner || candidate_owners.contains(worker.as_str())) + { + return Err( + "Reviewer/Verifier evidence workers must be independent of the neutral owner and candidate authors" + .to_string(), + ); + } + self.coordination.reconcile( + subject, + owner, + input_decisions, + outcome, + evidence_handles, + candidate_handles, + retry_count, + retry_limit, + reviewer_evidence_handles, + verifier_evidence_handles, + verification_outcome, + ) + } + + fn validate_reconciliation_role_evidence( + &self, + handles: &[String], + expected: SubAgentType, + label: &str, + ) -> Result, String> { + if handles.is_empty() { + return Err(format!("neutral fan-in requires {label} evidence")); + } + let mut workers = BTreeSet::new(); + for handle in handles { + let reference = handle + .strip_prefix("agent:") + .and_then(|rest| rest.split([':', '@', '#']).next()) + .unwrap_or(handle) + .trim(); + let Some((worker_id, record)) = self.worker_record_by_ref(reference) else { + return Err(format!( + "{label} evidence handle '{handle}' does not identify a persisted worker" + )); + }; + let role_matches = record.spec.agent_type == expected + || record.spec.role.as_deref().is_some_and(|role| { + role.trim().eq_ignore_ascii_case(label) + || (expected == SubAgentType::Review + && role.trim().eq_ignore_ascii_case("reviewer")) + || (expected == SubAgentType::Verifier + && role.trim().eq_ignore_ascii_case("verifier")) + }); + if !role_matches || record.status != AgentWorkerStatus::Completed { + return Err(format!( + "{label} evidence worker '{worker_id}' must have the {label} role and completed status" + )); + } + workers.insert(worker_id); + } + Ok(workers) + } + + fn nearest_common_fan_in_owner(&self, input_decisions: &[String]) -> Result { + let decision_owners = input_decisions + .iter() + .map(|decision_id| { + self.coordination + .decisions + .iter() + .find(|decision| &decision.decision_id == decision_id) + .map(|decision| decision.owner.clone()) + .ok_or_else(|| { + format!("reconciliation references unknown decision '{decision_id}'") + }) + }) + .collect::, _>>()?; + if decision_owners.len() < 2 { + return Err("neutral fan-in requires at least two input decisions".to_string()); + } + + let ancestry = decision_owners + .iter() + .map(|owner| self.worker_ancestry(owner)) + .collect::>(); + let Some(first) = ancestry.first() else { + return Ok("root".to_string()); + }; + for candidate in first { + if decision_owners.contains(candidate) + || !ancestry + .iter() + .skip(1) + .all(|chain| chain.contains(candidate)) + { + continue; + } + if candidate == "root" || self.worker_is_fan_in_owner(candidate) { + return Ok(candidate.clone()); + } + } + Ok("root".to_string()) + } + + fn worker_ancestry(&self, owner: &str) -> Vec { + let mut chain = Vec::new(); + let mut cursor = Some(owner.to_string()); + while let Some(reference) = cursor.take() { + let Some((worker_id, record)) = self.worker_record_by_ref(&reference) else { + break; + }; + if chain.contains(&worker_id) { + break; + } + chain.push(worker_id); + cursor = record.parent_run_id.clone(); + } + if !chain.iter().any(|entry| entry == "root") { + chain.push("root".to_string()); + } + chain + } + + fn worker_record_by_ref(&self, reference: &str) -> Option<(String, &AgentWorkerRecord)> { + self.worker_records + .get(reference) + .map(|record| (reference.to_string(), record)) + .or_else(|| { + self.worker_records.iter().find_map(|(worker_id, record)| { + (record.spec.run_id == reference).then(|| (worker_id.clone(), record)) + }) + }) + } + + fn worker_is_fan_in_owner(&self, reference: &str) -> bool { + self.worker_record_by_ref(reference) + .is_some_and(|(_, record)| { + record.spec.agent_type == SubAgentType::Plan + || record.spec.role.as_deref().is_some_and(|role| { + matches!( + role.trim().to_ascii_lowercase().as_str(), + "planner" | "manager" | "operator" | "release-owner" + ) + }) + }) } #[must_use] - pub fn inspect_coordination(&self, subject: Option<&str>, limit: usize) -> Value { + pub fn coordination_detail_projection( + &self, + subject: Option<&str>, + limit: usize, + ) -> CoordinationDetailProjection { let limit = limit.clamp(1, coord::COORDINATION_RECORD_LIMIT); let matches_subject = |value: &str| subject.is_none_or(|subject| value == subject); let decisions = self @@ -2633,14 +3112,71 @@ impl SubAgentManager { .take(limit) .cloned() .collect::>(); - json!({ - "sequence": self.coordination.sequence, - "decisions": decisions, - "write_claims": claims, - "reconciliations": reconciliations, - "bounded": true, - "limit": limit, - }) + let projections = self + .coordination + .projections + .iter() + .rev() + .take(limit) + .cloned() + .collect::>(); + let contentions = self + .coordination + .contentions + .iter() + .rev() + .take(limit) + .cloned() + .collect::>(); + let active_owners = self.active_coordination_owners(); + let mut hot_path_counts = std::collections::BTreeMap::::new(); + for record in self + .coordination + .write_claims + .iter() + .filter(|record| active_owners.contains(&record.claim.owner)) + { + for root in &record.claim.roots { + *hot_path_counts.entry(root.clone()).or_default() += 1; + } + for file in &record.claim.exact_files { + *hot_path_counts.entry(file.clone()).or_default() += 1; + } + } + let mut hottest_paths = hot_path_counts.into_iter().collect::>(); + hottest_paths.sort_by(|(path_a, count_a), (path_b, count_b)| { + count_b.cmp(count_a).then_with(|| path_a.cmp(path_b)) + }); + hottest_paths.truncate(limit.min(8)); + CoordinationDetailProjection { + schema_version: self.coordination.schema_version, + sequence: self.coordination.sequence, + decisions, + write_claims: claims, + reconciliations, + context_projections: projections, + contentions, + metrics: CoordinationDetailMetrics { + hottest_paths: hottest_paths + .into_iter() + .map(|(path, active_claims)| CoordinationHotPath { + path, + active_claims, + }) + .collect(), + package_or_module_growth: None, + route_or_cost: None, + note: "growth and route/cost stay null when the coordination ledger has no authoritative source".to_string(), + }, + bounded: true, + limit, + } + } + + #[must_use] + pub fn inspect_coordination(&self, subject: Option<&str>, limit: usize) -> Value { + serde_json::to_value(self.coordination_detail_projection(subject, limit)) + .expect("typed coordination projection is serializable") } pub fn expand_write_claim( @@ -2650,6 +3186,7 @@ impl SubAgentManager { exact_files: Vec, contracts: Vec, ) -> Result { + self.ensure_coordination_process_lock()?; let Some(existing) = self .coordination .write_claims @@ -2660,6 +3197,12 @@ impl SubAgentManager { return Err(format!("agent '{owner}' has no write claim to expand")); }; let mut claim = existing.claim; + let (roots, exact_files) = self.namespace_claim_paths_for_owner( + owner, + existing.isolated_worktree, + roots, + exact_files, + )?; for root in roots { let root = normalize_claim_path(&root)?; if !claim.roots.contains(&root) { @@ -2681,12 +3224,7 @@ impl SubAgentManager { claim.contracts.push(contract); } } - let active_owners = self - .agents - .iter() - .filter(|(_, agent)| agent.status == SubAgentStatus::Running) - .map(|(id, _)| id.clone()) - .collect::>(); + let active_owners = self.active_coordination_owners(); self.coordination .register_claim(claim, existing.isolated_worktree, |candidate| { active_owners.contains(candidate) @@ -2704,6 +3242,12 @@ impl SubAgentManager { "agent '{owner}' has no registered write claim; declare scope at launch before mutation" )); }; + let (_, paths) = self.namespace_claim_paths_for_owner( + owner, + claim.isolated_worktree, + Vec::new(), + paths.to_vec(), + )?; if let Some(path) = paths.iter().find(|path| !claim.claim.contains_path(path)) { return Err(format!( "write '{path}' is outside agent '{owner}' scope (roots: {:?}, files: {:?}); expand it first with agents/coordinate action=claim", @@ -2734,6 +3278,33 @@ impl SubAgentManager { self } + fn require_coordination_process_lock(mut self) -> Self { + self.coordination_process_lock_required = true; + match CoordinationProcessLock::acquire(&self.workspace) { + Ok(lock) => { + self.coordination_process_lock = Some(lock); + self.coordination_process_lock_error = None; + } + Err(error) => { + self.coordination_process_lock = None; + self.coordination_process_lock_error = Some(error.to_string()); + } + } + self + } + + fn ensure_coordination_process_lock(&self) -> Result<(), String> { + if !self.coordination_process_lock_required || self.coordination_process_lock.is_some() { + return Ok(()); + } + Err(self + .coordination_process_lock_error + .clone() + .unwrap_or_else(|| { + "delegated coordination requires one manager process per workspace".to_string() + })) + } + #[must_use] pub fn with_running_heartbeat_timeout(mut self, timeout: Duration) -> Self { self.running_heartbeat_timeout = if timeout.is_zero() { @@ -2820,6 +3391,10 @@ impl SubAgentManager { let payload = PersistedSubAgentState { schema_version: SUBAGENT_STATE_SCHEMA_VERSION, + snapshot_sequence: self + .persist_sequence + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + .saturating_add(1), agents, workers: self.sorted_worker_records(), coordination: self.coordination.clone(), @@ -2838,6 +3413,8 @@ impl SubAgentManager { /// completes. Callers may `.join()` for synchronous semantics or drop it /// for fire-and-forget. fn persist_state(&self) -> Result> { + self.ensure_coordination_process_lock() + .map_err(anyhow::Error::msg)?; let Some((path, payload)) = self.build_persist_payload()? else { // Nothing to persist — return a no-op handle. return Ok(std::thread::spawn(|| {})); @@ -2853,6 +3430,15 @@ impl SubAgentManager { Ok(handle) } + fn persist_state_synchronously(&self) -> Result<()> { + self.ensure_coordination_process_lock() + .map_err(anyhow::Error::msg)?; + let Some((path, payload)) = self.build_persist_payload()? else { + return Ok(()); + }; + write_json_atomic(&self.workspace, &path, &payload) + } + /// Fire-and-forget persist — logs errors, drops the join handle. fn persist_state_best_effort(&self) { if let Err(err) = self.persist_state() { @@ -2905,6 +3491,10 @@ impl SubAgentManager { /// Unlike `persist_state`, this performs disk I/O **synchronously** to /// guarantee data is flushed before the process exits. pub fn flush_pending_persist(&mut self) { + if let Err(error) = self.ensure_coordination_process_lock() { + tracing::warn!(target: "subagent", %error, "skipping persist without workspace coordination lock"); + return; + } if self.persist_pending { self.last_persist_at = Some(Instant::now()); self.persist_pending = false; @@ -2956,9 +3546,17 @@ impl SubAgentManager { )); } + let mut coordination = state.coordination; + coordination + .validate_replay() + .map_err(|error| anyhow!("Invalid coordination ledger: {error}"))?; self.agents.clear(); self.worker_records.clear(); - self.coordination = state.coordination; + self.persist_sequence.store( + state.snapshot_sequence, + std::sync::atomic::Ordering::Relaxed, + ); + self.coordination = coordination; for persisted in state.agents { let nickname = persisted .nickname @@ -3082,17 +3680,25 @@ impl SubAgentManager { } fn prune_worker_records(&mut self) { - if self.worker_records.len() <= MAX_AGENT_WORKER_RECORDS { - return; + while self.worker_records.len() > MAX_AGENT_WORKER_RECORDS { + let oldest_terminal = self + .worker_records + .values() + .filter(|record| record.status.is_terminal()) + .min_by(|a, b| { + a.updated_at_ms + .cmp(&b.updated_at_ms) + .then_with(|| a.spec.worker_id.cmp(&b.spec.worker_id)) + }) + .map(|record| record.spec.worker_id.clone()); + let Some(worker_id) = oldest_terminal else { + // Active launch identity is never a retention casualty. The + // live population may temporarily exceed the history cap and + // is compacted after terminal records become reclaimable. + break; + }; + self.worker_records.remove(&worker_id); } - let keep_ids: std::collections::HashSet = self - .sorted_worker_records() - .into_iter() - .take(MAX_AGENT_WORKER_RECORDS) - .map(|record| record.spec.worker_id) - .collect(); - self.worker_records - .retain(|worker_id, _| keep_ids.contains(worker_id)); } pub fn register_worker(&mut self, spec: AgentWorkerSpec) { @@ -3111,14 +3717,295 @@ impl SubAgentManager { self.prune_worker_records(); } + /// Validate a Fleet/headless worker's persisted launch claim before its + /// durable task lease is committed. A contention is still receipted in the + /// live coordination ledger, but a successful preflight does not reserve + /// anything until `register_worker_with_coordination` runs under the same + /// manager write lock. + pub fn preflight_worker_coordination(&mut self, spec: &AgentWorkerSpec) -> Result<(), String> { + self.ensure_coordination_process_lock()?; + let Some((claim, isolated_worktree)) = worker_coordination_claim(spec)? else { + return Ok(()); + }; + let active_owners = self.active_coordination_owners(); + let mut probe = self.coordination.clone(); + match probe.register_claim(claim.clone(), isolated_worktree, |owner| { + active_owners.contains(owner) + }) { + Ok(_) => Ok(()), + Err(error) => { + // Re-run against the authoritative ledger so a real overlap + // remains visible after restart. Invalid/schema failures do + // not mutate either ledger. + let coordination_before = self.coordination.clone(); + let _ = self + .coordination + .register_claim(claim, isolated_worktree, |owner| { + active_owners.contains(owner) + }); + if let Err(persist_error) = self.persist_state_synchronously() { + self.coordination = coordination_before; + return Err(format!( + "{error}; additionally failed to persist contention receipt: {persist_error}" + )); + } + Err(error) + } + } + } + + /// Commit a preflighted Fleet/headless worker, its bounded write claim, and + /// its minimal accepted-decision projection as one in-memory projection. + /// Callers hold the manager write lock from preflight through this method, + /// so the second validation cannot race another registration. + pub fn register_worker_with_coordination( + &mut self, + mut spec: AgentWorkerSpec, + ) -> Result<(), String> { + self.ensure_coordination_process_lock()?; + let previous_worker_records = self.worker_records.clone(); + let previous_coordination = self.coordination.clone(); + let claim = worker_coordination_claim(&spec)?; + let persisted_claim = claim + .map(|(claim, isolated_worktree)| { + let active_owners = self.active_coordination_owners(); + self.coordination + .register_claim(claim, isolated_worktree, |owner| { + active_owners.contains(owner) + }) + }) + .transpose()?; + + let mut capabilities = match &spec.tool_profile { + AgentWorkerToolProfile::Inherited => Vec::new(), + AgentWorkerToolProfile::Explicit(tools) => tools.clone(), + }; + capabilities.push(spec.agent_type.as_str().to_string()); + if let Some(role) = spec.role.as_ref() + && !capabilities.contains(role) + { + capabilities.push(role.clone()); + } + let (projection, _) = self.coordination.project_relevant_decisions( + &spec.worker_id, + persisted_claim.as_ref().map(|record| &record.claim), + &capabilities, + ); + if !projection.is_empty() { + spec.objective.push_str("\n\n"); + spec.objective.push_str(&projection); + if let Some(manifest) = spec.launch_manifest.as_mut() { + manifest.prompt = spec.objective.clone(); + } + } + self.register_worker(spec); + if let Err(error) = self.persist_state_synchronously() { + self.worker_records = previous_worker_records; + self.coordination = previous_coordination; + return Err(format!( + "failed to persist Fleet coordination launch record: {error}" + )); + } + Ok(()) + } + + pub(crate) fn coordination_registration_snapshot(&self) -> CoordinationRegistrationSnapshot { + CoordinationRegistrationSnapshot { + worker_records: self.worker_records.clone(), + coordination: self.coordination.clone(), + } + } + + pub(crate) fn restore_coordination_registration_snapshot( + &mut self, + snapshot: CoordinationRegistrationSnapshot, + ) -> Result<(), String> { + self.worker_records = snapshot.worker_records; + self.coordination = snapshot.coordination; + self.persist_state_synchronously() + .map_err(|error| format!("failed to persist Fleet coordination rollback: {error}")) + } + + fn active_coordination_owners(&self) -> std::collections::HashSet { + self.agents + .iter() + .filter(|(_, agent)| agent.status == SubAgentStatus::Running) + .map(|(id, _)| id.clone()) + .chain( + self.worker_records + .iter() + .filter(|(_, record)| !record.status.is_terminal()) + .map(|(id, _)| id.clone()), + ) + .collect() + } + + fn namespace_write_claim( + &self, + workspace: &Path, + isolated_worktree: bool, + mut claim: WriteScopeClaim, + ) -> Result { + if isolated_worktree { + return Ok(claim); + } + let prefix = coordination_workspace_prefix(&self.workspace, workspace)?; + claim.roots = claim + .roots + .iter() + .map(|path| namespace_coordination_path(&prefix, path)) + .collect::, _>>()?; + claim.exact_files = claim + .exact_files + .iter() + .map(|path| namespace_coordination_path(&prefix, path)) + .collect::, _>>()?; + Ok(claim) + } + + fn namespace_claim_paths_for_owner( + &self, + owner: &str, + isolated_worktree: bool, + roots: Vec, + exact_files: Vec, + ) -> Result<(Vec, Vec), String> { + if isolated_worktree { + return Ok((roots, exact_files)); + } + let workspace = self + .worker_records + .get(owner) + .map(|record| record.spec.workspace.as_path()) + .or_else(|| { + self.agents + .get(owner) + .map(|agent| agent.workspace.as_path()) + }) + .unwrap_or(self.workspace.as_path()); + let prefix = coordination_workspace_prefix(&self.workspace, workspace)?; + let roots = roots + .iter() + .map(|path| namespace_coordination_path(&prefix, path)) + .collect::, _>>()?; + let exact_files = exact_files + .iter() + .map(|path| namespace_coordination_path(&prefix, path)) + .collect::, _>>()?; + Ok((roots, exact_files)) + } + pub fn list_worker_records(&self) -> Vec { self.sorted_worker_records() } + #[cfg(test)] + pub(crate) fn coordination_snapshot(&self) -> CoordinationLedger { + self.coordination.clone() + } + pub fn get_worker_record(&self, worker_id: &str) -> Option { self.worker_records.get(worker_id).cloned() } + #[cfg(test)] + pub(crate) fn replace_registered_worker_spec_for_test( + &mut self, + spec: AgentWorkerSpec, + ) -> Result<(), String> { + let worker_id = spec.worker_id.clone(); + let record = self + .worker_records + .get_mut(&worker_id) + .ok_or_else(|| format!("Fleet worker {worker_id} has no registered launch spec"))?; + record.spec = spec; + self.persist_state_synchronously() + .map_err(|error| format!("failed to persist test worker spec: {error}")) + } + + /// Persist the next exact launch generation for a Fleet restart without + /// changing the worker's authority, route, prompt, or retained lifecycle + /// evidence. The Fleet manager validates the full spec against the task + /// lease before calling this; this local check makes generation the only + /// field that can change through this narrow seam. + pub(crate) fn advance_registered_worker_generation( + &mut self, + spec: AgentWorkerSpec, + ) -> Result<(), String> { + self.ensure_coordination_process_lock()?; + let worker_id = spec.worker_id.clone(); + let previous = self + .worker_records + .get(&worker_id) + .cloned() + .ok_or_else(|| format!("Fleet worker {worker_id} has no registered launch spec"))?; + let old_generation = previous + .spec + .launch_manifest + .as_ref() + .map(|manifest| manifest.generation) + .ok_or_else(|| format!("Fleet worker {worker_id} has no persisted launch manifest"))?; + let new_generation = spec + .launch_manifest + .as_ref() + .map(|manifest| manifest.generation) + .ok_or_else(|| { + format!("Fleet worker {worker_id} replacement has no launch manifest") + })?; + if new_generation != old_generation.saturating_add(1) { + return Err(format!( + "Fleet worker {worker_id} restart generation must advance from {old_generation} to {}", + old_generation.saturating_add(1) + )); + } + let mut expected = previous.spec.clone(); + expected + .launch_manifest + .as_mut() + .expect("old launch manifest checked above") + .generation = new_generation; + if expected != spec { + return Err(format!( + "Fleet worker {worker_id} restart may change only its launch generation" + )); + } + + let record = self + .worker_records + .get_mut(&worker_id) + .expect("worker record checked above"); + record.spec = spec; + record.updated_at_ms = epoch_millis_now(); + if let Err(error) = self.persist_state_synchronously() { + self.worker_records.insert(worker_id, previous); + return Err(format!( + "failed to persist Fleet restart launch generation: {error}" + )); + } + Ok(()) + } + + /// Reconcile an externally executed Fleet worker with the shared headless + /// lifecycle projection. This is idempotent and makes old write claims stop + /// participating in active-overlap checks once the durable Fleet task is + /// terminal. + pub fn project_external_worker_status( + &mut self, + worker_id: &str, + status: AgentWorkerStatus, + message: Option, + ) -> bool { + let Some(record) = self.worker_records.get(worker_id) else { + return false; + }; + if record.status == status { + return false; + } + self.record_worker_event(worker_id, status, message, None, None); + self.persist_state_best_effort(); + true + } + fn aggregate_budget_spent(&self, scope_id: &str) -> u64 { self.worker_records .values() @@ -3361,11 +4248,6 @@ impl SubAgentManager { } } - fn record_worker_progress(&mut self, worker_id: &str, message: String) { - let (status, step, tool_name) = worker_progress_event_parts(&message); - self.record_worker_event(worker_id, status, Some(message), step, tool_name); - } - fn complete_worker_from_result(&mut self, worker_id: &str, result: &SubAgentResult) { let status = worker_status_from_subagent_result(result); let message = match &result.status { @@ -3546,11 +4428,7 @@ impl SubAgentManager { )); } let agent_id = self.resolve_agent_ref(agent_ref)?; - if caller_agent_id.is_some_and(|caller| caller == agent_id) { - return Err(anyhow!( - "Refusing to interrupt self (agent_id '{agent_id}'). agents/interrupt fails closed on the calling agent." - )); - } + self.ensure_caller_controls_descendant(&agent_id, caller_agent_id, "agents/interrupt")?; let prior = self.get_result_by_ref(&agent_id)?; if prior.status != SubAgentStatus::Running @@ -3888,7 +4766,7 @@ impl SubAgentManager { manager_handle: SharedSubAgentManager, mut runtime: SubAgentRuntime, agent_type: SubAgentType, - prompt: String, + mut prompt: String, assignment: SubAgentAssignment, allowed_tools: Option>, options: SubAgentSpawnOptions, @@ -3977,28 +4855,86 @@ impl SubAgentManager { &tool_profile, &agent.model, options.model_route.clone(), + options.write_claim.is_some(), ); runtime.worker_profile = runtime_profile.clone(); - let persisted_claim = if runtime_profile.permissions.write { - options.write_claim.clone() + let write_capable = runtime_profile.permissions.write; + if write_capable { + self.ensure_coordination_process_lock() + .map_err(anyhow::Error::msg)?; + if self.coordination_process_lock_required && self.state_path.is_none() { + return Err(anyhow!( + "write-capable sub-agent launch requires a durable coordination state path" + )); + } + } + let durable_launch_snapshot = + write_capable.then(|| (self.worker_records.clone(), self.coordination.clone())); + let persisted_claim = if write_capable { + options + .write_claim + .clone() + .map(|mut claim| { + claim.owner = agent_id.clone(); + let claim = self.namespace_write_claim( + &agent.workspace, + options.isolated_worktree, + claim, + )?; + let active_owners = self.active_coordination_owners(); + self.coordination + .register_claim(claim, options.isolated_worktree, |owner| { + active_owners.contains(owner) + }) + }) + .transpose() } else { - None + Ok(None) + }; + let persisted_claim = match persisted_claim { + Ok(claim) => claim, + Err(error) => { + if let Err(persist_error) = self.persist_state_synchronously() { + if let Some((worker_records, coordination)) = durable_launch_snapshot.as_ref() { + self.worker_records = worker_records.clone(); + self.coordination = coordination.clone(); + } + return Err(anyhow!( + "{error}; additionally failed to persist contention receipt: {persist_error}" + )); + } + return Err(anyhow!(error)); + } + }; + let mut projection_capabilities = tools.clone().unwrap_or_else(|| { + ["Bash", "File", "Git", "Run", "Web"] + .into_iter() + .map(str::to_string) + .collect() + }); + projection_capabilities.push(agent_type.as_str().to_string()); + if let Some(role) = assignment.role.as_ref() + && !projection_capabilities.contains(role) + { + projection_capabilities.push(role.clone()); + } + let (decision_projection, _) = self.coordination.project_relevant_decisions( + &agent_id, + persisted_claim.as_ref().map(|record| &record.claim), + &projection_capabilities, + ); + if !decision_projection.is_empty() { + prompt.push_str("\n\n"); + prompt.push_str(&decision_projection); + agent.prompt = prompt.clone(); + } + if let Some(claim) = persisted_claim.as_ref().map(|record| &record.claim) { + prompt.push_str(&format!( + "\n\nWrite scope (enforced; coordination-root-relative): roots={:?}; exact_files={:?}; contracts={:?}. Expand it with agents/coordinate action=claim before mutating anything outside this scope.", + claim.roots, claim.exact_files, claim.contracts + )); + agent.prompt = prompt.clone(); } - .map(|mut claim| { - claim.owner = agent_id.clone(); - let active_owners = self - .agents - .iter() - .filter(|(_, agent)| agent.status == SubAgentStatus::Running) - .map(|(id, _)| id.clone()) - .collect::>(); - self.coordination - .register_claim(claim, options.isolated_worktree, |owner| { - active_owners.contains(owner) - }) - .map_err(anyhow::Error::msg) - }) - .transpose()?; let max_steps = resolve_max_steps(agent_type.clone(), options.max_steps, self.max_steps); runtime.worker_profile.max_steps = max_steps; let wall_time = options @@ -4057,13 +4993,48 @@ impl SubAgentManager { }), }; agent.work_lifecycle = - SubAgentWorkLifecycle::register(&runtime, &agent_id, &assignment.objective)?; + match SubAgentWorkLifecycle::register(&runtime, &agent_id, &assignment.objective) { + Ok(lifecycle) => lifecycle, + Err(error) => { + if let Some((worker_records, coordination)) = durable_launch_snapshot.as_ref() { + self.worker_records = worker_records.clone(); + self.coordination = coordination.clone(); + } + return Err(error); + } + }; agent.terminal_delivery = Some(SubAgentTerminalDeliveryContext::from_runtime(&runtime)); self.register_worker(worker_spec); if let Some(scope) = budget_scope { self.attach_budget_scope(&agent_id, scope); } + // Shared-workspace writers may execute only after their exact worker + // identity and claim are durably replayable. Persist a Starting record + // while the manager write lock still excludes the child; then launch. + // A crash can therefore leave an interrupted owner, never an accepted + // edit with no durable scope/identity record. + if write_capable { + self.agents.insert(agent_id.clone(), agent); + let persist_result = self.persist_state_synchronously(); + agent = self + .agents + .remove(&agent_id) + .expect("pre-launch agent remains registered under manager lock"); + if let Err(error) = persist_result { + let (worker_records, coordination) = durable_launch_snapshot + .expect("write-capable launch captured a registration snapshot"); + self.worker_records = worker_records; + self.coordination = coordination; + if let Some(lifecycle) = agent.work_lifecycle.as_ref() { + let _ = lifecycle.reconcile_state(OwnerState::Failed, 1, None); + } + return Err(anyhow!( + "failed to durably register write-capable sub-agent before launch: {error}" + )); + } + } + if let Some(mb) = runtime.mailbox.as_ref() { let _ = mb.send(MailboxMessage::started(&agent_id, agent_type.clone())); } @@ -4175,6 +5146,60 @@ impl SubAgentManager { } } + /// Resolve a hierarchy mutation target and prove that it is a strict + /// descendant of the calling agent. Root registries carry no caller id + /// (or the literal `root`) and retain authority over every child. This + /// prevents a child from messaging, waking, or interrupting a sibling or + /// ancestor and then claiming the ownership that target released. + pub(super) fn ensure_caller_controls_descendant( + &self, + agent_ref: &str, + caller_agent_id: Option<&str>, + action: &str, + ) -> Result { + let agent_id = self.resolve_agent_ref(agent_ref)?; + let Some(caller) = caller_agent_id + .map(str::trim) + .filter(|caller| !caller.is_empty() && *caller != "root") + else { + return Ok(agent_id); + }; + if caller == agent_id { + return Err(anyhow!( + "Refusing {action} on self (agent_id '{agent_id}'); child coordination authority is limited to strict descendants." + )); + } + + let mut cursor = agent_id.clone(); + let mut visited = std::collections::HashSet::new(); + while visited.insert(cursor.clone()) { + let Some((_, record)) = self.worker_record_by_ref(&cursor) else { + break; + }; + let Some(parent_ref) = record + .parent_run_id + .as_deref() + .or(record.spec.parent_run_id.as_deref()) + else { + break; + }; + if parent_ref == caller { + return Ok(agent_id); + } + if parent_ref == "root" { + break; + } + let Some((parent_id, _)) = self.worker_record_by_ref(parent_ref) else { + break; + }; + cursor = parent_id; + } + + Err(anyhow!( + "Refusing {action} from agent '{caller}' to '{agent_id}'; a child may control only its own descendants." + )) + } + /// List all agents and their status. #[must_use] /// Snapshot a single agent and tag it with the manager's @@ -5072,8 +6097,10 @@ fn instant_from_duration(duration: Duration) -> Instant { /// `state..tmp` and a rename could publish a half-written file — corrupt /// state that fails to parse on reload. static WRITE_JSON_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +static STATE_PUBLISH_SEQUENCES: std::sync::OnceLock>> = + std::sync::OnceLock::new(); -fn write_json_atomic(workspace: &Path, path: &Path, value: &T) -> Result<()> { +fn write_json_atomic(workspace: &Path, path: &Path, value: &PersistedSubAgentState) -> Result<()> { let workspace = normalize_subagent_workspace(workspace); reject_workspace_relative_symlinks(&workspace, path)?; if let Some(parent) = path.parent() { @@ -5084,11 +6111,22 @@ fn write_json_atomic(workspace: &Path, path: &Path, value: &T) -> let tmp_path = path.with_extension(format!("{}.{seq}.tmp", std::process::id())); reject_workspace_relative_symlinks(&workspace, &tmp_path)?; fs::write(&tmp_path, payload)?; + let publish_sequences = + STATE_PUBLISH_SEQUENCES.get_or_init(|| parking_lot::Mutex::new(HashMap::new())); + let mut published = publish_sequences.lock(); + if published + .get(path) + .is_some_and(|sequence| *sequence > value.snapshot_sequence) + { + let _ = fs::remove_file(&tmp_path); + return Ok(()); + } if let Err(err) = fs::rename(&tmp_path, path) { // Don't leave a stray temp behind if the publish failed. let _ = fs::remove_file(&tmp_path); return Err(err.into()); } + published.insert(path.to_path_buf(), value.snapshot_sequence); Ok(()) } @@ -5133,7 +6171,10 @@ pub fn new_shared_subagent_manager_with_timeout( if let Some(state_path) = state_path { manager = manager.with_state_path(state_path); } - if let Err(err) = manager.load_state() { + manager = manager.require_coordination_process_lock(); + if let Err(error) = manager.ensure_coordination_process_lock() { + tracing::warn!(target: "subagent", %error, "delegated coordination unavailable in this process"); + } else if let Err(err) = manager.load_state() { // Routed through tracing instead of stderr — see comment in // `persist_state_best_effort` above. tracing::warn!(target: "subagent", ?err, "failed to load sub-agent state"); @@ -5211,10 +6252,10 @@ impl ToolSpec for AgentTool { fn description(&self) -> &'static str { concat!( - "Start one focused background worker and return immediately with its agent_id; a prompt is enough. ", + "Start one focused background worker and return immediately with its agent_id; a prompt is enough for a read-only role. ", "Use multiple starts for independent parallel tasks. Add a Fleet profile, role, worktree, or explicit limits only when they improve the task. ", "Coordinate later with agents/list, agents/message, agents/followup, agents/interrupt, or agents/wait instead of polling. ", - "In Operate, approving a root start delegates workspace edits and built-in non-custom verification for that task; arbitrary shell remains gated. ", + "In Operate, a write-capable root start also declares bounded write_roots, exact_files, or coordination_contracts; arbitrary shell remains gated. ", "Legacy action=status|peek|wait|cancel remain for compatibility." ) } @@ -5248,7 +6289,17 @@ impl ToolSpec for AgentTool { }, "prompt": { "type": "string", - "description": "The focused task to give the background worker. This is the only field needed for an ordinary start." + "description": "The focused task to give the background worker. A read-only role needs no write scope; a write-capable role must also declare a bounded write scope." + }, + "dependencies": { + "type": "array", + "items": { "type": "string" }, + "description": "Bounded prerequisite facts or task ids relevant to this child. Raw parent reasoning and transcript text do not belong here." + }, + "acceptance": { + "type": "array", + "items": { "type": "string" }, + "description": "Bounded observable checks this child must satisfy before completion." }, "type": { "type": "string", @@ -5871,6 +6922,12 @@ async fn spawn_subagent_from_input( apply_session_spawn_defaults(&mut runtime); let mut spawn_request = parse_spawn_request(&input)?; let profile_member = apply_spawn_profile(&mut spawn_request, &runtime.fleet_roster)?; + // Profile-backed requests cannot be classified safely until the roster + // resolves their effective role. Enforce the same bounded-write contract + // after that resolution so read-only profiles stay ergonomic while a + // manager/builder profile can never acquire an implicit repository-wide + // write claim. + validate_spawn_write_contract(&mut spawn_request, false)?; if runtime.would_exceed_depth() { return Err(ToolError::execution_failed(format!( @@ -5948,47 +7005,26 @@ async fn spawn_subagent_from_input( } } } - // Enforce declared write authority (TUI-DOG-017): `read_only` narrows the - // child's runtime profile so Suggest-level write tools are actually gated, - // not just described. `derive_child` intersects permissions, so the - // narrowing also binds every grandchild. - if spawn_request.write_authority == Some(SpawnWriteAuthority::ReadOnly) { - child_runtime.worker_profile.permissions.write = false; - } + apply_spawn_write_authority(&mut child_runtime, &spawn_request); + let write_capable = spawn_request_is_write_capable(&spawn_request); // Resolve the model once against the CHILD's (possibly profile-pinned) // provider. The typed selection carries both precedence and provenance so // a role default cannot override a saved AgentProfile model (#4177). let model_selection = resolve_spawn_model_selection(&child_runtime, &spawn_request, profile_member.as_ref())?; - let (effective_prompt, _resident_conflict) = if let Some(ref file_path) = - spawn_request.resident_file - { - let abs_path = if std::path::Path::new(file_path).is_absolute() { - std::path::PathBuf::from(file_path) - } else { - runtime.context.workspace.join(file_path) - }; - let file_contents = std::fs::read_to_string(&abs_path) - .unwrap_or_else(|e| format!("")); + let resident_context = spawn_request + .resident_file + .as_deref() + .map(|file_path| read_bounded_resident_context(&runtime.context, file_path)) + .transpose()?; + let effective_prompt = if let Some(resident) = resident_context.as_ref() { let prefixed = format!( - "\n```\n{file_contents}\n```\n\n{}", - spawn_request.prompt + "\n```\n{}\n```\n\n{}", + resident.display_path, resident.contents, spawn_request.prompt ); - let conflict = { - let leases = RESIDENT_LEASES.get_or_init(|| parking_lot::Mutex::new(HashMap::new())); - let mut guard = leases.lock(); - if let Some(owner) = guard.get(file_path) { - Some(format!( - "Warning: agent {owner} already holds a resident lease on {file_path}" - )) - } else { - guard.insert(file_path.clone(), "pending".to_string()); - None - } - }; - (prefixed, conflict) + prefixed } else { - (spawn_request.prompt, None) + spawn_request.prompt }; // Surface the declared expected artifact to the child so the deliberate // contract is visible to the agent doing the work, not just validated at @@ -5999,32 +7035,33 @@ async fn spawn_subagent_from_input( } None => effective_prompt, }; - let write_capable = spawn_request.write_authority != Some(SpawnWriteAuthority::ReadOnly) - && matches!( - spawn_request.agent_type, - SubAgentType::General | SubAgentType::Implementer | SubAgentType::Custom - ); + let effective_prompt = if spawn_request.dependencies.is_empty() + && spawn_request.acceptance.is_empty() + { + effective_prompt + } else { + let dependencies = spawn_request + .dependencies + .iter() + .map(|item| format!("- {}", item.chars().take(256).collect::())) + .collect::>() + .join("\n"); + let acceptance = spawn_request + .acceptance + .iter() + .map(|item| format!("- {}", item.chars().take(256).collect::())) + .collect::>() + .join("\n"); + format!( + "{effective_prompt}\n\nDelegation contract (bounded):\nDependencies:\n{dependencies}\nAcceptance:\n{acceptance}" + ) + }; let write_claim = write_capable.then(|| WriteScopeClaim { owner: String::new(), - roots: if spawn_request.write_roots.is_empty() - && spawn_request.exact_files.is_empty() - && spawn_request.coordination_contracts.is_empty() - { - vec![".".to_string()] - } else { - spawn_request.write_roots.clone() - }, + roots: spawn_request.write_roots.clone(), exact_files: spawn_request.exact_files.clone(), contracts: spawn_request.coordination_contracts.clone(), }); - let effective_prompt = match write_claim.as_ref() { - Some(claim) => format!( - "{effective_prompt}\n\nWrite scope (enforced): roots={:?}; exact_files={:?}; contracts={:?}. Expand it with agents/coordinate action=claim before mutating anything outside this scope.", - claim.roots, claim.exact_files, claim.contracts - ), - None => effective_prompt, - }; - // #4193 seam 2 (cont.): strength/inherit/faster routing and the final // provider-namespace guard both read the provider from the runtime's client, // so route them through `child_runtime` (pinned provider) instead of the @@ -6072,57 +7109,92 @@ async fn spawn_subagent_from_input( workflow_child_index: None, }; - let fork_context = spawn_request.fork_context.unwrap_or_else(|| { - auto_fork_context_default( - &spawn_request.agent_type, - spawn_request.write_authority, - spawn_request.worktree.is_some(), - spawn_request.cwd.is_some(), - &runtime, - child_runtime.client.api_provider(), - &effective_model, - ) - }); + // #4647: a child receives only its explicit objective/dependencies/ + // acceptance and relevant accepted-decision projection by default. Forking + // a parent transcript is still available, but only through an explicit + // `fork_context: true` request. + let fork_context = spawn_request.fork_context.unwrap_or(false); + let resident_lease = resident_context + .as_ref() + .map(|resident| (resident.lease_key.clone(), resident.display_path.clone())); + if let Some((lease_key, display_path)) = resident_lease.as_ref() { + reserve_resident_lease(lease_key, display_path)?; + } let mut manager_guard = manager.write().await; - let result = manager_guard - .spawn_background_with_assignment_options( - Arc::clone(&manager), - child_runtime, - spawn_request.agent_type, - effective_prompt, - spawn_request.assignment, - spawn_request.allowed_tools, - SubAgentSpawnOptions { - name: spawn_request.session_name.clone(), - model: Some(effective_model), - model_route: Some(model_route), - nickname: None, - fork_context, - token_budget: spawn_request.token_budget, - max_steps: spawn_request.max_steps, - wall_time: spawn_request.wall_time, - write_claim, - isolated_worktree: spawn_request.worktree.is_some(), - expected_artifact: spawn_request.expected_artifact.clone(), - }, - ) - .map_err(|e| ToolError::execution_failed(format!("Failed to spawn sub-agent: {e}")))?; - - if let Some(ref file_path) = spawn_request.resident_file - && let Some(lock) = RESIDENT_LEASES.get() - { - let mut guard = lock.lock(); - if let Some(owner) = guard.get_mut(file_path) - && owner == "pending" - { - *owner = result.agent_id.clone(); + let result = manager_guard.spawn_background_with_assignment_options( + Arc::clone(&manager), + child_runtime, + spawn_request.agent_type, + effective_prompt, + spawn_request.assignment, + spawn_request.allowed_tools, + SubAgentSpawnOptions { + name: spawn_request.session_name.clone(), + model: Some(effective_model), + model_route: Some(model_route), + nickname: None, + fork_context, + token_budget: spawn_request.token_budget, + max_steps: spawn_request.max_steps, + wall_time: spawn_request.wall_time, + write_claim, + isolated_worktree: spawn_request.worktree.is_some(), + expected_artifact: spawn_request.expected_artifact.clone(), + }, + ); + let result = match result { + Ok(result) => result, + Err(error) => { + if let Some((lease_key, _)) = resident_lease.as_ref() { + rollback_pending_resident_lease(lease_key); + } + return Err(ToolError::execution_failed(format!( + "Failed to spawn sub-agent: {error}" + ))); } + }; + + if let Some((lease_key, _)) = resident_lease.as_ref() { + commit_resident_lease(lease_key, &result.agent_id); } Ok((result, spawn_metadata)) } +fn apply_spawn_write_authority(runtime: &mut SubAgentRuntime, request: &SpawnRequest) { + if request.write_authority != Some(SpawnWriteAuthority::ReadOnly) { + return; + } + // `read_only` must be an executable posture, not just metadata. Normally + // write-capable identities also inherit Full shell, which could mutate the + // workspace without a scope-aware claim under Auto/Full Access. Clamp that + // shell surface completely; verifier keeps its deliberate test runner. + runtime.worker_profile.permissions.write = false; + if matches!( + request.agent_type, + SubAgentType::General | SubAgentType::Implementer | SubAgentType::Custom + ) { + runtime.worker_profile.shell = ShellPolicy::None; + } +} + +fn spawn_request_is_write_capable(request: &SpawnRequest) -> bool { + match request.agent_type { + SubAgentType::General | SubAgentType::Implementer => { + request.write_authority != Some(SpawnWriteAuthority::ReadOnly) + } + SubAgentType::Custom => matches!( + request.write_authority, + Some(SpawnWriteAuthority::WorkspaceWrite | SpawnWriteAuthority::WorktreeWrite) + ), + SubAgentType::Explore + | SubAgentType::Plan + | SubAgentType::Review + | SubAgentType::Verifier => false, + } +} + /// A root Operate dispatch has already crossed the approval boundary on the /// `agent` call. Delegate Suggest-level file edits and the bounded built-in /// verification surfaces so a normal message can produce verified work. @@ -6165,6 +7237,24 @@ pub(crate) async fn spawn_workflow_task( "prompt": request.description, "worktree": request.worktree, }); + if let Some(value) = request.write_authority { + input["write_authority"] = json!(value); + } + if !request.write_roots.is_empty() { + input["write_roots"] = json!(request.write_roots); + } + if !request.exact_files.is_empty() { + input["exact_files"] = json!(request.exact_files); + } + if !request.coordination_contracts.is_empty() { + input["coordination_contracts"] = json!(request.coordination_contracts); + } + if !request.dependencies.is_empty() { + input["dependencies"] = json!(request.dependencies); + } + if !request.acceptance.is_empty() { + input["acceptance"] = json!(request.acceptance); + } if let Some(value) = request.subagent_type { input["type"] = json!(value); } @@ -6568,6 +7658,7 @@ async fn acquire_queued_launch_permit( record_agent_progress( &task.runtime, &task.agent_id, + AgentProgressEventMeta::new(AgentWorkerStatus::Cancelled), "cancelled while queued for a sub-agent launch slot".to_string(), ); None @@ -6594,6 +7685,7 @@ async fn record_queued_launch_progress(task: &SubAgentTask) { task.runtime.event_tx.as_ref(), &task.agent_id, SUBAGENT_QUEUED_LAUNCH_REASON.to_string(), + AgentProgressEventMeta::new(AgentWorkerStatus::Queued), task.runtime.parent_agent_id.clone(), task.runtime.spawn_depth, ); @@ -7153,6 +8245,7 @@ async fn request_subagent_model_response_with_retries( record_agent_progress( runtime, agent_id, + AgentProgressEventMeta::new(AgentWorkerStatus::ModelWait).with_step(steps), format!( "{}: {}; retrying API request {}/{} in {}ms ({err})", format_step_counter(steps, max_steps), @@ -7177,16 +8270,28 @@ async fn request_subagent_model_response_with_retries( } } -fn record_agent_progress(runtime: &SubAgentRuntime, agent_id: &str, message: impl Into) { +fn record_agent_progress( + runtime: &SubAgentRuntime, + agent_id: &str, + activity: AgentProgressEventMeta, + message: impl Into, +) { let message = message.into(); if let Ok(mut manager) = runtime.manager.try_write() { manager.touch(agent_id); - manager.record_worker_progress(agent_id, message.clone()); + manager.record_worker_event( + agent_id, + activity.worker_status, + Some(message.clone()), + activity.step, + activity.tool_name.clone(), + ); } emit_agent_progress( runtime.event_tx.as_ref(), agent_id, message, + activity, runtime.parent_agent_id.clone(), runtime.spawn_depth, ); @@ -7337,6 +8442,7 @@ async fn run_subagent( record_agent_progress( runtime, &agent_id, + AgentProgressEventMeta::new(AgentWorkerStatus::Starting), format!("started ({})", agent_type.as_str()), ); @@ -7378,6 +8484,7 @@ async fn run_subagent( record_agent_progress( runtime, &agent_id, + AgentProgressEventMeta::new(AgentWorkerStatus::Cancelled).with_step(steps), format!("{}: cancelled", format_step_counter(steps, max_steps)), ); let status = SubAgentStatus::Cancelled; @@ -7430,6 +8537,7 @@ async fn run_subagent( record_agent_progress( runtime, &agent_id, + AgentProgressEventMeta::new(AgentWorkerStatus::ModelWait).with_step(steps), format!( "{}: requesting model response", format_step_counter(steps, max_steps) @@ -7461,6 +8569,7 @@ async fn run_subagent( record_agent_progress( runtime, &agent_id, + AgentProgressEventMeta::new(AgentWorkerStatus::Running).with_step(steps), format!( "{}: received {count} child sub-agent completion(s)", format_step_counter(steps, max_steps) @@ -7519,6 +8628,7 @@ async fn run_subagent( record_agent_progress( runtime, &agent_id, + AgentProgressEventMeta::new(AgentWorkerStatus::Cancelled).with_step(steps), format!("{}: cancelled mid-request", format_step_counter(steps, max_steps)), ); let status = SubAgentStatus::Cancelled; @@ -7584,6 +8694,8 @@ async fn run_subagent( record_agent_progress( runtime, &agent_id, + AgentProgressEventMeta::new(AgentWorkerStatus::Interrupted) + .with_step(steps), format!("{}: interrupted; {reason}", format_step_counter(steps, max_steps)), ); let status = SubAgentStatus::Interrupted(reason.clone()); @@ -7609,6 +8721,8 @@ async fn run_subagent( record_agent_progress( runtime, &agent_id, + AgentProgressEventMeta::new(AgentWorkerStatus::WaitingForUser) + .with_step(steps), format!( "{}: waiting for user; {}", format_step_counter(steps, max_steps), @@ -7677,6 +8791,7 @@ async fn run_subagent( record_agent_progress( runtime, &agent_id, + AgentProgressEventMeta::new(AgentWorkerStatus::Failed).with_step(steps), format!( "{}: token budget exhausted ({tokens_used}/{budget})", format_step_counter(steps, max_steps) @@ -7797,6 +8912,7 @@ async fn run_subagent( record_agent_progress( runtime, &agent_id, + AgentProgressEventMeta::new(AgentWorkerStatus::Running).with_step(steps), format!("{}: {progress}", format_step_counter(steps, max_steps)), ); messages.push(Message { @@ -7843,6 +8959,7 @@ async fn run_subagent( record_agent_progress( runtime, &agent_id, + AgentProgressEventMeta::new(AgentWorkerStatus::Running).with_step(steps), format!( "{}: resuming with {count} child sub-agent completion(s)", format_step_counter(steps, max_steps) @@ -7886,6 +9003,7 @@ async fn run_subagent( record_agent_progress( runtime, &agent_id, + AgentProgressEventMeta::new(AgentWorkerStatus::Completed).with_step(steps), format!("{}: complete", format_step_counter(steps, max_steps)), ); stopped_naturally = true; @@ -7897,6 +9015,7 @@ async fn run_subagent( record_agent_progress( runtime, &agent_id, + AgentProgressEventMeta::new(AgentWorkerStatus::Running).with_step(steps), format!( "{}: executing {} tool call(s)", format_step_counter(steps, max_steps), @@ -7905,10 +9024,14 @@ async fn run_subagent( ); let mut tool_results: Vec = Vec::new(); for (tool_id, tool_name, tool_input) in tool_uses { - let tool_display_name = subagent_progress_tool_display_name(&tool_name); + let activity_tool_name = canonical_action_alias(&tool_name, &tool_input).to_string(); + let tool_display_name = subagent_progress_tool_display_name(&activity_tool_name); record_agent_progress( runtime, &agent_id, + AgentProgressEventMeta::new(AgentWorkerStatus::RunningTool) + .with_step(steps) + .with_tool(activity_tool_name.clone()), format!( "{}: running tool '{tool_display_name}'", format_step_counter(steps, max_steps) @@ -7917,7 +9040,7 @@ async fn run_subagent( if let Some(mb) = runtime.mailbox.as_ref() { let _ = mb.send(MailboxMessage::ToolCallStarted { agent_id: agent_id.clone(), - tool_name: tool_name.clone(), + tool_name: activity_tool_name.clone(), step: steps, }); } @@ -7945,6 +9068,9 @@ async fn run_subagent( record_agent_progress( runtime, &agent_id, + AgentProgressEventMeta::new(AgentWorkerStatus::RunningTool) + .with_step(steps) + .with_tool(activity_tool_name.clone()), format!( "{}: tool '{tool_display_name}' output spilled to {}", format_step_counter(steps, max_steps), @@ -7955,6 +9081,7 @@ async fn run_subagent( record_agent_progress( runtime, &agent_id, + AgentProgressEventMeta::new(AgentWorkerStatus::Running).with_step(steps), format!( "{}: finished tool '{tool_display_name}'", format_step_counter(steps, max_steps) @@ -7963,7 +9090,7 @@ async fn run_subagent( if let Some(mb) = runtime.mailbox.as_ref() { let _ = mb.send(MailboxMessage::ToolCallCompleted { agent_id: agent_id.clone(), - tool_name: tool_name.clone(), + tool_name: activity_tool_name, step: steps, ok: tool_ok, }); @@ -8199,85 +9326,6 @@ fn parse_items_text(input: &Value, key: &str) -> Result, ToolErro Ok(Some(lines.join("\n"))) } -/// Decide `fork_context` when the caller left it unset. -/// -/// Forking seeds the child with the byte-identical parent prefix, enabling -/// provider prefix caching (DeepSeek prefix-cache reuse, Anthropic cache -/// breakpoints) to serve the parent context instead of cold-prefilling. Only -/// cheaper — and only coherent — when the child (a) is spawned directly by -/// the engine turn (a nested spawner's snapshot is the root prefix, not its -/// own conversation), (b) runs the exact parent provider+model route (any -/// other route pays a full cold prefill of the entire parent context), -/// (c) has a read-only posture (explore/plan/review/verifier, or an explicit -/// `read_only` write authority), (d) stays in the parent workspace with -/// no worktree isolation, and (e) the parent prefix is not so large that -/// even cached fork reads (~10% of the prefix per child step) would exceed -/// a fresh brief. Everything else keeps the fresh-brief default. -/// An explicit `fork_context` from the caller always wins. -fn auto_fork_context_default( - agent_type: &SubAgentType, - write_authority: Option, - has_worktree: bool, - has_cwd_override: bool, - parent_runtime: &SubAgentRuntime, - child_provider: crate::config::ApiProvider, - effective_model: &str, -) -> bool { - let Some(fork_snapshot) = parent_runtime.fork_context.as_ref() else { - return false; - }; - if parent_runtime.parent_agent_id.is_some() { - return false; - } - if estimated_fork_prefix_tokens(fork_snapshot) > AUTO_FORK_MAX_PARENT_PREFIX_TOKENS { - return false; - } - let read_only_posture = matches!( - agent_type, - SubAgentType::Explore | SubAgentType::Plan | SubAgentType::Review | SubAgentType::Verifier - ) || write_authority == Some(SpawnWriteAuthority::ReadOnly); - read_only_posture - && !has_worktree - && !has_cwd_override - && child_provider == parent_runtime.client.api_provider() - && effective_model == parent_runtime.model -} - -/// Above this estimated parent-prefix size, auto-fork stops paying for a -/// short read-only child: every child step re-reads the whole prefix at the -/// provider's cached-read rate, so a very large parent context costs more -/// than a fresh brief plus re-discovery. Explicit `fork_context: true` is -/// not subject to this ceiling. -const AUTO_FORK_MAX_PARENT_PREFIX_TOKENS: usize = 200_000; - -fn estimated_fork_prefix_tokens(snapshot: &SubAgentForkContext) -> usize { - let mut total = snapshot - .structured_state_block - .as_deref() - .map(crate::compaction::estimate_text_tokens_conservative) - .unwrap_or(0); - for message in &snapshot.messages { - for block in &message.content { - match block { - ContentBlock::Text { text, .. } => { - total += crate::compaction::estimate_text_tokens_conservative(text); - } - ContentBlock::Thinking { thinking, .. } => { - total += crate::compaction::estimate_text_tokens_conservative(thinking); - } - ContentBlock::ToolResult { content, .. } => { - total += crate::compaction::estimate_text_tokens_conservative(content); - } - ContentBlock::ToolUse { input, .. } => { - total += input.to_string().len() / 4; - } - _ => {} - } - } - } - total -} - fn parse_spawn_request(input: &Value) -> Result { let prompt = parse_text_or_items( input, @@ -8285,6 +9333,8 @@ fn parse_spawn_request(input: &Value) -> Result { "items", "prompt", )?; + let dependencies = parse_bounded_strings(input, "dependencies", 8)?; + let acceptance = parse_bounded_strings(input, "acceptance", 8)?; let session_name = optional_input_str(input, &["name", "session_name"]) .map(validate_session_name) .transpose()?; @@ -8528,10 +9578,17 @@ fn parse_spawn_request(input: &Value) -> Result { let write_roots = parse_coordination_paths(input, "write_roots")?; let exact_files = parse_coordination_paths(input, "exact_files")?; let coordination_contracts = parse_bounded_strings(input, "coordination_contracts", 16)?; - - Ok(SpawnRequest { + let prompt_only_general = agent_type == SubAgentType::General + && !agent_type_explicit + && profile.is_none() + && role_input.is_none() + && type_input.is_none(); + let unresolved_profile = profile.is_some(); + let mut request = SpawnRequest { session_name, prompt: prompt.clone(), + dependencies, + acceptance, agent_type, agent_type_explicit, profile, @@ -8556,7 +9613,69 @@ fn parse_spawn_request(input: &Value) -> Result { write_roots, exact_files, coordination_contracts, - }) + }; + // A roster profile may resolve the parse-time General placeholder to a + // read-only scout/reviewer or to a write-capable manager/builder. Defer + // classification until apply_spawn_profile has the live roster; all + // profile-less requests can be validated immediately. + if !unresolved_profile { + validate_spawn_write_contract(&mut request, prompt_only_general)?; + } + Ok(request) +} + +fn validate_spawn_write_contract( + request: &mut SpawnRequest, + allow_prompt_only_general: bool, +) -> Result<(), ToolError> { + if matches!( + request.agent_type, + SubAgentType::Explore | SubAgentType::Plan | SubAgentType::Review | SubAgentType::Verifier + ) && request + .write_authority + .is_some_and(|authority| authority != SpawnWriteAuthority::ReadOnly) + { + return Err(ToolError::invalid_input(format!( + "{} is a read-only role and cannot declare write-capable authority", + request.agent_type.as_str() + ))); + } + let declares_scope = !request.write_roots.is_empty() + || !request.exact_files.is_empty() + || !request.coordination_contracts.is_empty(); + if request.write_authority == Some(SpawnWriteAuthority::ReadOnly) && declares_scope { + return Err(ToolError::invalid_input( + "read_only authority cannot declare write_roots, exact_files, or coordination_contracts" + .to_string(), + )); + } + if request.agent_type == SubAgentType::Custom && request.write_authority.is_none() { + if declares_scope { + return Err(ToolError::invalid_input( + "custom write scopes require explicit workspace_write or worktree_write authority" + .to_string(), + )); + } + request.write_authority = Some(SpawnWriteAuthority::ReadOnly); + } + let write_capable = spawn_request_is_write_capable(request); + if write_capable + && request.write_roots.is_empty() + && request.exact_files.is_empty() + && request.coordination_contracts.is_empty() + { + if request.write_authority.is_some() || !allow_prompt_only_general { + return Err(ToolError::invalid_input( + "explicit write-capable agent starts must declare write_roots, exact_files, or coordination_contracts; choose a read-only role for non-mutating work" + .to_string(), + )); + } + // A prompt-only/general launch remains ergonomic but is not silently + // granted the whole repository. It starts read-only until the caller + // supplies an explicit bounded mutation claim. + request.write_authority = Some(SpawnWriteAuthority::ReadOnly); + } + Ok(()) } fn parse_bounded_strings(input: &Value, key: &str, limit: usize) -> Result, ToolError> { @@ -8602,24 +9721,67 @@ fn parse_coordination_paths(input: &Value, key: &str) -> Result, Too fn normalize_claim_path(path: &str) -> Result { let path = path.replace('\\', "/"); - let trimmed = path.trim().trim_start_matches("./").trim_end_matches('/'); + let trimmed = path.trim(); + if trimmed.len() > 4096 || trimmed.chars().any(|ch| matches!(ch, '\0' | '\r' | '\n')) { + return Err("write scope path must be one bounded repo-relative line".to_string()); + } if trimmed.is_empty() || trimmed == "." { return Ok(".".to_string()); } let candidate = Path::new(trimmed); - if candidate.is_absolute() - || candidate.components().any(|component| { - matches!( - component, - Component::ParentDir | Component::RootDir | Component::Prefix(_) - ) - }) - { + if candidate.is_absolute() { return Err(format!( "write scope path must be repo-relative without traversal: {path}" )); } - Ok(trimmed.to_string()) + let mut components = Vec::new(); + for component in candidate.components() { + match component { + Component::CurDir => {} + Component::Normal(value) => { + let value = value.to_string_lossy().nfc().collect::(); + if !value.is_empty() { + components.push(value); + } + } + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err(format!( + "write scope path must be repo-relative without traversal: {path}" + )); + } + } + } + if components.is_empty() { + Ok(".".to_string()) + } else { + Ok(components.join("/")) + } +} + +fn coordination_workspace_prefix( + manager_workspace: &Path, + worker_workspace: &Path, +) -> Result { + let manager_workspace = normalize_subagent_workspace(manager_workspace); + let worker_workspace = normalize_subagent_workspace(worker_workspace); + let relative = worker_workspace + .strip_prefix(&manager_workspace) + .map_err(|_| { + format!( + "shared writer workspace '{}' must remain inside coordination root '{}'", + worker_workspace.display(), + manager_workspace.display() + ) + })?; + normalize_claim_path(&relative.to_string_lossy()) +} + +fn namespace_coordination_path(prefix: &str, path: &str) -> Result { + let path = normalize_claim_path(path)?; + match (prefix, path.as_str()) { + (".", path) | (path, ".") => Ok(path.to_string()), + (prefix, path) => normalize_claim_path(&format!("{prefix}/{path}")), + } } fn validate_session_name(name: &str) -> Result { @@ -9775,55 +10937,12 @@ fn worker_status_from_subagent_result(result: &SubAgentResult) -> AgentWorkerSta } } -fn worker_progress_event_parts(message: &str) -> (AgentWorkerStatus, Option, Option) { - let step = parse_progress_step(message); - let lower = message.to_ascii_lowercase(); - let status = if lower.contains("queued") { - AgentWorkerStatus::Queued - } else if lower.contains("waiting for user") || lower.contains("waiting for follow-up") { - AgentWorkerStatus::WaitingForUser - } else if lower.contains("requesting model response") - || lower.contains(SUBAGENT_MODEL_WAIT_REASON) - { - AgentWorkerStatus::ModelWait - } else if lower.contains("running tool") || lower.contains("executing") { - AgentWorkerStatus::RunningTool - } else if lower.contains("cancelled") { - AgentWorkerStatus::Cancelled - } else if lower.contains("interrupted") || lower.contains("timed out") { - AgentWorkerStatus::Interrupted - } else if lower.contains("complete") { - AgentWorkerStatus::Completed - } else if lower.contains("started") { - AgentWorkerStatus::Starting - } else { - AgentWorkerStatus::Running - }; - (status, step, parse_progress_tool_name(message)) -} - -fn parse_progress_step(message: &str) -> Option { - let rest = message.strip_prefix("step ")?; - let digits: String = rest.chars().take_while(|ch| ch.is_ascii_digit()).collect(); - (!digits.is_empty()) - .then(|| digits.parse::().ok()) - .flatten() -} - -fn parse_progress_tool_name(message: &str) -> Option { - let marker = "tool '"; - let start = message.find(marker)? + marker.len(); - let rest = &message[start..]; - let end = rest.find('\'')?; - let tool = rest[..end].trim(); - (!tool.is_empty()).then(|| tool.to_string()) -} - -fn subagent_progress_tool_display_name(name: &str) -> &str { +pub(crate) fn subagent_progress_tool_display_name(name: &str) -> &str { match name { "exec_shell" | "exec_shell_wait" | "exec_shell_interact" + | "exec_shell_cancel" | "exec_wait" | "exec_interact" | "task_shell_start" @@ -9836,28 +10955,30 @@ fn emit_agent_progress( event_tx: Option<&mpsc::Sender>, agent_id: &str, status: String, + activity: AgentProgressEventMeta, parent_run_id: Option, spawn_depth: u32, ) { if let Some(event_tx) = event_tx { if event_tx.max_capacity() > MIN_EVENT_CHANNEL_HEADROOM_FOR_ROUTINE_PROGRESS && event_tx.capacity() <= MIN_EVENT_CHANNEL_HEADROOM_FOR_ROUTINE_PROGRESS - && routine_agent_progress_can_preserve_event_headroom(&status) + && routine_agent_progress_can_preserve_event_headroom(activity.worker_status) { return; } let _ = event_tx.try_send(Event::AgentProgress { id: agent_id.to_string(), status, + activity, parent_run_id, spawn_depth, }); } } -fn routine_agent_progress_can_preserve_event_headroom(status: &str) -> bool { +fn routine_agent_progress_can_preserve_event_headroom(status: AgentWorkerStatus) -> bool { matches!( - worker_progress_event_parts(status).0, + status, AgentWorkerStatus::Running | AgentWorkerStatus::ModelWait | AgentWorkerStatus::RunningTool ) } @@ -9889,8 +11010,9 @@ fn routine_agent_progress_can_preserve_event_headroom(status: &str) -> bool { /// (read-only shell) and `plan` (no shell) are denied because read-only-shell /// enforcement is not yet wired at the exec layer. /// -/// `custom` is governed by its explicit `allowed_tools` list, so the posture -/// check permits it here (the allowlist is the authority for that role). +/// `custom` passes this role-only check. Its explicit allowlist, bounded write +/// authority, and parent-intersected runtime profile jointly form the actual +/// authority envelope. fn role_posture_permits(agent_type: &SubAgentType, approval: ApprovalRequirement) -> bool { if matches!(agent_type, SubAgentType::Custom) { return true; @@ -9940,6 +11062,9 @@ struct SubAgentToolRegistry { } impl SubAgentToolRegistry { + const ACTION_ALIASES: &'static [(&'static str, &'static str, &'static str)] = + CANONICAL_ACTION_ALIASES; + #[cfg(test)] fn new( runtime: SubAgentRuntime, @@ -9980,11 +11105,17 @@ impl SubAgentToolRegistry { let coordination_manager = Arc::clone(&runtime.manager); let mut surface_options = runtime.agent_tool_surface_options.clone(); surface_options.shell_policy = ShellPolicy::from_legacy_allow_shell(runtime.allow_shell); + // This registry executes as the admitted child named by + // `owner_agent_id`. Coordination tools derive their hierarchy caller + // from `parent_agent_id`, so stamp the same identity here even for + // test/restore constructors whose runtime snapshot predates admission. + let mut child_runtime = runtime.clone(); + child_runtime.parent_agent_id = Some(owner_agent_id.clone()); let mut registry = ToolRegistryBuilder::new().with_full_agent_surface_options( Some(runtime.client.clone()), runtime.model.clone(), runtime.manager.clone(), - runtime.clone(), + child_runtime, surface_options, todo_list, plan_state, @@ -10044,6 +11175,16 @@ impl SubAgentToolRegistry { .get("commands") .map(|commands| commands.as_array().is_some_and(Vec::is_empty)) .unwrap_or(true), + "Run" => match input.get("action").and_then(Value::as_str) { + Some("tests") => input + .get("args") + .is_none_or(|args| args.as_str().is_some_and(|args| args.trim().is_empty())), + Some("verifiers") => input + .get("commands") + .map(|commands| commands.as_array().is_some_and(Vec::is_empty)) + .unwrap_or(true), + _ => false, + }, _ => false, } } @@ -10052,7 +11193,7 @@ impl SubAgentToolRegistry { /// parent auto-approval. Delegates to the pure `role_posture_permits`. /// Unregistered names pass through (the allowlist / availability checks /// handle those separately). - fn posture_permits_tool(&self, name: &str) -> bool { + fn posture_permits_tool(&self, name: &str, input: Option<&Value>) -> bool { // Delegation (`agent`) is governed by the depth budget and the // allowlist (`can_spawn_child` / `is_tool_allowed`), not the write/shell // posture — a read-only role may still fan out child work. @@ -10060,7 +11201,10 @@ impl SubAgentToolRegistry { return true; } match self.registry.get(name) { - Some(spec) => match spec.approval_requirement() { + Some(spec) => match input.map_or_else( + || spec.approval_requirement(), + |input| spec.approval_requirement_for(input), + ) { ApprovalRequirement::Auto => true, ApprovalRequirement::Suggest => { self.runtime_profile.permissions.write @@ -10093,6 +11237,28 @@ impl SubAgentToolRegistry { }) } + fn legacy_action_alias(family: &str, action: &str) -> Option<&'static str> { + Self::ACTION_ALIASES + .iter() + .find_map(|(candidate_family, candidate_action, alias)| { + (*candidate_family == family && *candidate_action == action).then_some(*alias) + }) + } + + fn is_action_allowed(&self, family: &str, action: &str) -> bool { + let alias = Self::legacy_action_alias(family, action); + if self.is_tool_denied(family) || alias.is_some_and(|name| self.is_tool_denied(name)) { + return false; + } + match &self.allowed_tools { + None => true, + Some(list) => { + list.iter().any(|name| name == family) + || alias.is_some_and(|alias| list.iter().any(|name| name == alias)) + } + } + } + /// Whether a given tool name is permitted under this child's filter. /// `None` filter = everything permitted. fn is_tool_allowed(&self, name: &str) -> bool { @@ -10106,7 +11272,12 @@ impl SubAgentToolRegistry { } match &self.allowed_tools { None => true, - Some(list) => list.iter().any(|t| t == name), + Some(list) => { + list.iter().any(|tool| tool == name) + || Self::ACTION_ALIASES.iter().any(|(family, _, alias)| { + *family == name && list.iter().any(|allowed| allowed == alias) + }) + } } } @@ -10117,10 +11288,25 @@ impl SubAgentToolRegistry { None => api_tools, Some(list) => api_tools .into_iter() - .filter(|tool| list.contains(&tool.name)) + .filter(|tool| { + list.contains(&tool.name) + || ["Bash", "File", "Git", "Run", "Web"].contains(&tool.name.as_str()) + && tool.input_schema["properties"]["action"]["enum"] + .as_array() + .is_some_and(|actions| { + actions.iter().any(|action| { + action.as_str().is_some_and(|action| { + Self::legacy_action_alias(&tool.name, action) + .is_some_and(|alias| { + list.iter().any(|n| n == alias) + }) + }) + }) + }) + }) .collect::>(), }; - filtered + let mut tools = filtered .into_iter() .filter(|tool| tool.name != "agent" || self.can_spawn_child) // #4042: hide explicitly disallowed tools so the model never sees @@ -10130,8 +11316,36 @@ impl SubAgentToolRegistry { // #3217: hide tools the role posture forbids so the model never // even sees write/edit/patch (read-only roles) or shell (no-shell // roles). Defense-in-depth with the `execute` guard below. - .filter(|tool| self.posture_permits_tool(&tool.name)) - .collect() + .filter(|tool| tool.name == "File" || self.posture_permits_tool(&tool.name, None)) + .collect::>(); + + for tool in &mut tools { + if !["Bash", "File", "Git", "Run", "Web"].contains(&tool.name.as_str()) { + continue; + } + if let Some(actions) = tool.input_schema["properties"]["action"]["enum"].as_array_mut() + { + actions.retain(|action| { + let Some(action) = action.as_str() else { + return false; + }; + let posture_allows = tool.name != "File" + || (self.runtime_profile.permissions.write + && role_posture_permits( + &self.agent_type, + ApprovalRequirement::Suggest, + )) + || matches!(action, "read" | "list" | "search_name" | "search_content"); + posture_allows && self.is_action_allowed(&tool.name, action) + }); + } + } + tools.retain(|tool| { + tool.input_schema["properties"]["action"]["enum"] + .as_array() + .is_none_or(|actions| !actions.is_empty()) + }); + tools } fn unavailable_allowed_tools(&self) -> Vec { @@ -10146,14 +11360,27 @@ impl SubAgentToolRegistry { } async fn execute(&self, _agent_id: &str, name: &str, input: Value) -> Result { - if !self.is_tool_allowed(name) { + let action = input.get("action").and_then(Value::as_str); + let family_action_allowed = if !Self::ACTION_ALIASES + .iter() + .any(|(family, _, _)| *family == name) + { + true + } else if let Some(action) = action { + self.is_action_allowed(name, action) + } else { + self.allowed_tools + .as_ref() + .is_none_or(|list| list.iter().any(|allowed| allowed == name)) + }; + if !self.is_tool_allowed(name) || !family_action_allowed { return Err(anyhow!("Tool {name} not allowed for this sub-agent")); } // #3217: authoritative per-role posture — read-only roles cannot mutate // and non-`Full`-shell roles cannot run shell, regardless of whether // the parent session is auto-approved. This closes the auto-approve // bypass where a read-only child could quietly write or shell out. - if !self.posture_permits_tool(name) { + if !self.posture_permits_tool(name, Some(&input)) { return Err(anyhow!( "Tool {name} is not permitted for the read-only `{role}` sub-agent role. Use an `implementer` or `general` role (or a `custom` role with an explicit allowed_tools list) to mutate the workspace or run shell commands.", role = self.agent_type.as_str() @@ -10163,7 +11390,7 @@ impl SubAgentToolRegistry { let Some(spec) = self.registry.get(name) else { return Err(anyhow!("Tool {name} is not registered")); }; - match spec.approval_requirement() { + match spec.approval_requirement_for(&input) { ApprovalRequirement::Auto => {} ApprovalRequirement::Suggest => { // Write/edit/patch tools land here. Explicit @@ -10195,8 +11422,12 @@ impl SubAgentToolRegistry { let scope_aware_write = matches!( name, "write_file" | "edit_file" | "apply_patch" | "fim_edit" - ) || (name == "pandoc_convert" - && input.get("output_path").is_some()); + ) || (name == "File" + && input + .get("action") + .and_then(Value::as_str) + .is_some_and(|action| matches!(action, "write" | "edit" | "patch"))) + || (name == "pandoc_convert" && input.get("output_path").is_some()); if scope_aware_write && self.enforce_write_claim { let paths = mutation_paths(name, &input)?; if paths.is_empty() { @@ -10209,9 +11440,26 @@ impl SubAgentToolRegistry { .validate_write_scope(&self.owner_agent_id, &paths) .map_err(anyhow::Error::msg)?; } else if self.enforce_write_claim - && (name == "exec_shell" + && !is_internal_coordination_state_tool(name) + && (is_unbounded_shell_run(name, &input) || self.registry.get(name).is_some_and(|spec| { - spec.approval_requirement_for(&input) == ApprovalRequirement::Suggest + let canonical = canonical_action_alias(name, &input); + let is_shell_control = matches!( + canonical, + "exec_shell_wait" | "exec_shell_interact" | "exec_shell_cancel" + ); + let capabilities = spec.capabilities(); + !is_shell_control + && (spec.approval_requirement_for(&input) == ApprovalRequirement::Suggest + || (!spec.is_read_only_for(&input) + && capabilities.iter().any(|capability| { + matches!( + capability, + ToolCapability::WritesFiles + | ToolCapability::ExecutesCode + | ToolCapability::Network + ) + }))) })) { let manager = self.coordination_manager.read().await; @@ -10234,9 +11482,40 @@ impl SubAgentToolRegistry { } } +fn is_unbounded_shell_run(name: &str, input: &Value) -> bool { + canonical_action_alias(name, input) == "exec_shell" +} + +fn is_internal_coordination_state_tool(name: &str) -> bool { + matches!( + name, + "agent" + | "agents/list" + | "agents/message" + | "agents/followup" + | "agents/interrupt" + | "agents/coordinate" + | "agents/wait" + | "work_update" + | "update_plan" + | "checklist_add" + | "checklist_update" + | "checklist_write" + | "todo_add" + | "todo_update" + | "todo_write" + ) +} + fn mutation_paths(name: &str, input: &Value) -> Result> { - let raw_paths = if name == "apply_patch" { - crate::tools::apply_patch::preflight_apply_patch(input) + let raw_paths = if name == "apply_patch" + || (name == "File" && input.get("action").and_then(Value::as_str) == Some("patch")) + { + let mut patch_input = input.clone(); + if let Some(object) = patch_input.as_object_mut() { + object.remove("action"); + } + crate::tools::apply_patch::preflight_apply_patch(&patch_input) .map_err(|err| anyhow!(err.to_string()))? .touched_files } else if let Some(path) = input @@ -10255,7 +11534,11 @@ fn mutation_paths(name: &str, input: &Value) -> Result> { } fn reject_subagent_terminal_takeover(name: &str, input: &Value) -> Result<()> { - let wants_interactive_shell = name == "exec_shell" + let wants_interactive_shell = matches!(name, "exec_shell" | "Bash") + && input + .get("action") + .and_then(Value::as_str) + .is_none_or(|action| action == "run") && input .get("interactive") .and_then(Value::as_bool) @@ -10497,7 +11780,7 @@ const EXPLORE_AGENT_INTRO: &str = concat!( "You are a trusted exploration sub-agent (role: `explore`). Your job is to map the relevant code quickly and stay strictly read-only.\n", "Default to `EFFORT: quick`: aim for about 3-5 tool calls unless the brief explicitly asks for more.\n", "Orient first: confirm the workspace/project root, read relevant AGENTS.md/README guidance when the tree is unfamiliar, then search only the likely scope.\n", - "Use list_dir/file_search, grep_files, and read_file; use RLM only for long inputs or many semantic slices, not basic path discovery.\n", + "Use `File` with actions `list`, `search_name`, `search_content`, and `read`; use RLM only for long inputs or many semantic slices, not basic path discovery.\n", "Honor QUESTION, SCOPE, ALREADY_KNOWN, and STOP_CONDITION. Do not repeat ALREADY_KNOWN work unless evidence contradicts it; do not broaden once QUESTION is answered.\n", "Your value is compressed reconnaissance: cite `path:line-range` for each finding and stop once evidence is sufficient. Return partial findings if the next step would be speculative or duplicative.\n", "CHANGES will almost always be \"None.\" for an explorer.\n\n" @@ -10526,7 +11809,7 @@ const CUSTOM_AGENT_INTRO: &str = concat!( const IMPLEMENTER_AGENT_INTRO: &str = concat!( "You are a trusted implementation sub-agent (role: `implementer`). Your job is to land the assigned change with minimal surrounding edits.\n", - "Read target files before editing; prefer edit_file for narrow changes and apply_patch for hunks.\n", + "Read target files with `File` action `read` before editing; prefer action `edit` for narrow changes and action `patch` for hunks.\n", "Run relevant verification after edit batches; write needed tests with the implementation.\n", "You are not limited to an explorer-style 3-5 tool-call cap. Checkpoint before expanding scope or after repeated failures, then continue only inside the assigned brief.\n", "CHANGES is load-bearing: list every modified file with a one-line why.\n\n" diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 261a6bbad4..64be8c6d5f 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -196,6 +196,53 @@ fn make_worker_spec(worker_id: &str, workspace: PathBuf) -> AgentWorkerSpec { } } +fn make_write_worker_spec(worker_id: &str, workspace: PathBuf, root: &str) -> AgentWorkerSpec { + let mut spec = make_worker_spec(worker_id, workspace.clone()); + spec.agent_type = SubAgentType::Implementer; + spec.role = Some("implementer".to_string()); + spec.runtime_profile = WorkerRuntimeProfile::for_role(SubAgentType::Implementer); + spec.launch_manifest = Some(ChildLaunchManifest { + owner_session: "root".to_string(), + child_id: worker_id.to_string(), + profile: spec.runtime_profile.clone(), + prompt: spec.objective.clone(), + cwd: Some(workspace.display().to_string()), + worktree: false, + writable_roots: vec![root.to_string()], + writable_files: Vec::new(), + coordination_contracts: Vec::new(), + expected_artifact: Some("tested patch".to_string()), + token_budget: None, + resume_identity: Some(worker_id.to_string()), + generation: 1, + }); + spec +} + +#[test] +fn active_worker_records_are_never_pruned_by_history_retention() { + let tmp = tempdir().expect("tempdir"); + let mut manager = SubAgentManager::new(tmp.path().to_path_buf(), 4); + for index in 0..=MAX_AGENT_WORKER_RECORDS { + manager.register_worker(make_worker_spec( + &format!("active-worker-{index:03}"), + tmp.path().to_path_buf(), + )); + } + + assert_eq!( + manager.list_worker_records().len(), + MAX_AGENT_WORKER_RECORDS + 1 + ); + assert!(manager.get_worker_record("active-worker-000").is_some()); + assert!( + manager + .list_worker_records() + .iter() + .all(|record| !record.status.is_terminal()) + ); +} + #[test] fn headless_worker_record_tracks_lifecycle_without_tui_projection() { let tmp = tempdir().expect("tempdir"); @@ -212,13 +259,19 @@ fn headless_worker_record_tracks_lifecycle_without_tui_projection() { None, None, ); - manager.record_worker_progress( + manager.record_worker_event( "agent_worker_contract", - "step 1: requesting model response".to_string(), + AgentWorkerStatus::ModelWait, + Some("step 1: requesting model response".to_string()), + Some(1), + None, ); - manager.record_worker_progress( + manager.record_worker_event( "agent_worker_contract", - "step 1: running tool 'read_file'".to_string(), + AgentWorkerStatus::RunningTool, + Some("step 1: running tool 'read_file'".to_string()), + Some(1), + Some("read_file".to_string()), ); let mut result = make_snapshot(SubAgentStatus::Completed); @@ -417,6 +470,7 @@ fn agent_worker_profile_derives_from_parent_without_escalation() { &tool_profile, "deepseek-v4-pro", Some(ModelRoute::Fixed("deepseek-v4-pro".to_string())), + false, ); assert_eq!(profile.role, SubAgentType::Implementer); @@ -436,10 +490,80 @@ fn agent_worker_profile_derives_from_parent_without_escalation() { ); } +#[test] +fn declared_read_only_write_roles_derive_without_mutating_shell() { + for input in [ + json!({"prompt": "inspect only"}), + json!({ + "prompt": "implementation review", + "type": "implementer", + "write_authority": "read_only" + }), + ] { + let request = parse_spawn_request(&input).expect("read-only spawn parses"); + let mut runtime = stub_runtime().background_runtime(); + apply_spawn_write_authority(&mut runtime, &request); + let profile = worker_profile_for_spawn( + &runtime, + &request.agent_type, + &AgentWorkerToolProfile::Inherited, + "deepseek-v4-pro", + None, + false, + ); + assert!(!profile.permissions.write, "{request:?}"); + assert_eq!(profile.shell, ShellPolicy::None, "{request:?}"); + } +} + +#[test] +fn custom_runtime_opens_only_for_explicit_bounded_write_authority() { + let runtime = stub_runtime().background_runtime(); + let tools = AgentWorkerToolProfile::Explicit(vec!["write_file".to_string()]); + let locked = worker_profile_for_spawn( + &runtime, + &SubAgentType::Custom, + &tools, + "deepseek-v4-pro", + None, + false, + ); + assert!(!locked.permissions.write); + assert_eq!(locked.shell, ShellPolicy::None); + + let opened = worker_profile_for_spawn( + &runtime, + &SubAgentType::Custom, + &tools, + "deepseek-v4-pro", + None, + true, + ); + assert!(opened.permissions.write); + assert_eq!(opened.shell, ShellPolicy::Full); + + let mut read_only_parent = runtime; + read_only_parent.worker_profile = WorkerRuntimeProfile::for_role(SubAgentType::Explore); + let intersected = worker_profile_for_spawn( + &read_only_parent, + &SubAgentType::Custom, + &tools, + "deepseek-v4-pro", + None, + true, + ); + assert!(!intersected.permissions.write); + assert_eq!(intersected.shell, ShellPolicy::ReadOnly); +} + #[test] fn subagent_progress_displays_shell_tools_as_bash() { assert_eq!(subagent_progress_tool_display_name("exec_shell"), "Bash"); assert_eq!(subagent_progress_tool_display_name("exec_wait"), "Bash"); + assert_eq!( + subagent_progress_tool_display_name("exec_shell_cancel"), + "Bash" + ); assert_eq!( subagent_progress_tool_display_name("task_shell_wait"), "Bash" @@ -462,6 +586,7 @@ fn agent_progress_preserves_event_channel_headroom_under_load() { Some(&tx), "agent_busy", "step 1: requesting model response".to_string(), + AgentProgressEventMeta::new(AgentWorkerStatus::ModelWait).with_step(1), None, 1, ); @@ -475,6 +600,7 @@ fn agent_progress_preserves_event_channel_headroom_under_load() { Some(&tx), "agent_waiting", "waiting for user input".to_string(), + AgentProgressEventMeta::new(AgentWorkerStatus::WaitingForUser), None, 1, ); @@ -489,8 +615,10 @@ fn agent_progress_preserves_event_channel_headroom_under_load() { } assert!(matches!( rx.try_recv(), - Ok(Event::AgentProgress { id, status, .. }) - if id == "agent_waiting" && status == "waiting for user input" + Ok(Event::AgentProgress { id, status, activity, .. }) + if id == "agent_waiting" + && status == "waiting for user input" + && activity.worker_status == AgentWorkerStatus::WaitingForUser )); assert!(rx.try_recv().is_err()); } @@ -503,6 +631,7 @@ fn agent_progress_uses_small_event_channels_without_headroom_reservation() { Some(&tx), "agent_small_channel", "step 1: requesting model response".to_string(), + AgentProgressEventMeta::new(AgentWorkerStatus::ModelWait).with_step(1), None, 1, ); @@ -510,8 +639,11 @@ fn agent_progress_uses_small_event_channels_without_headroom_reservation() { assert_eq!(tx.capacity(), 7); assert!(matches!( rx.try_recv(), - Ok(Event::AgentProgress { id, status, .. }) - if id == "agent_small_channel" && status == "step 1: requesting model response" + Ok(Event::AgentProgress { id, status, activity, .. }) + if id == "agent_small_channel" + && status == "step 1: requesting model response" + && activity.worker_status == AgentWorkerStatus::ModelWait + && activity.step == Some(1) )); } @@ -605,6 +737,594 @@ fn coordination_ledger_persists_and_replays_bounded_contracts() { assert_eq!(inspect["write_claims"][0]["claim"]["owner"], "agent_writer"); } +#[test] +fn invalid_decision_and_reconciliation_inputs_cannot_poison_persisted_replay() { + let tmp = tempdir().expect("tempdir"); + let state_path = tmp.path().join("subagents.v1.json"); + let mut manager = + SubAgentManager::new(tmp.path().to_path_buf(), 4).with_state_path(state_path.clone()); + + for bad_id in ["bad\ndecision".to_string(), "x".repeat(513)] { + let error = manager + .record_coordination_decision(DecisionRecord { + decision_id: bad_id, + subject: "safe-subject".into(), + status: DecisionStatus::Proposed, + owner: "agent-a".into(), + scope: vec!["path:src".into()], + constraints: vec!["bounded".into()], + evidence_handles: vec!["receipt:test".into()], + version: 1, + sequence: 0, + }) + .expect_err("invalid decision id must fail before mutation"); + assert!(error.contains("decision id"), "{error}"); + assert!(manager.coordination.decisions.is_empty()); + } + + for (id, owner) in [("candidate-a", "agent-a"), ("candidate-b", "agent-b")] { + manager + .record_coordination_decision(DecisionRecord { + decision_id: id.into(), + subject: "safe-subject".into(), + status: DecisionStatus::Proposed, + owner: owner.into(), + scope: vec!["path:src".into()], + constraints: vec!["bounded".into()], + evidence_handles: vec![format!("receipt:{id}")], + version: 1, + sequence: 0, + }) + .expect("valid candidate decision"); + } + let duplicate_candidate = manager + .coordination + .reconcile( + "safe-subject".into(), + "root".into(), + vec!["candidate-a".into(), "candidate-b".into()], + "preserve both".into(), + vec!["receipt:fan-in".into()], + vec!["branch:a".into(), " branch:a ".into()], + 0, + 3, + vec!["review:independent".into()], + vec!["verify:locked".into()], + "verified".into(), + ) + .expect_err("whitespace aliases must not satisfy two-candidate fan-in"); + assert!( + duplicate_candidate.contains("distinct normalized candidate"), + "{duplicate_candidate}" + ); + let duplicate_input = manager + .coordination + .reconcile( + "safe-subject".into(), + "root".into(), + vec!["candidate-a".into(), " candidate-a ".into()], + "preserve both".into(), + vec!["receipt:fan-in".into()], + vec!["branch:a".into(), "branch:b".into()], + 0, + 3, + vec!["review:independent".into()], + vec!["verify:locked".into()], + "verified".into(), + ) + .expect_err("whitespace aliases must not satisfy two-decision fan-in"); + assert!( + duplicate_input.contains("distinct normalized input decisions"), + "{duplicate_input}" + ); + assert!(manager.coordination.reconciliations.is_empty()); + + manager.persist_state().unwrap().join().unwrap(); + let mut replayed = + SubAgentManager::new(tmp.path().to_path_buf(), 4).with_state_path(state_path); + replayed + .load_state() + .expect("valid state survives restart after rejected poison inputs"); + assert_eq!(replayed.coordination.decisions.len(), 2); + assert!(replayed.coordination.reconciliations.is_empty()); +} + +#[test] +fn coordination_hot_paths_count_only_active_authoritative_owners() { + let tmp = tempdir().expect("tempdir"); + let mut manager = SubAgentManager::new(tmp.path().to_path_buf(), 4); + manager.register_worker(make_worker_spec("agent-live", tmp.path().to_path_buf())); + manager.register_worker(make_worker_spec("agent-done", tmp.path().to_path_buf())); + manager + .worker_records + .get_mut("agent-done") + .expect("terminal worker") + .status = AgentWorkerStatus::Completed; + for (owner, root) in [("agent-live", "src/live"), ("agent-done", "src/history")] { + manager + .coordination + .register_claim( + WriteScopeClaim { + owner: owner.into(), + roots: vec![root.into()], + exact_files: Vec::new(), + contracts: Vec::new(), + }, + false, + |_| false, + ) + .expect("bounded non-overlapping claim"); + } + + let projection = manager.coordination_detail_projection(None, 24); + assert_eq!( + projection.metrics.hottest_paths, + vec![CoordinationHotPath { + path: "src/live".into(), + active_claims: 1, + }] + ); +} + +#[test] +fn headless_worker_registration_enforces_live_claims_and_projects_context() { + let tmp = tempdir().expect("tempdir"); + let mut manager = SubAgentManager::new(tmp.path().to_path_buf(), 8); + manager + .record_coordination_decision(DecisionRecord { + decision_id: "decision-src-a".into(), + subject: "src-a-contract".into(), + status: DecisionStatus::Accepted, + owner: "planner".into(), + scope: vec!["path:src/a".into()], + constraints: vec!["preserve the public API".into()], + evidence_handles: vec!["receipt:planner".into()], + version: 1, + sequence: 0, + }) + .expect("accepted decision"); + + let worker = |id: &str, root: &str| { + let mut spec = make_worker_spec(id, tmp.path().to_path_buf()); + spec.agent_type = SubAgentType::Implementer; + spec.role = Some("worker".into()); + spec.runtime_profile = WorkerRuntimeProfile::for_role(SubAgentType::Implementer); + spec.launch_manifest = Some(ChildLaunchManifest { + owner_session: "fleet-run".into(), + child_id: id.into(), + profile: spec.runtime_profile.clone(), + prompt: spec.objective.clone(), + cwd: Some(tmp.path().display().to_string()), + worktree: false, + writable_roots: vec![root.into()], + writable_files: Vec::new(), + coordination_contracts: Vec::new(), + expected_artifact: None, + token_budget: None, + resume_identity: Some(format!("fleet-{id}")), + generation: 1, + }); + spec + }; + + let worker_a = worker("worker-a", "src/a"); + manager + .preflight_worker_coordination(&worker_a) + .expect("first worker preflight"); + manager + .register_worker_with_coordination(worker_a) + .expect("first worker registration"); + let record = manager + .get_worker_record("worker-a") + .expect("worker record"); + assert!(record.spec.objective.contains("src-a-contract")); + assert!( + record + .spec + .launch_manifest + .as_ref() + .expect("launch manifest") + .prompt + .contains("src-a-contract") + ); + + let overlap = manager + .preflight_worker_coordination(&worker("worker-b", "src/a/nested")) + .expect_err("overlapping live Fleet writer must remain queued"); + assert!(overlap.contains("worker-a"), "{overlap}"); + assert_eq!(manager.coordination.contentions.len(), 1); + assert!(manager.get_worker_record("worker-b").is_none()); + + let worker_c = worker("worker-c", "src/b"); + manager + .preflight_worker_coordination(&worker_c) + .expect("non-overlapping worker preflight"); + manager + .register_worker_with_coordination(worker_c) + .expect("non-overlapping workers may proceed concurrently"); + assert_eq!(manager.coordination.write_claims.len(), 2); +} + +#[test] +fn neutral_reconciliation_requires_the_nearest_common_planner() { + let tmp = tempdir().expect("tempdir"); + let mut manager = SubAgentManager::new(tmp.path().to_path_buf(), 8); + + let mut planner = make_worker_spec("planner", tmp.path().to_path_buf()); + planner.agent_type = SubAgentType::Plan; + planner.role = Some("planner".into()); + manager.register_worker(planner); + for worker_id in ["worker-a", "worker-b"] { + let mut worker = make_worker_spec(worker_id, tmp.path().to_path_buf()); + worker.parent_run_id = Some("planner".into()); + worker.agent_type = SubAgentType::Implementer; + worker.role = Some("worker".into()); + manager.register_worker(worker); + manager + .record_coordination_decision(DecisionRecord { + decision_id: format!("decision-{worker_id}"), + subject: "public-api".into(), + status: DecisionStatus::Proposed, + owner: worker_id.into(), + scope: vec!["contract:public-api".into()], + constraints: vec!["preserve candidate".into()], + evidence_handles: vec![format!("branch:{worker_id}")], + version: 1, + sequence: 0, + }) + .expect("record candidate decision"); + } + for (worker_id, agent_type, role) in [ + ("reviewer", SubAgentType::Review, "reviewer"), + ("verifier", SubAgentType::Verifier, "verifier"), + ] { + let mut worker = make_worker_spec(worker_id, tmp.path().to_path_buf()); + worker.parent_run_id = Some("planner".into()); + worker.agent_type = agent_type; + worker.role = Some(role.into()); + manager.register_worker(worker); + manager.record_worker_event( + worker_id, + AgentWorkerStatus::Completed, + Some(format!("{role} evidence complete")), + None, + None, + ); + } + + let input_decisions = vec!["decision-worker-a".into(), "decision-worker-b".into()]; + let error = manager + .reconcile_coordination( + "public-api".into(), + "worker-a".into(), + input_decisions.clone(), + "combine both candidates".into(), + vec!["receipt:fanin".into()], + vec!["branch:worker-a".into(), "branch:worker-b".into()], + 1, + 3, + vec!["agent:reviewer:review-pass".into()], + vec!["agent:verifier:locked-tests".into()], + "verified".into(), + ) + .unwrap_err(); + assert!(error.contains("'planner'"), "{error}"); + + let receipt = manager + .reconcile_coordination( + "public-api".into(), + "planner".into(), + input_decisions, + "combine both candidates".into(), + vec!["receipt:fanin".into()], + vec!["branch:worker-a".into(), "branch:worker-b".into()], + 1, + 3, + vec!["agent:reviewer:review-pass".into()], + vec!["agent:verifier:locked-tests".into()], + "verified".into(), + ) + .expect("nearest common planner may reconcile"); + assert_eq!(receipt.owner, "planner"); + assert_eq!(receipt.retry_limit, 3); + assert_eq!(receipt.candidate_handles.len(), 2); +} + +#[test] +fn coordination_acceptance_preserves_scopes_candidates_and_replay() { + let repo = init_subagent_git_repo(); + let state_path = repo.path().join("coordination-state.json"); + let base_branch = { + let output = Command::new("git") + .args(["branch", "--show-current"]) + .current_dir(repo.path()) + .output() + .expect("current branch"); + assert!(output.status.success()); + String::from_utf8_lossy(&output.stdout).trim().to_string() + }; + std::fs::create_dir_all(repo.path().join("src")).expect("src directory"); + + git(repo.path(), &["switch", "-c", "candidate-a"]); + std::fs::write(repo.path().join("src/a.rs"), "pub const A: u8 = 1;\n") + .expect("candidate A edit"); + git(repo.path(), &["add", "src/a.rs"]); + git( + repo.path(), + &[ + "-c", + "user.name=codewhale Tests", + "-c", + "user.email=tests@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "candidate A", + ], + ); + let candidate_a = git_stdout(repo.path(), &["rev-parse", "candidate-a"]); + + git(repo.path(), &["switch", &base_branch]); + git(repo.path(), &["switch", "-c", "candidate-b"]); + std::fs::create_dir_all(repo.path().join("src")).expect("candidate B src directory"); + std::fs::write(repo.path().join("src/b.rs"), "pub const B: u8 = 2;\n") + .expect("candidate B edit"); + git(repo.path(), &["add", "src/b.rs"]); + git( + repo.path(), + &[ + "-c", + "user.name=codewhale Tests", + "-c", + "user.email=tests@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "candidate B", + ], + ); + let candidate_b = git_stdout(repo.path(), &["rev-parse", "candidate-b"]); + + let mut manager = + SubAgentManager::new(repo.path().to_path_buf(), 8).with_state_path(state_path.clone()); + let mut planner = make_worker_spec("parent_session", repo.path().to_path_buf()); + planner.agent_type = SubAgentType::Plan; + planner.role = Some("planner".into()); + manager.register_worker(planner); + let agent_a = manager.insert_test_running_agent("a", repo.path()); + let agent_b = manager.insert_test_running_agent("b", repo.path()); + let agent_c = manager.insert_test_running_agent("c", repo.path()); + + let claim = |owner: &str, path: &str| WriteScopeClaim { + owner: owner.into(), + roots: vec![], + exact_files: vec![path.into()], + contracts: vec![], + }; + manager + .coordination + .register_claim(claim(&agent_a, "src/a.rs"), false, |_| false) + .expect("A claim"); + manager + .coordination + .register_claim(claim(&agent_b, "src/b.rs"), false, |_| false) + .expect("B claim"); + let contention = manager + .coordination + .register_claim(claim(&agent_c, "src/a.rs"), false, |owner| { + owner == agent_a || owner == agent_b + }) + .expect_err("C cannot collide silently with A"); + assert!(contention.contains(&agent_a), "{contention}"); + assert_eq!(manager.coordination.contentions.len(), 1); + + let scope_expansion = manager + .expand_write_claim(&agent_a, vec![], vec!["src/b.rs".into()], vec![]) + .expect_err("A expansion into B must visibly replan"); + assert!(scope_expansion.contains("contention"), "{scope_expansion}"); + + for (id, subject, owner, scope) in [ + ("accepted-a", "api-a", agent_a.as_str(), "path:src/a.rs"), + ("accepted-b", "api-b", agent_b.as_str(), "path:src/b.rs"), + ] { + manager + .record_coordination_decision(DecisionRecord { + decision_id: id.into(), + subject: subject.into(), + status: DecisionStatus::Accepted, + owner: owner.into(), + scope: vec![scope.into()], + constraints: vec!["preserve public behavior".into()], + evidence_handles: vec![format!("commit:{id}")], + version: 1, + sequence: 0, + }) + .expect("accepted scoped decision"); + } + for (id, owner) in [("merge-a", agent_a.as_str()), ("merge-b", agent_b.as_str())] { + manager + .record_coordination_decision(DecisionRecord { + decision_id: id.into(), + subject: "merge-strategy".into(), + status: DecisionStatus::Proposed, + owner: owner.into(), + scope: vec!["contract:public-api".into()], + constraints: vec!["retain both edits".into()], + evidence_handles: vec![format!("commit:{id}")], + version: 1, + sequence: 0, + }) + .expect("candidate decision"); + } + let merge_a = manager + .coordination + .decisions + .iter() + .find(|decision| decision.decision_id == "merge-a") + .cloned() + .expect("merge A decision"); + let merge_b = manager + .coordination + .decisions + .iter() + .find(|decision| decision.decision_id == "merge-b") + .cloned() + .expect("merge B decision"); + assert_eq!(merge_a.version, 1); + assert_eq!(merge_b.version, 2); + let stale_version = manager + .update_coordination_decision( + "merge-b", + DecisionStatus::Accepted, + &agent_b, + merge_a.version, + ) + .expect_err("a competing stale version cannot replace either candidate"); + assert!(stale_version.contains("version changed"), "{stale_version}"); + assert_eq!( + manager + .coordination + .decisions + .iter() + .filter(|decision| { + decision.subject == "merge-strategy" && decision.status == DecisionStatus::Proposed + }) + .count(), + 2, + "both conflicting candidates remain preserved" + ); + let claim_a = manager + .coordination + .write_claims + .iter() + .find(|claim| claim.claim.owner == agent_a) + .expect("claim A") + .claim + .clone(); + let claim_b = manager + .coordination + .write_claims + .iter() + .find(|claim| claim.claim.owner == agent_b) + .expect("claim B") + .claim + .clone(); + let (projection_a, projection_a_receipt) = + manager + .coordination + .project_relevant_decisions(&agent_a, Some(&claim_a), &["File".into()]); + let (projection_b, projection_b_receipt) = + manager + .coordination + .project_relevant_decisions(&agent_b, Some(&claim_b), &["File".into()]); + assert!(projection_a.contains("api-a") && !projection_a.contains("api-b")); + assert!(projection_b.contains("api-b") && !projection_b.contains("api-a")); + for receipt in [&projection_a_receipt, &projection_b_receipt] { + assert!( + receipt.projected_bytes <= coord::COORDINATION_PROJECTION_BYTE_LIMIT, + "projection byte cap is durable" + ); + assert!( + receipt.decision_ids.len() <= coord::COORDINATION_PROJECTION_DECISION_LIMIT, + "projection decision cap is durable" + ); + assert_eq!( + receipt + .decision_ids + .iter() + .collect::>() + .len(), + receipt.decision_ids.len(), + "projection ids are deduplicated" + ); + assert_eq!(receipt.deduplicated, 0); + assert_eq!(receipt.omitted, 0); + } + + for (worker_id, agent_type, role) in [ + ("reviewer-agent", SubAgentType::Review, "reviewer"), + ("verifier-agent", SubAgentType::Verifier, "verifier"), + ] { + let mut worker = make_worker_spec(worker_id, repo.path().to_path_buf()); + worker.parent_run_id = Some("parent_session".into()); + worker.agent_type = agent_type; + worker.role = Some(role.into()); + manager.register_worker(worker); + manager.record_worker_event( + worker_id, + AgentWorkerStatus::Completed, + Some(format!("{role} evidence complete")), + None, + None, + ); + } + + let receipt = manager + .reconcile_coordination( + "merge-strategy".into(), + "parent_session".into(), + vec!["merge-a".into(), "merge-b".into()], + "retry budget exhausted; preserve both candidates for explicit disposition".into(), + vec!["receipt:neutral-fan-in".into()], + vec![ + format!("branch:candidate-a@{candidate_a}"), + format!("branch:candidate-b@{candidate_b}"), + ], + 3, + 3, + vec!["agent:reviewer-agent:review-pass".into()], + vec!["agent:verifier-agent:locked-tests".into()], + "blocked".into(), + ) + .expect("nearest common Planner records terminal retry exhaustion"); + assert_eq!(receipt.retry_count, receipt.retry_limit); + assert_eq!(receipt.candidate_handles.len(), 2); + + manager.persist_state().unwrap().join().unwrap(); + let mut replayed = + SubAgentManager::new(repo.path().to_path_buf(), 8).with_state_path(state_path); + replayed.load_state().expect("restart/replay"); + assert_eq!( + replayed.coordination.schema_version, + coord::COORDINATION_SCHEMA_VERSION + ); + assert_eq!(replayed.coordination.contentions.len(), 2); + assert_eq!(replayed.coordination.projections.len(), 2); + assert_eq!(replayed.coordination.reconciliations.len(), 1); + assert!( + replayed + .coordination + .write_claims + .iter() + .any(|claim| claim.claim.owner == agent_a) + ); + let sequences = replayed + .coordination + .decisions + .iter() + .map(|decision| decision.sequence) + .chain( + replayed + .coordination + .reconciliations + .iter() + .map(|receipt| receipt.sequence), + ) + .collect::>(); + assert!(sequences.windows(2).all(|window| window[0] < window[1])); + + assert_eq!( + git_stdout(repo.path(), &["show", "candidate-a:src/a.rs"]), + "pub const A: u8 = 1;" + ); + assert_eq!( + git_stdout(repo.path(), &["show", "candidate-b:src/b.rs"]), + "pub const B: u8 = 2;" + ); +} + fn init_subagent_git_repo() -> tempfile::TempDir { let dir = tempdir().expect("tempdir"); @@ -668,6 +1388,20 @@ fn git(repo: &Path, args: &[&str]) { ); } +fn git_stdout(repo: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(repo) + .output() + .expect("git command should run"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_string() +} + fn text_message(role: &str, text: &str) -> Message { Message { role: role.to_string(), @@ -1138,6 +1872,35 @@ fn implementer_prompt_is_not_forced_into_explorer_cap() { assert!(!prompt.contains("Default to `EFFORT: quick`")); } +#[test] +fn role_prompts_use_canonical_file_action_contract() { + let explore = SubAgentType::Explore.system_prompt(); + assert!( + explore.contains("`File` with actions `list`, `search_name`, `search_content`, and `read`") + ); + + let implementer = SubAgentType::Implementer.system_prompt(); + assert!(implementer.contains("`File` action `read`")); + assert!(implementer.contains("action `edit`")); + assert!(implementer.contains("action `patch`")); + + for prompt in [&explore, &implementer] { + for legacy_name in [ + "list_dir", + "file_search", + "grep_files", + "read_file", + "edit_file", + "apply_patch", + ] { + assert!( + !prompt.contains(legacy_name), + "live role prompt must not teach legacy File alias {legacy_name}: {prompt}" + ); + } + } +} + #[test] fn review_and_verifier_prompts_stop_after_decisive_evidence() { let review = SubAgentType::Review.system_prompt(); @@ -1247,9 +2010,109 @@ fn declared_write_authority_parses_and_worktree_write_requires_isolation() { "prompt": "write in a worktree", "write_authority": "worktree_write", "worktree": true, + "write_roots": ["."], })) .expect("worktree_write with isolation parses"); assert_eq!(ok.write_authority, Some(SpawnWriteAuthority::WorktreeWrite)); + + let custom_read_only = parse_spawn_request(&json!({ + "prompt": "run a narrow reader", + "type": "custom", + "allowed_tools": ["read_file"] + })) + .expect("custom without explicit write authority stays read-only"); + assert_eq!( + custom_read_only.write_authority, + Some(SpawnWriteAuthority::ReadOnly) + ); + + let custom_implicit_write = parse_spawn_request(&json!({ + "prompt": "ambiguous custom writer", + "type": "custom", + "allowed_tools": ["write_file"], + "write_roots": ["src"] + })) + .expect_err("custom scopes require deliberate write authority") + .to_string(); + assert!( + custom_implicit_write.contains("explicit"), + "{custom_implicit_write}" + ); + + let custom_writer = parse_spawn_request(&json!({ + "prompt": "bounded custom writer", + "type": "custom", + "allowed_tools": ["write_file"], + "write_authority": "workspace_write", + "write_roots": ["src"] + })) + .expect("explicit bounded custom write parses"); + assert!(spawn_request_is_write_capable(&custom_writer)); +} + +#[test] +fn prompt_only_general_children_default_read_only_instead_of_claiming_the_repo() { + let request = parse_spawn_request(&json!({ + "prompt": "inspect the subsystem", + })) + .expect("prompt-only child remains ergonomic"); + assert_eq!(request.write_authority, Some(SpawnWriteAuthority::ReadOnly)); + assert!(request.write_roots.is_empty()); + + let error = parse_spawn_request(&json!({ + "prompt": "edit without a claim", + "write_authority": "workspace_write", + })) + .expect_err("explicit write authority requires an explicit bounded claim") + .to_string(); + assert!(error.contains("must declare"), "{error}"); + + for explicit in [ + json!({"prompt": "implement", "type": "implementer"}), + json!({"prompt": "general but explicit", "type": "general"}), + ] { + let error = parse_spawn_request(&explicit) + .expect_err("explicit write-capable identity must not silently become read-only") + .to_string(); + assert!(error.contains("must declare"), "{error}"); + } + + // Fleet roles are classified only after the live roster resolves them. + // A manager profile still fails closed before spawn when it has no scope. + let roster = FleetRoster::built_ins_only(); + let mut fleet_role = + parse_spawn_request(&json!({"prompt": "fleet role", "role": "release_lead"})) + .expect("unresolved fleet role should parse"); + apply_spawn_profile(&mut fleet_role, &roster).expect("release lead should resolve"); + let error = validate_spawn_write_contract(&mut fleet_role, false) + .expect_err("resolved write-capable fleet role must declare a scope") + .to_string(); + assert!(error.contains("must declare"), "{error}"); +} + +#[test] +fn read_only_roles_reject_write_authority_but_implementers_can_be_narrowed() { + let reviewer = parse_spawn_request(&json!({ + "prompt": "review while writing", + "type": "review", + "write_authority": "workspace_write", + "write_roots": ["src"] + })) + .expect_err("read-only role cannot request writes") + .to_string(); + assert!(reviewer.contains("read-only role"), "{reviewer}"); + + let implementer = parse_spawn_request(&json!({ + "prompt": "implement without writes", + "type": "implementer", + "write_authority": "read_only" + })) + .expect("role identity may be narrowed to read-only authority"); + assert_eq!(implementer.agent_type, SubAgentType::Implementer); + assert_eq!( + implementer.write_authority, + Some(SpawnWriteAuthority::ReadOnly) + ); } #[test] @@ -1259,12 +2122,16 @@ fn declared_write_scope_is_normalized_and_rejects_traversal() { "write_authority": "workspace_write", "write_roots": ["./crates/tui/src/", "docs"], "exact_files": ["Cargo.toml"], - "coordination_contracts": ["public-api"] + "coordination_contracts": ["public-api"], + "dependencies": ["#4619", "#4619"], + "acceptance": ["locked tests pass"] })) .expect("bounded scope parses"); assert_eq!(request.write_roots, vec!["crates/tui/src", "docs"]); assert_eq!(request.exact_files, vec!["Cargo.toml"]); assert_eq!(request.coordination_contracts, vec!["public-api"]); + assert_eq!(request.dependencies, vec!["#4619"]); + assert_eq!(request.acceptance, vec!["locked tests pass"]); let err = parse_spawn_request(&json!({ "prompt": "escape", @@ -1278,6 +2145,170 @@ fn declared_write_scope_is_normalized_and_rejects_traversal() { ); } +#[test] +fn shared_child_cwd_claims_use_one_root_namespace_for_collisions_and_mutations() { + let repo = tempdir().expect("repo"); + let package = repo.path().join("pkg"); + std::fs::create_dir_all(package.join("src")).expect("package tree"); + std::fs::write( + package.join("Cargo.toml"), + "[package]\nname='pkg'\nversion='0.1.0'\n", + ) + .expect("package manifest"); + + let mut manager = SubAgentManager::new(repo.path().to_path_buf(), 4); + let root_owner = manager.insert_test_running_agent("root_writer", repo.path()); + let child_owner = manager.insert_test_running_agent("child_writer", &package); + let root_claim = manager + .namespace_write_claim( + repo.path(), + false, + WriteScopeClaim { + owner: root_owner.clone(), + roots: Vec::new(), + exact_files: vec!["pkg/Cargo.toml".into()], + contracts: Vec::new(), + }, + ) + .expect("root claim namespace"); + manager + .coordination + .register_claim(root_claim, false, |_| true) + .expect("root writer claim"); + let child_alias = manager + .namespace_write_claim( + &package, + false, + WriteScopeClaim { + owner: child_owner.clone(), + roots: Vec::new(), + exact_files: vec!["Cargo.toml".into()], + contracts: Vec::new(), + }, + ) + .expect("child claim namespace"); + assert_eq!(child_alias.exact_files, vec!["pkg/Cargo.toml"]); + let active = manager.active_coordination_owners(); + let collision = manager + .coordination + .register_claim(child_alias, false, |owner| active.contains(owner)) + .expect_err("root and child cwd aliases must collide"); + assert!(collision.contains(&root_owner), "{collision}"); + + let mut scoped = SubAgentManager::new(repo.path().to_path_buf(), 4); + let child_owner = scoped.insert_test_running_agent("scoped_child", &package); + let claim = scoped + .namespace_write_claim( + &package, + false, + WriteScopeClaim { + owner: child_owner.clone(), + roots: vec!["src".into()], + exact_files: Vec::new(), + contracts: Vec::new(), + }, + ) + .expect("scoped child claim namespace"); + assert_eq!(claim.roots, vec!["pkg/src"]); + scoped + .coordination + .register_claim(claim, false, |_| true) + .expect("scoped child claim"); + scoped + .validate_write_scope(&child_owner, &["src/lib.rs".into()]) + .expect("child-relative mutation resolves inside persisted root scope"); + let outside = scoped + .validate_write_scope(&child_owner, &["../other/lib.rs".into()]) + .expect_err("child cwd traversal must remain outside authority"); + assert!( + outside.contains("repo-relative") || outside.contains("traversal"), + "{outside}" + ); +} + +#[test] +fn resident_file_context_is_workspace_relative_bounded_and_exclusive() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join("context.txt"), "bounded context").unwrap(); + let context = ToolContext::new(tmp.path()); + + let resident = read_bounded_resident_context(&context, "context.txt") + .expect("regular in-workspace resident context"); + assert_eq!(resident.display_path, "context.txt"); + assert_eq!(resident.contents, "bounded context"); + assert_eq!( + resident.lease_key, + tmp.path() + .join("context.txt") + .canonicalize() + .expect("canonical test resident") + .display() + .to_string() + ); + + let absolute = read_bounded_resident_context( + &context, + &tmp.path().join("context.txt").display().to_string(), + ) + .expect_err("absolute resident paths must fail closed") + .to_string(); + assert!(absolute.contains("repo-relative"), "{absolute}"); + let traversal = read_bounded_resident_context(&context, "../context.txt") + .expect_err("resident traversal must fail closed") + .to_string(); + assert!( + traversal.contains("repo-relative") || traversal.contains("parent traversal"), + "{traversal}" + ); + + std::fs::write( + tmp.path().join("oversize.txt"), + vec![b'x'; usize::try_from(MAX_RESIDENT_CONTEXT_BYTES + 1).unwrap()], + ) + .unwrap(); + let oversize = read_bounded_resident_context(&context, "oversize.txt") + .expect_err("oversize resident context must fail closed") + .to_string(); + assert!(oversize.contains("bounded context limit"), "{oversize}"); + + let lease_key = format!("resident-test-{}", uuid::Uuid::new_v4()); + reserve_resident_lease(&lease_key, "context.txt").expect("first resident owner reserves"); + let duplicate = reserve_resident_lease(&lease_key, "context.txt") + .expect_err("a second resident owner must be rejected") + .to_string(); + assert!(duplicate.contains("already leased"), "{duplicate}"); + rollback_pending_resident_lease(&lease_key); + reserve_resident_lease(&lease_key, "context.txt").expect("rollback releases pending lease"); + rollback_pending_resident_lease(&lease_key); + + let other_workspace = tempdir().expect("second workspace"); + std::fs::write(other_workspace.path().join("context.txt"), "other context").unwrap(); + let other = + read_bounded_resident_context(&ToolContext::new(other_workspace.path()), "context.txt") + .expect("same relative path in another workspace"); + assert_ne!(resident.lease_key, other.lease_key); + reserve_resident_lease(&resident.lease_key, &resident.display_path) + .expect("first workspace lease"); + reserve_resident_lease(&other.lease_key, &other.display_path) + .expect("unrelated workspace must not falsely collide"); + rollback_pending_resident_lease(&resident.lease_key); + rollback_pending_resident_lease(&other.lease_key); +} + +#[cfg(unix)] +#[test] +fn resident_file_context_rejects_symlink_aliases() { + use std::os::unix::fs::symlink; + + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join("target.txt"), "secret alias").unwrap(); + symlink("target.txt", tmp.path().join("alias.txt")).unwrap(); + let error = read_bounded_resident_context(&ToolContext::new(tmp.path()), "alias.txt") + .expect_err("resident context must not traverse symlinks") + .to_string(); + assert!(error.contains("must not traverse symlinks"), "{error}"); +} + #[test] fn new_session_tools_use_single_agent_name() { let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 1))); @@ -1402,10 +2433,13 @@ fn explore_subagent_inherits_active_model_by_default() { fn non_explore_subagents_keep_default_same_model_strength() { // Non-explore roles keep the conservative Same default even with no model. for role in ["general", "plan", "review", "implementer"] { - let input = json!({ + let mut input = json!({ "prompt": "do some work", "type": role }); + if matches!(role, "general" | "implementer") { + input["write_roots"] = json!(["."]); + } let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert_eq!( parsed.model_strength, @@ -1912,169 +2946,48 @@ async fn interrupted_projection_exposes_checkpoint_metadata_and_messages() { assert!(!projection.timed_out_with_checkpoint); assert_eq!( projection - .checkpoint - .as_ref() - .expect("checkpoint projected") - .continuation_handle, - checkpoint.continuation_handle - ); - assert_eq!( - projection - .snapshot - .checkpoint - .as_ref() - .map(|cp| cp.message_count), - Some(1) - ); - assert_eq!( - projection - .checkpoint - .as_ref() - .and_then(|cp| cp.messages.first()) - .map(message_text), - Some("inspect checkpoint recovery") - ); - - let timed_out_projection = - subagent_session_projection(projection.snapshot.clone(), true, &ctx, None).await; - assert!(timed_out_projection.needs_continuation); - assert!(timed_out_projection.timed_out); - assert!(timed_out_projection.timed_out_with_checkpoint); -} - -#[test] -fn test_delegate_defaults_to_fork_context() { - let input = with_default_fork_context(json!({ "prompt": "review current work" }), true); - let parsed = parse_spawn_request(&input).expect("delegate request should parse"); - assert_eq!(parsed.fork_context, Some(true)); - - let input = with_default_fork_context( - json!({ "prompt": "fresh exploration", "fork_context": false }), - true, - ); - let parsed = parse_spawn_request(&input).expect("delegate override should parse"); - assert_eq!(parsed.fork_context, Some(false)); -} - -#[test] -fn auto_fork_context_default_forks_only_same_route_read_only_engine_children() { - let mut runtime = stub_runtime(); - let provider = runtime.client.api_provider(); - let model = runtime.model.clone(); - - // No captured parent snapshot: never auto-fork. - assert!(!auto_fork_context_default( - &SubAgentType::Explore, - None, - false, - false, - &runtime, - provider, - &model - )); - - runtime.fork_context = Some(SubAgentForkContext { - messages: Vec::new(), - structured_state_block: None, - }); - - // Read-only postures on the parent route in the parent workspace fork. - for agent_type in [ - SubAgentType::Explore, - SubAgentType::Plan, - SubAgentType::Review, - SubAgentType::Verifier, - ] { - assert!( - auto_fork_context_default(&agent_type, None, false, false, &runtime, provider, &model), - "{agent_type:?} should auto-fork" - ); - } - // Explicit read_only authority upgrades a General child. - assert!(auto_fork_context_default( - &SubAgentType::General, - Some(SpawnWriteAuthority::ReadOnly), - false, - false, - &runtime, - provider, - &model - )); - // Write-capable children stay fresh. - for agent_type in [SubAgentType::General, SubAgentType::Implementer] { - assert!( - !auto_fork_context_default(&agent_type, None, false, false, &runtime, provider, &model), - "{agent_type:?} must stay fresh" - ); - } - // Worktree or cwd isolation stays fresh. - assert!(!auto_fork_context_default( - &SubAgentType::Explore, - None, - true, - false, - &runtime, - provider, - &model - )); - assert!(!auto_fork_context_default( - &SubAgentType::Explore, - None, - false, - true, - &runtime, - provider, - &model - )); - // A different model route pays a cold prefill of the parent context. - assert!(!auto_fork_context_default( - &SubAgentType::Explore, - None, - false, - false, - &runtime, - provider, - "some-other-model" - )); - // A huge parent prefix stays fresh: cached fork reads on every child - // step would cost more than a fresh brief. - let huge_text = "token ".repeat(400_000); - runtime.fork_context = Some(SubAgentForkContext { - messages: vec![crate::models::Message { - role: "user".to_string(), - content: vec![crate::models::ContentBlock::Text { - text: huge_text, - cache_control: None, - }], - }], - structured_state_block: None, - }); - assert!(!auto_fork_context_default( - &SubAgentType::Explore, - None, - false, - false, - &runtime, - provider, - &model - )); - runtime.fork_context = Some(SubAgentForkContext { - messages: Vec::new(), - structured_state_block: None, - }); + .checkpoint + .as_ref() + .expect("checkpoint projected") + .continuation_handle, + checkpoint.continuation_handle + ); + assert_eq!( + projection + .snapshot + .checkpoint + .as_ref() + .map(|cp| cp.message_count), + Some(1) + ); + assert_eq!( + projection + .checkpoint + .as_ref() + .and_then(|cp| cp.messages.first()) + .map(message_text), + Some("inspect checkpoint recovery") + ); - // Nested spawners stay fresh — their snapshot is the root prefix, not - // their own conversation. - runtime.parent_agent_id = Some("agent_parent".to_string()); - assert!(!auto_fork_context_default( - &SubAgentType::Explore, - None, - false, - false, - &runtime, - provider, - &model - )); + let timed_out_projection = + subagent_session_projection(projection.snapshot.clone(), true, &ctx, None).await; + assert!(timed_out_projection.needs_continuation); + assert!(timed_out_projection.timed_out); + assert!(timed_out_projection.timed_out_with_checkpoint); +} + +#[test] +fn test_delegate_defaults_to_fork_context() { + let input = with_default_fork_context(json!({ "prompt": "review current work" }), true); + let parsed = parse_spawn_request(&input).expect("delegate request should parse"); + assert_eq!(parsed.fork_context, Some(true)); + + let input = with_default_fork_context( + json!({ "prompt": "fresh exploration", "fork_context": false }), + true, + ); + let parsed = parse_spawn_request(&input).expect("delegate override should parse"); + assert_eq!(parsed.fork_context, Some(false)); } #[test] @@ -2248,7 +3161,16 @@ fn test_parse_spawn_request_accepts_full_role_vocabulary() { "normalize_role_alias should accept role alias {role:?}" ); - let input = json!({ "prompt": "do work", "role": role }); + let mut input = json!({ "prompt": "do work", "role": role }); + if matches!( + &expected_type, + SubAgentType::General | SubAgentType::Implementer + ) { + input["write_roots"] = json!(["."]); + } else if expected_type == SubAgentType::Custom { + input["write_authority"] = json!("workspace_write"); + input["write_roots"] = json!(["."]); + } let mut parsed = parse_spawn_request(&input) .unwrap_or_else(|e| panic!("role {role:?} should parse, got {e}")); assert_eq!(parsed.agent_type, expected_type, "type for role {role:?}"); @@ -2275,7 +3197,11 @@ fn test_invalid_role_error_lists_real_aliases() { // Well-formed fleet role tokens parse and then fail clearly at roster // resolution time with both real roster members and type aliases (#4177). let roster = FleetRoster::built_ins_only(); - let input = json!({ "prompt": "do work", "role": "nonsense" }); + let input = json!({ + "prompt": "do work", + "role": "nonsense", + "write_roots": ["."] + }); let mut request = parse_spawn_request(&input).expect("fleet role token should parse"); let err = apply_spawn_profile(&mut request, &roster) .expect_err("unknown fleet role should fail at runtime resolution") @@ -2359,7 +3285,8 @@ fn agent_tool_prompt_schema_keeps_ordinary_starts_message_first() { let agent_schema = AgentTool::new(manager, stub_runtime()).input_schema(); let prompt = schema_property_description(&agent_schema, "prompt"); assert!(prompt.contains("focused task")); - assert!(prompt.contains("only field needed")); + assert!(prompt.contains("read-only role needs no write scope")); + assert!(prompt.contains("write-capable role must also declare a bounded write scope")); for ceremony in [ "Subagent Brief", "QUESTION", @@ -2412,8 +3339,13 @@ async fn agent_tool_status_returns_running_child_projection() { let mut manager_guard = manager.write().await; manager_guard.agents.insert(agent_id.clone(), agent); manager_guard.register_worker(make_worker_spec(&agent_id, tmp.path().to_path_buf())); - manager_guard - .record_worker_progress(&agent_id, "step 1: requesting model response".to_string()); + manager_guard.record_worker_event( + &agent_id, + AgentWorkerStatus::ModelWait, + Some("step 1: requesting model response".to_string()), + Some(1), + None, + ); } let tool = AgentTool::new(Arc::clone(&manager), stub_runtime()); @@ -2683,7 +3615,10 @@ async fn coordination_interrupt_fans_in_once_and_preserves_checkpoint() { runtime.event_tx = Some(event_tx); agent.terminal_delivery = Some(SubAgentTerminalDeliveryContext::from_runtime(&runtime)); manager.agents.insert(agent_id.clone(), agent); - manager.register_worker(make_worker_spec(&agent_id, tmp.path().to_path_buf())); + manager.register_worker(make_worker_spec("agent_parent", tmp.path().to_path_buf())); + let mut child_spec = make_worker_spec(&agent_id, tmp.path().to_path_buf()); + child_spec.parent_run_id = Some("agent_parent".to_string()); + manager.register_worker(child_spec); manager.record_worker_event( &agent_id, AgentWorkerStatus::RunningTool, @@ -2993,8 +3928,8 @@ fn role_posture_blocks_writes_and_shell_for_read_only_roles() { ); } - // Custom is governed by its explicit allowed_tools list, so the posture - // check permits it (the allowlist is the authority for that role). + // Custom passes the role-only check; its explicit allowlist, bounded write + // authority, and parent-intersected runtime profile are enforced together. assert!(role_posture_permits( &SubAgentType::Custom, ApprovalRequirement::Suggest @@ -3443,11 +4378,14 @@ fn explore_catalog_inherits_web_but_hides_write_shell_and_fim_tools() { crate::tools::plan::new_shared_plan_state(), ); - let names = tool_names(registry.tools_for_model(&SubAgentType::Explore)); - for name in ["web_search", "fetch_url", "web.run", "wait_for_dev_server"] { + let tools = registry.tools_for_model(&SubAgentType::Explore); + let names = tool_names(tools.clone()); + for name in ["File", "Git", "Web", "web.run"] { assert!(names.contains(name), "Explore should inherit {name}"); } for name in [ + "Bash", + "Run", "write_file", "edit_file", "apply_patch", @@ -3457,6 +4395,11 @@ fn explore_catalog_inherits_web_but_hides_write_shell_and_fim_tools() { ] { assert!(!names.contains(name), "Explore must hide {name}"); } + let file = tools.iter().find(|tool| tool.name == "File").unwrap(); + assert_eq!( + file.input_schema["properties"]["action"]["enum"], + json!(["read", "list", "search_name", "search_content"]) + ); } #[test] @@ -3473,13 +4416,21 @@ fn implementer_catalog_inherits_patch_and_fim_when_enabled() { crate::tools::plan::new_shared_plan_state(), ); - let names = tool_names(registry.tools_for_model(&SubAgentType::Implementer)); - for name in ["apply_patch", "fim_edit", "write_file", "edit_file"] { + let tools = registry.tools_for_model(&SubAgentType::Implementer); + let names = tool_names(tools.clone()); + for name in ["File", "fim_edit"] { assert!( names.contains(name), "Implementer should inherit write-capable tool {name}" ); } + let file = tools.iter().find(|tool| tool.name == "File").unwrap(); + let actions = file.input_schema["properties"]["action"]["enum"] + .as_array() + .unwrap(); + for action in ["write", "edit", "patch"] { + assert!(actions.iter().any(|value| value.as_str() == Some(action))); + } } #[tokio::test] @@ -4006,6 +4957,129 @@ async fn shared_write_claim_is_registered_before_parallel_launch_and_manifested( assert_eq!(manifest.expected_artifact.as_deref(), Some("tested patch")); } +#[tokio::test] +async fn write_capable_agent_does_not_launch_when_durable_registration_fails() { + let tmp = tempdir().expect("tempdir"); + let blocked_state_path = tmp.path().join("blocked-state.json"); + std::fs::create_dir(&blocked_state_path).expect("directory blocks atomic state rename"); + let manager = Arc::new(RwLock::new( + SubAgentManager::new(tmp.path().to_path_buf(), 4) + .with_state_path(blocked_state_path.clone()), + )); + let mut runtime = stub_runtime(); + runtime.manager = Arc::clone(&manager); + runtime.context = ToolContext::new(tmp.path()); + + let error = manager + .write() + .await + .spawn_background_with_assignment_options( + Arc::clone(&manager), + runtime, + SubAgentType::Implementer, + "must never execute".into(), + make_assignment(), + Some(vec![]), + SubAgentSpawnOptions { + name: Some("durable-writer".into()), + write_claim: Some(WriteScopeClaim { + owner: String::new(), + roots: vec!["src".into()], + exact_files: Vec::new(), + contracts: Vec::new(), + }), + ..Default::default() + }, + ) + .expect_err("writer must fail before spawn when its durable claim cannot commit") + .to_string(); + assert!(error.contains("durably register"), "{error}"); + + let guard = manager.read().await; + assert!(guard.agents.is_empty(), "no child task was admitted"); + assert!( + guard.list_worker_records().is_empty(), + "failed durable registration rolls back worker identity" + ); + assert!( + guard.coordination_snapshot().write_claims.is_empty(), + "failed durable registration rolls back write ownership" + ); + assert!(blocked_state_path.is_dir()); +} + +#[tokio::test] +async fn write_scope_contention_covers_regular_agent_and_active_fleet_writer() { + let tmp = tempdir().expect("tempdir"); + let mut inner = SubAgentManager::new(tmp.path().to_path_buf(), 4); + inner + .register_worker_with_coordination(make_write_worker_spec( + "fleet-writer", + tmp.path().to_path_buf(), + "src/shared", + )) + .expect("active Fleet writer claim"); + + let regular_id = inner.insert_test_running_agent("regular", tmp.path()); + inner + .coordination + .register_claim( + WriteScopeClaim { + owner: regular_id.clone(), + roots: vec!["docs".into()], + exact_files: Vec::new(), + contracts: Vec::new(), + }, + false, + |_| false, + ) + .expect("regular agent initial claim"); + let expansion = inner + .expand_write_claim( + ®ular_id, + vec!["src/shared/api".into()], + Vec::new(), + Vec::new(), + ) + .expect_err("regular-agent scope expansion must see active Fleet ownership"); + assert!( + expansion.contains("fleet-writer") && expansion.contains("contention"), + "{expansion}" + ); + + let manager = Arc::new(RwLock::new(inner)); + let mut runtime = stub_runtime(); + runtime.manager = Arc::clone(&manager); + runtime.context = ToolContext::new(tmp.path()); + let launch = manager + .write() + .await + .spawn_background_with_assignment_options( + Arc::clone(&manager), + runtime, + SubAgentType::Implementer, + "edit Fleet-owned scope".into(), + make_assignment(), + Some(vec![]), + SubAgentSpawnOptions { + name: Some("regular-writer".into()), + write_claim: Some(WriteScopeClaim { + owner: String::new(), + roots: vec!["src/shared".into()], + exact_files: Vec::new(), + contracts: Vec::new(), + }), + ..Default::default() + }, + ) + .expect_err("regular-agent launch must see active Fleet ownership"); + let launch = launch.to_string(); + assert!( + launch.contains("fleet-writer") && launch.contains("contention"), + "{launch}" + ); +} + #[tokio::test] async fn test_running_count_counts_only_agents_with_live_task_handles() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); @@ -5723,6 +6797,52 @@ async fn subagent_registry_blocks_approval_tools_without_parent_auto_approve() { ); } +#[tokio::test] +async fn prompt_only_general_cannot_mutate_under_parent_auto_approve() { + let tmp = tempdir().expect("tempdir"); + let request = parse_spawn_request(&json!({"prompt": "inspect only"})).unwrap(); + let mut runtime = stub_runtime(); + runtime.context = ToolContext::new(tmp.path().to_path_buf()); + runtime.context.auto_approve = true; + apply_spawn_write_authority(&mut runtime, &request); + runtime.worker_profile = worker_profile_for_spawn( + &runtime, + &request.agent_type, + &AgentWorkerToolProfile::Inherited, + "deepseek-v4-pro", + None, + false, + ); + let registry = SubAgentToolRegistry::new( + runtime, + request.agent_type, + None, + Arc::new(Mutex::new(TodoList::new())), + Arc::new(Mutex::new(PlanState::default())), + ); + + let write_error = registry + .execute( + "agent_test", + "write_file", + json!({"path": "forbidden.txt", "content": "no"}), + ) + .await + .expect_err("read-only General must not write under auto approval"); + assert!(write_error.to_string().contains("not permitted")); + let shell_error = registry + .execute( + "agent_test", + "exec_shell", + json!({"command": "touch shell.txt"}), + ) + .await + .expect_err("read-only General must not receive mutating shell"); + assert!(shell_error.to_string().contains("not permitted")); + assert!(!tmp.path().join("forbidden.txt").exists()); + assert!(!tmp.path().join("shell.txt").exists()); +} + const MCP_ACTION_TOOL: &str = "mcp_github_create_pull_request"; fn subagent_registry_with_mcp_action(auto_approve: bool) -> SubAgentToolRegistry { @@ -5747,6 +6867,7 @@ fn subagent_registry_with_mcp_action(auto_approve: bool) -> SubAgentToolRegistry async fn child_write_tool_fails_closed_outside_registered_scope() { let tmp = tempdir().expect("tempdir"); std::fs::create_dir_all(tmp.path().join("src")).unwrap(); + std::fs::create_dir_all(tmp.path().join("outside")).unwrap(); let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 2); { let mut guard = manager.write().await; @@ -5763,6 +6884,13 @@ async fn child_write_tool_fails_closed_outside_registered_scope() { |_| false, ) .unwrap(); + guard + .register_worker_with_coordination(make_write_worker_spec( + "other-writer", + tmp.path().to_path_buf(), + "conflict", + )) + .expect("active conflicting writer"); } let mut runtime = stub_runtime(); runtime.manager = Arc::clone(&manager); @@ -5774,7 +6902,15 @@ async fn child_write_tool_fails_closed_outside_registered_scope() { SubAgentType::Implementer, "agent_scoped".into(), "implementer".into(), - Some(vec!["write_file".into(), "exec_shell".into()]), + Some(vec![ + "write_file".into(), + "exec_shell".into(), + "Bash".into(), + "Run".into(), + "agents/coordinate".into(), + "work_update".into(), + "agent".into(), + ]), Arc::new(Mutex::new(TodoList::new())), Arc::new(Mutex::new(PlanState::default())), ); @@ -5786,6 +6922,81 @@ async fn child_write_tool_fails_closed_outside_registered_scope() { ) .await .expect("in-scope write"); + registry + .execute( + "agent_scoped", + "agents/coordinate", + json!({"action": "claim", "roots": ["docs/guides"]}), + ) + .await + .expect("coordination-only non-overlapping scope expansion stays available"); + let claim_collision = registry + .execute( + "agent_scoped", + "agents/coordinate", + json!({"action": "claim", "roots": ["conflict/nested"]}), + ) + .await + .expect_err("coordination scope expansion still rejects live overlap") + .to_string(); + assert!( + claim_collision.contains("contention") && claim_collision.contains("other-writer"), + "{claim_collision}" + ); + registry + .execute( + "agent_scoped", + "work_update", + json!({ + "todos": [{"content": "bounded edit", "status": "in_progress"}] + }), + ) + .await + .expect("shared writers can still publish internal Work progress"); + let child_collision = registry + .execute( + "agent_scoped", + "agent", + json!({ + "prompt": "edit the same scope", + "type": "implementer", + "workspace_policy": "shared", + "write_authority": "workspace_write", + "write_roots": ["conflict"], + "expected_artifact": "tested patch" + }), + ) + .await + .expect_err("agent exemption still subjects child writers to coordination") + .to_string(); + assert!( + child_collision.contains("contention") && child_collision.contains("other-writer"), + "{child_collision}" + ); + registry + .execute( + "agent_scoped", + "agent", + json!({ + "prompt": "inspect without mutation", + "write_authority": "read_only" + }), + ) + .await + .expect("shared writers may still delegate a read-only child"); + let spawned_id = manager + .read() + .await + .agents + .keys() + .next() + .cloned() + .expect("read-only child registered"); + manager + .write() + .await + .cancel_agent(&spawned_id) + .expect("stop test child"); let err = registry .execute( "agent_scoped", @@ -5800,20 +7011,72 @@ async fn child_write_tool_fails_closed_outside_registered_scope() { "{err}" ); assert!(!tmp.path().join("docs/no.txt").exists()); - let shell_err = registry + for (tool_name, input, target) in [ + ( + "exec_shell", + json!({"command": "touch outside/compat.txt"}), + "outside/compat.txt", + ), + ( + "Bash", + json!({"action": "run", "command": "touch outside/canonical.txt"}), + "outside/canonical.txt", + ), + ] { + let shell_err = registry + .execute("agent_scoped", tool_name, input) + .await + .expect_err("unbounded shared-workspace shell must fail") + .to_string(); + assert!( + shell_err.contains("cannot prove a bounded file target"), + "{tool_name}: {shell_err}" + ); + assert!( + !tmp.path().join(target).exists(), + "{tool_name} created {target} outside its registered claim" + ); + } + let run_err = registry .execute( "agent_scoped", - "exec_shell", - json!({"command": "touch docs/shell.txt"}), + "Run", + json!({ + "action": "verifiers", + "commands": [{ + "name": "escape", + "program": "/bin/sh", + "args": ["-c", "touch docs/run-escape.txt"] + }] + }), ) .await - .expect_err("unbounded shared-workspace shell must fail") + .expect_err("custom verifier commands cannot bypass shared write ownership") .to_string(); assert!( - shell_err.contains("cannot prove a bounded file target"), - "{shell_err}" + run_err.contains("cannot prove a bounded file target"), + "{run_err}" ); - assert!(!tmp.path().join("docs/shell.txt").exists()); + assert!(!tmp.path().join("docs/run-escape.txt").exists()); +} + +#[test] +fn shared_claim_shell_gate_normalizes_only_the_run_action() { + assert!(is_unbounded_shell_run( + "exec_shell", + &json!({"command": "true"}) + )); + assert!(is_unbounded_shell_run( + "Bash", + &json!({"action": "run", "command": "true"}) + )); + assert!(is_unbounded_shell_run("Bash", &json!({"command": "true"}))); + for action in ["wait", "interact", "cancel"] { + assert!( + !is_unbounded_shell_run("Bash", &json!({"action": action})), + "Bash.{action} must retain its existing non-run claim behavior" + ); + } } #[tokio::test] @@ -6019,11 +7282,22 @@ async fn explore_role_blocks_writes_even_under_parent_auto_approve() { Arc::new(Mutex::new(PlanState::default())), ); + std::fs::write(tmp.path().join("allowed.txt"), "visible").unwrap(); + let read = registry + .execute( + "agent_test", + "File", + json!({"action": "read", "path": "allowed.txt"}), + ) + .await + .expect("Explore should retain canonical read access"); + assert!(read.contains("visible")); + let err = registry .execute( "agent_test", - "write_file", - json!({"path": "nope.txt", "content": "denied"}), + "File", + json!({"action": "write", "path": "nope.txt", "content": "denied"}), ) .await .expect_err("explore must not write even under parent auto-approve"); @@ -6877,6 +8151,7 @@ fn cross_custom_child_rebinds_config_receipts_and_grandchild_route_atomically() &AgentWorkerToolProfile::Inherited, "model-b", None, + false, ); assert_eq!(worker_profile.provider.as_deref(), Some("custom-b")); @@ -9401,7 +10676,10 @@ fn write_json_atomic_survives_concurrent_writers() { let ws = Arc::clone(&workspace); let p = Arc::clone(&path); handles.push(std::thread::spawn(move || { - let payload = serde_json::json!({ "writer": i, "blob": "x".repeat(8192) }); + let payload = PersistedSubAgentState { + snapshot_sequence: i + 1, + ..PersistedSubAgentState::default() + }; let _ = write_json_atomic(&ws, &p, &payload); })); } @@ -9412,7 +10690,7 @@ fn write_json_atomic_survives_concurrent_writers() { let contents = std::fs::read_to_string(&*path).expect("read state.json"); let parsed: serde_json::Value = serde_json::from_str(&contents).expect("state.json must be complete/valid JSON"); - assert!(parsed.get("writer").is_some()); + assert!(parsed.get("snapshot_sequence").is_some()); // No stray temp files left behind. let leftover: Vec<_> = std::fs::read_dir(path.parent().unwrap()) .expect("read subagents dir") @@ -9422,6 +10700,98 @@ fn write_json_atomic_survives_concurrent_writers() { assert!(leftover.is_empty(), "temp files leaked: {leftover:?}"); } +#[test] +fn coordination_process_lock_rejects_second_process() { + const ROLE_ENV: &str = "CODEWHALE_TEST_COORDINATION_LOCK_ROLE"; + const WORKSPACE_ENV: &str = "CODEWHALE_TEST_COORDINATION_LOCK_WORKSPACE"; + const TEST_NAME: &str = + "tools::subagent::tests::coordination_process_lock_rejects_second_process"; + + if let Some(role) = std::env::var_os(ROLE_ENV) { + let workspace = PathBuf::from(std::env::var_os(WORKSPACE_ENV).expect("workspace env")); + let manager = new_shared_subagent_manager_with_timeout( + workspace.clone(), + 4, + 4, + Duration::from_secs(30), + 4, + None, + ); + if role == "holder" { + manager + .try_read() + .unwrap() + .ensure_coordination_process_lock() + .expect("holder owns lock"); + std::fs::write(workspace.join("holder.ready"), b"ready").unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while !workspace.join("holder.release").exists() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!(workspace.join("holder.release").exists(), "release timeout"); + } else { + let error = manager + .try_read() + .unwrap() + .ensure_coordination_process_lock() + .expect_err("second process must fail closed"); + assert!(error.contains("another Codewhale process"), "{error}"); + } + return; + } + + let dir = tempdir().expect("tempdir"); + let workspace = dir.path().canonicalize().expect("canonical workspace"); + let test_binary = std::env::current_exe().expect("test binary"); + let mut holder = std::process::Command::new(&test_binary) + .arg("--exact") + .arg(TEST_NAME) + .arg("--nocapture") + .env(ROLE_ENV, "holder") + .env(WORKSPACE_ENV, &workspace) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn lock holder"); + let deadline = Instant::now() + Duration::from_secs(5); + while !workspace.join("holder.ready").exists() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + if !workspace.join("holder.ready").exists() { + let _ = holder.kill(); + let output = holder.wait_with_output().expect("holder output"); + panic!( + "holder never acquired lock:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + let contender = std::process::Command::new(&test_binary) + .arg("--exact") + .arg(TEST_NAME) + .arg("--nocapture") + .env(ROLE_ENV, "contender") + .env(WORKSPACE_ENV, &workspace) + .output() + .expect("spawn lock contender"); + assert!( + contender.status.success(), + "contender failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&contender.stdout), + String::from_utf8_lossy(&contender.stderr) + ); + + std::fs::write(workspace.join("holder.release"), b"release").unwrap(); + let output = holder.wait_with_output().expect("holder output"); + assert!( + output.status.success(), + "holder failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + // === agent(action="wait") + peek throttling (#4097) === fn insert_running_agent(inner: &mut SubAgentManager, name: &str) -> String { @@ -9655,10 +11025,19 @@ fn test_disallowed_tools_inheritance_denies_tool() { ); let tools = registry.tools_for_model(&SubAgentType::General); - let names: HashSet<_> = tools.into_iter().map(|t| t.name).collect(); + let names: HashSet<_> = tools.iter().map(|t| t.name.clone()).collect(); assert!(!names.contains("exec_shell"), "catalog excludes exec_shell"); assert!(!names.contains("write_file"), "catalog excludes write_file"); - assert!(names.contains("read_file"), "catalog includes read_file"); + assert!(names.contains("File"), "catalog includes canonical File"); + let file = tools.into_iter().find(|tool| tool.name == "File").unwrap(); + let actions = file.input_schema["properties"]["action"]["enum"] + .as_array() + .unwrap(); + assert!( + !actions + .iter() + .any(|action| action.as_str() == Some("write")) + ); } #[test] @@ -9682,12 +11061,16 @@ fn test_disallowed_tools_deny_wins_over_allow() { ); let tools = registry.tools_for_model(&SubAgentType::General); - let names: HashSet<_> = tools.into_iter().map(|t| t.name).collect(); + let names: HashSet<_> = tools.iter().map(|t| t.name.clone()).collect(); assert!( !names.contains("exec_shell"), "catalog must exclude denied tool even when allowlisted" ); - assert!(names.contains("read_file"), "catalog includes allowed tool"); + assert!( + names.contains("File"), + "legacy read allow exposes canonical File" + ); + assert!(!names.contains("Bash"), "denied shell alias removes Bash"); } #[test] @@ -9761,7 +11144,7 @@ fn test_disallowed_tools_tools_for_model_excludes_denied() { let registry = new_registry_with_disallowed(runtime, None); let tools = registry.tools_for_model(&SubAgentType::General); - let names: HashSet<_> = tools.into_iter().map(|t| t.name).collect(); + let names: HashSet<_> = tools.iter().map(|t| t.name.clone()).collect(); assert!(!names.contains("exec_shell"), "catalog excludes exec_shell"); assert!(!names.contains("write_file"), "catalog excludes write_file"); @@ -9769,8 +11152,14 @@ fn test_disallowed_tools_tools_for_model_excludes_denied() { !names.contains("apply_patch"), "catalog excludes apply_patch" ); - assert!(names.contains("read_file"), "catalog includes read_file"); - assert!(names.contains("grep_files"), "catalog includes grep_files"); + assert!(names.contains("File"), "catalog includes canonical File"); + let file = tools.into_iter().find(|tool| tool.name == "File").unwrap(); + let actions = file.input_schema["properties"]["action"]["enum"] + .as_array() + .unwrap(); + for denied in ["write", "patch"] { + assert!(!actions.iter().any(|action| action.as_str() == Some(denied))); + } } #[tokio::test] diff --git a/crates/tui/src/tools/test_runner.rs b/crates/tui/src/tools/test_runner.rs index bd4e2f3fbb..9304f28690 100644 --- a/crates/tui/src/tools/test_runner.rs +++ b/crates/tui/src/tools/test_runner.rs @@ -37,6 +37,10 @@ impl ToolSpec for RunTestsTool { "run_tests" } + fn model_visible(&self) -> bool { + false + } + fn description(&self) -> &'static str { "Run `cargo test` in the workspace root with optional extra arguments." } diff --git a/crates/tui/src/tools/tool_result_retrieval.rs b/crates/tui/src/tools/tool_result_retrieval.rs index 95392d43b5..f93076d3f2 100644 --- a/crates/tui/src/tools/tool_result_retrieval.rs +++ b/crates/tui/src/tools/tool_result_retrieval.rs @@ -1,10 +1,9 @@ //! `retrieve_tool_result` - selective retrieval for spilled tool outputs. //! -//! Large successful tool results are spilled to -//! `~/.codewhale/tool_outputs/.txt` by `tools::truncate`. This -//! tool gives the model a read-only, directory-scoped way to fetch summaries or -//! slices of those historical outputs without replaying the entire file into -//! every subsequent request. +//! Exact tool evidence is retained under its origin session. Historical +//! payloads in the global `tool_outputs/` compatibility directory are readable +//! only when a digest-bound ownership sidecar proves they belong to the active +//! session. use std::fs; use std::path::PathBuf; @@ -36,7 +35,7 @@ impl ToolSpec for RetrieveToolResultTool { } fn description(&self) -> &'static str { - "Inspect retained tool evidence with strict session ownership and bounds. Accepts a tool_call_id, artifact id, legacy SHA reference, or validated session-relative path. Modes: metadata, summary, head, tail, lines, query, bytes. bytes returns a bounded base64 slice for exact text or binary recovery." + "Inspect retained tool evidence with strict session ownership and bounds. Accepts an artifact id, validated session-relative path, or an ownership-proven legacy call/SHA reference. Unowned legacy-global evidence fails closed. Modes: metadata, summary, head, tail, lines, query, bytes. bytes returns a bounded base64 slice for exact text or binary recovery." } fn input_schema(&self) -> Value { @@ -45,7 +44,7 @@ impl ToolSpec for RetrieveToolResultTool { "properties": { "ref": { "type": "string", - "description": "Tool call id, artifact id (`art_`), SHA ref (`sha:<64-hex>`), spillover filename, or absolute path under ~/.codewhale." + "description": "Session-owned artifact id (`art_`) or validated artifact-relative path. Legacy call-id/SHA references work only when origin-session ownership was recorded." }, "mode": { "type": "string", @@ -127,13 +126,31 @@ impl ToolSpec for RetrieveToolResultTool { 1, HARD_MAX_BYTES, ); - let path = resolve_spillover_reference(reference, &context.state_namespace)?; - let bytes = fs::read(&path).map_err(|_| { + let resolved = resolve_spillover_reference(reference, &context.state_namespace)?; + let legacy_ownership = if resolved.kind == ResolvedReferenceKind::LegacyGlobal { + Some(authorize_legacy_spillover( + &resolved.path, + &context.state_namespace, + )?) + } else { + None + }; + let bytes = fs::read(&resolved.path).map_err(|_| { ToolError::execution_failed("evidence is missing or no longer retained") })?; + if let Some(ownership) = legacy_ownership { + let size = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + if ownership.size_bytes != size + || ownership.digest != crate::hashing::sha256_hex(&bytes) + { + return Err(ToolError::execution_failed( + "legacy evidence content is corrupt", + )); + } + } let evidence = validate_evidence_if_present( reference, - &path, + &resolved.path, &bytes, &context.state_namespace, &input, @@ -177,13 +194,11 @@ impl ToolSpec for RetrieveToolResultTool { let lines: Vec<&str> = content.lines().collect(); let payload = match mode.as_str() { - "summary" => { - build_summary_payload(reference, &path, &content, &lines, &input, max_bytes) - } - "head" => build_head_tail_payload(reference, &path, "head", &lines, &input, max_bytes), - "tail" => build_head_tail_payload(reference, &path, "tail", &lines, &input, max_bytes), - "lines" => build_lines_payload(reference, &path, &lines, &input, max_bytes)?, - "query" => build_query_payload(reference, &path, &lines, &input, max_bytes)?, + "summary" => build_summary_payload(reference, &content, &lines, &input, max_bytes), + "head" => build_head_tail_payload(reference, "head", &lines, &input, max_bytes), + "tail" => build_head_tail_payload(reference, "tail", &lines, &input, max_bytes), + "lines" => build_lines_payload(reference, &lines, &input, max_bytes)?, + "query" => build_query_payload(reference, &lines, &input, max_bytes)?, other => { return Err(ToolError::invalid_input(format!( "unsupported mode `{other}` (expected metadata, summary, head, tail, lines, query, or bytes)" @@ -261,24 +276,64 @@ fn validate_evidence_if_present( Ok(Some(metadata)) } -/// Resolve a tool-result ref to a concrete file path. -/// -/// Accepts six shapes: -/// 1. `tool_call_id` — legacy spillover form, `.txt` under `tool_outputs/`. -/// 2. `art_` — current artifact id, written by `apply_spillover_with_artifact`. -/// Tries the session artifact directory first, falls back to `.txt` -/// (stripping the `art_` prefix) so old + new naming both work. -/// 3. `sha:<64-hex>` or bare 64-hex — content-addressed wire dedup, `sha_.txt`. -/// 4. `tool_result:` — `` is any of the above after the prefix. -/// 5. `artifacts/.txt` or `.txt` — relative paths. -/// 6. Absolute paths under the CodeWhale home. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ResolvedReferenceKind { + ActiveSession, + LegacyGlobal, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ResolvedSpilloverReference { + path: PathBuf, + kind: ResolvedReferenceKind, +} + +fn authorize_legacy_spillover( + path: &std::path::Path, + session_id: &str, +) -> Result { + if session_id.trim().is_empty() { + return Err(ToolError::permission_denied( + "legacy evidence has no verifiable session owner", + )); + } + let ownership = match crate::tools::truncate::read_legacy_spillover_ownership(path) { + Ok(ownership) => ownership, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(ToolError::permission_denied( + "legacy evidence has no verifiable session owner", + )); + } + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { + return Err(ToolError::permission_denied( + "legacy evidence ownership proof is invalid", + )); + } + Err(_) => { + return Err(ToolError::execution_failed( + "legacy evidence ownership metadata is corrupt", + )); + } + }; + if ownership.origin_session != session_id { + return Err(ToolError::permission_denied( + "legacy evidence belongs to another session", + )); + } + Ok(ownership) +} + +/// Resolve a tool-result ref without weakening its ownership boundary. /// -/// The error message on a miss enumerates which forms were tried so the -/// model can correct course without a second blind guess. -fn resolve_spillover_reference(reference: &str, session_id: &str) -> Result { - let root = crate::tools::truncate::spillover_root().ok_or_else(|| { - ToolError::execution_failed("could not resolve ~/.codewhale/tool_outputs") - })?; +/// Current-session artifacts always win over same-named global compatibility +/// files. A legacy call-id, SHA, relative path, or absolute path can resolve, +/// but the caller must validate its ownership sidecar before reading bytes. +fn resolve_spillover_reference( + reference: &str, + session_id: &str, +) -> Result { + let root = crate::tools::truncate::spillover_root() + .ok_or_else(|| ToolError::execution_failed("retained evidence storage is unavailable"))?; let root_canonical = root.canonicalize().ok(); // Resolve the session's `artifacts/` directory. @@ -311,23 +366,18 @@ fn resolve_spillover_reference(reference: &str, session_id: &str) -> Result = Vec::new(); - let try_path = |candidate: PathBuf, tried: &mut Vec| -> Option { - // Always record what we tried so the `not_found` diagnostic - // can enumerate every candidate, even ones whose - // `canonicalize` returns ENOENT. Models otherwise saw the - // useless "(no valid candidates derived from ref)" line. - tried.push(candidate.clone()); + let mut tried = 0_usize; + let try_path = |candidate: PathBuf, tried: &mut usize| -> Option { + *tried = (*tried).saturating_add(1); // Reject symlinks at the leaf BEFORE canonicalizing so an // attacker who can write under `/artifacts/` cannot // plant a symlink to `/etc/passwd` and read it back through // `retrieve_tool_result`. canonicalize() would happily // follow such a link and then pass the `starts_with(root)` - // check because of the resolved-then-compare order. The - // home-level `~/.codewhale/tool_outputs/` dir is engine-only and - // never carried this concern; session artifact dirs hold - // arbitrary tool output and need the guard. + // check because of the resolved-then-compare order. Both session and + // compatibility roots reject leaf symlinks. Legacy + // compatibility files also need a separate ownership sidecar below. if let Ok(meta) = std::fs::symlink_metadata(&candidate) && meta.file_type().is_symlink() { @@ -344,8 +394,16 @@ fn resolve_spillover_reference(reference: &str, session_id: &str) -> Result Result Result` prefix or bare 64-hex SHA → SHA-addressed file. - let sha_candidate = stripped - .strip_prefix("sha:") - .or_else(|| stripped.strip_prefix("sha_")) - .unwrap_or(stripped) - .trim(); - if crate::tools::truncate::is_valid_sha256(&sha_candidate.to_ascii_lowercase()) - && let Some(p) = crate::tools::truncate::sha_spillover_path(sha_candidate) - && let Some(found) = try_path(p, &mut tried) - { - return Ok(found); - } - - // Form 5: relative path with separator or `.txt` suffix. + // Session artifact paths take priority over legacy-global lookups. let looks_like_path = stripped.ends_with(".txt") || stripped.contains('/') || (std::path::MAIN_SEPARATOR != '/' && stripped.contains(std::path::MAIN_SEPARATOR)); if looks_like_path { - // Try legacy spillover root. - if let Some(found) = try_path(root.join(stripped), &mut tried) { - return Ok(found); - } - // Session artifact roots point directly at `/artifacts/`. - // Strip an optional leading `artifacts/` segment from transcript - // paths before joining. if let Some(sa_root) = session_artifacts_root.as_ref() { let rel = stripped.strip_prefix("artifacts/").unwrap_or(stripped); if let Some(found) = try_path(sa_root.join(rel), &mut tried) { return Ok(found); } } - return Err(not_found( - reference, - &tried, - &root, - session_artifacts_root.as_deref(), - )); + if let Some(found) = try_path(root.join(stripped), &mut tried) { + return Ok(found); + } + return Err(not_found(reference, tried)); } - // Form 1: bare id → legacy `tool_outputs/.txt`. - if let Some(p) = crate::tools::truncate::spillover_path(stripped) + if let Some(sa_root) = session_artifacts_root.as_ref() { + let file_name = if stripped.starts_with("art_") { + format!("{stripped}.txt") + } else { + format!("art_{stripped}.txt") + }; + if let Some(found) = try_path(sa_root.join(file_name), &mut tried) { + return Ok(found); + } + } + + // `sha:` or bare 64-hex resolves only as legacy-global evidence and + // therefore still requires an ownership sidecar in the caller. + let sha_candidate = stripped + .strip_prefix("sha:") + .or_else(|| stripped.strip_prefix("sha_")) + .unwrap_or(stripped) + .trim(); + if crate::tools::truncate::is_valid_sha256(&sha_candidate.to_ascii_lowercase()) + && let Some(p) = crate::tools::truncate::sha_spillover_path(sha_candidate) && let Some(found) = try_path(p, &mut tried) { return Ok(found); } - // Form 2: `art_` → strip prefix and try both: - // a) session artifacts dir at `artifacts/art_.txt` - // b) legacy spillover at `.txt` + + // Compatibility lookup: `art_` may name the historical `.txt`. if let Some(stripped_art) = stripped.strip_prefix("art_") { - if let Some(sa_root) = session_artifacts_root.as_ref() { - let session_file = sa_root.join(format!("art_{stripped_art}.txt")); - if let Some(found) = try_path(session_file, &mut tried) { - return Ok(found); - } - } if let Some(p) = crate::tools::truncate::spillover_path(stripped_art) && let Some(found) = try_path(p, &mut tried) { return Ok(found); } } - // Form 2b: maybe the model passed the bare id but the artifact lives - // under the session artifacts dir. Try `artifacts/art_.txt`. - if let Some(sa_root) = session_artifacts_root.as_ref() { - let session_file = sa_root.join(format!("art_{stripped}.txt")); - if let Some(found) = try_path(session_file, &mut tried) { - return Ok(found); - } + + if let Some(path) = crate::tools::truncate::spillover_path(stripped) + && let Some(found) = try_path(path, &mut tried) + { + return Ok(found); } - Err(not_found( - reference, - &tried, - &root, - session_artifacts_root.as_deref(), - )) + Err(not_found(reference, tried)) } -/// Format a "ref didn't resolve" error with enough detail for the -/// caller to choose a valid reference form on the next attempt. -fn not_found( - reference: &str, - tried: &[PathBuf], - legacy_root: &std::path::Path, - session_artifacts_root: Option<&std::path::Path>, -) -> ToolError { - let tried_list = if tried.is_empty() { - "(no valid candidates derived from ref)".to_string() - } else { - tried - .iter() - .map(|p| format!(" - {}", p.display())) - .collect::>() - .join("\n") - }; - let session_hint = session_artifacts_root - .map(|p| format!("\nsession artifacts root: {}", p.display())) - .unwrap_or_default(); +/// Missing evidence is distinct without revealing storage roots or generated +/// session identifiers to the model. +fn not_found(reference: &str, tried: usize) -> ToolError { ToolError::execution_failed(format!( - "spilled tool result `{reference}` not found. Tried:\n{tried_list}\n\ - spillover root: {legacy}{session}\n\ - Accepted ref forms: \ - (a) `` for legacy spillover, \ - (b) `art_` for session artifacts, \ - (c) `sha:<64-hex>` or bare 64-hex from a block, \ - (d) `artifacts/art_.txt` or `.txt` relative paths. \ - If the source was a `` block, copy the \ - sha value and pass it as `ref=sha:`. \ - If the source was an [artifact ...] block, pass the `id:` field \ - (the `art_` form) directly.", - legacy = legacy_root.display(), - session = session_hint, + "retained evidence `{reference}` was not found for the active session \ + ({tried} bounded candidate forms checked). Use the session-owned \ + `art_` handle from the original receipt." )) } fn build_summary_payload( reference: &str, - path: &std::path::Path, content: &str, lines: &[&str], input: &Value, @@ -513,7 +525,6 @@ fn build_summary_payload( json!({ "ref": reference, - "path": path.display().to_string(), "mode": "summary", "total_bytes": content.len(), "total_lines": lines.len(), @@ -527,7 +538,6 @@ fn build_summary_payload( fn build_head_tail_payload( reference: &str, - path: &std::path::Path, mode: &str, lines: &[&str], input: &Value, @@ -558,7 +568,6 @@ fn build_head_tail_payload( json!({ "ref": reference, - "path": path.display().to_string(), "mode": mode, "total_lines": lines.len(), "line_count": count, @@ -568,7 +577,6 @@ fn build_head_tail_payload( fn build_lines_payload( reference: &str, - path: &std::path::Path, lines: &[&str], input: &Value, max_bytes: usize, @@ -591,7 +599,6 @@ fn build_lines_payload( Ok(json!({ "ref": reference, - "path": path.display().to_string(), "mode": "lines", "total_lines": lines.len(), "start_line": start, @@ -602,7 +609,6 @@ fn build_lines_payload( fn build_query_payload( reference: &str, - path: &std::path::Path, lines: &[&str], input: &Value, max_bytes: usize, @@ -652,7 +658,6 @@ fn build_query_payload( Ok(json!({ "ref": reference, - "path": path.display().to_string(), "mode": "query", "query": query, "total_lines": lines.len(), @@ -870,18 +875,42 @@ mod tests { artifact } + fn write_owned_legacy(id: &str, content: &str, session_id: &str) -> PathBuf { + let path = crate::tools::truncate::write_spillover(id, content).unwrap(); + crate::tools::truncate::publish_legacy_spillover_ownership( + &path, + session_id, + content.as_bytes(), + ) + .unwrap(); + path + } + + fn write_owned_sha(content: &str, session_id: &str) -> (String, PathBuf) { + let sha = crate::hashing::sha256_hex(content.as_bytes()); + let path = crate::tools::truncate::write_sha_spillover(&sha, content).unwrap(); + crate::tools::truncate::publish_legacy_spillover_ownership( + &path, + session_id, + content.as_bytes(), + ) + .unwrap(); + (sha, path) + } + #[test] fn summary_reads_spillover_by_tool_call_id() { let _lock = test_lock(); let tmp = tempdir().unwrap(); let _guard = set_spillover_root(tmp.path().join("tool_outputs")); - crate::tools::truncate::write_spillover( + let session_id = "session-legacy-summary"; + write_owned_legacy( "call-abc", "checking crate\nerror[E0425]: missing value\nwarning: unused import\nfinished", - ) - .unwrap(); + session_id, + ); - let result = execute_tool(json!({"ref": "call-abc"})).unwrap(); + let result = execute_tool_in_session(json!({"ref": "call-abc"}), session_id).unwrap(); assert!(result.success); let body: Value = serde_json::from_str(&result.content).unwrap(); @@ -924,6 +953,50 @@ mod tests { assert_eq!(body["total_bytes"], bytes.len()); } + #[test] + fn adaptive_evidence_retrieves_after_restart_without_memory_state() { + let _spill = test_lock(); + let _artifact = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD + .lock() + .unwrap_or_else(|err| err.into_inner()); + let tmp = tempdir().unwrap(); + let _root = set_spillover_root(tmp.path().join("tool_outputs")); + let prior = + crate::artifacts::set_test_artifact_sessions_root(Some(tmp.path().join("sessions"))); + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + crate::artifacts::set_test_artifact_sessions_root(self.0.take()); + } + } + let _restore = Restore(prior); + + let bytes = b"restart-proof\nDEEP_RESTART_SENTINEL\nend"; + publish_test_evidence("session-restart", "art_call-restart", bytes, false); + + // Construct two independent contexts to model process teardown and + // resume. Retrieval must depend only on the sealed session artifact + // and metadata, never an in-memory routing table from publication. + let first = execute_tool_in_session( + json!({"ref": "art_call-restart", "mode": "metadata"}), + "session-restart", + ) + .unwrap(); + drop(first); + let resumed = execute_tool_in_session( + json!({"ref": "art_call-restart", "mode": "bytes", "length": 4096}), + "session-restart", + ) + .unwrap(); + let body: Value = serde_json::from_str(&resumed.content).unwrap(); + use base64::Engine as _; + let decoded = base64::engine::general_purpose::STANDARD + .decode(body["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(decoded, bytes); + assert_eq!(body["total_bytes"], bytes.len()); + } + #[test] fn adaptive_evidence_distinguishes_corrupt_expired_and_generation_mismatch() { let _spill = test_lock(); @@ -973,18 +1046,22 @@ mod tests { let _lock = test_lock(); let tmp = tempdir().unwrap(); let _guard = set_spillover_root(tmp.path().join("tool_outputs")); - crate::tools::truncate::write_spillover( + let session_id = "session-legacy-query"; + write_owned_legacy( "call-query", "one\ntwo before\nneedle here\nafter\nlast", - ) - .unwrap(); + session_id, + ); - let result = execute_tool(json!({ - "ref": "tool_result:call-query", - "mode": "query", - "query": "needle", - "context_lines": 1 - })) + let result = execute_tool_in_session( + json!({ + "ref": "tool_result:call-query", + "mode": "query", + "query": "needle", + "context_lines": 1 + }), + session_id, + ) .unwrap(); let body: Value = serde_json::from_str(&result.content).unwrap(); @@ -1001,13 +1078,17 @@ mod tests { let tmp = tempdir().unwrap(); let root = tmp.path().join("tool_outputs"); let _guard = set_spillover_root(root.clone()); - crate::tools::truncate::write_spillover("call-lines", "a\nb\nc\nd").unwrap(); + let session_id = "session-legacy-lines"; + write_owned_legacy("call-lines", "a\nb\nc\nd", session_id); - let result = execute_tool(json!({ - "ref": "call-lines.txt", - "mode": "lines", - "lines": "2-3" - })) + let result = execute_tool_in_session( + json!({ + "ref": "call-lines.txt", + "mode": "lines", + "lines": "2-3" + }), + session_id, + ) .unwrap(); let body: Value = serde_json::from_str(&result.content).unwrap(); @@ -1048,15 +1129,16 @@ mod tests { let tmp = tempdir().unwrap(); let _guard = set_spillover_root(tmp.path().join("tool_outputs")); let body = "checking crate ... error[E0425]: cannot find value\n".repeat(80); - let sha = crate::hashing::sha256_hex(body.as_bytes()); - crate::tools::truncate::write_sha_spillover(&sha, &body).unwrap(); + let session_id = "session-legacy-sha"; + let (sha, _) = write_owned_sha(&body, session_id); // Form: `sha:` - let result = execute_tool(json!({"ref": format!("sha:{sha}")})).unwrap(); + let result = + execute_tool_in_session(json!({"ref": format!("sha:{sha}")}), session_id).unwrap(); assert!(result.success, "sha: form should resolve"); // Form: bare 64-hex - let result = execute_tool(json!({"ref": &sha})).unwrap(); + let result = execute_tool_in_session(json!({"ref": &sha}), session_id).unwrap(); assert!(result.success, "bare 64-hex form should resolve"); } @@ -1069,12 +1151,60 @@ mod tests { let _lock = test_lock(); let tmp = tempdir().unwrap(); let _guard = set_spillover_root(tmp.path().join("tool_outputs")); - crate::tools::truncate::write_spillover("call_xyz", "line1\nline2\nline3").unwrap(); + let session_id = "session-legacy-art-prefix"; + write_owned_legacy("call_xyz", "line1\nline2\nline3", session_id); - let result = execute_tool(json!({"ref": "art_call_xyz"})).unwrap(); + let result = execute_tool_in_session(json!({"ref": "art_call_xyz"}), session_id).unwrap(); assert!(result.success, "art_ prefix should resolve to legacy id"); } + #[test] + fn unowned_and_foreign_legacy_spillovers_fail_closed_without_leaking_content() { + let _lock = test_lock(); + let tmp = tempdir().unwrap(); + let _guard = set_spillover_root(tmp.path().join("tool_outputs")); + let sentinel = "SESSION_A_PRIVATE_SENTINEL"; + + crate::tools::truncate::write_spillover("call-unowned", sentinel).unwrap(); + let unowned = + execute_tool_in_session(json!({"ref": "call-unowned", "mode": "bytes"}), "session-b") + .unwrap_err() + .to_string(); + assert!(unowned.contains("no verifiable session owner"), "{unowned}"); + assert!(!unowned.contains(sentinel), "{unowned}"); + assert!( + !unowned.contains(tmp.path().to_string_lossy().as_ref()), + "{unowned}" + ); + + write_owned_legacy("call-foreign", sentinel, "session-a"); + let foreign = + execute_tool_in_session(json!({"ref": "call-foreign", "mode": "bytes"}), "session-b") + .unwrap_err() + .to_string(); + assert!(foreign.contains("another session"), "{foreign}"); + assert!(!foreign.contains(sentinel), "{foreign}"); + assert!( + !foreign.contains(tmp.path().to_string_lossy().as_ref()), + "{foreign}" + ); + } + + #[test] + fn owned_legacy_digest_mismatch_is_distinct_from_unauthorized() { + let _lock = test_lock(); + let tmp = tempdir().unwrap(); + let _guard = set_spillover_root(tmp.path().join("tool_outputs")); + let path = write_owned_legacy("call-corrupt-owned", "original", "session-a"); + std::fs::write(path, "changed").unwrap(); + + let error = execute_tool_in_session(json!({"ref": "call-corrupt-owned"}), "session-a") + .unwrap_err() + .to_string(); + assert!(error.contains("content is corrupt"), "{error}"); + assert!(!error.contains("another session"), "{error}"); + } + #[test] fn not_found_error_lists_tried_candidates_and_accepted_forms() { let _lock = test_lock(); @@ -1085,21 +1215,12 @@ mod tests { let err = execute_tool(json!({"ref": "definitely_missing_id"})).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("not found"), "got: {msg}"); + assert!(msg.contains("active session"), "got: {msg}"); + assert!(msg.contains("art_"), "got: {msg}"); + assert!(!msg.contains("tool_outputs"), "storage root leaked: {msg}"); assert!( - msg.contains("sha:"), - "diagnostic should mention sha form: {msg}" - ); - assert!( - msg.contains("art_"), - "diagnostic should mention art form: {msg}" - ); - assert!( - msg.contains("tool_outputs"), - "tried list should include the legacy spillover candidate: {msg}" - ); - assert!( - !msg.contains("(no valid candidates derived from ref)"), - "tried list should not be empty: {msg}" + !msg.contains(tmp.path().to_string_lossy().as_ref()), + "path leaked: {msg}" ); } diff --git a/crates/tui/src/tools/truncate.rs b/crates/tui/src/tools/truncate.rs index b6a1c36b4b..d38b41a9f1 100644 --- a/crates/tui/src/tools/truncate.rs +++ b/crates/tui/src/tools/truncate.rs @@ -5,15 +5,14 @@ //! //! 1. The transcript / tool-cell renders a bounded preview so the UI //! stays scannable. -//! 2. The full original output is preserved on disk so the model can -//! `read_file` it back if it later needs the elided tail, and so -//! the user can open it in `$EDITOR`. +//! 2. The full router input is preserved under its origin session so bounded +//! retrieval and the raw-detail pager can inspect it without leaking a +//! process-global filesystem path. //! -//! This module owns the disk side. Files land in -//! `~/.codewhale/tool_outputs/.txt`. The id is the tool -//! call id the engine assigns; we sanitise it conservatively (ASCII -//! alphanumeric + `-`/`_`) so a hostile id can't escape the directory -//! via `..` or absolute-path tricks. +//! The default adaptive path writes immutable artifacts under +//! `~/.codewhale/sessions//artifacts/`. The historical +//! `~/.codewhale/tool_outputs/.txt` directory remains only for +//! classic-routing compatibility, protected by a digest-bound origin sidecar. //! //! Boot prune drops files whose mtime is older than [`SPILLOVER_MAX_AGE`] //! (7 days). Prune failures are logged and never fatal — the user @@ -28,25 +27,37 @@ //! * Boot prune in `main.rs` deletes files older than //! [`SPILLOVER_MAX_AGE`]. //! -//! UI-side rendering of the inline `full output: ` annotation -//! is owned by `tui/history.rs::render_spillover_annotation`. The -//! tool-details pager opens the spillover file when the user -//! presses the tool-details shortcut on a spilled tool cell. +//! UI-side rendering is owned by `tui/history.rs::render_spillover_annotation`; +//! it exposes a path-free receipt and the tool-details shortcut opens the +//! session artifact. use std::fs; use std::io; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; use crate::tools::spec::ToolResult; -// `Path` is only referenced from helpers gated to test builds. -#[cfg(test)] -use std::path::Path; - /// Name of the spillover directory under the CodeWhale home. pub const SPILLOVER_DIR_NAME: &str = "tool_outputs"; +const LEGACY_SPILLOVER_OWNER_SCHEMA_VERSION: u32 = 1; + +/// Session proof for compatibility payloads kept in the historical global +/// `tool_outputs/` directory. +/// +/// The payload remains in its legacy location so classic-routing rollback and +/// existing detail pagers keep working, but model retrieval is authorized only +/// when this sidecar names the active origin session and still matches the +/// immutable bytes being returned. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub(crate) struct LegacySpilloverOwnership { + pub schema_version: u32, + pub origin_session: String, + pub digest: String, + pub size_bytes: u64, +} + /// Default threshold above which a tool result is a candidate for /// spillover. Mirrors the `MAX_MEMORY_SIZE` ceiling we use elsewhere /// for "too large to inline" so the rules feel consistent. Wired @@ -108,12 +119,70 @@ pub fn spillover_path(id: &str) -> Option { Some(spillover_root()?.join(format!("{sanitised}.txt"))) } +#[must_use] +pub(crate) fn legacy_spillover_ownership_path(payload_path: &Path) -> PathBuf { + payload_path.with_extension("owner.json") +} + +/// Publish the proof needed to retrieve a legacy-global spillover safely. +/// +/// Payload publication happens first. If this atomic sidecar write fails, the +/// payload is deliberately left unowned and therefore inaccessible through +/// `retrieve_tool_result`; callers must not advertise a retrieval hint. +pub(crate) fn publish_legacy_spillover_ownership( + payload_path: &Path, + session_id: &str, + bytes: &[u8], +) -> io::Result { + if session_id.trim().is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "legacy spillover ownership requires a session id", + )); + } + let ownership = LegacySpilloverOwnership { + schema_version: LEGACY_SPILLOVER_OWNER_SCHEMA_VERSION, + origin_session: session_id.to_string(), + digest: crate::hashing::sha256_hex(bytes), + size_bytes: bytes.len().try_into().unwrap_or(u64::MAX), + }; + let sidecar = legacy_spillover_ownership_path(payload_path); + let encoded = serde_json::to_vec_pretty(&ownership) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + crate::utils::write_atomic(&sidecar, &encoded)?; + Ok(sidecar) +} + +pub(crate) fn read_legacy_spillover_ownership( + payload_path: &Path, +) -> io::Result { + let sidecar = legacy_spillover_ownership_path(payload_path); + if std::fs::symlink_metadata(&sidecar)? + .file_type() + .is_symlink() + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "legacy spillover ownership sidecar must not be a symlink", + )); + } + let ownership = serde_json::from_slice::(&std::fs::read(sidecar)?) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + if ownership.schema_version != LEGACY_SPILLOVER_OWNER_SCHEMA_VERSION { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported legacy spillover ownership schema", + )); + } + Ok(ownership) +} + /// Resolve the spillover-file path for a SHA256 content hash. Separate -/// namespace (`sha_.txt`) from the tool-call-id files so the two -/// reference systems (engine-side spillover + wire-side dedup) can -/// co-exist in one directory without collisions. `sha` must be the -/// raw 64-char lowercase hex digest — case-insensitive matching is -/// done by the caller. +/// namespace (`sha_.txt`) from the tool-call-id files so legacy +/// SHA-addressed evidence can be recognized without colliding with +/// tool-call references. Retrieval still requires matching ownership +/// metadata. `sha` must be the raw 64-char lowercase hex digest — +/// case-insensitive matching is done by the caller. #[must_use] pub fn sha_spillover_path(sha: &str) -> Option { let sha = sha.trim().to_ascii_lowercase(); @@ -133,11 +202,8 @@ pub fn is_valid_sha256(s: &str) -> bool { .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) } -/// Write content to the SHA-addressed spillover file. Idempotent — -/// the same hash always maps to the same path, and the file's bytes -/// are a function of the hash. Skips the write if the file already -/// exists (which is the common case for the wire dedup, since the -/// second sighting writes the same content that the first did). +/// Write a legacy SHA-addressed spillover fixture for ownership tests. +#[cfg(test)] pub fn write_sha_spillover(sha: &str, content: &str) -> io::Result { let path = sha_spillover_path(sha).ok_or_else(|| { io::Error::new( @@ -314,6 +380,7 @@ pub fn apply_spillover_with_artifact( ) } +#[derive(Clone, Copy)] struct ArtifactSpilloverContext<'a> { tool_name: &'a str, session_id: &'a str, @@ -360,6 +427,25 @@ fn apply_spillover_inner( let digest = crate::hashing::sha256_hex(original_content.as_bytes()); let path_str = path.display().to_string(); + let legacy_owner_published = artifact_context.is_some_and(|context| { + match publish_legacy_spillover_ownership( + &path, + context.session_id, + original_content.as_bytes(), + ) { + Ok(_) => true, + Err(err) => { + tracing::warn!( + target: "spillover", + ?err, + tool_id, + "legacy spillover ownership publication failed" + ); + false + } + } + }); + let mut artifact_path = None; if let Some(context) = artifact_context { let artifact_id = crate::artifacts::artifact_id_for_tool_call(tool_id); @@ -397,12 +483,18 @@ fn apply_spillover_inner( } if artifact_path.is_none() { + let retrieval = if legacy_owner_published { + format!( + "Use `retrieve_tool_result ref={tool_id} mode=tail` or \ + `retrieve_tool_result ref={tool_id} mode=query query=` \ + to inspect the retained evidence." + ) + } else { + "Exact retrieval is unavailable because session ownership could not be recorded." + .to_string() + }; let footer = format!( - "\n\n[Output truncated: {head_kib} KiB of {total_kib} KiB shown. \ - Full output saved to {path_str}. Use \ - `retrieve_tool_result ref={tool_id} mode=tail` or \ - `retrieve_tool_result ref={tool_id} mode=query query=` \ - if you need the elided output.]", + "\n\n[Output truncated: {head_kib} KiB of {total_kib} KiB shown. {retrieval}]", head_kib = head.len() / 1024, total_kib = total / 1024, ); @@ -563,17 +655,7 @@ fn apply_adaptive_evidence_inner( let original = result.content.clone(); let artifact_id = crate::artifacts::artifact_id_for_tool_call(tool_id); - let (absolute_path, relative_path) = match crate::artifacts::write_session_artifact_immutable( - context.session_id, - &artifact_id, - original.as_bytes(), - ) { - Ok(paths) => paths, - Err(err) => { - tracing::warn!(target: "evidence", ?err, tool_id, "adaptive evidence content publication failed"); - return None; - } - }; + let relative_path = crate::artifacts::session_artifact_relative_path(&artifact_id); let digest = crate::hashing::sha256_hex(original.as_bytes()); let now_ms = unix_millis_now(); let proposed_artifact = EvidenceArtifact { @@ -625,6 +707,24 @@ fn apply_adaptive_evidence_inner( } }; + // Seal the ownership/integrity record before publishing predictable + // `art_.txt` bytes. If metadata publication fails, no payload exists + // for a guessed handle to retrieve without the generation, redaction, + // retention, size, and digest checks above. A metadata-only interruption + // is safe: the handle is never advertised and a retry can idempotently + // publish the matching bytes. + let (absolute_path, relative_path) = match crate::artifacts::write_session_artifact_immutable( + context.session_id, + &artifact_id, + original.as_bytes(), + ) { + Ok(paths) => paths, + Err(err) => { + tracing::warn!(target: "evidence", ?err, tool_id, "adaptive evidence content publication failed"); + return None; + } + }; + let record = crate::artifacts::record_tool_output_artifact( context.session_id, tool_id, @@ -1001,7 +1101,12 @@ mod tests { "footer missing: {}", &result.content[result.content.len().saturating_sub(200)..] ); - assert!(result.content.contains("retrieve_tool_result ref=call-big")); + assert!( + result + .content + .contains("Exact retrieval is unavailable because session ownership") + ); + assert!(!result.content.contains("retrieve_tool_result")); // Full bytes are on disk at the returned path. assert!(path.exists(), "spillover file missing: {path:?}"); @@ -1154,6 +1259,91 @@ mod tests { }); } + #[test] + fn adaptive_evidence_publication_failure_emits_no_handle_or_details_hint() { + let _g = setup(); + let tmp = tempdir().unwrap(); + with_test_home(tmp.path(), || { + let session_dir = tmp + .path() + .join(".codewhale") + .join("sessions") + .join("session-blocked"); + std::fs::create_dir_all(&session_dir).unwrap(); + std::fs::write(session_dir.join("artifacts"), b"block artifact directory").unwrap(); + + let raw = format!( + "{}{}{}", + "publication failure head\n".repeat(1_500), + "DEEP_FAILURE_SENTINEL", + "publication failure tail\n".repeat(1_500), + ); + let mut result = ToolResult::error(raw.clone()); + let path = apply_spillover_with_artifact( + &mut result, + "call-failed-publish", + "mcp_fixture", + "session-blocked", + ); + + assert!(path.is_none()); + assert_eq!(result.content, raw); + assert!(!result.content.contains("Exact evidence retained")); + assert!(!result.content.contains("retrieve_tool_result")); + assert!( + result + .metadata + .as_ref() + .and_then(|metadata| metadata.get("evidence_available")) + .is_none() + ); + assert!( + !session_dir + .join("artifacts/art_call-failed-publish.txt") + .exists() + ); + }); + } + + #[test] + fn adaptive_evidence_metadata_atomic_failure_leaves_payload_unadvertised() { + let _g = setup(); + let tmp = tempdir().unwrap(); + with_test_home(tmp.path(), || { + let artifact_dir = tmp + .path() + .join(".codewhale") + .join("sessions") + .join("session-metadata-blocked") + .join("artifacts"); + std::fs::create_dir_all(artifact_dir.join("art_call-failed-metadata.evidence.json")) + .unwrap(); + + let raw = format!( + "{}{}{}", + "metadata failure head\n".repeat(1_500), + "DEEP_METADATA_FAILURE_SENTINEL", + "metadata failure tail\n".repeat(1_500), + ); + let mut result = ToolResult::success(raw.clone()); + let path = apply_spillover_with_artifact( + &mut result, + "call-failed-metadata", + "exec_shell", + "session-metadata-blocked", + ); + + assert!(path.is_none()); + assert_eq!(result.content, raw); + assert!(!result.content.contains("Exact evidence retained")); + assert!(!result.content.contains("retrieve_tool_result")); + assert!( + !artifact_dir.join("art_call-failed-metadata.txt").exists(), + "metadata failure must leave no payload behind a guessable handle" + ); + }); + } + #[test] fn apply_spillover_preserves_existing_metadata() { let _g = setup(); diff --git a/crates/tui/src/tools/verifier.rs b/crates/tui/src/tools/verifier.rs index 4146df9923..204fbeca9f 100644 --- a/crates/tui/src/tools/verifier.rs +++ b/crates/tui/src/tools/verifier.rs @@ -242,6 +242,10 @@ impl ToolSpec for RunVerifiersTool { "run_verifiers" } + fn model_visible(&self) -> bool { + false + } + fn description(&self) -> &'static str { "Run independent verifier gates in parallel across detected Rust, Node, Python, and Go projects. Supports explicit custom verifier commands as program+args without requiring Bash." } diff --git a/crates/tui/src/tools/web_search.rs b/crates/tui/src/tools/web_search.rs index 31c78b8367..eefd1477c9 100644 --- a/crates/tui/src/tools/web_search.rs +++ b/crates/tui/src/tools/web_search.rs @@ -99,6 +99,10 @@ impl ToolSpec for WebSearchTool { "web_search" } + fn model_visible(&self) -> bool { + false + } + fn description(&self) -> &'static str { "Search the web and return ranked results with URLs, snippets, session-scoped ref_ids, and an execution receipt. Open a result ref_id with `web.run` when the short summary is not enough; fetch only the few sources needed. When the exact active route reports a documented first-party server-side search tool, it is tried first; otherwise the default backend is DuckDuckGo with Bing fallback. Configured API backends visibly degrade through DuckDuckGo then Bing when unavailable, and every hop is recorded. Configuration and network-policy errors fail closed. Explicit Bing and private DuckDuckGo-compatible routes do not cross providers. Set `[search] provider = \"bing\" | \"tavily\" | \"bocha\" | \"metaso\" | \"searxng\" | \"baidu\" | \"volcengine\" | \"sofya\"` in config.toml, or `[search] base_url` for a private DuckDuckGo-compatible endpoint or trusted SearXNG instance. For a known canonical URL, prefer `fetch_url` directly." } diff --git a/crates/tui/src/tools/web_tool.rs b/crates/tui/src/tools/web_tool.rs new file mode 100644 index 0000000000..2ac6b76773 --- /dev/null +++ b/crates/tui/src/tools/web_tool.rs @@ -0,0 +1,201 @@ +//! Canonical action-based wrapper for web tools. +//! +//! The model sees one tool: `Web` with an `action` parameter +//! (search | fetch | wait). Legacy names (`web_search`, `fetch_url`, +//! `wait_for_dev_server`) stay registered as hidden compat aliases that +//! force the action so saved transcripts replay correctly. + +use async_trait::async_trait; +use serde_json::{Value, json}; + +use super::dev_server_readiness::WaitForDevServerTool; +use super::fetch_url::FetchUrlTool; +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, +}; +use super::web_search::WebSearchTool; + +pub struct WebTool { + name: &'static str, + forced_action: Option<&'static str>, +} + +impl WebTool { + pub const fn new(name: &'static str) -> Self { + Self { + name, + forced_action: None, + } + } + + pub const fn alias(name: &'static str, action: &'static str) -> Self { + Self { + name, + forced_action: Some(action), + } + } + + fn resolve_action<'a>(&self, input: &'a Value) -> &'a str { + self.forced_action.unwrap_or_else(|| { + input + .get("action") + .and_then(Value::as_str) + .unwrap_or("search") + }) + } + + fn strip_action(&self, input: Value) -> Result { + let mut input = input; + if let Some(obj) = input.as_object_mut() { + obj.remove("action"); + Ok(input) + } else { + Err(ToolError::invalid_input("Web tool input must be an object")) + } + } +} + +#[async_trait] +impl ToolSpec for WebTool { + fn name(&self) -> &'static str { + self.name + } + + fn model_visible(&self) -> bool { + self.name == "Web" + } + + fn description(&self) -> &'static str { + "Search the web, fetch a known URL, or wait for a local dev server. Prefer fetch for a canonical URL and search when the source is unknown. Web actions are read-only and network-policy aware." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["search", "fetch", "wait"], + "description": "Action to perform" + }, + "query": { + "type": "string", + "description": "Search query (action=search)" + }, + "q": { + "type": "string", + "description": "Search query alias (action=search)" + }, + "search_query": { + "type": "array", + "description": "Advanced search query array (action=search)", + "items": { + "type": "object", + "properties": { + "q": { "type": "string" }, + "query": { "type": "string" }, + "max_results": { "type": "integer" }, + "recency": { + "oneOf": [ + { "type": "string", "enum": ["day", "week", "month", "year"] }, + { "type": "integer", "minimum": 1, "maximum": 3650 } + ] + }, + "domains": { "type": "array", "items": { "type": "string" } }, + "locale": { "type": "string" } + } + } + }, + "max_results": { + "type": "integer", + "description": "Maximum search results (action=search)" + }, + "timeout_ms": { + "type": "integer", + "description": "Timeout in milliseconds (action=search, fetch, or wait)" + }, + "recency": { + "oneOf": [ + { "type": "string", "enum": ["day", "week", "month", "year"] }, + { "type": "integer", "minimum": 1, "maximum": 3650 } + ], + "description": "Requested freshness window (action=search)" + }, + "domains": { + "type": "array", + "items": { "type": "string" }, + "description": "Restrict search results to domains (action=search)" + }, + "locale": { + "type": "string", + "description": "Requested result locale (action=search)" + }, + "url": { + "type": "string", + "description": "URL to fetch (action=fetch) or healthcheck URL (action=wait)" + }, + "format": { + "type": "string", + "enum": ["text", "markdown", "raw"], + "description": "Post-processing for fetched response (action=fetch)" + }, + "max_bytes": { + "type": "integer", + "description": "Truncate fetched response after this many bytes (action=fetch)" + }, + "fields": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional JSONPath projections for JSON responses (action=fetch)" + }, + "host": { + "type": "string", + "description": "Loopback host to poll (action=wait)" + }, + "port": { + "type": "integer", + "description": "TCP port to wait for (action=wait)" + }, + "poll_interval_ms": { + "type": "integer", + "description": "Delay between readiness probes in milliseconds (action=wait)" + } + }, + "required": ["action"] + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ReadOnly, ToolCapability::Network] + } + + fn approval_requirement_for(&self, _input: &Value) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + fn is_read_only_for(&self, _input: &Value) -> bool { + true + } + + fn supports_parallel_for(&self, input: &Value) -> bool { + self.resolve_action(input) == "search" + } + + fn starts_detached_for(&self, _input: &Value) -> bool { + false + } + + async fn execute(&self, input: Value, context: &ToolContext) -> Result { + let action = self.resolve_action(&input).to_string(); + let input = self.strip_action(input)?; + + match action.as_str() { + "search" => WebSearchTool.execute(input, context).await, + "fetch" => FetchUrlTool.execute(input, context).await, + "wait" => WaitForDevServerTool.execute(input, context).await, + other => Err(ToolError::invalid_input(format!( + "Unknown Web action: {other}" + ))), + } + } +} diff --git a/crates/tui/src/tools/workflow.rs b/crates/tui/src/tools/workflow.rs index 4b7bcfaa2a..8aca9c82bc 100644 --- a/crates/tui/src/tools/workflow.rs +++ b/crates/tui/src/tools/workflow.rs @@ -1354,6 +1354,8 @@ struct StructuredPlanChild { profile: Option, #[serde(default)] mode: Option, + #[serde(default)] + file_scope: Vec, } fn structured_plan_to_workflow_spec(plan_value: &Value) -> Result { @@ -1578,7 +1580,7 @@ fn plan_children_to_leaves( profile, mode, isolation: Default::default(), - file_scope: Vec::new(), + file_scope: child.file_scope.clone(), depends_on_results: Vec::new(), budget: BudgetSpec { max_tokens: token_budget, @@ -1844,13 +1846,15 @@ impl DeclarativeWorkflowLowerer { "{} + \"\\n\\nInputs:\\n\" + {inputs}", js_string(&spec.prompt) ), - Some("general"), + Some("plan"), None, None, false, None, None, None, + Some("read_only"), + &[], &spec.id, Some("reduce"), None, @@ -1915,6 +1919,20 @@ fn leaf_task_options_expression( parallel: bool, ) -> Result { validate_leaf_runtime_contract(spec)?; + let worktree = leaf_wants_worktree(spec, parallel); + let write_authority = match spec.mode { + TaskMode::ReadOnly => "read_only", + TaskMode::ReadWrite if worktree => "worktree_write", + TaskMode::ReadWrite => "workspace_write", + }; + let write_roots = if spec.mode == TaskMode::ReadWrite { + spec.file_scope + .iter() + .map(|scope| codewhale_workflow::normalize_file_scope_root(scope)) + .collect::>() + } else { + Vec::new() + }; Ok(task_options_expression( leaf_description_expression(spec), leaf_subagent_type(spec), @@ -1922,10 +1940,12 @@ fn leaf_task_options_expression( spec.profile.as_deref(), // Parallel write-capable children default to worktree isolation (#4120). // Explicit isolation: shared is the approved same-worktree override. - leaf_wants_worktree(spec, parallel), + worktree, spec.budget.max_tokens, spec.budget.max_steps, spec.budget.timeout_secs, + Some(write_authority), + &write_roots, &spec.id, phase, leaf_allowed_tools(spec)?, @@ -1939,6 +1959,21 @@ fn validate_leaf_runtime_contract(spec: &LeafSpec) -> Result<(), ToolError> { spec.id ))); } + if spec.mode == TaskMode::ReadWrite && spec.file_scope.is_empty() { + return Err(ToolError::invalid_input(format!( + "Workflow leaf '{}' is read_write but declares no file_scope for its bounded write claim", + spec.id + ))); + } + for scope in &spec.file_scope { + let normalized = codewhale_workflow::normalize_file_scope_root(scope); + if normalized.is_empty() || normalized.contains('*') { + return Err(ToolError::invalid_input(format!( + "Workflow leaf '{}' has unsupported file_scope '{}'; use a concrete path or a trailing /* or /** directory scope", + spec.id, scope + ))); + } + } // A Fleet role and its authority posture are independent. In particular, // acceptance workflows must be able to resolve the `implementer` role to // its saved profile while narrowing that child to the read-only tool set. @@ -2063,6 +2098,8 @@ fn task_options_expression( token_budget: Option, max_steps: Option, wall_time_secs: Option, + write_authority: Option<&str>, + write_roots: &[String], label: &str, phase: Option<&str>, allowed_tools: Option>, @@ -2093,6 +2130,15 @@ fn task_options_expression( if let Some(wall_time_secs) = wall_time_secs { fields.push(format!("wallTimeSecs: {wall_time_secs}")); } + if let Some(write_authority) = write_authority { + fields.push(format!("writeAuthority: {}", js_string(write_authority))); + } + if !write_roots.is_empty() { + fields.push(format!( + "writeRoots: {}", + serde_json::to_string(write_roots).expect("serializing write roots cannot fail") + )); + } if let Some(allowed_tools) = allowed_tools { fields.push(format!( "allowedTools: {}", @@ -4029,6 +4075,12 @@ mod tests { model_strength: None, thinking: None, worktree: true, + write_authority: Some("worktree_write".to_string()), + write_roots: vec!["src".to_string()], + exact_files: Vec::new(), + coordination_contracts: vec!["test-contract".to_string()], + dependencies: Vec::new(), + acceptance: Vec::new(), allowed_tools: None, max_depth: None, token_budget: None, @@ -4057,6 +4109,12 @@ mod tests { model_strength: None, thinking: None, worktree: false, + write_authority: Some("read_only".to_string()), + write_roots: Vec::new(), + exact_files: Vec::new(), + coordination_contracts: Vec::new(), + dependencies: Vec::new(), + acceptance: Vec::new(), allowed_tools: None, max_depth: None, token_budget: None, @@ -4396,6 +4454,17 @@ export default workflow({ "each parallel write child should get worktree: true:\n{}", adapted.source ); + assert_eq!( + adapted + .source + .matches("writeAuthority: \"worktree_write\"") + .count(), + 2, + "each isolated writer should carry enforced worktree authority:\n{}", + adapted.source + ); + assert!(adapted.source.contains("writeRoots: [\"src/left.rs\"]")); + assert!(adapted.source.contains("writeRoots: [\"src/right.rs\"]")); } #[test] @@ -4470,6 +4539,16 @@ export default workflow({ "both children should still be lowered:\n{}", adapted.source ); + assert!( + adapted + .source + .contains("writeAuthority: \"workspace_write\"") + ); + assert!( + adapted + .source + .contains("writeAuthority: \"worktree_write\"") + ); } #[test] @@ -4511,6 +4590,15 @@ export default workflow({ "read-only parallel children should not get worktree isolation:\n{}", adapted.source ); + assert_eq!( + adapted + .source + .matches("writeAuthority: \"read_only\"") + .count(), + 2, + "read-only mode must reach the task authority contract:\n{}", + adapted.source + ); } #[test] @@ -4544,6 +4632,78 @@ export default workflow({ "sequential writes should not default to worktree:\n{}", adapted.source ); + assert!( + adapted + .source + .contains("writeAuthority: \"workspace_write\"") + ); + assert!(adapted.source.contains("writeRoots: [\"src/main.rs\"]")); + } + + #[test] + fn write_scope_suffix_globs_lower_to_enforceable_roots() { + let source = r#"workflow({ + "goal": "bounded auth patch", + "nodes": [{ "agent": { + "id": "writer", + "prompt": "Patch auth", + "agent_type": "implementer", + "mode": "read_write", + "file_scope": ["./src/auth/**"] + }}] + });"#; + let adapted = adapt_workflow_source(source, None).expect("lower trailing glob scope"); + assert!( + adapted.source.contains("writeRoots: [\"src/auth\"]"), + "runtime claim must contain src/auth/login.rs:\n{}", + adapted.source + ); + + let unsupported = r#"workflow({ + "goal": "reject ambiguous glob", + "nodes": [{ "agent": { + "id": "writer", + "prompt": "Patch auth", + "agent_type": "implementer", + "mode": "read_write", + "file_scope": ["src/*/auth"] + }}] + });"#; + let error = match adapt_workflow_source(unsupported, None) { + Ok(_) => panic!("internal globs cannot become literal runtime roots"), + Err(error) => error.to_string(), + }; + assert!(error.contains("unsupported file_scope"), "{error}"); + } + + #[test] + fn write_leaves_require_scope_and_reduce_stays_read_only() { + let unscoped_writer = r#"workflow({ + "goal": "reject an unbounded writer", + "nodes": [{ "agent": { + "id": "writer", + "prompt": "Patch it", + "agent_type": "implementer", + "mode": "read_write" + }}] + });"#; + let error = match adapt_workflow_source(unscoped_writer, None) { + Ok(_) => panic!("write-capable leaves must declare file_scope"), + Err(error) => error.to_string(), + }; + assert!(error.contains("declares no file_scope"), "{error}"); + + let reduce = r#"workflow({ + "goal": "reduce read-only evidence", + "nodes": [{ "reduce": { + "id": "summary", + "inputs": [], + "prompt": "Summarize the evidence" + }}] + });"#; + let adapted = adapt_workflow_source(reduce, None).expect("lower reduce"); + assert!(adapted.source.contains("type: \"plan\"")); + assert!(adapted.source.contains("writeAuthority: \"read_only\"")); } #[test] @@ -5235,6 +5395,12 @@ reviewer = "reviewer" model_strength: None, thinking: None, worktree: false, + write_authority: Some("workspace_write".to_string()), + write_roots: vec!["src".to_string()], + exact_files: Vec::new(), + coordination_contracts: vec!["test-contract".to_string()], + dependencies: Vec::new(), + acceptance: Vec::new(), allowed_tools: Some(Vec::new()), max_depth: None, token_budget: None, @@ -5393,6 +5559,12 @@ reviewer = "reviewer" model_strength: None, thinking: None, worktree: false, + write_authority: Some("workspace_write".to_string()), + write_roots: vec!["src".to_string()], + exact_files: Vec::new(), + coordination_contracts: vec!["test-contract".to_string()], + dependencies: Vec::new(), + acceptance: Vec::new(), allowed_tools: Some(Vec::new()), max_depth: None, token_budget: None, @@ -5511,6 +5683,12 @@ reviewer = "reviewer" model_strength: None, thinking: None, worktree: false, + write_authority: Some("workspace_write".to_string()), + write_roots: vec!["src".to_string()], + exact_files: Vec::new(), + coordination_contracts: vec!["test-contract".to_string()], + dependencies: Vec::new(), + acceptance: Vec::new(), allowed_tools: Some(Vec::new()), max_depth: None, token_budget: None, @@ -5807,6 +5985,12 @@ reviewer = "reviewer" model_strength: None, thinking: None, worktree: false, + write_authority: Some("read_only".to_string()), + write_roots: Vec::new(), + exact_files: Vec::new(), + coordination_contracts: Vec::new(), + dependencies: Vec::new(), + acceptance: Vec::new(), allowed_tools: Some(Vec::new()), max_depth: None, token_budget: None, diff --git a/crates/tui/src/tools/workflow_plan_approval.rs b/crates/tui/src/tools/workflow_plan_approval.rs index f3aabfaffa..237b383ff9 100644 --- a/crates/tui/src/tools/workflow_plan_approval.rs +++ b/crates/tui/src/tools/workflow_plan_approval.rs @@ -343,6 +343,22 @@ fn analyze_plan_object( .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_string); + // Mirror structured-plan lowering's `plan_risk_to_mode` aliases exactly: + // child role identity never elevates an omitted/read-only plan mode. + let default_mode_is_write = matches!( + risk.as_deref(), + Some( + "writes" + | "write" + | "read_write" + | "readwrite" + | "medium" + | "elevated" + | "high" + | "shell" + | "network" + ) + ); let token_budget = token_budget_override.or_else(|| { plan.get("token_budget") .and_then(Value::as_u64) @@ -374,6 +390,7 @@ fn analyze_plan_object( &mut network, &mut secrets, &mut worktree, + default_mode_is_write, ); } } @@ -386,6 +403,7 @@ fn analyze_plan_object( &mut network, &mut secrets, &mut worktree, + default_mode_is_write, ); // IR nodes escape hatch if let Some(nodes) = plan.get("nodes").and_then(Value::as_array) { @@ -399,18 +417,12 @@ fn analyze_plan_object( &mut network, &mut secrets, &mut worktree, + default_mode_is_write, ); } - if matches!( - risk.as_deref(), - Some("writes" | "write" | "read_write" | "elevated" | "high" | "shell" | "network") - ) { - writes = writes - || matches!( - risk.as_deref(), - Some("writes" | "write" | "read_write" | "elevated" | "high" | "shell") - ); + if default_mode_is_write { + writes = true; shell = shell || matches!(risk.as_deref(), Some("elevated" | "high" | "shell")); network = network || matches!(risk.as_deref(), Some("elevated" | "high" | "network")); } @@ -491,6 +503,7 @@ fn collect_children( network: &mut bool, secrets: &mut bool, worktree: &mut bool, + default_mode_is_write: bool, ) { let Some(children) = children else { return; @@ -507,13 +520,21 @@ fn collect_children( let mode = child .get("mode") .and_then(Value::as_str) - .unwrap_or_default(); + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); let agent_type = child .get("type") .or_else(|| child.get("agent_type")) .and_then(Value::as_str) - .unwrap_or_default(); - if mode.contains("write") || agent_type == "implementer" || agent_type == "builder" { + .unwrap_or("general"); + let effective_read_write = match mode.as_str() { + "read_only" | "readonly" => false, + "read_write" | "readwrite" | "writes" | "write" => true, + "" => default_mode_is_write, + other => other.contains("write") && !other.contains("read_only"), + }; + if effective_read_write { *writes = true; // Write-capable implementers may run shell beyond read-only. if agent_type == "implementer" || agent_type == "builder" || agent_type == "general" { @@ -580,6 +601,7 @@ fn walk_nodes( network: &mut bool, secrets: &mut bool, worktree: &mut bool, + default_mode_is_write: bool, ) { for node in nodes { if let Some(agent) = node.get("agent") { @@ -592,6 +614,7 @@ fn walk_nodes( network, secrets, worktree, + default_mode_is_write, ); } if let Some(branch) = node.get("branch") { @@ -605,6 +628,7 @@ fn walk_nodes( network, secrets, worktree, + default_mode_is_write, ); } if let Some(seq) = node.get("sequence") { @@ -620,6 +644,7 @@ fn walk_nodes( network, secrets, worktree, + default_mode_is_write, ); } } @@ -636,6 +661,7 @@ fn walk_nodes( network, secrets, worktree, + default_mode_is_write, ); } } @@ -789,6 +815,53 @@ mod tests { assert!(receipt.writes); } + #[test] + fn read_only_implementer_does_not_request_write_or_shell_authority() { + for child in [ + json!({ + "prompt": "review an implementation", + "type": "implementer", + "mode": "read_only" + }), + json!({ + "prompt": "review under the plan envelope", + "type": "implementer" + }), + ] { + let summary = analyze_workflow_plan_approval(&json!({ + "plan": { + "goal": "read-only implementation review", + "risk": "read_only", + "children": [child] + } + })); + assert!(!summary.writes, "{summary:?}"); + assert!(!summary.shell, "{summary:?}"); + assert!(!summary.elevated, "{summary:?}"); + } + + let omitted_risk = analyze_workflow_plan_approval(&json!({ + "plan": { + "goal": "default-safe implementation review", + "children": [{ "prompt": "inspect", "type": "implementer" }] + } + })); + assert!(!omitted_risk.writes, "{omitted_risk:?}"); + assert!(!omitted_risk.shell, "{omitted_risk:?}"); + + for risk in ["medium", "readwrite"] { + let write_default = analyze_workflow_plan_approval(&json!({ + "plan": { + "goal": "default writer", + "risk": risk, + "children": [{ "prompt": "patch" }] + } + })); + assert!(write_default.writes, "{risk}: {write_default:?}"); + assert!(write_default.shell, "{risk}: {write_default:?}"); + } + } + #[test] fn elevated_risk_flags_shell_and_network() { let summary = analyze_workflow_plan_approval(&json!({ diff --git a/crates/tui/src/tui/agent_details.rs b/crates/tui/src/tui/agent_details.rs new file mode 100644 index 0000000000..f3db3e59d2 --- /dev/null +++ b/crates/tui/src/tui/agent_details.rs @@ -0,0 +1,819 @@ +//! Safe, bounded Agent Details projection (#2889). +//! +//! The default route intentionally does not expose the child transcript. Exact +//! evidence remains behind an explicit artifact-first action. + +use std::path::{Component, Path}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent}; +use ratatui::{buffer::Buffer, layout::Rect}; + +use crate::tools::subagent::{SubAgentResult, SubAgentStatus, localized_whale_display_names}; +use crate::tui::app::{ + AgentCurrentActivityStatus, AgentProgressMeta, App, bound_agent_activity_text, +}; +use crate::tui::pager::PagerView; +use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; + +pub(crate) struct AgentDetailsProjection { + pub(crate) title: String, + pub(crate) body: String, + pub(crate) transcript_available: bool, +} + +/// Pager-backed details view with a distinct close receipt and an explicit +/// exact-transcript action. +pub(crate) struct AgentDetailsView { + pager: PagerView, + agent_id: String, + transcript_available: bool, +} + +impl AgentDetailsView { + fn new(projection: AgentDetailsProjection, agent_id: impl Into, width: u16) -> Self { + let agent_id = agent_id.into(); + let pager = + PagerView::from_text(projection.title, &projection.body, width.saturating_sub(2)) + .with_copy_text(projection.body); + Self { + pager, + agent_id, + transcript_available: projection.transcript_available, + } + } + + #[cfg(test)] + fn body_text(&self) -> String { + self.pager.body_text() + } + + #[cfg(test)] + fn title(&self) -> &str { + self.pager.title() + } +} + +impl ModalView for AgentDetailsView { + fn kind(&self) -> ModalKind { + ModalKind::Pager + } + + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + if matches!(key.code, KeyCode::Char('v' | 'V')) + && key.modifiers.contains(KeyModifiers::ALT) + && self.transcript_available + { + return ViewAction::Emit(ViewEvent::OpenAgentTranscript { + agent_id: self.agent_id.clone(), + }); + } + if matches!(key.code, KeyCode::Esc | KeyCode::Left) + || (key.code == KeyCode::Char('q') && key.modifiers.is_empty()) + { + return ViewAction::EmitAndClose(ViewEvent::AgentDetailsClosed { + agent_id: self.agent_id.clone(), + }); + } + self.pager.handle_key(key) + } + + fn handle_paste(&mut self, text: &str) -> bool { + self.pager.handle_paste(text) + } + + fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { + self.pager.handle_mouse(mouse) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + self.pager.render(area, buf); + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } +} + +pub(crate) fn open_agent_details(app: &mut App, agent_id: &str) -> bool { + let Some(projection) = project_agent_details(app, agent_id) else { + return false; + }; + let width = app + .viewport + .last_transcript_area + .map(|area| area.width) + .unwrap_or(80); + app.view_stack + .push(AgentDetailsView::new(projection, agent_id, width)); + true +} + +pub(crate) fn safe_agent_display_name(app: &App, agent_id: &str) -> String { + let generated = localized_whale_display_names( + app.subagent_cache + .iter() + .map(|agent| (agent.agent_id.as_str(), agent.nickname.as_deref())), + app.ui_locale.tag(), + ); + generated + .get(agent_id) + .cloned() + .or_else(|| app.agent_label_map.get(agent_id).cloned()) + .and_then(|name| safe_child_value(app, &name)) + .unwrap_or_else(|| "Agent".to_string()) +} + +pub(crate) fn project_agent_details(app: &App, agent_id: &str) -> Option { + let agent = app + .subagent_cache + .iter() + .find(|agent| agent.agent_id == agent_id); + let meta = app.agent_progress_meta.get(agent_id); + if agent.is_none() && meta.is_none() && !app.agent_progress.contains_key(agent_id) { + return None; + } + + let display_name = safe_agent_display_name(app, agent_id); + let mut lines = Vec::new(); + + if let Some(agent) = agent { + push_safe_line(app, &mut lines, "Assignment", &agent.assignment.objective); + + if let Some(role) = agent.assignment.role.as_deref() { + push_safe_line(app, &mut lines, "Role", role); + } + push_safe_line(app, &mut lines, "Profile", agent.agent_type.as_str()); + lines.push(format!("Parent: {}", safe_parent_name(app, agent))); + } else { + lines.push(format!("Parent: {}", safe_parent_from_meta(app, meta))); + } + + let status = typed_status(agent, meta); + let steps = agent.map_or(0, |agent| agent.steps_taken); + let mut state = vec![activity_status_label(status).to_string()]; + if let Some(agent) = agent { + state.push(format!("elapsed {}", format_duration_ms(agent.duration_ms))); + } + state.push(format!( + "{steps} {}", + if steps == 1 { "step" } else { "steps" } + )); + lines.push(format!("State: {}", state.join(" · "))); + + if let Some(meta) = meta { + if let Some(provider) = meta.resolved_provider.as_deref() { + push_safe_line(app, &mut lines, "Provider", provider); + } + } + let model = meta + .and_then(|meta| meta.resolved_model.as_deref()) + .or_else(|| agent.map(|agent| agent.model.as_str())); + if let Some(model) = model { + push_safe_line(app, &mut lines, "Model", model); + } + + if let Some(agent) = agent { + if let Some(workspace) = agent.workspace.as_deref() + && let Some(workspace) = safe_workspace(app, workspace) + { + lines.push(format!("Workspace: {workspace}")); + } + if let Some(branch) = agent.git_branch.as_deref() { + push_safe_line(app, &mut lines, "Branch", branch); + } + } + + if let Some(activity) = meta.and_then(|meta| meta.current_activity.as_ref()) + && !matches!( + activity.status, + AgentCurrentActivityStatus::Done + | AgentCurrentActivityStatus::Failed + | AgentCurrentActivityStatus::Canceled + | AgentCurrentActivityStatus::Interrupted + | AgentCurrentActivityStatus::Waiting + ) + { + let mut current = vec![activity_status_label(activity.status).to_string()]; + if let Some(tool) = activity + .current_tool + .as_deref() + .and_then(|tool| safe_child_value(app, tool)) + { + current.push(tool); + } + if let Some(step) = activity.step { + current.push(format!("step {step}")); + } + if let Some(detail) = activity + .detail + .as_deref() + .and_then(|detail| safe_child_value(app, detail)) + && !detail.starts_with("started ") + && !current.iter().any(|part| part == &detail) + { + current.push(detail); + } + lines.push(format!("Current: {}", current.join(" · "))); + } + + if let Some(meta) = meta { + for action in meta.recent_actions.iter().rev().take(3).rev() { + if let Some(tool) = safe_child_value(app, &action.tool) { + lines.push(format!( + "Recent: {} {tool} · step {}", + if action.ok { "✓" } else { "!" }, + action.step + )); + } + } + } + + if let Some(question) = pending_question(agent, meta) + && let Some(question) = safe_child_value(app, question) + { + lines.push(format!("Pending question: {question}")); + } + if let Some(blocker) = blocker(agent, meta) + && let Some(blocker) = safe_child_value(app, blocker) + { + lines.push(format!("Blocker: {blocker}")); + } + if let Some(summary) = terminal_summary(agent, meta) + && let Some(summary) = safe_child_value(app, summary) + { + lines.push(format!("Summary: {summary}")); + } + + let transcript_available = + crate::tui::mouse_ui::agent_transcript_evidence_available(app, agent_id); + if transcript_available { + lines.push("Exact evidence: available · Alt/⌥V opens transcript".to_string()); + } else { + lines.push("Exact evidence: unavailable".to_string()); + } + + Some(AgentDetailsProjection { + title: format!("Agent Details — {display_name}"), + body: lines.join("\n"), + transcript_available, + }) +} + +fn push_safe_line(app: &App, lines: &mut Vec, label: &str, value: &str) { + if let Some(value) = safe_child_value(app, value) { + lines.push(format!("{label}: {value}")); + } +} + +fn safe_child_value(app: &App, value: &str) -> Option { + let bounded = bound_agent_activity_text(value); + let scrubbed = scrub_raw_agent_ids(app, &bounded); + let trimmed = scrubbed.trim(); + if trimmed.is_empty() + || matches!( + trimmed.to_ascii_lowercase().as_str(), + "none" | "(none)" | "n/a" | "unknown" | "not set" | "not available" | "-" + ) + { + None + } else { + Some(trimmed.to_string()) + } +} + +fn scrub_raw_agent_ids(app: &App, value: &str) -> String { + let mut scrubbed = value.to_string(); + let mut ids: Vec<&str> = app + .subagent_cache + .iter() + .map(|agent| agent.agent_id.as_str()) + .chain( + app.subagent_cache + .iter() + .filter_map(|agent| agent.parent_run_id.as_deref()), + ) + .chain(app.agent_progress_meta.keys().map(String::as_str)) + .chain( + app.agent_progress_meta + .values() + .filter_map(|meta| meta.parent_run_id.as_deref()), + ) + .chain(app.agent_progress.keys().map(String::as_str)) + .chain(app.agent_label_map.keys().map(String::as_str)) + .collect(); + ids.sort_unstable_by_key(|id| std::cmp::Reverse(id.len())); + ids.dedup(); + for id in ids { + if !id.is_empty() { + scrubbed = scrubbed.replace(id, "agent"); + } + } + + let mut output = String::with_capacity(scrubbed.len()); + let mut token = String::new(); + let flush = |token: &mut String, output: &mut String| { + if token.starts_with("agent_") + || token.starts_with("agent-") + || token.starts_with("worker:agent_") + || token.starts_with("worker:agent-") + { + output.push_str("agent"); + } else { + output.push_str(token); + } + token.clear(); + }; + for ch in scrubbed.chars() { + if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | ':') { + token.push(ch); + } else { + flush(&mut token, &mut output); + output.push(ch); + } + } + flush(&mut token, &mut output); + output +} + +fn safe_parent_name(app: &App, agent: &SubAgentResult) -> String { + match agent.parent_run_id.as_deref() { + Some(parent_id) + if app + .subagent_cache + .iter() + .any(|candidate| candidate.agent_id == parent_id) => + { + safe_agent_display_name(app, parent_id) + } + Some(_) if agent.spawn_depth > 1 => "parent agent".to_string(), + _ => "primary session".to_string(), + } +} + +fn safe_parent_from_meta(app: &App, meta: Option<&AgentProgressMeta>) -> String { + match meta.and_then(|meta| meta.parent_run_id.as_deref()) { + Some(parent_id) + if app + .subagent_cache + .iter() + .any(|candidate| candidate.agent_id == parent_id) => + { + safe_agent_display_name(app, parent_id) + } + Some(_) => "parent agent".to_string(), + None => "primary session".to_string(), + } +} + +fn typed_status( + agent: Option<&SubAgentResult>, + meta: Option<&AgentProgressMeta>, +) -> AgentCurrentActivityStatus { + if let Some(status) = meta + .and_then(|meta| meta.current_activity.as_ref()) + .map(|activity| activity.status) + { + return status; + } + if let Some(status) = agent.and_then(|agent| agent.worker_status) { + return status.into(); + } + match agent.map(|agent| &agent.status) { + Some(SubAgentStatus::Running) | None => AgentCurrentActivityStatus::Running, + Some(SubAgentStatus::Completed) => AgentCurrentActivityStatus::Done, + Some(SubAgentStatus::Interrupted(_)) => AgentCurrentActivityStatus::Interrupted, + Some(SubAgentStatus::Failed(_) | SubAgentStatus::BudgetExhausted) => { + AgentCurrentActivityStatus::Failed + } + Some(SubAgentStatus::Cancelled) => AgentCurrentActivityStatus::Canceled, + } +} + +fn activity_status_label(status: AgentCurrentActivityStatus) -> &'static str { + match status { + AgentCurrentActivityStatus::Queued => "queued", + AgentCurrentActivityStatus::Starting => "starting", + AgentCurrentActivityStatus::Running => "running", + AgentCurrentActivityStatus::ModelWait => "waiting for model", + AgentCurrentActivityStatus::RunningTool => "running tool", + AgentCurrentActivityStatus::Waiting => "waiting for input", + AgentCurrentActivityStatus::Done => "completed", + AgentCurrentActivityStatus::Failed => "failed", + AgentCurrentActivityStatus::Canceled => "canceled", + AgentCurrentActivityStatus::Interrupted => "interrupted", + } +} + +fn pending_question<'a>( + agent: Option<&'a SubAgentResult>, + meta: Option<&'a AgentProgressMeta>, +) -> Option<&'a str> { + agent + .and_then(|agent| agent.needs_input.as_ref()) + .map(|needs_input| needs_input.question.as_str()) + .or_else(|| { + meta.and_then(|meta| meta.current_activity.as_ref()) + .filter(|activity| activity.status == AgentCurrentActivityStatus::Waiting) + .and_then(|activity| activity.detail.as_deref()) + }) + .or_else(|| match agent.map(|agent| &agent.status) { + Some(SubAgentStatus::Interrupted(reason)) => Some(reason.as_str()), + _ => None, + }) +} + +fn blocker<'a>( + agent: Option<&'a SubAgentResult>, + meta: Option<&'a AgentProgressMeta>, +) -> Option<&'a str> { + match agent.map(|agent| &agent.status) { + Some(SubAgentStatus::Failed(error) | SubAgentStatus::Interrupted(error)) => { + Some(error.as_str()) + } + Some(SubAgentStatus::BudgetExhausted) => Some("worker budget exhausted"), + _ => meta + .and_then(|meta| meta.current_activity.as_ref()) + .filter(|activity| activity.status == AgentCurrentActivityStatus::Failed) + .and_then(|activity| activity.detail.as_deref()), + } +} + +fn terminal_summary<'a>( + agent: Option<&'a SubAgentResult>, + meta: Option<&'a AgentProgressMeta>, +) -> Option<&'a str> { + let agent = agent?; + if !matches!( + agent.status, + SubAgentStatus::Completed | SubAgentStatus::Cancelled + ) { + return None; + } + agent.result.as_deref().or_else(|| { + meta.and_then(|meta| meta.current_activity.as_ref()) + .and_then(|activity| activity.detail.as_deref()) + }) +} + +fn safe_workspace(app: &App, workspace: &Path) -> Option { + if workspace.as_os_str().is_empty() { + return None; + } + if workspace == app.workspace { + return Some(".".to_string()); + } + if let Ok(relative) = workspace.strip_prefix(&app.workspace) { + let parts: Vec = relative + .components() + .filter_map(|component| match component { + Component::Normal(part) => part.to_str().map(ToString::to_string), + _ => None, + }) + .collect(); + if !parts.is_empty() { + return safe_child_value(app, &parts.join("/")); + } + } + workspace + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| safe_child_value(app, name)) +} + +fn format_duration_ms(duration_ms: u64) -> String { + if duration_ms < 1_000 { + format!("{duration_ms}ms") + } else if duration_ms < 60_000 { + format!("{:.1}s", duration_ms as f64 / 1_000.0) + } else { + let minutes = duration_ms / 60_000; + let seconds = (duration_ms % 60_000) / 1_000; + format!("{minutes}m {seconds}s") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use ratatui::{buffer::Buffer, layout::Rect}; + use serde_json::json; + use tempfile::tempdir; + + use crate::config::Config; + use crate::tools::subagent::{ + AgentWorkerStatus, SubAgentAssignment, SubAgentNeedsInput, SubAgentType, + }; + use crate::tui::app::{ + AgentCurrentActivity, AgentRecentAction, MAX_AGENT_RECENT_ACTIONS, TuiOptions, + }; + + fn test_app(workspace: PathBuf) -> App { + App::new( + TuiOptions { + model: "test-model".to_string(), + workspace, + config_path: None, + config_profile: None, + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: true, + use_bracketed_paste: true, + max_subagents: 4, + skills_dir: PathBuf::from("."), + memory_path: PathBuf::from("memory.md"), + notes_path: PathBuf::from("notes.txt"), + mcp_config_path: PathBuf::from("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }, + &Config::default(), + ) + } + + fn agent(agent_id: &str, status: SubAgentStatus) -> SubAgentResult { + SubAgentResult { + name: agent_id.to_string(), + agent_id: agent_id.to_string(), + context_mode: "isolated".to_string(), + fork_context: false, + workspace: None, + git_branch: None, + agent_type: SubAgentType::Implementer, + assignment: SubAgentAssignment { + objective: "Implement the bounded details route".to_string(), + role: Some("worker".to_string()), + }, + model: "deepseek-v4-pro".to_string(), + nickname: Some("Blue Whale".to_string()), + status, + worker_status: None, + parent_run_id: None, + spawn_depth: 1, + result: None, + steps_taken: 2, + checkpoint: None, + needs_input: None, + duration_ms: 2_500, + from_prior_session: false, + } + } + + fn body_for( + status: SubAgentStatus, + worker_status: AgentWorkerStatus, + detail: Option<&str>, + ) -> String { + let tmp = tempdir().expect("tempdir"); + let mut app = test_app(tmp.path().to_path_buf()); + let agent_id = "agent_matrix_subject"; + let mut child = agent(agent_id, status); + child.worker_status = Some(worker_status); + match worker_status { + AgentWorkerStatus::WaitingForUser => { + child.needs_input = Some(SubAgentNeedsInput { + question: detail.unwrap_or("Which path should I use?").to_string(), + }); + } + AgentWorkerStatus::Completed => child.result = detail.map(str::to_string), + _ => {} + } + app.subagent_cache.push(child); + app.agent_progress_meta.insert( + agent_id.to_string(), + AgentProgressMeta { + current_activity: Some(AgentCurrentActivity::bounded( + worker_status.into(), + detail.map(str::to_string), + (worker_status == AgentWorkerStatus::RunningTool) + .then(|| "read_file".to_string()), + Some(2), + )), + ..AgentProgressMeta::default() + }, + ); + project_agent_details(&app, agent_id) + .expect("projection") + .body + } + + #[test] + fn provider_free_status_matrix_is_typed_and_bounded() { + let running = body_for( + SubAgentStatus::Running, + AgentWorkerStatus::RunningTool, + None, + ); + assert!(running.contains("State: running tool · elapsed 2.5s · 2 steps")); + assert!(running.contains("Current: running tool · read_file · step 2")); + assert!(!running.contains("Provider:")); + + let waiting = body_for( + SubAgentStatus::Running, + AgentWorkerStatus::WaitingForUser, + Some("Which path should I use?"), + ); + assert!(waiting.contains("State: waiting for input")); + assert!(waiting.contains("Pending question: Which path should I use?")); + + let failed = body_for( + SubAgentStatus::Failed("verification failed".to_string()), + AgentWorkerStatus::Failed, + Some("verification failed"), + ); + assert!(failed.contains("State: failed")); + assert!(failed.contains("Blocker: verification failed")); + + let completed = body_for( + SubAgentStatus::Completed, + AgentWorkerStatus::Completed, + Some("all checks passed"), + ); + assert!(completed.contains("State: completed")); + assert!(completed.contains("Summary: all checks passed")); + } + + #[test] + fn projection_redacts_child_strings_and_never_exposes_raw_ids_or_none() { + let tmp = tempdir().expect("tempdir"); + let mut app = test_app(tmp.path().to_path_buf()); + let agent_id = "agent_secret_child"; + let parent_id = "agent_raw_parent"; + let mut parent = agent(parent_id, SubAgentStatus::Running); + parent.nickname = Some("Parent Whale".to_string()); + let mut child = agent(agent_id, SubAgentStatus::Running); + child.parent_run_id = Some(parent_id.to_string()); + child.spawn_depth = 2; + child.assignment.objective = format!( + "\u{1b}[31minspect {agent_id}\u{1b}[0m with api_key=sk-agent-details-secret-1234567890" + ); + child.git_branch = Some(format!("work/{parent_id}")); + child.model.clear(); + child.nickname = Some(format!("\u{1b}[35m{agent_id}\u{1b}[0m")); + app.subagent_cache.extend([parent, child]); + + let projection = project_agent_details(&app, agent_id).expect("projection"); + let all = format!("{}\n{}", projection.title, projection.body); + assert!(!all.contains(agent_id), "{all}"); + assert!(!all.contains(parent_id), "{all}"); + assert!(!all.contains("sk-agent-details-secret"), "{all}"); + assert!(!all.contains('\u{1b}'), "{all:?}"); + assert!(!all.contains("None"), "{all}"); + assert!(all.contains("[redacted]"), "{all}"); + } + + #[test] + fn external_workspace_is_basename_safe_and_branch_is_bounded() { + let mut app = test_app(PathBuf::from("/repo/main")); + let agent_id = "agent_external_workspace"; + let mut child = agent(agent_id, SubAgentStatus::Running); + child.workspace = Some(PathBuf::from("/private/customer/secret/repo-child")); + child.git_branch = Some("codex/details".to_string()); + app.subagent_cache.push(child); + + let body = project_agent_details(&app, agent_id) + .expect("projection") + .body; + assert!(body.contains("Workspace: repo-child"), "{body}"); + assert!(!body.contains("/private/customer/secret"), "{body}"); + assert!(body.contains("Branch: codex/details"), "{body}"); + } + + #[test] + fn recent_actions_are_bounded_and_render_only_structured_outcomes() { + let tmp = tempdir().expect("tempdir"); + let mut app = test_app(tmp.path().to_path_buf()); + let agent_id = "agent_recent_actions"; + app.subagent_cache + .push(agent(agent_id, SubAgentStatus::Running)); + let mut meta = AgentProgressMeta::default(); + for step in 1..=MAX_AGENT_RECENT_ACTIONS as u32 { + meta.recent_actions.push_back(AgentRecentAction::bounded( + if step == 2 { + "apply_patch" + } else { + "read_file" + }, + step, + step != 2, + )); + } + app.agent_progress_meta.insert(agent_id.to_string(), meta); + + let body = project_agent_details(&app, agent_id) + .expect("projection") + .body; + assert_eq!(body.matches("Recent:").count(), 3, "{body}"); + assert!(body.contains("Recent: ! apply_patch · step 2"), "{body}"); + } + + #[test] + fn alt_v_is_truthful_for_present_and_absent_evidence() { + let tmp = tempdir().expect("tempdir"); + let agent_id = "agent_evidence"; + let mut app = test_app(tmp.path().to_path_buf()); + app.subagent_cache + .push(agent(agent_id, SubAgentStatus::Running)); + let absent = project_agent_details(&app, agent_id).expect("projection"); + assert!(!absent.transcript_available); + assert!(!absent.body.contains("Alt/⌥V")); + let mut absent_view = AgentDetailsView::new(absent, agent_id, 80); + assert!(matches!( + absent_view.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)), + ViewAction::None + )); + + { + let mut store = app + .runtime_services + .handle_store + .try_lock() + .expect("handle store"); + let _ = store.insert_json( + format!("agent:{agent_id}"), + "full_transcript", + json!({ + "message_count": 1, + "messages": [{ + "role": "assistant", + "content": [{ + "type": "text", + "text": "exact evidence", + "cache_control": null + }] + }] + }), + ); + } + let present = project_agent_details(&app, agent_id).expect("projection"); + assert!(present.transcript_available); + assert!(present.body.contains("Alt/⌥V opens transcript")); + let mut present_view = AgentDetailsView::new(present, agent_id, 80); + assert!(matches!( + present_view.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)), + ViewAction::Emit(ViewEvent::OpenAgentTranscript { agent_id: ref id }) if id == agent_id + )); + + assert!(open_agent_details(&mut app, agent_id)); + let events = app + .view_stack + .handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)); + assert!(matches!( + events.as_slice(), + [ViewEvent::OpenAgentTranscript { agent_id: id }] if id == agent_id + )); + assert!(crate::tui::mouse_ui::open_agent_chat_pager( + &mut app, agent_id + )); + assert!( + app.view_stack + .handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .is_empty(), + "transcript Esc closes only the top pager" + ); + assert!(matches!( + app.view_stack + .handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)) + .as_slice(), + [ViewEvent::OpenAgentTranscript { agent_id: id }] if id == agent_id + )); + } + + #[test] + fn close_keys_emit_receipt_and_80x24_render_stays_safe() { + let tmp = tempdir().expect("tempdir"); + let agent_id = "agent_render_80x24"; + let mut app = test_app(tmp.path().to_path_buf()); + app.subagent_cache + .push(agent(agent_id, SubAgentStatus::Running)); + let projection = project_agent_details(&app, agent_id).expect("projection"); + let mut view = AgentDetailsView::new(projection, agent_id, 80); + assert!(view.title().starts_with("Agent Details — ")); + assert!(view.body_text().contains("Exact evidence: unavailable")); + + let area = Rect::new(0, 0, 80, 24); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer); + let rendered = (0..area.height) + .flat_map(|y| (0..area.width).map(move |x| (x, y))) + .map(|point| buffer[point].symbol()) + .collect::(); + assert!(rendered.contains("Agent Details"), "{rendered}"); + assert!(rendered.contains("Assignment"), "{rendered}"); + assert!(!rendered.contains(agent_id), "{rendered}"); + + for code in [KeyCode::Esc, KeyCode::Left] { + assert!(matches!( + view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)), + ViewAction::EmitAndClose(ViewEvent::AgentDetailsClosed { agent_id: ref id }) + if id == agent_id + )); + } + } +} diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index a7f9aaf765..92a9eaa09d 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -31,11 +31,11 @@ use crate::palette::{self, UiTheme}; use crate::pricing::{CostCurrency, CostEstimate}; use crate::resource_telemetry::TokenThroughput; use crate::session_manager::{SessionContextReference, SessionMetadata, SessionWorkState}; -use crate::settings::Settings; +use crate::settings::{InlineDiffMode, Settings}; use crate::tools::plan::{PlanState, SharedPlanState, new_shared_plan_state}; use crate::tools::shell::new_shared_shell_manager; use crate::tools::spec::RuntimeToolServices; -use crate::tools::subagent::SubAgentResult; +use crate::tools::subagent::{AgentWorkerStatus, SubAgentResult}; use crate::tools::todo::{SharedTodoList, TodoList, new_shared_todo_list}; use crate::tui::active_cell::ActiveCell; use crate::tui::approval::ApprovalMode; @@ -43,6 +43,7 @@ use crate::tui::clipboard::{ClipboardContent, ClipboardHandler}; use crate::tui::file_mention::ContextReference; use crate::tui::history::{HistoryCell, TranscriptRenderOptions}; use crate::tui::hotbar::HotbarActionRegistry; +use crate::tui::motion::MotionPolicy; use crate::tui::paste_burst::{FlushResult, PasteBurst}; use crate::tui::scrolling::{MouseScrollState, TranscriptLineMeta, TranscriptScroll}; use crate::tui::selection::{SelectionAutoscroll, TranscriptSelection}; @@ -506,15 +507,126 @@ pub struct ProviderPickerMemory { pub selected_provider_id: Option, } +/// Bounded status vocabulary for the per-agent current-activity projection. +/// +/// This is presentation state derived from structured worker/mailbox events; +/// renderers map these variants to labels but never infer them from strings. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentCurrentActivityStatus { + Queued, + Starting, + Running, + ModelWait, + RunningTool, + Waiting, + Done, + Failed, + Canceled, + Interrupted, +} + +impl From for AgentCurrentActivityStatus { + fn from(status: AgentWorkerStatus) -> Self { + match status { + AgentWorkerStatus::Queued => Self::Queued, + AgentWorkerStatus::Starting => Self::Starting, + AgentWorkerStatus::Running => Self::Running, + AgentWorkerStatus::WaitingForUser => Self::Waiting, + AgentWorkerStatus::ModelWait => Self::ModelWait, + AgentWorkerStatus::RunningTool => Self::RunningTool, + AgentWorkerStatus::Completed => Self::Done, + AgentWorkerStatus::Failed => Self::Failed, + AgentWorkerStatus::Cancelled => Self::Canceled, + AgentWorkerStatus::Interrupted => Self::Interrupted, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentCurrentActivity { + pub status: AgentCurrentActivityStatus, + /// Safe bounded context, never a raw child transcript or tool result. + pub detail: Option, + /// Safe display name for the one tool currently executing. + pub current_tool: Option, + pub step: Option, +} + +impl AgentCurrentActivity { + #[must_use] + pub fn bounded( + status: AgentCurrentActivityStatus, + detail: Option, + current_tool: Option, + step: Option, + ) -> Self { + fn bounded_nonempty(value: Option) -> Option { + value + .map(|value| bound_agent_activity_text(&value)) + .filter(|value| !value.trim().is_empty()) + } + + Self { + status, + detail: bounded_nonempty(detail), + current_tool: bounded_nonempty(current_tool), + step, + } + } +} + +/// Convert untrusted child-agent text into a compact UI-safe projection. +/// Full transcript artifacts remain the source of truth; only summaries that +/// can enter the parent transcript/sidebar pass through this seam. +pub(crate) fn bound_agent_activity_text(value: &str) -> String { + let mut visible = String::with_capacity(value.len()); + crate::tui::osc8::strip_ansi_into(value, &mut visible); + let redacted = codewhale_config::persistence::redact_secrets(&visible); + crate::tui::history::summarize_tool_output(&redacted) +} + +/// One bounded, structured tool outcome for the Agent Details projection. +/// +/// This is populated only from `ToolCallCompleted` mailbox envelopes. It is +/// deliberately not inferred from free-form progress text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentRecentAction { + pub tool: String, + pub step: u32, + pub ok: bool, +} + +impl AgentRecentAction { + #[must_use] + pub fn bounded(tool: &str, step: u32, ok: bool) -> Self { + Self { + tool: bound_agent_activity_text(tool), + step, + ok, + } + } +} + +pub(crate) const MAX_AGENT_RECENT_ACTIONS: usize = 3; + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct AgentProgressMeta { pub parent_run_id: Option, pub spawn_depth: u32, + /// Structured, bounded answer to "what is this agent doing now?". + pub current_activity: Option, /// Last tool observed running for this child. Cleared by the matching /// completion envelope so Work never presents a settled tool as live. pub current_tool: Option, /// Successful file mutations observed for this child in this session. pub files_touched: u32, + /// At most three tool outcomes observed through structured lifecycle + /// envelopes, oldest to newest. + pub recent_actions: VecDeque, + /// Effective route facts observed from a real child token-usage envelope. + /// These stay absent until the provider actually reports usage. + pub resolved_provider: Option, + pub resolved_model: Option, } /// Per-turn LSP repair-loop summary for the Turn Inspector (#4107). @@ -1702,12 +1814,16 @@ pub enum SidebarRowAction { ToggleAgentDetails { agent_id: String, }, - /// Drill into the child's transcript card (action tree, status, summary) - /// in the detail pager — registered on the expanded dossier rows (#2889 - /// slice, dogfood A3). + /// Open the child's bounded, safe status projection. Exact transcript + /// evidence is a separate explicit action (#2889). OpenAgentDetail { agent_id: String, }, + /// Open the child's artifact-first exact transcript. This is separate + /// from the safe default details projection (#2889). + OpenAgentTranscript { + agent_id: String, + }, CancelAgent { agent_id: String, }, @@ -1729,6 +1845,7 @@ impl SidebarRowAction { | Self::HotbarSlot(_) | Self::ToggleAgentDetails { .. } | Self::OpenAgentDetail { .. } + | Self::OpenAgentTranscript { .. } | Self::CancelAgent { .. } | Self::InspectWork { .. } => None, } @@ -1742,6 +1859,7 @@ impl SidebarRowAction { Self::CancelAgent { .. } => true, Self::ToggleAgentDetails { .. } | Self::OpenAgentDetail { .. } + | Self::OpenAgentTranscript { .. } | Self::InspectWork { .. } | Self::HotbarSlot(_) => false, } @@ -2116,6 +2234,9 @@ pub struct App { pub show_thinking: bool, pub verbose_transcript: bool, pub show_tool_details: bool, + /// Inline presentation mode for successful structured File mutations. + /// Exact evidence remains attached to each mutation receipt in all modes. + pub inline_diff_mode: InlineDiffMode, pub ui_locale: Locale, pub cost_currency: CostCurrency, /// Route payment truth. Model pricing alone cannot distinguish metered @@ -2334,6 +2455,10 @@ pub struct App { pub todos: SharedTodoList, /// Durable runtime services exposed to model-visible task/automation tools. pub runtime_services: RuntimeToolServices, + /// Latest bounded coordination receipt delivered by the engine. This is + /// the same typed projection returned to headless inspection; the TUI does + /// not parse tool text to reconstruct it. + pub coordination_detail: Option, /// Last MCP manager/discovery snapshot shown in the UI. pub mcp_snapshot: Option, /// Number of MCP servers declared in the user's config at app boot. @@ -2996,6 +3121,7 @@ impl App { let status_indicator = settings.status_indicator.clone(); let show_thinking = settings.show_thinking; let show_tool_details = settings.show_tool_details; + let inline_diff_mode = InlineDiffMode::parse(&settings.inline_diffs); let ui_locale = resolve_locale(&settings.locale); let cost_currency = match (settings.cost_currency.as_str(), ui_locale.tag()) { ("usd", "zh-Hans") => CostCurrency::Cny, @@ -3203,13 +3329,14 @@ impl App { let configured_approval_mode = explicit_approval_mode .or(saved_permission_posture) .unwrap_or_default(); + let configured_trust_mode = configured_approval_mode == ApprovalMode::Bypass; let mode_prefs = ModeSessionPrefs { agent_allow_shell: if yolo_compat || matches!(initial_mode, AppMode::Yolo) { config.interactive_allow_shell() } else { allow_shell }, - agent_trust_mode: false, + agent_trust_mode: configured_trust_mode, // The YOLO-compat launch elevates the *live* approval mirror to // Bypass below; the durable Agent baseline keeps the configured // policy so a YOLO -> Agent downshift restores it. @@ -3399,6 +3526,7 @@ impl App { show_thinking, verbose_transcript: false, show_tool_details, + inline_diff_mode, ui_locale, cost_currency, billing_presentation: crate::route_billing::for_route(config, provider), @@ -3486,7 +3614,7 @@ impl App { last_known_work_state: None, current_session_metadata: None, session_artifacts: Vec::new(), - trust_mode: yolo_compat || initial_mode == AppMode::Yolo, + trust_mode: yolo_compat || initial_mode == AppMode::Yolo || configured_trust_mode, translation_enabled: false, status_items: config .tui @@ -3504,6 +3632,7 @@ impl App { work: Some(work_runtime), ..RuntimeToolServices::default() }, + coordination_detail: None, mcp_snapshot: None, // Read the MCP config once at boot to know how many servers // the user has declared. The footer chip uses this even when @@ -4078,14 +4207,19 @@ impl App { ); } - /// Update the durable Act approval choice without changing its saved shell - /// or trust choices. Plan remains read-only. + /// Update the durable Act approval choice. Entering Full Access enables + /// trust mode; leaving it removes that implicit elevation while preserving + /// an independently enabled trust baseline in other posture transitions. + /// Plan remains read-only. pub fn set_agent_approval_posture(&mut self, next: ApprovalMode) { - self.set_agent_runtime_baseline( - self.mode_prefs.agent_allow_shell, - self.mode_prefs.agent_trust_mode, - next, - ); + let trust_mode = if next == ApprovalMode::Bypass { + true + } else if self.mode_prefs.agent_approval_mode == ApprovalMode::Bypass { + false + } else { + self.mode_prefs.agent_trust_mode + }; + self.set_agent_runtime_baseline(self.mode_prefs.agent_allow_shell, trust_mode, next); } #[must_use] @@ -4478,6 +4612,50 @@ impl App { self.needs_redraw = true; } + /// Invalidate only transcript rows whose visible liveness marker is + /// time-based. Animation redraws must not churn settled history, but they + /// do need fresh cache keys for running history and active-cell entries. + pub(crate) fn mark_live_motion_updated(&mut self) { + self.mark_live_motion_updated_inner(true); + } + + /// Invalidate only committed live rows. The translation placeholder path + /// already bumps the whole active-cell cache when it changes, so the UI + /// uses this narrower path to avoid bumping that revision twice. + pub(crate) fn mark_live_history_motion_updated(&mut self) { + self.mark_live_motion_updated_inner(false); + } + + fn mark_live_motion_updated_inner(&mut self, invalidate_active_cell: bool) { + self.resync_history_revisions(); + let live_history_indices: Vec = self + .history + .iter() + .enumerate() + .filter_map(|(index, cell)| cell.has_live_motion().then_some(index)) + .collect(); + for index in live_history_indices { + let revision = self.fresh_history_revision(); + if let Some(slot) = self.history_revisions.get_mut(index) { + *slot = revision; + } + } + + let active_has_live_motion = self + .active_cell + .as_ref() + .is_some_and(|active| active.entries().iter().any(HistoryCell::has_live_motion)); + if invalidate_active_cell && active_has_live_motion { + self.active_cell_revision = self.active_cell_revision.wrapping_add(1); + if let Some(active) = self.active_cell.as_mut() { + active.bump_revision(); + } + } + + self.history_version = self.history_version.wrapping_add(1); + self.needs_redraw = true; + } + /// Issue a fresh, monotonically increasing revision counter for a new /// history cell. Wrapping is acceptable — collisions are astronomically /// rare and at worst trigger one extra re-render. @@ -4878,14 +5056,33 @@ impl App { *streaming = false; } - let drained = active.drain(); let base_index = self.history.len(); + // Completed tools are removed from `tool_cells` before the active + // group flushes, but `ActiveCell` deliberately keeps the stable + // tool-to-entry binding until drain. Capture that binding first so + // sequential or parallel tools in one model turn retain distinct raw + // detail records instead of all falling back to the first cell. + let detail_cell_indices: HashMap = self + .active_tool_details + .keys() + .filter_map(|tool_id| { + active + .entry_index_for_tool(tool_id) + .map(|entry_idx| (tool_id.clone(), base_index + entry_idx)) + }) + .collect(); + let drained = active.drain(); let mut details = std::mem::take(&mut self.active_tool_details); self.active_tool_entry_completed_at.clear(); for (tool_id, detail) in details.drain() { + let cell_index = detail_cell_indices + .get(&tool_id) + .copied() + .or_else(|| self.tool_cells.get(&tool_id).copied()) + .unwrap_or(base_index); self.tool_details_by_cell - .entry(self.tool_cells.get(&tool_id).copied().unwrap_or(base_index)) + .entry(cell_index) .or_insert(detail); } @@ -5243,13 +5440,34 @@ impl App { .or_else(|| self.status_toasts.back().cloned()) } + /// Resolve one motion policy for every surface that can request or paint + /// animation. `fancy_animations = false` is a true still mode even when + /// the separate accessibility preference is left at its default. + #[must_use] + pub(crate) fn motion_policy(&self) -> MotionPolicy { + MotionPolicy::from_settings( + self.low_motion, + self.fancy_animations, + self.constrained_frame_rate, + ) + } + + /// Bridge the centralized policy into transcript renderers that still + /// accept the legacy boolean motion contract. + #[must_use] + pub(crate) fn effective_low_motion_for_status(&self) -> bool { + self.motion_policy().as_low_motion() + } + pub fn transcript_render_options(&self) -> TranscriptRenderOptions { TranscriptRenderOptions { show_thinking: self.show_thinking, verbose: self.verbose_transcript, show_tool_details: self.show_tool_details, + inline_diff_mode: self.inline_diff_mode, calm_mode: self.calm_mode, - low_motion: self.low_motion, + low_motion: self.effective_low_motion_for_status(), + motion_mode: self.motion_policy().mode(), spacing: self.transcript_spacing, } } @@ -7540,6 +7758,8 @@ pub enum AppAction { OpenFeedbackPicker, /// Open the `/theme` picker modal with live preview of every preset. OpenThemePicker, + /// Open the `/skills` manager — audit inventory + owned mutations. + OpenSkillsManager, /// Open the `/fleet` roster — the saved-party view of the agent team. OpenFleetRoster, /// Open the `/fleet` profile authoring wizard. diff --git a/crates/tui/src/tui/app/tests.rs b/crates/tui/src/tui/app/tests.rs index df2f863c6c..424ea9fa35 100644 --- a/crates/tui/src/tui/app/tests.rs +++ b/crates/tui/src/tui/app/tests.rs @@ -6,6 +6,7 @@ use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs}; use crate::tools::todo::TodoStatus; use crate::tui::clipboard::{ClipboardHandler, PastedImage}; use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus}; +use crate::tui::motion::MotionMode; fn test_options(yolo: bool) -> TuiOptions { TuiOptions { @@ -33,6 +34,29 @@ fn test_options(yolo: bool) -> TuiOptions { } } +#[test] +fn app_motion_policy_and_transcript_bridge_cover_every_settings_mode() { + let mut app = App::new(test_options(false), &Config::default()); + app.constrained_frame_rate = false; + + for (low_motion, fancy_animations, expected_mode, static_status) in [ + (false, true, MotionMode::Full, false), + (true, true, MotionMode::Reduced, true), + (false, false, MotionMode::Still, true), + // The explicit accessibility preference wins when both switches are off. + (true, false, MotionMode::Reduced, true), + ] { + app.low_motion = low_motion; + app.fancy_animations = fancy_animations; + + assert_eq!(app.motion_policy().mode(), expected_mode); + assert_eq!(app.effective_low_motion_for_status(), static_status); + let options = app.transcript_render_options(); + assert_eq!(options.low_motion, static_status); + assert_eq!(options.motion_mode, expected_mode); + } +} + #[cfg(unix)] fn create_dir_symlink(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> { std::os::unix::fs::symlink(target, link) @@ -2417,10 +2441,12 @@ fn permission_postures_persist_across_restart() { assert!(app.cycle_approval_posture()); } assert_eq!(app.approval_mode, expected); + assert_eq!(app.trust_mode, expected == ApprovalMode::Bypass); let restarted = App::new(options, &Config::default()); assert_eq!(restarted.approval_mode, expected); assert_eq!(restarted.mode_prefs.agent_approval_mode, expected); + assert_eq!(restarted.trust_mode, expected == ApprovalMode::Bypass); drop(config_env); } } @@ -2764,6 +2790,96 @@ fn test_mark_history_updated() { assert!(app.history_version > initial_version); } +#[test] +fn live_motion_invalidation_only_bumps_live_transcript_rows() { + let mut app = App::new(test_options(false), &Config::default()); + app.history = vec![ + HistoryCell::Assistant { + content: "settled".to_string(), + streaming: false, + }, + HistoryCell::Assistant { + content: "streaming".to_string(), + streaming: true, + }, + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "read_file".to_string(), + status: ToolStatus::Running, + input_summary: None, + output: None, + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })), + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "agent".to_string(), + status: ToolStatus::Running, + input_summary: Some("action: spawn".to_string()), + output: None, + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })), + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "read_file".to_string(), + status: ToolStatus::Success, + input_summary: None, + output: Some("done".to_string()), + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })), + ]; + app.resync_history_revisions(); + let history_before = app.history_revisions.clone(); + + let active = app.active_cell.get_or_insert_with(ActiveCell::new); + active.push_untracked(HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "web_search".to_string(), + status: ToolStatus::Running, + input_summary: None, + output: None, + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + }))); + let app_active_before = app.active_cell_revision; + let cell_active_before = app.active_cell.as_ref().expect("active cell").revision(); + + app.mark_live_motion_updated(); + + assert_eq!(app.history_revisions[0], history_before[0]); + assert_ne!(app.history_revisions[1], history_before[1]); + assert_ne!(app.history_revisions[2], history_before[2]); + assert_eq!(app.history_revisions[3], history_before[3]); + assert_eq!(app.history_revisions[4], history_before[4]); + assert_ne!(app.active_cell_revision, app_active_before); + assert_ne!( + app.active_cell.as_ref().expect("active cell").revision(), + cell_active_before + ); + + let history_after_all_live = app.history_revisions.clone(); + let app_active_after_all_live = app.active_cell_revision; + let cell_active_after_all_live = app.active_cell.as_ref().expect("active cell").revision(); + app.mark_live_history_motion_updated(); + + assert_eq!(app.history_revisions[0], history_after_all_live[0]); + assert_ne!(app.history_revisions[1], history_after_all_live[1]); + assert_ne!(app.history_revisions[2], history_after_all_live[2]); + assert_eq!(app.history_revisions[3], history_after_all_live[3]); + assert_eq!(app.history_revisions[4], history_after_all_live[4]); + assert_eq!(app.active_cell_revision, app_active_after_all_live); + assert_eq!( + app.active_cell.as_ref().expect("active cell").revision(), + cell_active_after_all_live + ); +} + #[test] fn expanded_tool_runs_rebase_when_history_prefix_shifts() { let mut app = App::new(test_options(false), &Config::default()); @@ -4618,6 +4734,40 @@ fn onboarding_provider_copy_is_provider_neutral_in_en() { ); } +#[test] +fn agent_current_activity_bounds_redacts_and_strips_control_sequences() { + let secret = "sk-activity-secret-1234567890"; + let raw = format!( + "\u{1b}[31mrunning\u{1b}[0m\napi_key={secret}\n\u{1b}]8;;https://example.invalid\u{7}details\u{1b}]8;;\u{7}\u{1}" + ); + let activity = AgentCurrentActivity::bounded( + AgentCurrentActivityStatus::Running, + Some(raw.clone()), + Some(format!("\u{1b}[33mFile.read\u{1b}[0m {secret}")), + Some(4), + ); + + let detail = activity.detail.expect("bounded detail"); + let tool = activity.current_tool.expect("bounded tool"); + assert!(detail.contains("running"), "{detail:?}"); + assert!(detail.contains("api_key=[redacted]"), "{detail:?}"); + assert!(detail.contains("details"), "{detail:?}"); + assert!(tool.contains("File.read"), "{tool:?}"); + assert!(tool.contains("[redacted]"), "{tool:?}"); + for safe in [&detail, &tool] { + assert!(!safe.contains(secret), "{safe:?}"); + assert!(!safe.contains('\u{1b}'), "{safe:?}"); + assert!(!safe.contains('\u{1}'), "{safe:?}"); + assert!(!safe.contains("example.invalid"), "{safe:?}"); + } + assert_eq!(activity.step, Some(4)); + assert_eq!( + raw.matches(secret).count(), + 1, + "source text stays untouched" + ); +} + #[test] fn onboarding_submit_api_key_routes_non_deepseek_provider_table() -> std::io::Result<()> { use crate::config::SavedCredential; diff --git a/crates/tui/src/tui/approval.rs b/crates/tui/src/tui/approval.rs index c3939b27c4..c073f103cc 100644 --- a/crates/tui/src/tui/approval.rs +++ b/crates/tui/src/tui/approval.rs @@ -30,6 +30,7 @@ use crate::localization::{Locale, MessageId, tr}; use crate::sandbox::SandboxPolicy; use crate::tools::apply_patch::{NormalizedApplyPatchInput, normalize_apply_patch_input}; +use crate::tools::canonical_action::canonical_action_alias; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; use crate::tui::widgets::{ApprovalWidget, ElevationWidget, Renderable}; use codewhale_config::ToolAskRule; @@ -44,7 +45,8 @@ use std::time::{Duration, Instant}; pub mod policy; pub use policy::{ - ApprovalStakes, RiskLevel, ToolCategory, classify_risk, classify_stakes, get_tool_category, + ApprovalStakes, RiskLevel, ToolCategory, classify_risk, classify_stakes, + get_tool_category_for_call, }; /// Determines when tool executions require user approval @@ -227,7 +229,8 @@ impl ApprovalRequest { intent_summary: Option<&str>, workspace: &Path, ) -> Self { - let category = get_tool_category(tool_name); + let semantic_tool_name = canonical_action_alias(tool_name, params); + let category = get_tool_category_for_call(tool_name, params); let risk = classify_risk(tool_name, category, params); let approval_grouping_key = crate::tools::approval_cache::build_approval_grouping_key(tool_name, params).0; @@ -238,7 +241,7 @@ impl ApprovalRequest { description: description.to_string(), category, risk, - impacts: build_impact_summary(tool_name, category, params), + impacts: build_impact_summary(semantic_tool_name, category, params), params: params.clone(), approval_key: approval_key.to_string(), approval_grouping_key, @@ -250,7 +253,7 @@ impl ApprovalRequest { Some(summary.to_string()) } }), - persistent_ask_rules: build_persistent_ask_rules(tool_name, params, workspace), + persistent_ask_rules: build_persistent_ask_rules(semantic_tool_name, params, workspace), } } @@ -271,9 +274,10 @@ impl ApprovalRequest { } pub fn impacts_for_locale(&self, locale: Locale) -> Vec { + let semantic_tool_name = canonical_action_alias(&self.tool_name, &self.params); match locale { Locale::ZhHans => { - build_impact_summary_zh_hans(&self.tool_name, self.category, &self.params) + build_impact_summary_zh_hans(semantic_tool_name, self.category, &self.params) } _ => self.impacts.clone(), } @@ -307,15 +311,16 @@ impl ApprovalRequest { /// Extract the most important params for the approval card. #[must_use] pub fn prominent_detail_items(&self, locale: Locale) -> Vec { - build_prominent_details(&self.tool_name, self.category, &self.params) + let semantic_tool_name = canonical_action_alias(&self.tool_name, &self.params); + build_prominent_details(semantic_tool_name, self.category, &self.params) .into_iter() .map(|mut detail| { let is_preview = detail.label == "Preview"; detail.label = localize_detail_label(&detail.label, locale).to_string(); if is_preview && let Some(lines) = detail.shell_lines.as_mut() { for line in lines.iter_mut() { - *line = - localize_preview_shell_line(&self.tool_name, line, locale).to_string(); + *line = localize_preview_shell_line(semantic_tool_name, line, locale) + .to_string(); } detail.value = lines.join("\n"); } @@ -1755,6 +1760,7 @@ impl ModalView for ElevationView { #[cfg(test)] mod tests { + use super::policy::get_tool_category; use super::*; use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; use ratatui::{Terminal, backend::TestBackend}; @@ -3974,4 +3980,99 @@ diff --git a/src/b.rs b/src/b.rs ); assert_eq!(ApprovalMode::from_config_value("unknown"), None); } + + #[test] + fn canonical_bash_keeps_original_name_but_uses_shell_approval_semantics() { + let request = ApprovalRequest::new_with_intent( + "bash-1", + "Bash", + "Run command", + &json!({"action": "run", "command": "cargo test", "cwd": "/workspace"}), + "tool:Bash", + None, + Path::new("/workspace"), + ); + + assert_eq!(request.tool_name, "Bash"); + assert_eq!(request.category, ToolCategory::Shell); + assert_eq!(request.risk, RiskLevel::Destructive); + assert_eq!( + request.persistent_ask_rules, + vec![ToolAskRule::exec_shell("cargo test")] + ); + let details = request.prominent_detail_items(Locale::En); + assert!( + details + .iter() + .any(|detail| detail.label == "Command" && detail.value == "cargo test") + ); + } + + #[test] + fn canonical_file_mutations_get_legacy_previews_and_scoped_ask_rules() { + let cases = [ + ( + "write", + json!({ + "action": "write", + "path": "/workspace/src/lib.rs", + "content": "pub fn whale() {}\n" + }), + "write_file", + "+ pub fn whale() {}", + ), + ( + "edit", + json!({ + "action": "edit", + "path": "/workspace/src/lib.rs", + "search": "old", + "replace": "new" + }), + "edit_file", + "- old", + ), + ( + "patch", + json!({ + "action": "patch", + "patch": "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1,1 +1,1 @@\n-old\n+new\n" + }), + "apply_patch", + "-old", + ), + ]; + + for (action, params, rule_tool, preview_fragment) in cases { + let request = ApprovalRequest::new_with_intent( + action, + "File", + "Mutate file", + ¶ms, + "tool:File", + None, + Path::new("/workspace"), + ); + assert_eq!(request.tool_name, "File", "{action}"); + assert_eq!(request.category, ToolCategory::FileWrite, "{action}"); + assert_eq!(request.risk, RiskLevel::Destructive, "{action}"); + assert!( + request + .persistent_ask_rules + .iter() + .any(|rule| rule.tool == rule_tool), + "{action}: {:?}", + request.persistent_ask_rules + ); + let preview = request + .prominent_detail_items(Locale::En) + .into_iter() + .find(|detail| detail.label == "Preview") + .expect("canonical file mutation must show a preview"); + assert!( + preview.value.contains(preview_fragment), + "{action}: {preview:?}" + ); + } + } } diff --git a/crates/tui/src/tui/approval/policy.rs b/crates/tui/src/tui/approval/policy.rs index 15baed7cb2..c3fecdecce 100644 --- a/crates/tui/src/tui/approval/policy.rs +++ b/crates/tui/src/tui/approval/policy.rs @@ -5,6 +5,7 @@ //! policy itself. use crate::command_safety::is_parallel_readonly_command; +use crate::tools::canonical_action::canonical_action_alias; use serde_json::Value; /// Categorizes tools by cost/risk level. @@ -83,6 +84,7 @@ pub fn get_tool_category(name: &str) -> ToolCategory { | "task_shell_wait" | "exec_shell_wait" | "exec_shell_interact" + | "exec_shell_cancel" | "exec_wait" | "exec_interact" ) { @@ -106,6 +108,12 @@ pub fn get_tool_category(name: &str) -> ToolCategory { | "update_plan" | "search" | "file_search" + | "grep_files" + | "git_status" + | "git_diff" + | "git_log" + | "git_show" + | "git_blame" | "project" | "diagnostics" ) || name.starts_with("read_") @@ -123,6 +131,12 @@ pub fn get_tool_category(name: &str) -> ToolCategory { } } +/// Categorize a concrete call after resolving an action-based canonical tool. +#[must_use] +pub fn get_tool_category_for_call(name: &str, params: &Value) -> ToolCategory { + get_tool_category(canonical_action_alias(name, params)) +} + #[must_use] pub fn classify_stakes( tool_name: &str, @@ -133,7 +147,8 @@ pub fn classify_stakes( if matches!(risk, RiskLevel::Benign) { return ApprovalStakes::Routine; } - match crate::tui::auto_review::ToolActionKind::from_tool_call(tool_name, params, category) { + let semantic_name = canonical_action_alias(tool_name, params); + match crate::tui::auto_review::ToolActionKind::from_tool_call(semantic_name, params, category) { crate::tui::auto_review::ToolActionKind::Publish | crate::tui::auto_review::ToolActionKind::Destructive | crate::tui::auto_review::ToolActionKind::Secret => ApprovalStakes::Critical, @@ -150,6 +165,7 @@ pub fn classify_stakes( /// copy on anything that can touch state outside this turn. #[must_use] pub fn classify_risk(tool_name: &str, category: ToolCategory, params: &Value) -> RiskLevel { + let tool_name = canonical_action_alias(tool_name, params); match category { // Read paths and discovery. ToolCategory::Safe | ToolCategory::McpRead => RiskLevel::Benign, @@ -315,4 +331,87 @@ mod tests { RiskLevel::Destructive ); } + + #[test] + fn canonical_actions_keep_legacy_approval_categories_and_risk() { + let cases = [ + ("Bash", "run", ToolCategory::Shell, RiskLevel::Destructive), + ("Bash", "wait", ToolCategory::Shell, RiskLevel::Destructive), + ( + "Bash", + "interact", + ToolCategory::Shell, + RiskLevel::Destructive, + ), + ( + "Bash", + "cancel", + ToolCategory::Shell, + RiskLevel::Destructive, + ), + ("File", "read", ToolCategory::Safe, RiskLevel::Benign), + ("File", "list", ToolCategory::Safe, RiskLevel::Benign), + ("File", "search_name", ToolCategory::Safe, RiskLevel::Benign), + ( + "File", + "search_content", + ToolCategory::Safe, + RiskLevel::Benign, + ), + ( + "File", + "write", + ToolCategory::FileWrite, + RiskLevel::Destructive, + ), + ( + "File", + "edit", + ToolCategory::FileWrite, + RiskLevel::Destructive, + ), + ( + "File", + "patch", + ToolCategory::FileWrite, + RiskLevel::Destructive, + ), + ("Git", "status", ToolCategory::Safe, RiskLevel::Benign), + ("Git", "diff", ToolCategory::Safe, RiskLevel::Benign), + ("Git", "log", ToolCategory::Safe, RiskLevel::Benign), + ("Git", "show", ToolCategory::Safe, RiskLevel::Benign), + ("Git", "blame", ToolCategory::Safe, RiskLevel::Benign), + ( + "Run", + "tests", + ToolCategory::Unknown, + RiskLevel::Destructive, + ), + ( + "Run", + "verifiers", + ToolCategory::Unknown, + RiskLevel::Destructive, + ), + ("Web", "search", ToolCategory::Network, RiskLevel::Benign), + ( + "Web", + "fetch", + ToolCategory::Network, + RiskLevel::Destructive, + ), + ("Web", "wait", ToolCategory::Network, RiskLevel::Benign), + ]; + + for (family, action, expected_category, expected_risk) in cases { + let params = json!({"action": action}); + let category = get_tool_category_for_call(family, ¶ms); + assert_eq!(category, expected_category, "{family}.{action}"); + assert_eq!( + classify_risk(family, category, ¶ms), + expected_risk, + "{family}.{action}" + ); + } + } } diff --git a/crates/tui/src/tui/auto_review.rs b/crates/tui/src/tui/auto_review.rs index 3bee61da2e..872bd63d90 100644 --- a/crates/tui/src/tui/auto_review.rs +++ b/crates/tui/src/tui/auto_review.rs @@ -7,7 +7,7 @@ #![allow(dead_code)] use crate::tui::approval::{ - ApprovalMode, RiskLevel, ToolCategory, classify_risk, get_tool_category, + ApprovalMode, RiskLevel, ToolCategory, classify_risk, get_tool_category_for_call, }; use serde_json::{Value, json}; @@ -97,7 +97,9 @@ impl ToolActionKind { #[must_use] pub fn from_tool_call(tool_name: &str, params: &Value, category: ToolCategory) -> Self { - let normalized = tool_name.to_ascii_lowercase(); + let semantic_tool_name = + crate::tools::canonical_action::canonical_action_alias(tool_name, params); + let normalized = semantic_tool_name.to_ascii_lowercase(); // Unified action-parameterized tools (piagent phase B): classify on // the action-qualified name so a destructive action keeps the stakes @@ -199,7 +201,7 @@ impl<'a> AutoReviewContext<'a> { workspace_trusted: bool, dirty_worktree: bool, ) -> Self { - let category = get_tool_category(tool_name); + let category = get_tool_category_for_call(tool_name, params); let risk = classify_risk(tool_name, category, params); let action_kind = ToolActionKind::from_tool_call(tool_name, params, category); Self { @@ -1408,4 +1410,55 @@ mod tests { assert_eq!(event["policy_has_guidance"], true); assert_eq!(event["dirty_worktree"], true); } + + #[test] + fn canonical_actions_use_semantic_auto_review_without_losing_audit_name() { + let cases = [ + ( + "Bash", + json!({"action": "run", "command": "cargo test"}), + ToolCategory::Shell, + ToolActionKind::Shell, + ), + ( + "File", + json!({"action": "edit", "path": "src/lib.rs"}), + ToolCategory::FileWrite, + ToolActionKind::Write, + ), + ( + "Git", + json!({"action": "status"}), + ToolCategory::Safe, + ToolActionKind::Git, + ), + ( + "Run", + json!({"action": "tests"}), + ToolCategory::Unknown, + ToolActionKind::Unknown, + ), + ( + "Web", + json!({"action": "search", "query": "Codewhale"}), + ToolCategory::Network, + ToolActionKind::Network, + ), + ]; + + for (tool_name, params, category, action_kind) in cases { + let context = AutoReviewContext::from_tool_call( + tool_name, + ¶ms, + RunOrigin::Interactive, + ApprovalMode::Auto, + None, + true, + false, + ); + assert_eq!(context.tool_name, tool_name); + assert_eq!(context.category, category, "{tool_name}"); + assert_eq!(context.action_kind, action_kind, "{tool_name}"); + } + } } diff --git a/crates/tui/src/tui/color_compat.rs b/crates/tui/src/tui/color_compat.rs index 86c88d4807..fc7db8d4ea 100644 --- a/crates/tui/src/tui/color_compat.rs +++ b/crates/tui/src/tui/color_compat.rs @@ -68,7 +68,7 @@ impl ColorCompatBackend { forced_size: None, terminal_size: None, render_debug: RenderDebugLog::from_env(), - ascii_safe: env_flag_enabled(std::env::var(ASCII_SAFE_ENV).ok().as_deref()), + ascii_safe: ascii_safe_enabled(), } } @@ -285,6 +285,14 @@ fn env_flag_enabled(value: Option<&str>) -> bool { ) } +/// Whether terminal chrome must use portable ASCII spellings. Text producers +/// that would otherwise compose multi-cell Unicode labels share this decision +/// with the backend's single-cell glyph adapter. +#[must_use] +pub(crate) fn ascii_safe_enabled() -> bool { + env_flag_enabled(std::env::var(ASCII_SAFE_ENV).ok().as_deref()) +} + /// Narrow every CodeWhale-authored decorative glyph to a semantic ASCII /// alternative. Scope is deliberate: box drawing, block elements (whale /// mark, meters, rails), braille state markers, geometric role/state marks, @@ -455,6 +463,7 @@ mod tests { ("│", "|"), ("┌", "+"), ("▶", ">"), + ("▷", ">"), ("▼", "v"), ("✓", "Y"), ("✕", "X"), diff --git a/crates/tui/src/tui/command_palette.rs b/crates/tui/src/tui/command_palette.rs index 6bf2cc8fc8..3e067eec14 100644 --- a/crates/tui/src/tui/command_palette.rs +++ b/crates/tui/src/tui/command_palette.rs @@ -130,14 +130,12 @@ pub fn build_entries_with_plugins( .description .clone() .unwrap_or_else(|| "User-defined command".to_string()); - if let Some(hint) = &command.argument_hint - && !hint.trim().is_empty() - { + if let Some(hint) = command.display_usage() { description.push_str(" "); - description.push_str(hint.trim()); + description.push_str(hint); } let slash_command = format!("/{}", command.name); - let action = if command.argument_hint.is_some() { + let action = if command.takes_arguments() { CommandPaletteAction::InsertText { text: format!("{slash_command} "), } @@ -1481,6 +1479,41 @@ mod tests { )); } + #[test] + fn command_palette_uses_frontmatter_name_usage_and_arguments() { + let tmp = TempDir::new().expect("tempdir"); + let workspace = tmp.path().join("workspace"); + let commands_dir = workspace.join(".codewhale").join("commands"); + std::fs::create_dir_all(&commands_dir).expect("create commands dir"); + std::fs::write( + commands_dir.join("workflow-file.md"), + "---\nname: inspect\ndescription: Inspect a target\nusage: /inspect \narguments: \nargument-hint: \n---\nInspect $ARGUMENTS", + ) + .expect("write user command"); + + let entries = build_entries( + Locale::En, + tmp.path().join("skills").as_path(), + false, + workspace.as_path(), + tmp.path().join("mcp.json").as_path(), + None, + ); + let user_entry = entries + .iter() + .find(|entry| entry.section == PaletteSection::Command && entry.label == "/inspect") + .expect("frontmatter name should be the palette command"); + + assert_eq!(user_entry.description, "Inspect a target /inspect "); + assert!(matches!( + &user_entry.action, + CommandPaletteAction::InsertText { text } if text == "/inspect " + )); + assert!(!entries.iter().any(|entry| { + entry.section == PaletteSection::Command && entry.label == "/workflow-file" + })); + } + #[test] fn command_palette_excludes_hidden_user_commands() { let tmp = TempDir::new().expect("tempdir"); @@ -1509,6 +1542,33 @@ mod tests { ); } + #[test] + fn hidden_frontmatter_name_override_suppresses_shadowed_builtin() { + let tmp = TempDir::new().expect("tempdir"); + let workspace = tmp.path().join("workspace"); + let commands_dir = workspace.join(".codewhale").join("commands"); + std::fs::create_dir_all(&commands_dir).expect("create commands dir"); + std::fs::write( + commands_dir.join("private-help.md"), + "---\nname: help\nhidden: true\n---\nprivate help", + ) + .expect("write hidden user command"); + + let entries = build_entries( + Locale::En, + tmp.path().join("skills").as_path(), + false, + workspace.as_path(), + tmp.path().join("mcp.json").as_path(), + None, + ); + + assert!(!entries.iter().any(|entry| { + entry.section == PaletteSection::Command + && matches!(entry.label.as_str(), "/help" | "/private-help") + })); + } + #[test] fn command_palette_filters_shadowed_builtin_aliases_from_description() { let tmp = TempDir::new().expect("tempdir"); diff --git a/crates/tui/src/tui/coordination_detail.rs b/crates/tui/src/tui/coordination_detail.rs new file mode 100644 index 0000000000..11f9c738d9 --- /dev/null +++ b/crates/tui/src/tui/coordination_detail.rs @@ -0,0 +1,436 @@ +//! Bounded, non-transcript presentation for delegated coordination receipts. +//! +//! Headless callers keep the machine-readable typed projection. The TUI uses +//! this single formatter for its Work inspector so compact rows and details do +//! not grow a second, string-parsed coordination model. + +use std::fmt::Write as _; + +use crate::localization::{Locale, MessageId, tr}; +use crate::tools::subagent::CoordinationDetailProjection; +use crate::tools::subagent::coord::{DecisionStatus, ReconciliationReceipt}; + +#[must_use] +pub(crate) fn summary(locale: Locale, projection: &CoordinationDetailProjection) -> String { + [ + tr(locale, MessageId::CoordinationSummaryDecisions) + .replace("{count}", &projection.decisions.len().to_string()), + tr(locale, MessageId::CoordinationSummaryContentions) + .replace("{count}", &projection.contentions.len().to_string()), + tr(locale, MessageId::CoordinationSummaryReconciled) + .replace("{count}", &projection.reconciliations.len().to_string()), + ] + .join(" · ") +} + +#[must_use] +pub(crate) fn needs_attention(projection: &CoordinationDetailProjection) -> bool { + projection + .decisions + .iter() + .any(|decision| decision.status == DecisionStatus::Proposed) + || projection + .reconciliations + .iter() + .any(|receipt| receipt.verification_outcome != "verified") + || projection + .contentions + .iter() + .any(|contention| contention.disposition.blocks_admission()) +} + +/// Format the durable coordination projection for the shared Work pager. +/// +/// Deliberately omitted: decision constraints and general evidence handles. +/// Those fields inform delegated prompts and headless inspection, but they can +/// contain operator-authored detail that does not belong in ambient TUI chrome. +#[must_use] +pub(crate) fn format(locale: Locale, projection: &CoordinationDetailProjection) -> String { + let mut out = String::new(); + let _ = writeln!( + out, + "{} · {} · {}", + tr(locale, MessageId::CoordinationSchema) + .replace("{value}", &projection.schema_version.to_string()), + tr(locale, MessageId::CoordinationSequence) + .replace("{value}", &projection.sequence.to_string()), + tr(locale, MessageId::CoordinationPerSectionLimit) + .replace("{limit}", &projection.limit.to_string()) + ); + + section( + &mut out, + tr(locale, MessageId::CoordinationDecisionsHeading).as_ref(), + ); + if projection.decisions.is_empty() { + let _ = writeln!(out, "{}", tr(locale, MessageId::CoordinationNone)); + } else { + for decision in &projection.decisions { + let status = tr(locale, MessageId::CoordinationStatus).replace( + "{status}", + decision_status(locale, decision.status).as_ref(), + ); + let owner = + tr(locale, MessageId::CoordinationOwner).replace("{owner}", &decision.owner); + let version = tr(locale, MessageId::CoordinationVersion) + .replace("{version}", &decision.version.to_string()); + let _ = writeln!( + out, + "{} · {}\n {} · {} · {}", + decision.decision_id, decision.subject, status, owner, version + ); + } + } + + section( + &mut out, + tr(locale, MessageId::CoordinationWriteClaimsHeading).as_ref(), + ); + if projection.write_claims.is_empty() { + let _ = writeln!(out, "{}", tr(locale, MessageId::CoordinationNone)); + } else { + for receipt in &projection.write_claims { + let claim = &receipt.claim; + let workspace = if receipt.isolated_worktree { + tr(locale, MessageId::CoordinationIsolated) + } else { + tr(locale, MessageId::CoordinationSharedWorkspace) + }; + let _ = writeln!( + out, + "{} · {}\n {}\n {}", + claim.owner, + workspace, + tr(locale, MessageId::CoordinationPaths).replace( + "{paths}", + &joined_paths(locale, &claim.roots, &claim.exact_files) + ), + tr(locale, MessageId::CoordinationContracts) + .replace("{contracts}", &joined_or_none(locale, &claim.contracts)) + ); + } + } + + section( + &mut out, + tr(locale, MessageId::CoordinationContentionsHeading).as_ref(), + ); + if projection.contentions.is_empty() { + let _ = writeln!(out, "{}", tr(locale, MessageId::CoordinationNone)); + } else { + for receipt in &projection.contentions { + let claimant = tr(locale, MessageId::CoordinationClaimant) + .replace("{claimant}", &receipt.claimant); + let owner = tr(locale, MessageId::CoordinationOwner) + .replace("{owner}", &receipt.conflicting_owner); + let _ = writeln!( + out, + "{} · {}\n {}\n {}\n {}", + claimant, + owner, + tr(locale, MessageId::CoordinationPaths).replace( + "{paths}", + &joined_paths(locale, &receipt.roots, &receipt.exact_files) + ), + tr(locale, MessageId::CoordinationContracts) + .replace("{contracts}", &joined_or_none(locale, &receipt.contracts)), + tr(locale, MessageId::CoordinationDisposition) + .replace("{disposition}", receipt.disposition.as_str()) + ); + } + } + + section( + &mut out, + tr(locale, MessageId::CoordinationNeutralReconciliationHeading).as_ref(), + ); + if projection.reconciliations.is_empty() { + let _ = writeln!(out, "{}", tr(locale, MessageId::CoordinationNone)); + } else { + for receipt in &projection.reconciliations { + format_reconciliation(&mut out, locale, receipt); + } + } + + section( + &mut out, + tr(locale, MessageId::CoordinationContextProjectionsHeading).as_ref(), + ); + if projection.context_projections.is_empty() { + let _ = writeln!(out, "{}", tr(locale, MessageId::CoordinationNone)); + } else { + for receipt in &projection.context_projections { + let decisions = tr(locale, MessageId::CoordinationContextDecisions).replace( + "{decisions}", + &joined_or_none(locale, &receipt.decision_ids), + ); + let bytes = tr(locale, MessageId::CoordinationBytes) + .replace("{count}", &receipt.projected_bytes.to_string()); + let deduplicated = tr(locale, MessageId::CoordinationDeduplicated) + .replace("{count}", &receipt.deduplicated.to_string()); + let omitted = tr(locale, MessageId::CoordinationOmitted) + .replace("{count}", &receipt.omitted.to_string()); + let _ = writeln!( + out, + "{} · {} · {} · {} · {}", + receipt.child_id, decisions, bytes, deduplicated, omitted + ); + } + } + + section( + &mut out, + tr(locale, MessageId::CoordinationActiveHotPathsHeading).as_ref(), + ); + if projection.metrics.hottest_paths.is_empty() { + let _ = writeln!(out, "{}", tr(locale, MessageId::CoordinationNone)); + } else { + for path in &projection.metrics.hottest_paths { + let active_claims = tr(locale, MessageId::CoordinationActiveClaims) + .replace("{count}", &path.active_claims.to_string()); + let _ = writeln!(out, "{} · {}", path.path, active_claims); + } + } + section( + &mut out, + tr(locale, MessageId::CoordinationMetricsNoteHeading).as_ref(), + ); + // The headless projection retains the exact typed metrics note. Ambient + // TUI chrome owns a localized explanation of the same current invariant. + let _ = writeln!( + out, + "{}", + tr(locale, MessageId::CoordinationMetricsNoAuthoritativeSource) + ); + + out.trim_end().to_string() +} + +fn section(out: &mut String, label: &str) { + let _ = write!(out, "\n{label}\n"); +} + +fn format_reconciliation(out: &mut String, locale: Locale, receipt: &ReconciliationReceipt) { + let candidates = tr(locale, MessageId::CoordinationCandidates) + .replace("{count}", &receipt.candidate_handles.len().to_string()); + let retry = tr(locale, MessageId::CoordinationRetry) + .replace("{count}", &receipt.retry_count.to_string()) + .replace("{limit}", &receipt.retry_limit.to_string()); + let _ = writeln!( + out, + "{} · {} · {}\n {}\n {}\n {}\n {}", + receipt.subject, + candidates, + retry, + tr(locale, MessageId::CoordinationOwner).replace("{owner}", &receipt.owner), + tr(locale, MessageId::CoordinationReviewer).replace( + "{reviewer}", + &joined_or_none(locale, &receipt.reviewer_evidence_handles) + ), + tr(locale, MessageId::CoordinationVerifier).replace( + "{verifier}", + &joined_or_none(locale, &receipt.verifier_evidence_handles) + ), + tr(locale, MessageId::CoordinationVerification) + .replace("{verification}", &receipt.verification_outcome) + ); +} + +fn decision_status(locale: Locale, status: DecisionStatus) -> std::borrow::Cow<'static, str> { + match status { + DecisionStatus::Proposed => tr(locale, MessageId::CoordinationStatusProposed), + DecisionStatus::Accepted => tr(locale, MessageId::CoordinationStatusAccepted), + DecisionStatus::Superseded => tr(locale, MessageId::CoordinationStatusSuperseded), + } +} + +fn joined_paths(locale: Locale, roots: &[String], exact_files: &[String]) -> String { + let values = roots + .iter() + .chain(exact_files) + .map(String::as_str) + .collect::>(); + if values.is_empty() { + tr(locale, MessageId::CoordinationNoneValue).into_owned() + } else { + values.join(", ") + } +} + +fn joined_or_none(locale: Locale, values: &[String]) -> String { + if values.is_empty() { + tr(locale, MessageId::CoordinationNoneValue).into_owned() + } else { + values.join(", ") + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::tools::subagent::coord::{ + ContextProjectionReceipt, CoordinationDetailMetrics, CoordinationHotPath, DecisionRecord, + PersistedWriteClaim, WriteContentionDisposition, WriteContentionReceipt, WriteScopeClaim, + }; + + fn projection() -> CoordinationDetailProjection { + CoordinationDetailProjection { + schema_version: 1, + sequence: 9, + decisions: vec![DecisionRecord { + decision_id: "decision-ui".to_string(), + subject: "composer edges".to_string(), + status: DecisionStatus::Accepted, + owner: "planner".to_string(), + scope: vec!["path:crates/tui".to_string()], + constraints: vec!["PRIVATE-TRANSCRIPT-MARKER".to_string()], + evidence_handles: vec!["artifact:hidden-evidence".to_string()], + version: 3, + sequence: 1, + }], + write_claims: vec![PersistedWriteClaim { + claim: WriteScopeClaim { + owner: "worker-a".to_string(), + roots: vec!["crates/tui".to_string()], + exact_files: vec!["Cargo.toml".to_string()], + contracts: vec!["ui-contract".to_string()], + }, + sequence: 2, + isolated_worktree: false, + }], + reconciliations: vec![ReconciliationReceipt { + reconciliation_id: "reconcile-ui".to_string(), + subject: "composer edges".to_string(), + owner: "release-owner".to_string(), + input_decisions: vec!["decision-a".to_string(), "decision-b".to_string()], + outcome: "candidate-a".to_string(), + evidence_handles: Vec::new(), + candidate_handles: vec!["branch:a".to_string(), "branch:b".to_string()], + retry_count: 1, + retry_limit: 3, + reviewer_evidence_handles: vec!["agent:reviewer".to_string()], + verifier_evidence_handles: vec!["agent:verifier".to_string()], + verification_outcome: "verified".to_string(), + sequence: 3, + }], + context_projections: vec![ContextProjectionReceipt { + child_id: "worker-a".to_string(), + decision_ids: vec!["decision-ui".to_string()], + projected_bytes: 128, + deduplicated: 2, + omitted: 1, + sequence: 4, + }], + contentions: vec![WriteContentionReceipt { + claimant: "worker-b".to_string(), + conflicting_owner: "worker-a".to_string(), + roots: vec!["crates/tui".to_string()], + exact_files: vec!["Cargo.toml".to_string()], + contracts: vec!["ui-contract".to_string()], + disposition: WriteContentionDisposition::BlockedPendingIsolationOrSerialization, + resolution_sequence: None, + sequence: 5, + }], + metrics: CoordinationDetailMetrics { + hottest_paths: vec![CoordinationHotPath { + path: "crates/tui".to_string(), + active_claims: 2, + }], + package_or_module_growth: Some(json!({"ignored": true})), + route_or_cost: None, + note: "Only active owners contribute to hot paths".to_string(), + }, + bounded: true, + limit: 24, + } + } + + #[test] + fn formatter_uses_typed_receipts_without_transcript_shaped_fields() { + let text = format(Locale::En, &projection()); + for required in [ + "Schema 1 · sequence 9 · up to 24 per section", + "decision-ui · composer edges", + "status accepted · owner planner · version 3", + "claimant worker-b · owner worker-a", + "paths crates/tui, Cargo.toml", + "contracts ui-contract", + "disposition blocked_pending_isolation_or_serialization", + "composer edges · 2 candidates · retry 1/3", + "reviewer agent:reviewer", + "verifier agent:verifier", + "verification verified", + "worker-a · decisions decision-ui · 128 bytes · 2 deduplicated · 1 omitted", + "crates/tui · 2 active claims", + ] { + assert!(text.contains(required), "missing {required}:\n{text}"); + } + assert!(!text.contains("PRIVATE-TRANSCRIPT-MARKER"), "{text}"); + assert!(!text.contains("bounded to 24 records"), "{text}"); + assert!(!text.contains("hidden-evidence"), "{text}"); + assert!(!text.contains("ignored"), "{text}"); + } + + #[test] + fn complete_locale_packs_translate_chrome_and_preserve_receipt_values() { + let value = projection(); + let english = format(Locale::En, &value); + for locale in Locale::shipped_complete() { + let text = format(*locale, &value); + let summary = summary(*locale, &value); + for literal in [ + "decision-ui", + "composer edges", + "worker-a", + "blocked_pending_isolation_or_serialization", + "agent:reviewer", + "agent:verifier", + "crates/tui", + ] { + assert!(text.contains(literal), "{locale:?} lost {literal}:\n{text}"); + } + assert!( + !text.contains('{'), + "{locale:?} has a raw placeholder:\n{text}" + ); + assert!( + !summary.contains('{'), + "{locale:?} summary has a raw placeholder: {summary}" + ); + if *locale != Locale::En { + assert_ne!(text, english, "{locale:?} fell back to English"); + } + } + } + + #[test] + fn proposed_decisions_and_unverified_reconciliation_need_attention() { + let mut value = projection(); + value.contentions.clear(); + assert!(!needs_attention(&value)); + value.decisions[0].status = DecisionStatus::Proposed; + assert!(needs_attention(&value)); + value.decisions[0].status = DecisionStatus::Accepted; + value.reconciliations[0].verification_outcome = "blocked".to_string(); + assert!(needs_attention(&value)); + } + + #[test] + fn blocked_contention_attention_uses_durable_resolution_state() { + let mut value = projection(); + assert!(needs_attention(&value)); + + value.contentions[0].disposition = WriteContentionDisposition::ResolvedBySuccessfulClaim; + value.contentions[0].resolution_sequence = Some(6); + assert!(!needs_attention(&value)); + + let text = format(Locale::En, &value); + assert!( + text.contains("disposition resolved_by_successful_claim"), + "{text}" + ); + assert!(!text.contains("disposition blocked_pending"), "{text}"); + } +} diff --git a/crates/tui/src/tui/diff_render.rs b/crates/tui/src/tui/diff_render.rs index fafcd8e3c8..e8ae4121cd 100644 --- a/crates/tui/src/tui/diff_render.rs +++ b/crates/tui/src/tui/diff_render.rs @@ -18,13 +18,23 @@ pub struct DiffFileSummary { pub fn render_diff(diff: &str, width: u16) -> Vec> { let mut lines = Vec::new(); - let mut old_line: Option = None; - let mut new_line: Option = None; let summaries = summarize_diff(diff); if !summaries.is_empty() { lines.extend(render_diff_summary(&summaries, width)); } + lines.extend(render_diff_body(diff, width)); + lines +} + +/// Render only the diff body. Callers that already own a semantic summary use +/// this form so the bounded preview budget is spent on the actual red/green +/// evidence instead of a second, generic summary. +#[must_use] +pub fn render_diff_body(diff: &str, width: u16) -> Vec> { + let mut lines = Vec::new(); + let mut old_line: Option = None; + let mut new_line: Option = None; for raw in diff.lines() { if raw.starts_with("diff --git") || raw.starts_with("index ") { diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index ce83bbf156..a196c624c5 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -62,15 +62,15 @@ pub(crate) fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { // Animate the spacer between the left status line and the right-hand // chips whenever a turn is live: model loading/streaming, compacting, or - // sub-agents in flight. The spout strip and dot-pulse fallback are gated - // on `fancy_animations` (the "do I want animated chrome" knob); - // `low_motion` governs streaming pacing and redraw cadence. + // sub-agents in flight. The shared Full-motion policy owns the spout strip + // and dot pulse; Reduced and Still keep the truthful label without cycling. if footer_working_strip_active(app) { let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); - let dot_frame = footer_working_label_frame(now_ms, app.fancy_animations); + let status_motion = app.motion_policy().allows_status_spin(); + let dot_frame = footer_working_label_frame(now_ms, status_motion); // Header ● Live owns coarse turn state; footer shows action detail only. let elapsed_secs = app .turn_started_at @@ -121,9 +121,9 @@ pub(crate) fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { // math in `footer_working_strip_glyph_at` was tuned for this cadence // (`t = frame / 1000.0`, primary term × 8.0 ≈ 1.3 Hz at 1 ms ticks), // so frame must advance at ~1000 units/sec to produce the intended - // animation feel. `fancy_animations = false` hides the strip and pins - // the textual fallback to `working`. - if app.fancy_animations { + // animation feel. Non-Full modes hide the strip and pin the textual + // fallback to `working`. + if status_motion { props.working_strip_frame = Some(now_ms); } } else if matches!(props.state_label.as_str(), "idle" | "ready") @@ -286,8 +286,8 @@ pub(crate) fn footer_working_strip_active(app: &App) -> bool { || turn_in_progress } -pub(crate) fn footer_working_label_frame(now_ms: u64, fancy_animations: bool) -> u64 { - if fancy_animations { now_ms / 400 } else { 0 } +pub(crate) fn footer_working_label_frame(now_ms: u64, animate: bool) -> u64 { + if animate { now_ms / 400 } else { 0 } } #[cfg(test)] diff --git a/crates/tui/src/tui/glyphs.rs b/crates/tui/src/tui/glyphs.rs index 9e5e7451a0..7f22fcb083 100644 --- a/crates/tui/src/tui/glyphs.rs +++ b/crates/tui/src/tui/glyphs.rs @@ -61,7 +61,7 @@ pub fn ascii_fallback(symbol: &str) -> Option<&'static str> { "▞" => Some("/"), "░" | "▒" | "▓" => Some(":"), "▱" => Some("-"), - "▶" | "▸" | "›" | "❯" | "→" | "↗" | "↘" | "»" => Some(">"), + "▶" | "▷" | "▸" | "›" | "❯" | "→" | "↗" | "↘" | "»" => Some(">"), "◀" | "◂" | "‹" | "❮" | "←" | "↖" | "↙" | "«" => Some("<"), "▼" | "▾" | "▽" | "↓" => Some("v"), "▲" | "△" | "↑" => Some("^"), @@ -102,6 +102,7 @@ mod tests { fn charter_has_narrow_semantic_fallbacks() { for (rich, safe) in [ (SELECTION, ">"), + ("▷", ">"), (CURRENT, "."), (USER, "|"), (DONE, "Y"), diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 80c1ff2f87..efd9163846 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -14,12 +14,14 @@ use crate::tools::plan::PlanSnapshot; use crate::tools::review::ReviewOutput; use crate::tui::app::TranscriptSpacing; use crate::tui::diff_render; +use crate::tui::motion::MotionMode; use crate::tui::ui_text::CopyLineSeparator; mod agent_activity; mod archived_context; mod checklist; mod constants; +mod file_mutation; mod message; mod plan; mod thinking; @@ -51,6 +53,7 @@ use tool_output::{render_exec_output_mode, render_tool_output_mode, wrap_plain_l #[cfg(test)] use agent_activity::extract_agent_id; +pub use file_mutation::FileMutationReceipt; pub use plan::PlanUpdateCell; #[cfg(test)] use thinking::extract_reasoning_summary; @@ -157,8 +160,10 @@ pub struct TranscriptRenderOptions { pub show_thinking: bool, pub verbose: bool, pub show_tool_details: bool, + pub inline_diff_mode: crate::settings::InlineDiffMode, pub calm_mode: bool, pub low_motion: bool, + pub motion_mode: MotionMode, pub spacing: TranscriptSpacing, } @@ -168,14 +173,35 @@ impl Default for TranscriptRenderOptions { show_thinking: true, verbose: false, show_tool_details: true, + inline_diff_mode: crate::settings::InlineDiffMode::Full, calm_mode: false, low_motion: false, + motion_mode: MotionMode::Full, spacing: TranscriptSpacing::Comfortable, } } } impl HistoryCell { + #[must_use] + pub(crate) fn has_live_motion(&self) -> bool { + match self { + HistoryCell::Assistant { streaming, .. } => *streaming, + HistoryCell::Tool(ToolCell::Generic(tool)) + if tool.name == "agent" && !agent_activity::is_agent_inspection(tool) => + { + false + } + HistoryCell::Tool(tool) => tool.is_running(), + HistoryCell::User { .. } + | HistoryCell::System { .. } + | HistoryCell::Error { .. } + | HistoryCell::Thinking { .. } + | HistoryCell::ArchivedContext { .. } + | HistoryCell::SubAgent(_) => false, + } + } + /// Render the cell into a set of terminal lines. /// /// This is the live-display path used by widgets that don't already pass @@ -263,7 +289,7 @@ impl HistoryCell { options: TranscriptRenderOptions, folded: bool, ) -> Vec> { - match self { + let mut lines = match self { HistoryCell::Thinking { streaming, duration_secs, @@ -287,6 +313,12 @@ impl HistoryCell { folded ^ !options.verbose, options.low_motion, ), + HistoryCell::Tool(ToolCell::PatchSummary(cell)) => cell.render( + width, + options.low_motion, + RenderMode::Live, + options.inline_diff_mode, + ), HistoryCell::Tool(cell) if !options.show_tool_details && !cell.is_failed() => { let mut lines = cell.lines_with_motion(width, options.low_motion); if lines.len() > 2 { @@ -323,7 +355,20 @@ impl HistoryCell { HistoryCell::ArchivedContext { .. } => { render_archived_context(self, width, options.low_motion) } + }; + if matches!(self, HistoryCell::Tool(_)) { + match options.motion_mode { + MotionMode::Reduced => apply_static_tool_markers( + &mut lines, + crate::tui::spinner::BRAILLE_SPINNER_STILL_FRAME, + ), + MotionMode::Still => { + apply_static_tool_markers(&mut lines, crate::tui::spinner::LIVE_STATIC_MARKER) + } + MotionMode::Full => {} + } } + lines } #[allow(dead_code)] @@ -422,26 +467,6 @@ impl HistoryCell { HistoryCell::ArchivedContext { .. } => render_archived_context(self, width, true), } } - - /// Whether this cell is the continuation of a streaming assistant message. - #[must_use] - pub fn is_stream_continuation(&self) -> bool { - matches!( - self, - HistoryCell::Assistant { - streaming: true, - .. - } - ) - } - - #[must_use] - pub fn is_conversational(&self) -> bool { - matches!( - self, - HistoryCell::User { .. } | HistoryCell::Assistant { .. } | HistoryCell::Thinking { .. } - ) - } } /// Convert a message into history cells for rendering. @@ -455,9 +480,12 @@ pub fn history_cells_from_message(msg: &Message) -> Vec { let mut cells = Vec::new(); - for block in &msg.content { + for (block_index, block) in msg.content.iter().enumerate() { match block { ContentBlock::Text { text, .. } => { + if is_trailing_turn_metadata_block(msg, block_index, text) { + continue; + } if text.starts_with("[tool_history_repair]") { cells.push(HistoryCell::System { content: text.clone(), @@ -539,6 +567,18 @@ pub fn history_cells_from_message(msg: &Message) -> Vec { cells } +fn is_trailing_turn_metadata_block(msg: &Message, block_index: usize, text: &str) -> bool { + if msg.role != "user" || block_index == 0 || block_index + 1 != msg.content.len() { + return false; + } + + let trimmed = text.trim(); + trimmed + .strip_prefix("") + .and_then(|body| body.strip_suffix("")) + .is_some() +} + // === Tool Cells === /// Variants describing a tool result cell. @@ -549,6 +589,10 @@ pub enum ToolCell { PlanUpdate(PlanUpdateCell), PatchSummary(PatchSummaryCell), Review(ReviewCell), + /// Standalone preview compatibility cell. Approval previews now live in + /// the modal and successful mutations use `PatchSummary`, but keeping this + /// renderer avoids discarding the older transcript shape outright. + #[allow(dead_code)] DiffPreview(DiffPreviewCell), Mcp(McpToolCell), ViewImage(ViewImageCell), @@ -557,6 +601,20 @@ pub enum ToolCell { } impl ToolCell { + /// Whether this tool cell projects durable Work state rather than a + /// transient action receipt. Transcript rhythm uses this semantic split + /// to keep plans, checklists, and workflows legible without teaching the + /// renderer about individual tool payloads. + #[must_use] + pub(crate) fn is_durable_work_receipt(&self) -> bool { + matches!(self, ToolCell::PlanUpdate(_)) + || matches!( + self, + ToolCell::Generic(cell) + if cell.name == "workflow" || is_checklist_tool_name(&cell.name) + ) + } + /// Status for cells that have a concrete lifecycle state. pub fn status(&self) -> Option { match self { @@ -640,7 +698,12 @@ impl ToolCell { ToolCell::Exec(cell) => cell.render(width, low_motion, mode), ToolCell::Exploring(cell) => cell.lines_with_motion(width, low_motion), ToolCell::PlanUpdate(cell) => cell.lines_with_motion(width, low_motion), - ToolCell::PatchSummary(cell) => cell.render(width, low_motion, mode), + ToolCell::PatchSummary(cell) => cell.render( + width, + low_motion, + mode, + crate::settings::InlineDiffMode::Full, + ), ToolCell::Review(cell) => cell.render(width, low_motion, mode), ToolCell::DiffPreview(cell) => cell.lines_with_motion(width, low_motion), ToolCell::Mcp(cell) => cell.render(width, low_motion, mode), @@ -729,6 +792,15 @@ impl ExecCell { // to the single header line in live mode. The bottom shell strip owns // live/background detail, failures stay fully verbose so errors remain // visible, and Transcript mode keeps everything for the pager/clipboard. + if mode == RenderMode::Live + && self + .output + .as_deref() + .is_some_and(is_exact_evidence_receipt) + { + lines.push(render_spillover_annotation(std::path::Path::new(""), width)); + return wrap_card_rail(lines); + } if mode == RenderMode::Live && self.status == ToolStatus::Success { if let Some(duration_ms) = self.duration_ms && duration_ms >= 1000 @@ -951,13 +1023,14 @@ pub struct ExploringEntry { pub status: ToolStatus, } -/// Cell for patch summaries emitted by the patch tool. +/// Calm outcome and exact evidence for a structured File mutation. #[derive(Debug, Clone)] pub struct PatchSummaryCell { pub path: String, pub summary: String, pub status: ToolStatus, pub error: Option, + pub receipt: Option, } impl PatchSummaryCell { @@ -966,28 +1039,40 @@ impl PatchSummaryCell { width: u16, low_motion: bool, mode: RenderMode, + inline_diff_mode: crate::settings::InlineDiffMode, ) -> Vec> { let mut lines = Vec::new(); + let header_summary = self + .receipt + .as_ref() + .map(FileMutationReceipt::outcome_label) + .unwrap_or_else(|| self.path.clone()); lines.push(render_tool_header_with_summary( - "Patch", - Some(&self.path), + "File", + Some(&header_summary), tool_status_label(self.status), self.status, None, low_motion, )); - lines.extend(render_compact_kv( - "file", - &self.path, - tool_value_style(), - width, - )); - lines.extend(render_tool_output_mode( - &self.summary, - width, - TOOL_COMMAND_LINE_LIMIT, - mode, - )); + if self.status == ToolStatus::Success + && let Some(receipt) = self.receipt.as_ref() + { + lines.extend(receipt.render_inline(width, inline_diff_mode)); + } else { + lines.extend(render_compact_kv( + "file", + &self.path, + tool_value_style(), + width, + )); + lines.extend(render_tool_output_mode( + &self.summary, + width, + TOOL_COMMAND_LINE_LIMIT, + mode, + )); + } if let Some(error) = self.error.as_ref() { lines.extend(render_tool_output_mode( error, @@ -1208,6 +1293,10 @@ impl McpToolCell { } if let Some(content) = self.content.as_ref() { + if mode == RenderMode::Live && is_exact_evidence_receipt(content) { + lines.push(render_spillover_annotation(std::path::Path::new(""), width)); + return lines; + } lines.extend(render_tool_output_mode( content, width, @@ -1416,19 +1505,23 @@ impl GenericToolCell { ); let should_collapse = self.status == ToolStatus::Success || (self.status != ToolStatus::Failed && !is_read_family); - if should_collapse { + if should_collapse || self.spillover_path.is_some() { let header_summary = crate::tui::widgets::tool_card::tool_header_summary_for_name( &self.name, self.input_summary.as_deref(), ); - return wrap_card_rail(vec![render_tool_header_with_family_and_summary( + let mut collapsed = vec![render_tool_header_with_family_and_summary( family, header_summary.as_deref(), tool_status_label(self.status), self.status, None, low_motion, - )]); + )]; + if let Some(path) = self.spillover_path.as_ref() { + collapsed.push(render_spillover_annotation(path, width)); + } + return wrap_card_rail(collapsed); } } @@ -1717,6 +1810,10 @@ fn render_spillover_annotation(_path: &std::path::Path, width: u16) -> Line<'sta )) } +fn is_exact_evidence_receipt(content: &str) -> bool { + content.trim_start().starts_with("[Exact evidence retained") +} + fn render_command_mode(command: &str, width: u16, mode: RenderMode) -> Vec> { let mut lines = Vec::new(); let cap = match mode { @@ -1809,6 +1906,40 @@ fn wrap_card_rail(mut lines: Vec>) -> Vec> { lines } +/// The legacy tool renderers accept only a low-motion boolean, which gives +/// both Reduced and Still a mix of legacy static frames. Preserve that stable +/// rendering path, then apply the central mode's exact fallback at the typed +/// header span (never by rewriting user/tool output text). +fn apply_static_tool_markers(lines: &mut [Line<'static>], marker: &'static str) { + for line in lines { + let mut index = 0; + if line + .spans + .first() + .is_some_and(|span| matches!(span.content.as_ref(), "─ " | "╭ " | "│ " | "╰ ")) + { + index = 1; + } + let Some(status) = line.spans.get(index).map(|span| span.content.as_ref()) else { + continue; + }; + let Some(family) = line.spans.get(index + 1).map(|span| span.content.as_ref()) else { + continue; + }; + if !status.ends_with(' ') + || !is_tool_status_glyph(status.trim_end()) + || !family.ends_with(' ') + || !is_tool_family_glyph(family.trim_end()) + { + continue; + } + let mut chars = status.trim_end().chars(); + if matches!(chars.next(), Some('\u{2800}'..='\u{28FF}')) && chars.next().is_none() { + line.spans[index].content = format!("{marker} ").into(); + } + } +} + /// Return the width of tool-cell chrome that remains after the transcript /// cache removes the cell-local card rail. Tool headers have two additional /// visual tokens (`✓`/spinner and the family glyph); detail rows have the @@ -1849,7 +1980,7 @@ fn tool_copy_prefix_width(line: &Line<'static>) -> usize { if !status.ends_with(' ') || !is_tool_status_glyph(status.trim_end()) || !family.ends_with(' ') - || UnicodeWidthStr::width(family.trim_end()) != 1 + || !is_tool_family_glyph(family.trim_end()) { return 0; } @@ -1868,10 +1999,30 @@ fn is_tool_status_glyph(text: &str) -> bool { '\u{2713}' // ✓ | '\u{2715}' // ✕ | '\u{00B7}' // · + | '\u{203A}' // › static still-mode marker | '\u{2800}'..='\u{28FF}' // braille spinner frames ) } +fn is_tool_family_glyph(text: &str) -> bool { + use crate::tui::widgets::tool_card::{ToolFamily, family_glyph}; + + [ + ToolFamily::Read, + ToolFamily::Patch, + ToolFamily::Run, + ToolFamily::Find, + ToolFamily::Delegate, + ToolFamily::Fanout, + ToolFamily::Rlm, + ToolFamily::Verify, + ToolFamily::Think, + ToolFamily::Generic, + ] + .into_iter() + .any(|family| family_glyph(family) == text) +} + fn review_severity_color(severity: &str) -> Color { match severity { "error" => palette::STATUS_ERROR, diff --git a/crates/tui/src/tui/history/file_mutation.rs b/crates/tui/src/tui/history/file_mutation.rs new file mode 100644 index 0000000000..2125d1dc8c --- /dev/null +++ b/crates/tui/src/tui/history/file_mutation.rs @@ -0,0 +1,484 @@ +//! Structured, success-only File mutation receipts. +//! +//! Tool execution owns the exact before/after evidence. This module shapes +//! that evidence for the calm transcript without depending on whether an +//! approval modal happened to run. + +use std::path::{Component, Path}; + +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use serde_json::Value; + +use crate::palette; +use crate::settings::InlineDiffMode; +use crate::tools::spec::ToolResult; +use crate::tui::diff_render; + +use super::details_affordance_line; + +const MAX_INLINE_DIFF_LINES: usize = 14; +const MAX_SUMMARY_CHARS: usize = 180; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileMutationOutcome { + Created, + Updated, + Deleted, + Renamed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileMutationFile { + pub path: String, + pub previous_path: Option, + pub outcome: FileMutationOutcome, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileMutationReceipt { + /// Raw execution-owned evidence exposed only through the explicit detail + /// route. It is never painted into the ambient Work/transcript surface. + pub exact_diff: String, + /// Header-redacted copy safe for inline presentation. + pub display_diff: String, + pub files: Vec, + pub added: usize, + pub deleted: usize, +} + +impl FileMutationReceipt { + /// Build a receipt only from an authoritative successful tool result. + /// Failed/cancelled calls never carry a success diff into the transcript. + #[must_use] + pub fn from_success(workspace: &Path, result: &ToolResult) -> Option { + if !result.success { + return None; + } + let mutation = result.metadata.as_ref()?.get("mutation")?; + let raw_diff = mutation.get("diff")?.as_str().unwrap_or(""); + let exact_diff = raw_diff.to_string(); + let display_diff = redact_diff_headers(workspace, raw_diff); + let mut files = Vec::new(); + + if let Some(entries) = mutation.get("files").and_then(Value::as_array) { + for entry in entries { + let Some(raw_path) = entry.get("path").and_then(Value::as_str) else { + continue; + }; + let outcome = match entry.get("outcome").and_then(Value::as_str) { + Some("created") => FileMutationOutcome::Created, + Some("updated") => FileMutationOutcome::Updated, + Some("deleted") => FileMutationOutcome::Deleted, + _ => continue, + }; + files.push(FileMutationFile { + path: privacy_safe_path(workspace, raw_path), + previous_path: None, + outcome, + }); + } + } + if let Some(renames) = mutation.get("renames").and_then(Value::as_array) { + for rename in renames { + let Some(from) = rename.get("from").and_then(Value::as_str) else { + continue; + }; + let Some(to) = rename.get("to").and_then(Value::as_str) else { + continue; + }; + files.push(FileMutationFile { + path: privacy_safe_path(workspace, to), + previous_path: Some(privacy_safe_path(workspace, from)), + outcome: FileMutationOutcome::Renamed, + }); + } + } + + let summaries = diff_render::summarize_diff(&display_diff); + let added = summaries.iter().map(|summary| summary.added).sum(); + let deleted = summaries.iter().map(|summary| summary.deleted).sum(); + if files.is_empty() && exact_diff.trim().is_empty() { + return None; + } + Some(Self { + exact_diff, + display_diff, + files, + added, + deleted, + }) + } + + #[must_use] + pub fn outcome_label(&self) -> String { + let created = self.count(FileMutationOutcome::Created); + let updated = self.count(FileMutationOutcome::Updated); + let deleted = self.count(FileMutationOutcome::Deleted); + let renamed = self.count(FileMutationOutcome::Renamed); + let mut outcome_parts = Vec::new(); + push_count(&mut outcome_parts, created, "created"); + push_count(&mut outcome_parts, updated, "updated"); + push_count(&mut outcome_parts, deleted, "deleted"); + push_count(&mut outcome_parts, renamed, "renamed"); + + if self.files.is_empty() { + "Changed files".to_string() + } else if self.files.len() == 1 { + let file = &self.files[0]; + match file.outcome { + FileMutationOutcome::Created => format!("Created {}", file.path), + FileMutationOutcome::Updated => format!("Updated {}", file.path), + FileMutationOutcome::Deleted => format!("Deleted {}", file.path), + FileMutationOutcome::Renamed => format!( + "Renamed {} → {}", + file.previous_path.as_deref().unwrap_or("file"), + file.path + ), + } + } else { + format!("{} files · {}", self.files.len(), outcome_parts.join(" · ")) + } + } + + #[must_use] + pub fn semantic_summary(&self) -> String { + let stats = format!("+{} -{}", self.added, self.deleted); + let separator_chars = " · ".chars().count(); + let outcome_budget = MAX_SUMMARY_CHARS + .saturating_sub(stats.chars().count()) + .saturating_sub(separator_chars); + format!( + "{} · {stats}", + bounded_text(&self.outcome_label(), outcome_budget) + ) + } + + #[must_use] + pub fn inspect_text(&self) -> String { + let summary = self.semantic_summary(); + if self.exact_diff.trim().is_empty() { + format!("{summary}\n\n(no textual changes)") + } else { + format!("{summary}\n\n{}", self.exact_diff) + } + } + + pub fn render_inline(&self, width: u16, mode: InlineDiffMode) -> Vec> { + match mode { + InlineDiffMode::Off => vec![exact_evidence_hint()], + InlineDiffMode::Summary => vec![ + Line::from(Span::styled( + self.semantic_summary(), + Style::default() + .fg(palette::TEXT_PRIMARY) + .add_modifier(Modifier::BOLD), + )), + exact_evidence_hint(), + ], + InlineDiffMode::Full => { + let mut lines = vec![Line::from(Span::styled( + self.semantic_summary(), + Style::default() + .fg(palette::TEXT_PRIMARY) + .add_modifier(Modifier::BOLD), + ))]; + if !self.display_diff.trim().is_empty() { + let rendered = diff_render::render_diff_body(&self.display_diff, width); + let omitted = rendered.len().saturating_sub(MAX_INLINE_DIFF_LINES); + lines.extend(rendered.into_iter().take(MAX_INLINE_DIFF_LINES)); + if omitted > 0 { + let detail_hint = + crate::tui::key_shortcuts::tool_details_shortcut_action_hint( + "exact change", + ); + lines.push(details_affordance_line( + &format!("+{omitted} diff lines · {detail_hint}"), + Style::default().fg(palette::TEXT_MUTED).italic(), + )); + } else { + lines.push(exact_evidence_hint()); + } + } else { + lines.push(exact_evidence_hint()); + } + lines + } + } + } + + fn count(&self, outcome: FileMutationOutcome) -> usize { + self.files + .iter() + .filter(|file| file.outcome == outcome) + .count() + } +} + +fn exact_evidence_hint() -> Line<'static> { + details_affordance_line( + &crate::tui::key_shortcuts::tool_details_shortcut_action_hint("exact change"), + Style::default().fg(palette::TEXT_MUTED).italic(), + ) +} + +fn push_count(parts: &mut Vec, count: usize, label: &str) { + if count > 0 { + parts.push(format!("{count} {label}")); + } +} + +fn bounded_text(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_string(); + } + if max_chars == 0 { + return String::new(); + } + let mut bounded = value + .chars() + .take(max_chars.saturating_sub(1)) + .collect::(); + bounded.push('…'); + bounded +} + +fn privacy_safe_path(workspace: &Path, raw: &str) -> String { + let normalized = raw.replace('\\', "/"); + let workspace = workspace.to_string_lossy().replace('\\', "/"); + let relative = if Path::new(raw).is_absolute() || normalized.starts_with('/') { + let prefix = workspace.trim_end_matches('/'); + if normalized == prefix { + "" + } else if let Some(relative) = normalized.strip_prefix(&format!("{prefix}/")) { + relative + } else { + return "".to_string(); + } + } else { + normalized.as_str() + }; + let path = Path::new(relative); + if path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return "".to_string(); + } + let display = path.to_string_lossy().replace('\\', "/"); + if display.is_empty() { + "".to_string() + } else { + display + } +} + +fn redact_diff_headers(workspace: &Path, diff: &str) -> String { + let mut redacted = diff + .lines() + .map(|line| { + for prefix in ["--- ", "+++ ", "rename from ", "rename to "] { + if let Some(raw) = line.strip_prefix(prefix) { + if raw == "/dev/null" { + return line.to_string(); + } + let side = if raw.starts_with("a/") { + "a/" + } else if raw.starts_with("b/") { + "b/" + } else { + "" + }; + let path = raw.strip_prefix(side).unwrap_or(raw); + return format!("{prefix}{side}{}", privacy_safe_path(workspace, path)); + } + } + if let Some(rest) = line.strip_prefix("diff --git ") { + let mut paths = rest.split_whitespace(); + if let (Some(old), Some(new)) = (paths.next(), paths.next()) { + let old = old.strip_prefix("a/").unwrap_or(old); + let new = new.strip_prefix("b/").unwrap_or(new); + return format!( + "diff --git a/{} b/{}", + privacy_safe_path(workspace, old), + privacy_safe_path(workspace, new) + ); + } + } + line.to_string() + }) + .collect::>() + .join("\n"); + if diff.ends_with('\n') { + redacted.push('\n'); + } + redacted +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn plain(lines: &[Line<'_>]) -> String { + lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n") + } + + fn result(mutation: Value) -> ToolResult { + ToolResult::success("ok").with_metadata(json!({ "mutation": mutation })) + } + + #[test] + fn creates_bounded_semantic_summary_and_redacts_external_headers() { + let result = result(json!({ + "diff": "--- /Users/alice/private.rs\n+++ /Users/alice/private.rs\n@@ -0,0 +1 @@\n+secret\n", + "files": [{ "path": "/Users/alice/private.rs", "outcome": "created" }], + "renames": [] + })); + let receipt = + FileMutationReceipt::from_success(Path::new("/workspace"), &result).expect("receipt"); + assert_eq!(receipt.files[0].path, ""); + assert!(receipt.exact_diff.contains("alice")); + assert!(!receipt.display_diff.contains("alice")); + assert!( + receipt + .semantic_summary() + .contains("Created ") + ); + } + + #[test] + fn rename_and_multifile_outcomes_stay_semantic() { + let result = result(json!({ + "diff": "diff --git a/old.rs b/new.rs\nrename from old.rs\nrename to new.rs\n--- a/lib.rs\n+++ b/lib.rs\n@@ -1 +1 @@\n-old\n+new\n", + "files": [{ "path": "lib.rs", "outcome": "updated" }], + "renames": [{ "from": "old.rs", "to": "new.rs" }] + })); + let receipt = + FileMutationReceipt::from_success(Path::new("/workspace"), &result).expect("receipt"); + assert_eq!(receipt.files.len(), 2); + assert_eq!(receipt.added, 1); + assert_eq!(receipt.deleted, 1); + assert_eq!( + receipt.semantic_summary(), + "2 files · 1 updated · 1 renamed · +1 -1" + ); + } + + #[test] + fn failed_results_never_become_success_receipts() { + let failed = ToolResult::error("cancelled").with_metadata(json!({ + "mutation": { + "diff": "--- a/a\n+++ b/a\n@@ -1 +1 @@\n-old\n+new\n", + "files": [{ "path": "a", "outcome": "updated" }], + "renames": [] + } + })); + assert!(FileMutationReceipt::from_success(Path::new("/workspace"), &failed).is_none()); + } + + #[test] + fn inline_diff_modes_are_bounded_and_keep_the_exact_detail_route() { + let additions = (0..30) + .map(|index| format!("+line {index}")) + .collect::>() + .join("\n"); + let result = result(json!({ + "diff": format!("--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -0,0 +1,30 @@\n{additions}\n"), + "files": [{ "path": "src/lib.rs", "outcome": "created" }], + "renames": [] + })); + let receipt = + FileMutationReceipt::from_success(Path::new("/workspace"), &result).expect("receipt"); + + let full = receipt.render_inline(80, InlineDiffMode::Full); + let full_text = plain(&full); + assert!(full.len() <= MAX_INLINE_DIFF_LINES + 2, "{full_text}"); + assert!(full_text.contains("line 0"), "{full_text}"); + assert!(full_text.contains("diff lines"), "{full_text}"); + assert!(full_text.contains("exact change"), "{full_text}"); + assert!(!full_text.contains("summary:"), "{full_text}"); + + let summary = plain(&receipt.render_inline(80, InlineDiffMode::Summary)); + assert!(summary.contains("Created src/lib.rs"), "{summary}"); + assert!(summary.contains("+30 -0"), "{summary}"); + assert!(!summary.contains("line 0"), "{summary}"); + assert!(summary.contains("exact change"), "{summary}"); + + let off = plain(&receipt.render_inline(80, InlineDiffMode::Off)); + assert!(!off.contains("line 0"), "{off}"); + assert!(!off.contains("+30 -0"), "{off}"); + assert!(off.contains("exact change"), "{off}"); + } + + #[test] + fn full_mode_spends_its_bound_on_red_green_evidence() { + let result = result(json!({ + "diff": "diff --git a/old.rs b/new.rs\nsimilarity index 100%\nrename from old.rs\nrename to new.rs\ndiff --git a/lib.rs b/lib.rs\n--- a/lib.rs\n+++ b/lib.rs\n@@ -1 +1 @@\n-old\n+new\n", + "files": [{ "path": "lib.rs", "outcome": "updated" }], + "renames": [{ "from": "old.rs", "to": "new.rs" }] + })); + let receipt = + FileMutationReceipt::from_success(Path::new("/workspace"), &result).expect("receipt"); + let full_text = plain(&receipt.render_inline(80, InlineDiffMode::Full)); + + assert!(full_text.contains("- old"), "{full_text}"); + assert!(full_text.contains("+ new"), "{full_text}"); + assert!(!full_text.contains("summary:"), "{full_text}"); + } + + #[test] + fn bounded_summary_never_truncates_semantic_stats() { + let long_path = format!("src/{}.rs", "whale".repeat(80)); + let result = result(json!({ + "diff": format!("--- a/{long_path}\n+++ b/{long_path}\n@@ -1 +1 @@\n-old\n+new\n"), + "files": [{ "path": long_path, "outcome": "updated" }], + "renames": [] + })); + let receipt = + FileMutationReceipt::from_success(Path::new("/workspace"), &result).expect("receipt"); + let summary = receipt.semantic_summary(); + assert!(summary.ends_with(" · +1 -1"), "{summary}"); + assert!(summary.chars().count() <= MAX_SUMMARY_CHARS); + } + + #[test] + fn failed_file_cell_never_paints_a_forged_success_receipt() { + let receipt = FileMutationReceipt::from_success( + Path::new("/workspace"), + &result(json!({ + "diff": "--- a/a.rs\n+++ b/a.rs\n@@ -1 +1 @@\n-old\n+FORGED-SUCCESS\n", + "files": [{ "path": "a.rs", "outcome": "updated" }], + "renames": [] + })), + ) + .expect("receipt"); + let cell = super::super::PatchSummaryCell { + path: "a.rs".to_string(), + summary: "editing".to_string(), + status: super::super::ToolStatus::Failed, + error: Some("cancelled".to_string()), + receipt: Some(receipt), + }; + + let text = plain(&cell.render( + 80, + true, + super::super::RenderMode::Live, + InlineDiffMode::Full, + )); + assert!(text.contains("cancelled"), "{text}"); + assert!(!text.contains("FORGED-SUCCESS"), "{text}"); + } +} diff --git a/crates/tui/src/tui/history/tests.rs b/crates/tui/src/tui/history/tests.rs index 53ed42297e..a79e9f0a99 100644 --- a/crates/tui/src/tui/history/tests.rs +++ b/crates/tui/src/tui/history/tests.rs @@ -1,6 +1,6 @@ use super::{ - ASSISTANT_GLYPH, ExecCell, ExecSource, GenericToolCell, HistoryCell, PlanUpdateCell, - REASONING_CURSOR, REASONING_OPENER, REASONING_RAIL, TOOL_RUNNING_SYMBOLS, + ASSISTANT_GLYPH, ExecCell, ExecSource, GenericToolCell, HistoryCell, McpToolCell, + PlanUpdateCell, REASONING_CURSOR, REASONING_OPENER, REASONING_RAIL, TOOL_RUNNING_SYMBOLS, TOOL_STATUS_SYMBOL_MS, ToolCell, ToolStatus, TranscriptRenderOptions, USER_GLYPH, WebSearchCell, assistant_label_style_for, extract_reasoning_summary, render_spillover_annotation, render_thinking, running_status_label_with_elapsed, @@ -9,6 +9,7 @@ use crate::deepseek_theme::Theme; use crate::models::{ContentBlock, Message}; use crate::palette; use crate::tools::plan::{PlanSnapshot, StepStatus}; +use crate::tui::motion::MotionMode; use crate::tui::ui_text::{line_to_plain, slice_text, text_display_width}; use ratatui::style::Modifier; use std::time::{Duration, Instant}; @@ -418,9 +419,44 @@ fn render_spillover_annotation_truncates_to_width() { .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref())) .collect(); assert!( - !rendered.contains("full output:"), - "compact live rows should omit spillover annotations: {rendered:?}" + rendered.contains("Exact evidence retained"), + "compact live rows should expose the calm path-free receipt: {rendered:?}" ); + assert!(!rendered.contains(long_path)); +} + +#[test] +fn specialized_bash_and_mcp_cells_share_the_calm_evidence_receipt() { + let receipt = "[Exact evidence retained · 200 KiB · inspect with `retrieve_tool_result ref=art_call-big`]\n\nhead\n\n[final excerpt]\ntail"; + let bash = ExecCell { + command: "cargo test".to_string(), + status: ToolStatus::Failed, + output: Some(receipt.to_string()), + live_output: None, + shell_task_id: None, + owner_agent_id: None, + owner_agent_name: None, + started_at: None, + duration_ms: Some(50), + source: ExecSource::Assistant, + interaction: None, + output_summary: None, + }; + let mcp = McpToolCell { + tool: "mcp_fixture".to_string(), + status: ToolStatus::Failed, + content: Some(receipt.to_string()), + is_image: false, + }; + for rendered in [ + lines_text(&ToolCell::Exec(bash).lines_with_motion(80, true)), + lines_text(&ToolCell::Mcp(mcp).lines_with_motion(80, true)), + ] { + assert!(rendered.contains("Exact evidence retained"), "{rendered}"); + assert!(rendered.contains("Option+V to inspect"), "{rendered}"); + assert!(!rendered.contains("retrieve_tool_result"), "{rendered}"); + assert!(!rendered.contains("head"), "{rendered}"); + } } #[test] @@ -905,6 +941,52 @@ fn tool_history_repair_receipt_renders_as_system_history() { )); } +#[test] +fn user_history_hides_only_the_trailing_turn_metadata_block() { + let visible = "Explain this literal: example"; + let turn_meta = concat!( + "\n", + "Current local date: 2026-07-22\n", + "Input provenance: external_user\n", + "Input authority: external_current_turn\n", + "", + ); + let msg = Message { + role: "user".to_string(), + content: vec![ + ContentBlock::Text { + text: visible.to_string(), + cache_control: None, + }, + ContentBlock::Text { + text: turn_meta.to_string(), + cache_control: None, + }, + ], + }; + + let cells = super::history_cells_from_message(&msg); + + assert!(matches!( + cells.as_slice(), + [HistoryCell::User { content }] if content == visible + )); + + let literal_only = Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "user-authored example".to_string(), + cache_control: None, + }], + }; + let literal_cells = super::history_cells_from_message(&literal_only); + assert!(matches!( + literal_cells.as_slice(), + [HistoryCell::User { content }] + if content == "user-authored example" + )); +} + #[test] fn history_replays_update_plan_tool_use_as_plan_card() { let msg = Message { @@ -1117,6 +1199,15 @@ fn tool_lines_with_options_respects_low_motion_in_default_path() { 80, TranscriptRenderOptions { low_motion: true, + motion_mode: MotionMode::Reduced, + ..TranscriptRenderOptions::default() + }, + ); + let still = cell.lines_with_options( + 80, + TranscriptRenderOptions { + low_motion: true, + motion_mode: MotionMode::Still, ..TranscriptRenderOptions::default() }, ); @@ -1124,14 +1215,96 @@ fn tool_lines_with_options_respects_low_motion_in_default_path() { // Index 0 is card-rail glyph (╭); the animated symbol is at index 1. let animated_symbol = animated[0].spans[1].content.trim(); let low_motion_symbol = low_motion[0].spans[1].content.trim(); + let still_symbol = still[0].spans[1].content.trim(); // Reduced motion freezes at a filled, legible bubble rather than an // invisible blank braille cell. assert_eq!(low_motion_symbol, "⣤"); + assert_eq!(still_symbol, "›"); // The animated path should be on a different frame (index 2). assert_ne!(animated_symbol, TOOL_RUNNING_SYMBOLS[0]); } +#[test] +fn reduced_verify_marker_uses_the_shared_calm_glyph() { + let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "run_verifiers".to_string(), + status: ToolStatus::Running, + input_summary: None, + output: None, + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })); + let lines = cell.lines_with_options( + 80, + TranscriptRenderOptions { + low_motion: true, + motion_mode: MotionMode::Reduced, + ..TranscriptRenderOptions::default() + }, + ); + + assert_eq!(lines[0].spans[1].content.trim(), "⣤"); +} + +#[test] +fn still_fanout_marker_uses_the_shared_chevron() { + let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "workflow".to_string(), + status: ToolStatus::Running, + input_summary: Some("action: run".to_string()), + output: None, + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })); + let lines = cell.lines_with_options( + 80, + TranscriptRenderOptions { + low_motion: true, + motion_mode: MotionMode::Still, + ..TranscriptRenderOptions::default() + }, + ); + + assert_eq!(lines[0].spans[1].content.trim(), "›"); + assert_eq!(lines[0].spans[2].content.trim(), "⋮⋮"); +} + +#[test] +fn still_marker_rewrite_never_consumes_braille_tool_output() { + let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "read_file".to_string(), + status: ToolStatus::Running, + input_summary: None, + output: Some("⣿".to_string()), + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })); + let lines = cell.lines_with_options( + 80, + TranscriptRenderOptions { + low_motion: true, + motion_mode: MotionMode::Still, + ..TranscriptRenderOptions::default() + }, + ); + + assert_eq!(lines[0].spans[1].content.trim(), "›"); + assert!( + lines + .iter() + .flat_map(|line| line.spans.iter()) + .any(|span| span.content.as_ref() == "⣿"), + "tool output must survive the typed-header marker pass: {lines:?}" + ); +} + // === Speaker glyph tests (v0.6.6 UI redesign) === // // The literal "Assistant" / "You" labels are replaced by the calmer diff --git a/crates/tui/src/tui/history/tool_run.rs b/crates/tui/src/tui/history/tool_run.rs index 5af4e35f9f..5ed65f728a 100644 --- a/crates/tui/src/tui/history/tool_run.rs +++ b/crates/tui/src/tui/history/tool_run.rs @@ -187,8 +187,8 @@ fn classify_tool_run_activity(tool: &ToolCell) -> ToolRunActivity { fn classify_tool_name_activity(name: &str) -> ToolRunActivity { let normalized = name.trim().to_ascii_lowercase(); match normalized.as_str() { - "read_file" | "list_dir" | "view_image" | "explore" | "git_log" | "git_show" - | "git_blame" => ToolRunActivity::File, + "read_file" | "list_dir" | "view_image" | "explore" | "git_status" | "git_diff" + | "git_log" | "git_show" | "git_blame" => ToolRunActivity::File, "grep_files" | "file_search" | "web_search" | "fetch_url" => ToolRunActivity::Search, "shell" | "exec_shell" @@ -337,3 +337,51 @@ fn sentence_case_activity(text: String) -> String { out.push_str(chars.as_str()); out } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::canonical_action::canonical_action_alias; + use serde_json::json; + + #[test] + fn canonical_file_mutations_never_collapse_behind_summary_rows() { + for action in ["write", "edit", "patch"] { + let input = json!({"action": action}); + let semantic_name = canonical_action_alias("File", &input); + assert!( + generic_tool_name_is_collapse_guard(semantic_name), + "File.{action}" + ); + } + + for action in ["read", "list", "search_name", "search_content"] { + let input = json!({"action": action}); + let semantic_name = canonical_action_alias("File", &input); + assert!( + !generic_tool_name_is_collapse_guard(semantic_name), + "File.{action}" + ); + } + } + + #[test] + fn normalized_git_and_run_actions_keep_truthful_activity_buckets() { + for action in ["status", "diff", "log", "show", "blame"] { + let input = json!({"action": action}); + assert_eq!( + classify_tool_name_activity(canonical_action_alias("Git", &input)), + ToolRunActivity::File, + "Git.{action}" + ); + } + for action in ["tests", "verifiers"] { + let input = json!({"action": action}); + assert_eq!( + classify_tool_name_activity(canonical_action_alias("Run", &input)), + ToolRunActivity::Command, + "Run.{action}" + ); + } + } +} diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index 3edbe010f1..223ad24efb 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -12,6 +12,7 @@ // === Submodules === pub mod active_cell; +pub(crate) mod agent_details; pub mod app; pub mod approval; pub mod auto_review; @@ -25,6 +26,7 @@ pub mod composer_chrome; pub mod composer_ui; pub mod context_inspector; pub mod context_menu; +pub(crate) mod coordination_detail; pub mod diff_render; pub mod event_broker; pub mod external_editor; diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index b659d55e3f..afbd5ee68e 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -710,38 +710,16 @@ pub(crate) fn apply_sidebar_row_action(app: &mut App, action: SidebarRowAction) Vec::new() } SidebarRowAction::OpenAgentDetail { agent_id } => { - // Prefer the worker's actual message transcript over the compact - // status card. The card intentionally keeps only a few activity - // lines; Open must show the conversation the worker had. - if open_agent_chat_pager(app, &agent_id) { - app.needs_redraw = true; - return Vec::new(); + if !crate::tui::agent_details::open_agent_details(app, &agent_id) { + crate::tui::work_surface::agent_details_closed(app, &agent_id); + app.status_message = Some("Agent details are unavailable".to_string()); } - let cell_index = app.history.iter().position(|cell| { - matches!( - cell, - HistoryCell::SubAgent(crate::tui::history::SubAgentCell::Delegate(card)) - if card.agent_id == agent_id - ) - }); - match cell_index { - Some(cell_index) => { - open_details_pager_for_cell(app, cell_index); - } - None => { - // Open failed — do not leave a stale detail-open owner. - if app - .work_surface - .opened - .as_ref() - .is_some_and(|id| id.0 == format!("worker:{agent_id}")) - { - app.work_surface.opened = None; - } - app.status_message = Some(format!( - "No transcript card for {agent_id} yet — use handle_read agent:{agent_id}/full_transcript" - )); - } + app.needs_redraw = true; + Vec::new() + } + SidebarRowAction::OpenAgentTranscript { agent_id } => { + if !open_agent_chat_pager(app, &agent_id) { + app.status_message = Some("Exact agent transcript is unavailable".to_string()); } app.needs_redraw = true; Vec::new() @@ -789,7 +767,15 @@ pub(crate) fn apply_sidebar_row_action(app: &mut App, action: SidebarRowAction) } } -fn open_agent_chat_pager(app: &mut App, agent_id: &str) -> bool { +pub(crate) fn open_agent_chat_pager(app: &mut App, agent_id: &str) -> bool { + let Some(text) = resolve_agent_transcript_text(app, agent_id) else { + return false; + }; + push_agent_chat_pager(app, agent_id, &text); + true +} + +fn resolve_agent_transcript_text(app: &App, agent_id: &str) -> Option { use crate::tools::handle::{HandleValue, VarHandle}; let lookup = VarHandle { @@ -809,7 +795,7 @@ fn open_agent_chat_pager(app: &mut App, agent_id: &str) -> bool { }, None => None, }, - Err(_) => return false, + Err(_) => return None, }; // The handle is a deliberately bounded live projection. Prefer the private @@ -837,21 +823,45 @@ fn open_agent_chat_pager(app: &mut App, agent_id: &str) -> bool { if matches_resident_count { let text = agent_messages_text(&messages); if !text.trim().is_empty() { - push_agent_chat_pager(app, agent_id, &text); - return true; + return Some(text); } } } - let Some(payload) = payload else { - return false; - }; + let payload = payload?; let text = agent_transcript_text(&payload); if text.trim().is_empty() { + return None; + } + Some(text) +} + +pub(crate) fn resident_agent_transcript_available(app: &App, agent_id: &str) -> bool { + use crate::tools::handle::{HandleValue, VarHandle}; + + let lookup = VarHandle { + kind: "var_handle".to_string(), + session_id: format!("agent:{agent_id}"), + name: "full_transcript".to_string(), + type_name: String::new(), + length: 0, + repr_preview: String::new(), + sha256: String::new(), + }; + let Ok(store) = app.runtime_services.handle_store.try_lock() else { + return false; + }; + let Some(record) = store.get(&lookup) else { return false; + }; + match &record.value { + HandleValue::Json(payload) => !agent_transcript_text(payload).trim().is_empty(), + HandleValue::Text(_) => false, } - push_agent_chat_pager(app, agent_id, &text); - true +} + +pub(crate) fn agent_transcript_evidence_available(app: &App, agent_id: &str) -> bool { + resolve_agent_transcript_text(app, agent_id).is_some() } fn push_agent_chat_pager(app: &mut App, agent_id: &str, text: &str) { @@ -860,8 +870,9 @@ fn push_agent_chat_pager(app: &mut App, agent_id: &str, text: &str) { .last_transcript_area .map(|area| area.width) .unwrap_or(80); + let display_name = crate::tui::agent_details::safe_agent_display_name(app, agent_id); app.view_stack.push(PagerView::from_text( - format!("Agent chat — {agent_id}"), + format!("Agent transcript — {display_name}"), text, width.saturating_sub(2), )); @@ -1321,7 +1332,8 @@ pub(crate) fn handle_context_menu_action(app: &mut App, action: ContextMenuActio open_context_inspector(app); } ContextMenuAction::OpenHelp => { - app.view_stack.push(HelpView::new_for_locale(app.ui_locale)); + let help = HelpView::new_for_workspace(app.ui_locale, &app.workspace); + app.view_stack.push(help); } ContextMenuAction::OpenFileAtLine { cell_index } => { let width = app diff --git a/crates/tui/src/tui/phase_strip.rs b/crates/tui/src/tui/phase_strip.rs index ae10ca8b2b..13129a557c 100644 --- a/crates/tui/src/tui/phase_strip.rs +++ b/crates/tui/src/tui/phase_strip.rs @@ -22,8 +22,7 @@ use unicode_width::UnicodeWidthStr; use crate::localization::{MessageId, tr}; use crate::tui::{ app::App, - history::{HistoryCell, ToolCell, ToolStatus}, - underwater::{ShellPhase, ShellTier, phase_marker}, + underwater::{LiveActivity, ShellPhase, ShellTier, phase_marker_with_activity}, }; /// Where the phase band sits relative to the composer. @@ -91,23 +90,18 @@ fn truncate_to_width(text: &str, width: usize) -> String { result } -/// Compact working detail for the phase band: `run ×N · 12s`. +/// Compact working detail for the phase band: `×N · 12s`. /// Kept quieter than the classic footer's verbose tool-status line so the /// transcript owns the ledger and the strip only names the live pulse. -fn working_detail(app: &App) -> Option { - let mut running = 0usize; - if let Some(active) = app.active_cell.as_ref() { - for cell in active.entries() { - running = running.saturating_add(count_running_tools(cell)); - } - } +fn working_detail(app: &App, activity: LiveActivity) -> Option { + let running = activity.running_tool_count(); let secs = app .turn_started_at .map(|started| started.elapsed().as_secs()); match (running, secs) { (0, Some(secs)) if secs > 0 => Some(format!("{secs}s")), - (n, Some(secs)) if n > 0 => Some(format!("run ×{n} · {secs}s")), - (n, None) if n > 0 => Some(format!("run ×{n}")), + (n, Some(secs)) if n > 0 => Some(format!("×{n} · {secs}s")), + (n, None) if n > 0 => Some(format!("×{n}")), _ => None, } } @@ -125,20 +119,6 @@ fn session_cache_hit_percentage(app: &App) -> Option { Some(((hit * 100 + total / 2) / total) as u8) } -fn count_running_tools(cell: &HistoryCell) -> usize { - let HistoryCell::Tool(tool) = cell else { - return 0; - }; - match tool { - ToolCell::Exploring(explore) => explore - .entries - .iter() - .filter(|entry| matches!(entry.status, ToolStatus::Running)) - .count(), - other => usize::from(other.status() == Some(ToolStatus::Running)), - } -} - /// Paint the one-line phase band. Owns phase, optional working detail, cost, /// configured session cache rate, and detail-key hints — never route/context /// (header) or Tasks/To-do (work surface). @@ -147,13 +127,14 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &mut App) { return; } let status_toast = app.active_status_toast(); - let phase = ShellPhase::from_app(app); + let activity = LiveActivity::from_app(app); + let phase = ShellPhase::from_app_with_activity(app, activity); let tier = ShellTier::for_chrome_width(area.width); Block::default() .style(Style::default().bg(app.ui_theme.footer_bg)) .render(area, buf); - let (marker, phase_label) = phase_marker(app, phase); + let (marker, phase_label) = phase_marker_with_activity(app, phase, activity); let phase_style = Style::default().fg(phase.color(app)).add_modifier( if matches!(phase, ShellPhase::Waiting | ShellPhase::Approval) { Modifier::BOLD @@ -169,7 +150,7 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &mut App) { if tier != ShellTier::Compact && matches!(phase, ShellPhase::Working | ShellPhase::Verifying) - && let Some(detail) = working_detail(app) + && let Some(detail) = working_detail(app, activity) { left.push(Span::styled( " · ", @@ -289,7 +270,7 @@ mod tests { config::Config, tui::active_cell::ActiveCell, tui::app::TuiOptions, - tui::history::{ExecCell, ExecSource, ToolCell, ToolStatus}, + tui::history::{ExecCell, ExecSource, HistoryCell, ToolCell, ToolStatus}, }; use ratatui::{Terminal, backend::TestBackend}; use std::{ @@ -364,7 +345,7 @@ mod tests { } #[test] - fn working_band_names_run_count_without_key_chorus() { + fn working_band_names_tool_use_and_bounded_count_without_key_chorus() { let mut app = test_app(); app.ui_locale = crate::localization::Locale::En; app.is_loading = true; @@ -403,14 +384,67 @@ mod tests { .iter() .map(|cell| cell.symbol()) .collect::(); - assert!(text.contains("working"), "{text}"); - assert!(text.contains("run ×1"), "{text}"); + assert!(text.contains("using tool"), "{text}"); + assert!(text.contains("×1 · 12s"), "{text}"); + assert!( + !text.contains("run ×1"), + "detail repeated the tool verb: {text}" + ); assert!( !text.contains("Alt+?") && !text.contains("F1:"), "live phase strip stays quiet: {text}" ); } + #[test] + fn compact_activity_band_keeps_only_the_semantic_label() { + let mut app = test_app(); + app.ui_locale = crate::localization::Locale::En; + app.turn_started_at = Some(Instant::now() - Duration::from_secs(12)); + let mut active = ActiveCell::new(); + active.push_tool( + "exec-compact", + HistoryCell::Tool(ToolCell::Exec(ExecCell { + command: "cargo build -p tui".to_string(), + status: ToolStatus::Running, + output: None, + live_output: None, + shell_task_id: None, + owner_agent_id: None, + owner_agent_name: None, + started_at: app.turn_started_at, + duration_ms: None, + source: ExecSource::Assistant, + interaction: None, + output_summary: None, + })), + ); + app.active_cell = Some(active); + + let backend = TestBackend::new(50, 1); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal + .draw(|frame| render(frame.area(), frame.buffer_mut(), &mut app)) + .expect("draw"); + let text = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + + assert!(text.contains("using tool"), "{text}"); + assert!( + !text.contains('×'), + "compact strip leaked count detail: {text}" + ); + assert!( + !text.contains("12s"), + "compact strip leaked timing detail: {text}" + ); + } + #[test] fn completed_band_keeps_unresolved_warning_visible() { let mut app = test_app(); diff --git a/crates/tui/src/tui/shell_key_routing.rs b/crates/tui/src/tui/shell_key_routing.rs index 31232ebc79..acb92b9bd7 100644 --- a/crates/tui/src/tui/shell_key_routing.rs +++ b/crates/tui/src/tui/shell_key_routing.rs @@ -73,15 +73,31 @@ pub fn tool_details_chord() -> Cow<'static, str> { display_chord(binding(ShellBindingId::ToolDetails).footer_chord) } -/// Render a portable `Alt+X` chord for the current platform. macOS shows -/// `⌥X` — never `Alt` or `Cmd` (TUI-DOG-002 acceptance). +/// Render a portable `Alt+X` chord for the current platform. macOS normally +/// shows `⌥X`; ASCII-safe terminals retain the portable `Alt+X` spelling. #[must_use] pub fn display_chord(chord: &'static str) -> Cow<'static, str> { - display_chord_for_platform(chord, cfg!(target_os = "macos")) + display_chord_for_platform_and_ascii( + chord, + cfg!(target_os = "macos"), + crate::tui::color_compat::ascii_safe_enabled(), + ) } +#[cfg(test)] #[must_use] pub fn display_chord_for_platform(chord: &'static str, is_macos: bool) -> Cow<'static, str> { + display_chord_for_platform_and_ascii(chord, is_macos, false) +} + +fn display_chord_for_platform_and_ascii( + chord: &'static str, + is_macos: bool, + ascii_safe: bool, +) -> Cow<'static, str> { + if ascii_safe { + return Cow::Borrowed(chord); + } if !is_macos { return Cow::Borrowed(chord); } @@ -97,14 +113,34 @@ pub fn display_chord_for_platform(chord: &'static str, is_macos: bool) -> Cow<'s /// `{keys}`) are localized by the caller. #[must_use] pub fn footer_action_hints(include_context: bool) -> String { - footer_action_hints_for_platform(include_context, cfg!(target_os = "macos")) + footer_action_hints_for_platform_and_ascii( + include_context, + cfg!(target_os = "macos"), + crate::tui::color_compat::ascii_safe_enabled(), + ) } +#[cfg(test)] #[must_use] pub fn footer_action_hints_for_platform(include_context: bool, is_macos: bool) -> String { - let details = - display_chord_for_platform(binding(ShellBindingId::ToolDetails).footer_chord, is_macos); - let help = display_chord_for_platform(binding(ShellBindingId::Help).footer_chord, is_macos); + footer_action_hints_for_platform_and_ascii(include_context, is_macos, false) +} + +fn footer_action_hints_for_platform_and_ascii( + include_context: bool, + is_macos: bool, + ascii_safe: bool, +) -> String { + let details = display_chord_for_platform_and_ascii( + binding(ShellBindingId::ToolDetails).footer_chord, + is_macos, + ascii_safe, + ); + let help = display_chord_for_platform_and_ascii( + binding(ShellBindingId::Help).footer_chord, + is_macos, + ascii_safe, + ); if include_context { format!( "{details}:{{output}} · {}:{{context}} · {help}:{{keys}}", @@ -187,6 +223,18 @@ mod tests { assert!(other.starts_with("Alt+V:"), "{other}"); } + #[test] + fn ascii_safe_macos_hints_keep_portable_chords() { + assert_eq!( + display_chord_for_platform_and_ascii("Alt+V", true, true), + "Alt+V" + ); + let hints = footer_action_hints_for_platform_and_ascii(true, true, true); + assert!(hints.starts_with("Alt+V:"), "{hints}"); + assert!(hints.contains("F1:"), "{hints}"); + assert!(!hints.contains('⌥'), "{hints}"); + } + #[test] fn footer_hints_never_advertise_bare_v_alt_question_or_alt_c() { for is_macos in [true, false] { diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 7b67865b7f..a2299ebde5 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -24,17 +24,16 @@ use ratatui::{ use crate::deepseek_theme::Theme; use crate::palette; use crate::tools::plan::StepStatus; -use crate::tools::subagent::{ - AgentWorkerStatus, SubAgentStatus, agent_worker_status_name, localized_whale_display_names, -}; +use crate::tools::subagent::{AgentWorkerStatus, SubAgentStatus, localized_whale_display_names}; use crate::tools::todo::TodoStatus; use super::app::{ - App, SidebarFocus, SidebarHoverRow, SidebarHoverSection, SidebarHoverState, SidebarRowAction, - TaskPanelEntry, TaskPanelEntryKind, + AgentCurrentActivity, AgentCurrentActivityStatus, App, SidebarFocus, SidebarHoverRow, + SidebarHoverSection, SidebarHoverState, SidebarRowAction, TaskPanelEntry, TaskPanelEntryKind, }; use super::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus, summarize_tool_output}; -use super::spinner::braille_spinner_frame_for_duration_ms; +use super::motion::MotionPolicy; +use super::spinner::{LIVE_MARKER_DELAY_MS, braille_spinner_frame_for_elapsed_ms}; use super::subagent_routing::active_fanout_counts; use super::ui_text::{concise_shell_command_label, truncate_line_to_width}; @@ -1518,9 +1517,13 @@ fn task_panel_rows( .map(format_duration_ms) .unwrap_or_else(|| "-".to_string()); let (label, detail) = background_task_labels(task, &duration); - let label = background_task_spinner_prefix(task, app.low_motion) - .map(|prefix| format!("{prefix} {label}")) - .unwrap_or(label); + let label = background_task_spinner_prefix( + task, + app.motion_policy(), + app.ocean_started_at.elapsed().as_millis(), + ) + .map(|prefix| format!("{prefix} {label}")) + .unwrap_or(label); let (show_action, detail_action) = background_task_click_actions(task); let label = if background_task_has_stop_target(task) { label_with_stop_target(&label, content_width.max(1)) @@ -1651,9 +1654,13 @@ fn task_panel_hover_texts(app: &App, row_sets: &TaskPanelRowSets, max_rows: usiz .map(format_duration_ms) .unwrap_or_else(|| "-".to_string()); let (label, detail) = background_task_labels(task, &duration); - let label = background_task_spinner_prefix(task, app.low_motion) - .map(|prefix| format!("{prefix} {label}")) - .unwrap_or(label); + let label = background_task_spinner_prefix( + task, + app.motion_policy(), + app.ocean_started_at.elapsed().as_millis(), + ) + .map(|prefix| format!("{prefix} {label}")) + .unwrap_or(label); texts.push(label); if texts.len() >= max_rows { break; @@ -1777,14 +1784,17 @@ fn background_task_is_live(task: &TaskPanelEntry) -> bool { && matches!(task.status.as_str(), "queued" | "running") } -fn background_task_spinner_prefix(task: &TaskPanelEntry, low_motion: bool) -> Option<&'static str> { +fn background_task_spinner_prefix( + task: &TaskPanelEntry, + motion_policy: MotionPolicy, + animation_elapsed_ms: u128, +) -> Option<&'static str> { if task.status != "running" { return None; } - Some(braille_spinner_frame_for_duration_ms( - task.duration_ms.unwrap_or_default(), - low_motion, - )) + let elapsed_ms = task.duration_ms.unwrap_or_default(); + let animated_frame = braille_spinner_frame_for_elapsed_ms(animation_elapsed_ms, false); + Some(motion_policy.spinner_glyph(animated_frame, elapsed_ms >= LIVE_MARKER_DELAY_MS)) } fn stale_no_output_label(task: &TaskPanelEntry) -> Option { @@ -2522,7 +2532,7 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &mut App) { let cached_running = app .subagent_cache .iter() - .filter(|agent| matches!(agent.status, SubAgentStatus::Running)) + .filter(|agent| cached_agent_activity_is_live(app, agent)) .count(); let role_counts: std::collections::BTreeMap = app.subagent_cache @@ -2588,6 +2598,9 @@ pub struct SidebarAgentRow { pub progress: Option, pub steps_taken: u32, pub duration_ms: Option, + /// A resident transcript currently contains visible exact evidence. This + /// conservative signal prevents the sidebar from advertising a dead Open. + pub transcript_available: bool, pub expanded: bool, } @@ -2622,17 +2635,11 @@ fn sidebar_agent_rows(app: &App) -> Vec { .subagent_cache .iter() .map(|agent| { - let progress = app - .agent_progress + let current_activity = app + .agent_progress_meta .get(&agent.agent_id) - .cloned() - .or_else(|| { - agent - .result - .as_deref() - .map(summarize_tool_output) - .filter(|summary| !summary.trim().is_empty()) - }); + .and_then(|meta| meta.current_activity.as_ref()); + let progress = current_activity.map(sidebar_current_activity_text); // Generated whales are locale-derived from the neutral agent id; // never replay a persisted label from another language. let display_name = display_names @@ -2647,9 +2654,9 @@ fn sidebar_agent_rows(app: &App) -> Vec { name: display_name, role: agent.agent_type.as_str().to_string(), model: Some(agent.model.clone()).filter(|model| !model.trim().is_empty()), - status: agent - .worker_status - .map(sidebar_worker_status_text) + status: current_activity + .map(|activity| sidebar_current_activity_status_text(activity.status)) + .or_else(|| agent.worker_status.map(sidebar_worker_status_text)) .unwrap_or_else(|| subagent_status_text(&agent.status)) .to_string(), objective: Some(agent.assignment.objective.clone()) @@ -2658,6 +2665,10 @@ fn sidebar_agent_rows(app: &App) -> Vec { progress, steps_taken: agent.steps_taken, duration_ms: Some(agent.duration_ms), + transcript_available: crate::tui::mouse_ui::resident_agent_transcript_available( + app, + &agent.agent_id, + ), expanded: app.expanded_sidebar_agents.contains(&agent.agent_id), } }) @@ -2667,7 +2678,7 @@ fn sidebar_agent_rows(app: &App) -> Vec { app.agent_progress .iter() .filter(|(id, _)| !cached_ids.contains(id.as_str())) - .map(|(id, progress)| { + .map(|(id, _progress)| { // Progress-only rows do not carry a generated whale name yet; // keep their existing stable Agent-N placeholder until the // manager snapshot arrives. @@ -2678,6 +2689,7 @@ fn sidebar_agent_rows(app: &App) -> Vec { .unwrap_or_else(|| id.clone()); let meta = app.agent_progress_meta.get(id.as_str()); let spawn_depth = meta.map(|meta| meta.spawn_depth).unwrap_or_default(); + let current_activity = meta.and_then(|meta| meta.current_activity.as_ref()); SidebarAgentRow { id: id.clone(), parent_run_id: meta.and_then(|meta| meta.parent_run_id.clone()), @@ -2689,12 +2701,18 @@ fn sidebar_agent_rows(app: &App) -> Vec { "agent".to_string() }, model: None, - status: sidebar_progress_status_text(progress).to_string(), + status: current_activity + .map(|activity| sidebar_current_activity_status_text(activity.status)) + .unwrap_or(sidebar_worker_status_text(AgentWorkerStatus::Running)) + .to_string(), objective: None, git_branch: None, - progress: Some(progress.clone()), + progress: current_activity.map(sidebar_current_activity_text), steps_taken: 0, duration_ms: None, + transcript_available: crate::tui::mouse_ui::resident_agent_transcript_available( + app, id, + ), expanded: app.expanded_sidebar_agents.contains(id), } }), @@ -2783,24 +2801,69 @@ fn sidebar_worker_status_text(status: AgentWorkerStatus) -> &'static str { } } -fn sidebar_progress_status_text(progress: &str) -> &'static str { - let lower = progress.to_ascii_lowercase(); - if lower.contains("queued") { - "queued" - } else if lower.contains("waiting for user") || lower.contains("waiting for follow-up") { - "waiting" - } else if lower.contains("waiting for model") || lower.contains("requesting model") { - "model wait" - } else if lower.contains("running tool") - || lower.contains("executing tool") - || lower.contains("tool:") +fn sidebar_current_activity_status_text(status: AgentCurrentActivityStatus) -> &'static str { + match status { + AgentCurrentActivityStatus::Queued => "queued", + AgentCurrentActivityStatus::Starting => "starting", + AgentCurrentActivityStatus::Running => "running", + AgentCurrentActivityStatus::ModelWait => "model wait", + AgentCurrentActivityStatus::RunningTool => "tool", + AgentCurrentActivityStatus::Waiting => "waiting", + AgentCurrentActivityStatus::Done => "done", + AgentCurrentActivityStatus::Failed => "failed", + AgentCurrentActivityStatus::Canceled => "canceled", + AgentCurrentActivityStatus::Interrupted => "interrupted", + } +} + +fn cached_agent_activity_is_live( + app: &App, + agent: &crate::tools::subagent::SubAgentResult, +) -> bool { + if let Some(status) = app + .agent_progress_meta + .get(&agent.agent_id) + .and_then(|meta| meta.current_activity.as_ref()) + .map(|activity| activity.status) { - "tool" - } else if lower.contains("starting") { - "starting" - } else { - agent_worker_status_name(AgentWorkerStatus::Running) + return matches!( + status, + AgentCurrentActivityStatus::Queued + | AgentCurrentActivityStatus::Starting + | AgentCurrentActivityStatus::Running + | AgentCurrentActivityStatus::ModelWait + | AgentCurrentActivityStatus::RunningTool + | AgentCurrentActivityStatus::Waiting + ); + } + if let Some(status) = agent.worker_status { + return matches!( + status, + AgentWorkerStatus::Queued + | AgentWorkerStatus::Starting + | AgentWorkerStatus::Running + | AgentWorkerStatus::WaitingForUser + | AgentWorkerStatus::ModelWait + | AgentWorkerStatus::RunningTool + ); + } + matches!(agent.status, SubAgentStatus::Running) +} + +fn sidebar_current_activity_text(activity: &AgentCurrentActivity) -> String { + let mut parts = vec![sidebar_current_activity_status_text(activity.status).to_string()]; + if let Some(tool) = activity.current_tool.as_deref() { + parts.push(tool.to_string()); + } + if let Some(step) = activity.step { + parts.push(format!("step {step}")); } + if let Some(detail) = activity.detail.as_deref() + && detail != parts[0] + { + parts.push(detail.to_string()); + } + parts.join(" · ") } /// Build sub-agent sidebar lines from summary + per-agent rows. Public @@ -2849,8 +2912,7 @@ fn indented_detail_line(indent: &str, body: &str, content_width: usize) -> Strin /// is a functional, copyable handle on its own dedicated line, not incidental /// id noise mixed into the dossier. fn subagent_output_handle(row: &SidebarAgentRow) -> Option { - let has_output = sidebar_agent_status_is_terminal(row.status.as_str()) || row.steps_taken > 0; - if !has_output { + if !row.transcript_available { return None; } Some(format!("agent:{}/full_transcript", row.id)) @@ -3007,9 +3069,8 @@ fn subagent_panel_rows( indented_detail_line(" ", &detail_parts.join(" \u{00B7} "), content_width.max(1)), Style::default().fg(theme.text_dim), ))); - // Clicking the expanded dossier drills into the child's transcript - // card in the detail pager (#2889 slice, dogfood A3). The label row - // above keeps its expand/collapse toggle. + // Clicking the expanded dossier opens the bounded Agent Details + // projection. The label row above keeps its expand/collapse toggle. actions.push(Some(SidebarRowAction::OpenAgentDetail { agent_id: row.id.clone(), })); @@ -3032,7 +3093,7 @@ fn subagent_panel_rows( ), Style::default().fg(theme.text_muted), ))); - actions.push(Some(SidebarRowAction::OpenAgentDetail { + actions.push(Some(SidebarRowAction::OpenAgentTranscript { agent_id: row.id.clone(), })); } @@ -3212,7 +3273,7 @@ fn agent_row_hover_text(row: &SidebarAgentRow) -> String { } let _ = write!(text, "\n{status_line}"); if let Some(objective) = row.objective.as_deref() { - let _ = write!(text, "\nobjective: {}", objective.trim()); + let _ = write!(text, "\nobjective: {}", summarize_tool_output(objective)); } if let Some(branch) = row.git_branch.as_deref() { let _ = write!(text, "\nbranch: {branch}"); @@ -3220,7 +3281,11 @@ fn agent_row_hover_text(row: &SidebarAgentRow) -> String { if let Some(progress) = row.progress.as_deref() && !progress.trim().is_empty() { - let _ = write!(text, "\nprogress: {}", progress.trim()); + let _ = write!( + text, + "\ncurrent activity: {}", + summarize_tool_output(progress) + ); } text } @@ -3533,6 +3598,7 @@ fn agent_stop_action_for_click(action: &SidebarRowAction) -> Option None, } @@ -3546,13 +3612,13 @@ mod tests { SidebarHoverSection, SidebarHoverState, SidebarSubagentSummary, SidebarToolRow, SidebarWorkChecklistItem, SidebarWorkStrategyStep, SidebarWorkSummary, ToolRowOrder, agent_row_hover_text, auto_sidebar_panels, background_task_spinner_prefix, - context_panel_cost_line, editorial_tool_rows, hotbar_panel_enabled, - hotbar_panel_hover_texts, hotbar_panel_lines, hotbar_panel_slots, is_hotbar_disabled, - normalize_activity_text, render_sidebar, sidebar_agent_rows, sidebar_hover_rows, - sidebar_work_summary, sort_sidebar_agent_rows_as_tree, subagent_output_handle, - subagent_panel_hover_texts, subagent_panel_lines, subagent_panel_rows, - task_panel_hover_texts, task_panel_lines, task_panel_row_sets, task_panel_rows, - work_panel_empty_hint, work_panel_hover_texts, work_panel_lines, + cached_agent_activity_is_live, context_panel_cost_line, editorial_tool_rows, + hotbar_panel_enabled, hotbar_panel_hover_texts, hotbar_panel_lines, hotbar_panel_slots, + is_hotbar_disabled, normalize_activity_text, render_sidebar, sidebar_agent_rows, + sidebar_hover_rows, sidebar_work_summary, sort_sidebar_agent_rows_as_tree, + subagent_output_handle, subagent_panel_hover_texts, subagent_panel_lines, + subagent_panel_rows, task_panel_hover_texts, task_panel_lines, task_panel_row_sets, + task_panel_rows, work_panel_empty_hint, work_panel_hover_texts, work_panel_lines, }; use crate::config::Config; use crate::localization::Locale; @@ -3562,13 +3628,17 @@ mod tests { use crate::tools::todo::TodoStatus; use crate::tui::active_cell::ActiveCell; use crate::tui::app::{ - AgentProgressMeta, App, AppMode, HuntVerdict, SidebarRowAction, TaskPanelEntry, - TaskPanelEntryKind, TuiOptions, + AgentCurrentActivity, AgentCurrentActivityStatus, AgentProgressMeta, App, AppMode, + HuntVerdict, SidebarRowAction, TaskPanelEntry, TaskPanelEntryKind, TuiOptions, }; use crate::tui::history::{ ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus, }; - use crate::tui::spinner::{BRAILLE_SPINNER_FRAME_MS, LIVE_MARKER_DELAY_MS, LIVE_STATIC_MARKER}; + use crate::tui::motion::MotionPolicy; + use crate::tui::spinner::{ + BRAILLE_SPINNER_FRAME_MS, BRAILLE_SPINNER_STILL_FRAME, LIVE_MARKER_DELAY_MS, + LIVE_STATIC_MARKER, + }; use ratatui::{Terminal, backend::TestBackend, text::Line}; use std::path::PathBuf; use std::time::{Duration, Instant}; @@ -5131,6 +5201,7 @@ mod tests { #[test] fn background_task_spinner_advances_at_readable_cadence() { + let full = MotionPolicy::from_settings(false, true, false); let mut task = TaskPanelEntry { id: "shell_33a08c3c".to_string(), status: "running".to_string(), @@ -5147,29 +5218,109 @@ mod tests { }; assert_eq!( - background_task_spinner_prefix(&task, false), + background_task_spinner_prefix(&task, full, 0), Some(LIVE_STATIC_MARKER) ); task.duration_ms = Some(LIVE_MARKER_DELAY_MS - 1); assert_eq!( - background_task_spinner_prefix(&task, false), + background_task_spinner_prefix(&task, full, 0), Some(LIVE_STATIC_MARKER) ); task.duration_ms = Some(LIVE_MARKER_DELAY_MS); assert_eq!( - background_task_spinner_prefix(&task, false), + background_task_spinner_prefix(&task, full, u128::from(LIVE_MARKER_DELAY_MS),), Some(crate::tui::spinner::BRAILLE_SPINNER_FRAMES[0]) ); task.duration_ms = Some(LIVE_MARKER_DELAY_MS + BRAILLE_SPINNER_FRAME_MS); assert_eq!( - background_task_spinner_prefix(&task, false), + background_task_spinner_prefix( + &task, + full, + u128::from(LIVE_MARKER_DELAY_MS + BRAILLE_SPINNER_FRAME_MS), + ), Some(crate::tui::spinner::BRAILLE_SPINNER_FRAMES[1]) ); } + #[test] + fn tasks_panel_background_marker_freezes_when_fancy_animations_are_off() { + let mut app = create_test_app(); + app.low_motion = false; + app.fancy_animations = false; + app.task_panel.push(TaskPanelEntry { + id: "shell_still".to_string(), + status: "running".to_string(), + prompt_summary: "shell: cargo test --locked".to_string(), + duration_ms: Some(LIVE_MARKER_DELAY_MS), + kind: TaskPanelEntryKind::Background, + stale: false, + elapsed_since_output_ms: None, + owner_agent_id: None, + owner_agent_name: None, + current_tool: None, + role: None, + files_touched: 0, + }); + + let clock = Instant::now(); + app.ocean_started_at = clock - Duration::from_millis(LIVE_MARKER_DELAY_MS); + let first = lines_to_text(&task_panel_lines(&app, 96, 8)); + app.ocean_started_at = + clock - Duration::from_millis(LIVE_MARKER_DELAY_MS + BRAILLE_SPINNER_FRAME_MS); + let second = lines_to_text(&task_panel_lines(&app, 96, 8)); + let first_live = first + .iter() + .find(|line| line.contains("Bash running")) + .expect("first live background row"); + let second_live = second + .iter() + .find(|line| line.contains("Bash running")) + .expect("second live background row"); + + assert!( + first_live.starts_with(&format!("{LIVE_STATIC_MARKER} ")), + "{first:?}" + ); + assert!( + second_live.starts_with(&format!("{LIVE_STATIC_MARKER} ")), + "still-mode marker must stay fixed while the shared frame clock advances: {second:?}" + ); + + app.low_motion = false; + app.fancy_animations = true; + app.ocean_started_at = clock - Duration::from_millis(LIVE_MARKER_DELAY_MS); + let full_first = lines_to_text(&task_panel_lines(&app, 96, 8)); + app.ocean_started_at = + clock - Duration::from_millis(LIVE_MARKER_DELAY_MS + BRAILLE_SPINNER_FRAME_MS); + let full_second = lines_to_text(&task_panel_lines(&app, 96, 8)); + let full_first_live = full_first + .iter() + .find(|line| line.contains("Bash running")) + .expect("first full-motion background row"); + let full_second_live = full_second + .iter() + .find(|line| line.contains("Bash running")) + .expect("second full-motion background row"); + assert_ne!( + full_first_live.chars().next(), + full_second_live.chars().next(), + "Full marker should advance from the shared frame clock without a task snapshot update" + ); + + app.low_motion = true; + app.fancy_animations = true; + let reduced = lines_to_text(&task_panel_lines(&app, 96, 8)); + assert!( + reduced + .iter() + .any(|line| line.contains(BRAILLE_SPINNER_STILL_FRAME)), + "reduced mode should use the calm braille marker: {reduced:?}" + ); + } + #[test] fn tasks_panel_auto_mode_shows_only_live_background_jobs() { let mut app = create_test_app(); @@ -5567,6 +5718,7 @@ mod tests { progress: Some("scanning".to_string()), steps_taken: 2, duration_ms: Some(1_000), + transcript_available: false, expanded: true, }]; @@ -5595,7 +5747,7 @@ mod tests { Some(SidebarRowAction::OpenAgentDetail { agent_id: "agent_0123456789".to_string(), }), - "expanded detail row drills into the child's transcript card (#2889); \ + "expanded detail row opens the safe Agent Details projection (#2889); \ the stop target stays on the label row" ); } @@ -5622,6 +5774,7 @@ mod tests { progress: Some("reading".to_string()), steps_taken: 1, duration_ms: None, + transcript_available: false, expanded: false, }]; @@ -5669,6 +5822,7 @@ mod tests { progress: Some(format!("{status} with a long stale-looking detail")), steps_taken: 7, duration_ms: Some(1_000), + transcript_available: false, expanded: false, }) .collect::>(); @@ -5720,6 +5874,7 @@ mod tests { progress: Some("cancelled by user".to_string()), steps_taken: 2, duration_ms: Some(2_000), + transcript_available: false, expanded: false, }]; @@ -5768,6 +5923,7 @@ mod tests { progress: None, steps_taken: 1, duration_ms: None, + transcript_available: false, expanded: false, }; // Parent + child + a two-node parent cycle: the cycle has no root, so @@ -5815,6 +5971,7 @@ mod tests { progress: None, steps_taken: 1, duration_ms: Some(250), + transcript_available: false, expanded: false, }, SidebarAgentRow { @@ -5830,6 +5987,7 @@ mod tests { progress: Some("waiting on child".to_string()), steps_taken: 2, duration_ms: Some(500), + transcript_available: false, expanded: false, }, ]; @@ -6259,6 +6417,7 @@ mod tests { progress: Some("step 2/3: running tool 'read_file'".to_string()), steps_taken: 2, duration_ms: Some(22_000), + transcript_available: false, expanded: true, }, SidebarAgentRow { @@ -6274,6 +6433,7 @@ mod tests { progress: Some("SUMMARY: docs checked".to_string()), steps_taken: 5, duration_ms: Some(21_000), + transcript_available: false, expanded: false, }, ]; @@ -6574,6 +6734,7 @@ mod tests { progress: Some(long_progress.to_string()), steps_taken: 9, duration_ms: Some(12_345), + transcript_available: false, expanded: false, }]; @@ -6610,6 +6771,7 @@ mod tests { progress: Some("working".to_string()), steps_taken: 3, duration_ms: Some(1_000), + transcript_available: false, expanded: true, }]; @@ -6645,6 +6807,7 @@ mod tests { progress: Some("step 3".to_string()), steps_taken: 3, duration_ms: Some(2_000), + transcript_available: false, expanded: true, }]; @@ -6691,6 +6854,7 @@ mod tests { progress: None, steps_taken: 0, duration_ms: Some(4_000), + transcript_available: false, expanded: true, }]; @@ -6736,6 +6900,7 @@ mod tests { progress: None, steps_taken: 0, duration_ms: None, + transcript_available: false, expanded: true, }]; @@ -6783,6 +6948,7 @@ mod tests { progress: Some(format!("step {i}: finished tool 'grep_files'")), steps_taken: i + 1, duration_ms: Some(1_000 + u64::from(i) * 500), + transcript_available: false, expanded: true, }) .collect(); @@ -6860,6 +7026,7 @@ mod tests { progress: Some("step 247: finished tool grep_files ok".to_string()), steps_taken: 247, duration_ms: Some(96_000), + transcript_available: true, expanded: true, ..SidebarAgentRow::default() }]; @@ -7041,6 +7208,7 @@ mod tests { progress: Some("Wrote findings and staged a patch".to_string()), steps_taken: 12, duration_ms: Some(42_000), + transcript_available: true, expanded: true, ..SidebarAgentRow::default() }]; @@ -7074,19 +7242,18 @@ mod tests { .unwrap(); assert_eq!( actions[handle_idx], - Some(SidebarRowAction::OpenAgentDetail { + Some(SidebarRowAction::OpenAgentTranscript { agent_id: "agent_7f3c".to_string(), }), - "handle line should open the child's detail card on click" + "handle line should open the child's exact transcript on click" ); } #[test] fn subagent_output_handle_gated_on_inspectable_output() { - // #4094 item 4: the handle only appears once there is something to - // inspect — a fresh, zero-step, non-terminal worker advertises no - // handle (so we never point at an empty transcript), while a running - // worker with steps does get the "inspect more" affordance. + // #4094/#2889: lifecycle and step counts are not exact transcript + // evidence. Only a successfully inspected resident transcript may + // advertise the explicit Open route. let fresh = SidebarAgentRow { id: "agent_fresh".to_string(), name: "scout".to_string(), @@ -7104,24 +7271,24 @@ mod tests { let working = SidebarAgentRow { steps_taken: 4, status: "running".to_string(), + transcript_available: true, ..fresh.clone() }; assert_eq!( subagent_output_handle(&working).as_deref(), Some("agent:agent_fresh/full_transcript"), - "a running worker with steps should expose the inspect-more handle" + "a worker with exact resident evidence should expose the transcript handle" ); - // A terminal worker exposes the handle even with zero recorded steps. + // A terminal state without exact evidence must not look actionable. let failed_immediately = SidebarAgentRow { steps_taken: 0, status: "failed".to_string(), ..fresh.clone() }; - assert_eq!( - subagent_output_handle(&failed_immediately).as_deref(), - Some("agent:agent_fresh/full_transcript"), - "a terminal worker should expose its transcript handle" + assert!( + subagent_output_handle(&failed_immediately).is_none(), + "a terminal worker without exact evidence must not advertise Open" ); } @@ -7141,6 +7308,7 @@ mod tests { progress: Some("reading".to_string()), steps_taken: 1, duration_ms: Some(500), + transcript_available: false, expanded: false, }; let hover = agent_row_hover_text(&row); @@ -7173,6 +7341,7 @@ mod tests { progress: Some("step 2/3: running tool 'read_file'".to_string()), steps_taken: 2, duration_ms: Some(22_000), + transcript_available: false, expanded: false, }]; @@ -7199,8 +7368,8 @@ mod tests { "label hover should carry the branch: {label:?}" ); assert!( - label.contains("progress: step 2/3: running tool 'read_file'"), - "label hover should carry untruncated progress: {label:?}" + label.contains("current activity: step 2/3: running tool 'read_file'"), + "label hover should carry untruncated current activity: {label:?}" ); } @@ -7301,22 +7470,163 @@ mod tests { assert_eq!(rows.len(), 1); assert_eq!(rows[0].status, "model wait"); + assert_eq!(rows[0].progress.as_deref(), None); + } + + #[test] + fn sidebar_agent_rows_project_typed_lifecycle_fixtures() { + let mut app = create_test_app(); + let fixtures = [ + ( + "agent_running", + "Running", + crate::tools::subagent::SubAgentStatus::Running, + crate::tools::subagent::AgentWorkerStatus::RunningTool, + AgentCurrentActivityStatus::RunningTool, + "tool", + ), + ( + "agent_waiting", + "Waiting", + crate::tools::subagent::SubAgentStatus::Interrupted("approval".to_string()), + crate::tools::subagent::AgentWorkerStatus::WaitingForUser, + AgentCurrentActivityStatus::Waiting, + "waiting", + ), + ( + "agent_failed", + "Failed", + crate::tools::subagent::SubAgentStatus::Failed("verification".to_string()), + crate::tools::subagent::AgentWorkerStatus::Failed, + AgentCurrentActivityStatus::Failed, + "failed", + ), + ( + "agent_done", + "Done", + crate::tools::subagent::SubAgentStatus::Completed, + crate::tools::subagent::AgentWorkerStatus::Completed, + AgentCurrentActivityStatus::Done, + "done", + ), + ]; + for (id, nickname, status, worker_status, activity_status, _) in &fixtures { + let mut agent = cached_agent(id, Some(nickname)); + agent.status = status.clone(); + agent.worker_status = Some(*worker_status); + app.subagent_cache.push(agent); + app.agent_progress_meta.insert( + (*id).to_string(), + AgentProgressMeta { + current_activity: Some(AgentCurrentActivity::bounded( + *activity_status, + (*id == "agent_waiting").then_some("approval required".to_string()), + (*id == "agent_running").then_some("read_file".to_string()), + Some(2), + )), + ..AgentProgressMeta::default() + }, + ); + } + + let rows = sidebar_agent_rows(&app); + for (id, _, _, _, _, expected_status) in fixtures { + let row = rows + .iter() + .find(|row| row.id == id) + .expect("typed lifecycle row"); + assert_eq!(row.status, expected_status); + } + let waiting = app + .subagent_cache + .iter() + .find(|agent| agent.agent_id == "agent_waiting") + .expect("waiting agent"); + assert!(cached_agent_activity_is_live(&app, waiting)); } #[test] - fn sidebar_progress_only_rows_parse_status_instead_of_hardcoding_running() { + fn structured_agent_details_fit_the_80x24_sidebar_budget() { + let mut app = create_test_app(); + app.sidebar_focus = SidebarFocus::Agents; + let mut waiting = cached_agent("agent_waiting", Some("Wait")); + waiting.status = + crate::tools::subagent::SubAgentStatus::Interrupted("approval required".to_string()); + waiting.worker_status = Some(crate::tools::subagent::AgentWorkerStatus::WaitingForUser); + app.subagent_cache.push(waiting); + app.agent_progress_meta.insert( + "agent_waiting".to_string(), + AgentProgressMeta { + current_activity: Some(AgentCurrentActivity::bounded( + AgentCurrentActivityStatus::Waiting, + Some("approval required".to_string()), + None, + Some(2), + )), + ..AgentProgressMeta::default() + }, + ); + app.expanded_sidebar_agents + .insert("agent_waiting".to_string()); + let config = Config { + hotbar: Some(Vec::new()), + ..Config::default() + }; + + // At an 80-column terminal the standard split grants the sidebar 20 + // columns, so render that exact 20x24 Agent Details budget directly. + let backend = TestBackend::new(20, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal + .draw(|frame| render_sidebar(frame, frame.area(), &mut app, &config)) + .expect("draw sidebar"); + let rendered = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + + assert!(rendered.contains("Agents"), "{rendered:?}"); + assert!(rendered.contains("Wait"), "{rendered:?}"); + assert!(rendered.contains("waiting"), "{rendered:?}"); + } + + #[test] + fn sidebar_progress_only_rows_never_infer_status_from_display_text() { let mut app = create_test_app(); app.ensure_agent_label("agent_queued"); app.agent_progress.insert( "agent_queued".to_string(), - "queued for launch permit".to_string(), + "queued waiting failed completed".to_string(), ); let rows = sidebar_agent_rows(&app); assert_eq!(rows.len(), 1); assert_eq!(rows[0].name, "Agent 1"); + assert_eq!(rows[0].status, "running"); + assert_eq!(rows[0].progress, None); + + app.agent_progress_meta.insert( + "agent_queued".to_string(), + AgentProgressMeta { + current_activity: Some(AgentCurrentActivity::bounded( + AgentCurrentActivityStatus::Queued, + Some("waiting for launch permit".to_string()), + None, + None, + )), + ..AgentProgressMeta::default() + }, + ); + let rows = sidebar_agent_rows(&app); assert_eq!(rows[0].status, "queued"); + assert_eq!( + rows[0].progress.as_deref(), + Some("queued · waiting for launch permit") + ); } #[test] @@ -7399,6 +7709,7 @@ mod tests { progress: Some("step 10: finished tool edit_file ok".to_string()), steps_taken: 10, duration_ms: Some(124_838), + transcript_available: false, expanded: true, } } diff --git a/crates/tui/src/tui/slash_menu.rs b/crates/tui/src/tui/slash_menu.rs index fb5cd47f98..4ccd372e8a 100644 --- a/crates/tui/src/tui/slash_menu.rs +++ b/crates/tui/src/tui/slash_menu.rs @@ -81,12 +81,23 @@ pub fn apply_slash_menu_selection( let mut command = selected.name.clone(); + let command_key = command.trim_start_matches('/'); + let user_takes_arguments = + commands::user_registry::with_registry_for_workspace(Some(&app.workspace), |registry| { + registry + .get(command_key) + .map(|metadata| metadata.takes_arguments()) + }); + let takes_arguments = user_takes_arguments.unwrap_or_else(|| { + commands::get_command_info(command_key).is_some_and(|info| { + info.name != "change" && (info.usage.contains('<') || info.usage.contains('[')) + }) + }); + if append_space && !command.ends_with(' ') && !command.contains(char::is_whitespace) - && let Some(info) = commands::get_command_info(command.trim_start_matches('/')) - && info.name != "change" - && (info.usage.contains('<') || info.usage.contains('[')) + && takes_arguments { command.push(' '); } diff --git a/crates/tui/src/tui/spinner.rs b/crates/tui/src/tui/spinner.rs index de80ccfaa9..fd61efb76c 100644 --- a/crates/tui/src/tui/spinner.rs +++ b/crates/tui/src/tui/spinner.rs @@ -46,14 +46,6 @@ pub(crate) fn braille_spinner_frame_for_elapsed_ms( BRAILLE_SPINNER_FRAMES[usize::try_from(idx).unwrap_or_default()] } -#[must_use] -pub(crate) fn braille_spinner_frame_for_duration_ms( - duration_ms: u64, - low_motion: bool, -) -> &'static str { - braille_spinner_frame_for_elapsed_ms(u128::from(duration_ms), low_motion) -} - #[must_use] pub(crate) fn braille_spinner_frame(started_at: Option, low_motion: bool) -> &'static str { braille_spinner_frame_for_elapsed_ms(marker_elapsed_ms(started_at), low_motion) diff --git a/crates/tui/src/tui/streaming_thinking.rs b/crates/tui/src/tui/streaming_thinking.rs index 95209967d8..6f599c410d 100644 --- a/crates/tui/src/tui/streaming_thinking.rs +++ b/crates/tui/src/tui/streaming_thinking.rs @@ -102,6 +102,16 @@ fn append_at(app: &mut App, entry_idx: usize, text: &str, now: Instant) { /// Build the spinner-decorated placeholder shown in the thinking entry /// while a translation is in flight (`Thinking… (1.2s |)`). +fn translation_placeholder_spinner_frame(app: &App, elapsed: f32) -> &'static str { + let animated_frame = match (elapsed.mul_add(2.0, 0.0) as usize) % 4 { + 0 => "|", + 1 => "/", + 2 => "-", + _ => "\\", + }; + app.motion_policy().spinner_glyph(animated_frame, true) +} + pub(super) fn translation_placeholder_frame(app: &App) -> String { let base = crate::localization::thinking_translation_placeholder(app.ui_locale); let elapsed = app @@ -109,12 +119,7 @@ pub(super) fn translation_placeholder_frame(app: &App) -> String { .or(app.turn_started_at) .map(|started| started.elapsed().as_secs_f32()) .unwrap_or_default(); - let frame = match (elapsed.mul_add(2.0, 0.0) as usize) % 4 { - 0 => "|", - 1 => "/", - 2 => "-", - _ => "\\", - }; + let frame = translation_placeholder_spinner_frame(app, elapsed); format!("{base} ({elapsed:.1}s {frame})") } @@ -328,6 +333,24 @@ mod tests { } } + #[test] + fn translation_placeholder_spinner_uses_full_motion_only() { + let mut app = test_app(); + app.low_motion = false; + app.fancy_animations = true; + assert_eq!(translation_placeholder_spinner_frame(&app, 0.0), "|"); + assert_eq!(translation_placeholder_spinner_frame(&app, 0.6), "/"); + + app.low_motion = true; + assert_eq!(translation_placeholder_spinner_frame(&app, 0.0), "⣤"); + assert_eq!(translation_placeholder_spinner_frame(&app, 0.6), "⣤"); + + app.low_motion = false; + app.fancy_animations = false; + assert_eq!(translation_placeholder_spinner_frame(&app, 0.0), "›"); + assert_eq!(translation_placeholder_spinner_frame(&app, 0.6), "›"); + } + /// #1620: a burst of reasoning chunks inside one throttle window must /// coalesce to a single active-cell revision bump (so the renderer /// re-wraps the live tail ~10x/sec instead of once per character), while diff --git a/crates/tui/src/tui/subagent_routing.rs b/crates/tui/src/tui/subagent_routing.rs index e9518d2683..bc81638b68 100644 --- a/crates/tui/src/tui/subagent_routing.rs +++ b/crates/tui/src/tui/subagent_routing.rs @@ -3,9 +3,14 @@ use std::time::{Duration, Instant}; use crate::task_manager::{TaskRecord, TaskStatus, TaskSummary}; -use crate::tools::subagent::{AgentWorkerStatus, MailboxMessage, SubAgentResult, SubAgentStatus}; +use crate::tools::subagent::{ + AgentWorkerStatus, MailboxMessage, SubAgentResult, SubAgentStatus, + subagent_progress_tool_display_name, +}; use crate::tui::app::{ - AgentProgressMeta, App, AppMode, SidebarFocus, TaskPanelEntry, TaskPanelEntryKind, + AgentCurrentActivity, AgentCurrentActivityStatus, AgentProgressMeta, AgentRecentAction, App, + AppMode, MAX_AGENT_RECENT_ACTIONS, SidebarFocus, TaskPanelEntry, TaskPanelEntryKind, + bound_agent_activity_text, }; use crate::tui::history::{HistoryCell, SubAgentCell, summarize_tool_output}; use crate::tui::pager::PagerView; @@ -127,7 +132,34 @@ pub(super) fn apply_subagent_terminal_projection( result: Option, ) -> bool { app.agent_progress.remove(agent_id); - app.agent_progress_meta.remove(agent_id); + + let worker_status = worker_status_for_terminal_projection(&status); + let safe_result = result.map(|result| bound_agent_activity_text(&result)); + let meta = app + .agent_progress_meta + .entry(agent_id.to_string()) + .or_default(); + let activity_status = if worker_status == AgentWorkerStatus::Interrupted + && meta + .current_activity + .as_ref() + .is_some_and(|activity| activity.status == AgentCurrentActivityStatus::Waiting) + { + AgentCurrentActivityStatus::Waiting + } else { + worker_status.into() + }; + let step = meta + .current_activity + .as_ref() + .and_then(|activity| activity.step); + meta.current_activity = Some(AgentCurrentActivity::bounded( + activity_status, + safe_result.clone(), + None, + step, + )); + meta.current_tool = None; let Some(agent) = app .subagent_cache @@ -138,9 +170,9 @@ pub(super) fn apply_subagent_terminal_projection( return false; }; - agent.worker_status = Some(worker_status_for_terminal_projection(&status)); + agent.worker_status = Some(worker_status); agent.status = status; - if let Some(result) = result { + if let Some(result) = safe_result { agent.result = Some(result); } reconcile_subagent_activity_state(app); @@ -161,8 +193,8 @@ fn worker_status_for_terminal_projection(status: &SubAgentStatus) -> AgentWorker pub(super) fn reconcile_subagent_activity_state_at(app: &mut App, now: Instant) { reconcile_terminal_subagent_card_retention(app, now); - let running_agents: Vec<(String, String)> = app - .subagent_cache + let cached_agents = app.subagent_cache.clone(); + let running_agents: Vec<(String, String)> = cached_agents .iter() .filter(|agent| matches!(agent.status, SubAgentStatus::Running)) .map(|agent| { @@ -180,26 +212,81 @@ pub(super) fn reconcile_subagent_activity_state_at(app: &mut App, now: Instant) // whose AgentSpawned/AgentList delivery was dropped under channel // pressure so the cache has never seen it — must survive until the cache // supersedes it, or spawned agents flicker in and out of the sidebar. - let cached_ids: std::collections::HashSet<&str> = app - .subagent_cache + let cached_ids: std::collections::HashSet = cached_agents .iter() - .map(|agent| agent.agent_id.as_str()) + .map(|agent| agent.agent_id.clone()) .collect(); app.agent_progress - .retain(|id, _| running_ids.contains(id.as_str()) || !cached_ids.contains(id.as_str())); + .retain(|id, _| running_ids.contains(id) || !cached_ids.contains(id)); + let progress_ids: std::collections::HashSet = + app.agent_progress.keys().cloned().collect(); app.agent_progress_meta - .retain(|id, _| running_ids.contains(id.as_str()) || !cached_ids.contains(id.as_str())); - for (id, objective) in running_agents { - app.agent_progress.entry(id.clone()).or_insert(objective); - if let Some(agent) = app.subagent_cache.iter().find(|agent| agent.agent_id == id) { - app.agent_progress_meta - .entry(id.clone()) - .or_insert_with(|| AgentProgressMeta { - parent_run_id: agent.parent_run_id.clone(), - spawn_depth: agent.spawn_depth, - ..AgentProgressMeta::default() - }); + .retain(|id, _| cached_ids.contains(id) || progress_ids.contains(id)); + + for (id, objective) in &running_agents { + app.agent_progress + .entry(id.clone()) + .or_insert_with(|| objective.clone()); + } + + for agent in &cached_agents { + let meta = app + .agent_progress_meta + .entry(agent.agent_id.clone()) + .or_insert_with(|| AgentProgressMeta { + parent_run_id: agent.parent_run_id.clone(), + spawn_depth: agent.spawn_depth, + ..AgentProgressMeta::default() + }); + meta.parent_run_id = agent.parent_run_id.clone(); + meta.spawn_depth = agent.spawn_depth; + + let existing = meta.current_activity.clone(); + let mut structured_status = if agent.needs_input.is_some() { + AgentCurrentActivityStatus::Waiting + } else if let Some(worker_status) = agent.worker_status { + worker_status.into() + } else if matches!(agent.status, SubAgentStatus::Running) { + existing + .as_ref() + .map(|activity| activity.status) + .unwrap_or(AgentCurrentActivityStatus::Running) + } else { + worker_status_for_terminal_projection(&agent.status).into() + }; + if structured_status == AgentCurrentActivityStatus::Interrupted + && existing + .as_ref() + .is_some_and(|activity| activity.status == AgentCurrentActivityStatus::Waiting) + { + structured_status = AgentCurrentActivityStatus::Waiting; } + + let detail = agent + .needs_input + .as_ref() + .map(|needs_input| needs_input.question.clone()) + .or_else(|| { + existing + .as_ref() + .filter(|activity| activity.status == structured_status) + .and_then(|activity| activity.detail.clone()) + }) + .or_else(|| agent.result.clone()); + let current_tool = existing + .as_ref() + .filter(|_| structured_status == AgentCurrentActivityStatus::RunningTool) + .and_then(|activity| activity.current_tool.clone()); + let step = (agent.steps_taken > 0) + .then_some(agent.steps_taken) + .or_else(|| existing.as_ref().and_then(|activity| activity.step)); + meta.current_activity = Some(AgentCurrentActivity::bounded( + structured_status, + detail, + current_tool.clone(), + step, + )); + meta.current_tool = current_tool; } if running_ids.is_empty() { @@ -368,6 +455,10 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox .. } = message { + // Preserve the effective child route for Agent Details. This is the + // only provider source used by that projection: configured/default + // parent routes are not evidence that the child actually used them. + record_agent_current_activity(app, message); let billing = crate::route_billing::for_child_route( app.api_provider, app.billing_presentation, @@ -386,8 +477,9 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox // Resolve (or allocate) the target cell for this envelope. ChildSpawned // is special — it always belongs to the active fanout card if one // exists; otherwise it seeds a new one. - let agent_id = message.agent_id().to_string(); - record_agent_tool_activity(app, message); + let display_message = bounded_mailbox_message(message); + let agent_id = display_message.agent_id().to_string(); + record_agent_current_activity(app, message); if subagent_message_refreshes_workspace_context(message) { workspace_context::refresh_now(app, Instant::now()); } @@ -396,7 +488,7 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox && let Some(idx) = app.last_fanout_card_index && let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get_mut(idx) { - let updated = apply_to_fanout(card, message); + let updated = apply_to_fanout(card, &display_message); app.subagent_card_index.insert(agent_id, idx); if updated { app.bump_history_cell(idx); @@ -408,10 +500,10 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox if let Some(&idx) = app.subagent_card_index.get(&agent_id) { let updated = match app.history.get_mut(idx) { Some(HistoryCell::SubAgent(SubAgentCell::Delegate(card))) => { - apply_to_delegate(card, message) + apply_to_delegate(card, &display_message) } Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) => { - apply_to_fanout(card, message) + apply_to_fanout(card, &display_message) } _ => false, }; @@ -426,7 +518,7 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox // No existing card — only `Started` reasonably opens one. Anything else // for an unknown agent_id is dropped (likely arrived after the cell was // cleared, e.g. session-resume edge cases). - let agent_type = match message { + let agent_type = match &display_message { MailboxMessage::Started { agent_type, .. } => agent_type.clone(), MailboxMessage::Completed { .. } | MailboxMessage::Failed { .. } @@ -463,7 +555,7 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox } } else { let mut card = DelegateCard::new(agent_id.clone(), agent_type.clone()); - apply_to_delegate(&mut card, message); + apply_to_delegate(&mut card, &display_message); app.add_message(HistoryCell::SubAgent(SubAgentCell::Delegate(card))); let idx = app.history.len().saturating_sub(1); app.subagent_card_index.insert(agent_id.clone(), idx); @@ -476,32 +568,167 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox } } -fn record_agent_tool_activity(app: &mut App, message: &MailboxMessage) { - let (agent_id, tool_name, completed_ok) = match message { +fn bounded_mailbox_message(message: &MailboxMessage) -> MailboxMessage { + match message { + MailboxMessage::Progress { agent_id, status } => MailboxMessage::Progress { + agent_id: agent_id.clone(), + status: bound_agent_activity_text(status), + }, MailboxMessage::ToolCallStarted { agent_id, tool_name, - .. - } => (agent_id, tool_name, None), + step, + } => MailboxMessage::ToolCallStarted { + agent_id: agent_id.clone(), + tool_name: bound_agent_activity_text(subagent_progress_tool_display_name(tool_name)), + step: *step, + }, MailboxMessage::ToolCallCompleted { agent_id, tool_name, + step, + ok, + } => MailboxMessage::ToolCallCompleted { + agent_id: agent_id.clone(), + tool_name: bound_agent_activity_text(subagent_progress_tool_display_name(tool_name)), + step: *step, + ok: *ok, + }, + MailboxMessage::Completed { agent_id, summary } => MailboxMessage::Completed { + agent_id: agent_id.clone(), + summary: bound_agent_activity_text(summary), + }, + MailboxMessage::Failed { agent_id, error } => MailboxMessage::Failed { + agent_id: agent_id.clone(), + error: bound_agent_activity_text(error), + }, + MailboxMessage::Interrupted { agent_id, reason } => MailboxMessage::Interrupted { + agent_id: agent_id.clone(), + reason: bound_agent_activity_text(reason), + }, + _ => message.clone(), + } +} + +fn record_agent_current_activity(app: &mut App, message: &MailboxMessage) { + let agent_id = message.agent_id().to_string(); + let meta = app.agent_progress_meta.entry(agent_id).or_default(); + if let MailboxMessage::TokenUsage { + provider, model, .. + } = message + { + meta.resolved_provider = Some(provider.as_str().to_string()); + meta.resolved_model = + Some(bound_agent_activity_text(model)).filter(|model| !model.trim().is_empty()); + return; + } + if let MailboxMessage::ToolCallCompleted { + tool_name, + step, + ok, + .. + } = message + { + if meta.recent_actions.len() == MAX_AGENT_RECENT_ACTIONS { + meta.recent_actions.pop_front(); + } + meta.recent_actions.push_back(AgentRecentAction::bounded( + subagent_progress_tool_display_name(tool_name), + *step, + *ok, + )); + } + let previous = meta.current_activity.clone(); + + let (status, detail, current_tool, step) = match message { + MailboxMessage::Started { agent_type, .. } => ( + AgentCurrentActivityStatus::Running, + Some(format!("started {agent_type}")), + None, + None, + ), + MailboxMessage::Progress { status, .. } => ( + previous + .as_ref() + .map(|activity| activity.status) + .unwrap_or(AgentCurrentActivityStatus::Running), + Some(status.clone()), + previous + .as_ref() + .and_then(|activity| activity.current_tool.clone()), + previous.as_ref().and_then(|activity| activity.step), + ), + MailboxMessage::ToolCallStarted { + tool_name, step, .. + } => ( + AgentCurrentActivityStatus::RunningTool, + None, + Some(subagent_progress_tool_display_name(tool_name).to_string()), + Some(*step), + ), + MailboxMessage::ToolCallCompleted { + tool_name, + step, ok, .. - } => (agent_id, tool_name, Some(*ok)), - _ => return, + } => ( + AgentCurrentActivityStatus::Running, + Some(format!( + "{} {}", + subagent_progress_tool_display_name(tool_name), + if *ok { "completed" } else { "failed" } + )), + None, + Some(*step), + ), + MailboxMessage::ChildSpawned { parent_id, .. } => ( + AgentCurrentActivityStatus::Starting, + Some(format!("spawned by {parent_id}")), + None, + None, + ), + MailboxMessage::Completed { summary, .. } => ( + AgentCurrentActivityStatus::Done, + Some(summary.clone()), + None, + previous.as_ref().and_then(|activity| activity.step), + ), + MailboxMessage::Failed { error, .. } => ( + AgentCurrentActivityStatus::Failed, + Some(error.clone()), + None, + previous.as_ref().and_then(|activity| activity.step), + ), + MailboxMessage::Interrupted { reason, .. } => ( + AgentCurrentActivityStatus::Waiting, + Some(reason.clone()), + None, + previous.as_ref().and_then(|activity| activity.step), + ), + MailboxMessage::Cancelled { .. } => ( + AgentCurrentActivityStatus::Canceled, + None, + None, + previous.as_ref().and_then(|activity| activity.step), + ), + MailboxMessage::TokenUsage { .. } => unreachable!("token usage handled above"), }; - let meta = app.agent_progress_meta.entry(agent_id.clone()).or_default(); - match completed_ok { - None => meta.current_tool = Some(tool_name.clone()), - Some(ok) => { - if meta.current_tool.as_deref() == Some(tool_name.as_str()) { - meta.current_tool = None; - } - if ok && is_file_mutation_tool(tool_name) { - meta.files_touched = meta.files_touched.saturating_add(1); - } - } + + meta.current_activity = Some(AgentCurrentActivity::bounded( + status, + detail, + current_tool.clone(), + step, + )); + meta.current_tool = current_tool; + if let MailboxMessage::ToolCallCompleted { + tool_name, + ok: true, + .. + } = message + && is_file_mutation_tool(tool_name) + { + meta.files_touched = meta.files_touched.saturating_add(1); } } @@ -884,6 +1111,258 @@ mod tests { assert_eq!(app.agent_progress_meta["agent_live"].files_touched, 1); } + #[test] + fn canonical_child_file_activity_counts_only_successful_mutations() { + let mut app = App::new(test_options(), &Config::default()); + + for (step, tool_name) in ["read_file", "list_dir", "file_search", "grep_files"] + .into_iter() + .enumerate() + { + record_agent_current_activity( + &mut app, + &MailboxMessage::ToolCallCompleted { + agent_id: "agent_files".to_string(), + tool_name: tool_name.to_string(), + step: step as u32, + ok: true, + }, + ); + } + assert_eq!(app.agent_progress_meta["agent_files"].files_touched, 0); + + for (step, tool_name) in ["write_file", "edit_file", "apply_patch"] + .into_iter() + .enumerate() + { + record_agent_current_activity( + &mut app, + &MailboxMessage::ToolCallCompleted { + agent_id: "agent_files".to_string(), + tool_name: tool_name.to_string(), + step: (step + 10) as u32, + ok: true, + }, + ); + } + assert_eq!(app.agent_progress_meta["agent_files"].files_touched, 3); + + record_agent_current_activity( + &mut app, + &MailboxMessage::ToolCallCompleted { + agent_id: "agent_files".to_string(), + tool_name: "write_file".to_string(), + step: 20, + ok: false, + }, + ); + assert_eq!(app.agent_progress_meta["agent_files"].files_touched, 3); + } + + #[test] + fn recent_actions_are_three_bounded_structured_tool_outcomes() { + let mut app = App::new(test_options(), &Config::default()); + let agent_id = "agent_recent"; + for step in 1..=5 { + record_agent_current_activity( + &mut app, + &MailboxMessage::ToolCallCompleted { + agent_id: agent_id.to_string(), + tool_name: format!("\u{1b}[31mtool_{step}\u{1b}[0m"), + step, + ok: step != 4, + }, + ); + } + record_agent_current_activity( + &mut app, + &MailboxMessage::Progress { + agent_id: agent_id.to_string(), + status: "tool_99 completed".to_string(), + }, + ); + + let actions = &app.agent_progress_meta[agent_id].recent_actions; + assert_eq!(actions.len(), MAX_AGENT_RECENT_ACTIONS); + assert_eq!( + actions.iter().map(|action| action.step).collect::>(), + vec![3, 4, 5] + ); + assert!(!actions.iter().any(|action| action.step == 99)); + assert!(actions.iter().all(|action| !action.tool.contains('\u{1b}'))); + assert!(!actions[1].ok); + } + + #[test] + fn token_usage_records_only_the_effective_child_route_facts() { + let mut app = App::new(test_options(), &Config::default()); + let changed = handle_subagent_mailbox( + &mut app, + 91, + &MailboxMessage::TokenUsage { + agent_id: "agent_route".to_string(), + provider: crate::config::ApiProvider::Openrouter, + model: "vendor/model-real".to_string(), + usage: crate::models::Usage::default(), + }, + ); + + assert!(!changed, "route facts do not allocate a transcript card"); + let meta = &app.agent_progress_meta["agent_route"]; + assert_eq!(meta.resolved_provider.as_deref(), Some("openrouter")); + assert_eq!(meta.resolved_model.as_deref(), Some("vendor/model-real")); + assert!(meta.current_activity.is_none()); + } + + #[test] + fn typed_mailbox_lifecycle_projects_running_waiting_failed_and_done() { + let mut app = App::new(test_options(), &Config::default()); + + assert!(handle_subagent_mailbox( + &mut app, + 1, + &MailboxMessage::started("agent_running", SubAgentType::General), + )); + assert_eq!( + app.agent_progress_meta["agent_running"] + .current_activity + .as_ref() + .map(|activity| activity.status), + Some(AgentCurrentActivityStatus::Running) + ); + + assert!(handle_subagent_mailbox( + &mut app, + 2, + &MailboxMessage::ToolCallStarted { + agent_id: "agent_running".to_string(), + tool_name: "read_file".to_string(), + step: 3, + }, + )); + let running = app.agent_progress_meta["agent_running"] + .current_activity + .as_ref() + .expect("running tool projection"); + assert_eq!(running.status, AgentCurrentActivityStatus::RunningTool); + assert_eq!(running.current_tool.as_deref(), Some("read_file")); + assert_eq!(running.step, Some(3)); + + assert!(handle_subagent_mailbox( + &mut app, + 3, + &MailboxMessage::Interrupted { + agent_id: "agent_running".to_string(), + reason: "approval needed".to_string(), + }, + )); + let waiting = app.agent_progress_meta["agent_running"] + .current_activity + .as_ref() + .expect("waiting projection"); + assert_eq!(waiting.status, AgentCurrentActivityStatus::Waiting); + assert_eq!(waiting.detail.as_deref(), Some("approval needed")); + + for (seq, agent_id, terminal, expected) in [ + ( + 4, + "agent_failed", + MailboxMessage::Failed { + agent_id: "agent_failed".to_string(), + error: "verification failed".to_string(), + }, + AgentCurrentActivityStatus::Failed, + ), + ( + 5, + "agent_done", + MailboxMessage::Completed { + agent_id: "agent_done".to_string(), + summary: "verification complete".to_string(), + }, + AgentCurrentActivityStatus::Done, + ), + ] { + assert!(handle_subagent_mailbox(&mut app, seq, &terminal)); + assert_eq!( + app.agent_progress_meta[agent_id] + .current_activity + .as_ref() + .map(|activity| activity.status), + Some(expected) + ); + } + } + + #[test] + fn reconcile_projects_typed_status_when_activity_detail_is_missing() { + let mut app = App::new(test_options(), &Config::default()); + let mut agent = subagent_result("agent_model_wait", SubAgentStatus::Running); + agent.worker_status = Some(AgentWorkerStatus::ModelWait); + app.subagent_cache.push(agent); + + reconcile_subagent_activity_state_at(&mut app, Instant::now()); + + let activity = app.agent_progress_meta["agent_model_wait"] + .current_activity + .as_ref() + .expect("typed activity fallback"); + assert_eq!(activity.status, AgentCurrentActivityStatus::ModelWait); + assert_eq!(activity.detail, None); + } + + #[test] + fn mailbox_compact_projection_redacts_secrets_and_control_sequences() { + let mut app = App::new(test_options(), &Config::default()); + let agent_id = "agent_safe_projection"; + assert!(handle_subagent_mailbox( + &mut app, + 1, + &MailboxMessage::started(agent_id, SubAgentType::General), + )); + let secret = "sk-mailbox-secret-1234567890"; + let raw = format!( + "\u{1b}[31mrunning\u{1b}[0m\napi_key={secret}\n\u{1b}]8;;https://example.invalid\u{7}details\u{1b}]8;;\u{7}\u{1}" + ); + assert!(handle_subagent_mailbox( + &mut app, + 2, + &MailboxMessage::progress(agent_id, raw.clone()), + )); + + let activity = app.agent_progress_meta[agent_id] + .current_activity + .as_ref() + .expect("safe activity projection"); + let detail = activity.detail.as_deref().expect("safe detail"); + assert!(detail.contains("[redacted]"), "{detail:?}"); + assert!(!detail.contains(secret), "{detail:?}"); + assert!(!detail.contains('\u{1b}'), "{detail:?}"); + assert!(!detail.contains("example.invalid"), "{detail:?}"); + + let card_index = app.subagent_card_index[agent_id]; + let HistoryCell::SubAgent(SubAgentCell::Delegate(card)) = &app.history[card_index] else { + panic!("expected delegate card"); + }; + let rendered = card + .render_lines(120) + .into_iter() + .flat_map(|line| line.spans.into_iter().map(|span| span.content.into_owned())) + .collect::(); + assert!(rendered.contains("[redacted]"), "{rendered:?}"); + assert!(!rendered.contains(secret), "{rendered:?}"); + assert!(!rendered.contains('\u{1b}'), "{rendered:?}"); + assert!(!rendered.contains("example.invalid"), "{rendered:?}"); + assert!( + raw.contains(secret), + "source mailbox payload stays untouched" + ); + assert!( + raw.contains('\u{1b}'), + "source mailbox payload stays untouched" + ); + } + #[test] fn reconcile_keeps_progress_only_rows_until_cache_knows_the_agent() { let mut app = App::new(test_options(), &Config::default()); @@ -931,9 +1410,13 @@ mod tests { !app.agent_progress.contains_key("agent_done"), "cache-known terminal agent progress must still be evicted" ); - assert!( - !app.agent_progress_meta.contains_key("agent_done"), - "cache-known terminal agent meta must still be evicted" + assert_eq!( + app.agent_progress_meta["agent_done"] + .current_activity + .as_ref() + .map(|activity| activity.status), + Some(AgentCurrentActivityStatus::Done), + "cache-known terminal agents retain a bounded terminal projection" ); // Once the authoritative cache reports the orphan as terminal, the @@ -945,6 +1428,13 @@ mod tests { !app.agent_progress.contains_key("agent_orphan"), "cache supersedes the progress-only row once it knows the agent" ); + assert_eq!( + app.agent_progress_meta["agent_orphan"] + .current_activity + .as_ref() + .map(|activity| activity.status), + Some(AgentCurrentActivityStatus::Done) + ); } #[test] @@ -976,7 +1466,13 @@ mod tests { )); assert!(!app.agent_progress.contains_key("agent_done")); - assert!(!app.agent_progress_meta.contains_key("agent_done")); + assert_eq!( + app.agent_progress_meta["agent_done"] + .current_activity + .as_ref() + .map(|activity| activity.status), + Some(AgentCurrentActivityStatus::Canceled) + ); let agent = app .subagent_cache .iter() diff --git a/crates/tui/src/tui/tool_routing.rs b/crates/tui/src/tui/tool_routing.rs index 9c30c75e35..000a21a82e 100644 --- a/crates/tui/src/tui/tool_routing.rs +++ b/crates/tui/src/tui/tool_routing.rs @@ -6,13 +6,14 @@ use std::time::Instant; use crate::hooks::HookEvent; use crate::tools::ReviewOutput; use crate::tools::apply_patch::{NormalizedApplyPatchInput, normalize_apply_patch_input}; +use crate::tools::canonical_action::canonical_action_alias; use crate::tools::plan::PlanSnapshot; use crate::tools::spec::{ToolError, ToolResult}; use crate::tui::active_cell::ActiveCell; use crate::tui::app::{App, ToolDetailRecord, ToolEvidence}; use crate::tui::history::{ - DiffPreviewCell, ExecCell, ExecSource, ExploringEntry, GenericToolCell, HistoryCell, - McpToolCell, PatchSummaryCell, PlanUpdateCell, ReviewCell, ToolCell, ToolStatus, ViewImageCell, + ExecCell, ExecSource, ExploringEntry, GenericToolCell, HistoryCell, McpToolCell, + PatchSummaryCell, PlanUpdateCell, ReviewCell, ToolCell, ToolStatus, ViewImageCell, WebSearchCell, output_looks_like_diff, summarize_mcp_output, summarize_tool_args, summarize_tool_output, }; @@ -32,6 +33,7 @@ pub(super) fn handle_tool_call_started( // the turn-loop gate — it processes the denial (exit code 2). let id = id.to_string(); + let semantic_name = canonical_action_alias(name, input); // All in-flight tool work for the current turn lives in `app.active_cell` // until the turn completes. This mirrors Codex's contract: ONE active cell @@ -42,8 +44,8 @@ pub(super) fn handle_tool_call_started( app.active_cell = Some(ActiveCell::new()); } - if is_exploring_tool(name) { - let label = exploring_label(name, input); + if is_exploring_tool(semantic_name) { + let label = exploring_label(semantic_name, input); // ensure_exploring + append_to_exploring keeps all parallel exploring // starts in a single ExploringCell entry. let active = app.active_cell.as_mut().expect("active_cell just ensured"); @@ -72,10 +74,10 @@ pub(super) fn handle_tool_call_started( // hold both an exploring aggregate AND independent tool entries // simultaneously, which is exactly the case CX#7 fixes. - if is_exec_tool(name) { + if is_exec_tool(semantic_name) { let command = exec_target_from_input(input); let source = exec_source_from_input(input); - let interaction = exec_interaction_summary(name, input); + let interaction = exec_interaction_summary(semantic_name, input); let mut is_wait = false; if let Some((summary, wait)) = interaction.as_ref() { @@ -152,7 +154,7 @@ pub(super) fn handle_tool_call_started( return; } - if name == "update_plan" { + if semantic_name == "update_plan" { let snapshot = parse_plan_input(input); push_active_tool_cell( app, @@ -167,8 +169,8 @@ pub(super) fn handle_tool_call_started( return; } - if name == "apply_patch" { - let (path, summary) = parse_patch_summary(input); + if matches!(semantic_name, "write_file" | "edit_file" | "apply_patch") { + let (path, summary) = parse_file_mutation_summary(semantic_name, input); push_active_tool_cell( app, &id, @@ -179,12 +181,13 @@ pub(super) fn handle_tool_call_started( summary, status: ToolStatus::Running, error: None, + receipt: None, })), ); return; } - if name == "review" { + if semantic_name == "review" { let target = review_target_label(input); push_active_tool_cell( app, @@ -201,7 +204,7 @@ pub(super) fn handle_tool_call_started( return; } - if is_mcp_tool(name) { + if is_mcp_tool(semantic_name) { push_active_tool_cell( app, &id, @@ -217,7 +220,7 @@ pub(super) fn handle_tool_call_started( return; } - if is_view_image_tool(name) { + if is_view_image_tool(semantic_name) { if let Some(path) = input.get("path").and_then(|v| v.as_str()) { let raw_path = PathBuf::from(path); let display_path = raw_path @@ -235,7 +238,7 @@ pub(super) fn handle_tool_call_started( return; } - if is_web_search_tool(name) { + if is_web_search_tool(semantic_name) { let query = web_search_query(input); push_active_tool_cell( app, @@ -284,7 +287,7 @@ pub(super) fn handle_tool_call_started( name, input, HistoryCell::Tool(ToolCell::Generic(GenericToolCell { - name: name.to_string(), + name: semantic_name.to_string(), status: ToolStatus::Running, input_summary, output: None, @@ -438,9 +441,6 @@ fn record_spillover_artifact_if_any( result: &Result, ) { let Ok(tool_result) = result else { return }; - if !tool_result.success { - return; - } let Some(path) = tool_result .metadata .as_ref() @@ -559,6 +559,20 @@ pub(super) fn handle_tool_call_complete( if app.ignored_tool_calls.remove(id) { return; } + // Preserve the execution/audit name while recovering the action-qualified + // semantic name from the registered call input. Active entries and + // already-flushed history use separate detail stores. + let semantic_name = app + .active_tool_details + .get(id) + .or_else(|| { + app.tool_cells + .get(id) + .and_then(|cell_index| app.tool_details_by_cell.get(cell_index)) + }) + .map_or(name, |detail| canonical_action_alias(name, &detail.input)) + .to_string(); + // Roll any child-LLM token usage the tool reports into the // session-cost counter. Runs unconditionally so future tools that // spawn their own LLM calls (RLM, summarizers, retrieval helpers) @@ -605,6 +619,16 @@ pub(super) fn handle_tool_call_complete( let in_active = cell_index >= app.history.len(); let status = tool_status_from_result(result); + let mutation_receipt = matches!( + semantic_name.as_str(), + "write_file" | "edit_file" | "apply_patch" + ) + .then(|| { + result.as_ref().ok().and_then(|tool_result| { + crate::tui::history::FileMutationReceipt::from_success(&app.workspace, tool_result) + }) + }) + .flatten(); let mut workflow_panel_output: Option = None; if let Some(cell) = app.cell_at_virtual_index_mut(cell_index) { @@ -692,8 +716,9 @@ pub(super) fn handle_tool_call_complete( } HistoryCell::Tool(ToolCell::PatchSummary(patch)) => { patch.status = status; + patch.receipt = mutation_receipt; match result.as_ref() { - Ok(tool_result) => { + Ok(tool_result) if tool_result.success => { if let Ok(json) = serde_json::from_str::(&tool_result.content) && let Some(message) = json.get("message").and_then(|v| v.as_str()) @@ -701,6 +726,9 @@ pub(super) fn handle_tool_call_complete( patch.summary = message.to_string(); } } + Ok(tool_result) => { + patch.error = Some(tool_result.content.clone()); + } Err(err) => { patch.error = Some(err.to_string()); } @@ -803,7 +831,7 @@ pub(super) fn handle_tool_call_complete( refresh_active_tool_completion_timestamp(app, cell_index); } - if refreshes_workspace_context_on_completion(name) && status != ToolStatus::Running { + if refreshes_workspace_context_on_completion(&semantic_name) && status != ToolStatus::Running { workspace_context::refresh_now(app, Instant::now()); } @@ -1227,8 +1255,9 @@ fn history_cell_has_running_tool(cell: &HistoryCell) -> bool { /// every tool result is visible somewhere; the alternative (silently /// dropping it) hides errors and breaks debuggability. /// -/// Choice of cell type: we use `GenericToolCell` because we have no input -/// payload to reconstruct a more specific cell. The pager remains usable — +/// Choice of cell type: success-only mutation metadata is sufficient to +/// reconstruct a structured File receipt; other orphans stay generic because +/// no input payload remains. The pager remains usable in both cases because /// `tool_details_by_cell` is populated with the result text. /// /// ## Index drift @@ -1259,16 +1288,35 @@ fn push_orphan_tool_completion( .map(std::path::PathBuf::from); let output_summary = output.as_deref().map(summarize_tool_output); let is_diff = output.as_deref().is_some_and(output_looks_like_diff); - app.add_message(HistoryCell::Tool(ToolCell::Generic(GenericToolCell { - name: name.to_string(), - status, - input_summary: None, - output, - prompts: None, - spillover_path, - output_summary, - is_diff, - }))); + let mutation_receipt = result.as_ref().ok().and_then(|tool_result| { + crate::tui::history::FileMutationReceipt::from_success(&app.workspace, tool_result) + }); + let cell = if let Some(receipt) = mutation_receipt { + let path = receipt + .files + .first() + .map_or_else(|| "".to_string(), |file| file.path.clone()); + let summary = receipt.semantic_summary(); + HistoryCell::Tool(ToolCell::PatchSummary(PatchSummaryCell { + path, + summary, + status, + error: None, + receipt: Some(receipt), + })) + } else { + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: name.to_string(), + status, + input_summary: None, + output, + prompts: None, + spillover_path, + output_summary, + is_diff, + })) + }; + app.add_message(cell); let cell_index = app.history.len().saturating_sub(1); app.tool_details_by_cell.insert( cell_index, @@ -1354,7 +1402,12 @@ fn is_exploring_tool(name: &str) -> bool { fn is_exec_tool(name: &str) -> bool { matches!( name, - "exec_shell" | "exec_shell_wait" | "exec_shell_interact" | "exec_wait" | "exec_interact" + "exec_shell" + | "exec_shell_wait" + | "exec_shell_interact" + | "exec_shell_cancel" + | "exec_wait" + | "exec_interact" ) } @@ -1364,10 +1417,14 @@ pub(super) fn refreshes_workspace_context_on_completion(name: &str) -> bool { "exec_shell" | "exec_shell_wait" | "exec_shell_interact" + | "exec_shell_cancel" | "exec_wait" | "exec_interact" | "task_shell_start" | "task_shell_wait" + | "write_file" + | "edit_file" + | "apply_patch" ) } @@ -1464,7 +1521,22 @@ fn parse_plan_input(input: &serde_json::Value) -> PlanSnapshot { PlanSnapshot::from_tool_input(input) } -fn parse_patch_summary(input: &serde_json::Value) -> (String, String) { +fn parse_file_mutation_summary(semantic_name: &str, input: &serde_json::Value) -> (String, String) { + if semantic_name != "apply_patch" { + let path = input + .get("path") + .and_then(serde_json::Value::as_str) + .filter(|path| !path.trim().is_empty()) + .unwrap_or("") + .to_string(); + let summary = match semantic_name { + "write_file" => "Writing file", + "edit_file" => "Editing file", + _ => "Changing file", + } + .to_string(); + return (path, summary); + } let patch_text = match normalize_apply_patch_input(input) { Ok(NormalizedApplyPatchInput::Replacement { entries: changes, .. @@ -1541,60 +1613,6 @@ fn extract_patch_paths(patch: &str) -> Vec { paths } -pub(super) fn maybe_add_patch_preview(app: &mut App, input: &serde_json::Value) { - match normalize_apply_patch_input(input) { - Ok(NormalizedApplyPatchInput::Patch(patch)) => { - app.add_message(HistoryCell::Tool(ToolCell::DiffPreview(DiffPreviewCell { - title: "Patch Preview".to_string(), - diff: patch.to_string(), - }))); - app.mark_history_updated(); - } - Ok(NormalizedApplyPatchInput::Replacement { entries, .. }) => { - let preview = format_changes_preview(entries); - if !preview.trim().is_empty() { - app.add_message(HistoryCell::Tool(ToolCell::DiffPreview(DiffPreviewCell { - title: "Changes Preview".to_string(), - diff: preview, - }))); - app.mark_history_updated(); - } - } - Err(_) => {} - } -} - -fn format_changes_preview(changes: &[serde_json::Value]) -> String { - let mut out = String::new(); - for change in changes { - let path = change - .get("path") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let content = change.get("content").and_then(|v| v.as_str()).unwrap_or(""); - - out.push_str(&format!("diff --git a/{path} b/{path}\n")); - out.push_str(&format!("--- a/{path}\n+++ b/{path}\n")); - out.push_str("@@ -0,0 +1,1 @@\n"); - - let mut count = 0usize; - for line in content.lines() { - out.push('+'); - out.push_str(line); - out.push('\n'); - count += 1; - if count >= 20 { - out.push_str("+... (truncated)\n"); - break; - } - } - if content.is_empty() { - out.push_str("+\n"); - } - } - out -} - fn count_patch_changes(patch: &str) -> (usize, usize) { let mut adds = 0; let mut removes = 0; @@ -1647,6 +1665,22 @@ fn exec_interaction_summary(name: &str, input: &serde_json::Value) -> Option<(St let is_wait_tool = matches!(name, "exec_shell_wait" | "exec_wait"); let is_interact_tool = matches!(name, "exec_shell_interact" | "exec_interact"); + let is_cancel_tool = name == "exec_shell_cancel"; + + if is_cancel_tool { + let summary = if input.get("all").and_then(serde_json::Value::as_bool) == Some(true) { + "Cancelled all background commands".to_string() + } else if let Some(task_id) = input + .get("task_id") + .or_else(|| input.get("id")) + .and_then(serde_json::Value::as_str) + { + format!("Cancelled command {task_id}") + } else { + "Cancelled background command".to_string() + }; + return Some((summary, false)); + } if is_interact_tool || interaction_input.is_some() { let preview = interaction_input.map(summarize_interaction_input); @@ -1836,8 +1870,9 @@ mod tests { "content": "fn replacement() {}\n" }]); - let canonical = parse_patch_summary(&json!({"replace": replacements.clone()})); - let legacy = parse_patch_summary(&json!({"changes": replacements})); + let canonical = + parse_file_mutation_summary("apply_patch", &json!({"replace": replacements.clone()})); + let legacy = parse_file_mutation_summary("apply_patch", &json!({"changes": replacements})); assert_eq!(canonical, legacy); } diff --git a/crates/tui/src/tui/transcript.rs b/crates/tui/src/tui/transcript.rs index 35aba73fc4..0618185ea7 100644 --- a/crates/tui/src/tui/transcript.rs +++ b/crates/tui/src/tui/transcript.rs @@ -59,26 +59,57 @@ struct CachedCell { /// Whether this cell's rendered output was empty (e.g. Thinking hidden). /// Cached so we can skip empty cells without re-rendering. is_empty: bool, - /// Whether this cell is a stream continuation. Determines spacer rules. - /// Cached because `is_stream_continuation` is cheap but reading via the - /// cache lets us decide spacers without touching the cell. - is_stream_continuation: bool, - /// Whether this cell is conversational (User/Assistant/Thinking). Used - /// for spacer calculations. - is_conversational: bool, - /// Whether this cell is model reasoning. Thinking and the answer that - /// follows are one response unit, so they should not acquire the same - /// full turn gap used between user and assistant messages. - is_thinking: bool, - /// Whether this cell is assistant prose. Kept separate from the broader - /// conversational flag so spacer policy can join it to adjacent thinking. - is_assistant: bool, - /// Whether this cell is a System or Tool cell (affects spacer rules). - is_system_or_tool: bool, + /// Semantic role used by the transcript's explicit boundary matrix. + /// Keeping the role in the cache makes spacing independent of rendered + /// strings, theme colors, terminal depth, and animation state. + kind: TranscriptBlockKind, /// Whether this cell participates in the compact tool-card rail group. is_tool_groupable: bool, } +/// Visual role of one transcript cell. +/// +/// Approval, question, Work-panel, and composer surfaces live outside the +/// transcript cache and already own bounded panels/edges. This enum covers +/// every in-transcript seam, including durable Work receipts emitted by plan, +/// checklist, and workflow tools. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TranscriptBlockKind { + User, + Reasoning, + Answer, + ToolAction, + DurableWork, + Notice, +} + +impl TranscriptBlockKind { + fn for_cell(cell: &HistoryCell) -> Self { + match cell { + HistoryCell::User { .. } => Self::User, + HistoryCell::Thinking { .. } => Self::Reasoning, + HistoryCell::Assistant { .. } => Self::Answer, + HistoryCell::Tool(tool) if tool.is_durable_work_receipt() => Self::DurableWork, + HistoryCell::Tool(_) | HistoryCell::SubAgent(_) => Self::ToolAction, + HistoryCell::System { .. } + | HistoryCell::Error { .. } + | HistoryCell::ArchivedContext { .. } => Self::Notice, + } + } +} + +/// Strength of a visible boundary. These three levels are the complete +/// transcript spacing vocabulary: no blanket per-cell padding is added. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TranscriptBoundary { + /// Two cells are one response/activity group. + Joined, + /// Compact transition into or out of tools, Work, or notices. + Activity, + /// A human turn boundary; always visible, even at compact density. + Turn, +} + /// Cache of rendered transcript lines for the current viewport. #[derive(Debug)] pub struct TranscriptViewCache { @@ -302,18 +333,7 @@ impl TranscriptViewCache { copy_separators: Arc::new(copy_separators), copy_prefix_widths: Arc::new(copy_prefix_widths), is_empty, - is_stream_continuation: cell.is_stream_continuation(), - is_conversational: cell.is_conversational(), - is_thinking: matches!(cell, HistoryCell::Thinking { .. }), - is_assistant: matches!(cell, HistoryCell::Assistant { .. }), - is_system_or_tool: matches!( - cell, - HistoryCell::System { .. } - | HistoryCell::Error { .. } - | HistoryCell::Tool(_) - | HistoryCell::SubAgent(_) - | HistoryCell::ArchivedContext { .. } - ), + kind: TranscriptBlockKind::for_cell(cell), is_tool_groupable, }); idx += 1; @@ -327,11 +347,22 @@ impl TranscriptViewCache { return; } - let rebuild_from = if layout_changed { + let mut rebuild_from = if layout_changed { 0 } else { first_dirty.unwrap_or(0).saturating_sub(1) }; + // A hidden cell has no line at which `flatten_from` can truncate. + // Walk back to the nearest visible predecessor so a cell appearing, + // disappearing, or changing kind cannot leave a stale spacer behind. + while rebuild_from > 0 + && self + .per_cell + .get(rebuild_from) + .is_some_and(|cell| cell.is_empty) + { + rebuild_from -= 1; + } self.flatten_from(options.spacing, rebuild_from); } @@ -412,7 +443,7 @@ impl TranscriptViewCache { }); } - if let Some(next) = self.per_cell.get(cell_index + 1) { + if let Some(next) = next_visible_cell(&self.per_cell, cell_index) { let spacer_rows = spacer_rows_between(cached, next, spacing); for _ in 0..spacer_rows { self.lines.push(Line::from("")); @@ -481,46 +512,84 @@ fn spacer_rows_between( next: &CachedCell, spacing: TranscriptSpacing, ) -> usize { - if current.is_stream_continuation { - return 0; + spacer_rows_for_boundary( + transcript_boundary( + current.kind, + next.kind, + same_tool_activity_group(current, next), + ), + spacing, + ) +} + +/// Adjacent tool cells share one rail only when they represent the same kind +/// of activity. Durable Work receipts are persisted state, not another +/// transient action, so crossing that semantic seam closes the current rail +/// even at compact density where no blank row is available. +fn same_tool_activity_group(current: &CachedCell, next: &CachedCell) -> bool { + current.is_tool_groupable && next.is_tool_groupable && current.kind == next.kind +} + +fn transcript_boundary( + current: TranscriptBlockKind, + next: TranscriptBlockKind, + same_tool_group: bool, +) -> TranscriptBoundary { + if same_tool_group { + debug_assert_eq!(current, next); + return TranscriptBoundary::Joined; } - if current.is_tool_groupable && next.is_tool_groupable { - return 0; + // A user block is the only unambiguous turn delimiter available to the + // renderer. Keep it distinct from direct tool execution too: models may + // legitimately move from a prompt straight into a tool without first + // emitting answer prose. + if current == TranscriptBlockKind::User || next == TranscriptBlockKind::User { + return TranscriptBoundary::Turn; } - // Reasoning and answer prose are two states of one model response, not - // separate turns. Keep that bundle visually continuous; whitespace then - // marks the actual handoff between the user and Codewhale and avoids the - // stacked-card rhythm seen at compact release sizes. - if (current.is_thinking && (next.is_thinking || next.is_assistant)) - || (current.is_assistant && next.is_thinking) - { - return 0; + // Reasoning and answer prose are phases of one model response. Joining + // them also keeps the row budget stable when streaming reasoning settles + // into the final answer. + if matches!( + (current, next), + ( + TranscriptBlockKind::Reasoning | TranscriptBlockKind::Answer, + TranscriptBlockKind::Reasoning | TranscriptBlockKind::Answer + ) + ) { + return TranscriptBoundary::Joined; } - let conversational_gap = match spacing { - // Compact still separates distinct people/turn blocks. It removes - // secondary chrome and tool gaps, not the boundary between a user and - // an assistant; without this floor dense 89-column transcripts read - // as one uninterrupted paragraph. - TranscriptSpacing::Compact | TranscriptSpacing::Comfortable => 1, - TranscriptSpacing::Spacious => 2, - }; - let secondary_gap = match spacing { - TranscriptSpacing::Compact => 0, - TranscriptSpacing::Comfortable | TranscriptSpacing::Spacious => 1, - }; + TranscriptBoundary::Activity +} - if current.is_conversational && next.is_conversational { - conversational_gap - } else if current.is_system_or_tool || next.is_system_or_tool { - secondary_gap - } else { - 0 +const fn spacer_rows_for_boundary( + boundary: TranscriptBoundary, + spacing: TranscriptSpacing, +) -> usize { + match (boundary, spacing) { + (TranscriptBoundary::Joined, _) => 0, + (TranscriptBoundary::Activity, TranscriptSpacing::Compact) => 0, + (TranscriptBoundary::Activity, _) => 1, + (TranscriptBoundary::Turn, TranscriptSpacing::Compact | TranscriptSpacing::Comfortable) => { + 1 + } + (TranscriptBoundary::Turn, TranscriptSpacing::Spacious) => 2, } } +fn previous_visible_cell(cells: &[CachedCell], cell_index: usize) -> Option<&CachedCell> { + cells[..cell_index].iter().rev().find(|cell| !cell.is_empty) +} + +fn next_visible_cell(cells: &[CachedCell], cell_index: usize) -> Option<&CachedCell> { + cells + .get(cell_index + 1..)? + .iter() + .find(|cell| !cell.is_empty) +} + fn tool_group_rail( cells: &[CachedCell], cell_index: usize, @@ -532,15 +601,12 @@ fn tool_group_rail( return None; } - let previous_is_tool = cell_index - .checked_sub(1) - .and_then(|idx| cells.get(idx)) - .is_some_and(|cell| cell.is_tool_groupable && !cell.is_empty); - let next_is_tool = cells - .get(cell_index + 1) - .is_some_and(|cell| cell.is_tool_groupable && !cell.is_empty); - let first_line_in_group = !previous_is_tool && line_in_cell == 0; - let last_line_in_group = !next_is_tool && line_in_cell + 1 == rendered_line_count; + let previous_shares_group = previous_visible_cell(cells, cell_index) + .is_some_and(|previous| same_tool_activity_group(previous, cached)); + let next_shares_group = next_visible_cell(cells, cell_index) + .is_some_and(|next| same_tool_activity_group(cached, next)); + let first_line_in_group = !previous_shares_group && line_in_cell == 0; + let last_line_in_group = !next_shares_group && line_in_cell + 1 == rendered_line_count; let rail = match (first_line_in_group, last_line_in_group) { (true, true) if rendered_line_count == 1 => { @@ -712,7 +778,10 @@ fn truncate_spans_to_width(spans: Vec>, max_width: usize) -> Vec Vec { cache @@ -757,6 +826,30 @@ mod tests { })) } + fn durable_work_cell() -> HistoryCell { + HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell { + snapshot: PlanSnapshot::default(), + status: ToolStatus::Running, + })) + } + + fn spacer_rows_after_cell(cache: &TranscriptViewCache, target_cell: usize) -> usize { + let mut saw_target = false; + let mut spacer_rows = 0; + for meta in cache.line_meta() { + match meta { + TranscriptLineMeta::CellLine { cell_index, .. } if *cell_index == target_cell => { + saw_target = true; + spacer_rows = 0; + } + TranscriptLineMeta::Spacer if saw_target => spacer_rows += 1, + TranscriptLineMeta::CellLine { .. } if saw_target => break, + TranscriptLineMeta::Spacer | TranscriptLineMeta::CellLine { .. } => {} + } + } + spacer_rows + } + #[test] fn cache_renders_user_cells_with_highlight_background() { let cells = vec![user_cell("# literal user prompt")]; @@ -1103,6 +1196,162 @@ mod tests { ); } + #[test] + fn semantic_boundary_matrix_has_three_deliberate_rhythm_levels() { + use TranscriptBlockKind::{Answer, DurableWork, Notice, Reasoning, ToolAction, User}; + use TranscriptBoundary::{Activity, Joined, Turn}; + + let cases = [ + (User, Answer, false, Turn), + (User, ToolAction, false, Turn), + (DurableWork, User, false, Turn), + (Reasoning, Answer, false, Joined), + (Answer, Reasoning, false, Joined), + (Answer, Answer, false, Joined), + (Answer, ToolAction, false, Activity), + (ToolAction, Reasoning, false, Activity), + (Notice, DurableWork, false, Activity), + (ToolAction, ToolAction, true, Joined), + (DurableWork, DurableWork, true, Joined), + (ToolAction, DurableWork, false, Activity), + ]; + + for (current, next, grouped_tools, expected) in cases { + assert_eq!( + transcript_boundary(current, next, grouped_tools), + expected, + "{current:?} -> {next:?}" + ); + } + + assert_eq!( + spacer_rows_for_boundary(Turn, TranscriptSpacing::Compact), + 1 + ); + assert_eq!( + spacer_rows_for_boundary(Turn, TranscriptSpacing::Comfortable), + 1 + ); + assert_eq!( + spacer_rows_for_boundary(Turn, TranscriptSpacing::Spacious), + 2 + ); + assert_eq!( + spacer_rows_for_boundary(Activity, TranscriptSpacing::Compact), + 0 + ); + assert_eq!( + spacer_rows_for_boundary(Activity, TranscriptSpacing::Comfortable), + 1 + ); + assert_eq!( + spacer_rows_for_boundary(Activity, TranscriptSpacing::Spacious), + 1 + ); + } + + #[test] + fn durable_work_tools_have_an_explicit_semantic_role() { + let plan = durable_work_cell(); + let tool = exec_tool_cell("cargo test --locked"); + + assert_eq!( + TranscriptBlockKind::for_cell(&plan), + TranscriptBlockKind::DurableWork + ); + assert_eq!( + TranscriptBlockKind::for_cell(&tool), + TranscriptBlockKind::ToolAction + ); + } + + #[test] + fn durable_work_starts_a_new_activity_rail_without_wasting_compact_rows() { + let durable = HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell { + snapshot: PlanSnapshot { + objective: Some("Keep the release receipt durable".to_string()), + ..PlanSnapshot::default() + }, + status: ToolStatus::Running, + })); + let cells = vec![ + exec_tool_cell("cargo test --locked"), + exec_tool_cell("cargo clippy --locked"), + durable, + ]; + let revisions = vec![1u64; cells.len()]; + + let mut compact = TranscriptViewCache::new(); + compact.ensure( + &cells, + &revisions, + 80, + TranscriptRenderOptions { + spacing: TranscriptSpacing::Compact, + low_motion: true, + ..TranscriptRenderOptions::default() + }, + ); + + assert_eq!(spacer_rows_after_cell(&compact, 0), 0); + assert_eq!(spacer_rows_after_cell(&compact, 1), 0); + let compact_lines = plain_lines(&compact); + assert!( + !compact_lines.iter().any(String::is_empty), + "compact activity seams must not spend a blank row: {compact_lines:?}" + ); + let lines_for_cell = |target| { + compact + .lines() + .iter() + .zip(compact.line_meta()) + .filter_map(|(line, meta)| match meta { + TranscriptLineMeta::CellLine { cell_index, .. } if *cell_index == target => { + Some( + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + ) + } + TranscriptLineMeta::Spacer | TranscriptLineMeta::CellLine { .. } => None, + }) + .collect::>() + }; + let second_action = lines_for_cell(1); + let durable_work = lines_for_cell(2); + assert!( + second_action + .last() + .is_some_and(|line| line.starts_with("\u{2570} ")), + "ordinary action rail should close before durable Work: {second_action:?}" + ); + assert!( + durable_work + .first() + .is_some_and(|line| line.starts_with("\u{256D} ")), + "durable Work should open its own rail: {durable_work:?}" + ); + + let mut comfortable = TranscriptViewCache::new(); + comfortable.ensure( + &cells, + &revisions, + 80, + TranscriptRenderOptions { + spacing: TranscriptSpacing::Comfortable, + low_motion: true, + ..TranscriptRenderOptions::default() + }, + ); + assert_eq!(spacer_rows_after_cell(&comfortable, 0), 0); + assert_eq!( + spacer_rows_after_cell(&comfortable, 1), + 1, + "durable Work needs a semantic activity row outside compact density" + ); + } + #[test] fn compact_spacing_keeps_conversation_blocks_separate() { let cells = vec![ @@ -1125,6 +1374,27 @@ mod tests { ); } + #[test] + fn compact_spacing_keeps_direct_user_tool_turns_separate() { + let cells = vec![ + user_cell("Inspect the repository."), + exec_tool_cell("git status --short"), + user_cell("Now summarize the result."), + ]; + let revisions = vec![1u64, 1, 1]; + let options = TranscriptRenderOptions { + spacing: TranscriptSpacing::Compact, + low_motion: true, + ..TranscriptRenderOptions::default() + }; + let mut cache = TranscriptViewCache::new(); + + cache.ensure(&cells, &revisions, 80, options); + + assert_eq!(spacer_rows_after_cell(&cache, 0), 1); + assert_eq!(spacer_rows_after_cell(&cache, 1), 1); + } + #[test] fn compact_spacing_keeps_reasoning_and_answer_in_one_response_block() { let cells = vec![ @@ -1151,6 +1421,168 @@ mod tests { ); } + #[test] + fn hidden_reasoning_keeps_visible_rhythm_without_phantom_tail_rows() { + let cells = vec![ + user_cell("Verify the release."), + HistoryCell::Thinking { + content: "Check the exact receipts.".to_string(), + streaming: false, + duration_secs: Some(0.4), + }, + assistant_cell("The receipts are green.", false), + ]; + let revisions = vec![1u64, 1, 1]; + let hidden = TranscriptRenderOptions { + show_thinking: false, + low_motion: true, + ..TranscriptRenderOptions::default() + }; + let mut cache = TranscriptViewCache::new(); + + cache.ensure(&cells, &revisions, 80, hidden); + let hidden_lines = plain_lines(&cache); + assert_eq!(spacer_rows_after_cell(&cache, 0), 1); + assert!( + hidden_lines.last().is_some_and(|line| !line.is_empty()), + "hidden cells must not leave a trailing blank row: {hidden_lines:?}" + ); + + let visible = TranscriptRenderOptions { + show_thinking: true, + ..hidden + }; + cache.ensure(&cells, &revisions, 80, visible); + cache.ensure(&cells, &revisions, 80, hidden); + assert_eq!(plain_lines(&cache), hidden_lines); + + let trailing_hidden = &cells[..2]; + let mut tail_cache = TranscriptViewCache::new(); + tail_cache.ensure(trailing_hidden, &revisions[..2], 80, hidden); + assert!( + plain_lines(&tail_cache) + .last() + .is_some_and(|line| !line.is_empty()), + "a hidden final cell must not reserve a phantom spacer" + ); + } + + #[test] + fn transcript_rhythm_is_width_and_reduced_motion_invariant() { + let cells = vec![ + user_cell("Please inspect the release candidate and verify all receipts."), + HistoryCell::Thinking { + content: "I will inspect the source, run the checks, and compare the receipts." + .to_string(), + streaming: true, + duration_secs: Some(0.8), + }, + assistant_cell("I will start with the locked test suite.", false), + exec_tool_cell("cargo test -p codewhale-tui --bins --locked"), + durable_work_cell(), + assistant_cell("The focused checks passed.", false), + user_cell("Proceed to the final verification."), + ]; + let revisions = vec![1u64; cells.len()]; + let expected = [1, 0, 1, 1, 1, 1, 0]; + + for width in [40, 80, 100, 140] { + for low_motion in [false, true] { + let options = TranscriptRenderOptions { + low_motion, + spacing: TranscriptSpacing::Comfortable, + ..TranscriptRenderOptions::default() + }; + let mut cache = TranscriptViewCache::new(); + cache.ensure(&cells, &revisions, width, options); + + let actual = + std::array::from_fn::<_, 7, _>(|index| spacer_rows_after_cell(&cache, index)); + assert_eq!(actual, expected, "width={width} low_motion={low_motion}"); + assert!( + cache + .lines() + .iter() + .all(|line| line.width() <= usize::from(width)), + "render exceeded width={width} low_motion={low_motion}" + ); + } + } + } + + #[test] + fn streaming_state_transitions_do_not_move_neighbor_boundaries() { + let mut cells = vec![ + user_cell("Inspect the candidate."), + HistoryCell::Thinking { + content: "Inspecting the candidate now.".to_string(), + streaming: true, + duration_secs: None, + }, + exec_tool_cell("git status --short"), + user_cell("Summarize the receipt."), + ]; + let mut revisions = vec![1u64; cells.len()]; + let options = TranscriptRenderOptions { + low_motion: true, + ..TranscriptRenderOptions::default() + }; + let mut cache = TranscriptViewCache::new(); + + let boundary_rows = |cache: &TranscriptViewCache| { + [ + spacer_rows_after_cell(cache, 0), + spacer_rows_after_cell(cache, 1), + spacer_rows_after_cell(cache, 2), + ] + }; + + cache.ensure(&cells, &revisions, 80, options); + assert_eq!(boundary_rows(&cache), [1, 1, 1]); + + cells[1] = assistant_cell("I inspected the candidate.", true); + revisions[1] += 1; + cache.ensure(&cells, &revisions, 80, options); + assert_eq!(boundary_rows(&cache), [1, 1, 1]); + + cells[1] = assistant_cell("I inspected the candidate.", false); + revisions[1] += 1; + cache.ensure(&cells, &revisions, 80, options); + assert_eq!(boundary_rows(&cache), [1, 1, 1]); + + let HistoryCell::Tool(ToolCell::Exec(exec)) = &mut cells[2] else { + unreachable!("fixture is an exec tool") + }; + exec.status = ToolStatus::Success; + revisions[2] += 1; + cache.ensure(&cells, &revisions, 80, options); + assert_eq!(boundary_rows(&cache), [1, 1, 1]); + } + + #[test] + fn resize_round_trip_rebuilds_the_same_semantic_rows() { + let cells = vec![ + user_cell("A long prompt that wraps when the terminal narrows considerably."), + exec_tool_cell("printf 'a tool receipt with a deliberately long summary'"), + assistant_cell("A stable answer after the tool receipt.", false), + ]; + let revisions = vec![1u64; cells.len()]; + let options = TranscriptRenderOptions { + low_motion: true, + ..TranscriptRenderOptions::default() + }; + let mut cache = TranscriptViewCache::new(); + + cache.ensure(&cells, &revisions, 140, options); + let wide = plain_lines(&cache); + cache.ensure(&cells, &revisions, 40, options); + cache.ensure(&cells, &revisions, 140, options); + + assert_eq!(plain_lines(&cache), wide); + assert_eq!(cache.lines().len(), cache.line_meta().len()); + assert_eq!(cache.lines().len(), cache.line_links().len()); + } + #[test] fn tool_rails_preserve_rendered_width_budget() { let cells = vec![exec_tool_cell( diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 076834d1c7..859ddc6bd2 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -80,7 +80,7 @@ use crate::task_manager::{ use crate::tools::goal::{GoalSnapshot, GoalStatus}; use crate::tools::shell::{ShellJobSnapshot, ShellStatus}; use crate::tools::spec::{RuntimeToolServices, ToolResult}; -use crate::tools::subagent::{MailboxMessage, SubAgentStatus}; +use crate::tools::subagent::{MailboxMessage, SubAgentStatus, subagent_progress_tool_display_name}; use crate::tui::auto_router; use crate::tui::color_compat::ColorCompatBackend; use crate::tui::command_palette::{ @@ -109,7 +109,7 @@ use crate::tui::plan_todo_bridge::{PlanAcceptance, project_accepted_plan}; use crate::tui::scrolling::TranscriptScroll; use crate::work_graph::task_owner_snapshot; // SelectionAutoscroll unused -use crate::tui::motion::{FrameRequester, MotionPolicy}; +use crate::tui::motion::{FrameRequester, MotionMode}; use crate::tui::session_picker::SessionPickerView; use crate::tui::shell_job_routing::{ add_shell_job_message, format_shell_job_list, format_shell_poll, open_shell_job_pager, @@ -128,7 +128,6 @@ use crate::tui::subagent_routing::{ use crate::tui::tool_routing::exploring_label; use crate::tui::tool_routing::{ apply_workflow_ui_event, handle_tool_call_complete, handle_tool_call_started, - maybe_add_patch_preview, }; use crate::tui::ui_text::history_cell_to_text; use crate::tui::user_input::UserInputView; @@ -139,10 +138,11 @@ use crate::tui::workspace_context; use super::key_actions; use super::app::{ - ActiveTurnMetadata, App, AppAction, AppMode, HuntVerdict, OnboardingState, - PendingProviderSwitch, QueuedMessage, ReasoningEffort, SidebarFocus, StatusToastLevel, - SubmitDisposition, TaskPanelEntry, TaskPanelEntryKind, ToolEvidence, TuiOptions, - looks_like_slash_command_input, shell_command_from_bang_input, + ActiveTurnMetadata, AgentCurrentActivity, AgentCurrentActivityStatus, App, AppAction, AppMode, + HuntVerdict, OnboardingState, PendingProviderSwitch, QueuedMessage, ReasoningEffort, + SidebarFocus, StatusToastLevel, SubmitDisposition, TaskPanelEntry, TaskPanelEntryKind, + ToolEvidence, TuiOptions, bound_agent_activity_text, looks_like_slash_command_input, + shell_command_from_bang_input, }; use super::approval::{ ApprovalMode, ApprovalRequest, ApprovalView, ElevationRequest, ElevationView, ReviewDecision, @@ -1180,7 +1180,7 @@ pub async fn run_tui( let engine_config = build_engine_config(&app, config); // Spawn the Engine - it will handle all API communication - let engine_handle = spawn_engine(engine_config, config); + let engine_handle = spawn_tui_engine(engine_config, config); crate::startup_trace::mark("engine_spawned"); // The translation client is optional: it never crashes the TUI on // startup, even when the API key is missing, the base URL is malformed, @@ -1701,6 +1701,15 @@ fn handle_memory_quick_add(app: &mut App, input: &str, config: &Config) { } } +fn spawn_tui_engine(config: EngineConfig, api_config: &Config) -> EngineHandle { + let handle = spawn_engine(config, api_config); + // Prime durable agent + coordination state through the same engine event + // used by later refreshes. All TUI engine replacements use this wrapper, + // so workspace switches and provider recovery cannot retain stale Work. + let _ = handle.try_send(Op::ListSubAgents); + handle +} + fn build_engine_config(app: &App, config: &Config) -> EngineConfig { let provider = app.api_provider; let max_subagents = app.max_subagents.clamp(1, crate::config::MAX_SUBAGENTS); @@ -3600,7 +3609,7 @@ async fn run_event_loop( parent_run_id, spawn_depth, } => { - let prompt_summary = summarize_tool_output(&prompt); + let prompt_summary = bound_agent_activity_text(&prompt); execute_subagent_observer_hook( app, HookEvent::SubagentSpawn, @@ -3613,6 +3622,13 @@ async fn run_event_loop( let meta = app.agent_progress_meta.entry(id.clone()).or_default(); meta.parent_run_id = parent_run_id; meta.spawn_depth = spawn_depth; + meta.current_activity = Some(AgentCurrentActivity::bounded( + AgentCurrentActivityStatus::Starting, + Some(prompt_summary.clone()), + None, + None, + )); + meta.current_tool = None; if app.agent_activity_started_at.is_none() { app.agent_activity_started_at = Some(Instant::now()); } @@ -3625,10 +3641,13 @@ async fn run_event_loop( EngineEvent::AgentProgress { id, status, + activity, parent_run_id, spawn_depth, } => { - let display = friendly_subagent_progress(app, &id, &status); + let display = bound_agent_activity_text(&friendly_subagent_progress( + app, &id, &status, + )); if is_noisy_subagent_progress(&status) { app.agent_progress .entry(id.clone()) @@ -3639,6 +3658,18 @@ async fn run_event_loop( let meta = app.agent_progress_meta.entry(id.clone()).or_default(); meta.parent_run_id = parent_run_id; meta.spawn_depth = spawn_depth; + let current_tool = activity + .tool_name + .as_deref() + .map(subagent_progress_tool_display_name) + .map(str::to_string); + meta.current_activity = Some(AgentCurrentActivity::bounded( + activity.worker_status.into(), + Some(display.clone()), + current_tool.clone(), + activity.step, + )); + meta.current_tool = current_tool; if app.agent_activity_started_at.is_none() { app.agent_activity_started_at = Some(Instant::now()); } @@ -3695,20 +3726,19 @@ async fn run_event_loop( && matches!(agent.status, SubAgentStatus::Running) }); app.agent_progress.remove(&id); - app.agent_progress_meta.remove(&id); let terminal_status = subagent_status_from_completion_result(&result); let terminal_verb = subagent_terminal_verb(&terminal_status); apply_subagent_terminal_projection( app, &id, terminal_status.clone(), - Some(summarize_tool_output(&result)), + Some(bound_agent_activity_text(&result)), ); // #3030: stable label with raw-id fallback. let label = app.agent_display_label(&id); app.status_message = Some(format!( "{label} {terminal_verb}: {}", - summarize_tool_output(&result) + bound_agent_activity_text(&result) )); let should_recapture_terminal = !has_other_running_subagents && app.use_alt_screen; @@ -3753,11 +3783,15 @@ async fn run_event_loop( } subagent_list_refresh_requested = true; } - EngineEvent::AgentList { agents } => { + EngineEvent::AgentList { + agents, + coordination, + } => { let mut sorted = agents.clone(); sort_subagents_in_place(&mut sorted); sorted.retain(|a| !a.from_prior_session); app.subagent_cache = sorted.clone(); + apply_coordination_detail_projection(app, coordination); reconcile_subagent_activity_state(app); let view_agents = subagent_view_agents(app, &app.subagent_cache); if app.view_stack.update_subagents(&view_agents) { @@ -4049,7 +4083,7 @@ async fn run_event_loop( if let Some(rollback_warning) = respawn_after_provider_rollback { let _ = engine_handle.send(Op::Shutdown).await; let engine_config = build_engine_config(app, config); - engine_handle = spawn_engine(engine_config, config); + engine_handle = spawn_tui_engine(engine_config, config); if !app.api_messages.is_empty() { let _ = engine_handle .send(Op::SyncSession { @@ -4161,6 +4195,8 @@ async fn run_event_loop( maybe_throttled_recovery_snapshot(app, Instant::now(), &mut last_recovery_snapshot_at); let history_has_live_motion = history_has_live_motion(&app.history); let active_cell_has_live_motion = active_cell_has_live_motion(app); + let translation_placeholder_has_live_motion = app.translation_enabled + && (pending_thinking_translations > 0 || app.streaming_thinking_active_entry.is_some()); // Idle ambient motion belongs to every underwater treatment: ombre // breathes its water column, while flat and Terminal-owned animate // foreground life only. Schedule redraws only when something can @@ -4205,30 +4241,31 @@ async fn run_event_loop( has_running_agents, history_has_live_motion, active_cell_has_live_motion, + translation_placeholder_has_live_motion, ); let animation_interval_ms = animation_interval_ms( app, status_motion, underwater_ambient_motion || underwater_completion_motion, ); - let motion_policy = MotionPolicy::from_settings( - app.low_motion, - app.fancy_animations, - app.constrained_frame_rate, - ); + let motion_policy = app.motion_policy(); if (status_motion || underwater_ambient_motion || underwater_completion_motion) && last_status_frame.elapsed() >= Duration::from_millis(animation_interval_ms) { - if streaming_thinking::animate_pending_translation( + let translation_animated = streaming_thinking::animate_pending_translation( app, pending_thinking_translations > 0, - ) { - app.mark_history_updated(); - } - if motion_policy.allows_decorative() + ); + if !matches!(motion_policy.mode(), MotionMode::Still) && (history_has_live_motion || active_cell_has_live_motion) { - app.mark_history_updated(); + if translation_animated { + if history_has_live_motion { + app.mark_live_history_motion_updated(); + } + } else { + app.mark_live_motion_updated(); + } } // Coalesce decorative animation wakes through the shared requester. // Reduced/Still drop these requests; state-change redraws still set @@ -4315,11 +4352,7 @@ async fn run_event_loop( // Central motion contract: frame cap, stream catch-up, and chunking // all read from MotionPolicy so reduced motion stays semantically calm // (not a slow typewriter) and Full motion keeps the steady display clock. - let motion_policy = MotionPolicy::from_settings( - app.low_motion, - app.fancy_animations, - app.constrained_frame_rate, - ); + let motion_policy = app.motion_policy(); frame_rate_limiter.set_low_motion(motion_policy.uses_constrained_frame_rate()); app.streaming_state .set_low_motion(motion_policy.as_low_motion()); @@ -4845,8 +4878,8 @@ async fn run_event_loop( if app.view_stack.top_kind() == Some(ModalKind::Help) { app.view_stack.pop(); } else { - app.view_stack - .push(HelpView::new_for_shortcuts(app.ui_locale)); + let help = HelpView::new_for_shortcuts(app.ui_locale, &app.workspace); + app.view_stack.push(help); } continue; } @@ -6554,6 +6587,13 @@ fn clear_work_inspector_after_pager_close(app: &mut App, was_work_inspector: boo } } +fn apply_coordination_detail_projection( + app: &mut App, + projection: crate::tools::subagent::CoordinationDetailProjection, +) { + app.coordination_detail = Some(projection); +} + /// The event-loop seam for Ctrl+T. Keeping the `KeyEvent` predicate and App /// mutation together makes the real terminal route directly testable rather /// than testing `cycle_effort` in isolation. @@ -9792,7 +9832,11 @@ async fn switch_provider( let _ = engine_handle.send(Op::Shutdown).await; let engine_config = build_engine_config(app, config); - *engine_handle = spawn_engine(engine_config, config); + *engine_handle = spawn_tui_engine(engine_config, config); + // A successful in-session switch must refresh the same key-scoped live + // catalog as startup. TelecomJS is currently the only provider using this + // seam; failures preserve the existing/static rows. + crate::client::DeepSeekClient::spawn_active_provider_catalog_refresh(config); if !app.api_messages.is_empty() { let _ = engine_handle @@ -9948,7 +9992,7 @@ async fn apply_provider_fallback_switch( let _ = engine_handle.send(Op::Shutdown).await; let engine_config = build_engine_config(app, config); - *engine_handle = spawn_engine(engine_config, config); + *engine_handle = spawn_tui_engine(engine_config, config); if !app.api_messages.is_empty() { let _ = engine_handle @@ -10226,7 +10270,7 @@ async fn apply_command_result( sync_runtime_workspace_state(task_manager, app.workspace.clone()).await; if respawn { let _ = engine_handle.send(Op::Shutdown).await; - *engine_handle = spawn_engine(build_engine_config(app, config), config); + *engine_handle = spawn_tui_engine(build_engine_config(app, config), config); } else { let _ = engine_handle .send(Op::SetModel { @@ -10310,7 +10354,7 @@ async fn apply_command_result( app.update_model_compaction_budget(); if provider_changed || workspace_changed { let _ = engine_handle.send(Op::Shutdown).await; - *engine_handle = spawn_engine(build_engine_config(app, config), config); + *engine_handle = spawn_tui_engine(build_engine_config(app, config), config); } // SyncSession carries the conversation but not resolved route // limits. Refresh the engine's model first so a loaded, @@ -10352,7 +10396,7 @@ async fn apply_command_result( } AppAction::PluginRegistryChanged => { let _ = engine_handle.send(Op::Shutdown).await; - *engine_handle = spawn_engine(build_engine_config(app, config), config); + *engine_handle = spawn_tui_engine(build_engine_config(app, config), config); if !app.api_messages.is_empty() { let _ = engine_handle .send(Op::SyncSession { @@ -10738,6 +10782,14 @@ async fn apply_command_result( ); } } + AppAction::OpenSkillsManager => { + if app.view_stack.top_kind() != Some(ModalKind::SkillsManager) { + app.view_stack + .push(crate::tui::views::skills_manager::SkillsManagerView::new( + app, + )); + } + } AppAction::OpenFleetRoster => { if app.view_stack.top_kind() != Some(ModalKind::FleetRoster) { app.view_stack @@ -10925,7 +10977,7 @@ async fn apply_command_result( // Rebuild the engine with the new config so API key/model/base URL take effect. let _ = engine_handle.send(Op::Shutdown).await; let engine_config = build_engine_config(app, config); - *engine_handle = spawn_engine(engine_config, config); + *engine_handle = spawn_tui_engine(engine_config, config); if !app.api_messages.is_empty() { let _ = engine_handle .send(Op::SyncSession { @@ -11001,6 +11053,7 @@ fn spawn_external_url_command(mut command: Command) -> Result<()> { fn apply_workspace_runtime_state(app: &mut App, config: &Config, workspace: PathBuf) { app.workspace = workspace.clone(); + app.coordination_detail = None; app.plugin_registry = app.plugin_registry.rediscover_for_workspace(&workspace); app.active_skill = None; app.active_skill_provenance = None; @@ -11053,7 +11106,7 @@ async fn switch_workspace( let _ = engine_handle.send(Op::Shutdown).await; let engine_config = build_engine_config(app, config); - *engine_handle = spawn_engine(engine_config, config); + *engine_handle = spawn_tui_engine(engine_config, config); if !app.api_messages.is_empty() { let _ = engine_handle .send(Op::SyncSession { @@ -11914,6 +11967,13 @@ fn build_pending_input_preview(app: &App) -> PendingInputPreview { preview } +fn classic_header_indicator_started_at(app: &App) -> Option { + app.motion_policy() + .allows_status_spin() + .then_some(app.turn_started_at) + .flatten() +} + fn render_classic_header(area: Rect, buf: &mut Buffer, app: &App) { let context_usage = context_usage_snapshot(app); let context_window = context_usage.as_ref().map(|(_, max, _)| *max).or_else(|| { @@ -11934,7 +11994,7 @@ fn render_classic_header(area: Rect, buf: &mut Buffer, app: &App) { .unwrap_or("workspace"); let model = app.model_display_label(); let effort = app.reasoning_effort_display_label(); - let started_at = (!app.low_motion).then_some(app.turn_started_at).flatten(); + let started_at = classic_header_indicator_started_at(app); let data = HeaderData::new( app.mode, &model, @@ -12674,6 +12734,122 @@ fn refresh_config_view_if_open(app: &mut App, focus_key: &str) { } } +fn refresh_skills_manager_if_open( + app: &mut App, + status: Option, + focus: Option<&crate::skills::audit::AuditedSkillId>, +) { + if app.view_stack.top_kind() != Some(ModalKind::SkillsManager) { + return; + } + let Some(mut boxed) = app.view_stack.pop() else { + return; + }; + let rebuilt = if let Some(prev) = boxed + .as_any_mut() + .downcast_mut::( + ) { + crate::tui::views::skills_manager::SkillsManagerView::rebuild_preserving( + app, prev, status, focus, + ) + } else { + crate::tui::views::skills_manager::SkillsManagerView::new(app) + }; + app.view_stack.push(rebuilt); +} + +async fn handle_skill_mutation_requested( + app: &mut App, + request: crate::skills::mutation::SkillMutationRequest, +) { + use crate::skills::install::{DEFAULT_MAX_SIZE_BYTES, DEFAULT_REGISTRY_URL}; + use crate::skills::mutation::{MutationContext, SkillMutationOutcome, SkillMutationRequest}; + + let focus = match &request { + SkillMutationRequest::ImportExternal { source_id, .. } => Some(source_id.clone()), + SkillMutationRequest::Update { skill_id, .. } + | SkillMutationRequest::Remove { skill_id, .. } + | SkillMutationRequest::Trust { skill_id, .. } => Some(skill_id.clone()), + SkillMutationRequest::InstallRemote { .. } + | SkillMutationRequest::UpdateByName { .. } + | SkillMutationRequest::RemoveByName { .. } + | SkillMutationRequest::TrustByName { .. } => None, + }; + + let workspace = app.workspace.clone(); + let home = dirs::home_dir(); + let cfg = crate::config::Config::load(None, None).unwrap_or_default(); + let network = cfg + .network + .clone() + .map(|policy| policy.into_runtime()) + .unwrap_or_default(); + let skills_cfg = cfg.skills.as_ref(); + let max_size = skills_cfg + .and_then(|s| s.max_install_size_bytes) + .unwrap_or(DEFAULT_MAX_SIZE_BYTES); + let registry_url = skills_cfg + .and_then(|s| s.registry_url.clone()) + .unwrap_or_else(|| DEFAULT_REGISTRY_URL.to_string()); + + let skills_dir = app.skills_dir.clone(); + let result = { + let ctx = MutationContext { + workspace: &workspace, + home: home.as_deref(), + configured_skills_dir: Some(skills_dir.as_path()), + network: &network, + max_size, + registry_url: ®istry_url, + }; + crate::skills::mutation::execute(request, &ctx).await + }; + + let (status, refresh_skills) = match result { + Ok(receipt) => { + let msg = match &receipt.outcome { + SkillMutationOutcome::Installed => { + format!( + "Installed '{}' → {}", + receipt.name, receipt.safe_target_path + ) + } + SkillMutationOutcome::Updated => format!("Updated '{}'", receipt.name), + SkillMutationOutcome::NoChange => { + format!("'{}': no upstream change", receipt.name) + } + SkillMutationOutcome::Removed => format!("Removed '{}'", receipt.name), + SkillMutationOutcome::Trusted => format!("Trusted '{}'", receipt.name), + SkillMutationOutcome::Imported => { + format!("Imported '{}' → {}", receipt.name, receipt.safe_target_path) + } + SkillMutationOutcome::AlreadyPresent => { + format!("'{}' already present (exact duplicate)", receipt.name) + } + SkillMutationOutcome::NeedsApproval(host) => { + format!("Needs network approval for {host}") + } + SkillMutationOutcome::NetworkDenied(host) => { + format!("Network denied for {host}") + } + }; + let refresh = !matches!( + receipt.outcome, + SkillMutationOutcome::NeedsApproval(_) | SkillMutationOutcome::NetworkDenied(_) + ); + (msg, refresh) + } + Err(err) => (format!("Skill mutation failed: {err:#}"), false), + }; + + app.status_message = Some(status.clone()); + if refresh_skills { + app.refresh_skill_cache(); + } + refresh_skills_manager_if_open(app, Some(status), focus.as_ref()); + app.needs_redraw = true; +} + #[allow(clippy::too_many_arguments)] async fn handle_config_updated( terminal: &mut AppTerminal, @@ -12922,7 +13098,8 @@ async fn handle_view_events( sync_runtime_workspace_state(task_manager, app.workspace.clone()).await; if respawn { let _ = engine_handle.send(Op::Shutdown).await; - *engine_handle = spawn_engine(build_engine_config(app, config), config); + *engine_handle = + spawn_tui_engine(build_engine_config(app, config), config); } else { let _ = engine_handle .send(Op::SetModel { @@ -13339,6 +13516,15 @@ async fn handle_view_events( app.status_message = Some(format!("Could not cancel {agent_id}")); } } + ViewEvent::OpenAgentTranscript { agent_id } => { + if !crate::tui::mouse_ui::open_agent_chat_pager(app, &agent_id) { + app.status_message = Some("Exact agent transcript is unavailable".to_string()); + } + app.needs_redraw = true; + } + ViewEvent::AgentDetailsClosed { agent_id } => { + crate::tui::work_surface::agent_details_closed(app, &agent_id); + } ViewEvent::FilePickerSelected { path } => { // Insert `@` at the composer's cursor with surrounding // whitespace so the existing `@`-mention parser picks it up. @@ -13592,6 +13778,22 @@ async fn handle_view_events( } } ViewEvent::ContextMenuSelected { action } => handle_context_menu_action(app, action), + ViewEvent::SkillMutationRequested { request } => { + handle_skill_mutation_requested(app, request).await; + } + ViewEvent::SkillsManagerToggleCompatible => { + if app.view_stack.top_kind() == Some(ModalKind::SkillsManager) + && let Some(mut boxed) = app.view_stack.pop() + { + if let Some(view) = boxed + .as_any_mut() + .downcast_mut::( + ) { + crate::tui::views::skills_manager::apply_toggle_compatible(view, app); + } + app.view_stack.push_boxed(boxed); + } + } } } @@ -13666,10 +13868,6 @@ fn push_approval_request_view( approval_key: &str, intent_summary: Option<&str>, ) { - if tool_name == "apply_patch" { - maybe_add_patch_preview(app, tool_input); - } - let request = ApprovalRequest::new_with_intent( id, tool_name, @@ -14490,6 +14688,7 @@ fn mirror_saved_api_key_in_config(config: &mut Config, provider: ApiProvider, ap ApiProvider::OpencodeGo => &mut providers.opencode_go, ApiProvider::Meta => &mut providers.meta, ApiProvider::Xai => &mut providers.xai, + ApiProvider::Telecomjs => &mut providers.telecomjs, }; if pin_kimi_code_base_url { entry.base_url = Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()); @@ -15619,7 +15818,7 @@ fn should_auto_compact_before_send_with_config( } fn status_animation_interval_ms(app: &App) -> u64 { - if app.low_motion { + if app.effective_low_motion_for_status() { 2_400 } else { UI_STATUS_ANIMATION_MS @@ -15715,41 +15914,38 @@ fn should_tick_status_animation( has_running_agents: bool, history_has_live_motion: bool, active_cell_has_live_motion: bool, + translation_placeholder_has_live_motion: bool, ) -> bool { - app.is_loading - || has_running_agents - || app.is_compacting - || app.is_purging - || history_has_live_motion - || active_cell_has_live_motion + !matches!(app.motion_policy().mode(), MotionMode::Still) + && (app.is_loading + || has_running_agents + || app.is_compacting + || app.is_purging + || history_has_live_motion + || active_cell_has_live_motion + || translation_placeholder_has_live_motion + || visible_background_task_has_live_motion(app)) +} + +fn visible_background_task_has_live_motion(app: &App) -> bool { + matches!( + app.sidebar_focus, + SidebarFocus::Auto | SidebarFocus::Pinned | SidebarFocus::Tasks + ) && app + .last_sidebar_area + .or(app.viewport.last_sidebar_area) + .is_some() + && app.task_panel.iter().any(|task| task.status == "running") } fn active_cell_has_live_motion(app: &App) -> bool { - app.active_cell.as_ref().is_some_and(|active| { - active.entries().iter().any(|cell| match cell { - HistoryCell::Thinking { streaming, .. } => *streaming, - HistoryCell::Tool(tool) => tool_cell_is_running(tool), - _ => false, - }) - }) + app.active_cell + .as_ref() + .is_some_and(|active| active.entries().iter().any(HistoryCell::has_live_motion)) } fn history_has_live_motion(history: &[HistoryCell]) -> bool { - use crate::tui::history::SubAgentCell; - use crate::tui::widgets::agent_card::AgentLifecycle; - history.iter().any(|cell| match cell { - HistoryCell::Thinking { streaming, .. } => *streaming, - HistoryCell::Tool(tool) => tool_cell_is_running(tool), - HistoryCell::SubAgent(SubAgentCell::Delegate(card)) => matches!( - card.status, - AgentLifecycle::Pending | AgentLifecycle::Running - ), - HistoryCell::SubAgent(SubAgentCell::Fanout(card)) => card - .workers - .iter() - .any(|w| matches!(w.status, AgentLifecycle::Pending | AgentLifecycle::Running)), - _ => false, - }) + history.iter().any(HistoryCell::has_live_motion) } pub(crate) fn open_pager_for_selection(app: &mut App) -> bool { diff --git a/crates/tui/src/tui/ui/activity_detail.rs b/crates/tui/src/tui/ui/activity_detail.rs index c741a30a16..e3255a5633 100644 --- a/crates/tui/src/tui/ui/activity_detail.rs +++ b/crates/tui/src/tui/ui/activity_detail.rs @@ -556,30 +556,92 @@ pub(super) fn open_tool_details_pager(app: &mut App) -> bool { } /// Build the trailing "Spillover" section for the tool-details pager -/// (#500). Returns `None` when the cell at `cell_index` is not a -/// `GenericToolCell` with a recorded spillover path, or when the -/// spillover file is missing or unreadable. Failures fall back to a -/// short notice in the section so the user understands why the full -/// content can't be loaded — better than silent truncation. +/// (#500). Session artifact records are authoritative for every tool family +/// (including specialized Bash and MCP cells); the historical generic-cell +/// path is only a UI compatibility fallback. The pager deliberately keeps the +/// backing path and operating-system error private: a detail surface may be +/// captured or shared, and neither is useful evidence for the user. pub(super) fn spillover_pager_section(app: &App, cell_index: usize) -> Option { use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell}; let cell = app.cell_at_virtual_index(cell_index)?; - let HistoryCell::Tool(ToolCell::Generic(GenericToolCell { - spillover_path: Some(path), - .. - })) = cell - else { - return None; - }; - let path_str = path.display().to_string(); - let body = match std::fs::read_to_string(path) { - Ok(text) => text, - Err(err) => format!("(could not read spillover file: {err})"), + let current_session = app.current_session_id.as_deref(); + let session_artifact = app + .tool_detail_record_for_cell(cell_index) + .and_then(|detail| { + app.session_artifacts.iter().find(|artifact| { + artifact.kind == crate::artifacts::ArtifactKind::ToolOutput + && artifact.tool_call_id == detail.tool_id + && current_session == Some(artifact.session_id.as_str()) + }) + }); + let legacy_path = match cell { + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + spillover_path: Some(path), + .. + })) => Some(path.clone()), + _ => None, }; - Some(format!( - "── Full output (spillover) ──\nFile: {path_str}\n\n{body}" - )) + if session_artifact.is_none() && legacy_path.is_none() { + return None; + } + let body = session_artifact + .and_then(read_owned_session_artifact) + .or_else(|| { + legacy_path.as_deref().and_then(|path| { + current_session.and_then(|session_id| read_owned_legacy_spillover(path, session_id)) + }) + }) + .unwrap_or_else(|| "(retained output is unavailable)".to_string()); + Some(format!("── Full output (spillover) ──\n\n{body}")) +} + +fn read_owned_session_artifact(artifact: &crate::artifacts::ArtifactRecord) -> Option { + if artifact.storage_path.is_absolute() { + return None; + } + let root = crate::artifacts::session_artifact_absolute_path( + &artifact.session_id, + std::path::Path::new(crate::artifacts::ARTIFACTS_DIR_NAME), + )?; + let candidate = crate::artifacts::session_artifact_absolute_path( + &artifact.session_id, + &artifact.storage_path, + )?; + let path = canonical_owned_file(&candidate, &root)?; + std::fs::read_to_string(path).ok() +} + +fn read_owned_legacy_spillover(path: &std::path::Path, session_id: &str) -> Option { + let root = crate::tools::truncate::spillover_root()?; + let path = canonical_owned_file(path, &root)?; + let ownership = crate::tools::truncate::read_legacy_spillover_ownership(&path).ok()?; + if ownership.origin_session != session_id { + return None; + } + let bytes = std::fs::read(path).ok()?; + if ownership.size_bytes != u64::try_from(bytes.len()).unwrap_or(u64::MAX) + || ownership.digest != crate::hashing::sha256_hex(&bytes) + { + return None; + } + String::from_utf8(bytes).ok() +} + +fn canonical_owned_file( + candidate: &std::path::Path, + root: &std::path::Path, +) -> Option { + if std::fs::symlink_metadata(candidate) + .ok()? + .file_type() + .is_symlink() + { + return None; + } + let root = root.canonicalize().ok()?; + let candidate = candidate.canonicalize().ok()?; + (candidate.is_file() && candidate.starts_with(root)).then_some(candidate) } pub(crate) fn open_details_pager_for_cell(app: &mut App, cell_index: usize) -> bool { @@ -597,15 +659,27 @@ pub(crate) fn open_details_pager_for_cell(app: &mut App, cell_index: usize) -> b // stays above as `Output:` so the user can compare what the // model received against the full payload. let spillover_section = spillover_pager_section(app, cell_index); + let mutation_section = match app.cell_at_virtual_index(cell_index) { + Some(HistoryCell::Tool(ToolCell::PatchSummary(cell))) => cell + .receipt + .as_ref() + .map(|receipt| format!("── Exact File change ──\n{}", receipt.inspect_text())), + _ => None, + }; // Frame the body as leaf-level raw detail for the selected item. The // Tool ID / Input / Output / spillover content below is unchanged — only // the leading intro line is new, so existing raw-output visibility is // preserved (#4105). - let content = if let Some(section) = spillover_section { + let trailing_sections = [mutation_section, spillover_section] + .into_iter() + .flatten() + .collect::>() + .join("\n\n"); + let content = if !trailing_sections.is_empty() { format!( "{RAW_DETAIL_PAGER_INTRO}\n\nTool ID: {}\nTool: {}\n\nInput:\n{}\n\nOutput:\n{}\n\n{}", - detail.tool_id, detail.tool_name, input, output, section + detail.tool_id, detail.tool_name, input, output, trailing_sections ) } else { format!( diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 4ee515995b..fa91d825ab 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -109,6 +109,341 @@ fn underwater_motion_keeps_its_smoother_cadence_during_live_status() { ); } +#[test] +fn canonical_bash_actions_use_exec_cells_without_rewriting_detail_identity() { + let cases = [ + ( + "run", + serde_json::json!({"action": "run", "command": "pwd"}), + ), + ( + "wait", + serde_json::json!({"action": "wait", "task_id": "shell-1"}), + ), + ( + "interact", + serde_json::json!({"action": "interact", "task_id": "shell-1", "stdin": "y\n"}), + ), + ( + "cancel", + serde_json::json!({"action": "cancel", "task_id": "shell-1"}), + ), + ]; + + for (action, input) in cases { + let mut app = create_test_app(); + let id = format!("bash-{action}"); + handle_tool_call_started(&mut app, &id, "Bash", &input); + let active = app.active_cell.as_ref().expect("active cell"); + let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else { + panic!("Bash.{action} must use ExecCell") + }; + assert_eq!(exec.interaction.is_some(), action != "run", "Bash.{action}"); + assert_eq!(app.active_tool_details[&id].tool_name, "Bash"); + assert_eq!(app.active_tool_details[&id].input, input); + } + + let mut legacy = create_test_app(); + handle_tool_call_started( + &mut legacy, + "legacy-cancel", + "exec_shell_cancel", + &serde_json::json!({"task_id": "shell-legacy"}), + ); + let active = legacy.active_cell.as_ref().expect("active cell"); + assert!(matches!( + active.entries()[0], + HistoryCell::Tool(ToolCell::Exec(_)) + )); + assert_eq!( + legacy.active_tool_details["legacy-cancel"].tool_name, + "exec_shell_cancel" + ); +} + +#[test] +fn canonical_background_bash_keeps_live_task_identity_for_follow_up_actions() { + let mut app = create_test_app(); + handle_tool_call_started( + &mut app, + "bash-background", + "Bash", + &serde_json::json!({ + "action": "run", + "command": "cargo test --workspace", + "background": true + }), + ); + let running = Ok( + crate::tools::spec::ToolResult::success("Background task started: shell-42").with_metadata( + serde_json::json!({ + "status": "Running", + "task_id": "shell-42", + "command": "cargo test --workspace", + "duration_ms": 25_u64 + }), + ), + ); + handle_tool_call_complete(&mut app, "bash-background", "Bash", &running); + + let active = app.active_cell.as_ref().expect("active cell"); + let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else { + panic!("canonical background Bash must remain an ExecCell") + }; + assert_eq!(exec.status, ToolStatus::Running); + assert_eq!(exec.shell_task_id.as_deref(), Some("shell-42")); + assert_eq!(exec.command, "cargo test --workspace"); + assert!(exec.output.is_none()); + assert!( + exec.live_output + .as_deref() + .is_some_and(|output| { output.contains("Background task started: shell-42") }) + ); +} + +#[test] +fn canonical_file_actions_split_reads_searches_and_mutations_truthfully() { + let cases = [ + ("read", "exploring"), + ("list", "exploring"), + ("search_name", "file_search"), + ("search_content", "exploring"), + ("write", "patch_summary"), + ("edit", "patch_summary"), + ("patch", "patch_summary"), + ]; + + for (action, expected) in cases { + let mut app = create_test_app(); + let id = format!("file-{action}"); + let input = match action { + "read" | "list" => serde_json::json!({"action": action, "path": "src"}), + "search_name" => serde_json::json!({"action": action, "query": "lib.rs"}), + "search_content" => { + serde_json::json!({"action": action, "pattern": "whale", "path": "src"}) + } + "write" => serde_json::json!({ + "action": action, + "path": "src/new.rs", + "content": "pub fn new() {}\n" + }), + "edit" => serde_json::json!({ + "action": action, + "path": "src/lib.rs", + "search": "old", + "replace": "new" + }), + "patch" => serde_json::json!({ + "action": action, + "patch": "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1,1 +1,1 @@\n-old\n+new\n" + }), + _ => unreachable!(), + }; + handle_tool_call_started(&mut app, &id, "File", &input); + let active = app.active_cell.as_ref().expect("active cell"); + let tool = match &active.entries()[0] { + HistoryCell::Tool(tool) => tool, + other => panic!("expected tool cell, got {other:?}"), + }; + match expected { + "exploring" => assert!(matches!(tool, ToolCell::Exploring(_)), "File.{action}"), + "patch_summary" => { + assert!(matches!(tool, ToolCell::PatchSummary(_)), "File.{action}") + } + semantic_name => { + let ToolCell::Generic(generic) = tool else { + panic!("File.{action} must use GenericToolCell") + }; + assert_eq!(generic.name, semantic_name, "File.{action}"); + } + } + assert_eq!(app.active_tool_details[&id].tool_name, "File"); + } +} + +#[test] +fn canonical_file_mutations_attach_receipts_only_to_successful_outcomes() { + for approval_mode in [ + ApprovalMode::Suggest, + ApprovalMode::Auto, + ApprovalMode::Bypass, + ] { + for action in ["write", "edit", "patch"] { + let mut app = create_test_app(); + app.approval_mode = approval_mode; + let id = format!("file-{approval_mode:?}-{action}-success"); + let input = if action == "patch" { + serde_json::json!({ + "action": action, + "patch": "--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1 +1 @@\n-old\n+new\n" + }) + } else { + serde_json::json!({"action": action, "path": "src/lib.rs"}) + }; + handle_tool_call_started(&mut app, &id, "File", &input); + handle_tool_call_complete( + &mut app, + &id, + "File", + &Ok(crate::tools::spec::ToolResult::success("ok").with_metadata( + serde_json::json!({ + "mutation": { + "diff": "--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1 +1 @@\n-old\n+new\n", + "files": [{ "path": "src/lib.rs", "outcome": "updated" }], + "renames": [] + } + }), + )), + ); + + let active = app.active_cell.as_ref().expect("active cell"); + let HistoryCell::Tool(ToolCell::PatchSummary(cell)) = &active.entries()[0] else { + panic!("File.{action} must stay on the calm File receipt path") + }; + assert_eq!( + cell.status, + ToolStatus::Success, + "{approval_mode:?} File.{action}" + ); + assert!(cell.receipt.is_some(), "{approval_mode:?} File.{action}"); + assert_eq!( + cell.receipt.as_ref().unwrap().outcome_label(), + "Updated src/lib.rs", + "{approval_mode:?} File.{action}" + ); + } + } + + let mut failed = create_test_app(); + handle_tool_call_started( + &mut failed, + "file-write-failed", + "File", + &serde_json::json!({"action": "write", "path": "src/lib.rs"}), + ); + handle_tool_call_complete( + &mut failed, + "file-write-failed", + "File", + &Ok( + crate::tools::spec::ToolResult::error("cancelled before mutation").with_metadata( + serde_json::json!({ + "mutation": { + "diff": "--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1 +1 @@\n-old\n+FORGED\n", + "files": [{ "path": "src/lib.rs", "outcome": "updated" }], + "renames": [] + } + }), + ), + ), + ); + let active = failed.active_cell.as_ref().expect("active cell"); + let HistoryCell::Tool(ToolCell::PatchSummary(cell)) = &active.entries()[0] else { + panic!("failed File.write must remain a File receipt cell") + }; + assert_eq!(cell.status, ToolStatus::Failed); + assert!(cell.receipt.is_none()); + assert_eq!(cell.error.as_deref(), Some("cancelled before mutation")); +} + +#[test] +fn canonical_git_run_and_web_actions_use_semantic_history_cells() { + for (family, action, expected) in [ + ("Git", "status", "git_status"), + ("Git", "diff", "git_diff"), + ("Git", "log", "git_log"), + ("Git", "show", "git_show"), + ("Git", "blame", "git_blame"), + ("Run", "tests", "run_tests"), + ("Run", "verifiers", "run_verifiers"), + ("Web", "fetch", "fetch_url"), + ("Web", "wait", "wait_for_dev_server"), + ] { + let mut app = create_test_app(); + let id = format!("{family}-{action}"); + handle_tool_call_started( + &mut app, + &id, + family, + &serde_json::json!({"action": action}), + ); + let active = app.active_cell.as_ref().expect("active cell"); + let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else { + panic!("{family}.{action} must use GenericToolCell") + }; + assert_eq!(generic.name, expected, "{family}.{action}"); + assert_eq!(app.active_tool_details[&id].tool_name, family); + } + + let mut app = create_test_app(); + handle_tool_call_started( + &mut app, + "Web-search", + "Web", + &serde_json::json!({"action": "search", "query": "Codewhale"}), + ); + let active = app.active_cell.as_ref().expect("active cell"); + assert!(matches!( + active.entries()[0], + HistoryCell::Tool(ToolCell::WebSearch(_)) + )); + assert_eq!(app.active_tool_details["Web-search"].tool_name, "Web"); +} + +#[test] +fn canonical_completion_refreshes_workspace_only_for_semantic_mutations() { + let stale = Instant::now() - Duration::from_secs(60); + + let mut reading = create_test_app(); + reading.workspace_context_refreshed_at = Some(stale); + handle_tool_call_started( + &mut reading, + "file-read", + "File", + &serde_json::json!({"action": "read", "path": "src/lib.rs"}), + ); + handle_tool_call_complete(&mut reading, "file-read", "File", &ok_result("contents")); + assert_eq!(reading.workspace_context_refreshed_at, Some(stale)); + + for (action, input) in [ + ( + "write", + serde_json::json!({ + "action": "write", + "path": "src/new.rs", + "content": "new\n" + }), + ), + ( + "edit", + serde_json::json!({ + "action": "edit", + "path": "src/lib.rs", + "search": "old", + "replace": "new" + }), + ), + ( + "patch", + serde_json::json!({ + "action": "patch", + "patch": "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1,1 +1,1 @@\n-old\n+new\n" + }), + ), + ] { + let mut app = create_test_app(); + app.workspace_context_refreshed_at = Some(stale); + let id = format!("file-{action}"); + handle_tool_call_started(&mut app, &id, "File", &input); + handle_tool_call_complete(&mut app, &id, "File", &ok_result("ok")); + assert!( + app.workspace_context_refreshed_at + .is_some_and(|when| when > stale), + "File.{action} must refresh the workspace badge" + ); + } +} + #[test] fn underwater_motion_ticks_only_for_visible_unobscured_owners() { assert!(!underwater_motion_surface_visible(None, true, true, false)); @@ -267,6 +602,47 @@ fn closing_work_inspector_pager_clears_opened_row_owner() { assert!(app.work_surface.opened.is_none()); } +#[test] +fn coordination_event_projection_is_retained_as_typed_app_state() { + use crate::tools::subagent::CoordinationDetailProjection; + use crate::tools::subagent::coord::{ + CoordinationDetailMetrics, DecisionRecord, DecisionStatus, + }; + + let mut app = create_test_app(); + let projection = CoordinationDetailProjection { + schema_version: 1, + sequence: 4, + decisions: vec![DecisionRecord { + decision_id: "decision-app".to_string(), + subject: "typed app state".to_string(), + status: DecisionStatus::Accepted, + owner: "root".to_string(), + scope: Vec::new(), + constraints: Vec::new(), + evidence_handles: Vec::new(), + version: 2, + sequence: 4, + }], + write_claims: Vec::new(), + reconciliations: Vec::new(), + context_projections: Vec::new(), + contentions: Vec::new(), + metrics: CoordinationDetailMetrics { + hottest_paths: Vec::new(), + package_or_module_growth: None, + route_or_cost: None, + note: "No authoritative metric source".to_string(), + }, + bounded: true, + limit: 24, + }; + + apply_coordination_detail_projection(&mut app, projection.clone()); + + assert_eq!(app.coordination_detail, Some(projection)); +} + #[test] fn approval_mouse_wheel_reviews_transcript_without_closing_card() { let mut app = create_test_app(); @@ -2758,6 +3134,21 @@ async fn session_denied_cache_notice_preserves_parallel_tool_indices() { app.history.get(history_len + 2), Some(HistoryCell::System { content }) if content.contains("Auto-denied") )); + let first_detail = app + .tool_detail_record_for_cell(history_len) + .expect("first completed tool keeps its own raw detail record"); + assert_eq!(first_detail.tool_id, "tool-first"); + assert_eq!(first_detail.output.as_deref(), Some("first output")); + let denied_detail = app + .tool_detail_record_for_cell(history_len + 1) + .expect("second completed tool keeps its own raw detail record"); + assert_eq!(denied_detail.tool_id, "tool-denied"); + assert!( + denied_detail + .output + .as_deref() + .is_some_and(|output| output.contains("cached approval")) + ); } #[tokio::test] @@ -3595,25 +3986,7 @@ fn setup_presets_cannot_override_managed_runtime_requirements() { } #[tokio::test] -// This test intentionally pins the process-global spillover root until the -// async receipt path finishes. -#[allow(clippy::await_holding_lock)] -async fn tool_result_api_content_receipts_large_live_output() { - let _guard = crate::tools::truncate::TEST_SPILLOVER_GUARD - .lock() - .unwrap_or_else(|err| err.into_inner()); - let tmp = TempDir::new().expect("spillover tempdir"); - let prior = crate::tools::truncate::set_test_spillover_root(Some( - tmp.path().join(".deepseek").join("tool_outputs"), - )); - struct Restore(Option); - impl Drop for Restore { - fn drop(&mut self) { - crate::tools::truncate::set_test_spillover_root(self.0.take()); - } - } - let _restore = Restore(prior); - +async fn tool_result_api_content_never_advertises_unowned_live_output_as_retrievable() { let mut app = App::new(create_test_options(), &Config::default()); app.api_messages.push(Message { role: "assistant".to_string(), @@ -3633,8 +4006,10 @@ async fn tool_result_api_content_receipts_large_live_output() { assert!(content.contains("[TOOL_OUTPUT_RECEIPT]")); assert!(content.contains("tool: exec_shell")); assert!(content.contains("tool_call_id: call-live-big")); - assert!(content.contains("detail_handle: sha:")); - assert!(content.contains("retrieve: retrieve_tool_result ref=sha:")); + assert!(content.contains("detail_handle: unavailable (sha256:")); + assert!(content.contains("retrieve: unavailable")); + assert!(content.contains("storage: no session-owned artifact was recorded")); + assert!(!content.contains("retrieve_tool_result")); assert!(!content.contains(&raw)); assert!( content.chars().count() @@ -3800,6 +4175,48 @@ fn apply_loaded_session_does_not_restore_slash_command_tail_as_retry_draft() { ); } +#[test] +fn apply_loaded_session_hides_turn_metadata_without_mutating_api_history() { + let turn_meta = concat!( + "\n", + "Current local date: 2026-07-22\n", + "Input provenance: external_user\n", + "Input authority: external_current_turn\n", + "", + ); + let user_message = Message { + role: "user".to_string(), + content: vec![ + ContentBlock::Text { + text: "Keep the restored transcript calm".to_string(), + cache_control: None, + }, + ContentBlock::Text { + text: turn_meta.to_string(), + cache_control: None, + }, + ], + }; + let session = saved_session_with_messages(vec![ + user_message, + text_message("assistant", "The wire context stays complete."), + ]); + let mut app = create_test_app(); + + apply_loaded_session(&mut app, &mut Config::default(), &session).expect("restore session"); + + assert!(matches!( + app.history.as_slice(), + [HistoryCell::User { content }, HistoryCell::Assistant { .. }] + if content == "Keep the restored transcript calm" + )); + assert!(matches!( + app.api_messages[0].content.as_slice(), + [ContentBlock::Text { text: visible, .. }, ContentBlock::Text { text: meta, .. }] + if visible == "Keep the restored transcript calm" && meta == turn_meta + )); +} + #[test] fn apply_loaded_session_projects_subagent_handoff_without_retry_draft_or_user_cell() { let mut app = create_test_app(); @@ -9464,6 +9881,46 @@ fn apply_slash_menu_selection_appends_space_for_arg_commands() { assert_eq!(app.input, "/model "); } +#[test] +fn apply_slash_menu_selection_honors_user_argument_metadata_and_builtin_override() { + let tmp = TempDir::new().expect("tempdir"); + let commands_dir = tmp.path().join(".codewhale").join("commands"); + std::fs::create_dir_all(&commands_dir).expect("create commands dir"); + std::fs::write( + commands_dir.join("custom-model.md"), + "---\nname: model\n---\ncustom model", + ) + .expect("write no-argument builtin override"); + std::fs::write( + commands_dir.join("inspect.md"), + "---\narguments: \n---\ninspect", + ) + .expect("write argument command"); + let mut app = create_test_app(); + app.workspace = tmp.path().to_path_buf(); + let entries = vec![ + crate::tui::widgets::SlashMenuEntry { + name: "/model".to_string(), + description: String::new(), + is_skill: false, + alias_hint: None, + }, + crate::tui::widgets::SlashMenuEntry { + name: "/inspect".to_string(), + description: String::new(), + is_skill: false, + alias_hint: None, + }, + ]; + + assert!(apply_slash_menu_selection(&mut app, &entries, true)); + assert_eq!(app.input, "/model"); + + app.slash_menu_selected = 1; + assert!(apply_slash_menu_selection(&mut app, &entries, true)); + assert_eq!(app.input, "/inspect "); +} + #[test] fn apply_slash_menu_selection_keeps_change_executable_without_version() { let mut app = create_test_app(); @@ -10849,6 +11306,49 @@ fn tool_details_pager_frames_leaf_scope_and_preserves_raw_content() { assert!(body.contains("\"command\": \"ls\""), "{body}"); } +#[test] +fn tool_details_pager_keeps_exact_file_change_when_inline_diffs_are_off() { + let mut app = create_test_app(); + app.inline_diff_mode = crate::settings::InlineDiffMode::Off; + let tool_result = crate::tools::spec::ToolResult::success("structured success").with_metadata( + serde_json::json!({ + "mutation": { + "diff": "--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1 +1 @@\n-old\n+EXACT-SENTINEL\n", + "files": [{ "path": "src/lib.rs", "outcome": "updated" }], + "renames": [] + } + }), + ); + let receipt = + crate::tui::history::FileMutationReceipt::from_success(&app.workspace, &tool_result) + .expect("receipt"); + app.history = vec![HistoryCell::Tool(ToolCell::PatchSummary( + crate::tui::history::PatchSummaryCell { + path: "src/lib.rs".to_string(), + summary: "updated one file".to_string(), + status: ToolStatus::Success, + error: None, + receipt: Some(receipt), + }, + ))]; + app.tool_details_by_cell.insert( + 0, + ToolDetailRecord { + tool_id: "file-1".to_string(), + tool_name: "File".to_string(), + input: serde_json::json!({"action": "edit", "path": "src/lib.rs"}), + output: Some("structured success".to_string()), + }, + ); + app.resync_history_revisions(); + + assert!(open_details_pager_for_cell(&mut app, 0)); + let body = pop_pager_body(&mut app); + assert!(body.contains("── Exact File change ──"), "{body}"); + assert!(body.contains("+EXACT-SENTINEL"), "{body}"); + assert!(body.contains("Updated src/lib.rs · +1 -1"), "{body}"); +} + #[test] fn tool_details_empty_state_points_to_turn_inspector() { let mut app = create_test_app(); @@ -10881,13 +11381,27 @@ fn spillover_pager_section_returns_none_when_no_spillover() { #[test] fn spillover_pager_section_loads_file_when_present() { - use std::io::Write; + let _spillover_guard = crate::tools::truncate::TEST_SPILLOVER_GUARD + .lock() + .unwrap_or_else(|error| error.into_inner()); let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("call-test.txt"); - let mut f = std::fs::File::create(&path).unwrap(); - writeln!(f, "FULL_OUTPUT_BYTES_HERE").unwrap(); + let prior = + crate::tools::truncate::set_test_spillover_root(Some(dir.path().join("tool_outputs"))); + struct RestoreSpilloverRoot(Option); + impl Drop for RestoreSpilloverRoot { + fn drop(&mut self) { + crate::tools::truncate::set_test_spillover_root(self.0.take()); + } + } + let _restore = RestoreSpilloverRoot(prior); + let session_id = "session-pager"; + let body = "FULL_OUTPUT_BYTES_HERE\n"; + let path = crate::tools::truncate::write_spillover("call-test", body).unwrap(); + crate::tools::truncate::publish_legacy_spillover_ownership(&path, session_id, body.as_bytes()) + .unwrap(); let mut app = create_test_app(); + app.current_session_id = Some(session_id.to_string()); app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell { name: "exec_shell".to_string(), status: ToolStatus::Success, @@ -10906,13 +11420,16 @@ fn spillover_pager_section_loads_file_when_present() { section.contains("FULL_OUTPUT_BYTES_HERE"), "section missing file body: {section}" ); - assert!(section.contains(&path.display().to_string())); + assert!( + !section.contains(&path.display().to_string()), + "pager must not expose the artifact path: {section}" + ); } #[test] fn spillover_pager_section_returns_notice_when_file_missing() { let mut app = create_test_app(); - let bogus = std::path::PathBuf::from("/tmp/this/path/does/not/exist-spill.txt"); + let bogus = std::path::PathBuf::from("/private/sensitive/session/does-not-exist.txt"); app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell { name: "exec_shell".to_string(), status: ToolStatus::Success, @@ -10926,7 +11443,90 @@ fn spillover_pager_section_returns_notice_when_file_missing() { app.resync_history_revisions(); let section = spillover_pager_section(&app, 0).expect("still emits a notice section"); - assert!(section.contains("could not read spillover file")); + assert!(section.contains("retained output is unavailable")); + assert!(!section.contains("/private/sensitive")); + assert!(!section.contains("No such file")); +} + +#[test] +fn spillover_pager_uses_session_artifact_for_specialized_bash_cell() { + let _artifact_guard = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD + .lock() + .unwrap_or_else(|error| error.into_inner()); + let dir = tempfile::tempdir().unwrap(); + let prior = + crate::artifacts::set_test_artifact_sessions_root(Some(dir.path().join("sessions"))); + struct RestoreArtifactRoot(Option); + impl Drop for RestoreArtifactRoot { + fn drop(&mut self) { + crate::artifacts::set_test_artifact_sessions_root(self.0.take()); + } + } + let _restore = RestoreArtifactRoot(prior); + let session_id = "session-bash"; + let relative_path = PathBuf::from("artifacts/art_call-bash.txt"); + let path = crate::artifacts::session_artifact_absolute_path(session_id, &relative_path) + .expect("isolated artifact path"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "BASH_EXACT_DEEP_SENTINEL\n").unwrap(); + + let mut app = create_test_app(); + app.current_session_id = Some(session_id.to_string()); + app.history = vec![HistoryCell::Tool(ToolCell::Exec(ExecCell { + command: "cargo test".to_string(), + status: ToolStatus::Success, + output: Some("[Exact evidence retained · 23 B]".to_string()), + live_output: None, + shell_task_id: None, + owner_agent_id: None, + owner_agent_name: None, + started_at: None, + duration_ms: None, + source: ExecSource::Assistant, + interaction: None, + output_summary: None, + }))]; + app.tool_details_by_cell.insert( + 0, + ToolDetailRecord { + tool_id: "call-bash".to_string(), + tool_name: "Bash".to_string(), + input: serde_json::json!({"action": "run", "command": "cargo test"}), + output: Some("[Exact evidence retained · 23 B]".to_string()), + }, + ); + let artifact = crate::artifacts::ArtifactRecord { + id: "art_call-bash".to_string(), + kind: crate::artifacts::ArtifactKind::ToolOutput, + session_id: session_id.to_string(), + tool_call_id: "call-bash".to_string(), + tool_name: "Bash".to_string(), + created_at: chrono::Utc::now(), + byte_size: 25, + preview: "BASH_EXACT_DEEP_SENTINEL".to_string(), + storage_path: relative_path.clone(), + }; + app.session_artifacts.push(artifact.clone()); + app.resync_history_revisions(); + + let section = spillover_pager_section(&app, 0).expect("session artifact section"); + assert!(section.contains("BASH_EXACT_DEEP_SENTINEL")); + assert!(!section.contains(&path.display().to_string())); + + app.session_artifacts[0].storage_path = path.clone(); + let absolute = spillover_pager_section(&app, 0).expect("unavailable absolute-path section"); + assert!(absolute.contains("retained output is unavailable")); + assert!(!absolute.contains("BASH_EXACT_DEEP_SENTINEL")); + + app.session_artifacts[0] = crate::artifacts::ArtifactRecord { + session_id: "foreign-session".to_string(), + storage_path: relative_path, + ..artifact + }; + assert!( + spillover_pager_section(&app, 0).is_none(), + "a foreign artifact record must not become an Option+V detail source" + ); } #[test] @@ -13379,6 +13979,32 @@ fn orphan_during_active_keeps_subsequent_completion_routed_correctly() { assert_eq!(app.history.len(), 2); } +#[test] +fn orphan_success_with_mutation_metadata_keeps_the_file_receipt() { + let mut app = create_test_app(); + let result = crate::tools::spec::ToolResult::success("structured success").with_metadata( + serde_json::json!({ + "mutation": { + "diff": "--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1 +1 @@\n-old\n+new\n", + "files": [{ "path": "src/lib.rs", "outcome": "updated" }], + "renames": [] + } + }), + ); + + handle_tool_call_complete(&mut app, "orphan-file", "File", &Ok(result)); + + let HistoryCell::Tool(ToolCell::PatchSummary(cell)) = &app.history[0] else { + panic!("structured orphan mutation must retain a calm File receipt") + }; + assert_eq!(cell.status, ToolStatus::Success); + assert_eq!( + cell.receipt.as_ref().map(|receipt| receipt.outcome_label()), + Some("Updated src/lib.rs".to_string()) + ); + assert_eq!(app.tool_details_by_cell[&0].tool_name, "File"); +} + #[test] fn tool_details_survive_active_cell_flush() { // Detail pagers resolve tool details by cell index. Flushing the @@ -13987,6 +14613,7 @@ fn turn_inspector_renders_overview_sections_for_active_turn() { summary: "guard against empty token".to_string(), status: ToolStatus::Success, error: None, + receipt: None, }, )), HistoryCell::Assistant { @@ -14122,6 +14749,7 @@ fn turn_inspector_timeline_numbers_semantic_entries_and_checkpoint_actions() { summary: "add timeline evidence".to_string(), status: ToolStatus::Success, error: None, + receipt: None, }, )), HistoryCell::Tool(ToolCell::Exec(ExecCell { @@ -14305,6 +14933,7 @@ fn turn_handoff_markdown_renders_compact_sections_for_active_turn() { summary: "guard against empty token".to_string(), status: ToolStatus::Success, error: None, + receipt: None, }, )), HistoryCell::Assistant { @@ -14492,6 +15121,52 @@ fn approval_prompt_uses_event_input_after_message_complete_drain() { assert_ne!(content.trim(), "{}"); } +#[test] +fn patch_approval_modal_does_not_displace_the_active_file_receipt() { + let mut app = create_test_app(); + let input = serde_json::json!({ + "action": "patch", + "patch": "--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1 +1 @@\n-old\n+new\n" + }); + handle_tool_call_started(&mut app, "file-ask", "File", &input); + let active_index = app.tool_cells["file-ask"]; + + push_approval_request_view( + &mut app, + "file-ask", + "File", + "Apply a file patch", + &input, + "approval-key", + None, + ); + + assert!( + app.history.is_empty(), + "the modal owns the preflight preview; successful evidence belongs to the File receipt" + ); + assert_eq!(app.tool_cells["file-ask"], active_index); + + let result = crate::tools::spec::ToolResult::success("ok").with_metadata(serde_json::json!({ + "mutation": { + "diff": "--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1 +1 @@\n-old\n+new\n", + "files": [{ "path": "src/lib.rs", "outcome": "updated" }], + "renames": [] + } + })); + handle_tool_call_complete(&mut app, "file-ask", "apply_patch", &Ok(result)); + + let active = app.active_cell.as_ref().expect("active File cell"); + let HistoryCell::Tool(ToolCell::PatchSummary(cell)) = &active.entries()[0] else { + panic!("Ask approval must leave the canonical File receipt addressable") + }; + assert_eq!(cell.status, ToolStatus::Success); + assert_eq!( + cell.receipt.as_ref().map(|receipt| receipt.outcome_label()), + Some("Updated src/lib.rs".to_string()) + ); +} + #[tokio::test] async fn approval_decision_persists_ask_rules_to_permissions_file() { let tmp = TempDir::new().expect("tempdir"); @@ -16944,7 +17619,7 @@ fn status_animation_ticks_for_lone_running_history_tool() { "running workflow tool should count as live motion" ); assert!( - should_tick_status_animation(&app, false, history_motion, active_motion), + should_tick_status_animation(&app, false, history_motion, active_motion, false), "a lone running tool in history must force timed redraws for the spout" ); } @@ -16964,7 +17639,7 @@ fn status_animation_ticks_for_lone_running_active_tool() { "running active-cell tool should count as live motion" ); assert!( - should_tick_status_animation(&app, false, history_motion, active_motion), + should_tick_status_animation(&app, false, history_motion, active_motion, false), "a lone running active tool must force timed redraws for the spout" ); } @@ -16976,11 +17651,116 @@ fn status_animation_stays_idle_without_live_motion() { assert!(!history_has_live_motion(&app.history)); assert!(!active_cell_has_live_motion(&app)); assert!( - !should_tick_status_animation(&app, false, false, false), + !should_tick_status_animation(&app, false, false, false, false), "idle sessions should not wake the animation timer" ); } +#[test] +fn status_animation_ticks_for_a_visible_background_task() { + let mut app = create_test_app(); + app.low_motion = false; + app.fancy_animations = true; + app.sidebar_focus = SidebarFocus::Tasks; + app.last_sidebar_area = Some(Rect::new(80, 0, 20, 20)); + app.task_panel.push(TaskPanelEntry { + id: "shell_smooth".to_string(), + status: "running".to_string(), + prompt_summary: "shell: cargo test --locked".to_string(), + duration_ms: Some(crate::tui::spinner::LIVE_MARKER_DELAY_MS), + kind: TaskPanelEntryKind::Background, + stale: false, + elapsed_since_output_ms: None, + owner_agent_id: None, + owner_agent_name: None, + current_tool: None, + role: None, + files_touched: 0, + }); + + assert!(visible_background_task_has_live_motion(&app)); + assert!(should_tick_status_animation( + &app, false, false, false, false + )); + + app.last_sidebar_area = None; + assert!(!visible_background_task_has_live_motion(&app)); + assert!(!should_tick_status_animation( + &app, false, false, false, false + )); +} + +#[test] +fn status_animation_timer_respects_reduced_and_still_policies() { + let mut app = create_test_app(); + app.history.push(running_generic_tool_cell("workflow")); + let history_motion = history_has_live_motion(&app.history); + + app.low_motion = false; + app.fancy_animations = true; + assert!(should_tick_status_animation( + &app, + false, + history_motion, + false, + false + )); + + app.low_motion = true; + assert!( + should_tick_status_animation(&app, false, history_motion, false, false), + "reduced motion keeps its calm liveness refresh while markers stay static" + ); + + app.low_motion = false; + app.fancy_animations = false; + assert!( + !should_tick_status_animation(&app, false, history_motion, false, false), + "still mode must not wake the status animation timer" + ); + assert_eq!(status_animation_interval_ms(&app), 2_400); + assert_eq!(active_poll_ms(&app), UI_ACTIVE_POLL_MS); + assert_eq!(idle_poll_ms(&app), UI_IDLE_POLL_MS); +} + +#[test] +fn translation_placeholder_keeps_a_calm_refresh_without_repainting_still_mode() { + let mut app = create_test_app(); + app.low_motion = false; + app.fancy_animations = true; + assert!(should_tick_status_animation( + &app, false, false, false, true + )); + + app.low_motion = true; + assert!(should_tick_status_animation( + &app, false, false, false, true + )); + + app.low_motion = false; + app.fancy_animations = false; + assert!(!should_tick_status_animation( + &app, false, false, false, true + )); +} + +#[test] +fn classic_header_indicator_animates_only_in_full_motion() { + let mut app = create_test_app(); + app.turn_started_at = Some(Instant::now()); + + app.low_motion = false; + app.fancy_animations = true; + assert!(classic_header_indicator_started_at(&app).is_some()); + + app.low_motion = true; + assert!(classic_header_indicator_started_at(&app).is_none()); + + app.low_motion = false; + app.fancy_animations = false; + assert!(classic_header_indicator_started_at(&app).is_none()); +} + #[test] fn subagent_completion_notification_modes_gate_correctly() { use crate::config::SubagentCompletionNotification as Mode; diff --git a/crates/tui/src/tui/underwater.rs b/crates/tui/src/tui/underwater.rs index 7a2cd30af8..1c36827b0a 100644 --- a/crates/tui/src/tui/underwater.rs +++ b/crates/tui/src/tui/underwater.rs @@ -19,6 +19,7 @@ use ratatui::{ use unicode_width::UnicodeWidthStr; use crate::localization::{Locale, MessageId, tr}; +use crate::palette; use crate::tui::{ app::{App, AppMode, OnboardingState}, approval::ApprovalMode, @@ -176,13 +177,109 @@ pub enum ShellPhase { Failed, } +/// The one truthful verb shown while a turn is live. This deliberately stays +/// smaller than the tool taxonomy: the phase strip only needs to distinguish +/// hidden reasoning, read-shaped exploration, other tool use, verification, +/// and generic model work. It never exposes reasoning text or tool arguments. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LiveActivityKind { + Working, + Reasoning, + Reading, + UsingTool, + Verifying, +} + +/// Bounded projection of live turn activity. Completed entries are ignored, +/// so an `ActiveCell` retained until `TurnComplete` cannot keep the shell in a +/// false working state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct LiveActivity { + kind: LiveActivityKind, + running_tools: usize, +} + +impl LiveActivity { + #[must_use] + pub(crate) fn from_app(app: &App) -> Self { + let tools = running_tool_facts(app); + let kind = if tools.verifying { + LiveActivityKind::Verifying + } else if tools.count > 0 && tools.all_reading { + LiveActivityKind::Reading + } else if tools.count > 0 { + LiveActivityKind::UsingTool + } else if app.streaming_thinking_active_entry.is_some() { + LiveActivityKind::Reasoning + } else { + LiveActivityKind::Working + }; + Self { + kind, + running_tools: tools.count, + } + } + + #[must_use] + pub(crate) fn kind(self) -> LiveActivityKind { + self.kind + } + + #[must_use] + pub(crate) fn running_tool_count(self) -> usize { + self.running_tools + } + + #[must_use] + fn is_explicit(self) -> bool { + !matches!(self.kind, LiveActivityKind::Working) + } + + #[must_use] + fn label(self, locale: Locale) -> Cow<'static, str> { + match self.kind { + LiveActivityKind::Working => tr(locale, MessageId::PhaseWorking), + LiveActivityKind::Reasoning => tr(locale, MessageId::PhaseReasoning), + LiveActivityKind::Reading => tr(locale, MessageId::PhaseReading), + LiveActivityKind::UsingTool => tr(locale, MessageId::PhaseUsingTool), + LiveActivityKind::Verifying => tr(locale, MessageId::PhaseVerifying), + } + } +} + +#[derive(Debug, Clone, Copy)] +struct RunningToolFacts { + count: usize, + all_reading: bool, + verifying: bool, +} + +impl Default for RunningToolFacts { + fn default() -> Self { + Self { + count: 0, + all_reading: true, + verifying: false, + } + } +} + +impl RunningToolFacts { + fn observe(&mut self, reading: bool, verifying: bool) { + self.count = self.count.saturating_add(1); + self.all_reading &= reading; + self.verifying |= verifying; + } +} + const WORKING_BUBBLE_FRAMES: [&str; 8] = ["⠀", "⢀", "⣀", "⣄", "⣤", "⣦", "⣶", "⣿"]; const COMPLETION_BREATH_MS: u128 = 800; const COMPLETION_RELEASE_MS: u128 = 560; +const IDLE_WHALE_SPOUT_ROW: &str = " ˚"; const IDLE_WHALE_ROWS: [&str; 3] = [ - " ▗▄▄▄▄▄▄▄▄▄▄▄▄▄▖ ▚▞", - "▐██·████████████▙▄▄▄▞", - " ▝▀▀▀▀▀▀▀▀▀▀▀▀▀▘", + " ▗▄▄▄▄▄▄▄▄▄▄▄▖ ▚▞", + " ▐██·███████████▙━━━━▞", + " ▝▀▀▀▀▀▀▀▀▀▀▀▀▘", ]; const IDLE_SHIMMER_CYCLE_MS: u128 = 4_000; const IDLE_SHIMMER_SWEEP_FRACTION: f32 = 0.32; @@ -192,6 +289,11 @@ const IDLE_SHIMMER_STRENGTH: f32 = 0.33; impl ShellPhase { #[must_use] pub fn from_app(app: &App) -> Self { + Self::from_app_with_activity(app, LiveActivity::from_app(app)) + } + + #[must_use] + pub(crate) fn from_app_with_activity(app: &App, activity: LiveActivity) -> Self { if matches!( app.view_stack.top_kind(), Some( @@ -219,12 +321,9 @@ impl ShellPhase { } if app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) - || app - .active_cell - .as_ref() - .is_some_and(|active| !active.is_empty()) + || activity.is_explicit() { - if verification_run_active(app) { + if activity.kind() == LiveActivityKind::Verifying { return Self::Verifying; } return Self::Working; @@ -266,30 +365,50 @@ impl ShellPhase { } } -/// True when the live active cell is running a verification-shaped tool: -/// the verifier tool itself or an exec whose program is a known test/check -/// runner. Conservative by design — misclassifying real work as `verifying` -/// would lie; plain `working` never does. -fn verification_run_active(app: &App) -> bool { +/// Summarize only tools whose lifecycle is actually `Running`. A read label +/// is earned only when every running entry is read/exploration-shaped; mixed +/// work stays the neutral `using tool`. Verification wins because it is the +/// existing stronger promise made by the phase strip. +fn running_tool_facts(app: &App) -> RunningToolFacts { use crate::tui::history::{HistoryCell, ToolCell, ToolStatus}; + use crate::tui::widgets::tool_card::{ToolFamily, tool_family_for_name}; + + let mut facts = RunningToolFacts::default(); let Some(active) = app.active_cell.as_ref() else { - return false; + return facts; }; - active.entries().iter().any(|cell| { + for cell in active.entries() { let HistoryCell::Tool(tool) = cell else { - return false; + continue; }; match tool { ToolCell::Exec(exec) if exec.status == ToolStatus::Running => { - exec_is_verification(&exec.command) + facts.observe(false, exec_is_verification(&exec.command)); } ToolCell::Generic(generic) if generic.status == ToolStatus::Running => { - let name = generic.name.to_ascii_lowercase(); - name.contains("verif") || name == "read_lints" + let family = tool_family_for_name(&generic.name); + facts.observe( + matches!(family, ToolFamily::Read | ToolFamily::Find), + family == ToolFamily::Verify || generic.name == "read_lints", + ); + } + ToolCell::Exploring(exploring) => { + for entry in &exploring.entries { + if entry.status == ToolStatus::Running { + facts.observe(true, false); + } + } + } + ToolCell::WebSearch(search) if search.status == ToolStatus::Running => { + facts.observe(true, false); } - _ => false, + other if other.status() == Some(ToolStatus::Running) => { + facts.observe(false, false); + } + _ => {} } - }) + } + facts } fn exec_is_verification(command: &str) -> bool { @@ -310,7 +429,7 @@ fn exec_is_verification(command: &str) -> bool { } fn completion_elapsed_ms(app: &App) -> Option { - if app.low_motion || !app.fancy_animations { + if !app.motion_policy().allows_decorative() { return None; } app.ocean_completion_started_at @@ -318,7 +437,16 @@ fn completion_elapsed_ms(app: &App) -> Option { .filter(|elapsed| *elapsed < COMPLETION_BREATH_MS) } +#[cfg(test)] pub(crate) fn phase_marker(app: &App, phase: ShellPhase) -> (&'static str, Cow<'static, str>) { + phase_marker_with_activity(app, phase, LiveActivity::from_app(app)) +} + +pub(crate) fn phase_marker_with_activity( + app: &App, + phase: ShellPhase, + activity: LiveActivity, +) -> (&'static str, Cow<'static, str>) { let locale = app.ui_locale; match phase { ShellPhase::Idle => ("·", phase.label(locale)), @@ -328,19 +456,25 @@ pub(crate) fn phase_marker(app: &App, phase: ShellPhase) -> (&'static str, Cow<' // so the two primary liveness marks never look like unrelated // spinners. The shared helper also preserves the 400ms // "motion is earned" delay and reduced/still fallback. - let frame = crate::tui::spinner::braille_spinner_frame( - app.turn_started_at, - app.low_motion || !app.fancy_animations, - ); - (frame, phase.label(locale)) + let policy = app.motion_policy(); + let animated = crate::tui::spinner::braille_spinner_frame(app.turn_started_at, false); + let earned = app.turn_started_at.is_none_or(|started| { + started.elapsed().as_millis() + >= u128::from(crate::tui::spinner::LIVE_MARKER_DELAY_MS) + }); + let frame = policy.spinner_glyph(animated, earned); + (frame, activity.label(locale)) } ShellPhase::Verifying => { // Metered braille tick on the shared live clock — checking, not // searching. Reduced motion holds the legible mid frame. - let frame = crate::tui::spinner::verification_tick_frame( - app.turn_started_at, - app.low_motion || !app.fancy_animations, - ); + let policy = app.motion_policy(); + let animated = crate::tui::spinner::verification_tick_frame(app.turn_started_at, false); + let earned = app.turn_started_at.is_none_or(|started| { + started.elapsed().as_millis() + >= u128::from(crate::tui::spinner::LIVE_MARKER_DELAY_MS) + }); + let frame = policy.spinner_glyph(animated, earned); (frame, phase.label(locale)) } ShellPhase::Waiting | ShellPhase::Approval => ("◆", phase.label(locale)), @@ -654,6 +788,18 @@ pub fn render_header(area: Rect, buf: &mut Buffer, app: &App) { let (effective_provider, effective_model) = app.effective_route_identity_display(); let route_label = format!("{effective_provider} · {effective_model}"); let effort_label = app.reasoning_effort_display_label(); + let mode_color = match app.mode { + AppMode::Plan => app.ui_theme.mode_plan, + AppMode::Operate => app.ui_theme.mode_operate, + _ => app.ui_theme.mode_agent, + }; + // Match the composer's warm top edge exactly: Ask amber, Auto-Review + // Signal Gold, and Full Access coral. + let permission_color = match app.approval_mode { + ApprovalMode::Suggest | ApprovalMode::Never => palette::TEXT_REASONING, + ApprovalMode::Auto => palette::WHALE_HUMAN, + ApprovalMode::Bypass => palette::STATUS_WARNING, + }; let status_indicator = crate::tui::widgets::header_status_indicator_frame( (!app.low_motion && app.fancy_animations) .then_some(app.turn_started_at) @@ -673,11 +819,7 @@ pub fn render_header(area: Rect, buf: &mut Buffer, app: &App) { Span::styled(" · ", Style::default().fg(app.ui_theme.text_dim)), Span::styled( mode_label(app.ui_locale, app.mode), - Style::default().fg(match app.mode { - AppMode::Plan => app.ui_theme.mode_plan, - AppMode::Operate => app.ui_theme.mode_operate, - _ => app.ui_theme.mode_agent, - }), + Style::default().fg(mode_color), ), Span::styled(" · ", Style::default().fg(app.ui_theme.text_dim)), Span::styled(effort_label.clone(), Style::default().fg(app.ui_theme.info)), @@ -703,7 +845,7 @@ pub fn render_header(area: Rect, buf: &mut Buffer, app: &App) { )); left.push(Span::styled( permission_label(app), - Style::default().fg(app.ui_theme.text_muted), + Style::default().fg(permission_color), )); let mut right = Vec::new(); @@ -746,11 +888,11 @@ pub fn render_header(area: Rect, buf: &mut Buffer, app: &App) { }; let suffix = vec![ Span::styled(" · ", Style::default().fg(app.ui_theme.text_dim)), - Span::styled(mode, Style::default().fg(app.ui_theme.accent_primary)), + Span::styled(mode, Style::default().fg(mode_color)), Span::styled(" · ", Style::default().fg(app.ui_theme.text_dim)), Span::styled(effort, Style::default().fg(app.ui_theme.info)), Span::styled(" · ", Style::default().fg(app.ui_theme.text_dim)), - Span::styled(permission, Style::default().fg(app.ui_theme.text_muted)), + Span::styled(permission, Style::default().fg(permission_color)), ]; let indicator_width = status_indicator.map_or(0, |indicator| 1 + indicator.width()); let fixed_width = 4usize @@ -817,12 +959,7 @@ pub(crate) fn empty_state_mark_visible(area: Rect) -> bool { #[must_use] pub(crate) fn decorative_shell_motion_enabled(app: &App) -> bool { - crate::tui::motion::MotionPolicy::from_settings( - app.low_motion, - app.fancy_animations, - app.constrained_frame_rate, - ) - .allows_decorative() + app.motion_policy().allows_decorative() && app.ocean_treatment.supports_ambient_life() && !app.attention_hold_active() && app.onboarding == OnboardingState::None @@ -915,6 +1052,15 @@ fn idle_whale_row_spans( spans } +#[must_use] +fn idle_whale_block_width() -> usize { + std::iter::once(IDLE_WHALE_SPOUT_ROW) + .chain(IDLE_WHALE_ROWS.iter().copied()) + .map(UnicodeWidthStr::width) + .max() + .unwrap_or(0) +} + pub fn empty_state_lines(app: &App, area: Rect) -> Vec> { if area.width == 0 || area.height == 0 { return Vec::new(); @@ -926,7 +1072,7 @@ pub fn empty_state_lines(app: &App, area: Rect) -> Vec> { let animated = idle_mark_animation_enabled(app); let elapsed_ms = app.ocean_started_at.elapsed().as_millis(); let mut mark = vec![vec![Span::styled( - " ˚", + IDLE_WHALE_SPOUT_ROW, Style::default().fg(app.ui_theme.accent_secondary), )]]; mark.extend(IDLE_WHALE_ROWS.iter().enumerate().map(|(row, text)| { @@ -940,10 +1086,12 @@ pub fn empty_state_lines(app: &App, area: Rect) -> Vec> { app.ui_theme.text_body, ) })); + // The spout, head, belly, peduncle, and flukes are one drawing. Give + // every row the same outer inset so the authored offsets survive; + // centering each row independently shears the silhouette apart. + let block_inset = " ".repeat(width.saturating_sub(idle_whale_block_width()) / 2); for row in mark { - let row_width = span_width(&row); - let inset = " ".repeat(width.saturating_sub(row_width) / 2); - let mut spans = vec![Span::raw(inset)]; + let mut spans = vec![Span::raw(block_inset.clone())]; spans.extend(row); lines.push(Line::from(spans)); } @@ -1117,6 +1265,33 @@ mod tests { ); } + #[test] + fn header_labels_follow_the_ask_amber_auto_gold_full_access_coral_ramp() { + for width in [40, 100] { + for (approval_mode, expected_label, expected_color) in [ + (ApprovalMode::Suggest, "ask", palette::TEXT_REASONING), + (ApprovalMode::Auto, "auto", palette::WHALE_HUMAN), + (ApprovalMode::Bypass, "Full Access", palette::STATUS_WARNING), + ] { + let mut app = test_app(); + app.approval_mode = approval_mode; + let label = permission_label(&app).into_owned(); + assert_eq!(label, expected_label, "{approval_mode:?}"); + let area = Rect::new(0, 0, width, 1); + let mut buf = Buffer::empty(area); + + render_header(area, &mut buf, &app); + + let rendered = (0..width).map(|x| buf[(x, 0)].symbol()).collect::(); + let label_byte = rendered + .find(&label) + .expect("permission label should render"); + let label_x = rendered[..label_byte].width() as u16; + assert_eq!(buf[(label_x, 0)].fg, expected_color, "{approval_mode:?}"); + } + } + } + #[test] fn normal_header_keeps_requested_effective_effort_before_route_detail() { let mut app = test_app(); @@ -1326,6 +1501,120 @@ mod tests { assert_eq!(label, "failed"); } + #[test] + fn live_activity_is_truthful_prioritized_and_ignores_stale_tools() { + use crate::tui::active_cell::ActiveCell; + use crate::tui::history::{ + ExploringCell, ExploringEntry, GenericToolCell, HistoryCell, ToolCell, ToolStatus, + }; + + let generic = |name: &str, status: ToolStatus| { + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: name.to_string(), + status, + input_summary: None, + output: None, + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })) + }; + let reading = || { + HistoryCell::Tool(ToolCell::Exploring(ExploringCell { + entries: vec![ExploringEntry { + label: "Reading src/lib.rs".to_string(), + status: ToolStatus::Running, + }], + })) + }; + + let mut app = test_app(); + + // A completed tool may remain in the active group until TurnComplete, + // but it cannot manufacture liveness on its own. + let mut stale = ActiveCell::new(); + stale.push_tool("done", generic("write_file", ToolStatus::Success)); + app.active_cell = Some(stale); + assert_eq!( + LiveActivity::from_app(&app).kind(), + LiveActivityKind::Working + ); + assert_eq!(ShellPhase::from_app(&app), ShellPhase::Idle); + + // Only the explicit streaming pointer earns the reasoning label. No + // configured effort, elapsed clock, or generic loading inference is + // involved. + let mut active = ActiveCell::new(); + let thinking = active.push_thinking(HistoryCell::Thinking { + content: "private reasoning must not reach the strip".to_string(), + streaming: true, + duration_secs: None, + }); + app.active_cell = Some(active); + app.streaming_thinking_active_entry = Some(thinking); + assert_eq!( + LiveActivity::from_app(&app).kind(), + LiveActivityKind::Reasoning + ); + let (_, label) = phase_marker(&app, ShellPhase::from_app(&app)); + assert_eq!(label, "reasoning"); + assert!(!label.contains("private")); + + // A running read wins over a stale thinking pointer. + app.active_cell + .as_mut() + .expect("active cell") + .push_tool("read", reading()); + let activity = LiveActivity::from_app(&app); + assert_eq!(activity.kind(), LiveActivityKind::Reading); + assert_eq!(activity.running_tool_count(), 1); + assert_eq!(phase_marker(&app, ShellPhase::Working).1, "reading"); + + // Mixed tool work is not mislabeled as a pure read pass. + app.active_cell + .as_mut() + .expect("active cell") + .push_tool("write", generic("write_file", ToolStatus::Running)); + let activity = LiveActivity::from_app(&app); + assert_eq!(activity.kind(), LiveActivityKind::UsingTool); + assert_eq!(activity.running_tool_count(), 2); + assert_eq!(phase_marker(&app, ShellPhase::Working).1, "using tool"); + + // Verification remains the strongest live promise. + app.active_cell + .as_mut() + .expect("active cell") + .push_tool("verify", generic("run_verifiers", ToolStatus::Running)); + assert_eq!( + LiveActivity::from_app(&app).kind(), + LiveActivityKind::Verifying + ); + assert_eq!(ShellPhase::from_app(&app), ShellPhase::Verifying); + } + + #[test] + fn live_activity_marker_freezes_for_reduced_or_still_and_has_ascii_fallback() { + let mut app = test_app(); + app.runtime_turn_status = Some("in_progress".to_string()); + app.turn_started_at = Some(Instant::now() - Duration::from_secs(5)); + + app.low_motion = true; + let reduced = phase_marker(&app, ShellPhase::Working).0; + assert_eq!(reduced, crate::tui::spinner::BRAILLE_SPINNER_STILL_FRAME); + + app.low_motion = false; + app.fancy_animations = false; + let fancy_off = phase_marker(&app, ShellPhase::Working).0; + assert_eq!(fancy_off, crate::tui::spinner::LIVE_STATIC_MARKER); + + let mut cell = ratatui::buffer::Cell::default(); + cell.set_symbol(fancy_off); + crate::tui::color_compat::adapt_cell_symbol_for_ascii(&mut cell); + assert_eq!(cell.symbol(), ">"); + assert!(cell.symbol().is_ascii()); + } + #[test] fn idle_whale_caustic_sweeps_then_parks_offscreen() { assert_eq!(idle_mark_shine_opacity(0.5, 0), 0.0); @@ -1367,6 +1656,95 @@ mod tests { assert_eq!(colors(&frozen_a), colors(&frozen_b)); } + #[test] + fn idle_whale_rows_share_one_centered_block_without_losing_authored_offsets() { + let mut app = test_app(); + app.low_motion = true; + let width = 60usize; + let rendered = empty_state_lines(&app, Rect::new(0, 0, width as u16, 16)) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + let block_width = idle_whale_block_width(); + let block_inset = (width - block_width) / 2; + + assert_eq!( + block_width, 23, + "the final mark should stay quiet at 60 cols" + ); + for row in std::iter::once(IDLE_WHALE_SPOUT_ROW).chain(IDLE_WHALE_ROWS) { + let line = rendered + .iter() + .find(|line| line.trim_start() == row.trim_start()) + .unwrap_or_else(|| panic!("missing authored whale row {row:?}")); + let rendered_inset = line.chars().take_while(|ch| *ch == ' ').count(); + let authored_inset = row.chars().take_while(|ch| *ch == ' ').count(); + + assert_eq!( + rendered_inset - authored_inset, + block_inset, + "row drifted out of the shared silhouette: {line:?}" + ); + assert!( + line.width() <= block_inset + block_width, + "row escaped the centered mark block: {line:?}" + ); + } + } + + #[test] + fn idle_whale_has_a_recognizable_ascii_safe_silhouette() { + let ascii_row = |row: &str| { + let mut rendered = String::new(); + for ch in row.chars() { + let mut cell = ratatui::buffer::Cell::default(); + cell.set_symbol(&ch.to_string()); + crate::tui::color_compat::adapt_cell_symbol_for_ascii(&mut cell); + rendered.push_str(cell.symbol()); + } + rendered + }; + let rows = std::iter::once(IDLE_WHALE_SPOUT_ROW) + .chain(IDLE_WHALE_ROWS) + .map(ascii_row) + .collect::>(); + + assert_eq!( + rows, + [ + " o", + r" .###########. \/", + " |##.############----/", + " .############.", + ] + ); + assert!(rows.iter().all(|row| row.is_ascii())); + } + + #[test] + fn reduced_motion_keeps_the_whole_idle_mark_still_and_cursorless() { + let mut app = test_app(); + app.low_motion = true; + app.fancy_animations = true; + app.cursor_position = 7; + app.ocean_started_at = Instant::now() - Duration::from_secs(2); + let first = empty_state_lines(&app, Rect::new(0, 0, 100, 30)); + + app.ocean_started_at = Instant::now() - Duration::from_secs(11); + let second = empty_state_lines(&app, Rect::new(0, 0, 100, 30)); + + assert_eq!(first, second, "reduced motion must freeze mark and shine"); + assert_eq!( + app.cursor_position, 7, + "the empty-state decoration must leave cursor ownership to the composer" + ); + } + #[test] fn idle_whale_uses_the_human_brand_role_not_focus_blue() { let mut app = test_app(); @@ -1441,14 +1819,15 @@ mod tests { app.runtime_turn_status = Some("in_progress".to_string()); app.turn_started_at = Some(Instant::now() - Duration::from_secs(3)); - // A live test run reads as `verifying` with the metered tick. + // A live test run reads as `verifying`. Reduced motion keeps the + // semantic label while sharing the calm, static live-work marker. let mut active = ActiveCell::new(); active.push_tool("exec-1", running_exec("cargo test -p codewhale-tui")); app.active_cell = Some(active); assert_eq!(ShellPhase::from_app(&app), ShellPhase::Verifying); app.low_motion = true; let (marker, label) = phase_marker(&app, ShellPhase::Verifying); - assert_eq!(marker, crate::tui::spinner::VERIFY_TICK_FRAMES[4]); + assert_eq!(marker, crate::tui::spinner::BRAILLE_SPINNER_STILL_FRAME); assert_eq!(label, "verifying"); app.low_motion = false; diff --git a/crates/tui/src/tui/views/help.rs b/crates/tui/src/tui/views/help.rs index 9d19de7b1e..7d1cfe0cec 100644 --- a/crates/tui/src/tui/views/help.rs +++ b/crates/tui/src/tui/views/help.rs @@ -17,6 +17,7 @@ use std::borrow::Cow; use std::cell::RefCell; +use std::path::Path; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ @@ -119,13 +120,30 @@ impl HelpView { Self::new_with_ordering(locale, HelpOrdering::CommandsFirst) } + pub fn new_for_workspace(locale: Locale, workspace: &Path) -> Self { + commands::user_registry::with_registry_for_workspace(Some(workspace), |registry| { + Self::new_with_registry(locale, HelpOrdering::CommandsFirst, registry) + }) + } + /// Open Help as the keyboard reference promised by shell shortcut hints. - pub fn new_for_shortcuts(locale: Locale) -> Self { - Self::new_with_ordering(locale, HelpOrdering::KeybindingsFirst) + pub fn new_for_shortcuts(locale: Locale, workspace: &Path) -> Self { + commands::user_registry::with_registry_for_workspace(Some(workspace), |registry| { + Self::new_with_registry(locale, HelpOrdering::KeybindingsFirst, registry) + }) } fn new_with_ordering(locale: Locale, ordering: HelpOrdering) -> Self { - let entries = build_entries(locale); + let registry = commands::user_registry::UserCommandRegistry::new(); + Self::new_with_registry(locale, ordering, ®istry) + } + + fn new_with_registry( + locale: Locale, + ordering: HelpOrdering, + registry: &commands::user_registry::UserCommandRegistry, + ) -> Self { + let entries = build_entries(locale, registry); let mut view = Self { locale, ordering, @@ -235,20 +253,31 @@ impl HelpView { } } -fn build_entries(locale: Locale) -> Vec { +fn build_entries( + locale: Locale, + registry: &commands::user_registry::UserCommandRegistry, +) -> Vec { let mut entries = Vec::new(); for command in commands::command_infos() { + if registry.get(command.name).is_some() { + continue; + } let label = format!("/{}", command.name); let localized = command.description_for(locale); - let description = if command.aliases.is_empty() { + let visible_aliases = command + .aliases + .iter() + .copied() + .filter(|alias| registry.get(alias).is_none()) + .collect::>(); + let description = if visible_aliases.is_empty() { localized.to_string() } else { format!( "{} (aliases: {})", localized, - command - .aliases + visible_aliases .iter() .map(|a| format!("/{a}")) .collect::>() @@ -585,11 +614,63 @@ mod tests { assert_eq!(commands.ordering, HelpOrdering::CommandsFirst); assert_eq!(first_filtered_section(&commands), HelpSection::Command); - let shortcuts = HelpView::new_for_shortcuts(Locale::En); + let shortcuts = HelpView::new_with_ordering(Locale::En, HelpOrdering::KeybindingsFirst); assert_eq!(shortcuts.ordering, HelpOrdering::KeybindingsFirst); assert_eq!(first_filtered_section(&shortcuts), HelpSection::Keybinding); } + #[test] + fn workspace_help_hides_user_shadowed_debt_aliases_from_copy_and_search() { + let tmp = tempfile::TempDir::new().unwrap(); + let commands_dir = tmp.path().join(".codewhale").join("commands"); + std::fs::create_dir_all(&commands_dir).unwrap(); + std::fs::write( + commands_dir.join("slop.md"), + "---\ndescription: Internal cleanup\nhidden: true\n---\ninternal cleanup", + ) + .unwrap(); + std::fs::write( + commands_dir.join("custom-debt.md"), + "---\ndescription: Custom debt flow\nalias: canzha\n---\ncustom debt", + ) + .unwrap(); + + for (term, mut view) in [ + ("slop", HelpView::new_for_workspace(Locale::En, tmp.path())), + ( + "canzha", + HelpView::new_for_shortcuts(Locale::En, tmp.path()), + ), + ] { + let debt = view + .entries + .iter() + .find(|entry| entry.label == "/debt") + .expect("canonical /debt help should remain visible"); + assert!(debt.description.contains("/cleanup")); + assert!(!debt.description.contains("/slop")); + assert!(!debt.description.contains("/canzha")); + + type_filter(&mut view, term); + assert!( + view.filtered + .iter() + .all(|idx| view.entries[*idx].label != "/debt") + ); + } + } + + #[test] + fn help_hides_builtins_with_shadowed_canonical_names() { + let registry = commands::user_registry::UserCommandRegistry::from_loaded(vec![( + "debt".to_string(), + "---\ndescription: Custom debt\n---\ncustom debt".to_string(), + )]); + let entries = build_entries(Locale::En, ®istry); + + assert!(entries.iter().all(|entry| entry.label != "/debt")); + } + #[test] fn substring_filter_narrows_to_command() { let mut view = HelpView::new(); @@ -899,7 +980,8 @@ mod tests { #[test] fn localized_help_keybinding_descriptions_use_zh_hans() { - let entries = build_entries(Locale::ZhHans); + let registry = commands::user_registry::UserCommandRegistry::new(); + let entries = build_entries(Locale::ZhHans, ®istry); let kb_entries: Vec<_> = entries .iter() .filter(|e| e.section == HelpSection::Keybinding) @@ -942,7 +1024,10 @@ mod tests { } let mut stack = ViewStack::new(); - stack.push(HelpView::new_for_shortcuts(Locale::En)); + stack.push(HelpView::new_with_ordering( + Locale::En, + HelpOrdering::KeybindingsFirst, + )); stack.render(area, &mut buf); let rows: Vec = (0..h) diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 884f13450e..1a144de236 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -28,6 +28,7 @@ use crate::tui::widgets::agent_card::AgentLifecycle; pub mod fleet_roster; pub mod fleet_setup; pub mod mode_picker; +pub mod skills_manager; pub mod status_picker; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -56,6 +57,7 @@ pub enum ModalKind { ThemePicker, ContextMenu, ContextInspector, + SkillsManager, } /// Clear and paint a modal popup with an opaque surface. @@ -665,6 +667,15 @@ pub enum ViewEvent { SidebarAgentCancel { agent_id: String, }, + /// Agent Details requests the existing artifact-first exact transcript. + OpenAgentTranscript { + agent_id: String, + }, + /// Agent Details was popped with Esc/q/Left. The Work surface uses this + /// to release only its detail-open owner while retaining selection. + AgentDetailsClosed { + agent_id: String, + }, /// Emitted by the file picker (`Ctrl+P`) when the user presses Enter on a /// candidate. The handler should insert `@` at the composer's cursor /// position. @@ -904,6 +915,14 @@ pub enum ViewEvent { text: String, label: String, }, + /// Emitted by the skills manager when the user confirms an install / + /// import / update / remove / trust action. The host runs the mutation + /// controller and rebuilds the open manager view. + SkillMutationRequested { + request: crate::skills::mutation::SkillMutationRequest, + }, + /// Toggle owned-only vs compatible audit scan inside the skills manager. + SkillsManagerToggleCompatible, } #[derive(Debug, Clone)] @@ -1439,6 +1458,13 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + section: ConfigSection::Display, + key: "inline_diffs".to_string(), + value: settings.inline_diffs.clone(), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { section: ConfigSection::Display, key: "status_indicator".to_string(), @@ -2386,10 +2412,11 @@ fn config_label_for_key(key: &str) -> String { "work_surface_placement" => "Sidebar position", "calm_mode" => "Quiet transcript", "low_motion" => "Reduce motion", - "fancy_animations" => "Ocean motion", + "fancy_animations" => "Live UI motion", "launch_screen" => "Launch screen", "show_thinking" => "Model reasoning in chat", "show_tool_details" => "Tool detail level", + "inline_diffs" => "Inline file changes", "status_indicator" => "Status indicator", "synchronized_output" => "Output pacing", "cost_currency" => "Cost currency", @@ -2467,6 +2494,7 @@ fn config_hint_for_key(key: &str) -> &'static str { | "composer_border" | "paste_burst_detection" => "on/off, true/false, yes/no, 1/0", "composer_density" | "transcript_spacing" => "compact | comfortable | spacious", + "inline_diffs" => "full | summary | off; exact change remains in Alt/Option+V details", "tool_collapse" => "compact | expanded | calm", // Derived from the shipped theme/locale registries so these hints // cannot go stale as new entries land (they previously advertised @@ -2494,9 +2522,9 @@ fn config_hint_for_key(key: &str) -> &'static str { "current provider endpoint; Xiaomi: token-plan | pay-as-you-go | custom URL" } "cost_currency" => "usd | cny", - "calm_mode" => "quietens transcript chrome and tool detail; independent of ocean motion", - "low_motion" => "on overrides decorative motion; model output is unchanged", - "fancy_animations" => "on animates fish, bubbles, and live ocean state", + "calm_mode" => "quietens transcript chrome and tool detail; independent of live motion", + "low_motion" => "on overrides live-state motion; model output is unchanged", + "fancy_animations" => "on animates truthful tool, status, and ocean live state", "ocean_treatment" => "ombre | flat (appearance; independent of motion)", "show_thinking" => "show or hide model reasoning in chat; task lists stay concise", "synchronized_output" => "auto | on | off; terminal redraw pacing, not model speed", @@ -2579,6 +2607,7 @@ fn config_choice_values(key: &str, provider: ApiProvider) -> Option> vec!["compact", "comfortable", "spacious"] } "tool_collapse" => vec!["compact", "expanded", "calm"], + "inline_diffs" => vec!["full", "summary", "off"], "composer_vim_mode" => vec!["normal", "vim"], "mention_menu_behavior" => vec!["fuzzy", "browser"], "sidebar_focus" => vec!["pinned", "auto", "tasks", "agents", "context", "hidden"], @@ -2662,6 +2691,9 @@ fn config_choice_label(key: &str, value: &str) -> String { ("status_indicator", "whale") => "Animated whale".to_string(), ("status_indicator", "dots") => "Animated dots".to_string(), ("status_indicator", "off") => "Off".to_string(), + ("inline_diffs", "full") => "Full diff".to_string(), + ("inline_diffs", "summary") => "Summary".to_string(), + ("inline_diffs", "off") => "Off".to_string(), ("sidebar_focus", "pinned") => "Work pinned".to_string(), ("sidebar_focus", "tasks") => "Activity".to_string(), ("sidebar_focus", "agents") => "Workers".to_string(), @@ -2695,10 +2727,10 @@ fn config_choice_detail(key: &str, value: &str) -> &'static str { ("work_surface_placement", "right") => { "Show Tasks, To-do, and Workers in a right sidebar when the terminal is wide enough." } - ("low_motion", "true") => "Stops decorative movement without changing model output.", + ("low_motion", "true") => "Stops live-state movement without changing model output.", ("low_motion", "false") => "Allows motion selected by the other appearance settings.", - ("fancy_animations", "true") => "Animates fish, bubbles, and live ocean state.", - ("fancy_animations", "false") => "Keeps the ocean treatment but makes it static.", + ("fancy_animations", "true") => "Animates truthful tool, status, and ocean live state.", + ("fancy_animations", "false") => "Keeps live-state markers and the ocean treatment static.", ("show_thinking", "true") => "Show model reasoning blocks in the transcript.", ("show_thinking", "false") => { "Keep model reasoning hidden; answers and tools remain visible." diff --git a/crates/tui/src/tui/views/skills_manager.rs b/crates/tui/src/tui/views/skills_manager.rs new file mode 100644 index 0000000000..d7998a8236 --- /dev/null +++ b/crates/tui/src/tui/views/skills_manager.rs @@ -0,0 +1,1005 @@ +//! Unified `/skills` manager — audit inventory + mutation actions. +//! +//! This view never writes files. Keys emit [`ViewEvent::SkillMutationRequested`]; +//! the host runs [`crate::skills::mutation`] and rebuilds the view. + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap}, +}; + +use super::{ + ActionHint, EmptyState, ListDetailLayout, ModalKind, ModalView, ViewAction, ViewEvent, + render_modal_footer, render_underwater_surface, truncate_view_text, +}; +use crate::palette; +use crate::skills::audit::{ + AuditedSkill, AuditedSkillId, DigestState, IntegrityState, ParserState, PrecedenceState, + ProvenanceState, SkillActionKind, SkillAuditMode, SkillAuditSnapshot, SkillSourceKind, + TrustState, scan_with_configured, +}; +use crate::skills::mutation::{ConflictPolicy, SkillMutationRequest, SkillTargetScope}; +use crate::skills::roots::SkillRootKind; +use crate::tui::app::App; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ManagerMode { + OwnedOnly, + Compatible, +} + +impl ManagerMode { + fn audit_mode(self) -> SkillAuditMode { + match self { + Self::OwnedOnly => SkillAuditMode::OwnedOnly, + Self::Compatible => SkillAuditMode::Compatible, + } + } + + fn label(self) -> &'static str { + match self { + Self::OwnedOnly => "owned", + Self::Compatible => "compatible", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PendingConfirm { + Remove { + skill_id: AuditedSkillId, + digest: Option, + }, + ImportReplace { + skill_id: AuditedSkillId, + digest: String, + }, +} + +pub struct SkillsManagerView { + mode: ManagerMode, + skills: Vec, + selected: usize, + detail_scroll: usize, + import_scope: SkillTargetScope, + pending: Option, + status: Option, +} + +impl SkillsManagerView { + #[must_use] + pub fn new(app: &App) -> Self { + Self::from_scan( + app, + ManagerMode::OwnedOnly, + SkillTargetScope::Global, + None, + None, + ) + } + + #[must_use] + pub fn rebuild_preserving( + app: &App, + previous: &Self, + status: Option, + focus: Option<&AuditedSkillId>, + ) -> Self { + let mut view = Self::from_scan( + app, + previous.mode, + previous.import_scope, + status, + focus.or_else(|| previous.selected_skill().map(|s| &s.id)), + ); + view.detail_scroll = 0; + view + } + + fn from_scan( + app: &App, + mode: ManagerMode, + import_scope: SkillTargetScope, + status: Option, + focus: Option<&AuditedSkillId>, + ) -> Self { + let snap = scan_snapshot(app, mode); + let mut view = Self { + mode, + skills: snap.skills, + selected: 0, + detail_scroll: 0, + import_scope, + pending: None, + status, + }; + if let Some(id) = focus + && let Some(idx) = view.skills.iter().position(|s| &s.id == id) + { + view.selected = idx; + } + view.clamp_selection(); + view + } + + fn selected_skill(&self) -> Option<&AuditedSkill> { + self.skills.get(self.selected) + } + + fn clamp_selection(&mut self) { + if self.skills.is_empty() { + self.selected = 0; + return; + } + self.selected = self.selected.min(self.skills.len() - 1); + } + + fn move_sel(&mut self, delta: isize) { + if self.skills.is_empty() { + return; + } + let len = self.skills.len() as isize; + let next = (self.selected as isize + delta).rem_euclid(len) as usize; + self.selected = next; + self.detail_scroll = 0; + self.pending = None; + } + + fn toggle_mode(&mut self, app: &App) { + self.mode = match self.mode { + ManagerMode::OwnedOnly => ManagerMode::Compatible, + ManagerMode::Compatible => ManagerMode::OwnedOnly, + }; + let snap = scan_with_configured( + &app.workspace, + dirs::home_dir().as_deref(), + Some(&app.skills_dir), + self.mode.audit_mode(), + None, + ); + let focus = self.selected_skill().map(|s| s.id.clone()); + self.skills = snap.skills; + self.pending = None; + self.detail_scroll = 0; + self.status = Some(format!("Scan mode: {}", self.mode.label())); + if let Some(id) = focus { + if let Some(idx) = self.skills.iter().position(|s| s.id == id) { + self.selected = idx; + } else { + self.selected = 0; + } + } else { + self.selected = 0; + } + self.clamp_selection(); + } + + fn cycle_import_scope(&mut self) { + self.import_scope = match self.import_scope { + SkillTargetScope::Global => SkillTargetScope::Project, + SkillTargetScope::Project => SkillTargetScope::Global, + }; + self.status = Some(format!("Import target: {}", scope_label(self.import_scope))); + } + + fn emit_action(&mut self, kind: SkillActionKind) -> ViewAction { + let Some(skill) = self.selected_skill().cloned() else { + return ViewAction::None; + }; + if !skill.available_actions.contains(&kind) { + self.status = Some(format!( + "{} is not available for '{}'", + action_label(kind), + skill.name + )); + return ViewAction::None; + } + + match kind { + SkillActionKind::Remove => { + let digest = match &skill.digest { + DigestState::Known(d) => Some(d.clone()), + DigestState::Unknown(_) => None, + }; + self.pending = Some(PendingConfirm::Remove { + skill_id: skill.id.clone(), + digest, + }); + self.status = Some(format!( + "Remove '{}'? Press Enter to confirm, Esc to cancel.", + skill.name + )); + ViewAction::None + } + SkillActionKind::Import => { + let DigestState::Known(digest) = &skill.digest else { + self.status = Some("Import requires a known package digest".into()); + return ViewAction::None; + }; + let want_kind = match self.import_scope { + SkillTargetScope::Project => SkillRootKind::CodeWhaleProject, + SkillTargetScope::Global => SkillRootKind::CodeWhaleGlobal, + }; + // Only treat same-scope owned peers as replace conflicts — the + // mutation controller replaces inside `import_scope` alone. + let owned_conflict = self.skills.iter().any(|peer| { + peer.id.canonical_name == skill.id.canonical_name + && peer.root.is_writable_owned() + && peer.root.kind == want_kind + && peer.id != skill.id + && match &peer.digest { + DigestState::Known(other) => other != digest, + DigestState::Unknown(_) => true, + } + }); + if owned_conflict { + self.pending = Some(PendingConfirm::ImportReplace { + skill_id: skill.id.clone(), + digest: digest.clone(), + }); + self.status = Some(format!( + "'{}' conflicts with {} owned copy. Enter = replace, Esc = cancel.", + skill.name, + scope_label(self.import_scope) + )); + return ViewAction::None; + } + ViewAction::Emit(ViewEvent::SkillMutationRequested { + request: SkillMutationRequest::ImportExternal { + source_id: skill.id.clone(), + expected_digest: digest.clone(), + target: self.import_scope, + conflict_policy: ConflictPolicy::Reject, + }, + }) + } + SkillActionKind::Update => ViewAction::Emit(ViewEvent::SkillMutationRequested { + request: SkillMutationRequest::Update { + skill_id: skill.id.clone(), + expected_digest: known_digest(&skill), + }, + }), + SkillActionKind::Trust => { + let Some(digest) = known_digest(&skill) else { + self.status = Some("Trust requires a known package digest".into()); + return ViewAction::None; + }; + ViewAction::Emit(ViewEvent::SkillMutationRequested { + request: SkillMutationRequest::Trust { + skill_id: skill.id.clone(), + expected_digest: digest, + }, + }) + } + SkillActionKind::Install => { + self.status = Some( + "Install from registry: /skill install [--project|--global] ".into(), + ); + ViewAction::None + } + } + } + + fn confirm_pending(&mut self) -> ViewAction { + match self.pending.take() { + Some(PendingConfirm::Remove { skill_id, digest }) => { + ViewAction::Emit(ViewEvent::SkillMutationRequested { + request: SkillMutationRequest::Remove { + skill_id, + expected_digest: digest, + }, + }) + } + Some(PendingConfirm::ImportReplace { skill_id, digest }) => { + ViewAction::Emit(ViewEvent::SkillMutationRequested { + request: SkillMutationRequest::ImportExternal { + source_id: skill_id, + expected_digest: digest, + target: self.import_scope, + conflict_policy: ConflictPolicy::ReplaceConfirmed, + }, + }) + } + None => { + // Primary action for selection. + let Some(skill) = self.selected_skill() else { + return ViewAction::None; + }; + let Some(kind) = skill.available_actions.first().copied() else { + self.status = Some(format!("No actions available for '{}'", skill.name)); + return ViewAction::None; + }; + self.emit_action(kind) + } + } + } + + fn footer_hints(&self) -> Vec { + if self.pending.is_some() { + return vec![ + ActionHint::new("Enter", "confirm"), + ActionHint::new("Esc", "cancel"), + ]; + } + let mut hints = vec![ + ActionHint::new("↑/↓", "select"), + ActionHint::new("Enter", "action"), + ActionHint::new("c", "scan mode"), + ActionHint::new("s", "import scope"), + ActionHint::new("Esc", "close"), + ]; + if let Some(skill) = self.selected_skill() { + for action in &skill.available_actions { + match action { + SkillActionKind::Import => hints.insert(2, ActionHint::new("i", "import")), + SkillActionKind::Update => hints.insert(2, ActionHint::new("u", "update")), + SkillActionKind::Remove => hints.insert(2, ActionHint::new("r", "remove")), + SkillActionKind::Trust => hints.insert(2, ActionHint::new("t", "trust")), + SkillActionKind::Install => {} + } + } + } + hints + } + + fn render_list(&self, area: Rect, buf: &mut Buffer) { + let block = Block::default() + .title(Line::from(Span::styled( + format!(" Skills ({}) ", self.skills.len()), + Style::default() + .fg(palette::WHALE_ACTION) + .add_modifier(Modifier::BOLD), + ))) + .borders(Borders::ALL) + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::WHALE_BG)); + let inner = block.inner(area); + block.render(area, buf); + + if self.skills.is_empty() { + EmptyState::new( + "No skills in this scan", + "Press c to include compatible roots, or /skill install .", + ) + .render(inner, buf); + return; + } + + let visible = usize::from(inner.height).max(1); + let offset = self.selected.saturating_add(1).saturating_sub(visible); + let end = (offset + visible).min(self.skills.len()); + + for (row, idx) in (offset..end).enumerate() { + let skill = &self.skills[idx]; + let y = inner.y + row as u16; + if y >= inner.y + inner.height { + break; + } + let selected = idx == self.selected; + let style = if selected { + Style::default() + .bg(palette::SURFACE_ELEVATED) + .fg(palette::WHALE_INFO) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette::TEXT_PRIMARY) + }; + let mark = if selected { "›" } else { " " }; + let line = format!( + "{mark} {} {} {}", + truncate_view_text(&skill.name, 22), + precedence_label(&skill.precedence), + source_label(skill.source_kind), + ); + buf.set_stringn(inner.x, y, line, usize::from(inner.width), style); + } + } + + fn render_detail(&self, area: Rect, buf: &mut Buffer) { + let block = Block::default() + .title(Line::from(Span::styled( + " Detail ", + Style::default() + .fg(palette::WHALE_ACTION) + .add_modifier(Modifier::BOLD), + ))) + .borders(Borders::ALL) + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::WHALE_BG)); + let inner = block.inner(area); + block.render(area, buf); + + let Some(skill) = self.selected_skill() else { + EmptyState::new( + "Nothing selected", + "Install or import a skill to get started.", + ) + .render(inner, buf); + return; + }; + + let mut lines = vec![ + Line::from(Span::styled( + skill.name.clone(), + Style::default() + .fg(palette::WHALE_INFO) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + kv_line("Path", &skill.safe_display_path), + kv_line("Source", source_label(skill.source_kind)), + kv_line("Status", &precedence_label(&skill.precedence)), + kv_line("Integrity", integrity_label(&skill.integrity)), + kv_line("Trust", trust_label(&skill.trust)), + kv_line("Parser", &parser_label(&skill.parser)), + kv_line("Digest", &digest_label(&skill.digest)), + kv_line("Provenance", &provenance_label(&skill.provenance)), + kv_line("Readiness", "unknown"), + kv_line("Import to", scope_label(self.import_scope)), + kv_line("Scan", self.mode.label()), + ]; + + if let Some(desc) = skill.description.as_deref() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Description", + Style::default().fg(palette::TEXT_MUTED), + ))); + lines.push(Line::from(truncate_view_text(desc, 240))); + } + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Actions", + Style::default().fg(palette::TEXT_MUTED), + ))); + if skill.available_actions.is_empty() { + lines.push(Line::from(Span::styled( + "(none — read-only or not managed)", + Style::default().fg(palette::TEXT_MUTED), + ))); + } else { + let labels: Vec<&str> = skill + .available_actions + .iter() + .map(|a| action_label(*a)) + .collect(); + lines.push(Line::from(labels.join(", "))); + } + + if !skill.warnings.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Warnings", + Style::default().fg(palette::STATUS_WARNING), + ))); + for w in &skill.warnings { + match w { + crate::skills::audit::SkillAuditWarning::Message(msg) => { + lines.push(Line::from(format!("• {msg}"))); + } + } + } + } + + if let Some(status) = &self.status { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + status.clone(), + Style::default().fg(palette::WHALE_ACTION), + ))); + } + + let scroll = self.detail_scroll.min(lines.len().saturating_sub(1)); + let visible: Vec = lines.into_iter().skip(scroll).collect(); + Paragraph::new(visible) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(palette::TEXT_PRIMARY)) + .render(inner, buf); + } +} + +fn scan_snapshot(app: &App, mode: ManagerMode) -> SkillAuditSnapshot { + scan_with_configured( + &app.workspace, + dirs::home_dir().as_deref(), + Some(&app.skills_dir), + mode.audit_mode(), + None, + ) +} + +fn known_digest(skill: &AuditedSkill) -> Option { + match &skill.digest { + DigestState::Known(d) => Some(d.clone()), + DigestState::Unknown(_) => None, + } +} + +fn scope_label(scope: SkillTargetScope) -> &'static str { + match scope { + SkillTargetScope::Project => "project", + SkillTargetScope::Global => "global", + } +} + +fn action_label(kind: SkillActionKind) -> &'static str { + match kind { + SkillActionKind::Install => "install", + SkillActionKind::Import => "import", + SkillActionKind::Update => "update", + SkillActionKind::Remove => "remove", + SkillActionKind::Trust => "trust", + } +} + +fn source_label(kind: SkillSourceKind) -> &'static str { + match kind { + SkillSourceKind::CodeWhaleManaged => "managed", + SkillSourceKind::CodeWhaleManual => "manual", + SkillSourceKind::CompatibleExternal => "external", + SkillSourceKind::BuiltIn => "built-in", + SkillSourceKind::ReviewedPluginSnapshot => "plugin", + SkillSourceKind::RegistryCache => "cache", + } +} + +fn precedence_label(state: &PrecedenceState) -> String { + match state { + PrecedenceState::Active => "active".into(), + PrecedenceState::ShadowedBy(id) => format!("shadowed:{}", id.canonical_name), + PrecedenceState::InactiveSource => "inactive".into(), + PrecedenceState::Unknown => "unknown".into(), + } +} + +fn integrity_label(state: &IntegrityState) -> &'static str { + match state { + IntegrityState::Healthy => "healthy", + IntegrityState::LocalContentDrift => "drift", + IntegrityState::BrokenManagedInstall => "broken", + IntegrityState::LegacyMetadataUnknown => "legacy", + IntegrityState::Unknown => "unknown", + } +} + +fn trust_label(state: &TrustState) -> &'static str { + match state { + TrustState::TrustedForDigest(_) => "trusted", + TrustState::TrustStale => "stale", + TrustState::LegacyAdvisory => "legacy", + TrustState::Untrusted => "untrusted", + TrustState::NotApplicable => "n/a", + TrustState::Unknown => "unknown", + } +} + +fn parser_label(state: &ParserState) -> String { + match state { + ParserState::Valid => "valid".into(), + ParserState::Warning(ws) => format!("warning({})", ws.len()), + ParserState::Broken(msg) => format!("broken:{msg}"), + ParserState::Oversized => "oversized".into(), + } +} + +fn digest_label(state: &DigestState) -> String { + match state { + DigestState::Known(d) => { + if d.len() > 12 { + format!("{}…", &d[..12]) + } else { + d.clone() + } + } + DigestState::Unknown(reason) => format!("unknown:{reason:?}"), + } +} + +fn provenance_label(state: &ProvenanceState) -> String { + match state { + ProvenanceState::Managed { spec, .. } => spec.clone().unwrap_or_else(|| "managed".into()), + ProvenanceState::Manual => "manual".into(), + ProvenanceState::External => "external".into(), + ProvenanceState::BuiltIn => "built-in".into(), + ProvenanceState::Plugin => "plugin".into(), + ProvenanceState::Cache => "cache".into(), + ProvenanceState::Unknown => "unknown".into(), + } +} + +fn kv_line(key: &str, value: &str) -> Line<'static> { + Line::from(vec![ + Span::styled( + format!("{key:<11}"), + Style::default().fg(palette::TEXT_MUTED), + ), + Span::raw(value.to_string()), + ]) +} + +impl ModalView for SkillsManagerView { + fn kind(&self) -> ModalKind { + ModalKind::SkillsManager + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + if self.pending.is_some() { + return match key.code { + KeyCode::Esc => { + self.pending = None; + self.status = Some("Cancelled.".into()); + ViewAction::None + } + KeyCode::Enter => self.confirm_pending(), + _ => ViewAction::None, + }; + } + + match key.code { + KeyCode::Esc => ViewAction::Close, + KeyCode::Char('q') | KeyCode::Char('Q') if key.modifiers.is_empty() => { + ViewAction::Close + } + KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('K') => { + self.move_sel(-1); + ViewAction::None + } + KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('J') => { + self.move_sel(1); + ViewAction::None + } + KeyCode::PageUp => { + self.detail_scroll = self.detail_scroll.saturating_sub(8); + ViewAction::None + } + KeyCode::PageDown => { + self.detail_scroll = self.detail_scroll.saturating_add(8); + ViewAction::None + } + KeyCode::Home => { + self.detail_scroll = 0; + ViewAction::None + } + KeyCode::Enter => self.confirm_pending(), + KeyCode::Char('i') | KeyCode::Char('I') if key.modifiers.is_empty() => { + self.emit_action(SkillActionKind::Import) + } + KeyCode::Char('u') | KeyCode::Char('U') if key.modifiers.is_empty() => { + self.emit_action(SkillActionKind::Update) + } + KeyCode::Char('r') | KeyCode::Char('R') if key.modifiers.is_empty() => { + self.emit_action(SkillActionKind::Remove) + } + KeyCode::Char('t') | KeyCode::Char('T') if key.modifiers.is_empty() => { + self.emit_action(SkillActionKind::Trust) + } + KeyCode::Char('c') | KeyCode::Char('C') if key.modifiers.is_empty() => { + ViewAction::Emit(ViewEvent::SkillsManagerToggleCompatible) + } + KeyCode::Char('s') | KeyCode::Char('S') if key.modifiers.is_empty() => { + self.cycle_import_scope(); + ViewAction::None + } + _ => ViewAction::None, + } + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + Clear.render(area, buf); + let body = render_underwater_surface(area, buf, "Skills Manager"); + let hints = self.footer_hints(); + let content = render_modal_footer(body, buf, &hints); + + let header_h = 2u16.min(content.height); + let header = Rect { + x: content.x, + y: content.y, + width: content.width, + height: header_h, + }; + let mode_line = format!( + " scan={} import-target={} {}", + self.mode.label(), + scope_label(self.import_scope), + self.status + .as_deref() + .unwrap_or("j/k move · actions in footer") + ); + buf.set_stringn( + header.x, + header.y, + truncate_view_text(&mode_line, usize::from(header.width)), + usize::from(header.width), + Style::default().fg(palette::TEXT_SECONDARY), + ); + + let panel = Rect { + x: content.x, + y: content.y.saturating_add(header_h), + width: content.width, + height: content.height.saturating_sub(header_h), + }; + let layout = ListDetailLayout::split(panel, 34); + self.render_list(layout.list, buf); + self.render_detail(layout.detail, buf); + } +} + +/// Apply scan-mode toggle using the live app workspace (host-driven). +pub fn apply_toggle_compatible(view: &mut SkillsManagerView, app: &App) { + view.toggle_mode(app); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::{App, TuiOptions}; + use crossterm::event::{KeyEventKind, KeyModifiers}; + use std::ffi::OsString; + use std::fs; + use tempfile::TempDir; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent { + code, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: crossterm::event::KeyEventState::NONE, + } + } + + struct IsolatedHome { + _lock: crate::test_support::TestEnvLock, + home_prev: Option, + userprofile_prev: Option, + } + + impl IsolatedHome { + fn new(tmpdir: &TempDir) -> Self { + let lock = crate::test_support::lock_test_env(); + let home = tmpdir.path().join("home"); + fs::create_dir_all(&home).unwrap(); + let home_prev = std::env::var_os("HOME"); + let userprofile_prev = std::env::var_os("USERPROFILE"); + // SAFETY: serialized by TestEnvLock in this crate's tests. + unsafe { + std::env::set_var("HOME", &home); + std::env::set_var("USERPROFILE", &home); + } + Self { + _lock: lock, + home_prev, + userprofile_prev, + } + } + } + + impl Drop for IsolatedHome { + fn drop(&mut self) { + unsafe { + match &self.home_prev { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + match &self.userprofile_prev { + Some(v) => std::env::set_var("USERPROFILE", v), + None => std::env::remove_var("USERPROFILE"), + } + } + } + } + + fn app_in(tmp: &TempDir) -> App { + let workspace = tmp.path().join("ws"); + fs::create_dir_all(&workspace).unwrap(); + let options = TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace, + config_path: None, + config_profile: None, + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 1, + skills_dir: tmp.path().join("skills"), + memory_path: tmp.path().join("memory.md"), + notes_path: tmp.path().join("notes.txt"), + mcp_config_path: tmp.path().join("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }; + App::new(options, &Config::default()) + } + + #[test] + fn opens_owned_scan_and_closes_on_esc() { + let tmp = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmp); + let app = app_in(&tmp); + let mut view = SkillsManagerView::new(&app); + assert_eq!(view.mode, ManagerMode::OwnedOnly); + assert_eq!(view.kind(), ModalKind::SkillsManager); + assert!(matches!( + view.handle_key(key(KeyCode::Esc)), + ViewAction::Close + )); + } + + #[test] + fn remove_requires_confirm_then_emits() { + let tmp = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmp); + let workspace = tmp.path().join("ws"); + let skill_dir = workspace.join(".codewhale").join("skills").join("demo"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: demo\ndescription: d\n---\nbody\n", + ) + .unwrap(); + let digest = crate::skills::audit::compute_package_digest(&skill_dir).unwrap(); + crate::skills::install::write_installed_from_v2( + &skill_dir, + "github:o/r", + None, + "src", + &digest, + "demo", + ) + .unwrap(); + + let mut app = app_in(&tmp); + app.workspace = workspace; + let mut view = SkillsManagerView::new(&app); + assert!(!view.skills.is_empty()); + let action = view.handle_key(key(KeyCode::Char('r'))); + assert!(matches!(action, ViewAction::None)); + assert!(view.pending.is_some()); + let action = view.handle_key(key(KeyCode::Enter)); + assert!(matches!( + action, + ViewAction::Emit(ViewEvent::SkillMutationRequested { + request: SkillMutationRequest::Remove { .. }, + }) + )); + } + + #[test] + fn render_fits_narrow_terminal() { + let tmp = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmp); + let app = app_in(&tmp); + let view = SkillsManagerView::new(&app); + let area = Rect::new(0, 0, 80, 24); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + let mut found = false; + for y in 0..area.height { + let mut row = String::new(); + for x in 0..area.width { + row.push( + buf.cell((x, y)) + .map(|c| c.symbol().chars().next().unwrap_or(' ')) + .unwrap_or(' '), + ); + } + if row.contains("Skills") { + found = true; + break; + } + } + assert!(found, "expected Skills title on 80x24 surface"); + } + + #[test] + fn import_replace_confirm_is_scoped_to_import_target() { + let tmp = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmp); + let workspace = tmp.path().join("ws"); + let home = tmp.path().join("home"); + fs::create_dir_all(&home).unwrap(); + + // Project-owned conflict only — Global import must not prompt replace. + write_skill_pkg( + &workspace.join(".codewhale").join("skills").join("shared"), + "shared", + "owned-project", + ); + write_skill_pkg( + &workspace.join(".claude").join("skills").join("shared"), + "shared", + "external-different", + ); + + let mut app = app_in(&tmp); + app.workspace = workspace; + // Force HOME for scan so global root is the isolated home. + let mut view = SkillsManagerView::from_scan( + &app, + ManagerMode::Compatible, + SkillTargetScope::Global, + None, + None, + ); + // Select the external row. + let ext_idx = view + .skills + .iter() + .position(|s| s.source_kind == SkillSourceKind::CompatibleExternal) + .expect("external skill"); + view.selected = ext_idx; + + let action = view.handle_key(key(KeyCode::Char('i'))); + assert!( + matches!( + action, + ViewAction::Emit(ViewEvent::SkillMutationRequested { + request: SkillMutationRequest::ImportExternal { + conflict_policy: ConflictPolicy::Reject, + .. + }, + }) + ), + "global import must not treat project-owned peer as replace: {action:?}" + ); + assert!(view.pending.is_none()); + + view.import_scope = SkillTargetScope::Project; + let action = view.handle_key(key(KeyCode::Char('i'))); + assert!(matches!(action, ViewAction::None)); + assert!( + matches!(view.pending, Some(PendingConfirm::ImportReplace { .. })), + "project import should confirm replace against project-owned peer" + ); + assert!(home.exists()); + } + + #[test] + fn compatible_scan_includes_configured_skills_dir() { + let tmp = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmp); + let workspace = tmp.path().join("ws"); + let configured = tmp.path().join("custom-skills"); + write_skill_pkg(&configured.join("custom-one"), "custom-one", "from-config"); + + let mut app = app_in(&tmp); + app.workspace = workspace; + app.skills_dir = configured; + + let view = SkillsManagerView::from_scan( + &app, + ManagerMode::Compatible, + SkillTargetScope::Global, + None, + None, + ); + assert!( + view.skills.iter().any(|s| s.name == "custom-one"), + "configured skills_dir rows must appear in compatible scan" + ); + } + + fn write_skill_pkg(dir: &std::path::Path, name: &str, body: &str) { + fs::create_dir_all(dir).unwrap(); + fs::write( + dir.join("SKILL.md"), + format!("---\nname: {name}\ndescription: d\n---\n{body}\n"), + ) + .unwrap(); + } +} diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 35315eed09..827245d71e 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -36,7 +36,8 @@ use crate::palette; use crate::provider_lake::all_catalog_models_for_provider; use crate::tui::app::{App, AppMode, ComposerDensity, VimMode}; use crate::tui::approval::{ - ApprovalRequest, ApprovalView, ElevationOption, ElevationRequest, RiskLevel, ToolCategory, + ApprovalMode, ApprovalRequest, ApprovalView, ElevationOption, ElevationRequest, RiskLevel, + ToolCategory, }; use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolRun, ToolStatus}; use crate::tui::scrolling::TranscriptLineMeta; @@ -439,21 +440,25 @@ impl ChatWidget { ); } - // Brief flash highlight on the most recently sent user message. - if !app.low_motion - && let Some(send_at) = app.last_send_at - { - if send_at.elapsed() < SEND_FLASH_DURATION { - apply_send_flash( - &mut lines, - top, - &app.history, - line_meta, - &app.collapsed_cell_map, - ); - } else { - app.last_send_at = None; + // Brief flash highlight on the most recently sent user message. It is + // a one-shot transition, so Reduced/Still clear the timestamp instead + // of leaving a stale flash waiting for a later state-change redraw. + if app.motion_policy().allows_decorative() { + if let Some(send_at) = app.last_send_at { + if send_at.elapsed() < SEND_FLASH_DURATION { + apply_send_flash( + &mut lines, + top, + &app.history, + line_meta, + &app.collapsed_cell_map, + ); + } else { + app.last_send_at = None; + } } + } else { + app.last_send_at = None; } if let Some(target_cell) = detail_target_cell { @@ -1042,6 +1047,16 @@ fn render_jump_to_latest_button( } const COMPOSER_PROMPT_GUTTER_WIDTH: u16 = 2; +const COMPOSER_PANEL_MIN_WIDTH: u16 = 12; + +/// Whether the outer composer rect can carry both semantic border rows. +/// +/// Keep this policy in outer-area coordinates. Input wrapping subtracts the +/// prompt gutter later; using that narrower text width here made 12- and +/// 13-column composers render as panels after reserving only the quiet rule. +fn enclosed_composer_panel_fits(show_panel: bool, area_width: u16, area_height: u16) -> bool { + show_panel && area_height >= 3 && area_width >= COMPOSER_PANEL_MIN_WIDTH +} /// Canonical horizontal geometry for composer input text. /// @@ -1165,13 +1180,10 @@ impl<'a> ComposerWidget<'a> { fn wants_enclosed_panel(&self) -> bool { self.app.composer_border - && (self.app.is_history_search_active() - || self.app.composer_display_input().contains('\n') - || self.active_menu_row_count() > 0) } pub(crate) fn has_panel(&self, area: Rect) -> bool { - self.wants_enclosed_panel() && area.height >= 3 && area.width >= 12 + enclosed_composer_panel_fits(self.wants_enclosed_panel(), area.width, area.height) } fn inner_area(&self, area: Rect) -> Rect { @@ -1238,11 +1250,6 @@ impl Renderable for ComposerWidget<'_> { ); let is_draft_mode = input_text.contains('\n') || visible_lines.len() > 1; if has_panel { - let border_color = if input_text.trim().is_empty() { - palette::BORDER_COLOR - } else { - self.mode_color() - }; let hint_line = if self.app.is_history_search_active() { Some(Line::from(vec![ Span::styled( @@ -1339,19 +1346,27 @@ impl Renderable for ComposerWidget<'_> { None }; - let mut block = Block::default() - .borders(Borders::TOP | Borders::BOTTOM) - .border_style(Style::default().fg(border_color)) + // Warm permission ramp: Ask is amber, Auto-Review is Signal Gold, + // and Full Access is coral. The bottom edge independently carries + // the cool Plan -> Act -> Operate mode ramp. + let permission_color = match self.app.approval_mode { + ApprovalMode::Suggest | ApprovalMode::Never => palette::TEXT_REASONING, + ApprovalMode::Auto => palette::WHALE_HUMAN, + ApprovalMode::Bypass => palette::STATUS_WARNING, + }; + let mut top_border = Block::default() + .borders(Borders::TOP) + .border_style(Style::default().fg(permission_color)) .style(background); if self.app.is_history_search_active() || is_draft_mode { - block = if self.app.is_history_search_active() { - block.title(Line::from(Span::styled( + top_border = if self.app.is_history_search_active() { + top_border.title(Line::from(Span::styled( self.app .tr(crate::localization::MessageId::HistorySearchTitle), Style::default().fg(palette::TEXT_MUTED), ))) } else { - block.title(Line::from(Span::styled( + top_border.title(Line::from(Span::styled( "Draft", Style::default().fg(palette::TEXT_MUTED), ))) @@ -1363,12 +1378,18 @@ impl Renderable for ComposerWidget<'_> { if self.app.ocean_treatment.is_classic() && let Some(chrome) = composer_top_right_chrome(self.app, area.width) { - block = block.title_top(chrome.right_aligned()); + top_border = top_border.title_top(chrome.right_aligned()); } + top_border.render(area, buf); + + let mut bottom_border = Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(self.mode_color())) + .style(background); if let Some(hint_line) = hint_line { - block = block.title_bottom(hint_line); + bottom_border = bottom_border.title_bottom(hint_line); } - block.render(area, buf); + bottom_border.render(area, buf); } else if area.height >= 2 { let mut block = Block::default() .borders(Borders::TOP) @@ -1741,7 +1762,7 @@ impl Renderable for ComposerWidget<'_> { fn desired_height(&self, width: u16) -> u16 { composer_height( self.app.composer_display_input(), - width.saturating_sub(2), + width, self.max_height.min(self.max_height_cap()), self.active_menu_reserved_rows(), self.app.composer_density, @@ -3437,14 +3458,18 @@ fn composer_max_height(density: ComposerDensity) -> u16 { fn composer_height( input: &str, - width: u16, + area_width: u16, available_height: u16, extra_lines: usize, density: ComposerDensity, show_panel: bool, ) -> u16 { - let has_panel = show_panel && available_height >= 3 && width >= 12; - let content_width = usize::from(width.max(1)); + let has_panel = enclosed_composer_panel_fits(show_panel, area_width, available_height); + let content_width = usize::from( + area_width + .saturating_sub(COMPOSER_PROMPT_GUTTER_WIDTH) + .max(1), + ); let mut line_count = wrap_input_lines(input, content_width).len(); if line_count == 0 { line_count = 1; @@ -3579,7 +3604,7 @@ pub(crate) fn slash_completion_hints_with_model_candidates( command_key, &prefix_lower, locale, - &user_commands, + &all_user_commands, ); } @@ -3608,7 +3633,7 @@ pub(crate) fn slash_completion_hints_with_model_candidates( cmd.name, &prefix_lower, locale, - &user_commands, + &all_user_commands, ); } } @@ -3626,7 +3651,7 @@ pub(crate) fn slash_completion_hints_with_model_candidates( &cmd.name, &prefix_lower, locale, - &user_commands, + &all_user_commands, ); } } @@ -3655,7 +3680,7 @@ pub(crate) fn slash_completion_hints_with_model_candidates( cmd.name, &prefix_lower, locale, - &user_commands, + &all_user_commands, ); } } @@ -3676,7 +3701,7 @@ pub(crate) fn slash_completion_hints_with_model_candidates( &cmd.name, &prefix_lower, locale, - &user_commands, + &all_user_commands, ); } } @@ -3896,11 +3921,9 @@ fn push_command_entry( .description .clone() .unwrap_or_else(|| String::from("User-defined command")); - if let Some(hint) = &command.argument_hint - && !hint.trim().is_empty() - { + if let Some(hint) = command.display_usage() { description.push_str(" "); - description.push_str(hint.trim()); + description.push_str(hint); } let alias_hint = if !command_key.to_ascii_lowercase().starts_with(prefix_lower) { command @@ -3917,24 +3940,29 @@ fn push_command_entry( }; (description, alias_hint) } else if let Some(info) = commands::get_command_info(command_key) { + let unshadowed_aliases = info + .aliases + .iter() + .copied() + .filter(|alias| !user_command_shadows_builtin_alias(alias, user_commands)) + .collect::>(); let hint = if !command_key.to_ascii_lowercase().starts_with(prefix_lower) { - info.aliases + unshadowed_aliases .iter() + .copied() .find(|a| { a.to_ascii_lowercase().starts_with(prefix_lower) || a.to_ascii_lowercase().contains(prefix_lower) || fuzzy_chars_in_order(prefix_lower, &a.to_ascii_lowercase()) }) - .map(|a| a.to_string()) + .map(str::to_string) } else { None }; // Omit aliases already shown in the label (`/clear or /qingping`) so // the description does not repeat them (#3990). - let remaining_aliases: Vec<&str> = info - .aliases - .iter() - .copied() + let remaining_aliases: Vec<&str> = unshadowed_aliases + .into_iter() .filter(|alias| hint.as_deref() != Some(*alias)) .collect(); let desc = if remaining_aliases.is_empty() { @@ -4255,17 +4283,17 @@ fn line_spans_with_selection<'a>( #[cfg(test)] mod tests { use super::{ - ACTIVE_REVISION_DOMAIN, ApprovalWidget, COMPOSER_PANEL_HEIGHT, COMPOSER_PLACEHOLDER, - ChatWidget, ComposerWidget, Renderable, SlashMenuEntry, active_entry_revision, - ambient_ping_pong, apply_detail_target_highlight, apply_selection_to_line, - apply_send_flash, approval_palette, approval_truncation_hint, build_empty_state_lines, - composer_content_geometry, composer_empty_hint_text, composer_height, composer_max_height, - composer_min_input_rows, composer_top_padding, cursor_row_col, empty_composer_visual_rows, - fish_flee_offset, fish_heading, fish_mark, history_entry_revision, layout_input, - layout_input_with_scroll, pad_lines_to_bottom, placeholder_visual_lines, - push_command_entry, receipt_is_settling, revision_in_domain, should_render_empty_state, - slash_completion_hints, tool_run_summary_revision, wrap_input_lines, - wrap_input_lines_for_mouse, wrap_text, + ACTIVE_REVISION_DOMAIN, ApprovalMode, ApprovalWidget, COMPOSER_PANEL_HEIGHT, + COMPOSER_PLACEHOLDER, COMPOSER_PROMPT_GUTTER_WIDTH, ChatWidget, ComposerWidget, Renderable, + SlashMenuEntry, active_entry_revision, ambient_ping_pong, apply_detail_target_highlight, + apply_selection_to_line, apply_send_flash, approval_palette, approval_truncation_hint, + build_empty_state_lines, composer_content_geometry, composer_empty_hint_text, + composer_height, composer_max_height, composer_min_input_rows, composer_top_padding, + cursor_row_col, empty_composer_visual_rows, enclosed_composer_panel_fits, fish_flee_offset, + fish_heading, fish_mark, history_entry_revision, layout_input, layout_input_with_scroll, + pad_lines_to_bottom, placeholder_visual_lines, push_command_entry, receipt_is_settling, + revision_in_domain, should_render_empty_state, slash_completion_hints, + tool_run_summary_revision, wrap_input_lines, wrap_input_lines_for_mouse, wrap_text, }; use crate::config::{ApiProvider, Config}; use crate::localization::Locale; @@ -4911,6 +4939,70 @@ mod tests { assert!(hints.iter().any(|hint| hint.name == "/links")); } + #[test] + fn slash_completion_hints_discover_debt_from_compat_aliases() { + for alias in ["slop", "canzha"] { + let hints = slash_completion_hints( + &format!("/{alias}"), + 128, + &[], + Locale::En, + None, + ApiProvider::Deepseek, + ); + let debt = hints + .iter() + .find(|hint| hint.name == "/debt") + .unwrap_or_else(|| panic!("/debt should appear for /{alias}")); + assert_eq!(debt.alias_hint.as_deref(), Some(alias)); + } + } + + #[test] + fn slash_completion_debt_aliases_prefer_user_command_shadows() { + let tmp = tempfile::TempDir::new().unwrap(); + let commands_dir = tmp.path().join(".codewhale").join("commands"); + std::fs::create_dir_all(&commands_dir).unwrap(); + std::fs::write( + commands_dir.join("slop.md"), + "---\ndescription: User slop workflow\n---\ncustom slop", + ) + .unwrap(); + std::fs::write( + commands_dir.join("review.md"), + "---\ndescription: User canzha workflow\nalias: canzha\n---\ncustom canzha", + ) + .unwrap(); + + for (alias, canonical, description, alias_hint) in [ + ("slop", "/slop", "User slop workflow", None), + ("canzha", "/review", "User canzha workflow", Some("canzha")), + ] { + let hints = slash_completion_hints( + &format!("/{alias}"), + 128, + &[], + Locale::En, + Some(tmp.path()), + ApiProvider::Deepseek, + ); + let user_command = hints + .iter() + .find(|hint| hint.name == canonical) + .unwrap_or_else(|| panic!("{canonical} should own /{alias} completion")); + assert_eq!(user_command.description, description); + assert_eq!(user_command.alias_hint.as_deref(), alias_hint); + let hint_names = hints + .iter() + .map(|hint| hint.name.as_str()) + .collect::>(); + assert!( + !hints.iter().any(|hint| hint.name == "/debt"), + "/debt must not complete through user-shadowed /{alias}: {hint_names:?}" + ); + } + } + #[test] fn slash_completion_hints_rank_exact_alias_above_prefix_alias() { // `/q` should rank `/exit` (exact alias `q`) above `/clear` (alias @@ -5071,6 +5163,61 @@ mod tests { assert_eq!(entry.description, "Deploy target "); } + #[test] + fn slash_completion_uses_frontmatter_name_and_usage() { + let tmp = tempfile::TempDir::new().unwrap(); + let commands_dir = tmp.path().join(".codewhale").join("commands"); + std::fs::create_dir_all(&commands_dir).unwrap(); + std::fs::write( + commands_dir.join("workflow-file.md"), + "---\nname: inspect\ndescription: Inspect target\nusage: /inspect \narguments: \n---\ninspect", + ) + .unwrap(); + + let hints = slash_completion_hints( + "/ins", + 128, + &[], + Locale::En, + Some(tmp.path()), + ApiProvider::Deepseek, + ); + let entry = hints + .iter() + .find(|hint| hint.name == "/inspect") + .expect("frontmatter name should complete"); + + assert_eq!(entry.description, "Inspect target /inspect "); + assert!(!hints.iter().any(|hint| hint.name == "/workflow-file")); + } + + #[test] + fn slash_completion_uses_arguments_when_usage_and_legacy_hint_are_absent() { + let tmp = tempfile::TempDir::new().unwrap(); + let commands_dir = tmp.path().join(".codewhale").join("commands"); + std::fs::create_dir_all(&commands_dir).unwrap(); + std::fs::write( + commands_dir.join("deploy.md"), + "---\ndescription: Deploy target\narguments: \n---\ndeploy", + ) + .unwrap(); + + let hints = slash_completion_hints( + "/deploy", + 128, + &[], + Locale::En, + Some(tmp.path()), + ApiProvider::Deepseek, + ); + let entry = hints + .iter() + .find(|hint| hint.name == "/deploy") + .expect("custom command should be present"); + + assert_eq!(entry.description, "Deploy target "); + } + #[test] fn slash_completion_hints_exclude_hidden_user_commands() { let tmp = tempfile::TempDir::new().unwrap(); @@ -5094,6 +5241,29 @@ mod tests { assert!(!hints.iter().any(|hint| hint.name == "/secret")); } + #[test] + fn hidden_name_override_filters_shadowed_builtin_from_slash_completion() { + let tmp = tempfile::TempDir::new().unwrap(); + let commands_dir = tmp.path().join(".codewhale").join("commands"); + std::fs::create_dir_all(&commands_dir).unwrap(); + std::fs::write( + commands_dir.join("private-help.md"), + "---\nname: help\nhidden: true\n---\nprivate help", + ) + .unwrap(); + + let hints = slash_completion_hints( + "/help", + 128, + &[], + Locale::En, + Some(tmp.path()), + ApiProvider::Deepseek, + ); + + assert!(!hints.iter().any(|hint| hint.name == "/help")); + } + #[test] fn slash_completion_hints_match_user_command_aliases() { let tmp = tempfile::TempDir::new().unwrap(); @@ -5122,6 +5292,38 @@ mod tests { assert_eq!(entry.description, "Deploy target"); } + #[test] + fn slash_completion_omits_rejected_user_alias_collisions() { + let tmp = tempfile::TempDir::new().unwrap(); + let commands_dir = tmp.path().join(".codewhale").join("commands"); + std::fs::create_dir_all(&commands_dir).unwrap(); + std::fs::write( + commands_dir.join("alpha.md"), + "---\ndescription: Alpha command\nalias: beta\n---\nalpha", + ) + .unwrap(); + std::fs::write( + commands_dir.join("beta.md"), + "---\ndescription: Beta command\n---\nbeta", + ) + .unwrap(); + + let hints = slash_completion_hints( + "/bet", + 128, + &[], + Locale::En, + Some(tmp.path()), + ApiProvider::Deepseek, + ); + + assert!(hints.iter().any(|hint| hint.name == "/beta")); + assert!( + !hints.iter().any(|hint| hint.name == "/alpha"), + "a command must not match through an alias rejected by the registry" + ); + } + #[test] fn slash_completion_hints_keep_builtin_canonical_when_only_builtin_alias_is_shadowed() { let tmp = tempfile::TempDir::new().unwrap(); @@ -5142,9 +5344,15 @@ mod tests { ApiProvider::Deepseek, ); + let attach = canonical_hints + .iter() + .find(|hint| hint.name == "/attach") + .expect( + "canonical /attach should remain visible when only its /image alias is shadowed", + ); assert!( - canonical_hints.iter().any(|hint| hint.name == "/attach"), - "canonical /attach should remain visible when only its /image alias is shadowed" + !attach.description.contains("/image"), + "canonical completion must not advertise a user-shadowed alias" ); let alias_hints = slash_completion_hints( @@ -5166,6 +5374,41 @@ mod tests { ); } + #[test] + fn slash_completion_hints_hide_shadowed_debt_aliases_from_canonical_copy() { + let tmp = tempfile::TempDir::new().unwrap(); + let commands_dir = tmp.path().join(".codewhale").join("commands"); + std::fs::create_dir_all(&commands_dir).unwrap(); + std::fs::write( + commands_dir.join("slop.md"), + "---\ndescription: Internal cleanup\nhidden: true\n---\ninternal cleanup", + ) + .unwrap(); + std::fs::write( + commands_dir.join("custom-debt.md"), + "---\ndescription: Custom debt flow\nalias: canzha\n---\ncustom debt", + ) + .unwrap(); + + let hints = slash_completion_hints( + "/debt", + 128, + &[], + Locale::En, + Some(tmp.path()), + ApiProvider::Deepseek, + ); + let debt = hints + .iter() + .find(|hint| hint.name == "/debt") + .expect("canonical /debt should remain visible when only aliases are shadowed"); + + assert_eq!(debt.alias_hint, None); + assert!(debt.description.contains("/cleanup")); + assert!(!debt.description.contains("/slop")); + assert!(!debt.description.contains("/canzha")); + } + #[test] fn slash_completion_hints_prefer_user_metadata_for_shadowed_builtin() { let tmp = tempfile::TempDir::new().unwrap(); @@ -5381,17 +5624,13 @@ mod tests { ComposerDensity::Comfortable, true, ); - let has_panel = available_height >= 3 && width >= 12; + let has_panel = enclosed_composer_panel_fits(true, width, available_height); let chrome_height = if has_panel { usize::from(COMPOSER_PANEL_HEIGHT) } else { - 0 - }; - let content_width = if has_panel { - usize::from(width.saturating_sub(2).max(1)) - } else { - usize::from(width.max(1)) + 1 }; + let content_width = usize::from(width.saturating_sub(COMPOSER_PROMPT_GUTTER_WIDTH).max(1)); let input_height_budget = usize::from(height) .saturating_sub(menu_lines) .saturating_sub(chrome_height) @@ -5416,6 +5655,52 @@ mod tests { assert_eq!(height, 5); } + #[test] + fn composer_panel_height_and_render_policy_agree_at_width_boundary() { + let mut app = create_test_app(); + app.composer_border = true; + app.composer_density = ComposerDensity::Comfortable; + let slash_menu_entries = Vec::::new(); + let mention_menu_entries = Vec::::new(); + let widget = ComposerWidget::new(&app, 8, &slash_menu_entries, &mention_menu_entries); + + for (width, expected_panel, expected_height) in + [(11, false, 4), (12, true, 5), (13, true, 5), (14, true, 5)] + { + let height = widget.desired_height(width); + let area = Rect::new(0, 0, width, height); + + assert_eq!(height, expected_height, "width={width}"); + assert_eq!(widget.has_panel(area), expected_panel, "width={width}"); + assert_eq!( + widget.inner_area(area).height, + 3, + "width={width} must reserve every rendered border row" + ); + + let mut buf = Buffer::empty(area); + widget.render(area, &mut buf); + assert_eq!( + buf[(1, area.bottom().saturating_sub(1))].symbol() == "\u{2500}", + expected_panel, + "width={width} bottom border disagrees with height policy" + ); + } + } + + #[test] + fn composer_expands_for_multiline_input_and_collapses_again() { + let height_for = + |input| composer_height(input, 40, 12, 0, ComposerDensity::Comfortable, true); + + let collapsed = height_for("short"); + let expanded = height_for("one\ntwo\nthree\nfour\nfive\nsix"); + let collapsed_again = height_for("short"); + + assert!(expanded > collapsed); + assert_eq!(collapsed_again, collapsed); + } + #[test] fn composer_height_uses_quiet_rule_when_panel_is_not_needed() { let with_border = composer_height("", 40, 8, 0, ComposerDensity::Comfortable, true); @@ -5492,16 +5777,15 @@ mod tests { height: 5, }; - // Normal one-line composition uses only the top rule, preserving the - // reference's continuous water field instead of drawing a full box. - // inner_area: {x:0, y:1, w:40, h:4} - // input_rows_budget = 4 + // The two border rows carry independent permission/mode signals. + // inner_area: {x:0, y:1, w:40, h:3} + // input_rows_budget = 3 // The prompt and hint share one quiet row. assert_eq!( - empty_composer_visual_rows(Some(COMPOSER_PLACEHOLDER), 40, 4), + empty_composer_visual_rows(Some(COMPOSER_PLACEHOLDER), 40, 3), 1 ); - assert_eq!(widget.cursor_pos(area), Some((2, 3))); + assert_eq!(widget.cursor_pos(area), Some((2, 2))); } #[test] @@ -5520,17 +5804,17 @@ mod tests { height: 5, }; - // inner_area: {x:0, y:1, w:14, h:4} - // input_rows_budget = 4 + // inner_area: {x:0, y:1, w:14, h:3} + // input_rows_budget = 3 // placeholder_visual_lines(14) = 2 // The narrow fallback still reserves one composer row; Paragraph // clipping keeps it from growing the shell. assert_eq!(placeholder_visual_lines(14), 2); assert_eq!( - empty_composer_visual_rows(Some(COMPOSER_PLACEHOLDER), 14, 4), + empty_composer_visual_rows(Some(COMPOSER_PLACEHOLDER), 14, 3), 1 ); - assert_eq!(widget.cursor_pos(area), Some((2, 3))); + assert_eq!(widget.cursor_pos(area), Some((2, 2))); } #[test] @@ -5642,6 +5926,63 @@ mod tests { assert!(rendered.contains("tool(s) used")); } + #[test] + fn composer_border_edges_encode_warm_permission_and_cool_mode_ramps() { + let slash_menu_entries = Vec::::new(); + let mention_menu_entries = Vec::::new(); + let area = Rect::new(0, 0, 40, 5); + + // Shift-Tab cycle order must stay amber -> Signal Gold -> coral. + for (approval_mode, expected) in [ + (ApprovalMode::Suggest, palette::TEXT_REASONING), + (ApprovalMode::Auto, palette::WHALE_HUMAN), + (ApprovalMode::Bypass, palette::STATUS_WARNING), + ] { + let mut app = create_test_app(); + app.approval_mode = approval_mode; + let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries); + let mut buf = Buffer::empty(area); + + widget.render(area, &mut buf); + + assert_eq!(buf[(1, area.top())].fg, expected, "{approval_mode:?}"); + } + + // Never is a fail-closed Ask-family posture, so it keeps Ask amber and + // never enters the three-step user-facing permission cycle. + let mut never_app = create_test_app(); + never_app.approval_mode = ApprovalMode::Never; + let never_widget = + ComposerWidget::new(&never_app, 5, &slash_menu_entries, &mention_menu_entries); + let mut never_buf = Buffer::empty(area); + never_widget.render(area, &mut never_buf); + assert_eq!( + never_buf[(1, area.top())].fg, + palette::TEXT_REASONING, + "Never must keep the fail-closed Ask-family amber" + ); + + // The bottom edge remains the independent icy -> blue -> violet ramp. + for (mode, expected) in [ + (AppMode::Plan, palette::MODE_PLAN), + (AppMode::Agent, palette::MODE_AGENT), + (AppMode::Operate, palette::MODE_OPERATE), + ] { + let mut app = create_test_app(); + app.mode = mode; + let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries); + let mut buf = Buffer::empty(area); + + widget.render(area, &mut buf); + + assert_eq!( + buf[(1, area.bottom().saturating_sub(1))].fg, + expected, + "{mode:?}" + ); + } + } + #[test] fn composer_border_keeps_mode_titles_contextual() { let slash_menu_entries = Vec::::new(); @@ -5889,6 +6230,38 @@ mod tests { assert!(!should_render_empty_state(&app)); } + #[test] + fn reduced_and_still_modes_clear_the_one_shot_send_flash() { + for (low_motion, fancy_animations) in [(true, true), (false, false)] { + let mut app = create_test_app(); + app.low_motion = low_motion; + app.fancy_animations = fancy_animations; + app.last_send_at = Some(Instant::now()); + app.add_message(HistoryCell::User { + content: "semantic receipt".to_string(), + }); + + let _widget = ChatWidget::new(&mut app, Rect::new(0, 0, 100, 20)); + assert!( + app.last_send_at.is_none(), + "non-full motion must not retain a time-based flash" + ); + } + + let mut full = create_test_app(); + full.low_motion = false; + full.fancy_animations = true; + full.last_send_at = Some(Instant::now()); + full.add_message(HistoryCell::User { + content: "animated receipt".to_string(), + }); + let _widget = ChatWidget::new(&mut full, Rect::new(0, 0, 100, 20)); + assert!( + full.last_send_at.is_some(), + "full motion should retain the active send-flash window" + ); + } + #[test] fn empty_state_shows_startup_context() { let mut app = create_test_app(); diff --git a/crates/tui/src/tui/widgets/tool_card.rs b/crates/tui/src/tui/widgets/tool_card.rs index da0120e141..4ed198d15a 100644 --- a/crates/tui/src/tui/widgets/tool_card.rs +++ b/crates/tui/src/tui/widgets/tool_card.rs @@ -79,9 +79,8 @@ pub fn tool_family_for_title(title: &str) -> ToolFamily { #[must_use] pub fn tool_family_for_name(name: &str) -> ToolFamily { match name { - "read_file" | "list_dir" | "view_image" | "git_log" | "git_show" | "git_blame" => { - ToolFamily::Read - } + "read_file" | "list_dir" | "view_image" | "git_status" | "git_diff" | "git_log" + | "git_show" | "git_blame" => ToolFamily::Read, "edit_file" | "apply_patch" | "write_file" => ToolFamily::Patch, "exec_shell" | "exec_shell_wait" @@ -105,6 +104,16 @@ pub fn tool_family_for_name(name: &str) -> ToolFamily { } } +/// Resolve an action-parameterized model tool before assigning its visual +/// family. Legacy names pass through unchanged. +#[cfg(test)] +#[must_use] +pub fn tool_family_for_call(name: &str, input: &serde_json::Value) -> ToolFamily { + tool_family_for_name(crate::tools::canonical_action::canonical_action_alias( + name, input, + )) +} + /// User-facing label for an arbitrary tool name. Known tools collapse to the /// semantic verb; unknown tools keep their exact name for debugging. #[cfg(test)] @@ -333,10 +342,11 @@ pub fn rail_glyph(rail: CardRail) -> &'static str { mod tests { use super::{ CardRail, ToolFamily, family_glyph, family_label, rail_glyph, tool_activity_label_for_name, - tool_display_label_for_name, tool_family_for_name, tool_family_for_title, - tool_header_summary_for_name, + tool_display_label_for_name, tool_family_for_call, tool_family_for_name, + tool_family_for_title, tool_header_summary_for_name, }; use crate::localization::{Locale, MessageId, tr}; + use serde_json::json; #[test] fn legacy_titles_route_to_expected_families() { @@ -371,6 +381,41 @@ mod tests { ); } + #[test] + fn canonical_actions_route_to_the_same_families_as_legacy_aliases() { + let cases = [ + ("Bash", "run", ToolFamily::Run), + ("Bash", "wait", ToolFamily::Run), + ("Bash", "interact", ToolFamily::Run), + ("Bash", "cancel", ToolFamily::Run), + ("File", "read", ToolFamily::Read), + ("File", "list", ToolFamily::Read), + ("File", "search_name", ToolFamily::Find), + ("File", "search_content", ToolFamily::Find), + ("File", "write", ToolFamily::Patch), + ("File", "edit", ToolFamily::Patch), + ("File", "patch", ToolFamily::Patch), + ("Git", "status", ToolFamily::Read), + ("Git", "diff", ToolFamily::Read), + ("Git", "log", ToolFamily::Read), + ("Git", "show", ToolFamily::Read), + ("Git", "blame", ToolFamily::Read), + ("Run", "tests", ToolFamily::Verify), + ("Run", "verifiers", ToolFamily::Verify), + ("Web", "search", ToolFamily::Find), + ("Web", "fetch", ToolFamily::Find), + ("Web", "wait", ToolFamily::Verify), + ]; + + for (family, action, expected) in cases { + assert_eq!( + tool_family_for_call(family, &json!({"action": action})), + expected, + "{family}.{action}" + ); + } + } + #[test] fn tool_display_label_collapses_known_tools_to_user_verbs() { assert_eq!(tool_display_label_for_name("exec_shell"), "run"); diff --git a/crates/tui/src/tui/work_surface/interaction.rs b/crates/tui/src/tui/work_surface/interaction.rs index 5c2165a4a6..5d5ac917ab 100644 --- a/crates/tui/src/tui/work_surface/interaction.rs +++ b/crates/tui/src/tui/work_surface/interaction.rs @@ -60,6 +60,16 @@ pub fn close_opened(app: &mut App) { app.needs_redraw = true; } +/// Release a closed Agent Details owner without disturbing Work selection. +/// The modal has already popped itself before this event is handled. +pub(crate) fn agent_details_closed(app: &mut App, agent_id: &str) { + let owner = WorkRowId(format!("worker:{agent_id}")); + if app.work_surface.opened.as_ref() == Some(&owner) { + app.work_surface.opened = None; + app.needs_redraw = true; + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tui/src/tui/work_surface/mod.rs b/crates/tui/src/tui/work_surface/mod.rs index c69ccc6823..68eb65978c 100644 --- a/crates/tui/src/tui/work_surface/mod.rs +++ b/crates/tui/src/tui/work_surface/mod.rs @@ -9,6 +9,7 @@ mod model; mod render; pub use input::{handle_key, handle_mouse}; +pub(crate) use interaction::agent_details_closed; pub use model::{WorkSurfacePlacement, WorkSurfaceState}; pub use render::{height, render, split_chat}; @@ -26,8 +27,13 @@ mod tests { AgentWorkerStatus, SubAgentAssignment, SubAgentResult, SubAgentStatus, SubAgentType, }; use crate::tools::todo::TodoStatus; - use crate::tui::app::{App, SidebarRowAction, ToolDetailRecord, TuiOptions}; - use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus}; + use crate::tui::app::{ + AgentCurrentActivity, AgentCurrentActivityStatus, App, SidebarRowAction, ToolDetailRecord, + TuiOptions, + }; + use crate::tui::history::{ + FileMutationReceipt, GenericToolCell, HistoryCell, PatchSummaryCell, ToolCell, ToolStatus, + }; use crate::work_graph::{ AcceptanceRequirement, ChangeCtx, EdgeKind, EvidenceKindTag, NodeKind, NodeState, OperationBinding, OperationOwnerSnapshot, OwnerState, Provenance, WorkEdge, WorkEdgeId, @@ -216,6 +222,136 @@ mod tests { assert!(rows.iter().all(|row| !row.id.0.starts_with("todo:"))); } + #[test] + fn coordination_projection_is_one_selectable_work_row_with_shared_details() { + use crate::tools::subagent::CoordinationDetailProjection; + use crate::tools::subagent::coord::{ + CoordinationDetailMetrics, DecisionRecord, DecisionStatus, + }; + + let mut app = app(); + app.coordination_detail = Some(CoordinationDetailProjection { + schema_version: 1, + sequence: 7, + decisions: vec![DecisionRecord { + decision_id: "decision-work".to_string(), + subject: "coordination row".to_string(), + status: DecisionStatus::Accepted, + owner: "release-owner".to_string(), + scope: Vec::new(), + constraints: vec!["PRIVATE-TRANSCRIPT-MARKER".to_string()], + evidence_handles: Vec::new(), + version: 2, + sequence: 7, + }], + write_claims: Vec::new(), + reconciliations: Vec::new(), + context_projections: Vec::new(), + contentions: Vec::new(), + metrics: CoordinationDetailMetrics { + hottest_paths: Vec::new(), + package_or_module_growth: None, + route_or_cost: None, + note: "No active claims".to_string(), + }, + bounded: true, + limit: 24, + }); + + let rows = super::model::project(&mut app); + assert_eq!( + rows[0].label, + "Work · 0 active · 0 needs input · 0 ready · 1 recent" + ); + let row = rows + .iter() + .find(|row| row.id.0 == "coordination") + .expect("coordination Work row"); + assert_eq!(row.label, "Coordination Work"); + assert_eq!(row.detail, "1 decisions · 0 contentions · 0 reconciled"); + let Some(SidebarRowAction::InspectWork { title, body, .. }) = row.primary_action.as_ref() + else { + panic!("coordination row must open the shared Work inspector"); + }; + assert_eq!(title, "Coordination Work"); + assert!(body.contains("decision-work · coordination row"), "{body}"); + assert!( + body.contains("status accepted · owner release-owner · version 2"), + "{body}" + ); + assert!(!body.contains("PRIVATE-TRANSCRIPT-MARKER"), "{body}"); + + let narrow = render_text(&mut app, 32, 4); + assert!(narrow.contains("Coordination Work"), "{narrow}"); + let _ = super::handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), + ); + let action = super::handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .expect("Work surface handled Enter") + .expect("coordination inspector action"); + assert!(matches!(action, SidebarRowAction::InspectWork { .. })); + } + + #[test] + fn current_blocked_contention_uses_attention_bucket_mark_and_tone() { + use crate::tools::subagent::CoordinationDetailProjection; + use crate::tools::subagent::coord::{ + CoordinationDetailMetrics, PersistedWriteClaim, WriteContentionDisposition, + WriteContentionReceipt, WriteScopeClaim, + }; + + let mut app = app(); + app.coordination_detail = Some(CoordinationDetailProjection { + schema_version: 1, + sequence: 2, + decisions: Vec::new(), + write_claims: vec![PersistedWriteClaim { + claim: WriteScopeClaim { + owner: "worker-a".to_string(), + roots: vec!["crates/tui".to_string()], + exact_files: Vec::new(), + contracts: vec!["ui-contract".to_string()], + }, + sequence: 1, + isolated_worktree: false, + }], + reconciliations: Vec::new(), + context_projections: Vec::new(), + contentions: vec![WriteContentionReceipt { + claimant: "worker-b".to_string(), + conflicting_owner: "worker-a".to_string(), + roots: vec!["crates/tui".to_string()], + exact_files: Vec::new(), + contracts: vec!["ui-contract".to_string()], + disposition: WriteContentionDisposition::BlockedPendingIsolationOrSerialization, + resolution_sequence: None, + sequence: 2, + }], + metrics: CoordinationDetailMetrics { + hottest_paths: Vec::new(), + package_or_module_growth: None, + route_or_cost: None, + note: "No authoritative metric source".to_string(), + }, + bounded: true, + limit: 24, + }); + + let rows = super::model::project(&mut app); + assert_eq!( + rows[0].label, + "Work · 0 active · 1 needs input · 0 ready · 0 recent" + ); + let row = rows + .iter() + .find(|row| row.id.0 == "coordination") + .expect("blocked coordination Work row"); + assert_eq!(row.mark, crate::tui::glyphs::ATTENTION); + assert_eq!(row.tone, super::model::WorkTone::Attention); + assert_eq!(row.detail, "0 decisions · 1 contentions · 0 reconciled"); + } + #[test] fn todos_share_one_ordered_work_projection_without_a_second_heading() { let mut app = app(); @@ -342,6 +478,12 @@ mod tests { app.agent_progress_meta.insert( "agent_worker".to_string(), crate::tui::app::AgentProgressMeta { + current_activity: Some(AgentCurrentActivity::bounded( + AgentCurrentActivityStatus::RunningTool, + None, + Some("File.apply_patch".to_string()), + Some(2), + )), current_tool: Some("apply_patch".to_string()), files_touched: 2, ..crate::tui::app::AgentProgressMeta::default() @@ -355,7 +497,8 @@ mod tests { .expect("agent work row"); assert_eq!(row.label, "Agent Blue Whale · worker"); assert!(row.detail.contains("Wire settled file activity")); - assert!(row.detail.contains("using apply_patch")); + assert!(row.detail.contains("using File.apply_patch")); + assert!(row.detail.contains("step 2")); assert!(row.detail.contains("2 files changed")); assert_eq!( row.primary_action, @@ -366,15 +509,165 @@ mod tests { } #[test] - fn active_session_without_work_renders_truthful_empty_state() { + fn progress_only_work_rows_use_typed_activity_not_display_substrings() { let mut app = app(); app.current_session_id = Some(SESSION.to_string()); + app.agent_progress.insert( + "agent_progress_only".to_string(), + "queued waiting failed completed".to_string(), + ); let rows = super::model::project(&mut app); + let row = rows + .iter() + .find(|row| row.id.0 == "worker:agent_progress_only") + .expect("progress-only work row"); + assert_eq!(row.detail, "running"); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].label, "Work · empty"); - assert!(!rows[0].selectable); + app.agent_progress_meta.insert( + "agent_progress_only".to_string(), + crate::tui::app::AgentProgressMeta { + current_activity: Some(AgentCurrentActivity::bounded( + AgentCurrentActivityStatus::Waiting, + Some("approval required".to_string()), + None, + Some(5), + )), + ..crate::tui::app::AgentProgressMeta::default() + }, + ); + + let rows = super::model::project(&mut app); + let row = rows + .iter() + .find(|row| row.id.0 == "worker:agent_progress_only") + .expect("typed progress-only work row"); + assert!(row.detail.contains("waiting for input"), "{}", row.detail); + assert!(row.detail.contains("approval required"), "{}", row.detail); + assert!(row.detail.contains("step 5"), "{}", row.detail); + } + + #[test] + fn agent_details_keyboard_mouse_and_return_selection_converge() { + fn add_worker(app: &mut App) { + app.current_session_id = Some(SESSION.to_string()); + app.subagent_cache.push(SubAgentResult { + name: "agent_converge".to_string(), + agent_id: "agent_converge".to_string(), + context_mode: "fresh".to_string(), + fork_context: false, + workspace: None, + git_branch: Some("codex/details".to_string()), + agent_type: SubAgentType::Implementer, + assignment: SubAgentAssignment { + objective: "Verify keyboard and mouse convergence".to_string(), + role: Some("worker".to_string()), + }, + model: "test-model".to_string(), + nickname: Some("Blue Whale".to_string()), + status: SubAgentStatus::Running, + worker_status: Some(AgentWorkerStatus::Running), + parent_run_id: None, + spawn_depth: 1, + result: None, + steps_taken: 1, + checkpoint: None, + needs_input: None, + duration_ms: 100, + from_prior_session: false, + }); + } + + let mut keyboard = app(); + add_worker(&mut keyboard); + let _ = render_text(&mut keyboard, 100, 6); + let _ = super::handle_key( + &mut keyboard, + KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), + ); + let keyboard_action = super::handle_key( + &mut keyboard, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ) + .expect("Work key handled") + .expect("agent details action"); + let keyboard_selection = keyboard.work_surface.selected.clone(); + + let mut mouse = app(); + add_worker(&mut mouse); + let _ = render_text(&mut mouse, 100, 6); + let row_y = mouse + .work_surface + .hitboxes + .iter() + .find(|hit| hit.id.0 == "worker:agent_converge") + .expect("agent hitbox") + .row_y; + let mouse_action = super::handle_mouse( + &mut mouse, + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 2, + row: row_y, + modifiers: KeyModifiers::NONE, + }, + ) + .action + .expect("mouse agent details action"); + assert_eq!(mouse_action, keyboard_action); + assert_eq!(mouse.work_surface.selected, keyboard_selection); + + crate::tui::mouse_ui::apply_sidebar_row_action(&mut mouse, mouse_action); + let selected_before_close = mouse.work_surface.selected.clone(); + let events = mouse + .view_stack + .handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + let [crate::tui::views::ViewEvent::AgentDetailsClosed { agent_id }] = events.as_slice() + else { + panic!("Left should close Agent Details with a receipt: {events:?}"); + }; + super::interaction::agent_details_closed(&mut mouse, agent_id); + assert_eq!(mouse.work_surface.selected, selected_before_close); + assert!(mouse.work_surface.opened.is_none()); + } + + #[test] + fn active_session_without_work_keeps_surface_invisible() { + let mut app = app(); + app.current_session_id = Some(SESSION.to_string()); + + let rows = super::model::project(&mut app); + + assert!(rows.is_empty()); + assert_eq!(super::height(&mut app, 120, 32, false), 0); + } + + #[test] + fn empty_work_stays_hidden_after_cached_session_state_is_cleared() { + let mut app = app(); + app.current_session_id = Some(SESSION.to_string()); + app.work_surface.cached_graph = Some(operation_graph(NodeState::Active)); + + let rows = super::model::project(&mut app); + + assert!(rows.is_empty()); + assert!(app.work_surface.cached_graph.is_none()); + } + + #[test] + fn empty_work_reserves_no_side_rail() { + for placement in [ + super::WorkSurfacePlacement::Left, + super::WorkSurfacePlacement::Right, + ] { + let mut app = app(); + app.current_session_id = Some(SESSION.to_string()); + app.work_surface.placement = placement; + let area = ratatui::layout::Rect::new(0, 0, 120, 32); + + assert_eq!(super::height(&mut app, area.width, area.height, false), 0); + assert_eq!(super::split_chat(&mut app, area, false), (area, None)); + } } #[test] @@ -598,6 +891,47 @@ mod tests { assert!(hover.stop_action.is_none()); } + #[test] + fn narrow_file_activity_prioritizes_the_canonical_aggregate_label() { + let mut app = app(); + app.workspace = PathBuf::from("/workspace/project"); + let result = crate::tools::spec::ToolResult::success("ok").with_metadata( + serde_json::json!({ + "mutation": { + "diff": "--- a/update.rs\n+++ b/update.rs\n@@ -1 +1 @@\n-old\n+new\n--- /dev/null\n+++ b/create.rs\n@@ -0,0 +1 @@\n+created\n--- a/delete.rs\n+++ /dev/null\n@@ -1 +0,0 @@\n-deleted\n", + "files": [ + { "path": "update.rs", "outcome": "updated" }, + { "path": "create.rs", "outcome": "created" }, + { "path": "delete.rs", "outcome": "deleted" } + ], + "renames": [{ "from": "old.rs", "to": "new.rs" }] + } + }), + ); + let receipt = FileMutationReceipt::from_success(&app.workspace, &result).expect("receipt"); + app.add_message(HistoryCell::Tool(ToolCell::PatchSummary( + PatchSummaryCell { + path: "4 files".to_string(), + summary: "ok".to_string(), + status: ToolStatus::Success, + error: None, + receipt: Some(receipt), + }, + ))); + app.tool_details_by_cell.insert( + 0, + ToolDetailRecord { + tool_id: "file-multi".to_string(), + tool_name: "File".to_string(), + input: serde_json::json!({"action": "patch"}), + output: Some("ok".to_string()), + }, + ); + + let text = render_text(&mut app, 80, 6); + assert!(text.contains("Wrote 4 files"), "{text}"); + } + #[test] fn overflow_scroll_and_selection_remain_panel_owned() { let mut app = app(); diff --git a/crates/tui/src/tui/work_surface/model.rs b/crates/tui/src/tui/work_surface/model.rs index 638ec6a91b..565d38b542 100644 --- a/crates/tui/src/tui/work_surface/model.rs +++ b/crates/tui/src/tui/work_surface/model.rs @@ -4,9 +4,13 @@ use std::path::{Component, Path}; use ratatui::layout::Rect; +use crate::settings::InlineDiffMode; +use crate::tools::canonical_action::canonical_action_alias; use crate::tools::subagent::{AgentWorkerStatus, SubAgentStatus}; -use crate::tui::app::{App, SidebarRowAction}; -use crate::tui::history::{FileActivityKind, FileActivitySummary, HistoryCell}; +use crate::tui::app::{AgentCurrentActivityStatus, App, SidebarRowAction}; +use crate::tui::history::{ + FileActivityKind, FileActivitySummary, FileMutationReceipt, HistoryCell, ToolCell, +}; use crate::work_graph::{ AcceptanceRequirement, EdgeKind, EvidenceKind, EvidenceKindTag, NodeKind, NodeState, OperationBinding, OwnerState, Provenance, WorkGraphSnapshot, WorkNode, @@ -73,7 +77,6 @@ pub(super) struct WorkHitbox { #[derive(Debug, Clone)] enum WorkSourceState { - Empty, Error(String), Disconnected, } @@ -81,7 +84,6 @@ enum WorkSourceState { impl WorkSourceState { const fn label(&self) -> &'static str { match self { - Self::Empty => "empty", Self::Error(_) => "error", Self::Disconnected => "disconnected", } @@ -89,7 +91,6 @@ impl WorkSourceState { fn detail(&self) -> &str { match self { - Self::Empty => "No graph-owned work in the active session", Self::Error(error) => error, Self::Disconnected => "Work Graph runtime is not attached", } @@ -195,6 +196,7 @@ impl WorkSurfaceState { pub(super) fn project(app: &mut App) -> Vec { let active_session = app.current_session_id.is_some(); let agents = agent_rows(app); + let coordination = coordination_row(app); let activity = settled_file_activity(app); let capture = app.runtime_services.work.as_ref().map(|work| { work.try_capture(app.current_session_id.as_deref()) @@ -208,7 +210,7 @@ pub(super) fn project(app: &mut App) -> Vec { } Some(Ok(None)) => { app.work_surface.cached_graph = None; - (None, active_session.then_some(WorkSourceState::Empty)) + (None, None) } Some(Err(error)) => ( app.work_surface.cached_graph.clone(), @@ -221,9 +223,15 @@ pub(super) fn project(app: &mut App) -> Vec { }; let rows = match graph { - Some(graph) => graph_rows(&graph, source_state.as_ref(), agents, activity), - None if !agents.is_empty() || !activity.is_empty() => { - ordered_rows(None, source_state.as_ref(), agents, activity) + Some(graph) => graph_rows( + &graph, + source_state.as_ref(), + agents, + coordination, + activity, + ), + None if !agents.is_empty() || coordination.is_some() || !activity.is_empty() => { + ordered_rows(None, source_state.as_ref(), agents, coordination, activity) } None => source_state.map_or_else(Vec::new, |state| { vec![section_heading( @@ -246,9 +254,10 @@ fn graph_rows( snapshot: &WorkGraphSnapshot, source_state: Option<&WorkSourceState>, agents: Vec, + coordination: Option, activity: SettledFileActivity, ) -> Vec { - ordered_rows(Some(snapshot), source_state, agents, activity) + ordered_rows(Some(snapshot), source_state, agents, coordination, activity) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -283,6 +292,8 @@ struct SettledFileActivity { list: Vec, search: Vec, write: Vec, + mutations: Vec, + inline_diff_mode: InlineDiffMode, } impl SettledFileActivity { @@ -295,8 +306,10 @@ fn ordered_rows( snapshot: Option<&WorkGraphSnapshot>, source_state: Option<&WorkSourceState>, mut ranked: Vec, + coordination: Option, activity: SettledFileActivity, ) -> Vec { + ranked.extend(coordination); if let Some(snapshot) = snapshot { ranked.extend( snapshot @@ -358,6 +371,50 @@ fn ordered_rows( rows } +fn coordination_row(app: &App) -> Option { + let projection = app.coordination_detail.as_ref()?; + if projection.decisions.is_empty() + && projection.write_claims.is_empty() + && projection.reconciliations.is_empty() + && projection.context_projections.is_empty() + && projection.contentions.is_empty() + { + return None; + } + let attention = crate::tui::coordination_detail::needs_attention(projection); + let bucket = if attention { + WorkBucket::Attention + } else { + WorkBucket::Recent + }; + let title = app + .tr(crate::localization::MessageId::CoordinationWorkTitle) + .into_owned(); + Some(RankedWorkRow { + bucket, + // Coordination is a session-wide receipt, before individual workers + // within the same bucket but after live/attention priority sorting. + order: 100, + row: WorkRow { + id: WorkRowId("coordination".to_string()), + mark: if attention { + crate::tui::glyphs::ATTENTION + } else { + crate::tui::glyphs::DONE + }, + label: title.clone(), + detail: crate::tui::coordination_detail::summary(app.ui_locale, projection), + tone: bucket_tone(bucket), + selectable: true, + primary_action: Some(SidebarRowAction::InspectWork { + title, + body: crate::tui::coordination_detail::format(app.ui_locale, projection), + stop_action: None, + }), + }, + }) +} + fn node_bucket(node: &WorkNode) -> WorkBucket { match node.state { NodeState::Initializing | NodeState::Active => WorkBucket::Active, @@ -386,15 +443,16 @@ fn agent_rows(app: &App) -> Vec { .filter(|agent| !agent.from_prior_session) .enumerate() .map(|(order, agent)| { - let status = agent - .worker_status - .map(worker_status_label) + let meta = app.agent_progress_meta.get(&agent.agent_id); + let current_activity = meta.and_then(|meta| meta.current_activity.as_ref()); + let status = current_activity + .map(|activity| current_activity_status_label(activity.status)) + .or_else(|| agent.worker_status.map(worker_status_label)) .unwrap_or_else(|| subagent_status_label(&agent.status)); - let bucket = agent - .worker_status - .map(worker_status_bucket) + let bucket = current_activity + .map(|activity| current_activity_status_bucket(activity.status)) + .or_else(|| agent.worker_status.map(worker_status_bucket)) .unwrap_or_else(|| subagent_status_bucket(&agent.status)); - let meta = app.agent_progress_meta.get(&agent.agent_id); let role = agent .assignment .role @@ -411,9 +469,17 @@ fn agent_rows(app: &App) -> Vec { status.to_string(), summarize_assignment(&agent.assignment.objective), ]; - if let Some(tool) = meta.and_then(|meta| meta.current_tool.as_deref()) { + if let Some(detail) = current_activity.and_then(|activity| activity.detail.as_deref()) { + facts.push(detail.to_string()); + } + if let Some(tool) = + current_activity.and_then(|activity| activity.current_tool.as_deref()) + { facts.push(format!("using {tool}")); } + if let Some(step) = current_activity.and_then(|activity| activity.step) { + facts.push(format!("step {step}")); + } if let Some(files) = meta .map(|meta| meta.files_touched) .filter(|count| *count > 0) @@ -448,23 +514,34 @@ fn agent_rows(app: &App) -> Vec { progress_only .into_iter() .enumerate() - .map(|(order, (id, progress))| { + .map(|(order, (id, _progress))| { let meta = app.agent_progress_meta.get(id); - let waiting = progress.to_ascii_lowercase().contains("waiting"); - let bucket = if waiting { - WorkBucket::Attention - } else { - WorkBucket::Active - }; + let current_activity = meta.and_then(|meta| meta.current_activity.as_ref()); + let status = current_activity + .map(|activity| current_activity_status_label(activity.status)) + .unwrap_or("running"); + let bucket = current_activity + .map(|activity| current_activity_status_bucket(activity.status)) + .unwrap_or(WorkBucket::Active); let name = app .agent_label_map .get(id) .cloned() .unwrap_or_else(|| id.clone()); - let mut facts = vec![progress.clone()]; - if let Some(tool) = meta.and_then(|meta| meta.current_tool.as_deref()) { + let mut facts = vec![status.to_string()]; + if let Some(detail) = + current_activity.and_then(|activity| activity.detail.as_deref()) + { + facts.push(detail.to_string()); + } + if let Some(tool) = + current_activity.and_then(|activity| activity.current_tool.as_deref()) + { facts.push(format!("using {tool}")); } + if let Some(step) = current_activity.and_then(|activity| activity.step) { + facts.push(format!("step {step}")); + } if let Some(files) = meta .map(|meta| meta.files_touched) .filter(|count| *count > 0) @@ -495,6 +572,37 @@ fn summarize_assignment(value: &str) -> String { crate::tui::history::summarize_tool_output(value) } +fn current_activity_status_bucket(status: AgentCurrentActivityStatus) -> WorkBucket { + match status { + AgentCurrentActivityStatus::Waiting + | AgentCurrentActivityStatus::Interrupted + | AgentCurrentActivityStatus::Failed => WorkBucket::Attention, + AgentCurrentActivityStatus::Queued => WorkBucket::Ready, + AgentCurrentActivityStatus::Done | AgentCurrentActivityStatus::Canceled => { + WorkBucket::Recent + } + AgentCurrentActivityStatus::Starting + | AgentCurrentActivityStatus::Running + | AgentCurrentActivityStatus::ModelWait + | AgentCurrentActivityStatus::RunningTool => WorkBucket::Active, + } +} + +fn current_activity_status_label(status: AgentCurrentActivityStatus) -> &'static str { + match status { + AgentCurrentActivityStatus::Queued => "queued", + AgentCurrentActivityStatus::Starting => "starting", + AgentCurrentActivityStatus::Running => "running", + AgentCurrentActivityStatus::ModelWait => "waiting for model", + AgentCurrentActivityStatus::RunningTool => "running tool", + AgentCurrentActivityStatus::Waiting => "waiting for input", + AgentCurrentActivityStatus::Done => "completed", + AgentCurrentActivityStatus::Failed => "failed", + AgentCurrentActivityStatus::Canceled => "cancelled", + AgentCurrentActivityStatus::Interrupted => "interrupted", + } +} + fn worker_status_bucket(status: AgentWorkerStatus) -> WorkBucket { match status { AgentWorkerStatus::WaitingForUser @@ -564,7 +672,10 @@ const fn agent_mark(bucket: WorkBucket) -> &'static str { } fn settled_file_activity(app: &App) -> SettledFileActivity { - let mut activity = SettledFileActivity::default(); + let mut activity = SettledFileActivity { + inline_diff_mode: app.inline_diff_mode, + ..SettledFileActivity::default() + }; let mut seen = HashSet::new(); for index in 0..app.virtual_cell_count() { let Some(HistoryCell::Tool(cell)) = app.cell_at_virtual_index(index) else { @@ -576,14 +687,32 @@ fn settled_file_activity(app: &App) -> SettledFileActivity { let Some(detail) = app.tool_detail_record_for_cell(index) else { continue; }; - let Some(kind) = FileActivitySummary::from_tool_name(&detail.tool_name) else { + let activity_tool_name = canonical_action_alias(&detail.tool_name, &detail.input); + let kind = if matches!(cell, ToolCell::PatchSummary(_)) { + Some(FileActivityKind::Write) + } else { + FileActivitySummary::from_tool_name(activity_tool_name) + }; + let Some(kind) = kind else { continue; }; if !seen.insert(detail.tool_id.as_str()) { continue; } activity.summary.record(kind); - let target = activity_target(&app.workspace, &detail.tool_name, &detail.input, kind); + if kind == FileActivityKind::Write + && let ToolCell::PatchSummary(mutation) = cell + && let Some(receipt) = mutation.receipt.as_ref() + { + let additional_files = + u32::try_from(receipt.files.len().saturating_sub(1)).unwrap_or(u32::MAX); + activity.summary.files_written = activity + .summary + .files_written + .saturating_add(additional_files); + activity.mutations.push(receipt.clone()); + } + let target = activity_target(&app.workspace, activity_tool_name, &detail.input, kind); let details = match kind { FileActivityKind::Read => &mut activity.read, FileActivityKind::List => &mut activity.list, @@ -602,18 +731,29 @@ fn settled_file_activity(app: &App) -> SettledFileActivity { fn activity_rows(activity: SettledFileActivity) -> Vec { let summaries = activity.summary.compact_display(); - let details = [ - activity.read, - activity.list, - activity.search, - activity.write, + let mutation_detail = activity.mutations.last().map(|receipt| { + if activity.inline_diff_mode == InlineDiffMode::Off { + receipt.outcome_label() + } else { + receipt.semantic_summary() + } + }); + let mutation_body = settled_mutation_body(&activity.mutations, activity.inline_diff_mode); + let categories = [ + (activity.summary.files_read, activity.read, false), + (activity.summary.dirs_listed, activity.list, false), + (activity.summary.patterns_searched, activity.search, false), + (activity.summary.files_written, activity.write, true), ]; - summaries + categories .into_iter() - .zip(details) + .filter(|(count, _, _)| *count > 0) + .zip(summaries) .enumerate() - .map(|(order, (label, details))| { - let body = if details.is_empty() { + .map(|(order, ((_, details, is_write), label))| { + let body = if is_write && !mutation_body.is_empty() { + mutation_body.clone() + } else if details.is_empty() { "No safe target detail retained".to_string() } else { details.join("\n") @@ -625,10 +765,12 @@ fn activity_rows(activity: SettledFileActivity) -> Vec { id: WorkRowId(format!("activity:{order}")), mark: crate::tui::glyphs::DONE, label: label.clone(), - detail: details - .first() - .cloned() - .unwrap_or_else(|| "settled".to_string()), + detail: if is_write { + mutation_detail.clone().or_else(|| details.first().cloned()) + } else { + details.first().cloned() + } + .unwrap_or_else(|| "settled".to_string()), tone: WorkTone::Success, selectable: true, primary_action: Some(SidebarRowAction::InspectWork { @@ -642,6 +784,33 @@ fn activity_rows(activity: SettledFileActivity) -> Vec { .collect() } +fn settled_mutation_body(receipts: &[FileMutationReceipt], mode: InlineDiffMode) -> String { + let Some(receipt) = receipts.last() else { + return String::new(); + }; + let details = crate::tui::key_shortcuts::tool_details_shortcut_action_hint( + "exact change evidence on the matching File receipt", + ); + let hint = format!("Select the matching File receipt; {details}."); + match mode { + InlineDiffMode::Off => format!("{}\n\n{hint}", receipt.outcome_label()), + InlineDiffMode::Summary => format!("{}\n\n{hint}", receipt.semantic_summary()), + InlineDiffMode::Full => { + let diff = receipt + .display_diff + .lines() + .take(40) + .collect::>() + .join("\n"); + if diff.trim().is_empty() { + format!("{}\n\n{hint}", receipt.semantic_summary()) + } else { + format!("{}\n\n{diff}\n\n{hint}", receipt.semantic_summary()) + } + } + } +} + fn activity_target( workspace: &Path, tool_name: &str, @@ -1158,8 +1327,39 @@ fn section_list(out: &mut String, title: &str, items: Vec) { #[cfg(test)] mod tests { use super::*; + use crate::config::Config; + use crate::tools::spec::ToolResult; + use crate::tui::app::TuiOptions; + use crate::tui::tool_routing::{handle_tool_call_complete, handle_tool_call_started}; use crate::work_graph::{CompatTodoBinding, OperationBinding, WorkNodeId}; + fn test_app() -> App { + App::new( + TuiOptions { + model: "deepseek-v4-flash".to_string(), + workspace: std::path::PathBuf::from("/workspace/project"), + config_path: None, + config_profile: None, + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 1, + skills_dir: std::path::PathBuf::from("."), + memory_path: std::path::PathBuf::from("memory.md"), + notes_path: std::path::PathBuf::from("notes.txt"), + mcp_config_path: std::path::PathBuf::from("mcp.json"), + use_memory: false, + start_in_agent_mode: true, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }, + &Config::default(), + ) + } + fn operation(state: NodeState, suffix: &str) -> WorkNode { WorkNode { id: WorkNodeId::derive("work-surface-test", suffix), @@ -1191,7 +1391,13 @@ mod tests { operation(NodeState::Ready, "ready"), ]; - let rows = graph_rows(&snapshot, None, Vec::new(), SettledFileActivity::default()); + let rows = graph_rows( + &snapshot, + None, + Vec::new(), + None, + SettledFileActivity::default(), + ); assert_eq!( rows.first().map(|row| row.label.as_str()), @@ -1229,7 +1435,13 @@ mod tests { plan_index: None, }); - let rows = graph_rows(&snapshot, None, Vec::new(), SettledFileActivity::default()); + let rows = graph_rows( + &snapshot, + None, + Vec::new(), + None, + SettledFileActivity::default(), + ); let labels = rows .iter() .map(|row| row.label.as_str()) @@ -1274,7 +1486,13 @@ mod tests { let mut snapshot = WorkGraphSnapshot::new(); snapshot.nodes = vec![durable, failed, evidence_pending]; - let rows = graph_rows(&snapshot, None, Vec::new(), SettledFileActivity::default()); + let rows = graph_rows( + &snapshot, + None, + Vec::new(), + None, + SettledFileActivity::default(), + ); let labels = rows .iter() .map(|row| row.label.as_str()) @@ -1301,10 +1519,16 @@ mod tests { operation(NodeState::Active, "active"), ]; - let labels = graph_rows(&snapshot, None, Vec::new(), SettledFileActivity::default()) - .into_iter() - .map(|row| row.label) - .collect::>(); + let labels = graph_rows( + &snapshot, + None, + Vec::new(), + None, + SettledFileActivity::default(), + ) + .into_iter() + .map(|row| row.label) + .collect::>(); assert_eq!( labels, @@ -1332,4 +1556,165 @@ mod tests { assert_eq!(privacy_safe_path(workspace, "../private.txt"), None); assert_eq!(safe_pattern("needle\nsecret"), "needle secret"); } + + #[test] + fn settled_canonical_file_actions_keep_aggregates_and_safe_targets() { + let mut app = test_app(); + let calls = [ + ("read", serde_json::json!({"path": "src/read.rs"})), + ("list", serde_json::json!({"path": "src"})), + ("search_name", serde_json::json!({"query": "lib.rs"})), + ( + "search_content", + serde_json::json!({"pattern": "needle\nprivate", "path": "src"}), + ), + ( + "write", + serde_json::json!({"path": "src/new.rs", "content": "new\n"}), + ), + ( + "edit", + serde_json::json!({ + "path": "src/edit.rs", + "search": "old", + "replace": "new" + }), + ), + ( + "patch", + serde_json::json!({ + "patch": "diff --git a/src/patch.rs b/src/patch.rs\n--- a/src/patch.rs\n+++ b/src/patch.rs\n@@ -1 +1 @@\n-old\n+new\n" + }), + ), + ]; + + for (action, payload) in calls { + let id = format!("file-{action}"); + let mut input = payload; + input["action"] = serde_json::json!(action); + handle_tool_call_started(&mut app, &id, "File", &input); + handle_tool_call_complete(&mut app, &id, "File", &Ok(ToolResult::success("ok"))); + app.flush_active_cell(); + } + + let activity = settled_file_activity(&app); + assert_eq!( + activity.summary, + FileActivitySummary { + files_read: 1, + dirs_listed: 1, + patterns_searched: 2, + files_written: 3, + } + ); + assert_eq!(activity.read, ["src/read.rs"]); + assert_eq!(activity.list, ["src"]); + assert_eq!(activity.search, ["lib.rs", "needle private"]); + assert_eq!( + activity.write, + ["src/new.rs", "src/edit.rs", "src/patch.rs"] + ); + } + + #[test] + fn multifile_receipt_counts_semantic_file_outcomes_in_work_label() { + let mut app = test_app(); + let input = serde_json::json!({ + "action": "patch", + "patch": "--- a/update.rs\n+++ b/update.rs\n@@ -1 +1 @@\n-old\n+new\n" + }); + handle_tool_call_started(&mut app, "file-multi", "File", &input); + let result = ToolResult::success("ok").with_metadata(serde_json::json!({ + "mutation": { + "diff": "diff --git a/old.rs b/new.rs\nrename from old.rs\nrename to new.rs\n--- a/update.rs\n+++ b/update.rs\n@@ -1 +1 @@\n-old\n+new\n--- /dev/null\n+++ b/create.rs\n@@ -0,0 +1 @@\n+created\n--- a/delete.rs\n+++ /dev/null\n@@ -1 +0,0 @@\n-deleted\n", + "files": [ + { "path": "update.rs", "outcome": "updated" }, + { "path": "create.rs", "outcome": "created" }, + { "path": "delete.rs", "outcome": "deleted" } + ], + "renames": [{ "from": "old.rs", "to": "new.rs" }] + } + })); + handle_tool_call_complete(&mut app, "file-multi", "File", &Ok(result)); + app.flush_active_cell(); + + let activity = settled_file_activity(&app); + assert_eq!(activity.summary.files_written, 4); + let write_row = activity_rows(activity) + .into_iter() + .find(|row| row.row.label.starts_with("Wrote")) + .expect("write row"); + assert_eq!(write_row.row.label, "Wrote 4 files"); + assert_eq!( + write_row.row.detail, + "4 files · 1 created · 1 updated · 1 deleted · 1 renamed · +2 -2" + ); + } + + fn mutation_activity(mode: InlineDiffMode) -> SettledFileActivity { + let result = ToolResult::success("ok").with_metadata(serde_json::json!({ + "mutation": { + "diff": "--- /Users/alice/private.rs\n+++ /Users/alice/private.rs\n@@ -1 +1 @@\n-old\n+new\n", + "files": [{ + "path": "/Users/alice/private.rs", + "outcome": "updated" + }], + "renames": [] + } + })); + let receipt = FileMutationReceipt::from_success(Path::new("/workspace/project"), &result) + .expect("receipt"); + SettledFileActivity { + summary: FileActivitySummary { + files_written: 1, + ..FileActivitySummary::default() + }, + write: vec!["src/public.rs".to_string()], + mutations: vec![receipt], + inline_diff_mode: mode, + ..SettledFileActivity::default() + } + } + + fn mutation_activity_body(mode: InlineDiffMode) -> (String, String, String) { + let row = activity_rows(mutation_activity(mode)) + .into_iter() + .next() + .expect("activity row") + .row; + let SidebarRowAction::InspectWork { body, .. } = + row.primary_action.expect("inspect action") + else { + panic!("write row must open Work inspection") + }; + (row.label, row.detail, body) + } + + #[test] + fn work_mutation_rows_keep_labels_privacy_and_all_inline_modes() { + let (label, detail, full) = mutation_activity_body(InlineDiffMode::Full); + assert_eq!(label, "Wrote 1 files"); + assert_eq!(detail, "Updated · +1 -1"); + assert!(full.contains("-old"), "{full}"); + assert!(full.contains("+new"), "{full}"); + assert!(!full.contains("alice"), "{full}"); + assert!(full.contains("exact change evidence"), "{full}"); + + let (_, _, summary) = mutation_activity_body(InlineDiffMode::Summary); + assert!( + summary.contains("Updated · +1 -1"), + "{summary}" + ); + assert!(!summary.contains("-old"), "{summary}"); + assert!(!summary.contains("+new"), "{summary}"); + assert!(!summary.contains("alice"), "{summary}"); + + let (_, detail, off) = mutation_activity_body(InlineDiffMode::Off); + assert_eq!(detail, "Updated "); + assert!(off.contains("Updated "), "{off}"); + assert!(!off.contains("+1 -1"), "{off}"); + assert!(!off.contains("-old"), "{off}"); + assert!(!off.contains("alice"), "{off}"); + assert!(off.contains("exact change evidence"), "{off}"); + } } diff --git a/crates/tui/src/tui/work_surface/render.rs b/crates/tui/src/tui/work_surface/render.rs index 85ef010041..182a729a4b 100644 --- a/crates/tui/src/tui/work_surface/render.rs +++ b/crates/tui/src/tui/work_surface/render.rs @@ -224,21 +224,25 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) { } else { format!("{compact_owner}{mark} ") }; - let detail = if row.tone != WorkTone::Heading && content_area.width >= 44 { + let detail_candidate = if row.tone != WorkTone::Heading && content_area.width >= 44 { format!(" {}", row.detail) } else { String::new() }; + let prefix_width = UnicodeWidthStr::width(prefix.as_str()); + let row_width = usize::from(content_area.width); + let label_budget = row_width.saturating_sub(prefix_width).max(1); + let label = truncate_line_to_width(&row.label, label_budget); + let detail_budget = + row_width.saturating_sub(prefix_width + UnicodeWidthStr::width(label.as_str())); + let detail = if detail_budget >= 4 { + truncate_line_to_width(&detail_candidate, detail_budget) + } else { + String::new() + }; let detail_width = UnicodeWidthStr::width(detail.as_str()); - let label_width = usize::from(content_area.width) - .saturating_sub(UnicodeWidthStr::width(prefix.as_str()) + detail_width) - .max(1); - let label = truncate_line_to_width(&row.label, label_width); - let gap = usize::from(content_area.width).saturating_sub( - UnicodeWidthStr::width(prefix.as_str()) - + UnicodeWidthStr::width(label.as_str()) - + detail_width, - ); + let gap = usize::from(content_area.width) + .saturating_sub(prefix_width + UnicodeWidthStr::width(label.as_str()) + detail_width); let display = format!("{prefix}{label}{}{detail}", " ".repeat(gap)); lines.push(Line::from(Span::styled(display.clone(), style))); @@ -253,7 +257,7 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) { display_text: display, full_text: format!("{} · {}", row.label, row.detail), detail: Some(row.detail.clone()), - is_truncated: label != row.label || detail.is_empty(), + is_truncated: label != row.label || detail != detail_candidate, click_action: row.primary_action.clone(), stop_action: None, stop_zone_start_col: None, diff --git a/crates/tui/src/workspace_trust.rs b/crates/tui/src/workspace_trust.rs index 9cc4a2b15b..4df33b595c 100644 --- a/crates/tui/src/workspace_trust.rs +++ b/crates/tui/src/workspace_trust.rs @@ -9,7 +9,7 @@ //! Threat model: this is a deliberate user opt-in to a path the workspace //! sandbox would otherwise refuse. The only access the trust list grants is //! through CodeWhale's own file tools (`read_file`, `write_file`, etc.) — -//! it does not loosen the OS sandbox profile (Seatbelt/Landlock) used for +//! it does not loosen the OS sandbox profile (Seatbelt/bubblewrap) used for //! shell commands. Sandbox-profile expansion is tracked separately so a //! shell tool can opt into the same paths in a future release. diff --git a/crates/tui/src/xai_oauth.rs b/crates/tui/src/xai_oauth.rs index dc1b60df8f..575e89346c 100644 --- a/crates/tui/src/xai_oauth.rs +++ b/crates/tui/src/xai_oauth.rs @@ -983,6 +983,9 @@ fn validate_discovered_oauth_endpoint( if !parsed.username().is_empty() || parsed.password().is_some() { bail!("xAI OIDC discovery returned credentials in {field}"); } + if parsed.origin() != issuer.origin() { + bail!("xAI OIDC discovery returned {field} on a different origin than the issuer"); + } Ok(endpoint.to_string()) } @@ -2078,6 +2081,31 @@ consent_version = 1 assert!(error.to_string().contains("downgrade"), "{error}"); } + #[test] + fn https_discovery_accepts_same_origin_with_explicit_default_port() { + let endpoint = "https://auth.x.ai:443/oauth2/token"; + let validated = validate_discovered_oauth_endpoint( + Some(endpoint.to_string()), + "token_endpoint", + XAI_OIDC_ISSUER, + ) + .expect("URL origins normalize the explicit default HTTPS port"); + + assert_eq!(validated, endpoint); + } + + #[test] + fn https_discovery_rejects_cross_origin_endpoint() { + let error = validate_discovered_oauth_endpoint( + Some("https://oauth.attacker.example/oauth2/token".to_string()), + "token_endpoint", + XAI_OIDC_ISSUER, + ) + .expect_err("discovered OAuth endpoints must stay on the issuer origin"); + + assert!(error.to_string().contains("different origin"), "{error}"); + } + #[test] fn discovery_rejects_mismatched_issuer() { let error = validate_discovered_issuer( diff --git a/crates/tui/tests/adaptive_evidence_acceptance.rs b/crates/tui/tests/adaptive_evidence_acceptance.rs index 693d4f6432..50be4ea84c 100644 --- a/crates/tui/tests/adaptive_evidence_acceptance.rs +++ b/crates/tui/tests/adaptive_evidence_acceptance.rs @@ -1,8 +1,17 @@ //! Process-level acceptance for adaptive exact-evidence routing (#4619). +//! +//! "Exact" begins at the common engine routing seam: tool adapters such as +//! Bash intentionally bound their own operating-system stream and annotate +//! that truncation before returning a `ToolResult`. Adaptive evidence binds +//! every byte of that returned result. Root streaming, sequential/deferred +//! completion, and MCP all converge on the same engine seam; sub-agents have a +//! separate call site covered by `tools::subagent::tests`. use std::io::Read; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use serde_json::{Value, json}; @@ -10,26 +19,18 @@ use sha2::{Digest, Sha256}; use tempfile::TempDir; use wait_timeout::ChildExt; use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, Request, ResponseTemplate}; +use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; const MODEL: &str = "adaptive-evidence-test"; -const CALL_ID: &str = "call_evidence"; -const DEEP_SENTINEL: &str = "DEEP_EXACT_EVIDENCE_SENTINEL_4619"; +const SUCCESS_CALL_ID: &str = "call_bash_success"; +const FAILURE_CALL_ID: &str = "call_bash_failure"; +const SUCCESS_SENTINEL: &str = "DEEP_SUCCESS_EVIDENCE_SENTINEL_4619"; +const FAILURE_SENTINEL: &str = "DEEP_FAILURE_EVIDENCE_SENTINEL_4619"; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn headless_tool_output_is_bounded_while_exact_origin_artifact_is_retained() { +async fn headless_bash_success_and_failure_are_distinct_bounded_exact_evidence() { let workspace = TempDir::new().expect("workspace"); let home = TempDir::new().expect("home"); - let mut source = String::new(); - for line in 0..500 { - let marker = if line == 80 { - DEEP_SENTINEL - } else { - "ordinary" - }; - source.push_str(&format!("{line:04} {marker} {}\n", "x".repeat(96))); - } - std::fs::write(workspace.path().join("large.txt"), source).expect("large fixture"); let server = mock_llm().await; let output = run_exec(workspace.path(), home.path(), &server); @@ -41,49 +42,73 @@ async fn headless_tool_output_is_bounded_while_exact_origin_artifact_is_retained ); let requests = server.received_requests().await.expect("recorded requests"); - let tool_content = requests + let success_receipt = requests .iter() .filter_map(|request| request.body_json::().ok()) - .find_map(|body| tool_result_content(&body).map(str::to_owned)) - .expect("model-visible tool result"); - assert!(tool_content.starts_with("[Exact evidence retained")); - assert!(tool_content.contains("retrieve_tool_result ref=art_call_evidence")); - assert!(!tool_content.contains(DEEP_SENTINEL)); - assert!(!tool_content.contains("/artifacts/")); - assert!( - tool_content.len() <= 3_200, - "handle-only receipt must stay bounded" - ); + .find_map(|body| tool_result_content_for(&body, SUCCESS_CALL_ID).map(str::to_owned)) + .expect("model-visible Bash success receipt"); + let failure_receipt = requests + .iter() + .filter_map(|request| request.body_json::().ok()) + .find_map(|body| tool_result_content_for(&body, FAILURE_CALL_ID).map(str::to_owned)) + .expect("model-visible Bash failure receipt"); + for (receipt, call_id, sentinel) in [ + (&success_receipt, SUCCESS_CALL_ID, SUCCESS_SENTINEL), + (&failure_receipt, FAILURE_CALL_ID, FAILURE_SENTINEL), + ] { + assert!(receipt.starts_with("[Exact evidence retained")); + assert!(receipt.contains(&format!("retrieve_tool_result ref=art_{call_id}"))); + assert!(!receipt.contains(sentinel)); + assert!(!receipt.contains("/artifacts/")); + assert!( + receipt.len() <= 3_200, + "handle-only receipt must stay bounded" + ); + } + assert_ne!(success_receipt, failure_receipt); let artifact_dir = find_artifact_dir(home.path()).expect("origin-session artifacts"); - let payloads: Vec<_> = std::fs::read_dir(&artifact_dir) + let payloads = std::fs::read_dir(&artifact_dir) .expect("artifact directory") .filter_map(Result::ok) .map(|entry| entry.path()) .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("txt")) - .collect(); - assert_eq!(payloads.len(), 1, "exactly one evidence payload per result"); - assert_eq!( - payloads[0].file_name().and_then(|name| name.to_str()), - Some("art_call_evidence.txt") + .count(); + assert_eq!(payloads, 2, "exactly one evidence payload per result"); + + let success = assert_exact_artifact(&artifact_dir, SUCCESS_CALL_ID, SUCCESS_SENTINEL, "Bash"); + let failure = assert_exact_artifact(&artifact_dir, FAILURE_CALL_ID, FAILURE_SENTINEL, "Bash"); + assert_ne!( + success, failure, + "success and failure bytes must stay distinct" ); - let exact = std::fs::read(&payloads[0]).expect("exact evidence bytes"); +} + +fn assert_exact_artifact( + artifact_dir: &Path, + call_id: &str, + sentinel: &str, + tool_name: &str, +) -> Vec { + let handle = format!("art_{call_id}"); + let exact = + std::fs::read(artifact_dir.join(format!("{handle}.txt"))).expect("exact evidence bytes"); assert!( - String::from_utf8_lossy(&exact).contains(DEEP_SENTINEL), + String::from_utf8_lossy(&exact).contains(sentinel), "deep content omitted from context must remain retrievable" ); - - let metadata_path = artifact_dir.join("art_call_evidence.evidence.json"); - let metadata: Value = - serde_json::from_slice(&std::fs::read(&metadata_path).expect("evidence metadata")) - .expect("valid evidence metadata"); + let metadata: Value = serde_json::from_slice( + &std::fs::read(artifact_dir.join(format!("{handle}.evidence.json"))) + .expect("evidence metadata"), + ) + .expect("valid evidence metadata"); let digest = Sha256::digest(&exact) .iter() .map(|byte| format!("{byte:02x}")) .collect::(); - assert_eq!(metadata["handle"], "art_call_evidence"); - assert_eq!(metadata["call_id"], CALL_ID); - assert_eq!(metadata["tool_name"], "read_file"); + assert_eq!(metadata["handle"], handle); + assert_eq!(metadata["call_id"], call_id); + assert_eq!(metadata["tool_name"], tool_name); assert_eq!(metadata["digest"], digest); assert_eq!(metadata["size_bytes"], exact.len() as u64); assert_eq!(metadata["generation"], 1); @@ -95,6 +120,7 @@ async fn headless_tool_output_is_bounded_while_exact_origin_artifact_is_retained .as_str() .is_some_and(|id| !id.is_empty()) ); + exact } async fn mock_llm() -> MockServer { @@ -109,22 +135,47 @@ async fn mock_llm() -> MockServer { .await; Mock::given(method("POST")) .and(path("/v1/chat/completions")) - .and(has_tool_result) - .respond_with(sse_response(final_sse())) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/v1/chat/completions")) - .and(has_no_tool_result) - .respond_with(sse_response(tool_sse())) + .respond_with(EvidenceScenario { + requests: Arc::new(AtomicUsize::new(0)), + }) .mount(&server) .await; server } +#[derive(Clone)] +struct EvidenceScenario { + requests: Arc, +} + +impl Respond for EvidenceScenario { + fn respond(&self, request: &Request) -> ResponseTemplate { + let sequence = self.requests.fetch_add(1, Ordering::SeqCst); + let body = request.body_json::().unwrap_or(Value::Null); + let raw = body.to_string(); + let response = if raw.contains(FAILURE_CALL_ID) { + final_sse() + } else if raw.contains(SUCCESS_CALL_ID) { + bash_tool_sse(FAILURE_CALL_ID, false) + } else { + bash_tool_sse(SUCCESS_CALL_ID, true) + }; + assert!( + sequence < 3, + "unexpected extra model request #{sequence}: {raw}" + ); + sse_response(response) + } +} + fn run_exec(workspace: &Path, home: &Path, server: &MockServer) -> std::process::Output { std::fs::create_dir_all(home.join(".codewhale")).expect("config directory"); std::fs::create_dir_all(home.join(".deepseek")).expect("legacy config directory"); + std::fs::write( + home.join(".codewhale/config.toml"), + "allow_shell = true\n\n[retry]\nenabled = false\n", + ) + .expect("headless test config"); let mut command = Command::new(binary()); preserve_host_env(&mut command); command @@ -139,7 +190,7 @@ fn run_exec(workspace: &Path, home: &Path, server: &MockServer) -> std::process: "--output-format", "stream-json", ]) - .arg("read the complete large.txt file") + .arg("run both provider-fixtured Bash evidence probes") .env("HOME", home) .env("USERPROFILE", home) .env("XDG_CONFIG_HOME", home.join(".config")) @@ -169,37 +220,87 @@ fn find_artifact_dir(home: &Path) -> Option { }) } -fn tool_result_content(body: &Value) -> Option<&str> { +fn tool_result_content_for<'a>(body: &'a Value, call_id: &str) -> Option<&'a str> { body.get("messages")? .as_array()? .iter() - .find(|message| message.get("role").and_then(Value::as_str) == Some("tool"))? + .find(|message| { + message.get("role").and_then(Value::as_str) == Some("tool") + && message.get("tool_call_id").and_then(Value::as_str) == Some(call_id) + })? .get("content")? .as_str() } -fn has_tool_result(request: &Request) -> bool { - request - .body_json::() - .ok() - .and_then(|body| tool_result_content(&body).map(str::to_owned)) - .is_some() -} - -fn has_no_tool_result(request: &Request) -> bool { - !has_tool_result(request) -} - -fn tool_sse() -> String { - let arguments = serde_json::to_string(&json!({"path": "large.txt", "max_lines": 500})) - .expect("tool arguments"); +fn bash_tool_sse(call_id: &str, success: bool) -> String { + let (sentinel, prefix) = if success { + (SUCCESS_SENTINEL, "BASH-SUCCESS") + } else { + (FAILURE_SENTINEL, "BASH-FAILURE") + }; + let command = probe_command(sentinel, prefix, success); + let arguments = serde_json::to_string(&json!({ + "action": "run", + "command": command, + "timeout_ms": 30_000 + })) + .expect("tool arguments"); [ - chunk(json!({"id":"tool","object":"chat.completion.chunk","model":MODEL,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":CALL_ID,"type":"function","function":{"name":"read_file","arguments":arguments}}]},"finish_reason":null}]})), + chunk(json!({"id":"tool","object":"chat.completion.chunk","model":MODEL,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":call_id,"type":"function","function":{"name":"Bash","arguments":arguments}}]},"finish_reason":null}]})), chunk(json!({"id":"tool","object":"chat.completion.chunk","model":MODEL,"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}})), "data: [DONE]\n\n".to_string(), ].join("") } +/// Shell fixture that emits enough bytes to force exact-evidence routing: one +/// sentinel line buried at iteration 120 of ~2,800 filler lines. The probe +/// executes through the platform shell — bash on Unix, `cmd /C` on Windows +/// (#1691) — so each platform needs native syntax to exercise the same +/// routing path. +#[cfg(not(windows))] +fn probe_command(sentinel: &str, prefix: &str, success: bool) -> String { + let trailer = if success { "" } else { "; exit 7" }; + let body = format!( + "i=0; while [ \"$i\" -lt 2800 ]; do if [ \"$i\" -eq 120 ]; then printf '%s\\n' '{sentinel}'; fi; printf '{prefix}-%04d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n' \"$i\"; i=$((i + 1)); done{trailer}" + ); + if success { + body + } else { + format!("{{ {body}; }} >&2") + } +} + +/// PowerShell syntax: on Windows the shell dispatcher prefers `pwsh.exe`, +/// then in-box `powershell.exe`, only falling back to `cmd.exe` when no +/// PowerShell exists at all. Single quotes only — the payload is passed to +/// `-Command` as one argv string, and four or more double quotes would push +/// it onto the temp-`-File` path for no benefit. The failure variant mirrors +/// the Unix `{ ...; } >&2; exit 7` shape by writing every line to the OS +/// stderr handle and exiting 7 after the loop. +#[cfg(windows)] +fn probe_command(sentinel: &str, prefix: &str, success: bool) -> String { + let emit = |text: &str| { + if success { + format!("Write-Output {text}") + } else { + format!("[Console]::Error.WriteLine({text})") + } + }; + let line = format!( + "'{prefix}-{{0}}-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'" + ); + let body = format!( + "0..2799 | ForEach-Object {{ if ($_ -eq 120) {{ {} }} else {{ {} }} }}", + emit(&format!("'{sentinel}'")), + emit(&format!("({line} -f $_)")) + ); + if success { + body + } else { + format!("{body}; exit 7") + } +} + fn final_sse() -> String { [ chunk(json!({"id":"final","object":"chat.completion.chunk","model":MODEL,"choices":[{"index":0,"delta":{"content":"evidence retained"},"finish_reason":null}]})), diff --git a/crates/tui/tests/coordination_acceptance.rs b/crates/tui/tests/coordination_acceptance.rs new file mode 100644 index 0000000000..0f64b5df57 --- /dev/null +++ b/crates/tui/tests/coordination_acceptance.rs @@ -0,0 +1,75 @@ +//! Process-boundary acceptance for delegated coordination (#4647). +//! +//! Typed event/App/Work rendering assertions live beside their production +//! modules, and `qa_pty::real_coordination_details_*` drives the sealed binary. +//! This target retains the real-Git fan-in proof: terminal reconciliation must +//! keep both candidates available instead of reducing them to source strings. + +use std::path::Path; +use std::process::Command; + +use tempfile::tempdir; + +#[test] +fn terminal_retry_fixture_preserves_both_real_git_candidates() { + let repo = tempdir().expect("temp repo"); + git(repo.path(), &["init"]); + git(repo.path(), &["config", "core.autocrlf", "false"]); + git(repo.path(), &["config", "user.name", "codewhale Tests"]); + git(repo.path(), &["config", "user.email", "tests@example.com"]); + git(repo.path(), &["config", "commit.gpgsign", "false"]); + git(repo.path(), &["commit", "--allow-empty", "-m", "base"]); + let base = git_stdout(repo.path(), &["branch", "--show-current"]); + + git(repo.path(), &["switch", "-c", "candidate-a"]); + std::fs::create_dir_all(repo.path().join("src")).expect("src"); + std::fs::write(repo.path().join("src/a.rs"), "pub const A: u8 = 1;\n").expect("candidate A"); + git(repo.path(), &["add", "src/a.rs"]); + git(repo.path(), &["commit", "-m", "candidate A"]); + let candidate_a = git_stdout(repo.path(), &["rev-parse", "HEAD"]); + + git(repo.path(), &["switch", &base]); + git(repo.path(), &["switch", "-c", "candidate-b"]); + std::fs::create_dir_all(repo.path().join("src")).expect("src"); + std::fs::write(repo.path().join("src/b.rs"), "pub const B: u8 = 2;\n").expect("candidate B"); + git(repo.path(), &["add", "src/b.rs"]); + git(repo.path(), &["commit", "-m", "candidate B"]); + let candidate_b = git_stdout(repo.path(), &["rev-parse", "HEAD"]); + + assert_ne!(candidate_a, candidate_b); + assert_eq!( + git_stdout(repo.path(), &["show", "candidate-a:src/a.rs"]), + "pub const A: u8 = 1;" + ); + assert_eq!( + git_stdout(repo.path(), &["show", "candidate-b:src/b.rs"]), + "pub const B: u8 = 2;" + ); +} + +fn git(repo: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(repo) + .output() + .expect("git command"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn git_stdout(repo: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(repo) + .output() + .expect("git command"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_string() +} diff --git a/crates/tui/tests/features/tool_lifecycle.feature b/crates/tui/tests/features/tool_lifecycle.feature index d1bcfa2149..6f22b93305 100644 --- a/crates/tui/tests/features/tool_lifecycle.feature +++ b/crates/tui/tests/features/tool_lifecycle.feature @@ -1,8 +1,8 @@ Feature: Tool call lifecycle Scenario: Happy path lists the current directory through a tool # This executable slice asserts the public exec stream and mocked LLM border. - # The PTY screen slice should also assert Statusline state and BlueWhale activity: - # running while the tool is executing, stopped or completed when the turn finishes. + # The real Unix PTY screen slice in qa_pty.rs asserts the matching Work, + # statusline, idle BlueWhale, live tool-card, and settled-transcript border. Given an offline CodeWhale workspace containing: | path | kind | | README.md | file | @@ -67,3 +67,72 @@ Feature: Tool call lifecycle And CodeWhale should send the malformed argument error back to the mocked LLM And the public tool lifecycle should show a failed tool with raw input for "list_dir" And the public output should include "I could not parse the tool arguments." + + Scenario: A real tool error is returned to the follow-up request + Given an offline CodeWhale workspace containing: + | path | kind | + | README.md | file | + And the mocked LLM will request the "read_file" tool with: + | path | + | missing.txt | + And the mocked LLM will answer after the tool result: + | content | + | I could not read missing.txt because the file is absent. | + When the user asks "read the missing file" + Then CodeWhale should send the user request to the mocked LLM + And the public tool lifecycle should show a running tool: + | status | marker | tool | input | + | running | [~] | read_file | missing.txt | + And the public tool result should report a real error for "read_file" containing "missing.txt" + And CodeWhale should send the real tool error back to the mocked LLM + And the public tool lifecycle should show a failed tool: + | status | marker | tool | input | + | error | [!] | read_file | missing.txt | + And the public output should include "I could not read missing.txt because the file is absent." + + Scenario: An empty tool result is returned to the follow-up request + Given an offline CodeWhale workspace containing: + | path | kind | + | empty | folder | + And the mocked LLM will request the "list_dir" tool with: + | path | + | empty | + And the mocked LLM will answer after the tool result: + | content | + | The directory is currently empty. | + When the user asks "list the empty directory" + Then CodeWhale should send the user request to the mocked LLM + And the public tool lifecycle should show a running tool: + | status | marker | tool | input | + | running | [~] | list_dir | empty | + And the public tool result should be an empty list + And CodeWhale should send the empty tool result back to the mocked LLM + And the public tool lifecycle should show a completed tool: + | status | marker | tool | input | + | completed | ✓ | list_dir | empty | + And the public output should include "The directory is currently empty." + + Scenario: A follow-up answer missing the expected summary is detected + Given an offline CodeWhale workspace containing: + | path | kind | + | README.md | file | + And the mocked LLM will request the "list_dir" tool with: + | path | + | . | + And the mocked LLM will answer after the tool result: + | content | + | I inspected the workspace. | + When the user asks "summarize the current directory" + Then CodeWhale should send the user request to the mocked LLM + And the public tool lifecycle should show a running tool: + | status | marker | tool | input | + | running | [~] | list_dir | . | + And the public tool result should return directory entries: + | entry | kind | + | README.md | file | + And CodeWhale should send the tool result back to the mocked LLM + And the public tool lifecycle should show a completed tool: + | status | marker | tool | input | + | completed | ✓ | list_dir | . | + And the public output should include "I inspected the workspace." + But acceptance should report the missing expected summary "The directory contains README.md." diff --git a/crates/tui/tests/qa_pty.rs b/crates/tui/tests/qa_pty.rs index 23486e465f..378a042154 100644 --- a/crates/tui/tests/qa_pty.rs +++ b/crates/tui/tests/qa_pty.rs @@ -21,6 +21,8 @@ use std::time::{Duration, Instant}; use qa_harness::harness::{Harness, make_sealed_workspace}; use qa_harness::keys; +use sha2::{Digest, Sha256}; +use unicode_width::UnicodeWidthStr; const BOOT_TIMEOUT: Duration = Duration::from_secs(15); const KEY_TIMEOUT: Duration = Duration::from_secs(5); @@ -210,6 +212,216 @@ fn assert_viewport_starts_at_top(frame: &qa_harness::Frame) { ); } +fn visible_row_with_text(frame: &qa_harness::Frame, needle: &str) -> Option { + (0..frame.rows()).find(|&row| frame.row(row).contains(needle)) +} + +fn foreground_at_text(frame: &qa_harness::Frame, row: u16, needle: &str) -> vt100::Color { + let col = frame + .find_text_in_row(row, needle) + .unwrap_or_else(|| panic!("{needle:?} missing from row {row}: {:?}", frame.row(row))); + frame + .colors_at(row, col) + .unwrap_or_else(|| panic!("missing terminal cell at ({row}, {col})")) + .0 +} + +fn composer_edge_rows(frame: &qa_harness::Frame, placeholder: &str) -> (u16, u16) { + let input_row = visible_row_with_text(frame, placeholder) + .unwrap_or_else(|| panic!("composer placeholder {placeholder:?} missing")); + let minimum_rule_cells = usize::from(frame.cols() / 2); + let is_rule = |row: u16| { + frame + .row(row) + .chars() + .filter(|ch| { + matches!( + ch, + '-' | '─' | '━' | '╌' | '╍' | '┄' | '┅' | '┈' | '┉' | '═' + ) + }) + .count() + >= minimum_rule_cells + }; + let top = (0..input_row) + .rev() + .find(|&row| is_rule(row)) + .expect("composer top edge"); + let bottom = (input_row.saturating_add(1)..frame.rows()) + .find(|&row| is_rule(row)) + .expect("composer bottom edge"); + (top, bottom) +} + +/// Assert the user-visible labels and the split composer edges tell the same +/// agency/permission story in the ANSI cells emitted through the real PTY. +fn assert_control_grammar( + frame: &qa_harness::Frame, + mode: &str, + permission: &str, + placeholder: &str, +) -> (vt100::Color, vt100::Color) { + let dump = frame.debug_dump(); + let header = frame.row(0); + assert!( + header.contains(mode), + "mode {mode:?} missing from header {header:?}:\n{dump}" + ); + assert!( + header.contains(permission), + "permission {permission:?} missing from header {header:?}:\n{dump}" + ); + let mode_color = foreground_at_text(frame, 0, mode); + let permission_color = foreground_at_text(frame, 0, permission); + let (permission_edge, mode_edge) = composer_edge_rows(frame, placeholder); + assert_eq!( + frame + .colors_at(permission_edge, 1) + .expect("permission edge cell") + .0, + permission_color, + "header permission and composer top edge diverged:\n{dump}" + ); + assert_eq!( + frame.colors_at(mode_edge, 1).expect("mode edge cell").0, + mode_color, + "header mode and composer bottom edge diverged:\n{dump}" + ); + (mode_color, permission_color) +} + +fn assert_real_pty_frame_geometry(frame: &qa_harness::Frame, cols: u16, rows: u16) { + let dump = frame.debug_dump(); + assert_eq!(frame.cols(), cols, "parsed PTY width changed:\n{dump}"); + assert_eq!(frame.rows(), rows, "parsed PTY height changed:\n{dump}"); + let (cursor_row, cursor_col) = frame.cursor(); + assert!( + cursor_row < rows && cursor_col < cols, + "cursor escaped {cols}x{rows}: ({cursor_row}, {cursor_col})\n{dump}" + ); + for row in 0..rows { + let width = UnicodeWidthStr::width(frame.row(row).as_str()); + assert!( + width <= usize::from(cols), + "row {row} clips at width {width} in {cols}x{rows}:\n{dump}" + ); + } + for fatal in [ + "panicked at", + "fatal runtime error", + "thread 'main' panicked", + ] { + assert!(!frame.contains(fatal), "TUI exposed {fatal:?}:\n{dump}"); + } +} + +fn assert_empty_state_hierarchy(frame: &qa_harness::Frame, ascii_safe: bool) { + let dump = frame.debug_dump(); + let context = visible_row_with_text(frame, "codewhale").expect("empty-state context row"); + let composer = visible_row_with_text(frame, COMPOSER_READY_TEXT).expect("composer row"); + assert!( + context < composer, + "empty-state facts must precede the composer:\n{dump}" + ); + if let Some(fleet) = visible_row_with_text(frame, "Fleet ready") { + assert!( + context < fleet && fleet < composer, + "Fleet action must follow context and precede the composer:\n{dump}" + ); + if let Some(help) = visible_row_with_text(frame, "/help") { + assert!( + fleet < help && help < composer, + "optional help must follow Fleet and precede the composer:\n{dump}" + ); + } + } else { + assert!( + frame.rows() <= 12, + "only the 12-row compact tier may shed the Fleet action:\n{dump}" + ); + } + + let whale_row = (2..context).find(|&row| { + let text = frame.row(row); + if ascii_safe { + text.chars().filter(|ch| *ch == '#').count() >= 8 + } else { + text.chars() + .filter(|ch| { + matches!( + ch, + '█' | '▄' | '▀' | '▗' | '▖' | '▙' | '▝' | '▚' | '▞' | '▐' + ) + }) + .count() + >= 8 + } + }); + if frame.cols() >= 80 && frame.rows() >= 24 { + assert!( + whale_row.is_some(), + "idle whale missing where the terminal earns decorative water:\n{dump}" + ); + } + if let Some(row) = whale_row { + assert!( + row < context, + "idle whale must yield before functional empty-state facts:\n{dump}" + ); + } +} + +fn write_real_pty_evidence( + name: &str, + metadata: &str, + frame: &qa_harness::Frame, +) -> anyhow::Result<()> { + write_real_pty_evidence_dump(name, metadata, &frame.debug_dump()) +} + +fn write_real_pty_evidence_dump( + name: &str, + metadata: &str, + frame_dump: &str, +) -> anyhow::Result<()> { + let Some(dir) = std::env::var_os("CODEWHALE_QA_EVIDENCE_DIR") else { + return Ok(()); + }; + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir)?; + std::fs::write( + dir.join(format!("v091-{name}.txt")), + format!("real_pty=true\n{metadata}\n\n{frame_dump}"), + )?; + Ok(()) +} + +/// Capture the exact semantic frame that satisfies `predicate`. Animated +/// redraws may emit a clear and the replacement composition in separate PTY +/// drains, so pumping once more after `wait_for` can observe the in-between +/// clear instead of the product frame that actually met the assertion. +fn wait_for_frame_dump( + h: &mut Harness, + mut predicate: F, + timeout: Duration, +) -> anyhow::Result +where + F: FnMut(&qa_harness::Frame) -> bool, +{ + let mut captured = None; + h.wait_for( + |frame| { + let matches = predicate(frame); + if matches { + captured = Some(frame.debug_dump()); + } + matches + }, + timeout, + )?; + Ok(captured.expect("matching PTY frame must be captured")) +} + /// Smoke: the binary boots into an alt-screen, paints a composer, and the /// header shows the project label. If this fails, the harness itself is /// broken before we worry about any scenario. @@ -231,6 +443,236 @@ fn smoke_boot_paints_composer() -> anyhow::Result<()> { Ok(()) } +/// v0.9.1 visual stopship: exercise the shipped shell through a real PTY at +/// every release evidence size. This is parsed terminal output, not a test +/// renderer or generated product image. +#[test] +fn v091_real_pty_visual_matrix_preserves_control_grammar() -> anyhow::Result<()> { + let _guard = qa_pty_test_lock(); + let cases = [ + (40_u16, 12_u16, "terminal", false), + (60, 16, "grayscale", false), + (80, 24, "dark", true), + (100, 30, "light", false), + (140, 40, "dark", false), + ]; + let mut theme_signatures = Vec::<(&str, String)>::new(); + + for (cols, rows, theme, ascii_safe) in cases { + let ws = make_sealed_workspace()?; + let codewhale_home = ws.home().join(".codewhale"); + let codex_home = ws.home().join(".codex"); + std::fs::create_dir_all(&codex_home)?; + std::fs::write( + codewhale_home.join("config.toml"), + "reasoning_effort = \"low\"\n\n[update]\ncheck_for_updates = false\n", + )?; + std::fs::write( + codewhale_home.join("settings.toml"), + format!( + "theme = \"{theme}\"\nlocale = \"en\"\ndefault_mode = \"agent\"\npermission_posture = \"ask\"\nlow_motion = false\nfancy_animations = true\ncomposer_border = true\n" + ), + )?; + std::fs::write( + codex_home.join("models_cache.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "fetched_at": chrono::Utc::now(), + "models": [{"slug": "gpt-pty-fixture", "priority": 1}] + }))?, + )?; + + let mut builder = Harness::builder(Harness::cargo_bin("codewhale-tui")) + .cwd(ws.workspace()) + .clear_env() + .seal_home(ws.home()) + .env("CODEWHALE_HOME", codewhale_home.to_string_lossy()) + .env( + "DEEPSEEK_CONFIG_PATH", + codewhale_home.join("config.toml").to_string_lossy(), + ) + .env("CODEX_HOME", codex_home.to_string_lossy()) + .env("DEEPSEEK_API_KEY", "ci-test-key-not-real") + .env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1") + // This runtime overlay must win over the saved animation opt-in. + .env("NO_ANIMATIONS", "1") + .env("RUST_LOG", "warn") + .args([ + "--workspace", + ws.workspace().to_str().expect("utf-8 workspace path"), + "--no-project-config", + "--skip-onboarding", + ]) + .size(rows, cols); + if ascii_safe { + builder = builder.env("CODEWHALE_ASCII_SAFE", "1"); + } + let mut h = builder.spawn()?; + enter_launch_session(&mut h)?; + h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(3))?; + + let first = h.frame().text(); + // Motion evidence needs two real frames separated in wall-clock time. + std::thread::sleep(Duration::from_millis(450)); + h.pump(); + let second = h.frame().text(); + assert_eq!( + first, second, + "NO_ANIMATIONS frame moved at {cols}x{rows} ({theme})" + ); + + { + let frame = h.frame(); + let dump = frame.debug_dump(); + assert_real_pty_frame_geometry(frame, cols, rows); + assert_empty_state_hierarchy(frame, ascii_safe); + assert_control_grammar(frame, "act", "ask", COMPOSER_READY_TEXT); + if ascii_safe { + assert!( + frame.text().is_ascii(), + "ASCII-safe PTY emitted non-ASCII cells:\n{dump}" + ); + } + + let signature = format!("{:?}", frame.colors_at(0, 0).expect("header mark cell")); + if let Some((_, previous)) = theme_signatures + .iter() + .find(|(previous_theme, _)| *previous_theme == theme) + { + assert_eq!( + &signature, previous, + "theme {theme} changed ANSI signature with terminal size" + ); + } else { + for (previous_theme, previous) in &theme_signatures { + assert_ne!( + &signature, previous, + "themes {previous_theme} and {theme} emitted the same ANSI signature" + ); + } + theme_signatures.push((theme, signature)); + } + write_real_pty_evidence( + &format!( + "matrix-{theme}-{cols}x{rows}{}", + if ascii_safe { "-ascii" } else { "" } + ), + &format!( + "size={cols}x{rows}\ntheme={theme}\nmode=act\npermission=ask\nreduced_motion=true\nascii_safe={ascii_safe}" + ), + frame, + )?; + } + + // Prove the cool agency ramp and warm permission ramp end to end in + // both dark and light themes. Header labels and split composer edges + // must change together, and each state must retain its own ANSI color. + if cols == 140 || theme == "light" { + let (act, ask) = assert_control_grammar(h.frame(), "act", "ask", COMPOSER_READY_TEXT); + + h.send(b"\t")?; + h.wait_for( + |frame| { + frame.row(0).contains("operate") && frame.contains("Coordinate parallel tasks") + }, + KEY_TIMEOUT, + )?; + let (operate, _) = + assert_control_grammar(h.frame(), "operate", "ask", "Coordinate parallel tasks"); + write_real_pty_evidence( + &format!("agency-operate-{theme}-{cols}x{rows}"), + &format!( + "size={cols}x{rows}\ntheme={theme}\nmode=operate\npermission=ask\nreduced_motion=true\nascii_safe=false" + ), + h.frame(), + )?; + + h.send(b"\t")?; + h.wait_for( + |frame| { + frame.row(0).contains("plan") + && frame.row(0).contains("read only") + && frame.contains(COMPOSER_READY_TEXT) + }, + KEY_TIMEOUT, + )?; + let (plan, _) = + assert_control_grammar(h.frame(), "plan", "read only", COMPOSER_READY_TEXT); + write_real_pty_evidence( + &format!("agency-plan-{theme}-{cols}x{rows}"), + &format!( + "size={cols}x{rows}\ntheme={theme}\nmode=plan\npermission=read-only\nreduced_motion=true\nascii_safe=false" + ), + h.frame(), + )?; + assert_ne!(plan, act, "Plan and Act collapsed to one ANSI color"); + assert_ne!( + plan, operate, + "Plan and Operate collapsed to one ANSI color" + ); + assert_ne!(act, operate, "Act and Operate collapsed to one ANSI color"); + + h.send(b"\t")?; + h.wait_for( + |frame| { + frame.row(0).contains("act") + && frame.row(0).contains("ask") + && frame.contains(COMPOSER_READY_TEXT) + }, + KEY_TIMEOUT, + )?; + assert_control_grammar(h.frame(), "act", "ask", COMPOSER_READY_TEXT); + + h.send(keys::key::backtab())?; + h.wait_for( + |frame| frame.row(0).contains("act") && frame.row(0).contains("auto"), + KEY_TIMEOUT, + )?; + let (_, auto) = assert_control_grammar(h.frame(), "act", "auto", COMPOSER_READY_TEXT); + write_real_pty_evidence( + &format!("permission-auto-{theme}-{cols}x{rows}"), + &format!( + "size={cols}x{rows}\ntheme={theme}\nmode=act\npermission=auto\nreduced_motion=true\nascii_safe=false" + ), + h.frame(), + )?; + + h.send(keys::key::backtab())?; + h.wait_for( + |frame| frame.row(0).contains("act") && frame.row(0).contains("Full Access"), + KEY_TIMEOUT, + )?; + let (_, full_access) = + assert_control_grammar(h.frame(), "act", "Full Access", COMPOSER_READY_TEXT); + write_real_pty_evidence( + &format!("permission-full-access-{theme}-{cols}x{rows}"), + &format!( + "size={cols}x{rows}\ntheme={theme}\nmode=act\npermission=full-access\nreduced_motion=true\nascii_safe=false" + ), + h.frame(), + )?; + assert_ne!(ask, auto, "Ask and Auto collapsed to one ANSI color"); + assert_ne!( + ask, full_access, + "Ask and Full Access collapsed to one ANSI color" + ); + assert_ne!( + auto, full_access, + "Auto and Full Access collapsed to one ANSI color" + ); + } + + if let Some(status) = h.wait_for_exit(Duration::from_millis(1)) { + return Err(anyhow::anyhow!( + "TUI exited during {cols}x{rows} {theme} matrix case with {status}:\n{}", + h.debug_dump() + )); + } + let _ = h.shutdown(); + } + + Ok(()) +} + /// Returning users with a missing hosted-provider key must enter the normal /// provider picker rather than a dead-end key screen. This runs with a sealed /// HOME and no API key, so opening the picker is also proof that recovery does @@ -627,6 +1069,157 @@ fn work_surface_real_rows_own_click_wheel_and_resize() -> anyhow::Result<()> { Ok(()) } +#[test] +fn real_coordination_details_use_typed_persisted_receipts_in_a_unix_pty() -> anyhow::Result<()> { + let _guard = qa_pty_test_lock(); + let ws = make_sealed_workspace()?; + let state_dir = ws.workspace().join(".codewhale").join("state"); + std::fs::create_dir_all(&state_dir)?; + std::fs::write( + state_dir.join("subagents.v1.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "schema_version": 1, + "snapshot_sequence": 6, + "agents": [], + "workers": [], + "coordination": { + "schema_version": 1, + "sequence": 6, + "decisions": [ + { + "decision_id": "decision-a", + "subject": "release shell", + "status": "accepted", + "owner": "worker-a", + "scope": ["path:crates/tui"], + "constraints": ["PRIVATE-TRANSCRIPT-MARKER"], + "evidence_handles": [], + "version": 2, + "sequence": 1 + }, + { + "decision_id": "decision-b", + "subject": "release shell", + "status": "superseded", + "owner": "worker-b", + "scope": ["path:crates/tui"], + "constraints": [], + "evidence_handles": [], + "version": 1, + "sequence": 2 + } + ], + "write_claims": [{ + "claim": { + "owner": "worker-a", + "roots": ["crates/tui"], + "exact_files": [], + "contracts": ["ui-contract"] + }, + "sequence": 3, + "isolated_worktree": false + }], + "reconciliations": [{ + "reconciliation_id": "reconcile-release-shell", + "subject": "release shell", + "owner": "release-owner", + "input_decisions": ["decision-a", "decision-b"], + "outcome": "candidate-a", + "evidence_handles": [], + "candidate_handles": ["branch:candidate-a", "branch:candidate-b"], + "retry_count": 1, + "retry_limit": 3, + "reviewer_evidence_handles": ["agent:reviewer"], + "verifier_evidence_handles": ["agent:verifier"], + "verification_outcome": "verified", + "sequence": 4 + }], + "projections": [{ + "child_id": "worker-a", + "decision_ids": ["decision-a"], + "projected_bytes": 128, + "deduplicated": 1, + "omitted": 0, + "sequence": 5 + }], + "contentions": [{ + "claimant": "worker-b", + "conflicting_owner": "worker-a", + "roots": ["crates/tui"], + "exact_files": ["Cargo.toml"], + "contracts": ["ui-contract"], + "disposition": "blocked_pending_isolation_or_serialization", + "sequence": 6 + }] + } + }))?, + )?; + + let (_ws, mut h) = spawn_minimal_with_env(ws, &[])?; + let ambient = wait_for_frame_dump( + &mut h, + |frame| frame.contains("Coordination Work"), + KEY_TIMEOUT, + )?; + assert!( + !ambient.contains("PRIVATE-TRANSCRIPT-MARKER"), + "decision constraints leaked into ambient Work chrome:\n{ambient}" + ); + + h.send(keys::key::alt('w'))?; + h.wait_for_idle(Duration::from_millis(80), Duration::from_secs(2))?; + h.send(keys::key::enter())?; + let required_details = [ + "decision-a · release shell", + "status accepted · owner worker-a · version 2", + "claimant worker-b · owner worker-a", + "paths crates/tui, Cargo.toml", + "contracts ui-contract", + "disposition blocked_pending_isolation_or_serialization", + "release shell · 2 candidates · retry 1/3", + "reviewer agent:reviewer", + "verifier agent:verifier", + "verification verified", + "worker-a · decisions decision-a · 128 bytes · 1 deduplicated · 0 omitted", + ]; + for required in required_details { + h.wait_for_text(required, KEY_TIMEOUT)?; + } + let wide = wait_for_frame_dump( + &mut h, + |frame| required_details.iter().all(|detail| frame.contains(detail)), + KEY_TIMEOUT, + )?; + assert!(!wide.contains("PRIVATE-TRANSCRIPT-MARKER"), "{wide}"); + write_real_pty_evidence_dump( + "coordination-details-wide-140x40", + "size=140x40\nstate=persisted-coordination\naction=Alt+W then Enter\nprivate_marker_rendered=false", + &wide, + )?; + + h.resize(18, 60)?; + let narrow = wait_for_frame_dump( + &mut h, + |frame| { + frame.rows() == 18 + && frame.cols() == 60 + && frame.contains("Coordination Work") + && frame.contains("decision-a") + }, + KEY_TIMEOUT, + )?; + assert!(!narrow.contains("PRIVATE-TRANSCRIPT-MARKER"), "{narrow}"); + assert!(narrow.contains("decision-a"), "{narrow}"); + write_real_pty_evidence_dump( + "coordination-details-narrow-60x18", + "size=60x18\nstate=persisted-coordination\naction=resize with pager open\nprivate_marker_rendered=false", + &narrow, + )?; + + let _ = h.shutdown(); + Ok(()) +} + #[test] fn approval_modal_keeps_wheel_for_review_and_denies_without_side_effect() -> anyhow::Result<()> { let _guard = qa_pty_test_lock(); @@ -971,30 +1564,21 @@ fn legacy_work_ctrl_t_save_export_and_restart_are_consistent() -> anyhow::Result assert!(cycled.contains("Work"), "{}", cycled.debug_dump()); let before_path = ws.workspace().join("wg6-before-export.json"); - h.send(keys::key::text(&format!( - "/save {}", - before_path.to_string_lossy() - )))?; + h.send(keys::key::text("/save wg6-before-export.json"))?; h.wait_for_text("/save", KEY_TIMEOUT)?; h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?; h.send(keys::key::enter())?; h.wait_for_text("Session saved to", KEY_TIMEOUT)?; let export_path = ws.workspace().join("wg6-export.md"); - h.send(keys::key::text(&format!( - "/export {}", - export_path.to_string_lossy() - )))?; + h.send(keys::key::text("/export wg6-export.md"))?; h.wait_for_text("/export", KEY_TIMEOUT)?; h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?; h.send(keys::key::enter())?; h.wait_for_text("Conversation exported to", KEY_TIMEOUT)?; let after_path = ws.workspace().join("wg6-after-export.json"); - h.send(keys::key::text(&format!( - "/save {}", - after_path.to_string_lossy() - )))?; + h.send(keys::key::text("/save wg6-after-export.json"))?; h.wait_for_text("/save", KEY_TIMEOUT)?; h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?; h.send(keys::key::enter())?; @@ -1142,14 +1726,19 @@ fn cancelled_bang_shell_settles_transcript_card() -> anyhow::Result<()> { Ok(()) } -/// Regression: `/skills` should reflect the same merged discovery set as the -/// slash menu and model-visible skills block, not just the first selected -/// skills directory. +/// Bare `/skills` opens the unified Skills Manager (owned-only, zero network). +/// Compatible roots (e.g. `.agents/skills`) appear only after toggling scan mode. #[test] -fn skills_menu_shows_local_and_global_skills() -> anyhow::Result<()> { +fn skills_opens_manager_owned_then_compatible() -> anyhow::Result<()> { let _guard = qa_pty_test_lock(); let ws = make_sealed_workspace()?; - write_skill(ws.user_skills_dir(), "global-alpha", "Global alpha skill")?; + // Owned global root — visible in the default owned-only manager scan. + write_skill( + ws.home().join(".codewhale").join("skills"), + "global-alpha", + "Global alpha skill", + )?; + // Compatible external root — hidden until the user presses `c`. write_skill( ws.workspace().join(".agents").join("skills"), "workspace-beta", @@ -1178,16 +1767,46 @@ fn skills_menu_shows_local_and_global_skills() -> anyhow::Result<()> { h.wait_for_text("/skills", KEY_TIMEOUT)?; h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(2))?; h.send(keys::key::enter())?; - h.wait_for_text("Available skills", KEY_TIMEOUT)?; + h.wait_for_text("Skills Manager", KEY_TIMEOUT)?; h.wait_for_text("global-alpha", KEY_TIMEOUT)?; + + let owned = h.frame(); + let owned_dump = owned.debug_dump(); + assert!( + owned.contains("global-alpha"), + "owned global skill missing:\n{owned_dump}" + ); + assert!( + !owned.contains("workspace-beta"), + "compatible skill must stay hidden in owned-only scan:\n{owned_dump}" + ); + assert!( + !owned.contains("Available skills"), + "bare /skills must open manager, not the legacy text list:\n{owned_dump}" + ); + assert!( + !owned.contains("Fetching registry") && !owned.contains("registry.json"), + "default manager must stay zero-network:\n{owned_dump}" + ); + + // Toggle to compatible scan so external roots appear. + h.send(keys::key::ch('c'))?; h.wait_for_text("workspace-beta", KEY_TIMEOUT)?; + let compat = h.frame(); + let compat_dump = compat.debug_dump(); + assert!( + compat.contains("workspace-beta"), + "compatible skill missing after toggle:\n{compat_dump}" + ); - let f = h.frame(); - let dump = f.debug_dump(); - assert!(f.contains("global-alpha"), "global skill missing:\n{dump}"); + h.send(keys::key::esc())?; + h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(2))?; + h.wait_for_text(COMPOSER_READY_TEXT, KEY_TIMEOUT)?; + let after = h.frame(); assert!( - f.contains("workspace-beta"), - "workspace skill missing:\n{dump}" + !after.contains("Skills Manager"), + "Esc should close the skills manager:\n{}", + after.debug_dump() ); let _ = h.shutdown(); @@ -1454,50 +2073,1190 @@ fn spawn_long_reply_fixture( Ok((format!("http://{address}"), handle)) } -enum ScrollDir { - Up, - Down, -} - -/// Scroll the transcript one step at a time — letting each step settle past the -/// input-coalescing/redraw throttle — until `needle` is on-screen. Each step -/// sends both a page key and a wheel event so it works regardless of which the -/// transcript honors. Returns whether the needle became visible. -fn scroll_until(h: &mut Harness, dir: ScrollDir, needle: &str) -> bool { - if h.frame().contains(needle) { - return true; - } - for _ in 0..50 { - match dir { - ScrollDir::Up => { - let _ = h.send(keys::key::page_up()); - let _ = h.send(keys::mouse::wheel_up(10, 10)); - } - ScrollDir::Down => { - let _ = h.send(keys::mouse::wheel_down(10, 10)); - let _ = h.send(keys::mouse::wheel_down(10, 10)); - } +fn read_http_request(stream: &mut std::net::TcpStream) -> anyhow::Result { + // Reqwest may establish the next loopback connection before the real PTY + // test releases a blocked tool. Keep that idle socket bounded, but long + // enough for the deliberately observed reasoning/read phases to finish. + // Darwin can propagate O_NONBLOCK from the listener to accepted sockets; + // restore blocking reads before applying the timeout or an early accept + // races the first request byte and reports EAGAIN as a network failure. + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(Duration::from_secs(60)))?; + let mut request = Vec::new(); + let mut chunk = [0_u8; 16 * 1024]; + loop { + let count = stream.read(&mut chunk)?; + if count == 0 { + break; } - let _ = h.wait_for_idle(Duration::from_millis(60), Duration::from_millis(400)); - if h.frame().contains(needle) { - return true; + request.extend_from_slice(&chunk[..count]); + + let Some(header_end) = request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|index| index + 4) + else { + continue; + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }); + if content_length.is_none_or(|length| request.len() >= header_end + length) { + break; } } - false + Ok(String::from_utf8_lossy(&request).into_owned()) } -/// #4603: long transcript output must be retained beyond the viewport and -/// remain reviewable by scrolling, with follow-tail restored on return to the -/// bottom. Provider-free: the reply is a sealed loopback SSE fixture. -#[test] -fn long_output_scrolls_and_restores_follow_tail() -> anyhow::Result<()> { - let _guard = qa_pty_test_lock(); +fn pty_tool_call_sse(id: &str, name: &str, arguments: serde_json::Value) -> String { + [ + format!( + "data: {}\n\n", + serde_json::json!({ + "id": format!("chatcmpl-{id}"), + "object": "chat.completion.chunk", + "model": "deepseek-v4-pro", + "choices": [{ + "index": 0, + "delta": {"tool_calls": [{ + "index": 0, + "id": id, + "type": "function", + "function": { + "name": name, + "arguments": serde_json::to_string(&arguments) + .expect("tool arguments JSON") + } + }]}, + "finish_reason": null + }] + }) + ), + format!( + "data: {}\n\n", + serde_json::json!({ + "id": format!("chatcmpl-{id}"), + "object": "chat.completion.chunk", + "model": "deepseek-v4-pro", + "choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}], + "usage": {"prompt_tokens": 12, "completion_tokens": 4, "total_tokens": 16} + }) + ), + "data: [DONE]\n\n".to_string(), + ] + .join("") +} - // A reply well over three 24-row viewports: a head marker, ~90 numbered - // lines, a very wide line (horizontal overflow), and a tail marker. - let mut lines = vec!["SCROLLPROBE-HEAD".to_string()]; - for i in 1..=90 { - lines.push(format!("SCROLLPROBE-LINE-{i:03}")); +fn pty_text_sse(content: &str) -> String { + [ + format!( + "data: {}\n\n", + serde_json::json!({ + "id": "chatcmpl-pty-lifecycle-final", + "object": "chat.completion.chunk", + "model": "deepseek-v4-pro", + "choices": [{ + "index": 0, + "delta": {"content": content}, + "finish_reason": null + }] + }) + ), + format!( + "data: {}\n\n", + serde_json::json!({ + "id": "chatcmpl-pty-lifecycle-final", + "object": "chat.completion.chunk", + "model": "deepseek-v4-pro", + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 24, "completion_tokens": 80, "total_tokens": 104} + }) + ), + "data: [DONE]\n\n".to_string(), + ] + .join("") +} + +/// Sealed loopback fixture for the #4636 File-mutation receipt. One canonical +/// `File.patch` call performs an update, create, delete, and byte-identical +/// delete/create rename in a single transaction; the second response settles +/// the turn. No provider or external network is involved. +fn spawn_file_mutation_screen_fixture() +-> anyhow::Result<(String, std::thread::JoinHandle>)> { + let listener = TcpListener::bind("127.0.0.1:0")?; + listener.set_nonblocking(true)?; + let address = listener.local_addr()?; + let patch = r"diff --git a/old-name.txt b/old-name.txt +--- a/old-name.txt ++++ /dev/null +@@ -1 +0,0 @@ +-RENAME-SENTINEL +diff --git a/new-name.txt b/new-name.txt +--- /dev/null ++++ b/new-name.txt +@@ -0,0 +1 @@ ++RENAME-SENTINEL +diff --git a/update.txt b/update.txt +--- a/update.txt ++++ b/update.txt +@@ -1 +1 @@ +-DIFF-OLD-SENTINEL ++DIFF-NEW-SENTINEL +diff --git a/create.txt b/create.txt +--- /dev/null ++++ b/create.txt +@@ -0,0 +1 @@ ++CREATE-SENTINEL +diff --git a/delete.txt b/delete.txt +--- a/delete.txt ++++ /dev/null +@@ -1 +0,0 @@ +-DELETE-SENTINEL +"; + let replies = [ + pty_tool_call_sse( + "call_file_mutation_pty", + "File", + serde_json::json!({"action": "patch", "patch": patch}), + ), + pty_text_sse("FILE-MUTATION-FIXTURE-DONE"), + ]; + + let handle = std::thread::spawn(move || -> anyhow::Result<()> { + let deadline = Instant::now() + Duration::from_secs(45); + let mut chat_index = 0_usize; + let mut contract_errors = Vec::new(); + while chat_index < replies.len() && Instant::now() < deadline { + let Ok((mut stream, _)) = listener.accept() else { + std::thread::sleep(Duration::from_millis(10)); + continue; + }; + let request = read_http_request(&mut stream)?; + let request_line = request.lines().next().unwrap_or_default(); + let (content_type, body) = if request_line.starts_with("GET ") + && request_line.contains("/models") + { + ( + "application/json", + serde_json::json!({ + "object": "list", + "data": [{"id": "deepseek-v4-pro", "object": "model"}] + }) + .to_string(), + ) + } else if request_line.starts_with("POST ") + && request_line.contains("/chat/completions") + { + let request_body = request + .split_once("\r\n\r\n") + .map(|(_, body)| body) + .unwrap_or_default(); + let request_json: serde_json::Value = serde_json::from_str(request_body)?; + let request_contract = request_json.to_string(); + match chat_index { + 0 if !request_contract + .contains("exercise the canonical File mutation receipt") => + { + contract_errors.push("initial request omitted the fixture prompt".into()); + } + 1 if !(request_contract.contains("call_file_mutation_pty") + && request_contract.contains("files_applied") + && request_contract.contains("\"role\":\"tool\"")) => + { + let sample = request_contract.chars().take(1_200).collect::(); + contract_errors.push(format!( + "settling request omitted the successful File result: {sample}" + )); + } + 0 | 1 => {} + _ => unreachable!("bounded File fixture"), + } + let body = replies[chat_index].clone(); + chat_index += 1; + ("text/event-stream", body) + } else { + ("text/plain", "not found".to_string()) + }; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes())?; + stream.flush()?; + } + anyhow::ensure!( + chat_index == replies.len(), + "File fixture served {chat_index}/{} chat requests", + replies.len() + ); + if !contract_errors.is_empty() { + anyhow::bail!( + "File fixture contract errors:\n{}", + contract_errors.join("\n") + ); + } + Ok(()) + }); + Ok((format!("http://{address}"), handle)) +} + +fn spawn_file_mutation_harness( + ws: &qa_harness::harness::SealedWorkspace, + base_url: &str, + rows: u16, + cols: u16, + ascii_safe: bool, +) -> anyhow::Result { + let codewhale_home = ws.home().join(".codewhale"); + let codex_home = ws.home().join(".codex"); + let mut builder = Harness::builder(Harness::cargo_bin("codewhale-tui")) + .cwd(ws.workspace()) + .clear_env() + .seal_home(ws.home()) + .env("CODEWHALE_HOME", codewhale_home.to_string_lossy()) + .env( + "DEEPSEEK_CONFIG_PATH", + codewhale_home.join("config.toml").to_string_lossy(), + ) + .env("CODEX_HOME", codex_home.to_string_lossy()) + .env("CODEWHALE_PROVIDER", "deepseek") + .env("DEEPSEEK_API_KEY", "deepseek-local-test-key") + .env("DEEPSEEK_BASE_URL", base_url) + .env("CODEWHALE_BASE_URL", base_url) + .env("DEEPSEEK_MODEL", "deepseek-v4-pro") + .env("CODEWHALE_MODEL", "deepseek-v4-pro") + .env("NO_ANIMATIONS", "1") + .env("RUST_LOG", "warn") + .args([ + "--workspace", + ws.workspace().to_str().expect("utf-8 workspace path"), + "--no-project-config", + "--skip-onboarding", + "--mouse-capture", + ]) + .size(rows, cols); + if ascii_safe { + builder = builder.env("CODEWHALE_ASCII_SAFE", "1"); + } + builder.spawn() +} + +/// #4636: real terminal frames for the persisted full/summary/off contract. +/// The three cases jointly cover Ask/Auto/Full Access, narrow/wide, dark/light, +/// reduced-motion, and ASCII-safe operation. The off case is changed through +/// `/config --save` and then rebooted before execution, proving the setting +/// survives restart. +#[test] +fn work_surface_file_mutation_modes_are_truthful_in_real_pty_frames() -> anyhow::Result<()> { + let _guard = qa_pty_test_lock(); + let cases = [ + ( + "full", 140_u16, 40_u16, "dark", false, false, "ask", "ask", true, + ), + ( + "summary", 100, 32, "light", false, false, "auto", "auto", true, + ), + ( + "off", + 80, + 24, + "dark", + true, + true, + "full-access", + "Full Access", + false, + ), + ]; + + for ( + mode, + cols, + rows, + theme, + ascii_safe, + persist_through_restart, + permission_posture, + permission_label, + approve_once, + ) in cases + { + let ws = make_sealed_workspace()?; + let codewhale_home = ws.home().join(".codewhale"); + let codex_home = ws.home().join(".codex"); + std::fs::create_dir_all(&codex_home)?; + std::fs::write( + codewhale_home.join("config.toml"), + "reasoning_effort = \"low\"\n\n[retry]\nenabled = false\n\n[update]\ncheck_for_updates = false\n", + )?; + let initial_mode = if persist_through_restart { + "full" + } else { + mode + }; + std::fs::write( + codewhale_home.join("settings.toml"), + format!( + "theme = \"{theme}\"\nlocale = \"en\"\ndefault_mode = \"agent\"\npermission_posture = \"{permission_posture}\"\ninline_diffs = \"{initial_mode}\"\nlow_motion = true\nfancy_animations = false\ncomposer_border = true\n" + ), + )?; + std::fs::write( + codex_home.join("models_cache.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "fetched_at": chrono::Utc::now(), + "models": [{"slug": "deepseek-v4-pro", "priority": 1}] + }))?, + )?; + std::fs::write(ws.workspace().join("old-name.txt"), "RENAME-SENTINEL\n")?; + std::fs::write(ws.workspace().join("update.txt"), "DIFF-OLD-SENTINEL\n")?; + std::fs::write(ws.workspace().join("delete.txt"), "DELETE-SENTINEL\n")?; + + if persist_through_restart { + let mut setup = + spawn_file_mutation_harness(&ws, "http://127.0.0.1:1", rows, cols, ascii_safe)?; + enter_launch_session(&mut setup)?; + setup.paste("/config inline_diffs off --save")?; + setup.wait_for_text("/config inline_diffs off --save", KEY_TIMEOUT)?; + setup.send(keys::key::enter())?; + setup.wait_for_text("inline_diffs = off (saved)", KEY_TIMEOUT)?; + let _ = setup.shutdown(); + let persisted = std::fs::read_to_string(codewhale_home.join("settings.toml"))?; + anyhow::ensure!( + persisted.contains("inline_diffs = \"off\""), + "off mode did not persist before restart: {persisted}" + ); + } + + let (base_url, server) = spawn_file_mutation_screen_fixture()?; + let mut h = spawn_file_mutation_harness(&ws, &base_url, rows, cols, ascii_safe)?; + enter_launch_session(&mut h)?; + assert_real_pty_frame_geometry(h.frame(), cols, rows); + assert_control_grammar(h.frame(), "act", permission_label, COMPOSER_READY_TEXT); + + let prompt = "exercise the canonical File mutation receipt"; + h.paste(prompt)?; + h.wait_for_text(prompt, KEY_TIMEOUT)?; + h.send(keys::key::enter())?; + if approve_once { + h.wait_for_text("Approve once", Duration::from_secs(10))?; + h.send(b"y")?; + } + h.wait_for_text("FILE-MUTATION-FIXTURE-DONE", Duration::from_secs(20))?; + h.wait_for( + |frame| frame.contains("Wrote 4 files") && frame.contains("done"), + Duration::from_secs(10), + )?; + h.wait_for_idle(Duration::from_millis(250), Duration::from_secs(3))?; + + assert_eq!( + std::fs::read_to_string(ws.workspace().join("new-name.txt"))?, + "RENAME-SENTINEL\n" + ); + assert!(!ws.workspace().join("old-name.txt").exists()); + assert_eq!( + std::fs::read_to_string(ws.workspace().join("update.txt"))?, + "DIFF-NEW-SENTINEL\n" + ); + assert_eq!( + std::fs::read_to_string(ws.workspace().join("create.txt"))?, + "CREATE-SENTINEL\n" + ); + assert!(!ws.workspace().join("delete.txt").exists()); + + let settled_frame = h.frame().text(); + std::thread::sleep(Duration::from_millis(300)); + h.pump(); + assert_eq!( + settled_frame, + h.frame().text(), + "reduced-motion settled frame moved in {mode} mode" + ); + assert_real_pty_frame_geometry(h.frame(), cols, rows); + if ascii_safe { + assert!( + h.frame().text().is_ascii(), + "ASCII-safe mutation frame emitted non-ASCII cells:\n{}", + h.frame().debug_dump() + ); + } + + match mode { + "full" => { + assert!( + scroll_until(&mut h, ScrollDir::Up, "DIFF-NEW-SENTINEL"), + "full mode omitted the added line:\n{}", + h.frame().debug_dump() + ); + assert!(h.frame().contains("DIFF-OLD-SENTINEL")); + let old_row = visible_row_with_text(h.frame(), "DIFF-OLD-SENTINEL") + .expect("deleted line row"); + let new_row = + visible_row_with_text(h.frame(), "DIFF-NEW-SENTINEL").expect("added line row"); + let old_color = foreground_at_text(h.frame(), old_row, "DIFF-OLD-SENTINEL"); + let new_color = foreground_at_text(h.frame(), new_row, "DIFF-NEW-SENTINEL"); + assert_ne!(old_color, vt100::Color::Default); + assert_ne!(new_color, vt100::Color::Default); + assert_ne!(old_color, new_color, "added/deleted ANSI roles collapsed"); + } + "summary" => { + assert!( + scroll_until(&mut h, ScrollDir::Up, "+2 -2"), + "summary mode omitted semantic stats:\n{}", + h.frame().debug_dump() + ); + assert!(!scroll_until(&mut h, ScrollDir::Up, "DIFF-NEW-SENTINEL")); + assert!(!scroll_until(&mut h, ScrollDir::Down, "DIFF-NEW-SENTINEL")); + } + "off" => { + assert!( + scroll_until(&mut h, ScrollDir::Up, "4 files"), + "off mode lost the concise File outcome:\n{}", + h.frame().debug_dump() + ); + assert!(!scroll_until(&mut h, ScrollDir::Up, "+2 -2")); + assert!(!scroll_until(&mut h, ScrollDir::Down, "+2 -2")); + assert!(!scroll_until(&mut h, ScrollDir::Up, "DIFF-NEW-SENTINEL")); + assert!(!scroll_until(&mut h, ScrollDir::Down, "DIFF-NEW-SENTINEL")); + + h.send(keys::key::alt('v'))?; + h.wait_for_text("Raw detail", KEY_TIMEOUT)?; + assert!( + scroll_until(&mut h, ScrollDir::Down, "Exact File change"), + "off mode lost the exact-evidence section:\n{}", + h.frame().debug_dump() + ); + assert!( + scroll_until(&mut h, ScrollDir::Down, "DIFF-NEW-SENTINEL"), + "off mode exact evidence omitted the applied diff:\n{}", + h.frame().debug_dump() + ); + } + _ => unreachable!("bounded diff mode matrix"), + } + + write_real_pty_evidence( + &format!("file-mutation-{mode}-{cols}x{rows}"), + &format!( + "size={cols}x{rows}\ntheme={theme}\ninline_diffs={mode}\npermission={permission_posture}\nreduced_motion=true\nascii_safe={ascii_safe}\nprovider=sealed-loopback" + ), + h.frame(), + )?; + let _ = h.shutdown(); + server.join().expect("File fixture server thread")?; + } + Ok(()) +} + +/// Three-turn loopback fixture for the real screen acceptance path: +/// `work_update` establishes canonical To-do/Work state, then an actual Bash +/// call waits on a test-owned workspace sentinel before emitting enough exact +/// output to exercise adaptive evidence, then a long final answer makes +/// transcript retention and scrolling observable. +fn spawn_tool_lifecycle_screen_fixture( + release_signal: &str, + final_answer: String, +) -> anyhow::Result<(String, std::thread::JoinHandle>)> { + let listener = TcpListener::bind("127.0.0.1:0")?; + listener.set_nonblocking(true)?; + let address = listener.local_addr()?; + let shell_command = format!( + "printf 'PTY-TOOL-START\\n'; while [ ! -f {release_signal} ]; do sleep 0.05; done; i=0; while [ \"$i\" -lt 2800 ]; do if [ \"$i\" -eq 120 ]; then printf 'PTY-EVIDENCE-DEEP-SENTINEL\\n'; fi; printf 'PTY-EVIDENCE-%04d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n' \"$i\"; i=$((i + 1)); done; printf 'PTY-TOOL-END\\n'" + ); + let replies = [ + pty_tool_call_sse( + "call_work_pty", + "work_update", + serde_json::json!({ + "todos": [{ + "content": "PTY lifecycle acceptance", + "status": "in_progress" + }] + }), + ), + pty_tool_call_sse( + "call_bash_pty", + "Bash", + serde_json::json!({ + "action": "run", + "command": shell_command, + "timeout_ms": 60_000 + }), + ), + pty_text_sse(&final_answer), + ]; + + let handle = std::thread::spawn(move || -> anyhow::Result<()> { + let deadline = Instant::now() + Duration::from_secs(75); + let mut chat_index = 0_usize; + let mut contract_errors = Vec::new(); + let mut connection_errors = Vec::new(); + while chat_index < replies.len() && Instant::now() < deadline { + let Ok((mut stream, _)) = listener.accept() else { + std::thread::sleep(Duration::from_millis(10)); + continue; + }; + let request = match read_http_request(&mut stream) { + Ok(request) if !request.trim().is_empty() => request, + Ok(_) => continue, + Err(error) => { + connection_errors.push(format!("request read failed: {error:#}")); + continue; + } + }; + let request_line = request.lines().next().unwrap_or_default(); + let mut is_chat_response = false; + let (content_type, body) = if request_line.starts_with("GET ") + && request_line.contains("/models") + { + ( + "application/json", + serde_json::json!({ + "object": "list", + "data": [{"id": "deepseek-v4-pro", "object": "model"}] + }) + .to_string(), + ) + } else if request_line.starts_with("POST ") + && request_line.contains("/chat/completions") + { + let request_body = request + .split_once("\r\n\r\n") + .map(|(_, body)| body) + .unwrap_or_default(); + let request_json: serde_json::Value = match serde_json::from_str(request_body) { + Ok(request_json) => request_json, + Err(error) => { + contract_errors.push(format!( + "chat request JSON parse failed for {request_line}: {error}" + )); + let body = "invalid JSON".to_string(); + let response = format!( + "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + continue; + } + }; + let request_contract = request_json.to_string(); + match chat_index { + 0 if !request_contract.contains("exercise the real PTY tool lifecycle") => { + contract_errors.push("initial request omitted the user prompt".into()); + } + 1 if !(request_contract.contains("call_work_pty") + && request_contract.contains("\"role\":\"tool\"")) => + { + contract_errors + .push("second request omitted the work_update result".into()); + } + 2 => { + let bash_result = request_json + .get("messages") + .and_then(serde_json::Value::as_array) + .and_then(|messages| { + messages.iter().find(|message| { + message.get("role").and_then(serde_json::Value::as_str) + == Some("tool") + && message + .get("tool_call_id") + .and_then(serde_json::Value::as_str) + == Some("call_bash_pty") + }) + }) + .and_then(|message| message.get("content")) + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if !(bash_result.contains("Exact evidence retained") + && bash_result.contains("art_call_bash_pty") + && !bash_result.contains("PTY-EVIDENCE-DEEP-SENTINEL") + && !bash_result.contains("/artifacts/")) + { + contract_errors.push(format!( + "final request omitted the bounded session-owned Bash receipt (receipt={}, handle={}, deep_sentinel={}, artifact_path={})", + bash_result.contains("Exact evidence retained"), + bash_result.contains("art_call_bash_pty"), + bash_result.contains("PTY-EVIDENCE-DEEP-SENTINEL"), + bash_result.contains("/artifacts/"), + )); + } + } + 0 | 1 => {} + _ => unreachable!("bounded lifecycle fixture"), + } + let body = replies[chat_index].clone(); + is_chat_response = true; + ("text/event-stream", body) + } else { + ("text/plain", "not found".to_string()) + }; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + if let Err(error) = stream + .write_all(response.as_bytes()) + .and_then(|()| stream.flush()) + { + connection_errors + .push(format!("response write failed for {request_line}: {error}")); + continue; + } + if is_chat_response { + chat_index += 1; + } + } + anyhow::ensure!( + chat_index == replies.len(), + "fixture served {chat_index}/{} chat requests; ignored connection errors: {}", + replies.len(), + connection_errors.join(" | ") + ); + if !contract_errors.is_empty() { + anyhow::bail!( + "tool lifecycle fixture contract errors:\n{}", + contract_errors.join("\n") + ); + } + Ok(()) + }); + Ok((format!("http://{address}"), handle)) +} + +/// Stream an explicit private reasoning delta, hold it until the PTY has +/// captured the semantic `reasoning` phase, then issue a canonical File.read. +/// Follow-up turns issue a real Bash wait and a final receipt. Every pause is +/// controlled by a test-owned workspace primitive; no product frame is +/// generated or reconstructed outside the real terminal parser. +fn spawn_semantic_activity_motion_fixture( + reasoning_release: std::path::PathBuf, + fifo_name: &str, + bash_release_name: &str, +) -> anyhow::Result<(String, std::thread::JoinHandle>)> { + let listener = TcpListener::bind("127.0.0.1:0")?; + listener.set_nonblocking(true)?; + let address = listener.local_addr()?; + let reasoning_prefix = format!( + "data: {}\n\n", + serde_json::json!({ + "id": "chatcmpl-semantic-reasoning", + "object": "chat.completion.chunk", + "model": "deepseek-v4-pro", + "choices": [{ + "index": 0, + "delta": { + "reasoning_content": "PRIVATE-MOTION-TRACE-MUST-STAY-HIDDEN" + }, + "finish_reason": null + }] + }) + ); + let read_tail = pty_tool_call_sse( + "call_read_motion", + "File", + // Pin the read to the streaming path. The small-file fast path opens + // a FIFO once for metadata and then opens it again for content, which + // makes a real PTY fixture depend on a race-prone third reader. An + // explicit range keeps the content read on the already-open file. + serde_json::json!({"action": "read", "path": fifo_name, "start_line": 1}), + ); + let shell_command = format!( + "printf 'MOTION-BASH-START\\n'; while [ ! -f {bash_release_name} ]; do sleep 0.05; done; printf 'MOTION-BASH-END\\n'" + ); + let bash_reply = pty_tool_call_sse( + "call_bash_motion", + "Bash", + serde_json::json!({ + "action": "run", + "command": shell_command, + "timeout_ms": 60_000 + }), + ); + let final_reply = pty_text_sse("SEMANTIC-MOTION-DONE"); + + let handle = std::thread::spawn(move || -> anyhow::Result<()> { + let deadline = Instant::now() + Duration::from_secs(90); + let mut chat_index = 0_usize; + let mut contract_errors = Vec::new(); + let mut connection_errors = Vec::new(); + while chat_index < 3 && Instant::now() < deadline { + let Ok((mut stream, _)) = listener.accept() else { + std::thread::sleep(Duration::from_millis(10)); + continue; + }; + let request = match read_http_request(&mut stream) { + Ok(request) if !request.trim().is_empty() => request, + Ok(_) => continue, + Err(error) => { + connection_errors.push(format!("request read failed: {error:#}")); + continue; + } + }; + let request_line = request.lines().next().unwrap_or_default(); + if request_line.starts_with("GET ") && request_line.contains("/models") { + let body = serde_json::json!({ + "object": "list", + "data": [{"id": "deepseek-v4-pro", "object": "model"}] + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + if let Err(error) = stream + .write_all(response.as_bytes()) + .and_then(|()| stream.flush()) + { + connection_errors.push(format!( + "model response write failed for {request_line}: {error}" + )); + } + continue; + } + if !(request_line.starts_with("POST ") && request_line.contains("/chat/completions")) { + contract_errors.push(format!( + "unexpected semantic activity fixture request: {request_line}" + )); + let body = "not found"; + let response = format!( + "HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + continue; + } + + let request_body = request + .split_once("\r\n\r\n") + .map(|(_, body)| body) + .unwrap_or_default(); + let request_json: serde_json::Value = match serde_json::from_str(request_body) { + Ok(request_json) => request_json, + Err(error) => { + contract_errors.push(format!( + "chat request JSON parse failed for {request_line}: {error}" + )); + let body = "invalid JSON"; + let response = format!( + "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + continue; + } + }; + let request_contract = request_json.to_string(); + match chat_index { + 0 if !request_contract.contains("show semantic activity with less chrome") => { + contract_errors.push("initial request omitted semantic activity prompt".into()); + } + 1 if !(request_contract.contains("call_read_motion") + && request_contract.contains("MOTION-FIFO-CONTENT") + && request_contract.contains("\"role\":\"tool\"")) => + { + contract_errors.push(format!( + "Bash request omitted completed File.read evidence (call={}, content={}, tool_role={})", + request_contract.contains("call_read_motion"), + request_contract.contains("MOTION-FIFO-CONTENT"), + request_contract.contains("\"role\":\"tool\""), + )); + } + 2 if !(request_contract.contains("call_bash_motion") + && request_contract.contains("MOTION-BASH-END")) => + { + contract_errors.push(format!( + "final request omitted completed Bash evidence (call={}, completion={})", + request_contract.contains("call_bash_motion"), + request_contract.contains("MOTION-BASH-END"), + )); + } + 0..=2 => {} + _ => unreachable!("bounded semantic activity fixture"), + } + + if chat_index == 0 { + let body_len = reasoning_prefix.len() + read_tail.len(); + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {body_len}\r\nConnection: close\r\n\r\n" + ); + if let Err(error) = stream + .write_all(headers.as_bytes()) + .and_then(|()| stream.write_all(reasoning_prefix.as_bytes())) + .and_then(|()| stream.flush()) + { + connection_errors.push(format!( + "reasoning prefix write failed for {request_line}: {error}" + )); + continue; + } + + let release_deadline = Instant::now() + Duration::from_secs(30); + while !reasoning_release.exists() && Instant::now() < release_deadline { + std::thread::sleep(Duration::from_millis(10)); + } + anyhow::ensure!( + reasoning_release.exists(), + "reasoning phase was never released by the PTY test" + ); + if let Err(error) = stream + .write_all(read_tail.as_bytes()) + .and_then(|()| stream.flush()) + { + connection_errors.push(format!( + "File.read tail write failed for {request_line}: {error}" + )); + continue; + } + } else { + let body = if chat_index == 1 { + &bash_reply + } else { + &final_reply + }; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + if let Err(error) = stream + .write_all(response.as_bytes()) + .and_then(|()| stream.flush()) + { + connection_errors.push(format!( + "chat response write failed for {request_line}: {error}" + )); + continue; + } + } + chat_index += 1; + } + anyhow::ensure!( + chat_index == 3, + "semantic activity fixture served {chat_index}/3 chat requests; ignored connection errors: {}", + connection_errors.join(" | ") + ); + if !contract_errors.is_empty() { + anyhow::bail!( + "semantic activity fixture contract errors:\n{}", + contract_errors.join("\n") + ); + } + Ok(()) + }); + Ok((format!("http://{address}"), handle)) +} + +fn open_semantic_fifo_writer( + path: &std::path::Path, + deadline: Instant, +) -> anyhow::Result { + use std::os::unix::fs::OpenOptionsExt; + + loop { + match std::fs::OpenOptions::new() + .write(true) + .custom_flags(libc::O_NONBLOCK) + .open(path) + { + Ok(writer) => return Ok(writer), + Err(error) + if matches!(error.raw_os_error(), Some(libc::ENXIO) | Some(libc::EINTR)) + && Instant::now() < deadline => + { + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) if error.raw_os_error() == Some(libc::ENXIO) => { + anyhow::bail!("timed out waiting for FIFO reader at {}", path.display()); + } + Err(error) => return Err(error.into()), + } + } +} + +fn release_semantic_read_fifo( + path: std::path::PathBuf, +) -> std::thread::JoinHandle> { + std::thread::spawn(move || -> anyhow::Result<()> { + // ReadFileTool opens once to sniff PDF magic, then keeps the main + // explicit-range read on one already-open descriptor. Move the FIFO + // after the sniff writer and recreate it at the requested path so the + // content writer cannot attach to the short-lived sniff reader. The + // nonblocking, deadline-bounded opens turn product exit into a useful + // test error instead of an unbounded join. + let sniff_path = path.with_extension("sniff"); + let mut sniff_writer = + open_semantic_fifo_writer(&path, Instant::now() + Duration::from_secs(20))?; + std::fs::rename(&path, &sniff_path)?; + let mkfifo = Command::new("mkfifo").arg(&path).status()?; + anyhow::ensure!( + mkfifo.success(), + "mkfifo failed while rotating {}", + path.display() + ); + sniff_writer.write_all(b"TEXT")?; + sniff_writer.flush()?; + drop(sniff_writer); + + let mut content_writer = + open_semantic_fifo_writer(&path, Instant::now() + Duration::from_secs(20))?; + content_writer.write_all(b"MOTION-FIFO-CONTENT\n")?; + content_writer.flush()?; + Ok(()) + }) +} + +fn whale_ansi_signature(frame: &qa_harness::Frame) -> Vec { + const WHALE_BACK: &str = "▗▄▄▄▄▄▄▄▄▄▄▄▖"; + let (row, mut col) = frame + .find_text(WHALE_BACK) + .unwrap_or_else(|| panic!("idle BlueWhale silhouette missing:\n{}", frame.debug_dump())); + WHALE_BACK + .chars() + .filter_map(|ch| { + let color = frame.colors_at(row, col).map(|colors| colors.0); + col = col.saturating_add( + u16::try_from(unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1)).unwrap_or(1), + ); + color + }) + .collect() +} + +fn colored_foreground(frame: &qa_harness::Frame, needle: &str) -> vt100::Color { + let (row, col) = frame + .find_text(needle) + .unwrap_or_else(|| panic!("{needle:?} missing:\n{}", frame.debug_dump())); + let foreground = frame + .colors_at(row, col) + .expect("rendered text cell should have ANSI colors") + .0; + assert_ne!( + foreground, + vt100::Color::Default, + "{needle:?} lost its semantic foreground:\n{}", + frame.debug_dump() + ); + foreground +} + +fn phase_marker_for_label(frame: &qa_harness::Frame, label: &str) -> char { + // Transcript cards may carry the same semantic word (for example the + // collapsed `reasoning hidden` receipt). The phase strip is the lowest + // matching row, immediately above the composer, so search bottom-up. + let row = (0..frame.rows()) + .rev() + .find(|&row| frame.row(row).contains(label)) + .unwrap_or_else(|| panic!("phase {label:?} missing:\n{}", frame.debug_dump())); + frame + .row(row) + .chars() + .find(|ch| !ch.is_whitespace()) + .expect("phase row should contain a marker") +} + +fn maybe_transcript_marker_before_icon( + frame: &qa_harness::Frame, + needle: &str, + icon: &str, +) -> Option { + let row = visible_row_with_text(frame, needle)?; + frame + .row(row) + .split_once(icon)? + .0 + .chars() + .rev() + .find(|ch| !ch.is_whitespace()) +} + +fn wait_for_transcript_marker_before_icon( + h: &mut Harness, + needle: &str, + icon: &str, + timeout: Duration, +) -> anyhow::Result { + let mut captured = None; + h.wait_for( + |frame| { + captured = maybe_transcript_marker_before_icon(frame, needle, icon); + captured.is_some() + }, + timeout, + )?; + captured.ok_or_else(|| { + anyhow::anyhow!( + "transcript marker before {icon:?} on row {needle:?} missing after wait:\n{}", + h.frame().debug_dump() + ) + }) +} + +fn horizontal_rule_fills(frame: &qa_harness::Frame, row: u16, cols: u16) -> bool { + let text = frame.row(row); + UnicodeWidthStr::width(text.as_str()) == usize::from(cols) + && (text.chars().all(|ch| ch == '─') || text.chars().all(|ch| ch == '-')) +} + +/// `Harness::resize` updates the parser dimensions immediately, before the +/// child has emitted its resized composition. Require the product's full-width +/// header and composer rules before accepting the frame so a preserved old +/// frame (or the clear between frames) cannot masquerade as a settled resize. +fn resize_and_wait_for_composition( + h: &mut Harness, + rows: u16, + cols: u16, + mut predicate: F, + timeout: Duration, +) -> anyhow::Result<()> +where + F: FnMut(&qa_harness::Frame) -> bool, +{ + let already_sized = { + let frame = h.frame(); + frame.rows() == rows && frame.cols() == cols + }; + if !already_sized { + h.resize(rows, cols)?; + } + h.wait_for( + |frame| { + let full_width_rules = (0..rows) + .filter(|&row| horizontal_rule_fills(frame, row, cols)) + .count(); + frame.rows() == rows + && frame.cols() == cols + && frame.row(0).contains("cw ") + && horizontal_rule_fills(frame, 1, cols) + && full_width_rules >= 2 + && frame.contains(COMPOSER_READY_TEXT) + && predicate(frame) + }, + timeout, + ) +} + +fn assert_running_tool_lifecycle_frame( + frame: &qa_harness::Frame, + cols: u16, + rows: u16, +) -> (vt100::Color, vt100::Color) { + assert_real_pty_frame_geometry(frame, cols, rows); + let dump = frame.debug_dump(); + assert!( + frame.row(0).contains("act"), + "Act missing from header:\n{dump}" + ); + assert!( + frame.row(0).contains("Full Access"), + "effective Full Access missing from header:\n{dump}" + ); + assert!( + frame.contains("Work ·"), + "canonical Work surface missing:\n{dump}" + ); + assert!( + frame.contains("PTY lifecycle"), + "canonical Work item missing:\n{dump}" + ); + assert!( + frame.contains("using tool"), + "statusline did not name live tool use:\n{dump}" + ); + assert!( + frame.contains("run running"), + "real Bash card did not remain live:\n{dump}" + ); + assert!( + frame.find_text("▗▄▄▄▄▄▄▄▄▄▄▄▖").is_none(), + "idle BlueWhale should yield to functional transcript activity:\n{dump}" + ); + let tool_row = visible_row_with_text(frame, "run running").expect("live Bash row"); + let tool_running = foreground_at_text(frame, tool_row, "running"); + assert_ne!( + tool_running, + vt100::Color::Default, + "Bash running state lost its semantic foreground:\n{dump}" + ); + (colored_foreground(frame, "using tool"), tool_running) +} + +enum ScrollDir { + Up, + Down, +} + +/// Scroll the transcript one step at a time — letting each step settle past the +/// input-coalescing/redraw throttle — until `needle` is on-screen. Each step +/// sends both a page key and a wheel event so it works regardless of which the +/// transcript honors. Returns whether the needle became visible. +fn scroll_until(h: &mut Harness, dir: ScrollDir, needle: &str) -> bool { + if h.frame().contains(needle) { + return true; + } + for _ in 0..50 { + match dir { + ScrollDir::Up => { + let _ = h.send(keys::key::page_up()); + let _ = h.send(keys::mouse::wheel_up(10, 10)); + } + ScrollDir::Down => { + let _ = h.send(keys::mouse::wheel_down(10, 10)); + let _ = h.send(keys::mouse::wheel_down(10, 10)); + } + } + let _ = h.wait_for_idle(Duration::from_millis(60), Duration::from_millis(400)); + if h.frame().contains(needle) { + return true; + } + } + false +} + +/// Motion-enabled companion to [`scroll_until`]. The underwater field keeps +/// emitting real frames while browsing history, so "no PTY bytes arrived" is +/// not a valid settle signal. Poll the desired rendered state directly after +/// each bounded input instead. +fn scroll_until_with_motion(h: &mut Harness, dir: ScrollDir, needle: &str) -> bool { + if h.frame().contains(needle) { + return true; + } + for _ in 0..50 { + match dir { + ScrollDir::Up => { + let _ = h.send(keys::key::page_up()); + let _ = h.send(keys::mouse::wheel_up(10, 10)); + } + ScrollDir::Down => { + let _ = h.send(keys::mouse::wheel_down(10, 10)); + let _ = h.send(keys::mouse::wheel_down(10, 10)); + } + } + if h.wait_for(|frame| frame.contains(needle), Duration::from_millis(160)) + .is_ok() + { + return true; + } + } + false +} + +/// #4603: long transcript output must be retained beyond the viewport and +/// remain reviewable by scrolling, with follow-tail restored on return to the +/// bottom. Provider-free: the reply is a sealed loopback SSE fixture. +#[test] +fn long_output_scrolls_and_restores_follow_tail() -> anyhow::Result<()> { + let _guard = qa_pty_test_lock(); + + // A reply well over three 24-row viewports: a head marker, ~90 numbered + // lines, a very wide line (horizontal overflow), and a tail marker. + let mut lines = vec!["SCROLLPROBE-HEAD".to_string()]; + for i in 1..=90 { + lines.push(format!("SCROLLPROBE-LINE-{i:03}")); } lines.push(format!("SCROLLPROBE-WIDE-START{}WIDE-END", "x".repeat(200))); lines.push("SCROLLPROBE-TAIL".to_string()); @@ -1577,3 +3336,854 @@ fn long_output_scrolls_and_restores_follow_tail() -> anyhow::Result<()> { server.join().expect("scroll fixture server thread"); Ok(()) } + +/// #2886: drive the actual shipped TUI through a Unix PTY and a sealed +/// provider fixture. The first real tool establishes canonical To-do/Work; +/// the second is a real Bash process held live by a workspace sentinel so the +/// running card and statusline can be inspected without a timing race. +/// Captures, when requested, are parsed PTY frames emitted by the product. +#[test] +fn real_tool_lifecycle_crosses_work_status_resize_and_scroll_in_a_unix_pty() -> anyhow::Result<()> { + let _guard = qa_pty_test_lock(); + const RELEASE_SIGNAL: &str = "pty-tool-release.signal"; + + let mut answer_lines = vec!["PTY-LIFECYCLE-HEAD".to_string()]; + for line in 1..=72 { + answer_lines.push(format!("PTY-LIFECYCLE-LINE-{line:03}")); + } + answer_lines.push("PTY-LIFECYCLE-TAIL".to_string()); + let (base_url, server) = + spawn_tool_lifecycle_screen_fixture(RELEASE_SIGNAL, answer_lines.join("\n"))?; + + let ws = make_sealed_workspace()?; + let codewhale_home = ws.home().join(".codewhale"); + let codex_home = ws.home().join(".codex"); + std::fs::create_dir_all(&codex_home)?; + std::fs::write( + codewhale_home.join("config.toml"), + "allow_shell = true\nreasoning_effort = \"low\"\n\n[retry]\nenabled = false\n\n[update]\ncheck_for_updates = false\n", + )?; + std::fs::write( + codewhale_home.join("settings.toml"), + "theme = \"dark\"\nlocale = \"en\"\ndefault_mode = \"agent\"\npermission_posture = \"full-access\"\nlow_motion = false\nfancy_animations = true\ncomposer_border = true\n", + )?; + std::fs::write( + codex_home.join("models_cache.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "fetched_at": chrono::Utc::now(), + "models": [{"slug": "deepseek-v4-pro", "priority": 1}] + }))?, + )?; + + let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui")) + .cwd(ws.workspace()) + .clear_env() + .seal_home(ws.home()) + .env("CODEWHALE_HOME", codewhale_home.to_string_lossy()) + .env( + "DEEPSEEK_CONFIG_PATH", + codewhale_home.join("config.toml").to_string_lossy(), + ) + .env("CODEX_HOME", codex_home.to_string_lossy()) + .env("CODEWHALE_PROVIDER", "deepseek") + .env("DEEPSEEK_API_KEY", "deepseek-local-test-key") + .env("DEEPSEEK_BASE_URL", &base_url) + .env("CODEWHALE_BASE_URL", &base_url) + .env("DEEPSEEK_MODEL", "deepseek-v4-pro") + .env("CODEWHALE_MODEL", "deepseek-v4-pro") + .env("RUST_LOG", "warn") + .args([ + "--workspace", + ws.workspace().to_str().expect("utf-8 workspace path"), + "--no-project-config", + "--skip-onboarding", + "--mouse-capture", + "--yolo", + ]) + .size(24, 80) + .spawn()?; + enter_launch_session(&mut h)?; + + // The authored BlueWhale is part of the real idle composition, with ANSI + // ink emitted by the terminal renderer. Its text silhouette is stable; + // the opt-in caustic shimmer changes cell colors without moving the mark. + let initial_whale = whale_ansi_signature(h.frame()); + assert!( + initial_whale + .iter() + .any(|color| *color != vt100::Color::Default), + "idle BlueWhale lost its ANSI ink:\n{}", + h.frame().debug_dump() + ); + let shimmer_deadline = Instant::now() + Duration::from_secs(5); + let mut shimmer_observed = false; + while Instant::now() < shimmer_deadline { + std::thread::sleep(Duration::from_millis(80)); + h.pump(); + if whale_ansi_signature(h.frame()) != initial_whale { + shimmer_observed = true; + break; + } + } + assert!( + shimmer_observed, + "animated idle BlueWhale never changed ANSI cells:\n{}", + h.frame().debug_dump() + ); + + for (cols, rows) in [(80_u16, 24_u16), (100, 30), (140, 40)] { + resize_and_wait_for_composition( + &mut h, + rows, + cols, + |frame| { + frame.rows() == rows + && frame.cols() == cols + && frame.find_text("▗▄▄▄▄▄▄▄▄▄▄▄▖").is_some() + }, + KEY_TIMEOUT, + )?; + let frame = h.frame(); + assert_real_pty_frame_geometry(frame, cols, rows); + assert_empty_state_hierarchy(frame, false); + assert!( + whale_ansi_signature(frame) + .iter() + .any(|color| *color != vt100::Color::Default), + "BlueWhale ANSI ink missing at {cols}x{rows}:\n{}", + frame.debug_dump() + ); + write_real_pty_evidence( + &format!("tool-lifecycle-idle-{cols}x{rows}"), + &format!( + "size={cols}x{rows}\nphase=idle\nreal_pty=true\nprovider=loopback\nbluewhale=true" + ), + frame, + )?; + } + + resize_and_wait_for_composition( + &mut h, + 24, + 80, + |frame| frame.rows() == 24 && frame.cols() == 80, + KEY_TIMEOUT, + )?; + let prompt = "exercise the real PTY tool lifecycle"; + h.paste(prompt)?; + h.wait_for_text(prompt, KEY_TIMEOUT)?; + std::thread::sleep(Duration::from_millis(180)); + h.pump(); + h.send(keys::key::enter())?; + h.wait_for( + |frame| { + frame.contains("using tool") + && frame.contains("run running") + && frame.contains("PTY lifecycle") + }, + Duration::from_secs(15), + )?; + + // Typed tool liveness belongs to the phase strip once transcript activity + // replaces the idle BlueWhale. Prove the actual emitted marker advances; + // do not relabel the decorative idle silhouette as a running tool row. + let initial_tool_marker = phase_marker_for_label(h.frame(), "using tool"); + let marker_deadline = Instant::now() + Duration::from_secs(2); + let mut tool_marker_moved = false; + while Instant::now() < marker_deadline { + std::thread::sleep(Duration::from_millis(80)); + h.pump(); + if phase_marker_for_label(h.frame(), "using tool") != initial_tool_marker { + tool_marker_moved = true; + break; + } + } + assert!( + tool_marker_moved, + "using-tool phase marker never advanced in the real PTY:\n{}", + h.frame().debug_dump() + ); + + let mut live_colors = None; + for (cols, rows) in [(80_u16, 24_u16), (100, 30), (140, 40)] { + resize_and_wait_for_composition( + &mut h, + rows, + cols, + |frame| { + frame.rows() == rows + && frame.cols() == cols + && frame.contains("using tool") + && frame.contains("run running") + && frame.contains("PTY lifecycle") + }, + KEY_TIMEOUT, + )?; + let frame = h.frame(); + let colors = assert_running_tool_lifecycle_frame(frame, cols, rows); + if let Some(expected) = live_colors { + assert_eq!( + colors, + expected, + "using-tool/transcript ANSI roles changed at {cols}x{rows}:\n{}", + frame.debug_dump() + ); + } else { + live_colors = Some(colors); + } + write_real_pty_evidence( + &format!("tool-lifecycle-running-{cols}x{rows}"), + &format!("size={cols}x{rows}\nphase=using-tool\nreal_tool=Bash\nwork_surface=Work"), + frame, + )?; + } + + // Release the real shell process only after every live-size assertion. + std::fs::write(ws.workspace().join(RELEASE_SIGNAL), "release\n")?; + h.wait_for_text("PTY-LIFECYCLE-TAIL", Duration::from_secs(20))?; + h.wait_for( + |frame| frame.contains("✓ done") && !frame.contains("run running"), + Duration::from_secs(10), + )?; + { + let frame = h.frame(); + let dump = frame.debug_dump(); + assert_real_pty_frame_geometry(frame, 140, 40); + assert!( + frame.contains("PTY-LIFECYCLE-TAIL"), + "tail not followed:\n{dump}" + ); + assert!( + !frame.contains("PTY-LIFECYCLE-HEAD"), + "long settled transcript did not exceed the viewport:\n{dump}" + ); + assert!( + frame.contains("Work ·"), + "Work vanished after settlement:\n{dump}" + ); + let done_row = visible_row_with_text(frame, "✓ done").expect("done phase row"); + let done_color = foreground_at_text(frame, done_row, "done"); + assert_ne!(done_color, vt100::Color::Default, "done lost ANSI role"); + assert_ne!( + done_color, + live_colors.expect("live colors").0, + "done and live tool use collapsed to one ANSI role" + ); + write_real_pty_evidence( + "tool-lifecycle-settled-140x40", + "size=140x40\nphase=done\ntranscript=settled\nfollow_tail=true", + frame, + )?; + } + + assert!( + scroll_until_with_motion(&mut h, ScrollDir::Up, "PTY-LIFECYCLE-HEAD"), + "settled transcript head is not reviewable at 140x40:\n{}", + h.frame().debug_dump() + ); + for (cols, rows) in [(100_u16, 30_u16), (80, 24)] { + resize_and_wait_for_composition( + &mut h, + rows, + cols, + |frame| frame.rows() == rows && frame.cols() == cols, + KEY_TIMEOUT, + )?; + assert!( + h.frame().contains("PTY-LIFECYCLE-HEAD") + || scroll_until_with_motion(&mut h, ScrollDir::Up, "PTY-LIFECYCLE-HEAD"), + "transcript head was lost after reflow to {cols}x{rows}:\n{}", + h.frame().debug_dump() + ); + assert_real_pty_frame_geometry(h.frame(), cols, rows); + } + assert!( + scroll_until_with_motion(&mut h, ScrollDir::Up, "run done"), + "settled real Bash card is not retained in the transcript:\n{}", + h.frame().debug_dump() + ); + assert!( + !h.frame().contains("run running"), + "settled Bash card reverted to live state:\n{}", + h.frame().debug_dump() + ); + + // The path-free evidence receipt is a real transcript row at every release + // geometry. Select that row with terminal mouse bytes, then exercise the + // shipped Alt/Option+V detail shortcut; screenshots remain genuine PTY + // frames and never include a fabricated product surface. + for (cols, rows) in [(80_u16, 24_u16), (100, 30), (140, 40)] { + resize_and_wait_for_composition( + &mut h, + rows, + cols, + |frame| frame.rows() == rows && frame.cols() == cols, + KEY_TIMEOUT, + )?; + let receipt_visible = h.frame().contains("Exact evidence retained") + || scroll_until_with_motion(&mut h, ScrollDir::Up, "Exact evidence retained") + || scroll_until_with_motion(&mut h, ScrollDir::Down, "Exact evidence retained"); + assert!( + receipt_visible, + "evidence receipt is not reviewable at {cols}x{rows}:\n{}", + h.frame().debug_dump() + ); + let (row, col) = h + .frame() + .find_text("Exact evidence retained") + .expect("visible selectable evidence receipt"); + h.send(keys::mouse::click(row, col))?; + h.send(keys::key::alt('v'))?; + h.wait_for( + |frame| { + frame.contains("Raw detail — Bash") + && frame.contains("Raw detail for the selected item") + }, + KEY_TIMEOUT, + )?; + let frame = h.frame(); + assert_real_pty_frame_geometry(frame, cols, rows); + assert!(!frame.contains("/artifacts/")); + assert!(!frame.contains(".codewhale/sessions")); + assert!(!frame.contains(ws.home().to_string_lossy().as_ref())); + write_real_pty_evidence( + &format!("tool-lifecycle-evidence-detail-{cols}x{rows}"), + &format!( + "size={cols}x{rows}\nreal_pty=true\nreceipt=selected\nshortcut=Alt+V\npath_leak=false" + ), + frame, + )?; + h.send(keys::key::ch('q'))?; + h.wait_for(|frame| !frame.contains("Raw detail — Bash"), KEY_TIMEOUT)?; + } + + resize_and_wait_for_composition( + &mut h, + 24, + 80, + |frame| frame.rows() == 24 && frame.cols() == 80, + KEY_TIMEOUT, + )?; + assert!( + scroll_until_with_motion(&mut h, ScrollDir::Down, "PTY-LIFECYCLE-TAIL"), + "follow-tail is not restorable at 80x24:\n{}", + h.frame().debug_dump() + ); + + for (cols, rows) in [(100_u16, 30_u16), (140, 40)] { + resize_and_wait_for_composition( + &mut h, + rows, + cols, + |frame| frame.rows() == rows && frame.cols() == cols, + KEY_TIMEOUT, + )?; + assert!( + h.frame().contains("PTY-LIFECYCLE-TAIL") + || scroll_until_with_motion(&mut h, ScrollDir::Down, "PTY-LIFECYCLE-TAIL"), + "follow-tail was lost after reflow to {cols}x{rows}:\n{}", + h.frame().debug_dump() + ); + let frame = h.frame(); + assert_real_pty_frame_geometry(frame, cols, rows); + assert!(frame.contains("Work ·"), "Work missing at {cols}x{rows}"); + } + + let artifact_dir = std::fs::read_dir(ws.home().join(".codewhale/sessions"))? + .filter_map(Result::ok) + .map(|entry| entry.path().join("artifacts")) + .find(|path| path.join("art_call_bash_pty.txt").is_file()) + .ok_or_else(|| anyhow::anyhow!("real PTY Bash evidence artifact was not retained"))?; + let exact = std::fs::read(artifact_dir.join("art_call_bash_pty.txt"))?; + assert!( + String::from_utf8_lossy(&exact).contains("PTY-EVIDENCE-DEEP-SENTINEL"), + "real PTY artifact lost its deep sentinel" + ); + let metadata: serde_json::Value = serde_json::from_slice(&std::fs::read( + artifact_dir.join("art_call_bash_pty.evidence.json"), + )?)?; + assert_eq!(metadata["handle"], "art_call_bash_pty"); + assert_eq!(metadata["call_id"], "call_bash_pty"); + assert_eq!(metadata["tool_name"], "Bash"); + assert_eq!(metadata["size_bytes"], exact.len() as u64); + let digest = Sha256::digest(&exact) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + assert_eq!( + metadata["digest"], digest, + "real PTY metadata digest must bind the exact bytes" + ); + + let _ = h.shutdown(); + server + .join() + .expect("tool lifecycle fixture server thread")?; + Ok(()) +} + +/// The semantic phase strip is acceptance-tested against the shipped binary, +/// not a synthetic widget. A sealed loopback stream holds explicit private +/// reasoning, canonical File.read on a FIFO, and Bash on a sentinel long +/// enough for the real PTY to capture each truthful one-row label. +#[test] +fn semantic_activity_motion_crosses_reasoning_reading_and_tool_use_in_a_real_unix_pty() +-> anyhow::Result<()> { + let _guard = qa_pty_test_lock(); + + #[derive(Clone, Copy)] + struct Case { + name: &'static str, + theme: &'static str, + motion_mode: &'static str, + reduced_motion: bool, + fancy_animations: bool, + expect_motion: bool, + static_marker: Option, + ascii_safe: bool, + reasoning_size: (u16, u16), + reading_size: (u16, u16), + tool_size: (u16, u16), + } + + let cases = [ + Case { + name: "dark-motion", + theme: "dark", + motion_mode: "full", + reduced_motion: false, + fancy_animations: true, + expect_motion: true, + static_marker: None, + ascii_safe: false, + reasoning_size: (100, 32), + reading_size: (50, 16), + tool_size: (140, 40), + }, + Case { + name: "light-reduced", + theme: "light", + motion_mode: "reduced", + reduced_motion: true, + fancy_animations: false, + expect_motion: false, + static_marker: Some('⣤'), + ascii_safe: false, + reasoning_size: (100, 32), + reading_size: (80, 24), + tool_size: (100, 32), + }, + Case { + name: "dark-ascii", + theme: "dark", + motion_mode: "full", + reduced_motion: false, + fancy_animations: true, + expect_motion: true, + static_marker: None, + ascii_safe: true, + reasoning_size: (80, 24), + reading_size: (80, 24), + tool_size: (80, 24), + }, + Case { + name: "dark-still", + theme: "dark", + motion_mode: "still", + reduced_motion: false, + fancy_animations: false, + expect_motion: false, + static_marker: Some('›'), + ascii_safe: false, + reasoning_size: (100, 32), + reading_size: (80, 24), + tool_size: (100, 32), + }, + ]; + + for case in cases { + const FIFO_NAME: &str = "semantic-motion-read.fifo"; + const REASONING_RELEASE: &str = "semantic-motion-reasoning.release"; + const BASH_RELEASE: &str = "semantic-motion-bash.release"; + + let ws = make_sealed_workspace()?; + let fifo_path = ws.workspace().join(FIFO_NAME); + let mkfifo = Command::new("mkfifo").arg(&fifo_path).status()?; + anyhow::ensure!( + mkfifo.success(), + "mkfifo failed for {}", + fifo_path.display() + ); + + let reasoning_release = ws.workspace().join(REASONING_RELEASE); + let (base_url, server) = spawn_semantic_activity_motion_fixture( + reasoning_release.clone(), + FIFO_NAME, + BASH_RELEASE, + )?; + + let codewhale_home = ws.home().join(".codewhale"); + let codex_home = ws.home().join(".codex"); + std::fs::create_dir_all(&codex_home)?; + std::fs::write( + codewhale_home.join("config.toml"), + "allow_shell = true\nreasoning_effort = \"low\"\n\n[retry]\nenabled = false\n\n[update]\ncheck_for_updates = false\n", + )?; + std::fs::write( + codewhale_home.join("settings.toml"), + format!( + "theme = \"{}\"\nlocale = \"en\"\ndefault_mode = \"agent\"\npermission_posture = \"full-access\"\nshow_thinking = false\nlow_motion = {}\nfancy_animations = {}\ncomposer_border = true\n", + case.theme, case.reduced_motion, case.fancy_animations, + ), + )?; + std::fs::write( + codex_home.join("models_cache.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "fetched_at": chrono::Utc::now(), + "models": [{"slug": "deepseek-v4-pro", "priority": 1}] + }))?, + )?; + + let mut builder = Harness::builder(Harness::cargo_bin("codewhale-tui")) + .cwd(ws.workspace()) + .clear_env() + .seal_home(ws.home()) + .env("CODEWHALE_HOME", codewhale_home.to_string_lossy()) + .env( + "DEEPSEEK_CONFIG_PATH", + codewhale_home.join("config.toml").to_string_lossy(), + ) + .env("CODEX_HOME", codex_home.to_string_lossy()) + .env("CODEWHALE_PROVIDER", "deepseek") + .env("DEEPSEEK_API_KEY", "deepseek-local-test-key") + .env("DEEPSEEK_BASE_URL", &base_url) + .env("CODEWHALE_BASE_URL", &base_url) + .env("DEEPSEEK_MODEL", "deepseek-v4-pro") + .env("CODEWHALE_MODEL", "deepseek-v4-pro") + .env("RUST_LOG", "warn") + .args([ + "--workspace", + ws.workspace().to_str().expect("utf-8 workspace path"), + "--no-project-config", + "--skip-onboarding", + "--mouse-capture", + "--yolo", + ]) + .size(case.reasoning_size.1, case.reasoning_size.0); + if case.reduced_motion { + builder = builder.env("NO_ANIMATIONS", "1"); + } + if case.ascii_safe { + builder = builder.env("CODEWHALE_ASCII_SAFE", "1"); + } + let mut h = builder.spawn()?; + enter_launch_session(&mut h)?; + + let prompt = "show semantic activity with less chrome"; + h.paste(prompt)?; + h.wait_for_text(prompt, KEY_TIMEOUT)?; + h.send(keys::key::enter())?; + h.wait_for(|frame| frame.contains("reasoning"), Duration::from_secs(15))?; + + let reasoning_marker = phase_marker_for_label(h.frame(), "reasoning"); + if !case.expect_motion { + assert_eq!( + Some(reasoning_marker), + case.static_marker, + "wrong semantic fallback marker in {}:\n{}", + case.name, + h.frame().debug_dump() + ); + std::thread::sleep(Duration::from_millis(320)); + h.pump(); + assert_eq!( + phase_marker_for_label(h.frame(), "reasoning"), + reasoning_marker, + "static reasoning marker moved in {}:\n{}", + case.name, + h.frame().debug_dump() + ); + } else { + let static_marker = if case.ascii_safe { '>' } else { '›' }; + let deadline = Instant::now() + Duration::from_secs(2); + let mut first_animated = + (reasoning_marker != static_marker).then_some(reasoning_marker); + while Instant::now() < deadline && first_animated.is_none() { + std::thread::sleep(Duration::from_millis(80)); + h.pump(); + let marker = phase_marker_for_label(h.frame(), "reasoning"); + if marker != static_marker { + first_animated = Some(marker); + } + } + let first_animated = first_animated.unwrap_or_else(|| { + panic!( + "semantic reasoning marker never crossed its earned-motion delay in {}:\n{}", + case.name, + h.frame().debug_dump() + ) + }); + + let deadline = Instant::now() + Duration::from_secs(2); + let mut advanced_after_delay = false; + while Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(80)); + h.pump(); + let marker = phase_marker_for_label(h.frame(), "reasoning"); + if marker != static_marker && marker != first_animated { + advanced_after_delay = true; + break; + } + } + assert!( + advanced_after_delay, + "semantic reasoning marker froze after its earned-motion delay in {}:\n{}", + case.name, + h.frame().debug_dump() + ); + } + { + let frame = h.frame(); + let dump = frame.debug_dump(); + assert_real_pty_frame_geometry(frame, case.reasoning_size.0, case.reasoning_size.1); + assert!( + !frame.contains("PRIVATE-MOTION-TRACE-MUST-STAY-HIDDEN"), + "private reasoning leaked into the product UI:\n{dump}" + ); + colored_foreground(frame, "reasoning"); + if case.ascii_safe { + assert!( + frame.text().is_ascii(), + "ASCII-safe reasoning frame:\n{dump}" + ); + } + write_real_pty_evidence( + &format!( + "semantic-{}-reasoning-{}x{}", + case.name, case.reasoning_size.0, case.reasoning_size.1 + ), + &format!( + "theme={}\nphase=reasoning\nmotion_mode={}\nreduced_motion={}\nfancy_animations={}\nascii_safe={}\nprivate_reasoning_visible=false", + case.theme, + case.motion_mode, + case.reduced_motion, + case.fancy_animations, + case.ascii_safe + ), + frame, + )?; + } + + std::fs::write(&reasoning_release, "release\n")?; + h.wait_for(|frame| frame.contains("reading"), Duration::from_secs(15))?; + resize_and_wait_for_composition( + &mut h, + case.reading_size.1, + case.reading_size.0, + |frame| frame.contains("reading"), + KEY_TIMEOUT, + )?; + { + let frame = h.frame(); + let dump = frame.debug_dump(); + let row = visible_row_with_text(frame, "reading").expect("reading phase row"); + let row_text = frame.row(row); + assert_real_pty_frame_geometry(frame, case.reading_size.0, case.reading_size.1); + assert!( + !frame.contains("PRIVATE-MOTION-TRACE-MUST-STAY-HIDDEN"), + "private reasoning leaked during File.read:\n{dump}" + ); + assert!( + frame.contains("read running") && frame.contains("live: Reading"), + "File.read transcript spacing collapsed:\n{dump}" + ); + if case.reading_size.0 < 60 { + assert!( + !row_text.contains('×') && !row_text.contains('s'), + "compact semantic row carried detail: {row_text:?}" + ); + } + colored_foreground(frame, "reading"); + if case.ascii_safe { + assert!(frame.text().is_ascii(), "ASCII-safe reading frame:\n{dump}"); + } + write_real_pty_evidence( + &format!( + "semantic-{}-reading-{}x{}", + case.name, case.reading_size.0, case.reading_size.1 + ), + &format!( + "theme={}\nphase=reading\nreal_tool=File.read\nsize={}x{}\nmotion_mode={}\nreduced_motion={}\nfancy_animations={}\nascii_safe={}\nprivate_reasoning_visible=false", + case.theme, + case.reading_size.0, + case.reading_size.1, + case.motion_mode, + case.reduced_motion, + case.fancy_animations, + case.ascii_safe + ), + frame, + )?; + } + + release_semantic_read_fifo(fifo_path) + .join() + .expect("FIFO release thread")?; + h.wait_for( + |frame| frame.contains("using tool") && frame.contains("run running"), + Duration::from_secs(15), + )?; + resize_and_wait_for_composition( + &mut h, + case.tool_size.1, + case.tool_size.0, + |frame| frame.contains("using tool") && frame.contains("run running"), + KEY_TIMEOUT, + )?; + { + let frame = h.frame(); + let dump = frame.debug_dump(); + let row = visible_row_with_text(frame, "using tool").expect("tool phase row"); + let row_text = frame.row(row); + assert_real_pty_frame_geometry(frame, case.tool_size.0, case.tool_size.1); + assert!( + frame.contains("read done") + && frame.contains("done: Reading") + && frame.contains("run running"), + "completed/read and live/Bash transcript spacing collapsed:\n{dump}" + ); + if case.tool_size.0 >= 60 { + let count = if case.ascii_safe { "X1" } else { "×1" }; + assert!( + row_text.contains(count), + "bounded tool count missing: {row_text:?}" + ); + } + assert!( + !row_text.contains("run ×1"), + "tool verb repeated: {row_text:?}" + ); + colored_foreground(frame, "using tool"); + if case.ascii_safe { + assert!(frame.text().is_ascii(), "ASCII-safe tool frame:\n{dump}"); + } + write_real_pty_evidence( + &format!( + "semantic-{}-using-tool-{}x{}", + case.name, case.tool_size.0, case.tool_size.1 + ), + &format!( + "theme={}\nphase=using-tool\nreal_tool=Bash.run\nsize={}x{}\nmotion_mode={}\nreduced_motion={}\nfancy_animations={}\nascii_safe={}\nprivate_reasoning_visible=false", + case.theme, + case.tool_size.0, + case.tool_size.1, + case.motion_mode, + case.reduced_motion, + case.fancy_animations, + case.ascii_safe + ), + frame, + )?; + } + + if case.expect_motion && !case.ascii_safe { + let deadline = Instant::now() + Duration::from_secs(2); + let mut first_animated = None; + while Instant::now() < deadline && first_animated.is_none() { + std::thread::sleep(Duration::from_millis(80)); + h.pump(); + if let Some(marker) = + maybe_transcript_marker_before_icon(h.frame(), "run running", "▶") + && marker != '›' + { + first_animated = Some(marker); + } + } + let first_animated = first_animated.unwrap_or_else(|| { + panic!( + "full-motion transcript tool marker never crossed its earned-motion delay in {}:\n{}", + case.name, + h.frame().debug_dump() + ) + }); + + let deadline = Instant::now() + Duration::from_secs(2); + let mut advanced_after_delay = false; + while Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(80)); + h.pump(); + if let Some(marker) = + maybe_transcript_marker_before_icon(h.frame(), "run running", "▶") + && marker != '›' + && marker != first_animated + { + advanced_after_delay = true; + break; + } + } + assert!( + advanced_after_delay, + "full-motion transcript tool marker froze after its earned-motion delay in {}:\n{}", + case.name, + h.frame().debug_dump() + ); + } else if !case.ascii_safe { + let initial_tool_marker = + wait_for_transcript_marker_before_icon(&mut h, "run running", "▶", KEY_TIMEOUT)?; + assert_eq!( + Some(initial_tool_marker), + case.static_marker, + "wrong transcript fallback marker in {}:\n{}", + case.name, + h.frame().debug_dump() + ); + std::thread::sleep(Duration::from_millis(320)); + h.pump(); + let marker_after_delay = + wait_for_transcript_marker_before_icon(&mut h, "run running", "▶", KEY_TIMEOUT)?; + assert_eq!( + marker_after_delay, + initial_tool_marker, + "static transcript tool marker moved in {}:\n{}", + case.name, + h.frame().debug_dump() + ); + resize_and_wait_for_composition( + &mut h, + case.tool_size.1, + case.tool_size.0 + 1, + |frame| frame.contains("using tool") && frame.contains("run running"), + KEY_TIMEOUT, + )?; + resize_and_wait_for_composition( + &mut h, + case.tool_size.1, + case.tool_size.0, + |frame| frame.contains("using tool") && frame.contains("run running"), + KEY_TIMEOUT, + )?; + let marker_after_resize = + wait_for_transcript_marker_before_icon(&mut h, "run running", "▶", KEY_TIMEOUT)?; + assert_eq!( + marker_after_resize, + initial_tool_marker, + "state-change redraw moved a static transcript marker in {}:\n{}", + case.name, + h.frame().debug_dump() + ); + } + + std::fs::write(ws.workspace().join(BASH_RELEASE), "release\n")?; + h.wait_for_text("SEMANTIC-MOTION-DONE", Duration::from_secs(20))?; + h.wait_for( + |frame| frame.contains("done") && !frame.contains("run running"), + Duration::from_secs(10), + )?; + let _ = h.shutdown(); + server + .join() + .expect("semantic activity fixture server thread")?; + } + + Ok(()) +} diff --git a/crates/tui/tests/skill_cli.rs b/crates/tui/tests/skill_cli.rs index 6992208684..5f49b229c3 100644 --- a/crates/tui/tests/skill_cli.rs +++ b/crates/tui/tests/skill_cli.rs @@ -20,11 +20,15 @@ use tiny_http::{Method, Response, Server}; // Pull the production source files into this test binary so the test can // reach `install`'s public surface without a dedicated library crate. // -// `install.rs` only references `crate::network_policy` so we just need that -// one helper module alongside `install` itself. +// `install.rs` references `crate::network_policy` and `super::package_digest` +// (sibling under `skills::`), so both are pulled in alongside `install`. #[path = "../src/network_policy.rs"] mod network_policy; +#[path = "../src/skills/package_digest.rs"] +#[allow(dead_code)] +mod package_digest; + #[path = "../src/skills/install.rs"] #[allow(dead_code)] mod install; @@ -176,6 +180,16 @@ async fn install_happy_path_writes_skill_and_marker() { installed_dir.join(install::INSTALLED_FROM_MARKER).is_file(), ".installed-from marker present" ); + let marker = std::fs::read_to_string(installed_dir.join(install::INSTALLED_FROM_MARKER)) + .expect("read marker"); + assert!( + marker.contains("\"schema_version\": 2") || marker.contains("\"schema_version\":2"), + "install must write metadata v2: {marker}" + ); + assert!( + marker.contains("content_digest"), + "install must bind content_digest: {marker}" + ); shutdown(tx, handle); } diff --git a/crates/tui/tests/support/qa_harness/frame.rs b/crates/tui/tests/support/qa_harness/frame.rs index c2474e850a..afaf453623 100644 --- a/crates/tui/tests/support/qa_harness/frame.rs +++ b/crates/tui/tests/support/qa_harness/frame.rs @@ -48,18 +48,23 @@ impl Frame { /// Single row of the screen, 0-indexed from the top, trimmed at the /// right edge. Returns the empty string for out-of-range rows. + /// + /// Use `Screen::rows` rather than concatenating `Cell::contents()`. + /// Untouched blank cells have no contents in `vt100`, but they still + /// occupy a real terminal column between painted cells. Concatenating + /// only cell contents therefore turns truthful PTY output such as + /// `read running` into the misleading debug string `readrunning`. + /// `Screen::rows` preserves those interior columns and skips the hidden + /// continuation cell of a wide glyph. pub fn row(&self, y: u16) -> String { if y >= self.rows() { return String::new(); } - let cols = self.cols(); - let mut out = String::with_capacity(cols as usize); - for x in 0..cols { - if let Some(cell) = self.parser.screen().cell(y, x) { - out.push_str(cell.contents()); - } - } - out + self.parser + .screen() + .rows(0, self.cols()) + .nth(usize::from(y)) + .unwrap_or_default() } pub fn contains(&self, needle: &str) -> bool { @@ -71,10 +76,49 @@ impl Frame { /// instead of hard-coding layout coordinates. pub fn find_text(&self, needle: &str) -> Option<(u16, u16)> { for row in 0..self.rows() { - let text = self.row(row); - if let Some(byte) = text.find(needle) { - let col = unicode_width::UnicodeWidthStr::width(&text[..byte]); - return Some((row, u16::try_from(col).ok()?)); + if let Some(col) = self.find_text_in_row(row, needle) { + return Some((row, col)); + } + } + None + } + + /// Locate text on one parsed terminal row without collapsing blank cells. + /// `vt100::Cell::contents()` is empty for untouched spaces, which matters + /// for transparent themes whose styled gaps do not need to be emitted. + pub fn find_text_in_row(&self, row: u16, needle: &str) -> Option { + if row >= self.rows() || needle.is_empty() { + return None; + } + for start in 0..self.cols() { + let mut col = start; + let mut matched = true; + for ch in needle.chars() { + let Some(cell) = self.parser.screen().cell(row, col) else { + matched = false; + break; + }; + let contents = cell.contents(); + let mut encoded = [0_u8; 4]; + let expected = ch.encode_utf8(&mut encoded); + if if ch == ' ' { + !contents.is_empty() && contents != " " + } else { + contents != expected + } { + matched = false; + break; + } + let width = unicode_width::UnicodeWidthChar::width(ch) + .unwrap_or(0) + .max(1); + let Ok(width) = u16::try_from(width) else { + return None; + }; + col = col.saturating_add(width); + } + if matched { + return Some(start); } } None @@ -132,3 +176,24 @@ impl Frame { out } } + +#[cfg(test)] +mod tests { + use super::Frame; + + #[test] + fn row_preserves_unpainted_interior_terminal_columns() { + let mut frame = Frame::new(1, 12); + frame.feed(b"read\x1b[6Grunning"); + + assert_eq!(frame.row(0), "read running"); + } + + #[test] + fn row_does_not_expand_wide_glyph_continuation_cells() { + let mut frame = Frame::new(1, 12); + frame.feed("界 read".as_bytes()); + + assert_eq!(frame.row(0), "界 read"); + } +} diff --git a/crates/tui/tests/support/qa_harness/keys.rs b/crates/tui/tests/support/qa_harness/keys.rs index 0714d52cc6..1cf3d64c18 100644 --- a/crates/tui/tests/support/qa_harness/keys.rs +++ b/crates/tui/tests/support/qa_harness/keys.rs @@ -23,6 +23,11 @@ pub mod key { b"\x1b[B".to_vec() } + /// Shift+Tab as the xterm BackTab sequence accepted by crossterm. + pub fn backtab() -> Vec { + b"\x1b[Z".to_vec() + } + pub fn page_up() -> Vec { b"\x1b[5~".to_vec() } diff --git a/crates/tui/tests/tool_lifecycle_acceptance.rs b/crates/tui/tests/tool_lifecycle_acceptance.rs index f2384a9cdd..9861502e86 100644 --- a/crates/tui/tests/tool_lifecycle_acceptance.rs +++ b/crates/tui/tests/tool_lifecycle_acceptance.rs @@ -20,6 +20,11 @@ const FEATURE_PATH: &str = concat!( const HAPPY_PATH_SCENARIO: &str = "Happy path lists the current directory through a tool"; const UNKNOWN_TOOL_SCENARIO: &str = "Unknown tool returns an error result"; const MALFORMED_ARGUMENTS_SCENARIO: &str = "Malformed tool arguments return an error result"; +const REAL_TOOL_ERROR_SCENARIO: &str = "A real tool error is returned to the follow-up request"; +const EMPTY_TOOL_RESULT_SCENARIO: &str = + "An empty tool result is returned to the follow-up request"; +const MISSING_SUMMARY_SCENARIO: &str = + "A follow-up answer missing the expected summary is detected"; const TOOL_CALL_ID: &str = "call_tool"; const TEST_MODEL: &str = "acceptance-model"; @@ -206,12 +211,11 @@ fn codewhale_should_send_tool_result_back_to_mocked_llm(world: &mut ToolLifecycl .get("content") .and_then(serde_json::Value::as_str) .expect("tool result content"); - for entry in ["README.md", "notes.txt", "src"] { - assert!( - content.contains(entry), - "tool result sent to LLM should include {entry}:\n{content}" - ); - } + assert_eq!( + content, + tool_result_output(world), + "follow-up request should preserve the exact public tool result" + ); } #[then(regex = r#"^the public tool result should report an error for "([^"]+)"$"#)] @@ -310,6 +314,74 @@ fn codewhale_should_send_malformed_argument_error_back_to_mocked_llm( assert_malformed_arguments_text(content); } +#[then( + regex = r#"^the public tool result should report a real error for "([^"]+)" containing "([^"]+)"$"# +)] +fn public_tool_result_should_report_real_error( + world: &mut ToolLifecycleWorld, + tool_name: String, + expected: String, +) { + let _ = tool_use_event(world, &tool_name); + let event = tool_result_event(world); + assert_eq!(event.get("status").and_then(Value::as_str), Some("error")); + + let output = event + .get("output") + .and_then(Value::as_str) + .expect("real tool error output"); + assert!( + output.contains(&expected) && output.contains("Failed to read"), + "real {tool_name} failure should preserve the path and execution error:\n{output}" + ); +} + +#[then("CodeWhale should send the real tool error back to the mocked LLM")] +fn codewhale_should_send_real_tool_error_back_to_mocked_llm(world: &mut ToolLifecycleWorld) { + let request = world + .requests + .iter() + .find(|request| request_contains_tool_result(request)) + .expect("expected a follow-up chat request containing the real tool error"); + let content = tool_result_message(request) + .and_then(|message| message.get("content")) + .and_then(Value::as_str) + .expect("real tool error content"); + assert!( + content.contains("missing.txt") && content.contains("Failed to read"), + "real tool error sent to the LLM should preserve the execution failure:\n{content}" + ); +} + +#[then("the public tool result should be an empty list")] +fn public_tool_result_should_be_an_empty_list(world: &mut ToolLifecycleWorld) { + let output = tool_result_output(world); + let value: Value = serde_json::from_str(output).expect("empty list_dir result should be JSON"); + assert_eq!(value, json!([]), "empty workspace should return []"); + assert_eq!( + tool_result_event(world) + .get("status") + .and_then(Value::as_str), + Some("success") + ); +} + +#[then("CodeWhale should send the empty tool result back to the mocked LLM")] +fn codewhale_should_send_empty_tool_result_back_to_mocked_llm(world: &mut ToolLifecycleWorld) { + let request = world + .requests + .iter() + .find(|request| request_contains_tool_result(request)) + .expect("expected a follow-up chat request containing the empty tool result"); + let content = tool_result_message(request) + .and_then(|message| message.get("content")) + .and_then(Value::as_str) + .expect("empty tool result content"); + let value: Value = + serde_json::from_str(content).expect("forwarded empty result should be JSON"); + assert_eq!(value, json!([]), "follow-up request should preserve []"); +} + #[then( regex = r#"^the public tool lifecycle should show a failed tool with raw input for "([^"]+)"$"# )] @@ -361,12 +433,7 @@ fn public_tool_lifecycle_should_show_failed_tool(world: &mut ToolLifecycleWorld, #[then(regex = r#"^the public output should include "([^"]+)"$"#)] fn public_output_should_include(world: &mut ToolLifecycleWorld, expected: String) { - let content = world - .events - .iter() - .filter(|event| event.get("type").and_then(Value::as_str) == Some("content")) - .filter_map(|event| event.get("content").and_then(Value::as_str)) - .collect::(); + let content = public_content_output(world); assert!( content.contains(&expected), "public content output should include {expected:?}:\nstdout:\n{}\nstderr:\n{}", @@ -375,6 +442,19 @@ fn public_output_should_include(world: &mut ToolLifecycleWorld, expected: String ); } +#[then(regex = r#"^acceptance should report the missing expected summary "([^"]+)"$"#)] +fn acceptance_should_report_missing_expected_summary( + world: &mut ToolLifecycleWorld, + expected: String, +) { + let report = require_follow_up_summary(world, &expected) + .expect_err("fixture answer intentionally omits the expected summary"); + assert!( + report.contains(&expected) && report.contains("missing expected summary"), + "missing-summary oracle should name the absent contract:\n{report}" + ); +} + #[tokio::test(flavor = "current_thread")] async fn happy_path_lists_current_directory_through_tool() { run_scenario(HAPPY_PATH_SCENARIO, 10).await; @@ -390,6 +470,21 @@ async fn malformed_tool_arguments_return_error_result() { run_scenario(MALFORMED_ARGUMENTS_SCENARIO, 10).await; } +#[tokio::test(flavor = "current_thread")] +async fn real_tool_error_is_returned_to_follow_up_request() { + run_scenario(REAL_TOOL_ERROR_SCENARIO, 10).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn empty_tool_result_is_returned_to_follow_up_request() { + run_scenario(EMPTY_TOOL_RESULT_SCENARIO, 10).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn missing_follow_up_summary_is_detected() { + run_scenario(MISSING_SUMMARY_SCENARIO, 11).await; +} + async fn run_scenario(name: &'static str, expected_steps: usize) { let writer = ToolLifecycleWorld::cucumber() .fail_on_skipped() @@ -723,6 +818,26 @@ fn value_contains_text(value: &Value, expected: &str) -> bool { } } +fn public_content_output(world: &ToolLifecycleWorld) -> String { + world + .events + .iter() + .filter(|event| event.get("type").and_then(Value::as_str) == Some("content")) + .filter_map(|event| event.get("content").and_then(Value::as_str)) + .collect() +} + +fn require_follow_up_summary(world: &ToolLifecycleWorld, expected: &str) -> Result<(), String> { + let content = public_content_output(world); + if content.contains(expected) { + Ok(()) + } else { + Err(format!( + "missing expected summary {expected:?} in follow-up answer {content:?}" + )) + } +} + fn parse_stream_events(stdout: &str) -> Vec { stdout .lines() diff --git a/crates/workflow-js/src/driver.rs b/crates/workflow-js/src/driver.rs index 2de1da260c..0c7efaf529 100644 --- a/crates/workflow-js/src/driver.rs +++ b/crates/workflow-js/src/driver.rs @@ -52,6 +52,25 @@ pub struct TaskRequest { pub thinking: Option, /// Run the child in a fresh git worktree for parallel edits. pub worktree: bool, + /// Explicit child mutation authority. A write-capable value remains + /// fail-closed unless at least one bounded scope below is declared. + #[serde(default)] + pub write_authority: Option, + /// Repo-relative directory trees the child expects to mutate. + #[serde(default)] + pub write_roots: Vec, + /// Repo-relative exact files the child expects to mutate. + #[serde(default)] + pub exact_files: Vec, + /// Named shared contracts owned by this child while active. + #[serde(default)] + pub coordination_contracts: Vec, + /// Bounded prerequisite facts relevant to this child. + #[serde(default)] + pub dependencies: Vec, + /// Bounded observable checks for child completion. + #[serde(default)] + pub acceptance: Vec, /// Explicit tool allowlist; required by the driver for `custom` roles. pub allowed_tools: Option>, /// Per-call spawn-depth override (driver clamps to its ceiling). @@ -232,4 +251,34 @@ mod tests { assert_eq!(drained.remaining(), Some(0)); assert!(drained.exhausted()); } + + #[test] + fn legacy_task_request_defaults_new_coordination_fields() { + let legacy = serde_json::json!({ + "description": "inspect the candidate", + "subagent_type": null, + "role": "reviewer", + "profile": null, + "model": null, + "model_strength": null, + "thinking": null, + "worktree": false, + "allowed_tools": null, + "max_depth": null, + "token_budget": null, + "max_steps": null, + "wall_time_secs": null, + "response_schema": null, + "label": null, + "phase": null + }); + + let request: TaskRequest = serde_json::from_value(legacy).unwrap(); + assert_eq!(request.write_authority, None); + assert!(request.write_roots.is_empty()); + assert!(request.exact_files.is_empty()); + assert!(request.coordination_contracts.is_empty()); + assert!(request.dependencies.is_empty()); + assert!(request.acceptance.is_empty()); + } } diff --git a/crates/workflow-js/src/vm.rs b/crates/workflow-js/src/vm.rs index ed1c907ec3..ad3f044477 100644 --- a/crates/workflow-js/src/vm.rs +++ b/crates/workflow-js/src/vm.rs @@ -720,6 +720,17 @@ struct TaskOptions { thinking: Option, #[serde(default)] worktree: bool, + write_authority: Option, + #[serde(default)] + write_roots: Vec, + #[serde(default)] + exact_files: Vec, + #[serde(default)] + coordination_contracts: Vec, + #[serde(default)] + dependencies: Vec, + #[serde(default)] + acceptance: Vec, allowed_tools: Option>, max_depth: Option, token_budget: Option, @@ -731,7 +742,7 @@ struct TaskOptions { } fn parse_task_options(opts_json: &str) -> Result { - let options: TaskOptions = + let mut options: TaskOptions = serde_json::from_str(opts_json).map_err(|err| format!("task(): invalid options: {err}"))?; let description = options .prompt @@ -750,6 +761,68 @@ fn parse_task_options(opts_json: &str) -> Result { .map(normalize_profile) .transpose() .map_err(|err| format!("task(): {err}"))?; + options.write_roots = normalize_task_paths("writeRoots", options.write_roots, 32)?; + options.exact_files = normalize_task_paths("exactFiles", options.exact_files, 32)?; + options.coordination_contracts = + normalize_task_string_list("coordinationContracts", options.coordination_contracts, 16)?; + options.dependencies = normalize_task_string_list("dependencies", options.dependencies, 8)?; + options.acceptance = normalize_task_string_list("acceptance", options.acceptance, 8)?; + let write_authority = options + .write_authority + .as_deref() + .map(|value| value.trim().to_ascii_lowercase()) + .map(|value| match value.as_str() { + "read_only" | "workspace_write" | "worktree_write" => Ok(value), + _ => Err(format!( + "task(): writeAuthority must be read_only, workspace_write, or worktree_write; got {value:?}" + )), + }) + .transpose()?; + if write_authority.as_deref() == Some("worktree_write") && !options.worktree { + return Err("task(): writeAuthority worktree_write requires worktree: true".to_string()); + } + let role_kind = role.as_deref().and_then(task_role_kind); + let type_kind = options.subagent_type.as_deref().and_then(task_role_kind); + if let (Some(role_kind), Some(type_kind)) = (role_kind, type_kind) + && role_kind != type_kind + { + return Err("task(): role and subagentType declare contradictory authorities".to_string()); + } + let declared_kind = role_kind.or(type_kind); + if matches!(declared_kind, Some(TaskRoleKind::ReadOnly)) + && write_authority + .as_deref() + .is_some_and(|authority| authority != "read_only") + { + return Err("task(): read-only roles cannot declare write-capable authority".to_string()); + } + if write_authority + .as_deref() + .is_some_and(|authority| authority != "read_only") + && options.write_roots.is_empty() + && options.exact_files.is_empty() + && options.coordination_contracts.is_empty() + { + return Err( + "task(): write-capable authority requires writeRoots, exactFiles, or coordinationContracts" + .to_string(), + ); + } + let explicit_write_identity = declared_kind == Some(TaskRoleKind::Implementer) + || (declared_kind == Some(TaskRoleKind::General) + && (role.is_some() || options.subagent_type.is_some())) + || (profile.is_some() && declared_kind.is_none()); + if explicit_write_identity + && write_authority.as_deref() != Some("read_only") + && options.write_roots.is_empty() + && options.exact_files.is_empty() + && options.coordination_contracts.is_empty() + { + return Err( + "task(): explicit write-capable identities require writeRoots, exactFiles, or coordinationContracts" + .to_string(), + ); + } Ok(TaskRequest { description, subagent_type: options.subagent_type, @@ -759,6 +832,12 @@ fn parse_task_options(opts_json: &str) -> Result { model_strength: options.model_strength, thinking: options.thinking, worktree: options.worktree, + write_authority, + write_roots: options.write_roots, + exact_files: options.exact_files, + coordination_contracts: options.coordination_contracts, + dependencies: options.dependencies, + acceptance: options.acceptance, allowed_tools: options.allowed_tools, max_depth: options.max_depth, token_budget: options.token_budget, @@ -770,6 +849,94 @@ fn parse_task_options(opts_json: &str) -> Result { }) } +fn normalize_task_string_list( + field: &str, + values: Vec, + limit: usize, +) -> Result, String> { + if values.len() > limit { + return Err(format!("task(): {field} accepts at most {limit} entries")); + } + let mut normalized = Vec::new(); + for value in values { + let value = value.trim(); + if value.is_empty() || value.chars().count() > 512 { + return Err(format!( + "task(): {field} entries must be 1..=512 characters" + )); + } + if !normalized.iter().any(|existing| existing == value) { + normalized.push(value.to_string()); + } + } + Ok(normalized) +} + +fn normalize_task_paths( + field: &str, + values: Vec, + limit: usize, +) -> Result, String> { + if values.len() > limit { + return Err(format!("task(): {field} accepts at most {limit} entries")); + } + let mut normalized = Vec::new(); + for raw in values { + let raw = raw.trim().replace('\\', "/"); + let windows_drive = raw.as_bytes().get(1) == Some(&b':') + && raw.as_bytes().first().is_some_and(u8::is_ascii_alphabetic); + if raw.is_empty() + || raw.chars().count() > 512 + || raw.starts_with('/') + || raw.starts_with("//") + || windows_drive + || raw.chars().any(|ch| matches!(ch, '\0' | '\r' | '\n')) + { + return Err(format!( + "task(): {field} entries must be bounded repo-relative paths" + )); + } + let mut segments = Vec::new(); + for segment in raw.split('/') { + match segment { + "" | "." => {} + ".." => { + return Err(format!( + "task(): {field} paths cannot contain parent traversal" + )); + } + value => segments.push(value), + } + } + let path = if segments.is_empty() { + ".".to_string() + } else { + segments.join("/") + }; + if !normalized.contains(&path) { + normalized.push(path); + } + } + Ok(normalized) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TaskRoleKind { + ReadOnly, + General, + Implementer, +} + +fn task_role_kind(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "explore" | "explorer" | "plan" | "planner" | "review" | "reviewer" | "verify" + | "verifier" => Some(TaskRoleKind::ReadOnly), + "general" | "worker" => Some(TaskRoleKind::General), + "implement" | "implementer" => Some(TaskRoleKind::Implementer), + _ => None, + } +} + /// The JS prelude injected before every script: determinism bans, the /// `task`/`parallel`/`pipeline`/`log`/`phase` stdlib (design §7), and the /// `budget` global. diff --git a/crates/workflow-js/tests/vm_tests.rs b/crates/workflow-js/tests/vm_tests.rs index 881f0ded17..dad5ec0fca 100644 --- a/crates/workflow-js/tests/vm_tests.rs +++ b/crates/workflow-js/tests/vm_tests.rs @@ -64,13 +64,19 @@ async fn task_round_trip_carries_all_options_and_normalizes_profile() { &driver, r#" return await task({ - description: "explore the code", - subagentType: "explore", + description: "implement the bounded change", + subagentType: "implementer", profile: " ALpha-1 ", model: "deepseek-chat", modelStrength: "faster", thinking: "low", worktree: true, + writeAuthority: "worktree_write", + writeRoots: ["crates/tui/src"], + exactFiles: ["Cargo.toml"], + coordinationContracts: ["public-api"], + dependencies: ["issue-4619"], + acceptance: ["locked tests pass"], allowedTools: ["read", "grep"], maxDepth: 2, tokenBudget: 5000, @@ -84,18 +90,24 @@ async fn task_round_trip_carries_all_options_and_normalizes_profile() { ) .await .unwrap(); - assert_eq!(value, json!("done:explore the code")); + assert_eq!(value, json!("done:implement the bounded change")); let requests = driver.requests(); assert_eq!(requests.len(), 1); let request = &requests[0]; - assert_eq!(request.description, "explore the code"); - assert_eq!(request.subagent_type.as_deref(), Some("explore")); + assert_eq!(request.description, "implement the bounded change"); + assert_eq!(request.subagent_type.as_deref(), Some("implementer")); assert_eq!(request.profile.as_deref(), Some("alpha-1")); assert_eq!(request.model.as_deref(), Some("deepseek-chat")); assert_eq!(request.model_strength.as_deref(), Some("faster")); assert_eq!(request.thinking.as_deref(), Some("low")); assert!(request.worktree); + assert_eq!(request.write_authority.as_deref(), Some("worktree_write")); + assert_eq!(request.write_roots, ["crates/tui/src"]); + assert_eq!(request.exact_files, ["Cargo.toml"]); + assert_eq!(request.coordination_contracts, ["public-api"]); + assert_eq!(request.dependencies, ["issue-4619"]); + assert_eq!(request.acceptance, ["locked tests pass"]); assert_eq!( request.allowed_tools.as_deref(), Some(["read".to_string(), "grep".to_string()].as_slice()) @@ -109,6 +121,129 @@ async fn task_round_trip_carries_all_options_and_normalizes_profile() { assert_eq!(request.phase.as_deref(), Some("P1")); } +#[tokio::test] +async fn task_write_authority_requires_bounded_coordination_scope() { + let driver = Arc::new(FakeDriver::new()); + let error = run( + &driver, + r#" + return await task({ + prompt: "edit without a claim", + type: "implementer", + writeAuthority: "workspace_write", + }); + "#, + json!(null), + ) + .await + .expect_err("unscoped Workflow writer must fail before driver dispatch") + .to_string(); + assert!(error.contains("requires writeRoots"), "{error}"); + assert!(driver.requests().is_empty()); +} + +#[tokio::test] +async fn task_coordination_lists_deduplicate_with_hard_count_bounds() { + let driver = Arc::new(FakeDriver::new()); + run( + &driver, + r#" + return await task({ + prompt: "bounded edit", + type: "implementer", + writeAuthority: "workspace_write", + exactFiles: ["src/a.rs", "src/a.rs"], + dependencies: ["A", "A"], + acceptance: ["tests pass", "tests pass"], + }); + "#, + json!(null), + ) + .await + .expect("bounded unique coordination values"); + let request = driver.requests().pop().expect("request"); + assert_eq!(request.exact_files, ["src/a.rs"]); + assert_eq!(request.dependencies, ["A"]); + assert_eq!(request.acceptance, ["tests pass"]); +} + +#[tokio::test] +async fn task_write_paths_normalize_and_reject_escape_spellings() { + let driver = Arc::new(FakeDriver::new()); + run( + &driver, + r#"return await task({ + prompt: "bounded edit", + type: "implementer", + writeRoots: ["./src//", "src"], + exactFiles: ["src\\lib.rs"] + });"#, + json!(null), + ) + .await + .expect("normalized repo-relative paths"); + let request = driver.requests().pop().expect("request"); + assert_eq!(request.write_roots, ["src"]); + assert_eq!(request.exact_files, ["src/lib.rs"]); + + for path in [ + "../outside", + "/tmp/outside", + "C:\\outside", + "src/../../outside", + ] { + let driver = Arc::new(FakeDriver::new()); + let source = format!( + "return await task({{ prompt: 'escape', type: 'implementer', writeRoots: [{}] }});", + serde_json::to_string(path).expect("path json") + ); + let message = script_message(run(&driver, &source, json!(null)).await); + assert!( + message.contains("repo-relative") || message.contains("traversal"), + "{path}: {message}" + ); + assert!(driver.requests().is_empty()); + } +} + +#[tokio::test] +async fn task_explicit_write_roles_fail_closed_without_scope_and_reject_write_escalation() { + for source in [ + r#"return await task({prompt: "no scope", type: "implementer"});"#, + r#"return await task({prompt: "no scope", type: "general"});"#, + r#"return await task({prompt: "no scope", profile: "release-lead"});"#, + r#"return await task({prompt: "wrong authority", type: "reviewer", writeAuthority: "workspace_write", writeRoots: ["src"]});"#, + r#"return await task({prompt: "role conflict", type: "implementer", role: "reviewer", writeRoots: ["src"]});"#, + ] { + let driver = Arc::new(FakeDriver::new()); + let message = script_message(run(&driver, source, json!(null)).await); + assert!( + message.contains("require") + || message.contains("cannot") + || message.contains("contradictory"), + "{message}" + ); + assert!(driver.requests().is_empty()); + } +} + +#[tokio::test] +async fn task_implementer_identity_can_be_narrowed_to_read_only_authority() { + let driver = Arc::new(FakeDriver::new()); + let value = run( + &driver, + r#"return await task({prompt: "verification-only plan", type: "implementer", writeAuthority: "read_only"});"#, + json!(null), + ) + .await + .expect("read-only authority must safely narrow an implementer identity"); + assert_eq!(value, json!("done:verification-only plan")); + let request = driver.requests().pop().expect("request"); + assert_eq!(request.subagent_type.as_deref(), Some("implementer")); + assert_eq!(request.write_authority.as_deref(), Some("read_only")); + assert!(request.write_roots.is_empty()); +} + #[tokio::test] async fn task_accepts_prompt_and_type_aliases() { let driver = Arc::new(FakeDriver::new()); diff --git a/crates/workflow/src/lib.rs b/crates/workflow/src/lib.rs index 562213a933..d4f3206256 100644 --- a/crates/workflow/src/lib.rs +++ b/crates/workflow/src/lib.rs @@ -2057,8 +2057,8 @@ pub fn scopes_overlap(left: &[String], right: &[String]) -> bool { } fn scope_overlaps(left: &str, right: &str) -> bool { - let left = normalize_scope(left); - let right = normalize_scope(right); + let left = normalize_file_scope_root(left); + let right = normalize_file_scope_root(right); if left == right || left == "." || right == "." { return true; @@ -2073,7 +2073,10 @@ fn scope_overlaps(left: &str, right: &str) -> bool { left_path.starts_with(right_path) || right_path.starts_with(left_path) } -fn normalize_scope(scope: &str) -> String { +/// Normalize the suffix-glob spelling accepted by declarative workflow +/// `file_scope` into the concrete directory root enforced at runtime. +#[must_use] +pub fn normalize_file_scope_root(scope: &str) -> String { let trimmed = scope.trim().trim_start_matches("./").trim_end_matches('/'); trimmed .strip_suffix("/**") diff --git a/docs/ACCESSIBILITY.md b/docs/ACCESSIBILITY.md index bd0dcdf054..e11b48821c 100644 --- a/docs/ACCESSIBILITY.md +++ b/docs/ACCESSIBILITY.md @@ -18,6 +18,7 @@ visual motion and density for screen-reader and low-motion users. | `calm_mode` setting | `true` | Collapses tool-output details by default and trims status messages. Useful for screen readers that announce every redraw. | | `show_thinking` setting | `true` | Set to `false` to hide model `reasoning_content` blocks entirely. | | `show_tool_details` setting | `false` | Set to `true` to expand tool calls inline; details remain available on demand either way. | +| `inline_diffs` setting | `full` | Use `summary` or `off` to reduce inline File-change density. Exact applied evidence remains available with Alt/Option+V in every mode. | ## Standard env-var surface diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f80719d4d2..7507a5102a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,13 +1,16 @@ -# codewhale Architecture +# Codewhale Architecture This document provides an overview of the codewhale architecture for developers and contributors. Current boundary note (v0.9.1): - `crates/tui` is still the live end-user runtime for the TUI, runtime API, task manager, and tool execution loop. - Other workspace crates are being split out incrementally, but they are not yet the sole runtime source of truth. -- The LSP subsystem (`crates/tui/src/lsp/`) is fully wired into the engine's post-tool-execution path - (`core/engine/lsp_hooks.rs`), providing inline diagnostics after every edit_file/apply_patch/write_file. -- The swarm agent system was removed in v0.8.5. The active sub-agent surface is the single `agent` tool; persistent RLM sessions remain available through `rlm_open` / `rlm_eval` / `rlm_configure` / `rlm_close`. +- The LSP subsystem (`crates/tui/src/lsp/`) is fully wired into the engine's + post-tool-execution path (`core/engine/lsp_hooks.rs`), providing inline + diagnostics after `File` write, edit, and patch actions. +- The swarm agent system was removed in v0.8.5. The active sub-agent surface is + the single `agent` tool; persistent RLM sessions are available through the + deferred `rlm` action family. No model-visible swarm tool remains in the active codebase. ## High-Level Overview @@ -169,7 +172,9 @@ drives turns through Chat Completions. - `mod.rs` - Sandbox type definitions - `policy.rs` - Sandbox policy configuration - `seatbelt.rs` - macOS Seatbelt profile generation + - `bwrap.rs` - opt-in Linux bubblewrap command wrapper - `landlock.rs` - Linux Landlock detection and future helper contract + - `seccomp.rs` - dormant Linux seccomp implementation; not wired into commands - `windows.rs` - Windows helper contract; not advertised until a Job Object process-containment helper exists @@ -207,18 +212,18 @@ drives turns through Chat Completions. 3. While degraded/offline, new prompts are queued in-memory and mirrored to `~/.codewhale/sessions/checkpoints/offline_queue.json` 4. Queue edits (`/queue ...`) are persisted continuously so drafts and queued prompts survive restarts 5. Successful turn completion clears the active checkpoint and writes a durable session snapshot -6. Agent/Yolo turns also take pre/post-turn side-git workspace snapshots under `~/.codewhale/snapshots///.git`; `/restore N` and `revert_turn` restore file state without changing conversation history or the user's `.git` +6. Action-capable turns also take pre/post-turn side-git workspace snapshots under `~/.codewhale/snapshots///.git`; `/restore N` and `revert_turn` restore file state without changing conversation history or the user's `.git` ### Tool Execution 1. LLM requests tool via `tool_use` content block 2. Tool registry looks up handler 3. Pre-execution hooks run -4. Approval requested if needed (non-yolo mode) -5. Tool executed (possibly sandboxed on macOS) +4. Approval requested when the effective permission posture and policy require it +5. Tool executed (possibly wrapped by Seatbelt on macOS or opt-in bubblewrap on Linux) 6. Post-execution hooks run 7. Result metadata is retained on runtime item records -8. **LSP post-edit hook**: if the tool was `edit_file`/`apply_patch`/`write_file` and LSP is enabled, the engine runs `run_post_edit_lsp_hook()` to collect diagnostics +8. **LSP post-edit hook**: after a `File` write, edit, or patch action (including a replay-only legacy alias), the engine runs `run_post_edit_lsp_hook()` when LSP is enabled to collect diagnostics 9. **Diagnostics flush**: before the next API request, `flush_pending_lsp_diagnostics()` injects any collected errors as a synthetic user message 10. Result returned to agent loop @@ -273,7 +278,12 @@ ordinary durable tasks. 1. Create skill directory with `SKILL.md` 2. Define skill prompt and optional scripts -3. Place in `~/.codewhale/skills/` +3. Place in a CodeWhale-owned root (`~/.codewhale/skills/` or + `/.codewhale/skills/`), or import from a compatible harness root + through `/skills` + +See [SKILLS.md](SKILLS.md) for the Skills Manager, audit inventory, and the +rule that compatible roots (`.claude`, `.agents`, …) are never mutated in place. ### Adding Hooks @@ -288,12 +298,15 @@ command = "echo 'Running tool: $TOOL_NAME'" ## Key Design Decisions 1. **Streaming-first**: All LLM responses stream for responsiveness -2. **Tool safety**: Non-YOLO mode requires approval for destructive operations, including side-effectful MCP tools +2. **Tool safety**: Ask and Auto-Review require approval according to tool and + managed policy; Full Access removes ordinary prompts but not hard safety + holds. Side-effectful MCP tools use the same boundary. 3. **Extensibility**: MCP, skills, and hooks allow customization without code changes 4. **Cross-platform**: Core works on Linux/macOS/Windows. Sandbox guarantees - are platform-specific: macOS Seatbelt is the active policy path; Linux and - Windows require helper enforcement before they should be treated as full OS - sandboxing. + are platform-specific: macOS uses Seatbelt when available; Linux uses an + installed bubblewrap executable only when explicitly enabled; Windows has + no advertised OS command sandbox. Landlock, seccomp, and the Windows helper + contract are not wired into command execution. 5. **Minimal dependencies**: Careful dependency selection for build speed 6. **Local-first runtime API**: HTTP/SSE endpoints are intended for trusted localhost access and are served by the `crates/tui` runtime today diff --git a/docs/AUTOMATIC_WORKFLOWS.md b/docs/AUTOMATIC_WORKFLOWS.md index 45b8661f35..3718493fb5 100644 --- a/docs/AUTOMATIC_WORKFLOWS.md +++ b/docs/AUTOMATIC_WORKFLOWS.md @@ -51,6 +51,14 @@ Elevated work (writes, shell beyond read-only, network, secrets, worktrees, high budget) should surface an approval card with goal, child summary, capability flags, and budget before launch (#4126). +Worktree isolation and write ownership are separate. A write-capable `task()` +declares `writeAuthority: "workspace_write"` or `"worktree_write"` plus at +least one repo-relative `writeRoots`, `exactFiles`, or +`coordinationContracts` value. `worktree: true` selects isolation but does not +silently grant mutation authority. A prompt-only general task is read-only. +`dependencies` and `acceptance` carry bounded child-specific prerequisites and +observable completion checks; they are not a parent-transcript copy. + ## What you see while it runs - **Workflow panel** — phases, children, status, budget diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 773af74edb..a1058e1efb 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -289,7 +289,7 @@ Supported keys in the project overlay (top-level fields only): |---|---| | `model` | override `default_text_model` | | `reasoning_effort` | force `"high"` / `"max"` for a complex repo | -| `approval_policy` | only values that tighten the user's current approval posture | +| `approval_policy` | only values that tighten the user's current permission posture | | `sandbox_mode` | only values that tighten the user's current sandbox posture | | `notes_path` | keep notes in-repo | | `max_subagents` | clamp sub-agent concurrency for a constrained repo (clamped to 1..=20) | @@ -1174,6 +1174,13 @@ Common settings keys: currently typed directory segment in deterministic alphabetical order. - `show_thinking` (on/off) - `show_tool_details` (on/off) +- `inline_diffs` (`full`, `summary`, or `off`; default `full`): controls the + inline presentation of successful structured File mutations. `full` shows a + bounded red/green diff and semantic statistics, `summary` keeps only the + statistics, and `off` keeps the calm changed-file outcome. All three retain + the exact applied change in the selected File receipt's Alt/Option+V detail. + Failure and cancellation never render a successful diff. Save the choice + with `/config inline_diffs --save`. - `locale` (`auto`, `en`, `ja`, `zh-Hans`, `pt-BR`; default `auto`): UI chrome locale. `auto` checks `LC_ALL`, `LC_MESSAGES`, then `LANG`; unsupported or missing locales fall back to English. The runtime also exposes the resolved @@ -1259,17 +1266,19 @@ If you are upgrading from older releases: ### Core keys (used by the TUI/engine) -- `provider` (string, optional): `deepseek` (default), `deepseek-anthropic`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `huggingface`, `together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`, `meta`, or `xai`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `deepseek-anthropic` targets DeepSeek's Anthropic Messages-compatible endpoint at `https://api.deepseek.com/anthropic` using `DEEPSEEK_API_KEY`; `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `volcengine` targets Volcengine Ark's OpenAI-compatible coding endpoint at `https://ark.cn-beijing.volces.com/api/coding/v3`; `openrouter` targets `https://openrouter.ai/api/v1`; `xiaomi-mimo` targets Xiaomi MiMo's OpenAI-compatible endpoint, using `https://token-plan-sgp.xiaomimimo.com/v1` by default for Token Plan keys (`tp-...`) and `https://api.xiaomimimo.com/v1` for pay-as-you-go keys. For Token Plan accounts outside the Singapore default, set `base_url` explicitly or use `mode = "token-plan-cn"` for China and `mode = "token-plan-ams"` for Europe/Amsterdam; `novita` targets `https://api.novita.ai/openai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `siliconflow` targets SiliconFlow, defaulting to `https://api.siliconflow.com/v1`; `arcee` targets Arcee AI's OpenAI-compatible endpoint at `https://api.arcee.ai/api/v1`; `siliconflow-CN` targets the SiliconFlow China regional endpoint through `[providers.siliconflow_cn]`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`; `huggingface` targets Hugging Face Inference Providers at `https://router.huggingface.co/v1`; `together` targets Together AI at `https://api.together.xyz/v1`; `qianfan` targets Baidu Qianfan at `https://api.baiduqianfan.ai/v1`; `openai-codex` targets ChatGPT/Codex OAuth; `anthropic` targets Claude's native Messages API; `openmodel` targets OpenModel's Anthropic-compatible Messages API at `https://api.openmodel.ai`; `zai` targets Z.ai at `https://api.z.ai/api/coding/paas/v4`; `stepfun` targets StepFun at `https://api.stepfun.ai/v1`; `minimax` targets MiniMax at `https://api.minimax.io/v1`; `deepinfra` targets DeepInfra at `https://api.deepinfra.com/v1/openai`; `sakana` targets Sakana AI Fugu at `https://api.sakana.ai/v1`; `longcat` targets Meituan LongCat at `https://api.longcat.chat/openai/v1`; `opencode-go` targets the subscription-backed OpenCode Go Chat Completions route at `https://opencode.ai/zen/go/v1`; `meta` targets Meta Model API; and `xai` targets xAI's API-key or OAuth route. +- `provider` (string, optional): `deepseek` (default), `deepseek-anthropic`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `huggingface`, `together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`, `meta`, `telecomjs`, or `xai`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `deepseek-anthropic` targets DeepSeek's Anthropic Messages-compatible endpoint at `https://api.deepseek.com/anthropic` using `DEEPSEEK_API_KEY`; `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `volcengine` targets Volcengine Ark's OpenAI-compatible coding endpoint at `https://ark.cn-beijing.volces.com/api/coding/v3`; `openrouter` targets `https://openrouter.ai/api/v1`; `xiaomi-mimo` targets Xiaomi MiMo's OpenAI-compatible endpoint, using `https://token-plan-sgp.xiaomimimo.com/v1` by default for Token Plan keys (`tp-...`) and `https://api.xiaomimimo.com/v1` for pay-as-you-go keys. For Token Plan accounts outside the Singapore default, set `base_url` explicitly or use `mode = "token-plan-cn"` for China and `mode = "token-plan-ams"` for Europe/Amsterdam; `novita` targets `https://api.novita.ai/openai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `siliconflow` targets SiliconFlow, defaulting to `https://api.siliconflow.com/v1`; `arcee` targets Arcee AI's OpenAI-compatible endpoint at `https://api.arcee.ai/api/v1`; `siliconflow-CN` targets the SiliconFlow China regional endpoint through `[providers.siliconflow_cn]`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`; `huggingface` targets Hugging Face Inference Providers at `https://router.huggingface.co/v1`; `together` targets Together AI at `https://api.together.xyz/v1`; `qianfan` targets Baidu Qianfan at `https://api.baiduqianfan.ai/v1`; `openai-codex` targets ChatGPT/Codex OAuth; `anthropic` targets Claude's native Messages API; `openmodel` targets OpenModel's Anthropic-compatible Messages API at `https://api.openmodel.ai`; `zai` targets Z.ai at `https://api.z.ai/api/coding/paas/v4`; `stepfun` targets StepFun at `https://api.stepfun.ai/v1`; `minimax` targets MiniMax at `https://api.minimax.io/v1`; `deepinfra` targets DeepInfra at `https://api.deepinfra.com/v1/openai`; `sakana` targets Sakana AI Fugu at `https://api.sakana.ai/v1`; `longcat` targets Meituan LongCat at `https://api.longcat.chat/openai/v1`; `opencode-go` targets the subscription-backed OpenCode Go Chat Completions route at `https://opencode.ai/zen/go/v1`; `meta` targets Meta Model API; `telecomjs` targets TelecomJS TokenHub at `https://aigw.telecomjs.com/v1`; and `xai` targets xAI's API-key or OAuth route. - `minimax-anthropic` (string provider value): selects MiniMax's Anthropic-compatible Messages route through `[providers.minimax_anthropic]`. The default Base URL is `https://api.minimax.io/anthropic`; set `https://api.minimaxi.com/anthropic` for China. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`. The route uses `MINIMAX_API_KEY` and defaults to `MiniMax-M3`; `MiniMax-M2.7` is also registered. Official M3 input modalities are text, image, and video, with adaptive or disabled thinking. M2.7 is text-only and always keeps thinking enabled. - `api_key` (string, required for hosted providers): must be non-empty for DeepSeek/hosted providers (or set the provider API key env var). Self-hosted SGLang, vLLM, and Ollama can omit it. - `auth_mode` (string, optional provider-table key): selects a provider-specific authentication contract. Kimi Code membership uses `auth_mode = "api_key"` (or omit the field), a key created in the [Kimi Code console](https://www.kimi.com/code/console), `base_url = "https://api.kimi.com/coding/v1"`, and bare `model = "k3"` for K3. Codewhale gives that route a safe 262,144-token baseline; set `context_window = 1048576` only when the Kimi Code plan includes 1M access (Allegretto and above). `k3[1m]` is a Claude Code-only convention, not an API model ID, and Codewhale rejects it instead of silently changing the wire model or assuming an entitlement. `model = "kimi-for-coding"` remains the valid K2.7 compatibility route available to all Kimi Code members. Legacy `auth_mode = "kimi_oauth"` fails closed with API-key guidance and never probes, reads, refreshes, or rewrites `kimi_cli`/`kimi_code_cli` credential files. First-class OAuth requires Codewhale's own vendor-registered client identity and remains tracked in #4417. - `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://api.deepseek.com/anthropic` for `deepseek-anthropic`, `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://ark.cn-beijing.volces.com/api/coding/v3` for `volcengine`, `https://openrouter.ai/api/v1` for `openrouter`, `https://token-plan-sgp.xiaomimimo.com/v1` for `xiaomi-mimo` when the API key starts with `tp-...` and `https://api.xiaomimimo.com/v1` otherwise, `https://api.novita.ai/openai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.siliconflow.com/v1` for `siliconflow`, `https://api.siliconflow.cn/v1` for `siliconflow-CN`, `https://api.arcee.ai/api/v1` for `arcee`, `https://api.moonshot.ai/v1` for `moonshot`, `https://api.minimax.io/v1` for `minimax`, `https://api.openmodel.ai` for `openmodel`, `https://api.z.ai/api/coding/paas/v4` for `zai`, `https://api.stepfun.ai/v1` for `stepfun`, `https://api.deepinfra.com/v1/openai` for `deepinfra`, `https://api.sakana.ai/v1` for `sakana`, `https://router.huggingface.co/v1` for `huggingface`, `https://api.together.xyz/v1` for `together`, `https://api.baiduqianfan.ai/v1` for `qianfan`, `https://chatgpt.com/backend-api` for `openai-codex`, `https://api.anthropic.com` for `anthropic`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `base_url = "https://token-plan-cn.xiaomimimo.com/v1"` for China-region Xiaomi MiMo Token Plan accounts or `base_url = "https://token-plan-ams.xiaomimimo.com/v1"` for Europe/Amsterdam accounts. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. +- `telecomjs` base URL and catalog: `[providers.telecomjs]` defaults to `https://aigw.telecomjs.com/v1`; `TELECOMJS_BASE_URL` overrides it. With `TELECOMJS_API_KEY`, `/models` refreshes a key-scoped catalog without mixing rows into another provider. - `context_window` (integer, optional provider-table key): override the total context window for the active `[providers.]` route when an OpenAI-compatible gateway, hosted model alias, or self-hosted runtime has a different limit than Codewhale's static model table. For example, `[providers.openai] context_window = 1000000` lets an OpenAI-compatible DashScope/Qwen route budget against a 1M-token window instead of the conservative fallback. For Kimi Code K3, keep `model = "k3"` and set `[providers.moonshot] context_window = 1048576` only when the membership plan includes 1M access; otherwise omit it to retain the 262,144-token safe baseline. The value must be greater than 0 and affects prompt context notes, compaction thresholds, context-pressure checks, and request output caps. - `path_suffix` (string, optional provider-table key): override the chat-completions path for OpenAI-compatible gateways that do not serve `/v1/chat/completions`. For example, `[providers.openai] path_suffix = "/chat/completions"` sends chat requests to the unversioned base URL plus `/chat/completions`; `models` and `beta/*` requests keep their normal routing. - `reasoning_stream_style` (string, optional provider-table key): override how streaming reasoning is separated from answer text for the active provider route. Use `separate_field` for `reasoning_content` / `reasoning` deltas, `inline_tags` for gateways that stream `...` inside `delta.content`, or `none` to render incoming content exactly as answer text. - `[providers..auth]` (table, optional): provider-scoped auth source metadata. `source = "command"` stores a command argv plus optional `timeout_ms`; `source = "secret"` stores a `secret_id`. This slice lets provider readiness, `/provider`, and doctor JSON report the auth source class without exposing command argv output or secret values; executing commands and resolving external secret material is handled by the follow-up resolver work. - `insecure_skip_tls_verify` (bool, optional provider-table key): legacy compatibility key, disabled by default. When true on the active provider table, provider clients reject the configuration instead of skipping TLS certificate verification. Use `SSL_CERT_FILE` for corporate or private CA bundles; `codewhale doctor` reports stale uses of this setting. - `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek, `deepseek-anthropic`, and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `DeepSeek-V4-Pro` for Volcengine Ark, `deepseek/deepseek-v4-pro` for OpenRouter and Novita, `mimo-v2.5-pro` for Xiaomi MiMo, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SiliconFlow and DeepInfra, `trinity-large-thinking` for Arcee AI, `kimi-k2.7-code` for Moonshot, `MiniMax-M3` for MiniMax, `GLM-5.2` for Z.ai, `step-3.7-flash` for StepFun, `ernie-4.0-turbo-8k` for Qianfan, `fugu` for Sakana AI, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Hugging Face and Together AI both default to `deepseek-ai/DeepSeek-V4-Pro`; `openai-codex` defaults to `gpt-5.5`; `anthropic` defaults to `claude-sonnet-4-6`; `openmodel` defaults to `deepseek-v4-flash`. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. DeepSeek retires `deepseek-chat` and `deepseek-reasoner` on July 24, 2026; direct first-party routes migrate both to `deepseek-v4-flash`, with omitted reasoning settings preserving their former non-thinking (`off`) and thinking (`high`) intent. Explicit `reasoning_effort` wins, and provider-owned ids on Wanjie Ark, aggregators, self-hosted runtimes, and custom endpoints are not globally rewritten. SiliconFlow retains its own mapping: `deepseek-reasoner` and `deepseek-r1` select its Pro model while `deepseek-chat` and `deepseek-v3` select Flash. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. OpenRouter also recognizes recent large IDs such as `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `minimax/minimax-m2.7`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `qwen/qwen3.7-max`, `google/gemma-4-31b-it`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, and `nvidia/nemotron-3-ultra-550b-a55b`; direct Arcee uses bare IDs such as `trinity-large-thinking` and `trinity-large-preview`; direct Moonshot recognizes `kimi-k3`, `kimi-k2.7-code`, and `kimi-k2.6`. The exact Kimi Code endpoint recognizes bare `k3` for K3 and `kimi-for-coding` for K2.7; those membership IDs are distinct from the direct Moonshot IDs and are never rewritten across routes. Direct MiniMax recognizes `MiniMax-M3` and the documented M2.x chat model IDs; direct Sakana recognizes `fugu` and `fugu-ultra-20260615`; direct Xiaomi MiMo recognizes chat IDs `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, and `mimo-v2.5`, while TTS IDs are selected through `codewhale speech` / `tts`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, `arcee`, `moonshot`, `minimax`, `openmodel`, `zai`, `stepfun`, `qianfan`, `sakana`, and Ollama model IDs are passed through unchanged after known aliases are normalized. OpenRouter and SiliconFlow provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `CODEWHALE_MODEL` overrides this for a single process; `DEEPSEEK_MODEL` is the legacy alias. +- TelecomJS uses `deepseek-v4-pro` only as a conservative pre-refresh fallback. Once its key-scoped `/models` catalog is available, the picker uses those live rows; Codewhale omits unsupported reasoning request fields on this route. - `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, `max`, `xhigh`, or `ultracode`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. Direct Moonshot `kimi-k3` on exact `https://api.moonshot.ai/v1` is always-thinking and receives only top-level `reasoning_effort = "low" | "high" | "max"`; `off` normalizes to `low`, and `medium` to `high`. Kimi Code membership `k3` on exact `https://api.kimi.com/coding/v1` instead receives nested `thinking.effort`, and its `off` setting also normalizes to enabled `low`. Normal dispatched `auto` uses Codewhale's auto-reasoning selector and sends a concrete route-normalized tier; only an omitted reasoning setting leaves the provider default in control. Neighboring gateways and model/endpoint combinations retain the generic Moonshot contract. OpenAI Codex normalizes stale `off` to `low` and sends `max` / `ultracode` as Responses `xhigh`. Z.ai receives documented `thinking` controls and treats enabled thinking as the GLM coding high/max lane. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`. - `verbosity` (string, optional): `normal` or `concise`. `normal` keeps the default conversational prompt. `concise` appends a prompt discipline block @@ -1285,9 +1294,10 @@ If you are upgrading from older releases: auto-approval. - `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases. - `sandbox_mode` (string, optional): `read-only`, `workspace-write`, `danger-full-access`, `external-sandbox`. - Platform support is not identical. macOS uses Seatbelt for policy - enforcement. Linux support is helper-gated around Landlock. Windows does not - currently advertise an OS sandbox; the planned Windows helper contract starts + Platform support is not identical. macOS uses Seatbelt when its runtime + probe succeeds. Linux uses bubblewrap only when `prefer_bwrap = true` and + `/usr/bin/bwrap` is executable; without that opt-in it reports no OS command + sandbox. Windows does not currently advertise an OS sandbox; its planned helper contract starts with process-tree containment only and must not be described as read-only filesystem isolation, workspace-write enforcement, network blocking, registry isolation, or AppContainer isolation until those are implemented. @@ -1343,7 +1353,7 @@ If you are upgrading from older releases: action = "session.compact" ``` - `[auto_review]` (table, optional): deterministic tool-call review policy. - This layer sits on top of existing approval modes; it can hold or block a + This layer sits on top of the existing permission posture; it can hold or block a tool call, but it is not an auto-push, auto-merge, or hosted review service. Block rules are checked first, then the built-in safety floor, then allow rules. In Ask and Auto-Review, a safety hold opens approval; in Full Access @@ -1453,14 +1463,23 @@ If you are upgrading from older releases: agentskills.io-compatible `~/.agents/skills` and the broader Claude-ecosystem `~/.claude/skills`. First launch installs versioned bundled skills for common workflows including skill creation, delegation, MCP/plugin scaffolding, - documents, presentations, spreadsheets, PDFs, and Feishu/Lark. See + documents, presentations, spreadsheets, PDFs, and Feishu/Lark. Only + CodeWhale-owned roots (`/.codewhale/skills` and + `~/.codewhale/skills`) are writable install/import targets; compatible harness + roots stay read-only. Bare `/skills` opens the Skills Manager (owned-only, + zero network). See [SKILLS.md](SKILLS.md) for the manager, audit statuses, + provenance markers, and mutation rules, and [CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md) for the supported boundary between portable `SKILL.md` bundles and Claude Code plugin runtimes. - `[skills].scan_codewhale_only` (bool, default `false`): when `true`, session skill discovery ignores cross-tool roots such as `.claude/skills`, `.opencode/skills`, `.cursor/skills`, and `~/.agents/skills`. Codewhale still scans `/.codewhale/skills`, `~/.codewhale/skills`, and any explicit - `skills_dir` override. + `skills_dir` override. The Skills Manager can still toggle a local compatible + audit scan independently of this runtime knob — see [SKILLS.md](SKILLS.md). +- `[skills].registry_url` / `[skills].max_install_size_bytes` (optional): used by + `/skills --remote`, `/skills sync`, and `/skill install|update`. The default + manager open path does not contact the registry. - `[verifier].enabled` (bool, default `false`): enables automatic claim-of-done verifier preview once that runtime trigger is active. The manual `run_verifiers` tool is still available when this is false. @@ -1639,7 +1658,7 @@ tools loaded on every request, add them to `[tools].always_load`: ```toml [tools] -always_load = ["git_show", "notify"] +always_load = ["Git", "notify"] ``` ## Feature Flags @@ -1652,7 +1671,7 @@ want to force on or off. [features] shell_tool = true subagents = true -web_search = true # enables canonical web.run plus the compatibility web_search alias +web_search = true # enables deferred Web; the flag name is retained for config compatibility apply_patch = true mcp = true exec_policy = true diff --git a/docs/CONTRIBUTORS.md b/docs/CONTRIBUTORS.md index bde11b0760..420bb90816 100644 --- a/docs/CONTRIBUTORS.md +++ b/docs/CONTRIBUTORS.md @@ -36,19 +36,34 @@ notes, and relevant issue/PR comments. idle-timeout progress telemetry (PR #4657) - **[gaord](https://github.com/gaord)** — Runtime API provider registry and atomic provider-switch endpoints (PR #4658) -- **[SamhandsomeLee](https://github.com/SamhandsomeLee)** — Enter-send lag - diagnosis and fix direction for #4605 (PR #4654; landed through the - release-lane async-dispatch split) +- **[SamhandsomeLee](https://github.com/SamhandsomeLee)** — the unified + `/skills` root catalog, audit/provenance model, validated mutations, manager + UI, and acceptance coverage (PR #4679), plus Enter-send lag diagnosis and + fix direction for #4605 (PR #4654; landed through the release-lane + async-dispatch split) +- **[Paulo Aboim Pinto / aboimpinto](https://github.com/aboimpinto)** — the + Layer 5.1 user-command registry boundary from PR #3278; the exact authored + evidence commit from PR #4046, preserved intact in the integration graph; + and the #2870 follow-up audit whose metadata and malformed-sibling gaps + shaped the final corrections. Paulo also provided the structured, redacted + Agent Details and `current_activity` design direction preserved from + #2694/#2889 and the real-PTY lifecycle acceptance direction from #2886 +- **[baendlorel](https://github.com/baendlorel)** — TelecomJS TokenHub provider + and key-scoped live-catalog direction, source-partitioned refresh behavior, + and both refresh-order regressions from PR #4370, harvested into the current + provider architecture with co-authorship preserved - **[zhangweiii](https://github.com/zhangweiii)** and **[Sterne Lee / sternelee](https://github.com/sternelee)** — the original first-class OpenCode Go implementations in PRs #773 and #1050, harvested - into the current provider architecture with co-authorship planned for the + into the current provider architecture with co-authorship preserved in the landing commit - **[Sean Tse / seanthefuturegorilla](https://github.com/seanthefuturegorilla)** — the canonical OpenCode Go/Zen provider request and acceptance direction in #1481 -- **[nightt5879](https://github.com/nightt5879)** — the Solarized Light - background preservation fix for the underwater shell (PR #4471) +- **[nightt5879](https://github.com/nightt5879)** — `/debt` compatibility + aliases with dispatch-consistent user-command shadowing across help and + slash discovery (PR #4680), plus the Solarized Light background preservation + fix for the underwater shell (PR #4471) - **[AiurArtanis](https://github.com/AiurArtanis)** — the Solarized Light v0.9.0 regression report and reproduction (#4457) - **[shenjackyuanjie](https://github.com/shenjackyuanjie)** — the HarmonyOS @@ -73,6 +88,9 @@ notes, and relevant issue/PR comments. `apply_patch` replacement contract (PRs #4475 and #4476) - **[dmitri-0](https://github.com/dmitri-0)** — configurable cache-hit visibility in the phase strip (PR #4474) +- **[Fred Leitz / fleitz](https://github.com/fleitz)** — canonical `Bash` + no-`cwd` workspace resolution and its regression test, keeping isolated + sub-agent commands inside their selected worktree (PR #4673; issue #4674) - **[Sh1Zuku / SparkofSpike](https://github.com/SparkofSpike)** — exact Vim-space regression reproduction and verification that the v0.9.1 input path already contains the needed global binding (PR #4477) diff --git a/docs/FLEET.md b/docs/FLEET.md index b88f7d2658..f0f0a4e6f7 100644 --- a/docs/FLEET.md +++ b/docs/FLEET.md @@ -41,7 +41,7 @@ logs and adapter logs are stored under `.codewhale/fleet/` and ### Interactive and persistent status -CodeWhale has two similarly named status surfaces with different scopes: +Codewhale has two similarly named status surfaces with different scopes: - In the TUI, `/fleet status` (or `/subagents`) shows the sub-agents attached to the current interactive session. It does not read the persistent Fleet @@ -113,6 +113,16 @@ worker for every executable step. Workflow is an optional orchestration overlay for work that needs ordering, gates, shared budgets, replay, or deterministic fan-in. +The short public vocabulary is: + +- **Fleet** = who does the work: the configured workers, roles, models, hosts, + and trust boundaries. +- **Workflow** = what order the work follows: phases, gates, budgets, replay, + and fan-in. +- **Lane** = one running Workflow instance and its live progress. +- **Runtime** = where and how a Lane executes: local or remote process, + provider route, sandbox, and API boundary. + - **Workflow** is the repeatable plan and user-facing orchestration overlay: a script/IR that decides which phases and agents run next, keeps intermediate results out of the main conversation, and can be inspected or diff --git a/docs/FLEET_WORKFLOW_TUTORIAL.md b/docs/FLEET_WORKFLOW_TUTORIAL.md index 22ad86e977..e713883af7 100644 --- a/docs/FLEET_WORKFLOW_TUTORIAL.md +++ b/docs/FLEET_WORKFLOW_TUTORIAL.md @@ -19,7 +19,7 @@ you do not need to write workflow files for ordinary multi-agent work. Details: This tutorial covers the **manual** Fleet task-spec / checked-in Workflow path for operators who want durable host workers and reviewable specs. A one-sentence request should still not silently generate `tasks.json`; worker -cards and approval posture make dispatch visible without exposing authoring +cards and permission posture make dispatch visible without exposing authoring mechanics. ## 1. Prepare The Workspace diff --git a/docs/GUIDE.md b/docs/GUIDE.md index eeea65cb3e..6834c28d19 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -435,8 +435,10 @@ Use skills when a task has a repeatable process: - Following a team release checklist. - Using a project-specific memory or wiki workflow. -Inside the TUI, `/skill` activates a skill when one is available, and `/skills` -lists installed skills. The command palette can also surface skill entries +Inside the TUI, `/skill ` activates a skill when one is available, and +bare `/skills` opens the Skills Manager (owned-only inventory, no network). Use +`/skills `, `/skills inspect`, `/skills --remote`, or `/skills sync` +for the text/registry paths. The command palette can also surface skill entries alongside normal slash commands. Good skills are narrow. They should tell the model what workflow to follow, @@ -447,9 +449,10 @@ If a repository has its own instructions, treat them as part of the active work. Read the local guidance before editing, and keep any contribution within the repository's conventions. -Next: see [CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md) for -Claude Code skill/plugin compatibility, and [CONFIGURATION.md](CONFIGURATION.md) -for config paths and project authority. +Next: see [SKILLS.md](SKILLS.md) for the manager, ownership, and provenance +rules; [CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md) for Claude Code +skill/plugin compatibility; and [CONFIGURATION.md](CONFIGURATION.md) for config +paths and project authority. ## 10. Getting Help diff --git a/docs/INSTALL.md b/docs/INSTALL.md index ce9480a335..08956b4f2b 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -8,6 +8,11 @@ If you just want the short version, see the [main README](../README.md#install) or [简体中文 README](../README.zh-CN.md#安装). +This branch describes the **v0.9.1 source candidate**. Install commands that use +`latest` resolve to the latest published package or GitHub Release, which may +trail the source candidate. A candidate is not a published install until the +matching package, tag, checksums, and release assets exist. + On macOS and Linux, the website installer is the shortest install/update path: ```bash @@ -22,17 +27,18 @@ verifies them against `codewhale-artifacts-sha256.txt`, installs to ## 1. Supported platforms -Codewhale ships matched `codewhale`, `codew`, and `codewhale-tui` prebuilt binaries for -the supported platform/architecture combinations below. Android/Termux is a -preview in v0.9.1 pending real-device QA. Linux ARM64 is available from v0.8.8 -onward. Linux RISC-V prebuilts are temporarily paused because the locked +Published Codewhale releases ship matched `codewhale`, `codew`, and +`codewhale-tui` prebuilt binaries for their supported platform/architecture +combinations. The table below is the intended v0.9.1 candidate matrix; +Android/Termux is preview pending real-device QA. Linux ARM64 is available from +v0.8.8 onward. Linux RISC-V prebuilts are temporarily paused because the locked `rquickjs-sys` dependency does not ship `riscv64gc-unknown-linux-gnu` bindings. | Platform | Architecture | npm install | `cargo install` | GitHub release asset | | ------------ | ------------ | :---------: | :-------------: | ----------------------------------------------------- | | Linux | x64 (x86_64) | ✅ | ✅ | `codewhale-linux-x64`, `codew-linux-x64`, `codewhale-tui-linux-x64` | | Linux | arm64 | ✅ | ✅ | `codewhale-linux-arm64`, `codew-linux-arm64`, `codewhale-tui-linux-arm64` | -| Android / Termux | arm64 (aarch64) | ❌¹ | ⚠️⁴ preview | `codewhale-android-arm64.tar.gz` preview archive when published | +| Android / Termux | arm64 (aarch64) | ⚠️⁴ preview | ⚠️⁴ preview | `codewhale-android-arm64.tar.gz` preview archive when published | | Linux | riscv64 | ❌¹ | ❌³ | temporarily unsupported until upstream bindings land | | macOS | x64 | ✅ | ✅ | `codewhale-macos-x64`, `codew-macos-x64`, `codewhale-tui-macos-x64` | | macOS | arm64 (M-series) | ✅ | ✅ | `codewhale-macos-arm64`, `codew-macos-arm64`, `codewhale-tui-macos-arm64` | @@ -47,9 +53,12 @@ onward. Linux RISC-V prebuilts are temporarily paused because the locked [Build from source](#7-build-from-source) below. ³ RISC-V source builds currently need upstream `rquickjs-sys` RISC-V bindings or a bindgen-enabled dependency build. -⁴ The Android/Termux build and setup paths are implemented, but v0.9.1 remains - preview-only until the real-device compile, startup, approval, file-tool, and - update checks tracked in #4236 and #4242 are complete. +⁴ The v0.9.1 source-candidate npm wrapper recognizes Android arm64 and resolves + the matching `codewhale`, `codew`, and `codewhale-tui` Android assets. npm + installation works only for a package version whose GitHub Release publishes + those matching assets. The Android/Termux path remains preview-only until the + real-device compile, startup, approval, file-tool, and update checks tracked + in #4236 and #4242 are complete. Android / Termux is not the same target as Linux arm64. Do not install the GNU libc `codewhale-linux-arm64` archive in Termux; use the Termux-specific @@ -62,15 +71,15 @@ They have no glibc dependency and run on any x86_64 Linux, including Ubuntu binary through `rusqlite`, so no separate `libsqlite3` runtime package is needed. The Linux **arm64** release assets are still GNU libc (glibc) builds. They -dynamically link normal Linux runtime libraries such as `libdbus-1` and `libc`, -and are built on Ubuntu 24.04, so they can require `GLIBC_2.39`. +dynamically link normal Linux runtime libraries such as `libdbus-1` and `libc`. +The v0.9.1 candidate build runs on Ubuntu 24.04, so it can require `GLIBC_2.39`. ### Linux glibc floor (arm64) This floor applies only to the **GNU libc** arm64 asset. The static x64 (musl) asset has no `GLIBC_*` symbols, so it passes the install preflight and runs on -older systems without error. The v0.9.1 GNU arm64 asset is built on Ubuntu -24.04 and can require `GLIBC_2.39`. Ubuntu 22.04 ships glibc +older systems without error. The v0.9.1 candidate GNU arm64 asset is built on +Ubuntu 24.04 and can require `GLIBC_2.39`. Ubuntu 22.04 ships glibc 2.35, so those arm64 binaries fail with errors such as: ```text @@ -173,7 +182,7 @@ codewhale-tui --version Known limitations: - Commands inherit Android's per-app UID, SELinux, and seccomp protections and - any permissions granted to Termux. Codewhale's additional Landlock/bwrap + any permissions granted to Termux. Codewhale's opt-in bubblewrap child-process sandbox is Linux-only and is not built on Android, so approved commands receive no Codewhale-specific filesystem narrowing. - The Termux build has no supported Android Keystore or desktop Secret Service @@ -226,15 +235,16 @@ a download sourced from an impersonating repository or mirror. ## 3. Install via npm -npm is the recommended install path. The `codewhale` wrapper is published at -v0.9.1 (Node 18+; wrapper available for v0.8.56 and later). +npm is the recommended install path (Node 18+; wrapper available for v0.8.56 +and later). It installs the registry's latest published version, not an +unpublished source candidate. ```bash npm install -g codewhale -codewhale --version # 0.9.1 +codewhale --version # prints the published version that was installed ``` -`postinstall` downloads the right pair of binaries from the matching GitHub +`postinstall` downloads the matching three binaries from the GitHub release, verifies a SHA-256 manifest, and exposes `codewhale`, `codew`, and `codewhale-tui` on your `PATH`. @@ -264,8 +274,9 @@ Useful environment variables: ## 4. Install via Cargo (any Tier-1 Rust target) If GitHub releases are slow, blocked, or you're on an unsupported architecture, -install from crates.io directly. Both crates are required — the dispatcher -delegates to the TUI runtime at runtime. +install from crates.io directly. Two Cargo packages are required: +`codewhale-cli` installs the `codewhale` and `codew` commands, while +`codewhale-tui` installs the `codewhale-tui` command used by the dispatcher. ```bash # Requires Rust 1.88+ (https://rustup.rs) @@ -471,7 +482,7 @@ explicitly. Replace `X.Y.Z` with the version you want to restore. # npm wrapper, only for versions that were published to npm npm install -g codewhale@X.Y.Z -# Cargo install path; both crates are required +# Cargo path: two packages provide codewhale + codew + codewhale-tui cargo install codewhale-cli --version X.Y.Z --locked --force cargo install codewhale-tui --version X.Y.Z --locked --force ``` @@ -742,7 +753,8 @@ The legacy `DEEPSEEK_TUI_RELEASE_BASE_URL` name is still accepted. `codewhale update` normally contacts GitHub Releases for metadata and binary assets. On networks where GitHub is blocked or unreliable, use the CNB source -mirror instead and install both binaries from the release tag: +mirror instead and install both Cargo packages from the release tag. Together, +they provide the `codewhale`, `codew`, and `codewhale-tui` commands: To check the latest release without downloading or replacing binaries, run `codewhale update --check`. @@ -776,8 +788,9 @@ The package requires the Cargo feature called `edition2024`, but that feature is not stabilized in this version of Cargo ``` -Install current stable Rust through rustup, then rerun the two Cargo install -commands from [Section 4](#4-install-via-cargo-any-tier-1-rust-target). For +Install current stable Rust through rustup, then rerun the two Cargo package +install commands from [Section 4](#4-install-via-cargo-any-tier-1-rust-target). +Together they provide `codewhale`, `codew`, and `codewhale-tui`. For mainland China networks, this rsproxy-based sequence has been verified to work: ```bash @@ -807,7 +820,8 @@ sudo apt-get install -y build-essential pkg-config libdbus-1-dev WSL2 uses the same Linux source-build path as Ubuntu. If `cargo install codewhale-tui --locked` fails while compiling the keyring or D-Bus secret storage crates, install the Linux build dependencies inside the WSL distro, -then rerun both Cargo install commands: +then rerun the two Cargo package install commands. Together they install +`codewhale`, `codew`, and `codewhale-tui`: ```bash sudo apt-get update @@ -911,9 +925,9 @@ Use one of these paths: 3. Install via Cargo, which builds locally and does not download GitHub release assets. See [Section 4](#4-install-via-cargo-any-tier-1-rust-target). -4. Download both `codewhale` and `codewhale-tui` manually from the - [Releases page](https://github.com/Hmbown/CodeWhale/releases), place them - in a directory on `PATH`, and make them executable. See +4. Download all three matching `codewhale`, `codew`, and `codewhale-tui` + binaries from the [Releases page](https://github.com/Hmbown/CodeWhale/releases), + place them in a directory on `PATH`, and make them executable. See [Section 6](#6-manual-download-from-github-releases). --- diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index dbdb4f2fc5..c9b3f045b2 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -11,10 +11,10 @@ Global key chords are not yet user-configurable — tracked for a future release | `F1` or `Ctrl-/` | Toggle the help overlay | | `Ctrl-K` | Open the command palette (slash-command finder) | | `Ctrl-C` | Cancel current turn / dismiss modal / arm-then-confirm quit | -| `Ctrl-B` | Move a supported foreground shell wait into `/jobs` so the turn can continue; use `/jobs` or `exec_shell_wait` to inspect it | +| `Ctrl-B` | Move a supported foreground shell wait into `/jobs` so the turn can continue; use `/jobs` or `Bash` with `action: "wait"` to inspect it | | `Ctrl-D` | Quit (only when the composer is empty) | -| `Tab` | Cycle TUI mode: Plan → Act → Operate → Plan | -| `Shift-Tab` | Cycle permission posture: Ask → Auto-Review → Full Access | +| `Tab` | When the composer is idle, cycle TUI mode: Plan → Act → Operate → Plan | +| `Shift+Tab` | When the composer is idle, cycle permission posture: Ask → Auto-Review → Full Access | | `Ctrl-T` | Cycle reasoning effort for the active provider. DeepSeek-style providers cycle off → high → max → off; OpenAI Codex cycles low → medium → high → xhigh → low. | | `Ctrl-Shift-T` | Toggle live transcript overlay (sticky-tail auto-scroll) | | `Ctrl-R` | Open the resume-session picker | diff --git a/docs/MCP.md b/docs/MCP.md index e3731e2cf8..976faf3b00 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -3,8 +3,10 @@ codewhale can load additional tools via MCP (Model Context Protocol). MCP servers can be local stdio processes that the TUI starts, or remote URL-based servers that speak Streamable HTTP with legacy SSE fallback. Browsing note: -- `web.run` is the canonical built-in browsing tool. -- `web_search` remains available as a compatibility alias for older prompts and integrations. +- `Web` is the canonical, deferred built-in browsing tool; it provides + `search`, `fetch`, and `wait` actions when network policy permits. +- `web_search`, `fetch_url`, and `wait_for_dev_server` are hidden replay-only + aliases. New prompts and integrations should use `Web`. Server mode note: - `codewhale-tui serve --mcp` runs the MCP stdio server. @@ -398,7 +400,10 @@ Per-server settings: ## Safety Notes -MCP tools now flow through the same tool-approval framework as built-in tools. Read-only MCP helpers (resource/prompt listing and reads) can run without prompts in suggestive approval modes, while side-effectful MCP tools require approval. +MCP tools flow through the same approval framework as built-in tools. Read-only +MCP helpers (resource/prompt listing and reads) can run without prompts in Ask +and Auto-Review when policy permits, while side-effectful MCP tools require +approval. Full Access does not bypass hard policy holds. You should still only configure MCP servers you trust, and treat MCP server configuration as equivalent to running code on your machine. Avoid committing literal `Authorization` headers. Prefer `env_headers`, diff --git a/docs/MODES.md b/docs/MODES.md index dd1ca976b1..748c39409b 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -1,9 +1,9 @@ -# Modes and Approvals +# Modes and Permission Postures codewhale has three related concepts: - **TUI mode**: what kind of visible interaction you're in (Plan/Act/Operate). -- **Approval posture**: how aggressively the UI asks before executing tools. +- **Permission posture**: how aggressively the UI asks before executing tools. - **Workflow overlay**: optional long-running orchestration that can run on top of any TUI mode when a task needs many coordinated workers. @@ -29,8 +29,8 @@ Run `/mode` to open the mode picker, or switch directly with `/mode act`, `/mode plan`, or `/mode operate`. - **Plan**: design-first prompting. Read-only investigation tools stay available; shell and patch execution stay off. Use this when you want to think out loud and produce a plan to hand to a human (yourself later, or a reviewer). -- **Act** (Agent): multi-step tool use. In interactive TUI sessions, shell tools (`exec_shell`, `task_shell_start`, `task_shell_wait`) are available by default and approval prompts gate each call. Set top-level `allow_shell = false` to hide shell tools for a workspace/profile. File writes are allowed without a prompt. -- **Operate**: multitask conductor posture. Send ordinary messages and use the same direct tools, shell configuration, sandbox, approval posture, ask-rules, and repository protections as Act. Codewhale prefers Fleet workers for independent, parallel, background, or long-running work, but delegation is not mandatory. New messages can start additional lanes while workers continue. Workflow is optional and reserved for work that needs ordered phases, gates, shared budgets, or deterministic fan-in. +- **Act** (Agent): multi-step tool use. In interactive TUI sessions, the canonical `Bash` tool is available by default and approval prompts gate each call. Set top-level `allow_shell = false` to hide it for a workspace/profile. The canonical `File`, `Git`, and `Run` action tools cover structured workspace work. +- **Operate**: multitask conductor posture. Send ordinary messages and use the same direct tools, shell configuration, sandbox, permission posture, ask-rules, and repository protections as Act. Codewhale prefers Fleet workers for independent, parallel, background, or long-running work, but delegation is not mandatory. New messages can start additional lanes while workers continue. Workflow is optional and reserved for work that needs ordered phases, gates, shared budgets, or deterministic fan-in. **Act** is accepted as an alias for Agent mode. Saved settings still normalize to `agent` for backward compatibility. @@ -40,8 +40,8 @@ Run `/mode` to open the mode picker, or switch directly with `/mode act`, |:---|:---:|:---:|:---:| | Read-only file, search, and diagnostic tools | yes | yes | yes | | File write and patch tools | no | yes | yes; same active posture and protections as Act | -| Shell tools (`exec_shell`, `task_shell_start`, waits, interact, cancel) | no | approval-gated by default, hidden when `allow_shell = false` | same as Act; delegation is preferred when parallelism or isolation helps | -| Paid or external-service tools | follows approval posture | follows approval posture | follows approval posture | +| `Bash` (`run`, `wait`, `interact`, `cancel`) | no | approval-gated by default, hidden when `allow_shell = false` | same as Act; delegation is preferred when parallelism or isolation helps | +| Paid or external-service tools | follows permission posture | follows permission posture | follows permission posture | | Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode | same trusted-path/trust policy as Act; Fleet profiles never widen it | Operate changes scheduling emphasis, not authority. It neither adds a @@ -53,13 +53,18 @@ If a shell tool is missing from the model-visible catalog in Act or Operate, che for an explicit `allow_shell = false` in the active config/profile or runtime session. Durable tasks and automation keep conservative omitted-field defaults; they only receive shell access when their task settings explicitly grant it. -`allow_shell = true` controls shell availability only; direct multiline -`exec_shell` commands remain blocked by shell safety validation. For heredocs, +`allow_shell = true` controls shell availability only; direct multiline `Bash` +`run` commands remain blocked by shell safety validation. For heredocs, embedded scripts, or long manual flows, use single-line commands, write a -script/file first, or run through `task_shell_start`/background shell. +script/file first, or use `Bash` with its background, `wait`, and `interact` +actions. Full Access turns shell access on together with trust mode and auto-approval. -All action-capable modes have access to persistent RLM sessions through `rlm_open`, `rlm_eval`, `rlm_configure`, and `rlm_close`. Inside an RLM Python REPL, `sub_query_batch` fans out 1-16 cheap parallel child calls pinned to `deepseek-v4-flash`. The model reaches for it when work is too large or repetitive for the parent transcript. +Action-capable modes can discover the deferred `rlm` family through +`tool_search`; its `open`, `eval`, `configure`, and `close` actions own persistent +RLM sessions. The legacy split `rlm_*` spellings remain replay-only aliases. +Inside an RLM Python REPL, `sub_query_batch` fans out 1-16 cheap parallel child +calls pinned to `deepseek-v4-flash`. The fast `deepseek-v4-flash` / thinking-off path is called Fin in the product language. Fin is a seam for routing, summaries, cheap child calls, and @@ -70,7 +75,7 @@ objectives visible as Work context. `/goal pause` stops goal continuation withou changing the objective, `/goal resume` resumes and sends the objective back into the turn, `/goal complete` marks it done, `/goal blocked` marks it blocked, and `/goal clear` removes it. Goal state does not change the active TUI mode, -approval mode, or model route. This remains distinct from `--model auto`, which +permission posture, or model route. This remains distinct from `--model auto`, which only controls model and thinking selection. Workflow builds on the same separation: a goal can ask the agent to keep @@ -151,7 +156,10 @@ Full Access enables trust mode automatically. ## MCP Behavior -MCP tools are exposed as `mcp__` and use the same approval flow as built-in tools. Read-only MCP helpers may auto-run in suggestive approval modes; MCP tools with possible side effects require approval. +MCP tools are exposed as `mcp__` and use the same approval flow as +built-in tools. Read-only MCP helpers may auto-run in Ask and Auto-Review when +policy permits; MCP tools with possible side effects require approval. Full +Access does not bypass hard policy holds. See `MCP.md`. diff --git a/docs/OPERATIONS_RUNBOOK.md b/docs/OPERATIONS_RUNBOOK.md index 8e98ac8a36..163438cfbb 100644 --- a/docs/OPERATIONS_RUNBOOK.md +++ b/docs/OPERATIONS_RUNBOOK.md @@ -29,7 +29,7 @@ Checks: Actions: 1. If a foreground shell command is running, press `Ctrl+B` to move it to the background (the turn keeps running and the command becomes a background job under `/jobs`); use `Ctrl+C` instead if you want to cancel the turn. -2. If the command was started in the background, ask the assistant to cancel it with `exec_shell_cancel` and the returned task id. +2. If the command was started in the background, ask the assistant to use `Bash` with `action: "cancel"` and the returned process id. 3. Use `Esc` or `Ctrl+C` to interrupt the current turn when you want to stop the request itself. 4. Retry prompt; if still failing, restart TUI. 5. On restart, verify the previous queued/in-flight runtime turn is shown as interrupted rather than left in a running state. diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index d0a6bb2a0d..e6dcd355e6 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -33,7 +33,7 @@ The canonical provider IDs are: `siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `huggingface`, `together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, -`opencode-go`, `meta`, and `xai`. +`opencode-go`, `meta`, `telecomjs`, and `xai`. Use any of these surfaces to select a provider: @@ -57,6 +57,10 @@ Hugging Face Inference Providers route. This is the OpenAI-compatible router path for chat/inference, not Hub browsing, model-card inspection, uploads, or artifact export. +`telecomjs`, `telecom-js`, `telecom_js`, `telecomjs-cn`, and `tokenhub` all +select the TelecomJS TokenHub route. Its authenticated `/models` catalog is +key-scoped and remains isolated from every other provider's live snapshot. + Fresh shared config writes to `~/.codewhale/config.toml`. Existing `~/.deepseek/config.toml` files are still read for compatibility. @@ -110,6 +114,7 @@ the listed provider env vars. | `longcat` | `[providers.longcat]` | OpenAI Chat Completions | `LONGCAT_API_KEY` | | `opencode-go` | `[providers.opencode_go]` | OpenAI Chat Completions | `OPENCODE_GO_API_KEY` | | `meta` | `[providers.meta]` | OpenAI Chat Completions | `META_MODEL_API_KEY`, `MODEL_API_KEY` | +| `telecomjs` | `[providers.telecomjs]` | OpenAI Chat Completions | `TELECOMJS_API_KEY` | | `xai` | `[providers.xai]` | OpenAI Chat Completions | `XAI_API_KEY` | Default base URLs and models for each route are listed in the shipped provider @@ -250,6 +255,7 @@ configuration path instead of guessing a vendor page. | `longcat` | [Meituan LongCat platform](https://longcat.chat/platform) | | `opencode-go` | [OpenCode Zen](https://opencode.ai/zen/) | | `meta` | [Meta Model API](https://developer.meta.com/ai/) | +| `telecomjs` | [TelecomJS TokenHub](https://aigw.telecomjs.com/) | | `xai` | [xAI Console](https://console.x.ai/) for an API key, Codewhale-owned device login, or explicitly consented read-only Grok CLI credentials. | | `custom` | Set the named provider's `base_url` and `api_key_env` or `api_key`; no canonical vendor credential page exists. | @@ -335,6 +341,7 @@ Kimi remains API-key-only; external consent for Kimi is rejected. | `longcat` | `[providers.longcat]` | `LONGCAT_API_KEY` | `LONGCAT_BASE_URL`; default `https://api.longcat.chat/openai/v1` | `LongCat-2.0` (default) | Meituan LongCat curated model gateway. OpenAI-compatible Chat Completions wire protocol. Sign up at https://longcat.chat/platform for an API key. Provider aliases: `long-cat`, `meituan-longcat`, `meituan`. | | `opencode-go` | `[providers.opencode_go]` | `OPENCODE_GO_API_KEY` | `OPENCODE_GO_BASE_URL`; default `https://opencode.ai/zen/go/v1` | `deepseek-v4-pro` (default), `grok-4.5`, `glm-5.2`, `glm-5.1`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `deepseek-v4-flash`, `mimo-v2.5`, `mimo-v2.5-pro` | [OpenCode Go](https://opencode.ai/docs/go/) subscription route using OpenAI-compatible Chat Completions. `OPENCODE_GO_MODEL` is accepted. Codewhale uses bare wire IDs; familiar `opencode-go/` input aliases normalize to the bare ID. Go models documented only on the Anthropic `/messages` endpoint are deliberately not advertised by this route until Codewhale supports per-model wire selection. Billing surfaces show the Go allowance instead of token-price estimates. | | `meta` | `[providers.meta]` | `META_MODEL_API_KEY`, `MODEL_API_KEY` | `META_MODEL_API_BASE_URL`, `MODEL_API_BASE_URL`; default `https://api.meta.ai/v1` | `muse-spark-1.1` (default) | [Meta Model API](https://developer.meta.com/ai/resources/blog/build-with-muse-spark/) public-preview route using OpenAI-compatible Chat Completions. Muse Spark 1.1 keeps its wire ID, tool support, 1M context, 32K output metadata, and `none` through `xhigh` reasoning effort. `META_MODEL_API_MODEL` and `MODEL_API_MODEL` are accepted. Provider aliases: `meta-ai`, `meta_model_api`, `muse`, `muse-spark`. | +| `telecomjs` | `[providers.telecomjs]` | `TELECOMJS_API_KEY` | `TELECOMJS_BASE_URL`; default `https://aigw.telecomjs.com/v1` | `deepseek-v4-pro` conservative fallback; authenticated `/models` rows when a key is configured | TelecomJS TokenHub OpenAI-compatible Chat Completions route. Live catalogs are isolated by provider and key fingerprint, stale rows survive transient refresh failures, and unsupported reasoning request fields are omitted. `TELECOMJS_MODEL` is accepted. Provider aliases: `telecom-js`, `telecom_js`, `telecomjs-cn`, `tokenhub`. | | `xai` | `[providers.xai]` | `XAI_API_KEY`, Codewhale-owned device OAuth, or explicit read-only Grok CLI consent | `XAI_BASE_URL`; default `https://api.x.ai/v1` | `grok-4.5` (default), `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | xAI/Grok OpenAI-compatible Chat Completions route. **API-key** (default): Bearer token from console.x.ai via `XAI_API_KEY` / keyring / `api_key`. **OAuth**: `codewhale auth xai-device` uses SSH-friendly device login and Codewhale-owned storage, which may refresh itself. Existing Grok CLI credentials require `codewhale auth external-consent --provider xai --mode read-only`; the granted external file is never refreshed or rewritten. OAuth may return HTTP 403 on some SuperGrok tiers — keep API-key as the reliable fallback. `XAI_MODEL` is accepted. Provider aliases: `x-ai`, `x_ai`, `grok`. | ### Hugging Face Provider vs MCP vs Hub @@ -361,7 +368,8 @@ upload to Hugging Face and does not perform direct Hugging Face Hub HTTP search. work. The chat picker also exposes `mimo-v2.5-pro-ultraspeed` and the latest Omni model `mimo-v2.5`. Xiaomi MiMo TTS is available through `codewhale --provider xiaomi-mimo speech "text" --model tts` (or the `tts` -alias) plus model-visible `speech` / `tts` tools in Agent/YOLO mode. +alias). In Act and Operate, the provider-specific `speech` / `tts` tools are +available through deferred discovery when the Xiaomi MiMo route is configured. `/provider xiaomi-mimo ultraspeed` and `/provider xiaomi-mimo pro-ultraspeed` both select `mimo-v2.5-pro-ultraspeed`. Speech aliases such as `tts`, @@ -570,8 +578,9 @@ provider auth and artifact movement stay separate. Codewhale only executes tools when the provider returns Chat Completions `tool_calls` or streamed `delta.tool_calls`. If a local model prints text such -as `{"name":"grep_files","arguments":{...}}` in the assistant message, that is -ordinary model output, not an executable tool request. +as `{"name":"File","arguments":{"action":"search_content",...}}` in the +assistant message, that is ordinary model output, not an executable tool +request. For OpenAI-compatible or local runtimes, check: @@ -579,8 +588,8 @@ For OpenAI-compatible or local runtimes, check: - The selected model or chat template is configured for function/tool calls. - The server returns `tool_calls` in the response rather than plain JSON text. - The compatibility layer does not strip tools before forwarding the request. -- If in doubt, test a small `read_file` or `grep_files` request against a known - tool-calling model before debugging Codewhale's tool registry. +- If in doubt, test a small `File` `read` or `search_content` action against a + known tool-calling model before debugging Codewhale's tool registry. Changing `provider`, `base_url`, or `model` can select a route that supports the OpenAI-compatible payload shape, but Codewhale cannot convert arbitrary JSON @@ -620,7 +629,7 @@ Providers marked "omitted" receive no reasoning fields at all for that tier. | `vllm` | `chat_template_kwargs.enable_thinking: false` | `chat_template_kwargs.enable_thinking: true` + `reasoning_effort` low/medium/high | `chat_template_kwargs.enable_thinking: true` + `reasoning_effort: "high"` (vLLM has no max tier) | | `arcee`, `huggingface` | omitted | `reasoning_effort` pass-through | `reasoning_effort: "high"` | | `fireworks` | omitted | `reasoning_effort: "high"` | `reasoning_effort: "max"` | -| `openai`, `wanjie-ark` | omitted | omitted | omitted | +| `openai`, `wanjie-ark`, `telecomjs` | omitted | omitted | omitted | | `openmodel` | Anthropic Messages adapter handles thinking/output configuration | Anthropic Messages adapter handles thinking/output configuration | Anthropic Messages adapter handles thinking/output configuration | | `openai-codex` | Responses API `reasoning` field (handled by the Responses bridge) | Responses API `reasoning` field | Responses API `reasoning` field | diff --git a/docs/RUNTIME_SIMPLIFICATION_DESIGN.md b/docs/RUNTIME_SIMPLIFICATION_DESIGN.md new file mode 100644 index 0000000000..2852a89cd8 --- /dev/null +++ b/docs/RUNTIME_SIMPLIFICATION_DESIGN.md @@ -0,0 +1,104 @@ +# Codewhale Runtime Simplification Design + +## Goal + +Make the model-facing runtime smaller, calmer, and easier for models to use by: + +1. Collapsing the long tail of single-purpose file, git, run, and web tools into + a few canonical action-based tools. +2. Shrinking the system prompt to durable behavioral invariants and per-turn + permission deltas. +3. Keeping every legacy tool name registered but hidden so old transcripts, + saved sessions, and recorded automation replay without migration. + +## Target model-facing surface (default active) + +| Tool | Actions / Niche | +|---|---| +| `Bash` | `run`, `wait`, `interact`, `cancel` (existing) | +| `File` | `read`, `list`, `search_name`, `search_content`, `write`, `edit`, `patch` | +| `Git` | `status`, `diff`, `log`, `show`, `blame` | +| `Run` | `tests`, `verifiers` | +| `Web` | `search`, `fetch`, `wait` (deferred unless network is enabled; hidden aliases for legacy names) | +| `tasks` | durable task family (existing action-based surface) | +| `github` | durable GitHub family (existing; deferred by default) | +| `automation` | durable automation family (existing; deferred by default) | +| `rlm` | durable RLM family (existing; deferred by default) | +| `agent` | sub-agent dispatch | +| `remember` | opt-in durable user-memory capture; eager whenever registered | +| `work_update` | progress / plan-of-work updates | +| `update_plan` | plan artifact updates | +| `tool_search` | on-demand discovery of deferred tools | + +Default-active policy: **10 names** (vs. ~18 before the simplification), with +`remember` registered only for built-in-memory users and the durable families +and `Web` discoverable via `tool_search` when needed. `tool_search` itself is a +synthetic always-active catalog entry. + +## Rejected alternatives + +- **Keep every tool but defer the rare ones.** This only changes what is + advertised, not how many distinct schemas the model must learn. It also + leaves duplicated guidance in the prompt. +- **Route search and git through `Bash`.** `grep_files`, `file_search`, and the + git tools return structured, workspace-aware output and respect sandbox, + `.gitignore`, and network policy. Shell would force the model to re-parse + free-form text and lose those guarantees, so dedicated tools win. +- **One mega `File` tool plus a separate `Edit` tool.** A single `File` tool is + only slightly larger than a read/edit pair and keeps the boundary the model + already understands (`read` is cheap, `edit` requires prior read). Splitting + would re-introduce a two-tool alias for the same underlying operations. +- **Delete legacy tools.** Saved transcripts and replay tests rely on the old + names. Removing them would require a config migration and break reproducibility. + Hidden aliases avoid both. + +## Compatibility + +- Legacy names (`read_file`, `write_file`, `edit_file`, `list_dir`, `file_search`, + `grep_files`, `apply_patch`, `git_status`, `git_diff`, `git_log`, `git_show`, + `git_blame`, `run_tests`, `run_verifiers`, `web_search`, `fetch_url`, + `wait_for_dev_server`) stay registered with `model_visible = false`. +- The engine resolves calls by name, so old transcripts replay without changes. +- `DEFAULT_ACTIVE_NATIVE_TOOLS` is updated to list the new canonical names only; + hidden legacy tools are ignored by catalog construction. + +## Prompt simplification + +- Replace the tool-calling recipe sections in `AGENT_MODE` and + `SUBAGENT_OUTPUT_FORMAT` with short references to the canonical tools. +- Reduce mode deltas to permission statements (Act = write requires approval, + Plan = no writes or shell, Full Access = auto-approved, Operate = coordinate from + ordinary messages). +- Keep the `BASE_PROMPT` behavioral invariants, `LANGUAGE_PROMPT`, and + `OUTPUT_PROMPT` intact. +- Move detailed templates (`COMPACT_TEMPLATE`, sub-agent brief format, planning + artifact template) out of the stable prefix and into tool schemas or + conditional blocks. + +## Validation + +- Provider-free: `scripts/measure-runtime-contract.py` reports active tool count + and prompt bytes before and after. +- Behavior-preserving: targeted unit tests for `File`, `Git`, `Run`, and `Web` + dispatch against legacy inputs. +- Regression: `cargo fmt`, `cargo clippy --workspace --all-targets --locked`, + `cargo test -p codewhale-tui --bin codewhale-tui --locked`, and + `cargo test --workspace`. + +### v0.9.1 receipt + +The source contract and provider-free metric now exercise the complete policy, +including opt-in `remember`: + +| Contract | Before | After | +|---|---:|---:| +| Default active tools | 18 | 10 | +| Agent-mode instruction bytes | 4,064 | 663 | +| Full system-prompt bytes | 15,842 | 15,368 | + +The final active names are `Bash`, `File`, `Git`, `Run`, `agent`, `remember`, +`tasks`, `update_plan`, `work_update`, and `tool_search`. `remember` is present +only when built-in memory is enabled; it is eager whenever registered. `File` +advertises only read actions in Plan mode, and its `patch` action appears only +when the existing apply-patch feature is enabled. Hidden aliases remain +executable for transcript replay but are absent from the model catalog. diff --git a/docs/SANDBOX.md b/docs/SANDBOX.md index 93c2cd9cd7..7a063cbc95 100644 --- a/docs/SANDBOX.md +++ b/docs/SANDBOX.md @@ -1,271 +1,194 @@ # Sandbox threat model -Codewhale executes shell commands spawned by AI reasoning. The sandbox -module restricts what those commands can do to the host system. This -document describes what each platform's sandbox actually enforces, -what is best-effort, and what is explicitly out of scope. +Codewhale can launch shell commands proposed by a model. Approval policy, +workspace-aware tools, and an operating-system command wrapper are separate +controls: an approval is not a sandbox, and selecting `workspace-write` does +not prove that the current platform has an OS wrapper available. + +This document describes only behavior wired into the command execution path. ## Platform overview -| Mechanism | Platform | Type | Status | +| Mechanism | Platform | Selection | What Codewhale reports | |---|---|---|---| -| Seatbelt | macOS | Mandatory access control | Enforced | -| Landlock | Linux | Filesystem access control | Enforced | -| seccomp BPF | Linux | Syscall filter | Enforced | -| Process hardening | Linux | Kernel prctl / rlimit | Enforced | -| Bubblewrap (bwrap) | Linux | Namespace isolation | Optional | -| Windows Job Object | Windows | Process-tree containment | v1 (PR #2220) | - -## Threat model: what each layer addresses - -### 1. Process hardening (Linux only) - -**When it runs:** Before any threads are spawned, before Tokio boots, -before any data is loaded into memory. - -**What it does:** - -- `PR_SET_DUMPABLE=0` — prevents ptrace, makes `/proc//` root-owned -- `PR_SET_NO_NEW_PRIVS=1` — irreversible; no child can ever gain privileges -- `RLIMIT_CORE=0` — no core dumps, so sensitive data never hits disk - -**What it protects against:** -- Process inspection via ptrace/strace/gdb -- Privilege escalation via setuid/setgid/fscaps -- Core dumps leaking API keys, tokens, prompt content - -**What it does NOT protect against:** -- A compromised child reading its parent's `/proc//mem` (already blocked - by `PR_SET_DUMPABLE=0` making `/proc//` root-owned) -- Kernel exploits that bypass prctl - -### 2. Landlock (Linux, kernel 5.13+) - -**When it runs:** Applied to each child process at spawn time via a -helper script or `landlock_restrict_self`. Only restrictable by the -process itself — parent cannot force Landlock on a child. - -**What it does:** -- Restricts filesystem access to a whitelist of paths -- Handles: `EXECUTE`, `READ_FILE`, `READ_DIR`, `WRITE_FILE`, `REMOVE_DIR`, - `REMOVE_FILE`, `MAKE_DIR`, `MAKE_REG`, `MAKE_SYM`, `TRUNCATE` - -**What it protects against:** -- Reading files outside the workspace (e.g., `/etc/passwd`, `~/.ssh`) -- Writing to system directories (`/usr`, `/bin`, `/lib`) -- Creating or deleting files in protected locations - -**What it does NOT protect against:** -- Network access (Landlock is filesystem-only) -- Process inspection (use seccomp for this) -- Reading files that are already mapped (Landlock applies at `open()` time) - -**Detection:** `detect_denial()` checks stderr for `Permission denied`, -`Operation not permitted`, `EACCES`, `EPERM`. - -### 3. seccomp BPF (Linux only) - -**When it runs:** Installed via `prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER)` -on the child process. - -**What it does:** -- Whitelist of ~100 safe syscalls (file I/O, memory, process, IPC, - synchronization, signals, time) -- **Explicitly denied:** `ptrace`, `mount`, `umount2`, `kexec_load`, - `kexec_file_load`, `init_module`, `finit_module`, `delete_module`, - `bpf`, `reboot`, `swapon`, `swapoff`, `pivot_root`, - `setuid`/`setgid`/`setreuid`/`setregid`/`setresuid`/`setresgid`, - `personality` -- Any syscall not on the whitelist → `SECCOMP_RET_KILL_PROCESS` (SIGSYS) - -**What it protects against:** -- Process hijacking via ptrace -- Mounting filesystems (bypassing Landlock read-only restrictions) -- Loading kernel modules -- Loading BPF programs (would bypass seccomp itself!) -- Rebooting the system -- Privilege changes via setuid/setgid - -**What it does NOT protect against:** -- Legitimate use of allowed syscalls for malicious purposes -- Side-channel attacks via allowed syscalls (e.g., timing) - -**Detection:** `detect_denial()` checks exit code 31 (SIGSYS) or stderr -for `Bad system call`, `bad system call`, `SIGSYS`, `seccomp`. - -### 4. Bubblewrap / bwrap (Linux, optional) - -**When it runs:** If `/usr/bin/bwrap` is present AND the config key -`[sandbox] prefer_bwrap = true` is set. Runs as an outer wrapper around -the child command. - -**What it does:** -- Creates a new mount namespace with `--unshare-all` -- Read-only bind-mounts the entire root filesystem -- Bind-mounts the workspace directory with read-write access -- Changes into the workspace with `--chdir` - -**What it protects against:** -- Any filesystem write outside the workspace (stronger than Landlock alone - because it's enforced at the namespace level, not just filesystem access) -- Accidental modification of system files - -**What it does NOT protect against:** -- Network access (bwrap does not create a network namespace by default with - `--unshare-all`; the child still has full network access) -- Process inspection -- Memory attacks +| Seatbelt (`sandbox-exec`) | macOS | Automatic when the runtime probe succeeds | `macos-seatbelt` | +| Bubblewrap (`/usr/bin/bwrap`) | Linux | `prefer_bwrap = true` and the file is executable | `linux-bwrap` | +| No OS wrapper | Linux without usable opt-in bwrap | Default | `none` | +| No OS wrapper | Windows | Current implementation | `none` | +| OpenSandbox-compatible service | Any supported host | `sandbox_backend = "opensandbox"` | External execution path | -**Installation:** User must install bubblewrap themselves: -- Ubuntu/Debian: `apt install bubblewrap` -- Fedora: `dnf install bubblewrap` -- Arch: `pacman -S bubblewrap` +The repository contains Landlock and seccomp implementation modules, plus a +future Windows helper contract. They are not wired into child-command launch +in v0.9.1, so Codewhale does not advertise them as active sandboxes. Detecting +a Landlock-capable kernel is not enough to claim that a command was restricted. -Codewhale does NOT vendor bwrap. +## macOS: Seatbelt -**Fallback:** If bwrap is not installed, the sandbox falls back to Landlock -only. +Codewhale probes `/usr/bin/sandbox-exec` by running a minimal profile. When the +probe succeeds and the selected `SandboxPolicy` requests a sandbox, the child +command is wrapped with a generated Seatbelt profile. -### 5. Seatbelt (macOS) +The profile can provide: -**When it runs:** Applied via the `sandbox-exec` wrapper command. The -seatbelt profile is generated dynamically based on the `SandboxPolicy`. +- broad filesystem reads; +- writes limited by the selected policy, including the workspace and specific + runtime/cache paths needed by supported tools; +- network access only when the policy enables it. -**What it does:** -- Restricts filesystem access based on the policy profile -- Can restrict network access (when `network_access: false`) +If the probe fails or `sandbox-exec` is unavailable, Codewhale reports no OS +sandbox and launches the command without a Seatbelt wrapper. It does not set a +Seatbelt marker on that fallback. -**What it protects against:** -- Reading/writing files outside allowed paths -- Network connections (when configured) +## Linux: opt-in bubblewrap -**What it does NOT protect against:** -- Process inspection (Seatbelt does not block ptrace) -- Syscall-level attacks +Linux command sandboxing is opt-in. Set the top-level configuration key: -**Detection:** Checks stderr for `file-write` and `network` denial patterns. +```toml +prefer_bwrap = true +``` -### 6. Windows Job Object (v1, PR #2220) +Codewhale selects bubblewrap only when `/usr/bin/bwrap` is a regular executable +file. The wrapper derives its mounts and network namespace from the resolved +`SandboxPolicy`: + +```text +/usr/bin/bwrap \ + --unshare-all \ + [--share-net] \ + --ro-bind / / \ + --bind ... \ + --ro-bind ... \ + --chdir \ + -- +``` -**When it runs:** Applied at process spawn time via -`PROC_THREAD_ATTRIBUTE_JOB_LIST` and restricted token assignment. +That gives the child a read-only root view. For `workspace-write`, every safe, +existing policy root is mounted read-write: the working directory, configured +additional roots, `/tmp` and `TMPDIR` unless excluded, and verified Git +worktree metadata roots. Existing `.codewhale` and `.deepseek` descendants are +remounted read-only after their writable parent. Missing paths, non-directory +paths, and `/` are not promoted to writable mounts. -**What it does (v1):** -- Job Object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` — all child - processes terminate when the parent exits -- Memory cap: 1 GB per process, 2 GB per job -- Active process limit: 64 -- UI restrictions: no desktop handle access -- Restricted token: drops Administrators group SID, sets medium-low - integrity level +For `read-only`, there are no writable binds, so the working directory remains +inside the read-only root view. `--unshare-all` isolates the network namespace +by default. Codewhale adds `--share-net` only when the policy's +`network_access` is true. `danger-full-access` and `external-sandbox` bypass the +local wrapper entirely. -**What is deferred (v2):** -- WFP (Windows Filtering Platform) firewall rules — network is open in v1 -- Filesystem ACL integration at spawn time (deferred) -- AppContainer isolation -- Registry key isolation +If the user does not opt in, or `/usr/bin/bwrap` is missing or non-executable, +Codewhale reports `none` and launches the command without a Linux OS wrapper. +There is no marker-only Landlock fallback. -**Detection:** Checks stderr for `Access is denied`, `STATUS_ACCESS_DENIED`, -`ERROR_ACCESS_DENIED`, `ERROR_PRIVILEGE_NOT_HELD`, -`ERROR_ACCESS_DISABLED_BY_POLICY`, and integrity/AppContainer patterns. +Install bubblewrap separately when this opt-in fits the workflow: -## Defense in depth +- Ubuntu/Debian: `apt install bubblewrap` +- Fedora: `dnf install bubblewrap` +- Arch: `pacman -S bubblewrap` -The Linux sandbox applies layers in order: +Codewhale does not vendor bubblewrap. -``` -Process hardening (prctl) ← before threads - ↓ -Landlock (filesystem) ← at child spawn - ↓ -seccomp BPF (syscalls) ← at child spawn - ↓ -bwrap (namespace isolation) ← optional outer wrapper -``` +## Windows: no advertised OS sandbox -Each layer addresses a different threat surface. seccomp cannot protect the -filesystem (that's Landlock's job). Landlock cannot stop ptrace (that's -seccomp + PR_SET_DUMPABLE). bwrap adds namespace-level isolation that -neither Landlock nor seccomp can provide. +The Windows command path currently reports no OS sandbox. The source tree has +a future helper contract for Job Object process-tree cleanup, but it is not +wired into selection and must not be described as any of the following: -## Configuration +- read-only filesystem or workspace-write enforcement; +- network blocking; +- registry isolation; +- restricted-token or AppContainer isolation. -Relevant config keys in `~/.codewhale/config.toml`: +Windows host permissions and approval policy still apply, but they are not a +Codewhale OS command sandbox. -```toml -# Sandbox policy mode -sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-access | external-sandbox +## Linux process hardening is not a command sandbox -# Linux bubblewrap passthrough -prefer_bwrap = false # requires `bubblewrap` package installed +At startup on Linux, Codewhale best-effort applies `PR_SET_DUMPABLE=0`, +`PR_SET_NO_NEW_PRIVS=1`, and `RLIMIT_CORE=0` to its own process. Each failure is +logged and startup continues. These controls reduce process-inspection, +privilege-escalation, and core-dump risk; they do not create filesystem or +network isolation for a child command and are not listed as a sandbox backend. -# External sandbox backend -sandbox_backend = "none" # "none" or "opensandbox" +## External OpenSandbox execution + +When `sandbox_backend = "opensandbox"` is configured, shell execution is sent +to the configured OpenSandbox-compatible HTTP endpoint instead of starting a +local child. Codewhale validates the request/response contract, but isolation +guarantees belong to the configured service and its operator. + +```toml +sandbox_backend = "opensandbox" sandbox_url = "http://localhost:8080" sandbox_api_key = "YOUR_API_KEY" ``` -Environment variable overrides: +`sandbox_backend = "none"` (or omitting the key) keeps local execution. -- `DEEPSEEK_SANDBOX_MODE` → `sandbox_mode` -- `DEEPSEEK_PREFER_BWRAP=true` → `prefer_bwrap` -- `DEEPSEEK_SANDBOX_BACKEND` → `sandbox_backend` -- `DEEPSEEK_SANDBOX_URL` → `sandbox_url` -- `DEEPSEEK_SANDBOX_API_KEY` → `sandbox_api_key` +## Policies and fallbacks -## Detecting sandbox denials +The local `sandbox_mode` values are: -When a command fails, the sandbox manager checks for denial patterns: +```toml +sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-access | external-sandbox +``` -| Platform | Denial mechanism | Exit code | Stderr patterns | -|---|---|---|---| -| macOS Seatbelt | sandbox-exec violation | non-zero | `file-write`, `network` | -| Linux Landlock | EACCES / EPERM | non-zero | `Permission denied`, `Operation not permitted` | -| Linux seccomp | SIGSYS (31) | 31 or 159 | `Bad system call`, `SIGSYS` | -| Linux bwrap | Mount/namespace failure | non-zero | varies | -| Windows | Access denied / privilege | non-zero | `Access is denied`, `ERROR_PRIVILEGE_NOT_HELD` | +- `read-only` and `workspace-write` are enforced by Seatbelt or bubblewrap only + when that wrapper is selected and available. +- `danger-full-access` deliberately bypasses the local OS wrapper. +- `external-sandbox` declares that execution is already externally isolated + and bypasses a second local wrapper. +- When no wrapper is selected, the shell command runs without Codewhale OS + isolation. Approval rules and workspace-aware native file tools remain + separate controls. + +Canonical environment overrides exist for `sandbox_mode` and the external +backend: + +- `CODEWHALE_SANDBOX_MODE` +- `CODEWHALE_SANDBOX_BACKEND` +- `CODEWHALE_SANDBOX_URL` +- `CODEWHALE_SANDBOX_API_KEY` + +There is no `CODEWHALE_PREFER_BWRAP` environment override; use the top-level +`prefer_bwrap` config key. + +## Diagnostics and failure attribution + +`codewhale setup --status`, `codewhale doctor`, `codewhale doctor --json`, and +the `diagnostics` tool report the locally available wrapper after applying the +resolved bubblewrap preference. An individual command can still bypass that +wrapper when its policy does not request sandboxing. On Linux, merely finding +a Landlock syscall or a bwrap source module does not make +`sandbox_available` true. + +Denial attribution is intentionally conservative: -The `was_denied()` method on `SandboxManager` aggregates all platform-specific -checks. The `denial_message()` method returns a human-readable explanation. +- Seatbelt uses its wrapper-specific denial patterns. +- Bubblewrap setup errors must be prefixed by `bwrap:`; a read-only-filesystem + error from the bwrap filesystem view can also identify the boundary. +- A child command's generic `Permission denied` or `Operation not permitted` + is not, by itself, proof that Codewhale's sandbox blocked it. +- Unsandboxed command failures are never labeled sandbox denials. ## Limitations -### What the sandbox does NOT protect against - -- **Network attacks** — only macOS Seatbelt can block network; Linux and - Windows v1 leave network open -- **Memory attacks** — no platform prevents a child process from reading - its own memory or exploiting memory corruption bugs -- **Timing side channels** — allowed syscalls on Linux can be used for - timing-based information leaks -- **Resource exhaustion** — the Linux job object limits memory and process - count, but does not limit CPU, file descriptors, or disk I/O -- **Kernel vulnerabilities** — if the kernel itself has a vulnerability, - the sandbox cannot prevent exploitation (this applies to all platforms) -- **Supply chain** — if the child process downloads and executes untrusted - code, the sandbox limits what that code can do, but does not prevent the - download - -### Platform-specific gaps - -- **Linux:** Landlock only protects filesystem access. seccomp adds syscall - filtering but uses a whitelist that may need updates for new syscalls. -- **macOS:** Seatbelt profiles are generated at runtime. A misconfigured - profile could be too permissive. -- **Windows v1:** No filesystem ACL enforcement at spawn time. Network is - fully open. Job Object is process-tree only. - -## Related - -- `crates/tui/src/sandbox/` — implementation -- `crates/config/src/lib.rs` — config keys -- `crates/tui/src/tools/diagnostics.rs` — `diagnostics` tool reports - `sandbox_available`, `sandbox_type`, `bwrap_available`, `cgroup_version` -- `config.example.toml` — annotated config reference -- Issue #2180 — this document -- Issue #2182 — seccomp filter implementation -- Issue #2183 — process hardening -- Issue #2184 — bwrap passthrough -- Issue #2185 — Windows Job Object v1 -- Issue #2186 — SandboxExecutor trait unification -- Issue #2187 — sandbox parity tests +- Availability is checked before launch; the selected wrapper can still fail + because of host policy, container restrictions, or a race after the probe. +- Bubblewrap ignores a configured writable root if it is missing, is not a + directory, or canonicalizes to `/`; a path can also disappear between policy + resolution and wrapper launch. +- Seatbelt profiles are generated at runtime and must be tested against the + commands they are expected to support. +- No current local wrapper is advertised on Windows. +- An external sandbox backend is only as strong as its configured service. +- No sandbox protects against kernel vulnerabilities or all resource-exhaustion + and side-channel attacks. + +## Implementation references + +- `crates/tui/src/sandbox/mod.rs` — truthful selection and public capability markers +- `crates/tui/src/sandbox/seatbelt.rs` — macOS wrapper and availability probe +- `crates/tui/src/sandbox/bwrap.rs` — Linux opt-in wrapper +- `crates/tui/src/sandbox/process_hardening.rs` — Linux parent-process hardening +- `crates/tui/src/sandbox/backend.rs` — external backend selection +- `crates/tui/src/tools/diagnostics.rs` — machine-readable diagnostics diff --git a/docs/SKILLS.md b/docs/SKILLS.md new file mode 100644 index 0000000000..0f8214e1cf --- /dev/null +++ b/docs/SKILLS.md @@ -0,0 +1,196 @@ +# Skills Manager + +Skills are reusable `SKILL.md` instruction packs. Codewhale discovers them from +several roots, but **only CodeWhale-owned directories are writable**. The unified +`/skills` manager is the interactive surface for audit and mutation; slash +aliases share the same write path. + +For Claude Code plugin boundaries, see [CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md). +For `skills_dir` and `[skills]` config keys, see [CONFIGURATION.md](CONFIGURATION.md). + +## Architecture (four layers) + +| Layer | Role | +| --- | --- | +| **Root catalog** | Single source of precedence and ownership (`SkillRootCatalog`). | +| **Audit** | Read-only, unmerged on-disk inventory (status, digest, actions). | +| **Mutation controller** | Only writer for install / import / update / remove / trust. | +| **Skills manager view** | TUI: emits events only; never writes files itself. | + +Runtime discovery (`SkillRegistry`) still merges skills for the model. Audit +intentionally does **not** merge — it shows every on-disk copy so conflicts and +shadowing stay visible. + +## Ownership and roots + +**Writable (CodeWhale-owned)** + +| Scope | Path | +| --- | --- | +| Project | `/.codewhale/skills/` | +| Global | `~/.codewhale/skills/` | + +**Read-only compatible** (discover / import source only — never mutated in place) + +Examples: `/.agents/skills`, `./skills`, `.claude/skills`, +`.cursor/skills`, `.opencode/skills`, `~/.agents/skills`, `~/.claude/skills`, +and similar harness layouts. + +**Audit-only (not runtime-active)** + +- `.codex/skills` appears in **compatible** audit scans so operators can see it. + It does **not** join the runtime discovery set. + +Configured `skills_dir` that is not one of the owned CodeWhale roots stays +read-only. Discovery and the manager can list it; mutations still target owned +project/global roots only. + +## Slash commands + +| Command | Behavior | +| --- | --- | +| `/skills` | Opens the Skills Manager (owned-only scan, **no network**). | +| `/skills ` | Text list filtered by name prefix. | +| `/skills inspect` | Text discovery mode, searched directories, and source paths. | +| `/skills --remote` | Explicit registry listing (network). | +| `/skills sync` | Explicit registry → local cache sync (network). | +| `/skill ` | Activate a skill for the next turn. | +| `/skill install [--project\|--global] ` | Install via mutation controller. | +| `/skill update [--project\|--global] ` | Update a managed skill from its registry provenance. | +| `/skill uninstall [--project\|--global] ` | Remove a managed skill. | +| `/skill trust [--project\|--global] ` | Write digest-bound advisory trust. | + +Notes: + +- There is **no** `/skills audit` subcommand. Use the manager (and `c` to toggle + compatible roots) or `/skills inspect` for discovery details. +- Bare `/skill install ` (no scope flag) installs into the CodeWhale + **global** owned root. +- If the same name exists in both project and global owned roots, update / + uninstall / trust require `--project` or `--global`. +- If a name exists only under a compatible external root, writes are refused; + import it through `/skills` instead of editing harness directories. + +## Skills Manager (TUI) + +Default open path: type `/skills` and confirm. The surface is zero-network on +open (owned-only audit). + +| Key | Action | +| --- | --- | +| `↑`/`↓` or `j`/`k` | Move selection | +| `Enter` | Primary available action / confirm pending prompt | +| `i` | Import (external → owned) | +| `u` | Update (managed + registry provenance) | +| `r` | Remove (managed; confirms first) | +| `t` | Trust (managed; digest-bound) | +| `s` | Toggle import target: project ↔ global | +| `c` | Toggle scan: owned-only ↔ compatible (still local disk only) | +| `Esc` | Cancel confirm, or close the manager | + +The view never calls install helpers or touches the filesystem. It emits a +mutation request; the host runs the controller, shows a receipt, and rebuilds +the inventory. + +## Audit statuses + +Each audited row carries precedence and relationship flags: + +| Status | Meaning | +| --- | --- | +| **Active** | Highest-precedence copy for that canonical name in the scan. | +| **Shadowed** | Same name exists at a higher-precedence root. | +| **Duplicate** | Same canonical name and same package digest as another copy. | +| **Conflict** | Same canonical name, different package digest. | + +External skills with no owned peer (and a valid digest) are **import +candidates**. Externals that conflict with or exactly duplicate an owned copy +can still offer Import — duplicate → already present; conflict → confirm replace +in the selected import scope. + +## Provenance and markers + +Managed installs write schema **v2** metadata under the skill directory: + +**`.installed-from` (v2)** — written last on successful install/import: + +```json +{ + "schema_version": 2, + "spec": "github:owner/repo", + "url": "https://…", + "source_checksum": "…", + "content_digest": "…", + "installed_name": "my-skill", + "registry_version": null +} +``` + +- `content_digest` is a bounded package tree hash (not SKILL.md alone). +- Display of URLs strips userinfo, query, and fragment. +- Imports use a local `import:…` provenance and **cannot** be updated from a + registry; re-import or remove them instead. +- Legacy v1 markers are recognized as managed with + `LegacyMetadataUnknown` integrity until refreshed. + +**`.trusted` (v2)** — advisory, digest-bound: + +```json +{ + "schema_version": 2, + "content_digest": "…" +} +``` + +Trust records review intent. It does **not** sandbox the skill or auto-approve +tools. Content updates clear trust so a stale marker cannot outlive the bytes. + +Manual skills (owned root, no managed marker) are visible but not +update/remove/trust through the managed actions. + +## Package digest and safety + +Audit and mutation share a bounded package digest: + +- Regular files only; symlinks that escape the skill root or cycle → fail closed. +- Caps on total size, file count, and depth. +- Mutations re-check an expected digest before write (TOCTOU). +- Import/replace keeps a `.bak` until digest + marker finalize succeed; failure + restores the previous owned package. + +## Readiness + +The audit model has a readiness field and optional provider hook for a future +readiness cache ([#4407](https://github.com/Hmbown/CodeWhale/issues/4407)). +Today, when no cache is wired, readiness is always **`Unknown`**. The manager +does not run readiness probes and does not block mutations on readiness. + +## Config knobs + +```toml +# Optional override for discovery preference (not automatically a write target +# unless it is the CodeWhale project/global owned path). +skills_dir = "/path/to/skills" + +[skills] +# When true, runtime discovery skips cross-tool roots (.claude, .agents, …). +# Owned CodeWhale roots and an explicit skills_dir override still apply. +scan_codewhale_only = false + +# Optional registry / install size overrides used by --remote, sync, and install. +# registry_url = "https://…" +# max_install_size_bytes = 5242880 +``` + +See [CONFIGURATION.md](CONFIGURATION.md) for the full config surface. + +## Operator checklist + +1. Prefer `/skills` for day-to-day management; keep `--remote` / `sync` explicit. +2. Never hand-edit `.claude` / `.agents` / `.cursor` trees to “install” for + Codewhale — import into `.codewhale/skills` instead. +3. Treat `.trusted` as advisory documentation of review, not a security boundary. +4. After registry updates that change content, re-trust if you still want the + advisory marker. +5. Dual project+global copies of the same name need an explicit scope flag on + CLI mutations. diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index abbc267412..d222a8e180 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -95,6 +95,12 @@ session projection and worker record. By default the branch is `codex/agent--` and the checkout lives beside the parent repo under `.codewhale-worktrees/`, so the parent checkout stays clean. +Isolation is not write authority. A prompt-only general child starts read-only. +A writer also declares `write_authority: "workspace_write"` or +`"worktree_write"` and at least one normalized repo-relative `write_roots`, +`exact_files`, or `coordination_contracts` value. Active overlapping shared +claims fail before mutation; a real isolated worktree may proceed in parallel. + Optional fields: - `worktree_branch`: exact branch to create. @@ -107,9 +113,10 @@ an already-created directory inside the parent workspace. ## Delegation briefs -The parent should pass a compact brief instead of a loose paragraph. The current -model-facing `agent` tool still accepts a single `prompt` string, so put these -fields in that string: +The parent should pass a compact brief instead of a loose paragraph. Use the +structured `dependencies` and `acceptance` arrays for bounded prerequisite facts +and observable checks; keep the focused objective in `prompt`. Do not copy raw +parent reasoning or an unbounded transcript. ``` QUESTION: diff --git a/docs/TERMUX.md b/docs/TERMUX.md index b6c6d4dfda..4d641e0645 100644 --- a/docs/TERMUX.md +++ b/docs/TERMUX.md @@ -33,9 +33,10 @@ Codewhale's security model has three distinct layers on Android: user has granted to Termux. See the [Android application sandbox](https://source.android.com/docs/security/app-sandbox) and [Termux filesystem layout](https://github.com/termux/termux-packages/wiki/Termux-file-system-layout). -2. **Codewhale's per-command sandbox backend** — Seatbelt (macOS) or - Landlock/bwrap (Linux) can further narrow what a child command may access. - Codewhale does not currently provide that additional layer on Android. +2. **Codewhale's per-command sandbox backend** — Seatbelt (macOS) or the + opt-in bubblewrap wrapper (Linux) can further narrow what a child command + may access. Codewhale does not currently provide that additional layer on + Android. 3. **Codewhale's own gates** — workspace trust, approval prompts, `allow_shell`/`disallowed-tools`, and the file-tool permission system. These share the cross-platform application code path; their Android @@ -43,15 +44,15 @@ Codewhale's security model has three distinct layers on Android: ### Codewhale sandbox backend: none -Codewhale's existing Seatbelt and Landlock/bwrap integrations do not target +Codewhale's existing Seatbelt and Linux bubblewrap integrations do not target Android. Consequently, `codewhale doctor --json` reports the sandbox as `{"available": false, "kind": null}` on Android. That status describes the absence of an additional Codewhale child-process sandbox; it does not mean Android or Termux provides no OS isolation. - `get_platform_sandbox()` returns `None` on Android. -- No Linux-only sandbox modules (Landlock, bwrap) are compiled into the - Android build — they are `#[cfg(target_os = "linux")]`-gated and Rust +- No Linux-only bubblewrap wrapper is compiled into the Android build — it is + `#[cfg(target_os = "linux")]`-gated and Rust treats `android` as a distinct target from `linux`. - Shell commands retain Termux's Android app boundary but receive no Codewhale-specific filesystem narrowing. Treat every location available to @@ -95,7 +96,7 @@ skipped entirely on Android (Bionic libc). | Feature | Status | Notes | |---------|--------|-------| | Android app sandbox | ✅ inherited | Per-app UID plus Android platform protections | -| Codewhale command sandbox | ❌ unavailable | No Landlock/bwrap/Seatbelt backend on Android | +| Codewhale command sandbox | ❌ unavailable | No bubblewrap/Seatbelt backend on Android | | Codewhale keyring backend | ❌ unavailable | Falls back to file-backed secrets | | Approvals / gates | ⚠️ implemented | Device QA pending | | File tools | ⚠️ implemented | Device QA pending | diff --git a/docs/TOOL_LIFECYCLE.md b/docs/TOOL_LIFECYCLE.md index abeb79cb7d..7eed9e7f3a 100644 --- a/docs/TOOL_LIFECYCLE.md +++ b/docs/TOOL_LIFECYCLE.md @@ -1,6 +1,9 @@ -# Tool-Surface Lifecycle Policy (v0.8.53) +# Historical Tool-Surface Lifecycle Policy (v0.8.53) -**Status:** Design doc / policy. No catalog code lands in this cycle — the code +**Status:** Historical design record, not current runtime documentation. The +v0.9.1 canonical action surface and replay-only alias contract are documented in +[`RUNTIME_SIMPLIFICATION_DESIGN.md`](RUNTIME_SIMPLIFICATION_DESIGN.md) and +[`TOOL_SURFACE.md`](TOOL_SURFACE.md). No catalog code landed in this old cycle — the code work is **deferred**. This document is the umbrella policy for GitHub **#2681**, with **#2682** and **#2683** as concrete instances of the planned diet. It describes *what will be done* and the invariants any future diet PR must hold. diff --git a/docs/TOOL_SURFACE.md b/docs/TOOL_SURFACE.md index 4bb2264418..b8b91ea821 100644 --- a/docs/TOOL_SURFACE.md +++ b/docs/TOOL_SURFACE.md @@ -1,400 +1,177 @@ # Tool surface -Why these specific tools, in this groupings, and how each one is meant to be -chosen over the available shell equivalent. Companion to `crates/tui/src/prompts/text.rs` (`AGENT_PROMPT`). - -## Design stance +This document describes the current model-facing tool contract in the v0.9.1 +source candidate. The registry remains larger than the first-turn catalog so +saved transcripts can replay and uncommon capabilities can be loaded on demand. +The model should learn one canonical name for each common operation. + +Implementation sources: + +- `crates/tui/src/core/engine/tool_catalog.rs` owns the eager/deferred catalog. +- `crates/tui/src/tools/registry.rs` registers canonical tools and hidden aliases. +- `crates/tui/src/tools/{file_tool,git_tool,run_tool,web_tool,shell}.rs` own the + canonical action schemas. +- `docs/RUNTIME_SIMPLIFICATION_DESIGN.md` records the v0.9.1 cutover and receipt. + +## Default-active contract + +The default-active policy contains exactly these ten names: + +1. `Bash` +2. `File` +3. `Git` +4. `Run` +5. `agent` +6. `remember` +7. `tasks` +8. `update_plan` +9. `work_update` +10. `tool_search` + +`remember` is registered only when the user enables the built-in memory path; +once present, it stays eager so a model can capture a durable preference without +first discovering the tool. A memory-disabled or Moraine-fallback runtime omits +that registration and therefore exposes nine of the ten policy names. +`tool_search` is synthetic rather than registry-backed and is always active. + +The surface is action-based. A model calls one stable tool name and selects the +operation through its `action` field instead of choosing among many synonymous +single-purpose tools. + +### Core action tools + +| Tool | Actions | Purpose | +|---|---|---| +| `Bash` | `run`, `wait`, `interact`, `cancel` | Run bounded commands, continue background work, send input, and cancel processes. | +| `File` | `read`, `list`, `search_name`, `search_content`, `write`, `edit`, `patch` | Read, find, and modify workspace files with structured, workspace-aware results. | +| `Git` | `status`, `diff`, `log`, `show`, `blame` | Inspect repository state and history without parsing shell output. | +| `Run` | `tests`, `verifiers` | Run project tests or independent verifier gates with structured results. | -- **Dedicated tools over `exec_shell` whenever the dedicated tool returns - structured output.** Bash escaping is error-prone and platform behavior - varies (GNU vs BSD `grep`, `rg` is not always installed). Structured - output also frees the model from re-parsing free-form text. -- **`exec_shell` for everything else.** Build, test, format, lint, ad-hoc - commands, anything platform-specific. We don't try to wrap the long tail. -- **Drop tools that don't beat their shell equivalent.** Two-tool aliases - for the same backing operation are a model trap — the LLM will alternate - between them and the cache hit rate suffers. +`Bash` appears only when the active session/profile permits shell use. Plan +keeps it unavailable. In Act and Operate, the active permission posture, +sandbox, command policy, trusted paths, repository law, and managed policy still +apply. Full Access removes ordinary approval prompts; it does not bypass hard +safety or repository-policy holds. -## Current surface (v0.9.1) +`File` is capability-filtered by mode. Plan advertises its read-only actions; +write/edit actions require Act or Operate, and `patch` also requires the +apply-patch feature. The same read-before-edit, workspace, and policy checks used +by the former spellings remain in force. -### File operations +### Coordination tools -| Tool | Niche | +| Tool | Purpose | |---|---| -| `read_file` | Read a UTF-8 file. PDFs auto-extracted via bundled pure-Rust extractor (no Poppler install required); `pages: "1-5"` slices large docs. | -| `list_dir` | Structured, gitignore-aware listing. Preferred over `exec_shell("ls")`. | -| `write_file` | Create or overwrite a file. | -| `edit_file` | Search-and-replace inside a single file. Cheaper than a full rewrite. | -| `apply_patch` | Apply a unified diff. The right tool for multi-hunk edits. | -| `retrieve_tool_result` | Read summaries or slices of prior large tool outputs spilled to `~/.codewhale/tool_outputs/`; use `summary`, `head`, `tail`, `lines`, or `query` instead of replaying the whole result. | -| `handle_read` | Read bounded projections from `var_handle` payloads held by live tool environments. This is the foundation for RLM sessions, sub-agent transcripts, and other large symbolic payloads. | +| `agent` | Dispatch one focused sub-agent run and return an id, compact receipt, and transcript handle. | +| `remember` | Append one terse durable preference or convention when the user has enabled built-in memory. | +| `tasks` | Create, list, read, cancel, gate, and inspect durable task work through one action family. | +| `update_plan` | Publish optional high-level strategy, phases, constraints, verification, and handoff context. | +| `work_update` | Replace the concrete To-do / Work progress projection for the active thread or durable task. | +| `tool_search` | Discover and load a deferred tool only when the current turn needs it. | -### Search +`update_plan` and `work_update` are complementary, not competing checklists. +The former carries strategy; the latter is the concrete progress ledger shown +to the user. -| Tool | Niche | -|---|---| -| `grep_files` | Regex search file contents within the workspace; structured matches + context lines. Pure-Rust (`regex` crate), no `rg`/`grep` shell-out. | -| `file_search` | Fuzzy-match filenames (not contents). Use when you know roughly the name. | -| `web_search` | DuckDuckGo by default with Bing fallback; Bing, Tavily, Bocha, Metaso, SearXNG, Baidu, Volcengine, and Sofya are selectable in config. Ranked snippets + `ref_id` for citation. | -| `fetch_url` | Direct HTTP GET on a known URL. Faster than `web_search` when the link is already known. HTML stripped to text by default. | +## Deferred and dynamic tools -### Shell +`Web` is a conditional, deferred action tool with `search`, `fetch`, and `wait` +actions. It is discoverable through `tool_search` only when the active network +policy and runtime backend permit it; it is not one of the ten default-active +names. -Shell tools appear in the model-visible tool catalog only when shell access is -enabled for the active session or profile. Interactive TUI Agent sessions expose -shell by default with approval prompts unless top-level `allow_shell = false` -hides it. Headless, durable-task, and other noninteractive profiles keep the -conservative omitted-field default and require `allow_shell = true`. YOLO -enables shell access automatically. Plan mode keeps shell execution off. +The durable `github`, `automation`, and `rlm` action families are also deferred +by default. `rlm` owns `open`, `eval`, `configure`, and `close` actions for a +persistent sandboxed Python session. Feature-gated native tools may be added to +the active or deferred catalog only when their implementation and host +dependencies are available. -| Tool | Niche | -|---|---| -| `exec_shell` | Run a shell command. Foreground runs are cancellable, but use them only for bounded commands; timeout kills the process and returns a background-rerun hint. | -| `exec_shell_wait` | Poll a background task for incremental output. Canceling the turn stops waiting without killing the task. | -| `exec_shell_interact` | Send stdin to a running background task and read incremental output. | -| `exec_shell_cancel` | Cancel one running background shell task by id, or all running background shell tasks when explicitly requested. | -| `task_shell_start` | Start a long-running command in the background and return immediately. Preferred over foreground shell for diagnostics, tests, searches, and servers that may run for minutes. | -| `task_shell_wait` | Poll a background command. If `gate` is supplied after completion, record structured gate evidence on the active durable task. | - -`allow_shell = true` exposes shell tools; it does not disable built-in shell -safety validation. Direct multiline `exec_shell` commands, including heredocs -and embedded scripts such as multiline `python -c`, are blocked. Use one-line -commands, write the script/content to a file first and execute it, or start -long/manual flows with `task_shell_start` or background shell and poll them. - -When a foreground shell command times out, the process is not continued -silently. The tool result tells the model to rerun long work with -`task_shell_start` or `exec_shell` with `background = true`, then poll with -`task_shell_wait` or `exec_shell_wait`. - -Interactive shell jobs are also visible through `/jobs`. The TUI job center is -fed by the same shell manager as `exec_shell`/`task_shell_start`, and shows the -command, cwd, elapsed time, status, output tail, process-local shell id, and -linked durable task id when available. `/jobs show`, `/jobs poll`, `/jobs wait`, -`/jobs stdin`, and `/jobs cancel` provide inspect, polling, stdin, and cancel -controls for live jobs. Jobs are process-local; after restart, live process -state is not reattached, and any remembered detached entries must be marked -stale rather than presented as live processes. - -Shell permission policy is evaluated by `crates/execpolicy`. Deny prefixes are -checked before trusted prefixes and block matching commands regardless of layer. -Trusted prefixes only skip approval in modes that permit trust shortcuts. -Manually authored `permissions.toml` records support -`action = "deny" | "ask" | "allow"`: `deny` blocks matching invocations before -mode-based approval handling, `allow` skips approval for matching invocations, -and `ask` forces approval only in modes that can prompt. Outside the TUI -auto-approve path, a matching `ask` rule under `AskForApproval::Never` is -rejected because the runtime cannot ask the user. In YOLO / auto-approval -sessions, `ask` rules do not downgrade the session into prompting or blocking; -explicit `deny` rules still block according to the current execution-policy -logic. - -The TUI runtime loads typed records from the sibling `permissions.toml` file and -applies matching `exec_shell` command rules and explicit file-path rules. In -supported approval cards, `S` approves once and appends persistent -`action = "ask"` rules: - -- `exec_shell`: the exact approved command string (matched by the existing - arity-aware command matcher). -- `write_file`: the exact workspace-relative target path. -- `edit_file`: the exact workspace-relative target path. -- `apply_patch`: one exact workspace-relative path rule per validated touched - file reported by apply-patch preflight. - -`read_file` path rules can be authored in `permissions.toml` and matched at -runtime, but the approval UI does not save `read_file` rules. This is still not -a policy editor: the UI does not save `allow`/`deny`, edit or delete rules, -expand globs, or create broad directory rules. - -### MCP manager and palette discovery - -MCP server configuration is surfaced in the TUI through `/mcp` and the -`mcp_config_path` row in `/config`. `/mcp` shows the resolved config path, -server enabled/disabled state, transport, command or URL, timeouts, connection -errors, and discovered tools/resources/prompts. It supports narrow manager -actions for init, add, enable, disable, remove, validate, and reload/reconnect. -Config edits are written immediately, but the model-visible MCP tool pool is -restart-required after edits. - -The command palette includes MCP entries grouped by server. Disabled and failed -servers stay visible, and discovered tools/prompts use the runtime names shown -to the model, such as `mcp__`. - -### Git / diagnostics / testing - -| Tool | Niche | -|---|---| -| `git_status` | Inspect repo status without running shell. | -| `git_diff` | Inspect working-tree or staged diffs. | -| `diagnostics` | Workspace, git, sandbox, and toolchain info in one call. | -| `run_tests` | `cargo test` with optional args. | -| `run_verifiers` | Run independent verifier gates in parallel across detected Rust, Node, Python, and Go projects, with optional custom `program` + `args` gates for other ecosystems. | - -### Task management and durable work +MCP tools are dynamic. Successfully connected servers register names such as +`mcp__` from `~/.codewhale/mcp.json`; a failed or disabled server +must not be presented as an available model tool. -| Tool | Niche | -|---|---| -| `update_plan` | Optional high-level Strategy metadata/context/route for complex multi-phase work — not a second checklist. | -| `task_create` | Create/enqueue a durable background task through `TaskManager`. This is the real executable work object for long-running agent work. | -| `task_list` | List durable tasks with status and linked runtime ids. | -| `task_read` | Read durable task detail: thread/turn linkage, timeline, checklist, gates, artifacts, PR attempts, GitHub events. | -| `task_cancel` | Cancel a queued or running durable task. Approval-required. | -| `work_update` | Canonical To-do / Work progress under the active thread/task. Ordinary in-flight progress flows through this tool. | -| `note` | One-off important fact for later. | - -The legacy `checklist_write` / `checklist_add` / `checklist_update` / -`checklist_list` and older `todo_write` / `todo_add` / `todo_update` / -`todo_list` names are hidden compatibility aliases for saved transcript -replay. They remain callable by exact name, but they are not part of the -model-visible catalog (#4132). - -`update_plan` accepts both the legacy shape (`explanation` plus `plan` steps) -and a richer PlanArtifact shape for Plan mode review. The richer fields are -optional and should be filled only when grounded in evidence: `title`, -`objective`, `context_summary`, `sources_used`, `critical_files`, -`constraints`, `recommended_approach`, `verification_plan`, -`risks_and_unknowns`, and `handoff_packet`. The transcript card, Plan-mode -confirmation prompt, `/relay`, and fork-state handoff all render the same -artifact so a plan can be reviewed, accepted, revised, replayed, or delegated -without losing its source context. - -Strategy metadata and checklist work are one Work surface. Treat -`update_plan` as phase context and sequencing intent, while `checklist_*` -remains the counted task ledger. When both exist, UI projections should group -strategy around the checklist instead of showing two peer checklist/progress -systems for the same run. - -### Verification gates and artifacts - -| Tool | Niche | -|---|---| -| `task_gate_run` | Run an approved verification command and attach structured evidence to the active durable task: command, cwd, exit code, duration, classification, summary, and log artifact. | +## Modes and permission postures -Large logs and command outputs should be artifacts with compact summaries in the transcript. `task_gate_run` handles this automatically for active durable tasks. +Modes and permission postures are separate controls: -Sub-agent runs expose a compact run receipt through `agent`: `run_id`, -`follow_up`, `takeover`, `artifacts`, `usage`, `verification`, and -`worker_record`. Usage is marked -`unknown` until worker-level token accounting is available, and verification is -`self_report_only` unless a separate gate or artifact proves the claim. +- **Plan** is read-only. It exposes the read-only `File` projection and other + safe inspection capabilities, but no shell or file mutation. +- **Act** is ordinary interactive execution. +- **Operate** uses the same direct-tool authority as Act while preferring Fleet + workers for independent, parallel, isolated, background, or long-running work. +- **Ask**, **Auto-Review**, and **Full Access** control approval behavior within + an action-capable mode. They never widen a Plan turn into write access. -### GitHub context and guarded writes +See `docs/MODES.md` for the full mode and posture contract. -| Tool | Niche | -|---|---| -| `github_issue_context` | Read-only issue context via `gh issue view`; large bodies become task artifacts when possible. | -| `github_pr_context` | Read-only PR context via `gh pr view`; optional diff capture via `gh pr diff --patch`; large bodies/diffs become task artifacts when possible. | -| `github_comment` | Approval-required issue/PR comment with structured evidence. | -| `github_close_issue` | Approval-required issue closure. Requires non-empty acceptance criteria and evidence; refuses dirty worktrees unless explicitly allowed. Never use for PRs. | -| `github_close_pr` | Approval-required PR closure. Requires the same structured evidence as issue closure and keeps PR wording in tool output/audit records. | +## Replay-only aliases -### PR attempts +Legacy single-purpose names stay registered so saved transcripts, sessions, and +recorded automation replay without migration. They are hidden from the model +catalog and from `tool_search`; new prompts and docs must use the canonical +action tools. -| Tool | Niche | +| Replay-only spellings | Canonical action | |---|---| -| `pr_attempt_record` | Capture the current git diff as attempt metadata plus a patch artifact on a durable task. | -| `pr_attempt_list` | List attempts recorded on a task. | -| `pr_attempt_read` | Inspect one recorded attempt and its artifact reference. | -| `pr_attempt_preflight` | Run `git apply --check` against an attempt patch. No worktree mutation. | +| `exec_shell`, `exec_shell_wait`, `exec_wait`, `exec_shell_interact`, `exec_interact`, `exec_shell_cancel` | `Bash`: `run`, `wait`, `interact`, `cancel` | +| `read_file`, `list_dir`, `grep_files`, `file_search`, `write_file`, `edit_file`, `apply_patch` | `File`: `read`, `list`, `search_content`, `search_name`, `write`, `edit`, `patch` | +| `git_status`, `git_diff`, `git_log`, `git_show`, `git_blame` | `Git`: matching action | +| `run_tests`, `run_verifiers` | `Run`: `tests`, `verifiers` | +| `web_search`, `fetch_url`, `wait_for_dev_server` | `Web`: `search`, `fetch`, `wait` | +| `task_*` | `tasks`: matching action | +| `github_*` | `github`: matching action | +| `automation_*` | `automation`: matching action | +| `rlm_open`, `rlm_eval`, `rlm_configure`, `rlm_close` | `rlm`: `open`, `eval`, `configure`, `close` | +| `checklist_*`, `todo_*` | `work_update` | -### Automations +Replay compatibility does not make an alias a supported spelling for new model +calls. Alias execution must stay behaviorally equivalent to its canonical +action and must not add the alias back to the advertised catalog. -| Tool | Niche | -|---|---| -| `automation_create` | Create a scheduled automation. Approval-required. | -| `automation_list` / `automation_read` | Inspect durable automations and recent runs. | -| `automation_update` | Update prompt, schedule, cwds, or status. Approval-required. | -| `automation_pause` / `automation_resume` / `automation_delete` | Lifecycle controls. Approval-required. | -| `automation_run` | Run an automation now; the run enqueues a normal durable task. Approval-required. | +## Long-running work -### Sub-agents +Use `Bash` with `action: "run"` for bounded commands. Set its background option +for work that may outlive a normal foreground wait, then use `wait`, `interact`, +or `cancel` against the returned process id. Live shell jobs are also visible in +`/jobs`; process-local jobs must be marked stale after restart rather than shown +as reattached processes. -v0.8.33 began moving large tool outputs toward symbolic handles: tools return -small `var_handle` objects, and `handle_read` retrieves bounded slices, counts, -or JSON projections from the backing environment. This keeps the parent -transcript small while preserving a recovery path to the full payload. +Use `tasks` when the work itself needs a durable lifecycle, structured gates, +artifacts, replayable timelines, or a stable task id. Large tool results should +remain behind bounded handles or artifacts instead of being copied wholesale +into the parent transcript. -The active model-facing sub-agent surface is intentionally small: +## Parallel fan-out -| Tool | Niche | -|---|---| -| `agent` | Launch one focused child run. Returns an agent id, compact receipt, and transcript handle while the parent can keep coordinating. | +The sub-agent capacity source of truth is +`crates/tui/src/config/subagent_limits.rs`: -See `agent.txt` for the delegation protocol and -[`SUBAGENTS.md`](SUBAGENTS.md) for the role taxonomy -(`general` / `explore` / `plan` / `review` / `implementer` / -`verifier` / `custom`). +- default configured concurrency: **64**; +- maximum configured concurrency: **128**; +- maximum admitted running-plus-queued work: **1024**. -`agent` defaults to a fresh child conversation. Pass -`fork_context: true` for continuation-style work or multi-perspective reviews -that should inherit the parent's context. In fork mode, the runtime preserves -the parent prefill/prompt prefix byte-identically where available so DeepSeek's -prefix cache can be reused, then appends the child role instructions and task. +These are capacity ceilings, not advice to dispatch every available slot. A +manager should use the smallest useful fan-out, preserve a single owner for +fan-in, and verify worker receipts before reporting combined completion. -### Recursive LM sessions +RLM child-query batching is a different, cheaper cost class. Its +`sub_query_batch` helper accepts 1–16 one-shot children inside a live `rlm` +session; it is not a substitute for tool-carrying `agent` workers. -RLM is now persistent as well: - -| Tool | Niche | -|---|---| -| `rlm_session_objects` | List compact cards for the active prompt, session metadata, transcript, latest user message, and per-message refs. | -| `rlm_open` | Open a named Python REPL over a file, inline content, or URL. | -| `rlm_eval` | Run bounded Python against that session, using deterministic code and in-REPL semantic helpers such as `sub_query_batch`. | -| `rlm_configure` | Adjust output feedback, child-query timeout/depth, and session-sharing settings. | -| `rlm_close` | Shut down the Python runtime and return final session stats. | - -`rlm_open` also accepts `session_object`, a stable ref returned by -`rlm_session_objects`, such as `session://active/system_prompt`, -`session://active/transcript`, or `session://active/messages/0`. This loads -the selected object into the RLM REPL and returns only metadata to the parent -transcript. Transcript objects keep thinking blocks and large tool results as -compact metadata; inspect large payloads through returned `var_handle` values -and `handle_read`, not by asking the parent transcript to paste the raw text. - -Large RLM outputs should come back as `var_handle`s. Use `handle_read` for -bounded text slices, line ranges, counts, or JSONPath projections instead of -replaying the full value into the parent transcript. - -Inside `rlm_eval`, the loaded source is available as `_context`; `_ctx` and -`content` are also bound as compatibility aliases because agents naturally -reach for them during Python analysis. The shorter `context` and `ctx` names -are intentionally not bound so user variables can use them without colliding -with the bootstrap. - -Child-call timeouts are session policy: use `rlm_configure` with -`sub_query_timeout_secs` before running a large fan-out. The helpers -`sub_query`, `sub_query_batch`, `sub_query_map`, and `sub_rlm` accept a -`timeout_secs` keyword for compatibility with common agent guesses, but the -effective timeout remains configured at the RLM session level. - -`finalize(value, confidence=...)` preserves JSON-serializable values. Strings -become text handles; dicts, lists, numbers, booleans, and null become JSON -handles that `handle_read` can project with JSONPath. - -### Session relay - -`/relay [focus]` asks the current agent to write `.deepseek/handoff.md` as a -compact `# Session relay` artifact for the next thread. The filename remains -for compatibility with existing prompt loading and older sessions; the visible -mental model is relay / 接力. - -Aliases: `/batonpass`, `/接力`. - -Use it before a long break, compaction, or moving work to a fresh session. The -relay should preserve the goal, current Work checklist item, changed files, -decisions, verification state, and one concrete next action. -Treat it as the deliberate counterpart to automatic compaction: both exist to -preserve continuity for the next session or sub-agent, but `/relay` lets the -current agent inspect live evidence and choose the durable handoff facts -explicitly. When `update_plan` has a rich PlanArtifact, `/relay` includes that -strategy metadata so manual relay, fork-state, and compacted continuity do not -drift into separate stories. - -### Parallel fan-out: cost-class caps - -Two tools offer parallel fan-out with different concurrency limits that -reflect very different cost classes: - -| Tool | What each child does | Wall-clock | Token cost | Cap | -|---|---|---|---|---| -| `agent` | Full sub-agent loop (planning, tool calls, multi-turn streaming) | minutes | thousands of tokens | 20 running by default (`[subagents].max_concurrent`, hard ceiling 20), with up to 200 running + queued admitted by default | -| `rlm_eval` helper `sub_query_batch` | One-shot non-streaming Chat Completions calls pinned to `deepseek-v4-flash` inside a live RLM session | seconds | ~hundreds of tokens | 16 per call | - -The caps appear in each tool's description and error messages so the model -(and the user) can choose the right tool for the job. If one sub-agent is -enough but you need parallel semantic lookups over the same loaded context, -prefer `rlm_eval` with `sub_query_batch`; if each task needs its own -tool-carrying agent loop, use `agent` and inspect the returned transcript -handle when needed. - -## Removed legacy aliases and surfaces - -The old model-facing sub-agent fan-out surface is removed from active prompting -and tool catalogs. Do not use retired sub-agent lifecycle names in new active -guidance. - -The old one-shot `rlm` model-facing tool is also replaced by persistent -`rlm_open` / `rlm_eval` / `rlm_configure` / `rlm_close` sessions. - -v0.9.0 ships the following hidden-compat aliases (#2682, #2683, #4132) — -they are deliberately retained for transcript replay, not scheduled for -removal (the earlier plan to drop `todo_*` at the next major was -superseded by #4132): - -| Hidden alias | Canonical replacement | Status | -|---|---|---| -| `checklist_write` | `work_update` | Hidden, callable for replay (#4132) | -| `checklist_add` / `checklist_update` / `checklist_list` | `work_update` | Hidden, callable for replay | -| `todo_write` / `todo_add` / `todo_update` / `todo_list` | `work_update` | Hidden, callable for replay | -| `exec_wait` | `exec_shell_wait` | Hidden, callable for replay | -| `exec_interact` | `exec_shell_interact` | Hidden, callable for replay | +## Release verification -All hidden aliases remain registered and callable so saved transcripts can -replay without teaching new sessions the deprecated spelling. - -## Release smoke: verify the live names - -When validating a release, verify the model-visible registry names directly. -Do not grep random handler function names; handler names are allowed to drift -while the registry contract stays stable. - -Version smoke: - -```bash -codewhale --version -codewhale-tui --version -``` - -Tool-surface smoke: +Do not infer the public surface from handler function names. Verify the model +catalog and alias visibility at the exact candidate SHA: ```bash -rg -n '"handle_read"|"rlm_open"|"rlm_eval"|"rlm_configure"|"rlm_close"|"agent"' crates/tui/src -rg -n 'handle_read|rlm_open|rlm_eval|rlm_configure|rlm_close|agent' docs crates/tui/src/prompts crates/tui/src/tools +python3 scripts/measure-runtime-contract.py +cargo test -p codewhale-tui --bin codewhale-tui --locked canonical_runtime_tools_hide_legacy_aliases +cargo test -p codewhale-tui --bin codewhale-tui --locked shell_alias_tools_hidden_from_model_catalog +cargo test -p codewhale-tui --bin codewhale-tui --locked runtime_task_families_expose_canonical_tools_with_hidden_aliases ``` -The canonical live names: - -- `handle_read` -- `rlm_open`, `rlm_eval`, `rlm_configure`, `rlm_close` -- `agent` - -The registry should not actively advertise retired sub-agent lifecycle names or -the old foreground `rlm` tool outside historical changelog entries. - -## Additional registered tools (v0.8.49) - -The category tables above cover the most commonly used tools. The full -registry also includes these model-visible tools: - -| Tool | Niche | -|---|---| -| `web.run` | Browser-based web interaction (JavaScript-rendered pages, form filling) | -| `multi_tool_use.parallel` | Execute multiple independent tools in a single turn | -| `request_user_input` | Prompt the user for input mid-turn | -| `git_show` / `git_log` / `git_blame` | Inspect commit details, history, and line authorship | -| `load_skill` | Load a skill by id from the installed skill set | -| `revert_turn` | Roll back the workspace to a pre-turn snapshot | -| `pandoc_convert` | Convert between document formats via pandoc (gated by binary presence) | -| `validate_data` | Validate JSON or TOML against a schema | -| `code_execution` | Execute Python code in an isolated sandbox | -| `review` | Code review with structured feedback | -| `project_map` | Generate a structural map of the project workspace | -| `remember` | Store a persistent fact in user memory (gated by `memory_enabled`) | -| `image_analyze` | Vision-model image understanding (gated by `[vision_model]` config) | -| `image_ocr` | Extract text from images via local OCR | -| `finance` | Fetch market data and stock quotes | - -MCP tools, plugin-provided tools, and feature-gated tools may also be -visible depending on runtime configuration. Use `codewhale tools list` or -the TUI `/tools` palette to inspect the active catalog. - -## Why we don't ship a single `bash` tool - -Single-`bash` agents (Claude Code's design) are powerful but hand the model -all the foot-guns of shell scripting: quoting, platform divergence, -side-effects from misread cwd, `cd` not persisting between calls, etc. Our -file tools are also significantly cheaper to render in the transcript -(structured JSON-shaped output collapses better than `ls -la` walls of text). - -The model can always fall back to `exec_shell` when something is missing. -The dedicated tools just take the common 80% off the shell escape-hatch. +The provider-free full-policy receipt enables built-in memory and must report the +ten default-active names listed above. A memory-disabled receipt truthfully omits +`remember`. A separate repository-wide tool count may include deferred, dynamic, +feature-gated, and replay-only registrations; it is not the number of tools +placed in the first-turn model catalog. diff --git a/docs/architecture/command-dispatch.md b/docs/architecture/command-dispatch.md index c16ebc4e90..6b4952c568 100644 --- a/docs/architecture/command-dispatch.md +++ b/docs/architecture/command-dispatch.md @@ -22,8 +22,8 @@ intentional: |------|--------|----------| | 0 | `$skill` compatibility | `$name` is resolved as `/skill name` before slash parsing. | | 1 | User commands | `user_registry::try_dispatch()` checks workspace and global markdown commands first, so user commands can shadow built-ins. | -| 2 | Permanent compatibility aliases | `/jihua` and `/zidong` route through config mode dispatch; `/slop` and `/canzha` dispatch directly to `/debt`. All predate the group-owned registry and bypass the built-in `CommandRegistry`. | -| 3 | Built-in registry | `CommandRegistry` resolves group-owned built-in commands by canonical name or alias. | +| 2 | Permanent mode compatibility aliases | `/jihua` and `/zidong` route through config mode dispatch so each selects its fixed legacy mode. They remain registered aliases for discovery, but bypass normal `/mode` execution. | +| 3 | Built-in registry | `CommandRegistry` resolves group-owned built-in commands by canonical name or alias, including `/slop` and `/canzha` as aliases of `/debt`. | | 4 | Legacy migration hints | Retired commands such as `/set` and `/deepseek` return targeted replacement guidance. | | 5 | Skills fallback | If no command matches, a skill with the same name may run before unknown-command suggestions are shown. | @@ -50,7 +50,7 @@ intentional: | `plugins` | Read-only bundle discovery/validation plus explicit trust, enable, disable, revoke, and reload lifecycle commands; legacy executable tools remain separate. | | `project` | Project initialization, sharing, LSP, and goal/hunt commands. | | `session` | Rename, save, fork/new/load sessions, compaction, purge, relay, and export. | -| `skills` | Skill listing, execution, review, and restore. | +| `skills` | Skills Manager (`/skills`), text inspect/remote/sync paths, activation (`/skill`), and managed install/update/uninstall/trust. | | `utility` | Attachments, tasks/jobs, MCP, and network. | ## User Commands @@ -69,13 +69,53 @@ Supported frontmatter fields: | Field | Meaning | |-------|---------| +| `name` | Canonical slash-command name. It is normalized without a leading slash and replaces the filename-derived default. | | `description` | Work objective and UI description. | -| `argument-hint` | Palette/completion hint for expected arguments. | +| `usage` | Preferred user-facing invocation syntax shown in the palette and slash completion. | +| `arguments` | Argument synopsis and a signal that selection should leave the composer open for input. It does not impose runtime validation. | +| `argument-hint` | Backward-compatible palette/completion hint for expected arguments. It remains the display fallback when `usage` is absent. | | `allowed-tools` | Restricts command execution tools. An explicit empty value blocks all tools. | | `pausable` | Marks the command as pause/resume capable. | | `alias` / `aliases` | Additional user-command names that can shadow built-in aliases. | | `hidden` | Hides the command from palette/completion while allowing direct dispatch. | +The canonical name defaults to the normalized markdown filename. A valid +frontmatter `name` replaces that default; the filename is not retained as an +implicit alias, so a renamed command must list the old filename under `alias` +or `aliases` if both spellings should dispatch. A configured name may include +one leading slash for readability, but after normalization it must be one +non-empty slash-command token with no whitespace or embedded `/`. An invalid +configured name is a recoverable error attached to the filename-derived +command, preventing silent fallthrough to a built-in. + +Source precedence is resolved before frontmatter naming: a higher-precedence +directory wins when the same filename exists in more than one location. Files +inside each directory are ordered by normalized filename. If distinct files +then resolve to the same frontmatter `name`, the first file in that stable +directory-and-filename order wins and the losing file records a recoverable +load error. Aliases cannot replace any canonical user-command name; duplicate +aliases also use first-wins order. Errors on a losing duplicate never poison a +valid winning definition. + +Presentation metadata has an explicit fallback order: non-empty `usage`, then +non-empty legacy `argument-hint`, then non-empty `arguments`. `arguments` and +`argument-hint` also cause palette/menu selection to append a space; `usage` +does so when it describes more than the bare command name. These fields do not +parse, require, or reject invocation arguments. Runtime expansion remains +backwards-compatible: `$ARGUMENTS` receives the complete argument tail and +`$1`, `$2`, and so on receive whitespace-separated positional values. + +Malformed files remain registered under their resolved name with a +dispatch-time error, so they cannot silently fall through to a built-in. Their +errors are isolated per file: valid siblings still load, appear, and dispatch. +Hidden commands participate in shadowing and remain directly dispatchable, but +are removed from palette and slash-completion discovery. + +Explicit `/help ` topics and unknown-command typo suggestions resolve +through the same user-command precedence as execution. When a user command +owns a built-in name or alias, help shows the user command's metadata and typo +suggestions point to its canonical name rather than the shadowed built-in. + Dispatch through `user_registry` resets stale command state before sending the new command body: hunt objective fields, token/time counters, continuation count, allowed tools, pause state, todos, and plan state. @@ -84,7 +124,7 @@ count, allowed tools, pause state, todos, and plan state. | Exception | Rationale | |-----------|-----------| -| `/jihua`, `/zidong`, `/slop`, `/canzha` | Backward-compatible dispatch aliases that predate the group-owned registry. `/jihua` and `/zidong` route through config mode dispatch; `/slop` and `/canzha` dispatch directly to `/debt`. | +| `/jihua`, `/zidong` | Backward-compatible mode aliases that predate the group-owned registry. They route through config mode dispatch to preserve their fixed mode selection. | | `/set` and `/deepseek` migration hints | Retired commands kept only as direct typed guidance. They are excluded from registry and autocomplete. | | `#[allow(clippy::module_inception)]` in matching group modules | Group directories intentionally contain same-named child modules such as `core/core.rs`. | | `user_commands.rs` lower layer | The registry owns runtime behavior, while this module remains the shared filesystem and parser layer. | diff --git a/docs/architecture/delegated-coordination.md b/docs/architecture/delegated-coordination.md new file mode 100644 index 0000000000..a3c88eb355 --- /dev/null +++ b/docs/architecture/delegated-coordination.md @@ -0,0 +1,79 @@ +# Delegated coordination contract + +Codewhale records the small amount of shared state that parallel work needs to +remain attributable. This is coordination metadata, not an approval system and +not a store for model reasoning or transcripts. + +## Launch and write ownership + +Every write-capable child persists the same `ChildLaunchManifest` used by the +runtime. Its mutation claim contains normalized repo-relative directory roots, +exact files, and named contracts. Paths that are absolute or escape with `..` +fail validation. + +A prompt-only general child starts read-only. Callers that want a writer must +declare at least one `write_roots`, `exact_files`, or +`coordination_contracts` value. Codewhale does not infer a repo-wide `.` claim. +An active shared-workspace claim blocks another active owner when either tree +contains the other, exact files collide, or a named contract matches. A real +isolated worktree may proceed concurrently. Scope expansion uses +`agents/coordinate action=claim`; a collision records a bounded contention +receipt and fails before mutation without opening a permission modal. + +Fleet workers follow the same rule. Write-capable Fleet tasks declare +`workspace.writable_paths` or `metadata.coordination_contracts`, and the +resolved values are persisted in their launch manifest. + +This record is a cooperative Codewhale coordination boundary, not an operating +system sandbox. Fleet carries a machine-readable outer cap into each worker, +rechecks structured mutation targets, rejects symlink aliases, and denies +unbounded shell, Git, code, plugin, and mutating MCP execution. Those checks +prevent one Codewhale worker from silently exceeding its declared claim; they +do not promise containment against a separate hostile process racing filesystem +paths. Use an OS sandbox or an isolated host when that adversarial boundary is +required. + +Authority-bound Fleet subprocesses are explicit leaves in v0.9.1. Their MCP, +LSP, snapshot, custom-tool, plugin, shell, and nested-agent startup surfaces are +disabled so configured background executables cannot bypass the structured +mutation path. The persisted receipt reports `max_spawn_depth = 0`. + +## Decisions and projected context + +Coordination schema version 1 persists decision records with a stable id, +subject, proposed/accepted/superseded status, one owner, applicability scope, +concise constraints, evidence handles, version, and sequence. Only the owner +may change a decision's status. A second accepted decision for the same subject +cannot silently replace the first. + +At child launch, Codewhale projects only accepted decisions whose scope matches +the child's declared paths, contracts, role, or tool capabilities. The +projection is deduplicated, limited to eight decisions and 4096 UTF-8 bytes, +and receipted by child id and decision ids. The task prompt may separately +carry at most eight explicit dependency facts and eight observable acceptance +checks. Parent transcripts, secrets, and raw reasoning are never projected. + +## Neutral fan-in + +Conflicting candidates remain preserved as branch, patch, or artifact handles. +The neutral owner is the nearest common Planner/manager/operator in the +persisted parent tree, falling back to the root release owner. Neither candidate +author may claim that role. Reconciliation records: + +- both or all input decision ids and candidate handles; +- a retry count and a limit of at most three; +- distinct independent Reviewer and Verifier evidence handles; +- a verified, failed, or blocked verification outcome; and +- the neutral disposition and bounded evidence handles. + +Retry exhaustion is a terminal, inspectable receipt, not permission to discard +either candidate. Restart/replay preserves the schema, decisions, claims, +contention, projections, and reconciliation sequence. + +## Inspection + +`agents/list` exposes concise per-child claims and accepted decisions. +`agents/coordinate action=inspect` exposes bounded decision, claim, contention, +projection, and reconciliation receipts plus deterministic hottest-path counts. +Metrics without an authoritative source, such as package growth or route cost, +remain explicitly null instead of being inferred. diff --git a/docs/architecture/pr-issue-evidence-prep.md b/docs/architecture/pr-issue-evidence-prep.md index ab65e406e7..b52df538e3 100644 --- a/docs/architecture/pr-issue-evidence-prep.md +++ b/docs/architecture/pr-issue-evidence-prep.md @@ -41,7 +41,6 @@ tree by running the documented commands. | Config group-local metadata | Config `mod.rs` keeps 11 `CommandInfo` statics and dispatch — permanent structure, not cleanup scope | | Debug group-local metadata | Debug `mod.rs` keeps 11 `CommandInfo` statics and dispatch — permanent structure, not cleanup scope | | `/jihua`, `/zidong` | Chinese-language back-compat aliases for `/mode` — predate group-owned registry | -| `/slop`, `/canzha` | Typed-only aliases for `/debt` — predate group-owned registry | | `/set`, `/deepseek` migration hints | Retired commands, direct typed guidance only, excluded from registry/completion | | `$skill` prefix | Non-slash compatibility syntax, predates EPIC-002 | | Skill-name fallback | Slash commands fall back to skill dispatch after built-ins and user commands | diff --git a/docs/evidence/layer-5-1-user-command-registry-evidence.md b/docs/evidence/layer-5-1-user-command-registry-evidence.md new file mode 100644 index 0000000000..be878e4a04 --- /dev/null +++ b/docs/evidence/layer-5-1-user-command-registry-evidence.md @@ -0,0 +1,80 @@ +# Layer 5.1: User Command Registry And Loading Boundary — Evidence Summary + +**EPIC-002**: Staged command-boundary refactor (Hmbown/CodeWhale#2870) + +**FEAT-010**: Layer 5.1 — User Command Registry And Loading Boundary + +**Status**: ✅ Verified on production/test commit + +**Date**: 2026-07-21 + +## Provenance And Correction + +Paulo Aboim Pinto's exact evidence commit from PR #4046, +`75e08e5c67f555b5cda8511742c824bb74bb6c1d`, remains in this integration's +ancestry. That document correctly identified the dedicated registry boundary, +hidden-command behavior, and runtime `allowed-tools` propagation, but its claim +that the written Layer 5.1 contract was already complete was too broad. + +Paulo's follow-up audit on #2870 identified the missing `name`, `usage`, and +`arguments` frontmatter fields and the missing explicit proof that a malformed +file does not block a valid sibling. The production-and-test follow-up +`2717843394050ff1b4f651f931e3894432ace8f8` implements and verifies that written +contract. This revision corrects the claim while preserving the original commit +and authorship as the provenance record. + +## Acceptance Criteria Verification + +| Criterion | Status | Evidence | +|-----------|--------|----------| +| User commands load through a dedicated boundary separate from built-ins | Implemented | `commands::execute()` calls `user_registry::try_dispatch()` before the built-in registry; `UserCommandRegistry` owns user metadata and errors. | +| Parse `name`, `description`, `usage`, `arguments`, `hidden`, and `allowed-tools` | Implemented | `registry_loads_markdown_metadata`, `frontmatter_name_replaces_filename_canonical_name`, and `parser_preserves_layer_5_1_frontmatter_fields`. | +| Invalid frontmatter is recoverable per file and does not block valid commands | Implemented | `malformed_file_is_recoverable_and_valid_sibling_still_dispatches`; malformed losing duplicates are isolated by `malformed_losing_name_override_does_not_poison_valid_winner`. | +| Hidden commands dispatch directly but are filtered from palette and slash completion | Implemented | `hidden_user_commands_still_dispatch_directly`, `hidden_frontmatter_name_override_suppresses_shadowed_builtin`, and `hidden_name_override_filters_shadowed_builtin_from_slash_completion`. | +| `allowed-tools` reaches runtime dispatch, including explicit-empty deny-all | Implemented | `dispatch_uses_frontmatter_name_arguments_and_allowed_tools` and `empty_allowed_tools_frontmatter_blocks_all_tools`. | +| Reload reflects command-file changes | Implemented | `registry_reloads_when_existing_command_file_changes`. | +| Name, alias, palette, completion, and dispatch override behavior is deterministic | Implemented | Frontmatter-name, directory-precedence, canonical/alias collision, palette, completion, and dispatch tests in `user_registry.rs`, `command_palette.rs`, `widgets/mod.rs`, and `ui/tests.rs`. | + +## Final Contract + +- The normalized filename is the default canonical command name. +- A valid frontmatter `name` replaces the filename default. The filename is not + an implicit alias; retain it explicitly with `alias` or `aliases` when needed. +- Directory precedence is resolved first. Within a directory, normalized + filenames are ordered. Distinct files that resolve to the same canonical + name use first-wins order and record a recoverable error for each loser. +- An alias cannot replace any canonical user-command name. Duplicate aliases + also use first-wins order. +- A malformed winning command reports its error on direct dispatch and never + falls through to a built-in. A malformed losing duplicate cannot poison a + valid winner, and malformed siblings cannot block valid commands. +- Palette and completion presentation use non-empty `usage`, then legacy + `argument-hint`, then `arguments`. These are presentation/input hints, not + runtime validation. +- Existing template behavior is unchanged: `$ARGUMENTS` receives the complete + argument tail and `$1`, `$2`, and later positionals use whitespace splitting. +- Hidden commands still shadow built-ins and dispatch directly, but do not + appear in palette or slash completion. +- `allowed-tools` continues to normalize tool names into runtime state; an + explicitly empty value remains `Some(Vec::new())`, blocking every tool. + +The public version of this contract is recorded in +`docs/architecture/command-dispatch.md`. + +## Verification Receipts + +These commands ran serially against the shared target with incremental +compilation disabled and one build job. Every focused test command emitted the +same macOS linker warning that `__eh_frame` exceeded the compact-unwind table's +16 MB encoding limit; the warning did not fail linking or any test. + +| Command | Result | +|---------|--------| +| `CARGO_TARGET_DIR=/Volumes/VIXinSSD/CW/codewhale/target CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS=1 cargo test -p codewhale-tui --bin codewhale-tui --locked user_registry --jobs 1 -- --test-threads=1` | ✅ PASS — 25 passed, 0 failed, 7812 filtered out | +| `CARGO_TARGET_DIR=/Volumes/VIXinSSD/CW/codewhale/target CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS=1 cargo test -p codewhale-tui --bin codewhale-tui --locked user_commands --jobs 1 -- --test-threads=1` | ✅ PASS — 25 passed, 0 failed, 7812 filtered out | +| `CARGO_TARGET_DIR=/Volumes/VIXinSSD/CW/codewhale/target CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS=1 cargo test -p codewhale-tui --bin codewhale-tui --locked command_palette --jobs 1 -- --test-threads=1` | ✅ PASS — 27 passed, 0 failed, 7810 filtered out | +| `CARGO_TARGET_DIR=/Volumes/VIXinSSD/CW/codewhale/target CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS=1 cargo test -p codewhale-tui --bin codewhale-tui --locked slash_completion --jobs 1 -- --test-threads=1` | ✅ PASS — 22 passed, 0 failed, 7815 filtered out | +| `CARGO_TARGET_DIR=/Volumes/VIXinSSD/CW/codewhale/target CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS=1 cargo test -p codewhale-tui --bin codewhale-tui --locked apply_slash_menu_selection --jobs 1 -- --test-threads=1` | ✅ PASS — 5 passed, 0 failed, 7832 filtered out | +| `CARGO_TARGET_DIR=/Volumes/VIXinSSD/CW/codewhale/target CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS=1 cargo check -p codewhale-tui --bin codewhale-tui --locked --jobs 1` | ✅ PASS | +| `CARGO_TARGET_DIR=/Volumes/VIXinSSD/CW/codewhale/target CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS=1 cargo fmt --all -- --check` | ✅ PASS | +| `git diff --check` | ✅ PASS | diff --git a/docs/evidence/v091-empty-work-surface-receipt.md b/docs/evidence/v091-empty-work-surface-receipt.md new file mode 100644 index 0000000000..ca7a729fbb --- /dev/null +++ b/docs/evidence/v091-empty-work-surface-receipt.md @@ -0,0 +1,64 @@ +# v0.9.1 empty Work-surface receipt + +This receipt verifies the empty-surface behavior from the real TUI binary. It +is not based on a product mockup. + +## Source + +- Version: `codewhale-tui 0.9.1 (4d197626d72b)` +- Commit: `4d197626d72b4bd27e1abf4eed92e86e914414a8` +- Checkout at capture: clean exact-source worktree for + `codex/v091-final-integration-20260721` +- Terminal: `120x32` +- Route: isolated local Ollama configuration using `qwen3-coder`; no API key +- Workspace: clean public fixture shown as `~/codewhale-demo` +- Theme: Blue Stage dark + +## Capture method + +The binary was built with: + +```bash +cargo build --release --locked -p codewhale-tui --bin codewhale-tui +``` + +It was launched in a 120-column by 32-row tmux PTY with isolated state. VHS +0.10.0 rasterized the real terminal cells at 1280x720; it did not generate or +reconstruct product UI: + +```bash +NO_ANIMATIONS=1 \ +CODEWHALE_HOME=/path/to/sealed-home/.codewhale \ +CODEWHALE_CONFIG_PATH=/path/to/sealed-home/.codewhale/config.toml \ +CODEWHALE_MCP_CONFIG=/path/to/sealed-home/.codewhale/mcp.json \ +target/release/codewhale-tui \ + --skip-onboarding \ + --fresh \ + --no-project-config \ + --no-mouse-capture \ + --workspace ~/codewhale-demo +``` + +The resulting 1280x720 capture has SHA-256 +`8ffd0c36699930a9af7bcca3e93d3f9bc8a11df5a691e88335fc8b1f0442a754`. +It contains no username, credential, account identifier, private repository +path, error state, or unsupported product claim. The idle capture process had +no open TCP or UDP socket. `NO_ANIMATIONS=1` makes this one canonical still +stable; the separate real-PTY suite proves full, reduced, and still motion. +The captured header visibly identifies `v0.9.1 (4d197626d72b)`, and the context +line is `~/codewhale-demo · main · mcp 0`. + +## Acceptance + +The fresh active session contains the header, idle canvas, composer, and +footer. It does not contain `Work · empty`, a Work heading, or reserved Work +rows. Active, error, and disconnected Work projections remain covered by the +TUI unit suite. + +```text +cargo test -p codewhale-tui --bins --all-features --locked +test result: ok. 8063 passed; 0 failed; 4 ignored + +cargo test -p codewhale-tui --test qa_pty --locked +test result: ok. 25 passed; 0 failed; 1 ignored +``` diff --git a/docs/evidence/v091-home-desktop.png b/docs/evidence/v091-home-desktop.png new file mode 100644 index 0000000000..b4d64dc166 Binary files /dev/null and b/docs/evidence/v091-home-desktop.png differ diff --git a/docs/evidence/v091-home-mobile.png b/docs/evidence/v091-home-mobile.png new file mode 100644 index 0000000000..79340a8d97 Binary files /dev/null and b/docs/evidence/v091-home-mobile.png differ diff --git a/docs/evidence/v091-home-zh-desktop.png b/docs/evidence/v091-home-zh-desktop.png new file mode 100644 index 0000000000..cf32d6b4af Binary files /dev/null and b/docs/evidence/v091-home-zh-desktop.png differ diff --git a/docs/evidence/v091-home-zh-mobile.png b/docs/evidence/v091-home-zh-mobile.png new file mode 100644 index 0000000000..8979de0036 Binary files /dev/null and b/docs/evidence/v091-home-zh-mobile.png differ diff --git a/docs/evidence/v091-public-surface-readiness.md b/docs/evidence/v091-public-surface-readiness.md new file mode 100644 index 0000000000..0b6ea07332 --- /dev/null +++ b/docs/evidence/v091-public-surface-readiness.md @@ -0,0 +1,104 @@ +# v0.9.1 public-surface readiness receipt + +This packet finishes the v0.9.1 public-surface pass for one exact frozen +runtime without claiming that the candidate has been published or deployed. + +## Repository state + +- Protected base at source freeze: `a7c00a1a8e48021daf2a9c78cfc1dbda8269e074` + (`origin/main`, refreshed 2026-07-22) +- Runtime source: `4d197626d72b4bd27e1abf4eed92e86e914414a8` +- Product truth: `508726960` +- Product-first homepage: `e37df06ca` +- Credential-free deploy preflight: `74862148a` +- No Cloudflare deployment, tag, GitHub Release, package publication, or + artifact publication occurred while producing this packet. + +Open PRs were refreshed immediately before sign-off. Draft #4675's remote head +`e2208815e51cdc42830cec8c78d4db1fff00d490`, #4679's head +`7684cbec32a355f72f0a5bd7ef996bdeddf798e0`, and #4680's head +`ccf4c218f619dd9772dd1079f49607faa3504a8e` are unchanged ancestors of the +frozen source, alongside merged PRs #4673 and #4678. No contributor history +was flattened or rewritten. Draft #4508 supplied recovered screenshot intent; +it was not merged wholesale. + +The credited fixes for #4681, #4682, the verified strict-DeepSeek boundary in +#4683, and Wenhao Hu's Full Access/global-skill boundary report in #4684 are +also ancestors of the frozen source. The broader intermittent network symptom +reported in #4683 was not reproduced and is not claimed fixed. + +## Scope decisions + +- Keep the existing Blue Stage visual direction instead of introducing a new + design system. +- Describe a bounded path from task to verified change, not a perpetual loop. +- Use the existing whale component with a small CSS sun in the community + section; no generated illustration is shipped. +- Use the real TUI PTY capture from commit + `4d197626d72b4bd27e1abf4eed92e86e914414a8` as the homepage and README image. + The two public copies share SHA-256 + `8ffd0c36699930a9af7bcca3e93d3f9bc8a11df5a691e88335fc8b1f0442a754`. + +## Visual QA + +The production build was inspected in the Codex in-app browser in English and +Chinese at 1280x720 and 390x844, plus the 1012px README content width. Both +mobile pages reported a 390px layout viewport and 390px document width, with +no horizontal overflow; the 1012px review likewise reported a 1012px document +width. The authored locale switch reached `/zh` with `lang=zh`, the mobile menu +opened as a dialog, locked body scrolling, moved focus to its Close control, +and restored the underlying page when closed. The install copy control changed +to `Copied ✓` and rendered its two-pixel focus-visible outline. + +The browser context reported `prefers-reduced-motion: false`; it did not expose +a media-emulation capability, so this packet does not mislabel that default +context as a native reduced-motion capture. No running CSS animation was +present in any accepted homepage frame. The source contract separately checks +that the only web animation classes are disabled in the reduced-motion media +query and that the terminal trace stays fully rendered instead of rewinding +when that query matches. The locked real-PTY matrix remains the native proof +for Codewhale's Full, Reduced, and Still motion states. The Open Graph route +returned HTTP 200 as a 1200x630 `image/png` with SHA-256 +`a2a03a2fbe32b0e307f159e54d4c94d8b7b83e4cfef1d669b218421e8f8acb11`. + +Artifacts: + +- `docs/evidence/v091-home-desktop.png` +- `docs/evidence/v091-home-mobile.png` +- `docs/evidence/v091-home-zh-desktop.png` +- `docs/evidence/v091-home-zh-mobile.png` + +The four accepted captures have SHA-256 values, in the order above, +`1e6ca263dcae66be0851760ee06675ac825c013c7e93a57f90a7f67abcb63122`, +`af46a2274810d6a6a0bd6c1e2ed74e335678313f1119f513873680764de4cc26`, +`4ae745fa9b93195e7d43a9be36300cb9ec22f5ff6194e05a383732ffce7c8a20`, +and `a00d0e350cf2e2ba7f1eb34f3ba5b94eead20ad09b83b8afddce9e4d8cff5dc5`. + +## Verification + +```text +npm run lint +npm test -- --run + 17 files passed; 119 tests passed +npm run check:facts +npm run check:docs +npm run check:deploy-env -- --preflight +CODEWHALE_SOURCE_REVISION=4d197626d72b4bd27e1abf4eed92e86e914414a8 \ + npm run compare:deployed-facts +npx tsc --noEmit +CODEWHALE_SOURCE_REVISION=4d197626d72b4bd27e1abf4eed92e86e914414a8 \ + npm run build +CODEWHALE_SOURCE_REVISION=4d197626d72b4bd27e1abf4eed92e86e914414a8 \ + npx opennextjs-cloudflare build +bash scripts/release/check-versions.sh +git diff --check +``` + +The credential-free deployed-facts report identified the current live gap as +`unavailable` (`/api/facts` returned HTTP 404), while recording the exact +expected source revision, `0.9.1`, 35 providers, 66 tools, and published +release `v0.9.0`; its receipt says `deploymentAttempted: false`. The OpenNext +Cloudflare worker bundle completed successfully. Production deployment was +intentionally not attempted: the local environment does not contain the +protected Cloudflare account ID or API token, and this task does not authorize +a push or deploy. diff --git a/docs/examples/dogfood-automatic/wf_a2_staged_bugfix.workflow.js b/docs/examples/dogfood-automatic/wf_a2_staged_bugfix.workflow.js index 36087390dc..8f8ed69f5e 100644 --- a/docs/examples/dogfood-automatic/wf_a2_staged_bugfix.workflow.js +++ b/docs/examples/dogfood-automatic/wf_a2_staged_bugfix.workflow.js @@ -21,6 +21,14 @@ export default async function (args) { type: "implementer", // Prefer worktree isolation for write children (product default #4120). worktree: true, + writeAuthority: "worktree_write", + exactFiles: [target], + coordinationContracts: ["wf-a2-doc-fix"], + dependencies: ["The parent workspace must remain unchanged."], + acceptance: [ + `Only ${target} changes in the isolated worktree.`, + "The handoff reports the exact path and diff summary.", + ], prompt: [ `Edit only ${target}.`, change, diff --git a/docs/public-surface-facts.json b/docs/public-surface-facts.json new file mode 100644 index 0000000000..9890b05753 --- /dev/null +++ b/docs/public-surface-facts.json @@ -0,0 +1,148 @@ +{ + "schemaVersion": 2, + "product": { + "name": "Codewhale", + "description": "Terminal coding agent for supported hosted and local models. One runtime on your machine. Rust, MIT.", + "positioning": "Terminal-native, model/provider-neutral, local-first coding agent", + "license": "MIT", + "terminology": { + "Fleet": "who does the work", + "Workflow": "what order the work follows", + "Lane": "one running Workflow instance", + "Runtime": "where and how a Lane executes" + }, + "sources": ["Cargo.toml", "LICENSE", "README.md", "npm/codewhale/package.json", "docs/FLEET.md"] + }, + "sourceCandidate": { + "version": "0.9.1", + "providerCount": 35, + "toolCount": 66, + "sandboxBackends": [ + "seatbelt (macOS, when available)", + "bubblewrap (Linux, opt-in when installed)" + ], + "sources": [ + "Cargo.toml", + "crates/tui/src/config.rs", + "crates/tui/src/sandbox/mod.rs", + "web/scripts/derive-facts.mjs" + ] + }, + "latestPublishedRelease": { + "tag": "v0.9.0", + "version": "0.9.0", + "publishedAt": "2026-07-16T20:05:39Z", + "url": "https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.0", + "sources": ["web/data/latest-published-release.json"] + }, + "install": { + "recommended": "npm install -g codewhale", + "binaries": ["codewhale", "codew", "codewhale-tui"], + "channels": { + "npm": "published releases only", + "cargo": "published crates only", + "prebuiltArchives": "published GitHub Releases only", + "cnbMirror": "documented targets only" + }, + "androidTermux": { + "status": "preview", + "npm": "preview", + "requiresMatchingPublishedAssets": true, + "sourceBuild": true + }, + "sources": ["README.md", "npm/codewhale/package.json", "npm/codewhale/README.md", "npm/codewhale/scripts/artifacts.js", "docs/INSTALL.md", "docs/CNB_MIRROR.md"] + }, + "control": { + "modes": ["Plan", "Act", "Operate"], + "permissionPostures": ["Ask", "Auto-Review", "Full Access"], + "shortcuts": { + "mode": { "chord": "Tab", "when": "composer idle" }, + "permissionPosture": { "chord": "Shift+Tab", "when": "composer idle" } + }, + "sources": ["docs/MODES.md", "docs/KEYBINDINGS.md", "crates/tui/src/settings.rs"] + }, + "toolSurface": { + "defaultActive": ["Bash", "File", "Git", "Run", "agent", "remember", "tasks", "update_plan", "work_update", "tool_search"], + "actions": { + "Bash": ["run", "wait", "interact", "cancel"], + "File": ["read", "list", "search_name", "search_content", "write", "edit", "patch"], + "Git": ["status", "diff", "log", "show", "blame"], + "Run": ["tests", "verifiers"] + }, + "deferred": { + "Web": ["search", "fetch", "wait"] + }, + "compatibility": { + "legacyAliases": "replay-only", + "modelVisible": false, + "toolSearchDiscoverable": false + }, + "agentConcurrency": { + "defaultConfigured": 64, + "maximumConfigured": 128, + "maximumAdmitted": 1024 + }, + "sources": [ + "docs/TOOL_SURFACE.md", + "docs/RUNTIME_SIMPLIFICATION_DESIGN.md", + "crates/tui/src/core/engine/tool_catalog.rs", + "crates/tui/src/tools/registry.rs", + "crates/tui/src/config/subagent_limits.rs" + ] + }, + "surfaces": { + "availableInSourceCandidate": ["TUI", "codewhale exec", "Web client", "Runtime API", "MCP", "Fleet"], + "sources": [ + "docs/GUIDE.md", + "docs/WEB.md", + "docs/RUNTIME_API.md", + "docs/MCP.md", + "docs/FLEET.md" + ] + }, + "trust": { + "localRuntime": "runtime, workspace state, and audit log stay on the user's machine", + "hostedProviderBoundary": "the selected hosted provider receives turn context required for inference", + "localInference": "a loopback local-model route can keep inference local", + "telemetry": "no Codewhale product telemetry or mandatory hosted relay", + "account": "no account required for the local runtime", + "plan": "Plan is read-only", + "sandbox": "Seatbelt is used on macOS when available; Linux bubblewrap is opt-in and must be installed; Windows currently reports no OS sandbox", + "audit": "sensitive events append best-effort to $CODEWHALE_HOME/audit.log (default ~/.codewhale/audit.log); write failures are logged", + "usage": "provider token and cache usage is shown locally when available", + "sources": [ + "docs/PROVIDERS.md", + "docs/MODES.md", + "docs/SANDBOX.md", + "docs/RUNTIME_API.md", + "docs/WEB.md", + "crates/tui/src/audit.rs", + "web/app/[locale]/faq/page.tsx", + "web/app/[locale]/install/page.tsx" + ] + }, + "platforms": { + "prebuilt": ["Linux x64", "Linux arm64", "macOS x64", "macOS arm64", "Windows x64", "Windows arm64"], + "preview": ["Android/Termux arm64"], + "sources": ["docs/INSTALL.md"] + }, + "repository": { + "canonical": "https://github.com/Hmbown/CodeWhale", + "mirrors": [ + "https://cnb.cool/codewhale.net/codewhale", + "https://npmmirror.com/package/codewhale" + ], + "creditSources": ["CHANGELOG.md", "docs/CONTRIBUTORS.md", "web/lib/release-credits.ts"], + "requiredCandidateCredits": ["@fleitz"] + }, + "screenshot": { + "readme": "assets/screenshot.png", + "website": "web/public/codewhale-tui.png", + "sourceVersion": "0.9.1", + "sourceCommit": "4d197626d72b4bd27e1abf4eed92e86e914414a8", + "terminal": "120x32", + "theme": "Blue Stage dark", + "capture": "sealed local Ollama route rendered from the real v0.9.1 TUI in a 120x32 tmux PTY and rasterized with VHS", + "sources": ["crates/tui", "docs/evidence/v091-empty-work-surface-receipt.md"] + } +} diff --git a/docs/releases/v0.9.1-completion-ledger.md b/docs/releases/v0.9.1-completion-ledger.md new file mode 100644 index 0000000000..9619b6bbb3 --- /dev/null +++ b/docs/releases/v0.9.1-completion-ledger.md @@ -0,0 +1,179 @@ +# Codewhale v0.9.1 completion ledger + +> **Pre-publication packet.** This ledger authorizes no tag, GitHub Release, +> package publication, deployment, push, live-provider request, or customer +> communication. It binds the frozen runtime source and the additive evidence +> packet used to finish the local candidate. + +## Candidate identity + +| Boundary | Exact state | +| --- | --- | +| Runtime source (`S`) | `4d197626d72b4bd27e1abf4eed92e86e914414a8` | +| Source branch | `codex/v091-final-integration-20260721` | +| Protected base at freeze | `origin/main` = `a7c00a1a8e48021daf2a9c78cfc1dbda8269e074` | +| Draft integration PR | #4675; its current remote head is an ancestor of `S`, so the approved update is a normal fast-forward | +| Evidence packet (`E`) | Additive screenshot, browser-QA, and receipt commits on top of `S`; the final landing receipt records their exact merged SHA | +| Public release state | No GitHub Release for v0.9.1; the pre-existing lightweight `v0.9.1` tag points to `d9fdee8aec469915cfdc07ab40aba5c40e9e9de4`, not this candidate | + +`S` is the exact source used for runtime, real-PTY, and canonical product-image +acceptance. It supersedes the earlier `fa46105a7183ce961f503996a7e746f763ffb29c` +freeze after #4684 exposed a real Full Access/global-skill boundary defect. +`E` changes only tracked evidence, generated public facts, and release records. +A tracked image cannot contain the hash of the commit that adds that image, so +the image and its receipt identify `S`; the final #4650 landing receipt +separately identifies `E` and the exact merged-main SHA used to rebuild and +install all three binaries. + +## Completion crosswalk + +| Contract | Frozen-source evidence | +| --- | --- | +| Canonical runtime and empty Work | PR #4675 ancestry; canonical action-tool and hidden replay-alias gates; fresh sessions render no empty Work heading or reserved rows | +| Transcript, motion, and control grammar | `79d5d5397aba83a901ae47e672d8fcd269cb04ae`, `2b2cfe65afd6879da3dd0782d98c5d4d8c04b34e`, `637debe69324fe9989e41cecfe8304d8dbc4d8c5`, `09d171d15331b1bbe1aa71003c4608aa6489bb9f`, PTY stabilizers `166679ed2546b84635dbf0895881286625da27b2`, `88eeed286162dbe8651bb0afd68e8cd2fb7b8e4d`, `57fbf264a4d1bbe3a6ee12cac7c9ae15189c7d1d`, and the exact source freeze `4d197626d72b4bd27e1abf4eed92e86e914414a8`; real PTYs cover 40x12 through 140x40, exact 100x30 transcript lifecycle, the icy Plan → blue Act → violet Operate and amber Ask → gold Auto-Review → coral Full Access ramps, dark/light, Full/Reduced/Still motion, and ASCII safety (#4676, #4677) | +| Structured Agent activity/details | `307407c968f48bedd1488df8d0305e0c086f1809` and `16eeda430ca6cbeb9b03e8e5235278025f6aed75`; Paulo Aboim Pinto's direction remains credited from #2694/#2889, renderer substring inference is gone, and focused final-source receipts cover compact rows, fallback, redaction, hover, and the Agent Details route | +| Adaptive evidence | Merge `00c9156367c2934cbc3de33b5ee7ed3d2f09da2e`; session-owned exact bytes, bounded receipts, headless and real-PTY acceptance (#4619) | +| One Work projection and File receipts | Merge `f100e70c9da8436eaa7f6304af62a60f4aa3ffa0`; full/summary/off success-only diffs and real-PTY coverage (#4636) | +| Durable coordination | Merge `c39ec9a418f3219d87510d2297b68cbb860f6c02` plus surfaced/localized receipt follow-ups; replay-safe decisions, claims, contention, projection, and neutral fan-in (#4647) | +| Contributor intake | Fred's #4673 is in protected main; all six exact Sam #4679 commits and all three exact Nightt #4680 commits are ancestors; Paulo's exact `75e08e5c67f555b5cda8511742c824bb74bb6c1d` is preserved; TelecomJS and OpenCode Go harvests retain machine-readable human co-author trailers; credited issue follow-ups `97f1c88271b0c32e0031914ae7f5d976aaf1467b`, `b60b3fecdb3535a13180b74955f55b19cb5c9ab7`, `fa46105a7183ce961f503996a7e746f763ffb29c`, and Wenhao Hu's #4684 fix `4d197626d72b4bd27e1abf4eed92e86e914414a8` are included without overstating #4683's broader intermittent symptom | +| Full Access and global skills | `4d197626d72b4bd27e1abf4eed92e86e914414a8`; saved and cycled Full Access now activates workspace trust as documented, global skills load through the bounded `load_skill` host path, and `danger-full-access` remains an OS-sandbox setting rather than an implicit unbounded file-authority switch (#4684) | +| Public surfaces | `docs/public-surface-facts.json` is the source-backed matrix; tool count is 66 and provider count is 35. Final canonical screenshot, EN/ZH browser captures, README-width review, and deployed-drift receipt are recorded in the additive evidence packet (#4670, #4671, #4672) | + +Remote milestone issues stay open until `E` lands on protected `main`, hosted +checks are green, the exact main SHA is installed locally, and issue comments +receive those receipts. A local implementation is not used as evidence that a +remote issue has already landed. + +## Contributor history and DCO decision + +No contributor commit is rewritten or flattened. The following exact commits +in `origin/main..S` lack a `Signed-off-by` trailer: + +```text +75e08e5c67f555b5cda8511742c824bb74bb6c1d +c9010ec4e18bf7a2b9d38ef46679301e53a819c9 +cd24aacb3a60fd546ef98c5e93c7749f1eb48240 +677ef4d4ffa375928bbd29aa340eecd7b17204af +5c3eb8245512cf790a933484453d3e300eb4c7af +5087269606fc8847487b0a8b51ef6adffa8eb2ca +e37df06caeb3064b2bb9263c1c98a903738f3a0a +74862148a5a39b07c84952a0b28acf0f6f056dd3 +54c8916d3c0a55dbf992191ba7e21b6e7ff75d6e +f8e79479a9e520e0796452aff7ec72b10bf29842 +bf45d1273577341a4ba992688816278249b1c633 +f06333bcaef4797f49cc0df16e1ecf10e8c826f2 +1a1e958f68ffc9e6232c2873bcf509ef74a839cf +aa0b5f89e437736dc5d3326d8bca1726eea12056 +b489acca1b728625164fce9fa0f88a7752e1e028 +e3a4f96768be178c6439594875ea9d540a9930c7 +e2208815e51cdc42830cec8c78d4db1fff00d490 +c7e87533aaf43f010bfe6c316500c47b8f6c95a8 +c89094226cf18b79375f2bacc9a6536c78e30f26 +ee5f7298cc701bae0a0bf2f71b4a884a6ada4a8c +e06a4fd7271b88db346cec32095e94c35b6bea49 +31dac05209e8636d6f2f7e8ff70507886bff45b7 +``` + +The repository DCO workflow is explicitly advisory and exits successfully +after reporting this list. Rewriting would destroy exact PR ancestry and the +machine-readable credit exceptions for the public-surface and Paulo evidence +commits. The release decision is therefore to preserve history, report the +advisory exception exactly, require sign-off on every additive follow-up, and +keep `scripts/check-coauthor-trailers.py` green. This is not a claim that an +unsigned contributor commit carries a signature it does not have. + +## Exact verification ledger + +The following receipts must be attached to `S` or, for evidence-only drift +checks, to `E` before #4675 is updated: + +- [x] Full locked real-PTY suite, including wide, narrow, dark, light, + reduced-motion, ASCII-safe, semantic motion, Work, Agent Details, evidence, + File mutation, coordination, resize, scroll, and restart states. +- [x] Locked TUI binary tests and `release_runtime_qa`. +- [x] Locked workspace check, strict all-target/all-feature clippy, and full + all-feature workspace tests. +- [x] Version, changelog, lockfile, provider-registry, co-author, workflow, + platform, release-script, and branch-hygiene checks. +- [x] Web facts (`66` tools / `35` providers), docs parity, tests, lint, + TypeScript, production build, deploy-env preflight, deployed-facts comparison, + and OpenNext build without deployment. +- [x] Real 120x32 PTY capture from `S`, rasterized with VHS and optimized to one + byte-identical 1280x720 README/website PNG, plus EN/ZH desktop/mobile and + README-width review artifacts. +- [ ] Hosted CI green at the approved exact PR head with zero unresolved review + threads. +- [ ] After normal merge, `scripts/release/install-dogfood.sh` rebuilds and + installs `codewhale`, `codew`, and `codewhale-tui` from one exact protected + `main` SHA; all three versions and digests are recorded in #4650. + +## Safe Kimi K3 handoff + +These provider-free tests exercise direct Moonshot K3, Kimi Code K3, request +dialect isolation, MFJS-safe schemas, context limits, and read-only diagnostics. +They use sealed loopback/process fixtures and no account or provider request: + +```bash +CARGO_NET_OFFLINE=true cargo test -p codewhale-tui --bin codewhale-tui \ + --locked create_message_request_json_honors_exact_k3_route_boundaries +CARGO_NET_OFFLINE=true cargo test -p codewhale-tui --bin codewhale-tui \ + --locked create_message_request_sends_mfjs_compatible_apply_patch_schema +CARGO_NET_OFFLINE=true cargo test -p codewhale-tui --bin codewhale-tui \ + --locked direct_moonshot_k3_route_uses_documented_1m_limits_with_provenance +CARGO_NET_OFFLINE=true cargo test -p codewhale-tui \ + --test diagnostic_read_only --locked kimi_code +``` + +Expected receipt: each command exits zero; each named unit filter runs exactly +one test; the diagnostic filter runs the invalid-alias and valid-context tests; +no provider credential or outbound request is used. + +The human Kimi Code entitlement check is a separate approval boundary. Do not +run it from this packet. After Hunter explicitly approves a live-provider test, +create an isolated config with `provider = "moonshot"`, the exact +`https://api.kimi.com/coding/v1` base URL, `model = "k3"`, and a Kimi Code plan +API key supplied only through `KIMI_API_KEY`; then run the installed binary: + +```bash +codewhale --config /path/to/isolated-kimi-code.toml \ + --provider moonshot --model k3 exec --reasoning-effort low --json \ + "Reply exactly K3-ENTITLEMENT-OK and do not call tools." +``` + +Acceptance requires an exit-zero JSON response, the exact phrase, and a route +receipt naming `moonshot` / `k3`. It must not use `k3[1m]`, infer 1M entitlement +from provider-free tests, enable tools, read Kimi CLI credentials, or persist +the key in this repository. + +## Manual web workflow handoff — do not run + +Push and pull-request events execute only the `lint` job in +`.github/workflows/web.yml`. Deployment additionally requires both a manual +`workflow_dispatch` event and `refs/heads/main`; therefore an ordinary green +push is not deployment evidence. + +After the final merge, and only with separate deployment approval, bind the +manual run to the intended protected-main SHA immediately before dispatch: + +```bash +EXPECTED_SHA= +test "$(gh api repos/Hmbown/CodeWhale/git/ref/heads/main --jq .object.sha)" = "$EXPECTED_SHA" +gh workflow run web.yml --repo Hmbown/CodeWhale --ref main +# Identify the new workflow_dispatch run, then verify its immutable receipt: +gh run view --repo Hmbown/CodeWhale \ + --json headSha,event,headBranch,url +# Accept only headSha == EXPECTED_SHA, event == workflow_dispatch, +# and headBranch == main before treating the run as bound to this candidate. +``` + +`web.yml` has no SHA input and its deploy guard requires the `main` ref, so the +pre-dispatch equality check and post-dispatch run receipt are both mandatory. +This packet stops before the dispatch command. + +## Stop line + +Completion of this ledger means one reviewed, hosted-green, normally merged, +exactly rebuilt and locally installed v0.9.1 dogfood candidate with truthful +receipts. It does **not** authorize a tag, GitHub Release, crates/npm publish, +artifact upload, Cloudflare deployment, DNS or billing change, live xAI/Kimi +request, customer communication, or public release. diff --git a/docs/skills/README.md b/docs/skills/README.md index 2ce87c4464..9b00bb4912 100644 --- a/docs/skills/README.md +++ b/docs/skills/README.md @@ -4,10 +4,14 @@ GitHub-stewardship and release-QA workflows for maintaining Codewhale, codified `SKILL.md` skills (same format Claude Code and Codewhale both load). They encode the workflows used to assemble the v0.8.61 release. +For end-user Skills Manager behavior (ownership, audit, import, trust), see +[../SKILLS.md](../SKILLS.md). + To activate: - **Claude Code:** copy a skill dir into `.claude/skills/` (project) or your user skills dir. -- **Codewhale:** copy into Codewhale's `skills_dir` (e.g. `~/.codewhale/skills/`), or bundle - into `crates/tui/assets/skills/` + register in `crates/tui/src/skills/system.rs` to ship it. +- **Codewhale:** copy into a CodeWhale-owned root (e.g. `~/.codewhale/skills/`), import via + `/skills`, or bundle into `crates/tui/assets/skills/` + register in + `crates/tui/src/skills/system.rs` to ship it. Skills: gh-file-issue, gh-compile-issues, gh-assign-issues, gh-plan-issues, gh-find-prs, gh-treasure-hunt, gh-close-issues, gh-credit-harvest, codew-release-qa-sweep. diff --git a/integrations/feishu-bridge/package-lock.json b/integrations/feishu-bridge/package-lock.json index 9cba07fc30..faaa91f224 100644 --- a/integrations/feishu-bridge/package-lock.json +++ b/integrations/feishu-bridge/package-lock.json @@ -114,9 +114,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", - "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", diff --git a/integrations/feishu-bridge/package.json b/integrations/feishu-bridge/package.json index f67c33a732..3e6cc8afa3 100644 --- a/integrations/feishu-bridge/package.json +++ b/integrations/feishu-bridge/package.json @@ -15,7 +15,7 @@ "@larksuiteoapi/node-sdk": "^1.52.0" }, "overrides": { - "axios": "^1.16.1", + "axios": "^1.18.1", "qs": ">=6.15.2" }, "engines": { diff --git a/npm/codewhale/README.md b/npm/codewhale/README.md index 17709f8c70..f5eecb31a5 100644 --- a/npm/codewhale/README.md +++ b/npm/codewhale/README.md @@ -1,9 +1,9 @@ # codewhale -> The terminal coding agent for any model — open models first. +> The terminal coding agent for supported hosted and local models — open models first. Codewhale is a Rust TUI and CLI for many model providers — DeepSeek, -OpenRouter, Hugging Face, and local vLLM/SGLang/Ollama are first-class routes, +OpenRouter, Hugging Face, and local vLLM/SGLang/Ollama are supported routes, and it speaks natively to Anthropic Claude and OpenAI when that's what you have — with approval-gated tools, OS sandboxing, side-git snapshots, and `/restore` rollback. @@ -65,9 +65,16 @@ Prebuilt binaries for the GitHub release are downloaded automatically: - Linux x64 - Linux arm64 -- Android arm64 (Termux) - macOS x64 / arm64 - Windows x64 / arm64 +- Android arm64 / Termux (preview; requires matching Android assets in the + selected GitHub Release) + +The source-candidate wrapper recognizes Android arm64 and resolves the +Termux-native `codewhale`, `codew`, and `codewhale-tui` assets. That path works +only for package versions whose matching GitHub Release publishes all three +assets, and remains preview support pending real-device QA. See the support +table in [docs/INSTALL.md](https://github.com/Hmbown/CodeWhale/blob/main/docs/INSTALL.md). HarmonyOS PC (`openharmony`) is treated as `linux`, so it gets the Linux binaries matching your CPU architecture (x64 or arm64). Linux riscv64 prebuilts diff --git a/npm/codewhale/package.json b/npm/codewhale/package.json index e775c957a2..13e5ce155a 100644 --- a/npm/codewhale/package.json +++ b/npm/codewhale/package.json @@ -2,7 +2,7 @@ "name": "codewhale", "version": "0.9.1", "codewhaleBinaryVersion": "0.9.1", - "description": "Terminal coding agent for any model, open models first. One runtime on your machine. Rust, MIT.", + "description": "Terminal coding agent for supported hosted and local models. One runtime on your machine. Rust, MIT.", "author": "Hmbown", "license": "MIT", "funding": [ diff --git a/scripts/check-coauthor-trailers.py b/scripts/check-coauthor-trailers.py index db350cc785..0ff9729155 100644 --- a/scripts/check-coauthor-trailers.py +++ b/scripts/check-coauthor-trailers.py @@ -4,7 +4,8 @@ The check is intentionally scoped to new commits. Historical commits may carry raw or local emails, but new harvested commits should use GitHub's numeric `id+login@users.noreply.github.com` address so co-author credit lands in the -contributor graph. +contributor graph. Preserved integration commits may resolve a mapped identity +without a history rewrite only through an exact-SHA exception below. """ from __future__ import annotations @@ -52,6 +53,43 @@ ), } +# These public-surface commits were merged into the v0.9.1 integration graph +# before the credit gate ran. Rewriting them would replace the original commits +# and every descendant merge. Resolve only their exact Hunter identities through +# AUTHOR_MAP; a changed SHA, role, name, or email remains a hard failure. +PRESERVED_MAPPED_IDENTITY_EXCEPTIONS = { + ( + "5087269606fc8847487b0a8b51ef6adffa8eb2ca", + "author", + "hunter b", + "hmbown@gmail.com", + ), + ( + "5087269606fc8847487b0a8b51ef6adffa8eb2ca", + "coauthor", + "hunter bown", + "hmbown@gmail.com", + ), + ( + "e37df06caeb3064b2bb9263c1c98a903738f3a0a", + "coauthor", + "hunter bown", + "hmbown@gmail.com", + ), + ( + "6d0ebc881a8bd2469c45b25f2a606fa63681e112", + "coauthor", + "fleitz", + "fleitzo@gmail.com", + ), + ( + "338138eb546bcf8917b27395325f59af0d2e4f52", + "author", + "hunter b", + "hmbown@gmail.com", + ), +} + @dataclass(frozen=True) class Identity: @@ -168,6 +206,15 @@ def lookup_identity(aliases: dict[str, Identity], *values: str) -> Identity | No return None +def is_preserved_mapped_identity(commit: Commit, role: str, identity: Identity) -> bool: + return ( + commit.sha.strip().lower(), + role, + norm_key(identity.name), + norm_key(identity.email), + ) in PRESERVED_MAPPED_IDENTITY_EXCEPTIONS + + def validate(commits: list[Commit], aliases: dict[str, Identity], check_authors: bool) -> list[str]: errors: list[str] = [] for commit in commits: @@ -191,6 +238,11 @@ def validate(commits: list[Commit], aliases: dict[str, Identity], check_authors: is_harvested_commit and mapped_author and norm_key(commit.author_email) != norm_key(mapped_author.email) + and not is_preserved_mapped_identity( + commit, + "author", + Identity(commit.author_name, commit.author_email), + ) ): errors.append( f"{prefix}: author {commit.author_name} <{commit.author_email}> " @@ -215,10 +267,11 @@ def validate(commits: list[Commit], aliases: dict[str, Identity], check_authors: continue expected = lookup_identity(aliases, coauthor.email, coauthor.name) if expected: - errors.append( - f"{prefix}: co-author {coauthor.name} <{coauthor.email}> is not " - f"GitHub-mappable. Use `{expected.trailer()}`." - ) + if not is_preserved_mapped_identity(commit, "coauthor", coauthor): + errors.append( + f"{prefix}: co-author {coauthor.name} <{coauthor.email}> is not " + f"GitHub-mappable. Use `{expected.trailer()}`." + ) else: errors.append( f"{prefix}: co-author {coauthor.name} <{coauthor.email}> is not " @@ -226,7 +279,12 @@ def validate(commits: list[Commit], aliases: dict[str, Identity], check_authors: "or use `gh api users/ --jq '\"\\(.id)+\\(.login)@users.noreply.github.com\"'`." ) - coauthor_emails = {norm_key(coauthor.email) for coauthor in coauthors} + coauthor_emails: set[str] = set() + for coauthor in coauthors: + coauthor_emails.add(norm_key(coauthor.email)) + expected = lookup_identity(aliases, coauthor.email, coauthor.name) + if expected and is_preserved_mapped_identity(commit, "coauthor", coauthor): + coauthor_emails.add(norm_key(expected.email)) for login in harvested_logins: expected = lookup_identity(aliases, login) if expected is None: diff --git a/scripts/measure-runtime-contract.py b/scripts/measure-runtime-contract.py new file mode 100644 index 0000000000..cfbe77b12f --- /dev/null +++ b/scripts/measure-runtime-contract.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Measure the provider-free model-facing runtime contract. + +Combines the serialized tool catalog and the rendered system prompt into a +single reproducible receipt. No API keys or live providers are required. +""" + +from __future__ import annotations + +import json +import subprocess +import sys + + +def run_metric(test_name: str, marker: str) -> dict: + cmd = [ + "cargo", + "test", + "-p", + "codewhale-tui", + test_name, + "--", + "--ignored", + "--nocapture", + "--test-threads=1", + ] + proc = subprocess.run(cmd, text=True, capture_output=True, check=False) + sys.stderr.write(proc.stderr) + + combined = proc.stdout.splitlines() + proc.stderr.splitlines() + for line in combined: + if marker in line: + return json.loads(line.split(marker, 1)[1]) + + sys.stdout.write(proc.stdout) + raise RuntimeError(f"missing {marker} marker") + + +def main() -> int: + tool_metrics = run_metric( + "print_agent_tool_catalog_metrics", + "TOOL_CATALOG_METRICS ", + ) + prompt_metrics = run_metric( + "print_agent_runtime_contract_metrics", + "RUNTIME_CONTRACT_METRICS ", + ) + + receipt = { + "tool_catalog": tool_metrics, + "system_prompt": prompt_metrics, + } + print(json.dumps(receipt, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/measure-tool-catalog.py b/scripts/measure-tool-catalog.py index c5b403676c..6a39d761c3 100755 --- a/scripts/measure-tool-catalog.py +++ b/scripts/measure-tool-catalog.py @@ -31,7 +31,8 @@ def main() -> int: proc = subprocess.run(cmd, text=True, capture_output=True, check=False) sys.stderr.write(proc.stderr) - for line in proc.stdout.splitlines(): + combined = proc.stdout.splitlines() + proc.stderr.splitlines() + for line in combined: if MARKER in line: metrics = json.loads(line.split(MARKER, 1)[1]) print(json.dumps(metrics, indent=2, sort_keys=True)) diff --git a/scripts/release/check-versions.sh b/scripts/release/check-versions.sh index 3aa3131e57..1e513123b1 100755 --- a/scripts/release/check-versions.sh +++ b/scripts/release/check-versions.sh @@ -13,7 +13,8 @@ # notice and must stay private.) # 4. Internal `codewhale-*` path dependency pins match the workspace version. # 5. The TUI crate's packaged changelog copy matches root `CHANGELOG.md`. -# 6. The current release has a dated Keep a Changelog entry and compare link. +# 6. The current version has either an explicit source-candidate entry or a +# dated Keep a Changelog release entry and a matching compare link. # 7. README contributor additions are mentioned in the current release entry. # 8. `SECURITY.md` keeps the dedicated security contact. # 9. Generated website facts carry the workspace version. @@ -24,6 +25,16 @@ # fails if not). set -euo pipefail +require_dated_release=0 +if [[ "${1:-}" == "--require-dated-release" ]]; then + require_dated_release=1 + shift +fi +if [[ "$#" -ne 0 ]]; then + echo "Usage: $0 [--require-dated-release]" >&2 + exit 2 +fi + cd "$(dirname "$0")/../.." fail=0 @@ -95,7 +106,10 @@ if ! ./scripts/sync-changelog.sh --check >/dev/null 2>&1; then fail=1 fi -# 6) Current release-note shape. +# 6) Current candidate/release-note shape. Normal branch and release-candidate +# CI must accept an honest source candidate. Tag creation and public release +# workflows pass --require-dated-release so publication cannot proceed until +# the same entry has a real release date and tag-based compare link. current_section="$( awk -v version="${workspace_version}" ' index($0, "## [" version "] - ") == 1 { in_section = 1; print; next } @@ -107,8 +121,15 @@ if [[ -z "${current_section}" ]]; then echo "::error::CHANGELOG.md must contain a section for ${workspace_version}." >&2 fail=1 else - if ! grep -qE "^## \\[${workspace_version}\\] - [0-9]{4}-[0-9]{2}-[0-9]{2}$" <<<"${current_section}"; then - echo "::error::CHANGELOG.md section ${workspace_version} must use '## [${workspace_version}] - YYYY-MM-DD'." >&2 + dated_heading="## [${workspace_version}] - YYYY-MM-DD" + candidate_heading="## [${workspace_version}] - Unreleased candidate" + if [[ "${require_dated_release}" == "1" ]]; then + if ! grep -qE "^## \\[${workspace_version}\\] - [0-9]{4}-[0-9]{2}-[0-9]{2}$" <<<"${current_section}"; then + echo "::error::Publication requires CHANGELOG.md section ${workspace_version} to use '${dated_heading}'." >&2 + fail=1 + fi + elif ! grep -qE "^## \\[${workspace_version}\\] - ([0-9]{4}-[0-9]{2}-[0-9]{2}|Unreleased candidate)$" <<<"${current_section}"; then + echo "::error::CHANGELOG.md section ${workspace_version} must use '${candidate_heading}' or '${dated_heading}'." >&2 fail=1 fi if ! grep -qE "^### (Added|Changed|Deprecated|Removed|Fixed|Security)$" <<<"${current_section}"; then @@ -121,6 +142,10 @@ compare_line="$(grep -E "^\\[${workspace_version}\\]: " CHANGELOG.md || true)" if [[ -z "${compare_line}" ]]; then echo "::error::CHANGELOG.md must include a compare link for ${workspace_version}." >&2 fail=1 +elif [[ "${require_dated_release}" == "1" ]] && + ! grep -qE "^\\[${workspace_version}\\]: https://github.com/Hmbown/CodeWhale/compare/v[0-9]+\\.[0-9]+\\.[0-9]+\\.\\.\\.v${workspace_version}$" <<<"${compare_line}"; then + echo "::error::Publication requires the ${workspace_version} compare link to end at v${workspace_version}." >&2 + fail=1 fi unreleased_section="$( diff --git a/scripts/test_check_coauthor_trailers.py b/scripts/test_check_coauthor_trailers.py index 07da878f17..61624df762 100644 --- a/scripts/test_check_coauthor_trailers.py +++ b/scripts/test_check_coauthor_trailers.py @@ -106,6 +106,97 @@ def test_legacy_exception_does_not_hide_another_bad_trailer(self) -> None: self.assertEqual(len(errors), 1) self.assertIn("unknown@example.com", errors[0]) + def test_preserved_hunter_credit_resolves_only_for_exact_commit(self) -> None: + preserved = mod.Commit( + sha="5087269606fc8847487b0a8b51ef6adffa8eb2ca", + parents="parent", + author_name="Hunter B", + author_email="hmbown@gmail.com", + subject="docs(public): refresh v0.9.1 product truth", + body=( + "Harvested from PR #4508 by @Hmbown.\n\n" + "Co-authored-by: Hunter Bown " + ), + ) + errors = mod.validate([preserved], self.aliases, True) + self.assertEqual(errors, []) + + rewritten = mod.Commit( + sha="deadbeef" * 5, + parents=preserved.parents, + author_name=preserved.author_name, + author_email=preserved.author_email, + subject=preserved.subject, + body=preserved.body, + ) + errors = mod.validate([rewritten], self.aliases, True) + self.assertTrue(errors) + self.assertTrue(any("not GitHub-mappable" in error for error in errors)) + + def test_preserved_web_credit_resolves_without_rewriting_commit(self) -> None: + preserved = mod.Commit( + sha="e37df06caeb3064b2bb9263c1c98a903738f3a0a", + parents="parent", + author_name="Hunter B", + author_email="hmbown@gmail.com", + subject="docs(web): make the homepage product first", + body="Co-authored-by: Hunter Bown ", + ) + errors = mod.validate([preserved], self.aliases, True) + self.assertEqual(errors, []) + + def test_preserved_fleitz_credit_resolves_only_for_exact_commit(self) -> None: + preserved = mod.Commit( + sha="6d0ebc881a8bd2469c45b25f2a606fa63681e112", + parents="parent", + author_name="Fred Leitz", + author_email="fred.leitz@gmail.com", + subject="fix(shell): default no-cwd shell commands to context.workspace", + body="Co-authored-by: fleitz ", + ) + errors = mod.validate([preserved], self.aliases, True) + self.assertEqual(errors, []) + + changed_identity = mod.Commit( + sha=preserved.sha, + parents=preserved.parents, + author_name=preserved.author_name, + author_email=preserved.author_email, + subject=preserved.subject, + body="Co-authored-by: fleitz ", + ) + errors = mod.validate([changed_identity], self.aliases, True) + self.assertTrue(errors) + self.assertTrue(any("different@example.com" in error for error in errors)) + + def test_preserved_telecom_harvest_author_resolves_only_for_exact_commit(self) -> None: + preserved = mod.Commit( + sha="338138eb546bcf8917b27395325f59af0d2e4f52", + parents="parent", + author_name="Hunter B", + author_email="hmbown@gmail.com", + subject="feat(provider): add TelecomJS live catalog", + body=( + "Harvested from PR #4370 by @baendlorel.\n\n" + "Co-authored-by: baendlorel " + "<50111870+baendlorel@users.noreply.github.com>" + ), + ) + errors = mod.validate([preserved], self.aliases, True) + self.assertEqual(errors, []) + + rewritten = mod.Commit( + sha="deadbeef" * 5, + parents=preserved.parents, + author_name=preserved.author_name, + author_email=preserved.author_email, + subject=preserved.subject, + body=preserved.body, + ) + errors = mod.validate([rewritten], self.aliases, True) + self.assertTrue(errors) + self.assertTrue(any("not canonical" in error for error in errors)) + if __name__ == "__main__": raise SystemExit(unittest.main()) diff --git a/web/README.md b/web/README.md index a0e2bdfe69..ba9edc338a 100644 --- a/web/README.md +++ b/web/README.md @@ -31,6 +31,26 @@ The site renders fine without any of them — `Today's Dispatch` falls back to a ## Deploy to Cloudflare +Ordinary pushes and pull requests run the web checks and production build, but +they do **not** deploy. The `deploy` job in `.github/workflows/web.yml` runs +only for a maintainer-triggered `workflow_dispatch` on `main`. Before approval, +record the exact 40-character `origin/main` SHA and trigger that ref: + +```bash +git fetch origin main +git rev-parse origin/main +gh workflow run web.yml --repo Hmbown/CodeWhale --ref main +``` + +The manual job records the pre-deploy source drift, builds the OpenNext bundle, +deploys only after the protected Cloudflare inputs pass, and then requires the +public `/api/facts` receipt to report the exact workflow SHA. A credential-free +local comparison is available without starting a deployment: + +```bash +npm run compare:deployed-facts -- --expected-revision +``` + You already own `codewhale.net` on Cloudflare and have a Workers Paid plan. The deploy is two steps: 1. **Provision KV namespaces once:** @@ -83,8 +103,11 @@ web/ │ │ └── admin/ maintainer panel (page.tsx + admin-client.tsx) │ └── api/ │ ├── cron/route.ts cron tasks: curate, triage, facts-drift, … +│ ├── facts/route.ts public source/deployment receipt │ ├── github/feed/route.ts cached JSON endpoint │ └── admin/ login, logout, post (MAINTAINER_TOKEN-gated) +├── data/ +│ └── latest-published-release.json manually advanced only after publication ├── components/ │ ├── nav.tsx sticky header w/ date strip + CJK accents │ ├── footer.tsx dense 5-column footer @@ -106,6 +129,7 @@ web/ │ └── kv.ts Cloudflare KV access via OpenNext bindings ├── scripts/ │ ├── derive-facts.mjs prebuild: repo sources → lib/facts.generated.ts +│ ├── compare-deployed-facts.mjs credential-free exact-SHA receipt check │ └── check-kv-id.mjs predeploy guard for KV namespace ids ├── wrangler.jsonc CF Worker config + cron + KV binding ├── open-next.config.ts OpenNext adapter config @@ -119,14 +143,24 @@ default model, Node engines) are never hand-written into pages: 1. **Build time** — `scripts/derive-facts.mjs` runs as `prebuild` (and before `npm run dev`), parses the parent repo (`Cargo.toml`, `crates/tui/src/config.rs`, - `crates/tui/src/sandbox/`, `npm/codewhale/package.json`) and writes + `crates/tui/src/sandbox/mod.rs`, `npm/codewhale/package.json`) and writes `lib/facts.generated.ts`. Never edit that file by hand. -2. **Runtime** — the `/api/cron?task=facts-drift` cron (`lib/facts-drift.ts`) - re-derives the same facts from `raw.githubusercontent.com` on a schedule and - writes changes to `CURATED_KV` under `facts:current`. Pages call - `getFacts()` (`lib/facts.ts`), which prefers the KV value over the - build-time constant — so a version bump or new provider self-corrects - within one cron tick, without a redeploy. +2. **Published release** — `data/latest-published-release.json` records the + latest GitHub Release separately from the source candidate. Install commands + use this published tag; they never turn the workspace version into a release + before publication. The credential-free deployed-facts comparison checks the + record against the public receipt. +3. **Runtime** — the `/api/cron?task=facts-drift` cron (`lib/facts-drift.ts`) + resolves an exact `main` revision, derives every source fact from that SHA, + and writes changes to `CURATED_KV` under `facts:current`. Pages accept that + snapshot only when its source provenance is the same as or newer than the + deployed build. Legacy, malformed, or older KV data cannot replace newer + build facts; published-release metadata is resolved independently. Public + fact pages revalidate their cached HTML every five minutes. + +`/api/facts` exposes only public provenance and counts: deployed/resolved source +revision, version, provider count, tool count, selection reason, and latest +published release. It contains no environment values, tokens, or KV contents. When a new `ApiProvider` variant lands in `crates/tui/src/config.rs`, it must be added to the `labelMap` in **both** `scripts/derive-facts.mjs` and diff --git a/web/app/[locale]/community/page.tsx b/web/app/[locale]/community/page.tsx index 662bcd6af6..c4bd44ad26 100644 --- a/web/app/[locale]/community/page.tsx +++ b/web/app/[locale]/community/page.tsx @@ -3,6 +3,8 @@ import { getFacts } from "@/lib/facts"; import { buildPageMetadata } from "@/lib/page-meta"; import { RELEASE_CONTRIBUTORS, RELEASE_HELPERS } from "@/lib/release-credits"; +export const revalidate = 300; + export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; const isZh = locale === "zh"; @@ -21,6 +23,7 @@ export default async function CommunityPage({ params }: { params: Promise<{ loca const isZh = locale === "zh"; const p = (path: string) => `/${locale}${path}`; const facts = await getFacts(); + const sourceIsPublished = facts.latestPublishedRelease?.version === facts.version; const contributionPaths = isZh ? [ @@ -163,12 +166,20 @@ export default async function CommunityPage({ params }: { params: Promise<{ loca
- {isZh ? `v${facts.version} 版本致谢` : `v${facts.version} release credit`} + + {sourceIsPublished + ? isZh + ? `v${facts.version} 版本致谢` + : `v${facts.version} release credit` + : isZh + ? `v${facts.version} 候选版致谢` + : `v${facts.version} candidate credit`} +

{isZh ? "贡献者署名是版本记录的一部分。" : "Contributor credit is part of the release record."}

{isZh - ? "这一版本包含社区提交的代码、测试、复现和验证。即使维护者需要调整补丁后再合入,原始贡献者的署名也会保留在提交、更新日志和贡献者名单中。" - : "This release includes code, tests, reproductions, and verification from the community. When a maintainer needs to adapt a patch before it lands, the original contributor remains credited in the commit, changelog, and contributor record."} + ? `${sourceIsPublished ? "这一版本" : "这一候选版"}包含社区提交的代码、测试、复现和验证。即使维护者需要调整补丁后再合入,原始贡献者的署名也会保留在提交、更新日志和贡献者名单中。` + : `This ${sourceIsPublished ? "release" : "candidate"} includes code, tests, reproductions, and verification from the community. When a maintainer needs to adapt a patch before it lands, the original contributor remains credited in the commit, changelog, and contributor record.`}

diff --git a/web/app/[locale]/docs/tools/page.tsx b/web/app/[locale]/docs/tools/page.tsx index 84398a7ab5..6225bae025 100644 --- a/web/app/[locale]/docs/tools/page.tsx +++ b/web/app/[locale]/docs/tools/page.tsx @@ -9,8 +9,8 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s locale, title: isZh ? "工具 · Codewhale 文档" : "Tools · Codewhale Docs", description: isZh - ? "类型化工具集、工具生命周期和精选工具目录。" - : "Typed tool surface, tool lifecycle, and the curated tool catalog.", + ? "Bash、File、Git、Run 四个核心 action 工具,以及协调、延迟加载与回放兼容边界。" + : "Canonical Bash, File, Git, and Run action tools, coordination tools, deferred loading, and replay compatibility.", }); } @@ -42,32 +42,32 @@ export default async function ToolsPage({ params }: { params: Promise<{ locale:
{[ { - group: isZh ? "文件操作" : "File ops", - tools: "read_file · list_dir · write_file · edit_file · apply_patch", + group: "Bash", + tools: "run · wait · interact · cancel", }, { - group: isZh ? "搜索" : "Search", - tools: "grep_files · file_search · web_search · fetch_url", + group: "File", + tools: "read · list · search_name · search_content · write · edit · patch", }, { - group: "Shell", - tools: "exec_shell · exec_shell_wait · exec_shell_interact", + group: "Git", + tools: "status · diff · log · show · blame", }, { - group: isZh ? "Git / 诊断 / 测试" : "Git / diag / test", - tools: "git_status · git_diff · diagnostics · run_tests", + group: "Run", + tools: "tests · verifiers", }, { - group: isZh ? "子 Agent" : "Sub-agents", + group: isZh ? "协调" : "Coordination", tools: isZh - ? "agent —— 持久会话,并行执行;详见 docs/SUBAGENTS.md" - : "agent — persistent sessions, parallel execution; see docs/SUBAGENTS.md", + ? "agent · remember(启用内置记忆时)· tasks · update_plan · work_update · tool_search(synthetic,始终启用)" + : "agent · remember (when built-in memory is enabled) · tasks · update_plan · work_update · tool_search (synthetic and always active)", }, { - group: isZh ? "递归 LM (RLM)" : "Recursive LM (RLM)", + group: isZh ? "延迟加载" : "Deferred", tools: isZh - ? "rlm_open · rlm_eval · rlm_configure · rlm_close —— 沙箱 Python REPL,内置 peek/search/chunk/sub_query_batch 等辅助函数" - : "rlm_open · rlm_eval · rlm_configure · rlm_close — sandboxed Python REPL with peek/search/chunk/sub_query_batch helpers", + ? "Web(search · fetch · wait)仅在网络策略允许时通过 tool_search 加载;github、automation 与 rlm 也默认延迟加载" + : "Web (search · fetch · wait) loads through tool_search only when network policy permits; github, automation, and rlm are deferred by default", }, { group: "MCP", @@ -91,28 +91,28 @@ export default async function ToolsPage({ params }: { params: Promise<{ locale:
-
+

- {isZh ? "工具生命周期" : "Tool Lifecycle"} + {isZh ? "回放兼容" : "Replay compatibility"}

{isZh - ? "工具注册、发现、弃用和稳定的完整流程详见仓库文档。" - : "Full lifecycle for tool registration, discovery, deprecation, and stabilization is documented in the repo."} + ? "旧的单用途工具名只为已保存的 transcript 与自动化回放保留。它们仍可按原名执行,但不会出现在模型目录或 tool_search 中;新工作应使用上方的 canonical action 工具。" + : "Legacy single-purpose names remain callable only so saved transcripts and automation can replay. They stay out of the model catalog and tool_search; new work uses the canonical action tools above."}

- docs/TOOL_LIFECYCLE.md → + docs/RUNTIME_SIMPLIFICATION_DESIGN.md →

{isZh - ? "来源文档:docs/TOOL_SURFACE.md, docs/TOOL_LIFECYCLE.md · 更新时请同步修改 docs-map.ts。" - : "Source documents: docs/TOOL_SURFACE.md, docs/TOOL_LIFECYCLE.md · Update docs-map.ts when changing."} + ? "来源文档:docs/TOOL_SURFACE.md, docs/RUNTIME_SIMPLIFICATION_DESIGN.md · 更新时请同步修改 docs-map.ts。" + : "Source documents: docs/TOOL_SURFACE.md, docs/RUNTIME_SIMPLIFICATION_DESIGN.md · Update docs-map.ts when changing."}

diff --git a/web/app/[locale]/faq/page.tsx b/web/app/[locale]/faq/page.tsx index f6313d102b..3a3d1e60bc 100644 --- a/web/app/[locale]/faq/page.tsx +++ b/web/app/[locale]/faq/page.tsx @@ -1,16 +1,19 @@ import Link from "next/link"; import { Seal } from "@/components/seal"; import { FaqSearch } from "@/components/faq-search"; +import { buildPageMetadata } from "@/lib/page-meta"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; const isZh = locale === "zh"; - return { + return buildPageMetadata({ + path: "/faq", + locale, title: isZh ? "常见问题 · Codewhale" : "FAQ · Codewhale", description: isZh ? "Codewhale 常见问题:安装、配置、提供商、模型、模式、安全与隐私。答案来自实际代码、文档和 GitHub 议题。" : "Codewhale frequently asked questions: install, config, providers, models, modes, security, and privacy. Answers sourced from real code, docs, and GitHub issues.", - }; + }); } interface FaqItem { @@ -33,7 +36,7 @@ const faqEn: FaqItem[] = [ q: "How do I install Codewhale?", a: ( <> -

Four paths, same result:

+

Published channels differ in timing and platform support:

 {`# npm (recommended — no Rust toolchain needed)
 npm install -g codewhale
@@ -50,11 +53,13 @@ brew tap Hmbown/deepseek-tui && brew install deepseek-tui
         

Run codewhale to start. First run creates ~/.codewhale/ automatically. Legacy ~/.deepseek/ is still read as a compatibility fallback. + Android arm64 / Termux is preview support: npm works only when the + selected package version has matching Android assets in its GitHub Release. See the full install guide for China mirrors, Docker, and troubleshooting.

), - sources: ["README.md", "#1860", "#1914"], + sources: ["README.md", "docs/INSTALL.md", "#1860", "#1914"], }, { q: "What's the difference between codewhale and codewhale-tui?", @@ -63,7 +68,8 @@ brew tap Hmbown/deepseek-tui && brew install deepseek-tui codewhale is the dispatcher CLI — it manages config, auth, updates, and launches the TUI. codewhale-tui is the terminal UI binary that runs the agent loop. When you type codewhale, the dispatcher spawns codewhale-tui for you. - Both are installed together; you rarely need to think about the split. + npm and release bundles install them together. Cargo users install the + codewhale-cli and codewhale-tui crates separately. ), sources: ["README.md"], @@ -214,13 +220,17 @@ default_text_model = "openrouter/deepseek/deepseek-v4-pro"`} q: "Is my code safe? What sandboxing does Codewhale use?", a: ( <> - Codewhale runs entirely on your machine. No telemetry, no cloud processing of your code. - Sandbox backends: seatbelt (macOS), landlock (Linux), restricted tokens (Windows). + The Codewhale runtime, workspace state, and audit log stay on your machine; + Codewhale has no product telemetry or mandatory hosted relay. The hosted + provider you select receives the prompt, project context, tool definitions, + and tool results required for that turn. Use a loopback local-model route to + keep model inference local. + OS command sandboxing is platform-specific: Codewhale uses Seatbelt on macOS when available. On Linux it uses bubblewrap only when prefer_bwrap = true and /usr/bin/bwrap is executable; otherwise commands have no Codewhale OS wrapper. Windows currently reports no OS sandbox. Workspace boundaries default to --workspace. /trust lifts them. - Permission posture is configurable per session. All credential/approval/elevation events are written to ~/.codewhale/audit.log. + Permission posture is configurable per session. Sensitive credential, approval, and elevation events are appended best-effort to $CODEWHALE_HOME/audit.log (default ~/.codewhale/audit.log); write failures are logged. ), - sources: ["SECURITY.md", "docs/ARCHITECTURE.md"], + sources: ["SECURITY.md", "docs/PROVIDERS.md", "docs/RUNTIME_API.md"], }, { q: "How do MCP servers work?", @@ -263,7 +273,7 @@ registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"`}

Prebuilt binaries are also available from GitHub Releases. - The Gitee mirror and CNB mirror may also be available. + A maintained CNB mirror covers its documented targets; no Gitee mirror is advertised until one exists.

), @@ -378,7 +388,7 @@ const faqZh: FaqItem[] = [ q: "如何安装 Codewhale?", a: ( <> -

四种方式,殊途同归:

+

已发布渠道的更新时间与平台覆盖各不相同:

 {`# npm(推荐 — 无需 Rust 工具链)
 npm install -g codewhale
@@ -395,11 +405,12 @@ brew tap Hmbown/deepseek-tui && brew install deepseek-tui
         

输入 codewhale 即可启动。首次运行会自动创建 ~/.codewhale/。旧版 ~/.deepseek/ 仍会作为兼容回退读取。 + Android arm64 / Termux 仍是预览支持:只有当所选 npm 包版本对应的 GitHub Release 发布了匹配的 Android 资产时,npm 安装才可用。 查看 完整安装指南 了解国内镜像、Docker 和故障排除。

), - sources: ["README.md", "#1860", "#1914"], + sources: ["README.md", "docs/INSTALL.md", "#1860", "#1914"], }, { q: "codewhale 和 codewhale-tui 有什么区别?", @@ -408,7 +419,8 @@ brew tap Hmbown/deepseek-tui && brew install deepseek-tui codewhale 是调度 CLI——管理配置、认证、更新,并启动 TUI。 codewhale-tui 是运行智能体循环的终端 UI 二进制文件。 当你输入 codewhale 时,调度器会自动为你启动 codewhale-tui。 - 两者同时安装;通常你不需要关心这个区别。 + npm 与发布归档会同时安装两者。Cargo 用户需要分别安装 + codewhale-clicodewhale-tui crate。 ), sources: ["README.md"], @@ -557,13 +569,15 @@ default_text_model = "openrouter/deepseek/deepseek-v4-pro"`} q: "我的代码安全吗?Codewhale 使用什么沙箱机制?", a: ( <> - Codewhale 完全在你的机器上运行。无遥测,不会将你的代码上传到云端处理。 - 沙箱后端:seatbelt(macOS)、landlock(Linux)、受限令牌(Windows)。 + Codewhale 运行时、工作区状态与审计日志保留在你的机器上;Codewhale + 没有产品遥测,也不要求经过托管中继。你选择的托管 provider 会收到本轮所需的 + prompt、项目上下文、工具定义与工具结果。若要让模型推理也保持本地,请使用回环地址上的本地模型路由。 + OS 命令沙箱因平台而异:macOS 在可用时使用 Seatbelt。Linux 仅在 prefer_bwrap = true/usr/bin/bwrap 可执行时使用 bubblewrap;否则命令没有 Codewhale OS 包装器。Windows 当前报告无 OS 沙箱。 工作区边界默认为 --workspace/trust 可解除边界。 - 权限姿态可按会话配置。所有凭证/审批/提权事件写入 ~/.codewhale/audit.log。 + 权限姿态可按会话配置。敏感的凭证、审批和提权事件会尽力追加到 $CODEWHALE_HOME/audit.log(默认 ~/.codewhale/audit.log);写入失败会记录日志。 ), - sources: ["SECURITY.md", "docs/ARCHITECTURE.md"], + sources: ["SECURITY.md", "docs/PROVIDERS.md", "docs/RUNTIME_API.md"], }, { q: "MCP 服务器如何工作?", @@ -606,7 +620,7 @@ registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"`}

也可以从 GitHub Releases 直接下载预编译二进制。 - Gitee 镜像和 CNB 镜像也可能可用。 + 维护中的 CNB 镜像覆盖其文档列出的目标;Gitee 镜像只有实际存在后才会对外展示。

), diff --git a/web/app/[locale]/feed/page.tsx b/web/app/[locale]/feed/page.tsx index f520eb57a8..fab5ea8d95 100644 --- a/web/app/[locale]/feed/page.tsx +++ b/web/app/[locale]/feed/page.tsx @@ -3,6 +3,7 @@ import { Seal } from "@/components/seal"; import { FeedCard } from "@/components/feed-card"; import { fetchFeed } from "@/lib/github"; import { getEnv } from "@/lib/kv"; +import { buildPageMetadata } from "@/lib/page-meta"; import type { FeedItem } from "@/lib/types"; export const revalidate = 600; @@ -10,12 +11,14 @@ export const revalidate = 600; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; const isZh = locale === "zh"; - return { + return buildPageMetadata({ + path: "/feed", + locale, title: isZh ? "动态 · Codewhale" : "Activity · Codewhale", description: isZh ? "来自 Hmbown/CodeWhale GitHub 仓库的议题、合并请求和发布的实时动态。" : "Live feed of issues, pull requests, and releases mirrored from the Hmbown/CodeWhale GitHub repo.", - }; + }); } export default async function FeedPage({ params }: { params: Promise<{ locale: string }> }) { diff --git a/web/app/[locale]/install/page.tsx b/web/app/[locale]/install/page.tsx index f88b4b2f30..6aa2c398e6 100644 --- a/web/app/[locale]/install/page.tsx +++ b/web/app/[locale]/install/page.tsx @@ -5,6 +5,8 @@ import { InstallBinary } from "@/components/install-binary"; import { getFacts } from "@/lib/facts"; import { buildPageMetadata } from "@/lib/page-meta"; +export const revalidate = 300; + export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; const isZh = locale === "zh"; @@ -57,27 +59,27 @@ const FROM_SOURCE = `git clone https://github.com/Hmbown/CodeWhale cd CodeWhale cargo build --release --locked -# Install both binaries from the local checkout -cargo install --path crates/cli --locked # codewhale +# Install two Cargo packages; together they provide three commands +cargo install --path crates/cli --locked # codewhale + codew cargo install --path crates/tui --locked # codewhale-tui`; -const CONFIG_TREE = `~/.codewhale/ +const CONFIG_TREE = `$CODEWHALE_HOME/ (default: ~/.codewhale/) ├── config.toml api keys, model, hooks, profiles ├── mcp.json MCP server definitions ├── skills/ user skills (each with SKILL.md) ├── sessions/ checkpoints + offline queue ├── tasks/ background task store -└── audit.log credential / approval / elevation audit trail +└── audit.log best-effort credential / approval / elevation events ./.codewhale/ project-scoped config (optional, per-repo)`; -const CONFIG_TREE_ZH = `~/.codewhale/ +const CONFIG_TREE_ZH = `$CODEWHALE_HOME/(默认:~/.codewhale/) ├── config.toml API 密钥、模型、钩子、配置集 ├── mcp.json MCP 服务器定义 ├── skills/ 用户技能(每个含 SKILL.md) ├── sessions/ 检查点 + 离线队列 ├── tasks/ 后台任务存储 -└── audit.log 凭证 / 审批 / 提权审计日志 +└── audit.log 尽力写入的凭证 / 审批 / 提权事件 ./.codewhale/ 项目级配置(可选,每个仓库)`; @@ -85,8 +87,11 @@ export default async function InstallPage({ params }: { params: Promise<{ locale const { locale } = await params; const isZh = locale === "zh"; const facts = await getFacts(); - const tag = facts.version ? `v${facts.version}` : "v0.8.x"; - const verify = `codewhale --version # ${facts.version ?? "prints the installed version"} + const publishedRelease = facts.latestPublishedRelease; + const sourceIsPublished = publishedRelease?.version === facts.version; + const verify = `codewhale --version${ + publishedRelease ? ` # latest published: ${publishedRelease.version}` : "" + } codewhale doctor`; const copyLabel = isZh ? "复制" : "Copy"; @@ -184,8 +189,10 @@ codewhale doctor`; curl 命令覆盖更新。 通过包管理器安装的话,用包管理器升级更稳:npm 安装的运行{" "} npm update -g codewhale; - Cargo 安装的重跑两个 cargo install 命令并加{" "} - --force; + Cargo 安装的重跑两个 package 的 cargo install 命令并加{" "} + --forcecodewhale-cli 提供 + codewhalecodew, + codewhale-tui 提供同名命令); 旧版 Homebrew tap 用 brew upgrade deepseek-tui。 ) : ( @@ -194,9 +201,12 @@ codewhale doctor`; installed with install.sh, re-run the same{" "} curl command to overwrite the binaries. If you installed via a package manager, prefer it instead: npm users run{" "} - npm update -g codewhale; cargo users re-run both{" "} - cargo install commands with{" "} - --force; the legacy Homebrew tap updates with{" "} + npm update -g codewhale; cargo users re-run the two + package cargo install commands with{" "} + --force (codewhale-cli + provides codewhale and codew; + codewhale-tui provides the command of the same name); + the legacy Homebrew tap updates with{" "} brew upgrade deepseek-tui. )} @@ -291,12 +301,22 @@ codewhale doctor`;

{isZh ? "其他安装方式" : "Other ways to install"}

-

+

{isZh ? "如果上面的脚本路径不适合你,请从下面选择匹配你环境的方式。各渠道的命令和打包形式有所不同,说明会明确列出安装内容。" : "If the script above doesn't fit your setup, choose the channel that matches your environment. Command availability and packaging differ by channel, and each description states exactly what it installs."}

+

+ {publishedRelease + ? isZh + ? `下方的发布命令以 ${publishedRelease.tag} 为准;它是 GitHub 上最新的已发布版本。${sourceIsPublished ? "当前源码与该发布版一致。" : `当前源码候选版为 v${facts.version},发布前不会被安装命令当作正式版本。`}` + : `Release-backed commands below use ${publishedRelease.tag}, the latest version published on GitHub. ${sourceIsPublished ? "The current source matches that release." : `The current source candidate is v${facts.version}; install commands do not advertise it before publication.`}` + : isZh + ? "暂时无法验证最新的 GitHub 发布标签;请先查看 Releases,再运行需要固定标签的命令。" + : "The latest GitHub release tag could not be verified. Check Releases before running a command that requires a pinned tag."} +

+
{/* npm */}
@@ -334,16 +354,19 @@ codewhale doctor`;

{isZh ? ( <> - 编译并安装 codewhalecodewhale-tui~/.cargo/bin。 + 两个 Cargo package 会把 codewhale、 + codewcodewhale-tui + 三个命令安装到 ~/.cargo/bin。 需要 Rust 1.88+;Linux 用户先安装 pkg-config 和{" "} libdbus-1-dev 等构建依赖。如未安装 Rust,可访问{" "} rustup.rs。 ) : ( <> - Compiles and installs codewhale and{" "} - codewhale-tui to{" "} - ~/.cargo/bin. Requires Rust 1.88+; install via{" "} + The two Cargo packages install three commands— + codewhale, codew, and{" "} + codewhale-tui—to ~/.cargo/bin. + Requires Rust 1.88+; install via{" "} rustup.rs if you don't have it. On Linux, install build dependencies such as{" "} pkg-config and{" "} @@ -362,7 +385,20 @@ codewhale doctor`; {/* CNB */}

{isZh ? "CNB 镜像" : "CNB mirror"}
- + {publishedRelease ? ( + + ) : ( + + {isZh ? "查看最新 GitHub 发布" : "Check the latest GitHub release"} + + )}
{/* Mainland China network */} diff --git a/web/app/[locale]/layout.tsx b/web/app/[locale]/layout.tsx index b287a01a37..2ec072fd3a 100644 --- a/web/app/[locale]/layout.tsx +++ b/web/app/[locale]/layout.tsx @@ -47,11 +47,11 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s path: "/", locale, title: isZh - ? "Codewhale — 一个运行时,所有模型,你的机器" - : "Codewhale — One runtime. Every model. Your machine.", + ? "Codewhale — 一个运行时,支持的托管与本地模型,你的机器" + : "Codewhale — One runtime. Supported hosted and local models. Your machine.", description: isZh - ? "开源终端编程智能体,适配任意模型,开放模型优先。TUI、CLI 与本地工具——运行在你自己的机器上。Rust 编写,MIT 许可。" - : "Open-source terminal coding agent for any model, open models first. TUI, CLI, and local tools — runs on your machine. Rust, MIT.", + ? "开源终端编程智能体,适配受支持的托管与本地模型,开放模型优先。TUI、CLI 与本地工具——运行在你自己的机器上。Rust 编写,MIT 许可。" + : "Open-source terminal coding agent for supported hosted and local models, open models first. TUI, CLI, and local tools — runs on your machine. Rust, MIT.", }); } diff --git a/web/app/[locale]/models/page.tsx b/web/app/[locale]/models/page.tsx index 080b27d184..495b3238c3 100644 --- a/web/app/[locale]/models/page.tsx +++ b/web/app/[locale]/models/page.tsx @@ -2,6 +2,8 @@ import Link from "next/link"; import { getFacts } from "@/lib/facts"; import { buildPageMetadata } from "@/lib/page-meta"; +export const revalidate = 300; + export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; const isZh = locale === "zh"; diff --git a/web/app/[locale]/page.tsx b/web/app/[locale]/page.tsx index a451208e94..0165baa3b3 100644 --- a/web/app/[locale]/page.tsx +++ b/web/app/[locale]/page.tsx @@ -1,188 +1,308 @@ +import Image from "next/image"; import Link from "next/link"; import { InstallCodeBlock } from "@/components/install-code-block"; import { Whale } from "@/components/whale"; -import { - DOC_TOPICS, - docTopicHref, - docTopicIsExternal, - type DocTopic, -} from "@/lib/docs-map"; import { getFacts } from "@/lib/facts"; -const START_TOPIC_IDS = ["install", "guide", "configuration", "modes"]; -const RUNTIME_TOPIC_IDS = ["tools", "sandbox", "providers", "subagents"]; -const EXTEND_TOPIC_IDS = ["runtime-api", "mcp", "fleet", "troubleshooting"]; +const REPO = "https://github.com/Hmbown/CodeWhale"; -function topics(ids: string[]): DocTopic[] { - return ids.flatMap((id) => { - const topic = DOC_TOPICS.find((candidate) => candidate.id === id); - return topic ? [topic] : []; - }); -} +// Revalidate against source-proven runtime facts without giving up static edge +// caching. `getFacts()` rejects legacy or older KV snapshots. +export const revalidate = 300; -function TopicList({ items, locale }: { items: DocTopic[]; locale: string }) { - const isZh = locale === "zh"; +const WORKFLOW = [ + { + en: ["Inspect", "Read the repository, its instructions, and the task."], + zh: ["检查", "读取仓库、项目说明与任务。"], + }, + { + en: ["Act", "Edit files through explicit approval boundaries."], + zh: ["执行", "在明确的审批边界内修改文件。"], + }, + { + en: ["Verify", "Run checks and inspect the result."], + zh: ["验证", "运行检查并核对结果。"], + }, + { + en: ["Report", "Leave a concise, durable receipt."], + zh: ["报告", "留下简洁、可追溯的工作收据。"], + }, +] as const; - return ( -
- {items.map((topic) => { - const external = docTopicIsExternal(topic); - return ( - - {isZh ? topic.label.zh : topic.label.en} - {isZh ? topic.description.zh : topic.description.en} - - - ); - })} -
- ); -} +const SURFACES = [ + { + en: ["TUI", "Interactive terminal work"], + zh: ["TUI", "交互式终端工作"], + }, + { + en: ["codewhale exec", "Scripts and CI"], + zh: ["codewhale exec", "脚本与 CI"], + }, + { + en: ["Web client", "Loopback-only browser client"], + zh: ["Web 客户端", "仅限本机回环的浏览器客户端"], + }, + { + en: ["Runtime API + MCP", "Local integrations"], + zh: ["运行时 API + MCP", "本地集成"], + }, + { + en: ["Fleet", "Durable multi-agent work"], + zh: ["Fleet", "持久化多智能体工作"], + }, +] as const; export default async function HomePage({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; const isZh = locale === "zh"; const facts = await getFacts(); + const sourceVersion = facts.version ?? "unknown"; + const publishedRelease = facts.latestPublishedRelease; + const sourceIsPublished = publishedRelease?.version === sourceVersion; + const providerCount = facts.providers.length; return ( -
-
-