Compare commits
343 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 518db2b37e | |||
| 05efea2109 | |||
| d970495929 | |||
| 66df75a5a3 | |||
| b28d66e98b | |||
| 3920028c6c | |||
| 27583e61e6 | |||
| 865bc79408 | |||
| 3726669a89 | |||
| bf6def00d7 | |||
| 4d1fe43116 | |||
| b60262fa81 | |||
| 4ed1869a97 | |||
| 9935832d9b | |||
| 2ba40aeab8 | |||
| 1fceff0c5c | |||
| 1ed798db56 | |||
| a60d805e09 | |||
| e33681da89 | |||
| 4a487f3cea | |||
| a77b12876f | |||
| 254cb5148b | |||
| de4fc3caa9 | |||
| 037aab9cf3 | |||
| 2d6fe4fde6 | |||
| cc4e8a734f | |||
| 82c7e3eb40 | |||
| 87addc9a7a | |||
| 4c3a2b17a4 | |||
| 2d4ff2570a | |||
| 1496a1b776 | |||
| a090439c38 | |||
| 4127662e98 | |||
| e3638ea223 | |||
| f62d113166 | |||
| 7e5b25f1e2 | |||
| 00aae33b44 | |||
| e3e9123d34 | |||
| 6d49b281b5 | |||
| 54a18ce158 | |||
| 58d91d01b2 | |||
| f82b2fa8a4 | |||
| 85d4827e46 | |||
| c88d1b8e09 | |||
| 9238ac83e0 | |||
| 59029fcdca | |||
| d39324820a | |||
| e2cd593508 | |||
| aab06c71b8 | |||
| 1ab8f8e829 | |||
| ea4c868b07 | |||
| 238bf173f9 | |||
| a5df72ec7b | |||
| 985a4c8be1 | |||
| 06ea7a8039 | |||
| 376c69c9a2 | |||
| 2f05b07e91 | |||
| b1d33331df | |||
| 3d4ff41761 | |||
| 0e74dc503e | |||
| 7a28bb4906 | |||
| 4f829bc7d1 | |||
| e511e10738 | |||
| a4b5e78204 | |||
| 31842a96b6 | |||
| a44a6dc225 | |||
| cacb7bc424 | |||
| ddc4e231a2 | |||
| 3815bd7051 | |||
| b9dad36472 | |||
| 32d136b35f | |||
| a8af16d3eb | |||
| 7abd6edb87 | |||
| a97b66876a | |||
| 4e5ac2ba39 | |||
| add98ef1c6 | |||
| 1b6e77f6f0 | |||
| 33d4bf7179 | |||
| ad37fc85d3 | |||
| 59882c9f27 | |||
| f3a3963350 | |||
| 92cd2b2317 | |||
| 9bd112e6c2 | |||
| a51eaab55c | |||
| 21a30e4715 | |||
| 31044240c1 | |||
| 86f43d0f78 | |||
| e81ed33fcc | |||
| 6ef4467329 | |||
| 291599707b | |||
| 12d4f7b845 | |||
| c76f2d70bf | |||
| db1a3d6f25 | |||
| af3ba0e1d1 | |||
| 7b16ca61d7 | |||
| 499c2fbb61 | |||
| 8118e4ce92 | |||
| eef5be4e29 | |||
| cedcccc337 | |||
| 9055f8e9cf | |||
| 46aec12a1b | |||
| 687dfde086 | |||
| 5b738bc65c | |||
| cb69741b66 | |||
| 73c4b81538 | |||
| 011f1f2726 | |||
| 6444630ce7 | |||
| 616af71c90 | |||
| d25a8ba0da | |||
| 16b597dd26 | |||
| 26cb5196ca | |||
| 8fd0cd1e64 | |||
| e9bce3db9d | |||
| c7dcf8bb51 | |||
| 48c4122820 | |||
| 3c84e7609d | |||
| a11c6f3b91 | |||
| 1369099e9a | |||
| 0a820e1506 | |||
| 5f8df4b5e0 | |||
| ceb45781be | |||
| be5c8f8bfd | |||
| 5798de8b39 | |||
| f993077d54 | |||
| 63a5ba8c47 | |||
| 3eeda34cf0 | |||
| b39cf56505 | |||
| 789a7bf1ff | |||
| f2febee96b | |||
| 1536ed852f | |||
| de30396e7c | |||
| 7aa5c6bac0 | |||
| 11f6c99bec | |||
| 5162341fd1 | |||
| b899c42be9 | |||
| fde3cb0ff0 | |||
| cb1e994829 | |||
| 5763000c92 | |||
| 4a6933754e | |||
| f864ca67f7 | |||
| 9bac910fc6 | |||
| f11657e77f | |||
| f157d34a51 | |||
| aa47e142c9 | |||
| f6beb4464a | |||
| 49fb811ad1 | |||
| eef8542e0c | |||
| 91bca01a9d | |||
| 519a0d6162 | |||
| 853cb707bb | |||
| 7ef5cd3846 | |||
| a79014025c | |||
| 8f01171a37 | |||
| fdac09b00e | |||
| cd4bf73215 | |||
| 8d4a9f0356 | |||
| 131bf525f2 | |||
| 9a3015892d | |||
| f93d7ab0b9 | |||
| 519c42f050 | |||
| df65a60595 | |||
| a2d3920a25 | |||
| 754bf9a6f5 | |||
| f2dad3d6cd | |||
| 1fee0325b3 | |||
| 7f60a6a200 | |||
| 2d4a9cb58c | |||
| ef6104957d | |||
| 9f22e2a59f | |||
| 0f98274960 | |||
| 001921d056 | |||
| 51ae6ae6b2 | |||
| fd28361c87 | |||
| a701490dde | |||
| 4863fb6db9 | |||
| 89c8d7da75 | |||
| cf84784332 | |||
| 851563da7f | |||
| 12888571e1 | |||
| de256a794a | |||
| 16445405d7 | |||
| e0f0285b97 | |||
| 96f6ba9451 | |||
| 2556a55ecf | |||
| 8a222e9a17 | |||
| 3966b014c9 | |||
| b8aaec21f4 | |||
| b5cf91ec7f | |||
| 9c9d385e1d | |||
| 6d4ff224fc | |||
| 5bdfeb7146 | |||
| e54b8043df | |||
| 471c6d8bb0 | |||
| 636b84610e | |||
| 41232cb3be | |||
| dd84af2297 | |||
| d90b6eda3c | |||
| 93b14c4dde | |||
| 6fb9a9e27b | |||
| ade598d054 | |||
| eb5faf754b | |||
| 679e681d41 | |||
| 2f5a824c19 | |||
| 6989574363 | |||
| 6915769297 | |||
| 7689e0de58 | |||
| e291350b4c | |||
| 2d95a5d09c | |||
| 686bc164c0 | |||
| 6857c1f0bb | |||
| 1a878a2a4a | |||
| 123c7643a9 | |||
| 39197e58b9 | |||
| df0afabbda | |||
| 3fccdc775f | |||
| 2ba1b45fa5 | |||
| cb134ec30f | |||
| 6c63aacbbc | |||
| c20386d29c | |||
| d0bbb7b375 | |||
| b596b6bfa3 | |||
| b9e85906f7 | |||
| f65a60d9f4 | |||
| d00b0fe404 | |||
| ce42330c29 | |||
| 21f4299786 | |||
| 98ea81dc20 | |||
| 7e0e369186 | |||
| a0f9df7795 | |||
| c07f00c285 | |||
| d2bdb9d1cf | |||
| eae3b7ffb9 | |||
| 23be47ab6b | |||
| cae5626e6c | |||
| 0918686b19 | |||
| d1d7b65fb7 | |||
| be676502df | |||
| fdc336ec2b | |||
| 6d7add26c9 | |||
| 4b728a1d84 | |||
| 927024f80e | |||
| 69ddf337b4 | |||
| 5e5608cee2 | |||
| 59e710271e | |||
| 4772354da2 | |||
| 0cb8ce7423 | |||
| 1b77edd814 | |||
| aa9d88121f | |||
| 4e3087b0e2 | |||
| 8e60f7a5f7 | |||
| 307c26d641 | |||
| 011b9a9860 | |||
| a6fd150bda | |||
| b847ba56aa | |||
| f6972f3e0a | |||
| 555b1e9003 | |||
| 4862176f09 | |||
| 5dcd264640 | |||
| 5c228c5bdb | |||
| 548b8b52df | |||
| 8cf9280e25 | |||
| 649a9a1bb9 | |||
| a88d2f018f | |||
| 301b2ca1d9 | |||
| 3339b43cc6 | |||
| eb70916a30 | |||
| a9702c496d | |||
| 44524b9d8d | |||
| ead5d43aac | |||
| 580668bd3e | |||
| 70191981ac | |||
| eed131721f | |||
| feacbd1e55 | |||
| 18769cdbe8 | |||
| ecb6c5b53f | |||
| 5de9b71697 | |||
| bf69e7ff54 | |||
| efcf47a1d1 | |||
| 5d93dd00e0 | |||
| 3a27a2d813 | |||
| 21ed173cf1 | |||
| ac07033abb | |||
| 1b3600229c | |||
| 0e61855743 | |||
| 093186a1a4 | |||
| 43a55ce87b | |||
| f0e4d8a266 | |||
| c9a45fc382 | |||
| 8f2b622dc5 | |||
| 1c67aa38cb | |||
| 4847397f57 | |||
| 3273a7ae26 | |||
| d57ce9d06f | |||
| ec5747f7d7 | |||
| e733c00892 | |||
| 1699a7cf71 | |||
| 180271efaf | |||
| 9e2c929bf1 | |||
| b6585ea990 | |||
| 24e4ac9b9c | |||
| 0527358f86 | |||
| 070eebfad7 | |||
| 37160d6f8d | |||
| 267c6dc10f | |||
| 795d9542a3 | |||
| 9accef6092 | |||
| 7242381022 | |||
| 8b5df3f669 | |||
| 4ac9bc61d3 | |||
| a75a475b99 | |||
| e9b6afd87c | |||
| dd61f99b1d | |||
| fd1489c818 | |||
| c44cd9e96c | |||
| 411ec84739 | |||
| 9ccfddeba1 | |||
| 74b5192110 | |||
| 43f933ca5d | |||
| 147e407464 | |||
| 6dba4934c3 | |||
| 091f675350 | |||
| 497a426d05 | |||
| 2e0a3ab3c0 | |||
| bb8f6d8ca1 | |||
| eca3d0e21a | |||
| cafa4e5e15 | |||
| 8c05b84379 | |||
| 5eb0385e8f | |||
| 50cb6df2f2 | |||
| c329d37fc4 | |||
| 6187221e0f | |||
| 6b7eb20ef1 | |||
| 70d34e1f22 | |||
| 9678e0c1fc | |||
| 77462eaac2 | |||
| 0ff42dc268 | |||
| 0b173fa87e | |||
| b2f1d8e4ab | |||
| 0e6ae746b6 | |||
| b2b13851ec | |||
| 71033d9a9e | |||
| 9146c4f63f | |||
| 73dd36514a |
@@ -19,14 +19,22 @@
|
||||
|
||||
.rust_workspace_gates_stage: &rust_workspace_gates_stage
|
||||
name: rust workspace gates
|
||||
# The all-feature TUI test crate is large enough that concurrent rustc and
|
||||
# clippy processes or disposable test debug metadata can exceed the shared
|
||||
# CNB runner's memory. Keep the full gate surface, but serialize Cargo,
|
||||
# omit test-only debug tables, and use the established workspace-test stack
|
||||
# size so deep runtime API tests do not abort on the platform default.
|
||||
timeout: 45m
|
||||
script: |
|
||||
set -eu
|
||||
export CARGO_BUILD_JOBS=1
|
||||
export CARGO_PROFILE_TEST_DEBUG=0
|
||||
./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
|
||||
cargo test --workspace --all-features --locked
|
||||
RUST_MIN_STACK=16777216 cargo test --workspace --all-features --locked
|
||||
# Parity gates as first-class steps so drift surfaces as a named failure,
|
||||
# not a buried workspace-test entry. Mirrors release.yml's parity job.
|
||||
cargo test -p codewhale-protocol --test parity_protocol --locked
|
||||
@@ -60,12 +68,12 @@
|
||||
set -eu
|
||||
# The release profile uses full LTO and one codegen unit. Bound Cargo's
|
||||
# parallelism so the final links cannot exhaust a shared CNB runner.
|
||||
cargo build --jobs 2 --release --locked -p codewhale-cli -p codewhale-tui
|
||||
cargo build --jobs 2 --release --locked -p codewhale-cli
|
||||
cp target/release/codewhale target/release/codew
|
||||
export PATH="$PWD/target/release:$PATH"
|
||||
node scripts/release/npm-wrapper-smoke.js
|
||||
./target/release/codewhale --version
|
||||
./target/release/codew --version
|
||||
./target/release/codewhale-tui --version
|
||||
|
||||
.linux_release_preflight: &linux_release_preflight
|
||||
name: linux release preflight
|
||||
@@ -100,12 +108,12 @@
|
||||
set -eu
|
||||
# Keep the production release profile intact while avoiding a burst of
|
||||
# concurrent rustc/linker processes on the shared release runner.
|
||||
cargo build --jobs 2 --release --locked -p codewhale-cli -p codewhale-tui
|
||||
cargo build --jobs 2 --release --locked -p codewhale-cli
|
||||
cp target/release/codewhale target/release/codew
|
||||
export PATH="$PWD/target/release:$PATH"
|
||||
node scripts/release/npm-wrapper-smoke.js
|
||||
./target/release/codewhale --version
|
||||
./target/release/codew --version
|
||||
./target/release/codewhale-tui --version
|
||||
|
||||
main:
|
||||
push:
|
||||
@@ -141,13 +149,13 @@ $:
|
||||
./scripts/release/check-ohos-deps.sh
|
||||
cargo build --jobs 2 --release --locked \
|
||||
--target x86_64-unknown-linux-musl \
|
||||
-p codewhale-cli -p codewhale-tui
|
||||
-p codewhale-cli # single binary
|
||||
|
||||
mkdir -p target/cnb-release
|
||||
BIN_DIR="target/x86_64-unknown-linux-musl/release"
|
||||
cp "$BIN_DIR/codewhale" target/cnb-release/codewhale-linux-x64
|
||||
cp "$BIN_DIR/codew" target/cnb-release/codew-linux-x64
|
||||
cp "$BIN_DIR/codewhale-tui" target/cnb-release/codewhale-tui-linux-x64
|
||||
cp "$BIN_DIR/codewhale" target/cnb-release/codew-linux-x64
|
||||
cp "$BIN_DIR/codewhale" target/cnb-release/codewhale-tui-linux-x64
|
||||
strip \
|
||||
target/cnb-release/codewhale-linux-x64 \
|
||||
target/cnb-release/codew-linux-x64 \
|
||||
@@ -188,7 +196,7 @@ $:
|
||||
echo "Assets:"
|
||||
echo "- codewhale-linux-x64"
|
||||
echo "- codew-linux-x64"
|
||||
echo "- codewhale-tui-linux-x64"
|
||||
echo "- codewhale-tui-linux-x64 (v0.9.4 compatibility alias)"
|
||||
echo "- codewhale-artifacts-sha256.txt"
|
||||
} > target/cnb-release/CNB_RELEASE.md
|
||||
|
||||
|
||||
@@ -19,3 +19,6 @@
|
||||
|
||||
# AtlasCloud OpenAI-compatible endpoint
|
||||
# ATLASCLOUD_API_KEY=
|
||||
|
||||
# Mistral AI (la Plateforme) — https://console.mistral.ai/api-keys
|
||||
# MISTRAL_API_KEY=
|
||||
|
||||
@@ -183,6 +183,9 @@ heloanc = heloanc <61081755+heloanc@users.noreply.github.com>
|
||||
heloanc@users.noreply.github.com = heloanc <61081755+heloanc@users.noreply.github.com>
|
||||
bistack = Sun Zhenyuan <9128763+bistack@users.noreply.github.com>
|
||||
zhenyuan.sun@163.com = Sun Zhenyuan <9128763+bistack@users.noreply.github.com>
|
||||
skyzhao1223 = SKY ZHAO <15373810+skyzhao1223@users.noreply.github.com>
|
||||
zhaotian1 = SKY ZHAO <15373810+skyzhao1223@users.noreply.github.com>
|
||||
zhaotian1@wps.cn = SKY ZHAO <15373810+skyzhao1223@users.noreply.github.com>
|
||||
vFONGv = Matthew.Fong <21223725+vFONGv@users.noreply.github.com>
|
||||
fangb0987612345@gmail.com = Matthew.Fong <21223725+vFONGv@users.noreply.github.com>
|
||||
luismateusvargas = Luis Mateus Vargas <289766246+luismateusvargas@users.noreply.github.com>
|
||||
@@ -209,3 +212,5 @@ adity982 = ADITYA <59918965+adity982@users.noreply.github.com>
|
||||
vibecoding-skills = Harsh Dattani <209214219+vibecoding-skills@users.noreply.github.com>
|
||||
XhesicaFrost = XhesicaFrost <142909332+XhesicaFrost@users.noreply.github.com>
|
||||
ffaacceelee = ffaacceelee <11267580+ffaacceelee@users.noreply.github.com>
|
||||
mky = mky <817223+mky@users.noreply.github.com>
|
||||
cacdcaecawae = cacdcaecawae <109055297+cacdcaecawae@users.noreply.github.com>
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
allAssetNames,
|
||||
allReleaseAssetNames,
|
||||
BUNDLE_ASSET_NAMES,
|
||||
LEGACY_TUI_BRIDGE_ASSET_NAMES,
|
||||
} = require(path.join(repoRoot, "npm", "codewhale", "scripts", "artifacts"));
|
||||
|
||||
function read(relativePath) {
|
||||
@@ -20,11 +21,24 @@ function valuesForKey(source, key) {
|
||||
return [...source.matchAll(expression)].map((match) => match[1]);
|
||||
}
|
||||
|
||||
function namedStep(source, name) {
|
||||
const marker = ` - name: ${name}\n`;
|
||||
const start = source.indexOf(marker);
|
||||
assert.notEqual(start, -1, `missing workflow step: ${name}`);
|
||||
const next = source.indexOf("\n - ", start + marker.length);
|
||||
return source.slice(start, next === -1 ? source.length : next);
|
||||
}
|
||||
|
||||
const ci = read(".github/workflows/ci.yml");
|
||||
const nightly = read(".github/workflows/nightly.yml");
|
||||
const candidate = read(".github/workflows/release-candidate.yml");
|
||||
const artifacts = read(".github/workflows/release-artifacts.yml");
|
||||
const release = read(".github/workflows/release.yml");
|
||||
const releaseDockerfile = read("packaging/docker/Dockerfile.release");
|
||||
const cnb = read(".cnb.yml");
|
||||
const bundles = read("scripts/release/create-release-bundles.sh");
|
||||
const archiveInstaller = read("scripts/release/install.sh");
|
||||
const cliDispatcher = read("crates/cli/src/lib.rs");
|
||||
const runbook = read("docs/RELEASE_RUNBOOK.md");
|
||||
|
||||
assert.match(ci, /^ workflow_dispatch:\n inputs:\n expected_sha:/m);
|
||||
@@ -37,6 +51,68 @@ for (const output of ["heavy", "workflow", "mobile", "actions"]) {
|
||||
}
|
||||
assert.match(manualForceBlock[1], /#EXPECTED_SHA.*-ne 40/s);
|
||||
assert.match(manualForceBlock[1], /actual.*EXPECTED_SHA/s);
|
||||
assert.match(
|
||||
ci,
|
||||
/run: cargo test -p codewhale-tui --test pty qa_pty::skills_opens_manager_owned_then_compatible -- --ignored --exact/,
|
||||
"CI must run the isolated Skills Manager acceptance from the consolidated PTY target",
|
||||
);
|
||||
assert.doesNotMatch(ci, /--test qa_pty\b/, "CI must not name the removed qa_pty target");
|
||||
|
||||
const expectedNightlyTargets = [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"aarch64-unknown-linux-musl",
|
||||
"x86_64-apple-darwin",
|
||||
"aarch64-apple-darwin",
|
||||
"x86_64-pc-windows-msvc",
|
||||
"aarch64-pc-windows-msvc",
|
||||
].sort();
|
||||
assert.deepEqual([...new Set(valuesForKey(nightly, "target"))].sort(), expectedNightlyTargets);
|
||||
assert.deepEqual(
|
||||
[
|
||||
...valuesForKey(nightly, "primary_artifact"),
|
||||
...valuesForKey(nightly, "alias_artifact"),
|
||||
].sort(),
|
||||
[
|
||||
"codewhale-linux-x64",
|
||||
"codew-linux-x64",
|
||||
"codewhale-linux-arm64",
|
||||
"codew-linux-arm64",
|
||||
"codewhale-macos-x64",
|
||||
"codew-macos-x64",
|
||||
"codewhale-macos-arm64",
|
||||
"codew-macos-arm64",
|
||||
"codewhale-windows-x64.exe",
|
||||
"codew-windows-x64.exe",
|
||||
"codewhale-windows-arm64.exe",
|
||||
"codew-windows-arm64.exe",
|
||||
].sort(),
|
||||
);
|
||||
assert.match(
|
||||
nightly,
|
||||
/cargo build --release --locked --target \$\{\{ matrix\.target \}\} -p codewhale-cli/,
|
||||
);
|
||||
assert.match(nightly, /startsWith\(matrix\.target, 'x86_64-'\).*runner\.arch == 'X64'/s);
|
||||
assert.match(nightly, /startsWith\(matrix\.target, 'aarch64-'\).*runner\.arch == 'ARM64'/s);
|
||||
const nightlyArmMuslSetup = namedStep(nightly, "Install Linux ARM64 musl toolchain");
|
||||
assert.match(nightlyArmMuslSetup, /matrix\.target == 'aarch64-unknown-linux-musl'/);
|
||||
assert.match(nightlyArmMuslSetup, /apt-get install -y binutils musl-tools/);
|
||||
assert.match(nightlyArmMuslSetup, /rustup target add --toolchain stable aarch64-unknown-linux-musl/);
|
||||
const nightlyArmStaticSmoke = namedStep(
|
||||
nightly,
|
||||
"Verify static Linux ARM64 binary and launch",
|
||||
);
|
||||
assert.match(
|
||||
nightlyArmStaticSmoke,
|
||||
/matrix\.target == 'aarch64-unknown-linux-musl' && runner\.arch == 'ARM64'/,
|
||||
);
|
||||
assert.match(nightlyArmStaticSmoke, /readelf -l "\$\{bin_path\}"/);
|
||||
assert.match(nightlyArmStaticSmoke, /grep -Fq 'INTERP'/);
|
||||
assert.match(nightlyArmStaticSmoke, /"\$\{bin_path\}" --version/);
|
||||
assert.doesNotMatch(nightly, /codewhale-tui/);
|
||||
assert.doesNotMatch(nightly, /target\/[^\n]*\/codew(?:\.exe)?/);
|
||||
assert.match(nightly, /cp "\$\{bin_path\}" "\$\{dir\}\/\$\{artifact\}"/);
|
||||
assert.match(nightly, /cmp -s[\s\S]*nightly-primary[\s\S]*nightly-alias/);
|
||||
assert.equal((nightly.match(/retention-days: 14/g) || []).length, 2);
|
||||
|
||||
assert.match(candidate, /^ workflow_dispatch:\n inputs:\n expected_sha:/m);
|
||||
assert.doesNotMatch(candidate, /^ (push|pull_request|schedule):/m);
|
||||
@@ -101,7 +177,7 @@ assert.match(artifacts, /^ workflow_call:/m);
|
||||
assert.match(artifacts, /^permissions:\n contents: read$/m);
|
||||
const expectedTargets = [
|
||||
"x86_64-unknown-linux-musl",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"aarch64-unknown-linux-musl",
|
||||
"aarch64-linux-android",
|
||||
"x86_64-apple-darwin",
|
||||
"aarch64-apple-darwin",
|
||||
@@ -110,6 +186,27 @@ const expectedTargets = [
|
||||
].sort();
|
||||
assert.deepEqual([...new Set(valuesForKey(artifacts, "target"))].sort(), expectedTargets);
|
||||
|
||||
const releaseMuslBuild = namedStep(artifacts, "Build static Linux binaries (musl)");
|
||||
assert.match(releaseMuslBuild, /endsWith\(matrix\.target, '-unknown-linux-musl'\)/);
|
||||
assert.match(releaseMuslBuild, /apt-get install -y binutils musl-tools/);
|
||||
assert.match(releaseMuslBuild, /rustup target add --toolchain stable \$\{\{ matrix\.target \}\}/);
|
||||
assert.match(
|
||||
releaseMuslBuild,
|
||||
/cargo build --profile dist --locked --target \$\{\{ matrix\.target \}\} -p codewhale-cli/,
|
||||
);
|
||||
const releaseStaticSmoke = namedStep(
|
||||
artifacts,
|
||||
"Verify static Linux binaries and launch on matching native runners",
|
||||
);
|
||||
assert.match(releaseStaticSmoke, /endsWith\(matrix\.target, '-unknown-linux-musl'\)/);
|
||||
assert.match(
|
||||
releaseStaticSmoke,
|
||||
/startsWith\(matrix\.target, 'aarch64-'\) && runner\.arch == 'ARM64'/,
|
||||
);
|
||||
assert.match(releaseStaticSmoke, /readelf -l "\$\{bin_path\}"/);
|
||||
assert.match(releaseStaticSmoke, /grep -Fq 'INTERP'/);
|
||||
assert.match(releaseStaticSmoke, /"\$\{bin_path\}" --version/);
|
||||
|
||||
const builtAssetNames = [
|
||||
...valuesForKey(artifacts, "cli_artifact"),
|
||||
...valuesForKey(artifacts, "shim_artifact"),
|
||||
@@ -118,10 +215,18 @@ const builtAssetNames = [
|
||||
assert.equal(builtAssetNames.length, 21);
|
||||
assert.deepEqual(
|
||||
[...new Set(builtAssetNames)].sort(),
|
||||
allAssetNames().filter((name) => name !== "codewhale.bat").sort(),
|
||||
[
|
||||
...allAssetNames().filter((name) => name !== "codewhale.bat"),
|
||||
...LEGACY_TUI_BRIDGE_ASSET_NAMES,
|
||||
].sort(),
|
||||
);
|
||||
assert.match(
|
||||
artifacts,
|
||||
/stage_binary "\$\{\{ matrix\.cli_binary \}\}" "\$\{\{ matrix\.tui_artifact \}\}"/,
|
||||
"legacy TUI bridge assets must be staged from the one compiled codewhale binary",
|
||||
);
|
||||
const bundleInvocations = [...bundles.matchAll(
|
||||
/^bundle (\S+) \\\n\s+\S+ \S+ \S+ (tar\.gz|zip) (""|portable)$/gm,
|
||||
/^bundle (\S+) \\\n\s+\S+ \S+ (tar\.gz|zip) (""|portable)$/gm,
|
||||
)].map((match) => {
|
||||
const variant = match[3] === "portable" ? "-portable" : "";
|
||||
return `codewhale-${match[1]}${variant}.${match[2]}`;
|
||||
@@ -133,6 +238,13 @@ assert.match(artifacts, /codew-windows-arm64\.exe/);
|
||||
assert.match(artifacts, /CodeWhaleSetup\.exe/);
|
||||
assert.match(artifacts, /assemble-release-assets\.js --verify release-assets/);
|
||||
assert.match(artifacts, /CODEWHALE_SMOKE_ASSETS_DIR/);
|
||||
const bundleStep = namedStep(artifacts, "Create and checksum platform archives");
|
||||
assert.match(bundleStep, /git show -s --format=%ct "\$\{\{ inputs\.source_sha \}\}"/);
|
||||
assert.match(
|
||||
bundleStep,
|
||||
/SOURCE_DATE_EPOCH="\$\{source_date_epoch\}"[\s\\]+bash scripts\/release\/create-release-bundles\.sh artifacts bundles/,
|
||||
);
|
||||
assert.doesNotMatch(bundleStep, /\bdate\b/, "bundle timestamps must come from the pinned source commit, not wall-clock time");
|
||||
|
||||
assert.equal(allReleaseAssetNames().length, 34);
|
||||
assert.match(release, /^ artifacts:\n/m);
|
||||
@@ -148,10 +260,143 @@ assert.equal(
|
||||
assert.match(release, /overwrite_files:\s*false/);
|
||||
assert.match(release, /fail_on_unmatched_files:\s*true/);
|
||||
|
||||
assert.match(release, /^ docker-build:\n/m);
|
||||
assert.match(release, /^ docker:\n/m);
|
||||
assert.match(release, /runner: ubuntu-latest\n\s+platform: linux\/amd64/);
|
||||
assert.match(release, /runner: ubuntu-24\.04-arm\n\s+platform: linux\/arm64/);
|
||||
assert.match(release, /cli_artifact: codewhale-linux-x64/);
|
||||
assert.match(release, /cli_artifact: codewhale-linux-arm64/);
|
||||
assert.match(release, /shim_artifact: codew-linux-x64/);
|
||||
assert.match(release, /shim_artifact: codew-linux-arm64/);
|
||||
assert.doesNotMatch(
|
||||
release,
|
||||
/docker\/setup-qemu-action/,
|
||||
"public container publication must not funnel both architectures through QEMU",
|
||||
);
|
||||
const releaseDockerBytes = namedStep(release, "Verify native release bytes");
|
||||
assert.match(releaseDockerBytes, /CLI_ARTIFACT: \$\{\{ matrix\.cli_artifact \}\}/);
|
||||
assert.match(releaseDockerBytes, /SHIM_ARTIFACT: \$\{\{ matrix\.shim_artifact \}\}/);
|
||||
assert.match(
|
||||
releaseDockerBytes,
|
||||
/mv -- "docker-context\/bin\/\$\{CLI_ARTIFACT\}" docker-context\/bin\/codewhale/,
|
||||
);
|
||||
assert.match(
|
||||
releaseDockerBytes,
|
||||
/mv -- "docker-context\/bin\/\$\{SHIM_ARTIFACT\}" docker-context\/bin\/codew/,
|
||||
);
|
||||
assert.match(releaseDockerBytes, /cmp docker-context\/bin\/codewhale docker-context\/bin\/codew/);
|
||||
const releaseDockerBuild = namedStep(release, "Assemble and push native image by digest");
|
||||
assert.match(releaseDockerBuild, /context: docker-context/);
|
||||
assert.match(releaseDockerBuild, /file: infra\/packaging\/docker\/Dockerfile\.release/);
|
||||
assert.match(releaseDockerBuild, /platforms: \$\{\{ matrix\.platform \}\}/);
|
||||
assert.match(releaseDockerBuild, /provenance: mode=max/);
|
||||
assert.match(releaseDockerBuild, /sbom: true/);
|
||||
assert.match(releaseDockerBuild, /push-by-digest=true/);
|
||||
const releaseDockerManifest = namedStep(release, "Publish multi-architecture manifest");
|
||||
assert.match(releaseDockerManifest, /Expected exactly two native image digests/);
|
||||
assert.match(releaseDockerManifest, /docker buildx imagetools create/);
|
||||
const releaseDockerSmoke = namedStep(release, "Verify and smoke published container");
|
||||
assert.match(releaseDockerSmoke, /linux\/amd64/);
|
||||
assert.match(releaseDockerSmoke, /linux\/arm64/);
|
||||
assert.match(releaseDockerSmoke, /--entrypoint codewhale/);
|
||||
assert.match(releaseDockerSmoke, /--entrypoint codew/);
|
||||
|
||||
const npmJob = release.match(/\n npm:\n([\s\S]*?)\n homebrew:\n/);
|
||||
assert.ok(npmJob, "public release must retain a dedicated npm publication job");
|
||||
assert.match(npmJob[1], /^ needs: \[release, resolve\]$/m);
|
||||
assert.match(npmJob[1], /needs\.release\.result == 'success'/);
|
||||
assert.match(npmJob[1], /^ contents: read$/m);
|
||||
assert.match(npmJob[1], /^ id-token: write$/m);
|
||||
assert.match(npmJob[1], /ref: \$\{\{ needs\.resolve\.outputs\.sha \}\}/);
|
||||
assert.match(npmJob[1], /fetch-depth: 0/);
|
||||
assert.match(npmJob[1], /node-version: 24/);
|
||||
assert.match(npmJob[1], /registry-url: https:\/\/registry\.npmjs\.org/);
|
||||
assert.match(npmJob[1], /package-manager-cache: false/);
|
||||
assert.match(npmJob[1], /npm install --global npm@12\.0\.2/);
|
||||
const npmTagGate = namedStep(release, "Revalidate release tag before npm publish");
|
||||
const npmAssetGate = namedStep(release, "Revalidate public release assets");
|
||||
const npmPublish = namedStep(release, "Publish npm wrapper with trusted publishing");
|
||||
assert.match(npmTagGate, /verify-remote-tag\.sh/);
|
||||
assert.match(npmAssetGate, /verify-release-assets\.sh/);
|
||||
assert.match(npmAssetGate, /GH_TOKEN: \$\{\{ github\.token \}\}/);
|
||||
assert.match(npmPublish, /working-directory: npm\/codewhale/);
|
||||
assert.match(npmPublish, /npm publish --access public/);
|
||||
assert.doesNotMatch(npmJob[1], /NPM_TOKEN|NODE_AUTH_TOKEN|secrets\./);
|
||||
assert.ok(
|
||||
release.indexOf("Revalidate public release assets") <
|
||||
release.indexOf("Publish npm wrapper with trusted publishing"),
|
||||
"npm publication must follow the public exact-asset gate",
|
||||
);
|
||||
|
||||
assert.match(releaseDockerfile, /^FROM debian:bookworm-slim$/m);
|
||||
assert.match(releaseDockerfile, /ca-certificates/);
|
||||
assert.match(releaseDockerfile, /libdbus-1-3/);
|
||||
assert.match(releaseDockerfile, /COPY .*bin\/codewhale \/usr\/local\/bin\/codewhale/);
|
||||
assert.match(releaseDockerfile, /COPY .*bin\/codew \/usr\/local\/bin\/codew/);
|
||||
assert.match(releaseDockerfile, /^USER codewhale$/m);
|
||||
assert.doesNotMatch(
|
||||
releaseDockerfile,
|
||||
/\bcargo\s+build\b|^FROM\s+rust:/m,
|
||||
"release container assembly must reuse the already-verified release binaries",
|
||||
);
|
||||
|
||||
assert.match(runbook, /release[- ]candidate/i);
|
||||
assert.match(runbook, /expected_sha/);
|
||||
assert.match(runbook, /34/);
|
||||
assert.match(runbook, /does not create a tag/i);
|
||||
assert.match(runbook, /explicit.*approval/i);
|
||||
|
||||
console.log("Release workflow contracts OK: exact-head full CI and 7-target/34-asset non-publishing candidate.");
|
||||
const cnbRustGates = cnb.match(
|
||||
/\.rust_workspace_gates_stage: &rust_workspace_gates_stage([\s\S]*?)\n\.linux_rust_gates:/,
|
||||
);
|
||||
assert.ok(cnbRustGates, "CNB must retain the shared Rust workspace gate");
|
||||
assert.match(
|
||||
cnbRustGates[1],
|
||||
/timeout: 45m[\s\S]*export CARGO_BUILD_JOBS=1[\s\S]*export CARGO_PROFILE_TEST_DEBUG=0[\s\S]*cargo check --workspace --all-targets --locked[\s\S]*cargo clippy --workspace --all-targets --all-features --locked -- -D warnings[\s\S]*RUST_MIN_STACK=16777216 cargo test --workspace --all-features --locked/,
|
||||
"CNB must serialize the memory-heavy Rust gate and preserve the workspace test stack contract",
|
||||
);
|
||||
assert.equal(
|
||||
(cnb.match(/^\s+- \*rust_workspace_gates_stage$/gm) || []).length,
|
||||
2,
|
||||
"both CNB Rust pipelines must reuse the constrained workspace gate",
|
||||
);
|
||||
|
||||
const cnbPreflight = cnb.match(
|
||||
/\.linux_release_preflight: &linux_release_preflight([\s\S]*?)\nmain:/,
|
||||
);
|
||||
assert.ok(cnbPreflight, "CNB must retain a dedicated release preflight");
|
||||
const cnbBuild = cnbPreflight[1].indexOf(
|
||||
"cargo build --jobs 2 --release --locked -p codewhale-cli",
|
||||
);
|
||||
const cnbAlias = cnbPreflight[1].indexOf(
|
||||
"cp target/release/codewhale target/release/codew",
|
||||
);
|
||||
const cnbSmoke = cnbPreflight[1].indexOf("node scripts/release/npm-wrapper-smoke.js");
|
||||
assert.ok(cnbBuild >= 0, "CNB release preflight must build the consolidated runtime");
|
||||
assert.ok(cnbAlias > cnbBuild, "CNB release preflight must materialize codew after the build");
|
||||
assert.ok(cnbSmoke > cnbAlias, "CNB release preflight must materialize codew before smoke");
|
||||
|
||||
assert.doesNotMatch(
|
||||
archiveInstaller,
|
||||
/cargo install codewhale --locked/,
|
||||
"glibc recovery must name the published codewhale-cli crate",
|
||||
);
|
||||
assert.equal(
|
||||
(archiveInstaller.match(/cargo install codewhale-cli --locked/g) || []).length,
|
||||
2,
|
||||
"both glibc recovery branches must name codewhale-cli",
|
||||
);
|
||||
assert.match(
|
||||
archiveInstaller,
|
||||
/legacy_tui="\$BIN_DIR\/codewhale-tui"[\s\S]*install_binary "\$SCRIPT_DIR\/codewhale" "\$legacy_tui"/,
|
||||
"archive upgrades must refresh the retired TUI path from consolidated bytes",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
cliDispatcher,
|
||||
/codewhale_config::auto_model::classify/,
|
||||
"the CLI dispatcher must leave auto routing to the provider-aware runtime",
|
||||
);
|
||||
|
||||
console.log(
|
||||
"Workflow contracts OK: 6-target/12-asset single-runtime nightly and exact-head 7-target/34-asset release candidate.",
|
||||
);
|
||||
|
||||
@@ -35,23 +35,19 @@ sha() {
|
||||
|
||||
# --- read checksums ---------------------------------------------------
|
||||
|
||||
# Canonical dispatcher and TUI
|
||||
# One compiled runtime exposed under the two supported command names.
|
||||
SHA_COD_MACOS_ARM="$(sha codewhale-macos-arm64)"
|
||||
SHA_CODEW_MACOS_ARM="$(sha codew-macos-arm64)"
|
||||
SHA_TUI_MACOS_ARM="$(sha codewhale-tui-macos-arm64)"
|
||||
SHA_COD_MACOS_X64="$(sha codewhale-macos-x64)"
|
||||
SHA_CODEW_MACOS_X64="$(sha codew-macos-x64)"
|
||||
SHA_TUI_MACOS_X64="$(sha codewhale-tui-macos-x64)"
|
||||
SHA_COD_LINUX_ARM="$(sha codewhale-linux-arm64)"
|
||||
SHA_CODEW_LINUX_ARM="$(sha codew-linux-arm64)"
|
||||
SHA_TUI_LINUX_ARM="$(sha codewhale-tui-linux-arm64)"
|
||||
SHA_COD_LINUX_X64="$(sha codewhale-linux-x64)"
|
||||
SHA_CODEW_LINUX_X64="$(sha codew-linux-x64)"
|
||||
SHA_TUI_LINUX_X64="$(sha codewhale-tui-linux-x64)"
|
||||
readonly SHA_COD_MACOS_ARM SHA_CODEW_MACOS_ARM SHA_TUI_MACOS_ARM
|
||||
readonly SHA_COD_MACOS_X64 SHA_CODEW_MACOS_X64 SHA_TUI_MACOS_X64
|
||||
readonly SHA_COD_LINUX_ARM SHA_CODEW_LINUX_ARM SHA_TUI_LINUX_ARM
|
||||
readonly SHA_COD_LINUX_X64 SHA_CODEW_LINUX_X64 SHA_TUI_LINUX_X64
|
||||
readonly SHA_COD_MACOS_ARM SHA_CODEW_MACOS_ARM
|
||||
readonly SHA_COD_MACOS_X64 SHA_CODEW_MACOS_X64
|
||||
readonly SHA_COD_LINUX_ARM SHA_CODEW_LINUX_ARM
|
||||
readonly SHA_COD_LINUX_X64 SHA_CODEW_LINUX_X64
|
||||
|
||||
# --- temp dirs --------------------------------------------------------
|
||||
|
||||
@@ -78,10 +74,6 @@ class DeepseekTui < Formula
|
||||
url "${BASE_URL}/codew-macos-arm64", using: :nounzip
|
||||
sha256 "${SHA_CODEW_MACOS_ARM}"
|
||||
end
|
||||
resource "tui" do
|
||||
url "${BASE_URL}/codewhale-tui-macos-arm64", using: :nounzip
|
||||
sha256 "${SHA_TUI_MACOS_ARM}"
|
||||
end
|
||||
else
|
||||
url "${BASE_URL}/codewhale-macos-x64", using: :nounzip
|
||||
sha256 "${SHA_COD_MACOS_X64}"
|
||||
@@ -89,10 +81,6 @@ class DeepseekTui < Formula
|
||||
url "${BASE_URL}/codew-macos-x64", using: :nounzip
|
||||
sha256 "${SHA_CODEW_MACOS_X64}"
|
||||
end
|
||||
resource "tui" do
|
||||
url "${BASE_URL}/codewhale-tui-macos-x64", using: :nounzip
|
||||
sha256 "${SHA_TUI_MACOS_X64}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -104,10 +92,6 @@ class DeepseekTui < Formula
|
||||
url "${BASE_URL}/codew-linux-arm64", using: :nounzip
|
||||
sha256 "${SHA_CODEW_LINUX_ARM}"
|
||||
end
|
||||
resource "tui" do
|
||||
url "${BASE_URL}/codewhale-tui-linux-arm64", using: :nounzip
|
||||
sha256 "${SHA_TUI_LINUX_ARM}"
|
||||
end
|
||||
else
|
||||
url "${BASE_URL}/codewhale-linux-x64", using: :nounzip
|
||||
sha256 "${SHA_COD_LINUX_X64}"
|
||||
@@ -115,23 +99,17 @@ class DeepseekTui < Formula
|
||||
url "${BASE_URL}/codew-linux-x64", using: :nounzip
|
||||
sha256 "${SHA_CODEW_LINUX_X64}"
|
||||
end
|
||||
resource "tui" do
|
||||
url "${BASE_URL}/codewhale-tui-linux-x64", using: :nounzip
|
||||
sha256 "${SHA_TUI_LINUX_X64}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def install
|
||||
bin.install Dir["*"].first => "codewhale"
|
||||
resource("codew").stage { bin.install Dir["*"].first => "codew" }
|
||||
resource("tui").stage { bin.install Dir["*"].first => "codewhale-tui" }
|
||||
end
|
||||
|
||||
test do
|
||||
system "#{bin}/codewhale", "--version"
|
||||
system "#{bin}/codew", "--version"
|
||||
system "#{bin}/codewhale-tui", "--version"
|
||||
end
|
||||
end
|
||||
EOF
|
||||
|
||||
@@ -11,16 +11,12 @@ formula="${tmp_dir}/deepseek-tui.rb"
|
||||
assets=(
|
||||
codewhale-macos-arm64
|
||||
codew-macos-arm64
|
||||
codewhale-tui-macos-arm64
|
||||
codewhale-macos-x64
|
||||
codew-macos-x64
|
||||
codewhale-tui-macos-x64
|
||||
codewhale-linux-arm64
|
||||
codew-linux-arm64
|
||||
codewhale-tui-linux-arm64
|
||||
codewhale-linux-x64
|
||||
codew-linux-x64
|
||||
codewhale-tui-linux-x64
|
||||
)
|
||||
|
||||
for asset in "${assets[@]}"; do
|
||||
@@ -38,6 +34,9 @@ grep -Fq 'desc "Agentic terminal for open-source and open-weight coding models"'
|
||||
test "$(grep -Fc 'resource "codew" do' "${formula}")" -eq 4
|
||||
grep -Fq 'bin.install Dir["*"].first => "codew"' "${formula}"
|
||||
grep -Fq 'system "#{bin}/codew", "--version"' "${formula}"
|
||||
grep -Fq 'system "#{bin}/codewhale-tui", "--version"' "${formula}"
|
||||
if grep -Fq 'codewhale-tui' "${formula}"; then
|
||||
echo "Homebrew formula must not install the legacy TUI compatibility asset" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "update-homebrew-tap tests passed"
|
||||
|
||||
@@ -172,6 +172,7 @@ jobs:
|
||||
bash scripts/release/install-dogfood.test.sh
|
||||
bash scripts/release/prepare-release.test.sh
|
||||
bash scripts/release/require-release-tag-checkout.test.sh
|
||||
bash scripts/release/validate-crate-publish-order.test.sh
|
||||
bash scripts/release/verify-remote-tag.test.sh
|
||||
bash .github/scripts/update-homebrew-tap.test.sh
|
||||
node .github/scripts/release-workflows.test.js
|
||||
@@ -270,6 +271,11 @@ jobs:
|
||||
- name: Check dead-code budget
|
||||
if: needs.changes.outputs.heavy == 'true'
|
||||
run: python3 scripts/check-dead-code-budget.py
|
||||
- name: Test runtime-contract measurement harness
|
||||
if: needs.changes.outputs.heavy == 'true'
|
||||
run: |
|
||||
python3 scripts/test_measure_runtime_contract.py
|
||||
python3 scripts/test_check_runtime_contract_budget.py
|
||||
# The offline runtime-contract measurement needs the full locked graph,
|
||||
# dev-dependencies included (e.g. wiremock -> assert-json-diff), but
|
||||
# clippy above builds no test targets and the rust-cache registry key
|
||||
@@ -289,9 +295,11 @@ jobs:
|
||||
# Provider-free paused-consumer measurement of the production
|
||||
# persistence request channel. RSS is sampled only on macOS; every host
|
||||
# enforces the accepted/retained request and payload contract.
|
||||
- name: Test persistence-backlog checker
|
||||
- name: Test persistence-backlog measurement and checker harnesses
|
||||
if: needs.changes.outputs.heavy == 'true'
|
||||
run: python3 scripts/test_check_persistence_backlog_budget.py
|
||||
run: |
|
||||
python3 scripts/test_measure_persistence_backlog.py
|
||||
python3 scripts/test_check_persistence_backlog_budget.py
|
||||
- name: Check persistence-backlog budget
|
||||
if: needs.changes.outputs.heavy == 'true'
|
||||
run: python3 scripts/check-persistence-backlog-budget.py
|
||||
@@ -428,6 +436,20 @@ jobs:
|
||||
- name: Run tests
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch')
|
||||
run: cargo test --workspace --all-features --locked
|
||||
env:
|
||||
# Give test threads the stack the product gives itself. main.rs runs
|
||||
# the owner thread and every tokio worker at
|
||||
# CODEWHALE_MAIN_STACK_BYTES (16 MiB) because the engine and
|
||||
# runtime-thread futures are genuinely deep. `#[tokio::test]` builds
|
||||
# its own runtime and never sees that, so tests ran the same code on
|
||||
# ~2 MiB (~1 MiB on Windows) — a configuration that never ships.
|
||||
# That gap is what aborted the whole Windows test binary with
|
||||
# STATUS_STACK_OVERFLOW in start_turn_accepts_dynamic_tools_and_
|
||||
# environment_id, masking every other Windows result (78afd8d3d4
|
||||
# Box::pin'd that one frame; the mismatch itself remained). std reads
|
||||
# this for any thread spawned without an explicit size, which covers
|
||||
# both libtest's per-test threads and tokio's workers.
|
||||
RUST_MIN_STACK: '16777216'
|
||||
# The Ubuntu lint lane validates non-RSS backlog fields. Run the same
|
||||
# source-bound measurement on macOS so loss or growth of RSS evidence
|
||||
# fails closed instead of becoming an unsupported-field skip.
|
||||
@@ -437,10 +459,11 @@ jobs:
|
||||
- name: Run isolated Skills Manager PTY acceptance
|
||||
# This real-PTY scenario is deterministic in a fresh process (10/10
|
||||
# locally) but can inherit event starvation after the full qa_pty
|
||||
# suite on loaded Linux runners. Keep the assertion intact and run it
|
||||
# separately on Unix after the workspace suite has released its PTYs.
|
||||
# module suite on loaded Linux runners. Keep the assertion intact and
|
||||
# run it separately on Unix after the workspace suite has released its
|
||||
# PTYs.
|
||||
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'windows-latest' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch')
|
||||
run: cargo test -p codewhale-tui --test qa_pty skills_opens_manager_owned_then_compatible -- --ignored --exact
|
||||
run: cargo test -p codewhale-tui --test pty qa_pty::skills_opens_manager_owned_then_compatible -- --ignored --exact
|
||||
- name: Lockfile drift guard
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch')
|
||||
run: git diff --exit-code -- Cargo.lock
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Debug windows python
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
debug:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Probe python availability
|
||||
shell: pwsh
|
||||
run: |
|
||||
python --version
|
||||
python3 --version
|
||||
where.exe python
|
||||
where.exe python3
|
||||
py -3 --version
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
cache-bin: false
|
||||
- name: Run failing test with output
|
||||
shell: pwsh
|
||||
run: cargo test -p codewhale-tui --lib --all-features --locked -- --nocapture full_access_auto_approves_non_bypassable_registered_tools
|
||||
env:
|
||||
RUST_MIN_STACK: '16777216'
|
||||
@@ -33,51 +33,39 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
platform: linux-x64
|
||||
cli_binary: codewhale
|
||||
shim_binary: codew
|
||||
tui_binary: codewhale-tui
|
||||
cli_artifact: codewhale-linux-x64
|
||||
tui_artifact: codewhale-tui-linux-x64
|
||||
binary: codewhale
|
||||
primary_artifact: codewhale-linux-x64
|
||||
alias_artifact: codew-linux-x64
|
||||
- os: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
target: aarch64-unknown-linux-musl
|
||||
platform: linux-arm64
|
||||
cli_binary: codewhale
|
||||
shim_binary: codew
|
||||
tui_binary: codewhale-tui
|
||||
cli_artifact: codewhale-linux-arm64
|
||||
tui_artifact: codewhale-tui-linux-arm64
|
||||
binary: codewhale
|
||||
primary_artifact: codewhale-linux-arm64
|
||||
alias_artifact: codew-linux-arm64
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
platform: macos-x64
|
||||
cli_binary: codewhale
|
||||
shim_binary: codew
|
||||
tui_binary: codewhale-tui
|
||||
cli_artifact: codewhale-macos-x64
|
||||
tui_artifact: codewhale-tui-macos-x64
|
||||
binary: codewhale
|
||||
primary_artifact: codewhale-macos-x64
|
||||
alias_artifact: codew-macos-x64
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
platform: macos-arm64
|
||||
cli_binary: codewhale
|
||||
shim_binary: codew
|
||||
tui_binary: codewhale-tui
|
||||
cli_artifact: codewhale-macos-arm64
|
||||
tui_artifact: codewhale-tui-macos-arm64
|
||||
binary: codewhale
|
||||
primary_artifact: codewhale-macos-arm64
|
||||
alias_artifact: codew-macos-arm64
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
platform: windows-x64
|
||||
cli_binary: codewhale.exe
|
||||
shim_binary: codew.exe
|
||||
tui_binary: codewhale-tui.exe
|
||||
cli_artifact: codewhale-windows-x64.exe
|
||||
tui_artifact: codewhale-tui-windows-x64.exe
|
||||
binary: codewhale.exe
|
||||
primary_artifact: codewhale-windows-x64.exe
|
||||
alias_artifact: codew-windows-x64.exe
|
||||
- os: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
platform: windows-arm64
|
||||
cli_binary: codewhale.exe
|
||||
shim_binary: codew.exe
|
||||
tui_binary: codewhale-tui.exe
|
||||
cli_artifact: codewhale-windows-arm64.exe
|
||||
tui_artifact: codewhale-tui-windows-arm64.exe
|
||||
binary: codewhale.exe
|
||||
primary_artifact: codewhale-windows-arm64.exe
|
||||
alias_artifact: codew-windows-arm64.exe
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -92,14 +80,16 @@ jobs:
|
||||
if: steps.sccache.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
|
||||
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
|
||||
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
|
||||
{
|
||||
echo "SCCACHE_GHA_ENABLED=true"
|
||||
echo "RUSTC_WRAPPER=sccache"
|
||||
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1"
|
||||
} >> "${GITHUB_ENV}"
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
cache-bin: false
|
||||
- name: Install Linux system dependencies
|
||||
if: runner.os == 'Linux'
|
||||
- name: Install Linux GNU system dependencies
|
||||
if: matrix.target == 'x86_64-unknown-linux-gnu'
|
||||
run: |
|
||||
for i in 1 2 3 4 5; do
|
||||
sudo apt-get update && break
|
||||
@@ -107,6 +97,13 @@ jobs:
|
||||
sleep 15
|
||||
done
|
||||
sudo apt-get install -y libdbus-1-dev pkg-config
|
||||
- name: Install Linux ARM64 musl toolchain
|
||||
if: matrix.target == 'aarch64-unknown-linux-musl'
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y binutils musl-tools
|
||||
rustup target add --toolchain stable aarch64-unknown-linux-musl
|
||||
- name: Build
|
||||
shell: bash
|
||||
# Nightly artifacts are disposable smoke binaries (14-day retention),
|
||||
@@ -117,7 +114,7 @@ jobs:
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
if cargo build --release --locked --target ${{ matrix.target }} -p codewhale-cli -p codewhale-tui; then
|
||||
if cargo build --release --locked --target ${{ matrix.target }} -p codewhale-cli; then
|
||||
exit 0
|
||||
fi
|
||||
if [ "${attempt}" -lt 3 ]; then
|
||||
@@ -127,30 +124,40 @@ jobs:
|
||||
done
|
||||
echo "Build failed after 3 attempts" >&2
|
||||
exit 1
|
||||
- name: Smoke native ARM binaries
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu' || matrix.target == 'aarch64-pc-windows-msvc'
|
||||
- name: Verify static Linux ARM64 binary and launch
|
||||
if: matrix.target == 'aarch64-unknown-linux-musl' && runner.arch == 'ARM64'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bin_path="target/${{ matrix.target }}/release/${{ matrix.binary }}"
|
||||
if readelf -l "${bin_path}" | grep -Fq 'INTERP'; then
|
||||
echo "Expected a static musl binary, but ${bin_path} has an ELF interpreter" >&2
|
||||
exit 1
|
||||
fi
|
||||
"${bin_path}" --version
|
||||
- name: Smoke binary on matching native runners
|
||||
if: >-
|
||||
(startsWith(matrix.target, 'x86_64-') && runner.arch == 'X64') ||
|
||||
(startsWith(matrix.target, 'aarch64-') && runner.arch == 'ARM64')
|
||||
shell: bash
|
||||
run: |
|
||||
bin_dir="target/${{ matrix.target }}/release"
|
||||
"${bin_dir}/${{ matrix.cli_binary }}" --version
|
||||
"${bin_dir}/${{ matrix.shim_binary }}" --version
|
||||
"${bin_dir}/${{ matrix.tui_binary }}" --version
|
||||
"${bin_dir}/${{ matrix.binary }}" --version
|
||||
- name: Stage artifact
|
||||
id: stage
|
||||
shell: bash
|
||||
run: |
|
||||
short_sha="${GITHUB_SHA::12}"
|
||||
stage_one() {
|
||||
local binary="$1"
|
||||
local artifact="$2"
|
||||
local dir="$3"
|
||||
local bin_path="target/${{ matrix.target }}/release/${binary}"
|
||||
if [ ! -f "${bin_path}" ]; then
|
||||
echo "Binary not at ${bin_path}; searching target/ for ${binary}:"
|
||||
find target -name "${binary}" -type f
|
||||
exit 1
|
||||
fi
|
||||
bin_path="target/${{ matrix.target }}/release/${{ matrix.binary }}"
|
||||
if [ ! -f "${bin_path}" ]; then
|
||||
echo "Binary not at ${bin_path}; searching target/ for ${{ matrix.binary }}:"
|
||||
find target -name "${{ matrix.binary }}" -type f
|
||||
exit 1
|
||||
fi
|
||||
|
||||
stage_copy() {
|
||||
local artifact="$1"
|
||||
local dir="$2"
|
||||
mkdir -p "${dir}"
|
||||
cp "${bin_path}" "${dir}/${artifact}"
|
||||
cat > "${dir}/nightly-build-info.txt" <<INFO
|
||||
@@ -162,17 +169,20 @@ jobs:
|
||||
INFO
|
||||
}
|
||||
|
||||
stage_one "${{ matrix.cli_binary }}" "${{ matrix.cli_artifact }}" nightly-cli
|
||||
stage_one "${{ matrix.tui_binary }}" "${{ matrix.tui_artifact }}" nightly-tui
|
||||
echo "cli_name=${{ matrix.cli_artifact }}-${short_sha}" >> "${GITHUB_OUTPUT}"
|
||||
echo "tui_name=${{ matrix.tui_artifact }}-${short_sha}" >> "${GITHUB_OUTPUT}"
|
||||
stage_copy "${{ matrix.primary_artifact }}" nightly-primary
|
||||
stage_copy "${{ matrix.alias_artifact }}" nightly-alias
|
||||
cmp -s \
|
||||
"nightly-primary/${{ matrix.primary_artifact }}" \
|
||||
"nightly-alias/${{ matrix.alias_artifact }}"
|
||||
echo "primary_name=${{ matrix.primary_artifact }}-${short_sha}" >> "${GITHUB_OUTPUT}"
|
||||
echo "alias_name=${{ matrix.alias_artifact }}-${short_sha}" >> "${GITHUB_OUTPUT}"
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.stage.outputs.cli_name }}
|
||||
path: nightly-cli/*
|
||||
name: ${{ steps.stage.outputs.primary_name }}
|
||||
path: nightly-primary/*
|
||||
retention-days: 14
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.stage.outputs.tui_name }}
|
||||
path: nightly-tui/*
|
||||
name: ${{ steps.stage.outputs.alias_name }}
|
||||
path: nightly-alias/*
|
||||
retention-days: 14
|
||||
|
||||
@@ -29,6 +29,9 @@ env:
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.platform }}
|
||||
# FreeBSD is a source-build target validated via `cargo check --target x86_64-unknown-freebsd -p codewhale-cli --locked`
|
||||
# (see packaging/freebsd/README.md and docs/INSTALL.md#freebsd). The 7×1 prebuilt matrix stays 7 targets;
|
||||
# FreeBSD has no prebuilt asset, no npm binary, and no matrix bloat — it builds from source.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -38,16 +41,14 @@ jobs:
|
||||
platform: linux-x64
|
||||
cli_binary: codewhale
|
||||
shim_binary: codew
|
||||
tui_binary: codewhale-tui
|
||||
cli_artifact: codewhale-linux-x64
|
||||
shim_artifact: codew-linux-x64
|
||||
tui_artifact: codewhale-tui-linux-x64
|
||||
- os: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
target: aarch64-unknown-linux-musl
|
||||
platform: linux-arm64
|
||||
cli_binary: codewhale
|
||||
shim_binary: codew
|
||||
tui_binary: codewhale-tui
|
||||
cli_artifact: codewhale-linux-arm64
|
||||
shim_artifact: codew-linux-arm64
|
||||
tui_artifact: codewhale-tui-linux-arm64
|
||||
@@ -56,7 +57,6 @@ jobs:
|
||||
platform: android-arm64
|
||||
cli_binary: codewhale
|
||||
shim_binary: codew
|
||||
tui_binary: codewhale-tui
|
||||
cli_artifact: codewhale-android-arm64
|
||||
shim_artifact: codew-android-arm64
|
||||
tui_artifact: codewhale-tui-android-arm64
|
||||
@@ -65,7 +65,6 @@ jobs:
|
||||
platform: macos-x64
|
||||
cli_binary: codewhale
|
||||
shim_binary: codew
|
||||
tui_binary: codewhale-tui
|
||||
cli_artifact: codewhale-macos-x64
|
||||
shim_artifact: codew-macos-x64
|
||||
tui_artifact: codewhale-tui-macos-x64
|
||||
@@ -74,7 +73,6 @@ jobs:
|
||||
platform: macos-arm64
|
||||
cli_binary: codewhale
|
||||
shim_binary: codew
|
||||
tui_binary: codewhale-tui
|
||||
cli_artifact: codewhale-macos-arm64
|
||||
shim_artifact: codew-macos-arm64
|
||||
tui_artifact: codewhale-tui-macos-arm64
|
||||
@@ -83,7 +81,6 @@ jobs:
|
||||
platform: windows-x64
|
||||
cli_binary: codewhale.exe
|
||||
shim_binary: codew.exe
|
||||
tui_binary: codewhale-tui.exe
|
||||
cli_artifact: codewhale-windows-x64.exe
|
||||
shim_artifact: codew-windows-x64.exe
|
||||
tui_artifact: codewhale-tui-windows-x64.exe
|
||||
@@ -92,7 +89,6 @@ jobs:
|
||||
platform: windows-arm64
|
||||
cli_binary: codewhale.exe
|
||||
shim_binary: codew.exe
|
||||
tui_binary: codewhale-tui.exe
|
||||
cli_artifact: codewhale-windows-arm64.exe
|
||||
shim_artifact: codew-windows-arm64.exe
|
||||
tui_artifact: codewhale-tui-windows-arm64.exe
|
||||
@@ -120,23 +116,14 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
cache-bin: false
|
||||
- name: Install Linux ARM64 system dependencies
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
for i in 1 2 3 4 5; do
|
||||
sudo apt-get update && break
|
||||
echo "apt-get update failed (attempt $i); retrying in 15s"
|
||||
sleep 15
|
||||
done
|
||||
sudo apt-get install -y libdbus-1-dev pkg-config
|
||||
- name: Build static Linux x64 binaries (musl)
|
||||
if: matrix.target == 'x86_64-unknown-linux-musl'
|
||||
- name: Build static Linux binaries (musl)
|
||||
if: endsWith(matrix.target, '-unknown-linux-musl')
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y musl-tools
|
||||
rustup target add --toolchain stable x86_64-unknown-linux-musl
|
||||
cargo build --profile dist --locked --target x86_64-unknown-linux-musl -p codewhale-cli -p codewhale-tui
|
||||
sudo apt-get install -y binutils musl-tools
|
||||
rustup target add --toolchain stable ${{ matrix.target }}
|
||||
cargo build --profile dist --locked --target ${{ matrix.target }} -p codewhale-cli
|
||||
- name: Configure Android NDK linker
|
||||
if: matrix.target == 'aarch64-linux-android' && runner.os == 'Linux'
|
||||
shell: bash
|
||||
@@ -184,9 +171,32 @@ jobs:
|
||||
echo "BINDGEN_EXTRA_CLANG_ARGS_aarch64_linux_android=--target=aarch64-linux-android24 --sysroot=${ndk}/toolchains/llvm/prebuilt/linux-x86_64/sysroot"
|
||||
} >> "${GITHUB_ENV}"
|
||||
- name: Build
|
||||
if: matrix.target != 'x86_64-unknown-linux-musl'
|
||||
if: ${{ !endsWith(matrix.target, '-unknown-linux-musl') }}
|
||||
shell: bash
|
||||
run: cargo build --profile dist --locked --target ${{ matrix.target }} -p codewhale-cli -p codewhale-tui
|
||||
run: cargo build --profile dist --locked --target ${{ matrix.target }} -p codewhale-cli
|
||||
- name: Materialize codew command alias
|
||||
shell: bash
|
||||
run: |
|
||||
bin_dir="target/${{ matrix.target }}/dist"
|
||||
cp "${bin_dir}/${{ matrix.cli_binary }}" "${bin_dir}/${{ matrix.shim_binary }}"
|
||||
cmp "${bin_dir}/${{ matrix.cli_binary }}" "${bin_dir}/${{ matrix.shim_binary }}"
|
||||
- name: Verify static Linux binaries and launch on matching native runners
|
||||
if: >-
|
||||
endsWith(matrix.target, '-unknown-linux-musl') &&
|
||||
((startsWith(matrix.target, 'x86_64-') && runner.arch == 'X64') ||
|
||||
(startsWith(matrix.target, 'aarch64-') && runner.arch == 'ARM64'))
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bin_dir="target/${{ matrix.target }}/dist"
|
||||
for binary in "${{ matrix.cli_binary }}" "${{ matrix.shim_binary }}"; do
|
||||
bin_path="${bin_dir}/${binary}"
|
||||
if readelf -l "${bin_path}" | grep -Fq 'INTERP'; then
|
||||
echo "Expected a static musl binary, but ${bin_path} has an ELF interpreter" >&2
|
||||
exit 1
|
||||
fi
|
||||
"${bin_path}" --version
|
||||
done
|
||||
- name: Smoke binaries on matching native runners
|
||||
if: >-
|
||||
matrix.target != 'aarch64-linux-android' &&
|
||||
@@ -197,7 +207,6 @@ jobs:
|
||||
bin_dir="target/${{ matrix.target }}/dist"
|
||||
"${bin_dir}/${{ matrix.cli_binary }}" --version
|
||||
"${bin_dir}/${{ matrix.shim_binary }}" --version
|
||||
"${bin_dir}/${{ matrix.tui_binary }}" --version
|
||||
- name: Stage binaries
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -215,7 +224,10 @@ jobs:
|
||||
|
||||
stage_binary "${{ matrix.cli_binary }}" "${{ matrix.cli_artifact }}"
|
||||
stage_binary "${{ matrix.shim_binary }}" "${{ matrix.shim_artifact }}"
|
||||
stage_binary "${{ matrix.tui_binary }}" "${{ matrix.tui_artifact }}"
|
||||
# One-release compatibility bridge for v0.9.4's hard-coded release
|
||||
# completeness/updater contract. This is the same runtime, not a
|
||||
# separately compiled TUI binary.
|
||||
stage_binary "${{ matrix.cli_binary }}" "${{ matrix.tui_artifact }}"
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ${{ matrix.cli_artifact }}
|
||||
@@ -252,7 +264,15 @@ jobs:
|
||||
pattern: '*'
|
||||
- name: Create and checksum platform archives
|
||||
shell: bash
|
||||
run: bash scripts/release/create-release-bundles.sh artifacts bundles
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_date_epoch="$(git show -s --format=%ct "${{ inputs.source_sha }}")"
|
||||
if [[ ! "${source_date_epoch}" =~ ^[0-9]+$ ]]; then
|
||||
echo "Could not read a Unix timestamp for source commit ${{ inputs.source_sha }}" >&2
|
||||
exit 1
|
||||
fi
|
||||
SOURCE_DATE_EPOCH="${source_date_epoch}" \
|
||||
bash scripts/release/create-release-bundles.sh artifacts bundles
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: codewhale-bundles
|
||||
@@ -285,7 +305,6 @@ jobs:
|
||||
$ErrorActionPreference = "Stop"
|
||||
Copy-Item "artifacts\codewhale-windows-x64.exe\codewhale-windows-x64.exe" "scripts\installer\codewhale.exe"
|
||||
Copy-Item "artifacts\codew-windows-x64.exe\codew-windows-x64.exe" "scripts\installer\codew.exe"
|
||||
Copy-Item "artifacts\codewhale-tui-windows-x64.exe\codewhale-tui-windows-x64.exe" "scripts\installer\codewhale-tui.exe"
|
||||
$makensis = "${env:ProgramFiles(x86)}\NSIS\makensis.exe"
|
||||
if (!(Test-Path $makensis)) {
|
||||
$makensis = "${env:ProgramFiles}\NSIS\makensis.exe"
|
||||
@@ -348,7 +367,7 @@ jobs:
|
||||
with:
|
||||
name: codewhale-release-assets
|
||||
path: release-assets
|
||||
- name: Verify 34-asset inventory and checksum manifests
|
||||
- name: Verify 34-asset bridge inventory and checksum manifests (single binary)
|
||||
run: node scripts/release/assemble-release-assets.js --verify release-assets
|
||||
- name: Test release inventory contracts
|
||||
run: |
|
||||
@@ -366,6 +385,6 @@ jobs:
|
||||
echo ""
|
||||
echo "- Source: \`${{ inputs.source_sha }}\`"
|
||||
echo "- Version metadata: \`${{ inputs.version }}\`"
|
||||
echo "- Inventory: 7 targets / 34 files"
|
||||
echo "- Inventory: 7 targets / 34 files (single binary; 7 legacy alias assets)"
|
||||
echo "- Publication: none (Actions artifact \`codewhale-release-assets\` only)"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
@@ -167,7 +167,6 @@ jobs:
|
||||
docker pull "${IMAGE}"
|
||||
docker run --rm --entrypoint codewhale "${IMAGE}" --version
|
||||
docker run --rm --entrypoint codew "${IMAGE}" --version
|
||||
docker run --rm --entrypoint codewhale-tui "${IMAGE}" --version
|
||||
|
||||
homebrew:
|
||||
needs: resolve
|
||||
|
||||
+214
-32
@@ -163,6 +163,12 @@ jobs:
|
||||
-A clippy::assertions_on_constants
|
||||
- name: Workspace tests
|
||||
run: cargo test --workspace --all-features --locked
|
||||
env:
|
||||
# Match the CI test lane: test threads get the same stack the product
|
||||
# gives itself (main.rs CODEWHALE_MAIN_STACK_BYTES). See the note in
|
||||
# ci.yml's "Run tests" step. Without it this gate runs the deep
|
||||
# engine/runtime futures on a stack that never ships.
|
||||
RUST_MIN_STACK: '16777216'
|
||||
- name: Protocol schema parity
|
||||
run: cargo test -p codewhale-protocol --test parity_protocol --locked
|
||||
- name: State persistence parity
|
||||
@@ -179,26 +185,149 @@ jobs:
|
||||
version: ${{ needs.resolve.outputs.version }}
|
||||
retention_days: 14
|
||||
|
||||
docker:
|
||||
docker-build:
|
||||
needs: [artifacts, resolve]
|
||||
if: ${{ !cancelled() && needs.artifacts.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
name: Docker ${{ matrix.platform }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-latest
|
||||
platform: linux/amd64
|
||||
architecture: amd64
|
||||
cli_artifact: codewhale-linux-x64
|
||||
shim_artifact: codew-linux-x64
|
||||
- runner: ubuntu-24.04-arm
|
||||
platform: linux/arm64
|
||||
architecture: arm64
|
||||
cli_artifact: codewhale-linux-arm64
|
||||
shim_artifact: codew-linux-arm64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout release source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
path: source
|
||||
- name: Checkout release infrastructure
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
path: infra
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||
- name: Download Codewhale release binary
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: ${{ matrix.cli_artifact }}
|
||||
path: docker-context/bin
|
||||
- name: Download codew release alias
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: ${{ matrix.shim_artifact }}
|
||||
path: docker-context/bin
|
||||
- name: Verify native release bytes
|
||||
shell: bash
|
||||
env:
|
||||
CLI_ARTIFACT: ${{ matrix.cli_artifact }}
|
||||
SHIM_ARTIFACT: ${{ matrix.shim_artifact }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mv -- "docker-context/bin/${CLI_ARTIFACT}" docker-context/bin/codewhale
|
||||
mv -- "docker-context/bin/${SHIM_ARTIFACT}" docker-context/bin/codew
|
||||
chmod 0755 docker-context/bin/codewhale docker-context/bin/codew
|
||||
cmp docker-context/bin/codewhale docker-context/bin/codew
|
||||
docker-context/bin/codewhale --version
|
||||
docker-context/bin/codew --version
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Normalize image name
|
||||
id: image
|
||||
shell: bash
|
||||
run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
|
||||
- name: Extract image labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
|
||||
with:
|
||||
images: |
|
||||
${{ steps.image.outputs.name }}
|
||||
tags: |
|
||||
type=raw,value=${{ needs.resolve.outputs.version }}
|
||||
- name: Revalidate release tag before container upload
|
||||
env:
|
||||
EXPECTED_SHA: ${{ needs.resolve.outputs.sha }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
run: |
|
||||
./infra/scripts/release/verify-remote-tag.sh \
|
||||
"https://github.com/${GITHUB_REPOSITORY}.git" \
|
||||
"${TAG}" \
|
||||
"${EXPECTED_SHA}"
|
||||
- name: Assemble and push native image by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
env:
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
with:
|
||||
context: docker-context
|
||||
file: infra/packaging/docker/Dockerfile.release
|
||||
platforms: ${{ matrix.platform }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ steps.image.outputs.name }},push-by-digest=true,name-canonical=true,push=true
|
||||
- name: Smoke native image digest
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ steps.image.outputs.name }}@${{ steps.build.outputs.digest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pull "${IMAGE}"
|
||||
docker run --rm --entrypoint codewhale "${IMAGE}" --version
|
||||
docker run --rm --entrypoint codew "${IMAGE}" --version
|
||||
- name: Export image digest
|
||||
shell: bash
|
||||
env:
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! [[ "${DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]; then
|
||||
echo "Unexpected image digest: ${DIGEST}" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p digests
|
||||
touch "digests/${DIGEST#sha256:}"
|
||||
- name: Upload image digest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: docker-digest-${{ matrix.architecture }}
|
||||
path: digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
overwrite: true
|
||||
|
||||
docker:
|
||||
needs: [docker-build, resolve]
|
||||
if: ${{ !cancelled() && needs.docker-build.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout release infrastructure
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
path: infra
|
||||
- name: Download native image digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
path: digests
|
||||
pattern: docker-digest-*
|
||||
merge-multiple: true
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
- name: Log in to GitHub Container Registry
|
||||
@@ -237,34 +366,52 @@ jobs:
|
||||
"https://github.com/${GITHUB_REPOSITORY}.git" \
|
||||
"${TAG}" \
|
||||
"${EXPECTED_SHA}"
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
- name: Publish multi-architecture manifest
|
||||
shell: bash
|
||||
env:
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
with:
|
||||
context: source
|
||||
file: infra/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
build-args: |
|
||||
DEEPSEEK_BUILD_SHA=${{ needs.resolve.outputs.sha }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Smoke published container entrypoints
|
||||
IMAGE: ${{ steps.image.outputs.name }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t digest_files < <(find digests -maxdepth 1 -type f -printf '%f\n' | sort)
|
||||
if [[ "${#digest_files[@]}" -ne 2 ]]; then
|
||||
echo "Expected exactly two native image digests; found ${#digest_files[@]}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sources=()
|
||||
for digest in "${digest_files[@]}"; do
|
||||
if ! [[ "${digest}" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "Unexpected image digest file: ${digest}" >&2
|
||||
exit 1
|
||||
fi
|
||||
sources+=("${IMAGE}@sha256:${digest}")
|
||||
done
|
||||
|
||||
tag_args=()
|
||||
while IFS= read -r tag; do
|
||||
[[ -n "${tag}" ]] && tag_args+=(--tag "${tag}")
|
||||
done <<< "${TAGS}"
|
||||
if [[ "${#tag_args[@]}" -eq 0 ]]; then
|
||||
echo "No container tags were generated." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker buildx imagetools create "${tag_args[@]}" "${sources[@]}"
|
||||
- name: Verify and smoke published container
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ steps.image.outputs.name }}:${{ needs.resolve.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx imagetools inspect "${IMAGE}"
|
||||
raw_manifest="$(docker buildx imagetools inspect --raw "${IMAGE}")"
|
||||
jq -e \
|
||||
'[.manifests[] | select(.platform.os == "linux") | "linux/\(.platform.architecture)"] | unique | sort == ["linux/amd64", "linux/arm64"]' \
|
||||
<<< "${raw_manifest}"
|
||||
docker pull "${IMAGE}"
|
||||
docker run --rm --entrypoint codewhale "${IMAGE}" --version
|
||||
docker run --rm --entrypoint codew "${IMAGE}" --version
|
||||
docker run --rm --entrypoint codewhale-tui "${IMAGE}" --version
|
||||
|
||||
release:
|
||||
needs: [artifacts, docker, resolve]
|
||||
@@ -314,6 +461,45 @@ jobs:
|
||||
overwrite_files: false
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
npm:
|
||||
needs: [release, resolve]
|
||||
if: ${{ !cancelled() && needs.release.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24
|
||||
registry-url: https://registry.npmjs.org
|
||||
package-manager-cache: false
|
||||
- name: Pin OIDC-capable npm CLI
|
||||
run: npm install --global npm@12.0.2
|
||||
- name: Revalidate release tag before npm publish
|
||||
env:
|
||||
EXPECTED_SHA: ${{ needs.resolve.outputs.sha }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
run: |
|
||||
./scripts/release/verify-remote-tag.sh \
|
||||
"https://github.com/${GITHUB_REPOSITORY}.git" \
|
||||
"${TAG}" \
|
||||
"${EXPECTED_SHA}"
|
||||
- name: Revalidate public release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: ./scripts/release/verify-release-assets.sh "${{ needs.resolve.outputs.version }}"
|
||||
- name: Test npm wrapper
|
||||
working-directory: npm/codewhale
|
||||
run: npm test
|
||||
- name: Publish npm wrapper with trusted publishing
|
||||
working-directory: npm/codewhale
|
||||
run: npm publish --access public
|
||||
|
||||
homebrew:
|
||||
needs: [release, resolve]
|
||||
if: ${{ !cancelled() && needs.release.result == 'success' }}
|
||||
@@ -363,7 +549,3 @@ jobs:
|
||||
TAP_REPO: Hmbown/homebrew-deepseek-tui
|
||||
TOKEN: ${{ secrets.HOMEBREW_TAP_PAT || secrets.RELEASE_TAG_PAT }}
|
||||
run: bash .github/scripts/update-homebrew-tap.sh
|
||||
|
||||
# npm publish is intentionally not automated. The npm account requires 2FA OTP
|
||||
# on every publish. Publish the wrapper manually only after the immutable public
|
||||
# GitHub asset gate in docs/RELEASE_RUNBOOK.md succeeds.
|
||||
|
||||
@@ -52,6 +52,25 @@ jobs:
|
||||
- name: Build production site
|
||||
run: npm run build
|
||||
|
||||
deploy-reminder:
|
||||
name: Deployment approval needed
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Surface the manual deployment gate
|
||||
env:
|
||||
REVISION: ${{ github.sha }}
|
||||
run: |
|
||||
echo "::notice title=Web deployment approval needed::Revision ${REVISION} passed the web gates but is not deployed. Dispatch web.yml on main to publish it."
|
||||
{
|
||||
echo "## Web deployment approval needed"
|
||||
echo
|
||||
echo "Revision \`${REVISION}\` passed the web gates but has **not** been deployed."
|
||||
echo
|
||||
echo "A maintainer can publish it with \`gh workflow run web.yml --repo Hmbown/CodeWhale --ref main\`."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
deploy:
|
||||
name: Deploy to Cloudflare
|
||||
runs-on: ubuntu-latest
|
||||
@@ -103,9 +122,10 @@ jobs:
|
||||
run: npm run compare:deployed-facts -- --expected-revision "$GITHUB_SHA"
|
||||
- name: Check Cloudflare deploy environment
|
||||
run: npm run check:deploy-env
|
||||
- name: Build OpenNext bundle
|
||||
run: npm run build && npx opennextjs-cloudflare build
|
||||
- name: Deploy
|
||||
# npm's deploy script performs one OpenNext build, then deploys that exact
|
||||
# bundle. Wrangler must not run a custom post-cache build: OpenNext
|
||||
# populates the remote cache before it hands the bundle to Wrangler.
|
||||
- name: Build and deploy exact OpenNext bundle
|
||||
run: npm run deploy
|
||||
- name: Verify exact deployed revision
|
||||
# The public /api/facts receipt must identify this workflow's exact
|
||||
|
||||
@@ -54,6 +54,7 @@ docs/*.pdf
|
||||
!scripts/**
|
||||
!.github/scripts/**
|
||||
!web/public/install.sh
|
||||
!packaging/winget/**
|
||||
test.txt
|
||||
TODO*.md
|
||||
todo*.md
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Hmbown.CodeWhale — winget singleton manifest for CodeWhale (single binary)
|
||||
# This is a mirror of packaging/winget/Hmbown.CodeWhale.yaml for tooling that expects .winget/.
|
||||
# Keep both in sync; the canonical source is packaging/winget/Hmbown.CodeWhale.yaml.
|
||||
# See packaging/winget/README.md for update instructions.
|
||||
|
||||
PackageIdentifier: Hmbown.CodeWhale
|
||||
PackageVersion: 0.9.5
|
||||
DefaultLocale: en-US
|
||||
ManifestType: singleton
|
||||
ManifestVersion: 1.6.0
|
||||
|
||||
Publisher: Hmbown
|
||||
PublisherUrl: https://github.com/Hmbown
|
||||
PublisherSupportUrl: https://github.com/Hmbown/CodeWhale/issues
|
||||
PackageName: CodeWhale
|
||||
PackageUrl: https://github.com/Hmbown/CodeWhale
|
||||
License: MIT
|
||||
LicenseUrl: https://github.com/Hmbown/CodeWhale/blob/main/LICENSE
|
||||
Copyright: Copyright (c) Hmbown
|
||||
ShortDescription: Terminal coding agent for supported hosted and local models
|
||||
Description: |
|
||||
CodeWhale is a terminal coding agent that runs on your machine. The v0.9.5 single-binary
|
||||
release ships one `codewhale` binary per target (plus the `codew` shim) across Linux x64 (musl),
|
||||
Linux arm64, Android arm64, macOS x64/arm64, and Windows x64/arm64. See https://github.com/Hmbown/CodeWhale
|
||||
for provider setup, Fleet workflows, and the full install guide.
|
||||
Author: Hmbown
|
||||
Moniker: codewhale
|
||||
Tags:
|
||||
- codewhale
|
||||
- deepseek
|
||||
- cli
|
||||
- tui
|
||||
- terminal
|
||||
- ai
|
||||
- coding-agent
|
||||
- rust
|
||||
MinimumOSVersion: 10.0.0.0
|
||||
ReleaseNotes: https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.5
|
||||
ReleaseNotesUrl: https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.5
|
||||
|
||||
Installers:
|
||||
- Architecture: x64
|
||||
InstallerType: nullsoft
|
||||
Scope: user
|
||||
InstallerUrl: https://github.com/Hmbown/CodeWhale/releases/download/v0.9.5/CodeWhaleSetup.exe
|
||||
InstallerSha256: 0000000000000000000000000000000000000000000000000000000000000000
|
||||
ProductCode: CodeWhale
|
||||
UpgradeBehavior: install
|
||||
ReleaseDate: 2026-08-07
|
||||
- Architecture: x64
|
||||
InstallerType: zip
|
||||
Scope: user
|
||||
InstallerUrl: https://github.com/Hmbown/CodeWhale/releases/download/v0.9.5/codewhale-windows-x64.zip
|
||||
InstallerSha256: 0000000000000000000000000000000000000000000000000000000000000000
|
||||
NestedInstallerType: portable
|
||||
NestedInstallerFiles:
|
||||
- RelativeFilePath: codewhale-windows-x64/codewhale.exe
|
||||
PortableCommandAlias: codewhale
|
||||
- RelativeFilePath: codewhale-windows-x64/codew.exe
|
||||
PortableCommandAlias: codew
|
||||
ReleaseDate: 2026-08-07
|
||||
- Architecture: x64
|
||||
InstallerType: zip
|
||||
Scope: user
|
||||
InstallerUrl: https://github.com/Hmbown/CodeWhale/releases/download/v0.9.5/codewhale-windows-x64-portable.zip
|
||||
InstallerSha256: 0000000000000000000000000000000000000000000000000000000000000000
|
||||
NestedInstallerType: portable
|
||||
NestedInstallerFiles:
|
||||
- RelativeFilePath: codewhale-windows-x64-portable/codewhale.exe
|
||||
PortableCommandAlias: codewhale
|
||||
- RelativeFilePath: codewhale-windows-x64-portable/codew.exe
|
||||
PortableCommandAlias: codew
|
||||
ReleaseDate: 2026-08-07
|
||||
- Architecture: arm64
|
||||
InstallerType: zip
|
||||
Scope: user
|
||||
InstallerUrl: https://github.com/Hmbown/CodeWhale/releases/download/v0.9.5/codewhale-windows-arm64.zip
|
||||
InstallerSha256: 0000000000000000000000000000000000000000000000000000000000000000
|
||||
NestedInstallerType: portable
|
||||
NestedInstallerFiles:
|
||||
- RelativeFilePath: codewhale-windows-arm64/codewhale.exe
|
||||
PortableCommandAlias: codewhale
|
||||
- RelativeFilePath: codewhale-windows-arm64/codew.exe
|
||||
PortableCommandAlias: codew
|
||||
ReleaseDate: 2026-08-07
|
||||
- Architecture: arm64
|
||||
InstallerType: zip
|
||||
Scope: user
|
||||
InstallerUrl: https://github.com/Hmbown/CodeWhale/releases/download/v0.9.5/codewhale-windows-arm64-portable.zip
|
||||
InstallerSha256: 0000000000000000000000000000000000000000000000000000000000000000
|
||||
NestedInstallerType: portable
|
||||
NestedInstallerFiles:
|
||||
- RelativeFilePath: codewhale-windows-arm64-portable/codewhale.exe
|
||||
PortableCommandAlias: codewhale
|
||||
- RelativeFilePath: codewhale-windows-arm64-portable/codew.exe
|
||||
PortableCommandAlias: codew
|
||||
ReleaseDate: 2026-08-07
|
||||
@@ -1,8 +1,8 @@
|
||||
# Repository Agent Guidance
|
||||
|
||||
Durable rules only. Perishable lane state — branch, milestone, known flakes,
|
||||
closed investigations — lives in `docs/ops/CURRENT.md`; read it, don't trust
|
||||
memory of it.
|
||||
closed investigations — lives in the private `codewhale-ops` repo, not here.
|
||||
Read it there; don't trust memory of it.
|
||||
|
||||
## Intent is the artifact
|
||||
|
||||
@@ -93,6 +93,6 @@ and every model/provider first-class — none privileged.
|
||||
- Keep gates warm and dry-run unless Hunter explicitly approves enforcement.
|
||||
- Leave unrelated edits by other people or agents intact.
|
||||
|
||||
Full ethos: `docs/AGENT_ETHOS.md`. Issue triage standard:
|
||||
`docs/AGENT_READY_ISSUES.md`. Release queue and harvest procedure:
|
||||
`docs/RELEASE_QUEUE.md`.
|
||||
Full ethos: `docs/AGENT_ETHOS.md`. Issue-triage standard, release queue, and
|
||||
harvest procedure live in the private `codewhale-ops` repo — they are
|
||||
maintainer process, not contributor-facing contract.
|
||||
|
||||
+419
-4
@@ -7,7 +7,292 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.9.4] - 2026-08-05
|
||||
## [0.9.6] - 2026-08-11
|
||||
|
||||
Codewhale v0.9.6 is a subtractive release: fewer runtime guards, one stable
|
||||
prompt, truthful provider endings, and a smaller compaction path that preserves
|
||||
the provider cache. The changes were grounded by matched Terminal-Bench 2.1
|
||||
runs against Pi 0.8.41 and by dogfooding repeated manual compaction.
|
||||
|
||||
### Added
|
||||
|
||||
- `web_search` defaults to Firecrawl Cloud without an API key; keyless requests
|
||||
are headerless and quota-bounded, while an optional user key raises limits.
|
||||
- Green web builds on `main` now emit an actionable manual-deploy reminder, so
|
||||
site changes cannot quietly appear shipped while Cloudflare still serves an
|
||||
older revision.
|
||||
- Mistral AI is a first-class provider route, including Codestral models,
|
||||
first-party reasoning support, authentication, picker entries, and aliases.
|
||||
- Headless `Bash` can transfer explicitly requested persistent Unix services
|
||||
out of an exec run, with ownership and cleanup receipts.
|
||||
- `/remote-env` opens hosted Work from the current GitHub or CNB branch tip and
|
||||
states exactly which unpushed, dirty, ignored, secret, and session state stays
|
||||
local.
|
||||
- Linux ARM64 release and nightly assets are static musl builds with native
|
||||
launch checks.
|
||||
- Maintainers can report observed daily active installs from the same anonymous,
|
||||
aggregate telemetry dataset; no additional client data is collected.
|
||||
- Fleet-dispatched members under a read-only evidence (no-network) ceiling now
|
||||
keep the `Web` tool's read-only `search` and `fetch` actions — parity with an
|
||||
ordinary scout — while every reaching surface (`web.run`, `fetch_url`,
|
||||
`github`, MCP) stays denied and the sentinel-backed capability envelope
|
||||
remains the fail-closed backstop.
|
||||
- `/fleet setup` can show an optional, deterministic, unratified role-to-model
|
||||
advisory built only from configured ready routes. Accept, edit, and reject
|
||||
all remain inside the existing human-reviewed profile save boundary; the
|
||||
advisory never launches a Fleet or writes a second configuration.
|
||||
- `/update` checks for a newer Codewhale release and installs it from inside
|
||||
the TUI, while `tui_help` gives agents the same command and key map users see.
|
||||
- Markdown file paths render as OSC 8 links where the terminal supports them,
|
||||
and every agent row can open that agent's transcript directly.
|
||||
- ACP editor sessions can execute multi-round file, search, Git, patch, and
|
||||
explicitly enabled shell tool calls through the shared Runtime registry.
|
||||
Shell access requires both the client's terminal capability and Codewhale's
|
||||
headless shell opt-in, and cancellation stops an in-flight tool before the
|
||||
turn returns (#5225 by @rafaelcavalheri).
|
||||
- Lowercase `read` returns bounded typed PNG, JPEG, GIF, and WebP results to
|
||||
image-capable Chat, Responses, Anthropic, and ACP routes. Text-only routes
|
||||
receive an explicit omission receipt; image bytes never spill into ordinary
|
||||
transcript, export, compaction, or relay text.
|
||||
|
||||
### Changed
|
||||
|
||||
- Anonymous usage counting is on by default for fresh installs and disclosed in
|
||||
a native first-run Codewhale modal with an immediate opt-out. Prior declines
|
||||
remain off. Codewhale does not collect conversations, code, prompts, files,
|
||||
repo or branch names, credentials, model content, or per-turn activity
|
||||
timelines.
|
||||
- Wide terminals use a responsive, full-screen ocean canvas with modest
|
||||
gutters: prose keeps a readable measure while tools, diffs, work surfaces,
|
||||
the composer, and status chrome can use the available width. Turn and major
|
||||
activity seams breathe without padding every call inside a tool group.
|
||||
- Root CLI help describes product actions directly instead of exposing internal
|
||||
TUI/runtime layers.
|
||||
- `Bash action="wait"` now blocks by default when a wait is requested; callers
|
||||
can still ask for a nonblocking snapshot, and persistent service ownership
|
||||
remains explicit.
|
||||
- Compaction is one cache-stable summary request followed by one committed
|
||||
replacement summary and a bounded recent-message tail. Older saved sessions
|
||||
still restore.
|
||||
- Ask, Work, Auto-Review, and Full Access share one stable base prompt. Modes
|
||||
continue to differ through permissions and the live tool catalog; the former
|
||||
Act label is now Work throughout the product and shipped locales.
|
||||
- Full Access now auto-approves non-bypassable tools consistently, and the
|
||||
default choice shown on ordinary approval cards is configurable.
|
||||
- Model, context-window, dispatch-name, and nested-agent spawn receipts report
|
||||
the route and limits actually used rather than silently substituting a
|
||||
guessed identity.
|
||||
- Child-agent launches mint one immutable route receipt before admission and
|
||||
preserve it through status, interruption, completion, resume, Work Graph,
|
||||
and ledger projections, so provider/model attribution cannot drift (#5305).
|
||||
- Goal runs no longer stop because of internal continuation, repeated-gap, or
|
||||
unanswered-question guards. Explicit user limits and terminal goal states
|
||||
remain authoritative.
|
||||
- Account-owned `/rc` remote control now keeps exclusive ownership and a
|
||||
crash-recoverable delivery journal until the server acknowledges terminal,
|
||||
approval, failure, and snapshot state.
|
||||
- `todo_write` is an optional progress surface rather than required model
|
||||
ceremony.
|
||||
- New turns use one small, stable toolbox: `read`, `write`, `edit`, `bash`,
|
||||
`agent`, `todo_write`, and `tool_search`. The optional progress tool stays
|
||||
visible as familiar working memory; specialized native, Web, MCP, plugin,
|
||||
memory, task, and verification tools are policy-filtered and searchable;
|
||||
activated schemas stay in a bounded per-conversation cache. Every sub-agent
|
||||
keeps its own search and cache, including policy-allowed Web research, while
|
||||
forked context and parent activations remain warm starts rather than allowlists.
|
||||
- The direct file and shell schemas follow Pi's deliberately small contract:
|
||||
bounded complete-line reads, hash-free writes, unambiguous multi-edit with
|
||||
BOM/CRLF preservation and conservative fuzzy matching, and one foreground
|
||||
`bash` command with a bounded chronological output tail. Modes change
|
||||
execution authority, not those primitive names.
|
||||
- Codewhale no longer re-states the To-do list to the model. The model learns
|
||||
what is on the list from the tool result its own `todo_write` call returned,
|
||||
which is ordinary conversation history — the same way Pi's To-do works. The
|
||||
transient `<codewhale:work_state>` block that used to ride the tail of every
|
||||
parent turn-loop and sub-agent step request is gone, along with the stable
|
||||
system prefix being disturbed by list changes. A snapshot is still shown once,
|
||||
where a person asked for it: the `<codewhale:fork_state>` block a newly forked
|
||||
sub-agent is handed, `/relay` handoff instructions, and the agent card. The
|
||||
complete To-do stays visible in the UI. A structural test asserts real
|
||||
outbound provider request bodies do not carry the list.
|
||||
- Scout and Reviewer name the read-only investigator roles. Both expose exactly
|
||||
one shell entry point — canonical lowercase `bash`, bounded by the strict
|
||||
read-only classifier — and the legacy `Bash` alias stays denied in the catalog
|
||||
and at dispatch. Previously a case-insensitive name match let a call spelled
|
||||
`Bash` execute through that carve-out, returning raw shell to a read-only role.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Sending more context while a lowercase `bash` command is running now moves
|
||||
the command to `/jobs` and returns a successful running receipt instead of
|
||||
falsely reporting `Command exited with code -1`; the process keeps running
|
||||
and its completion still arrives through the normal runtime event.
|
||||
|
||||
- First-run usage disclosure now opens as a native Codewhale modal instead of a
|
||||
shell questionnaire before application startup. Telemetry remains unarmed
|
||||
until the native choice is made, and an in-memory Disable choice governs the
|
||||
current session even when its preference cannot be saved.
|
||||
- `/compact` completion, failure, queued, duplicate, and mailbox outcomes are
|
||||
durable transcript receipts instead of short-lived toasts. A stray terminal
|
||||
event can no longer leave every later compaction stuck as already running.
|
||||
- Compaction now follows Codex's simple transcript shape: recent user context
|
||||
followed by one ordinary history checkpoint. It never appends the summary,
|
||||
the To-do list, or volatile shell/worker state to the standing system prompt;
|
||||
reloads migrate the persisted carrier back into exactly one history item.
|
||||
- Automatic compaction uses a percentage of the real context window, clamped to
|
||||
the route's spendable ceiling. Pressure comes from the current parent-route
|
||||
prompt, not cumulative billing or child-model usage.
|
||||
- Compaction, review, verify, routing, setup, Fleet, MCP, RLM, vision,
|
||||
translation, and sub-agent calls inherit the resolved route's normal output,
|
||||
sampling, and reasoning policy. Small internal-task token caps no longer
|
||||
truncate thinking routes or special-case individual providers.
|
||||
- Incomplete provider responses fail truthfully across ordinary turns and every
|
||||
internal model consumer. Partial text stays interrupted, pending tool calls do
|
||||
not execute, and billed usage is retained.
|
||||
- Transport-only `(reasoning omitted)` placeholders no longer enter new
|
||||
transcripts and are filtered from restored sessions. Reasoning expand/collapse
|
||||
actions stay attached to the exact rendered cell, including after replacement,
|
||||
restore, filtering, and resize (#5291).
|
||||
- Step-budget exhaustion is a typed failure and cannot release a pending
|
||||
persistent service. Cancellation after terminal usage still charges the turn.
|
||||
- Deferred tools now preserve a completed result when a provider reuses its
|
||||
tool-call ID on the retry turn, preventing successful plugin calls from
|
||||
entering a repeated execution loop.
|
||||
- Website setup, provider, diagnostics, Fleet, and single-runtime claims now
|
||||
match the source candidate.
|
||||
- Opening the sub-agent register no longer hides the to-do list: the Agents
|
||||
panel shows the full register and the durable checklist together, and the
|
||||
register header is a two-way door that returns to Tasks on a second click.
|
||||
- The ⌥V / Alt+V details chord opens the selected work-surface row's own
|
||||
inspector instead of the transcript's nearest tool cell, so a selected
|
||||
to-do row shows its own content rather than the latest reasoning.
|
||||
- The first-run usage disclosure now asks a clear question — "Help improve
|
||||
Codewhale?" — with unambiguous "Yes, keep anonymous counts" / "No, turn off
|
||||
tracking" choices in every shipped locale, and states the persistent opt-out
|
||||
command. Consent semantics are unchanged: telemetry stays unarmed until a
|
||||
choice is made.
|
||||
- macOS screencapture screenshots referenced in a message are copied to a
|
||||
stable attachments directory the moment the message is received, and the
|
||||
reference is rewritten to the stable path, so the image still exists when
|
||||
the agent reads it. Only files under a screencapture "Temporary Items"
|
||||
directory are touched; copies are idempotent and a failed copy keeps the
|
||||
original reference.
|
||||
- Manual `/compact` during an active turn now queues even when the engine's
|
||||
bounded op mailbox is saturated. The request defers client-side, retries as
|
||||
mailbox slots free, and cannot latch as already running after it settles.
|
||||
- Interactive `/load`, startup `--resume`, and `/resume` picker paths preserve
|
||||
the persisted provider, endpoint, and model identity; picker resume also
|
||||
leaves a durable transcript receipt.
|
||||
- Relative `mcp_config_path` values no longer depend on the launch directory or
|
||||
silently load an empty server pool: Codewhale warns and falls back to the
|
||||
user-global MCP configuration. Explicit absolute paths remain authoritative.
|
||||
- Alibaba Model Studio `qwen3.8-max` and `qwen3.8-max-preview` still stream
|
||||
their current reasoning, but no longer replay historical `reasoning_content`
|
||||
that those routes do not accept. Historical reasoning replay is now gated by
|
||||
the exact provider/API/model contract, so unknown `*-thinking` lookalikes
|
||||
fail closed while documented Qwen, Kimi, DeepSeek, Mistral, Anthropic, and
|
||||
Responses continuity rules remain intact.
|
||||
- Compatibility File/patch calls retain optional content-hash guards when a
|
||||
caller supplies them. The new direct `write` and `edit` schemas do not expose
|
||||
hash or prior-read ceremony.
|
||||
- Shell previews hold back incomplete UTF-8 sequences instead of emitting
|
||||
replacement characters, and compaction receipts report token deltas.
|
||||
- Nested agents may narrow but can never widen their inherited depth budget
|
||||
(#5317 by @ousamabenyounes).
|
||||
- Container publication now assembles AMD64 and ARM64 images in parallel on
|
||||
native runners from the already-verified static release binaries, then
|
||||
publishes and checks one multi-architecture manifest. It no longer rebuilds
|
||||
both targets through the single long-running QEMU job that lost its runner.
|
||||
|
||||
### Removed
|
||||
|
||||
- The no-progress guard, repeated-read guard, and injected tool-error strategy
|
||||
coaching. Productive polling, repeated inspection, and model-owned recovery
|
||||
are no longer interrupted by runtime heuristics.
|
||||
- Never-wired decision-card, keybinding, hover, shell-execution, engine-op, and
|
||||
release-script paths were deleted so the supported runtime has one route for
|
||||
each behavior.
|
||||
|
||||
### Contributors
|
||||
|
||||
- Xavier Pestel (@xavierpestel-ai) — Mistral AI provider route (#5295).
|
||||
- Ben Younes (@ousamabenyounes) — inherited nested-agent depth cap (#5317).
|
||||
- Rafael Cavalheri (@rafaelcavalheri) — ACP agentic tool turns (#5225).
|
||||
|
||||
## [0.9.5] - 2026-08-08
|
||||
|
||||
Codewhale v0.9.5 consolidates the terminal application into one compiled
|
||||
runtime while preserving the familiar `codewhale` and `codew` commands. It
|
||||
also expands the managed Runtime API, makes session and Fleet work easier to
|
||||
inspect and resume, and removes the hidden local continuation backstop that
|
||||
could end productive work without a final assistant response.
|
||||
|
||||
### Added
|
||||
|
||||
- **`model = "auto"` for prompt-based tier selection**: When set, the
|
||||
dispatcher analyses the user's prompt before delegating to the TUI and
|
||||
selects `deepseek-v4-pro` for complex tasks or `deepseek-v4-flash` for simple
|
||||
tasks (PR #5257).
|
||||
- Runtime API controls for persistent goals, bounded memory inspection, MCP
|
||||
server and skill lifecycle management, and durable Fleet receipt evidence.
|
||||
- Append-only session-tree history with `/tree`, `/branch`, `/fork`, and
|
||||
`/resume`, plus `/rc` remote control and managed login.
|
||||
- A unified Fleet roster for built-in dispatch postures and a pinned indicator
|
||||
that keeps active background work visible above the composer.
|
||||
- Incremental MCP registry refreshes that return the local snapshot immediately
|
||||
and update it in the background.
|
||||
- Scout and Reviewer agents can use a bounded direct-command evidence shell for
|
||||
read-only workspace, Git, and GitHub inspection, and can keep private working
|
||||
notes in their own To-do while the durable transcript retains their evidence.
|
||||
|
||||
### Changed
|
||||
|
||||
- `codewhale-cli` now contains the terminal runtime directly. Release installers
|
||||
expose byte-identical `codewhale` and `codew` commands without a separate TUI
|
||||
executable. The v0.9.5 asset set alone retains deprecated
|
||||
`codewhale-tui-*` filenames as byte-identical compatibility copies so
|
||||
installed v0.9.4 clients can discover and complete this upgrade.
|
||||
- Startup release checks cache successful lookups for one hour. The updater
|
||||
downloads and verifies the primary runtime once, then refreshes any existing
|
||||
`codew` or legacy `codewhale-tui` command paths from the same bytes.
|
||||
- Headless `codewhale exec` runs and verifier benchmark rollouts no longer
|
||||
impose a 100-step default. `--max-turns` remains available as an explicit
|
||||
opt-in ceiling; Fleet workers retain their separately configured budget.
|
||||
- Goal token and time budgets are telemetry rather than default stop
|
||||
conditions, and automatic goal continuation is unlimited unless the user
|
||||
explicitly configures a continuation ceiling.
|
||||
- Command-palette and slash-completion shadowing now share one alias-aware
|
||||
discovery contract.
|
||||
- The website install guidance, localized product copy, navigation controls,
|
||||
social metadata, and Cloudflare build pipeline now describe and deploy the
|
||||
same one-runtime release contract.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The hidden 20-step no-user-input backstop no longer ends productive turns.
|
||||
Tool results, queued steering, child completions, REPL feedback, and goal
|
||||
continuations can all reach the next provider step and a final assistant
|
||||
response; explicit user-configured limits and genuine stuck-loop guards remain.
|
||||
- Complete error details are directly inspectable after a failure instead of
|
||||
leaving the terminal with a clipped, unrecoverable error fragment.
|
||||
- A newly minted OAuth credential is adopted in the same provider-selection
|
||||
flow instead of requiring a second picker trip.
|
||||
- Fresh session titles can replace a stale cached `New Session` placeholder,
|
||||
unknown model context limits fail loudly, and release/source-install fallbacks
|
||||
no longer request binaries removed by the single-runtime conversion.
|
||||
|
||||
### Contributors
|
||||
|
||||
- [Sh1Zuku](https://github.com/SparkofSpike) (`@SparkofSpike`) fixed stale
|
||||
cached session titles that could pin the `New Session` placeholder.
|
||||
- [Paulo Aboim Pinto](https://github.com/aboimpinto) (`@aboimpinto`) built the
|
||||
shared alias-aware command discovery contract and acceptance coverage.
|
||||
- [Sun Zhenyuan](https://github.com/bistack) (`@bistack`) contributed the
|
||||
background incremental MCP Registry refresh.
|
||||
- [SKY ZHAO](https://github.com/skyzhao1223) (`@skyzhao1223`) contributed
|
||||
prompt-based `model = "auto"` routing in PR #5257.
|
||||
|
||||
## [0.9.4] - 2026-08-07
|
||||
Codewhale v0.9.4 ships the release-train harness work: the familiar Fleet
|
||||
roster/setup face with a clear operator-leader and user/folder scope, a
|
||||
work strip that keeps actionable agents instead of a permanent archive,
|
||||
@@ -19,6 +304,23 @@ File edits, terminal width, and Windows installation.
|
||||
|
||||
### Added
|
||||
|
||||
- Memory maintenance: `remember` gains `revise` and `retire` beside the
|
||||
default `append`. Both name the exact note they target and both require
|
||||
the evidence for the change. Append-only memory decays — a correction
|
||||
sits behind the note it contradicts and both keep reaching the model —
|
||||
so the model can now keep its own durable notes true instead of only
|
||||
adding to them.
|
||||
- An audit trail for durable state the model writes about you. Every
|
||||
in-place memory edit is journalled to `memory/JOURNAL.md`, and every
|
||||
continual-harness `refine` / `remove` to a `JOURNAL.md` beside its state,
|
||||
each with before, after, and evidence. Harness removal previously left no
|
||||
record at all even though the entry leaves state entirely, so the journal
|
||||
is now the only place its content survives.
|
||||
- A first-run tip that says so: the first time Codewhale saves something
|
||||
durable it points at `/memory`, translated into all fifteen complete
|
||||
locale packs. This state shaped later sessions and nothing ever mentioned
|
||||
it existed.
|
||||
|
||||
- Sub-agent checkpoint resume: `agents/followup` resumes an
|
||||
`interrupted_continuable` child from its checkpoint into a fresh agent loop —
|
||||
new agent id, original prompt plus the prior conversation tail — when a
|
||||
@@ -125,6 +427,39 @@ File edits, terminal width, and Windows installation.
|
||||
- Acceptance-level Gherkin coverage locking the existing user-command
|
||||
precedence, alias shadowing, fallback, and invalid-command error contract
|
||||
(PR #4992).
|
||||
- Agent Plugins v1.0.0: consume, publish, and slugify packaged sub-agent
|
||||
briefs, with an install/update/uninstall on-ramp in the TUI (PR #5182). A
|
||||
plugin bundles a prompt, posture, and routing as one shareable artifact;
|
||||
on-disk migration of the older `plugin.toml` scaffold is deliberately out
|
||||
of scope for this train.
|
||||
- `send_later`: a model-callable one-shot delayed continuation tool, so the
|
||||
model can schedule a single future nudge without an operator-approved
|
||||
durable automation (PR #5138).
|
||||
- `/advisor`: an opt-in background advisor watcher for live turns (PR #5139).
|
||||
- Notification quiet mode with per-category switches and action-first copy
|
||||
(PR #5066).
|
||||
- Automation scheduling forms — one-shot `ONCE`, five-field cron, and honest
|
||||
watcher modes — created through the approval-gated `automation` tool
|
||||
(PR #5183).
|
||||
- Sub-agent `resume_from` continuation chains (PR #5142), child-result
|
||||
diff-tainting when a claimed diff is not visible to git, per-turn usage
|
||||
receipts on the exec stream-json stream, and spawn receipts that report
|
||||
the model each sub-agent actually ran on.
|
||||
- Transport resilience: sub-agent exec transport retries with a 600 s
|
||||
default (PR #5210), SSE header stalls retryable instead of fatal, and
|
||||
headless turn resume after mid-stream network drops with an `EX_TEMPFAIL`
|
||||
exit.
|
||||
- Session durability and control: a deterministic compaction continuation
|
||||
contract (PR #5064), persisting interrupted output (PR #5206), stop-word
|
||||
cancellation (PR #5207), token-counter refresh (PR #5204), deny-by-default
|
||||
approval cards (PR #5090), and the Operate completion gate (PR #5067).
|
||||
- zh-Hant promoted to a full shipped locale with complete `en.json` parity
|
||||
(PR #5143).
|
||||
- A persistent update-available chip in the header, with the startup update
|
||||
check throttled and naming the right command.
|
||||
- RLM static intent extraction for code blocks (`rlm_block_intent.rs`)
|
||||
landed as groundwork for a future code-mode approval flow; it is not yet
|
||||
wired into the turn pipeline and ships dormant by design.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -171,12 +506,38 @@ File edits, terminal width, and Windows installation.
|
||||
futures-util to 0.3.33, libc to 0.2.189, actions/stale to 11.0.0, and
|
||||
docker/login-action to 4.5.2. The locked graph also includes the
|
||||
event-listener 5.4.2 fix for RUSTSEC-2026-0221.
|
||||
- The progress surface now speaks plainly everywhere: the last user-visible
|
||||
"Work update is pending" notices say "To-do list", the tool constructor and
|
||||
the docs name `todo_write` as the single canonical progress tool, and
|
||||
`work_update`, `TodoWrite`, and `todo` stay registered as hidden
|
||||
compatibility aliases so saved transcripts keep replaying.
|
||||
- Sub-agent and `agents/wait` waits stay short by default and by cap:
|
||||
blocking waits default to 30 s and refuse to block past 120 s, because a
|
||||
blocked wait deafens the session to typed input and settled children
|
||||
already report back as `<codewhale:subagent.done>` sentinels.
|
||||
- `Bash` `action=wait` honors `timeout_secs` (seconds) and bare `timeout`
|
||||
(milliseconds) alongside canonical `timeout_ms`, and `block` as an alias
|
||||
for `wait`, so a habit formed on other wait tools gets the duration it
|
||||
asked for instead of silently falling back to the 30 s default; the result
|
||||
metadata reports the real `wait_timeout_ms` applied.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The memory journal is no longer indexed as memory. It is Markdown in the
|
||||
memory tree, so the source walk collected it and every retired note
|
||||
re-entered the searchable set under its `before:` line — putting the
|
||||
exact facts a revision had just removed back into the prompt.
|
||||
- `memory_path` pointed at an already-native store no longer derives a
|
||||
second store nested inside it, which silently wrote somewhere other than
|
||||
the file the user named.
|
||||
- `muse` and `muse-spark` resolved to `muse-spark-1.1` in the agent
|
||||
registry while config had defaulted to `muse-spark-1.2`, so the CLI and
|
||||
app-server routed those aliases somewhere the configured default never
|
||||
pointed. The registry now carries 1.2 and the contributor variant.
|
||||
|
||||
- An explicit `type=builder` (or its `implementer` alias) plus
|
||||
`write_authority=read_only` now fails closed at spawn instead of launching a
|
||||
labeled write role that silently had only recon tools and then self-BLOCKED
|
||||
labeled write role that silently had only read-only tools and then self-BLOCKED
|
||||
after burning a turn (#5123). The check is deliberately narrow, because two
|
||||
neighbouring combinations are legitimate and stay legal:
|
||||
- `type=worker` + `read_only` — worker is the unnamed default (it renders as
|
||||
@@ -196,7 +557,7 @@ File edits, terminal width, and Windows installation.
|
||||
total the worker budget uses) instead of completion tokens alone; elapsed
|
||||
time still freezes when the child settles.
|
||||
- Live work-bar rows for sub-agents show how many to-dos they still have
|
||||
left (`N left`) when the child's own ledger has unsettled items — never a
|
||||
left (`N left`) when the child's own list has unsettled items — never a
|
||||
fabricated zero when no list exists.
|
||||
|
||||
- Surfaces no longer claim an OS sandbox on platforms that cannot enforce one.
|
||||
@@ -335,6 +696,56 @@ File edits, terminal width, and Windows installation.
|
||||
- Transcript wheel scrolling under iTerm2: xterm alternate-scroll (DECSET
|
||||
1007) now stays off while mouse capture is active, so wheel events arrive as
|
||||
mouse events instead of being converted into arrow keys (#5223, PR #5234).
|
||||
- A stalled model stream no longer ends the turn as `Completed` over a
|
||||
frozen reasoning block: a mid-stream chunk-timeout now counts toward the
|
||||
stream-error budget, so a stall with nothing streamed retries the request
|
||||
transparently, and a stall that exhausts the retry budget fails the turn
|
||||
with the real reason instead of reporting success.
|
||||
- A finished background shell task now wakes the engine even when no goal is
|
||||
active: the idle loop starts an ordinary runtime turn so the completion
|
||||
reaches the model immediately instead of sitting unclaimed until the user
|
||||
types (a dead provider route claims the completion once and reports where
|
||||
the output lives instead of re-arming the same error every tick).
|
||||
- Sub-agent final reports that exceed the summary budget are now spilled to
|
||||
a session artifact, and the truncation footer names the
|
||||
`retrieve_tool_result` ref for the elided middle instead of telling the
|
||||
model the bytes are unrecoverable; write failures degrade to the honest
|
||||
no-ref footer.
|
||||
- An interactive mid-stream network drop after partial output no longer fails
|
||||
the turn: the partial reply is preserved as a committed assistant message,
|
||||
a runtime continuation message is appended, and the request is re-issued
|
||||
bounded by the stream-retry budget.
|
||||
- Large pasted input is no longer sent to the model twice as inline text and
|
||||
as a backup `.md` paste file; the submitted message now carries only the
|
||||
`@`-mention so the model reads the file once.
|
||||
- A builder sub-agent can run ordinary shell writes again. Write claims
|
||||
outlive the agents that register them, so a workspace accumulated one per
|
||||
builder that ever ran — six completed agents left four standing claims in
|
||||
testing — and the shared-checkout gate counted those long-finished children
|
||||
as live contenders. Every later builder was refused `Bash` writes with
|
||||
"cannot prove a bounded file target" and pushed toward worktree isolation,
|
||||
which puts the work in a checkout the operator never looks at. The gate now
|
||||
asks the question it meant to ask: is another *running* child writing in this
|
||||
shared checkout. Concurrent writers are still gated; a lone builder writes in
|
||||
the workspace you are actually watching.
|
||||
- Ctrl-C during the first moments of startup no longer kills Codewhale
|
||||
outright. The terminating-signal handlers were registered inside the task
|
||||
that waits on them, and a spawned task does not run until the scheduler
|
||||
first polls it, so a SIGINT arriving in that window hit the default
|
||||
disposition — the process died with no exit code, no terminal restore, and
|
||||
no session record. The handlers are now installed synchronously, before
|
||||
the telemetry notice and before arming, so the window is closed.
|
||||
- The documented tool list on the docs site named `update_plan` and
|
||||
`work_update` as coordination tools. Neither is callable by the model —
|
||||
`update_plan` replays older Plan artifacts and `work_update` is a hidden
|
||||
compatibility alias — so the page listed two tools a reader cannot use and
|
||||
omitted `todo_write`, the one they can.
|
||||
|
||||
### Security
|
||||
|
||||
- Bumped `nanoid` past GHSA-2v37-7h3g-55p8 (a custom generator given size
|
||||
zero could loop indefinitely), restoring a zero-advisory `npm audit` for
|
||||
the website.
|
||||
|
||||
### Removed
|
||||
|
||||
@@ -366,6 +777,8 @@ File edits, terminal width, and Windows installation.
|
||||
- [vFONGv](https://github.com/vFONGv) (`@vFONGv`) wrote the zh-CN Windows
|
||||
beginner guide with screenshots in PR #5229, harvested after its base branch
|
||||
was accidentally deleted during maintainer cleanup.
|
||||
- [mky](https://github.com/mky) (`@mky`) fixed the FreeBSD build (PR #5254, `rquickjs` `bindgen` on FreeBSD).
|
||||
- [cacdcaecawae](https://github.com/cacdcaecawae) (`@cacdcaecawae`) contributed embedder-owned sub-agent state roots (PR #5252).
|
||||
|
||||
## [0.9.3] - 2026-07-31
|
||||
|
||||
@@ -5032,7 +5445,9 @@ overflow report and `/theme` picker edge-wrapping patch in #1814.
|
||||
|
||||
Older releases (v0.8.39 and earlier) are archived in [docs/CHANGELOG_ARCHIVE.md](docs/CHANGELOG_ARCHIVE.md).
|
||||
|
||||
[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.9.4...HEAD
|
||||
[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.9.6...HEAD
|
||||
[0.9.6]: https://github.com/Hmbown/CodeWhale/compare/v0.9.5...v0.9.6
|
||||
[0.9.5]: https://github.com/Hmbown/CodeWhale/compare/v0.9.4...v0.9.5
|
||||
[0.9.4]: https://github.com/Hmbown/CodeWhale/compare/v0.9.3...v0.9.4
|
||||
[0.9.3]: https://github.com/Hmbown/CodeWhale/compare/v0.9.2...v0.9.3
|
||||
[0.9.2]: https://github.com/Hmbown/CodeWhale/compare/v0.9.1...v0.9.2
|
||||
|
||||
+1
-1
@@ -328,7 +328,7 @@ reopened, ask the contributor to resubmit after the allowlist PR is merged.
|
||||
|
||||
Codewhale is allowed to help improve Codewhale, but the contribution still has
|
||||
to be shaped for human review. The recommended workflow is the
|
||||
[recursive self-improvement prompt](docs/RECURSIVE_SELF_IMPROVEMENT.md): run it
|
||||
[recursive self-improvement prompt](the `codewhale-ops` repo): run it
|
||||
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 reference path for this loop
|
||||
today, but any configured provider works — the review shape matters more than
|
||||
|
||||
Generated
+626
-1034
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -21,11 +21,11 @@ members = [
|
||||
"crates/workflow",
|
||||
"crates/workflow-js",
|
||||
]
|
||||
default-members = ["crates/cli", "crates/app-server", "crates/tui"]
|
||||
default-members = ["crates/cli"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.4"
|
||||
version = "0.9.6"
|
||||
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
|
||||
@@ -44,7 +44,7 @@ clap = { version = "4.5.54", features = ["derive"] }
|
||||
clap_complete = "4.5"
|
||||
dirs = "6.0.0"
|
||||
encoding_rs = "0.8.35"
|
||||
jsonschema = { version = "0.48", default-features = false }
|
||||
jsonschema = { version = "0.46", default-features = false }
|
||||
reqwest = { version = "0.13.1", default-features = false, features = ["json", "rustls-no-provider", "socks"] }
|
||||
# NOT "parallel": the Workflow VM stays single-threaded and bridges to the
|
||||
# multi-thread engine over channels (see crates/workflow-js).
|
||||
@@ -60,10 +60,11 @@ tokio = { version = "1.50.0", features = ["fs", "io-util", "io-std", "macros", "
|
||||
toml = "1.0.6"
|
||||
toml_edit = "0.25.12"
|
||||
sha2 = "0.11"
|
||||
tower-http = { version = "0.7", features = ["cors"] }
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
tracing = "0.1"
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
tokio-util = { version = "0.7.16", features = ["io", "full"] }
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
mimalloc = { version = "0.1", default-features = false }
|
||||
|
||||
|
||||
+7
-9
@@ -5,7 +5,7 @@
|
||||
# Run: docker run --rm -it -e DEEPSEEK_API_KEY -v codewhale-home:/home/codewhale/.codewhale codewhale
|
||||
#
|
||||
# The image ships the canonical binaries (`codewhale`, `codew`, and
|
||||
# `codewhale-tui`) in a minimal runtime layer.
|
||||
# `codewhale`) in a minimal runtime layer.
|
||||
#
|
||||
# API keys MUST be passed at runtime (never baked into the image):
|
||||
# docker run --rm -it -e DEEPSEEK_API_KEY codewhale
|
||||
@@ -53,18 +53,18 @@ RUN rustup target add "$(cat /rust-target)"
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
|
||||
# Build both binaries for the target platform. --locked ensures
|
||||
# reproducible builds from the committed lockfile.
|
||||
# Build the one runtime for the target platform. Expose the same verified
|
||||
# bytes under both supported command names. --locked keeps the build
|
||||
# reproducible from the committed lockfile.
|
||||
RUN --mount=type=cache,id=codewhale-target-${TARGETARCH},target=/build/target,sharing=locked \
|
||||
--mount=type=cache,id=codewhale-cargo-registry-${TARGETARCH},target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=codewhale-cargo-git-${TARGETARCH},target=/usr/local/cargo/git,sharing=locked \
|
||||
rustup target add "$(cat /rust-target)" \
|
||||
&& cargo build --release --locked --target "$(cat /rust-target)" \
|
||||
-p codewhale-cli -p codewhale-tui \
|
||||
-p codewhale-cli \
|
||||
&& mkdir -p /out \
|
||||
&& cp target/$(cat /rust-target)/release/codewhale /out/ \
|
||||
&& cp target/$(cat /rust-target)/release/codew /out/ \
|
||||
&& cp target/$(cat /rust-target)/release/codewhale-tui /out/
|
||||
&& cp target/$(cat /rust-target)/release/codewhale /out/codew
|
||||
|
||||
# ── Stage 2: Runtime ──────────────────────────────────────────────────
|
||||
FROM debian:bookworm-slim
|
||||
@@ -86,10 +86,8 @@ WORKDIR /home/codewhale
|
||||
|
||||
COPY --from=builder --chown=codewhale:codewhale /out/codewhale /usr/local/bin/codewhale
|
||||
COPY --from=builder --chown=codewhale:codewhale /out/codew /usr/local/bin/codew
|
||||
COPY --from=builder --chown=codewhale:codewhale /out/codewhale-tui /usr/local/bin/codewhale-tui
|
||||
|
||||
# The dispatcher expects to find its companion binary next to it.
|
||||
# Both are in /usr/local/bin — no further path setup needed.
|
||||
# `codewhale` and `codew` are two command names for the same runtime.
|
||||
|
||||
ENTRYPOINT ["codewhale"]
|
||||
CMD []
|
||||
|
||||
+15
-2
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:a87c9f323f35 -->
|
||||
<!-- source: README.md sha256:4fb18fffb0fe -->
|
||||
# Codewhale
|
||||
|
||||
Un agente de programación de código abierto para tu terminal — trae tu propio modelo.
|
||||
@@ -15,6 +15,13 @@ queda lista o te necesita. Cambia de modelo a mitad de tarea con `/model`.
|
||||
Trabaja de forma interactiva en la TUI, o ejecuta `codewhale exec` en scripts y
|
||||
CI. Está escrito en Rust, con licencia MIT, y corre en tu máquina.
|
||||
|
||||
Lo que no se parece a otros harnesses: **tú eliges el modelo de cada rol, y no
|
||||
tienen por qué coincidir.** Una fleet fija un proveedor, un modelo y un nivel de
|
||||
razonamiento por rol — así un modelo barato y rápido puede dirigir a uno de
|
||||
razonamiento caro, o un builder GLM puede trabajar en la misma tarea que un
|
||||
reviewer Kimi. Escribe tus propios roles y tu propia constitution, y el harness
|
||||
es tuyo en lugar de nuestro.
|
||||
|
||||
Siempre estamos buscando personas que contribuyan y formas de mejorar. Si falta
|
||||
un modelo o proveedor que usas, o algo se rompe, contárnoslo es una de las cosas
|
||||
más útiles que puedes hacer — mira [Contribuir](#contribuir).
|
||||
@@ -52,7 +59,7 @@ codewhale web # local browser client on 127.0.0.1
|
||||
En la TUI: `/model` cambia proveedor y modelo juntos, `/fleet` ejecuta un
|
||||
equipo de workers, `/undo` deshace el último turno y `/restore <N>` revierte el
|
||||
workspace a una instantánea anterior (`/restore` sin argumentos solo las
|
||||
lista). Cuando el compositor está vacío, `Tab` cicla entre Plan / Act /
|
||||
lista). Cuando el compositor está vacío, `Tab` cicla entre Plan / Work /
|
||||
Operate; con texto escrito, `Tab` completa comandos slash y menciones `@`.
|
||||
`Shift+Tab` cicla la postura de permiso Ask / Auto-Review / Full Access en
|
||||
cualquier momento. `!` ejecuta un comando de shell por la ruta normal de
|
||||
@@ -65,6 +72,12 @@ aprobación.
|
||||
todo a través de un solo runtime y un solo conjunto de herramientas. Los
|
||||
presupuestos de contexto y los precios vienen de la ruta real, y un precio
|
||||
desconocido se muestra como desconocido en lugar de $0.
|
||||
- **Un harness que tú escribes.** Los roles son archivos que puedes leer y
|
||||
editar — un modelo, una postura de herramientas e instrucciones permanentes por
|
||||
rol — guardados en el proyecto para que el equipo los comparta, o junto a tus
|
||||
ajustes personales para que te acompañen entre repos. Una constitution registra
|
||||
cómo quieres que el agente se comporte en cada sesión, de modo que el harness se
|
||||
ajuste a tu práctica y no a la nuestra.
|
||||
- **Solo lectura hasta que permitas más.** El modo Plan no cambia archivos, y
|
||||
las aprobaciones controlan los comandos riesgosos. Cuando un sandbox del
|
||||
sistema operativo realmente envuelve un comando, Codewhale lo indica: Seatbelt
|
||||
|
||||
+5
-2
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:a87c9f323f35 -->
|
||||
<!-- source: README.md sha256:4fb18fffb0fe -->
|
||||
# Codewhale
|
||||
|
||||
Sebuah coding agent sumber terbuka untuk terminal Anda — bawa model pilihan Anda sendiri.
|
||||
@@ -7,6 +7,8 @@ Codewhale berawal sebagai pengalaman asli (native) untuk DeepSeek. Sejak saat it
|
||||
|
||||
Berikan penyedia, model, dan tugas: Codewhale akan membaca kode Anda, mengedit berkas, menjalankan perintah, serta memeriksa hasil kerjanya sendiri, lalu berhenti setelah pekerjaan selesai atau ketika membutuhkan arahan Anda. Ganti model di tengah tugas dengan `/model`. Bekerja secara interaktif di TUI, atau jalankan `codewhale exec` dalam skrip dan CI. Dibuat menggunakan Rust, berlisensi MIT, dan berjalan langsung di mesin Anda sendiri.
|
||||
|
||||
Yang membedakannya dari harness lain: **Anda memilih model untuk setiap peran, dan model-model itu tidak harus sama.** Sebuah fleet menyematkan penyedia, model, dan tingkat penalaran per peran — sehingga model yang murah dan cepat bisa mengarahkan model penalaran yang mahal, atau seorang builder GLM bisa mengerjakan tugas yang sama dengan seorang reviewer Kimi. Tulis peran Anda sendiri, constitution Anda sendiri, dan harness itu menjadi milik Anda, bukan milik kami.
|
||||
|
||||
Kami selalu membuka kesempatan bagi para kontributor dan cara untuk terus berkembang. Jika model atau penyedia yang Anda gunakan belum tersedia, atau ada hal yang tidak berjalan semestinya, memberi tahu kami adalah salah satu kontribusi paling berharga yang bisa Anda lakukan — lihat [Kontribusi](#kontribusi).
|
||||
|
||||
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [codewhale.net](https://codewhale.net/) · [Docs](docs) · [Changelog](CHANGELOG.md) · [Discord](https://discord.gg/37gfS3ksug)
|
||||
@@ -36,11 +38,12 @@ codewhale web # local browser client on 127.0.0.1
|
||||
```
|
||||
|
||||
|
||||
Di dalam TUI: `/model` mengganti penyedia dan model sekaligus, `/fleet` menjalankan tim pekerja (workers), `/undo` membatalkan langkah (turn) terakhir, dan `/restore <N>` mengembalikan workspace ke snapshot sebelumnya (`/restore` tanpa argumen hanya menampilkan daftarnya). Saat composer kosong, `Tab` beralih antar mode Plan / Act / Operate; bila composer berisi teks, `Tab` justru melengkapi perintah slash dan sebutan `@`. `Shift+Tab` beralih antar postur izin Ask / Auto-Review / Full Access kapan saja. `!` menjalankan perintah shell melalui alur persetujuan normal.
|
||||
Di dalam TUI: `/model` mengganti penyedia dan model sekaligus, `/fleet` menjalankan tim pekerja (workers), `/undo` membatalkan langkah (turn) terakhir, dan `/restore <N>` mengembalikan workspace ke snapshot sebelumnya (`/restore` tanpa argumen hanya menampilkan daftarnya). Saat composer kosong, `Tab` beralih antar mode Plan / Work / Operate; bila composer berisi teks, `Tab` justru melengkapi perintah slash dan sebutan `@`. `Shift+Tab` beralih antar postur izin Ask / Auto-Review / Full Access kapan saja. `!` menjalankan perintah shell melalui alur persetujuan normal.
|
||||
|
||||
## Fitur & Kapabilitas
|
||||
|
||||
- **Model mana saja, penyedia apa saja.** DeepSeek, Claude, GPT, Kimi, GLM, dan 30+ penyedia lainnya, ditambah vLLM, SGLang, atau Ollama milik Anda sendiri tanpa memerlukan API key — semuanya melalui satu runtime dan satu kumpulan alat. Batas konteks dan harga diambil dari rute sebenarnya, dan harga yang tidak diketahui ditampilkan sebagai *unknown* daripada $0.
|
||||
- **Harness yang Anda tulis sendiri.** Peran adalah berkas yang bisa Anda baca dan sunting — satu model, satu sikap perkakas, dan instruksi tetap untuk tiap peran — disimpan di dalam proyek agar tim berbagi, atau di samping pengaturan pribadi Anda agar ikut berpindah antar repo. Constitution mencatat bagaimana Anda ingin agen berperilaku di setiap sesi, sehingga harness mengikuti cara kerja Anda, bukan cara kami.
|
||||
- **Read-only sampai Anda memberi izin lebih.** Mode Plan tidak dapat mengubah berkas, dan gerbang persetujuan memproteksi perintah berisiko. Ketika sandbox OS membungkus perintah, Codewhale akan menginformasikannya: Seatbelt pada macOS (jika tersedia), serta opsi bubblewrap di Linux. Berkas `constitution.json` repositori dikompilasi menjadi pembatas penulisan yang bahkan tidak dapat dilewati oleh mode Full Access.
|
||||
- **Pekerjaan yang dapat dilanjutkan.** Fleet mencatat setiap langkah ke ledger bertipe append-only, sehingga `fleet resume` dapat melanjutkan pekerjaan tepat di mana Anda meninggalkannya.
|
||||
|
||||
|
||||
+6
-3
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:a87c9f323f35 -->
|
||||
<!-- source: README.md sha256:4fb18fffb0fe -->
|
||||
# Codewhale
|
||||
|
||||
ターミナルで動くオープンソースのコーディングエージェント — モデルはあなたが持ち込む。
|
||||
@@ -7,6 +7,8 @@ Codewhale は DeepSeek のためのネイティブ体験として始まりまし
|
||||
|
||||
プロバイダ、モデル、タスクを渡すと、コードを読み、ファイルを編集し、コマンドを実行し、自分の作業を確認して、タスクが完了するかあなたの手が必要になった時点で止まります。タスクの途中でも `/model` でモデルを切り替えられます。対話的な作業には TUI を、スクリプトと CI には `codewhale exec` を。Rust 製、MIT ライセンスで、あなたのマシン上で動きます。
|
||||
|
||||
他のハーネスと違うのはここです。**役割ごとにどのモデルを使うかはあなたが決められ、しかも揃える必要がありません。** Fleet は役割ごとにプロバイダ・モデル・推論ティアを個別に固定します。だから速くて安いモデルが高価な推論モデルを指揮することも、GLM の builder と Kimi の reviewer が同じ仕事に取り組むこともできます。自分の役割と自分の constitution を書けば、そのハーネスは私たちのものではなく、あなたのものになります。
|
||||
|
||||
私たちは常にコントリビューターと改善の方法を探しています。使っているモデルやプロバイダが見当たらないとき、あるいは何かが壊れたときは、それを知らせてもらえることが最も役に立つことのひとつです — [コントリビューション](#コントリビューション)を見てください。
|
||||
|
||||
[English](README.md) · [简体中文](README.zh-CN.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [codewhale.net](https://codewhale.net/) · [Docs](docs) · [Changelog](CHANGELOG.md) · [Discord](https://discord.gg/37gfS3ksug)
|
||||
@@ -36,11 +38,12 @@ codewhale web # local browser client on 127.0.0.1
|
||||
```
|
||||
|
||||
|
||||
TUI では、`/model` がプロバイダとモデルをまとめて切り替え、`/fleet` がワーカーのチームを走らせ、`/undo` が直前のターンを取り消し、`/restore <N>` がワークスペースを以前のスナップショットへ巻き戻します(引数なしの `/restore` は一覧を表示するだけです)。入力欄が空のとき、`Tab` は Plan / Act / Operate を順に切り替えます。入力欄に文字があるときの `Tab` はスラッシュコマンドと `@` メンションの補完になります。`Shift+Tab` はいつでも Ask / Auto-Review / Full Access の権限スタンスを順に切り替えます。`!` は Shell コマンドを通常の承認経路で実行します。
|
||||
TUI では、`/model` がプロバイダとモデルをまとめて切り替え、`/fleet` がチームを組み立てて走らせ(一度にひとつの役割、それぞれが自分のモデルを持ちます)、`/undo` が直前のターンを取り消し、`/restore <N>` がワークスペースを以前のスナップショットへ巻き戻します(引数なしの `/restore` は一覧を表示するだけです)。入力欄が空のとき、`Tab` は Plan / Work / Operate を順に切り替えます。入力欄に文字があるときの `Tab` はスラッシュコマンドと `@` メンションの補完になります。`Shift+Tab` はいつでも Ask / Auto-Review / Full Access の権限スタンスを順に切り替えます。`!` は Shell コマンドを通常の承認経路で実行します。
|
||||
|
||||
## できること
|
||||
|
||||
- **どのモデルでも、どのプロバイダでも。** DeepSeek、Claude、GPT、Kimi、GLM をはじめ 30 以上のプロバイダ、そしてキー不要のあなた自身の vLLM・SGLang・Ollama が、すべてひとつのランタイムとひとつのツール群を通って動きます。コンテキスト予算と価格は実際のルートに由来し、不明な価格は $0 ではなく不明と表示されます。
|
||||
- **どのモデルでも、どのプロバイダでも、そしてどんな組み合わせでも。** DeepSeek、Claude、GPT、Kimi、GLM をはじめ 30 以上のプロバイダ、そしてキー不要のあなた自身の vLLM・SGLang・Ollama が、すべてひとつのランタイムとひとつのツール群を通って動きます。保存された役割は `provider`・`model`・推論ティアを明示的に記録するので、ひとつの実行の中で Fleet が複数のベンダーにまたがることができ、役割のルートはそのとき有効なプロバイダに左右されません。コンテキスト予算と価格は実際のルートに由来し、不明な価格は $0 ではなく不明と表示されます。
|
||||
- **あなたが書くハーネス。** 役割は読んで編集できるファイルです。役割ごとにモデル、ツールの姿勢、常設の指示を持ち、チームで共有するならプロジェクトに、リポジトリをまたいで持ち歩くなら個人設定の隣に置きます。constitution はすべてのセッションを通じてエージェントにどう振る舞ってほしいかを記録し、ハーネスを私たちのやり方ではなくあなたのやり方に合わせます。
|
||||
- **許可するまでは読み取り専用。** Plan モードはファイルを変更せず、リスクのあるコマンドは承認でゲートされます。OS サンドボックスが実際にコマンドをラップするとき、Codewhale はそれを明示します。macOS では利用可能な Seatbelt、Linux ではオプトインの bubblewrap です。リポジトリの `constitution.json` は書き込みホールドへとコンパイルされ、Full Access でもスキップできません。
|
||||
- **再開できる作業。** Fleet はすべてのステップを追記専用の台帳に記録するので、`fleet resume` で止めたところから再開できます。
|
||||
|
||||
|
||||
+6
-3
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:a87c9f323f35 -->
|
||||
<!-- source: README.md sha256:4fb18fffb0fe -->
|
||||
# Codewhale
|
||||
|
||||
터미널에서 쓰는 오픈소스 코딩 에이전트 — 모델은 당신이 가져옵니다.
|
||||
@@ -7,6 +7,8 @@ Codewhale은 DeepSeek을 위한 네이티브 경험으로 시작했습니다.
|
||||
|
||||
프로바이더, 모델, 작업을 지정하면 코드를 읽고, 파일을 편집하고, 명령을 실행하고, 스스로 작업을 확인하며, 작업이 끝나거나 사용자의 판단이 필요해지면 멈춥니다. 작업 도중에도 `/model`로 모델을 바꿀 수 있습니다. 대화형 작업에는 TUI를, 스크립트와 CI에는 `codewhale exec`를 사용합니다. Rust로 작성했고, MIT 라이선스이며, 당신의 컴퓨터에서 실행됩니다.
|
||||
|
||||
다른 하네스와 다른 점은 이것입니다. **역할마다 어떤 모델을 쓸지 당신이 고르고, 서로 같을 필요가 없습니다.** Fleet은 역할별로 프로바이더, 모델, 추론 등급을 각각 고정합니다. 그래서 빠르고 저렴한 모델이 값비싼 추론 모델을 지휘할 수도 있고, GLM builder와 Kimi reviewer가 같은 작업을 함께 처리할 수도 있습니다. 자신의 역할과 자신의 constitution을 쓰면, 그 하네스는 우리 것이 아니라 당신 것이 됩니다.
|
||||
|
||||
우리는 항상 기여자와 개선할 방법을 찾고 있습니다. 사용하는 모델이나 프로바이더가 빠져 있거나 무언가가 깨진다면, 그것을 알려 주는 일이 할 수 있는 가장 유용한 일 중 하나입니다 — [기여](#기여)를 참고하세요.
|
||||
|
||||
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [codewhale.net](https://codewhale.net/) · [Docs](docs) · [Changelog](CHANGELOG.md) · [Discord](https://discord.gg/37gfS3ksug)
|
||||
@@ -36,11 +38,12 @@ codewhale web # local browser client on 127.0.0.1
|
||||
```
|
||||
|
||||
|
||||
TUI 안에서: `/model`은 프로바이더와 모델을 함께 전환하고, `/fleet`은 워커 팀을 실행하며, `/undo`는 직전 턴을 되돌리고, `/restore <N>`은 워크스페이스를 이전 스냅샷으로 되돌립니다(인자 없는 `/restore`는 스냅샷 목록만 보여줍니다). 입력창이 비어 있을 때 `Tab`은 Plan / Act / Operate 모드를 순환하고, 입력창에 내용이 있으면 `Tab`은 슬래시 명령과 `@` 멘션을 자동 완성합니다. `Shift+Tab`은 언제든지 Ask / Auto-Review / Full Access 권한 태세를 순환합니다. `!`는 일반 승인 경로를 거쳐 셸 명령을 실행합니다.
|
||||
TUI 안에서: `/model`은 프로바이더와 모델을 함께 전환하고, `/fleet`은 팀을 구성하고 실행하며(한 번에 한 역할씩, 각자 자기 모델을 가집니다), `/undo`는 직전 턴을 되돌리고, `/restore <N>`은 워크스페이스를 이전 스냅샷으로 되돌립니다(인자 없는 `/restore`는 스냅샷 목록만 보여줍니다). 입력창이 비어 있을 때 `Tab`은 Plan / Work / Operate 모드를 순환하고, 입력창에 내용이 있으면 `Tab`은 슬래시 명령과 `@` 멘션을 자동 완성합니다. `Shift+Tab`은 언제든지 Ask / Auto-Review / Full Access 권한 태세를 순환합니다. `!`는 일반 승인 경로를 거쳐 셸 명령을 실행합니다.
|
||||
|
||||
## 기능
|
||||
|
||||
- **어떤 모델이든, 어떤 프로바이더든.** DeepSeek, Claude, GPT, Kimi, GLM 등 30개 이상의 프로바이더와 키 없이 쓰는 자체 vLLM, SGLang, Ollama가 모두 하나의 런타임과 하나의 도구 세트를 통해 동작합니다. 컨텍스트 예산과 가격은 실제 라우트에서 가져오며, 알 수 없는 가격은 $0이 아니라 알 수 없음으로 표시됩니다.
|
||||
- **어떤 모델이든, 어떤 프로바이더든, 그리고 어떤 조합이든.** DeepSeek, Claude, GPT, Kimi, GLM 등 30개 이상의 프로바이더와 키 없이 쓰는 자체 vLLM, SGLang, Ollama가 모두 하나의 런타임과 하나의 도구 세트를 통해 동작합니다. 저장된 역할은 `provider`, `model`, 추론 등급을 명시적으로 기록하므로 하나의 실행 안에서 Fleet이 여러 벤더에 걸칠 수 있고, 역할의 라우트는 그때 활성화된 프로바이더에 좌우되지 않습니다. 컨텍스트 예산과 가격은 실제 라우트에서 가져오며, 알 수 없는 가격은 $0이 아니라 알 수 없음으로 표시됩니다.
|
||||
- **당신이 직접 쓰는 하네스.** 역할은 읽고 수정할 수 있는 파일입니다. 역할마다 모델, 도구 태세, 상시 지시를 담아 팀과 공유하려면 프로젝트에, 저장소를 옮겨 다니며 쓰려면 개인 설정 옆에 둡니다. constitution은 모든 세션에서 에이전트가 어떻게 행동하기를 바라는지 기록해, 하네스가 우리 방식이 아니라 당신의 방식에 맞도록 합니다.
|
||||
- **허용하기 전까지는 읽기 전용.** Plan 모드는 파일을 바꾸지 않고, 위험한 명령은 승인을 거칩니다. OS 샌드박스가 실제로 명령을 래핑할 때 Codewhale은 이를 그대로 표시합니다. macOS에서는 사용 가능한 Seatbelt, Linux에서는 옵트인 bubblewrap입니다. 저장소의 `constitution.json`은 Full Access조차 건너뛸 수 없는 쓰기 홀드로 컴파일됩니다.
|
||||
- **이어서 할 수 있는 작업.** Fleet은 모든 단계를 추가 전용 원장에 기록하므로, `fleet resume`으로 멈춘 지점부터 이어갈 수 있습니다.
|
||||
|
||||
|
||||
@@ -13,6 +13,13 @@ you. Switch models mid-task with `/model`. Work interactively in the TUI, or run
|
||||
`codewhale exec` in scripts and CI. It's written in Rust, licensed MIT, and runs
|
||||
on your machine.
|
||||
|
||||
The part that isn't like other harnesses: **you pick the model for each role,
|
||||
and they don't have to match.** A fleet pins a provider, a model, and a
|
||||
reasoning tier per role — so a cheap fast model can direct an expensive
|
||||
reasoning one, or a GLM builder can work the same job as a Kimi reviewer.
|
||||
Write your own roles, your own constitution, and the harness is yours rather
|
||||
than ours.
|
||||
|
||||
We're always looking for contributors and ways to improve. If a model or
|
||||
provider you use is missing, or something breaks, telling us is one of the most
|
||||
useful things you can do — see [Contributing](#contributing).
|
||||
@@ -46,20 +53,29 @@ codewhale exec "fix the failing test" # headless
|
||||
codewhale web # local browser client on 127.0.0.1
|
||||
```
|
||||
|
||||
In the TUI: `/model` switches provider and model together, `/fleet` runs a team
|
||||
of workers, `/undo` reverts the last turn, and `/restore <N>` rolls the
|
||||
workspace back to an earlier snapshot (bare `/restore` lists them). `Tab`
|
||||
cycles Plan / Act / Operate when the composer is empty — with text in it, `Tab`
|
||||
In the TUI: `/model` switches provider and model together, `/fleet` builds and
|
||||
runs the team — one role at a time, each with its own model — `/undo` reverts
|
||||
the last turn, and `/restore <N>` rolls the workspace back to an earlier
|
||||
snapshot (bare `/restore` lists them). `Tab`
|
||||
cycles Plan / Work / Operate when the composer is empty — with text in it, `Tab`
|
||||
completes slash commands and `@` mentions instead. `Shift+Tab` cycles the
|
||||
Ask / Auto-Review / Full Access permission posture at any time. `!` runs a
|
||||
shell command through the normal approval path.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Any model, any provider.** DeepSeek, Claude, GPT, Kimi, GLM, and 30+
|
||||
providers, plus your own vLLM, SGLang, or Ollama with no key — all through one
|
||||
runtime and one toolset. Context limits and prices come from the real route,
|
||||
and an unknown price shows as unknown rather than $0.
|
||||
- **Any model, any provider — and any mix of them.** DeepSeek, Claude, GPT,
|
||||
Kimi, GLM, and 30+ providers, plus your own vLLM, SGLang, or Ollama with no
|
||||
key, all through one runtime and one toolset. A saved role records its
|
||||
`provider`, `model`, and reasoning tier explicitly, so a fleet can span
|
||||
vendors in a single run and a role's route never depends on whichever
|
||||
provider happens to be active. Context limits and prices come from the real
|
||||
route, and an unknown price shows as unknown rather than $0.
|
||||
- **A harness you author.** Roles are files you can read and edit — a model, a
|
||||
tool posture, and standing instructions per role — kept in the project so the
|
||||
team shares them, or beside your other personal settings so they follow you
|
||||
between repos. A constitution records how you want the agent to behave across
|
||||
every session, so the harness matches your practice instead of ours.
|
||||
- **Read-only until you allow more.** Plan mode can't change files, and
|
||||
approvals gate risky commands. When an OS sandbox actually wraps a command,
|
||||
Codewhale says so: Seatbelt on macOS where available, opt-in bubblewrap on
|
||||
|
||||
+15
-2
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:a87c9f323f35 -->
|
||||
<!-- source: README.md sha256:4fb18fffb0fe -->
|
||||
# Codewhale
|
||||
|
||||
Um agente de programação de código aberto para o seu terminal — traga o seu próprio modelo.
|
||||
@@ -15,6 +15,13 @@ termina ou quando precisa de você. Troque de modelo no meio da tarefa com
|
||||
`/model`. Trabalhe de forma interativa na TUI, ou rode `codewhale exec` em
|
||||
scripts e CI. É escrito em Rust, licenciado sob MIT, e roda na sua máquina.
|
||||
|
||||
O que não se parece com outros harnesses: **você escolhe o modelo de cada
|
||||
papel, e eles não precisam ser iguais.** Uma fleet fixa um provedor, um modelo e
|
||||
um nível de raciocínio por papel — então um modelo barato e rápido pode dirigir
|
||||
um modelo de raciocínio caro, ou um builder GLM pode trabalhar na mesma tarefa
|
||||
que um reviewer Kimi. Escreva seus próprios papéis e sua própria constitution, e
|
||||
o harness passa a ser seu, não nosso.
|
||||
|
||||
Estamos sempre em busca de pessoas que contribuam e de formas de melhorar. Se um
|
||||
modelo ou provedor que você usa está faltando, ou se algo quebra, nos contar é
|
||||
uma das coisas mais úteis que você pode fazer — veja [Contribuindo](#contribuindo).
|
||||
@@ -52,7 +59,7 @@ codewhale web # local browser client on 127.0.0.1
|
||||
Na TUI: `/model` troca provedor e modelo juntos, `/fleet` executa uma equipe
|
||||
de workers, `/undo` desfaz o último turno e `/restore <N>` reverte o workspace
|
||||
para um snapshot anterior (`/restore` sem argumento apenas os lista). Quando o
|
||||
compositor está vazio, `Tab` cicla entre Plan / Act / Operate; com texto
|
||||
compositor está vazio, `Tab` cicla entre Plan / Work / Operate; com texto
|
||||
digitado, `Tab` completa comandos slash e menções `@`. `Shift+Tab` cicla a
|
||||
postura de permissão Ask / Auto-Review / Full Access a qualquer momento. `!`
|
||||
executa um comando de shell pelo caminho normal de aprovação.
|
||||
@@ -64,6 +71,12 @@ executa um comando de shell pelo caminho normal de aprovação.
|
||||
tudo por um único runtime e um único conjunto de ferramentas. Orçamentos de
|
||||
contexto e preços vêm da rota real, e um preço desconhecido aparece como
|
||||
desconhecido em vez de $0.
|
||||
- **Um harness escrito por você.** Papéis são arquivos que você pode ler e
|
||||
editar — um modelo, uma postura de ferramentas e instruções permanentes por
|
||||
papel — guardados no projeto para o time compartilhar, ou ao lado das suas
|
||||
configurações pessoais para acompanharem você entre repositórios. Uma
|
||||
constitution registra como você quer que o agente se comporte em cada sessão,
|
||||
para que o harness siga a sua prática, e não a nossa.
|
||||
- **Somente leitura até você permitir mais.** O modo Plan não altera arquivos,
|
||||
e as aprovações controlam os comandos arriscados. Quando um sandbox do
|
||||
sistema operacional realmente envolve um comando, o Codewhale avisa: Seatbelt
|
||||
|
||||
+15
-2
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:a87c9f323f35 -->
|
||||
<!-- source: README.md sha256:4fb18fffb0fe -->
|
||||
# Codewhale
|
||||
|
||||
Открытый агент для программирования в вашем терминале — модель приносите с собой.
|
||||
@@ -16,6 +16,13 @@ Codewhale начинался как нативный клиент для DeepSee
|
||||
и CI. Он написан на Rust, распространяется по лицензии MIT и работает на вашей
|
||||
машине.
|
||||
|
||||
Чем это не похоже на другие harness: **вы сами выбираете модель для каждой
|
||||
роли, и они не обязаны совпадать.** Fleet закрепляет провайдера, модель и
|
||||
уровень рассуждений отдельно для каждой роли — поэтому дешёвая и быстрая модель
|
||||
может руководить дорогой рассуждающей, а builder на GLM может работать над той
|
||||
же задачей, что и reviewer на Kimi. Опишите свои роли и свою constitution — и
|
||||
harness станет вашим, а не нашим.
|
||||
|
||||
Мы всегда ищем участников и способы стать лучше. Если модели или провайдера,
|
||||
которым вы пользуетесь, не хватает, или что-то сломалось, сообщить нам об этом —
|
||||
одно из самых полезных действий с вашей стороны: см.
|
||||
@@ -55,7 +62,7 @@ codewhale web # local browser client on 127.0.0.1
|
||||
команду воркеров, `/undo` отменяет последний ход, а `/restore <N>` откатывает
|
||||
рабочую копию к более раннему снимку (`/restore` без аргумента только выводит их
|
||||
список). Когда поле ввода пустое, `Tab` циклически переключает режимы Plan /
|
||||
Act / Operate; если в поле есть текст, `Tab` дополняет слэш-команды и упоминания
|
||||
Work / Operate; если в поле есть текст, `Tab` дополняет слэш-команды и упоминания
|
||||
`@`. `Shift+Tab` переключает уровни прав Ask / Auto-Review / Full Access в любой
|
||||
момент. `!` запускает команду оболочки через обычный путь подтверждения.
|
||||
|
||||
@@ -66,6 +73,12 @@ Act / Operate; если в поле есть текст, `Tab` дополняе
|
||||
через единый рантайм и единый набор инструментов. Лимиты контекста и цены
|
||||
берутся из реального маршрута, а неизвестная цена отображается как неизвестная,
|
||||
а не как $0.
|
||||
- **Harness, который пишете вы.** Роли — это файлы, которые можно прочитать и
|
||||
изменить: для каждой роли своя модель, своя позиция по инструментам и
|
||||
постоянные инструкции. Держите их в проекте, чтобы ими пользовалась команда,
|
||||
или рядом с личными настройками, чтобы они следовали за вами между
|
||||
репозиториями. Constitution фиксирует, как вы хотите, чтобы агент вёл себя в
|
||||
каждой сессии, — так harness подстраивается под вашу практику, а не под нашу.
|
||||
- **Только чтение, пока вы не разрешите больше.** Режим Plan не может изменять
|
||||
файлы, а рискованные команды требуют подтверждения. Когда команду действительно
|
||||
оборачивает песочница ОС, Codewhale сообщает об этом: Seatbelt на macOS, где он
|
||||
|
||||
+15
-2
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:a87c9f323f35 -->
|
||||
<!-- source: README.md sha256:4fb18fffb0fe -->
|
||||
# Codewhale
|
||||
|
||||
Агент для програмування з відкритим кодом у вашому терміналі — модель приносите ви.
|
||||
@@ -16,6 +16,13 @@ Codewhale починався як нативний інструмент для D
|
||||
у скриптах і CI. Він написаний на Rust, поширюється за ліцензією MIT і працює
|
||||
на вашому комп'ютері.
|
||||
|
||||
Чим це не схоже на інші harness: **ви самі обираєте модель для кожної ролі, і
|
||||
вони не мусять збігатися.** Fleet закріплює провайдера, модель і рівень
|
||||
міркувань окремо для кожної ролі — тож дешева і швидка модель може керувати
|
||||
дорогою міркувальною, а builder на GLM може працювати над тим самим завданням,
|
||||
що й reviewer на Kimi. Опишіть свої ролі та свою constitution — і harness стане
|
||||
вашим, а не нашим.
|
||||
|
||||
Ми завжди шукаємо учасників і способи стати кращими. Якщо моделі чи
|
||||
провайдера, якими ви користуєтесь, бракує, або щось ламається, повідомити про
|
||||
це — одна з найкорисніших речей, які ви можете зробити — див.
|
||||
@@ -54,7 +61,7 @@ codewhale web # local browser client on 127.0.0.1
|
||||
У TUI: `/model` перемикає провайдера й модель разом, `/fleet` запускає команду
|
||||
працівників, `/undo` скасовує останній крок, а `/restore <N>` відкочує робочу
|
||||
копію до давнішого знімка (`/restore` без аргументу лише виводить їхній
|
||||
список). Коли поле введення порожнє, `Tab` циклічно перемикає Plan / Act /
|
||||
список). Коли поле введення порожнє, `Tab` циклічно перемикає Plan / Work /
|
||||
Operate; якщо в полі є текст, `Tab` доповнює слеш-команди та згадки `@`.
|
||||
`Shift+Tab` перемикає режими дозволів Ask / Auto-Review / Full Access будь-коли.
|
||||
`!` виконує команду оболонки через звичайний шлях затвердження.
|
||||
@@ -66,6 +73,12 @@ Operate; якщо в полі є текст, `Tab` доповнює слеш-к
|
||||
ключа — усе через одне середовище виконання й один набір інструментів. Ліміти
|
||||
контексту й ціни беруться з реального маршруту, а невідома ціна показується
|
||||
як невідома, а не як $0.
|
||||
- **Harness, який пишете ви.** Ролі — це файли, які можна прочитати й змінити:
|
||||
для кожної ролі своя модель, своя позиція щодо інструментів і постійні
|
||||
інструкції. Тримайте їх у проєкті, щоб ними користувалася команда, або поруч з
|
||||
особистими налаштуваннями, щоб вони йшли за вами між репозиторіями.
|
||||
Constitution фіксує, як ви хочете, щоб агент поводився в кожній сесії, — тож
|
||||
harness підлаштовується під вашу практику, а не під нашу.
|
||||
- **Лише читання, доки ви не дозволите більше.** Режим Plan не може змінювати
|
||||
файли, а ризиковані команди проходять через затвердження. Коли пісочниця ОС
|
||||
справді обгортає команду, Codewhale каже про це: Seatbelt на macOS, де він
|
||||
|
||||
+14
-2
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:a87c9f323f35 -->
|
||||
<!-- source: README.md sha256:4fb18fffb0fe -->
|
||||
# Codewhale
|
||||
|
||||
Một coding agent mã nguồn mở cho terminal của bạn — mang theo model của riêng bạn.
|
||||
@@ -15,6 +15,13 @@ thành hoặc cần đến bạn. Đổi model giữa chừng bằng `/model`. L
|
||||
trong TUI, hoặc chạy `codewhale exec` trong script và CI. Viết bằng Rust, giấy
|
||||
phép MIT, và chạy trên máy của bạn.
|
||||
|
||||
Điều khác biệt so với các harness khác: **bạn chọn model cho từng vai trò, và
|
||||
chúng không cần phải giống nhau.** Một fleet ghim provider, model và mức suy
|
||||
luận riêng cho từng vai trò — nên một model nhanh và rẻ có thể điều phối một
|
||||
model suy luận đắt tiền, hoặc một builder GLM có thể làm chung việc với một
|
||||
reviewer Kimi. Hãy viết vai trò của riêng bạn, constitution của riêng bạn, và
|
||||
harness đó là của bạn chứ không phải của chúng tôi.
|
||||
|
||||
Chúng tôi luôn tìm kiếm người đóng góp và cách cải thiện. Nếu một model hay
|
||||
provider bạn dùng còn thiếu, hoặc có gì đó hỏng, báo cho chúng tôi biết là một
|
||||
trong những điều hữu ích nhất bạn có thể làm — xem [Đóng góp](#đóng-góp).
|
||||
@@ -52,7 +59,7 @@ codewhale web # local browser client on 127.0.0.1
|
||||
Trong TUI: `/model` đổi provider và model cùng lúc, `/fleet` chạy một đội
|
||||
worker, `/undo` hoàn tác lượt gần nhất, và `/restore <N>` đưa workspace về một
|
||||
ảnh chụp trước đó (`/restore` không tham số chỉ liệt kê chúng). Khi vùng soạn
|
||||
thảo trống, `Tab` chuyển vòng qua Plan / Act / Operate; khi vùng soạn thảo có
|
||||
thảo trống, `Tab` chuyển vòng qua Plan / Work / Operate; khi vùng soạn thảo có
|
||||
chữ, `Tab` lại hoàn tất lệnh slash và nhắc `@`. `Shift+Tab` chuyển vòng qua tư
|
||||
thế quyền Ask / Auto-Review / Full Access bất cứ lúc nào. `!` chạy một lệnh
|
||||
shell qua đường phê duyệt bình thường.
|
||||
@@ -63,6 +70,11 @@ shell qua đường phê duyệt bình thường.
|
||||
GLM, hơn 30 provider, và vLLM, SGLang hay Ollama của riêng bạn — không cần
|
||||
key — đều chạy qua một runtime và một bộ công cụ. Ngân sách ngữ cảnh và giá
|
||||
lấy từ route thật; giá chưa rõ hiển thị là chưa rõ, chứ không phải $0.
|
||||
- **Một harness do bạn viết.** Vai trò là những tệp bạn có thể đọc và sửa — mỗi
|
||||
vai trò một model, một tư thế công cụ và các chỉ dẫn thường trực — đặt trong dự
|
||||
án để cả nhóm dùng chung, hoặc cạnh các thiết lập cá nhân để đi theo bạn giữa
|
||||
các repo. Constitution ghi lại cách bạn muốn agent hành xử trong mọi phiên, để
|
||||
harness khớp với cách làm của bạn thay vì của chúng tôi.
|
||||
- **Chỉ đọc cho tới khi bạn cho phép thêm.** Chế độ Plan không đổi file, và mọi
|
||||
lệnh rủi ro đều qua phê duyệt. Khi một sandbox của hệ điều hành thực sự bọc
|
||||
lệnh, Codewhale nói rõ điều đó: Seatbelt trên macOS khi khả dụng, bubblewrap
|
||||
|
||||
+6
-3
@@ -1,4 +1,4 @@
|
||||
<!-- source: README.md sha256:a87c9f323f35 -->
|
||||
<!-- source: README.md sha256:4fb18fffb0fe -->
|
||||
# Codewhale
|
||||
|
||||
一个面向终端的开源编程智能体——模型由你自带。
|
||||
@@ -7,6 +7,8 @@ Codewhale 最初是为 DeepSeek 打造的原生体验,如今已成长为一个
|
||||
|
||||
给它一个 provider、一个模型和一个任务:它会读你的代码、改文件、跑命令、检查自己的工作,并在任务完成或需要你介入时停下。任务中途用 `/model` 切换模型。交互式工作用 TUI,脚本和 CI 用 `codewhale exec`。它用 Rust 编写,采用 MIT 许可,运行在你自己的机器上。
|
||||
|
||||
和其他 harness 不一样的地方在于:**每个角色用哪个模型由你决定,而且它们不必相同。** 一个 Fleet 为每个角色分别固定 provider、模型和推理档位——所以又快又便宜的模型可以指挥昂贵的推理模型,GLM 的 builder 也可以和 Kimi 的 reviewer 干同一份活。写下你自己的角色、你自己的 constitution,这套 harness 就是你的,而不是我们的。
|
||||
|
||||
我们一直在寻找贡献者和改进的方式。如果你在用的某个模型或 provider 还不支持,或者有什么东西坏了,告诉我们就是你能做的最有用的事之一——见[贡献](#贡献)。
|
||||
|
||||
[English](README.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [codewhale.net](https://codewhale.net/) · [Docs](docs) · [Changelog](CHANGELOG.md) · [Discord 社区](https://discord.gg/37gfS3ksug)
|
||||
@@ -36,11 +38,12 @@ codewhale web # local browser client on 127.0.0.1
|
||||
```
|
||||
|
||||
|
||||
在 TUI 中:`/model` 同时切换 provider 和模型,`/fleet` 运行一组 worker,`/undo` 撤销上一轮,`/restore <N>` 把工作区回滚到更早的快照(不带参数的 `/restore` 只列出快照)。输入区为空时,`Tab` 在 Plan / Act / Operate 之间循环切换;输入区有内容时,`Tab` 改为补全斜杠命令和 `@` 提及。`Shift+Tab` 在任何时候都能循环切换 Ask / Auto-Review / Full Access 权限姿态。`!` 让 shell 命令经由正常的审批路径运行。
|
||||
在 TUI 中:`/model` 同时切换 provider 和模型,`/fleet` 构建并运行团队——一次一个角色,各自带着自己的模型,`/undo` 撤销上一轮,`/restore <N>` 把工作区回滚到更早的快照(不带参数的 `/restore` 只列出快照)。输入区为空时,`Tab` 在 Plan / Work / Operate 之间循环切换;输入区有内容时,`Tab` 改为补全斜杠命令和 `@` 提及。`Shift+Tab` 在任何时候都能循环切换 Ask / Auto-Review / Full Access 权限姿态。`!` 让 shell 命令经由正常的审批路径运行。
|
||||
|
||||
## 功能
|
||||
|
||||
- **任意模型,任意 provider。** DeepSeek、Claude、GPT、Kimi、GLM 等 30 多家 provider,以及你自己的 vLLM、SGLang、Ollama——无需 key——全都跑在同一套运行时和同一套工具之上。上下文预算与价格取自真实路由;价格未知时显示未知,而不是 $0。
|
||||
- **任意模型,任意 provider——也可以任意混搭。** DeepSeek、Claude、GPT、Kimi、GLM 等 30 多家 provider,以及你自己的 vLLM、SGLang、Ollama——无需 key——全都跑在同一套运行时和同一套工具之上。保存下来的角色会显式记录它的 `provider`、`model` 和推理档位,所以一个 Fleet 可以在同一次运行里跨越多家厂商,角色的路由也不会取决于当时恰好激活的是哪个 provider。上下文预算与价格取自真实路由;价格未知时显示未知,而不是 $0。
|
||||
- **由你亲手写就的 harness。** 角色就是你能读、能改的文件——每个角色一个模型、一套工具姿态和一份常驻指令——放在项目里让团队共享,或放在你的个人设置旁边,跟着你在不同仓库之间走。constitution 记录你希望 agent 在每一次会话中如何行事,让这套 harness 贴合你的做法,而不是我们的。
|
||||
- **默认只读,放开权限才更进一步。** Plan 模式不改动文件,审批把关每一次高风险命令。只有当命令确实被 OS 沙箱包装时,Codewhale 才会如实标明:macOS 上是可用时启用的 Seatbelt,Linux 上是需显式启用的 bubblewrap。仓库的 `constitution.json` 会编译成写入拦截,连 Full Access 也无法跳过。
|
||||
- **随时可以续跑的工作。** Fleet 把每一步记录在只追加的账本里,`fleet resume` 从你停下的地方继续。
|
||||
|
||||
|
||||
@@ -74,6 +74,9 @@ base_url = "https://api.deepseek.com/beta"
|
||||
# trinity-large-preview — direct Arcee AI API model ID
|
||||
# deepseek-ai/DeepSeek-V4-Pro — SGLang self-hosted Pro model ID
|
||||
# deepseek-ai/DeepSeek-V4-Flash — SGLang self-hosted Flash model ID
|
||||
# auto — auto-select between flash and pro based on task complexity.
|
||||
# Complex tasks (debugging, refactoring, architecture) → pro;
|
||||
# simple tasks (lookups, formatting, Q&A) → flash.
|
||||
default_text_model = "deepseek-v4-pro"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -731,6 +734,15 @@ max_subagents = 10 # optional (1-20)
|
||||
# base_url = "https://api.x.ai/v1"
|
||||
# model = "grok-4.5" # or grok-4.3, grok-build
|
||||
|
||||
# Mistral AI — la Plateforme (https://console.mistral.ai/)
|
||||
# OpenAI-compatible Chat Completions route.
|
||||
# Provider aliases: mistral, mistral-ai, mistralai, la-plateforme
|
||||
# Env var aliases: MISTRAL_API_KEY, MISTRAL_BASE_URL, MISTRAL_MODEL
|
||||
[providers.mistral]
|
||||
# api_key = "YOUR_MISTRAL_API_KEY"
|
||||
# base_url = "https://api.mistral.ai/v1"
|
||||
# model = "mistral-code-latest" # or mistral-medium-latest, mistral-small-latest, mistral-large-latest
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Alibaba Cloud Model Studio — Token Plan
|
||||
# (https://bailian.console.aliyun.com/)
|
||||
|
||||
@@ -8,5 +8,5 @@ repository.workspace = true
|
||||
description = "Model/provider registry and fallback strategy for Codewhale"
|
||||
|
||||
[dependencies]
|
||||
codewhale-config = { path = "../config", version = "0.9.4" }
|
||||
codewhale-config = { path = "../config", version = "0.9.6" }
|
||||
serde.workspace = true
|
||||
|
||||
+62
-6
@@ -1106,14 +1106,25 @@ impl Default for ModelRegistry {
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
// Meta Model API / Muse Spark.
|
||||
// Meta Model API / Muse Spark. Keep these in step with
|
||||
// `DEFAULT_META_MODEL` in config's provider_defaults and with the
|
||||
// bundled models.dev catalog: this registry resolves the `muse`
|
||||
// aliases for the CLI and app-server, so a stale id here silently
|
||||
// routes them somewhere the configured default never points.
|
||||
ModelInfo {
|
||||
id: "muse-spark-1.1".to_string(),
|
||||
id: "muse-spark-1.2".to_string(),
|
||||
provider: ProviderKind::Meta,
|
||||
aliases: vec!["muse-spark".to_string(), "muse".to_string()],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "muse-spark-1.2-contributor".to_string(),
|
||||
provider: ProviderKind::Meta,
|
||||
aliases: vec!["muse-spark-contributor".to_string()],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
// xAI / Grok (https://api.x.ai/v1)
|
||||
ModelInfo {
|
||||
id: "grok-4.5".to_string(),
|
||||
@@ -1157,6 +1168,51 @@ impl Default for ModelRegistry {
|
||||
supports_tools: true,
|
||||
supports_reasoning: false,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "mistral-code-latest".to_string(),
|
||||
provider: ProviderKind::Mistral,
|
||||
aliases: vec![
|
||||
"codestral".to_string(),
|
||||
"codestral-latest".to_string(),
|
||||
"mistral-code".to_string(),
|
||||
],
|
||||
supports_tools: true,
|
||||
supports_reasoning: false,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "mistral-medium-latest".to_string(),
|
||||
provider: ProviderKind::Mistral,
|
||||
aliases: vec![
|
||||
"mistral-medium".to_string(),
|
||||
"mistral-medium-3-5".to_string(),
|
||||
],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "mistral-small-latest".to_string(),
|
||||
provider: ProviderKind::Mistral,
|
||||
aliases: vec![
|
||||
"mistral-small".to_string(),
|
||||
"mistral-small-2603".to_string(),
|
||||
],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "magistral-small-latest".to_string(),
|
||||
provider: ProviderKind::Mistral,
|
||||
aliases: vec!["magistral".to_string(), "magistral-small".to_string()],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "mistral-large-latest".to_string(),
|
||||
provider: ProviderKind::Mistral,
|
||||
aliases: vec!["mistral-large".to_string()],
|
||||
supports_tools: true,
|
||||
supports_reasoning: false,
|
||||
},
|
||||
];
|
||||
Self::new(models)
|
||||
}
|
||||
@@ -1900,7 +1956,7 @@ mod tests {
|
||||
(ProviderKind::Minimax, "MiniMax-M2.1"),
|
||||
(ProviderKind::MinimaxAnthropic, "MiniMax-M3"),
|
||||
(ProviderKind::Openmodel, "deepseek-v4-flash"),
|
||||
(ProviderKind::Meta, "muse-spark-1.1"),
|
||||
(ProviderKind::Meta, "muse-spark-1.2"),
|
||||
(ProviderKind::Xai, "grok-4.5"),
|
||||
] {
|
||||
assert!(
|
||||
@@ -2004,14 +2060,14 @@ mod tests {
|
||||
|
||||
let default = registry.resolve(None, Some(ProviderKind::Meta));
|
||||
assert_eq!(default.resolved.provider, ProviderKind::Meta);
|
||||
assert_eq!(default.resolved.id, "muse-spark-1.1");
|
||||
assert_eq!(default.resolved.id, "muse-spark-1.2");
|
||||
assert!(default.used_fallback);
|
||||
|
||||
let alias = registry.resolve(Some("muse-spark"), Some(ProviderKind::Meta));
|
||||
assert_eq!(alias.resolved.provider, ProviderKind::Meta);
|
||||
assert_eq!(alias.resolved.id, "muse-spark-1.1");
|
||||
assert_eq!(alias.resolved.id, "muse-spark-1.2");
|
||||
assert!(!alias.used_fallback);
|
||||
assert_eq!(model_family("muse-spark-1.1"), ModelFamily::Meta);
|
||||
assert_eq!(model_family("muse-spark-1.2"), ModelFamily::Meta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -12,16 +12,16 @@ autobins = false
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
codewhale-agent = { path = "../agent", version = "0.9.4" }
|
||||
codewhale-config = { path = "../config", version = "0.9.4" }
|
||||
codewhale-core = { path = "../core", version = "0.9.4" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.9.4" }
|
||||
codewhale-hooks = { path = "../hooks", version = "0.9.4" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.9.4" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.9.4" }
|
||||
codewhale-release = { path = "../release", version = "0.9.4" }
|
||||
codewhale-state = { path = "../state", version = "0.9.4" }
|
||||
codewhale-tools = { path = "../tools", version = "0.9.4" }
|
||||
codewhale-agent = { path = "../agent", version = "0.9.6" }
|
||||
codewhale-config = { path = "../config", version = "0.9.6" }
|
||||
codewhale-core = { path = "../core", version = "0.9.6" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.9.6" }
|
||||
codewhale-hooks = { path = "../hooks", version = "0.9.6" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.9.6" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.9.6" }
|
||||
codewhale-release = { path = "../release", version = "0.9.6" }
|
||||
codewhale-state = { path = "../state", version = "0.9.6" }
|
||||
codewhale-tools = { path = "../tools", version = "0.9.6" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
rustls.workspace = true
|
||||
|
||||
+14
-18
@@ -11,27 +11,23 @@ description = "Agentic terminal facade for open-source and open-weight coding mo
|
||||
name = "codewhale"
|
||||
path = "src/main.rs"
|
||||
|
||||
# Short-form convenience alias — forwards to `codewhale` silently.
|
||||
[[bin]]
|
||||
name = "codew"
|
||||
path = "src/bin/codew.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
clap_complete.workspace = true
|
||||
codewhale-agent = { path = "../agent", version = "0.9.4" }
|
||||
codewhale-app-server = { path = "../app-server", version = "0.9.4" }
|
||||
codewhale-config = { path = "../config", version = "0.9.4" }
|
||||
codewhale-lane = { path = "../lane", version = "0.9.4" }
|
||||
codewhale-workflow = { path = "../workflow", version = "0.9.4" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.9.4" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.9.4" }
|
||||
codewhale-paths = { path = "../paths", version = "0.9.4" }
|
||||
codewhale-release = { path = "../release", version = "0.9.4" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.9.4" }
|
||||
codewhale-state = { path = "../state", version = "0.9.4" }
|
||||
codewhale-telemetry = { path = "../telemetry", version = "0.9.4" }
|
||||
codewhale-tui = { path = "../tui", version = "0.9.6" }
|
||||
codewhale-agent = { path = "../agent", version = "0.9.6" }
|
||||
codewhale-app-server = { path = "../app-server", version = "0.9.6" }
|
||||
codewhale-config = { path = "../config", version = "0.9.6" }
|
||||
codewhale-lane = { path = "../lane", version = "0.9.6" }
|
||||
codewhale-workflow = { path = "../workflow", version = "0.9.6" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.9.6" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.9.6" }
|
||||
codewhale-paths = { path = "../paths", version = "0.9.6" }
|
||||
codewhale-release = { path = "../release", version = "0.9.6" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.9.6" }
|
||||
codewhale-state = { path = "../state", version = "0.9.6" }
|
||||
codewhale-telemetry = { path = "../telemetry", version = "0.9.6" }
|
||||
chrono.workspace = true
|
||||
console = "0.16.3"
|
||||
dirs.workspace = true
|
||||
@@ -49,7 +45,7 @@ webbrowser = "1.0"
|
||||
zeroize = "1.8.2"
|
||||
|
||||
[build-dependencies]
|
||||
codewhale-build-support = { path = "../build-support", version = "0.9.4" }
|
||||
codewhale-build-support = { path = "../build-support", version = "0.9.6" }
|
||||
|
||||
# Parent-death cleanup for delegated server children (#3259): on Linux the
|
||||
# dispatcher sets PR_SET_PDEATHSIG so the child is signalled if the dispatcher
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
//! Convenience `codew` alias.
|
||||
//!
|
||||
//! Forwards argv to the `codewhale` dispatcher silently. This is a
|
||||
//! permanent short-form alias — six fewer keystrokes, same binary.
|
||||
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = 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<std::process::ExitStatus> {
|
||||
// Prefer the dispatcher installed next to this shim. Falling back to PATH
|
||||
// first can silently run an older global `codewhale` after a fresh install.
|
||||
if let Ok(exe_path) = env::current_exe()
|
||||
&& let Some(sibling) = sibling_codewhale_path(&exe_path)
|
||||
&& sibling.is_file()
|
||||
{
|
||||
return Command::new(sibling).args(args).status();
|
||||
}
|
||||
|
||||
// Fall back to PATH for unusual installs that ship only the shim.
|
||||
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),
|
||||
}
|
||||
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"codewhale not found on PATH or in sibling directory",
|
||||
))
|
||||
}
|
||||
|
||||
fn sibling_codewhale_path(exe_path: &Path) -> Option<PathBuf> {
|
||||
exe_path
|
||||
.parent()
|
||||
.map(|dir| dir.join(format!("codewhale{}", std::env::consts::EXE_SUFFIX)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::sibling_codewhale_path;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn sibling_dispatcher_uses_platform_executable_suffix() {
|
||||
let path = Path::new("/tmp/codewhale-bin/codew");
|
||||
let sibling = sibling_codewhale_path(path).expect("sibling");
|
||||
|
||||
assert_eq!(
|
||||
sibling,
|
||||
Path::new("/tmp/codewhale-bin")
|
||||
.join(format!("codewhale{}", std::env::consts::EXE_SUFFIX))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,10 @@ enum CloudCommand {
|
||||
Logout,
|
||||
/// Manage provider API keys stored in the signed-in Codewhale account.
|
||||
Keys(CloudKeysArgs),
|
||||
/// Inspect the account document; local settings import is not available yet.
|
||||
Pull(CloudPullArgs),
|
||||
/// Push local settings to the account document (never automatic, --dry-run required).
|
||||
Push(CloudPushArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
@@ -67,6 +71,20 @@ struct CloudLoginArgs {
|
||||
timeout_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
struct CloudPullArgs {
|
||||
/// Inspect the account document without writing local files.
|
||||
#[arg(long, default_value_t = false)]
|
||||
dry_run: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
struct CloudPushArgs {
|
||||
/// Show what would be pushed without writing the remote document.
|
||||
#[arg(long, default_value_t = false)]
|
||||
dry_run: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
struct CloudKeysArgs {
|
||||
#[command(subcommand)]
|
||||
@@ -663,6 +681,52 @@ fn run_with<T: CloudTransport, W: Write>(
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
CloudCommand::Pull(args) => {
|
||||
if !args.dry_run {
|
||||
bail!(
|
||||
"Account settings import is not available yet; local config was not changed. Run `codewhale account pull --dry-run` to inspect the signed-in account."
|
||||
);
|
||||
}
|
||||
let user = client.me()?;
|
||||
// `/api/me` currently exposes account identity and key metadata,
|
||||
// not a versioned settings document that can be applied locally.
|
||||
// Stay read-only and explicit until that import contract exists.
|
||||
writeln!(out, "Account settings (pull --dry-run):")?;
|
||||
writeln!(out, "Account ID: {}", printable(&user.id))?;
|
||||
writeln!(out, "Profile: {}", printable(profile))?;
|
||||
writeln!(out, "API: {api_base}")?;
|
||||
writeln!(
|
||||
out,
|
||||
"dry-run: remote settings import is not available; local config unchanged"
|
||||
)?;
|
||||
// Show the invariant: Bearer custody stays in the OS keyring, never in config.toml.
|
||||
writeln!(
|
||||
out,
|
||||
"Secure custody: Bearer tokens remain in the OS keyring"
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
CloudCommand::Push(args) => {
|
||||
let user = client.me()?;
|
||||
if !args.dry_run {
|
||||
bail!(
|
||||
"Push is never automatic; re-run with --dry-run to preview, then confirm explicitly"
|
||||
);
|
||||
}
|
||||
writeln!(out, "Account settings (push --dry-run):")?;
|
||||
writeln!(out, "Account ID: {}", printable(&user.id))?;
|
||||
writeln!(out, "Profile: {}", printable(profile))?;
|
||||
writeln!(out, "API: {api_base}")?;
|
||||
writeln!(
|
||||
out,
|
||||
"dry-run: would PATCH /api/me/preferences with If-Match revision check (412 on conflict)"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
"No credentials, paths, or env are copied; only explicit fields (field-level last-writer-wins)"
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -434,6 +434,95 @@ fn status_refreshes_once_on_unauthorized_and_never_displays_tokens() {
|
||||
assert_eq!(requests[2].path, "/api/me");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_pull_refuses_to_claim_unimplemented_local_import() {
|
||||
let (temp, config) = test_config();
|
||||
let config_path = config.path().to_path_buf();
|
||||
let (secrets, _) = test_secrets();
|
||||
let transport = FakeTransport::new(vec![]);
|
||||
let mut output = Vec::new();
|
||||
let mut key_reader = |_| bail!("unused");
|
||||
let mut opener = |_| true;
|
||||
let mut sleeper = |_| {};
|
||||
|
||||
let error = run_with(
|
||||
command(&["codewhale", "account", "pull"]),
|
||||
"default",
|
||||
"https://api.codewhale.net",
|
||||
&config,
|
||||
&secrets,
|
||||
&secrets,
|
||||
&transport,
|
||||
&mut output,
|
||||
&mut key_reader,
|
||||
&mut opener,
|
||||
&mut sleeper,
|
||||
)
|
||||
.expect_err("non-dry-run pull must fail until settings import exists");
|
||||
|
||||
assert!(error.to_string().contains("import is not available"));
|
||||
assert!(error.to_string().contains("local config was not changed"));
|
||||
assert!(
|
||||
output.is_empty(),
|
||||
"a rejected pull must not print success text"
|
||||
);
|
||||
assert!(
|
||||
transport.requests().is_empty(),
|
||||
"a rejected pull needs no API call"
|
||||
);
|
||||
assert!(
|
||||
!config_path.exists(),
|
||||
"a rejected pull must not create config.toml"
|
||||
);
|
||||
drop(temp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_pull_dry_run_is_truthful_and_read_only() {
|
||||
let (temp, config) = test_config();
|
||||
let config_path = config.path().to_path_buf();
|
||||
let (secrets, _) = test_secrets();
|
||||
let transport = FakeTransport::new(vec![response(200, account("acct-pull"))]);
|
||||
CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
|
||||
.save_auth(auth("access-secret", "refresh-secret", "acct-pull"))
|
||||
.unwrap();
|
||||
let mut output = Vec::new();
|
||||
let mut key_reader = |_| bail!("unused");
|
||||
let mut opener = |_| true;
|
||||
let mut sleeper = |_| {};
|
||||
|
||||
run_with(
|
||||
command(&["codewhale", "account", "pull", "--dry-run"]),
|
||||
"default",
|
||||
"https://api.codewhale.net",
|
||||
&config,
|
||||
&secrets,
|
||||
&secrets,
|
||||
&transport,
|
||||
&mut output,
|
||||
&mut key_reader,
|
||||
&mut opener,
|
||||
&mut sleeper,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let output = String::from_utf8(output).unwrap();
|
||||
assert!(output.contains("Account settings (pull --dry-run):"));
|
||||
assert!(output.contains("Account ID: acct-pull"));
|
||||
assert!(output.contains("remote settings import is not available"));
|
||||
assert!(output.contains("local config unchanged"));
|
||||
assert!(!output.contains("Pulled account document"));
|
||||
assert!(!output.contains("would hydrate"));
|
||||
assert!(!output.contains("access-secret"));
|
||||
assert!(!output.contains("refresh-secret"));
|
||||
assert!(!config_path.exists(), "dry-run must not create config.toml");
|
||||
let requests = transport.requests();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert!(requests[0].method == HttpMethod::Get);
|
||||
assert_eq!(requests[0].path, "/api/me");
|
||||
drop(temp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_terminal_refresh_responses_preserve_the_local_session() {
|
||||
for status in [403, 429, 500, 503] {
|
||||
|
||||
+341
-1779
File diff suppressed because it is too large
Load Diff
@@ -14,5 +14,22 @@ fn main() -> std::process::ExitCode {
|
||||
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
|
||||
}
|
||||
|
||||
// Single-binary argv0 dispatch: `codew` is now an alias for `codewhale`
|
||||
// without a second compiled artifact. Checking the binary basename keeps
|
||||
// the install surface at one file while preserving the six-keystroke save.
|
||||
let _ = std::env::args().next().and_then(|argv0| {
|
||||
let base = std::path::Path::new(&argv0)
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("");
|
||||
let trimmed = base
|
||||
.strip_suffix(std::env::consts::EXE_SUFFIX)
|
||||
.unwrap_or(base);
|
||||
if trimmed == "codew" {
|
||||
// No-op: the single `codewhale` binary handles both names.
|
||||
}
|
||||
None::<()>
|
||||
});
|
||||
|
||||
codewhale_cli::run_cli()
|
||||
}
|
||||
|
||||
+251
-302
@@ -40,7 +40,7 @@ pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Re
|
||||
let legacy_binary = is_legacy_binary(¤t_exe);
|
||||
ensure_supported_release_target(std::env::consts::OS, std::env::consts::ARCH)?;
|
||||
|
||||
let targets = update_targets_for_exe(¤t_exe);
|
||||
let plan = update_plan_for_exe(¤t_exe);
|
||||
let channel = ReleaseChannel::from_beta_flag(beta);
|
||||
let current_version = env!("CARGO_PKG_VERSION");
|
||||
let proxy = proxy_arg
|
||||
@@ -119,58 +119,55 @@ pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Re
|
||||
}
|
||||
};
|
||||
|
||||
// Step 3: Download and verify every colocated binary in the install.
|
||||
let mut downloads = Vec::new();
|
||||
for target in &targets {
|
||||
let asset = select_platform_asset(release, &target.asset_stem).with_context(|| {
|
||||
format!(
|
||||
"no asset found for platform {} in release {latest_tag}. \
|
||||
Available assets: {}",
|
||||
target.asset_stem,
|
||||
release
|
||||
.assets
|
||||
.iter()
|
||||
.map(|a| a.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})?;
|
||||
// Step 3: Download and verify the sole implementation binary once. The
|
||||
// installed `codew` and pre-0.9.5 `codewhale-tui` command paths are
|
||||
// compatibility names for these exact bytes, not separate release assets.
|
||||
let asset = select_platform_asset(release, &plan.asset_stem).with_context(|| {
|
||||
format!(
|
||||
"no asset found for platform {} in release {latest_tag}. \
|
||||
Available assets: {}",
|
||||
plan.asset_stem,
|
||||
release
|
||||
.assets
|
||||
.iter()
|
||||
.map(|a| a.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})?;
|
||||
|
||||
println!("Downloading {}...", asset.name);
|
||||
let bytes =
|
||||
download_url(&asset.browser_download_url, proxy.as_ref()).with_context(|| {
|
||||
format!(
|
||||
"failed to download {}\n{}",
|
||||
asset.name,
|
||||
update_network_fallback_hint()
|
||||
)
|
||||
})?;
|
||||
println!("Downloading {}...", asset.name);
|
||||
let bytes = download_url(&asset.browser_download_url, proxy.as_ref()).with_context(|| {
|
||||
format!(
|
||||
"failed to download {}\n{}",
|
||||
asset.name,
|
||||
update_network_fallback_hint()
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(checksums) = &checksum_manifest {
|
||||
let expected = checksums
|
||||
.get(&asset.name)
|
||||
.with_context(|| format!("checksum manifest is missing {}", asset.name))?;
|
||||
let actual = sha256_hex(&bytes);
|
||||
if !actual.eq_ignore_ascii_case(expected) {
|
||||
bail!(
|
||||
"SHA256 mismatch for {}!\n expected: {expected}\n actual: {actual}",
|
||||
asset.name
|
||||
);
|
||||
}
|
||||
if let Some(checksums) = &checksum_manifest {
|
||||
let expected = checksums
|
||||
.get(&asset.name)
|
||||
.with_context(|| format!("checksum manifest is missing {}", asset.name))?;
|
||||
let actual = sha256_hex(&bytes);
|
||||
if !actual.eq_ignore_ascii_case(expected) {
|
||||
bail!(
|
||||
"SHA256 mismatch for {}!\n expected: {expected}\n actual: {actual}",
|
||||
asset.name
|
||||
);
|
||||
}
|
||||
|
||||
preflight_downloaded_binary(&asset.name, &bytes)?;
|
||||
downloads.push((target.path.clone(), asset.name.clone(), bytes));
|
||||
}
|
||||
|
||||
preflight_downloaded_binary(&asset.name, &bytes)?;
|
||||
|
||||
if checksum_manifest.is_some() {
|
||||
println!("SHA256 checksum verified.");
|
||||
}
|
||||
|
||||
// Step 4: Replace binaries only after all downloads and the primary
|
||||
// Step 4: Replace command paths only after the download and the running
|
||||
// executable identity verify. The preflight happens before a colocated
|
||||
// sibling can change, then the primary is checked again just in time.
|
||||
replace_verified_downloads(&downloads, || {
|
||||
// compatibility path can change, then the identity is checked just in time.
|
||||
replace_verified_downloads(&plan.target_paths, &bytes, || {
|
||||
validate_primary_update_identity(&executable_identity)
|
||||
})?;
|
||||
|
||||
@@ -179,9 +176,9 @@ pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Re
|
||||
Updated binaries:\n{}\n\
|
||||
\n\
|
||||
Restart the application to use the new version.",
|
||||
downloads
|
||||
plan.target_paths
|
||||
.iter()
|
||||
.map(|(path, asset, _)| format!(" - {} ({asset})", path.display()))
|
||||
.map(|path| format!(" - {} ({})", path.display(), asset.name))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
@@ -524,7 +521,8 @@ fn validate_primary_update_identity(identity: &UpdateExecutableIdentity) -> Resu
|
||||
}
|
||||
|
||||
fn replace_verified_downloads<F>(
|
||||
downloads: &[(PathBuf, String, Vec<u8>)],
|
||||
target_paths: &[PathBuf],
|
||||
verified_bytes: &[u8],
|
||||
validate_primary_identity: F,
|
||||
) -> Result<()>
|
||||
where
|
||||
@@ -533,11 +531,12 @@ where
|
||||
// Fail before mutating a sibling if the primary pathname no longer names
|
||||
// the process image that initiated this update.
|
||||
validate_primary_identity()?;
|
||||
for (path, _, bytes) in downloads.iter().rev() {
|
||||
replace_binary_with_validation(path, bytes, || {
|
||||
for path in target_paths.iter().rev() {
|
||||
replace_binary_with_validation(path, verified_bytes, || {
|
||||
// Re-check after each temp file is fully staged and immediately
|
||||
// before every destructive rename. This protects paired installs
|
||||
// before the sibling as well as just in time for the primary.
|
||||
// before every destructive rename. The running command is first
|
||||
// in the plan and therefore replaced last, after its colocated
|
||||
// compatibility names have received the same verified bytes.
|
||||
validate_primary_identity()
|
||||
})?;
|
||||
}
|
||||
@@ -628,10 +627,11 @@ fn legacy_binary_message(current_exe: &Path) -> String {
|
||||
"\
|
||||
this binary ({exe}) is using the legacy deepseek/deepseek-tui command name.
|
||||
|
||||
The package has been renamed to `codewhale`. This update will install canonical
|
||||
Codewhale binaries (`codewhale` and, when present, `codewhale-tui`) beside the
|
||||
legacy command when the install directory is writable. DeepSeek provider support
|
||||
is unchanged.
|
||||
The package has been renamed to `codewhale`. This update will install the
|
||||
canonical `codewhale` command and refresh any existing `codew` or
|
||||
`codewhale-tui` compatibility command from the same binary beside the legacy
|
||||
command when the install directory is writable.
|
||||
DeepSeek provider support is unchanged.
|
||||
|
||||
If this update cannot write to the install directory, reinstall using your
|
||||
original install method:
|
||||
@@ -644,13 +644,12 @@ original install method:
|
||||
cargo uninstall deepseek-tui-cli 2>/dev/null || true
|
||||
cargo uninstall deepseek-tui 2>/dev/null || true
|
||||
cargo install codewhale-cli --locked
|
||||
cargo install codewhale-tui --locked
|
||||
|
||||
Homebrew:
|
||||
brew upgrade deepseek-tui
|
||||
|
||||
Manual binary:
|
||||
download the matched codewhale and codewhale-tui assets from
|
||||
download the matched codewhale asset from
|
||||
https://github.com/Hmbown/CodeWhale/releases/latest
|
||||
|
||||
Once `codewhale` is on your PATH, run `codewhale update` for future updates.",
|
||||
@@ -658,96 +657,79 @@ Once `codewhale` is on your PATH, run `codewhale update` for future updates.",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn binary_prefix_for_exe(current_exe: &Path) -> &'static str {
|
||||
fn command_name_for_exe(current_exe: &Path) -> String {
|
||||
let exe_name = current_exe
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("codewhale")
|
||||
.to_ascii_lowercase();
|
||||
if exe_name.contains("codewhale-tui") || exe_name.contains("deepseek-tui") {
|
||||
"codewhale-tui"
|
||||
} else {
|
||||
"codewhale"
|
||||
}
|
||||
exe_name
|
||||
.strip_suffix(".exe")
|
||||
.unwrap_or(&exe_name)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn sibling_prefix_for(prefix: &str) -> &'static str {
|
||||
if prefix == "codewhale-tui" {
|
||||
"codewhale"
|
||||
} else {
|
||||
"codewhale-tui"
|
||||
}
|
||||
fn command_path_beside(current_exe: &Path, command: &str) -> PathBuf {
|
||||
current_exe.with_file_name(format!("{command}{}", std::env::consts::EXE_SUFFIX))
|
||||
}
|
||||
|
||||
fn sibling_binary_path(current_exe: &Path, sibling_prefix: &str) -> PathBuf {
|
||||
current_exe.with_file_name(format!("{sibling_prefix}{}", std::env::consts::EXE_SUFFIX))
|
||||
}
|
||||
|
||||
fn canonical_binary_path_for_prefix(current_exe: &Path, prefix: &str) -> PathBuf {
|
||||
if is_legacy_binary(current_exe) {
|
||||
current_exe.with_file_name(format!("{prefix}{}", std::env::consts::EXE_SUFFIX))
|
||||
} else {
|
||||
fn installed_command_path(current_exe: &Path, command: &str) -> PathBuf {
|
||||
if command_name_for_exe(current_exe) == command {
|
||||
current_exe.to_path_buf()
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_binary_name_for_prefix(prefix: &str) -> &'static str {
|
||||
if prefix == "codewhale-tui" {
|
||||
"deepseek-tui"
|
||||
} else {
|
||||
"deepseek"
|
||||
command_path_beside(current_exe, command)
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_sibling_binary_path(current_exe: &Path, sibling_prefix: &str) -> PathBuf {
|
||||
current_exe.with_file_name(format!(
|
||||
"{}{}",
|
||||
legacy_binary_name_for_prefix(sibling_prefix),
|
||||
std::env::consts::EXE_SUFFIX
|
||||
))
|
||||
fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
|
||||
if !paths.iter().any(|existing| existing == &path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn should_update_sibling(
|
||||
current_exe: &Path,
|
||||
canonical_sibling: &Path,
|
||||
sibling_prefix: &str,
|
||||
) -> bool {
|
||||
canonical_sibling.exists()
|
||||
|| (is_legacy_binary(current_exe)
|
||||
&& legacy_sibling_binary_path(current_exe, sibling_prefix).exists())
|
||||
fn legacy_tui_command_exists_beside(current_exe: &Path) -> bool {
|
||||
command_name_for_exe(current_exe) == "deepseek-tui"
|
||||
|| command_path_beside(current_exe, "deepseek-tui").exists()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct UpdateTarget {
|
||||
path: PathBuf,
|
||||
struct UpdatePlan {
|
||||
target_paths: Vec<PathBuf>,
|
||||
asset_stem: String,
|
||||
}
|
||||
|
||||
fn update_targets_for_exe(current_exe: &Path) -> Vec<UpdateTarget> {
|
||||
let current_prefix = binary_prefix_for_exe(current_exe);
|
||||
let mut targets = vec![UpdateTarget {
|
||||
path: canonical_binary_path_for_prefix(current_exe, current_prefix),
|
||||
fn update_plan_for_exe(current_exe: &Path) -> UpdatePlan {
|
||||
let mut target_paths = Vec::new();
|
||||
|
||||
// Keep the process image first so reverse-order replacement updates the
|
||||
// command currently running the updater last. Pre-rebrand command names
|
||||
// retain their historical migration behavior: install canonical commands
|
||||
// beside them instead of overwriting the legacy path.
|
||||
if !is_legacy_binary(current_exe) {
|
||||
push_unique_path(&mut target_paths, current_exe.to_path_buf());
|
||||
}
|
||||
|
||||
let primary = installed_command_path(current_exe, "codewhale");
|
||||
push_unique_path(&mut target_paths, primary);
|
||||
|
||||
for alias in ["codew", "codewhale-tui"] {
|
||||
let alias_path = installed_command_path(current_exe, alias);
|
||||
let migrate_legacy_tui = alias == "codewhale-tui"
|
||||
&& is_legacy_binary(current_exe)
|
||||
&& legacy_tui_command_exists_beside(current_exe);
|
||||
if alias_path.exists() || command_name_for_exe(current_exe) == alias || migrate_legacy_tui {
|
||||
push_unique_path(&mut target_paths, alias_path);
|
||||
}
|
||||
}
|
||||
|
||||
UpdatePlan {
|
||||
target_paths,
|
||||
asset_stem: release_asset_stem_for_prefix(
|
||||
current_prefix,
|
||||
"codewhale",
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
),
|
||||
}];
|
||||
|
||||
let sibling_prefix = sibling_prefix_for(current_prefix);
|
||||
let sibling = sibling_binary_path(current_exe, sibling_prefix);
|
||||
if should_update_sibling(current_exe, &sibling, sibling_prefix) {
|
||||
targets.push(UpdateTarget {
|
||||
path: sibling,
|
||||
asset_stem: release_asset_stem_for_prefix(
|
||||
sibling_prefix,
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
targets
|
||||
}
|
||||
|
||||
fn release_asset_stem_for_prefix(prefix: &str, os: &str, rust_arch: &str) -> String {
|
||||
@@ -766,8 +748,8 @@ fn release_asset_name_for_prefix(prefix: &str, os: &str, rust_arch: &str) -> Str
|
||||
|
||||
#[cfg(test)]
|
||||
fn release_asset_stem_for(current_exe: &Path, os: &str, rust_arch: &str) -> String {
|
||||
let prefix = binary_prefix_for_exe(current_exe);
|
||||
release_asset_stem_for_prefix(prefix, os, rust_arch)
|
||||
let _ = current_exe;
|
||||
release_asset_stem_for_prefix("codewhale", os, rust_arch)
|
||||
}
|
||||
|
||||
pub(crate) fn asset_matches_platform(asset_name: &str, binary_name: &str) -> bool {
|
||||
@@ -954,13 +936,11 @@ fn release_from_asset_base_url(
|
||||
browser_download_url: mirror_asset_url(base_url, CHECKSUM_MANIFEST_ASSET),
|
||||
}];
|
||||
|
||||
for prefix in ["codewhale", "codewhale-tui"] {
|
||||
let name = release_asset_name_for_prefix(prefix, os, rust_arch);
|
||||
assets.push(Asset {
|
||||
browser_download_url: mirror_asset_url(base_url, &name),
|
||||
name,
|
||||
});
|
||||
}
|
||||
let name = release_asset_name_for_prefix("codewhale", os, rust_arch);
|
||||
assets.push(Asset {
|
||||
browser_download_url: mirror_asset_url(base_url, &name),
|
||||
name,
|
||||
});
|
||||
|
||||
Release {
|
||||
tag_name: tag_name.to_string(),
|
||||
@@ -1341,7 +1321,6 @@ Official Linux release binaries are GNU libc builds. Ubuntu 22.04 ships glibc
|
||||
Install from source on this host instead:
|
||||
|
||||
cargo install codewhale-cli --locked
|
||||
cargo install codewhale-tui --locked
|
||||
|
||||
Release engineering follow-up: build Linux GNU assets against an older glibc
|
||||
baseline, or add a musl/static Linux asset. Set CODEWHALE_SKIP_GLIBC_CHECK=1 to
|
||||
@@ -1527,7 +1506,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolved, executable.canonicalize().unwrap());
|
||||
assert_eq!(update_targets_for_exe(&resolved)[0].path, resolved);
|
||||
assert_eq!(update_plan_for_exe(&resolved).target_paths[0], resolved);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -1550,10 +1529,7 @@ mod tests {
|
||||
|
||||
let resolved =
|
||||
resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &invoked).unwrap();
|
||||
let target_paths = update_targets_for_exe(&resolved)
|
||||
.into_iter()
|
||||
.map(|target| target.path)
|
||||
.collect::<Vec<_>>();
|
||||
let target_paths = update_plan_for_exe(&resolved).target_paths;
|
||||
|
||||
assert_eq!(
|
||||
target_paths,
|
||||
@@ -1813,19 +1789,8 @@ mod tests {
|
||||
std::fs::write(&swapped_primary, b"externally swapped primary").unwrap();
|
||||
std::fs::rename(&swapped_primary, &primary).unwrap();
|
||||
|
||||
let downloads = vec![
|
||||
(
|
||||
primary.clone(),
|
||||
"codewhale-android-arm64".to_string(),
|
||||
b"downloaded primary".to_vec(),
|
||||
),
|
||||
(
|
||||
sibling.clone(),
|
||||
"codewhale-tui-android-arm64".to_string(),
|
||||
b"downloaded sibling".to_vec(),
|
||||
),
|
||||
];
|
||||
let error = replace_verified_downloads(&downloads, || {
|
||||
let target_paths = vec![primary.clone(), sibling.clone()];
|
||||
let error = replace_verified_downloads(&target_paths, b"downloaded binary", || {
|
||||
resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &primary)
|
||||
.map(|_| ())
|
||||
})
|
||||
@@ -1859,20 +1824,9 @@ mod tests {
|
||||
write_test_executable(&swapped_primary);
|
||||
std::fs::write(&swapped_primary, b"externally swapped primary").unwrap();
|
||||
|
||||
let downloads = vec![
|
||||
(
|
||||
primary.clone(),
|
||||
"codewhale-android-arm64".to_string(),
|
||||
b"downloaded primary".to_vec(),
|
||||
),
|
||||
(
|
||||
sibling.clone(),
|
||||
"codewhale-tui-android-arm64".to_string(),
|
||||
b"downloaded sibling".to_vec(),
|
||||
),
|
||||
];
|
||||
let target_paths = vec![primary.clone(), sibling.clone()];
|
||||
let validation_calls = Cell::new(0);
|
||||
let error = replace_verified_downloads(&downloads, || {
|
||||
let error = replace_verified_downloads(&target_paths, b"downloaded binary", || {
|
||||
let call = validation_calls.get() + 1;
|
||||
validation_calls.set(call);
|
||||
if call == 1 {
|
||||
@@ -1910,13 +1864,9 @@ mod tests {
|
||||
write_test_executable(&swapped_primary);
|
||||
std::fs::write(&swapped_primary, b"externally swapped primary").unwrap();
|
||||
|
||||
let downloads = vec![(
|
||||
primary.clone(),
|
||||
"codewhale-android-arm64".to_string(),
|
||||
b"downloaded primary".to_vec(),
|
||||
)];
|
||||
let target_paths = vec![primary.clone()];
|
||||
let validation_calls = Cell::new(0);
|
||||
let error = replace_verified_downloads(&downloads, || {
|
||||
let error = replace_verified_downloads(&target_paths, b"downloaded binary", || {
|
||||
let call = validation_calls.get() + 1;
|
||||
validation_calls.set(call);
|
||||
if call == 1 {
|
||||
@@ -1949,58 +1899,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify binary prefix detection for dispatcher vs TUI binary.
|
||||
/// Every command name resolves to the sole implementation asset.
|
||||
#[test]
|
||||
fn test_binary_prefix_detection() {
|
||||
// TUI binary should use codewhale-tui prefix
|
||||
assert_eq!(
|
||||
binary_prefix_for_exe(Path::new("codewhale-tui")),
|
||||
"codewhale-tui"
|
||||
);
|
||||
assert_eq!(
|
||||
binary_prefix_for_exe(Path::new("codewhale-tui.exe")),
|
||||
"codewhale-tui"
|
||||
);
|
||||
assert_eq!(
|
||||
binary_prefix_for_exe(Path::new("CodeWhale-TUI.exe")),
|
||||
"codewhale-tui"
|
||||
);
|
||||
assert_eq!(
|
||||
binary_prefix_for_exe(Path::new("/usr/local/bin/codewhale-tui")),
|
||||
"codewhale-tui"
|
||||
);
|
||||
|
||||
// Dispatcher binary should use codewhale prefix
|
||||
assert_eq!(binary_prefix_for_exe(Path::new("codewhale")), "codewhale");
|
||||
assert_eq!(
|
||||
binary_prefix_for_exe(Path::new("codewhale.exe")),
|
||||
"codewhale"
|
||||
);
|
||||
assert_eq!(
|
||||
binary_prefix_for_exe(Path::new("/usr/local/bin/codewhale")),
|
||||
"codewhale"
|
||||
);
|
||||
|
||||
// Fallback for unknown names
|
||||
assert_eq!(
|
||||
binary_prefix_for_exe(Path::new("other-binary")),
|
||||
"codewhale"
|
||||
);
|
||||
|
||||
// Legacy names still map to the canonical update asset prefixes.
|
||||
assert_eq!(
|
||||
binary_prefix_for_exe(Path::new("deepseek-tui")),
|
||||
"codewhale-tui"
|
||||
);
|
||||
assert_eq!(
|
||||
binary_prefix_for_exe(Path::new("/usr/local/bin/deepseek-tui")),
|
||||
"codewhale-tui"
|
||||
);
|
||||
assert_eq!(
|
||||
binary_prefix_for_exe(Path::new("DeepSeek-TUI.exe")),
|
||||
"codewhale-tui"
|
||||
);
|
||||
assert_eq!(binary_prefix_for_exe(Path::new("deepseek")), "codewhale");
|
||||
fn every_invocation_name_uses_codewhale_release_asset() {
|
||||
for command in [
|
||||
"codewhale",
|
||||
"codewhale.exe",
|
||||
"codew",
|
||||
"codew.exe",
|
||||
"codewhale-tui",
|
||||
"CodeWhale-TUI.exe",
|
||||
"deepseek",
|
||||
"deepseek-tui",
|
||||
"other-binary",
|
||||
] {
|
||||
assert_eq!(
|
||||
release_asset_stem_for(Path::new(command), "macos", "aarch64"),
|
||||
"codewhale-macos-arm64"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2037,7 +1954,7 @@ mod tests {
|
||||
let message = legacy_binary_message(Path::new("/usr/local/bin/deepseek-tui"));
|
||||
|
||||
assert!(message.contains("legacy deepseek/deepseek-tui command name"));
|
||||
assert!(message.contains("install canonical"));
|
||||
assert!(message.contains("canonical `codewhale` command"));
|
||||
assert!(message.contains("DeepSeek provider support"));
|
||||
assert!(message.contains("is unchanged"));
|
||||
assert!(message.contains("npm uninstall -g deepseek-tui"));
|
||||
@@ -2045,13 +1962,13 @@ mod tests {
|
||||
assert!(message.contains("cargo uninstall deepseek-tui-cli 2>/dev/null || true"));
|
||||
assert!(message.contains("cargo uninstall deepseek-tui 2>/dev/null || true"));
|
||||
assert!(message.contains("cargo install codewhale-cli --locked"));
|
||||
assert!(message.contains("cargo install codewhale-tui --locked"));
|
||||
assert!(!message.contains("cargo install codewhale-tui --locked"));
|
||||
assert!(message.contains("brew upgrade deepseek-tui"));
|
||||
assert!(message.contains("https://github.com/Hmbown/CodeWhale/releases/latest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_dispatcher_update_targets_canonical_codewhale_pair() {
|
||||
fn legacy_dispatcher_update_targets_canonical_compatibility_commands() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dispatcher = dir
|
||||
.path()
|
||||
@@ -2062,14 +1979,10 @@ mod tests {
|
||||
std::fs::write(&dispatcher, b"legacy dispatcher").unwrap();
|
||||
std::fs::write(&tui, b"legacy tui").unwrap();
|
||||
|
||||
let targets = update_targets_for_exe(&dispatcher);
|
||||
let paths = targets
|
||||
.iter()
|
||||
.map(|target| target.path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let plan = update_plan_for_exe(&dispatcher);
|
||||
|
||||
assert_eq!(
|
||||
paths,
|
||||
plan.target_paths,
|
||||
vec![
|
||||
dir.path()
|
||||
.join(format!("codewhale{}", std::env::consts::EXE_SUFFIX)),
|
||||
@@ -2077,12 +1990,12 @@ mod tests {
|
||||
.join(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX))
|
||||
]
|
||||
);
|
||||
assert!(targets[0].asset_stem.starts_with("codewhale-"));
|
||||
assert!(targets[1].asset_stem.starts_with("codewhale-tui-"));
|
||||
assert!(plan.asset_stem.starts_with("codewhale-"));
|
||||
assert!(!plan.asset_stem.starts_with("codewhale-tui-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_tui_update_targets_canonical_tui_pair() {
|
||||
fn legacy_tui_update_targets_canonical_compatibility_commands() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dispatcher = dir
|
||||
.path()
|
||||
@@ -2093,23 +2006,19 @@ mod tests {
|
||||
std::fs::write(&dispatcher, b"legacy dispatcher").unwrap();
|
||||
std::fs::write(&tui, b"legacy tui").unwrap();
|
||||
|
||||
let targets = update_targets_for_exe(&tui);
|
||||
let paths = targets
|
||||
.iter()
|
||||
.map(|target| target.path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let plan = update_plan_for_exe(&tui);
|
||||
|
||||
assert_eq!(
|
||||
paths,
|
||||
plan.target_paths,
|
||||
vec![
|
||||
dir.path()
|
||||
.join(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX)),
|
||||
.join(format!("codewhale{}", std::env::consts::EXE_SUFFIX)),
|
||||
dir.path()
|
||||
.join(format!("codewhale{}", std::env::consts::EXE_SUFFIX))
|
||||
.join(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX))
|
||||
]
|
||||
);
|
||||
assert!(targets[0].asset_stem.starts_with("codewhale-tui-"));
|
||||
assert!(targets[1].asset_stem.starts_with("codewhale-"));
|
||||
assert!(plan.asset_stem.starts_with("codewhale-"));
|
||||
assert!(!plan.asset_stem.starts_with("codewhale-tui-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2120,18 +2029,8 @@ mod tests {
|
||||
("codewhale", "linux", "x86_64", "codewhale-linux-x64"),
|
||||
("codewhale", "windows", "x86_64", "codewhale-windows-x64"),
|
||||
("codewhale", "windows", "aarch64", "codewhale-windows-arm64"),
|
||||
(
|
||||
"codewhale-tui",
|
||||
"macos",
|
||||
"aarch64",
|
||||
"codewhale-tui-macos-arm64",
|
||||
),
|
||||
(
|
||||
"codewhale-tui",
|
||||
"linux",
|
||||
"x86_64",
|
||||
"codewhale-tui-linux-x64",
|
||||
),
|
||||
("codew", "macos", "aarch64", "codewhale-macos-arm64"),
|
||||
("codewhale-tui", "linux", "x86_64", "codewhale-linux-x64"),
|
||||
];
|
||||
|
||||
for (exe, os, arch, expected) in cases {
|
||||
@@ -2140,7 +2039,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_targets_include_existing_sibling_tui_for_dispatcher() {
|
||||
fn update_plan_includes_existing_compatibility_tui_for_primary() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dispatcher = dir
|
||||
.path()
|
||||
@@ -2151,30 +2050,94 @@ mod tests {
|
||||
std::fs::write(&dispatcher, b"dispatcher").unwrap();
|
||||
std::fs::write(&tui, b"tui").unwrap();
|
||||
|
||||
let targets = update_targets_for_exe(&dispatcher);
|
||||
let paths = targets
|
||||
let plan = update_plan_for_exe(&dispatcher);
|
||||
let paths = plan
|
||||
.target_paths
|
||||
.iter()
|
||||
.map(|target| target.path.as_path())
|
||||
.map(PathBuf::as_path)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(paths, vec![dispatcher.as_path(), tui.as_path()]);
|
||||
assert!(targets[0].asset_stem.starts_with("codewhale-"));
|
||||
assert!(targets[1].asset_stem.starts_with("codewhale-tui-"));
|
||||
assert!(plan.asset_stem.starts_with("codewhale-"));
|
||||
assert!(!plan.asset_stem.starts_with("codewhale-tui-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_targets_skip_missing_sibling() {
|
||||
fn update_plan_skips_missing_compatibility_commands() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dispatcher = dir
|
||||
.path()
|
||||
.join(format!("codewhale{}", std::env::consts::EXE_SUFFIX));
|
||||
std::fs::write(&dispatcher, b"dispatcher").unwrap();
|
||||
|
||||
let targets = update_targets_for_exe(&dispatcher);
|
||||
let plan = update_plan_for_exe(&dispatcher);
|
||||
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert_eq!(targets[0].path, dispatcher);
|
||||
assert!(targets[0].asset_stem.starts_with("codewhale-"));
|
||||
assert_eq!(plan.target_paths, vec![dispatcher]);
|
||||
assert!(plan.asset_stem.starts_with("codewhale-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v094_three_command_install_updates_every_path_from_primary_bytes() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let primary = dir
|
||||
.path()
|
||||
.join(format!("codewhale{}", std::env::consts::EXE_SUFFIX));
|
||||
let codew = dir
|
||||
.path()
|
||||
.join(format!("codew{}", std::env::consts::EXE_SUFFIX));
|
||||
let legacy_tui = dir
|
||||
.path()
|
||||
.join(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
|
||||
for path in [&primary, &codew, &legacy_tui] {
|
||||
std::fs::write(path, b"v0.9.4 old bytes").unwrap();
|
||||
}
|
||||
|
||||
let plan = update_plan_for_exe(&primary);
|
||||
assert_eq!(
|
||||
plan.target_paths,
|
||||
vec![primary.clone(), codew.clone(), legacy_tui.clone()]
|
||||
);
|
||||
assert!(plan.asset_stem.starts_with("codewhale-"));
|
||||
assert!(!plan.asset_stem.contains("codewhale-tui"));
|
||||
|
||||
replace_verified_downloads(&plan.target_paths, b"v0.9.5 primary bytes", || Ok(())).unwrap();
|
||||
|
||||
for path in [&primary, &codew, &legacy_tui] {
|
||||
assert_eq!(std::fs::read(path).unwrap(), b"v0.9.5 primary bytes");
|
||||
}
|
||||
assert_ne!(std::fs::read(codew).unwrap(), b"v0.9.4 old bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_alias_invocation_keeps_running_path_first_and_updates_primary() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let primary = dir
|
||||
.path()
|
||||
.join(format!("codewhale{}", std::env::consts::EXE_SUFFIX));
|
||||
let codew = dir
|
||||
.path()
|
||||
.join(format!("codew{}", std::env::consts::EXE_SUFFIX));
|
||||
let legacy_tui = dir
|
||||
.path()
|
||||
.join(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
|
||||
for invoked in [&codew, &legacy_tui] {
|
||||
for path in [&primary, &codew, &legacy_tui] {
|
||||
std::fs::write(path, b"old").unwrap();
|
||||
}
|
||||
let plan = update_plan_for_exe(invoked);
|
||||
assert_eq!(plan.target_paths.first(), Some(invoked));
|
||||
assert!(plan.target_paths.contains(&primary));
|
||||
assert!(plan.target_paths.contains(&codew));
|
||||
assert!(plan.target_paths.contains(&legacy_tui));
|
||||
assert!(plan.asset_stem.starts_with("codewhale-"));
|
||||
assert!(!plan.asset_stem.starts_with("codewhale-tui-"));
|
||||
|
||||
replace_verified_downloads(&plan.target_paths, b"new primary bytes", || Ok(()))
|
||||
.unwrap();
|
||||
for path in [&primary, &codew, &legacy_tui] {
|
||||
assert_eq!(std::fs::read(path).unwrap(), b"new primary bytes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2366,10 +2329,9 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win
|
||||
assert_eq!(content, "fresh binary");
|
||||
}
|
||||
|
||||
/// Mocked GitHub release payload covering both the dispatcher (`codewhale`)
|
||||
/// and the legacy TUI (`codewhale-tui`) binaries across our published
|
||||
/// platform/arch matrix, plus a checksum sibling that must never be picked
|
||||
/// as the primary binary.
|
||||
/// Mocked GitHub release payload covering the sole implementation binary
|
||||
/// across the published platform/arch matrix, plus a checksum sibling that
|
||||
/// must never be picked as the binary.
|
||||
fn mocked_release() -> Release {
|
||||
let json = r#"{
|
||||
"tag_name": "v0.8.8",
|
||||
@@ -2379,12 +2341,7 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win
|
||||
{ "name": "codewhale-macos-arm64", "browser_download_url": "https://example.invalid/codewhale-macos-arm64" },
|
||||
{ "name": "codewhale-windows-x64.exe", "browser_download_url": "https://example.invalid/codewhale-windows-x64.exe" },
|
||||
{ "name": "codewhale-windows-x64.exe.sha256", "browser_download_url": "https://example.invalid/codewhale-windows-x64.exe.sha256" },
|
||||
{ "name": "codewhale-windows-arm64.exe", "browser_download_url": "https://example.invalid/codewhale-windows-arm64.exe" },
|
||||
{ "name": "codewhale-tui-linux-x64", "browser_download_url": "https://example.invalid/codewhale-tui-linux-x64" },
|
||||
{ "name": "codewhale-tui-macos-x64", "browser_download_url": "https://example.invalid/codewhale-tui-macos-x64" },
|
||||
{ "name": "codewhale-tui-macos-arm64", "browser_download_url": "https://example.invalid/codewhale-tui-macos-arm64" },
|
||||
{ "name": "codewhale-tui-windows-x64.exe","browser_download_url": "https://example.invalid/codewhale-tui-windows-x64.exe" },
|
||||
{ "name": "codewhale-tui-windows-arm64.exe","browser_download_url": "https://example.invalid/codewhale-tui-windows-arm64.exe" }
|
||||
{ "name": "codewhale-windows-arm64.exe", "browser_download_url": "https://example.invalid/codewhale-windows-arm64.exe" }
|
||||
]
|
||||
}"#;
|
||||
serde_json::from_str(json).expect("mock release JSON")
|
||||
@@ -2410,40 +2367,38 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mocked_release_selects_tui_asset_when_tui_binary_invokes_update() {
|
||||
fn mocked_release_selects_primary_asset_when_compatibility_alias_invokes_update() {
|
||||
let release = mocked_release();
|
||||
let stem = release_asset_stem_for(
|
||||
Path::new("/usr/local/bin/codewhale-tui"),
|
||||
"macos",
|
||||
"aarch64",
|
||||
);
|
||||
let asset = select_platform_asset(&release, &stem).expect("TUI platform asset");
|
||||
assert_eq!(asset.name, "codewhale-tui-macos-arm64");
|
||||
let asset = select_platform_asset(&release, &stem).expect("primary platform asset");
|
||||
assert_eq!(asset.name, "codewhale-macos-arm64");
|
||||
|
||||
let windows_stem =
|
||||
release_asset_stem_for(Path::new("C:\\codewhale-tui.exe"), "windows", "aarch64");
|
||||
let windows_stem = release_asset_stem_for(Path::new("C:\\codew.exe"), "windows", "aarch64");
|
||||
let windows_asset =
|
||||
select_platform_asset(&release, &windows_stem).expect("Windows ARM64 TUI asset");
|
||||
assert_eq!(windows_asset.name, "codewhale-tui-windows-arm64.exe");
|
||||
select_platform_asset(&release, &windows_stem).expect("Windows ARM64 primary asset");
|
||||
assert_eq!(windows_asset.name, "codewhale-windows-arm64.exe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn android_arm64_maps_to_android_release_assets() {
|
||||
// The generic format!("{prefix}-{os}-{arch}") path naturally produces
|
||||
// Android asset stems. Verify the full stem for both dispatcher and TUI
|
||||
// binaries so `codewhale update` on Termux requests Android assets, not
|
||||
// linux-arm64 (#4241).
|
||||
// Android asset stems. Verify every supported command name resolves to
|
||||
// the primary Android asset, never Linux or a removed TUI asset (#4241).
|
||||
assert_eq!(
|
||||
release_asset_stem_for_prefix("codewhale", "android", "aarch64"),
|
||||
"codewhale-android-arm64"
|
||||
);
|
||||
assert_eq!(
|
||||
release_asset_stem_for_prefix("codewhale-tui", "android", "aarch64"),
|
||||
"codewhale-tui-android-arm64"
|
||||
release_asset_stem_for(Path::new("codewhale-tui"), "android", "aarch64"),
|
||||
"codewhale-android-arm64"
|
||||
);
|
||||
assert_eq!(
|
||||
release_asset_stem_for_prefix("codew", "android", "aarch64"),
|
||||
"codew-android-arm64"
|
||||
release_asset_stem_for(Path::new("codew"), "android", "aarch64"),
|
||||
"codewhale-android-arm64"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2485,10 +2440,10 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win
|
||||
dispatcher.browser_download_url,
|
||||
"https://mirror.example/releases/v0.8.36/codewhale-linux-x64"
|
||||
);
|
||||
let tui = select_platform_asset(&release, "codewhale-tui-linux-x64").expect("tui asset");
|
||||
assert_eq!(
|
||||
tui.browser_download_url,
|
||||
"https://mirror.example/releases/v0.8.36/codewhale-tui-linux-x64"
|
||||
assert_eq!(release.assets.len(), 2);
|
||||
assert!(
|
||||
select_platform_asset(&release, "codewhale-tui-linux-x64").is_none(),
|
||||
"mirror fallback must not synthesize a removed TUI asset"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2506,10 +2461,7 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win
|
||||
select_platform_asset(&release, "codewhale-windows-x64")
|
||||
.is_some_and(|asset| asset.name == "codewhale-windows-x64.exe")
|
||||
);
|
||||
assert!(
|
||||
select_platform_asset(&release, "codewhale-tui-windows-x64")
|
||||
.is_some_and(|asset| asset.name == "codewhale-tui-windows-x64.exe")
|
||||
);
|
||||
assert!(select_platform_asset(&release, "codewhale-tui-windows-x64").is_none());
|
||||
|
||||
let arm_release = release_from_mirror_base_url(
|
||||
"https://mirror.example/releases/v0.9.1",
|
||||
@@ -2549,11 +2501,8 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win
|
||||
dispatcher.browser_download_url,
|
||||
"https://github.com/Hmbown/CodeWhale/releases/download/v0.8.61/codewhale-macos-arm64"
|
||||
);
|
||||
let tui = select_platform_asset(&release, "codewhale-tui-macos-arm64").expect("tui asset");
|
||||
assert_eq!(
|
||||
tui.browser_download_url,
|
||||
"https://github.com/Hmbown/CodeWhale/releases/download/v0.8.61/codewhale-tui-macos-arm64"
|
||||
);
|
||||
assert_eq!(release.assets.len(), 2);
|
||||
assert!(select_platform_asset(&release, "codewhale-tui-macos-arm64").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2590,11 +2539,11 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win
|
||||
fn cnb_release_base_url_includes_tag_directory() {
|
||||
assert_eq!(
|
||||
codewhale_release::cnb_release_base_url("0.8.47"),
|
||||
"https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
|
||||
"https://cnb.cool/codewhale.net/codewhale/-/releases/download/v0.8.47"
|
||||
);
|
||||
assert_eq!(
|
||||
codewhale_release::cnb_release_base_url("v0.8.47"),
|
||||
"https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
|
||||
"https://cnb.cool/codewhale.net/codewhale/-/releases/download/v0.8.47"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2668,7 +2617,7 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win
|
||||
"{hint}"
|
||||
);
|
||||
assert!(hint.contains("codewhale-cli"), "{hint}");
|
||||
assert!(hint.contains("codewhale-tui --locked"), "{hint}");
|
||||
assert!(!hint.contains("codewhale-tui --locked"), "{hint}");
|
||||
}
|
||||
|
||||
fn serve_http_responses(
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
//! The facade must not migrate secrets before it delegates static diagnostics.
|
||||
//! Diagnostic dispatch (`doctor`, `setup --status`) runs the real in-process
|
||||
//! TUI entry via `run_tui_in_process` — the single `codewhale` binary calls
|
||||
//! `codewhale_tui::run` directly, so there is no sibling TUI binary to delegate
|
||||
//! to anymore (#5259 single-binary argv0 dispatch). These invariants stay: the
|
||||
//! dispatcher must not migrate legacy secrets, must not rewrite legacy
|
||||
//! settings, and must not create any state under a sealed HOME when running a
|
||||
//! read-only diagnostic. `doctor --context-json` must still emit a
|
||||
//! machine-readable context source map (`{"entries":[...]}`).
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
@@ -11,20 +17,14 @@ use codewhale_secrets::{FileKeyringStore, KeyringStore};
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn dispatcher_diagnostics_leave_legacy_secret_state_unchanged() {
|
||||
for (args, expected_tui_args, expects_json) in [
|
||||
(&["doctor"][..], &["doctor"][..], false),
|
||||
(&["doctor", "--json"][..], &["doctor", "--json"][..], false),
|
||||
(
|
||||
&["doctor", "--context-json"][..],
|
||||
&["doctor", "--context-json"][..],
|
||||
true,
|
||||
),
|
||||
(
|
||||
&["setup", "--status"][..],
|
||||
&["setup", "--status"][..],
|
||||
false,
|
||||
),
|
||||
fn dispatcher_diagnostics_are_in_process_and_read_only() {
|
||||
// (cli args, whether stdout must be a JSON object carrying an `entries`
|
||||
// array). Only `doctor --context-json` carries the context source map.
|
||||
for (args, expects_entries_json) in [
|
||||
(&["doctor"][..], false),
|
||||
(&["doctor", "--json"][..], false),
|
||||
(&["doctor", "--context-json"][..], true),
|
||||
(&["setup", "--status"][..], false),
|
||||
] {
|
||||
let fixture = TempDir::new().expect("fixture root");
|
||||
let sealed_home = fixture.path().join("sealed-home");
|
||||
@@ -43,19 +43,10 @@ fn dispatcher_diagnostics_leave_legacy_secret_state_unchanged() {
|
||||
let before_paths = relative_paths(&sealed_home);
|
||||
let before_legacy = fs::read(&legacy).expect("read synthetic legacy store");
|
||||
|
||||
let receipt = fixture.path().join("delegated-args.txt");
|
||||
let fake_tui = fixture.path().join("fake-codewhale-tui");
|
||||
fs::write(
|
||||
&fake_tui,
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$DIAGNOSTIC_DISPATCH_RECEIPT\"\nif [ \"$1\" = doctor ] && [ \"$2\" = --context-json ]; then\n printf '%s\\n' '{\"entries\":[]}'\nfi\n",
|
||||
)
|
||||
.expect("write fake TUI");
|
||||
let mut permissions = fs::metadata(&fake_tui)
|
||||
.expect("fake TUI metadata")
|
||||
.permissions();
|
||||
permissions.set_mode(0o700);
|
||||
fs::set_permissions(&fake_tui, permissions).expect("make fake TUI executable");
|
||||
|
||||
// The diagnostic runs entirely in-process: the single `codewhale` binary
|
||||
// dispatches through `run_tui_in_process` -> `codewhale_tui::run`. No
|
||||
// `DEEPSEEK_TUI_BIN` sibling is spawned, so there is no receipt to read;
|
||||
// assert the in-process behavior and the read-only invariants instead.
|
||||
let output = Command::new(codewhale_binary())
|
||||
.args(args)
|
||||
.env_clear()
|
||||
@@ -63,8 +54,6 @@ fn dispatcher_diagnostics_leave_legacy_secret_state_unchanged() {
|
||||
.env("USERPROFILE", &sealed_home)
|
||||
.env("CODEWHALE_HOME", &codewhale_home)
|
||||
.env("CODEWHALE_SECRET_BACKEND", "file")
|
||||
.env("DEEPSEEK_TUI_BIN", &fake_tui)
|
||||
.env("DIAGNOSTIC_DISPATCH_RECEIPT", &receipt)
|
||||
.output()
|
||||
.expect("run dispatcher diagnostic");
|
||||
|
||||
@@ -74,29 +63,23 @@ fn dispatcher_diagnostics_leave_legacy_secret_state_unchanged() {
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(&receipt)
|
||||
.expect("fake TUI receipt")
|
||||
.lines()
|
||||
.collect::<Vec<_>>(),
|
||||
expected_tui_args,
|
||||
"dispatcher must preserve the diagnostic command shape"
|
||||
);
|
||||
if expects_json {
|
||||
|
||||
if expects_entries_json {
|
||||
let report: serde_json::Value = serde_json::from_slice(&output.stdout)
|
||||
.unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"facade {args:?} must preserve machine-readable output: {error}\nstdout:\n{}\nstderr:\n{}",
|
||||
"doctor --context-json must emit a machine-readable context source map: {error}\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
report["entries"].is_array(),
|
||||
"facade {args:?} must preserve the context source map\nstdout:\n{}",
|
||||
"doctor --context-json must carry an `entries` array\nstdout:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
relative_paths(&sealed_home),
|
||||
before_paths,
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
//! The kill switch has to reach the process that would emit.
|
||||
//! The kill switch has to reach the in-process runtime that would emit.
|
||||
//!
|
||||
//! The dispatcher itself almost never emits: every interactive and headless
|
||||
//! session is delegated to the sibling `codewhale-tui` binary, which re-resolves
|
||||
//! telemetry from *its own* environment and config file. So the switch is only
|
||||
//! real if the resolved value — not the raw flag — is in that child's
|
||||
//! environment. This drives the real `codewhale` binary and reads what the
|
||||
//! child actually received.
|
||||
//! The single `codewhale` binary resolves dispatcher overrides, states the
|
||||
//! telemetry floor in its environment, and then calls `codewhale_tui::run`.
|
||||
//! That runtime re-resolves telemetry before it can arm. These tests drive the
|
||||
//! real binary through the keyless `features list` command and use the local
|
||||
//! dry-run sink as the end-to-end observable: an enabled positive control must
|
||||
//! write session events, while a kill switch must create no telemetry state.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use codewhale_config::{SetupState, TELEMETRY_NOTICE_VERSION};
|
||||
@@ -20,51 +17,45 @@ use tempfile::TempDir;
|
||||
|
||||
/// `CODEWHALE_TELEMETRY=0` beats `--telemetry true`, end to end.
|
||||
///
|
||||
/// The notice decision is recorded on this home on purpose: without it the run
|
||||
/// would be off for want of consent, and the test would pass without the kill
|
||||
/// switch ever being consulted.
|
||||
/// The positive control proves the runtime is enabled before the kill switch is
|
||||
/// applied, so the zero-state assertion cannot pass vacuously.
|
||||
#[test]
|
||||
fn env_off_beats_cli_on_end_to_end() {
|
||||
// Positive control first: the flag does reach the child, so the assertion
|
||||
// below is about the floor and not about a flag that goes nowhere.
|
||||
let on = dispatch_and_read_child_env(&["--telemetry", "true", "exec", "hi"], None);
|
||||
assert_eq!(
|
||||
on.get("CODEWHALE_TELEMETRY").map(String::as_str),
|
||||
Some("true"),
|
||||
"`--telemetry true` must reach the delegated child at all"
|
||||
// Positive control first: the flag reaches the in-process runtime and
|
||||
// arms its dry-run sink, so the assertion below is about the floor and not
|
||||
// about a command that never crossed the dispatch boundary.
|
||||
let on = dispatch_and_read_telemetry(None);
|
||||
let dry_run = on
|
||||
.dry_run
|
||||
.expect("`--telemetry true` must write the dry-run sink");
|
||||
assert!(
|
||||
dry_run.contains("\"event\":\"session_start\"")
|
||||
&& dry_run.contains("\"event\":\"session_end\""),
|
||||
"the real in-process runtime must record a complete session: {dry_run}"
|
||||
);
|
||||
|
||||
let off = dispatch_and_read_child_env(&["--telemetry", "true", "exec", "hi"], Some("0"));
|
||||
assert_eq!(
|
||||
off.get("CODEWHALE_TELEMETRY").map(String::as_str),
|
||||
Some("false"),
|
||||
"`CODEWHALE_TELEMETRY=0` must beat `--telemetry true` in the child's environment"
|
||||
);
|
||||
assert_eq!(
|
||||
off.get("DEEPSEEK_TELEMETRY").map(String::as_str),
|
||||
Some("false"),
|
||||
"the legacy alias must carry the same resolved value"
|
||||
let off = dispatch_and_read_telemetry(Some("0"));
|
||||
assert!(
|
||||
!off.telemetry_dir_exists && off.dry_run.is_none(),
|
||||
"`CODEWHALE_TELEMETRY=0` must beat `--telemetry true` before the runtime arms"
|
||||
);
|
||||
}
|
||||
|
||||
/// A value the resolver cannot parse resolves to off, rather than falling
|
||||
/// through to the flag.
|
||||
#[test]
|
||||
fn an_unparseable_telemetry_env_value_reaches_the_child_as_off() {
|
||||
let child = dispatch_and_read_child_env(&["--telemetry", "true", "exec", "hi"], Some("maybe"));
|
||||
assert_eq!(
|
||||
child.get("CODEWHALE_TELEMETRY").map(String::as_str),
|
||||
Some("false"),
|
||||
"a typo in the kill switch must never resolve to on"
|
||||
fn an_unparseable_telemetry_env_value_keeps_the_in_process_runtime_off() {
|
||||
let evidence = dispatch_and_read_telemetry(Some("maybe"));
|
||||
assert!(
|
||||
!evidence.telemetry_dir_exists && evidence.dry_run.is_none(),
|
||||
"a typo in the kill switch must never arm the in-process runtime"
|
||||
);
|
||||
}
|
||||
|
||||
/// Run the real dispatcher against a fake sibling TUI that dumps its
|
||||
/// environment, and return that environment.
|
||||
fn dispatch_and_read_child_env(
|
||||
args: &[&str],
|
||||
telemetry_env: Option<&str>,
|
||||
) -> BTreeMap<String, String> {
|
||||
/// Re-enabling through the documented settings command must clear a decline
|
||||
/// recorded by the former opt-in notice as well as the config-file floor.
|
||||
#[test]
|
||||
fn config_set_true_reenables_a_historical_decline() {
|
||||
let fixture = TempDir::new().expect("fixture root");
|
||||
let home = fixture.path().join("home");
|
||||
let codewhale_home = fixture.path().join("codewhale-home");
|
||||
@@ -73,28 +64,73 @@ fn dispatch_and_read_child_env(
|
||||
fs::create_dir_all(dir).expect("create fixture dir");
|
||||
}
|
||||
|
||||
// A recorded acceptance, so the run is not off for want of consent.
|
||||
let mut state = SetupState::default();
|
||||
state.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, true);
|
||||
state.record_telemetry_notice("1", false);
|
||||
let state_path = codewhale_home.join("setup_state.json");
|
||||
state
|
||||
.save_to(&codewhale_home.join("setup_state.json"))
|
||||
.expect("write setup state");
|
||||
.save_to(&state_path)
|
||||
.expect("write historical decline");
|
||||
|
||||
let config_path = fixture.path().join("config.toml");
|
||||
fs::write(&config_path, "telemetry = true\n").expect("write config");
|
||||
fs::write(&config_path, "telemetry = false\n").expect("write config");
|
||||
|
||||
let receipt = fixture.path().join("child-env.txt");
|
||||
let fake_tui = fixture.path().join("fake-codewhale-tui");
|
||||
let output = Command::new(codewhale_binary())
|
||||
.current_dir(&workspace)
|
||||
.env_clear()
|
||||
.env("PATH", std::env::var_os("PATH").expect("PATH"))
|
||||
.env("HOME", &home)
|
||||
.env("USERPROFILE", &home)
|
||||
.env("CODEWHALE_HOME", &codewhale_home)
|
||||
.env("CODEWHALE_SECRET_BACKEND", "file")
|
||||
.args([
|
||||
"--config",
|
||||
config_path.to_str().expect("config path"),
|
||||
"config",
|
||||
"set",
|
||||
"telemetry",
|
||||
"true",
|
||||
])
|
||||
.output()
|
||||
.expect("run config set");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"config set failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
fs::read_to_string(&config_path)
|
||||
.expect("read config")
|
||||
.contains("telemetry = true")
|
||||
);
|
||||
let state = SetupState::load_from(&state_path).expect("read setup state");
|
||||
assert!(state.telemetry_accepted(TELEMETRY_NOTICE_VERSION));
|
||||
assert!(!state.telemetry_opted_out());
|
||||
}
|
||||
|
||||
struct DispatchEvidence {
|
||||
telemetry_dir_exists: bool,
|
||||
dry_run: Option<String>,
|
||||
}
|
||||
|
||||
/// Run the real dispatcher into a keyless in-process command and report the
|
||||
/// telemetry state it actually left behind.
|
||||
fn dispatch_and_read_telemetry(telemetry_env: Option<&str>) -> DispatchEvidence {
|
||||
let fixture = TempDir::new().expect("fixture root");
|
||||
let home = fixture.path().join("home");
|
||||
let codewhale_home = fixture.path().join("codewhale-home");
|
||||
let workspace = fixture.path().join("workspace");
|
||||
for dir in [&home, &codewhale_home, &workspace] {
|
||||
fs::create_dir_all(dir).expect("create fixture dir");
|
||||
}
|
||||
|
||||
let config_path = fixture.path().join("config.toml");
|
||||
fs::write(
|
||||
&fake_tui,
|
||||
format!("#!/bin/sh\nenv > '{}'\n", receipt.display()),
|
||||
&config_path,
|
||||
// An explicitly empty endpoint is the network-free dry-run sink.
|
||||
"telemetry = true\ntelemetry_endpoint = \"\"\n",
|
||||
)
|
||||
.expect("write fake TUI");
|
||||
let mut permissions = fs::metadata(&fake_tui)
|
||||
.expect("fake TUI metadata")
|
||||
.permissions();
|
||||
permissions.set_mode(0o700);
|
||||
fs::set_permissions(&fake_tui, permissions).expect("make fake TUI executable");
|
||||
.expect("write config");
|
||||
|
||||
let mut command = Command::new(codewhale_binary());
|
||||
command
|
||||
@@ -105,56 +141,42 @@ fn dispatch_and_read_child_env(
|
||||
.env("USERPROFILE", &home)
|
||||
.env("CODEWHALE_HOME", &codewhale_home)
|
||||
.env("CODEWHALE_SECRET_BACKEND", "file")
|
||||
.env("CODEWHALE_TUI_BIN", &fake_tui)
|
||||
.env(
|
||||
"CODEWHALE_RELEASE_BASE_URL",
|
||||
"https://example.invalid/releases",
|
||||
)
|
||||
.arg("--config")
|
||||
.arg(&config_path)
|
||||
.args(args);
|
||||
.args(["--telemetry", "true", "features", "list"]);
|
||||
if let Some(value) = telemetry_env {
|
||||
command.env("CODEWHALE_TELEMETRY", value);
|
||||
}
|
||||
let output = command.output().expect("run codewhale dispatcher");
|
||||
|
||||
let dumped = fs::read_to_string(&receipt).unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"the delegated child must have run and dumped its environment: {error}\n\
|
||||
stdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
});
|
||||
|
||||
// A dispatcher run that never emits must also never create telemetry state
|
||||
// in the operator's home.
|
||||
assert!(
|
||||
!codewhale_home.join("telemetry").exists(),
|
||||
"the dispatcher must not create telemetry state for a delegated command"
|
||||
output.status.success(),
|
||||
"the in-process feature command must succeed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stdout).contains("feature\tstage\tenabled"),
|
||||
"the real in-process feature command must have run\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
dumped
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
line.split_once('=')
|
||||
.map(|(key, value)| (key.to_string(), value.to_string()))
|
||||
})
|
||||
.collect()
|
||||
let telemetry_dir = codewhale_home.join("telemetry");
|
||||
let dry_run = match fs::read_to_string(telemetry_dir.join("dryrun.jsonl")) {
|
||||
Ok(contents) => Some(contents),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(error) => panic!("read telemetry dry-run sink: {error}"),
|
||||
};
|
||||
DispatchEvidence {
|
||||
telemetry_dir_exists: telemetry_dir.exists(),
|
||||
dry_run,
|
||||
}
|
||||
}
|
||||
|
||||
fn codewhale_binary() -> PathBuf {
|
||||
if let Some(path) = option_env!("CARGO_BIN_EXE_codewhale") {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
if let Ok(path) = std::env::var("CARGO_BIN_EXE_codewhale") {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
let mut path = std::env::current_exe().expect("current test executable path");
|
||||
path.pop();
|
||||
if path.ends_with("deps") {
|
||||
path.pop();
|
||||
}
|
||||
path.push(format!("codewhale{}", std::env::consts::EXE_SUFFIX));
|
||||
path
|
||||
fn codewhale_binary() -> &'static str {
|
||||
env!("CARGO_BIN_EXE_codewhale")
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ description = "Config schema and precedence model for Codewhale"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.9.4" }
|
||||
codewhale-paths = { path = "../paths", version = "0.9.4" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.9.4" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.9.6" }
|
||||
codewhale-paths = { path = "../paths", version = "0.9.6" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.9.6" }
|
||||
fd-lock = "4.0.4"
|
||||
libc = "0.2"
|
||||
serde.workspace = true
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
//! Legacy DeepSeek-scoped prompt complexity classifier.
|
||||
//!
|
||||
//! This pure scorer is retained for API compatibility, but the consolidated
|
||||
//! CLI dispatcher must not use it to resolve provider-neutral `model = "auto"`:
|
||||
//! doing so fabricates DeepSeek model ids for every active provider. The TUI's
|
||||
//! provider-aware router owns runtime auto selection. Callers may use this
|
||||
//! helper only when their candidate pair is explicitly the DeepSeek pair:
|
||||
//!
|
||||
//! - **`deepseek-v4-pro`** — complex tasks (debugging, refactoring, design,
|
||||
//! security review, multi-file changes, code generation, …).
|
||||
//! - **`deepseek-v4-flash`** — simple tasks (lookups, formatting, small edits,
|
||||
//! translation, Q&A, …).
|
||||
//!
|
||||
//! This is a pure rule-based classifier. It lives in the config crate because
|
||||
//! the resolved model name is a config-level concern; the route resolver never
|
||||
//! sees the `"auto"` sentinel or the prompt text.
|
||||
|
||||
/// The resolved model name for the pro tier.
|
||||
pub const PRO_MODEL: &str = "deepseek-v4-pro";
|
||||
|
||||
/// The resolved model name for the flash tier.
|
||||
pub const FLASH_MODEL: &str = "deepseek-v4-flash";
|
||||
|
||||
/// The threshold score above which a task is classified as complex (pro).
|
||||
/// Score ≥ 2 → pro, else → flash.
|
||||
const PRO_THRESHOLD: i32 = 2;
|
||||
|
||||
/// Strong indicators of a complex task. Each match adds +3.
|
||||
const COMPLEX_STRONG: &[&str] = &[
|
||||
// Debugging & fixing
|
||||
"debug",
|
||||
"bug",
|
||||
"fix",
|
||||
"error",
|
||||
"crash",
|
||||
"异常",
|
||||
"错误",
|
||||
"调试",
|
||||
"故障",
|
||||
"排查",
|
||||
"root cause",
|
||||
// Architecture & design
|
||||
"refactor",
|
||||
"重构",
|
||||
"architecture",
|
||||
"架构",
|
||||
"design pattern",
|
||||
"系统设计",
|
||||
"高并发",
|
||||
"分布式",
|
||||
"microservice",
|
||||
// Security
|
||||
"security",
|
||||
"安全",
|
||||
"vulnerability",
|
||||
"漏洞",
|
||||
"渗透",
|
||||
"exploit",
|
||||
// Code generation
|
||||
"implement",
|
||||
"实现",
|
||||
"generate",
|
||||
"生成",
|
||||
"create",
|
||||
"创建",
|
||||
"build",
|
||||
"构建",
|
||||
"开发",
|
||||
"prototype",
|
||||
// Complex analysis
|
||||
"analyze",
|
||||
"分析",
|
||||
"review",
|
||||
"审查",
|
||||
"audit",
|
||||
"审计",
|
||||
"optimize",
|
||||
"优化",
|
||||
"migrate",
|
||||
"迁移",
|
||||
// Multi-file / large scale
|
||||
"multi-file",
|
||||
"multiple files",
|
||||
"多个文件",
|
||||
"整个项目",
|
||||
"full project",
|
||||
"重构整个",
|
||||
"large scale",
|
||||
// Testing
|
||||
"unit test",
|
||||
"integration test",
|
||||
"e2e test",
|
||||
"测试用例",
|
||||
"test suite",
|
||||
"coverage",
|
||||
// Complex logic
|
||||
"algorithm",
|
||||
"算法",
|
||||
"状态机",
|
||||
"state machine",
|
||||
"concurrent",
|
||||
"并行",
|
||||
"异步",
|
||||
"async",
|
||||
// Documentation / PRD
|
||||
"architecture document",
|
||||
"设计文档",
|
||||
"技术方案",
|
||||
"prd",
|
||||
];
|
||||
|
||||
/// Medium-strength indicators. Each match adds +1.
|
||||
const COMPLEX_MEDIUM: &[&str] = &[
|
||||
"change",
|
||||
"修改",
|
||||
"update",
|
||||
"更新",
|
||||
"add",
|
||||
"添加",
|
||||
"新增",
|
||||
"feature",
|
||||
"功能",
|
||||
"improve",
|
||||
"改进",
|
||||
"enhance",
|
||||
"config",
|
||||
"配置",
|
||||
"setup",
|
||||
"设置",
|
||||
"deploy",
|
||||
"部署",
|
||||
"ci/cd",
|
||||
"pipeline",
|
||||
"script",
|
||||
"脚本",
|
||||
"tool",
|
||||
"工具",
|
||||
"api",
|
||||
"interface",
|
||||
"接口",
|
||||
"endpoint",
|
||||
"database",
|
||||
"数据库",
|
||||
"schema",
|
||||
"query",
|
||||
"document",
|
||||
"文档",
|
||||
"readme",
|
||||
];
|
||||
|
||||
/// Simple-task indicators. Each match subtracts -1.
|
||||
const SIMPLE: &[&str] = &[
|
||||
"find",
|
||||
"查找",
|
||||
"search",
|
||||
"搜索",
|
||||
"look up",
|
||||
"查询",
|
||||
"what is",
|
||||
"什么是",
|
||||
"explain",
|
||||
"解释",
|
||||
"tell me",
|
||||
"告诉我",
|
||||
"how to",
|
||||
"如何",
|
||||
"format",
|
||||
"格式化",
|
||||
"pretty",
|
||||
"list",
|
||||
"列出",
|
||||
"show",
|
||||
"显示",
|
||||
"print",
|
||||
"rename",
|
||||
"重命名",
|
||||
"move",
|
||||
"移动",
|
||||
"copy",
|
||||
"复制",
|
||||
"delete",
|
||||
"删除",
|
||||
"remove",
|
||||
"typo",
|
||||
"拼写",
|
||||
"spelling",
|
||||
"grammar",
|
||||
"quick",
|
||||
"快速",
|
||||
"simple",
|
||||
"简单",
|
||||
"hello world",
|
||||
"demo",
|
||||
"example",
|
||||
"示例",
|
||||
"translate",
|
||||
"翻译",
|
||||
"convert",
|
||||
"转换",
|
||||
"short",
|
||||
"简短",
|
||||
"brief",
|
||||
"简要",
|
||||
];
|
||||
|
||||
/// Classify a prompt for the legacy DeepSeek candidate pair.
|
||||
///
|
||||
/// Uses a simple scoring system:
|
||||
/// - Strong complex keyword: +3
|
||||
/// - Medium complex keyword: +1
|
||||
/// - Simple keyword: -1
|
||||
/// - Prompt length > 500 chars: +2, > 200 chars: +1
|
||||
/// - Contains code fence or backtick: +1
|
||||
/// - Contains a file path: +1
|
||||
/// - Multi-line (> 5 newlines): +1
|
||||
///
|
||||
/// Total ≥ 2 → `PRO_MODEL`, else → `FLASH_MODEL`.
|
||||
#[must_use]
|
||||
pub fn classify(prompt: &str) -> &'static str {
|
||||
if score(prompt) >= PRO_THRESHOLD {
|
||||
PRO_MODEL
|
||||
} else {
|
||||
FLASH_MODEL
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the raw complexity score for a prompt.
|
||||
#[must_use]
|
||||
pub fn score(prompt: &str) -> i32 {
|
||||
let lower = prompt.to_ascii_lowercase();
|
||||
let mut score = 0i32;
|
||||
|
||||
// Strong complex keywords: +3 (first match only to avoid overcounting)
|
||||
if COMPLEX_STRONG.iter().any(|kw| lower.contains(kw)) {
|
||||
score += 3;
|
||||
}
|
||||
|
||||
// Medium complex keywords: +1 each
|
||||
for kw in COMPLEX_MEDIUM {
|
||||
if lower.contains(kw) {
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Simple keywords: -1 each
|
||||
for kw in SIMPLE {
|
||||
if lower.contains(kw) {
|
||||
score -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Length factor: long prompts tend to be more complex
|
||||
let len = prompt.len();
|
||||
if len > 500 {
|
||||
score += 2;
|
||||
} else if len > 200 {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
// Code fence or backtick: actual coding task
|
||||
if prompt.contains("```") || prompt.contains('`') {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
// File path pattern: e.g. /path/to/file.rs or C:\path
|
||||
// Simple heuristic: path-like sequences contain / or \ and .
|
||||
if (prompt.contains('/') || prompt.contains('\\')) && prompt.contains('.') {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
// Multi-line: more lines = more context
|
||||
if prompt.chars().filter(|&c| c == '\n').count() > 5 {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
score
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_debug_task_uses_pro() {
|
||||
assert_eq!(classify("帮我调试这个bug,程序崩溃了"), PRO_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refactor_task_uses_pro() {
|
||||
assert_eq!(
|
||||
classify("refactor the user module with a new architecture"),
|
||||
PRO_MODEL
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_security_review_uses_pro() {
|
||||
assert_eq!(
|
||||
classify("review this code for security vulnerabilities"),
|
||||
PRO_MODEL
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_lookup_uses_flash() {
|
||||
assert_eq!(classify("查找昨天的日志文件"), FLASH_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translation_uses_flash() {
|
||||
assert_eq!(classify("translate this to Chinese"), FLASH_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_formatting_uses_flash() {
|
||||
assert_eq!(classify("format this code"), FLASH_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_long_prompt_gets_bonus() {
|
||||
let long = "a".repeat(300);
|
||||
// No keywords, long prompt gives +1, total = 1 < 2 → flash
|
||||
assert_eq!(classify(&long), FLASH_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_very_long_prompt_gets_more_bonus() {
|
||||
let long = "a".repeat(600);
|
||||
// No keywords, very long prompt gives +2, total = 2 → pro
|
||||
assert_eq!(classify(&long), PRO_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_code_block_gets_bonus() {
|
||||
// Code block without keywords, +1, total = 1 < 2 → flash
|
||||
assert_eq!(classify("```\nhello\n```"), FLASH_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_keywords_pro_wins() {
|
||||
// "refactor" is strong (+3), "explain" is simple (-1), total = 2 → pro
|
||||
assert_eq!(classify("refactor and explain the code"), PRO_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_implement_task_uses_pro() {
|
||||
assert_eq!(
|
||||
classify("implement a new feature for the user module"),
|
||||
PRO_MODEL
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quick_question_uses_flash() {
|
||||
assert_eq!(classify("what is the capital of France?"), FLASH_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_score_never_negative() {
|
||||
// Even for very simple queries, score should be predictable
|
||||
let s = score("hello world");
|
||||
assert!(s >= -10); // sanity check
|
||||
}
|
||||
}
|
||||
+42
-17
@@ -1,4 +1,5 @@
|
||||
pub mod auth_source;
|
||||
pub mod auto_model;
|
||||
pub mod catalog;
|
||||
mod config_document;
|
||||
pub mod external_credentials;
|
||||
@@ -399,6 +400,16 @@ pub struct ProvidersToml {
|
||||
alias = "grok"
|
||||
)]
|
||||
pub xai: ProviderConfigToml,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "ProviderConfigToml::is_empty",
|
||||
alias = "mistral-ai",
|
||||
alias = "mistral_ai",
|
||||
alias = "mistralai",
|
||||
alias = "la-plateforme",
|
||||
alias = "la_plateforme"
|
||||
)]
|
||||
pub mistral: ProviderConfigToml,
|
||||
/// Jiangsu Telecom TokenHub — OpenAI-compatible AI gateway.
|
||||
#[serde(
|
||||
default,
|
||||
@@ -626,6 +637,7 @@ impl ProvidersToml {
|
||||
ProviderKind::OpencodeZen => &self.opencode_zen,
|
||||
ProviderKind::Meta => &self.meta,
|
||||
ProviderKind::Xai => &self.xai,
|
||||
ProviderKind::Mistral => &self.mistral,
|
||||
ProviderKind::Telecomjs => &self.telecomjs,
|
||||
ProviderKind::ModelstudioTokenPlan => &self.modelstudio_token_plan,
|
||||
ProviderKind::ModelstudioTokenPlanAnthropic => &self.modelstudio_token_plan_anthropic,
|
||||
@@ -672,6 +684,7 @@ impl ProvidersToml {
|
||||
ProviderKind::OpencodeZen => &mut self.opencode_zen,
|
||||
ProviderKind::Meta => &mut self.meta,
|
||||
ProviderKind::Xai => &mut self.xai,
|
||||
ProviderKind::Mistral => &mut self.mistral,
|
||||
ProviderKind::Telecomjs => &mut self.telecomjs,
|
||||
ProviderKind::ModelstudioTokenPlan => &mut self.modelstudio_token_plan,
|
||||
ProviderKind::ModelstudioTokenPlanAnthropic => {
|
||||
@@ -730,8 +743,8 @@ pub struct ConfigToml {
|
||||
/// [`DEFAULT_TELEMETRY_ENDPOINT`] — not "send nowhere". Setting it to the
|
||||
/// empty string is the way to say send nowhere: that resolves to no
|
||||
/// endpoint, which appends batches to `dryrun.jsonl` and constructs no HTTP
|
||||
/// client. Either way nothing is sent until telemetry is enabled *and* the
|
||||
/// first-run notice has been answered with Enable.
|
||||
/// client. Either way a persistent or run-scoped opt-out still prevents any
|
||||
/// batch from being constructed.
|
||||
///
|
||||
/// Kept as a scalar sibling of `telemetry` rather than folded into a
|
||||
/// `[telemetry]` table. `telemetry` is already a scalar and every section
|
||||
@@ -1863,6 +1876,7 @@ impl<'de> Deserialize<'de> for FleetRole {
|
||||
pub enum FleetSlot {
|
||||
Manager,
|
||||
Scout,
|
||||
Planner,
|
||||
Implementer,
|
||||
Reviewer,
|
||||
Verifier,
|
||||
@@ -1879,6 +1893,7 @@ impl FleetSlot {
|
||||
match self {
|
||||
Self::Manager => "manager",
|
||||
Self::Scout => "scout",
|
||||
Self::Planner => "planner",
|
||||
Self::Implementer => "implementer",
|
||||
Self::Reviewer => "reviewer",
|
||||
Self::Verifier => "verifier",
|
||||
@@ -1894,6 +1909,7 @@ impl FleetSlot {
|
||||
match value.trim() {
|
||||
"manager" | "coordinator" => Self::Manager,
|
||||
"scout" | "research" | "research-worker" => Self::Scout,
|
||||
"planner" | "plan" | "awaiter" => Self::Planner,
|
||||
"implementer" | "builder" => Self::Implementer,
|
||||
"reviewer" => Self::Reviewer,
|
||||
"verifier" | "tester" => Self::Verifier,
|
||||
@@ -3187,6 +3203,7 @@ impl ConfigToml {
|
||||
ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL.to_string(),
|
||||
ProviderKind::Meta => DEFAULT_META_BASE_URL.to_string(),
|
||||
ProviderKind::Xai => DEFAULT_XAI_BASE_URL.to_string(),
|
||||
ProviderKind::Mistral => DEFAULT_MISTRAL_BASE_URL.to_string(),
|
||||
ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL.to_string(),
|
||||
ProviderKind::ModelstudioTokenPlan
|
||||
| ProviderKind::ModelstudioTokenPlanAnthropic
|
||||
@@ -3341,7 +3358,7 @@ impl ConfigToml {
|
||||
.telemetry
|
||||
.or(env.telemetry)
|
||||
.or(self.telemetry)
|
||||
.unwrap_or(false);
|
||||
.unwrap_or(true);
|
||||
// `telemetry = false` written to the config file is the off switch the
|
||||
// first-run notice and `docs/TELEMETRY.md` both advertise as the
|
||||
// *persistent* one, so it is a floor and not merely the last term of a
|
||||
@@ -3377,10 +3394,9 @@ impl ConfigToml {
|
||||
// missing value — it is the local dry-run sink, and it stays reachable
|
||||
// by resolving to `None` instead of falling through to the default.
|
||||
//
|
||||
// None of this is a consent decision. A session only reaches an
|
||||
// endpoint after `telemetry` above resolved true, which requires the
|
||||
// first-run notice to have been answered with Enable; the kill switches
|
||||
// are all upstream of this line.
|
||||
// None of this changes the user's opt-out. A session only reaches an
|
||||
// endpoint after `telemetry` above resolved true; every persistent and
|
||||
// run-scoped kill switch is upstream of this line.
|
||||
let telemetry_endpoint = match env
|
||||
.telemetry_endpoint
|
||||
.clone()
|
||||
@@ -3443,11 +3459,9 @@ fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &Provi
|
||||
/// Analytics Engine and stores nothing else. See `docs/TELEMETRY.md` for what a
|
||||
/// batch contains and `telemetry-ingest/` for the handler.
|
||||
///
|
||||
/// This is a *default*, not a floor, and it changes nothing about consent: it is
|
||||
/// only ever consulted for a session that is already enabled, which requires the
|
||||
/// first-run notice to have been answered with Enable. `CODEWHALE_TELEMETRY=0`,
|
||||
/// `telemetry = false`, and a recorded decline all stop the session long before
|
||||
/// an endpoint is read.
|
||||
/// This is a *default*, not a floor, and it changes nothing about permission:
|
||||
/// `CODEWHALE_TELEMETRY=0`, `telemetry = false`, and a recorded decline all
|
||||
/// stop the session long before an endpoint is read.
|
||||
///
|
||||
/// An explicit value — `CODEWHALE_TELEMETRY_ENDPOINT` or `telemetry_endpoint` in
|
||||
/// the config file — wins outright, and an explicit *empty* value resolves to no
|
||||
@@ -4247,6 +4261,7 @@ fn default_model_for_provider(provider: ProviderKind) -> &'static str {
|
||||
ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_MODEL,
|
||||
ProviderKind::Meta => DEFAULT_META_MODEL,
|
||||
ProviderKind::Xai => DEFAULT_XAI_MODEL,
|
||||
ProviderKind::Mistral => DEFAULT_MISTRAL_MODEL,
|
||||
ProviderKind::Telecomjs => DEFAULT_TELECOMJS_MODEL,
|
||||
ProviderKind::ModelstudioTokenPlan
|
||||
| ProviderKind::ModelstudioTokenPlanAnthropic
|
||||
@@ -4294,6 +4309,7 @@ fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
|
||||
ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL,
|
||||
ProviderKind::Meta => DEFAULT_META_BASE_URL,
|
||||
ProviderKind::Xai => DEFAULT_XAI_BASE_URL,
|
||||
ProviderKind::Mistral => DEFAULT_MISTRAL_BASE_URL,
|
||||
ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL,
|
||||
ProviderKind::ModelstudioTokenPlan => DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL,
|
||||
ProviderKind::ModelstudioTokenPlanAnthropic => MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL,
|
||||
@@ -4810,11 +4826,10 @@ pub struct ResolvedRuntimeOptions {
|
||||
/// A human wrote `telemetry = false` into the config file.
|
||||
///
|
||||
/// This is the *persistent* opt-out, and it is deliberately narrower than
|
||||
/// "telemetry resolved to false". `false` is also the value when nobody has
|
||||
/// said anything at all, and it is what the dispatcher forwards to the TUI
|
||||
/// on every ordinary run; a consumer that reads those as a revocation would
|
||||
/// destroy the identity and buffered events of a consenting user who merely
|
||||
/// set `CODEWHALE_TELEMETRY=0` for one command. Run-scoped kill switches
|
||||
/// "telemetry resolved to false". A run-scoped kill switch also resolves
|
||||
/// false; treating that as a revocation would destroy the identity and
|
||||
/// buffered events of a user who merely set `CODEWHALE_TELEMETRY=0` for one
|
||||
/// command. Run-scoped kill switches
|
||||
/// (`--telemetry false`, the environment variable) stop the run and leave
|
||||
/// every byte on disk alone; only this flag authorizes the wipe.
|
||||
pub telemetry_explicit_off: bool,
|
||||
@@ -6550,6 +6565,8 @@ struct EnvRuntimeOverrides {
|
||||
meta_model: Option<String>,
|
||||
xai_base_url: Option<String>,
|
||||
xai_model: Option<String>,
|
||||
mistral_base_url: Option<String>,
|
||||
mistral_model: Option<String>,
|
||||
telecomjs_base_url: Option<String>,
|
||||
telecomjs_model: Option<String>,
|
||||
modelstudio_token_plan_base_url: Option<String>,
|
||||
@@ -6854,6 +6871,12 @@ impl EnvRuntimeOverrides {
|
||||
xai_model: std::env::var("XAI_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
mistral_base_url: std::env::var("MISTRAL_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
mistral_model: std::env::var("MISTRAL_MODEL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
telecomjs_base_url: std::env::var("TELECOMJS_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
@@ -6954,6 +6977,7 @@ impl EnvRuntimeOverrides {
|
||||
ProviderKind::OpencodeZen => self.opencode_zen_base_url.clone(),
|
||||
ProviderKind::Meta => self.meta_base_url.clone(),
|
||||
ProviderKind::Xai => self.xai_base_url.clone(),
|
||||
ProviderKind::Mistral => self.mistral_base_url.clone(),
|
||||
ProviderKind::Telecomjs => self.telecomjs_base_url.clone(),
|
||||
ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic => {
|
||||
self.modelstudio_token_plan_base_url.clone()
|
||||
@@ -6996,6 +7020,7 @@ impl EnvRuntimeOverrides {
|
||||
ProviderKind::OpencodeZen => self.opencode_zen_model.clone(),
|
||||
ProviderKind::Meta => self.meta_model.clone(),
|
||||
ProviderKind::Xai => self.xai_model.clone(),
|
||||
ProviderKind::Mistral => self.mistral_model.clone(),
|
||||
ProviderKind::Telecomjs => self.telecomjs_model.clone(),
|
||||
ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic => {
|
||||
self.modelstudio_token_plan_model.clone()
|
||||
|
||||
@@ -12,24 +12,25 @@ use super::{
|
||||
DEFAULT_FIREWORKS_MODEL, DEFAULT_HUGGINGFACE_BASE_URL, DEFAULT_HUGGINGFACE_MODEL,
|
||||
DEFAULT_LONGCAT_BASE_URL, DEFAULT_LONGCAT_MODEL, DEFAULT_META_BASE_URL, DEFAULT_META_MODEL,
|
||||
DEFAULT_MINIMAX_ANTHROPIC_BASE_URL, DEFAULT_MINIMAX_BASE_URL, DEFAULT_MINIMAX_MODEL,
|
||||
DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL, DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL,
|
||||
DEFAULT_MODELSTUDIO_TOKEN_PLAN_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_CODEX_BASE_URL, DEFAULT_OPENAI_CODEX_MODEL,
|
||||
DEFAULT_OPENAI_MODEL, DEFAULT_OPENCODE_GO_BASE_URL, DEFAULT_OPENCODE_GO_MODEL,
|
||||
DEFAULT_OPENCODE_ZEN_BASE_URL, DEFAULT_OPENCODE_ZEN_MODEL, DEFAULT_OPENMODEL_BASE_URL,
|
||||
DEFAULT_OPENMODEL_MODEL, DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OPENROUTER_MODEL,
|
||||
DEFAULT_QIANFAN_BASE_URL, DEFAULT_QIANFAN_MODEL, DEFAULT_SAKANA_BASE_URL, DEFAULT_SAKANA_MODEL,
|
||||
DEFAULT_SGLANG_BASE_URL, DEFAULT_SGLANG_MODEL, DEFAULT_SILICONFLOW_BASE_URL,
|
||||
DEFAULT_SILICONFLOW_CN_BASE_URL, DEFAULT_SILICONFLOW_MODEL, DEFAULT_STEPFUN_BASE_URL,
|
||||
DEFAULT_STEPFUN_MODEL, DEFAULT_TELECOMJS_BASE_URL, DEFAULT_TELECOMJS_MODEL,
|
||||
DEFAULT_TOGETHER_BASE_URL, DEFAULT_TOGETHER_MODEL, DEFAULT_VLLM_BASE_URL, DEFAULT_VLLM_MODEL,
|
||||
DEFAULT_VOLCENGINE_BASE_URL, DEFAULT_VOLCENGINE_MODEL, DEFAULT_WANJIE_ARK_BASE_URL,
|
||||
DEFAULT_WANJIE_ARK_MODEL, DEFAULT_XAI_BASE_URL, DEFAULT_XAI_MODEL,
|
||||
DEFAULT_XIAOMI_MIMO_BASE_URL, DEFAULT_XIAOMI_MIMO_MODEL, DEFAULT_ZAI_BASE_URL,
|
||||
DEFAULT_ZAI_MODEL, MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL,
|
||||
MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL, ProviderKind,
|
||||
DEFAULT_MISTRAL_BASE_URL, DEFAULT_MISTRAL_MODEL, DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL,
|
||||
DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL, DEFAULT_MODELSTUDIO_TOKEN_PLAN_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_CODEX_BASE_URL, DEFAULT_OPENAI_CODEX_MODEL, DEFAULT_OPENAI_MODEL,
|
||||
DEFAULT_OPENCODE_GO_BASE_URL, DEFAULT_OPENCODE_GO_MODEL, DEFAULT_OPENCODE_ZEN_BASE_URL,
|
||||
DEFAULT_OPENCODE_ZEN_MODEL, DEFAULT_OPENMODEL_BASE_URL, DEFAULT_OPENMODEL_MODEL,
|
||||
DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OPENROUTER_MODEL, DEFAULT_QIANFAN_BASE_URL,
|
||||
DEFAULT_QIANFAN_MODEL, DEFAULT_SAKANA_BASE_URL, DEFAULT_SAKANA_MODEL, DEFAULT_SGLANG_BASE_URL,
|
||||
DEFAULT_SGLANG_MODEL, DEFAULT_SILICONFLOW_BASE_URL, DEFAULT_SILICONFLOW_CN_BASE_URL,
|
||||
DEFAULT_SILICONFLOW_MODEL, DEFAULT_STEPFUN_BASE_URL, DEFAULT_STEPFUN_MODEL,
|
||||
DEFAULT_TELECOMJS_BASE_URL, DEFAULT_TELECOMJS_MODEL, DEFAULT_TOGETHER_BASE_URL,
|
||||
DEFAULT_TOGETHER_MODEL, DEFAULT_VLLM_BASE_URL, DEFAULT_VLLM_MODEL, DEFAULT_VOLCENGINE_BASE_URL,
|
||||
DEFAULT_VOLCENGINE_MODEL, DEFAULT_WANJIE_ARK_BASE_URL, DEFAULT_WANJIE_ARK_MODEL,
|
||||
DEFAULT_XAI_BASE_URL, DEFAULT_XAI_MODEL, DEFAULT_XIAOMI_MIMO_BASE_URL,
|
||||
DEFAULT_XIAOMI_MIMO_MODEL, DEFAULT_ZAI_BASE_URL, DEFAULT_ZAI_MODEL,
|
||||
MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL, MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL,
|
||||
ProviderKind,
|
||||
};
|
||||
|
||||
/// Wire protocol spoken by a provider.
|
||||
@@ -391,6 +392,12 @@ pub const fn credential_help(kind: ProviderKind) -> CredentialHelp {
|
||||
docs_url: None,
|
||||
guidance: "Use an xAI Console API key or Codewhale's native device login. Reading an existing Grok CLI file requires explicit provider-scoped read-only consent.",
|
||||
},
|
||||
ProviderKind::Mistral => CredentialHelp {
|
||||
acquisition: ApiKey,
|
||||
credential_url: Some("https://console.mistral.ai/api-keys"),
|
||||
docs_url: Some("https://docs.mistral.ai/"),
|
||||
guidance: "Create a Mistral API key in the Mistral Console (la Plateforme).",
|
||||
},
|
||||
ProviderKind::Telecomjs => CredentialHelp {
|
||||
acquisition: ApiKey,
|
||||
credential_url: Some("https://aigw.telecomjs.com/"),
|
||||
@@ -905,6 +912,17 @@ provider!(
|
||||
"qianfan",
|
||||
aliases: ["baidu-qianfan", "baidu_qianfan", "baidu"]
|
||||
);
|
||||
provider!(
|
||||
Mistral,
|
||||
Mistral,
|
||||
"mistral",
|
||||
"Mistral AI",
|
||||
DEFAULT_MISTRAL_BASE_URL,
|
||||
DEFAULT_MISTRAL_MODEL,
|
||||
["MISTRAL_API_KEY"],
|
||||
"mistral",
|
||||
aliases: ["mistral-ai", "mistral_ai", "mistralai", "la-plateforme", "la_plateforme"]
|
||||
);
|
||||
|
||||
/// OpenAI Codex / ChatGPT OAuth provider using the Responses API.
|
||||
pub struct OpenaiCodex;
|
||||
@@ -1523,6 +1541,7 @@ static OPENCODE_GO: OpencodeGo = OpencodeGo;
|
||||
static OPENCODE_ZEN: OpencodeZen = OpencodeZen;
|
||||
static META: Meta = Meta;
|
||||
static XAI: Xai = Xai;
|
||||
static MISTRAL: Mistral = Mistral;
|
||||
static TELECOMJS: Telecomjs = Telecomjs;
|
||||
static MODELSTUDIO_TOKEN_PLAN: ModelstudioTokenPlan = ModelstudioTokenPlan;
|
||||
static MODELSTUDIO_TOKEN_PLAN_ANTHROPIC: ModelstudioTokenPlanAnthropic =
|
||||
@@ -1532,7 +1551,7 @@ static MODELSTUDIO_CODING_PLAN_ANTHROPIC: ModelstudioCodingPlanAnthropic =
|
||||
ModelstudioCodingPlanAnthropic;
|
||||
static CUSTOM: Custom = Custom;
|
||||
|
||||
static PROVIDER_REGISTRY: [&dyn Provider; 41] = [
|
||||
static PROVIDER_REGISTRY: [&dyn Provider; 42] = [
|
||||
&DEEPSEEK,
|
||||
&DEEPSEEK_ANTHROPIC,
|
||||
&NVIDIA_NIM,
|
||||
@@ -1568,6 +1587,7 @@ static PROVIDER_REGISTRY: [&dyn Provider; 41] = [
|
||||
&OPENCODE_ZEN,
|
||||
&META,
|
||||
&XAI,
|
||||
&MISTRAL,
|
||||
&TELECOMJS,
|
||||
&MODELSTUDIO_TOKEN_PLAN,
|
||||
&MODELSTUDIO_TOKEN_PLAN_ANTHROPIC,
|
||||
|
||||
@@ -167,6 +167,9 @@ pub(crate) const DEFAULT_META_BASE_URL: &str = "https://api.meta.ai/v1";
|
||||
// xAI / Grok API-key route defaults
|
||||
pub(crate) const DEFAULT_XAI_MODEL: &str = "grok-4.5";
|
||||
pub(crate) const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1";
|
||||
// Mistral AI (la Plateforme) defaults
|
||||
pub(crate) const DEFAULT_MISTRAL_MODEL: &str = "mistral-code-latest";
|
||||
pub(crate) const DEFAULT_MISTRAL_BASE_URL: &str = "https://api.mistral.ai/v1";
|
||||
// TelecomJS (Jiangsu Telecom TokenHub) defaults
|
||||
pub(crate) const DEFAULT_TELECOMJS_MODEL: &str = "deepseek-v4-pro";
|
||||
pub(crate) const DEFAULT_TELECOMJS_BASE_URL: &str = "https://aigw.telecomjs.com/v1";
|
||||
|
||||
@@ -131,6 +131,15 @@ pub enum ProviderKind {
|
||||
Meta,
|
||||
#[serde(alias = "x-ai", alias = "x_ai", alias = "grok")]
|
||||
Xai,
|
||||
/// Mistral AI — la Plateforme (OpenAI-compatible Chat Completions).
|
||||
#[serde(
|
||||
alias = "mistral-ai",
|
||||
alias = "mistral_ai",
|
||||
alias = "mistralai",
|
||||
alias = "la-plateforme",
|
||||
alias = "la_plateforme"
|
||||
)]
|
||||
Mistral,
|
||||
/// Jiangsu Telecom TokenHub (OpenAI-compatible).
|
||||
///
|
||||
/// An AI gateway operated by Jiangsu Telecom that speaks the OpenAI Chat
|
||||
@@ -195,7 +204,7 @@ impl ProviderKind {
|
||||
/// stay on the enum for serde and `provider_for_kind`, but they are not
|
||||
/// first-class catalog rows. Plan is `mode` / base_url; dialect is
|
||||
/// `wire = openai|anthropic` on the primary provider config.
|
||||
pub const ALL: [Self; 36] = [
|
||||
pub const ALL: [Self; 37] = [
|
||||
Self::Deepseek,
|
||||
Self::NvidiaNim,
|
||||
Self::Openai,
|
||||
@@ -229,6 +238,7 @@ impl ProviderKind {
|
||||
Self::OpencodeZen,
|
||||
Self::Meta,
|
||||
Self::Xai,
|
||||
Self::Mistral,
|
||||
Self::Telecomjs,
|
||||
Self::ModelstudioTokenPlan,
|
||||
Self::Custom,
|
||||
|
||||
@@ -40,10 +40,11 @@ pub const SETUP_STATE_FILE_NAME: &str = "setup_state.json";
|
||||
///
|
||||
/// The notice is owed whenever
|
||||
/// [`SetupState::telemetry_notice_decided_for`] does not match this string.
|
||||
/// Bumping it re-asks everyone, so it is bumped only when what would be
|
||||
/// collected materially changes. Keying it to the app version would re-prompt
|
||||
/// every release, which is nagging with extra steps.
|
||||
pub const TELEMETRY_NOTICE_VERSION: &str = "1";
|
||||
/// Bumping it re-asks prior acceptors and unanswered users, so it is bumped only
|
||||
/// when the collection policy, schema, or disclosure materially changes. Prior
|
||||
/// declines remain off. Keying it to the app version would re-prompt every
|
||||
/// release, which is nagging with extra steps.
|
||||
pub const TELEMETRY_NOTICE_VERSION: &str = "3";
|
||||
|
||||
/// Canonical setup step ids. The ordering matches the first-run spine so a
|
||||
/// `BTreeMap<SetupStep, _>` renders in wizard order.
|
||||
@@ -335,8 +336,8 @@ pub struct SetupState {
|
||||
/// Never auto-completed and never deferred-completed: unlike the
|
||||
/// constitution checkpoint, which records a `Deferred` completion on the
|
||||
/// skip-onboarding path, a telemetry notice that was not rendered and
|
||||
/// answered leaves this `None`. Silence is not consent, so the failure
|
||||
/// mode of a missing notice is that nothing is ever collected.
|
||||
/// answered leaves this `None`. Collection follows the documented default
|
||||
/// while the notice remains owed on the next interactive launch.
|
||||
///
|
||||
/// These are *fields* rather than a new [`SetupStep`] variant on purpose:
|
||||
/// an unknown enum variant fails the whole record parse and silently drops
|
||||
@@ -344,9 +345,9 @@ pub struct SetupState {
|
||||
/// checkpoint — while unknown fields are ignored.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub telemetry_notice_decided_for: Option<String>,
|
||||
/// The user's answer to the notice. Only meaningful when
|
||||
/// [`Self::telemetry_notice_decided_for`] matches the current
|
||||
/// [`TELEMETRY_NOTICE_VERSION`].
|
||||
/// The user's answer to the notice. `false` with any recorded notice
|
||||
/// version is a durable opt-out; `true` records acknowledgment of that
|
||||
/// version's disclosure.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub telemetry_opt_in: bool,
|
||||
}
|
||||
@@ -505,12 +506,7 @@ impl SetupState {
|
||||
self
|
||||
}
|
||||
|
||||
/// True when the user was asked the current notice and said yes.
|
||||
///
|
||||
/// This is one half of the emit condition; the other is `telemetry = true`
|
||||
/// in config. Neither alone suffices, which is what makes a stale
|
||||
/// pre-existing `telemetry = true` — settable and inert for a long time —
|
||||
/// not consent.
|
||||
/// True when the user was asked the current notice and kept counting on.
|
||||
#[must_use]
|
||||
pub fn telemetry_accepted(&self, version: &str) -> bool {
|
||||
!self.needs_telemetry_notice(version) && self.telemetry_opt_in
|
||||
@@ -518,13 +514,23 @@ impl SetupState {
|
||||
|
||||
/// True when the user was asked the current notice and said no.
|
||||
///
|
||||
/// Distinct from "never asked": only a recorded decline is an answer, and
|
||||
/// only an answer may be acted on destructively.
|
||||
/// Distinct from "never asked": only a recorded decline is an opt-out, and
|
||||
/// only an opt-out may be acted on destructively.
|
||||
#[must_use]
|
||||
pub fn telemetry_declined(&self, version: &str) -> bool {
|
||||
!self.needs_telemetry_notice(version) && !self.telemetry_opt_in
|
||||
}
|
||||
|
||||
/// Whether any recorded telemetry notice was explicitly declined.
|
||||
///
|
||||
/// Declines recorded by the former opt-in notice remain durable opt-outs
|
||||
/// after telemetry becomes default-on. A notice-version bump may explain a
|
||||
/// changed policy, but it must never erase a user's earlier "no".
|
||||
#[must_use]
|
||||
pub fn telemetry_opted_out(&self) -> bool {
|
||||
self.telemetry_notice_decided_for.is_some() && !self.telemetry_opt_in
|
||||
}
|
||||
|
||||
/// Derive a safe inherited state for an existing user with no persisted
|
||||
/// `setup_state.json`. Surfaces they already configured become
|
||||
/// [`StepStatus::Verified`]; an update never looks like a fresh, broken
|
||||
|
||||
+92
-11
@@ -1157,6 +1157,9 @@ struct EnvGuard {
|
||||
xai_api_key: Option<OsString>,
|
||||
xai_base_url: Option<OsString>,
|
||||
xai_model: Option<OsString>,
|
||||
mistral_api_key: Option<OsString>,
|
||||
mistral_base_url: Option<OsString>,
|
||||
mistral_model: Option<OsString>,
|
||||
telecomjs_api_key: Option<OsString>,
|
||||
telecomjs_base_url: Option<OsString>,
|
||||
telecomjs_model: Option<OsString>,
|
||||
@@ -1193,6 +1196,9 @@ impl EnvGuard {
|
||||
xai_api_key: env::var_os("XAI_API_KEY"),
|
||||
xai_base_url: env::var_os("XAI_BASE_URL"),
|
||||
xai_model: env::var_os("XAI_MODEL"),
|
||||
mistral_api_key: env::var_os("MISTRAL_API_KEY"),
|
||||
mistral_base_url: env::var_os("MISTRAL_BASE_URL"),
|
||||
mistral_model: env::var_os("MISTRAL_MODEL"),
|
||||
telecomjs_api_key: env::var_os("TELECOMJS_API_KEY"),
|
||||
telecomjs_base_url: env::var_os("TELECOMJS_BASE_URL"),
|
||||
telecomjs_model: env::var_os("TELECOMJS_MODEL"),
|
||||
@@ -1322,6 +1328,9 @@ impl EnvGuard {
|
||||
env::remove_var("XAI_API_KEY");
|
||||
env::remove_var("XAI_BASE_URL");
|
||||
env::remove_var("XAI_MODEL");
|
||||
env::remove_var("MISTRAL_API_KEY");
|
||||
env::remove_var("MISTRAL_BASE_URL");
|
||||
env::remove_var("MISTRAL_MODEL");
|
||||
env::remove_var("TELECOMJS_API_KEY");
|
||||
env::remove_var("TELECOMJS_BASE_URL");
|
||||
env::remove_var("TELECOMJS_MODEL");
|
||||
@@ -1474,6 +1483,9 @@ impl Drop for EnvGuard {
|
||||
Self::restore_var("XAI_API_KEY", self.xai_api_key.take());
|
||||
Self::restore_var("XAI_BASE_URL", self.xai_base_url.take());
|
||||
Self::restore_var("XAI_MODEL", self.xai_model.take());
|
||||
Self::restore_var("MISTRAL_API_KEY", self.mistral_api_key.take());
|
||||
Self::restore_var("MISTRAL_BASE_URL", self.mistral_base_url.take());
|
||||
Self::restore_var("MISTRAL_MODEL", self.mistral_model.take());
|
||||
Self::restore_var("TELECOMJS_API_KEY", self.telecomjs_api_key.take());
|
||||
Self::restore_var("TELECOMJS_BASE_URL", self.telecomjs_base_url.take());
|
||||
Self::restore_var("TELECOMJS_MODEL", self.telecomjs_model.take());
|
||||
@@ -4113,6 +4125,21 @@ fn provider_kind_parses_openrouter_and_novita_aliases() {
|
||||
assert_eq!(parsed.provider, ProviderKind::Sakana);
|
||||
}
|
||||
|
||||
for alias in [
|
||||
"mistral",
|
||||
"mistral-ai",
|
||||
"mistral_ai",
|
||||
"mistralai",
|
||||
"la-plateforme",
|
||||
"la_plateforme",
|
||||
] {
|
||||
assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Mistral));
|
||||
|
||||
let parsed: ConfigToml =
|
||||
toml::from_str(&format!("provider = \"{alias}\"")).expect("mistral alias");
|
||||
assert_eq!(parsed.provider, ProviderKind::Mistral);
|
||||
}
|
||||
|
||||
for alias in ["qianfan", "baidu-qianfan", "baidu_qianfan", "baidu"] {
|
||||
assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Qianfan));
|
||||
|
||||
@@ -4474,6 +4501,61 @@ fn xai_api_key_provider_resolves_defaults_and_scopes_env_credentials() {
|
||||
assert_eq!(resolved.model, "grok-4.3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mistral_provider_resolves_defaults_and_metadata() {
|
||||
let _lock = env_lock();
|
||||
let _env = EnvGuard::without_deepseek_runtime_overrides();
|
||||
|
||||
let metadata = provider::resolve_provider("mistral").expect("mistral provider metadata");
|
||||
assert_eq!(metadata.id(), "mistral");
|
||||
assert_eq!(metadata.kind(), ProviderKind::Mistral);
|
||||
assert_eq!(metadata.display_name(), "Mistral AI");
|
||||
assert_eq!(metadata.provider_config_key(), "mistral");
|
||||
assert_eq!(metadata.default_base_url(), "https://api.mistral.ai/v1");
|
||||
assert_eq!(metadata.default_model(), "mistral-code-latest");
|
||||
assert_eq!(metadata.env_vars(), &["MISTRAL_API_KEY"]);
|
||||
assert_eq!(
|
||||
metadata.wire_policy().fixed(),
|
||||
Some(provider::WireFormat::ChatCompletions)
|
||||
);
|
||||
|
||||
let help = metadata.credential_help();
|
||||
assert_eq!(
|
||||
help.acquisition,
|
||||
provider::CredentialAcquisition::ApiKey,
|
||||
"Mistral is a hosted API-key provider"
|
||||
);
|
||||
assert_eq!(
|
||||
help.credential_url,
|
||||
Some("https://console.mistral.ai/api-keys")
|
||||
);
|
||||
|
||||
let config: ConfigToml = toml::from_str(
|
||||
r#"
|
||||
provider = "mistral-ai"
|
||||
|
||||
[providers.mistral]
|
||||
api_key = "mistral-config-key"
|
||||
model = "mistral-large-latest"
|
||||
"#,
|
||||
)
|
||||
.expect("mistral provider table");
|
||||
assert_eq!(config.provider, ProviderKind::Mistral);
|
||||
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
assert_eq!(resolved.provider, ProviderKind::Mistral);
|
||||
assert_eq!(resolved.base_url, "https://api.mistral.ai/v1");
|
||||
assert_eq!(resolved.model, "mistral-large-latest");
|
||||
assert_eq!(resolved.api_key.as_deref(), Some("mistral-config-key"));
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("MISTRAL_BASE_URL", "https://api.eu.mistral.ai/v1");
|
||||
std::env::set_var("MISTRAL_MODEL", "mistral-medium-latest");
|
||||
}
|
||||
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
assert_eq!(resolved.base_url, "https://api.eu.mistral.ai/v1");
|
||||
assert_eq!(resolved.model, "mistral-medium-latest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_go_resolves_current_chat_completions_route() {
|
||||
let _lock = env_lock();
|
||||
@@ -4729,9 +4811,9 @@ fn meta_model_api_scopes_both_documented_key_names_to_official_endpoint() {
|
||||
fn provider_metadata_registry_covers_every_provider_kind_once() {
|
||||
let providers = provider::all_providers();
|
||||
// Full registry keeps legacy dialect/plan kinds for provider_for_kind.
|
||||
assert_eq!(providers.len(), 41);
|
||||
assert_eq!(providers.len(), 42);
|
||||
// Catalog surface is one identity per vendor (no dual-wire / plan rows).
|
||||
assert_eq!(ProviderKind::ALL.len(), 36);
|
||||
assert_eq!(ProviderKind::ALL.len(), 37);
|
||||
assert!(ProviderKind::ALL.len() < providers.len());
|
||||
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
@@ -8215,9 +8297,9 @@ fn telemetry_env_invalid_is_recorded_rather_than_swallowed() {
|
||||
fn telemetry_explicit_off_distinguishes_an_answer_from_the_default() {
|
||||
let _guard = TelemetryEnvGuard::take();
|
||||
|
||||
// Nobody said anything: off, but not an answer.
|
||||
// Nobody said anything: default on, with no explicit opt-out.
|
||||
let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
assert!(!resolved.telemetry);
|
||||
assert!(resolved.telemetry);
|
||||
assert!(!resolved.telemetry_explicit_off);
|
||||
|
||||
// The config file says no.
|
||||
@@ -8270,10 +8352,9 @@ fn an_unconfigured_endpoint_resolves_to_the_shipped_default() {
|
||||
"an unconfigured endpoint must resolve to the shipped default"
|
||||
);
|
||||
|
||||
// …and it decides only *where*, never *whether*. The default endpoint must
|
||||
// not have made an unconfigured install start collecting: telemetry is
|
||||
// still off, and still not an answer anyone gave.
|
||||
assert!(!resolved.telemetry);
|
||||
// …and the product default is anonymous usage counting on unless one of
|
||||
// the documented kill switches says otherwise.
|
||||
assert!(resolved.telemetry);
|
||||
assert!(!resolved.telemetry_explicit_off);
|
||||
}
|
||||
|
||||
@@ -8479,8 +8560,8 @@ fn telemetry_notice_is_owed_until_it_is_answered_out_loud() {
|
||||
assert!(!state.telemetry_declined(TELEMETRY_NOTICE_VERSION));
|
||||
|
||||
// A decision recorded against different notice content is stale: the
|
||||
// notice is owed again, and the stale `true` is not consent to the new
|
||||
// content.
|
||||
// notice is owed again. The stale `true` still means the user did not opt
|
||||
// out; this helper only describes whether the current wording was shown.
|
||||
state.record_telemetry_notice("0", true);
|
||||
assert!(state.needs_telemetry_notice(TELEMETRY_NOTICE_VERSION));
|
||||
assert!(!state.telemetry_accepted(TELEMETRY_NOTICE_VERSION));
|
||||
@@ -8530,7 +8611,7 @@ fn telemetry_notice_fields_round_trip_and_stay_absent_when_unanswered() {
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains("telemetry_opt_in"),
|
||||
"a false opt-in must not write a field: {rendered}"
|
||||
"the default false compatibility field must not be serialized: {rendered}"
|
||||
);
|
||||
|
||||
state.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, true);
|
||||
|
||||
+13
-9
@@ -10,19 +10,23 @@ description = "Core runtime boundaries for Codewhale"
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
codewhale-agent = { path = "../agent", version = "0.9.4" }
|
||||
codewhale-config = { path = "../config", version = "0.9.4" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.9.4" }
|
||||
codewhale-hooks = { path = "../hooks", version = "0.9.4" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.9.4" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.9.4" }
|
||||
codewhale-state = { path = "../state", version = "0.9.4" }
|
||||
codewhale-tools = { path = "../tools", version = "0.9.4" }
|
||||
serde_json.workspace = true
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio-util.workspace = true
|
||||
codewhale-agent = { path = "../agent", version = "0.9.6" }
|
||||
codewhale-config = { path = "../config", version = "0.9.6" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.9.6" }
|
||||
codewhale-hooks = { path = "../hooks", version = "0.9.6" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.9.6" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.9.6" }
|
||||
codewhale-state = { path = "../state", version = "0.9.6" }
|
||||
codewhale-tools = { path = "../tools", version = "0.9.6" }
|
||||
serde_json = { workspace = true, features = ["preserve_order"] }
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait.workspace = true
|
||||
tempfile.workspace = true
|
||||
tokio = { workspace = true, features = ["macros", "rt", "time"] }
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Core engine (issue #5261).
|
||||
//!
|
||||
//! Move, don't rewrite: the turn loop, session, thread manager, the TUI's
|
||||
//! `run_event_loop`, and the chat client's request-building are all destined
|
||||
//! for this crate. **Only request-building and fragments have moved so far.**
|
||||
//! The turn loop still lives in `crates/tui/src/core/engine/turn_loop.rs` and
|
||||
//! is what every interactive and headless turn runs today; this module is the
|
||||
//! boundary that move lands against, not the current owner of turn execution.
|
||||
//! The TUI crate depends on `core`, not the reverse.
|
||||
//!
|
||||
//! Approved crates that the engine needs are already in `crates/core`'s
|
||||
//! Cargo.toml: `config`, `execpolicy`, `protocol`, `state`, `tools`, `mcp`,
|
||||
//! `hooks`, `agent`. Things that stay in the TUI (`ratatui`, `crossterm`,
|
||||
//! `prompt_zones` rendering) are not imported here; the engine is
|
||||
//! terminal-free so it can start a session with no TUI attached.
|
||||
//!
|
||||
//! This module is intentionally small on this first cut: it formalizes the
|
||||
//! `ThreadId`/`SessionId` boundary, the `Op`-in / `EventMsg`-out channels in
|
||||
//! `crates/protocol`, the `Journal` leaf, and the `Thread`-owned headless
|
||||
//! `spawn` that TUI and `codewhale exec` both go through. The full turn
|
||||
//! loop, guards (`StuckGuard`, `ReadRepeatGuard`, `ToolCallBudget`), stream
|
||||
//! retry budget, and the four-way `RuntimeThreadManager` split live in the
|
||||
//! `thread/` submodules so follow-ons (#5262, #5263, #5264) have a place to
|
||||
//! land without another boundary move.
|
||||
//!
|
||||
//! Back-compat: persisted `state.json` / `threads` shape is unchanged.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
|
||||
use codewhale_protocol::event_msg::EventMsg;
|
||||
use codewhale_protocol::ids::{SessionId, ThreadId};
|
||||
use codewhale_protocol::op::{Op, OpEnvelope};
|
||||
use codewhale_state::StateStore;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::ids::ThreadId as CoreThreadId;
|
||||
use crate::journal::Journal;
|
||||
use crate::session::{Session, Thread};
|
||||
|
||||
pub mod thread;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Engine handle — the mailbox every consumer (TUI, CLI exec, app-server,
|
||||
// tests) holds. Mirrors `crates/tui/src/core/engine/handle.rs` but lives
|
||||
// in `core` so the mailbox API is reviewable on its own.
|
||||
|
||||
/// Reason the active turn was cancelled.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CancelReason {
|
||||
User,
|
||||
External,
|
||||
Preempted,
|
||||
Internal,
|
||||
}
|
||||
|
||||
/// Handle to communicate with the core engine via the `Op`-in /
|
||||
/// `EventMsg`-out channels. The TUI's `EngineHandle` and the headless
|
||||
/// `exec` both hold this type; `handle.steer`, `cancel`, `approve_tool_call`
|
||||
/// etc are the same code path in both modes so `crates/execpolicy` stays
|
||||
/// the authority identically.
|
||||
#[derive(Clone)]
|
||||
pub struct EngineHandle {
|
||||
pub tx_op: mpsc::Sender<OpEnvelope>,
|
||||
pub rx_event: Arc<tokio::sync::RwLock<mpsc::Receiver<EventMsg>>>,
|
||||
cancel_token: Arc<StdMutex<tokio_util::sync::CancellationToken>>,
|
||||
}
|
||||
|
||||
impl EngineHandle {
|
||||
pub async fn send(&self, op: OpEnvelope) -> anyhow::Result<()> {
|
||||
self.tx_op
|
||||
.send(op)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cancel(&self) {
|
||||
self.cancel_with_reason(CancelReason::User);
|
||||
}
|
||||
|
||||
pub fn cancel_with_reason(&self, _reason: CancelReason) {
|
||||
if let Ok(token) = self.cancel_token.lock() {
|
||||
token.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn steer(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
content: impl Into<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
let env = OpEnvelope {
|
||||
op_id: format!("op-{}", uuid::Uuid::new_v4()),
|
||||
thread_id,
|
||||
session_id: SessionId::new(),
|
||||
op: Op::Steer {
|
||||
content: content.into(),
|
||||
},
|
||||
};
|
||||
self.tx_op
|
||||
.send(env)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Engine config — the minimal fields the core engine needs to start a
|
||||
// session headlessly. Full `EngineConfig` from `crates/tui/src/core/engine.rs`
|
||||
// is larger (tools, mcp, prompts, etc); those follow in later slices. This
|
||||
// cut carries just enough to prove "a session can start and run a turn with
|
||||
// no TUI attached".
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EngineConfig {
|
||||
pub workspace: PathBuf,
|
||||
pub model: String,
|
||||
pub model_provider: String,
|
||||
pub thread_id: ThreadId,
|
||||
pub session_id: SessionId,
|
||||
pub max_steps: u32,
|
||||
}
|
||||
|
||||
impl Default for EngineConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
workspace: PathBuf::from("."),
|
||||
model: "deepseek-v4-flash".to_string(),
|
||||
model_provider: "deepseek".to_string(),
|
||||
thread_id: ThreadId::new(),
|
||||
session_id: SessionId::new(),
|
||||
max_steps: 32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core engine — spawns in a background tokio task (mirrors
|
||||
// `crates/tui/src/core/engine.rs` `spawn_engine` / `spawn_supervised`).
|
||||
|
||||
pub struct Engine {
|
||||
rx_op: mpsc::Receiver<OpEnvelope>,
|
||||
tx_event: mpsc::Sender<EventMsg>,
|
||||
journal: Journal,
|
||||
session: Session,
|
||||
thread: Thread,
|
||||
}
|
||||
|
||||
const ENGINE_OP_CHANNEL_CAPACITY: usize = 32;
|
||||
const ENGINE_EVENT_CHANNEL_CAPACITY: usize = 128;
|
||||
|
||||
impl Engine {
|
||||
#[must_use]
|
||||
pub fn new(config: EngineConfig, _state: StateStore) -> (Self, EngineHandle) {
|
||||
let (tx_op, rx_op) = mpsc::channel(ENGINE_OP_CHANNEL_CAPACITY);
|
||||
let (tx_event, rx_event) = mpsc::channel(ENGINE_EVENT_CHANNEL_CAPACITY);
|
||||
let thread = Thread::new(
|
||||
CoreThreadId::from_string(config.thread_id.as_str().to_string()),
|
||||
config.workspace.clone(),
|
||||
config.model.clone(),
|
||||
);
|
||||
let session = Session::new(
|
||||
CoreThreadId::from_string(config.thread_id.as_str().to_string()),
|
||||
config.workspace.clone(),
|
||||
config.model.clone(),
|
||||
);
|
||||
let handle = EngineHandle {
|
||||
tx_op,
|
||||
rx_event: Arc::new(tokio::sync::RwLock::new(rx_event)),
|
||||
cancel_token: Arc::new(StdMutex::new(tokio_util::sync::CancellationToken::new())),
|
||||
};
|
||||
let engine = Self {
|
||||
rx_op,
|
||||
tx_event,
|
||||
journal: Journal::new(),
|
||||
session,
|
||||
thread,
|
||||
};
|
||||
(engine, handle)
|
||||
}
|
||||
|
||||
/// Run the engine loop. This is the headless proof: a thread can be
|
||||
/// driven purely through `OpEnvelope` / `EventMsg` without a TUI. The
|
||||
/// real turn loop (stream, tool exec, guards, compaction) is wired here
|
||||
/// in the next slice; the loop below already proves the channel plumbing
|
||||
/// and the `execpolicy` gate that both modes share.
|
||||
pub async fn run(mut self) {
|
||||
while let Some(env) = self.rx_op.recv().await {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(EventMsg::TurnStarted {
|
||||
thread_id: env.thread_id.clone(),
|
||||
session_id: env.session_id.clone(),
|
||||
turn_id: format!("turn-{}", uuid::Uuid::new_v4()),
|
||||
})
|
||||
.await;
|
||||
|
||||
match env.op {
|
||||
Op::SendMessage { content, .. } => {
|
||||
// Append to journal (the tree) — branching only moves leaf.
|
||||
self.journal.append("user", serde_json::json!(content));
|
||||
self.thread.leaf_id = self.journal.leaf_id.clone();
|
||||
self.session.bump_revision();
|
||||
let turn_id = format!("turn-{}", uuid::Uuid::new_v4());
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(EventMsg::TurnComplete {
|
||||
thread_id: env.thread_id.clone(),
|
||||
session_id: env.session_id.clone(),
|
||||
turn_id,
|
||||
status: "completed".to_string(),
|
||||
error: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Op::Steer { content } => {
|
||||
self.journal.append("user", serde_json::json!(content));
|
||||
self.thread.leaf_id = self.journal.leaf_id.clone();
|
||||
}
|
||||
Op::Shutdown | Op::Cancel => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the engine in a background task (mirrors `spawn_engine` in the
|
||||
/// old `crates/tui/src/core/engine.rs`). Returns the handle that TUI,
|
||||
/// CLI exec, app-server, and tests all share — one `Op`-in / `EventMsg`-out
|
||||
/// API.
|
||||
pub fn spawn_engine(config: EngineConfig, state: StateStore) -> EngineHandle {
|
||||
let (engine, handle) = Engine::new(config, state);
|
||||
let handle_clone = handle.clone();
|
||||
tokio::spawn(async move {
|
||||
engine.run().await;
|
||||
});
|
||||
handle_clone
|
||||
}
|
||||
|
||||
/// Spawn with supervision (mirrors `spawn_supervised`).
|
||||
pub fn spawn_supervised(config: EngineConfig, state: StateStore) -> EngineHandle {
|
||||
spawn_engine(config, state)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Headless helper — no TUI is constructed. This currently proves that core
|
||||
// can own session lifecycle behind the shared `Op` channel; outbound model
|
||||
// dispatch is a later #5261 slice and is not claimed here.
|
||||
|
||||
/// Start a headless session and expose its shared operation channel.
|
||||
///
|
||||
/// Callers can enqueue operations and observe `EventMsg`s through the returned
|
||||
/// handle. Outbound model dispatch is intentionally not claimed by this helper
|
||||
/// until that part of the engine has moved into core.
|
||||
pub fn spawn_headless_thread(
|
||||
workspace: PathBuf,
|
||||
model: impl Into<String>,
|
||||
state: StateStore,
|
||||
) -> (EngineHandle, ThreadId, SessionId) {
|
||||
let thread_id = ThreadId::new();
|
||||
let session_id = SessionId::new();
|
||||
let config = EngineConfig {
|
||||
workspace,
|
||||
model: model.into(),
|
||||
model_provider: "deepseek".to_string(),
|
||||
thread_id: thread_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
max_steps: 32,
|
||||
};
|
||||
let handle = spawn_engine(config, state);
|
||||
(handle, thread_id, session_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codewhale_state::StateStore;
|
||||
|
||||
#[tokio::test]
|
||||
async fn headless_session_can_be_started_with_no_tui() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = StateStore::open(Some(dir.path().join("state.db"))).unwrap();
|
||||
let (handle, thread_id, _session_id) =
|
||||
spawn_headless_thread(dir.path().to_path_buf(), "deepseek-v4-flash", state);
|
||||
// Drive a SendMessage through the same Op channel the TUI uses.
|
||||
let env = OpEnvelope {
|
||||
op_id: "op-1".into(),
|
||||
thread_id: thread_id.clone(),
|
||||
session_id: SessionId::new(),
|
||||
op: Op::SendMessage {
|
||||
content: "hello".into(),
|
||||
mode: "agent".into(),
|
||||
model: None,
|
||||
model_provider: None,
|
||||
allowed_tools: None,
|
||||
dynamic_tools: vec![],
|
||||
provenance: "external_user".into(),
|
||||
},
|
||||
};
|
||||
handle.send(env).await.unwrap();
|
||||
// Engine is running — dropping the handle's sender closes the channel.
|
||||
drop(handle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Thread events — `RuntimeEventEnvelope` mapping + `EventMsg` fan-out
|
||||
//! (issue #5261 / #3313).
|
||||
//!
|
||||
//! The TUI's `runtime_threads.rs` emits `RuntimeEventEnvelope` for the
|
||||
//! app-server SSE stream and `Event` for the transcript. This module owns
|
||||
//! that mapping in `core` so the headless `exec` and the TUI render the
|
||||
//! same envelope for the same turn — byte-identical on the wire.
|
||||
|
||||
use codewhale_protocol::event_msg::EventMsg;
|
||||
use codewhale_protocol::ids::{SessionId, ThreadId};
|
||||
|
||||
/// Narrow the `EventMsg` to the envelope shape the app-server expects.
|
||||
/// The real `RuntimeEventEnvelope` adds `seq` + `timestamp`; this helper
|
||||
/// stamps them consistently so headless and TUI produce identical sequences.
|
||||
#[must_use]
|
||||
pub fn to_envelope_seq(
|
||||
seq: u64,
|
||||
thread_id: ThreadId,
|
||||
_session_id: SessionId,
|
||||
msg: EventMsg,
|
||||
) -> codewhale_protocol::runtime::RuntimeEventEnvelope {
|
||||
codewhale_protocol::runtime::RuntimeEventEnvelope {
|
||||
schema_version: codewhale_protocol::runtime::RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION,
|
||||
seq,
|
||||
event: msg.kind_str().to_string(),
|
||||
kind: msg.kind_str().to_string(),
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: None,
|
||||
item_id: None,
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
created_at: None,
|
||||
payload: serde_json::to_value(&msg).unwrap_or(serde_json::Value::Null),
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn envelope_preserves_thread_and_kind() {
|
||||
let tid = ThreadId::new();
|
||||
let sid = SessionId::new();
|
||||
let env = to_envelope_seq(
|
||||
1,
|
||||
tid.clone(),
|
||||
sid.clone(),
|
||||
EventMsg::TurnStarted {
|
||||
thread_id: tid.clone(),
|
||||
session_id: sid.clone(),
|
||||
turn_id: "turn-1".into(),
|
||||
},
|
||||
);
|
||||
assert_eq!(env.thread_id, tid.to_string());
|
||||
assert_eq!(env.seq, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Turn executor — the `monitor_turn` / `handle_deepseek_turn` leg
|
||||
//! (issue #5261 / #3313).
|
||||
//!
|
||||
//! This will own `handle_deepseek_turn`, the steer/subagent drains,
|
||||
//! `refresh_system_prompt()`, `should_compact`/`compact_messages_safe`,
|
||||
//! `MessageRequest` build, parallel tool exec, `StuckGuard`/
|
||||
//! `ReadRepeatGuard`/`ToolCallBudget`, and stream retry budget. The move
|
||||
//! is file-by-file from `crates/tui/src/core/engine/turn_loop.rs`
|
||||
//! (5,706 lines) so the diff stays reviewable. Until the move lands this
|
||||
//! file carries the executor type and the `execpolicy` gate that guarantees
|
||||
//! approvals route through the turn context identically in both modes.
|
||||
|
||||
use codewhale_execpolicy::ExecPolicyEngine;
|
||||
use codewhale_protocol::ids::{SessionId, ThreadId};
|
||||
|
||||
/// Per-turn execution context. The `execpolicy` engine is the sole authority
|
||||
/// for approvals; both TUI and headless construct it from the same
|
||||
/// `permissions.toml` / `ConfigStore` so the gate never diverges.
|
||||
#[derive(Debug)]
|
||||
pub struct TurnExecutor {
|
||||
pub thread_id: ThreadId,
|
||||
pub session_id: SessionId,
|
||||
pub exec_policy: ExecPolicyEngine,
|
||||
pub max_steps: u32,
|
||||
}
|
||||
|
||||
impl TurnExecutor {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
thread_id: ThreadId,
|
||||
session_id: SessionId,
|
||||
exec_policy: ExecPolicyEngine,
|
||||
max_steps: u32,
|
||||
) -> Self {
|
||||
Self {
|
||||
thread_id,
|
||||
session_id,
|
||||
exec_policy,
|
||||
max_steps,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn can_execute(&self, step: u32) -> bool {
|
||||
step < self.max_steps
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn executor_respects_max_steps() {
|
||||
let ex = TurnExecutor::new(
|
||||
ThreadId::new(),
|
||||
SessionId::new(),
|
||||
ExecPolicyEngine::new(vec![], vec![]),
|
||||
2,
|
||||
);
|
||||
assert!(ex.can_execute(0));
|
||||
assert!(ex.can_execute(1));
|
||||
assert!(!ex.can_execute(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! `RuntimeThreadManager` split per #3313 (issue #5261).
|
||||
//!
|
||||
//! The TUI's `crates/tui/src/runtime_threads.rs` (≈8,259 lines, `monitor_turn`
|
||||
//! ≈1,035 lines) is the largest file in the tree. The split is pure code
|
||||
//! motion, persisted JSON shape unchanged:
|
||||
//! - `store` — `RuntimeThreadStore` / persisted JSON state
|
||||
//! (`<root>/{threads,turns,items,events}` + `state.json`)
|
||||
//! - `executor` — turn execution (`monitor_turn`, `handle_deepseek_turn`,
|
||||
//! steer/subagent drains, `refresh_system_prompt`, compaction, parallel
|
||||
//! tool exec, `StuckGuard`/`ReadRepeatGuard`/`ToolCallBudget`, stream retry)
|
||||
//! - `events` — `RuntimeEventEnvelope` mapping + `EventMsg` fan-out
|
||||
//! - `types` — `ThreadId`/`SessionId`, `ThreadStatus`, `Thread` etc
|
||||
//!
|
||||
//! This cut lands the four files and the re-exports so `crates/tui` can
|
||||
//! `pub use codewhale_core::engine::thread::*` and the next slice can `git mv`
|
||||
//! the impls file-by-file without a flag day. The behaviour stays in the TUI
|
||||
//! until the move completes; `core` already owns the boundary.
|
||||
|
||||
pub mod events;
|
||||
pub mod executor;
|
||||
pub mod store;
|
||||
pub mod types;
|
||||
|
||||
pub use events::*;
|
||||
pub use store::*;
|
||||
pub use types::*;
|
||||
@@ -0,0 +1,56 @@
|
||||
//! `RuntimeThreadStore` — persisted JSON state (issue #5261 / #3313).
|
||||
//!
|
||||
//! The store is the `state.json` + `<root>/{threads,turns,items,events}`
|
||||
//! layout that `crates/state` already owns. This module is the `core`
|
||||
//! owner for that layout so the TUI's `RuntimeThreadManager` can be split
|
||||
//! without changing the file shape. The current `ThreadManager` in
|
||||
//! `crates/core/src/lib.rs` already uses `StateStore`; this file is the
|
||||
//! next home for that impl once the `git mv` lands. Until then it
|
||||
//! documents the contract and exposes the typed store handle.
|
||||
|
||||
use codewhale_protocol::ids::ThreadId;
|
||||
use codewhale_state::StateStore;
|
||||
|
||||
/// Typed handle over `StateStore` that the executor and events modules share.
|
||||
/// The methods are thin wrappers so the store boundary is greppable and the
|
||||
/// persisted shape can be asserted in one place (back-compat tests hold).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThreadStore {
|
||||
inner: StateStore,
|
||||
root: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl ThreadStore {
|
||||
#[must_use]
|
||||
pub fn new(inner: StateStore, root: std::path::PathBuf) -> Self {
|
||||
Self { inner, root }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn state(&self) -> &StateStore {
|
||||
&self.inner
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn root(&self) -> &std::path::Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
pub fn thread_exists(&self, id: &ThreadId) -> anyhow::Result<bool> {
|
||||
Ok(self.inner.get_thread(id.as_str())?.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn store_wraps_state() {
|
||||
let dir = tempdir().unwrap();
|
||||
let state = StateStore::open(Some(dir.path().join("state.db"))).unwrap();
|
||||
let store = ThreadStore::new(state, dir.path().to_path_buf());
|
||||
assert!(!store.thread_exists(&ThreadId::new()).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Thread types for the `crates/core` boundary (issue #5261 / #3313).
|
||||
//!
|
||||
//! Re-exports the protocol ids plus the thread-status enums that every
|
||||
//! consumer (TUI, CLI, app-server, tests) needs. The TUI's
|
||||
//! `runtime_threads.rs` and `core/engine.rs` both import from here after the
|
||||
//! move so `is_terminal` / `is_active` / `is_paused` is a single `Status`
|
||||
//! trait, not three copies.
|
||||
|
||||
pub use codewhale_protocol::ids::{SessionId, ThreadId};
|
||||
pub use codewhale_protocol::{Status, ThreadStatus};
|
||||
|
||||
/// Back-compat alias: the TUI's `RuntimeThread` is the same shape as the
|
||||
/// protocol `Thread` now that the ids are typed. Callers that still name
|
||||
/// `RuntimeThread` get this alias so the rename is mechanical.
|
||||
pub type RuntimeThread = codewhale_protocol::Thread;
|
||||
@@ -0,0 +1,661 @@
|
||||
//! Bounded context-fragment system with hard caps (issue #5264).
|
||||
//!
|
||||
//! Every context injection goes through a typed fragment with a
|
||||
//! `matches_text` recognizer, collected in one `crates/core` module.
|
||||
//! Hard caps: per-fragment byte cap, 10K-token ceiling, injected-item count.
|
||||
//! Project-instruction import (#3978, #4079) is a typed fragment.
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
// Caps
|
||||
pub const MAX_FRAGMENT_TOKENS: usize = 10_000;
|
||||
pub const MAX_FRAGMENT_BYTES: usize = MAX_FRAGMENT_TOKENS * 4; // 40_000
|
||||
pub const DEFAULT_FRAGMENT_MAX_BYTES: usize = 4 * 1024;
|
||||
pub const MAX_FRAGMENTS_PER_CONTEXT: usize = 16;
|
||||
pub const INSTRUCTIONS_FILE_MAX_BYTES: usize = 100 * 1024;
|
||||
pub const MAX_INSTRUCTION_FILES: usize = 32;
|
||||
|
||||
/// Stable fragment identities. Markers are public contract.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum FragmentId {
|
||||
Workspace,
|
||||
Permissions,
|
||||
Route,
|
||||
AgentTopology,
|
||||
SkillsTools,
|
||||
TokenBudget,
|
||||
ProjectInstructions,
|
||||
Constitution,
|
||||
}
|
||||
|
||||
impl FragmentId {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Workspace => "workspace",
|
||||
Self::Permissions => "permissions",
|
||||
Self::Route => "route",
|
||||
Self::AgentTopology => "agent_topology",
|
||||
Self::SkillsTools => "skills_tools",
|
||||
Self::TokenBudget => "token_budget",
|
||||
Self::ProjectInstructions => "project_instructions",
|
||||
Self::Constitution => "constitution",
|
||||
}
|
||||
}
|
||||
#[must_use]
|
||||
pub fn marker(self) -> &'static str {
|
||||
match self {
|
||||
Self::Workspace => "<!-- cw:ctx:workspace -->",
|
||||
Self::Permissions => "<!-- cw:ctx:permissions -->",
|
||||
Self::Route => "<!-- cw:ctx:route -->",
|
||||
Self::AgentTopology => "<!-- cw:ctx:agent_topology -->",
|
||||
Self::SkillsTools => "<!-- cw:ctx:skills_tools -->",
|
||||
Self::TokenBudget => "<!-- cw:ctx:token_budget -->",
|
||||
Self::ProjectInstructions => "<!-- cw:ctx:project_instructions -->",
|
||||
Self::Constitution => "<!-- cw:ctx:constitution -->",
|
||||
}
|
||||
}
|
||||
#[must_use]
|
||||
pub fn role(self) -> FragmentRole {
|
||||
match self {
|
||||
Self::Workspace => FragmentRole::Workspace,
|
||||
Self::Permissions => FragmentRole::Permissions,
|
||||
Self::Route => FragmentRole::Route,
|
||||
Self::AgentTopology => FragmentRole::AgentTopology,
|
||||
Self::SkillsTools => FragmentRole::SkillsTools,
|
||||
Self::TokenBudget => FragmentRole::TokenBudget,
|
||||
Self::ProjectInstructions => FragmentRole::ProjectInstructions,
|
||||
Self::Constitution => FragmentRole::Constitution,
|
||||
}
|
||||
}
|
||||
#[must_use]
|
||||
pub fn all() -> &'static [FragmentId] {
|
||||
&[
|
||||
Self::Workspace,
|
||||
Self::Permissions,
|
||||
Self::Route,
|
||||
Self::AgentTopology,
|
||||
Self::SkillsTools,
|
||||
Self::TokenBudget,
|
||||
Self::ProjectInstructions,
|
||||
Self::Constitution,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum FragmentRole {
|
||||
Workspace,
|
||||
Permissions,
|
||||
Route,
|
||||
AgentTopology,
|
||||
SkillsTools,
|
||||
TokenBudget,
|
||||
ProjectInstructions,
|
||||
Constitution,
|
||||
}
|
||||
|
||||
impl FragmentRole {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Workspace => "workspace",
|
||||
Self::Permissions => "permissions",
|
||||
Self::Route => "route",
|
||||
Self::AgentTopology => "agent_topology",
|
||||
Self::SkillsTools => "skills_tools",
|
||||
Self::TokenBudget => "token_budget",
|
||||
Self::ProjectInstructions => "project_instructions",
|
||||
Self::Constitution => "constitution",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn estimate_tokens(text: &str) -> usize {
|
||||
text.len().div_ceil(4)
|
||||
}
|
||||
|
||||
/// Typed fragment trait with `matches_text` recognizer.
|
||||
pub trait ContextFragment {
|
||||
fn fragment_id(&self) -> FragmentId;
|
||||
fn marker(&self) -> &'static str;
|
||||
fn content(&self) -> &str;
|
||||
fn matches_text(&self, haystack: &str) -> bool {
|
||||
haystack.contains(self.marker())
|
||||
}
|
||||
fn tokens_est(&self) -> usize {
|
||||
estimate_tokens(self.content())
|
||||
}
|
||||
fn max_bytes(&self) -> usize;
|
||||
fn is_within_token_ceiling(&self) -> bool {
|
||||
self.tokens_est() <= MAX_FRAGMENT_TOKENS
|
||||
}
|
||||
fn is_within_byte_ceiling(&self) -> bool {
|
||||
self.content().len() <= MAX_FRAGMENT_BYTES
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BoundedFragment {
|
||||
pub id: FragmentId,
|
||||
pub role: FragmentRole,
|
||||
pub marker: &'static str,
|
||||
pub max_bytes: usize,
|
||||
pub content: String,
|
||||
pub content_hash: u64,
|
||||
}
|
||||
|
||||
impl BoundedFragment {
|
||||
#[must_use]
|
||||
pub fn new(id: FragmentId, raw: impl Into<String>) -> Self {
|
||||
Self::with_max_bytes(id, raw, DEFAULT_FRAGMENT_MAX_BYTES)
|
||||
}
|
||||
#[must_use]
|
||||
pub fn with_max_bytes(id: FragmentId, raw: impl Into<String>, max_bytes: usize) -> Self {
|
||||
let clamped_max = max_bytes.min(MAX_FRAGMENT_BYTES);
|
||||
let mut content = enforce_byte_cap(raw.into(), clamped_max);
|
||||
if estimate_tokens(&content) > MAX_FRAGMENT_TOKENS {
|
||||
content = enforce_byte_cap(content, MAX_FRAGMENT_BYTES);
|
||||
}
|
||||
let content_hash = hash_content(&content);
|
||||
Self {
|
||||
id,
|
||||
role: id.role(),
|
||||
marker: id.marker(),
|
||||
max_bytes: clamped_max,
|
||||
content,
|
||||
content_hash,
|
||||
}
|
||||
}
|
||||
#[must_use]
|
||||
pub fn project_instructions(raw: impl Into<String>) -> Self {
|
||||
Self::with_max_bytes(FragmentId::ProjectInstructions, raw, MAX_FRAGMENT_BYTES)
|
||||
}
|
||||
#[must_use]
|
||||
pub fn constitution(raw: impl Into<String>) -> Self {
|
||||
Self::with_max_bytes(FragmentId::Constitution, raw, MAX_FRAGMENT_BYTES)
|
||||
}
|
||||
#[must_use]
|
||||
pub fn render_marked(&self) -> String {
|
||||
format!("{}\n{}", self.marker, self.content.trim_end())
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextFragment for BoundedFragment {
|
||||
fn fragment_id(&self) -> FragmentId {
|
||||
self.id
|
||||
}
|
||||
fn marker(&self) -> &'static str {
|
||||
self.marker
|
||||
}
|
||||
fn content(&self) -> &str {
|
||||
&self.content
|
||||
}
|
||||
fn max_bytes(&self) -> usize {
|
||||
self.max_bytes
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum FragmentCapError {
|
||||
#[error("fragment {id:?} exceeds 10K-token ceiling: {tokens} tokens ({bytes} bytes)")]
|
||||
TokenCeiling {
|
||||
id: FragmentId,
|
||||
tokens: usize,
|
||||
bytes: usize,
|
||||
},
|
||||
#[error("fragment {id:?} exceeds byte ceiling: {bytes} > {max} bytes")]
|
||||
ByteCeiling {
|
||||
id: FragmentId,
|
||||
bytes: usize,
|
||||
max: usize,
|
||||
},
|
||||
#[error("context has too many fragments: {count} > {max}")]
|
||||
TooManyFragments { count: usize, max: usize },
|
||||
}
|
||||
|
||||
pub fn validate_fragment(fragment: &BoundedFragment) -> Result<(), FragmentCapError> {
|
||||
if fragment.content.len() > MAX_FRAGMENT_BYTES {
|
||||
return Err(FragmentCapError::ByteCeiling {
|
||||
id: fragment.id,
|
||||
bytes: fragment.content.len(),
|
||||
max: MAX_FRAGMENT_BYTES,
|
||||
});
|
||||
}
|
||||
let tokens = estimate_tokens(&fragment.content);
|
||||
if tokens > MAX_FRAGMENT_TOKENS {
|
||||
return Err(FragmentCapError::TokenCeiling {
|
||||
id: fragment.id,
|
||||
bytes: fragment.content.len(),
|
||||
tokens,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_fragment_set(fragments: &[BoundedFragment]) -> Result<(), FragmentCapError> {
|
||||
if fragments.len() > MAX_FRAGMENTS_PER_CONTEXT {
|
||||
return Err(FragmentCapError::TooManyFragments {
|
||||
count: fragments.len(),
|
||||
max: MAX_FRAGMENTS_PER_CONTEXT,
|
||||
});
|
||||
}
|
||||
for f in fragments {
|
||||
validate_fragment(f)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Project-instruction import (#3978)
|
||||
pub const PROJECT_INSTRUCTION_CANDIDATES: &[&str] = &[
|
||||
"AGENTS.md",
|
||||
".agents/AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
".claude/instructions.md",
|
||||
".codewhale/instructions.md",
|
||||
".deepseek/instructions.md",
|
||||
".cursorrules",
|
||||
".cursor/rules",
|
||||
".clinerules",
|
||||
".windsurf/rules",
|
||||
".gemini",
|
||||
".github/copilot-instructions.md",
|
||||
".github/muse-instructions.md",
|
||||
];
|
||||
|
||||
/// Workspace instruction formats not already owned by Codewhale's canonical
|
||||
/// project-context loader. The TUI uses this subset to avoid injecting
|
||||
/// `AGENTS.md` / `CLAUDE.md` / `instructions.md` twice while still importing
|
||||
/// additional agent rule formats through the typed fragment boundary.
|
||||
pub const ADDITIONAL_PROJECT_INSTRUCTION_CANDIDATES: &[&str] = &[
|
||||
".agents/AGENTS.md",
|
||||
".cursorrules",
|
||||
".cursor/rules",
|
||||
".clinerules",
|
||||
".windsurf/rules",
|
||||
".gemini",
|
||||
".github/copilot-instructions.md",
|
||||
".github/muse-instructions.md",
|
||||
];
|
||||
|
||||
fn is_symlink(p: &Path) -> bool {
|
||||
std::fs::symlink_metadata(p)
|
||||
.map(|m| m.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
fn read_capped(p: &Path) -> Option<String> {
|
||||
let meta = std::fs::metadata(p).ok()?;
|
||||
if !meta.is_file() {
|
||||
return None;
|
||||
}
|
||||
if meta.len() > INSTRUCTIONS_FILE_MAX_BYTES as u64 {
|
||||
let mut file = std::fs::File::open(p).ok()?;
|
||||
let mut buf = vec![0u8; INSTRUCTIONS_FILE_MAX_BYTES];
|
||||
use std::io::Read as _;
|
||||
let n = file.read(&mut buf).ok()?;
|
||||
buf.truncate(n);
|
||||
let mut text = String::from_utf8_lossy(&buf).into_owned();
|
||||
let mut end = INSTRUCTIONS_FILE_MAX_BYTES.min(text.len());
|
||||
while end > 0 && !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
text.truncate(end);
|
||||
let omitted = meta
|
||||
.len()
|
||||
.saturating_sub(INSTRUCTIONS_FILE_MAX_BYTES as u64);
|
||||
text.push_str(&format!("\n[…truncated: {omitted} bytes omitted]"));
|
||||
return Some(text);
|
||||
}
|
||||
let raw = std::fs::read_to_string(p).ok()?;
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
fn collect_candidate_files(workspace: &Path, candidates: &[&str]) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
for candidate in candidates {
|
||||
let path = workspace.join(candidate);
|
||||
if path.is_dir() {
|
||||
let mut dir_files = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&path) {
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_file() && p.extension().is_some_and(|e| e == "md") && !is_symlink(&p) {
|
||||
dir_files.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(&path) {
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir()
|
||||
&& !is_symlink(&p)
|
||||
&& let Ok(sub) = std::fs::read_dir(&p)
|
||||
{
|
||||
for se in sub.flatten() {
|
||||
let sp = se.path();
|
||||
if sp.is_file()
|
||||
&& sp.extension().is_some_and(|e| e == "md")
|
||||
&& !is_symlink(&sp)
|
||||
{
|
||||
dir_files.push(sp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dir_files.sort();
|
||||
let remaining = MAX_INSTRUCTION_FILES.saturating_sub(files.len());
|
||||
dir_files.truncate(remaining);
|
||||
files.extend(dir_files);
|
||||
} else if path.is_file() && !is_symlink(&path) {
|
||||
files.push(path);
|
||||
}
|
||||
if files.len() >= MAX_INSTRUCTION_FILES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
files.truncate(MAX_INSTRUCTION_FILES);
|
||||
files.sort();
|
||||
files.dedup();
|
||||
files
|
||||
}
|
||||
|
||||
fn load_project_instruction_fragment_from_candidates(
|
||||
workspace: &Path,
|
||||
candidates: &[&str],
|
||||
) -> Option<BoundedFragment> {
|
||||
let files = collect_candidate_files(workspace, candidates);
|
||||
if files.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut sections = Vec::new();
|
||||
for path in files {
|
||||
if let Some(content) = read_capped(&path) {
|
||||
let rel = path
|
||||
.strip_prefix(workspace)
|
||||
.unwrap_or(&path)
|
||||
.display()
|
||||
.to_string();
|
||||
sections.push(format!(
|
||||
"<project_instructions source=\"{rel}\">\n{content}\n</project_instructions>"
|
||||
));
|
||||
}
|
||||
}
|
||||
if sections.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let merged = sections.join("\n\n");
|
||||
let fragment = BoundedFragment::project_instructions(merged);
|
||||
debug_assert!(validate_fragment(&fragment).is_ok());
|
||||
Some(fragment)
|
||||
}
|
||||
|
||||
pub fn load_project_instruction_fragment(workspace: &Path) -> Option<BoundedFragment> {
|
||||
load_project_instruction_fragment_from_candidates(workspace, PROJECT_INSTRUCTION_CANDIDATES)
|
||||
}
|
||||
|
||||
/// Load only instruction formats that the canonical TUI project-context path
|
||||
/// does not already render. This prevents duplicate authority while retaining
|
||||
/// the broader compatibility import added by the bounded fragment system.
|
||||
pub fn load_additional_project_instruction_fragment(workspace: &Path) -> Option<BoundedFragment> {
|
||||
load_project_instruction_fragment_from_candidates(
|
||||
workspace,
|
||||
ADDITIONAL_PROJECT_INSTRUCTION_CANDIDATES,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn project_instructions_from_sources(
|
||||
sources: impl IntoIterator<Item = (String, String)>,
|
||||
) -> Option<BoundedFragment> {
|
||||
let mut sections = Vec::new();
|
||||
for (name, content) in sources {
|
||||
let trimmed = content.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let body = if trimmed.len() > INSTRUCTIONS_FILE_MAX_BYTES {
|
||||
let mut end = INSTRUCTIONS_FILE_MAX_BYTES;
|
||||
while end > 0 && !trimmed.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
let omitted = trimmed.len() - end;
|
||||
format!("{}\n[…truncated: {omitted} bytes omitted]", &trimmed[..end])
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
};
|
||||
sections.push(format!(
|
||||
"<project_instructions source=\"{name}\">\n{body}\n</project_instructions>"
|
||||
));
|
||||
if sections.len() >= MAX_INSTRUCTION_FILES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if sections.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(BoundedFragment::project_instructions(sections.join("\n\n")))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct FragmentBudgetSnapshot {
|
||||
pub fragment_ids: Vec<String>,
|
||||
pub fragment_markers: Vec<String>,
|
||||
pub max_fragment_bytes: usize,
|
||||
pub max_fragment_tokens: usize,
|
||||
pub default_fragment_max_bytes: usize,
|
||||
pub max_fragments_per_context: usize,
|
||||
pub instructions_file_max_bytes: usize,
|
||||
pub max_instruction_files: usize,
|
||||
pub project_instruction_candidates: Vec<String>,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn fragment_budget_snapshot() -> FragmentBudgetSnapshot {
|
||||
FragmentBudgetSnapshot {
|
||||
fragment_ids: FragmentId::all()
|
||||
.iter()
|
||||
.map(|id| id.as_str().to_string())
|
||||
.collect(),
|
||||
fragment_markers: FragmentId::all()
|
||||
.iter()
|
||||
.map(|id| id.marker().to_string())
|
||||
.collect(),
|
||||
max_fragment_bytes: MAX_FRAGMENT_BYTES,
|
||||
max_fragment_tokens: MAX_FRAGMENT_TOKENS,
|
||||
default_fragment_max_bytes: DEFAULT_FRAGMENT_MAX_BYTES,
|
||||
max_fragments_per_context: MAX_FRAGMENTS_PER_CONTEXT,
|
||||
instructions_file_max_bytes: INSTRUCTIONS_FILE_MAX_BYTES,
|
||||
max_instruction_files: MAX_INSTRUCTION_FILES,
|
||||
project_instruction_candidates: PROJECT_INSTRUCTION_CANDIDATES
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_content(content: &str) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
content.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
fn enforce_byte_cap(raw: String, max_bytes: usize) -> String {
|
||||
if max_bytes == 0 {
|
||||
return String::new();
|
||||
}
|
||||
if raw.len() <= max_bytes {
|
||||
return raw;
|
||||
}
|
||||
let omitted = raw.len().saturating_sub(max_bytes);
|
||||
let marker = format!("\n[…truncated: {omitted} bytes omitted]");
|
||||
if marker.len() >= max_bytes {
|
||||
return marker.chars().take(max_bytes).collect();
|
||||
}
|
||||
let keep = max_bytes.saturating_sub(marker.len());
|
||||
let mut end = keep;
|
||||
while end > 0 && !raw.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
let mut out = raw[..end].to_string();
|
||||
out.push_str(&marker);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn fragment_has_matches_text_recognizer() {
|
||||
let fragment = BoundedFragment::new(FragmentId::Workspace, "repo: /tmp/demo");
|
||||
let rendered = fragment.render_marked();
|
||||
assert!(fragment.matches_text(&rendered));
|
||||
assert!(!fragment.matches_text("no marker here"));
|
||||
assert_eq!(FragmentId::Workspace.marker(), "<!-- cw:ctx:workspace -->");
|
||||
assert_eq!(
|
||||
FragmentId::ProjectInstructions.marker(),
|
||||
"<!-- cw:ctx:project_instructions -->"
|
||||
);
|
||||
assert_eq!(
|
||||
FragmentId::Constitution.marker(),
|
||||
"<!-- cw:ctx:constitution -->"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn all_fragment_types_go_through_bounded_module() {
|
||||
for id in FragmentId::all() {
|
||||
let fragment = BoundedFragment::new(*id, "hello");
|
||||
assert_eq!(fragment.marker, id.marker());
|
||||
assert_eq!(fragment.id, *id);
|
||||
validate_fragment(&fragment).expect("small fragment must pass caps");
|
||||
assert!(fragment.is_within_token_ceiling());
|
||||
assert!(fragment.is_within_byte_ceiling());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn per_fragment_byte_cap_truncates_with_marker() {
|
||||
let oversized = "x".repeat(DEFAULT_FRAGMENT_MAX_BYTES + 64);
|
||||
let fragment = BoundedFragment::new(FragmentId::AgentTopology, oversized);
|
||||
assert!(fragment.content.len() <= DEFAULT_FRAGMENT_MAX_BYTES);
|
||||
assert!(fragment.content.contains("[…truncated:"));
|
||||
validate_fragment(&fragment).expect("truncated fragment must pass caps");
|
||||
}
|
||||
#[test]
|
||||
fn ten_k_token_ceiling_is_enforced() {
|
||||
let huge = "a".repeat(MAX_FRAGMENT_BYTES + 1_000);
|
||||
let fragment = BoundedFragment::project_instructions(huge);
|
||||
assert!(fragment.content.len() <= MAX_FRAGMENT_BYTES);
|
||||
assert!(estimate_tokens(&fragment.content) <= MAX_FRAGMENT_TOKENS);
|
||||
validate_fragment(&fragment).expect("capped fragment must satisfy token ceiling");
|
||||
let also_huge = "b".repeat(MAX_FRAGMENT_BYTES + 5000);
|
||||
let fragment = BoundedFragment::with_max_bytes(FragmentId::Workspace, also_huge, 100_000);
|
||||
assert!(fragment.max_bytes <= MAX_FRAGMENT_BYTES);
|
||||
assert!(fragment.content.len() <= MAX_FRAGMENT_BYTES);
|
||||
assert!(fragment.is_within_token_ceiling());
|
||||
}
|
||||
#[test]
|
||||
fn injected_item_count_cap_is_enforced() {
|
||||
let fragments: Vec<BoundedFragment> = (0..MAX_FRAGMENTS_PER_CONTEXT)
|
||||
.map(|i| BoundedFragment::new(FragmentId::Workspace, format!("item {i}")))
|
||||
.collect();
|
||||
validate_fragment_set(&fragments).expect("exactly MAX_FRAGMENTS must pass");
|
||||
let mut too_many = fragments.clone();
|
||||
too_many.push(BoundedFragment::new(FragmentId::Route, "one too many"));
|
||||
let err = validate_fragment_set(&too_many).expect_err("one over cap must fail");
|
||||
assert!(matches!(err, FragmentCapError::TooManyFragments { .. }));
|
||||
}
|
||||
#[test]
|
||||
fn project_instruction_import_is_a_typed_fragment() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let ws = dir.path();
|
||||
fs::write(ws.join(".cursorrules"), "cursor: always use tabs").expect("write cursor");
|
||||
fs::write(ws.join(".clinerules"), "cline: prefer functional style").expect("write cline");
|
||||
fs::create_dir_all(ws.join(".windsurf").join("rules")).expect("mkdir windsurf");
|
||||
fs::write(
|
||||
ws.join(".windsurf").join("rules").join("extra.md"),
|
||||
"# windsurf extra",
|
||||
)
|
||||
.expect("write windsurf");
|
||||
fs::create_dir_all(ws.join(".github")).expect("mkdir github");
|
||||
fs::write(
|
||||
ws.join(".github").join("copilot-instructions.md"),
|
||||
"# copilot says hello",
|
||||
)
|
||||
.expect("write copilot");
|
||||
let fragment =
|
||||
load_project_instruction_fragment(ws).expect("must find imported instructions");
|
||||
assert_eq!(fragment.id, FragmentId::ProjectInstructions);
|
||||
assert!(fragment.matches_text(&fragment.render_marked()));
|
||||
assert!(
|
||||
fragment.content.contains(".cursorrules") || fragment.content.contains(".clinerules")
|
||||
);
|
||||
validate_fragment(&fragment).expect("project-instructions fragment must satisfy caps");
|
||||
let from_sources = project_instructions_from_sources(vec![
|
||||
("AGENTS.md".to_string(), "# AGENTS\nbe helpful".to_string()),
|
||||
(
|
||||
".cursorrules".to_string(),
|
||||
"cursor: do the thing".to_string(),
|
||||
),
|
||||
])
|
||||
.expect("sources");
|
||||
assert_eq!(from_sources.id, FragmentId::ProjectInstructions);
|
||||
assert!(from_sources.content.contains("AGENTS.md"));
|
||||
assert!(from_sources.content.contains(".cursorrules"));
|
||||
validate_fragment(&from_sources).expect("explicit sources must also satisfy caps");
|
||||
}
|
||||
#[test]
|
||||
fn additional_project_instruction_import_does_not_duplicate_canonical_authority() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let ws = dir.path();
|
||||
fs::write(ws.join("AGENTS.md"), "canonical authority marker").expect("write agents");
|
||||
|
||||
assert!(
|
||||
load_additional_project_instruction_fragment(ws).is_none(),
|
||||
"AGENTS.md is already owned by the canonical project-context loader"
|
||||
);
|
||||
|
||||
fs::write(ws.join(".cursorrules"), "additional cursor marker").expect("write cursor rules");
|
||||
let additional = load_additional_project_instruction_fragment(ws)
|
||||
.expect("additional rules must produce a typed fragment");
|
||||
assert!(additional.content.contains("additional cursor marker"));
|
||||
assert!(!additional.content.contains("canonical authority marker"));
|
||||
|
||||
let complete = load_project_instruction_fragment(ws)
|
||||
.expect("complete importer must retain every supported source");
|
||||
assert!(complete.content.contains("canonical authority marker"));
|
||||
assert!(complete.content.contains("additional cursor marker"));
|
||||
}
|
||||
#[test]
|
||||
fn fragment_budget_snapshot_is_stable() {
|
||||
let snap = fragment_budget_snapshot();
|
||||
assert_eq!(snap.max_fragment_tokens, 10_000);
|
||||
assert_eq!(snap.max_fragment_bytes, 40_000);
|
||||
assert_eq!(snap.max_fragments_per_context, 16);
|
||||
assert_eq!(snap.default_fragment_max_bytes, 4 * 1024);
|
||||
assert!(
|
||||
snap.fragment_ids
|
||||
.contains(&"project_instructions".to_string())
|
||||
);
|
||||
assert!(snap.fragment_ids.contains(&"constitution".to_string()));
|
||||
assert!(
|
||||
snap.project_instruction_candidates
|
||||
.contains(&".cursorrules".to_string())
|
||||
);
|
||||
assert!(
|
||||
snap.project_instruction_candidates
|
||||
.contains(&".github/copilot-instructions.md".to_string())
|
||||
);
|
||||
assert!(
|
||||
snap.fragment_markers
|
||||
.contains(&"<!-- cw:ctx:project_instructions -->".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! `ThreadId` / `SessionId` for the `crates/core` boundary (issue #5261).
|
||||
//!
|
||||
//! Re-exports the protocol ids so every crate that depends on `core` (the
|
||||
//! TUI, CLI, app-server) speaks the same typed ids without depending on
|
||||
//! `protocol` directly. The persisted JSON shape stays a plain string
|
||||
//! (`"thread-…"` / `"session-…"`) so existing `state.json` / `threads/`
|
||||
//! files need no migration.
|
||||
|
||||
pub use codewhale_protocol::ids::{SessionId, ThreadId};
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Session tree journal placeholder (issue #5262).
|
||||
//!
|
||||
//! The journal is append-only with an in-memory tree projection:
|
||||
//! every non-header entry carries `id` + `parentId`, the active position is
|
||||
//! a `leafId`, appending creates a child of the leaf, and branching only
|
||||
//! moves the leaf — it never rewrites history. This file lands the entry
|
||||
//! shape that #5262's tree operations hang off of; compaction and
|
||||
//! branch-summary entry kinds are included as first-class kinds but their
|
||||
//! *strategies* are deferred.
|
||||
//!
|
||||
//! Re-exports the protocol journal as the canonical shape so `protocol` and
|
||||
//! `core` agree on the wire. `core` adds the `SessionJournal` wrapper that
|
||||
//! owns the `current_leaf_id` column in `state.threads`.
|
||||
|
||||
pub use codewhale_protocol::journal::{Journal, JournalEntry};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Persisted thread metadata extension for the tree. This is the
|
||||
/// `current_leaf_id` column added to `state.threads`; `None` before the
|
||||
/// first turn, `Some(id)` after. The existing `threads` JSON shape is
|
||||
/// otherwise unchanged (back-compat: old rows read as `None` and the next
|
||||
/// append mints the header leaf).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ThreadLeafState {
|
||||
pub thread_id: String,
|
||||
pub leaf_id: Option<String>,
|
||||
}
|
||||
|
||||
/// First-class journal entry kinds (data shape lands now; strategies later).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum JournalKind {
|
||||
Header,
|
||||
User,
|
||||
Assistant,
|
||||
ToolResult,
|
||||
Compaction,
|
||||
BranchSummary,
|
||||
}
|
||||
|
||||
impl JournalKind {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Header => "header",
|
||||
Self::User => "user",
|
||||
Self::Assistant => "assistant",
|
||||
Self::ToolResult => "tool_result",
|
||||
Self::Compaction => "compaction",
|
||||
Self::BranchSummary => "branch_summary",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn leaf_state_roundtrip() {
|
||||
let s = ThreadLeafState {
|
||||
thread_id: "thread-1".into(),
|
||||
leaf_id: Some("entry-abc".into()),
|
||||
};
|
||||
let j = serde_json::to_string(&s).unwrap();
|
||||
let back: ThreadLeafState = serde_json::from_str(&j).unwrap();
|
||||
assert_eq!(back, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_append_is_child_of_leaf() {
|
||||
let mut j = Journal::new();
|
||||
let a = j.append("header", json!({}));
|
||||
let b = j.append("user", json!("hi"));
|
||||
assert_eq!(j.get(&b).unwrap().parent_id.as_deref(), Some(a.as_str()));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
pub mod engine;
|
||||
pub mod fragments;
|
||||
pub mod ids;
|
||||
pub mod journal;
|
||||
pub mod request;
|
||||
pub mod session;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Provider-neutral outbound model-request boundary.
|
||||
//!
|
||||
//! The request DTOs in this module are consumed by the TUI transport today
|
||||
//! and are intentionally free of terminal, HTTP, or provider-client state.
|
||||
//! Keeping the logical request in `codewhale-core` lets a headless session
|
||||
//! prepare the same serializable value before the existing TUI client applies
|
||||
//! provider-specific wire shaping.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Request payload handed to the model-client preparation seam.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct MessageRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<Message>,
|
||||
pub max_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system: Option<SystemPrompt>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<Tool>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thinking: Option<serde_json::Value>,
|
||||
/// DeepSeek reasoning-effort tier: "off" | "low" | "medium" | "high" | "max".
|
||||
/// Translated by the client into DeepSeek's `reasoning_effort` + `thinking` fields.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_effort: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
}
|
||||
|
||||
/// Inputs that distinguish a primary agent-turn request.
|
||||
///
|
||||
/// Provider-neutral defaults (`stream = true`, no metadata, no provider-side
|
||||
/// thinking object, and no sampling overrides) are applied once by
|
||||
/// [`prepare_primary_turn_request`]. Both the production turn loop and its
|
||||
/// read-only preview use this input so those defaults cannot drift.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PrimaryTurnRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<Message>,
|
||||
pub max_tokens: u32,
|
||||
pub system: Option<SystemPrompt>,
|
||||
pub tools: Option<Vec<Tool>>,
|
||||
pub tool_choice: Option<serde_json::Value>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
}
|
||||
|
||||
/// Prepare the provider-neutral request for a primary agent turn.
|
||||
///
|
||||
/// This function performs no I/O and no provider-specific transformation.
|
||||
/// The existing client transport remains responsible for secret redaction,
|
||||
/// protocol binding, dialect shaping, and endpoint selection.
|
||||
#[must_use]
|
||||
pub fn prepare_primary_turn_request(input: PrimaryTurnRequest) -> MessageRequest {
|
||||
MessageRequest {
|
||||
model: input.model,
|
||||
messages: input.messages,
|
||||
max_tokens: input.max_tokens,
|
||||
system: input.system,
|
||||
tools: input.tools,
|
||||
tool_choice: input.tool_choice,
|
||||
metadata: None,
|
||||
thinking: None,
|
||||
reasoning_effort: input.reasoning_effort,
|
||||
stream: Some(true),
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// System prompt representation (plain text or structured blocks).
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum SystemPrompt {
|
||||
Text(String),
|
||||
Blocks(Vec<SystemBlock>),
|
||||
}
|
||||
|
||||
/// A structured system prompt block.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct SystemBlock {
|
||||
#[serde(rename = "type")]
|
||||
pub block_type: String,
|
||||
pub text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_control: Option<CacheControl>,
|
||||
}
|
||||
|
||||
/// OpenAI-compatible image URL payload inside a multimodal message.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct ImageUrlContent {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// A chat message with role and content blocks.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct Message {
|
||||
pub role: String,
|
||||
pub content: Vec<ContentBlock>,
|
||||
}
|
||||
|
||||
/// Internal role used for assistant text that was visible before a turn was interrupted.
|
||||
pub const INTERRUPTED_ASSISTANT_ROLE: &str = "assistant_interrupted";
|
||||
/// Prefix attached to interrupted assistant output when it is replayed as context.
|
||||
pub const INTERRUPTED_ASSISTANT_CONTEXT_PREFIX: &str = "[The following assistant output was interrupted before completion and may be incomplete or wrong]\n";
|
||||
|
||||
/// Provider-owned reasoning continuity that is safe to replay only on the
|
||||
/// exact originating API and model. The encrypted payload is deliberately
|
||||
/// separate from readable [`ContentBlock::Thinking`] text.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct OpaqueReasoningState {
|
||||
pub provider: String,
|
||||
pub api: String,
|
||||
pub model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
pub encrypted_content: String,
|
||||
}
|
||||
|
||||
/// A single content block inside a message.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ContentBlock {
|
||||
#[serde(rename = "text")]
|
||||
Text {
|
||||
text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cache_control: Option<CacheControl>,
|
||||
},
|
||||
#[serde(rename = "image_url")]
|
||||
ImageUrl { image_url: ImageUrlContent },
|
||||
#[serde(rename = "thinking")]
|
||||
Thinking {
|
||||
thinking: String,
|
||||
/// Anthropic signed-thinking signature (#3014). Only populated on the
|
||||
/// native Messages dialect and serde-skipped when absent so OpenAI
|
||||
/// dialects are unaffected. Anthropic rejects tool loops that drop or
|
||||
/// modify signed thinking blocks, so replay this verbatim.
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
signature: Option<String>,
|
||||
/// Opaque Responses-style continuity. Never synthesize this from the
|
||||
/// readable `thinking` text or carry it across a route/model switch.
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
state: Option<OpaqueReasoningState>,
|
||||
},
|
||||
#[serde(rename = "tool_use")]
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
caller: Option<ToolCaller>,
|
||||
},
|
||||
#[serde(rename = "tool_result")]
|
||||
ToolResult {
|
||||
tool_use_id: String,
|
||||
content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
is_error: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content_blocks: Option<Vec<serde_json::Value>>,
|
||||
},
|
||||
#[serde(rename = "server_tool_use")]
|
||||
ServerToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
#[serde(rename = "tool_search_tool_result")]
|
||||
ToolSearchToolResult {
|
||||
tool_use_id: String,
|
||||
content: serde_json::Value,
|
||||
},
|
||||
#[serde(rename = "code_execution_tool_result")]
|
||||
CodeExecutionToolResult {
|
||||
tool_use_id: String,
|
||||
content: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
impl ContentBlock {
|
||||
/// Build readable reasoning with no provider-owned continuity state.
|
||||
#[must_use]
|
||||
pub fn thinking(thinking: impl Into<String>) -> Self {
|
||||
Self::Thinking {
|
||||
thinking: thinking.into(),
|
||||
signature: None,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache control metadata for tool definitions and blocks.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct CacheControl {
|
||||
#[serde(rename = "type")]
|
||||
pub cache_type: String,
|
||||
}
|
||||
|
||||
/// Metadata describing who invoked a tool call.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct ToolCaller {
|
||||
#[serde(rename = "type")]
|
||||
pub caller_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Tool definition exposed to the model.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct Tool {
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub tool_type: Option<String>,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub input_schema: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_callers: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub defer_loading: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_examples: Option<Vec<serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub strict: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_control: Option<CacheControl>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn primary_turn() -> PrimaryTurnRequest {
|
||||
PrimaryTurnRequest {
|
||||
model: "deepseek-v4-flash".to_string(),
|
||||
messages: vec![Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "inspect the request".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
}],
|
||||
max_tokens: 4096,
|
||||
system: Some(SystemPrompt::Text("system".to_string())),
|
||||
tools: Some(vec![Tool {
|
||||
tool_type: None,
|
||||
name: "read_file".to_string(),
|
||||
description: "Read a file".to_string(),
|
||||
input_schema: json!({"zeta": 1, "alpha": 2, "type": "object"}),
|
||||
allowed_callers: None,
|
||||
defer_loading: None,
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
}]),
|
||||
tool_choice: Some(json!({"type": "auto"})),
|
||||
reasoning_effort: Some("high".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_turn_preparation_has_stable_serialized_bytes() {
|
||||
let first = prepare_primary_turn_request(primary_turn());
|
||||
let second = prepare_primary_turn_request(primary_turn());
|
||||
let first_bytes = serde_json::to_vec(&first).expect("serialize first request");
|
||||
let second_bytes = serde_json::to_vec(&second).expect("serialize second request");
|
||||
|
||||
assert_eq!(first_bytes, second_bytes);
|
||||
assert_eq!(
|
||||
first_bytes,
|
||||
br#"{"model":"deepseek-v4-flash","messages":[{"role":"user","content":[{"type":"text","text":"inspect the request"}]}],"max_tokens":4096,"system":"system","tools":[{"name":"read_file","description":"Read a file","input_schema":{"zeta":1,"alpha":2,"type":"object"}}],"tool_choice":{"type":"auto"},"reasoning_effort":"high","stream":true}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_turn_preparation_owns_shared_defaults() {
|
||||
let request = prepare_primary_turn_request(primary_turn());
|
||||
assert_eq!(request.stream, Some(true));
|
||||
assert!(request.metadata.is_none());
|
||||
assert!(request.thinking.is_none());
|
||||
assert!(request.temperature.is_none());
|
||||
assert!(request.top_p.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! `Thread` / `Session` split (issue #5261).
|
||||
//!
|
||||
//! `codewhale`'s `Session` was really a thread. The new split is:
|
||||
//! - `Thread` — durable, persisted, owns the append-only `Journal` and the
|
||||
//! `leafId` cursor. One row in `state.threads`, one directory on disk.
|
||||
//! - `Session` — ephemeral, per-turn / per-engine-lifetime, owns the
|
||||
//! in-memory `TurnContext` plus the live approval/sandbox posture for this
|
||||
//! `SessionId`. Many sessions can attach to one thread over time, but only
|
||||
//! one `Session` drives a turn for a given `ThreadId` at a time.
|
||||
//!
|
||||
//! The thread manager (`ThreadManager` in `crate::lib`) already can start a
|
||||
//! session with no TUI attached (`spawn_thread_with_history`); this file
|
||||
//! formalizes the types that make that first-class and moves the former
|
||||
//! `crates/tui/src/core/session.rs` state (model, reasoning_effort,
|
||||
//! `AppendLog`, `PrefixStabilityManager`, `frozen_prefix`,
|
||||
//! `messages_revision`) into `crates/core` so both TUI and headless share it.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ids::{SessionId, ThreadId};
|
||||
use crate::journal::Journal;
|
||||
|
||||
/// Durable thread (the former `Session`). One per conversation, persisted in
|
||||
/// `state.threads`. The only new field vs the old `Session` is `leaf_id` — the
|
||||
/// journal cursor — plus the typed `ThreadId`. All other fields keep their
|
||||
/// persisted JSON shape unchanged.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Thread {
|
||||
pub thread_id: ThreadId,
|
||||
/// Active branch tip. `None` before the first journal header.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub leaf_id: Option<String>,
|
||||
/// Journal (append-only). In-memory projection of the persisted
|
||||
/// `threads/turns/items/events` layout is derived root→leaf.
|
||||
#[serde(default)]
|
||||
pub journal: Journal,
|
||||
pub model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub workspace: PathBuf,
|
||||
#[serde(default)]
|
||||
pub ephemeral: bool,
|
||||
}
|
||||
|
||||
impl Thread {
|
||||
#[must_use]
|
||||
pub fn new(thread_id: ThreadId, workspace: PathBuf, model: impl Into<String>) -> Self {
|
||||
Self {
|
||||
thread_id,
|
||||
leaf_id: None,
|
||||
journal: Journal::new(),
|
||||
model: model.into(),
|
||||
reasoning_effort: None,
|
||||
workspace,
|
||||
ephemeral: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn leaf_id(&self) -> Option<&str> {
|
||||
self.leaf_id.as_deref()
|
||||
}
|
||||
|
||||
pub fn set_leaf(&mut self, leaf: Option<String>) {
|
||||
self.leaf_id = leaf;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ephemeral session within a thread (one engine lifetime / one turn's
|
||||
/// live posture). The TUI's `EngineHandle` and the headless `exec` both
|
||||
/// hold a `Session` that points at the same `ThreadId` but with different
|
||||
/// `SessionId`s.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Session {
|
||||
pub session_id: SessionId,
|
||||
pub thread_id: ThreadId,
|
||||
/// Model for this session's next turn (may differ from thread default).
|
||||
pub model: String,
|
||||
pub workspace: PathBuf,
|
||||
/// Monotonic `messages_revision` for prefix-cache memoization (carried
|
||||
/// from the former `Session::messages_revision`).
|
||||
pub messages_revision: u64,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
#[must_use]
|
||||
pub fn new(thread_id: ThreadId, workspace: PathBuf, model: impl Into<String>) -> Self {
|
||||
Self {
|
||||
session_id: SessionId::new(),
|
||||
thread_id,
|
||||
model: model.into(),
|
||||
workspace,
|
||||
messages_revision: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bump_revision(&mut self) {
|
||||
self.messages_revision = self.messages_revision.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Split helper: derive a `Session` from an existing `Thread` without
|
||||
/// cloning the journal. Headless and TUI call the same constructor so
|
||||
/// the request shape stays identical.
|
||||
#[must_use]
|
||||
pub fn session_for_thread(thread: &Thread, workspace: PathBuf) -> Session {
|
||||
Session::new(thread.thread_id.clone(), workspace, thread.model.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thread_and_session_ids_are_distinct_scopes() {
|
||||
let t = Thread::new(ThreadId::new(), PathBuf::from("/tmp"), "deepseek-v4-flash");
|
||||
let s1 = Session::new(t.thread_id.clone(), PathBuf::from("/tmp"), &t.model);
|
||||
let s2 = Session::new(t.thread_id.clone(), PathBuf::from("/tmp"), &t.model);
|
||||
assert_eq!(s1.thread_id, s2.thread_id);
|
||||
assert_ne!(s1.session_id, s2.session_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_is_moved_not_rewritten() {
|
||||
let mut t = Thread::new(ThreadId::new(), PathBuf::from("/tmp"), "m");
|
||||
let a = t.journal.append("header", serde_json::json!({}));
|
||||
let b = t.journal.append("user", serde_json::json!("b"));
|
||||
t.leaf_id = t.journal.leaf_id.clone();
|
||||
assert_eq!(t.leaf_id.as_deref(), Some(b.as_str()));
|
||||
assert!(t.journal.branch_to(&a));
|
||||
t.leaf_id = t.journal.leaf_id.clone();
|
||||
assert_eq!(t.leaf_id.as_deref(), Some(a.as_str()));
|
||||
assert_eq!(t.journal.len(), 2); // history never rewritten; branching only moved the leaf
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,5 @@ description = "Execution policy and approval model for Codewhale"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
codewhale-protocol = { path = "../protocol", version = "0.9.4" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.9.6" }
|
||||
serde.workspace = true
|
||||
|
||||
@@ -11,8 +11,8 @@ description = "Hook dispatch and notifications support for Codewhale"
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
chrono.workspace = true
|
||||
codewhale-protocol = { path = "../protocol", version = "0.9.4" }
|
||||
codewhale-release = { path = "../release", version = "0.9.4" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.9.6" }
|
||||
codewhale-release = { path = "../release", version = "0.9.6" }
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -10,7 +10,7 @@ description = "Lane registry and Runtime backends for Codewhale workflow instanc
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
codewhale-config = { path = "../config", version = "0.9.4" }
|
||||
codewhale-config = { path = "../config", version = "0.9.6" }
|
||||
fd-lock = "4.0.4"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! `EventMsg`-out API in `crates/protocol` (issue #5261).
|
||||
//!
|
||||
//! Mirrors `crates/tui/src/core/events::Event` but as a serializable
|
||||
//! protocol. The TUI's `rx_event` / `Event` channel, the app-server's SSE
|
||||
//! stream, and the CLI's `stream-json` output all speak this one type so
|
||||
//! headless and TUI observe byte-identical event shapes for the same `Op`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ids::{SessionId, ThreadId};
|
||||
|
||||
/// One event emitted by the core engine to every consumer (TUI, CLI,
|
||||
/// app-server, tests). This is the `EventMsg`-out half of the `Op`-in /
|
||||
/// `EventMsg`-out contract. It is a straight projection of the existing
|
||||
/// internal `Event` variants (streaming deltas, tool lifecycle, turn
|
||||
/// lifecycle, approvals) plus the thread/session ids that `ThreadId` /
|
||||
/// `SessionId` now make explicit.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "event", rename_all = "snake_case")]
|
||||
pub enum EventMsg {
|
||||
TurnStarted {
|
||||
thread_id: ThreadId,
|
||||
session_id: SessionId,
|
||||
turn_id: String,
|
||||
},
|
||||
ResponseDelta {
|
||||
thread_id: ThreadId,
|
||||
session_id: SessionId,
|
||||
delta: String,
|
||||
#[serde(default)]
|
||||
channel: String,
|
||||
},
|
||||
ToolCallStarted {
|
||||
thread_id: ThreadId,
|
||||
session_id: SessionId,
|
||||
tool_call_id: String,
|
||||
tool_name: String,
|
||||
input: Value,
|
||||
},
|
||||
ToolCallComplete {
|
||||
thread_id: ThreadId,
|
||||
session_id: SessionId,
|
||||
tool_call_id: String,
|
||||
tool_name: String,
|
||||
result: Value,
|
||||
},
|
||||
TurnComplete {
|
||||
thread_id: ThreadId,
|
||||
session_id: SessionId,
|
||||
turn_id: String,
|
||||
status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
},
|
||||
TurnUsage {
|
||||
thread_id: ThreadId,
|
||||
session_id: SessionId,
|
||||
input_tokens: u32,
|
||||
output_tokens: u32,
|
||||
},
|
||||
CompactionStarted {
|
||||
thread_id: ThreadId,
|
||||
session_id: SessionId,
|
||||
message: String,
|
||||
},
|
||||
CompactionCompleted {
|
||||
thread_id: ThreadId,
|
||||
session_id: SessionId,
|
||||
message: String,
|
||||
},
|
||||
Error {
|
||||
thread_id: ThreadId,
|
||||
session_id: SessionId,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Envelope that carries an `EventMsg` over the wire / channel with a
|
||||
/// monotonic seq so consumers can detect drops. Mirrors the existing
|
||||
/// `RuntimeEventEnvelope` but typed to `EventMsg`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EventEnvelope {
|
||||
pub seq: u64,
|
||||
pub thread_id: ThreadId,
|
||||
pub session_id: SessionId,
|
||||
pub turn_id: Option<String>,
|
||||
pub event: EventMsg,
|
||||
}
|
||||
|
||||
impl EventMsg {
|
||||
#[must_use]
|
||||
pub fn kind_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::TurnStarted { .. } => "turn_started",
|
||||
Self::ResponseDelta { .. } => "response_delta",
|
||||
Self::ToolCallStarted { .. } => "tool_call_started",
|
||||
Self::ToolCallComplete { .. } => "tool_call_complete",
|
||||
Self::TurnComplete { .. } => "turn_complete",
|
||||
Self::TurnUsage { .. } => "turn_usage",
|
||||
Self::CompactionStarted { .. } => "compaction_started",
|
||||
Self::CompactionCompleted { .. } => "compaction_completed",
|
||||
Self::Error { .. } => "error",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn thread_id(&self) -> &ThreadId {
|
||||
match self {
|
||||
Self::TurnStarted { thread_id, .. }
|
||||
| Self::ResponseDelta { thread_id, .. }
|
||||
| Self::ToolCallStarted { thread_id, .. }
|
||||
| Self::ToolCallComplete { thread_id, .. }
|
||||
| Self::TurnComplete { thread_id, .. }
|
||||
| Self::TurnUsage { thread_id, .. }
|
||||
| Self::CompactionStarted { thread_id, .. }
|
||||
| Self::CompactionCompleted { thread_id, .. }
|
||||
| Self::Error { thread_id, .. } => thread_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn event_msg_roundtrip() {
|
||||
let msg = EventMsg::TurnComplete {
|
||||
thread_id: ThreadId::new(),
|
||||
session_id: SessionId::new(),
|
||||
turn_id: "turn-1".into(),
|
||||
status: "completed".into(),
|
||||
error: None,
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
let back: EventMsg = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.kind_str(), "turn_complete");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Typed `ThreadId` / `SessionId` for the `crates/core` boundary (issue #5261).
|
||||
//!
|
||||
//! `codewhale`'s `Session` is really a thread. The new boundary introduces
|
||||
//! two ids so every consumer — TUI, CLI, app-server, tests — can name the
|
||||
//! right scope:
|
||||
//! - `ThreadId` — long-lived conversation (persisted in `state.json` / `threads/`)
|
||||
//! - `SessionId` — one turn/session within a thread (ephemeral engine handle)
|
||||
//!
|
||||
//! Both are thin wrappers around the existing `"thread-…"` string id so the
|
||||
//! persisted JSON shape stays unchanged. They serialize as plain strings,
|
||||
//! deserialize from plain strings or `{ "id": "…" }`, and parse from either.
|
||||
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Long-lived conversation id. Backwards compatible with the existing
|
||||
/// `thread-{uuid}` string form used in `crates/state` and `runtime_threads`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ThreadId(pub String);
|
||||
|
||||
impl ThreadId {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self(format!("thread-{}", Uuid::new_v4()))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_string(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ThreadId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ThreadId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ThreadId {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ThreadId> for String {
|
||||
fn from(id: ThreadId) -> Self {
|
||||
id.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ThreadId {
|
||||
type Err = std::convert::Infallible;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(Self(s.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// One engine session within a thread (a single `Op` turn or a supervised
|
||||
/// engine lifetime). Distinct from `ThreadId` so the thread manager can
|
||||
/// start a session with no TUI attached and so tests can assert headless
|
||||
/// == TUI byte-identical requests for the same `ThreadId` + `SessionId` pair.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct SessionId(pub String);
|
||||
|
||||
impl SessionId {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self(format!("session-{}", Uuid::new_v4()))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_string(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SessionId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SessionId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for SessionId {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SessionId> for String {
|
||||
fn from(id: SessionId) -> Self {
|
||||
id.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for SessionId {
|
||||
type Err = std::convert::Infallible;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(Self(s.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thread_id_roundtrip() {
|
||||
let id = ThreadId::new();
|
||||
let s = id.to_string();
|
||||
assert!(s.starts_with("thread-"));
|
||||
let parsed: ThreadId = s.parse().unwrap();
|
||||
assert_eq!(parsed.as_str(), id.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_id_display() {
|
||||
let id = SessionId::from_string("session-abc");
|
||||
assert_eq!(format!("{id}"), "session-abc");
|
||||
let json = serde_json::to_string(&id).unwrap();
|
||||
assert_eq!(json, "\"session-abc\"");
|
||||
let back: SessionId = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Session tree journal placeholder (issue #5262).
|
||||
//!
|
||||
//! The journal is append-only with an in-memory tree projection. Every
|
||||
//! non-header entry carries `id` + `parentId`; the active position is a
|
||||
//! `leafId`; appending creates a child of the leaf; branching only moves the
|
||||
//! leaf — it never rewrites history. This file lands the *entry shape* that
|
||||
//! #5262's tree operations (`/tree`, `/branch`, `/fork`, `/resume`) and the
|
||||
//! deferred compaction/branch-summary entry kinds hang off of. The strategies
|
||||
//! themselves are deferred, but the shape must be stable now so no migration
|
||||
//! is needed later.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// One journal entry. All entries except the root header have `id` + `parent_id`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct JournalEntry {
|
||||
/// Stable entry id (`entry-{uuid}`).
|
||||
pub id: String,
|
||||
/// Parent entry id; `None` only for the root header.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_id: Option<String>,
|
||||
/// Entry kind (`header`, `user`, `assistant`, `tool_result`, `compaction`, `branch_summary`, …).
|
||||
pub kind: String,
|
||||
/// Payload (text, tool output, compaction summary, etc).
|
||||
#[serde(default)]
|
||||
pub payload: Value,
|
||||
/// When the entry was created (unix seconds).
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// Append-only journal with a `leafId` cursor. The tree projection is
|
||||
/// derived root→leaf; moving `leaf_id` branches without rewriting history.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct Journal {
|
||||
pub entries: Vec<JournalEntry>,
|
||||
/// Active position. `None` before the header is appended.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub leaf_id: Option<String>,
|
||||
}
|
||||
|
||||
impl Journal {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Append a new entry as a child of the current leaf. Returns the new id.
|
||||
pub fn append(&mut self, kind: impl Into<String>, payload: Value) -> String {
|
||||
let id = format!("entry-{}", uuid::Uuid::new_v4());
|
||||
let parent_id = self.leaf_id.clone();
|
||||
let entry = JournalEntry {
|
||||
id: id.clone(),
|
||||
parent_id,
|
||||
kind: kind.into(),
|
||||
payload,
|
||||
created_at: chrono::Utc::now().timestamp(),
|
||||
};
|
||||
self.entries.push(entry);
|
||||
self.leaf_id = Some(id.clone());
|
||||
id
|
||||
}
|
||||
|
||||
/// Branch: move `leaf_id` to an existing ancestor without rewriting.
|
||||
/// Returns `false` when `target` is not found.
|
||||
pub fn branch_to(&mut self, target: &str) -> bool {
|
||||
if self.entries.iter().any(|e| e.id == target) {
|
||||
self.leaf_id = Some(target.to_string());
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Project the active path root→leaf as a slice of entries in order.
|
||||
#[must_use]
|
||||
pub fn active_path(&self) -> Vec<&JournalEntry> {
|
||||
let Some(leaf) = self.leaf_id.as_deref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
// Build id→parent map for walk.
|
||||
let mut by_id = std::collections::HashMap::new();
|
||||
for e in &self.entries {
|
||||
by_id.insert(e.id.as_str(), e);
|
||||
}
|
||||
let mut path = Vec::new();
|
||||
let mut cur: Option<&str> = Some(leaf);
|
||||
while let Some(id) = cur {
|
||||
if let Some(entry) = by_id.get(id) {
|
||||
path.push(*entry);
|
||||
cur = entry.parent_id.as_deref();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
path.reverse();
|
||||
path
|
||||
}
|
||||
|
||||
/// Find entry by id.
|
||||
#[must_use]
|
||||
pub fn get(&self, id: &str) -> Option<&JournalEntry> {
|
||||
self.entries.iter().find(|e| e.id == id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn append_sets_parent_and_leaf() {
|
||||
let mut j = Journal::new();
|
||||
let a = j.append("header", json!({}));
|
||||
assert_eq!(j.leaf_id.as_deref(), Some(a.as_str()));
|
||||
let b = j.append("user", json!("hi"));
|
||||
let entry = j.get(&b).unwrap();
|
||||
assert_eq!(entry.parent_id.as_deref(), Some(a.as_str()));
|
||||
assert_eq!(j.leaf_id.as_deref(), Some(b.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branching_only_moves_leaf() {
|
||||
let mut j = Journal::new();
|
||||
let a = j.append("header", json!({}));
|
||||
let b = j.append("user", json!("b"));
|
||||
let c = j.append("assistant", json!("c"));
|
||||
assert_eq!(j.entries.len(), 3);
|
||||
assert!(j.branch_to(&b));
|
||||
assert_eq!(j.leaf_id.as_deref(), Some(b.as_str()));
|
||||
// History untouched.
|
||||
assert_eq!(j.entries.len(), 3);
|
||||
// Active path is now a→b.
|
||||
let path = j.active_path();
|
||||
assert_eq!(path.len(), 2);
|
||||
assert_eq!(path[0].id, a);
|
||||
assert_eq!(path[1].id, b);
|
||||
let d = j.append("user", json!("d after branch"));
|
||||
let ent = j.get(&d).unwrap();
|
||||
assert_eq!(ent.parent_id.as_deref(), Some(b.as_str()));
|
||||
// Old c still exists as a sibling branch that is no longer on the active path.
|
||||
assert!(j.get(&c).is_some());
|
||||
let path2 = j.active_path();
|
||||
assert_eq!(path2.len(), 3);
|
||||
assert_eq!(path2[2].id, d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_is_serializable_and_preserves_shape() {
|
||||
let mut j = Journal::new();
|
||||
j.append("header", json!({}));
|
||||
j.append("user", json!("hello"));
|
||||
let s = serde_json::to_string(&j).unwrap();
|
||||
let back: Journal = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(back, j);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,11 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
pub mod agent_run;
|
||||
pub mod event_msg;
|
||||
pub mod fleet;
|
||||
pub mod ids;
|
||||
pub mod journal;
|
||||
pub mod op;
|
||||
pub mod runtime;
|
||||
pub mod workroom;
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! `Op`-in API in `crates/protocol` (issue #5261).
|
||||
//!
|
||||
//! The TUI engine already had an internal channel (`Op` in
|
||||
//! `crates/tui/src/core/ops.rs` with `tx_op` / `rx_op` and `tx_steer`).
|
||||
//! This protocol file formalizes that channel so TUI, CLI, app-server, and
|
||||
//! tests share one serializable API. The wire is `OpEnvelope` + `Op`;
|
||||
//! transports that already speak JSON (app-server, tests) can send the
|
||||
//! envelope directly, while in-process callers continue to use the typed
|
||||
//! enum.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ids::{SessionId, ThreadId};
|
||||
|
||||
/// Every `Op` is paired with the ids that route it. This is the
|
||||
/// `Op`-in half of the `Op`-in / `EventMsg`-out contract.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpEnvelope {
|
||||
/// Monotonic `op:<n>` for dedup / tracing within a session.
|
||||
pub op_id: String,
|
||||
pub thread_id: ThreadId,
|
||||
pub session_id: SessionId,
|
||||
pub op: Op,
|
||||
}
|
||||
|
||||
/// Operations that can be submitted to the core engine. This is the
|
||||
/// protocol view of `crates/tui/src/core/ops::Op` — same lifecycle,
|
||||
/// same provenance gate — but serializable and free of `mpsc` / `oneshot`
|
||||
/// fields. In-process callers convert at the boundary; out-of-process
|
||||
/// callers send the JSON directly.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Op {
|
||||
/// Drive one model turn: `role=user` content plus the resolved route
|
||||
/// receipt the engine will freeze at the client-freeze boundary. Headless
|
||||
/// and TUI must produce byte-identical `MessageRequest`s for identical
|
||||
/// `Op::SendMessage` payloads.
|
||||
SendMessage {
|
||||
content: String,
|
||||
/// Effective mode for this turn (`"plan" | "agent" | "operate"` etc).
|
||||
#[serde(default = "default_mode")]
|
||||
mode: String,
|
||||
/// Optional explicit route/model the caller resolved already (mirrors
|
||||
/// `ResolvedRuntimeRoute` in `crates_tui::route_runtime`). `None` means
|
||||
/// "use the thread's current route".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
model_provider: Option<String>,
|
||||
/// Tool restriction from slash-command frontmatter.
|
||||
#[serde(default)]
|
||||
allowed_tools: Option<Vec<String>>,
|
||||
/// Runtime-supplied dynamic tools for this turn only.
|
||||
#[serde(default)]
|
||||
dynamic_tools: Vec<Value>,
|
||||
/// Structural input provenance — only `ExternalUser` may inherit
|
||||
/// YOLO/auto-approval authority (mirrors `UserInputProvenance`).
|
||||
#[serde(default = "default_provenance")]
|
||||
provenance: String,
|
||||
},
|
||||
|
||||
/// Steer an in-flight turn with additional user content (drains into
|
||||
/// the turn loop's `rx_steer` channel).
|
||||
Steer {
|
||||
content: String,
|
||||
},
|
||||
|
||||
/// Re-check and dispatch a goal continuation (synthetic turn that
|
||||
/// continues the same logical goal run).
|
||||
ContinueGoal,
|
||||
|
||||
/// Execute a local composer shell command without a model turn.
|
||||
RunShellCommand {
|
||||
command: String,
|
||||
},
|
||||
|
||||
/// Set goal status without dispatching a model turn.
|
||||
SetGoalStatus {
|
||||
status: String,
|
||||
#[serde(default)]
|
||||
clear: bool,
|
||||
},
|
||||
|
||||
Cancel,
|
||||
Shutdown,
|
||||
|
||||
/// Describe the exact request the next turn would send without sending it
|
||||
/// (`/dryrun` / `/preview-request`, #1004). Headless and TUI must render
|
||||
/// identical manifests for identical inputs.
|
||||
PreviewOutboundRequest {
|
||||
#[serde(default)]
|
||||
json: bool,
|
||||
#[serde(default)]
|
||||
base_prompt_only: bool,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_mode() -> String {
|
||||
"agent".to_string()
|
||||
}
|
||||
|
||||
fn default_provenance() -> String {
|
||||
"external_user".to_string()
|
||||
}
|
||||
|
||||
impl Op {
|
||||
#[must_use]
|
||||
pub fn is_send_message(&self) -> bool {
|
||||
matches!(self, Self::SendMessage { .. })
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn kind_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::SendMessage { .. } => "send_message",
|
||||
Self::Steer { .. } => "steer",
|
||||
Self::ContinueGoal => "continue_goal",
|
||||
Self::RunShellCommand { .. } => "run_shell_command",
|
||||
Self::SetGoalStatus { .. } => "set_goal_status",
|
||||
Self::Cancel => "cancel",
|
||||
Self::Shutdown => "shutdown",
|
||||
Self::PreviewOutboundRequest { .. } => "preview_outbound_request",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a headless `SendMessage` envelope with fresh ids. This is the
|
||||
/// one-line helper every headless caller (CLI `exec`, app-server, tests)
|
||||
/// uses so TUI and headless start a session identically.
|
||||
#[must_use]
|
||||
pub fn headless_send_message_op(thread_id: ThreadId, content: impl Into<String>) -> OpEnvelope {
|
||||
OpEnvelope {
|
||||
op_id: format!("op-{}", uuid::Uuid::new_v4()),
|
||||
thread_id: thread_id.clone(),
|
||||
session_id: SessionId::new(),
|
||||
op: Op::SendMessage {
|
||||
content: content.into(),
|
||||
mode: default_mode(),
|
||||
model: None,
|
||||
model_provider: None,
|
||||
allowed_tools: None,
|
||||
dynamic_tools: Vec::new(),
|
||||
provenance: default_provenance(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn op_envelope_roundtrip() {
|
||||
let env = headless_send_message_op(ThreadId::new(), "hello");
|
||||
let json = serde_json::to_string(&env).unwrap();
|
||||
let back: OpEnvelope = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.thread_id, env.thread_id);
|
||||
assert!(back.op.is_send_message());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steer_roundtrip() {
|
||||
let op = Op::Steer {
|
||||
content: "more".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&op).unwrap();
|
||||
let back: Op = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.kind_str(), "steer");
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,25 @@ pub struct RuntimeCapabilities {
|
||||
pub fleet_event_stream: bool,
|
||||
#[serde(default)]
|
||||
pub fleet_local_target: bool,
|
||||
/// `GET/PUT/DELETE /v1/threads/{id}/goal` and the `complete`/`block`
|
||||
/// lifecycle actions are available.
|
||||
#[serde(default)]
|
||||
pub thread_goals: bool,
|
||||
/// `GET /v1/memory` and `GET /v1/memory/{id}` are available for
|
||||
/// bounded inspection of the native memory store. `POST /v1/memory`
|
||||
/// and `DELETE /v1/memory` are also available (auth-gated via the
|
||||
/// standard route layer) for lifecycle controls.
|
||||
#[serde(default)]
|
||||
pub memory: bool,
|
||||
/// Whether the runtime supports create/update/enable/disable/reconnect/delete
|
||||
/// operations on MCP server configuration via the `POST|GET|PATCH|DELETE
|
||||
/// /v1/apps/mcp/servers` family of endpoints.
|
||||
#[serde(default)]
|
||||
pub mcp_server_management: bool,
|
||||
/// Skill lifecycle operations (install, update, uninstall, trust, audit)
|
||||
/// are available via the HTTP API.
|
||||
#[serde(default)]
|
||||
pub skill_lifecycle: bool,
|
||||
}
|
||||
|
||||
/// Experimental opt-in flags advertised by `GET /v1/runtime/info`.
|
||||
@@ -355,6 +374,10 @@ mod tests {
|
||||
fleet_event_replay: true,
|
||||
fleet_event_stream: true,
|
||||
fleet_local_target: true,
|
||||
thread_goals: true,
|
||||
memory: true,
|
||||
mcp_server_management: false,
|
||||
skill_lifecycle: false,
|
||||
};
|
||||
let value = serde_json::to_value(&caps).unwrap();
|
||||
let obj = value.as_object().unwrap();
|
||||
@@ -364,6 +387,8 @@ mod tests {
|
||||
assert!(obj.contains_key("worker_runtime"));
|
||||
assert_eq!(obj.get("fleet_run_create").unwrap(), &json!(true));
|
||||
assert_eq!(obj.get("fleet_event_stream").unwrap(), &json!(true));
|
||||
assert_eq!(obj.get("thread_goals").unwrap(), &json!(true));
|
||||
assert_eq!(obj.get("memory").unwrap(), &json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize};
|
||||
pub const UPDATE_CHECK_CACHE_FILE: &str = "update-check.json";
|
||||
|
||||
/// Default hours between network update checks.
|
||||
pub const DEFAULT_CHECK_INTERVAL_HOURS: u64 = 24;
|
||||
pub const DEFAULT_CHECK_INTERVAL_HOURS: u64 = 1;
|
||||
|
||||
/// Explicit opt-out, and the `update-notifier` convention many CLIs honour.
|
||||
const OPT_OUT_ENV: &[&str] = &["CODEWHALE_NO_UPDATE_CHECK", "NO_UPDATE_NOTIFIER"];
|
||||
@@ -185,13 +185,13 @@ mod tests {
|
||||
checked_at_unix: 1_000_000,
|
||||
latest_tag: Some("v0.9.5".to_string()),
|
||||
};
|
||||
// 23h later: still fresh on a 24h interval.
|
||||
assert!(entry.is_fresh(1_000_000 + 23 * 3600, 24));
|
||||
// 25h later: stale.
|
||||
assert!(!entry.is_fresh(1_000_000 + 25 * 3600, 24));
|
||||
// One second before the one-hour boundary: still fresh.
|
||||
assert!(entry.is_fresh(1_000_000 + 3599, 1));
|
||||
// One second beyond the boundary: stale.
|
||||
assert!(!entry.is_fresh(1_000_000 + 3601, 1));
|
||||
// Exactly at the boundary counts as stale, so the interval is a true
|
||||
// upper bound on cache age.
|
||||
assert!(!entry.is_fresh(1_000_000 + 24 * 3600, 24));
|
||||
assert!(!entry.is_fresh(1_000_000 + 3600, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -47,7 +47,7 @@ pub const LEGACY_UPDATE_VERSION_ENV: &str = "DEEPSEEK_VERSION";
|
||||
/// User-Agent header sent with release metadata requests.
|
||||
pub const UPDATE_USER_AGENT: &str = "codewhale-updater";
|
||||
|
||||
const CNB_RELEASE_ASSET_BASE: &str = "https://cnb.cool/Hmbown/CodeWhale/-/releases";
|
||||
const CNB_RELEASE_ASSET_BASE: &str = "https://cnb.cool/codewhale.net/codewhale/-/releases/download";
|
||||
const RELEASE_METADATA_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Build a reqwest client builder with the TLS roots appropriate for the
|
||||
@@ -198,12 +198,11 @@ pub fn update_network_fallback_hint() -> String {
|
||||
format!(
|
||||
"GitHub release downloads may be blocked or slow on this network.\n\
|
||||
For mainland China, use one of these fallback paths:\n\
|
||||
1. Source build from the CNB mirror, installing both shipped binaries:\n\
|
||||
1. Source build from the CNB mirror, installing the shipped binary:\n\
|
||||
cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-cli --locked --force\n\
|
||||
cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-tui --locked --force\n\
|
||||
2. Use a binary asset mirror:\n\
|
||||
{RELEASE_BASE_URL_ENV}=https://<mirror>/<release-assets>/ {UPDATE_VERSION_ENV}=X.Y.Z codewhale update\n\
|
||||
The mirror directory must contain {CHECKSUM_MANIFEST_ASSET} and the platform binaries."
|
||||
The mirror directory must contain {CHECKSUM_MANIFEST_ASSET} and the codewhale platform binary."
|
||||
)
|
||||
}
|
||||
|
||||
@@ -564,7 +563,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
release_base_url_from_env("v1.2.3"),
|
||||
Some("https://cnb.cool/Hmbown/CodeWhale/-/releases/v1.2.3".to_string())
|
||||
Some("https://cnb.cool/codewhale.net/codewhale/-/releases/download/v1.2.3".to_string())
|
||||
);
|
||||
|
||||
set_release_env(RELEASE_BASE_URL_ENV, "https://explicit.example.com");
|
||||
@@ -620,6 +619,14 @@ mod tests {
|
||||
hint.contains(CHECKSUM_MANIFEST_ASSET),
|
||||
"hint missing CHECKSUM_MANIFEST_ASSET"
|
||||
);
|
||||
assert!(
|
||||
hint.contains("codewhale platform binary"),
|
||||
"hint must describe the sole implementation asset"
|
||||
);
|
||||
assert!(
|
||||
!hint.contains("codewhale-tui"),
|
||||
"hint must not request the removed TUI implementation asset"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -733,11 +740,11 @@ mod tests {
|
||||
fn cnb_release_base_url_includes_tag_directory() {
|
||||
assert_eq!(
|
||||
cnb_release_base_url("0.8.47"),
|
||||
"https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
|
||||
"https://cnb.cool/codewhale.net/codewhale/-/releases/download/v0.8.47"
|
||||
);
|
||||
assert_eq!(
|
||||
cnb_release_base_url("v0.8.47"),
|
||||
"https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
|
||||
"https://cnb.cool/codewhale.net/codewhale/-/releases/download/v0.8.47"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ repository.workspace = true
|
||||
description = "Secret storage backends for Codewhale, with OS keyring and file fallback"
|
||||
|
||||
[dependencies]
|
||||
codewhale-paths = { path = "../paths", version = "0.9.4" }
|
||||
codewhale-paths = { path = "../paths", version = "0.9.6" }
|
||||
chrono.workspace = true
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -10,8 +10,8 @@ description = "Session/thread persistence and recovery model for Codewhale"
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
codewhale-paths = { path = "../paths", version = "0.9.4" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.9.4" }
|
||||
codewhale-paths = { path = "../paths", version = "0.9.6" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.9.6" }
|
||||
fd-lock = "4.0.4"
|
||||
rusqlite.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -5,14 +5,14 @@ edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Opt-in product telemetry client for Codewhale"
|
||||
description = "Anonymous, user-disableable product usage counting for Codewhale"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
codewhale-config = { path = "../config", version = "0.9.4" }
|
||||
codewhale-paths = { path = "../paths", version = "0.9.4" }
|
||||
codewhale-release = { path = "../release", version = "0.9.4" }
|
||||
codewhale-config = { path = "../config", version = "0.9.6" }
|
||||
codewhale-paths = { path = "../paths", version = "0.9.6" }
|
||||
codewhale-release = { path = "../release", version = "0.9.6" }
|
||||
fd-lock = "4.0.4"
|
||||
reqwest = { workspace = true, features = ["blocking"] }
|
||||
serde.workspace = true
|
||||
@@ -22,9 +22,9 @@ tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
codewhale-build-support = { path = "../build-support", version = "0.9.4" }
|
||||
codewhale-build-support = { path = "../build-support", version = "0.9.6" }
|
||||
|
||||
[dev-dependencies]
|
||||
# Used as an *assertion*, never as a filter: every string leaf of a serialized
|
||||
# batch is run through `redact_for_disclosure` and must come back unredacted.
|
||||
codewhale-workflow = { path = "../workflow", version = "0.9.4" }
|
||||
codewhale-workflow = { path = "../workflow", version = "0.9.6" }
|
||||
|
||||
@@ -29,7 +29,6 @@ pub const BATCH_MAX_BYTES: usize = 64 * 1024;
|
||||
|
||||
pub(crate) enum Message {
|
||||
Event(Box<Event>),
|
||||
Flush(SyncSender<FlushOutcome>),
|
||||
Shutdown(SyncSender<FlushOutcome>),
|
||||
}
|
||||
|
||||
@@ -85,11 +84,6 @@ impl Handle {
|
||||
let _ = self.tx.send(Message::Event(Box::new(event)));
|
||||
}
|
||||
|
||||
/// Ask for a flush and wait at most `deadline` for the answer.
|
||||
pub(crate) fn flush(&self, deadline: Duration) -> FlushOutcome {
|
||||
self.round_trip(deadline, Message::Flush)
|
||||
}
|
||||
|
||||
/// Ask for a final flush and stop the thread.
|
||||
pub(crate) fn shutdown(&self, deadline: Duration) -> FlushOutcome {
|
||||
self.round_trip(deadline, Message::Shutdown)
|
||||
@@ -123,10 +117,6 @@ fn run(context: &Context, rx: &Receiver<Message>) {
|
||||
append(context, &event);
|
||||
None
|
||||
}
|
||||
Message::Flush(ack) => {
|
||||
let _ = ack.send(flush(context));
|
||||
None
|
||||
}
|
||||
Message::Shutdown(ack) => {
|
||||
let _ = ack.send(flush(context));
|
||||
Some(())
|
||||
|
||||
+134
-89
@@ -12,20 +12,18 @@
|
||||
//! | `install_id.json` | the random install id |
|
||||
//! | `disabled` | the tombstone: present ⇒ nothing is appended, drained, or sent |
|
||||
//!
|
||||
//! **Appends never take a lock.** One `O_APPEND` `write(2)` under `PIPE_BUF` is
|
||||
//! atomic on every filesystem this ships to, and taking `fd_lock` here would be
|
||||
//! a *blocking* acquisition on the panic hook and the SIGINT path. `flock` is
|
||||
//! per-fd within a process, so an actor panic while holding the compaction lock
|
||||
//! would self-deadlock the hook — `catch_unwind` runs *after* the hook, so it
|
||||
//! cannot save this — and a second Codewhale process sharing `CODEWHALE_HOME`
|
||||
//! would hang Ctrl-C, breaking the second-signal contract in `main.rs`.
|
||||
//! Appends, compaction, delivery, identity/state writes, startup arming, and
|
||||
//! wipe share one sibling lock. Runtime and exit paths take it with
|
||||
//! `try_write()`: on contention the event or batch is dropped. Only startup
|
||||
//! arming and the user-requested wipe may wait. That keeps the panic hook and
|
||||
//! SIGINT path non-blocking while also making opt-out an ordering boundary —
|
||||
//! after wipe returns, no pre-wipe writer or sender can still publish data.
|
||||
//!
|
||||
//! Compaction is the only lock holder and uses `try_write()`: on contention it
|
||||
//! skips this cycle. Appenders re-open per append, so a compaction rewrite
|
||||
//! cannot leave anyone writing to a stale inode.
|
||||
//! Appenders re-open per append, so a compaction rewrite cannot leave anyone
|
||||
//! writing to a stale inode.
|
||||
|
||||
use std::fs::{self, DirBuilder, File, OpenOptions};
|
||||
use std::io::Write as _;
|
||||
use std::io::{Read as _, Write as _};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
@@ -37,6 +35,16 @@ pub const MAX_BYTES: u64 = 256 * 1024;
|
||||
/// A single append must fit in one atomic `write(2)`.
|
||||
pub const MAX_LINE_BYTES: usize = 4096;
|
||||
|
||||
/// Exact contents of the opt-out tombstone observed by a consent decision.
|
||||
///
|
||||
/// A fresh nonce is written when a machine transitions into an opted-out
|
||||
/// period. Arming may clear only the exact generation its decision observed,
|
||||
/// so an older consent token cannot erase a newer opt-out.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct TombstoneGeneration(Vec<u8>);
|
||||
|
||||
const MAX_TOMBSTONE_BYTES: u64 = 128;
|
||||
|
||||
/// Below this size a sink cannot possibly hold [`MAX_EVENTS`] lines, so an
|
||||
/// append skips the count probe entirely. The shortest serializable event line
|
||||
/// is well over 8 bytes, and `512 * 9 > 4096`, so this bound is safe by
|
||||
@@ -94,6 +102,29 @@ pub fn tombstone_present(root: &Path) -> bool {
|
||||
tombstone_path(root).exists()
|
||||
}
|
||||
|
||||
/// Read the exact tombstone generation, or `None` when collection has never
|
||||
/// been disabled in this home.
|
||||
pub(crate) fn tombstone_generation(root: &Path) -> Result<Option<TombstoneGeneration>> {
|
||||
let path = tombstone_path(root);
|
||||
let file = match File::open(&path) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => {
|
||||
return Err(
|
||||
anyhow::Error::new(error).context(format!("failed to open {}", path.display()))
|
||||
);
|
||||
}
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_TOMBSTONE_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
if bytes.len() as u64 > MAX_TOMBSTONE_BYTES {
|
||||
anyhow::bail!("{} exceeds the tombstone size limit", path.display());
|
||||
}
|
||||
Ok(Some(TombstoneGeneration(bytes)))
|
||||
}
|
||||
|
||||
/// Create the telemetry directory `0700`, if it is missing.
|
||||
pub fn ensure_dir(root: &Path) -> Result<()> {
|
||||
if root.is_dir() {
|
||||
@@ -125,38 +156,12 @@ fn secure(_file: &File) -> Result<()> {
|
||||
|
||||
/// Append one serialized event or batch to `path`.
|
||||
///
|
||||
/// Returns `None` — never an error — when the tombstone is present, when the
|
||||
/// line would not fit in one atomic write, or when any filesystem step fails.
|
||||
/// Telemetry is fail-open by construction: it never returns an error to a
|
||||
/// caller and never blocks a turn, a tool, or process exit.
|
||||
/// Returns `None` — never an error — when the tombstone is present, the privacy
|
||||
/// lock is held, the line would not fit in one atomic write, or any filesystem
|
||||
/// step fails. The lock acquisition is non-blocking, including on the panic and
|
||||
/// signal paths.
|
||||
pub fn append(root: &Path, path: &Path, line: &str) -> Option<()> {
|
||||
if tombstone_present(root) {
|
||||
return None;
|
||||
}
|
||||
let bytes = line.as_bytes();
|
||||
if bytes.is_empty() || bytes.len() + 1 > MAX_LINE_BYTES {
|
||||
return None;
|
||||
}
|
||||
ensure_dir(root).ok()?;
|
||||
|
||||
let mut buf = Vec::with_capacity(bytes.len() + 1);
|
||||
buf.extend_from_slice(bytes);
|
||||
buf.push(b'\n');
|
||||
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.ok()?;
|
||||
secure(&file).ok()?;
|
||||
// One `write(2)`, not `write_fmt` and not two calls: a split write is what
|
||||
// a concurrent appender would interleave with.
|
||||
(&file).write_all(&buf).ok()?;
|
||||
file.sync_data().ok()?;
|
||||
drop(file);
|
||||
|
||||
enforce_ring(root, path);
|
||||
Some(())
|
||||
append_with_limit(root, path, line, MAX_LINE_BYTES)
|
||||
}
|
||||
|
||||
/// Append a line that is too large for one atomic `write(2)`, serialising
|
||||
@@ -167,47 +172,52 @@ pub fn append(root: &Path, path: &Path, line: &str) -> Option<()> {
|
||||
/// handler, so a **non-blocking** `try_write` is safe there. On contention the
|
||||
/// batch is dropped, which is the same fail-open behavior as a failed POST.
|
||||
pub fn append_locked(root: &Path, path: &Path, line: &str) -> Option<()> {
|
||||
if tombstone_present(root) {
|
||||
return None;
|
||||
}
|
||||
append_with_limit(root, path, line, MAX_BYTES as usize)
|
||||
}
|
||||
|
||||
fn append_with_limit(root: &Path, path: &Path, line: &str, limit: usize) -> Option<()> {
|
||||
let bytes = line.as_bytes();
|
||||
if bytes.is_empty() || bytes.len() as u64 + 1 > MAX_BYTES {
|
||||
if bytes.is_empty() || bytes.len() + 1 > limit {
|
||||
return None;
|
||||
}
|
||||
ensure_dir(root).ok()?;
|
||||
|
||||
let mut buf = Vec::with_capacity(bytes.len() + 1);
|
||||
buf.extend_from_slice(bytes);
|
||||
buf.push(b'\n');
|
||||
|
||||
let wrote = try_with_lock(root, || {
|
||||
if tombstone_present(root) {
|
||||
return Ok(false);
|
||||
}
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.with_context(|| format!("failed to open {}", path.display()))?;
|
||||
secure(&file)?;
|
||||
(&file)
|
||||
.write_all(&buf)
|
||||
.with_context(|| format!("failed to append to {}", path.display()))?;
|
||||
file.sync_data()
|
||||
.with_context(|| format!("failed to sync {}", path.display()))?;
|
||||
Ok(true)
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(false);
|
||||
|
||||
let wrote = try_with_lock(root, || append_under_lock(root, path, &buf))
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(false);
|
||||
if !wrote {
|
||||
return None;
|
||||
}
|
||||
|
||||
enforce_ring(root, path);
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Append bytes while the caller holds the sibling privacy lock.
|
||||
fn append_under_lock(root: &Path, path: &Path, buf: &[u8]) -> Result<bool> {
|
||||
if tombstone_present(root) {
|
||||
return Ok(false);
|
||||
}
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.with_context(|| format!("failed to open {}", path.display()))?;
|
||||
secure(&file)?;
|
||||
// One `write(2)`, not `write_fmt` and not two calls: a split write is what
|
||||
// a concurrent appender would interleave with.
|
||||
(&file)
|
||||
.write_all(buf)
|
||||
.with_context(|| format!("failed to append to {}", path.display()))?;
|
||||
file.sync_data()
|
||||
.with_context(|| format!("failed to sync {}", path.display()))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Keep the newest [`MAX_EVENTS`] lines and at most [`MAX_BYTES`], under the
|
||||
/// compaction lock. On lock contention this cycle is skipped: the next append
|
||||
/// tries again, and the cap is a ceiling on disk footprint, not an invariant
|
||||
@@ -220,15 +230,25 @@ fn enforce_ring(root: &Path, path: &Path) {
|
||||
if len < PROBE_BYTES {
|
||||
return;
|
||||
}
|
||||
let Ok(contents) = fs::read_to_string(path) else {
|
||||
return;
|
||||
};
|
||||
let lines: Vec<&str> = contents.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
if lines.len() <= MAX_EVENTS && len <= MAX_BYTES {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = try_with_lock(root, || {
|
||||
// Re-read under the same lock as wipe. Reusing a snapshot captured
|
||||
// before a concurrent wipe would resurrect the records it truncated.
|
||||
if tombstone_present(root) {
|
||||
return Ok(());
|
||||
}
|
||||
let Ok(meta) = fs::metadata(path) else {
|
||||
return Ok(());
|
||||
};
|
||||
let len = meta.len();
|
||||
if len < PROBE_BYTES {
|
||||
return Ok(());
|
||||
}
|
||||
let contents = fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
let lines: Vec<&str> = contents.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
if lines.len() <= MAX_EVENTS && len <= MAX_BYTES {
|
||||
return Ok(());
|
||||
}
|
||||
let mut kept: Vec<&str> = lines
|
||||
.iter()
|
||||
.rev()
|
||||
@@ -383,14 +403,26 @@ pub fn truncate(path: &Path) -> Result<()> {
|
||||
pub fn wipe(root: &Path) -> Result<()> {
|
||||
with_lock(root, || {
|
||||
let tombstone = tombstone_path(root);
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&tombstone)
|
||||
.with_context(|| format!("failed to write {}", tombstone.display()))?;
|
||||
secure(&file)?;
|
||||
drop(file);
|
||||
// A tombstone generation identifies one durable opted-out period. Keep
|
||||
// it stable across later launches that re-observe the same persistent
|
||||
// choice; re-enable removes the file, so the next real opt-out creates
|
||||
// a naturally distinct generation. An unreadable or oversized file is
|
||||
// repaired in the fail-closed direction by replacing it here; legacy
|
||||
// empty tombstones remain valid stable generations.
|
||||
if tombstone_generation(root).ok().flatten().is_none() {
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&tombstone)
|
||||
.with_context(|| format!("failed to write {}", tombstone.display()))?;
|
||||
secure(&file)?;
|
||||
file.write_all(uuid::Uuid::new_v4().to_string().as_bytes())
|
||||
.with_context(|| format!("failed to write {}", tombstone.display()))?;
|
||||
file.sync_data()
|
||||
.with_context(|| format!("failed to sync {}", tombstone.display()))?;
|
||||
drop(file);
|
||||
}
|
||||
|
||||
let mut failure: Option<anyhow::Error> = None;
|
||||
for path in [buffer_path(root), dryrun_path(root)] {
|
||||
@@ -415,14 +447,27 @@ pub fn wipe(root: &Path) -> Result<()> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Clear the tombstone and drop anything buffered before consent.
|
||||
/// Clear the tombstone and drop anything buffered before this process was
|
||||
/// permitted.
|
||||
///
|
||||
/// Called by `init` on every arming. No event recorded before the user said yes
|
||||
/// can be in the batch that follows it — a stale buffer left by an earlier
|
||||
/// consenting run, or by a bug, is not evidence of this user's consent.
|
||||
pub fn arm(root: &Path) -> Result<()> {
|
||||
/// Called by `init` on every arming. Both the exact tombstone generation and a
|
||||
/// fresh durable permission check must still match while the wipe lock is held.
|
||||
/// A stale buffer left by an earlier run or by a bug cannot enter the new
|
||||
/// process's batch.
|
||||
pub(crate) fn arm(
|
||||
root: &Path,
|
||||
observed_generation: Option<&TombstoneGeneration>,
|
||||
permission_still_enabled: impl FnOnce() -> bool,
|
||||
) -> Result<()> {
|
||||
ensure_dir(root)?;
|
||||
with_lock(root, || {
|
||||
let current_generation = tombstone_generation(root)?;
|
||||
if current_generation.as_ref() != observed_generation {
|
||||
anyhow::bail!("telemetry permission changed before arming");
|
||||
}
|
||||
if !permission_still_enabled() {
|
||||
anyhow::bail!("telemetry permission is no longer enabled");
|
||||
}
|
||||
let tombstone = tombstone_path(root);
|
||||
if tombstone.exists() {
|
||||
fs::remove_file(&tombstone)
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! The shipped default endpoint is `codewhale_config::DEFAULT_TELEMETRY_ENDPOINT`,
|
||||
//! the first-party ingest service documented in `docs/TELEMETRY.md`. That
|
||||
//! default decides only *where* a batch goes, never *whether* one exists: this
|
||||
//! module is reached only by a session that resolved telemetry on, which
|
||||
//! requires the first-run notice to have been answered with Enable.
|
||||
//! module is reached only by a session that resolved telemetry on after every
|
||||
//! persistent and run-scoped opt-out was applied.
|
||||
//!
|
||||
//! `None` here is the dry-run sink, reachable by configuring an empty endpoint:
|
||||
//! batches are serialized with the same serializer a real endpoint would see and
|
||||
@@ -35,12 +35,20 @@ pub enum SendOutcome {
|
||||
|
||||
/// Serialize and deliver one batch.
|
||||
///
|
||||
/// The tombstone is re-checked immediately before delivery, so a wipe that
|
||||
/// landed while the batch was being assembled still stops it.
|
||||
/// Network delivery holds the same non-blocking privacy lock as appends and
|
||||
/// wipe. A wipe waits for an already-started POST to finish; a POST that races
|
||||
/// a held or completed wipe is dropped before reaching the wire. Therefore no
|
||||
/// delivery can remain in flight after persistent opt-out returns.
|
||||
pub fn send(root: &Path, endpoint: Option<&str>, batch: &Batch) -> SendOutcome {
|
||||
if buffer::tombstone_present(root) {
|
||||
return SendOutcome::Dropped;
|
||||
}
|
||||
send_with_transport(root, endpoint, batch, post)
|
||||
}
|
||||
|
||||
pub(crate) fn send_with_transport(
|
||||
root: &Path,
|
||||
endpoint: Option<&str>,
|
||||
batch: &Batch,
|
||||
transport: impl FnOnce(&str, &str, String) -> SendOutcome,
|
||||
) -> SendOutcome {
|
||||
let Ok(body) = serde_json::to_string(batch) else {
|
||||
return SendOutcome::Dropped;
|
||||
};
|
||||
@@ -52,7 +60,15 @@ pub fn send(root: &Path, endpoint: Option<&str>, batch: &Batch) -> SendOutcome {
|
||||
None => SendOutcome::Dropped,
|
||||
}
|
||||
}
|
||||
Some(endpoint) => post(endpoint, &batch.app_version, body),
|
||||
Some(endpoint) => buffer::try_with_lock(root, || {
|
||||
if buffer::tombstone_present(root) {
|
||||
return Ok(SendOutcome::Dropped);
|
||||
}
|
||||
Ok(transport(endpoint, &batch.app_version, body))
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(SendOutcome::Dropped),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
//! The one place that decides whether anything may be collected, and the token
|
||||
//! that makes that decision unforgeable.
|
||||
//! The one place that decides whether anonymous usage counting may run, and the
|
||||
//! token that makes that decision unforgeable.
|
||||
//!
|
||||
//! Every emitting surface calls [`decide`] and then, if and only if it gets
|
||||
//! [`TelemetryDecision::Enabled`], hands the contained [`TelemetryConsent`] to
|
||||
//! [`crate::init`]. `TelemetryConsent` has no `Default`, no public constructor,
|
||||
//! and cannot be built from a `bool`; `init` takes it **by value**. That is what
|
||||
//! makes consent enforceable by the type system rather than by six init sites
|
||||
//! makes the permission decision enforceable by the type system rather than by six init sites
|
||||
//! each remembering to re-check the same five-part predicate.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use codewhale_config::{ResolvedRuntimeOptions, SetupState, TELEMETRY_NOTICE_VERSION};
|
||||
use codewhale_config::{ResolvedRuntimeOptions, SetupState};
|
||||
|
||||
use crate::buffer;
|
||||
use crate::event::Surface;
|
||||
@@ -21,23 +21,23 @@ pub const TELEMETRY_DIR: &str = "telemetry";
|
||||
/// The outcome of the emit predicate.
|
||||
///
|
||||
/// The split between [`Self::OptedOut`] and [`Self::ForcedOff`] is load-bearing,
|
||||
/// not cosmetic. "Telemetry resolved to false" is the *default* state of every
|
||||
/// installation, so a wipe keyed on it would delete a consenting user's identity
|
||||
/// and unflushed buffer every time they ran one `codewhale exec` with a
|
||||
/// not cosmetic. A run-scoped kill switch also resolves telemetry to false, so
|
||||
/// a wipe keyed on that value would delete a user's identity and unflushed
|
||||
/// buffer every time they ran one `codewhale exec` with a
|
||||
/// transient `CODEWHALE_TELEMETRY=0` — the recipe the runtime docs themselves
|
||||
/// prescribe.
|
||||
#[derive(Debug)]
|
||||
pub enum TelemetryDecision {
|
||||
/// The user answered the notice and said yes, and nothing forces off.
|
||||
/// Anonymous usage counting is enabled and nothing forces it off.
|
||||
Enabled(TelemetryConsent),
|
||||
/// A human said no — `--telemetry false`, `CODEWHALE_TELEMETRY=0`,
|
||||
/// `telemetry = false`, or declining the notice. **The only variant that
|
||||
/// touches disk**: it wipes and leaves a tombstone.
|
||||
/// A human persistently said no — `telemetry = false` in durable config or
|
||||
/// declining the notice. **The only variant that touches disk**: it wipes
|
||||
/// and leaves a tombstone. CLI and environment false values are run-scoped
|
||||
/// kill switches and produce [`Self::ForcedOff`] instead.
|
||||
OptedOut,
|
||||
/// Off for a reason that is not the user's answer: no notice decision
|
||||
/// recorded, an unparseable env value, an unresolvable home, a rejected
|
||||
/// endpoint, or a bumped notice version. Touches nothing, ever. Leaves
|
||||
/// identity and buffer exactly as they were.
|
||||
/// Off for a run-scoped or environmental reason: an unparseable env value,
|
||||
/// an unresolvable home, or a rejected endpoint. Touches nothing, ever.
|
||||
/// Leaves identity and buffer exactly as they were.
|
||||
ForcedOff,
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ pub struct TelemetryConsent {
|
||||
endpoint: Option<String>,
|
||||
surface: Surface,
|
||||
config_path: Option<PathBuf>,
|
||||
tombstone_generation: Option<buffer::TombstoneGeneration>,
|
||||
}
|
||||
|
||||
impl TelemetryConsent {
|
||||
@@ -108,6 +109,21 @@ impl TelemetryConsent {
|
||||
pub fn surface(&self) -> Surface {
|
||||
self.surface
|
||||
}
|
||||
|
||||
/// Exact opt-out generation this decision observed.
|
||||
pub(crate) fn tombstone_generation(&self) -> Option<&buffer::TombstoneGeneration> {
|
||||
self.tombstone_generation.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
enum TelemetryEvaluation {
|
||||
Enabled {
|
||||
root: PathBuf,
|
||||
endpoint: Option<String>,
|
||||
tombstone_generation: Option<buffer::TombstoneGeneration>,
|
||||
},
|
||||
OptedOut(Option<PathBuf>),
|
||||
ForcedOff,
|
||||
}
|
||||
|
||||
/// Why an endpoint was refused.
|
||||
@@ -193,58 +209,100 @@ pub fn decide(
|
||||
decide_in_home(home.as_deref(), resolved, setup, surface)
|
||||
}
|
||||
|
||||
/// Load the privacy-bearing setup record for a telemetry decision.
|
||||
///
|
||||
/// A genuinely missing record is a fresh installation and therefore uses the
|
||||
/// documented default. An existing record that cannot be read or parsed may
|
||||
/// contain a durable decline, so it fails closed instead of being replaced by
|
||||
/// a default-on value.
|
||||
#[must_use]
|
||||
pub fn load_setup_state_for_decision() -> Option<SetupState> {
|
||||
let path = SetupState::path().ok()?;
|
||||
load_setup_state_for_decision_at(&path)
|
||||
}
|
||||
|
||||
/// Injectable form of [`load_setup_state_for_decision`] used by every surface
|
||||
/// and by regression tests.
|
||||
#[must_use]
|
||||
pub fn load_setup_state_for_decision_at(path: &Path) -> Option<SetupState> {
|
||||
match path.try_exists() {
|
||||
Ok(false) => Some(SetupState::default()),
|
||||
Ok(true) => SetupState::load_from(path),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the emit predicate against an explicit Codewhale home.
|
||||
///
|
||||
/// The predicate, in order:
|
||||
///
|
||||
/// 1. Telemetry resolved to `false` **and** a human said so → `OptedOut`;
|
||||
/// resolved `false` from the unset default → `ForcedOff`.
|
||||
/// 2. Notice decision recorded and declined → `OptedOut`.
|
||||
/// 3. No notice decision for the current notice version → `ForcedOff`. **A
|
||||
/// pre-existing `telemetry = true` is not consent**: the key has been
|
||||
/// settable and inert for a long time, so anyone who set it set a no-op. The
|
||||
/// notice record is an independent AND condition, never inferred from the
|
||||
/// bool.
|
||||
/// 4. No resolvable home → `ForcedOff`.
|
||||
/// 5. Endpoint configured but refused by [`validate_endpoint`] → `ForcedOff`.
|
||||
/// 6. Otherwise `Enabled`.
|
||||
/// 1. Telemetry resolved to `false` from persistent config → `OptedOut`;
|
||||
/// resolved `false` from a run-scoped or invalid-value floor → `ForcedOff`.
|
||||
/// 2. Any recorded notice decline → `OptedOut`, including a decline recorded
|
||||
/// by the former opt-in notice.
|
||||
/// 3. No resolvable home → `ForcedOff`.
|
||||
/// 4. Endpoint configured but refused by [`validate_endpoint`] → `ForcedOff`.
|
||||
/// 5. Otherwise `Enabled`.
|
||||
///
|
||||
/// Consent is **machine-scoped**. The notice is only ever *rendered* on a TTY,
|
||||
/// but a decision recorded on a TTY authorizes later non-TTY runs on the same
|
||||
/// home. A fresh CI home has no decision, so step 3 fires and nothing is
|
||||
/// collected — and nothing is written to disk to find out.
|
||||
/// The notice is only ever *rendered* on a TTY. The interactive TUI explains
|
||||
/// the default in a native startup modal before telemetry is armed; headless
|
||||
/// surfaces use the same documented default and kill switches.
|
||||
pub fn decide_in_home(
|
||||
home: Option<&Path>,
|
||||
resolved: &ResolvedRuntimeOptions,
|
||||
setup: &SetupState,
|
||||
surface: Surface,
|
||||
) -> TelemetryDecision {
|
||||
match evaluate_in_home(home, resolved, setup) {
|
||||
TelemetryEvaluation::Enabled {
|
||||
root,
|
||||
endpoint,
|
||||
tombstone_generation,
|
||||
} => TelemetryDecision::Enabled(TelemetryConsent {
|
||||
root,
|
||||
endpoint,
|
||||
surface,
|
||||
config_path: None,
|
||||
tombstone_generation,
|
||||
}),
|
||||
TelemetryEvaluation::OptedOut(root) => opted_out(root.as_deref()),
|
||||
TelemetryEvaluation::ForcedOff => TelemetryDecision::ForcedOff,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate the permission predicate without performing the opt-out wipe.
|
||||
///
|
||||
/// Keeping the classification pure lets `init` re-check it while holding the
|
||||
/// privacy lock. The public decision path maps `OptedOut` to the destructive
|
||||
/// wipe exactly once, outside that already-held lock.
|
||||
fn evaluate_in_home(
|
||||
home: Option<&Path>,
|
||||
resolved: &ResolvedRuntimeOptions,
|
||||
setup: &SetupState,
|
||||
) -> TelemetryEvaluation {
|
||||
let root = home.map(|home| home.join(TELEMETRY_DIR));
|
||||
|
||||
// 1. An explicit "off" from a human is an answer and wipes; the unset
|
||||
// default is not an answer and must leave every byte alone.
|
||||
// 1. An explicit persistent "off" is an opt-out and wipes. Run-scoped or
|
||||
// invalid-value false is only a kill switch and leaves disk alone.
|
||||
if !resolved.telemetry {
|
||||
if resolved.telemetry_explicit_off {
|
||||
return opted_out(root.as_deref());
|
||||
return TelemetryEvaluation::OptedOut(root);
|
||||
}
|
||||
return TelemetryDecision::ForcedOff;
|
||||
return TelemetryEvaluation::ForcedOff;
|
||||
}
|
||||
|
||||
// 2/3. The notice record is an independent condition. Declining is an
|
||||
// answer; never having been asked is not.
|
||||
if setup.needs_telemetry_notice(TELEMETRY_NOTICE_VERSION) {
|
||||
return TelemetryDecision::ForcedOff;
|
||||
}
|
||||
if !setup.telemetry_opt_in {
|
||||
return opted_out(root.as_deref());
|
||||
// 2. A historical or current decline remains a durable opt-out. Notice
|
||||
// version bumps may update disclosure, never reverse a user's "no".
|
||||
if setup.telemetry_opted_out() {
|
||||
return TelemetryEvaluation::OptedOut(root);
|
||||
}
|
||||
|
||||
// 4. Nowhere to keep an install id or a buffer.
|
||||
// 3. Nowhere to keep an install id or a buffer.
|
||||
let Some(root) = root else {
|
||||
return TelemetryDecision::ForcedOff;
|
||||
return TelemetryEvaluation::ForcedOff;
|
||||
};
|
||||
|
||||
// 5. A refused endpoint is a configuration error, not a user answer.
|
||||
// 4. A refused endpoint is a configuration error, not a user answer.
|
||||
let endpoint = match resolved.telemetry_endpoint.as_deref() {
|
||||
Some(raw) if !raw.trim().is_empty() => match validate_endpoint(raw) {
|
||||
Ok(endpoint) => Some(endpoint),
|
||||
@@ -253,18 +311,54 @@ pub fn decide_in_home(
|
||||
"telemetry endpoint refused ({}); telemetry is off for this run",
|
||||
error.label()
|
||||
);
|
||||
return TelemetryDecision::ForcedOff;
|
||||
return TelemetryEvaluation::ForcedOff;
|
||||
}
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
|
||||
TelemetryDecision::Enabled(TelemetryConsent {
|
||||
let Ok(tombstone_generation) = buffer::tombstone_generation(&root) else {
|
||||
return TelemetryEvaluation::ForcedOff;
|
||||
};
|
||||
TelemetryEvaluation::Enabled {
|
||||
root,
|
||||
endpoint,
|
||||
surface,
|
||||
config_path: None,
|
||||
})
|
||||
tombstone_generation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-check the current durable permission without wiping or clearing state.
|
||||
///
|
||||
/// Called only while `init` holds the telemetry privacy lock. A stale consent
|
||||
/// token may arm only when the config, setup-state answer, home, and endpoint
|
||||
/// still classify as enabled.
|
||||
pub(crate) fn permission_still_enabled(config_path: Option<&Path>, expected_root: &Path) -> bool {
|
||||
let Ok(setup_path) = SetupState::path() else {
|
||||
return false;
|
||||
};
|
||||
let home = codewhale_paths::codewhale_home().ok().flatten();
|
||||
permission_still_enabled_in_home(config_path, &setup_path, home.as_deref(), expected_root)
|
||||
}
|
||||
|
||||
pub(crate) fn permission_still_enabled_in_home(
|
||||
config_path: Option<&Path>,
|
||||
setup_path: &Path,
|
||||
home: Option<&Path>,
|
||||
expected_root: &Path,
|
||||
) -> bool {
|
||||
let Ok(store) = codewhale_config::ConfigStore::load(config_path.map(Path::to_path_buf)) else {
|
||||
return false;
|
||||
};
|
||||
let resolved = store
|
||||
.config
|
||||
.resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
|
||||
let Some(setup) = load_setup_state_for_decision_at(setup_path) else {
|
||||
return false;
|
||||
};
|
||||
matches!(
|
||||
evaluate_in_home(home, &resolved, &setup),
|
||||
TelemetryEvaluation::Enabled { root, .. } if root == expected_root
|
||||
)
|
||||
}
|
||||
|
||||
/// Re-run the predicate from the filesystem, for the flush path.
|
||||
@@ -275,13 +369,26 @@ pub fn decide_in_home(
|
||||
/// load fails: a flush is never the right place to guess.
|
||||
#[must_use]
|
||||
pub fn re_decide(config_path: Option<&Path>, surface: Surface) -> TelemetryDecision {
|
||||
let Ok(setup_path) = SetupState::path() else {
|
||||
return TelemetryDecision::ForcedOff;
|
||||
};
|
||||
re_decide_with_setup_path(config_path, &setup_path, surface)
|
||||
}
|
||||
|
||||
pub(crate) fn re_decide_with_setup_path(
|
||||
config_path: Option<&Path>,
|
||||
setup_path: &Path,
|
||||
surface: Surface,
|
||||
) -> TelemetryDecision {
|
||||
let Ok(store) = codewhale_config::ConfigStore::load(config_path.map(Path::to_path_buf)) else {
|
||||
return TelemetryDecision::ForcedOff;
|
||||
};
|
||||
let resolved = store
|
||||
.config
|
||||
.resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
|
||||
let setup = SetupState::load().ok().flatten().unwrap_or_default();
|
||||
let Some(setup) = load_setup_state_for_decision_at(setup_path) else {
|
||||
return TelemetryDecision::ForcedOff;
|
||||
};
|
||||
decide(&resolved, &setup, surface)
|
||||
}
|
||||
|
||||
|
||||
@@ -59,24 +59,29 @@ pub struct TelemetryState {
|
||||
/// always the safe direction — the cost is one rotation, and the docs already
|
||||
/// say no count derived from `install_id` is a user count.
|
||||
pub fn read_or_create_install_id(root: &Path) -> Result<InstallId> {
|
||||
let path = buffer::install_id_path(root);
|
||||
let existing = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|body| serde_json::from_str::<InstallId>(&body).ok())
|
||||
.filter(|record| uuid::Uuid::parse_str(record.install_id.trim()).is_ok())
|
||||
.filter(|record| !is_expired(&record.rotated_at));
|
||||
if let Some(record) = existing {
|
||||
return Ok(record);
|
||||
}
|
||||
let record = InstallId {
|
||||
schema_version: 1,
|
||||
install_id: uuid::Uuid::new_v4().to_string(),
|
||||
rotated_at: now_rfc3339(),
|
||||
};
|
||||
buffer::ensure_dir(root)?;
|
||||
codewhale_config::persistence::atomic_write_json(&path, &record)
|
||||
.with_context(|| format!("failed to write {}", path.display()))?;
|
||||
Ok(record)
|
||||
buffer::try_with_lock(root, || {
|
||||
if buffer::tombstone_present(root) {
|
||||
anyhow::bail!("telemetry is disabled");
|
||||
}
|
||||
let path = buffer::install_id_path(root);
|
||||
let existing = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|body| serde_json::from_str::<InstallId>(&body).ok())
|
||||
.filter(|record| uuid::Uuid::parse_str(record.install_id.trim()).is_ok())
|
||||
.filter(|record| !is_expired(&record.rotated_at));
|
||||
if let Some(record) = existing {
|
||||
return Ok(record);
|
||||
}
|
||||
let record = InstallId {
|
||||
schema_version: 1,
|
||||
install_id: uuid::Uuid::new_v4().to_string(),
|
||||
rotated_at: now_rfc3339(),
|
||||
};
|
||||
codewhale_config::persistence::atomic_write_json(&path, &record)
|
||||
.with_context(|| format!("failed to write {}", path.display()))?;
|
||||
Ok(record)
|
||||
})?
|
||||
.ok_or_else(|| anyhow::anyhow!("telemetry privacy lock is held"))
|
||||
}
|
||||
|
||||
fn is_expired(rotated_at: &str) -> bool {
|
||||
@@ -100,10 +105,15 @@ pub fn read_state(root: &Path) -> TelemetryState {
|
||||
|
||||
/// Write `state.json`.
|
||||
pub fn write_state(root: &Path, state: &TelemetryState) -> Result<()> {
|
||||
buffer::ensure_dir(root)?;
|
||||
let path = buffer::state_path(root);
|
||||
codewhale_config::persistence::atomic_write_json(&path, state)
|
||||
.with_context(|| format!("failed to write {}", path.display()))
|
||||
buffer::try_with_lock(root, || {
|
||||
if buffer::tombstone_present(root) {
|
||||
anyhow::bail!("telemetry is disabled");
|
||||
}
|
||||
let path = buffer::state_path(root);
|
||||
codewhale_config::persistence::atomic_write_json(&path, state)
|
||||
.with_context(|| format!("failed to write {}", path.display()))
|
||||
})?
|
||||
.ok_or_else(|| anyhow::anyhow!("telemetry privacy lock is held"))
|
||||
}
|
||||
|
||||
/// RFC3339 UTC at second precision. The only timestamp this crate produces, and
|
||||
|
||||
+20
-57
@@ -1,4 +1,4 @@
|
||||
//! Opt-in product telemetry for Codewhale.
|
||||
//! Default-on, user-disableable anonymous product usage counting for Codewhale.
|
||||
//!
|
||||
//! The whole of what this crate may ever send is [`event`]. The whole of what
|
||||
//! decides whether it may send anything is [`decision`]. Nothing else in the
|
||||
@@ -9,7 +9,7 @@
|
||||
//!
|
||||
//! # The shape of the guarantee
|
||||
//!
|
||||
//! Consent is a **value**, not a convention. [`decide`] is the only constructor
|
||||
//! Permission is a **value**, not a convention. [`decide`] is the only constructor
|
||||
//! of [`TelemetryConsent`]; [`init`] takes one by value and there is no
|
||||
//! bool-taking sibling. Six init sites cannot each drift from the predicate,
|
||||
//! because they never see the predicate.
|
||||
@@ -21,8 +21,8 @@
|
||||
//! construction empty until resolution completes. A disabled user's panic
|
||||
//! therefore writes nothing and creates no directory.
|
||||
//!
|
||||
//! Arming also **truncates** the buffer. No event recorded before consent can
|
||||
//! ever be in the batch that follows it.
|
||||
//! Arming also **truncates** any stale buffer before a newly permitted process
|
||||
//! begins recording.
|
||||
//!
|
||||
//! # Failure posture
|
||||
//!
|
||||
@@ -53,7 +53,7 @@ pub use actor::{BATCH_MAX_BYTES, BATCH_MAX_EVENTS, FlushOutcome};
|
||||
pub use counters::{Counter, ErrorCounter, SessionCounters};
|
||||
pub use decision::{
|
||||
EndpointError, TELEMETRY_DIR, TelemetryConsent, TelemetryDecision, decide, decide_in_home,
|
||||
re_decide, validate_endpoint,
|
||||
load_setup_state_for_decision, load_setup_state_for_decision_at, re_decide, validate_endpoint,
|
||||
};
|
||||
pub use envelope::reduce_panic_site;
|
||||
pub use event::{
|
||||
@@ -68,9 +68,6 @@ pub use event::{
|
||||
/// handshake would hold a user's terminal past exit.
|
||||
pub const SHUTDOWN_FLUSH_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Minimum gap between startup drains.
|
||||
pub const STARTUP_DRAIN_INTERVAL_HOURS: i64 = 6;
|
||||
|
||||
/// Everything a write path needs once the process is armed.
|
||||
struct Armed {
|
||||
handle: actor::Handle,
|
||||
@@ -93,10 +90,16 @@ pub fn init(consent: TelemetryConsent) {
|
||||
return;
|
||||
}
|
||||
let root = consent.root().to_path_buf();
|
||||
let observed_generation = consent.tombstone_generation().cloned();
|
||||
let config_path = consent.config_path().map(std::path::Path::to_path_buf);
|
||||
|
||||
// Clear the tombstone and drop anything buffered before consent. A stale
|
||||
// buffer is not evidence of this user's answer.
|
||||
if let Err(error) = buffer::arm(&root) {
|
||||
// Re-check durable permission under the same ordering lock as wipe. The
|
||||
// generation match prevents consent resolved before a newer opt-out from
|
||||
// clearing that opt-out; the fresh predicate preserves intentional
|
||||
// `config set telemetry true` re-enablement.
|
||||
if let Err(error) = buffer::arm(&root, observed_generation.as_ref(), || {
|
||||
decision::permission_still_enabled(config_path.as_deref(), &root)
|
||||
}) {
|
||||
tracing::debug!("telemetry could not prepare its buffer: {error}");
|
||||
return;
|
||||
}
|
||||
@@ -213,18 +216,18 @@ pub fn record(event: Event) {
|
||||
armed.handle.record(event);
|
||||
}
|
||||
|
||||
/// Write an event synchronously, without the writer thread and **without any
|
||||
/// lock**.
|
||||
/// Write an event synchronously, without the writer thread.
|
||||
///
|
||||
/// The synchronous escape hatch for the three paths where the async world is
|
||||
/// gone or going: the panic hook, `record_caught_panic`, and the signal task
|
||||
/// immediately before `std::process::exit`. One `O_APPEND` `write(2)` under
|
||||
/// `PIPE_BUF`, a `sync_data`, and return — microseconds.
|
||||
///
|
||||
/// Taking the compaction lock here would be a *blocking* acquisition on both of
|
||||
/// those paths. `flock` is per-fd within a process, so an actor panic while
|
||||
/// holding that lock would self-deadlock the hook, and a second Codewhale
|
||||
/// process sharing `CODEWHALE_HOME` would hang Ctrl-C.
|
||||
/// The append takes the shared privacy lock with `try_write()`, never a blocking
|
||||
/// acquisition. If the actor, a wipe, or another Codewhale process sharing
|
||||
/// `CODEWHALE_HOME` holds it, the event is dropped immediately. This preserves
|
||||
/// the panic/SIGINT liveness contract without allowing a write to race past a
|
||||
/// completed opt-out.
|
||||
///
|
||||
/// A no-op when unarmed, which is what makes a disabled user's panic write
|
||||
/// nothing and create no directory.
|
||||
@@ -260,19 +263,6 @@ pub fn exit_class() -> ExitClass {
|
||||
})
|
||||
}
|
||||
|
||||
/// Flush whatever is buffered, waiting at most `deadline`.
|
||||
///
|
||||
/// Blocking, so async callers must hand this to `spawn_blocking` and bound it —
|
||||
/// [`SHUTDOWN_FLUSH_TIMEOUT`] is the teardown budget. Consent is re-resolved
|
||||
/// from disk inside the writer thread before anything is sent.
|
||||
///
|
||||
/// Returns [`FlushOutcome::Empty`] when unarmed.
|
||||
pub fn flush_blocking(deadline: Duration) -> FlushOutcome {
|
||||
ARMED
|
||||
.get()
|
||||
.map_or(FlushOutcome::Empty, |armed| armed.handle.flush(deadline))
|
||||
}
|
||||
|
||||
/// Final flush, then stop the writer thread.
|
||||
///
|
||||
/// Returns [`FlushOutcome::Empty`] when unarmed.
|
||||
@@ -281,30 +271,3 @@ pub fn shutdown_blocking(deadline: Duration) -> FlushOutcome {
|
||||
.get()
|
||||
.map_or(FlushOutcome::Empty, |armed| armed.handle.shutdown(deadline))
|
||||
}
|
||||
|
||||
/// Whether a startup drain is due: a prior session crashed or was signalled and
|
||||
/// left events behind, and enough time has passed since the last attempt.
|
||||
///
|
||||
/// The check happens **before** the drain task is spawned, so "not due" means no
|
||||
/// task at all rather than a task that returns early.
|
||||
#[must_use]
|
||||
pub fn startup_drain_due() -> bool {
|
||||
let Some(armed) = ARMED.get() else {
|
||||
return false;
|
||||
};
|
||||
let path = buffer::buffer_path(&armed.root);
|
||||
if buffer::read_lines(&path).is_empty() {
|
||||
return false;
|
||||
}
|
||||
let state = envelope::read_state(&armed.root);
|
||||
let Some(last) = state.last_flush else {
|
||||
return true;
|
||||
};
|
||||
let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(&last) else {
|
||||
return true;
|
||||
};
|
||||
chrono::Utc::now()
|
||||
.signed_duration_since(parsed.with_timezone(&chrono::Utc))
|
||||
.num_hours()
|
||||
>= STARTUP_DRAIN_INTERVAL_HOURS
|
||||
}
|
||||
|
||||
@@ -7,55 +7,30 @@
|
||||
//!
|
||||
//! Two properties of the wording are deliberate and load-bearing:
|
||||
//!
|
||||
//! 1. **The declining option is pre-selected and Enter takes it.** Enabling
|
||||
//! requires a deliberate keystroke. There is no third option, no "improve
|
||||
//! the product" checkbox, and nothing pre-checked.
|
||||
//! 1. **The default is stated plainly and the opt-out is immediate.** The
|
||||
//! native TUI starts on the yes choice and makes the opt-out equally
|
||||
//! reachable.
|
||||
//! 2. **The red lines are stated as "not collected", not as "anonymized".**
|
||||
//! Sampling and hashing are not the same promise, and a notice that implies
|
||||
//! them when neither is true is worse than no notice.
|
||||
|
||||
/// Headline shown above [`NOTICE_BODY`].
|
||||
pub const NOTICE_HEADLINE: &str = "Help improve CodeWhale?";
|
||||
pub const NOTICE_HEADLINE: &str = "Help improve Codewhale?";
|
||||
|
||||
/// The notice itself.
|
||||
///
|
||||
/// Wrapped at 72 columns so it renders unchanged in a modal, in a pipe, and in
|
||||
/// an 80-column terminal.
|
||||
/// Wrapped at 72 columns so it renders unchanged in the native responsive
|
||||
/// modal and remains readable in an 80-column terminal.
|
||||
pub const NOTICE_BODY: &str = "\
|
||||
CodeWhale can send anonymous usage counts: which version you run, your
|
||||
OS and CPU family, which features you used, how long sessions ran, and
|
||||
how they ended.
|
||||
Codewhale counts: which version you run, OS and CPU family, session
|
||||
duration and outcome, and aggregate feature and error counters.
|
||||
|
||||
It never sends prompts, code, file names, paths, repo or branch names,
|
||||
model output, model names, or credentials. Not sampled, not hashed —
|
||||
not collected.
|
||||
It never collects your conversations, code, prompts, files, repo or
|
||||
branch names, model content, or credentials — and it never sends a
|
||||
per-turn or per-tool timeline of agent activity.
|
||||
|
||||
You are identified only by a random ID stored on this machine. It is
|
||||
deleted the moment you turn this off, and it is replaced every 90 days.
|
||||
You are identified only by a random ID stored on this machine, replaced
|
||||
every 90 days. Change your mind any time:
|
||||
codewhale config set telemetry false
|
||||
|
||||
Full schema, field by field: docs/TELEMETRY.md
|
||||
Turn it off any time: codewhale config set telemetry false
|
||||
or CODEWHALE_TELEMETRY=0";
|
||||
|
||||
/// The question, with the declining answer capitalised as the default.
|
||||
pub const NOTICE_PROMPT: &str = "Enable telemetry? [y/N]";
|
||||
|
||||
/// The line printed once a decision is recorded, so the user has a receipt.
|
||||
#[must_use]
|
||||
pub fn decision_receipt(opt_in: bool) -> &'static str {
|
||||
if opt_in {
|
||||
"Telemetry is on. Turn it off any time with `codewhale config set telemetry false`."
|
||||
} else {
|
||||
"Telemetry stays off. You will not be asked again."
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a typed answer means yes.
|
||||
///
|
||||
/// Everything else — an empty line, EOF, a closed pipe, `n`, a typo — means no.
|
||||
/// That asymmetry is the point: only an affirmative answer is an affirmative
|
||||
/// answer.
|
||||
#[must_use]
|
||||
pub fn answer_is_yes(input: &str) -> bool {
|
||||
matches!(input.trim().to_ascii_lowercase().as_str(), "y" | "yes")
|
||||
}
|
||||
Full schema, field by field: docs/TELEMETRY.md";
|
||||
|
||||
+255
-84
@@ -17,7 +17,10 @@ use codewhale_config::{
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::buffer;
|
||||
use crate::decision::{EndpointError, TelemetryDecision, decide_in_home, validate_endpoint};
|
||||
use crate::decision::{
|
||||
EndpointError, TelemetryDecision, decide_in_home, load_setup_state_for_decision_at,
|
||||
permission_still_enabled_in_home, re_decide_with_setup_path, validate_endpoint,
|
||||
};
|
||||
use crate::envelope;
|
||||
use crate::event::*;
|
||||
|
||||
@@ -59,6 +62,46 @@ fn stale_setup() -> SetupState {
|
||||
setup
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_state_loader_defaults_only_when_the_privacy_record_is_absent() {
|
||||
let home = temp_home();
|
||||
let path = home.path().join("setup_state.json");
|
||||
|
||||
assert!(
|
||||
load_setup_state_for_decision_at(&path).is_some(),
|
||||
"a genuinely fresh install uses the documented default"
|
||||
);
|
||||
|
||||
std::fs::write(&path, b"{not-json").expect("write corrupt setup state");
|
||||
assert!(
|
||||
load_setup_state_for_decision_at(&path).is_none(),
|
||||
"an existing unreadable privacy record must fail closed"
|
||||
);
|
||||
|
||||
accepted_setup()
|
||||
.save_to(&path)
|
||||
.expect("write valid setup state");
|
||||
assert!(
|
||||
load_setup_state_for_decision_at(&path)
|
||||
.is_some_and(|setup| setup.telemetry_accepted(TELEMETRY_NOTICE_VERSION)),
|
||||
"a valid setup state remains usable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_redecision_fails_closed_on_a_corrupt_setup_state() {
|
||||
let home = temp_home();
|
||||
let config_path = home.path().join("config.toml");
|
||||
let setup_path = home.path().join("setup_state.json");
|
||||
std::fs::write(&config_path, "telemetry = true\n").expect("write config");
|
||||
std::fs::write(&setup_path, b"{not-json").expect("write corrupt setup state");
|
||||
|
||||
assert!(matches!(
|
||||
re_decide_with_setup_path(Some(&config_path), &setup_path, Surface::Exec),
|
||||
TelemetryDecision::ForcedOff
|
||||
));
|
||||
}
|
||||
|
||||
/// One instance of every event variant, populated with the most adversarial
|
||||
/// values the schema permits.
|
||||
///
|
||||
@@ -413,7 +456,7 @@ fn every_legitimately_recorded_event_survives_the_drain() {
|
||||
);
|
||||
|
||||
// Dialect kinds (`deepseek-anthropic`, the Model Studio plan variants) are
|
||||
// absent from `ProviderKind::ALL`, which is the 36-row *catalog* subset,
|
||||
// absent from `ProviderKind::ALL`, which is the 37-row *catalog* subset,
|
||||
// but `ApiProvider::kind()` yields them for real routes. Narrowing the
|
||||
// provider bound to the catalog would drop those users' `session_end`.
|
||||
for kind in [
|
||||
@@ -513,15 +556,15 @@ fn decision_matrix_is_exhaustive() {
|
||||
let home = temp_home();
|
||||
let path = home.path();
|
||||
|
||||
// Row: nobody has said anything. Default off is not an answer.
|
||||
// Row: nobody has said anything. Anonymous usage counting is default-on.
|
||||
assert!(matches!(
|
||||
decide_in_home(
|
||||
Some(path),
|
||||
&resolved(false, false, None),
|
||||
&resolved(true, false, None),
|
||||
&SetupState::default(),
|
||||
Surface::Tui
|
||||
),
|
||||
TelemetryDecision::ForcedOff
|
||||
TelemetryDecision::Enabled(_)
|
||||
));
|
||||
|
||||
// Row: a human said off. That is an answer.
|
||||
@@ -535,8 +578,7 @@ fn decision_matrix_is_exhaustive() {
|
||||
TelemetryDecision::OptedOut
|
||||
));
|
||||
|
||||
// Row: on, but never asked. A pre-existing `telemetry = true` is not
|
||||
// consent — the key has been settable and inert for a long time.
|
||||
// Row: on, notice not yet shown. Headless/default-on still works.
|
||||
assert!(matches!(
|
||||
decide_in_home(
|
||||
Some(path),
|
||||
@@ -544,7 +586,7 @@ fn decision_matrix_is_exhaustive() {
|
||||
&SetupState::default(),
|
||||
Surface::Tui
|
||||
),
|
||||
TelemetryDecision::ForcedOff
|
||||
TelemetryDecision::Enabled(_)
|
||||
));
|
||||
|
||||
// Row: on, asked, declined.
|
||||
@@ -558,7 +600,8 @@ fn decision_matrix_is_exhaustive() {
|
||||
TelemetryDecision::OptedOut
|
||||
));
|
||||
|
||||
// Row: on, but the notice content changed since they answered.
|
||||
// Row: on, but the notice content changed since they answered yes. A
|
||||
// disclosure refresh does not pause usage counting.
|
||||
assert!(matches!(
|
||||
decide_in_home(
|
||||
Some(path),
|
||||
@@ -566,7 +609,7 @@ fn decision_matrix_is_exhaustive() {
|
||||
&stale_setup(),
|
||||
Surface::Tui
|
||||
),
|
||||
TelemetryDecision::ForcedOff
|
||||
TelemetryDecision::Enabled(_)
|
||||
));
|
||||
|
||||
// Row: on and accepted, no home to keep state in.
|
||||
@@ -619,19 +662,18 @@ fn decision_matrix_is_exhaustive() {
|
||||
.is_enabled()
|
||||
);
|
||||
|
||||
// Row: every headless surface is reachable, because consent is
|
||||
// machine-scoped: a TTY-recorded decision authorizes later exec, cli,
|
||||
// app-server, mcp-server, and serve runs on the same home.
|
||||
// Row: every headless surface uses the same documented default and kill
|
||||
// switches.
|
||||
for surface in Surface::ALL {
|
||||
assert!(
|
||||
decide_in_home(
|
||||
Some(path),
|
||||
&resolved(true, false, None),
|
||||
&accepted_setup(),
|
||||
&SetupState::default(),
|
||||
*surface
|
||||
)
|
||||
.is_enabled(),
|
||||
"{surface:?} must be able to emit on a consenting machine"
|
||||
"{surface:?} must be able to emit by default"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -670,8 +712,6 @@ fn only_opt_out_touches_disk() {
|
||||
// have broken: `false` is the *default*, so it fired on every ordinary run.
|
||||
let forced_off_rows: Vec<(ResolvedRuntimeOptions, SetupState)> = vec![
|
||||
(resolved(false, false, None), accepted_setup()),
|
||||
(resolved(true, false, None), SetupState::default()),
|
||||
(resolved(true, false, None), stale_setup()),
|
||||
(
|
||||
resolved(true, false, Some("http://example.com/t")),
|
||||
accepted_setup(),
|
||||
@@ -762,21 +802,23 @@ fn the_tombstone_outlives_every_run_the_opt_out_covers() {
|
||||
!buffer::install_id_path(&root).exists(),
|
||||
"{surface:?} minted a new identity for an opted-out machine"
|
||||
);
|
||||
assert!(!buffer::state_path(&root).exists());
|
||||
assert!(buffer::read_lines(&buffer::buffer_path(&root)).is_empty());
|
||||
assert!(buffer::read_lines(&buffer::dryrun_path(&root)).is_empty());
|
||||
assert_eq!(snapshot(&root), after_wipe, "{surface:?} touched disk");
|
||||
}
|
||||
|
||||
// Only writing the setting back turns collection on again, and that is the
|
||||
// one path allowed to clear the tombstone.
|
||||
assert!(
|
||||
decide_in_home(
|
||||
Some(home.path()),
|
||||
&resolved(true, false, None),
|
||||
&accepted_setup(),
|
||||
Surface::Tui,
|
||||
)
|
||||
.is_enabled()
|
||||
);
|
||||
buffer::arm(&root).expect("re-consent arms");
|
||||
let TelemetryDecision::Enabled(consent) = decide_in_home(
|
||||
Some(home.path()),
|
||||
&resolved(true, false, None),
|
||||
&accepted_setup(),
|
||||
Surface::Tui,
|
||||
) else {
|
||||
panic!("an explicit re-enable must produce consent");
|
||||
};
|
||||
buffer::arm(&root, consent.tombstone_generation(), || true).expect("re-consent arms");
|
||||
assert!(!buffer::tombstone_present(&root));
|
||||
}
|
||||
|
||||
@@ -827,7 +869,7 @@ fn an_opt_out_on_a_fresh_home_creates_nothing() {
|
||||
assert!(matches!(decision, TelemetryDecision::OptedOut));
|
||||
assert!(
|
||||
!root.exists(),
|
||||
"a user who never opted in must not get a telemetry directory for saying no"
|
||||
"a fresh user who opts out must not get a telemetry directory"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1081,7 +1123,7 @@ fn drain_skips_a_torn_trailing_line() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_never_blocks_on_a_held_lock() {
|
||||
fn append_drops_without_blocking_on_a_held_privacy_lock() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
buffer::ensure_dir(&root).expect("create root");
|
||||
@@ -1102,16 +1144,20 @@ fn append_never_blocks_on_a_held_lock() {
|
||||
|
||||
held.wait();
|
||||
let started = Instant::now();
|
||||
buffer::append(&root, &path, &line(7)).expect("append under a held lock");
|
||||
let outcome = buffer::append(&root, &path, &line(7));
|
||||
let elapsed = started.elapsed();
|
||||
release.wait();
|
||||
holder.join().expect("holder thread").expect("holder lock");
|
||||
|
||||
assert!(outcome.is_none(), "a contended append must be dropped");
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(250),
|
||||
"an append waited {elapsed:?} on a lock it must never take"
|
||||
"an append waited {elapsed:?} on the privacy lock"
|
||||
);
|
||||
assert!(
|
||||
buffer::read_lines(&path).is_empty(),
|
||||
"the panic-safe path bypassed the privacy lock"
|
||||
);
|
||||
assert_eq!(buffer::read_lines(&path).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1135,11 +1181,107 @@ fn arming_truncates_a_pre_consent_buffer() {
|
||||
buffer::append(&root, &buffer::buffer_path(&root), &line(1)).expect("append");
|
||||
buffer::wipe(&root).expect("wipe");
|
||||
|
||||
buffer::arm(&root).expect("arm");
|
||||
let generation = buffer::tombstone_generation(&root).expect("read wipe generation");
|
||||
buffer::arm(&root, generation.as_ref(), || true).expect("arm");
|
||||
assert!(!buffer::tombstone_present(&root));
|
||||
assert!(buffer::read_lines(&buffer::buffer_path(&root)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_consent_cannot_clear_a_newer_opt_out_but_fresh_reenable_can() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
let config_path = home.path().join("config.toml");
|
||||
let setup_path = home.path().join("setup_state.json");
|
||||
accepted_setup()
|
||||
.save_to(&setup_path)
|
||||
.expect("write accepted setup state");
|
||||
|
||||
// This process resolved the old enabled config before another process
|
||||
// persisted an opt-out and completed its wipe.
|
||||
let stale_resolved = resolved(true, false, None);
|
||||
let TelemetryDecision::Enabled(pre_wipe_consent) = decide_in_home(
|
||||
Some(home.path()),
|
||||
&stale_resolved,
|
||||
&accepted_setup(),
|
||||
Surface::Tui,
|
||||
) else {
|
||||
panic!("pre-wipe enabled facts must produce consent");
|
||||
};
|
||||
std::fs::write(&config_path, "telemetry = false\n").expect("persist opt-out");
|
||||
buffer::ensure_dir(&root).expect("create telemetry root");
|
||||
buffer::wipe(&root).expect("complete newer wipe");
|
||||
assert!(
|
||||
buffer::arm(&root, pre_wipe_consent.tombstone_generation(), || true).is_err(),
|
||||
"an old consent token cleared a newer tombstone generation"
|
||||
);
|
||||
assert!(buffer::tombstone_present(&root));
|
||||
|
||||
// Even the difficult ordering — stale config facts combined with the new
|
||||
// tombstone generation — cannot arm, because arm re-reads the durable
|
||||
// predicate while holding the wipe lock.
|
||||
let TelemetryDecision::Enabled(stale_consent) = decide_in_home(
|
||||
Some(home.path()),
|
||||
&stale_resolved,
|
||||
&accepted_setup(),
|
||||
Surface::Tui,
|
||||
) else {
|
||||
panic!("fixture must carry stale enabled facts");
|
||||
};
|
||||
assert!(
|
||||
buffer::arm(&root, stale_consent.tombstone_generation(), || {
|
||||
permission_still_enabled_in_home(
|
||||
Some(&config_path),
|
||||
&setup_path,
|
||||
Some(home.path()),
|
||||
&root,
|
||||
)
|
||||
})
|
||||
.is_err(),
|
||||
"stale consent cleared a completed opt-out"
|
||||
);
|
||||
assert!(buffer::tombstone_present(&root));
|
||||
|
||||
// The documented explicit re-enable updates the durable register first. A
|
||||
// fresh consent observes both that value and the current generation, so it
|
||||
// may clear exactly that tombstone.
|
||||
std::fs::write(&config_path, "telemetry = true\n").expect("persist re-enable");
|
||||
let TelemetryDecision::Enabled(fresh_consent) = decide_in_home(
|
||||
Some(home.path()),
|
||||
&resolved(true, false, None),
|
||||
&accepted_setup(),
|
||||
Surface::Tui,
|
||||
) else {
|
||||
panic!("fresh re-enable must produce consent");
|
||||
};
|
||||
buffer::arm(&root, fresh_consent.tombstone_generation(), || {
|
||||
permission_still_enabled_in_home(Some(&config_path), &setup_path, Some(home.path()), &root)
|
||||
})
|
||||
.expect("fresh re-enable arms");
|
||||
assert!(!buffer::tombstone_present(&root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_wipe_blocks_identity_and_state_recreation() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
buffer::ensure_dir(&root).expect("create telemetry root");
|
||||
envelope::read_or_create_install_id(&root).expect("seed install id");
|
||||
envelope::write_state(&root, &envelope::TelemetryState::default()).expect("seed state");
|
||||
buffer::wipe(&root).expect("wipe telemetry home");
|
||||
|
||||
assert!(
|
||||
envelope::read_or_create_install_id(&root).is_err(),
|
||||
"an in-flight flush recreated the deleted install id"
|
||||
);
|
||||
assert!(
|
||||
envelope::write_state(&root, &envelope::TelemetryState::default()).is_err(),
|
||||
"an in-flight flush recreated state after opt-out"
|
||||
);
|
||||
assert!(!buffer::install_id_path(&root).exists());
|
||||
assert!(!buffer::state_path(&root).exists());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ unarmed gate --
|
||||
|
||||
#[test]
|
||||
@@ -1157,11 +1299,6 @@ fn record_blocking_is_a_noop_when_unarmed() {
|
||||
});
|
||||
crate::set_exit_class(ExitClass::Panic);
|
||||
assert_eq!(crate::exit_class(), ExitClass::Clean);
|
||||
assert_eq!(
|
||||
crate::flush_blocking(Duration::from_millis(10)),
|
||||
crate::FlushOutcome::Empty
|
||||
);
|
||||
assert!(!crate::startup_drain_due());
|
||||
assert!(
|
||||
!root.exists(),
|
||||
"an unarmed process must create no directory"
|
||||
@@ -1204,6 +1341,62 @@ fn a_tombstoned_home_sends_nothing_even_with_an_endpoint() {
|
||||
assert!(buffer::read_lines(&buffer::dryrun_path(&root)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wipe_and_delivery_share_one_ordering_boundary() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
let entered = std::sync::Arc::new(std::sync::Barrier::new(2));
|
||||
let release = std::sync::Arc::new(std::sync::Barrier::new(2));
|
||||
|
||||
let send_root = root.clone();
|
||||
let send_entered = entered.clone();
|
||||
let send_release = release.clone();
|
||||
let send = std::thread::spawn(move || {
|
||||
crate::client::send_with_transport(
|
||||
&send_root,
|
||||
Some("https://telemetry.codewhale.ai/v1/batch"),
|
||||
&every_field_batch(),
|
||||
move |_, _, _| {
|
||||
send_entered.wait();
|
||||
send_release.wait();
|
||||
crate::client::SendOutcome::Accepted
|
||||
},
|
||||
)
|
||||
});
|
||||
entered.wait();
|
||||
|
||||
// The real send path is paused inside its transport callback. A
|
||||
// non-blocking probe must observe the same lock that wipe takes; this
|
||||
// deterministically pins the entire delivery inside the boundary without
|
||||
// depending on loopback networking in a restricted test sandbox.
|
||||
assert!(
|
||||
buffer::try_with_lock(&root, || Ok(()))
|
||||
.expect("probe privacy lock")
|
||||
.is_none(),
|
||||
"network delivery did not hold the wipe lock"
|
||||
);
|
||||
|
||||
// Start the real blocking wipe while the POST is still in flight. It can
|
||||
// only complete after the response releases the sender's privacy guard.
|
||||
let wipe_root = root.clone();
|
||||
let wipe = std::thread::spawn(move || buffer::wipe(&wipe_root));
|
||||
release.wait();
|
||||
assert_eq!(
|
||||
send.join().expect("send thread"),
|
||||
crate::client::SendOutcome::Accepted
|
||||
);
|
||||
|
||||
wipe.join()
|
||||
.expect("wipe thread")
|
||||
.expect("wipe after delivery");
|
||||
assert!(buffer::tombstone_present(&root));
|
||||
assert_eq!(
|
||||
crate::client::send(&root, Some("http://127.0.0.1:1/t"), &every_field_batch()),
|
||||
crate::client::SendOutcome::Dropped,
|
||||
"a send crossed the completed wipe boundary"
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- buckets --
|
||||
|
||||
#[test]
|
||||
@@ -1298,12 +1491,11 @@ fn no_public_api_accepts_a_bare_bool() {
|
||||
let init: fn(crate::TelemetryConsent) = crate::init;
|
||||
let _ = init;
|
||||
|
||||
// The only source of one is `decide`, which needs both a resolved config
|
||||
// and a setup-state record — neither of which a caller can fake into "yes"
|
||||
// without the user having answered.
|
||||
// The only source of one is `decide`, which still applies every persistent
|
||||
// and run-scoped opt-out before constructing the capability.
|
||||
let home = temp_home();
|
||||
assert!(
|
||||
!decide_in_home(
|
||||
decide_in_home(
|
||||
Some(home.path()),
|
||||
&resolved(true, false, None),
|
||||
&SetupState::default(),
|
||||
@@ -1598,24 +1790,27 @@ fn an_install_or_upgrade_is_reported_once_per_version() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_notice_promises_exactly_what_the_schema_collects() {
|
||||
fn the_notice_summarizes_what_the_schema_collects_and_states_every_red_line() {
|
||||
use crate::notice;
|
||||
|
||||
let body = notice::NOTICE_BODY;
|
||||
|
||||
// Everything the envelope carries has to be described. `install_id` is
|
||||
// "a random ID stored on this machine"; the rest are named directly.
|
||||
// The modal names the useful product categories and links the exact
|
||||
// field-by-field schema. `install_id` is "a random ID stored on this
|
||||
// machine"; transport metadata remains in the linked document. The body
|
||||
// wraps at 72 columns, so multi-word claims are matched across the
|
||||
// reflowed whitespace.
|
||||
let flat: String = body.split_whitespace().collect();
|
||||
for claim in [
|
||||
"which version you run",
|
||||
"version",
|
||||
"OS and CPU family",
|
||||
"which features you used",
|
||||
"how long sessions ran",
|
||||
"how they ended",
|
||||
"session duration and outcome",
|
||||
"aggregate feature and error counters",
|
||||
"random ID stored on this machine",
|
||||
"every 90 days",
|
||||
] {
|
||||
assert!(
|
||||
body.contains(claim),
|
||||
flat.contains(&claim.split_whitespace().collect::<String>()),
|
||||
"the notice does not describe: {claim}"
|
||||
);
|
||||
}
|
||||
@@ -1623,50 +1818,26 @@ fn the_notice_promises_exactly_what_the_schema_collects() {
|
||||
// And every red line has to be stated as *not collected*, not as
|
||||
// anonymized or sampled — two promises this client does not make.
|
||||
for red_line in [
|
||||
"prompts",
|
||||
"conversations",
|
||||
"code",
|
||||
"file names",
|
||||
"paths",
|
||||
"prompts",
|
||||
"files",
|
||||
"repo or branch names",
|
||||
"model output",
|
||||
"model names",
|
||||
"model content",
|
||||
"credentials",
|
||||
"per-turn or per-tool timeline",
|
||||
] {
|
||||
assert!(
|
||||
body.contains(red_line),
|
||||
flat.contains(&red_line.split_whitespace().collect::<String>()),
|
||||
"the notice does not disclaim: {red_line}"
|
||||
);
|
||||
}
|
||||
assert!(body.contains("Not sampled, not hashed"));
|
||||
assert!(!body.to_ascii_lowercase().contains("anonymized"));
|
||||
|
||||
// The two documented ways out, both of which are real.
|
||||
// The modal names the persistent opt-out because that is the switch that
|
||||
// also fulfils its deletion promise. Run-only kill switches stay in the
|
||||
// linked schema document, which explains that they erase nothing.
|
||||
assert!(body.contains("codewhale config set telemetry false"));
|
||||
assert!(body.contains("CODEWHALE_TELEMETRY=0"));
|
||||
assert!(!body.contains("CODEWHALE_TELEMETRY=0"));
|
||||
assert!(body.contains("docs/TELEMETRY.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_affirmative_answer_is_an_answer() {
|
||||
use crate::notice::answer_is_yes;
|
||||
|
||||
assert!(answer_is_yes("y"));
|
||||
assert!(answer_is_yes("Y\n"));
|
||||
assert!(answer_is_yes(" yes \n"));
|
||||
// Enter, EOF, a typo, and a stray keystroke all decline. The default is
|
||||
// the safe direction and it is reachable without aiming.
|
||||
assert!(!answer_is_yes(""));
|
||||
assert!(!answer_is_yes("\n"));
|
||||
assert!(!answer_is_yes("n"));
|
||||
assert!(!answer_is_yes("ye"));
|
||||
assert!(!answer_is_yes("1"));
|
||||
assert!(!answer_is_yes("true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_notice_prompt_capitalises_the_declining_default() {
|
||||
// `[y/N]`, not `[Y/n]` and not `[y/n]`. The shape of the prompt is the
|
||||
// first thing a user reads about which way Enter goes.
|
||||
assert!(crate::notice::NOTICE_PROMPT.contains("[y/N]"));
|
||||
assert!(!crate::notice::NOTICE_PROMPT.contains("[Y/n]"));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
codewhale-protocol = { path = "../protocol", version = "0.9.4" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.9.6" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
@@ -133,6 +133,13 @@ pub struct ToolResult {
|
||||
pub metadata: Option<Value>,
|
||||
}
|
||||
|
||||
/// Provider-neutral non-text content returned alongside a tool result.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ToolResultContentBlock {
|
||||
Image { mime_type: String, data: String },
|
||||
}
|
||||
|
||||
impl ToolResult {
|
||||
/// Create a successful result with content.
|
||||
#[must_use]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Scope: the TUI, the runtime engine embedded in it, and everything a user sees.
|
||||
Read the repo-root `AGENTS.md` first. Current flakes and known debt are in
|
||||
`docs/ops/CURRENT.md`, not here.
|
||||
the `codewhale-ops` repo, not here.
|
||||
|
||||
## The shell grammar (do not regress it)
|
||||
|
||||
@@ -43,7 +43,7 @@ Adding a string is a four-part change: see `locales/AGENTS.md`.
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
cargo test -p codewhale-tui --bins --locked # unit suite (bin targets only)
|
||||
cargo test -p codewhale-tui --lib --locked # library unit suite
|
||||
cargo test -p codewhale-tui --tests --locked # every crates/tui/tests/ target
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
```
|
||||
@@ -52,16 +52,16 @@ Narrower reruns of the slow acceptance targets, once `--tests` has told you
|
||||
which one moved:
|
||||
|
||||
```sh
|
||||
cargo test -p codewhale-tui --test qa_pty --locked # PTY snapshots
|
||||
cargo test -p codewhale-tui --test release_runtime_qa --locked
|
||||
cargo test -p codewhale-tui --test terminal_matrix_qa --locked
|
||||
cargo test -p codewhale-tui --test pty qa_pty --locked
|
||||
cargo test -p codewhale-tui --test pty release_runtime_qa --locked
|
||||
cargo test -p codewhale-tui --test pty terminal_matrix_qa --locked
|
||||
```
|
||||
|
||||
**`--bins` and `--tests` are disjoint target sets.** `crates/tui/tests/` holds
|
||||
two dozen process-level acceptance targets that a `--bins` run never compiles,
|
||||
**`--lib` and `--tests` are disjoint target sets.** `crates/tui/tests/` holds
|
||||
two dozen process-level acceptance targets that a `--lib` run never compiles,
|
||||
let alone executes, so a green `cargo test -p codewhale-tui --bin codewhale-tui`
|
||||
says nothing about them. `adaptive_evidence_acceptance` sat red across two
|
||||
releases for exactly that reason: every routine command anyone ran was a `--bins`
|
||||
releases for exactly that reason: every routine command anyone ran was a unit-only
|
||||
run, and only `cargo test --workspace` reached it. Run both, or run the
|
||||
workspace gate.
|
||||
|
||||
|
||||
+416
-257
@@ -7,7 +7,292 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.9.4] - 2026-08-05
|
||||
## [0.9.6] - 2026-08-11
|
||||
|
||||
Codewhale v0.9.6 is a subtractive release: fewer runtime guards, one stable
|
||||
prompt, truthful provider endings, and a smaller compaction path that preserves
|
||||
the provider cache. The changes were grounded by matched Terminal-Bench 2.1
|
||||
runs against Pi 0.8.41 and by dogfooding repeated manual compaction.
|
||||
|
||||
### Added
|
||||
|
||||
- `web_search` defaults to Firecrawl Cloud without an API key; keyless requests
|
||||
are headerless and quota-bounded, while an optional user key raises limits.
|
||||
- Green web builds on `main` now emit an actionable manual-deploy reminder, so
|
||||
site changes cannot quietly appear shipped while Cloudflare still serves an
|
||||
older revision.
|
||||
- Mistral AI is a first-class provider route, including Codestral models,
|
||||
first-party reasoning support, authentication, picker entries, and aliases.
|
||||
- Headless `Bash` can transfer explicitly requested persistent Unix services
|
||||
out of an exec run, with ownership and cleanup receipts.
|
||||
- `/remote-env` opens hosted Work from the current GitHub or CNB branch tip and
|
||||
states exactly which unpushed, dirty, ignored, secret, and session state stays
|
||||
local.
|
||||
- Linux ARM64 release and nightly assets are static musl builds with native
|
||||
launch checks.
|
||||
- Maintainers can report observed daily active installs from the same anonymous,
|
||||
aggregate telemetry dataset; no additional client data is collected.
|
||||
- Fleet-dispatched members under a read-only evidence (no-network) ceiling now
|
||||
keep the `Web` tool's read-only `search` and `fetch` actions — parity with an
|
||||
ordinary scout — while every reaching surface (`web.run`, `fetch_url`,
|
||||
`github`, MCP) stays denied and the sentinel-backed capability envelope
|
||||
remains the fail-closed backstop.
|
||||
- `/fleet setup` can show an optional, deterministic, unratified role-to-model
|
||||
advisory built only from configured ready routes. Accept, edit, and reject
|
||||
all remain inside the existing human-reviewed profile save boundary; the
|
||||
advisory never launches a Fleet or writes a second configuration.
|
||||
- `/update` checks for a newer Codewhale release and installs it from inside
|
||||
the TUI, while `tui_help` gives agents the same command and key map users see.
|
||||
- Markdown file paths render as OSC 8 links where the terminal supports them,
|
||||
and every agent row can open that agent's transcript directly.
|
||||
- ACP editor sessions can execute multi-round file, search, Git, patch, and
|
||||
explicitly enabled shell tool calls through the shared Runtime registry.
|
||||
Shell access requires both the client's terminal capability and Codewhale's
|
||||
headless shell opt-in, and cancellation stops an in-flight tool before the
|
||||
turn returns (#5225 by @rafaelcavalheri).
|
||||
- Lowercase `read` returns bounded typed PNG, JPEG, GIF, and WebP results to
|
||||
image-capable Chat, Responses, Anthropic, and ACP routes. Text-only routes
|
||||
receive an explicit omission receipt; image bytes never spill into ordinary
|
||||
transcript, export, compaction, or relay text.
|
||||
|
||||
### Changed
|
||||
|
||||
- Anonymous usage counting is on by default for fresh installs and disclosed in
|
||||
a native first-run Codewhale modal with an immediate opt-out. Prior declines
|
||||
remain off. Codewhale does not collect conversations, code, prompts, files,
|
||||
repo or branch names, credentials, model content, or per-turn activity
|
||||
timelines.
|
||||
- Wide terminals use a responsive, full-screen ocean canvas with modest
|
||||
gutters: prose keeps a readable measure while tools, diffs, work surfaces,
|
||||
the composer, and status chrome can use the available width. Turn and major
|
||||
activity seams breathe without padding every call inside a tool group.
|
||||
- Root CLI help describes product actions directly instead of exposing internal
|
||||
TUI/runtime layers.
|
||||
- `Bash action="wait"` now blocks by default when a wait is requested; callers
|
||||
can still ask for a nonblocking snapshot, and persistent service ownership
|
||||
remains explicit.
|
||||
- Compaction is one cache-stable summary request followed by one committed
|
||||
replacement summary and a bounded recent-message tail. Older saved sessions
|
||||
still restore.
|
||||
- Ask, Work, Auto-Review, and Full Access share one stable base prompt. Modes
|
||||
continue to differ through permissions and the live tool catalog; the former
|
||||
Act label is now Work throughout the product and shipped locales.
|
||||
- Full Access now auto-approves non-bypassable tools consistently, and the
|
||||
default choice shown on ordinary approval cards is configurable.
|
||||
- Model, context-window, dispatch-name, and nested-agent spawn receipts report
|
||||
the route and limits actually used rather than silently substituting a
|
||||
guessed identity.
|
||||
- Child-agent launches mint one immutable route receipt before admission and
|
||||
preserve it through status, interruption, completion, resume, Work Graph,
|
||||
and ledger projections, so provider/model attribution cannot drift (#5305).
|
||||
- Goal runs no longer stop because of internal continuation, repeated-gap, or
|
||||
unanswered-question guards. Explicit user limits and terminal goal states
|
||||
remain authoritative.
|
||||
- Account-owned `/rc` remote control now keeps exclusive ownership and a
|
||||
crash-recoverable delivery journal until the server acknowledges terminal,
|
||||
approval, failure, and snapshot state.
|
||||
- `todo_write` is an optional progress surface rather than required model
|
||||
ceremony.
|
||||
- New turns use one small, stable toolbox: `read`, `write`, `edit`, `bash`,
|
||||
`agent`, `todo_write`, and `tool_search`. The optional progress tool stays
|
||||
visible as familiar working memory; specialized native, Web, MCP, plugin,
|
||||
memory, task, and verification tools are policy-filtered and searchable;
|
||||
activated schemas stay in a bounded per-conversation cache. Every sub-agent
|
||||
keeps its own search and cache, including policy-allowed Web research, while
|
||||
forked context and parent activations remain warm starts rather than allowlists.
|
||||
- The direct file and shell schemas follow Pi's deliberately small contract:
|
||||
bounded complete-line reads, hash-free writes, unambiguous multi-edit with
|
||||
BOM/CRLF preservation and conservative fuzzy matching, and one foreground
|
||||
`bash` command with a bounded chronological output tail. Modes change
|
||||
execution authority, not those primitive names.
|
||||
- Codewhale no longer re-states the To-do list to the model. The model learns
|
||||
what is on the list from the tool result its own `todo_write` call returned,
|
||||
which is ordinary conversation history — the same way Pi's To-do works. The
|
||||
transient `<codewhale:work_state>` block that used to ride the tail of every
|
||||
parent turn-loop and sub-agent step request is gone, along with the stable
|
||||
system prefix being disturbed by list changes. A snapshot is still shown once,
|
||||
where a person asked for it: the `<codewhale:fork_state>` block a newly forked
|
||||
sub-agent is handed, `/relay` handoff instructions, and the agent card. The
|
||||
complete To-do stays visible in the UI. A structural test asserts real
|
||||
outbound provider request bodies do not carry the list.
|
||||
- Scout and Reviewer name the read-only investigator roles. Both expose exactly
|
||||
one shell entry point — canonical lowercase `bash`, bounded by the strict
|
||||
read-only classifier — and the legacy `Bash` alias stays denied in the catalog
|
||||
and at dispatch. Previously a case-insensitive name match let a call spelled
|
||||
`Bash` execute through that carve-out, returning raw shell to a read-only role.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Sending more context while a lowercase `bash` command is running now moves
|
||||
the command to `/jobs` and returns a successful running receipt instead of
|
||||
falsely reporting `Command exited with code -1`; the process keeps running
|
||||
and its completion still arrives through the normal runtime event.
|
||||
|
||||
- First-run usage disclosure now opens as a native Codewhale modal instead of a
|
||||
shell questionnaire before application startup. Telemetry remains unarmed
|
||||
until the native choice is made, and an in-memory Disable choice governs the
|
||||
current session even when its preference cannot be saved.
|
||||
- `/compact` completion, failure, queued, duplicate, and mailbox outcomes are
|
||||
durable transcript receipts instead of short-lived toasts. A stray terminal
|
||||
event can no longer leave every later compaction stuck as already running.
|
||||
- Compaction now follows Codex's simple transcript shape: recent user context
|
||||
followed by one ordinary history checkpoint. It never appends the summary,
|
||||
the To-do list, or volatile shell/worker state to the standing system prompt;
|
||||
reloads migrate the persisted carrier back into exactly one history item.
|
||||
- Automatic compaction uses a percentage of the real context window, clamped to
|
||||
the route's spendable ceiling. Pressure comes from the current parent-route
|
||||
prompt, not cumulative billing or child-model usage.
|
||||
- Compaction, review, verify, routing, setup, Fleet, MCP, RLM, vision,
|
||||
translation, and sub-agent calls inherit the resolved route's normal output,
|
||||
sampling, and reasoning policy. Small internal-task token caps no longer
|
||||
truncate thinking routes or special-case individual providers.
|
||||
- Incomplete provider responses fail truthfully across ordinary turns and every
|
||||
internal model consumer. Partial text stays interrupted, pending tool calls do
|
||||
not execute, and billed usage is retained.
|
||||
- Transport-only `(reasoning omitted)` placeholders no longer enter new
|
||||
transcripts and are filtered from restored sessions. Reasoning expand/collapse
|
||||
actions stay attached to the exact rendered cell, including after replacement,
|
||||
restore, filtering, and resize (#5291).
|
||||
- Step-budget exhaustion is a typed failure and cannot release a pending
|
||||
persistent service. Cancellation after terminal usage still charges the turn.
|
||||
- Deferred tools now preserve a completed result when a provider reuses its
|
||||
tool-call ID on the retry turn, preventing successful plugin calls from
|
||||
entering a repeated execution loop.
|
||||
- Website setup, provider, diagnostics, Fleet, and single-runtime claims now
|
||||
match the source candidate.
|
||||
- Opening the sub-agent register no longer hides the to-do list: the Agents
|
||||
panel shows the full register and the durable checklist together, and the
|
||||
register header is a two-way door that returns to Tasks on a second click.
|
||||
- The ⌥V / Alt+V details chord opens the selected work-surface row's own
|
||||
inspector instead of the transcript's nearest tool cell, so a selected
|
||||
to-do row shows its own content rather than the latest reasoning.
|
||||
- The first-run usage disclosure now asks a clear question — "Help improve
|
||||
Codewhale?" — with unambiguous "Yes, keep anonymous counts" / "No, turn off
|
||||
tracking" choices in every shipped locale, and states the persistent opt-out
|
||||
command. Consent semantics are unchanged: telemetry stays unarmed until a
|
||||
choice is made.
|
||||
- macOS screencapture screenshots referenced in a message are copied to a
|
||||
stable attachments directory the moment the message is received, and the
|
||||
reference is rewritten to the stable path, so the image still exists when
|
||||
the agent reads it. Only files under a screencapture "Temporary Items"
|
||||
directory are touched; copies are idempotent and a failed copy keeps the
|
||||
original reference.
|
||||
- Manual `/compact` during an active turn now queues even when the engine's
|
||||
bounded op mailbox is saturated. The request defers client-side, retries as
|
||||
mailbox slots free, and cannot latch as already running after it settles.
|
||||
- Interactive `/load`, startup `--resume`, and `/resume` picker paths preserve
|
||||
the persisted provider, endpoint, and model identity; picker resume also
|
||||
leaves a durable transcript receipt.
|
||||
- Relative `mcp_config_path` values no longer depend on the launch directory or
|
||||
silently load an empty server pool: Codewhale warns and falls back to the
|
||||
user-global MCP configuration. Explicit absolute paths remain authoritative.
|
||||
- Alibaba Model Studio `qwen3.8-max` and `qwen3.8-max-preview` still stream
|
||||
their current reasoning, but no longer replay historical `reasoning_content`
|
||||
that those routes do not accept. Historical reasoning replay is now gated by
|
||||
the exact provider/API/model contract, so unknown `*-thinking` lookalikes
|
||||
fail closed while documented Qwen, Kimi, DeepSeek, Mistral, Anthropic, and
|
||||
Responses continuity rules remain intact.
|
||||
- Compatibility File/patch calls retain optional content-hash guards when a
|
||||
caller supplies them. The new direct `write` and `edit` schemas do not expose
|
||||
hash or prior-read ceremony.
|
||||
- Shell previews hold back incomplete UTF-8 sequences instead of emitting
|
||||
replacement characters, and compaction receipts report token deltas.
|
||||
- Nested agents may narrow but can never widen their inherited depth budget
|
||||
(#5317 by @ousamabenyounes).
|
||||
- Container publication now assembles AMD64 and ARM64 images in parallel on
|
||||
native runners from the already-verified static release binaries, then
|
||||
publishes and checks one multi-architecture manifest. It no longer rebuilds
|
||||
both targets through the single long-running QEMU job that lost its runner.
|
||||
|
||||
### Removed
|
||||
|
||||
- The no-progress guard, repeated-read guard, and injected tool-error strategy
|
||||
coaching. Productive polling, repeated inspection, and model-owned recovery
|
||||
are no longer interrupted by runtime heuristics.
|
||||
- Never-wired decision-card, keybinding, hover, shell-execution, engine-op, and
|
||||
release-script paths were deleted so the supported runtime has one route for
|
||||
each behavior.
|
||||
|
||||
### Contributors
|
||||
|
||||
- Xavier Pestel (@xavierpestel-ai) — Mistral AI provider route (#5295).
|
||||
- Ben Younes (@ousamabenyounes) — inherited nested-agent depth cap (#5317).
|
||||
- Rafael Cavalheri (@rafaelcavalheri) — ACP agentic tool turns (#5225).
|
||||
|
||||
## [0.9.5] - 2026-08-08
|
||||
|
||||
Codewhale v0.9.5 consolidates the terminal application into one compiled
|
||||
runtime while preserving the familiar `codewhale` and `codew` commands. It
|
||||
also expands the managed Runtime API, makes session and Fleet work easier to
|
||||
inspect and resume, and removes the hidden local continuation backstop that
|
||||
could end productive work without a final assistant response.
|
||||
|
||||
### Added
|
||||
|
||||
- **`model = "auto"` for prompt-based tier selection**: When set, the
|
||||
dispatcher analyses the user's prompt before delegating to the TUI and
|
||||
selects `deepseek-v4-pro` for complex tasks or `deepseek-v4-flash` for simple
|
||||
tasks (PR #5257).
|
||||
- Runtime API controls for persistent goals, bounded memory inspection, MCP
|
||||
server and skill lifecycle management, and durable Fleet receipt evidence.
|
||||
- Append-only session-tree history with `/tree`, `/branch`, `/fork`, and
|
||||
`/resume`, plus `/rc` remote control and managed login.
|
||||
- A unified Fleet roster for built-in dispatch postures and a pinned indicator
|
||||
that keeps active background work visible above the composer.
|
||||
- Incremental MCP registry refreshes that return the local snapshot immediately
|
||||
and update it in the background.
|
||||
- Scout and Reviewer agents can use a bounded direct-command evidence shell for
|
||||
read-only workspace, Git, and GitHub inspection, and can keep private working
|
||||
notes in their own To-do while the durable transcript retains their evidence.
|
||||
|
||||
### Changed
|
||||
|
||||
- `codewhale-cli` now contains the terminal runtime directly. Release installers
|
||||
expose byte-identical `codewhale` and `codew` commands without a separate TUI
|
||||
executable. The v0.9.5 asset set alone retains deprecated
|
||||
`codewhale-tui-*` filenames as byte-identical compatibility copies so
|
||||
installed v0.9.4 clients can discover and complete this upgrade.
|
||||
- Startup release checks cache successful lookups for one hour. The updater
|
||||
downloads and verifies the primary runtime once, then refreshes any existing
|
||||
`codew` or legacy `codewhale-tui` command paths from the same bytes.
|
||||
- Headless `codewhale exec` runs and verifier benchmark rollouts no longer
|
||||
impose a 100-step default. `--max-turns` remains available as an explicit
|
||||
opt-in ceiling; Fleet workers retain their separately configured budget.
|
||||
- Goal token and time budgets are telemetry rather than default stop
|
||||
conditions, and automatic goal continuation is unlimited unless the user
|
||||
explicitly configures a continuation ceiling.
|
||||
- Command-palette and slash-completion shadowing now share one alias-aware
|
||||
discovery contract.
|
||||
- The website install guidance, localized product copy, navigation controls,
|
||||
social metadata, and Cloudflare build pipeline now describe and deploy the
|
||||
same one-runtime release contract.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The hidden 20-step no-user-input backstop no longer ends productive turns.
|
||||
Tool results, queued steering, child completions, REPL feedback, and goal
|
||||
continuations can all reach the next provider step and a final assistant
|
||||
response; explicit user-configured limits and genuine stuck-loop guards remain.
|
||||
- Complete error details are directly inspectable after a failure instead of
|
||||
leaving the terminal with a clipped, unrecoverable error fragment.
|
||||
- A newly minted OAuth credential is adopted in the same provider-selection
|
||||
flow instead of requiring a second picker trip.
|
||||
- Fresh session titles can replace a stale cached `New Session` placeholder,
|
||||
unknown model context limits fail loudly, and release/source-install fallbacks
|
||||
no longer request binaries removed by the single-runtime conversion.
|
||||
|
||||
### Contributors
|
||||
|
||||
- [Sh1Zuku](https://github.com/SparkofSpike) (`@SparkofSpike`) fixed stale
|
||||
cached session titles that could pin the `New Session` placeholder.
|
||||
- [Paulo Aboim Pinto](https://github.com/aboimpinto) (`@aboimpinto`) built the
|
||||
shared alias-aware command discovery contract and acceptance coverage.
|
||||
- [Sun Zhenyuan](https://github.com/bistack) (`@bistack`) contributed the
|
||||
background incremental MCP Registry refresh.
|
||||
- [SKY ZHAO](https://github.com/skyzhao1223) (`@skyzhao1223`) contributed
|
||||
prompt-based `model = "auto"` routing in PR #5257.
|
||||
|
||||
## [0.9.4] - 2026-08-07
|
||||
Codewhale v0.9.4 ships the release-train harness work: the familiar Fleet
|
||||
roster/setup face with a clear operator-leader and user/folder scope, a
|
||||
work strip that keeps actionable agents instead of a permanent archive,
|
||||
@@ -19,6 +304,23 @@ File edits, terminal width, and Windows installation.
|
||||
|
||||
### Added
|
||||
|
||||
- Memory maintenance: `remember` gains `revise` and `retire` beside the
|
||||
default `append`. Both name the exact note they target and both require
|
||||
the evidence for the change. Append-only memory decays — a correction
|
||||
sits behind the note it contradicts and both keep reaching the model —
|
||||
so the model can now keep its own durable notes true instead of only
|
||||
adding to them.
|
||||
- An audit trail for durable state the model writes about you. Every
|
||||
in-place memory edit is journalled to `memory/JOURNAL.md`, and every
|
||||
continual-harness `refine` / `remove` to a `JOURNAL.md` beside its state,
|
||||
each with before, after, and evidence. Harness removal previously left no
|
||||
record at all even though the entry leaves state entirely, so the journal
|
||||
is now the only place its content survives.
|
||||
- A first-run tip that says so: the first time Codewhale saves something
|
||||
durable it points at `/memory`, translated into all fifteen complete
|
||||
locale packs. This state shaped later sessions and nothing ever mentioned
|
||||
it existed.
|
||||
|
||||
- Sub-agent checkpoint resume: `agents/followup` resumes an
|
||||
`interrupted_continuable` child from its checkpoint into a fresh agent loop —
|
||||
new agent id, original prompt plus the prior conversation tail — when a
|
||||
@@ -125,6 +427,39 @@ File edits, terminal width, and Windows installation.
|
||||
- Acceptance-level Gherkin coverage locking the existing user-command
|
||||
precedence, alias shadowing, fallback, and invalid-command error contract
|
||||
(PR #4992).
|
||||
- Agent Plugins v1.0.0: consume, publish, and slugify packaged sub-agent
|
||||
briefs, with an install/update/uninstall on-ramp in the TUI (PR #5182). A
|
||||
plugin bundles a prompt, posture, and routing as one shareable artifact;
|
||||
on-disk migration of the older `plugin.toml` scaffold is deliberately out
|
||||
of scope for this train.
|
||||
- `send_later`: a model-callable one-shot delayed continuation tool, so the
|
||||
model can schedule a single future nudge without an operator-approved
|
||||
durable automation (PR #5138).
|
||||
- `/advisor`: an opt-in background advisor watcher for live turns (PR #5139).
|
||||
- Notification quiet mode with per-category switches and action-first copy
|
||||
(PR #5066).
|
||||
- Automation scheduling forms — one-shot `ONCE`, five-field cron, and honest
|
||||
watcher modes — created through the approval-gated `automation` tool
|
||||
(PR #5183).
|
||||
- Sub-agent `resume_from` continuation chains (PR #5142), child-result
|
||||
diff-tainting when a claimed diff is not visible to git, per-turn usage
|
||||
receipts on the exec stream-json stream, and spawn receipts that report
|
||||
the model each sub-agent actually ran on.
|
||||
- Transport resilience: sub-agent exec transport retries with a 600 s
|
||||
default (PR #5210), SSE header stalls retryable instead of fatal, and
|
||||
headless turn resume after mid-stream network drops with an `EX_TEMPFAIL`
|
||||
exit.
|
||||
- Session durability and control: a deterministic compaction continuation
|
||||
contract (PR #5064), persisting interrupted output (PR #5206), stop-word
|
||||
cancellation (PR #5207), token-counter refresh (PR #5204), deny-by-default
|
||||
approval cards (PR #5090), and the Operate completion gate (PR #5067).
|
||||
- zh-Hant promoted to a full shipped locale with complete `en.json` parity
|
||||
(PR #5143).
|
||||
- A persistent update-available chip in the header, with the startup update
|
||||
check throttled and naming the right command.
|
||||
- RLM static intent extraction for code blocks (`rlm_block_intent.rs`)
|
||||
landed as groundwork for a future code-mode approval flow; it is not yet
|
||||
wired into the turn pipeline and ships dormant by design.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -171,12 +506,38 @@ File edits, terminal width, and Windows installation.
|
||||
futures-util to 0.3.33, libc to 0.2.189, actions/stale to 11.0.0, and
|
||||
docker/login-action to 4.5.2. The locked graph also includes the
|
||||
event-listener 5.4.2 fix for RUSTSEC-2026-0221.
|
||||
- The progress surface now speaks plainly everywhere: the last user-visible
|
||||
"Work update is pending" notices say "To-do list", the tool constructor and
|
||||
the docs name `todo_write` as the single canonical progress tool, and
|
||||
`work_update`, `TodoWrite`, and `todo` stay registered as hidden
|
||||
compatibility aliases so saved transcripts keep replaying.
|
||||
- Sub-agent and `agents/wait` waits stay short by default and by cap:
|
||||
blocking waits default to 30 s and refuse to block past 120 s, because a
|
||||
blocked wait deafens the session to typed input and settled children
|
||||
already report back as `<codewhale:subagent.done>` sentinels.
|
||||
- `Bash` `action=wait` honors `timeout_secs` (seconds) and bare `timeout`
|
||||
(milliseconds) alongside canonical `timeout_ms`, and `block` as an alias
|
||||
for `wait`, so a habit formed on other wait tools gets the duration it
|
||||
asked for instead of silently falling back to the 30 s default; the result
|
||||
metadata reports the real `wait_timeout_ms` applied.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The memory journal is no longer indexed as memory. It is Markdown in the
|
||||
memory tree, so the source walk collected it and every retired note
|
||||
re-entered the searchable set under its `before:` line — putting the
|
||||
exact facts a revision had just removed back into the prompt.
|
||||
- `memory_path` pointed at an already-native store no longer derives a
|
||||
second store nested inside it, which silently wrote somewhere other than
|
||||
the file the user named.
|
||||
- `muse` and `muse-spark` resolved to `muse-spark-1.1` in the agent
|
||||
registry while config had defaulted to `muse-spark-1.2`, so the CLI and
|
||||
app-server routed those aliases somewhere the configured default never
|
||||
pointed. The registry now carries 1.2 and the contributor variant.
|
||||
|
||||
- An explicit `type=builder` (or its `implementer` alias) plus
|
||||
`write_authority=read_only` now fails closed at spawn instead of launching a
|
||||
labeled write role that silently had only recon tools and then self-BLOCKED
|
||||
labeled write role that silently had only read-only tools and then self-BLOCKED
|
||||
after burning a turn (#5123). The check is deliberately narrow, because two
|
||||
neighbouring combinations are legitimate and stay legal:
|
||||
- `type=worker` + `read_only` — worker is the unnamed default (it renders as
|
||||
@@ -196,7 +557,7 @@ File edits, terminal width, and Windows installation.
|
||||
total the worker budget uses) instead of completion tokens alone; elapsed
|
||||
time still freezes when the child settles.
|
||||
- Live work-bar rows for sub-agents show how many to-dos they still have
|
||||
left (`N left`) when the child's own ledger has unsettled items — never a
|
||||
left (`N left`) when the child's own list has unsettled items — never a
|
||||
fabricated zero when no list exists.
|
||||
|
||||
- Surfaces no longer claim an OS sandbox on platforms that cannot enforce one.
|
||||
@@ -335,6 +696,56 @@ File edits, terminal width, and Windows installation.
|
||||
- Transcript wheel scrolling under iTerm2: xterm alternate-scroll (DECSET
|
||||
1007) now stays off while mouse capture is active, so wheel events arrive as
|
||||
mouse events instead of being converted into arrow keys (#5223, PR #5234).
|
||||
- A stalled model stream no longer ends the turn as `Completed` over a
|
||||
frozen reasoning block: a mid-stream chunk-timeout now counts toward the
|
||||
stream-error budget, so a stall with nothing streamed retries the request
|
||||
transparently, and a stall that exhausts the retry budget fails the turn
|
||||
with the real reason instead of reporting success.
|
||||
- A finished background shell task now wakes the engine even when no goal is
|
||||
active: the idle loop starts an ordinary runtime turn so the completion
|
||||
reaches the model immediately instead of sitting unclaimed until the user
|
||||
types (a dead provider route claims the completion once and reports where
|
||||
the output lives instead of re-arming the same error every tick).
|
||||
- Sub-agent final reports that exceed the summary budget are now spilled to
|
||||
a session artifact, and the truncation footer names the
|
||||
`retrieve_tool_result` ref for the elided middle instead of telling the
|
||||
model the bytes are unrecoverable; write failures degrade to the honest
|
||||
no-ref footer.
|
||||
- An interactive mid-stream network drop after partial output no longer fails
|
||||
the turn: the partial reply is preserved as a committed assistant message,
|
||||
a runtime continuation message is appended, and the request is re-issued
|
||||
bounded by the stream-retry budget.
|
||||
- Large pasted input is no longer sent to the model twice as inline text and
|
||||
as a backup `.md` paste file; the submitted message now carries only the
|
||||
`@`-mention so the model reads the file once.
|
||||
- A builder sub-agent can run ordinary shell writes again. Write claims
|
||||
outlive the agents that register them, so a workspace accumulated one per
|
||||
builder that ever ran — six completed agents left four standing claims in
|
||||
testing — and the shared-checkout gate counted those long-finished children
|
||||
as live contenders. Every later builder was refused `Bash` writes with
|
||||
"cannot prove a bounded file target" and pushed toward worktree isolation,
|
||||
which puts the work in a checkout the operator never looks at. The gate now
|
||||
asks the question it meant to ask: is another *running* child writing in this
|
||||
shared checkout. Concurrent writers are still gated; a lone builder writes in
|
||||
the workspace you are actually watching.
|
||||
- Ctrl-C during the first moments of startup no longer kills Codewhale
|
||||
outright. The terminating-signal handlers were registered inside the task
|
||||
that waits on them, and a spawned task does not run until the scheduler
|
||||
first polls it, so a SIGINT arriving in that window hit the default
|
||||
disposition — the process died with no exit code, no terminal restore, and
|
||||
no session record. The handlers are now installed synchronously, before
|
||||
the telemetry notice and before arming, so the window is closed.
|
||||
- The documented tool list on the docs site named `update_plan` and
|
||||
`work_update` as coordination tools. Neither is callable by the model —
|
||||
`update_plan` replays older Plan artifacts and `work_update` is a hidden
|
||||
compatibility alias — so the page listed two tools a reader cannot use and
|
||||
omitted `todo_write`, the one they can.
|
||||
|
||||
### Security
|
||||
|
||||
- Bumped `nanoid` past GHSA-2v37-7h3g-55p8 (a custom generator given size
|
||||
zero could loop indefinitely), restoring a zero-advisory `npm audit` for
|
||||
the website.
|
||||
|
||||
### Removed
|
||||
|
||||
@@ -366,6 +777,8 @@ File edits, terminal width, and Windows installation.
|
||||
- [vFONGv](https://github.com/vFONGv) (`@vFONGv`) wrote the zh-CN Windows
|
||||
beginner guide with screenshots in PR #5229, harvested after its base branch
|
||||
was accidentally deleted during maintainer cleanup.
|
||||
- [mky](https://github.com/mky) (`@mky`) fixed the FreeBSD build (PR #5254, `rquickjs` `bindgen` on FreeBSD).
|
||||
- [cacdcaecawae](https://github.com/cacdcaecawae) (`@cacdcaecawae`) contributed embedder-owned sub-agent state roots (PR #5252).
|
||||
|
||||
## [0.9.3] - 2026-07-31
|
||||
|
||||
@@ -3076,260 +3489,6 @@ folds in several community contributions.
|
||||
- Config robustness: atomic permission-rule save, one-time config `.bak` backup before the first changed write, `CODEWHALE_HOME` as primary config home, and accepting the dispatcher-written config shape (camelCase aliases + `[features.enabled]` table) so legacy/dual-written configs parse cleanly
|
||||
- Dependency/CI bumps: docker login/qemu actions, softprops gh-release, download-artifact, vitest, @opennextjs/cloudflare, form-data, js-yaml, dompurify, ws
|
||||
|
||||
## [0.8.60] - 2026-06-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Agent Fleet real-run cutover (#3154/#3096).** `codewhale fleet run` now
|
||||
launches durable workers through the headless `codewhale exec --output-format
|
||||
stream-json` path instead of the local simulation interpreter, with terminal
|
||||
worker events freeing leases so queued fleet tasks continue running.
|
||||
- **Read-only shell parallelism (#2983).** The engine can now run conservative
|
||||
read-only shell calls in parallel, including strict `bash`/`sh`/`zsh -c`
|
||||
wrappers for whitelisted commands, while writes, stdin, background TTY work,
|
||||
redirects, pipes, command substitution, and follow-mode tails stay serial.
|
||||
- **Declarative JS/TS WhaleFlow authoring (#3097).** WhaleFlow now accepts a
|
||||
compile-only `workflow({...})` JavaScript/TypeScript authoring form that
|
||||
lowers into the existing `WorkflowSpec` validator without executing user
|
||||
JavaScript.
|
||||
- **Slash-menu Ctrl+P/Ctrl+N navigation (#3196).** The slash command menu now
|
||||
supports Ctrl+P/Ctrl+N movement without letting the global file picker steal
|
||||
focus while the menu is open. Thanks @1Git2Clone for the PR.
|
||||
- **New models and first-party provider routes.** This release adds
|
||||
**GLM-5.2** (selectable on the Z.ai Coding Plan and over OpenRouter as
|
||||
`z-ai/glm-5.2`, alongside the existing GLM-5.1 default), a first-party
|
||||
**Z.ai** provider route, a first-party **StepFun / StepFlash** route
|
||||
(`step-3.7-flash`), and a first-party **MiniMax** route defaulting to
|
||||
`MiniMax-M3` with the M2.7/M2.5/M2.1 family selectable (#3187/#3191).
|
||||
|
||||
### Changed
|
||||
|
||||
- **README and contributor credits.** The README now has a shorter public
|
||||
overview and moves the full contributor ledger to `docs/CONTRIBUTORS.md`,
|
||||
preserving public thanks for [DeepSeek](https://github.com/deepseek-ai),
|
||||
[DataWhale](https://github.com/datawhalechina),
|
||||
[OpenWarp](https://github.com/zerx-lab/warp), and
|
||||
[Open Design](https://github.com/nexu-io/open-design).
|
||||
- **Fleet-backed sub-agent direction.** Runtime docs now state the intended
|
||||
cutover clearly: "sub-agent" is role/UX vocabulary, while durable detached
|
||||
work should converge on the fleet-backed worker lifecycle with retries,
|
||||
receipts, and ledgered inspection.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sub-agent eval no longer blocks by default.** `agent_eval` now returns the
|
||||
current projection immediately and delivers follow-up input without waiting
|
||||
for a running child to finish its provider call. Pass `block:true` for an
|
||||
intentional terminal wait.
|
||||
- **Z.ai GLM thinking traces.** Direct Z.ai requests now use the documented
|
||||
`thinking` shape, preserve and replay `reasoning_content`, classify GLM
|
||||
reasoning streams as thinking output, and accept `ultracode` as a max-effort
|
||||
alias.
|
||||
- **Claude skill archive compatibility (#2743).** `/skill install` keeps
|
||||
portable Claude-style skill folders supported while rejecting multi-skill
|
||||
Claude plugin archives clearly instead of silently installing only one skill
|
||||
and dropping plugin semantics. Thanks @AiurArtanis for the ecosystem request.
|
||||
|
||||
## [0.8.59] - 2026-06-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Moonshot Kimi K2.7 Code model.** The Moonshot/Kimi provider now defaults to
|
||||
`kimi-k2.7-code`, recognizes `kimi`/`kimi-k2` aliases for that model, keeps
|
||||
explicit `kimi-k2.6` selectable, and adds the OpenRouter
|
||||
`moonshotai/kimi-k2.7-code` registry row.
|
||||
- **Concise verbosity mode (#3052).** CLI noninteractive launches now default
|
||||
to concise prompt/output discipline unless overridden by config, env, or
|
||||
`--verbosity`, while interactive TUI launches remain normal by default.
|
||||
Thanks @cyq1017 for the PR.
|
||||
- **Ephemeral generated project context (#3058).** Opening CodeWhale in a
|
||||
directory with no instruction files now keeps the bounded generated project
|
||||
overview in memory instead of creating `.codewhale/instructions.md`.
|
||||
- **ACP registry auth metadata (#1447).** The ACP stdio adapter now advertises
|
||||
terminal authentication setup in `initialize.authMethods`, matching the
|
||||
registry's validation requirement.
|
||||
- **Sidebar context menus (#3065).** Right-clicking the sidebar no longer shows
|
||||
`Paste`; clickable sidebar rows now offer their row command as the first
|
||||
context action.
|
||||
- **Sidebar hover popovers (#3088).** Streaming turns now keep sidebar hover
|
||||
popovers responsive while continuing to throttle transcript/body mouse
|
||||
motion.
|
||||
- **Dark-theme selection contrast (#3074, thanks @drpars).** Session, config,
|
||||
help, context-menu, and approval selections now use the muted selection
|
||||
background instead of the bright accent color.
|
||||
- **Cursor-style activity metadata rows (#3146).** Dense successful tool-run
|
||||
summaries now render as a single muted `Explored ...` / `Updated metadata`
|
||||
row, include short command-family labels for successful generic verifier
|
||||
groups, and keep keyboard/mouse expansion and detail inspection intact.
|
||||
- **Provider-wait observability (#3095).** Footer stall reasons now name the
|
||||
active provider/model route, idle seconds vs stream budget, and whether a
|
||||
fanout plan is still at `0 running` or dispatch is pending. Structured
|
||||
provider-wait incidents log once per turn from the main tick loop (not on
|
||||
every footer redraw).
|
||||
- **Interactive fanout launch gate (#3095).** Direct sub-agent children queue
|
||||
behind a configurable semaphore (`[subagents] interactive_max_launch`,
|
||||
default 4) with a visible `queued: waiting for an interactive fanout slot`
|
||||
reason before their first model step.
|
||||
- **Goal lifecycle controls.** `/goal` is now the primary command surface for
|
||||
session goals, with `pause`, `resume`, `complete`, `blocked`, and `clear`
|
||||
controls while `/hunt` remains a compatibility alias.
|
||||
- **Persistent thread-goal API.** App-server clients can now set, get, and clear
|
||||
durable thread goals through `thread/goal/set`, `thread/goal/get`, and
|
||||
`thread/goal/clear`, backed by the state store with Codex-style status and
|
||||
token/time accounting fields.
|
||||
- **Command-boundary ownership layers (#2888/#3055).** Built-in slash command
|
||||
metadata now lives in `commands/registry.rs`, slash parsing in
|
||||
`commands/parse.rs`, and handlers under group-owned command areas, preserving
|
||||
the existing dispatch surface while reducing future `commands/mod.rs` churn.
|
||||
- **Approval-rule source metadata (#1186/#2971).** Runtime API
|
||||
`approval.required` events now include optional `matched_rule` metadata when
|
||||
an execution-policy rule caused the prompt. Thanks @greyfreedom for the PR
|
||||
and @Ram9199 for the audit-semantics discussion.
|
||||
- **Localized tool-family labels (#2901).** Tool activity labels for read,
|
||||
patch, run, find, delegate, fanout, RLM, verify, think, and generic tool
|
||||
work now route through the shipped locale tables. Thanks @gordonlu for the
|
||||
PR.
|
||||
- **Localized config section labels (#2918).** The interactive config view now
|
||||
localizes section and session/saved scope labels while preserving English
|
||||
search terms. Thanks @gordonlu for the PR.
|
||||
- **Localized config editor labels (#2919).** The config editor modal now
|
||||
localizes edit labels, default/unavailable placeholders, and effective
|
||||
currency hints. Thanks @gordonlu for the PR.
|
||||
- **Hotbar number-key dispatch (#3056).** Bare `1`-`8` now trigger bound
|
||||
hotbar slots only when the composer is empty, while `Alt+1`-`Alt+8` trigger
|
||||
slots regardless of composer text and overlays keep key ownership. Thanks
|
||||
@reidliu41 for the PR.
|
||||
- **Voice dictation commands (#3051).** `/voice`, `/voice-send`, and
|
||||
`/voice-control` now record through `sox`/`rec`/`arecord`, transcribe via the
|
||||
active provider's chat-completions API, and insert transcripts at the
|
||||
composer cursor. The `voice.toggle` hotbar action dispatches the real voice
|
||||
command, with help and status text localized across all seven shipped
|
||||
locales. Thanks @huqiantao for the PR.
|
||||
- **Thread rewind and snapshot restore API (#2808).** GUI clients can now call
|
||||
`POST /v1/threads/{id}/undo`, `/patch-undo`, and `/retry` to fork, roll back,
|
||||
or rerun recent thread turns, plus `POST /v1/snapshots/{id}/restore` to
|
||||
restore a workspace snapshot by id. Thanks @bengao168 for the PR.
|
||||
- **Active provider fallback chain (#2773).** Configured `fallback_providers`
|
||||
now build an ordered primary-plus-fallback route that the TUI can report,
|
||||
advance through, and reset with `/provider fallback reset`, including footer
|
||||
visibility for fallback state. Thanks @idling11 for the PR.
|
||||
- **Provider metadata registry (#3005).** Built-in provider ids, display names,
|
||||
defaults, env vars, config keys, aliases, and wire formats now live in a
|
||||
shared metadata registry, with the provider drift check covering the registry
|
||||
contract. Thanks @sximelon for the PR.
|
||||
- **Hugging Face provider route (#2879).** Hugging Face Inference Providers now
|
||||
have first-class config, env, docs, and registry coverage for the
|
||||
OpenAI-compatible router, including `huggingface`/`hugging-face`/
|
||||
`hugging_face`/`hf` aliases and `HUGGINGFACE_*`/`HF_*` env fallbacks. Thanks
|
||||
@mvanhorn for the PR.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **SSE data lines without spaces (#3152).** Chat Completions, Responses, and
|
||||
Anthropic stream readers now accept both `data: {...}` and `data:{...}` SSE
|
||||
frames, matching the spec and preventing providers that omit the optional
|
||||
space from streaming empty output. Thanks @wgeeker for the PR.
|
||||
- **Runtime thread detail N+1 reads (#3141).** `get_thread_detail` now scans
|
||||
persisted turn items once and groups them by turn instead of reading the
|
||||
items directory once per turn, preserving item order while keeping large
|
||||
thread detail loads responsive.
|
||||
- **Project-local hook trust boundary (#3140).** `.codewhale/hooks.toml` is now
|
||||
loaded only after the workspace is trusted in user-owned config, matching the
|
||||
project-local MCP trust model while preserving the documented shell-command
|
||||
hook contract.
|
||||
- **Skill registry sync latency (#3139).** `/skills sync` now syncs registry
|
||||
entries with bounded ordered concurrency, so network latency no longer stacks
|
||||
one skill at a time while output order stays deterministic.
|
||||
- **SiliconFlow China provider config (#2893/#2895).** `siliconflow-CN`
|
||||
now reads its own `[providers.siliconflow_cn]` / `[providers.siliconflow-CN]`
|
||||
table and falls back to `[providers.siliconflow]` only for unset
|
||||
`api_key`/`base_url`/`model` fields. Thanks @Artenx for the report and
|
||||
@idling11 for the PR.
|
||||
- **Self-update download timeout (#3006).** `codewhale update` now applies a
|
||||
five-minute HTTP client timeout so blocked or very slow GitHub release
|
||||
downloads fail instead of hanging indefinitely. Thanks @New2Niu for the PR.
|
||||
- **Legacy `deepseek` update migration (#2960/#3013/#3053).** Running
|
||||
`deepseek update` or `deepseek-tui update` from a pre-rebrand install now
|
||||
returns copy-pasteable npm, Cargo, Homebrew, and manual-binary migration
|
||||
steps instead of trying to spawn a missing `codewhale` binary. README and
|
||||
rebrand docs now cover the same upgrade path. Thanks @jazzi and
|
||||
@tiangangQiu for the reports, @cyq1017 for the update-path PR, and
|
||||
@angus-guo for the README PR.
|
||||
- **Short `codew` shim delegation.** The `codew` convenience binary now
|
||||
prefers the sibling `codewhale` dispatcher installed next to it before
|
||||
falling back to `PATH`, preventing fresh local builds or installs from
|
||||
accidentally invoking an older global dispatcher.
|
||||
- **Constitution trust wording (#2950/#3008).** The base prompt now explains
|
||||
that "begins with an A" means a baseline of trust, not a literal output
|
||||
formatting rule. Thanks @cyq1017 for the PR.
|
||||
- **TUI provider-source recovery (#3007/#3011).** Unsupported interactive
|
||||
providers now report whether the value came from `--provider`, environment,
|
||||
or config. Config-sourced unsupported providers fall back to DeepSeek without
|
||||
forwarding stale keyring secrets. Thanks @cyq1017 for the PR.
|
||||
- **Exec auto-model handoff (#3148).** `codewhale exec --model auto` now
|
||||
survives the CLI/TUI boundary by honoring the CodeWhale model env alias and
|
||||
legacy DeepSeek model handoff before falling back to provider defaults.
|
||||
Thanks @hongchen1993 for the PR.
|
||||
- **macOS shortcut modifiers (#2938/#2943).** Ctrl-like shortcuts that are
|
||||
reported as `SUPER` by macOS terminals now work for backgrounding tasks and
|
||||
sidebar-focus chords without rewriting clipboard shortcuts. Thanks @idling11
|
||||
for the PR.
|
||||
- **TUI mouse-report leak (#3063/#3067).** Strip raw SGR mouse coordinate
|
||||
tails from the composer even when `use_mouse_capture` is false, covering
|
||||
orphaned terminal reporting state after crashes or focus races.
|
||||
- **Interrupted sub-agent lifecycle (#3080).** API-timeout interruptions now
|
||||
emit `MailboxMessage::Interrupted`, render terminal interrupted cards, and
|
||||
reconcile stale running fanout counts from manager snapshots.
|
||||
- **OpenAI Codex stream diagnostics and active tool collapse (#3146).** The
|
||||
Responses bridge now reports nested `response.failed` /
|
||||
`response.incomplete` errors instead of `unknown`, and dense successful
|
||||
in-flight tool bursts collapse into the same calm activity metadata row as
|
||||
committed history.
|
||||
- **OpenAI Codex reasoning tiers.** Switching from DeepSeek to `openai-codex`
|
||||
now normalizes stale reasoning state into Responses-compatible
|
||||
`low`/`medium`/`high`/`xhigh` tiers. Startup, `/config`, and the model
|
||||
picker now display Codex labels instead of leaking DeepSeek
|
||||
`off`/`max` names, while Codex still reports as a Responses payload
|
||||
provider. The Responses request builder also clamps legacy `minimal` input
|
||||
to `low` and has regression coverage that Codex requests use
|
||||
`reasoning.effort`, not DeepSeek `thinking` fields.
|
||||
- **OpenAI Codex context metadata (#3070).** The `gpt-5.5` default and
|
||||
CodeWhale aliases now use OpenAI's documented 1,050,000-token context window
|
||||
and 128,000 max-output metadata for context pressure, prompts, and doctor
|
||||
capability output.
|
||||
- **OpenAI Codex effective context budgeting.** The public OpenAI API metadata
|
||||
for `gpt-5.5` remains 1,050,000 tokens, but the `openai-codex` OAuth route now
|
||||
budgets prompts against the 400K Codex-family effective window so preflight
|
||||
compaction runs before the backend returns `context_length_exceeded`.
|
||||
- **OpenRouter Nemotron 3 Ultra preset.** The OpenRouter preset and model
|
||||
registry now emit `nvidia/nemotron-3-ultra-550b-a55b` while keeping the old
|
||||
Ultra aliases compatible.
|
||||
- **OpenRouter auth after MiMo switches (#3064).** Switching from Xiaomi MiMo
|
||||
to OpenRouter now has regression coverage for preflight key failures and
|
||||
Bearer auth header isolation before any request can be dispatched.
|
||||
- **Responses strict-tool schema compatibility (#3062/#3017/#1883).** Responses
|
||||
function tools now preserve per-tool strict-mode compatibility, keep optional
|
||||
strict-schema fields nullable, and append deterministic constraint notes when
|
||||
root composition groups must be flattened for Responses.
|
||||
- **Runtime prompt autonomous loop guard (#3061).** Runtime policy reference
|
||||
now explicitly forbids initiating new work when `<runtime_prompt>` is the
|
||||
only new turn content and no tool/sub-agent handoff is pending.
|
||||
- **Goal runtime status sync.** Goal token budgets and active/paused/complete
|
||||
status now sync into the engine alongside the objective, and model-visible
|
||||
`update_goal` can only mark goals complete or blocked.
|
||||
|
||||
### Contributors
|
||||
|
||||
- Devin session work on #3080/#3095 (PRs #3103, #3104, #3106) — Hunter Bown
|
||||
(maintainer integration/cherry-pick on `codex/v0.8.59-release-ready`).
|
||||
- Nightt (@nightt5879) for the Responses strict-tool schema hardening in PR
|
||||
#3062.
|
||||
- yekern (@yekern) for the #3061 runtime-prompt loop safety report and repro
|
||||
that shaped the dispatch guard.
|
||||
- Paulo Aboim Pinto (@aboimpinto) for the staged command-boundary design and
|
||||
Layer 3 registry/parser extraction in PR #2888, plus the #2851/#2791/#2870
|
||||
architecture stream that guided the grouped command areas in #3055.
|
||||
|
||||
---
|
||||
|
||||
Older releases: [CHANGELOG.md](https://github.com/Hmbown/CodeWhale/blob/main/CHANGELOG.md) and [docs/CHANGELOG_ARCHIVE.md](https://github.com/Hmbown/CodeWhale/blob/main/docs/CHANGELOG_ARCHIVE.md).
|
||||
|
||||
+22
-18
@@ -16,6 +16,10 @@ json = ["schemaui/json"]
|
||||
toml = ["schemaui/toml"]
|
||||
long-running-tests = []
|
||||
|
||||
[lib]
|
||||
name = "codewhale_tui"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "codewhale-tui"
|
||||
path = "src/main.rs"
|
||||
@@ -23,21 +27,22 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
ahash = "0.8"
|
||||
anyhow.workspace = true
|
||||
codewhale-config = { path = "../config", version = "0.9.4" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.9.4" }
|
||||
codewhale-lane = { path = "../lane", version = "0.9.4" }
|
||||
codewhale-paths = { path = "../paths", version = "0.9.4" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.9.4" }
|
||||
codewhale-release = { path = "../release", version = "0.9.4" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.9.4" }
|
||||
codewhale-telemetry = { path = "../telemetry", version = "0.9.4" }
|
||||
codewhale-tools = { path = "../tools", version = "0.9.4" }
|
||||
codewhale-workflow = { path = "../workflow", version = "0.9.4" }
|
||||
codewhale-workflow-js = { path = "../workflow-js", version = "0.9.4" }
|
||||
codewhale-config = { path = "../config", version = "0.9.6" }
|
||||
codewhale-core = { path = "../core", version = "0.9.6" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.9.6" }
|
||||
codewhale-lane = { path = "../lane", version = "0.9.6" }
|
||||
codewhale-paths = { path = "../paths", version = "0.9.6" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.9.6" }
|
||||
codewhale-release = { path = "../release", version = "0.9.6" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.9.6" }
|
||||
codewhale-telemetry = { path = "../telemetry", version = "0.9.6" }
|
||||
codewhale-tools = { path = "../tools", version = "0.9.6" }
|
||||
codewhale-workflow = { path = "../workflow", version = "0.9.6" }
|
||||
codewhale-workflow-js = { path = "../workflow-js", version = "0.9.6" }
|
||||
schemaui = { version = "0.12.0", default-features = false, optional = true }
|
||||
async-stream = "0.3.6"
|
||||
async-trait.workspace = true
|
||||
base64 = "0.23.0"
|
||||
base64 = "0.22.1"
|
||||
axum.workspace = true
|
||||
clap.workspace = true
|
||||
clap_complete.workspace = true
|
||||
@@ -55,7 +60,7 @@ oauth2 = "5"
|
||||
ratatui = { version = "=0.30.0", features = ["unstable-rendered-line-info"] }
|
||||
ratatui-core = "=0.1.0"
|
||||
regex = "1.11"
|
||||
reqwest = { workspace = true, features = ["blocking", "stream", "form", "http2", "gzip"] }
|
||||
reqwest = { workspace = true, features = ["blocking", "stream", "form", "http2"] }
|
||||
rusqlite.workspace = true
|
||||
rmcp = { version = "2.2.0", default-features = false, features = ["auth", "client"] }
|
||||
rustls.workspace = true
|
||||
@@ -84,14 +89,12 @@ tower-http.workspace = true
|
||||
wait-timeout = "0.2"
|
||||
webbrowser = "1.0"
|
||||
shlex = "1.3.0"
|
||||
tiny_http = "0.12"
|
||||
globset = "0.4"
|
||||
ignore = "0.4"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
htmd = "0.5.4"
|
||||
lru = "0.18"
|
||||
lru = "0.16"
|
||||
parking_lot = "0.12"
|
||||
readability = { version = "0.3.0", default-features = false }
|
||||
tar = "0.4"
|
||||
flate2 = "1.1"
|
||||
sha2.workspace = true
|
||||
@@ -101,11 +104,12 @@ shell-words = "1.1.1"
|
||||
mimalloc.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
codewhale-build-support = { path = "../build-support", version = "0.9.4" }
|
||||
codewhale-build-support = { path = "../build-support", version = "0.9.6" }
|
||||
|
||||
[dev-dependencies]
|
||||
cucumber = "0.23.0"
|
||||
wiremock = "0.6"
|
||||
tiny_http = "0.12"
|
||||
pretty_assertions = "1.4"
|
||||
rio-vt = "0.5.1"
|
||||
|
||||
@@ -113,7 +117,7 @@ rio-vt = "0.5.1"
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Threading"] }
|
||||
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Pipes", "Win32_System_Threading"] }
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows", all(target_os = "linux", not(target_env = "ohos"))))'.dependencies]
|
||||
arboard = "3.4"
|
||||
|
||||
@@ -9,6 +9,19 @@
|
||||
"HistoryHintAccept": "Enter acceptar",
|
||||
"HistoryHintRestore": "Esc restaurar",
|
||||
"HistoryNoMatches": " Cap coincidència",
|
||||
"TranscriptReasoningExpand": "amplia",
|
||||
"TelemetryNoticeHeadline": "Vols ajudar a millorar Codewhale?",
|
||||
"TelemetryNoticeBody": "Codewhale compta: la versió que executes, el sistema operatiu i la família\nde CPU, la durada i el resultat de la sessió, i recomptes agregats de funcions\ni errors.\n\nNo recopila mai les teves converses, codi, prompts, fitxers, noms de\nfitxers, repositoris o branques, contingut del model ni credencials — i mai no\nenvia una cronologia de l'activitat de l'agent per torn o eina.\n\nNomés t'identifica un identificador aleatori desat en aquesta màquina, que se\nsubstitueix cada 90 dies. Pots canviar d'opinió en qualsevol moment:\n codewhale config set telemetry false\n\nEsquema complet, camp per camp: docs/TELEMETRY.md",
|
||||
"TelemetryNoticeCompactBody": "Compta: versió, SO/CPU, temps/resultat, funcions/errors.\nID local rota/90d.\nMai: xats/codi/prompts/fitxers/noms; contingut del model/credencials; torns/eines.\nVegeu: docs/TELEMETRY.md\nDesact.: codewhale config set telemetry false",
|
||||
"TelemetryNoticeChoiceKeep": "Sí, mantenir el recompte anònim",
|
||||
"TelemetryNoticeChoiceDisable": "No, desactivar el seguiment",
|
||||
"TelemetryNoticeActionChoose": "triar",
|
||||
"TelemetryNoticeActionConfirm": "confirmar",
|
||||
"TelemetryNoticeActionExit": "sortir",
|
||||
"TelemetryNoticeReceiptEnabled": "Sí, el recompte anònim continua actiu.",
|
||||
"TelemetryNoticeReceiptDisabled": "El recompte anònim d'ús està desactivat. No t'ho tornarem a preguntar.",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "El recompte anònim continua actiu en aquesta sessió. Codewhale no ha pogut desar l'elecció i tornarà a preguntar al pròxim inici.",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "El recompte anònim d'ús està desactivat en aquesta sessió. Codewhale no ha pogut desar l'elecció i tornarà a preguntar al pròxim inici.",
|
||||
"StatusPickerTitle": " Línia d'estat ",
|
||||
"StatusPickerInstruction": "Tria els elements que vols al peu:",
|
||||
"StatusPickerActionToggle": "commutar ",
|
||||
@@ -42,7 +55,7 @@
|
||||
"HotbarActionSessionCompactDescription": "Compacta el context de la conversació actual.",
|
||||
"HotbarActionModePlanName": "Mode Plan",
|
||||
"HotbarActionModePlanDescription": "Pensa un pla abans d'actuar.",
|
||||
"HotbarActionModeAgentName": "Mode Act",
|
||||
"HotbarActionModeAgentName": "Mode Work",
|
||||
"HotbarActionModeAgentDescription": "Treballa directament a la sessió actual.",
|
||||
"HotbarActionModeYoloName": "Full Access (Act)",
|
||||
"HotbarActionModeYoloDescription": "Compatibilitat: Act amb permisos de Full Access (no és un mode separat).",
|
||||
@@ -259,6 +272,7 @@
|
||||
"CmdModelsDescription": "Obtén els IDs de model en viu de l'API activa",
|
||||
"CmdModelDbDescription": "Explora la base de dades de models inclosa",
|
||||
"CmdNetworkDescription": "Gestiona les regles de xarxa de permís i denegació",
|
||||
"CmdUpdateDescription": "Comprova i instal·la una nova versió de CodeWhale",
|
||||
"CmdNoteDescription": "Afegeix, llista, edita o elimina notes de l'espai de treball",
|
||||
"CmdThemeDescription": "Canvia de tema o obre el selector de temes",
|
||||
"CmdProviderDescription": "Canvia el proveïdor i/o model actiu",
|
||||
@@ -281,6 +295,12 @@
|
||||
"CmdQueueIndexMin": "L'índex ha de ser >= 1",
|
||||
"CmdRelayDescription": "Crea un relay de sessió (接力) per a un fil nou",
|
||||
"CmdRemoteControlDescription": "Reprèn aquesta sessió exacta des del teu compte web de Codewhale",
|
||||
"CmdRemoteEnvDescription": "Obre un Work allotjat nou des de la punta d'una branca de GitHub o CNB",
|
||||
"CmdRemoteEnvOverview": "El Work allotjat inicia un entorn nou des de la punta de la branca disponible a GitHub o CNB.\n\nNo mou aquesta carpeta local ni inclou commits no enviats, fitxers modificats o ignorats, secrets ni l'estat de la sessió.\n\nUtilitza {command} per obrir el llançador de Work allotjat.",
|
||||
"CmdRemoteEnvOpening": "S'està obrint el Work allotjat per a {repo} a la branca {branch}.\n\nAixò inicia un entorn nou des de la punta de la branca disponible a l'amfitrió Git configurat com a {origin}. L'estat exclusivament local no s'hi inclou.\n\nSi el navegador no s'obre, utilitza:\n{url}",
|
||||
"CmdRemoteEnvUnavailable": "Codewhale no ha pogut resoldre alhora un origen compatible de GitHub o CNB i una branca activa per a aquesta carpeta. Canvia a una branca i configura {origin} amb un URL HTTPS o SSH; després torna a provar {command}. No s'ha creat ni assignat res.",
|
||||
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale no carrega, migra ni sincronitza codi font local amb el Work allotjat. Utilitza {command} per començar des de la punta de la branca disponible a GitHub o CNB. Els commits no enviats, els fitxers modificats o ignorats, els secrets i l'estat de la sessió es mantenen en local.",
|
||||
"CmdRemoteEnvBrowserLabel": "Work allotjat de Codewhale",
|
||||
"CmdRenameDescription": "Reanomena la sessió actual",
|
||||
"CmdRestoreDescription": "Reverteix l'espai de treball a una instantània anterior pre/post-torn. Sense argument, llista les instantànies recents.",
|
||||
"CmdRetryDescription": "Reintenta l'última petició",
|
||||
@@ -288,6 +308,9 @@
|
||||
"CmdRlmDescription": "Obre un context RLM persistent per a un fitxer o text",
|
||||
"CmdSaveDescription": "Desa la sessió a un fitxer",
|
||||
"CmdForkDescription": "Bifurca la conversació activa cap a una sessió germana",
|
||||
"CmdTreeDescription": "Mostra l'historial de la sessió com un arbre (la fulla és la branca activa)",
|
||||
"CmdBranchDescription": "Mou la branca activa a una entrada de sessió existent sense reescriure l'historial",
|
||||
"CmdResumeDescription": "Reprèn una sessió, amb l'opció d'importar un fitxer JSON de sessió exportat",
|
||||
"CmdNewDescription": "Inicia una sessió desada nova",
|
||||
"CmdSessionsDescription": "Obre el selector de l'historial de sessions",
|
||||
"CmdSettingsDescription": "Obre l'editor de configuració amb tipus",
|
||||
@@ -583,7 +606,7 @@
|
||||
"SetupConstitutionExistingLabel": "Fitxer existent:",
|
||||
"SetupConstitutionExpertOverrideLabel": "Override expert:",
|
||||
"SetupConstitutionGuidedHint": "1-6 ajusten l'esborrany, G previsualitza, G de nou ratifica. Només orientació: mai no canvia aprovacions del runtime, sandbox, shell, xarxa, confiança ni permisos MCP.",
|
||||
"SetupConstitutionGuidedAnswersHint": "Les respostes guiades només desen preferències globals d'usuari. El nucli inclòs continua actiu; els mòduls de doctrina queden per als prompts de mode/futures adhesions.",
|
||||
"SetupConstitutionGuidedAnswersHint": "Les respostes guiades només desen preferències globals d'usuari. El nucli inclòs continua actiu; l'execució queda en la política d'execució i futures adhesions.",
|
||||
"SetupConstitutionPurposeLabel": "Propòsit:",
|
||||
"SetupConstitutionAutonomyLabel": "Iniciativa:",
|
||||
"SetupConstitutionEvidenceLabel": "Evidència:",
|
||||
@@ -706,7 +729,7 @@
|
||||
"CtxMenuHelp": "Ajuda",
|
||||
"CtxMenuHelpDesc": "dreceres de teclat i ordres",
|
||||
"FanoutCounts": "{done} fetes · {running} en curs · {failed} fallides · {pending} pendents",
|
||||
"AppModeAgent": "Act",
|
||||
"AppModeAgent": "Work",
|
||||
"AppModeAuto": "Auto",
|
||||
"AppModeYolo": "Full Access (etiqueta de mode obsoleta)",
|
||||
"AppModePlan": "Plan",
|
||||
@@ -768,6 +791,13 @@
|
||||
"ElevationOptionWriteDesc": "Reintenta aquesta crida d'eina amb abast d'escriptura addicional del sistema de fitxers",
|
||||
"ElevationOptionFullAccessDesc": "Reintenta sense límits de sandbox; concedeix accés sense restriccions al sistema de fitxers i a la xarxa",
|
||||
"ElevationOptionAbortDesc": "Cancel·la aquesta execució d'eina",
|
||||
"ContextAutoCompacting": "Compactant automàticament el context…",
|
||||
"ContextManualCompacting": "Compactant el context…",
|
||||
"ContextCompactionQueued": "La compactació del context s'ha posat a la cua; s'executarà després del torn actiu.",
|
||||
"ContextCompactionAlreadyRunning": "La compactació del context ja està en curs.",
|
||||
"ContextCompactionQueueFull": "No s'ha pogut posar la compactació del context a la cua perquè el motor està ocupat; torna-ho a provar quan acabi el torn.",
|
||||
"ContextCompactionQueueClosed": "La compactació del context no està disponible perquè el motor ja no s'està executant.",
|
||||
"ContextCompactionRouteInvalid": "No es pot compactar perquè la ruta del proveïdor actiu no és vàlida: {error}",
|
||||
"CtxInspTitle": "Inspector de context",
|
||||
"CtxInspSessionContext": "Context de la sessió",
|
||||
"CtxInspSystemPrompt": "Estructura del prompt del sistema",
|
||||
@@ -969,7 +999,7 @@
|
||||
"PhaseDone": "fet",
|
||||
"PhaseFailed": "fallit",
|
||||
"PhaseFinishing": "acabant",
|
||||
"ChipModeAct": "act",
|
||||
"ChipModeAct": "work",
|
||||
"ChipModePlan": "plan",
|
||||
"ChipModeOperate": "operate",
|
||||
"ChipPermissionReadOnly": "només lectura",
|
||||
@@ -1203,6 +1233,8 @@
|
||||
"BehavioralTipClearedInput": "Esborrat · {chord} restaura",
|
||||
"BehavioralTipMcpValidation": "{command} inicia els servidors i mostra per què",
|
||||
"BehavioralTipRepeatedCommand": "{command} ho pot fixar",
|
||||
"BehavioralTipDurableStateWritten": "Desat · {command} per revisar",
|
||||
"BehavioralTipTodoWrite": "Consell: segueix les tasques de diversos passos amb {command} per mantenir el progrés visible",
|
||||
"SettingLockedDuringTurn": "{setting} està blocat mentre s'executa un torn — prem Esc per interrompre primer",
|
||||
"SettingSubjectMode": "Mode",
|
||||
"SettingSubjectThinking": "Raonament",
|
||||
|
||||
@@ -9,6 +9,19 @@
|
||||
"HistoryHintAccept": "Enter übernehmen",
|
||||
"HistoryHintRestore": "Esc wiederherstellen",
|
||||
"HistoryNoMatches": " Keine Treffer",
|
||||
"TranscriptReasoningExpand": "erweitern",
|
||||
"TelemetryNoticeHeadline": "Helfen Sie mit, Codewhale zu verbessern?",
|
||||
"TelemetryNoticeBody": "Codewhale zählt: die ausgeführte Version, Betriebssystem und Prozessorfamilie,\nSitzungsdauer und -ergebnis sowie zusammengefasste Funktions- und\nFehlerzähler.\n\nEs erfasst nie Unterhaltungen, Code, Prompts, Dateien, Datei-, Repository-\noder Branch-Namen, Modellinhalte oder Zugangsdaten — und sendet nie eine\nAktivitätschronik des Agenten pro Runde oder Werkzeug.\n\nZur Identifikation dient nur eine zufällige, auf diesem Gerät gespeicherte\nKennung, die alle 90 Tage ersetzt wird. Sie können Ihre Meinung jederzeit\nändern:\n codewhale config set telemetry false\n\nVollständiges Schema, Feld für Feld: docs/TELEMETRY.md",
|
||||
"TelemetryNoticeCompactBody": "Zählt: Version, OS/CPU, Dauer/Ergebnis, Funktionen/Fehler.\nLokale Zufalls-ID wechselt/90T.\nNie: Chat/Code/Prompts/Dateien/Namen; Modellinhalt/Zugangsdaten; Runden/Werkzeuge.\nSchema: docs/TELEMETRY.md\nAus: codewhale config set telemetry false",
|
||||
"TelemetryNoticeChoiceKeep": "Ja, anonyme Zählung beibehalten",
|
||||
"TelemetryNoticeChoiceDisable": "Nein, Nachverfolgung abschalten",
|
||||
"TelemetryNoticeActionChoose": "wählen",
|
||||
"TelemetryNoticeActionConfirm": "bestätigen",
|
||||
"TelemetryNoticeActionExit": "beenden",
|
||||
"TelemetryNoticeReceiptEnabled": "Ja, die anonyme Zählung bleibt eingeschaltet.",
|
||||
"TelemetryNoticeReceiptDisabled": "Die anonyme Nutzungszählung ist ausgeschaltet. Sie werden nicht erneut gefragt.",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "Die anonyme Zählung bleibt für diese Sitzung eingeschaltet. Codewhale konnte die Auswahl nicht speichern und fragt beim nächsten Start erneut.",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "Die anonyme Nutzungszählung ist für diese Sitzung ausgeschaltet. Codewhale konnte die Auswahl nicht speichern und fragt beim nächsten Start erneut.",
|
||||
"StatusPickerTitle": " Statuszeile ",
|
||||
"StatusPickerInstruction": "Wähle die Chips für die Fußzeile:",
|
||||
"StatusPickerActionToggle": "umschalten ",
|
||||
@@ -42,7 +55,7 @@
|
||||
"HotbarActionSessionCompactDescription": "Aktuellen Gesprächskontext komprimieren.",
|
||||
"HotbarActionModePlanName": "Plan-Modus",
|
||||
"HotbarActionModePlanDescription": "Erst einen Plan durchdenken, dann handeln.",
|
||||
"HotbarActionModeAgentName": "Act-Modus",
|
||||
"HotbarActionModeAgentName": "Work-Modus",
|
||||
"HotbarActionModeAgentDescription": "Direkt in der aktuellen Sitzung arbeiten.",
|
||||
"HotbarActionModeYoloName": "Full Access (Act)",
|
||||
"HotbarActionModeYoloDescription": "Kompatibilität: Act mit Full-Access-Berechtigungen (kein eigener Modus).",
|
||||
@@ -259,6 +272,7 @@
|
||||
"CmdModelsDescription": "Live-Modell-IDs von der aktiven API abrufen",
|
||||
"CmdModelDbDescription": "Mitgelieferte Modelldatenbank durchsuchen",
|
||||
"CmdNetworkDescription": "Netzwerk-Allow- und Deny-Regeln verwalten",
|
||||
"CmdUpdateDescription": "Neue CodeWhale-Version suchen und installieren",
|
||||
"CmdNoteDescription": "Workspace-Notizen hinzufügen, auflisten, bearbeiten oder entfernen",
|
||||
"CmdThemeDescription": "Theme wechseln oder Theme-Auswahl öffnen",
|
||||
"CmdProviderDescription": "Aktiven Provider und/oder Modell wechseln",
|
||||
@@ -281,6 +295,12 @@
|
||||
"CmdQueueIndexMin": "Index muss >= 1 sein",
|
||||
"CmdRelayDescription": "Session-Relay (接力) für einen frischen Thread erstellen",
|
||||
"CmdRemoteControlDescription": "Diese exakte Sitzung aus deinem Codewhale-Webkonto fortsetzen",
|
||||
"CmdRemoteEnvDescription": "Neues gehostetes Work von einer GitHub- oder CNB-Branch-Spitze öffnen",
|
||||
"CmdRemoteEnvOverview": "Gehostetes Work startet eine neue Umgebung von der Branch-Spitze, die bei GitHub oder CNB verfügbar ist.\n\nDieser lokale Ordner wird nicht verschoben; nicht gepushte Commits, geänderte oder ignorierte Dateien, Geheimnisse und Sitzungsstatus werden nicht übernommen.\n\nMit {command} öffnest du den Launcher für gehostetes Work.",
|
||||
"CmdRemoteEnvOpening": "Gehostetes Work für {repo} auf Branch {branch} wird geöffnet.\n\nDabei startet eine neue Umgebung von der Branch-Spitze, die beim als {origin} konfigurierten Git-Host verfügbar ist. Rein lokaler Status wird nicht übernommen.\n\nFalls sich der Browser nicht öffnet, verwende:\n{url}",
|
||||
"CmdRemoteEnvUnavailable": "Codewhale konnte für diesen Ordner nicht sowohl einen unterstützten GitHub- oder CNB-Origin als auch einen ausgecheckten Branch ermitteln. Checke einen Branch aus, konfiguriere {origin} mit einer HTTPS- oder SSH-URL und versuche {command} erneut. Nichts wurde erstellt oder zugewiesen.",
|
||||
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale lädt lokalen Quellcode nicht in gehostetes Work hoch und migriert oder synchronisiert ihn nicht dorthin. Verwende {command}, um von der bei GitHub oder CNB verfügbaren Branch-Spitze zu starten. Nicht gepushte Commits, geänderte oder ignorierte Dateien, Geheimnisse und Sitzungsstatus bleiben lokal.",
|
||||
"CmdRemoteEnvBrowserLabel": "Gehostetes Work von Codewhale",
|
||||
"CmdRenameDescription": "Aktuelle Sitzung umbenennen",
|
||||
"CmdRestoreDescription": "Workspace auf einen früheren Pre-/Post-Turn-Snapshot zurücksetzen. Ohne Argument werden die letzten Snapshots aufgelistet.",
|
||||
"CmdRetryDescription": "Letzte Anfrage wiederholen",
|
||||
@@ -288,6 +308,9 @@
|
||||
"CmdRlmDescription": "Persistenten RLM-Kontext für eine Datei oder Text öffnen",
|
||||
"CmdSaveDescription": "Sitzung in Datei speichern",
|
||||
"CmdForkDescription": "Aktives Gespräch in eine Schwester-Sitzung forken",
|
||||
"CmdTreeDescription": "Sitzungsverlauf als Baum anzeigen (das Blatt ist der aktive Zweig)",
|
||||
"CmdBranchDescription": "Aktiven Zweig zu einem vorhandenen Sitzungseintrag verschieben, ohne den Verlauf neu zu schreiben",
|
||||
"CmdResumeDescription": "Sitzung fortsetzen, optional durch Import einer exportierten Sitzungs-JSON-Datei",
|
||||
"CmdNewDescription": "Neue gespeicherte Sitzung starten",
|
||||
"CmdSessionsDescription": "Sitzungsverlauf-Auswahl öffnen",
|
||||
"CmdSettingsDescription": "Typisierten Einstellungs-Editor öffnen",
|
||||
@@ -583,7 +606,7 @@
|
||||
"SetupConstitutionExistingLabel": "Bestehende Datei:",
|
||||
"SetupConstitutionExpertOverrideLabel": "Experten-Override:",
|
||||
"SetupConstitutionGuidedHint": "1-6 passt den Entwurf an, G zeigt die Vorschau, erneut G ratifiziert. Nur Leitlinien: Laufzeit-Freigabe, Sandbox, Shell, Netzwerk, Vertrauen oder MCP-Berechtigungen werden nie geändert.",
|
||||
"SetupConstitutionGuidedAnswersHint": "Geführte Antworten speichern nur benutzer-globale Präferenzen. Der mitgelieferte Kern bleibt aktiv; Doktrin-Module bleiben in Modus-Prompts/künftigen Opt-ins.",
|
||||
"SetupConstitutionGuidedAnswersHint": "Geführte Antworten speichern nur benutzer-globale Präferenzen. Der mitgelieferte Kern bleibt aktiv; Ausführung liegt bei der Laufzeitrichtlinie und künftigen Opt-ins.",
|
||||
"SetupConstitutionPurposeLabel": "Zweck:",
|
||||
"SetupConstitutionAutonomyLabel": "Initiative:",
|
||||
"SetupConstitutionEvidenceLabel": "Belege:",
|
||||
@@ -706,7 +729,7 @@
|
||||
"CtxMenuHelp": "Hilfe",
|
||||
"CtxMenuHelpDesc": "Tastenbelegung und Befehle",
|
||||
"FanoutCounts": "{done} fertig · {running} läuft · {failed} fehlgeschlagen · {pending} ausstehend",
|
||||
"AppModeAgent": "Act",
|
||||
"AppModeAgent": "Work",
|
||||
"AppModeAuto": "Auto",
|
||||
"AppModeYolo": "Full Access (veralteter Modusname)",
|
||||
"AppModePlan": "Plan",
|
||||
@@ -768,6 +791,13 @@
|
||||
"ElevationOptionWriteDesc": "Tool-Aufruf mit zusätzlichem beschreibbarem Dateisystembereich wiederholen",
|
||||
"ElevationOptionFullAccessDesc": "Ohne Sandbox-Limits wiederholen; gewährt uneingeschränkten Dateisystem- und Netzwerkzugriff",
|
||||
"ElevationOptionAbortDesc": "Diese Tool-Ausführung abbrechen",
|
||||
"ContextAutoCompacting": "Kontext wird automatisch komprimiert…",
|
||||
"ContextManualCompacting": "Kontext wird komprimiert…",
|
||||
"ContextCompactionQueued": "Die Kontextkomprimierung wurde eingereiht; sie wird nach dem aktiven Zug ausgeführt.",
|
||||
"ContextCompactionAlreadyRunning": "Die Kontextkomprimierung läuft bereits.",
|
||||
"ContextCompactionQueueFull": "Die Kontextkomprimierung konnte nicht eingereiht werden, weil die Engine ausgelastet ist; versuche es nach Abschluss des Zugs erneut.",
|
||||
"ContextCompactionQueueClosed": "Die Kontextkomprimierung ist nicht verfügbar, weil die Engine nicht mehr läuft.",
|
||||
"ContextCompactionRouteInvalid": "Die Komprimierung ist nicht möglich, weil die aktive Provider-Route ungültig ist: {error}",
|
||||
"CtxInspTitle": "Kontext-Inspektor",
|
||||
"CtxInspSessionContext": "Sitzungskontext",
|
||||
"CtxInspSystemPrompt": "System-Prompt-Struktur",
|
||||
@@ -969,7 +999,7 @@
|
||||
"PhaseDone": "fertig",
|
||||
"PhaseFailed": "fehlgeschlagen",
|
||||
"PhaseFinishing": "schließt ab",
|
||||
"ChipModeAct": "act",
|
||||
"ChipModeAct": "work",
|
||||
"ChipModePlan": "plan",
|
||||
"ChipModeOperate": "operate",
|
||||
"ChipPermissionReadOnly": "nur lesen",
|
||||
@@ -1203,6 +1233,8 @@
|
||||
"BehavioralTipClearedInput": "Geleert · {chord} stellt wieder her",
|
||||
"BehavioralTipMcpValidation": "{command} startet Server und zeigt warum",
|
||||
"BehavioralTipRepeatedCommand": "{command} kann dies anpinnen",
|
||||
"BehavioralTipDurableStateWritten": "Gespeichert · {command} ansehen",
|
||||
"BehavioralTipTodoWrite": "Tipp: Mehrstufige Aufgaben mit {command} verfolgen — so bleibt der Fortschritt sichtbar",
|
||||
"SettingLockedDuringTurn": "{setting} ist gesperrt, während ein Turn läuft — zuerst Esc zum Unterbrechen drücken",
|
||||
"SettingSubjectMode": "Modus",
|
||||
"SettingSubjectThinking": "Thinking",
|
||||
|
||||
@@ -9,6 +9,19 @@
|
||||
"HistoryHintAccept": "Enter accept",
|
||||
"HistoryHintRestore": "Esc restore",
|
||||
"HistoryNoMatches": " No matches",
|
||||
"TranscriptReasoningExpand": "expand",
|
||||
"TelemetryNoticeHeadline": "Help improve Codewhale?",
|
||||
"TelemetryNoticeBody": "Codewhale counts: which version you run, OS and CPU family, session\nduration and outcome, and aggregate feature and error counters.\n\nIt never collects your conversations, code, prompts, files, repo or\nbranch names, model content, or credentials — and it never sends a\nper-turn or per-tool timeline of agent activity.\n\nYou are identified only by a random ID stored on this machine, replaced\nevery 90 days. Change your mind any time:\n codewhale config set telemetry false\n\nFull schema, field by field: docs/TELEMETRY.md",
|
||||
"TelemetryNoticeCompactBody": "Counts: version, OS/CPU, session\ntime/result, feature/error totals.\nRandom local ID rotates every 90d.\nNever: chat/code/prompts/files/names;\nmodel content/credentials; turn/tools.\nSchema: docs/TELEMETRY.md\nOff: codewhale config set telemetry\nfalse",
|
||||
"TelemetryNoticeChoiceKeep": "Yes, keep anonymous counts",
|
||||
"TelemetryNoticeChoiceDisable": "No, turn off tracking",
|
||||
"TelemetryNoticeActionChoose": "choose",
|
||||
"TelemetryNoticeActionConfirm": "confirm",
|
||||
"TelemetryNoticeActionExit": "exit",
|
||||
"TelemetryNoticeReceiptEnabled": "Yes — anonymous counts stay on.",
|
||||
"TelemetryNoticeReceiptDisabled": "Anonymous usage counting is off. You will not be asked again.",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "Counts stay on for this session. Codewhale could not save the choice, so it will ask again next launch.",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "Anonymous usage counting is off for this session. Codewhale could not save the choice, so it will ask again next launch.",
|
||||
"StatusPickerTitle": " Status line ",
|
||||
"StatusPickerInstruction": "Pick the chips you want in the footer:",
|
||||
"StatusPickerActionToggle": "toggle ",
|
||||
@@ -42,7 +55,7 @@
|
||||
"HotbarActionSessionCompactDescription": "Compact the current conversation context.",
|
||||
"HotbarActionModePlanName": "Plan mode",
|
||||
"HotbarActionModePlanDescription": "Think through a plan before acting.",
|
||||
"HotbarActionModeAgentName": "Act mode",
|
||||
"HotbarActionModeAgentName": "Work mode",
|
||||
"HotbarActionModeAgentDescription": "Do direct work in the current session.",
|
||||
"HotbarActionModeYoloName": "Full Access (Act)",
|
||||
"HotbarActionModeYoloDescription": "Compatibility: Act with Full Access permissions (not a separate mode).",
|
||||
@@ -239,7 +252,7 @@
|
||||
"CmdLogoutDescription": "Clear API key and return to setup",
|
||||
"CmdMcpDescription": "Open or manage MCP servers",
|
||||
"CmdPluginDescription": "Inspect and manage trusted plugin bundles; legacy executable tools stay separate",
|
||||
"CmdPluginBundleUsage": "Usage: /plugin [list|show <name>|validate [name]|install <spec>|update <name>|uninstall <name>|trust <name> [review-token]|enable <name>|disable <name>|revoke <name>|reload|tools [name]]",
|
||||
"CmdPluginBundleUsage": "Usage: /plugin [list|show <name>|validate [name]|export <name> <dir>|install <spec>|update <name>|uninstall <name>|trust <name> [review-token]|enable <name>|disable <name>|revoke <name>|reload|tools [name]]",
|
||||
"CmdPluginBundleNoneFound": "No Codewhale plugin bundles discovered.",
|
||||
"CmdPluginBundleListHeader": "Plugin bundles ({count}):",
|
||||
"CmdPluginLegacyListHeader": "Legacy executable plugin tools ({count}) in {dir}:",
|
||||
@@ -262,6 +275,7 @@
|
||||
"CmdModelsDescription": "Fetch live model IDs from the active API",
|
||||
"CmdModelDbDescription": "Browse the bundled model database",
|
||||
"CmdNetworkDescription": "Manage network allow and deny rules",
|
||||
"CmdUpdateDescription": "Check for and install a new CodeWhale release",
|
||||
"CmdNoteDescription": "Add, list, edit, or remove workspace notes",
|
||||
"CmdThemeDescription": "Switch theme or open the theme picker",
|
||||
"CmdProviderDescription": "Switch the active provider and/or model",
|
||||
@@ -284,6 +298,12 @@
|
||||
"CmdQueueIndexMin": "Index must be >= 1",
|
||||
"CmdRelayDescription": "Create a session relay (接力) for a fresh thread",
|
||||
"CmdRemoteControlDescription": "Resume this exact session from your Codewhale web account",
|
||||
"CmdRemoteEnvDescription": "Open new hosted Work from a GitHub or CNB branch tip",
|
||||
"CmdRemoteEnvOverview": "Hosted Work starts a new environment from the branch tip available at GitHub or CNB.\n\nIt does not move this local folder or include unpushed commits, dirty or ignored files, secrets, or session state.\n\nUse {command} to open the hosted Work launcher.",
|
||||
"CmdRemoteEnvOpening": "Opening hosted Work for {repo} on branch {branch}.\n\nThis starts a new environment from the branch tip available at the Git host configured as {origin}. Local-only state is not included.\n\nIf the browser does not open, use:\n{url}",
|
||||
"CmdRemoteEnvUnavailable": "Codewhale could not resolve both a supported GitHub or CNB origin and a checked-out branch for this folder. Check out a branch and configure {origin} with an HTTPS or SSH URL, then try {command} again. Nothing was created or allocated.",
|
||||
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale does not upload, migrate, or sync local source into hosted Work. Use {command} to start from the branch tip available at GitHub or CNB. Unpushed commits, dirty or ignored files, secrets, and session state stay local.",
|
||||
"CmdRemoteEnvBrowserLabel": "Codewhale hosted Work",
|
||||
"CmdRenameDescription": "Rename the current session",
|
||||
"CmdRestoreDescription": "Roll back the workspace to a prior pre/post-turn snapshot. With no arg, lists recent snapshots.",
|
||||
"CmdRetryDescription": "Retry the last request",
|
||||
@@ -291,6 +311,9 @@
|
||||
"CmdRlmDescription": "Open a persistent RLM context for a file or text",
|
||||
"CmdSaveDescription": "Save session to file",
|
||||
"CmdForkDescription": "Fork the active conversation into a sibling session",
|
||||
"CmdTreeDescription": "Show the session history as a tree (the leaf is the active branch)",
|
||||
"CmdBranchDescription": "Move the active branch to an existing session entry without rewriting history",
|
||||
"CmdResumeDescription": "Resume a session, optionally importing an exported session JSON file",
|
||||
"CmdNewDescription": "Start a fresh saved session",
|
||||
"CmdSessionsDescription": "Open session history picker",
|
||||
"CmdSettingsDescription": "Open the typed settings editor",
|
||||
@@ -606,7 +629,7 @@
|
||||
"SetupConstitutionExistingLabel": "Existing file:",
|
||||
"SetupConstitutionExpertOverrideLabel": "Expert override:",
|
||||
"SetupConstitutionGuidedHint": "1-6 tune the draft, G previews, G again ratifies. Guidance only: it never changes runtime approval, sandbox, shell, network, trust, or MCP permissions.",
|
||||
"SetupConstitutionGuidedAnswersHint": "Guided answers save user-global preferences only. The bundled core stays active; doctrine modules stay in mode prompts/future opt-ins.",
|
||||
"SetupConstitutionGuidedAnswersHint": "Guided answers save user-global preferences only. The bundled core stays active; execution stays with runtime policy and future opt-ins.",
|
||||
"SetupConstitutionPurposeLabel": "Purpose:",
|
||||
"SetupConstitutionAutonomyLabel": "Initiative:",
|
||||
"SetupConstitutionEvidenceLabel": "Evidence:",
|
||||
@@ -729,7 +752,7 @@
|
||||
"CtxMenuHelp": "Help",
|
||||
"CtxMenuHelpDesc": "keybindings and commands",
|
||||
"FanoutCounts": "{done} done · {running} running · {failed} failed · {pending} pending",
|
||||
"AppModeAgent": "Act",
|
||||
"AppModeAgent": "Work",
|
||||
"AppModeAuto": "Auto",
|
||||
"AppModeYolo": "Full Access (deprecated mode label)",
|
||||
"AppModePlan": "Plan",
|
||||
@@ -791,6 +814,13 @@
|
||||
"ElevationOptionWriteDesc": "Retry this tool call with additional writable filesystem scope",
|
||||
"ElevationOptionFullAccessDesc": "Retry without sandbox limits; grants unrestricted filesystem and network access",
|
||||
"ElevationOptionAbortDesc": "Cancel this tool execution",
|
||||
"ContextAutoCompacting": "Context automatically compacting…",
|
||||
"ContextManualCompacting": "Compacting context…",
|
||||
"ContextCompactionQueued": "Context compaction queued; it will run after the active turn.",
|
||||
"ContextCompactionAlreadyRunning": "Context compaction is already in progress.",
|
||||
"ContextCompactionQueueFull": "Context compaction could not be queued because the engine is busy; try again after the turn completes.",
|
||||
"ContextCompactionQueueClosed": "Context compaction is unavailable because the engine is no longer running.",
|
||||
"ContextCompactionRouteInvalid": "Cannot compact because the active provider route is invalid: {error}",
|
||||
"CtxInspTitle": "Context inspector",
|
||||
"CtxInspSessionContext": "Session Context",
|
||||
"CtxInspSystemPrompt": "System Prompt Structure",
|
||||
@@ -992,7 +1022,7 @@
|
||||
"PhaseDone": "done",
|
||||
"PhaseFailed": "failed",
|
||||
"PhaseFinishing": "finishing",
|
||||
"ChipModeAct": "act",
|
||||
"ChipModeAct": "work",
|
||||
"ChipModePlan": "plan",
|
||||
"ChipModeOperate": "operate",
|
||||
"ChipPermissionReadOnly": "read only",
|
||||
@@ -1226,6 +1256,8 @@
|
||||
"BehavioralTipClearedInput": "Cleared · {chord} restores",
|
||||
"BehavioralTipMcpValidation": "{command} starts servers and shows why",
|
||||
"BehavioralTipRepeatedCommand": "{command} can pin this",
|
||||
"BehavioralTipDurableStateWritten": "Saved · {command} to inspect",
|
||||
"BehavioralTipTodoWrite": "Tip: track multi-step work with {command} — it keeps progress visible",
|
||||
"SettingLockedDuringTurn": "{setting} is locked while a turn is running — press Esc to interrupt first",
|
||||
"SettingSubjectMode": "Mode",
|
||||
"SettingSubjectThinking": "Thinking",
|
||||
|
||||
@@ -9,6 +9,19 @@
|
||||
"HistoryHintAccept": "Enter aceptar",
|
||||
"HistoryHintRestore": "Esc restaurar",
|
||||
"HistoryNoMatches": " Sin resultados",
|
||||
"TranscriptReasoningExpand": "expandir",
|
||||
"TelemetryNoticeHeadline": "¿Ayudas a mejorar Codewhale?",
|
||||
"TelemetryNoticeBody": "Codewhale cuenta: la versión que ejecutas, el sistema operativo y la familia\nde CPU, la duración y el resultado de la sesión, y conteos agregados de\nfunciones y errores.\n\nNunca recopila tus conversaciones, código, prompts, archivos, nombres de\narchivos, repositorios o ramas, contenido del modelo ni credenciales — y nunca\nenvía una cronología de la actividad del agente por turno o herramienta.\n\nSolo te identifica un ID aleatorio almacenado en esta máquina, que se\nreemplaza cada 90 días. Cambia de opinión cuando quieras:\n codewhale config set telemetry false\n\nEsquema completo, campo por campo: docs/TELEMETRY.md",
|
||||
"TelemetryNoticeCompactBody": "Cuenta: versión, SO/CPU, tiempo/resultado, funciones/errores.\nID local rota/90d.\nNunca: chats/código/prompts/archivos/nombres; datos del modelo/credenciales; turnos/herramientas.\nVer: docs/TELEMETRY.md\nApag.: codewhale config set telemetry false",
|
||||
"TelemetryNoticeChoiceKeep": "Sí, mantener el conteo anónimo",
|
||||
"TelemetryNoticeChoiceDisable": "No, desactivar el seguimiento",
|
||||
"TelemetryNoticeActionChoose": "elegir",
|
||||
"TelemetryNoticeActionConfirm": "confirmar",
|
||||
"TelemetryNoticeActionExit": "salir",
|
||||
"TelemetryNoticeReceiptEnabled": "Sí, el conteo anónimo sigue activo.",
|
||||
"TelemetryNoticeReceiptDisabled": "El conteo anónimo de uso está desactivado. No volveremos a preguntarte.",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "El conteo anónimo sigue activo en esta sesión. Codewhale no pudo guardar la opción y volverá a preguntar en el próximo inicio.",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "El conteo anónimo de uso está desactivado en esta sesión. Codewhale no pudo guardar la opción, así que volverá a preguntar en el próximo inicio.",
|
||||
"StatusPickerTitle": " Línea de estado ",
|
||||
"StatusPickerInstruction": "Elige los elementos que quieres en el pie:",
|
||||
"StatusPickerActionToggle": "alternar ",
|
||||
@@ -42,7 +55,7 @@
|
||||
"HotbarActionSessionCompactDescription": "Compactar el contexto de la conversación actual.",
|
||||
"HotbarActionModePlanName": "Modo Plan",
|
||||
"HotbarActionModePlanDescription": "Piensa el plan antes de actuar.",
|
||||
"HotbarActionModeAgentName": "Modo Act",
|
||||
"HotbarActionModeAgentName": "Modo Work",
|
||||
"HotbarActionModeAgentDescription": "Trabaja directo en la sesión actual.",
|
||||
"HotbarActionModeYoloName": "Acceso total (Act)",
|
||||
"HotbarActionModeYoloDescription": "Compatibilidad: Act con acceso total (no es un modo aparte).",
|
||||
@@ -262,6 +275,7 @@
|
||||
"CmdModelsDescription": "Obtener IDs de modelo en vivo de la API activa",
|
||||
"CmdModelDbDescription": "Explorar la base de datos de modelos integrada",
|
||||
"CmdNetworkDescription": "Gestionar reglas de red permitidas y bloqueadas",
|
||||
"CmdUpdateDescription": "Buscar e instalar una nueva versión de CodeWhale",
|
||||
"CmdNoteDescription": "Agregar nota al archivo persistente (.codewhale/notes.md)",
|
||||
"CmdThemeDescription": "Alternar entre tema claro y oscuro",
|
||||
"CmdProviderDescription": "Cambiar o mostrar el backend LLM activo (deepseek | nvidia-nim | ollama)",
|
||||
@@ -284,6 +298,12 @@
|
||||
"CmdQueueIndexMin": "El índice debe ser >= 1",
|
||||
"CmdRelayDescription": "Crear un relay de sesión (接力) para un hilo nuevo",
|
||||
"CmdRemoteControlDescription": "Reanudar esta sesión exacta desde tu cuenta web de Codewhale",
|
||||
"CmdRemoteEnvDescription": "Abrir un Work alojado nuevo desde la punta de una rama de GitHub o CNB",
|
||||
"CmdRemoteEnvOverview": "El Work alojado inicia un entorno nuevo desde la punta de la rama disponible en GitHub o CNB.\n\nNo mueve esta carpeta local ni incluye commits sin enviar, archivos modificados o ignorados, secretos ni el estado de la sesión.\n\nUsa {command} para abrir el iniciador de Work alojado.",
|
||||
"CmdRemoteEnvOpening": "Abriendo el Work alojado para {repo} en la rama {branch}.\n\nEsto inicia un entorno nuevo desde la punta de la rama disponible en el host Git configurado como {origin}. No se incluye el estado que solo existe localmente.\n\nSi el navegador no se abre, usa:\n{url}",
|
||||
"CmdRemoteEnvUnavailable": "Codewhale no pudo resolver para esta carpeta tanto un origen compatible de GitHub o CNB como una rama activa. Cambia a una rama y configura {origin} con una URL HTTPS o SSH; después vuelve a intentar {command}. No se creó ni asignó nada.",
|
||||
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale no carga, migra ni sincroniza el código fuente local con el Work alojado. Usa {command} para iniciar desde la punta de la rama disponible en GitHub o CNB. Los commits sin enviar, los archivos modificados o ignorados, los secretos y el estado de la sesión permanecen locales.",
|
||||
"CmdRemoteEnvBrowserLabel": "Work alojado de Codewhale",
|
||||
"CmdRenameDescription": "Renombrar la sesión actual",
|
||||
"CmdRestoreDescription": "Revertir el workspace a un snapshot pre/post-turno anterior. Sin argumento, lista los snapshots recientes.",
|
||||
"CmdRetryDescription": "Repetir la última solicitud",
|
||||
@@ -291,6 +311,9 @@
|
||||
"CmdRlmDescription": "Abrir un contexto RLM persistente para un archivo o texto",
|
||||
"CmdSaveDescription": "Guardar la sesión en archivo",
|
||||
"CmdForkDescription": "Bifurcar la conversación activa a una sesión hermana",
|
||||
"CmdTreeDescription": "Mostrar el historial de la sesión como árbol (la hoja es la rama activa)",
|
||||
"CmdBranchDescription": "Mover la rama activa a una entrada existente de la sesión sin reescribir el historial",
|
||||
"CmdResumeDescription": "Reanudar una sesión, con la opción de importar un archivo JSON de sesión exportado",
|
||||
"CmdNewDescription": "Iniciar una nueva sesión guardada",
|
||||
"CmdSessionsDescription": "Abrir el selector de sesiones",
|
||||
"CmdSettingsDescription": "Abrir el editor de ajustes con tipos",
|
||||
@@ -606,7 +629,7 @@
|
||||
"SetupConstitutionExistingLabel": "Archivo existente:",
|
||||
"SetupConstitutionExpertOverrideLabel": "Override experto:",
|
||||
"SetupConstitutionGuidedHint": "1-6 ajustan el borrador, G previsualiza, G otra vez ratifica. Es solo guía: nunca cambia aprobaciones de runtime, sandbox, shell, red, confianza ni permisos MCP.",
|
||||
"SetupConstitutionGuidedAnswersHint": "Las respuestas guiadas solo guardan preferencias globales de usuario. El núcleo incluido sigue activo; los módulos de doctrina quedan en prompts de modo/futuros opt-ins.",
|
||||
"SetupConstitutionGuidedAnswersHint": "Las respuestas guiadas solo guardan preferencias globales de usuario. El núcleo incluido sigue activo; la ejecución queda en la política de ejecución y futuros opt-ins.",
|
||||
"SetupConstitutionPurposeLabel": "Propósito:",
|
||||
"SetupConstitutionAutonomyLabel": "Iniciativa:",
|
||||
"SetupConstitutionEvidenceLabel": "Evidencia:",
|
||||
@@ -729,7 +752,7 @@
|
||||
"CtxMenuHelp": "Ayuda",
|
||||
"CtxMenuHelpDesc": "atajos de teclado y comandos",
|
||||
"FanoutCounts": "{done} completado · {running} ejecutando · {failed} falló · {pending} pendiente",
|
||||
"AppModeAgent": "Act",
|
||||
"AppModeAgent": "Work",
|
||||
"AppModeAuto": "Automático",
|
||||
"AppModeYolo": "Acceso total (etiqueta obsoleta)",
|
||||
"AppModePlan": "Plan",
|
||||
@@ -789,6 +812,13 @@
|
||||
"ElevationOptionWriteDesc": "Reintenta esta llamada con ámbito adicional de sistema de archivos grabable",
|
||||
"ElevationOptionFullAccessDesc": "Reintenta sin límites de sandbox; concede acceso sin restricciones al sistema de archivos y red",
|
||||
"ElevationOptionAbortDesc": "Cancelar esta ejecución de herramienta",
|
||||
"ContextAutoCompacting": "Compactando automáticamente el contexto…",
|
||||
"ContextManualCompacting": "Compactando el contexto…",
|
||||
"ContextCompactionQueued": "La compactación del contexto quedó en cola; se ejecutará después del turno activo.",
|
||||
"ContextCompactionAlreadyRunning": "La compactación del contexto ya está en curso.",
|
||||
"ContextCompactionQueueFull": "No se pudo poner en cola la compactación del contexto porque el motor está ocupado; inténtalo de nuevo cuando termine el turno.",
|
||||
"ContextCompactionQueueClosed": "La compactación del contexto no está disponible porque el motor ya no está en ejecución.",
|
||||
"ContextCompactionRouteInvalid": "No se puede compactar porque la ruta del proveedor activo no es válida: {error}",
|
||||
"CtxInspTitle": "Inspector de contexto",
|
||||
"CtxInspSessionContext": "Contexto de la sesión",
|
||||
"CtxInspSystemPrompt": "Estructura del prompt del sistema",
|
||||
@@ -992,7 +1022,7 @@
|
||||
"PhaseDone": "listo",
|
||||
"PhaseFailed": "falló",
|
||||
"PhaseFinishing": "finalizando",
|
||||
"ChipModeAct": "act",
|
||||
"ChipModeAct": "work",
|
||||
"ChipModePlan": "plan",
|
||||
"ChipModeOperate": "operate",
|
||||
"ChipPermissionReadOnly": "solo lectura",
|
||||
@@ -1226,6 +1256,8 @@
|
||||
"BehavioralTipClearedInput": "Borrado · {chord} restaura",
|
||||
"BehavioralTipMcpValidation": "{command} inicia los servidores y muestra el motivo",
|
||||
"BehavioralTipRepeatedCommand": "{command} puede fijar esto",
|
||||
"BehavioralTipDurableStateWritten": "Guardado · {command} para ver",
|
||||
"BehavioralTipTodoWrite": "Consejo: sigue el trabajo de varios pasos con {command} para mantener visible el progreso",
|
||||
"SettingLockedDuringTurn": "{setting} está bloqueado mientras se ejecuta un turno: presiona Esc para interrumpir primero",
|
||||
"SettingSubjectMode": "El modo",
|
||||
"SettingSubjectThinking": "El razonamiento",
|
||||
|
||||
@@ -9,6 +9,19 @@
|
||||
"HistoryHintAccept": "Enter accepter",
|
||||
"HistoryHintRestore": "Esc restaurer",
|
||||
"HistoryNoMatches": " Aucun résultat",
|
||||
"TranscriptReasoningExpand": "développer",
|
||||
"TelemetryNoticeHeadline": "Aidez-nous à améliorer Codewhale ?",
|
||||
"TelemetryNoticeBody": "Codewhale compte : la version exécutée, le système d'exploitation et la\nfamille du processeur, la durée et le résultat de la session, ainsi que les\ntotaux de fonctions et d'erreurs.\n\nIl ne collecte jamais vos conversations, votre code, vos prompts, fichiers,\nnoms de fichiers, de dépôts ou de branches, contenus du modèle ou identifiants\n— et n'envoie jamais de chronologie de l'activité par tour ou par outil.\n\nVous êtes identifié uniquement par un identifiant aléatoire stocké sur cette\nmachine, remplacé tous les 90 jours. Changez d'avis à tout moment :\n codewhale config set telemetry false\n\nSchéma complet, champ par champ : docs/TELEMETRY.md",
|
||||
"TelemetryNoticeCompactBody": "Compte: version, OS/CPU, durée/résultat, fonctions/erreurs.\nID local aléatoire change/90j.\nJamais: chats/code/prompts/fichiers/noms; contenu modèle/identifiants; tours/outils.\nVoir: docs/TELEMETRY.md\nArrêt: codewhale config set telemetry false",
|
||||
"TelemetryNoticeChoiceKeep": "Oui, conserver le comptage anonyme",
|
||||
"TelemetryNoticeChoiceDisable": "Non, désactiver le suivi",
|
||||
"TelemetryNoticeActionChoose": "choisir",
|
||||
"TelemetryNoticeActionConfirm": "confirmer",
|
||||
"TelemetryNoticeActionExit": "quitter",
|
||||
"TelemetryNoticeReceiptEnabled": "Oui, les comptages anonymes restent actifs.",
|
||||
"TelemetryNoticeReceiptDisabled": "Le comptage anonyme de l'utilisation est désactivé. La question ne sera plus posée.",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "Les comptages anonymes restent actifs pour cette session. Codewhale n'a pas pu enregistrer ce choix et reposera la question au prochain lancement.",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "Le comptage anonyme est désactivé pour cette session. Codewhale n'a pas pu enregistrer le choix et reposera la question au prochain lancement.",
|
||||
"StatusPickerTitle": " Barre d'état ",
|
||||
"StatusPickerInstruction": "Choisissez les éléments à afficher dans le pied de page :",
|
||||
"StatusPickerActionToggle": "basculer ",
|
||||
@@ -42,7 +55,7 @@
|
||||
"HotbarActionSessionCompactDescription": "Compacter le contexte de la conversation en cours.",
|
||||
"HotbarActionModePlanName": "Mode Plan",
|
||||
"HotbarActionModePlanDescription": "Réfléchir à un plan avant d'agir.",
|
||||
"HotbarActionModeAgentName": "Mode Act",
|
||||
"HotbarActionModeAgentName": "Mode Work",
|
||||
"HotbarActionModeAgentDescription": "Travailler directement dans la session en cours.",
|
||||
"HotbarActionModeYoloName": "Full Access (Act)",
|
||||
"HotbarActionModeYoloDescription": "Compatibilité : Act avec les permissions Full Access (pas un mode à part).",
|
||||
@@ -259,6 +272,7 @@
|
||||
"CmdModelsDescription": "Récupérer les IDs de modèles en direct depuis l'API active",
|
||||
"CmdModelDbDescription": "Parcourir la base de modèles intégrée",
|
||||
"CmdNetworkDescription": "Gérer les règles réseau d'autorisation et de refus",
|
||||
"CmdUpdateDescription": "Rechercher et installer une nouvelle version de CodeWhale",
|
||||
"CmdNoteDescription": "Ajouter, lister, modifier ou supprimer des notes du workspace",
|
||||
"CmdThemeDescription": "Changer de thème ou ouvrir le sélecteur de thème",
|
||||
"CmdProviderDescription": "Changer le fournisseur et/ou le modèle actif",
|
||||
@@ -281,6 +295,12 @@
|
||||
"CmdQueueIndexMin": "L'index doit être >= 1",
|
||||
"CmdRelayDescription": "Créer un relais de session (接力) pour un nouveau fil",
|
||||
"CmdRemoteControlDescription": "Reprendre cette session exacte depuis votre compte web Codewhale",
|
||||
"CmdRemoteEnvDescription": "Ouvrir un nouveau Work hébergé depuis la pointe d'une branche GitHub ou CNB",
|
||||
"CmdRemoteEnvOverview": "Le Work hébergé démarre un nouvel environnement depuis la pointe de la branche disponible sur GitHub ou CNB.\n\nIl ne déplace pas ce dossier local et n'inclut ni les commits non poussés, ni les fichiers modifiés ou ignorés, ni les secrets, ni l'état de la session.\n\nUtilisez {command} pour ouvrir le lanceur du Work hébergé.",
|
||||
"CmdRemoteEnvOpening": "Ouverture du Work hébergé pour {repo} sur la branche {branch}.\n\nUn nouvel environnement démarre depuis la pointe de la branche disponible sur l'hôte Git configuré comme {origin}. L'état uniquement local n'est pas inclus.\n\nSi le navigateur ne s'ouvre pas, utilisez :\n{url}",
|
||||
"CmdRemoteEnvUnavailable": "Codewhale n'a pas pu trouver à la fois une origine GitHub ou CNB prise en charge et une branche extraite pour ce dossier. Extrayez une branche, configurez {origin} avec une URL HTTPS ou SSH, puis réessayez {command}. Rien n'a été créé ni alloué.",
|
||||
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale ne téléverse, ne migre et ne synchronise pas le code source local dans le Work hébergé. Utilisez {command} pour démarrer depuis la pointe de la branche disponible sur GitHub ou CNB. Les commits non poussés, les fichiers modifiés ou ignorés, les secrets et l'état de la session restent locaux.",
|
||||
"CmdRemoteEnvBrowserLabel": "Work hébergé de Codewhale",
|
||||
"CmdRenameDescription": "Renommer la session en cours",
|
||||
"CmdRestoreDescription": "Restaurer le workspace à un snapshot pré/post-tour antérieur. Sans argument, liste les snapshots récents.",
|
||||
"CmdRetryDescription": "Réessayer la dernière requête",
|
||||
@@ -288,6 +308,9 @@
|
||||
"CmdRlmDescription": "Ouvrir un contexte RLM persistant pour un fichier ou un texte",
|
||||
"CmdSaveDescription": "Enregistrer la session dans un fichier",
|
||||
"CmdForkDescription": "Dupliquer la conversation active dans une session sœur",
|
||||
"CmdTreeDescription": "Afficher l’historique de la session sous forme d’arbre (la feuille est la branche active)",
|
||||
"CmdBranchDescription": "Déplacer la branche active vers une entrée de session existante sans réécrire l’historique",
|
||||
"CmdResumeDescription": "Reprendre une session, avec la possibilité d’importer un fichier JSON de session exporté",
|
||||
"CmdNewDescription": "Démarrer une nouvelle session enregistrée",
|
||||
"CmdSessionsDescription": "Ouvrir le sélecteur d'historique des sessions",
|
||||
"CmdSettingsDescription": "Ouvrir l'éditeur de paramètres typés",
|
||||
@@ -583,7 +606,7 @@
|
||||
"SetupConstitutionExistingLabel": "Fichier existant :",
|
||||
"SetupConstitutionExpertOverrideLabel": "Dérogation experte :",
|
||||
"SetupConstitutionGuidedHint": "1-6 ajustent le brouillon, G affiche l'aperçu, G à nouveau ratifie. Directives seulement : cela ne change jamais les approbations runtime, le sandbox, le shell, le réseau, la confiance ou les permissions MCP.",
|
||||
"SetupConstitutionGuidedAnswersHint": "Les réponses guidées n'enregistrent que des préférences globales utilisateur. Le noyau intégré reste actif ; les modules de doctrine restent dans les prompts de mode/futures options.",
|
||||
"SetupConstitutionGuidedAnswersHint": "Les réponses guidées n'enregistrent que des préférences globales utilisateur. Le noyau intégré reste actif ; l'exécution relève de la politique d'exécution et de futures options.",
|
||||
"SetupConstitutionPurposeLabel": "Objectif :",
|
||||
"SetupConstitutionAutonomyLabel": "Initiative :",
|
||||
"SetupConstitutionEvidenceLabel": "Preuve :",
|
||||
@@ -706,7 +729,7 @@
|
||||
"CtxMenuHelp": "Aide",
|
||||
"CtxMenuHelpDesc": "raccourcis clavier et commandes",
|
||||
"FanoutCounts": "{done} terminé · {running} en cours · {failed} échoué · {pending} en attente",
|
||||
"AppModeAgent": "Act",
|
||||
"AppModeAgent": "Work",
|
||||
"AppModeAuto": "Auto",
|
||||
"AppModeYolo": "Full Access (étiquette de mode obsolète)",
|
||||
"AppModePlan": "Plan",
|
||||
@@ -768,6 +791,13 @@
|
||||
"ElevationOptionWriteDesc": "Réessayer cet appel d'outil avec une portée inscriptible supplémentaire du système de fichiers",
|
||||
"ElevationOptionFullAccessDesc": "Réessayer sans limites de sandbox ; accorde un accès illimité au système de fichiers et au réseau",
|
||||
"ElevationOptionAbortDesc": "Annuler cette exécution d'outil",
|
||||
"ContextAutoCompacting": "Compression automatique du contexte en cours…",
|
||||
"ContextManualCompacting": "Compression du contexte en cours…",
|
||||
"ContextCompactionQueued": "La compression du contexte a été mise en file d’attente ; elle s’exécutera après le tour actif.",
|
||||
"ContextCompactionAlreadyRunning": "La compression du contexte est déjà en cours.",
|
||||
"ContextCompactionQueueFull": "La compression du contexte n’a pas pu être mise en file d’attente car le moteur est occupé ; réessayez une fois le tour terminé.",
|
||||
"ContextCompactionQueueClosed": "La compression du contexte est indisponible car le moteur ne fonctionne plus.",
|
||||
"ContextCompactionRouteInvalid": "Impossible de compacter, car la route du fournisseur actif n’est pas valide : {error}",
|
||||
"CtxInspTitle": "Inspecteur de contexte",
|
||||
"CtxInspSessionContext": "Contexte de la session",
|
||||
"CtxInspSystemPrompt": "Structure du prompt système",
|
||||
@@ -969,7 +999,7 @@
|
||||
"PhaseDone": "terminé",
|
||||
"PhaseFailed": "échoué",
|
||||
"PhaseFinishing": "finalisation",
|
||||
"ChipModeAct": "act",
|
||||
"ChipModeAct": "work",
|
||||
"ChipModePlan": "plan",
|
||||
"ChipModeOperate": "operate",
|
||||
"ChipPermissionReadOnly": "lecture seule",
|
||||
@@ -1203,6 +1233,8 @@
|
||||
"BehavioralTipClearedInput": "Effacé · {chord} restaure",
|
||||
"BehavioralTipMcpValidation": "{command} démarre les serveurs et montre pourquoi",
|
||||
"BehavioralTipRepeatedCommand": "{command} peut épingler ceci",
|
||||
"BehavioralTipDurableStateWritten": "Enregistré · {command} pour voir",
|
||||
"BehavioralTipTodoWrite": "Astuce : suivez les tâches en plusieurs étapes avec {command} pour garder la progression visible",
|
||||
"SettingLockedDuringTurn": "{setting} est verrouillé pendant qu'un tour est en cours — appuyez d'abord sur Esc pour interrompre",
|
||||
"SettingSubjectMode": "Le mode",
|
||||
"SettingSubjectThinking": "Le raisonnement",
|
||||
|
||||
@@ -9,6 +9,19 @@
|
||||
"HistoryHintAccept": "Enter चुनें",
|
||||
"HistoryHintRestore": "Esc पुनर्स्थापित",
|
||||
"HistoryNoMatches": " कोई मिलान नहीं",
|
||||
"TranscriptReasoningExpand": "विस्तार",
|
||||
"TelemetryNoticeHeadline": "क्या आप Codewhale को बेहतर बनाने में मदद करेंगे?",
|
||||
"TelemetryNoticeBody": "Codewhale गणना करता है: चल रहा संस्करण, ऑपरेटिंग सिस्टम व प्रोसेसर परिवार,\nसत्र की अवधि व परिणाम, और सुविधाओं व त्रुटियों की कुल गणना।\n\nयह आपकी बातचीत, कोड, प्रॉम्प्ट, फ़ाइलें, फ़ाइल, भंडार या शाखा के नाम,\nमॉडल की सामग्री या परिचय प्रमाण कभी एकत्र नहीं करता, और प्रत्येक चरण या\nऔज़ार की एजेंट गतिविधि का क्रम भी कभी नहीं भेजता।\n\nपहचान के लिए केवल इस मशीन पर रखी एक आकस्मिक पहचान संख्या उपयोग होती है,\nजो हर 90 दिन में बदल दी जाती है। कभी भी अपना निर्णय बदलें:\n codewhale config set telemetry false\n\nहर क्षेत्र सहित पूरा प्रारूप: docs/TELEMETRY.md",
|
||||
"TelemetryNoticeCompactBody": "गणना: संस्करण, सिस्टम/प्रोसेसर, सत्र अवधि/परिणाम और सुविधा/त्रुटि योग।\nआकस्मिक स्थानीय पहचान हर 90 दिन में बदलती है।\nकभी नहीं: बातचीत/कोड/प्रॉम्प्ट/फ़ाइल/नाम, मॉडल सामग्री/परिचय प्रमाण, चरण/औज़ार।\nप्रारूप: docs/TELEMETRY.md\nबंद: codewhale config set telemetry false",
|
||||
"TelemetryNoticeChoiceKeep": "हाँ, अनाम गणना जारी रखें",
|
||||
"TelemetryNoticeChoiceDisable": "नहीं, ट्रैकिंग बंद करें",
|
||||
"TelemetryNoticeActionChoose": "चुनें",
|
||||
"TelemetryNoticeActionConfirm": "पुष्टि",
|
||||
"TelemetryNoticeActionExit": "बाहर",
|
||||
"TelemetryNoticeReceiptEnabled": "हाँ, अनाम गणना चालू रहेगी।",
|
||||
"TelemetryNoticeReceiptDisabled": "अनाम उपयोग गणना बंद है। दोबारा नहीं पूछा जाएगा।",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "इस सत्र में अनाम गणना चालू रहेगी। Codewhale चयन सहेज नहीं सका, इसलिए अगली बार शुरू होने पर फिर पूछेगा।",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "इस सत्र में अनाम उपयोग गणना बंद है। Codewhale चयन सहेज नहीं सका, इसलिए अगली बार शुरू होने पर फिर पूछेगा।",
|
||||
"StatusPickerTitle": " स्टेटस लाइन ",
|
||||
"StatusPickerInstruction": "फ़ुटर में चाहिए वाले चिप चुनें:",
|
||||
"StatusPickerActionToggle": "टॉगल ",
|
||||
@@ -42,7 +55,7 @@
|
||||
"HotbarActionSessionCompactDescription": "वर्तमान वार्तालाप संदर्भ को कॉम्पैक्ट करें।",
|
||||
"HotbarActionModePlanName": "Plan मोड",
|
||||
"HotbarActionModePlanDescription": "कार्य करने से पहले योजना सोचें।",
|
||||
"HotbarActionModeAgentName": "Act मोड",
|
||||
"HotbarActionModeAgentName": "Work मोड",
|
||||
"HotbarActionModeAgentDescription": "वर्तमान सत्र में सीधे काम करें।",
|
||||
"HotbarActionModeYoloName": "Full Access (Act)",
|
||||
"HotbarActionModeYoloDescription": "संगतता: Full Access अनुमतियों के साथ Act (अलग मोड नहीं)।",
|
||||
@@ -259,6 +272,7 @@
|
||||
"CmdModelsDescription": "सक्रिय API से लाइव मॉडल ID लाएँ",
|
||||
"CmdModelDbDescription": "बंडल किया मॉडल डेटाबेस ब्राउज़ करें",
|
||||
"CmdNetworkDescription": "नेटवर्क अनुमति और निषेध नियम प्रबंधित करें",
|
||||
"CmdUpdateDescription": "नया CodeWhale रिलीज़ जाँचें और इंस्टॉल करें",
|
||||
"CmdNoteDescription": "वर्कस्पेस नोट जोड़ें, सूचीबद्ध करें, संपादित करें या हटाएँ",
|
||||
"CmdThemeDescription": "थीम बदलें या थीम चयनकर्ता खोलें",
|
||||
"CmdProviderDescription": "सक्रिय प्रोवाइडर और/या मॉडल बदलें",
|
||||
@@ -281,6 +295,12 @@
|
||||
"CmdQueueIndexMin": "इंडेक्स >= 1 होनी चाहिए",
|
||||
"CmdRelayDescription": "नए थ्रेड के लिए सत्र रिले (接力) बनाएँ",
|
||||
"CmdRemoteControlDescription": "अपने Codewhale वेब खाते से यही सत्र जारी रखें",
|
||||
"CmdRemoteEnvDescription": "GitHub या CNB ब्रांच टिप से नया होस्टेड Work खोलें",
|
||||
"CmdRemoteEnvOverview": "होस्टेड Work, GitHub या CNB पर उपलब्ध ब्रांच टिप से नया एनवायरनमेंट शुरू करता है।\n\nयह इस लोकल फ़ोल्डर को नहीं ले जाता और इसमें पुश न किए गए कमिट, बदली हुई या इग्नोर की गई फ़ाइलें, सीक्रेट या सत्र स्थिति शामिल नहीं होती।\n\nहोस्टेड Work लॉन्चर खोलने के लिए {command} का उपयोग करें।",
|
||||
"CmdRemoteEnvOpening": "{repo} के लिए {branch} ब्रांच पर होस्टेड Work खोला जा रहा है।\n\nयह {origin} के रूप में कॉन्फ़िगर किए गए Git होस्ट पर उपलब्ध ब्रांच टिप से नया एनवायरनमेंट शुरू करता है। केवल लोकल स्थिति शामिल नहीं होती।\n\nअगर ब्राउज़र नहीं खुले, तो इसका उपयोग करें:\n{url}",
|
||||
"CmdRemoteEnvUnavailable": "Codewhale इस फ़ोल्डर के लिए समर्थित GitHub या CNB ओरिजिन और चेक आउट की गई ब्रांच, दोनों का पता नहीं लगा सका। किसी ब्रांच को चेक आउट करें, {origin} को HTTPS या SSH URL से कॉन्फ़िगर करें और फिर {command} आज़माएँ। कुछ भी बनाया या आवंटित नहीं किया गया।",
|
||||
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale लोकल सोर्स को होस्टेड Work में अपलोड, माइग्रेट या सिंक नहीं करता। GitHub या CNB पर उपलब्ध ब्रांच टिप से शुरू करने के लिए {command} का उपयोग करें। पुश न किए गए कमिट, बदली हुई या इग्नोर की गई फ़ाइलें, सीक्रेट और सत्र स्थिति लोकल ही रहती हैं।",
|
||||
"CmdRemoteEnvBrowserLabel": "Codewhale होस्टेड Work",
|
||||
"CmdRenameDescription": "वर्तमान सत्र का नाम बदलें",
|
||||
"CmdRestoreDescription": "वर्कस्पेस को पूर्व pre/post-turn स्नैपशॉट पर वापस लाएँ। बिना आर्ग के हालिया स्नैपशॉट सूचीबद्ध करता है।",
|
||||
"CmdRetryDescription": "अंतिम अनुरोध पुनः प्रयास करें",
|
||||
@@ -288,6 +308,9 @@
|
||||
"CmdRlmDescription": "फ़ाइल या टेक्स्ट के लिए स्थायी RLM संदर्भ खोलें",
|
||||
"CmdSaveDescription": "सत्र को फ़ाइल में सहेजें",
|
||||
"CmdForkDescription": "सक्रिय वार्तालाप को सिबलिंग सत्र में फ़ोर्क करें",
|
||||
"CmdTreeDescription": "सत्र इतिहास को वृक्ष के रूप में दिखाएँ (अंतिम नोड सक्रिय शाखा है)",
|
||||
"CmdBranchDescription": "इतिहास को दोबारा लिखे बिना सक्रिय शाखा को किसी मौजूदा सत्र प्रविष्टि पर ले जाएँ",
|
||||
"CmdResumeDescription": "सत्र फिर से शुरू करें, और चाहें तो निर्यात की गई सत्र JSON फ़ाइल आयात करें",
|
||||
"CmdNewDescription": "नया सहेजा गया सत्र शुरू करें",
|
||||
"CmdSessionsDescription": "सत्र इतिहास चयनकर्ता खोलें",
|
||||
"CmdSettingsDescription": "टाइप किया सेटिंग्स संपादक खोलें",
|
||||
@@ -583,7 +606,7 @@
|
||||
"SetupConstitutionExistingLabel": "मौजूदा फ़ाइल:",
|
||||
"SetupConstitutionExpertOverrideLabel": "विशेषज्ञ ओवरराइड:",
|
||||
"SetupConstitutionGuidedHint": "1-6 मसौदा समायोजित करते हैं, G पूर्वावलोकन, फिर G अंगीकार। केवल मार्गदर्शन: यह रनटाइम अनुमति, सैंडबॉक्स, शेल, नेटवर्क, ट्रस्ट या MCP अनुमतियाँ कभी नहीं बदलता।",
|
||||
"SetupConstitutionGuidedAnswersHint": "निर्देशित उत्तर केवल उपयोगकर्ता-वैश्विक प्राथमिकताएँ सहेजते हैं। bundled कोर सक्रिय रहता है; सिद्धांत मॉड्यूल मोड प्रॉम्प्ट/भविष्य के opt-in में रहते हैं।",
|
||||
"SetupConstitutionGuidedAnswersHint": "निर्देशित उत्तर केवल उपयोगकर्ता-वैश्विक प्राथमिकताएँ सहेजते हैं। bundled कोर सक्रिय रहता है; निष्पादन रनटाइम नीति और भविष्य के opt-in के पास रहता है।",
|
||||
"SetupConstitutionPurposeLabel": "उद्देश्य:",
|
||||
"SetupConstitutionAutonomyLabel": "पहल:",
|
||||
"SetupConstitutionEvidenceLabel": "साक्ष्य:",
|
||||
@@ -706,7 +729,7 @@
|
||||
"CtxMenuHelp": "मदद",
|
||||
"CtxMenuHelpDesc": "कीबाइंडिंग और कमांड",
|
||||
"FanoutCounts": "{done} पूर्ण · {running} चल रहे · {failed} विफल · {pending} लंबित",
|
||||
"AppModeAgent": "Act",
|
||||
"AppModeAgent": "Work",
|
||||
"AppModeAuto": "Auto",
|
||||
"AppModeYolo": "Full Access (अप्रचलित मोड लेबल)",
|
||||
"AppModePlan": "Plan",
|
||||
@@ -768,6 +791,13 @@
|
||||
"ElevationOptionWriteDesc": "अतिरिक्त लिखने योग्य फ़ाइलसिस्टम दायरे के साथ यह टूल कॉल फिर आज़माएँ",
|
||||
"ElevationOptionFullAccessDesc": "सैंडबॉक्स सीमाओं के बिना फिर आज़माएँ; अप्रतिबंधित फ़ाइलसिस्टम और नेटवर्क एक्सेस देता है",
|
||||
"ElevationOptionAbortDesc": "यह टूल निष्पादन रद्द करें",
|
||||
"ContextAutoCompacting": "संदर्भ अपने आप संपीड़ित किया जा रहा है…",
|
||||
"ContextManualCompacting": "संदर्भ संपीड़ित किया जा रहा है…",
|
||||
"ContextCompactionQueued": "संदर्भ संपीड़न कतार में लगा दिया गया है; यह सक्रिय टर्न के बाद चलेगा।",
|
||||
"ContextCompactionAlreadyRunning": "संदर्भ संपीड़न पहले से जारी है।",
|
||||
"ContextCompactionQueueFull": "इंजन व्यस्त होने के कारण संदर्भ संपीड़न को कतार में नहीं लगाया जा सका; टर्न पूरा होने के बाद फिर कोशिश करें।",
|
||||
"ContextCompactionQueueClosed": "संदर्भ संपीड़न उपलब्ध नहीं है क्योंकि इंजन अब नहीं चल रहा है।",
|
||||
"ContextCompactionRouteInvalid": "संपीड़ित नहीं किया जा सकता क्योंकि सक्रिय प्रोवाइडर रूट अमान्य है: {error}",
|
||||
"CtxInspTitle": "कॉन्टेक्स्ट इंस्पेक्टर",
|
||||
"CtxInspSessionContext": "सत्र कॉन्टेक्स्ट",
|
||||
"CtxInspSystemPrompt": "सिस्टम प्रॉम्प्ट संरचना",
|
||||
@@ -969,7 +999,7 @@
|
||||
"PhaseDone": "पूर्ण",
|
||||
"PhaseFailed": "विफल",
|
||||
"PhaseFinishing": "समापन हो रहा",
|
||||
"ChipModeAct": "act",
|
||||
"ChipModeAct": "work",
|
||||
"ChipModePlan": "plan",
|
||||
"ChipModeOperate": "operate",
|
||||
"ChipPermissionReadOnly": "केवल पठन",
|
||||
@@ -1203,6 +1233,8 @@
|
||||
"BehavioralTipClearedInput": "साफ़ हुआ · {chord} से पुनर्स्थापित",
|
||||
"BehavioralTipMcpValidation": "{command} सर्वर शुरू करता है और कारण दिखाता है",
|
||||
"BehavioralTipRepeatedCommand": "{command} इसे पिन कर सकता है",
|
||||
"BehavioralTipDurableStateWritten": "सहेजा गया · {command} से देखें",
|
||||
"BehavioralTipTodoWrite": "सुझाव: कई चरणों वाले काम को {command} से ट्रैक करें—इससे प्रगति दिखाई देती रहती है",
|
||||
"SettingLockedDuringTurn": "टर्न चलने के दौरान {setting} लॉक है — पहले Esc से बाधित करें",
|
||||
"SettingSubjectMode": "मोड",
|
||||
"SettingSubjectThinking": "थिंकिंग",
|
||||
|
||||
@@ -9,6 +9,19 @@
|
||||
"HistoryHintAccept": "Enter terima",
|
||||
"HistoryHintRestore": "Esc pulihkan",
|
||||
"HistoryNoMatches": " Tidak ada hasil",
|
||||
"TranscriptReasoningExpand": "perluas",
|
||||
"TelemetryNoticeHeadline": "Bantu tingkatkan Codewhale?",
|
||||
"TelemetryNoticeBody": "Codewhale menghitung: versi yang dijalankan, sistem operasi dan keluarga CPU,\ndurasi dan hasil sesi, serta hitungan agregat fitur dan galat.\n\nIa tidak pernah mengumpulkan percakapan, kode, prompt, berkas, nama berkas,\nrepositori atau cabang, konten model, maupun kredensial Anda — dan tidak pernah\nmengirim linimasa aktivitas agen per giliran atau per alat.\n\nAnda hanya dikenali melalui ID acak yang disimpan di mesin ini, diganti setiap\n90 hari. Ubah keputusan kapan saja:\n codewhale config set telemetry false\n\nSkema lengkap, per bidang: docs/TELEMETRY.md",
|
||||
"TelemetryNoticeCompactBody": "Hitungan: versi, sistem/CPU, durasi/hasil, serta total fitur/galat.\nID lokal acak diganti setiap 90 hari.\nTidak pernah: percakapan/kode/prompt/berkas/nama, konten model/kredensial, giliran/alat.\nSkema: docs/TELEMETRY.md\nNonaktifkan: codewhale config set telemetry false",
|
||||
"TelemetryNoticeChoiceKeep": "Ya, pertahankan penghitungan anonim",
|
||||
"TelemetryNoticeChoiceDisable": "Tidak, matikan pelacakan",
|
||||
"TelemetryNoticeActionChoose": "pilih",
|
||||
"TelemetryNoticeActionConfirm": "konfirmasi",
|
||||
"TelemetryNoticeActionExit": "keluar",
|
||||
"TelemetryNoticeReceiptEnabled": "Ya, penghitungan anonim tetap aktif.",
|
||||
"TelemetryNoticeReceiptDisabled": "Penghitungan penggunaan anonim dinonaktifkan. Anda tidak akan ditanya lagi.",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "Penghitungan anonim tetap aktif untuk sesi ini. Codewhale tidak dapat menyimpan pilihan, sehingga akan bertanya lagi saat peluncuran berikutnya.",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "Penghitungan penggunaan anonim dinonaktifkan untuk sesi ini. Codewhale tidak dapat menyimpan pilihan, sehingga akan bertanya lagi saat peluncuran berikutnya.",
|
||||
"StatusPickerTitle": " Baris status ",
|
||||
"StatusPickerInstruction": "Pilih chip yang ingin ditampilkan di bilah bawah:",
|
||||
"StatusPickerActionToggle": "alihkan ",
|
||||
@@ -42,7 +55,7 @@
|
||||
"HotbarActionSessionCompactDescription": "Padatkan konteks percakapan saat ini.",
|
||||
"HotbarActionModePlanName": "Mode Plan",
|
||||
"HotbarActionModePlanDescription": "Susun rencana sebelum bertindak.",
|
||||
"HotbarActionModeAgentName": "Mode Act",
|
||||
"HotbarActionModeAgentName": "Mode Work",
|
||||
"HotbarActionModeAgentDescription": "Bekerja langsung di sesi saat ini.",
|
||||
"HotbarActionModeYoloName": "Full Access (Act)",
|
||||
"HotbarActionModeYoloDescription": "Kompatibilitas: Act dengan izin Full Access (bukan mode terpisah).",
|
||||
@@ -259,6 +272,7 @@
|
||||
"CmdModelsDescription": "Ambil ID model langsung dari API aktif",
|
||||
"CmdModelDbDescription": "Telusuri database model bawaan",
|
||||
"CmdNetworkDescription": "Kelola aturan izinkan dan tolak jaringan",
|
||||
"CmdUpdateDescription": "Periksa dan pasang rilis CodeWhale baru",
|
||||
"CmdNoteDescription": "Tambah, tampilkan, edit, atau hapus catatan workspace",
|
||||
"CmdThemeDescription": "Ganti tema atau buka pemilih tema",
|
||||
"CmdProviderDescription": "Ganti penyedia dan/atau model aktif",
|
||||
@@ -281,6 +295,12 @@
|
||||
"CmdQueueIndexMin": "Indeks harus >= 1",
|
||||
"CmdRelayDescription": "Buat relay sesi (接力) untuk thread baru",
|
||||
"CmdRemoteControlDescription": "Lanjutkan sesi persis ini dari akun web Codewhale Anda",
|
||||
"CmdRemoteEnvDescription": "Buka Work terhosting baru dari ujung branch GitHub atau CNB",
|
||||
"CmdRemoteEnvOverview": "Work terhosting memulai lingkungan baru dari ujung branch yang tersedia di GitHub atau CNB.\n\nTindakan ini tidak memindahkan folder lokal ini atau menyertakan commit yang belum di-push, file yang berubah atau diabaikan, rahasia, maupun status sesi.\n\nGunakan {command} untuk membuka peluncur Work terhosting.",
|
||||
"CmdRemoteEnvOpening": "Membuka Work terhosting untuk {repo} pada branch {branch}.\n\nTindakan ini memulai lingkungan baru dari ujung branch yang tersedia di host Git yang dikonfigurasi sebagai {origin}. Status yang hanya ada secara lokal tidak disertakan.\n\nJika browser tidak terbuka, gunakan:\n{url}",
|
||||
"CmdRemoteEnvUnavailable": "Codewhale tidak dapat menemukan sekaligus origin GitHub atau CNB yang didukung dan branch aktif untuk folder ini. Checkout sebuah branch dan konfigurasikan {origin} dengan URL HTTPS atau SSH, lalu coba {command} lagi. Tidak ada yang dibuat atau dialokasikan.",
|
||||
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale tidak mengunggah, memigrasikan, atau menyinkronkan kode sumber lokal ke Work terhosting. Gunakan {command} untuk memulai dari ujung branch yang tersedia di GitHub atau CNB. Commit yang belum di-push, file yang berubah atau diabaikan, rahasia, dan status sesi tetap lokal.",
|
||||
"CmdRemoteEnvBrowserLabel": "Work terhosting Codewhale",
|
||||
"CmdRenameDescription": "Ubah nama sesi saat ini",
|
||||
"CmdRestoreDescription": "Kembalikan workspace ke snapshot pra/pasca-giliran sebelumnya. Tanpa argumen, menampilkan snapshot terbaru.",
|
||||
"CmdRetryDescription": "Coba lagi permintaan terakhir",
|
||||
@@ -288,6 +308,9 @@
|
||||
"CmdRlmDescription": "Buka konteks RLM persisten untuk file atau teks",
|
||||
"CmdSaveDescription": "Simpan sesi ke file",
|
||||
"CmdForkDescription": "Fork percakapan aktif ke sesi saudara",
|
||||
"CmdTreeDescription": "Tampilkan riwayat sesi sebagai pohon (daun adalah cabang aktif)",
|
||||
"CmdBranchDescription": "Pindahkan cabang aktif ke entri sesi yang ada tanpa menulis ulang riwayat",
|
||||
"CmdResumeDescription": "Lanjutkan sesi, dengan opsi mengimpor file JSON sesi yang diekspor",
|
||||
"CmdNewDescription": "Mulai sesi tersimpan baru",
|
||||
"CmdSessionsDescription": "Buka pemilih riwayat sesi",
|
||||
"CmdSettingsDescription": "Buka editor pengaturan bertipe",
|
||||
@@ -583,7 +606,7 @@
|
||||
"SetupConstitutionExistingLabel": "File yang ada:",
|
||||
"SetupConstitutionExpertOverrideLabel": "Override ahli:",
|
||||
"SetupConstitutionGuidedHint": "1-6 menyesuaikan draf, G pratinjau, G lagi meratifikasi. Hanya panduan: tidak pernah mengubah persetujuan runtime, sandbox, shell, jaringan, kepercayaan, atau izin MCP.",
|
||||
"SetupConstitutionGuidedAnswersHint": "Jawaban terpandu hanya menyimpan preferensi global pengguna. Inti bawaan tetap aktif; modul doktrin tetap di prompt mode/opt-in mendatang.",
|
||||
"SetupConstitutionGuidedAnswersHint": "Jawaban terpandu hanya menyimpan preferensi global pengguna. Inti bawaan tetap aktif; eksekusi berada di kebijakan runtime dan opt-in mendatang.",
|
||||
"SetupConstitutionPurposeLabel": "Tujuan:",
|
||||
"SetupConstitutionAutonomyLabel": "Inisiatif:",
|
||||
"SetupConstitutionEvidenceLabel": "Bukti:",
|
||||
@@ -706,7 +729,7 @@
|
||||
"CtxMenuHelp": "Bantuan",
|
||||
"CtxMenuHelpDesc": "keybinding dan perintah",
|
||||
"FanoutCounts": "{done} selesai · {running} berjalan · {failed} gagal · {pending} menunggu",
|
||||
"AppModeAgent": "Act",
|
||||
"AppModeAgent": "Work",
|
||||
"AppModeAuto": "Auto",
|
||||
"AppModeYolo": "Akses penuh (label mode usang)",
|
||||
"AppModePlan": "Plan",
|
||||
@@ -768,6 +791,13 @@
|
||||
"ElevationOptionWriteDesc": "Coba lagi panggilan tool ini dengan cakupan filesystem tulis tambahan",
|
||||
"ElevationOptionFullAccessDesc": "Coba lagi tanpa batas sandbox; memberikan akses filesystem dan jaringan tanpa batas",
|
||||
"ElevationOptionAbortDesc": "Batalkan eksekusi tool ini",
|
||||
"ContextAutoCompacting": "Konteks sedang dipadatkan secara otomatis…",
|
||||
"ContextManualCompacting": "Sedang memadatkan konteks…",
|
||||
"ContextCompactionQueued": "Pemadatan konteks telah masuk antrean; proses akan berjalan setelah giliran aktif selesai.",
|
||||
"ContextCompactionAlreadyRunning": "Pemadatan konteks sedang berlangsung.",
|
||||
"ContextCompactionQueueFull": "Pemadatan konteks tidak dapat dimasukkan ke antrean karena mesin sedang sibuk; coba lagi setelah giliran selesai.",
|
||||
"ContextCompactionQueueClosed": "Pemadatan konteks tidak tersedia karena mesin tidak lagi berjalan.",
|
||||
"ContextCompactionRouteInvalid": "Tidak dapat memadatkan karena rute penyedia aktif tidak valid: {error}",
|
||||
"CtxInspTitle": "Inspektor konteks",
|
||||
"CtxInspSessionContext": "Konteks Sesi",
|
||||
"CtxInspSystemPrompt": "Struktur System Prompt",
|
||||
@@ -969,7 +999,7 @@
|
||||
"PhaseDone": "selesai",
|
||||
"PhaseFailed": "gagal",
|
||||
"PhaseFinishing": "menyelesaikan",
|
||||
"ChipModeAct": "act",
|
||||
"ChipModeAct": "work",
|
||||
"ChipModePlan": "plan",
|
||||
"ChipModeOperate": "operate",
|
||||
"ChipPermissionReadOnly": "baca saja",
|
||||
@@ -1203,6 +1233,8 @@
|
||||
"BehavioralTipClearedInput": "Terhapus · {chord} memulihkan",
|
||||
"BehavioralTipMcpValidation": "{command} memulai server dan menunjukkan alasannya",
|
||||
"BehavioralTipRepeatedCommand": "{command} dapat menyematkan ini",
|
||||
"BehavioralTipDurableStateWritten": "Tersimpan · {command} untuk melihat",
|
||||
"BehavioralTipTodoWrite": "Tips: lacak pekerjaan bertahap dengan {command} agar progres tetap terlihat",
|
||||
"SettingLockedDuringTurn": "{setting} terkunci saat giliran berjalan — tekan Esc untuk menginterupsi dulu",
|
||||
"SettingSubjectMode": "Mode",
|
||||
"SettingSubjectThinking": "Thinking",
|
||||
|
||||
@@ -9,6 +9,19 @@
|
||||
"HistoryHintAccept": "Enter 確定",
|
||||
"HistoryHintRestore": "Esc 復元",
|
||||
"HistoryNoMatches": " 一致なし",
|
||||
"TranscriptReasoningExpand": "展開",
|
||||
"TelemetryNoticeHeadline": "Codewhale の改善にご協力いただけますか?",
|
||||
"TelemetryNoticeBody": "Codewhale が集計するのは次の情報です。実行中のバージョン、OS と CPU の種類、\nセッション時間と結果、機能およびエラーの集計カウンター。\n\n会話、コード、プロンプト、ファイル、リポジトリ名やブランチ名、モデルの内容、\n認証情報は収集しません。また、ターンごと、ツールごとのエージェント活動の\n履歴も送信しません。\n\n識別には、このマシンに保存されたランダム ID のみを使用します。この ID は\n90 日ごとに置き換えられます。あとからいつでも変更できます:\n codewhale config set telemetry false\n\n全フィールドのスキーマ: docs/TELEMETRY.md",
|
||||
"TelemetryNoticeCompactBody": "集計: 版、OS/CPU、時間/結果、機能/エラー。\nローカルのランダムIDは90日ごとに更新。\n収集なし: 会話/コード/プロンプト/ファイル/名前、モデル内容/認証情報、ターン/ツール。\nスキーマ: docs/TELEMETRY.md\n無効: codewhale config set telemetry false",
|
||||
"TelemetryNoticeChoiceKeep": "はい、匿名の利用状況集計を続けます",
|
||||
"TelemetryNoticeChoiceDisable": "いいえ、利用状況集計をオフにします",
|
||||
"TelemetryNoticeActionChoose": "選択",
|
||||
"TelemetryNoticeActionConfirm": "確定",
|
||||
"TelemetryNoticeActionExit": "終了",
|
||||
"TelemetryNoticeReceiptEnabled": "はい。匿名の利用状況集計はオンのままです。",
|
||||
"TelemetryNoticeReceiptDisabled": "匿名の利用状況集計はオフです。今後は確認しません。",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "このセッションでは匿名の利用状況集計はオンのままです。Codewhale は選択を保存できなかったため、次回の起動時に再度確認します。",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "このセッションでは匿名の利用状況集計がオフです。Codewhale は選択を保存できなかったため、次回の起動時に再度確認します。",
|
||||
"StatusPickerTitle": " ステータス行 ",
|
||||
"StatusPickerInstruction": "フッターに表示する項目を選択:",
|
||||
"StatusPickerActionToggle": "切替 ",
|
||||
@@ -42,7 +55,7 @@
|
||||
"HotbarActionSessionCompactDescription": "現在の会話コンテキストを圧縮します。",
|
||||
"HotbarActionModePlanName": "Plan モード",
|
||||
"HotbarActionModePlanDescription": "実行前に計画を立てます。",
|
||||
"HotbarActionModeAgentName": "Act モード",
|
||||
"HotbarActionModeAgentName": "Work モード",
|
||||
"HotbarActionModeAgentDescription": "現在のセッションで直接作業します。",
|
||||
"HotbarActionModeYoloName": "フルアクセス (Act)",
|
||||
"HotbarActionModeYoloDescription": "互換: Act + フルアクセス権限(独立モードではありません)。",
|
||||
@@ -262,6 +275,7 @@
|
||||
"CmdModelsDescription": "アクティブな API からライブのモデル ID を取得する",
|
||||
"CmdModelDbDescription": "内蔵のモデルデータベースを閲覧する",
|
||||
"CmdNetworkDescription": "ネットワーク許可・拒否ルールを管理",
|
||||
"CmdUpdateDescription": "新しい CodeWhale リリースを確認してインストールします",
|
||||
"CmdNoteDescription": "ワークスペースノートの追加、一覧、編集、削除",
|
||||
"CmdThemeDescription": "テーマを切り替え(ダーク/ライト/グレースケール/システム)",
|
||||
"CmdProviderDescription": "現在の LLM バックエンドを切り替え・確認(deepseek | nvidia-nim | ollama)",
|
||||
@@ -284,6 +298,12 @@
|
||||
"CmdQueueIndexMin": "インデックスは 1 以上である必要があります",
|
||||
"CmdRelayDescription": "新しいスレッド用のセッションリレー(接力)を作成",
|
||||
"CmdRemoteControlDescription": "Codewhaleウェブアカウントからこのセッションを再開",
|
||||
"CmdRemoteEnvDescription": "GitHub または CNB のブランチ先端から新しいホスト型 Work を開く",
|
||||
"CmdRemoteEnvOverview": "ホスト型 Work は、GitHub または CNB で利用可能なブランチ先端から新しい環境を開始します。\n\nこのローカルフォルダーは移動されず、未プッシュのコミット、変更済みまたは無視対象のファイル、シークレット、セッション状態も含まれません。\n\n{command} でホスト型 Work のランチャーを開きます。",
|
||||
"CmdRemoteEnvOpening": "{repo} のブランチ {branch} 用にホスト型 Work を開いています。\n\n{origin} に設定された Git ホストで利用可能なブランチ先端から新しい環境を開始します。ローカルにしかない状態は含まれません。\n\nブラウザーが開かない場合は、次を使用してください:\n{url}",
|
||||
"CmdRemoteEnvUnavailable": "このフォルダーについて、対応する GitHub または CNB の origin とチェックアウト済みブランチの両方を解決できませんでした。ブランチをチェックアウトし、{origin} に HTTPS または SSH URL を設定してから、{command} を再試行してください。何も作成または割り当てられていません。",
|
||||
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale はローカルソースをホスト型 Work にアップロード、移行、同期しません。{command} を使用して、GitHub または CNB で利用可能なブランチ先端から開始してください。未プッシュのコミット、変更済みまたは無視対象のファイル、シークレット、セッション状態はローカルに残ります。",
|
||||
"CmdRemoteEnvBrowserLabel": "Codewhale ホスト型 Work",
|
||||
"CmdRenameDescription": "現在のセッションの名前を変更",
|
||||
"CmdRestoreDescription": "ワークスペースを以前のターン前/後スナップショットへロールバック。引数なしで最近のスナップショットを一覧表示。",
|
||||
"CmdRetryDescription": "直前のリクエストを再試行",
|
||||
@@ -291,6 +311,9 @@
|
||||
"CmdRlmDescription": "ファイルまたはテキスト用の永続 RLM コンテキストを開く",
|
||||
"CmdSaveDescription": "セッションをファイルに保存",
|
||||
"CmdForkDescription": "現在の会話を兄弟セッションに fork",
|
||||
"CmdTreeDescription": "セッション履歴をツリー表示(末端がアクティブなブランチ)",
|
||||
"CmdBranchDescription": "履歴を書き換えず、アクティブなブランチを既存のセッション項目へ移動",
|
||||
"CmdResumeDescription": "セッションを再開(エクスポート済みセッション JSON の読み込みにも対応)",
|
||||
"CmdNewDescription": "新しい保存済みセッションを開始",
|
||||
"CmdSessionsDescription": "セッション履歴ピッカーを開く",
|
||||
"CmdSettingsDescription": "型付き設定エディターを開く",
|
||||
@@ -606,7 +629,7 @@
|
||||
"SetupConstitutionExistingLabel": "既存ファイル:",
|
||||
"SetupConstitutionExpertOverrideLabel": "上級者オーバーライド:",
|
||||
"SetupConstitutionGuidedHint": "1-6 で草案を調整、G でプレビュー、もう一度 G で承認します。これは指針だけであり、実行時の承認、サンドボックス、シェル、ネットワーク、信頼、MCP 権限は変更しません。",
|
||||
"SetupConstitutionGuidedAnswersHint": "ガイド回答はユーザーグローバル設定だけを保存します。同梱コアは有効のまま、doctrine モジュールはモードプロンプト/将来の opt-in に残ります。",
|
||||
"SetupConstitutionGuidedAnswersHint": "ガイド回答はユーザーグローバル設定だけを保存します。同梱コアは有効のまま、実行はランタイムポリシーと将来の opt-in が担います。",
|
||||
"SetupConstitutionPurposeLabel": "目的:",
|
||||
"SetupConstitutionAutonomyLabel": "主体性:",
|
||||
"SetupConstitutionEvidenceLabel": "根拠:",
|
||||
@@ -729,7 +752,7 @@
|
||||
"CtxMenuHelp": "ヘルプ",
|
||||
"CtxMenuHelpDesc": "キー操作とコマンド",
|
||||
"FanoutCounts": "{done} 完了 · {running} 実行中 · {failed} 失敗 · {pending} 保留",
|
||||
"AppModeAgent": "Act",
|
||||
"AppModeAgent": "Work",
|
||||
"AppModeAuto": "自動",
|
||||
"AppModeYolo": "フルアクセス(非推奨ラベル)",
|
||||
"AppModePlan": "Plan",
|
||||
@@ -789,6 +812,13 @@
|
||||
"ElevationOptionWriteDesc": "追加の書き込み可能ファイルシステム範囲で再試行",
|
||||
"ElevationOptionFullAccessDesc": "サンドボックス制限なしで再試行(ファイルシステムとネットワークへの無制限アクセス)",
|
||||
"ElevationOptionAbortDesc": "このツール実行をキャンセル",
|
||||
"ContextAutoCompacting": "コンテキストを自動圧縮しています…",
|
||||
"ContextManualCompacting": "コンテキストを圧縮しています…",
|
||||
"ContextCompactionQueued": "コンテキスト圧縮をキューに追加しました。アクティブなターンの完了後に実行されます。",
|
||||
"ContextCompactionAlreadyRunning": "コンテキスト圧縮はすでに進行中です。",
|
||||
"ContextCompactionQueueFull": "エンジンがビジーなため、コンテキスト圧縮をキューに追加できませんでした。ターンの完了後にもう一度お試しください。",
|
||||
"ContextCompactionQueueClosed": "エンジンがすでに停止しているため、コンテキスト圧縮を利用できません。",
|
||||
"ContextCompactionRouteInvalid": "アクティブなプロバイダールートが無効なため圧縮できません: {error}",
|
||||
"CtxInspTitle": "コンテキストインスペクタ",
|
||||
"CtxInspSessionContext": "セッションコンテキスト",
|
||||
"CtxInspSystemPrompt": "システムプロンプト構造",
|
||||
@@ -992,7 +1022,7 @@
|
||||
"PhaseDone": "完了",
|
||||
"PhaseFailed": "失敗",
|
||||
"PhaseFinishing": "仕上げ中",
|
||||
"ChipModeAct": "act",
|
||||
"ChipModeAct": "work",
|
||||
"ChipModePlan": "plan",
|
||||
"ChipModeOperate": "operate",
|
||||
"ChipPermissionReadOnly": "読み取り専用",
|
||||
@@ -1226,6 +1256,8 @@
|
||||
"BehavioralTipClearedInput": "クリアしました · {chord} で復元",
|
||||
"BehavioralTipMcpValidation": "{command} はサーバーを起動し、原因を表示します",
|
||||
"BehavioralTipRepeatedCommand": "{command} でこれを固定できます",
|
||||
"BehavioralTipDurableStateWritten": "保存しました · {command} で確認",
|
||||
"BehavioralTipTodoWrite": "ヒント: 複数ステップの作業は {command} で追跡すると、進捗を確認できます",
|
||||
"SettingLockedDuringTurn": "ターンの実行中は{setting}を変更できません。まず Esc で中断してください",
|
||||
"SettingSubjectMode": "モード",
|
||||
"SettingSubjectThinking": "思考",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user