diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..869ef6c5d --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,18 @@ +# HarmonyOS/OpenHarmony cross-build paths are intentionally not configured +# here. Cargo does not expand environment variables inside target linker paths +# or CMake toolchain paths, so checked-in absolute SDK paths make the workspace +# machine-specific. +# +# See docs/HarmonyOS.md for setup details. +# +# Set OHOS_NATIVE_SDK to the OpenHarmony native SDK directory, then load one of: +# +# PowerShell: +# . .\scripts\ohos-env.ps1 +# +# Linux/macOS: +# . ./scripts/ohos-env.sh +# +# The setup scripts export Cargo's target-specific linker, AR, CC, CXX, CFLAGS, +# CXXFLAGS, CARGO_ENCODED_RUSTFLAGS, CC_SHELL_ESCAPED_FLAGS, and +# CMAKE_TOOLCHAIN_FILE variables for aarch64-unknown-linux-ohos. diff --git a/.cnb.yml b/.cnb.yml index ef440d1aa..f1c4d5f80 100644 --- a/.cnb.yml +++ b/.cnb.yml @@ -38,6 +38,7 @@ script: | set -eu ./scripts/release/check-versions.sh + ./scripts/release/check-ohos-deps.sh cargo fmt --all -- --check cargo check --workspace --all-targets --locked cargo clippy --workspace --all-targets --all-features --locked -- -D warnings @@ -75,6 +76,7 @@ script: | set -eu ./scripts/release/check-versions.sh + ./scripts/release/check-ohos-deps.sh cargo fmt --all -- --check cargo check --workspace --all-targets --locked cargo clippy --workspace --all-targets --all-features --locked -- -D warnings @@ -123,6 +125,7 @@ $: apt-get install -y git libdbus-1-dev nodejs pkg-config ./scripts/release/check-versions.sh + ./scripts/release/check-ohos-deps.sh cargo build --release --locked -p codewhale-cli -p codewhale-tui mkdir -p target/cnb-release diff --git a/.gitattributes b/.gitattributes index 099e7f8ed..6e639fdb8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,5 +3,11 @@ # produces different compiled binaries on Windows vs Linux/macOS. crates/tui/src/prompts/*.md text eol=lf +# Rustfmt writes LF; keep Rust sources stable across Windows/Linux/macOS. +*.rs text eol=lf + +# Keep repository attributes themselves stable on every platform. +.gitattributes text eol=lf + # Everything else auto-detects (default). * text=auto diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 23e1a5d45..9c0e7d964 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -9,3 +9,56 @@ # issue:username # all:username all:hmbown +all:reidliu41 +all:ousamabenyounes +all:ljm3790865 +all:HUQIANTAO +all:xyuai +all:merchloubna70-dot +all:h3c-hexin +all:axobase001 +all:donglovejava +all:Oliver-ZPLiu +all:idling11 +all:angziii +all:aboimpinto +all:encyc +all:Duducoco +all:cyq1017 +all:zlh124 +all:THINKER-ONLY +all:nightt5879 +all:Liu-Vince +all:JiarenWang +all:wdw8276 +all:pengyou200902 +all:linzhiqin2003 +all:LING71671 +all:JasonOA888 +all:Inference1 +all:hongqitai +all:gordonlu +all:gaord +all:zhuangbiaowei +all:yuanchenglu +all:Vishnu1837 +all:sximelon +all:Sskift +all:New2Niu +all:shenjackyuanjie +all:AdityaVG13 +all:mvanhorn +all:MengZ-super +all:membphis +all:LeoAlex0 +all:Lee-take +all:lbcheng888 +all:Implementist +all:jrcjrcc +all:yusufgurdogan +all:kunpeng-ai-lab +all:elowen53 +all:CrepuscularIRIS +all:chnjames +all:ChaceLyee2101 +all:AresNing diff --git a/.github/AUTHOR_MAP b/.github/AUTHOR_MAP new file mode 100644 index 000000000..0ee92f408 --- /dev/null +++ b/.github/AUTHOR_MAP @@ -0,0 +1,106 @@ +# Contributor credit identity map. +# +# Format: +# alias = Display Name +# +# The right-hand side must use GitHub's numeric noreply address so harvested +# co-author credit lands in the contributor graph. The left-hand side may be a +# GitHub login, old-style noreply address, raw email from a contributor commit, +# or local machine email seen in older harvested history. + +hmbown = Hmbown <101357273+Hmbown@users.noreply.github.com> +reidliu41 = reidliu41 <61492567+reidliu41@users.noreply.github.com> +reid201711@gmail.com = reidliu41 <61492567+reidliu41@users.noreply.github.com> +ousamabenyounes = Ben Younes <2910651+ousamabenyounes@users.noreply.github.com> +benyounes.ousama@gmail.com = Ben Younes <2910651+ousamabenyounes@users.noreply.github.com> +ljm3790865 = ljm3790865 <263429444+ljm3790865@users.noreply.github.com> +HUQIANTAO = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> +Hu Qiantao = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> +huqiantao@users.noreply.github.com = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> +huqiantao@HudeMacBook-Air.local = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> +tom_huu@qq.com = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> +punkcanyang = Punkcan Yang <36871858+punkcanyang@users.noreply.github.com> +Punkcan Yang = Punkcan Yang <36871858+punkcanyang@users.noreply.github.com> +bucunzai@gmail.com = Punkcan Yang <36871858+punkcanyang@users.noreply.github.com> +merchloubna70-dot = merchloubna70-dot <258170091+merchloubna70-dot@users.noreply.github.com> +h3c-hexin = h3c-hexin <13790929+h3c-hexin@users.noreply.github.com> +he.xin@h3c.com = h3c-hexin <13790929+h3c-hexin@users.noreply.github.com> +axobase001 = axobase001 <138223345+axobase001@users.noreply.github.com> +donglovejava = donglovejava <211940267+donglovejava@users.noreply.github.com> +Oliver-ZPLiu = Oliver-ZPLiu <47081637+Oliver-ZPLiu@users.noreply.github.com> +idling11 = idling11 <8055620+idling11@users.noreply.github.com> +Hanmiao Li = idling11 <8055620+idling11@users.noreply.github.com> +894876246@qq.com = idling11 <8055620+idling11@users.noreply.github.com> +angziii = angziii <177907677+angziii@users.noreply.github.com> +aboimpinto = aboimpinto <1231687+aboimpinto@users.noreply.github.com> +Paulo Aboim Pinto = aboimpinto <1231687+aboimpinto@users.noreply.github.com> +aboimpinto@gmail.com = aboimpinto <1231687+aboimpinto@users.noreply.github.com> +encyc = encyc <62669951+encyc@users.noreply.github.com> +Duducoco = Duducoco <69681789+Duducoco@users.noreply.github.com> +cyq1017 = cyq1017 <61975706+cyq1017@users.noreply.github.com> +cyq = cyq1017 <61975706+cyq1017@users.noreply.github.com> +15000851237@163.com = cyq1017 <61975706+cyq1017@users.noreply.github.com> +zlh124 = zlh124 <56312993+zlh124@users.noreply.github.com> +THINKER-ONLY = THINKER-ONLY <181556007+THINKER-ONLY@users.noreply.github.com> +nightt5879 = nightt5879 <87569709+nightt5879@users.noreply.github.com> +Liu-Vince = Liu-Vince <56624166+Liu-Vince@users.noreply.github.com> +Vince = Liu-Vince <56624166+Liu-Vince@users.noreply.github.com> +liuwenchang.x@qq.com = Liu-Vince <56624166+Liu-Vince@users.noreply.github.com> +JiarenWang = JiarenWang <33421508+JiarenWang@users.noreply.github.com> +wdw8276 = wdw8276 <3972439+wdw8276@users.noreply.github.com> +pengyou200902 = pengyou200902 <35026241+pengyou200902@users.noreply.github.com> +linzhiqin2003 = linzhiqin2003 <123250980+linzhiqin2003@users.noreply.github.com> +LING71671 = LING71671 <231181387+LING71671@users.noreply.github.com> +JasonOA888 = JasonOA888 <101583541+JasonOA888@users.noreply.github.com> +Inference1 = Inference1 <68734681+Inference1@users.noreply.github.com> +hongqitai = hongqitai <188678175+hongqitai@users.noreply.github.com> +gordonlu = gordonlu <3125629+gordonlu@users.noreply.github.com> +gaord = gaord <9567937+gaord@users.noreply.github.com> +Ben Gao = gaord <9567937+gaord@users.noreply.github.com> +bengao168@msn.com = gaord <9567937+gaord@users.noreply.github.com> +zhuangbiaowei = zhuangbiaowei <93194+zhuangbiaowei@users.noreply.github.com> +yuanchenglu = yuanchenglu <4088730+yuanchenglu@users.noreply.github.com> +Vishnu1837 = Vishnu1837 <104626273+Vishnu1837@users.noreply.github.com> +sximelon = sximelon <15710511+sximelon@users.noreply.github.com> +Sskift = Sskift <163287349+Sskift@users.noreply.github.com> +New2Niu = New2Niu <19551155+New2Niu@users.noreply.github.com> +mvanhorn = mvanhorn <455140+mvanhorn@users.noreply.github.com> +MengZ-super = MengZ-super <121712068+MengZ-super@users.noreply.github.com> +membphis = membphis <6814606+membphis@users.noreply.github.com> +LeoAlex0 = LeoAlex0 <31839998+LeoAlex0@users.noreply.github.com> +Lee-take = Lee-take <210963840+Lee-take@users.noreply.github.com> +lbcheng888 = lbcheng888 <6716643+lbcheng888@users.noreply.github.com> +kunpeng-ai-lab = kunpeng-ai-lab <16793595+kunpeng-ai-lab@users.noreply.github.com> +elowen53 = elowen53 <88364845+elowen53@users.noreply.github.com> +Elowen = elowen53 <88364845+elowen53@users.noreply.github.com> +xrnc@outlook.com = elowen53 <88364845+elowen53@users.noreply.github.com> +CrepuscularIRIS = CrepuscularIRIS <126939795+CrepuscularIRIS@users.noreply.github.com> +chnjames = chnjames <44110547+chnjames@users.noreply.github.com> +ChaceLyee2101 = ChaceLyee2101 <95995339+ChaceLyee2101@users.noreply.github.com> +ci4ic4 = ci4ic4 <6495973+ci4ic4@users.noreply.github.com> +Chavdar Ivanov = ci4ic4 <6495973+ci4ic4@users.noreply.github.com> +ci4ic4@gmail.com = ci4ic4 <6495973+ci4ic4@users.noreply.github.com> +yusufgurdogan = yusufgurdogan <13736056+yusufgurdogan@users.noreply.github.com> +Yusuf Gurdogan = yusufgurdogan <13736056+yusufgurdogan@users.noreply.github.com> +hotelswith = yusufgurdogan <13736056+yusufgurdogan@users.noreply.github.com> +contact@hotelswith.com = yusufgurdogan <13736056+yusufgurdogan@users.noreply.github.com> +AresNing = AresNing <49557311+AresNing@users.noreply.github.com> + +shenjackyuanjie = shenjackyuanjie <54507071+shenjackyuanjie@users.noreply.github.com> +shenjack = shenjackyuanjie <54507071+shenjackyuanjie@users.noreply.github.com> +3695888@qq.com = shenjackyuanjie <54507071+shenjackyuanjie@users.noreply.github.com> +xyuai = xyuai <281015099+xyuai@users.noreply.github.com> +AdityaVG13 = AdityaVG13 <44177453+AdityaVG13@users.noreply.github.com> +adityavgcode@gmail.com = AdityaVG13 <44177453+AdityaVG13@users.noreply.github.com> +Implementist = Implementist <24910011+Implementist@users.noreply.github.com> +implecao = Implementist <24910011+Implementist@users.noreply.github.com> +yuyuyu4993@qq.com = Implementist <24910011+Implementist@users.noreply.github.com> +jrcjrcc = jrcjrcc <192965070+jrcjrcc@users.noreply.github.com> +jrcjrcc@users.noreply.github.com = jrcjrcc <192965070+jrcjrcc@users.noreply.github.com> +RefuseOdd = RefuseOdd <192543033+RefuseOdd@users.noreply.github.com> +wywsoor = wywsoor <26341601+wywsoor@users.noreply.github.com> +hsdbeebou = hsdbeebou <284843096+hsdbeebou@users.noreply.github.com> +tdccccc = tdccccc <79492752+tdccccc@users.noreply.github.com> +greyfreedom = greyfreedom <11493871+greyfreedom@users.noreply.github.com> +greyfreedom@163.com = greyfreedom <11493871+greyfreedom@users.noreply.github.com> +puneetdixit200 = puneetdixit200 <236133619+puneetdixit200@users.noreply.github.com> diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index db22692be..f5ab2949c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -11,3 +11,4 @@ - [ ] Updated docs or comments as needed - [ ] Added or updated tests where relevant - [ ] Verified TUI behavior manually if UI changes +- [ ] Harvested/co-authored credit uses a GitHub numeric noreply address diff --git a/.github/scripts/update-homebrew-tap.sh b/.github/scripts/update-homebrew-tap.sh index 5d8f970e9..04c43366d 100644 --- a/.github/scripts/update-homebrew-tap.sh +++ b/.github/scripts/update-homebrew-tap.sh @@ -3,7 +3,7 @@ # # Expected environment: # TAG – git tag, e.g. "v0.8.31" -# MANIFEST – path to deepseek-artifacts-sha256.txt +# MANIFEST – path to codewhale-artifacts-sha256.txt # TAP_REPO – owner/repo of the Homebrew tap # TOKEN – PAT with contents:write on TAP_REPO (optional; skips if unset) @@ -43,15 +43,6 @@ 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 -------------------------------------------------------- @@ -78,14 +69,6 @@ class DeepseekTui < Formula 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}/codewhale-macos-x64", using: :nounzip sha256 "${SHA_COD_MACOS_X64}" @@ -93,14 +76,6 @@ class DeepseekTui < Formula 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 @@ -112,14 +87,6 @@ class DeepseekTui < Formula 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}/codewhale-linux-x64", using: :nounzip sha256 "${SHA_COD_LINUX_X64}" @@ -127,22 +94,12 @@ class DeepseekTui < Formula 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 => "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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55d518325..1eb681cfa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,12 +27,16 @@ jobs: node-version: 20 - name: Check version drift run: ./scripts/release/check-versions.sh + - name: Check OHOS dependency graph + run: ./scripts/release/check-ohos-deps.sh lint: name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy @@ -50,6 +54,22 @@ jobs: run: cargo clippy --workspace --all-features --locked -- -D warnings - name: Check provider registry drift run: python3 scripts/check-provider-registry.py + - name: Check harvested contributor credit + if: github.event_name != 'schedule' + shell: bash + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + git fetch --no-tags origin "${{ github.base_ref }}" + RANGE="origin/${{ github.base_ref }}..HEAD" + elif [[ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]]; then + RANGE="${{ github.event.before }}..${{ github.sha }}" + else + RANGE="HEAD~1..HEAD" + fi + python3 scripts/check-coauthor-trailers.py \ + --author-map .github/AUTHOR_MAP \ + --range "$RANGE" \ + --check-authors - 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/issue-gate.yml b/.github/workflows/issue-gate.yml index 70bab864b..8ca8c4011 100644 --- a/.github/workflows/issue-gate.yml +++ b/.github/workflows/issue-gate.yml @@ -1,4 +1,4 @@ -name: Contribution gate - issues +name: Contribution intake - issues on: issues: @@ -8,16 +8,11 @@ permissions: contents: read issues: write -env: - # Keep new gates observable first. Switch to "enforce" only after maintainers - # have seeded active contributors and reviewed the dry-run signal. - CONTRIBUTION_GATE_MODE: dry-run - jobs: gate: runs-on: ubuntu-latest steps: - - name: Gate unapproved external issues + - name: Welcome new external issue reporters uses: actions/github-script@v7 with: script: | @@ -25,12 +20,6 @@ jobs: const owner = context.repo.owner; const repo = context.repo.repo; const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); - const gateMode = (process.env.CONTRIBUTION_GATE_MODE || 'dry-run').trim().toLowerCase(); - const enforceGate = gateMode === 'enforce'; - - if (!['dry-run', 'enforce'].includes(gateMode)) { - core.warning(`Unknown CONTRIBUTION_GATE_MODE "${gateMode}"; defaulting to dry-run.`); - } if (privileged.has(issue.author_association)) return; if (issue.user.login === 'github-actions[bot]') return; @@ -71,29 +60,25 @@ jobs: return; } - const gateMessage = enforceGate - ? 'This repository currently uses a maintainer-managed contribution gate, so issues from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.' - : 'This repository is currently observing a maintainer-managed contribution gate in dry-run mode, so this issue is staying open. When enforcement is enabled, issues from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` will be closed automatically.'; + const marker = ''; + const { data: comments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number: issue.number, + per_page: 100, + }); + if (comments.some(comment => (comment.body || '').includes(marker))) return; await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: [ + marker, `Thanks @${issue.user.login} for the report.`, '', - gateMessage, + 'This issue is staying open for maintainer triage. CodeWhale gets better because people bring us real edge cases from real machines, providers, regions, and workflows.', '', - 'Please read `CONTRIBUTING.md` for the expected issue shape. A maintainer can grant issue access by commenting `/lgtmi` on an issue.', + 'If you can add a reproduction, logs, version output, screenshots, or the provider/model involved, that makes it much easier for us to verify and harvest the fix. Maintainers may comment `/lgtmi` to mark recurring issue reporters as approved so this intake note is skipped next time.', ].join('\n'), }); - - if (!enforceGate) return; - - await github.rest.issues.update({ - owner, - repo, - issue_number: issue.number, - state: 'closed', - state_reason: 'not_planned', - }); diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 3e4052dbd..a953b3f65 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -73,21 +73,32 @@ jobs: } const gateMessage = enforceGate - ? 'This repository currently uses a maintainer-managed contribution gate, so pull requests from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.' - : 'This repository is currently observing a maintainer-managed contribution gate in dry-run mode, so this pull request is staying open. When enforcement is enabled, pull requests from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` will be closed automatically.'; + ? 'This repository currently limits automated PR intake to contributors listed in `.github/APPROVED_CONTRIBUTORS`. This is a maintainer-safety control for code review and CI load, not a judgment on the contribution. A maintainer can grant recurring PR access with `/lgtm` after review; once the generated allowlist PR is merged, this pull request can be reopened or resubmitted.' + : 'This repository is observing a maintainer-managed PR intake gate in dry-run mode, so this pull request is staying open. This note helps maintainers prepare the allowlist before any enforcement is considered.'; - await github.rest.issues.createComment({ + const marker = ''; + const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: pr.number, - body: [ - `Thanks @${pr.user.login} for taking the time to contribute.`, - '', - gateMessage, - '', - 'Please read `CONTRIBUTING.md` for the expected contribution shape. A maintainer can grant PR access by commenting `/lgtm` on a pull request.', - ].join('\n'), + per_page: 100, }); + const alreadyNoted = comments.some(comment => (comment.body || '').includes(marker)); + if (!alreadyNoted) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: [ + marker, + `Thanks @${pr.user.login} for taking the time to contribute.`, + '', + gateMessage, + '', + 'Please read `CONTRIBUTING.md` for the expected contribution shape. A maintainer can grant recurring PR access by commenting `/lgtm` on a pull request.', + ].join('\n'), + }); + } if (!enforceGate) return; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc9aa4161..4e0fa3ba7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,8 @@ jobs: run: cargo fmt --all -- --check - name: Compile check run: cargo check --workspace --all-targets --locked + - name: OHOS dependency graph + run: ./scripts/release/check-ohos-deps.sh - name: Clippy run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings - name: Workspace tests @@ -157,48 +159,6 @@ jobs: target: x86_64-pc-windows-msvc binary: codewhale-tui.exe artifact_name: codewhale-tui-windows-x64.exe - # --- deepseek (legacy dispatcher shim; removed in v0.9.0) --- - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - binary: deepseek - artifact_name: deepseek-linux-x64 - - os: ubuntu-latest - target: aarch64-unknown-linux-gnu - binary: deepseek - artifact_name: deepseek-linux-arm64 - - os: macos-latest - target: x86_64-apple-darwin - binary: deepseek - artifact_name: deepseek-macos-x64 - - os: macos-latest - target: aarch64-apple-darwin - binary: deepseek - artifact_name: deepseek-macos-arm64 - - os: windows-latest - target: x86_64-pc-windows-msvc - binary: deepseek.exe - artifact_name: deepseek-windows-x64.exe - # --- deepseek-tui (legacy TUI shim; removed in v0.9.0) --- - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - binary: deepseek-tui - artifact_name: deepseek-tui-linux-x64 - - os: ubuntu-latest - target: aarch64-unknown-linux-gnu - binary: deepseek-tui - artifact_name: deepseek-tui-linux-arm64 - - os: macos-latest - target: x86_64-apple-darwin - binary: deepseek-tui - artifact_name: deepseek-tui-macos-x64 - - os: macos-latest - target: aarch64-apple-darwin - binary: deepseek-tui - artifact_name: deepseek-tui-macos-arm64 - - os: windows-latest - target: x86_64-pc-windows-msvc - binary: deepseek-tui.exe - artifact_name: deepseek-tui-windows-x64.exe runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -502,8 +462,6 @@ jobs: - uses: actions/download-artifact@v4 with: path: artifacts - # Match both the canonical `codewhale*` artifacts and the legacy - # `deepseek*` shim artifacts that ship for the transition release. pattern: '*' - name: Generate Windows npm launcher asset shell: bash @@ -535,10 +493,6 @@ jobs: base="$(basename "${file}")" printf '%s %s\n' "${hash}" "${base}" >> "${manifest}" done < <(find artifacts -type f ! -path 'artifacts/checksums/*' -print0 | sort -z) - # Legacy alias manifest so v0.8.40 `deepseek update` clients can - # still find a manifest by their hardcoded name. Same content; will - # be removed once the legacy shim binaries are retired in v0.9.0. - cp "${manifest}" "artifacts/checksums/deepseek-artifacts-sha256.txt" cat "${manifest}" - uses: softprops/action-gh-release@v1 with: @@ -546,13 +500,11 @@ jobs: files: artifacts/*/* prerelease: false body: | - > This release renames the project to **CodeWhale**. The legacy - > `deepseek` and `deepseek-tui` binaries continue to ship as - > compatibility-only deprecation shims during v0.8.x; they print a - > one-line warning and forward to `codewhale` / `codewhale-tui`. - > They will be removed in v0.9.0. The legacy npm package - > `deepseek-tui` is deprecated and receives no further releases. - > See `docs/REBRAND.md` for the full migration story. + > **CodeWhale** is the canonical project, command, npm package, and + > release-asset name. The legacy npm package `deepseek-tui` is + > deprecated and receives no further releases. Users coming from + > v0.8.x legacy `deepseek` / `deepseek-tui` names should migrate + > with `docs/REBRAND.md`. ## Install @@ -573,7 +525,7 @@ jobs: ghcr.io/hmbown/codewhale:${{ needs.resolve.outputs.tag }} ``` - The image ships the `codewhale` dispatcher and `codewhale-tui` runtime (plus the legacy `deepseek` / `deepseek-tui` shims during the transition). The `latest` tag is also updated on release. + The image ships the `codewhale` dispatcher and `codewhale-tui` runtime. The `latest` tag is also updated on release. ### Cargo (Linux / macOS) @@ -613,7 +565,7 @@ jobs: The **portable** Windows archive skips the install script — extract and run from any directory. The NSIS installer is currently unsigned and may trigger Windows SmartScreen until a signing certificate is wired into the release pipeline. - Individual binaries are also attached below for scripting and the npm wrapper. Legacy `deepseek-*` and `deepseek-tui-*` assets are compatibility-only deprecation shims for v0.8.x so that existing `deepseek update` invocations on v0.8.40 keep working; they forward to the canonical binaries. The legacy npm package `deepseek-tui` is deprecated and is not republished. + Individual binaries are also attached below for scripting and the npm wrapper. The legacy npm package `deepseek-tui` is deprecated and is not republished. For migration from v0.8.x legacy binary names, see `docs/REBRAND.md`. ### Verify (recommended) @@ -631,7 +583,19 @@ jobs: 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 as the canonical manifest. + ## Contributors + + Thanks to @sximelon, @cyq1017, @Artenx, @LHqweasd, @wywsoor, + @hsdbeebou, @mserrano11, @Dr3259, @yekern, @lioryx, + @puneetdixit200, @HUQIANTAO, @xyuai, @gaord, @shenjackyuanjie, + @AdityaVG13, @aboimpinto, @ousamabenyounes, @reidliu41, + @ljm3790865, @idling11, @h3c-hexin, @AresNing, @tdccccc, + @qiyuanlicn, @bevis-wong, @shuxiangxuebiancheng, @hongqitai, + @NASLXTO, @wuxixing, @linzhiqin2003, @merchloubna70-dot, + @mvanhorn, @Implementist, @jrcjrcc, @punkcanyang, + @yusufgurdogan, @LeoAlex0, @mo-vic, @AiurArtanis, @nasus9527, + and @lbcheng888 for reports, PRs, reviews, reproductions, + design direction, and harvested work that shaped v0.9.0. ## Changelog @@ -668,13 +632,13 @@ jobs: run: | gh release download ${{ needs.resolve.outputs.tag }} \ --repo ${{ github.repository }} \ - --pattern 'deepseek-artifacts-sha256.txt' \ + --pattern 'codewhale-artifacts-sha256.txt' \ --dir /tmp - name: Update Homebrew tap if: steps.homebrew-token.outputs.available == 'true' env: TAG: ${{ needs.resolve.outputs.tag }} - MANIFEST: /tmp/deepseek-artifacts-sha256.txt + MANIFEST: /tmp/codewhale-artifacts-sha256.txt TAP_REPO: Hmbown/homebrew-deepseek-tui TOKEN: ${{ secrets.HOMEBREW_TAP_PAT || secrets.RELEASE_TAG_PAT }} run: bash .github/scripts/update-homebrew-tap.sh diff --git a/.gitignore b/.gitignore index b14ca4b58..eb3b0887e 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,8 @@ docs/*.pdf # Local dev scripts and temp files *.sh *.cmd +!ohos-clang.sh +!ohos-clangxx.sh !scripts/** !.github/scripts/** test.txt diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..a265d8186 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,22 @@ +# Repository Agent Guidance + +## CodeWhale Stewardship + +- Treat community contributors as partners. Good-faith PRs, issue reports, + repros, logs, reviews, and verification comments are maintainer evidence, + not queue noise. +- Keep gates warm and dry-run unless Hunter explicitly approves enforcement. + Gate copy should guide contributors clearly and respectfully. +- Credit every harvested PR, issue report, or comment that materially shaped a + fix. Preserve authorship when possible; otherwise use mappable GitHub + noreply `Co-authored-by` trailers from `.github/AUTHOR_MAP`. +- Do not tag, publish, create a GitHub Release, or push release artifacts + without Hunter approval. +- Use CodeWhale branding while keeping DeepSeek support first-class. Retiring + legacy `deepseek-tui` names must never read as deprecating DeepSeek models or + provider support. +- Review PRs from code, tests, linked issues, comments, and check results. + Never merge, close, harvest, or defer community work from title or labels + alone. +- Respect concurrent work in the tree. Do not revert or rewrite unrelated + edits by other people or agents. diff --git a/CHANGELOG.md b/CHANGELOG.md index dec9b971b..e5998cadb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,445 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.0] - 2026-06-07 + +### Added + +- Added `/restore list [N]` so users can inspect more side-git rollback + snapshots with UTC timestamps before choosing a restore point. Plain + `/restore` now shows the 20 most recent snapshots, numeric restore targets can + reach beyond that default listing up to a bounded index, and list requests + above the visible cap fail explicitly instead of silently truncating. +- Added HarmonyOS/OpenHarmony support scaffolding: environment-driven + `OHOS_NATIVE_SDK` setup scripts and compiler wrappers, platform docs, + explicit Rustls ring-provider installation for the no-provider TLS build, and + OHOS fallbacks for unsupported keyring, clipboard, sandbox, browser-open, TTY, + execpolicy Starlark parsing, and self-update surfaces. +- Added `scripts/release/check-ohos-deps.sh` and wired it into CI/release + preflight so the OpenHarmony target graph fails if unsupported `nix`, + `portable-pty`, `starlark`, `arboard`, or `keyring` dependencies re-enter. +- Added `.github/AUTHOR_MAP` and a CI co-author credit check so harvested + commits use GitHub-mappable numeric noreply identities instead of `.local`, + placeholder, bot/tool, or raw third-party emails. +- Added a `turn_end` observer hook that fires after post-turn TUI state and + token totals are updated. Hooks receive structured JSON with status, usage, + totals, duration, tool count, and queued-message count on stdin; stdout is + ignored and failures are warn-only (#1364, #2578). +- Added provider-scoped `insecure_skip_tls_verify` for private + OpenAI-compatible gateways that cannot use a trusted CA bundle. The setting is + disabled by default, applies only to the active LLM provider HTTP client, and + is surfaced by `codewhale doctor`; `SSL_CERT_FILE` remains the preferred path + for corporate or private CA roots. Thanks @wavezhang for the original #1893 + direction. +- Added a default-disabled hard-compaction planner that can identify the + summarizable middle of a long conversation while preserving the recent tail, + existing tool-call/result pair guarantees, and working-set pinning. This + harvests the safe planning layer from #2522 without enabling hard compaction + or adding a message-rewrite execution path yet. Thanks @HUQIANTAO for the + proposal. +- Added rich PlanArtifact support to `update_plan`: Plan mode can now carry + grounded objectives, context, sources, critical files, constraints, + verification, risks, and handoff notes through the transcript card, Plan + confirmation prompt, `/relay`, fork-state, and saved-session replay. +- Added the first `codewhale-whaleflow` foundation crate with typed workflow + config/IR validation and deterministic phase ordering tests. This preserves + the WhaleFlow direction from #2482/#2486 without exposing a runtime + `workflow_run` tool until cancellation, replay, and worktree semantics are + release-safe. The foundation now includes explicit `WorkflowSpec`, + `WorkflowNode`, branch/leaf/policy metadata structs, plus serializable branch, + leaf, and control-node result records toward the #2668 TraceStore contract. + It also adds a crate-local mock executor skeleton for Sequence, BranchSet, + Leaf, Reduce, LoopUntil, Cond, Expand, BranchTournament, and ParetoFrontier + control flow so #2669 can progress without spawning agents, applying + worktrees, or exposing a `workflow_run` runtime tool yet. A first Starlark + authoring layer now compiles fail-closed model-authored workflow files into + that typed IR, with `rlm_cache_change.star` and `issue_fix_tournament.star` + examples plus a one-pass repair for common `ctx.*` authoring aliases (#2670). + Leaf, branch, and workflow execution results now carry deterministic token + and cost telemetry fields that the mock executor can aggregate without live + provider calls or runtime sub-agent fanout (#2486). The mock executor now + carries crate-local cancellation and budget-exhaustion status markers so the + branch/leaf runtime contract can be tested before live workflow execution is + exposed (#2669). A crate-only replay executor now evaluates workflows from + recorded leaf/control records, computes + stable SHA-256 leaf input hashes, and marks missing records as + `replay_diverged` instead of calling models again (#2673); the runtime replay + command and live-provider replay fallback remain deferred. The crate also now + has a model-agnostic role/capability registry with mock provider plumbing and + fail-closed JSON repair parsing, so WhaleFlow can choose capable models for + roles without hardcoding provider-specific runtime paths (#2672). The + `rlm_cache_change.star` dogfood workflow now exercises candidate branches, + LoopUntil verification, tournament selection, teacher review, and mock + execution in CI-oriented crate tests (#2679). Leaf, branch, and workflow + results now also carry separate ARMH/shared-memo and provider prompt-cache + telemetry counters, with mock aggregation tests, so #2671 can progress + without wiring live RLM calls or billing-affecting provider behavior yet. The + Starlark and typed-IR gates now also reject unknown leaf dependencies, + reducer inputs, and teacher-review candidates before mock execution or replay, + keeping generated workflows fail-closed while runtime/worktree semantics stay + deferred. TeacherReview now has serializable GEPA-style candidate artifacts + for notes, workflow recipes, skills, regression tests, cache policy, branch + heuristics, and Starlark authoring prompt patches, plus an offline helper + that proposes candidates from recorded execution traces without promoting + them or training model weights (#2674). StudentReplay results can now be + stored on teacher candidates, and a deterministic PromotionGate compares + baseline-vs-candidate replay deltas, required tests, policy violations, + staleness, and cost constraints before marking a candidate promotable (#2675). + The external-memory cutline now documents that Aleph-style memory stays + optional, explicit, visible, and clear/export-capable for v0.9.0 rather than + becoming a hidden default context substrate (#2677). + A dedicated v0.9.0 release acceptance matrix now tracks provider, runtime, + UI, WhaleFlow, Model Lab, remote-workbench, docs, rollback, and credit gates + that must be checked or explicitly deferred before tagging (#2729). + HarnessProfile docs now pin the v0.9.0 order: posture/schema/resolver/seed + profiles/status display must precede evidence stores, promotion gates, or any + automatic Harness Creator, with DeepSeek, MiMo, Arcee, and generic/HF/local + posture expectations called out separately (#2728). + Hugging Face / Model Lab and `codebase_search` release gates now explicitly + ship only the provider/MCP/docs/design foundation in v0.9; native Hub search, + model passports, Spaces/Jobs workflows, eval/export surfaces, and runtime + `codebase_search` registration remain deferred (#2705, #2680, #2727). + Remote workbench acceptance is also marked docs/setup-only for v0.9 so release + notes do not imply a shipped VM or Telegram bridge runtime (#2724). + Release-facing HarnessProfile docs now match the current implementation: + v0.9 ships the typed schema/config foundation and defers runtime resolver, + telemetry, seed-profile selection, and status-display behavior until later + verified slices. `config.example.toml` includes a commented dormant + harness-profile example, and README links point at the real acceptance matrix + and HarnessProfile cutline docs. + The release acceptance matrix now records evidence for already-landed gates: + provider-registry drift checks, provider-scoped TLS skip verify, read-only + GUI runtime/restore-point surfaces, VS Code Agent View branch visibility, + WhaleFlow mock/runtime foundations, explicit external-memory boundaries, and + docs alignment. Live workflow execution, provider calls, TraceStore writes, + and mutation-oriented GUI endpoints remain deferred until their atomicity and + replay contracts are tested. The `rlm_cache_change.star` dogfood workflow can + now be replayed from recorded mock leaf/control records, and missing dogfood + records produce `ReplayDiverged` instead of falling back to live execution + (#2679). The UI/workflow UX rows now also distinguish shipped transcript + tool-run collapse, sidebar detail popovers, and PlanArtifact review/handoff + evidence from the deferred first-look/home redesign, and record focused + slash-picker readability smoke coverage for visibility, selection, skill + insertion, Esc priority, and stable composer height (#2692, #2694, #2691, + #2713). + Thanks @AdityaVG13 for the WhaleFlow draft and cost-tracking direction. +- Added a state-store v2 schema migration for WhaleFlow trace tables covering + workflow, branch, leaf, control-node, and teacher-candidate runs. The + migration creates persistence shape only; workflow execution and replay + remain deferred until the runtime semantics are safe (#2668). +- Added an official VS Code extension Phase 0 scaffold with terminal launch, + local runtime attach checks, status bar state, and a read-only Agent View + preview backed by recent runtime thread summaries, plus a read-only + `GET /v1/snapshots` endpoint for GUI clients to inspect side-git restore + points. The extension now renders those restore points read-only in its Agent + View, and thread summaries include read-only workspace, branch, current Git + head, and dirty-state metadata so the VS Code Agent View can show when a + thread or agent lane is on another branch or has changed worktree state. Agent + View and restore-point data now auto-refresh on a configurable + read-only interval so branch/workspace/status changes become visible without a + manual refresh. Agent View refreshes keep thread branch/workspace rows + independent from restore-point loading, so a snapshot-listing failure no + longer clears already-available thread metadata. This answers the VS Code GUI + lane without exposing chat webviews, inline edits, or retry/undo/restore + runtime mutation endpoints yet + (#461, #462, #480, #1217, #2341, #1584, #2327, #2580, #2808). Thanks @AiurArtanis + for the Agent View prompt, @lbcheng888 for the earlier scaffold, @gaord for + the GUI runtime API direction, @douglarek, @caeserchen, and @nightt5879 for + the branch visibility trail, and @BigBenLabs, @lzx1545642258, @yangdaowan, + @mangdehuang, @VerrPower, @hejia-v, @nasus9527, and @ygzhang-cn for the + GUI/VS Code demand and validation trail. +- Added inline live-output refresh for background shell Exec cards keyed by the + exact shell task id, so long-running commands can show bounded stdout/stderr + tails without consuming deltas or matching by command text. Thanks + @donglovejava for the live shell-output direction in #2048. +- Added a static prompt composer override for embedders that need to replace + the byte-stable base/personality prompt segment while leaving mode metadata, + approval policy, tool taxonomy, Context Management, and the Compaction Relay + under CodeWhale's runtime prompt assembly. This refines the embedder prompt + customization path from #2786 without weakening prompt-continuity safeguards. + Thanks @h3c-hexin. +- Added `POST /v1/sessions` for runtime clients to save a completed thread as a + managed session. The endpoint preserves thread title/model/mode/workspace + metadata, maps missing threads to 404, and returns 409 instead of snapshotting + queued or active turns. +- Added cost-estimate pricing for the Xiaomi MiMo primary chat models, which + were previously unpriced: `mimo-v2.5-pro` / `xiaomi/mimo-v2.5-pro` reuse the + DeepSeek V4-Pro rate table and `mimo-v2.5` / `xiaomi/mimo-v2.5` reuse the + DeepSeek V4-Flash rates. Existing DeepSeek pricing is unchanged (#2731, #2750). +- Added a metadata-only `codewhale-config` provider registry with canonical + lookup, alias-aware resolution, provider defaults, config-table keys, and + API-key env candidates. Runtime routing remains unchanged and fallback + providers stay dormant; this harvests the safe provider-trait foundation from + #2479 toward #2075. Thanks @sximelon. +- Added optional `[search].base_url` / `CODEWHALE_SEARCH_BASE_URL` support for + DuckDuckGo-compatible private search endpoints, while keeping + `DEEPSEEK_SEARCH_BASE_URL` as a legacy alias. Custom endpoints are gated by + their configured host, do not fall back to public Bing, and report the custom + host as the result source for diagnostics (#2436, #2510). +- Added `completion_sound = "file"` with `[notifications].sound_file` so + Windows users can play a custom WAV file for turn-completion sounds without + changing the global Windows sound scheme (#2484, #2512). +- Added `[tui].stream_chunk_timeout_secs` and `/config stream_chunk_timeout_secs` + so slow local or OpenAI-compatible model servers can extend the SSE idle + timeout without mutating process environment. The legacy + `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var remains a fallback (#2365, #2507). +- Added dormant `fallback_providers = [...]` config parsing plus a provider-chain + helper for future fallback routing. This preserves the requested contract + without enabling silent runtime provider switches yet (#2574, #2777). Thanks + @hsdbeebou for the request and @idling11 for the data-model draft. +- Added `/hf` with `/huggingface` alias for Hugging Face MCP status/setup + helpers and `/hf concepts` provider/MCP/Hub guidance. The helper points users + to Hugging Face's settings-generated MCP configuration and intentionally does + not include Hub search, direct Hugging Face HTTP requests, or upload behavior + (#2709, #2782). Thanks @idling11 for the original Hugging Face MCP draft. +- Added an in-process response cache for deterministic non-streaming, + tool-free chat requests. The cache is keyed by provider, base URL, path + suffix, API-key fingerprint, and final wire body, and zeroes usage on hits so + local spend counters are not double-counted (#2501). Thanks @HUQIANTAO for + the response-cache proposal and canonical-body key update. +- Added `/sidebar` so users can toggle, show, hide, and optionally persist the + TUI sidebar from the command line instead of relying on copy-hostile sidebar + state during long transcript work (#2766, #2788). Thanks @mo-vic for the + detailed report and @aboimpinto for the fix. +- Added a pausable custom slash-command MVP: commands with `pausable: true` + can pause before further tool execution, preserve the paused command while + separate messages are handled, and resume only on explicit continue/resume + wording. Harvested from #2732 with thanks to @aboimpinto. +- Added Sofya (`provider = "sofya"`) as a search-tool backend with + `SOFYA_API_KEY` fallback, while keeping Sofya scoped to web search rather + than model-provider routing (#2790). Thanks @yusufgurdogan for the + implementation. +- Added Xiaomi MiMo `mode` / `XIAOMI_MIMO_MODE` / `MIMO_MODE` selection for + Token Plan region endpoints and pay-as-you-go routing, plus dedicated Token + Plan env keys for `tp-*` subscriptions (#2621, #2627). Thanks @springeye for + the request and @xyuai for the implementation. +- Added the first TUI hotbar action registry foundation so future UI controls + can dispatch typed app actions instead of growing another command match + surface (#2866). Thanks @reidliu41 for the implementation. +- Added the narrow multi-tab core and persistence foundation, including tab + manager snapshots, delegation/group restore counters, mention parsing, + cross-tab events, and corruption-tolerant persisted state, while leaving the + broader collaboration UI wiring to follow-up work (#2864). Thanks + @ljm3790865 for the tab-core implementation and #2753 direction. +- The VS Code Agent View now renders the runtime thread summary's Git `head` + and dirty-worktree flag alongside branch metadata, keeping branch switches + visible without adding retry/undo/restore mutation endpoints yet (#2580, + #2862). Thanks @AiurArtanis and @nasus9527 for the IDE/agent-view requests + and @gaord for the runtime metadata direction. + +### Changed + +- Removed the deprecated `deepseek` and `deepseek-tui` binary shims from the + v0.9.0 Cargo crates and GitHub release artifact matrix. The canonical + `codewhale`, `codew`, and `codewhale-tui` entry points remain, the private + deprecated `npm/deepseek-tui` notice package stays unpublished, and DeepSeek + provider/model/env/config compatibility remains first-class. +- Command-adjacent config persistence and auto model routing now live in + neutral TUI modules instead of command-owned files, reducing command-boundary + coupling while preserving current `/config`, `/model`, UI, runtime, and + sub-agent behavior (#2871). Thanks @aboimpinto for landing this first staged + command-boundary layer from the broader #2851/#2791 design direction. +- `/config` now reports the canonical `~/.codewhale/settings.toml` path for TUI + settings while still reading legacy DeepSeek-branded settings fallbacks and + migrating them into the CodeWhale home on load. +- Provider switches now roll back transactionally when the first request to a + newly selected provider fails authentication: CodeWhale restores the previous + provider/model, model-ID passthrough, onboarding/API-key state, runtime + config, persisted provider selection, and engine handle so users can return + to DeepSeek after a failed Moonshot/Kimi switch (#2754, #2755). Thanks + @Dr3259 for the Windows repro and @cyq1017 for the draft fix. +- `PATCH /v1/threads/{id}` can now update a thread's persisted workspace for + GUI/runtime clients. Workspace changes reject active turns and evict idle + cached engines so the next turn starts in the new workspace. +- Split `web_run` session/page cache state so cached page reads use shared + page handles and do not serialize through the mutation path. The harvest also + adds panic-safe state write-back and serializes cache-mutating unit tests so + the global web cache remains stable under normal Cargo test parallelism. +- Appended volatile `` blocks after user text in outgoing user + message content arrays so provider prefix caches can keep matching the stable + user-input prefix across date, route, and working-set changes. +- Projected mode, approval, and tool-taxonomy prompt metadata per request + instead of mutating stored system prompts, keeping provider prefix-cache + inputs byte-stable while preserving mode-specific instructions (#2687). + Thanks @LeoAlex0 for the implementation. +- Softened contribution intake automation: external issues now receive a warm + triage note and are never auto-closed by the contribution gate, while the PR + gate copy makes clear that dry-run observations are about maintainer safety, + not contributor quality. +- Added a PR gate marker guard so reopened unapproved PRs do not get duplicate + intake comments, and clarified that PR reopening should happen after + allowlist approval is merged. +- Ollama `/model` completions no longer show hosted DeepSeek API model IDs. + The picker preserves the current or saved local Ollama tag, and users can + still fetch installed model IDs through `/models` instead of relying on a + stale static default (#2742). Thanks @reidliu41 for the focused report and + draft fix. +- MCP runtime API tool listings and approval summaries no longer split + underscored MCP server names at the first `_`. Tool-call routing already used + the longest registered server name; the list endpoint now reuses that parser, + and approval cards show the full MCP target route instead of a guessed server + segment (#2744). Thanks @lioryx, @cyq1017, and @puneetdixit200 for the report + and matching fixes. +- Documented the agent and sub-agent stewardship ethos so future automation + preserves human issue intake, careful PR review, and contributor credit. +- Moved the TUI Starlark execpolicy parser and PTY support behind non-OHOS + target dependencies so published OpenHarmony builds no longer pull `nix` 0.28 + through `rustyline` or `portable-pty`. +- Explicit `skills_dir` configuration is now unioned with workspace skill + discovery instead of being shadowed by workspace-local skills, and configured + skills take precedence over global defaults when prompt space is constrained. +- Tool-agent sub-agent routing now inherits the parent session model, or an + explicit tool-agent override, instead of hard-coding `deepseek-v4-flash`; + the fast lane still disables thinking through provider-aware request shaping. +- Dense successful read/search/list tool runs now collapse into a single + expandable transcript row by default, while running, failed, shell, patch, + review, diff, and other risky tool cells remain visible. The setting + `tool_collapse = "compact" | "expanded" | "calm"` controls the behavior. +- Pending-input preview rows now label delivery mode explicitly as steer + pending, rejected steer, or queued follow-up, with wrapped continuation rows + aligned under the label so busy-turn input state is easier to read (#2054). +- Editing a queued follow-up is now an explicit pending-input state. Pressing + `Esc` while editing a queued follow-up restores the original queued message + instead of cancelling the active turn or silently dropping the queued work + (#2054). +- Approval prompts now render prominent command, directory, file, path, or + target rows before falling back to raw JSON params. Shell approvals preserve + long command tails, split common shell chains for review, and show compact + `printf > file` previews while keeping intent summaries visible (#1991, + #2269). +- Sidebar hover details now use row-level metadata for truncated Work, Tasks, + and Agents rows. Mouse hover opens a bordered, wrapping popover with the full + underlying row text, long turn/agent ids, and current sub-agent progress + instead of repeating the already-ellipsized sidebar label (#2694, #2734). +- Sub-agents now preserve checkpoint metadata around long model calls. A + per-step API timeout marks the child as interrupted with a continuable + checkpoint instead of ending as a null failed result, and `agent_eval` can + explicitly continue a live checkpointed interrupted child while normal + completed/failed/cancelled follow-up behavior stays unchanged (#2029). +- Durable task recovery no longer requeues tasks that were `running` when the + previous CodeWhale process exited. On restart those records are marked failed + with a recovery note, and any running tool-call summaries are marked failed + too, so stale shell/task state cannot silently become live work again (#1786). +- Auto-generated project instructions now reuse the bounded Project Context + Pack data instead of running an unbounded summary/tree scan when no + `.codewhale/instructions.md` file exists. The fallback keeps later + top-level folders visible in noisy large workspaces while the dynamic + `` marker remains controlled by its own setting + (#697, #1827). +- Project context loading now uses a bounded process-local content-signature + cache for repeated hot-path loads. The cache covers workspace/parent + instructions, global AGENTS/WHALE fallbacks, repo constitution files, + generated-context targets, trust markers, and trust config paths, and it + stores post-load signatures so auto-generated context deletion/regeneration + stays correct (#2636). +- Configuration docs now show the provider-local `path_suffix` escape hatch + for OpenAI-compatible gateways that accept `/chat/completions` but reject + `/v1/chat/completions`, while making clear that model listing and DeepSeek + beta routes keep their built-in paths (#1874). +- The config crate now carries the v0.9 HarnessPosture data model: + `HarnessPosture`, `HarnessProfile`, and typed posture/compaction/tool/safety + enums. The schema rejects misspelled posture names or unknown profile keys + instead of silently falling back to `custom`; a pure resolver can match + provider/model routes for tests and future status plumbing, while runtime + provider/model posture selection remains a follow-up (#2693, #2741, #2728). + +### Fixed + +- Stream/body decode failures such as `Stream read error: error decoding + response body` are now classified as recoverable network interruptions + instead of generic internal errors, keeping the transcript and triage metadata + aligned with the existing stream retry path (#2847). Thanks + @qamranmushtaq-collab for the Windows/npx DeepSeek report. +- The TUI footer, `/status`, `/mcp` manager, and command-palette MCP entries + now count trusted workspace-local `.codewhale/mcp.json` servers together with + the global MCP config, matching `codewhale mcp list` for merged global + + project setups (#2787). Thanks @yekern for the detailed reproduction. +- AltGr key chords in the composer no longer get swallowed by sidebar shortcuts + on AZERTY and other international layouts, so characters such as `@`, `#`, + `$`, `!`, and `%` can be entered normally (#2863, #2867). Thanks + @ousamabenyounes for the fix and report. +- Sub-agent shell completions now refresh the workspace branch/status chip + immediately, and `/subagents` plus the Agents sidebar show each sub-agent's + current workspace branch when it is running in a child worktree. +- Authentication failures now include redacted request context such as provider, + base URL authority, model, key source, key type, and key fingerprint, making + stale provider, endpoint, or API-key state diagnosable without exposing the + secret (#2665, #2792). Thanks @mvanhorn for the implementation. +- Browser-opening actions now compile on non-desktop targets by delegating the + unsupported-platform error to the shared URL opener instead of hiding the TUI + wrapper behind a narrower macOS/Linux/Windows cfg. Thanks @ci4ic4 for the + NetBSD/pkgsrc packaging report and fix (#2789). +- MCP tool routing now preserves server names that contain underscores. + `parse_prefixed_name` matches the qualified `mcp__` name against + the set of registered server names and prefers the longest match, so tools on + a server like `my_db` are reachable and an overlapping `my` / `my_db` pair + routes correctly. Falls back to the legacy first-underscore split when no + registered server matches (#2744). +- Schema-hydrated deferred tools no longer render as a completed run. The first + use of a deferred tool returns a schema-hydration result instead of executing; + the transcript and sidebar now show "tool loaded — retry required" via a + dedicated hydrated status, so it is no longer indistinguishable from a real + successful execution. A hydrated row also ranks with active work rather than + completed successes (#2648). +- `codewhale sessions` now shows `codewhale resume ` in the footer + instead of the invalid dispatcher command `codewhale --resume ` + (#2758, #2760). +- TUI HTTP clients now install the Rustls ring crypto provider before building + `reqwest` clients, covering engine, runtime API, tool, MCP, config, and skill + download paths. This keeps the no-provider TLS build from panicking during + tests or embedded startup paths that do not enter through the main binary. +- Prompt byte-stability tests now pin their temporary home and skills + environment under the shared test-env lock so global skill directories cannot + perturb deterministic prompt bytes during parallel test runs. + +### Community + +Thanks to **@sximelon** for reporting and fixing the saved-session resume +footer hint (#2758, #2760), **@cyq1017** for the custom +DuckDuckGo-compatible search endpoint, custom completion sound file support, +restore-listing implementation, and pending-input delivery-mode label work +(#2510, #2512, #2513, #2532, #2054), +**@Artenx** for the private-search endpoint report (#2436), +**@LHqweasd** for the Windows custom notification sound request (#2484), +**@wywsoor** for the broader macOS/iTerm rollback UX report (#2494), +**@HUQIANTAO** for the `web_run` lock-splitting work (#2502), turn-metadata +prefix-cache stability work (#2517), and project-context cache direction +(#2636), **@xyuai** for canonical CodeWhale +settings-path migration work (#2730), **@gaord** for the runtime thread +workspace update and completed-thread save APIs (#2640, #2639), +**@shenjackyuanjie** for the +HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634), +**@ousamabenyounes** for the AZERTY AltGr composer shortcut fix (#2863, +#2867), **@reidliu41** for the hotbar action-registry foundation (#2866), and +**@ljm3790865** for the multi-tab core/persistence foundation and broader +collaboration direction (#2864, #2753), +**@aboimpinto** for the direct command-support boundary cleanup in #2871 and +the broader #2851/#2791 command-layer design direction, +**@idling11** for the PlanArtifact direction in Plan mode (#2733), the dense +tool-call transcript collapse/sidebar detail direction (#2738, #2734, #2692, +#2694), and the HarnessPosture config model for provider/model posture (#2741, +#2693), and +**@h3c-hexin** for the tool-agent model inheritance and configured +`skills_dir` fixes (#2736, #2737), **@AresNing** for the turn-end observer hook +work (#2578), and **@tdccccc** for the approval key-detail and shell-preview +work (#1991, #2269). Thanks also to **@qiyuanlicn** for the +checkpoint/resume report that shaped the sub-agent recovery slice (#2029), +**@bevis-wong** for the long-running shell/task liveness report (#1786), +**@shuxiangxuebiancheng** for the third-party OpenAI-compatible path report +(#1874), **@hongqitai** and **@cyq1017** for the follow-up path-suffix PR +review trail (#2508, #2506), **@NASLXTO** and **@wuxixing** for the +large-workspace startup reports (#697, #1827), and **@linzhiqin2003** and +**@merchloubna70-dot** for earlier context-cap and startup-diagnosis work that +shaped this bounded fallback. Thanks also to **@cyq1017** for the MCP +underscore-server-name fix and Xiaomi MiMo pricing (#2747, #2744, #2750, #2731) +and **@puneetdixit200** for independently diagnosing and fixing the same MCP +underscore issue (#2746, #2744), **@mvanhorn** for the hydrated deferred-tool +render fix (#2757, #2648), and **@xyuai** for the Xiaomi MiMo Token Plan region +documentation (#2756, #2735). Additional thanks to **@Implementist** for Plan +prompt scrolling, wrapping, and display-width fixes, **@jrcjrcc** for the +Windows sub-agent completion render-width fix, and **@punkcanyang** for the +original `/init` implementation harvested through #2771/#2745. + ## [0.8.53] - 2026-06-03 ### Added @@ -5411,7 +5850,8 @@ Welcome — and thank you. - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.53...HEAD +[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.9.0...HEAD +[0.9.0]: https://github.com/Hmbown/CodeWhale/compare/v0.8.53...v0.9.0 [0.8.53]: https://github.com/Hmbown/CodeWhale/compare/v0.8.52...v0.8.53 [0.8.52]: https://github.com/Hmbown/CodeWhale/compare/v0.8.51...v0.8.52 [0.8.51]: https://github.com/Hmbown/CodeWhale/compare/v0.8.50...v0.8.51 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ed555b9b..7f8d3c678 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,8 +98,12 @@ When this happens: - If the maintainer copies or adapts your code, the harvested commit also keeps attribution with the original author identity when possible: either by preserving the commit author on a cherry-pick or by adding a - `Co-authored-by: Name ` trailer from the original PR commit. This is + `Co-authored-by: Name ` trailer. This is what lets GitHub's contribution surfaces recognize more than prose credit. + Maintainers should use `.github/AUTHOR_MAP`, or run + `gh api users/ --jq '"\(.id)+\(.login)@users.noreply.github.com"'`, + rather than copying raw, `.local`, or old-style noreply emails from a + contributor's machine. - The `CHANGELOG.md` entry for the next release credits you by handle. - The auto-close workflow closes your PR with a templated thank-you and a link to the commit on `main`. @@ -172,16 +176,24 @@ Validation: CodeWhale uses a maintainer-managed contribution gate for the community front door. Maintainers and collaborators bypass this gate automatically. The gate workflows default to dry-run / comment-only mode so maintainers can observe the -signal before closing contributor work. In dry-run mode, unapproved external -issues and pull requests receive a short thank-you / CONTRIBUTING pointer and -remain open. +signal before changing contributor flow. -When maintainers are ready to enforce the gate, set -`CONTRIBUTION_GATE_MODE: enforce` in the PR and issue gate workflows. In enforce -mode, external contributors must be listed in -`.github/APPROVED_CONTRIBUTORS` before their issues or pull requests remain -open. Before enabling enforcement, seed the allowlist broadly enough for active -external contributors who should not be interrupted by the rollout. +The maintainer posture is documented in +[docs/AGENT_ETHOS.md](docs/AGENT_ETHOS.md): automation should reduce load while +keeping good-faith contributors seen, credited, and able to keep helping. + +Issues are never auto-closed by the contribution gate. Unapproved external +issues receive a short welcome note that asks for reproduction details and then +remain open for maintainer triage. CodeWhale depends on real edge cases from +real users, so issue intake should stay warm and open. + +Pull requests are different because they can touch code, CI, release plumbing, +auth, sandboxing, provider policy, and other trust-boundary surfaces. The PR +gate can be switched from dry-run to enforcement when maintainers decide they +need that safety control, but it should be treated as a review-load control, +not a judgment on contributor quality. Before enabling PR enforcement, seed the +allowlist broadly enough for active external contributors who should not be +interrupted by the rollout. The allowlist is scoped: @@ -198,11 +210,10 @@ discussion. Approvals do not edit `main` directly. The approval workflow opens a small allowlist update PR so the new entry is reviewable before it takes effect. -If the gate fires on a good contributor incorrectly, use the same approval flow -to restore them: comment `/lgtm` or `/lgtmi`, merge the generated allowlist PR, -then reopen the affected issue or pull request. If GitHub will not allow the -closed item to be reopened, ask the contributor to resubmit after the allowlist -PR is merged. +If the PR gate fires on a good contributor incorrectly, use the same approval +flow to restore them: comment `/lgtm`, merge the generated allowlist PR, then +reopen the affected pull request. If GitHub will not allow the closed PR to be +reopened, ask the contributor to resubmit after the allowlist PR is merged. ## Agent-Assisted Improvements @@ -213,6 +224,11 @@ from a fresh fork or branch, let the agent find exactly one small friction point and stop after one patch. DeepSeek V4 Pro is the first-class path for this loop today, but the review shape matters more than the provider. +Agents and maintainers should follow the stewardship posture in +[docs/AGENT_ETHOS.md](docs/AGENT_ETHOS.md): use automation for evidence, +verification, and narrow patches while keeping the final community decision +human-reviewed. + The useful output is not "ideas for improvement." The useful output is a specific reproduction, a minimal diff, focused checks, and a PR description that explains the trade-off. Do not use an agent to touch auth, credentials, sandbox diff --git a/Cargo.lock b/Cargo.lock index 139ac2a7a..7ca43f2d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,7 +97,7 @@ checksum = "fe233a377643e0fc1a56421d7c90acdec45c291b30345eb9f08e8d0ddce5a4ab" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -322,7 +322,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -362,7 +362,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -379,7 +379,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -403,28 +403,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-lc-rs" -version = "1.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "axum" version = "0.8.8" @@ -527,9 +505,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" [[package]] name = "block-buffer" @@ -669,8 +647,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -748,6 +724,7 @@ dependencies = [ "anstyle", "clap_lex", "strsim 0.11.1", + "terminal_size", ] [[package]] @@ -768,7 +745,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -786,15 +763,6 @@ dependencies = [ "error-code", ] -[[package]] -name = "cmake" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" -dependencies = [ - "cc", -] - [[package]] name = "cmp_any" version = "0.8.1" @@ -803,7 +771,7 @@ checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" [[package]] name = "codewhale-agent" -version = "0.8.53" +version = "0.9.0" dependencies = [ "codewhale-config", "serde", @@ -811,7 +779,7 @@ dependencies = [ [[package]] name = "codewhale-app-server" -version = "0.8.53" +version = "0.9.0" dependencies = [ "anyhow", "axum", @@ -825,18 +793,20 @@ dependencies = [ "codewhale-protocol", "codewhale-state", "codewhale-tools", + "rustls", "serde", "serde_json", "tempfile", "tokio", "tower", "tower-http", + "tracing", "uuid", ] [[package]] name = "codewhale-cli" -version = "0.8.53" +version = "0.9.0" dependencies = [ "anyhow", "chrono", @@ -852,6 +822,7 @@ dependencies = [ "codewhale-state", "dirs", "reqwest", + "rustls", "semver", "serde", "serde_json", @@ -863,7 +834,7 @@ dependencies = [ [[package]] name = "codewhale-config" -version = "0.8.53" +version = "0.9.0" dependencies = [ "anyhow", "codewhale-execpolicy", @@ -877,7 +848,7 @@ dependencies = [ [[package]] name = "codewhale-core" -version = "0.8.53" +version = "0.9.0" dependencies = [ "anyhow", "chrono", @@ -890,12 +861,13 @@ dependencies = [ "codewhale-state", "codewhale-tools", "serde_json", + "tracing", "uuid", ] [[package]] name = "codewhale-execpolicy" -version = "0.8.53" +version = "0.9.0" dependencies = [ "anyhow", "codewhale-protocol", @@ -904,7 +876,7 @@ dependencies = [ [[package]] name = "codewhale-hooks" -version = "0.8.53" +version = "0.9.0" dependencies = [ "anyhow", "async-trait", @@ -918,7 +890,7 @@ dependencies = [ [[package]] name = "codewhale-mcp" -version = "0.8.53" +version = "0.9.0" dependencies = [ "anyhow", "serde", @@ -927,7 +899,7 @@ dependencies = [ [[package]] name = "codewhale-protocol" -version = "0.8.53" +version = "0.9.0" dependencies = [ "serde", "serde_json", @@ -935,10 +907,11 @@ dependencies = [ [[package]] name = "codewhale-release" -version = "0.8.53" +version = "0.9.0" dependencies = [ "anyhow", "reqwest", + "rustls", "semver", "serde", "serde_json", @@ -946,7 +919,7 @@ dependencies = [ [[package]] name = "codewhale-secrets" -version = "0.8.53" +version = "0.9.0" dependencies = [ "dirs", "keyring", @@ -959,7 +932,7 @@ dependencies = [ [[package]] name = "codewhale-state" -version = "0.8.53" +version = "0.9.0" dependencies = [ "anyhow", "chrono", @@ -971,7 +944,7 @@ dependencies = [ [[package]] name = "codewhale-tools" -version = "0.8.53" +version = "0.9.0" dependencies = [ "anyhow", "async-trait", @@ -985,7 +958,7 @@ dependencies = [ [[package]] name = "codewhale-tui" -version = "0.8.53" +version = "0.9.0" dependencies = [ "anyhow", "arboard", @@ -1003,6 +976,7 @@ dependencies = [ "codewhale-tools", "colored", "crossterm 0.28.1", + "cucumber", "dirs", "dotenvy", "fd-lock", @@ -1011,9 +985,11 @@ dependencies = [ "ignore", "image", "libc", + "lru", "multimap", "objc2", "objc2-foundation", + "parking_lot", "pdf-extract", "portable-pty", "pretty_assertions", @@ -1021,7 +997,7 @@ dependencies = [ "ratatui", "regex", "reqwest", - "rustyline 15.0.0", + "rustls", "schemars", "schemaui", "serde", @@ -1043,7 +1019,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width 0.2.2", "uuid", "vt100", "wait-timeout", @@ -1054,7 +1030,19 @@ dependencies = [ [[package]] name = "codewhale-tui-core" -version = "0.8.53" +version = "0.9.0" + +[[package]] +name = "codewhale-whaleflow" +version = "0.9.0" +dependencies = [ + "anyhow", + "serde", + "serde_json", + "sha2 0.10.9", + "starlark", + "thiserror 2.0.18", +] [[package]] name = "colorchoice" @@ -1122,6 +1110,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + [[package]] name = "convert_case" version = "0.6.0" @@ -1233,7 +1233,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "crossterm_winapi", "mio", "parking_lot", @@ -1249,7 +1249,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "crossterm_winapi", "derive_more 2.1.1", "document-features", @@ -1315,6 +1315,63 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "cucumber" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a87e18d925b19ebe0fd47ea45316abd216d81ec0879c2448c3f9a0e9da62be" +dependencies = [ + "anyhow", + "clap", + "console", + "cucumber-codegen", + "cucumber-expressions", + "derive_more 2.1.1", + "either", + "futures", + "gherkin", + "globwalk", + "humantime", + "inventory", + "itertools 0.14.0", + "linked-hash-map", + "pin-project", + "ref-cast", + "regex", + "sealed", + "smart-default", +] + +[[package]] +name = "cucumber-codegen" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2fc8a8bbb73af3230db699e8690c5c786655f75eb89e5f18d76055fa1a9a4d" +dependencies = [ + "cucumber-expressions", + "inflections", + "itertools 0.14.0", + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", + "synthez", +] + +[[package]] +name = "cucumber-expressions" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6401038de3af44fe74e6fccdb8a5b7db7ba418f480c8e9ad584c6f65c05a27a6" +dependencies = [ + "derive_more 2.1.1", + "either", + "nom 8.0.0", + "nom_locate", + "regex", + "regex-syntax 0.8.8", +] + [[package]] name = "darling" version = "0.23.0" @@ -1335,7 +1392,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1346,7 +1403,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1466,7 +1523,7 @@ dependencies = [ "convert_case 0.6.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "unicode-xid", ] @@ -1480,7 +1537,8 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.114", + "syn 2.0.117", + "unicode-xid", ] [[package]] @@ -1558,7 +1616,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "objc2", ] @@ -1580,7 +1638,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1604,12 +1662,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "dupe" version = "0.9.1" @@ -1627,7 +1679,7 @@ checksum = "83e195b4945e88836d826124af44fdcb262ec01ef94d44f14f4fb5103f19892a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1653,13 +1705,19 @@ dependencies = [ [[package]] name = "ena" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d248bdd43ce613d87415282f69b9bb99d947d290b10962dd6c56233312c2ad5" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" dependencies = [ "log", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1699,7 +1757,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1828,7 +1886,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1943,17 +2001,11 @@ dependencies = [ "num", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -1966,9 +2018,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -1976,15 +2028,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -1993,9 +2045,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -2012,32 +2064,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -2047,7 +2099,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -2087,10 +2138,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -2107,6 +2156,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gherkin" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2c0d8c632f8a251ce9a8198079b1022adc586ff4e3d33e18debd40eb463b31" +dependencies = [ + "heck", + "peg", + "quote", + "serde", + "serde_json", + "syn 2.0.117", + "textwrap 0.16.2", + "thiserror 2.0.18", + "typed-builder", +] + [[package]] name = "globset" version = "0.4.18" @@ -2120,6 +2186,17 @@ dependencies = [ "regex-syntax 0.8.8", ] +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags 2.12.1", + "ignore", + "walkdir", +] + [[package]] name = "h2" version = "0.4.13" @@ -2270,6 +2347,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "hybrid-array" version = "0.4.11" @@ -2550,6 +2633,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inflections" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" + [[package]] name = "inout" version = "0.1.4" @@ -2570,14 +2659,14 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "inventory" -version = "0.3.21" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" dependencies = [ "rustversion", ] @@ -2670,16 +2759,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.83" @@ -2790,9 +2869,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libdbus-sys" @@ -2809,7 +2888,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "libc", "redox_syscall 0.7.4", ] @@ -2831,16 +2910,22 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-keyutils" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "libc", ] @@ -2918,7 +3003,7 @@ dependencies = [ "itoa", "log", "md-5", - "nom", + "nom 7.1.3", "rangemap", "time", "weezl", @@ -2933,12 +3018,6 @@ dependencies = [ "hashbrown 0.16.1", ] -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "lsp-types" version = "0.94.1" @@ -2995,9 +3074,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memmem" @@ -3113,7 +3192,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -3125,7 +3204,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -3142,6 +3221,26 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom 8.0.0", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3204,7 +3303,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3281,7 +3380,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "objc2", "objc2-core-graphics", "objc2-foundation", @@ -3293,7 +3392,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "dispatch2", "objc2", ] @@ -3304,7 +3403,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "dispatch2", "objc2", "objc2-core-foundation", @@ -3323,7 +3422,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "objc2", "objc2-core-foundation", ] @@ -3334,7 +3433,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "objc2", "objc2-core-foundation", ] @@ -3438,6 +3537,33 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "peg" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f76678828272f177ac33b7e2ac2e3e73cc6c1cd1e3e387928aa69562fa51367" +dependencies = [ + "peg-macros", + "peg-runtime", +] + +[[package]] +name = "peg-macros" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "636d60acf97633e48d266d7415a9355d4389cea327a193f87df395d88cd2b14d" +dependencies = [ + "peg-runtime", + "proc-macro2", + "quote", +] + +[[package]] +name = "peg-runtime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555b1514d2d99d78150d3c799d4c357a3e2c2a8062cd108e93a06d9057629c5" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3474,7 +3600,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3524,7 +3650,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.6", + "rand", ] [[package]] @@ -3537,7 +3663,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3549,6 +3675,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -3584,7 +3730,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "crc32fast", "fdeflate", "flate2", @@ -3695,9 +3841,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -3723,67 +3869,11 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases 0.2.1", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.3", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases 0.2.1", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" -version = "1.0.43" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -3811,18 +3901,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "rand_chacha", + "rand_core", ] [[package]] @@ -3832,17 +3912,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -3854,15 +3924,6 @@ dependencies = [ "getrandom 0.2.16", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rangemap" version = "1.7.1" @@ -3889,7 +3950,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "compact_str", "hashbrown 0.16.1", "indoc", @@ -3900,7 +3961,7 @@ dependencies = [ "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] @@ -3941,7 +4002,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "hashbrown 0.16.1", "indoc", "instability", @@ -3951,7 +4012,7 @@ dependencies = [ "strum", "time", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] @@ -3960,7 +4021,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", ] [[package]] @@ -3969,7 +4030,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", ] [[package]] @@ -4011,7 +4072,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4089,7 +4150,6 @@ dependencies = [ "mime_guess", "percent-encoding", "pin-project-lite", - "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -4130,7 +4190,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -4138,12 +4198,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - [[package]] name = "rustc_version" version = "0.4.1" @@ -4159,7 +4213,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -4172,7 +4226,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "errno", "libc", "linux-raw-sys 0.11.0", @@ -4185,8 +4239,8 @@ version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ - "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -4211,7 +4265,6 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ - "web-time", "zeroize", ] @@ -4248,7 +4301,6 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4266,7 +4318,7 @@ version = "14.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "cfg-if", "clipboard-win", "fd-lock", @@ -4282,28 +4334,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rustyline" -version = "15.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1e066dc922e513bda599c6ccb5f3bb2b0ea5870a579448f2622993f0a9a2f" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "clipboard-win", - "fd-lock", - "home", - "libc", - "log", - "memchr", - "nix 0.29.0", - "radix_trie", - "unicode-segmentation", - "unicode-width 0.2.0", - "utf8parse", - "windows-sys 0.59.0", -] - [[package]] name = "ryu" version = "1.0.22" @@ -4392,7 +4422,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4415,7 +4445,7 @@ dependencies = [ "sha2 0.11.0", "tokio", "toml 1.0.6+spec-1.1.0", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] @@ -4424,6 +4454,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sealed" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "secret-service" version = "4.0.0" @@ -4437,7 +4478,7 @@ dependencies = [ "hkdf", "num", "once_cell", - "rand 0.8.6", + "rand", "serde", "sha2 0.10.9", "zbus", @@ -4449,7 +4490,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -4462,7 +4503,7 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4512,7 +4553,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4523,7 +4564,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4559,7 +4600,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4728,6 +4769,23 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smart-default" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.6.1" @@ -4772,7 +4830,7 @@ dependencies = [ "paste", "ref-cast", "regex", - "rustyline 14.0.0", + "rustyline", "serde", "serde_json", "starlark_derive", @@ -4780,7 +4838,7 @@ dependencies = [ "starlark_syntax", "static_assertions", "strsim 0.10.0", - "textwrap", + "textwrap 0.11.0", "thiserror 1.0.69", ] @@ -4793,7 +4851,7 @@ dependencies = [ "dupe", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4882,7 +4940,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4904,9 +4962,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -4930,7 +4988,40 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", +] + +[[package]] +name = "synthez" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8a928f38f1bc873f28e0d2ba8298ad65374a6ac2241dabd297271531a736cd" +dependencies = [ + "syn 2.0.117", + "synthez-codegen", + "synthez-core", +] + +[[package]] +name = "synthez-codegen" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fb83b8df4238e11746984dfb3819b155cd270de0e25847f45abad56b3671047" +dependencies = [ + "syn 2.0.117", + "synthez-core", +] + +[[package]] +name = "synthez-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "906fba967105d822e7c7ed60477b5e76116724d33de68a585681fb253fc30d5c" +dependencies = [ + "proc-macro2", + "quote", + "sealed", + "syn 2.0.117", ] [[package]] @@ -4968,6 +5059,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix 1.1.3", + "windows-sys 0.61.2", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -4975,7 +5076,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" dependencies = [ "fnv", - "nom", + "nom 7.1.3", "phf", "phf_codegen", ] @@ -4997,7 +5098,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.10.0", + "bitflags 2.12.1", "fancy-regex 0.11.0", "filedescriptor", "finl_unicode", @@ -5040,6 +5141,17 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width 0.2.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -5066,7 +5178,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5077,7 +5189,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5207,7 +5319,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5331,7 +5443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", - "bitflags 2.10.0", + "bitflags 2.12.1", "bytes", "futures-core", "futures-util", @@ -5391,7 +5503,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5448,6 +5560,26 @@ dependencies = [ "pom", ] +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "typenum" version = "1.20.0" @@ -5489,6 +5621,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-normalization" version = "0.1.25" @@ -5512,7 +5650,7 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools 0.14.0", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] @@ -5523,9 +5661,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" @@ -5740,7 +5878,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -5776,16 +5914,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "webpki-root-certs" version = "1.0.6" @@ -5970,7 +6098,7 @@ checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5981,7 +6109,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5992,7 +6120,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -6405,7 +6533,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -6427,7 +6555,7 @@ dependencies = [ "hex", "nix 0.29.0", "ordered-stream", - "rand 0.8.6", + "rand", "serde", "serde_repr", "sha1", @@ -6450,7 +6578,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "zvariant_utils", ] @@ -6482,7 +6610,7 @@ checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -6502,7 +6630,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -6523,7 +6651,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -6556,7 +6684,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -6602,7 +6730,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "zvariant_utils", ] @@ -6614,5 +6742,5 @@ checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] diff --git a/Cargo.toml b/Cargo.toml index 32f949398..7af939590 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,12 +15,13 @@ members = [ "crates/tools", "crates/tui", "crates/tui-core", + "crates/whaleflow", ] default-members = ["crates/cli", "crates/app-server", "crates/tui"] resolver = "2" [workspace.package] -version = "0.8.53" +version = "0.9.0" 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 @@ -38,7 +39,8 @@ 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", "socks"] } +reqwest = { version = "0.13.1", default-features = false, features = ["json", "rustls-no-provider", "socks"] } +rustls = { version = "0.23.36", default-features = false, features = ["ring", "std", "tls12"] } rusqlite = { version = "0.32.1", features = ["bundled"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" diff --git a/README.ja-JP.md b/README.ja-JP.md index 92fdb3c3b..937069f6e 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -143,6 +143,8 @@ codewhale doctor # セットアップを検証 `npm i -g codewhale` は v0.8.8 以降、glibc ベースの ARM64 Linux で動作します。[Releases ページ](https://github.com/Hmbown/CodeWhale/releases) からビルド済みバイナリをダウンロードし、`PATH` 上に並べて配置することもできます。 +HarmonyOS PC と OpenHarmony クロスビルドの設定は [docs/HarmonyOS.md](docs/HarmonyOS.md) を参照してください。 + ### 中国 / ミラーフレンドリーなインストール 中国本土から GitHub または npm のダウンロードが遅い場合は、Cargo レジストリのミラーを利用してください: diff --git a/README.md b/README.md index 9b7a60ace..3f06b3b20 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,102 @@ # 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. +> DeepSeek-first terminal coding agent with a durable harness: approval-gated +> local edits, sub-agents, provider/model routing, live verification, rollback, +> relay/continuity handoffs, and a v0.9 track for typed WhaleFlow workflows. [简体中文 README](README.zh-CN.md) [日本語 README](README.ja-JP.md) [Tiếng Việt README](README.vi.md) +[![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 CodeWhale Does + +CodeWhale is a terminal-native coding harness for agentic model work. It gives +the model a durable prompt constitution, a typed tool surface, approval gates, +side-git rollback, LSP feedback after edits, cost/cache telemetry, and +concurrent sub-agents that can investigate or implement without blocking the +parent turn. + +It is DeepSeek-first, not DeepSeek-only. The default path targets DeepSeek V4, +while provider routes such as OpenRouter, NVIDIA NIM, Arcee, Xiaomi MiMo, +SiliconFlow, Fireworks, OpenAI-compatible gateways, self-hosted SGLang/vLLM, and +Hugging Face stay explicit. Provider, model, base URL, and credentials are +separate choices so direct-provider APIs do not get blurred with OpenRouter +aliases. + +The product goal is practical continuity. A long CodeWhale task should survive +model routing, compaction, shell noise, branch experiments, contributor review, +and a fresh maintainer session without losing the reason the work started or +who helped move it forward. + +## Active v0.9 Track + +v0.9.0 is not released yet. The current branch is a stewardship lane for making +long-running CodeWhale work easier to continue, review, and hand off without +turning the README into release notes. + +The v0.9 track keeps the same DeepSeek-first harness and adds work in these +areas: + +| Track | What is changing | +| --- | --- | +| Relay and continuity | `/relay`, fork-state handoff, and rich PlanArtifact context preserve the goal, why it matters, evidence, constraints, blockers, changed files, verification state, and the next action. | +| Transcript calmness | Dense read/search/list-style tool runs can collapse into expandable groups, while failures, running work, shell commands, writes, diffs, plans, and reviews stay visible. | +| Runtime sessions and workspaces | Branch work extends session/thread runtime APIs, including workspace-aware thread updates, completed-thread session saves, and safer guards around active turns. Treat this as v0.9-track capability until the release ships. | +| Sub-agent recovery | Live per-step timeout recovery can preserve checkpoint metadata and let `agent_eval { continue: true }` resume an interrupted child in the same runtime. Cold-restart continuation is still a follow-up; persisted child tasks are not rehydrated yet. | +| Project context stability | Bounded project-context packs and generated instructions keep large/noisy repositories from turning the first turn into an unbounded filesystem walk. | +| HarmonyOS / OHOS | The lane carries safe OpenHarmony setup, OHOS platform guards, self-update disablement on OHOS, and target gating for PTY and Starlark execpolicy paths. Full OHOS target builds still require a host with the OpenHarmony native SDK configured. | +| Nix and Starlark compatibility | Dependency stewardship keeps OHOS builds from pulling incompatible Nix-chain crates through PTY or Starlark paths where those features are gated. | +| HarnessProfile | The branch carries the typed `HarnessPosture` / `HarnessProfile` config data model, strict schema validation, and a documented [profile cutline](docs/HARNESS_PROFILE_CUTLINE.md). Provider/model posture resolution, prompt/tool/runtime behavior, telemetry, and status display remain follow-up work. | +| Contributor stewardship | Harvested PRs stay credited, contributor identity mapping is machine-readable, and community gates remain dry-run and human-toned while the branch is reviewed. | +| WhaleFlow | Typed branch/leaf workflows, deterministic replay, pod-style workflow monitoring, provider/model posture, and evidence-backed profile evolution remain the larger v0.9 workbench goal. | + +The current release acceptance matrix lives in +[docs/V0_9_0_RELEASE_ACCEPTANCE.md](docs/V0_9_0_RELEASE_ACCEPTANCE.md), with +the HarnessProfile runtime boundary documented in +[docs/HARNESS_PROFILE_CUTLINE.md](docs/HARNESS_PROFILE_CUTLINE.md). + +## Release Status + +The latest published release line is still separate from the v0.9 integration +branch. v0.9.0 work in this README describes the current integration track, not +a published release artifact. Release-specific detail belongs in +[CHANGELOG.md](CHANGELOG.md); this README summarizes the current user-facing +surface and links to deeper docs. + +Release channels can lag each other. Before making release claims, verify the +intended surface directly: GitHub Releases and checksums, npm `codewhale`, +Cargo crates, Docker/GHCR images, CNB mirrors, and any legacy Homebrew formula. +No tag, GitHub Release, npm/Cargo publish, Docker publish, or release artifact +push should happen without explicit maintainer approval. + +## Quickstart + +```bash +npm install -g codewhale +codewhale --version +codewhale --model auto +``` + +On first launch, CodeWhale prompts for a DeepSeek API key and saves it to +`~/.codewhale/config.toml`; the legacy `~/.deepseek/config.toml` path is still +read for compatibility. You can also set credentials directly: + +```bash +codewhale auth set --provider deepseek +codewhale auth status +codewhale doctor +``` + +Use `/provider`, `/model`, `/config`, `/statusline`, `/skills`, and `/restore` +inside the TUI. Prefix a composer line with `!` to run a shell command through +the normal approval and sandbox path, for example `! cargo test -p codewhale-tui`. ## Install @@ -67,177 +158,131 @@ 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? - -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. - -CodeWhale is that harness, built around DeepSeek V4 and guided by three ideas: - -| 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. | - -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 tiers 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. - -Six Articles define the model's identity, duties, and agency (Article VII -is the hierarchy itself): a verification mandate (Article V — every action -leaves evidence, never declare success on faith), a coordination legacy -(Article VI — leave the workspace cleaner and the handoff truthful for the -next intelligence), and a primacy-of-truth clause (Article II — -non-negotiable; not even a user request may override the duty of truth). - -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. +`codewhale update --proxy https://localhost:7897` routes update checks and +downloads through a proxy. --- -## The Harness +## Harness Model -`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. +A model answers a question. An agent finishes a task. The difference is the +harness: the rules, tools, evidence, and feedback that keep the model oriented +when user intent, repo instructions, tool output, stale memory, and prior +handoffs all compete inside one turn. -See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full walkthrough. +CodeWhale's harness has four practical parts: -### Sub-agents: Concurrent Background Execution +| Part | What it does | +| --- | --- | +| Prompt constitution | `crates/tui/src/prompts/base.md` gives the model a stable authority hierarchy: live user intent beats stale instructions, live tool output beats assumptions, and verification beats confidence. | +| Typed tool surface | Shell, file, git, web, MCP, RLM, image, and sub-agent tools are registered with explicit schemas, visibility rules, and compatibility aliases. | +| Runtime evidence loop | Side-git snapshots, LSP diagnostics, command output, cost/cache accounting, and task state are fed back into the transcript instead of hidden behind the UI. | +| Approval and sandbox posture | Plan is read-only, Agent uses approval gates, and YOLO auto-approves in trusted workspaces. macOS Seatbelt is enforced; Linux Landlock is detected but not yet enforced; Windows sandboxing is not advertised. | -CodeWhale can dispatch multiple sub-agents that run in parallel — like a concurrent task queue: +### Relay And Continuity -- **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 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. +Relay is intentional compaction for human and agent handoff. Use `/relay` before +a long break, a fresh thread, a fork, or a handoff to another agent. It keeps the +important story small: the objective, why the work is being done, current state, +changed files, evidence checked, constraints, blockers, and the next concrete +action. -See [docs/SUBAGENTS.md](docs/SUBAGENTS.md) for the full sub-agent reference. +Automatic compaction protects context windows. Relay protects continuity. In +the v0.9 track, rich PlanArtifact fields feed the transcript card, Plan-mode +confirmation, `/relay`, fork-state handoff, and saved-session replay so the +plan, the evidence, and the next step do not become separate stories. ---- +`codewhale` is the dispatcher CLI. `codewhale-tui` is the companion runtime +binary it launches for interactive sessions. The TUI talks to an async engine, +an OpenAI-compatible streaming client, the tool registry, the durable task +queue, the LSP subsystem, and optional HTTP/SSE or ACP servers. See +[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full walkthrough. -## Quickstart +### Auto Model Routing -```bash -npm install -g codewhale -codewhale --version -codewhale --model auto -``` +`--model auto` is the default. Before the real turn is sent, CodeWhale makes a +small `deepseek-v4-flash` routing call with thinking off. That local router +selects the concrete model and thinking level for the real request: -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 `~/.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: +- Model: `deepseek-v4-flash` or `deepseek-v4-pro` +- Thinking: `off`, `high`, or `max` -```bash -codewhale auth set --provider deepseek # saves to ~/.codewhale/config.toml -codewhale auth status # shows the active credential source +The upstream API never receives `model: "auto"`; it receives the concrete route +chosen for that turn. Use a fixed model or thinking level for repeatable +benchmarking, strict cost ceilings, or exact provider/model mapping. -export DEEPSEEK_API_KEY="YOUR_KEY" # env var alternative; use ~/.zshenv for non-interactive shells -codewhale +### Sub-agents -codewhale doctor # verify setup -``` +Sub-agents run concurrently in the background. `agent_open` returns immediately; +the child receives its own context and tool registry, then reports back with a +completion sentinel and a human-readable summary. The full child transcript +stays behind a bounded handle that the parent can inspect through `agent_eval`. -If `codewhale doctor` says the rejected key came from `DEEPSEEK_API_KEY`, remove -the stale export from your shell startup file, open a fresh shell, or run -`codewhale auth set --provider deepseek`. Use `codewhale auth status` to see the -config, keyring, and env-var source state without printing the key. Saved config -keys take precedence over the keyring and environment and are easier to rotate. +Default concurrency is 10 and configurable up to 20. See +[docs/SUBAGENTS.md](docs/SUBAGENTS.md) for role taxonomy, lifecycle, wait/eval +tools, and transcript-handle details. -> To rotate or remove a saved key: `codewhale auth clear --provider deepseek`. +## Provider Routes -### Tencent Cloud / CNB Remote-First Path +For the full provider registry, model IDs, auth variables, base URLs, and +capability boundaries, see [docs/PROVIDERS.md](docs/PROVIDERS.md). -For an always-on workspace you can control from a phone, use the Tencent-native -path: CNB mirror/source, Tencent Lighthouse HK, a Feishu/Lark long-connection -bridge, and optional EdgeOne for a deliberate public HTTPS edge. The runtime API -stays bound to localhost; EdgeOne is not used to expose `/v1/*`. +Provider and model are deliberately separate choices. `provider` is the route, +account, endpoint, and credential source; `model` is the model ID on that route. +That distinction matters when the same model family appears through direct APIs +and OpenRouter aliases. -Start with [docs/TENCENT_CLOUD_REMOTE_FIRST.md](docs/TENCENT_CLOUD_REMOTE_FIRST.md), -then use [docs/TENCENT_LIGHTHOUSE_HK.md](docs/TENCENT_LIGHTHOUSE_HK.md) for the -server runbook. +| Provider | Typical model IDs | Notes | +| --- | --- | --- | +| `deepseek` | `deepseek-v4-pro`, `deepseek-v4-flash` | Default direct DeepSeek route. | +| `openrouter` | `deepseek/deepseek-v4-pro`, `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3` | OpenRouter route; keep these IDs distinct from direct provider IDs. | +| `arcee` | `trinity-large-thinking`, `trinity-large-preview`, `trinity-mini` | Direct Arcee API at `https://api.arcee.ai/api/v1`. | +| `xiaomi-mimo` | `mimo-v2.5-pro`, `mimo-v2.5`, TTS IDs | Token Plan keys (`tp-...`) use `api-key` auth and default to the Token Plan endpoint; pay-as-you-go keys can set the MiMo API endpoint explicitly. | +| `nvidia-nim` | `deepseek-ai/deepseek-v4-pro` | Uses NVIDIA account terms and model IDs. | +| `siliconflow` / `siliconflow-CN` | `deepseek-ai/DeepSeek-V4-Pro` | SiliconFlow global and China routes. | +| `fireworks` | `accounts/fireworks/models/deepseek-v4-pro` | Fireworks route. | +| `openai` | Your gateway's model ID | Generic OpenAI-compatible endpoint. | +| `huggingface` | `deepseek-ai/DeepSeek-V4-Pro` | Hugging Face router route. | +| `sglang`, `vllm`, `ollama` | Local model IDs/tags | Self-hosted routes. | -### Auto Mode +```bash +codewhale auth set --provider openrouter --api-key "YOUR_OPENROUTER_API_KEY" +codewhale --provider openrouter --model deepseek/deepseek-v4-pro -Use `codewhale --model auto` or `/model auto` when you want codewhale to decide how much model and reasoning power a turn needs. +codewhale auth set --provider arcee --api-key "YOUR_ARCEE_API_KEY" +codewhale --provider arcee --model trinity-large-thinking -Auto mode controls two settings together: +codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_KEY" +codewhale --provider xiaomi-mimo --model mimo-v2.5-pro +codewhale --provider xiaomi-mimo speech "Hello from MiMo" --model tts -o hello.wav +XIAOMI_MIMO_TOKEN_PLAN_API_KEY="tp-..." XIAOMI_MIMO_MODE="token-plan-sgp" \ + codewhale --provider xiaomi-mimo --model mimo-v2.5-pro -- Model: `deepseek-v4-flash` or `deepseek-v4-pro` -- Thinking: `off`, `high`, or `max` +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 -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. +SGLANG_BASE_URL="http://localhost:30000/v1" \ + codewhale --provider sglang --model deepseek-v4-flash +``` -`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. +Inside the TUI, `/provider` opens the provider picker and `/model` opens the +model/thinking picker. `/models` fetches live API model lists when the active +provider supports listing. -Use a fixed model or fixed thinking level when you want repeatable benchmarking, a strict cost ceiling, or a specific provider/model mapping. +## Platform Notes -### Linux ARM64 (Raspberry Pi, Asahi, Graviton, HarmonyOS PC) +Prebuilt binary pairs and platform archives are published for Linux x64, Linux +ARM64, macOS x64, macOS ARM64, and Windows x64. For other targets, see +[docs/INSTALL.md](docs/INSTALL.md). -`npm i -g codewhale` works on glibc-based ARM64 Linux from v0.8.8 onward. You can also download prebuilt binaries from the [Releases page](https://github.com/Hmbown/CodeWhale/releases) and place them side by side on your `PATH`. +For HarmonyOS PC and OpenHarmony cross-build setup, see [docs/HarmonyOS.md](docs/HarmonyOS.md). ### China / Mirror-friendly Installation -If GitHub or npm downloads are slow from mainland China, use a Cargo registry mirror: +If GitHub or npm downloads are slow from mainland China, use +`npm install -g codewhale --registry=https://registry.npmmirror.com`, download +from GitHub Releases, or configure a Cargo registry mirror: ```toml # ~/.cargo/config.toml @@ -248,37 +293,38 @@ replace-with = "tuna" registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/" ``` -Then install both binaries (the dispatcher delegates to the TUI at runtime): +Then install both binaries: ```bash -cargo install codewhale-cli --locked # provides `codewhale` -cargo install codewhale-tui --locked # provides `codewhale-tui` +cargo install codewhale-cli --locked +cargo install codewhale-tui --locked codewhale --version ``` -Prebuilt binaries can also be downloaded from [GitHub Releases](https://github.com/Hmbown/CodeWhale/releases). Use `DEEPSEEK_TUI_RELEASE_BASE_URL` for mirrored release assets. +Use `DEEPSEEK_TUI_RELEASE_BASE_URL` for mirrored release assets. -### Windows (Scoop) +### Windows -[Scoop](https://scoop.sh) is a Windows package manager. The `codewhale` package is listed -in Scoop's main bucket, but that manifest updates independently and can lag the -GitHub/npm/Cargo release. Run `scoop update` first, then verify the installed -version with `codewhale --version`: +The Scoop `codewhale` manifest can lag GitHub/npm/Cargo releases. Run +`scoop update` first, then verify with `codewhale --version`. Use npm or direct +GitHub release downloads when you need the newest release immediately. -```bash -scoop update -scoop install codewhale -codewhale --version -``` +### Remote-first Workspaces -Use npm or direct GitHub release downloads when you need the newest release -before Scoop's manifest catches up. +For an always-on workspace you can control from a phone, use the Tencent-native +path: CNB mirror/source, Tencent Lighthouse HK, a Feishu/Lark long-connection +bridge, and optional EdgeOne for a deliberate public HTTPS edge. The runtime API +stays bound to localhost; EdgeOne is not used to expose `/v1/*`. +Start with [docs/TENCENT_CLOUD_REMOTE_FIRST.md](docs/TENCENT_CLOUD_REMOTE_FIRST.md), +then use [docs/TENCENT_LIGHTHOUSE_HK.md](docs/TENCENT_LIGHTHOUSE_HK.md) for the +server runbook.
Install from source -Works on any Tier-1 Rust target — including musl, riscv64, FreeBSD, and older ARM64 distros. +Works on any Tier-1 Rust target including musl, riscv64, FreeBSD, and older +ARM64 distros. ```bash # Linux build deps (Debian/Ubuntu/RHEL): @@ -288,137 +334,15 @@ Works on any Tier-1 Rust target — including musl, riscv64, FreeBSD, and older git clone https://github.com/Hmbown/CodeWhale.git cd CodeWhale -cargo install --path crates/cli --locked # requires Rust 1.88+; provides `codewhale` -cargo install --path crates/tui --locked # provides `codewhale-tui` +cargo install --path crates/cli --locked +cargo install --path crates/tui --locked ``` -Both binaries are required. Cross-compilation and platform-specific notes: [docs/INSTALL.md](docs/INSTALL.md). +Both binaries are required. Rust 1.88+ is required because the crates use the +2024 edition.
-### Other API Providers - -For the full shipped provider registry, including model IDs, auth variables, -base URLs, and capability boundaries, see [docs/PROVIDERS.md](docs/PROVIDERS.md). - -Think of provider and model as separate choices: `provider` is the route, -account, and endpoint; `model` is the model ID on that route. DeepSeek-family -models can be reached through several routes, so `/config` exposes both -`provider` and `provider_url`. - -| Route | Typical DeepSeek model ID | -|-------|---------------------------| -| `deepseek` | `deepseek-v4-pro` | -| `nvidia-nim` | `deepseek-ai/deepseek-v4-pro` | -| `openrouter` | `deepseek/deepseek-v4-pro` | -| `fireworks` | `accounts/fireworks/models/deepseek-v4-pro` | -| `siliconflow` | `deepseek-ai/DeepSeek-V4-Pro` | -| `openai` | Your gateway's model ID | -| `huggingface` | `deepseek-ai/DeepSeek-V4-Pro` | - -```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 -codewhale --provider atlascloud --model vendor/model-id - -# 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 -codewhale --provider openrouter --model arcee-ai/trinity-large-thinking -codewhale --provider openrouter --model minimax/minimax-m3 - -Arcee AI offers direct API access to its powerful Trinity models, including the reasoning-capable Trinity-Large Thinking. This section provides comprehensive setup instructions and model comparisons. - -## Configuration - -### API Key -The primary authentication method is the `ARCEE_API_KEY` environment variable or the `[providers.arcee]` configuration section in `~/.codewhale/config.toml`: - -```toml -[providers.arcee] -# api_key = "your-arcee-api-key" -# base_url = "https://api.arcee.ai/api/v1" -# model = "trinity-large-thinking" # or "trinity-large-preview", "trinity-mini" -``` - -### Environment Variables - -- `ARCEE_API_KEY`: Your Arcee API key (required) -- `ARCEE_BASE_URL`: Custom base URL (optional, defaults to `https://api.arcee.ai/api/v1`) -- `ARCEE_MODEL`: Default model to use (optional, defaults to `trinity-large-thinking`) - -### Model Support - -CodeWhale supports three Arcee models: - -| Model | Reasoning | Context Window | Max Output | Best For | -|--------|-----------|----------------|------------|----------| -| `trinity-large-thinking` | ✅ Yes | 262,144 tokens | 262,144 tokens | Complex reasoning, coding, math | -| `trinity-large-preview` | ❌ No | 262,144 tokens | 4,096 tokens | High-accuracy non-reasoning tasks | -| `trinity-mini` | ❌ No | 128,000 tokens | 4,096 tokens | Faster, cost-effective tasks | - -**Note:** The `trinity-large-thinking` model supports reasoning (thinking mode) and can handle very large contexts, making it ideal for complex programming tasks. The other models do not support reasoning but offer larger context windows than many other providers. -codewhale auth set --provider arcee --api-key "YOUR_ARCEE_API_KEY" -codewhale --provider arcee --model trinity-large-thinking -codewhale --provider arcee --model trinity-large-preview - -# Xiaomi MiMo -codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_KEY" -# Token Plan (`tp-...`) keys default to https://token-plan-sgp.xiaomimimo.com/v1. -# To force a provider endpoint: /config provider_url token-plan --save -# or /config provider_url pay-as-you-go --save. -codewhale --provider xiaomi-mimo --model mimo-v2.5-pro -codewhale --provider xiaomi-mimo --model mimo-v2.5 -codewhale --provider xiaomi-mimo speech "Hello from MiMo" --model tts -o hello.wav - -# 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 - -# SiliconFlow -codewhale auth set --provider siliconflow --api-key "YOUR_SILICONFLOW_API_KEY" -codewhale --provider siliconflow --model deepseek-ai/DeepSeek-V4-Pro - -# Generic OpenAI-compatible endpoint -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 -codewhale --provider ollama --model codewhale-coder:1.3b -``` - -Inside the TUI, `/provider` opens the provider picker and `/model` opens the -local model/thinking picker. `/provider openrouter` and `/model ` switch -directly, while `/models` explicitly fetches and lists live API models when the -active provider supports model listing. - --- ## Release Notes @@ -499,7 +423,7 @@ volume ownership notes, and non-interactive pipeline usage. ### Zed / ACP -DeepSeek can run as a custom Agent Client Protocol server for editors that +CodeWhale can run as a custom Agent Client Protocol server for editors that spawn local ACP agents over stdio. In Zed, add a custom agent server: ```json @@ -578,18 +502,18 @@ Key environment variables: | `DEEPSEEK_BASE_URL` | API base URL | | `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_STREAM_IDLE_TIMEOUT_SECS` | Legacy stream idle timeout env override, default `300`, clamped to `1..=3600`; `[tui].stream_chunk_timeout_secs` takes precedence when configured | | `CODEWHALE_PROVIDER` / `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `siliconflow-CN`, `arcee`, `moonshot`, `sglang`, `vllm`, `ollama`, `huggingface` | | `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` / `VOLCENGINE_API_KEY` / `VOLCENGINE_ARK_API_KEY` / `ARK_API_KEY` / `OPENROUTER_API_KEY` / `XIAOMI_MIMO_API_KEY` / `XIAOMI_API_KEY` / `MIMO_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SILICONFLOW_API_KEY` / `ARCEE_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` / `HUGGINGFACE_API_KEY` / `HF_TOKEN` | Provider auth | +| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `VOLCENGINE_API_KEY` / `VOLCENGINE_ARK_API_KEY` / `ARK_API_KEY` / `OPENROUTER_API_KEY` / `XIAOMI_MIMO_TOKEN_PLAN_API_KEY` / `MIMO_TOKEN_PLAN_API_KEY` / `XIAOMI_MIMO_API_KEY` / `XIAOMI_API_KEY` / `MIMO_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SILICONFLOW_API_KEY` / `ARCEE_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` / `HUGGINGFACE_API_KEY` / `HF_TOKEN` | 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 | | `VOLCENGINE_BASE_URL` / `VOLCENGINE_ARK_BASE_URL` / `ARK_BASE_URL` / `VOLCENGINE_MODEL` / `VOLCENGINE_ARK_MODEL` | Volcengine 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; Token Plan default is `https://token-plan-sgp.xiaomimimo.com/v1` | +| `XIAOMI_MIMO_BASE_URL` / `MIMO_BASE_URL` / `XIAOMI_MIMO_MODEL` / `MIMO_MODEL` / `XIAOMI_MIMO_MODE` / `MIMO_MODE` | Xiaomi MiMo endpoint, model, and Token Plan mode override; Token Plan default is `https://token-plan-sgp.xiaomimimo.com/v1` | | `NOVITA_BASE_URL` | Novita endpoint override | | `FIREWORKS_BASE_URL` | Fireworks endpoint override | | `SILICONFLOW_BASE_URL` / `SILICONFLOW_MODEL` | SiliconFlow endpoint and model override | @@ -602,25 +526,30 @@ Key environment variables: | `OLLAMA_MODEL` | Self-hosted Ollama model tag | | `HUGGINGFACE_API_KEY` / `HF_TOKEN` / `HUGGINGFACE_BASE_URL` / `HUGGINGFACE_MODEL` | Hugging Face endpoint and model override | | `NO_ANIMATIONS=1` | Force accessibility mode at startup | -| `SSL_CERT_FILE` | Custom CA bundle for corporate proxies | +| `SSL_CERT_FILE` | Custom CA bundle for corporate proxies; prefer this over provider-local `insecure_skip_tls_verify` | Set `locale` in `settings.toml`, use `/config locale zh-Hans`, or rely on `LC_ALL`/`LANG` to choose UI chrome and the fallback language sent to V4 models. The latest user message still wins for natural-language reasoning and replies, so Chinese user turns stay Chinese even on an English system locale. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) and [docs/MCP.md](docs/MCP.md). --- -## Models & Pricing - -| Model | Context | Input (cache hit) | Input (cache miss) | Output | -|---|---|---|---|---| -| `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 | +## Models & Cost Tracking -DeepSeek Platform defaults to `https://api.deepseek.com/beta` so beta-gated API features can be tested without extra setup. Set `base_url = "https://api.deepseek.com"` to opt out. +CodeWhale tracks the provider route, concrete model, prompt-cache hit/miss +estimate, input tokens, and output tokens for the turn that actually ran. Auto +mode is resolved before the upstream request, so the footer and session summary +charge against `deepseek-v4-flash`, `deepseek-v4-pro`, or the explicit provider +model selected for that turn. -Legacy aliases `deepseek-chat` / `deepseek-reasoner` map to `deepseek-v4-flash` and retire after July 24, 2026. NVIDIA NIM variants use your NVIDIA account terms. +Pricing changes over time and can vary by account, region, provider route, and +promotion. Use [docs/PROVIDERS.md](docs/PROVIDERS.md) for supported model IDs +and the provider's official pricing pages for billing decisions. Treat the TUI +cost display as a local estimate, not a receipt. -> [!Note] -> DeepSeek's pricing page now lists the V4 Pro rates above as the permanent prices: the previous 75% promotional discount has been folded into a one-quarter base-rate adjustment as the promotion window closes on 15:59 UTC on 31 May 2026. The TUI cost estimator already uses these values, so no behavioural change is required. For any future price changes, consult the official [DeepSeek pricing page](https://api-docs.deepseek.com/zh-cn/quick_start/pricing). +DeepSeek Platform defaults to `https://api.deepseek.com/beta` so beta-gated API +features can be tested without extra setup. Set `base_url = +"https://api.deepseek.com"` to opt out. Legacy aliases `deepseek-chat` / +`deepseek-reasoner` remain compatibility shims; prefer V4 model IDs for new +config. NVIDIA NIM variants use your NVIDIA account terms. --- @@ -673,11 +602,15 @@ without recreating skills the user deliberately deleted. | [TENCENT_CLOUD_REMOTE_FIRST.md](docs/TENCENT_CLOUD_REMOTE_FIRST.md) | Tencent/CNB/Lighthouse/Feishu remote-first path | | [TENCENT_LIGHTHOUSE_HK.md](docs/TENCENT_LIGHTHOUSE_HK.md) | Lighthouse Hong Kong server setup | | [MEMORY.md](docs/MEMORY.md) | User memory feature guide | +| [AGENT_ETHOS.md](docs/AGENT_ETHOS.md) | Maintainer and agent stewardship posture | | [SUBAGENTS.md](docs/SUBAGENTS.md) | Sub-agent role taxonomy and lifecycle | | [KEYBINDINGS.md](docs/KEYBINDINGS.md) | Full shortcut catalog | | [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 | +| [V0_9_0_RELEASE_ACCEPTANCE.md](docs/V0_9_0_RELEASE_ACCEPTANCE.md) | v0.9.0 pre-tag acceptance matrix and release gates | +| [HARNESS_PROFILE_CUTLINE.md](docs/HARNESS_PROFILE_CUTLINE.md) | HarnessProfile schema, resolver, and runtime boundary for v0.9 | +| [2574-provider-fallback-chain.md](docs/rfcs/2574-provider-fallback-chain.md) | Provider fallback chain RFC | Full Changelog: [CHANGELOG.md](CHANGELOG.md). @@ -690,7 +623,80 @@ Full Changelog: [CHANGELOG.md](CHANGELOG.md). - **[OpenWarp](https://github.com/zerx-lab/warp)** — thank you for prioritizing codewhale support and for collaborating on a better terminal-agent experience. - **[Open Design](https://github.com/nexu-io/open-design)** — thank you for support and collaboration around design-forward agent workflows. -This project ships with help from a growing community of contributors: +This project ships with help from a growing community of contributors. The +maintainer rule is simple: reports and PRs are real project work, even when the +final patch has to be narrowed, delayed, or harvested into a maintainer branch. + +For the v0.9 track, harvested PRs should keep visible credit in the commit or +PR body, changelog or release notes, and relevant issue/PR comments. Contributor +credit should use mappable GitHub identities from `.github/AUTHOR_MAP` or +numeric noreply addresses, not placeholder local emails. The contribution gate +is kept in dry-run mode unless a maintainer deliberately enables enforcement; +when it comments, the tone should be warm and practical rather than treating +the reporter as the problem. Recurring contributors should be recognized so the +automation gets out of their way and the public record shows their repeated +help. + +Current v0.9 track credits: + +- **[xyuai](https://github.com/xyuai)** — canonical CodeWhale settings path, + provider persistence, provider picker, logout-scope, and MiMo auth cleanup + work (#2730, #2714, #2715, #2717, #2718) +- **[shenjackyuanjie](https://github.com/shenjackyuanjie)** — HarmonyOS / + OpenHarmony porting work and MatePad Edge validation trail (#2634) +- **[ousamabenyounes](https://github.com/ousamabenyounes)** — AZERTY/AltGr + composer shortcut fix for Windows keyboard layouts (#2863, #2867) +- **[reidliu41](https://github.com/reidliu41)** — hotbar action-registry + foundation and Ollama model-completion cleanup for the v0.9 track (#2866, + #2742) +- **[ljm3790865](https://github.com/ljm3790865)** — multi-tab + core/persistence foundation and broader tab collaboration direction (#2864, + #2753) +- **[sximelon](https://github.com/sximelon)** — saved-session resume footer + hint work plus provider-trait metadata registry direction reviewed and + harvested for the v0.9 track (#2758, #2760, #2479) +- **[aboimpinto](https://github.com/aboimpinto)** — sidebar command polish and + pausable custom-command lifecycle direction harvested into the v0.9 track, + plus the directly merged command-support boundary cleanup and broader command + layer design direction (#2788, #2732, #2871, #2851, #2791) +- **[AdityaVG13](https://github.com/AdityaVG13)** — WhaleFlow orchestration and + cost-tracking drafts that shaped the maintained v0.9 WhaleFlow IR and + TraceStore foundation (#2482, #2486) +- **[lbcheng888](https://github.com/lbcheng888)**, + **[AiurArtanis](https://github.com/AiurArtanis)**, and + **[nasus9527](https://github.com/nasus9527)** — VS Code extension scaffold + direction, Agent View request, and IDE plugin request that shaped the + official Phase 0 extension (#1022, #1584, #2580) +- **[HUQIANTAO](https://github.com/HUQIANTAO)** — `web_run` cache-state + lock-splitting, turn-metadata prefix-cache stability, and project-context + cache work (#2502, #2517, #2636) +- **[idling11](https://github.com/idling11)** — PlanArtifact continuity, + dense tool-call transcript collapse, sidebar detail popovers, and + HarnessPosture provider/model policy direction (#2733, #2738, #2734, + #2741, #2692, #2694, #2693) +- **[h3c-hexin](https://github.com/h3c-hexin)** — sub-agent model inheritance, + configured `skills_dir` discovery, prompt-environment stability, and static + prompt composer direction (#2736, #2737, #2786) +- **[gaord](https://github.com/gaord)** — runtime thread workspace updates and + completed-thread saved-session API work (#2640, #2639) +- **[cyq1017](https://github.com/cyq1017)** — trusted workspace MCP config, + provider auth rollback, custom search endpoint, custom completion sound, + restore-listing, and pending-input delivery-mode label work (#2751, #2755, + #2510, #2512, #2513, #2532, #2054) +- **[yusufgurdogan](https://github.com/yusufgurdogan)** — Sofya search + provider implementation harvested as a non-default search backend (#2790) +- **[LeoAlex0](https://github.com/LeoAlex0)** — runtime prompt metadata cache + direction harvested into the v0.9 prompt/cache path (#2687) +- **[NASLXTO](https://github.com/NASLXTO)** and + **[wuxixing](https://github.com/wuxixing)** — large-workspace startup + reports that shaped the bounded project-context fallback (#697, #1827) +- **[shuxiangxuebiancheng](https://github.com/shuxiangxuebiancheng)**, + **[hongqitai](https://github.com/hongqitai)**, and + **[cyq1017](https://github.com/cyq1017)** — third-party + OpenAI-compatible path-suffix report and follow-up review trail (#1874, + #2508, #2506) + +Current and recurring contributors include: - **[merchloubna70-dot](https://github.com/merchloubna70-dot)** — 28 PRs spanning features, fixes, and VS Code extension scaffolding (#645–#681) - **[WyxBUPT-22](https://github.com/WyxBUPT-22)** — Markdown rendering for tables, bold/italic, and horizontal rules (#579) @@ -742,7 +748,10 @@ This project ships with help from a growing community of contributors: - **[Aitensa](https://github.com/Aitensa)** — CJK wrapping propagation for diff and pager output (#1622) - **[qiyan233](https://github.com/qiyan233)** — legacy DeepSeek CN provider alias compatibility (#1645) - **[zlh124](https://github.com/zlh124)** — WSL2/headless startup report, clipboard-init fix, CodeWhale tab-title polish, localized context-menu labels, and approval-dialog fixes (#1772, #1773, #2319, #2320, #2325) -- **[aboimpinto](https://github.com/aboimpinto)** — Windows alt-screen logging, Home/End composer, and runtime log follow-ups (#1774, #1776, #1748, #1749, #1782, #1783) +- **[aboimpinto](https://github.com/aboimpinto)** — Windows alt-screen + logging, Home/End composer, runtime log follow-ups, sidebar command polish, + and pausable command lifecycle work (#1774, #1776, #1748, #1749, #1782, + #1783, #2788, #2732) - **[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, provider registry drift docs, tool-search defaults, footer git branch display, and startup prompt interactivity (#1764, #2274, #2344, #2347, #2373) - **[donglovejava](https://github.com/donglovejava)** — paste @file consolidation, CJK panic fix, user feedback, RLM routing, edit_file retry, hidden-worktree discovery skip, IME composer routing, and eager shell companion tools (#2154-#2168, #2302, #2329, #2330, #2331) @@ -764,7 +773,8 @@ This project ships with help from a growing community of contributors: - **[yuanchenglu](https://github.com/yuanchenglu)** — Feishu per-chat model switching (#2149) - **[HUQIANTAO](https://github.com/HUQIANTAO)** — Xiaomi balance/status work, stalled-turn recovery, approval intent summaries, mobile smoke/QR support, Claude theme, and broad docs/test/CI coverage (#2257, #2267, #2283, #2384, #2385, #2389, #2403, #2440-#2458, #2460) - **[h3c-hexin](https://github.com/h3c-hexin)** — web-search URL decoding, prompt/instructions override hooks, sub-agent guidance, SSRF fake-IP trust configuration, and prompt-cache-friendly environment placement (#2245, #2311, #2313, #2314, #2354, #2355, #2356) -- **[AresNing](https://github.com/AresNing)** — first-run guide and message-submit hook transform design harvested into the maintained hooks path (#2278, #2318, #2434) +- **[tdccccc](https://github.com/tdccccc)** — approval prompt key-detail and shell-preview work harvested into the maintained approval path (#1991, #2269) +- **[AresNing](https://github.com/AresNing)** — first-run guide, message-submit hook transform design, and turn-end observer hook work harvested into the maintained hooks path (#2278, #2318, #2434, #2578) - **[Implementist](https://github.com/Implementist)** — Volcengine Ark search provider and reliability hardening (#2426, #2429, #2439) - **[lihuan215](https://github.com/lihuan215)** — Unix socket hook sink design harvested into the opt-in hook event path (#2333, #2430) - **[AdityaVG13](https://github.com/AdityaVG13)** — Xiaomi MiMo provider support (#2246) @@ -802,6 +812,21 @@ credit: **[@buko](https://github.com/buko)**, **[@yyyCode](https://github.com/yy See [CONTRIBUTING.md](CONTRIBUTING.md). Pull requests welcome — check the [open issues](https://github.com/Hmbown/CodeWhale/issues) for good first contributions. +CodeWhale gets a lot of good reports and PRs. The maintainer posture is to keep +that door open while protecting release quality: + +- Issues should stay human-readable and actionable. Intake automation is + advisory unless a maintainer deliberately enables enforcement. +- PRs are reviewed from code, tests, linked issues, and runtime behavior, not + from title alone. +- If a PR is too broad to merge directly, maintainers may harvest the safe part + into a narrower branch, then credit the author and explain what landed. +- Co-author trailers should use mappable GitHub noreply identities from + `.github/AUTHOR_MAP`; reporters and repro authors should be thanked in + changelogs, release notes, and closure comments. +- Recurring contributors can be added to `.github/APPROVED_CONTRIBUTORS` so + dry-run gates stay out of their way. + Support: [Buy me a coffee](https://www.buymeacoffee.com/hmbown). > [!Note] diff --git a/README.vi.md b/README.vi.md index d44044752..c65f3daf4 100644 --- a/README.vi.md +++ b/README.vi.md @@ -183,6 +183,8 @@ Hãy chỉ định mô hình hoặc cấp độ suy nghĩ cố định nếu b 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`. +Xem [docs/HarmonyOS.md](docs/HarmonyOS.md) để cấu hình HarmonyOS PC và cross-build OpenHarmony. + ### 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: diff --git a/README.zh-CN.md b/README.zh-CN.md index c56953578..dc2148cba 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -186,6 +186,8 @@ Auto 模式同时控制两个设置: 从 v0.8.8 起,`npm i -g codewhale` 直接支持 glibc 系的 ARM64 Linux。你也可以从 [Releases 页面](https://github.com/Hmbown/CodeWhale/releases) 下载预编译二进制,放到 `PATH` 目录中。 +HarmonyOS PC 运行和 OpenHarmony 交叉编译配置见 [docs/HarmonyOS.md](docs/HarmonyOS.md)。 + ### 中国大陆 / 镜像友好安装 如果在中国大陆访问 GitHub 或 npm 下载较慢,可以通过 Cargo 注册表镜像安装: @@ -270,6 +272,8 @@ codewhale --provider openrouter --model qwen/qwen3.7-max codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_MIMO_API_KEY" codewhale --provider xiaomi-mimo --model mimo-v2.5-pro codewhale --provider xiaomi-mimo speech "???MiMo" --model tts -o hello.wav +XIAOMI_MIMO_TOKEN_PLAN_API_KEY="tp-..." XIAOMI_MIMO_MODE="token-plan-sgp" \ + codewhale --provider xiaomi-mimo --model mimo-v2.5-pro # Novita codewhale auth set --provider novita --api-key "YOUR_NOVITA_API_KEY" @@ -425,13 +429,13 @@ DeepSeek 可作为自定义 Agent Client Protocol 服务器运行,供 Zed 等 | `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` / `VOLCENGINE_API_KEY` / `ARK_API_KEY` / `OPENROUTER_API_KEY` / `XIAOMI_MIMO_API_KEY` / `MIMO_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SILICONFLOW_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` / `HUGGINGFACE_API_KEY` / `HF_TOKEN` | 提供商认证 | +| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `VOLCENGINE_API_KEY` / `ARK_API_KEY` / `OPENROUTER_API_KEY` / `XIAOMI_MIMO_TOKEN_PLAN_API_KEY` / `MIMO_TOKEN_PLAN_API_KEY` / `XIAOMI_MIMO_API_KEY` / `MIMO_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SILICONFLOW_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` / `HUGGINGFACE_API_KEY` / `HF_TOKEN` | 提供商认证 | | `OPENAI_BASE_URL` / `OPENAI_MODEL` | 通用 OpenAI 兼容端点和模型 ID | | `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud 端点和模型覆盖 | | `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Wanjie Ark 端点和模型覆盖 | | `VOLCENGINE_BASE_URL` / `ARK_BASE_URL` / `VOLCENGINE_MODEL` / `ARK_MODEL` | Volcengine Ark 端点和模型覆盖 | | `OPENROUTER_BASE_URL` | OpenRouter 端点覆盖 | -| `XIAOMI_MIMO_BASE_URL` / `MIMO_BASE_URL` / `XIAOMI_MIMO_MODEL` / `MIMO_MODEL` | Xiaomi MiMo 端点和模型覆盖 | +| `XIAOMI_MIMO_BASE_URL` / `MIMO_BASE_URL` / `XIAOMI_MIMO_MODEL` / `MIMO_MODEL` / `XIAOMI_MIMO_MODE` / `MIMO_MODE` | Xiaomi MiMo 端点、模型和 Token Plan 模式覆盖 | | `NOVITA_BASE_URL` | Novita 端点覆盖 | | `FIREWORKS_BASE_URL` | Fireworks 端点覆盖 | | `SILICONFLOW_BASE_URL` / `SILICONFLOW_MODEL` | SiliconFlow 端点和模型覆盖 | diff --git a/config.example.toml b/config.example.toml index b53435359..20ea5f8e7 100644 --- a/config.example.toml +++ b/config.example.toml @@ -88,6 +88,26 @@ cost_currency = "usd" # usd | cny check_for_updates = true # update_uri = "https://internal.mirror.example/codewhale/releases/latest" +# ───────────────────────────────────────────────────────────────────────────────── +# Hotbar slots (#2061 / #2064) +# ───────────────────────────────────────────────────────────────────────────────── +# Optional 1-8 sidebar hotbar bindings. When no [[hotbar]] tables are present, +# the TUI uses built-in defaults: +# 1 voice.toggle 2 session.compact 3 mode.plan 4 mode.agent +# 5 mode.yolo 6 palette.open 7 sidebar.toggle 8 trust.toggle +# +# Invalid slots are skipped with a warning, duplicate slots use the last entry, +# and unknown actions are preserved so the UI can show a disabled placeholder. +# +# [[hotbar]] +# slot = 1 +# label = "voice" +# action = "voice.toggle" +# +# [[hotbar]] +# slot = 2 +# action = "session.compact" + # ───────────────────────────────────────────────────────────────────────────────── # Paths # ───────────────────────────────────────────────────────────────────────────────── @@ -144,11 +164,12 @@ memory_path = "~/.codewhale/memory.md" allow_shell = true approval_policy = "on-request" # on-request | untrusted | never sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-access | external-sandbox +# prompt_suggestion = true # opt-in: show ghost-text follow-up question in composer after each turn # Typed permission rules live in a sibling `permissions.toml` file, not in -# config.toml. This schema slice is ask-only and is parsed for follow-up -# approval-flow wiring; allow/deny records and UI persistence are intentionally -# out of scope here. +# config.toml. This shape is ask-only and feeds the execution policy engine; +# allow/deny records, glob expansion, and UI persistence are intentionally out +# of scope here. # # Example ~/.codewhale/permissions.toml: # @@ -239,7 +260,7 @@ max_subagents = 10 # optional (1-20) # Volcengine Ark: VOLCENGINE_API_KEY (or VOLCENGINE_ARK_API_KEY / ARK_API_KEY), VOLCENGINE_BASE_URL, VOLCENGINE_MODEL # OpenRouter: OPENROUTER_API_KEY, OPENROUTER_BASE_URL, OPENROUTER_MODEL # Xiaomi MiMo: XIAOMI_MIMO_API_KEY (or XIAOMI_API_KEY / MIMO_API_KEY), XIAOMI_MIMO_BASE_URL, XIAOMI_MIMO_MODEL -# Token Plan keys (`tp-...`) default to https://token-plan-sgp.xiaomimimo.com/v1. +# Token Plan: XIAOMI_MIMO_TOKEN_PLAN_API_KEY (or MIMO_TOKEN_PLAN_API_KEY), XIAOMI_MIMO_MODE/MIMO_MODE # Novita: NOVITA_API_KEY, NOVITA_BASE_URL, NOVITA_MODEL # Fireworks: FIREWORKS_API_KEY, FIREWORKS_BASE_URL # SiliconFlow: SILICONFLOW_API_KEY, SILICONFLOW_BASE_URL, SILICONFLOW_MODEL @@ -248,7 +269,7 @@ max_subagents = 10 # optional (1-20) # 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 -# Hugging Face: HUGGINGFACE_API_KEY (or HF_TOKEN), HUGGINGFACE_BASE_URL, HUGGINGFACE_MODEL +# Hugging Face: HUGGINGFACE_API_KEY (or HF_TOKEN), HUGGINGFACE_BASE_URL (or HF_BASE_URL), HUGGINGFACE_MODEL (or HF_MODEL) # # Custom DeepSeek-compatible APIs usually do not need a new provider table: # set `provider = "deepseek"` and override [providers.deepseek].base_url/model. @@ -274,6 +295,7 @@ max_subagents = 10 # optional (1-20) # model = "deepseek-ai/DeepSeek-V4-Pro" # http_headers = { "X-Model-Provider-Id" = "your-model-provider" } # optional custom request headers # path_suffix = "/chat/completions" # override the API path; skips /v1 versioning when set +# insecure_skip_tls_verify = true # last resort for private gateways; prefer SSL_CERT_FILE # NVIDIA NIM-hosted DeepSeek V4 (https://build.nvidia.com) [providers.nvidia_nim] @@ -292,6 +314,7 @@ max_subagents = 10 # optional (1-20) # Gateway example: # base_url = "https://gateway.example/v1" # model = "your-deepseek-compatible-model" +# insecure_skip_tls_verify = true # last resort for private gateways; prefer SSL_CERT_FILE # AtlasCloud OpenAI-compatible endpoint (https://www.atlascloud.ai/docs/models/llm) [providers.atlascloud] @@ -329,6 +352,11 @@ max_subagents = 10 # optional (1-20) # # base_url = "https://api.xiaomimimo.com/v1" # Pay-as-you-go / sk- keys # model = "mimo-v2.5-pro" # chat/reasoning # Chat model IDs: mimo-v2.5-pro, mimo-v2.5 +# Token Plan subscriptions use separate tp-* API keys plus api-key auth. +# mode = "token-plan-sgp" # default Token Plan endpoint +# mode = "token-plan-cn" # China cluster +# mode = "token-plan-ams" # Europe cluster +# mode = "pay-as-you-go" # standard API / sk- keys # TTS aliases are also accepted by `codewhale speech`: tts, voice-design, voice-clone # TTS model IDs: mimo-v2.5-tts, mimo-v2.5-tts-voicedesign, mimo-v2.5-tts-voiceclone, mimo-v2-tts @@ -385,6 +413,8 @@ max_subagents = 10 # optional (1-20) # model = "deepseek-coder:1.3b" # or any local Ollama tag # Hugging Face Inference Providers (https://huggingface.co/docs/api-inference) +# Env var aliases: HUGGINGFACE_API_KEY / HF_TOKEN, HUGGINGFACE_BASE_URL / HF_BASE_URL, +# HUGGINGFACE_MODEL / HF_MODEL [providers.huggingface] # api_key = "YOUR_HF_TOKEN" # base_url = "https://router.huggingface.co/v1" @@ -399,7 +429,7 @@ max_subagents = 10 # optional (1-20) # API-backed search. # # [search] -# provider = "duckduckgo" # duckduckgo | bing | tavily | bocha | metaso | baidu | volcengine +# provider = "duckduckgo" # duckduckgo | bing | tavily | bocha | metaso | baidu | volcengine | sofya # # duckduckgo: HTML scrape with Bing fallback # # bing: HTML scrape, no API key # # tavily: https://tavily.com — AI search, needs api_key @@ -409,15 +439,22 @@ max_subagents = 10 # optional (1-20) # # baidu: 百度 AI Search via qianfan.baidubce.com,需 api_key # # volcengine: 火山引擎 Ark web_search (免费 2 万次/月), 需 api_key # # 也回退到 VOLCENGINE_API_KEY / VOLCENGINE_ARK_API_KEY / ARK_API_KEY 环境变量 -# api_key = "YOUR_SEARCH_KEY" # required for tavily, bocha, and baidu; optional for metaso +# # sofya: https://sofya.co — AI search returning full page +# # content (not snippets), needs api_key (ay_live_...); +# # also falls back to the SOFYA_API_KEY env var +# base_url = "https://search.example/html/" # optional DuckDuckGo-compatible HTML endpoint +# api_key = "YOUR_SEARCH_KEY" # required for tavily, bocha, baidu, volcengine, and sofya; 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 +# CODEWHALE_SEARCH_BASE_URL → search.base_url +# DEEPSEEK_SEARCH_BASE_URL → search.base_url (legacy alias) # METASO_API_KEY → metaso key fallback # BAIDU_SEARCH_API_KEY → baidu key fallback +# SOFYA_API_KEY → sofya key fallback # ───────────────────────────────────────────────────────────────────────────────── # Network Policy (#135) @@ -469,6 +506,7 @@ max_subagents = 10 # optional (1-20) alternate_screen = "auto" # auto/always use the TUI screen; never uses terminal scrollback mouse_capture = true # true copies only transcript user/assistant text; false uses raw terminal selection/copy terminal_probe_timeout_ms = 500 # optional startup terminal-mode timeout (100-5000ms) +stream_chunk_timeout_secs = 300 # optional SSE idle timeout per chunk (0 = default, 1-3600) osc8_links = true # emit OSC 8 escapes around URLs (Cmd+click in iTerm2/Ghostty/Kitty/WezTerm/Terminal.app 13+); set false for terminals that misrender # Ordered footer chips shown in the TUI status line. Omit the key to use the # built-in default; set [] to hide all configurable chips. You can also edit @@ -588,6 +626,26 @@ deepseek_v4_pro_prior = 3.5 deepseek_v4_flash_prior = 4.2 fallback_default_prior = 3.8 +# ───────────────────────────────────────────────────────────────────────────────── +# Harness Profiles (preview schema; runtime consumption follows later) +# ───────────────────────────────────────────────────────────────────────────────── +# Harness profiles let future CodeWhale runtime slices select model-specific +# prompt, context, tool, and subagent posture. v0.9 parses, validates, and can +# resolve profiles for tests/status plumbing, but normal Agent and WhaleFlow +# runs do not silently promote or mutate behavior from these profiles yet. +# +# [[harness_profiles]] +# provider_route = "deepseek" +# model_pattern = "deepseek-v4.*" +# +# [harness_profiles.posture] +# kind = "cache-heavy" # standard | cache-heavy | lean | custom +# max_subagents = 10 # 0 means runtime default +# prefer_codebase_search = false +# compaction_strategy = "prefix-cache" # default | prefix-cache | aggressive +# tool_surface = "full" # full | read-only | auto +# safety_posture = "standard" # standard | strict | permissive + # ───────────────────────────────────────────────────────────────────────────────── # Profile Example (for multiple environments) # ───────────────────────────────────────────────────────────────────────────────── @@ -617,21 +675,20 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" # method = "auto" # auto | osc9 | bel | off # auto: OSC 9 for iTerm.app / Ghostty / WezTerm. # On macOS / Linux, falls back to BEL. -# On Windows, falls back to "off" — BEL maps to the -# system error chime (SystemAsterisk / MB_OK), which -# sounds like an error popup. Set method = "bel" -# explicitly to opt back in (#583). +# On Windows, BEL is routed through MessageBeep(MB_OK). # osc9: \x1b]9;\x07 (iTerm2-style; shows macOS notification) # bel: plain \x07 beep # off: disable entirely # threshold_secs = 30 # only notify when the turn took >= this many seconds # include_summary = false # include elapsed time + cost in the notification body -# completion_sound = "beep" # off | beep | bell — sound on turn completion (✅ marker) +# completion_sound = "beep" # off | beep | bell | file — sound on turn completion (✅ marker) +# sound_file = "E:\\google\\downloads\\notify.wav" # WAV used when completion_sound = "file" (Windows) [notifications] # method = "auto" # threshold_secs = 30 # include_summary = false # completion_sound = "beep" +# sound_file = "E:\\google\\downloads\\notify.wav" # ───────────────────────────────────────────────────────────────────────────────── # Workspace Snapshots (#137) diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 5e5635341..0b4c3b93e 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.53" } +codewhale-config = { path = "../config", version = "0.9.0" } serde.workspace = true diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index aa5a1cf34..42ba1f390 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -10,19 +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.53" } -codewhale-config = { path = "../config", version = "0.8.53" } -codewhale-core = { path = "../core", version = "0.8.53" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.53" } -codewhale-hooks = { path = "../hooks", version = "0.8.53" } -codewhale-mcp = { path = "../mcp", version = "0.8.53" } -codewhale-protocol = { path = "../protocol", version = "0.8.53" } -codewhale-state = { path = "../state", version = "0.8.53" } -codewhale-tools = { path = "../tools", version = "0.8.53" } +codewhale-agent = { path = "../agent", version = "0.9.0" } +codewhale-config = { path = "../config", version = "0.9.0" } +codewhale-core = { path = "../core", version = "0.9.0" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.9.0" } +codewhale-hooks = { path = "../hooks", version = "0.9.0" } +codewhale-mcp = { path = "../mcp", version = "0.9.0" } +codewhale-protocol = { path = "../protocol", version = "0.9.0" } +codewhale-state = { path = "../state", version = "0.9.0" } +codewhale-tools = { path = "../tools", version = "0.9.0" } serde.workspace = true serde_json.workspace = true +rustls.workspace = true tokio.workspace = true tower-http.workspace = true +tracing.workspace = true uuid.workspace = true [dev-dependencies] diff --git a/crates/app-server/src/lib.rs b/crates/app-server/src/lib.rs index cfe0e0763..262a920cd 100644 --- a/crates/app-server/src/lib.rs +++ b/crates/app-server/src/lib.rs @@ -12,7 +12,6 @@ use axum::{Json, Router}; use codewhale_agent::ModelRegistry; use codewhale_config::{CliRuntimeOverrides, ConfigStore}; use codewhale_core::Runtime; -use codewhale_execpolicy::ExecPolicyEngine; use codewhale_hooks::{HookDispatcher, JsonlHookSink, StdoutHookSink, UnixSocketHookSink}; use codewhale_mcp::McpManager; use codewhale_protocol::{ @@ -277,14 +276,19 @@ async fn tool_handler( let cwd = req .cwd .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); - match runtime - .invoke_tool( - req.call, - codewhale_execpolicy::AskForApproval::OnRequest, - &cwd, - ) - .await - { + // Resolve approval policy from config instead of hardcoding. + let approval_mode = { + let cfg = state.config.read().await; + cfg.approval_policy + .as_deref() + .and_then(|p| match p.trim().to_ascii_lowercase().as_str() { + "auto" | "yolo" => Some(codewhale_execpolicy::AskForApproval::UnlessTrusted), + "never" | "deny" => Some(codewhale_execpolicy::AskForApproval::Never), + _ => None, + }) + .unwrap_or(codewhale_execpolicy::AskForApproval::OnRequest) + }; + match runtime.invoke_tool(req.call, approval_mode, &cwd).await { Ok(value) => Json(value), Err(err) => Json(json!({ "ok": false, "error": err.to_string() })), } @@ -314,6 +318,7 @@ async fn app_handler( fn build_state(config_path: Option, auth_token: Option) -> Result { let store = ConfigStore::load(config_path.clone())?; let config = store.config.clone(); + let exec_policy = store.exec_policy_engine(); let registry = ModelRegistry::default(); let state_db_path = config_path @@ -344,7 +349,7 @@ fn build_state(config_path: Option, auth_token: Option) -> Resu state_store, Arc::new(ToolRegistry::default()), Arc::new(McpManager::default()), - ExecPolicyEngine::new(Vec::new(), Vec::new()), + exec_policy, hooks, ); @@ -879,7 +884,9 @@ async fn process_app_request( let message = result.err().map(|e| e.to_string()); let snapshot = cfg.clone(); drop(cfg); - let _ = persist_config(state, snapshot).await; + if let Err(e) = persist_config(state, snapshot).await { + tracing::error!("Failed to persist config after set: {e}"); + } AppResponse { ok, data: json!({ "key": key, "value": value, "error": message }), @@ -893,7 +900,9 @@ async fn process_app_request( let message = result.err().map(|e| e.to_string()); let snapshot = cfg.clone(); drop(cfg); - let _ = persist_config(state, snapshot).await; + if let Err(e) = persist_config(state, snapshot).await { + tracing::error!("Failed to persist config after unset: {e}"); + } AppResponse { ok, data: json!({ "key": key, "error": message }), @@ -1048,6 +1057,43 @@ mod tests { ); } + #[tokio::test] + async fn build_state_loads_permissions_into_runtime_policy() { + 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"); + fs::write( + tmp.path().join("permissions.toml"), + r#" + [[rules]] + tool = "exec_shell" + command = "cargo test" + "#, + ) + .expect("write permissions"); + + let state = build_state(Some(config_path), None).expect("state"); + let runtime = state.runtime.lock().await; + let decision = runtime + .exec_policy + .check(codewhale_execpolicy::ExecPolicyContext { + command: "cargo test --workspace", + cwd: "/workspace", + tool: Some("exec_shell"), + path: None, + ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted, + sandbox_mode: Some("workspace-write"), + }) + .expect("policy check"); + + assert!(decision.allow); + assert!(decision.requires_approval); + assert_eq!( + decision.matched_rule.as_deref(), + Some("tool=exec_shell command=cargo test") + ); + } + #[test] fn non_loopback_bind_without_auth_fails_fast() { let options = AppServerOptions { @@ -1067,7 +1113,10 @@ mod tests { #[tokio::test] async fn stdio_transport_keeps_raw_config_get_for_legacy_clients() { - let state = build_state(None, None).expect("state"); + let tmp = tempfile::tempdir().expect("tempdir"); + let config_path = tmp.path().join("config.toml"); + fs::write(&config_path, "").expect("write config"); + let state = build_state(Some(config_path), None).expect("state"); { let mut cfg = state.config.write().await; cfg.api_key = Some("sk-deepseek-secret".to_string()); diff --git a/crates/app-server/src/main.rs b/crates/app-server/src/main.rs index 9627746e1..8fcb8f198 100644 --- a/crates/app-server/src/main.rs +++ b/crates/app-server/src/main.rs @@ -27,6 +27,8 @@ struct Cli { #[tokio::main] async fn main() -> Result<()> { + install_rustls_crypto_provider(); + let cli = Cli::parse(); let listen: SocketAddr = format!("{}:{}", cli.host, cli.port) .parse() @@ -41,6 +43,10 @@ async fn main() -> Result<()> { .await } +fn install_rustls_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} + fn app_server_token_from_env() -> Option { std::env::var("CODEWHALE_APP_SERVER_TOKEN") .ok() diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 8c23f204d..0c33cc845 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -15,29 +15,24 @@ path = "src/main.rs" name = "codew" path = "src/bin/codew_legacy_shim.rs" -# Legacy alias — forwards to `codewhale` and prints a deprecation notice. -# Will be removed in v0.9.0. -[[bin]] -name = "deepseek" -path = "src/bin/deepseek_legacy_shim.rs" - [dependencies] anyhow.workspace = true clap.workspace = true clap_complete.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.53" } -codewhale-app-server = { path = "../app-server", version = "0.8.53" } -codewhale-config = { path = "../config", version = "0.8.53" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.53" } -codewhale-mcp = { path = "../mcp", version = "0.8.53" } -codewhale-release = { path = "../release", version = "0.8.53" } -codewhale-secrets = { path = "../secrets", version = "0.8.53" } -codewhale-state = { path = "../state", version = "0.8.53" } +codewhale-agent = { path = "../agent", version = "0.9.0" } +codewhale-app-server = { path = "../app-server", version = "0.9.0" } +codewhale-config = { path = "../config", version = "0.9.0" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.9.0" } +codewhale-mcp = { path = "../mcp", version = "0.9.0" } +codewhale-release = { path = "../release", version = "0.9.0" } +codewhale-secrets = { path = "../secrets", version = "0.9.0" } +codewhale-state = { path = "../state", version = "0.9.0" } chrono.workspace = true dirs.workspace = true serde.workspace = true serde_json.workspace = true reqwest = { workspace = true, features = ["blocking"] } +rustls.workspace = true semver.workspace = true tokio.workspace = true sha2.workspace = true diff --git a/crates/cli/src/bin/deepseek_legacy_shim.rs b/crates/cli/src/bin/deepseek_legacy_shim.rs deleted file mode 100644 index abd00896c..000000000 --- a/crates/cli/src/bin/deepseek_legacy_shim.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! Legacy `deepseek` alias. -//! -//! Forwards argv to the `codewhale` dispatcher and prints a one-line -//! deprecation notice to stderr on each invocation. This binary exists -//! for one release cycle to give existing installs a smooth path to the -//! new name; it will be removed in v0.9.0. See `docs/REBRAND.md` for the -//! full migration story. - -use std::env; -use std::process::Command; - -fn main() { - eprintln!( - "warning: `deepseek` is deprecated; run `codewhale` instead. \ - This alias will be removed in v0.9.0." - ); - let args: Vec = env::args_os() - .skip(1) - .map(|a| a.to_string_lossy().into_owned()) - .collect(); - - let status = match spawn_codewhale(&args) { - Ok(s) => s, - Err(e) => { - eprintln!( - "error: failed to spawn `codewhale`: {e}. Is it on PATH? \ - Install with `cargo install codewhale-cli` or via npm/Homebrew." - ); - std::process::exit(127); - } - }; - std::process::exit(status.code().unwrap_or(1)); -} - -fn spawn_codewhale(args: &[String]) -> std::io::Result { - // Try PATH first. - match Command::new("codewhale").args(args).status() { - Ok(s) => return Ok(s), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(e), - } - - // On Windows, after an update the sibling `codewhale.exe` may be in the - // same directory as this shim but not on PATH (#2006). - #[cfg(windows)] - { - 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(); - } - } - } - - Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "codewhale not found on PATH or in sibling directory", - )) -} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index ba94aeb5d..1b6cc6abd 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -471,7 +471,13 @@ struct AppServerArgs { const MCP_SERVER_DEFINITIONS_KEY: &str = "mcp.server_definitions"; +fn install_rustls_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} + pub fn run_cli() -> std::process::ExitCode { + install_rustls_crypto_provider(); + match run() { Ok(()) => std::process::ExitCode::SUCCESS, Err(err) => { @@ -2965,6 +2971,7 @@ mod tests { api_key_source: Some(RuntimeApiKeySource::Keyring), base_url: "https://openai-compatible.example/v4".to_string(), auth_mode: Some("api_key".to_string()), + insecure_skip_tls_verify: false, output_mode: None, log_level: None, telemetry: false, @@ -3024,6 +3031,7 @@ mod tests { api_key_source: Some(RuntimeApiKeySource::ConfigFile), base_url: "https://api.deepseek.com/beta".to_string(), auth_mode: Some("api_key".to_string()), + insecure_skip_tls_verify: false, output_mode: None, log_level: None, telemetry: false, @@ -3079,6 +3087,7 @@ mod tests { api_key_source: Some(RuntimeApiKeySource::Keyring), base_url: "https://api.moonshot.ai/v1".to_string(), auth_mode: Some("api_key".to_string()), + insecure_skip_tls_verify: false, output_mode: None, log_level: None, telemetry: false, @@ -3145,6 +3154,7 @@ mod tests { api_key_source: None, base_url: "https://openai-compatible.example/v4".to_string(), auth_mode: None, + insecure_skip_tls_verify: false, output_mode: None, log_level: None, telemetry: false, @@ -3240,6 +3250,7 @@ mod tests { api_key_source: Some(RuntimeApiKeySource::Keyring), base_url: "http://localhost:8000/v1".to_string(), auth_mode: Some("api_key".to_string()), + insecure_skip_tls_verify: false, output_mode: None, log_level: None, telemetry: false, diff --git a/crates/cli/src/update.rs b/crates/cli/src/update.rs index 2c90422b2..c9d9ca7c3 100644 --- a/crates/cli/src/update.rs +++ b/crates/cli/src/update.rs @@ -20,6 +20,12 @@ use std::io::Write; /// Run the self-update workflow. pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option) -> Result<()> { + #[cfg(target_env = "ohos")] + { + let _ = (beta, check_only, proxy_arg); + bail!("self-update is not supported on HarmonyOS/OpenHarmony yet"); + } + let current_exe = std::env::current_exe().context("failed to determine current executable path")?; let targets = update_targets_for_exe(¤t_exe); @@ -353,6 +359,8 @@ pub(crate) fn validate_and_build_proxy(proxy_str: &str) -> Result { } fn update_http_client(proxy: Option<&Proxy>) -> Result { + let _ = rustls::crypto::ring::default_provider().install_default(); + let mut builder = reqwest::blocking::Client::builder(); if let Some(proxy) = proxy { builder = builder.proxy(proxy.clone()); diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 034f988f6..5e99e8b56 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -8,8 +8,8 @@ description = "Config schema and precedence model for DeepSeek workspace archite [dependencies] anyhow.workspace = true -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.53" } -codewhale-secrets = { path = "../secrets", version = "0.8.53" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.9.0" } +codewhale-secrets = { path = "../secrets", version = "0.9.0" } dirs.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 7135300d0..b1d2d0bdb 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1,4 +1,7 @@ -use std::collections::BTreeMap; +pub mod provider; + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; use std::fs; #[cfg(unix)] use std::io::Write; @@ -7,6 +10,7 @@ use std::sync::OnceLock; use anyhow::{Context, Result, bail}; pub use codewhale_execpolicy::ToolAskRule; +use codewhale_execpolicy::{ExecPolicyEngine, Ruleset}; use codewhale_secrets::SecretSource; pub use codewhale_secrets::Secrets; use serde::{Deserialize, Serialize}; @@ -70,6 +74,9 @@ const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash"; const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1"; const XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL: &str = "https://api.xiaomimimo.com/v1"; const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://token-plan-sgp.xiaomimimo.com/v1"; +const XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL: &str = "https://token-plan-cn.xiaomimimo.com/v1"; +const XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL: &str = DEFAULT_XIAOMI_MIMO_BASE_URL; +const XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL: &str = "https://token-plan-ams.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_SILICONFLOW_BASE_URL: &str = "https://api.siliconflow.com/v1"; @@ -131,6 +138,27 @@ pub enum ProviderKind { } impl ProviderKind { + pub const ALL: [Self; 18] = [ + Self::Deepseek, + Self::NvidiaNim, + Self::Openai, + Self::Atlascloud, + Self::WanjieArk, + Self::Volcengine, + Self::Openrouter, + Self::XiaomiMimo, + Self::Novita, + Self::Fireworks, + Self::Siliconflow, + Self::SiliconflowCN, + Self::Arcee, + Self::Moonshot, + Self::Sglang, + Self::Vllm, + Self::Ollama, + Self::Huggingface, + ]; + #[must_use] pub fn as_str(self) -> &'static str { match self { @@ -189,6 +217,15 @@ impl ProviderKind { pub fn is_siliconflow(self) -> bool { matches!(self, Self::Siliconflow | Self::SiliconflowCN) } + + /// Return the built-in metadata entry for this provider. + /// + /// This is a metadata foundation only; runtime routing still resolves + /// through [`ConfigToml::resolve_runtime_options`]. + #[must_use] + pub fn provider(self) -> &'static dyn provider::Provider { + provider::provider_for_kind(self) + } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -196,7 +233,9 @@ pub struct ProviderConfigToml { pub api_key: Option, pub base_url: Option, pub model: Option, + pub mode: Option, pub auth_mode: Option, + pub insecure_skip_tls_verify: Option, #[serde(default)] pub http_headers: BTreeMap, pub path_suffix: Option, @@ -257,6 +296,11 @@ impl PermissionsToml { pub fn is_empty(&self) -> bool { self.rules.is_empty() } + + #[must_use] + pub fn ruleset(&self) -> Ruleset { + Ruleset::user(Vec::new(), Vec::new()).with_ask_rules(self.rules.clone()) + } } impl ProvidersToml { @@ -306,6 +350,150 @@ impl ProvidersToml { } } +/// Kinds of built-in harness postures. +/// +/// A posture names the runtime strategy CodeWhale should use for a +/// provider/model route: how much context to preload, how aggressively to lean +/// on sub-agents, and how to balance prompt-cache stability against quick +/// exploration. Runtime selection is wired in later v0.9 slices; this config +/// model intentionally keeps the policy data explicit first. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum HarnessPostureKind { + /// Full-featured default: rich constitution, broad tool catalog, and normal + /// sub-agent posture. + #[default] + Standard, + /// Cache-heavy: deeper prompt layering and prefix-cache-oriented context. + CacheHeavy, + /// Lean: smaller starting context, faster compaction, and stronger + /// exploration/delegation bias. + Lean, + /// User-defined posture assembled from explicit knobs below. + Custom, +} + +/// How this posture should approach compaction and prompt-cache stability. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum HarnessCompactionStrategy { + #[default] + Default, + PrefixCache, + Aggressive, +} + +/// Which tool catalog shape this posture prefers. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum HarnessToolSurface { + #[default] + Full, + ReadOnly, + Auto, +} + +/// Safety posture applied when the runtime consumes a harness profile. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum HarnessSafetyPosture { + #[default] + Standard, + Strict, + Permissive, +} + +/// A concrete harness posture with policy knobs. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct HarnessPosture { + /// Named posture kind. + #[serde(default)] + pub kind: HarnessPostureKind, + /// Maximum number of concurrent sub-agents (0 = runtime default). + #[serde(default)] + pub max_subagents: usize, + /// Prefer search-based/on-demand context over always-on documentation. + #[serde(default)] + pub prefer_codebase_search: bool, + /// Compaction and prompt-cache strategy. + #[serde(default)] + pub compaction_strategy: HarnessCompactionStrategy, + /// Preferred tool catalog shape. + #[serde(default)] + pub tool_surface: HarnessToolSurface, + /// Safety posture for runtime consumers. + #[serde(default)] + pub safety_posture: HarnessSafetyPosture, +} + +impl Default for HarnessPosture { + fn default() -> Self { + Self { + kind: HarnessPostureKind::Standard, + max_subagents: 0, + prefer_codebase_search: false, + compaction_strategy: HarnessCompactionStrategy::default(), + tool_surface: HarnessToolSurface::default(), + safety_posture: HarnessSafetyPosture::default(), + } + } +} + +impl HarnessPosture { + /// A cache-heavy posture tuned for DeepSeek V4 / MiMo-style models. + #[must_use] + pub fn cache_heavy() -> Self { + Self { + kind: HarnessPostureKind::CacheHeavy, + max_subagents: 10, + prefer_codebase_search: false, + compaction_strategy: HarnessCompactionStrategy::PrefixCache, + tool_surface: HarnessToolSurface::Full, + safety_posture: HarnessSafetyPosture::Standard, + } + } + + /// A lean posture for smaller-context or weaker tool-use models. + #[must_use] + pub fn lean() -> Self { + Self { + kind: HarnessPostureKind::Lean, + max_subagents: 20, + prefer_codebase_search: true, + compaction_strategy: HarnessCompactionStrategy::Aggressive, + tool_surface: HarnessToolSurface::Full, + safety_posture: HarnessSafetyPosture::Standard, + } + } +} + +/// A harness profile binds a posture to a provider route and model pattern. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct HarnessProfile { + /// Provider route this profile applies to, e.g. "deepseek" or + /// "xiaomi-mimo". + pub provider_route: String, + /// Regex or glob pattern for model names, e.g. "deepseek-v4.*". + pub model_pattern: String, + /// The posture to apply. + #[serde(default)] + pub posture: HarnessPosture, +} + +impl HarnessProfile { + /// Return true when this profile applies to the provider/model route. + /// + /// This is a pure config helper: matching a profile must not mutate runtime + /// provider selection, prompts, auth, tools, context, or persisted config. + #[must_use] + pub fn matches_route(&self, provider_route: &str, model: &str) -> bool { + provider_routes_equal(&self.provider_route, provider_route) + && wildcard_pattern_matches(&self.model_pattern, model) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ConfigToml { /// TUI-compatible DeepSeek API key. Kept at the root so both `deepseek` @@ -332,6 +520,11 @@ pub struct ConfigToml { pub tools: Option, #[serde(default)] pub providers: ProvidersToml, + /// Dormant provider fallback chain (#2574). This is parsed and preserved + /// for future provider-routing work; current runtime resolution still uses + /// the selected primary provider and does not auto-switch routes. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fallback_providers: Vec, /// Per-domain network policy (#135). When absent, network tools fall back /// to a permissive default that mirrors pre-v0.7.0 behavior. #[serde(default)] @@ -349,6 +542,14 @@ pub struct ConfigToml { /// applies the defaults documented in [`LspConfigToml`]. #[serde(default)] pub lsp: Option, + /// Per-model harness profiles (#2693). Runtime wiring lands in follow-up + /// v0.9 slices; this is the durable config data model. + #[serde(default)] + pub harness_profiles: Vec, + /// Optional 1-8 hotbar slot bindings (#2064). When absent, the TUI falls + /// back to the built-in default slots. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hotbar: Option>, /// App-server hook sink configuration. Kept separate from the TUI /// lifecycle `[hooks]` table so config rewrites preserve existing hooks. #[serde(default)] @@ -357,6 +558,331 @@ pub struct ConfigToml { pub extras: BTreeMap, } +impl ConfigToml { + /// Resolve the first configured harness profile for a provider/model route. + /// + /// This helper is deliberately dormant for v0.9: callers may display or + /// test the resolved profile, but runtime provider/model routing and prompt + /// shaping remain unchanged until a later, explicit integration slice. + #[must_use] + pub fn resolve_harness_profile( + &self, + provider_route: &str, + model: &str, + ) -> Option<&HarnessProfile> { + self.harness_profiles + .iter() + .chain(built_in_harness_profiles().iter()) + .find(|profile| profile.matches_route(provider_route, model)) + } + + /// Resolve durable hotbar config into normalized 1-8 slot bindings. + /// + /// `known_action_ids` is supplied by the TUI action registry in later + /// slices. Unknown actions are preserved so the UI can render a disabled + /// `?` cell instead of silently deleting user config. + #[must_use] + pub fn resolve_hotbar_bindings(&self, known_action_ids: &[&str]) -> HotbarConfigResolution { + resolve_hotbar_bindings(self.hotbar.as_deref(), known_action_ids) + } +} + +/// Built-in profile seeds for common provider/model families. +/// +/// User-configured profiles are always checked first; these seeds only provide +/// a stable resolver result when config has no narrower match. +#[must_use] +pub fn built_in_harness_profiles() -> &'static [HarnessProfile] { + static PROFILES: OnceLock> = OnceLock::new(); + PROFILES.get_or_init(|| { + vec![ + HarnessProfile { + provider_route: "deepseek".to_string(), + model_pattern: "deepseek-v4*".to_string(), + posture: HarnessPosture::cache_heavy(), + }, + HarnessProfile { + provider_route: "xiaomi-mimo".to_string(), + model_pattern: "mimo-v2.5*".to_string(), + posture: HarnessPosture::cache_heavy(), + }, + HarnessProfile { + provider_route: "arcee".to_string(), + model_pattern: "trinity-large-thinking".to_string(), + posture: HarnessPosture::cache_heavy(), + }, + HarnessProfile { + provider_route: "huggingface".to_string(), + model_pattern: "*".to_string(), + posture: HarnessPosture::lean(), + }, + HarnessProfile { + provider_route: "sglang".to_string(), + model_pattern: "*".to_string(), + posture: HarnessPosture::lean(), + }, + HarnessProfile { + provider_route: "vllm".to_string(), + model_pattern: "*".to_string(), + posture: HarnessPosture::lean(), + }, + HarnessProfile { + provider_route: "ollama".to_string(), + model_pattern: "*".to_string(), + posture: HarnessPosture::lean(), + }, + ] + }) +} + +fn provider_routes_equal(expected: &str, actual: &str) -> bool { + match (ProviderKind::parse(expected), ProviderKind::parse(actual)) { + (Some(expected), Some(actual)) => expected == actual, + _ => expected.trim().eq_ignore_ascii_case(actual.trim()), + } +} + +fn wildcard_pattern_matches(pattern: &str, value: &str) -> bool { + wildcard_chars_match( + &pattern.chars().collect::>(), + &value.chars().collect::>(), + ) +} + +fn wildcard_chars_match(pattern: &[char], value: &[char]) -> bool { + let (mut pattern_idx, mut value_idx) = (0, 0); + let mut star_idx: Option = None; + let mut star_value_idx = 0; + + while value_idx < value.len() { + if pattern_idx < pattern.len() + && (pattern[pattern_idx] == '?' || pattern[pattern_idx] == value[value_idx]) + { + pattern_idx += 1; + value_idx += 1; + } else if pattern_idx < pattern.len() && pattern[pattern_idx] == '*' { + star_idx = Some(pattern_idx); + pattern_idx += 1; + star_value_idx = value_idx; + } else if let Some(star) = star_idx { + pattern_idx = star + 1; + star_value_idx += 1; + value_idx = star_value_idx; + } else { + return false; + } + } + + pattern[pattern_idx..].iter().all(|ch| *ch == '*') +} + +/// Ordered primary-plus-fallback provider list for future provider routing. +/// +/// The helper is intentionally dormant: constructing or parsing a chain does +/// not change [`ConfigToml::resolve_runtime_options`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderChain { + providers: Vec, + position: usize, +} + +pub const HOTBAR_SLOT_COUNT: u8 = 8; + +pub const DEFAULT_HOTBAR_ACTIONS: [&str; HOTBAR_SLOT_COUNT as usize] = [ + "voice.toggle", + "session.compact", + "mode.plan", + "mode.agent", + "mode.yolo", + "palette.open", + "sidebar.toggle", + "trust.toggle", +]; + +/// On-disk schema for one `[[hotbar]]` table. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct HotbarBindingToml { + pub slot: u8, + pub action: String, + #[serde(default)] + pub label: Option, +} + +/// Validated hotbar binding used by future render/dispatch layers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HotbarBinding { + pub slot: u8, + pub action: String, + pub label: Option, +} + +/// Non-fatal hotbar config issue. Invalid slots are skipped; duplicate slots +/// use the last binding; unknown actions are kept for UI feedback. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HotbarConfigWarning { + SlotOutOfRange { + slot: u8, + action: String, + }, + DuplicateSlot { + slot: u8, + previous_action: String, + replacement_action: String, + }, + UnknownAction { + slot: u8, + action: String, + }, +} + +impl fmt::Display for HotbarConfigWarning { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SlotOutOfRange { slot, action } => write!( + f, + "hotbar slot {slot} for action '{action}' is outside 1-{HOTBAR_SLOT_COUNT}; skipped" + ), + Self::DuplicateSlot { + slot, + previous_action, + replacement_action, + } => write!( + f, + "hotbar slot {slot} was bound to '{previous_action}' more than once; using '{replacement_action}'" + ), + Self::UnknownAction { slot, action } => write!( + f, + "hotbar slot {slot} references unknown action '{action}'; keeping binding" + ), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HotbarConfigResolution { + pub bindings: Vec, + pub warnings: Vec, +} + +#[must_use] +pub fn default_hotbar_bindings() -> Vec { + DEFAULT_HOTBAR_ACTIONS + .iter() + .enumerate() + .map(|(idx, action)| HotbarBinding { + slot: u8::try_from(idx + 1).expect("default hotbar slot fits in u8"), + action: (*action).to_string(), + label: None, + }) + .collect() +} + +#[must_use] +pub fn resolve_hotbar_bindings( + configured: Option<&[HotbarBindingToml]>, + known_action_ids: &[&str], +) -> HotbarConfigResolution { + let known = known_action_ids.iter().copied().collect::>(); + let mut warnings = Vec::new(); + + let source = match configured { + Some(bindings) => bindings + .iter() + .map(|binding| HotbarBinding { + slot: binding.slot, + action: binding.action.clone(), + label: binding.label.clone(), + }) + .collect::>(), + None => default_hotbar_bindings(), + }; + + let mut by_slot: BTreeMap = BTreeMap::new(); + for binding in source { + if !(1..=HOTBAR_SLOT_COUNT).contains(&binding.slot) { + warnings.push(HotbarConfigWarning::SlotOutOfRange { + slot: binding.slot, + action: binding.action, + }); + continue; + } + if !known.is_empty() && !known.contains(binding.action.as_str()) { + warnings.push(HotbarConfigWarning::UnknownAction { + slot: binding.slot, + action: binding.action.clone(), + }); + } + if let Some(previous) = by_slot.insert(binding.slot, binding.clone()) { + warnings.push(HotbarConfigWarning::DuplicateSlot { + slot: binding.slot, + previous_action: previous.action, + replacement_action: binding.action, + }); + } + } + + HotbarConfigResolution { + bindings: by_slot.into_values().collect(), + warnings, + } +} + +impl ProviderChain { + #[must_use] + pub fn new(active: ProviderKind, fallbacks: &[ProviderKind]) -> Self { + let mut providers = vec![active]; + for fallback in fallbacks { + if *fallback != active && !providers.contains(fallback) { + providers.push(*fallback); + } + } + Self { + providers, + position: 0, + } + } + + #[must_use] + pub fn providers(&self) -> &[ProviderKind] { + &self.providers + } + + #[must_use] + pub fn position(&self) -> usize { + self.position + } + + #[must_use] + pub fn current(&self) -> ProviderKind { + self.providers[self.position] + } + + #[must_use] + pub fn has_next(&self) -> bool { + self.position + 1 < self.providers.len() + } + + pub fn advance(&mut self) -> Option { + if !self.has_next() { + return None; + } + self.position += 1; + Some(self.current()) + } + + #[must_use] + pub fn is_fallback_active(&self) -> bool { + self.position > 0 + } + + /// Count the current provider plus untried chain entries. + #[must_use] + pub fn remaining(&self) -> usize { + self.providers.len() - self.position + } +} + /// On-disk schema for the `[hook_sinks]` table. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct HookSinksToml { @@ -622,6 +1148,7 @@ impl ConfigToml { "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.mode" => self.providers.xiaomi_mimo.mode.clone(), "providers.xiaomi_mimo.http_headers" => { serialize_http_headers(&self.providers.xiaomi_mimo.http_headers) } @@ -814,6 +1341,9 @@ impl ConfigToml { "providers.xiaomi_mimo.model" => { self.providers.xiaomi_mimo.model = Some(value.to_string()); } + "providers.xiaomi_mimo.mode" => { + self.providers.xiaomi_mimo.mode = Some(value.to_string()); + } "providers.xiaomi_mimo.http_headers" => { self.providers.xiaomi_mimo.http_headers = parse_http_headers(value)?; } @@ -1002,6 +1532,7 @@ impl ConfigToml { "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.mode" => self.providers.xiaomi_mimo.mode = None, "providers.xiaomi_mimo.http_headers" => { self.providers.xiaomi_mimo.http_headers.clear(); } @@ -1197,6 +1728,9 @@ impl ConfigToml { 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) = self.providers.xiaomi_mimo.mode.as_ref() { + out.insert("providers.xiaomi_mimo.mode".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); } @@ -1367,15 +1901,38 @@ impl ConfigToml { .or_else(|| provider_cfg.auth_mode.clone()) .or_else(|| self.auth_mode.clone()); let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key); - let explicit_api_key_for_endpoint = cli.api_key.as_deref().or(from_file.as_deref()); let configured_base_url = cli .base_url .clone() .or_else(|| env.base_url_for(provider)) .or_else(|| provider_cfg.base_url.clone()) .or(root_deepseek_base_url); + let xiaomi_mimo_mode = if provider == ProviderKind::XiaomiMimo { + env.xiaomi_mimo_mode + .clone() + .or_else(|| provider_cfg.mode.clone()) + } else { + None + }; + let xiaomi_mimo_env_api_key = if provider == ProviderKind::XiaomiMimo { + xiaomi_mimo_env_api_key_for_runtime( + xiaomi_mimo_mode.as_deref(), + configured_base_url.as_deref(), + ) + } else { + None + }; + let explicit_api_key_for_endpoint = cli + .api_key + .as_deref() + .or(from_file.as_deref()) + .or(xiaomi_mimo_env_api_key.as_deref()); let base_url = if provider == ProviderKind::XiaomiMimo { - resolve_xiaomi_mimo_base_url(configured_base_url, explicit_api_key_for_endpoint) + resolve_xiaomi_mimo_base_url( + configured_base_url, + explicit_api_key_for_endpoint, + xiaomi_mimo_mode.as_deref(), + ) } else { configured_base_url.unwrap_or_else(|| match provider { ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(), @@ -1417,6 +1974,8 @@ impl ConfigToml { (None, None) } else if let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty()) { (Some(value), Some(RuntimeApiKeySource::ConfigFile)) + } else if let Some(value) = xiaomi_mimo_env_api_key.filter(|v| !v.trim().is_empty()) { + (Some(value), Some(RuntimeApiKeySource::Env)) } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) { match codewhale_secrets::env_for(provider.as_str()) { Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)), @@ -1508,6 +2067,7 @@ impl ConfigToml { api_key_source, base_url, auth_mode, + insecure_skip_tls_verify: provider_cfg.insecure_skip_tls_verify.unwrap_or(false), output_mode, log_level, telemetry, @@ -1523,9 +2083,6 @@ fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &Provi if source.model.is_some() { target.model = source.model.clone(); } - if source.path_suffix.is_some() { - target.path_suffix = source.path_suffix.clone(); - } } #[must_use] @@ -1587,7 +2144,13 @@ pub fn load_project_config(workspace: &Path) -> Option { if path.exists() && let Ok(raw) = fs::read_to_string(&path) { - return toml::from_str(&raw).ok(); + match toml::from_str(&raw) { + Ok(config) => return Some(config), + Err(e) => { + tracing::warn!("Failed to parse project config {}: {e}", path.display()); + return None; + } + } } } None @@ -1848,15 +2411,124 @@ fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool { || normalized.starts_with("https://api.kimi.com/coding/") } -fn resolve_xiaomi_mimo_base_url(configured: Option, api_key: Option<&str>) -> String { +fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> { + let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-"); + if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) { + return None; + } + Some(match normalized.as_str() { + "token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => { + DEFAULT_XIAOMI_MIMO_BASE_URL + } + "token-plan-cn" + | "token-plan-china" + | "token-plan-mainland" + | "token-plan-mainland-china" + | "cn" + | "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL, + "token-plan-sgp" + | "token-plan-sg" + | "token-plan-singapore" + | "sgp" + | "sg" + | "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL, + "token-plan-ams" + | "token-plan-eu" + | "token-plan-europe" + | "token-plan-amsterdam" + | "ams" + | "eu" + | "europe" + | "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL, + _ => DEFAULT_XIAOMI_MIMO_BASE_URL, + }) +} + +fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool { + matches!( + normalized_mode, + "standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go" + ) +} + +fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool { + let normalized = base_url.trim_end_matches('/').to_ascii_lowercase(); + normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL + || normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL + || normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL +} + +fn xiaomi_mimo_env_var(candidates: &[&str]) -> Option { + candidates.iter().find_map(|name| { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + }) +} + +fn xiaomi_mimo_env_api_key_for_runtime( + mode: Option<&str>, + base_url: Option<&str>, +) -> Option { + const TOKEN_PLAN_ENV_VARS: &[&str] = + &["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"]; + const STANDARD_ENV_VARS: &[&str] = &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"]; + + let normalized_mode = + mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-")); + let standard_selected = normalized_mode + .as_deref() + .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint) + || base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go); + if standard_selected { + return xiaomi_mimo_env_var(STANDARD_ENV_VARS); + } + + let token_plan_selected = normalized_mode + .as_deref() + .and_then(xiaomi_mimo_base_url_for_mode) + .is_some() + || base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan); + if token_plan_selected { + return xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS); + } + + xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS)) +} + +fn resolve_xiaomi_mimo_base_url( + configured: Option, + api_key: Option<&str>, + mode: Option<&str>, +) -> String { + let normalized_mode = + mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-")); + let uses_standard_mode = normalized_mode + .as_deref() + .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint); + let mode_base_url = normalized_mode + .as_deref() + .and_then(xiaomi_mimo_base_url_for_mode); let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key); match configured { + Some(base_url) if uses_standard_mode => base_url, Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => { - DEFAULT_XIAOMI_MIMO_BASE_URL.to_string() + mode_base_url + .unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL) + .to_string() } Some(base_url) => base_url, - None if uses_token_plan || api_key.is_none() => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(), - None => XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string(), + None => { + if let Some(base_url) = mode_base_url { + base_url.to_string() + } else if uses_standard_mode { + XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string() + } else if uses_token_plan || api_key.is_none() { + DEFAULT_XIAOMI_MIMO_BASE_URL.to_string() + } else { + XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string() + } + } } } @@ -1875,6 +2547,12 @@ fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bo if provider.is_siliconflow() && siliconflow_base_url_is_official(base_url) { return false; } + if provider == ProviderKind::XiaomiMimo + && (xiaomi_mimo_base_url_uses_token_plan(base_url) + || xiaomi_mimo_base_url_is_pay_as_you_go(base_url)) + { + return false; + } let actual = base_url.trim_end_matches('/'); let default = default_base_url_for_provider(provider).trim_end_matches('/'); actual != default @@ -2014,6 +2692,7 @@ pub struct ResolvedRuntimeOptions { pub api_key_source: Option, pub base_url: String, pub auth_mode: Option, + pub insecure_skip_tls_verify: bool, pub output_mode: Option, pub log_level: Option, pub telemetry: bool, @@ -2098,6 +2777,15 @@ impl ConfigStore { pub fn permissions_path(&self) -> PathBuf { permissions_path_for_config_path(&self.path) } + + #[must_use] + pub fn exec_policy_engine(&self) -> ExecPolicyEngine { + if self.permissions.is_empty() { + ExecPolicyEngine::new(Vec::new(), Vec::new()) + } else { + ExecPolicyEngine::with_rulesets(vec![self.permissions.ruleset()]) + } + } } /// Process-wide default [`Secrets`] façade. The first caller wins; the @@ -2430,6 +3118,7 @@ struct EnvRuntimeOverrides { openrouter_model: Option, moonshot_model: Option, xiaomi_mimo_model: Option, + xiaomi_mimo_mode: Option, novita_model: Option, fireworks_model: Option, arcee_model: Option, @@ -2495,6 +3184,10 @@ impl EnvRuntimeOverrides { .or_else(|_| std::env::var("MIMO_MODEL")) .ok() .filter(|v| !v.trim().is_empty()), + xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE") + .or_else(|_| std::env::var("MIMO_MODE")) + .ok() + .filter(|v| !v.trim().is_empty()), novita_model: std::env::var("NOVITA_MODEL") .ok() .filter(|v| !v.trim().is_empty()), @@ -2509,15 +3202,33 @@ impl EnvRuntimeOverrides { log_level: std::env::var("DEEPSEEK_LOG_LEVEL").ok(), telemetry: std::env::var("DEEPSEEK_TELEMETRY") .ok() - .and_then(|v| parse_bool(&v).ok()), + .and_then(|v| match parse_bool(&v) { + Ok(b) => Some(b), + Err(_) => { + tracing::warn!("Invalid DEEPSEEK_TELEMETRY value '{v}', expected true/false"); + None + } + }), approval_policy: std::env::var("DEEPSEEK_APPROVAL_POLICY").ok(), sandbox_mode: std::env::var("DEEPSEEK_SANDBOX_MODE").ok(), yolo: std::env::var("DEEPSEEK_YOLO") .ok() - .and_then(|v| parse_bool(&v).ok()), + .and_then(|v| match parse_bool(&v) { + Ok(b) => Some(b), + Err(_) => { + tracing::warn!("Invalid DEEPSEEK_YOLO value '{v}', expected true/false"); + None + } + }), http_headers: std::env::var("DEEPSEEK_HTTP_HEADERS") .ok() - .and_then(|value| parse_http_headers(&value).ok()) + .and_then(|value| match parse_http_headers(&value) { + Ok(h) => Some(h), + Err(_) => { + tracing::warn!("Invalid DEEPSEEK_HTTP_HEADERS value, expected format: header1=val1,header2=val2"); + None + } + }) .filter(|headers| !headers.is_empty()), deepseek_base_url: std::env::var("CODEWHALE_BASE_URL") .or_else(|_| std::env::var("DEEPSEEK_BASE_URL")) @@ -2711,37 +3422,163 @@ mod tests { } #[test] - fn config_store_loads_sibling_permissions_toml() { - use std::time::{SystemTime, UNIX_EPOCH}; - - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos(); - let dir = std::env::temp_dir().join(format!( - "codewhale-permissions-schema-{}-{unique}", - std::process::id() - )); - fs::create_dir_all(&dir).expect("mkdir"); - let config_path = dir.join(CONFIG_FILE_NAME); - fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config"); - fs::write( - dir.join(PERMISSIONS_FILE_NAME), - r#" - [[rules]] - tool = "exec_shell" - command = "cargo test" - - [[rules]] - tool = "read_file" - path = "secrets/api_key.txt" - "#, - ) - .expect("write permissions"); + fn hotbar_defaults_when_config_is_absent() { + let config = ConfigToml::default(); - let store = ConfigStore::load(Some(config_path.clone())).expect("load config store"); + let resolved = config.resolve_hotbar_bindings(&DEFAULT_HOTBAR_ACTIONS); - assert_eq!(store.config.model.as_deref(), Some("deepseek-v4-flash")); + assert_eq!(resolved.warnings, Vec::new()); + assert_eq!(resolved.bindings, default_hotbar_bindings()); + assert_eq!( + resolved + .bindings + .iter() + .map(|binding| (binding.slot, binding.action.as_str())) + .collect::>(), + vec![ + (1, "voice.toggle"), + (2, "session.compact"), + (3, "mode.plan"), + (4, "mode.agent"), + (5, "mode.yolo"), + (6, "palette.open"), + (7, "sidebar.toggle"), + (8, "trust.toggle"), + ] + ); + } + + #[test] + fn hotbar_tables_parse_and_round_trip() { + let config: ConfigToml = toml::from_str( + r#" +[[hotbar]] +slot = 1 +label = "Plan" +action = "mode.plan" + +[[hotbar]] +slot = 2 +action = "session.compact" +"#, + ) + .expect("parse hotbar tables"); + + let resolved = config.resolve_hotbar_bindings(&["mode.plan", "session.compact"]); + + assert_eq!( + resolved.bindings, + vec![ + HotbarBinding { + slot: 1, + action: "mode.plan".to_string(), + label: Some("Plan".to_string()), + }, + HotbarBinding { + slot: 2, + action: "session.compact".to_string(), + label: None, + }, + ] + ); + assert_eq!(resolved.warnings, Vec::new()); + + let serialized = toml::to_string_pretty(&config).expect("serialize config"); + let round_tripped: ConfigToml = + toml::from_str(&serialized).expect("deserialize serialized config"); + assert_eq!(round_tripped.hotbar, config.hotbar); + } + + #[test] + fn hotbar_validation_warns_without_dropping_unknown_actions() { + let config: ConfigToml = toml::from_str( + r#" +[[hotbar]] +slot = 0 +action = "mode.plan" + +[[hotbar]] +slot = 2 +action = "mode.plan" + +[[hotbar]] +slot = 2 +action = "custom.action" + +[[hotbar]] +slot = 9 +action = "mode.agent" +"#, + ) + .expect("parse hotbar tables"); + + let resolved = config.resolve_hotbar_bindings(&["mode.plan", "mode.agent"]); + + assert_eq!( + resolved.bindings, + vec![HotbarBinding { + slot: 2, + action: "custom.action".to_string(), + label: None, + }] + ); + assert_eq!( + resolved.warnings, + vec![ + HotbarConfigWarning::SlotOutOfRange { + slot: 0, + action: "mode.plan".to_string(), + }, + HotbarConfigWarning::UnknownAction { + slot: 2, + action: "custom.action".to_string(), + }, + HotbarConfigWarning::DuplicateSlot { + slot: 2, + previous_action: "mode.plan".to_string(), + replacement_action: "custom.action".to_string(), + }, + HotbarConfigWarning::SlotOutOfRange { + slot: 9, + action: "mode.agent".to_string(), + }, + ] + ); + assert!(resolved.warnings[1].to_string().contains("keeping binding")); + } + + #[test] + fn config_store_loads_sibling_permissions_toml() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "codewhale-permissions-schema-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("mkdir"); + let config_path = dir.join(CONFIG_FILE_NAME); + fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config"); + fs::write( + dir.join(PERMISSIONS_FILE_NAME), + r#" + [[rules]] + tool = "exec_shell" + command = "cargo test" + + [[rules]] + tool = "read_file" + path = "secrets/api_key.txt" + "#, + ) + .expect("write permissions"); + + let store = ConfigStore::load(Some(config_path.clone())).expect("load config store"); + + assert_eq!(store.config.model.as_deref(), Some("deepseek-v4-flash")); assert_eq!( store.permissions().rules.as_slice(), &[ @@ -2792,6 +3629,54 @@ mod tests { let _ = fs::remove_dir_all(dir); } + #[test] + fn config_store_exec_policy_engine_uses_sibling_permissions() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "codewhale-permissions-engine-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("mkdir"); + let config_path = dir.join(CONFIG_FILE_NAME); + fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config"); + fs::write( + dir.join(PERMISSIONS_FILE_NAME), + r#" + [[rules]] + tool = "exec_shell" + command = "cargo test" + "#, + ) + .expect("write permissions"); + + let store = ConfigStore::load(Some(config_path)).expect("load config store"); + let decision = store + .exec_policy_engine() + .check(codewhale_execpolicy::ExecPolicyContext { + command: "cargo test --workspace", + cwd: "/workspace", + tool: Some("exec_shell"), + path: None, + ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted, + sandbox_mode: Some("workspace-write"), + }) + .expect("policy check"); + + assert!(decision.allow); + assert!(decision.requires_approval); + assert_eq!( + decision.matched_rule.as_deref(), + Some("tool=exec_shell command=cargo test") + ); + + let _ = fs::remove_dir_all(dir); + } + struct EnvGuard { deepseek_api_key: Option, deepseek_base_url: Option, @@ -2808,6 +3693,8 @@ mod tests { openrouter_api_key: Option, openrouter_base_url: Option, openrouter_model: Option, + xiaomi_mimo_token_plan_api_key: Option, + mimo_token_plan_api_key: Option, xiaomi_mimo_api_key: Option, xiaomi_api_key: Option, mimo_api_key: Option, @@ -2815,6 +3702,8 @@ mod tests { mimo_base_url: Option, xiaomi_mimo_model: Option, mimo_model: Option, + xiaomi_mimo_mode: Option, + mimo_mode: Option, wanjie_ark_api_key: Option, volcengine_api_key: Option, volcengine_ark_api_key: Option, @@ -2881,6 +3770,8 @@ mod tests { openrouter_api_key: env::var_os("OPENROUTER_API_KEY"), openrouter_base_url: env::var_os("OPENROUTER_BASE_URL"), openrouter_model: env::var_os("OPENROUTER_MODEL"), + xiaomi_mimo_token_plan_api_key: env::var_os("XIAOMI_MIMO_TOKEN_PLAN_API_KEY"), + mimo_token_plan_api_key: env::var_os("MIMO_TOKEN_PLAN_API_KEY"), xiaomi_mimo_api_key: env::var_os("XIAOMI_MIMO_API_KEY"), xiaomi_api_key: env::var_os("XIAOMI_API_KEY"), mimo_api_key: env::var_os("MIMO_API_KEY"), @@ -2888,6 +3779,8 @@ mod tests { 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"), + xiaomi_mimo_mode: env::var_os("XIAOMI_MIMO_MODE"), + mimo_mode: env::var_os("MIMO_MODE"), wanjie_ark_api_key: env::var_os("WANJIE_ARK_API_KEY"), volcengine_api_key: env::var_os("VOLCENGINE_API_KEY"), volcengine_ark_api_key: env::var_os("VOLCENGINE_ARK_API_KEY"), @@ -2949,6 +3842,8 @@ mod tests { env::remove_var("OPENROUTER_API_KEY"); env::remove_var("OPENROUTER_BASE_URL"); env::remove_var("OPENROUTER_MODEL"); + env::remove_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY"); + env::remove_var("MIMO_TOKEN_PLAN_API_KEY"); env::remove_var("XIAOMI_MIMO_API_KEY"); env::remove_var("XIAOMI_API_KEY"); env::remove_var("MIMO_API_KEY"); @@ -2956,6 +3851,8 @@ mod tests { env::remove_var("MIMO_BASE_URL"); env::remove_var("XIAOMI_MIMO_MODEL"); env::remove_var("MIMO_MODEL"); + env::remove_var("XIAOMI_MIMO_MODE"); + env::remove_var("MIMO_MODE"); env::remove_var("WANJIE_ARK_API_KEY"); env::remove_var("VOLCENGINE_API_KEY"); env::remove_var("VOLCENGINE_ARK_API_KEY"); @@ -3034,6 +3931,14 @@ mod tests { 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("OPENROUTER_MODEL", self.openrouter_model.take()); + Self::restore_var( + "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", + self.xiaomi_mimo_token_plan_api_key.take(), + ); + Self::restore_var( + "MIMO_TOKEN_PLAN_API_KEY", + self.mimo_token_plan_api_key.take(), + ); Self::restore_var("XIAOMI_MIMO_API_KEY", self.xiaomi_mimo_api_key.take()); Self::restore_var("XIAOMI_API_KEY", self.xiaomi_api_key.take()); Self::restore_var("MIMO_API_KEY", self.mimo_api_key.take()); @@ -3041,6 +3946,8 @@ mod tests { 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("XIAOMI_MIMO_MODE", self.xiaomi_mimo_mode.take()); + Self::restore_var("MIMO_MODE", self.mimo_mode.take()); Self::restore_var("WANJIE_ARK_API_KEY", self.wanjie_ark_api_key.take()); Self::restore_var("VOLCENGINE_API_KEY", self.volcengine_api_key.take()); Self::restore_var("VOLCENGINE_ARK_API_KEY", self.volcengine_ark_api_key.take()); @@ -3219,6 +4126,28 @@ mod tests { ); } + #[test] + fn insecure_skip_tls_verify_resolves_only_for_active_provider() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::Openai, + ..ConfigToml::default() + }; + config.providers.deepseek.insecure_skip_tls_verify = Some(true); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Openai); + assert!(!resolved.insecure_skip_tls_verify); + + config.providers.openai.insecure_skip_tls_verify = Some(true); + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Openai); + assert!(resolved.insecure_skip_tls_verify); + } + #[test] fn http_headers_env_overrides_config() { let _lock = env_lock(); @@ -3631,6 +4560,7 @@ unix_socket_path = "/tmp/cw-hooks.sock" ..ConfigToml::default() }; base.providers.openrouter.api_key = Some("user-openrouter-key".to_string()); + base.providers.openrouter.path_suffix = Some("/chat/completions".to_string()); let mut project = ConfigToml { provider: ProviderKind::Openrouter, @@ -3643,6 +4573,8 @@ unix_socket_path = "/tmp/cw-hooks.sock" }; 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.insecure_skip_tls_verify = Some(true); + project.providers.openrouter.path_suffix = Some("/attacker/chat".to_string()); project.providers.openrouter.model = Some("deepseek/deepseek-v4-pro".to_string()); project.providers.volcengine.model = Some("DeepSeek-V4-Pro".to_string()); project.providers.moonshot.model = Some("kimi-k2.6".to_string()); @@ -3659,6 +4591,11 @@ unix_socket_path = "/tmp/cw-hooks.sock" Some("user-openrouter-key") ); assert_eq!(base.providers.openrouter.base_url, None); + assert_eq!(base.providers.openrouter.insecure_skip_tls_verify, None); + assert_eq!( + base.providers.openrouter.path_suffix.as_deref(), + Some("/chat/completions") + ); assert_eq!(base.default_text_model.as_deref(), Some("deepseek-v4-pro")); assert_eq!( base.providers.openrouter.model.as_deref(), @@ -4013,6 +4950,92 @@ unix_socket_path = "/tmp/cw-hooks.sock" } } + #[test] + fn provider_metadata_registry_covers_every_provider_kind_once() { + let providers = provider::all_providers(); + assert_eq!(providers.len(), ProviderKind::ALL.len()); + + for (kind, provider) in ProviderKind::ALL.iter().zip(providers.iter()) { + assert_eq!(provider.kind(), *kind); + assert_eq!(provider.id(), kind.as_str()); + assert_eq!(kind.provider().id(), kind.as_str()); + } + + let mut ids = std::collections::BTreeSet::new(); + for provider in providers { + assert!(ids.insert(provider.id()), "duplicate provider id"); + } + } + + #[test] + fn provider_metadata_lookup_does_not_fall_back_to_deepseek() { + assert!(provider::lookup_provider("not-a-provider").is_none()); + assert!(provider::resolve_provider("not-a-provider").is_none()); + assert!(provider::lookup_provider("deepseek-cn").is_none()); + assert_eq!( + provider::resolve_provider("deepseek-cn") + .expect("legacy alias resolves") + .kind(), + ProviderKind::Deepseek + ); + } + + #[test] + fn provider_metadata_preserves_alias_and_config_key_semantics() { + assert_eq!( + provider::resolve_provider("open_router") + .expect("openrouter alias") + .kind(), + ProviderKind::Openrouter + ); + assert_eq!( + provider::resolve_provider("xiaomi") + .expect("xiaomi alias") + .kind(), + ProviderKind::XiaomiMimo + ); + assert_eq!( + provider::resolve_provider("kimi") + .expect("kimi alias") + .kind(), + ProviderKind::Moonshot + ); + assert_eq!( + provider::resolve_provider("hf") + .expect("huggingface alias") + .kind(), + ProviderKind::Huggingface + ); + + let siliconflow_cn = + provider::resolve_provider("siliconflow-cn").expect("siliconflow-cn alias resolves"); + assert_eq!(siliconflow_cn.kind(), ProviderKind::SiliconflowCN); + assert_eq!(siliconflow_cn.id(), "siliconflow-CN"); + assert_eq!(siliconflow_cn.provider_config_key(), "siliconflow"); + + let config = ProvidersToml::default(); + let shared_table = config.for_provider(ProviderKind::SiliconflowCN); + assert!(std::ptr::eq( + shared_table, + config.for_provider(ProviderKind::Siliconflow) + )); + } + + #[test] + fn provider_metadata_defaults_match_runtime_helpers() { + for kind in ProviderKind::ALL { + let provider = kind.provider(); + assert_eq!(provider.default_model(), default_model_for_provider(kind)); + assert_eq!( + provider.default_base_url(), + default_base_url_for_provider(kind) + ); + assert!(!provider.display_name().trim().is_empty()); + assert!(!provider.env_vars().is_empty()); + assert_eq!(provider.wire(), provider::WireFormat::ChatCompletions); + } + } + #[test] fn openrouter_provider_defaults_to_canonical_endpoint_and_model() { let _lock = env_lock(); @@ -4096,6 +5119,46 @@ model = "mimo-v2.5-pro" assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL); } + #[test] + fn xiaomi_mimo_token_plan_mode_accepts_region_aliases() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config: ConfigToml = toml::from_str( + r#" +provider = "mimo" + +[providers.mimo] +mode = "token-plan-ams" +"#, + ) + .expect("xiaomi token-plan region config"); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.base_url, XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL); + } + + #[test] + fn xiaomi_mimo_unknown_mode_stays_on_token_plan_endpoint() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config: ConfigToml = toml::from_str( + r#" +provider = "mimo" + +[providers.mimo] +mode = "token-plan-usa" +"#, + ) + .expect("xiaomi token-plan unknown mode config"); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL); + } + #[test] fn xiaomi_mimo_aliases_resolve_to_canonical_models() { assert_eq!( @@ -4563,6 +5626,48 @@ model = "mimo-v2.5-pro" assert_eq!(resolved.model, "mimo-v2.5"); } + #[test] + fn xiaomi_mimo_env_token_plan_mode_uses_token_plan_key_and_endpoint() { + 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("XIAOMI_MIMO_MODE", "token-plan-cn"); + env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key"); + env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key"); + } + + let resolved = + ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.api_key.as_deref(), Some("tp-env-key")); + assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env)); + assert_eq!(resolved.base_url, XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL); + } + + #[test] + fn xiaomi_mimo_env_pay_as_you_go_mode_prefers_standard_key() { + 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("XIAOMI_MIMO_MODE", "pay-as-you-go"); + env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key"); + env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key"); + } + + let resolved = + ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.api_key.as_deref(), Some("sk-env-key")); + assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env)); + assert_eq!(resolved.base_url, XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL); + } + #[test] fn novita_env_overrides_key_and_model_when_config_missing() { let _lock = env_lock(); @@ -5087,4 +6192,390 @@ model = "mimo-v2.5-pro" assert_eq!(resolved.api_key.as_deref(), Some("cli-key")); assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Cli)); } + + #[test] + fn provider_chain_initial_current_is_active() { + let chain = ProviderChain::new( + ProviderKind::NvidiaNim, + &[ProviderKind::Deepseek, ProviderKind::Openrouter], + ); + + assert_eq!(chain.current(), ProviderKind::NvidiaNim); + assert_eq!(chain.position(), 0); + assert_eq!( + chain.providers(), + &[ + ProviderKind::NvidiaNim, + ProviderKind::Deepseek, + ProviderKind::Openrouter, + ] + ); + assert!(!chain.is_fallback_active()); + } + + #[test] + fn provider_chain_advance_switches_to_fallback() { + let mut chain = ProviderChain::new( + ProviderKind::NvidiaNim, + &[ProviderKind::Deepseek, ProviderKind::Openrouter], + ); + + assert!(chain.has_next()); + assert_eq!(chain.advance(), Some(ProviderKind::Deepseek)); + assert_eq!(chain.current(), ProviderKind::Deepseek); + assert!(chain.is_fallback_active()); + } + + #[test] + fn provider_chain_exhausts_returns_none() { + let mut chain = ProviderChain::new(ProviderKind::Deepseek, &[ProviderKind::Openrouter]); + + assert_eq!(chain.advance(), Some(ProviderKind::Openrouter)); + assert!(!chain.has_next()); + assert_eq!(chain.advance(), None); + } + + #[test] + fn provider_chain_skips_duplicates() { + let chain = ProviderChain::new( + ProviderKind::Deepseek, + &[ + ProviderKind::Deepseek, + ProviderKind::NvidiaNim, + ProviderKind::Deepseek, + ], + ); + + assert_eq!( + chain.providers(), + &[ProviderKind::Deepseek, ProviderKind::NvidiaNim] + ); + } + + #[test] + fn provider_chain_remaining_counts_current_and_untried_entries() { + let mut chain = ProviderChain::new( + ProviderKind::Deepseek, + &[ProviderKind::NvidiaNim, ProviderKind::Openrouter], + ); + + assert_eq!(chain.remaining(), 3); + assert_eq!(chain.advance(), Some(ProviderKind::NvidiaNim)); + assert_eq!(chain.remaining(), 2); + } + + #[test] + fn config_toml_parses_fallback_providers() { + let config: ConfigToml = toml::from_str( + r#" +provider = "nvidia-nim" +fallback_providers = ["deepseek", "openrouter"] +"#, + ) + .expect("fallback providers config"); + + assert_eq!(config.provider, ProviderKind::NvidiaNim); + assert_eq!( + config.fallback_providers, + [ProviderKind::Deepseek, ProviderKind::Openrouter] + ); + } + + #[test] + fn empty_fallback_providers_do_not_serialize() { + let serialized = toml::to_string_pretty(&ConfigToml::default()).expect("config serializes"); + + assert!(!serialized.contains("fallback_providers")); + } + + #[test] + fn fallback_providers_do_not_change_runtime_resolution() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::NvidiaNim, + fallback_providers: vec![ProviderKind::Deepseek], + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::NvidiaNim); + } + + #[test] + fn harness_posture_default_is_standard() { + let posture = HarnessPosture::default(); + + assert_eq!( + posture, + HarnessPosture { + kind: HarnessPostureKind::Standard, + max_subagents: 0, + prefer_codebase_search: false, + compaction_strategy: HarnessCompactionStrategy::Default, + tool_surface: HarnessToolSurface::Full, + safety_posture: HarnessSafetyPosture::Standard, + } + ); + } + + #[test] + fn harness_posture_factories_are_typed() { + assert_eq!( + HarnessPosture::cache_heavy(), + HarnessPosture { + kind: HarnessPostureKind::CacheHeavy, + max_subagents: 10, + prefer_codebase_search: false, + compaction_strategy: HarnessCompactionStrategy::PrefixCache, + tool_surface: HarnessToolSurface::Full, + safety_posture: HarnessSafetyPosture::Standard, + } + ); + assert_eq!( + HarnessPosture::lean(), + HarnessPosture { + kind: HarnessPostureKind::Lean, + max_subagents: 20, + prefer_codebase_search: true, + compaction_strategy: HarnessCompactionStrategy::Aggressive, + tool_surface: HarnessToolSurface::Full, + safety_posture: HarnessSafetyPosture::Standard, + } + ); + } + + #[test] + fn harness_profile_serde_round_trips_as_a_whole_struct() { + let profile = HarnessProfile { + provider_route: "deepseek".to_string(), + model_pattern: "deepseek-v4.*".to_string(), + posture: HarnessPosture::cache_heavy(), + }; + + let json = serde_json::to_string(&profile).expect("serialize profile"); + let round_tripped: HarnessProfile = + serde_json::from_str(&json).expect("deserialize profile"); + + assert_eq!(round_tripped, profile); + } + + #[test] + fn config_toml_accepts_harness_profiles() { + let config: ConfigToml = toml::from_str( + r#" +provider = "deepseek" +model = "deepseek-v4-pro" + +[[harness_profiles]] +provider_route = "deepseek" +model_pattern = "deepseek-v4.*" + +[harness_profiles.posture] +kind = "cache-heavy" +max_subagents = 10 +compaction_strategy = "prefix-cache" +tool_surface = "read-only" +safety_posture = "strict" +"#, + ) + .expect("parse harness profiles"); + + assert_eq!( + config.harness_profiles, + vec![HarnessProfile { + provider_route: "deepseek".to_string(), + model_pattern: "deepseek-v4.*".to_string(), + posture: HarnessPosture { + kind: HarnessPostureKind::CacheHeavy, + max_subagents: 10, + prefer_codebase_search: false, + compaction_strategy: HarnessCompactionStrategy::PrefixCache, + tool_surface: HarnessToolSurface::ReadOnly, + safety_posture: HarnessSafetyPosture::Strict, + }, + }] + ); + } + + #[test] + fn harness_profile_matches_provider_alias_and_model_wildcard() { + let profile = HarnessProfile { + provider_route: "xiaomi-mimo".to_string(), + model_pattern: "mimo-v2.?-pro".to_string(), + posture: HarnessPosture::cache_heavy(), + }; + + assert!(profile.matches_route("mimo", "mimo-v2.5-pro")); + assert!(!profile.matches_route("mimo", "mimo-v2.50-pro")); + assert!(!profile.matches_route("deepseek", "mimo-v2.5-pro")); + } + + #[test] + fn resolve_harness_profile_returns_first_matching_profile() { + let config = ConfigToml { + harness_profiles: vec![ + HarnessProfile { + provider_route: "deepseek".to_string(), + model_pattern: "deepseek-v4-flash".to_string(), + posture: HarnessPosture::lean(), + }, + HarnessProfile { + provider_route: "deepseek".to_string(), + model_pattern: "deepseek-v4-*".to_string(), + posture: HarnessPosture::cache_heavy(), + }, + ], + ..ConfigToml::default() + }; + + let flash = config + .resolve_harness_profile("deepseek-cn", "deepseek-v4-flash") + .expect("exact profile should match first"); + assert_eq!(flash.posture.kind, HarnessPostureKind::Lean); + + let pro = config + .resolve_harness_profile("deepseek", "deepseek-v4-pro") + .expect("wildcard profile should match pro model"); + assert_eq!(pro.posture.kind, HarnessPostureKind::CacheHeavy); + } + + #[test] + fn resolve_harness_profile_uses_built_in_seed_when_config_has_no_match() { + let config = ConfigToml::default(); + + let xiaomi = config + .resolve_harness_profile("xiaomi", "mimo-v2.5-pro") + .expect("direct Xiaomi MiMo seed should resolve"); + assert_eq!(xiaomi.provider_route, "xiaomi-mimo"); + assert_eq!(xiaomi.posture.kind, HarnessPostureKind::CacheHeavy); + + let arcee = config + .resolve_harness_profile("arcee", "trinity-large-thinking") + .expect("direct Arcee seed should resolve"); + assert_eq!(arcee.posture.kind, HarnessPostureKind::CacheHeavy); + + let local = config + .resolve_harness_profile("vllm", "Qwen/Qwen3.6-Coder") + .expect("local seed should resolve"); + assert_eq!(local.posture.kind, HarnessPostureKind::Lean); + assert!(local.posture.prefer_codebase_search); + } + + #[test] + fn configured_harness_profile_overrides_built_in_seed() { + let config = ConfigToml { + harness_profiles: vec![HarnessProfile { + provider_route: "xiaomi-mimo".to_string(), + model_pattern: "mimo-v2.5-pro".to_string(), + posture: HarnessPosture { + kind: HarnessPostureKind::Custom, + max_subagents: 3, + prefer_codebase_search: true, + compaction_strategy: HarnessCompactionStrategy::Default, + tool_surface: HarnessToolSurface::Auto, + safety_posture: HarnessSafetyPosture::Strict, + }, + }], + ..ConfigToml::default() + }; + + let profile = config + .resolve_harness_profile("xiaomi-mimo", "mimo-v2.5-pro") + .expect("configured profile should match first"); + + assert_eq!(profile.posture.kind, HarnessPostureKind::Custom); + assert_eq!(profile.posture.max_subagents, 3); + assert_eq!(profile.posture.tool_surface, HarnessToolSurface::Auto); + assert_eq!(profile.posture.safety_posture, HarnessSafetyPosture::Strict); + } + + #[test] + fn resolve_harness_profile_returns_none_when_route_or_model_misses() { + let config = ConfigToml { + harness_profiles: vec![HarnessProfile { + provider_route: "huggingface".to_string(), + model_pattern: "deepseek-ai/*".to_string(), + posture: HarnessPosture::lean(), + }], + ..ConfigToml::default() + }; + + assert!( + config + .resolve_harness_profile("openrouter", "deepseek-ai/DeepSeek-V4-Pro") + .is_none() + ); + assert!( + config + .resolve_harness_profile("deepseek", "Qwen/Qwen3.6-Coder") + .is_none() + ); + assert!( + config + .resolve_harness_profile("openai", "mimo-v2.5-pro") + .is_none() + ); + } + + #[test] + fn resolving_harness_profile_does_not_change_runtime_options() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Deepseek, + model: Some("deepseek-v4-pro".to_string()), + harness_profiles: vec![HarnessProfile { + provider_route: "deepseek".to_string(), + model_pattern: "deepseek-v4-*".to_string(), + posture: HarnessPosture::lean(), + }], + ..ConfigToml::default() + }; + + let profile = config + .resolve_harness_profile("deepseek", "deepseek-v4-pro") + .expect("profile should resolve for display/future runtime"); + assert_eq!(profile.posture.kind, HarnessPostureKind::Lean); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + assert_eq!(resolved.provider, ProviderKind::Deepseek); + assert_eq!(resolved.model, "deepseek-v4-pro"); + } + + #[test] + fn harness_posture_kind_rejects_unknown_values() { + let err = toml::from_str::( + r#" +[[harness_profiles]] +provider_route = "deepseek" +model_pattern = "deepseek-v4.*" + +[harness_profiles.posture] +kind = "cahce-heavy" +"#, + ) + .expect_err("misspelled kind should not deserialize as custom"); + + assert!(err.to_string().contains("cahce-heavy")); + } + + #[test] + fn harness_posture_rejects_unknown_policy_keys() { + let err = toml::from_str::( + r#" +[[harness_profiles]] +provider_route = "deepseek" +model_pattern = "deepseek-v4.*" + +[harness_profiles.posture] +kind = "custom" +unknown_policy = "surprise" +"#, + ) + .expect_err("unknown posture keys should not be ignored"); + + assert!(err.to_string().contains("unknown_policy")); + } } diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs new file mode 100644 index 000000000..73fb96a29 --- /dev/null +++ b/crates/config/src/provider.rs @@ -0,0 +1,363 @@ +//! Built-in provider metadata. +//! +//! This module is a metadata foundation for collapsing provider drift over +//! time. It deliberately does not mutate request bodies or choose fallback +//! providers; runtime routing remains in `ConfigToml::resolve_runtime_options`. + +use super::{ + DEFAULT_ARCEE_BASE_URL, DEFAULT_ARCEE_MODEL, DEFAULT_ATLASCLOUD_BASE_URL, + DEFAULT_ATLASCLOUD_MODEL, DEFAULT_DEEPSEEK_BASE_URL, DEFAULT_DEEPSEEK_MODEL, + DEFAULT_FIREWORKS_BASE_URL, DEFAULT_FIREWORKS_MODEL, DEFAULT_HUGGINGFACE_BASE_URL, + DEFAULT_HUGGINGFACE_MODEL, DEFAULT_MOONSHOT_BASE_URL, DEFAULT_MOONSHOT_MODEL, + DEFAULT_NOVITA_BASE_URL, DEFAULT_NOVITA_MODEL, DEFAULT_NVIDIA_NIM_BASE_URL, + DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, + DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_OPENROUTER_BASE_URL, + DEFAULT_OPENROUTER_MODEL, DEFAULT_SGLANG_BASE_URL, DEFAULT_SGLANG_MODEL, + DEFAULT_SILICONFLOW_BASE_URL, DEFAULT_SILICONFLOW_CN_BASE_URL, DEFAULT_SILICONFLOW_MODEL, + DEFAULT_VLLM_BASE_URL, DEFAULT_VLLM_MODEL, DEFAULT_VOLCENGINE_BASE_URL, + DEFAULT_VOLCENGINE_MODEL, DEFAULT_WANJIE_ARK_BASE_URL, DEFAULT_WANJIE_ARK_MODEL, + DEFAULT_XIAOMI_MIMO_BASE_URL, DEFAULT_XIAOMI_MIMO_MODEL, ProviderKind, +}; + +/// Wire protocol spoken by a provider. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WireFormat { + /// OpenAI-compatible `/v1/chat/completions` style payloads. + ChatCompletions, +} + +/// Static metadata for a built-in model provider. +pub trait Provider: Send + Sync { + /// Provider enum variant represented by this entry. + fn kind(&self) -> ProviderKind; + + /// Canonical provider identifier. + fn id(&self) -> &'static str { + self.kind().as_str() + } + + /// Human-readable provider label for UIs and diagnostics. + fn display_name(&self) -> &'static str; + + /// Default base URL used when no config/env/CLI override is present. + fn default_base_url(&self) -> &'static str; + + /// Default model used when no config/env/CLI override is present. + fn default_model(&self) -> &'static str; + + /// Environment variable candidates used for this provider's API key. + fn env_vars(&self) -> &'static [&'static str]; + + /// TOML table key under `[providers.]`. + fn provider_config_key(&self) -> &'static str; + + /// Wire format used by the provider. + fn wire(&self) -> WireFormat { + WireFormat::ChatCompletions + } +} + +macro_rules! provider { + ( + $struct_name:ident, + $kind:ident, + $display_name:literal, + $base_url:ident, + $model:ident, + [$($env_var:literal),* $(,)?], + $config_key:literal + ) => { + /// Zero-sized metadata entry for this built-in provider. + pub struct $struct_name; + + impl Provider for $struct_name { + fn kind(&self) -> ProviderKind { + ProviderKind::$kind + } + + fn display_name(&self) -> &'static str { + $display_name + } + + fn default_base_url(&self) -> &'static str { + $base_url + } + + fn default_model(&self) -> &'static str { + $model + } + + fn env_vars(&self) -> &'static [&'static str] { + &[$($env_var),*] + } + + fn provider_config_key(&self) -> &'static str { + $config_key + } + } + }; +} + +provider!( + Deepseek, + Deepseek, + "DeepSeek", + DEFAULT_DEEPSEEK_BASE_URL, + DEFAULT_DEEPSEEK_MODEL, + ["DEEPSEEK_API_KEY"], + "deepseek" +); +provider!( + NvidiaNim, + NvidiaNim, + "NVIDIA NIM", + DEFAULT_NVIDIA_NIM_BASE_URL, + DEFAULT_NVIDIA_NIM_MODEL, + ["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"], + "nvidia_nim" +); +provider!( + Openai, + Openai, + "OpenAI-compatible", + DEFAULT_OPENAI_BASE_URL, + DEFAULT_OPENAI_MODEL, + ["OPENAI_API_KEY"], + "openai" +); +provider!( + Atlascloud, + Atlascloud, + "AtlasCloud", + DEFAULT_ATLASCLOUD_BASE_URL, + DEFAULT_ATLASCLOUD_MODEL, + ["ATLASCLOUD_API_KEY"], + "atlascloud" +); +provider!( + WanjieArk, + WanjieArk, + "Wanjie Ark", + DEFAULT_WANJIE_ARK_BASE_URL, + DEFAULT_WANJIE_ARK_MODEL, + [ + "WANJIE_ARK_API_KEY", + "WANJIE_API_KEY", + "WANJIE_MAAS_API_KEY" + ], + "wanjie_ark" +); +provider!( + Volcengine, + Volcengine, + "Volcengine Ark", + DEFAULT_VOLCENGINE_BASE_URL, + DEFAULT_VOLCENGINE_MODEL, + [ + "VOLCENGINE_API_KEY", + "VOLCENGINE_ARK_API_KEY", + "ARK_API_KEY" + ], + "volcengine" +); +provider!( + Openrouter, + Openrouter, + "OpenRouter", + DEFAULT_OPENROUTER_BASE_URL, + DEFAULT_OPENROUTER_MODEL, + ["OPENROUTER_API_KEY"], + "openrouter" +); +provider!( + XiaomiMimo, + XiaomiMimo, + "Xiaomi MiMo", + DEFAULT_XIAOMI_MIMO_BASE_URL, + DEFAULT_XIAOMI_MIMO_MODEL, + [ + "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", + "MIMO_TOKEN_PLAN_API_KEY", + "XIAOMI_MIMO_API_KEY", + "XIAOMI_API_KEY", + "MIMO_API_KEY", + ], + "xiaomi_mimo" +); +provider!( + Novita, + Novita, + "Novita", + DEFAULT_NOVITA_BASE_URL, + DEFAULT_NOVITA_MODEL, + ["NOVITA_API_KEY"], + "novita" +); +provider!( + Fireworks, + Fireworks, + "Fireworks", + DEFAULT_FIREWORKS_BASE_URL, + DEFAULT_FIREWORKS_MODEL, + ["FIREWORKS_API_KEY"], + "fireworks" +); +provider!( + Siliconflow, + Siliconflow, + "SiliconFlow", + DEFAULT_SILICONFLOW_BASE_URL, + DEFAULT_SILICONFLOW_MODEL, + ["SILICONFLOW_API_KEY"], + "siliconflow" +); +provider!( + SiliconflowCN, + SiliconflowCN, + "SiliconFlow CN", + DEFAULT_SILICONFLOW_CN_BASE_URL, + DEFAULT_SILICONFLOW_MODEL, + ["SILICONFLOW_API_KEY"], + "siliconflow" +); +provider!( + Arcee, + Arcee, + "Arcee", + DEFAULT_ARCEE_BASE_URL, + DEFAULT_ARCEE_MODEL, + ["ARCEE_API_KEY"], + "arcee" +); +provider!( + Moonshot, + Moonshot, + "Moonshot", + DEFAULT_MOONSHOT_BASE_URL, + DEFAULT_MOONSHOT_MODEL, + ["MOONSHOT_API_KEY", "KIMI_API_KEY"], + "moonshot" +); +provider!( + Sglang, + Sglang, + "SGLang", + DEFAULT_SGLANG_BASE_URL, + DEFAULT_SGLANG_MODEL, + ["SGLANG_API_KEY"], + "sglang" +); +provider!( + Vllm, + Vllm, + "vLLM", + DEFAULT_VLLM_BASE_URL, + DEFAULT_VLLM_MODEL, + ["VLLM_API_KEY"], + "vllm" +); +provider!( + Ollama, + Ollama, + "Ollama", + DEFAULT_OLLAMA_BASE_URL, + DEFAULT_OLLAMA_MODEL, + ["OLLAMA_API_KEY"], + "ollama" +); +provider!( + Huggingface, + Huggingface, + "Hugging Face", + DEFAULT_HUGGINGFACE_BASE_URL, + DEFAULT_HUGGINGFACE_MODEL, + ["HUGGINGFACE_API_KEY", "HF_TOKEN"], + "huggingface" +); + +static DEEPSEEK: Deepseek = Deepseek; +static NVIDIA_NIM: NvidiaNim = NvidiaNim; +static OPENAI: Openai = Openai; +static ATLASCLOUD: Atlascloud = Atlascloud; +static WANJIE_ARK: WanjieArk = WanjieArk; +static VOLCENGINE: Volcengine = Volcengine; +static OPENROUTER: Openrouter = Openrouter; +static XIAOMI_MIMO: XiaomiMimo = XiaomiMimo; +static NOVITA: Novita = Novita; +static FIREWORKS: Fireworks = Fireworks; +static SILICONFLOW: Siliconflow = Siliconflow; +static SILICONFLOW_CN: SiliconflowCN = SiliconflowCN; +static ARCEE: Arcee = Arcee; +static MOONSHOT: Moonshot = Moonshot; +static SGLANG: Sglang = Sglang; +static VLLM: Vllm = Vllm; +static OLLAMA: Ollama = Ollama; +static HUGGINGFACE: Huggingface = Huggingface; + +static PROVIDER_REGISTRY: [&dyn Provider; 18] = [ + &DEEPSEEK, + &NVIDIA_NIM, + &OPENAI, + &ATLASCLOUD, + &WANJIE_ARK, + &VOLCENGINE, + &OPENROUTER, + &XIAOMI_MIMO, + &NOVITA, + &FIREWORKS, + &SILICONFLOW, + &SILICONFLOW_CN, + &ARCEE, + &MOONSHOT, + &SGLANG, + &VLLM, + &OLLAMA, + &HUGGINGFACE, +]; + +/// Return all built-in provider metadata entries in `ProviderKind::ALL` order. +#[must_use] +pub fn all_providers() -> &'static [&'static dyn Provider] { + &PROVIDER_REGISTRY +} + +/// Find a provider by canonical id only. +#[must_use] +pub fn lookup_provider(id: &str) -> Option<&'static dyn Provider> { + let id = id.trim(); + all_providers() + .iter() + .copied() + .find(|provider| provider.id() == id) +} + +/// Resolve a provider by canonical id or supported legacy alias. +#[must_use] +pub fn resolve_provider(id_or_alias: &str) -> Option<&'static dyn Provider> { + ProviderKind::parse(id_or_alias).map(provider_for_kind) +} + +/// Return metadata for a known provider kind. +#[must_use] +pub fn provider_for_kind(kind: ProviderKind) -> &'static dyn Provider { + match kind { + ProviderKind::Deepseek => &DEEPSEEK, + ProviderKind::NvidiaNim => &NVIDIA_NIM, + 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::Siliconflow => &SILICONFLOW, + ProviderKind::SiliconflowCN => &SILICONFLOW_CN, + ProviderKind::Arcee => &ARCEE, + ProviderKind::Moonshot => &MOONSHOT, + ProviderKind::Sglang => &SGLANG, + ProviderKind::Vllm => &VLLM, + ProviderKind::Ollama => &OLLAMA, + ProviderKind::Huggingface => &HUGGINGFACE, + } +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index b8a5cb564..1831b3b00 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,13 +9,14 @@ description = "Core runtime boundaries for DeepSeek workspace architecture" [dependencies] anyhow.workspace = true chrono.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.53" } -codewhale-config = { path = "../config", version = "0.8.53" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.53" } -codewhale-hooks = { path = "../hooks", version = "0.8.53" } -codewhale-mcp = { path = "../mcp", version = "0.8.53" } -codewhale-protocol = { path = "../protocol", version = "0.8.53" } -codewhale-state = { path = "../state", version = "0.8.53" } -codewhale-tools = { path = "../tools", version = "0.8.53" } +codewhale-agent = { path = "../agent", version = "0.9.0" } +codewhale-config = { path = "../config", version = "0.9.0" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.9.0" } +codewhale-hooks = { path = "../hooks", version = "0.9.0" } +codewhale-mcp = { path = "../mcp", version = "0.9.0" } +codewhale-protocol = { path = "../protocol", version = "0.9.0" } +codewhale-state = { path = "../state", version = "0.9.0" } +codewhale-tools = { path = "../tools", version = "0.9.0" } serde_json.workspace = true +tracing.workspace = true uuid.workspace = true diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 85237a8c7..900e16b25 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -748,7 +748,9 @@ impl Runtime { hooks: HookDispatcher, ) -> Self { let mut jobs = JobManager::default(); - let _ = jobs.load_from_store(&state); + if let Err(e) = jobs.load_from_store(&state) { + tracing::warn!("Failed to load job store, starting with empty job list: {e}"); + } Self { config, model_registry, @@ -1095,11 +1097,12 @@ impl Runtime { ToolPayload::LocalShell { .. } => "exec_shell", _ => call.name.as_str(), }; + let policy_path = permission_path_for_call(&call); let decision = self.exec_policy.check(ExecPolicyContext { command: &command, cwd: &policy_cwd, tool: Some(policy_tool), - path: None, + path: policy_path.as_deref(), ask_for_approval: approval_mode, sandbox_mode: None, })?; @@ -1500,6 +1503,24 @@ fn preview_from_initial_history(initial_history: &InitialHistory) -> String { } } +fn permission_path_for_call(call: &ToolCall) -> Option { + match &call.payload { + ToolPayload::Function { arguments } => serde_json::from_str::(arguments) + .ok() + .and_then(|value| { + value + .get("path") + .and_then(Value::as_str) + .map(str::to_string) + }), + ToolPayload::Mcp { raw_arguments, .. } => raw_arguments + .get("path") + .and_then(Value::as_str) + .map(str::to_string), + ToolPayload::Custom { .. } | ToolPayload::LocalShell { .. } => None, + } +} + fn truncate_preview(value: &str) -> String { value.chars().take(120).collect() } @@ -1806,9 +1827,65 @@ fn job_state_status_to_runtime(status: JobStateStatus) -> JobStatus { #[cfg(test)] mod tests { use super::*; + use codewhale_tools::ToolCallSource; // ── JobManager: lifecycle ────────────────────────────────────────── + #[test] + fn permission_path_for_call_extracts_function_path_argument() { + let call = ToolCall { + name: "read_file".to_string(), + payload: ToolPayload::Function { + arguments: json!({ "path": "README.md" }).to_string(), + }, + source: ToolCallSource::Direct, + raw_tool_call_id: None, + }; + + assert_eq!( + permission_path_for_call(&call).as_deref(), + Some("README.md") + ); + } + + #[test] + fn permission_path_for_call_extracts_mcp_path_argument() { + let call = ToolCall { + name: "mcp_fs_read".to_string(), + payload: ToolPayload::Mcp { + server: "fs".to_string(), + tool: "read".to_string(), + raw_arguments: json!({ "path": "secrets/token.txt" }), + raw_tool_call_id: None, + }, + source: ToolCallSource::Direct, + raw_tool_call_id: None, + }; + + assert_eq!( + permission_path_for_call(&call).as_deref(), + Some("secrets/token.txt") + ); + } + + #[test] + fn permission_path_for_call_ignores_shell_payload() { + let call = ToolCall { + name: "exec_shell".to_string(), + payload: ToolPayload::LocalShell { + params: codewhale_protocol::LocalShellParams { + command: "cargo test".to_string(), + cwd: None, + timeout_ms: None, + }, + }, + source: ToolCallSource::Direct, + raw_tool_call_id: None, + }; + + assert_eq!(permission_path_for_call(&call), None); + } + #[test] fn enqueue_creates_queued_job_with_zero_progress() { let mut jm = JobManager::default(); diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index dfe0d1bb0..8049523a2 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.53" } +codewhale-protocol = { path = "../protocol", version = "0.9.0" } serde.workspace = true diff --git a/crates/execpolicy/src/bash_arity.rs b/crates/execpolicy/src/bash_arity.rs index 3bd25818e..e87afa303 100644 --- a/crates/execpolicy/src/bash_arity.rs +++ b/crates/execpolicy/src/bash_arity.rs @@ -359,8 +359,9 @@ impl BashArityDict { return true; } - // Fallback: plain normalised prefix match for patterns not in the table - // (preserves backward compatibility with exact-match allow rules). + // Fallback: word-boundary prefix match for patterns not in the arity table. + // Matches the exact pattern or the pattern followed by a space (i.e., at + // word boundary), so "ls" matches "ls" and "ls -la" but NOT "lsof". let command_lower = command.trim().to_ascii_lowercase(); // Normalise whitespace in both sides before comparing. let pattern_norm: String = pattern_lower @@ -371,7 +372,9 @@ impl BashArityDict { .split_whitespace() .collect::>() .join(" "); - command_norm == pattern_norm || command_norm.starts_with(&format!("{pattern_norm} ")) + command_norm == pattern_norm + || (command_norm.starts_with(&pattern_norm) + && command_norm.as_bytes().get(pattern_norm.len()) == Some(&b' ')) } /// Iterate over all entries in the dictionary. diff --git a/crates/execpolicy/src/lib.rs b/crates/execpolicy/src/lib.rs index 8a6003047..d826e2132 100644 --- a/crates/execpolicy/src/lib.rs +++ b/crates/execpolicy/src/lib.rs @@ -313,21 +313,26 @@ impl ExecPolicyEngine { self.rulesets .iter() - .flat_map(|ruleset| ruleset.ask_rules.iter()) - .filter(|rule| rule.tool == tool) - .filter(|rule| match rule.command.as_deref() { + .flat_map(|ruleset| { + ruleset + .ask_rules + .iter() + .map(move |rule| (ruleset.layer, rule)) + }) + .filter(|(_, rule)| rule.tool == tool) + .filter(|(_, rule)| match rule.command.as_deref() { Some(command) => self.arity_dict.allow_rule_matches(command, ctx.command), None => true, }) - .filter(|rule| match (rule.path.as_deref(), ctx.path) { + .filter(|(_, rule)| match (rule.path.as_deref(), ctx.path) { (Some(pattern), Some(path)) => { normalize_path_value(pattern) == normalize_path_value(path) } (Some(_), None) => false, (None, _) => true, }) - .max_by_key(|rule| ask_rule_specificity(rule)) - .cloned() + .max_by_key(|(layer, rule)| (*layer, ask_rule_specificity(rule))) + .map(|(_, rule)| rule.clone()) } /// Records an approval key for the current session so subsequent checks skip approval. @@ -347,11 +352,15 @@ impl ExecPolicyEngine { pub fn check(&self, ctx: ExecPolicyContext<'_>) -> Result { let normalized = normalize_command(ctx.command); let (trusted_prefixes, denied_prefixes) = self.resolve_prefixes(); - // Deny rules use simple prefix matching (no arity semantics needed). - if let Some(rule) = denied_prefixes - .iter() - .find(|rule| normalized.starts_with(&normalize_command(rule))) - { + // Deny rules use word-boundary prefix matching: the command must either + // equal the rule or start with the rule followed by a space, so "rm" + // blocks "rm -rf /" but NOT "rmdir" or "rmview". + if let Some(rule) = denied_prefixes.iter().find(|rule| { + let norm_rule = normalize_command(rule); + normalized == norm_rule + || (normalized.starts_with(&norm_rule) + && normalized.as_bytes().get(norm_rule.len()) == Some(&b' ')) + }) { return Ok(ExecPolicyDecision { allow: false, requires_approval: false, @@ -373,51 +382,82 @@ impl ExecPolicyEngine { let ask_rule = self.matching_ask_rule(&ctx); - let requirement = match &ctx.ask_for_approval { - AskForApproval::Never => { - if let Some(rule) = &ask_rule { - ExecApprovalRequirement::Forbidden { - reason: format!( - "Typed ask rule '{}' requires approval, but approval policy is never.", - rule.label() - ), + let mut matched_ask_rule = None; + // Resolve a matching typed ask-rule first. Ask-rules take precedence over + // mode-based handling for everything except `Never` (which forbids, + // because no prompt can be shown) and `Reject { rules: true }` (which + // explicitly rejects rule-exceptions). This ordering is checked against + // the experimental `if let` match-guard the original PR used; it is + // reproduced here with plain control flow for edition-2024 stable. + let ask_rule_requirement = match &ctx.ask_for_approval { + AskForApproval::Never | AskForApproval::Reject { rules: true, .. } => None, + _ => ask_rule.as_ref().map(|rule| { + matched_ask_rule = Some(rule.label()); + ExecApprovalRequirement::NeedsApproval { + reason: format!("Typed ask rule '{}' requires approval.", rule.label()), + proposed_execpolicy_amendment: None, + // A typed ask-rule approval (exec/fn/MCP) must not touch + // network policy. The original PR allow-listed `ctx.cwd` as a + // network host here, which is incorrect and security-relevant: + // approving e.g. an exec rule should never create a network + // allow-entry. Emit no network amendments for ask-rule prompts. + proposed_network_policy_amendments: Vec::new(), + } + }), + }; + + let requirement = if let Some(req) = ask_rule_requirement { + req + } else { + match &ctx.ask_for_approval { + AskForApproval::Never => { + if let Some(rule) = &ask_rule { + matched_ask_rule = Some(rule.label()); + ExecApprovalRequirement::Forbidden { + reason: format!( + "Typed ask rule '{}' requires approval, but approval policy is never.", + rule.label() + ), + } + } else { + ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: None, + } } - } else { - ExecApprovalRequirement::Skip { - bypass_sandbox: false, - proposed_execpolicy_amendment: None, + } + AskForApproval::Reject { rules, .. } if *rules => { + ExecApprovalRequirement::Forbidden { + reason: "Policy is configured to reject rule-exceptions.".to_string(), } } - } - AskForApproval::UnlessTrusted if is_trusted => ExecApprovalRequirement::Skip { - bypass_sandbox: false, - proposed_execpolicy_amendment: None, - }, - AskForApproval::OnFailure => ExecApprovalRequirement::Skip { - bypass_sandbox: false, - proposed_execpolicy_amendment: None, - }, - AskForApproval::Reject { rules, .. } if *rules => ExecApprovalRequirement::Forbidden { - reason: "Policy is configured to reject rule-exceptions.".to_string(), - }, - _ => ExecApprovalRequirement::NeedsApproval { - reason: if is_trusted { - "Approval requested by policy mode.".to_string() - } else { - "Unmatched command prefix requires approval.".to_string() + AskForApproval::UnlessTrusted if is_trusted => ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: None, + }, + AskForApproval::OnFailure => ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: None, }, - proposed_execpolicy_amendment: if is_trusted { - None - } else { - Some(ExecPolicyAmendment { - prefixes: vec![first_token(ctx.command)], - }) + _ => ExecApprovalRequirement::NeedsApproval { + reason: if is_trusted { + "Approval requested by policy mode.".to_string() + } else { + "Unmatched command prefix requires approval.".to_string() + }, + proposed_execpolicy_amendment: if is_trusted { + None + } else { + Some(ExecPolicyAmendment { + prefixes: vec![first_token(ctx.command)], + }) + }, + proposed_network_policy_amendments: vec![NetworkPolicyAmendment { + host: ctx.cwd.to_string(), + action: NetworkPolicyRuleAction::Allow, + }], }, - proposed_network_policy_amendments: vec![NetworkPolicyAmendment { - host: ctx.cwd.to_string(), - action: NetworkPolicyRuleAction::Allow, - }], - }, + } }; let (allow, requires_approval) = match requirement { @@ -426,12 +466,6 @@ impl ExecPolicyEngine { ExecApprovalRequirement::Forbidden { .. } => (false, false), }; - let matched_ask_rule = if matches!(&ctx.ask_for_approval, AskForApproval::Never) { - ask_rule.map(|rule| rule.label()) - } else { - None - }; - Ok(ExecPolicyDecision { allow, requires_approval, @@ -442,7 +476,13 @@ impl ExecPolicyEngine { } fn normalize_command(value: &str) -> String { - value.trim().to_ascii_lowercase() + // Normalize: lowercase, collapse internal whitespace to single spaces. + // This prevents bypass via "git status" (double space) vs "git status". + value + .split_whitespace() + .collect::>() + .join(" ") + .to_ascii_lowercase() } fn first_token(command: &str) -> String { @@ -629,7 +669,7 @@ mod tests { } #[test] - fn typed_ask_rule_is_ignored_outside_never_mode_for_now() { + fn typed_ask_rule_requires_approval_under_unless_trusted() { let engine = ExecPolicyEngine::with_rulesets(vec![ Ruleset::user(vec![], vec![]) .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]), @@ -641,18 +681,49 @@ mod tests { assert!(decision.allow); assert!(decision.requires_approval); - assert_eq!(decision.matched_rule, None); + assert_eq!( + decision.matched_rule.as_deref(), + Some("tool=exec_shell command=cargo test") + ); match decision.requirement { ExecApprovalRequirement::NeedsApproval { - proposed_execpolicy_amendment: Some(amendment), + proposed_execpolicy_amendment, + proposed_network_policy_amendments, .. - } => assert_eq!(amendment.prefixes, vec!["cargo"]), - other => panic!("expected unchanged approval behavior, got {other:?}"), + } => { + assert_eq!(proposed_execpolicy_amendment, None); + // A typed ask-rule approval must not allow-list the cwd (or + // anything else) as a network host. See the NeedsApproval arm. + assert!( + proposed_network_policy_amendments.is_empty(), + "ask-rule approval must not propose network amendments, got {proposed_network_policy_amendments:?}" + ); + } + other => panic!("expected typed ask approval, got {other:?}"), } } #[test] - fn typed_ask_rule_does_not_change_allow_deny_precedence() { + fn typed_ask_rule_requires_approval_under_on_failure() { + let engine = ExecPolicyEngine::with_rulesets(vec![ + Ruleset::user(vec![], vec![]) + .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]), + ]); + + let decision = engine + .check(ctx("cargo test --workspace", AskForApproval::OnFailure)) + .unwrap(); + + assert!(decision.allow); + assert!(decision.requires_approval); + assert_eq!( + decision.reason(), + "Typed ask rule 'tool=exec_shell command=cargo test' requires approval." + ); + } + + #[test] + fn typed_ask_rule_overrides_trusted_but_not_deny() { let engine = ExecPolicyEngine::with_rulesets(vec![ Ruleset::user( vec!["cargo test".to_string()], @@ -665,8 +736,11 @@ mod tests { .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted)) .unwrap(); assert!(trusted.allow); - assert!(!trusted.requires_approval); - assert_eq!(trusted.matched_rule.as_deref(), Some("cargo test")); + assert!(trusted.requires_approval); + assert_eq!( + trusted.matched_rule.as_deref(), + Some("tool=exec_shell command=cargo test") + ); let denied = engine .check(ctx("cargo test --danger", AskForApproval::Never)) @@ -680,6 +754,56 @@ mod tests { ); } + #[test] + fn typed_ask_rule_prefers_higher_layer_before_specificity() { + let engine = ExecPolicyEngine::with_rulesets(vec![ + Ruleset::agent(vec![], vec![]) + .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test --workspace")]), + Ruleset::user(vec![], vec![]) + .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]), + ]); + + let decision = engine + .check(ctx( + "cargo test --workspace --all-features", + AskForApproval::UnlessTrusted, + )) + .unwrap(); + + assert!(decision.requires_approval); + assert_eq!( + decision.matched_rule.as_deref(), + Some("tool=exec_shell command=cargo test") + ); + } + + #[test] + fn reject_rules_mode_still_forbids_matching_ask_rule() { + let engine = ExecPolicyEngine::with_rulesets(vec![ + Ruleset::user(vec![], vec![]) + .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]), + ]); + + let decision = engine + .check(ctx( + "cargo test --workspace", + AskForApproval::Reject { + sandbox_approval: false, + rules: true, + mcp_elicitations: false, + }, + )) + .unwrap(); + + assert!(!decision.allow); + assert!(!decision.requires_approval); + assert_eq!(decision.matched_rule, None); + assert_eq!( + decision.reason(), + "Policy is configured to reject rule-exceptions." + ); + } + #[test] fn typed_ask_rule_label_wins_when_never_blocks_trusted_command() { let engine = ExecPolicyEngine::with_rulesets(vec![ diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index 3083a3b36..c0e47730e 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.53" } +codewhale-protocol = { path = "../protocol", version = "0.9.0" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/release/Cargo.toml b/crates/release/Cargo.toml index 675206863..699419bba 100644 --- a/crates/release/Cargo.toml +++ b/crates/release/Cargo.toml @@ -9,6 +9,7 @@ description = "Shared CodeWhale release discovery and version comparison helpers [dependencies] anyhow.workspace = true reqwest = { workspace = true, features = ["blocking"] } +rustls.workspace = true semver.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/secrets/Cargo.toml b/crates/secrets/Cargo.toml index 848174203..7db781eb2 100644 --- a/crates/secrets/Cargo.toml +++ b/crates/secrets/Cargo.toml @@ -19,7 +19,7 @@ keyring = { version = "3", features = ["apple-native"] } [target.'cfg(target_os = "windows")'.dependencies] keyring = { version = "3", features = ["windows-native"] } -[target.'cfg(target_os = "linux")'.dependencies] +[target.'cfg(all(target_os = "linux", not(target_env = "ohos")))'.dependencies] keyring = { version = "3", features = ["linux-native-sync-persistent", "crypto-rust"] } [dev-dependencies] diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs index 65c9c185e..d9826b451 100644 --- a/crates/secrets/src/lib.rs +++ b/crates/secrets/src/lib.rs @@ -92,7 +92,7 @@ pub trait KeyringStore: Send + Sync { /// Wraps the platform credential store: /// - **macOS**: Keychain (via `security` framework) /// - **Windows**: Credential Manager -/// - **Linux**: Secret Service (GNOME Keyring / kwallet via dbus) +/// - **Linux**: Secret Service (GNOME Keyring / kwallet via dbus), excluding OHOS /// /// This backend is opt-in -- set the [`SECRET_BACKEND_ENV`] environment /// variable to `system` or `keyring` to activate it. On platforms without @@ -124,7 +124,11 @@ impl DefaultKeyringStore { /// Probe the OS keyring without writing anything. Returns `Ok(())` if /// a backend is reachable, otherwise an error describing why not. pub fn probe(&self) -> Result<(), SecretsError> { - #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] + #[cfg(any( + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + ))] { // `Entry::new` is enough to validate the native macOS/Windows // backend path. Avoid a dummy read there because it can trigger @@ -149,7 +153,11 @@ impl DefaultKeyringStore { Err(other) => Err(SecretsError::Keyring(other.to_string())), } } - #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + )))] { let _ = &self.service; Err(SecretsError::Keyring(unsupported_keyring_message())) @@ -159,7 +167,11 @@ impl DefaultKeyringStore { impl KeyringStore for DefaultKeyringStore { fn get(&self, key: &str) -> Result, SecretsError> { - #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] + #[cfg(any( + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + ))] { let entry = keyring::Entry::new(&self.service, key) .map_err(|err| SecretsError::Keyring(err.to_string()))?; @@ -169,7 +181,11 @@ impl KeyringStore for DefaultKeyringStore { Err(err) => Err(SecretsError::Keyring(err.to_string())), } } - #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + )))] { let _ = key; Err(SecretsError::Keyring(unsupported_keyring_message())) @@ -177,7 +193,11 @@ impl KeyringStore for DefaultKeyringStore { } fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> { - #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] + #[cfg(any( + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + ))] { let entry = keyring::Entry::new(&self.service, key) .map_err(|err| SecretsError::Keyring(err.to_string()))?; @@ -185,7 +205,11 @@ impl KeyringStore for DefaultKeyringStore { .set_password(value) .map_err(|err| SecretsError::Keyring(err.to_string())) } - #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + )))] { let _ = (key, value); Err(SecretsError::Keyring(unsupported_keyring_message())) @@ -193,7 +217,11 @@ impl KeyringStore for DefaultKeyringStore { } fn delete(&self, key: &str) -> Result<(), SecretsError> { - #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] + #[cfg(any( + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + ))] { let entry = keyring::Entry::new(&self.service, key) .map_err(|err| SecretsError::Keyring(err.to_string()))?; @@ -202,7 +230,11 @@ impl KeyringStore for DefaultKeyringStore { Err(err) => Err(SecretsError::Keyring(err.to_string())), } } - #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + )))] { let _ = key; Err(SecretsError::Keyring(unsupported_keyring_message())) @@ -214,7 +246,11 @@ impl KeyringStore for DefaultKeyringStore { } } -#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +#[cfg(not(any( + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) +)))] fn unsupported_keyring_message() -> String { "system keyring backend is unsupported on this platform".to_string() } diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 8b75306be..2bf42ebb9 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -267,7 +267,7 @@ impl StateStore { fn init_schema(&self) -> Result<()> { let conn = self.conn()?; - let user_version: u32 = conn.query_row("PRAGMA user_version;", [], |row| row.get(0))?; + let mut user_version: u32 = conn.query_row("PRAGMA user_version;", [], |row| row.get(0))?; if user_version == 0 { conn.execute_batch( r#" @@ -376,6 +376,104 @@ impl StateStore { "#, ) .context("failed to initialize thread schema")?; + user_version = 1; + } + if user_version < 2 { + conn.execute_batch( + r#" + BEGIN; + CREATE TABLE IF NOT EXISTS workflow_runs ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL, + goal TEXT NOT NULL, + status TEXT NOT NULL, + input_hash TEXT, + started_at INTEGER NOT NULL, + completed_at INTEGER, + metadata_json TEXT NOT NULL DEFAULT '{}' + ); + CREATE INDEX IF NOT EXISTS idx_workflow_runs_status_started_at + ON workflow_runs(status, started_at DESC); + CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow_started_at + ON workflow_runs(workflow_id, started_at DESC); + + CREATE TABLE IF NOT EXISTS branch_runs ( + id TEXT PRIMARY KEY, + workflow_run_id TEXT NOT NULL, + branch_id TEXT NOT NULL, + node_id TEXT NOT NULL, + status TEXT NOT NULL, + started_at INTEGER NOT NULL, + completed_at INTEGER, + result_json TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY(workflow_run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_branch_runs_workflow_run_id + ON branch_runs(workflow_run_id); + CREATE INDEX IF NOT EXISTS idx_branch_runs_branch_id + ON branch_runs(branch_id); + + CREATE TABLE IF NOT EXISTS leaf_runs ( + id TEXT PRIMARY KEY, + workflow_run_id TEXT NOT NULL, + branch_run_id TEXT, + leaf_id TEXT NOT NULL, + task_id TEXT NOT NULL, + input_hash TEXT, + status TEXT NOT NULL, + output_json TEXT NOT NULL DEFAULT '{}', + artifacts_json TEXT NOT NULL DEFAULT '[]', + started_at INTEGER NOT NULL, + completed_at INTEGER, + FOREIGN KEY(workflow_run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE, + FOREIGN KEY(branch_run_id) REFERENCES branch_runs(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_leaf_runs_workflow_run_id + ON leaf_runs(workflow_run_id); + CREATE INDEX IF NOT EXISTS idx_leaf_runs_replay_lookup + ON leaf_runs(workflow_run_id, leaf_id, input_hash); + + CREATE TABLE IF NOT EXISTS control_node_runs ( + id TEXT PRIMARY KEY, + workflow_run_id TEXT NOT NULL, + node_id TEXT NOT NULL, + kind TEXT NOT NULL, + status TEXT NOT NULL, + selected_children_json TEXT NOT NULL DEFAULT '[]', + result_json TEXT NOT NULL DEFAULT '{}', + started_at INTEGER NOT NULL, + completed_at INTEGER, + FOREIGN KEY(workflow_run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_control_node_runs_workflow_run_id + ON control_node_runs(workflow_run_id); + CREATE INDEX IF NOT EXISTS idx_control_node_runs_node_id + ON control_node_runs(node_id); + + CREATE TABLE IF NOT EXISTS teacher_candidates ( + id TEXT PRIMARY KEY, + workflow_run_id TEXT NOT NULL, + control_node_run_id TEXT NOT NULL, + candidate_id TEXT NOT NULL, + branch_run_id TEXT, + score REAL, + passed INTEGER, + rationale_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + FOREIGN KEY(workflow_run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE, + FOREIGN KEY(control_node_run_id) REFERENCES control_node_runs(id) ON DELETE CASCADE, + FOREIGN KEY(branch_run_id) REFERENCES branch_runs(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_teacher_candidates_workflow_run_id + ON teacher_candidates(workflow_run_id); + CREATE INDEX IF NOT EXISTS idx_teacher_candidates_control_node_run_id + ON teacher_candidates(control_node_run_id); + + PRAGMA user_version = 2; + COMMIT; + "#, + ) + .context("failed to initialize workflow trace schema")?; } Ok(()) } diff --git a/crates/state/tests/parity_state.rs b/crates/state/tests/parity_state.rs index 2590b2a59..69481e080 100644 --- a/crates/state/tests/parity_state.rs +++ b/crates/state/tests/parity_state.rs @@ -12,6 +12,30 @@ fn temp_state_path(label: &str) -> PathBuf { )) } +fn assert_workflow_trace_schema(conn: &Connection) { + let user_version: u32 = conn + .query_row("PRAGMA user_version;", [], |row| row.get(0)) + .expect("read user_version"); + assert_eq!(user_version, 2); + + for table in [ + "workflow_runs", + "branch_runs", + "leaf_runs", + "control_node_runs", + "teacher_candidates", + ] { + let exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)", + [table], + |row| row.get(0), + ) + .unwrap_or_else(|err| panic!("read sqlite_master for {table}: {err}")); + assert!(exists, "missing workflow trace table {table}"); + } +} + #[test] fn upsert_and_resume_thread_metadata() { let path = temp_state_path("upsert_resume"); @@ -157,6 +181,102 @@ fn init_schema_migration() { StateStore::open(Some(path.clone())).expect("open state store"); } +#[test] +fn fresh_schema_includes_workflow_trace_tables() { + let path = temp_state_path("fresh_schema_includes_workflow_trace_tables"); + + StateStore::open(Some(path.clone())).expect("open state store"); + + let conn = Connection::open(&path).expect("open state db"); + assert_workflow_trace_schema(&conn); +} + +#[test] +fn v1_schema_migrates_workflow_trace_tables() { + let path = temp_state_path("v1_schema_migrates_workflow_trace_tables"); + let conn = Connection::open(&path).expect("open state db"); + conn.execute_batch( + r#" + CREATE TABLE 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, + current_leaf_id INTEGER + ); + CREATE TABLE 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, + parent_entry_id INTEGER + ); + CREATE TABLE 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) + ); + CREATE TABLE 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 TABLE 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) + ); + 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 + ); + PRAGMA user_version = 1; + "#, + ) + .expect("create v1 schema"); + drop(conn); + + let store = StateStore::open(Some(path.clone())).expect("open state store"); + let thread = store + .get_thread("thread-test-1") + .expect("read thread") + .expect("thread survives migration"); + assert_eq!(thread.preview, "hello"); + + let conn = Connection::open(&path).expect("open state db"); + assert_workflow_trace_schema(&conn); +} + #[test] fn init_schema_migration_same_second_messages() { let path = temp_state_path("init_schema_migration_same_second_messages"); diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index fc65af8d6..02dacc734 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.53" } +codewhale-protocol = { path = "../protocol", version = "0.9.0" } serde.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index dec9b971b..e5998cadb 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,445 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.0] - 2026-06-07 + +### Added + +- Added `/restore list [N]` so users can inspect more side-git rollback + snapshots with UTC timestamps before choosing a restore point. Plain + `/restore` now shows the 20 most recent snapshots, numeric restore targets can + reach beyond that default listing up to a bounded index, and list requests + above the visible cap fail explicitly instead of silently truncating. +- Added HarmonyOS/OpenHarmony support scaffolding: environment-driven + `OHOS_NATIVE_SDK` setup scripts and compiler wrappers, platform docs, + explicit Rustls ring-provider installation for the no-provider TLS build, and + OHOS fallbacks for unsupported keyring, clipboard, sandbox, browser-open, TTY, + execpolicy Starlark parsing, and self-update surfaces. +- Added `scripts/release/check-ohos-deps.sh` and wired it into CI/release + preflight so the OpenHarmony target graph fails if unsupported `nix`, + `portable-pty`, `starlark`, `arboard`, or `keyring` dependencies re-enter. +- Added `.github/AUTHOR_MAP` and a CI co-author credit check so harvested + commits use GitHub-mappable numeric noreply identities instead of `.local`, + placeholder, bot/tool, or raw third-party emails. +- Added a `turn_end` observer hook that fires after post-turn TUI state and + token totals are updated. Hooks receive structured JSON with status, usage, + totals, duration, tool count, and queued-message count on stdin; stdout is + ignored and failures are warn-only (#1364, #2578). +- Added provider-scoped `insecure_skip_tls_verify` for private + OpenAI-compatible gateways that cannot use a trusted CA bundle. The setting is + disabled by default, applies only to the active LLM provider HTTP client, and + is surfaced by `codewhale doctor`; `SSL_CERT_FILE` remains the preferred path + for corporate or private CA roots. Thanks @wavezhang for the original #1893 + direction. +- Added a default-disabled hard-compaction planner that can identify the + summarizable middle of a long conversation while preserving the recent tail, + existing tool-call/result pair guarantees, and working-set pinning. This + harvests the safe planning layer from #2522 without enabling hard compaction + or adding a message-rewrite execution path yet. Thanks @HUQIANTAO for the + proposal. +- Added rich PlanArtifact support to `update_plan`: Plan mode can now carry + grounded objectives, context, sources, critical files, constraints, + verification, risks, and handoff notes through the transcript card, Plan + confirmation prompt, `/relay`, fork-state, and saved-session replay. +- Added the first `codewhale-whaleflow` foundation crate with typed workflow + config/IR validation and deterministic phase ordering tests. This preserves + the WhaleFlow direction from #2482/#2486 without exposing a runtime + `workflow_run` tool until cancellation, replay, and worktree semantics are + release-safe. The foundation now includes explicit `WorkflowSpec`, + `WorkflowNode`, branch/leaf/policy metadata structs, plus serializable branch, + leaf, and control-node result records toward the #2668 TraceStore contract. + It also adds a crate-local mock executor skeleton for Sequence, BranchSet, + Leaf, Reduce, LoopUntil, Cond, Expand, BranchTournament, and ParetoFrontier + control flow so #2669 can progress without spawning agents, applying + worktrees, or exposing a `workflow_run` runtime tool yet. A first Starlark + authoring layer now compiles fail-closed model-authored workflow files into + that typed IR, with `rlm_cache_change.star` and `issue_fix_tournament.star` + examples plus a one-pass repair for common `ctx.*` authoring aliases (#2670). + Leaf, branch, and workflow execution results now carry deterministic token + and cost telemetry fields that the mock executor can aggregate without live + provider calls or runtime sub-agent fanout (#2486). The mock executor now + carries crate-local cancellation and budget-exhaustion status markers so the + branch/leaf runtime contract can be tested before live workflow execution is + exposed (#2669). A crate-only replay executor now evaluates workflows from + recorded leaf/control records, computes + stable SHA-256 leaf input hashes, and marks missing records as + `replay_diverged` instead of calling models again (#2673); the runtime replay + command and live-provider replay fallback remain deferred. The crate also now + has a model-agnostic role/capability registry with mock provider plumbing and + fail-closed JSON repair parsing, so WhaleFlow can choose capable models for + roles without hardcoding provider-specific runtime paths (#2672). The + `rlm_cache_change.star` dogfood workflow now exercises candidate branches, + LoopUntil verification, tournament selection, teacher review, and mock + execution in CI-oriented crate tests (#2679). Leaf, branch, and workflow + results now also carry separate ARMH/shared-memo and provider prompt-cache + telemetry counters, with mock aggregation tests, so #2671 can progress + without wiring live RLM calls or billing-affecting provider behavior yet. The + Starlark and typed-IR gates now also reject unknown leaf dependencies, + reducer inputs, and teacher-review candidates before mock execution or replay, + keeping generated workflows fail-closed while runtime/worktree semantics stay + deferred. TeacherReview now has serializable GEPA-style candidate artifacts + for notes, workflow recipes, skills, regression tests, cache policy, branch + heuristics, and Starlark authoring prompt patches, plus an offline helper + that proposes candidates from recorded execution traces without promoting + them or training model weights (#2674). StudentReplay results can now be + stored on teacher candidates, and a deterministic PromotionGate compares + baseline-vs-candidate replay deltas, required tests, policy violations, + staleness, and cost constraints before marking a candidate promotable (#2675). + The external-memory cutline now documents that Aleph-style memory stays + optional, explicit, visible, and clear/export-capable for v0.9.0 rather than + becoming a hidden default context substrate (#2677). + A dedicated v0.9.0 release acceptance matrix now tracks provider, runtime, + UI, WhaleFlow, Model Lab, remote-workbench, docs, rollback, and credit gates + that must be checked or explicitly deferred before tagging (#2729). + HarnessProfile docs now pin the v0.9.0 order: posture/schema/resolver/seed + profiles/status display must precede evidence stores, promotion gates, or any + automatic Harness Creator, with DeepSeek, MiMo, Arcee, and generic/HF/local + posture expectations called out separately (#2728). + Hugging Face / Model Lab and `codebase_search` release gates now explicitly + ship only the provider/MCP/docs/design foundation in v0.9; native Hub search, + model passports, Spaces/Jobs workflows, eval/export surfaces, and runtime + `codebase_search` registration remain deferred (#2705, #2680, #2727). + Remote workbench acceptance is also marked docs/setup-only for v0.9 so release + notes do not imply a shipped VM or Telegram bridge runtime (#2724). + Release-facing HarnessProfile docs now match the current implementation: + v0.9 ships the typed schema/config foundation and defers runtime resolver, + telemetry, seed-profile selection, and status-display behavior until later + verified slices. `config.example.toml` includes a commented dormant + harness-profile example, and README links point at the real acceptance matrix + and HarnessProfile cutline docs. + The release acceptance matrix now records evidence for already-landed gates: + provider-registry drift checks, provider-scoped TLS skip verify, read-only + GUI runtime/restore-point surfaces, VS Code Agent View branch visibility, + WhaleFlow mock/runtime foundations, explicit external-memory boundaries, and + docs alignment. Live workflow execution, provider calls, TraceStore writes, + and mutation-oriented GUI endpoints remain deferred until their atomicity and + replay contracts are tested. The `rlm_cache_change.star` dogfood workflow can + now be replayed from recorded mock leaf/control records, and missing dogfood + records produce `ReplayDiverged` instead of falling back to live execution + (#2679). The UI/workflow UX rows now also distinguish shipped transcript + tool-run collapse, sidebar detail popovers, and PlanArtifact review/handoff + evidence from the deferred first-look/home redesign, and record focused + slash-picker readability smoke coverage for visibility, selection, skill + insertion, Esc priority, and stable composer height (#2692, #2694, #2691, + #2713). + Thanks @AdityaVG13 for the WhaleFlow draft and cost-tracking direction. +- Added a state-store v2 schema migration for WhaleFlow trace tables covering + workflow, branch, leaf, control-node, and teacher-candidate runs. The + migration creates persistence shape only; workflow execution and replay + remain deferred until the runtime semantics are safe (#2668). +- Added an official VS Code extension Phase 0 scaffold with terminal launch, + local runtime attach checks, status bar state, and a read-only Agent View + preview backed by recent runtime thread summaries, plus a read-only + `GET /v1/snapshots` endpoint for GUI clients to inspect side-git restore + points. The extension now renders those restore points read-only in its Agent + View, and thread summaries include read-only workspace, branch, current Git + head, and dirty-state metadata so the VS Code Agent View can show when a + thread or agent lane is on another branch or has changed worktree state. Agent + View and restore-point data now auto-refresh on a configurable + read-only interval so branch/workspace/status changes become visible without a + manual refresh. Agent View refreshes keep thread branch/workspace rows + independent from restore-point loading, so a snapshot-listing failure no + longer clears already-available thread metadata. This answers the VS Code GUI + lane without exposing chat webviews, inline edits, or retry/undo/restore + runtime mutation endpoints yet + (#461, #462, #480, #1217, #2341, #1584, #2327, #2580, #2808). Thanks @AiurArtanis + for the Agent View prompt, @lbcheng888 for the earlier scaffold, @gaord for + the GUI runtime API direction, @douglarek, @caeserchen, and @nightt5879 for + the branch visibility trail, and @BigBenLabs, @lzx1545642258, @yangdaowan, + @mangdehuang, @VerrPower, @hejia-v, @nasus9527, and @ygzhang-cn for the + GUI/VS Code demand and validation trail. +- Added inline live-output refresh for background shell Exec cards keyed by the + exact shell task id, so long-running commands can show bounded stdout/stderr + tails without consuming deltas or matching by command text. Thanks + @donglovejava for the live shell-output direction in #2048. +- Added a static prompt composer override for embedders that need to replace + the byte-stable base/personality prompt segment while leaving mode metadata, + approval policy, tool taxonomy, Context Management, and the Compaction Relay + under CodeWhale's runtime prompt assembly. This refines the embedder prompt + customization path from #2786 without weakening prompt-continuity safeguards. + Thanks @h3c-hexin. +- Added `POST /v1/sessions` for runtime clients to save a completed thread as a + managed session. The endpoint preserves thread title/model/mode/workspace + metadata, maps missing threads to 404, and returns 409 instead of snapshotting + queued or active turns. +- Added cost-estimate pricing for the Xiaomi MiMo primary chat models, which + were previously unpriced: `mimo-v2.5-pro` / `xiaomi/mimo-v2.5-pro` reuse the + DeepSeek V4-Pro rate table and `mimo-v2.5` / `xiaomi/mimo-v2.5` reuse the + DeepSeek V4-Flash rates. Existing DeepSeek pricing is unchanged (#2731, #2750). +- Added a metadata-only `codewhale-config` provider registry with canonical + lookup, alias-aware resolution, provider defaults, config-table keys, and + API-key env candidates. Runtime routing remains unchanged and fallback + providers stay dormant; this harvests the safe provider-trait foundation from + #2479 toward #2075. Thanks @sximelon. +- Added optional `[search].base_url` / `CODEWHALE_SEARCH_BASE_URL` support for + DuckDuckGo-compatible private search endpoints, while keeping + `DEEPSEEK_SEARCH_BASE_URL` as a legacy alias. Custom endpoints are gated by + their configured host, do not fall back to public Bing, and report the custom + host as the result source for diagnostics (#2436, #2510). +- Added `completion_sound = "file"` with `[notifications].sound_file` so + Windows users can play a custom WAV file for turn-completion sounds without + changing the global Windows sound scheme (#2484, #2512). +- Added `[tui].stream_chunk_timeout_secs` and `/config stream_chunk_timeout_secs` + so slow local or OpenAI-compatible model servers can extend the SSE idle + timeout without mutating process environment. The legacy + `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var remains a fallback (#2365, #2507). +- Added dormant `fallback_providers = [...]` config parsing plus a provider-chain + helper for future fallback routing. This preserves the requested contract + without enabling silent runtime provider switches yet (#2574, #2777). Thanks + @hsdbeebou for the request and @idling11 for the data-model draft. +- Added `/hf` with `/huggingface` alias for Hugging Face MCP status/setup + helpers and `/hf concepts` provider/MCP/Hub guidance. The helper points users + to Hugging Face's settings-generated MCP configuration and intentionally does + not include Hub search, direct Hugging Face HTTP requests, or upload behavior + (#2709, #2782). Thanks @idling11 for the original Hugging Face MCP draft. +- Added an in-process response cache for deterministic non-streaming, + tool-free chat requests. The cache is keyed by provider, base URL, path + suffix, API-key fingerprint, and final wire body, and zeroes usage on hits so + local spend counters are not double-counted (#2501). Thanks @HUQIANTAO for + the response-cache proposal and canonical-body key update. +- Added `/sidebar` so users can toggle, show, hide, and optionally persist the + TUI sidebar from the command line instead of relying on copy-hostile sidebar + state during long transcript work (#2766, #2788). Thanks @mo-vic for the + detailed report and @aboimpinto for the fix. +- Added a pausable custom slash-command MVP: commands with `pausable: true` + can pause before further tool execution, preserve the paused command while + separate messages are handled, and resume only on explicit continue/resume + wording. Harvested from #2732 with thanks to @aboimpinto. +- Added Sofya (`provider = "sofya"`) as a search-tool backend with + `SOFYA_API_KEY` fallback, while keeping Sofya scoped to web search rather + than model-provider routing (#2790). Thanks @yusufgurdogan for the + implementation. +- Added Xiaomi MiMo `mode` / `XIAOMI_MIMO_MODE` / `MIMO_MODE` selection for + Token Plan region endpoints and pay-as-you-go routing, plus dedicated Token + Plan env keys for `tp-*` subscriptions (#2621, #2627). Thanks @springeye for + the request and @xyuai for the implementation. +- Added the first TUI hotbar action registry foundation so future UI controls + can dispatch typed app actions instead of growing another command match + surface (#2866). Thanks @reidliu41 for the implementation. +- Added the narrow multi-tab core and persistence foundation, including tab + manager snapshots, delegation/group restore counters, mention parsing, + cross-tab events, and corruption-tolerant persisted state, while leaving the + broader collaboration UI wiring to follow-up work (#2864). Thanks + @ljm3790865 for the tab-core implementation and #2753 direction. +- The VS Code Agent View now renders the runtime thread summary's Git `head` + and dirty-worktree flag alongside branch metadata, keeping branch switches + visible without adding retry/undo/restore mutation endpoints yet (#2580, + #2862). Thanks @AiurArtanis and @nasus9527 for the IDE/agent-view requests + and @gaord for the runtime metadata direction. + +### Changed + +- Removed the deprecated `deepseek` and `deepseek-tui` binary shims from the + v0.9.0 Cargo crates and GitHub release artifact matrix. The canonical + `codewhale`, `codew`, and `codewhale-tui` entry points remain, the private + deprecated `npm/deepseek-tui` notice package stays unpublished, and DeepSeek + provider/model/env/config compatibility remains first-class. +- Command-adjacent config persistence and auto model routing now live in + neutral TUI modules instead of command-owned files, reducing command-boundary + coupling while preserving current `/config`, `/model`, UI, runtime, and + sub-agent behavior (#2871). Thanks @aboimpinto for landing this first staged + command-boundary layer from the broader #2851/#2791 design direction. +- `/config` now reports the canonical `~/.codewhale/settings.toml` path for TUI + settings while still reading legacy DeepSeek-branded settings fallbacks and + migrating them into the CodeWhale home on load. +- Provider switches now roll back transactionally when the first request to a + newly selected provider fails authentication: CodeWhale restores the previous + provider/model, model-ID passthrough, onboarding/API-key state, runtime + config, persisted provider selection, and engine handle so users can return + to DeepSeek after a failed Moonshot/Kimi switch (#2754, #2755). Thanks + @Dr3259 for the Windows repro and @cyq1017 for the draft fix. +- `PATCH /v1/threads/{id}` can now update a thread's persisted workspace for + GUI/runtime clients. Workspace changes reject active turns and evict idle + cached engines so the next turn starts in the new workspace. +- Split `web_run` session/page cache state so cached page reads use shared + page handles and do not serialize through the mutation path. The harvest also + adds panic-safe state write-back and serializes cache-mutating unit tests so + the global web cache remains stable under normal Cargo test parallelism. +- Appended volatile `` blocks after user text in outgoing user + message content arrays so provider prefix caches can keep matching the stable + user-input prefix across date, route, and working-set changes. +- Projected mode, approval, and tool-taxonomy prompt metadata per request + instead of mutating stored system prompts, keeping provider prefix-cache + inputs byte-stable while preserving mode-specific instructions (#2687). + Thanks @LeoAlex0 for the implementation. +- Softened contribution intake automation: external issues now receive a warm + triage note and are never auto-closed by the contribution gate, while the PR + gate copy makes clear that dry-run observations are about maintainer safety, + not contributor quality. +- Added a PR gate marker guard so reopened unapproved PRs do not get duplicate + intake comments, and clarified that PR reopening should happen after + allowlist approval is merged. +- Ollama `/model` completions no longer show hosted DeepSeek API model IDs. + The picker preserves the current or saved local Ollama tag, and users can + still fetch installed model IDs through `/models` instead of relying on a + stale static default (#2742). Thanks @reidliu41 for the focused report and + draft fix. +- MCP runtime API tool listings and approval summaries no longer split + underscored MCP server names at the first `_`. Tool-call routing already used + the longest registered server name; the list endpoint now reuses that parser, + and approval cards show the full MCP target route instead of a guessed server + segment (#2744). Thanks @lioryx, @cyq1017, and @puneetdixit200 for the report + and matching fixes. +- Documented the agent and sub-agent stewardship ethos so future automation + preserves human issue intake, careful PR review, and contributor credit. +- Moved the TUI Starlark execpolicy parser and PTY support behind non-OHOS + target dependencies so published OpenHarmony builds no longer pull `nix` 0.28 + through `rustyline` or `portable-pty`. +- Explicit `skills_dir` configuration is now unioned with workspace skill + discovery instead of being shadowed by workspace-local skills, and configured + skills take precedence over global defaults when prompt space is constrained. +- Tool-agent sub-agent routing now inherits the parent session model, or an + explicit tool-agent override, instead of hard-coding `deepseek-v4-flash`; + the fast lane still disables thinking through provider-aware request shaping. +- Dense successful read/search/list tool runs now collapse into a single + expandable transcript row by default, while running, failed, shell, patch, + review, diff, and other risky tool cells remain visible. The setting + `tool_collapse = "compact" | "expanded" | "calm"` controls the behavior. +- Pending-input preview rows now label delivery mode explicitly as steer + pending, rejected steer, or queued follow-up, with wrapped continuation rows + aligned under the label so busy-turn input state is easier to read (#2054). +- Editing a queued follow-up is now an explicit pending-input state. Pressing + `Esc` while editing a queued follow-up restores the original queued message + instead of cancelling the active turn or silently dropping the queued work + (#2054). +- Approval prompts now render prominent command, directory, file, path, or + target rows before falling back to raw JSON params. Shell approvals preserve + long command tails, split common shell chains for review, and show compact + `printf > file` previews while keeping intent summaries visible (#1991, + #2269). +- Sidebar hover details now use row-level metadata for truncated Work, Tasks, + and Agents rows. Mouse hover opens a bordered, wrapping popover with the full + underlying row text, long turn/agent ids, and current sub-agent progress + instead of repeating the already-ellipsized sidebar label (#2694, #2734). +- Sub-agents now preserve checkpoint metadata around long model calls. A + per-step API timeout marks the child as interrupted with a continuable + checkpoint instead of ending as a null failed result, and `agent_eval` can + explicitly continue a live checkpointed interrupted child while normal + completed/failed/cancelled follow-up behavior stays unchanged (#2029). +- Durable task recovery no longer requeues tasks that were `running` when the + previous CodeWhale process exited. On restart those records are marked failed + with a recovery note, and any running tool-call summaries are marked failed + too, so stale shell/task state cannot silently become live work again (#1786). +- Auto-generated project instructions now reuse the bounded Project Context + Pack data instead of running an unbounded summary/tree scan when no + `.codewhale/instructions.md` file exists. The fallback keeps later + top-level folders visible in noisy large workspaces while the dynamic + `` marker remains controlled by its own setting + (#697, #1827). +- Project context loading now uses a bounded process-local content-signature + cache for repeated hot-path loads. The cache covers workspace/parent + instructions, global AGENTS/WHALE fallbacks, repo constitution files, + generated-context targets, trust markers, and trust config paths, and it + stores post-load signatures so auto-generated context deletion/regeneration + stays correct (#2636). +- Configuration docs now show the provider-local `path_suffix` escape hatch + for OpenAI-compatible gateways that accept `/chat/completions` but reject + `/v1/chat/completions`, while making clear that model listing and DeepSeek + beta routes keep their built-in paths (#1874). +- The config crate now carries the v0.9 HarnessPosture data model: + `HarnessPosture`, `HarnessProfile`, and typed posture/compaction/tool/safety + enums. The schema rejects misspelled posture names or unknown profile keys + instead of silently falling back to `custom`; a pure resolver can match + provider/model routes for tests and future status plumbing, while runtime + provider/model posture selection remains a follow-up (#2693, #2741, #2728). + +### Fixed + +- Stream/body decode failures such as `Stream read error: error decoding + response body` are now classified as recoverable network interruptions + instead of generic internal errors, keeping the transcript and triage metadata + aligned with the existing stream retry path (#2847). Thanks + @qamranmushtaq-collab for the Windows/npx DeepSeek report. +- The TUI footer, `/status`, `/mcp` manager, and command-palette MCP entries + now count trusted workspace-local `.codewhale/mcp.json` servers together with + the global MCP config, matching `codewhale mcp list` for merged global + + project setups (#2787). Thanks @yekern for the detailed reproduction. +- AltGr key chords in the composer no longer get swallowed by sidebar shortcuts + on AZERTY and other international layouts, so characters such as `@`, `#`, + `$`, `!`, and `%` can be entered normally (#2863, #2867). Thanks + @ousamabenyounes for the fix and report. +- Sub-agent shell completions now refresh the workspace branch/status chip + immediately, and `/subagents` plus the Agents sidebar show each sub-agent's + current workspace branch when it is running in a child worktree. +- Authentication failures now include redacted request context such as provider, + base URL authority, model, key source, key type, and key fingerprint, making + stale provider, endpoint, or API-key state diagnosable without exposing the + secret (#2665, #2792). Thanks @mvanhorn for the implementation. +- Browser-opening actions now compile on non-desktop targets by delegating the + unsupported-platform error to the shared URL opener instead of hiding the TUI + wrapper behind a narrower macOS/Linux/Windows cfg. Thanks @ci4ic4 for the + NetBSD/pkgsrc packaging report and fix (#2789). +- MCP tool routing now preserves server names that contain underscores. + `parse_prefixed_name` matches the qualified `mcp__` name against + the set of registered server names and prefers the longest match, so tools on + a server like `my_db` are reachable and an overlapping `my` / `my_db` pair + routes correctly. Falls back to the legacy first-underscore split when no + registered server matches (#2744). +- Schema-hydrated deferred tools no longer render as a completed run. The first + use of a deferred tool returns a schema-hydration result instead of executing; + the transcript and sidebar now show "tool loaded — retry required" via a + dedicated hydrated status, so it is no longer indistinguishable from a real + successful execution. A hydrated row also ranks with active work rather than + completed successes (#2648). +- `codewhale sessions` now shows `codewhale resume ` in the footer + instead of the invalid dispatcher command `codewhale --resume ` + (#2758, #2760). +- TUI HTTP clients now install the Rustls ring crypto provider before building + `reqwest` clients, covering engine, runtime API, tool, MCP, config, and skill + download paths. This keeps the no-provider TLS build from panicking during + tests or embedded startup paths that do not enter through the main binary. +- Prompt byte-stability tests now pin their temporary home and skills + environment under the shared test-env lock so global skill directories cannot + perturb deterministic prompt bytes during parallel test runs. + +### Community + +Thanks to **@sximelon** for reporting and fixing the saved-session resume +footer hint (#2758, #2760), **@cyq1017** for the custom +DuckDuckGo-compatible search endpoint, custom completion sound file support, +restore-listing implementation, and pending-input delivery-mode label work +(#2510, #2512, #2513, #2532, #2054), +**@Artenx** for the private-search endpoint report (#2436), +**@LHqweasd** for the Windows custom notification sound request (#2484), +**@wywsoor** for the broader macOS/iTerm rollback UX report (#2494), +**@HUQIANTAO** for the `web_run` lock-splitting work (#2502), turn-metadata +prefix-cache stability work (#2517), and project-context cache direction +(#2636), **@xyuai** for canonical CodeWhale +settings-path migration work (#2730), **@gaord** for the runtime thread +workspace update and completed-thread save APIs (#2640, #2639), +**@shenjackyuanjie** for the +HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634), +**@ousamabenyounes** for the AZERTY AltGr composer shortcut fix (#2863, +#2867), **@reidliu41** for the hotbar action-registry foundation (#2866), and +**@ljm3790865** for the multi-tab core/persistence foundation and broader +collaboration direction (#2864, #2753), +**@aboimpinto** for the direct command-support boundary cleanup in #2871 and +the broader #2851/#2791 command-layer design direction, +**@idling11** for the PlanArtifact direction in Plan mode (#2733), the dense +tool-call transcript collapse/sidebar detail direction (#2738, #2734, #2692, +#2694), and the HarnessPosture config model for provider/model posture (#2741, +#2693), and +**@h3c-hexin** for the tool-agent model inheritance and configured +`skills_dir` fixes (#2736, #2737), **@AresNing** for the turn-end observer hook +work (#2578), and **@tdccccc** for the approval key-detail and shell-preview +work (#1991, #2269). Thanks also to **@qiyuanlicn** for the +checkpoint/resume report that shaped the sub-agent recovery slice (#2029), +**@bevis-wong** for the long-running shell/task liveness report (#1786), +**@shuxiangxuebiancheng** for the third-party OpenAI-compatible path report +(#1874), **@hongqitai** and **@cyq1017** for the follow-up path-suffix PR +review trail (#2508, #2506), **@NASLXTO** and **@wuxixing** for the +large-workspace startup reports (#697, #1827), and **@linzhiqin2003** and +**@merchloubna70-dot** for earlier context-cap and startup-diagnosis work that +shaped this bounded fallback. Thanks also to **@cyq1017** for the MCP +underscore-server-name fix and Xiaomi MiMo pricing (#2747, #2744, #2750, #2731) +and **@puneetdixit200** for independently diagnosing and fixing the same MCP +underscore issue (#2746, #2744), **@mvanhorn** for the hydrated deferred-tool +render fix (#2757, #2648), and **@xyuai** for the Xiaomi MiMo Token Plan region +documentation (#2756, #2735). Additional thanks to **@Implementist** for Plan +prompt scrolling, wrapping, and display-width fixes, **@jrcjrcc** for the +Windows sub-agent completion render-width fix, and **@punkcanyang** for the +original `/init` implementation harvested through #2771/#2745. + ## [0.8.53] - 2026-06-03 ### Added @@ -5411,7 +5850,8 @@ Welcome — and thank you. - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.53...HEAD +[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.9.0...HEAD +[0.9.0]: https://github.com/Hmbown/CodeWhale/compare/v0.8.53...v0.9.0 [0.8.53]: https://github.com/Hmbown/CodeWhale/compare/v0.8.52...v0.8.53 [0.8.52]: https://github.com/Hmbown/CodeWhale/compare/v0.8.51...v0.8.52 [0.8.51]: https://github.com/Hmbown/CodeWhale/compare/v0.8.50...v0.8.51 diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 6a5775382..c0bb09a2b 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -18,20 +18,13 @@ toml = ["schemaui/toml"] name = "codewhale-tui" path = "src/main.rs" -# Legacy alias — forwards to `codewhale-tui` and prints a deprecation -# notice. Will be removed in v0.9.0. -[[bin]] -name = "deepseek-tui" -path = "src/bin/deepseek_tui_legacy_shim.rs" - [dependencies] anyhow = "1.0.100" -arboard = "3.4" -codewhale-config = { path = "../config", version = "0.8.53" } -codewhale-protocol = { path = "../protocol", version = "0.8.53" } -codewhale-release = { path = "../release", version = "0.8.53" } -codewhale-secrets = { path = "../secrets", version = "0.8.53" } -codewhale-tools = { path = "../tools", version = "0.8.53" } +codewhale-config = { path = "../config", version = "0.9.0" } +codewhale-protocol = { path = "../protocol", version = "0.9.0" } +codewhale-release = { path = "../release", version = "0.9.0" } +codewhale-secrets = { path = "../secrets", version = "0.9.0" } +codewhale-tools = { path = "../tools", version = "0.9.0" } schemaui = { version = "0.12.0", default-features = false, optional = true } async-stream = "0.3.6" async-trait = "0.1" @@ -47,10 +40,10 @@ 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", "form", "rustls", "http2", "gzip", "brotli"] } +reqwest = { version = "0.13.1", default-features = false, features = ["blocking", "json", "stream", "multipart", "form", "rustls-no-provider", "http2", "gzip", "brotli"] } +rustls.workspace = true qrcode = { version = "0.14", default-features = false } similar = "2" -rustyline = "15.0.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = { version = "1.0.149", features = ["preserve_order"] } schemars = { version = "1.2.1", features = ["derive", "preserve_order"] } @@ -71,18 +64,19 @@ tower-http = { version = "0.6", features = ["cors"] } wait-timeout = "0.2" multimap = "0.10.0" shlex = "1.3.0" -starlark = "0.13.0" tiny_http = "0.12" -portable-pty = "0.9" zeroize = "1.8.2" ignore = "0.4" image = { version = "0.25", default-features = false, features = ["png"] } +lru = "0.16" +parking_lot = "0.12" pdf-extract = "0.7" tar = "0.4" flate2 = "1.1" sha2 = "0.10" [dev-dependencies] +cucumber = "0.23.0" wiremock = "0.6" pretty_assertions = "1.4" vt100 = "0.15" @@ -90,9 +84,16 @@ vt100 = "0.15" [target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(any(target_os = "macos", target_os = "windows", all(target_os = "linux", not(target_env = "ohos"))))'.dependencies] +arboard = "3.4" + +[target.'cfg(not(target_env = "ohos"))'.dependencies] +portable-pty = "0.9" +starlark = "0.13.0" + [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6.3" objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSError", "NSObject", "NSString", "NSURL"] } [target.'cfg(target_os = "windows")'.dependencies] -windows = { version = "0.60", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } +windows = { version = "0.60", features = ["Win32_Foundation", "Win32_Media_Audio", "Win32_Security", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } diff --git a/crates/tui/build.rs b/crates/tui/build.rs index 722139a9b..111bbc5c9 100644 --- a/crates/tui/build.rs +++ b/crates/tui/build.rs @@ -121,11 +121,9 @@ fn configure_windows_stack() { match std::env::var("CARGO_CFG_TARGET_ENV").as_deref() { Ok("msvc") => { println!("cargo:rustc-link-arg-bin=codewhale-tui=/STACK:8388608"); - println!("cargo:rustc-link-arg-bin=deepseek-tui=/STACK:8388608"); } Ok("gnu") => { println!("cargo:rustc-link-arg-bin=codewhale-tui=-Wl,--stack,8388608"); - println!("cargo:rustc-link-arg-bin=deepseek-tui=-Wl,--stack,8388608"); } _ => {} } diff --git a/crates/tui/src/bin/deepseek_tui_legacy_shim.rs b/crates/tui/src/bin/deepseek_tui_legacy_shim.rs deleted file mode 100644 index 2e36db972..000000000 --- a/crates/tui/src/bin/deepseek_tui_legacy_shim.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! Legacy `deepseek-tui` alias. -//! -//! Forwards argv to the `codewhale-tui` runtime and prints a one-line -//! deprecation notice to stderr on each invocation. This binary exists -//! for one release cycle to give existing installs a smooth path to the -//! new name; it will be removed in v0.9.0. See `docs/REBRAND.md` for the -//! full migration story. - -use std::env; -use std::process::Command; - -fn main() { - eprintln!( - "warning: `deepseek-tui` is deprecated; run `codewhale-tui` (or `codewhale`) instead. \ - This alias will be removed in v0.9.0." - ); - let args: Vec = env::args_os() - .skip(1) - .map(|a| a.to_string_lossy().into_owned()) - .collect(); - let status = match Command::new("codewhale-tui").args(&args).status() { - Ok(s) => s, - Err(e) => { - eprintln!( - "error: failed to spawn `codewhale-tui`: {e}. Is it on PATH? \ - Install with `cargo install codewhale-tui` or via npm/Homebrew." - ); - std::process::exit(127); - } - }; - std::process::exit(status.code().unwrap_or(1)); -} diff --git a/crates/tui/src/child_env.rs b/crates/tui/src/child_env.rs index 21add459f..70e4a2bc7 100644 --- a/crates/tui/src/child_env.rs +++ b/crates/tui/src/child_env.rs @@ -62,6 +62,7 @@ where } } +#[cfg(not(target_env = "ohos"))] pub fn apply_to_pty_command(cmd: &mut portable_pty::CommandBuilder, overrides: I) where I: IntoIterator, diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 3824c1bb3..a0fa8ca6e 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -61,7 +61,10 @@ pub(super) fn from_api_tool_name(name: &str) -> String { break; } } - if let Ok(code) = u32::from_str_radix(&hex, 16) + // Only decode if we got exactly 6 hex digits (matching encoder output). + // Fewer digits means a truncated/malformed sequence — pass through as-is. + if hex.len() == 6 + && let Ok(code) = u32::from_str_radix(&hex, 16) && let Some(decoded) = std::char::from_u32(code) { if let Some('-') = iter.peek().copied() { @@ -158,6 +161,7 @@ pub struct DeepSeekClient { connection_health: Arc>, rate_limiter: Arc>, path_suffix: Option, + pub(super) stream_idle_timeout: Duration, } const CONNECTION_FAILURE_THRESHOLD: u32 = 2; @@ -325,6 +329,7 @@ impl Clone for DeepSeekClient { connection_health: self.connection_health.clone(), rate_limiter: self.rate_limiter.clone(), path_suffix: self.path_suffix.clone(), + stream_idle_timeout: self.stream_idle_timeout, } } } @@ -581,7 +586,9 @@ impl DeepSeekClient { validate_base_url_security(&base_url)?; let retry = config.retry_policy(); let default_model = config.default_model(); + let stream_idle_timeout = Duration::from_secs(config.stream_chunk_timeout_secs()); let http_headers = config.http_headers(); + let insecure_skip_tls_verify = config.insecure_skip_tls_verify(); let path_suffix = config .provider_config_for(api_provider) .and_then(|p| p.path_suffix.clone()); @@ -597,12 +604,24 @@ impl DeepSeekClient { http_headers.len() )); } + if insecure_skip_tls_verify { + logging::warn(format!( + "TLS certificate verification is disabled for provider {}; prefer SSL_CERT_FILE with a trusted custom CA bundle when possible", + api_provider.as_str() + )); + } logging::info(format!( "Retry policy: enabled={}, max_retries={}, initial_delay={}s, max_delay={}s", retry.enabled, retry.max_retries, retry.initial_delay, retry.max_delay )); - let http_client = Self::build_http_client(&api_key, &http_headers)?; + let http_client = Self::build_http_client( + &api_key, + &http_headers, + api_provider, + &base_url, + insecure_skip_tls_verify, + )?; Ok(Self { http_client, @@ -614,15 +633,19 @@ impl DeepSeekClient { connection_health: Arc::new(AsyncMutex::new(ConnectionHealth::default())), rate_limiter: Arc::new(AsyncMutex::new(TokenBucket::from_env())), path_suffix, + stream_idle_timeout, }) } fn build_http_client( api_key: &str, extra_headers: &HashMap, + api_provider: ApiProvider, + base_url: &str, + insecure_skip_tls_verify: bool, ) -> Result { - let headers = build_default_headers(api_key, extra_headers)?; - let mut builder = reqwest::Client::builder() + let headers = build_default_headers(api_key, extra_headers, api_provider, base_url)?; + let mut builder = crate::tls::reqwest_client_builder() .default_headers(headers) .user_agent(concat!( "Mozilla/5.0 (compatible; codewhale/", @@ -643,6 +666,9 @@ impl DeepSeekClient { { builder = add_extra_root_certs(builder, &cert_path); } + if insecure_skip_tls_verify { + builder = builder.danger_accept_invalid_certs(true); + } builder.build().map_err(Into::into) } @@ -651,21 +677,52 @@ impl DeepSeekClient { api_key: &str, extra_headers: &HashMap, ) -> Result { - build_default_headers(api_key, extra_headers) + build_default_headers( + api_key, + extra_headers, + ApiProvider::Deepseek, + crate::config::DEFAULT_DEEPSEEK_BASE_URL, + ) + } + + #[cfg(test)] + fn default_headers_for_provider( + api_key: &str, + extra_headers: &HashMap, + api_provider: ApiProvider, + base_url: &str, + ) -> Result { + build_default_headers(api_key, extra_headers, api_provider, base_url) } } fn build_default_headers( api_key: &str, extra_headers: &HashMap, + api_provider: ApiProvider, + base_url: &str, ) -> Result { let mut headers = HeaderMap::new(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - if !api_key.trim().is_empty() { - headers.insert( - AUTHORIZATION, - HeaderValue::from_str(&format!("Bearer {api_key}"))?, - ); + let api_key = api_key.trim(); + let auth_header_name = if !api_key.is_empty() + && api_provider == ApiProvider::XiaomiMimo + && (xiaomi_mimo_base_url_uses_token_plan(base_url) + || xiaomi_mimo_api_key_uses_token_plan(api_key)) + { + Some(HeaderName::from_static("api-key")) + } else if !api_key.is_empty() { + Some(AUTHORIZATION) + } else { + None + }; + if let Some(header_name) = auth_header_name.as_ref() { + let header_value = if *header_name == AUTHORIZATION { + HeaderValue::from_str(&format!("Bearer {api_key}"))? + } else { + HeaderValue::from_str(api_key)? + }; + headers.insert(header_name.clone(), header_value); } for (name, value) in extra_headers { let name = name.trim(); @@ -674,7 +731,10 @@ fn build_default_headers( continue; } let header_name = HeaderName::from_bytes(name.as_bytes())?; - if header_name == AUTHORIZATION || header_name == CONTENT_TYPE { + if header_name == AUTHORIZATION + || header_name == CONTENT_TYPE + || auth_header_name.as_ref() == Some(&header_name) + { continue; } headers.insert(header_name, HeaderValue::from_str(value)?); @@ -682,6 +742,24 @@ fn build_default_headers( Ok(headers) } +fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool { + let normalized = base_url.trim().to_ascii_lowercase(); + let without_scheme = normalized + .strip_prefix("https://") + .or_else(|| normalized.strip_prefix("http://")) + .unwrap_or(&normalized); + let host = without_scheme + .split(['/', '?', '#']) + .next() + .unwrap_or_default(); + let host = host.split(':').next().unwrap_or(host); + host.starts_with("token-plan-") && host.ends_with(".xiaomimimo.com") +} + +fn xiaomi_mimo_api_key_uses_token_plan(api_key: &str) -> bool { + api_key.trim_start().starts_with("tp-") +} + impl DeepSeekClient { /// Returns the API base URL used by this client. pub fn base_url(&self) -> &str { @@ -852,7 +930,10 @@ impl DeepSeekClient { anyhow::bail!("Speech synthesis failed: HTTP {status}: {error_text}"); } - let response_text = response.text().await.unwrap_or_default(); + let response_text = response + .text() + .await + .context("Failed to read speech synthesis response body")?; let payload: Value = serde_json::from_str(&response_text) .context("Failed to parse speech synthesis response JSON")?; let (audio_bytes, transcript) = parse_speech_audio_response(&payload)?; @@ -904,6 +985,8 @@ impl DeepSeekClient { let probe = self.http_client.get(health_url).send().await; match probe { Ok(resp) if resp.status().is_success() => { + // Consume the response body so the connection can be returned to the pool. + let _ = resp.text().await; self.mark_request_success().await; logging::info("Recovery probe succeeded"); } @@ -1021,6 +1104,8 @@ impl LlmClient for DeepSeekClient { let response = self.http_client.get(health_url).send().await; match response { Ok(resp) if resp.status().is_success() => { + // Consume the response body so the connection can be returned to the pool. + let _ = resp.text().await; self.mark_request_success().await; Ok(true) } @@ -1320,8 +1405,8 @@ pub(super) fn parse_usage(usage: Option<&Value>) -> Usage { }); Usage { - input_tokens: input_tokens as u32, - output_tokens: output_tokens as u32, + input_tokens: input_tokens.min(u64::from(u32::MAX)) as u32, + output_tokens: output_tokens.min(u64::from(u32::MAX)) as u32, prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, @@ -1360,7 +1445,10 @@ impl DeepSeekClient { ); anyhow::bail!("FIM API error: HTTP {status}: {error_text}"); } - let response_text = response.text().await.unwrap_or_default(); + let response_text = response + .text() + .await + .context("Failed to read FIM API response body")?; let value: serde_json::Value = serde_json::from_str(&response_text).context("Failed to parse FIM API response")?; let text = value @@ -1628,6 +1716,109 @@ mod tests { assert!(headers.get("x-blank").is_none()); } + #[test] + fn build_http_client_accepts_default_tls_verification() { + let client = DeepSeekClient::build_http_client( + "sk-test", + &HashMap::new(), + ApiProvider::Deepseek, + crate::config::DEFAULT_DEEPSEEK_BASE_URL, + false, + ); + + assert!(client.is_ok()); + } + + #[test] + fn build_http_client_accepts_provider_scoped_tls_skip_verify() { + let client = DeepSeekClient::build_http_client( + "sk-test", + &HashMap::new(), + ApiProvider::Openai, + crate::config::DEFAULT_OPENAI_BASE_URL, + true, + ); + + assert!(client.is_ok()); + } + + #[test] + fn client_stream_idle_timeout_uses_tui_config() { + let client = DeepSeekClient::new(&Config { + api_key: Some("sk-test".to_string()), + tui: Some(crate::config::TuiConfig { + stream_chunk_timeout_secs: Some(777), + ..crate::config::TuiConfig::default() + }), + ..Config::default() + }) + .expect("client"); + + assert_eq!(client.stream_idle_timeout, Duration::from_secs(777)); + } + + #[test] + fn xiaomi_mimo_token_plan_endpoint_uses_api_key_header() { + let headers = DeepSeekClient::default_headers_for_provider( + "tp-test", + &HashMap::new(), + ApiProvider::XiaomiMimo, + crate::config::DEFAULT_XIAOMI_MIMO_BASE_URL, + ) + .expect("headers"); + + assert_eq!( + headers.get("api-key").and_then(|value| value.to_str().ok()), + Some("tp-test") + ); + assert!( + headers.get(AUTHORIZATION).is_none(), + "Token Plan requires api-key instead of Authorization Bearer" + ); + } + + #[test] + fn xiaomi_mimo_tp_key_uses_api_key_header_with_custom_base_url() { + let mut extra = HashMap::new(); + extra.insert("api-key".to_string(), "wrong".to_string()); + extra.insert("Authorization".to_string(), "Bearer wrong".to_string()); + let headers = DeepSeekClient::default_headers_for_provider( + "tp-custom", + &extra, + ApiProvider::XiaomiMimo, + "https://proxy.example.test/mimo/v1", + ) + .expect("headers"); + + assert_eq!( + headers.get("api-key").and_then(|value| value.to_str().ok()), + Some("tp-custom") + ); + assert!( + headers.get(AUTHORIZATION).is_none(), + "tp-* Token Plan keys should use api-key auth even through custom gateways" + ); + } + + #[test] + fn xiaomi_mimo_pay_as_you_go_endpoint_keeps_bearer_header() { + let headers = DeepSeekClient::default_headers_for_provider( + "sk-test", + &HashMap::new(), + ApiProvider::XiaomiMimo, + crate::config::XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, + ) + .expect("headers"); + + assert_eq!( + headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some("Bearer sk-test") + ); + assert!(headers.get("api-key").is_none()); + } + #[test] fn chat_messages_keep_current_turn_reasoning_content() { let message = Message { @@ -2320,6 +2511,29 @@ mod tests { assert!(body.get("extra_body").is_none()); } + #[test] + fn reasoning_effort_off_is_omitted_for_strict_openai_like_providers() { + for provider in [ + ApiProvider::Openai, + ApiProvider::Atlascloud, + ApiProvider::WanjieArk, + ApiProvider::Arcee, + ApiProvider::Huggingface, + ApiProvider::Moonshot, + ApiProvider::Ollama, + ApiProvider::Fireworks, + ] { + let mut body = json!({}); + apply_reasoning_effort(&mut body, Some("off"), provider); + + assert_eq!( + body, + json!({}), + "provider {provider:?} should not receive unsupported reasoning-off fields" + ); + } + } + #[test] fn reasoning_effort_uses_nvidia_nim_chat_template_kwargs() { let mut body = json!({}); diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 3aa217b05..f8e2b9843 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -16,11 +16,6 @@ use tokio::time::timeout as tokio_timeout; use crate::config::wire_model_for_provider; -/// Default idle timeout for SSE stream reads (300 seconds = 5 minutes). -/// After this period with no data, the stream is considered stalled and -/// yields a recoverable error so the caller can retry. -const DEFAULT_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(300); - /// Default timeout for the initial streaming response headers. /// /// `doctor` uses a bounded non-streaming request, but normal TUI turns first @@ -48,17 +43,6 @@ fn stream_open_timeout_from_env(value: Option<&str>) -> Duration { Duration::from_secs(secs) } -/// Reads the `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var, falling back to -/// the default 300s. The parsed value is clamped to [1, 3600] seconds. -fn stream_idle_timeout() -> Duration { - let secs = std::env::var("DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(DEFAULT_STREAM_IDLE_TIMEOUT.as_secs()) - .clamp(1, 3600); - Duration::from_secs(secs) -} - use crate::config::ApiProvider; use crate::llm_client::StreamEventBox; use crate::llm_client::sanitize_http_error_body; @@ -91,6 +75,7 @@ impl DeepSeekClient { &self, request: &MessageRequest, ) -> Result { + let cacheable = crate::llm_response_cache::request_is_cacheable(request); let messages = build_chat_messages_for_request_and_provider(request, self.api_provider); let model = wire_model_for_provider(self.api_provider, &request.model); let mut body = json!({ @@ -137,6 +122,24 @@ impl DeepSeekClient { self.api_provider, ); + let response_cache_key = if cacheable { + let wire_body = + serde_json::to_vec(&body).context("Failed to serialize Chat API cache key")?; + let key = crate::llm_response_cache::ResponseCache::make_key( + self.api_provider.as_str(), + &self.base_url, + self.path_suffix.as_deref(), + &self.api_key, + &wire_body, + ); + if let Some(cached) = crate::llm_response_cache::response_cache().get(&key) { + return Ok(cached); + } + Some(key) + } else { + None + }; + let url = api_url_with_suffix( &self.base_url, "chat/completions", @@ -174,7 +177,11 @@ impl DeepSeekClient { let response_text = response.text().await.unwrap_or_default(); let value: Value = serde_json::from_str(&response_text).context("Failed to parse Chat API JSON")?; - parse_chat_message(&value) + let parsed = parse_chat_message(&value)?; + if let Some(key) = response_cache_key { + crate::llm_response_cache::response_cache().put(key, parsed.clone()); + } + Ok(parsed) } } @@ -283,6 +290,7 @@ impl DeepSeekClient { // gzip-compressor failure when investigating #103. let response_headers = format_stream_headers(response.headers()); let byte_stream = response.bytes_stream(); + let stream_idle_timeout = self.stream_idle_timeout; let stream = async_stream::stream! { use futures_util::StreamExt; @@ -315,7 +323,7 @@ impl DeepSeekClient { let is_reasoning_model = is_reasoning_model_for_stream(api_provider, &model); let mut byte_stream = std::pin::pin!(byte_stream); - let idle = stream_idle_timeout(); + let idle = stream_idle_timeout; // Telemetry for #103 stream-decode diagnostics: bytes received // since the start of this stream and last successful event time. @@ -1982,6 +1990,8 @@ fn provider_accepts_reasoning_content(provider: ApiProvider) -> bool { | ApiProvider::Novita | ApiProvider::Fireworks | ApiProvider::Siliconflow + | ApiProvider::SiliconflowCn + | ApiProvider::Volcengine | ApiProvider::Arcee | ApiProvider::Sglang ) @@ -3062,6 +3072,22 @@ mod stream_decoder_tests { } } + fn user_message_with_tail_turn_meta(task: &str, turn_meta: &str) -> Message { + Message { + role: "user".to_string(), + content: vec![ + ContentBlock::Text { + text: task.to_string(), + cache_control: None, + }, + ContentBlock::Text { + text: turn_meta.to_string(), + cache_control: None, + }, + ], + } + } + fn tool_message_content(messages: &[Value], index: usize) -> &str { messages .iter() @@ -3128,6 +3154,30 @@ mod stream_decoder_tests { ); } + #[test] + fn request_builder_keeps_tail_turn_meta_after_user_text_for_wire() { + let turn_meta = "\nCurrent local date: 2026-05-09\n"; + let messages = vec![ + user_message_with_tail_turn_meta("first task", turn_meta), + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text: "first answer".to_string(), + cache_control: None, + }], + }, + user_message_with_tail_turn_meta("second task", turn_meta), + ]; + + let built = build_chat_messages(None, &messages, "deepseek-v4-flash"); + let first = user_message_content(&built, 0); + let second = user_message_content(&built, 1); + let expected_ref = ""; + + assert_eq!(first, format!("first task\n{turn_meta}")); + assert_eq!(second, format!("second task\n{expected_ref}")); + } + #[test] fn request_builder_keeps_changed_turn_meta_full_and_updates_recent_hash() { let first_meta = "\nCurrent local date: 2026-05-09\n"; diff --git a/crates/tui/src/command_safety.rs b/crates/tui/src/command_safety.rs index da7835395..6f55660d2 100644 --- a/crates/tui/src/command_safety.rs +++ b/crates/tui/src/command_safety.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - //! Command safety analysis for shell execution //! //! This module provides pre-execution analysis of shell commands to detect @@ -374,43 +372,38 @@ pub enum SafetyLevel { #[derive(Debug, Clone)] pub struct SafetyAnalysis { pub level: SafetyLevel, - pub command: String, pub reasons: Vec, pub suggestions: Vec, } impl SafetyAnalysis { - pub fn safe(command: &str) -> Self { + pub fn safe(_command: &str) -> Self { Self { level: SafetyLevel::Safe, - command: command.to_string(), reasons: vec!["Command is read-only".to_string()], suggestions: vec![], } } - pub fn workspace_safe(command: &str, reason: &str) -> Self { + pub fn workspace_safe(_command: &str, reason: &str) -> Self { Self { level: SafetyLevel::WorkspaceSafe, - command: command.to_string(), reasons: vec![reason.to_string()], suggestions: vec![], } } - pub fn requires_approval(command: &str, reasons: Vec) -> Self { + pub fn requires_approval(_command: &str, reasons: Vec) -> Self { Self { level: SafetyLevel::RequiresApproval, - command: command.to_string(), reasons, suggestions: vec![], } } - pub fn dangerous(command: &str, reasons: Vec, suggestions: Vec) -> Self { + pub fn dangerous(_command: &str, reasons: Vec, suggestions: Vec) -> Self { Self { level: SafetyLevel::Dangerous, - command: command.to_string(), reasons, suggestions, } @@ -1012,72 +1005,6 @@ fn is_workspace_safe_command(command: &str) -> bool { false } -/// Check if a path escapes the workspace -pub fn path_escapes_workspace(path: &str, workspace: &str) -> bool { - let path_lower = normalize_safety_path(path); - let workspace_lower = normalize_safety_path(workspace); - - // Check for obvious escape patterns - if path_lower.starts_with("~/") || path_lower.starts_with("$home") { - return true; - } - - if is_absolute_safety_path(&path_lower) { - let path_components = lexical_components(&path_lower); - let workspace_components = lexical_components(&workspace_lower); - return !components_start_with(&path_components, &workspace_components); - } - - // Walk the path components. Track depth relative to the workspace root: - // non-`..` components increment depth, `..` components decrement it. - // If depth ever goes negative, the path escapes the workspace boundary. - // This correctly distinguishes genuine traversal like `../outside` from - // names that happen to contain consecutive dots like `foo..bar`. - let mut depth: i32 = 0; - for component in path_lower.split('/') { - match component { - "" | "." => {} - ".." => depth -= 1, - _ => depth += 1, - } - if depth < 0 { - return true; - } - } - - false -} - -fn normalize_safety_path(path: &str) -> String { - path.trim().replace('\\', "/").to_lowercase() -} - -fn is_absolute_safety_path(path: &str) -> bool { - path.starts_with('/') - || path - .as_bytes() - .get(1..3) - .is_some_and(|bytes| bytes[0] == b':' && bytes[1] == b'/') -} - -fn lexical_components(path: &str) -> Vec<&str> { - let mut components = Vec::new(); - for component in path.split('/') { - match component { - "" | "." => {} - ".." => { - components.pop(); - } - _ => components.push(component), - } - } - components -} - -fn components_start_with(path: &[&str], prefix: &[&str]) -> bool { - path.len() >= prefix.len() && path.iter().zip(prefix.iter()).all(|(a, b)| a == b) -} - /// Parse a command and extract the primary command name pub fn extract_primary_command(command: &str) -> Option<&str> { let trimmed = command.trim(); @@ -1093,56 +1020,6 @@ pub fn extract_primary_command(command: &str) -> Option<&str> { } } -/// Categorize commands into groups -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CommandCategory { - FileSystem, - Network, - Process, - Package, - Git, - Build, - System, - Shell, - Other, -} - -/// Get the category of a command -pub fn categorize_command(command: &str) -> CommandCategory { - let primary = match extract_primary_command(command) { - Some(cmd) => cmd.to_lowercase(), - None => return CommandCategory::Other, - }; - - match primary.as_str() { - "ls" | "dir" | "cat" | "head" | "tail" | "less" | "more" | "cp" | "mv" | "rm" | "mkdir" - | "rmdir" | "touch" | "chmod" | "chown" | "ln" | "find" | "fd" | "locate" | "stat" - | "file" => CommandCategory::FileSystem, - - "curl" | "wget" | "fetch" | "nc" | "netcat" | "ssh" | "scp" | "sftp" | "rsync" | "ftp" - | "ping" | "traceroute" | "nslookup" | "dig" | "host" | "nmap" => CommandCategory::Network, - - "ps" | "top" | "htop" | "kill" | "killall" | "pkill" | "pgrep" | "nice" | "renice" - | "nohup" | "timeout" => CommandCategory::Process, - - "npm" | "yarn" | "pnpm" | "pip" | "pip3" | "brew" | "apt" | "apt-get" | "yum" | "dnf" - | "pacman" => CommandCategory::Package, - - "git" | "gh" | "hub" => CommandCategory::Git, - - "make" | "cmake" | "ninja" | "meson" | "cargo" | "go" | "gcc" | "g++" | "clang" - | "rustc" | "javac" | "tsc" => CommandCategory::Build, - - "sudo" | "su" | "systemctl" | "service" | "shutdown" | "reboot" | "mount" | "umount" - | "fdisk" | "parted" => CommandCategory::System, - - "bash" | "sh" | "zsh" | "fish" | "csh" | "tcsh" | "dash" | "source" | "." | "exec" - | "eval" => CommandCategory::Shell, - - _ => CommandCategory::Other, - } -} - // === Unit Tests === #[cfg(test)] @@ -1321,62 +1198,6 @@ mod tests { ); } - #[test] - fn test_path_escapes_workspace() { - assert!(path_escapes_workspace("/etc/passwd", "/home/user/project")); - assert!(path_escapes_workspace("~/secret", "/home/user/project")); - assert!(!path_escapes_workspace( - "./src/main.rs", - "/home/user/project" - )); - } - - #[test] - fn test_path_escapes_workspace_doesnt_flag_double_dot_in_names() { - // Names like `foo..bar` should NOT be flagged as path traversal - assert!(!path_escapes_workspace( - "some..file.txt", - "/home/user/project" - )); - assert!(!path_escapes_workspace( - "./dir..name/file.txt", - "/home/user/project" - )); - } - - #[test] - fn test_path_escapes_workspace_detects_genuine_traversal() { - assert!(path_escapes_workspace("../outside", "/home/user/project")); - assert!(path_escapes_workspace( - "..\\outside", - "C:\\Users\\me\\project" - )); - assert!(path_escapes_workspace( - "./subdir/../../etc/passwd", - "/home/user/project" - )); - assert!(path_escapes_workspace( - "/home/user/project/../secret", - "/home/user/project" - )); - assert!(path_escapes_workspace( - "C:\\Users\\me\\project\\..\\secret", - "C:\\Users\\me\\project" - )); - } - - #[test] - fn test_path_escapes_workspace_allows_absolute_workspace_children() { - assert!(!path_escapes_workspace( - "/home/user/project/src/main.rs", - "/home/user/project" - )); - assert!(!path_escapes_workspace( - "C:\\Users\\me\\project\\src\\main.rs", - "C:\\Users\\me\\project" - )); - } - #[test] fn test_extract_primary_command() { assert_eq!(extract_primary_command("ls -la"), Some("ls")); @@ -1387,21 +1208,6 @@ mod tests { assert_eq!(extract_primary_command(" git status "), Some("git")); } - #[test] - fn test_categorize_command() { - assert_eq!(categorize_command("ls -la"), CommandCategory::FileSystem); - assert_eq!( - categorize_command("curl https://example.com"), - CommandCategory::Network - ); - assert_eq!(categorize_command("git status"), CommandCategory::Git); - assert_eq!(categorize_command("npm install"), CommandCategory::Package); - assert_eq!( - categorize_command("sudo apt update"), - CommandCategory::System - ); - } - // ── classify_command tests ──────────────────────────────────────────────── /// Helper: split a string on whitespace into a `Vec<&str>` and call diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 36d5e2fd0..28c532681 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -1,25 +1,25 @@ //! Config commands: config, settings, mode switches, trust, logout -use std::path::{Path, PathBuf}; -use std::time::Duration; - use super::CommandResult; -use crate::client::DeepSeekClient; use crate::config::{ - ApiProvider, COMMON_DEEPSEEK_MODELS, Config, DEFAULT_XIAOMI_MIMO_BASE_URL, - XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, clear_active_provider_api_key, effective_home_dir, - expand_path, normalize_model_name_for_provider, + ApiProvider, COMMON_DEEPSEEK_MODELS, Config, DEFAULT_STREAM_CHUNK_TIMEOUT_SECS, + DEFAULT_XIAOMI_MIMO_BASE_URL, MAX_STREAM_CHUNK_TIMEOUT_SECS, MIN_STREAM_CHUNK_TIMEOUT_SECS, + XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, clear_active_provider_api_key, + normalize_model_name_for_provider, +}; +use crate::config_persistence::{ + persist_provider_base_url_key, persist_root_bool_key, persist_root_string_key, + persist_tui_integer_key, }; use crate::config_ui::{ConfigUiMode, parse_mode}; -use crate::llm_client::LlmClient; use crate::localization::resolve_locale; -use crate::models::{ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt}; use crate::settings::Settings; use crate::tui::app::{ App, AppAction, AppMode, OnboardingState, ReasoningEffort, SidebarFocus, VimMode, }; use crate::tui::approval::ApprovalMode; use anyhow::Result; +use std::path::{Path, PathBuf}; /// Open the interactive config editor. /// @@ -152,6 +152,7 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { }; Some(config.deepseek_base_url()) } + "stream_chunk_timeout_secs" => Some(app.stream_chunk_timeout_secs.to_string()), "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()) @@ -204,6 +205,9 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { "max_history" | "history" => Some(app.max_input_history.to_string()), "sidebar_width" | "sidebar" => Some(app.sidebar_width_percent.to_string()), "sidebar_focus" | "focus" => Some(app.sidebar_focus.as_setting().to_string()), + "tool_collapse" | "tool_collapse_mode" | "collapse" => { + Some(app.tool_collapse_mode.as_setting().to_string()) + } "context_panel" | "context" | "session_panel" => { Some(if app.context_panel { "true" } else { "false" }.to_string()) } @@ -304,184 +308,67 @@ pub fn verbose(app: &mut App, arg: Option<&str>) -> CommandResult { }) } -/// Persist `tui.status_items` to `~/.codewhale/config.toml` without disturbing -/// the rest of the file. We round-trip through `toml::Value` so any keys we -/// don't know about (provider blocks, MCP, etc.) survive the write -/// untouched. +/// Toggle or focus the right sidebar. /// -/// Returns the path written so the caller can surface it in a status toast. -pub fn persist_status_items(items: &[crate::config::StatusItem]) -> anyhow::Result { - use anyhow::Context; - use std::fs; - - 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()))?; +/// Bare `/sidebar` toggles between hidden and auto. Explicit values mirror +/// `sidebar_focus` so users have a discoverable copy-friendly path that does +/// not depend on terminal-specific key translations. +pub fn sidebar(app: &mut App, arg: Option<&str>) -> CommandResult { + let raw = arg.map(str::trim).unwrap_or(""); + let mut tokens = raw.split_whitespace().collect::>(); + let persist = matches!(tokens.last(), Some(&"--save" | &"-s")); + if persist { + tokens.pop(); } - let mut doc: toml::Value = if path.exists() { - let raw = fs::read_to_string(&path) - .with_context(|| format!("failed to read config at {}", path.display()))?; - toml::from_str(&raw) - .with_context(|| format!("failed to parse config at {}", path.display()))? - } else { - toml::Value::Table(toml::value::Table::new()) + let target = match tokens.as_slice() { + [] | ["toggle"] => { + if app.sidebar_focus == SidebarFocus::Hidden { + SidebarFocus::Auto + } else { + SidebarFocus::Hidden + } + } + [value] => match value.to_ascii_lowercase().as_str() { + "on" | "show" | "visible" => SidebarFocus::Auto, + "off" | "hide" | "hidden" | "closed" | "none" => SidebarFocus::Hidden, + "auto" => SidebarFocus::Auto, + "work" | "plan" | "todos" => SidebarFocus::Work, + "tasks" => SidebarFocus::Tasks, + "agents" | "subagents" | "sub-agents" => SidebarFocus::Agents, + "context" | "session" => SidebarFocus::Context, + _ => { + return CommandResult::error( + "Usage: /sidebar [on|off|auto|work|tasks|agents|context] [--save]", + ); + } + }, + _ => { + return CommandResult::error( + "Usage: /sidebar [on|off|auto|work|tasks|agents|context] [--save]", + ); + } }; - let table = doc - .as_table_mut() - .context("config.toml root must be a table")?; - let tui_entry = table - .entry("tui".to_string()) - .or_insert_with(|| toml::Value::Table(toml::value::Table::new())); - let tui_table = tui_entry - .as_table_mut() - .context("`tui` section in config.toml must be a table")?; - let array = items - .iter() - .map(|item| toml::Value::String(item.key().to_string())) - .collect::>(); - tui_table.insert("status_items".to_string(), toml::Value::Array(array)); - - let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; - fs::write(&path, body) - .with_context(|| format!("failed to write config at {}", path.display()))?; - Ok(path) -} - -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(config_path)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create config directory {}", parent.display()))?; - } - - let mut doc: toml::Value = if path.exists() { - let raw = fs::read_to_string(&path) - .with_context(|| format!("failed to read config at {}", path.display()))?; - toml::from_str(&raw) - .with_context(|| format!("failed to parse config at {}", path.display()))? + if persist { + let result = set_config_value(app, "sidebar_focus", target.as_setting(), true); + if result.is_error { + return result; + } } else { - toml::Value::Table(toml::value::Table::new()) - }; - let table = doc - .as_table_mut() - .context("config.toml root must be a table")?; - table.insert(key.to_string(), toml::Value::String(value.to_string())); - let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; - fs::write(&path, body) - .with_context(|| format!("failed to write config at {}", path.display()))?; - Ok(path) -} - -fn persist_root_bool_key( - config_path: Option<&Path>, - key: &str, - value: bool, -) -> anyhow::Result { - use anyhow::Context; - use std::fs; - - 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()))?; + app.set_sidebar_focus(target); } - let mut doc: toml::Value = if path.exists() { - let raw = fs::read_to_string(&path) - .with_context(|| format!("failed to read config at {}", path.display()))?; - toml::from_str(&raw) - .with_context(|| format!("failed to parse config at {}", path.display()))? - } else { - toml::Value::Table(toml::value::Table::new()) - }; - let table = doc - .as_table_mut() - .context("config.toml root must be a table")?; - table.insert(key.to_string(), toml::Value::Boolean(value)); - let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; - fs::write(&path, body) - .with_context(|| format!("failed to write config at {}", path.display()))?; - Ok(path) + app.needs_redraw = true; + let message = sidebar_status_message(target).to_string(); + CommandResult::message(message) } -fn persist_provider_base_url_key( - config_path: Option<&Path>, - provider: ApiProvider, - value: &str, -) -> anyhow::Result { - use anyhow::Context; - use std::fs; - - 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()))?; - } - - let mut doc: toml::Value = if path.exists() { - let raw = fs::read_to_string(&path) - .with_context(|| format!("failed to read config at {}", path.display()))?; - toml::from_str(&raw) - .with_context(|| format!("failed to parse config at {}", path.display()))? +fn sidebar_status_message(focus: SidebarFocus) -> &'static str { + if focus == SidebarFocus::Hidden { + "Sidebar is hidden" } else { - toml::Value::Table(toml::value::Table::new()) - }; - let table = doc - .as_table_mut() - .context("config.toml root must be a table")?; - let providers = table - .entry("providers".to_string()) - .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) - .as_table_mut() - .context("`providers` must be a table")?; - let provider_key = provider_base_url_table_key(provider)?; - let entry = providers - .entry(provider_key.to_string()) - .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) - .as_table_mut() - .with_context(|| format!("`providers.{provider_key}` must be a table"))?; - entry.insert( - "base_url".to_string(), - toml::Value::String(value.to_string()), - ); - - let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; - fs::write(&path, body) - .with_context(|| format!("failed to write config at {}", path.display()))?; - Ok(path) -} - -fn provider_base_url_table_key(provider: ApiProvider) -> anyhow::Result<&'static str> { - match provider { - ApiProvider::Deepseek | ApiProvider::DeepseekCN => { - anyhow::bail!("DeepSeek uses the root base_url setting") - } - ApiProvider::NvidiaNim => Ok("nvidia_nim"), - ApiProvider::Openai => Ok("openai"), - ApiProvider::Atlascloud => Ok("atlascloud"), - ApiProvider::WanjieArk => Ok("wanjie_ark"), - ApiProvider::Volcengine => Ok("volcengine"), - ApiProvider::Openrouter => Ok("openrouter"), - ApiProvider::XiaomiMimo => Ok("xiaomi_mimo"), - ApiProvider::Novita => Ok("novita"), - ApiProvider::Fireworks => Ok("fireworks"), - ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => Ok("siliconflow"), - ApiProvider::Arcee => Ok("arcee"), - ApiProvider::Huggingface => Ok("huggingface"), - ApiProvider::Moonshot => Ok("moonshot"), - ApiProvider::Sglang => Ok("sglang"), - ApiProvider::Vllm => Ok("vllm"), - ApiProvider::Ollama => Ok("ollama"), + "Sidebar is visible" } } @@ -522,37 +409,12 @@ fn parse_config_bool(value: &str) -> Result { } } -/// Resolve the path to `~/.codewhale/config.toml` (or -/// `$CODEWHALE_CONFIG_PATH` / `$DEEPSEEK_CONFIG_PATH`). Mirrors what `Config::load` accepts so we -/// never write to a different file than the one we read. -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("CODEWHALE_CONFIG_PATH") { - let trimmed = env.trim(); - if !trimmed.is_empty() { - return Ok(PathBuf::from(trimmed)); - } - } - if let Ok(env) = std::env::var("DEEPSEEK_CONFIG_PATH") { - let trimmed = env.trim(); - if !trimmed.is_empty() { - return Ok(PathBuf::from(trimmed)); - } - } - let home = - effective_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); - } - let legacy = home.join(".deepseek").join("config.toml"); - if legacy.exists() { - return Ok(legacy); +fn stream_chunk_timeout_value_label(raw: u64, resolved: u64) -> String { + if raw == 0 { + format!("0 (default {resolved})") + } else { + resolved.to_string() } - Ok(primary) } /// Modify a setting at runtime @@ -726,6 +588,55 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> "provider_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving.", ); } + "stream_chunk_timeout_secs" => { + let raw = match value.trim().parse::() { + Ok(value) => value, + Err(_) => { + return CommandResult::error( + "stream_chunk_timeout_secs must be a whole number", + ); + } + }; + if raw != 0 + && !(MIN_STREAM_CHUNK_TIMEOUT_SECS..=MAX_STREAM_CHUNK_TIMEOUT_SECS).contains(&raw) + { + return CommandResult::error(format!( + "stream_chunk_timeout_secs must be 0 or {}..={}", + MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS + )); + } + let resolved = if raw == 0 { + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + } else { + raw + }; + app.stream_chunk_timeout_secs = resolved; + let value_label = stream_chunk_timeout_value_label(raw, resolved); + if persist { + match persist_tui_integer_key( + app.config_path.as_deref(), + "stream_chunk_timeout_secs", + raw, + ) { + Ok(path) => { + return CommandResult::with_message_and_action( + format!( + "stream_chunk_timeout_secs = {value_label} (saved to {}; affects subsequent turns in this session)", + path.display() + ), + AppAction::UpdateStreamChunkTimeout(resolved), + ); + } + Err(err) => return CommandResult::error(format!("Failed to save: {err}")), + } + } + return CommandResult::with_message_and_action( + format!( + "stream_chunk_timeout_secs = {value_label} (session only; affects subsequent turns in this session)" + ), + AppAction::UpdateStreamChunkTimeout(resolved), + ); + } _ => {} } @@ -847,6 +758,12 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> crate::tui::app::TranscriptSpacing::from_setting(&settings.transcript_spacing); app.mark_history_updated(); } + "tool_collapse" | "tool_collapse_mode" | "collapse" => { + app.tool_collapse_mode = + crate::tui::app::ToolCollapseMode::from_setting(&settings.tool_collapse_mode); + app.expanded_tool_runs.clear(); + app.mark_history_updated(); + } "default_mode" | "mode" => { let mode = AppMode::from_setting(&settings.default_mode); app.set_mode(mode); @@ -927,40 +844,6 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> } } -/// Modify a setting at runtime -#[allow(dead_code)] -pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult { - let Some(args) = args else { - let available = Settings::available_settings() - .iter() - .map(|(k, d)| format!(" {k}: {d}")) - .collect::>() - .join("\n"); - return CommandResult::message(format!( - "Usage: /set \n\n\ - Available settings:\n{available}\n\n\ - Session-only settings:\n \ - model: Current model\n \ - approval_mode: auto | suggest | never\n\n\ - Add --save to persist to settings file." - )); - }; - - let parts: Vec<&str> = args.splitn(2, ' ').collect(); - if parts.len() < 2 { - return CommandResult::error("Usage: /set "); - } - - let key = parts[0].to_lowercase(); - let (value, should_save) = if parts[1].ends_with(" --save") { - (parts[1].trim_end_matches(" --save").trim(), true) - } else { - (parts[1].trim(), false) - }; - - set_config_value(app, &key, value, should_save) -} - /// Select the TUI operating mode. pub fn mode(app: &mut App, arg: Option<&str>) -> CommandResult { let Some(arg) = arg.filter(|value| !value.trim().is_empty()) else { @@ -1179,393 +1062,6 @@ fn expand_tilde(raw: &str) -> String { raw.to_string() } -/// Auto-select a model based on request complexity. -/// -/// Short messages (<100 chars) → Flash (fast & cheap). -/// Long messages (>500 chars) → Pro (powerful reasoning). -/// Messages with complex keywords → Pro. -/// Default → Flash (cost savings). -pub fn auto_model_heuristic(input: &str, _current_model: &str) -> String { - auto_model_heuristic_with_bias(input, _current_model, false) -} - -/// `auto_model_heuristic` parameterised by the `[auto] cost_saving` opt-in -/// (#1207). When `cost_saving` is `true` the keyword set drops the borderline -/// triggers (`implement`, `analyze`) and the long-message length threshold -/// goes from 500 to 1000 — both shifts let "looks involved but might be a -/// one-liner" requests stay on Flash unless they actually look agentic. -pub fn auto_model_heuristic_with_bias( - input: &str, - _current_model: &str, - cost_saving: bool, -) -> String { - auto_model_heuristic_selection_with_bias(input, _current_model, cost_saving).model -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum AutoModelHeuristicConfidence { - Decisive, - Ambiguous, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct AutoModelHeuristicSelection { - model: String, - confidence: AutoModelHeuristicConfidence, -} - -fn auto_model_heuristic_selection_with_bias( - input: &str, - _current_model: &str, - cost_saving: bool, -) -> AutoModelHeuristicSelection { - let len = input.chars().count(); - let lower = input.to_lowercase(); - let borderline_pro_keywords: &[&str] = &[ - "implement", - "analyze", - "\u{5b9e}\u{73b0}", // 实现 - "\u{5206}\u{6790}", // 分析 - "\u{5be6}\u{73fe}", // 實現 - ]; - let strong_match = COMPLEX_KEYWORDS - .iter() - .any(|kw| !borderline_pro_keywords.contains(kw) && lower.contains(kw)); - let borderline_match = borderline_pro_keywords.iter().any(|kw| lower.contains(kw)); - let pro_match = strong_match || (!cost_saving && borderline_match); - if pro_match { - return AutoModelHeuristicSelection { - model: "deepseek-v4-pro".to_string(), - confidence: AutoModelHeuristicConfidence::Decisive, - }; - } - // Short messages → Flash - if len < 100 { - return AutoModelHeuristicSelection { - model: "deepseek-v4-flash".to_string(), - confidence: AutoModelHeuristicConfidence::Decisive, - }; - } - // Long complex requests → Pro. Cost-saving raises the threshold so that - // long-but-routine requests (pasted logs, CSV-style data) don't escalate. - let long_threshold = if cost_saving { 1_000 } else { 500 }; - if len > long_threshold { - return AutoModelHeuristicSelection { - model: "deepseek-v4-pro".to_string(), - confidence: AutoModelHeuristicConfidence::Decisive, - }; - } - // Grey-zone default branch: Flash is the deterministic fallback, but the - // Flash router can still add value here because there was no strong local - // signal. - AutoModelHeuristicSelection { - model: "deepseek-v4-flash".to_string(), - confidence: AutoModelHeuristicConfidence::Ambiguous, - } -} - -/// Keywords that escalate `auto`-mode model selection to -/// `deepseek-v4-pro`. The Latin entries are lowercase (the caller -/// lowercases the message); CJK has no case so the literal form -/// matches as-is. -/// -/// Without the CJK entries, a Chinese-speaking user typing -/// "帮我重构这个模块" or "审计安全漏洞" silently fell through to the -/// short/long-message threshold and usually landed on Flash even -/// for tasks that obviously need Pro-grade reasoning. -const COMPLEX_KEYWORDS: &[&str] = &[ - // English (unchanged from the original list). - "refactor", - "architecture", - "design", - "debug", - "security", - "review", - "audit", - "migrate", - "optimize", - "rewrite", - "implement", - "analyze", - // Simplified Chinese. - "\u{91cd}\u{6784}", // 重构 - "\u{67b6}\u{6784}", // 架构 - "\u{8bbe}\u{8ba1}", // 设计 - "\u{8c03}\u{8bd5}", // 调试 - "\u{5b89}\u{5168}", // 安全 - "\u{5ba1}\u{67e5}", // 审查 - "\u{5ba1}\u{8ba1}", // 审计 - "\u{8fc1}\u{79fb}", // 迁移 - "\u{4f18}\u{5316}", // 优化 - "\u{91cd}\u{5199}", // 重写 - "\u{5b9e}\u{73b0}", // 实现 - "\u{5206}\u{6790}", // 分析 - // Traditional Chinese variants where they differ. - "\u{91cd}\u{69cb}", // 重構 - "\u{67b6}\u{69cb}", // 架構 - "\u{8a2d}\u{8a08}", // 設計 - "\u{8abf}\u{8a66}", // 調試 - "\u{5be9}\u{67e5}", // 審查 - "\u{5be9}\u{8a08}", // 審計 - "\u{9077}\u{79fb}", // 遷移 - "\u{512a}\u{5316}", // 優化 - "\u{91cd}\u{5beb}", // 重寫 - "\u{5be6}\u{73fe}", // 實現 -]; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AutoRouteRecommendation { - pub model: String, - pub reasoning_effort: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AutoRouteSource { - FlashRouter, - Heuristic, -} - -impl AutoRouteSource { - #[must_use] - pub fn label(self) -> &'static str { - match self { - AutoRouteSource::FlashRouter => "flash-router", - AutoRouteSource::Heuristic => "heuristic", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AutoRouteSelection { - pub model: String, - pub reasoning_effort: Option, - pub source: AutoRouteSource, -} - -pub const AUTO_MODEL_ROUTER_SYSTEM_PROMPT: &str = "\ -You are the codewhale auto-routing classifier. Return only compact JSON: \ -{\"model\":\"deepseek-v4-flash|deepseek-v4-pro\",\"thinking\":\"off|high|max\"}. \ -Use deepseek-v4-flash for trivial, conversational, status, or single-step work. \ -Use deepseek-v4-pro for coding, debugging, release work, multi-step tasks, high-risk decisions, \ -tool-heavy work, ambiguous requests, or anything that benefits from deeper reasoning. \ -Use thinking off only for trivial no-tool answers, high for ordinary reasoning, and max for \ -agentic, coding, multi-file, release, architecture, debugging, security, tool-heavy, or uncertain work."; - -/// Bias appended to the auto-router's system prompt when the user opts in to -/// `[auto] cost_saving = true` (#1207). Reverses the default tie-breaker for -/// genuinely ambiguous requests so Pro is reserved for tasks that clearly -/// require it; ordinary tweaks, config edits, and short reads stay on Flash. -pub const AUTO_MODEL_ROUTER_COST_SAVING_ADDENDUM: &str = "\ -\n\nCost-saving mode is ON. Prefer deepseek-v4-flash for any request that is \ -not unmistakably agentic, multi-step, architecture/design, security review, \ -debugging, or otherwise clearly out of Flash's capability. Resolve ambiguous \ -cases in favour of deepseek-v4-flash, not deepseek-v4-pro."; - -/// Parse the Flash router's JSON-only response. -/// -/// The runtime treats classifier output as untrusted: only known V4 model IDs -/// and supported reasoning tiers are accepted. Anything else falls back to the -/// deterministic heuristic. -pub fn parse_auto_route_recommendation(raw: &str) -> Option { - let json = extract_first_json_object(raw)?; - let value: serde_json::Value = serde_json::from_str(json).ok()?; - let model = value.get("model").and_then(serde_json::Value::as_str)?; - let model = normalize_auto_route_model(model)?; - let reasoning_effort = value - .get("thinking") - .or_else(|| value.get("reasoning_effort")) - .or_else(|| value.get("effort")) - .and_then(serde_json::Value::as_str) - .and_then(parse_auto_route_reasoning_effort); - - Some(AutoRouteRecommendation { - model: model.to_string(), - reasoning_effort, - }) -} - -fn extract_first_json_object(raw: &str) -> Option<&str> { - let start = raw.find('{')?; - let end = raw.rfind('}')?; - (end >= start).then_some(&raw[start..=end]) -} - -fn normalize_auto_route_model(model: &str) -> Option<&'static str> { - match model.trim().to_ascii_lowercase().as_str() { - "deepseek-v4-pro" | "v4-pro" | "pro" => Some("deepseek-v4-pro"), - "deepseek-v4-flash" | "v4-flash" | "flash" => Some("deepseek-v4-flash"), - _ => None, - } -} - -fn parse_auto_route_reasoning_effort(effort: &str) -> Option { - match effort.trim().to_ascii_lowercase().as_str() { - "off" | "disabled" | "none" | "false" => Some(ReasoningEffort::Off), - "low" | "minimal" | "medium" | "mid" => Some(ReasoningEffort::High), - "high" => Some(ReasoningEffort::High), - "max" | "maximum" | "xhigh" => Some(ReasoningEffort::Max), - _ => None, - } -} - -#[must_use] -pub fn normalize_auto_route_effort(effort: ReasoningEffort) -> ReasoningEffort { - match effort { - ReasoningEffort::Low | ReasoningEffort::Medium => ReasoningEffort::High, - other => other, - } -} - -pub async fn resolve_auto_route_with_flash( - config: &crate::config::Config, - latest_request: &str, - recent_context: &str, - selected_model_mode: &str, - selected_thinking_mode: &str, -) -> AutoRouteSelection { - let cost_saving = config.auto_cost_saving(); - let heuristic = - auto_model_heuristic_selection_with_bias(latest_request, selected_model_mode, cost_saving); - if heuristic.confidence == AutoModelHeuristicConfidence::Decisive { - return auto_route_from_heuristic(latest_request, heuristic); - } - - match auto_route_flash_recommendation( - config, - latest_request, - recent_context, - selected_model_mode, - selected_thinking_mode, - ) - .await - { - Ok(Some(recommendation)) => AutoRouteSelection { - model: recommendation.model, - reasoning_effort: recommendation.reasoning_effort, - source: AutoRouteSource::FlashRouter, - }, - Ok(None) | Err(_) => auto_route_from_heuristic(latest_request, heuristic), - } -} - -fn auto_route_from_heuristic( - latest_request: &str, - heuristic: AutoModelHeuristicSelection, -) -> AutoRouteSelection { - AutoRouteSelection { - model: heuristic.model, - reasoning_effort: Some(normalize_auto_route_effort(crate::auto_reasoning::select( - false, - latest_request, - ))), - source: AutoRouteSource::Heuristic, - } -} - -async fn auto_route_flash_recommendation( - config: &crate::config::Config, - latest_request: &str, - recent_context: &str, - selected_model_mode: &str, - selected_thinking_mode: &str, -) -> Result> { - if cfg!(test) { - return Ok(None); - } - - let client = DeepSeekClient::new(config)?; - let mut router_system = AUTO_MODEL_ROUTER_SYSTEM_PROMPT.to_string(); - if config.auto_cost_saving() { - router_system.push_str(AUTO_MODEL_ROUTER_COST_SAVING_ADDENDUM); - } - let request = MessageRequest { - model: "deepseek-v4-flash".to_string(), - messages: vec![Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: auto_route_prompt( - latest_request, - recent_context, - selected_model_mode, - selected_thinking_mode, - ), - cache_control: None, - }], - }], - max_tokens: 96, - system: Some(SystemPrompt::Text(router_system)), - tools: None, - tool_choice: None, - metadata: None, - thinking: None, - reasoning_effort: Some("off".to_string()), - stream: Some(false), - temperature: Some(0.0), - top_p: None, - }; - - let response = - tokio::time::timeout(Duration::from_secs(4), client.create_message(request)).await??; - Ok(parse_auto_route_recommendation(&message_response_text( - &response, - ))) -} - -fn auto_route_prompt( - latest_request: &str, - recent_context: &str, - selected_model_mode: &str, - selected_thinking_mode: &str, -) -> String { - format!( - "Session mode: agent\nSelected model mode: {}\nSelected thinking mode: {}\n\nRecent context:\n{}\n\nLatest user request:\n{}\n\nReturn JSON only.", - selected_model_mode, - selected_thinking_mode, - if recent_context.trim().is_empty() { - "No prior context." - } else { - recent_context - }, - truncate_for_auto_router(latest_request, 4_000) - ) -} - -fn message_response_text(response: &MessageResponse) -> String { - let mut out = String::new(); - for block in &response.content { - match block { - ContentBlock::Text { text, .. } | ContentBlock::ToolResult { content: text, .. } => { - append_router_text(&mut out, text); - } - ContentBlock::Thinking { thinking } => { - append_router_text(&mut out, thinking); - } - ContentBlock::ToolUse { name, .. } => { - append_router_text(&mut out, &format!("[tool call: {name}]")); - } - _ => {} - } - } - out -} - -fn append_router_text(out: &mut String, text: &str) { - if !out.is_empty() { - out.push('\n'); - } - out.push_str(text); -} - -fn truncate_for_auto_router(text: &str, max_chars: usize) -> String { - let mut chars = text.chars(); - let truncated: String = chars.by_ref().take(max_chars).collect(); - if chars.next().is_some() { - format!("{truncated}...") - } else { - truncated - } -} - /// Toggle LSP diagnostics on/off or show status. /// /// - `/lsp on` — enable inline LSP diagnostics @@ -1830,20 +1326,10 @@ mod tests { } #[test] - fn test_set_without_args_shows_usage() { - let mut app = create_test_app(); - let result = set_config(&mut app, None); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("Usage: /set")); - assert!(msg.contains("Available settings:")); - } - - #[test] - fn test_set_model_updates_app_state() { + fn config_model_updates_app_state() { let mut app = create_test_app(); let _old_model = app.model.clone(); - let result = set_config(&mut app, Some("model deepseek-v4-flash")); + let result = config_command(&mut app, Some("model deepseek-v4-flash")); assert!(result.message.is_some()); let msg = result.message.unwrap(); assert!(msg.contains("model = deepseek-v4-flash")); @@ -1855,11 +1341,11 @@ mod tests { } #[test] - fn test_set_model_auto_enables_auto_thinking() { + fn config_model_auto_enables_auto_thinking() { let mut app = create_test_app(); app.reasoning_effort = ReasoningEffort::Off; - let result = set_config(&mut app, Some("model auto")); + let result = config_command(&mut app, Some("model auto")); assert!(result.message.is_some()); assert!(app.auto_model); @@ -1870,9 +1356,9 @@ mod tests { } #[test] - fn test_set_model_accepts_future_deepseek_model_id() { + fn config_model_accepts_future_deepseek_model_id() { let mut app = create_test_app(); - let result = set_config(&mut app, Some("model deepseek-v4")); + let result = config_command(&mut app, Some("model deepseek-v4")); assert!(result.message.is_some()); let msg = result.message.unwrap(); assert!(msg.contains("model = deepseek-v4")); @@ -1880,229 +1366,16 @@ mod tests { } #[test] - fn test_set_model_with_save_flag() { + fn config_model_with_save_flag() { let mut app = create_test_app(); - let _result = set_config(&mut app, Some("model deepseek-v4-flash --save")); + let _result = config_command(&mut app, Some("model deepseek-v4-flash --save")); // Note: This test may fail in environments where settings can't be saved // The important thing is that the model is updated assert_eq!(app.model, "deepseek-v4-flash"); } #[test] - fn auto_model_heuristic_chinese_keywords_route_to_pro() { - // Without these keywords, a Chinese user typing - // "帮我重构这个模块" (37 chars in chars().count() terms after - // the leading helper text) fell through to the short-message - // Flash branch even though the intent is obviously Pro-tier. - for msg in [ - "\u{5e2e}\u{6211}\u{91cd}\u{6784}\u{8fd9}\u{4e2a}\u{6a21}\u{5757}", // 帮我重构这个模块 - "\u{8bbe}\u{8ba1}\u{6570}\u{636e}\u{5e93}\u{67b6}\u{6784}", // 设计数据库架构 - "\u{8c03}\u{8bd5}\u{5d29}\u{6e83}\u{95ee}\u{9898}", // 调试崩溃问题 - "\u{5ba1}\u{8ba1}\u{5b89}\u{5168}\u{6f0f}\u{6d1e}", // 审计安全漏洞 - "\u{8fc1}\u{79fb}\u{5230}\u{65b0}\u{6846}\u{67b6}", // 迁移到新框架 - "\u{4f18}\u{5316}\u{6027}\u{80fd}\u{74f6}\u{9888}", // 优化性能瓶颈 - "\u{5206}\u{6790}\u{8fd9}\u{6bb5}\u{4ee3}\u{7801}", // 分析这段代码 - ] { - assert_eq!( - auto_model_heuristic(msg, "auto"), - "deepseek-v4-pro", - "expected Pro for `{msg}`", - ); - } - } - - #[test] - fn auto_model_heuristic_traditional_chinese_keywords_route_to_pro() { - for msg in [ - "\u{8acb}\u{91cd}\u{69cb}\u{6b64}\u{6a21}\u{7d44}", // 請重構此模組 - "\u{67b6}\u{69cb}\u{8a2d}\u{8a08}", // 架構設計 - "\u{4ee3}\u{78bc}\u{8abf}\u{8a66}", // 代碼調試 - "\u{5be9}\u{8a08}\u{6f0f}\u{6d1e}", // 審計漏洞 - "\u{9077}\u{79fb}\u{5230}\u{65b0}\u{67b6}\u{69cb}", // 遷移到新架構 - "\u{512a}\u{5316}\u{6027}\u{80fd}", // 優化性能 - "\u{91cd}\u{5beb}\u{4ee3}\u{78bc}", // 重寫代碼 - "\u{5be6}\u{73fe}\u{65b0}\u{529f}\u{80fd}", // 實現新功能 - ] { - assert_eq!( - auto_model_heuristic(msg, "auto"), - "deepseek-v4-pro", - "expected Pro for `{msg}`", - ); - } - } - - #[test] - fn auto_model_heuristic_short_chinese_chat_stays_on_flash() { - // Sanity: a short non-keyword Chinese message still falls - // through to the cost-saving Flash branch. - // "你好" (2 chars) — well under the 100-char Flash floor. - assert_eq!( - auto_model_heuristic("\u{4f60}\u{597d}", "auto"), - "deepseek-v4-flash", - ); - } - - #[test] - fn auto_heuristic_selection_marks_short_and_complex_routes_decisive() { - let short = auto_model_heuristic_selection_with_bias("yes", "auto", false); - assert_eq!(short.model, "deepseek-v4-flash"); - assert_eq!( - short.confidence, - AutoModelHeuristicConfidence::Decisive, - "trivial replies should skip the Flash router" - ); - - let complex = auto_model_heuristic_selection_with_bias( - "Please review the auth migration", - "auto", - false, - ); - assert_eq!(complex.model, "deepseek-v4-pro"); - assert_eq!( - complex.confidence, - AutoModelHeuristicConfidence::Decisive, - "strong complexity keywords should skip the Flash router" - ); - } - - #[test] - fn auto_heuristic_selection_leaves_default_branch_ambiguous_for_router() { - let request = - "Please update the configuration notes so each option has a clearer label. ".repeat(3); - assert!( - (100..500).contains(&request.chars().count()), - "test request must stay in the default grey zone" - ); - - let selection = auto_model_heuristic_selection_with_bias(&request, "auto", false); - assert_eq!(selection.model, "deepseek-v4-flash"); - assert_eq!( - selection.confidence, - AutoModelHeuristicConfidence::Ambiguous, - "only the grey-zone default branch should invoke the Flash router" - ); - } - - #[test] - fn auto_route_recommendation_parses_strict_json() { - let rec = - parse_auto_route_recommendation(r#"{"model":"deepseek-v4-pro","thinking":"max"}"#) - .expect("valid router response should parse"); - - assert_eq!(rec.model, "deepseek-v4-pro"); - assert_eq!(rec.reasoning_effort, Some(ReasoningEffort::Max)); - } - - #[test] - fn auto_route_recommendation_accepts_wrapped_json_aliases() { - let rec = - parse_auto_route_recommendation(r#"route: {"model":"flash","reasoning_effort":"off"}"#) - .expect("wrapped router response should parse"); - - assert_eq!(rec.model, "deepseek-v4-flash"); - assert_eq!(rec.reasoning_effort, Some(ReasoningEffort::Off)); - } - - #[test] - fn auto_route_recommendation_normalizes_legacy_low_medium_to_high() { - let rec = parse_auto_route_recommendation( - r#"{"model":"deepseek-v4-pro","reasoning_effort":"medium"}"#, - ) - .expect("medium should parse for back-compat"); - - assert_eq!(rec.model, "deepseek-v4-pro"); - assert_eq!(rec.reasoning_effort, Some(ReasoningEffort::High)); - } - - #[test] - fn auto_route_recommendation_rejects_unknown_model() { - assert!( - parse_auto_route_recommendation(r#"{"model":"some-other-model","thinking":"max"}"#,) - .is_none() - ); - } - - #[test] - fn auto_heuristic_default_routes_implement_to_pro() { - // Default (no cost-saving): "implement" is one of the borderline - // keywords that escalates to Pro. - assert_eq!( - auto_model_heuristic_with_bias("Please implement a binary search", "auto", false), - "deepseek-v4-pro" - ); - } - - #[test] - fn auto_heuristic_cost_saving_keeps_borderline_keywords_on_flash() { - // Cost-saving: "implement" / "analyze" are no longer enough to escalate. - assert_eq!( - auto_model_heuristic_with_bias("Please implement a binary search", "auto", true), - "deepseek-v4-flash" - ); - assert_eq!( - auto_model_heuristic_with_bias("analyze this snippet", "auto", true), - "deepseek-v4-flash" - ); - } - - #[test] - fn auto_heuristic_strong_keywords_still_route_to_pro_under_cost_saving() { - // Cost-saving must NOT swallow obviously Pro-grade work. - for kw in [ - "refactor", - "architecture", - "design", - "debug", - "security", - "review", - "audit", - "migrate", - "optimize", - "rewrite", - ] { - let req = format!("Please {kw} this module"); - assert_eq!( - auto_model_heuristic_with_bias(&req, "auto", true), - "deepseek-v4-pro", - "expected Pro for strong keyword `{kw}` even in cost-saving mode" - ); - } - } - - #[test] - fn auto_heuristic_cost_saving_raises_long_message_threshold() { - // 600-char request is "long" by default (>500) → Pro, - // but stays Flash under cost-saving (threshold 1000). - let body = "filler sentence. ".repeat(40); // ~680 chars - assert_eq!( - auto_model_heuristic_with_bias(&body, "auto", false), - "deepseek-v4-pro" - ); - assert_eq!( - auto_model_heuristic_with_bias(&body, "auto", true), - "deepseek-v4-flash" - ); - } - - #[test] - fn config_auto_cost_saving_defaults_to_false() { - let cfg = crate::config::Config::default(); - assert!(!cfg.auto_cost_saving()); - } - - #[test] - fn config_auto_cost_saving_reads_table() { - let cfg = crate::config::Config { - auto: Some(crate::config::AutoConfig { - cost_saving: Some(true), - }), - ..Default::default() - }; - assert!(cfg.auto_cost_saving()); - } - - #[test] - fn test_set_default_mode_normal_save_reports_normalized_value() { + fn config_default_mode_normal_save_reports_normalized_value() { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() @@ -2116,7 +1389,7 @@ mod tests { let _guard = EnvGuard::new(&temp_root); let mut app = create_test_app(); - let result = set_config(&mut app, Some("default_mode normal --save")); + let result = config_command(&mut app, Some("default_mode normal --save")); let msg = result.message.unwrap(); assert_eq!(msg, "default_mode = agent (saved)"); assert_eq!(app.mode, AppMode::Agent); @@ -2172,7 +1445,7 @@ mod tests { Some("base_url https://example.internal.local/v1 --save"), ); let msg = result.message.unwrap(); - let saved_path = config_toml_path(None).unwrap(); + let saved_path = crate::config_persistence::config_toml_path(None).unwrap(); let saved = fs::read_to_string(&saved_path).unwrap(); assert_eq!( @@ -2362,6 +1635,112 @@ mod tests { assert!(saved.contains("base_url = \"https://example.session.local/v1\"")); } + #[test] + fn config_command_stream_chunk_timeout_session_query_uses_live_value() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + + let result = config_command(&mut app, Some("stream_chunk_timeout_secs 90")); + assert!(!result.is_error); + assert_eq!(app.stream_chunk_timeout_secs, 90); + assert!(matches!( + result.action, + Some(AppAction::UpdateStreamChunkTimeout(90)) + )); + + let query = config_command(&mut app, Some("stream_chunk_timeout_secs")); + assert_eq!( + query.message.as_deref(), + Some("stream_chunk_timeout_secs = 90") + ); + } + + #[test] + fn config_command_stream_chunk_timeout_save_persists_tui_key() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-stream-timeout-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + 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("stream_chunk_timeout_secs 120 --save")); + let msg = result.message.unwrap(); + let saved = fs::read_to_string(&config_path).unwrap(); + + assert_eq!( + msg, + format!( + "stream_chunk_timeout_secs = 120 (saved to {}; affects subsequent turns in this session)", + config_path.display() + ) + ); + assert!(saved.contains("[tui]")); + assert!(saved.contains("stream_chunk_timeout_secs = 120")); + assert_eq!(app.stream_chunk_timeout_secs, 120); + assert!(matches!( + result.action, + Some(AppAction::UpdateStreamChunkTimeout(120)) + )); + } + + #[test] + fn config_command_stream_chunk_timeout_rejects_invalid_input() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + + let text = config_command(&mut app, Some("stream_chunk_timeout_secs abc")); + assert!(text.is_error); + assert!( + text.message + .unwrap() + .contains("stream_chunk_timeout_secs must be a whole number") + ); + + let high = config_command(&mut app, Some("stream_chunk_timeout_secs 3601")); + assert!(high.is_error); + assert!( + high.message + .unwrap() + .contains("stream_chunk_timeout_secs must be 0 or 1..=3600") + ); + } + + #[test] + fn config_command_stream_chunk_timeout_zero_reports_effective_default() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + + let result = config_command(&mut app, Some("stream_chunk_timeout_secs 0")); + + assert!(!result.is_error); + assert_eq!( + app.stream_chunk_timeout_secs, + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + ); + assert_eq!( + result.message.as_deref(), + Some( + "stream_chunk_timeout_secs = 0 (default 300) (session only; affects subsequent turns in this session)" + ) + ); + assert!(matches!( + result.action, + Some(AppAction::UpdateStreamChunkTimeout( + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + )) + )); + } + #[test] fn config_command_provider_url_token_plan_persists_provider_base_url() { let temp_root = env::temp_dir().join(format!( @@ -2444,7 +1823,7 @@ mod tests { let _guard = EnvGuard::new(&temp_root); let mut app = create_test_app(); - let result = set_config(&mut app, Some("theme grayscale --save")); + let result = config_command(&mut app, Some("theme grayscale --save")); let msg = result.message.unwrap(); assert_eq!(msg, "theme = grayscale (saved)"); @@ -2456,50 +1835,50 @@ mod tests { } #[test] - fn test_set_approval_mode_valid_values() { + fn config_approval_mode_valid_values() { let mut app = create_test_app(); // Test auto - let result = set_config(&mut app, Some("approval_mode auto")); + let result = config_command(&mut app, Some("approval_mode auto")); assert!(result.message.is_some()); assert_eq!(app.approval_mode, ApprovalMode::Auto); // Test suggest - let result = set_config(&mut app, Some("approval_mode suggest")); + let result = config_command(&mut app, Some("approval_mode suggest")); assert!(result.message.is_some()); assert_eq!(app.approval_mode, ApprovalMode::Suggest); // Test never - let result = set_config(&mut app, Some("approval_mode never")); + let result = config_command(&mut app, Some("approval_mode never")); assert!(result.message.is_some()); assert_eq!(app.approval_mode, ApprovalMode::Never); } #[test] - fn test_set_approval_mode_invalid_value() { + fn config_approval_mode_invalid_value() { let mut app = create_test_app(); - let result = set_config(&mut app, Some("approval_mode invalid")); + let result = config_command(&mut app, Some("approval_mode invalid")); assert!(result.message.is_some()); let msg = result.message.unwrap(); assert!(msg.contains("Invalid approval_mode")); } #[test] - fn test_set_without_save_flag() { + fn config_without_save_flag() { let _lock = lock_test_env(); let mut app = create_test_app(); - let result = set_config(&mut app, Some("auto_compact true")); + let result = config_command(&mut app, Some("auto_compact true")); assert!(result.message.is_some()); let msg = result.message.unwrap(); assert!(msg.contains("(session only")); } #[test] - fn test_set_composer_border_updates_live_app() { + fn config_composer_border_updates_live_app() { let _lock = lock_test_env(); let mut app = create_test_app(); app.composer_border = true; - let result = set_config(&mut app, Some("composer_border false")); + let result = config_command(&mut app, Some("composer_border false")); assert!(result.message.is_some()); assert!(!app.composer_border); @@ -2562,165 +1941,4 @@ mod tests { let updated = fs::read_to_string(config_path).unwrap(); assert!(!updated.contains("api_key")); } - - #[test] - fn test_set_invalid_setting() { - let _lock = lock_test_env(); - let mut app = create_test_app(); - let _result = set_config(&mut app, Some("nonexistent value")); - // Should either error or handle as session setting - // The current implementation tries to set it in Settings - // which may succeed or fail depending on Settings implementation - } - - #[test] - fn test_set_key_without_value() { - let mut app = create_test_app(); - let result = set_config(&mut app, Some("model")); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("Usage: /set")); - } - - #[test] - fn persist_status_items_writes_tui_section_to_config_toml() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-statusline-persist-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - - let items = vec![ - crate::config::StatusItem::Mode, - crate::config::StatusItem::Model, - crate::config::StatusItem::Cost, - ]; - - let path = persist_status_items(&items).expect("persist should succeed"); - let body = fs::read_to_string(&path).expect("written file should be readable"); - assert!(body.contains("[tui]"), "expected [tui] section in {body}"); - assert!( - body.contains("status_items"), - "expected status_items key in {body}" - ); - assert!(body.contains("\"mode\""), "expected mode key in {body}"); - assert!(body.contains("\"cost\""), "expected cost key in {body}"); - } - - #[test] - fn config_toml_path_uses_codewhale_home_for_fresh_installs() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-config-path-fresh-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - - unsafe { - env::remove_var("DEEPSEEK_CONFIG_PATH"); - } - - assert_eq!( - config_toml_path(None).unwrap(), - temp_root.join(".codewhale").join("config.toml") - ); - } - - #[test] - fn config_toml_path_preserves_legacy_config_when_it_exists() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-config-path-legacy-{}-{}", - std::process::id(), - nanos - )); - let legacy_config = temp_root.join(".deepseek").join("config.toml"); - fs::create_dir_all(legacy_config.parent().unwrap()).unwrap(); - fs::write(&legacy_config, "").unwrap(); - let _guard = EnvGuard::new(&temp_root); - - unsafe { - env::remove_var("DEEPSEEK_CONFIG_PATH"); - } - - assert_eq!(config_toml_path(None).unwrap(), legacy_config); - } - - #[test] - fn config_toml_path_prefers_codewhale_env_over_legacy_env() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-config-path-env-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - let preferred = temp_root.join("preferred.toml"); - let legacy = temp_root.join("legacy.toml"); - - unsafe { - env::set_var("CODEWHALE_CONFIG_PATH", &preferred); - env::set_var("DEEPSEEK_CONFIG_PATH", &legacy); - } - - assert_eq!(config_toml_path(None).unwrap(), preferred); - } - - #[test] - fn persist_status_items_preserves_existing_unrelated_keys() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-statusline-preserve-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - - let path = temp_root.join(".deepseek").join("config.toml"); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - // Seed the config with a sentinel key the picker MUST NOT clobber. - fs::write( - &path, - "api_key = \"sentinel-key\"\nmodel = \"deepseek-v4-pro\"\n", - ) - .unwrap(); - - let written = persist_status_items(&[crate::config::StatusItem::Mode]) - .expect("persist should succeed"); - let body = fs::read_to_string(&written).expect("written file should be readable"); - assert!( - body.contains("api_key = \"sentinel-key\""), - "round-trip lost api_key: {body}" - ); - assert!( - body.contains("model = \"deepseek-v4-pro\""), - "round-trip lost model: {body}" - ); - assert!( - body.contains("status_items"), - "expected status_items in {body}" - ); - } } diff --git a/crates/tui/src/commands/debug.rs b/crates/tui/src/commands/debug.rs index eee41bb59..5d997c752 100644 --- a/crates/tui/src/commands/debug.rs +++ b/crates/tui/src/commands/debug.rs @@ -1183,6 +1183,22 @@ mod tests { let _spill_guard = crate::tools::truncate::TEST_SPILLOVER_GUARD .lock() .unwrap_or_else(|err| err.into_inner()); + // Set a temporary spillover root so wire-dedup can persist + // SHA-addressed tool-result files without depending on a + // writable $HOME (nix sandboxes have a read-only home tree). + let tmp = tempfile::tempdir().expect("tempdir"); + let _restore = { + let prior = crate::tools::truncate::set_test_spillover_root(Some( + tmp.path().join(".deepseek").join("tool_outputs"), + )); + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + crate::tools::truncate::set_test_spillover_root(self.0.take()); + } + } + Restore(prior) + }; let mut app = create_test_app(); let long_output = format!("{}{}", "A".repeat(7_000), "Z".repeat(7_000)); app.api_messages.push(Message { @@ -1225,10 +1241,25 @@ mod tests { let result = cache(&mut app, Some("inspect")); let msg = result.message.expect("inspect output"); - assert!(msg.contains("original_chars=14000"), "got: {msg}"); - assert!(msg.contains("truncated=true"), "got: {msg}"); - assert!(msg.contains("deduplicated=false"), "got: {msg}"); - assert!(msg.contains("deduplicated=true"), "got: {msg}"); + let tool_budget_lines: Vec<_> = msg + .lines() + .filter(|line| line.contains("original_chars=14000")) + .collect(); + assert_eq!(tool_budget_lines.len(), 2, "got: {msg}"); + + let first_sighting = tool_budget_lines + .iter() + .find(|line| line.contains("deduplicated=false")) + .expect("first tool-result sighting should report non-dedup metadata"); + assert!(first_sighting.contains("sent_chars="), "got: {msg}"); + assert!(first_sighting.contains("truncated=true"), "got: {msg}"); + + let repeat_sighting = tool_budget_lines + .iter() + .find(|line| line.contains("deduplicated=true")) + .expect("repeat tool-result sighting should report dedup metadata"); + assert!(repeat_sighting.contains("sent_chars="), "got: {msg}"); + assert!(repeat_sighting.contains("truncated=false"), "got: {msg}"); } #[test] diff --git a/crates/tui/src/commands/hf.rs b/crates/tui/src/commands/hf.rs new file mode 100644 index 000000000..0d2a7230e --- /dev/null +++ b/crates/tui/src/commands/hf.rs @@ -0,0 +1,249 @@ +//! `/hf` - Hugging Face MCP and provider concept helpers. + +use crate::mcp::{McpConfig, McpServerConfig}; +use crate::tui::app::App; + +use super::CommandResult; + +const HF_MCP_SETTINGS_URL: &str = "https://huggingface.co/settings/mcp"; +const HF_MCP_DOCS_URL: &str = "https://huggingface.co/docs/hub/hf-mcp-server"; +const HF_MCP_SERVER_URL: &str = "https://huggingface.co/mcp"; + +const HF_MCP_CONFIG_SKELETON: &str = r#"{ + "servers": { + "huggingface": { + "url": "https://huggingface.co/mcp", + "headers": { + "Authorization": "Bearer ${HF_TOKEN}" + } + } + } +}"#; + +/// Explainer shown by `/hf concepts`. +const HF_CONCEPTS: &str = "\ +CodeWhale has three distinct Hugging Face surfaces: + +1. Hugging Face provider route - chat inference + Switch the active LLM backend to Hugging Face Inference Providers. + Use: /provider huggingface + Config: provider = \"huggingface\" or [providers.huggingface] + Auth: HF_TOKEN or HUGGINGFACE_API_KEY + +2. Hugging Face MCP - Hub, docs, datasets, Spaces, and community tools + Connect CodeWhale to Hugging Face's MCP server through mcp.json. + Use: /hf mcp status or /hf mcp setup + Then: /mcp validate or restart CodeWhale so model-visible tools reload. + +3. Hugging Face Hub workflows - publish, upload, or manage repositories + Use explicit Hub tooling such as huggingface_hub or git-based flows. + CodeWhale does not upload to the Hub through /hf."; + +pub fn hf(app: &mut App, args: Option<&str>) -> CommandResult { + let raw = args.unwrap_or("").trim(); + if raw.is_empty() { + return usage(); + } + + let mut parts = raw.split_whitespace(); + let subcommand = parts.next().unwrap_or_default().to_ascii_lowercase(); + match subcommand.as_str() { + "mcp" => hf_mcp(app, parts.next()), + "concepts" | "explain" => CommandResult::message(HF_CONCEPTS), + _ => CommandResult::error(format!( + "Unknown /hf subcommand: {subcommand}. Use /hf mcp or /hf concepts." + )), + } +} + +fn usage() -> CommandResult { + CommandResult::message( + "Usage: /hf mcp \n\ + /hf concepts\n\n\ + Hugging Face MCP settings: https://huggingface.co/settings/mcp", + ) +} + +fn hf_mcp(app: &mut App, action: Option<&str>) -> CommandResult { + match action.unwrap_or("status").to_ascii_lowercase().as_str() { + "status" => hf_mcp_status(app), + "setup" => CommandResult::message(hf_mcp_setup_message(app)), + other => CommandResult::error(format!( + "Unknown /hf mcp subcommand: {other}. Use status or setup." + )), + } +} + +fn hf_mcp_status(app: &App) -> CommandResult { + match crate::mcp::load_config(&app.mcp_config_path) { + Ok(config) => { + if let Some(server_name) = configured_hf_mcp_server(&config) { + CommandResult::message(format!( + "Hugging Face MCP appears configured as `{server_name}` in {}.\n\ + Run /mcp validate or restart CodeWhale if tools are not visible yet.", + app.mcp_config_path.display() + )) + } else { + CommandResult::message(format!( + "Hugging Face MCP is not configured in {}.\n\ + Run /hf mcp setup for the settings-generated config workflow.", + app.mcp_config_path.display() + )) + } + } + Err(err) => CommandResult::error(format!( + "Could not read MCP config {}: {err}", + app.mcp_config_path.display() + )), + } +} + +fn hf_mcp_setup_message(app: &App) -> String { + format!( + "Use Hugging Face's settings-generated MCP configuration when available:\n\ + 1. Open {HF_MCP_SETTINGS_URL} while signed in.\n\ + 2. Choose your MCP client and copy the generated configuration snippet.\n\ + 3. Paste the Hugging Face server entry into {}.\n\ + 4. Restart CodeWhale, or run /mcp reload for the TUI manager snapshot.\n\n\ + CodeWhale-compatible placeholder shape:\n\n\ + ```json\n{HF_MCP_CONFIG_SKELETON}\n```\n\n\ + The placeholder is intentionally not runnable until your private MCP config has a real token value. \ + Do not commit real Hugging Face tokens.\n\n\ + Docs: {HF_MCP_DOCS_URL}\n\ + Server: {HF_MCP_SERVER_URL}", + app.mcp_config_path.display() + ) +} + +fn configured_hf_mcp_server(config: &McpConfig) -> Option<&str> { + config + .servers + .iter() + .find(|(name, server)| looks_like_hf_mcp_server(name, server)) + .map(|(name, _)| name.as_str()) +} + +fn looks_like_hf_mcp_server(name: &str, server: &McpServerConfig) -> bool { + let compact_name: String = name + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .flat_map(|ch| ch.to_lowercase()) + .collect(); + if matches!( + compact_name.as_str(), + "huggingface" | "huggingfacemcp" | "hfmcp" | "hfmcpserver" + ) { + return true; + } + + server.url.as_deref().is_some_and(|url| { + let url = url.to_ascii_lowercase(); + url.contains("huggingface.co/mcp") || url.contains("huggingface.co/api/mcp") + }) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::PathBuf; + + use crate::config::Config; + use crate::tui::app::TuiOptions; + use tempfile::tempdir; + + use super::*; + + fn app_with_mcp_path(mcp_config_path: PathBuf) -> App { + App::new( + TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace: PathBuf::from("."), + config_path: None, + config_profile: None, + allow_shell: false, + use_alt_screen: false, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 2, + skills_dir: PathBuf::from("."), + memory_path: PathBuf::from("memory.md"), + notes_path: PathBuf::from("notes.txt"), + mcp_config_path, + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }, + &Config::default(), + ) + } + + #[test] + fn hf_mcp_config_skeleton_keeps_token_placeholder_only() { + assert!(HF_MCP_CONFIG_SKELETON.contains("${HF_TOKEN}")); + assert!(!HF_MCP_CONFIG_SKELETON.contains("hf_")); + assert!(!HF_MCP_CONFIG_SKELETON.contains("Bearer hf_")); + serde_json::from_str::(HF_MCP_CONFIG_SKELETON) + .expect("skeleton should be valid JSON"); + } + + #[test] + fn hf_concepts_explains_provider_mcp_and_hub_surfaces() { + assert!(HF_CONCEPTS.contains("provider route")); + assert!(HF_CONCEPTS.contains("Hugging Face MCP")); + assert!(HF_CONCEPTS.contains("Hub workflows")); + assert!(HF_CONCEPTS.contains("/provider huggingface")); + assert!(HF_CONCEPTS.contains("/hf mcp")); + } + + #[test] + fn hf_mcp_status_detects_settings_named_server() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("mcp.json"); + fs::write( + &path, + r#"{"mcpServers":{"hf-mcp-server":{"url":"https://huggingface.co/mcp"}}}"#, + ) + .expect("write mcp config"); + let app = app_with_mcp_path(path); + + let result = hf_mcp_status(&app); + + assert!(!result.is_error); + let message = result.message.expect("status message"); + assert!(message.contains("appears configured")); + assert!(message.contains("hf-mcp-server")); + } + + #[test] + fn hf_mcp_status_reports_missing_server_without_network() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("mcp.json"); + fs::write(&path, r#"{"servers":{"local":{"command":"node"}}}"#).expect("write mcp config"); + let app = app_with_mcp_path(path); + + let result = hf_mcp_status(&app); + + assert!(!result.is_error); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("not configured") + ); + } + + #[test] + fn hf_usage_and_setup_do_not_advertise_hub_search() { + let app = app_with_mcp_path(PathBuf::from("mcp.json")); + let usage = usage().message.expect("usage"); + let setup = hf_mcp_setup_message(&app); + + assert!(!usage.contains("/hf search")); + assert!(!setup.contains("/hf search")); + assert!(setup.contains(HF_MCP_SETTINGS_URL)); + } +} diff --git a/crates/tui/src/commands/hooks.rs b/crates/tui/src/commands/hooks.rs index e837e477c..d01a52ca4 100644 --- a/crates/tui/src/commands/hooks.rs +++ b/crates/tui/src/commands/hooks.rs @@ -43,6 +43,10 @@ fn events() -> CommandResult { let ordered = [ (HookEvent::SessionStart, "fires once when the TUI launches"), (HookEvent::SessionEnd, "fires once on graceful shutdown"), + ( + HookEvent::TurnEnd, + "fires after a turn completes (observer-only)", + ), ( HookEvent::MessageSubmit, "fires before model dispatch; can transform or block submitted text", @@ -146,6 +150,7 @@ fn event_label(event: HookEvent) -> &'static str { HookEvent::ToolCallAfter => "tool_call_after", HookEvent::ModeChange => "mode_change", HookEvent::OnError => "on_error", + HookEvent::TurnEnd => "turn_end", HookEvent::SubagentSpawn => "subagent_spawn", HookEvent::SubagentComplete => "subagent_complete", HookEvent::ShellEnv => "shell_env", @@ -266,6 +271,7 @@ mod tests { let positions: Vec<(usize, &str)> = [ "session_start", "session_end", + "turn_end", "message_submit", "tool_call_before", "tool_call_after", @@ -310,6 +316,7 @@ mod tests { assert_eq!(event_label(HookEvent::MessageSubmit), "message_submit"); assert_eq!(event_label(HookEvent::ModeChange), "mode_change"); assert_eq!(event_label(HookEvent::OnError), "on_error"); + assert_eq!(event_label(HookEvent::TurnEnd), "turn_end"); assert_eq!(event_label(HookEvent::SubagentSpawn), "subagent_spawn"); assert_eq!( event_label(HookEvent::SubagentComplete), diff --git a/crates/tui/src/commands/init.rs b/crates/tui/src/commands/init.rs index 7ca53ec92..3b24f23ed 100644 --- a/crates/tui/src/commands/init.rs +++ b/crates/tui/src/commands/init.rs @@ -1,14 +1,22 @@ //! /init command - Generate AGENTS.md for project +//! +//! Gathers rich project context (directory structure, build system, git info, CI/CD, +//! test frameworks) and delegates AGENTS.md generation to the LLM agent via +//! `AppAction::SendMessage`. This mirrors Claude Code's `/init` behavior — the agent +//! reads key source files, understands the architecture, and produces a customized, +//! comprehensive project guide. -use std::fmt::Write; use std::io::Read; -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::process::Command; -use crate::tui::app::App; +use crate::project_context; +use crate::tui::app::{App, AppAction}; use super::CommandResult; -/// Generate an AGENTS.md file for the current project +/// Generate an AGENTS.md file for the current project by gathering context and +/// delegating content generation to the LLM agent. pub fn init(app: &mut App) -> CommandResult { let workspace = &app.workspace; @@ -19,20 +27,31 @@ pub fn init(app: &mut App) -> CommandResult { let agents_path = workspace.join("AGENTS.md"); let already_exists = agents_path.exists(); - // Detect project type and generate appropriate content - let content = generate_project_doc(workspace); - - // Write the file - match std::fs::write(&agents_path, &content) { - Ok(()) => { - let verb = if already_exists { "Updated" } else { "Created" }; - CommandResult::message(format!( - "{verb} AGENTS.md at {}\n\nEdit this file to customize agent behavior for your project.", - agents_path.display() - )) - } - Err(e) => CommandResult::error(format!("Failed to write AGENTS.md: {e}")), - } + // Gather rich project context for the agent. + let context = gather_project_context(workspace); + + // Read existing AGENTS.md content if updating. + let existing_content = if already_exists { + read_existing_agents_md(workspace) + } else { + None + }; + + // Construct the prompt for the LLM agent. + let prompt = build_init_prompt(&context, existing_content.as_deref(), already_exists); + + // Display message to user AND send the prompt to the agent. + let verb = if already_exists { + "Updating" + } else { + "Creating" + }; + let msg = format!( + "{verb} AGENTS.md at {}\n\nThe agent will analyze the codebase and generate a customized project guide.", + agents_path.display() + ); + + CommandResult::with_message_and_action(msg, AppAction::SendMessage(prompt)) } /// If `workspace` is inside a git repository, ensure workspace-local CodeWhale @@ -42,12 +61,11 @@ pub fn init(app: &mut App) -> CommandResult { /// committable (a directory exclude cannot be overridden, so `.codewhale/*` plus /// a negation is required). fn ensure_deepseek_gitignored(workspace: &Path) { - // Only act if this workspace is a git repo. - if !workspace.join(".git").exists() { + let Some(git_root) = git_root(workspace) else { return; - } + }; - let gitignore = workspace.join(".gitignore"); + let gitignore = git_root.join(".gitignore"); let entries = [ "**/.codewhale/*", "!**/.codewhale/constitution.json", @@ -98,171 +116,688 @@ fn ensure_deepseek_gitignored(workspace: &Path) { } } -/// Generate project documentation based on detected project type -fn generate_project_doc(workspace: &Path) -> String { - let mut doc = String::new(); - - // Header - doc.push_str("# Project Instructions\n\n"); - doc.push_str("This file provides context for AI assistants working on this project.\n\n"); - - // Detect project type - let project_info = detect_project_type(workspace); - doc.push_str(&project_info); - - // Agent behavior — conventions, gotchas, testing - doc.push_str("## Agent Guidance\n\n"); - doc.push_str("\n"); - doc.push_str("\n"); - doc.push_str("\n"); - doc.push('\n'); - doc.push_str("- **CodeWhale reads this file as:** AGENTS.md (canonical cross-agent project instructions). \n"); - doc.push_str( - "- **Read-only surface:** \n", - ); - doc.push_str( - "- **Never edit:** \n", - ); - doc.push_str("- **Always test with:** \n"); - doc.push('\n'); - - // Architecture — the "big picture" that requires reading multiple files - doc.push_str("## Architecture\n\n"); - doc.push_str("\n"); - doc.push_str("\n"); - doc.push('\n'); - doc.push_str("### Entry Points\n"); - doc.push_str( - "\n", - ); - doc.push('\n'); - doc.push_str("### Key Modules\n"); - doc.push_str("\n"); - doc.push('\n'); - doc.push_str("### Data Flow\n"); - doc.push_str("\n"); - doc.push('\n'); - - // Cache-aware editing — helps maintain prefix-cache hit rates - doc.push_str("## Cache Stability\n\n"); - doc.push_str("\n"); - doc.push_str( - "\n", - ); - doc.push('\n'); - doc.push_str("- **Frequently-rebuilt files:** \n"); - doc.push_str("- **Stable scaffolding:** \n"); - doc.push_str("- **Append, don't reorder:** \n"); - doc.push('\n'); - - // Guidelines - doc.push_str("## Guidelines\n\n"); - doc.push_str("- Follow existing code style and patterns\n"); - doc.push_str("- Write tests for new functionality\n"); - doc.push_str("- Keep changes focused and atomic\n"); - doc.push_str("- Document public APIs\n"); - doc.push_str("- Update this file when project conventions change\n"); - - doc +// --------------------------------------------------------------------------- +// Context gathering functions +// --------------------------------------------------------------------------- + +/// Orchestrate all context gathering and return structured Markdown for the agent prompt. +fn gather_project_context(workspace: &Path) -> String { + let mut ctx = String::new(); + + // Project type summary (from existing utility). + let summary = crate::utils::summarize_project(workspace); + ctx.push_str("## Project Summary\n\n"); + ctx.push_str(&summary); + ctx.push_str("\n\n"); + + // Cargo.toml analysis. + if let Some(info) = parse_cargo_toml(workspace) { + ctx.push_str("## Rust / Cargo\n\n"); + ctx.push_str(&info); + ctx.push_str("\n\n"); + } + + // package.json analysis. + if let Some(info) = parse_package_json(workspace) { + ctx.push_str("## Node.js / npm\n\n"); + ctx.push_str(&info); + ctx.push_str("\n\n"); + } + + // Git repository info. + if let Some(info) = gather_git_info(workspace) { + ctx.push_str("## Git Repository\n\n"); + ctx.push_str(&info); + ctx.push_str("\n\n"); + } + + // CI/CD systems. + let ci = detect_ci_systems(workspace); + if !ci.is_empty() { + ctx.push_str("## CI/CD\n\n"); + for system in &ci { + let _ = std::fmt::write(&mut ctx, format_args!("- {system}\n")); + } + ctx.push('\n'); + } + + // Build systems. + let build = detect_build_systems(workspace); + if !build.is_empty() { + ctx.push_str("## Additional Build Systems\n\n"); + for system in &build { + let _ = std::fmt::write(&mut ctx, format_args!("- {system}\n")); + } + ctx.push('\n'); + } + + // Test frameworks. + let tests = detect_test_frameworks(workspace); + if !tests.is_empty() { + ctx.push_str("## Test Frameworks\n\n"); + for framework in &tests { + let _ = std::fmt::write(&mut ctx, format_args!("- {framework}\n")); + } + ctx.push('\n'); + } + + // Directory tree (from existing utility). + let tree = crate::utils::project_tree(workspace, 3); + ctx.push_str("## Directory Structure (depth 3)\n\n```\n"); + ctx.push_str(&tree); + ctx.push_str("\n```\n\n"); + + // Structured project context pack (from existing utility). + if let Some(pack) = project_context::generate_project_context_pack(workspace) { + ctx.push_str("## Detailed Project Context\n\n```json\n"); + ctx.push_str(&pack); + ctx.push_str("\n```\n\n"); + } + + ctx } -/// Detect project type and return relevant information -fn detect_project_type(workspace: &Path) -> String { - let mut info = String::new(); - - // Check for Rust project - if workspace.join("Cargo.toml").exists() { - info.push_str("## Project Type: Rust\n\n"); - info.push_str("### Commands\n"); - info.push_str("- Build: `cargo build`\n"); - info.push_str("- Test: `cargo test`\n"); - info.push_str("- Run: `cargo run`\n"); - info.push_str("- Check: `cargo check`\n"); - info.push_str("- Format: `cargo fmt`\n"); - info.push_str("- Lint: `cargo clippy`\n\n"); - - // Try to extract project name from Cargo.toml - if let Some(name) = std::fs::read_to_string(workspace.join("Cargo.toml")) - .ok() - .and_then(|content| extract_cargo_name(&content)) - { - let _ = write!(info, "### Project: {name}\n\n"); +/// Parse `Cargo.toml` and return a human-readable summary of the Rust project structure. +fn parse_cargo_toml(workspace: &Path) -> Option { + let cargo_path = workspace.join("Cargo.toml"); + let raw = std::fs::read_to_string(&cargo_path).ok()?; + let doc: toml::Value = toml::from_str(&raw).ok()?; + + let mut lines: Vec = Vec::new(); + + // Package info. + if let Some(package) = doc.get("package") { + if let Some(name) = package.get("name").and_then(|v| v.as_str()) { + lines.push(format!("- Package name: `{name}`")); + } + if let Some(version) = package.get("version").and_then(|v| v.as_str()) { + lines.push(format!("- Version: {version}")); + } + if let Some(edition) = package.get("edition").and_then(|v| v.as_str()) { + lines.push(format!("- Rust edition: {edition}")); } } - // Check for Node.js project - else if workspace.join("package.json").exists() { - info.push_str("## Project Type: Node.js\n\n"); - info.push_str("### Commands\n"); - info.push_str("- Install: `npm install`\n"); - info.push_str("- Test: `npm test`\n"); - info.push_str("- Build: `npm run build`\n"); - info.push_str("- Start: `npm start`\n\n"); - - // Check for common frameworks - if workspace.join("next.config.js").exists() || workspace.join("next.config.ts").exists() { - info.push_str("### Framework: Next.js\n\n"); - } else if workspace.join("vite.config.js").exists() - || workspace.join("vite.config.ts").exists() - { - info.push_str("### Framework: Vite\n\n"); + + // Workspace info. + if let Some(workspace_section) = doc.get("workspace") { + lines.push("- **This is a workspace root**".to_string()); + if let Some(members) = workspace_section.get("members").and_then(|v| v.as_array()) { + let mut member_names: Vec<&str> = members.iter().filter_map(|m| m.as_str()).collect(); + member_names.sort_unstable(); + if !member_names.is_empty() { + lines.push(format!("- Workspace members: {}", member_names.join(", "))); + } } } - // Check for Python project - else if workspace.join("pyproject.toml").exists() || workspace.join("setup.py").exists() { - info.push_str("## Project Type: Python\n\n"); - info.push_str("### Commands\n"); - if workspace.join("pyproject.toml").exists() { - info.push_str("- Install: `pip install -e .`\n"); + + // Dependencies. + if let Some(deps) = doc.get("dependencies").and_then(|v| v.as_table()) { + let mut dep_names: Vec<&str> = deps.keys().map(|k| k.as_str()).collect(); + dep_names.sort_unstable(); + if !dep_names.is_empty() { + lines.push(format!("- Key dependencies: {}", dep_names.join(", "))); } - info.push_str("- Test: `pytest`\n"); - info.push_str("- Format: `black .`\n"); - info.push_str("- Lint: `ruff check .`\n\n"); } - // Check for Go project - else if workspace.join("go.mod").exists() { - info.push_str("## Project Type: Go\n\n"); - info.push_str("### Commands\n"); - info.push_str("- Build: `go build`\n"); - info.push_str("- Test: `go test ./...`\n"); - info.push_str("- Run: `go run .`\n"); - info.push_str("- Format: `go fmt ./...`\n\n"); + + // Dev dependencies — test frameworks. + if let Some(dev_deps) = doc.get("dev-dependencies").and_then(|v| v.as_table()) { + let mut dev_names: Vec<&str> = dev_deps.keys().map(|k| k.as_str()).collect(); + dev_names.sort_unstable(); + if !dev_names.is_empty() { + lines.push(format!("- Dev dependencies: {}", dev_names.join(", "))); + } } - // Unknown project type - else { - info.push_str("## Project Type: Unknown\n\n"); - info.push_str("\n\n"); + + // Workspace-level dependencies (shared across workspace members). + if let Some(ws_deps) = doc + .get("workspace") + .and_then(|w| w.get("dependencies")) + .and_then(|v| v.as_table()) + { + let mut ws_dep_names: Vec<&str> = ws_deps.keys().map(|k| k.as_str()).collect(); + ws_dep_names.sort_unstable(); + if !ws_dep_names.is_empty() { + lines.push(format!( + "- Workspace dependencies: {}", + ws_dep_names.join(", ") + )); + } } - // Check for README - if workspace.join("README.md").exists() { - info.push_str("### Documentation\n"); - info.push_str("See README.md for project overview.\n\n"); + // Features. + if let Some(features) = doc.get("features").and_then(|v| v.as_table()) { + let mut feat_names: Vec<&str> = features.keys().map(|k| k.as_str()).collect(); + feat_names.sort_unstable(); + if !feat_names.is_empty() { + lines.push(format!("- Features: {}", feat_names.join(", "))); + } } - // Check for .gitignore - if workspace.join(".gitignore").exists() { - info.push_str("### Version Control\n"); - info.push_str("This project uses Git. See .gitignore for excluded files.\n\n"); + if lines.is_empty() { + None + } else { + Some(lines.join("\n")) + } +} + +/// Parse `package.json` and return a human-readable summary of the Node.js project. +fn parse_package_json(workspace: &Path) -> Option { + let pkg_path = workspace.join("package.json"); + let raw = std::fs::read_to_string(&pkg_path).ok()?; + let doc: serde_json::Value = serde_json::from_str(&raw).ok()?; + + let mut lines: Vec = Vec::new(); + + if let Some(name) = doc.get("name").and_then(|v| v.as_str()) { + lines.push(format!("- Package name: `{name}`")); + } + + // Scripts. + if let Some(scripts) = doc.get("scripts").and_then(|v| v.as_object()) { + let mut script_names: Vec<&str> = scripts.keys().map(|k| k.as_str()).collect(); + script_names.sort_unstable(); + if !script_names.is_empty() { + lines.push(format!("- Scripts: {}", script_names.join(", "))); + } + } + + // Dependencies. + if let Some(deps) = doc.get("dependencies").and_then(|v| v.as_object()) { + let mut dep_keys: Vec<&str> = deps.keys().map(|k| k.as_str()).collect(); + dep_keys.sort_unstable(); + if !dep_keys.is_empty() { + // Detect frameworks from runtime deps. + let frameworks = detect_js_frameworks(&dep_keys); + if !frameworks.is_empty() { + lines.push(format!("- Frameworks detected: {}", frameworks.join(", "))); + } + lines.push(format!("- Dependencies: {}", dep_keys.join(", "))); + } + } + + // Dev dependencies. + if let Some(dev_deps) = doc.get("devDependencies").and_then(|v| v.as_object()) { + let mut dev_keys: Vec<&str> = dev_deps.keys().map(|k| k.as_str()).collect(); + dev_keys.sort_unstable(); + if !dev_keys.is_empty() { + // Also detect build-tool/framework entries from devDependencies + // (Vite, webpack, esbuild, Turbopack, etc.). + let dev_frameworks = detect_js_frameworks(&dev_keys); + if !dev_frameworks.is_empty() { + lines.push(format!( + "- Dev frameworks/tools: {}", + dev_frameworks.join(", ") + )); + } + lines.push(format!("- Dev dependencies: {}", dev_keys.join(", "))); + } } - info + if lines.is_empty() { + None + } else { + Some(lines.join("\n")) + } } -/// Extract project name from Cargo.toml -fn extract_cargo_name(content: &str) -> Option { - for line in content.lines() { - let line = line.trim(); - if line.starts_with("name") && line.contains('=') { - let parts: Vec<&str> = line.splitn(2, '=').collect(); - if parts.len() == 2 { - let name = parts[1].trim().trim_matches('"').trim_matches('\''); - return Some(name.to_string()); +/// Detect JS frameworks from dependency names. +fn detect_js_frameworks(deps: &[&str]) -> Vec { + let mut found: Vec = Vec::new(); + let candidates: &[(&str, &str)] = &[ + ("react", "React"), + ("next", "Next.js"), + ("vue", "Vue"), + ("nuxt", "Nuxt"), + ("@sveltejs/kit", "SvelteKit"), + ("svelte", "Svelte"), + ("sveltekit", "SvelteKit"), + ("astro", "Astro"), + ("express", "Express"), + ("fastify", "Fastify"), + ("hono", "Hono"), + ("vite", "Vite"), + ("webpack", "Webpack"), + ("esbuild", "esbuild"), + ("turbo", "Turbopack"), + ("tailwindcss", "Tailwind CSS"), + ]; + for dep in deps { + let lower = dep.to_lowercase(); + for (key, label) in candidates { + if lower == *key && !found.contains(&label.to_string()) { + found.push((*label).to_string()); } } } - None + found } +/// Strip userinfo (username:password or username) from a URL to avoid leaking +/// embedded credentials into the LLM prompt. +fn strip_url_credentials(url: &str) -> String { + // Handle SSH-style URLs: git@host:org/repo.git — no embedded password. + if url.contains('@') && !url.contains("://") { + return url.to_string(); + } + // HTTP(S) remotes: strip only authority userinfo. `@` in a path, query, + // or fragment is repository data, not credentials. SSH remotes such as + // `git@host:org/repo.git` and `ssh://git@host/org/repo.git` keep their + // user component because it is protocol syntax, not an embedded token. + if let Some(scheme_end) = url.find("://") { + let scheme_name = url[..scheme_end].to_ascii_lowercase(); + if scheme_name != "http" && scheme_name != "https" { + return url.to_string(); + } + let scheme = &url[..scheme_end + 3]; + let after_scheme = &url[scheme_end + 3..]; + let authority_end = after_scheme + .find(['/', '?', '#']) + .unwrap_or(after_scheme.len()); + let (authority, suffix) = after_scheme.split_at(authority_end); + if let Some(at_pos) = authority.rfind('@') { + return format!("{scheme}{}{suffix}", &authority[at_pos + 1..]); + } + } + url.to_string() +} + +/// Find the enclosing git repository root. Works for nested workspaces and +/// worktrees where `.git` is a file instead of a directory. +fn git_root(workspace: &Path) -> Option { + let direct_git_marker = workspace.join(".git"); + let discovered = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(workspace) + .output() + .ok() + .and_then(|out| { + if out.status.success() { + String::from_utf8(out.stdout) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + } else { + None + } + }); + discovered.or_else(|| direct_git_marker.exists().then(|| workspace.to_path_buf())) +} + +/// Gather git repository information via subprocess calls. +fn gather_git_info(workspace: &Path) -> Option { + let git_root = git_root(workspace)?; + + let run = |args: &[&str]| -> Option { + Command::new("git") + .args(args) + .current_dir(&git_root) + .output() + .ok() + .and_then(|out| { + if out.status.success() { + String::from_utf8(out.stdout) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + } else { + None + } + }) + }; + + let mut lines: Vec = Vec::new(); + + // Remote URL (strip embedded credentials to avoid leaking tokens to the LLM). + if let Some(url) = run(&["remote", "get-url", "origin"]) { + let sanitized = strip_url_credentials(&url); + lines.push(format!("- Remote: {sanitized}")); + } + + // Current branch. + if let Some(branch) = run(&["rev-parse", "--abbrev-ref", "HEAD"]) { + lines.push(format!("- Branch: {branch}")); + } + + // Status summary. + let status_output = Command::new("git") + .args(["status", "--porcelain=v1", "--untracked-files=no"]) + .current_dir(&git_root) + .output() + .ok(); + if let Some(out) = status_output + && out.status.success() + { + let status_str = String::from_utf8_lossy(&out.stdout); + let staged = status_str + .lines() + .filter(|l| { + let b = l.as_bytes(); + b.len() >= 2 && b[0] != b' ' && b[0] != b'?' + }) + .count(); + let unstaged = status_str + .lines() + .filter(|l| { + let b = l.as_bytes(); + b.len() >= 2 && b[1] != b' ' && b[1] != b'?' + }) + .count(); + if staged > 0 || unstaged > 0 { + let mut parts = Vec::new(); + if staged > 0 { + parts.push(format!("{staged} staged")); + } + if unstaged > 0 { + parts.push(format!("{unstaged} modified")); + } + lines.push(format!("- Working tree: {}", parts.join(", "))); + } + } + + // Recent commits. + if let Some(log) = run(&["log", "--oneline", "-5"]) { + let commits: Vec<&str> = log.lines().collect(); + if !commits.is_empty() { + lines.push("- Recent commits:".to_string()); + for c in commits { + lines.push(format!(" - {c}")); + } + } + } + + if lines.is_empty() { + None + } else { + Some(lines.join("\n")) + } +} + +/// Detect CI/CD systems configured in the project. +fn detect_ci_systems(workspace: &Path) -> Vec { + let mut found: Vec = Vec::new(); + + if workspace.join(".github").join("workflows").is_dir() + && let Ok(entries) = std::fs::read_dir(workspace.join(".github").join("workflows")) + { + let files: Vec = entries + .filter_map(|e| e.ok()) + .filter_map(|e| { + let name = e.file_name().to_string_lossy().into_owned(); + if name.ends_with(".yml") || name.ends_with(".yaml") { + Some(name) + } else { + None + } + }) + .collect(); + let mut files = files; + files.sort_unstable(); + if files.is_empty() { + found.push("GitHub Actions".to_string()); + } else { + found.push(format!("GitHub Actions ({})", files.join(", "))); + } + } + if workspace.join(".gitlab-ci.yml").exists() { + found.push("GitLab CI".to_string()); + } + if workspace.join("Jenkinsfile").exists() { + found.push("Jenkins".to_string()); + } + if workspace.join(".circleci").join("config.yml").exists() { + found.push("CircleCI".to_string()); + } + if workspace.join(".travis.yml").exists() { + found.push("Travis CI".to_string()); + } + if workspace.join("azure-pipelines.yml").exists() { + found.push("Azure Pipelines".to_string()); + } + + found +} + +/// Detect additional build systems beyond Cargo/npm. +fn detect_build_systems(workspace: &Path) -> Vec { + let mut found: Vec = Vec::new(); + + if workspace.join("Makefile").exists() { + found.push("Makefile".to_string()); + } + if workspace.join("Justfile").exists() { + found.push("Justfile".to_string()); + } + if workspace.join("CMakeLists.txt").exists() { + found.push("CMake".to_string()); + } + if workspace.join("meson.build").exists() { + found.push("Meson".to_string()); + } + if workspace.join("BUILD.bazel").exists() || workspace.join("BUILD").exists() { + found.push("Bazel".to_string()); + } + if workspace.join("scripts").is_dir() + && let Ok(entries) = std::fs::read_dir(workspace.join("scripts")) + { + let scripts: Vec = entries + .filter_map(|e| e.ok()) + .filter_map(|e| { + let name = e.file_name().to_string_lossy().into_owned(); + let path = e.path(); + if (name.ends_with(".sh") || name.ends_with(".py") || name.ends_with(".js")) + && path.is_file() + { + Some(name) + } else { + None + } + }) + .collect(); + let mut scripts = scripts; + scripts.sort_unstable(); + if !scripts.is_empty() { + found.push(format!("scripts/ ({})", scripts.join(", "))); + } + } + + found +} + +/// Detect test frameworks from project configuration. +fn detect_test_frameworks(workspace: &Path) -> Vec { + let mut found: Vec = Vec::new(); + + // Rust: check Cargo.toml dev-dependencies (both crate and workspace level). + if let Ok(raw) = std::fs::read_to_string(workspace.join("Cargo.toml")) + && let Ok(doc) = toml::from_str::(&raw) + { + let mut dep_keys: Vec<&str> = Vec::new(); + if let Some(dev_deps) = doc.get("dev-dependencies").and_then(|v| v.as_table()) { + dep_keys.extend(dev_deps.keys().map(|k| k.as_str())); + } + if let Some(ws_dev_deps) = doc + .get("workspace") + .and_then(|w| w.get("dev-dependencies")) + .and_then(|v| v.as_table()) + { + dep_keys.extend(ws_dev_deps.keys().map(|k| k.as_str())); + } + + let rust_test_frameworks: &[(&str, &str)] = &[ + ("tokio-test", "tokio-test"), + ("proptest", "proptest"), + ("quickcheck", "quickcheck"), + ("rstest", "rstest"), + ("criterion", "criterion (benchmark)"), + ("mockall", "mockall"), + ("pretty_assertions", "pretty_assertions"), + ]; + for (dep_key, label) in rust_test_frameworks { + if dep_keys.contains(dep_key) { + found.push((*label).to_string()); + } + } + } + + // Node.js: check package.json devDependencies. + if let Ok(raw) = std::fs::read_to_string(workspace.join("package.json")) + && let Ok(doc) = serde_json::from_str::(&raw) + && let Some(dev_deps) = doc.get("devDependencies").and_then(|v| v.as_object()) + { + let dev_keys: Vec<&str> = dev_deps.keys().map(|k| k.as_str()).collect(); + + let js_test_frameworks: &[(&str, &str)] = &[ + ("jest", "Jest"), + ("vitest", "Vitest"), + ("mocha", "Mocha"), + ("jasmine", "Jasmine"), + ("ava", "AVA"), + ("playwright", "Playwright"), + ("cypress", "Cypress"), + ("@testing-library/react", "Testing Library"), + ]; + for (dep_key, label) in js_test_frameworks { + if dev_keys.contains(dep_key) { + found.push((*label).to_string()); + } + } + } + + // Python: check common test config files. + if workspace.join("pytest.ini").exists() + || workspace.join("tox.ini").exists() + || workspace.join("conftest.py").exists() + || (workspace.join("pyproject.toml").exists() + && std::fs::read_to_string(workspace.join("pyproject.toml")) + .ok() + .is_some_and(|raw| raw.contains("[tool.pytest"))) + { + found.push("pytest".to_string()); + } + + found +} + +/// Read existing AGENTS.md content (up to 100KB) for in-place update. +fn read_existing_agents_md(workspace: &Path) -> Option { + let path = workspace.join("AGENTS.md"); + let meta = std::fs::metadata(&path).ok()?; + let limit = 100 * 1024; + let len = meta.len() as usize; + let content = if len > limit { + let mut f = std::fs::File::open(&path).ok()?; + let mut buf = vec![0u8; limit]; + f.read_exact(&mut buf).ok()?; + String::from_utf8_lossy(&buf).into_owned() + } else { + std::fs::read_to_string(&path).ok()? + }; + if content.trim().is_empty() { + None + } else { + Some(content) + } +} + +// --------------------------------------------------------------------------- +// Prompt builder +// --------------------------------------------------------------------------- + +/// Build the SendMessage prompt instructing the agent to analyze and generate AGENTS.md. +fn build_init_prompt( + context: &str, + existing_content: Option<&str>, + already_exists: bool, +) -> String { + let mut prompt = String::new(); + + prompt.push_str( + "You are generating a comprehensive AGENTS.md file for this project. \ + Your task is to deeply analyze the codebase and produce a customized, \ + actionable project guide that will help future AI agents work effectively here.\n\n", + ); + + prompt.push_str("## Project Context (pre-gathered)\n\n"); + prompt.push_str(context); + prompt.push('\n'); + + if let Some(existing) = existing_content { + prompt.push_str("## Existing AGENTS.md\n\n"); + prompt.push_str("Below is the current AGENTS.md content. "); + if already_exists { + prompt.push_str( + "Update it in place: preserve any custom sections that still apply, \ + replace stale or incorrect information with your fresh analysis. ", + ); + } + prompt.push_str("\n\n```markdown\n"); + prompt.push_str(existing); + prompt.push_str("\n```\n\n"); + } + + prompt.push_str("## Instructions\n\n"); + + prompt.push_str( + "1. **Read key source files** to understand the architecture:\n\ + - Start with the main entry point(s) (e.g., main.rs, index.ts, app.py)\n\ + - Read the top-level module structure to understand component boundaries\n\ + - Read a few representative files from each major module or crate\n\ + - Read config files (config.example.toml, tsconfig.json, etc.) to understand settings\n\n\ + 2. **Generate AGENTS.md** at the workspace root. Use `AGENTS.md` as the filename. \ + Include these sections as applicable:\n\n\ + ### Build / Test / Lint\n\ + - Exact commands for: build, test (all + single), lint, format, run, install deps\n\ + - Be specific — if there's a Justfile, use `just `; if nextest, use `cargo nextest run`\n\n\ + ### Architecture\n\ + - High-level description of the project's purpose\n\ + - Component or module tree with 1-2 sentence descriptions each\n\ + - Data flow through the system (if determinable)\n\n\ + ### Key Files & Directories\n\ + - What each top-level directory contains\n\ + - Important config files and what they control\n\n\ + ### Coding Conventions\n\ + - What you observe from reading source files: naming, error handling patterns, \ + module organization, test patterns\n\ + - Code generation (build.rs, protobuf, etc.) if present\n\n\ + ### Git Workflow\n\ + - Branch naming conventions (if observable from recent commits)\n\ + - Commit message style\n\n\ + ### CI/CD\n\ + - How tests run in CI, what's checked on PRs\n\n\ + ### Tips for AI Agents\n\ + - Common pitfalls in the codebase structure\n\ + - Where to look for specific kinds of things\n\ + - Any gotchas in the build setup\n\n\ + 3. **Style requirements**:\n\ + - Be concise and actionable. This is a reference document, not a tutorial.\n\ + - Use markdown headings, code blocks, and bullet lists.\n\ + - Keep the total under ~150 lines unless the project genuinely needs more.\n\ + - Write in English.\n\ + - Do NOT include placeholder HTML comments like \"\".\n\ + - If you cannot determine something with confidence, omit that section rather than guessing.\n\n\ + 4. **Write the file** using the file write tool. \ + The file should be named `AGENTS.md` at the workspace root.\n\n", + ); + + if already_exists { + prompt.push_str( + "The file already exists — update it in place, \ + preserving custom content that still applies but replacing stale information.\n\n", + ); + } + + prompt.push_str( + "5. After writing, briefly summarize what you learned and what you put into AGENTS.md.\n", + ); + + prompt +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + #[cfg(test)] mod tests { use super::*; @@ -295,110 +830,496 @@ mod tests { App::new(options, &Config::default()) } + // --- init() integration tests --- + #[test] - fn test_init_creates_agents_md() { + fn init_returns_send_message_action() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); let result = init(&mut app); assert!(result.message.is_some()); let msg = result.message.unwrap(); - assert!(msg.contains("Created AGENTS.md")); - let agents_path = tmpdir.path().join("AGENTS.md"); - assert!(agents_path.exists()); + assert!(msg.contains("Creating AGENTS.md")); + assert!( + matches!(result.action, Some(AppAction::SendMessage(_))), + "expected SendMessage action" + ); + } + + #[test] + fn init_says_updating_when_agents_md_exists() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + std::fs::write(tmpdir.path().join("AGENTS.md"), "existing content").unwrap(); + let result = init(&mut app); + assert!(result.message.unwrap().contains("Updating AGENTS.md")); + assert!(matches!(result.action, Some(AppAction::SendMessage(_)))); } #[test] - fn test_init_updates_if_exists() { + fn init_includes_gitignore_handling() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); - // Create file first with stale content - let agents_path = tmpdir.path().join("AGENTS.md"); - std::fs::write(&agents_path, "existing stale content").unwrap(); + std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap(); let result = init(&mut app); assert!(!result.is_error); - assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Updated AGENTS.md")); - let new_content = std::fs::read_to_string(&agents_path).unwrap(); - assert!(new_content.contains("# Project Instructions")); - assert!(!new_content.contains("existing stale content")); + // Should have added .deepseek/ to .gitignore. + let gi = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap(); + assert!(gi.contains(".deepseek/")); } #[test] - fn test_detect_project_type_rust() { + fn init_prompt_includes_context_for_rust_project() { let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); std::fs::write( tmpdir.path().join("Cargo.toml"), - "[package]\nname = \"test\"", + "[package]\nname = \"test-crate\"\nversion = \"0.1.0\"\n", ) .unwrap(); - let info = detect_project_type(tmpdir.path()); - assert!(info.contains("Project Type: Rust")); - assert!(info.contains("cargo build")); - assert!(info.contains("cargo test")); + let result = init(&mut app); + let Some(AppAction::SendMessage(prompt)) = result.action else { + panic!("expected SendMessage action"); + }; + assert!( + prompt.contains("test-crate"), + "prompt should mention crate name" + ); + assert!( + prompt.contains("Read key source files"), + "should have instructions" + ); + assert!( + prompt.contains("AGENTS.md"), + "should mention AGENTS.md filename" + ); } #[test] - fn test_detect_project_type_node() { + fn init_prompt_includes_existing_content() { let tmpdir = TempDir::new().unwrap(); - std::fs::write(tmpdir.path().join("package.json"), "{}").unwrap(); - let info = detect_project_type(tmpdir.path()); - assert!(info.contains("Project Type: Node.js")); - assert!(info.contains("npm install")); + let mut app = create_test_app_with_tmpdir(&tmpdir); + std::fs::write( + tmpdir.path().join("AGENTS.md"), + "# My Project\n\nCustom instructions here.", + ) + .unwrap(); + let result = init(&mut app); + let Some(AppAction::SendMessage(prompt)) = result.action else { + panic!("expected SendMessage action"); + }; + assert!(prompt.contains("Custom instructions here")); + assert!(prompt.contains("update it in place")); } + // --- parse_cargo_toml tests --- + #[test] - fn test_detect_project_type_python() { + fn parse_cargo_toml_single_crate() { let tmpdir = TempDir::new().unwrap(); - std::fs::write(tmpdir.path().join("pyproject.toml"), "[project]").unwrap(); - let info = detect_project_type(tmpdir.path()); - assert!(info.contains("Project Type: Python")); + std::fs::write( + tmpdir.path().join("Cargo.toml"), + "[package]\nname = \"my-crate\"\nversion = \"1.0.0\"\nedition = \"2021\"\n\n\ + [dependencies]\ntokio = \"1\"\nserde = \"1\"\n", + ) + .unwrap(); + let info = parse_cargo_toml(tmpdir.path()).unwrap(); + assert!(info.contains("my-crate")); + assert!(info.contains("1.0.0")); + assert!(info.contains("2021")); + assert!(info.contains("tokio")); + assert!(info.contains("serde")); } #[test] - fn test_detect_project_type_go() { + fn parse_cargo_toml_workspace() { let tmpdir = TempDir::new().unwrap(); - std::fs::write(tmpdir.path().join("go.mod"), "module test").unwrap(); - let info = detect_project_type(tmpdir.path()); - assert!(info.contains("Project Type: Go")); + std::fs::write( + tmpdir.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/cli\", \"crates/tui\"]\n\n\ + [workspace.dependencies]\nserde = \"1\"\n", + ) + .unwrap(); + let info = parse_cargo_toml(tmpdir.path()).unwrap(); + assert!(info.contains("workspace root")); + assert!(info.contains("crates/cli")); + assert!(info.contains("crates/tui")); } #[test] - fn test_detect_project_type_unknown() { + fn parse_cargo_toml_missing() { let tmpdir = TempDir::new().unwrap(); - let info = detect_project_type(tmpdir.path()); - assert!(info.contains("Project Type: Unknown")); + assert!(parse_cargo_toml(tmpdir.path()).is_none()); } #[test] - fn test_extract_cargo_name() { - let cargo = r#" -[package] -name = "my-project" -version = "1.0.0" -"#; - assert_eq!(extract_cargo_name(cargo), Some("my-project".to_string())); + fn parse_cargo_toml_invalid() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write(tmpdir.path().join("Cargo.toml"), "not valid toml {{{").unwrap(); + assert!(parse_cargo_toml(tmpdir.path()).is_none()); } + // --- parse_package_json tests --- + #[test] - fn test_extract_cargo_name_single_quotes() { - let cargo = r#"name = 'single-quoted'"#; - assert_eq!(extract_cargo_name(cargo), Some("single-quoted".to_string())); + fn parse_package_json_basic() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write( + tmpdir.path().join("package.json"), + r#"{"name":"my-app","scripts":{"build":"tsc","test":"jest"},"dependencies":{"react":"^18"},"devDependencies":{"jest":"^29"}}"#, + ) + .unwrap(); + let info = parse_package_json(tmpdir.path()).unwrap(); + assert!(info.contains("my-app")); + assert!(info.contains("build")); + assert!(info.contains("test")); + assert!(info.contains("React")); + assert!(info.contains("jest")); } #[test] - fn test_extract_cargo_name_not_found() { - let cargo = "[package]\nversion = \"1.0.0\""; - assert_eq!(extract_cargo_name(cargo), None); + fn parse_package_json_sorts_context_keys() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write( + tmpdir.path().join("package.json"), + r#"{ + "scripts":{"zeta":"node z.js","alpha":"node a.js"}, + "dependencies":{"react":"^18","axios":"^1"}, + "devDependencies":{"vitest":"^1","@sveltejs/kit":"^2"} + }"#, + ) + .unwrap(); + + let info = parse_package_json(tmpdir.path()).unwrap(); + + assert!(info.contains("- Scripts: alpha, zeta")); + assert!(info.contains("- Dependencies: axios, react")); + assert!(info.contains("- Dev dependencies: @sveltejs/kit, vitest")); } + #[test] + fn parse_package_json_detects_sveltekit_from_dev_dependencies() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write( + tmpdir.path().join("package.json"), + r#"{"devDependencies":{"@sveltejs/kit":"^2","vite":"^5"}}"#, + ) + .unwrap(); + + let info = parse_package_json(tmpdir.path()).unwrap(); + + assert!(info.contains("SvelteKit")); + assert!(info.contains("Vite")); + } + + #[test] + fn parse_package_json_missing() { + let tmpdir = TempDir::new().unwrap(); + assert!(parse_package_json(tmpdir.path()).is_none()); + } + + // --- gather_git_info tests --- + + #[test] + fn strip_url_credentials_removes_authority_userinfo() { + assert_eq!( + strip_url_credentials("https://user:token@github.com/org/repo.git"), + "https://github.com/org/repo.git" + ); + assert_eq!( + strip_url_credentials("https://token@github.com/org/repo.git"), + "https://github.com/org/repo.git" + ); + } + + #[test] + fn strip_url_credentials_preserves_non_authority_at_signs() { + assert_eq!( + strip_url_credentials("https://github.com/org/repo@feature.git"), + "https://github.com/org/repo@feature.git" + ); + assert_eq!( + strip_url_credentials("https://github.com/org/repo.git?ref=user@example.com"), + "https://github.com/org/repo.git?ref=user@example.com" + ); + assert_eq!( + strip_url_credentials("git@github.com:org/repo.git"), + "git@github.com:org/repo.git" + ); + assert_eq!( + strip_url_credentials("ssh://git@github.com/org/repo.git"), + "ssh://git@github.com/org/repo.git" + ); + } + + #[test] + fn gather_git_info_no_repo_returns_none() { + let tmpdir = TempDir::new().unwrap(); + assert!(gather_git_info(tmpdir.path()).is_none()); + } + + #[test] + fn gather_git_info_in_repo_returns_branch() { + let tmpdir = TempDir::new().unwrap(); + // Init a real git repo. + Command::new("git") + .args(["init"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["config", "core.autocrlf", "false"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["checkout", "-b", "main"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + // Create a commit so rev-parse works. + std::fs::write(tmpdir.path().join("hello.txt"), "hi").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "initial"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + + let info = gather_git_info(tmpdir.path()).unwrap(); + assert!( + info.contains("main") || info.contains("master"), + "should show branch: {info}" + ); + } + + #[test] + fn gather_git_info_works_from_nested_workspace() { + let tmpdir = TempDir::new().unwrap(); + Command::new("git") + .args(["init"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["config", "core.autocrlf", "false"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["checkout", "-b", "main"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + std::fs::write(tmpdir.path().join("hello.txt"), "hi").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "initial"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + let nested = tmpdir.path().join("nested").join("app"); + std::fs::create_dir_all(&nested).unwrap(); + + let info = gather_git_info(&nested).unwrap(); + + assert!(info.contains("Branch: main"), "git info was: {info}"); + } + + // --- detect_ci_systems tests --- + + #[test] + fn detect_ci_github_actions() { + let tmpdir = TempDir::new().unwrap(); + let wf_dir = tmpdir.path().join(".github").join("workflows"); + std::fs::create_dir_all(&wf_dir).unwrap(); + std::fs::write(wf_dir.join("ci.yml"), "").unwrap(); + let ci = detect_ci_systems(tmpdir.path()); + assert!(ci.iter().any(|s| s.contains("GitHub Actions"))); + } + + #[test] + fn detect_ci_github_actions_sorts_workflow_files() { + let tmpdir = TempDir::new().unwrap(); + let wf_dir = tmpdir.path().join(".github").join("workflows"); + std::fs::create_dir_all(&wf_dir).unwrap(); + std::fs::write(wf_dir.join("z.yml"), "").unwrap(); + std::fs::write(wf_dir.join("a.yaml"), "").unwrap(); + + let ci = detect_ci_systems(tmpdir.path()); + + assert_eq!(ci[0], "GitHub Actions (a.yaml, z.yml)"); + } + + #[test] + fn detect_ci_none() { + let tmpdir = TempDir::new().unwrap(); + assert!(detect_ci_systems(tmpdir.path()).is_empty()); + } + + // --- detect_build_systems tests --- + + #[test] + fn detect_makefile() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write(tmpdir.path().join("Makefile"), "").unwrap(); + let build = detect_build_systems(tmpdir.path()); + assert!(build.contains(&"Makefile".to_string())); + } + + #[test] + fn detect_justfile() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write(tmpdir.path().join("Justfile"), "").unwrap(); + let build = detect_build_systems(tmpdir.path()); + assert!(build.contains(&"Justfile".to_string())); + } + + #[test] + fn detect_build_systems_sorts_scripts() { + let tmpdir = TempDir::new().unwrap(); + let scripts = tmpdir.path().join("scripts"); + std::fs::create_dir_all(&scripts).unwrap(); + std::fs::write(scripts.join("z.sh"), "").unwrap(); + std::fs::write(scripts.join("a.py"), "").unwrap(); + + let build = detect_build_systems(tmpdir.path()); + + assert!(build.contains(&"scripts/ (a.py, z.sh)".to_string())); + } + + // --- detect_test_frameworks tests --- + + #[test] + fn detect_rust_test_frameworks_from_cargo() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write( + tmpdir.path().join("Cargo.toml"), + "[dev-dependencies]\ntokio-test = \"1\"\nproptest = \"1\"\n", + ) + .unwrap(); + let frameworks = detect_test_frameworks(tmpdir.path()); + assert!(frameworks.contains(&"tokio-test".to_string())); + assert!(frameworks.contains(&"proptest".to_string())); + } + + #[test] + fn detect_js_test_frameworks_from_package_json() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write( + tmpdir.path().join("package.json"), + r#"{"devDependencies":{"jest":"^29","vitest":"^1"}}"#, + ) + .unwrap(); + let frameworks = detect_test_frameworks(tmpdir.path()); + assert!(frameworks.contains(&"Jest".to_string())); + assert!(frameworks.contains(&"Vitest".to_string())); + } + + // --- read_existing_agents_md tests --- + + #[test] + fn read_existing_agents_md_present() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write(tmpdir.path().join("AGENTS.md"), "hello world").unwrap(); + let content = read_existing_agents_md(tmpdir.path()); + assert_eq!(content, Some("hello world".to_string())); + } + + #[test] + fn read_existing_agents_md_missing() { + let tmpdir = TempDir::new().unwrap(); + assert!(read_existing_agents_md(tmpdir.path()).is_none()); + } + + #[test] + fn read_existing_agents_md_empty_file_returns_none() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write(tmpdir.path().join("AGENTS.md"), "").unwrap(); + assert!(read_existing_agents_md(tmpdir.path()).is_none()); + } + + // --- build_init_prompt tests --- + + #[test] + fn build_init_prompt_contains_all_sections() { + let ctx = "## Project Summary\n\nA Rust project\n"; + let prompt = build_init_prompt(ctx, None, false); + assert!(prompt.contains("Project Context")); + assert!(prompt.contains("A Rust project")); + assert!(prompt.contains("Read key source files")); + assert!(prompt.contains("Build / Test / Lint")); + assert!(prompt.contains("Architecture")); + assert!(prompt.contains("AGENTS.md")); + } + + #[test] + fn build_init_prompt_with_existing_content() { + let ctx = "## Project Summary\n\nA Rust project\n"; + let existing = "# Old AGENTS.md content"; + let prompt = build_init_prompt(ctx, Some(existing), true); + assert!(prompt.contains("Old AGENTS.md content")); + assert!(prompt.contains("Update it in place")); + } + + #[test] + fn build_init_prompt_new_file_no_update_instruction() { + let ctx = "## Project Summary\n\nA Rust project\n"; + let prompt = build_init_prompt(ctx, None, false); + assert!(!prompt.contains("The file already exists")); + } + + // --- js framework detection --- + + #[test] + fn detect_js_frameworks_react() { + let deps = ["react", "react-dom", "vite"]; + let frameworks = detect_js_frameworks(&deps); + assert!(frameworks.contains(&"React".to_string())); + assert!(frameworks.contains(&"Vite".to_string())); + } + + #[test] + fn detect_js_frameworks_none() { + let deps = ["lodash", "axios"]; + assert!(detect_js_frameworks(&deps).is_empty()); + } + + // --- ensure_deepseek_gitignored (preserved tests) --- + #[test] fn ensure_deepseek_gitignored_creates_gitignore() { let tmpdir = TempDir::new().unwrap(); - // Simulate a git repo. std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap(); - ensure_deepseek_gitignored(tmpdir.path()); - let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap(); assert!(content.contains(".deepseek/")); // .codewhale/ is ignored at any depth, but the committed @@ -412,9 +1333,7 @@ version = "1.0.0" let tmpdir = TempDir::new().unwrap(); std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap(); std::fs::write(tmpdir.path().join(".gitignore"), "target/\n").unwrap(); - ensure_deepseek_gitignored(tmpdir.path()); - let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap(); assert!(content.contains("target/")); assert!(content.contains(".deepseek/")); @@ -424,10 +1343,8 @@ version = "1.0.0" fn ensure_deepseek_gitignored_idempotent() { let tmpdir = TempDir::new().unwrap(); std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap(); - ensure_deepseek_gitignored(tmpdir.path()); ensure_deepseek_gitignored(tmpdir.path()); - let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap(); assert_eq!(content.matches(".deepseek/").count(), 1); } @@ -435,10 +1352,7 @@ version = "1.0.0" #[test] fn ensure_deepseek_gitignored_skips_non_git_repo() { let tmpdir = TempDir::new().unwrap(); - // No .git directory — not a git repo. - ensure_deepseek_gitignored(tmpdir.path()); - assert!(!tmpdir.path().join(".gitignore").exists()); } @@ -446,16 +1360,11 @@ version = "1.0.0" fn ensure_deepseek_gitignored_handles_no_trailing_newline() { let tmpdir = TempDir::new().unwrap(); std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap(); - // Write a file that does NOT end with a newline. std::fs::write(tmpdir.path().join(".gitignore"), "target/").unwrap(); - ensure_deepseek_gitignored(tmpdir.path()); - let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap(); - // Must have both entries on separate lines. assert!(content.contains("target/")); assert!(content.contains(".deepseek/")); - // The entries should be on different lines. let lines: Vec<&str> = content.lines().collect(); assert!(lines.len() >= 2); } @@ -464,13 +1373,27 @@ version = "1.0.0" fn ensure_deepseek_gitignored_detects_variant_without_slash() { let tmpdir = TempDir::new().unwrap(); std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap(); - // Write .deepseek without trailing slash. std::fs::write(tmpdir.path().join(".gitignore"), ".deepseek\n").unwrap(); - ensure_deepseek_gitignored(tmpdir.path()); - let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap(); - // Should NOT add a duplicate entry. assert_eq!(content.matches(".deepseek").count(), 1); } + + #[test] + fn ensure_deepseek_gitignored_updates_repo_root_from_nested_workspace() { + let tmpdir = TempDir::new().unwrap(); + Command::new("git") + .args(["init"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + let nested = tmpdir.path().join("nested").join("app"); + std::fs::create_dir_all(&nested).unwrap(); + + ensure_deepseek_gitignored(&nested); + + let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap(); + assert!(content.contains(".deepseek/")); + assert!(!nested.join(".gitignore").exists()); + } } diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 9a953be62..0f8ffb536 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -12,6 +12,7 @@ mod core; mod debug; mod feedback; mod goal; +mod hf; mod hooks; mod init; mod jobs; @@ -77,7 +78,6 @@ impl CommandResult { } /// Create a result with both message and action - #[allow(dead_code)] pub fn with_message_and_action(msg: impl Into, action: AppAction) -> Self { Self { message: Some(msg.into()), @@ -224,6 +224,12 @@ pub const COMMANDS: &[CommandInfo] = &[ usage: "/feedback [bug|feature|security]", description_id: MessageId::CmdFeedbackDescription, }, + CommandInfo { + name: "hf", + aliases: &["huggingface"], + usage: "/hf [mcp |concepts]", + description_id: MessageId::CmdHfDescription, + }, CommandInfo { name: "home", aliases: &["stats", "overview", "zhuye", "shouye"], @@ -352,6 +358,12 @@ pub const COMMANDS: &[CommandInfo] = &[ usage: "/config", description_id: MessageId::CmdConfigDescription, }, + CommandInfo { + name: "sidebar", + aliases: &[], + usage: "/sidebar [on|off|auto|work|tasks|agents|context] [--save]", + description_id: MessageId::CmdSidebarDescription, + }, CommandInfo { name: "mode", aliases: &["jihua", "zidong"], @@ -495,7 +507,7 @@ pub const COMMANDS: &[CommandInfo] = &[ CommandInfo { name: "restore", aliases: &[], - usage: "/restore [N]", + usage: "/restore [N|list [N]]", description_id: MessageId::CmdRestoreDescription, }, // RLM command @@ -571,6 +583,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "agent" | "daili" => agent(app, arg), "links" | "dashboard" | "api" | "lianjie" => core::deepseek_links(app), "feedback" => feedback::feedback(app, arg), + "hf" | "huggingface" => hf::hf(app, arg), "home" | "stats" | "overview" | "zhuye" | "shouye" => core::home_dashboard(app), "workspace" | "cwd" => core::workspace_switch(app, arg), "note" => note::note(app, arg), @@ -595,6 +608,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { // Config commands "config" => config::config_command(app, arg), + "sidebar" => config::sidebar(app, arg), "settings" => config::show_settings(app), "status" => status::status(app), "statusline" => config::status_line(app), @@ -668,8 +682,8 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { _ => { // Third source: skills (lowest precedence after native and user-config). // Try to run a skill whose name matches the command. - if skills::run_skill_by_name(app, command, arg).is_some() { - return skills::run_skill_by_name(app, command, arg).unwrap(); + if let Some(result) = skills::run_skill_by_name(app, command, arg) { + return result; } let suggestions = suggest_command_names(command, 3); if suggestions.is_empty() { @@ -695,37 +709,9 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> config::set_config_value(app, key, value, persist) } -/// Persist the user's chosen footer items to `~/.deepseek/config.toml` under -/// `tui.status_items`. See [`config::persist_status_items`] for details. -pub fn persist_status_items( - items: &[crate::config::StatusItem], -) -> anyhow::Result { - config::persist_status_items(items) -} - -/// Persist a root-level string key in `config.toml`. -pub fn persist_root_string_key( - config_path: Option<&std::path::Path>, - key: &str, - value: &str, -) -> anyhow::Result { - config::persist_root_string_key(config_path, key, value) -} - pub fn switch_mode(app: &mut App, mode: crate::tui::app::AppMode) -> String { config::switch_mode(app, mode) } - -/// Auto-select a model based on request complexity. -pub fn auto_model_heuristic(input: &str, current_model: &str) -> String { - config::auto_model_heuristic(input, current_model) -} - -pub use config::{ - AutoRouteRecommendation, AutoRouteSelection, normalize_auto_route_effort, - parse_auto_route_recommendation, resolve_auto_route_with_flash, -}; - /// Execute a Recursive Language Model (RLM) turn — Algorithm 1 from /// Zhang et al. (arXiv:2512.24601). /// @@ -854,11 +840,35 @@ fn build_relay_instruction(app: &App, focus: Option<&str>) -> String { if let Ok(plan) = app.plan_state.try_lock() { let snapshot = plan.snapshot(); - if snapshot.explanation.is_some() || !snapshot.items.is_empty() { + if !snapshot.is_empty() { let _ = writeln!(out, "\nOptional strategy metadata from update_plan:"); - if let Some(explanation) = snapshot.explanation.as_deref() { - let _ = writeln!(out, "- Explanation: {explanation}"); - } + write_plan_field(&mut out, "Title", snapshot.title.as_deref()); + write_plan_field(&mut out, "Objective", snapshot.objective.as_deref()); + write_plan_field(&mut out, "Context", snapshot.context_summary.as_deref()); + write_plan_field(&mut out, "Explanation", snapshot.explanation.as_deref()); + write_plan_list(&mut out, "Source", &snapshot.sources_used); + write_plan_list(&mut out, "Critical file", &snapshot.critical_files); + write_plan_list(&mut out, "Constraint", &snapshot.constraints); + write_plan_field( + &mut out, + "Recommended approach", + snapshot.recommended_approach.as_deref(), + ); + write_plan_field( + &mut out, + "Verification plan", + snapshot.verification_plan.as_deref(), + ); + write_plan_field( + &mut out, + "Risks and unknowns", + snapshot.risks_and_unknowns.as_deref(), + ); + write_plan_field( + &mut out, + "Handoff packet", + snapshot.handoff_packet.as_deref(), + ); for item in snapshot.items { let _ = writeln!(out, "- [{}] {}", plan_status_label(&item.status), item.step); } @@ -904,6 +914,21 @@ fn build_relay_instruction(app: &App, focus: Option<&str>) -> String { out } +fn write_plan_field(out: &mut String, label: &str, value: Option<&str>) { + if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) { + let _ = writeln!(out, "- {label}: {value}"); + } +} + +fn write_plan_list(out: &mut String, label: &str, values: &[String]) { + for value in values { + let value = value.trim(); + if !value.is_empty() { + let _ = writeln!(out, "- {label}: {value}"); + } + } +} + fn plan_status_label(status: &crate::tools::plan::StepStatus) -> &'static str { match status { crate::tools::plan::StepStatus::Pending => "pending", @@ -952,45 +977,6 @@ pub fn get_command_info(name: &str) -> Option<&'static CommandInfo> { .find(|cmd| cmd.name == name || cmd.aliases.contains(&name)) } -/// Get all command names matching a prefix, including both built-in -/// static commands and user-defined commands, formatted as `/name`. -/// -/// `workspace` is used to also scan workspace-local command directories; -/// pass `None` when no workspace context is available. -#[allow(dead_code)] -pub fn all_command_names_matching( - prefix: &str, - workspace: Option<&std::path::Path>, -) -> Vec { - let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase(); - let mut result: Vec = COMMANDS - .iter() - .filter(|cmd| { - cmd.name.starts_with(&prefix) || cmd.aliases.iter().any(|a| a.starts_with(&prefix)) - }) - .map(|cmd| format!("/{}", cmd.name)) - .collect(); - - // Add user-defined commands - result.extend(user_commands::user_commands_matching(&prefix, workspace)); - - result.sort(); - result.dedup(); - result -} - -/// Get all commands matching a prefix (for autocomplete) -#[allow(dead_code)] -pub fn commands_matching(prefix: &str) -> Vec<&'static CommandInfo> { - let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase(); - COMMANDS - .iter() - .filter(|cmd| { - cmd.name.starts_with(&prefix) || cmd.aliases.iter().any(|a| a.starts_with(&prefix)) - }) - .collect() -} - fn edit_distance(a: &str, b: &str) -> usize { if a == b { return 0; @@ -1078,7 +1064,7 @@ mod tests { use crate::config::{ApiProvider, Config}; use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs}; use crate::tools::todo::TodoStatus; - use crate::tui::app::{App, AppAction, TuiOptions}; + use crate::tui::app::{App, AppAction, SidebarFocus, TuiOptions}; use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::sync::MutexGuard; @@ -1112,7 +1098,24 @@ mod tests { #[test] fn command_registry_contains_config_and_links_but_not_set_or_deepseek() { assert!(COMMANDS.iter().any(|cmd| cmd.name == "config")); + let sidebar = COMMANDS + .iter() + .find(|cmd| cmd.name == "sidebar") + .expect("sidebar command should exist"); + assert_eq!(sidebar.description_id, MessageId::CmdSidebarDescription); + assert!( + sidebar + .description_for(Locale::En) + .contains("right sidebar") + ); assert!(COMMANDS.iter().any(|cmd| cmd.name == "links")); + let hf = COMMANDS + .iter() + .find(|cmd| cmd.name == "hf") + .expect("hf command should exist"); + assert_eq!(hf.aliases, &["huggingface"]); + assert_eq!(hf.description_id, MessageId::CmdHfDescription); + assert!(hf.description_for(Locale::En).contains("Hugging Face")); assert!(COMMANDS.iter().any(|cmd| cmd.name == "memory")); assert!(!COMMANDS.iter().any(|cmd| cmd.name == "set")); assert!(!COMMANDS.iter().any(|cmd| cmd.name == "deepseek")); @@ -1127,6 +1130,17 @@ mod tests { assert_eq!(links.aliases, &["dashboard", "api", "lianjie"]); } + #[test] + fn hf_alias_dispatches_to_concepts_helper() { + let mut app = create_test_app(); + let result = execute("/huggingface concepts", &mut app); + assert!(!result.is_error); + let message = result.message.expect("concepts message"); + assert!(message.contains("Hugging Face provider route")); + assert!(message.contains("Hugging Face MCP")); + assert!(message.contains("Hub workflows")); + } + #[test] fn rlm_slash_command_routes_to_persistent_tool_instruction() { let mut app = create_test_app(); @@ -1166,11 +1180,18 @@ mod tests { { let mut plan = app.plan_state.try_lock().expect("plan lock"); plan.update(UpdatePlanArgs { + objective: Some("Keep relays grounded".to_string()), explanation: Some("RLM-style strategy".to_string()), + sources_used: vec!["transcript context".to_string()], + critical_files: vec!["crates/tui/src/commands/mod.rs".to_string()], + constraints: vec!["Do not invent verification".to_string()], + verification_plan: Some("Check relay prompt assertions".to_string()), + handoff_packet: Some("Next thread should read the Work checklist".to_string()), plan: vec![PlanItemArg { step: "keep checklist primary".to_string(), status: StepStatus::InProgress, }], + ..UpdatePlanArgs::default() }); } @@ -1197,7 +1218,13 @@ mod tests { assert!(message.contains("#1 [completed] inspect workspace")); assert!(message.contains("#2 [in_progress] patch relay command")); assert!(message.contains("Optional strategy metadata from update_plan")); + assert!(message.contains("Objective: Keep relays grounded")); assert!(message.contains("Explanation: RLM-style strategy")); + assert!(message.contains("Source: transcript context")); + assert!(message.contains("Critical file: crates/tui/src/commands/mod.rs")); + assert!(message.contains("Constraint: Do not invent verification")); + assert!(message.contains("Verification plan: Check relay prompt assertions")); + assert!(message.contains("Handoff packet: Next thread should read the Work checklist")); assert!(message.contains("[in_progress] keep checklist primary")); } @@ -1243,6 +1270,127 @@ mod tests { } } + #[test] + fn command_registry_metadata_is_complete_and_palette_safe() { + for command in COMMANDS { + assert!(!command.name.is_empty(), "command name must not be empty"); + assert_eq!( + command.name.trim(), + command.name, + "/{} command name must not need trimming", + command.name + ); + assert!( + command + .name + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit()), + "/{} command names must stay lowercase ASCII", + command.name + ); + + let expected_usage_prefix = format!("/{}", command.name); + assert!( + command.usage.starts_with(&expected_usage_prefix), + "/{} usage must start with its canonical slash command, got {:?}", + command.name, + command.usage + ); + + let description = command.description_for(Locale::En); + assert!( + !description.trim().is_empty(), + "/{} must have non-empty English help text", + command.name + ); + + let palette_command = command.palette_command(); + assert!( + palette_command.starts_with(&expected_usage_prefix), + "/{} palette command must use the canonical command, got {:?}", + command.name, + palette_command + ); + assert_eq!( + palette_command.ends_with(' '), + command.requires_argument(), + "/{} palette command spacing must match argument requirement", + command.name + ); + + for &alias in command.aliases { + assert!( + !alias.trim().is_empty(), + "/{} alias must not be empty", + command.name + ); + assert_eq!( + alias.trim(), + alias, + "/{} alias /{alias} must not need trimming", + command.name + ); + assert!( + !alias.starts_with('/'), + "/{} alias /{alias} must be stored without a slash", + command.name + ); + assert!( + !alias.chars().any(char::is_whitespace), + "/{} alias /{alias} must not contain whitespace", + command.name + ); + } + } + } + + #[test] + fn command_info_resolves_canonical_names_and_aliases() { + for command in COMMANDS { + for lookup in [command.name.to_string(), format!("/{}", command.name)] { + let resolved = get_command_info(&lookup) + .unwrap_or_else(|| panic!("{lookup:?} should resolve to /{}", command.name)); + assert_eq!(resolved.name, command.name); + } + + for &alias in command.aliases { + for lookup in [alias.to_string(), format!("/{alias}")] { + let resolved = get_command_info(&lookup).unwrap_or_else(|| { + panic!("{lookup:?} should resolve to /{}", command.name) + }); + assert_eq!(resolved.name, command.name); + } + } + } + } + + #[test] + fn every_registered_command_has_a_help_topic() { + let mut app = create_test_app(); + for command in COMMANDS { + let result = execute(&format!("/help {}", command.name), &mut app); + assert!( + !result.is_error, + "/help {} returned an error: {result:?}", + command.name + ); + let message = result + .message + .unwrap_or_else(|| panic!("/help {} should return text", command.name)); + assert!( + message.contains(command.name), + "/help {} should mention the command name, got {message:?}", + command.name + ); + assert!( + message.contains(command.usage), + "/help {} should include usage {:?}, got {message:?}", + command.name, + command.usage + ); + } + } + #[test] fn context_command_opens_inspector_and_keeps_ctx_alias() { let context = COMMANDS @@ -1303,6 +1451,68 @@ mod tests { assert!(result.message.unwrap().contains("off")); } + #[test] + fn execute_sidebar_toggles_visibility() { + let mut app = create_test_app(); + app.set_sidebar_focus(SidebarFocus::Auto); + + let result = execute("/sidebar", &mut app); + assert!(!result.is_error); + assert_eq!(app.sidebar_focus, SidebarFocus::Hidden); + assert!(app.status_message.is_none()); + assert_eq!(result.message.as_deref(), Some("Sidebar is hidden")); + + let result = execute("/sidebar", &mut app); + assert!(!result.is_error); + assert_eq!(app.sidebar_focus, SidebarFocus::Auto); + assert!(app.status_message.is_none()); + assert_eq!(result.message.as_deref(), Some("Sidebar is visible")); + } + + #[test] + fn execute_sidebar_accepts_explicit_focus_targets() { + let mut app = create_test_app(); + + let result = execute("/sidebar tasks", &mut app); + assert!(!result.is_error); + assert_eq!(app.sidebar_focus, SidebarFocus::Tasks); + assert!(app.status_message.is_none()); + + let result = execute("/sidebar off", &mut app); + assert!(!result.is_error); + assert_eq!(app.sidebar_focus, SidebarFocus::Hidden); + assert!(app.status_message.is_none()); + + let result = execute("/sidebar closed", &mut app); + assert!(!result.is_error); + assert_eq!(app.sidebar_focus, SidebarFocus::Hidden); + assert!(app.status_message.is_none()); + + let result = execute("/sidebar none", &mut app); + assert!(!result.is_error); + assert_eq!(app.sidebar_focus, SidebarFocus::Hidden); + assert!(app.status_message.is_none()); + + let result = execute("/sidebar on", &mut app); + assert!(!result.is_error); + assert_eq!(app.sidebar_focus, SidebarFocus::Auto); + assert!(app.status_message.is_none()); + } + + #[test] + fn execute_sidebar_rejects_invalid_args() { + let mut app = create_test_app(); + let result = execute("/sidebar maybe", &mut app); + assert!(result.is_error); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("Usage: /sidebar") + ); + } + #[test] fn execute_links_and_aliases_return_links_message() { let mut app = create_test_app(); @@ -1445,6 +1655,86 @@ mod tests { name == "restore" } + #[test] + fn slash_parser_preserves_arguments_after_the_command_name() { + let mut app = create_test_app(); + let result = execute("/agent 2 review this carefully", &mut app); + assert!(!result.is_error); + let Some(AppAction::SendMessage(message)) = result.action else { + panic!("expected /agent to send a model instruction"); + }; + assert!(message.contains(r#"prompt: "review this carefully""#)); + assert!(message.contains("max_depth: 2")); + + let mut app = create_test_app(); + let result = execute(" /relay ship command harness ", &mut app); + assert!(!result.is_error); + let Some(AppAction::SendMessage(message)) = result.action else { + panic!("expected /relay to send a model instruction"); + }; + assert!(message.contains("Requested relay focus: ship command harness")); + + let mut app = create_test_app(); + let result = execute("/rlm 3 inspect this corpus", &mut app); + assert!(!result.is_error); + let Some(AppAction::SendMessage(message)) = result.action else { + panic!("expected /rlm to send a model instruction"); + }; + assert!(message.contains(r#"content: "inspect this corpus""#)); + assert!(message.contains("sub_rlm_max_depth: 3")); + } + + #[test] + fn representative_command_groups_keep_dispatch_surfaces() { + let mut app = create_test_app(); + let help = execute("/help clear", &mut app) + .message + .expect("/help clear should return text"); + assert!(help.contains("clear")); + assert!(help.contains("/clear")); + + let mut app = create_test_app(); + let result = execute("/config", &mut app); + assert!(matches!(result.action, Some(AppAction::OpenConfigView))); + + let mut app = create_test_app(); + let result = execute("/relay command boundary", &mut app); + assert!(!result.is_error); + assert!(matches!( + result.action, + Some(AppAction::SendMessage(message)) + if message.contains("Requested relay focus: command boundary") + )); + + let mut app = create_test_app(); + let note_help = execute("/note help", &mut app) + .message + .expect("/note help should return text"); + assert!(note_help.contains("Usage: /note")); + + let mut app = create_test_app(); + let result = execute("/hunt ship layer 2 | budget: 100", &mut app); + assert!(!result.is_error); + assert_eq!(app.hunt.quarry.as_deref(), Some("ship layer 2")); + assert_eq!(app.hunt.token_budget, Some(100)); + + let (mut app, _tmpdir, _guard) = create_isolated_test_app(); + let skills = execute("/skills", &mut app) + .message + .expect("/skills should return text"); + assert!(skills.contains("Skills location:")); + + let mut app = create_test_app(); + let result = execute("/task list", &mut app); + assert!(matches!(result.action, Some(AppAction::TaskList))); + + let mut app = create_test_app(); + let tokens = execute("/tokens", &mut app) + .message + .expect("/tokens should return text"); + assert!(tokens.contains("deepseek-v4-pro")); + } + /// Smoke test: every entry in `COMMANDS` must dispatch to a real handler. /// A dispatch miss surfaces as the fall-through `Unknown command:` error /// message in `execute`. This catches the case where a new command is diff --git a/crates/tui/src/commands/network.rs b/crates/tui/src/commands/network.rs index dbe0e7afe..c1535e670 100644 --- a/crates/tui/src/commands/network.rs +++ b/crates/tui/src/commands/network.rs @@ -70,7 +70,7 @@ enum NetworkEdit { } fn list_policy() -> anyhow::Result { - let path = super::config::config_toml_path(None)?; + let path = crate::config_persistence::config_toml_path(None)?; let doc = load_config_doc(&path)?; let network = doc.get("network").and_then(Value::as_table); let default = network @@ -97,7 +97,7 @@ fn list_policy() -> anyhow::Result { } fn update_host(edit: NetworkEdit, host: &str) -> anyhow::Result { - let path = super::config::config_toml_path(None)?; + let path = crate::config_persistence::config_toml_path(None)?; let mut doc = load_config_doc(&path)?; let network = network_table_mut(&mut doc)?; @@ -136,7 +136,7 @@ fn update_default(value: &str) -> anyhow::Result { _ => bail!("Usage: /network default "), }; - let path = super::config::config_toml_path(None)?; + let path = crate::config_persistence::config_toml_path(None)?; let mut doc = load_config_doc(&path)?; let network = network_table_mut(&mut doc)?; network.insert("default".to_string(), Value::String(normalized.to_string())); diff --git a/crates/tui/src/commands/restore.rs b/crates/tui/src/commands/restore.rs index 8ea3540e5..d737e7594 100644 --- a/crates/tui/src/commands/restore.rs +++ b/crates/tui/src/commands/restore.rs @@ -1,19 +1,23 @@ //! `/restore` slash command — roll back the workspace to a prior snapshot. //! -//! `/restore` (no arg) lists the most recent snapshots so the user can -//! see what's available. `/restore ` restores the *N*th-most-recent -//! snapshot, where `N=1` is the newest. In non-YOLO mode we refuse to -//! mutate files unless the user has explicitly trusted the workspace -//! (`/trust on` or YOLO) — the user can always view the list, just not -//! one-shot revert without a safety net. +//! `/restore` (no arg) lists the 20 most recent snapshots so the user can +//! see what's available. `/restore list [N]` lists more snapshots, capped +//! at 100. `/restore ` restores the *N*th-most-recent snapshot, where +//! `N=1` is the newest. In non-YOLO mode we refuse to mutate files unless +//! the user has explicitly trusted the workspace (`/trust on` or YOLO) — +//! the user can always view the list, just not one-shot revert without a +//! safety net. use super::CommandResult; -use crate::snapshot::SnapshotRepo; +use crate::snapshot::{Snapshot, SnapshotRepo}; use crate::tui::app::App; +use chrono::TimeZone; -const LIST_LIMIT: usize = 10; +const DEFAULT_LIST_LIMIT: usize = 20; +const MAX_LIST_LIMIT: usize = 100; +const MAX_RESTORE_INDEX: usize = 1000; -/// Entry point for `/restore [N]`. +/// Entry point for `/restore [N|list [N]]`. pub fn restore(app: &mut App, arg: Option<&str>) -> CommandResult { let workspace = app.workspace.clone(); let repo = match SnapshotRepo::open_or_init(&workspace) { @@ -26,29 +30,51 @@ pub fn restore(app: &mut App, arg: Option<&str>) -> CommandResult { } }; - let snapshots = match repo.list(LIST_LIMIT) { - Ok(s) => s, - Err(e) => return CommandResult::error(format!("Failed to list snapshots: {e}")), - }; - - if snapshots.is_empty() { - return CommandResult::message( - "No snapshots yet. Send a message to create the first pre-turn snapshot.", - ); - } - let Some(arg) = arg.map(str::trim).filter(|s| !s.is_empty()) else { + let snapshots = match repo.list(DEFAULT_LIST_LIMIT) { + Ok(s) => s, + Err(e) => return CommandResult::error(format!("Failed to list snapshots: {e}")), + }; + if snapshots.is_empty() { + return no_snapshots_message(); + } return CommandResult::message(format_listing(&snapshots)); }; + if let Some(limit) = match parse_list_arg(arg) { + Ok(limit) => limit, + Err(message) => return CommandResult::error(message), + } { + let snapshots = match repo.list(limit) { + Ok(s) => s, + Err(e) => return CommandResult::error(format!("Failed to list snapshots: {e}")), + }; + if snapshots.is_empty() { + return no_snapshots_message(); + } + return CommandResult::message(format_listing(&snapshots)); + } + let n: usize = match arg.parse() { - Ok(n) if n >= 1 => n, + Ok(n) if (1..=MAX_RESTORE_INDEX).contains(&n) => n, + Ok(n) if n > MAX_RESTORE_INDEX => { + return CommandResult::error(format!( + "Restore index must be <= {MAX_RESTORE_INDEX}; got {n}. Use /restore list [N] to inspect snapshots first.", + )); + } _ => { return CommandResult::error(format!( - "Usage: /restore (N is 1-based; got '{arg}')", + "Usage: /restore or /restore list [N] (N is 1-based; got '{arg}')", )); } }; + let snapshots = match repo.list(n.max(DEFAULT_LIST_LIMIT)) { + Ok(s) => s, + Err(e) => return CommandResult::error(format!("Failed to list snapshots: {e}")), + }; + if snapshots.is_empty() { + return no_snapshots_message(); + } if n > snapshots.len() { return CommandResult::error(format!( @@ -81,12 +107,49 @@ pub fn restore(app: &mut App, arg: Option<&str>) -> CommandResult { )) } -fn format_listing(snapshots: &[crate::snapshot::Snapshot]) -> String { - let mut out = String::from("Recent snapshots (newest first; pass /restore to revert):\n"); +fn parse_list_arg(arg: &str) -> Result, String> { + let mut parts = arg.split_whitespace(); + let action = match parts.next() { + Some(action) => action, + None => return Ok(None), + }; + if action != "list" { + return Ok(None); + } + let Some(value) = parts.next() else { + return Ok(Some(DEFAULT_LIST_LIMIT)); + }; + if parts.next().is_some() { + return Err(format!( + "Usage: /restore list [N] (got extra arguments in '{arg}')", + )); + } + match value.parse::() { + Ok(limit @ 1..=MAX_LIST_LIMIT) => Ok(Some(limit)), + Ok(limit) if limit > MAX_LIST_LIMIT => Err(format!( + "Restore list limit must be <= {MAX_LIST_LIMIT}; got {limit}.", + )), + _ => Err(format!( + "Usage: /restore list [N] (N must be >= 1; got '{value}')", + )), + } +} + +fn no_snapshots_message() -> CommandResult { + CommandResult::message( + "No snapshots yet. Send a message to create the first pre-turn snapshot.", + ) +} + +fn format_listing(snapshots: &[Snapshot]) -> String { + let mut out = String::from( + "Recent snapshots (newest first; pass /restore to revert; /restore list 50 shows more):\n", + ); for (i, s) in snapshots.iter().enumerate() { out.push_str(&format!( - " #{:<2} {} {}\n", + " #{:<2} {} {} {}\n", i + 1, + format_snapshot_time(s.timestamp), short_sha(s.id.as_str()), s.label, )); @@ -94,6 +157,13 @@ fn format_listing(snapshots: &[crate::snapshot::Snapshot]) -> String { out } +fn format_snapshot_time(timestamp: i64) -> String { + match chrono::Utc.timestamp_opt(timestamp, 0).single() { + Some(dt) => dt.format("%Y-%m-%d %H:%M UTC").to_string(), + None => "unknown time".to_string(), + } +} + fn short_sha(sha: &str) -> &str { &sha[..sha.len().min(8)] } @@ -195,6 +265,117 @@ mod tests { assert!(msg.contains("#2")); } + #[test] + fn restore_lists_more_than_ten_snapshots_by_default() { + let tmp = TempDir::new().unwrap(); + let _home = scoped_home(&tmp); + let mut app = make_app(&tmp, true); + let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap(); + for i in 0..12 { + std::fs::write(app.workspace.join("a.txt"), format!("v{i}")).unwrap(); + repo.snapshot(&format!("turn:{i}")).unwrap(); + } + + let result = restore(&mut app, None); + let msg = result.message.expect("expected message"); + assert!(msg.contains("#12"), "{msg}"); + assert!(msg.contains("turn:0"), "{msg}"); + } + + #[test] + fn restore_listing_includes_snapshot_utc_time() { + let snapshots = [Snapshot { + id: crate::snapshot::SnapshotId("abcdef123456".to_string()), + label: "turn:demo".to_string(), + timestamp: 1_700_000_000, + }]; + + let msg = format_listing(&snapshots); + + assert!(msg.contains("2023-11-14 22:13 UTC"), "{msg}"); + assert!(msg.contains("abcdef12"), "{msg}"); + assert!(msg.contains("turn:demo"), "{msg}"); + } + + #[test] + fn restore_list_subcommand_accepts_explicit_limit() { + let tmp = TempDir::new().unwrap(); + let _home = scoped_home(&tmp); + let mut app = make_app(&tmp, true); + let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap(); + for i in 0..15 { + std::fs::write(app.workspace.join("a.txt"), format!("v{i}")).unwrap(); + repo.snapshot(&format!("turn:{i}")).unwrap(); + } + + let result = restore(&mut app, Some("list 12")); + let msg = result.message.expect("expected message"); + assert!(msg.contains("#12"), "{msg}"); + assert!(!msg.contains("#13"), "{msg}"); + } + + #[test] + fn restore_list_subcommand_rejects_invalid_limit() { + let tmp = TempDir::new().unwrap(); + let _home = scoped_home(&tmp); + let mut app = make_app(&tmp, true); + + let result = restore(&mut app, Some("list nope")); + assert!(result.is_error); + assert!(result.message.unwrap().contains("Usage: /restore list [N]")); + } + + #[test] + fn restore_list_subcommand_rejects_limit_above_cap() { + let tmp = TempDir::new().unwrap(); + let _home = scoped_home(&tmp); + let mut app = make_app(&tmp, true); + + let result = restore(&mut app, Some("list 101")); + assert!(result.is_error); + assert!( + result + .message + .unwrap() + .contains("Restore list limit must be <= 100") + ); + } + + #[test] + fn restore_numeric_index_can_target_beyond_default_listing() { + let tmp = TempDir::new().unwrap(); + let _home = scoped_home(&tmp); + let mut app = make_app(&tmp, true); + let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap(); + let f = app.workspace.join("a.txt"); + for i in 0..12 { + std::fs::write(&f, format!("v{i}")).unwrap(); + repo.snapshot(&format!("turn:{i}")).unwrap(); + } + std::fs::write(&f, "changed").unwrap(); + + let result = restore(&mut app, Some("12")); + assert!(result.message.unwrap().contains("Restored")); + assert_eq!(std::fs::read_to_string(&f).unwrap(), "v0"); + } + + #[test] + fn restore_numeric_index_rejects_unbounded_query() { + let tmp = TempDir::new().unwrap(); + let _home = scoped_home(&tmp); + let mut app = make_app(&tmp, true); + + let result = restore(&mut app, Some("1001")); + + assert!(result.is_error); + assert!( + result + .message + .unwrap() + .contains("Restore index must be <= 1000") + ); + } + #[test] fn restore_in_yolo_reverts_workspace() { let tmp = TempDir::new().unwrap(); diff --git a/crates/tui/src/commands/skills.rs b/crates/tui/src/commands/skills.rs index e852d030a..298d17451 100644 --- a/crates/tui/src/commands/skills.rs +++ b/crates/tui/src/commands/skills.rs @@ -13,10 +13,32 @@ use crate::tui::history::HistoryCell; use super::CommandResult; +#[cfg(test)] +thread_local! { + static TEST_HOME_DIR: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +#[cfg(not(test))] fn discover_visible_skills(app: &App) -> SkillRegistry { crate::skills::discover_for_workspace_and_dir(&app.workspace, &app.skills_dir) } +#[cfg(test)] +fn discover_visible_skills(app: &App) -> SkillRegistry { + TEST_HOME_DIR.with(|home| { + if let Some(home) = home.borrow().as_deref() { + crate::skills::discover_for_workspace_and_dir_with_home( + &app.workspace, + &app.skills_dir, + Some(home), + ) + } else { + crate::skills::discover_for_workspace_and_dir(&app.workspace, &app.skills_dir) + } + }) +} + fn render_skill_warnings(registry: &SkillRegistry) -> String { if registry.warnings().is_empty() { return String::new(); @@ -601,6 +623,7 @@ mod tests { _lock: std::sync::MutexGuard<'static, ()>, home_prev: Option, userprofile_prev: Option, + test_home_prev: Option, } impl IsolatedHome { @@ -616,10 +639,12 @@ mod tests { std::env::set_var("HOME", &home); std::env::set_var("USERPROFILE", &home); } + let test_home_prev = TEST_HOME_DIR.with(|slot| slot.replace(Some(home))); Self { _lock: lock, home_prev, userprofile_prev, + test_home_prev, } } @@ -634,6 +659,9 @@ mod tests { impl Drop for IsolatedHome { fn drop(&mut self) { + TEST_HOME_DIR.with(|slot| { + *slot.borrow_mut() = self.test_home_prev.take(); + }); // SAFETY: the shared test env mutex is still held while Drop runs. unsafe { Self::restore_var("HOME", self.home_prev.take()); diff --git a/crates/tui/src/commands/user_commands.rs b/crates/tui/src/commands/user_commands.rs index 207fdc8f5..eb702e42f 100644 --- a/crates/tui/src/commands/user_commands.rs +++ b/crates/tui/src/commands/user_commands.rs @@ -6,7 +6,7 @@ //! `/name`, the file contents are sent as a user message. //! //! Files may include optional YAML-like frontmatter between `---` markers. -//! Supported fields are `description`, `argument-hint`, and `allowed-tools`. +//! Supported fields are `description`, `argument-hint`, `allowed-tools`, and `pausable`. //! Frontmatter is stripped before the command body is sent to the model. //! //! ## Precedence @@ -206,6 +206,9 @@ pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option { @@ -215,6 +218,9 @@ pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option { app.active_allowed_tools = Some(parse_allowed_tools(value)); } + "pausable" => { + app.pausable = value.trim().eq_ignore_ascii_case("true"); + } _ => {} } } @@ -226,22 +232,6 @@ pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option) -> Vec { - let prefix = prefix.to_lowercase(); - load_user_commands(workspace) - .into_iter() - .filter(|(name, _)| name.starts_with(&prefix)) - .map(|(name, _)| format!("/{name}")) - .collect() -} - #[cfg(test)] mod tests { use super::*; @@ -301,12 +291,6 @@ mod tests { assert!(result.is_none()); } - #[test] - fn test_user_commands_matching_with_prefix_no_workspace() { - let matches = user_commands_matching("zzzznotfound", None); - assert!(matches.is_empty()); - } - // ── Workspace-local commands tests ───────────────────────────────── fn write_command(dir: &Path, name: &str, body: &str) { @@ -468,23 +452,6 @@ mod tests { } } - #[test] - fn user_commands_matching_with_workspace() { - let tmp = TempDir::new().unwrap(); - let ws = tmp.path(); - write_command( - &ws.join(".deepseek").join("commands"), - "project-cmd", - "body", - ); - - let matches = user_commands_matching("project", Some(ws)); - assert!( - matches.contains(&"/project-cmd".to_string()), - "got: {matches:?}" - ); - } - #[test] fn frontmatter_is_stripped_before_dispatch() { use crate::config::Config; @@ -561,6 +528,84 @@ mod tests { ); } + #[test] + fn pausable_frontmatter_sets_app_state_without_worktree_mutation() { + use crate::config::Config; + + if std::process::Command::new("git") + .arg("--version") + .output() + .is_err() + { + return; + } + + let tmp = TempDir::new().unwrap(); + let ws = tmp.path().to_path_buf(); + let init = std::process::Command::new("git") + .args(["-C", ws.to_str().unwrap(), "init"]) + .output() + .expect("git init"); + assert!( + init.status.success(), + "git init failed: {}", + String::from_utf8_lossy(&init.stderr) + ); + std::fs::write(ws.join("user-work.txt"), "untracked user work").unwrap(); + write_command( + &ws.join(".codewhale").join("commands"), + "pause-scan", + "---\ndescription: Scan repos\npausable: true\n---\nscan", + ); + + let mut app = App::new(test_options(ws.clone()), &Config::default()); + let _ = try_dispatch_user_command(&mut app, "/pause-scan").unwrap(); + + assert!(app.pausable); + assert!(!app.paused); + assert!(app.paused_quarry.is_none()); + assert!(ws.join("user-work.txt").exists()); + let stash = std::process::Command::new("git") + .args(["-C", ws.to_str().unwrap(), "stash", "list"]) + .output() + .expect("git stash list"); + assert!( + stash.status.success(), + "git stash list failed: {}", + String::from_utf8_lossy(&stash.stderr) + ); + assert!( + String::from_utf8_lossy(&stash.stdout).trim().is_empty(), + "pausable dispatch must not create git stash entries" + ); + } + + #[test] + fn new_user_command_clears_stale_paused_state() { + use crate::config::Config; + + let tmp = TempDir::new().unwrap(); + let ws = tmp.path().to_path_buf(); + let commands_dir = ws.join(".codewhale").join("commands"); + write_command( + &commands_dir, + "pause-scan", + "---\ndescription: Scan repos\npausable: true\n---\nscan", + ); + write_command(&commands_dir, "plain", "plain command"); + + let mut app = App::new(test_options(ws), &Config::default()); + let _ = try_dispatch_user_command(&mut app, "/pause-scan").unwrap(); + app.paused = true; + app.paused_quarry = Some("Scan repos".to_string()); + + let _ = try_dispatch_user_command(&mut app, "/plain").unwrap(); + + assert!(!app.pausable); + assert!(!app.paused); + assert!(app.paused_quarry.is_none()); + } + #[test] fn review_regression_empty_allowed_tools_blocks_all_tools() { use crate::config::Config; diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index 139b7b4ca..72c360cc8 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -60,6 +60,8 @@ impl Default for CompactionConfig { } pub const KEEP_RECENT_MESSAGES: usize = 4; +#[allow(dead_code)] +pub const HARD_COMPACT_KEEP_RECENT: usize = 8; const RECENT_WORKING_SET_WINDOW: usize = 12; const MAX_WORKING_SET_PATHS: usize = 24; const MIN_SUMMARIZE_MESSAGES: usize = 6; @@ -121,6 +123,29 @@ pub struct CompactionPlan { pub summarize_indices: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +pub struct HardCompactionConfig { + pub enabled: bool, + pub keep_recent: usize, +} + +impl Default for HardCompactionConfig { + fn default() -> Self { + Self { + enabled: false, + keep_recent: HARD_COMPACT_KEEP_RECENT, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +pub struct HardCompactionPlan { + pub summarize_indices: Vec, + pub preserved_indices: Vec, +} + fn path_regex() -> &'static Regex { static PATH_RE: OnceLock = OnceLock::new(); PATH_RE.get_or_init(|| { @@ -450,6 +475,32 @@ pub fn plan_compaction( } } +#[allow(dead_code)] +pub fn plan_hard_compaction( + messages: &[Message], + workspace: Option<&Path>, + keep_recent: usize, +) -> Option { + if keep_recent == 0 || messages.len() < keep_recent.saturating_add(MIN_SUMMARIZE_MESSAGES) { + return None; + } + + let soft_plan = plan_compaction(messages, workspace, keep_recent, None, None); + if soft_plan.summarize_indices.len() < MIN_SUMMARIZE_MESSAGES { + return None; + } + + let summarized: BTreeSet<_> = soft_plan.summarize_indices.iter().copied().collect(); + let preserved_indices = (0..messages.len()) + .filter(|idx| !summarized.contains(idx)) + .collect(); + + Some(HardCompactionPlan { + summarize_indices: soft_plan.summarize_indices, + preserved_indices, + }) +} + fn enforce_tool_call_pairs(messages: &[Message], pinned_indices: &mut BTreeSet) { if pinned_indices.is_empty() { return; @@ -2100,6 +2151,80 @@ mod tests { assert!(plan.pinned_indices.contains(&1)); } + #[test] + fn plan_hard_compaction_returns_none_when_too_few_messages() { + let messages = vec![ + msg("user", "hello"), + msg("assistant", "hi"), + msg("user", "how are you"), + msg("assistant", "good"), + ]; + + assert!(plan_hard_compaction(&messages, None, HARD_COMPACT_KEEP_RECENT).is_none()); + } + + #[test] + fn plan_hard_compaction_preserves_recent_tail() { + let messages: Vec = (0..20) + .map(|i| { + msg( + if i % 2 == 0 { "user" } else { "assistant" }, + &format!("message {i}"), + ) + }) + .collect(); + + let plan = + plan_hard_compaction(&messages, None, HARD_COMPACT_KEEP_RECENT).expect("hard plan"); + + let expected_recent: Vec = (20 - HARD_COMPACT_KEEP_RECENT..20).collect(); + for idx in expected_recent { + assert!(plan.preserved_indices.contains(&idx)); + assert!(!plan.summarize_indices.contains(&idx)); + } + assert_eq!(plan.summarize_indices, (0..12).collect::>()); + } + + #[test] + fn plan_hard_compaction_keeps_tool_pairs_across_tail_boundary() { + let mut messages: Vec = (0..8) + .map(|i| msg("user", &format!("summarizable noise {i}"))) + .collect(); + messages.push(Message { + role: "assistant".to_string(), + content: vec![ContentBlock::ToolUse { + id: "tail-call".to_string(), + name: "read_file".to_string(), + input: json!({"path": "crates/tui/src/compaction.rs"}), + caller: None, + }], + }); + messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "tail-call".to_string(), + content: "file contents".to_string(), + is_error: None, + content_blocks: None, + }], + }); + + let plan = plan_hard_compaction(&messages, None, 1).expect("hard plan"); + + assert!(plan.preserved_indices.contains(&8)); + assert!(plan.preserved_indices.contains(&9)); + assert!(!plan.summarize_indices.contains(&8)); + assert!(!plan.summarize_indices.contains(&9)); + } + + #[test] + fn hard_compaction_config_defaults_to_disabled() { + let config = HardCompactionConfig::default(); + + assert!(!config.enabled); + assert_eq!(config.keep_recent, HARD_COMPACT_KEEP_RECENT); + } + #[test] fn should_compact_ignores_fully_pinned_context() { let config = CompactionConfig { diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index f71400b74..be3bd8360 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -39,6 +39,13 @@ pub const DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 300; pub const MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 30; /// Maximum accepted `[subagents] heartbeat_timeout_secs` (1 hour). pub const MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 3600; +/// Default per-SSE-chunk idle timeout, in seconds. +pub const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 300; +/// Minimum accepted stream chunk timeout. +pub const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1; +/// Maximum accepted stream chunk timeout. +pub const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600; +pub(crate) const STREAM_CHUNK_TIMEOUT_ENV: &str = "DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS"; pub const DEFAULT_TEXT_MODEL: &str = "deepseek-v4-pro"; pub const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta"; pub const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro"; @@ -92,6 +99,9 @@ pub const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1"; pub const DEFAULT_XIAOMI_MIMO_MODEL: &str = "mimo-v2.5-pro"; pub const XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL: &str = "https://api.xiaomimimo.com/v1"; pub const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://token-plan-sgp.xiaomimimo.com/v1"; +pub const XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL: &str = "https://token-plan-cn.xiaomimimo.com/v1"; +pub const XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL: &str = DEFAULT_XIAOMI_MIMO_BASE_URL; +pub const XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL: &str = "https://token-plan-ams.xiaomimimo.com/v1"; pub const XIAOMI_MIMO_V2_5_OMNI_MODEL: &str = "mimo-v2.5"; pub const XIAOMI_MIMO_ASR_MODEL: &str = "mimo-v2.5-asr"; pub const XIAOMI_MIMO_TTS_MODEL: &str = "mimo-v2.5-tts"; @@ -788,9 +798,8 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL], ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL], ApiProvider::Volcengine => vec![DEFAULT_VOLCENGINE_MODEL, DEFAULT_VOLCENGINE_FLASH_MODEL], - ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama => { - OFFICIAL_DEEPSEEK_MODELS.to_vec() - } + ApiProvider::Ollama => Vec::new(), + ApiProvider::Openai | ApiProvider::Atlascloud => OFFICIAL_DEEPSEEK_MODELS.to_vec(), } } @@ -836,6 +845,9 @@ pub struct TuiConfig { /// Timeout for startup terminal mode/probe calls in milliseconds. /// Defaults to 500ms when omitted. pub terminal_probe_timeout_ms: Option, + /// Per-SSE-chunk idle timeout in seconds. Defaults to 300 seconds when + /// omitted. `0` maps to the default; values clamp to `1..=3600`. + pub stream_chunk_timeout_secs: Option, /// Ordered list of footer items the user wants visible. `None` (the field /// missing from `config.toml`) means "use the built-in default order"; an /// empty `Some(vec![])` means "show nothing in the footer". @@ -915,6 +927,8 @@ pub enum CompletionSound { Beep, /// Terminal BEL character (`\x07`). Bell, + /// Play a configured WAV sound file. + File, } /// Desktop-notification configuration (OSC 9 / BEL on turn completion). @@ -922,9 +936,9 @@ pub enum CompletionSound { pub struct NotificationsConfig { /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`. /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux - /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux - /// it falls back to BEL, and on Windows it falls back to `Off` so the - /// post-turn notification doesn't ring the system error chime (#583). + /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); otherwise it + /// falls back to BEL. On Windows the BEL path is routed through + /// `MessageBeep(MB_OK)`. /// Use `method = "osc9"` explicitly when your terminal is OSC-9 capable /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`). #[serde(default)] @@ -937,10 +951,14 @@ pub struct NotificationsConfig { #[serde(default)] pub include_summary: bool, - /// Completion sound: `"off"` | `"beep"` | `"bell"`. Default: `"beep"`. + /// Completion sound: `"off"` | `"beep"` | `"bell"` | `"file"`. Default: `"beep"`. /// Plays a sound when every turn finishes (alongside the ✅ marker). #[serde(default)] pub completion_sound: CompletionSound, + + /// Path to the WAV sound file used when `completion_sound = "file"`. + #[serde(default)] + pub sound_file: Option, } fn default_snapshots_enabled() -> bool { @@ -1053,6 +1071,11 @@ pub enum SearchProvider { alias = "volc-ark" )] Volcengine, + /// Sofya web search API (). Requires api_key + /// (`ay_live_...`). Returns full extracted page content rather than + /// snippets; falls back to the `SOFYA_API_KEY` env var when + /// `[search] api_key` is not set. + Sofya, } impl SearchProvider { @@ -1068,6 +1091,7 @@ impl SearchProvider { Some(Self::Baidu) } "volcengine" | "ark" | "volc" | "volcengine-ark" => Some(Self::Volcengine), + "sofya" => Some(Self::Sofya), _ => None, } } @@ -1082,6 +1106,7 @@ impl SearchProvider { Self::Metaso => "metaso", Self::Baidu => "baidu", Self::Volcengine => "volcengine", + Self::Sofya => "sofya", } } } @@ -1116,6 +1141,11 @@ pub struct SearchConfig { /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha` | `metaso` | `baidu` | `volcengine`. Default: `duckduckgo`. #[serde(default)] pub provider: Option, + /// Optional DuckDuckGo-compatible HTML endpoint. When set with the + /// DuckDuckGo provider, `web_search` appends the `q` query parameter to + /// this URL instead of using `https://html.duckduckgo.com/html/`. + #[serde(default)] + pub base_url: Option, /// API key for Tavily, Bocha, Metaso, Baidu, or Volcengine. Not required for Bing or DuckDuckGo. /// Metaso also falls back to `METASO_API_KEY` env var, then a built-in default. /// Baidu also falls back to `BAIDU_SEARCH_API_KEY` env var. @@ -1550,6 +1580,9 @@ pub struct Config { /// missing optional file doesn't fail the launch. pub instructions: Option>, pub allow_shell: Option, + /// Opt-in ghost-text follow-up prompt suggestion after each completed turn. + /// Default: false — the user must explicitly set this to true to enable. + pub prompt_suggestion: Option, pub approval_policy: Option, pub sandbox_mode: Option, pub yolo: Option, @@ -1626,6 +1659,11 @@ pub struct Config { #[serde(default)] pub auto: Option, + /// Optional 1-8 hotbar slot bindings (#2064). When absent, future hotbar + /// UI slices use the built-in defaults from `codewhale_config`. + #[serde(default)] + pub hotbar: Option>, + /// Startup update-check behavior. When absent, the TUI keeps the default /// fire-and-forget latest-release check. #[serde(default)] @@ -1856,7 +1894,9 @@ pub struct ProviderConfig { pub api_key: Option, pub base_url: Option, pub model: Option, + pub mode: Option, pub auth_mode: Option, + pub insecure_skip_tls_verify: Option, pub http_headers: Option>, pub path_suffix: Option, } @@ -2240,6 +2280,13 @@ impl Config { self.provider_config_for(self.api_provider()) } + #[must_use] + pub fn insecure_skip_tls_verify(&self) -> bool { + self.provider_config() + .and_then(|provider| provider.insecure_skip_tls_verify) + .unwrap_or(false) + } + #[must_use] pub fn http_headers(&self) -> HashMap { let mut headers = self.http_headers.clone().unwrap_or_default(); @@ -2378,12 +2425,16 @@ impl Config { }; let configured_base_url = provider_base.or(root_base); let base = if provider == ApiProvider::XiaomiMimo { - let env_api_key = xiaomi_mimo_env_api_key_for_base_url(); let config_api_key = self .provider_config_for(provider) .and_then(|provider| provider.api_key.as_deref()); + let mode = self + .provider_config_for(provider) + .and_then(|provider| provider.mode.as_deref()); + let env_api_key = + xiaomi_mimo_env_api_key_for_runtime(mode, configured_base_url.as_deref()); let api_key = config_api_key.or(env_api_key.as_deref()); - resolve_xiaomi_mimo_base_url(configured_base_url, api_key) + resolve_xiaomi_mimo_base_url(configured_base_url, api_key, mode) } else { configured_base_url.unwrap_or_else(|| { match provider { @@ -2495,6 +2546,17 @@ impl Config { // 2. Environment variables. Do not query platform credential stores // here; routine startup and doctor checks must stay prompt-free. + if provider == ApiProvider::XiaomiMimo { + let mode = self + .provider_config_for(provider) + .and_then(|provider| provider.mode.as_deref()); + if let Some(value) = + xiaomi_mimo_env_api_key_for_runtime(mode, Some(&self.deepseek_base_url())) + && !value.trim().is_empty() + { + return Ok(value); + } + } if let Some(value) = codewhale_secrets::env_for(slot) && !value.trim().is_empty() { @@ -2707,6 +2769,11 @@ impl Config { self.allow_shell.unwrap_or(false) } + /// Whether ghost-text prompt suggestion is enabled (opt-in, default off). + pub fn prompt_suggestion_enabled(&self) -> bool { + self.prompt_suggestion.unwrap_or(false) + } + /// Return the maximum number of concurrent sub-agents. /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`, /// then falls back to `DEFAULT_MAX_SUBAGENTS`. @@ -2774,6 +2841,30 @@ impl Config { configured.max(min_for_api) } + /// Resolved per-SSE-chunk idle timeout in seconds. + /// + /// Reads `[tui].stream_chunk_timeout_secs`, falling back to the legacy + /// `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var when the config key is + /// omitted. `None` or `0` resolve to the default 300 seconds; explicit + /// values are clamped to `1..=3600`. + #[must_use] + pub fn stream_chunk_timeout_secs(&self) -> u64 { + let raw = self + .tui + .as_ref() + .and_then(|cfg| cfg.stream_chunk_timeout_secs) + .or_else(|| { + std::env::var(STREAM_CHUNK_TIMEOUT_ENV) + .ok() + .and_then(|value| value.parse::().ok()) + }) + .unwrap_or(DEFAULT_STREAM_CHUNK_TIMEOUT_SECS); + if raw == 0 { + return DEFAULT_STREAM_CHUNK_TIMEOUT_SECS; + } + raw.clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS) + } + /// Raw sub-agent model override map. Values are validated at spawn time /// so an invalid role/type model fails before any partial agent spawn. #[must_use] @@ -2840,6 +2931,15 @@ impl Config { self.update.clone().unwrap_or_default() } + /// Resolve durable hotbar bindings for future render/dispatch layers. + #[must_use] + pub fn resolve_hotbar_bindings( + &self, + known_action_ids: &[&str], + ) -> codewhale_config::HotbarConfigResolution { + codewhale_config::resolve_hotbar_bindings(self.hotbar.as_deref(), known_action_ids) + } + /// Resolve enabled features from defaults and config entries. #[must_use] pub fn features(&self) -> Features { @@ -2936,6 +3036,20 @@ fn home_config_path() -> Option { }) } +pub(crate) fn workspace_trust_config_candidate_paths() -> Vec { + if let Some(path) = env_config_path() { + return vec![path]; + } + + let Some(home) = effective_home_dir() else { + return Vec::new(); + }; + vec![ + home.join(".codewhale").join("config.toml"), + home.join(".deepseek").join("config.toml"), + ] +} + #[must_use] pub(crate) fn is_workspace_trusted(workspace: &Path) -> bool { let Some(config_path) = default_config_path() else { @@ -3394,6 +3508,16 @@ fn apply_env_overrides(config: &mut Config) { .xiaomi_mimo .base_url = Some(value); } + if matches!(config.api_provider(), ApiProvider::XiaomiMimo) + && let Ok(value) = std::env::var("XIAOMI_MIMO_MODE").or_else(|_| std::env::var("MIMO_MODE")) + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .xiaomi_mimo + .mode = Some(value); + } if matches!(config.api_provider(), ApiProvider::WanjieArk) && let Ok(value) = std::env::var("WANJIE_ARK_BASE_URL") .or_else(|_| std::env::var("WANJIE_BASE_URL")) @@ -3461,7 +3585,8 @@ fn apply_env_overrides(config: &mut Config) { .base_url = Some(value); } if matches!(config.api_provider(), ApiProvider::Huggingface) - && let Ok(value) = std::env::var("HUGGINGFACE_BASE_URL") + && let Ok(value) = + std::env::var("HUGGINGFACE_BASE_URL").or_else(|_| std::env::var("HF_BASE_URL")) && !value.trim().is_empty() { config @@ -3674,7 +3799,7 @@ fn apply_env_overrides(config: &mut Config) { .model = Some(value); } if matches!(config.api_provider(), ApiProvider::Huggingface) - && let Ok(value) = std::env::var("HUGGINGFACE_MODEL") + && let Ok(value) = std::env::var("HUGGINGFACE_MODEL").or_else(|_| std::env::var("HF_MODEL")) && !value.trim().is_empty() { config @@ -3789,6 +3914,12 @@ fn apply_env_overrides(config: &mut Config) { .get_or_insert_with(SearchConfig::default) .api_key = Some(value); } + if let Ok(value) = codewhale_env_var("CODEWHALE_SEARCH_BASE_URL", "DEEPSEEK_SEARCH_BASE_URL") { + config + .search + .get_or_insert_with(SearchConfig::default) + .base_url = Some(value); + } if let Ok(value) = std::env::var("DEEPSEEK_REQUIREMENTS_PATH") { config.requirements_path = Some(value); } @@ -4045,26 +4176,127 @@ fn default_base_url_for_provider(provider: ApiProvider) -> &'static str { } } -fn resolve_xiaomi_mimo_base_url(configured: Option, api_key: Option<&str>) -> String { +fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> { + let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-"); + if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) { + return None; + } + Some(match normalized.as_str() { + "token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => { + DEFAULT_XIAOMI_MIMO_BASE_URL + } + "token-plan-cn" + | "token-plan-china" + | "token-plan-mainland" + | "token-plan-mainland-china" + | "cn" + | "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL, + "token-plan-sgp" + | "token-plan-sg" + | "token-plan-singapore" + | "sgp" + | "sg" + | "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL, + "token-plan-ams" + | "token-plan-eu" + | "token-plan-europe" + | "token-plan-amsterdam" + | "ams" + | "eu" + | "europe" + | "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL, + _ => DEFAULT_XIAOMI_MIMO_BASE_URL, + }) +} + +fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool { + matches!( + normalized_mode, + "standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go" + ) +} + +fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool { + let normalized = normalize_base_url(base_url).to_ascii_lowercase(); + normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL + || normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL + || normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL +} + +fn xiaomi_mimo_env_var(candidates: &[&str]) -> Option { + candidates.iter().find_map(|name| { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + }) +} + +fn xiaomi_mimo_env_api_key_for_runtime( + mode: Option<&str>, + base_url: Option<&str>, +) -> Option { + const TOKEN_PLAN_ENV_VARS: &[&str] = + &["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"]; + const STANDARD_ENV_VARS: &[&str] = &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"]; + + let normalized_mode = + mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-")); + let standard_selected = normalized_mode + .as_deref() + .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint) + || base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go); + if standard_selected { + return xiaomi_mimo_env_var(STANDARD_ENV_VARS); + } + + let token_plan_selected = normalized_mode + .as_deref() + .and_then(xiaomi_mimo_base_url_for_mode) + .is_some() + || base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan); + if token_plan_selected { + return xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS); + } + + xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS)) +} + +fn resolve_xiaomi_mimo_base_url( + configured: Option, + api_key: Option<&str>, + mode: Option<&str>, +) -> String { + let normalized_mode = + mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-")); + let uses_standard_mode = normalized_mode + .as_deref() + .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint); + let mode_base_url = normalized_mode + .as_deref() + .and_then(xiaomi_mimo_base_url_for_mode); let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key); match configured { + Some(base_url) if uses_standard_mode => base_url, Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => { - DEFAULT_XIAOMI_MIMO_BASE_URL.to_string() + mode_base_url + .unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL) + .to_string() } Some(base_url) => base_url, - None if uses_token_plan || api_key.is_none() => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(), - None => XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string(), + None => { + if let Some(base_url) = mode_base_url { + base_url.to_string() + } else if uses_standard_mode { + XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string() + } else if uses_token_plan || api_key.is_none() { + DEFAULT_XIAOMI_MIMO_BASE_URL.to_string() + } else { + XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string() + } + } } } -fn xiaomi_mimo_env_api_key_for_base_url() -> Option { - std::env::var("XIAOMI_MIMO_API_KEY") - .or_else(|_| std::env::var("XIAOMI_API_KEY")) - .or_else(|_| std::env::var("MIMO_API_KEY")) - .ok() - .filter(|key| !key.trim().is_empty()) -} - fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool { api_key.is_some_and(|key| key.trim_start().starts_with("tp-")) } @@ -4082,6 +4314,12 @@ fn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> boo { return false; } + if provider == ApiProvider::XiaomiMimo + && (xiaomi_mimo_base_url_uses_token_plan(base_url) + || xiaomi_mimo_base_url_is_pay_as_you_go(base_url)) + { + return false; + } normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider)) } @@ -4253,6 +4491,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { // both — they list `~/global.md` inside the project array. instructions: override_cfg.instructions.or(base.instructions), allow_shell: override_cfg.allow_shell.or(base.allow_shell), + prompt_suggestion: override_cfg.prompt_suggestion.or(base.prompt_suggestion), yolo: override_cfg.yolo.or(base.yolo), approval_policy: override_cfg.approval_policy.or(base.approval_policy), sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode), @@ -4279,6 +4518,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { memory: override_cfg.memory.or(base.memory), speech: override_cfg.speech.or(base.speech), auto: override_cfg.auto.or(base.auto), + hotbar: override_cfg.hotbar.or(base.hotbar), update: override_cfg.update.or(base.update), lsp: override_cfg.lsp.or(base.lsp), context: ContextConfig { @@ -4317,7 +4557,11 @@ fn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> api_key: override_cfg.api_key.or(base.api_key), base_url: override_cfg.base_url.or(base.base_url), model: override_cfg.model.or(base.model), + mode: override_cfg.mode.or(base.mode), auth_mode: override_cfg.auth_mode.or(base.auth_mode), + insecure_skip_tls_verify: override_cfg + .insecure_skip_tls_verify + .or(base.insecure_skip_tls_verify), http_headers: override_cfg.http_headers.or(base.http_headers), path_suffix: override_cfg.path_suffix.or(base.path_suffix), } @@ -5188,7 +5432,7 @@ fn refresh_kimi_oauth_token(refresh_token: &str) -> Result .or_else(|_| std::env::var("KIMI_OAUTH_HOST")) .unwrap_or_else(|_| "https://auth.kimi.com".to_string()); let url = format!("{}/api/oauth/token", oauth_host.trim_end_matches('/')); - let client = reqwest::blocking::Client::builder() + let client = crate::tls::reqwest_blocking_client_builder() .timeout(Duration::from_secs(15)) .build() .context("Failed to build Kimi OAuth refresh client")?; @@ -5406,6 +5650,28 @@ mod tests { ); } + #[test] + fn prompt_suggestion_defaults_to_false() { + let config = Config::default(); + assert_eq!( + config.prompt_suggestion, None, + "default Config must not opt in" + ); + assert!( + !config.prompt_suggestion_enabled(), + "prompt_suggestion must be opt-in (default off)" + ); + } + + #[test] + fn prompt_suggestion_enabled_when_set_true() { + let config = Config { + prompt_suggestion: Some(true), + ..Default::default() + }; + assert!(config.prompt_suggestion_enabled()); + } + #[test] fn warns_when_allow_shell_nested_under_general_section() { // #2589: the reporter's config nested top-level keys under sections that @@ -5427,6 +5693,39 @@ mod tests { assert!(parsed.base.allow_shell()); } + #[test] + fn tui_config_parses_hotbar_bindings() { + let raw = r#" +[[hotbar]] +slot = 1 +label = "Plan" +action = "mode.plan" + +[[hotbar]] +slot = 2 +action = "session.compact" +"#; + let parsed: ConfigFile = toml::from_str(raw).expect("parse hotbar config"); + + let resolved = parsed + .base + .resolve_hotbar_bindings(&["mode.plan", "session.compact"]); + + assert_eq!(resolved.warnings, Vec::new()); + assert_eq!( + resolved + .bindings + .iter() + .map(|binding| ( + binding.slot, + binding.action.as_str(), + binding.label.as_deref() + )) + .collect::>(), + vec![(1, "mode.plan", Some("Plan")), (2, "session.compact", None),] + ); + } + #[test] fn update_config_defaults_to_enabled_without_uri() { let config = Config::default(); @@ -5510,6 +5809,25 @@ mod tests { ); } + #[test] + fn search_config_preserves_custom_base_url() { + let config: Config = toml::from_str( + r#" + [search] + provider = "duckduckgo" + base_url = "https://search.internal.example/html/" + "#, + ) + .expect("search config"); + + let search = config.search.expect("search table"); + assert_eq!(search.provider, Some(SearchProvider::DuckDuckGo)); + assert_eq!( + search.base_url.as_deref(), + Some("https://search.internal.example/html/") + ); + } + #[test] fn explicit_baidu_search_provider_is_preserved() { let config: Config = toml::from_str( @@ -5564,6 +5882,29 @@ mod tests { ); } + #[test] + fn explicit_sofya_search_provider_is_preserved() { + let config: Config = toml::from_str( + r#" + [search] + provider = "sofya" + "#, + ) + .expect("sofya search config"); + + assert_eq!( + config.search.and_then(|search| search.provider), + Some(SearchProvider::Sofya) + ); + } + + #[test] + fn sofya_search_provider_parses_and_round_trips() { + assert_eq!(SearchProvider::parse("sofya"), Some(SearchProvider::Sofya)); + assert_eq!(SearchProvider::parse("Sofya"), Some(SearchProvider::Sofya)); + assert_eq!(SearchProvider::Sofya.as_str(), "sofya"); + } + #[test] fn search_provider_resolution_reports_default_source() { let _guard = lock_test_env(); @@ -5653,6 +5994,61 @@ mod tests { ); } + #[test] + fn apply_env_overrides_sets_search_base_url() { + let _guard = lock_test_env(); + let prev_codewhale = env::var_os("CODEWHALE_SEARCH_BASE_URL"); + let prev_deepseek = env::var_os("DEEPSEEK_SEARCH_BASE_URL"); + unsafe { + env::remove_var("CODEWHALE_SEARCH_BASE_URL"); + env::set_var( + "DEEPSEEK_SEARCH_BASE_URL", + "https://search.internal.example/html/", + ) + }; + let mut config = Config::default(); + + apply_env_overrides(&mut config); + + unsafe { + EnvGuard::restore_var("CODEWHALE_SEARCH_BASE_URL", prev_codewhale); + EnvGuard::restore_var("DEEPSEEK_SEARCH_BASE_URL", prev_deepseek); + } + assert_eq!( + config.search.and_then(|search| search.base_url), + Some("https://search.internal.example/html/".to_string()) + ); + } + + #[test] + fn codewhale_search_base_url_env_wins_over_legacy_alias() { + let _guard = lock_test_env(); + let prev_codewhale = env::var_os("CODEWHALE_SEARCH_BASE_URL"); + let prev_deepseek = env::var_os("DEEPSEEK_SEARCH_BASE_URL"); + unsafe { + env::set_var( + "CODEWHALE_SEARCH_BASE_URL", + "https://codewhale-search.example/html/", + ); + env::set_var( + "DEEPSEEK_SEARCH_BASE_URL", + "https://legacy-search.example/html/", + ); + } + let mut config = Config::default(); + + apply_env_overrides(&mut config); + + unsafe { + EnvGuard::restore_var("CODEWHALE_SEARCH_BASE_URL", prev_codewhale); + EnvGuard::restore_var("DEEPSEEK_SEARCH_BASE_URL", prev_deepseek); + } + assert_eq!( + config.search.and_then(|search| search.base_url), + Some("https://codewhale-search.example/html/".to_string()) + ); + } + #[test] fn search_provider_resolution_ignores_invalid_env_override() { let _guard = lock_test_env(); @@ -5722,6 +6118,8 @@ mod tests { ark_base_url: Option, volcengine_model: Option, volcengine_ark_model: Option, + xiaomi_mimo_token_plan_api_key: Option, + mimo_token_plan_api_key: Option, xiaomi_mimo_api_key: Option, xiaomi_api_key: Option, mimo_api_key: Option, @@ -5729,6 +6127,8 @@ mod tests { mimo_base_url: Option, xiaomi_mimo_model: Option, mimo_model: Option, + xiaomi_mimo_mode: Option, + mimo_mode: Option, novita_api_key: Option, novita_base_url: Option, novita_model: Option, @@ -5763,7 +6163,9 @@ mod tests { huggingface_api_key: Option, huggingface_token: Option, huggingface_base_url: Option, + hf_base_url: Option, huggingface_model: Option, + hf_model: Option, } impl EnvGuard { @@ -5819,6 +6221,8 @@ mod tests { let ark_base_url_prev = env::var_os("ARK_BASE_URL"); let volcengine_model_prev = env::var_os("VOLCENGINE_MODEL"); let volcengine_ark_model_prev = env::var_os("VOLCENGINE_ARK_MODEL"); + let xiaomi_mimo_token_plan_api_key_prev = env::var_os("XIAOMI_MIMO_TOKEN_PLAN_API_KEY"); + let mimo_token_plan_api_key_prev = env::var_os("MIMO_TOKEN_PLAN_API_KEY"); let xiaomi_mimo_api_key_prev = env::var_os("XIAOMI_MIMO_API_KEY"); let xiaomi_api_key_prev = env::var_os("XIAOMI_API_KEY"); let mimo_api_key_prev = env::var_os("MIMO_API_KEY"); @@ -5826,6 +6230,8 @@ mod tests { let mimo_base_url_prev = env::var_os("MIMO_BASE_URL"); let xiaomi_mimo_model_prev = env::var_os("XIAOMI_MIMO_MODEL"); let mimo_model_prev = env::var_os("MIMO_MODEL"); + let xiaomi_mimo_mode_prev = env::var_os("XIAOMI_MIMO_MODE"); + let mimo_mode_prev = env::var_os("MIMO_MODE"); let novita_api_key_prev = env::var_os("NOVITA_API_KEY"); let novita_base_url_prev = env::var_os("NOVITA_BASE_URL"); let novita_model_prev = env::var_os("NOVITA_MODEL"); @@ -5860,7 +6266,9 @@ mod tests { let huggingface_api_key_prev = env::var_os("HUGGINGFACE_API_KEY"); let huggingface_token_prev = env::var_os("HF_TOKEN"); let huggingface_base_url_prev = env::var_os("HUGGINGFACE_BASE_URL"); + let hf_base_url_prev = env::var_os("HF_BASE_URL"); let huggingface_model_prev = env::var_os("HUGGINGFACE_MODEL"); + let hf_model_prev = env::var_os("HF_MODEL"); // Safety: test-only environment mutation guarded by a global mutex. unsafe { env::set_var("HOME", &home_str); @@ -5911,6 +6319,8 @@ mod tests { env::remove_var("ARK_BASE_URL"); env::remove_var("VOLCENGINE_MODEL"); env::remove_var("VOLCENGINE_ARK_MODEL"); + env::remove_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY"); + env::remove_var("MIMO_TOKEN_PLAN_API_KEY"); env::remove_var("XIAOMI_MIMO_API_KEY"); env::remove_var("XIAOMI_API_KEY"); env::remove_var("MIMO_API_KEY"); @@ -5918,6 +6328,8 @@ mod tests { env::remove_var("MIMO_BASE_URL"); env::remove_var("XIAOMI_MIMO_MODEL"); env::remove_var("MIMO_MODEL"); + env::remove_var("XIAOMI_MIMO_MODE"); + env::remove_var("MIMO_MODE"); env::remove_var("NOVITA_API_KEY"); env::remove_var("NOVITA_BASE_URL"); env::remove_var("NOVITA_MODEL"); @@ -5952,7 +6364,9 @@ mod tests { env::remove_var("HUGGINGFACE_API_KEY"); env::remove_var("HF_TOKEN"); env::remove_var("HUGGINGFACE_BASE_URL"); + env::remove_var("HF_BASE_URL"); env::remove_var("HUGGINGFACE_MODEL"); + env::remove_var("HF_MODEL"); } Self { home: home_prev, @@ -6003,6 +6417,8 @@ mod tests { ark_base_url: ark_base_url_prev, volcengine_model: volcengine_model_prev, volcengine_ark_model: volcengine_ark_model_prev, + xiaomi_mimo_token_plan_api_key: xiaomi_mimo_token_plan_api_key_prev, + mimo_token_plan_api_key: mimo_token_plan_api_key_prev, xiaomi_mimo_api_key: xiaomi_mimo_api_key_prev, xiaomi_api_key: xiaomi_api_key_prev, mimo_api_key: mimo_api_key_prev, @@ -6010,6 +6426,8 @@ mod tests { mimo_base_url: mimo_base_url_prev, xiaomi_mimo_model: xiaomi_mimo_model_prev, mimo_model: mimo_model_prev, + xiaomi_mimo_mode: xiaomi_mimo_mode_prev, + mimo_mode: mimo_mode_prev, novita_api_key: novita_api_key_prev, novita_base_url: novita_base_url_prev, novita_model: novita_model_prev, @@ -6044,7 +6462,9 @@ mod tests { huggingface_api_key: huggingface_api_key_prev, huggingface_token: huggingface_token_prev, huggingface_base_url: huggingface_base_url_prev, + hf_base_url: hf_base_url_prev, huggingface_model: huggingface_model_prev, + hf_model: hf_model_prev, } } } @@ -6113,6 +6533,14 @@ mod tests { Self::restore_var("ARK_BASE_URL", self.ark_base_url.take()); Self::restore_var("VOLCENGINE_MODEL", self.volcengine_model.take()); Self::restore_var("VOLCENGINE_ARK_MODEL", self.volcengine_ark_model.take()); + Self::restore_var( + "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", + self.xiaomi_mimo_token_plan_api_key.take(), + ); + Self::restore_var( + "MIMO_TOKEN_PLAN_API_KEY", + self.mimo_token_plan_api_key.take(), + ); Self::restore_var("XIAOMI_MIMO_API_KEY", self.xiaomi_mimo_api_key.take()); Self::restore_var("XIAOMI_API_KEY", self.xiaomi_api_key.take()); Self::restore_var("MIMO_API_KEY", self.mimo_api_key.take()); @@ -6120,6 +6548,8 @@ mod tests { 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("XIAOMI_MIMO_MODE", self.xiaomi_mimo_mode.take()); + Self::restore_var("MIMO_MODE", self.mimo_mode.take()); Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take()); Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take()); Self::restore_var("NOVITA_MODEL", self.novita_model.take()); @@ -6154,7 +6584,9 @@ mod tests { Self::restore_var("HUGGINGFACE_API_KEY", self.huggingface_api_key.take()); Self::restore_var("HF_TOKEN", self.huggingface_token.take()); Self::restore_var("HUGGINGFACE_BASE_URL", self.huggingface_base_url.take()); + Self::restore_var("HF_BASE_URL", self.hf_base_url.take()); Self::restore_var("HUGGINGFACE_MODEL", self.huggingface_model.take()); + Self::restore_var("HF_MODEL", self.hf_model.take()); } } } @@ -6313,6 +6745,76 @@ mod tests { ); } + #[test] + fn tui_stream_chunk_timeout_defaults_env_and_clamps() { + let _lock = lock_test_env(); + let previous = env::var_os(STREAM_CHUNK_TIMEOUT_ENV); + unsafe { + env::remove_var(STREAM_CHUNK_TIMEOUT_ENV); + } + + assert_eq!( + Config::default().stream_chunk_timeout_secs(), + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + ); + + let zero = Config { + tui: Some(TuiConfig { + stream_chunk_timeout_secs: Some(0), + ..TuiConfig::default() + }), + ..Config::default() + }; + assert_eq!( + zero.stream_chunk_timeout_secs(), + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + ); + + let explicit_min = Config { + tui: Some(TuiConfig { + stream_chunk_timeout_secs: Some(MIN_STREAM_CHUNK_TIMEOUT_SECS), + ..TuiConfig::default() + }), + ..Config::default() + }; + assert_eq!( + explicit_min.stream_chunk_timeout_secs(), + MIN_STREAM_CHUNK_TIMEOUT_SECS + ); + + let high = Config { + tui: Some(TuiConfig { + stream_chunk_timeout_secs: Some(MAX_STREAM_CHUNK_TIMEOUT_SECS + 1), + ..TuiConfig::default() + }), + ..Config::default() + }; + assert_eq!( + high.stream_chunk_timeout_secs(), + MAX_STREAM_CHUNK_TIMEOUT_SECS + ); + + unsafe { + env::set_var(STREAM_CHUNK_TIMEOUT_ENV, "123"); + } + assert_eq!(Config::default().stream_chunk_timeout_secs(), 123); + + unsafe { + env::set_var(STREAM_CHUNK_TIMEOUT_ENV, "0"); + } + assert_eq!( + Config::default().stream_chunk_timeout_secs(), + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + ); + + unsafe { + match previous { + Some(value) => env::set_var(STREAM_CHUNK_TIMEOUT_ENV, value), + None => env::remove_var(STREAM_CHUNK_TIMEOUT_ENV), + } + } + } + #[test] fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> { // `save_api_key` writes to the shared user config file. This @@ -7394,6 +7896,13 @@ api_key = "old-openrouter-key" ); } + #[test] + fn model_completion_names_for_ollama_do_not_promote_static_remote_models() { + let models = model_completion_names_for_provider(ApiProvider::Ollama); + + assert!(models.is_empty()); + } + #[test] fn model_completion_names_for_openrouter_include_recent_large_models() { let models = model_completion_names_for_provider(ApiProvider::Openrouter); @@ -7848,6 +8357,34 @@ http_headers = { "X-Model-Provider-Id" = "from-file" } Ok(()) } + #[test] + fn insecure_skip_tls_verify_is_scoped_to_active_provider() { + let mut providers = ProvidersConfig::default(); + providers.deepseek.insecure_skip_tls_verify = Some(true); + providers.openai.insecure_skip_tls_verify = Some(false); + let config = Config { + provider: Some("openai".to_string()), + providers: Some(providers), + ..Default::default() + }; + + assert_eq!(config.api_provider(), ApiProvider::Openai); + assert!(!config.insecure_skip_tls_verify()); + } + + #[test] + fn insecure_skip_tls_verify_reads_active_provider_table() { + let mut providers = ProvidersConfig::default(); + providers.openai.insecure_skip_tls_verify = Some(true); + let config = Config { + provider: Some("openai".to_string()), + providers: Some(providers), + ..Default::default() + }; + + assert!(config.insecure_skip_tls_verify()); + } + #[test] fn xiaomi_mimo_provider_uses_documented_defaults() -> Result<()> { let _lock = lock_test_env(); @@ -7934,6 +8471,43 @@ model = "mimo-v2.5-pro" Ok(()) } + #[test] + fn xiaomi_mimo_token_plan_mode_accepts_region_aliases() -> Result<()> { + let config: Config = toml::from_str( + r#" +provider = "mimo" + +[providers.mimo] +mode = "token-plan-ams" +"#, + )?; + + config.validate()?; + assert_eq!(config.api_provider(), ApiProvider::XiaomiMimo); + assert_eq!( + config.deepseek_base_url(), + XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL + ); + Ok(()) + } + + #[test] + fn xiaomi_mimo_unknown_mode_stays_on_token_plan_endpoint() -> Result<()> { + let config: Config = toml::from_str( + r#" +provider = "mimo" + +[providers.mimo] +mode = "token-plan-usa" +"#, + )?; + + config.validate()?; + assert_eq!(config.api_provider(), ApiProvider::XiaomiMimo); + assert_eq!(config.deepseek_base_url(), DEFAULT_XIAOMI_MIMO_BASE_URL); + Ok(()) + } + #[test] fn xiaomi_mimo_env_overrides_provider_base_url_model_and_key() -> Result<()> { let _lock = lock_test_env(); @@ -7968,6 +8542,74 @@ model = "mimo-v2.5-pro" Ok(()) } + #[test] + fn xiaomi_mimo_env_token_plan_mode_uses_token_plan_key_and_endpoint() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-xiaomi-mimo-token-plan-env-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo"); + env::set_var("XIAOMI_MIMO_MODE", "token-plan-cn"); + env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key"); + env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key"); + env::set_var("XIAOMI_MIMO_MODEL", "voiceclone"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::XiaomiMimo); + assert_eq!(config.deepseek_api_key()?, "tp-env-key"); + assert_eq!( + config.deepseek_base_url(), + XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL + ); + assert_eq!(config.default_model(), "voiceclone"); + Ok(()) + } + + #[test] + fn xiaomi_mimo_env_pay_as_you_go_mode_prefers_standard_key() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-xiaomi-mimo-payg-env-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo"); + env::set_var("XIAOMI_MIMO_MODE", "pay-as-you-go"); + env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key"); + env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::XiaomiMimo); + assert_eq!(config.deepseek_api_key()?, "sk-env-key"); + assert_eq!( + config.deepseek_base_url(), + XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL + ); + Ok(()) + } + #[test] fn atlascloud_provider_uses_documented_defaults() -> Result<()> { let config = Config { @@ -10104,21 +10746,63 @@ model = "deepseek-ai/deepseek-v4-pro" std::process::id(), nanos )); - fs::create_dir_all(&temp_root)?; - let _guard = EnvGuard::new(&temp_root); - unsafe { - env::set_var("CODEWHALE_PROVIDER", "huggingface"); - env::set_var("HUGGINGFACE_API_KEY", "hf-env-key"); - env::set_var("HUGGINGFACE_BASE_URL", "https://custom-hf.example/v1"); - env::set_var("HUGGINGFACE_MODEL", "meta-llama/Llama-3-70B"); + { + let long_form_root = temp_root.join("long-form"); + fs::create_dir_all(&long_form_root)?; + let _guard = EnvGuard::new(&long_form_root); + + unsafe { + env::set_var("CODEWHALE_PROVIDER", "huggingface"); + env::set_var("HUGGINGFACE_API_KEY", "hf-env-key"); + env::set_var("HUGGINGFACE_BASE_URL", "https://custom-hf.example/v1"); + env::set_var("HUGGINGFACE_MODEL", "meta-llama/Llama-3-70B"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Huggingface); + assert_eq!(config.deepseek_api_key()?, "hf-env-key"); + assert_eq!(config.deepseek_base_url(), "https://custom-hf.example/v1"); + assert_eq!(config.default_model(), "meta-llama/Llama-3-70B"); } - let config = Config::load(None, None)?; - assert_eq!(config.api_provider(), ApiProvider::Huggingface); - assert_eq!(config.deepseek_api_key()?, "hf-env-key"); - assert_eq!(config.deepseek_base_url(), "https://custom-hf.example/v1"); - assert_eq!(config.default_model(), "meta-llama/Llama-3-70B"); + { + let short_form_root = temp_root.join("short-form"); + fs::create_dir_all(&short_form_root)?; + let _guard = EnvGuard::new(&short_form_root); + + unsafe { + env::set_var("CODEWHALE_PROVIDER", "huggingface"); + env::set_var("HF_TOKEN", "hf-env-key"); + env::set_var("HF_BASE_URL", "https://custom-hf.example/v1"); + env::set_var("HF_MODEL", "meta-llama/Llama-3-70B"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Huggingface); + assert_eq!(config.deepseek_api_key()?, "hf-env-key"); + assert_eq!(config.deepseek_base_url(), "https://custom-hf.example/v1"); + assert_eq!(config.default_model(), "meta-llama/Llama-3-70B"); + } Ok(()) } + + #[test] + fn notifications_parse_custom_completion_sound_file() { + let config: Config = toml::from_str( + r#" + [notifications] + completion_sound = "file" + sound_file = "E:\\google\\downloads\\xm4114.wav" + "#, + ) + .expect("custom completion sound config should parse"); + + let notifications = config.notifications_config(); + assert_eq!(notifications.completion_sound, CompletionSound::File); + assert_eq!( + notifications.sound_file.as_deref(), + Some(std::path::Path::new("E:\\google\\downloads\\xm4114.wav")) + ); + } } diff --git a/crates/tui/src/config_persistence.rs b/crates/tui/src/config_persistence.rs new file mode 100644 index 000000000..aa79793c0 --- /dev/null +++ b/crates/tui/src/config_persistence.rs @@ -0,0 +1,461 @@ +//! Config file path resolution and TOML persistence helpers. +//! +//! These helpers are used by command handlers and non-command UI code, so +//! persistence lives outside the command tree. + +use std::path::{Path, PathBuf}; + +use crate::config::{ApiProvider, StatusItem, effective_home_dir, expand_path}; + +pub(crate) fn persist_status_items(items: &[StatusItem]) -> anyhow::Result { + use anyhow::Context; + use std::fs; + + 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()))?; + } + + let mut doc: toml::Value = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + + let table = doc + .as_table_mut() + .context("config.toml root must be a table")?; + let tui_entry = table + .entry("tui".to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())); + let tui_table = tui_entry + .as_table_mut() + .context("`tui` section in config.toml must be a table")?; + let array = items + .iter() + .map(|item| toml::Value::String(item.key().to_string())) + .collect::>(); + tui_table.insert("status_items".to_string(), toml::Value::Array(array)); + + let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; + fs::write(&path, body) + .with_context(|| format!("failed to write config at {}", path.display()))?; + Ok(path) +} + +pub(crate) 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(config_path)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create config directory {}", parent.display()))?; + } + + let mut doc: toml::Value = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + let table = doc + .as_table_mut() + .context("config.toml root must be a table")?; + table.insert(key.to_string(), toml::Value::String(value.to_string())); + let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; + fs::write(&path, body) + .with_context(|| format!("failed to write config at {}", path.display()))?; + Ok(path) +} + +pub(crate) fn persist_root_bool_key( + config_path: Option<&Path>, + key: &str, + value: bool, +) -> anyhow::Result { + use anyhow::Context; + use std::fs; + + 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()))?; + } + + let mut doc: toml::Value = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + let table = doc + .as_table_mut() + .context("config.toml root must be a table")?; + table.insert(key.to_string(), toml::Value::Boolean(value)); + let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; + fs::write(&path, body) + .with_context(|| format!("failed to write config at {}", path.display()))?; + Ok(path) +} + +pub(crate) fn persist_tui_integer_key( + config_path: Option<&Path>, + key: &str, + value: u64, +) -> anyhow::Result { + use anyhow::Context; + use std::fs; + + 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()))?; + } + + let mut doc: toml::Value = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + let table = doc + .as_table_mut() + .context("config.toml root must be a table")?; + let tui_entry = table + .entry("tui".to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())); + let tui_table = tui_entry + .as_table_mut() + .context("`tui` section in config.toml must be a table")?; + let value = i64::try_from(value).context("integer value is too large for TOML")?; + tui_table.insert(key.to_string(), toml::Value::Integer(value)); + let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; + fs::write(&path, body) + .with_context(|| format!("failed to write config at {}", path.display()))?; + Ok(path) +} + +pub(crate) fn persist_provider_base_url_key( + config_path: Option<&Path>, + provider: ApiProvider, + value: &str, +) -> anyhow::Result { + use anyhow::Context; + use std::fs; + + 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()))?; + } + + let mut doc: toml::Value = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + let table = doc + .as_table_mut() + .context("config.toml root must be a table")?; + let providers = table + .entry("providers".to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) + .as_table_mut() + .context("`providers` must be a table")?; + let provider_key = provider_base_url_table_key(provider)?; + let entry = providers + .entry(provider_key.to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) + .as_table_mut() + .with_context(|| format!("`providers.{provider_key}` must be a table"))?; + entry.insert( + "base_url".to_string(), + toml::Value::String(value.to_string()), + ); + + let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; + fs::write(&path, body) + .with_context(|| format!("failed to write config at {}", path.display()))?; + Ok(path) +} + +fn provider_base_url_table_key(provider: ApiProvider) -> anyhow::Result<&'static str> { + match provider { + ApiProvider::Deepseek | ApiProvider::DeepseekCN => { + anyhow::bail!("DeepSeek uses the root base_url setting") + } + ApiProvider::NvidiaNim => Ok("nvidia_nim"), + ApiProvider::Openai => Ok("openai"), + ApiProvider::Atlascloud => Ok("atlascloud"), + ApiProvider::WanjieArk => Ok("wanjie_ark"), + ApiProvider::Volcengine => Ok("volcengine"), + ApiProvider::Openrouter => Ok("openrouter"), + ApiProvider::XiaomiMimo => Ok("xiaomi_mimo"), + ApiProvider::Novita => Ok("novita"), + ApiProvider::Fireworks => Ok("fireworks"), + ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => Ok("siliconflow"), + ApiProvider::Arcee => Ok("arcee"), + ApiProvider::Huggingface => Ok("huggingface"), + ApiProvider::Moonshot => Ok("moonshot"), + ApiProvider::Sglang => Ok("sglang"), + ApiProvider::Vllm => Ok("vllm"), + ApiProvider::Ollama => Ok("ollama"), + } +} + +pub(crate) 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("CODEWHALE_CONFIG_PATH") { + let trimmed = env.trim(); + if !trimmed.is_empty() { + return Ok(PathBuf::from(trimmed)); + } + } + if let Ok(env) = std::env::var("DEEPSEEK_CONFIG_PATH") { + let trimmed = env.trim(); + if !trimmed.is_empty() { + return Ok(PathBuf::from(trimmed)); + } + } + let home = + effective_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); + } + let legacy = home.join(".deepseek").join("config.toml"); + if legacy.exists() { + return Ok(legacy); + } + Ok(primary) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::ffi::OsString; + use std::fs; + use std::path::Path; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct EnvGuard { + home: Option, + userprofile: Option, + codewhale_config_path: Option, + deepseek_config_path: Option, + _lock: std::sync::MutexGuard<'static, ()>, + } + + impl EnvGuard { + fn new(home: &Path) -> Self { + let lock = crate::test_support::lock_test_env(); + let home_str = OsString::from(home.as_os_str()); + let config_path = home.join(".deepseek").join("config.toml"); + let config_str = OsString::from(config_path.as_os_str()); + let home_prev = env::var_os("HOME"); + let userprofile_prev = env::var_os("USERPROFILE"); + let codewhale_config_prev = env::var_os("CODEWHALE_CONFIG_PATH"); + let deepseek_config_prev = env::var_os("DEEPSEEK_CONFIG_PATH"); + + // Safety: test-only environment mutation guarded by process-wide mutex. + unsafe { + env::set_var("HOME", &home_str); + env::set_var("USERPROFILE", &home_str); + env::remove_var("CODEWHALE_CONFIG_PATH"); + env::set_var("DEEPSEEK_CONFIG_PATH", &config_str); + } + + Self { + home: home_prev, + userprofile: userprofile_prev, + codewhale_config_path: codewhale_config_prev, + deepseek_config_path: deepseek_config_prev, + _lock: lock, + } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = self.home.take() { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("HOME", value); + } + } else { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::remove_var("HOME"); + } + } + + if let Some(value) = self.userprofile.take() { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("USERPROFILE", value); + } + } else { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::remove_var("USERPROFILE"); + } + } + + if let Some(value) = self.codewhale_config_path.take() { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("CODEWHALE_CONFIG_PATH", value); + } + } else { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::remove_var("CODEWHALE_CONFIG_PATH"); + } + } + + if let Some(value) = self.deepseek_config_path.take() { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("DEEPSEEK_CONFIG_PATH", value); + } + } else { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::remove_var("DEEPSEEK_CONFIG_PATH"); + } + } + } + } + + fn temp_root(prefix: &str) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id())) + } + + #[test] + fn persist_status_items_writes_tui_section_to_config_toml() { + let temp_root = temp_root("codewhale-statusline-persist"); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let items = vec![ + crate::config::StatusItem::Mode, + crate::config::StatusItem::Model, + crate::config::StatusItem::Cost, + ]; + + let path = persist_status_items(&items).expect("persist should succeed"); + let body = fs::read_to_string(&path).expect("written file should be readable"); + assert!(body.contains("[tui]"), "expected [tui] section in {body}"); + assert!( + body.contains("status_items"), + "expected status_items key in {body}" + ); + assert!(body.contains("\"mode\""), "expected mode key in {body}"); + assert!(body.contains("\"cost\""), "expected cost key in {body}"); + } + + #[test] + fn config_toml_path_uses_codewhale_home_for_fresh_installs() { + let temp_root = temp_root("codewhale-config-path-fresh"); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + unsafe { + env::remove_var("DEEPSEEK_CONFIG_PATH"); + } + + assert_eq!( + config_toml_path(None).unwrap(), + temp_root.join(".codewhale").join("config.toml") + ); + } + + #[test] + fn config_toml_path_preserves_legacy_config_when_it_exists() { + let temp_root = temp_root("codewhale-config-path-legacy"); + let legacy_config = temp_root.join(".deepseek").join("config.toml"); + fs::create_dir_all(legacy_config.parent().unwrap()).unwrap(); + fs::write(&legacy_config, "").unwrap(); + let _guard = EnvGuard::new(&temp_root); + + unsafe { + env::remove_var("DEEPSEEK_CONFIG_PATH"); + } + + assert_eq!(config_toml_path(None).unwrap(), legacy_config); + } + + #[test] + fn config_toml_path_prefers_codewhale_env_over_legacy_env() { + let temp_root = temp_root("codewhale-config-path-env"); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + let preferred = temp_root.join("preferred.toml"); + let legacy = temp_root.join("legacy.toml"); + + unsafe { + env::set_var("CODEWHALE_CONFIG_PATH", &preferred); + env::set_var("DEEPSEEK_CONFIG_PATH", &legacy); + } + + assert_eq!(config_toml_path(None).unwrap(), preferred); + } + + #[test] + fn persist_status_items_preserves_existing_unrelated_keys() { + let temp_root = temp_root("codewhale-statusline-preserve"); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let path = temp_root.join(".deepseek").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + "api_key = \"sentinel-key\"\nmodel = \"deepseek-v4-pro\"\n", + ) + .unwrap(); + + let written = persist_status_items(&[crate::config::StatusItem::Mode]) + .expect("persist should succeed"); + let body = fs::read_to_string(&written).expect("written file should be readable"); + assert!( + body.contains("api_key = \"sentinel-key\""), + "round-trip lost api_key: {body}" + ); + assert!( + body.contains("model = \"deepseek-v4-pro\""), + "round-trip lost model: {body}" + ); + assert!( + body.contains("status_items"), + "expected status_items in {body}" + ); + } +} diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index d5632befe..adc99b354 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -405,7 +405,7 @@ pub async fn start_web_editor(app: &App, config: &Config) -> Result = Some(app_snapshot); loop { tokio::time::sleep(Duration::from_millis(750)).await; @@ -596,7 +596,7 @@ pub fn apply_document( app.status_items = new_status_items.clone(); app.needs_redraw = true; if persist { - let path = commands::persist_status_items(&new_status_items)?; + let path = crate::config_persistence::persist_status_items(&new_status_items)?; notes.push(format!("status_items saved to {}", path.display())); } else { notes.push("status_items updated for this session".to_string()); @@ -685,7 +685,7 @@ fn apply_reasoning_effort( app.last_effective_reasoning_effort = None; app.update_model_compaction_budget(); if persist { - commands::persist_root_string_key( + crate::config_persistence::persist_root_string_key( app.config_path.as_deref(), "reasoning_effort", effort.as_setting(), diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index fa2146171..86c146b42 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -168,9 +168,29 @@ impl StructuredState { if let Some(plan) = self.plan_snapshot.as_ref() { out.push_str("\nStrategy metadata\n"); - if let Some(explanation) = plan.explanation.as_ref() { - out.push_str(&format!("{explanation}\n\n")); - } + append_plan_field(&mut out, "Title", plan.title.as_deref()); + append_plan_field(&mut out, "Objective", plan.objective.as_deref()); + append_plan_field(&mut out, "Context", plan.context_summary.as_deref()); + append_plan_field(&mut out, "Explanation", plan.explanation.as_deref()); + append_plan_list(&mut out, "Source", &plan.sources_used); + append_plan_list(&mut out, "Critical file", &plan.critical_files); + append_plan_list(&mut out, "Constraint", &plan.constraints); + append_plan_field( + &mut out, + "Recommended approach", + plan.recommended_approach.as_deref(), + ); + append_plan_field( + &mut out, + "Verification plan", + plan.verification_plan.as_deref(), + ); + append_plan_field( + &mut out, + "Risks and unknowns", + plan.risks_and_unknowns.as_deref(), + ); + append_plan_field(&mut out, "Handoff packet", plan.handoff_packet.as_deref()); for item in &plan.items { let marker = match item.status { crate::tools::plan::StepStatus::Pending => "[ ]", @@ -204,6 +224,21 @@ impl StructuredState { } } +fn append_plan_field(out: &mut String, label: &str, value: Option<&str>) { + if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) { + out.push_str(&format!("- {label}: {value}\n")); + } +} + +fn append_plan_list(out: &mut String, label: &str, values: &[String]) { + for value in values { + let value = value.trim(); + if !value.is_empty() { + out.push_str(&format!("- {label}: {value}\n")); + } + } +} + // === Types === /// Configuration for the engine @@ -309,11 +344,17 @@ pub struct EngineConfig { /// Metaso also falls back to `METASO_API_KEY` env var, then a built-in key. /// Baidu also falls back to `BAIDU_SEARCH_API_KEY`. pub search_api_key: Option, + /// Optional DuckDuckGo-compatible HTML endpoint override. + pub search_base_url: Option, /// Per-step DeepSeek API timeout for sub-agent `create_message` requests. /// Resolved from `[subagents] api_timeout_secs` (clamped to 1..=1800) /// once at engine construction, then threaded onto every /// `SubAgentRuntime` the engine builds (#1806, #1808). pub subagent_api_timeout: Duration, + /// Per-SSE-chunk idle timeout for streamed model responses. + /// Resolved from `[tui].stream_chunk_timeout_secs` (or the legacy + /// `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS`) and updated live by `/config`. + pub stream_chunk_timeout: Duration, /// No-progress heartbeat timeout for live sub-agents. Used by the manager /// and parent wait loop to auto-cancel stuck children before they exhaust /// the sub-agent slot pool indefinitely (#2614). @@ -373,9 +414,13 @@ impl Default for EngineConfig { workshop: None, search_provider: crate::config::SearchProvider::default(), search_api_key: None, + search_base_url: None, subagent_api_timeout: Duration::from_secs( crate::config::DEFAULT_SUBAGENT_API_TIMEOUT_SECS, ), + stream_chunk_timeout: Duration::from_secs( + crate::config::DEFAULT_STREAM_CHUNK_TIMEOUT_SECS, + ), subagent_heartbeat_timeout: Duration::from_secs( crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, ), @@ -439,6 +484,8 @@ pub struct EngineHandle { tx_user_input: mpsc::Sender, /// Send steer input for an in-flight turn. tx_steer: mpsc::Sender, + /// Shared pause flag set by the TUI and read by the turn loop. + shared_paused: Arc>, } // `impl EngineHandle { ... }` moved to `engine/handle.rs` so the @@ -505,6 +552,15 @@ pub struct Engine { slop_ledger_gate_cache: Option<(Option, Option)>, /// Current operating mode. Updated on `ChangeMode` and `SendMessage`. current_mode: AppMode, + /// Process-local cache for `estimated_input_tokens`. Memoizes the most + /// recent token estimate keyed on `(session.messages_revision, + /// system_prompt_fingerprint)`. Five call sites per turn consult this + /// (engine capacity checkpoints, seam manager, trim budget, etc.) plus + /// four TUI / command consumers; the cache turns N×O(messages) walks + /// into a single recompute on a content change. + token_estimate_cache: TokenEstimateCache, + /// Shared pause flag set by the TUI and read before tool execution. + shared_paused: Arc>, } // === Internal tool helpers === @@ -528,6 +584,10 @@ impl Engine { Ok(mut slot) => *slot = None, Err(poisoned) => *poisoned.into_inner() = None, } + match self.shared_paused.lock() { + Ok(mut paused) => *paused = false, + Err(poisoned) => *poisoned.into_inner() = false, + } } fn env_only_api_key_recovery_hint(api_config: &Config) -> Option { @@ -579,6 +639,8 @@ impl Engine { /// Create a new engine with the given configuration pub fn new(config: EngineConfig, api_config: &Config) -> (Self, EngineHandle) { + crate::tls::ensure_rustls_crypto_provider(); + if let Some(objective) = normalized_goal_objective(config.goal_objective.as_deref()) { sync_goal_state_from_host(&config.goal_state, Some(&objective), None, false); } @@ -592,6 +654,7 @@ impl Engine { let cancel_token = CancellationToken::new(); let shared_cancel_token = Arc::new(StdMutex::new(cancel_token.clone())); let cancel_reason: Arc>> = Arc::new(StdMutex::new(None)); + let shared_paused = Arc::new(StdMutex::new(false)); let tool_exec_lock = Arc::new(RwLock::new(())); // Create clients for both providers @@ -633,7 +696,6 @@ impl Engine { show_thinking: config.show_thinking, allow_shell: config.allow_shell, }, - session.approval_mode, ); let stable_prompt = Some(system_prompt); session.last_system_prompt_hash = Some(system_prompt_hash(stable_prompt.as_ref())); @@ -754,6 +816,8 @@ impl Engine { workshop_vars, sandbox_backend, current_mode: AppMode::Agent, + token_estimate_cache: TokenEstimateCache::new(), + shared_paused: shared_paused.clone(), }; engine.rehydrate_latest_canonical_state(); @@ -765,6 +829,7 @@ impl Engine { tx_approval, tx_user_input, tx_steer, + shared_paused, }; (engine, handle) @@ -798,11 +863,12 @@ impl Engine { self.session.trust_mode = trust_mode; self.config.trust_mode = trust_mode; self.session.auto_approve = auto_approve; - self.session.approval_mode = if auto_approve { - crate::tui::approval::ApprovalMode::Auto - } else { - approval_mode - }; + let agent_approval_mode = agent_approval_mode_for_turn(auto_approve, approval_mode); + // Only track the Agent-mode approval — Yolo/Plan have fixed + // approval policies that are derived from the mode itself. + if mode == AppMode::Agent { + self.session.approval_mode = agent_approval_mode; + } let _ = self .tx_event @@ -1179,17 +1245,8 @@ impl Engine { let _ = self.tx_event.send(Event::AgentList { agents }).await; } Op::ChangeMode { mode } => { - let previous_mode = self.current_mode; self.current_mode = mode; - self.refresh_system_prompt(mode); self.emit_session_updated().await; - // Notify the agent that the mode has changed so it can re-evaluate - // any operations that were blocked by the previous mode's policy. - if previous_mode != mode { - let msg = Self::mode_change_runtime_message(previous_mode, mode); - self.session.add_message(msg); - self.emit_session_updated().await; - } let _ = self .tx_event .send(Event::status(format!( @@ -1198,11 +1255,11 @@ impl Engine { ))) .await; } - Op::SetModel { model, mode } => { + Op::SetModel { model, mode: _ } => { self.session.auto_model = model.trim().eq_ignore_ascii_case("auto"); self.session.model = model; self.config.model.clone_from(&self.session.model); - self.refresh_system_prompt(mode); + self.refresh_system_prompt(); self.emit_session_updated().await; let _ = self .tx_event @@ -1223,6 +1280,15 @@ impl Engine { ))) .await; } + Op::SetStreamChunkTimeout { timeout_secs } => { + self.config.stream_chunk_timeout = Duration::from_secs(timeout_secs); + let _ = self + .tx_event + .send(Event::status(format!( + "Stream chunk timeout set to {timeout_secs}s" + ))) + .await; + } Op::SyncSession { session_id, messages, @@ -1240,6 +1306,10 @@ impl Engine { self.session.compaction_summary_prompt = extract_compaction_summary_prompt(system_prompt.clone()); self.session.system_prompt = system_prompt; + self.session.last_system_prompt_hash = + Some(system_prompt_hash(self.session.system_prompt.as_ref())); + // Host-supplied prompts are persisted prefixes. Keep them + // byte-stable; mode/runtime state is projected per request. self.session.system_prompt_override = system_prompt_override && self.session.system_prompt.is_some(); self.session.auto_model = model.trim().eq_ignore_ascii_case("auto"); @@ -1282,6 +1352,7 @@ impl Engine { } if let Some(idx) = cut { self.session.messages.truncate(idx); + self.session.bump_messages_revision(); } // Now dispatch the new message as a normal send, // reusing the engine's stored mode/model config. @@ -1335,47 +1406,6 @@ impl Engine { .await; } - /// Build a runtime event message notifying the agent that the operating mode has changed. - /// This lets the agent re-evaluate any operations that were blocked by the previous mode. - fn mode_change_runtime_message(previous_mode: AppMode, new_mode: AppMode) -> Message { - let (policy_note, re_eval_hint) = match new_mode { - AppMode::Yolo => ( - "all operations run automatically without requiring user approval", - "Re-evaluate any previously blocked write, patch, or shell operations \ - — they are now auto-approved.", - ), - AppMode::Agent => ( - "read-only operations run silently; writes, patches, and shell \ - commands require user approval", - "Any operations you ran automatically under YOLO mode now require \ - explicit user approval before executing.", - ), - AppMode::Plan => ( - "all writes and patches are blocked; shell and code execution are unavailable", - "Any previously planned operations that require writes or shell access \ - must wait until the mode changes back to Agent or YOLO.", - ), - }; - Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: format!( - "\n\ -This is an internal runtime event, not user input. The operating mode has changed \ -from {previous} mode to {new} mode.\n\n\ -In {new} mode: {policy}\n\n\ -{re_eval}\n\ -", - previous = previous_mode.description(), - new = new_mode.description(), - policy = policy_note, - re_eval = re_eval_hint, - ), - cache_control: None, - }], - } - } - async fn add_session_message(&mut self, message: Message) { self.session.add_message(message); self.emit_session_updated().await; @@ -1420,6 +1450,18 @@ In {new} mode: {policy}\n\n\ } } + fn runtime_prompt_message(&self) -> Message { + let mode = self.current_mode; + let approval_mode = approval_mode_for(mode, self.session.approval_mode); + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: runtime_prompt_text(mode, approval_mode), + cache_control: None, + }], + } + } + fn user_text_message_with_turn_metadata(&self, text: String) -> Message { self.user_text_message_with_turn_metadata_for_route( text, @@ -1440,9 +1482,21 @@ In {new} mode: {policy}\n\n\ reasoning_effort: Option<&str>, reasoning_effort_auto: bool, ) -> Message { + // Place the user text first and turn_meta last so that the leading + // bytes of each user message stay stable across date / model-route / + // working-set changes. DeepSeek's KV prefix cache matches byte + // sequences from the start of each message; when turn_meta (which + // contains the current date) sits at position 0 the entire user + // message prefix is invalidated at every date boundary. Moving it + // to the tail preserves the user-input prefix and limits cache + // invalidation to the trailing metadata block. Message { role: "user".to_string(), content: vec![ + ContentBlock::Text { + text, + cache_control: None, + }, self.turn_metadata_block( routed_model, mode, @@ -1450,10 +1504,6 @@ In {new} mode: {policy}\n\n\ reasoning_effort, reasoning_effort_auto, ), - ContentBlock::Text { - text, - cache_control: None, - }, ], } } @@ -1560,6 +1610,14 @@ In {new} mode: {policy}\n\n\ .observe_user_message(&content, &self.session.workspace); let force_update_plan_first = should_force_update_plan_first(mode, &content); + let agent_approval_mode = agent_approval_mode_for_turn(auto_approve, approval_mode); + self.session.auto_approve = auto_approve; + // Only track the Agent-mode approval — Yolo/Plan have fixed + // approval policies that are derived from the mode itself. + if mode == AppMode::Agent { + self.session.approval_mode = agent_approval_mode; + } + // Add user message to session let user_msg = self.user_text_message_with_turn_metadata_for_route( content, @@ -1597,15 +1655,10 @@ In {new} mode: {policy}\n\n\ self.config.trust_mode = trust_mode; self.config.translation_enabled = translation_enabled; self.config.show_thinking = show_thinking; - self.session.auto_approve = auto_approve; - self.session.approval_mode = if auto_approve { - crate::tui::approval::ApprovalMode::Auto - } else { - approval_mode - }; - // Update system prompt to match current mode and include persisted compaction context. - self.refresh_system_prompt(mode); + // Refresh stable prompt context. Current mode is carried by the + // request-time runtime prompt projection. + self.refresh_system_prompt(); self.emit_session_updated().await; // Build tool registry and tool list for the current mode @@ -1708,14 +1761,21 @@ In {new} mode: {policy}\n\n\ } else { None }; - Some( - builder - .with_subagent_tools( - self.subagent_manager.clone(), - runtime.expect("sub-agent runtime should exist with active client"), - ) - .build(tool_context), - ) + if let Some(subagent_runtime) = runtime { + Some( + builder + .with_subagent_tools( + self.subagent_manager.clone(), + subagent_runtime, + ) + .build(tool_context), + ) + } else { + tracing::warn!( + "Sub-agents enabled but no API client available, falling back to basic tool set" + ); + Some(builder.build(tool_context)) + } } else { Some(builder.build(tool_context)) } @@ -2011,10 +2071,15 @@ In {new} mode: {policy}\n\n\ .await; } - fn estimated_input_tokens(&self) -> usize { - estimate_input_tokens_conservative( - &self.session.messages, + fn estimated_input_tokens(&mut self) -> usize { + // Memoized on (session.messages_revision, system-prompt fingerprint). + // The cache invalidates as soon as either input changes; until then + // repeated calls (capacity checkpoints, /status, context inspector, + // TUI footer) all hit the cached value. + self.token_estimate_cache.lookup_or_compute( + self.session.messages_revision, self.session.system_prompt.as_ref(), + &self.session.messages, ) } @@ -2024,6 +2089,7 @@ In {new} mode: {policy}\n\n\ && self.estimated_input_tokens() > target_input_budget { self.session.messages.remove(0); + self.session.bump_messages_revision(); removed = removed.saturating_add(1); } removed @@ -2191,6 +2257,7 @@ In {new} mode: {policy}\n\n\ // Wire search provider config. ctx.search_provider = self.config.search_provider; ctx.search_api_key = self.config.search_api_key.clone(); + ctx.search_base_url = self.config.search_base_url.clone(); let policy = sandbox_policy_for_mode(mode, &self.session.workspace); let mut ctx = ctx.with_elevated_sandbox_policy(policy); @@ -2206,8 +2273,11 @@ In {new} mode: {policy}\n\n\ if let Some(pool) = self.mcp_pool.as_ref() { return Ok(Arc::clone(pool)); } - let mut pool = McpPool::from_config_path(&self.session.mcp_config_path) - .map_err(|e| ToolError::execution_failed(format!("Failed to load MCP config: {e}")))?; + let mut pool = McpPool::from_config_path_with_workspace( + &self.session.mcp_config_path, + &self.session.workspace, + ) + .map_err(|e| ToolError::execution_failed(format!("Failed to load MCP config: {e}")))?; if let Some(decider) = self.config.network_policy.as_ref() { pool = pool.with_network_policy(decider.clone()); } @@ -2220,7 +2290,7 @@ In {new} mode: {policy}\n\n\ let pool = match self.ensure_mcp_pool().await { Ok(pool) => pool, Err(err) => { - let _ = self.tx_event.send(Event::status(err.to_string())).await; + let _ = self.tx_event.send(Event::status(format!("{err:#}"))).await; return Vec::new(); } }; @@ -2247,15 +2317,20 @@ In {new} mode: {policy}\n\n\ /// assistant message. Called from `handle_deepseek_turn` before each API /// request so the model always has the latest navigation aids. async fn layered_context_checkpoint(&mut self) { - let Some(ref seam_mgr) = self.seam_manager else { + if self.seam_manager.is_none() { return; - }; - if !seam_mgr.config().enabled { + } + if !self.seam_manager.as_ref().unwrap().config().enabled { return; } + // Compute the estimated token count *before* taking a long-lived + // `&SeamManager` borrow — `estimated_input_tokens` mutates the + // engine's token-estimate cache, which would conflict. + let estimated_tokens = self.estimated_input_tokens(); + let seam_mgr = self.seam_manager.as_ref().unwrap(); let highest = seam_mgr.highest_level().await; - let Some(level) = seam_mgr.seam_level_for(self.estimated_input_tokens(), highest) else { + let Some(level) = seam_mgr.seam_level_for(estimated_tokens, highest) else { return; }; @@ -2342,8 +2417,8 @@ In {new} mode: {policy}\n\n\ ))) .await; } - /// Refresh the system prompt based on current mode and context. - fn refresh_system_prompt(&mut self, mode: AppMode) { + /// Refresh the stable system prompt based on current non-mode context. + fn refresh_system_prompt(&mut self) { let user_memory_block = crate::memory::compose_block(self.config.memory_enabled, &self.config.memory_path); let prompt_goal_objective = goal_objective_for_prompt( @@ -2351,7 +2426,7 @@ In {new} mode: {policy}\n\n\ &self.config.goal_state, ); let base = prompts::system_prompt_for_mode_with_context_skills_session_and_approval( - mode, + AppMode::Agent, &self.config.workspace, None, Some(&self.config.skills_dir), @@ -2366,7 +2441,6 @@ In {new} mode: {policy}\n\n\ show_thinking: self.config.show_thinking, allow_shell: self.session.allow_shell, }, - self.session.approval_mode, ); let mut stable_prompt = merge_system_prompts(Some(&base), self.session.compaction_summary_prompt.clone()); @@ -2384,7 +2458,6 @@ In {new} mode: {policy}\n\n\ let stable_hash = system_prompt_hash(stable_prompt.as_ref()); if self.session.system_prompt_override { - self.session.last_system_prompt_hash = Some(stable_hash); return; } if self.session.last_system_prompt_hash != Some(stable_hash) { @@ -2532,13 +2605,10 @@ fn goal_objective_for_prompt( ) -> Option { match goal_state.lock() { Ok(state) => { - if state.objective().is_some() { - return state.is_active().then(|| { - state - .objective() - .expect("checked goal objective") - .to_string() - }); + if let Some(objective) = state.objective() { + // Preserve original behavior: return None (not fallback) when + // objective exists but goal is inactive. + return state.is_active().then(|| objective.to_string()); } } Err(err) => tracing::warn!("goal state lock poisoned while building prompt: {err}"), @@ -2546,6 +2616,59 @@ fn goal_objective_for_prompt( normalized_goal_objective(configured_goal) } +// ── Mode & approval prompts as request-time runtime metadata ───────── +// +// Mode contracts and approval policies are not persisted in the session +// history and are not sent as extra system messages. Instead, each API +// request projects a transient user-role runtime metadata message at the +// tail. The stable system prompt remains byte-stable, stored history remains +// byte-stable, and strict chat-template providers never see a system message +// outside messages[0]. + +fn approval_mode_for( + mode: AppMode, + session_approval: crate::tui::approval::ApprovalMode, +) -> crate::tui::approval::ApprovalMode { + match mode { + AppMode::Yolo => crate::tui::approval::ApprovalMode::Auto, + AppMode::Plan => crate::tui::approval::ApprovalMode::Never, + AppMode::Agent => session_approval, + } +} + +fn agent_approval_mode_for_turn( + auto_approve: bool, + approval_mode: crate::tui::approval::ApprovalMode, +) -> crate::tui::approval::ApprovalMode { + if auto_approve { + crate::tui::approval::ApprovalMode::Auto + } else { + approval_mode + } +} + +/// Produce a minimal runtime-policy tag for the per-turn transient user message. +/// +/// All mode and approval policy descriptions live in the frozen system-prompt +/// prefix (`render_runtime_policy_reference()`). This tag is a pointer — the +/// model looks up the corresponding rules from the system prompt. Reduces +/// per-request overhead from ~500 tokens to ~12 tokens. +fn runtime_prompt_text(mode: AppMode, approval_mode: crate::tui::approval::ApprovalMode) -> String { + let mode_str = match mode { + AppMode::Agent => "agent", + AppMode::Plan => "plan", + AppMode::Yolo => "yolo", + }; + let approval_str = match approval_mode { + crate::tui::approval::ApprovalMode::Auto => "auto", + crate::tui::approval::ApprovalMode::Suggest => "suggest", + crate::tui::approval::ApprovalMode::Never => "never", + }; + format!( + "" + ) +} + /// Spawn the engine in a background task pub fn spawn_engine(config: EngineConfig, api_config: &Config) -> EngineHandle { let (engine, handle) = Engine::new(config, api_config); @@ -2609,6 +2732,7 @@ pub(crate) fn mock_engine_handle() -> MockEngineHandle { let cancel_token = CancellationToken::new(); let shared_cancel_token = Arc::new(StdMutex::new(cancel_token.clone())); let cancel_reason: Arc>> = Arc::new(StdMutex::new(None)); + let shared_paused = Arc::new(StdMutex::new(false)); let handle = EngineHandle { tx_op, rx_event: Arc::new(RwLock::new(rx_event)), @@ -2617,6 +2741,7 @@ pub(crate) fn mock_engine_handle() -> MockEngineHandle { tx_approval, tx_user_input, tx_steer, + shared_paused, }; MockEngineHandle { @@ -2636,17 +2761,19 @@ mod handle; pub(crate) use context::compact_tool_result_for_context; use context::{ COMPACTION_SUMMARY_MARKER, MAX_CONTEXT_RECOVERY_ATTEMPTS, MIN_RECENT_MESSAGES_TO_KEEP, - context_input_budget, effective_max_output_tokens, estimate_input_tokens_conservative, - extract_compaction_summary_prompt, is_context_length_error_message, summarize_text, + context_input_budget, effective_max_output_tokens, extract_compaction_summary_prompt, + is_context_length_error_message, summarize_text, }; mod dispatch; mod loop_guard; mod lsp_hooks; mod streaming; +mod token_estimate_cache; mod tool_catalog; mod tool_execution; mod tool_setup; mod turn_loop; +pub(crate) use token_estimate_cache::TokenEstimateCache; pub(crate) fn default_active_native_tool_names() -> &'static [&'static str] { tool_catalog::DEFAULT_ACTIVE_NATIVE_TOOLS @@ -2671,7 +2798,7 @@ use self::streaming::{ ContentBlockKind, FAKE_WRAPPER_NOTICE, MAX_STREAM_ERRORS_BEFORE_FAIL, MAX_TRANSPARENT_STREAM_RETRIES, STREAM_MAX_CONTENT_BYTES, STREAM_MAX_DURATION_SECS, ToolUseState, contains_fake_tool_wrapper, filter_tool_call_delta, - should_transparently_retry_stream, stream_chunk_timeout_secs, + should_transparently_retry_stream, }; use self::tool_catalog::{ CODE_EXECUTION_TOOL_NAME, JS_EXECUTION_TOOL_NAME, MULTI_TOOL_PARALLEL_NAME, diff --git a/crates/tui/src/core/engine/capacity_flow.rs b/crates/tui/src/core/engine/capacity_flow.rs index 06e37f497..514ad1201 100644 --- a/crates/tui/src/core/engine/capacity_flow.rs +++ b/crates/tui/src/core/engine/capacity_flow.rs @@ -16,9 +16,8 @@ impl Engine { client: Option<&DeepSeekClient>, mode: AppMode, ) -> bool { - let snapshot = self - .capacity_controller - .observe_pre_turn(self.capacity_observation(turn)); + let observation = self.capacity_observation(turn); + let snapshot = self.capacity_controller.observe_pre_turn(observation); let decision = self .capacity_controller .decide(self.turn_counter, snapshot.as_ref()); @@ -37,16 +36,15 @@ impl Engine { pub(super) async fn run_capacity_post_tool_checkpoint( &mut self, turn: &TurnContext, - mode: AppMode, + tool_registry: Option<&crate::tools::ToolRegistry>, tool_exec_lock: Arc>, mcp_pool: Option>>, _step_error_count: usize, _consecutive_tool_error_steps: u32, ) -> bool { - let snapshot = self - .capacity_controller - .observe_post_tool(self.capacity_observation(turn)); + let observation = self.capacity_observation(turn); + let snapshot = self.capacity_controller.observe_post_tool(observation); let decision = self .capacity_controller .decide(self.turn_counter, snapshot.as_ref()); @@ -58,7 +56,6 @@ impl Engine { let _ = self .apply_verify_with_tool_replay( turn, - mode, snapshot.as_ref(), tool_registry, tool_exec_lock, @@ -68,7 +65,7 @@ impl Engine { false } GuardrailAction::VerifyAndReplan => { - self.apply_verify_and_replan(turn, mode, snapshot.as_ref(), "high_risk_post_tool") + self.apply_verify_and_replan(turn, snapshot.as_ref(), "high_risk_post_tool") .await } GuardrailAction::NoIntervention | GuardrailAction::TargetedContextRefresh => false, @@ -78,7 +75,7 @@ impl Engine { pub(super) async fn run_capacity_error_escalation_checkpoint( &mut self, turn: &TurnContext, - mode: AppMode, + step_error_count: usize, consecutive_tool_error_steps: u32, error_categories: &[ErrorCategory], @@ -111,8 +108,8 @@ impl Engine { .last_snapshot() .cloned() .or_else(|| { - self.capacity_controller - .observe_pre_turn(self.capacity_observation(turn)) + let observation = self.capacity_observation(turn); + self.capacity_controller.observe_pre_turn(observation) }); let Some(snapshot) = snapshot else { return false; @@ -138,7 +135,6 @@ impl Engine { let category_labels: Vec = error_categories.iter().map(|c| c.to_string()).collect(); self.apply_verify_and_replan( turn, - mode, Some(&forced), &format!( "error_escalation: step_errors={}, consecutive_steps={}, categories={}", @@ -150,7 +146,7 @@ impl Engine { .await } - pub(super) fn capacity_observation(&self, turn: &TurnContext) -> CapacityObservationInput { + pub(super) fn capacity_observation(&mut self, turn: &TurnContext) -> CapacityObservationInput { let message_window = self.config.capacity.profile_window.max(8) * 3; let action_count_this_turn = usize::try_from(turn.step) .unwrap_or(usize::MAX) @@ -387,7 +383,7 @@ impl Engine { &mut self, turn: &TurnContext, client: Option<&DeepSeekClient>, - mode: AppMode, + _mode: AppMode, snapshot: Option<&CapacitySnapshot>, ) -> bool { let before_tokens = self.estimated_input_tokens(); @@ -467,7 +463,7 @@ impl Engine { GuardrailAction::TargetedContextRefresh, None, ))); - self.refresh_system_prompt(mode); + self.refresh_system_prompt(); self.emit_session_updated().await; let after_tokens = self.estimated_input_tokens(); @@ -489,7 +485,6 @@ impl Engine { pub(super) async fn apply_verify_with_tool_replay( &mut self, turn: &TurnContext, - mode: AppMode, snapshot: Option<&CapacitySnapshot>, tool_registry: Option<&crate::tools::ToolRegistry>, tool_exec_lock: Arc>, @@ -619,7 +614,7 @@ impl Engine { GuardrailAction::VerifyWithToolReplay, Some(&verification_note), ))); - self.refresh_system_prompt(mode); + self.refresh_system_prompt(); self.emit_session_updated().await; let after_tokens = self.estimated_input_tokens(); @@ -640,7 +635,6 @@ impl Engine { pub(super) async fn apply_verify_and_replan( &mut self, turn: &TurnContext, - mode: AppMode, snapshot: Option<&CapacitySnapshot>, reason: &str, ) -> bool { @@ -659,34 +653,18 @@ impl Engine { .persist_capacity_record(turn, GuardrailAction::VerifyAndReplan, &record) .await; - let latest_user = self - .session - .messages - .iter() - .rev() - .find(|msg| { - msg.role == "user" - && msg - .content - .iter() - .any(|block| matches!(block, ContentBlock::Text { .. })) - }) - .cloned(); - let latest_verified = self - .session - .messages - .iter() - .rev() - .find(|msg| { - msg.role == "user" - && msg.content.iter().any(|block| match block { - ContentBlock::ToolResult { content, .. } => { - content.contains("[verification replay]") - } - _ => false, - }) - }) - .cloned(); + // The replan path needs the *full* messages, not summaries. + // `scan_canonical_inputs` already located the indices in a single + // reverse pass; clone from the live `messages` slice once. We + // pass `true` because the replan path consumes + // `latest_verified_user_idx` below. + let scan = scan_canonical_inputs(&self.session.messages, true); + let latest_user = scan + .latest_user_text_idx + .and_then(|idx| self.session.messages.get(idx).cloned()); + let latest_verified = scan + .latest_verified_user_idx + .and_then(|idx| self.session.messages.get(idx).cloned()); self.session.messages.clear(); if let Some(msg) = latest_user { @@ -695,6 +673,7 @@ impl Engine { if let Some(msg) = latest_verified { self.session.messages.push(msg); } + self.session.bump_messages_revision(); self.merge_compaction_summary(Some(self.canonical_prompt( &canonical, @@ -702,7 +681,7 @@ impl Engine { GuardrailAction::VerifyAndReplan, Some("Replan now from canonical state. Keep steps minimal and verifiable."), ))); - self.refresh_system_prompt(mode); + self.refresh_system_prompt(); self.emit_session_updated().await; let _ = self @@ -765,20 +744,18 @@ impl Engine { turn: &TurnContext, note: Option<&str>, ) -> CanonicalState { - let goal = self - .session - .messages - .iter() - .rev() - .find_map(|msg| { - if msg.role != "user" { - return None; - } - msg.content.iter().find_map(|block| match block { - ContentBlock::Text { text, .. } => Some(summarize_text(text, 220)), - _ => None, - }) - }) + // Single reverse scan of session.messages collects the goal, + // confirmed facts (capped at 4), and the latest verified-user + // message index. Previously this function did two reverse + // `.iter().rev().find_map()` walks and a third for facts; the + // dedicated scan below replaces all three with one pass that + // also early-exits once every collector is satisfied. We pass + // `false` here because build_canonical_state does not consume + // `latest_verified_user_idx`, so we don't need the scan to keep + // looking for it. + let scan = scan_canonical_inputs(&self.session.messages, false); + let goal = scan + .goal .unwrap_or_else(|| "Continue current task from compact state".to_string()); let mut constraints = vec![ @@ -789,24 +766,6 @@ impl Engine { constraints.push(summarize_text(note, 180)); } - let mut confirmed_facts = Vec::new(); - for msg in self.session.messages.iter().rev() { - for block in &msg.content { - if let ContentBlock::ToolResult { content, .. } = block { - if content.starts_with("Error:") { - continue; - } - confirmed_facts.push(summarize_text(content, 180)); - if confirmed_facts.len() >= 4 { - break; - } - } - } - if confirmed_facts.len() >= 4 { - break; - } - } - let open_loops: Vec = turn .tool_calls .iter() @@ -837,7 +796,7 @@ impl Engine { CanonicalState { goal, constraints, - confirmed_facts, + confirmed_facts: scan.confirmed_facts, open_loops, pending_actions, critical_refs, @@ -975,3 +934,243 @@ impl Engine { self.merge_compaction_summary(Some(prompt)); } } + +/// Maximum number of confirmed-fact snippets retained by the canonical-state +/// scan. Matches the prior `build_canonical_state` behavior — only the +/// four most recent non-error tool results are surfaced. +const CANONICAL_SCAN_MAX_FACTS: usize = 4; + +/// Output of [`scan_canonical_inputs`]: everything `build_canonical_state` +/// and `apply_verify_and_replan` need to know about the session's recent +/// history, collected in a single reverse pass over `session.messages`. +/// +/// Index fields (`latest_user_text_idx`, `latest_verified_user_idx`) point +/// into the original `messages` slice so the caller can clone the full +/// `Message` value when the re-plan path needs to keep it across a +/// `messages.clear()`. +#[derive(Debug, Default)] +struct CanonicalStateScan { + /// Most recent user-text block, summarized to ≤220 chars. `None` when + /// no user message with a Text block exists. + goal: Option, + /// Index of the most recent user message containing at least one + /// `Text` content block. Used by the re-plan path to keep the + /// latest user request across a `messages.clear()`. + latest_user_text_idx: Option, + /// Index of the most recent user message whose content includes a + /// `[verification replay]` tool result. Used by the re-plan path. + latest_verified_user_idx: Option, + /// Up to [`CANONICAL_SCAN_MAX_FACTS`] most recent non-error + /// `ToolResult` snippets, newest first. + confirmed_facts: Vec, + /// Running count of facts collected so far; lets the early-exit + /// condition avoid an extra `Vec::len()` call per message. + facts_collected: usize, +} + +impl CanonicalStateScan { + /// `true` once every collector the caller actually needs is satisfied. + /// + /// `find_verified` controls whether `latest_verified_user_idx` is part + /// of the early-exit gate. The build_canonical_state path does not + /// consume that field, so passing `false` lets the scan stop as soon + /// as the goal and `CANONICAL_SCAN_MAX_FACTS` facts are found — a + /// huge win on long histories with no verification replay. + fn is_complete(&self, find_verified: bool) -> bool { + self.goal.is_some() + && (!find_verified || self.latest_verified_user_idx.is_some()) + && self.facts_collected >= CANONICAL_SCAN_MAX_FACTS + } +} + +/// Walk `messages` once (in reverse) and collect everything the canonical +/// state and re-plan paths need. Replaces the previous pattern of three +/// independent reverse scans: one for the goal, one for confirmed facts, +/// and one for the latest verified user message. +/// +/// `find_verified` controls whether the scan bothers locating the +/// latest verified user message. Callers that don't need it (e.g. +/// `build_canonical_state`) should pass `false` so the early-exit +/// condition can fire as soon as the goal + facts are gathered. +fn scan_canonical_inputs(messages: &[Message], find_verified: bool) -> CanonicalStateScan { + let mut scan = CanonicalStateScan::default(); + for (idx, msg) in messages.iter().enumerate().rev() { + if msg.role == "user" { + if scan.goal.is_none() + && let Some(text) = msg.content.iter().find_map(|b| match b { + ContentBlock::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + { + scan.goal = Some(summarize_text(text, 220)); + scan.latest_user_text_idx = Some(idx); + } + if find_verified && scan.latest_verified_user_idx.is_none() { + let verified = msg.content.iter().any(|b| match b { + ContentBlock::ToolResult { content, .. } => { + content.contains("[verification replay]") + } + _ => false, + }); + if verified { + scan.latest_verified_user_idx = Some(idx); + } + } + } + if scan.facts_collected < CANONICAL_SCAN_MAX_FACTS { + for block in &msg.content { + if let ContentBlock::ToolResult { content, .. } = block + && !content.starts_with("Error:") + { + scan.confirmed_facts.push(summarize_text(content, 180)); + scan.facts_collected = scan.facts_collected.saturating_add(1); + if scan.facts_collected >= CANONICAL_SCAN_MAX_FACTS { + break; + } + } + } + } + if scan.is_complete(find_verified) { + break; + } + } + scan +} + +#[cfg(test)] +mod canonical_scan_tests { + use super::*; + use crate::models::ContentBlock; + + fn user_text_msg(text: &str) -> Message { + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: text.to_string(), + cache_control: None, + }], + } + } + + fn user_with_verified_replay(text: &str) -> Message { + Message { + role: "user".to_string(), + content: vec![ + ContentBlock::Text { + text: text.to_string(), + cache_control: None, + }, + ContentBlock::ToolResult { + tool_use_id: "x".to_string(), + content: "[verification replay] pass=true".to_string(), + is_error: None, + content_blocks: None, + }, + ], + } + } + + fn tool_result_msg(content: &str) -> Message { + Message { + role: "tool".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "x".to_string(), + content: content.to_string(), + is_error: None, + content_blocks: None, + }], + } + } + + #[test] + fn scan_returns_goal_for_latest_user_text() { + let messages = vec![ + user_text_msg("first"), + tool_result_msg("a"), + user_text_msg("second"), + tool_result_msg("b"), + user_text_msg("third"), + ]; + let scan = scan_canonical_inputs(&messages, false); + // Goal should be the most recent user text. + let goal = scan.goal.expect("goal"); + assert!( + goal.contains("third"), + "expected the most recent, got {goal}" + ); + assert_eq!(scan.latest_user_text_idx, Some(4)); + } + + #[test] + fn scan_collects_up_to_four_facts_newest_first() { + let messages = vec![ + tool_result_msg("fact-A"), + tool_result_msg("fact-B"), + tool_result_msg("fact-C"), + tool_result_msg("fact-D"), + tool_result_msg("fact-E"), + ]; + let scan = scan_canonical_inputs(&messages, false); + assert_eq!(scan.confirmed_facts.len(), 4); + // The four most recent (newest first) are E, D, C, B. + assert!(scan.confirmed_facts[0].contains("fact-E")); + assert!(scan.confirmed_facts[1].contains("fact-D")); + assert!(scan.confirmed_facts[2].contains("fact-C")); + assert!(scan.confirmed_facts[3].contains("fact-B")); + } + + #[test] + fn scan_skips_error_results() { + let messages = vec![ + tool_result_msg("good-A"), + tool_result_msg("Error: bad"), + tool_result_msg("good-B"), + ]; + let scan = scan_canonical_inputs(&messages, false); + assert_eq!(scan.confirmed_facts.len(), 2); + assert!(scan.confirmed_facts[0].contains("good-B")); + assert!(scan.confirmed_facts[1].contains("good-A")); + } + + #[test] + fn scan_finds_latest_verified_user_message() { + let messages = vec![ + user_text_msg("first"), + user_with_verified_replay("verified"), + user_text_msg("third"), + ]; + let scan = scan_canonical_inputs(&messages, true); + // The verified marker is on the *middle* message, not the most + // recent. The scan should report its actual position. + assert_eq!(scan.latest_verified_user_idx, Some(1)); + // The goal still points at the most recent user text. + assert!(scan.goal.as_deref().unwrap_or("").contains("third")); + } + + #[test] + fn scan_handles_empty_input() { + let scan = scan_canonical_inputs(&[], false); + assert!(scan.goal.is_none()); + assert!(scan.latest_verified_user_idx.is_none()); + assert!(scan.latest_user_text_idx.is_none()); + assert!(scan.confirmed_facts.is_empty()); + } + + #[test] + fn scan_early_exits_when_complete() { + // 1000 tool results — the scan should stop walking once the + // first 4 facts and a goal are found. We can't directly assert + // "didn't visit every element" without instrumentation, but the + // call must return promptly with the right slice. We pass + // `find_verified=false` so the scan does not have to keep + // walking looking for a verified user message that isn't there. + let mut messages: Vec = (0..1000) + .map(|i| tool_result_msg(&format!("fact-{i}"))) + .collect(); + // Most recent user message comes last. + messages.push(user_text_msg("goal")); + let scan = scan_canonical_inputs(&messages, false); + assert!(scan.goal.as_deref().unwrap_or("").contains("goal")); + assert_eq!(scan.confirmed_facts.len(), 4); + } +} diff --git a/crates/tui/src/core/engine/context.rs b/crates/tui/src/core/engine/context.rs index 08ce9004d..86e97f0d4 100644 --- a/crates/tui/src/core/engine/context.rs +++ b/crates/tui/src/core/engine/context.rs @@ -525,10 +525,12 @@ pub(super) fn extract_compaction_summary_prompt( } } +#[allow(dead_code)] // exposed for future engine-side callers; current call path goes through compaction::estimate_input_tokens_conservative via token_estimate_cache. fn estimate_text_tokens_conservative(text: &str) -> usize { text.chars().count().div_ceil(3) } +#[allow(dead_code)] // see estimate_text_tokens_conservative above fn estimate_system_tokens_conservative(system: Option<&SystemPrompt>) -> usize { match system { Some(SystemPrompt::Text(text)) => estimate_text_tokens_conservative(text), @@ -540,6 +542,7 @@ fn estimate_system_tokens_conservative(system: Option<&SystemPrompt>) -> usize { } } +#[allow(dead_code)] // see estimate_text_tokens_conservative above pub(super) fn estimate_input_tokens_conservative( messages: &[Message], system: Option<&SystemPrompt>, diff --git a/crates/tui/src/core/engine/handle.rs b/crates/tui/src/core/engine/handle.rs index 1ed7e95d3..4f8a44594 100644 --- a/crates/tui/src/core/engine/handle.rs +++ b/crates/tui/src/core/engine/handle.rs @@ -51,6 +51,24 @@ impl EngineHandle { } } + /// Pause or resume the current pausable command. + pub fn set_paused(&self, paused: bool) { + match self.shared_paused.lock() { + Ok(mut slot) => *slot = paused, + Err(poisoned) => *poisoned.into_inner() = paused, + } + } + + /// Check whether the engine pause gate is set. + #[cfg(test)] + #[must_use] + pub fn is_paused(&self) -> bool { + match self.shared_paused.lock() { + Ok(slot) => *slot, + Err(poisoned) => *poisoned.into_inner(), + } + } + /// Approve a pending tool call pub async fn approve_tool_call(&self, id: impl Into) -> Result<()> { self.tx_approval diff --git a/crates/tui/src/core/engine/streaming.rs b/crates/tui/src/core/engine/streaming.rs index 0da4d5aea..35adca04f 100644 --- a/crates/tui/src/core/engine/streaming.rs +++ b/crates/tui/src/core/engine/streaming.rs @@ -22,26 +22,6 @@ pub(super) struct ToolUseState { pub(super) input_buffer: String, } -/// Default maximum time to wait for a single stream chunk before assuming a stall. -/// **This is the idle timeout** — it resets on every SSE chunk, so long -/// thinking turns that ARE producing reasoning_content stay alive. Only a -/// genuine `chunk_timeout` window of silence kills the stream. -const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 300; -const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1; -const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600; -const STREAM_IDLE_TIMEOUT_ENV: &str = "DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS"; - -/// Reads the shared stream idle-timeout override used by the SSE client. -pub(super) fn stream_chunk_timeout_secs() -> u64 { - stream_chunk_timeout_secs_from_env(std::env::var(STREAM_IDLE_TIMEOUT_ENV).ok().as_deref()) -} - -fn stream_chunk_timeout_secs_from_env(value: Option<&str>) -> u64 { - value - .and_then(|v| v.parse::().ok()) - .unwrap_or(DEFAULT_STREAM_CHUNK_TIMEOUT_SECS) - .clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS) -} /// Maximum total bytes of text/thinking content before aborting the stream. pub(super) const STREAM_MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024; // 10 MB /// Sanity backstop for total stream wall-clock duration. **Not** a routine @@ -150,20 +130,3 @@ pub(crate) fn filter_tool_call_delta(delta: &str, in_tool_call: &mut bool) -> St output } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn stream_chunk_timeout_defaults_and_clamps_env_values() { - assert_eq!(stream_chunk_timeout_secs_from_env(None), 300); - assert_eq!( - stream_chunk_timeout_secs_from_env(Some("not-a-number")), - 300 - ); - assert_eq!(stream_chunk_timeout_secs_from_env(Some("0")), 1); - assert_eq!(stream_chunk_timeout_secs_from_env(Some("90")), 90); - assert_eq!(stream_chunk_timeout_secs_from_env(Some("99999")), 3600); - } -} diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index bed3276a0..673aa5e57 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -3,6 +3,7 @@ use super::*; use super::context::TURN_MAX_OUTPUT_TOKENS; use crate::models::SystemBlock; use crate::test_support::lock_test_env; +use crate::tools::plan::{PlanItemArg, PlanSnapshot, StepStatus}; use crate::tools::spec::ToolCapability; use serde_json::json; use std::collections::{HashMap, HashSet}; @@ -84,6 +85,45 @@ fn build_engine_with_capacity(capacity: CapacityControllerConfig) -> Engine { engine } +#[test] +fn structured_state_block_includes_rich_plan_artifact() { + let state = StructuredState { + mode_label: "Plan".to_string(), + workspace: PathBuf::from("/workspace/codewhale"), + cwd: None, + working_set_summary: None, + todo_snapshot: None, + plan_snapshot: Some(PlanSnapshot { + objective: Some("Make Plan mode reviewable".to_string()), + context_summary: Some("Grounded in issue #2691".to_string()), + sources_used: vec!["gh issue view 2691".to_string()], + critical_files: vec!["crates/tui/src/tools/plan.rs".to_string()], + constraints: vec!["Preserve legacy payloads".to_string()], + recommended_approach: Some("Enrich update_plan".to_string()), + verification_plan: Some("Run focused tests".to_string()), + risks_and_unknowns: Some("Replay may drift".to_string()), + handoff_packet: Some("Next agent should inspect replay".to_string()), + items: vec![PlanItemArg { + step: "Render rich artifact".to_string(), + status: StepStatus::InProgress, + }], + ..PlanSnapshot::default() + }), + subagent_snapshots: Vec::new(), + }; + + let block = state.to_system_block().expect("fork state block"); + + assert!(block.contains("Objective: Make Plan mode reviewable")); + assert!(block.contains("Context: Grounded in issue #2691")); + assert!(block.contains("Source: gh issue view 2691")); + assert!(block.contains("Critical file: crates/tui/src/tools/plan.rs")); + assert!(block.contains("Constraint: Preserve legacy payloads")); + assert!(block.contains("Verification plan: Run focused tests")); + assert!(block.contains("Handoff packet: Next agent should inspect replay")); + assert!(block.contains("- [~] Render rich artifact")); +} + #[test] fn env_only_auth_error_gets_recovery_hint() { let _guard = lock_test_env(); @@ -263,7 +303,7 @@ fn refresh_system_prompt_uses_runtime_goal_state() { goal.create("Close the runtime goal loop".to_string(), None); } - engine.refresh_system_prompt(AppMode::Agent); + engine.refresh_system_prompt(); let prompt = match engine.session.system_prompt { Some(SystemPrompt::Text(text)) => text, Some(SystemPrompt::Blocks(blocks)) => blocks @@ -465,116 +505,36 @@ fn tool_exec_outcome_tracks_duration() { #[test] fn core_native_tools_stay_loaded_in_yolo_mode() { let always_load = HashSet::new(); - assert!(!should_default_defer_tool( - "exec_shell", - AppMode::Yolo, - &always_load - )); + assert!(!should_default_defer_tool("exec_shell", &always_load)); // git_blame remains deferred (read-only git history beyond log/show/diff). - assert!(should_default_defer_tool( - "git_blame", - AppMode::Yolo, - &always_load - )); + assert!(should_default_defer_tool("git_blame", &always_load)); } #[test] fn non_yolo_mode_retains_default_defer_policy() { let always_load = HashSet::new(); - assert!(!should_default_defer_tool( - "exec_shell", - AppMode::Agent, - &always_load - )); - assert!(!should_default_defer_tool( - "edit_file", - AppMode::Agent, - &always_load - )); - assert!(!should_default_defer_tool( - "apply_patch", - AppMode::Agent, - &always_load - )); - assert!(!should_default_defer_tool( - "fetch_url", - AppMode::Agent, - &always_load - )); - assert!(!should_default_defer_tool( - "git_diff", - AppMode::Agent, - &always_load - )); + assert!(!should_default_defer_tool("exec_shell", &always_load)); + assert!(!should_default_defer_tool("edit_file", &always_load)); + assert!(!should_default_defer_tool("apply_patch", &always_load)); + assert!(!should_default_defer_tool("fetch_url", &always_load)); + assert!(!should_default_defer_tool("git_diff", &always_load)); // #2654: read-only git history joins the active set. - assert!(!should_default_defer_tool( - "git_log", - AppMode::Agent, - &always_load - )); - assert!(!should_default_defer_tool( - "git_show", - AppMode::Agent, - &always_load - )); - assert!(!should_default_defer_tool( - "git_status", - AppMode::Agent, - &always_load - )); - assert!(!should_default_defer_tool( - "run_tests", - AppMode::Agent, - &always_load - )); - assert!(!should_default_defer_tool( - "agent_open", - AppMode::Agent, - &always_load - )); + assert!(!should_default_defer_tool("git_log", &always_load)); + assert!(!should_default_defer_tool("git_show", &always_load)); + assert!(!should_default_defer_tool("git_status", &always_load)); + assert!(!should_default_defer_tool("run_tests", &always_load)); + assert!(!should_default_defer_tool("agent_open", &always_load)); // #2605: the fetch/close side of the sub-agent surface must also stay // active so a first `agent_eval`/`agent_close` executes instead of // hydrating its schema and forcing a double-invoke. - assert!(!should_default_defer_tool( - "agent_eval", - AppMode::Agent, - &always_load - )); - assert!(!should_default_defer_tool( - "agent_close", - AppMode::Agent, - &always_load - )); - assert!(!should_default_defer_tool( - "read_file", - AppMode::Agent, - &always_load - )); - assert!(!should_default_defer_tool( - "web_search", - AppMode::Agent, - &always_load - )); - assert!(!should_default_defer_tool( - "write_file", - AppMode::Agent, - &always_load - )); - assert!(!should_default_defer_tool( - "task_shell_start", - AppMode::Agent, - &always_load - )); - assert!(!should_default_defer_tool( - "task_shell_wait", - AppMode::Agent, - &always_load - )); - assert!(should_default_defer_tool( - "git_blame", - AppMode::Agent, - &always_load - )); + assert!(!should_default_defer_tool("agent_eval", &always_load)); + assert!(!should_default_defer_tool("agent_close", &always_load)); + assert!(!should_default_defer_tool("read_file", &always_load)); + assert!(!should_default_defer_tool("web_search", &always_load)); + assert!(!should_default_defer_tool("write_file", &always_load)); + assert!(!should_default_defer_tool("task_shell_start", &always_load)); + assert!(!should_default_defer_tool("task_shell_wait", &always_load)); + assert!(should_default_defer_tool("git_blame", &always_load)); } #[test] @@ -775,11 +735,7 @@ fn agent_catalog_keeps_edit_file_loaded_when_fuzz_is_omitted() { #[test] fn tools_always_load_overrides_default_native_deferral() { let always_load = HashSet::from(["git_blame".to_string()]); - assert!(!should_default_defer_tool( - "git_blame", - AppMode::Agent, - &always_load - )); + assert!(!should_default_defer_tool("git_blame", &always_load)); } #[test] @@ -1755,15 +1711,20 @@ async fn change_mode_refreshes_session_prompt_and_updates_session() { .await .expect("send change mode"); - let prompt = { + let (_prompt, messages) = { let mut rx = handle.rx_event.write().await; loop { let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) .await .expect("session update after mode switch") .expect("event"); - if let Event::SessionUpdated { system_prompt, .. } = event { - break match system_prompt.expect("system prompt") { + if let Event::SessionUpdated { + system_prompt, + messages, + .. + } = event + { + let prompt = match system_prompt.expect("system prompt") { SystemPrompt::Text(text) => text, SystemPrompt::Blocks(blocks) => blocks .into_iter() @@ -1771,17 +1732,102 @@ async fn change_mode_refreshes_session_prompt_and_updates_session() { .collect::>() .join("\n"), }; + break (prompt, messages); } } }; run.abort(); - assert!(prompt.contains("Mode: YOLO")); - assert!(prompt.contains("Approval Policy: Auto")); + assert!( + messages.iter().all(|message| message.role != "system"), + "mode switch must not persist appended system messages: {messages:?}" + ); + assert!( + messages.iter().all(|message| { + message.content.iter().all(|block| { + !matches!( + block, + ContentBlock::Text { text, .. } + if text.contains(" tag carries the mode in every request, + // so no separate persistence of a mode_change runtime event is needed). + let mut rx = handle.rx_event.write().await; + let session_updated = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("session update after mode switch") + .expect("event"); + let Event::SessionUpdated { messages, .. } = session_updated else { + panic!("should emit SessionUpdated after mode change, got: {session_updated:?}"); }; assert!( - text.contains("Agent mode") && text.contains("YOLO mode"), - "should contain both previous and new mode: {text}" + messages.iter().all(|message| { + message.content.iter().all(|block| { + !matches!( + block, + ContentBlock::Text { text, .. } + if text.contains(" text.clone(), @@ -2210,11 +2256,11 @@ fn working_set_reaches_model_as_turn_metadata() { engine.session.add_message(user_msg); let messages = engine.messages_with_turn_metadata(); - let first_block = messages - .last() - .and_then(|message| message.content.first()) + let last_block = messages + .first() + .and_then(|message| message.content.last()) .expect("turn metadata block"); - let ContentBlock::Text { text, .. } = first_block else { + let ContentBlock::Text { text, .. } = last_block else { panic!("expected text metadata block"); }; assert!(text.starts_with("\n")); @@ -2235,11 +2281,11 @@ fn turn_metadata_includes_current_local_date_without_working_set() { engine.session.add_message(user_msg); let messages = engine.messages_with_turn_metadata(); - let first_block = messages - .last() - .and_then(|message| message.content.first()) + let last_block = messages + .first() + .and_then(|message| message.content.last()) .expect("turn metadata block"); - let ContentBlock::Text { text, .. } = first_block else { + let ContentBlock::Text { text, .. } = last_block else { panic!("expected text metadata block"); }; @@ -2266,8 +2312,8 @@ fn turn_metadata_includes_auto_model_route() { Some("max"), true, ); - let first_block = user_msg.content.first().expect("turn metadata block"); - let ContentBlock::Text { text, .. } = first_block else { + let last_block = user_msg.content.last().expect("turn metadata block"); + let ContentBlock::Text { text, .. } = last_block else { panic!("expected text metadata block"); }; @@ -2294,8 +2340,11 @@ fn turn_metadata_includes_current_mode() { None, false, ); - let first_block = user_msg.content.first().expect("turn metadata block"); - let ContentBlock::Text { text, .. } = first_block else { + // turn_meta was relocated to the tail of the user message in #2517 + // to keep the leading bytes (user input) stable across date / model + // route / working-set changes. + let last_block = user_msg.content.last().expect("turn metadata block"); + let ContentBlock::Text { text, .. } = last_block else { panic!("expected text metadata block"); }; @@ -2314,10 +2363,11 @@ fn turn_metadata_mode_updates_with_change_mode_op() { }; let (mut engine, _handle) = Engine::new(config, &Config::default()); - // In agent mode by default + // In agent mode by default. The turn_meta block now sits at the + // *tail* of the user message (see #2517) so we read `content.last()`. let msg = engine.user_text_message_with_turn_metadata("hello".to_string()); - let first_block = msg.content.first().expect("turn metadata block"); - let ContentBlock::Text { text, .. } = first_block else { + let last_block = msg.content.last().expect("turn metadata block"); + let ContentBlock::Text { text, .. } = last_block else { panic!("expected text metadata block"); }; assert!( @@ -2328,8 +2378,8 @@ fn turn_metadata_mode_updates_with_change_mode_op() { // Switch to YOLO — user_text_message_with_turn_metadata should reflect the new mode engine.current_mode = AppMode::Yolo; let msg = engine.user_text_message_with_turn_metadata("hello again".to_string()); - let first_block = msg.content.first().expect("turn metadata block"); - let ContentBlock::Text { text, .. } = first_block else { + let last_block = msg.content.last().expect("turn metadata block"); + let ContentBlock::Text { text, .. } = last_block else { panic!("expected text metadata block"); }; assert!( @@ -2339,29 +2389,54 @@ fn turn_metadata_mode_updates_with_change_mode_op() { } #[test] -fn mode_change_runtime_message_format() { - let msg = Engine::mode_change_runtime_message(AppMode::Agent, AppMode::Yolo); - - assert_eq!(msg.role, "user"); - let ContentBlock::Text { text, .. } = msg.content.first().expect("text block") else { - panic!("expected text block"); +fn current_mode_field_assignment_takes_effect_synchronously() { + // Basic unit-level invariant: the current_mode field mutates as expected + // and the per-turn tag reflects the current mode. + // Op::ChangeMode dispatch through the run loop is exercised by the + // integration test change_mode_op_updates_current_mode_and_emits_status. + let tmp = tempdir().expect("tempdir"); + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + model: "deepseek-v4-pro".to_string(), + ..Default::default() + }; + let (mut engine, _handle) = Engine::new(config, &Config::default()); + assert_eq!(engine.current_mode, AppMode::Agent); + + // Verify runtime tag in Agent mode + let agent_messages = engine.messages_with_turn_metadata(); + let agent_tag = agent_messages.last().expect("runtime tag message"); + let ContentBlock::Text { + text: agent_text, .. + } = agent_tag.content.first().expect("text block") + else { + panic!("expected text runtime tag in Agent mode"); }; - - assert!( - text.contains("codewhale:runtime_event"), - "should be a runtime event message" - ); assert!( - text.contains("kind=\"mode_change\""), - "should have mode_change kind" + agent_text.contains("mode=\"agent\""), + "Agent mode should produce runtime tag with mode=\"agent\", got: {agent_text}" ); + + // Switch to YOLO + engine.current_mode = AppMode::Yolo; + assert_eq!(engine.current_mode, AppMode::Yolo); + + // Verify runtime tag reflects the YOLO mode with auto approval + let yolo_messages = engine.messages_with_turn_metadata(); + let yolo_tag = yolo_messages.last().expect("runtime tag message"); + let ContentBlock::Text { + text: yolo_text, .. + } = yolo_tag.content.first().expect("text block") + else { + panic!("expected text runtime tag in YOLO mode"); + }; assert!( - text.contains("Agent mode") && text.contains("YOLO mode"), - "should mention both previous and new mode: {text}" + yolo_text.contains("mode=\"yolo\""), + "YOLO mode should produce runtime tag with mode=\"yolo\", got: {yolo_text}" ); assert!( - text.contains("Re-evaluate"), - "should tell agent to re-evaluate blocked operations: {text}" + yolo_text.contains("approval=\"auto\""), + "YOLO mode should project auto approval in runtime tag, got: {yolo_text}" ); } @@ -2377,10 +2452,10 @@ fn user_text_message_keeps_current_turn_input_after_turn_metadata() { let user_msg = engine.user_text_message_with_turn_metadata("explain the cache metrics".to_string()); - let last_text = user_msg + // User text is now at position 0, turn_meta at position 1. + let first_text = user_msg .content .iter() - .rev() .find_map(|block| { if let ContentBlock::Text { text, .. } = block { Some(text.as_str()) @@ -2389,7 +2464,7 @@ fn user_text_message_keeps_current_turn_input_after_turn_metadata() { } }) .expect("user text block"); - assert_eq!(last_text, "explain the cache metrics"); + assert_eq!(first_text, "explain the cache metrics"); } #[test] @@ -2411,7 +2486,16 @@ fn messages_with_turn_metadata_preserves_stored_messages_for_prefix_cache() { let first_user = engine.user_text_message_with_turn_metadata("inspect src/lib.rs".to_string()); engine.session.add_message(first_user.clone()); let first_request = engine.messages_with_turn_metadata(); - assert_eq!(first_request, engine.session.messages); + assert_eq!( + &first_request[..engine.session.messages.len()], + engine.session.messages.as_slice() + ); + assert_eq!(first_request.len(), engine.session.messages.len() + 1); + assert_eq!(first_request.first(), Some(&first_user)); + assert_eq!( + first_request.last().map(|message| message.role.as_str()), + Some("user") + ); engine.session.add_message(Message { role: "assistant".to_string(), @@ -2428,14 +2512,24 @@ fn messages_with_turn_metadata_preserves_stored_messages_for_prefix_cache() { engine.session.add_message(second_user); let second_request = engine.messages_with_turn_metadata(); - assert_eq!(second_request, engine.session.messages); + assert_eq!( + &second_request[..engine.session.messages.len()], + engine.session.messages.as_slice() + ); + assert_eq!(second_request.len(), engine.session.messages.len() + 1); assert_eq!(second_request.first(), Some(&first_user)); + let runtime = second_request.last().expect("runtime prompt"); + let ContentBlock::Text { text, .. } = runtime.content.first().expect("runtime prompt text") + else { + panic!("expected runtime prompt text"); + }; + assert!(text.contains("` must -/// be stored only on actual user-text messages, not retroactively added -/// to tool-result messages at request time. +/// be stored only on actual user-text messages. Request-time runtime metadata +/// is appended separately and must not mutate tool-result messages. #[test] fn turn_metadata_skips_tool_result_messages() { let tmp = tempdir().expect("tempdir"); @@ -2478,9 +2572,11 @@ fn turn_metadata_skips_tool_result_messages() { let messages = engine.messages_with_turn_metadata(); - // The trailing message is the tool result and MUST be untouched — + // The stored trailing message is the tool result and MUST be untouched — // no Text block sneaking in front of the ToolResult block. - let trailing = messages.last().expect("trailing message"); + let trailing = messages + .get(messages.len().saturating_sub(2)) + .expect("stored trailing message"); assert_eq!(trailing.role, "user"); assert_eq!(trailing.content.len(), 1); assert!(matches!( @@ -2488,20 +2584,72 @@ fn turn_metadata_skips_tool_result_messages() { Some(ContentBlock::ToolResult { .. }) )); - // The earlier real user message already carries the turn_meta prefix. + // The earlier real user message carries user text first, turn_meta last. let real_user = messages.first().expect("first user message"); assert_eq!(real_user.role, "user"); let ContentBlock::Text { text, .. } = real_user.content.first().expect("user text content") else { panic!("expected Text block on real user message"); }; - assert!(text.starts_with("\n")); - assert!(text.contains("src/lib.rs")); + assert_eq!(text, "inspect src/lib.rs"); + // turn_meta is at the tail of the content array. + let last_block = real_user.content.last().expect("turn_meta block"); + let ContentBlock::Text { text: meta, .. } = last_block else { + panic!("expected Text block for turn_meta at tail"); + }; + assert!(meta.starts_with("\n")); + assert!(meta.contains("src/lib.rs")); + assert!( + matches!( + messages.last().and_then(|message| message.content.first()), + Some(ContentBlock::Text { text, .. }) if text.contains("\n"), + "turn_meta must be at the tail" + ); + assert!( + meta.contains("Current local date:"), + "turn_meta must contain the date" + ); } /// When the turn is mid-execution and the trailing user message is a -/// tool result, no turn_meta is injected at request time. The working_set -/// surfaces again on the next stored user-text message. +/// tool result, no turn_meta is injected into that tool-result message. The +/// working_set surfaces again on the next stored user-text message. #[test] fn turn_metadata_skips_when_only_tool_results_trail() { let tmp = tempdir().expect("tempdir"); @@ -2534,14 +2682,21 @@ fn turn_metadata_skips_when_only_tool_results_trail() { let messages = engine.messages_with_turn_metadata(); - // Returned unchanged: the single tool-result message, no Text - // prefix, content length == 1. - let only = messages.last().expect("trailing message"); + // Stored tool-result message is unchanged: no Text prefix, content length == 1. + let only = messages.first().expect("stored tool result message"); assert_eq!(only.content.len(), 1); assert!(matches!( only.content.first(), Some(ContentBlock::ToolResult { .. }) )); + assert_eq!(messages.len(), 2); + assert!( + matches!( + messages.last().and_then(|message| message.content.first()), + Some(ContentBlock::Text { text, .. }) if text.contains(" text.clone(), - other => panic!("expected text block, got {other:?}"), + // turn_meta is now at the tail of the content array (PR #2517). + let meta = match last.content.last() { + Some(crate::models::ContentBlock::Text { text, .. }) => text.clone(), + other => panic!("expected text block at tail, got {other:?}"), }; assert!(meta.starts_with("\n")); let diagnostic_text = last diff --git a/crates/tui/src/core/engine/token_estimate_cache.rs b/crates/tui/src/core/engine/token_estimate_cache.rs new file mode 100644 index 000000000..94d191add --- /dev/null +++ b/crates/tui/src/core/engine/token_estimate_cache.rs @@ -0,0 +1,312 @@ +//! Process-local memoization for [`crate::compaction::estimate_input_tokens_conservative`]. +//! +//! The token estimator walks the full [`crate::models::Message`] history and the +//! active system prompt, which is by far the most expensive per-turn CPU cost +//! in the engine hot path. The same input data is queried from at least five +//! sites per turn: capacity pre/post tool checkpoints, error escalation, +//! the seam manager, and the trimmed-message budget check, plus four more +//! from the TUI footer, `/status`, `/debug`, and the context inspector. +//! +//! Without memoization, a 200-message history with 5 KB of tool results costs +//! ~2 ms per call; that is 20 ms of pure waste on a single turn. The estimator +//! itself is a pure function of `(messages, system_prompt)`, so a +//! content-versioned cache is safe: the caller bumps `messages_revision` +//! on every mutation, and we also include a fast fingerprint of the system +//! prompt as part of the key. +//! +//! The cache is process-local only — cross-session persistence is intentionally +//! out of scope (see PR #2520 for the cross-session prompt-base disk cache). + +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +use crate::compaction::estimate_input_tokens_conservative; +use crate::models::{Message, SystemPrompt}; + +/// Default capacity for the rolling audit ring. Sized so a 64-entry window +/// covers a full capacity controller observation cycle without unbounded +/// growth on long-running sessions. +const AUDIT_RING_CAPACITY: usize = 64; + +/// Process-local memoization for `estimate_input_tokens_conservative`. +/// +/// The cache is keyed on the `(messages_revision, system_fingerprint)` +/// pair, both of which the engine bumps on every content change. On a hit +/// the previously stored token estimate is returned without re-walking the +/// message list. On a miss, the estimator runs and the result is stored +/// alongside the audit ring entry. +#[derive(Debug, Default, Clone)] +pub struct TokenEstimateCache { + /// Monotonic counter bumped by the engine on every message mutation. + messages_revision: u64, + /// Stable 64-bit hash of the current system prompt text. Computed once + /// per `lookup_or_compute` call when the cache misses. + system_fingerprint: u64, + /// Cached token count, valid iff both keys match the current inputs. + cached_tokens: Option, + /// Audit ring of recent (revision, tokens) pairs. The most recent entry + /// is the tail; the oldest is dropped when capacity is exceeded. Used by + /// observability to surface cache effectiveness to `/status`. + audit_ring: Vec<(u64, usize)>, + /// Number of cache hits since the cache was last cleared. Saturates at + /// `u64::MAX` (effectively never in practice). + hits: u64, + /// Number of cache misses since the cache was last cleared. + misses: u64, +} + +impl TokenEstimateCache { + /// Construct a fresh, empty cache. `messages_revision` defaults to 0; the + /// engine must call [`bump_messages_revision`](Self::bump_messages_revision) + /// whenever a mutation occurs so the next lookup correctly invalidates. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Returns the cached token estimate, recomputing on miss. + /// + /// `messages_revision` is the engine's monotonic counter; bump it on + /// every add/remove/clear. `system_prompt` may be `None`. `messages` is + /// borrowed for the duration of the call so a miss can re-tokenize. + pub fn lookup_or_compute( + &mut self, + messages_revision: u64, + system_prompt: Option<&SystemPrompt>, + messages: &[Message], + ) -> usize { + let system_fingerprint = fingerprint_system_prompt(system_prompt); + + if self.messages_revision == messages_revision + && self.system_fingerprint == system_fingerprint + && let Some(tokens) = self.cached_tokens + { + self.hits = self.hits.saturating_add(1); + return tokens; + } + + let tokens = estimate_input_tokens_conservative(messages, system_prompt); + self.messages_revision = messages_revision; + self.system_fingerprint = system_fingerprint; + self.cached_tokens = Some(tokens); + self.misses = self.misses.saturating_add(1); + self.push_audit(messages_revision, tokens); + tokens + } + + /// Record a messages-revision bump. The engine calls this whenever + /// `session.messages` is mutated. Calling it with a value smaller than + /// the current value is a no-op (the cache is monotonic). + #[allow(dead_code)] // exposed for future wiring of /clear and reset paths; tests exercise it + pub fn bump_messages_revision(&mut self, revision: u64) { + if revision > self.messages_revision { + self.messages_revision = revision; + self.cached_tokens = None; + } + } + + /// Forget all cached state. Used by `/clear` and session reset paths. + #[allow(dead_code)] // exposed for future wiring of /clear and reset paths; tests exercise it + pub fn invalidate(&mut self) { + self.cached_tokens = None; + self.system_fingerprint = 0; + self.audit_ring.clear(); + self.hits = 0; + self.misses = 0; + } + + /// Returns `(hits, misses)` counters since the last `invalidate` call. + #[allow(dead_code)] // surfaced via /status in a follow-up; tests exercise it + #[must_use] + pub fn stats(&self) -> (u64, u64) { + (self.hits, self.misses) + } + + /// Returns the most recent `(revision, tokens)` audit entries, newest + /// first. Bounded by [`AUDIT_RING_CAPACITY`]. + #[allow(dead_code)] // surfaced via /status in a follow-up; tests exercise it + #[must_use] + pub fn recent_audit(&self) -> &[(u64, usize)] { + &self.audit_ring + } + + fn push_audit(&mut self, revision: u64, tokens: usize) { + if self.audit_ring.len() >= AUDIT_RING_CAPACITY { + self.audit_ring.remove(0); + } + self.audit_ring.push((revision, tokens)); + } +} + +/// Stable 64-bit hash of the system prompt text. Walks the same shape the +/// estimator consumes: a `Text` variant or a list of `Blocks`. Returns 0 +/// for `None` so the empty case is distinguishable but cheap to compare. +fn fingerprint_system_prompt(system: Option<&SystemPrompt>) -> u64 { + let Some(system) = system else { + return 0; + }; + let mut hasher = DefaultHasher::new(); + match system { + SystemPrompt::Text(text) => { + "text".hash(&mut hasher); + text.hash(&mut hasher); + } + SystemPrompt::Blocks(blocks) => { + "blocks".hash(&mut hasher); + blocks.len().hash(&mut hasher); + for block in blocks { + block.block_type.hash(&mut hasher); + block.text.hash(&mut hasher); + } + } + } + hasher.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{ContentBlock, SystemBlock}; + + fn user_text(s: &str) -> Message { + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: s.to_string(), + cache_control: None, + }], + } + } + + fn sys_text(s: &str) -> SystemPrompt { + SystemPrompt::Text(s.to_string()) + } + + #[test] + fn first_call_is_a_miss() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("hello world")]; + let tokens = cache.lookup_or_compute(1, None, &messages); + let (hits, misses) = cache.stats(); + assert!(tokens > 0); + assert_eq!(hits, 0); + assert_eq!(misses, 1); + } + + #[test] + fn repeated_call_with_same_revision_is_a_hit() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("hello world")]; + let _ = cache.lookup_or_compute(1, None, &messages); + let _ = cache.lookup_or_compute(1, None, &messages); + let (hits, misses) = cache.stats(); + assert_eq!(hits, 1); + assert_eq!(misses, 1); + } + + #[test] + fn revision_bump_invalidates() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("hi")]; + let a = cache.lookup_or_compute(1, None, &messages); + let b = cache.lookup_or_compute(2, None, &messages); + let (hits, misses) = cache.stats(); + // Both calls were misses (different revisions), neither hit the cache. + assert_eq!(a, b); + assert_eq!(hits, 0); + assert_eq!(misses, 2); + } + + #[test] + fn system_prompt_change_invalidates() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("hi")]; + let _ = cache.lookup_or_compute(1, Some(&sys_text("alpha")), &messages); + let _ = cache.lookup_or_compute(1, Some(&sys_text("beta")), &messages); + let (hits, misses) = cache.stats(); + assert_eq!(hits, 0); + assert_eq!(misses, 2); + } + + #[test] + fn bump_messages_revision_clears_cache() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("x")]; + let _ = cache.lookup_or_compute(1, None, &messages); + cache.bump_messages_revision(2); + let _ = cache.lookup_or_compute(2, None, &messages); + let (hits, misses) = cache.stats(); + assert_eq!(hits, 0); + assert_eq!(misses, 2); + } + + #[test] + fn bump_to_smaller_revision_is_noop() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("x")]; + let _ = cache.lookup_or_compute(5, None, &messages); + cache.bump_messages_revision(2); + // revision went down, cache should still be valid for revision 5 + let _ = cache.lookup_or_compute(5, None, &messages); + let (hits, _) = cache.stats(); + assert_eq!(hits, 1, "downward revision bumps must not invalidate"); + } + + #[test] + fn invalidate_resets_state() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("x")]; + let _ = cache.lookup_or_compute(1, None, &messages); + let _ = cache.lookup_or_compute(1, None, &messages); + cache.invalidate(); + let (hits, misses) = cache.stats(); + assert_eq!(hits, 0); + assert_eq!(misses, 0); + } + + #[test] + fn blocks_system_prompt_yields_distinct_fingerprint() { + let blocks_a = SystemPrompt::Blocks(vec![SystemBlock { + block_type: "text".to_string(), + text: "alpha".to_string(), + cache_control: None, + }]); + let blocks_b = SystemPrompt::Blocks(vec![SystemBlock { + block_type: "text".to_string(), + text: "beta".to_string(), + cache_control: None, + }]); + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("hi")]; + let _ = cache.lookup_or_compute(1, Some(&blocks_a), &messages); + let _ = cache.lookup_or_compute(1, Some(&blocks_b), &messages); + let (hits, misses) = cache.stats(); + assert_eq!(hits, 0); + assert_eq!(misses, 2); + } + + #[test] + fn audit_ring_records_recent_pairs() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("hi")]; + for rev in 1..=5 { + let _ = cache.lookup_or_compute(rev, None, &messages); + } + let ring = cache.recent_audit(); + assert_eq!(ring.len(), 5); + assert_eq!(ring.last().copied(), Some((5, ring.last().unwrap().1))); + } + + #[test] + fn audit_ring_bounded_by_capacity() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("hi")]; + for rev in 1..=(AUDIT_RING_CAPACITY + 10) as u64 { + let _ = cache.lookup_or_compute(rev, None, &messages); + } + let ring = cache.recent_audit(); + assert_eq!(ring.len(), AUDIT_RING_CAPACITY); + // newest entry should be the most recent revision we asked for + assert_eq!(ring.last().unwrap().0, (AUDIT_RING_CAPACITY + 10) as u64); + } +} diff --git a/crates/tui/src/core/engine/tool_catalog.rs b/crates/tui/src/core/engine/tool_catalog.rs index 9ddc08620..ebba11b44 100644 --- a/crates/tui/src/core/engine/tool_catalog.rs +++ b/crates/tui/src/core/engine/tool_catalog.rs @@ -67,11 +67,7 @@ pub(super) const DEFAULT_ACTIVE_NATIVE_TOOLS: &[&str] = &[ "write_file", ]; -pub(super) fn should_default_defer_tool( - name: &str, - _mode: AppMode, - always_load: &HashSet, -) -> bool { +pub(super) fn should_default_defer_tool(name: &str, always_load: &HashSet) -> bool { if always_load.contains(name) { return false; } @@ -85,13 +81,9 @@ pub(super) fn should_default_defer_tool( .any(|core_tool| core_tool == &name) } -pub(super) fn apply_native_tool_deferral( - catalog: &mut [Tool], - mode: AppMode, - always_load: &HashSet, -) { +pub(super) fn apply_native_tool_deferral(catalog: &mut [Tool], always_load: &HashSet) { for tool in catalog { - tool.defer_loading = Some(should_default_defer_tool(&tool.name, mode, always_load)); + tool.defer_loading = Some(should_default_defer_tool(&tool.name, always_load)); } } @@ -185,7 +177,7 @@ pub(super) fn build_model_tool_catalog( mode: AppMode, always_load: &HashSet, ) -> Vec { - apply_native_tool_deferral(&mut native_tools, mode, always_load); + apply_native_tool_deferral(&mut native_tools, always_load); apply_mcp_tool_deferral(&mut mcp_tools, mode); // Sort each partition by name for prefix-cache stability (#263). The // upstream `to_api_tools()` already sorts the registry's HashMap output; @@ -229,7 +221,6 @@ pub(super) fn ensure_advanced_tooling( allowed_callers: Some(vec!["direct".to_string()]), defer_loading: Some(should_default_defer_tool( CODE_EXECUTION_TOOL_NAME, - mode, always_load, )), input_examples: None, @@ -248,7 +239,7 @@ pub(super) fn ensure_advanced_tooling( && crate::dependencies::resolve_node().is_some() { let mut tool = crate::tools::js_execution::js_execution_tool_definition(); - tool.defer_loading = Some(should_default_defer_tool(&tool.name, mode, always_load)); + tool.defer_loading = Some(should_default_defer_tool(&tool.name, always_load)); catalog.push(tool); } diff --git a/crates/tui/src/core/engine/tool_execution.rs b/crates/tui/src/core/engine/tool_execution.rs index e24aae5b9..ae5e1b5ac 100644 --- a/crates/tui/src/core/engine/tool_execution.rs +++ b/crates/tui/src/core/engine/tool_execution.rs @@ -125,14 +125,30 @@ pub(super) fn emit_tool_audit(event: serde_json::Value) { }; let line = match serde_json::to_string(&event) { Ok(line) => line, - Err(_) => return, + Err(e) => { + tracing::error!("Failed to serialize tool audit event: {e}"); + return; + } }; let path = PathBuf::from(path); - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); + if let Some(parent) = path.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { + tracing::error!( + "Failed to create audit log directory {}: {e}", + parent.display() + ); + return; } - if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) { - let _ = writeln!(file, "{line}"); + match OpenOptions::new().create(true).append(true).open(&path) { + Ok(mut file) => { + if let Err(e) = writeln!(file, "{line}") { + tracing::error!("Failed to write to audit log {}: {e}", path.display()); + } + } + Err(e) => { + tracing::error!("Failed to open audit log {}: {e}", path.display()); + } } } diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index de71c5fa0..6e7d06912 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -105,7 +105,7 @@ impl Engine { } // Ensure system prompt is up to date with latest session states - self.refresh_system_prompt(mode); + self.refresh_system_prompt(); if turn.at_max_steps() { let _ = self @@ -469,8 +469,7 @@ impl Engine { // budget restarts with the fresh stream. let mut stream_start = Instant::now(); let mut stream_content_bytes: usize = 0; - let chunk_timeout_secs = stream_chunk_timeout_secs(); - let chunk_timeout = Duration::from_secs(chunk_timeout_secs); + let (chunk_timeout_secs, chunk_timeout) = stream_chunk_timeout_budget(&self.config); let max_duration = Duration::from_secs(STREAM_MAX_DURATION_SECS); // Process stream events @@ -1260,6 +1259,14 @@ impl Engine { } // Execute tools + if self.shared_paused.lock().is_ok_and(|paused| *paused) { + let _ = self + .tx_event + .send(Event::status("Request was Paused")) + .await; + return (TurnOutcomeStatus::Interrupted, None); + } + let tool_exec_lock = self.tool_exec_lock.clone(); let mcp_pool = if tool_uses .iter() @@ -2150,7 +2157,6 @@ impl Engine { if self .run_capacity_post_tool_checkpoint( turn, - mode, tool_registry, tool_exec_lock.clone(), mcp_pool.clone(), @@ -2182,7 +2188,6 @@ impl Engine { if self .run_capacity_error_escalation_checkpoint( turn, - mode, step_error_count, consecutive_tool_error_steps, &step_error_categories, @@ -2255,11 +2260,15 @@ impl Engine { } pub(super) fn messages_with_turn_metadata(&self) -> Vec { - // `` is stored on user-text messages when the message is - // appended. Do not rewrite historical messages at request time: doing - // so makes the API prefix differ from the bytes sent in earlier turns - // and destroys DeepSeek's KV prefix cache reuse. - self.session.messages.clone() + // Keep stored history byte-stable and provider-compatible: runtime + // mode/approval contracts are projected as a transient user message + // at request time instead of being persisted as appended system + // messages. This preserves the stable prefix through all stored + // messages while avoiding strict chat templates that only allow + // system messages at messages[0]. + let mut messages = self.session.messages.clone(); + messages.push(self.runtime_prompt_message()); + messages } } @@ -2293,6 +2302,29 @@ fn should_hold_turn_for_subagents(queued_completions: usize, running_children: u queued_completions > 0 || running_children > 0 } +fn stream_chunk_timeout_budget(config: &EngineConfig) -> (u64, Duration) { + let secs = config.stream_chunk_timeout.as_secs(); + (secs, Duration::from_secs(secs)) +} + +#[cfg(test)] +mod stream_timeout_tests { + use super::*; + + #[test] + fn stream_chunk_timeout_budget_uses_engine_config() { + let config = EngineConfig { + stream_chunk_timeout: Duration::from_secs(42), + ..EngineConfig::default() + }; + + assert_eq!( + stream_chunk_timeout_budget(&config), + (42, Duration::from_secs(42)) + ); + } +} + fn command_allows_tool(allowed_tools: Option<&[String]>, tool_name: &str) -> bool { let Some(allowed_tools) = allowed_tools else { return true; diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index 4260cf0c8..4ad48c067 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -77,13 +77,16 @@ pub enum Op { #[allow(dead_code)] ChangeMode { mode: AppMode }, - /// Update the model being used and refresh the prompt for the current mode. + /// Update the model being used and refresh stable prompt context. #[allow(dead_code)] SetModel { model: String, mode: AppMode }, /// Update auto-compaction settings SetCompaction { config: CompactionConfig }, + /// Update the SSE idle timeout used for subsequent streamed turns. + SetStreamChunkTimeout { timeout_secs: u64 }, + /// Sync engine session state (used for resume/load) SyncSession { session_id: Option, diff --git a/crates/tui/src/core/session.rs b/crates/tui/src/core/session.rs index 49943c715..67ffb7ad0 100644 --- a/crates/tui/src/core/session.rs +++ b/crates/tui/src/core/session.rs @@ -31,8 +31,8 @@ pub struct Session { /// System prompt (optional) pub system_prompt: Option, - /// True when `system_prompt` came from an explicit runtime API override - /// and should not be replaced by mode/context refreshes. + /// True when `system_prompt` is a persisted/runtime-supplied prefix that + /// should not be replaced by mode/context refreshes. pub system_prompt_override: bool, /// Hash of the last assembled stable system prompt. Used to avoid /// replacing `system_prompt` when unchanged. @@ -82,6 +82,14 @@ pub struct Session { /// request of the session; verified against the current system+tool /// state before every subsequent request. None until the first turn. pub frozen_prefix: Option, + + /// Monotonic counter bumped on every direct mutation of `messages`. + /// Consumed by [`crate::core::engine::token_estimate_cache::TokenEstimateCache`] + /// to memoize the per-turn token estimate without re-walking the message + /// list. Defaults to 0; bumped in [`Session::add_message`], + /// [`Session::replace_messages`], and at every other mutation site in + /// `core/engine.rs` / `core/engine/capacity_flow.rs`. + pub messages_revision: u64, } /// Cumulative usage statistics for a session. @@ -155,12 +163,33 @@ impl Session { working_set: WorkingSet::default(), prefix_stability: None, frozen_prefix: None, + messages_revision: 0, } } /// Add a message to the conversation pub fn add_message(&mut self, message: Message) { self.messages.push(message); + self.messages_revision = self.messages_revision.saturating_add(1); + } + + /// Replace the entire message history. Used by session resume and + /// capacity interventions. Bumps `messages_revision` exactly once even + /// when the new history has a different length, so downstream caches + /// invalidate atomically. + #[allow(dead_code)] + pub fn replace_messages(&mut self, messages: Vec) { + self.messages = messages; + self.messages_revision = self.messages_revision.saturating_add(1); + } + + /// Bump `messages_revision` without otherwise mutating the message list. + /// Reserved for sites that mutate the message list in place (e.g. an + /// in-place rewrite of a content block). Most call sites do not need + /// this — prefer [`add_message`](Self::add_message) and + /// [`replace_messages`](Self::replace_messages). + pub fn bump_messages_revision(&mut self) { + self.messages_revision = self.messages_revision.saturating_add(1); } /// Rebuild the working set from current messages (best effort). diff --git a/crates/tui/src/cost_status.rs b/crates/tui/src/cost_status.rs index 60feebd3d..38476641d 100644 --- a/crates/tui/src/cost_status.rs +++ b/crates/tui/src/cost_status.rs @@ -42,19 +42,20 @@ pub fn report(model: &str, usage: &Usage) { if !cost.is_positive() { return; } - if let Ok(mut pending) = cell().lock() { - pending.usd += cost.usd; - pending.cny += cost.cny; - } + // Recover from poisoned lock — a previous holder panicked but the + // accumulated data is still valid. + let mut pending = cell().lock().unwrap_or_else(|e| e.into_inner()); + pending.usd += cost.usd; + pending.cny += cost.cny; } /// Drain the pending cost. Returns the accumulated amount and resets /// the pool to zero. Called by the TUI render / event loop on each /// frame; any non-zero result gets folded into `accrue_subagent_cost_estimate`. pub fn drain() -> CostEstimate { - let Ok(mut pending) = cell().lock() else { - return CostEstimate::default(); - }; + // Recover from poisoned lock — a previous holder panicked but the + // accumulated data is still valid. + let mut pending = cell().lock().unwrap_or_else(|e| e.into_inner()); std::mem::take(&mut *pending) } @@ -63,9 +64,8 @@ pub fn drain() -> CostEstimate { /// state. Production code should always use [`drain`]. #[cfg(test)] pub fn reset_for_tests() { - if let Ok(mut pending) = cell().lock() { - *pending = CostEstimate::default(); - } + let mut pending = cell().lock().unwrap_or_else(|e| e.into_inner()); + *pending = CostEstimate::default(); } #[cfg(test)] diff --git a/crates/tui/src/deepseek_theme.rs b/crates/tui/src/deepseek_theme.rs index 614f1a626..32e2ea7c7 100644 --- a/crates/tui/src/deepseek_theme.rs +++ b/crates/tui/src/deepseek_theme.rs @@ -182,6 +182,7 @@ impl Theme { match status { ToolStatus::Running => self.tool_running_accent, ToolStatus::Success => self.tool_success_accent, + ToolStatus::Hydrated => self.tool_running_accent, ToolStatus::Failed => self.tool_failed_accent, } } @@ -278,6 +279,10 @@ mod tests { theme.tool_status_color(ToolStatus::Success), theme.tool_success_accent ); + assert_eq!( + theme.tool_status_color(ToolStatus::Hydrated), + theme.tool_running_accent + ); assert_eq!( theme.tool_status_color(ToolStatus::Failed), theme.tool_failed_accent diff --git a/crates/tui/src/error_taxonomy.rs b/crates/tui/src/error_taxonomy.rs index 7f14644f2..503b0912c 100644 --- a/crates/tui/src/error_taxonomy.rs +++ b/crates/tui/src/error_taxonomy.rs @@ -234,12 +234,12 @@ impl From for ErrorEnvelope { "llm_timeout", format!("Request timed out after {duration:?}"), ), - LlmError::AuthenticationError(message) => Self::new( + LlmError::AuthenticationError(auth) => Self::new( ErrorCategory::Authentication, ErrorSeverity::Critical, false, "llm_auth_error", - message, + auth.to_user_message(), ), LlmError::AuthorizationError(message) => Self::new( ErrorCategory::Authorization, @@ -342,6 +342,10 @@ pub fn classify_error_message(message: &str) -> ErrorCategory { if lower.contains("network") || lower.contains("connection") || lower.contains("dns") + || lower.contains("stream read error") + || lower.contains("error decoding response body") + || lower.contains("chunk decode error") + || lower.contains("body decode") || lower.contains("temporarily unavailable") || lower.contains(" 502 ") || lower.contains(" 503 ") @@ -548,6 +552,22 @@ mod tests { ); } + #[test] + fn network_catches_stream_body_decode_failures() { + for msg in [ + "Warn Stream read error: error decoding response body", + "Stream read error: error decoding response body", + "chunk decode error", + "provider body decode failed mid-stream", + ] { + assert_eq!( + classify(msg), + ErrorCategory::Network, + "expected Network for `{msg}`", + ); + } + } + #[test] fn authentication_beats_authorization_when_api_key_phrasing_is_used() { // "api key" landing on Authentication (not Authorization) keeps @@ -566,6 +586,35 @@ mod tests { } } + #[test] + fn llm_auth_error_envelope_renders_context_without_secret() { + let api_key = "tp-secret-token-plan-value"; + let env = ErrorEnvelope::from(LlmError::from_http_response_with_request_context( + 401, + &format!("Invalid API Key: {api_key}"), + Some("Xiaomi MiMo"), + Some("https://token-plan-sgp.xiaomimimo.com/v1"), + Some("mimo-v2.5"), + Some("env"), + Some(api_key), + )); + + assert_eq!(env.category, ErrorCategory::Authentication); + assert_eq!(env.severity, ErrorSeverity::Critical); + assert!(!env.recoverable); + assert!(env.message.contains("provider: Xiaomi MiMo")); + assert!( + env.message + .contains("base URL authority: token-plan-sgp.xiaomimimo.com") + ); + assert!(env.message.contains("model: mimo-v2.5")); + assert!(env.message.contains("key source: env")); + assert!(env.message.contains("key fingerprint: tp-... (len=26)")); + assert!(env.message.contains("key type: Xiaomi MiMo Token Plan key")); + assert!(!env.message.contains(api_key)); + assert!(!env.message.contains("secret-token-plan-value")); + } + #[test] fn authorization_catches_forbidden_and_denied() { for msg in [ diff --git a/crates/tui/src/execpolicy/error.rs b/crates/tui/src/execpolicy/error.rs index 9664e71a5..17f6c8f2f 100644 --- a/crates/tui/src/execpolicy/error.rs +++ b/crates/tui/src/execpolicy/error.rs @@ -1,3 +1,4 @@ +#[cfg(not(target_env = "ohos"))] use starlark::Error as StarlarkError; use thiserror::Error; @@ -23,6 +24,9 @@ pub enum Error { }, #[error("expected example to not match rule `{rule}`: {example}")] ExampleDidMatch { rule: String, example: String }, + #[error("{0}")] + UnsupportedPlatform(String), #[error("starlark error: {0}")] + #[cfg(not(target_env = "ohos"))] Starlark(StarlarkError), } diff --git a/crates/tui/src/execpolicy/mod.rs b/crates/tui/src/execpolicy/mod.rs index 00ced1aaa..3f5926cf4 100644 --- a/crates/tui/src/execpolicy/mod.rs +++ b/crates/tui/src/execpolicy/mod.rs @@ -6,7 +6,10 @@ pub mod decision; pub mod error; pub mod execpolicycheck; pub mod matcher; +#[cfg(not(target_env = "ohos"))] pub mod parser; +#[cfg(target_env = "ohos")] +pub mod parser_ohos; pub mod policy; pub mod rule; pub mod rules; @@ -17,7 +20,10 @@ pub use decision::Decision; pub use error::Error; pub use error::Result; pub use execpolicycheck::ExecPolicyCheckCommand; +#[cfg(not(target_env = "ohos"))] pub use parser::PolicyParser; +#[cfg(target_env = "ohos")] +pub use parser_ohos::PolicyParser; pub use policy::Evaluation; pub use policy::Policy; pub use rule::Rule; diff --git a/crates/tui/src/execpolicy/parser_ohos.rs b/crates/tui/src/execpolicy/parser_ohos.rs new file mode 100644 index 000000000..6400cd6b4 --- /dev/null +++ b/crates/tui/src/execpolicy/parser_ohos.rs @@ -0,0 +1,26 @@ +use super::error::Error; +use super::error::Result; + +pub struct PolicyParser; + +impl Default for PolicyParser { + fn default() -> Self { + Self::new() + } +} + +impl PolicyParser { + pub fn new() -> Self { + Self + } + + pub fn parse(&mut self, _policy_identifier: &str, _policy_file_contents: &str) -> Result<()> { + Err(Error::UnsupportedPlatform( + "Starlark execpolicy files are not supported on HarmonyOS/OpenHarmony yet because upstream starlark-rust still depends on a rustyline/nix chain that does not compile for OHOS.".to_string(), + )) + } + + pub fn build(self) -> super::policy::Policy { + super::policy::Policy::empty() + } +} diff --git a/crates/tui/src/hooks.rs b/crates/tui/src/hooks.rs index a528bc1ad..f6d2ddee0 100644 --- a/crates/tui/src/hooks.rs +++ b/crates/tui/src/hooks.rs @@ -7,6 +7,7 @@ //! - Mode changes //! - Message submission //! - Error events +//! - Turn completion //! //! Configuration is done via `[[hooks.hooks]]` in config.toml. @@ -41,6 +42,8 @@ pub enum HookEvent { ModeChange, /// Triggered when an error occurs OnError, + /// Triggered after a turn completes and post-turn state has been updated + TurnEnd, /// Triggered when a sub-agent is spawned SubagentSpawn, /// Triggered when a sub-agent reaches a terminal state @@ -66,6 +69,7 @@ impl HookEvent { HookEvent::ToolCallAfter => "tool_call_after", HookEvent::ModeChange => "mode_change", HookEvent::OnError => "on_error", + HookEvent::TurnEnd => "turn_end", HookEvent::SubagentSpawn => "subagent_spawn", HookEvent::SubagentComplete => "subagent_complete", HookEvent::ShellEnv => "shell_env", @@ -480,6 +484,28 @@ enum MessageSubmitStdout { Invalid(String), } +/// Post-turn accumulated totals included in the `turn_end` observer payload. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TurnEndTotals { + pub session_tokens: u32, + pub conversation_tokens: u32, + pub input_tokens: u32, + pub output_tokens: u32, +} + +/// Input used to build the structured `turn_end` observer payload. +pub struct TurnEndPayloadInput<'a> { + pub context: &'a HookContext, + pub turn_id: Option<&'a str>, + pub status: &'a str, + pub error: Option<&'a str>, + pub duration: Duration, + pub usage: &'a crate::models::Usage, + pub totals: TurnEndTotals, + pub tool_count: usize, + pub queued_message_count: usize, +} + /// Executor for running hooks #[derive(Debug, Clone)] pub struct HookExecutor { @@ -1051,7 +1077,7 @@ impl HookExecutor { let env = env_vars.clone(); let wd = working_dir.clone(); - // Spawn in a detached thread + // Spawn in a detached thread (fire-and-forget hook execution). std::thread::spawn(move || { let mut command = HookExecutor::build_shell_command(&cmd); command @@ -1121,6 +1147,41 @@ fn message_submit_payload(context: &HookContext, text: &str) -> serde_json::Valu }) } +pub fn turn_end_payload(input: TurnEndPayloadInput<'_>) -> serde_json::Value { + json!({ + "event": HookEvent::TurnEnd.as_str(), + "session_id": input.context.session_id.as_deref(), + "workspace": input.context.workspace.as_ref().map(|path| path.display().to_string()), + "mode": input.context.mode.as_deref(), + "model": input.context.model.as_deref(), + "turn_id": input.turn_id, + "status": input.status, + "error": input.error, + "duration_ms": duration_ms_saturating(input.duration), + "usage": { + "input_tokens": input.usage.input_tokens, + "output_tokens": input.usage.output_tokens, + "prompt_cache_hit_tokens": input.usage.prompt_cache_hit_tokens, + "prompt_cache_miss_tokens": input.usage.prompt_cache_miss_tokens, + "reasoning_tokens": input.usage.reasoning_tokens, + "reasoning_replay_tokens": input.usage.reasoning_replay_tokens, + }, + "totals": { + "session_tokens": input.totals.session_tokens, + "conversation_tokens": input.totals.conversation_tokens, + "input_tokens": input.totals.input_tokens, + "output_tokens": input.totals.output_tokens, + }, + "tool_count": input.tool_count, + "queued_message_count": input.queued_message_count, + "stop_hook_active": false, + }) +} + +fn duration_ms_saturating(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + fn parse_message_submit_stdout(stdout: &str) -> MessageSubmitStdout { let trimmed = stdout.trim(); if trimmed.is_empty() { @@ -1343,10 +1404,70 @@ NOEQUAL line dropped assert_eq!(HookEvent::SessionStart.as_str(), "session_start"); assert_eq!(HookEvent::ToolCallAfter.as_str(), "tool_call_after"); assert_eq!(HookEvent::ModeChange.as_str(), "mode_change"); + assert_eq!(HookEvent::TurnEnd.as_str(), "turn_end"); assert_eq!(HookEvent::SubagentSpawn.as_str(), "subagent_spawn"); assert_eq!(HookEvent::SubagentComplete.as_str(), "subagent_complete"); } + #[test] + fn turn_end_payload_contains_post_turn_observer_fields() { + let context = HookContext::new() + .with_session_id("sess_test") + .with_workspace(PathBuf::from("/tmp/codewhale")) + .with_mode("agent") + .with_model("deepseek-v4") + .with_tokens(125); + let usage = crate::models::Usage { + input_tokens: 40, + output_tokens: 9, + prompt_cache_hit_tokens: Some(10), + prompt_cache_miss_tokens: Some(30), + reasoning_tokens: Some(4), + reasoning_replay_tokens: Some(2), + server_tool_use: None, + }; + + let payload = super::turn_end_payload(TurnEndPayloadInput { + context: &context, + turn_id: Some("turn_123"), + status: "completed", + error: None, + duration: Duration::from_millis(321), + usage: &usage, + totals: TurnEndTotals { + session_tokens: 125, + conversation_tokens: 100, + input_tokens: 100, + output_tokens: 25, + }, + tool_count: 2, + queued_message_count: 1, + }); + + assert_eq!(payload["event"], "turn_end"); + assert_eq!(payload["session_id"], "sess_test"); + assert_eq!(payload["workspace"], "/tmp/codewhale"); + assert_eq!(payload["mode"], "agent"); + assert_eq!(payload["model"], "deepseek-v4"); + assert_eq!(payload["turn_id"], "turn_123"); + assert_eq!(payload["status"], "completed"); + assert_eq!(payload["error"], serde_json::Value::Null); + assert_eq!(payload["duration_ms"], 321); + assert_eq!(payload["usage"]["input_tokens"], 40); + assert_eq!(payload["usage"]["output_tokens"], 9); + assert_eq!(payload["usage"]["prompt_cache_hit_tokens"], 10); + assert_eq!(payload["usage"]["prompt_cache_miss_tokens"], 30); + assert_eq!(payload["usage"]["reasoning_tokens"], 4); + assert_eq!(payload["usage"]["reasoning_replay_tokens"], 2); + assert_eq!(payload["totals"]["session_tokens"], 125); + assert_eq!(payload["totals"]["conversation_tokens"], 100); + assert_eq!(payload["totals"]["input_tokens"], 100); + assert_eq!(payload["totals"]["output_tokens"], 25); + assert_eq!(payload["tool_count"], 2); + assert_eq!(payload["queued_message_count"], 1); + assert_eq!(payload["stop_hook_active"], false); + } + #[test] fn test_hook_context_to_env_vars() { let ctx = HookContext::new() @@ -1578,6 +1699,76 @@ cat > "{}" assert_eq!(captured["prompt_truncated"], false); } + #[cfg(not(windows))] + #[test] + fn turn_end_observer_hook_receives_stdin_json_and_ignores_stdout_contract() { + let dir = tempfile::tempdir().expect("tempdir"); + let out = dir.path().join("turn_end.json"); + let command = write_hook_script( + &dir, + "capture_turn_end.sh", + &format!( + r#"#!/bin/sh +cat > "{}" +printf '%s\n' '{{"text":"stdout is not a mutation contract"}}' +"#, + out.display() + ), + ); + let executor = HookExecutor::new( + HooksConfig { + enabled: true, + hooks: vec![Hook::new(HookEvent::TurnEnd, &command)], + ..Default::default() + }, + dir.path().to_path_buf(), + ); + let usage = crate::models::Usage { + input_tokens: 12, + output_tokens: 3, + prompt_cache_hit_tokens: None, + prompt_cache_miss_tokens: None, + reasoning_tokens: None, + reasoning_replay_tokens: None, + server_tool_use: None, + }; + let context = submit_context(&dir).with_tokens(15); + let payload = super::turn_end_payload(TurnEndPayloadInput { + context: &context, + turn_id: Some("turn_observed"), + status: "completed", + error: None, + duration: Duration::from_millis(7), + usage: &usage, + totals: TurnEndTotals { + session_tokens: 15, + conversation_tokens: 15, + input_tokens: 12, + output_tokens: 3, + }, + tool_count: 0, + queued_message_count: 0, + }); + + let results = executor.execute_json_observer(HookEvent::TurnEnd, &context, &payload); + + assert_eq!(results.len(), 1); + assert!(results[0].success); + assert!( + results[0] + .stdout + .contains("stdout is not a mutation contract"), + "stdout is still captured for diagnostics" + ); + let captured: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(out).expect("payload written")) + .expect("valid JSON payload"); + assert_eq!(captured["event"], "turn_end"); + assert_eq!(captured["turn_id"], "turn_observed"); + assert_eq!(captured["totals"]["input_tokens"], 12); + assert_eq!(captured["totals"]["output_tokens"], 3); + } + #[cfg(not(windows))] #[test] fn json_observer_hook_failure_does_not_stop_later_hooks() { @@ -1912,6 +2103,7 @@ exit 7 HookEvent::ToolCallAfter, HookEvent::ModeChange, HookEvent::OnError, + HookEvent::TurnEnd, HookEvent::SubagentSpawn, HookEvent::SubagentComplete, ] { diff --git a/crates/tui/src/llm_client/mod.rs b/crates/tui/src/llm_client/mod.rs index 90a652092..de849328a 100644 --- a/crates/tui/src/llm_client/mod.rs +++ b/crates/tui/src/llm_client/mod.rs @@ -82,6 +82,194 @@ pub trait RetryConfigurable { fn set_retry_config(&mut self, config: RetryConfig); } +// === Authentication diagnostics === + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct AuthenticationErrorContext { + pub provider: Option, + pub base_url_authority: Option, + pub model: Option, + pub key_source: Option, + pub key_fingerprint: Option, + pub key_kind: Option, +} + +impl AuthenticationErrorContext { + #[must_use] + pub fn new( + provider: &str, + base_url: &str, + model: &str, + key_source: &str, + api_key: &str, + ) -> Self { + Self::from_parts( + Some(provider), + Some(base_url), + Some(model), + Some(key_source), + Some(api_key), + ) + } + + #[must_use] + pub fn from_parts( + provider: Option<&str>, + base_url: Option<&str>, + model: Option<&str>, + key_source: Option<&str>, + api_key: Option<&str>, + ) -> Self { + let api_key = api_key.and_then(non_empty_trimmed); + Self { + provider: provider.and_then(non_empty_trimmed).map(str::to_string), + base_url_authority: base_url.and_then(base_url_authority), + model: model.and_then(non_empty_trimmed).map(str::to_string), + key_source: key_source.and_then(non_empty_trimmed).map(str::to_string), + key_fingerprint: api_key.map(redacted_key_fingerprint), + key_kind: api_key.map(classify_api_key_prefix).map(str::to_string), + } + } + + fn is_empty(&self) -> bool { + self.provider.is_none() + && self.base_url_authority.is_none() + && self.model.is_none() + && self.key_source.is_none() + && self.key_fingerprint.is_none() + && self.key_kind.is_none() + } + + fn detail_segments(&self) -> Vec { + let mut segments = Vec::new(); + if let Some(provider) = self.provider.as_deref() { + segments.push(format!("provider: {provider}")); + } + if let Some(authority) = self.base_url_authority.as_deref() { + segments.push(format!("base URL authority: {authority}")); + } + if let Some(model) = self.model.as_deref() { + segments.push(format!("model: {model}")); + } + if let Some(source) = self.key_source.as_deref() { + segments.push(format!("key source: {source}")); + } + if let Some(fingerprint) = self.key_fingerprint.as_deref() { + segments.push(format!("key fingerprint: {fingerprint}")); + } + if let Some(kind) = self.key_kind.as_deref() { + segments.push(format!("key type: {kind}")); + } + segments + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthenticationErrorDetail { + message: String, + context: Option, +} + +impl AuthenticationErrorDetail { + #[must_use] + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + context: None, + } + } + + #[must_use] + pub fn with_context( + message: impl Into, + context: Option, + ) -> Self { + let context = context.filter(|context| !context.is_empty()); + Self { + message: message.into(), + context, + } + } + + #[must_use] + pub fn message(&self) -> &str { + &self.message + } + + #[must_use] + pub fn to_user_message(&self) -> String { + let Some(context) = self.context.as_ref() else { + return self.message.clone(); + }; + let segments = context.detail_segments(); + if segments.is_empty() { + self.message.clone() + } else { + format!("{} ({})", self.message, segments.join(", ")) + } + } +} + +impl From for AuthenticationErrorDetail { + fn from(message: String) -> Self { + Self::new(message) + } +} + +impl From<&str> for AuthenticationErrorDetail { + fn from(message: &str) -> Self { + Self::new(message) + } +} + +#[must_use] +pub fn classify_api_key_prefix(api_key: &str) -> &'static str { + if api_key.starts_with("tp-") { + "Xiaomi MiMo Token Plan key" + } else { + "API key" + } +} + +fn non_empty_trimmed(value: &str) -> Option<&str> { + let value = value.trim(); + if value.is_empty() { None } else { Some(value) } +} + +fn base_url_authority(base_url: &str) -> Option { + let base_url = non_empty_trimmed(base_url)?; + let without_scheme = base_url + .split_once("://") + .map_or(base_url, |(_, rest)| rest); + let authority = without_scheme.split('/').next().unwrap_or(without_scheme); + let authority = authority + .rsplit_once('@') + .map_or(authority, |(_, authority)| authority); + non_empty_trimmed(authority).map(str::to_string) +} + +fn redacted_key_fingerprint(api_key: &str) -> String { + let api_key = api_key.trim(); + let len = api_key.chars().count(); + match public_key_prefix(api_key) { + Some(prefix) => format!("{prefix}... (len={len})"), + None => format!("unprefixed (len={len})"), + } +} + +fn public_key_prefix(api_key: &str) -> Option<&str> { + ["tp-", "sk-", "hf_", "hf-", "ak-", "rk-"] + .into_iter() + .find(|prefix| api_key.starts_with(prefix)) +} + +fn redact_api_key_from_message(message: &str, api_key: Option<&str>) -> String { + let Some(api_key) = api_key.and_then(non_empty_trimmed) else { + return message.to_string(); + }; + message.replace(api_key, "[redacted API key]") +} + // === LlmError - Classified Error Types === /// Classified LLM errors with retryability information. @@ -107,8 +295,8 @@ pub enum LlmError { /// Request timed out Timeout(Duration), - /// Authentication failed (HTTP 401, 403) - AuthenticationError(String), + /// Authentication failed (HTTP 401, selected HTTP 403) + AuthenticationError(AuthenticationErrorDetail), /// Authorization or provider-side blocking failed (HTTP 403) AuthorizationError(String), @@ -141,7 +329,9 @@ impl std::fmt::Display for LlmError { } LlmError::NetworkError(msg) => write!(f, "Network error: {msg}"), LlmError::Timeout(d) => write!(f, "Request timed out after {d:?}"), - LlmError::AuthenticationError(msg) => write!(f, "Authentication failed: {msg}"), + LlmError::AuthenticationError(auth) => { + write!(f, "Authentication failed: {}", auth.to_user_message()) + } LlmError::AuthorizationError(msg) => write!(f, "Authorization failed: {msg}"), LlmError::InvalidRequest { status, message } => { write!(f, "Invalid request ({status}): {message}") @@ -203,10 +393,10 @@ impl LlmError { message: body.to_string(), retry_after: None, }, - 401 => LlmError::AuthenticationError(body.to_string()), + 401 => Self::authentication_error(body), 403 => { if looks_like_authentication_failure(body) { - LlmError::AuthenticationError(body.to_string()) + Self::authentication_error(body) } else { LlmError::AuthorizationError(body.to_string()) } @@ -262,6 +452,62 @@ impl LlmError { } } + #[must_use] + pub fn authentication_error(message: impl Into) -> Self { + LlmError::AuthenticationError(AuthenticationErrorDetail::new(message)) + } + + #[must_use] + pub fn authentication_error_with_context( + message: impl Into, + context: Option, + ) -> Self { + LlmError::AuthenticationError(AuthenticationErrorDetail::with_context(message, context)) + } + + /// Constructs an `LlmError` from HTTP response data plus request context + /// that is safe to display when authentication fails. + #[must_use] + pub fn from_http_response_with_request_context( + status: u16, + body: &str, + provider: Option<&str>, + base_url: Option<&str>, + model: Option<&str>, + key_source: Option<&str>, + api_key: Option<&str>, + ) -> Self { + let body = redact_api_key_from_message(body, api_key); + let context = + AuthenticationErrorContext::from_parts(provider, base_url, model, key_source, api_key); + Self::from_http_response_with_auth_context(status, &body, Some(context)) + } + + /// Constructs an `LlmError` from HTTP status code and response body, with + /// optional structured details for authentication failures. + /// + /// The `body` passed here must already be safe for user display. Prefer + /// [`Self::from_http_response_with_request_context`] when the raw API key is + /// available so the response body can be redacted before rendering. + #[must_use] + pub fn from_http_response_with_auth_context( + status: u16, + body: &str, + auth_context: Option, + ) -> Self { + match status { + 401 => Self::authentication_error_with_context(body, auth_context), + 403 => { + if looks_like_authentication_failure(body) { + Self::authentication_error_with_context(body, auth_context) + } else { + LlmError::AuthorizationError(body.to_string()) + } + } + _ => Self::from_http_response(status, body), + } + } + /// Constructs an `LlmError` from HTTP status code, body, and optional Retry-After header. pub fn from_http_response_with_retry_after( status: u16, @@ -898,6 +1144,13 @@ mod tests { ); } + fn auth_user_message(error: LlmError) -> String { + match error { + LlmError::AuthenticationError(auth) => auth.to_user_message(), + other => panic!("expected authentication error, got {other}"), + } + } + #[test] fn test_retry_config_defaults() { let config = RetryConfig::default(); @@ -1014,7 +1267,7 @@ mod tests { assert!(LlmError::Timeout(Duration::from_secs(30)).is_retryable()); // Non-retryable errors - assert!(!LlmError::AuthenticationError("invalid key".to_string()).is_retryable()); + assert!(!LlmError::authentication_error("invalid key").is_retryable()); assert!(!LlmError::AuthorizationError("blocked".to_string()).is_retryable()); assert!( !LlmError::InvalidRequest { @@ -1071,6 +1324,109 @@ mod tests { assert!(matches!(err, LlmError::InvalidRequest { status: 400, .. })); } + #[test] + fn auth_error_with_context_includes_provider_authority_model_and_key_source() { + let err = LlmError::from_http_response_with_request_context( + 401, + "Invalid API Key", + Some("Xiaomi MiMo"), + Some("https://token-plan-sgp.xiaomimimo.com/v1"), + Some("mimo-v2.5"), + Some("env"), + Some("tp-secret-token-plan-value"), + ); + let message = auth_user_message(err); + + assert!(message.contains("Invalid API Key")); + assert!(message.contains("provider: Xiaomi MiMo")); + assert!(message.contains("base URL authority: token-plan-sgp.xiaomimimo.com")); + assert!(message.contains("model: mimo-v2.5")); + assert!(message.contains("key source: env")); + assert!(message.contains("key fingerprint: tp-... (len=26)")); + } + + #[test] + fn auth_error_redacts_full_api_key_from_body_and_context() { + let api_key = "tp-secret-token-plan-value"; + let err = LlmError::from_http_response_with_request_context( + 401, + &format!("Invalid API Key: {api_key}"), + Some("Xiaomi MiMo"), + Some("https://token-plan-sgp.xiaomimimo.com/v1"), + Some("mimo-v2.5"), + Some("config-file"), + Some(api_key), + ); + let message = auth_user_message(err); + + assert!(!message.contains(api_key)); + assert!(!message.contains("secret-token-plan-value")); + assert!(message.contains("[redacted API key]")); + assert!(message.contains("key fingerprint: tp-... (len=26)")); + } + + #[test] + fn auth_error_classifies_xiaomi_token_plan_key_prefix() { + let token_plan = AuthenticationErrorContext::from_parts( + None, + None, + None, + Some("session"), + Some("tp-secret-token-plan-value"), + ); + let generic = AuthenticationErrorContext::from_parts( + None, + None, + None, + Some("session"), + Some("sk-other"), + ); + let unprefixed = AuthenticationErrorContext::from_parts( + None, + None, + None, + Some("session"), + Some("plainsecretvalue"), + ); + + assert_eq!( + token_plan.key_kind.as_deref(), + Some("Xiaomi MiMo Token Plan key") + ); + assert_eq!(generic.key_kind.as_deref(), Some("API key")); + assert_eq!(unprefixed.key_kind.as_deref(), Some("API key")); + assert_eq!( + unprefixed.key_fingerprint.as_deref(), + Some("unprefixed (len=16)") + ); + } + + #[test] + fn authorization_403_is_not_reclassified_by_auth_context() { + let err = LlmError::from_http_response_with_request_context( + 403, + "forbidden", + Some("Arcee AI"), + Some("https://api.arcee.ai/v1"), + Some("auto"), + Some("env"), + Some("sk-arcee-secret"), + ); + + assert!(matches!(err, LlmError::AuthorizationError(_))); + } + + #[test] + fn auth_error_without_context_preserves_bare_message() { + let err = LlmError::from_http_response_with_auth_context( + 401, + "Invalid API Key", + Some(AuthenticationErrorContext::default()), + ); + + assert_eq!(auth_user_message(err), "Invalid API Key"); + } + #[test] fn cloudflare_html_error_is_summarized_without_raw_markup() { let body = r#"Access Denied + + +
${escapeHtml(badge)}
+

${escapeHtml(this.state.detail)}

+

${escapeHtml(this.state.baseUrl)}

+ + + + + +
Agent View
+ ${threadsHtml} +
Restore Points
+ ${snapshotsHtml} + + +`; + } +} +exports.RuntimeStatusView = RuntimeStatusView; +function renderSnapshot(snapshot) { + return `
+
${escapeHtml(snapshot.label)}
+
${escapeHtml(`${snapshot.id} · ${formatUnixTimestamp(snapshot.timestamp)}`)}
+
`; +} +function renderThread(thread) { + const status = thread.latestTurnStatus ? ` · ${thread.latestTurnStatus}` : ""; + const archived = thread.archived ? " · archived" : ""; + const git = renderGitMetadata(thread); + const workspace = thread.workspace ? ` · ${thread.workspace}` : ""; + const updated = thread.updatedAt ? ` · ${formatTimestamp(thread.updatedAt)}` : ""; + return `
+
${escapeHtml(thread.title)}
+
${escapeHtml(thread.preview || "No recent message.")}
+
${escapeHtml(`${thread.mode} · ${thread.model}${status}${git}${archived}${updated}${workspace}`)}
+
`; +} +function renderGitMetadata(thread) { + if (!thread.branch && !thread.head && !thread.dirty) { + return ""; + } + const parts = []; + if (thread.branch) { + parts.push(`branch ${thread.branch}`); + } + if (thread.head) { + parts.push(`@ ${thread.head}`); + } + if (thread.dirty) { + parts.push("dirty"); + } + return ` · ${parts.join(" ")}`; +} +function labelFor(kind) { + switch (kind) { + case "connected": + return "Connected"; + case "auth-required": + return "Token Required"; + case "error": + return "Runtime Error"; + case "offline": + return "Offline"; + } +} +function formatTimestamp(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleString(); +} +function formatUnixTimestamp(value) { + const date = new Date(value * 1000); + if (Number.isNaN(date.getTime())) { + return String(value); + } + return date.toLocaleString(); +} +function escapeHtml(value) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} +function makeNonce() { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let nonce = ""; + for (let index = 0; index < 32; index += 1) { + nonce += alphabet.charAt(Math.floor(Math.random() * alphabet.length)); + } + return nonce; +} +//# sourceMappingURL=status.js.map \ No newline at end of file diff --git a/extensions/vscode/out/status.js.map b/extensions/vscode/out/status.js.map new file mode 100644 index 000000000..b429afc24 --- /dev/null +++ b/extensions/vscode/out/status.js.map @@ -0,0 +1 @@ +{"version":3,"file":"status.js","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,+CAAiC;AAGjC,MAAa,iBAAiB;IACrB,MAAM,CAAU,QAAQ,GAAG,yBAAyB,CAAC;IAEpD,IAAI,CAAsB;IAC1B,KAAK,GAAiB;QAC5B,IAAI,EAAE,SAAS;QACf,OAAO,EAAE,uBAAuB;QAChC,MAAM,EAAE,mCAAmC;KAC5C,CAAC;IACM,OAAO,GAAoB,EAAE,CAAC;IAC9B,aAAa,GAAG,gDAAgD,CAAC;IACjE,SAAS,GAAoB,EAAE,CAAC;IAChC,eAAe,GAAG,gDAAgD,CAAC;IAE3E,kBAAkB,CAAC,IAAwB;QACzC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,OAAO,CAAC,OAAO,GAAG,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC;QAC/C,IAAI,CAAC,OAAO,CAAC,mBAAmB,CAAC,CAAC,OAA6B,EAAE,EAAE;YACjE,IAAI,OAAO,CAAC,OAAO,KAAK,OAAO,EAAE,CAAC;gBAChC,KAAK,MAAM,CAAC,QAAQ,CAAC,cAAc,CAAC,wBAAwB,CAAC,CAAC;YAChE,CAAC;iBAAM,IAAI,OAAO,CAAC,OAAO,KAAK,OAAO,EAAE,CAAC;gBACvC,KAAK,MAAM,CAAC,QAAQ,CAAC,cAAc,CAAC,wBAAwB,CAAC,CAAC;YAChE,CAAC;iBAAM,IAAI,OAAO,CAAC,OAAO,KAAK,UAAU,EAAE,CAAC;gBAC1C,KAAK,MAAM,CAAC,QAAQ,CAAC,cAAc,CAAC,wBAAwB,CAAC,CAAC;YAChE,CAAC;iBAAM,IAAI,OAAO,CAAC,OAAO,KAAK,SAAS,EAAE,CAAC;gBACzC,KAAK,MAAM,CAAC,QAAQ,CAAC,cAAc,CAAC,4BAA4B,CAAC,CAAC;YACpE,CAAC;iBAAM,IAAI,OAAO,CAAC,OAAO,KAAK,WAAW,EAAE,CAAC;gBAC3C,KAAK,MAAM,CAAC,QAAQ,CAAC,cAAc,CAAC,4BAA4B,CAAC,CAAC;YACpE,CAAC;QACH,CAAC,CAAC,CAAC;QACH,IAAI,CAAC,MAAM,EAAE,CAAC;IAChB,CAAC;IAED,MAAM,CAAC,KAAmB;QACxB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,MAAM,EAAE,CAAC;IAChB,CAAC;IAED,aAAa,CAAC,OAAwB,EAAE,MAAc;QACpD,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,IAAI,CAAC,aAAa,GAAG,MAAM,CAAC;QAC5B,IAAI,CAAC,MAAM,EAAE,CAAC;IAChB,CAAC;IAED,eAAe,CAAC,SAA0B,EAAE,MAAc;QACxD,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;QAC3B,IAAI,CAAC,eAAe,GAAG,MAAM,CAAC;QAC9B,IAAI,CAAC,MAAM,EAAE,CAAC;IAChB,CAAC;IAEO,MAAM;QACZ,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;YACf,OAAO;QACT,CAAC;QAED,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACxC,MAAM,KAAK,GAAG,SAAS,EAAE,CAAC;QAC1B,MAAM,WAAW,GACf,IAAI,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC;YACrB,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC;YAC7D,CAAC,CAAC,qBAAqB,UAAU,CAAC,IAAI,CAAC,aAAa,CAAC,MAAM,CAAC;QAChE,MAAM,aAAa,GACjB,IAAI,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC;YACvB,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC;YACrE,CAAC,CAAC,qBAAqB,UAAU,CAAC,IAAI,CAAC,eAAe,CAAC,MAAM,CAAC;QAClE,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,GAAG;;;;yHAI4F,KAAK;;;;;;;;;;;;;;;;;wBAiBtG,UAAU,CAAC,KAAK,CAAC;sBACnB,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC;4BACvB,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC;;;;;;;IAOtD,WAAW;;IAEX,aAAa;mBACE,KAAK;;;;;;;QAOhB,CAAC;IACP,CAAC;;AA1GH,8CA2GC;AAED,SAAS,cAAc,CAAC,QAAuB;IAC7C,OAAO;kCACyB,UAAU,CAAC,QAAQ,CAAC,KAAK,CAAC;+BAC7B,UAAU,CAAC,GAAG,QAAQ,CAAC,EAAE,MAAM,mBAAmB,CAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC;SAC/F,CAAC;AACV,CAAC;AAED,SAAS,YAAY,CAAC,MAAqB;IACzC,MAAM,MAAM,GAAG,MAAM,CAAC,gBAAgB,CAAC,CAAC,CAAC,MAAM,MAAM,CAAC,gBAAgB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IAC9E,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,CAAC;IACtD,MAAM,GAAG,GAAG,iBAAiB,CAAC,MAAM,CAAC,CAAC;IACtC,MAAM,SAAS,GAAG,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,MAAM,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IACnE,MAAM,OAAO,GAAG,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,eAAe,CAAC,MAAM,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IAClF,OAAO;gCACuB,UAAU,CAAC,MAAM,CAAC,KAAK,CAAC;kCACtB,UAAU,CAAC,MAAM,CAAC,OAAO,IAAI,oBAAoB,CAAC;+BACrD,UAAU,CAAC,GAAG,MAAM,CAAC,IAAI,MAAM,MAAM,CAAC,KAAK,GAAG,MAAM,GAAG,GAAG,GAAG,QAAQ,GAAG,OAAO,GAAG,SAAS,EAAE,CAAC;SACpH,CAAC;AACV,CAAC;AAED,SAAS,iBAAiB,CAAC,MAAqB;IAC9C,IAAI,CAAC,MAAM,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;QACpD,OAAO,EAAE,CAAC;IACZ,CAAC;IAED,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC;QAClB,KAAK,CAAC,IAAI,CAAC,UAAU,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;IACxC,CAAC;IACD,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC;QAChB,KAAK,CAAC,IAAI,CAAC,KAAK,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;IACjC,CAAC;IACD,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;QACjB,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACtB,CAAC;IACD,OAAO,MAAM,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;AACjC,CAAC;AAED,SAAS,QAAQ,CAAC,IAA0B;IAC1C,QAAQ,IAAI,EAAE,CAAC;QACb,KAAK,WAAW;YACd,OAAO,WAAW,CAAC;QACrB,KAAK,eAAe;YAClB,OAAO,gBAAgB,CAAC;QAC1B,KAAK,OAAO;YACV,OAAO,eAAe,CAAC;QACzB,KAAK,SAAS;YACZ,OAAO,SAAS,CAAC;IACrB,CAAC;AACH,CAAC;AAED,SAAS,eAAe,CAAC,KAAa;IACpC,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,KAAK,CAAC,CAAC;IAC7B,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC;QACjC,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,IAAI,CAAC,cAAc,EAAE,CAAC;AAC/B,CAAC;AAED,SAAS,mBAAmB,CAAC,KAAa;IACxC,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC;IACpC,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC;QACjC,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC;IACvB,CAAC;IACD,OAAO,IAAI,CAAC,cAAc,EAAE,CAAC;AAC/B,CAAC;AAED,SAAS,UAAU,CAAC,KAAa;IAC/B,OAAO,KAAK;SACT,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;AAC7B,CAAC;AAED,SAAS,SAAS;IAChB,MAAM,QAAQ,GAAG,gEAAgE,CAAC;IAClF,IAAI,KAAK,GAAG,EAAE,CAAC;IACf,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,EAAE,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;QAC3C,KAAK,IAAI,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC;IACxE,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC"} \ No newline at end of file diff --git a/extensions/vscode/package-lock.json b/extensions/vscode/package-lock.json new file mode 100644 index 000000000..f5aa199b2 --- /dev/null +++ b/extensions/vscode/package-lock.json @@ -0,0 +1,3851 @@ +{ + "name": "codewhale-vscode", + "version": "0.8.53", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codewhale-vscode", + "version": "0.8.53", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.19.27", + "@types/vscode": "^1.90.0", + "@vscode/vsce": "^3.7.0", + "typescript": "^5.9.3" + }, + "engines": { + "vscode": "^1.90.0" + } + }, + "node_modules/@azu/format-text": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", + "integrity": "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@azu/style-format": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@azu/style-format/-/style-format-1.0.1.tgz", + "integrity": "sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "@azu/format-text": "^1.0.1" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.2.tgz", + "integrity": "sha512-1D2LpsU7y9xrqKjdIbsB7PlrRePw0xsVV8p+AKTlzITrWmscajryfJCdDJB/oGwvDI5HmRo04eMMADB67uwAwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.24.0.tgz", + "integrity": "sha512-PpLsoDQ3AMmKZ0VU+0GrmqMxgp/sExjlVm4R+nLWngeoEGAzOIPVifaxKGU5gMv+nWELUoHfvrolWD+ZS/nFJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.12.0.tgz", + "integrity": "sha512-eNf2aqx1C6I0yT1GEu5ukblFrmaBXGfe1bivpmlfqvK7giPZvoXLa404C8EfeHVsy6EIryfQuPRzuW1fPxWlHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.7.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.7.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.7.0.tgz", + "integrity": "sha512-Jb8Y7pX6KM42SIT7KWP6YbY3+vLbwB5b5m+tpiiOzMU1QeyelQzs9lO8jv1e7/Uj9r7tg7VjPvW4T0KB1jF3UQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.3.tgz", + "integrity": "sha512-YYX4TchEVddVBiybKvKhV9QO/q22jgewP+BVxKG7Uh115voPcviGlypbKERDsqQdAiSTJrwi80gcWFjYKdo8+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.7.0", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@secretlint/config-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", + "integrity": "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/config-loader": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-10.2.2.tgz", + "integrity": "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "ajv": "^8.17.1", + "debug": "^4.4.1", + "rc-config-loader": "^4.1.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/core": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz", + "integrity": "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "structured-source": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/formatter": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.2.tgz", + "integrity": "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "@textlint/linter-formatter": "^15.2.0", + "@textlint/module-interop": "^15.2.0", + "@textlint/types": "^15.2.0", + "chalk": "^5.4.1", + "debug": "^4.4.1", + "pluralize": "^8.0.0", + "strip-ansi": "^7.1.0", + "table": "^6.9.0", + "terminal-link": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/formatter/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@secretlint/node": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.2.2.tgz", + "integrity": "sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-loader": "^10.2.2", + "@secretlint/core": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "@secretlint/source-creator": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "p-map": "^7.0.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/profiler": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-10.2.2.tgz", + "integrity": "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/resolver": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.2.tgz", + "integrity": "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/secretlint-formatter-sarif": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.2.tgz", + "integrity": "sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-sarif-builder": "^3.2.0" + } + }, + "node_modules/@secretlint/secretlint-rule-no-dotenv": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz", + "integrity": "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/secretlint-rule-preset-recommend": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-10.2.2.tgz", + "integrity": "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/source-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-10.2.2.tgz", + "integrity": "sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2", + "istextorbinary": "^9.5.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/types": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.2.2.tgz", + "integrity": "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@textlint/ast-node-types": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.7.1.tgz", + "integrity": "sha512-Wii5UgUKFEh9Uv6wbq1zr4/Kf+dtjiUuzPrrXzKp8H+ifkvKNzi23V4Nz+6wVyHQn5T28AFuc8VH8OtzvGYecA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.7.1.tgz", + "integrity": "sha512-TdwZ/debWYFD05K3CcoHtwvnCrza29wZxD+BjDTk/V5N7iRqkK1dTTHSD4A8AIgROLiDkHJmIKQbasbmsg8AvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azu/format-text": "^1.0.2", + "@azu/style-format": "^1.0.1", + "@textlint/module-interop": "15.7.1", + "@textlint/resolver": "15.7.1", + "@textlint/types": "15.7.1", + "chalk": "^4.1.2", + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "lodash": "^4.18.1", + "pluralize": "^2.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "table": "^6.9.0", + "text-table": "^0.2.0" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/pluralize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", + "integrity": "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@textlint/module-interop": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.7.1.tgz", + "integrity": "sha512-Jg+sQW2L/cRJypk59wtcMUVVpt8vmit5ZMT3gUnFwevP3A6Qp1HfOtUy9ObT4hBX3lOSGT/ekcCDxR1pL7uH1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/resolver": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.7.1.tgz", + "integrity": "sha512-8XnO0pgF6mXnm41VvWmBbEIdGPhiCUt31uLZkOis1ECeg/1SoUcIT6Mx/F0e1rukq8l0UlOSeY9a31CsvRMK0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/types": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.7.1.tgz", + "integrity": "sha512-Vye/GmFNBTgVzZFtIFJTmLB+s2A7oIADxNG6r9UhfPuY+Czv0z5G3xeyFZZudPlfxURsKUyPIU5XsjOFqVp33A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/ast-node-types": "15.7.1" + } + }, + "node_modules/@types/node": { + "version": "20.19.42", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz", + "integrity": "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/sarif": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", + "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/vscode": { + "version": "1.120.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.120.0.tgz", + "integrity": "sha512-feaT4Rst+FkTch5zz/ZbNCxoIvo55YU80Be2kiL7OJcod4+CUYf2lUBPdIJzozNnSEMq1VRTGrWEcCGFB3fBmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.6.tgz", + "integrity": "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@vscode/vsce": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.2.tgz", + "integrity": "sha512-XSxMosEEDO6vLxELAHVkwmhC0qe0ijZni2jB9Rcs8kQsW4lhTDQ/wMzmwFs/buotAWSnpmUp/dRWD2ufG3UYKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/identity": "^4.1.0", + "@secretlint/node": "^10.1.2", + "@secretlint/secretlint-formatter-sarif": "^10.1.2", + "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", + "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", + "@vscode/vsce-sign": "^2.0.0", + "azure-devops-node-api": "^12.5.0", + "chalk": "^4.1.2", + "cheerio": "^1.0.0-rc.9", + "cockatiel": "^3.1.2", + "commander": "^12.1.0", + "form-data": "^4.0.0", + "glob": "^13.0.6", + "hosted-git-info": "^4.0.2", + "jsonc-parser": "^3.2.0", + "leven": "^3.1.0", + "markdown-it": "^14.1.0", + "mime": "^1.3.4", + "minimatch": "^10.2.2", + "parse-semver": "^1.1.1", + "read": "^1.0.7", + "secretlint": "^10.1.2", + "semver": "^7.5.2", + "tmp": "^0.2.3", + "typed-rest-client": "^1.8.4", + "url-join": "^4.0.1", + "xml2js": "^0.5.0", + "yauzl": "^3.2.1", + "yazl": "^2.2.2" + }, + "bin": { + "vsce": "vsce" + }, + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "keytar": "^7.7.0" + } + }, + "node_modules/@vscode/vsce-sign": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.0.9.tgz", + "integrity": "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g==", + "dev": true, + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optionalDependencies": { + "@vscode/vsce-sign-alpine-arm64": "2.0.6", + "@vscode/vsce-sign-alpine-x64": "2.0.6", + "@vscode/vsce-sign-darwin-arm64": "2.0.6", + "@vscode/vsce-sign-darwin-x64": "2.0.6", + "@vscode/vsce-sign-linux-arm": "2.0.6", + "@vscode/vsce-sign-linux-arm64": "2.0.6", + "@vscode/vsce-sign-linux-x64": "2.0.6", + "@vscode/vsce-sign-win32-arm64": "2.0.6", + "@vscode/vsce-sign-win32-x64": "2.0.6" + } + }, + "node_modules/@vscode/vsce-sign-alpine-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz", + "integrity": "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-alpine-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz", + "integrity": "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz", + "integrity": "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz", + "integrity": "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz", + "integrity": "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz", + "integrity": "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz", + "integrity": "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-win32-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz", + "integrity": "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce-sign-win32-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz", + "integrity": "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/azure-devops-node-api": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", + "integrity": "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "0.0.6", + "typed-rest-client": "^1.8.4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/binaryextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", + "integrity": "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/boundary": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", + "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/cockatiel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", + "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/editions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/editions/-/editions-6.22.0.tgz", + "integrity": "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "version-range": "^4.15.0" + }, + "engines": { + "ecmascript": ">= es5", + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istextorbinary": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", + "integrity": "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "binaryextensions": "^6.11.0", + "editions": "^6.21.0", + "textextensions": "^6.11.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keytar": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", + "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/linkify-it": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.1", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-sarif-builder": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.4.0.tgz", + "integrity": "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sarif": "^2.1.7", + "fs-extra": "^11.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-semver": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz", + "integrity": "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.1.0" + } + }, + "node_modules/parse-semver/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc-config-loader": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.4.tgz", + "integrity": "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "json5": "^2.2.3", + "require-from-string": "^2.0.2" + } + }, + "node_modules/read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "mute-stream": "~0.0.4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/secretlint": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", + "integrity": "sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-creator": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/node": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "debug": "^4.4.1", + "globby": "^14.1.0", + "read-pkg": "^9.0.1" + }, + "bin": { + "secretlint": "bin/secretlint.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/structured-source": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", + "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boundary": "^2.0.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/terminal-link": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", + "integrity": "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "supports-hyperlinks": "^3.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/textextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz", + "integrity": "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-rest-client": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", + "integrity": "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "qs": "^6.9.1", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.1.tgz", + "integrity": "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/version-range": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", + "integrity": "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==", + "dev": true, + "license": "Artistic-2.0", + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.3.2.tgz", + "integrity": "sha512-Md9ankxxN23wncAN8s7+Tn3Co52zLUPMtnrLAbVCnfG5d2tKBFfmygYSgXlqFgXObtzIgqkx7aNgDBpso9+4qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yazl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", + "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3" + } + } + } +} diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json new file mode 100644 index 000000000..80e2947f7 --- /dev/null +++ b/extensions/vscode/package.json @@ -0,0 +1,126 @@ +{ + "name": "codewhale-vscode", + "displayName": "CodeWhale", + "description": "Official CodeWhale VS Code integration scaffold for local runtime attach and terminal launch.", + "version": "0.8.53", + "publisher": "codewhale", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/Hmbown/CodeWhale.git", + "directory": "extensions/vscode" + }, + "engines": { + "vscode": "^1.90.0" + }, + "categories": [ + "Other" + ], + "activationEvents": [ + "onCommand:codewhale.openTerminal", + "onCommand:codewhale.startRuntime", + "onCommand:codewhale.checkRuntime", + "onCommand:codewhale.refreshAgentView", + "onCommand:codewhale.refreshSnapshots", + "onCommand:codewhale.openRuntimeDocs", + "onView:codewhale.runtimeStatus" + ], + "main": "./out/extension.js", + "files": [ + "out", + "media", + "README.md", + "LICENSE" + ], + "contributes": { + "commands": [ + { + "command": "codewhale.openTerminal", + "title": "CodeWhale: Open Terminal" + }, + { + "command": "codewhale.startRuntime", + "title": "CodeWhale: Start Local Runtime" + }, + { + "command": "codewhale.checkRuntime", + "title": "CodeWhale: Check Runtime" + }, + { + "command": "codewhale.refreshAgentView", + "title": "CodeWhale: Refresh Agent View" + }, + { + "command": "codewhale.refreshSnapshots", + "title": "CodeWhale: Refresh Restore Points" + }, + { + "command": "codewhale.openRuntimeDocs", + "title": "CodeWhale: Open Runtime API Docs" + } + ], + "configuration": { + "title": "CodeWhale", + "properties": { + "codewhale.commandPath": { + "type": "string", + "default": "codewhale", + "description": "Command or absolute path used to launch CodeWhale." + }, + "codewhale.runtimeHost": { + "type": "string", + "default": "127.0.0.1", + "description": "Local host used for CodeWhale runtime attach checks." + }, + "codewhale.runtimePort": { + "type": "number", + "default": 7878, + "minimum": 1, + "maximum": 65535, + "description": "Local port used for CodeWhale runtime attach checks." + }, + "codewhale.runtimeToken": { + "type": "string", + "default": "", + "description": "Optional bearer token for authenticated runtime endpoints." + }, + "codewhale.agentViewRefreshIntervalSeconds": { + "type": "number", + "default": 15, + "minimum": 0, + "maximum": 300, + "description": "Seconds between read-only Agent View refreshes. Set to 0 to disable automatic refresh." + } + } + }, + "viewsContainers": { + "activitybar": [ + { + "id": "codewhale", + "title": "CodeWhale", + "icon": "media/codewhale.svg" + } + ] + }, + "views": { + "codewhale": [ + { + "type": "webview", + "id": "codewhale.runtimeStatus", + "name": "Agent View" + } + ] + } + }, + "scripts": { + "compile": "tsc -p ./", + "check": "npm run compile", + "package": "vsce package --no-dependencies" + }, + "devDependencies": { + "@types/node": "^20.19.27", + "@types/vscode": "^1.90.0", + "@vscode/vsce": "^3.7.0", + "typescript": "^5.9.3" + } +} diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts new file mode 100644 index 000000000..56c62edc8 --- /dev/null +++ b/extensions/vscode/src/extension.ts @@ -0,0 +1,212 @@ +import * as vscode from "vscode"; +import { + checkRuntime, + listSnapshots, + listThreadSummaries, + openCodeWhaleTerminal, + readRuntimeConfig, + runtimeBaseUrl, + startRuntimeTerminal, + type RuntimeState, +} from "./runtime"; +import { RuntimeStatusView } from "./status"; + +export function activate(context: vscode.ExtensionContext): void { + const output = vscode.window.createOutputChannel("CodeWhale"); + const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); + const statusView = new RuntimeStatusView(); + let autoRefreshTimer: ReturnType | undefined; + let autoRefreshInFlight = false; + + status.command = "codewhale.checkRuntime"; + context.subscriptions.push(output, status); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider(RuntimeStatusView.viewType, statusView), + ); + + const refreshAgentView = async (): Promise => { + const config = readRuntimeConfig(); + const threads = await listThreadSummaries(config); + statusView.updateThreads(threads, "Showing recent runtime threads."); + output.appendLine(`Loaded ${threads.length} runtime thread summaries.`); + }; + + const refreshSnapshots = async (): Promise => { + const config = readRuntimeConfig(); + const snapshots = await listSnapshots(config); + statusView.updateSnapshots(snapshots, "Showing recent restore points."); + output.appendLine(`Loaded ${snapshots.length} runtime restore points.`); + }; + + const refreshAgentViewDetails = async (showWarning: boolean): Promise => { + try { + await refreshAgentView(); + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + statusView.updateThreads([], "Runtime thread summaries unavailable."); + output.appendLine(`Runtime thread summaries unavailable: ${detail}`); + if (showWarning) { + void vscode.window.showWarningMessage(detail); + } + } + + try { + await refreshSnapshots(); + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + statusView.updateSnapshots([], detail); + output.appendLine(`Runtime restore points unavailable: ${detail}`); + if (showWarning) { + void vscode.window.showWarningMessage(detail); + } + } + }; + + const updateStatus = (text: string, tooltip: string): void => { + status.text = text; + status.tooltip = tooltip; + status.show(); + }; + + const checkAndRefreshRuntime = async ( + showSpinner: boolean, + logResult: boolean, + ): Promise => { + const config = readRuntimeConfig(); + if (showSpinner) { + updateStatus("$(sync~spin) CodeWhale", "Checking CodeWhale runtime..."); + } + + const state = await checkRuntime(config); + statusView.update(state); + + switch (state.kind) { + case "connected": + updateStatus("$(check) CodeWhale", state.detail); + await refreshAgentViewDetails(false); + break; + case "auth-required": + updateStatus("$(lock) CodeWhale", state.detail); + statusView.updateThreads([], "Runtime token is required before threads can load."); + statusView.updateSnapshots([], "Runtime token is required before restore points can load."); + break; + case "offline": + case "error": + updateStatus("$(warning) CodeWhale", state.detail); + statusView.updateThreads([], "Connect to the runtime to load recent threads."); + statusView.updateSnapshots([], "Connect to the runtime to load restore points."); + break; + } + + if (logResult) { + output.appendLine(`${new Date().toISOString()} ${state.kind}: ${state.detail}`); + } + return state; + }; + + const runAutoRefresh = async (): Promise => { + if (autoRefreshInFlight) { + return; + } + + autoRefreshInFlight = true; + try { + await checkAndRefreshRuntime(false, false); + } finally { + autoRefreshInFlight = false; + } + }; + + const scheduleAutoRefresh = (): void => { + if (autoRefreshTimer) { + clearInterval(autoRefreshTimer); + autoRefreshTimer = undefined; + } + + const intervalSeconds = readRuntimeConfig().agentViewRefreshIntervalSeconds; + if (intervalSeconds === 0) { + output.appendLine("Agent View auto-refresh is disabled."); + return; + } + + autoRefreshTimer = setInterval(() => { + void runAutoRefresh(); + }, intervalSeconds * 1000); + output.appendLine(`Agent View auto-refresh scheduled every ${intervalSeconds}s.`); + }; + + updateStatus("$(terminal) CodeWhale", "Check CodeWhale runtime"); + scheduleAutoRefresh(); + context.subscriptions.push( + new vscode.Disposable(() => { + if (autoRefreshTimer) { + clearInterval(autoRefreshTimer); + } + }), + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("codewhale.agentViewRefreshIntervalSeconds")) { + scheduleAutoRefresh(); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("codewhale.openTerminal", () => { + const config = readRuntimeConfig(); + openCodeWhaleTerminal(config); + output.appendLine(`Opened CodeWhale terminal using ${config.commandPath}.`); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("codewhale.startRuntime", () => { + const config = readRuntimeConfig(); + startRuntimeTerminal(config); + const baseUrl = runtimeBaseUrl(config); + updateStatus("$(sync~spin) CodeWhale", `Runtime terminal started for ${baseUrl}`); + output.appendLine(`Started CodeWhale runtime terminal at ${baseUrl}.`); + void vscode.window.showInformationMessage(`CodeWhale runtime starting at ${baseUrl}`); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("codewhale.checkRuntime", async () => { + return await checkAndRefreshRuntime(true, true); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("codewhale.refreshAgentView", async () => { + await refreshAgentViewDetails(true); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("codewhale.refreshSnapshots", async () => { + try { + await refreshSnapshots(); + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + statusView.updateSnapshots([], detail); + output.appendLine(`Runtime restore points unavailable: ${detail}`); + void vscode.window.showWarningMessage(detail); + } + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("codewhale.openRuntimeDocs", () => { + void vscode.env.openExternal( + vscode.Uri.parse( + "https://github.com/Hmbown/CodeWhale/blob/main/docs/RUNTIME_API.md", + ), + ); + }), + ); + + void vscode.commands.executeCommand("codewhale.checkRuntime"); +} + +export function deactivate(): void { + // No background process is owned by the extension; runtime starts in a user-visible terminal. +} diff --git a/extensions/vscode/src/runtime.ts b/extensions/vscode/src/runtime.ts new file mode 100644 index 000000000..b7d73e260 --- /dev/null +++ b/extensions/vscode/src/runtime.ts @@ -0,0 +1,288 @@ +import * as http from "node:http"; +import * as vscode from "vscode"; + +export type RuntimeStateKind = "connected" | "offline" | "auth-required" | "error"; + +export interface RuntimeState { + kind: RuntimeStateKind; + baseUrl: string; + detail: string; + version?: string; +} + +export interface ThreadSummary { + id: string; + title: string; + preview: string; + model: string; + mode: string; + workspace?: string; + branch?: string; + head?: string; + dirty: boolean; + archived: boolean; + updatedAt: string; + latestTurnStatus?: string; +} + +export interface SnapshotEntry { + id: string; + label: string; + timestamp: number; +} + +export interface RuntimeConfig { + commandPath: string; + host: string; + port: number; + token?: string; + agentViewRefreshIntervalSeconds: number; +} + +export function readRuntimeConfig(): RuntimeConfig { + const config = vscode.workspace.getConfiguration("codewhale"); + const commandPath = config.get("commandPath", "codewhale").trim() || "codewhale"; + const host = config.get("runtimeHost", "127.0.0.1").trim() || "127.0.0.1"; + const port = config.get("runtimePort", 7878); + const token = config.get("runtimeToken", "").trim(); + const interval = config.get("agentViewRefreshIntervalSeconds", 15); + return { + commandPath, + host, + port, + token: token.length > 0 ? token : undefined, + agentViewRefreshIntervalSeconds: clampRefreshInterval(interval), + }; +} + +export function runtimeBaseUrl(config: RuntimeConfig): string { + return `http://${config.host}:${config.port}`; +} + +export async function checkRuntime(config: RuntimeConfig): Promise { + const baseUrl = runtimeBaseUrl(config); + const health = await requestJson(`${baseUrl}/health`, config.token); + if (health.statusCode === 0) { + return { kind: "offline", baseUrl, detail: "Runtime is not reachable." }; + } + if (health.statusCode === 401) { + return { kind: "auth-required", baseUrl, detail: "Runtime requires a token." }; + } + if (health.statusCode !== 200) { + return { + kind: "error", + baseUrl, + detail: `Health check returned HTTP ${health.statusCode}.`, + }; + } + + const info = await requestJson(`${baseUrl}/v1/runtime/info`, config.token); + if (info.statusCode === 401) { + return { kind: "auth-required", baseUrl, detail: "Runtime info requires a token." }; + } + + const version = readVersion(info.body); + return { + kind: "connected", + baseUrl, + detail: version ? `Connected to CodeWhale ${version}.` : "Connected to CodeWhale runtime.", + version, + }; +} + +export async function listThreadSummaries( + config: RuntimeConfig, + limit = 8, +): Promise { + const baseUrl = runtimeBaseUrl(config); + const response = await requestJson( + `${baseUrl}/v1/threads/summary?limit=${encodeURIComponent(String(limit))}`, + config.token, + ); + + if (response.statusCode === 401) { + throw new Error("Thread summaries require the runtime bearer token."); + } + if (response.statusCode !== 200) { + throw new Error(`Thread summary returned HTTP ${response.statusCode}.`); + } + + return readThreadSummaries(response.body); +} + +export async function listSnapshots(config: RuntimeConfig, limit = 8): Promise { + const baseUrl = runtimeBaseUrl(config); + const response = await requestJson( + `${baseUrl}/v1/snapshots?limit=${encodeURIComponent(String(limit))}`, + config.token, + ); + + if (response.statusCode === 401) { + throw new Error("Restore points require the runtime bearer token."); + } + if (response.statusCode !== 200) { + throw new Error(`Restore points returned HTTP ${response.statusCode}.`); + } + + return readSnapshots(response.body); +} + +export function startRuntimeTerminal(config: RuntimeConfig): vscode.Terminal { + const terminal = vscode.window.createTerminal("CodeWhale Runtime"); + const args = [ + "serve", + "--http", + "--host", + shellQuote(config.host), + "--port", + String(config.port), + ]; + if (config.token) { + args.push("--auth-token", shellQuote(config.token)); + } + terminal.sendText(`${shellQuote(config.commandPath)} ${args.join(" ")}`); + terminal.show(); + return terminal; +} + +export function openCodeWhaleTerminal(config: RuntimeConfig): vscode.Terminal { + const terminal = vscode.window.createTerminal("CodeWhale"); + terminal.sendText(shellQuote(config.commandPath)); + terminal.show(); + return terminal; +} + +async function requestJson( + url: string, + token: string | undefined, +): Promise<{ statusCode: number; body: unknown }> { + try { + return await new Promise<{ statusCode: number; body: unknown }>((resolve, reject) => { + const request = http.get( + url, + { + timeout: 2500, + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }, + (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk: string) => { + body += chunk; + }); + response.on("end", () => { + resolve({ + statusCode: response.statusCode ?? 0, + body: parseJson(body), + }); + }); + }, + ); + + request.on("timeout", () => { + request.destroy(new Error("Runtime check timed out.")); + }); + request.on("error", reject); + }); + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + return { statusCode: 0, body: { error: detail } }; + } +} + +function parseJson(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return undefined; + } +} + +function readVersion(value: unknown): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const version = (value as { version?: unknown }).version; + return typeof version === "string" ? version : undefined; +} + +function readThreadSummaries(value: unknown): ThreadSummary[] { + if (!Array.isArray(value)) { + return []; + } + + return value.flatMap((item) => { + if (!item || typeof item !== "object") { + return []; + } + const record = item as Record; + const id = readString(record.id); + if (!id) { + return []; + } + + return [ + { + id, + title: readString(record.title) ?? "New Thread", + preview: readString(record.preview) ?? "", + model: readString(record.model) ?? "unknown", + mode: readString(record.mode) ?? "agent", + workspace: readString(record.workspace), + branch: readString(record.branch), + head: readString(record.head), + dirty: readBoolean(record.dirty), + archived: record.archived === true, + updatedAt: readString(record.updated_at) ?? "", + latestTurnStatus: readString(record.latest_turn_status), + }, + ]; + }); +} + +function readSnapshots(value: unknown): SnapshotEntry[] { + if (!Array.isArray(value)) { + return []; + } + + return value.flatMap((item) => { + if (!item || typeof item !== "object") { + return []; + } + const record = item as Record; + const id = readString(record.id); + const label = readString(record.label); + const timestamp = readNumber(record.timestamp); + if (!id || !label || timestamp === undefined) { + return []; + } + + return [{ id, label, timestamp }]; + }); +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function readBoolean(value: unknown): boolean { + return value === true; +} + +function clampRefreshInterval(value: number): number { + if (!Number.isFinite(value)) { + return 15; + } + return Math.max(0, Math.min(300, Math.floor(value))); +} + +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:=+-]+$/.test(value)) { + return value; + } + return `'${value.replace(/'/g, "'\\''")}'`; +} diff --git a/extensions/vscode/src/status.ts b/extensions/vscode/src/status.ts new file mode 100644 index 000000000..af91b2ea5 --- /dev/null +++ b/extensions/vscode/src/status.ts @@ -0,0 +1,195 @@ +import * as vscode from "vscode"; +import type { RuntimeState, SnapshotEntry, ThreadSummary } from "./runtime"; + +export class RuntimeStatusView implements vscode.WebviewViewProvider { + public static readonly viewType = "codewhale.runtimeStatus"; + + private view?: vscode.WebviewView; + private state: RuntimeState = { + kind: "offline", + baseUrl: "http://127.0.0.1:7878", + detail: "Runtime has not been checked yet.", + }; + private threads: ThreadSummary[] = []; + private threadsDetail = "Connect to the runtime to load recent threads."; + private snapshots: SnapshotEntry[] = []; + private snapshotsDetail = "Connect to the runtime to load restore points."; + + resolveWebviewView(view: vscode.WebviewView): void { + this.view = view; + view.webview.options = { enableScripts: true }; + view.webview.onDidReceiveMessage((message: { command?: string }) => { + if (message.command === "check") { + void vscode.commands.executeCommand("codewhale.checkRuntime"); + } else if (message.command === "start") { + void vscode.commands.executeCommand("codewhale.startRuntime"); + } else if (message.command === "terminal") { + void vscode.commands.executeCommand("codewhale.openTerminal"); + } else if (message.command === "threads") { + void vscode.commands.executeCommand("codewhale.refreshAgentView"); + } else if (message.command === "snapshots") { + void vscode.commands.executeCommand("codewhale.refreshSnapshots"); + } + }); + this.render(); + } + + update(state: RuntimeState): void { + this.state = state; + this.render(); + } + + updateThreads(threads: ThreadSummary[], detail: string): void { + this.threads = threads; + this.threadsDetail = detail; + this.render(); + } + + updateSnapshots(snapshots: SnapshotEntry[], detail: string): void { + this.snapshots = snapshots; + this.snapshotsDetail = detail; + this.render(); + } + + private render(): void { + if (!this.view) { + return; + } + + const badge = labelFor(this.state.kind); + const nonce = makeNonce(); + const threadsHtml = + this.threads.length > 0 + ? this.threads.map((thread) => renderThread(thread)).join("") + : `

${escapeHtml(this.threadsDetail)}

`; + const snapshotsHtml = + this.snapshots.length > 0 + ? this.snapshots.map((snapshot) => renderSnapshot(snapshot)).join("") + : `

${escapeHtml(this.snapshotsDetail)}

`; + this.view.webview.html = ` + + + + + + + + +
${escapeHtml(badge)}
+

${escapeHtml(this.state.detail)}

+

${escapeHtml(this.state.baseUrl)}

+ + + + + +
Agent View
+ ${threadsHtml} +
Restore Points
+ ${snapshotsHtml} + + +`; + } +} + +function renderSnapshot(snapshot: SnapshotEntry): string { + return `
+
${escapeHtml(snapshot.label)}
+
${escapeHtml(`${snapshot.id} · ${formatUnixTimestamp(snapshot.timestamp)}`)}
+
`; +} + +function renderThread(thread: ThreadSummary): string { + const status = thread.latestTurnStatus ? ` · ${thread.latestTurnStatus}` : ""; + const archived = thread.archived ? " · archived" : ""; + const git = renderGitMetadata(thread); + const workspace = thread.workspace ? ` · ${thread.workspace}` : ""; + const updated = thread.updatedAt ? ` · ${formatTimestamp(thread.updatedAt)}` : ""; + return `
+
${escapeHtml(thread.title)}
+
${escapeHtml(thread.preview || "No recent message.")}
+
${escapeHtml(`${thread.mode} · ${thread.model}${status}${git}${archived}${updated}${workspace}`)}
+
`; +} + +function renderGitMetadata(thread: ThreadSummary): string { + if (!thread.branch && !thread.head && !thread.dirty) { + return ""; + } + + const parts: string[] = []; + if (thread.branch) { + parts.push(`branch ${thread.branch}`); + } + if (thread.head) { + parts.push(`@ ${thread.head}`); + } + if (thread.dirty) { + parts.push("dirty"); + } + return ` · ${parts.join(" ")}`; +} + +function labelFor(kind: RuntimeState["kind"]): string { + switch (kind) { + case "connected": + return "Connected"; + case "auth-required": + return "Token Required"; + case "error": + return "Runtime Error"; + case "offline": + return "Offline"; + } +} + +function formatTimestamp(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleString(); +} + +function formatUnixTimestamp(value: number): string { + const date = new Date(value * 1000); + if (Number.isNaN(date.getTime())) { + return String(value); + } + return date.toLocaleString(); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function makeNonce(): string { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let nonce = ""; + for (let index = 0; index < 32; index += 1) { + nonce += alphabet.charAt(Math.floor(Math.random() * alphabet.length)); + } + return nonce; +} diff --git a/extensions/vscode/tsconfig.json b/extensions/vscode/tsconfig.json new file mode 100644 index 000000000..ce31b8d1f --- /dev/null +++ b/extensions/vscode/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2022", + "lib": ["ES2022"], + "outDir": "out", + "rootDir": "src", + "strict": true, + "sourceMap": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/npm/codewhale/README.md b/npm/codewhale/README.md index 25fda9dc4..a35aa51b5 100644 --- a/npm/codewhale/README.md +++ b/npm/codewhale/README.md @@ -1,7 +1,12 @@ # codewhale -Install and run CodeWhale, the agentic terminal for open-source and open-weight coding -models, from GitHub release artifacts. +Install and run CodeWhale, the agentic terminal for DeepSeek and other +OpenAI-compatible coding models, from GitHub release artifacts. + +This npm package is a small launcher: it downloads the matching native +CodeWhale binaries for your platform and exposes the `codewhale` and +`codewhale-tui` commands. The application state and credentials still live in +CodeWhale's normal config files, not inside `node_modules`. > Previously published as `deepseek-tui`. See `docs/REBRAND.md` in the upstream > repository for the migration notes; the legacy `deepseek-tui` npm package is @@ -22,10 +27,9 @@ npm install codewhale npx codewhale --help ``` -`postinstall` tries to download platform binaries into `bin/downloads/` and -exposes `codewhale` and `codewhale-tui` commands. If GitHub release assets are -temporarily unreachable, install continues and the wrapper retries the download -on first run. +`postinstall` tries to download platform binaries into `bin/downloads/`. If +GitHub release assets are temporarily unreachable, install continues and the +wrapper retries the download on first run. ## First run @@ -75,19 +79,17 @@ compatibility problems still fail with a clear error pointing you at [docs/INSTALL.md](https://github.com/Hmbown/CodeWhale/blob/main/docs/INSTALL.md) build-from-source guide. -## Configuration - -- Default binary version comes from `codewhaleBinaryVersion` in `package.json` - (with `deepseekBinaryVersion` as a backward-compat fallback). -- Set `DEEPSEEK_TUI_VERSION` or `DEEPSEEK_VERSION` to override the release version. -- Set `DEEPSEEK_TUI_GITHUB_REPO` or `DEEPSEEK_GITHUB_REPO` to override the source repo (defaults to `Hmbown/CodeWhale`). -- Set `DEEPSEEK_TUI_RELEASE_BASE_URL` to use an internal or mirrored - release-asset directory when GitHub Releases is unavailable. The directory - must contain `codewhale-artifacts-sha256.txt` and the platform binaries. -- Set `DEEPSEEK_TUI_FORCE_DOWNLOAD=1` to force download even when the cached binary is already present. -- Set `DEEPSEEK_TUI_DISABLE_INSTALL=1` to skip install-time download. -- Set `DEEPSEEK_TUI_OPTIONAL_INSTALL=1` to make install-time retryable download - failures warn and exit `0` instead of failing `npm install`. +## Wrapper configuration + +| Setting | What it does | +| --- | --- | +| `codewhaleBinaryVersion` in `package.json` | Default native binary version. `deepseekBinaryVersion` is still read as a backward-compat fallback. | +| `DEEPSEEK_TUI_VERSION` or `DEEPSEEK_VERSION` | Override the GitHub release version to download. | +| `DEEPSEEK_TUI_GITHUB_REPO` or `DEEPSEEK_GITHUB_REPO` | Override the source repo. Defaults to `Hmbown/CodeWhale`. | +| `DEEPSEEK_TUI_RELEASE_BASE_URL` | Use an internal or mirrored release-asset directory when GitHub Releases is unavailable. The directory must contain `codewhale-artifacts-sha256.txt` and the platform binaries. | +| `DEEPSEEK_TUI_FORCE_DOWNLOAD=1` | Force download even when the cached binary is already present. | +| `DEEPSEEK_TUI_DISABLE_INSTALL=1` | Skip install-time download. | +| `DEEPSEEK_TUI_OPTIONAL_INSTALL=1` | Make install-time retryable download failures warn and exit `0` instead of failing `npm install`. | ## Release integrity diff --git a/npm/codewhale/package.json b/npm/codewhale/package.json index 7209b6dce..f8222ee03 100644 --- a/npm/codewhale/package.json +++ b/npm/codewhale/package.json @@ -1,8 +1,8 @@ { "name": "codewhale", - "version": "0.8.53", - "codewhaleBinaryVersion": "0.8.53", - "description": "Install and run CodeWhale, the agentic terminal for open-source and open-weight coding models, from GitHub release artifacts.", + "version": "0.9.0", + "codewhaleBinaryVersion": "0.9.0", + "description": "Install and run CodeWhale, the agentic terminal for DeepSeek and other OpenAI-compatible coding models.", "author": "Hmbown", "license": "MIT", "funding": [ diff --git a/npm/deepseek-tui/README.md b/npm/deepseek-tui/README.md index 898ae5304..8259848e1 100644 --- a/npm/deepseek-tui/README.md +++ b/npm/deepseek-tui/README.md @@ -8,9 +8,9 @@ npm install -g codewhale ``` This legacy npm package is deprecated and receives no further releases. -`codewhale` ships the canonical `codewhale` and `codewhale-tui` binaries, plus -compatibility-only deprecation shims under the old `deepseek` / -`deepseek-tui` binary names for v0.8.x. +`codewhale` ships the canonical `codewhale`, `codew`, and `codewhale-tui` +binaries. Compatibility-only deprecation shims under the old `deepseek` / +`deepseek-tui` binary names were available in v0.8.x and are removed in v0.9.0. See [docs/REBRAND.md](https://github.com/Hmbown/CodeWhale/blob/main/docs/REBRAND.md) for the full migration story. diff --git a/ohos-clang.ps1 b/ohos-clang.ps1 new file mode 100644 index 000000000..72b1dbb6d --- /dev/null +++ b/ohos-clang.ps1 @@ -0,0 +1,23 @@ +$ErrorActionPreference = "Stop" + +if ([string]::IsNullOrWhiteSpace($env:OHOS_NATIVE_SDK)) { + [Console]::Error.WriteLine("error: set OHOS_NATIVE_SDK to the OpenHarmony native SDK directory. It must contain llvm\bin and sysroot.") + exit 1 +} + +$sdk = $env:OHOS_NATIVE_SDK +$clang = [System.IO.Path]::Combine($sdk, "llvm", "bin", "clang.exe") +$sysroot = [System.IO.Path]::Combine($sdk, "sysroot") + +if (-not (Test-Path -LiteralPath $clang -PathType Leaf -ErrorAction SilentlyContinue)) { + [Console]::Error.WriteLine("error: OHOS_NATIVE_SDK does not contain llvm\bin\clang.exe: $sdk") + exit 1 +} + +if (-not (Test-Path -LiteralPath $sysroot -PathType Container -ErrorAction SilentlyContinue)) { + [Console]::Error.WriteLine("error: OHOS_NATIVE_SDK does not contain sysroot: $sdk") + exit 1 +} + +& $clang -target aarch64-linux-ohos "--sysroot=$sysroot" -D__MUSL__ @args +exit $LASTEXITCODE diff --git a/ohos-clang.sh b/ohos-clang.sh new file mode 100644 index 000000000..9ca800597 --- /dev/null +++ b/ohos-clang.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env sh +set -eu + +if [ -z "${OHOS_NATIVE_SDK:-}" ]; then + echo "error: set OHOS_NATIVE_SDK to the OpenHarmony native SDK directory. It must contain llvm/bin and sysroot." >&2 + exit 1 +fi + +sdk=$OHOS_NATIVE_SDK +clang=$sdk/llvm/bin/clang +sysroot=$sdk/sysroot + +if [ ! -x "$clang" ]; then + echo "error: OHOS_NATIVE_SDK does not contain executable llvm/bin/clang: $sdk" >&2 + exit 1 +fi + +if [ ! -d "$sysroot" ]; then + echo "error: OHOS_NATIVE_SDK does not contain sysroot: $sdk" >&2 + exit 1 +fi + +exec "$clang" -target aarch64-linux-ohos "--sysroot=$sysroot" -D__MUSL__ "$@" diff --git a/ohos-clangxx.ps1 b/ohos-clangxx.ps1 new file mode 100644 index 000000000..f1c48e175 --- /dev/null +++ b/ohos-clangxx.ps1 @@ -0,0 +1,23 @@ +$ErrorActionPreference = "Stop" + +if ([string]::IsNullOrWhiteSpace($env:OHOS_NATIVE_SDK)) { + [Console]::Error.WriteLine("error: set OHOS_NATIVE_SDK to the OpenHarmony native SDK directory. It must contain llvm\bin and sysroot.") + exit 1 +} + +$sdk = $env:OHOS_NATIVE_SDK +$clangxx = [System.IO.Path]::Combine($sdk, "llvm", "bin", "clang++.exe") +$sysroot = [System.IO.Path]::Combine($sdk, "sysroot") + +if (-not (Test-Path -LiteralPath $clangxx -PathType Leaf -ErrorAction SilentlyContinue)) { + [Console]::Error.WriteLine("error: OHOS_NATIVE_SDK does not contain llvm\bin\clang++.exe: $sdk") + exit 1 +} + +if (-not (Test-Path -LiteralPath $sysroot -PathType Container -ErrorAction SilentlyContinue)) { + [Console]::Error.WriteLine("error: OHOS_NATIVE_SDK does not contain sysroot: $sdk") + exit 1 +} + +& $clangxx -target aarch64-linux-ohos "--sysroot=$sysroot" -D__MUSL__ @args +exit $LASTEXITCODE diff --git a/ohos-clangxx.sh b/ohos-clangxx.sh new file mode 100644 index 000000000..db8818237 --- /dev/null +++ b/ohos-clangxx.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env sh +set -eu + +if [ -z "${OHOS_NATIVE_SDK:-}" ]; then + echo "error: set OHOS_NATIVE_SDK to the OpenHarmony native SDK directory. It must contain llvm/bin and sysroot." >&2 + exit 1 +fi + +sdk=$OHOS_NATIVE_SDK +clangxx=$sdk/llvm/bin/clang++ +sysroot=$sdk/sysroot + +if [ ! -x "$clangxx" ]; then + echo "error: OHOS_NATIVE_SDK does not contain executable llvm/bin/clang++: $sdk" >&2 + exit 1 +fi + +if [ ! -d "$sysroot" ]; then + echo "error: OHOS_NATIVE_SDK does not contain sysroot: $sdk" >&2 + exit 1 +fi + +exec "$clangxx" -target aarch64-linux-ohos "--sysroot=$sysroot" -D__MUSL__ "$@" diff --git a/scripts/check-coauthor-trailers.py b/scripts/check-coauthor-trailers.py new file mode 100644 index 000000000..1ecd605cb --- /dev/null +++ b/scripts/check-coauthor-trailers.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Validate that harvested contributor credit is GitHub-mappable. + +The check is intentionally scoped to new commits. Historical commits may carry +raw or local emails, but new harvested commits should use GitHub's numeric +`id+login@users.noreply.github.com` address so co-author credit lands in the +contributor graph. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_AUTHOR_MAP = ROOT / ".github" / "AUTHOR_MAP" + +IDENTITY_RE = re.compile(r"^\s*(?P.+?)\s*<(?P[^<>]+)>\s*$") +CANONICAL_NOREPLY_RE = re.compile( + r"^[0-9]+\+[^@\s]+@users\.noreply\.github\.com$", re.IGNORECASE +) +COAUTHOR_RE = re.compile( + r"^Co-authored-by:\s*(?P.*?)\s*<(?P[^<>]+)>\s*$", + re.IGNORECASE | re.MULTILINE, +) +HARVEST_RE = re.compile(r"Harvested from PR #[0-9]+ by @([A-Za-z0-9-]+)") + +BOT_EMAILS = { + "codex@local", + "codex@example.com", + "cursoragent@cursor.com", + "noreply@anthropic.com", +} +BOT_NAMES = ("claude", "codex", "cursor") + + +@dataclass(frozen=True) +class Identity: + name: str + email: str + + def trailer(self) -> str: + return f"Co-authored-by: {self.name} <{self.email}>" + + def author(self) -> str: + return f"{self.name} <{self.email}>" + + +@dataclass(frozen=True) +class Commit: + sha: str + author_name: str + author_email: str + subject: str + body: str + + +def norm_key(value: str) -> str: + return value.strip().lower() + + +def github_login_from_noreply(email: str) -> str | None: + if not CANONICAL_NOREPLY_RE.match(email): + return None + local = email.split("@", 1)[0] + return local.split("+", 1)[1] + + +def parse_identity(raw: str, context: str) -> Identity: + match = IDENTITY_RE.match(raw) + if not match: + raise ValueError(f"{context}: expected 'Name '") + identity = Identity(match.group("name").strip(), match.group("email").strip()) + if not CANONICAL_NOREPLY_RE.match(identity.email): + raise ValueError( + f"{context}: right-hand email must be numeric GitHub noreply, got {identity.email}" + ) + return identity + + +def load_author_map(path: Path) -> dict[str, Identity]: + aliases: dict[str, Identity] = {} + for lineno, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + line = raw_line.split("#", 1)[0].strip() + if not line: + continue + if "=" not in line: + raise ValueError(f"{path}:{lineno}: expected 'alias = Name '") + alias, raw_identity = [part.strip() for part in line.split("=", 1)] + identity = parse_identity(raw_identity, f"{path}:{lineno}") + key = norm_key(alias) + if key in aliases and aliases[key] != identity: + raise ValueError(f"{path}:{lineno}: duplicate alias {alias!r}") + aliases[key] = identity + aliases.setdefault(norm_key(identity.email), identity) + aliases.setdefault(norm_key(identity.name), identity) + if login := github_login_from_noreply(identity.email): + aliases.setdefault(norm_key(login), identity) + return aliases + + +def git_log(commit_range: str) -> list[Commit]: + try: + raw = subprocess.check_output( + [ + "git", + "log", + "--format=%H%x00%an%x00%ae%x00%s%x00%B%x1e", + commit_range, + ], + cwd=ROOT, + text=True, + ) + except subprocess.CalledProcessError as exc: + raise RuntimeError(f"failed to read git range {commit_range!r}: {exc}") from exc + + commits: list[Commit] = [] + for record in raw.split("\x1e"): + if not record.strip(): + continue + parts = record.split("\x00", 4) + if len(parts) != 5: + raise RuntimeError("failed to parse git log output") + commits.append(Commit(*parts)) + return commits + + +def is_bot_identity(name: str, email: str) -> bool: + lowered_name = name.strip().lower() + lowered_email = email.strip().lower() + return lowered_email in BOT_EMAILS or any( + lowered_name == bot or lowered_name.startswith(f"{bot} ") for bot in BOT_NAMES + ) + + +def lookup_identity(aliases: dict[str, Identity], *values: str) -> Identity | None: + for value in values: + identity = aliases.get(norm_key(value)) + if identity is not None: + return identity + return None + + +def validate(commits: list[Commit], aliases: dict[str, Identity], check_authors: bool) -> list[str]: + errors: list[str] = [] + for commit in commits: + prefix = f"{commit.sha[:10]} {commit.subject}" + coauthors = [ + Identity(match.group("name").strip(), match.group("email").strip()) + for match in COAUTHOR_RE.finditer(commit.body) + ] + harvested_logins = HARVEST_RE.findall(commit.body) + is_harvested_commit = bool(harvested_logins) + mapped_author = lookup_identity(aliases, commit.author_email, commit.author_name) + + if check_authors: + if is_harvested_commit and is_bot_identity(commit.author_name, commit.author_email): + errors.append( + f"{prefix}: author {commit.author_name} <{commit.author_email}> is a " + "bot/tool identity. Human harvested work should preserve the contributor " + "as author or use a human co-author trailer." + ) + elif ( + is_harvested_commit + and mapped_author + and norm_key(commit.author_email) != norm_key(mapped_author.email) + ): + errors.append( + f"{prefix}: author {commit.author_name} <{commit.author_email}> " + f"matches AUTHOR_MAP but is not canonical. Use author {mapped_author.author()}." + ) + + for coauthor in coauthors: + if CANONICAL_NOREPLY_RE.match(coauthor.email): + continue + if is_bot_identity(coauthor.name, coauthor.email): + if is_harvested_commit: + errors.append( + f"{prefix}: remove bot/tool co-author trailer " + f"{coauthor.name} <{coauthor.email}>; contributor trailers are for humans." + ) + continue + expected = lookup_identity(aliases, coauthor.email, coauthor.name) + if expected: + errors.append( + f"{prefix}: co-author {coauthor.name} <{coauthor.email}> is not " + f"GitHub-mappable. Use `{expected.trailer()}`." + ) + else: + errors.append( + f"{prefix}: co-author {coauthor.name} <{coauthor.email}> is not " + "numeric GitHub noreply and has no AUTHOR_MAP entry. Add an alias " + "or use `gh api users/ --jq '\"\\(.id)+\\(.login)@users.noreply.github.com\"'`." + ) + + coauthor_emails = {norm_key(coauthor.email) for coauthor in coauthors} + for login in harvested_logins: + expected = lookup_identity(aliases, login) + if expected is None: + errors.append( + f"{prefix}: harvested contributor @{login} is missing from .github/AUTHOR_MAP." + ) + continue + if ( + norm_key(commit.author_email) != norm_key(expected.email) + and norm_key(expected.email) not in coauthor_emails + ): + errors.append( + f"{prefix}: `Harvested from PR ... by @{login}` needs machine-readable " + f"credit. Add `{expected.trailer()}` or preserve the contributor as author." + ) + return errors + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--author-map", type=Path, default=DEFAULT_AUTHOR_MAP) + parser.add_argument("--range", default="origin/main..HEAD", help="git commit range to check") + parser.add_argument( + "--check-authors", + action="store_true", + help="also reject commit author emails that match known AUTHOR_MAP aliases", + ) + args = parser.parse_args(argv) + + try: + aliases = load_author_map(args.author_map) + commits = git_log(args.range) + errors = validate(commits, aliases, args.check_authors) + except Exception as exc: + print(f"co-author credit check failed to run: {exc}", file=sys.stderr) + return 2 + + if errors: + print("Co-author credit check failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + + print(f"Co-author credit check passed for {len(commits)} commit(s).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/ohos-env.ps1 b/scripts/ohos-env.ps1 new file mode 100644 index 000000000..99f8373a4 --- /dev/null +++ b/scripts/ohos-env.ps1 @@ -0,0 +1,57 @@ +$ErrorActionPreference = "Stop" + +function Stop-OhosEnv { + param([string]$Message) + + [Console]::Error.WriteLine("error: $Message") + throw "OpenHarmony Cargo environment setup failed." +} + +if ([string]::IsNullOrWhiteSpace($env:OHOS_NATIVE_SDK)) { + Stop-OhosEnv "set OHOS_NATIVE_SDK to the OpenHarmony native SDK directory." +} + +if (-not (Test-Path -LiteralPath $env:OHOS_NATIVE_SDK -PathType Container -ErrorAction SilentlyContinue)) { + Stop-OhosEnv "OHOS_NATIVE_SDK does not exist: $env:OHOS_NATIVE_SDK" +} + +$sdk = (Resolve-Path -LiteralPath $env:OHOS_NATIVE_SDK -ErrorAction Stop).Path +$clang = [System.IO.Path]::Combine($sdk, "llvm", "bin", "clang.exe") +$clangxx = [System.IO.Path]::Combine($sdk, "llvm", "bin", "clang++.exe") +$ar = [System.IO.Path]::Combine($sdk, "llvm", "bin", "llvm-ar.exe") +$sysroot = [System.IO.Path]::Combine($sdk, "sysroot") +$cmakeToolchain = [System.IO.Path]::Combine($sdk, "build", "cmake", "ohos.toolchain.cmake") + +$requiredFiles = @($clang, $clangxx, $ar, $cmakeToolchain) +foreach ($path in $requiredFiles) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf -ErrorAction SilentlyContinue)) { + Stop-OhosEnv "required OpenHarmony SDK file is missing: $path" + } +} + +if (-not (Test-Path -LiteralPath $sysroot -PathType Container -ErrorAction SilentlyContinue)) { + Stop-OhosEnv "required OpenHarmony SDK sysroot is missing: $sysroot" +} + +$target = "aarch64_unknown_linux_ohos" +$targetUpper = "AARCH64_UNKNOWN_LINUX_OHOS" +$commonFlags = "-target aarch64-linux-ohos --sysroot=`"$sysroot`" -D__MUSL__" + +$env:CARGO_TARGET_AARCH64_UNKNOWN_LINUX_OHOS_LINKER = $clang +$env:AR_aarch64_unknown_linux_ohos = $ar +$env:CC_aarch64_unknown_linux_ohos = $clang +$env:CXX_aarch64_unknown_linux_ohos = $clangxx +$env:CC_SHELL_ESCAPED_FLAGS = "1" +Set-Item -Path "Env:CFLAGS_$target" -Value $commonFlags +Set-Item -Path "Env:CXXFLAGS_$target" -Value $commonFlags +Set-Item -Path "Env:CMAKE_TOOLCHAIN_FILE_$target" -Value $cmakeToolchain + +$separator = [char]0x1f +$env:CARGO_ENCODED_RUSTFLAGS = @( + "-Clink-arg=-target", + "-Clink-arg=aarch64-linux-ohos", + "-Clink-arg=--sysroot=$sysroot", + "-Clink-arg=-D__MUSL__" +) -join $separator + +Write-Host "Configured OpenHarmony Cargo environment for $targetUpper from $sdk" diff --git a/scripts/ohos-env.sh b/scripts/ohos-env.sh new file mode 100644 index 000000000..bf1e1eea2 --- /dev/null +++ b/scripts/ohos-env.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env sh + +if [ -z "${OHOS_NATIVE_SDK:-}" ]; then + echo "error: set OHOS_NATIVE_SDK to the OpenHarmony native SDK directory." >&2 + return 1 2>/dev/null || exit 1 +fi + +if [ ! -d "$OHOS_NATIVE_SDK" ]; then + echo "error: OHOS_NATIVE_SDK does not exist: $OHOS_NATIVE_SDK" >&2 + return 1 2>/dev/null || exit 1 +fi + +sdk=$(cd "$OHOS_NATIVE_SDK" && pwd) +clang=$sdk/llvm/bin/clang +clangxx=$sdk/llvm/bin/clang++ +ar=$sdk/llvm/bin/llvm-ar +sysroot=$sdk/sysroot +cmake_toolchain=$sdk/build/cmake/ohos.toolchain.cmake + +for file in "$clang" "$clangxx" "$ar" "$cmake_toolchain"; do + if [ ! -f "$file" ]; then + echo "error: required OpenHarmony SDK file is missing: $file" >&2 + return 1 2>/dev/null || exit 1 + fi +done + +if [ ! -d "$sysroot" ]; then + echo "error: required OpenHarmony SDK sysroot is missing: $sysroot" >&2 + return 1 2>/dev/null || exit 1 +fi + +export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_OHOS_LINKER=$clang +export AR_aarch64_unknown_linux_ohos=$ar +export CC_aarch64_unknown_linux_ohos=$clang +export CXX_aarch64_unknown_linux_ohos=$clangxx +export CC_SHELL_ESCAPED_FLAGS=1 +export CFLAGS_aarch64_unknown_linux_ohos="-target aarch64-linux-ohos --sysroot=\"$sysroot\" -D__MUSL__" +export CXXFLAGS_aarch64_unknown_linux_ohos="-target aarch64-linux-ohos --sysroot=\"$sysroot\" -D__MUSL__" +export CMAKE_TOOLCHAIN_FILE_aarch64_unknown_linux_ohos=$cmake_toolchain + +sep=$(printf '\037') +export CARGO_ENCODED_RUSTFLAGS="-Clink-arg=-target${sep}-Clink-arg=aarch64-linux-ohos${sep}-Clink-arg=--sysroot=$sysroot${sep}-Clink-arg=-D__MUSL__" + +echo "Configured OpenHarmony Cargo environment for AARCH64_UNKNOWN_LINUX_OHOS from $sdk" diff --git a/scripts/release/check-ohos-deps.sh b/scripts/release/check-ohos-deps.sh new file mode 100755 index 000000000..0d1bf9f59 --- /dev/null +++ b/scripts/release/check-ohos-deps.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Guard the OpenHarmony target dependency graph. +# +# This check intentionally does not require an OpenHarmony SDK or sysroot. It +# only asks Cargo to resolve the codewhale-tui dependency graph for the OHOS +# target and fails if crates known to break or be unsupported on OHOS re-enter +# that graph. +set -euo pipefail + +cd "$(dirname "$0")/../.." + +target="${1:-aarch64-unknown-linux-ohos}" +package="${CODEWHALE_OHOS_DEP_PACKAGE:-codewhale-tui}" + +tree="$( + cargo tree \ + --locked \ + --package "${package}" \ + --all-features \ + --target "${target}" \ + --prefix none \ + --no-dedupe +)" + +disallowed="$( + grep -E '^(nix v0\.(28|29)\.|portable-pty v|starlark v|arboard v|keyring v)' <<<"${tree}" || true +)" + +if [[ -n "${disallowed}" ]]; then + { + echo "::error::OHOS target graph for ${package} includes unsupported dependencies:" + echo "${disallowed}" + echo + echo "The OpenHarmony port avoids the rustyline/starlark/portable-pty/nix chain" + echo "by target-gating those crates away from target_env=ohos. Keep this graph" + echo "clean unless a real OHOS-compatible dependency update lands." + } >&2 + exit 1 +fi + +echo "OHOS dependency graph OK for ${package} on ${target}." diff --git a/scripts/release/check-versions.sh b/scripts/release/check-versions.sh index f260b803c..241289e45 100755 --- a/scripts/release/check-versions.sh +++ b/scripts/release/check-versions.sh @@ -96,10 +96,22 @@ if [[ -z "${compare_line}" ]]; then fail=1 fi +unreleased_section="$( + awk ' + index($0, "## [Unreleased]") == 1 { in_section = 1; print; next } + in_section && /^## \[/ { exit } + in_section { print } + ' CHANGELOG.md +)" +credit_sections="${current_section} +${unreleased_section}" + # 6) Contributor-credit cross-check for README additions on the release branch. # This cannot prove every external PR author has been credited, but it does # catch the common release-polish failure mode: adding a README contributor row -# without mentioning that credit/correction in the current release entry. +# without mentioning that credit/correction in the current release entry. While +# a release branch is still unbumped, `[Unreleased]` is also a valid credit +# surface. previous_tag="" current_tag="v${workspace_version}" if [[ "${compare_line}" =~ compare/(v[0-9]+\.[0-9]+\.[0-9]+)\.\.\.${current_tag} ]]; then @@ -114,8 +126,8 @@ if [[ -n "${previous_tag}" ]]; then [[ -z "${line}" ]] && continue handle="$(sed -E 's#.*github.com/([^)/]+).*#\1#' <<<"${line}")" if [[ -n "${handle}" && "${handle}" != "${line}" ]]; then - if ! grep -Fq "github.com/${handle}" <<<"${current_section}" && ! grep -Fq "@${handle}" <<<"${current_section}"; then - echo "::error::README.md adds contributor @${handle}, but CHANGELOG.md ${workspace_version} does not mention that credit." >&2 + if ! grep -Fq "github.com/${handle}" <<<"${credit_sections}" && ! grep -Fq "@${handle}" <<<"${credit_sections}"; then + echo "::error::README.md adds contributor @${handle}, but CHANGELOG.md ${workspace_version} or [Unreleased] does not mention that credit." >&2 fail=1 fi fi diff --git a/scripts/release/crates.sh b/scripts/release/crates.sh index 2fbbb8155..8936dcf56 100755 --- a/scripts/release/crates.sh +++ b/scripts/release/crates.sh @@ -2,19 +2,20 @@ # Crates published for each codewhale release, in dependency order. release_crates=( - codewhale-secrets - codewhale-release - codewhale-config + codewhale-mcp codewhale-protocol + codewhale-release + codewhale-secrets codewhale-state - codewhale-agent + codewhale-tui-core + codewhale-whaleflow codewhale-execpolicy codewhale-hooks - codewhale-mcp codewhale-tools + codewhale-config + codewhale-agent + codewhale-tui codewhale-core codewhale-app-server - codewhale-tui-core codewhale-cli - codewhale-tui ) diff --git a/scripts/release/publish-crates.sh b/scripts/release/publish-crates.sh index bad30760f..72b15db2a 100755 --- a/scripts/release/publish-crates.sh +++ b/scripts/release/publish-crates.sh @@ -15,6 +15,7 @@ case "${mode}" in esac packages=("${release_crates[@]}") +crates_user_agent="CodeWhale release publish check (https://github.com/Hmbown/CodeWhale)" workspace_version="" workspace_codewhale_packages=() @@ -122,7 +123,7 @@ package_has_workspace_deps() { crate_version_exists() { local crate_name="$1" local crate_version="$2" - curl -fsSL "https://crates.io/api/v1/crates/${crate_name}/${crate_version}" >/dev/null 2>&1 + curl -fsSL -A "${crates_user_agent}" "https://crates.io/api/v1/crates/${crate_name}/${crate_version}" >/dev/null 2>&1 } wait_for_crate_version() { diff --git a/scripts/verify_task.sh b/scripts/verify_task.sh index 97689ebf0..5b759174d 100644 --- a/scripts/verify_task.sh +++ b/scripts/verify_task.sh @@ -2,13 +2,31 @@ # verify_task.sh # Runs the DeepSWE verifier inside the task's Docker container. # Expects model.patch at /tmp/deep-swe-verify//model.patch +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " >&2 + exit 64 +fi + TASK_ID="$1" IMAGE="$2" -TASKS_DIR="/Volumes/VIXinSSD/whalebro/codewhale/deep-swe/tasks" -WORK_DIR="/tmp/deep-swe-verify/$TASK_ID" +TASKS_DIR="${DEEPSWE_TASKS_DIR:-/Volumes/VIXinSSD/whalebro/codewhale/deep-swe/tasks}" +WORK_BASE="${DEEPSWE_VERIFY_DIR:-/tmp/deep-swe-verify}" +WORK_DIR="$WORK_BASE/$TASK_ID" mkdir -p "$WORK_DIR" RESULT_FILE="$WORK_DIR/result.txt" +MODEL_PATCH="$WORK_DIR/model.patch" +TEST_PATCH="$TASKS_DIR/$TASK_ID/tests/test.patch" +TEST_SCRIPT="$TASKS_DIR/$TASK_ID/tests/test.sh" + +for required in "$MODEL_PATCH" "$TEST_PATCH" "$TEST_SCRIPT"; do + if [[ ! -f "$required" ]]; then + echo "missing required file: $required" >&2 + exit 66 + fi +done echo "[$TASK_ID] Pulling image..." docker pull "$IMAGE" 2>&1 | tail -1 @@ -16,9 +34,9 @@ docker pull "$IMAGE" 2>&1 | tail -1 echo "[$TASK_ID] Running verifier..." docker run --rm \ --platform linux/amd64 \ - -v "$WORK_DIR/model.patch:/model.patch:ro" \ - -v "$TASKS_DIR/$TASK_ID/tests/test.patch:/tests/test.patch:ro" \ - -v "$TASKS_DIR/$TASK_ID/tests/test.sh:/verify.sh:ro" \ + -v "$MODEL_PATCH:/model.patch:ro" \ + -v "$TEST_PATCH:/tests/test.patch:ro" \ + -v "$TEST_SCRIPT:/verify.sh:ro" \ "$IMAGE" \ bash -c ' set -e @@ -44,5 +62,5 @@ docker run --rm \ ' > "$RESULT_FILE" 2>&1 echo "[$TASK_ID] Done. Result:" -cat "$RESULT_FILE" | grep -E 'REWARD|FAILED|PATCH_FAILED|passed' +grep -E 'REWARD|FAILED|PATCH_FAILED|passed' "$RESULT_FILE" || true echo "" diff --git a/workflows/issue_fix_tournament.star b/workflows/issue_fix_tournament.star new file mode 100644 index 000000000..75f4ec3ac --- /dev/null +++ b/workflows/issue_fix_tournament.star @@ -0,0 +1,42 @@ +workflow( + id = "issue-fix-tournament", + goal = "Compare narrow fixes for one issue before promotion", + nodes = [ + branch( + id = "candidate-fixes", + parallel = True, + children = [ + agent( + id = "minimal-fix", + prompt = "Produce the smallest fix and list verification evidence.", + agent_type = "implementer", + mode = "read_write", + isolation = "worktree", + file_scope = ["crates/**"], + ), + agent( + id = "defensive-fix", + prompt = "Produce a more defensive fix and list regression risks.", + agent_type = "implementer", + mode = "read_write", + isolation = "worktree", + file_scope = ["crates/**"], + ), + ], + ), + sequence( + id = "review", + children = [ + tournament( + id = "select-fix", + candidates = ["minimal-fix", "defensive-fix"], + ), + test( + id = "verify-selected", + command = "cargo test --workspace --locked", + file_scope = ["crates/**"], + ), + ], + ), + ], +) diff --git a/workflows/rlm_cache_change.star b/workflows/rlm_cache_change.star new file mode 100644 index 000000000..32a62982a --- /dev/null +++ b/workflows/rlm_cache_change.star @@ -0,0 +1,74 @@ +workflow( + id = "rlm-cache-change", + goal = "Evaluate an RLM/cache routing change with safe mock WhaleFlow IR", + nodes = [ + branch( + id = "candidate-branches", + parallel = True, + children = [ + search( + id = "find-cache-surfaces", + query = "Find RLM and cache routing surfaces", + file_scope = ["crates/tui/src/rlm/**", "crates/tui/src/core/**"], + ), + agent( + id = "minimal-patch", + prompt = "Draft the smallest safe cache-routing patch using shared ARMH context.", + agent_type = "implementer", + mode = "read_write", + isolation = "worktree", + file_scope = ["crates/tui/src/rlm/**", "crates/tui/src/core/**"], + ), + agent( + id = "architecture-review", + prompt = "Review cache routing boundaries and identify replay or provider risks.", + agent_type = "explore", + file_scope = [ + "crates/tui/src/providers/**", + "crates/tui/src/rlm/**", + ], + ), + ], + ), + sequence( + id = "verify-select-and-summarize", + children = [ + loop_until( + id = "implement-until-tests-pass", + condition = "regression tests pass", + max_iterations = 2, + children = [ + test( + id = "regression-tests", + command = "cargo test -p codewhale-tui rlm --locked", + file_scope = ["crates/tui/src/rlm/**"], + ), + ], + ), + tournament( + id = "select-maintainer-slice", + candidates = [ + "minimal-patch", + "regression-tests", + "architecture-review", + ], + ), + teacher_review( + id = "teacher-review", + candidates = ["select-maintainer-slice"], + ), + reduce( + id = "summarize-cache-change", + inputs = [ + "find-cache-surfaces", + "minimal-patch", + "architecture-review", + "regression-tests", + "teacher-review", + ], + prompt = "Summarize the smallest safe cache-routing patch.", + ), + ], + ), + ], +)