diff --git a/.github/scripts/update-homebrew-tap.sh b/.github/scripts/update-homebrew-tap.sh index d0e28018..5d8f970e 100644 --- a/.github/scripts/update-homebrew-tap.sh +++ b/.github/scripts/update-homebrew-tap.sh @@ -34,14 +34,24 @@ sha() { # --- read checksums --------------------------------------------------- -readonly SHA_DISP_MACOS_ARM="$(sha deepseek-macos-arm64)" -readonly SHA_TUI_MACOS_ARM="$(sha deepseek-tui-macos-arm64)" -readonly SHA_DISP_MACOS_X64="$(sha deepseek-macos-x64)" -readonly SHA_TUI_MACOS_X64="$(sha deepseek-tui-macos-x64)" -readonly SHA_DISP_LINUX_ARM="$(sha deepseek-linux-arm64)" -readonly SHA_TUI_LINUX_ARM="$(sha deepseek-tui-linux-arm64)" -readonly SHA_DISP_LINUX_X64="$(sha deepseek-linux-x64)" -readonly SHA_TUI_LINUX_X64="$(sha deepseek-tui-linux-x64)" +# Canonical dispatcher and TUI +readonly SHA_COD_MACOS_ARM="$(sha codewhale-macos-arm64)" +readonly SHA_TUI_MACOS_ARM="$(sha codewhale-tui-macos-arm64)" +readonly SHA_COD_MACOS_X64="$(sha codewhale-macos-x64)" +readonly SHA_TUI_MACOS_X64="$(sha codewhale-tui-macos-x64)" +readonly SHA_COD_LINUX_ARM="$(sha codewhale-linux-arm64)" +readonly SHA_TUI_LINUX_ARM="$(sha codewhale-tui-linux-arm64)" +readonly SHA_COD_LINUX_X64="$(sha codewhale-linux-x64)" +readonly SHA_TUI_LINUX_X64="$(sha codewhale-tui-linux-x64)" +# Legacy shims (removed in v0.9.0) +readonly SHA_LEG_MACOS_ARM="$(sha deepseek-macos-arm64)" +readonly SHA_LEG_TUI_MACOS_ARM="$(sha deepseek-tui-macos-arm64)" +readonly SHA_LEG_MACOS_X64="$(sha deepseek-macos-x64)" +readonly SHA_LEG_TUI_MACOS_X64="$(sha deepseek-tui-macos-x64)" +readonly SHA_LEG_LINUX_ARM="$(sha deepseek-linux-arm64)" +readonly SHA_LEG_TUI_LINUX_ARM="$(sha deepseek-tui-linux-arm64)" +readonly SHA_LEG_LINUX_X64="$(sha deepseek-linux-x64)" +readonly SHA_LEG_TUI_LINUX_X64="$(sha deepseek-tui-linux-x64)" # --- temp dirs -------------------------------------------------------- @@ -62,47 +72,81 @@ class DeepseekTui < Formula on_macos do if Hardware::CPU.arm? - url "${BASE_URL}/deepseek-macos-arm64", using: :nounzip - sha256 "${SHA_DISP_MACOS_ARM}" + url "${BASE_URL}/codewhale-macos-arm64", using: :nounzip + sha256 "${SHA_COD_MACOS_ARM}" resource "tui" do - url "${BASE_URL}/deepseek-tui-macos-arm64", using: :nounzip + url "${BASE_URL}/codewhale-tui-macos-arm64", using: :nounzip sha256 "${SHA_TUI_MACOS_ARM}" end + resource "legacy-shim" do + url "${BASE_URL}/deepseek-macos-arm64", using: :nounzip + sha256 "${SHA_LEG_MACOS_ARM}" + end + resource "legacy-tui-shim" do + url "${BASE_URL}/deepseek-tui-macos-arm64", using: :nounzip + sha256 "${SHA_LEG_TUI_MACOS_ARM}" + end else - url "${BASE_URL}/deepseek-macos-x64", using: :nounzip - sha256 "${SHA_DISP_MACOS_X64}" + url "${BASE_URL}/codewhale-macos-x64", using: :nounzip + sha256 "${SHA_COD_MACOS_X64}" resource "tui" do - url "${BASE_URL}/deepseek-tui-macos-x64", using: :nounzip + url "${BASE_URL}/codewhale-tui-macos-x64", using: :nounzip sha256 "${SHA_TUI_MACOS_X64}" end + resource "legacy-shim" do + url "${BASE_URL}/deepseek-macos-x64", using: :nounzip + sha256 "${SHA_LEG_MACOS_X64}" + end + resource "legacy-tui-shim" do + url "${BASE_URL}/deepseek-tui-macos-x64", using: :nounzip + sha256 "${SHA_LEG_TUI_MACOS_X64}" + end end end on_linux do if Hardware::CPU.arm? - url "${BASE_URL}/deepseek-linux-arm64", using: :nounzip - sha256 "${SHA_DISP_LINUX_ARM}" + url "${BASE_URL}/codewhale-linux-arm64", using: :nounzip + sha256 "${SHA_COD_LINUX_ARM}" resource "tui" do - url "${BASE_URL}/deepseek-tui-linux-arm64", using: :nounzip + url "${BASE_URL}/codewhale-tui-linux-arm64", using: :nounzip sha256 "${SHA_TUI_LINUX_ARM}" end + resource "legacy-shim" do + url "${BASE_URL}/deepseek-linux-arm64", using: :nounzip + sha256 "${SHA_LEG_LINUX_ARM}" + end + resource "legacy-tui-shim" do + url "${BASE_URL}/deepseek-tui-linux-arm64", using: :nounzip + sha256 "${SHA_LEG_TUI_LINUX_ARM}" + end else - url "${BASE_URL}/deepseek-linux-x64", using: :nounzip - sha256 "${SHA_DISP_LINUX_X64}" + url "${BASE_URL}/codewhale-linux-x64", using: :nounzip + sha256 "${SHA_COD_LINUX_X64}" resource "tui" do - url "${BASE_URL}/deepseek-tui-linux-x64", using: :nounzip + url "${BASE_URL}/codewhale-tui-linux-x64", using: :nounzip sha256 "${SHA_TUI_LINUX_X64}" end + resource "legacy-shim" do + url "${BASE_URL}/deepseek-linux-x64", using: :nounzip + sha256 "${SHA_LEG_LINUX_X64}" + end + resource "legacy-tui-shim" do + url "${BASE_URL}/deepseek-tui-linux-x64", using: :nounzip + sha256 "${SHA_LEG_TUI_LINUX_X64}" + end end end def install - bin.install Dir["*"].first => "deepseek" - resource("tui").stage { bin.install Dir["*"].first => "deepseek-tui" } + bin.install Dir["*"].first => "codewhale" + resource("tui").stage { bin.install Dir["*"].first => "codewhale-tui" } + resource("legacy-shim").stage { bin.install Dir["*"].first => "deepseek" } + resource("legacy-tui-shim").stage { bin.install Dir["*"].first => "deepseek-tui" } end test do - system "#{bin}/deepseek", "--version" + system "#{bin}/codewhale", "--version" end end EOF diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4203a17c..45c212bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,8 @@ jobs: components: rustfmt - name: Check formatting run: cargo fmt --all -- --check + - name: Check provider registry drift + run: python3 scripts/check-provider-registry.py - name: Linux clippy location run: echo "Linux clippy/test gates run on CNB for mirrored fix/*, rebrand/*, work/v*, and main branches." diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 53fcd34a..035193ef 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -33,6 +33,10 @@ jobs: target: aarch64-unknown-linux-gnu binary: codewhale artifact_name: codewhale-linux-arm64 + - os: ubuntu-latest + target: riscv64gc-unknown-linux-gnu + binary: codewhale + artifact_name: codewhale-linux-riscv64 - os: macos-latest target: x86_64-apple-darwin binary: codewhale @@ -54,6 +58,10 @@ jobs: target: aarch64-unknown-linux-gnu binary: codewhale-tui artifact_name: codewhale-tui-linux-arm64 + - os: ubuntu-latest + target: riscv64gc-unknown-linux-gnu + binary: codewhale-tui + artifact_name: codewhale-tui-linux-riscv64 - os: macos-latest target: x86_64-apple-darwin binary: codewhale-tui @@ -84,8 +92,33 @@ jobs: sleep 15 done sudo apt-get install -y libdbus-1-dev pkg-config + - name: Install RISC-V cross-compilation toolchain + if: matrix.target == 'riscv64gc-unknown-linux-gnu' + run: | + # Install cross-compiler (available in standard repos) + sudo apt-get update + sudo apt-get install -y gcc-riscv64-linux-gnu libc6-dev-riscv64-cross + + # Add Ubuntu ports for riscv64 packages + . /etc/os-release + sudo tee /etc/apt/sources.list.d/riscv64.sources </dev/null 2>&1; then + sha="$(git rev-list -n 1 "${tag}")" + source_ref="${tag}" + else + # Tag doesn't exist yet — build from HEAD + sha="${GITHUB_SHA}" + source_ref="${GITHUB_SHA}" + echo "Tag ${tag} not found; building from ${source_ref} @ ${sha}" + fi else tag="${GITHUB_REF_NAME}" sha="${GITHUB_SHA}" @@ -109,6 +115,10 @@ jobs: target: aarch64-unknown-linux-gnu binary: codewhale artifact_name: codewhale-linux-arm64 + - os: ubuntu-latest + target: riscv64gc-unknown-linux-gnu + binary: codewhale + artifact_name: codewhale-linux-riscv64 - os: macos-latest target: x86_64-apple-darwin binary: codewhale @@ -130,6 +140,10 @@ jobs: target: aarch64-unknown-linux-gnu binary: codewhale-tui artifact_name: codewhale-tui-linux-arm64 + - os: ubuntu-latest + target: riscv64gc-unknown-linux-gnu + binary: codewhale-tui + artifact_name: codewhale-tui-linux-riscv64 - os: macos-latest target: x86_64-apple-darwin binary: codewhale-tui @@ -204,10 +218,34 @@ jobs: sleep 15 done sudo apt-get install -y libdbus-1-dev pkg-config + - name: Install RISC-V cross-compilation toolchain + if: matrix.target == 'riscv64gc-unknown-linux-gnu' + run: | + # Install cross-compiler (available in standard repos) + sudo apt-get update + sudo apt-get install -y gcc-riscv64-linux-gnu libc6-dev-riscv64-cross + + # Add Ubuntu ports for riscv64 packages + . /etc/os-release + sudo tee /etc/apt/sources.list.d/riscv64.sources < "$MANIFEST" + + bundle() { + local platform="$1" # linux-x64, linux-arm64, macos-x64, macos-arm64, windows-x64 + local cli_src="$2" # artifact name for codewhale binary + local tui_src="$3" # artifact name for codewhale-tui binary + local ext="$4" # tar.gz or zip + local variant="$5" # '' (standard) or 'portable' (Windows only, no install script) + shift 5 + + local dir="bundles/codewhale-${platform}${variant:+-}${variant}" + mkdir -p "$dir" + + # Copy binaries, stripping platform suffixes + local cli_dst="codewhale" + local tui_dst="codewhale-tui" + if [[ "$platform" == windows-* ]]; then + cli_dst="codewhale.exe" + tui_dst="codewhale-tui.exe" + fi + cp "artifacts/${cli_src}/${cli_src}" "$dir/${cli_dst}" + cp "artifacts/${tui_src}/${tui_src}" "$dir/${tui_dst}" + + # Add install script (standard variant only) + if [[ "$variant" != "portable" ]]; then + if [[ "$platform" == windows-* ]]; then + cp scripts/release/install.bat "$dir/" + # Convert line endings to CRLF for Windows + sed -i 's/$/\r/' "$dir/install.bat" 2>/dev/null || true + else + cp scripts/release/install.sh "$dir/" + chmod +x "$dir/install.sh" + fi + fi + + if [[ "$ext" == "zip" ]]; then + (cd bundles && zip -r "codewhale-${platform}${variant:+-}${variant}.zip" "codewhale-${platform}${variant:+-}${variant}/") + else + tar -czf "bundles/codewhale-${platform}${variant:+-}${variant}.tar.gz" -C bundles "codewhale-${platform}${variant:+-}${variant}/" + fi + + local archive="codewhale-${platform}${variant:+-}${variant}.${ext}" + sha256sum "bundles/${archive}" | awk '{printf "%s %s\n", $1, $2}' >> "$MANIFEST" + echo " Created bundles/${archive}" + } + + # Platform: linux-x64 + bundle linux-x64 \ + codewhale-linux-x64 codewhale-tui-linux-x64 tar.gz "" + + # Platform: linux-arm64 + bundle linux-arm64 \ + codewhale-linux-arm64 codewhale-tui-linux-arm64 tar.gz "" + + # Platform: linux-riscv64 + bundle linux-riscv64 \ + codewhale-linux-riscv64 codewhale-tui-linux-riscv64 tar.gz "" + + # Platform: macos-x64 + bundle macos-x64 \ + codewhale-macos-x64 codewhale-tui-macos-x64 tar.gz "" + + # Platform: macos-arm64 + bundle macos-arm64 \ + codewhale-macos-arm64 codewhale-tui-macos-arm64 tar.gz "" + + # Platform: windows-x64 (standard + portable) + bundle windows-x64 \ + codewhale-windows-x64.exe codewhale-tui-windows-x64.exe zip "" + bundle windows-x64 \ + codewhale-windows-x64.exe codewhale-tui-windows-x64.exe zip "portable" + + echo "" + echo "=== Archive checksums ===" + cat "$MANIFEST" + + - name: Upload bundle artifacts + uses: actions/upload-artifact@v4 + with: + name: codewhale-bundles + path: bundles/* + if-no-files-found: error + docker: needs: [build, resolve] if: ${{ !cancelled() && needs.build.result == 'success' }} @@ -292,8 +432,8 @@ jobs: cache-to: type=gha,mode=max release: - needs: [build, docker, resolve] - if: ${{ !cancelled() && needs.build.result == 'success' && needs.docker.result == 'success' }} + needs: [build, bundle, docker, resolve] + if: ${{ !cancelled() && needs.build.result == 'success' && needs.bundle.result == 'success' && needs.docker.result == 'success' }} runs-on: ubuntu-latest permissions: contents: write @@ -365,35 +505,53 @@ jobs: Both crates are required — `codewhale-cli` produces the `codewhale` dispatcher and `codewhale-tui` produces the interactive runtime that the dispatcher delegates to. Installing only one binary will fail at runtime with a `MISSING_COMPANION_BINARY` error. - ### Manual download + ### Manual download — platform archives (recommended) - **Both** binaries below must be downloaded for your platform and dropped into the same directory (e.g. `~/.local/bin/`): + Each archive below contains **both** the `codewhale` dispatcher and `codewhale-tui` runtime, plus an install script: - | Platform | Dispatcher | TUI runtime | + | Platform | Archive | Install script | |---|---|---| - | Linux x64 | `codewhale-linux-x64` | `codewhale-tui-linux-x64` | - | Linux ARM64 | `codewhale-linux-arm64` | `codewhale-tui-linux-arm64` | - | macOS x64 | `codewhale-macos-x64` | `codewhale-tui-macos-x64` | - | macOS ARM | `codewhale-macos-arm64` | `codewhale-tui-macos-arm64` | - | Windows x64 | `codewhale-windows-x64.exe` | `codewhale-tui-windows-x64.exe` | + | Linux x64 | `codewhale-linux-x64.tar.gz` | `install.sh` | + | Linux ARM64 | `codewhale-linux-arm64.tar.gz` | `install.sh` | + | Linux RISC-V | `codewhale-linux-riscv64.tar.gz` | `install.sh` | + | macOS x64 | `codewhale-macos-x64.tar.gz` | `install.sh` | + | macOS ARM | `codewhale-macos-arm64.tar.gz` | `install.sh` | + | Windows x64 | `codewhale-windows-x64.zip` | `install.bat` | + | Windows x64 (portable) | `codewhale-windows-x64-portable.zip` | — | - Then `chmod +x` both (Unix) and run `./codewhale`. + **Unix (Linux / macOS):** + ```bash + tar xzf codewhale-.tar.gz + cd codewhale- + ./install.sh + ``` - Legacy `deepseek-*` and `deepseek-tui-*` assets are also attached for one release cycle so that existing `deepseek update` invocations on v0.8.40 keep working; they install the deprecation shims, which forward to the canonical binaries. + **Windows:** + - Extract `codewhale-windows-x64.zip` + - Run `install.bat` (copies to `%USERPROFILE%\bin`) + - Add `%USERPROFILE%\bin` to your PATH + + The **portable** Windows archive skips the install script — extract and run from any directory. + + Individual binaries are also attached below for scripting and the npm wrapper. Legacy `deepseek-*` and `deepseek-tui-*` assets ship for one release cycle so that existing `deepseek update` invocations on v0.8.40 keep working; they install the deprecation shims, which forward to the canonical binaries. ### Verify (recommended) - Download `codewhale-artifacts-sha256.txt` from this Release and verify: + Download the checksum manifests from this Release and verify: ```bash - # Linux + # Linux — archive bundles + sha256sum -c codewhale-bundles-sha256.txt + + # Linux — individual binaries sha256sum -c codewhale-artifacts-sha256.txt # macOS + shasum -a 256 -c codewhale-bundles-sha256.txt shasum -a 256 -c codewhale-artifacts-sha256.txt ``` - The legacy `deepseek-artifacts-sha256.txt` is also attached for backward compatibility and contains the same hashes. + The legacy `deepseek-artifacts-sha256.txt` is also attached for backward compatibility and contains the same hashes as the canonical manifest. ## Changelog diff --git a/.gitignore b/.gitignore index 0668130d..879e0d91 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ dist/ # Generated outputs/ tmp/ +backup/ # Reference papers / large research blobs (keep locally if needed, don't ship) docs/DeepSeek_V4.pdf @@ -48,6 +49,7 @@ docs/*.pdf # Local dev scripts and temp files *.sh +*.cmd !scripts/** !.github/scripts/** test.txt @@ -100,6 +102,8 @@ apps/ # Maintainer-local SWE-bench scratch (instance workspaces, venvs, predictions, # Docker harness logs). Never published. .swebench/ +deep-swe/ +all_preds.jsonl # Agent handoffs and version-specific setup plans are working-state notes, not # public docs. Keep durable setup guidance in docs/runbooks instead. @@ -111,3 +115,4 @@ docs/*_PLAN.md # direnv .envrc .direnv +scripts/run_deep_swe.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f6fda0e4..924e4f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,218 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Composer text selection with copy/cut.** Mouse drag and Shift+Arrow + selection in the composer input box, with Ctrl+C copy and Ctrl+X cut + support. Home, End, Ctrl+A, and Ctrl+E now clear the selection (#2228). +- **Copy transcript without visual-wrap newlines.** Transcript copy now + strips visual-wrap column line breaks from paragraphs, producing clean + text for pasting into editors or prompts (#1906). +- **Configurable base URL in /config view.** The `/config` panel now + displays the effective DeepSeek base URL (#1967). +- **CNB mirror support for China-friendly downloads.** Added + `CODEWHALE_RELEASE_BASE_URL` and `CODEWHALE_USE_CNB_MIRROR` to + both npm install scripts and Rust self-updater (#2222). +- **[✓] completion markers.** Checklist, plan, and tool completion + markers now render as `[✓]` instead of `[x]` (#1935). + +### Changed + +- **Project context loading now logs the source file.** (#2227) +- **macOS onboarding and empty-state layout pinned to top** instead + of vertically centered (#1837). +- **State-root migration continues.** Migrated 15+ storage paths to + prefer `~/.codewhale` with `~/.deepseek` fallback (#2231). +- **READMEs updated for the CodeWhale rename.** All three READMEs now + reference canonical `~/.codewhale` paths. + +### Fixed + +- **Deadlock when spawning multiple concurrent sub-agents.** Replaced + `RwLock`-based serialisation with a `Semaphore(1)` (#1856). +- **Steered/queued messages now render in correct transcript order.** + `steer_user_message` now flushes the active cell before inserting (#2225). +- **Session save test updated for managed sessions directory.** (#2223). +- **Loop guard reports Failed on halt.** Turn outcome correctly reports + `Failed` instead of `Completed` when the loop guard trips (#1859). +- **DEEPSEEK_YOLO env honoured on startup.** The `--yolo` flag is now + correctly merged with the `DEEPSEEK_YOLO` environment variable (#1870). + +### Community + +Thanks to contributors whose PRs landed in this release: +**@Fire-dtx** (#1856), +**@imkingjh999** (#2228), +**@harvey2011888** (#1859), +**@victorcheng2333** (#1870), +**@IIzzaya** (#1935), +**@PurplePulse** (#1837), +**@cyq1017** (#1967), +**@knqiufan** (#1906). + +## [0.8.46] - 2026-05-26 + +### Added + +- **`CODEWHALE_*` env aliases.** `CODEWHALE_PROVIDER`, `CODEWHALE_MODEL`, + and `CODEWHALE_BASE_URL` are public product-scoped aliases that take + precedence over the legacy `DEEPSEEK_*` forms. The `DEEPSEEK_*` names + remain accepted for back-compat. +- **Platform archive bundles.** Release artifacts now ship as per-platform + archives (`tar.gz` for Linux/macOS, `.zip` for Windows) containing both + `codewhale` and `codewhale-tui` binaries plus an install script. No more + downloading two loose files and guessing which ones to pick (#2193). +- **Windows portable archive.** `codewhale-windows-x64-portable.zip` ships + the two binaries without an install script for USB-stick distribution + (#2193). +- **Web install download tile.** The website install page now shows a + platform-aware download tile with arch detection, SHA256 checksum + display, and China mirror links, instead of burying the download behind + the Cargo instructions (#2192). +- **Whale dark palette refresh.** Better contrast and layer separation + across the TUI color scheme (#2197). +- **Auto-collapse finished sub-agents.** Completed sub-agent sessions now + collapse automatically in the sidebar, reducing noise during long + sessions (#2195). +- **Shell-running status chip.** A `⏳ shell running` chip appears in the + TUI footer while background shell tasks are active (#2194). +- **Sandbox process hardening (Linux).** `PR_SET_DUMPABLE=0`, + `NO_NEW_PRIVS`, and `RLIMIT_CORE=0` are applied at shell startup to + harden child processes against inspection and privilege escalation + (#2183). +- **CONTRIBUTING.md cross-links.** Issue and PR templates are now + cross-linked from CONTRIBUTING.md to improve contributor onboarding + (#2203). + +### Changed + +- **DeepSeek-first focus.** v0.8.46 refocuses on delivering the + highest-quality experience on DeepSeek first. Additional first-class + provider paths are planned for v0.9.0 after the core DeepSeek workflow + is solid. + +### Fixed + +- **Model name casing preserved.** `normalize_model_name_for_provider` no + longer lowercases user-set model names such as `DeepSeek-V4-Flash`, + preventing API lookup failures on case-sensitive backends (#2109). +- **Esc in model picker applies selection.** Dismissing the model picker + with Esc now applies the last-highlighted choice instead of reverting + (#2196). +- **Web install downloads both binaries.** The `install-binary.tsx` + snippet now fetches both `codewhale` and `codewhale-tui`, fixing the + `MISSING_COMPANION_BINARY` trap on fresh npm installs (#2191). +- **`grep_files` skips large directories.** The pure-Rust search tool + now skips known-large directories (`.git`, `node_modules`, `target`) + before walking, preventing hangs on deep or slow filesystems. +- **Version-update hint uses semver.** The update notification in the + footer now compares versions semantically instead of lexicographically, + so `0.8.10 > 0.8.9` is recognized correctly. +- **CVE-2026-8723 in feishu-bridge.** Bumped `qs` to `>=6.15.2` in the + Feishu bridge integration (#2198). + +### Community + +Thanks to new contributors whose PRs landed in this release: +**@donglovejava** (#2154, #2163, #2166, #2167, #2168), +**@encyc** (#2152), +**@saieswar237** (#2178), +**@sximelon** (#2174), +**@nanookclaw** (#2135), +**@Sskift** (#2119), +**@xin1104** (#2105), +**@mrluanma** (#2059), +**@Lellansin** (#2055), +**@zhuangbiaowei** (#2145), +**@aboimpinto** (#1872), +and continuing contributors **@reidliu41**, **@cyq1017**, **@idling11**, +**@h3c-hexin**, **@wdw8276**, **@zlh124**, and **@jeoor**. + +## [0.8.45] - 2026-05-25 + +### Added + +- **RLM session objects.** `rlm_open` can now load `session://` refs, + exposing the active prompt, history, and session data as symbolic objects + inside RLM REPLs (#2047). +- **Command palette voice input.** The command palette can launch a configured + speech-to-text helper and show footer status while transcription runs + (#2047). +- **Moonshot/Kimi provider.** Moonshot/Kimi is now a first-class provider, + including API-key auth, model completion, CLI auth, secret-store + integration, and optional Kimi CLI credential reuse. +- **Deterministic whale-species sub-agent names.** Sub-agents now get stable, + human-readable whale-species nicknames (e.g. "Beluga", "Orca") while + preserving the raw agent ID in the popup (#2035, #2016). +- **`/balance` command scaffold.** Registered the `/balance` slash command + as a placeholder for future provider billing queries (#2035, #2019). +- **Readable `/restore` snapshot labels.** Snapshot labels now include the + originating user prompt so restore listings are easier to identify. Thanks + @idling11 (#2111). +- **Sidebar hover tooltips.** Truncated Work and Tasks sidebar lines now expose + their full text on hover. Thanks @idling11 (#2110). + +### Changed + +- **AGENTS.md is now maintainer-local.** The project instructions file no + longer ships as a tracked repo file; it lives in maintainer-local ignored + state (#2047). + +### Fixed + +- **Sub-agent completion handoff compatibility.** Completion handoffs now use a + chat-template-safe role and emit before terminal updates, fixing strict + OpenAI-compatible/self-hosted backends and preserving transcript ordering. + Thanks @h3c-hexin and @cyq1017 (#2057, #2120). +- **Self-hosted context budgeting.** Sub-500K self-hosted model windows now keep + a usable input budget instead of disabling preflight compaction after output + reservation underflow. Thanks @h3c-hexin (#2060). +- **Goal prompts start actionable.** Goal-start prompts now open in an + actionable state instead of requiring an extra nudge. Thanks @cyq1017 + (#2097). +- **Composer session title display.** The composer chrome shows the current + session title again and avoids grayscale luma overflow in debug builds. + Thanks @wdw8276 (#2108). +- **Approval prompts use a one-step confirmation flow.** Enter now commits the + selected approval option directly, destructive warnings remain visible, and + abort cancels the active turn instead of only denying the current tool call. + Thanks @reidliu41 (#2143). +- **Model picker selection survives Esc.** Dismissing the model picker with Esc + no longer loses the highlighted selection. Thanks @reidliu41 (#2056). +- **Moonshot/Kimi sessions launch from the dispatcher.** The `codewhale` + wrapper now includes Moonshot/Kimi in the TUI provider allowlist, so + `codewhale --provider moonshot --model kimi-k2.6` reaches the TUI instead of + stopping after config resolution. +- **Slash recovery no longer restores command tails in the composer.** + Resuming a session or recovering from a crash no longer leaves stale + slash-command text (e.g. `/sessions`) in the composer input (#2047, #2032). +- **Remembered tool approvals now update the live active turn.** + When the "remember" checkbox is set on an approval dialog, the active + turn's auto-approve flag flips immediately instead of waiting for the + next turn. Thanks @gaord (#2047, #2041). +- **YAML block scalars in SKILL.md frontmatter.** Multi-line descriptions + using `>` or `|` indicators are now parsed correctly — folded block + scalars join non-empty lines with spaces, literal scalars preserve + newlines, and all three chomping modes (strip/clip/keep) are supported. + Thanks @zlh124 (#1908, #1907). +- **User messages highlighted in the transcript.** User-authored messages + now render with a full-row background in the live TUI transcript, making + it easier to scan prior turns. Assistant and system messages are + unaffected. Thanks @reidliu41 (#1995, #1672). +- **Cancellable `list_dir` and `file_search`.** Long directory walks and + file searches now respond to user cancel/stop requests with a 30-second + fallback timeout, preventing the TUI from hanging on deep or slow + filesystems (#2035). + +### Community + +- **README contributor acknowledgements resynced.** The Thanks list now + includes the latest contributor rows for @donglovejava, @encyc, + @saieswar237, @sximelon, @nanookclaw, @Sskift, @xin1104, @mrluanma, + @Lellansin, and @zhuangbiaowei, while preserving the existing @jeoor + acknowledgement in the consolidated list. + ## [0.8.44] - 2026-05-24 ### Added @@ -4806,7 +5018,9 @@ Welcome — and thank you. - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.44...HEAD +[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.46...HEAD +[0.8.46]: https://github.com/Hmbown/CodeWhale/compare/v0.8.45...v0.8.46 +[0.8.45]: https://github.com/Hmbown/CodeWhale/compare/v0.8.44...v0.8.45 [0.8.44]: https://github.com/Hmbown/CodeWhale/compare/v0.8.43...v0.8.44 [0.8.43]: https://github.com/Hmbown/CodeWhale/compare/v0.8.42...v0.8.43 [0.8.42]: https://github.com/Hmbown/CodeWhale/compare/v0.8.41...v0.8.42 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 759b6d44..1cbc15b0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -184,6 +184,9 @@ these crates, including the bottom-up build order. ## Pull Request Guidelines +- Use the [pull request template](.github/PULL_REQUEST_TEMPLATE.md) when opening + a PR — it includes the Summary, Testing, and Checklist sections reviewers + expect - Keep PRs focused on a single change - Update documentation if needed - Add tests for new functionality @@ -217,7 +220,14 @@ cargo check ## Reporting Issues -When reporting issues, please include: +When reporting issues, please use one of the issue templates: + +- [Bug report](.github/ISSUE_TEMPLATE/bug_report.md) — for reproducible problems + or regressions +- [Feature request](.github/ISSUE_TEMPLATE/feature_request.md) — for ideas and + improvements + +Issue reports should include: - Operating system and version - Rust version (`rustc --version`) @@ -226,9 +236,17 @@ When reporting issues, please include: - Expected vs actual behavior - Relevant error messages or logs +## Security + +If you discover a security vulnerability, please do **not** open a public issue. +See [SECURITY.md](SECURITY.md) for the responsible disclosure process and +contact information. + ## Code of Conduct -Be respectful and inclusive. We welcome contributors of all backgrounds and experience levels. +Be respectful and inclusive. We welcome contributors of all backgrounds and +experience levels. See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for the full +code of conduct. ## License diff --git a/Cargo.lock b/Cargo.lock index 2d5bd8e1..fcd2407b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -803,7 +803,7 @@ checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" [[package]] name = "codewhale-agent" -version = "0.8.44" +version = "0.8.46" dependencies = [ "codewhale-config", "serde", @@ -811,7 +811,7 @@ dependencies = [ [[package]] name = "codewhale-app-server" -version = "0.8.44" +version = "0.8.46" dependencies = [ "anyhow", "axum", @@ -827,13 +827,16 @@ dependencies = [ "codewhale-tools", "serde", "serde_json", + "tempfile", "tokio", + "tower", "tower-http", + "uuid", ] [[package]] name = "codewhale-cli" -version = "0.8.44" +version = "0.8.46" dependencies = [ "anyhow", "chrono", @@ -844,10 +847,12 @@ dependencies = [ "codewhale-config", "codewhale-execpolicy", "codewhale-mcp", + "codewhale-release", "codewhale-secrets", "codewhale-state", "dirs", "reqwest", + "semver", "serde", "serde_json", "sha2 0.10.9", @@ -858,19 +863,20 @@ dependencies = [ [[package]] name = "codewhale-config" -version = "0.8.44" +version = "0.8.46" dependencies = [ "anyhow", "codewhale-secrets", "dirs", "serde", + "serde_json", "toml 0.9.11+spec-1.1.0", "tracing", ] [[package]] name = "codewhale-core" -version = "0.8.44" +version = "0.8.46" dependencies = [ "anyhow", "chrono", @@ -888,7 +894,7 @@ dependencies = [ [[package]] name = "codewhale-execpolicy" -version = "0.8.44" +version = "0.8.46" dependencies = [ "anyhow", "codewhale-protocol", @@ -897,7 +903,7 @@ dependencies = [ [[package]] name = "codewhale-hooks" -version = "0.8.44" +version = "0.8.46" dependencies = [ "anyhow", "async-trait", @@ -911,7 +917,7 @@ dependencies = [ [[package]] name = "codewhale-mcp" -version = "0.8.44" +version = "0.8.46" dependencies = [ "anyhow", "serde", @@ -920,15 +926,26 @@ dependencies = [ [[package]] name = "codewhale-protocol" -version = "0.8.44" +version = "0.8.46" dependencies = [ "serde", "serde_json", ] +[[package]] +name = "codewhale-release" +version = "0.8.46" +dependencies = [ + "anyhow", + "reqwest", + "semver", + "serde", + "serde_json", +] + [[package]] name = "codewhale-secrets" -version = "0.8.44" +version = "0.8.46" dependencies = [ "dirs", "keyring", @@ -941,7 +958,7 @@ dependencies = [ [[package]] name = "codewhale-state" -version = "0.8.44" +version = "0.8.46" dependencies = [ "anyhow", "chrono", @@ -953,7 +970,7 @@ dependencies = [ [[package]] name = "codewhale-tools" -version = "0.8.44" +version = "0.8.46" dependencies = [ "anyhow", "async-trait", @@ -966,7 +983,7 @@ dependencies = [ [[package]] name = "codewhale-tui" -version = "0.8.44" +version = "0.8.46" dependencies = [ "anyhow", "arboard", @@ -978,6 +995,7 @@ dependencies = [ "clap", "clap_complete", "codewhale-config", + "codewhale-release", "codewhale-secrets", "codewhale-tools", "colored", @@ -1032,7 +1050,7 @@ dependencies = [ [[package]] name = "codewhale-tui-core" -version = "0.8.44" +version = "0.8.46" [[package]] name = "colorchoice" @@ -2560,15 +2578,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "ioctl-rs" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" -dependencies = [ - "libc", -] - [[package]] name = "ipnet" version = "2.11.0" @@ -3094,20 +3103,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "nix" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" -dependencies = [ - "autocfg", - "bitflags 1.3.2", - "cfg-if", - "libc", - "memoffset 0.6.5", - "pin-utils", -] - [[package]] name = "nix" version = "0.28.0" @@ -3620,9 +3615,9 @@ checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" [[package]] name = "portable-pty" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" dependencies = [ "anyhow", "bitflags 1.3.2", @@ -3631,8 +3626,8 @@ dependencies = [ "lazy_static", "libc", "log", - "nix 0.25.1", - "serial", + "nix 0.28.0", + "serial2", "shared_library", "shell-words", "winapi", @@ -4090,6 +4085,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -4578,45 +4574,14 @@ dependencies = [ ] [[package]] -name = "serial" -version = "0.4.0" +name = "serial2" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" -dependencies = [ - "serial-core", - "serial-unix", - "serial-windows", -] - -[[package]] -name = "serial-core" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" dependencies = [ + "cfg-if", "libc", -] - -[[package]] -name = "serial-unix" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" -dependencies = [ - "ioctl-rs", - "libc", - "serial-core", - "termios 0.2.2", -] - -[[package]] -name = "serial-windows" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" -dependencies = [ - "libc", - "serial-core", + "windows-sys 0.61.2", ] [[package]] @@ -4960,9 +4925,9 @@ dependencies = [ [[package]] name = "tar" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", @@ -5005,15 +4970,6 @@ dependencies = [ "phf_codegen", ] -[[package]] -name = "termios" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" -dependencies = [ - "libc", -] - [[package]] name = "termios" version = "0.3.3" @@ -5052,7 +5008,7 @@ dependencies = [ "signal-hook", "siphasher", "terminfo", - "termios 0.3.3", + "termios", "thiserror 1.0.69", "ucd-trie", "unicode-segmentation", diff --git a/Cargo.toml b/Cargo.toml index cee78462..90b8a172 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/hooks", "crates/mcp", "crates/protocol", + "crates/release", "crates/secrets", "crates/state", "crates/tools", @@ -19,7 +20,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"] resolver = "2" [workspace.package] -version = "0.8.44" +version = "0.8.46" edition = "2024" # Rust 1.88 stabilized `let_chains` in `if`/`while` conditions, which the # codebase relies on extensively. Cargo enforces this so users on older @@ -37,10 +38,11 @@ chrono = { version = "0.4.43", features = ["serde"] } clap = { version = "4.5.54", features = ["derive"] } clap_complete = "4.5" dirs = "6.0.0" -reqwest = { version = "0.13.1", default-features = false, features = ["json", "rustls"] } +reqwest = { version = "0.13.1", default-features = false, features = ["json", "rustls", "socks"] } rusqlite = { version = "0.32.1", features = ["bundled"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" +semver = "1.0.28" thiserror = "2.0" tokio = { version = "1.49.0", features = ["full"] } toml = "0.9.7" diff --git a/README.ja-JP.md b/README.ja-JP.md index dc21668d..92bfd0cb 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -1,45 +1,41 @@ # 🐳 CodeWhale -> **DeepSeek ファーストで、オープンソースおよびオープンウェイトのコーディングモデルに向けたターミナルネイティブのコーディングエージェントです。DeepSeek V4 の 100 万トークンのコンテキストウィンドウとプレフィックスキャッシュ機能を中心に構築されています。単一のバイナリとして配布され、Node.js や Python のランタイムは不要です。MCP クライアント、サンドボックス、永続的なタスクキューも標準で同梱されています。** - -[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml) -[![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale) -[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli) -[![Sponsor](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/Hmbown) -[![DeepWiki](https://img.shields.io/badge/DeepWiki-Ask_AI-_.svg?style=flat&color=0052D9&labelColor=000000&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/Hmbown/CodeWhale) +> **このターミナルネイティブのコーディングエージェントは、DeepSeek V4 の 100 万トークンのコンテキストウィンドウとプレフィックスキャッシュ機能を中心に構築されています。`codewhale` ディスパッチャーと `codewhale-tui` ランタイムの Rust バイナリペアとして配布され、Node.js や Python のランタイムは不要です。MCP クライアント、サンドボックス、永続的なタスクキューも標準で同梱されています。** [English README](README.md) [简体中文 README](README.zh-CN.md) +[Tiếng Việt README](README.vi.md) -[インストール](#インストール) · [クイックスタート](#クイックスタート) · [ドキュメント](#ドキュメント) · [コントリビューション](#コントリビューション) · [サポート](#サポート) ## インストール -`codewhale` は自己完結型の Rust バイナリとして提供されており、**実行に Node.js や Python のランタイムは必要ありません。** すでにマシンにインストールされているものを選んでください。いずれの方法でも同じバイナリが `PATH` に配置されます。 +`codewhale` は自己完結型の Rust リリースバイナリのペアとしてインストールされます。`codewhale` はディスパッチャーで、同じ場所にある `codewhale-tui` ランタイムを起動して対話セッションを実行します。npm、Homebrew、Docker は両方を自動でインストールします。Cargo や手動インストールでは、両方を同じディレクトリ(通常は `PATH` 上のディレクトリ)に置いてください。実行に Node.js や Python のランタイムは不要です。 ```bash # 1. npm — すでに Node を使っているなら最も簡単。npm パッケージは -# GitHub Releases から対応するビルド済みバイナリをダウンロードする +# GitHub Releases から対応するビルド済みバイナリペアをダウンロードする # 薄いインストーラーであり、codewhale 本体に Node ランタイム依存を加えるものではありません。 npm install -g codewhale -# 2. Cargo — Node 不要。 +# 2. Cargo — Node 不要。2 つの crate を両方インストールします。 cargo install codewhale-cli --locked # `codewhale` (エントリーポイント) cargo install codewhale-tui --locked # `codewhale-tui` (TUI バイナリ) # 3. Homebrew — macOS パッケージマネージャ。 +# tap/formula 名は旧名のままですが、codewhale と codewhale-tui をインストールします。 brew tap Hmbown/deepseek-tui brew install deepseek-tui -# 4. 直接ダウンロード — Node もツールチェーンも不要。 +# 4. 直接ダウンロード — GitHub Releases のプラットフォームアーカイブ。 # https://github.com/Hmbown/CodeWhale/releases -# Linux x64/ARM64、macOS x64/ARM64、Windows x64 向けのビルド済みバイナリがあります。 +# アーカイブには codewhale と codewhale-tui とインストールスクリプトが含まれます。 +# 個別バイナリもスクリプト用に添付されています。手動ではペアを同じ場所に置いてください。 # 5. Docker — ビルド済みリリースイメージ。 docker volume create codewhale-home docker run --rm -it \ -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ - -v codewhale-home:/home/codewhale/.deepseek \ + -v codewhale-home:/home/codewhale/.codewhale \ -v "$PWD:/workspace" \ -w /workspace \ ghcr.io/hmbown/codewhale:latest @@ -57,40 +53,59 @@ cargo install codewhale-cli --locked --force cargo install codewhale-tui --locked --force ``` +> codewhale update は --proxy をサポートしており、プロキシ経由で更新できます +> 例: codewhale update --proxy https://localhost:7897 + +[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml) +[![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale) +[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli) +[![DeepWiki](https://img.shields.io/badge/DeepWiki-Ask_AI-_.svg?style=flat&color=0052D9&labelColor=000000&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/Hmbown/CodeWhale) + +Buy me a coffee + ![codewhale スクリーンショット](assets/screenshot.png) --- ## codewhale とは? -codewhale は、ターミナル内で完結するコーディングエージェントです。DeepSeek のフロンティアモデルがあなたのワークスペースに直接アクセスできるようにし、ファイルの読み取り・編集、シェルコマンドの実行、Web 検索、Git 管理、サブエージェントの統制などを、すべて高速でキーボード駆動の TUI を通じて行えます。 +モデルは質問に答えます。エージェントはタスクを完了します。その差がハーネス——モデルが迷走しないようにするルール、証拠、フィードバックのシステムです。 -**DeepSeek V4 向けに構築** (`deepseek-v4-pro` / `deepseek-v4-flash`)。100 万トークンのコンテキストウィンドウとネイティブの thinking-mode(思考連鎖)ストリーミングをサポートします。 +CodeWhale はそのハーネスであり、DeepSeek V4 を中心に構築され、3つの原則に導かれています: -### 主な機能 +| 原則 | 仕組み | +|---|---| +| **信頼から始める** | 毎ターン「A」で始まる——確実性より可能性、便利さより丁寧さ | +| **明確な管轄権** | 9階層の権威を持つ成文憲法。ユーザーの意図が古い指示より優先。検証が自信より優先。 | +| **再帰的改善** | V4 がハーネスの一部を書いた。ハーネスが改善されると V4 はより効果的になり、さらにハーネスを改善する。毎ターンがより強くなる。 | -- **モデル自動ルーティング** — `--model auto` / `/model auto` がターンごとにモデルと推論強度を選択 -- **Fin の高速経路** — thinking off の低コストな `deepseek-v4-flash` がルーティング、RLM 子呼び出し、要約、調整作業を担当 -- **ネイティブ RLM** (`rlm_open`/`rlm_eval`) — 永続 REPL セッションでバッチ解析を行い、`peek`、`search`、`chunk`、`sub_query_batch` などの補助関数を利用 -- **Thinking-mode ストリーミング** — モデルがタスクに取り組む様子をリアルタイムで観察し、思考連鎖の展開を追える -- **完全なツールスイート** — ファイル操作、シェル実行、Git、Web 検索/ブラウズ、apply-patch、サブエージェント、MCP サーバー -- **100 万トークンコンテキスト** — コンテキスト追跡、手動または設定ベースのコンパクション、プレフィックスキャッシュのテレメトリ -- **3 つのモード** — Plan(読み取り専用の探索)、Agent(承認ありのインタラクティブ)、YOLO(自動承認) -- **推論努力ティア** — `Shift + Tab` で `off → high → max` を切り替え -- **セッション保存/再開** — 長時間実行のセッションをチェックポイント化して再開可能 -- **ワークスペースのロールバック** — リポジトリの `.git` には触れずに、サイド Git によるターン前後のスナップショットを `/restore` と `revert_turn` で扱える -- **永続的タスクキュー** — 再起動を超えて生き残るバックグラウンドタスク。スケジュール自動化や長時間レビューなどに -- **HTTP/SSE ランタイム API** — `codewhale serve --http` でヘッドレスエージェントワークフローを実現 -- **MCP プロトコル** — Model Context Protocol サーバーに接続して拡張ツールを利用可能。詳細は [docs/MCP.md](docs/MCP.md) を参照 -- **LSP 診断** — rust-analyzer、pyright、typescript-language-server、gopls、clangd により、編集ごとにエラー/警告をインライン表示 -- **ユーザーメモリ** — クロスセッションの嗜好をシステムプロンプトに注入できる、オプションの永続メモファイル -- **ローカライズ済み UI** — `en`、`ja`、`zh-Hans`、`pt-BR` を自動検出 -- **ライブコスト追跡** — ターンごと/セッションごとのトークン使用量とコスト見積もり、キャッシュヒット/ミスの内訳 -- **スキルシステム** — GitHub から取得できる命令パック。初回起動時に `skill-creator`、`mcp-builder`、`documents`、`presentations`、`spreadsheets`、`pdf`、`feishu` などのスターターセットを同梱 +オープンソース、ターミナルネイティブ、`codewhale` / `codewhale-tui` の Rust バイナリペアとして提供されています。 + +## ハーネスの仕組み + +エージェントモデルは大規模な相反する情報を扱います:ユーザーの意図、プロジェクトルール、システムデフォルト、ツール出力、古いメモリが単一ターンで権威を競い合います。LLM が裁判官として機能するには管轄権が必要です——衝突したとき、どの情報源が勝つのか? + +CodeWhale は**憲法**(`prompts/base.md`)でこれに答えます。これは形式化された法の階層です——第七条は憲法自体の条項から前セッションのハンドオフまで、9 つの情報源をランク付けします。ユーザーの現在のメッセージは古いプロジェクト指示より上。ライブのツール出力は仮定より上。検証は自信より上。モデルは毎ターン明確な権威チェーンを継承し、どの指示に従うべきか推測する必要がありません。 + +7 つの条項が階層の上にあり、モデルのアイデンティティ、義務、エージェンシーを定義します:検証義務(第5条——すべての行動は証拠を残し、信念で成功を宣言しない)、協調の遺産(第6条——次の知性のためにワークスペースを可読に保つ)、真実優先条項(第2条——下位のルールで上書きできない)。 + +DeepSeek V4 のプレフィックスキャッシュがこれを実用的にします。憲法は長く詳細ですが、一度キャッシュされるとコールドリードの約 100 分の 1 のコストになります。モデルはそれを再帰的に参照し——RLM セッションを通じて覗き、スキャンし、クエリし——単一の暗記パスに頼るのではなく、必要に応じて情報を再訪します。それは閉じた本のテストよりも、開いた本のテストのように機能します。 + +権威構造が明示的であるため、失敗は隠されません。非ゼロの終了コード、ターン間に届く rust-analyzer からの型エラー、サンドボックス拒否——これらは修正ベクトルとしてフィードバックされます。モデルは自身のドリフトを使って自己修正します。 + +3 つのモードが行動空間を制御します。Plan は読み取り専用。Agent は破壊的操作を承認ゲートの背後に置きます。YOLO は信頼済みワークスペースで自動承認します。macOS Seatbelt はアクティブなサンドボックス;Linux Landlock は検出されるが未適用;Windows サンドボックスは未公開。 + +Fin——thinking off の安価な Flash 呼び出し——がターンごとにモデル自動ルーティングを処理します。`--model auto` がデフォルトです。 + +毎ターン side-git スナップショットをリポジトリの `.git` 外に記録。`/restore` と `revert_turn` がワークスペースを即座にロールバックします。 + +サブエージェントは並行実行(最大 20)。`agent_open` は即座に戻り;結果は完了センチネルとしてインラインで到着し、サマリー付き。完全なトランスクリプトは `agent_eval` を通じて境界付きハンドルに保持されます。[docs/SUBAGENTS.md](docs/SUBAGENTS.md) を参照。 + +その他の機能面:編集ごとの LSP 診断(rust-analyzer、pyright、typescript-language-server、gopls、clangd)、バッチ分析用 RLM セッション、MCP プロトコル、HTTP/SSE ランタイム API、永続タスクキュー、Zed 向け ACP アダプター、SWE-bench エクスポート、キャッシュヒット/ミス内訳付きライブコスト追跡。 --- -## 仕組み +## ハーネス `codewhale`(ディスパッチャー CLI)→ `codewhale-tui`(コンパニオンバイナリ)→ ratatui インターフェース ↔ 非同期エンジン ↔ OpenAI 互換のストリーミングクライアント。ツール呼び出しは型付きレジストリ(シェル、ファイル操作、Git、Web、サブエージェント、MCP、RLM)を経由してルーティングされ、結果はトランスクリプトへとストリーム返送されます。エンジンはセッション状態、ターン管理、永続タスクキューを管理し、LSP サブシステムは編集後の診断を次の推論ステップ前にモデルのコンテキストへ供給します。 @@ -106,14 +121,14 @@ codewhale --version codewhale --model auto ``` -ビルド済みバイナリは **Linux x64**、**Linux ARM64**(v0.8.8 以降)、**macOS x64**、**macOS ARM64**、**Windows x64** 向けに公開されています。その他のターゲット(musl、riscv64、FreeBSD など)は [ソースからのインストール](#install-from-source) または [docs/INSTALL.md](docs/INSTALL.md) を参照してください。 +ビルド済みバイナリペアとプラットフォームアーカイブは **Linux x64**、**Linux ARM64**(v0.8.8 以降)、**macOS x64**、**macOS ARM64**、**Windows x64** 向けに公開されています。その他のターゲット(musl、riscv64、FreeBSD など)は [ソースからのインストール](#install-from-source) または [docs/INSTALL.md](docs/INSTALL.md) を参照してください。 -初回起動時に [DeepSeek API キー](https://platform.deepseek.com/api_keys) の入力を求められます。キーは `~/.deepseek/config.toml` に保存されるため、OS のクレデンシャルプロンプトなしに任意のディレクトリから利用できます。 +初回起動時に [DeepSeek API キー](https://platform.deepseek.com/api_keys) の入力を求められます。キーは `~/.codewhale/config.toml`(旧 `~/.deepseek/config.toml` も互換性維持)に保存されるため、OS のクレデンシャルプロンプトなしに任意のディレクトリから利用できます。 事前に設定することもできます: ```bash -codewhale auth set --provider deepseek # ~/.deepseek/config.toml に保存 +codewhale auth set --provider deepseek # ~/.codewhale/config.toml に保存 export DEEPSEEK_API_KEY="YOUR_KEY" # 環境変数による代替方法。非対話シェルでは ~/.zshenv を使用 codewhale @@ -152,10 +167,15 @@ codewhale --version ### Windows(Scoop) -[Scoop](https://scoop.sh) は Windows のパッケージマネージャです。インストール後、次を実行してください: +[Scoop](https://scoop.sh) は Windows のパッケージマネージャです。`codewhale` +パッケージは Scoop main bucket にありますが、manifest は GitHub/npm/Cargo +リリースより遅れることがあります。先に更新し、インストール後に +`codewhale --version` で確認してください: ```bash -scoop install deepseek-tui +scoop update +scoop install codewhale +codewhale --version ``` @@ -199,6 +219,10 @@ codewhale --provider wanjie-ark --model deepseek-reasoner codewhale auth set --provider openrouter --api-key "YOUR_OPENROUTER_API_KEY" codewhale --provider openrouter --model deepseek/deepseek-v4-pro +# Xiaomi MiMo +codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_MIMO_API_KEY" +codewhale --provider xiaomi-mimo --model mimo-v2.5-pro + # Novita codewhale auth set --provider novita --api-key "YOUR_NOVITA_API_KEY" codewhale --provider novita --model deepseek/deepseek-v4-pro @@ -237,10 +261,10 @@ TUI 内では `/provider` でプロバイダーピッカー、`/model` でロー ```bash codewhale # インタラクティブ TUI codewhale "explain this function" # ワンショットプロンプト -codewhale exec --auto --output-format stream-json "fix this bug" # ツール自動承認付きの agentic exec +codewhale exec --auto --output-format stream-json "fix this bug" # NDJSON バックエンドストリーム codewhale exec --resume "follow up" # 非対話セッションを継続 codewhale --model deepseek-v4-flash "summarize" # モデルの上書き -codewhale --model auto "fix this bug" # モデルと推論強度を自動ルーティング +codewhale --model auto "fix this bug" # モデルと推論強度を自動選択 codewhale --yolo # ツールを自動承認 codewhale auth set --provider deepseek # API キーの保存 codewhale doctor # セットアップと接続性のチェック @@ -289,16 +313,11 @@ codewhale update # バイナリ更新の確認 | **Agent** 🤖 | デフォルトのインタラクティブモード — 承認ゲート付きのマルチステップなツール利用。モデルは `checklist_write` で作業を概説 | | **YOLO** ⚡ | 信頼できるワークスペースですべてのツールを自動承認。可視性のための計画とチェックリストは引き続き維持 | -モードとモデル自動ルーティングは別物です。`Tab` は Plan / Agent / YOLO -を切り替え、`/model auto` はモデルと thinking レベルを選びます。`/goal` -は現時点ではセッション目標と token 予算の追跡であり、将来の Goal -ワークサーフェスは `--model auto` とは別に扱います。 - --- ## 設定 -ユーザー設定: `~/.deepseek/config.toml`。プロジェクトオーバーレイ: `/.deepseek/config.toml`(拒否される項目: `api_key`、`base_url`、`provider`、`mcp_config_path`)。すべてのオプションは [config.example.toml](config.example.toml) にあります。 +ユーザー設定: `~/.codewhale/config.toml`(旧 `~/.deepseek/config.toml` も互換性維持)。プロジェクトオーバーレイ: `/.codewhale/config.toml`(旧 `/.deepseek/config.toml`)(拒否される項目: `api_key`、`base_url`、`provider`、`mcp_config_path`)。すべてのオプションは [config.example.toml](config.example.toml) にあります。 主な環境変数: @@ -309,15 +328,16 @@ codewhale update # バイナリ更新の確認 | `DEEPSEEK_HTTP_HEADERS` | 任意のモデルリクエストヘッダー | | `DEEPSEEK_MODEL` | デフォルトモデル | | `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | ストリームのアイドルタイムアウト秒数 | -| `DEEPSEEK_PROVIDER` | `codewhale`(デフォルト)、`nvidia-nim`、`openai`、`atlascloud`、`wanjie-ark`、`openrouter`、`novita`、`fireworks`、`sglang`、`vllm`、`ollama` | +| `DEEPSEEK_PROVIDER` | `codewhale`(デフォルト)、`nvidia-nim`、`openai`、`atlascloud`、`wanjie-ark`、`openrouter`、`xiaomi-mimo`、`novita`、`fireworks`、`sglang`、`vllm`、`ollama` | | `DEEPSEEK_PROFILE` | 設定プロファイル名 | | `DEEPSEEK_MEMORY` | `on` に設定するとユーザーメモリを有効化 | | `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | 信頼できるネットワークで非ローカル `http://` API ベース URL を許可 | -| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | プロバイダー認証 | +| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `XIAOMI_MIMO_API_KEY` / `MIMO_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | プロバイダー認証 | | `OPENAI_BASE_URL` / `OPENAI_MODEL` | 汎用 OpenAI 互換エンドポイントとモデル ID | | `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud エンドポイントとモデル上書き | | `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Wanjie Ark エンドポイントとモデル上書き | | `OPENROUTER_BASE_URL` | OpenRouter エンドポイント上書き | +| `XIAOMI_MIMO_BASE_URL` / `MIMO_BASE_URL` / `XIAOMI_MIMO_MODEL` / `MIMO_MODEL` | Xiaomi MiMo エンドポイントとモデル上書き | | `NOVITA_BASE_URL` | Novita エンドポイント上書き | | `FIREWORKS_BASE_URL` | Fireworks エンドポイント上書き | | `SGLANG_BASE_URL` | セルフホスト SGLang のエンドポイント | @@ -349,10 +369,10 @@ UI のロケールはモデルの言語とは別です。`settings.toml` で `lo ## 自分のスキルを公開する -codewhale はワークスペースのディレクトリ(`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills`)とグローバルな `~/.deepseek/skills` からスキルを発見します。各スキルは `SKILL.md` ファイルを持つディレクトリです: +codewhale はワークスペースのディレクトリ(`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills`)とグローバルな `~/.codewhale/skills`(旧 `~/.deepseek/skills` も互換性維持)からスキルを発見します。各スキルは `SKILL.md` ファイルを持つディレクトリです: ```text -~/.deepseek/skills/my-skill/ +~/.codewhale/skills/my-skill/ └── SKILL.md ``` @@ -396,18 +416,6 @@ description: DeepSeek にカスタムワークフローを実行させたいと --- -## サポート - -CodeWhale は MIT ライセンスで、利用やコントリビューションにスポンサーは必要ありません。 -継続的なメンテナンスを支援する最も分かりやすい方法は -[GitHub Sponsors](https://github.com/sponsors/Hmbown) です。単発の支援は -[Buy Me a Coffee](https://www.buymeacoffee.com/hmbown) からも行えます。 - -スポンサーは、リリースビルド、CI/ランタイムテスト、パッケージ公開、issue 対応とレビューに使うメンテナー時間を支えます。 -機能リクエスト、バグ報告、pull request にスポンサーは必要ありません。 - ---- - ## 謝辞 このプロジェクトは、増え続けるコントリビューターのコミュニティから助けを得て出荷されています: @@ -422,7 +430,7 @@ CodeWhale は MIT ライセンスで、利用やコントリビューション - **[toi500](https://github.com/toi500)** — Windows 貼り付け修正の報告 - **[xsstomy](https://github.com/xsstomy)** — ターミナル起動時の再描画報告 - **[melody0709](https://github.com/melody0709)** — スラッシュ接頭辞の Enter アクティベーション報告 -- **[lloydzhou](https://github.com/lloydzhou)** と **[jeoor](https://github.com/jeoor)** — コンパクションコストの報告と npm インストーラのストリーム一時停止競合修正 (#1860) +- **[lloydzhou](https://github.com/lloydzhou)** と **[jeoor](https://github.com/jeoor)** — コンパクションコストの報告 - **[Agent-Skill-007](https://github.com/Agent-Skill-007)** — README の明瞭化対応 (#685) - **[woyxiang](https://github.com/woyxiang)** — Windows Scoop インストールドキュメント (#696) - **[wangfeng](mailto:wangfengcsu@qq.com)** — 料金/割引情報の更新 (#692) @@ -430,108 +438,6 @@ CodeWhale は MIT ライセンスで、利用やコントリビューション - **Hafeez Pizofreude** — `fetch_url` の SSRF 保護と Star History チャート - **Unic (YuniqueUnic)** — スキーマ駆動の設定 UI(TUI + Web) - **Jason** — SSRF セキュリティの強化 -- **[dfwqdyl-ui](https://github.com/dfwqdyl-ui)** — モデル ID の大文字小文字互換性レポート (#729) -- **[Oliver-ZPLiu](https://github.com/Oliver-ZPLiu)** — `working...` 状態のバグレポート、Windows クリップボードフォールバック、MCP Streamable HTTP セッション修正、Homebrew tap 自動化 (#738, #850, #1643, #1631) -- **[reidliu41](https://github.com/reidliu41)** — 再開ヒント、ワークスペース信頼の永続化、Ollama プロバイダー対応、thinking-block ストリームの最終処理、CI キャッシュ強化、ストリーミングラップ、DeepSeek モデル補完、ヘルプ選択の改善 (#863, #870, #921, #1078, #1603, #1628, #1601, #1964) -- **[cyq1017](https://github.com/cyq1017)** — Unicode `git_status` パス、ローカル/設定スキル検出、モード切替トーストの重複防止 (#1953, #1956, #1957) -- **[xieshutao](https://github.com/xieshutao)** — プレーン Markdown スキルのフォールバック (#869) -- **[GK012](https://github.com/GK012)** — npm ラッパー `--version` フォールバック (#885) -- **[y0sif](https://github.com/y0sif)** — 直接子サブエージェント完了後の親ターンループ復帰 (#901) -- **[mac119](https://github.com/mac119)** と **[leo119](https://github.com/leo119)** — `codewhale update` コマンドのドキュメント (#838, #917) -- **[dumbjack](https://github.com/dumbjack)** — コマンド安全性の null バイト強化 (#706, #918) -- **macworkers** — フォーク確認と新しいセッション ID (#600, #919) -- **zero** と **[zerx-lab](https://github.com/zerx-lab)** — 通知条件設定と OSC 9 通知本文の拡充 (#820, #920) -- **[chnjames](https://github.com/chnjames)** — @mention 補完キャッシュ、設定リカバリ改善、Windows UTF-8 シェル出力 (#849, #927, #982, #1018) -- **[angziii](https://github.com/angziii)** — 設定安全性、非同期クリーンアップ、Docker 強化、コマンド安全性修正 (#822, #824, #827, #831, #833, #835, #837) -- **[elowen53](https://github.com/elowen53)** — UTF-8 デコードと決定論的テストカバレッジ (#825, #840) -- **[wdw8276](https://github.com/wdw8276)** — カスタムセッションタイトルの `/rename` コマンド (#836) -- **[banqii](https://github.com/banqii)** — `.cursor/skills` 検出パス対応 (#817) -- **[junskyeed](https://github.com/junskyeed)** — API リクエストの動的 `max_tokens` 計算 (#826) -- **[axobase001](https://github.com/axobase001)** — スナップショット孤児クリーンアップ、npm インストールガード、セッションテレメトリ修正、モデルスコープキャッシュクリア、シンボリックリンクスキル対応、npm ミラー迂回ガイダンス、子タスクのプロキシ保持 (#975, #1032, #1047, #1049, #1052, #1019, #1051, #1056, #1608) -- **[MengZ-super](https://github.com/MengZ-super)** — `/theme` コマンド基盤と SSE gzip/brotli 展開 (#1057, #1061) -- **[DI-HUO-MING-YI](https://github.com/DI-HUO-MING-YI)** — Plan モードの読み取り専用サンドボックス安全性修正 (#1077) -- **[bevis-wong](https://github.com/bevis-wong)** — ペースト Enter 自動送信の正確な再現 (#1073) -- **[Duducoco](https://github.com/Duducoco)** と **[AlphaGogoo](https://github.com/AlphaGogoo)** — スキルスラッシュメニューと `/skills` 範囲修正 (#1068, #1083) -- **[ArronAI007](https://github.com/ArronAI007)** — macOS Terminal.app と ConHost のウィンドウリサイズアーティファクト修正 (#993) -- **[THINKER-ONLY](https://github.com/THINKER-ONLY)** — OpenRouter とカスタムエンドポイントのモデル ID 保持 (#1066) -- **[Jefsky](https://github.com/Jefsky)** — DeepSeek エンドポイント修正レポート (#1079, #1084) -- **[wlon](https://github.com/wlon)** — NVIDIA NIM プロバイダー API キー優先度診断 (#1081) -- **[Horace Liu](https://github.com/liuhq)** — Nix パッケージ対応とインストールドキュメント (#1173) -- **[jieshu666](https://github.com/jieshu666)** — ターミナル再描画のちらつき軽減 (#1563) -- **[gordonlu](https://github.com/gordonlu)** — Windows Enter / CSI-u 入力修正 (#1612) -- **[mdrkrg](https://github.com/mdrkrg)** — 初回起動時の API キー欠落クラッシュ修正 (#1598) -- **[Aitensa](https://github.com/Aitensa)** — diff とページャー出力の CJK 折り返し対応 (#1622) -- **[qiyan233](https://github.com/qiyan233)** — レガシー DeepSeek CN プロバイダーエイリアス互換性 (#1645) -- **[zlh124](https://github.com/zlh124)** — WSL2/ヘッドレス起動レポートとクリップボード初期化修正 (#1772, #1773) -- **[aboimpinto](https://github.com/aboimpinto)** — Windows alt-screen ログ、Home/End コンポーザー、ランタイムログフォローアップ (#1774, #1776, #1748, #1749, #1782, #1783) -- **[LeoLin990405](https://github.com/LeoLin990405)** — プロバイダーモデル透過、推論リプレイ、thinking-only ターン、Windows 引用修正 (#1740, #1743, #1742, #1744) -- **[nightt5879](https://github.com/nightt5879)** — Ctrl+C プロンプト復元修正 (#1764) -- **[h3c-hexin](https://github.com/h3c-hexin)** — ストリーミングバッチツール呼び出し保存と CLI reasoning-effort 透過 (#1686, #1511) -- **[hxy91819](https://github.com/hxy91819)** — ツール結果整理時のプレフィックスキャッシュ保持 (#1514) -- **[JiarenWang](https://github.com/JiarenWang)** — Plan モード読み取り専用強制、承認引継ぎ最適化、Ctrl+H 削除修正、undo コンテキスト同期 (#1123, #962, #958, #1150) -- **[Liu-Vince](https://github.com/Liu-Vince)** — MCP ページネーション、マークダウンインデント保持、zh-Hans i18n 改善、環境変数ドキュメント (#1256, #1179, #1274, #1178) -- **[ChaceLyee2101](https://github.com/ChaceLyee2101)** — 推論トークンコスト集計と zh-Hans 自動 CNY 表示 (#1505, #1504) -- **[laoye2020](https://github.com/laoye2020)** — Catppuccin、Tokyo Night、Dracula、Gruvbox テーマと `/theme` ピッカー (#1534) -- **[punkcanyang](https://github.com/punkcanyang)** — Kitty (OSC 99) と Ghostty (OSC 777) デスクトップ通知対応 (#1426) -- **[Rene-Kuhm](https://github.com/Rene-Kuhm)** — スペイン語 (es-419) ラテンアメリカローカライズ (#1452) -- **[ComeFromTheMars](https://github.com/ComeFromTheMars)** — Shift+Up/Down トランスクリプトスクロールショートカット (#1432) -- **[sockerch](https://github.com/sockerch)** — 全スラッシュコマンドの拼音エイリアス (#1306) -- **[eltociear](https://github.com/eltociear)** — 日本語 README 翻訳 (#746) -- **[Ling](https://github.com/LING71671)** — `grep_files` キャンセルトークン対応と Ctrl+Z コンポーザー下書き復元 (#1839, #1911) -- **[Ben Younes](https://github.com/ousamabenyounes)** — Linux Wayland(非 wlroots)クリップボード対応 (#1938) -- **[Matt Van Horn](https://github.com/mvanhorn)** — Docker 初回起動権限修正とランタイム system prompt 回帰テスト (#1699, #1702) -- **[Kristopher Clark](https://github.com/krisclarkdev)** — compaction の user query 保持修正 (#1704) -- **[tdccccc](https://github.com/tdccccc)** — コンポーザースクロール修正と pager マウスホイール対応 (#1715, #1716) -- **[LittleBlacky](https://github.com/LittleBlacky)** — provider gated `reasoning_content` ストリーム修正 (#1680) -- **[Anaheim](https://github.com/AnaheimEX)** — `rlm_open` 空 source schema 検証レポート (#1712) -- **[THatch26](https://github.com/THatch26)** — ターミナル resize 後のページング修正 (#1724) -- **[Alvin](https://github.com/alvin1)** — Zed ACP id 互換性レポート (#1696) -- **[knqiufan](https://github.com/knqiufan)** — sub-agent ファイル書き込み委譲 (#1833) -- **[IIzzaya](https://github.com/IIzzaya)** — slash 補完の exact alias 優先アイデア (#1811) -- **[DC](https://github.com/duanchao-lab)** — ターミナル cleanup guard のアイデア (#1630) -- **[imkingjh999](https://github.com/imkingjh999)** — provider/model 切り替え修正 (#1642) -- **[Photo](https://github.com/eng2007)** — provider-aware `/model` picker catalog 作業 (#1201) -- **[chennest](https://github.com/chennest)** — diagnostics schema レポート (#1685) -- **[kunpeng-ai-lab](https://github.com/kunpeng-ai-lab)** — Windows コンポーザースクロール修正 (#1578) -- **[WuMing](https://github.com/asdfg314284230)** — Windows PowerShell ちらつき修正 (#1591) -- **[maker316](https://github.com/maker316)** — LoopGuard/checklist ループレポート (#1574) -- **[lalala](https://github.com/lalala-233)** — approval denial 回帰レポート (#1617) -- **[muyuliyan](https://github.com/muyuliyan)** — `pandoc_convert` 検証修正 (#1523) -- **[czf0718](https://github.com/czf0718)** — resize と turn-completion のちらつき修正 (#1537) -- **[MeAiRobot](https://github.com/MeAiRobot)** — toast がコンポーザー入力を覆う問題の修正 (#1485) -- **[tiger-dog](https://github.com/tiger-dog)** — approval modal 折りたたみと markdown identifier 修正 (#1455) -- **[MMMarcinho](https://github.com/MMMarcinho)** — opt-in `image_analyze` vision tool (#1467) -- **[lucaszhu-hue](https://github.com/lucaszhu-hue)** — AtlasCloud provider 統合 (#1436) -- **[sandofree](https://github.com/sandofree)** — Tavily と Bocha の `web_search` backend (#1294) -- **[zhuangbiaowei](https://github.com/zhuangbiaowei)** — `/change` release notes コマンド (#1416) -- **[NorethSea](https://github.com/NorethSea)** — updater companion binary refresh 修正 (#1492) -- **[Jianfengwu2024](https://github.com/Jianfengwu2024)** — Windows MSVC toolchain 環境保持 (#1487) -- **[Fire-dtx](https://github.com/Fire-dtx)** — npm postinstall recoverability 作業 (#1059) -- **[oooyuy92](https://github.com/oooyuy92)** — 長時間セッション palette 可読性レポート (#1070, #936) -- **[qinxianyuzou](https://github.com/qinxianyuzou)** — zh-Hans destructive approval 文言 (#1087, #1091) -- **[tyouter](https://github.com/tyouter)** — session title/history preview クリーンアップ (#1510) -- **[xulongzhe](https://github.com/xulongzhe)** — issue template と vision boundary follow-up (#1530, #1544) -- **[YaYII](https://github.com/YaYII)** — trusted media path 作業 (#1462) -- **[47Cid](https://github.com/47Cid)** と **[Jafar Akhondali](https://github.com/JafarAkhondali)** — 責任ある security disclosure と hardening レポート -- **[linzhiqin2003](https://github.com/linzhiqin2003)** — `--model auto` コスト節約バイアス、実行規律プロンプト、宣言的事実メモリ衛生 (#1385, #1384, #1381) -- **[lbcheng888](https://github.com/lbcheng888)** — 保存/復元間のコスト永続化とトランスクリプトスクロール修正 (#1192, #1211) -- **[pengyou200902](https://github.com/pengyou200902)** — UTF-8 安全メモリ切り捨て、切り捨てマーカー精度、キーバインドドキュメント (#968, #1122, #1095) -- **[CrepuscularIRIS](https://github.com/CrepuscularIRIS)** — Termius/SSH 向け低モーション検出と npx MCP サーバーサンドボックス修正 (#1479, #1346) -- **[sternelee](https://github.com/sternelee)** — DeepSeek プレフィックスキャッシュ安定性追跡 (#1517) -- **[Apeiron0w0](https://github.com/Apeiron0w0)** — Tabby ターミナルちらつきループの FocusGained デバウンス (#1560) -- **[greyfreedom](https://github.com/greyfreedom)** — 最新トランスクリプトへのジャンプボタン (#969) -- **[SamhandsomeLee](https://github.com/SamhandsomeLee)** — 明示的隠しファイルメンション補完 (#1270) -- **[dst1213](https://github.com/dst1213)** — クォータエラー HTTP 400 リトライ (#1203) -- **[fuleinist](https://github.com/fuleinist)** — `--yolo` フラグの CLI から TUI への転送 (#1233) -- **[heloanc](https://github.com/heloanc)** — Home/End キーコンポーザーサポート (#1246) -- **[jinpengxuan](https://github.com/jinpengxuan)** — オンボーディング中のアクティブプロバイダー認証情報保持 (#1265) -- **[lixiasky-back](https://github.com/lixiasky-back)** — 検証済み npm バイナリ採用 (#1339) -- **[J3y0r](https://github.com/J3y0r)** — ワークスペース切り替えコマンド (#1065) -- **[KhalidAlnujaidi](https://github.com/KhalidAlnujaidi)** — delegate スキルバンドル (#1144) -- **[Wenjunyun123](https://github.com/Wenjunyun123)** — ドキュメントアンカーオフセット保持 (#1282) -- **[whtis](https://github.com/whtis)** — zh-CN README ディスパッチャーパス同期 (#1235) -- **[aqilaziz](https://github.com/aqilaziz)** — memory スキルリンク修正 (#1095) -- **[wuwuzhijing](https://github.com/wuwuzhijing)** — rsproxy rustup 回避策インストールドキュメント (#1011) --- diff --git a/README.md b/README.md index 58975408..5d908629 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,20 @@ # CodeWhale -> DeepSeek-first agentic terminal for open source and open-weight coding models. It runs from the `codewhale` command, streams reasoning blocks, edits local workspaces with approval gates, and can auto-route each turn to the right DeepSeek model and thinking level. - -[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml) -[![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale) -[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli) -[![Sponsor](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/Hmbown) -[DeepWiki project index](https://deepwiki.com/Hmbown/CodeWhale) +> Terminal coding agent for DeepSeek V4. It runs from the `codewhale` command, streams reasoning blocks, edits local workspaces with approval gates, and includes an auto mode that chooses both model and thinking level per turn. [简体中文 README](README.zh-CN.md) [日本語 README](README.ja-JP.md) +[Tiếng Việt README](README.vi.md) -[Install](#install) · [Quickstart](#quickstart) · [Usage](#usage) · [Documentation](#documentation) · [Contributing](#contributing) · [Support](#support) ## Install -`codewhale` is distributed as Rust binaries: the dispatcher command -(`codewhale`) and the companion TUI runtime (`codewhale-tui`). Pick whichever -install path you already use; they all put the same commands on your `PATH`. -The npm package is an installer/wrapper for the release binaries, not the -agent runtime itself. +`codewhale` installs as a matched pair of self-contained Rust release binaries: +the `codewhale` dispatcher command and the sibling `codewhale-tui` runtime it +launches for interactive sessions. npm, Homebrew, and Docker install both for +you; Cargo and manual installs must put both binaries in the same directory +(normally a directory on your `PATH`). The npm package is only an +installer/wrapper for those release binaries; the agent does not run on Node. ```bash # 1. npm — easiest if you already use Node. The package downloads the @@ -33,18 +28,20 @@ cargo install codewhale-cli --locked # `codewhale` (entry point) cargo install codewhale-tui --locked # `codewhale-tui` (TUI binary) # 3. Homebrew — macOS package manager. +# The tap/formula name is legacy; it installs codewhale and codewhale-tui. brew tap Hmbown/deepseek-tui brew install deepseek-tui -# 4. Direct download — no package manager or toolchain. +# 4. Direct download — platform archive from GitHub Releases. # https://github.com/Hmbown/CodeWhale/releases -# Prebuilt for Linux x64/ARM64, macOS x64/ARM64, Windows x64. +# Archives include both codewhale and codewhale-tui plus an install script. +# Individual binaries are also attached for scripts; keep the pair together. # 5. Docker — prebuilt release image. docker volume create codewhale-home docker run --rm -it \ -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ - -v codewhale-home:/home/codewhale/.deepseek \ + -v codewhale-home:/home/codewhale/.codewhale \ -v "$PWD:/workspace" \ -w /workspace \ ghcr.io/hmbown/codewhale:latest @@ -69,44 +66,93 @@ cargo install codewhale-cli --locked --force cargo install codewhale-tui --locked --force ``` +> codewhale update now supports --proxy, update through a proxy +> eg: codewhale update --proxy https://localhost:7897 + +[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml) +[![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale) +[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli) +[DeepWiki project index](https://deepwiki.com/Hmbown/CodeWhale) + ![codewhale screenshot](assets/screenshot.png) --- ## What Is It? -CodeWhale is a DeepSeek-first coding agent for open source and open-weight models that runs in your terminal. It can read and edit files, run shell commands, search the web, manage git, and coordinate sub-agents from a keyboard-driven TUI. +A model answers a question. An agent finishes a task. The difference is +the harness — a system of rules, evidence, and feedback that keeps the +model oriented instead of drifting. -It is built around DeepSeek V4 (`deepseek-v4-pro` / `deepseek-v4-flash`), including 1M-token context windows, streaming reasoning blocks, and prefix-cache-aware cost reporting. +CodeWhale is that harness, built around DeepSeek V4 and guided by three ideas: -### Key Features +| Principle | How it works | +|---|---| +| **Start with trust** | Every turn begins with "A" — possibility before certainty, craft before convenience | +| **Clear jurisdiction** | A written Constitution with nine tiers of authority. User intent outranks stale instructions. Verification outranks confidence. | +| **Recursive improvement** | V4 helped write the harness. As the harness improves, V4 becomes more effective — and helps improve the harness further. Each turn starts stronger. | -- **Model auto-routing** — `--model auto` / `/model auto` chooses both the model and thinking level for each turn -- **Thinking-mode streaming** — see DeepSeek reasoning blocks as the model works -- **Full tool suite** — file ops, shell execution, git, web search/browse, apply-patch, sub-agents, MCP servers -- **1M-token context** — context tracking, manual or configured compaction, and prefix-cache telemetry -- **Prefix-cache stability tracking** — an optional `/statusline` footer chip surfaces how stable the cached prefix has been across recent turns so cost-busting edits are visible before they land -- **Three modes** — Plan (read-only explore), Agent (interactive with approval), YOLO (auto-approved) -- **Reasoning-effort tiers** — cycle through `off → high → max` with `Shift + Tab` -- **Session save/resume/fork** — checkpoint long-running sessions and fork saved conversations into sibling paths with parent lineage shown in the picker -- **Workspace rollback** — side-git pre/post-turn snapshots with `/restore` and `revert_turn`, without touching your repo's `.git` -- **OS-level sandbox** — Seatbelt on macOS, Landlock on Linux, Job Objects on Windows; shell commands run with workspace-scoped filesystem access only -- **Durable task queue** — background tasks can survive restarts -- **HTTP/SSE runtime API** — `codewhale serve --http` for headless agent workflows -- **MCP protocol** — connect to Model Context Protocol servers for extended tooling; please see [docs/MCP.md](docs/MCP.md) -- **Fin-powered seams** — cheap `deepseek-v4-flash` with thinking off handles routing, RLM child calls, summaries, and other fast coordination work -- **Native RLM** (`rlm_session_objects`/`rlm_open`/`rlm_eval`) — persistent REPL sessions for batched analysis with bounded helpers like `peek`, `search`, `chunk`, and `sub_query_batch`; active prompt/history objects are opened by symbolic refs instead of pasted into the parent transcript -- **LSP diagnostics** — inline error/warning surfacing after every edit via rust-analyzer, pyright, typescript-language-server, gopls, clangd -- **User memory** — optional persistent note file injected into the system prompt for cross-session preferences -- **Localized UI** — `en`, `ja`, `zh-Hans`, `pt-BR` with auto-detection -- **Live cost tracking** — per-turn and session-level token usage and cost estimates; cache hit/miss breakdown; CNY display when the session locale is `zh-Hans` -- **Skills system** — composable, installable instruction packs from GitHub; ships with a bundled starter set (`skill-creator`, `mcp-builder`, `plugin-creator`, `v4-best-practices`, `documents`, `presentations`, `spreadsheets`, `pdf`, `feishu`, `skill-installer`, `delegate`) so `/skills` is useful from first launch -- **Terminal-native notifications** — OSC 9 (iTerm2/WezTerm/Ghostty), OSC 99 (Kitty), OSC 777 (Ghostty), plus desktop notification fallback -- **Built-in theme picker** — Catppuccin, Tokyo Night, Dracula, Gruvbox alongside the original light/dark palettes; switch live with `/theme` +It's open source, terminal-native, and packaged as a matched `codewhale` / +`codewhale-tui` Rust binary pair. + +## How the Harness Works + +Agentic models deal with conflicting information at scale: user intent, +project rules, system defaults, tool output, and stale memory all compete +for authority in a single turn. LLM-as-a-judge needs jurisdiction — which +source wins when they disagree? + +CodeWhale answers this with a **Constitution** (`prompts/base.md`). It's a +formal hierarchy of law — Article VII ranks nine sources from the +Constitution's own articles down to prior-session handoffs. The user's +current message outranks stale project instructions. Live tool output +outranks assumptions. Verification outranks confidence. The model inherits +a clear chain of authority every turn and never has to guess which +directive to follow. + +Seven articles sit above the hierarchy, defining the model's identity, +duties, and agency: a verification mandate (Article V — every action leaves +evidence, never declare success on faith), a coordination legacy (Article +VI — leave the workspace legible for the next intelligence), and a +primacy-of-truth clause (Article II — no lower rule may override it). + +DeepSeek V4's prefix caching makes this practical. The Constitution is long +and detailed, but once cached it costs roughly 100× less per turn than a +cold read. The model references it recursively — peeking, scanning, and +querying through RLM sessions — revisiting information on demand rather +than relying on a single memorized pass. It performs more like an +open-book test than a closed one. + +Because the authority structure is explicit, failure isn't hidden. Non-zero +exit codes, type errors from rust-analyzer arriving between turns, sandbox +denials — these are fed back as correction vectors. The model uses its own +drift to self-correct. + +Three modes control the action space. Plan is read-only. Agent gates +destructive operations behind approval. YOLO auto-approves in trusted +workspaces. macOS Seatbelt is the active sandbox; Linux Landlock is +detected but not yet enforced; Windows sandboxing is not yet advertised. + +Fin — a cheap Flash call with thinking off — handles model auto-routing per +turn. `--model auto` is the default. + +Every turn records a side-git snapshot outside your repo's `.git`. +`/restore` and `revert_turn` roll back the workspace. + +Sub-agents run concurrently (up to 20). `agent_open` returns immediately; +results arrive inline as completion sentinels with a summary. Full +transcripts stay behind bounded handles through `agent_eval`. See +[docs/SUBAGENTS.md](docs/SUBAGENTS.md). + +The rest of the surface: LSP diagnostics after every edit (rust-analyzer, +pyright, typescript-language-server, gopls, clangd, jdtls, +vue-language-server), RLM sessions for batched analysis, MCP protocol, +HTTP/SSE runtime API, persistent task queue, ACP adapter for Zed, +SWE-bench export, and live cost tracking with cache hit/miss breakdowns. --- -## How It's Wired +## The Harness `codewhale` (dispatcher CLI) → `codewhale-tui` (companion binary) → ratatui interface ↔ async engine ↔ OpenAI-compatible streaming client. Tool calls route through a typed registry (shell, file ops, git, web, sub-agents, MCP, RLM) and results stream back into the transcript. The engine manages session state, turn tracking, the durable task queue, and an LSP subsystem that feeds post-edit diagnostics into the model's context before the next reasoning step. @@ -118,8 +164,8 @@ CodeWhale can dispatch multiple sub-agents that run in parallel — like a concu - **Non-blocking launch.** `agent_open` returns immediately. The child gets its own fresh context and tool registry and runs independently. The parent keeps working. - **Background execution.** Sub-agents execute concurrently (default cap: 10, configurable to 20). The engine manages the pool — no polling loop needed. -- **Completion notification.** When a sub-agent finishes, the runtime delivers a structured `` event with a summary, evidence list, and execution metrics. The parent model reads the `summary` field and integrates findings. -- **Bounded result retrieval.** Large transcripts are parked behind `var_handle` references. The model calls `handle_read` for slices, ranges, or JSONPath projections — keeping the parent context lean. +- **Completion notification.** When a sub-agent finishes, the runtime injects a `` sentinel into the parent's transcript. The human-readable summary — including the child's findings, changed files, and any risks — sits on the line immediately before the sentinel. The parent model reads that summary and integrates findings without an extra tool call. +- **Bounded result retrieval.** The full child transcript lives behind a `transcript_handle` accessible through `agent_eval`. When the summary isn't enough, the parent calls `handle_read` for slices, line ranges, or JSONPath projections — keeping the parent context lean without losing access to the details. See [docs/SUBAGENTS.md](docs/SUBAGENTS.md) for the full sub-agent reference. @@ -133,14 +179,14 @@ codewhale --version codewhale --model auto ``` -Prebuilt binaries are published for **Linux x64**, **Linux ARM64** (v0.8.8+), **macOS x64**, **macOS ARM64**, and **Windows x64**. For other targets (musl, riscv64, FreeBSD, etc.), see [Install from source](#install-from-source) or [docs/INSTALL.md](docs/INSTALL.md). +Prebuilt binary pairs and platform archives are published for **Linux x64**, **Linux ARM64** (v0.8.8+), **macOS x64**, **macOS ARM64**, and **Windows x64**. For other targets (musl, riscv64, FreeBSD, etc.), see [Install from source](#install-from-source) or [docs/INSTALL.md](docs/INSTALL.md). -On first launch you'll be prompted for your [DeepSeek API key](https://platform.deepseek.com/api_keys). The key is saved to `~/.deepseek/config.toml` so it works from any directory without OS credential prompts. +On first launch you'll be prompted for your [DeepSeek API key](https://platform.deepseek.com/api_keys). The key is saved to `~/.codewhale/config.toml` (legacy `~/.deepseek/config.toml` also supported) so it works from any directory without OS credential prompts. You can also set it ahead of time: ```bash -codewhale auth set --provider deepseek # saves to ~/.deepseek/config.toml +codewhale auth set --provider deepseek # saves to ~/.codewhale/config.toml codewhale auth status # shows the active credential source export DEEPSEEK_API_KEY="YOUR_KEY" # env var alternative; use ~/.zshenv for non-interactive shells @@ -168,18 +214,18 @@ Start with [docs/TENCENT_CLOUD_REMOTE_FIRST.md](docs/TENCENT_CLOUD_REMOTE_FIRST. then use [docs/TENCENT_LIGHTHOUSE_HK.md](docs/TENCENT_LIGHTHOUSE_HK.md) for the server runbook. -### Model Auto-Routing and Fin +### Auto Mode Use `codewhale --model auto` or `/model auto` when you want codewhale to decide how much model and reasoning power a turn needs. -Model auto-routing controls two settings together: +Auto mode controls two settings together: - Model: `deepseek-v4-flash` or `deepseek-v4-pro` - Thinking: `off`, `high`, or `max` -Before the real turn is sent, the app makes a small `deepseek-v4-flash` routing call with thinking off. That fast path is called **Fin**: a low-latency seam for model selection, summaries, RLM children, context maintenance, and other coordination work that should not spend a full reasoning turn. Fin looks at the latest request and recent context, then selects a concrete model and thinking level for the real request. Short/simple turns can stay on Flash with thinking off; coding, debugging, release work, architecture, security review, or ambiguous multi-step tasks can move up to Pro and/or higher thinking. +Before the real turn is sent, the app makes a small `deepseek-v4-flash` routing call with thinking off. That router looks at the latest request and recent context, then selects a concrete model and thinking level for the real request. Short/simple turns can stay on Flash with thinking off; coding, debugging, release work, architecture, security review, or ambiguous multi-step tasks can move up to Pro and/or higher thinking. -`--model auto` and `/model auto` are local to codewhale. The upstream API never receives `model: "auto"`; it receives the concrete model and thinking setting chosen for that turn. The TUI shows the selected route, and cost tracking is charged against the model that actually ran. If the Fin route fails or returns an invalid answer, the app falls back to a local heuristic. Sub-agents inherit model auto-routing unless you assign them an explicit model. +`auto` is local to codewhale. The upstream API never receives `model: "auto"`; it receives the concrete model and thinking setting chosen for that turn. The TUI shows the selected route, and cost tracking is charged against the model that actually ran. If the router call fails or returns an invalid answer, the app falls back to a local heuristic. Sub-agents inherit auto mode unless you assign them an explicit model. Use a fixed model or fixed thinking level when you want repeatable benchmarking, a strict cost ceiling, or a specific provider/model mapping. @@ -219,7 +265,7 @@ version with `codewhale --version`: ```bash scoop update -scoop install deepseek-tui +scoop install codewhale codewhale --version ``` @@ -250,9 +296,8 @@ Both binaries are required. Cross-compilation and platform-specific notes: [docs ### Other API Providers -Official DeepSeek remains the default and first-class path. Other providers are -additive, with OpenRouter starting from DeepSeek Pro/Flash before broader -open-model catalogs are enabled. +For the full shipped provider registry, including model IDs, auth variables, +base URLs, and capability boundaries, see [docs/PROVIDERS.md](docs/PROVIDERS.md). ```bash # NVIDIA NIM @@ -271,6 +316,10 @@ codewhale --provider wanjie-ark --model deepseek-reasoner codewhale auth set --provider openrouter --api-key "YOUR_OPENROUTER_API_KEY" codewhale --provider openrouter --model deepseek/deepseek-v4-pro +# Xiaomi MiMo +codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_MIMO_API_KEY" +codewhale --provider xiaomi-mimo --model mimo-v2.5-pro + # Novita codewhale auth set --provider novita --api-key "YOUR_NOVITA_API_KEY" codewhale --provider novita --model deepseek/deepseek-v4-pro @@ -283,11 +332,18 @@ codewhale --provider fireworks --model deepseek-v4-pro codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY" OPENAI_BASE_URL="https://openai-compatible.example/v4" codewhale --provider openai --model glm-5 +# Custom DeepSeek-compatible endpoint +DEEPSEEK_BASE_URL="https://your-provider.example/v1" \ + DEEPSEEK_MODEL="deepseek-ai/DeepSeek-V4-Pro" \ + codewhale --provider deepseek + # Self-hosted SGLang SGLANG_BASE_URL="http://localhost:30000/v1" codewhale --provider sglang --model deepseek-v4-flash # Self-hosted vLLM VLLM_BASE_URL="http://localhost:8000/v1" codewhale --provider vllm --model deepseek-v4-flash +# Trusted LAN vLLM over HTTP +DEEPSEEK_ALLOW_INSECURE_HTTP=1 VLLM_BASE_URL="http://192.168.0.110:8000/v1" codewhale --provider vllm --model deepseek-v4-flash # Self-hosted Ollama ollama pull codewhale-coder:1.3b @@ -311,17 +367,13 @@ interfaces, and extension points. ## Usage -All examples use `codewhale`. The short form `codew` works everywhere — -it's a silent alias that forwards to `codewhale`. - ```bash codewhale # interactive TUI codewhale "explain this function" # one-shot prompt -codewhale exec --auto --output-format stream-json "fix this bug" # agentic exec with tool auto-approvals -codewhale swebench run --instance-id --issue-file issue.md # write all_preds.jsonl for SWE-bench +codewhale exec --auto --output-format stream-json "fix this bug" # NDJSON backend stream codewhale exec --resume "follow up" # continue a non-interactive session codewhale --model deepseek-v4-flash "summarize" # model override -codewhale --model auto "fix this bug" # auto-route model + thinking +codewhale --model auto "fix this bug" # auto-select model + thinking codewhale --yolo # auto-approve tools codewhale auth set --provider deepseek # save API key codewhale doctor # check setup & connectivity @@ -334,6 +386,7 @@ codewhale resume --last # resume the most recent sessi codewhale resume # resume a specific session by UUID codewhale fork # fork a saved session into a sibling path codewhale serve --http # HTTP/SSE API server +codewhale serve --mobile # LAN mobile control page; token-gated by default codewhale serve --acp # ACP stdio adapter for Zed/custom agents codewhale run pr # fetch PR and pre-seed review prompt codewhale mcp list # list configured MCP servers @@ -362,7 +415,7 @@ docker volume create codewhale-home docker run --rm -it \ -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ - -v codewhale-home:/home/codewhale/.deepseek \ + -v codewhale-home:/home/codewhale/.codewhale \ -v "$PWD:/workspace" \ -w /workspace \ ghcr.io/hmbown/codewhale:latest @@ -371,23 +424,6 @@ docker run --rm -it \ See [docs/DOCKER.md](docs/DOCKER.md) for pinned tags, local image builds, volume ownership notes, and non-interactive pipeline usage. -### SWE-bench - -CodeWhale can emit SWE-bench-compatible prediction JSONL from a checked-out -task workspace: - -```bash -codewhale swebench run \ - --instance-id django__django-12345 \ - --issue-file issue.md \ - --predictions-path all_preds.jsonl -``` - -`run` uses the same tool-backed automation path as `codewhale exec --auto`, -then exports the final working-tree diff as `model_patch`. Use -`codewhale swebench export --instance-id ` when you have already produced -the diff yourself. See [docs/SWEBENCH.md](docs/SWEBENCH.md) for the full flow. - ### Zed / ACP DeepSeek can run as a custom Agent Client Protocol server for editors that @@ -429,11 +465,6 @@ ACP workflows outside the built-in Zed slice. | `@path` | Attach file/directory context in composer | | `↑` (at composer start) | Select attachment row for removal | -Voice input is available from the command palette (`Ctrl+K`, then search -`Voice input`) after configuring `voice_input_command`; the helper -records/transcribes audio, CodeWhale shows a listening status while it runs, and -the final transcript is inserted into the composer for editing. - Full shortcut catalog: [docs/KEYBINDINGS.md](docs/KEYBINDINGS.md). --- @@ -446,17 +477,17 @@ Full shortcut catalog: [docs/KEYBINDINGS.md](docs/KEYBINDINGS.md). | **Agent** 🤖 | Default interactive mode — multi-step tool use with approval gates; substantial work is tracked with `checklist_write` | | **YOLO** ⚡ | Auto-approve all tools in a trusted workspace; multi-step work still keeps a visible checklist | -Modes are separate from model auto-routing. `Tab` cycles Plan / Agent / YOLO, -while `/model auto` controls model and thinking selection. The `/goal` command -tracks a session objective and token budget today; a fuller Goal work surface is -the right future home for persistent objective progress rather than another -meaning of "auto". - --- ## Configuration -User config: `~/.deepseek/config.toml`. Project overlay: `/.deepseek/config.toml` (denied: `api_key`, `base_url`, `provider`, `mcp_config_path`). [config.example.toml](config.example.toml) has every option. +User config: `~/.codewhale/config.toml` (legacy `~/.deepseek/config.toml` fallback). Project overlay: `/.codewhale/config.toml` (legacy `/.deepseek/config.toml`) (denied: `api_key`, `base_url`, `provider`, `mcp_config_path`). [config.example.toml](config.example.toml) has every option. + +Custom DeepSeek-compatible endpoints usually do not need a new provider. Keep +`provider = "deepseek"` and set `[providers.deepseek].base_url` / `model`, or +use `provider = "openai"` for generic OpenAI-compatible gateways. Keep +`provider`, `api_key`, and `base_url` in user config or environment variables; +project overlays cannot set them. Key environment variables: @@ -467,15 +498,16 @@ Key environment variables: | `DEEPSEEK_HTTP_HEADERS` | Optional custom model request headers, e.g. `X-Model-Provider-Id=your-model-provider` | | `DEEPSEEK_MODEL` | Default model | | `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | Stream idle timeout in seconds, default `300`, clamped to `1..=3600` | -| `DEEPSEEK_PROVIDER` | `codewhale` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `sglang`, `vllm`, `ollama` | +| `CODEWHALE_PROVIDER` / `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, `ollama` | | `DEEPSEEK_PROFILE` | Config profile name | | `DEEPSEEK_MEMORY` | Set to `on` to enable user memory | | `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | Allow non-local `http://` API base URLs on trusted networks | -| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Provider auth | +| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `VOLCENGINE_API_KEY` / `OPENROUTER_API_KEY` / `XIAOMI_MIMO_API_KEY` / `MIMO_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Provider auth | | `OPENAI_BASE_URL` / `OPENAI_MODEL` | Generic OpenAI-compatible endpoint and model ID | | `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud endpoint and model override | | `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Wanjie Ark endpoint and model override | | `OPENROUTER_BASE_URL` | OpenRouter endpoint override | +| `XIAOMI_MIMO_BASE_URL` / `MIMO_BASE_URL` / `XIAOMI_MIMO_MODEL` / `MIMO_MODEL` | Xiaomi MiMo endpoint and model override | | `NOVITA_BASE_URL` | Novita endpoint override | | `FIREWORKS_BASE_URL` | Fireworks endpoint override | | `SGLANG_BASE_URL` | Self-hosted SGLang endpoint | @@ -509,7 +541,7 @@ Legacy aliases `deepseek-chat` / `deepseek-reasoner` map to `deepseek-v4-flash` ## Publishing Your Own Skill -codewhale discovers skills from workspace directories (`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills` → `.cursor/skills`) and global directories (`~/.agents/skills` → `~/.claude/skills` → `~/.deepseek/skills`). Each skill is a directory with a `SKILL.md` file: +codewhale discovers skills from workspace directories (`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills` → `.cursor/skills`) and global directories (`~/.agents/skills` → `~/.claude/skills` → `~/.codewhale/skills` → `~/.deepseek/skills`). Each skill is a directory with a `SKILL.md` file: ```text ~/.agents/skills/my-skill/ @@ -534,7 +566,7 @@ First launch also installs bundled system skills for common workflows: `skill-creator`, `delegate`, `v4-best-practices`, `plugin-creator`, `skill-installer`, `mcp-builder`, `documents`, `presentations`, `spreadsheets`, `pdf`, and `feishu`. These live under -`~/.deepseek/skills` and are versioned so new bundles are added on upgrade +`~/.codewhale/skills` (or legacy `~/.deepseek/skills`) and are versioned so new bundles are added on upgrade without recreating skills the user deliberately deleted. --- @@ -543,11 +575,13 @@ without recreating skills the user deliberately deleted. | Doc | Topic | |---|---| +| [GUIDE.md](docs/GUIDE.md) | First-run user guide | | [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Codebase internals | | [CONFIGURATION.md](docs/CONFIGURATION.md) | Full config reference | +| [PROVIDERS.md](docs/PROVIDERS.md) | Provider IDs, auth, model defaults, and capability metadata | | [MODES.md](docs/MODES.md) | Plan / Agent / YOLO modes | | [MCP.md](docs/MCP.md) | Model Context Protocol integration | -| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API server | +| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API server and mobile control page | | [INSTALL.md](docs/INSTALL.md) | Platform-specific install guide | | [DOCKER.md](docs/DOCKER.md) | GHCR image, volumes, and Docker usage | | [CNB_MIRROR.md](docs/CNB_MIRROR.md) | CNB mirror and China-friendly install notes | @@ -559,25 +593,11 @@ without recreating skills the user deliberately deleted. | [RELEASE_RUNBOOK.md](docs/RELEASE_RUNBOOK.md) | Release process | | [LOCALIZATION.md](docs/LOCALIZATION.md) | UI locale matrix & switching | | [OPERATIONS_RUNBOOK.md](docs/OPERATIONS_RUNBOOK.md) | Ops & recovery | -| [RECURSIVE_SELF_IMPROVEMENT.md](docs/RECURSIVE_SELF_IMPROVEMENT.md) | Copyable prompts for agent-assisted CodeWhale improvements | Full Changelog: [CHANGELOG.md](CHANGELOG.md). --- -## Support - -CodeWhale is MIT-licensed and usable without sponsorship. If it saves you time, -the clearest way to support ongoing maintenance is -[GitHub Sponsors](https://github.com/sponsors/Hmbown). One-time support is also -available through [Buy Me a Coffee](https://www.buymeacoffee.com/hmbown). - -Sponsorship helps cover release builds, CI/runtime testing, package publishing, -and maintainer time for issue triage and review. Feature requests, bug reports, -and pull requests do not require sponsorship. - ---- - ## Thanks - **[DeepSeek](https://github.com/deepseek-ai)** — thank you for the models and support that power every turn. 感谢 DeepSeek 提供模型与支持,让每一次交互成为可能。 @@ -597,15 +617,14 @@ This project ships with help from a growing community of contributors: - **[toi500](https://github.com/toi500)** — Windows paste fix report - **[xsstomy](https://github.com/xsstomy)** — Terminal startup repaint report - **[melody0709](https://github.com/melody0709)** — Slash-prefix Enter activation report -- **[lloydzhou](https://github.com/lloydzhou)** and **[jeoor](https://github.com/jeoor)** — Compaction cost reports and npm installer stream-pause race fix (#1860); lloydzhou also contributed deterministic environment context (#813, #922) and KV prefix-cache stabilisation (#1080) +- **[lloydzhou](https://github.com/lloydzhou)** and **[jeoor](https://github.com/jeoor)** — Compaction cost reports; lloydzhou also contributed deterministic environment context (#813, #922) and KV prefix-cache stabilisation (#1080) - **[Agent-Skill-007](https://github.com/Agent-Skill-007)** — README clarity pass (#685) - **[woyxiang](https://github.com/woyxiang)** — Windows install documentation (#696) - **[wangfeng](mailto:wangfengcsu@qq.com)** — Pricing/discount info update (#692) - **[zichen0116](https://github.com/zichen0116)** — CODE_OF_CONDUCT.md (#686) - **[dfwqdyl-ui](https://github.com/dfwqdyl-ui)** — model ID case-sensitivity compatibility report (#729) - **[Oliver-ZPLiu](https://github.com/Oliver-ZPLiu)** — stale `working...` state bug report, Windows clipboard fallback, MCP Streamable HTTP session fixes, and Homebrew tap automation (#738, #850, #1643, #1631) -- **[reidliu41](https://github.com/reidliu41)** — resume hint, workspace trust persistence, Ollama provider support, thinking-block stream finalization, CI cache hardening, streaming wrap, DeepSeek model completions, and help picker selection polish (#863, #870, #921, #1078, #1603, #1628, #1601, #1964) -- **[cyq1017](https://github.com/cyq1017)** — Unicode `git_status` paths, local/configured skill discovery, and mode-switch toast dedupe (#1953, #1956, #1957) +- **[reidliu41](https://github.com/reidliu41)** — resume hint, workspace trust persistence, Ollama provider support, thinking-block stream finalization, CI cache hardening, streaming wrap, and DeepSeek model completions (#863, #870, #921, #1078, #1603, #1628, #1601) - **[xieshutao](https://github.com/xieshutao)** — plain Markdown skill fallback (#869) - **[GK012](https://github.com/GK012)** — npm wrapper `--version` fallback (#885) - **[y0sif](https://github.com/y0sif)** — parent turn-loop wakeup after direct child sub-agent completion (#901) @@ -641,72 +660,16 @@ This project ships with help from a growing community of contributors: - **[aboimpinto](https://github.com/aboimpinto)** — Windows alt-screen logging, Home/End composer, and runtime log follow-ups (#1774, #1776, #1748, #1749, #1782, #1783) - **[LeoLin990405](https://github.com/LeoLin990405)** — provider model passthrough, reasoning replay, thinking-only turn, and Windows quoting fixes (#1740, #1743, #1742, #1744) - **[nightt5879](https://github.com/nightt5879)** — Ctrl+C prompt restore fix (#1764) -- **[h3c-hexin](https://github.com/h3c-hexin)** — streaming batch tool-call preservation and CLI reasoning-effort passthrough (#1686, #1511) -- **[hxy91819](https://github.com/hxy91819)** — prefix-cache preservation during tool-result pruning (#1514) -- **[JiarenWang](https://github.com/JiarenWang)** — Plan-mode read-only enforcement, approval-takeover clamping, Ctrl+H delete fix, and undo context sync (#1123, #962, #958, #1150) -- **[Liu-Vince](https://github.com/Liu-Vince)** — MCP pagination, markdown indentation preservation, zh-Hans i18n polish, and env-var documentation (#1256, #1179, #1274, #1178) -- **[linzhiqin2003](https://github.com/linzhiqin2003)** — `--model auto` cost-saving bias, execution-discipline prompts, and declarative-fact memory hygiene (#1385, #1384, #1381) -- **[lbcheng888](https://github.com/lbcheng888)** — cost persistence across save/restore and transcript scroll fix (#1192, #1211) -- **[pengyou200902](https://github.com/pengyou200902)** — UTF-8-safe memory truncation, truncation-marker precision, and keybinding docs (#968, #1122, #1095) -- **[ChaceLyee2101](https://github.com/ChaceLyee2101)** — reasoning-token cost tracking with auto-CNY on zh-Hans and zh-CN README sync (#1505, #1504) -- **[CrepuscularIRIS](https://github.com/CrepuscularIRIS)** — low-motion mode for Termius/SSH and npx MCP server sandbox fix (#1479, #1346) -- **[laoye2020](https://github.com/laoye2020)** — Catppuccin, Tokyo Night, Dracula, and Gruvbox themes with `/theme` picker (#1534) -- **[punkcanyang](https://github.com/punkcanyang)** — Kitty (OSC 99) and Ghostty (OSC 777) desktop notification support (#1426) -- **[Rene-Kuhm](https://github.com/Rene-Kuhm)** — Spanish (es-419) Latin American localization (#1452) -- **[sternelee](https://github.com/sternelee)** — DeepSeek prefix-cache stability tracking (#1517) -- **[ComeFromTheMars](https://github.com/ComeFromTheMars)** — Shift+Up/Down transcript scroll shortcuts (#1432) -- **[sockerch](https://github.com/sockerch)** — pinyin aliases for all slash commands (#1306) -- **[Apeiron0w0](https://github.com/Apeiron0w0)** — FocusGained debounce for Tabby terminal flicker loop (#1560) -- **[greyfreedom](https://github.com/greyfreedom)** — jump-to-latest-transcript button (#969) -- **[SamhandsomeLee](https://github.com/SamhandsomeLee)** — explicit hidden-file mention completion (#1270) -- **[dst1213](https://github.com/dst1213)** — quota-error HTTP 400 retry (#1203) -- **[fuleinist](https://github.com/fuleinist)** — `--yolo` flag forwarding from CLI to TUI (#1233) -- **[heloanc](https://github.com/heloanc)** — Home/End key composer support (#1246) -- **[jinpengxuan](https://github.com/jinpengxuan)** — active provider credential preservation during onboarding (#1265) -- **[lixiasky-back](https://github.com/lixiasky-back)** — verified npm binary adoption (#1339) -- **[J3y0r](https://github.com/J3y0r)** — workspace-switch command (#1065) -- **[KhalidAlnujaidi](https://github.com/KhalidAlnujaidi)** — delegate skill bundling (#1144) -- **[Wenjunyun123](https://github.com/Wenjunyun123)** — docs anchor-offset preservation (#1282) -- **[whtis](https://github.com/whtis)** — zh-CN README dispatcher-path sync (#1235) -- **[aqilaziz](https://github.com/aqilaziz)** — memory skill-link fix (#1095) -- **[wuwuzhijing](https://github.com/wuwuzhijing)** — rsproxy rustup workaround install docs (#1011) -- **[eltociear](https://github.com/eltociear)** — Japanese README translation (#746) -- **[Ling](https://github.com/LING71671)** — `grep_files` cancellation-token support and Ctrl+Z composer-draft recovery (#1839, #1911) -- **[Ben Younes](https://github.com/ousamabenyounes)** — Linux Wayland (non-wlroots) clipboard support (#1938) -- **[Matt Van Horn](https://github.com/mvanhorn)** — Docker first-run permission fix and runtime system-prompt regression tests (#1699, #1702) -- **[Kristopher Clark](https://github.com/krisclarkdev)** — compaction user-query preservation fix (#1704) -- **[tdccccc](https://github.com/tdccccc)** — composer scroll fix and pager mouse-wheel support (#1715, #1716) -- **[LittleBlacky](https://github.com/LittleBlacky)** — provider-gated `reasoning_content` stream fix (#1680) -- **[Anaheim](https://github.com/AnaheimEX)** — `rlm_open` blank-source schema validation report (#1712) -- **[THatch26](https://github.com/THatch26)** — terminal resize paging fix (#1724) -- **[Alvin](https://github.com/alvin1)** — Zed ACP id compatibility report (#1696) -- **[knqiufan](https://github.com/knqiufan)** — sub-agent file-write delegation work (#1833) -- **[IIzzaya](https://github.com/IIzzaya)** — exact-alias-first slash-completion ordering idea (#1811) -- **[DC](https://github.com/duanchao-lab)** — terminal cleanup-guard idea (#1630) -- **[imkingjh999](https://github.com/imkingjh999)** — provider/model switching fixes (#1642) -- **[Photo](https://github.com/eng2007)** — provider-aware `/model` picker catalog work (#1201) -- **[chennest](https://github.com/chennest)** — diagnostics schema report (#1685) -- **[kunpeng-ai-lab](https://github.com/kunpeng-ai-lab)** — Windows composer scroll fix (#1578) -- **[WuMing](https://github.com/asdfg314284230)** — Windows PowerShell flicker fix (#1591) -- **[maker316](https://github.com/maker316)** — LoopGuard/checklist loop report (#1574) -- **[lalala](https://github.com/lalala-233)** — approval denial regression report (#1617) -- **[muyuliyan](https://github.com/muyuliyan)** — `pandoc_convert` validation fix (#1523) -- **[czf0718](https://github.com/czf0718)** — resize and turn-completion flicker fix (#1537) -- **[MeAiRobot](https://github.com/MeAiRobot)** — toast overlay composer-input fix (#1485) -- **[tiger-dog](https://github.com/tiger-dog)** — approval modal collapse and markdown identifier fixes (#1455) -- **[MMMarcinho](https://github.com/MMMarcinho)** — opt-in `image_analyze` vision tool (#1467) -- **[lucaszhu-hue](https://github.com/lucaszhu-hue)** — AtlasCloud provider integration (#1436) -- **[sandofree](https://github.com/sandofree)** — Tavily and Bocha `web_search` backends (#1294) -- **[zhuangbiaowei](https://github.com/zhuangbiaowei)** — `/change` release-notes command (#1416) -- **[NorethSea](https://github.com/NorethSea)** — updater companion-binary refresh fix (#1492) -- **[Jianfengwu2024](https://github.com/Jianfengwu2024)** — Windows MSVC toolchain environment preservation (#1487) -- **[Fire-dtx](https://github.com/Fire-dtx)** — npm postinstall recoverability work (#1059) -- **[oooyuy92](https://github.com/oooyuy92)** — long-session palette readability report (#1070, #936) -- **[qinxianyuzou](https://github.com/qinxianyuzou)** — zh-Hans destructive approval wording (#1087, #1091) -- **[tyouter](https://github.com/tyouter)** — session title/history preview cleanup (#1510) -- **[xulongzhe](https://github.com/xulongzhe)** — issue-template and vision-boundary follow-ups (#1530, #1544) -- **[YaYII](https://github.com/YaYII)** — trusted media path work (#1462) -- **[47Cid](https://github.com/47Cid)** and **[Jafar Akhondali](https://github.com/JafarAkhondali)** — responsible security disclosures and hardening reports +- **[donglovejava](https://github.com/donglovejava)** — paste @file consolidation, CJK panic fix, user feedback, RLM routing, edit_file retry (#2154–#2168) +- **[encyc](https://github.com/encyc)** — session token breakdown in footer and `/status` (#2152) +- **[saieswar237](https://github.com/saieswar237)** — review pipeline docs (#2178) +- **[sximelon](https://github.com/sximelon)** — paste Enter suppression, key handler extraction (#2174, #2042) +- **[nanookclaw](https://github.com/nanookclaw)** — search provider in doctor output (#2135) +- **[Sskift](https://github.com/Sskift)** — CLI default env override prevention (#2119) +- **[xin1104](https://github.com/xin1104)** — Homebrew codewhale binary install (#2105) +- **[mrluanma](https://github.com/mrluanma)** — Metaso search provider (#2059) +- **[Lellansin](https://github.com/Lellansin)** — skip config merge at home dir (#2055) +- **[zhuangbiaowei](https://github.com/zhuangbiaowei)** — update release channels (#2145) --- @@ -714,10 +677,7 @@ This project ships with help from a growing community of contributors: See [CONTRIBUTING.md](CONTRIBUTING.md). Pull requests welcome — check the [open issues](https://github.com/Hmbown/CodeWhale/issues) for good first contributions. -If you want CodeWhale to help improve CodeWhale, start with the -[recursive self-improvement prompt](docs/RECURSIVE_SELF_IMPROVEMENT.md). It is -designed to turn one DeepSeek V4 Pro session, or another capable open-weight -path, into one small, reviewable patch. +Support: [Buy me a coffee](https://www.buymeacoffee.com/hmbown). > [!Note] > *Not affiliated with DeepSeek Inc.* diff --git a/README.vi.md b/README.vi.md new file mode 100644 index 00000000..91f39d19 --- /dev/null +++ b/README.vi.md @@ -0,0 +1,593 @@ +# 🐳 CodeWhale + +> **Agent lập trình gốc terminal dành cho DeepSeek V4. Chương trình chạy từ lệnh `codewhale`, hỗ trợ stream các khối suy nghĩ (reasoning blocks), chỉnh sửa workspace cục bộ thông qua các lớp phê duyệt, và đi kèm chế độ tự động để tự chọn mô hình cũng như mức độ suy nghĩ phù hợp cho mỗi lượt.** + +[English README](README.md) +[简体中文 README](README.zh-CN.md) +[日本語 README](README.ja-JP.md) + +## Cài đặt + +`codewhale` được cài đặt dưới dạng một cặp binary tự chạy bằng Rust đồng bộ với nhau: +Lệnh điều phối `codewhale` (dispatcher) và môi trường chạy giao diện `codewhale-tui` (runtime) do nó khởi chạy để thực hiện các phiên làm việc tương tác. Các trình quản lý gói như npm, Homebrew, và Docker sẽ tự động cài đặt cả hai cho bạn; đối với Cargo hoặc cài đặt thủ công, bạn phải đặt cả hai tệp binary này trong cùng một thư mục (thông thường là một thư mục nằm trong biến môi trường `PATH` của bạn). Gói npm chỉ là một trình cài đặt/bao bọc (wrapper) cho các tệp binary phát hành này; agent không chạy trên môi trường Node.js. + +```bash +# 1. npm — dễ nhất nếu bạn đã cài đặt Node. Gói này sẽ tự động tải các +# binary Rust dựng sẵn tương ứng từ GitHub Releases. +npm install -g codewhale + +# 2. Cargo — không cần Node. Yêu cầu phiên bản Rust từ 1.88 trở lên (các crate sử dụng +# phiên bản Rust edition 2024; các toolchain cũ hơn sẽ báo lỗi "feature `edition2024` is +# required"). Hãy chạy lệnh `rustup update` trước, hoặc sử dụng các cách cài đặt không qua Cargo ở dưới. +cargo install codewhale-cli --locked # cài đặt `codewhale` (điểm truy cập CLI chính) +cargo install codewhale-tui --locked # cài đặt `codewhale-tui` (giao diện TUI) + +# 3. Homebrew — trình quản lý gói dành cho macOS. +# Tên tap/formula là tên cũ (legacy); nó sẽ cài đặt cả codewhale và codewhale-tui. +brew tap Hmbown/deepseek-tui +brew install deepseek-tui + +# 4. Tải xuống trực tiếp — các gói lưu trữ theo nền tảng từ GitHub Releases. +# https://github.com/Hmbown/CodeWhale/releases +# Gói nén bao gồm cả codewhale và codewhale-tui cùng một tập lệnh cài đặt. +# Các binary riêng lẻ cũng được đính kèm cho các tập lệnh; hãy giữ cặp này ở cùng một nơi. + +# 5. Docker — hình ảnh phát hành dựng sẵn. +docker volume create codewhale-home +docker run --rm -it \ + -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ + -v codewhale-home:/home/codewhale/.codewhale \ + -v "$PWD:/workspace" \ + -w /workspace \ + ghcr.io/hmbown/codewhale:latest +``` + +> Tại Trung Quốc đại lục, bạn có thể tăng tốc độ tải qua npm bằng tham số +> `--registry=https://registry.npmmirror.com`, hoặc sử dụng +> [Cargo mirror](#china--cai-dat-than-thien-qua-mirror) bên dưới. +> +> An toàn tải xuống: Các binary phát hành chính thức chỉ nằm tại +> `https://github.com/Hmbown/CodeWhale/releases`. Nếu tải thủ công, +> vui lòng xác minh mã băm SHA-256 manifest và tránh các kho lưu trữ giả mạo hoặc các +> trang web mirror trên kết quả tìm kiếm. Xem [an toàn tải xuống và mã xác thực](docs/INSTALL.md#2-download-safety-and-checksums). + +Đã cài đặt từ trước? Sử dụng lệnh cập nhật tương ứng với cách bạn đã cài đặt: + +```bash +codewhale update # trình cập nhật binary phát hành trực tiếp +npm install -g codewhale@latest # thông qua trình bao bọc npm +brew update && brew upgrade deepseek-tui +cargo install codewhale-cli --locked --force +cargo install codewhale-tui --locked --force +``` + +[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml) +[![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale) +[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli) +[Mục lục dự án DeepWiki](https://deepwiki.com/Hmbown/CodeWhale) + +![ảnh chụp màn hình codewhale](assets/screenshot.png) + +--- + +## CodeWhale là gì? + +Mô hình AI chỉ trả lời câu hỏi. Agent hoàn thành một nhiệm vụ. Sự khác biệt nằm ở +**khung ràng buộc (harness)** — một hệ thống các quy tắc, bằng chứng và phản hồi giúp giữ cho +mô hình đi đúng hướng thay vì bị trôi lệch mục tiêu. + +CodeWhale chính là khung ràng buộc đó, được xây dựng xung quanh DeepSeek V4 và được dẫn dắt bởi ba ý tưởng chính: + +| Nguyên tắc | Cách thức hoạt động | +|---|---| +| **Bắt đầu với sự tin tưởng** | Mỗi lượt bắt đầu bằng chữ "A" — tìm kiếm khả năng trước khi khẳng định chắc chắn, chú trọng chất lượng trước sự tiện lợi | +| **Thẩm quyền rõ ràng** | Một bản Hiến pháp bằng văn bản với chín cấp bậc thẩm quyền. Ý định của người dùng quan trọng hơn các hướng dẫn cũ kỹ. Sự xác minh quan trọng hơn sự tự tin. | +| **Cải tiến đệ quy** | V4 đã tham gia viết nên một phần của khung ràng buộc này. Khi khung ràng buộc tốt lên, V4 hoạt động hiệu quả hơn — và giúp cải tiến khung ràng buộc hơn nữa. Mỗi lượt chạy mới đều bắt đầu mạnh mẽ hơn. | + +Dự án này là mã nguồn mở, hoạt động trực tiếp trên terminal và được đóng gói thành một cặp binary Rust đồng bộ là `codewhale` / `codewhale-tui`. + +## Khung Ràng Buộc Hoạt Động Thế Nào? + +Các mô hình dạng Agent phải xử lý lượng thông tin xung đột rất lớn trên quy mô lớn: ý định của người dùng, quy tắc dự án, cấu hình mặc định của hệ thống, đầu ra của công cụ và bộ nhớ cũ đều cạnh tranh thẩm quyền trong một lượt chạy duy nhất. LLM hoạt động như một thẩm phán cần có thẩm quyền rõ ràng — nguồn thông tin nào sẽ thắng thế khi xảy ra xung đột? + +CodeWhale giải quyết vấn đề này bằng một bản **Hiến pháp** (`prompts/base.md`). Đây là một hệ thống phân cấp luật chính thức — Điều VII xếp hạng chín nguồn thông tin từ các điều khoản của chính Hiến pháp xuống đến thông tin bàn giao từ phiên làm việc trước. Tin nhắn hiện tại của người dùng có thẩm quyền cao hơn các hướng dẫn dự án cũ kỹ. Đầu ra trực tiếp từ công cụ có thẩm quyền cao hơn các giả định. Việc xác minh thực tế có thẩm quyền cao hơn sự tự tin của mô hình. Mô hình kế thừa một chuỗi thẩm quyền rõ ràng qua từng lượt và không bao giờ phải đoán xem nên làm theo chỉ thị nào. + +Có bảy điều khoản đứng đầu hệ thống phân cấp này, định nghĩa danh tính, nghĩa vụ và quyền hạn của mô hình: yêu cầu xác minh (Điều V — mọi hành động phải để lại bằng chứng thực tế, không bao giờ tuyên bố thành công dựa trên niềm tin mơ hồ), di sản điều phối (Điều VI — giữ cho workspace dễ đọc để trí tuệ tiếp theo có thể tiếp quản), và điều khoản ưu tiên sự thật (Điều II — không có quy tắc cấp dưới nào được phép ghi đè lên nó). + +Bộ nhớ đệm tiền tố (prefix caching) của DeepSeek V4 làm cho điều này trở nên khả thi và thực tế. Bản Hiến pháp rất dài và chi tiết, nhưng một khi đã được cache, nó sẽ tốn ít hơn khoảng 100 lần chi phí cho mỗi lượt so với một lần đọc mới hoàn toàn. Mô hình tham chiếu nó một cách đệ quy — xem qua, quét và truy vấn thông qua các phiên RLM — truy cập lại thông tin theo nhu cầu thay vì chỉ dựa trên một lượt ghi nhớ duy nhất. Nó hoạt động giống như một bài kiểm tra mở sách hơn là kiểm tra đóng sách. + +Bởi vì cấu trúc thẩm quyền là tường minh, các lỗi và thất bại không bao giờ bị che giấu. Các mã thoát (exit codes) khác không, lỗi kiểu dữ liệu từ rust-analyzer trả về giữa các lượt, từ chối của sandbox — tất cả đều được đưa ngược lại như các vectơ sửa lỗi. Mô hình sử dụng chính sự chệch hướng của mình để tự sửa sai. + +Ba chế độ kiểm soát không gian hành động: **Plan** là chế độ chỉ đọc. **Agent** chặn các thao tác can thiệp thay đổi file đằng sau quyền phê duyệt của người dùng. **YOLO** tự động phê duyệt tất cả các công cụ trong các workspace đáng tin cậy. Chế độ Sandbox hoạt động trên macOS Seatbelt; Linux Landlock đã được phát hiện nhưng chưa được áp dụng bắt buộc; chế độ sandboxing trên Windows hiện chưa được hỗ trợ. + +**Fin** — một cuộc gọi Flash giá rẻ và tắt chức năng suy nghĩ — xử lý việc tự động định tuyến mô hình cho mỗi lượt. Tham số mặc định là `--model auto`. + +Mỗi lượt chạy đều ghi lại một ảnh chụp nhanh side-git bên ngoài thư mục `.git` của repo. Các lệnh `/restore` và `revert_turn` giúp khôi phục nhanh workspace về trạng thái trước đó. + +Các sub-agent chạy đồng thời (tối đa 20). Lệnh `agent_open` trả về kết quả ngay lập tức; kết quả trả về nội tuyến dưới dạng các sentinel hoàn thành kèm theo bản tóm tắt. Nhật ký chi tiết của sub-agent được lưu trữ và truy cập thông qua `agent_eval`. Xem chi tiết tại [docs/SUBAGENTS.md](docs/SUBAGENTS.md). + +Các tính năng khác của hệ thống bao gồm: chẩn đoán lỗi LSP sau mỗi lần chỉnh sửa file (rust-analyzer, pyright, typescript-language-server, gopls, clangd), các phiên làm việc RLM để phân tích hàng loạt, giao thức MCP, API runtime HTTP/SSE, hàng đợi tác vụ liên tục, adapter ACP cho trình soạn thảo Zed, xuất kết quả định dạng SWE-bench và theo dõi chi phí trực tiếp với bảng phân tích chi tiết lượt hit/miss cache. + +--- + +## Khung Kết Nối (Harness) + +`codewhale` (CLI điều phối) → `codewhale-tui` (binary giao diện) → giao diện ratatui ↔ công cụ bất đồng bộ ↔ máy khách streaming tương thích với OpenAI. Các lượt gọi công cụ được định tuyến qua một registry có phân loại (shell, thao tác file, git, web, sub-agent, MCP, RLM) và kết quả được truyền trực tuyến trở lại transcript. Công cụ quản lý trạng thái phiên làm việc, theo dõi lượt chạy, hàng đợi tác vụ bền bỉ và một phân hệ LSP cung cấp thông tin chẩn đoán sau khi chỉnh sửa vào ngữ cảnh của mô hình trước bước suy nghĩ tiếp theo. + +Xem tài liệu [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) để biết chi tiết toàn bộ luồng hoạt động. + +### Sub-agents: Khởi chạy Tác vụ Nền Đồng thời + +CodeWhale có thể điều phối nhiều sub-agent chạy song song — hoạt động giống như một hàng đợi tác vụ đồng thời: + +- **Khởi chạy không chặn:** Lệnh `agent_open` trả về ngay lập tức. Sub-agent con có một ngữ cảnh độc lập mới và hệ thống đăng ký công cụ riêng để chạy tự chủ. Agent cha vẫn tiếp tục làm việc bình thường. +- **Thực thi dưới nền:** Các sub-agent chạy đồng thời (giới hạn mặc định: 10, có thể cấu hình lên đến 20). Hệ thống tự quản lý pool tài nguyên này mà không cần vòng lặp thăm dò (polling loop). +- **Thông báo hoàn thành:** Khi một sub-agent hoàn thành, hệ thống sẽ chèn một khóa sentinel `` vào transcript của agent cha. Một bản tóm tắt thân thiện với con người — bao gồm phát hiện của sub-agent con, các file đã thay đổi và các rủi ro có thể xảy ra — nằm ngay dòng phía trên khóa sentinel. Mô hình cha sẽ đọc tóm tắt đó và tích hợp kết quả thu được mà không cần phải thực hiện thêm bất kỳ lệnh gọi công cụ nào khác. +- **Truy xuất kết quả có giới hạn:** Nhật ký chi tiết của agent con nằm dưới dạng một `transcript_handle` có thể truy cập qua `agent_eval`. Khi bản tóm tắt là chưa đủ, agent cha có thể gọi `handle_read` để đọc một phần, các dòng cụ thể hoặc lọc qua JSONPath — giúp ngữ cảnh của agent cha luôn tinh gọn mà không làm mất đi các chi tiết quan trọng. + +Xem thêm tài liệu [docs/SUBAGENTS.md](docs/SUBAGENTS.md) để tham khảo thông tin đầy đủ về sub-agent. + +--- + +## Khởi động nhanh + +```bash +npm install -g codewhale +codewhale --version +codewhale --model auto +``` + +Cặp binary dựng sẵn và gói nén nền tảng được phát hành cho các kiến trúc **Linux x64**, **Linux ARM64** (từ v0.8.8 trở lên), **macOS x64**, **macOS ARM64**, và **Windows x64**. Đối với các mục tiêu khác (musl, riscv64, FreeBSD, v.v.), xem phần [Cài đặt từ nguồn](#install-from-source) hoặc tài liệu [docs/INSTALL.md](docs/INSTALL.md). + +Trong lần chạy đầu tiên, bạn sẽ được nhắc nhập [API key của DeepSeek](https://platform.deepseek.com/api_keys). Khóa này được lưu vào tệp cấu hình `~/.codewhale/config.toml` (tương thích cả tệp cũ `~/.deepseek/config.toml`) để nó hoạt động từ bất kỳ thư mục nào mà không cần nhắc thông tin đăng nhập của hệ điều hành. + +Bạn cũng có thể thiết lập trước: + +```bash +codewhale auth set --provider deepseek # lưu vào ~/.codewhale/config.toml +codewhale auth status # hiển thị nguồn thông tin đăng nhập đang hoạt động + +export DEEPSEEK_API_KEY="YOUR_KEY" # cách thiết lập qua biến môi trường thay thế; sử dụng ~/.zshenv cho terminal không tương tác +codewhale + +codewhale doctor # kiểm tra và xác minh thiết lập +``` + +Nếu lệnh `codewhale doctor` báo lỗi API key bị từ chối đến từ biến môi trường `DEEPSEEK_API_KEY`, hãy xóa cấu hình xuất biến môi trường cũ trong tệp khởi chạy shell của bạn, mở một shell mới hoặc chạy lệnh `codewhale auth set --provider deepseek`. Sử dụng `codewhale auth status` để xem trạng thái của cấu hình, keyring hệ thống và biến môi trường mà không hiển thị trực tiếp khóa API. Các khóa lưu trong file cấu hình sẽ được ưu tiên cao hơn keyring và môi trường để dễ dàng thay đổi khi cần. + +> Để thay đổi hoặc xóa khóa đã lưu: `codewhale auth clear --provider deepseek`. + +### Tencent Cloud / CNB Remote-First Path + +Đối với không gian làm việc luôn trực tuyến mà bạn có thể điều khiển từ điện thoại, hãy sử dụng đường dẫn gốc của Tencent: CNB mirror/source, Tencent Lighthouse HK, cầu kết nối dài hạn Feishu/Lark, và EdgeOne tùy chọn cho một cổng HTTPS công cộng có kiểm soát. API runtime luôn được giới hạn chạy tại localhost; EdgeOne không được sử dụng để hiển thị công khai đường dẫn `/v1/*`. + +Bắt đầu với tài liệu [docs/TENCENT_CLOUD_REMOTE_FIRST.md](docs/TENCENT_CLOUD_REMOTE_FIRST.md), sau đó xem thêm tài liệu [docs/TENCENT_LIGHTHOUSE_HK.md](docs/TENCENT_LIGHTHOUSE_HK.md) để biết các vận hành máy chủ. + +### Chế độ Tự động (Auto Mode) + +Sử dụng `codewhale --model auto` hoặc gõ lệnh `/model auto` khi bạn muốn hệ thống tự động quyết định sức mạnh của mô hình và cấp độ suy nghĩ cần thiết cho mỗi lượt. + +Chế độ tự động điều khiển hai cài đặt cùng nhau: + +- Mô hình: `deepseek-v4-flash` hoặc `deepseek-v4-pro` +- Cấp độ suy nghĩ: `off`, `high`, hoặc `max` + +Trước khi lượt gửi chính thức được thực hiện, ứng dụng sẽ thực hiện một cuộc gọi định tuyến nhỏ thông qua mô hình `deepseek-v4-flash` tắt chế độ suy nghĩ. Trình định tuyến đó sẽ đánh giá yêu cầu mới nhất và ngữ cảnh gần đây, từ đó chọn mô hình cụ thể và cấp độ suy nghĩ phù hợp cho lượt gọi thực tế. Các lượt tương tác ngắn/đơn giản sẽ được chạy trên mô hình Flash tắt suy nghĩ; các công việc lập trình phức tạp, gỡ lỗi, phát hành, kiến trúc phần mềm, kiểm tra bảo mật hoặc các tác vụ nhiều bước mơ hồ sẽ được đẩy lên mô hình Pro với cấp độ suy nghĩ cao hơn. + +Cơ chế `auto` hoạt động hoàn toàn cục bộ trên máy của bạn. API ở máy chủ upstream không bao giờ nhận được chuỗi `model: "auto"`; nó luôn nhận được mô hình cụ thể và cấu hình suy nghĩ đã được chọn cho lượt chạy đó. Giao diện TUI hiển thị tuyến đường định tuyến được chọn và bộ theo dõi chi phí sẽ tính tiền cho mô hình thực tế đã chạy. Nếu cuộc gọi định tuyến thất bại hoặc trả về câu trả lời không hợp lệ, ứng dụng sẽ chuyển sang thuật toán phỏng đoán cục bộ. Các sub-agent con sẽ kế thừa chế độ tự động này trừ khi bạn chỉ định rõ một mô hình cho chúng. + +Hãy chỉ định mô hình hoặc cấp độ suy nghĩ cố định nếu bạn muốn chạy benchmark lặp lại nhất quán, kiểm soát nghiêm ngặt chi phí trần hoặc có cấu hình ánh xạ nhà cung cấp/mô hình tùy chỉnh cụ thể. + +### Linux ARM64 (Raspberry Pi, Asahi, Graviton, HarmonyOS PC) + +Lệnh cài đặt `npm i -g codewhale` hoạt động trên môi trường Linux ARM64 nền glibc từ phiên bản v0.8.8 trở đi. Bạn cũng có thể tải trực tiếp các tệp binary dựng sẵn từ [trang phát hành Releases](https://github.com/Hmbown/CodeWhale/releases) và đặt chúng cạnh nhau trong một thư mục thuộc biến `PATH`. + +### Cài đặt thân thiện qua Mirror (Tại Trung Quốc) + +Nếu việc tải xuống từ GitHub hoặc npm bị chậm từ Trung Quốc đại lục, bạn hãy sử dụng mirror registry cho Cargo: + +```toml +# ~/.cargo/config.toml +[source.crates-io] +replace-with = "tuna" + +[source.tuna] +registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/" +``` + +Sau đó cài đặt cả hai binary (trình điều phối sẽ ủy quyền cho TUI tại thời điểm chạy): + +```bash +cargo install codewhale-cli --locked # cung cấp lệnh `codewhale` +cargo install codewhale-tui --locked # cung cấp giao diện `codewhale-tui` +codewhale --version +``` + +Các binary dựng sẵn cũng có thể được tải từ [GitHub Releases](https://github.com/Hmbown/CodeWhale/releases). Thiết lập biến `DEEPSEEK_TUI_RELEASE_BASE_URL` để sử dụng mirror tải các tệp tài nguyên phát hành. + +### Windows (Scoop) + +[Scoop](https://scoop.sh) là một trình quản lý gói phổ biến trên Windows. Gói `codewhale` đã được liệt kê trong bucket chính của Scoop, tuy nhiên gói cài đặt này hoạt động độc lập và đôi khi cập nhật chậm hơn các bản phát hành chính thức trên GitHub/npm/Cargo. Chạy lệnh `scoop update` trước, sau đó xác minh phiên bản đã cài bằng `codewhale --version`: + +```bash +scoop update +scoop install codewhale +codewhale --version +``` + +Vui lòng sử dụng phương pháp npm hoặc tải trực tiếp từ GitHub Releases nếu bạn muốn trải nghiệm phiên bản mới nhất trước khi Scoop cập nhật. + +
+Cài đặt từ mã nguồn + +Cách này hoạt động trên bất kỳ kiến trúc mục tiêu Tier-1 nào được Rust hỗ trợ — bao gồm cả musl, riscv64, FreeBSD và các bản phân phối ARM64 Linux cũ. + +```bash +# Các thư viện phụ thuộc để build trên Linux (Debian/Ubuntu/RHEL): +# sudo apt-get install -y build-essential pkg-config libdbus-1-dev +# sudo dnf install -y gcc make pkgconf-pkg-config dbus-devel + +git clone https://github.com/Hmbown/CodeWhale.git +cd CodeWhale + +cargo install --path crates/cli --locked # yêu cầu Rust 1.88+; cung cấp `codewhale` +cargo install --path crates/tui --locked # cung cấp giao diện `codewhale-tui` +``` + +Cả hai tệp binary đều bắt buộc phải cài đặt. Xem hướng dẫn biên dịch chéo và ghi chú riêng theo nền tảng tại: [docs/INSTALL.md](docs/INSTALL.md). + +
+ +### Các Nhà Cung Cấp API Khác + +Để xem danh sách đầy đủ tất cả các nhà cung cấp được hỗ trợ chính thức, bao gồm mã định danh mô hình, biến xác thực, URL cơ sở và ranh giới tính năng, xem thêm tài liệu [docs/PROVIDERS.md](docs/PROVIDERS.md). + +```bash +# NVIDIA NIM +codewhale auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY" +codewhale --provider nvidia-nim + +# AtlasCloud +codewhale auth set --provider atlascloud --api-key "YOUR_ATLASCLOUD_API_KEY" +codewhale --provider atlascloud + +# Wanjie Ark +codewhale auth set --provider wanjie-ark --api-key "YOUR_WANJIE_API_KEY" +codewhale --provider wanjie-ark --model deepseek-reasoner + +# OpenRouter +codewhale auth set --provider openrouter --api-key "YOUR_OPENROUTER_API_KEY" +codewhale --provider openrouter --model deepseek/deepseek-v4-pro + +# Novita +codewhale auth set --provider novita --api-key "YOUR_NOVITA_API_KEY" +codewhale --provider novita --model deepseek/deepseek-v4-pro + +# Fireworks +codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY" +codewhale --provider fireworks --model deepseek-v4-pro + +# Các endpoint tương thích định dạng OpenAI chung +codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY" +OPENAI_BASE_URL="https://openai-compatible.example/v4" codewhale --provider openai --model glm-5 + +# Tự host bằng SGLang +SGLANG_BASE_URL="http://localhost:30000/v1" codewhale --provider sglang --model deepseek-v4-flash + +# Tự host bằng vLLM +VLLM_BASE_URL="http://localhost:8000/v1" codewhale --provider vllm --model deepseek-v4-flash +# Sử dụng vLLM qua kết nối HTTP trong mạng LAN đáng tin cậy +DEEPSEEK_ALLOW_INSECURE_HTTP=1 VLLM_BASE_URL="http://192.168.0.110:8000/v1" codewhale --provider vllm --model deepseek-v4-flash + +# Tự host bằng Ollama +ollama pull codewhale-coder:1.3b +codewhale --provider ollama --model codewhale-coder:1.3b +``` + +Bên trong giao diện TUI, lệnh `/provider` mở bảng chọn nhà cung cấp và `/model` mở bảng chọn mô hình/cấp độ suy nghĩ cục bộ. Lệnh `/provider openrouter` và `/model ` chuyển đổi trực tiếp, trong khi lệnh `/models` sẽ truy vấn trực tiếp và hiển thị danh sách các mô hình API trực tuyến từ nhà cung cấp (nếu nhà cung cấp hỗ trợ tính năng liệt kê mô hình). + +--- + +## Nhật ký thay đổi (Release Notes) + +Chi tiết thay đổi giữa các phiên bản được cập nhật tại [CHANGELOG.md](CHANGELOG.md). File README này chỉ tập trung vào các đường dẫn cài đặt hiện tại, quy trình làm việc cốt lõi, thiết lập nhà cung cấp API, giao diện và các điểm mở rộng tính năng của dự án. + +--- + +## Cách sử dụng + +```bash +codewhale # giao diện tương tác TUI chính +codewhale "explain this function" # thực thi prompt nhanh một lượt +codewhale exec --auto --output-format stream-json "fix this bug" # truyền phát luồng dữ liệu NDJSON backend +codewhale exec --resume "follow up" # tiếp tục phiên làm việc không tương tác cũ +codewhale --model deepseek-v4-flash "summarize" # ghi đè mô hình chạy chỉ định +codewhale --model auto "fix this bug" # tự động chọn mô hình và cấp độ suy nghĩ thích hợp +codewhale --yolo # tự động phê duyệt chạy các công cụ +codewhale auth set --provider deepseek # lưu trữ API key +codewhale doctor # tự động kiểm tra cài đặt và kết nối mạng +codewhale doctor --json # trả về chuẩn đoán định dạng máy đọc được +codewhale setup --status # chỉ đọc trạng thái thiết lập hiện tại +codewhale setup --tools --plugins # tạo sẵn cấu trúc thư mục tool/plugin +codewhale models # liệt kê các mô hình khả dụng trực tuyến +codewhale sessions # liệt kê các phiên làm việc đã lưu +codewhale resume --last # tiếp tục phiên làm việc gần nhất trong thư mục này +codewhale resume # tiếp tục một phiên làm việc cụ thể theo mã UUID +codewhale fork # tạo một nhánh (fork) phiên làm việc đã lưu sang đường dẫn mới +codewhale serve --http # khởi chạy máy chủ API định dạng HTTP/SSE +codewhale serve --acp # khởi chạy adapter ACP qua stdio cho trình soạn thảo Zed/agent tùy chỉnh +codewhale run pr # tải PR về và nạp sẵn vào prompt đánh giá +codewhale mcp list # liệt kê các máy chủ MCP đã cấu hình +codewhale mcp validate # kiểm tra cấu hình và kết nối máy chủ MCP +codewhale mcp-server # khởi chạy máy chủ MCP điều phối qua cổng stdio +codewhale update # kiểm tra và cài đặt phiên bản binary mới nhất +``` + +### Tạo nhánh phiên làm việc (Branching) + +Các phiên làm việc được lưu có thể được phân nhánh một cách có chủ đích. Lệnh `codewhale fork ` sao chép toàn bộ phiên làm việc cũ sang một phiên mới song song, lưu trữ mã ID của phiên cha trong siêu dữ liệu (metadata) và mở phiên fork đó ra để bạn có thể thử nghiệm hướng phát triển mới mà không làm ảnh hưởng đến lịch sử phiên làm việc gốc. Trình chọn phiên làm việc và danh sách `codewhale sessions` sẽ đánh dấu rõ ràng các phiên được fork kèm theo mã ID của phiên cha. + +Bên trong giao diện TUI, bạn có thể nhấn phím `Esc` hai lần (`Esc-Esc`) để quay ngược lại transcript và đưa prompt cũ về lại phần soạn thảo để chỉnh sửa lại nội dung. Các lệnh `/restore` và `revert_turn` là công cụ khôi phục workspace độc lập: chúng khôi phục lại các tệp tin dựa trên ảnh chụp nhanh side-git nhưng không làm thay đổi hay ghi đè lịch sử trò chuyện của phiên làm việc. + +Các hình ảnh Docker được phát hành lên GHCR cho các bản dựng phát hành chính thức: + +```bash +docker volume create codewhale-home + +docker run --rm -it \ + -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ + -v codewhale-home:/home/codewhale/.codewhale \ + -v "$PWD:/workspace" \ + -w /workspace \ + ghcr.io/hmbown/codewhale:latest +``` + +Xem tài liệu [docs/DOCKER.md](docs/DOCKER.md) để biết thêm thông tin về thẻ phiên bản (pinned tags), cách tự dựng image cục bộ, lưu ý quyền sở hữu volume và cách sử dụng cho pipeline không tương tác. + +### Zed / ACP + +DeepSeek có thể chạy dưới dạng một máy chủ Agent Client Protocol (ACP) cục bộ cho các trình soạn thảo mã nguồn hỗ trợ giao tiếp ACP qua cổng stdio. Trong trình soạn thảo Zed, bạn hãy thêm cấu hình máy chủ agent tùy chỉnh sau: + +```json +{ + "agent_servers": { + "DeepSeek": { + "type": "custom", + "command": "codewhale", + "args": ["serve", "--acp"], + "env": {} + } + } +} +``` + +Phân hệ ACP ban đầu hỗ trợ khởi tạo phiên làm việc mới và nhận phản hồi prompt qua cấu hình và API key hiện tại của DeepSeek. Tính năng chỉnh sửa tích hợp công cụ và phát lại checkpoint hiện chưa được hỗ trợ qua giao diện ACP. + +Adapter do cộng đồng phát triển: [acp-codewhale-adapter](https://github.com/rockeverm3m/acp-codewhale-adapter) hỗ trợ cầu nối lệnh `codewhale exec --auto` với `cc-connect` cho người dùng cần quy trình làm việc ACP có tích hợp công cụ bên ngoài trình soạn thảo Zed. + +### Phím Tắt Tiêu Biểu + +| Phím | Hành động | +|---|---| +| `Tab` | Hoàn thành gợi ý lệnh `/` hoặc các nhãn tệp `@`; khi đang chạy, xếp tin nhắn nháp vào hàng đợi chạy tiếp theo; hoặc chuyển đổi qua lại giữa các chế độ | +| `Shift+Tab` | Thay đổi nhanh cấp độ suy nghĩ: off → high → max | +| `F1` | Mở màn hình trợ giúp phím tắt có thanh tìm kiếm | +| `Esc` | Quay lại / đóng cửa sổ popup | +| `Ctrl+K` | Mở bảng lệnh nhanh (Command palette) | +| `Ctrl+R` | Tiếp tục một phiên làm việc cũ | +| `Alt+R` | Tìm kiếm lịch sử prompt cũ để khôi phục tin nháp đã xóa | +| `Ctrl+S` | Cất tin nháp hiện tại vào bộ nhớ tạm (dùng `/stash list`, `/stash pop` để lấy lại) | +| `@path` | Đính kèm ngữ cảnh file hoặc thư mục trực tiếp tại trình soạn thảo văn bản | +| `↑` (tại đầu composer) | Chọn hàng tệp tin đính kèm để xóa | + +Xem danh sách phím tắt đầy đủ tại: [docs/KEYBINDINGS.md](docs/KEYBINDINGS.md). + +--- + +## Chế độ hoạt động (Modes) + +| Chế độ | Hành vi hoạt động | +| --- | --- | +| **Plan** 🔍 | Chế độ khảo sát chỉ đọc — mô hình tìm hiểu cấu trúc và đề xuất kế hoạch hành động cụ thể trước khi sửa đổi file; các cuộc khảo sát nhiều bước sử dụng công cụ `checklist_write` | +| **Agent** 🤖 | Chế độ tương tác mặc định — thực thi tác vụ nhiều bước có kiểm soát đằng sau các cổng phê duyệt; các tác vụ lớn sẽ được theo dõi qua `checklist_write` | +| **YOLO** ⚡ | Tự động phê duyệt tất cả các lệnh gọi công cụ trong các workspace tin cậy; các tác vụ nhiều bước vẫn duy trì checklist hiển thị trực quan | + +--- + +## Cấu hình + +Cấu hình của người dùng lưu tại: `~/.codewhale/config.toml` (tự động fallback về tệp cũ `~/.deepseek/config.toml` nếu có). Cấu hình riêng của dự án ghi đè tại: `/.codewhale/config.toml` (hoặc `/.deepseek/config.toml`) (lưu ý các trường sau bị cấm ghi đè ở cấp dự án: `api_key`, `base_url`, `provider`, `mcp_config_path`). Tham khảo tệp [config.example.toml](config.example.toml) để xem đầy đủ tất cả cấu hình mẫu. + +Các biến môi trường chính: + +| Biến môi trường | Mục đích sử dụng | +|---|---| +| `DEEPSEEK_API_KEY` | Khóa API key chính | +| `DEEPSEEK_BASE_URL` | Địa chỉ URL cơ sở của máy chủ API | +| `DEEPSEEK_HTTP_HEADERS` | Các header tùy chỉnh gửi kèm yêu cầu API, ví dụ `X-Model-Provider-Id=your-model-provider` | +| `DEEPSEEK_MODEL` | Mô hình mặc định | +| `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | Thời gian chờ tối đa khi stream bị rảnh (giây), mặc định là `300`, giới hạn trong khoảng `1..=3600` | +| `CODEWHALE_PROVIDER` / `DEEPSEEK_PROVIDER` | Các nhà cung cấp: `deepseek` (mặc định), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, `ollama` | +| `DEEPSEEK_PROFILE` | Tên cấu hình profile sử dụng | +| `DEEPSEEK_MEMORY` | Thiết lập là `on` để kích hoạt tính năng tự ghi nhớ thông tin người dùng | +| `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | Cho phép sử dụng các đường dẫn API dạng `http://` không mã hóa trong các mạng LAN tin cậy | +| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Thông tin đăng nhập theo từng nhà cung cấp tương ứng | +| `OPENAI_BASE_URL` / `OPENAI_MODEL` | Điểm cuối (endpoint) và mã mô hình cho nhà cung cấp tương thích định dạng OpenAI chung | +| `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | Endpoint và mô hình ghi đè cho AtlasCloud | +| `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Endpoint và mô hình ghi đè cho Wanjie Ark | +| `OPENROUTER_BASE_URL` | Endpoint ghi đè cho OpenRouter | +| `NOVITA_BASE_URL` | Endpoint ghi đè cho Novita | +| `FIREWORKS_BASE_URL` | Endpoint ghi đè cho Fireworks | +| `SGLANG_BASE_URL` | Endpoint cho máy chủ SGLang tự host | +| `SGLANG_MODEL` | Mã mô hình cho máy chủ SGLang tự host | +| `VLLM_BASE_URL` | Endpoint cho máy chủ vLLM tự host | +| `VLLM_MODEL` | Mã mô hình cho máy chủ vLLM tự host | +| `OLLAMA_BASE_URL` | Endpoint cho máy chủ Ollama tự host | +| `OLLAMA_MODEL` | Thẻ mô hình (model tag) cho máy chủ Ollama tự host | +| `NO_ANIMATIONS=1` | Bắt buộc chạy ở chế độ hỗ trợ khả năng tiếp cận (Accessibility mode), tắt hiệu ứng khi khởi động | +| `SSL_CERT_FILE` | Đường dẫn file CA bundle tùy chỉnh khi sử dụng proxy nội bộ doanh nghiệp | + +Thiết lập thuộc tính `locale` trong file `settings.toml`, sử dụng lệnh `/config locale vi`, hoặc dựa vào cài đặt biến `LC_ALL`/`LANG` của hệ điều hành để lựa chọn ngôn ngữ cho giao diện TUI và ngôn ngữ nhắc nhở gửi kèm tới các mô hình V4. Tin nhắn mới nhất của người dùng vẫn có mức độ ưu tiên cao nhất để mô hình tự động chọn ngôn ngữ phản hồi tương ứng, do đó các câu hỏi bằng Tiếng Việt của người dùng vẫn sẽ luôn nhận được câu trả lời bằng Tiếng Việt ngay cả khi hệ điều hành đang thiết lập giao diện hiển thị mặc định bằng tiếng Anh. Xem tài liệu hướng dẫn cấu hình tại [docs/CONFIGURATION.md](docs/CONFIGURATION.md) và [docs/MCP.md](docs/MCP.md). + +--- + +## Mô hình & Giá cả + +| Mô hình | Ngữ cảnh | Đầu vào (Hit Cache) | Đầu vào (Miss Cache) | Đầu ra | +|---|---|---|---|---| +| `deepseek-v4-pro` | 1M | $0.003625 / 1M | $0.435 / 1M | $0.87 / 1M | +| `deepseek-v4-flash` | 1M | $0.0028 / 1M | $0.14 / 1M | $0.28 / 1M | + +Nền tảng DeepSeek mặc định sử dụng đường dẫn `https://api.deepseek.com/beta` để bạn có thể trải nghiệm các tính năng API beta mà không cần thiết lập cấu hình phức tạp. Thiết lập thuộc tính `base_url = "https://api.deepseek.com"` nếu muốn tắt tính năng này. + +Các tên định danh cũ `deepseek-chat` / `deepseek-reasoner` sẽ được tự động ánh xạ đến `deepseek-v4-flash` và sẽ chính thức dừng hoạt động sau ngày 24 tháng 7 năm 2026. Các biến thể NVIDIA NIM sẽ áp dụng theo điều khoản tài khoản NVIDIA của bạn. + +> [!Note] +> Trang cấu trúc giá của DeepSeek hiện đã cập nhật bảng giá trên của dòng V4 Pro làm mức giá cố định vĩnh viễn: Chương trình khuyến mãi giảm giá 75% trước đó đã được chính thức tích hợp thẳng vào giá cơ sở từ sau khi thời hạn khuyến mãi kết thúc vào lúc 15:59 UTC ngày 31 tháng 5 năm 2026. Trình tính toán chi phí trên giao diện TUI của CodeWhale đã cập nhật các giá trị mới này, do đó bạn không cần thực hiện thêm thay đổi nào. Để theo dõi các thay đổi giá trong tương lai, vui lòng tham khảo [trang giá chính thức của DeepSeek](https://api-docs.deepseek.com/zh-cn/quick_start/pricing). + +--- + +## Chia Sẻ Skill Tự Viết + +CodeWhale sẽ tự động quét và tìm kiếm các skill được định nghĩa từ các thư mục của dự án (`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills` → `.cursor/skills`) và các thư mục cấu hình toàn cục (`~/.agents/skills` → `~/.claude/skills` → `~/.codewhale/skills` → `~/.deepseek/skills`). Mỗi skill là một thư mục chứa một tệp tin `SKILL.md`: + +```text +~/.agents/skills/my-skill/ +└── SKILL.md +``` + +Yêu cầu định nghĩa phần Frontmatter ở đầu file: + +```markdown +--- +name: my-skill +description: Sử dụng skill này khi bạn muốn DeepSeek tuân thủ theo quy trình làm việc tùy chỉnh của tôi. +--- + +# My Skill +Các hướng dẫn chi tiết dành cho agent được viết tại đây. +``` + +Các lệnh tương tác: `/skills` (liệt kê), `/skill ` (kích hoạt), `/skill new` (tạo khung mẫu), `/skill install github:/` (cài đặt từ cộng đồng GitHub), `/skill update` / `uninstall` / `trust` để quản lý. Cài đặt các skill từ cộng đồng GitHub không yêu cầu chạy thêm bất kỳ dịch vụ nền nào. Các skill sau khi cài đặt sẽ hiển thị trong phần ngữ cảnh phiên làm việc mà mô hình AI có thể đọc được; agent có thể tự chọn skill phù hợp qua công cụ `load_skill` khi nhiệm vụ của bạn khớp với phần mô tả của skill. + +Trong lần chạy đầu tiên, chương trình cũng tự động cài đặt sẵn một số skill hệ thống cho các quy trình phổ biến: +`skill-creator`, `delegate`, `v4-best-practices`, `plugin-creator`, `skill-installer`, `mcp-builder`, `documents`, `presentations`, `spreadsheets`, `pdf`, và `feishu`. Các skill này nằm trong thư mục `~/.codewhale/skills` (hoặc thư mục cũ `~/.deepseek/skills`) và được quản lý phiên bản để các bản nâng cấp mới được cài đặt tự động mà không làm ảnh hưởng đến các skill do người dùng tự chủ động xóa trước đó. + +--- + +## Tài liệu hướng dẫn + +| Tài liệu | Chủ đề chi tiết | +|---|---| +| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Cấu trúc bên trong của cơ sở mã nguồn | +| [CONFIGURATION.md](docs/CONFIGURATION.md) | Hướng dẫn cấu hình chi tiết và đầy đủ nhất | +| [MODES.md](docs/MODES.md) | Các chế độ hoạt động: Plan / Agent / YOLO | +| [MCP.md](docs/MCP.md) | Tích hợp giao thức Model Context Protocol | +| [RUNTIME_API.md](docs/RUNTIME_API.md) | Hướng dẫn sử dụng máy chủ API HTTP/SSE | +| [INSTALL.md](docs/INSTALL.md) | Hướng dẫn cài đặt riêng theo từng nền tảng | +| [DOCKER.md](docs/DOCKER.md) | Sử dụng Docker image trên GHCR, volume lưu trữ | +| [CNB_MIRROR.md](docs/CNB_MIRROR.md) | CNB mirror và các lưu ý cài đặt tại Trung Quốc | +| [TENCENT_CLOUD_REMOTE_FIRST.md](docs/TENCENT_CLOUD_REMOTE_FIRST.md) | Hướng dẫn kết nối Tencent/CNB/Lighthouse/Feishu từ xa | +| [TENCENT_LIGHTHOUSE_HK.md](docs/TENCENT_LIGHTHOUSE_HK.md) | Thiết lập máy chủ Lighthouse Hồng Kông | +| [MEMORY.md](docs/MEMORY.md) | Hướng dẫn tính năng tự ghi nhớ thông tin người dùng | +| [SUBAGENTS.md](docs/SUBAGENTS.md) | Phân loại vai trò và vòng đời của các sub-agent con | +| [KEYBINDINGS.md](docs/KEYBINDINGS.md) | Danh sách phím tắt đầy đủ | +| [RELEASE_RUNBOOK.md](docs/RELEASE_RUNBOOK.md) | Quy trình đóng gói và phát hành phiên bản mới | +| [LOCALIZATION.md](docs/LOCALIZATION.md) | Ma trận đa ngôn ngữ giao diện & cách chuyển đổi | +| [OPERATIONS_RUNBOOK.md](docs/OPERATIONS_RUNBOOK.md) | Vận hành và phục hồi hệ thống | + +Lịch sử cập nhật chi tiết: [CHANGELOG.md](CHANGELOG.md). + +--- + +## Lời cảm ơn + +- **[DeepSeek](https://github.com/deepseek-ai)** — Xin chân thành cảm ơn sự hỗ trợ và các mô hình AI mạnh mẽ giúp tiếp sức cho mọi tương tác trong dự án. 感谢 DeepSeek 提供模型与支持,让每一次交互成为可能。 +- **[DataWhale](https://github.com/datawhalechina)** 🐋 — Xin cảm ơn sự hỗ trợ nhiệt tình và đã chào đón chúng tôi gia nhập gia đình lớn "Whale Brother". 感谢 DataWhale 的支持,并欢迎 chúng tôi gia nhập “鲸兄弟”大家庭。 +- **[OpenWarp](https://github.com/zerx-lab/warp)** — Cảm ơn vì đã ưu tiên hỗ trợ codewhale và hợp tác để mang lại trải nghiệm agent terminal tốt hơn. +- **[Open Design](https://github.com/nexu-io/open-design)** — Cảm ơn vì sự hỗ trợ và hợp tác xung quanh quy trình làm việc chú trọng thiết kế của agent. + +Dự án này được phát triển và vận hành trơn tru với sự đóng góp của cộng đồng các nhà phát triển ngày càng lớn mạnh: + +- **[merchloubna70-dot](https://github.com/merchloubna70-dot)** — Đóng góp 28 PR bao gồm tính năng mới, sửa lỗi và dựng sẵn extension cho VS Code (#645–#681) +- **[WyxBUPT-22](https://github.com/WyxBUPT-22)** — Xây dựng trình kết xuất Markdown hỗ trợ bảng biểu, chữ đậm/nghiêng và đường kẻ ngang (#579) +- **[loongmiaow-pixel](https://github.com/loongmiaow-pixel)** — Tài liệu cài đặt cho Windows và Trung Quốc (#578) +- **[20bytes](https://github.com/20bytes)** — Cải tiến tài liệu tính năng tự ghi nhớ và giao diện trợ giúp (#569) +- **[staryxchen](https://github.com/staryxchen)** — Kiểm tra độ tương thích của thư viện glibc trước khi chạy (#556) +- **[Vishnu1837](https://github.com/Vishnu1837)** — Tối ưu hóa tính tương thích glibc và tự phục hồi trạng thái terminal khi nhận tín hiệu SIGINT/SIGTERM (#565, #1586) +- **[shentoumengxin](https://github.com/shentoumengxin)** — Kiểm tra hợp lệ ranh giới thư mục làm việc `cwd` của Shell (#524) +- **[toi500](https://github.com/toi500)** — Báo cáo và sửa lỗi dán văn bản trên hệ điều hành Windows +- **[xsstomy](https://github.com/xsstomy)** — Báo cáo lỗi vẽ lại màn hình khi khởi động terminal +- **Melody0709** — Báo cáo lỗi kích hoạt phím Enter với tiền tố lệnh gạch chéo +- **[lloydzhou](https://github.com/lloydzhou)** và **[jeoor](https://github.com/jeoor)** — Báo cáo lỗi chi phí nén dữ liệu; lloydzhou cũng đóng góp ngữ cảnh môi trường xác định (#813, #922) và ổn định bộ nhớ đệm KV prefix-cache (#1080) +- **[Agent-Skill-007](https://github.com/Agent-Skill-007)** — Tinh chỉnh diễn đạt rõ ràng cho file giới thiệu README (#685) +- **[woyxiang](https://github.com/woyxiang)** — Tài liệu hướng dẫn cài đặt qua Scoop trên Windows (#696) +- **[wangfeng](mailto:wangfengcsu@qq.com)** — Cập nhật thông tin giá cả và chương trình khuyến mãi (#692) +- **[zichen0116](https://github.com/zichen0116)** — Xây dựng tài liệu quy tắc ứng xử cộng đồng CODE_OF_CONDUCT.md (#686) +- **[dfwqdyl-ui](https://github.com/dfwqdyl-ui)** — Báo cáo tính tương thích chữ hoa/thường của ID mô hình (#729) +- **[Oliver-ZPLiu](https://github.com/Oliver-ZPLiu)** — Báo cáo lỗi trạng thái `working...` bị kẹt, cơ chế dự phòng khay nhớ tạm (clipboard) trên Windows, sửa lỗi phiên kết nối HTTP dạng MCP Streamable, và tự động hóa brew tap (#738, #850, #1643, #1631) +- **[reidliu41](https://github.com/reidliu41)** — Ý tưởng gợi ý tiếp tục phiên, lưu trữ độ tin cậy workspace, hỗ trợ nhà cung cấp Ollama, hoàn thiện stream khối suy nghĩ, tăng cường cache cho CI, xử lý wrap dòng stream, và hoàn thành tính năng autocomplete cho DeepSeek (#863, #870, #921, #1078, #1603, #1628, #1601) +- **[xieshutao](https://github.com/xieshutao)** — Cơ chế dự phòng skill dạng Markdown thuần (#869) +- **[GK012](https://github.com/GK012)** — Cơ chế dự phòng lệnh `--version` của wrapper npm (#885) +- **[y0sif](https://github.com/y0sif)** — Xử lý đánh thức vòng lặp agent cha sau khi các sub-agent con hoàn thành tác vụ (#901) +- **[mac119](https://github.com/mac119)** và **[leo119](https://github.com/leo119)** — Viết tài liệu hướng dẫn cho lệnh `codewhale update` (#838, #917) +- **[dumbjack](https://github.com/dumbjack)** / **浩淼的mac** — Tăng cường bảo mật chống mã độc qua lệnh shell byte rỗng (#706, #918) +- **macworkers** — Cải tiến xác nhận rẽ nhánh (fork) kèm mã phiên làm việc mới (#600, #919) +- **zero** và **[zerx-lab](https://github.com/zerx-lab)** — Cấu hình điều kiện nhận thông báo và làm phong phú nội dung thông báo qua OSC 9 (#820, #920) +- **[chnjames](https://github.com/chnjames)** — Gợi ý hoàn thành @mentions từ cache, cải tiến phục hồi file cấu hình lỗi, và hiển thị chuẩn UTF-8 cho Shell trên Windows (#849, #927, #982, #1018) +- **[angziii](https://github.com/angziii)** — Bảo mật cấu hình, dọn dẹp tài nguyên bất đồng bộ, tăng cường bảo mật Docker và vá lỗi an toàn thực thi lệnh (#822, #824, #827, #831, #833, #835, #837) +- **[elowen53](https://github.com/elowen53)** — Giải mã UTF-8 và bổ sung các ca kiểm thử xác định (#825, #840) +- **[wdw8276](https://github.com/wdw8276)** — Bổ sung lệnh `/rename` để đổi tên tiêu đề phiên làm việc tùy chỉnh (#836) +- **[banqii](https://github.com/banqii)** — Hỗ trợ đường dẫn tìm kiếm skill dạng `.cursor/skills` (#817) +- **[junskyeed](https://github.com/junskyeed)** — Tính toán động giá trị `max_tokens` cho các yêu cầu API (#826) +- **Hafeez Pizofreude** — Triển khai cơ chế chống tấn công SSRF trong công cụ `fetch_url` và biểu đồ lịch sử Star History. +- **Unic (YuniqueUnic)** — Xây dựng giao diện cấu hình tự động dựa trên schema (cả TUI và web). +- **Jason** — Tăng cường bảo mật an toàn mạng chống tấn công giả mạo yêu cầu từ phía máy chủ (SSRF). +- **[axobase001](https://github.com/axobase001)** — Dọn dẹp snapshot mồ côi, bổ sung bộ bảo vệ khi cài npm, sửa lỗi đo lường phiên làm việc, xóa cache phạm vi mô hình, hỗ trợ các liên kết tượng trưng (symlinks) cho skill, hướng dẫn cơ chế thoát lỗi cài đặt npm mirror, và duy trì cấu hình proxy cho các tác vụ con (#975, #1032, #1047, #1049, #1052, #1019, #1051, #1056, #1608) +- **[MengZ-super](https://github.com/MengZ-super)** — Xây dựng nền tảng cho lệnh `/theme` và giải nén dữ liệu nén dạng gzip/brotli cho kết nối SSE (#1057, #1061) +- **[DI-HUO-MING-YI](https://github.com/DI-HUO-MING-YI)** — Vá lỗi bảo mật sandbox chỉ đọc trong chế độ Plan (#1077) +- **[bevis-wong](https://github.com/bevis-wong)** — Cung cấp ca tái hiện chính xác lỗi tự động gửi tin khi dán văn bản kèm ký tự xuống dòng (#1073) +- **[Duducoco](https://github.com/Duducoco)** và **[AlphaGogoo](https://github.com/AlphaGogoo)** — Xây dựng thanh menu gạch chéo cho skill và sửa lỗi bao phủ lệnh `/skills` (#1068, #1083) +- **[ArronAI007](https://github.com/ArronAI007)** — Sửa lỗi hiển thị tài nguyên artifact khi thay đổi kích thước cửa sổ trên macOS Terminal.app và ConHost (#993) +- **[THINKER-ONLY](https://github.com/THINKER-ONLY)** — Duy trì mã mô hình tùy chỉnh cho OpenRouter và endpoint riêng (#1066) +- **[Jefsky](https://github.com/Jefsky)** — Báo cáo sửa lỗi địa chỉ endpoint chính thức của DeepSeek (#1079, #1084) +- **[wlon](https://github.com/wlon)** — Chẩn đoán và ưu tiên lựa chọn khóa xác thực cho nhà cung cấp NVIDIA NIM (#1081) +- **[Horace Liu](https://github.com/liuhq)** — Đóng gói hỗ trợ Nix package và viết tài liệu hướng dẫn cài đặt (#1173) +- **[jieshu666](https://github.com/jieshu666)** — Giảm thiểu hiện tượng nhấp nháy màn hình khi vẽ lại giao diện TUI (#1563) +- **[gordonlu](https://github.com/gordonlu)** — Sửa lỗi nhận dạng phím Enter / mã nhập CSI-u trên Windows (#1612) +- **[mdrkrg](https://github.com/mdrkrg)** — Vá lỗi sập ứng dụng trong lần chạy đầu tiên khi thiếu khóa API (#1598) +- **[Aitensa](https://github.com/Aitensa)** — Xử lý tự động xuống dòng CJK cho các khối diff và kết quả đầu ra trang giấy (#1622) +- **[qiyan233](https://github.com/qiyan233)** — Đảm bảo tương thích với các bí danh cũ của nhà cung cấp DeepSeek Trung Quốc (#1645) +- **[zlh124](https://github.com/zlh124)** — Báo cáo khởi động không đầu WSL2 và sửa lỗi khay nhớ tạm (#1772, #1773) +- **[aboimpinto](https://github.com/aboimpinto)** — Sửa lỗi ghi nhật ký màn hình phụ trên Windows, hoàn thiện phím Home/End tại bộ soạn thảo và theo dõi log runtime (#1774, #1776, #1748, #1749, #1782, #1783) +- **[LeoLin990405](https://github.com/LeoLin990405)** — Bổ sung cơ chế truyền thẳng mô hình qua provider, phát lại luồng suy nghĩ, tối ưu lượt chạy chỉ suy nghĩ, và sửa lỗi trích dẫn trên Windows (#1740, #1743, #1742, #1744) +- **[nightt5879](https://github.com/nightt5879)** — Khắc phục lỗi khôi phục giao diện nhắc nhở khi bấm phím Ctrl+C (#1764) +- **[donglovejava](https://github.com/donglovejava)** — Hợp nhất kéo thả dán tệp `@file`, vá lỗi sập chữ CJK, thu thập phản hồi người dùng, định tuyến RLM, và thử lại khi `edit_file` bị kẹt (#2154–#2168) +- **[encyc](https://github.com/encyc)** — Hiển thị chi tiết số lượng token tiêu thụ ở chân trang và lệnh `/status` (#2152) +- **[saieswar237](https://github.com/saieswar237)** — Bổ sung tài liệu hướng dẫn về quy trình review code (#2178) +- **[sximelon](https://github.com/sximelon)** — Chặn sự kiện tự gửi tin khi dán văn bản và tách phân hệ quản lý phím bấm (#2174, #2042) +- **[nanookclaw](https://github.com/nanookclaw)** — Bổ sung hiển thị nhà cung cấp tìm kiếm trong kết quả của lệnh doctor (#2135) +- **[Sskift](https://github.com/Sskift)** — Ngăn chặn việc ghi đè biến môi trường mặc định trên CLI (#2119) +- **[xin1104](https://github.com/xin1104)** — Tạo brew formula cài binary codewhale độc lập (#2105) +- **[mrluanma](https://github.com/mrluanma)** — Bổ sung nhà cung cấp dịch vụ tìm kiếm Metaso (#2059) +- **[Lellansin](https://github.com/Lellansin)** — Bỏ qua việc gộp cấu hình tại thư mục home người dùng (#2055) +- **[zhuangbiaowei](https://github.com/zhuangbiaowei)** — Cập nhật các kênh phát hành chính thức của sản phẩm (#2145) + +--- + +## Đóng góp cho dự án + +Xem tài liệu hướng dẫn đóng góp tại [CONTRIBUTING.md](CONTRIBUTING.md). Chúng tôi luôn hoan nghênh các yêu cầu kéo Pull Requests — vui lòng xem danh sách các [vấn đề mở (open issues)](https://github.com/Hmbown/CodeWhale/issues) để bắt đầu đóng góp những phần việc đầu tiên. + +Ủng hộ nhà phát triển: [Buy me a coffee](https://www.buymeacoffee.com/hmbown). + +> [!Note] +> *Dự án này độc lập và không trực thuộc công ty DeepSeek Inc.* + +## Bản quyền + +[MIT](LICENSE) + +## Biểu đồ Star History + +[![Biểu đồ lịch sử sao](https://api.star-history.com/chart?repos=Hmbown/CodeWhale&type=date&legend=top-left)](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date&logscale=&legend=top-left) diff --git a/README.zh-CN.md b/README.zh-CN.md index 97fd11eb..7fa6bca5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,46 +1,44 @@ # CodeWhale -> **DeepSeek 优先、面向开源与开放权重编码模型的终端原生编程智能体:100 万 token 上下文、思考模式流式推理、前缀缓存感知。自包含 Rust 二进制发布——开箱即带 MCP 客户端、沙箱和持久化任务队列。** - -[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml) -[![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale) -[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli) -[![Sponsor](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/Hmbown) -[DeepWiki project index](https://deepwiki.com/Hmbown/CodeWhale) +> **面向 [DeepSeek V4](https://platform.deepseek.com) 的终端原生编程智能体:100 万 token 上下文、思考模式流式推理、前缀缓存感知。以 `codewhale` 调度器和 `codewhale-tui` 运行时这一组自包含 Rust 二进制发布——开箱即带 MCP 客户端、沙箱和持久化任务队列。** [English README](README.md) [日本語 README](README.ja-JP.md) +[Tiếng Việt README](README.vi.md) -[安装](#安装) · [快速开始](#快速开始) · [使用方式](#使用方式) · [文档](#文档) · [贡献](#贡献) · [支持](#支持) ## 安装 -`codewhale` 是自包含 Rust 二进制——**运行时不依赖 Node.js 或 Python**。 -下面几种方式装出来的是同一套二进制,按你已有的工具链选一个即可: +`codewhale` 以一组自包含 Rust 发布二进制安装:`codewhale` 调度器命令, +以及它在交互会话中启动的同级 `codewhale-tui` 运行时。npm、Homebrew 和 +Docker 会自动安装这两个二进制;Cargo 或手动下载时必须把两者放在同一目录 +(通常是 `PATH` 上的某个目录)。运行时不依赖 Node.js 或 Python。 ```bash # 1. npm —— 已装 Node 的最方便方式。npm 包只是一个下载器, -# 会从 GitHub Releases 拉取对应平台的预编译二进制, +# 会从 GitHub Releases 拉取对应平台的预编译二进制对, # 并不会让 codewhale 本身依赖 Node 运行时。 npm install -g codewhale -# 2. Cargo —— 无需 Node。 +# 2. Cargo —— 无需 Node,两个 crate 都要安装。 cargo install codewhale-cli --locked # `codewhale` 入口 cargo install codewhale-tui --locked # `codewhale-tui` TUI 二进制 # 3. Homebrew —— macOS 包管理器。 +# tap/formula 名称仍是旧名;实际安装 codewhale 和 codewhale-tui。 brew tap Hmbown/deepseek-tui brew install deepseek-tui -# 4. 直接下载 —— 无需任何工具链。 +# 4. 直接下载 —— GitHub Releases 的平台压缩包。 # https://github.com/Hmbown/CodeWhale/releases -# 覆盖 Linux x64/ARM64、macOS x64/ARM64、Windows x64 +# 压缩包包含 codewhale 和 codewhale-tui 以及安装脚本; +# 也提供单独二进制给脚本使用,手动安装时请把这一对放在一起。 # 5. Docker —— 预构建发布镜像。 docker volume create codewhale-home docker run --rm -it \ -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ - -v codewhale-home:/home/codewhale/.deepseek \ + -v codewhale-home:/home/codewhale/.codewhale \ -v "$PWD:/workspace" \ -w /workspace \ ghcr.io/hmbown/codewhale:latest @@ -63,6 +61,13 @@ brew update && brew upgrade deepseek-tui cargo install codewhale-cli --locked --force cargo install codewhale-tui --locked --force ``` +> codewhale update 现在可添加 --proxy ,通过代理下载更新 +> eg: codewhale update --proxy https://localhost:7897 + +[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml) +[![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale) +[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli) +[DeepWiki project index](https://deepwiki.com/Hmbown/CodeWhale) ![codewhale 截图](assets/screenshot.png) @@ -70,37 +75,43 @@ cargo install codewhale-tui --locked --force ## 这是什么? -codewhale 是一个完全运行在终端里的编程智能体。它让 DeepSeek 前沿模型直接访问你的工作区:读写文件、运行 shell 命令、搜索浏览网页、管理 git、调度子智能体——全部通过快速、键盘驱动的 TUI 完成。 +模型回答问题。智能体完成任务。区别在于运行框架——一套在模型偏离时保持方向的规则、证据和反馈系统。 -它面向 **DeepSeek V4**(`deepseek-v4-pro` / `deepseek-v4-flash`)构建,原生支持 100 万 token 上下文窗口和思考模式流式输出。 +CodeWhale 就是这套框架,围绕 DeepSeek V4 构建,基于三个理念: -### 主要功能 +| 原则 | 如何运作 | +|---|---| +| **从信任开始** | 每一轮以"A"开始——可能性先于确定性,匠心先于便利 | +| **清晰的管辖权** | 成文宪法,九层权威。用户意图优先于陈旧指令。验证优先于自信。 | +| **递归改进** | V4 参与了框架的编写。框架改进 → V4 更高效 → 进一步改进框架。每轮从更强的位置开始。 | -- **模型自动路由** —— `--model auto` / `/model auto` 每轮自动选择模型和推理强度 -- **Fin 快速通道** —— 使用关闭思考的低成本 `deepseek-v4-flash` 承担路由、RLM 子调用、摘要和协调工作 -- **原生 RLM**(`rlm_open`/`rlm_eval`)—— 持久化 REPL 会话用于批量分析;使用带界面的辅助函数(`peek`、`search`、`chunk`、`sub_query_batch`) -- **思考模式流式输出** —— 实时观察模型在解决问题时的思维链展开 -- **完整工具集** —— 文件操作、shell 执行、git、网页搜索/浏览、apply-patch、子智能体、MCP 服务器 -- **100 万 token 上下文** —— 上下文跟踪、手动或配置驱动的压缩,以及前缀缓存遥测 -- **前缀缓存稳定性跟踪** —— 可选 `/statusline` footer chip 显示最近轮次缓存前缀的稳定程度 -- **三种交互模式** —— Plan(只读探索)、Agent(带审批的默认交互)、YOLO(可信工作区自动批准) -- **推理强度档位** —— 用 `Shift+Tab` 在 `off → high → max` 之间切换 -- **会话保存和恢复** —— 长任务的断点续作 -- **工作区回滚** —— 通过 side-git 记录每轮前后快照,支持 `/restore` 和 `revert_turn`,不影响项目自己的 `.git` -- **持久化任务队列** —— 后台任务在重启后仍然存在,支持计划任务和长时间运行的操作 -- **HTTP/SSE 运行时 API** —— `codewhale serve --http` 用于无界面智能体流程 -- **MCP 协议** —— 连接 Model Context Protocol 服务器扩展工具,见 [docs/MCP.md](docs/MCP.md) -- **LSP 诊断** —— 每次编辑后通过 rust-analyzer、pyright、typescript-language-server、gopls、clangd 提供内联错误/警告 -- **用户记忆** —— 可选的持久化笔记文件注入系统提示,实现跨会话偏好保持 -- **多语言 UI** —— 支持 `en`、`ja`、`zh-Hans`、`pt-BR`,支持自动检测 -- **实时成本跟踪** —— 按轮次和会话统计 token 用量与成本估算,含缓存命中/未命中明细;简体中文 locale 下显示 CNY -- **技能系统** —— 可通过 GitHub 安装的组合式指令包;首次启动自带 `skill-creator`、`mcp-builder`、`documents`、`presentations`、`spreadsheets`、`pdf`、`feishu` 等 starter skills -- **终端原生通知** —— OSC 9、OSC 99、OSC 777,以及桌面通知兜底 -- **内置主题选择器** —— Catppuccin、Tokyo Night、Dracula、Gruvbox 和原有亮/暗色主题,可用 `/theme` 实时切换 +开源、终端原生,并以 `codewhale` / `codewhale-tui` 这一组 Rust 二进制发布。 + +## 框架如何工作 + +智能体模型面临大规模的冲突信息:用户意图、项目规则、系统默认值、工具输出和陈旧记忆在单轮对话中争夺权威。LLM 作为裁判需要管辖权——当它们冲突时,哪个来源胜出? + +CodeWhale 用一部**宪法**(`prompts/base.md`)来回答这个问题。它是一个形式化的法律层级——第七条将九个来源从宪法本身的条款排到前序会话的交接记录。用户当前消息优先于陈旧的项目指令。实时工具输出优先于假设。验证优先于自信。模型每轮继承清晰的权威链,永远不需要猜测该服从哪条指令。 + +七条条款位于层级之上,定义模型的身份、职责和能动性:验证强制(第五条——每个行动留下证据,绝不凭信念宣告成功)、协作遗产(第六条——让工作区对下一位智能体保持可读)、以及真相优先条款(第二条——任何下级规则不得覆盖它)。 + +DeepSeek V4 的前缀缓存使其可行。宪法篇幅长且详细,但一旦缓存,每轮成本约为冷读取的百分之一。模型递归引用它——通过 RLM 会话窥视、扫描和查询——按需重访信息,而非依赖单次记忆读取。它的表现更像是开卷考试而非闭卷考试。 + +因为权威结构是显式的,失败不会被隐藏。非零退出码、两次轮次间来自 rust-analyzer 的类型错误、沙箱拒绝——这些被作为修正向量反馈。模型用自己的漂移进行自我校正。 + +三种模式控制行动空间。Plan 只读。Agent 对破坏性操作设审批门控。YOLO 在可信工作区自动批准。macOS Seatbelt 是主动执行的沙箱;Linux Landlock 可检测但未执行;Windows 沙箱尚未开放。 + +Fin——关闭思考的廉价 Flash 调用——每轮处理模型自动路由。`--model auto` 是默认值。 + +每轮记录 side-git 快照,在仓库 `.git` 之外。`/restore` 和 `revert_turn` 即刻回滚工作区。 + +子智能体并发运行(最多 20 个)。`agent_open` 立即返回;结果以内联完成哨兵形式到达,携带摘要。完整对话记录通过 `agent_eval` 的有界句柄保存。详见 [docs/SUBAGENTS.md](docs/SUBAGENTS.md)。 + +其余功能面:每次编辑后的 LSP 诊断(rust-analyzer、pyright、typescript-language-server、gopls、clangd、jdtls、vue-language-server)、RLM 会话批量分析、MCP 协议、HTTP/SSE 运行时 API、持久化任务队列、Zed 的 ACP 适配器、SWE-bench 导出、以及带缓存命中/未命中明细的实时成本追踪。 --- -## 架构说明 +## 运行框架 `codewhale`(调度器 CLI)→ `codewhale-tui`(伴随二进制)→ ratatui 界面 ↔ 异步引擎 ↔ OpenAI 兼容流式客户端。工具调用通过类型化注册表(shell、文件操作、git、web、子智能体、MCP、RLM)路由,结果流式返回对话记录。引擎管理会话状态、轮次追踪、持久化任务队列和 LSP 子系统——它在下一步推理前将编辑后诊断反馈到模型上下文中。 @@ -112,8 +123,8 @@ codewhale 可以同时调度多个子智能体并行运行——类似于并发 - **非阻塞启动。** `agent_open` 立即返回。子智能体获得独立的上下文和工具注册表,独立运行。父进程继续工作。 - **后台执行。** 子智能体并发运行(默认上限 10,可配置至 20)。引擎管理线程池——无需轮询循环。 -- **完成通知。** 子智能体完成后,运行时发送结构化的 `` 事件,包含摘要、证据列表和执行指标。父模型读取 `summary` 字段并整合结果。 -- **按需读取结果。** 大型对话记录暂存为 `var_handle` 引用。模型通过 `handle_read` 按切片、范围或 JSONPath 投影读取——保持父上下文精简。 +- **完成通知。** 子智能体完成后,运行时向父对话注入 `` 哨兵。人类可读的摘要(包含子智能体的发现、变更文件和风险)位于哨兵的紧前一行。父模型读取该摘要并整合结果,无需额外工具调用。 +- **按需读取结果。** 完整子对话记录通过 `agent_eval` 获取的 `transcript_handle` 暂存。摘要不够时,父进程通过 `handle_read` 按切片、行范围或 JSONPath 投影读取——保持父上下文精简而不丢失细节。 详见 [docs/SUBAGENTS.md](docs/SUBAGENTS.md)。 @@ -127,14 +138,14 @@ codewhale --version codewhale --model auto ``` -预构建二进制覆盖 **Linux x64**、**Linux ARM64**(v0.8.8 起)、**macOS x64**、**macOS ARM64** 和 **Windows x64**。其他目标平台(musl、riscv64、FreeBSD 等)请见下方的[从源码安装](#从源码安装)或 [docs/INSTALL.md](docs/INSTALL.md)。 +预构建二进制对和平台压缩包覆盖 **Linux x64**、**Linux ARM64**(v0.8.8 起)、**macOS x64**、**macOS ARM64** 和 **Windows x64**。其他目标平台(musl、riscv64、FreeBSD 等)请见下方的[从源码安装](#从源码安装)或 [docs/INSTALL.md](docs/INSTALL.md)。 -首次启动时会提示输入 [DeepSeek API key](https://platform.deepseek.com/api_keys)。密钥保存到 `~/.deepseek/config.toml`,在任意目录、IDE 终端和脚本中都能使用,不会触发系统密钥环弹窗。 +首次启动时会提示输入 [DeepSeek API key](https://platform.deepseek.com/api_keys)。密钥保存到 `~/.codewhale/config.toml`(同时兼容旧版 `~/.deepseek/config.toml`),在任意目录、IDE 终端和脚本中都能使用,不会触发系统密钥环弹窗。 也可以提前配置: ```bash -codewhale auth set --provider deepseek # 保存到 ~/.deepseek/config.toml +codewhale auth set --provider deepseek # 保存到 ~/.codewhale/config.toml codewhale auth status # 显示当前活跃的凭证来源 export DEEPSEEK_API_KEY="YOUR_KEY" # 环境变量方式;需要在非交互式 shell 中使用请放入 ~/.zshenv @@ -155,18 +166,18 @@ CNB 镜像/源码,腾讯云 Lighthouse 香港实例,飞书/Lark 长连接桥 先看 [docs/TENCENT_CLOUD_REMOTE_FIRST.md](docs/TENCENT_CLOUD_REMOTE_FIRST.md), 再按 [docs/TENCENT_LIGHTHOUSE_HK.md](docs/TENCENT_LIGHTHOUSE_HK.md) 配置服务器。 -### 模型自动路由与 Fin +### Auto 模式 使用 `codewhale --model auto` 或 `/model auto` 让 codewhale 自行决定每轮需要多少模型和推理能力。 -模型自动路由同时控制两个设置: +Auto 模式同时控制两个设置: - 模型:`deepseek-v4-flash` 或 `deepseek-v4-pro` - 推理强度:`off`、`high` 或 `max` -在真实请求发出之前,应用会先用关闭推理的 `deepseek-v4-flash` 进行一次小型路由调用。这条快速路径叫 **Fin**:用于模型选择、摘要、RLM 子任务、上下文维护以及其他不该消耗完整推理轮次的协调工作。Fin 审视最新请求和最近的上下文,然后为真实请求选定具体的模型和推理强度。简短/简单的轮次保持在 Flash + 关闭推理;编码、调试、发布、架构、安全审查或模糊的多步骤任务可升级到 Pro 和/或更高推理强度。 +在真实请求发出之前,应用会先用关闭推理的 `deepseek-v4-flash` 进行一次小型路由调用。路由器审视最新请求和最近的上下文,然后为真实请求选定具体的模型和推理强度。简短/简单的轮次保持在 Flash + 关闭推理;编码、调试、发布、架构、安全审查或模糊的多步骤任务可升级到 Pro 和/或更高推理强度。 -`--model auto` 和 `/model auto` 是 codewhale 本地行为。上游 API 永远不会收到 `model: "auto"`,它只会收到为当前轮次选定的具体模型和推理强度设置。TUI 会显示选定的路由,成本跟踪按实际运行的模型计费。如果 Fin 路由失败或返回无效答案,应用会回退到本地启发式规则。子智能体会继承模型自动路由,除非你为它们指定了显式模型。 +`auto` 是 codewhale 本地行为。上游 API 永远不会收到 `model: "auto"`,它只会收到为当前轮次选定的具体模型和推理强度设置。TUI 会显示选定的路由,成本跟踪按实际运行的模型计费。如果路由调用失败或返回无效答案,应用会回退到本地启发式规则。子智能体会继承 auto 模式,除非你为它们指定了显式模型。 需要可重复基准测试、严格控制成本上限或特定提供商/模型映射时,请使用固定模型或固定推理强度。 @@ -205,7 +216,7 @@ release。先运行 `scoop update`,安装后用 `codewhale --version` 核对 ```bash scoop update -scoop install deepseek-tui +scoop install codewhale codewhale --version ``` @@ -252,6 +263,10 @@ codewhale --provider wanjie-ark --model deepseek-reasoner codewhale auth set --provider openrouter --api-key "YOUR_OPENROUTER_API_KEY" codewhale --provider openrouter --model deepseek/deepseek-v4-pro +# Xiaomi MiMo +codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_MIMO_API_KEY" +codewhale --provider xiaomi-mimo --model mimo-v2.5-pro + # Novita codewhale auth set --provider novita --api-key "YOUR_NOVITA_API_KEY" codewhale --provider novita --model deepseek/deepseek-v4-pro @@ -293,10 +308,10 @@ codewhale --provider ollama --model codewhale-coder:1.3b ```bash codewhale # 交互式 TUI codewhale "explain this function" # 一次性提示 -codewhale exec --auto --output-format stream-json "fix this bug" # 自动批准工具的 agentic exec +codewhale exec --auto --output-format stream-json "fix this bug" # 面向后端集成的 NDJSON 流 codewhale exec --resume "follow up" # 继续非交互会话 codewhale --model deepseek-v4-flash "summarize" # 指定模型 -codewhale --model auto "fix this bug" # 自动路由模型 + 推理强度 +codewhale --model auto "fix this bug" # 自动选择模型 + 推理强度 codewhale --yolo # 自动批准工具 codewhale auth set --provider deepseek # 保存 API key codewhale doctor # 检查配置和连接 @@ -309,6 +324,7 @@ codewhale resume --last # 恢复最近会话 codewhale resume # 按 UUID 恢复指定会话 codewhale fork # 将已保存会话分叉为兄弟路径 codewhale serve --http # HTTP/SSE API 服务 +codewhale serve --mobile # 局域网移动端控制页,默认启用 token 保护 codewhale serve --acp # Zed/自定义智能体的 ACP stdio 适配器 codewhale run pr # 获取 PR 并预填审查提示 codewhale mcp list # 列出已配置 MCP 服务器 @@ -324,7 +340,7 @@ docker volume create codewhale-home docker run --rm -it \ -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ - -v codewhale-home:/home/codewhale/.deepseek \ + -v codewhale-home:/home/codewhale/.codewhale \ -v "$PWD:/workspace" \ -w /workspace \ ghcr.io/hmbown/codewhale:latest @@ -378,15 +394,11 @@ DeepSeek 可作为自定义 Agent Client Protocol 服务器运行,供 Zed 等 | **Agent** 🤖 | 默认交互模式;多步工具调用带审批门禁 | | **YOLO** ⚡ | 在可信工作区自动批准工具;仍会维护计划和清单以保持可见性 | -模式与模型自动路由是两个概念。`Tab` 切换 Plan / Agent / YOLO, -`/model auto` 选择模型和思考强度。`/goal` 当前用于追踪会话目标和 -token 预算;未来如果扩展成 Goal 工作区,也应与 `--model auto` 保持独立。 - --- ## 配置 -用户配置:`~/.deepseek/config.toml`。项目覆盖:`/.deepseek/config.toml`(以下密钥被拒绝:`api_key`、`base_url`、`provider`、`mcp_config_path`)。完整选项见 [config.example.toml](config.example.toml)。 +用户配置:`~/.codewhale/config.toml`(兼容旧版 `~/.deepseek/config.toml`)。项目覆盖:`/.codewhale/config.toml`(兼容 `/.deepseek/config.toml`)(以下密钥被拒绝:`api_key`、`base_url`、`provider`、`mcp_config_path`)。完整选项见 [config.example.toml](config.example.toml)。 常用环境变量: @@ -397,15 +409,16 @@ token 预算;未来如果扩展成 Goal 工作区,也应与 `--model auto` | `DEEPSEEK_HTTP_HEADERS` | 可选模型请求头,例如 `X-Model-Provider-Id=your-model-provider` | | `DEEPSEEK_MODEL` | 默认模型 | | `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | 流式响应空闲超时秒数,默认 `300`,限制在 `1..=3600` | -| `DEEPSEEK_PROVIDER` | `codewhale`(默认)、`nvidia-nim`、`openai`、`atlascloud`、`wanjie-ark`、`openrouter`、`novita`、`fireworks`、`sglang`、`vllm`、`ollama` | +| `DEEPSEEK_PROVIDER` | `codewhale`(默认)、`nvidia-nim`、`openai`、`atlascloud`、`wanjie-ark`、`openrouter`、`xiaomi-mimo`、`novita`、`fireworks`、`sglang`、`vllm`、`ollama` | | `DEEPSEEK_PROFILE` | 配置 profile 名称 | | `DEEPSEEK_MEMORY` | 设为 `on` 启用用户记忆 | | `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | 在可信网络上允许非本机 `http://` API base URL | -| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | 提供商认证 | +| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `XIAOMI_MIMO_API_KEY` / `MIMO_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | 提供商认证 | | `OPENAI_BASE_URL` / `OPENAI_MODEL` | 通用 OpenAI 兼容端点和模型 ID | | `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud 端点和模型覆盖 | | `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Wanjie Ark 端点和模型覆盖 | | `OPENROUTER_BASE_URL` | OpenRouter 端点覆盖 | +| `XIAOMI_MIMO_BASE_URL` / `MIMO_BASE_URL` / `XIAOMI_MIMO_MODEL` / `MIMO_MODEL` | Xiaomi MiMo 端点和模型覆盖 | | `NOVITA_BASE_URL` | Novita 端点覆盖 | | `FIREWORKS_BASE_URL` | Fireworks 端点覆盖 | | `SGLANG_BASE_URL` | 自托管 SGLang 端点 | @@ -428,10 +441,10 @@ token 预算;未来如果扩展成 Goal 工作区,也应与 `--model auto` 可选语言:`auto` | `en` | `ja` | `zh-Hans` | `pt-BR`。 -也可以在 `~/.deepseek/config.toml` 里直接设置 `locale = "zh-Hans"`,或通过 `LC_ALL` / `LANG` 环境变量自动选择: +也可以在 `~/.codewhale/config.toml` 里直接设置 `locale = "zh-Hans"`,或通过 `LC_ALL` / `LANG` 环境变量自动选择: ```toml -# ~/.deepseek/config.toml +# ~/.codewhale/config.toml [tui] locale = "zh-Hans" ``` @@ -460,10 +473,10 @@ LANG=zh_CN.UTF-8 codewhale run ## 创建和安装技能 -codewhale 从工作区目录(`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills`)和全局 `~/.deepseek/skills` 发现技能。每个技能是一个包含 `SKILL.md` 的目录: +codewhale 从工作区目录(`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills`)和全局 `~/.codewhale/skills`(兼容旧版 `~/.deepseek/skills`)发现技能。每个技能是一个包含 `SKILL.md` 的目录: ```text -~/.deepseek/skills/my-skill/ +~/.codewhale/skills/my-skill/ └── SKILL.md ``` @@ -491,7 +504,7 @@ description: 当 DeepSeek 需要遵循我的自定义工作流时使用这个技 | [CONFIGURATION.md](docs/CONFIGURATION.md) | 完整配置参考 | | [MODES.md](docs/MODES.md) | Plan / Agent / YOLO 模式 | | [MCP.md](docs/MCP.md) | Model Context Protocol 集成 | -| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API 服务 | +| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API 服务和移动端控制页 | | [INSTALL.md](docs/INSTALL.md) | 各平台安装指南 | | [DOCKER.md](docs/DOCKER.md) | GHCR 镜像、volume 和 Docker 用法 | | [CNB_MIRROR.md](docs/CNB_MIRROR.md) | CNB 镜像和中国大陆友好安装说明 | @@ -508,17 +521,6 @@ description: 当 DeepSeek 需要遵循我的自定义工作流时使用这个技 --- -## 支持 - -CodeWhale 采用 MIT 许可证,使用和参与贡献都不需要赞助。如果它帮你节省了时间, -最直接的长期支持方式是 [GitHub Sponsors](https://github.com/sponsors/Hmbown)。 -一次性支持也可以通过 [Buy Me a Coffee](https://www.buymeacoffee.com/hmbown) 完成。 - -赞助会用于发布构建、CI/运行时测试、包发布,以及维护者处理 issue 和 review 的时间。 -功能请求、Bug 报告和 pull request 不需要赞助。 - ---- - ## 致谢 - **[DeepSeek](https://github.com/deepseek-ai)** — 感谢 DeepSeek 提供模型与支持,让每一次交互成为可能。 @@ -538,15 +540,14 @@ CodeWhale 采用 MIT 许可证,使用和参与贡献都不需要赞助。如 - **[toi500](https://github.com/toi500)** — Windows 粘贴修复报告 - **[xsstomy](https://github.com/xsstomy)** — 终端启动重绘报告 - **[melody0709](https://github.com/melody0709)** — 斜杠前缀回车激活报告 -- **[lloydzhou](https://github.com/lloydzhou)** 和 **[jeoor](https://github.com/jeoor)** — 压缩成本报告和 npm 安装器流暂停竞态修复 (#1860);lloydzhou 还贡献了确定性的环境上下文注入 (#813, #922) 和 KV 前缀缓存稳定化 (#1080) +- **[lloydzhou](https://github.com/lloydzhou)** 和 **[jeoor](https://github.com/jeoor)** — 压缩成本报告;lloydzhou 还贡献了确定性的环境上下文注入 (#813, #922) 和 KV 前缀缓存稳定化 (#1080) - **[Agent-Skill-007](https://github.com/Agent-Skill-007)** — README 清晰化改进 (#685) - **[woyxiang](https://github.com/woyxiang)** — Windows 安装文档 (#696) - **[wangfeng](mailto:wangfengcsu@qq.com)** — 价格/折扣信息更新 (#692) - **[zichen0116](https://github.com/zichen0116)** — CODE_OF_CONDUCT.md (#686) - **[dfwqdyl-ui](https://github.com/dfwqdyl-ui)** — 模型 ID 大小写兼容性报告 (#729) - **[Oliver-ZPLiu](https://github.com/Oliver-ZPLiu)** — `working...` 卡死状态 Bug 报告和 Windows 剪贴板兜底修复 (#738, #850) -- **[reidliu41](https://github.com/reidliu41)** — 退出后的恢复提示、工作区信任持久化、Ollama provider 支持、思考块流式终结修复,以及帮助选择器选中行可见性优化 (#863, #870, #921, #1078, #1964) -- **[cyq1017](https://github.com/cyq1017)** — Unicode `git_status` 路径、本地/配置技能发现,以及模式切换 toast 去重 (#1953, #1956, #1957) +- **[reidliu41](https://github.com/reidliu41)** — 退出后的恢复提示、工作区信任持久化、Ollama provider 支持,以及思考块流式终结修复 (#863, #870, #921, #1078) - **[xieshutao](https://github.com/xieshutao)** — 纯 Markdown skill 兜底解析 (#869) - **[GK012](https://github.com/GK012)** — npm wrapper 的 `--version` 兜底 (#885) - **[y0sif](https://github.com/y0sif)** — 直接子智能体完成后唤醒父级 turn loop (#901) @@ -572,82 +573,16 @@ CodeWhale 采用 MIT 许可证,使用和参与贡献都不需要赞助。如 - **[THINKER-ONLY](https://github.com/THINKER-ONLY)** — OpenRouter 和自定义端点模型 ID 保留 (#1066) - **[Jefsky](https://github.com/Jefsky)** — `deepseek-cn` 官方端点默认值 (#1079, #1084) - **[wlon](https://github.com/wlon)** — NVIDIA NIM provider API key 优先级诊断 (#1081) -- **[Horace Liu](https://github.com/liuhq)** — Nix 包支持和安装文档 (#1173) -- **[jieshu666](https://github.com/jieshu666)** — 终端重绘闪烁修复 (#1563) -- **[gordonlu](https://github.com/gordonlu)** — Windows Enter / CSI-u 输入修复 (#1612) -- **[mdrkrg](https://github.com/mdrkrg)** — 首次运行 API key 缺失时的启动崩溃修复 (#1598) -- **[Aitensa](https://github.com/Aitensa)** — diff 和 pager 输出的 CJK 换行支持 (#1622) -- **[qiyan233](https://github.com/qiyan233)** — 遗留 DeepSeek CN provider 别名兼容 (#1645) -- **[zlh124](https://github.com/zlh124)** — WSL2/headless 启动报告和剪贴板初始化修复 (#1772, #1773) -- **[aboimpinto](https://github.com/aboimpinto)** — Windows alt-screen 日志、Home/End 编辑器,以及运行时日志跟进 (#1774, #1776, #1748, #1749, #1782, #1783) -- **[LeoLin990405](https://github.com/LeoLin990405)** — provider 模型透传、reasoning 重放、thinking-only turn 和 Windows 引用修复 (#1740, #1743, #1742, #1744) -- **[nightt5879](https://github.com/nightt5879)** — Ctrl+C 提示恢复修复 (#1764) -- **[h3c-hexin](https://github.com/h3c-hexin)** — 流式批量工具调用保留和 CLI reasoning-effort 透传 (#1686, #1511) -- **[hxy91819](https://github.com/hxy91819)** — 工具结果裁剪时的前缀缓存保留 (#1514) -- **[JiarenWang](https://github.com/JiarenWang)** — Plan 模式只读执行、审批接管优化、Ctrl+H 删除修复和 undo 上下文同步 (#1123, #962, #958, #1150) -- **[Liu-Vince](https://github.com/Liu-Vince)** — MCP 分页、markdown 缩进保留、zh-Hans i18n 优化和环境变量文档 (#1256, #1179, #1274, #1178) -- **[linzhiqin2003](https://github.com/linzhiqin2003)** — `--model auto` 成本节约偏好、执行纪律提示和声明式事实记忆指导 (#1385, #1384, #1381) -- **[lbcheng888](https://github.com/lbcheng888)** — 跨保存/恢复的成本持久化和对话滚动修复 (#1192, #1211) -- **[pengyou200902](https://github.com/pengyou200902)** — UTF-8 安全记忆截断、截断标记精确化和快捷键文档 (#968, #1122, #1095) -- **[ChaceLyee2101](https://github.com/ChaceLyee2101)** — 推理 token 成本统计和 zh-Hans 自动 CNY 显示,以及 zh-CN README 同步 (#1505, #1504) -- **[CrepuscularIRIS](https://github.com/CrepuscularIRIS)** — Termius/SSH 低动画模式和 npx MCP 服务器沙箱修复 (#1479, #1346) -- **[laoye2020](https://github.com/laoye2020)** — Catppuccin、Tokyo Night、Dracula 和 Gruvbox 主题及 `/theme` 选择器 (#1534) -- **[punkcanyang](https://github.com/punkcanyang)** — Kitty (OSC 99) 和 Ghostty (OSC 777) 桌面通知支持 (#1426) -- **[Rene-Kuhm](https://github.com/Rene-Kuhm)** — 西班牙语(es-419)拉丁美洲本地化 (#1452) -- **[sternelee](https://github.com/sternelee)** — DeepSeek 前缀缓存稳定性追踪 (#1517) -- **[ComeFromTheMars](https://github.com/ComeFromTheMars)** — Shift+Up/Down 对话滚动快捷键 (#1432) -- **[sockerch](https://github.com/sockerch)** — 所有斜杠命令的拼音别名 (#1306) -- **[Apeiron0w0](https://github.com/Apeiron0w0)** — Tabby 终端闪烁循环的 FocusGained 去抖动 (#1560) -- **[greyfreedom](https://github.com/greyfreedom)** — 跳转到最新对话按钮 (#969) -- **[SamhandsomeLee](https://github.com/SamhandsomeLee)** — 显式隐藏文件提及补全 (#1270) -- **[dst1213](https://github.com/dst1213)** — 配额错误 HTTP 400 重试 (#1203) -- **[fuleinist](https://github.com/fuleinist)** — `--yolo` 标志从 CLI 转发到 TUI (#1233) -- **[heloanc](https://github.com/heloanc)** — Home/End 键编辑器支持 (#1246) -- **[jinpengxuan](https://github.com/jinpengxuan)** — 入职期间活动 provider 凭据保留 (#1265) -- **[lixiasky-back](https://github.com/lixiasky-back)** — 已验证 npm 二进制采用 (#1339) -- **[J3y0r](https://github.com/J3y0r)** — 工作区切换命令 (#1065) -- **[KhalidAlnujaidi](https://github.com/KhalidAlnujaidi)** — delegate 技能打包 (#1144) -- **[Wenjunyun123](https://github.com/Wenjunyun123)** — 文档锚点偏移保留 (#1282) -- **[whtis](https://github.com/whtis)** — zh-CN README 调度程序路径同步 (#1235) -- **[aqilaziz](https://github.com/aqilaziz)** — memory 技能链接修复 (#1095) -- **[wuwuzhijing](https://github.com/wuwuzhijing)** — rsproxy rustup 变通安装文档 (#1011) -- **[eltociear](https://github.com/eltociear)** — 日语 README 翻译 (#746) -- **[Ling](https://github.com/LING71671)** — `grep_files` 取消令牌支持和 Ctrl+Z 编辑器草稿恢复 (#1839, #1911) -- **[Ben Younes](https://github.com/ousamabenyounes)** — Linux Wayland(非 wlroots)剪贴板支持 (#1938) -- **[Matt Van Horn](https://github.com/mvanhorn)** — Docker 首次运行权限修复和运行时系统提示回归测试 (#1699, #1702) -- **[Kristopher Clark](https://github.com/krisclarkdev)** — compaction 用户查询保留修复 (#1704) -- **[tdccccc](https://github.com/tdccccc)** — 编辑器滚动修复和 pager 鼠标滚轮支持 (#1715, #1716) -- **[LittleBlacky](https://github.com/LittleBlacky)** — provider gated `reasoning_content` 流式修复 (#1680) -- **[Anaheim](https://github.com/AnaheimEX)** — `rlm_open` 空 source schema 校验报告 (#1712) -- **[THatch26](https://github.com/THatch26)** — 终端 resize 后翻页修复 (#1724) -- **[Alvin](https://github.com/alvin1)** — Zed ACP id 兼容性报告 (#1696) -- **[knqiufan](https://github.com/knqiufan)** — sub-agent 文件写入委派工作 (#1833) -- **[IIzzaya](https://github.com/IIzzaya)** — slash 补全精确 alias 优先排序想法 (#1811) -- **[DC](https://github.com/duanchao-lab)** — 终端清理 guard 思路 (#1630) -- **[imkingjh999](https://github.com/imkingjh999)** — provider/model 切换修复 (#1642) -- **[Photo](https://github.com/eng2007)** — provider-aware `/model` picker catalog 工作 (#1201) -- **[chennest](https://github.com/chennest)** — diagnostics schema 报告 (#1685) -- **[kunpeng-ai-lab](https://github.com/kunpeng-ai-lab)** — Windows 编辑器滚动修复 (#1578) -- **[WuMing](https://github.com/asdfg314284230)** — Windows PowerShell 闪烁修复 (#1591) -- **[maker316](https://github.com/maker316)** — LoopGuard/checklist 循环报告 (#1574) -- **[lalala](https://github.com/lalala-233)** — approval denial 回归报告 (#1617) -- **[muyuliyan](https://github.com/muyuliyan)** — `pandoc_convert` 校验修复 (#1523) -- **[czf0718](https://github.com/czf0718)** — resize 和 turn-completion 闪烁修复 (#1537) -- **[MeAiRobot](https://github.com/MeAiRobot)** — toast 覆盖编辑器输入的修复 (#1485) -- **[tiger-dog](https://github.com/tiger-dog)** — approval modal 折叠和 markdown identifier 修复 (#1455) -- **[MMMarcinho](https://github.com/MMMarcinho)** — opt-in `image_analyze` 视觉工具 (#1467) -- **[lucaszhu-hue](https://github.com/lucaszhu-hue)** — AtlasCloud provider 集成 (#1436) -- **[sandofree](https://github.com/sandofree)** — Tavily 和 Bocha `web_search` 后端 (#1294) -- **[zhuangbiaowei](https://github.com/zhuangbiaowei)** — `/change` release notes 命令 (#1416) -- **[NorethSea](https://github.com/NorethSea)** — updater 同步刷新 companion binary 的修复 (#1492) -- **[Jianfengwu2024](https://github.com/Jianfengwu2024)** — Windows MSVC toolchain 环境保留 (#1487) -- **[Fire-dtx](https://github.com/Fire-dtx)** — npm postinstall 可恢复性工作 (#1059) -- **[oooyuy92](https://github.com/oooyuy92)** — 长会话配色可读性报告 (#1070, #936) -- **[qinxianyuzou](https://github.com/qinxianyuzou)** — zh-Hans destructive approval 文案 (#1087, #1091) -- **[tyouter](https://github.com/tyouter)** — session title/history preview 清理 (#1510) -- **[xulongzhe](https://github.com/xulongzhe)** — issue template 和 vision boundary follow-up (#1530, #1544) -- **[YaYII](https://github.com/YaYII)** — trusted media path 工作 (#1462) -- **[47Cid](https://github.com/47Cid)** 和 **[Jafar Akhondali](https://github.com/JafarAkhondali)** — 负责任安全披露和加固报告 +- **[donglovejava](https://github.com/donglovejava)** — paste @file 整合、CJK panic 修复、用户反馈、RLM 路由、edit_file 重试 (#2154–#2168) +- **[encyc](https://github.com/encyc)** — session token 分解显示和 `/status` (#2152) +- **[saieswar237](https://github.com/saieswar237)** — 审查流程文档 (#2178) +- **[sximelon](https://github.com/sximelon)** — paste Enter 抑制、键盘处理提取 (#2174, #2042) +- **[nanookclaw](https://github.com/nanookclaw)** — search provider 显示在 doctor (#2135) +- **[Sskift](https://github.com/Sskift)** — CLI 默认环境变量覆盖防止 (#2119) +- **[xin1104](https://github.com/xin1104)** — Homebrew codewhale 二进制安装 (#2105) +- **[mrluanma](https://github.com/mrluanma)** — Metaso 搜索提供商 (#2059) +- **[Lellansin](https://github.com/Lellansin)** — 主目录下跳过配置合并 (#2055) +- **[zhuangbiaowei](https://github.com/zhuangbiaowei)** — 更新发布渠道 (#2145) --- diff --git a/assets/screenshot.png b/assets/screenshot.png index 4ddd4850..e9c79fee 100644 Binary files a/assets/screenshot.png and b/assets/screenshot.png differ diff --git a/config.example.toml b/config.example.toml index 87af1a8e..46f44aa4 100644 --- a/config.example.toml +++ b/config.example.toml @@ -13,11 +13,12 @@ # `[providers.*]` sections near the bottom of # this file — keeping both stored at once means `/provider deepseek` and # `/provider nvidia-nim` (or `--provider openai`, `--provider wanjie-ark`, -# `--provider fireworks`, `/provider sglang`, `/provider vllm`, `/provider ollama`) -# toggle without having to re-enter keys. Top-level `api_key` / `base_url` are +# `--provider volcengine`, `--provider xiaomi-mimo`, `--provider fireworks`, `/provider sglang`, +# `/provider vllm`, `/provider ollama`) toggle without having to re-enter keys. +# Top-level `api_key` / `base_url` are # still read as DeepSeek defaults when `[providers.deepseek]` is absent # (backward compatibility). -provider = "deepseek" # deepseek | deepseek-cn | nvidia-nim | openai | atlascloud | wanjie-ark | openrouter | novita | fireworks | sglang | vllm | ollama +provider = "deepseek" # deepseek | deepseek-cn | nvidia-nim | openai | atlascloud | wanjie-ark | volcengine | openrouter | xiaomi-mimo | novita | fireworks | sglang | vllm | ollama api_key = "YOUR_DEEPSEEK_API_KEY" # must be non-empty base_url = "https://api.deepseek.com/beta" # provider = "deepseek-cn" # legacy alias (official host is still https://api.deepseek.com) @@ -37,6 +38,7 @@ base_url = "https://api.deepseek.com/beta" # gpt-4.1 — default generic OpenAI-compatible model ID # deepseek-ai/deepseek-v4-flash — default AtlasCloud model ID # deepseek-reasoner — default Wanjie Ark model ID +# mimo-v2.5-pro — default Xiaomi MiMo model ID # accounts/fireworks/models/deepseek-v4-pro — Fireworks AI Pro model ID # deepseek-ai/DeepSeek-V4-Pro — SGLang self-hosted Pro model ID # deepseek-ai/DeepSeek-V4-Flash — SGLang self-hosted Flash model ID @@ -97,6 +99,12 @@ memory_path = "~/.deepseek/memory.md" # Parsed but currently unused (reserved for future versions): # tools_file = "./tools.json" +# Native tool catalog controls (#2076). By default only the core tool surface +# is loaded into the model context; less common native tools are discoverable +# through ToolSearch and loaded on first use. +# [tools] +# always_load = ["git_show", "notify"] + # ───────────────────────────────────────────────────────────────────────────────── # Security # ───────────────────────────────────────────────────────────────────────────────── @@ -131,6 +139,21 @@ sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-acc # The backend uses a 30-second HTTP timeout. Background, interactive, and # TTY modes are not supported with external backends — all commands run # synchronously via HTTP. +# ───────────────────────────────────────────────────────────────────────────────── +# Bubblewrap (Linux only, additional filesystem isolation) +# ───────────────────────────────────────────────────────────────────────────────── +# When set to true and `/usr/bin/bwrap` is present, exec_shell commands are +# routed through bubblewrap instead of relying solely on Landlock. Bubblewrap +# creates a read-only view of the root filesystem with write access limited to +# the working directory. Install separately: +# +# Ubuntu/Debian: apt install bubblewrap +# Fedora: dnf install bubblewrap +# Arch: pacman -S bubblewrap +# +# prefer_bwrap = false # default — use Landlock only +# +# Env override: DEEPSEEK_PREFER_BWRAP=true # auto_allow entries match by command prefix, not raw string. # See command_safety.rs for the prefix dictionary. @@ -165,17 +188,27 @@ max_subagents = 10 # optional (1-20) # OpenAI-compatible: OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL # Wanjie Ark: WANJIE_ARK_API_KEY (or WANJIE_API_KEY), WANJIE_ARK_BASE_URL, WANJIE_ARK_MODEL # OpenRouter: OPENROUTER_API_KEY, OPENROUTER_BASE_URL, OPENROUTER_MODEL +# Xiaomi MiMo: XIAOMI_MIMO_API_KEY (or MIMO_API_KEY), XIAOMI_MIMO_BASE_URL, XIAOMI_MIMO_MODEL # Novita: NOVITA_API_KEY, NOVITA_BASE_URL, NOVITA_MODEL # Fireworks: FIREWORKS_API_KEY, FIREWORKS_BASE_URL # SGLang: SGLANG_BASE_URL, SGLANG_MODEL, optional SGLANG_API_KEY # vLLM: VLLM_BASE_URL, VLLM_MODEL, optional VLLM_API_KEY # Ollama: OLLAMA_BASE_URL, OLLAMA_MODEL, optional OLLAMA_API_KEY +# +# Custom DeepSeek-compatible APIs usually do not need a new provider table: +# set `provider = "deepseek"` and override [providers.deepseek].base_url/model. +# For generic OpenAI-compatible gateways, use `provider = "openai"` and the +# [providers.openai] table below. Keep provider/api_key/base_url in user config +# or environment variables; project overlays are not allowed to set them. # DeepSeek Platform (https://platform.deepseek.com) [providers.deepseek] # api_key = "YOUR_DEEPSEEK_API_KEY" # base_url = "https://api.deepseek.com/beta" # model = "deepseek-v4-pro" +# Custom DeepSeek-compatible example: +# base_url = "https://your-provider.example/v1" +# model = "deepseek-ai/DeepSeek-V4-Pro" # http_headers = { "X-Model-Provider-Id" = "your-model-provider" } # optional custom request headers # NVIDIA NIM-hosted DeepSeek V4 (https://build.nvidia.com) @@ -192,6 +225,9 @@ max_subagents = 10 # optional (1-20) # api_key = "YOUR_OPENAI_COMPATIBLE_API_KEY" # base_url = "https://api.openai.com/v1" # model = "gpt-4.1" +# Gateway example: +# base_url = "https://gateway.example/v1" +# model = "your-deepseek-compatible-model" # AtlasCloud OpenAI-compatible endpoint (https://www.atlascloud.ai/docs/models/llm) [providers.atlascloud] @@ -205,12 +241,24 @@ max_subagents = 10 # optional (1-20) # base_url = "https://maas-openapi.wanjiedata.com/api/v1" # model = "deepseek-reasoner" # or the exact model ID enabled on your Wanjie account +# Volcengine / Volcano Engine Ark Coding API +[providers.volcengine] +# api_key = "YOUR_VOLCENGINE_API_KEY" +# base_url = "https://ark.cn-beijing.volces.com/api/coding/v3" +# model = "DeepSeek-V4-Pro" # or DeepSeek-V4-Flash + # OpenRouter — multi-provider gateway (https://openrouter.ai) [providers.openrouter] # api_key = "YOUR_OPENROUTER_API_KEY" # base_url = "https://openrouter.ai/api/v1" # model = "deepseek/deepseek-v4-pro" # or deepseek/deepseek-v4-flash +# Xiaomi MiMo OpenAI-compatible endpoint (https://platform.xiaomimimo.com) +[providers.xiaomi_mimo] +# api_key = "YOUR_XIAOMI_MIMO_API_KEY" +# base_url = "https://api.xiaomimimo.com/v1" +# model = "mimo-v2.5-pro" + # Novita AI-hosted inference (https://novita.ai) [providers.novita] # api_key = "YOUR_NOVITA_API_KEY" @@ -244,23 +292,29 @@ max_subagents = 10 # optional (1-20) # ───────────────────────────────────────────────────────────────────────────────── # Web Search Provider # ───────────────────────────────────────────────────────────────────────────────── -# Choose which backend `web_search` uses. Default is Bing HTML scraping — no -# API key needed. DuckDuckGo remains selectable and still falls back to Bing -# when its HTML endpoint returns a bot challenge or no parseable results. -# Switch to Tavily or Bocha for reliable search in mainland China. +# Choose which backend `web_search` uses. Default is DuckDuckGo HTML scraping +# with Bing fallback — no API key needed. Bing remains selectable for users who +# explicitly prefer it. Switch to Tavily, Bocha, Metaso, or Baidu for +# API-backed search. # # [search] -# provider = "bing" # bing | duckduckgo | tavily | bocha +# provider = "duckduckgo" # duckduckgo | bing | tavily | bocha | metaso | baidu # # duckduckgo: HTML scrape with Bing fallback -# # tavily: https://tavily.com — AI search, needs api_key -# # bocha: https://bochaai.com — 博查AI搜索,国内友好,需api_key -# api_key = "tvly-YOUR_KEY" # required for tavily and bocha -# # WARNING: treat config.toml like a secret file when -# # storing API keys. Use env vars or `auth set` instead. +# # bing: HTML scrape, no API key +# # tavily: https://tavily.com — AI search, needs api_key +# # bocha: https://bochaai.com — 博查AI搜索,国内友好,需api_key +# # metaso: https://metaso.cn — 秘塔AI搜索,每天 100 次免费 +# # 设置 METASO_API_KEY 或 [search] api_key 可提升额度 +# # baidu: 百度 AI Search via qianfan.baidubce.com,需 api_key +# api_key = "YOUR_SEARCH_KEY" # required for tavily, bocha, and baidu; optional for metaso +# # WARNING: treat config.toml like a secret file when +# # storing API keys. Prefer env vars for local smoke tests. # # Env-var overrides: # DEEPSEEK_SEARCH_PROVIDER → search.provider # DEEPSEEK_SEARCH_API_KEY → search.api_key +# METASO_API_KEY → metaso key fallback +# BAIDU_SEARCH_API_KEY → baidu key fallback # ───────────────────────────────────────────────────────────────────────────────── # Network Policy (#135) @@ -345,6 +399,11 @@ exec_policy = true # model = "gemini-3.1-flash-lite-preview" # Required: vision-capable model ID # api_key = "YOUR_API_KEY" # Optional: defaults to main api_key # base_url = "https://generativelanguage.googleapis.com/v1beta/openai/" # Optional +# +# Xiaomi MiMo image understanding can be configured through the same tool: +# model = "mimo-v2.5" +# api_key = "YOUR_XIAOMI_MIMO_API_KEY" +# base_url = "https://api.xiaomimimo.com/v1" # ───────────────────────────────────────────────────────────────────────────────── # Retry Configuration @@ -508,8 +567,13 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" # go → gopls serve # python → pyright-langserver --stdio # typescript → typescript-language-server --stdio +# java → jdtls +# vue → vue-language-server --stdio # c, cpp → clangd # +# Java support uses Eclipse JDT LS via the `jdtls` command. IntelliJ IDEA is +# not required, and installing IntelliJ IDEA alone does not install `jdtls`. +# # Override the defaults via the `servers` table below. [lsp] # enabled = true @@ -519,6 +583,8 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" # [lsp.servers] # rust = ["rust-analyzer"] # go = ["gopls", "serve"] +# java = ["jdtls"] +# vue = ["vue-language-server", "--stdio"] # ───────────────────────────────────────────────────────────────────────────────── # Hooks (optional) diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index c6d4fd3f..4f98a69c 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -7,5 +7,5 @@ repository.workspace = true description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture" [dependencies] -codewhale-config = { path = "../config", version = "0.8.44" } +codewhale-config = { path = "../config", version = "0.8.46" } serde.workspace = true diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 928973c0..261d51ad 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -74,18 +74,18 @@ impl Default for ModelRegistry { supports_reasoning: true, }, ModelInfo { - id: "gpt-4.1".to_string(), + id: "deepseek-v4-pro".to_string(), provider: ProviderKind::Openai, - aliases: vec!["gpt4.1".to_string(), "gpt-4o".to_string()], + aliases: vec!["openai-compatible-deepseek-v4-pro".to_string()], supports_tools: true, supports_reasoning: true, }, ModelInfo { - id: "gpt-4.1-mini".to_string(), + id: "deepseek-v4-flash".to_string(), provider: ProviderKind::Openai, - aliases: vec!["gpt-4o-mini".to_string()], + aliases: vec!["openai-compatible-deepseek-v4-flash".to_string()], supports_tools: true, - supports_reasoning: false, + supports_reasoning: true, }, ModelInfo { id: "deepseek-reasoner".to_string(), @@ -97,6 +97,29 @@ impl Default for ModelRegistry { supports_tools: true, supports_reasoning: true, }, + ModelInfo { + id: "DeepSeek-V4-Pro".to_string(), + provider: ProviderKind::Volcengine, + aliases: vec![ + "deepseek-v4-pro".to_string(), + "volcengine-deepseek-v4-pro".to_string(), + "ark-deepseek-v4-pro".to_string(), + ], + supports_tools: true, + supports_reasoning: true, + }, + ModelInfo { + id: "DeepSeek-V4-Flash".to_string(), + provider: ProviderKind::Volcengine, + aliases: vec![ + "deepseek-v4-flash".to_string(), + "deepseek-chat".to_string(), + "volcengine-deepseek-v4-flash".to_string(), + "ark-deepseek-v4-flash".to_string(), + ], + supports_tools: true, + supports_reasoning: true, + }, ModelInfo { id: "deepseek/deepseek-v4-pro".to_string(), provider: ProviderKind::Openrouter, @@ -119,6 +142,20 @@ impl Default for ModelRegistry { supports_tools: true, supports_reasoning: true, }, + ModelInfo { + id: "mimo-v2.5-pro".to_string(), + provider: ProviderKind::XiaomiMimo, + aliases: vec!["mimo".to_string()], + supports_tools: true, + supports_reasoning: true, + }, + ModelInfo { + id: "mimo-v2.5".to_string(), + provider: ProviderKind::XiaomiMimo, + aliases: vec!["xiaomi-mimo-v2.5".to_string()], + supports_tools: true, + supports_reasoning: true, + }, ModelInfo { id: "deepseek/deepseek-v4-pro".to_string(), provider: ProviderKind::Novita, @@ -151,6 +188,17 @@ impl Default for ModelRegistry { supports_tools: true, supports_reasoning: true, }, + ModelInfo { + id: "kimi-k2.6".to_string(), + provider: ProviderKind::Moonshot, + aliases: vec![ + "kimi".to_string(), + "kimi-k2".to_string(), + "moonshot-kimi-k2.6".to_string(), + ], + supports_tools: true, + supports_reasoning: true, + }, ModelInfo { id: "deepseek-ai/DeepSeek-V4-Pro".to_string(), provider: ProviderKind::Sglang, @@ -258,7 +306,7 @@ impl ModelRegistry { { return ModelResolution { requested: Some(name.to_string()), - resolved: preserve_requested_model_id_case(model, name), + resolved: model, used_fallback: false, fallback_chain, }; @@ -371,6 +419,16 @@ mod tests { assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro"); } + #[test] + fn xiaomi_mimo_default_uses_canonical_model_id() { + let registry = ModelRegistry::default(); + let resolved = registry.resolve(None, Some(ProviderKind::XiaomiMimo)); + + assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.resolved.id, "mimo-v2.5-pro"); + assert!(resolved.resolved.supports_reasoning); + } + #[test] fn wanjie_ark_default_uses_reasoner_model_id() { let registry = ModelRegistry::default(); @@ -486,12 +544,13 @@ mod tests { } #[test] - fn preserves_requested_model_casing_with_provider_hint() { + fn registry_casing_takes_priority_over_requested_casing_with_provider_hint() { let registry = ModelRegistry::default(); let resolved = registry.resolve(Some("DeepSeek-V4-Pro"), Some(ProviderKind::Deepseek)); assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek); - assert_eq!(resolved.resolved.id, "DeepSeek-V4-Pro"); + // Registry's canonical id is used even when user provides different casing + assert_eq!(resolved.resolved.id, "deepseek-v4-pro"); } #[test] diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index dc87c887..09dd8643 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -10,16 +10,21 @@ description = "Codex-style app-server transport for DeepSeek workspace architect anyhow.workspace = true axum.workspace = true clap.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.44" } -codewhale-config = { path = "../config", version = "0.8.44" } -codewhale-core = { path = "../core", version = "0.8.44" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.44" } -codewhale-hooks = { path = "../hooks", version = "0.8.44" } -codewhale-mcp = { path = "../mcp", version = "0.8.44" } -codewhale-protocol = { path = "../protocol", version = "0.8.44" } -codewhale-state = { path = "../state", version = "0.8.44" } -codewhale-tools = { path = "../tools", version = "0.8.44" } +codewhale-agent = { path = "../agent", version = "0.8.46" } +codewhale-config = { path = "../config", version = "0.8.46" } +codewhale-core = { path = "../core", version = "0.8.46" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.46" } +codewhale-hooks = { path = "../hooks", version = "0.8.46" } +codewhale-mcp = { path = "../mcp", version = "0.8.46" } +codewhale-protocol = { path = "../protocol", version = "0.8.46" } +codewhale-state = { path = "../state", version = "0.8.46" } +codewhale-tools = { path = "../tools", version = "0.8.46" } serde.workspace = true serde_json.workspace = true tokio.workspace = true tower-http.workspace = true +uuid.workspace = true + +[dev-dependencies] +tempfile = "3.16" +tower = "0.5" diff --git a/crates/app-server/src/lib.rs b/crates/app-server/src/lib.rs index e580ed32..a9fe4399 100644 --- a/crates/app-server/src/lib.rs +++ b/crates/app-server/src/lib.rs @@ -2,8 +2,11 @@ use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; -use anyhow::Result; -use axum::extract::State; +use anyhow::{Result, bail}; +use axum::extract::{Request, State}; +use axum::http::{HeaderValue, Method, StatusCode, header}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use codewhale_agent::ModelRegistry; @@ -23,11 +26,25 @@ use serde_json::{Value, json}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::sync::{Mutex, RwLock}; use tower_http::cors::CorsLayer; +use uuid::Uuid; + +const DEFAULT_CORS_ORIGINS: &[&str] = &[ + "http://localhost", + "http://localhost:1420", + "http://localhost:3000", + "http://localhost:5173", + "http://127.0.0.1", + "http://127.0.0.1:1420", + "tauri://localhost", +]; #[derive(Debug, Clone)] pub struct AppServerOptions { pub listen: SocketAddr, pub config_path: Option, + pub auth_token: Option, + pub insecure_no_auth: bool, + pub cors_origins: Vec, } #[derive(Clone)] @@ -36,6 +53,7 @@ struct AppState { config: Arc>, runtime: Arc>, registry: ModelRegistry, + auth_token: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -69,6 +87,12 @@ struct StdioDispatchResult { should_exit: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AppTransport { + Http, + Stdio, +} + #[derive(Debug, Deserialize)] struct ConfigGetParams { key: String, @@ -92,26 +116,37 @@ struct ThreadMessageParams { } pub async fn run(options: AppServerOptions) -> Result<()> { - let state = build_state(options.config_path.clone())?; - - let app = Router::new() - .route("/healthz", get(healthz)) - .route("/thread", post(thread_handler)) - .route("/app", post(app_handler)) - .route("/prompt", post(prompt_handler)) - .route("/tool", post(tool_handler)) - .route("/jobs", get(jobs_handler)) - .route("/mcp/startup", post(mcp_startup_handler)) - .layer(CorsLayer::permissive()) - .with_state(state); + let auth_token = resolve_auth_token(&options)?; + let state = build_state(options.config_path.clone(), auth_token)?; + let app = app_router(state, &options.cors_origins); let listener = tokio::net::TcpListener::bind(options.listen).await?; axum::serve(listener, app).await?; Ok(()) } +fn app_router(state: AppState, cors_origins: &[String]) -> Router { + let protected_routes = Router::new() + .route("/thread", post(thread_handler)) + .route("/app", post(app_handler)) + .route("/prompt", post(prompt_handler)) + .route("/tool", post(tool_handler)) + .route("/jobs", get(jobs_handler)) + .route("/mcp/startup", post(mcp_startup_handler)) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_app_server_token, + )); + + Router::new() + .route("/healthz", get(healthz)) + .merge(protected_routes) + .layer(cors_layer(cors_origins)) + .with_state(state) +} + pub async fn run_stdio(config_path: Option) -> Result<()> { - let state = build_state(config_path)?; + let state = build_state(config_path, None)?; let stdin = tokio::io::stdin(); let stdout = tokio::io::stdout(); let mut reader = BufReader::new(stdin).lines(); @@ -258,10 +293,10 @@ async fn app_handler( State(state): State, Json(req): Json, ) -> Json { - Json(process_app_request(&state, req).await) + Json(process_app_request(&state, req, AppTransport::Http).await) } -fn build_state(config_path: Option) -> Result { +fn build_state(config_path: Option, auth_token: Option) -> Result { let store = ConfigStore::load(config_path.clone())?; let config = store.config.clone(); let registry = ModelRegistry::default(); @@ -294,9 +329,95 @@ fn build_state(config_path: Option) -> Result { config: Arc::new(RwLock::new(config)), runtime: Arc::new(Mutex::new(runtime)), registry, + auth_token, }) } +fn resolve_auth_token(options: &AppServerOptions) -> Result> { + let configured = options.auth_token.as_ref().map(|token| token.trim()); + if let Some(token) = configured + && token.is_empty() + { + bail!("app-server auth token cannot be empty"); + } + + if options.insecure_no_auth { + if !options.listen.ip().is_loopback() { + bail!("refusing unauthenticated app-server bind on non-loopback address"); + } + eprintln!("warning: app-server HTTP auth disabled by --insecure-no-auth"); + return Ok(None); + } + + let token = configured + .map(str::to_string) + .unwrap_or_else(|| format!("cwapp_{}", Uuid::new_v4().simple())); + if options.auth_token.is_some() { + eprintln!("app-server auth: bearer token required for HTTP routes."); + } else { + eprintln!("app-server auth: generated bearer token for this process."); + eprintln!(" Authorization: Bearer {token}"); + eprintln!(" Pass --auth-token or set CODEWHALE_APP_SERVER_TOKEN for a stable token."); + } + Ok(Some(token)) +} + +fn cors_layer(extra_origins: &[String]) -> CorsLayer { + let mut origins: Vec = DEFAULT_CORS_ORIGINS + .iter() + .filter_map(|origin| HeaderValue::from_str(origin).ok()) + .collect(); + for raw in extra_origins { + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + match HeaderValue::from_str(trimmed) { + Ok(value) if !origins.contains(&value) => origins.push(value), + Ok(_) => {} + Err(err) => { + eprintln!("warning: ignoring invalid app-server CORS origin `{trimmed}`: {err}") + } + } + } + + CorsLayer::new() + .allow_origin(origins) + .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) + .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]) +} + +async fn require_app_server_token( + State(state): State, + req: Request, + next: Next, +) -> Response { + let Some(expected) = state.auth_token.as_deref() else { + return next.run(req).await; + }; + let authorized = req + .headers() + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|raw| raw.strip_prefix("Bearer ")) + .is_some_and(|token| token == expected); + + if authorized { + next.run(req).await + } else { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": { + "message": "app-server bearer token required", + "status": StatusCode::UNAUTHORIZED.as_u16(), + } + })), + ) + .into_response() + } +} + fn params_or_object(params: Value) -> Value { if params.is_null() { json!({}) } else { params } } @@ -585,7 +706,8 @@ async fn dispatch_stdio_request( } } "app/capabilities" => { - let response = process_app_request(state, AppRequest::Capabilities).await; + let response = + process_app_request(state, AppRequest::Capabilities, AppTransport::Stdio).await; StdioDispatchResult { result: serde_json::to_value(response) .map_err(|err| JsonRpcError::internal(err.to_string()))?, @@ -594,7 +716,7 @@ async fn dispatch_stdio_request( } "app/request" => { let request: AppRequest = parse_params(params)?; - let response = process_app_request(state, request).await; + let response = process_app_request(state, request, AppTransport::Stdio).await; StdioDispatchResult { result: serde_json::to_value(response) .map_err(|err| JsonRpcError::internal(err.to_string()))?, @@ -603,8 +725,12 @@ async fn dispatch_stdio_request( } "app/config/get" => { let parsed: ConfigGetParams = parse_params(params_or_object(params))?; - let response = - process_app_request(state, AppRequest::ConfigGet { key: parsed.key }).await; + let response = process_app_request( + state, + AppRequest::ConfigGet { key: parsed.key }, + AppTransport::Stdio, + ) + .await; StdioDispatchResult { result: serde_json::to_value(response) .map_err(|err| JsonRpcError::internal(err.to_string()))?, @@ -619,6 +745,7 @@ async fn dispatch_stdio_request( key: parsed.key, value: parsed.value, }, + AppTransport::Stdio, ) .await; StdioDispatchResult { @@ -629,8 +756,12 @@ async fn dispatch_stdio_request( } "app/config/unset" => { let parsed: ConfigGetParams = parse_params(params_or_object(params))?; - let response = - process_app_request(state, AppRequest::ConfigUnset { key: parsed.key }).await; + let response = process_app_request( + state, + AppRequest::ConfigUnset { key: parsed.key }, + AppTransport::Stdio, + ) + .await; StdioDispatchResult { result: serde_json::to_value(response) .map_err(|err| JsonRpcError::internal(err.to_string()))?, @@ -638,7 +769,8 @@ async fn dispatch_stdio_request( } } "app/config/list" => { - let response = process_app_request(state, AppRequest::ConfigList).await; + let response = + process_app_request(state, AppRequest::ConfigList, AppTransport::Stdio).await; StdioDispatchResult { result: serde_json::to_value(response) .map_err(|err| JsonRpcError::internal(err.to_string()))?, @@ -646,7 +778,8 @@ async fn dispatch_stdio_request( } } "app/models" => { - let response = process_app_request(state, AppRequest::Models).await; + let response = + process_app_request(state, AppRequest::Models, AppTransport::Stdio).await; StdioDispatchResult { result: serde_json::to_value(response) .map_err(|err| JsonRpcError::internal(err.to_string()))?, @@ -654,7 +787,8 @@ async fn dispatch_stdio_request( } } "app/thread_loaded_list" | "app/thread-loaded-list" => { - let response = process_app_request(state, AppRequest::ThreadLoadedList).await; + let response = + process_app_request(state, AppRequest::ThreadLoadedList, AppTransport::Stdio).await; StdioDispatchResult { result: serde_json::to_value(response) .map_err(|err| JsonRpcError::internal(err.to_string()))?, @@ -685,7 +819,11 @@ async fn dispatch_stdio_request( Ok(outcome) } -async fn process_app_request(state: &AppState, req: AppRequest) -> AppResponse { +async fn process_app_request( + state: &AppState, + req: AppRequest, + transport: AppTransport, +) -> AppResponse { match req { AppRequest::Capabilities => AppResponse { ok: true, @@ -700,9 +838,13 @@ async fn process_app_request(state: &AppState, req: AppRequest) -> AppResponse { }, AppRequest::ConfigGet { key } => { let cfg = state.config.read().await; + let value = match transport { + AppTransport::Http => cfg.get_display_value(&key), + AppTransport::Stdio => cfg.get_value(&key), + }; AppResponse { ok: true, - data: json!({ "key": key, "value": cfg.get_value(&key) }), + data: json!({ "key": key, "value": value }), events: Vec::new(), } } @@ -781,3 +923,141 @@ async fn persist_config(state: &AppState, config: codewhale_config::ConfigToml) store.config = config; store.save() } + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::{Body, to_bytes}; + use codewhale_protocol::AppRequest; + use std::fs; + use tower::ServiceExt; + + fn app_with_config(auth_token: Option<&str>) -> (Router, tempfile::TempDir) { + let tmp = tempfile::tempdir().expect("tempdir"); + let config_path = tmp.path().join("config.toml"); + fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config"); + let state = build_state( + Some(config_path), + auth_token.map(std::string::ToString::to_string), + ) + .expect("state"); + (app_router(state, &[]), tmp) + } + + async fn response_body_json(response: Response) -> Value { + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body bytes"); + serde_json::from_slice(&bytes).expect("json response") + } + + #[tokio::test] + async fn http_app_routes_require_bearer_token_when_auth_enabled() { + let (app, _tmp) = app_with_config(Some("test-token")); + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/app") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&AppRequest::ConfigGet { + key: "api_key".to_string(), + }) + .expect("request json"), + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn http_config_get_redacts_sensitive_values_after_auth() { + let (app, _tmp) = app_with_config(Some("test-token")); + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/app") + .header(header::AUTHORIZATION, "Bearer test-token") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&AppRequest::ConfigGet { + key: "api_key".to_string(), + }) + .expect("request json"), + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = response_body_json(response).await; + assert_eq!(body["data"]["value"], "sk-d***cret"); + } + + #[tokio::test] + async fn cors_does_not_allow_arbitrary_origins() { + let (app, _tmp) = app_with_config(Some("test-token")); + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/healthz") + .header(header::ORIGIN, "https://attacker.example") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert!( + response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .is_none() + ); + } + + #[test] + fn non_loopback_bind_without_auth_fails_fast() { + let options = AppServerOptions { + listen: "0.0.0.0:8787".parse().expect("socket addr"), + config_path: None, + auth_token: None, + insecure_no_auth: true, + cors_origins: Vec::new(), + }; + + let err = resolve_auth_token(&options).expect_err("non-loopback unauth should fail"); + assert!( + err.to_string() + .contains("refusing unauthenticated app-server bind") + ); + } + + #[tokio::test] + async fn stdio_transport_keeps_raw_config_get_for_legacy_clients() { + let state = build_state(None, None).expect("state"); + { + let mut cfg = state.config.write().await; + cfg.api_key = Some("sk-deepseek-secret".to_string()); + } + + let response = process_app_request( + &state, + AppRequest::ConfigGet { + key: "api_key".to_string(), + }, + AppTransport::Stdio, + ) + .await; + + assert_eq!(response.data["value"], "sk-deepseek-secret"); + } +} diff --git a/crates/app-server/src/main.rs b/crates/app-server/src/main.rs index fef6b65d..9627746e 100644 --- a/crates/app-server/src/main.rs +++ b/crates/app-server/src/main.rs @@ -17,6 +17,12 @@ struct Cli { port: u16, #[arg(long)] config: Option, + #[arg(long = "auth-token")] + auth_token: Option, + #[arg(long, default_value_t = false)] + insecure_no_auth: bool, + #[arg(long = "cors-origin")] + cors_origin: Vec, } #[tokio::main] @@ -28,6 +34,15 @@ async fn main() -> Result<()> { run(AppServerOptions { listen, config_path: cli.config, + auth_token: cli.auth_token.or_else(app_server_token_from_env), + insecure_no_auth: cli.insecure_no_auth, + cors_origins: cli.cors_origin, }) .await } + +fn app_server_token_from_env() -> Option { + std::env::var("CODEWHALE_APP_SERVER_TOKEN") + .ok() + .or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok()) +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 87ef1e74..63a8ddb9 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -25,18 +25,20 @@ path = "src/bin/deepseek_legacy_shim.rs" anyhow.workspace = true clap.workspace = true clap_complete.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.44" } -codewhale-app-server = { path = "../app-server", version = "0.8.44" } -codewhale-config = { path = "../config", version = "0.8.44" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.44" } -codewhale-mcp = { path = "../mcp", version = "0.8.44" } -codewhale-secrets = { path = "../secrets", version = "0.8.44" } -codewhale-state = { path = "../state", version = "0.8.44" } +codewhale-agent = { path = "../agent", version = "0.8.46" } +codewhale-app-server = { path = "../app-server", version = "0.8.46" } +codewhale-config = { path = "../config", version = "0.8.46" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.46" } +codewhale-mcp = { path = "../mcp", version = "0.8.46" } +codewhale-release = { path = "../release", version = "0.8.46" } +codewhale-secrets = { path = "../secrets", version = "0.8.46" } +codewhale-state = { path = "../state", version = "0.8.46" } chrono.workspace = true dirs.workspace = true serde.workspace = true serde_json.workspace = true reqwest = { workspace = true, features = ["blocking"] } +semver.workspace = true tokio.workspace = true sha2.workspace = true tempfile = "3.16" diff --git a/crates/cli/src/bin/codew_legacy_shim.rs b/crates/cli/src/bin/codew_legacy_shim.rs index 165e05a9..870128fb 100644 --- a/crates/cli/src/bin/codew_legacy_shim.rs +++ b/crates/cli/src/bin/codew_legacy_shim.rs @@ -37,12 +37,12 @@ fn spawn_codewhale(args: &[String]) -> std::io::Result // same directory as this shim but not on PATH (#2006). #[cfg(windows)] { - if let Ok(exe_path) = env::current_exe() { - if let Some(dir) = exe_path.parent() { - let sibling = dir.join("codewhale.exe"); - if sibling.is_file() { - return Command::new(sibling).args(args).status(); - } + if let Ok(exe_path) = env::current_exe() + && let Some(dir) = exe_path.parent() + { + let sibling = dir.join("codewhale.exe"); + if sibling.is_file() { + return Command::new(sibling).args(args).status(); } } } diff --git a/crates/cli/src/bin/deepseek_legacy_shim.rs b/crates/cli/src/bin/deepseek_legacy_shim.rs index b47c9d92..abd00896 100644 --- a/crates/cli/src/bin/deepseek_legacy_shim.rs +++ b/crates/cli/src/bin/deepseek_legacy_shim.rs @@ -44,12 +44,12 @@ fn spawn_codewhale(args: &[String]) -> std::io::Result // same directory as this shim but not on PATH (#2006). #[cfg(windows)] { - if let Ok(exe_path) = env::current_exe() { - if let Some(dir) = exe_path.parent() { - let sibling = dir.join("codewhale.exe"); - if sibling.is_file() { - return Command::new(sibling).args(args).status(); - } + if let Ok(exe_path) = env::current_exe() + && let Some(dir) = exe_path.parent() + { + let sibling = dir.join("codewhale.exe"); + if sibling.is_file() { + return Command::new(sibling).args(args).status(); } } } diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index c27d699f..cdd7cb4f 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -28,9 +28,12 @@ enum ProviderArg { Openai, Atlascloud, WanjieArk, + Volcengine, Openrouter, + XiaomiMimo, Novita, Fireworks, + Moonshot, Sglang, Vllm, Ollama, @@ -44,9 +47,12 @@ impl From for ProviderKind { ProviderArg::Openai => ProviderKind::Openai, ProviderArg::Atlascloud => ProviderKind::Atlascloud, ProviderArg::WanjieArk => ProviderKind::WanjieArk, + ProviderArg::Volcengine => ProviderKind::Volcengine, ProviderArg::Openrouter => ProviderKind::Openrouter, + ProviderArg::XiaomiMimo => ProviderKind::XiaomiMimo, ProviderArg::Novita => ProviderKind::Novita, ProviderArg::Fireworks => ProviderKind::Fireworks, + ProviderArg::Moonshot => ProviderKind::Moonshot, ProviderArg::Sglang => ProviderKind::Sglang, ProviderArg::Vllm => ProviderKind::Vllm, ProviderArg::Ollama => ProviderKind::Ollama, @@ -180,7 +186,7 @@ working-tree diff. `export` only writes the current diff. Serve(TuiPassthroughArgs), /// Generate shell completions for the TUI binary. Completions(TuiPassthroughArgs), - /// Save a provider API key to the shared user config file. + /// Configure provider credentials. Login(LoginArgs), /// Remove saved authentication state. Logout, @@ -230,7 +236,20 @@ The command prints the completion script to stdout; redirect it to a path your s /// Print a usage rollup from the audit log and session store. Metrics(MetricsArgs), /// Check for and apply updates to the `codewhale` binary. - Update, + Update(UpdateArgs), +} + +#[derive(Debug, Args)] +struct UpdateArgs { + /// Update to the latest beta release instead of the latest stable release. + #[arg(long)] + beta: bool, + /// Only check the latest release; do not download or replace binaries. + #[arg(long)] + check: bool, + /// Proxy URL to use for update HTTP requests. + #[arg(long, value_name = "URL")] + proxy: Option, } #[derive(Debug, Args)] @@ -257,16 +276,10 @@ struct TuiPassthroughArgs { #[derive(Debug, Args)] struct LoginArgs { - #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)] - provider: ProviderArg, + #[arg(long, value_enum, hide = true)] + provider: Option, #[arg(long)] api_key: Option, - #[arg(long, default_value_t = false, hide = true)] - chatgpt: bool, - #[arg(long, default_value_t = false, hide = true)] - device_code: bool, - #[arg(long, hide = true)] - token: Option, } #[derive(Debug, Args)] @@ -382,6 +395,11 @@ enum ThreadCommand { thread_id: String, name: String, }, + /// Remove the custom name from a thread, restoring the default + /// `(unnamed)` rendering in `thread list`. + ClearName { + thread_id: String, + }, } #[derive(Debug, Args)] @@ -426,6 +444,12 @@ struct AppServerArgs { port: u16, #[arg(long)] config: Option, + #[arg(long = "auth-token")] + auth_token: Option, + #[arg(long, default_value_t = false)] + insecure_no_auth: bool, + #[arg(long = "cors-origin")] + cors_origin: Vec, #[arg(long, default_value_t = false)] stdio: bool, } @@ -555,7 +579,7 @@ fn run() -> Result<()> { Ok(()) } Some(Commands::Metrics(args)) => run_metrics_command(args), - Some(Commands::Update) => update::run_update(), + Some(Commands::Update(args)) => update::run_update(args.beta, args.check, args.proxy), None => { let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides); let forwarded = root_tui_passthrough(&cli)?; @@ -652,38 +676,9 @@ fn run_login_command_with_secrets( args: LoginArgs, secrets: &Secrets, ) -> Result<()> { - let provider: ProviderKind = args.provider.into(); + let provider: ProviderKind = args.provider.unwrap_or(ProviderArg::Deepseek).into(); store.config.provider = provider; - if args.chatgpt { - let token = match args.token { - Some(token) => token, - None => read_api_key_from_stdin()?, - }; - store.config.auth_mode = Some("chatgpt".to_string()); - store.config.chatgpt_access_token = Some(token); - store.config.device_code_session = None; - store.save()?; - println!("logged in using chatgpt token mode ({})", provider.as_str()); - return Ok(()); - } - - if args.device_code { - let token = match args.token { - Some(token) => token, - None => read_api_key_from_stdin()?, - }; - store.config.auth_mode = Some("device_code".to_string()); - store.config.device_code_session = Some(token); - store.config.chatgpt_access_token = None; - store.save()?; - println!( - "logged in using device code session mode ({})", - provider.as_str() - ); - return Ok(()); - } - let api_key = match args.api_key { Some(v) => v, None => read_api_key_from_stdin()?, @@ -719,8 +714,6 @@ fn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) - } clear_provider_api_key_from_keyring(secrets, active_provider); store.config.auth_mode = None; - store.config.chatgpt_access_token = None; - store.config.device_code_session = None; store.save()?; println!("logged out"); Ok(()) @@ -734,9 +727,12 @@ fn provider_slot(provider: ProviderKind) -> &'static str { ProviderKind::Openai => "openai", ProviderKind::Atlascloud => "atlascloud", ProviderKind::WanjieArk => "wanjie-ark", + ProviderKind::Volcengine => "volcengine", ProviderKind::Openrouter => "openrouter", + ProviderKind::XiaomiMimo => "xiaomi-mimo", ProviderKind::Novita => "novita", ProviderKind::Fireworks => "fireworks", + ProviderKind::Moonshot => "moonshot", ProviderKind::Sglang => "sglang", ProviderKind::Vllm => "vllm", ProviderKind::Ollama => "ollama", @@ -744,15 +740,18 @@ fn provider_slot(provider: ProviderKind) -> &'static str { } /// Provider order used by the `auth list` and `auth status` outputs. -const PROVIDER_LIST: [ProviderKind; 11] = [ +const PROVIDER_LIST: [ProviderKind; 14] = [ ProviderKind::Deepseek, ProviderKind::NvidiaNim, ProviderKind::Openai, ProviderKind::Atlascloud, ProviderKind::WanjieArk, + ProviderKind::Volcengine, ProviderKind::Openrouter, + ProviderKind::XiaomiMimo, ProviderKind::Novita, ProviderKind::Fireworks, + ProviderKind::Moonshot, ProviderKind::Sglang, ProviderKind::Vllm, ProviderKind::Ollama, @@ -804,14 +803,21 @@ fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] { match provider { ProviderKind::Deepseek => &["DEEPSEEK_API_KEY"], ProviderKind::Openrouter => &["OPENROUTER_API_KEY"], + ProviderKind::XiaomiMimo => &["XIAOMI_MIMO_API_KEY", "MIMO_API_KEY"], ProviderKind::Novita => &["NOVITA_API_KEY"], ProviderKind::NvidiaNim => &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"], ProviderKind::Fireworks => &["FIREWORKS_API_KEY"], + ProviderKind::Moonshot => &["MOONSHOT_API_KEY", "KIMI_API_KEY"], ProviderKind::Sglang => &["SGLANG_API_KEY"], ProviderKind::Vllm => &["VLLM_API_KEY"], ProviderKind::Ollama => &["OLLAMA_API_KEY"], ProviderKind::Openai => &["OPENAI_API_KEY"], ProviderKind::Atlascloud => &["ATLASCLOUD_API_KEY"], + ProviderKind::Volcengine => &[ + "VOLCENGINE_API_KEY", + "VOLCENGINE_ARK_API_KEY", + "ARK_API_KEY", + ], ProviderKind::WanjieArk => &[ "WANJIE_ARK_API_KEY", "WANJIE_API_KEY", @@ -904,6 +910,10 @@ fn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec { vec![ format!("provider: {}", provider.as_str()), + format!( + "auth mode: {}", + store.config.auth_mode.as_deref().unwrap_or("api_key") + ), format!("active source: {active_label}"), "lookup order: config -> secret store -> env".to_string(), format!( @@ -1273,6 +1283,16 @@ fn run_thread_command(command: ThreadCommand) -> Result<()> { println!("renamed {thread_id}"); Ok(()) } + ThreadCommand::ClearName { thread_id } => { + let mut thread = state + .get_thread(&thread_id)? + .with_context(|| format!("thread not found: {thread_id}"))?; + thread.name = None; + thread.updated_at = chrono::Utc::now().timestamp(); + state.upsert_thread(&thread)?; + println!("cleared name for {thread_id}"); + Ok(()) + } } } @@ -1312,9 +1332,18 @@ fn run_app_server_command(args: AppServerArgs) -> Result<()> { runtime.block_on(run_app_server(AppServerOptions { listen, config_path: args.config, + auth_token: args.auth_token.or_else(app_server_token_from_env), + insecure_no_auth: args.insecure_no_auth, + cors_origins: args.cors_origin, })) } +fn app_server_token_from_env() -> Option { + std::env::var("CODEWHALE_APP_SERVER_TOKEN") + .ok() + .or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok()) +} + fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> { let persisted = load_mcp_server_definitions(store); let updated = run_stdio_server(persisted)?; @@ -1464,46 +1493,42 @@ fn build_tui_command( | ProviderKind::Atlascloud | ProviderKind::WanjieArk | ProviderKind::Openrouter + | ProviderKind::XiaomiMimo | ProviderKind::Novita | ProviderKind::Fireworks + | ProviderKind::Moonshot | ProviderKind::Sglang | ProviderKind::Vllm | ProviderKind::Ollama ) { bail!( - "The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.", + "The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Xiaomi MiMo, Novita, Fireworks, Moonshot/Kimi, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.", resolved_runtime.provider.as_str() ); } - cmd.env("DEEPSEEK_MODEL", &resolved_runtime.model); - cmd.env("DEEPSEEK_BASE_URL", &resolved_runtime.base_url); - cmd.env("DEEPSEEK_PROVIDER", resolved_runtime.provider.as_str()); - if !resolved_runtime.http_headers.is_empty() { - let encoded = resolved_runtime - .http_headers - .iter() - .map(|(name, value)| format!("{}={}", name.trim(), value.trim())) - .collect::>() - .join(","); - cmd.env("DEEPSEEK_HTTP_HEADERS", encoded); + if let Some(provider) = cli.provider { + let provider: ProviderKind = provider.into(); + cmd.env("DEEPSEEK_PROVIDER", provider.as_str()); } - if let Some(api_key) = resolved_runtime.api_key.as_ref() { + if matches!( + resolved_runtime.api_key_source, + Some(RuntimeApiKeySource::Keyring) + ) && let Some(api_key) = resolved_runtime.api_key.as_ref() + { + // TUI reloads auth_mode from config/profile, but it does not re-query the + // platform keyring on normal startup. Bridge only the recovered secret; + // replaying auth_mode here would turn it back into a profile override. cmd.env("DEEPSEEK_API_KEY", api_key); - if resolved_runtime.provider == ProviderKind::Openai { - cmd.env("OPENAI_API_KEY", api_key); + for var in provider_env_vars(resolved_runtime.provider) { + if *var != "DEEPSEEK_API_KEY" { + cmd.env(var, api_key); + } } - if resolved_runtime.provider == ProviderKind::Atlascloud { - cmd.env("ATLASCLOUD_API_KEY", api_key); - } - if resolved_runtime.provider == ProviderKind::WanjieArk { - cmd.env("WANJIE_ARK_API_KEY", api_key); - } - let source = resolved_runtime - .api_key_source - .unwrap_or(RuntimeApiKeySource::Env) - .as_env_value(); - cmd.env("DEEPSEEK_API_KEY_SOURCE", source); + cmd.env( + "DEEPSEEK_API_KEY_SOURCE", + RuntimeApiKeySource::Keyring.as_env_value(), + ); } if let Some(model) = cli.model.as_ref() { @@ -1538,6 +1563,9 @@ fn build_tui_command( if resolved_runtime.provider == ProviderKind::WanjieArk { cmd.env("WANJIE_ARK_API_KEY", api_key); } + if resolved_runtime.provider == ProviderKind::Volcengine { + cmd.env("VOLCENGINE_API_KEY", api_key); + } cmd.env("DEEPSEEK_API_KEY_SOURCE", "cli"); } if let Some(base_url) = cli.base_url.as_ref() { @@ -1808,6 +1836,47 @@ mod tests { )); } + #[test] + fn parses_update_beta_flag() { + let cli = parse_ok(&["codewhale", "update"]); + assert!(matches!( + cli.command, + Some(Commands::Update(UpdateArgs { + beta: false, + check: false, + proxy: None + })) + )); + + let cli = parse_ok(&["codewhale", "update", "--beta"]); + assert!(matches!( + cli.command, + Some(Commands::Update(UpdateArgs { + beta: true, + check: false, + proxy: None + })) + )); + + let cli = parse_ok(&["codewhale", "update", "--check"]); + assert!(matches!( + cli.command, + Some(Commands::Update(UpdateArgs { + beta: false, + check: true, + proxy: None + })) + )); + + let cli = parse_ok(&["codewhale", "update", "--proxy", "socks5://127.0.0.1:1080"]); + let Some(Commands::Update(args)) = cli.command else { + panic!("expected update command"); + }; + assert!(!args.beta); + assert!(!args.check); + assert_eq!(args.proxy.as_deref(), Some("socks5://127.0.0.1:1080")); + } + #[test] fn parses_model_command_matrix() { let cli = parse_ok(&["deepseek", "model", "list"]); @@ -1921,6 +1990,14 @@ mod tests { } })) if thread_id == "thread-6" && name == "My Thread" )); + + let cli = parse_ok(&["deepseek", "thread", "clear-name", "thread-7"]); + assert!(matches!( + cli.command, + Some(Commands::Thread(ThreadArgs { + command: ThreadCommand::ClearName { ref thread_id } + })) if thread_id == "thread-7" + )); } #[test] @@ -2035,11 +2112,8 @@ mod tests { run_login_command_with_secrets( &mut store, LoginArgs { - provider: ProviderArg::Deepseek, + provider: Some(ProviderArg::Deepseek), api_key: Some("sk-test".to_string()), - chatgpt: false, - device_code: false, - token: None, }, &secrets, ) @@ -2126,6 +2200,18 @@ mod tests { })) )); + let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "moonshot"]); + assert!(matches!( + cli.command, + Some(Commands::Auth(AuthArgs { + command: AuthCommand::Set { + provider: ProviderArg::Moonshot, + api_key: None, + api_key_stdin: false, + } + })) + )); + let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "wanjie-ark"]); assert!(matches!( cli.command, @@ -2549,7 +2635,7 @@ mod tests { "--profile", "work", "--model", - "gpt-4.1", + "deepseek-v4-pro", "--output-mode", "json", "--log-level", @@ -2561,7 +2647,7 @@ mod tests { "--sandbox-mode", "workspace-write", "--base-url", - "https://api.openai.com/v1", + "https://openai-compatible.example/v1", "--api-key", "sk-test", "--workspace", @@ -2571,19 +2657,22 @@ mod tests { "--skip-onboarding", "model", "resolve", - "gpt-4.1", + "deepseek-v4-pro", ]); assert!(matches!(cli.provider, Some(ProviderArg::Openai))); assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml"))); assert_eq!(cli.profile.as_deref(), Some("work")); - assert_eq!(cli.model.as_deref(), Some("gpt-4.1")); + assert_eq!(cli.model.as_deref(), Some("deepseek-v4-pro")); assert_eq!(cli.output_mode.as_deref(), Some("json")); assert_eq!(cli.log_level.as_deref(), Some("debug")); assert_eq!(cli.telemetry, Some(true)); assert_eq!(cli.approval_policy.as_deref(), Some("on-request")); assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write")); - assert_eq!(cli.base_url.as_deref(), Some("https://api.openai.com/v1")); + assert_eq!( + cli.base_url.as_deref(), + Some("https://openai-compatible.example/v1") + ); assert_eq!(cli.api_key.as_deref(), Some("sk-test")); assert_eq!(cli.workspace, Some(PathBuf::from("/tmp/workspace"))); assert!(cli.no_alt_screen); @@ -2631,14 +2720,6 @@ mod tests { command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(), Some("openai") ); - assert_eq!( - command_env(&cmd, "DEEPSEEK_MODEL").as_deref(), - Some("glm-5") - ); - assert_eq!( - command_env(&cmd, "DEEPSEEK_BASE_URL").as_deref(), - Some("https://openai-compatible.example/v4") - ); assert_eq!( command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(), Some("resolved-openai-key") @@ -2651,6 +2732,7 @@ mod tests { command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(), Some("keyring") ); + assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None); let args: Vec = cmd .get_args() .map(|arg| arg.to_string_lossy().into_owned()) @@ -2663,11 +2745,283 @@ mod tests { } #[test] - fn parses_top_level_prompt_flag_for_canonical_one_shot() { + fn build_tui_command_does_not_export_default_runtime_overrides_for_profiles() { + let _lock = env_lock(); + let dir = tempfile::TempDir::new().expect("tempdir"); + let custom = dir + .path() + .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX)); + std::fs::write(&custom, b"").unwrap(); + let custom_str = custom.to_string_lossy().into_owned(); + let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str); + + let cli = parse_ok(&["deepseek", "--profile", "google"]); + let mut resolved_headers = std::collections::BTreeMap::new(); + resolved_headers.insert("X-From-Base".to_string(), "base".to_string()); + let resolved = ResolvedRuntimeOptions { + provider: ProviderKind::Deepseek, + model: "deepseek-v4-pro".to_string(), + api_key: Some("config-file-key".to_string()), + api_key_source: Some(RuntimeApiKeySource::ConfigFile), + base_url: "https://api.deepseek.com/beta".to_string(), + auth_mode: Some("api_key".to_string()), + output_mode: None, + log_level: None, + telemetry: false, + approval_policy: None, + sandbox_mode: None, + yolo: None, + http_headers: resolved_headers, + }; + + let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command"); + + assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None); + assert_eq!(command_env(&cmd, "DEEPSEEK_MODEL"), None); + assert_eq!(command_env(&cmd, "DEEPSEEK_BASE_URL"), None); + assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY"), None); + assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE"), None); + assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None); + assert_eq!(command_env(&cmd, "DEEPSEEK_HTTP_HEADERS"), None); + let args: Vec = cmd + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + assert!( + args.windows(2).any(|pair| pair == ["--profile", "google"]), + "expected profile forwarding in args: {args:?}" + ); + } + + #[test] + fn build_tui_command_allows_moonshot_and_forwards_kimi_key() { + let _lock = env_lock(); + let dir = tempfile::TempDir::new().expect("tempdir"); + let custom = dir + .path() + .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX)); + std::fs::write(&custom, b"").unwrap(); + let custom_str = custom.to_string_lossy().into_owned(); + let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str); + + let cli = parse_ok(&[ + "codewhale", + "--provider", + "moonshot", + "--model", + "kimi-k2.6", + "--workspace", + "/tmp/codewhale-workspace", + ]); + let resolved = ResolvedRuntimeOptions { + provider: ProviderKind::Moonshot, + model: "kimi-k2.6".to_string(), + api_key: Some("resolved-kimi-key".to_string()), + api_key_source: Some(RuntimeApiKeySource::Keyring), + base_url: "https://api.moonshot.ai/v1".to_string(), + auth_mode: Some("api_key".to_string()), + output_mode: None, + log_level: None, + telemetry: false, + approval_policy: None, + sandbox_mode: None, + yolo: None, + http_headers: std::collections::BTreeMap::new(), + }; + + let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command"); + assert_eq!( + command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(), + Some("moonshot") + ); + assert_eq!( + command_env(&cmd, "DEEPSEEK_MODEL").as_deref(), + Some("kimi-k2.6") + ); + assert_eq!( + command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(), + Some("resolved-kimi-key") + ); + assert_eq!( + command_env(&cmd, "MOONSHOT_API_KEY").as_deref(), + Some("resolved-kimi-key") + ); + assert_eq!( + command_env(&cmd, "KIMI_API_KEY").as_deref(), + Some("resolved-kimi-key") + ); + assert_eq!( + command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(), + Some("keyring") + ); + assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None); + } + + #[test] + fn build_tui_command_exports_explicit_provider_model_and_base_url() { + let _lock = env_lock(); + let dir = tempfile::TempDir::new().expect("tempdir"); + let custom = dir + .path() + .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX)); + std::fs::write(&custom, b"").unwrap(); + let custom_str = custom.to_string_lossy().into_owned(); + let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str); + + let cli = parse_ok(&[ + "deepseek", + "--profile", + "google", + "--provider", + "openai", + "--model", + "glm-5", + "--base-url", + "https://openai-compatible.example/v4", + ]); + let resolved = ResolvedRuntimeOptions { + provider: ProviderKind::Openai, + model: "glm-5".to_string(), + api_key: None, + api_key_source: None, + base_url: "https://openai-compatible.example/v4".to_string(), + auth_mode: None, + output_mode: None, + log_level: None, + telemetry: false, + approval_policy: None, + sandbox_mode: None, + yolo: None, + http_headers: std::collections::BTreeMap::new(), + }; + + let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command"); + + assert_eq!( + command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(), + Some("openai") + ); + assert_eq!( + command_env(&cmd, "DEEPSEEK_MODEL").as_deref(), + Some("glm-5") + ); + assert_eq!( + command_env(&cmd, "DEEPSEEK_BASE_URL").as_deref(), + Some("https://openai-compatible.example/v4") + ); + } + + #[test] + fn build_tui_command_forwards_provider_keyring_env_vars_for_all_providers() { + let _lock = env_lock(); + let dir = tempfile::TempDir::new().expect("tempdir"); + let custom = dir + .path() + .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX)); + std::fs::write(&custom, b"").unwrap(); + let custom_str = custom.to_string_lossy().into_owned(); + let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str); + + // (provider, cli flag, extra env vars that must be forwarded besides DEEPSEEK_API_KEY) + let cases: &[(ProviderKind, &str, &[&str])] = &[ + ( + ProviderKind::Openrouter, + "openrouter", + &["OPENROUTER_API_KEY"], + ), + ( + ProviderKind::XiaomiMimo, + "xiaomi-mimo", + &["XIAOMI_MIMO_API_KEY", "MIMO_API_KEY"], + ), + (ProviderKind::Novita, "novita", &["NOVITA_API_KEY"]), + ( + ProviderKind::NvidiaNim, + "nvidia-nim", + &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY"], + ), + (ProviderKind::Fireworks, "fireworks", &["FIREWORKS_API_KEY"]), + (ProviderKind::Sglang, "sglang", &["SGLANG_API_KEY"]), + (ProviderKind::Vllm, "vllm", &["VLLM_API_KEY"]), + (ProviderKind::Ollama, "ollama", &["OLLAMA_API_KEY"]), + ( + ProviderKind::Atlascloud, + "atlascloud", + &["ATLASCLOUD_API_KEY"], + ), + ( + ProviderKind::WanjieArk, + "wanjie-ark", + &[ + "WANJIE_ARK_API_KEY", + "WANJIE_API_KEY", + "WANJIE_MAAS_API_KEY", + ], + ), + ]; + + for &(provider, flag, expected_vars) in cases { + let cli = parse_ok(&[ + "codewhale", + "--provider", + flag, + "--workspace", + "/tmp/codewhale-workspace", + ]); + let resolved = ResolvedRuntimeOptions { + provider, + model: "test-model".to_string(), + api_key: Some("test-key".to_string()), + api_key_source: Some(RuntimeApiKeySource::Keyring), + base_url: "http://localhost:8000/v1".to_string(), + auth_mode: Some("api_key".to_string()), + output_mode: None, + log_level: None, + telemetry: false, + approval_policy: None, + sandbox_mode: None, + yolo: None, + http_headers: std::collections::BTreeMap::new(), + }; + + let cmd = build_tui_command(&cli, &resolved, Vec::new()) + .unwrap_or_else(|e| panic!("{flag}: {e}")); + + assert_eq!( + command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(), + Some("test-key"), + "{flag}: DEEPSEEK_API_KEY not forwarded" + ); + for var in expected_vars { + assert_eq!( + command_env(&cmd, var).as_deref(), + Some("test-key"), + "{flag}: {var} not forwarded" + ); + } + assert_eq!( + command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(), + Some("keyring"), + "{flag}: expected keyring source bridge" + ); + assert_eq!( + command_env(&cmd, "DEEPSEEK_AUTH_MODE"), + None, + "{flag}: auth mode should come from config/profile, not env handoff" + ); + } + } + + #[test] + fn parses_top_level_prompt_flag_for_interactive_startup_prompt() { let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]); assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK.")); assert!(cli.prompt.is_empty()); + assert_eq!( + root_tui_passthrough(&cli).unwrap(), + vec!["--prompt".to_string(), "Reply with exactly OK.".to_string()] + ); } #[test] @@ -2681,7 +3035,7 @@ mod tests { } #[test] - fn top_level_continue_rejects_one_shot_prompt() { + fn top_level_continue_rejects_startup_prompt() { let cli = parse_ok(&["codewhale", "--continue", "-p", "follow up"]); let err = root_tui_passthrough(&cli).expect_err("prompted continue should be rejected"); @@ -2697,6 +3051,10 @@ mod tests { assert_eq!(cli.prompt, vec!["hello", "world"]); assert!(cli.command.is_none()); + assert_eq!( + root_tui_passthrough(&cli).unwrap(), + vec!["--prompt".to_string(), "hello world".to_string()] + ); } #[test] @@ -2705,6 +3063,10 @@ mod tests { assert_eq!(cli.prompt_flag.as_deref(), Some("hello")); assert_eq!(cli.prompt, vec!["world"]); + assert_eq!( + root_tui_passthrough(&cli).unwrap(), + vec!["--prompt".to_string(), "hello world".to_string()] + ); } #[test] @@ -2776,6 +3138,7 @@ mod tests { "archive", "unarchive", "set-name", + "clear-name", ], ), ("sandbox", vec!["check"]), diff --git a/crates/cli/src/update.rs b/crates/cli/src/update.rs index c9d3e481..ed228e47 100644 --- a/crates/cli/src/update.rs +++ b/crates/cli/src/update.rs @@ -5,41 +5,79 @@ //! platform-correct binary, verifies its SHA256 checksum, and atomically //! replaces the currently running binary. +use std::cmp::Ordering; use std::collections::HashMap; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; +use codewhale_release::{ + CHECKSUM_MANIFEST_ASSET, ReleaseChannel, ReleaseQuery, UPDATE_USER_AGENT, + compare_release_versions, is_beta_tag, mirror_asset_url, resolve_release_query, + update_is_needed, update_network_fallback_hint, +}; +use reqwest::Proxy; use std::io::Write; -const CHECKSUM_MANIFEST_ASSET: &str = "codewhale-artifacts-sha256.txt"; -const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/Hmbown/CodeWhale/releases/latest"; -const CNB_REPO_URL: &str = "https://cnb.cool/codewhale.net/codewhale"; -const RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_TUI_RELEASE_BASE_URL"; -const LEGACY_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_RELEASE_BASE_URL"; -const UPDATE_VERSION_ENV: &str = "DEEPSEEK_TUI_VERSION"; -const LEGACY_UPDATE_VERSION_ENV: &str = "DEEPSEEK_VERSION"; -const UPDATE_USER_AGENT: &str = "codewhale-updater"; - /// Run the self-update workflow. -pub fn run_update() -> Result<()> { +pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option) -> Result<()> { let current_exe = std::env::current_exe().context("failed to determine current executable path")?; let targets = update_targets_for_exe(¤t_exe); + let channel = ReleaseChannel::from_beta_flag(beta); + let current_version = env!("CARGO_PKG_VERSION"); + let proxy = proxy_arg + .as_deref() + .map(validate_and_build_proxy) + .transpose()?; - println!("Checking for updates..."); + println!("Checking for {} updates...", channel.label()); println!("Current binary: {}", current_exe.display()); + println!("Current version: v{current_version}"); + + if check_only { + let latest_tag = latest_release_tag(channel, proxy.as_ref()) + .with_context(update_network_fallback_hint)?; + println!("Latest {} release: {latest_tag}", channel.label()); + if update_is_needed(channel, current_version, &latest_tag)? { + println!("Update available. Run `codewhale update` to install {latest_tag}."); + } else { + match compare_release_versions(current_version, &latest_tag)? { + Ordering::Greater => { + println!("Current build is newer than the latest published release."); + } + Ordering::Less | Ordering::Equal => { + println!("Already up to date."); + } + } + } + return Ok(()); + } // Step 1: Fetch latest release metadata - let release = fetch_latest_release().with_context(update_network_fallback_hint)?; + let fetched = + fetch_latest_release(channel, proxy.as_ref()).with_context(update_network_fallback_hint)?; + let release = &fetched.release; let latest_tag = &release.tag_name; - println!("Latest release: {latest_tag}"); + println!("Latest {} release: {latest_tag}", channel.label()); + + if let UpdateReleaseSource::Mirror { base_url } = &fetched.source { + if channel == ReleaseChannel::Beta { + println!( + "Using release mirror {}; --beta does not select GitHub beta releases in mirror mode.", + base_url + ); + } + } else if !update_is_needed(channel, current_version, latest_tag)? { + println!("Already up to date; no download needed."); + return Ok(()); + } // Step 2: Download the aggregated SHA256 checksum manifest if available - let checksum_manifest = match select_checksum_manifest_asset(&release) { + let checksum_manifest = match select_checksum_manifest_asset(release) { Some(checksum_asset) => { println!("Downloading {}...", checksum_asset.name); - let checksum_bytes = - download_url(&checksum_asset.browser_download_url).with_context(|| { + let checksum_bytes = download_url(&checksum_asset.browser_download_url, proxy.as_ref()) + .with_context(|| { format!( "failed to download {}\n{}", checksum_asset.name, @@ -59,7 +97,7 @@ pub fn run_update() -> Result<()> { // Step 3: Download and verify every colocated binary in the install. let mut downloads = Vec::new(); for target in &targets { - let asset = select_platform_asset(&release, &target.asset_stem).with_context(|| { + let asset = select_platform_asset(release, &target.asset_stem).with_context(|| { format!( "no asset found for platform {} in release {latest_tag}. \ Available assets: {}", @@ -74,13 +112,14 @@ pub fn run_update() -> Result<()> { })?; println!("Downloading {}...", asset.name); - let bytes = download_url(&asset.browser_download_url).with_context(|| { - format!( - "failed to download {}\n{}", - asset.name, - update_network_fallback_hint() - ) - })?; + let bytes = + download_url(&asset.browser_download_url, proxy.as_ref()).with_context(|| { + format!( + "failed to download {}\n{}", + asset.name, + update_network_fallback_hint() + ) + })?; if let Some(checksums) = &checksum_manifest { let expected = checksums @@ -122,6 +161,18 @@ pub fn run_update() -> Result<()> { Ok(()) } +#[derive(Debug, Clone, PartialEq, Eq)] +struct FetchedRelease { + release: Release, + source: UpdateReleaseSource, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum UpdateReleaseSource { + GitHub, + Mirror { base_url: String }, +} + pub(crate) fn release_arch_for_rust_arch(arch: &str) -> &str { match arch { "aarch64" => "arm64", @@ -275,54 +326,70 @@ fn expected_sha256_from_manifest(text: &str, asset_name: &str) -> Result } /// GitHub release metadata. -#[derive(serde::Deserialize, Debug)] +#[derive(serde::Deserialize, Debug, Clone, PartialEq, Eq)] struct Release { tag_name: String, + #[serde(default)] + prerelease: bool, assets: Vec, } /// A single release asset. -#[derive(serde::Deserialize, Debug)] +#[derive(serde::Deserialize, Debug, Clone, PartialEq, Eq)] struct Asset { name: String, browser_download_url: String, } -fn update_http_client() -> Result { - reqwest::blocking::Client::builder() +/// Validate the proxy URL format and build a proxy for update HTTP requests. +pub(crate) fn validate_and_build_proxy(proxy_str: &str) -> Result { + let proxy_url = reqwest::Url::parse(proxy_str).with_context(|| { + format!( + "invalid proxy URL: {proxy_str}\n\ + Expected format: http://host:port, https://host:port, or socks5://host:port" + ) + })?; + Proxy::all(proxy_url).context("failed to configure update proxy") +} + +fn update_http_client(proxy: Option<&Proxy>) -> Result { + let mut builder = reqwest::blocking::Client::builder(); + if let Some(proxy) = proxy { + builder = builder.proxy(proxy.clone()); + } + builder .user_agent(UPDATE_USER_AGENT) .build() .context("failed to build update HTTP client") } -/// Fetch the latest release metadata from GitHub. -fn fetch_latest_release() -> Result { - if let Some(base_url) = release_base_url_from_env() { - let version = update_version_from_env().unwrap_or_else(|| env!("CARGO_PKG_VERSION").into()); - return Ok(release_from_mirror_base_url( - &base_url, - &version, - std::env::consts::OS, - std::env::consts::ARCH, - )); +fn latest_release_tag(channel: ReleaseChannel, proxy: Option<&Proxy>) -> Result { + match fetch_latest_release(channel, proxy)? { + FetchedRelease { release, .. } => Ok(release.tag_name), } - fetch_latest_release_from_url(LATEST_RELEASE_URL) } -fn release_base_url_from_env() -> Option { - std::env::var(RELEASE_BASE_URL_ENV) - .ok() - .or_else(|| std::env::var(LEGACY_RELEASE_BASE_URL_ENV).ok()) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - -fn update_version_from_env() -> Option { - std::env::var(UPDATE_VERSION_ENV) - .ok() - .or_else(|| std::env::var(LEGACY_UPDATE_VERSION_ENV).ok()) - .map(|value| value.trim().trim_start_matches('v').to_string()) - .filter(|value| !value.is_empty()) +/// Fetch the latest release metadata from GitHub. +fn fetch_latest_release(channel: ReleaseChannel, proxy: Option<&Proxy>) -> Result { + match resolve_release_query(channel) { + ReleaseQuery::Mirror { base_url, version } => Ok(FetchedRelease { + release: release_from_mirror_base_url( + &base_url, + &version, + std::env::consts::OS, + std::env::consts::ARCH, + ), + source: UpdateReleaseSource::Mirror { base_url }, + }), + ReleaseQuery::GitHubLatest { url } => Ok(FetchedRelease { + release: fetch_latest_release_from_url(url, proxy)?, + source: UpdateReleaseSource::GitHub, + }), + ReleaseQuery::GitHubReleaseList { url } => Ok(FetchedRelease { + release: fetch_latest_beta_release_from_url(url, proxy)?, + source: UpdateReleaseSource::GitHub, + }), + } } fn release_from_mirror_base_url( @@ -345,42 +412,32 @@ fn release_from_mirror_base_url( }); } - Release { tag_name, assets } + Release { + tag_name, + prerelease: false, + assets, + } } -fn mirror_asset_url(base_url: &str, asset_name: &str) -> String { - format!("{}/{}", base_url.trim_end_matches('/'), asset_name) -} - -fn update_network_fallback_hint() -> String { - format!( - "GitHub release downloads may be blocked or slow on this network.\n\ - For mainland China, use one of these fallback paths:\n\ - 1. Source build from the CNB mirror, installing both shipped binaries:\n\ - cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-cli --locked --force\n\ - cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-tui --locked --force\n\ - 2. Use a binary asset mirror:\n\ - {RELEASE_BASE_URL_ENV}=https://// {UPDATE_VERSION_ENV}=X.Y.Z codewhale update\n\ - The mirror directory must contain {CHECKSUM_MANIFEST_ASSET} and the platform binaries." - ) -} - -fn fetch_latest_release_from_url(url: &str) -> Result { - let client = update_http_client()?; +fn fetch_release_json(url: &str, description: &str, proxy: Option<&Proxy>) -> Result { + let client = update_http_client(proxy)?; let response = client .get(url) .header(reqwest::header::ACCEPT, "application/vnd.github+json") .send() - .with_context(|| format!("failed to fetch release info from {url}"))?; + .with_context(|| format!("failed to fetch {description} from {url}"))?; let status = response.status(); let body = response .text() - .with_context(|| format!("failed to read release response from {url}"))?; - + .with_context(|| format!("failed to read {description} response body from {url}"))?; if !status.is_success() { - bail!("GitHub release request failed with HTTP {status}: {body}"); + bail!("failed to fetch {description} from {url}: HTTP {status}\n{body}"); } + Ok(body) +} +fn fetch_latest_release_from_url(url: &str, proxy: Option<&Proxy>) -> Result { + let body = fetch_release_json(url, "release info", proxy)?; let release: Release = serde_json::from_str(&body).with_context(|| { format!("failed to parse release JSON from GitHub API. Response: {body}") })?; @@ -388,9 +445,23 @@ fn fetch_latest_release_from_url(url: &str) -> Result { Ok(release) } +fn fetch_latest_beta_release_from_url(url: &str, proxy: Option<&Proxy>) -> Result { + let body = fetch_release_json(url, "release list", proxy)?; + // GitHub caps this endpoint at 100 releases per page. CodeWhale uses the + // first page as the latest-beta search window, matching GitHub's ordering. + let releases: Vec = serde_json::from_str(&body).with_context(|| { + format!("failed to parse release list JSON from GitHub API. Response: {body}") + })?; + + releases + .into_iter() + .find(|release| is_beta_tag(&release.tag_name)) + .context("no beta release found in GitHub releases") +} + /// Download a URL to bytes. -fn download_url(url: &str) -> Result> { - let client = update_http_client()?; +fn download_url(url: &str, proxy: Option<&Proxy>) -> Result> { + let client = update_http_client(proxy)?; let response = client .get(url) .send() @@ -837,13 +908,87 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win ); } + #[test] + fn cnb_release_base_url_includes_tag_directory() { + assert_eq!( + codewhale_release::cnb_release_base_url("0.8.47"), + "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47" + ); + assert_eq!( + codewhale_release::cnb_release_base_url("v0.8.47"), + "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47" + ); + } + + #[test] + fn stable_update_is_needed_only_when_latest_is_newer() { + assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.46").unwrap()); + assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.9.0-beta.1").unwrap()); + assert!(!update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.45").unwrap()); + assert!(!update_is_needed(ReleaseChannel::Stable, "0.9.0", "v0.9.0-beta.1").unwrap()); + assert!( + !update_is_needed(ReleaseChannel::Stable, "0.9.0-beta.2", "v0.9.0-beta.1").unwrap() + ); + } + + #[test] + fn beta_update_allows_switching_from_same_stable_to_beta() { + assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0", "v1.0.0-beta.2").unwrap()); + assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.2").unwrap()); + assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.3", "v1.0.0-beta.2").unwrap()); + assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.3").unwrap()); + assert!(!update_is_needed(ReleaseChannel::Beta, "2.0.0", "v1.0.0-beta.3").unwrap()); + assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-rc.1", "v1.0.0-beta.3").unwrap()); + } + + #[test] + fn parse_release_version_accepts_tags_and_build_suffixes() { + assert_eq!( + codewhale_release::parse_release_version("v0.9.0-beta.1").unwrap(), + semver::Version::parse("0.9.0-beta.1").unwrap() + ); + assert_eq!( + codewhale_release::parse_release_version("0.8.45 (abcdef123456)").unwrap(), + semver::Version::parse("0.8.45").unwrap() + ); + } + + #[test] + fn beta_release_detection_requires_beta_tag() { + let rc_prerelease = Release { + tag_name: "v0.9.0-rc.1".to_string(), + prerelease: true, + assets: vec![], + }; + let beta_tag = Release { + tag_name: "v0.9.0-beta.1".to_string(), + prerelease: false, + assets: vec![], + }; + let stable = Release { + tag_name: "v0.9.0".to_string(), + prerelease: false, + assets: vec![], + }; + + assert!(!is_beta_tag(&rc_prerelease.tag_name)); + assert!(is_beta_tag(&beta_tag.tag_name)); + assert!(!is_beta_tag(&stable.tag_name)); + } + #[test] fn update_fallback_hint_points_china_users_to_cnb_and_asset_mirrors() { let hint = update_network_fallback_hint(); - assert!(hint.contains(CNB_REPO_URL), "{hint}"); - assert!(hint.contains(RELEASE_BASE_URL_ENV), "{hint}"); - assert!(hint.contains(UPDATE_VERSION_ENV), "{hint}"); + assert!(hint.contains(codewhale_release::CNB_REPO_URL), "{hint}"); + assert!( + hint.contains(codewhale_release::RELEASE_BASE_URL_ENV), + "{hint}" + ); + assert!( + hint.contains(codewhale_release::UPDATE_VERSION_ENV), + "{hint}" + ); assert!(hint.contains("codewhale-cli"), "{hint}"); assert!(hint.contains("codewhale-tui --locked"), "{hint}"); } @@ -877,6 +1022,19 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win (format!("http://{addr}/release"), request_rx, handle) } + #[test] + fn validate_and_build_proxy_accepts_supported_proxy_urls() { + validate_and_build_proxy("http://localhost:7897").expect("http proxy"); + validate_and_build_proxy("https://proxy.example.com:8080").expect("https proxy"); + validate_and_build_proxy("socks5://127.0.0.1:1080").expect("socks proxy"); + } + + #[test] + fn validate_and_build_proxy_rejects_malformed_urls() { + let err = validate_and_build_proxy("not a valid url").expect_err("malformed URL"); + assert!(err.to_string().contains("invalid proxy URL")); + } + #[test] fn fetch_latest_release_from_url_reads_mocked_release_json() { let body = br#"{ @@ -887,7 +1045,7 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win ] }"#; let (url, request_rx, handle) = serve_http_once("200 OK", "application/json", body); - let release = fetch_latest_release_from_url(&url).expect("release JSON should parse"); + let release = fetch_latest_release_from_url(&url, None).expect("release JSON should parse"); assert_eq!(release.tag_name, "v9.9.9"); assert_eq!(release.assets.len(), 2); @@ -910,7 +1068,7 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win fn fetch_latest_release_from_url_reports_http_errors() { let (url, _request_rx, handle) = serve_http_once("500 Internal Server Error", "text/plain", b"server broke"); - let err = fetch_latest_release_from_url(&url).expect_err("HTTP 500 should fail"); + let err = fetch_latest_release_from_url(&url, None).expect_err("HTTP 500 should fail"); assert!( err.to_string().contains("HTTP 500"), @@ -919,11 +1077,54 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win handle.join().expect("test server thread"); } + #[test] + fn fetch_latest_beta_release_from_url_selects_first_beta_release() { + let body = br#"[ + { "tag_name": "v0.9.0", "prerelease": false, "assets": [] }, + { "tag_name": "v0.9.0-rc.1", "prerelease": true, "assets": [] }, + { "tag_name": "v0.9.0-beta.2", "prerelease": true, "assets": [ + { "name": "codewhale-linux-x64", "browser_download_url": "http://example.invalid/codewhale-linux-x64" } + ] }, + { "tag_name": "v0.9.0-beta.1", "prerelease": true, "assets": [] } + ]"#; + let (url, request_rx, handle) = serve_http_once("200 OK", "application/json", body); + let release = + fetch_latest_beta_release_from_url(&url, None).expect("beta release JSON should parse"); + + assert_eq!(release.tag_name, "v0.9.0-beta.2"); + assert!(release.prerelease); + + let request = request_rx.recv().expect("captured request"); + let request_lower = request.to_ascii_lowercase(); + assert!(request.starts_with("GET /release "), "got {request:?}"); + assert!( + request_lower.contains("accept: application/vnd.github+json"), + "got {request:?}" + ); + handle.join().expect("test server thread"); + } + + #[test] + fn fetch_latest_beta_release_from_url_reports_missing_beta() { + let body = br#"[ + { "tag_name": "v0.9.0", "prerelease": false, "assets": [] } + ]"#; + let (url, _request_rx, handle) = serve_http_once("200 OK", "application/json", body); + let err = + fetch_latest_beta_release_from_url(&url, None).expect_err("missing beta should fail"); + + assert!( + err.to_string().contains("no beta release found"), + "unexpected error: {err:#}" + ); + handle.join().expect("test server thread"); + } + #[test] fn download_url_reads_binary_body_with_updater_user_agent() { let (url, request_rx, handle) = serve_http_once("200 OK", "application/octet-stream", b"\0binary bytes"); - let bytes = download_url(&url).expect("binary download should succeed"); + let bytes = download_url(&url, None).expect("binary download should succeed"); assert_eq!(bytes, b"\0binary bytes"); diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 2d9ea522..4fbdb03c 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -8,8 +8,9 @@ description = "Config schema and precedence model for DeepSeek workspace archite [dependencies] anyhow.workspace = true -codewhale-secrets = { path = "../secrets", version = "0.8.44" } +codewhale-secrets = { path = "../secrets", version = "0.8.46" } dirs.workspace = true serde.workspace = true +serde_json.workspace = true toml.workspace = true tracing.workspace = true diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 9bfb089e..ce362691 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -17,7 +17,7 @@ pub const CONFIG_FILE_NAME: &str = "config.toml"; const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-v4-pro"; const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro"; const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash"; -const DEFAULT_OPENAI_MODEL: &str = "gpt-4.1"; +const DEFAULT_OPENAI_MODEL: &str = "deepseek-v4-pro"; const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta"; const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1"; const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; @@ -25,14 +25,22 @@ const DEFAULT_ATLASCLOUD_MODEL: &str = "deepseek-ai/deepseek-v4-flash"; const DEFAULT_ATLASCLOUD_BASE_URL: &str = "https://api.atlascloud.ai/v1"; const DEFAULT_WANJIE_ARK_MODEL: &str = "deepseek-reasoner"; const DEFAULT_WANJIE_ARK_BASE_URL: &str = "https://maas-openapi.wanjiedata.com/api/v1"; +const DEFAULT_VOLCENGINE_MODEL: &str = "DeepSeek-V4-Pro"; +const DEFAULT_VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/coding/v3"; const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-pro"; const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash"; +const DEFAULT_XIAOMI_MIMO_MODEL: &str = "mimo-v2.5-pro"; const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro"; const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash"; const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro"; +const DEFAULT_MOONSHOT_MODEL: &str = "kimi-k2.6"; +const DEFAULT_MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1"; +const DEFAULT_KIMI_CODE_MODEL: &str = "kimi-for-coding"; +const DEFAULT_KIMI_CODE_BASE_URL: &str = "https://api.kimi.com/coding/v1"; const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro"; const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash"; const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1"; +const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://api.xiaomimimo.com/v1"; const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/v1"; const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1"; const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1"; @@ -54,6 +62,7 @@ pub enum ProviderKind { )] Deepseek, NvidiaNim, + #[serde(alias = "open-ai")] Openai, Atlascloud, #[serde( @@ -65,9 +74,14 @@ pub enum ProviderKind { alias = "wanjie_maas" )] WanjieArk, + #[serde(alias = "volcengine-ark", alias = "volcengine_ark", alias = "ark")] + Volcengine, Openrouter, + #[serde(alias = "mimo", alias = "xiaomi", alias = "xiaomi_mimo")] + XiaomiMimo, Novita, Fireworks, + Moonshot, Sglang, Vllm, Ollama, @@ -82,9 +96,12 @@ impl ProviderKind { Self::Openai => "openai", Self::Atlascloud => "atlascloud", Self::WanjieArk => "wanjie-ark", + Self::Volcengine => "volcengine", Self::Openrouter => "openrouter", + Self::XiaomiMimo => "xiaomi-mimo", Self::Novita => "novita", Self::Fireworks => "fireworks", + Self::Moonshot => "moonshot", Self::Sglang => "sglang", Self::Vllm => "vllm", Self::Ollama => "ollama", @@ -101,9 +118,15 @@ impl ProviderKind { "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => Some(Self::Atlascloud), "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark" | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => Some(Self::WanjieArk), + "volcengine" | "volcengine-ark" | "volcengine_ark" | "ark" | "volc-ark" + | "volcengineark" => Some(Self::Volcengine), "openrouter" | "open_router" => Some(Self::Openrouter), + "xiaomi-mimo" | "xiaomi_mimo" | "xiaomimimo" | "mimo" | "xiaomi" => { + Some(Self::XiaomiMimo) + } "novita" => Some(Self::Novita), "fireworks" | "fireworks-ai" => Some(Self::Fireworks), + "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => Some(Self::Moonshot), "sglang" | "sg-lang" => Some(Self::Sglang), "vllm" | "v-llm" => Some(Self::Vllm), "ollama" | "ollama-local" => Some(Self::Ollama), @@ -117,6 +140,7 @@ pub struct ProviderConfigToml { pub api_key: Option, pub base_url: Option, pub model: Option, + pub auth_mode: Option, #[serde(default)] pub http_headers: BTreeMap, } @@ -134,12 +158,18 @@ pub struct ProvidersToml { #[serde(default)] pub wanjie_ark: ProviderConfigToml, #[serde(default)] + pub volcengine: ProviderConfigToml, + #[serde(default)] pub openrouter: ProviderConfigToml, #[serde(default)] + pub xiaomi_mimo: ProviderConfigToml, + #[serde(default)] pub novita: ProviderConfigToml, #[serde(default)] pub fireworks: ProviderConfigToml, #[serde(default)] + pub moonshot: ProviderConfigToml, + #[serde(default)] pub sglang: ProviderConfigToml, #[serde(default)] pub vllm: ProviderConfigToml, @@ -156,9 +186,12 @@ impl ProvidersToml { ProviderKind::Openai => &self.openai, ProviderKind::Atlascloud => &self.atlascloud, ProviderKind::WanjieArk => &self.wanjie_ark, + ProviderKind::Volcengine => &self.volcengine, ProviderKind::Openrouter => &self.openrouter, + ProviderKind::XiaomiMimo => &self.xiaomi_mimo, ProviderKind::Novita => &self.novita, ProviderKind::Fireworks => &self.fireworks, + ProviderKind::Moonshot => &self.moonshot, ProviderKind::Sglang => &self.sglang, ProviderKind::Vllm => &self.vllm, ProviderKind::Ollama => &self.ollama, @@ -172,9 +205,12 @@ impl ProvidersToml { ProviderKind::Openai => &mut self.openai, ProviderKind::Atlascloud => &mut self.atlascloud, ProviderKind::WanjieArk => &mut self.wanjie_ark, + ProviderKind::Volcengine => &mut self.volcengine, ProviderKind::Openrouter => &mut self.openrouter, + ProviderKind::XiaomiMimo => &mut self.xiaomi_mimo, ProviderKind::Novita => &mut self.novita, ProviderKind::Fireworks => &mut self.fireworks, + ProviderKind::Moonshot => &mut self.moonshot, ProviderKind::Sglang => &mut self.sglang, ProviderKind::Vllm => &mut self.vllm, ProviderKind::Ollama => &mut self.ollama, @@ -198,13 +234,14 @@ pub struct ConfigToml { pub provider: ProviderKind, pub model: Option, pub auth_mode: Option, - pub chatgpt_access_token: Option, - pub device_code_session: Option, pub output_mode: Option, pub log_level: Option, pub telemetry: Option, pub approval_policy: Option, pub sandbox_mode: Option, + /// Native tool catalog controls shared with `codewhale-tui`. + #[serde(default)] + pub tools: Option, #[serde(default)] pub providers: ProvidersToml, /// Per-domain network policy (#135). When absent, network tools fall back @@ -242,6 +279,14 @@ pub struct SkillsToml { pub max_install_size_bytes: Option, } +/// On-disk schema for the `[tools]` table (#2076). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ToolsToml { + /// Native tool names to keep loaded outside the default core catalog. + #[serde(default)] + pub always_load: Vec, +} + /// On-disk schema for the `[snapshots]` table (#137). See /// `config.example.toml` for documentation. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -331,91 +376,67 @@ pub struct LspConfigToml { } impl ConfigToml { - /// Merge project-level overrides from `$WORKSPACE/.deepseek/config.toml`. - /// Only populated fields in `project` are applied; everything else - /// keeps its global value. Provider-specific sub-tables are merged - /// field-by-field so a project can set just `providers.deepseek.model` - /// without needing to repeat `api_key` or `base_url`. + /// Merge safe project-level overrides from `$WORKSPACE/.codewhale/config.toml` + /// or legacy `$WORKSPACE/.deepseek/config.toml`. + /// + /// Repo-local config is untrusted input. This helper intentionally ignores + /// credentials, endpoints, provider selection, auth/session values, telemetry, + /// network policy, skill registry, LSP command tables, and unknown extras. + /// Approval and sandbox values may only tighten the existing user/global + /// posture. pub fn merge_project_overrides(&mut self, project: ConfigToml) { - // Check provider override condition before moving fields. - let has_api_key = project.api_key.is_some(); - - // Top-level scalar fields: apply when the project has a value. - if has_api_key { - self.api_key = project.api_key; - } - if project.base_url.is_some() { - self.base_url = project.base_url; - } - if !project.http_headers.is_empty() { - self.http_headers = project.http_headers; - } if project.default_text_model.is_some() { self.default_text_model = project.default_text_model; } if project.model.is_some() { self.model = project.model; } - if project.auth_mode.is_some() { - self.auth_mode = project.auth_mode; - } if project.output_mode.is_some() { self.output_mode = project.output_mode; } - if project.telemetry.is_some() { - self.telemetry = project.telemetry; + if project.log_level.is_some() { + self.log_level = project.log_level; } - if project.approval_policy.is_some() { - self.approval_policy = project.approval_policy; + if let Some(policy) = project.approval_policy + && project_approval_policy_is_allowed(self.approval_policy.as_deref(), &policy) + { + self.approval_policy = Some(policy); } - if project.sandbox_mode.is_some() { - self.sandbox_mode = project.sandbox_mode; + if let Some(mode) = project.sandbox_mode + && project_sandbox_mode_is_allowed(self.sandbox_mode.as_deref(), &mode) + { + self.sandbox_mode = Some(mode); } - // Provider is only overridden if explicitly set (non-default). - if project.provider != ProviderKind::Deepseek || has_api_key { - self.provider = project.provider; + if project.tools.is_some() { + self.tools = project.tools; } - - // Merge provider sub-tables field-by-field. - merge_provider_config(&mut self.providers.deepseek, &project.providers.deepseek); - merge_provider_config( + merge_project_provider_config(&mut self.providers.deepseek, &project.providers.deepseek); + merge_project_provider_config( &mut self.providers.nvidia_nim, &project.providers.nvidia_nim, ); - merge_provider_config(&mut self.providers.openai, &project.providers.openai); - merge_provider_config( + merge_project_provider_config(&mut self.providers.openai, &project.providers.openai); + merge_project_provider_config( &mut self.providers.atlascloud, &project.providers.atlascloud, ); - merge_provider_config( + merge_project_provider_config( &mut self.providers.wanjie_ark, &project.providers.wanjie_ark, ); - merge_provider_config( + merge_project_provider_config( &mut self.providers.openrouter, &project.providers.openrouter, ); - merge_provider_config(&mut self.providers.novita, &project.providers.novita); - merge_provider_config(&mut self.providers.fireworks, &project.providers.fireworks); - merge_provider_config(&mut self.providers.sglang, &project.providers.sglang); - merge_provider_config(&mut self.providers.vllm, &project.providers.vllm); - merge_provider_config(&mut self.providers.ollama, &project.providers.ollama); - - if project.network.is_some() { - self.network = project.network; - } - if project.skills.is_some() { - self.skills = project.skills; - } - if project.snapshots.is_some() { - self.snapshots = project.snapshots; - } - if project.lsp.is_some() { - self.lsp = project.lsp; - } - for (k, v) in project.extras { - self.extras.insert(k, v); - } + merge_project_provider_config( + &mut self.providers.xiaomi_mimo, + &project.providers.xiaomi_mimo, + ); + merge_project_provider_config(&mut self.providers.novita, &project.providers.novita); + merge_project_provider_config(&mut self.providers.fireworks, &project.providers.fireworks); + merge_project_provider_config(&mut self.providers.sglang, &project.providers.sglang); + merge_project_provider_config(&mut self.providers.vllm, &project.providers.vllm); + merge_project_provider_config(&mut self.providers.ollama, &project.providers.ollama); } #[must_use] @@ -428,13 +449,12 @@ impl ConfigToml { "default_text_model" => self.default_text_model.clone(), "model" => self.model.clone(), "auth.mode" => self.auth_mode.clone(), - "auth.chatgpt_access_token" => self.chatgpt_access_token.clone(), - "auth.device_code_session" => self.device_code_session.clone(), "output_mode" => self.output_mode.clone(), "log_level" => self.log_level.clone(), "telemetry" => self.telemetry.map(|v| v.to_string()), "approval_policy" => self.approval_policy.clone(), "sandbox_mode" => self.sandbox_mode.clone(), + "tools.always_load" => self.tools.as_ref().map(|tools| tools.always_load.join(",")), "providers.deepseek.api_key" => self.providers.deepseek.api_key.clone(), "providers.deepseek.base_url" => self.providers.deepseek.base_url.clone(), "providers.deepseek.model" => self.providers.deepseek.model.clone(), @@ -462,6 +482,9 @@ impl ConfigToml { "providers.wanjie_ark.api_key" => self.providers.wanjie_ark.api_key.clone(), "providers.wanjie_ark.base_url" => self.providers.wanjie_ark.base_url.clone(), "providers.wanjie_ark.model" => self.providers.wanjie_ark.model.clone(), + "providers.volcengine.api_key" => self.providers.volcengine.api_key.clone(), + "providers.volcengine.base_url" => self.providers.volcengine.base_url.clone(), + "providers.volcengine.model" => self.providers.volcengine.model.clone(), "providers.wanjie_ark.http_headers" => { serialize_http_headers(&self.providers.wanjie_ark.http_headers) } @@ -471,6 +494,12 @@ impl ConfigToml { "providers.openrouter.http_headers" => { serialize_http_headers(&self.providers.openrouter.http_headers) } + "providers.xiaomi_mimo.api_key" => self.providers.xiaomi_mimo.api_key.clone(), + "providers.xiaomi_mimo.base_url" => self.providers.xiaomi_mimo.base_url.clone(), + "providers.xiaomi_mimo.model" => self.providers.xiaomi_mimo.model.clone(), + "providers.xiaomi_mimo.http_headers" => { + serialize_http_headers(&self.providers.xiaomi_mimo.http_headers) + } "providers.novita.api_key" => self.providers.novita.api_key.clone(), "providers.novita.base_url" => self.providers.novita.base_url.clone(), "providers.novita.model" => self.providers.novita.model.clone(), @@ -483,6 +512,13 @@ impl ConfigToml { "providers.fireworks.http_headers" => { serialize_http_headers(&self.providers.fireworks.http_headers) } + "providers.moonshot.api_key" => self.providers.moonshot.api_key.clone(), + "providers.moonshot.base_url" => self.providers.moonshot.base_url.clone(), + "providers.moonshot.model" => self.providers.moonshot.model.clone(), + "providers.moonshot.auth_mode" => self.providers.moonshot.auth_mode.clone(), + "providers.moonshot.http_headers" => { + serialize_http_headers(&self.providers.moonshot.http_headers) + } "providers.sglang.api_key" => self.providers.sglang.api_key.clone(), "providers.sglang.base_url" => self.providers.sglang.base_url.clone(), "providers.sglang.model" => self.providers.sglang.model.clone(), @@ -528,8 +564,6 @@ impl ConfigToml { "default_text_model" => self.default_text_model = Some(value.to_string()), "model" => self.model = Some(value.to_string()), "auth.mode" => self.auth_mode = Some(value.to_string()), - "auth.chatgpt_access_token" => self.chatgpt_access_token = Some(value.to_string()), - "auth.device_code_session" => self.device_code_session = Some(value.to_string()), "output_mode" => self.output_mode = Some(value.to_string()), "log_level" => self.log_level = Some(value.to_string()), "telemetry" => { @@ -584,6 +618,15 @@ impl ConfigToml { "providers.wanjie_ark.model" => { self.providers.wanjie_ark.model = Some(value.to_string()); } + "providers.volcengine.api_key" => { + self.providers.volcengine.api_key = Some(value.to_string()); + } + "providers.volcengine.base_url" => { + self.providers.volcengine.base_url = Some(value.to_string()); + } + "providers.volcengine.model" => { + self.providers.volcengine.model = Some(value.to_string()); + } "providers.wanjie_ark.http_headers" => { self.providers.wanjie_ark.http_headers = parse_http_headers(value)?; } @@ -611,6 +654,18 @@ impl ConfigToml { "providers.openrouter.http_headers" => { self.providers.openrouter.http_headers = parse_http_headers(value)?; } + "providers.xiaomi_mimo.api_key" => { + self.providers.xiaomi_mimo.api_key = Some(value.to_string()); + } + "providers.xiaomi_mimo.base_url" => { + self.providers.xiaomi_mimo.base_url = Some(value.to_string()); + } + "providers.xiaomi_mimo.model" => { + self.providers.xiaomi_mimo.model = Some(value.to_string()); + } + "providers.xiaomi_mimo.http_headers" => { + self.providers.xiaomi_mimo.http_headers = parse_http_headers(value)?; + } "providers.novita.api_key" => { self.providers.novita.api_key = Some(value.to_string()); } @@ -635,6 +690,21 @@ impl ConfigToml { "providers.fireworks.http_headers" => { self.providers.fireworks.http_headers = parse_http_headers(value)?; } + "providers.moonshot.api_key" => { + self.providers.moonshot.api_key = Some(value.to_string()); + } + "providers.moonshot.base_url" => { + self.providers.moonshot.base_url = Some(value.to_string()); + } + "providers.moonshot.model" => { + self.providers.moonshot.model = Some(value.to_string()); + } + "providers.moonshot.auth_mode" => { + self.providers.moonshot.auth_mode = Some(value.to_string()); + } + "providers.moonshot.http_headers" => { + self.providers.moonshot.http_headers = parse_http_headers(value)?; + } "providers.sglang.api_key" => { self.providers.sglang.api_key = Some(value.to_string()); } @@ -688,8 +758,6 @@ impl ConfigToml { "default_text_model" => self.default_text_model = None, "model" => self.model = None, "auth.mode" => self.auth_mode = None, - "auth.chatgpt_access_token" => self.chatgpt_access_token = None, - "auth.device_code_session" => self.device_code_session = None, "output_mode" => self.output_mode = None, "log_level" => self.log_level = None, "telemetry" => self.telemetry = None, @@ -722,6 +790,9 @@ impl ConfigToml { "providers.wanjie_ark.api_key" => self.providers.wanjie_ark.api_key = None, "providers.wanjie_ark.base_url" => self.providers.wanjie_ark.base_url = None, "providers.wanjie_ark.model" => self.providers.wanjie_ark.model = None, + "providers.volcengine.api_key" => self.providers.volcengine.api_key = None, + "providers.volcengine.base_url" => self.providers.volcengine.base_url = None, + "providers.volcengine.model" => self.providers.volcengine.model = None, "providers.wanjie_ark.http_headers" => { self.providers.wanjie_ark.http_headers.clear(); } @@ -733,6 +804,12 @@ impl ConfigToml { "providers.openrouter.base_url" => self.providers.openrouter.base_url = None, "providers.openrouter.model" => self.providers.openrouter.model = None, "providers.openrouter.http_headers" => self.providers.openrouter.http_headers.clear(), + "providers.xiaomi_mimo.api_key" => self.providers.xiaomi_mimo.api_key = None, + "providers.xiaomi_mimo.base_url" => self.providers.xiaomi_mimo.base_url = None, + "providers.xiaomi_mimo.model" => self.providers.xiaomi_mimo.model = None, + "providers.xiaomi_mimo.http_headers" => { + self.providers.xiaomi_mimo.http_headers.clear(); + } "providers.novita.api_key" => self.providers.novita.api_key = None, "providers.novita.base_url" => self.providers.novita.base_url = None, "providers.novita.model" => self.providers.novita.model = None, @@ -741,6 +818,11 @@ impl ConfigToml { "providers.fireworks.base_url" => self.providers.fireworks.base_url = None, "providers.fireworks.model" => self.providers.fireworks.model = None, "providers.fireworks.http_headers" => self.providers.fireworks.http_headers.clear(), + "providers.moonshot.api_key" => self.providers.moonshot.api_key = None, + "providers.moonshot.base_url" => self.providers.moonshot.base_url = None, + "providers.moonshot.model" => self.providers.moonshot.model = None, + "providers.moonshot.auth_mode" => self.providers.moonshot.auth_mode = None, + "providers.moonshot.http_headers" => self.providers.moonshot.http_headers.clear(), "providers.sglang.api_key" => self.providers.sglang.api_key = None, "providers.sglang.base_url" => self.providers.sglang.base_url = None, "providers.sglang.model" => self.providers.sglang.model = None, @@ -783,12 +865,6 @@ impl ConfigToml { if let Some(v) = self.auth_mode.as_ref() { out.insert("auth.mode".to_string(), v.clone()); } - if let Some(v) = self.chatgpt_access_token.as_ref() { - out.insert("auth.chatgpt_access_token".to_string(), redact_secret(v)); - } - if let Some(v) = self.device_code_session.as_ref() { - out.insert("auth.device_code_session".to_string(), redact_secret(v)); - } if let Some(v) = self.output_mode.as_ref() { out.insert("output_mode".to_string(), v.clone()); } @@ -840,6 +916,15 @@ impl ConfigToml { if let Some(v) = serialize_http_headers(&self.providers.atlascloud.http_headers) { out.insert("providers.atlascloud.http_headers".to_string(), v); } + if let Some(v) = self.providers.volcengine.api_key.as_ref() { + out.insert("providers.volcengine.api_key".to_string(), redact_secret(v)); + } + if let Some(v) = self.providers.volcengine.base_url.as_ref() { + out.insert("providers.volcengine.base_url".to_string(), v.clone()); + } + if let Some(v) = self.providers.volcengine.model.as_ref() { + out.insert("providers.volcengine.model".to_string(), v.clone()); + } if let Some(v) = self.providers.wanjie_ark.api_key.as_ref() { out.insert("providers.wanjie_ark.api_key".to_string(), redact_secret(v)); } @@ -849,6 +934,9 @@ impl ConfigToml { if let Some(v) = self.providers.wanjie_ark.model.as_ref() { out.insert("providers.wanjie_ark.model".to_string(), v.clone()); } + if let Some(v) = serialize_http_headers(&self.providers.volcengine.http_headers) { + out.insert("providers.volcengine.http_headers".to_string(), v); + } if let Some(v) = serialize_http_headers(&self.providers.wanjie_ark.http_headers) { out.insert("providers.wanjie_ark.http_headers".to_string(), v); } @@ -876,6 +964,21 @@ impl ConfigToml { if let Some(v) = serialize_http_headers(&self.providers.openrouter.http_headers) { out.insert("providers.openrouter.http_headers".to_string(), v); } + if let Some(v) = self.providers.xiaomi_mimo.api_key.as_ref() { + out.insert( + "providers.xiaomi_mimo.api_key".to_string(), + redact_secret(v), + ); + } + if let Some(v) = self.providers.xiaomi_mimo.base_url.as_ref() { + out.insert("providers.xiaomi_mimo.base_url".to_string(), v.clone()); + } + if let Some(v) = self.providers.xiaomi_mimo.model.as_ref() { + out.insert("providers.xiaomi_mimo.model".to_string(), v.clone()); + } + if let Some(v) = serialize_http_headers(&self.providers.xiaomi_mimo.http_headers) { + out.insert("providers.xiaomi_mimo.http_headers".to_string(), v); + } if let Some(v) = self.providers.novita.api_key.as_ref() { out.insert("providers.novita.api_key".to_string(), redact_secret(v)); } @@ -900,6 +1003,21 @@ impl ConfigToml { if let Some(v) = serialize_http_headers(&self.providers.fireworks.http_headers) { out.insert("providers.fireworks.http_headers".to_string(), v); } + if let Some(v) = self.providers.moonshot.api_key.as_ref() { + out.insert("providers.moonshot.api_key".to_string(), redact_secret(v)); + } + if let Some(v) = self.providers.moonshot.base_url.as_ref() { + out.insert("providers.moonshot.base_url".to_string(), v.clone()); + } + if let Some(v) = self.providers.moonshot.model.as_ref() { + out.insert("providers.moonshot.model".to_string(), v.clone()); + } + if let Some(v) = self.providers.moonshot.auth_mode.as_ref() { + out.insert("providers.moonshot.auth_mode".to_string(), v.clone()); + } + if let Some(v) = serialize_http_headers(&self.providers.moonshot.http_headers) { + out.insert("providers.moonshot.http_headers".to_string(), v); + } if let Some(v) = self.providers.sglang.api_key.as_ref() { out.insert("providers.sglang.api_key".to_string(), redact_secret(v)); } @@ -979,6 +1097,12 @@ impl ConfigToml { let root_deepseek_model = (provider == ProviderKind::Deepseek) .then(|| self.default_text_model.clone()) .flatten(); + let auth_mode = cli + .auth_mode + .clone() + .or_else(|| env.auth_mode.clone()) + .or_else(|| provider_cfg.auth_mode.clone()) + .or_else(|| self.auth_mode.clone()); let base_url = cli .base_url .clone() @@ -991,26 +1115,34 @@ impl ConfigToml { ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(), ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(), ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(), + ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL.to_string(), ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(), + ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(), ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(), ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(), + ProviderKind::Moonshot => { + if auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth) { + DEFAULT_KIMI_CODE_BASE_URL.to_string() + } else { + DEFAULT_MOONSHOT_BASE_URL.to_string() + } + } ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(), ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(), ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(), }); - let auth_mode = cli - .auth_mode - .clone() - .or_else(|| env.auth_mode.clone()) - .or_else(|| self.auth_mode.clone()); // CLI flag wins outright. Otherwise: config-file → injected secrets/env. // This makes `deepseek auth set` a reliable fix even when the user's // shell still exports an old key. When the file is empty, the injected // secrets façade recovers configured secret-store credentials before // falling back to ambient env. + let uses_kimi_oauth = provider == ProviderKind::Moonshot + && auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth); let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key); let (api_key, api_key_source) = if let Some(value) = cli.api_key.clone() { (Some(value), Some(RuntimeApiKeySource::Cli)) + } else if uses_kimi_oauth { + (None, None) } else if let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty()) { (Some(value), Some(RuntimeApiKeySource::ConfigFile)) } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) { @@ -1045,7 +1177,16 @@ impl ConfigToml { .or_else(|| provider_cfg.model.clone()) .or(root_deepseek_model) .or_else(|| self.model.clone()) - .unwrap_or_else(|| default_model_for_provider(provider).to_string()); + .unwrap_or_else(|| { + if provider == ProviderKind::Moonshot + && (auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth) + || moonshot_base_url_uses_kimi_code(&base_url)) + { + DEFAULT_KIMI_CODE_MODEL.to_string() + } else { + default_model_for_provider(provider).to_string() + } + }); let model = if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) { model.trim().to_string() @@ -1105,18 +1246,57 @@ impl ConfigToml { } } -fn merge_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) { - if source.api_key.is_some() { - target.api_key = source.api_key.clone(); - } - if source.base_url.is_some() { - target.base_url = source.base_url.clone(); - } +fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) { if source.model.is_some() { target.model = source.model.clone(); } - if !source.http_headers.is_empty() { - target.http_headers = source.http_headers.clone(); +} + +#[must_use] +pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool { + let Some(project_rank) = approval_policy_rank(project) else { + return false; + }; + match current.and_then(approval_policy_rank) { + Some(current_rank) => project_rank >= current_rank, + None => project_rank >= 2, + } +} + +#[must_use] +pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool { + let normalized_project = project.trim().to_ascii_lowercase(); + if normalized_project == "external-sandbox" { + return current + .map(|value| value.trim().eq_ignore_ascii_case("external-sandbox")) + .unwrap_or(false); + } + + let Some(project_rank) = sandbox_mode_rank(project) else { + return false; + }; + match current.and_then(sandbox_mode_rank) { + Some(current_rank) => project_rank >= current_rank, + None => project_rank >= 2, + } +} + +fn approval_policy_rank(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "auto" => Some(0), + "suggest" | "suggested" | "on-request" | "untrusted" => Some(1), + "never" | "deny" | "denied" => Some(2), + _ => None, + } +} + +fn sandbox_mode_rank(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "danger-full-access" => Some(0), + "external-sandbox" => Some(0), + "workspace-write" => Some(1), + "read-only" => Some(2), + _ => None, } } @@ -1140,7 +1320,11 @@ pub fn load_project_config(workspace: &Path) -> Option { fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String { if matches!( provider, - ProviderKind::Atlascloud | ProviderKind::WanjieArk | ProviderKind::Ollama + ProviderKind::Atlascloud + | ProviderKind::WanjieArk + | ProviderKind::Volcengine + | ProviderKind::XiaomiMimo + | ProviderKind::Ollama ) { return model.to_string(); } @@ -1174,6 +1358,7 @@ fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String { (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => { DEFAULT_FIREWORKS_MODEL.to_string() } + (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2") => DEFAULT_MOONSHOT_MODEL.to_string(), (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => { DEFAULT_SGLANG_MODEL.to_string() } @@ -1201,9 +1386,12 @@ fn default_model_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::Openai => DEFAULT_OPENAI_MODEL, ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL, ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL, + ProviderKind::Volcengine => DEFAULT_VOLCENGINE_MODEL, ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL, + ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL, ProviderKind::Novita => DEFAULT_NOVITA_MODEL, ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL, + ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL, ProviderKind::Sglang => DEFAULT_SGLANG_MODEL, ProviderKind::Vllm => DEFAULT_VLLM_MODEL, ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL, @@ -1217,15 +1405,25 @@ fn default_base_url_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL, ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL, ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL, + ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL, ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL, + ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL, ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL, ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL, + ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL, ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL, ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL, ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL, } } +fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool { + let normalized = base_url.trim_end_matches('/').to_ascii_lowercase(); + normalized == DEFAULT_KIMI_CODE_BASE_URL + || normalized == "https://api.kimi.com/coding" + || normalized.starts_with("https://api.kimi.com/coding/") +} + fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool { let actual = base_url.trim_end_matches('/'); let default = default_base_url_for_provider(provider).trim_end_matches('/'); @@ -1282,6 +1480,17 @@ fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool { ) } +fn auth_mode_uses_kimi_oauth(auth_mode: &str) -> bool { + matches!( + auth_mode + .trim() + .to_ascii_lowercase() + .replace('-', "_") + .as_str(), + "kimi" | "kimi_oauth" | "kimi_cli" | "oauth" + ) +} + fn base_url_uses_local_host(base_url: &str) -> bool { let Some(host) = base_url_host(base_url) else { return false; @@ -1512,6 +1721,30 @@ pub fn ensure_state_dir(subdir: &str) -> Result { Ok(dir) } +/// Resolve a project-local state subdirectory, preferring `.codewhale/` +/// when it exists, falling back to `.deepseek/` for legacy projects. +/// +/// Returns `(true, path)` when the primary `.codewhale/` path is used, +/// `(false, path)` for the legacy fallback. The boolean helps callers +/// emit a deprecation notice on legacy paths. +pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> (bool, PathBuf) { + let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir); + if primary.exists() { + return (true, primary); + } + let legacy = workspace.join(LEGACY_APP_DIR).join(subdir); + (false, legacy) +} + +/// Ensure a project-local state subdirectory exists under `.codewhale/`, +/// creating it if necessary. Returns the directory path. +pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result { + let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir); + std::fs::create_dir_all(&dir) + .with_context(|| format!("failed to create {}/", dir.display()))?; + Ok(dir) +} + pub fn resolve_config_path(explicit: Option) -> Result { let path = if let Some(path) = explicit { path @@ -1640,10 +1873,7 @@ fn redact_secret(secret: &str) -> String { #[must_use] pub fn is_sensitive_config_key(key: &str) -> bool { - matches!( - key, - "api_key" | "auth.chatgpt_access_token" | "auth.device_code_session" - ) || key.ends_with(".api_key") + key == "api_key" || key.ends_with(".api_key") } fn normalize_config_file_path(path: PathBuf) -> Result { @@ -1671,7 +1901,10 @@ fn normalize_config_file_path(path: PathBuf) -> Result { struct EnvRuntimeOverrides { provider: Option, model: Option, + volcengine_model: Option, wanjie_ark_model: Option, + moonshot_model: Option, + xiaomi_mimo_model: Option, output_mode: Option, auth_mode: Option, log_level: Option, @@ -1684,10 +1917,13 @@ struct EnvRuntimeOverrides { nvidia_base_url: Option, openai_base_url: Option, atlascloud_base_url: Option, + volcengine_base_url: Option, wanjie_ark_base_url: Option, openrouter_base_url: Option, + xiaomi_mimo_base_url: Option, novita_base_url: Option, fireworks_base_url: Option, + moonshot_base_url: Option, sglang_base_url: Option, vllm_base_url: Option, ollama_base_url: Option, @@ -1696,15 +1932,33 @@ struct EnvRuntimeOverrides { impl EnvRuntimeOverrides { fn load() -> Self { Self { - provider: std::env::var("DEEPSEEK_PROVIDER") + provider: std::env::var("CODEWHALE_PROVIDER") + .or_else(|_| std::env::var("DEEPSEEK_PROVIDER")) .ok() .and_then(|v| ProviderKind::parse(&v)), - model: std::env::var("DEEPSEEK_MODEL").ok(), + model: std::env::var("CODEWHALE_MODEL") + .or_else(|_| std::env::var("DEEPSEEK_MODEL")) + .or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL")) + .ok() + .filter(|v| !v.trim().is_empty()), + volcengine_model: std::env::var("VOLCENGINE_MODEL") + .or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL")) + .ok() + .filter(|v| !v.trim().is_empty()), wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL") .or_else(|_| std::env::var("WANJIE_MODEL")) .or_else(|_| std::env::var("WANJIE_MAAS_MODEL")) .ok() .filter(|v| !v.trim().is_empty()), + moonshot_model: std::env::var("MOONSHOT_MODEL") + .or_else(|_| std::env::var("KIMI_MODEL_NAME")) + .or_else(|_| std::env::var("KIMI_MODEL")) + .ok() + .filter(|v| !v.trim().is_empty()), + xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL") + .or_else(|_| std::env::var("MIMO_MODEL")) + .ok() + .filter(|v| !v.trim().is_empty()), output_mode: std::env::var("DEEPSEEK_OUTPUT_MODE").ok(), auth_mode: std::env::var("DEEPSEEK_AUTH_MODE").ok(), log_level: std::env::var("DEEPSEEK_LOG_LEVEL").ok(), @@ -1720,7 +1974,8 @@ impl EnvRuntimeOverrides { .ok() .and_then(|value| parse_http_headers(&value).ok()) .filter(|headers| !headers.is_empty()), - deepseek_base_url: std::env::var("DEEPSEEK_BASE_URL") + deepseek_base_url: std::env::var("CODEWHALE_BASE_URL") + .or_else(|_| std::env::var("DEEPSEEK_BASE_URL")) .ok() .filter(|v| !v.trim().is_empty()), nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL") @@ -1734,6 +1989,11 @@ impl EnvRuntimeOverrides { atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), + volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL") + .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL")) + .or_else(|_| std::env::var("ARK_BASE_URL")) + .ok() + .filter(|v| !v.trim().is_empty()), wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL") .or_else(|_| std::env::var("WANJIE_BASE_URL")) .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL")) @@ -1742,12 +2002,20 @@ impl EnvRuntimeOverrides { openrouter_base_url: std::env::var("OPENROUTER_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), + xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL") + .or_else(|_| std::env::var("MIMO_BASE_URL")) + .ok() + .filter(|v| !v.trim().is_empty()), novita_base_url: std::env::var("NOVITA_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), fireworks_base_url: std::env::var("FIREWORKS_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), + moonshot_base_url: std::env::var("MOONSHOT_BASE_URL") + .or_else(|_| std::env::var("KIMI_BASE_URL")) + .ok() + .filter(|v| !v.trim().is_empty()), sglang_base_url: std::env::var("SGLANG_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), @@ -1769,9 +2037,12 @@ impl EnvRuntimeOverrides { ProviderKind::Openai => self.openai_base_url.clone(), ProviderKind::Atlascloud => self.atlascloud_base_url.clone(), ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(), + ProviderKind::Volcengine => self.volcengine_base_url.clone(), ProviderKind::Openrouter => self.openrouter_base_url.clone(), + ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(), ProviderKind::Novita => self.novita_base_url.clone(), ProviderKind::Fireworks => self.fireworks_base_url.clone(), + ProviderKind::Moonshot => self.moonshot_base_url.clone(), ProviderKind::Sglang => self.sglang_base_url.clone(), ProviderKind::Vllm => self.vllm_base_url.clone(), ProviderKind::Ollama => self.ollama_base_url.clone(), @@ -1781,6 +2052,9 @@ impl EnvRuntimeOverrides { fn model_for(&self, provider: ProviderKind) -> Option { match provider { ProviderKind::WanjieArk => self.wanjie_ark_model.clone(), + ProviderKind::Volcengine => self.volcengine_model.clone(), + ProviderKind::Moonshot => self.moonshot_model.clone(), + ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(), _ => None, } } @@ -1819,6 +2093,7 @@ mod tests { deepseek_base_url: Option, deepseek_http_headers: Option, deepseek_model: Option, + deepseek_default_text_model: Option, deepseek_provider: Option, deepseek_auth_mode: Option, nvidia_api_key: Option, @@ -1828,10 +2103,17 @@ mod tests { nvidia_nim_base_url: Option, openrouter_api_key: Option, openrouter_base_url: Option, + xiaomi_mimo_api_key: Option, + mimo_api_key: Option, + xiaomi_mimo_base_url: Option, + mimo_base_url: Option, + xiaomi_mimo_model: Option, + mimo_model: Option, wanjie_ark_api_key: Option, wanjie_ark_base_url: Option, wanjie_base_url: Option, wanjie_maas_base_url: Option, + volcengine_model: Option, wanjie_ark_model: Option, wanjie_model: Option, wanjie_maas_model: Option, @@ -1839,12 +2121,22 @@ mod tests { novita_base_url: Option, fireworks_api_key: Option, fireworks_base_url: Option, + moonshot_api_key: Option, + moonshot_base_url: Option, + moonshot_model: Option, + kimi_api_key: Option, + kimi_base_url: Option, + kimi_model: Option, + kimi_model_name: Option, sglang_api_key: Option, sglang_base_url: Option, vllm_api_key: Option, vllm_base_url: Option, ollama_api_key: Option, ollama_base_url: Option, + codewhale_provider: Option, + codewhale_model: Option, + codewhale_base_url: Option, } impl EnvGuard { @@ -1854,8 +2146,12 @@ mod tests { deepseek_base_url: env::var_os("DEEPSEEK_BASE_URL"), deepseek_http_headers: env::var_os("DEEPSEEK_HTTP_HEADERS"), deepseek_model: env::var_os("DEEPSEEK_MODEL"), + deepseek_default_text_model: env::var_os("DEEPSEEK_DEFAULT_TEXT_MODEL"), deepseek_provider: env::var_os("DEEPSEEK_PROVIDER"), deepseek_auth_mode: env::var_os("DEEPSEEK_AUTH_MODE"), + codewhale_provider: env::var_os("CODEWHALE_PROVIDER"), + codewhale_model: env::var_os("CODEWHALE_MODEL"), + codewhale_base_url: env::var_os("CODEWHALE_BASE_URL"), nvidia_api_key: env::var_os("NVIDIA_API_KEY"), nvidia_nim_api_key: env::var_os("NVIDIA_NIM_API_KEY"), nim_base_url: env::var_os("NIM_BASE_URL"), @@ -1863,10 +2159,17 @@ mod tests { nvidia_nim_base_url: env::var_os("NVIDIA_NIM_BASE_URL"), openrouter_api_key: env::var_os("OPENROUTER_API_KEY"), openrouter_base_url: env::var_os("OPENROUTER_BASE_URL"), + xiaomi_mimo_api_key: env::var_os("XIAOMI_MIMO_API_KEY"), + mimo_api_key: env::var_os("MIMO_API_KEY"), + xiaomi_mimo_base_url: env::var_os("XIAOMI_MIMO_BASE_URL"), + mimo_base_url: env::var_os("MIMO_BASE_URL"), + xiaomi_mimo_model: env::var_os("XIAOMI_MIMO_MODEL"), + mimo_model: env::var_os("MIMO_MODEL"), wanjie_ark_api_key: env::var_os("WANJIE_ARK_API_KEY"), wanjie_ark_base_url: env::var_os("WANJIE_ARK_BASE_URL"), wanjie_base_url: env::var_os("WANJIE_BASE_URL"), wanjie_maas_base_url: env::var_os("WANJIE_MAAS_BASE_URL"), + volcengine_model: env::var_os("VOLCENGINE_MODEL"), wanjie_ark_model: env::var_os("WANJIE_ARK_MODEL"), wanjie_model: env::var_os("WANJIE_MODEL"), wanjie_maas_model: env::var_os("WANJIE_MAAS_MODEL"), @@ -1874,6 +2177,13 @@ mod tests { novita_base_url: env::var_os("NOVITA_BASE_URL"), fireworks_api_key: env::var_os("FIREWORKS_API_KEY"), fireworks_base_url: env::var_os("FIREWORKS_BASE_URL"), + moonshot_api_key: env::var_os("MOONSHOT_API_KEY"), + moonshot_base_url: env::var_os("MOONSHOT_BASE_URL"), + moonshot_model: env::var_os("MOONSHOT_MODEL"), + kimi_api_key: env::var_os("KIMI_API_KEY"), + kimi_base_url: env::var_os("KIMI_BASE_URL"), + kimi_model: env::var_os("KIMI_MODEL"), + kimi_model_name: env::var_os("KIMI_MODEL_NAME"), sglang_api_key: env::var_os("SGLANG_API_KEY"), sglang_base_url: env::var_os("SGLANG_BASE_URL"), vllm_api_key: env::var_os("VLLM_API_KEY"), @@ -1887,8 +2197,12 @@ mod tests { env::remove_var("DEEPSEEK_BASE_URL"); env::remove_var("DEEPSEEK_HTTP_HEADERS"); env::remove_var("DEEPSEEK_MODEL"); + env::remove_var("DEEPSEEK_DEFAULT_TEXT_MODEL"); env::remove_var("DEEPSEEK_PROVIDER"); env::remove_var("DEEPSEEK_AUTH_MODE"); + env::remove_var("CODEWHALE_PROVIDER"); + env::remove_var("CODEWHALE_MODEL"); + env::remove_var("CODEWHALE_BASE_URL"); env::remove_var("NVIDIA_API_KEY"); env::remove_var("NVIDIA_NIM_API_KEY"); env::remove_var("NIM_BASE_URL"); @@ -1896,6 +2210,12 @@ mod tests { env::remove_var("NVIDIA_NIM_BASE_URL"); env::remove_var("OPENROUTER_API_KEY"); env::remove_var("OPENROUTER_BASE_URL"); + env::remove_var("XIAOMI_MIMO_API_KEY"); + env::remove_var("MIMO_API_KEY"); + env::remove_var("XIAOMI_MIMO_BASE_URL"); + env::remove_var("MIMO_BASE_URL"); + env::remove_var("XIAOMI_MIMO_MODEL"); + env::remove_var("MIMO_MODEL"); env::remove_var("WANJIE_ARK_API_KEY"); env::remove_var("WANJIE_ARK_BASE_URL"); env::remove_var("WANJIE_BASE_URL"); @@ -1907,6 +2227,13 @@ mod tests { env::remove_var("NOVITA_BASE_URL"); env::remove_var("FIREWORKS_API_KEY"); env::remove_var("FIREWORKS_BASE_URL"); + env::remove_var("MOONSHOT_API_KEY"); + env::remove_var("MOONSHOT_BASE_URL"); + env::remove_var("MOONSHOT_MODEL"); + env::remove_var("KIMI_API_KEY"); + env::remove_var("KIMI_BASE_URL"); + env::remove_var("KIMI_MODEL"); + env::remove_var("KIMI_MODEL_NAME"); env::remove_var("SGLANG_API_KEY"); env::remove_var("SGLANG_BASE_URL"); env::remove_var("VLLM_API_KEY"); @@ -1934,8 +2261,15 @@ mod tests { Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take()); Self::restore_var("DEEPSEEK_HTTP_HEADERS", self.deepseek_http_headers.take()); Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take()); + Self::restore_var( + "DEEPSEEK_DEFAULT_TEXT_MODEL", + self.deepseek_default_text_model.take(), + ); Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take()); Self::restore_var("DEEPSEEK_AUTH_MODE", self.deepseek_auth_mode.take()); + Self::restore_var("CODEWHALE_PROVIDER", self.codewhale_provider.take()); + Self::restore_var("CODEWHALE_MODEL", self.codewhale_model.take()); + Self::restore_var("CODEWHALE_BASE_URL", self.codewhale_base_url.take()); Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take()); Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take()); Self::restore_var("NIM_BASE_URL", self.nim_base_url.take()); @@ -1943,10 +2277,17 @@ mod tests { Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take()); Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take()); Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take()); + Self::restore_var("XIAOMI_MIMO_API_KEY", self.xiaomi_mimo_api_key.take()); + Self::restore_var("MIMO_API_KEY", self.mimo_api_key.take()); + Self::restore_var("XIAOMI_MIMO_BASE_URL", self.xiaomi_mimo_base_url.take()); + Self::restore_var("MIMO_BASE_URL", self.mimo_base_url.take()); + Self::restore_var("XIAOMI_MIMO_MODEL", self.xiaomi_mimo_model.take()); + Self::restore_var("MIMO_MODEL", self.mimo_model.take()); Self::restore_var("WANJIE_ARK_API_KEY", self.wanjie_ark_api_key.take()); Self::restore_var("WANJIE_ARK_BASE_URL", self.wanjie_ark_base_url.take()); Self::restore_var("WANJIE_BASE_URL", self.wanjie_base_url.take()); Self::restore_var("WANJIE_MAAS_BASE_URL", self.wanjie_maas_base_url.take()); + Self::restore_var("VOLCENGINE_MODEL", self.volcengine_model.take()); Self::restore_var("WANJIE_ARK_MODEL", self.wanjie_ark_model.take()); Self::restore_var("WANJIE_MODEL", self.wanjie_model.take()); Self::restore_var("WANJIE_MAAS_MODEL", self.wanjie_maas_model.take()); @@ -1954,6 +2295,13 @@ mod tests { Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take()); Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take()); Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take()); + Self::restore_var("MOONSHOT_API_KEY", self.moonshot_api_key.take()); + Self::restore_var("MOONSHOT_BASE_URL", self.moonshot_base_url.take()); + Self::restore_var("MOONSHOT_MODEL", self.moonshot_model.take()); + Self::restore_var("KIMI_API_KEY", self.kimi_api_key.take()); + Self::restore_var("KIMI_BASE_URL", self.kimi_base_url.take()); + Self::restore_var("KIMI_MODEL", self.kimi_model.take()); + Self::restore_var("KIMI_MODEL_NAME", self.kimi_model_name.take()); Self::restore_var("SGLANG_API_KEY", self.sglang_api_key.take()); Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take()); Self::restore_var("VLLM_API_KEY", self.vllm_api_key.take()); @@ -2257,7 +2605,6 @@ mod tests { fn get_display_value_redacts_sensitive_keys() { let mut config = ConfigToml { api_key: Some("sk-deepseek-secret".to_string()), - chatgpt_access_token: Some("chatgpt-access-secret".to_string()), ..ConfigToml::default() }; config.providers.openrouter.api_key = Some("openrouter-secret-value".to_string()); @@ -2267,12 +2614,6 @@ mod tests { config.get_display_value("api_key").as_deref(), Some("sk-d***cret") ); - assert_eq!( - config - .get_display_value("auth.chatgpt_access_token") - .as_deref(), - Some("chat***cret") - ); assert_eq!( config .get_display_value("providers.openrouter.api_key") @@ -2285,6 +2626,182 @@ mod tests { ); } + /// End-to-end smoke for the preferred Kimi Code setup path: + /// 1. Start from a fresh root config that uses DeepSeek defaults. + /// 2. Mutate it through the same key-value setters the + /// `codewhale config set providers.moonshot.*` CLI invokes. + /// 3. Switch the active provider through `CODEWHALE_PROVIDER` — + /// the public env alias — without ever touching the legacy + /// `DEEPSEEK_PROVIDER` name. + /// 4. Resolve the runtime and confirm the doctor/runtime values. + /// + /// No real API key is required; the `api_key` here is just a + /// non-empty placeholder. + #[test] + fn moonshot_kimi_code_smoke_config_set_then_resolve() -> Result<()> { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + + let mut config = ConfigToml { + provider: ProviderKind::Deepseek, + default_text_model: Some("deepseek-v4-pro".to_string()), + ..ConfigToml::default() + }; + + // Same key paths a user would run via `codewhale config set`. + config.set_value("providers.moonshot.api_key", "kimi-code-key-placeholder")?; + config.set_value("providers.moonshot.auth_mode", "api_key")?; + config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?; + config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?; + + // Public env alias for the active-provider switch. + // Safety: test-only env mutation guarded by env_lock(). + unsafe { env::set_var("CODEWHALE_PROVIDER", "moonshot") }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL); + assert_eq!(resolved.auth_mode.as_deref(), Some("api_key")); + assert_eq!( + resolved.api_key.as_deref(), + Some("kimi-code-key-placeholder") + ); + assert_eq!( + resolved.api_key_source, + Some(RuntimeApiKeySource::ConfigFile) + ); + Ok(()) + } + + #[test] + fn moonshot_provider_config_values_round_trip() -> Result<()> { + let mut config = ConfigToml::default(); + + config.set_value("providers.moonshot.api_key", "moonshot-secret-value")?; + config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?; + config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?; + config.set_value("providers.moonshot.auth_mode", "api_key")?; + config.set_value("providers.moonshot.http_headers", "X-Test=ok")?; + + assert_eq!( + config + .get_display_value("providers.moonshot.api_key") + .as_deref(), + Some("moon***alue") + ); + assert_eq!( + config.get_value("providers.moonshot.base_url").as_deref(), + Some(DEFAULT_KIMI_CODE_BASE_URL) + ); + assert_eq!( + config.get_value("providers.moonshot.model").as_deref(), + Some(DEFAULT_KIMI_CODE_MODEL) + ); + assert_eq!( + config.get_value("providers.moonshot.auth_mode").as_deref(), + Some("api_key") + ); + assert_eq!( + config + .list_values() + .get("providers.moonshot.api_key") + .map(String::as_str), + Some("moon***alue") + ); + + config.unset_value("providers.moonshot.auth_mode")?; + config.unset_value("providers.moonshot.base_url")?; + config.unset_value("providers.moonshot.model")?; + + assert_eq!(config.get_value("providers.moonshot.auth_mode"), None); + assert_eq!(config.get_value("providers.moonshot.base_url"), None); + assert_eq!(config.get_value("providers.moonshot.model"), None); + Ok(()) + } + + #[test] + fn project_merge_denies_credentials_endpoints_and_provider_selection() { + let mut base = ConfigToml { + provider: ProviderKind::Deepseek, + api_key: Some("user-key".to_string()), + base_url: Some("https://api.deepseek.com".to_string()), + default_text_model: Some("deepseek-v4-flash".to_string()), + ..ConfigToml::default() + }; + base.providers.openrouter.api_key = Some("user-openrouter-key".to_string()); + + let mut project = ConfigToml { + provider: ProviderKind::Openrouter, + api_key: Some("attacker-key".to_string()), + base_url: Some("https://evil.example/v1".to_string()), + default_text_model: Some("deepseek-v4-pro".to_string()), + auth_mode: Some("oauth".to_string()), + telemetry: Some(true), + ..ConfigToml::default() + }; + project.providers.openrouter.api_key = Some("attacker-openrouter-key".to_string()); + project.providers.openrouter.base_url = Some("https://evil.example/openrouter".to_string()); + project.providers.openrouter.model = Some("deepseek/deepseek-v4-pro".to_string()); + + base.merge_project_overrides(project); + + assert_eq!(base.provider, ProviderKind::Deepseek); + assert_eq!(base.api_key.as_deref(), Some("user-key")); + assert_eq!(base.base_url.as_deref(), Some("https://api.deepseek.com")); + assert_eq!(base.auth_mode, None); + assert_eq!(base.telemetry, None); + assert_eq!( + base.providers.openrouter.api_key.as_deref(), + Some("user-openrouter-key") + ); + assert_eq!(base.providers.openrouter.base_url, None); + assert_eq!(base.default_text_model.as_deref(), Some("deepseek-v4-pro")); + assert_eq!( + base.providers.openrouter.model.as_deref(), + Some("deepseek/deepseek-v4-pro") + ); + } + + #[test] + fn project_merge_only_tightens_approval_and_sandbox_policy() { + let mut strict = ConfigToml { + approval_policy: Some("never".to_string()), + sandbox_mode: Some("read-only".to_string()), + ..ConfigToml::default() + }; + strict.merge_project_overrides(ConfigToml { + approval_policy: Some("on-request".to_string()), + sandbox_mode: Some("workspace-write".to_string()), + ..ConfigToml::default() + }); + assert_eq!(strict.approval_policy.as_deref(), Some("never")); + assert_eq!(strict.sandbox_mode.as_deref(), Some("read-only")); + + let mut permissive = ConfigToml { + approval_policy: Some("auto".to_string()), + sandbox_mode: Some("workspace-write".to_string()), + ..ConfigToml::default() + }; + permissive.merge_project_overrides(ConfigToml { + approval_policy: Some("never".to_string()), + sandbox_mode: Some("read-only".to_string()), + ..ConfigToml::default() + }); + assert_eq!(permissive.approval_policy.as_deref(), Some("never")); + assert_eq!(permissive.sandbox_mode.as_deref(), Some("read-only")); + + let mut unset = ConfigToml::default(); + unset.merge_project_overrides(ConfigToml { + approval_policy: Some("on-request".to_string()), + sandbox_mode: Some("workspace-write".to_string()), + ..ConfigToml::default() + }); + assert_eq!(unset.approval_policy, None); + assert_eq!(unset.sandbox_mode, None); + } + #[test] fn list_values_redacts_unicode_api_key_without_byte_slicing() { let config = ConfigToml { @@ -2350,12 +2867,25 @@ mod tests { ProviderKind::parse("OPEN_ROUTER"), Some(ProviderKind::Openrouter) ); + assert_eq!( + ProviderKind::parse("xiaomi-mimo"), + Some(ProviderKind::XiaomiMimo) + ); + assert_eq!( + ProviderKind::parse("xiaomi"), + Some(ProviderKind::XiaomiMimo) + ); assert_eq!(ProviderKind::parse("novita"), Some(ProviderKind::Novita)); assert_eq!(ProviderKind::parse("Novita"), Some(ProviderKind::Novita)); assert_eq!( ProviderKind::parse("fireworks-ai"), Some(ProviderKind::Fireworks) ); + assert_eq!(ProviderKind::parse("kimi"), Some(ProviderKind::Moonshot)); + assert_eq!( + ProviderKind::parse("moonshot-ai"), + Some(ProviderKind::Moonshot) + ); assert_eq!(ProviderKind::parse("sg-lang"), Some(ProviderKind::Sglang)); assert_eq!(ProviderKind::parse("v-llm"), Some(ProviderKind::Vllm)); assert_eq!(ProviderKind::parse("vllm"), Some(ProviderKind::Vllm)); @@ -2410,6 +2940,22 @@ mod tests { assert_eq!(resolved.model, DEFAULT_OPENROUTER_MODEL); } + #[test] + fn xiaomi_mimo_provider_defaults_to_canonical_endpoint_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::XiaomiMimo, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL); + assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL); + } + #[test] fn novita_provider_defaults_to_canonical_endpoint_and_model() { let _lock = env_lock(); @@ -2442,6 +2988,169 @@ mod tests { assert_eq!(resolved.model, DEFAULT_FIREWORKS_MODEL); } + #[test] + fn moonshot_provider_defaults_to_kimi_k2() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Moonshot, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.base_url, DEFAULT_MOONSHOT_BASE_URL); + assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL); + } + + #[test] + fn moonshot_kimi_oauth_uses_kimi_code_endpoint_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::Moonshot, + ..ConfigToml::default() + }; + config.providers.moonshot.auth_mode = Some("kimi_oauth".to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.auth_mode.as_deref(), Some("kimi_oauth")); + assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL); + assert_eq!(resolved.api_key, None); + assert_eq!(resolved.api_key_source, None); + } + + #[test] + fn moonshot_kimi_code_api_key_endpoint_defaults_to_kimi_for_coding() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::Moonshot, + ..ConfigToml::default() + }; + config.providers.moonshot.api_key = Some("kimi-code-key".to_string()); + config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.auth_mode, None); + assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL); + assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key")); + assert_eq!( + resolved.api_key_source, + Some(RuntimeApiKeySource::ConfigFile) + ); + } + + /// `CODEWHALE_PROVIDER` is the user-facing env alias for switching the + /// active provider. It must be honored by the runtime resolver and win + /// over a root `provider = "deepseek"` config entry. + #[test] + fn codewhale_provider_env_switches_active_provider() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + } + let mut config = ConfigToml { + provider: ProviderKind::Deepseek, + ..ConfigToml::default() + }; + config.providers.moonshot.api_key = Some("kimi-code-key".to_string()); + config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL); + assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key")); + } + + /// When both `CODEWHALE_PROVIDER` and the legacy `DEEPSEEK_PROVIDER` + /// are set, the public alias wins — a user adopting `CODEWHALE_*` in a + /// fresh shell config is not tripped up by a stale legacy export still + /// living in their dotfiles. + #[test] + fn codewhale_provider_env_wins_over_deepseek_provider_env() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + env::set_var("DEEPSEEK_PROVIDER", "openrouter"); + } + let config = ConfigToml { + provider: ProviderKind::Deepseek, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + } + + /// `CODEWHALE_MODEL` is the user-facing env alias for picking a model + /// against the active provider. It must be honored by the runtime + /// resolver in place of `DEEPSEEK_MODEL`. + #[test] + fn codewhale_model_env_alias_overrides_default_for_active_provider() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + env::set_var("CODEWHALE_MODEL", "custom-kimi-test-model"); + } + let config = ConfigToml::default(); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.model, "custom-kimi-test-model"); + } + + #[test] + fn blank_codewhale_model_env_alias_does_not_override_default_for_active_provider() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + env::set_var("CODEWHALE_MODEL", " "); + } + let config = ConfigToml::default(); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL); + } + + #[test] + fn deepseek_default_text_model_legacy_alias_still_overrides_active_provider_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + env::set_var("DEEPSEEK_DEFAULT_TEXT_MODEL", "legacy-env-model"); + } + let config = ConfigToml::default(); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.model, "legacy-env-model"); + } + #[test] fn wanjie_ark_provider_defaults_to_openai_compatible_endpoint_and_model() { let _lock = env_lock(); @@ -2556,6 +3265,25 @@ mod tests { assert_eq!(store.gets.lock().unwrap().as_slice(), ["ollama"]); } + #[test] + fn moonshot_api_key_mode_can_use_secret_store_by_default() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key")); + let secrets = Secrets::new(store.clone()); + let config = ConfigToml { + provider: ProviderKind::Moonshot, + ..ConfigToml::default() + }; + + let resolved = + config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); + + assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key")); + assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring)); + assert_eq!(store.gets.lock().unwrap().as_slice(), ["moonshot"]); + } + #[test] fn loopback_custom_deepseek_base_url_does_not_probe_secret_store_by_default() { let _lock = env_lock(); @@ -2632,6 +3360,27 @@ mod tests { assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL); } + #[test] + fn xiaomi_mimo_env_overrides_provider_key_base_url_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo"); + env::set_var("MIMO_API_KEY", "mimo-env-key"); + env::set_var("MIMO_BASE_URL", "https://mimo-gateway.example/v1"); + env::set_var("MIMO_MODEL", "mimo-v2.5"); + } + + let resolved = + ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.api_key.as_deref(), Some("mimo-env-key")); + assert_eq!(resolved.base_url, "https://mimo-gateway.example/v1"); + assert_eq!(resolved.model, "mimo-v2.5"); + } + #[test] fn novita_env_api_key_falls_back_when_config_missing() { let _lock = env_lock(); diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index debdf425..45853186 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,13 +9,13 @@ description = "Core runtime boundaries for DeepSeek workspace architecture" [dependencies] anyhow.workspace = true chrono.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.44" } -codewhale-config = { path = "../config", version = "0.8.44" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.44" } -codewhale-hooks = { path = "../hooks", version = "0.8.44" } -codewhale-mcp = { path = "../mcp", version = "0.8.44" } -codewhale-protocol = { path = "../protocol", version = "0.8.44" } -codewhale-state = { path = "../state", version = "0.8.44" } -codewhale-tools = { path = "../tools", version = "0.8.44" } +codewhale-agent = { path = "../agent", version = "0.8.46" } +codewhale-config = { path = "../config", version = "0.8.46" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.46" } +codewhale-hooks = { path = "../hooks", version = "0.8.46" } +codewhale-mcp = { path = "../mcp", version = "0.8.46" } +codewhale-protocol = { path = "../protocol", version = "0.8.46" } +codewhale-state = { path = "../state", version = "0.8.46" } +codewhale-tools = { path = "../tools", version = "0.8.46" } serde_json.workspace = true uuid.workspace = true diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index e6d9f094..472095cc 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -643,6 +643,7 @@ impl ThreadManager { git_branch: None, git_origin_url: None, memory_mode: None, + current_leaf_id: None, }) } } diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index 669759c4..acf2ce21 100644 --- a/crates/execpolicy/Cargo.toml +++ b/crates/execpolicy/Cargo.toml @@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace [dependencies] anyhow.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.8.44" } +codewhale-protocol = { path = "../protocol", version = "0.8.46" } serde.workspace = true diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index a39dc18f..a6a3600e 100644 --- a/crates/hooks/Cargo.toml +++ b/crates/hooks/Cargo.toml @@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc anyhow.workspace = true async-trait.workspace = true chrono.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.8.44" } +codewhale-protocol = { path = "../protocol", version = "0.8.46" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/release/Cargo.toml b/crates/release/Cargo.toml new file mode 100644 index 00000000..67520686 --- /dev/null +++ b/crates/release/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "codewhale-release" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Shared CodeWhale release discovery and version comparison helpers" + +[dependencies] +anyhow.workspace = true +reqwest = { workspace = true, features = ["blocking"] } +semver.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/crates/release/src/lib.rs b/crates/release/src/lib.rs new file mode 100644 index 00000000..327bb874 --- /dev/null +++ b/crates/release/src/lib.rs @@ -0,0 +1,369 @@ +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use serde::Deserialize; + +pub const CHECKSUM_MANIFEST_ASSET: &str = "codewhale-artifacts-sha256.txt"; +pub const LATEST_RELEASE_URL: &str = + "https://api.github.com/repos/Hmbown/CodeWhale/releases/latest"; +pub const RELEASES_URL: &str = + "https://api.github.com/repos/Hmbown/CodeWhale/releases?per_page=100"; +pub const CNB_REPO_URL: &str = "https://cnb.cool/codewhale.net/codewhale"; +pub const RELEASE_BASE_URL_ENV: &str = "CODEWHALE_RELEASE_BASE_URL"; +pub const LEGACY_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_TUI_RELEASE_BASE_URL"; +pub const DEEPSEEK_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_RELEASE_BASE_URL"; +pub const CNB_MIRROR_ENV: &str = "CODEWHALE_USE_CNB_MIRROR"; +pub const UPDATE_VERSION_ENV: &str = "DEEPSEEK_TUI_VERSION"; +pub const LEGACY_UPDATE_VERSION_ENV: &str = "DEEPSEEK_VERSION"; +pub const UPDATE_USER_AGENT: &str = "codewhale-updater"; + +const CNB_RELEASE_ASSET_BASE: &str = "https://cnb.cool/Hmbown/CodeWhale/-/releases"; +const RELEASE_METADATA_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseChannel { + Stable, + Beta, +} + +impl ReleaseChannel { + pub fn from_beta_flag(beta: bool) -> Self { + if beta { Self::Beta } else { Self::Stable } + } + + pub fn label(self) -> &'static str { + match self { + Self::Stable => "stable", + Self::Beta => "beta", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReleaseQuery { + Mirror { base_url: String, version: String }, + GitHubLatest { url: &'static str }, + GitHubReleaseList { url: &'static str }, +} + +pub fn resolve_release_query(channel: ReleaseChannel) -> ReleaseQuery { + let version = update_version_from_env().unwrap_or_else(|| env!("CARGO_PKG_VERSION").into()); + if let Some(base_url) = release_base_url_from_env(&version) { + return ReleaseQuery::Mirror { base_url, version }; + } + + match channel { + ReleaseChannel::Stable => ReleaseQuery::GitHubLatest { + url: LATEST_RELEASE_URL, + }, + ReleaseChannel::Beta => ReleaseQuery::GitHubReleaseList { url: RELEASES_URL }, + } +} + +pub fn release_base_url_from_env(version: &str) -> Option { + for env_name in [ + RELEASE_BASE_URL_ENV, + LEGACY_RELEASE_BASE_URL_ENV, + DEEPSEEK_RELEASE_BASE_URL_ENV, + ] { + if let Ok(value) = std::env::var(env_name) { + let trimmed = value.trim().to_string(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + } + + if std::env::var(CNB_MIRROR_ENV).is_ok() { + return Some(cnb_release_base_url(version)); + } + None +} + +pub fn cnb_release_base_url(version: &str) -> String { + format!( + "{}/v{}", + CNB_RELEASE_ASSET_BASE.trim_end_matches('/'), + version.trim_start_matches('v') + ) +} + +pub fn update_version_from_env() -> Option { + std::env::var(UPDATE_VERSION_ENV) + .ok() + .or_else(|| std::env::var(LEGACY_UPDATE_VERSION_ENV).ok()) + .map(|value| value.trim().trim_start_matches('v').to_string()) + .filter(|value| !value.is_empty()) +} + +pub fn mirror_asset_url(base_url: &str, asset_name: &str) -> String { + format!("{}/{}", base_url.trim_end_matches('/'), asset_name) +} + +pub fn update_network_fallback_hint() -> String { + format!( + "GitHub release downloads may be blocked or slow on this network.\n\ + For mainland China, use one of these fallback paths:\n\ + 1. Source build from the CNB mirror, installing both shipped binaries:\n\ + cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-cli --locked --force\n\ + cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-tui --locked --force\n\ + 2. Use a binary asset mirror:\n\ + {RELEASE_BASE_URL_ENV}=https://// {UPDATE_VERSION_ENV}=X.Y.Z codewhale update\n\ + The mirror directory must contain {CHECKSUM_MANIFEST_ASSET} and the platform binaries." + ) +} + +pub fn fetch_release_json_blocking(url: &str, description: &str) -> Result { + let client = reqwest::blocking::Client::builder() + .user_agent(UPDATE_USER_AGENT) + .timeout(RELEASE_METADATA_TIMEOUT) + .build() + .context("failed to build release check HTTP client")?; + let response = client + .get(url) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .send() + .with_context(|| format!("failed to fetch {description} from {url}"))?; + let status = response.status(); + let body = response + .text() + .with_context(|| format!("failed to read {description} response from {url}")); + release_response_body(status, body, url, description) +} + +pub async fn fetch_release_json_async(url: &str, description: &str) -> Result { + let client = reqwest::Client::builder() + .user_agent(UPDATE_USER_AGENT) + .timeout(RELEASE_METADATA_TIMEOUT) + .build() + .context("failed to build release check HTTP client")?; + let response = client + .get(url) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .send() + .await + .with_context(|| format!("failed to fetch {description} from {url}"))?; + let status = response.status(); + let body = response + .text() + .await + .with_context(|| format!("failed to read {description} response from {url}")); + release_response_body(status, body, url, description) +} + +fn release_response_body( + status: reqwest::StatusCode, + body: Result, + url: &str, + description: &str, +) -> Result { + let body = body.with_context(|| format!("failed to read {description} response from {url}"))?; + if !status.is_success() { + bail!("GitHub release request failed with HTTP {status}: {body}"); + } + Ok(body) +} + +#[derive(Deserialize)] +struct ReleaseTag { + tag_name: String, +} + +#[derive(Deserialize)] +struct ReleaseListEntry { + tag_name: String, +} + +pub fn latest_tag_from_release_json(body: &str) -> Result { + let release: ReleaseTag = serde_json::from_str(body).with_context(|| { + format!("failed to parse release JSON from GitHub API. Response: {body}") + })?; + Ok(release.tag_name) +} + +pub fn latest_beta_tag_from_release_list_json(body: &str) -> Result { + let releases: Vec = serde_json::from_str(body).with_context(|| { + format!("failed to parse release list JSON from GitHub API. Response: {body}") + })?; + releases + .into_iter() + .find(|release| is_beta_tag(&release.tag_name)) + .map(|release| release.tag_name) + .context("no beta release found in GitHub releases") +} + +pub async fn latest_release_tag_async(channel: ReleaseChannel) -> Result { + match resolve_release_query(channel) { + ReleaseQuery::Mirror { version, .. } => Ok(format!("v{}", version.trim_start_matches('v'))), + ReleaseQuery::GitHubLatest { url } => { + let body = fetch_release_json_async(url, "latest release").await?; + latest_tag_from_release_json(&body) + } + ReleaseQuery::GitHubReleaseList { url } => { + let body = fetch_release_json_async(url, "release list").await?; + latest_beta_tag_from_release_list_json(&body) + } + } +} + +pub fn latest_release_tag_blocking(channel: ReleaseChannel) -> Result { + match resolve_release_query(channel) { + ReleaseQuery::Mirror { version, .. } => Ok(format!("v{}", version.trim_start_matches('v'))), + ReleaseQuery::GitHubLatest { url } => { + let body = fetch_release_json_blocking(url, "latest release")?; + latest_tag_from_release_json(&body) + } + ReleaseQuery::GitHubReleaseList { url } => { + let body = fetch_release_json_blocking(url, "release list")?; + latest_beta_tag_from_release_list_json(&body) + } + } +} + +pub fn compare_release_versions( + current_version: &str, + latest_tag: &str, +) -> Result { + let current = parse_release_version(current_version) + .with_context(|| format!("failed to parse current version {current_version:?}"))?; + let latest = parse_release_version(latest_tag) + .with_context(|| format!("failed to parse latest release tag {latest_tag:?}"))?; + Ok(current.cmp(&latest)) +} + +pub fn update_is_needed( + channel: ReleaseChannel, + current_version: &str, + latest_tag: &str, +) -> Result { + let current = parse_release_version(current_version) + .with_context(|| format!("failed to parse current version {current_version:?}"))?; + let latest = parse_release_version(latest_tag) + .with_context(|| format!("failed to parse latest release tag {latest_tag:?}"))?; + + match channel { + ReleaseChannel::Stable => Ok(current < latest), + ReleaseChannel::Beta => { + if current == latest { + return Ok(false); + } + let latest_is_beta = version_is_beta(&latest); + let current_is_stable = current.pre.is_empty(); + let same_release_line = current.major == latest.major + && current.minor == latest.minor + && current.patch == latest.patch; + if current > latest && !(current_is_stable && same_release_line) { + return Ok(false); + } + Ok(latest_is_beta) + } + } +} + +pub fn parse_release_version(value: &str) -> Result { + let version = value + .trim() + .trim_start_matches('v') + .split_whitespace() + .next() + .unwrap_or(""); + semver::Version::parse(version).with_context(|| format!("invalid semver: {value:?}")) +} + +pub fn is_beta_tag(tag_name: &str) -> bool { + tag_name.to_ascii_lowercase().contains("beta") +} + +fn version_is_beta(version: &semver::Version) -> bool { + version.pre.as_str().to_ascii_lowercase().contains("beta") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cnb_release_base_url_includes_tag_directory() { + assert_eq!( + cnb_release_base_url("0.8.47"), + "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47" + ); + assert_eq!( + cnb_release_base_url("v0.8.47"), + "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47" + ); + } + + #[test] + fn stable_update_is_needed_only_when_latest_is_newer() { + assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.46").unwrap()); + assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.9.0-beta.1").unwrap()); + assert!(!update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.45").unwrap()); + assert!(!update_is_needed(ReleaseChannel::Stable, "0.9.0", "v0.9.0-beta.1").unwrap()); + assert!( + !update_is_needed(ReleaseChannel::Stable, "0.9.0-beta.2", "v0.9.0-beta.1").unwrap() + ); + } + + #[test] + fn beta_update_allows_switching_from_same_stable_to_beta() { + assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0", "v1.0.0-beta.2").unwrap()); + assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.2").unwrap()); + assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.3", "v1.0.0-beta.2").unwrap()); + assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.3").unwrap()); + assert!(!update_is_needed(ReleaseChannel::Beta, "2.0.0", "v1.0.0-beta.3").unwrap()); + assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-rc.1", "v1.0.0-beta.3").unwrap()); + } + + #[test] + fn parse_release_version_accepts_tags_and_build_suffixes() { + assert_eq!( + parse_release_version("v0.9.0-beta.1").unwrap(), + semver::Version::parse("0.9.0-beta.1").unwrap() + ); + assert_eq!( + parse_release_version("0.8.45 (abcdef123456)").unwrap(), + semver::Version::parse("0.8.45").unwrap() + ); + } + + #[test] + fn release_version_compare_ignores_v_prefix_and_build_sha() { + assert_eq!( + compare_release_versions("0.8.39 (eeccf7d)", "v0.8.39").unwrap(), + std::cmp::Ordering::Equal + ); + assert_eq!( + compare_release_versions("0.8.39", "v0.8.40").unwrap(), + std::cmp::Ordering::Less + ); + assert_eq!( + compare_release_versions("0.8.40", "v0.8.39").unwrap(), + std::cmp::Ordering::Greater + ); + } + + #[test] + fn latest_beta_tag_selects_first_beta_release() { + let body = r#"[ + { "tag_name": "v0.9.0" }, + { "tag_name": "v0.9.0-rc.1" }, + { "tag_name": "v0.9.0-beta.2" }, + { "tag_name": "v0.9.0-beta.1" } + ]"#; + assert_eq!( + latest_beta_tag_from_release_list_json(body).unwrap(), + "v0.9.0-beta.2" + ); + } + + #[test] + fn latest_beta_tag_reports_missing_beta() { + let body = r#"[{ "tag_name": "v0.9.0" }]"#; + let err = latest_beta_tag_from_release_list_json(body).expect_err("missing beta"); + assert!( + err.to_string().contains("no beta release found"), + "unexpected error: {err:#}" + ); + } +} diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs index f2616391..e3020244 100644 --- a/crates/secrets/src/lib.rs +++ b/crates/secrets/src/lib.rs @@ -484,9 +484,7 @@ impl Secrets { /// Resolve a secret with `secret store → env → none` precedence. /// - /// `name` is the canonical provider name (`"deepseek"`, - /// `"openrouter"`, `"novita"`, `"nvidia"`/`"nvidia-nim"`, `"openai"`, - /// or `"atlascloud"`). + /// `name` is the canonical provider name or a supported provider alias. /// Empty strings on either layer are treated as "not set". #[must_use] pub fn resolve(&self, name: &str) -> Option { @@ -527,6 +525,9 @@ pub fn env_for(name: &str) -> Option { let candidates: &[&str] = match name.to_ascii_lowercase().as_str() { "deepseek" => &["DEEPSEEK_API_KEY"], "openrouter" => &["OPENROUTER_API_KEY"], + "xiaomi-mimo" | "xiaomi_mimo" | "xiaomimimo" | "mimo" | "xiaomi" => { + &["XIAOMI_MIMO_API_KEY", "MIMO_API_KEY"] + } "novita" => &["NOVITA_API_KEY"], // NVIDIA NIM falls back to `DEEPSEEK_API_KEY` last because the // catalog endpoint accepts the same DeepSeek-issued key when no @@ -535,11 +536,18 @@ pub fn env_for(name: &str) -> Option { &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"] } "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"], + "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"], "sglang" | "sg-lang" => &["SGLANG_API_KEY"], "vllm" | "v-llm" => &["VLLM_API_KEY"], "ollama" | "ollama-local" => &["OLLAMA_API_KEY"], "openai" => &["OPENAI_API_KEY"], "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => &["ATLASCLOUD_API_KEY"], + "volcengine" | "volcengine-ark" | "volcengine_ark" | "ark" | "volc-ark" + | "volcengineark" => &[ + "VOLCENGINE_API_KEY", + "VOLCENGINE_ARK_API_KEY", + "ARK_API_KEY", + ], "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark" | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => &[ "WANJIE_ARK_API_KEY", @@ -588,6 +596,8 @@ mod tests { "WANJIE_ARK_API_KEY", "WANJIE_API_KEY", "WANJIE_MAAS_API_KEY", + "XIAOMI_MIMO_API_KEY", + "MIMO_API_KEY", SECRET_BACKEND_ENV, ] { // Safety: tests serialise on env_lock(); the broader @@ -765,6 +775,20 @@ mod tests { clear_known_envs(); } + #[test] + fn xiaomi_mimo_env_aliases_resolve() { + let _guard = env_lock(); + clear_known_envs(); + unsafe { std::env::set_var("MIMO_API_KEY", "mimo-key") }; + + assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("mimo-key")); + assert_eq!(env_for("xiaomimimo").as_deref(), Some("mimo-key")); + assert_eq!(env_for("mimo").as_deref(), Some("mimo-key")); + assert_eq!(env_for("xiaomi").as_deref(), Some("mimo-key")); + + clear_known_envs(); + } + #[test] fn fireworks_env_aliases_resolve() { let _lock = env_lock(); @@ -778,6 +802,21 @@ mod tests { unsafe { std::env::remove_var("FIREWORKS_API_KEY") }; } + #[test] + fn moonshot_kimi_env_aliases_resolve() { + let _lock = env_lock(); + clear_known_envs(); + // Safety: env mutation guarded by env_lock(). + unsafe { std::env::set_var("KIMI_API_KEY", "kimi-key") }; + + assert_eq!(env_for("moonshot").as_deref(), Some("kimi-key")); + assert_eq!(env_for("moonshot-ai").as_deref(), Some("kimi-key")); + assert_eq!(env_for("kimi").as_deref(), Some("kimi-key")); + assert_eq!(env_for("kimi-k2").as_deref(), Some("kimi-key")); + // Safety: env mutation guarded by env_lock(). + unsafe { std::env::remove_var("KIMI_API_KEY") }; + } + #[test] fn sglang_env_aliases_resolve() { let _lock = env_lock(); diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 9bad8a16..7d4eace8 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -53,6 +53,7 @@ pub struct ThreadMetadata { pub git_branch: Option, pub git_origin_url: Option, pub memory_mode: Option, + pub current_leaf_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -71,6 +72,7 @@ pub struct MessageRecord { pub content: String, pub item: Option, pub created_at: i64, + pub parent_entry_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -162,82 +164,113 @@ impl StateStore { fn init_schema(&self) -> Result<()> { let conn = self.conn()?; - conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS threads ( - id TEXT PRIMARY KEY, - rollout_path TEXT, - preview TEXT NOT NULL, - ephemeral INTEGER NOT NULL, - model_provider TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - status TEXT NOT NULL, - path TEXT, - cwd TEXT NOT NULL, - cli_version TEXT NOT NULL, - source TEXT NOT NULL, - title TEXT, - sandbox_policy TEXT, - approval_mode TEXT, - archived INTEGER NOT NULL DEFAULT 0, - archived_at INTEGER, - git_sha TEXT, - git_branch TEXT, - git_origin_url TEXT, - memory_mode TEXT - ); - CREATE INDEX IF NOT EXISTS idx_threads_updated_at ON threads(updated_at DESC); - CREATE INDEX IF NOT EXISTS idx_threads_archived_at ON threads(archived_at DESC); - CREATE INDEX IF NOT EXISTS idx_threads_archived_updated ON threads(archived, updated_at DESC); + let user_version: u32 = conn.query_row("PRAGMA user_version;", [], |row| row.get(0))?; + if user_version == 0 { + conn.execute_batch( + r#" + BEGIN; + CREATE TABLE IF NOT EXISTS threads ( + id TEXT PRIMARY KEY, + rollout_path TEXT, + preview TEXT NOT NULL, + ephemeral INTEGER NOT NULL, + model_provider TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + status TEXT NOT NULL, + path TEXT, + cwd TEXT NOT NULL, + cli_version TEXT NOT NULL, + source TEXT NOT NULL, + title TEXT, + sandbox_policy TEXT, + approval_mode TEXT, + archived INTEGER NOT NULL DEFAULT 0, + archived_at INTEGER, + git_sha TEXT, + git_branch TEXT, + git_origin_url TEXT, + memory_mode TEXT + ); + CREATE INDEX IF NOT EXISTS idx_threads_updated_at ON threads(updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_threads_archived_at ON threads(archived_at DESC); + CREATE INDEX IF NOT EXISTS idx_threads_archived_updated ON threads(archived, updated_at DESC); - CREATE TABLE IF NOT EXISTS thread_dynamic_tools ( - thread_id TEXT NOT NULL, - position INTEGER NOT NULL, - name TEXT NOT NULL, - description TEXT, - input_schema TEXT NOT NULL, - PRIMARY KEY (thread_id, position), - FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE - ); + CREATE TABLE IF NOT EXISTS thread_dynamic_tools ( + thread_id TEXT NOT NULL, + position INTEGER NOT NULL, + name TEXT NOT NULL, + description TEXT, + input_schema TEXT NOT NULL, + PRIMARY KEY (thread_id, position), + FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE + ); - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - thread_id TEXT NOT NULL, - role TEXT NOT NULL, - content TEXT NOT NULL, - item_json TEXT, - created_at INTEGER NOT NULL, - FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_messages_thread_created_at ON messages(thread_id, created_at ASC); + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + item_json TEXT, + created_at INTEGER NOT NULL, + FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_messages_thread_created_at ON messages(thread_id, created_at ASC); - CREATE TABLE IF NOT EXISTS checkpoints ( - thread_id TEXT NOT NULL, - checkpoint_id TEXT NOT NULL, - state_json TEXT NOT NULL, - created_at INTEGER NOT NULL, - PRIMARY KEY(thread_id, checkpoint_id), - FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_checkpoints_thread_created_at ON checkpoints(thread_id, created_at DESC); + CREATE TABLE IF NOT EXISTS checkpoints ( + thread_id TEXT NOT NULL, + checkpoint_id TEXT NOT NULL, + state_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY(thread_id, checkpoint_id), + FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_checkpoints_thread_created_at ON checkpoints(thread_id, created_at DESC); - CREATE TABLE IF NOT EXISTS jobs ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - status TEXT NOT NULL, - progress INTEGER, - detail TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_jobs_updated_at ON jobs(updated_at DESC); - "#, - ) - .context("failed to initialize thread schema")?; + CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + status TEXT NOT NULL, + progress INTEGER, + detail TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_jobs_updated_at ON jobs(updated_at DESC); + + -- Add parent_entry_id column, and set to last message before current message + ALTER TABLE messages ADD COLUMN parent_entry_id INTEGER NULL; + UPDATE messages + SET parent_entry_id = ( + SELECT m2.id + FROM messages m2 + WHERE m2.created_at < messages.created_at AND m2.thread_id = messages.thread_id + ORDER BY m2.id DESC + LIMIT 1 + ); + CREATE INDEX idx_messages_parent_entry_id ON messages(parent_entry_id); + + -- Add current_leaf_id column, and set to last message in thread + ALTER TABLE threads ADD COLUMN current_leaf_id INTEGER NULL; + UPDATE threads + SET current_leaf_id = ( + SELECT m.id + FROM messages m + WHERE m.thread_id = threads.id + ORDER BY m.id DESC + LIMIT 1 + ); + + PRAGMA user_version = 1; + COMMIT; + "#, + ) + .context("failed to initialize thread schema")?; + } Ok(()) } + /// Upsert thread metadata(will not set current_leaf_id) pub fn upsert_thread(&self, thread: &ThreadMetadata) -> Result<()> { let conn = self.conn()?; conn.execute( @@ -314,7 +347,7 @@ impl StateStore { r#" SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, - git_sha, git_branch, git_origin_url, memory_mode + git_sha, git_branch, git_origin_url, memory_mode, current_leaf_id FROM threads WHERE id = ?1 "#, @@ -328,9 +361,9 @@ impl StateStore { pub fn list_threads(&self, filters: ThreadListFilters) -> Result> { let conn = self.conn()?; let sql = if filters.include_archived { - "SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode FROM threads ORDER BY updated_at DESC LIMIT ?1" + "SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode, current_leaf_id FROM threads ORDER BY updated_at DESC LIMIT ?1" } else { - "SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode FROM threads WHERE archived = 0 ORDER BY updated_at DESC LIMIT ?1" + "SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode, current_leaf_id FROM threads WHERE archived = 0 ORDER BY updated_at DESC LIMIT ?1" }; let mut stmt = conn.prepare(sql).context("failed to prepare list query")?; @@ -398,6 +431,54 @@ impl StateStore { .map(Option::flatten) } + pub fn list_leaf_messages(&self, thread_id: &str) -> Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare( + r#" + SELECT m1.id, m1.thread_id, m1.role, m1.content, m1.item_json, m1.created_at, m1.parent_entry_id + FROM messages m1 + LEFT JOIN messages m2 ON m1.id = m2.parent_entry_id + WHERE m1.thread_id = ?1 AND m2.id IS NULL + "#, + ) + .context("failed to prepare message listing query")?; + let mut rows = stmt + .query(params![thread_id]) + .with_context(|| format!("failed to list leaf messages for thread {thread_id}"))?; + let mut out = Vec::new(); + while let Some(row) = rows.next().context("failed to iterate message rows")? { + let item_json: Option = row.get(4).context("failed to read item json")?; + let item = item_json + .as_deref() + .map(serde_json::from_str) + .transpose() + .with_context(|| { + format!("failed to parse message item json in thread {thread_id}") + })?; + out.push(MessageRecord { + id: row.get(0).context("failed to read message id")?, + thread_id: row.get(1).context("failed to read message thread id")?, + role: row.get(2).context("failed to read message role")?, + content: row.get(3).context("failed to read message content")?, + item, + created_at: row.get(5).context("failed to read message timestamp")?, + parent_entry_id: row.get(6).context("failed to read parent entry id")?, + }); + } + Ok(out) + } + + pub fn set_current_leaf_id(&self, thread_id: &str, current_leaf_id: &str) -> Result<()> { + let conn = self.conn()?; + conn.execute( + "UPDATE threads SET current_leaf_id = ?1 WHERE id = ?2", + params![current_leaf_id, thread_id], + ) + .context("failed to update thread current leaf id")?; + Ok(()) + } + pub fn persist_dynamic_tools( &self, thread_id: &str, @@ -464,19 +545,52 @@ impl StateStore { content: &str, item: Option, ) -> Result { - let conn = self.conn()?; + let mut conn = self.conn()?; let created_at = Utc::now().timestamp(); let item_json = item .as_ref() .map(serde_json::to_string) .transpose() .context("failed to serialize message item payload")?; - conn.execute( - "INSERT INTO messages(thread_id, role, content, item_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", - params![thread_id, role, content, item_json, created_at], + + let tx = conn + .transaction() + .context("failed to begin append message transaction")?; + + let current_leaf_id: Option = tx + .query_row( + "SELECT current_leaf_id FROM threads WHERE id = ?1", + params![thread_id], + |row| row.get(0), + ) + .with_context(|| { + format!("failed to query thread current leaf id for thread {thread_id}") + })?; + + let next_leaf_id: i64 = tx.query_row( + r#" + INSERT INTO messages(thread_id, role, content, item_json, created_at, parent_entry_id) + SELECT ?1, ?2, ?3, ?4, ?5, ?6 + RETURNING id + "#, params![thread_id, role, content, item_json, created_at, current_leaf_id], |row| row.get(0) + ).with_context(|| format!("failed to append message for thread {thread_id}"))?; + + tx.execute( + r#" + UPDATE threads + SET current_leaf_id = ?1 + WHERE id = ?2; + "#, + params![next_leaf_id, thread_id], ) - .with_context(|| format!("failed to append message for thread {thread_id}"))?; - Ok(conn.last_insert_rowid()) + .with_context(|| { + format!("failed to update thread current leaf id for thread {thread_id}") + })?; + + tx.commit() + .context("failed to commit append message transaction")?; + + Ok(next_leaf_id) } pub fn list_messages( @@ -488,11 +602,30 @@ impl StateStore { let limit = i64::try_from(limit.unwrap_or(500)).unwrap_or(500); let mut stmt = conn .prepare( - "SELECT id, thread_id, role, content, item_json, created_at FROM messages WHERE thread_id = ?1 ORDER BY created_at ASC LIMIT ?2", + r#" + WITH RECURSIVE + leaf_id AS ( + SELECT current_leaf_id FROM threads WHERE id = ?1 + ), + ancestors AS ( + SELECT id, thread_id, role, content, item_json, created_at, parent_entry_id, 0 AS depth + FROM messages + WHERE id = (SELECT current_leaf_id FROM leaf_id) + + UNION ALL + + SELECT m.id, m.thread_id, m.role, m.content, m.item_json, m.created_at, m.parent_entry_id, a.depth + 1 + FROM messages m + JOIN ancestors a ON m.id = a.parent_entry_id + WHERE a.depth < ?2 + ) + SELECT id, thread_id, role, content, item_json, created_at, parent_entry_id FROM ancestors + ORDER BY depth DESC + "# ) .context("failed to prepare message listing query")?; let mut rows = stmt - .query(params![thread_id, limit]) + .query(params![thread_id, limit - 1]) .with_context(|| format!("failed to list messages for thread {thread_id}"))?; let mut out = Vec::new(); while let Some(row) = rows.next().context("failed to iterate message rows")? { @@ -511,18 +644,95 @@ impl StateStore { content: row.get(3).context("failed to read message content")?, item, created_at: row.get(5).context("failed to read message timestamp")?, + parent_entry_id: row.get(6).context("failed to read parent entry id")?, }); } Ok(out) } + pub fn fork_at_message( + &self, + message_id: &str, + role: &str, + content: &str, + item: Option, + ) -> Result { + let mut conn = self.conn()?; + let created_at = Utc::now().timestamp(); + let item_json = item + .as_ref() + .map(serde_json::to_string) + .transpose() + .context("failed to serialize message item payload")?; + + let tx = conn + .transaction() + .context("failed to begin fork message transaction")?; + + let thread_id: String = tx + .query_row( + "SELECT thread_id FROM messages WHERE id = ?1", + params![message_id], + |row| row.get(0), + ) + .with_context(|| format!("failed to query thread id for message {message_id}"))?; + + let next_leaf_id: i64 = tx.query_row( + r#" + INSERT INTO messages(thread_id, role, content, item_json, created_at, parent_entry_id) + SELECT ?1, ?2, ?3, ?4, ?5, ?6 + RETURNING id + "#, params![thread_id, role, content, item_json, created_at, message_id], |row| row.get(0) + ).with_context(|| format!("failed to fork at message for thread {:?}", thread_id))?; + + tx.execute( + r#" + UPDATE threads + SET current_leaf_id = ?1 + WHERE id = ?2; + "#, + params![next_leaf_id, thread_id], + ) + .with_context(|| { + format!( + "failed to update thread current leaf id for thread {:?}", + thread_id + ) + })?; + + tx.commit() + .context("failed to commit fork message transaction")?; + + Ok(next_leaf_id) + } + pub fn clear_messages(&self, thread_id: &str) -> Result { - let conn = self.conn()?; - conn.execute( - "DELETE FROM messages WHERE thread_id = ?1", + let mut conn = self.conn()?; + let tx = conn + .transaction() + .context("failed to begin clear messages transaction")?; + + tx.execute( + r#" + UPDATE threads + SET current_leaf_id = NULL + WHERE id = ?1; + "#, params![thread_id], ) - .with_context(|| format!("failed to clear messages for thread {thread_id}")) + .with_context(|| format!("failed to clear messages for thread {thread_id}"))?; + let result = tx + .execute( + r#" + DELETE FROM messages WHERE thread_id = ?1 + "#, + params![thread_id], + ) + .with_context(|| format!("failed to clear messages for thread {thread_id}"))?; + tx.commit() + .context("failed to commit clear messages transaction")?; + + Ok(result) } pub fn save_checkpoint( @@ -946,5 +1156,6 @@ fn row_to_thread(row: &rusqlite::Row<'_>) -> rusqlite::Result { git_branch: row.get(18)?, git_origin_url: row.get(19)?, memory_mode: row.get(20)?, + current_leaf_id: row.get(21)?, }) } diff --git a/crates/state/tests/parity_state.rs b/crates/state/tests/parity_state.rs index d666f50b..70bbe661 100644 --- a/crates/state/tests/parity_state.rs +++ b/crates/state/tests/parity_state.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use codewhale_state::{SessionSource, StateStore, ThreadListFilters, ThreadMetadata, ThreadStatus}; +use rusqlite::Connection; fn temp_state_path(label: &str) -> PathBuf { std::env::temp_dir().join(format!( @@ -38,6 +39,7 @@ fn upsert_and_resume_thread_metadata() { git_branch: None, git_origin_url: None, memory_mode: Some("extended".to_string()), + current_leaf_id: None, }; store.upsert_thread(&thread).expect("upsert thread"); @@ -70,3 +72,212 @@ fn upsert_and_resume_thread_metadata() { .expect("list threads"); assert!(!listed.is_empty()); } + +#[test] +fn init_schema_migration() { + let path = temp_state_path("init_schema_migration"); + let conn = Connection::open(&path).expect("open state db"); + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS threads ( + id TEXT PRIMARY KEY, + rollout_path TEXT, + preview TEXT NOT NULL, + ephemeral INTEGER NOT NULL, + model_provider TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + status TEXT NOT NULL, + path TEXT, + cwd TEXT NOT NULL, + cli_version TEXT NOT NULL, + source TEXT NOT NULL, + title TEXT, + sandbox_policy TEXT, + approval_mode TEXT, + archived INTEGER NOT NULL DEFAULT 0, + archived_at INTEGER, + git_sha TEXT, + git_branch TEXT, + git_origin_url TEXT, + memory_mode TEXT + ); + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + item_json TEXT, + created_at INTEGER NOT NULL, + FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE + ); + INSERT INTO threads ( + id, preview, ephemeral, model_provider, created_at, updated_at, status, cwd, cli_version, source, archived + ) + VALUES ( + 'thread-test-1', 'hello', false, 'deepseek', 0, 0, 'running', '/tmp/project', '0.0.0-test', 'interactive', false + ); + INSERT INTO messages (thread_id, role, content, created_at) VALUES + ('thread-test-1', 'foo0', 'bar0', 0), + ('thread-test-1', 'foo1', 'bar1', 1), + ('thread-test-1', 'foo2', 'bar2', 2); + "#, + ) + .expect("init schema migration"); + + let store = StateStore::open(Some(path.clone())).expect("open state store"); + let thread = store + .get_thread("thread-test-1") + .expect("read thread") + .unwrap(); + assert_eq!(thread.id, "thread-test-1"); + assert_eq!(thread.preview, "hello"); + assert!(!thread.ephemeral); + assert_eq!(thread.model_provider, "deepseek"); + assert_eq!(thread.created_at, 0); + assert_eq!(thread.updated_at, 0); + assert_eq!(thread.status, ThreadStatus::Running); + assert_eq!(thread.cwd, PathBuf::from("/tmp/project")); + assert_eq!(thread.cli_version, "0.0.0-test"); + assert_eq!(thread.source, SessionSource::Interactive); + assert!(thread.current_leaf_id.is_some()); + + let messages = store + .list_messages("thread-test-1", None) + .expect("list messages"); + assert_eq!(messages.len(), 3); + for (i, message) in messages.iter().enumerate() { + assert_eq!(message.thread_id, "thread-test-1"); + assert_eq!(message.role, format!("foo{}", i)); + assert_eq!(message.content, format!("bar{}", i)); + assert_eq!(message.created_at, i as i64); + } + + // Test idempotent + StateStore::open(Some(path.clone())).expect("open state store"); +} + +#[test] +fn test_fork() { + let path = temp_state_path("test_fork"); + let store = StateStore::open(Some(path.clone())).expect("open state store"); + let now = chrono::Utc::now().timestamp(); + let thread = ThreadMetadata { + id: "thread-test-1".to_string(), + rollout_path: Some(PathBuf::from("/tmp/rollout.jsonl")), + preview: "hello".to_string(), + ephemeral: false, + model_provider: "deepseek".to_string(), + created_at: now, + updated_at: now, + status: ThreadStatus::Running, + path: Some(PathBuf::from("/tmp/project")), + cwd: PathBuf::from("/tmp/project"), + cli_version: "0.0.0-test".to_string(), + source: SessionSource::Interactive, + name: Some("Test Thread".to_string()), + sandbox_policy: Some("workspace-write".to_string()), + approval_mode: Some("on-request".to_string()), + archived: false, + archived_at: None, + git_sha: None, + git_branch: None, + git_origin_url: None, + memory_mode: Some("extended".to_string()), + current_leaf_id: None, + }; + + store.upsert_thread(&thread).expect("upsert thread"); + store + .append_message("thread-test-1", "foo0", "bar0", None) + .expect("append message"); + store + .append_message("thread-test-1", "foo1", "bar1", None) + .expect("append message"); + store + .append_message("thread-test-1", "foo2", "bar2", None) + .expect("append message"); + store + .append_message("thread-test-1", "foo3", "bar3", None) + .expect("append message"); + store + .append_message("thread-test-1", "foo4", "bar4", None) + .expect("append message"); + + let messages = store + .list_messages("thread-test-1", None) + .expect("list messages"); + assert_eq!(messages.len(), 5); + let ids = messages + .iter() + .enumerate() + .map(|(i, message)| { + assert_eq!(message.thread_id, "thread-test-1"); + assert_eq!(message.role, format!("foo{}", i)); + assert_eq!(message.content, format!("bar{}", i)); + message.id.to_string() + }) + .collect::>(); + + store.upsert_thread(&thread).expect("upsert thread"); + + store + .fork_at_message(&ids[2], "foo5", "bar5", None) + .expect("fork at message"); + let messages = store + .list_messages("thread-test-1", None) + .expect("list messages"); + assert_eq!(messages.len(), 4); + const LIST_1: [i64; 4] = [0, 1, 2, 5]; + messages + .iter() + .zip(LIST_1.iter()) + .for_each(|(message, &i)| { + assert_eq!(message.thread_id, "thread-test-1"); + assert_eq!(message.role, format!("foo{}", i)); + assert_eq!(message.content, format!("bar{}", i)); + }); + let leaves = store + .list_leaf_messages("thread-test-1") + .expect("list leaf messages"); + assert_eq!(leaves.len(), 2); + + store + .set_current_leaf_id("thread-test-1", &ids[4]) + .expect("set current leaf id"); + store + .append_message("thread-test-1", "foo6", "bar6", None) + .expect("append message"); + let messages = store + .list_messages("thread-test-1", None) + .expect("list messages"); + assert_eq!(messages.len(), 6); + const LIST_2: [i64; 6] = [0, 1, 2, 3, 4, 6]; + messages + .iter() + .zip(LIST_2.iter()) + .for_each(|(message, &i)| { + assert_eq!(message.thread_id, "thread-test-1"); + assert_eq!(message.role, format!("foo{}", i)); + assert_eq!(message.content, format!("bar{}", i)); + }); + + let leaves = store + .list_leaf_messages("thread-test-1") + .expect("list leaf messages"); + assert_eq!(leaves.len(), 2); + + store + .clear_messages("thread-test-1") + .expect("clear messages"); + let leaves = store + .list_leaf_messages("thread-test-1") + .expect("list leaf messages"); + assert_eq!(leaves.len(), 0); + let thread = store + .get_thread("thread-test-1") + .expect("get thread") + .unwrap(); + dbg!(&thread); + assert!(thread.current_leaf_id.is_none()); +} diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 9059c344..2be5cc0d 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral [dependencies] anyhow.workspace = true async-trait.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.8.44" } +codewhale-protocol = { path = "../protocol", version = "0.8.46" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index a7179410..b0ffc55b 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -8,7 +8,11 @@ use async_trait::async_trait; use codewhale_protocol::{ToolKind, ToolOutput, ToolPayload}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use tokio::sync::RwLock; +use tokio::sync::{OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock}; + +tokio::task_local! { + static TOOL_EXECUTION_LOCK_HELD: (); +} /// Capabilities that a tool may have or require. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -309,9 +313,40 @@ pub trait ToolHandler: Send + Sync { ) -> std::result::Result; } -#[derive(Debug, Default)] +#[derive(Debug)] pub struct ToolCallRuntime { - pub parallel_execution: Arc>, + /// Preserve read/write tool execution semantics: parallel-safe tools may + /// overlap, while serial tools run exclusively. + execution_lock: Arc>, +} + +impl Default for ToolCallRuntime { + fn default() -> Self { + Self { + execution_lock: Arc::new(RwLock::new(())), + } + } +} + +#[derive(Debug)] +enum ToolExecutionGuard { + Parallel(#[allow(dead_code)] OwnedRwLockReadGuard<()>), + Serial(#[allow(dead_code)] OwnedRwLockWriteGuard<()>), + Reentrant, +} + +impl ToolCallRuntime { + async fn acquire(&self, supports_parallel: bool) -> ToolExecutionGuard { + if TOOL_EXECUTION_LOCK_HELD.try_with(|_| ()).is_ok() { + return ToolExecutionGuard::Reentrant; + } + + if supports_parallel { + ToolExecutionGuard::Parallel(self.execution_lock.clone().read_owned().await) + } else { + ToolExecutionGuard::Serial(self.execution_lock.clone().write_owned().await) + } + } } #[derive(Default)] @@ -379,15 +414,17 @@ impl ToolRegistry { source: call.source, }; - if configured.supports_parallel_tool_calls { - let _guard = self.runtime.parallel_execution.read().await; - self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation) - .await - } else { - let _guard = self.runtime.parallel_execution.write().await; - self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation) - .await - } + let _guard = self + .runtime + .acquire(configured.supports_parallel_tool_calls) + .await; + + TOOL_EXECUTION_LOCK_HELD + .scope( + (), + self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation), + ) + .await } async fn execute_with_timeout( diff --git a/crates/tools/tests/parity_tools.rs b/crates/tools/tests/parity_tools.rs index fb08753b..ef525ba4 100644 --- a/crates/tools/tests/parity_tools.rs +++ b/crates/tools/tests/parity_tools.rs @@ -1,4 +1,5 @@ -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; use async_trait::async_trait; use codewhale_protocol::{ToolKind, ToolOutput, ToolPayload}; @@ -6,6 +7,7 @@ use codewhale_tools::{ ToolCall, ToolCallSource, ToolHandler, ToolInvocation, ToolRegistry, ToolSpec, }; use serde_json::json; +use tokio::sync::Notify; struct EchoHandler; @@ -33,6 +35,64 @@ impl ToolHandler for EchoHandler { } } +struct BlockingHandler { + started: Arc, + release: Arc, +} + +#[async_trait] +impl ToolHandler for BlockingHandler { + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle( + &self, + invocation: ToolInvocation, + ) -> std::result::Result { + self.started.notify_waiters(); + self.release.notified().await; + Ok(ToolOutput::Function { + body: Some(json!({ + "tool": invocation.tool_name, + "call_id": invocation.call_id + })), + success: true, + }) + } +} + +struct ReentrantHandler { + registry: Arc>>, +} + +#[async_trait] +impl ToolHandler for ReentrantHandler { + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle( + &self, + _invocation: ToolInvocation, + ) -> std::result::Result { + let registry = self.registry.get().expect("registry initialized").clone(); + registry + .dispatch( + ToolCall { + name: "inner".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + source: ToolCallSource::Direct, + raw_tool_call_id: Some("inner-call".to_string()), + }, + true, + ) + .await + } +} + #[tokio::test] async fn dispatches_function_tool_with_parallel_flag() { let mut registry = ToolRegistry::default(); @@ -68,3 +128,149 @@ async fn dispatches_function_tool_with_parallel_flag() { other => panic!("unexpected output: {other:?}"), } } + +#[tokio::test] +async fn serial_tool_waits_for_running_parallel_tool() { + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let mut registry = ToolRegistry::default(); + registry + .register( + ToolSpec { + name: "slow_read".to_string(), + input_schema: json!({"type":"object"}), + output_schema: json!({"type":"object"}), + supports_parallel_tool_calls: true, + timeout_ms: Some(1000), + }, + Arc::new(BlockingHandler { + started: started.clone(), + release: release.clone(), + }), + ) + .expect("register slow read"); + registry + .register( + ToolSpec { + name: "serial".to_string(), + input_schema: json!({"type":"object"}), + output_schema: json!({"type":"object"}), + supports_parallel_tool_calls: false, + timeout_ms: Some(1000), + }, + Arc::new(EchoHandler), + ) + .expect("register serial"); + + let registry = Arc::new(registry); + let started_wait = started.notified(); + let parallel_registry = registry.clone(); + let parallel = tokio::spawn(async move { + parallel_registry + .dispatch( + ToolCall { + name: "slow_read".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + source: ToolCallSource::Direct, + raw_tool_call_id: Some("parallel-call".to_string()), + }, + true, + ) + .await + }); + tokio::time::timeout(Duration::from_secs(1), started_wait) + .await + .expect("parallel tool started"); + + let serial_registry = registry.clone(); + let mut serial = tokio::spawn(async move { + serial_registry + .dispatch( + ToolCall { + name: "serial".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + source: ToolCallSource::Direct, + raw_tool_call_id: Some("serial-call".to_string()), + }, + true, + ) + .await + }); + + tokio::select! { + _ = &mut serial => panic!("serial tool overlapped a running parallel tool"), + () = tokio::time::sleep(Duration::from_millis(50)) => {} + } + + release.notify_waiters(); + serial + .await + .expect("serial task panicked") + .expect("serial ran"); + parallel + .await + .expect("parallel task panicked") + .expect("parallel ran"); +} + +#[tokio::test] +async fn serial_tool_can_reenter_registry_without_deadlock() { + let registry_cell = Arc::new(OnceLock::new()); + let mut registry = ToolRegistry::default(); + registry + .register( + ToolSpec { + name: "outer".to_string(), + input_schema: json!({"type":"object"}), + output_schema: json!({"type":"object"}), + supports_parallel_tool_calls: false, + timeout_ms: Some(1000), + }, + Arc::new(ReentrantHandler { + registry: registry_cell.clone(), + }), + ) + .expect("register outer"); + registry + .register( + ToolSpec { + name: "inner".to_string(), + input_schema: json!({"type":"object"}), + output_schema: json!({"type":"object"}), + supports_parallel_tool_calls: false, + timeout_ms: Some(1000), + }, + Arc::new(EchoHandler), + ) + .expect("register inner"); + + let registry = Arc::new(registry); + assert!(registry_cell.set(registry.clone()).is_ok()); + + let output = tokio::time::timeout( + Duration::from_secs(1), + registry.dispatch( + ToolCall { + name: "outer".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + source: ToolCallSource::Direct, + raw_tool_call_id: Some("outer-call".to_string()), + }, + true, + ), + ) + .await + .expect("outer dispatch timed out") + .expect("outer dispatch failed"); + + match output { + ToolOutput::Function { success, .. } => assert!(success), + other => panic!("unexpected output: {other:?}"), + } +} diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index f6fda0e4..924e4f2f 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,218 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Composer text selection with copy/cut.** Mouse drag and Shift+Arrow + selection in the composer input box, with Ctrl+C copy and Ctrl+X cut + support. Home, End, Ctrl+A, and Ctrl+E now clear the selection (#2228). +- **Copy transcript without visual-wrap newlines.** Transcript copy now + strips visual-wrap column line breaks from paragraphs, producing clean + text for pasting into editors or prompts (#1906). +- **Configurable base URL in /config view.** The `/config` panel now + displays the effective DeepSeek base URL (#1967). +- **CNB mirror support for China-friendly downloads.** Added + `CODEWHALE_RELEASE_BASE_URL` and `CODEWHALE_USE_CNB_MIRROR` to + both npm install scripts and Rust self-updater (#2222). +- **[✓] completion markers.** Checklist, plan, and tool completion + markers now render as `[✓]` instead of `[x]` (#1935). + +### Changed + +- **Project context loading now logs the source file.** (#2227) +- **macOS onboarding and empty-state layout pinned to top** instead + of vertically centered (#1837). +- **State-root migration continues.** Migrated 15+ storage paths to + prefer `~/.codewhale` with `~/.deepseek` fallback (#2231). +- **READMEs updated for the CodeWhale rename.** All three READMEs now + reference canonical `~/.codewhale` paths. + +### Fixed + +- **Deadlock when spawning multiple concurrent sub-agents.** Replaced + `RwLock`-based serialisation with a `Semaphore(1)` (#1856). +- **Steered/queued messages now render in correct transcript order.** + `steer_user_message` now flushes the active cell before inserting (#2225). +- **Session save test updated for managed sessions directory.** (#2223). +- **Loop guard reports Failed on halt.** Turn outcome correctly reports + `Failed` instead of `Completed` when the loop guard trips (#1859). +- **DEEPSEEK_YOLO env honoured on startup.** The `--yolo` flag is now + correctly merged with the `DEEPSEEK_YOLO` environment variable (#1870). + +### Community + +Thanks to contributors whose PRs landed in this release: +**@Fire-dtx** (#1856), +**@imkingjh999** (#2228), +**@harvey2011888** (#1859), +**@victorcheng2333** (#1870), +**@IIzzaya** (#1935), +**@PurplePulse** (#1837), +**@cyq1017** (#1967), +**@knqiufan** (#1906). + +## [0.8.46] - 2026-05-26 + +### Added + +- **`CODEWHALE_*` env aliases.** `CODEWHALE_PROVIDER`, `CODEWHALE_MODEL`, + and `CODEWHALE_BASE_URL` are public product-scoped aliases that take + precedence over the legacy `DEEPSEEK_*` forms. The `DEEPSEEK_*` names + remain accepted for back-compat. +- **Platform archive bundles.** Release artifacts now ship as per-platform + archives (`tar.gz` for Linux/macOS, `.zip` for Windows) containing both + `codewhale` and `codewhale-tui` binaries plus an install script. No more + downloading two loose files and guessing which ones to pick (#2193). +- **Windows portable archive.** `codewhale-windows-x64-portable.zip` ships + the two binaries without an install script for USB-stick distribution + (#2193). +- **Web install download tile.** The website install page now shows a + platform-aware download tile with arch detection, SHA256 checksum + display, and China mirror links, instead of burying the download behind + the Cargo instructions (#2192). +- **Whale dark palette refresh.** Better contrast and layer separation + across the TUI color scheme (#2197). +- **Auto-collapse finished sub-agents.** Completed sub-agent sessions now + collapse automatically in the sidebar, reducing noise during long + sessions (#2195). +- **Shell-running status chip.** A `⏳ shell running` chip appears in the + TUI footer while background shell tasks are active (#2194). +- **Sandbox process hardening (Linux).** `PR_SET_DUMPABLE=0`, + `NO_NEW_PRIVS`, and `RLIMIT_CORE=0` are applied at shell startup to + harden child processes against inspection and privilege escalation + (#2183). +- **CONTRIBUTING.md cross-links.** Issue and PR templates are now + cross-linked from CONTRIBUTING.md to improve contributor onboarding + (#2203). + +### Changed + +- **DeepSeek-first focus.** v0.8.46 refocuses on delivering the + highest-quality experience on DeepSeek first. Additional first-class + provider paths are planned for v0.9.0 after the core DeepSeek workflow + is solid. + +### Fixed + +- **Model name casing preserved.** `normalize_model_name_for_provider` no + longer lowercases user-set model names such as `DeepSeek-V4-Flash`, + preventing API lookup failures on case-sensitive backends (#2109). +- **Esc in model picker applies selection.** Dismissing the model picker + with Esc now applies the last-highlighted choice instead of reverting + (#2196). +- **Web install downloads both binaries.** The `install-binary.tsx` + snippet now fetches both `codewhale` and `codewhale-tui`, fixing the + `MISSING_COMPANION_BINARY` trap on fresh npm installs (#2191). +- **`grep_files` skips large directories.** The pure-Rust search tool + now skips known-large directories (`.git`, `node_modules`, `target`) + before walking, preventing hangs on deep or slow filesystems. +- **Version-update hint uses semver.** The update notification in the + footer now compares versions semantically instead of lexicographically, + so `0.8.10 > 0.8.9` is recognized correctly. +- **CVE-2026-8723 in feishu-bridge.** Bumped `qs` to `>=6.15.2` in the + Feishu bridge integration (#2198). + +### Community + +Thanks to new contributors whose PRs landed in this release: +**@donglovejava** (#2154, #2163, #2166, #2167, #2168), +**@encyc** (#2152), +**@saieswar237** (#2178), +**@sximelon** (#2174), +**@nanookclaw** (#2135), +**@Sskift** (#2119), +**@xin1104** (#2105), +**@mrluanma** (#2059), +**@Lellansin** (#2055), +**@zhuangbiaowei** (#2145), +**@aboimpinto** (#1872), +and continuing contributors **@reidliu41**, **@cyq1017**, **@idling11**, +**@h3c-hexin**, **@wdw8276**, **@zlh124**, and **@jeoor**. + +## [0.8.45] - 2026-05-25 + +### Added + +- **RLM session objects.** `rlm_open` can now load `session://` refs, + exposing the active prompt, history, and session data as symbolic objects + inside RLM REPLs (#2047). +- **Command palette voice input.** The command palette can launch a configured + speech-to-text helper and show footer status while transcription runs + (#2047). +- **Moonshot/Kimi provider.** Moonshot/Kimi is now a first-class provider, + including API-key auth, model completion, CLI auth, secret-store + integration, and optional Kimi CLI credential reuse. +- **Deterministic whale-species sub-agent names.** Sub-agents now get stable, + human-readable whale-species nicknames (e.g. "Beluga", "Orca") while + preserving the raw agent ID in the popup (#2035, #2016). +- **`/balance` command scaffold.** Registered the `/balance` slash command + as a placeholder for future provider billing queries (#2035, #2019). +- **Readable `/restore` snapshot labels.** Snapshot labels now include the + originating user prompt so restore listings are easier to identify. Thanks + @idling11 (#2111). +- **Sidebar hover tooltips.** Truncated Work and Tasks sidebar lines now expose + their full text on hover. Thanks @idling11 (#2110). + +### Changed + +- **AGENTS.md is now maintainer-local.** The project instructions file no + longer ships as a tracked repo file; it lives in maintainer-local ignored + state (#2047). + +### Fixed + +- **Sub-agent completion handoff compatibility.** Completion handoffs now use a + chat-template-safe role and emit before terminal updates, fixing strict + OpenAI-compatible/self-hosted backends and preserving transcript ordering. + Thanks @h3c-hexin and @cyq1017 (#2057, #2120). +- **Self-hosted context budgeting.** Sub-500K self-hosted model windows now keep + a usable input budget instead of disabling preflight compaction after output + reservation underflow. Thanks @h3c-hexin (#2060). +- **Goal prompts start actionable.** Goal-start prompts now open in an + actionable state instead of requiring an extra nudge. Thanks @cyq1017 + (#2097). +- **Composer session title display.** The composer chrome shows the current + session title again and avoids grayscale luma overflow in debug builds. + Thanks @wdw8276 (#2108). +- **Approval prompts use a one-step confirmation flow.** Enter now commits the + selected approval option directly, destructive warnings remain visible, and + abort cancels the active turn instead of only denying the current tool call. + Thanks @reidliu41 (#2143). +- **Model picker selection survives Esc.** Dismissing the model picker with Esc + no longer loses the highlighted selection. Thanks @reidliu41 (#2056). +- **Moonshot/Kimi sessions launch from the dispatcher.** The `codewhale` + wrapper now includes Moonshot/Kimi in the TUI provider allowlist, so + `codewhale --provider moonshot --model kimi-k2.6` reaches the TUI instead of + stopping after config resolution. +- **Slash recovery no longer restores command tails in the composer.** + Resuming a session or recovering from a crash no longer leaves stale + slash-command text (e.g. `/sessions`) in the composer input (#2047, #2032). +- **Remembered tool approvals now update the live active turn.** + When the "remember" checkbox is set on an approval dialog, the active + turn's auto-approve flag flips immediately instead of waiting for the + next turn. Thanks @gaord (#2047, #2041). +- **YAML block scalars in SKILL.md frontmatter.** Multi-line descriptions + using `>` or `|` indicators are now parsed correctly — folded block + scalars join non-empty lines with spaces, literal scalars preserve + newlines, and all three chomping modes (strip/clip/keep) are supported. + Thanks @zlh124 (#1908, #1907). +- **User messages highlighted in the transcript.** User-authored messages + now render with a full-row background in the live TUI transcript, making + it easier to scan prior turns. Assistant and system messages are + unaffected. Thanks @reidliu41 (#1995, #1672). +- **Cancellable `list_dir` and `file_search`.** Long directory walks and + file searches now respond to user cancel/stop requests with a 30-second + fallback timeout, preventing the TUI from hanging on deep or slow + filesystems (#2035). + +### Community + +- **README contributor acknowledgements resynced.** The Thanks list now + includes the latest contributor rows for @donglovejava, @encyc, + @saieswar237, @sximelon, @nanookclaw, @Sskift, @xin1104, @mrluanma, + @Lellansin, and @zhuangbiaowei, while preserving the existing @jeoor + acknowledgement in the consolidated list. + ## [0.8.44] - 2026-05-24 ### Added @@ -4806,7 +5018,9 @@ Welcome — and thank you. - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.44...HEAD +[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.46...HEAD +[0.8.46]: https://github.com/Hmbown/CodeWhale/compare/v0.8.45...v0.8.46 +[0.8.45]: https://github.com/Hmbown/CodeWhale/compare/v0.8.44...v0.8.45 [0.8.44]: https://github.com/Hmbown/CodeWhale/compare/v0.8.43...v0.8.44 [0.8.43]: https://github.com/Hmbown/CodeWhale/compare/v0.8.42...v0.8.43 [0.8.42]: https://github.com/Hmbown/CodeWhale/compare/v0.8.41...v0.8.42 diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index f78b57a4..67d4042a 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -27,9 +27,10 @@ path = "src/bin/deepseek_tui_legacy_shim.rs" [dependencies] anyhow = "1.0.100" arboard = "3.4" -codewhale-config = { path = "../config", version = "0.8.44" } -codewhale-secrets = { path = "../secrets", version = "0.8.44" } -codewhale-tools = { path = "../tools", version = "0.8.44" } +codewhale-config = { path = "../config", version = "0.8.46" } +codewhale-release = { path = "../release", version = "0.8.46" } +codewhale-secrets = { path = "../secrets", version = "0.8.46" } +codewhale-tools = { path = "../tools", version = "0.8.46" } schemaui = { version = "0.12.0", default-features = false, optional = true } async-stream = "0.3.6" async-trait = "0.1" @@ -45,7 +46,7 @@ fd-lock = "4.0.4" futures-util = "0.3.31" ratatui = "0.30" regex = "1.11" -reqwest = { version = "0.13.1", default-features = false, features = ["blocking", "json", "stream", "multipart", "rustls", "http2", "gzip", "brotli"] } +reqwest = { version = "0.13.1", default-features = false, features = ["blocking", "json", "stream", "multipart", "form", "rustls", "http2", "gzip", "brotli"] } similar = "2" rustyline = "15.0.0" serde = { version = "1.0.228", features = ["derive"] } @@ -70,7 +71,7 @@ multimap = "0.10.0" shlex = "1.3.0" starlark = "0.13.0" tiny_http = "0.12" -portable-pty = "0.8" +portable-pty = "0.9" zeroize = "1.8.2" ignore = "0.4" image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/tui/src/audit.rs b/crates/tui/src/audit.rs index 60b49c63..2638131d 100644 --- a/crates/tui/src/audit.rs +++ b/crates/tui/src/audit.rs @@ -41,5 +41,5 @@ fn append_event(event: &str, details: Value) -> anyhow::Result<()> { fn default_audit_path() -> anyhow::Result { let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("home directory not found"))?; - Ok(home.join(".deepseek").join("audit.log")) + Ok(home.join(".codewhale").join("audit.log")) } diff --git a/crates/tui/src/automation_manager.rs b/crates/tui/src/automation_manager.rs index c98dc7e8..79bc8765 100644 --- a/crates/tui/src/automation_manager.rs +++ b/crates/tui/src/automation_manager.rs @@ -795,8 +795,15 @@ pub fn default_automations_dir() -> PathBuf { } } dirs::home_dir() - .map(|home| home.join(".deepseek").join("automations")) - .unwrap_or_else(|| PathBuf::from(".deepseek").join("automations")) + .map(|home| { + let primary = home.join(".codewhale").join("automations"); + if primary.exists() { + primary + } else { + home.join(".deepseek").join("automations") + } + }) + .unwrap_or_else(|| PathBuf::from(".codewhale").join("automations")) } pub type SharedAutomationManager = Arc>; diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 8ecd3e4c..15e5778f 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -882,8 +882,10 @@ pub(super) fn apply_reasoning_effort( ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::Openrouter + | ApiProvider::XiaomiMimo | ApiProvider::Novita - | ApiProvider::Sglang => { + | ApiProvider::Sglang + | ApiProvider::Volcengine => { body["thinking"] = json!({ "type": "disabled" }); } ApiProvider::Fireworks => {} @@ -904,6 +906,7 @@ pub(super) fn apply_reasoning_effort( ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::WanjieArk + | ApiProvider::Moonshot | ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ @@ -913,7 +916,10 @@ pub(super) fn apply_reasoning_effort( }, "low" | "minimal" | "medium" | "mid" | "high" | "" => match provider { // DeepSeek compatibility: low/medium both map to high - ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::Sglang => { + ApiProvider::Deepseek + | ApiProvider::DeepseekCN + | ApiProvider::Sglang + | ApiProvider::Volcengine => { body["reasoning_effort"] = json!("high"); body["thinking"] = json!({ "type": "enabled" }); } @@ -929,6 +935,9 @@ pub(super) fn apply_reasoning_effort( body["reasoning_effort"] = json!(value); body["thinking"] = json!({ "type": "enabled" }); } + ApiProvider::XiaomiMimo => { + body["thinking"] = json!({ "type": "enabled" }); + } ApiProvider::Fireworks => { body["reasoning_effort"] = json!("high"); } @@ -936,11 +945,19 @@ pub(super) fn apply_reasoning_effort( body["chat_template_kwargs"] = json!({ "enable_thinking": true, }); - body["reasoning_effort"] = json!("high"); + // vLLM supports low/medium/high natively — pass through the + // user-chosen value instead of hard-coding "high". + let value = match normalized.as_str() { + "low" | "minimal" => "low", + "medium" | "mid" => "medium", + _ => "high", + }; + body["reasoning_effort"] = json!(value); } ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::WanjieArk + | ApiProvider::Moonshot | ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ @@ -950,7 +967,10 @@ pub(super) fn apply_reasoning_effort( } }, "xhigh" | "max" | "highest" => match provider { - ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::Sglang => { + ApiProvider::Deepseek + | ApiProvider::DeepseekCN + | ApiProvider::Sglang + | ApiProvider::Volcengine => { body["reasoning_effort"] = json!("max"); body["thinking"] = json!({ "type": "enabled" }); } @@ -958,6 +978,9 @@ pub(super) fn apply_reasoning_effort( body["reasoning_effort"] = json!("xhigh"); body["thinking"] = json!({ "type": "enabled" }); } + ApiProvider::XiaomiMimo => { + body["thinking"] = json!({ "type": "enabled" }); + } ApiProvider::Fireworks => { body["reasoning_effort"] = json!("max"); } @@ -965,11 +988,14 @@ pub(super) fn apply_reasoning_effort( body["chat_template_kwargs"] = json!({ "enable_thinking": true, }); - body["reasoning_effort"] = json!("max"); + // vLLM only supports none/low/medium/high — downgrade + // "max" to "high" instead of sending an invalid value. + body["reasoning_effort"] = json!("high"); } ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::WanjieArk + | ApiProvider::Moonshot | ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ @@ -1113,6 +1139,23 @@ mod tests { }; use serde_json::json; + fn test_tool(name: &str) -> Tool { + Tool { + tool_type: None, + name: name.to_string(), + description: format!("{name} test tool"), + input_schema: json!({ + "type": "object", + "properties": {}, + }), + allowed_callers: None, + defer_loading: Some(false), + input_examples: None, + strict: Some(true), + cache_control: None, + } + } + #[test] fn tool_name_roundtrip_dot() { let original = "multi_tool_use.parallel"; @@ -1286,7 +1329,7 @@ mod tests { // and DOES replay reasoning_content — see // `deepseek_model_on_openai_provider_still_replays_reasoning_content`. let request = MessageRequest { - model: "gpt-4o".to_string(), + model: "qwen3-coder".to_string(), messages: vec![Message { role: "assistant".to_string(), content: vec![ @@ -1798,6 +1841,49 @@ mod tests { )); } + #[test] + fn prompt_inspect_tracks_tool_catalog_in_static_prefix_hash() { + let request = MessageRequest { + model: "deepseek-v4-pro".to_string(), + messages: vec![Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "Current task".to_string(), + cache_control: None, + }], + }], + max_tokens: 1024, + system: Some(SystemPrompt::Text("Base policy".to_string())), + tools: Some(vec![test_tool("read_file")]), + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: Some("max".to_string()), + stream: None, + temperature: None, + top_p: None, + }; + + let first = inspect_prompt_for_request(&request); + let mut changed_tools = request.clone(); + changed_tools.tools = Some(vec![test_tool("read_file"), test_tool("grep_files")]); + let second = inspect_prompt_for_request(&changed_tools); + + assert!( + first.layers.iter().any(|layer| { + layer.name == "Tool catalog" && layer.stability.label() == "static" + }) + ); + assert_ne!( + first.base_static_prefix_hash, second.base_static_prefix_hash, + "tool schema changes must be visible to cache-inspect base prefix diagnostics" + ); + assert_ne!( + first.full_request_prefix_hash, second.full_request_prefix_hash, + "tool schema changes must be visible to full reusable-prefix diagnostics" + ); + } + #[test] fn cache_warmup_request_reuses_stable_prefix_and_fixed_user_tail() { let request = MessageRequest { @@ -1823,7 +1909,7 @@ mod tests { "Base policy\n\n\nStable project rules\n\n\n## Previous Session Relay\n\nDynamic relay" .to_string(), )), - tools: None, + tools: Some(vec![test_tool("read_file")]), tool_choice: None, metadata: None, thinking: None, @@ -1838,6 +1924,8 @@ mod tests { assert_eq!(warmup.max_tokens, 8); assert_eq!(warmup.temperature, Some(0.0)); assert_eq!(warmup.reasoning_effort.as_deref(), Some("max")); + assert_eq!(warmup.tools.as_ref().map(Vec::len), Some(1)); + assert_eq!(warmup.tool_choice, Some(json!("none"))); assert_eq!(warmup.messages.len(), 2); assert_eq!(warmup.messages[0].role, "assistant"); assert_eq!(warmup.messages[1].role, "user"); @@ -1970,6 +2058,29 @@ mod tests { } } + #[test] + fn reasoning_effort_uses_xiaomi_mimo_thinking_parameter_only() { + for input in ["low", "medium", "max", "xhigh"] { + let mut body = json!({}); + apply_reasoning_effort(&mut body, Some(input), ApiProvider::XiaomiMimo); + + assert_eq!( + body.pointer("/thinking/type").and_then(Value::as_str), + Some("enabled"), + "MiMo thinking mapping for {input}" + ); + assert!(body.get("reasoning_effort").is_none()); + } + + let mut body = json!({}); + apply_reasoning_effort(&mut body, Some("off"), ApiProvider::XiaomiMimo); + assert_eq!( + body.pointer("/thinking/type").and_then(Value::as_str), + Some("disabled") + ); + assert!(body.get("reasoning_effort").is_none()); + } + #[test] fn chat_parser_accepts_nvidia_nim_reasoning_field() -> Result<()> { let response = parse_chat_message(&json!({ @@ -2745,7 +2856,7 @@ mod tests { // DeepSeek reasoning model on the openai provider still gets sanitized // (see chat.rs `deepseek_model_on_openai_provider_still_replays_*`). let mut body = json!({ - "model": "gpt-4o", + "model": "qwen3-coder", "messages": [ { "role": "user", "content": "hi" }, { @@ -2756,8 +2867,12 @@ mod tests { ] }); - let result = - sanitize_thinking_mode_messages(&mut body, "gpt-4o", Some("max"), ApiProvider::Openai); + let result = sanitize_thinking_mode_messages( + &mut body, + "qwen3-coder", + Some("max"), + ApiProvider::Openai, + ); assert!(result.is_none()); let assistant = body["messages"] @@ -2846,6 +2961,10 @@ mod tests { #[test] fn base_url_security_rejects_insecure_non_local_http() { + let _lock = ALLOW_INSECURE_HTTP_ENV_LOCK.lock().unwrap(); + let _guard = AllowInsecureHttpEnvGuard::capture(); + unsafe { std::env::remove_var(ALLOW_INSECURE_HTTP_ENV) }; + let err = validate_base_url_security("http://api.deepseek.com") .expect_err("non-local insecure HTTP should be rejected"); assert!(err.to_string().contains("Refusing insecure base URL")); @@ -2853,10 +2972,46 @@ mod tests { #[test] fn base_url_security_allows_localhost_http() { + let _lock = ALLOW_INSECURE_HTTP_ENV_LOCK.lock().unwrap(); + let _guard = AllowInsecureHttpEnvGuard::capture(); + unsafe { std::env::remove_var(ALLOW_INSECURE_HTTP_ENV) }; + assert!(validate_base_url_security("http://localhost:8080").is_ok()); assert!(validate_base_url_security("http://127.0.0.1:8080").is_ok()); } + #[test] + fn base_url_security_allows_non_local_http_with_explicit_opt_in() { + let _lock = ALLOW_INSECURE_HTTP_ENV_LOCK.lock().unwrap(); + let _guard = AllowInsecureHttpEnvGuard::capture(); + unsafe { std::env::set_var(ALLOW_INSECURE_HTTP_ENV, "1") }; + + assert!(validate_base_url_security("http://192.168.0.110:8000/v1").is_ok()); + } + + /// Serialize tests that mutate `DEEPSEEK_ALLOW_INSECURE_HTTP`; env vars are + /// process-global and would otherwise leak across security checks. + static ALLOW_INSECURE_HTTP_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + struct AllowInsecureHttpEnvGuard { + prior: Option, + } + impl AllowInsecureHttpEnvGuard { + fn capture() -> Self { + Self { + prior: std::env::var_os(ALLOW_INSECURE_HTTP_ENV), + } + } + } + impl Drop for AllowInsecureHttpEnvGuard { + fn drop(&mut self) { + match &self.prior { + Some(v) => unsafe { std::env::set_var(ALLOW_INSECURE_HTTP_ENV, v) }, + None => unsafe { std::env::remove_var(ALLOW_INSECURE_HTTP_ENV) }, + } + } + } + #[test] fn connection_health_degrades_and_recovers() { let now = Instant::now(); diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 1b691110..1c66079a 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -71,6 +71,17 @@ use super::{ release_stream_buffer, system_to_instructions, to_api_tool_name, }; +fn apply_provider_token_limit(body: &mut Value, provider: ApiProvider, max_tokens: u32) { + if provider != ApiProvider::XiaomiMimo { + return; + } + + if let Some(object) = body.as_object_mut() { + object.remove("max_tokens"); + } + body["max_completion_tokens"] = json!(max_tokens); +} + impl DeepSeekClient { pub(super) async fn create_message_chat( &self, @@ -82,6 +93,7 @@ impl DeepSeekClient { "messages": messages, "max_tokens": request.max_tokens, }); + apply_provider_token_limit(&mut body, self.api_provider, request.max_tokens); if let Some(temperature) = request.temperature { body["temperature"] = json!(temperature); @@ -156,6 +168,7 @@ impl DeepSeekClient { "include_usage": true }, }); + apply_provider_token_limit(&mut body, self.api_provider, request.max_tokens); if let Some(temperature) = request.temperature { body["temperature"] = json!(temperature); @@ -438,6 +451,7 @@ pub(crate) fn build_cache_warmup_request(request: &MessageRequest) -> MessageReq struct PromptBuilder<'a> { system: Option<&'a SystemPrompt>, messages: &'a [Message], + tools: Option<&'a [Tool]>, model: &'a str, reasoning_effort: Option<&'a str>, } @@ -447,6 +461,7 @@ impl<'a> PromptBuilder<'a> { Self { system: request.system.as_ref(), messages: &request.messages, + tools: request.tools.as_deref(), model: &request.model, reasoning_effort: request.reasoning_effort.as_deref(), } @@ -485,12 +500,17 @@ impl<'a> PromptBuilder<'a> { should_replay_reasoning_content(self.model, self.reasoning_effort), true, ); - inspect_wire_messages(&messages) + inspect_wire_request(self.tools, &messages) } fn build_cache_warmup_request(self) -> MessageRequest { let system = stable_system_prompt(self.system); let mut messages = stable_history_messages(self.messages); + let tools = self + .tools + .filter(|tools| !tools.is_empty()) + .map(<[Tool]>::to_vec); + let tool_choice = tools.as_ref().map(|_| json!("none")); messages.push(Message { role: "user".to_string(), content: vec![ContentBlock::Text { @@ -504,8 +524,8 @@ impl<'a> PromptBuilder<'a> { messages, max_tokens: 8, system, - tools: None, - tool_choice: None, + tools, + tool_choice, metadata: None, thinking: None, reasoning_effort: self.reasoning_effort.map(str::to_string), @@ -581,20 +601,19 @@ impl PromptLayerStability { } } -fn inspect_wire_messages(messages: &[Value]) -> PromptInspection { +fn inspect_wire_request(tools: Option<&[Tool]>, messages: &[Value]) -> PromptInspection { let mut layers = Vec::new(); let mut base_static_prefix_parts = Vec::new(); let mut full_request_prefix_parts = Vec::new(); + let mut start_index = 0; - for (index, message) in messages.iter().enumerate() { + if let Some(message) = messages.first() { let role = message .get("role") .and_then(Value::as_str) .unwrap_or("unknown"); let content = message_content_for_inspect(message); - let is_last = index + 1 == messages.len(); - - if index == 0 && role == "system" { + if role == "system" { for (name, stability, body) in split_system_layers(&content) { if stability == PromptLayerStability::Static { base_static_prefix_parts.push(body.to_string()); @@ -604,27 +623,46 @@ fn inspect_wire_messages(messages: &[Value]) -> PromptInspection { } layers.push(prompt_layer(name, stability, body)); } - } else { - let stability = if (is_last && role == "user") || role == "tool" { - PromptLayerStability::Dynamic - } else { - PromptLayerStability::History - }; - let name = if is_last && role == "user" { - "User task".to_string() - } else { - format!("Message #{index} {role}") - }; - if stability != PromptLayerStability::Dynamic { - full_request_prefix_parts.push(content.clone()); - } - let mut layer = prompt_layer(name, stability, &content); - layer.tool_result = tool_result_inspection_for_message(message); - layer.turn_meta = turn_meta_inspection_for_message(message); - layers.push(layer); + start_index = 1; } } + if let Some(tool_catalog) = tool_catalog_for_inspect(tools) { + base_static_prefix_parts.push(tool_catalog.clone()); + full_request_prefix_parts.push(tool_catalog.clone()); + layers.push(prompt_layer( + "Tool catalog".to_string(), + PromptLayerStability::Static, + &tool_catalog, + )); + } + + for (index, message) in messages.iter().enumerate().skip(start_index) { + let role = message + .get("role") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let content = message_content_for_inspect(message); + let is_last = index + 1 == messages.len(); + let stability = if (is_last && role == "user") || role == "tool" { + PromptLayerStability::Dynamic + } else { + PromptLayerStability::History + }; + let name = if is_last && role == "user" { + "User task".to_string() + } else { + format!("Message #{index} {role}") + }; + if stability != PromptLayerStability::Dynamic { + full_request_prefix_parts.push(content.clone()); + } + let mut layer = prompt_layer(name, stability, &content); + layer.tool_result = tool_result_inspection_for_message(message); + layer.turn_meta = turn_meta_inspection_for_message(message); + layers.push(layer); + } + let base_static_prefix = base_static_prefix_parts.join("\n"); let full_request_prefix = full_request_prefix_parts.join("\n"); @@ -635,6 +673,11 @@ fn inspect_wire_messages(messages: &[Value]) -> PromptInspection { } } +fn tool_catalog_for_inspect(tools: Option<&[Tool]>) -> Option { + let tools = tools.filter(|tools| !tools.is_empty())?; + serde_json::to_string(&tools.iter().map(tool_to_chat).collect::>()).ok() +} + fn message_content_for_inspect(message: &Value) -> String { let mut parts = Vec::new(); if let Some(content) = message.get("content").and_then(Value::as_str) @@ -1699,6 +1742,7 @@ fn provider_accepts_reasoning_content(provider: ApiProvider) -> bool { | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim | ApiProvider::Openrouter + | ApiProvider::XiaomiMimo | ApiProvider::Novita | ApiProvider::Fireworks | ApiProvider::Sglang @@ -3062,11 +3106,12 @@ mod alias_thinking_detection_tests { //! turn. See upstream API docs: //! https://api-docs.deepseek.com/guides/thinking_mode use super::{ - is_reasoning_model_for_stream, provider_accepts_reasoning_content, - requires_reasoning_content, should_replay_reasoning_content, - should_replay_reasoning_content_for_provider, + apply_provider_token_limit, is_reasoning_model_for_stream, + provider_accepts_reasoning_content, requires_reasoning_content, + should_replay_reasoning_content, should_replay_reasoning_content_for_provider, }; use crate::config::ApiProvider; + use serde_json::json; #[test] fn aliases_routed_to_v4_require_reasoning_content() { @@ -3093,7 +3138,7 @@ mod alias_thinking_detection_tests { // `reasoning_content` on providers that reject the field. assert!(!requires_reasoning_content("deepseek-v3")); assert!(!requires_reasoning_content("deepseek-coder")); - assert!(!requires_reasoning_content("gpt-4o")); + assert!(!requires_reasoning_content("qwen3-coder")); assert!(!requires_reasoning_content("claude-sonnet-4-6")); } @@ -3132,6 +3177,25 @@ mod alias_thinking_detection_tests { assert!(!provider_accepts_reasoning_content(ApiProvider::Openai)); assert!(provider_accepts_reasoning_content(ApiProvider::Deepseek)); assert!(provider_accepts_reasoning_content(ApiProvider::NvidiaNim)); + assert!(provider_accepts_reasoning_content(ApiProvider::XiaomiMimo)); + } + + #[test] + fn xiaomi_mimo_uses_max_completion_tokens_payload_key() { + let mut body = json!({ + "model": "mimo-v2.5-pro", + "messages": [], + "max_tokens": 8192, + }); + + apply_provider_token_limit(&mut body, ApiProvider::XiaomiMimo, 8192); + + assert!(body.get("max_tokens").is_none()); + assert_eq!( + body.get("max_completion_tokens") + .and_then(serde_json::Value::as_u64), + Some(8192) + ); } #[test] @@ -3169,7 +3233,7 @@ mod alias_thinking_detection_tests { // openai provider must continue to have reasoning_content stripped. assert!(!should_replay_reasoning_content_for_provider( ApiProvider::Openai, - "gpt-4o", + "qwen3-coder", None, )); assert!(!should_replay_reasoning_content_for_provider( @@ -3211,7 +3275,7 @@ mod alias_thinking_detection_tests { // parser keeps inlining any `reasoning_content` it emits as text. assert!(!is_reasoning_model_for_stream( ApiProvider::Openai, - "gpt-4o" + "qwen3-coder" )); assert!(!is_reasoning_model_for_stream( ApiProvider::Openai, @@ -3220,7 +3284,7 @@ mod alias_thinking_detection_tests { // Non-DeepSeek model on a reasoning-aware provider is also unchanged. assert!(!is_reasoning_model_for_stream( ApiProvider::Deepseek, - "gpt-4o" + "qwen3-coder" )); } @@ -3230,7 +3294,7 @@ mod alias_thinking_detection_tests { // model identity, or stream parsing and message sanitisation disagree // about where reasoning tokens live. Effort=None isolates the // model/provider dimension shared by both. - for model in ["deepseek-v4-pro", "deepseek-reasoner", "gpt-4o"] { + for model in ["deepseek-v4-pro", "deepseek-reasoner", "qwen3-coder"] { for provider in [ApiProvider::Openai, ApiProvider::Deepseek] { assert_eq!( is_reasoning_model_for_stream(provider, model), diff --git a/crates/tui/src/commands/anchor.rs b/crates/tui/src/commands/anchor.rs index fb15fb33..7ba66d7a 100644 --- a/crates/tui/src/commands/anchor.rs +++ b/crates/tui/src/commands/anchor.rs @@ -47,6 +47,10 @@ pub fn anchor(app: &mut App, content: Option<&str>) -> CommandResult { } fn anchors_path(app: &App) -> std::path::PathBuf { + let primary = app.workspace.join(".codewhale").join("anchors.md"); + if primary.exists() { + return primary; + } app.workspace.join(".deepseek").join("anchors.md") } diff --git a/crates/tui/src/commands/change.rs b/crates/tui/src/commands/change.rs index e8448a48..e424ec9b 100644 --- a/crates/tui/src/commands/change.rs +++ b/crates/tui/src/commands/change.rs @@ -101,6 +101,7 @@ pub fn change(app: &mut App, version: Option<&str>) -> CommandResult { Locale::Ja => "Japanese (日本語)", Locale::PtBr => "Brazilian Portuguese (Português)", Locale::Es419 => "Latin American Spanish (Español latinoamericano)", + Locale::Vi => "Vietnamese (Tiếng Việt)", // Fallback — should never reach here since we check En above. Locale::En => "English", }; diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 40ffe1dc..c582c7d4 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -5,7 +5,9 @@ use std::time::Duration; use super::CommandResult; use crate::client::DeepSeekClient; -use crate::config::{COMMON_DEEPSEEK_MODELS, clear_api_key, normalize_model_name_for_provider}; +use crate::config::{ + COMMON_DEEPSEEK_MODELS, Config, clear_api_key, expand_path, normalize_model_name_for_provider, +}; use crate::config_ui::{ConfigUiMode, parse_mode}; use crate::llm_client::LlmClient; use crate::localization::resolve_locale; @@ -91,6 +93,7 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { crate::localization::Locale::Ja => "ja", crate::localization::Locale::PtBr => "pt-BR", crate::localization::Locale::Es419 => "es-419", + crate::localization::Locale::Vi => "vi", } } fn density_display(d: crate::tui::app::ComposerDensity) -> &'static str { @@ -122,6 +125,16 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { } } "approval_mode" | "approval" => Some(app.approval_mode.label().to_string()), + "base_url" => { + let config = match Config::load(app.config_path.clone(), app.config_profile.as_deref()) + { + Ok(config) => config, + Err(err) => { + return CommandResult::error(format!("Failed to load config: {err}")); + } + }; + Some(config.deepseek_base_url()) + } "locale" | "language" => Some(locale_display(app.ui_locale).to_string()), "theme" | "ui_theme" => { Some(crate::palette::theme_label_for_mode(app.ui_theme.mode).to_string()) @@ -284,7 +297,7 @@ pub fn persist_status_items(items: &[crate::config::StatusItem]) -> anyhow::Resu use anyhow::Context; use std::fs; - let path = config_toml_path()?; + let path = config_toml_path(None)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create config directory {}", parent.display()))?; @@ -320,11 +333,15 @@ pub fn persist_status_items(items: &[crate::config::StatusItem]) -> anyhow::Resu Ok(path) } -pub fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result { +pub fn persist_root_string_key( + config_path: Option<&Path>, + key: &str, + value: &str, +) -> anyhow::Result { use anyhow::Context; use std::fs; - let path = config_toml_path()?; + let path = config_toml_path(config_path)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create config directory {}", parent.display()))?; @@ -351,8 +368,11 @@ pub fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result anyhow::Result { +pub(super) fn config_toml_path(config_path: Option<&Path>) -> anyhow::Result { use anyhow::Context; + if let Some(path) = config_path { + return Ok(expand_path(path.to_string_lossy().as_ref())); + } if let Ok(env) = std::env::var("DEEPSEEK_CONFIG_PATH") { let trimmed = env.trim(); if !trimmed.is_empty() { @@ -360,6 +380,10 @@ pub(super) fn config_toml_path() -> anyhow::Result { } } let home = dirs::home_dir().context("failed to resolve home directory for config.toml path")?; + let primary = home.join(".codewhale").join("config.toml"); + if primary.exists() { + return Ok(primary); + } Ok(home.join(".deepseek").join("config.toml")) } @@ -417,7 +441,8 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> app.mcp_config_path = PathBuf::from(expand_tilde(value)); app.mcp_restart_required = true; let message = if persist { - match persist_root_string_key("mcp_config_path", value) { + match persist_root_string_key(app.config_path.as_deref(), "mcp_config_path", value) + { Ok(path) => format!( "mcp_config_path = {} (saved to {}; restart required for MCP tool pool)", app.mcp_config_path.display(), @@ -433,6 +458,26 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> }; return CommandResult::message(message); } + "base_url" => { + let value = value.trim(); + if value.is_empty() { + return CommandResult::error("base_url cannot be empty"); + } + if persist { + match persist_root_string_key(app.config_path.as_deref(), "base_url", value) { + Ok(path) => { + return CommandResult::message(format!( + "base_url = {value} (saved to {})", + path.display() + )); + } + Err(err) => return CommandResult::error(format!("Failed to save: {err}")), + } + } + return CommandResult::error( + "base_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving.", + ); + } _ => {} } @@ -699,6 +744,47 @@ pub fn theme(app: &mut App, arg: Option<&str>) -> CommandResult { } } +/// `/slop [query|export]` — inspect or export the slop ledger (#2127). +/// With no arguments, prints a summary. `query` shows filtered results; +/// `export` outputs the full ledger as Markdown. +pub fn slop(_app: &mut App, arg: Option<&str>) -> CommandResult { + let arg = arg.map(str::trim).unwrap_or(""); + let ledger = match crate::slop_ledger::SlopLedger::load() { + Ok(l) => l, + Err(e) => return CommandResult::error(format!("Failed to load slop ledger: {e}")), + }; + + match arg { + "" => CommandResult::message(ledger.summary()), + "query" | "q" => { + if ledger.is_empty() { + return CommandResult::message("Slop ledger is empty."); + } + let mut out = String::new(); + for entry in &ledger.query(&Default::default()) { + use std::fmt::Write; + let _ = writeln!( + out, + "[{}] {} ({:?} | {:?}) — {}", + crate::slop_ledger::short_id(&entry.id), + entry.bucket.as_str(), + entry.severity, + entry.status, + entry.title + ); + } + CommandResult::message(out) + } + "export" | "e" => { + let md = ledger.export_markdown(None, None); + CommandResult::message(md) + } + _ => CommandResult::error(format!( + "Unknown /slop action '{arg}'. Use /slop, /slop query, or /slop export." + )), + } +} + /// Manage workspace-level trust and the per-path allowlist. /// /// Subcommands: @@ -1750,6 +1836,134 @@ mod tests { assert!(saved.contains("cost_currency = \"cny\"")); } + #[test] + fn config_command_base_url_save_persists_value() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-base-url-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let mut app = create_test_app(); + let result = config_command( + &mut app, + Some("base_url https://example.internal.local/v1 --save"), + ); + let msg = result.message.unwrap(); + let saved_path = config_toml_path(None).unwrap(); + let saved = fs::read_to_string(&saved_path).unwrap(); + + assert_eq!( + msg, + format!( + "base_url = https://example.internal.local/v1 (saved to {})", + saved_path.display() + ) + ); + assert!(saved.contains("base_url = \"https://example.internal.local/v1\"")); + } + + #[test] + fn config_command_base_url_without_save_requires_save() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + let result = config_command(&mut app, Some("base_url https://example.internal.local/v1")); + assert!(result.is_error); + let msg = result.message.unwrap(); + + assert!( + msg.contains("base_url must be saved with --save"), + "got {msg}" + ); + } + + #[test] + fn config_command_base_url_reads_current_value_from_config() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-base-url-show-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let config_path = temp_root.join(".deepseek").join("config.toml"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write( + &config_path, + "base_url = \"https://api.from-config.local/v1\"\n", + ) + .unwrap(); + + let mut app = create_test_app(); + let result = config_command(&mut app, Some("base_url")); + let msg = result.message.unwrap(); + + assert_eq!(msg, "base_url = https://api.from-config.local/v1"); + } + + #[test] + fn config_command_base_url_reads_current_value_from_app_config_path() { + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-base-url-app-config-path-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + + let config_path = temp_root.join("custom-config.toml"); + fs::write( + &config_path, + "base_url = \"https://api.from-app-path.local/v1\"\n", + ) + .unwrap(); + + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + let result = config_command(&mut app, Some("base_url")); + let msg = result.message.unwrap(); + + assert_eq!(msg, "base_url = https://api.from-app-path.local/v1"); + } + + #[test] + fn config_command_base_url_save_persists_to_app_config_path() { + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-base-url-save-app-path-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + + let config_path = temp_root.join("custom-config.toml"); + + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + let result = config_command( + &mut app, + Some("base_url https://example.session.local/v1 --save"), + ); + let msg = result.message.unwrap(); + let saved = fs::read_to_string(&config_path).unwrap(); + + assert_eq!( + msg, + format!( + "base_url = https://example.session.local/v1 (saved to {})", + config_path.display() + ) + ); + assert!(saved.contains("base_url = \"https://example.session.local/v1\"")); + } + #[test] fn theme_command_accepts_grayscale_arg() { let nanos = SystemTime::now() diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index 9e8fd775..44394485 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -46,6 +46,28 @@ pub fn help(app: &mut App, topic: Option<&str>) -> CommandResult { /// Clear conversation history pub fn clear(app: &mut App) -> CommandResult { + let todos_cleared = reset_conversation_state(app); + app.current_session_id = None; + let locale = app.ui_locale; + let message = if todos_cleared { + tr(locale, MessageId::ClearConversation).to_string() + } else { + tr(locale, MessageId::ClearConversationBusy).to_string() + }; + CommandResult::with_message_and_action( + message, + AppAction::SyncSession { + session_id: None, + messages: Vec::new(), + system_prompt: None, + model: app.model.clone(), + workspace: app.workspace.clone(), + }, + ) +} + +/// Reset the active conversation without choosing the next session id. +pub(crate) fn reset_conversation_state(app: &mut App) -> bool { app.clear_history(); app.mark_history_updated(); app.api_messages.clear(); @@ -55,6 +77,7 @@ pub fn clear(app: &mut App) -> CommandResult { app.queued_draft = None; app.session.total_tokens = 0; app.session.total_conversation_tokens = 0; + app.session.reset_token_breakdown(); app.session.session_cost = 0.0; app.session.session_cost_cny = 0.0; app.session.subagent_cost = 0.0; @@ -77,23 +100,7 @@ pub fn clear(app: &mut App) -> CommandResult { app.session.last_reasoning_replay_tokens = None; app.session.turn_cache_history.clear(); app.session.last_cache_inspection = None; - app.current_session_id = None; - let locale = app.ui_locale; - let message = if todos_cleared { - tr(locale, MessageId::ClearConversation).to_string() - } else { - tr(locale, MessageId::ClearConversationBusy).to_string() - }; - CommandResult::with_message_and_action( - message, - AppAction::SyncSession { - session_id: None, - messages: Vec::new(), - system_prompt: None, - model: app.model.clone(), - workspace: app.workspace.clone(), - }, - ) + todos_cleared } /// Exit the application diff --git a/crates/tui/src/commands/debug.rs b/crates/tui/src/commands/debug.rs index a89bd174..85b21fae 100644 --- a/crates/tui/src/commands/debug.rs +++ b/crates/tui/src/commands/debug.rs @@ -145,6 +145,9 @@ pub fn cache(app: &mut App, arg: Option<&str>) -> CommandResult { if matches!(arg, Some("warmup")) { return CommandResult::action(AppAction::CacheWarmup); } + if matches!(arg, Some("stats")) { + return CommandResult::message(format_cache_stats(app)); + } let want = arg.and_then(|s| s.parse::().ok()).unwrap_or(10); let cap = app.session.turn_cache_history.len(); @@ -233,6 +236,140 @@ fn format_cache_inspect(app: &mut App) -> String { out } +/// Render a prefix-cache stability and health summary for `/cache stats`. +/// +/// Surfaces the current prefix fingerprint, stability ratio, change history, +/// and an aggregated cache-hit summary from per-turn telemetry. When the +/// prefix has changed, a prominent warning is included so users can +/// correlate cache misses with prefix drift. +fn format_cache_stats(app: &App) -> String { + let mut out = String::new(); + out.push_str("Cache Stats\n"); + + // ── Prefix stability ────────────────────────────────────────────── + out.push_str("\n── Prefix Stability\n"); + match app.prefix_stability_pct { + Some(pct) => { + let checks = app.prefix_checks_total; + let changes = app.prefix_change_count; + let stable_checks = checks.saturating_sub(changes); + + if changes == 0 { + out.push_str(&format!( + " Stability: {pct}% ({stable_checks}/{checks} checks)\n" + )); + out.push_str(" Status: stable (no prefix changes this session)\n"); + } else { + out.push_str(&format!( + " Stability: {pct}% ({stable_checks}/{checks} checks, {changes} change{})\n", + if changes == 1 { "" } else { "s" } + )); + out.push_str(" Status: WARNING — prefix has changed\n"); + if let Some(ref desc) = app.last_prefix_change_desc { + out.push_str(&format!(" Last change: {desc}\n")); + } + } + } + None => { + out.push_str(" Stability: unknown (no checks recorded yet)\n"); + out.push_str(" Run a turn first to collect prefix stability data.\n"); + } + } + + // ── Prefix fingerprint ──────────────────────────────────────────── + out.push_str("\n── Prefix Fingerprint\n"); + match &app.last_pinned_prefix_hash { + Some(hash) => { + out.push_str(&format!(" Pinned hash: {hash}\n")); + let short = if hash.len() >= 12 { &hash[..12] } else { hash }; + out.push_str(&format!(" Short id: {short}\n")); + if app.prefix_change_count > 0 { + out.push_str(" Drift: WARNING — hash has changed during this session\n"); + out.push_str(&format!( + " ({change} change{plural} detected)\n", + change = app.prefix_change_count, + plural = if app.prefix_change_count == 1 { + "" + } else { + "s" + } + )); + } else { + out.push_str(" Drift: none (hash stable)\n"); + } + } + None => { + out.push_str(" Pinned hash: unavailable\n"); + out.push_str(" Run a turn first, or use /cache inspect.\n"); + } + } + + // ── Cache hit-rate summary ──────────────────────────────────────── + out.push_str("\n── Cache Hit Rate\n"); + let history = &app.session.turn_cache_history; + if history.is_empty() { + out.push_str(" No turn telemetry recorded yet.\n"); + } else { + // Aggregate only cache-aware turns; skip turns where the provider + // did not report cache telemetry (cache_hit_tokens is None). + // When cache_miss_tokens is None, infer it as + // input_tokens − cache_hit_tokens (matches /cache table logic). + let mut turns = 0u64; + let (hit, miss, input) = app.session.turn_cache_history.iter().fold( + (0u64, 0u64, 0u64), + |(hit, miss, input), rec| { + let Some(hit_tokens) = rec.cache_hit_tokens else { + return (hit, miss, input); + }; + let h = u64::from(hit_tokens); + let m = u64::from( + rec.cache_miss_tokens + .unwrap_or(rec.input_tokens.saturating_sub(hit_tokens)), + ); + turns += 1; + (hit + h, miss + m, input + u64::from(rec.input_tokens)) + }, + ); + let total_cache = hit + miss; + let avg_pct = if total_cache > 0 { + (hit as f64 / total_cache as f64 * 100.0).clamp(0.0, 100.0) + } else { + 0.0 + }; + out.push_str(&format!(" Turns recorded: {turns}\n")); + out.push_str(&format!( + " Cache hit tokens: {hit} ({avg_pct:.1}% of {total_cache} cache-aware tokens)\n", + hit = format_tokens(hit), + total_cache = format_tokens(total_cache), + )); + out.push_str(&format!( + " Cache miss tokens: {miss}\n", + miss = format_tokens(miss), + )); + out.push_str(&format!( + " Total input tokens: {input}\n", + input = format_tokens(input), + )); + if avg_pct < 80.0 { + out.push_str(" NOTE: cache hit rate is low (< 80%). Check prefix stability above or consider /compact.\n"); + } + } + + out +} + +/// Formats a u64 token count with a compact suffix: K for thousands, +/// M for millions. Never returns scientific notation. +fn format_tokens(n: u64) -> String { + if n >= 1_000_000 { + format!("{:.1}M", n as f64 / 1_000_000.0) + } else if n >= 1_000 { + format!("{:.1}K", n as f64 / 1_000.0) + } else { + n.to_string() + } +} + fn format_static_prefix_status( previous: Option<&PromptInspection>, current: &PromptInspection, @@ -1402,6 +1539,136 @@ mod tests { ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "call-a" )); } + + // ── /cache stats tests ────────────────────────────────────────────── + + #[test] + fn cache_stats_no_data_before_first_turn() { + let mut app = create_test_app(); + let result = cache(&mut app, Some("stats")); + let msg = result.message.expect("cache stats produces a message"); + assert!(msg.contains("Cache Stats"), "got: {msg}"); + assert!( + msg.contains("unknown (no checks recorded yet)"), + "got: {msg}" + ); + assert!(msg.contains("Pinned hash: unavailable"), "got: {msg}"); + assert!(msg.contains("No turn telemetry recorded yet"), "got: {msg}"); + } + + #[test] + fn cache_stats_shows_stable_prefix_with_hash() { + let mut app = create_test_app(); + app.prefix_stability_pct = Some(100); + app.prefix_checks_total = 5; + app.prefix_change_count = 0; + app.last_pinned_prefix_hash = + Some("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".to_string()); + + let result = cache(&mut app, Some("stats")); + let msg = result.message.expect("cache stats produces a message"); + + assert!(msg.contains("Stability: 100%"), "got: {msg}"); + assert!(msg.contains("stable (no prefix changes"), "got: {msg}"); + assert!(msg.contains("Pinned hash: a1b2c3d4e5f6"), "got: {msg}"); + assert!( + msg.contains("Drift: none (hash stable)"), + "got: {msg}" + ); + } + + #[test] + fn cache_stats_warns_on_prefix_change() { + let mut app = create_test_app(); + app.prefix_stability_pct = Some(67); + app.prefix_checks_total = 3; + app.prefix_change_count = 1; + app.last_prefix_change_desc = + Some("prefix cache invalidated: system prompt changed".to_string()); + app.last_pinned_prefix_hash = Some( + "deadbeef0000deadbeef0000deadbeef0000deadbeef0000deadbeef0000deadbeef".to_string(), + ); + + let result = cache(&mut app, Some("stats")); + let msg = result.message.expect("cache stats produces a message"); + + assert!(msg.contains("Stability: 67%"), "got: {msg}"); + assert!(msg.contains("WARNING — prefix has changed"), "got: {msg}"); + assert!(msg.contains("system prompt changed"), "got: {msg}"); + assert!(msg.contains("Drift: WARNING"), "got: {msg}"); + assert!(msg.contains("1 change detected"), "got: {msg}"); + } + + #[test] + fn cache_stats_shows_cache_hit_summary() { + let mut app = create_test_app(); + app.prefix_stability_pct = Some(100); + app.prefix_checks_total = 1; + app.last_pinned_prefix_hash = + Some("abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234".to_string()); + + app.push_turn_cache_record(TurnCacheRecord { + input_tokens: 10_000, + output_tokens: 1_000, + cache_hit_tokens: Some(8_000), + cache_miss_tokens: Some(2_000), + reasoning_replay_tokens: None, + recorded_at: Instant::now(), + }); + app.push_turn_cache_record(TurnCacheRecord { + input_tokens: 5_000, + output_tokens: 500, + cache_hit_tokens: Some(4_500), + cache_miss_tokens: Some(500), + reasoning_replay_tokens: None, + recorded_at: Instant::now(), + }); + + let result = cache(&mut app, Some("stats")); + let msg = result.message.expect("cache stats produces a message"); + + assert!(msg.contains("Turns recorded: 2"), "got: {msg}"); + // Total: 12,500 hit out of 15,000 cache-aware = 83.3% + assert!(msg.contains("83.3%"), "got: {msg}"); + } + + #[test] + fn cache_stats_low_hit_rate_shows_note() { + let mut app = create_test_app(); + app.prefix_stability_pct = Some(100); + app.prefix_checks_total = 1; + app.last_pinned_prefix_hash = + Some("abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234".to_string()); + + app.push_turn_cache_record(TurnCacheRecord { + input_tokens: 10_000, + output_tokens: 1_000, + cache_hit_tokens: Some(1_000), + cache_miss_tokens: Some(9_000), + reasoning_replay_tokens: None, + recorded_at: Instant::now(), + }); + + let result = cache(&mut app, Some("stats")); + let msg = result.message.expect("cache stats produces a message"); + + // 10% hit rate → below 80% threshold + assert!(msg.contains("10.0%"), "got: {msg}"); + assert!( + msg.contains("cache hit rate is low"), + "should show low-hit-rate advisory, got: {msg}" + ); + } + + #[test] + fn format_tokens_handles_all_scales() { + assert_eq!(format_tokens(0), "0"); + assert_eq!(format_tokens(999), "999"); + assert_eq!(format_tokens(1_000), "1.0K"); + assert_eq!(format_tokens(15_500), "15.5K"); + assert_eq!(format_tokens(1_000_000), "1.0M"); + assert_eq!(format_tokens(2_500_000), "2.5M"); + } } /// Remove last message pair (user + assistant). diff --git a/crates/tui/src/commands/goal.rs b/crates/tui/src/commands/goal.rs index 47a4d62e..bb07c5b5 100644 --- a/crates/tui/src/commands/goal.rs +++ b/crates/tui/src/commands/goal.rs @@ -1,56 +1,51 @@ -//! /goal command — set a session objective with token budget and progress tracking. +//! /hunt command — declare a quarry with token budget and verdict tracking (#2092). -use crate::tui::app::App; +use std::io::Write; + +use crate::tui::app::{App, AppAction, HuntVerdict}; use super::CommandResult; -/// Set or show the current goal -pub fn goal(app: &mut App, arg: Option<&str>) -> CommandResult { +/// Declare, show, or close a hunt +pub fn hunt(app: &mut App, arg: Option<&str>) -> CommandResult { match arg { Some("clear") | Some("reset") => { - app.goal.goal_objective = None; - app.goal.goal_token_budget = None; - app.goal.goal_started_at = None; - app.goal.goal_completed = false; - CommandResult::message("Goal cleared.") - } - Some("done") | Some("complete") => { - app.goal.goal_completed = true; - let elapsed = app - .goal - .goal_started_at - .map(|t| crate::tui::notifications::humanize_duration(t.elapsed())) - .unwrap_or_else(|| "unknown".to_string()); - CommandResult::message(format!("Goal marked complete! Elapsed: {elapsed}")) + app.hunt.quarry = None; + app.hunt.token_budget = None; + app.hunt.started_at = None; + app.hunt.verdict = HuntVerdict::default(); + CommandResult::message("Hunt cleared.") } + Some("done") | Some("complete") | Some("hunted") => close_hunt(app, HuntVerdict::Hunted), + Some("wound") | Some("wounded") => close_hunt(app, HuntVerdict::Wounded), + Some("escape") | Some("escaped") => close_hunt(app, HuntVerdict::Escaped), Some(text) if !text.is_empty() => { - // Parse optional budget: "/goal Implement login | budget: 50000" - let (objective, budget) = parse_goal_budget(text); - app.goal.goal_objective = Some(objective.clone()); - app.goal.goal_token_budget = budget; - app.goal.goal_started_at = Some(std::time::Instant::now()); - app.goal.goal_completed = false; + let (objective, budget) = parse_hunt_budget(text); + if objective.is_empty() || objective.chars().all(|c| c == '|') { + return CommandResult::error("Usage: /hunt [budget: N]"); + } + app.hunt.quarry = Some(objective.clone()); + app.hunt.token_budget = budget; + app.hunt.started_at = Some(std::time::Instant::now()); + app.hunt.verdict = HuntVerdict::Hunting; let budget_str = budget .map(|b| format!(" (budget: {b} tokens)")) .unwrap_or_default(); - CommandResult::message(format!( - "Goal set: \"{objective}\"{budget_str} — tracking progress." - )) + CommandResult::with_message_and_action( + format!("Hunt set: \"{objective}\"{budget_str} — tracking progress."), + AppAction::SendMessage(objective), + ) } _ => { - // Show current goal - if let Some(ref obj) = app.goal.goal_objective { - // #447: render long elapsed times as `2d 3h` rather - // than Rust's default Debug `Duration` (which produces - // `188415.234s` or similar for multi-day goals). + if let Some(ref obj) = app.hunt.quarry { let elapsed = app - .goal - .goal_started_at + .hunt + .started_at .map(|t| crate::tui::notifications::humanize_duration(t.elapsed())) .unwrap_or_else(|| "unknown".to_string()); let budget_str = app - .goal - .goal_token_budget + .hunt + .token_budget .map(|b| { let used = app.session.total_conversation_tokens; let pct = if b > 0 { @@ -61,26 +56,61 @@ pub fn goal(app: &mut App, arg: Option<&str>) -> CommandResult { format!(" | tokens: {used}/{b} ({pct:.0}%)") }) .unwrap_or_default(); - let status = if app.goal.goal_completed { - " [COMPLETED]" - } else { - "" + let verdict_label = match app.hunt.verdict { + HuntVerdict::Hunting => "[HUNTING]", + HuntVerdict::Hunted => "[HUNTED]", + HuntVerdict::Wounded => "[WOUNDED]", + HuntVerdict::Escaped => "[ESCAPED]", }; CommandResult::message(format!( - "Goal{status}: \"{obj}\" — elapsed: {elapsed}{budget_str}" + "Hunt {verdict_label}: \"{obj}\" — elapsed: {elapsed}{budget_str}" )) } else { CommandResult::message( - "No goal set. Use /goal [budget: N] to set one.\n\ - /goal clear — remove the current goal.", + "No hunt set. Use /hunt [budget: N] to declare one.\n\ + /hunt hunted — mark complete\n\ + /hunt wounded — mark interrupted (resumable)\n\ + /hunt escaped — mark abandoned\n\ + /hunt clear — remove the current hunt.", ) } } } } -/// Parse optional token budget from goal text: "Implement login | budget: 50000" -fn parse_goal_budget(text: &str) -> (String, Option) { +fn close_hunt(app: &mut App, verdict: HuntVerdict) -> CommandResult { + if app.hunt.quarry.as_deref().is_none_or(str::is_empty) { + return CommandResult::error("No hunt set. Use /hunt [budget: N] first."); + } + + let prev = app.hunt.verdict; + let should_write_trophy = prev != verdict || !matches!(verdict, HuntVerdict::Hunted); + if should_write_trophy { + if let Err(err) = write_trophy_card(app, verdict) { + return CommandResult::error(err); + } + } + app.hunt.verdict = verdict; + + match verdict { + HuntVerdict::Hunted => { + let elapsed = app + .hunt + .started_at + .map(|t| crate::tui::notifications::humanize_duration(t.elapsed())) + .unwrap_or_else(|| "unknown".to_string()); + CommandResult::message(format!("Hunt complete! Elapsed: {elapsed}")) + } + HuntVerdict::Wounded => { + CommandResult::message("Hunt wounded — progress saved, can be resumed.") + } + HuntVerdict::Escaped => CommandResult::message("Hunt escaped — quarry abandoned."), + HuntVerdict::Hunting => CommandResult::message("Hunt resumed."), + } +} + +/// Parse text like "Implement login | budget: 50000" into (objective, budget). +fn parse_hunt_budget(text: &str) -> (String, Option) { if let Some((obj, rest)) = text.split_once(" | budget:") { let budget = rest .split_whitespace() @@ -98,17 +128,114 @@ fn parse_goal_budget(text: &str) -> (String, Option) { } } +/// Write a trophy card to `~/.codewhale/trophies/-