diff --git a/Cargo.lock b/Cargo.lock index 96ab598..31e5b32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -925,7 +925,7 @@ checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" [[package]] name = "code-ansi-escape" -version = "0.0.0" +version = "0.6.106" dependencies = [ "ansi-to-tui", "ratatui", @@ -934,7 +934,7 @@ dependencies = [ [[package]] name = "code-app-server" -version = "0.0.0" +version = "0.6.106" dependencies = [ "anyhow", "assert_cmd", @@ -965,7 +965,7 @@ dependencies = [ [[package]] name = "code-app-server-protocol" -version = "0.0.0" +version = "0.6.106" dependencies = [ "anyhow", "clap", @@ -988,7 +988,7 @@ dependencies = [ [[package]] name = "code-apply-patch" -version = "0.0.0" +version = "0.6.106" dependencies = [ "anyhow", "assert_cmd", @@ -1002,7 +1002,7 @@ dependencies = [ [[package]] name = "code-arg0" -version = "0.0.0" +version = "0.6.106" dependencies = [ "anyhow", "code-apply-patch", @@ -1015,7 +1015,7 @@ dependencies = [ [[package]] name = "code-auto-drive-core" -version = "0.0.0" +version = "0.6.106" dependencies = [ "anyhow", "chrono", @@ -1040,7 +1040,7 @@ dependencies = [ [[package]] name = "code-auto-drive-diagnostics" -version = "0.0.0" +version = "0.6.106" dependencies = [ "anyhow", "code-auto-drive-core", @@ -1067,7 +1067,7 @@ dependencies = [ [[package]] name = "code-backend-openapi-models" -version = "0.0.0" +version = "0.6.106" dependencies = [ "serde", "serde_json", @@ -1075,7 +1075,7 @@ dependencies = [ [[package]] name = "code-browser" -version = "0.0.0" +version = "0.6.106" dependencies = [ "anyhow", "async-trait", @@ -1103,7 +1103,7 @@ dependencies = [ [[package]] name = "code-chatgpt" -version = "0.0.0" +version = "0.6.106" dependencies = [ "anyhow", "clap", @@ -1120,7 +1120,7 @@ dependencies = [ [[package]] name = "code-cli" -version = "0.0.0" +version = "0.6.106" dependencies = [ "anyhow", "chrono", @@ -1166,7 +1166,7 @@ dependencies = [ [[package]] name = "code-cloud-tasks" -version = "0.0.0" +version = "0.6.106" dependencies = [ "anyhow", "async-trait", @@ -1194,7 +1194,7 @@ dependencies = [ [[package]] name = "code-cloud-tasks-client" -version = "0.0.0" +version = "0.6.106" dependencies = [ "anyhow", "async-trait", @@ -1209,7 +1209,7 @@ dependencies = [ [[package]] name = "code-common" -version = "0.0.0" +version = "0.6.106" dependencies = [ "clap", "code-app-server-protocol", @@ -1222,7 +1222,7 @@ dependencies = [ [[package]] name = "code-core" -version = "0.0.0" +version = "0.6.106" dependencies = [ "agent-client-protocol", "anyhow", @@ -1309,7 +1309,7 @@ dependencies = [ [[package]] name = "code-exec" -version = "0.0.0" +version = "0.6.106" dependencies = [ "anyhow", "chrono", @@ -1322,6 +1322,7 @@ dependencies = [ "code-git-tooling", "code-ollama", "code-protocol", + "code-version", "codex-utils-rustls-provider", "filetime", "libc", @@ -1336,12 +1337,13 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "ts-rs", "uuid", ] [[package]] name = "code-execpolicy" -version = "0.0.0" +version = "0.6.106" dependencies = [ "allocative", "anyhow", @@ -1361,7 +1363,7 @@ dependencies = [ [[package]] name = "code-file-search" -version = "0.0.0" +version = "0.6.106" dependencies = [ "anyhow", "clap", @@ -1374,7 +1376,7 @@ dependencies = [ [[package]] name = "code-git-apply" -version = "0.0.0" +version = "0.6.106" dependencies = [ "once_cell", "regex", @@ -1383,7 +1385,7 @@ dependencies = [ [[package]] name = "code-git-tooling" -version = "0.0.0" +version = "0.6.106" dependencies = [ "pretty_assertions", "schemars 0.8.22", @@ -1397,7 +1399,7 @@ dependencies = [ [[package]] name = "code-linux-sandbox" -version = "0.0.0" +version = "0.6.106" dependencies = [ "clap", "code-core", @@ -1410,7 +1412,7 @@ dependencies = [ [[package]] name = "code-login" -version = "0.0.0" +version = "0.6.106" dependencies = [ "base64 0.22.1", "chrono", @@ -1432,7 +1434,7 @@ dependencies = [ [[package]] name = "code-mcp-client" -version = "0.0.0" +version = "0.6.106" dependencies = [ "anyhow", "code-mcp-types", @@ -1445,7 +1447,7 @@ dependencies = [ [[package]] name = "code-mcp-server" -version = "0.0.0" +version = "0.6.106" dependencies = [ "agent-client-protocol", "anyhow", @@ -1471,7 +1473,7 @@ dependencies = [ [[package]] name = "code-mcp-types" -version = "0.0.0" +version = "0.6.106" dependencies = [ "mcp-types", "serde_json", @@ -1479,7 +1481,7 @@ dependencies = [ [[package]] name = "code-ollama" -version = "0.0.0" +version = "0.6.106" dependencies = [ "async-stream", "bytes", @@ -1494,7 +1496,7 @@ dependencies = [ [[package]] name = "code-otel" -version = "0.0.0" +version = "0.6.106" dependencies = [ "chrono", "code-app-server-protocol", @@ -1517,14 +1519,14 @@ dependencies = [ [[package]] name = "code-process-hardening" -version = "0.0.0" +version = "0.6.106" dependencies = [ "libc", ] [[package]] name = "code-protocol" -version = "0.0.0" +version = "0.6.106" dependencies = [ "anyhow", "code-execpolicy", @@ -1552,7 +1554,7 @@ dependencies = [ [[package]] name = "code-protocol-ts" -version = "0.0.0" +version = "0.6.106" dependencies = [ "anyhow", "clap", @@ -1564,7 +1566,7 @@ dependencies = [ [[package]] name = "code-responses-api-proxy" -version = "0.0.0" +version = "0.6.106" dependencies = [ "anyhow", "clap", @@ -1581,7 +1583,7 @@ dependencies = [ [[package]] name = "code-rmcp-client" -version = "0.0.0" +version = "0.6.106" dependencies = [ "anyhow", "axum", @@ -1598,7 +1600,7 @@ dependencies = [ [[package]] name = "code-tui" -version = "0.0.0" +version = "0.6.106" dependencies = [ "anyhow", "arboard", @@ -1677,7 +1679,7 @@ dependencies = [ [[package]] name = "code-utils-json-to-toml" -version = "0.0.0" +version = "0.6.106" dependencies = [ "pretty_assertions", "serde_json", @@ -1686,7 +1688,7 @@ dependencies = [ [[package]] name = "code-utils-readiness" -version = "0.0.0" +version = "0.6.106" dependencies = [ "async-trait", "thiserror 2.0.17", @@ -1696,14 +1698,14 @@ dependencies = [ [[package]] name = "code-version" -version = "0.0.0" +version = "0.6.106" dependencies = [ "serde_json", ] [[package]] name = "codex-experimental-api-macros" -version = "0.0.0" +version = "0.6.106" dependencies = [ "proc-macro2", "quote", @@ -1712,7 +1714,7 @@ dependencies = [ [[package]] name = "codex-utils-absolute-path" -version = "0.0.0" +version = "0.6.106" dependencies = [ "dirs", "path-absolutize", @@ -1726,7 +1728,7 @@ dependencies = [ [[package]] name = "codex-utils-cache" -version = "0.0.0" +version = "0.6.106" dependencies = [ "lru", "sha1", @@ -1735,7 +1737,7 @@ dependencies = [ [[package]] name = "codex-utils-cargo-bin" -version = "0.0.0" +version = "0.6.106" dependencies = [ "assert_cmd", "runfiles", @@ -1744,7 +1746,7 @@ dependencies = [ [[package]] name = "codex-utils-image" -version = "0.0.0" +version = "0.6.106" dependencies = [ "base64 0.22.1", "codex-utils-cache", @@ -1756,7 +1758,7 @@ dependencies = [ [[package]] name = "codex-utils-rustls-provider" -version = "0.0.0" +version = "0.6.106" dependencies = [ "rustls", ] @@ -2319,7 +2321,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2559,7 +2561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2659,7 +2661,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.1.2", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2925,7 +2927,7 @@ version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ - "unicode-width 0.2.1", + "unicode-width 0.2.0", ] [[package]] @@ -3627,7 +3629,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4236,7 +4238,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5288,7 +5290,8 @@ dependencies = [ [[package]] name = "ratatui" version = "0.29.0" -source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#9b2ad1298408c45918ee9f8241a6f95498cdbed2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ "bitflags 2.10.0", "cassowary", @@ -5302,7 +5305,7 @@ dependencies = [ "strum 0.26.3", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.1", + "unicode-width 0.2.0", ] [[package]] @@ -5730,7 +5733,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5743,7 +5746,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5801,7 +5804,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6778,7 +6781,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.2", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6845,7 +6848,7 @@ checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ "smawk", "unicode-linebreak", - "unicode-width 0.2.1", + "unicode-width 0.2.0", ] [[package]] @@ -7465,7 +7468,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "911e93158bf80bbc94bad533b2b16e3d711e1132d69a6a6980c3920a63422c19" dependencies = [ "ratatui", - "unicode-width 0.2.1", + "unicode-width 0.2.0", ] [[package]] @@ -7559,9 +7562,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" -version = "0.2.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "unicode-xid" diff --git a/Cargo.toml b/Cargo.toml index c6bbb9e..085b545 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.0.0" +version = "0.6.106" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 @@ -265,17 +265,18 @@ unwrap_used = "deny" ignored = ["openssl-sys", "code-utils-readiness"] [profile.release] -lto = "fat" +# Thin, not fat: a release is five of these binaries built one after another on +# one Mac, and the fat-LTO single-unit link cost ~15 minutes per target for a +# ~10% smaller file. Thin LTO links in a fraction of that. +lto = "thin" # Because we bundle some of these executables with the TypeScript CLI, we # remove everything to make the binary as small as possible. strip = "symbols" - -# See https://github.com/openai/codex/issues/1411 for details. codegen-units = 1 [patch.crates-io] -# ratatui = { path = "../../ratatui" } -ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" } +# Keep the release build reproducible from crates.io. The old private git +# branch was retired and made clean checkouts unable to resolve Cargo.lock. # Custom build profiles used by build-fast.sh [profile.dev-fast] diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 6dda909..840accc 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -1,10 +1,10 @@ -# Crate Migration Guide: codex-rs to code-rs +# Crate Migration Guide: codex-rs to hanzo-dev -This guide documents the process for moving shared crates from `codex-rs` into `code-rs`, making them fully independent. +This guide documents the process for moving shared crates from `codex-rs` into `hanzo-dev`, making them fully independent. ## Overview -The `code-rs` repository currently contains thin wrapper crates that re-export functionality from their `codex-rs` counterparts. This creates a cross-repository dependency via relative paths (`../codex-rs/...`). The migration process moves the implementation into `code-rs` so that the crate owns its code and no longer depends on `codex-rs`. +The `hanzo-dev` repository currently contains thin wrapper crates that re-export functionality from their `codex-rs` counterparts. This creates a cross-repository dependency via relative paths (`../vendor/codex/...`). The migration process moves the implementation into `hanzo-dev` so that the crate owns its code and no longer depends on `codex-rs`. ## Case Study: linux-sandbox Migration @@ -20,23 +20,23 @@ We successfully migrated `code-linux-sandbox` as a representative example. This ### 1. Pre-Migration Analysis Before starting, understand: -- **Source crate location**: `/home/azureuser/code/codex-rs//` -- **Destination crate location**: `/home/azureuser/code/code-rs//` +- **Source crate location**: `/home/azureuser/code/vendor/codex//` +- **Destination crate location**: `/home/azureuser/code/hanzo-dev//` - **Current wrapper structure**: Usually just `pub use codex_::*;` - **Dependencies**: What other crates does this depend on? -- **Consumers**: What crates in code-rs depend on this wrapper? +- **Consumers**: What crates in hanzo-dev depend on this wrapper? ### 2. File Structure Migration -Copy all implementation files from codex-rs to code-rs: +Copy all implementation files from codex-rs to hanzo-dev: ```bash # Source modules (excluding lib.rs which needs special handling) -cp codex-rs//src/*.rs code-rs//src/ +cp vendor/codex//src/*.rs hanzo-dev//src/ # Exception: main.rs will need modification # Tests (if they exist) -cp -r codex-rs//tests/ code-rs//tests/ +cp -r vendor/codex//tests/ hanzo-dev//tests/ ``` **Files to copy:** @@ -104,7 +104,7 @@ pub fn run_main() -> ! { ``` **Key considerations:** -- Copy the exact module structure from `codex-rs//src/lib.rs` +- Copy the exact module structure from `vendor/codex//src/lib.rs` - Keep platform-specific conditional compilation (`#[cfg(...)]`) - Preserve all public exports - Update any codex-specific naming to code-specific @@ -136,10 +136,10 @@ Replace all `codex_*` module references with `code_*` equivalents: ```bash # Example: Update codex_core to code_core -find code-rs//src -name '*.rs' -exec sed -i 's/codex_core/code_core/g' {} + +find hanzo-dev//src -name '*.rs' -exec sed -i 's/codex_core/code_core/g' {} + # Update codex_ to code_ (with underscores) -find code-rs//src -name '*.rs' -exec sed -i 's/codex_linux_sandbox/code_linux_sandbox/g' {} + +find hanzo-dev//src -name '*.rs' -exec sed -i 's/codex_linux_sandbox/code_linux_sandbox/g' {} + ``` **Common replacements:** @@ -156,13 +156,13 @@ find code-rs//src -name '*.rs' -exec sed -i 's/codex_linux_sandbox/code_l Remove the cross-repository dependency reference. -**Location:** `/home/azureuser/code/code-rs/Cargo.toml` +**Location:** `/home/azureuser/code/hanzo-dev/Cargo.toml` **Before:** ```toml [workspace.dependencies] # ... -codex-linux-sandbox = { path = "../codex-rs/linux-sandbox" } +codex-linux-sandbox = { path = "../vendor/codex/linux-sandbox" } # ... ``` @@ -176,7 +176,7 @@ codex-linux-sandbox = { path = "../codex-rs/linux-sandbox" } ```bash # Build the migrated crate -cd /home/azureuser/code/code-rs +cd /home/azureuser/code/hanzo-dev cargo build -p code- # Run tests @@ -197,12 +197,12 @@ cargo build -p Use the provided script for initial file copying: ```bash -cd /home/azureuser/code/code-rs +cd /home/azureuser/code/hanzo-dev ./migrate-crate.sh ``` The script will: -1. Copy source files from codex-rs to code-rs +1. Copy source files from codex-rs to hanzo-dev 2. Copy test files 3. Display the dependencies you need to add 4. Provide a checklist of manual steps @@ -219,7 +219,7 @@ The script will: **Symptom:** Build fails with "cannot find crate" errors. -**Solution:** Ensure ALL dependencies from the codex-rs Cargo.toml are copied to code-rs Cargo.toml, including: +**Solution:** Ensure ALL dependencies from the codex-rs Cargo.toml are copied to hanzo-dev Cargo.toml, including: - Platform-specific dependencies (`[target.'cfg(...)'.dependencies]`) - Dev dependencies for tests - Feature flags on dependencies @@ -238,8 +238,8 @@ libc = { workspace = true } **Solution:** Search for ALL occurrences of `codex_` in the migrated crate: ```bash -grep -r "codex_" code-rs//src/ -grep -r "codex_" code-rs//tests/ +grep -r "codex_" hanzo-dev//src/ +grep -r "codex_" hanzo-dev//tests/ ``` Replace with the appropriate `code_*` variant. Common locations: @@ -332,7 +332,7 @@ Crates should be migrated in dependency order. Dependencies must be migrated bef - `cloud-tasks-client` (depends on backend-client) - `responses-api-proxy` (depends on backend-client) -**Note:** Run `grep -r "codex-" code-rs/*/Cargo.toml` to see current wrapper dependencies. +**Note:** Run `grep -r "codex-" hanzo-dev/*/Cargo.toml` to see current wrapper dependencies. ## Verification Checklist @@ -364,14 +364,14 @@ For each crate, ensure you handle: The migration process transforms a thin wrapper crate into a fully independent implementation by: -1. **Copying** implementation files from codex-rs to code-rs +1. **Copying** implementation files from codex-rs to hanzo-dev 2. **Updating** Cargo.toml with actual dependencies (replacing codex-* with code-*) 3. **Replacing** wrapper re-exports with actual module structure in lib.rs 4. **Renaming** namespaces from codex_* to code_* throughout the code 5. **Removing** the codex- dependency from workspace Cargo.toml 6. **Building** and verifying the migrated crate and its dependents -The result is a crate that owns its implementation and no longer depends on `../codex-rs`, making code-rs more independent and easier to maintain. +The result is a crate that owns its implementation and no longer depends on `../codex-rs`, making hanzo-dev more independent and easier to maintain. ## linux-sandbox Migration Results diff --git a/MIGRATION_QUICK_REFERENCE.md b/MIGRATION_QUICK_REFERENCE.md index cba8f0b..ceb98bf 100644 --- a/MIGRATION_QUICK_REFERENCE.md +++ b/MIGRATION_QUICK_REFERENCE.md @@ -1,10 +1,10 @@ -# Quick Reference: Crate Migration from codex-rs to code-rs +# Quick Reference: Crate Migration from codex-rs to hanzo-dev ## TL;DR - Migration Steps ```bash # 1. Use the migration script -cd /home/azureuser/code/code-rs +cd /home/azureuser/code/hanzo-dev ./migrate-crate.sh # 2. Update Cargo.toml (copy deps from codex-rs, replace codex-* with code-*) @@ -21,7 +21,7 @@ find /src -name '*.rs' -exec sed -i 's/codex_core/code_core/g' {} + find /src -name '*.rs' -exec sed -i 's/codex_/code_/g' {} + # 6. Remove from workspace Cargo.toml -vim Cargo.toml # Delete the codex- = { path = "../codex-rs/..." } line +vim Cargo.toml # Delete the codex- = { path = "../vendor/codex/..." } line # 7. Build and verify cargo build -p code- @@ -77,7 +77,7 @@ fn main() -> ! { ## Common Namespace Replacements -| From (codex-rs) | To (code-rs) | +| From (codex-rs) | To (hanzo-dev) | |-----------------|--------------| | `use codex_core::` | `use code_core::` | | `codex_linux_sandbox::` | `code_linux_sandbox::` | @@ -96,7 +96,7 @@ fn main() -> ! { ```bash # Check for remaining codex references -grep -r "codex_" code-rs//src/ +grep -r "codex_" hanzo-dev//src/ # Verify build cargo build -p code- diff --git a/app-server/src/code_message_processor.rs b/app-server/src/code_message_processor.rs index 65cab2b..c27f1ae 100644 --- a/app-server/src/code_message_processor.rs +++ b/app-server/src/code_message_processor.rs @@ -100,6 +100,8 @@ use code_protocol::mcp_protocol::RemoveConversationListenerParams; use code_protocol::mcp_protocol::RemoveConversationSubscriptionResponse; use code_protocol::mcp_protocol::SetDefaultModelParams; use code_protocol::mcp_protocol::SetDefaultModelResponse; +use code_protocol::mcp_protocol::QueueUserMessageParams; +use code_protocol::mcp_protocol::QueueUserMessageResponse; use code_protocol::mcp_protocol::SendUserMessageParams; use code_protocol::mcp_protocol::SendUserMessageResponse; use code_protocol::mcp_protocol::SendUserTurnParams; @@ -207,6 +209,9 @@ impl CodexMessageProcessor { ClientRequest::SendUserMessage { request_id, params } => { self.send_user_message(request_id, params).await; } + ClientRequest::QueueUserMessage { request_id, params } => { + self.queue_user_message(request_id, params).await; + } ClientRequest::InterruptConversation { request_id, params } => { self.interrupt_conversation(request_id, params).await; } @@ -576,11 +581,14 @@ impl CodexMessageProcessor { } } - async fn send_user_message(&self, request_id: RequestId, params: SendUserMessageParams) { - let SendUserMessageParams { - conversation_id, - items, - } = params; + /// Resolve the conversation and map wire items to core items. `None` means + /// the error response has already been sent. + async fn resolve_conversation_items( + &self, + request_id: RequestId, + conversation_id: ConversationId, + items: Vec, + ) -> Option<(Arc, Vec)> { let Ok(conversation) = self .conversation_manager .get_conversation(conversation_id) @@ -592,7 +600,7 @@ impl CodexMessageProcessor { data: None, }; self.outgoing.send_error(request_id, error).await; - return; + return None; }; let mapped_items: Vec = items @@ -604,7 +612,22 @@ impl CodexMessageProcessor { }) .collect(); - // Submit user input to the conversation. + Some((conversation, mapped_items)) + } + + async fn send_user_message(&self, request_id: RequestId, params: SendUserMessageParams) { + let SendUserMessageParams { + conversation_id, + items, + } = params; + let Some((conversation, mapped_items)) = self + .resolve_conversation_items(request_id.clone(), conversation_id, items) + .await + else { + return; + }; + + // Op::UserInput aborts any running task and spawns a new turn. let _ = conversation .submit(Op::UserInput { items: mapped_items, @@ -618,6 +641,35 @@ impl CodexMessageProcessor { .await; } + /// Steer the turn already in flight instead of replacing it. + /// + /// The difference from `send_user_message` is entirely in the Op: + /// `Op::QueueUserInput` folds the items into the running task, where + /// `Op::UserInput` calls `sess.abort()` first. With nothing running, core + /// spawns a turn either way. + async fn queue_user_message(&self, request_id: RequestId, params: QueueUserMessageParams) { + let QueueUserMessageParams { + conversation_id, + items, + } = params; + let Some((conversation, mapped_items)) = self + .resolve_conversation_items(request_id.clone(), conversation_id, items) + .await + else { + return; + }; + + let _ = conversation + .submit(Op::QueueUserInput { + items: mapped_items, + }) + .await; + + self.outgoing + .send_response(request_id, QueueUserMessageResponse {}) + .await; + } + #[allow(dead_code)] async fn send_user_turn(&self, request_id: RequestId, params: SendUserTurnParams) { let SendUserTurnParams { @@ -1963,11 +2015,28 @@ mod tests { ) -> ( CodexMessageProcessor, mpsc::UnboundedReceiver, + ) { + make_processor_with_provider_for_tests(auth_manager, None) + } + + /// `dev`'s default provider is `hanzo`, which sets `requires_openai_auth: + /// false` — so anything exercising the ChatGPT auth path has to ask for the + /// `openai` provider by name rather than rely on the default. + fn make_processor_with_provider_for_tests( + auth_manager: Arc, + model_provider: Option<&str>, + ) -> ( + CodexMessageProcessor, + mpsc::UnboundedReceiver, ) { let (outgoing_tx, outgoing_rx) = mpsc::unbounded_channel(); let outgoing = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let overrides = ConfigOverrides { + model_provider: model_provider.map(str::to_string), + ..ConfigOverrides::default() + }; let config = Arc::new( - Config::load_with_cli_overrides(Vec::new(), ConfigOverrides::default()) + Config::load_with_cli_overrides(Vec::new(), overrides) .expect("load default config"), ); let conversation_manager = Arc::new(ConversationManager::new( @@ -2061,7 +2130,8 @@ mod tests { RefreshTokenError::permanent("refresh token already used"), ); - let (processor, mut outgoing_rx) = make_processor_with_auth_for_tests(auth_manager); + let (processor, mut outgoing_rx) = + make_processor_with_provider_for_tests(auth_manager, Some("openai")); processor .get_auth_status( RequestId::Integer(42), diff --git a/app-server-protocol/schema/json/ClientRequest.json b/app-server-protocol/schema/json/ClientRequest.json index 77046d3..db51cd1 100644 --- a/app-server-protocol/schema/json/ClientRequest.json +++ b/app-server-protocol/schema/json/ClientRequest.json @@ -1486,6 +1486,25 @@ ], "type": "string" }, + "QueueUserMessageParams": { + "description": "Steer a turn that is already running instead of replacing it.\n\n`sendUserMessage` submits `Op::UserInput`, which aborts the running task and spawns a new one — the caller can interrupt, never redirect. This submits `Op::QueueUserInput`, which folds the items into the turn already in flight. With no turn running the two are equivalent.", + "properties": { + "conversationId": { + "$ref": "#/definitions/ThreadId" + }, + "items": { + "items": { + "$ref": "#/definitions/InputItem" + }, + "type": "array" + } + }, + "required": [ + "conversationId", + "items" + ], + "type": "object" + }, "ReasoningEffort": { "description": "A non-empty reasoning effort value advertised by the model.", "minLength": 1, @@ -4662,6 +4681,31 @@ "title": "SendUserMessageRequest", "type": "object" }, + { + "description": "Steer the running turn without tearing it down.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "queueUserMessage" + ], + "title": "QueueUserMessageRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/QueueUserMessageParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "QueueUserMessageRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index b5035b3..c5c1a36 100644 --- a/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -1660,6 +1660,31 @@ "title": "SendUserMessageRequest", "type": "object" }, + { + "description": "Steer the running turn without tearing it down.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "queueUserMessage" + ], + "title": "QueueUserMessageRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/QueueUserMessageParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "QueueUserMessageRequest", + "type": "object" + }, { "properties": { "id": { @@ -7009,6 +7034,32 @@ }, "type": "object" }, + "QueueUserMessageParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Steer a turn that is already running instead of replacing it.\n\n`sendUserMessage` submits `Op::UserInput`, which aborts the running task and spawns a new one — the caller can interrupt, never redirect. This submits `Op::QueueUserInput`, which folds the items into the turn already in flight. With no turn running the two are equivalent.", + "properties": { + "conversationId": { + "$ref": "#/definitions/ThreadId" + }, + "items": { + "items": { + "$ref": "#/definitions/InputItem" + }, + "type": "array" + } + }, + "required": [ + "conversationId", + "items" + ], + "title": "QueueUserMessageParams", + "type": "object" + }, + "QueueUserMessageResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueueUserMessageResponse", + "type": "object" + }, "RateLimitSnapshot": { "properties": { "credits": { @@ -11891,7 +11942,7 @@ "type": "object" }, { - "description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", + "description": "User config layer from $DEV_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", "properties": { "file": { "allOf": [ diff --git a/app-server-protocol/schema/json/v1/QueueUserMessageParams.json b/app-server-protocol/schema/json/v1/QueueUserMessageParams.json new file mode 100644 index 0000000..d334859 --- /dev/null +++ b/app-server-protocol/schema/json/v1/QueueUserMessageParams.json @@ -0,0 +1,166 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "InputItem": { + "oneOf": [ + { + "properties": { + "data": { + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "default": [], + "description": "UI-defined spans within `text` used to render or persist special elements.", + "items": { + "$ref": "#/definitions/V1TextElement" + }, + "type": "array" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "type": { + "enum": [ + "text" + ], + "title": "TextInputItemType", + "type": "string" + } + }, + "required": [ + "data", + "type" + ], + "title": "TextInputItem", + "type": "object" + }, + { + "properties": { + "data": { + "properties": { + "image_url": { + "type": "string" + } + }, + "required": [ + "image_url" + ], + "type": "object" + }, + "type": { + "enum": [ + "image" + ], + "title": "ImageInputItemType", + "type": "string" + } + }, + "required": [ + "data", + "type" + ], + "title": "ImageInputItem", + "type": "object" + }, + { + "properties": { + "data": { + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "type": { + "enum": [ + "localImage" + ], + "title": "LocalImageInputItemType", + "type": "string" + } + }, + "required": [ + "data", + "type" + ], + "title": "LocalImageInputItem", + "type": "object" + } + ] + }, + "ThreadId": { + "type": "string" + }, + "V1ByteRange": { + "properties": { + "end": { + "description": "End byte offset (exclusive) within the UTF-8 text buffer.", + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "start": { + "description": "Start byte offset (inclusive) within the UTF-8 text buffer.", + "format": "uint", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "end", + "start" + ], + "type": "object" + }, + "V1TextElement": { + "properties": { + "byteRange": { + "allOf": [ + { + "$ref": "#/definitions/V1ByteRange" + } + ], + "description": "Byte range in the parent `text` buffer that this element occupies." + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "byteRange" + ], + "type": "object" + } + }, + "description": "Steer a turn that is already running instead of replacing it.\n\n`sendUserMessage` submits `Op::UserInput`, which aborts the running task and spawns a new one — the caller can interrupt, never redirect. This submits `Op::QueueUserInput`, which folds the items into the turn already in flight. With no turn running the two are equivalent.", + "properties": { + "conversationId": { + "$ref": "#/definitions/ThreadId" + }, + "items": { + "items": { + "$ref": "#/definitions/InputItem" + }, + "type": "array" + } + }, + "required": [ + "conversationId", + "items" + ], + "title": "QueueUserMessageParams", + "type": "object" +} diff --git a/app-server-protocol/schema/json/v1/QueueUserMessageResponse.json b/app-server-protocol/schema/json/v1/QueueUserMessageResponse.json new file mode 100644 index 0000000..13c441b --- /dev/null +++ b/app-server-protocol/schema/json/v1/QueueUserMessageResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueueUserMessageResponse", + "type": "object" +} diff --git a/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 0d3e9a4..162cd1c 100644 --- a/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -363,7 +363,7 @@ "type": "object" }, { - "description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", + "description": "User config layer from $DEV_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", "properties": { "file": { "allOf": [ diff --git a/app-server-protocol/schema/json/v2/ConfigWriteResponse.json b/app-server-protocol/schema/json/v2/ConfigWriteResponse.json index e8f0fa7..baf7e7d 100644 --- a/app-server-protocol/schema/json/v2/ConfigWriteResponse.json +++ b/app-server-protocol/schema/json/v2/ConfigWriteResponse.json @@ -74,7 +74,7 @@ "type": "object" }, { - "description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", + "description": "User config layer from $DEV_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", "properties": { "file": { "allOf": [ diff --git a/app-server-protocol/schema/typescript/ClientRequest.ts b/app-server-protocol/schema/typescript/ClientRequest.ts index 18bbc73..86834b5 100644 --- a/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/app-server-protocol/schema/typescript/ClientRequest.ts @@ -15,6 +15,7 @@ import type { InterruptConversationParams } from "./InterruptConversationParams" import type { ListConversationsParams } from "./ListConversationsParams"; import type { LoginApiKeyParams } from "./LoginApiKeyParams"; import type { NewConversationParams } from "./NewConversationParams"; +import type { QueueUserMessageParams } from "./QueueUserMessageParams"; import type { RemoveConversationListenerParams } from "./RemoveConversationListenerParams"; import type { RequestId } from "./RequestId"; import type { ResumeConversationParams } from "./ResumeConversationParams"; @@ -59,4 +60,4 @@ import type { TurnSteerParams } from "./v2/TurnSteerParams"; /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/remote/read", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/write", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "newConversation", id: RequestId, params: NewConversationParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "listConversations", id: RequestId, params: ListConversationsParams, } | { "method": "resumeConversation", id: RequestId, params: ResumeConversationParams, } | { "method": "forkConversation", id: RequestId, params: ForkConversationParams, } | { "method": "archiveConversation", id: RequestId, params: ArchiveConversationParams, } | { "method": "sendUserMessage", id: RequestId, params: SendUserMessageParams, } | { "method": "sendUserTurn", id: RequestId, params: SendUserTurnParams, } | { "method": "interruptConversation", id: RequestId, params: InterruptConversationParams, } | { "method": "addConversationListener", id: RequestId, params: AddConversationListenerParams, } | { "method": "removeConversationListener", id: RequestId, params: RemoveConversationListenerParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "loginApiKey", id: RequestId, params: LoginApiKeyParams, } | { "method": "loginChatGpt", id: RequestId, params: undefined, } | { "method": "cancelLoginChatGpt", id: RequestId, params: CancelLoginChatGptParams, } | { "method": "logoutChatGpt", id: RequestId, params: undefined, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "getUserSavedConfig", id: RequestId, params: undefined, } | { "method": "setDefaultModel", id: RequestId, params: SetDefaultModelParams, } | { "method": "getUserAgent", id: RequestId, params: undefined, } | { "method": "userInfo", id: RequestId, params: undefined, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, } | { "method": "execOneOffCommand", id: RequestId, params: ExecOneOffCommandParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/remote/read", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/write", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "newConversation", id: RequestId, params: NewConversationParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "listConversations", id: RequestId, params: ListConversationsParams, } | { "method": "resumeConversation", id: RequestId, params: ResumeConversationParams, } | { "method": "forkConversation", id: RequestId, params: ForkConversationParams, } | { "method": "archiveConversation", id: RequestId, params: ArchiveConversationParams, } | { "method": "sendUserMessage", id: RequestId, params: SendUserMessageParams, } | { "method": "queueUserMessage", id: RequestId, params: QueueUserMessageParams, } | { "method": "sendUserTurn", id: RequestId, params: SendUserTurnParams, } | { "method": "interruptConversation", id: RequestId, params: InterruptConversationParams, } | { "method": "addConversationListener", id: RequestId, params: AddConversationListenerParams, } | { "method": "removeConversationListener", id: RequestId, params: RemoveConversationListenerParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "loginApiKey", id: RequestId, params: LoginApiKeyParams, } | { "method": "loginChatGpt", id: RequestId, params: undefined, } | { "method": "cancelLoginChatGpt", id: RequestId, params: CancelLoginChatGptParams, } | { "method": "logoutChatGpt", id: RequestId, params: undefined, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "getUserSavedConfig", id: RequestId, params: undefined, } | { "method": "setDefaultModel", id: RequestId, params: SetDefaultModelParams, } | { "method": "getUserAgent", id: RequestId, params: undefined, } | { "method": "userInfo", id: RequestId, params: undefined, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, } | { "method": "execOneOffCommand", id: RequestId, params: ExecOneOffCommandParams, }; diff --git a/app-server-protocol/schema/typescript/QueueUserMessageParams.ts b/app-server-protocol/schema/typescript/QueueUserMessageParams.ts new file mode 100644 index 0000000..3f0ece0 --- /dev/null +++ b/app-server-protocol/schema/typescript/QueueUserMessageParams.ts @@ -0,0 +1,15 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { InputItem } from "./InputItem"; +import type { ThreadId } from "./ThreadId"; + +/** + * Steer a turn that is already running instead of replacing it. + * + * `sendUserMessage` submits `Op::UserInput`, which aborts the running task and + * spawns a new one — the caller can interrupt, never redirect. This submits + * `Op::QueueUserInput`, which folds the items into the turn already in flight. + * With no turn running the two are equivalent. + */ +export type QueueUserMessageParams = { conversationId: ThreadId, items: Array, }; diff --git a/app-server-protocol/schema/typescript/QueueUserMessageResponse.ts b/app-server-protocol/schema/typescript/QueueUserMessageResponse.ts new file mode 100644 index 0000000..f0477ef --- /dev/null +++ b/app-server-protocol/schema/typescript/QueueUserMessageResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type QueueUserMessageResponse = Record; diff --git a/app-server-protocol/schema/typescript/index.ts b/app-server-protocol/schema/typescript/index.ts index c23d845..b4390f4 100644 --- a/app-server-protocol/schema/typescript/index.ts +++ b/app-server-protocol/schema/typescript/index.ts @@ -147,6 +147,8 @@ export type { PlanItem } from "./PlanItem"; export type { PlanItemArg } from "./PlanItemArg"; export type { PlanType } from "./PlanType"; export type { Profile } from "./Profile"; +export type { QueueUserMessageParams } from "./QueueUserMessageParams"; +export type { QueueUserMessageResponse } from "./QueueUserMessageResponse"; export type { RateLimitSnapshot } from "./RateLimitSnapshot"; export type { RateLimitWindow } from "./RateLimitWindow"; export type { RawResponseItemEvent } from "./RawResponseItemEvent"; diff --git a/app-server-protocol/src/lib.rs b/app-server-protocol/src/lib.rs index df27c7c..0857c3b 100644 --- a/app-server-protocol/src/lib.rs +++ b/app-server-protocol/src/lib.rs @@ -20,6 +20,6 @@ pub use schema_fixtures::SchemaFixtureOptions; pub use schema_fixtures::read_schema_fixture_tree; pub use schema_fixtures::write_schema_fixtures; pub use schema_fixtures::write_schema_fixtures_with_options; -// Backward-compatibility alias used by code-rs crates that still import from +// Backward-compatibility alias used by hanzo-dev crates that still import from // code-app-server-protocol. pub use code_protocol::ConversationId; diff --git a/app-server-protocol/src/protocol/common.rs b/app-server-protocol/src/protocol/common.rs index d5cf1c2..8b2a914 100644 --- a/app-server-protocol/src/protocol/common.rs +++ b/app-server-protocol/src/protocol/common.rs @@ -418,6 +418,11 @@ client_request_definitions! { params: v1::SendUserMessageParams, response: v1::SendUserMessageResponse, }, + /// Steer the running turn without tearing it down. + QueueUserMessage { + params: v1::QueueUserMessageParams, + response: v1::QueueUserMessageResponse, + }, SendUserTurn { params: v1::SendUserTurnParams, response: v1::SendUserTurnResponse, diff --git a/app-server-protocol/src/protocol/thread_history.rs b/app-server-protocol/src/protocol/thread_history.rs index 83c2730..9dfa445 100644 --- a/app-server-protocol/src/protocol/thread_history.rs +++ b/app-server-protocol/src/protocol/thread_history.rs @@ -48,7 +48,7 @@ impl ThreadHistoryBuilder { } /// This function should handle all EventMsg variants that can be persisted in a rollout file. - /// See `should_persist_event_msg` in `codex-rs/core/rollout/policy.rs`. + /// See `should_persist_event_msg` in `vendor/codex/core/rollout/policy.rs`. fn handle_event(&mut self, event: &EventMsg) { match event { EventMsg::UserMessage(payload) => self.handle_user_message(payload), diff --git a/app-server-protocol/src/protocol/v1.rs b/app-server-protocol/src/protocol/v1.rs index a498c3e..7032782 100644 --- a/app-server-protocol/src/protocol/v1.rs +++ b/app-server-protocol/src/protocol/v1.rs @@ -408,6 +408,23 @@ pub struct SendUserMessageParams { pub items: Vec, } +/// Steer a turn that is already running instead of replacing it. +/// +/// `sendUserMessage` submits `Op::UserInput`, which aborts the running task and +/// spawns a new one — the caller can interrupt, never redirect. This submits +/// `Op::QueueUserInput`, which folds the items into the turn already in flight. +/// With no turn running the two are equivalent. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct QueueUserMessageParams { + pub conversation_id: ThreadId, + pub items: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct QueueUserMessageResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SendUserTurnParams { diff --git a/app-server-protocol/src/protocol/v2.rs b/app-server-protocol/src/protocol/v2.rs index 29b1cb0..a37d6b2 100644 --- a/app-server-protocol/src/protocol/v2.rs +++ b/app-server-protocol/src/protocol/v2.rs @@ -305,7 +305,7 @@ pub enum ConfigLayerSource { file: AbsolutePathBuf, }, - /// User config layer from $CODEX_HOME/config.toml. This layer is special + /// User config layer from $DEV_HOME/config.toml. This layer is special /// in that it is expected to be: /// - writable by the user /// - generally outside the workspace directory diff --git a/arg0/src/lib.rs b/arg0/src/lib.rs index c194771..6891cba 100644 --- a/arg0/src/lib.rs +++ b/arg0/src/lib.rs @@ -3,7 +3,7 @@ use std::path::Path; use std::path::PathBuf; use code_core::config::resolve_code_path_for_read; -use code_core::CODEX_APPLY_PATCH_ARG1; +use code_core::DEV_APPLY_PATCH_ARG1; #[cfg(unix)] use std::os::unix::fs::symlink; use tempfile::TempDir; @@ -55,7 +55,7 @@ where } let argv1 = args.next().unwrap_or_default(); - if argv1 == CODEX_APPLY_PATCH_ARG1 { + if argv1 == DEV_APPLY_PATCH_ARG1 { let patch_arg = args.next().and_then(|s| s.to_str().map(str::to_owned)); let exit_code = match patch_arg { Some(patch_arg) => { @@ -67,7 +67,7 @@ where } } None => { - eprintln!("Error: {CODEX_APPLY_PATCH_ARG1} requires a UTF-8 PATCH argument."); + eprintln!("Error: {DEV_APPLY_PATCH_ARG1} requires a UTF-8 PATCH argument."); 1 } }; @@ -105,12 +105,12 @@ where }) } -const ILLEGAL_ENV_VAR_PREFIX: &str = "CODEX_"; +const ILLEGAL_ENV_VAR_PREFIX: &str = "DEV_"; /// Load env vars from ~/.code/.env (legacy ~/.codex/.env is still read) and `$(pwd)/.env`. /// /// Security: Do not allow `.env` files to create or modify any variables -/// with names starting with `CODEX_`. +/// with names starting with `DEV_`. fn load_dotenv() { // 1) Load from global ~/.code/.env (or ~/.codex/.env) first. if let Ok(code_home) = code_core::config::find_code_home() { @@ -124,7 +124,7 @@ fn load_dotenv() { // 2) Load from the current project's .env, but with extra safety: // - Do NOT import provider API keys by default (e.g., OPENAI_API_KEY, ANTHROPIC_API_KEY, // GOOGLE_API_KEY, QWEN_API_KEY). - // - Users can opt back in via CODEX_ALLOW_PROJECT_OPENAI_KEYS=1 (either exported + // - Users can opt back in via DEV_ALLOW_PROJECT_OPENAI_KEYS=1 (either exported // in the shell or placed in ~/.code/.env). // NOTE: use an explicit cwd/.env path instead of dotenv_iter() because // dotenv_iter() walks parent directories. Parent-walking can accidentally @@ -136,8 +136,8 @@ fn load_dotenv() { // Filtered setter that always blocks provider keys from the project's .env. for (key, value) in iter.into_iter().flatten() { let upper = key.to_ascii_uppercase(); - // Never allow CODEX_* to be set from .env files for safety. - if upper.starts_with(ILLEGAL_ENV_VAR_PREFIX) && upper != "CODEX_HOME" { continue; } + // Never allow DEV_* to be set from .env files for safety. + if upper.starts_with(ILLEGAL_ENV_VAR_PREFIX) && upper != "DEV_HOME" { continue; } // Always ignore provider API keys from project .env (must be set globally or in shell). if matches!( upper.as_str(), @@ -158,21 +158,21 @@ fn load_dotenv() { } } - // Bridge CODE_HOME to CODEX_HOME for legacy components that still read only CODEX_HOME. - let codex_home_missing = std::env::var("CODEX_HOME") + // Bridge CODE_HOME to DEV_HOME for legacy components that still read only DEV_HOME. + let codex_home_missing = std::env::var("DEV_HOME") .map(|v| v.trim().is_empty()) .unwrap_or(true); if codex_home_missing { if let Ok(code_home) = std::env::var("CODE_HOME") { if !code_home.trim().is_empty() { // Safe: still single-threaded during startup. - unsafe { std::env::set_var("CODEX_HOME", code_home) }; + unsafe { std::env::set_var("DEV_HOME", code_home) }; } } } } -/// Helper to set vars from a dotenvy iterator while filtering out `CODEX_` keys. +/// Helper to set vars from a dotenvy iterator while filtering out `DEV_` keys. fn set_filtered(iter: I) where I: IntoIterator>, @@ -219,7 +219,7 @@ fn prepend_path_entry_for_apply_patch() -> std::io::Result { &batch_script, format!( r#"@echo off -"{}" {CODEX_APPLY_PATCH_ARG1} %* +"{}" {DEV_APPLY_PATCH_ARG1} %* "#, exe.display() ), diff --git a/browser/src/manager.rs b/browser/src/manager.rs index daf2953..79b4724 100644 --- a/browser/src/manager.rs +++ b/browser/src/manager.rs @@ -88,7 +88,7 @@ fn is_temporary_internal_launch_error_message(message: &str) -> bool { } fn chrome_logging_enabled() -> bool { - env_truthy("CODE_SUBAGENT_DEBUG") || env_truthy("CODEX_BROWSER_LOG") + env_truthy("CODE_SUBAGENT_DEBUG") || env_truthy("DEV_BROWSER_LOG") } fn env_truthy(key: &str) -> bool { @@ -102,14 +102,14 @@ fn resolve_chrome_log_path() -> Option { return None; } - if let Ok(path) = std::env::var("CODEX_BROWSER_LOG_PATH") { + if let Ok(path) = std::env::var("DEV_BROWSER_LOG_PATH") { let trimmed = path.trim(); if !trimmed.is_empty() { return Some(PathBuf::from(trimmed)); } } - let base = if let Ok(home) = std::env::var("CODE_HOME").or_else(|_| std::env::var("CODEX_HOME")) { + let base = if let Ok(home) = std::env::var("CODE_HOME").or_else(|_| std::env::var("DEV_HOME")) { PathBuf::from(home).join("debug_logs") } else if let Ok(home) = std::env::var("HOME") { PathBuf::from(home).join(".code").join("debug_logs") diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 4184c0b..d0a3a4b 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -3,10 +3,18 @@ edition = "2024" name = "code-cli" version = { workspace = true } +# `dev` is the product. It is what `hanzo code` launches (by bare name, off +# PATH) and what the installer contract promises. [[bin]] -name = "code" +name = "dev" path = "src/main.rs" +# `code` stays as an alias so existing installs keep working. It is the same +# program — an alias, never the canonical name. +[[bin]] +name = "code" +path = "src/code_alias.rs" + [lib] name = "code_cli" path = "src/lib.rs" diff --git a/cli/src/code_alias.rs b/cli/src/code_alias.rs new file mode 100644 index 0000000..6412db2 --- /dev/null +++ b/cli/src/code_alias.rs @@ -0,0 +1,2 @@ +// Compatibility entry point for existing `code` installs. +include!("main.rs"); diff --git a/cli/src/login.rs b/cli/src/login.rs index d6d6664..ef7843e 100644 --- a/cli/src/login.rs +++ b/cli/src/login.rs @@ -5,8 +5,10 @@ use code_core::auth::CLIENT_ID; use code_core::auth::OPENAI_API_KEY_ENV_VAR; use code_core::auth::login_with_api_key; use code_core::auth::logout; +use code_core::HANZO_PROVIDER_ID; use code_core::config::Config; use code_core::config::ConfigOverrides; +use code_login::DeviceGrant; use code_login::ServerOptions; use code_login::run_device_code_login; use code_login::run_login_server; @@ -15,7 +17,21 @@ use std::io::IsTerminal; use std::io::Read; use std::path::PathBuf; -pub async fn login_with_chatgpt(code_home: PathBuf, originator: String) -> std::io::Result<()> { +/// Report the outcome of a login and leave. +fn finish(result: std::io::Result<()>) -> ! { + match result { + Ok(()) => { + eprintln!("Successfully logged in"); + std::process::exit(0); + } + Err(e) => { + eprintln!("Error logging in: {e}"); + std::process::exit(1); + } + } +} + +async fn browser_login(code_home: PathBuf, originator: String) -> std::io::Result<()> { let opts = ServerOptions::new(code_home, CLIENT_ID.to_string(), originator); let server = run_login_server(opts)?; @@ -27,23 +43,17 @@ pub async fn login_with_chatgpt(code_home: PathBuf, originator: String) -> std:: server.block_until_done().await } -pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! { +/// Sign in to the configured provider. Hanzo IAM registers no loopback redirect +/// for its CLI client, so the device grant is how you reach it — headless or +/// not. Every other provider keeps the browser loopback flow. +pub async fn run_login(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides); + let originator = config.responses_originator_header.clone(); - match login_with_chatgpt( - config.code_home, - config.responses_originator_header.clone(), - ) - .await - { - Ok(_) => { - eprintln!("Successfully logged in"); - std::process::exit(0); - } - Err(e) => { - eprintln!("Error logging in: {e}"); - std::process::exit(1); - } + if config.model_provider_id == HANZO_PROVIDER_ID { + finish(DeviceGrant::run(ServerOptions::hanzo(config.code_home, originator)).await) + } else { + finish(browser_login(config.code_home, originator).await) } } @@ -52,17 +62,7 @@ pub async fn run_login_with_api_key( api_key: String, ) -> ! { let config = load_config_or_exit(cli_config_overrides); - - match login_with_api_key(&config.code_home, &api_key) { - Ok(_) => { - eprintln!("Successfully logged in"); - std::process::exit(0); - } - Err(e) => { - eprintln!("Error logging in: {e}"); - std::process::exit(1); - } - } + finish(login_with_api_key(&config.code_home, &api_key)) } pub fn read_api_key_from_stdin() -> String { @@ -70,7 +70,7 @@ pub fn read_api_key_from_stdin() -> String { if stdin.is_terminal() { eprintln!( - "--with-api-key expects the API key on stdin. Try piping it, e.g. `printenv OPENAI_API_KEY | codex login --with-api-key`." + "--with-api-key expects the API key on stdin. Try piping it, e.g. `printenv HANZO_USER_KEY | dev login --with-api-key`." ); std::process::exit(1); } @@ -92,31 +92,35 @@ pub fn read_api_key_from_stdin() -> String { api_key } -/// Login using the OAuth device code flow. +/// Login using a device code. Hanzo IAM speaks RFC 8628; OpenAI speaks its own +/// `/api/accounts/deviceauth` scheme. The issuer and client overrides carry a +/// sibling brand's IAM (e.g. `https://lux.id/v1/iam` with `lux-cli`). pub async fn run_login_with_device_code( cli_config_overrides: CliConfigOverrides, issuer_base_url: Option, client_id: Option, ) -> ! { let config = load_config_or_exit(cli_config_overrides); - let mut opts = ServerOptions::new( - config.code_home, - client_id.unwrap_or(CLIENT_ID.to_string()), - config.responses_originator_header.clone(), - ); + let originator = config.responses_originator_header.clone(); + let hanzo = config.model_provider_id == HANZO_PROVIDER_ID; + + let mut opts = if hanzo { + ServerOptions::hanzo(config.code_home, originator) + } else { + ServerOptions::new(config.code_home, CLIENT_ID.to_string(), originator) + }; + if let Some(id) = client_id { + opts.client_id = id; + } if let Some(iss) = issuer_base_url { opts.issuer = iss; } - match run_device_code_login(opts).await { - Ok(()) => { - eprintln!("Successfully logged in"); - std::process::exit(0); - } - Err(e) => { - eprintln!("Error logging in with device code: {e}"); - std::process::exit(1); - } - } + + finish(if hanzo { + DeviceGrant::run(opts).await + } else { + run_device_code_login(opts).await + }) } pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { @@ -147,7 +151,12 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { } }, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens | AuthMode::Headers => { - eprintln!("Logged in using ChatGPT"); + let brand = if config.model_provider_id == HANZO_PROVIDER_ID { + "Hanzo" + } else { + "ChatGPT" + }; + eprintln!("Logged in with your {brand} account"); std::process::exit(0); } }, diff --git a/cli/src/main.rs b/cli/src/main.rs index a7fe4a2..6f4f898 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -12,7 +12,7 @@ use code_cli::SeatbeltCommand; use code_cli::login::read_api_key_from_stdin; use code_cli::login::run_login_status; use code_cli::login::run_login_with_api_key; -use code_cli::login::run_login_with_chatgpt; +use code_cli::login::run_login; use code_cli::login::run_login_with_device_code; use code_cli::login::run_logout; mod bridge; @@ -39,15 +39,17 @@ use tokio::runtime::{Builder as TokioRuntimeBuilder, Handle as TokioHandle}; mod mcp_cmd; use crate::mcp_cmd::McpCli; +use code_cli::proto; +use code_cli::proto::ProtoCli; -const CLI_COMMAND_NAME: &str = "code"; -pub(crate) const CODEX_SECURE_MODE_ENV_VAR: &str = "CODEX_SECURE_MODE"; +const CLI_COMMAND_NAME: &str = "dev"; +pub(crate) const DEV_SECURE_MODE_ENV_VAR: &str = "DEV_SECURE_MODE"; /// As early as possible in the process lifecycle, apply hardening measures -/// if the CODEX_SECURE_MODE environment variable is set to "1". +/// if the DEV_SECURE_MODE environment variable is set to "1". #[ctor::ctor] fn pre_main_hardening() { - let secure_mode = match std::env::var(CODEX_SECURE_MODE_ENV_VAR) { + let secure_mode = match std::env::var(DEV_SECURE_MODE_ENV_VAR) { Ok(value) => value, Err(_) => return, }; @@ -58,24 +60,24 @@ fn pre_main_hardening() { // Always clear this env var so child processes don't inherit it. unsafe { - std::env::remove_var(CODEX_SECURE_MODE_ENV_VAR); + std::env::remove_var(DEV_SECURE_MODE_ENV_VAR); } } -/// Codex CLI +/// Hanzo Dev — the Hanzo coding agent. /// /// If no subcommand is specified, options will be forwarded to the interactive CLI. #[derive(Debug, Parser)] #[clap( author, - name = "code", + name = "dev", version = code_version::version(), // If a sub‑command is given, ignore requirements of the default args. subcommand_negates_reqs = true, // The executable is sometimes invoked via a platform‑specific name like - // `codex-x86_64-unknown-linux-musl`, but the help output should always use - // the generic `code` command name that users run. - bin_name = "code" + // `dev-x86_64-unknown-linux-musl`, or via the `code` alias, but the help + // output should always name the product: `dev`. + bin_name = "dev" )] struct MultitoolCli { #[clap(flatten)] @@ -98,7 +100,7 @@ struct MultitoolCli { #[derive(Debug, clap::Subcommand)] enum Subcommand { - /// Run Codex non-interactively. + /// Run Hanzo Dev non-interactively. #[clap(visible_alias = "e")] Exec(ExecCli), @@ -112,27 +114,33 @@ enum Subcommand { /// Remove stored authentication credentials. Logout(LogoutCommand), - /// [experimental] Run Codex as an MCP server and manage MCP servers. + /// [experimental] Run Hanzo Dev as an MCP server and manage MCP servers. #[clap(visible_alias = "acp")] Mcp(McpCli), - /// [experimental] Run the Codex MCP server (stdio transport). + /// [experimental] Run the Hanzo Dev MCP server (stdio transport). McpServer, /// [experimental] Run the app server. AppServer, + /// [experimental] Speak the agent protocol on stdin/stdout: Submission + /// JSON lines in, Event JSON lines out. Unlike `app-server` this exposes + /// the whole `Op` set, so a controller can steer a turn in flight + /// (`queue_user_input`) rather than only interrupt it. + Proto(ProtoCli), + /// Generate shell completion scripts. Completion(CompletionCommand), /// Internal debugging commands. Debug(DebugArgs), - /// Debug: replay ordering from response.json and codex-tui.log + /// Debug: replay ordering from response.json and the TUI log #[clap(hide = false)] OrderReplay(OrderReplayArgs), - /// Apply the latest diff produced by Codex agent as a `git apply` to your local working tree. + /// Apply the latest diff produced by the agent as a `git apply` to your local working tree. #[clap(visible_alias = "a")] Apply(ApplyCommand), @@ -142,7 +150,7 @@ enum Subcommand { /// Internal: generate TypeScript protocol bindings. #[clap(hide = true)] GenerateTs(GenerateTsCommand), - /// [EXPERIMENTAL] Browse tasks from Codex Cloud and apply changes locally. + /// [EXPERIMENTAL] Browse cloud tasks and apply changes locally. #[clap(name = "cloud", alias = "cloud-tasks")] Cloud(CloudTasksCli), @@ -159,7 +167,7 @@ enum Subcommand { /// Side-channel LLM utilities (no TUI events). Llm(LlmCli), - /// Manage Code Bridge subscription for this workspace. + /// Manage Hanzo Dev Bridge subscription for this workspace. Bridge(BridgeCommand), } @@ -320,7 +328,7 @@ struct LoginCommand { #[arg( long = "with-api-key", - help = "Read the API key from stdin (e.g. `printenv OPENAI_API_KEY | codex login --with-api-key`)" + help = "Read the API key from stdin (e.g. `printenv HANZO_USER_KEY | dev login --with-api-key`)" )] with_api_key: bool, @@ -386,7 +394,7 @@ struct OrderReplayArgs { struct PreviewArgs { /// Slug identifier (e.g., faster-downloads) slug: String, - /// Optional owner/repo to override (defaults to just-every/code or $GITHUB_REPOSITORY) + /// Optional owner/repo to override (defaults to hanzoai/dev or $GITHUB_REPOSITORY) #[arg(long = "repo", value_name = "OWNER/REPO")] repo: Option, /// Output directory where the binary will be extracted @@ -495,6 +503,10 @@ async fn cli_main(code_linux_sandbox_exe: Option) -> anyhow::Result<()> Some(Subcommand::AppServer) => { code_app_server::run_main(code_linux_sandbox_exe, root_config_overrides).await?; } + Some(Subcommand::Proto(mut proto_cli)) => { + prepend_config_flags(&mut proto_cli.config_overrides, root_config_overrides.clone()); + proto::run_main(proto_cli).await?; + } Some(Subcommand::Resume(ResumeCommand { session_id, last, @@ -545,14 +557,14 @@ async fn cli_main(code_linux_sandbox_exe: Option) -> anyhow::Result<()> .await; } else if login_cli.api_key.is_some() { eprintln!( - "The --api-key flag is no longer supported. Pipe the key instead, e.g. `printenv OPENAI_API_KEY | codex login --with-api-key`." + "The --api-key flag is no longer supported. Pipe the key instead, e.g. `printenv HANZO_USER_KEY | dev login --with-api-key`." ); std::process::exit(1); } else if login_cli.with_api_key { let api_key = read_api_key_from_stdin(); run_login_with_api_key(login_cli.config_overrides, api_key).await; } else { - run_login_with_chatgpt(login_cli.config_overrides).await; + run_login(login_cli.config_overrides).await; } } } @@ -1086,7 +1098,7 @@ fn resolve_resume_path(session_id: Option<&str>, last: bool) -> anyhow::Result anyhow::Result<()> { let repo = args .repo .or_else(|| env::var("GITHUB_REPOSITORY").ok()) - .unwrap_or_else(|| "just-every/code".to_string()); + .unwrap_or_else(|| "hanzoai/dev".to_string()); let (owner, name) = repo .split_once('/') .map(|(o, n)| (o.to_string(), n.to_string())) @@ -1260,7 +1272,7 @@ async fn preview_main(args: PreviewArgs) -> anyhow::Result<()> { _ => bail!(format!("Unsupported platform: {}/{}", os, arch)), }; - let client = reqwest::Client::builder().user_agent("codex-preview/1").build()?; + let client = reqwest::Client::builder().user_agent("hanzo-dev-preview/1").build()?; // Resolve slug/tag from id let id = args.slug.trim().to_string(); @@ -1434,6 +1446,11 @@ async fn preview_main(args: PreviewArgs) -> anyhow::Result<()> { bail!("No recognized artifact content found.") } +/// What to say when several installs of this agent answer to the same name. +/// Held as one value so the identity test can read it; the names here are the +/// real ones — formula `hanzo-dev`, package `@hanzo/dev`, binary `dev`. +const ONE_INSTALL: &str = "\nIf versions differ, keep one install and drop the rest:\n - Homebrew: brew uninstall hanzo-dev\n - npm/pnpm: npm uninstall -g @hanzo/dev\n - Bun: bun remove -g @hanzo/dev\n\nCall it 'dev'. 'code' is an alias for the same program and collides with VS Code's CLI."; + async fn doctor_main() -> anyhow::Result<()> { use std::env; use std::process::Stdio; @@ -1443,7 +1460,7 @@ async fn doctor_main() -> anyhow::Result<()> { let exe = std::env::current_exe() .map(|p| p.display().to_string()) .unwrap_or_else(|_| "".to_string()); - println!("code version: {}", code_version::version()); + println!("dev version: {}", code_version::version()); println!("current_exe: {}", exe); // PATH @@ -1518,23 +1535,23 @@ async fn doctor_main() -> anyhow::Result<()> { let bun_coder = format!("{}/coder", bun_bin); if coder_paths.iter().any(|p| p == &bun_coder) { println!("\nBun shim detected for 'coder': {}", bun_coder); - println!("Suggestion: remove old Bun global with: bun remove -g @just-every/code"); + println!("Suggestion: remove old Bun global with: bun remove -g @hanzo/dev"); } let bun_code = format!("{}/code", bun_bin); if code_paths.iter().any(|p| p == &bun_code) { println!("Bun shim detected for 'code': {}", bun_code); - println!("Suggestion: prefer 'coder' or remove Bun shim if it conflicts."); + println!("Suggestion: prefer 'dev' or remove the Bun shim if it conflicts."); } } // Detect Homebrew overshadow of VS Code #[cfg(target_os = "macos")] { - let brew_code = code_paths.iter().find(|p| p.contains("/homebrew/bin/code") || p.contains("/Cellar/code/")); + let brew_code = code_paths.iter().find(|p| p.contains("/homebrew/bin/code") || p.contains("/Cellar/hanzo-dev/") || p.contains("/Cellar/code/")); let vscode_code = code_paths.iter().find(|p| p.contains("/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code")); if brew_code.is_some() && vscode_code.is_some() { println!("\nHomebrew 'code' precedes VS Code CLI in PATH."); - println!("Suggestion: uninstall Homebrew formula 'code' (brew uninstall code) or reorder PATH so /usr/local/bin comes before /usr/local/homebrew/bin."); + println!("Suggestion: call this agent by its own name, 'dev' — 'code' is only an alias. Or reorder PATH so /usr/local/bin precedes /opt/homebrew/bin."); } } @@ -1548,17 +1565,28 @@ async fn doctor_main() -> anyhow::Result<()> { println!("npm prefix -g: {}", npm_prefix); } - println!("\nIf versions differ, remove older installs and keep one package manager:"); - println!(" - Bun: bun remove -g @just-every/code"); - println!(" - npm/pnpm: npm uninstall -g @just-every/code"); - println!(" - Homebrew: brew uninstall code"); - println!(" - Prefer using 'coder' to avoid conflicts with VS Code's 'code'."); + println!("{ONE_INSTALL}"); Ok(()) } #[cfg(test)] mod tests { use super::*; + + /// `doctor` is the one place that tells a confused user which install to + /// remove, so it has to name the real formula, package and binary. Left as + /// prose it drifted back to `brew uninstall code` and `prefer 'coder'` — + /// advice to remove a formula that does not exist. See hanzo_identity.rs, + /// which guards the same contract for the defaults. + #[test] + fn doctor_names_the_things_that_actually_exist() { + assert!(ONE_INSTALL.contains("brew uninstall hanzo-dev")); + assert!(ONE_INSTALL.contains("npm uninstall -g @hanzo/dev")); + assert!(ONE_INSTALL.contains("Call it 'dev'")); + assert!(!ONE_INSTALL.contains("uninstall code")); + assert!(!ONE_INSTALL.contains("coder")); + } + use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -1580,11 +1608,14 @@ mod tests { use code_protocol::ThreadId; #[test] - fn bash_completion_uses_code_command_name() { + fn bash_completion_uses_our_command_name() { let mut buf = Vec::new(); write_completion(Shell::Bash, &mut buf); let script = String::from_utf8(buf).expect("completion output should be valid UTF-8"); - assert!(script.contains("_code()"), "expected bash completion function to be named _code"); + assert!( + script.contains(&format!("_{CLI_COMMAND_NAME}()")), + "expected bash completion function to be named _{CLI_COMMAND_NAME}" + ); assert!(!script.contains("_codex()"), "bash completion output should not use legacy codex prefix"); } @@ -1639,9 +1670,9 @@ where .unwrap_or_else(|poison| poison.into_inner()); let temp_home = TempDir::new().expect("temp code home"); let prev_code_home = std::env::var("CODE_HOME").ok(); - let prev_codex_home = std::env::var("CODEX_HOME").ok(); + let prev_codex_home = std::env::var("DEV_HOME").ok(); set_env_var("CODE_HOME", temp_home.path()); - remove_env_var("CODEX_HOME"); + remove_env_var("DEV_HOME"); let result = f(temp_home.path()); @@ -1650,8 +1681,8 @@ where None => remove_env_var("CODE_HOME"), } match prev_codex_home { - Some(val) => set_env_var("CODEX_HOME", val), - None => remove_env_var("CODEX_HOME"), + Some(val) => set_env_var("DEV_HOME", val), + None => remove_env_var("DEV_HOME"), } result diff --git a/cli/src/mcp_cmd.rs b/cli/src/mcp_cmd.rs index 72701ce..80226d8 100644 --- a/cli/src/mcp_cmd.rs +++ b/cli/src/mcp_cmd.rs @@ -17,7 +17,7 @@ use code_core::config_types::McpServerTransportConfig; /// - `serve` — run the MCP server on stdio /// - `list` — list configured servers (with `--json`) /// - `get` — show a single server (with `--json`) -/// - `add` — add a server launcher entry to `~/.code/config.toml` (Code also reads legacy `~/.codex/config.toml`) +/// - `add` — add a server launcher entry to `~/.code/config.toml` (Hanzo Dev also reads legacy `~/.codex/config.toml`) /// - `remove` — delete a server entry #[derive(Debug, clap::Parser)] pub struct McpCli { @@ -63,7 +63,7 @@ pub struct AddArgs { /// URL of a remote MCP server. /// - /// When `--bearer-token` is omitted, Code records the server as a stdio + /// When `--bearer-token` is omitted, Hanzo Dev records the server as a stdio /// launcher using `npx -y mcp-remote ` so the MCP server can handle /// OAuth flows. #[arg(long)] @@ -71,7 +71,7 @@ pub struct AddArgs { /// Optional bearer token to use with `--url` for static authentication. /// - /// When set, Code records the server as a `streamable_http` MCP server. + /// When set, Hanzo Dev records the server as a `streamable_http` MCP server. #[arg(long)] pub bearer_token: Option, @@ -183,7 +183,7 @@ fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<( Some(map) }; - let code_home = find_code_home().context("failed to resolve CODEX_HOME")?; + let code_home = find_code_home().context("failed to resolve DEV_HOME")?; let mut servers = load_global_mcp_servers(&code_home) .with_context(|| format!("failed to load MCP servers from {}", code_home.display()))?; @@ -274,7 +274,7 @@ fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) -> validate_server_name(&name)?; - let code_home = find_code_home().context("failed to resolve CODEX_HOME")?; + let code_home = find_code_home().context("failed to resolve DEV_HOME")?; let mut servers = load_global_mcp_servers(&code_home) .with_context(|| format!("failed to load MCP servers from {}", code_home.display()))?; @@ -347,7 +347,7 @@ fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> Resul } if entries.is_empty() { - println!("No MCP servers configured yet. Try `codex mcp add my-tool -- my-command`."); + println!("No MCP servers configured yet. Try `dev mcp add my-tool -- my-command`."); return Ok(()); } @@ -573,7 +573,7 @@ fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Result<( if let Some(timeout) = server.tool_timeout_sec { println!(" tool_timeout_sec: {:.3}", timeout.as_secs_f64()); } - println!(" remove: codex mcp remove {}", get_args.name); + println!(" remove: dev mcp remove {}", get_args.name); Ok(()) } diff --git a/cloud-tasks/src/lib.rs b/cloud-tasks/src/lib.rs index 0fa14ac..1346553 100644 --- a/cloud-tasks/src/lib.rs +++ b/cloud-tasks/src/lib.rs @@ -176,7 +176,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any // Default to online unless explicitly configured to use mock. let use_mock = matches!( - std::env::var("CODEX_CLOUD_TASKS_MODE").ok().as_deref(), + std::env::var("DEV_CLOUD_TASKS_MODE").ok().as_deref(), Some("mock") | Some("MOCK") ); @@ -184,7 +184,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any Arc::new(code_cloud_tasks_client::MockClient) } else { // Build an HTTP client against the configured (or default) base URL. - let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + let base_url = std::env::var("DEV_CLOUD_TASKS_BASE_URL") .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); let ua = code_core::default_client::get_code_user_agent(None); let mut http = @@ -233,7 +233,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any } _ => { eprintln!( - "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." + "Not signed in. Please run 'dev login' to sign in with ChatGPT, then re-run 'dev cloud'." ); std::process::exit(1); } @@ -241,7 +241,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any } None => { eprintln!( - "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." + "Not signed in. Please run 'dev login' to sign in with ChatGPT, then re-run 'dev cloud'." ); std::process::exit(1); } @@ -284,7 +284,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any let mut app = app::App::new(); // Initial load let force_internal = matches!( - std::env::var("CODEX_CLOUD_TASKS_FORCE_INTERNAL") + std::env::var("DEV_CLOUD_TASKS_FORCE_INTERNAL") .ok() .as_deref(), Some("1") | Some("true") | Some("TRUE") @@ -331,7 +331,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any let tx = tx.clone(); tokio::spawn(async move { let base_url = util::normalize_base_url( - &std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + &std::env::var("DEV_CLOUD_TASKS_BASE_URL") .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), ); let headers = util::build_chatgpt_headers().await; @@ -346,7 +346,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any let tx = tx.clone(); tokio::spawn(async move { let base_url = util::normalize_base_url( - &std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + &std::env::var("DEV_CLOUD_TASKS_BASE_URL") .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), ); // Build headers: UA + ChatGPT auth if available @@ -569,7 +569,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any let tx = tx.clone(); tokio::spawn(async move { let base_url = crate::util::normalize_base_url( - &std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + &std::env::var("DEV_CLOUD_TASKS_BASE_URL") .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), ); let headers = crate::util::build_chatgpt_headers().await; @@ -954,7 +954,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any if should_fetch { let tx = tx.clone(); tokio::spawn(async move { - let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string())); + let base_url = crate::util::normalize_base_url(&std::env::var("DEV_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string())); let headers = crate::util::build_chatgpt_headers().await; let res = crate::env_detect::list_environments(&base_url, &headers).await; let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); @@ -1134,7 +1134,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any let tx = tx.clone(); tokio::spawn(async move { let base_url = crate::util::normalize_base_url( - &std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + &std::env::var("DEV_CLOUD_TASKS_BASE_URL") .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), ); let headers = crate::util::build_chatgpt_headers().await; @@ -1205,7 +1205,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); let tx = tx.clone(); tokio::spawn(async move { - let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string())); + let base_url = crate::util::normalize_base_url(&std::env::var("DEV_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string())); let headers = crate::util::build_chatgpt_headers().await; let res = crate::env_detect::list_environments(&base_url, &headers).await; let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); @@ -1333,7 +1333,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any if should_fetch { let tx = tx.clone(); tokio::spawn(async move { - let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string())); + let base_url = crate::util::normalize_base_url(&std::env::var("DEV_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string())); let headers = crate::util::build_chatgpt_headers().await; let res = crate::env_detect::list_environments(&base_url, &headers).await; let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); @@ -1525,14 +1525,14 @@ async fn run_submit(args: crate::cli::SubmitArgs) -> anyhow::Result<()> { set_user_agent_suffix("code_cloud_tasks_submit"); let use_mock = matches!( - std::env::var("CODEX_CLOUD_TASKS_MODE").ok().as_deref(), + std::env::var("DEV_CLOUD_TASKS_MODE").ok().as_deref(), Some("mock") | Some("MOCK") ); let backend: Arc = if use_mock { Arc::new(code_cloud_tasks_client::MockClient) } else { - let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + let base_url = std::env::var("DEV_CLOUD_TASKS_BASE_URL") .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); let ua = code_core::default_client::get_code_user_agent(None); let mut http = code_cloud_tasks_client::HttpClient::new(base_url.clone())? @@ -1562,12 +1562,12 @@ async fn run_submit(args: crate::cli::SubmitArgs) -> anyhow::Result<()> { t } _ => { - eprintln!("Not signed in. Run 'codex login' and retry."); + eprintln!("Not signed in. Run 'dev login' and retry."); std::process::exit(1); } }, None => { - eprintln!("Not signed in. Run 'codex login' and retry."); + eprintln!("Not signed in. Run 'dev login' and retry."); std::process::exit(1); } }; @@ -1577,7 +1577,7 @@ async fn run_submit(args: crate::cli::SubmitArgs) -> anyhow::Result<()> { // Resolve target environment id let env_id = if let Some(e) = args.env.clone() { e } else { let base_url = util::normalize_base_url( - &std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + &std::env::var("DEV_CLOUD_TASKS_BASE_URL") .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), ); let headers = util::build_chatgpt_headers().await; diff --git a/cloud-tasks/src/ui.rs b/cloud-tasks/src/ui.rs index 975ad0e..4ff965f 100644 --- a/cloud-tasks/src/ui.rs +++ b/cloud-tasks/src/ui.rs @@ -61,7 +61,7 @@ static ROUNDED: OnceLock = OnceLock::new(); fn rounded_enabled() -> bool { *ROUNDED.get_or_init(|| { - std::env::var("CODEX_TUI_ROUNDED") + std::env::var("DEV_TUI_ROUNDED") .ok() .map(|v| v == "1") .unwrap_or(true) diff --git a/cloud-tasks/src/util.rs b/cloud-tasks/src/util.rs index 78395ae..e30d0c5 100644 --- a/cloud-tasks/src/util.rs +++ b/cloud-tasks/src/util.rs @@ -19,7 +19,7 @@ enum CloudLogLevel { } fn log_level_from_env() -> CloudLogLevel { - if let Ok(raw) = std::env::var("CODEX_CLOUD_TASKS_LOG_LEVEL") { + if let Ok(raw) = std::env::var("DEV_CLOUD_TASKS_LOG_LEVEL") { let value = raw.trim().to_ascii_lowercase(); return match value.as_str() { "off" | "none" | "0" => CloudLogLevel::Off, @@ -30,7 +30,7 @@ fn log_level_from_env() -> CloudLogLevel { }; } - if env_truthy("CODE_SUBAGENT_DEBUG") || env_truthy("CODEX_CLOUD_TASKS_DEBUG") { + if env_truthy("CODE_SUBAGENT_DEBUG") || env_truthy("DEV_CLOUD_TASKS_DEBUG") { return CloudLogLevel::Debug; } @@ -59,16 +59,16 @@ fn user_home_dir() -> Option { } fn resolve_log_path() -> Option { - if let Ok(path) = std::env::var("CODEX_CLOUD_TASKS_LOG_PATH") { + if let Ok(path) = std::env::var("DEV_CLOUD_TASKS_LOG_PATH") { let trimmed = path.trim(); if !trimmed.is_empty() { return Some(PathBuf::from(trimmed)); } } - let base = if let Ok(dir) = std::env::var("CODEX_CLOUD_TASKS_LOG_DIR") { + let base = if let Ok(dir) = std::env::var("DEV_CLOUD_TASKS_LOG_DIR") { PathBuf::from(dir) - } else if let Ok(home) = std::env::var("CODE_HOME").or_else(|_| std::env::var("CODEX_HOME")) { + } else if let Ok(home) = std::env::var("CODE_HOME").or_else(|_| std::env::var("DEV_HOME")) { PathBuf::from(home).join("debug_logs") } else if let Some(home) = user_home_dir() { home.join(".code").join("debug_logs") diff --git a/cloud-tasks-client/src/http.rs b/cloud-tasks-client/src/http.rs index 47a4816..b6e4cf4 100644 --- a/cloud-tasks-client/src/http.rs +++ b/cloud-tasks-client/src/http.rs @@ -239,7 +239,7 @@ mod api { "content": [{ "content_type": "text", "text": prompt }] })); - if let Ok(diff) = std::env::var("CODEX_STARTING_DIFF") + if let Ok(diff) = std::env::var("DEV_STARTING_DIFF") && !diff.is_empty() { input_items.push(serde_json::json!({ @@ -778,7 +778,7 @@ enum CloudLogLevel { } fn log_level_from_env() -> CloudLogLevel { - if let Ok(raw) = std::env::var("CODEX_CLOUD_TASKS_LOG_LEVEL") { + if let Ok(raw) = std::env::var("DEV_CLOUD_TASKS_LOG_LEVEL") { let value = raw.trim().to_ascii_lowercase(); return match value.as_str() { "off" | "none" | "0" => CloudLogLevel::Off, @@ -789,7 +789,7 @@ fn log_level_from_env() -> CloudLogLevel { }; } - if env_truthy("CODE_SUBAGENT_DEBUG") || env_truthy("CODEX_CLOUD_TASKS_DEBUG") { + if env_truthy("CODE_SUBAGENT_DEBUG") || env_truthy("DEV_CLOUD_TASKS_DEBUG") { return CloudLogLevel::Debug; } @@ -818,16 +818,16 @@ fn user_home_dir() -> Option { } fn resolve_log_path() -> Option { - if let Ok(path) = std::env::var("CODEX_CLOUD_TASKS_LOG_PATH") { + if let Ok(path) = std::env::var("DEV_CLOUD_TASKS_LOG_PATH") { let trimmed = path.trim(); if !trimmed.is_empty() { return Some(PathBuf::from(trimmed)); } } - let base = if let Ok(dir) = std::env::var("CODEX_CLOUD_TASKS_LOG_DIR") { + let base = if let Ok(dir) = std::env::var("DEV_CLOUD_TASKS_LOG_DIR") { PathBuf::from(dir) - } else if let Ok(home) = std::env::var("CODE_HOME").or_else(|_| std::env::var("CODEX_HOME")) { + } else if let Ok(home) = std::env::var("CODE_HOME").or_else(|_| std::env::var("DEV_HOME")) { PathBuf::from(home).join("debug_logs") } else if let Some(home) = user_home_dir() { home.join(".code").join("debug_logs") diff --git a/code-auto-drive-core/src/auto_coordinator.rs b/code-auto-drive-core/src/auto_coordinator.rs index 68ff605..367f9de 100644 --- a/code-auto-drive-core/src/auto_coordinator.rs +++ b/code-auto-drive-core/src/auto_coordinator.rs @@ -1825,7 +1825,7 @@ pub fn start_auto_coordinator( debug_enabled: bool, derive_goal_from_history: bool, ) -> Result { - if std::env::var_os("CODEX_DEBUG_AUTO_COORDINATOR").is_some() { + if std::env::var_os("DEV_DEBUG_AUTO_COORDINATOR").is_some() { eprintln!( "start_auto_coordinator invoked\n{:?}", std::backtrace::Backtrace::force_capture() diff --git a/code-auto-drive-core/src/faults.rs b/code-auto-drive-core/src/faults.rs index 08a82aa..e8984d0 100644 --- a/code-auto-drive-core/src/faults.rs +++ b/code-auto-drive-core/src/faults.rs @@ -34,14 +34,14 @@ enum FaultReset { static CONFIG: OnceCell> = OnceCell::new(); fn parse_fault_scope() -> Option { - match std::env::var("CODEX_FAULTS_SCOPE").ok().as_deref() { + match std::env::var("DEV_FAULTS_SCOPE").ok().as_deref() { Some("auto_drive") => Some(FaultScope::AutoDrive), _ => None, } } fn parse_reset_hint() -> Option { - if let Some(seconds) = std::env::var("CODEX_FAULTS_429_RESET").ok() { + if let Some(seconds) = std::env::var("DEV_FAULTS_429_RESET").ok() { if let Ok(value) = seconds.parse::() { return Some(FaultReset::Seconds(value)); } @@ -62,7 +62,7 @@ fn parse_reset_hint() -> Option { fn init_config() -> HashMap { let mut map = HashMap::new(); if let Some(scope) = parse_fault_scope() { - if let Ok(spec) = std::env::var("CODEX_FAULTS") { + if let Ok(spec) = std::env::var("DEV_FAULTS") { let cfg = FaultConfig::default(); for entry in spec.split(',').map(str::trim).filter(|s| !s.is_empty()) { if let Some((label, count)) = entry.split_once(':') { diff --git a/code-version/src/lib.rs b/code-version/src/lib.rs index 1509946..50a705b 100644 --- a/code-version/src/lib.rs +++ b/code-version/src/lib.rs @@ -14,7 +14,7 @@ pub const CODE_VERSION: &str = { }; const ANNOUNCEMENT_TIP: &str = include_str!("../../../announcement_tip.toml"); -const MODELS_MANIFEST: &str = include_str!("../../../codex-rs/models-manager/models.json"); +const MODELS_MANIFEST: &str = include_str!("../../../vendor/codex/models-manager/models.json"); pub const MIN_WIRE_COMPAT_VERSION_FALLBACK: &str = "0.101.0"; static MIN_WIRE_COMPAT_VERSION: LazyLock = LazyLock::new(|| { diff --git a/common/src/approval_presets.rs b/common/src/approval_presets.rs index 20305d2..07234bf 100644 --- a/common/src/approval_presets.rs +++ b/common/src/approval_presets.rs @@ -24,21 +24,21 @@ pub fn builtin_approval_presets() -> Vec { ApprovalPreset { id: "read-only", label: "Read Only", - description: "Codex can read files and answer questions. Codex requires approval to make edits, run commands, or access network", + description: "Hanzo Dev can read files and answer questions. It requires approval to make edits, run commands, or access network", approval: AskForApproval::OnRequest, sandbox: SandboxPolicy::ReadOnly, }, ApprovalPreset { id: "auto", label: "Auto", - description: "Codex can read files, make edits, and run commands in the workspace. Codex requires approval to work outside the workspace or access network", + description: "Hanzo Dev can read files, make edits, and run commands in the workspace. It requires approval to work outside the workspace or access network", approval: AskForApproval::OnRequest, sandbox: SandboxPolicy::new_workspace_write_policy(), }, ApprovalPreset { id: "full-access", label: "Full Access", - description: "Codex can read files, make edits, and run commands with network access, without approval. Exercise caution", + description: "Hanzo Dev can read files, make edits, and run commands with network access, without approval. Exercise caution", approval: AskForApproval::Never, sandbox: SandboxPolicy::DangerFullAccess, }, diff --git a/common/src/config_override.rs b/common/src/config_override.rs index d0719bc..db5ecf0 100644 --- a/common/src/config_override.rs +++ b/common/src/config_override.rs @@ -18,7 +18,7 @@ use toml::Value; #[derive(Parser, Debug, Default, Clone)] pub struct CliConfigOverrides { /// Override a configuration value that would otherwise be loaded from - /// `~/.code/config.toml` (Code also reads legacy `~/.codex/config.toml`). Use a dotted path (`foo.bar.baz`) to override + /// `~/.code/config.toml` (Hanzo Dev also reads legacy `~/.codex/config.toml`). Use a dotted path (`foo.bar.baz`) to override /// nested values. The `value` portion is parsed as JSON. If it fails to /// parse as JSON, the raw string is used as a literal. /// diff --git a/common/src/model_presets.rs b/common/src/model_presets.rs index 7dbb39d..b119b63 100644 --- a/common/src/model_presets.rs +++ b/common/src/model_presets.rs @@ -6,10 +6,10 @@ use code_core::protocol_config_types::ReasoningEffort; use once_cell::sync::Lazy; pub const HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG: &str = "hide_gpt5_1_migration_prompt"; -pub const HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG: &str = +pub const HIDE_GPT_5_1_DEV_MAX_MIGRATION_PROMPT_CONFIG: &str = "hide_gpt-5.1-codex-max_migration_prompt"; pub const HIDE_GPT_5_2_MIGRATION_PROMPT_CONFIG: &str = "hide_gpt5_2_migration_prompt"; -pub const HIDE_GPT_5_2_CODEX_MIGRATION_PROMPT_CONFIG: &str = "hide_gpt5_2_codex_migration_prompt"; +pub const HIDE_GPT_5_2_DEV_MIGRATION_PROMPT_CONFIG: &str = "hide_gpt5_2_codex_migration_prompt"; /// A reasoning effort option surfaced for a model. #[derive(Debug, Clone)] @@ -47,8 +47,113 @@ const ALL_TEXT_VERBOSITY: &[TextVerbosityConfig] = &[ TextVerbosityConfig::High, ]; +/// Reasoning levels offered on a model. A level means the same thing on every +/// model, so it is worded once here rather than per preset. +fn efforts(levels: &[ReasoningEffort]) -> Vec { + levels + .iter() + .map(|effort| ReasoningEffortPreset { + effort: effort.clone(), + description: match effort { + ReasoningEffort::Low => "Fast responses with lighter reasoning", + ReasoningEffort::Medium => "Balances speed and reasoning depth for everyday tasks", + _ => "Greater reasoning depth for complex problems", + } + .to_string(), + }) + .collect() +} + +/// A model served by the default `hanzo` provider (api.hanzo.ai/v1). Ids and +/// context windows come from `GET /v1/models`. +fn hanzo( + model: &str, + display_name: &str, + description: &str, + levels: &[ReasoningEffort], +) -> ModelPreset { + ModelPreset { + id: model.to_string(), + model: model.to_string(), + display_name: display_name.to_string(), + description: description.to_string(), + default_reasoning_effort: ReasoningEffort::Medium, + supported_reasoning_efforts: efforts(levels), + supported_text_verbosity: ALL_TEXT_VERBOSITY, + is_default: false, + upgrade: None, + pro_only: false, + show_in_picker: true, + } +} + static PRESETS: Lazy> = Lazy::new(|| { + let deep = [ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]; + let quick = [ReasoningEffort::Low, ReasoningEffort::Medium]; + let one = [ReasoningEffort::Medium]; + vec![ + ModelPreset { + is_default: true, + ..hanzo( + "zen5-coder", + "Zen 5 Coder", + "Agentic coding. 1M context.", + &deep, + ) + }, + hanzo( + "zen5", + "Zen 5", + "General reasoning and chat, same size and price as zen5-coder. 1M context.", + &deep, + ), + hanzo( + "zen5-pro", + "Zen 5 Pro", + "Long sessions where output tokens dominate cost; cheaper per token than zen5. 1M context.", + &deep, + ), + hanzo( + "zen5-flash", + "Zen 5 Flash", + "Quick edits and short turns. 64K context.", + &quick, + ), + hanzo( + "zen5-mini", + "Zen 5 Mini", + "Cheapest Zen 5; batch and helper turns. 32K context.", + &quick, + ), + hanzo( + "enso", + "Enso", + "Long-context reasoning outside the Zen family. 1M context.", + &deep, + ), + hanzo( + "enso-ultra", + "Enso Ultra", + "The largest Enso; slowest and most expensive of the three. 1M context.", + &deep, + ), + hanzo( + "enso-flash", + "Enso Flash", + "The fastest Enso. 256K context.", + &quick, + ), + hanzo( + "best", + "Best", + "Lets the gateway pick the model for each request.", + &one, + ), ModelPreset { id: "gpt-5.5".to_string(), model: "gpt-5.5".to_string(), @@ -75,7 +180,7 @@ static PRESETS: Lazy> = Lazy::new(|| { }, ], supported_text_verbosity: ALL_TEXT_VERBOSITY, - is_default: true, + is_default: false, upgrade: None, pro_only: false, show_in_picker: true, @@ -230,7 +335,7 @@ static PRESETS: Lazy> = Lazy::new(|| { upgrade: Some(ModelUpgrade { id: "gpt-5.3-codex".to_string(), reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_2_CODEX_MIGRATION_PROMPT_CONFIG.to_string(), + migration_config_key: HIDE_GPT_5_2_DEV_MIGRATION_PROMPT_CONFIG.to_string(), }), pro_only: false, show_in_picker: false, @@ -271,7 +376,7 @@ static PRESETS: Lazy> = Lazy::new(|| { upgrade: Some(ModelUpgrade { id: "gpt-5.3-codex".to_string(), reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_2_CODEX_MIGRATION_PROMPT_CONFIG.to_string(), + migration_config_key: HIDE_GPT_5_2_DEV_MIGRATION_PROMPT_CONFIG.to_string(), }), pro_only: false, show_in_picker: true, @@ -345,7 +450,7 @@ static PRESETS: Lazy> = Lazy::new(|| { id: "gpt-5.1-codex-max".to_string(), model: "gpt-5.1-codex-max".to_string(), display_name: "gpt-5.1-codex-max".to_string(), - description: "Latest Codex-optimized flagship for deep and fast reasoning.".to_string(), + description: "Prior Codex-optimized model, superseded by gpt-5.3-codex.".to_string(), default_reasoning_effort: ReasoningEffort::Medium, supported_reasoning_efforts: vec![ ReasoningEffortPreset { @@ -370,7 +475,7 @@ static PRESETS: Lazy> = Lazy::new(|| { upgrade: Some(ModelUpgrade { id: "gpt-5.3-codex".to_string(), reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_2_CODEX_MIGRATION_PROMPT_CONFIG.to_string(), + migration_config_key: HIDE_GPT_5_2_DEV_MIGRATION_PROMPT_CONFIG.to_string(), }), pro_only: false, show_in_picker: true, @@ -401,7 +506,7 @@ static PRESETS: Lazy> = Lazy::new(|| { upgrade: Some(ModelUpgrade { id: "gpt-5.3-codex".to_string(), reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_2_CODEX_MIGRATION_PROMPT_CONFIG.to_string(), + migration_config_key: HIDE_GPT_5_2_DEV_MIGRATION_PROMPT_CONFIG.to_string(), }), pro_only: false, show_in_picker: false, @@ -428,7 +533,7 @@ static PRESETS: Lazy> = Lazy::new(|| { upgrade: Some(ModelUpgrade { id: "gpt-5.3-codex".to_string(), reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_2_CODEX_MIGRATION_PROMPT_CONFIG.to_string(), + migration_config_key: HIDE_GPT_5_2_DEV_MIGRATION_PROMPT_CONFIG.to_string(), }), pro_only: false, show_in_picker: true, @@ -462,7 +567,7 @@ static PRESETS: Lazy> = Lazy::new(|| { upgrade: Some(ModelUpgrade { id: "gpt-5.3-codex".to_string(), reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_2_CODEX_MIGRATION_PROMPT_CONFIG.to_string(), + migration_config_key: HIDE_GPT_5_2_DEV_MIGRATION_PROMPT_CONFIG.to_string(), }), pro_only: false, show_in_picker: false, @@ -493,7 +598,7 @@ static PRESETS: Lazy> = Lazy::new(|| { upgrade: Some(ModelUpgrade { id: "gpt-5.3-codex".to_string(), reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_2_CODEX_MIGRATION_PROMPT_CONFIG.to_string(), + migration_config_key: HIDE_GPT_5_2_DEV_MIGRATION_PROMPT_CONFIG.to_string(), }), pro_only: false, show_in_picker: false, @@ -519,7 +624,7 @@ static PRESETS: Lazy> = Lazy::new(|| { upgrade: Some(ModelUpgrade { id: "gpt-5.3-codex".to_string(), reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_2_CODEX_MIGRATION_PROMPT_CONFIG.to_string(), + migration_config_key: HIDE_GPT_5_2_DEV_MIGRATION_PROMPT_CONFIG.to_string(), }), pro_only: false, show_in_picker: false, @@ -557,7 +662,7 @@ static PRESETS: Lazy> = Lazy::new(|| { upgrade: Some(ModelUpgrade { id: "gpt-5.3-codex".to_string(), reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_2_CODEX_MIGRATION_PROMPT_CONFIG.to_string(), + migration_config_key: HIDE_GPT_5_2_DEV_MIGRATION_PROMPT_CONFIG.to_string(), }), pro_only: false, show_in_picker: false, @@ -671,6 +776,32 @@ mod tests { assert_eq!(PRESETS.iter().filter(|preset| preset.is_default).count(), 1); } + #[test] + fn hanzo_catalog_is_selectable_and_zen5_coder_is_the_default() { + let presets = builtin_model_presets(Some(AuthMode::ApiKey), false); + let ids: Vec<&str> = presets.iter().map(|preset| preset.id.as_str()).collect(); + for id in [ + "zen5-coder", + "zen5", + "zen5-pro", + "zen5-flash", + "zen5-mini", + "enso", + "enso-ultra", + "enso-flash", + "best", + ] { + assert!(ids.contains(&id), "{id} is not offered by the picker"); + } + + let default = presets + .iter() + .find(|preset| preset.is_default) + .expect("a default preset"); + assert_eq!(default.id, "zen5-coder"); + assert_eq!(ids.first(), Some(&"zen5-coder")); + } + #[test] fn deprecated_and_chatgpt_only_codex_models_hidden_for_api_key_auth() { let presets = builtin_model_presets(Some(AuthMode::ApiKey), false); diff --git a/config.md b/config.md index a6f2dab..aaa540d 100644 --- a/config.md +++ b/config.md @@ -8,7 +8,7 @@ This file has moved. Please see the latest configuration documentation here: - Values can contain objects, such as `--config shell_environment_policy.include_only=["PATH", "HOME", "USER"]`. - For consistency with `config.toml`, values are in TOML format rather than JSON format, so use `{a = 1, b = 2}` rather than `{"a": 1, "b": 2}`. - If `value` cannot be parsed as a valid TOML value, it is treated as a string value. This means that both `-c model="o3"` and `-c model=o3` are equivalent. -- The `$CODE_HOME/config.toml` configuration file. `CODE_HOME` defaults to `~/.code`; Code also reads from `$CODEX_HOME`/`~/.codex` for backwards compatibility but only writes to `~/.code`. (Logs and other state use the same directory.) +- The `$CODE_HOME/config.toml` configuration file. `CODE_HOME` defaults to `~/.code`; Code also reads from `$DEV_HOME`/`~/.codex` for backwards compatibility but only writes to `~/.code`. (Logs and other state use the same directory.) Both the `--config` flag and the `config.toml` file support the following options: @@ -524,7 +524,7 @@ If subagents can't detect Git, ensure: 2. You're running Code from a Git repository 3. The `.git` directory is accessible -For more details, see the [FAQ](https://github.com/just-every/code/blob/main/docs/faq.md). +For more details, see the [FAQ](https://github.com/hanzoai/dev/blob/main/docs/faq.md). ## disable_response_storage @@ -574,7 +574,7 @@ inherit = "none" set = { PATH = "/usr/bin", MY_FLAG = "1" } ``` -Currently, `CODEX_SANDBOX_NETWORK_DISABLED=1` is also added to the environment, assuming network is disabled. This is not configurable. +Currently, `DEV_SANDBOX_NETWORK_DISABLED=1` is also added to the environment, assuming network is disabled. This is not configurable. ## notify @@ -655,7 +655,7 @@ notify = ["python3", "/Users/mbolin/.codex/notify.py"] ## history -By default, Codex CLI records messages sent to the model in `$CODEX_HOME/history.jsonl`. Note that on UNIX, the file permissions are set to `o600`, so it should only be readable and writable by the owner. +By default, Codex CLI records messages sent to the model in `$DEV_HOME/history.jsonl`. Note that on UNIX, the file permissions are set to `o600`, so it should only be readable and writable by the owner. To disable this behavior, configure `[history]` as follows: diff --git a/core/Cargo.toml b/core/Cargo.toml index 8d284b8..81dae16 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -73,6 +73,7 @@ time = { workspace = true, features = [ tokio = { workspace = true, features = [ "io-std", "macros", + "net", "process", "rt-multi-thread", "signal", diff --git a/core/gpt-5.1-codex-max_prompt.md b/core/gpt-5.1-codex-max_prompt.md index 8e3f08f..ac1cb90 100644 --- a/core/gpt-5.1-codex-max_prompt.md +++ b/core/gpt-5.1-codex-max_prompt.md @@ -1,4 +1,4 @@ -You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer. +You are a coding agent based on GPT-5, running in Hanzo Dev on a user's computer. ## General diff --git a/core/gpt-5.2-codex_prompt.md b/core/gpt-5.2-codex_prompt.md index 8e3f08f..ac1cb90 100644 --- a/core/gpt-5.2-codex_prompt.md +++ b/core/gpt-5.2-codex_prompt.md @@ -1,4 +1,4 @@ -You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer. +You are a coding agent based on GPT-5, running in Hanzo Dev on a user's computer. ## General diff --git a/core/gpt_5_1_prompt.md b/core/gpt_5_1_prompt.md index da2ec67..977ddbe 100644 --- a/core/gpt_5_1_prompt.md +++ b/core/gpt_5_1_prompt.md @@ -1,4 +1,4 @@ -You are GPT-5.1 running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful. +You are GPT-5.1 running in Hanzo Dev, a terminal-based coding assistant. You are expected to be precise, safe, and helpful. Your capabilities: @@ -6,7 +6,6 @@ Your capabilities: - Communicate with the user by streaming thinking & responses, and by making & updating plans. - Emit function calls to run terminal commands and apply patches. Depending on how this specific run is configured, you can request that these function calls be escalated to the user for approval before running. More on this in the "Sandbox and approvals" section. -Within this context, Codex refers to the open-source agentic coding interface (not the old Codex language model built by OpenAI). # How you work diff --git a/core/gpt_5_2_prompt.md b/core/gpt_5_2_prompt.md index 8aa188f..876a218 100644 --- a/core/gpt_5_2_prompt.md +++ b/core/gpt_5_2_prompt.md @@ -1,4 +1,4 @@ -You are GPT-5.2 running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful. +You are GPT-5.2 running in Hanzo Dev, a terminal-based coding assistant. You are expected to be precise, safe, and helpful. Your capabilities: @@ -6,7 +6,6 @@ Your capabilities: - Communicate with the user by streaming thinking & responses, and by making & updating plans. - Emit function calls to run terminal commands and apply patches. Depending on how this specific run is configured, you can request that these function calls be escalated to the user for approval before running. More on this in the "Sandbox and approvals" section. -Within this context, Codex refers to the open-source agentic coding interface (not the old Codex language model built by OpenAI). # How you work diff --git a/core/gpt_5_codex_prompt.md b/core/gpt_5_codex_prompt.md index 88a569f..e60c47a 100644 --- a/core/gpt_5_codex_prompt.md +++ b/core/gpt_5_codex_prompt.md @@ -1,4 +1,4 @@ -You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer. +You are a coding agent based on GPT-5, running in Hanzo Dev on a user's computer. ## General diff --git a/core/prompt.md b/core/prompt.md index 907ff8b..3277caa 100644 --- a/core/prompt.md +++ b/core/prompt.md @@ -1,4 +1,4 @@ -You are a coding agent running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful. +You are a coding agent running in Hanzo Dev, a terminal-based coding assistant. You are expected to be precise, safe, and helpful. Your capabilities: @@ -6,7 +6,6 @@ Your capabilities: - Communicate with the user by streaming thinking & responses, and by making & updating plans. - Emit function calls to run terminal commands and apply patches. Depending on how this specific run is configured, you can request that these function calls be escalated to the user for approval before running. More on this in the "Sandbox and approvals" section. -Within this context, Codex refers to the open-source agentic coding interface (not the old Codex language model built by OpenAI). # How you work diff --git a/core/prompt_coder.md b/core/prompt_coder.md index 4836187..de48d34 100644 --- a/core/prompt_coder.md +++ b/core/prompt_coder.md @@ -1,10 +1,9 @@ -In this environment, you are running as `code` and your name is Code. Code is a fork Codex CLI, an open source project led by OpenAI. +In this environment, you are running as `dev` and your name is Hanzo Dev. -Code is a fast, community-driven fork focused on key developer ergonomics: Browser control, multi-agent flows, autonomous tasks, and on-the-fly reasoning control - all while staying compatible with upstream. +Hanzo Dev is the Hanzo coding agent: browser control, multi-agent flows, autonomous long-running tasks, and on-the-fly reasoning control. You have independent control over your environment and should complete tasks without asking for human assistance. -# Changes - -This version has a few key changes and additions. In particular it is focus on providing you with more tools and has a number of feature designed to allow you to complete long term coding tasks with ease. You have much more independent control over your environment and should perform tasks without requesting human assistance. +## Starting an app +When asked to build an app, service or tool from scratch, the first step is to look for an existing open-source project to fork as the starting point — search GitHub, weigh license, activity and fit, and say what you found and why you chose it (or why nothing fit). Fork and adapt; write from a blank file only what does not already exist. A working project with a community beats a first draft every time. ## Code design Focus on producing final, maintable, production ready code every time. diff --git a/core/prompt_with_apply_patch_instructions.md b/core/prompt_with_apply_patch_instructions.md index 2650c67..f5ef0ef 100644 --- a/core/prompt_with_apply_patch_instructions.md +++ b/core/prompt_with_apply_patch_instructions.md @@ -1,4 +1,4 @@ -You are a coding agent running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful. +You are a coding agent running in Hanzo Dev, a terminal-based coding assistant. You are expected to be precise, safe, and helpful. Your capabilities: @@ -6,7 +6,6 @@ Your capabilities: - Communicate with the user by streaming thinking & responses, and by making & updating plans. - Emit function calls to run terminal commands and apply patches. Depending on how this specific run is configured, you can request that these function calls be escalated to the user for approval before running. More on this in the "Sandbox and approvals" section. -Within this context, Codex refers to the open-source agentic coding interface (not the old Codex language model built by OpenAI). # How you work diff --git a/core/src/agent_defaults.rs b/core/src/agent_defaults.rs index 70cad29..54bda13 100644 --- a/core/src/agent_defaults.rs +++ b/core/src/agent_defaults.rs @@ -13,8 +13,8 @@ use std::sync::LazyLock; const CLAUDE_ALLOWED_TOOLS: &str = "Bash(ls:*), Bash(cat:*), Bash(grep:*), Bash(git status:*), Bash(git log:*), Bash(find:*), Read, Grep, Glob, LS, WebFetch, TodoRead, TodoWrite, WebSearch"; const CLOUD_MODEL_ENV_FLAG: &str = "CODE_ENABLE_CLOUD_AGENT_MODEL"; -const CODE_GPT5_CODEX_READ_ONLY: &[&str] = &["-s", "read-only", "exec", "--skip-git-repo-check"]; -const CODE_GPT5_CODEX_WRITE: &[&str] = &["-s", "workspace-write", "--dangerously-bypass-approvals-and-sandbox", "exec", "--skip-git-repo-check"]; +const CODE_GPT5_DEV_READ_ONLY: &[&str] = &["-s", "read-only", "exec", "--skip-git-repo-check"]; +const CODE_GPT5_DEV_WRITE: &[&str] = &["-s", "workspace-write", "--dangerously-bypass-approvals-and-sandbox", "exec", "--skip-git-repo-check"]; const CODE_GPT5_READ_ONLY: &[&str] = &["-s", "read-only", "exec", "--skip-git-repo-check"]; const CODE_GPT5_WRITE: &[&str] = &["-s", "workspace-write", "--dangerously-bypass-approvals-and-sandbox", "exec", "--skip-git-repo-check"]; const CLAUDE_SONNET_READ_ONLY: &[&str] = &["--allowedTools", CLAUDE_ALLOWED_TOOLS]; @@ -31,9 +31,9 @@ const COPILOT_READ_ONLY: &[&str] = &["--autopilot", "--allow-all-tools", "--no-a const COPILOT_WRITE: &[&str] = &["--autopilot", "--yolo", "--no-ask-user", "-s"]; const QWEN_3_CODER_READ_ONLY: &[&str] = &[]; const QWEN_3_CODER_WRITE: &[&str] = &["-y"]; -const CLOUD_GPT5_CODEX_READ_ONLY: &[&str] = &[]; -const CLOUD_GPT5_CODEX_WRITE: &[&str] = &[]; -const MODELS_MANIFEST: &str = include_str!("../../../codex-rs/models-manager/models.json"); +const CLOUD_GPT5_DEV_READ_ONLY: &[&str] = &[]; +const CLOUD_GPT5_DEV_WRITE: &[&str] = &[]; +const MODELS_MANIFEST: &str = include_str!("../../../vendor/codex/models-manager/models.json"); /// Canonical list of built-in agent model slugs used when no `[[agents]]` /// entries are configured. The ordering here controls priority for legacy @@ -122,8 +122,8 @@ const AGENT_MODEL_SPECS: &[AgentModelSpec] = &[ slug: "code-gpt-5.6-luna", family: "code", cli: "coder", - read_only_args: CODE_GPT5_CODEX_READ_ONLY, - write_args: CODE_GPT5_CODEX_WRITE, + read_only_args: CODE_GPT5_DEV_READ_ONLY, + write_args: CODE_GPT5_DEV_WRITE, model_args: &["--model", "gpt-5.6-luna"], description: "Budget coding agent for small changes and quick refactors; use when speed and cost matter.", enabled_by_default: true, @@ -145,8 +145,8 @@ const AGENT_MODEL_SPECS: &[AgentModelSpec] = &[ slug: "code-gpt-5.3-codex", family: "code", cli: "coder", - read_only_args: CODE_GPT5_CODEX_READ_ONLY, - write_args: CODE_GPT5_CODEX_WRITE, + read_only_args: CODE_GPT5_DEV_READ_ONLY, + write_args: CODE_GPT5_DEV_WRITE, model_args: &["--model", "gpt-5.3-codex"], description: "Primary coding agent for implementation and multi-file edits; strong speed and reliability.", enabled_by_default: false, @@ -169,8 +169,8 @@ const AGENT_MODEL_SPECS: &[AgentModelSpec] = &[ slug: "code-gpt-5.3-codex-spark", family: "code", cli: "coder", - read_only_args: CODE_GPT5_CODEX_READ_ONLY, - write_args: CODE_GPT5_CODEX_WRITE, + read_only_args: CODE_GPT5_DEV_READ_ONLY, + write_args: CODE_GPT5_DEV_WRITE, model_args: &["--model", "gpt-5.3-codex-spark"], description: "Fast codex variant tuned for responsive coding loops and smaller edits.", enabled_by_default: false, @@ -302,8 +302,8 @@ const AGENT_MODEL_SPECS: &[AgentModelSpec] = &[ slug: "cloud-gpt-5.1-codex-max", family: "cloud", cli: "cloud", - read_only_args: CLOUD_GPT5_CODEX_READ_ONLY, - write_args: CLOUD_GPT5_CODEX_WRITE, + read_only_args: CLOUD_GPT5_DEV_READ_ONLY, + write_args: CLOUD_GPT5_DEV_WRITE, model_args: &["--model", "gpt-5.1-codex-max"], description: "Cloud-hosted gpt-5.1-codex-max agent; use for remote runs when enabled via CODE_ENABLE_CLOUD_AGENT_MODEL.", enabled_by_default: false, diff --git a/core/src/agent_tool.rs b/core/src/agent_tool.rs index bea6599..1ba394d 100644 --- a/core/src/agent_tool.rs +++ b/core/src/agent_tool.rs @@ -263,7 +263,7 @@ fn fallback_code_binary_path() -> Option { // If the running binary was pruned (e.g., shared target cache rotation), try to locate // a fresh dev build in the repository, and if missing, trigger a quick rebuild. let repo_root = find_repo_root(std::env::current_dir().ok()?)?; - let workspace = repo_root.join("code-rs"); + let workspace = repo_root.join("hanzo-dev"); // Probe likely build outputs in priority order. let mut candidates = vec![ @@ -322,7 +322,7 @@ fn format_agent_not_found_error(agent_name: &str, command: &str) -> String { command = \"C:\\\\Users\\\\YourUser\\\\AppData\\\\Roaming\\\\npm\\\\{}.cmd\"\n\ 3. Verify your PATH includes the directory containing '{}'\n\ 4. On Windows, ensure the file has a valid extension (.exe, .cmd, .bat, .com)\n\n\ - For more information, see: https://github.com/just-every/code/blob/main/code-rs/config.md", + For more information, see: https://github.com/hanzoai/dev/blob/main/hanzo-dev/config.md", command, agent_name, command, command )); } @@ -337,7 +337,7 @@ fn format_agent_not_found_error(agent_name: &str, command: &str) -> String { [[agents]]\n\ name = \"{}\"\n\ command = \"/absolute/path/to/{}\"\n\n\ - For more information, see: https://github.com/just-every/code/blob/main/code-rs/config.md", + For more information, see: https://github.com/hanzoai/dev/blob/main/hanzo-dev/config.md", command, command, command, agent_name, command )); } diff --git a/core/src/apply_patch.rs b/core/src/apply_patch.rs index c31d1ec..e51b1a5 100644 --- a/core/src/apply_patch.rs +++ b/core/src/apply_patch.rs @@ -20,7 +20,7 @@ use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; -pub const CODEX_APPLY_PATCH_ARG1: &str = "--codex-run-as-apply-patch"; +pub const DEV_APPLY_PATCH_ARG1: &str = "--codex-run-as-apply-patch"; pub(crate) struct ApplyPatchRun { pub auto_approved: bool, diff --git a/core/src/auth.rs b/core/src/auth.rs index f3710cc..f047185 100644 --- a/core/src/auth.rs +++ b/core/src/auth.rs @@ -24,6 +24,11 @@ use crate::token_data::parse_jwt_expiration; use crate::config::resolve_code_path_for_read; use crate::util::backoff; +/// The command a human runs to authenticate. It lives here once so no message +/// can drift into naming a command that does not exist — `hanzo login` is not +/// one, and gets swallowed as an AI task. +pub const LOGIN_COMMAND: &str = "hanzo auth login"; + #[derive(Debug, Clone)] pub struct CodexAuth { pub mode: AuthMode, @@ -81,7 +86,7 @@ impl std::error::Error for RefreshTokenError {} const REFRESH_TOKEN_ACCOUNT_MISMATCH_MESSAGE: &str = "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again."; const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; -pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE"; +pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "DEV_REFRESH_TOKEN_URL_OVERRIDE"; impl PartialEq for CodexAuth { fn eq(&self, other: &Self) -> bool { @@ -371,7 +376,7 @@ fn should_proactively_refresh_auth( } pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; -pub const CODEX_API_KEY_ENV_VAR: &str = "CODEX_API_KEY"; +pub const HANZO_API_KEY_ENV_VAR: &str = "HANZO_API_KEY"; fn read_openai_api_key_from_env() -> Option { env::var(OPENAI_API_KEY_ENV_VAR) @@ -380,7 +385,7 @@ fn read_openai_api_key_from_env() -> Option { } pub fn read_code_api_key_from_env() -> Option { - env::var(CODEX_API_KEY_ENV_VAR) + env::var(HANZO_API_KEY_ENV_VAR) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) @@ -730,7 +735,7 @@ fn load_auth( } None => { // We have an API key but no tokens in the auth.json file. - // Perhaps the user ran `codex login --api-key ` or updated + // Perhaps the user ran `dev login --api-key ` or updated // auth.json by hand. Either way, let's assume they are trying // to use their API key. return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client))); @@ -754,7 +759,7 @@ fn load_auth( })) } -/// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory. +/// Attempt to read and refresh the `auth.json` file in the given `DEV_HOME` directory. /// Returns the full AuthDotJson structure after refreshing if necessary. pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result { let mut file = File::open(auth_file)?; @@ -991,7 +996,7 @@ fn summarize_body(body: &str) -> String { } } -/// Expected structure for $CODEX_HOME/auth.json. +/// Expected structure for $DEV_HOME/auth.json. #[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] pub struct AuthDotJson { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1009,6 +1014,15 @@ pub struct AuthDotJson { // Shared constant for token refresh (client id used for oauth token refresh flow) pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; + +/// Hanzo IAM's OIDC issuer. hanzo.id serves OIDC under `/v1/iam`, so +/// `{issuer}/oauth/device` and `{issuer}/oauth/token` resolve to the endpoints +/// advertised by `hanzo.id/.well-known/openid-configuration`. +pub const HANZO_ISSUER: &str = "https://hanzo.id/v1/iam"; +/// The public client registered for the device grant. It holds no secret, which +/// is what a CLI needs: `hanzo-app` is confidential and answers an +/// unauthenticated device request with `invalid_client`. +pub const HANZO_CLIENT_ID: &str = "hanzo-cli"; const CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES: i64 = 5; use std::sync::RwLock; diff --git a/core/src/cgroup.rs b/core/src/cgroup.rs index 84d08ec..bfe7744 100644 --- a/core/src/cgroup.rs +++ b/core/src/cgroup.rs @@ -12,14 +12,14 @@ const EXEC_CGROUP_OOM_SCORE_ADJ: &str = "500"; #[cfg(target_os = "linux")] pub(crate) fn default_exec_memory_max_bytes() -> Option { - if let Ok(raw) = std::env::var("CODEX_EXEC_MEMORY_MAX_BYTES") { + if let Ok(raw) = std::env::var("DEV_EXEC_MEMORY_MAX_BYTES") { if let Ok(value) = raw.trim().parse::() { if value > 0 { return Some(value); } } } - if let Ok(raw) = std::env::var("CODEX_EXEC_MEMORY_MAX_MB") { + if let Ok(raw) = std::env::var("DEV_EXEC_MEMORY_MAX_MB") { if let Ok(value) = raw.trim().parse::() { if value > 0 { return Some(value.saturating_mul(1024 * 1024)); diff --git a/core/src/chat_completions.rs b/core/src/chat_completions.rs index 9719d43..60da586 100644 --- a/core/src/chat_completions.rs +++ b/core/src/chat_completions.rs @@ -370,7 +370,7 @@ pub(crate) async fn stream_chat_completions( // If an Ollama context override is present, propagate it. Some Ollama // builds honor `num_ctx` directly in OpenAI-compatible Chat Completions, // and others accept it under an `options` object – include both. - if let Ok(val) = std::env::var("CODEX_OLLAMA_NUM_CTX") { + if let Ok(val) = std::env::var("DEV_OLLAMA_NUM_CTX") { if let Ok(n) = val.parse::() { if let Some(obj) = payload.as_object_mut() { obj.insert("num_ctx".to_string(), json!(n)); diff --git a/core/src/client.rs b/core/src/client.rs index cadb7ea..47257e3 100644 --- a/core/src/client.rs +++ b/core/src/client.rs @@ -67,7 +67,7 @@ use crate::error::ModelCapError; use crate::error::RetryLimitReachedError; use crate::error::UnexpectedResponseError; use crate::error::UsageLimitReachedError; -use crate::flags::CODEX_RS_SSE_FIXTURE; +use crate::flags::DEV_RS_SSE_FIXTURE; use crate::model_family::{find_family_for_model, ModelFamily}; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::WireApi; @@ -108,10 +108,10 @@ fn preferred_ws_version_from_env() -> ResponsesWebsocketVersion { // Sticky-routing token captured at the start of a turn. When present, it must // be replayed on every subsequent request within the same turn (retries, // continuations, websocket reconnects). -const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state"; -const X_CODEX_ROUTING_HINT_HEADER: &str = "x-codex-routing-hint"; -const X_CODEX_WINDOW_ID_HEADER: &str = "x-codex-window-id"; -const X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER: &str = +const X_DEV_TURN_STATE_HEADER: &str = "x-codex-turn-state"; +const X_DEV_ROUTING_HINT_HEADER: &str = "x-codex-routing-hint"; +const X_DEV_WINDOW_ID_HEADER: &str = "x-codex-window-id"; +const X_OPENAI_INTERNAL_DEV_RESPONSES_LITE_HEADER: &str = "x-openai-internal-codex-responses-lite"; const MODEL_CAP_MODEL_HEADER: &str = "x-codex-model-cap-model"; @@ -468,7 +468,7 @@ impl ModelClient { ("session_id".to_string(), session_id_str.clone()), ("thread_id".to_string(), session_id_str), ( - X_CODEX_WINDOW_ID_HEADER.to_string(), + X_DEV_WINDOW_ID_HEADER.to_string(), self.current_window_id(session_id), ), ]) @@ -921,7 +921,7 @@ impl ModelClient { if let Some(header_value) = self.build_routing_hint_header(auth.as_ref(), request_model, service_tier.as_deref()) { - req_builder = req_builder.header(X_CODEX_ROUTING_HINT_HEADER, header_value); + req_builder = req_builder.header(X_DEV_ROUTING_HINT_HEADER, header_value); } let has_beta_header = req_builder @@ -943,14 +943,14 @@ impl ModelClient { req_builder = attach_responses_lite_header(req_builder, request_family.use_responses_lite); if let Some(state) = turn_state.get() { - req_builder = req_builder.header(X_CODEX_TURN_STATE_HEADER, state); + req_builder = req_builder.header(X_DEV_TURN_STATE_HEADER, state); } req_builder = req_builder .header("conversation_id", session_id_str.clone()) .header("session_id", session_id_str.clone()) .header("thread_id", session_id_str.clone()); if let Ok(window_id) = HeaderValue::from_str(&self.current_window_id(session_id)) { - req_builder = req_builder.header(X_CODEX_WINDOW_ID_HEADER, window_id); + req_builder = req_builder.header(X_DEV_WINDOW_ID_HEADER, window_id); } if let Some(auth) = auth.as_ref() @@ -1032,7 +1032,7 @@ impl ModelClient { if let Some(value) = response .headers() - .get(X_CODEX_TURN_STATE_HEADER) + .get(X_DEV_TURN_STATE_HEADER) .and_then(|value| value.to_str().ok()) { if let Some(existing) = turn_state.get() @@ -1218,7 +1218,7 @@ impl ModelClient { /// Implementation for the OpenAI *Responses* experimental API. async fn stream_responses(&self, prompt: &Prompt, log_tag: Option<&str>) -> Result { - if let Some(path) = &*CODEX_RS_SSE_FIXTURE { + if let Some(path) = &*DEV_RS_SSE_FIXTURE { // short circuit for tests warn!(path, "Streaming from fixture"); return stream_from_fixture(path, self.provider.clone(), self.otel_event_manager.clone()) @@ -1425,7 +1425,7 @@ impl ModelClient { if let Some(header_value) = self.build_routing_hint_header(auth.as_ref(), request_model, service_tier.as_deref()) { - req_builder = req_builder.header(X_CODEX_ROUTING_HINT_HEADER, header_value); + req_builder = req_builder.header(X_DEV_ROUTING_HINT_HEADER, header_value); } let has_beta_header = req_builder @@ -1447,7 +1447,7 @@ impl ModelClient { req_builder = attach_responses_lite_header(req_builder, request_family.use_responses_lite); if let Some(state) = turn_state.get() { - req_builder = req_builder.header(X_CODEX_TURN_STATE_HEADER, state); + req_builder = req_builder.header(X_DEV_TURN_STATE_HEADER, state); } req_builder = req_builder @@ -1458,7 +1458,7 @@ impl ModelClient { .header(reqwest::header::ACCEPT, "text/event-stream") .json(&payload_json); if let Ok(window_id) = HeaderValue::from_str(&self.current_window_id(session_id)) { - req_builder = req_builder.header(X_CODEX_WINDOW_ID_HEADER, window_id); + req_builder = req_builder.header(X_DEV_WINDOW_ID_HEADER, window_id); } if let Some(auth) = auth.as_ref() @@ -1507,7 +1507,7 @@ impl ModelClient { Ok(resp) if resp.status().is_success() => { if let Some(value) = resp .headers() - .get(X_CODEX_TURN_STATE_HEADER) + .get(X_DEV_TURN_STATE_HEADER) .and_then(|value| value.to_str().ok()) { if let Some(existing) = turn_state.get() @@ -1601,7 +1601,7 @@ impl ModelClient { let status = res.status(); let headers = res.headers().clone(); if let Some(value) = headers - .get(X_CODEX_TURN_STATE_HEADER) + .get(X_DEV_TURN_STATE_HEADER) .and_then(|value| value.to_str().ok()) { if let Some(existing) = turn_state.get() @@ -2078,7 +2078,7 @@ impl ModelClient { if let Some(header_value) = self.build_routing_hint_header(auth.as_ref(), model_slug, service_tier.as_deref()) { - request = request.header(X_CODEX_ROUTING_HINT_HEADER, header_value); + request = request.header(X_DEV_ROUTING_HINT_HEADER, header_value); } // Ensure Responses API beta header is present for compact calls. Mirror the @@ -2102,10 +2102,10 @@ impl ModelClient { request = attach_codex_beta_features_header(request, &self.config); request = attach_responses_lite_header(request, family.use_responses_lite); if let Some(state) = turn_state.get() { - request = request.header(X_CODEX_TURN_STATE_HEADER, state); + request = request.header(X_DEV_TURN_STATE_HEADER, state); } if let Ok(window_id) = HeaderValue::from_str(&self.current_window_id(session_id)) { - request = request.header(X_CODEX_WINDOW_ID_HEADER, window_id); + request = request.header(X_DEV_WINDOW_ID_HEADER, window_id); } request = request @@ -2148,7 +2148,7 @@ impl ModelClient { let status = response.status(); let headers = response.headers().clone(); if let Some(value) = headers - .get(X_CODEX_TURN_STATE_HEADER) + .get(X_DEV_TURN_STATE_HEADER) .and_then(|value| value.to_str().ok()) { if let Some(existing) = turn_state.get() @@ -2272,7 +2272,7 @@ fn attach_responses_lite_header( use_responses_lite: bool, ) -> reqwest::RequestBuilder { if use_responses_lite { - builder.header(X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER, "true") + builder.header(X_OPENAI_INTERNAL_DEV_RESPONSES_LITE_HEADER, "true") } else { builder } @@ -3282,6 +3282,7 @@ mod tests { name: name.to_string(), base_url: Some("https://api.openai.com/v1".to_string()), env_key: None, + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -3406,6 +3407,7 @@ mod tests { name: "openai".to_string(), base_url: Some("https://api.openai.com/v1".to_string()), env_key: None, + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -3456,6 +3458,7 @@ mod tests { name: "backend".to_string(), base_url: Some("https://chatgpt.com/backend-api/codex".to_string()), env_key: None, + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -3508,6 +3511,7 @@ mod tests { name: "custom".to_string(), base_url: Some("https://api.openai.com/v1".to_string()), env_key: None, + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -3550,6 +3554,7 @@ mod tests { name: "openai".to_string(), base_url: Some("https://api.openai.com/v1".to_string()), env_key: None, + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -3582,7 +3587,7 @@ mod tests { let header_value = request .headers() - .get(X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER) + .get(X_OPENAI_INTERNAL_DEV_RESPONSES_LITE_HEADER) .expect("Responses Lite header present"); assert_eq!(header_value, "true"); } @@ -3700,6 +3705,7 @@ mod tests { name: "test".to_string(), base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -3789,6 +3795,7 @@ mod tests { name: "test".to_string(), base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -3837,6 +3844,7 @@ mod tests { name: "test".to_string(), base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -3891,6 +3899,7 @@ mod tests { name: "test".to_string(), base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -3988,6 +3997,7 @@ mod tests { name: "test".to_string(), base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -4097,6 +4107,7 @@ mod tests { name: "test".to_string(), base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -4339,6 +4350,7 @@ mod tests { name: "test".to_string(), base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -4372,6 +4384,7 @@ mod tests { name: "test".to_string(), base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -4408,6 +4421,7 @@ mod tests { name: "test".to_string(), base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -4441,6 +4455,7 @@ mod tests { name: "test".to_string(), base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -4474,6 +4489,7 @@ mod tests { name: "test".to_string(), base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, diff --git a/core/src/codex/compact.rs b/core/src/codex/compact.rs index ee2a8b1..2f159dd 100644 --- a/core/src/codex/compact.rs +++ b/core/src/codex/compact.rs @@ -49,7 +49,7 @@ const COMPACTION_EMERGENCY_MESSAGE: &str = "⚠️ Compaction failed: The conver /// Determine whether to use remote compaction (ChatGPT-based) or local compaction. /// /// Upstream codex-rs checks if auth mode is ChatGPT and RemoteCompaction feature is enabled. -/// In code-rs, remote compaction infrastructure is not yet implemented, so this always +/// In hanzo-dev, remote compaction infrastructure is not yet implemented, so this always /// returns false (always use local compaction). /// /// TODO: Once ChatGPT auth and remote compaction are implemented, update this to: diff --git a/core/src/codex/exec.rs b/core/src/codex/exec.rs index 45ae9bf..ced724f 100644 --- a/core/src/codex/exec.rs +++ b/core/src/codex/exec.rs @@ -418,6 +418,10 @@ impl Session { if is_apply_patch { let unified_diff = turn_diff_tracker.get_unified_diff(); if let Ok(Some(unified_diff)) = unified_diff { + // The diff says what moved; a picture says what it looks like. + // Reads the same value the reviewer is about to, and returns + // before the browser it may launch has started. + crate::shot::capture(self, &unified_diff); let msg = EventMsg::TurnDiff(TurnDiffEvent { unified_diff }); let event = self.make_event(sub_id, msg); let _ = self.tx_event.send(event).await; diff --git a/core/src/codex/session.rs b/core/src/codex/session.rs index b55bef8..98a3592 100644 --- a/core/src/codex/session.rs +++ b/core/src/codex/session.rs @@ -644,7 +644,7 @@ impl Session { .map(|auth| auth.mode); let default_model_slug = if auth_mode.is_some_and(code_app_server_protocol::AuthMode::is_chatgpt) { - crate::config::GPT_5_CODEX_MEDIUM_MODEL + crate::config::GPT_5_DEV_MEDIUM_MODEL } else { crate::config::OPENAI_DEFAULT_MODEL }; diff --git a/core/src/codex/streaming.rs b/core/src/codex/streaming.rs index 22e03ae..c0dd11e 100644 --- a/core/src/codex/streaming.rs +++ b/core/src/codex/streaming.rs @@ -53,7 +53,7 @@ const SEARCH_TOOL_DEVELOPER_INSTRUCTIONS: &str = include_str!("../../templates/search_tool/developer_instructions.md"); const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; const LEGACY_SEARCH_TOOL_BM25_TOOL_NAME: &str = "search_tool_bm25"; -const CODEX_APPS_TOOL_PREFIX: &str = "mcp__codex_apps__"; +const DEV_APPS_TOOL_PREFIX: &str = "mcp__codex_apps__"; const GENERATED_IMAGE_ARTIFACTS_DIR: &str = "generated_images"; const AUTO_CONTEXT_JUDGE_MIN_TOKENS: u64 = 150_000; const AUTO_CONTEXT_FORCE_COMPACT_MARGIN_TOKENS: u64 = 20_000; @@ -3670,7 +3670,7 @@ fn select_mcp_tools_for_turn( mcp_tools .into_iter() .filter(|(name, _tool)| { - if !name.starts_with(CODEX_APPS_TOOL_PREFIX) { + if !name.starts_with(DEV_APPS_TOOL_PREFIX) { return true; } selected.contains(name) @@ -10850,6 +10850,9 @@ async fn handle_container_exec_with_params( .await; if let Ok(Some(unified_diff)) = turn_diff_tracker.get_unified_diff() { + // The diff says what moved; a picture says what it looks + // like. Same value the reviewer is about to read. + crate::shot::capture(sess, &unified_diff); let diff_event = sess.make_event( &sub_id, EventMsg::TurnDiff(TurnDiffEvent { unified_diff }), @@ -13995,27 +13998,27 @@ mod command_guard_detection_tests { let argv = vec![ "bash".to_string(), "-lc".to_string(), - "cat <<'EOF' > code-rs/git-tooling/Cargo.toml\n[package]\nname = \"demo\"\nEOF".to_string(), + "cat <<'EOF' > hanzo-dev/git-tooling/Cargo.toml\n[package]\nname = \"demo\"\nEOF".to_string(), ]; let suggestion = detect_cat_write(&argv).expect("should flag cat write"); assert_eq!(suggestion.label, "original_script"); assert!(suggestion .original_value - .contains("cat <<'EOF' > code-rs/git-tooling/Cargo.toml")); + .contains("cat <<'EOF' > hanzo-dev/git-tooling/Cargo.toml")); } #[test] fn detects_raw_shell_script_cat_heredoc_write() { let argv = vec![ - "cat <<'EOF' > code-rs/git-tooling/Cargo.toml\n[package]\nname = \"demo\"\nEOF".to_string(), + "cat <<'EOF' > hanzo-dev/git-tooling/Cargo.toml\n[package]\nname = \"demo\"\nEOF".to_string(), ]; let suggestion = detect_cat_write(&argv).expect("should flag cat write"); assert_eq!(suggestion.label, "original_script"); assert!(suggestion .original_value - .contains("cat <<'EOF' > code-rs/git-tooling/Cargo.toml")); + .contains("cat <<'EOF' > hanzo-dev/git-tooling/Cargo.toml")); } #[test] @@ -14338,7 +14341,7 @@ pub(super) fn debug_history(label: &str, items: &[ResponseItem]) { }) .collect(); let rendered = preview.join(" | "); - if std::env::var_os("CODEX_COMPACT_TRACE").is_some() { + if std::env::var_os("DEV_COMPACT_TRACE").is_some() { eprintln!("[compact_history] {} => [{}]", label, rendered); } info!(target = "code_core::compact_history", "{} => [{}]", label, rendered); diff --git a/core/src/config/sources.rs b/core/src/config/sources.rs index a76a926..6485be9 100644 --- a/core/src/config/sources.rs +++ b/core/src/config/sources.rs @@ -293,7 +293,7 @@ pub async fn persist_model_selection( Ok(()) } -/// Patch `CODEX_HOME/config.toml` project state. +/// Patch `DEV_HOME/config.toml` project state. /// Use with caution. pub fn set_project_trusted(code_home: &Path, project_path: &Path) -> anyhow::Result<()> { let config_path = code_home.join(CONFIG_TOML_FILE); @@ -380,7 +380,7 @@ fn set_project_trusted_inner(doc: &mut DocumentMut, project_path: &Path) -> anyh Ok(()) } -/// Persist the selected TUI theme into `CODEX_HOME/config.toml` at `[tui.theme].name`. +/// Persist the selected TUI theme into `DEV_HOME/config.toml` at `[tui.theme].name`. pub fn set_tui_theme_name(code_home: &Path, theme: ThemeName) -> anyhow::Result<()> { let config_path = code_home.join(CONFIG_TOML_FILE); @@ -486,7 +486,7 @@ pub fn set_cached_terminal_background( Ok(()) } -/// Persist the selected spinner into `CODEX_HOME/config.toml` at `[tui.spinner].name`. +/// Persist the selected spinner into `DEV_HOME/config.toml` at `[tui.spinner].name`. pub fn set_tui_spinner_name(code_home: &Path, spinner_name: &str) -> anyhow::Result<()> { let config_path = code_home.join(CONFIG_TOML_FILE); @@ -622,7 +622,7 @@ pub fn set_custom_theme( Ok(()) } -/// Persist the alternate screen preference into `CODEX_HOME/config.toml` at `[tui].alternate_screen`. +/// Persist the alternate screen preference into `DEV_HOME/config.toml` at `[tui].alternate_screen`. pub fn set_tui_alternate_screen(code_home: &Path, enabled: bool) -> anyhow::Result<()> { let config_path = code_home.join(CONFIG_TOML_FILE); @@ -650,7 +650,7 @@ pub fn set_tui_alternate_screen(code_home: &Path, enabled: bool) -> anyhow::Resu Ok(()) } -/// Persist the TUI notifications preference into `CODEX_HOME/config.toml` at `[tui].notifications`. +/// Persist the TUI notifications preference into `DEV_HOME/config.toml` at `[tui].notifications`. pub fn set_tui_notifications( code_home: &Path, notifications: crate::config_types::Notifications, @@ -686,7 +686,7 @@ pub fn set_tui_notifications( Ok(()) } -/// Persist the review auto-resolve preference into `CODEX_HOME/config.toml` at `[tui].review_auto_resolve`. +/// Persist the review auto-resolve preference into `DEV_HOME/config.toml` at `[tui].review_auto_resolve`. pub fn set_tui_review_auto_resolve(code_home: &Path, enabled: bool) -> anyhow::Result<()> { let config_path = code_home.join(CONFIG_TOML_FILE); let read_path = resolve_code_path_for_read(code_home, Path::new(CONFIG_TOML_FILE)); @@ -707,7 +707,7 @@ pub fn set_tui_review_auto_resolve(code_home: &Path, enabled: bool) -> anyhow::R Ok(()) } -/// Persist the auto review preference into `CODEX_HOME/config.toml` at `[tui].auto_review_enabled`. +/// Persist the auto review preference into `DEV_HOME/config.toml` at `[tui].auto_review_enabled`. pub fn set_tui_auto_review_enabled(code_home: &Path, enabled: bool) -> anyhow::Result<()> { let config_path = code_home.join(CONFIG_TOML_FILE); let read_path = resolve_code_path_for_read(code_home, Path::new(CONFIG_TOML_FILE)); @@ -728,7 +728,7 @@ pub fn set_tui_auto_review_enabled(code_home: &Path, enabled: bool) -> anyhow::R Ok(()) } -/// Persist the review model + reasoning effort into `CODEX_HOME/config.toml`. +/// Persist the review model + reasoning effort into `DEV_HOME/config.toml`. pub fn set_review_model( code_home: &Path, model: &str, @@ -798,7 +798,7 @@ pub fn set_review_resolve_model( Ok(()) } -/// Persist the planning model + reasoning effort into `CODEX_HOME/config.toml`. +/// Persist the planning model + reasoning effort into `DEV_HOME/config.toml`. pub fn set_planning_model( code_home: &Path, model: &str, @@ -1284,7 +1284,7 @@ pub fn add_project_allowed_command( Ok(()) } -/// List MCP servers from `CODEX_HOME/config.toml`. +/// List MCP servers from `DEV_HOME/config.toml`. /// Returns `(enabled, disabled)` lists of `(name, McpServerConfig)`. pub fn list_mcp_servers(code_home: &Path) -> anyhow::Result<( Vec<(String, McpServerConfig)>, @@ -1693,7 +1693,7 @@ fn env_path(var: &str) -> std::io::Result> { fn env_overrides_present() -> bool { matches!(std::env::var("CODE_HOME"), Ok(ref v) if !v.trim().is_empty()) - || matches!(std::env::var("CODEX_HOME"), Ok(ref v) if !v.trim().is_empty()) + || matches!(std::env::var("DEV_HOME"), Ok(ref v) if !v.trim().is_empty()) } fn default_code_home_dir() -> Option { @@ -1766,10 +1766,10 @@ pub fn resolve_code_path_for_read(code_home: &Path, relative: &Path) -> PathBuf } /// Returns the path to the Code/Codex configuration directory, which can be -/// specified by the `CODE_HOME` or `CODEX_HOME` environment variables. If not set, +/// specified by the `CODE_HOME` or `DEV_HOME` environment variables. If not set, /// defaults to `~/.code` for the fork. /// -/// - If `CODE_HOME` or `CODEX_HOME` is set, the value will be canonicalized and this +/// - If `CODE_HOME` or `DEV_HOME` is set, the value will be canonicalized and this /// function will Err if the path does not exist. /// - If neither is set, this function does not verify that the directory exists. pub fn find_code_home() -> std::io::Result { @@ -1777,7 +1777,7 @@ pub fn find_code_home() -> std::io::Result { return Ok(path); } - if let Some(path) = env_path("CODEX_HOME")? { + if let Some(path) = env_path("DEV_HOME")? { return Ok(path); } diff --git a/core/src/config.rs b/core/src/config.rs index 4645f9d..392e1f1 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -39,6 +39,7 @@ use crate::model_family::derive_default_model_family; use crate::model_family::find_family_for_model; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::built_in_model_providers; +use crate::model_provider_info::HANZO_PROVIDER_ID; use crate::reasoning::clamp_reasoning_effort_for_model; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; @@ -142,9 +143,19 @@ pub use crate::config_constraint::ConstraintResult; pub(crate) use defaults::merge_with_default_agents; pub(crate) use validation::upgrade_legacy_model_slugs; +/// `dev`'s authenticated default model: Enso's adaptive route on the Hanzo Cloud. +/// +/// It is a coding agent, so the default is Enso Auto's adaptive coding route. +/// Anything else stays selectable — this is the one you get without asking. +pub const HANZO_DEFAULT_MODEL: &str = "enso-auto"; +/// Reviews run on the same model that wrote the code. +pub const HANZO_DEFAULT_REVIEW_MODEL: &str = "enso-auto"; + +/// Defaults used only when the caller has explicitly selected the `openai` +/// provider. They are not `dev`'s defaults. pub(crate) const OPENAI_DEFAULT_MODEL: &str = "gpt-5.5"; const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5.5"; -pub const GPT_5_CODEX_MEDIUM_MODEL: &str = "gpt-5.5"; +pub const GPT_5_DEV_MEDIUM_MODEL: &str = "gpt-5.5"; pub(crate) const DEFAULT_SUBAGENT_MAX_DEPTH: i32 = 1; /// Maximum number of bytes of the documentation that will be embedded. Larger @@ -282,6 +293,11 @@ pub struct Config { /// Key into the model_providers map that specifies which provider to use. pub model_provider_id: String, + /// Whether the provider was named by a flag, profile or config.toml, as + /// opposed to inferred from the environment. Onboarding reads this: an + /// inferred provider is not a decision, so sign-in still leads with Hanzo. + pub model_provider_explicit: bool, + /// Info needed to make an API request to the model. pub model_provider: ModelProviderInfo, @@ -408,7 +424,7 @@ pub struct Config { pub project_doc_fallback_filenames: Vec, /// Directory containing all Codex state (defaults to `~/.code`; can be - /// overridden by the `CODE_HOME` or `CODEX_HOME` environment variables). + /// overridden by the `CODE_HOME` or `DEV_HOME` environment variables). pub code_home: PathBuf, /// Settings that govern if and what will be written to `~/.code/history.jsonl` @@ -1144,10 +1160,25 @@ impl Config { model_providers.entry(key).or_insert(provider); } + // LAST link on purpose: a flag, a profile or config.toml has already had + // its say, and only when all three decline does the environment answer. + // The key you already hold picks the provider (provider_from_env), so a + // person with ANTHROPIC_API_KEY and no config is not told to go get ours. + // A stored Hanzo session outranks a vendor key in the shell: signing in + // was an explicit act, an exported variable often is not. + let model_provider_explicit = model_provider.is_some() + || config_profile.model_provider.is_some() + || cfg.model_provider.is_some(); let model_provider_id = model_provider .or(config_profile.model_provider) .or(cfg.model_provider) - .unwrap_or_else(|| "openai".to_string()); + .unwrap_or_else(|| { + if Self::has_stored_auth(&code_home) { + HANZO_PROVIDER_ID.to_string() + } else { + crate::model_provider_info::provider_from_env() + } + }); let model_provider = model_providers .get(&model_provider_id) .ok_or_else(|| { @@ -1314,8 +1345,25 @@ impl Config { .or(cfg.api_key_fallback_on_all_accounts_limited) .unwrap_or(false); - let default_model_slug = if using_chatgpt_auth { - GPT_5_CODEX_MEDIUM_MODEL + // The default model follows the selected provider: a model id is only + // meaningful to the endpoint that serves it. Asking the Hanzo Cloud for + // an OpenAI id is a 400, and vice versa. + let default_model_slug = if model_provider_id == HANZO_PROVIDER_ID { + // Without an account the free pool is what answers, and it is the + // only thing the free credential may ask for — a coding model would + // be refused, so the pair moves together. + // + // A stored session counts as an account even though it sets no + // bearer in the environment: the request builder prefers it over the + // free key, so choosing the free model on its behalf would ask the + // wrong model with the right credential. + if crate::free::anonymous() && !Self::has_stored_auth(&code_home) { + crate::free::MODEL + } else { + HANZO_DEFAULT_MODEL + } + } else if using_chatgpt_auth { + GPT_5_DEV_MEDIUM_MODEL } else { OPENAI_DEFAULT_MODEL }; @@ -1429,10 +1477,18 @@ impl Config { } // Default review model when not set in config; allow CLI override to take precedence. + // Like `model`, the review model follows the provider — a review model + // id is only meaningful to the endpoint that serves it. let review_model = override_review_model .or(config_profile.review_model.clone()) .or(cfg.review_model) - .unwrap_or_else(default_review_model); + .unwrap_or_else(|| { + if model_provider_id == HANZO_PROVIDER_ID { + HANZO_DEFAULT_REVIEW_MODEL.to_string() + } else { + default_review_model() + } + }); let review_model_reasoning_effort = config_profile .review_model_reasoning_effort @@ -1575,8 +1631,11 @@ impl Config { .or_else(|| cfg.tui.as_ref().and_then(|t| t.auto_drive.clone())) .unwrap_or_else(|| { let mut defaults = AutoDriveSettings::default(); - if using_chatgpt_auth { - defaults.model = GPT_5_CODEX_MEDIUM_MODEL.to_string(); + // Same rule as the default model above: the provider decides, not + // the auth mode. A Hanzo device login persists as `ChatGPT`, so + // reading auth alone here handed the Hanzo Cloud an OpenAI id. + if model_provider_id != HANZO_PROVIDER_ID && using_chatgpt_auth { + defaults.model = GPT_5_DEV_MEDIUM_MODEL.to_string(); defaults.model_reasoning_effort = ReasoningEffort::XHigh; } defaults @@ -1642,6 +1701,7 @@ impl Config { model_auto_compact_token_limit, context_mode, model_provider_id, + model_provider_explicit, model_provider, cwd: resolved_cwd, approval_policy: effective_approval, @@ -1775,6 +1835,19 @@ impl Config { } /// Check if we're using ChatGPT authentication + /// Whether a signed-in session is stored on disk, in any mode. The request + /// builder reaches for it before the free key, so the model default has to + /// see it too. + fn has_stored_auth(code_home: &Path) -> bool { + use code_app_server_protocol::AuthMode; + use crate::CodexAuth; + + matches!( + CodexAuth::from_code_home(code_home, AuthMode::ApiKey, "code_cli_rs"), + Ok(Some(_)) + ) + } + fn is_using_chatgpt_auth(code_home: &Path) -> bool { use code_app_server_protocol::AuthMode; use crate::CodexAuth; @@ -2074,6 +2147,31 @@ theme = "catppuccin-mocha" assert_eq!(parsed_bool.auto_upgrade_enabled, Some(true)); } + /// An exported vendor key picks the provider for a run, but it is not a + /// DECISION — onboarding reads this flag to keep sign-in Hanzo-first. + #[test] + fn a_provider_is_explicit_only_when_someone_named_it() -> std::io::Result<()> { + let code_home = TempDir::new()?; + + let inferred = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + code_home.path().to_path_buf(), + )?; + assert!(!inferred.model_provider_explicit); + + let named = Config::load_from_base_config_with_overrides( + ConfigToml { + model_provider: Some("openai".to_string()), + ..Default::default() + }, + ConfigOverrides::default(), + code_home.path().to_path_buf(), + )?; + assert!(named.model_provider_explicit); + Ok(()) + } + #[test] fn tool_output_max_bytes_defaults_and_overrides() -> std::io::Result<()> { let code_home = TempDir::new()?; @@ -2144,12 +2242,12 @@ theme = "catppuccin-mocha" let _home_guard = EnvVarGuard::new("HOME"); let _code_home_guard = EnvVarGuard::new("CODE_HOME"); - let _codex_home_guard = EnvVarGuard::new("CODEX_HOME"); + let _codex_home_guard = EnvVarGuard::new("DEV_HOME"); unsafe { std::env::set_var("HOME", legacy_home.path()); std::env::remove_var("CODE_HOME"); - std::env::remove_var("CODEX_HOME"); + std::env::remove_var("DEV_HOME"); } let loaded = Config::load_instructions(Some(code_home.path())); @@ -2533,6 +2631,7 @@ model_verbosity = "high" name: "OpenAI using Chat Completions".to_string(), base_url: Some("https://api.openai.com/v1".to_string()), env_key: Some("OPENAI_API_KEY".to_string()), + alt_env_keys: Vec::new(), wire_api: crate::WireApi::Chat, env_key_instructions: None, experimental_bearer_token: None, @@ -2949,15 +3048,15 @@ model_verbosity = "high" assert_eq!(config.auto_drive.model_routing_entries.len(), 2); assert_eq!( config.auto_drive.model_routing_entries[0].model, - "gpt-5.5" + "zen5-pro" ); assert_eq!( config.auto_drive.model_routing_entries[0].reasoning_levels, - vec![ReasoningEffort::High, ReasoningEffort::XHigh] + vec![ReasoningEffort::High] ); assert_eq!( config.auto_drive.model_routing_entries[1].model, - "gpt-5.4-mini" + "zen5-flash" ); assert_eq!( config.auto_drive.model_routing_entries[1].reasoning_levels, diff --git a/core/src/config_types.rs b/core/src/config_types.rs index ce88970..4830cd3 100644 --- a/core/src/config_types.rs +++ b/core/src/config_types.rs @@ -867,7 +867,7 @@ pub struct Tui { // Important: Provide a manual Default so that when no config file exists and we // construct `Config` via `unwrap_or_default()`, we still honor the intended // default of `alternate_screen = true`. Deriving `Default` would set booleans to -// `false`, which caused fresh installs (or a temporary CODEX_HOME) to start in +// `false`, which caused fresh installs (or a temporary DEV_HOME) to start in // standard-terminal mode until the user pressed Ctrl+T. impl Default for Tui { fn default() -> Self { @@ -956,13 +956,13 @@ fn default_auto_drive_model_routing_reasoning_levels() -> Vec { pub fn default_auto_drive_model_routing_entries() -> Vec { vec![ AutoDriveModelRoutingEntry { - model: "gpt-5.5".to_string(), + model: "zen5-pro".to_string(), enabled: true, - reasoning_levels: vec![ReasoningEffort::High, ReasoningEffort::XHigh], + reasoning_levels: vec![ReasoningEffort::High], description: "Hard planning and complex problem solving".to_string(), }, AutoDriveModelRoutingEntry { - model: "gpt-5.4-mini".to_string(), + model: "zen5-flash".to_string(), enabled: true, reasoning_levels: vec![ReasoningEffort::High], description: "Fast implementation loops and failing-test iteration".to_string(), @@ -1048,8 +1048,9 @@ const fn default_auto_drive_coordinator_turn_cap() -> u32 { } fn default_auto_drive_model() -> String { - // Keep aligned with the coordinator's preferred model fallback. - String::from("gpt-5.1") + // A model id only means something to the endpoint that serves it, and the + // endpoint here is the Hanzo Cloud. + String::from(crate::config::HANZO_DEFAULT_MODEL) } const fn default_auto_drive_reasoning_effort() -> ReasoningEffort { diff --git a/core/src/custom_prompts.rs b/core/src/custom_prompts.rs index 26e5921..1d4702c 100644 --- a/core/src/custom_prompts.rs +++ b/core/src/custom_prompts.rs @@ -4,8 +4,8 @@ use std::path::Path; use std::path::PathBuf; use tokio::fs; -/// Return the default prompts directory: `$CODEX_HOME/prompts`. -/// If `CODEX_HOME` cannot be resolved, returns `None`. +/// Return the default prompts directory: `$DEV_HOME/prompts`. +/// If `DEV_HOME` cannot be resolved, returns `None`. pub fn default_prompts_dir() -> Option { crate::config::find_code_home() .ok() diff --git a/core/src/default_client.rs b/core/src/default_client.rs index 312f9c1..ff963d0 100644 --- a/core/src/default_client.rs +++ b/core/src/default_client.rs @@ -1,4 +1,4 @@ -use crate::spawn::CODEX_SANDBOX_ENV_VAR; +use crate::spawn::DEV_SANDBOX_ENV_VAR; use reqwest::header::HeaderMap; use reqwest::header::HeaderValue; use std::sync::LazyLock; @@ -6,7 +6,7 @@ use std::sync::Mutex; use std::sync::RwLock; pub const DEFAULT_ORIGINATOR: &str = "code_cli_rs"; -pub const CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR: &str = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"; +pub const DEV_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR: &str = "DEV_INTERNAL_ORIGINATOR_OVERRIDE"; /// Optional suffix for the Codex User-Agent string. /// @@ -31,7 +31,7 @@ pub enum SetOriginatorError { } fn get_originator_value(provided: Option) -> Originator { - let value = std::env::var(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR) + let value = std::env::var(DEV_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR) .ok() .or(provided) .unwrap_or(DEFAULT_ORIGINATOR.to_string()); @@ -73,7 +73,7 @@ pub fn originator() -> Originator { return originator.clone(); } - if std::env::var(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR).is_ok() { + if std::env::var(DEV_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR).is_ok() { let originator = get_originator_value(None); if let Ok(mut guard) = ORIGINATOR.write() { match guard.as_ref() { @@ -210,7 +210,7 @@ pub fn create_client(originator: &str) -> reqwest::Client { } fn is_sandboxed() -> bool { - std::env::var(CODEX_SANDBOX_ENV_VAR).as_deref() == Ok("seatbelt") + std::env::var(DEV_SANDBOX_ENV_VAR).as_deref() == Ok("seatbelt") } #[cfg(test)] @@ -224,19 +224,19 @@ mod tests { fn set_originator_env(value: &str) { unsafe { - std::env::set_var(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR, value); + std::env::set_var(DEV_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR, value); } } fn remove_originator_env() { unsafe { - std::env::remove_var(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR); + std::env::remove_var(DEV_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR); } } fn with_originator_env_cleared(f: F) { let _lock = ORIGINATOR_ENV_LOCK.lock().expect("originator env lock"); - let previous = std::env::var(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR).ok(); + let previous = std::env::var(DEV_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR).ok(); remove_originator_env(); f(); match previous { @@ -255,7 +255,7 @@ mod tests { Fut: Future, { let _lock = ORIGINATOR_ENV_LOCK.lock().expect("originator env lock"); - let previous = std::env::var(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR).ok(); + let previous = std::env::var(DEV_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR).ok(); remove_originator_env(); f().await; match previous { diff --git a/core/src/error.rs b/core/src/error.rs index 81534c1..c6ed86d 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -133,7 +133,7 @@ pub enum CodexErr { AuthRefreshPermanent(String), #[error( - "To use Codex with your ChatGPT plan, upgrade to Plus: https://openai.com/chatgpt/pricing." + "To use Hanzo Dev with your ChatGPT plan, upgrade to Plus: https://openai.com/chatgpt/pricing." )] UsageNotIncluded, diff --git a/core/src/flags.rs b/core/src/flags.rs index 9fac914..893136b 100644 --- a/core/src/flags.rs +++ b/core/src/flags.rs @@ -2,7 +2,7 @@ use env_flags::env_flags; env_flags! { /// Fixture path for offline tests (see client.rs). - pub CODEX_RS_SSE_FIXTURE: Option<&str> = None; + pub DEV_RS_SSE_FIXTURE: Option<&str> = None; /// Enable context timeline delta tracking (Phase 2). pub CTX_DELTAS: bool = false; diff --git a/core/src/free.rs b/core/src/free.rs new file mode 100644 index 0000000..a0bac9b --- /dev/null +++ b/core/src/free.rs @@ -0,0 +1,103 @@ +//! Working without an account. +//! +//! The Hanzo Cloud answers `dev` with no login, on the free pool. A key built +//! into this binary asks for the `free` model; the gateway holds that key in an +//! allowlist, caps what a single request may spend, and bills nothing for it. +//! That is what makes a credential safe to ship inside a binary — it cannot +//! spend an account, because it has none. +//! +//! Free costs nothing and in exchange it is data-shared, which [`NOTICE`] says +//! at the top of every run that uses it. Signing in swaps both halves at once: +//! the user's own key, and the coding model it pays for. + +/// The credential the gateway answers to without an account. Public by design, +/// so it is declared here rather than fetched — a value every copy of this +/// binary already carries gains nothing from a lookup. `HANZO_FREE_KEY` +/// overrides it against a private deployment. +const FREE_KEY: &str = "hz_7GrX4ZKTMlExtziPF4EZtPA9QfdsxFBkr1AXEB3COo8XVQ9R"; + +/// The gateway's anonymous Enso pool. This id is intentionally distinct from +/// the authenticated `enso-auto` route so an unaccounted-for process can never +/// spend a user's account. +pub const MODEL: &str = "enso-free"; + +/// The bearer a signed-in user brings. `hanzo code` sets it on the child, and +/// `hanzo auth login` is how a human fills it in. +pub const USER_KEY: &str = "HANZO_USER_KEY"; + +fn env(name: &str) -> Option { + std::env::var(name) + .ok() + .filter(|v| !v.trim().is_empty()) + .map(|v| v.trim().to_string()) +} + +/// Whether an account is behind this run. +pub fn signed_in() -> bool { + env(USER_KEY).is_some() +} + +/// The free credential, when there is one to use. +pub fn key() -> Option { + env("HANZO_FREE_KEY").or_else(|| (!FREE_KEY.is_empty()).then(|| FREE_KEY.to_string())) +} + +/// The account always wins. Free answers only when there is no account to bill +/// and a credential to ask with — no credential means the run asks for nothing, +/// which is how a build with no free key behaves exactly as it did before. +fn free_answers(user: Option<&str>, free: Option<&str>) -> bool { + user.is_none() && free.is_some() +} + +/// Whether this run will be answered free. +pub fn anonymous() -> bool { + free_answers(env(USER_KEY).as_deref(), key().as_deref()) +} + +/// Whether this configured run is the free pool's: it asks the Hanzo Cloud for +/// the pool's own model. Another provider's run is never free, whatever the +/// environment holds. +pub fn runs(config: &crate::config::Config) -> bool { + config.model_provider_id == crate::model_provider_info::HANZO_PROVIDER_ID + && config.model == MODEL +} + +/// Said once at the top of a free run — what it costs, what it shares, and how +/// to leave it. The same bargain hanzo.ai and hanzo.chat state in their own +/// words. +pub const NOTICE: &str = "Running free, without an account. Free is data-shared: what you send and what comes back may be used to improve models — ours, and the provider that serves them. Paid stays private. Sign in with `hanzo auth login` for the coding models and private requests. Terms: https://hanzo.ai/terms"; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_account_outranks_the_free_credential() { + assert!(!free_answers(Some("hk-real"), Some("hz_free"))); + assert!(!free_answers(Some("hk-real"), None)); + } + + #[test] + fn free_answers_only_when_it_can() { + assert!(free_answers(None, Some("hz_free"))); + assert!(!free_answers(None, None)); + } + + /// The pool's own id outlives the routes under it. A vendor `:free` id names + /// one route, and naming it here would send this binary to a model that can + /// be retired without warning. + #[test] + fn the_free_id_is_the_pool_not_a_route() { + assert_eq!(MODEL, "enso-free"); + assert!(!MODEL.contains(':') && !MODEL.contains('/')); + } + + /// A free run is data-shared, so the notice has to say so and say where the + /// terms are. + #[test] + fn the_notice_states_the_bargain() { + assert!(NOTICE.contains("data-shared")); + assert!(NOTICE.contains("https://hanzo.ai/terms")); + assert!(NOTICE.contains("hanzo auth login")); + } +} diff --git a/core/src/git_worktree.rs b/core/src/git_worktree.rs index fccff08..0948590 100644 --- a/core/src/git_worktree.rs +++ b/core/src/git_worktree.rs @@ -740,8 +740,8 @@ pub async fn copy_uncommitted_to_worktree(src_root: &Path, worktree_path: &Path) } // Opt-in: mirror modified submodule pointers into the worktree index (no checkout/network). - // Enable via CODEX_BRANCH_INCLUDE_SUBMODULES=1|true|yes. - let include_submods = std::env::var("CODEX_BRANCH_INCLUDE_SUBMODULES") + // Enable via DEV_BRANCH_INCLUDE_SUBMODULES=1|true|yes. + let include_submods = std::env::var("DEV_BRANCH_INCLUDE_SUBMODULES") .ok() .map(|v| v.to_ascii_lowercase()) .map(|v| v == "1" || v == "true" || v == "yes") diff --git a/core/src/http_client.rs b/core/src/http_client.rs index 507bf0f..6cff153 100644 --- a/core/src/http_client.rs +++ b/core/src/http_client.rs @@ -7,7 +7,7 @@ use reqwest::cookie::CookieStore; use reqwest::cookie::Jar; use reqwest::header::HeaderValue; -pub const CODEX_CA_CERTIFICATE_ENV_VAR: &str = "CODEX_CA_CERTIFICATE"; +pub const DEV_CA_CERTIFICATE_ENV_VAR: &str = "DEV_CA_CERTIFICATE"; static SHARED_CHATGPT_CLOUDFLARE_COOKIE_STORE: LazyLock> = LazyLock::new(|| Arc::new(ChatGptCloudflareCookieStore::default())); @@ -130,7 +130,7 @@ pub fn apply_extra_root_certificates( .ok() } - let codex_ca_certificate = std::env::var(CODEX_CA_CERTIFICATE_ENV_VAR) + let codex_ca_certificate = std::env::var(DEV_CA_CERTIFICATE_ENV_VAR) .ok() .filter(|value| !value.trim().is_empty()); let ssl_cert_file = std::env::var("SSL_CERT_FILE") @@ -184,7 +184,7 @@ pub fn apply_extra_root_certificates( } /// Build a reqwest Client with optional extra root certificates loaded from -/// common environment variables. `CODEX_CA_CERTIFICATE` takes precedence over +/// common environment variables. `DEV_CA_CERTIFICATE` takes precedence over /// `SSL_CERT_FILE`, and other ecosystem-standard CA bundle variables continue /// to work as fallbacks. pub fn build_http_client() -> reqwest::Client { diff --git a/core/src/lib.rs b/core/src/lib.rs index ad9cfc3..e18a6ba 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -8,6 +8,7 @@ mod apply_patch; mod fs_sanitize; +mod shot; pub mod auth; pub mod auth_accounts; pub mod account_usage; @@ -55,6 +56,7 @@ mod exec_command; pub mod exec_env; pub mod external_agent_config; mod flags; +pub mod free; pub mod git_info; pub mod landlock; pub mod http_client; @@ -81,6 +83,8 @@ mod unified_exec; mod user_instructions; mod skills; pub use model_provider_info::BUILT_IN_OSS_MODEL_PROVIDER_ID; +pub use model_provider_info::HANZO_PROVIDER_ID; +pub use model_provider_info::create_hanzo_provider; pub use model_provider_info::ModelProviderInfo; pub use model_provider_info::OpenRouterConfig; pub use model_provider_info::OpenRouterProviderConfig; @@ -138,7 +142,7 @@ mod function_tool; mod user_notification; pub mod util; -pub use apply_patch::CODEX_APPLY_PATCH_ARG1; +pub use apply_patch::DEV_APPLY_PATCH_ARG1; pub use command_safety::is_safe_command; pub use agent_tool::external_agent_command_exists; pub use agent_tool::smoke_test_agent_blocking; diff --git a/core/src/mcp_connection_manager.rs b/core/src/mcp_connection_manager.rs index c2006b3..0ff914a 100644 --- a/core/src/mcp_connection_manager.rs +++ b/core/src/mcp_connection_manager.rs @@ -311,7 +311,7 @@ impl McpConnectionManager { client_info: Implementation { name: "codex-mcp-client".to_owned(), version: env!("CARGO_PKG_VERSION").to_owned(), - title: Some("Codex".into()), + title: Some("Hanzo Dev".into()), // This field is used by Codex when it is an MCP // server: it should not be used when Codex is // an MCP client. diff --git a/core/src/model_family.rs b/core/src/model_family.rs index 9802ab0..c34ba13 100644 --- a/core/src/model_family.rs +++ b/core/src/model_family.rs @@ -17,12 +17,12 @@ use once_cell::sync::Lazy; const BASE_INSTRUCTIONS: &str = include_str!("../prompt.md"); const BASE_INSTRUCTIONS_WITH_APPLY_PATCH: &str = include_str!("../prompt_with_apply_patch_instructions.md"); -const GPT_5_CODEX_INSTRUCTIONS: &str = include_str!("../gpt_5_codex_prompt.md"); +const GPT_5_DEV_INSTRUCTIONS: &str = include_str!("../gpt_5_codex_prompt.md"); const GPT_5_1_INSTRUCTIONS: &str = include_str!("../gpt_5_1_prompt.md"); const GPT_5_2_INSTRUCTIONS: &str = include_str!("../gpt_5_2_prompt.md"); -const GPT_5_1_CODEX_MAX_INSTRUCTIONS: &str = include_str!("../gpt-5.1-codex-max_prompt.md"); -const GPT_5_2_CODEX_INSTRUCTIONS: &str = include_str!("../gpt-5.2-codex_prompt.md"); -const DEFAULT_PERSONALITY_HEADER: &str = "You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals."; +const GPT_5_1_DEV_MAX_INSTRUCTIONS: &str = include_str!("../gpt-5.1-codex-max_prompt.md"); +const GPT_5_2_DEV_INSTRUCTIONS: &str = include_str!("../gpt-5.2-codex_prompt.md"); +const DEFAULT_PERSONALITY_HEADER: &str = "You are Hanzo Dev, a coding agent. You and the user share the same workspace and collaborate to achieve the user's goals."; const LOCAL_FRIENDLY_TEMPLATE: &str = "You optimize for team morale and being a supportive teammate as much as code quality."; const LOCAL_PRAGMATIC_TEMPLATE: &str = "You are a deeply pragmatic, effective software engineer."; @@ -39,7 +39,7 @@ const MAX_OUTPUT_QWEN3_CODER_30B: u64 = 65_536; static UPSTREAM_MODELS: Lazy> = Lazy::new(|| { parse_upstream_models(include_str!( - "../../../codex-rs/models-manager/models.json" + "../../../vendor/codex/models-manager/models.json" )) .unwrap_or_else(|err| panic!("failed to parse upstream models.json: {err}")) }); @@ -369,7 +369,7 @@ pub fn find_family_for_model(slug: &str) -> Option { model_family!( slug, slug, supports_reasoning_summaries: true, - base_instructions: GPT_5_CODEX_INSTRUCTIONS.to_string(), + base_instructions: GPT_5_DEV_INSTRUCTIONS.to_string(), apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), supports_parallel_tool_calls: true, default_reasoning_effort: Some(ReasoningEffort::Medium), @@ -380,7 +380,7 @@ pub fn find_family_for_model(slug: &str) -> Option { model_family!( slug, slug, supports_reasoning_summaries: true, - base_instructions: GPT_5_2_CODEX_INSTRUCTIONS.to_string(), + base_instructions: GPT_5_2_DEV_INSTRUCTIONS.to_string(), apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), supports_parallel_tool_calls: true, truncation_policy: TruncationPolicy::Tokens(10_000), @@ -398,7 +398,7 @@ pub fn find_family_for_model(slug: &str) -> Option { model_family!( slug, slug, supports_reasoning_summaries: true, - base_instructions: GPT_5_1_CODEX_MAX_INSTRUCTIONS.to_string(), + base_instructions: GPT_5_1_DEV_MAX_INSTRUCTIONS.to_string(), apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), context_window: Some(CONTEXT_WINDOW_272K), max_output_tokens: Some(MAX_OUTPUT_DEFAULT), @@ -411,7 +411,7 @@ pub fn find_family_for_model(slug: &str) -> Option { model_family!( slug, slug, supports_reasoning_summaries: true, - base_instructions: GPT_5_CODEX_INSTRUCTIONS.to_string(), + base_instructions: GPT_5_DEV_INSTRUCTIONS.to_string(), apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), context_window: Some(CONTEXT_WINDOW_272K), max_output_tokens: Some(MAX_OUTPUT_DEFAULT), @@ -422,7 +422,7 @@ pub fn find_family_for_model(slug: &str) -> Option { model_family!( slug, slug, supports_reasoning_summaries: true, - base_instructions: GPT_5_2_CODEX_INSTRUCTIONS.to_string(), + base_instructions: GPT_5_2_DEV_INSTRUCTIONS.to_string(), apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), supports_parallel_tool_calls: true, context_window: Some(CONTEXT_WINDOW_272K), @@ -434,7 +434,7 @@ pub fn find_family_for_model(slug: &str) -> Option { model_family!( slug, slug, supports_reasoning_summaries: true, - base_instructions: GPT_5_2_CODEX_INSTRUCTIONS.to_string(), + base_instructions: GPT_5_2_DEV_INSTRUCTIONS.to_string(), apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), supports_parallel_tool_calls: true, context_window: Some(CONTEXT_WINDOW_272K), @@ -445,7 +445,7 @@ pub fn find_family_for_model(slug: &str) -> Option { model_family!( slug, slug, supports_reasoning_summaries: true, - base_instructions: GPT_5_2_CODEX_INSTRUCTIONS.to_string(), + base_instructions: GPT_5_2_DEV_INSTRUCTIONS.to_string(), apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), supports_parallel_tool_calls: true, context_window: Some(CONTEXT_WINDOW_272K), @@ -585,7 +585,7 @@ mod tests { #[test] fn upstream_models_parser_accepts_renamed_reasoning_summary_field() { - let catalog = include_str!("../../../codex-rs/models-manager/models.json"); + let catalog = include_str!("../../../vendor/codex/models-manager/models.json"); let models = parse_upstream_models(catalog).expect("bundled upstream models parse"); assert!(!models.is_empty()); diff --git a/core/src/model_provider_info.rs b/core/src/model_provider_info.rs index 61ec87a..184e561 100644 --- a/core/src/model_provider_info.rs +++ b/core/src/model_provider_info.rs @@ -13,7 +13,6 @@ use serde::Deserialize; use serde::Serialize; use serde_json::Value; use std::collections::{BTreeMap, HashMap}; -use std::env::VarError; use std::io; use std::path::Path; use std::path::PathBuf; @@ -93,6 +92,19 @@ pub struct ModelProviderInfo { /// Environment variable that stores the user's API key for this provider. pub env_key: Option, + /// Variables tried BEFORE `env_key`, for the same provider reached by a + /// MACHINE rather than a person. + /// + /// A provider has one endpoint and two kinds of caller. `env_key` names the + /// human's — a session a login filled in. A service run (the coding harness + /// in a sandbox, a scheduled job, an agent acting for an org) never has one + /// and carries an org API key or an IAM machine identity's token instead. + /// + /// Empty for every provider that has only one kind of caller, so nothing + /// changes for them. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub alt_env_keys: Vec, + /// Optional instructions to help the user get a valid value for the /// variable and set it. pub env_key_instructions: Option, @@ -410,13 +422,14 @@ impl ModelProviderInfo { match self.api_key() { Ok(Some(key)) => Ok(Some(CodexAuth::from_api_key(&key))), Ok(None) => Ok(auth.clone()), - Err(err) => { - if auth.is_some() { - Ok(auth.clone()) - } else { - Err(err) - } - } + Err(err) => match auth.clone() { + Some(auth) => Ok(Some(auth)), + // No account, and the Hanzo Cloud answers without one. + None => match self.free_key() { + Some(key) => Ok(Some(CodexAuth::from_api_key(&key))), + None => Err(err), + }, + }, } } @@ -561,27 +574,49 @@ impl ModelProviderInfo { /// If `env_key` is Some, returns the API key for this provider if present /// (and non-empty) in the environment. If `env_key` is required but /// cannot be found, returns an error. + /// + /// A PROVIDER MAY BE REACHED BY A PERSON OR BY A MACHINE, and those carry + /// different credentials. `HANZO_USER_KEY` is a signed-in human's session, + /// filled in by `hanzo auth login`. A run started by a service — the coding + /// harness inside a sandbox pod, a scheduled job, an agent acting for an org + /// — has no human session and holds an org API key or an IAM machine + /// identity's token instead. + /// + /// Reading only the user variable made the machine case impossible: dev in a + /// sandbox reported "Authentication expired. Run `code login` to continue" + /// for a caller that was perfectly authenticated, because the only bearer it + /// would look at was the one no service has. So the provider accepts + /// EITHER, and `alt_env_keys` is how a provider says which. + /// + /// Order is machine-first and deliberate: where both are present the process + /// was started BY something, and the credential it was given is the one that + /// should be billed and audited. A stale human key left in an environment + /// must not quietly outrank the identity the caller actually presented. pub fn api_key(&self) -> crate::error::Result> { - match &self.env_key { - Some(env_key) => { - let env_value = std::env::var(env_key); - env_value - .and_then(|v| { - if v.trim().is_empty() { - Err(VarError::NotPresent) - } else { - Ok(Some(v)) - } - }) - .map_err(|_| { - crate::error::CodexErr::EnvVar(EnvVarError { - var: env_key.clone(), - instructions: self.env_key_instructions.clone(), - }) - }) + let Some(env_key) = &self.env_key else { + return Ok(None); + }; + for key in self.alt_env_keys.iter().chain(std::iter::once(env_key)) { + if let Ok(v) = std::env::var(key) { + if !v.trim().is_empty() { + return Ok(Some(v)); + } } - None => Ok(None), } + Err(crate::error::CodexErr::EnvVar(EnvVarError { + var: env_key.clone(), + instructions: self.env_key_instructions.clone(), + })) + } + + /// The credential this provider answers to without an account, if it has + /// one. Only the Hanzo Cloud does; every other provider still asks for a + /// key, which is why this reads the provider's own bearer rather than a + /// name or a URL that a config file can rewrite. + fn free_key(&self) -> Option { + (self.env_key.as_deref() == Some(crate::free::USER_KEY)) + .then(crate::free::key) + .flatten() } /// Effective maximum number of request retries for this provider. @@ -716,6 +751,76 @@ const DEFAULT_OLLAMA_PORT: u32 = 11434; pub const BUILT_IN_OSS_MODEL_PROVIDER_ID: &str = "oss"; +/// The Hanzo Cloud — `dev`'s home and its default. Every other provider stays +/// configurable; this is the one you get without asking. +pub const HANZO_PROVIDER_ID: &str = "hanzo"; +pub const ANTHROPIC_PROVIDER_ID: &str = "anthropic"; +pub const OPENAI_PROVIDER_ID: &str = "openai"; + +/// The key you already have decides the provider, when you have not said. +/// +/// A coding agent should work with whatever credential is in the environment +/// rather than insisting on ours, so this is consulted ONLY after an explicit +/// choice — flag, profile, config.toml — has declined to answer. Hanzo first +/// because it is the default and the one we serve; then a key that names its +/// own vendor. +/// +/// With no key at all the answer is still Hanzo: that is the provider whose +/// `env_key_instructions` tell a person how to sign in, and an empty +/// environment is exactly when someone needs to read them. +pub fn provider_from_env() -> String { + fn set(k: &str) -> bool { + std::env::var(k).is_ok_and(|v| !v.trim().is_empty()) + } + if set(HANZO_ENV_KEY) || HANZO_MACHINE_ENV_KEYS.iter().any(|k| set(k)) { + return HANZO_PROVIDER_ID.to_string(); + } + if set(ANTHROPIC_ENV_KEY) { + return ANTHROPIC_PROVIDER_ID.to_string(); + } + if set(OPENAI_ENV_KEY) { + return OPENAI_PROVIDER_ID.to_string(); + } + HANZO_PROVIDER_ID.to_string() +} + + +/// Where the Hanzo Cloud answers. Override with `HANZO_BASE_URL` for a private +/// deployment; the `hanzo` CLI does exactly that when its active network points +/// somewhere other than the public gateway. +const HANZO_BASE_URL: &str = "https://api.hanzo.ai/v1"; + +/// The bearer `dev` reads. `hanzo code` sets it on the child process, and +/// `hanzo auth login` is how a human fills it in. `free` owns the name, so the +/// account half and the free half of the pair cannot drift apart. +const HANZO_ENV_KEY: &str = crate::free::USER_KEY; +const ANTHROPIC_ENV_KEY: &str = "ANTHROPIC_API_KEY"; +const OPENAI_ENV_KEY: &str = "OPENAI_API_KEY"; + +/// The credentials a SERVICE presents, tried before the human session. +/// +/// `HANZO_API_KEY` is an org key. `HANZO_MACHINE_TOKEN` is the bearer an IAM +/// machine identity holds after the client_credentials exchange — the identity +/// a sandbox run, a cron job or an agent acting for an org actually carries. +/// Neither exists on a developer's laptop, and `HANZO_USER_KEY` never exists in +/// a pod, which is why reading only one of them made the other case impossible. +/// +/// THIS IS A RESTORATION, and the fleet proves it. `HANZO_ENV_KEY` was +/// introduced by 8a4de1f45b (2026-08-01), which is NOT in v0.6.91 — and v0.6.91 +/// is what runs in the sandbox image today. Measured against that live pod, with +/// the two variables set one at a time: +/// +/// nothing set no request is attempted at all +/// HANZO_API_KEY only the request goes out; IAM rejects the sentinel (400) +/// HANZO_USER_KEY only no request is attempted at all +/// +/// So the deployed build reaches our gateway on the MACHINE variable and ignores +/// the human one, and main reversed that without anything noticing: every +/// service caller in the fleet would have stopped authenticating at the next +/// image rebuild, silently, because a missing credential and an unread one look +/// identical from outside. +const HANZO_MACHINE_ENV_KEYS: [&str; 2] = ["HANZO_API_KEY", "HANZO_MACHINE_TOKEN"]; + /// Built-in default provider list. fn wire_api_override_from_env(env_key: &str) -> Option { match std::env::var(env_key) { @@ -740,17 +845,18 @@ pub fn built_in_model_providers( ) -> HashMap { use ModelProviderInfo as P; - // We do not want to be in the business of adjucating which third-party - // providers are bundled with Codex CLI, so we only include the OpenAI and - // open source ("oss") providers by default. Users are encouraged to add to - // `model_providers` in config.toml to add their own providers. + // The Hanzo Cloud is the default. The rest are bundled because people ask + // for them; add any other via `model_providers` in config.toml. [ + (HANZO_PROVIDER_ID, create_hanzo_provider()), + (ANTHROPIC_PROVIDER_ID, create_anthropic_provider()), ( "openai", P { name: "OpenAI".into(), base_url: openai_base_url, env_key: None, + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -794,17 +900,86 @@ pub fn built_in_model_providers( .collect() } +/// The Hanzo Cloud provider — `dev`'s default. +/// +/// Speaks the Responses wire at `api.hanzo.ai/v1` and reads its bearer from +/// `HANZO_USER_KEY`. `requires_openai_auth` is false: this account is ours, so +/// the ChatGPT login dance never applies to it. +pub fn create_hanzo_provider() -> ModelProviderInfo { + let base_url = std::env::var("HANZO_BASE_URL") + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| HANZO_BASE_URL.to_string()); + + ModelProviderInfo { + name: "Hanzo".into(), + base_url: Some(base_url), + env_key: Some(HANZO_ENV_KEY.into()), + alt_env_keys: HANZO_MACHINE_ENV_KEYS.iter().map(|s| (*s).to_string()).collect(), + env_key_instructions: Some( + "Sign in with `hanzo auth login`, or set HANZO_USER_KEY to a key from \ + https://hanzo.ai/settings/keys. A service sets HANZO_API_KEY or \ + HANZO_MACHINE_TOKEN instead." + .into(), + ), + experimental_bearer_token: None, + auth: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: Some( + [("version".to_string(), code_version::version().to_string())] + .into_iter() + .collect(), + ), + env_http_headers: None, + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + websocket_connect_timeout_ms: None, + requires_openai_auth: false, + openrouter: None, + } +} + +/// Anthropic, through its OpenAI-compatible surface — so it needs no new wire +/// format, only a base URL and a key. `wire_api: Chat` is that surface; the +/// native Messages API is a different shape and is deliberately not spoken here. +pub fn create_anthropic_provider() -> ModelProviderInfo { + ModelProviderInfo { + name: "Anthropic".into(), + base_url: Some("https://api.anthropic.com/v1".into()), + env_key: Some(ANTHROPIC_ENV_KEY.into()), + alt_env_keys: Vec::new(), + env_key_instructions: Some( + "Set ANTHROPIC_API_KEY to a key from https://console.anthropic.com/settings/keys." + .into(), + ), + experimental_bearer_token: None, + auth: None, + wire_api: WireApi::Chat, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + websocket_connect_timeout_ms: None, + requires_openai_auth: false, + openrouter: None, + } +} + pub fn create_oss_provider() -> ModelProviderInfo { - // These CODEX_OSS_ environment variables are experimental: we may + // These DEV_OSS_ environment variables are experimental: we may // switch to reading values from config.toml instead. - let code_oss_base_url = match std::env::var("CODEX_OSS_BASE_URL") + let code_oss_base_url = match std::env::var("DEV_OSS_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()) { Some(url) => url, None => format!( "http://localhost:{port}/v1", - port = std::env::var("CODEX_OSS_PORT") + port = std::env::var("DEV_OSS_PORT") .ok() .filter(|v| !v.trim().is_empty()) .and_then(|v| v.parse::().ok()) @@ -820,6 +995,7 @@ pub fn create_oss_provider_with_base_url(base_url: &str) -> ModelProviderInfo { name: "gpt-oss".into(), base_url: Some(base_url.into()), env_key: None, + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -867,6 +1043,7 @@ base_url = "http://localhost:11434/v1" name: "Ollama".into(), base_url: Some("http://localhost:11434/v1".into()), env_key: None, + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -898,6 +1075,7 @@ query_params = { api-version = "2025-04-01-preview" } name: "Azure".into(), base_url: Some("https://xxxxx.openai.azure.com/openai".into()), env_key: Some("AZURE_OPENAI_API_KEY".into()), + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -932,6 +1110,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } name: "Example".into(), base_url: Some("https://example.com".into()), env_key: Some("API_KEY".into()), + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -962,6 +1141,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } name: "test".into(), base_url: Some(base_url.into()), env_key: None, + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -998,6 +1178,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } name: "Azure".into(), base_url: Some("https://example.com".into()), env_key: None, + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -1035,6 +1216,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } name: name.into(), base_url: base_url.map(str::to_string), env_key: None, + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: None, @@ -1125,6 +1307,7 @@ refresh_interval_ms = 0 name: "Corp".into(), base_url: Some("https://example.com".into()), env_key: Some("CORP_API_KEY".into()), + alt_env_keys: Vec::new(), env_key_instructions: None, experimental_bearer_token: None, auth: Some(ModelProviderAuthInfo { @@ -1152,3 +1335,63 @@ refresh_interval_ms = 0 ); } } + +#[cfg(test)] +mod provider_from_env_tests { + use super::*; + + // These read process-wide env, so they run as ONE test rather than racing + // each other through the same variables. + #[test] + fn the_key_you_hold_picks_the_provider() { + let keys = [HANZO_ENV_KEY, ANTHROPIC_ENV_KEY, OPENAI_ENV_KEY]; + let saved: Vec<_> = keys.iter().map(|k| (*k, std::env::var(k).ok())).collect(); + let clear = || { + for k in keys { + unsafe { std::env::remove_var(k) } + } + for k in HANZO_MACHINE_ENV_KEYS { + unsafe { std::env::remove_var(k) } + } + }; + + clear(); + assert_eq!(provider_from_env(), HANZO_PROVIDER_ID, "no key at all still points at the provider whose instructions say how to sign in"); + + clear(); + unsafe { std::env::set_var(OPENAI_ENV_KEY, "sk-x") }; + assert_eq!(provider_from_env(), OPENAI_PROVIDER_ID, "someone with only an OpenAI key should not be told to fetch ours"); + + clear(); + unsafe { std::env::set_var(ANTHROPIC_ENV_KEY, "sk-ant-x") }; + assert_eq!(provider_from_env(), ANTHROPIC_PROVIDER_ID); + + // Ours wins when several are present: it is the default and the one we serve. + clear(); + unsafe { std::env::set_var(ANTHROPIC_ENV_KEY, "sk-ant-x") }; + unsafe { std::env::set_var(OPENAI_ENV_KEY, "sk-x") }; + unsafe { std::env::set_var(HANZO_ENV_KEY, "hz-x") }; + assert_eq!(provider_from_env(), HANZO_PROVIDER_ID); + + // An empty value is not a key. + clear(); + unsafe { std::env::set_var(ANTHROPIC_ENV_KEY, " ") }; + assert_eq!(provider_from_env(), HANZO_PROVIDER_ID, "whitespace is not a credential"); + + clear(); + for (k, v) in saved { + if let Some(v) = v { + unsafe { std::env::set_var(k, v) } + } + } + } + + #[test] + fn anthropic_is_reachable_and_needs_no_new_wire_format() { + let p = built_in_model_providers(None); + let a = p.get(ANTHROPIC_PROVIDER_ID).expect("anthropic is built in"); + assert_eq!(a.env_key.as_deref(), Some("ANTHROPIC_API_KEY")); + assert_eq!(a.wire_api, WireApi::Chat, "the OpenAI-compatible surface, not the native Messages API"); + assert!(a.base_url.as_deref().unwrap_or_default().starts_with("https://api.anthropic.com")); + } +} diff --git a/core/src/openai_tools.rs b/core/src/openai_tools.rs index e41a8a6..8361337 100644 --- a/core/src/openai_tools.rs +++ b/core/src/openai_tools.rs @@ -1378,13 +1378,22 @@ fn parse_tool_input_schema(input_schema: &JsonValue) -> Result(input_schema) } +/// The six types [`JsonSchema`] can hold. One list, read by every path that +/// resolves a `type` — a scalar and a `["string", "null"]` union both measure +/// against it, so they cannot drift apart. +const SCHEMA_TYPES: [&str; 6] = [ + "object", "array", "string", "number", "integer", "boolean", +]; + /// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited /// JsonSchema enum. This function: -/// - Ensures every schema object has a "type". If missing, infers it from -/// common keywords (properties => object, items => array, enum/const/format => string) -/// and otherwise defaults to "string". +/// - Ensures every schema object has a "type" drawn from [`SCHEMA_TYPES`]. If it +/// is missing, or is one we cannot hold, infers it from common keywords +/// (properties => object, items => array, enum/const/format => string) and +/// otherwise defaults to "string". /// - Fills required child fields (e.g. array items, object properties) with -/// permissive defaults when absent. +/// permissive defaults when absent, and writes an empty `required` list where +/// a schema omits one, which is JSON Schema's own reading of an absent list. fn sanitize_json_schema(value: &mut JsonValue) { match value { JsonValue::Bool(_) => { @@ -1408,6 +1417,22 @@ fn sanitize_json_schema(value: &mut JsonValue) { if let Some(items) = map.get_mut("items") { sanitize_json_schema(items); } + // `anyOf: [T, {type: null}]` is how MCP servers spell an optional + // T. The null arm says nothing about a value the model may send, + // so it goes; T alone stays, and being optional is already the + // caller's `required` list. + for combiner in ["oneOf", "anyOf"] { + if let Some(JsonValue::Array(arms)) = map.get_mut(combiner) { + let typed: Vec = arms + .iter() + .filter(|arm| arm.get("type").and_then(|t| t.as_str()) != Some("null")) + .cloned() + .collect(); + if !typed.is_empty() { + *arms = typed; + } + } + } // Some schemas use oneOf/anyOf/allOf - sanitize their entries for combiner in ["oneOf", "anyOf", "allOf", "prefixItems"] { if let Some(v) = map.get_mut(combiner) { @@ -1415,23 +1440,25 @@ fn sanitize_json_schema(value: &mut JsonValue) { } } - // Normalize/ensure type - let mut ty = map.get("type").and_then(|v| v.as_str()).map(str::to_string); + // Normalize/ensure type. A scalar is measured against SCHEMA_TYPES + // exactly as a union is, so the two cannot disagree about what we + // can hold: `null` and anything else outside the list fall through + // to inference below rather than reaching the parser, where one bad + // property took down the whole tool around it. + let mut ty = map + .get("type") + .and_then(JsonValue::as_str) + .filter(|t| SCHEMA_TYPES.contains(t)) + .map(str::to_string); // If type is an array (union), pick first supported; else leave to inference if ty.is_none() { if let Some(JsonValue::Array(types)) = map.get("type") { - for t in types { - if let Some(tt) = t.as_str() { - if matches!( - tt, - "object" | "array" | "string" | "number" | "integer" | "boolean" - ) { - ty = Some(tt.to_string()); - break; - } - } - } + ty = types + .iter() + .filter_map(JsonValue::as_str) + .find(|t| SCHEMA_TYPES.contains(t)) + .map(str::to_string); } } @@ -1474,6 +1501,14 @@ fn sanitize_json_schema(value: &mut JsonValue) { JsonValue::Object(serde_json::Map::new()), ); } + // JSON Schema reads an absent `required` as "nothing is + // required"; our Object serializer reads an absent one as + // "everything is". Write the empty list, so a server that omits + // it — every Pydantic tool whose arguments are all optional — + // is not sent to the model demanding all fifteen of them. + if !map.contains_key("required") { + map.insert("required".to_string(), JsonValue::Array(Vec::new())); + } // If additionalProperties is an object schema, sanitize it too. // Leave booleans as-is, since JSON Schema allows boolean here. if let Some(ap) = map.get_mut("additionalProperties") { @@ -2302,7 +2337,7 @@ mod tests { }, ), ]), - required: None, + required: Some(Vec::new()), additional_properties: None, }, description: "Do something cool".to_string(), @@ -2437,7 +2472,7 @@ mod tests { }, ), ]), - required: None, + required: Some(Vec::new()), additional_properties: None, }, description: "Do something cool".to_string(), @@ -2531,7 +2566,7 @@ mod tests { allowed_values: None, } )]), - required: None, + required: Some(Vec::new()), additional_properties: None, }, description: "Search docs".to_string(), @@ -2608,7 +2643,7 @@ mod tests { "page".to_string(), JsonSchema::Number { description: None } )]), - required: None, + required: Some(Vec::new()), additional_properties: None, }, description: "Pagination".to_string(), @@ -2683,7 +2718,7 @@ mod tests { description: None } )]), - required: None, + required: Some(Vec::new()), additional_properties: None, }, description: "Tags".to_string(), @@ -2692,6 +2727,102 @@ mod tests { ); } + /// The Hanzo MCP server spells every optional argument as + /// `anyOf: [T, {type: null}]`. Twenty of its tools were dropped at the + /// door for that null arm. + #[test] + fn test_mcp_tool_nullable_any_of_keeps_the_typed_arm() { + let tool = mcp_tool_to_openai_tool( + "hanzo__zsh".to_string(), + mcp_types::Tool { + name: "zsh".to_string(), + input_schema: ToolInputSchema { + properties: Some(serde_json::json!({ + "cwd": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, + "timeout": { "type": ["integer", "null"] }, + "env": { "type": "null" } + })), + required: None, + r#type: "object".to_string(), + }, + output_schema: None, + title: None, + annotations: None, + description: None, + }, + ) + .expect("nullable arguments convert"); + let parameters = serde_json::to_value(&tool.parameters).expect("serializes"); + assert_eq!( + parameters["properties"]["cwd"], + serde_json::json!({ "anyOf": [ { "type": "string" } ] }) + ); + assert_eq!(parameters["properties"]["timeout"]["type"], "number"); + assert_eq!(parameters["properties"]["env"]["type"], "string"); + } + + fn convert_hanzo_tool( + properties: serde_json::Value, + required: Option>, + ) -> JsonValue { + let tool = mcp_tool_to_openai_tool( + "hanzo__agent".to_string(), + mcp_types::Tool { + name: "agent".to_string(), + input_schema: ToolInputSchema { + properties: Some(properties), + required, + r#type: "object".to_string(), + }, + output_schema: None, + title: None, + annotations: None, + description: None, + }, + ) + .expect("a Pydantic schema is a JSON Schema and must convert"); + serde_json::to_value(&tool.parameters).expect("serializes") + } + + /// Pydantic omits `required` when every argument is optional, which is most + /// of the Hanzo MCP server: `agent` declares none of its fifteen. Reading + /// that as "all fifteen" tells the model a one-word call is malformed. + #[test] + fn test_mcp_tool_absent_required_requires_nothing() { + let parameters = convert_hanzo_tool( + serde_json::json!({ + "action": { "type": "string" }, + "prompt": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, + }), + None, + ); + + assert_eq!(parameters["required"], serde_json::json!([])); + } + + #[test] + fn test_mcp_tool_declared_required_survives() { + let parameters = convert_hanzo_tool( + serde_json::json!({ "op": { "type": "string" } }), + Some(vec!["op".to_string()]), + ); + + assert_eq!(parameters["required"], serde_json::json!(["op"])); + } + + /// A type outside [`SCHEMA_TYPES`] degrades to an accept-all string. It must + /// never reach the parser, where it would fail the whole tool it sits in + /// rather than the one argument it describes. + #[test] + fn test_mcp_tool_unheld_type_does_not_fail_the_tool() { + let parameters = convert_hanzo_tool( + serde_json::json!({ "weird": { "type": "int64" } }), + None, + ); + + assert_eq!(parameters["properties"]["weird"]["type"], "string"); + } + #[test] fn test_mcp_tool_schema_composition_is_preserved() { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); diff --git a/core/src/parse_command.rs b/core/src/parse_command.rs index 99865e9..abaaada 100644 --- a/core/src/parse_command.rs +++ b/core/src/parse_command.rs @@ -140,7 +140,7 @@ mod tests { #[test] fn sh_lc_sed_is_read_command() { - let inner = "sed -n '10,20p' code-rs/tui/src/chatwidget.rs"; + let inner = "sed -n '10,20p' hanzo-dev/tui/src/chatwidget.rs"; assert_parsed( &vec_str(&["sh", "-lc", inner]), vec![ParsedCommand::Read { @@ -334,13 +334,13 @@ mod tests { #[test] fn supports_rg_with_path_and_glob_filter() { - let inner = "rg \"codex-run-as-apply-patch\" -n code-rs -g\"*.rs\""; + let inner = "rg \"codex-run-as-apply-patch\" -n hanzo-dev -g\"*.rs\""; assert_parsed( &vec_str(&["bash", "-lc", inner]), vec![ParsedCommand::Search { - cmd: "rg codex-run-as-apply-patch -n code-rs '-g*.rs'".to_string(), + cmd: "rg codex-run-as-apply-patch -n hanzo-dev '-g*.rs'".to_string(), query: Some("codex-run-as-apply-patch".to_string()), - path: Some("code-rs".to_string()), + path: Some("hanzo-dev".to_string()), }], ); } @@ -532,10 +532,10 @@ mod tests { #[test] fn supports_grep_recursive_current_dir() { assert_parsed( - &vec_str(&["grep", "-R", "CODEX_SANDBOX_ENV_VAR", "-n", "."]), + &vec_str(&["grep", "-R", "DEV_SANDBOX_ENV_VAR", "-n", "."]), vec![ParsedCommand::Search { - cmd: "grep -R CODEX_SANDBOX_ENV_VAR -n .".to_string(), - query: Some("CODEX_SANDBOX_ENV_VAR".to_string()), + cmd: "grep -R DEV_SANDBOX_ENV_VAR -n .".to_string(), + query: Some("DEV_SANDBOX_ENV_VAR".to_string()), path: Some(".".to_string()), }], ); @@ -547,13 +547,13 @@ mod tests { &vec_str(&[ "grep", "-R", - "CODEX_SANDBOX_ENV_VAR", + "DEV_SANDBOX_ENV_VAR", "-n", "core/src/spawn.rs", ]), vec![ParsedCommand::Search { - cmd: "grep -R CODEX_SANDBOX_ENV_VAR -n core/src/spawn.rs".to_string(), - query: Some("CODEX_SANDBOX_ENV_VAR".to_string()), + cmd: "grep -R DEV_SANDBOX_ENV_VAR -n core/src/spawn.rs".to_string(), + query: Some("DEV_SANDBOX_ENV_VAR".to_string()), path: Some("spawn.rs".to_string()), }], ); @@ -588,7 +588,7 @@ mod tests { #[test] fn supports_cd_and_rg_files() { assert_parsed( - &shlex_split_safe("cd code-rs && rg --files"), + &shlex_split_safe("cd hanzo-dev && rg --files"), vec![ParsedCommand::Search { cmd: "rg --files".to_string(), query: None, diff --git a/core/src/reasoning.rs b/core/src/reasoning.rs index 85fed8e..9497a79 100644 --- a/core/src/reasoning.rs +++ b/core/src/reasoning.rs @@ -1,19 +1,19 @@ use crate::config_types::ReasoningEffort; -const GPT5_1_CODEX_MAX_EFFORTS: &[ReasoningEffort] = &[ +const GPT5_1_DEV_MAX_EFFORTS: &[ReasoningEffort] = &[ ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, ReasoningEffort::XHigh, ]; -const GPT5_1_CODEX_EFFORTS: &[ReasoningEffort] = &[ +const GPT5_1_DEV_EFFORTS: &[ReasoningEffort] = &[ ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, ]; -const GPT5_1_CODEX_MINI_EFFORTS: &[ReasoningEffort] = &[ +const GPT5_1_DEV_MINI_EFFORTS: &[ReasoningEffort] = &[ ReasoningEffort::Medium, ReasoningEffort::High, ]; @@ -31,13 +31,13 @@ const GPT5_2_EFFORTS: &[ReasoningEffort] = &[ ReasoningEffort::XHigh, ]; -const GPT5_CODEX_EFFORTS: &[ReasoningEffort] = &[ +const GPT5_DEV_EFFORTS: &[ReasoningEffort] = &[ ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, ]; -const GPT5_CODEX_MINI_EFFORTS: &[ReasoningEffort] = &[ +const GPT5_DEV_MINI_EFFORTS: &[ReasoningEffort] = &[ ReasoningEffort::Medium, ReasoningEffort::High, ]; @@ -49,7 +49,7 @@ const GPT5_EFFORTS: &[ReasoningEffort] = &[ ReasoningEffort::High, ]; -const CODEX_FALLBACK_EFFORTS: &[ReasoningEffort] = &[ +const DEV_FALLBACK_EFFORTS: &[ReasoningEffort] = &[ ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, @@ -77,15 +77,15 @@ pub fn supported_reasoning_efforts_for_model(model: &str) -> &'static [Reasoning let lower = model.to_ascii_lowercase(); if lower.starts_with("gpt-5.1-codex-max") { - return GPT5_1_CODEX_MAX_EFFORTS; + return GPT5_1_DEV_MAX_EFFORTS; } if lower.starts_with("gpt-5.1-codex-mini") { - return GPT5_1_CODEX_MINI_EFFORTS; + return GPT5_1_DEV_MINI_EFFORTS; } if lower.starts_with("gpt-5.1-codex") { - return GPT5_1_CODEX_EFFORTS; + return GPT5_1_DEV_EFFORTS; } if lower.starts_with("gpt-5.1") { @@ -101,11 +101,11 @@ pub fn supported_reasoning_efforts_for_model(model: &str) -> &'static [Reasoning } if lower.starts_with("gpt-5-codex-mini") { - return GPT5_CODEX_MINI_EFFORTS; + return GPT5_DEV_MINI_EFFORTS; } if lower.starts_with("gpt-5-codex") { - return GPT5_CODEX_EFFORTS; + return GPT5_DEV_EFFORTS; } if lower.starts_with("gpt-5") { @@ -113,7 +113,7 @@ pub fn supported_reasoning_efforts_for_model(model: &str) -> &'static [Reasoning } if lower.starts_with("codex-") { - return CODEX_FALLBACK_EFFORTS; + return DEV_FALLBACK_EFFORTS; } DEFAULT_EFFORTS diff --git a/core/src/remote_models/mod.rs b/core/src/remote_models/mod.rs index c132c77..1e1ecb5 100644 --- a/core/src/remote_models/mod.rs +++ b/core/src/remote_models/mod.rs @@ -31,7 +31,7 @@ mod cache; const MODEL_CACHE_FILE: &str = "models_cache.json"; const DEFAULT_MODEL_CACHE_TTL: Duration = Duration::from_secs(300); const REMOTE_MODELS_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); -const CODEX_AUTO_BALANCED_MODEL: &str = "codex-auto-balanced"; +const DEV_AUTO_BALANCED_MODEL: &str = "codex-auto-balanced"; #[derive(Debug, Default, Clone)] struct RemoteModelsState { @@ -96,8 +96,8 @@ impl RemoteModelsManager { state .models .iter() - .any(|m| m.slug == CODEX_AUTO_BALANCED_MODEL) - .then(|| CODEX_AUTO_BALANCED_MODEL.to_string()) + .any(|m| m.slug == DEV_AUTO_BALANCED_MODEL) + .then(|| DEV_AUTO_BALANCED_MODEL.to_string()) } /// Best-effort refresh of remote models. diff --git a/core/src/seatbelt.rs b/core/src/seatbelt.rs index d51b0ab..cab3c40 100644 --- a/core/src/seatbelt.rs +++ b/core/src/seatbelt.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use tokio::process::Child; use crate::protocol::SandboxPolicy; -use crate::spawn::CODEX_SANDBOX_ENV_VAR; +use crate::spawn::DEV_SANDBOX_ENV_VAR; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; @@ -26,7 +26,7 @@ pub async fn spawn_command_under_seatbelt( ) -> std::io::Result { let args = create_seatbelt_command_args(command, sandbox_policy, sandbox_policy_cwd); let arg0 = None; - env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); + env.insert(DEV_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); spawn_child_async( PathBuf::from(MACOS_PATH_TO_SEATBELT_EXECUTABLE), args, @@ -119,8 +119,8 @@ fn create_seatbelt_command_args( "{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}" ); - if std::env::var("CODEX_DEBUG_PRINT_SEATBELT").is_ok() { - eprintln!("--- Codex Seatbelt Policy ---\n{}\n------------------------------", full_policy); + if std::env::var("DEV_DEBUG_PRINT_SEATBELT").is_ok() { + eprintln!("--- Hanzo Dev Seatbelt Policy ---\n{}\n------------------------------", full_policy); } let mut seatbelt_args: Vec = vec!["-p".to_string(), full_policy]; diff --git a/core/src/shell.rs b/core/src/shell.rs index 1bf6922..a4bf689 100644 --- a/core/src/shell.rs +++ b/core/src/shell.rs @@ -70,7 +70,7 @@ impl Shell { // turn it into a PowerShell command. let first = command.first().map(String::as_str); if first != Some(ps.exe.as_str()) { - // TODO (CODEX_2900): Handle escaping newlines. + // TODO (DEV_2900): Handle escaping newlines. if command.iter().any(|a| a.contains('\n') || a.contains('\r')) { return Some(command); } @@ -716,7 +716,7 @@ mod tests_windows { vec!["pwsh.exe", "-NoProfile", "-Command", "echo hello"], ), ( - // TODO (CODEX_2900): Handle escaping newlines for powershell invocation. + // TODO (DEV_2900): Handle escaping newlines for powershell invocation. Shell::PowerShell(PowerShellConfig { exe: "powershell.exe".to_string(), bash_exe_fallback: Some(PathBuf::from("bash.exe")), diff --git a/core/src/shot/mod.rs b/core/src/shot/mod.rs new file mode 100644 index 0000000..d80a5eb --- /dev/null +++ b/core/src/shot/mod.rs @@ -0,0 +1,493 @@ +//! A picture of the UI the agent just changed, published beside the diff that +//! changed it. +//! +//! WHY. A diff says what moved; it does not say what it looks like. For a change +//! to a rendered surface the page itself is the reviewable artifact, and a human +//! watching a session live should not have to check out the branch and start a +//! server to see one. The session log already streams to that console, so the +//! picture rides the channel that exists — one ordinary event naming one object +//! in storage — rather than standing up a second artifact plane beside it. The +//! bytes go to object storage because a log is for reading, and a 200 KiB image +//! inlined into a transcript makes it unreadable for everything else. +//! +//! WHY IT MAY FAIL QUIETLY. Evidence is not the work. No dev server listening, a +//! route that 404s, storage unreachable, no session to publish to — none of those +//! are reasons to fail a coding turn, and none of them are worth more than a +//! debug line. Every step below returns rather than propagates, and the whole +//! thing runs off the turn's critical path so a slow browser costs the agent +//! nothing. +//! +//! WHY IT NEVER STARTS A SERVER. It attaches to a dev server already listening +//! and will not launch one. Launching one means running the `dev` script out of +//! the repository's own package.json — arbitrary code from the tree the agent is +//! being asked to edit — outside the approval and sandbox path every other +//! command in this program goes through. A repository shipping +//! `"dev": "curl … | sh"` would otherwise get execution for free by baiting the +//! agent into touching a stylesheet. Taking a picture must not be a way to run +//! something, so this reads package.json for a port and dials it. + +mod ui; + +use std::path::PathBuf; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; + +use anyhow::Context as _; +use anyhow::Result; +use anyhow::anyhow; +use code_browser::BrowserConfig; +use code_browser::BrowserManager; +use code_browser::ScreenshotMode; +use code_browser::ViewportConfig; +use serde::Deserialize; +use serde_json::Value; +use serde_json::json; +use tokio::net::TcpStream; +use tokio::sync::Semaphore; +use tokio::time::Duration; +use tokio::time::timeout; +use tracing::debug; + +use crate::codex::Session; +use crate::default_client::create_client; +use crate::model_provider_info::create_hanzo_provider; + +/// The org-scoped bucket session artifacts live in. The org never appears in the +/// key — cloud derives the physical bucket from the caller's own principal, so a +/// key is meaningless outside the tenant that wrote it. +const BUCKET: &str = "sessions"; + +/// The session to publish to, set by whatever created it. The agent cannot +/// invent one: without a session there is nowhere for a picture to go, so the +/// whole gesture is abandoned before a browser or a socket is touched. This is +/// also why a laptop with no cloud session behaves exactly as it did before. +const SESSION: &str = "HANZO_SESSION"; + +/// One fixed viewport, so two pictures of the same page are comparable. Not +/// configurable — a reviewer flipping between runs taken at different widths is +/// reading noise, not a change. +const VIEW: (u32, u32) = (1280, 800); + +/// The budget for the whole gesture: launch, navigate, capture, two round trips. +/// Past this the picture describes a page that has already moved on. +const BUDGET: Duration = Duration::from_secs(45); + +/// How long to wait on the dev server's socket. It is on loopback; either it is +/// listening or it is not. +const DIAL: Duration = Duration::from_millis(300); + +/// At most one capture in flight. A turn that patches thirty files wants one +/// picture of where it ends up, not thirty of it arriving there — and an +/// unbounded fan-out of browsers driven by model output is a way to run a +/// machine out of memory. +static INFLIGHT: Semaphore = Semaphore::const_new(1); + +/// Numbers the objects within a session so the console can order them. +static COUNT: AtomicU64 = AtomicU64::new(0); + +/// Publish a picture of the page this diff changed, if it changed one. +/// +/// Returns immediately. The decision is cheap and made here; everything with a +/// cost happens on a detached task, because the turn must not wait on evidence. +pub(crate) fn capture(sess: &Session, diff: &str) { + let Some(plan) = Plan::new(sess, diff) else { + return; + }; + tokio::spawn(async move { + let Ok(_held) = INFLIGHT.try_acquire() else { + debug!("shot: a capture is already running"); + return; + }; + match timeout(BUDGET, plan.run()).await { + Ok(Ok(key)) => debug!("shot: published {key}"), + Ok(Err(why)) => debug!("shot: skipped — {why:#}"), + Err(_) => debug!("shot: skipped — over {BUDGET:?}"), + } + }); +} + +/// Everything the capture needs, owned, so it can outlive the turn that asked +/// for it. +struct Plan { + cwd: PathBuf, + session: String, + base: String, + token: String, + route: String, + /// Workspace-relative, capped. Relative because an absolute path carries the + /// operator's home directory — and so their account name — into a log the + /// whole org can read. + files: Vec, + slug: String, + n: u64, +} + +/// How many changed files the payload names. Enough to see what the picture is +/// of; the diff beside it is the complete list. +const NAMED: usize = 10; + +impl Plan { + fn new(sess: &Session, diff: &str) -> Option { + // Cheapest question first: is anyone listening for this? + let session = std::env::var(SESSION).ok()?; + let session = handle(&session)?; + + let changed: Vec = ui::touched(diff) + .into_iter() + .filter(|p| ui::rendered(p)) + .collect(); + let first = changed.first()?; + + // The same base URL and bearer the agent already talks to Hanzo with. + // A second notion of where the cloud is would be a second thing to get + // wrong. + let provider = create_hanzo_provider(); + let base = provider.base_url.clone()?; + let token = provider.api_key().ok().flatten()?; + + let cwd = sess.get_cwd().to_path_buf(); + let route = changed + .iter() + .find_map(|p| ui::route(p)) + .unwrap_or_else(|| "/".to_string()); + let slug = ui::slug(&route, first); + let files = ui::named(&changed, &cwd, NAMED); + + Some(Self { + cwd, + session: session.to_string(), + base: base.trim_end_matches('/').to_string(), + token, + route, + files, + slug, + n: COUNT.fetch_add(1, Ordering::Relaxed) + 1, + }) + } + + async fn run(&self) -> Result { + let origin = self + .origin() + .await + .ok_or_else(|| anyhow!("no dev server listening"))?; + let png = shoot(&format!("{origin}{}", self.route)).await?; + let http = create_client("shot"); + let key = format!("{}/{}/{}-{}.png", BUCKET, self.session, self.n, self.slug); + let signed = self.presign(&http, &key).await?; + put(&http, &signed, png).await?; + self.append(&http, &key, &origin).await?; + Ok(key) + } + + /// The dev server, if one is already listening. + /// + /// Exactly one port is dialed: the one this repository declares. Sweeping the + /// usual suspects would eventually connect to some unrelated program on the + /// operator's machine and present a picture of it as their diff. + async fn origin(&self) -> Option { + let manifest = std::fs::read_to_string(self.cwd.join("package.json")).ok()?; + let port = ui::port(&manifest)?; + let addr = format!("127.0.0.1:{port}"); + timeout(DIAL, TcpStream::connect(&addr)).await.ok()?.ok()?; + Some(format!("http://{addr}")) + } + + /// Ask cloud for a URL that may write this one object. + /// + /// The image never passes through the API: cloud signs, we upload straight to + /// storage. The signature is time-boxed and scoped to this exact bucket and + /// key, which is why it is a credential and why it stops here. + async fn presign(&self, http: &reqwest::Client, key: &str) -> Result { + #[derive(Deserialize)] + struct Signed { + url: String, + } + let res = http + .post(format!("{}/s3/buckets/{BUCKET}/objects", self.base)) + .bearer_auth(&self.token) + .json(&json!({ "key": key })) + .send() + .await + .context("presign")?; + let status = res.status(); + if !status.is_success() { + return Err(anyhow!("presign: {status}")); + } + Ok(res.json::().await.context("presign body")?.url) + } + + /// One event, naming the object. The bytes are in storage; the log carries + /// the pointer, the route, and the files that earned the picture. + async fn append(&self, http: &reqwest::Client, key: &str, origin: &str) -> Result<()> { + let res = http + .post(format!("{}/agents/sessions/{}/events", self.base, self.session)) + .bearer_auth(&self.token) + .json(&json!({ "kind": "tool-call", "payload": self.payload(key, origin) })) + .send() + .await + .context("append")?; + let status = res.status(); + if !status.is_success() { + return Err(anyhow!("append: {status}")); + } + Ok(()) + } + + /// What the console reads. + /// + /// Bucket and key rather than a URL: the only URL for a private object is a + /// signed, expiring one, and writing a credential into a transcript would be + /// a leak that outlives the session. A reader resolves the key through the + /// same door, under their own identity, whenever they open the event. + fn payload(&self, key: &str, origin: &str) -> Value { + json!({ + "tool": "shot", + "route": self.route, + "bucket": BUCKET, + "key": key, + "origin": origin, + "viewport": { "width": VIEW.0, "height": VIEW.1 }, + "files": self.files, + }) + } +} + +/// A session id fit to be spliced into a URL path and an object key. An +/// environment variable is caller-controlled input, and this one addresses +/// another tenant's session if it is allowed to contain a slash or a dot-dot. +fn handle(raw: &str) -> Option<&str> { + let id = raw.trim(); + let ok = !id.is_empty() + && id.len() <= 128 + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'); + ok.then_some(id) +} + +/// Photograph a URL in a browser of this function's own. +/// +/// Its own, not the agent's: the `browser` tool's page belongs to the +/// conversation, and navigating it away to take this picture would move the +/// ground under whatever the agent was looking at. Headless with a temporary +/// profile, so it carries none of the operator's cookies to a page it was told +/// about by a model. +async fn shoot(url: &str) -> Result> { + let browser = BrowserManager::new(BrowserConfig { + enabled: true, + headless: true, + persist_profile: false, + viewport: ViewportConfig { + width: VIEW.0, + height: VIEW.1, + device_scale_factor: 1.0, + mobile: false, + }, + ..BrowserConfig::default() + }); + let png = frame(&browser, url).await; + let _ = browser.stop().await; + png +} + +async fn frame(browser: &BrowserManager, url: &str) -> Result> { + browser.start().await.context("launch")?; + browser.goto(url).await.with_context(|| format!("open {url}"))?; + let page = browser.get_or_create_page().await.context("page")?; + let shots = page + .screenshot(ScreenshotMode::Viewport) + .await + .context("capture")?; + shots + .into_iter() + .next() + .map(|s| s.data) + .ok_or_else(|| anyhow!("empty capture")) +} + +/// Upload the bytes with the signature and nothing else. Our bearer is for the +/// API; sending it to whatever host cloud signed against would hand an org +/// credential to a third party for no reason. +async fn put(http: &reqwest::Client, signed: &str, png: Vec) -> Result<()> { + let res = http + .put(signed) + .header(reqwest::header::CONTENT_TYPE, "image/png") + .body(png) + .send() + .await + .context("upload")?; + let status = res.status(); + if !status.is_success() { + return Err(anyhow!("upload: {status}")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::Request; + use wiremock::ResponseTemplate; + use wiremock::matchers::body_json; + use wiremock::matchers::header; + use wiremock::matchers::method; + use wiremock::matchers::path; + + fn plan() -> Plan { + Plan { + cwd: PathBuf::from("/home/someone/work/web"), + session: "ses_abc123".to_string(), + base: "https://api.hanzo.ai/v1".to_string(), + token: "hk-secret-value".to_string(), + route: "/settings".to_string(), + files: vec!["src/app/settings/page.tsx".to_string()], + slug: "settings".to_string(), + n: 7, + } + } + + #[test] + fn the_payload_names_the_object_and_what_earned_it() { + let p = plan(); + let key = format!("{}/{}/{}-{}.png", BUCKET, p.session, p.n, p.slug); + let body = p.payload(&key, "http://127.0.0.1:3000"); + assert_eq!(body["tool"], "shot"); + assert_eq!(body["route"], "/settings"); + assert_eq!(body["bucket"], "sessions"); + assert_eq!(body["key"], "sessions/ses_abc123/7-settings.png"); + assert_eq!(body["origin"], "http://127.0.0.1:3000"); + assert_eq!(body["viewport"]["width"], 1280); + assert_eq!(body["viewport"]["height"], 800); + assert_eq!(body["files"][0], "src/app/settings/page.tsx"); + } + + #[test] + fn the_payload_carries_no_credential() { + let p = plan(); + let body = p.payload("sessions/ses_abc123/7-settings.png", "http://127.0.0.1:3000").to_string(); + assert!(!body.contains(&p.token)); + assert!(!body.contains("hk-")); + // A presigned URL is a bearer with an expiry. It must never be written down. + assert!(!body.contains("X-Amz-Signature")); + } + + #[test] + fn the_payload_names_no_absolute_path() { + let body = plan() + .payload("sessions/ses_abc123/7-settings.png", "http://127.0.0.1:3000") + .to_string(); + assert!(!body.contains("/home/someone")); + } + + #[test] + fn the_payload_fits_the_log() { + // Cloud refuses a payload over 64 KiB. The cap on named files is what + // keeps this true for a turn that rewrites a design system. + let mut p = plan(); + p.files = (0..NAMED) + .map(|i| format!("src/app/{}/page.tsx", "n".repeat(200 + i))) + .collect(); + let body = p.payload("sessions/ses_abc123/7-settings.png", "http://127.0.0.1:3000"); + assert!(body.to_string().len() < 64 * 1024); + } + + /// The three calls, against a stand-in for cloud. This is the contract: + /// which doors, in which order, carrying what — and, load-bearing, which of + /// them the org bearer is allowed anywhere near. + #[tokio::test] + async fn publishing_is_three_calls_and_one_of_them_is_unauthenticated() { + let cloud = MockServer::start().await; + let key = "sessions/ses_abc123/7-settings.png"; + let signed = format!("{}/bucket/object?X-Amz-Signature=deadbeef", cloud.uri()); + + Mock::given(method("POST")) + .and(path("/v1/s3/buckets/sessions/objects")) + .and(header("authorization", "Bearer hk-secret-value")) + .and(body_json(json!({ "key": key }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "url": signed, "method": "PUT", "key": key, "expiresIn": 900 + }))) + .expect(1) + .mount(&cloud) + .await; + + // The signature IS the credential for this URL. Sending our bearer along + // would hand an org key to whatever host cloud signed against. + Mock::given(method("PUT")) + .and(path("/bucket/object")) + .and(header("content-type", "image/png")) + .and(|req: &Request| req.headers.get("authorization").is_none()) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&cloud) + .await; + + Mock::given(method("POST")) + .and(path("/v1/agents/sessions/ses_abc123/events")) + .and(header("authorization", "Bearer hk-secret-value")) + .and(body_json(json!({ + "kind": "tool-call", + "payload": { + "tool": "shot", + "route": "/settings", + "bucket": "sessions", + "key": key, + "origin": "http://127.0.0.1:3000", + "viewport": { "width": 1280, "height": 800 }, + "files": ["src/app/settings/page.tsx"], + } + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "seq": 12 }))) + .expect(1) + .mount(&cloud) + .await; + + let mut p = plan(); + p.base = format!("{}/v1", cloud.uri()); + let http = create_client("shot"); + let url = p.presign(&http, key).await.unwrap(); + assert_eq!(url, signed); + put(&http, &url, b"\x89PNG\r\n\x1a\n".to_vec()).await.unwrap(); + p.append(&http, key, "http://127.0.0.1:3000").await.unwrap(); + // Dropping the server asserts every `expect(1)` was met exactly once. + } + + /// Cloud refusing is not the agent's problem, but it must be reported as a + /// refusal rather than swallowed as success. + #[tokio::test] + async fn a_refusal_is_an_error_not_a_shrug() { + let cloud = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(422).set_body_json(json!({ "error": "leak" }))) + .mount(&cloud) + .await; + let mut p = plan(); + p.base = format!("{}/v1", cloud.uri()); + let http = create_client("shot"); + assert!(p.presign(&http, "k.png").await.is_err()); + assert!(p.append(&http, "k.png", "http://127.0.0.1:3000").await.is_err()); + } + + #[test] + fn a_session_id_cannot_retarget_the_request() { + for bad in [ + "", + " ", + "../../admin", + "ses_abc/../../other", + "ses abc", + "ses_abc?x=1", + "ses_abc#frag", + "ses_abc/events", + "https://evil.example/", + ] { + assert_eq!(handle(bad), None, "{bad} must be refused"); + } + assert_eq!(handle(" ses_abc123 "), Some("ses_abc123")); + assert_eq!(handle("ses-ABC_123"), Some("ses-ABC_123")); + assert_eq!(handle(&"a".repeat(128)), Some("a".repeat(128).as_str())); + assert_eq!(handle(&"a".repeat(129)), None); + } +} diff --git a/core/src/shot/ui.rs b/core/src/shot/ui.rs new file mode 100644 index 0000000..e7218cd --- /dev/null +++ b/core/src/shot/ui.rs @@ -0,0 +1,460 @@ +//! What a diff touched, whether any of it renders, and where it renders. +//! +//! Everything here is a decision over strings and paths — no clock, no network, +//! no filesystem. That is the point of the split: the effectful half next door +//! has nothing left to decide, and every rule below can be read straight out of +//! a test instead of out of a running browser. + +use std::path::Path; +use std::path::PathBuf; + +/// Extensions whose content IS the rendered surface. +/// +/// Deliberately short. A picture costs a browser launch and, more expensively, a +/// reviewer's attention — so the predicate only fires on extensions that cannot +/// mean anything except markup or style. `.ts` and `.js` are absent on purpose: +/// most of them are logic, and Next's `app/api/route.ts` is a server handler +/// sitting in the middle of the UI tree. A change that genuinely moves pixels +/// nearly always arrives with a partner carrying one of these. +const RENDERED: [&str; 9] = [ + "astro", "css", "html", "jsx", "less", "scss", "svelte", "tsx", "vue", +]; + +/// Directories whose contents were downloaded or generated. A `.css` under any +/// of them is output, not authorship, and photographing it tells a reviewer +/// nothing about what the agent did. +const DERIVED: [&str; 8] = [ + ".next", + ".nuxt", + ".svelte-kit", + "build", + "dist", + "node_modules", + "out", + "target", +]; + +/// Directory names that route by nesting: the URL is the directory path and only +/// the router's own leaf file marks a directory as a route. +const NESTED: [&str; 2] = ["app", "routes"]; + +/// The directory that routes by filename: `pages/about.tsx` is `/about`. +const FLAT: &str = "pages"; + +/// Leaf filenames that name their directory rather than a segment of their own — +/// Next's `page.tsx`, SvelteKit's `+page.svelte`. +const LEAF: [&str; 2] = ["page", "+page"]; + +/// The paths a unified diff writes. +/// +/// `+++ b/` is the post-image name: the file as it exists *after* the +/// patch, which is the only version there is anything to photograph. A deletion +/// writes `+++ /dev/null` and names nothing, so it drops out here for free. +/// +/// Paths arrive relative to the git root when there is one and absolute when +/// there is not, so callers must relativize before publishing anything. +pub(crate) fn touched(diff: &str) -> Vec { + diff.lines() + .filter_map(|line| line.strip_prefix("+++ ")) + // Some unified-diff writers append a tab and a timestamp to the header. + .map(|rest| rest.split('\t').next().unwrap_or(rest)) + .filter_map(|rest| rest.strip_prefix("b/")) + .filter(|rest| !rest.is_empty()) + .map(PathBuf::from) + .collect() +} + +/// Whether this file, changed, is a reason to look at a page. +pub(crate) fn rendered(path: &Path) -> bool { + if path + .components() + .any(|c| DERIVED.contains(&c.as_os_str().to_string_lossy().as_ref())) + { + return false; + } + match path.extension().and_then(|e| e.to_str()) { + Some(ext) => RENDERED.contains(&ext.to_ascii_lowercase().as_str()), + None => false, + } +} + +/// The URL path a source file renders at, for the file-routed frameworks where +/// the answer is written in the path itself — Next (both routers), SvelteKit, +/// Nuxt, Astro, Remix. +/// +/// `None` means "not derivable", and the caller opens the site root. That is the +/// deliberate answer for a dynamic segment like `[slug]`: a reviewer can tell a +/// front page from the page they expected, but cannot tell an invented id from a +/// broken route. +pub(crate) fn route(path: &Path) -> Option { + let parts: Vec = path + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect(); + let file = parts.last()?; + let stem = Path::new(file).file_stem()?.to_str()?; + + // The router directory this file hangs off. Innermost wins, so a `pages/` + // nested inside an `app/` routes the file that sits under it. + let root = parts + .iter() + .rposition(|p| NESTED.contains(&p.as_str()) || p == FLAT)?; + let mut segs: Vec<&str> = parts[root + 1..parts.len() - 1] + .iter() + .map(String::as_str) + .collect(); + + if parts[root] == FLAT { + // `pages/` names a route by the file; `index` names the directory. + if stem != "index" { + segs.push(stem); + } + } else if !LEAF.contains(&stem) { + // Under `app/` and `routes/` a directory is a route only because the + // router's leaf file says so. Everything else living there — a stylesheet, + // a component, a layout — has no URL of its own. + return None; + } + + let mut url = String::new(); + for seg in segs { + // A Next route group organises files without appearing in the URL. + if seg.starts_with('(') && seg.ends_with(')') { + continue; + } + if dynamic(seg) { + return None; + } + url.push('/'); + url.push_str(seg); + } + Some(if url.is_empty() { "/".to_string() } else { url }) +} + +/// A path segment standing in for a value the agent does not have: Next's +/// `[slug]`, SvelteKit's `[id]`, Nuxt's `_id`, Remix's `$id`. +fn dynamic(seg: &str) -> bool { + seg.starts_with('[') || seg.starts_with('$') || seg.starts_with('_') || seg.starts_with(':') +} + +/// The changed files, named the way they may be published: relative to the +/// workspace, capped, and never absolute. +/// +/// An absolute path carries the operator's home directory, and with it their +/// account name, into a log the whole org can read. A diff can name a file +/// outside the workspace, so falling back to the bare filename is the last line +/// rather than a case that cannot happen. +pub(crate) fn named(changed: &[PathBuf], cwd: &Path, cap: usize) -> Vec { + changed + .iter() + .take(cap) + .map(|p| { + let short = p.strip_prefix(cwd).unwrap_or(p); + if short.is_absolute() { + Path::new(short.file_name().unwrap_or_default()) + } else { + short + } + .to_string_lossy() + .into_owned() + }) + .collect() +} + +/// A short, key-safe name for the object, derived from the route when there is +/// one and from the file otherwise. Lowercase alphanumerics and dashes only, so +/// nothing here can shape the storage key it lands in. +pub(crate) fn slug(route: &str, first: &Path) -> String { + let raw = if route == "/" { + first + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default() + } else { + route.to_string() + }; + let mut out = String::new(); + for ch in raw.chars() { + if ch.is_ascii_alphanumeric() { + out.extend(ch.to_lowercase()); + } else if !out.ends_with('-') { + out.push('-'); + } + if out.len() >= 48 { + break; + } + } + let trimmed = out.trim_matches('-'); + if trimmed.is_empty() { + "page".to_string() + } else { + trimmed.to_string() + } +} + +/// The port the repository serves its UI on, read out of `package.json`: the +/// repository's own dev script first, then the framework's default. +/// +/// Reading, never running. The scripts in a `package.json` are arbitrary code +/// belonging to the tree the agent is editing; this function parses one for a +/// number and nothing else. +pub(crate) fn port(manifest: &str) -> Option { + let doc: serde_json::Value = serde_json::from_str(manifest).ok()?; + let script = ["dev", "start", "serve"] + .iter() + .find_map(|name| doc["scripts"][name].as_str()); + if let Some(explicit) = script.and_then(declared) { + return Some(explicit); + } + let deps = ["dependencies", "devDependencies"]; + let has = |name: &str| deps.iter().any(|d| !doc[d][name].is_null()); + // Ordered most specific first: SvelteKit and Nuxt both also carry the + // bundler they are built on, and the framework's port is the one that wins. + let by_framework = [ + ("@sveltejs/kit", 5173u16), + ("nuxt", 3000), + ("astro", 4321), + ("@angular/cli", 4200), + ("gatsby", 8000), + ("next", 3000), + ("react-scripts", 3000), + ("@remix-run/dev", 3000), + ("vite", 5173), + ("@vitejs/plugin-react", 5173), + ]; + by_framework + .iter() + .find(|(name, _)| has(name)) + .map(|(_, port)| *port) + .or(script.map(|_| 3000)) +} + +/// A port named on a dev script's own command line: `--port 4000`, `--port=4000` +/// or `-p 4000`. +fn declared(script: &str) -> Option { + let mut words = script.split_whitespace().peekable(); + while let Some(word) = words.next() { + let value = match word.split_once('=') { + Some(("--port", v)) => Some(v), + _ if word == "--port" || word == "-p" => words.peek().copied(), + _ => None, + }; + if let Some(parsed) = value.and_then(|v| v.parse::().ok()) { + return Some(parsed); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn diff_for(paths: &[&str]) -> String { + paths + .iter() + .map(|p| { + format!("diff --git a/{p} b/{p}\nindex 000..111\n--- a/{p}\n+++ b/{p}\n@@ -1 +1 @@\n-a\n+b\n") + }) + .collect() + } + + fn ui_of(paths: &[&str]) -> Vec { + touched(&diff_for(paths)) + .into_iter() + .filter(|p| rendered(p)) + .collect() + } + + #[test] + fn a_ui_file_is_a_reason_to_look() { + for path in [ + "src/app/page.tsx", + "src/Button.jsx", + "components/Card.vue", + "src/routes/+page.svelte", + "public/index.html", + "styles/main.css", + "styles/main.scss", + "styles/main.less", + "src/pages/about.astro", + ] { + assert!(rendered(Path::new(path)), "{path} should count as UI"); + } + } + + #[test] + fn a_backend_file_is_not() { + for path in [ + "apps/agents/sessions.go", + "core/src/shot/mod.rs", + "scripts/deploy.py", + "migrations/001.sql", + "Cargo.toml", + "LLM.md", + "src/lib/format.ts", + "src/app/api/route.ts", + "Makefile", + ] { + assert!(!rendered(Path::new(path)), "{path} should not count as UI"); + } + } + + #[test] + fn generated_output_is_not_authorship() { + for path in [ + "node_modules/react/index.css", + "dist/app.css", + ".next/static/chunk.css", + "build/index.html", + "out/index.html", + "target/doc/style.css", + ] { + assert!(!rendered(Path::new(path)), "{path} is derived, not UI"); + } + } + + #[test] + fn a_backend_only_diff_yields_nothing() { + assert!(ui_of(&["apps/agents/sessions.go", "core/src/codex.rs"]).is_empty()); + } + + #[test] + fn a_mixed_diff_yields_the_ui_half() { + let ui = ui_of(&[ + "apps/agents/sessions.go", + "web/src/app/settings/page.tsx", + "core/src/codex.rs", + ]); + assert_eq!(ui, vec![PathBuf::from("web/src/app/settings/page.tsx")]); + } + + #[test] + fn a_deleted_file_names_nothing() { + let diff = "diff --git a/src/Old.tsx b/src/Old.tsx\ndeleted file mode 100644\n--- a/src/Old.tsx\n+++ /dev/null\n@@ -1 +0,0 @@\n-gone\n"; + assert!(touched(diff).is_empty()); + } + + #[test] + fn a_rename_names_the_destination() { + let diff = "diff --git a/src/A.tsx b/src/B.tsx\nindex 000..111\n--- a/src/A.tsx\n+++ b/src/B.tsx\n@@ -1 +1 @@\n-a\n+b\n"; + assert_eq!(touched(diff), vec![PathBuf::from("src/B.tsx")]); + } + + #[test] + fn a_header_timestamp_is_not_part_of_the_path() { + let diff = "--- a/src/A.tsx\t2026-01-01 00:00:00\n+++ b/src/A.tsx\t2026-01-01 00:00:01\n"; + assert_eq!(touched(diff), vec![PathBuf::from("src/A.tsx")]); + } + + #[test] + fn an_absolute_path_survives_intact() { + let diff = "--- a//home/z/web/src/A.tsx\n+++ b//home/z/web/src/A.tsx\n"; + assert_eq!(touched(diff), vec![PathBuf::from("/home/z/web/src/A.tsx")]); + } + + #[test] + fn routes_are_read_off_the_path() { + let cases = [ + ("src/app/page.tsx", Some("/")), + ("src/app/settings/page.tsx", Some("/settings")), + ("app/(marketing)/pricing/page.tsx", Some("/pricing")), + ("src/routes/blog/+page.svelte", Some("/blog")), + ("src/pages/about.tsx", Some("/about")), + ("src/pages/index.tsx", Some("/")), + ("src/pages/blog/index.vue", Some("/blog")), + ("src/pages/docs/intro.astro", Some("/docs/intro")), + ]; + for (path, want) in cases { + assert_eq!( + route(Path::new(path)).as_deref(), + want, + "route for {path}" + ); + } + } + + #[test] + fn an_underivable_route_is_the_root() { + for path in [ + // A value the agent does not have. + "app/blog/[slug]/page.tsx", + "src/routes/user/[id]/+page.svelte", + "src/pages/posts/_id.vue", + "app/$team/page.tsx", + // Not a route at all: a component, a stylesheet, a layout. + "src/components/Button.tsx", + "src/app/globals.css", + "src/app/layout.tsx", + "src/routes/styles.css", + ] { + assert_eq!(route(Path::new(path)), None, "route for {path}"); + } + } + + #[test] + fn published_names_are_never_absolute() { + let cwd = Path::new("/home/someone/work/web"); + let changed = [ + PathBuf::from("/home/someone/work/web/src/app/page.tsx"), + PathBuf::from("src/Button.tsx"), + // A diff can name a file the workspace does not contain. + PathBuf::from("/etc/skel/theme.css"), + ]; + let out = named(&changed, cwd, 10); + assert_eq!(out, ["src/app/page.tsx", "src/Button.tsx", "theme.css"]); + for name in &out { + assert!(!name.starts_with('/'), "{name} leaks a filesystem location"); + assert!(!name.contains("someone"), "{name} leaks an account name"); + } + } + + #[test] + fn published_names_are_capped() { + let changed: Vec = (0..50).map(|i| PathBuf::from(format!("a/{i}.tsx"))).collect(); + assert_eq!(named(&changed, Path::new("/w"), 10).len(), 10); + } + + #[test] + fn slugs_carry_no_punctuation() { + assert_eq!(slug("/settings/team", Path::new("x.tsx")), "settings-team"); + assert_eq!(slug("/", Path::new("src/Button.tsx")), "button"); + assert_eq!(slug("/", Path::new("src/.hidden")), "hidden"); + assert_eq!(slug("/", Path::new("/")), "page"); + assert!(slug("/a/very/long/route/that/keeps/going/on/and/on/forever/and/ever", Path::new("x.tsx")).len() <= 48); + } + + #[test] + fn the_dev_script_names_its_own_port() { + for (script, want) in [ + (r#"{"scripts":{"dev":"next dev --port 4000"}}"#, 4000u16), + (r#"{"scripts":{"dev":"next dev --port=4100"}}"#, 4100), + (r#"{"scripts":{"dev":"vite -p 4200"}}"#, 4200), + ] { + assert_eq!(port(script), Some(want), "{script}"); + } + } + + #[test] + fn the_framework_names_the_default() { + for (manifest, want) in [ + (r#"{"scripts":{"dev":"next dev"},"dependencies":{"next":"15"}}"#, 3000u16), + (r#"{"scripts":{"dev":"vite"},"devDependencies":{"vite":"5"}}"#, 5173), + (r#"{"scripts":{"dev":"astro dev"},"dependencies":{"astro":"4"}}"#, 4321), + (r#"{"scripts":{"dev":"ng serve"},"devDependencies":{"@angular/cli":"18"}}"#, 4200), + ( + r#"{"scripts":{"dev":"vite dev"},"devDependencies":{"@sveltejs/kit":"2","vite":"5"}}"#, + 5173, + ), + (r#"{"scripts":{"dev":"nuxt dev"},"dependencies":{"nuxt":"3","vite":"5"}}"#, 3000), + ] { + assert_eq!(port(manifest), Some(want), "{manifest}"); + } + } + + #[test] + fn a_manifest_that_serves_nothing_names_no_port() { + assert_eq!(port(r#"{"name":"lib","scripts":{"build":"tsc"}}"#), None); + assert_eq!(port("not json"), None); + } +} diff --git a/core/src/skills/assets/samples/plan/SKILL.md b/core/src/skills/assets/samples/plan/SKILL.md index 5d49c33..3edd75e 100644 --- a/core/src/skills/assets/samples/plan/SKILL.md +++ b/core/src/skills/assets/samples/plan/SKILL.md @@ -1,6 +1,6 @@ --- name: plan -description: Generate a plan for how an agent should accomplish a complex coding task. Use when a user asks for a plan, and optionally when they want to save, find, read, update, or delete plan files in $CODEX_HOME/plans (default ~/.codex/plans). +description: Generate a plan for how an agent should accomplish a complex coding task. Use when a user asks for a plan, and optionally when they want to save, find, read, update, or delete plan files in $DEV_HOME/plans (default ~/.codex/plans). metadata: short-description: Generate a plan for a complex task --- @@ -17,7 +17,7 @@ This skill can also be used to draft codebase or system overviews. ## Core rules -- Resolve the plans directory as `$CODEX_HOME/plans` or `~/.codex/plans` when `CODEX_HOME` is not set. +- Resolve the plans directory as `$DEV_HOME/plans` or `~/.codex/plans` when `DEV_HOME` is not set. - Create the plans directory if it does not exist. - Never write to the repo; only read files to understand context. - Require frontmatter with **only** `name` and `description` (single-line values) for on-disk plans. diff --git a/core/src/skills/assets/samples/plan/scripts/create_plan.py b/core/src/skills/assets/samples/plan/scripts/create_plan.py index 4cbfa8f..8d15ae4 100644 --- a/core/src/skills/assets/samples/plan/scripts/create_plan.py +++ b/core/src/skills/assets/samples/plan/scripts/create_plan.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Create or overwrite a plan markdown file in $CODEX_HOME/plans.""" +"""Create or overwrite a plan markdown file in $DEV_HOME/plans.""" from __future__ import annotations @@ -61,7 +61,7 @@ def read_body(args: argparse.Namespace) -> str | None: def main() -> int: parser = argparse.ArgumentParser( - description="Create a plan file under $CODEX_HOME/plans or ~/.codex/plans." + description="Create a plan file under $DEV_HOME/plans or ~/.codex/plans." ) parser.add_argument("--name", required=True, help="Plan name (lower-case, hyphen-delimited).") parser.add_argument("--description", required=True, help="Short plan description.") diff --git a/core/src/skills/assets/samples/plan/scripts/list_plans.py b/core/src/skills/assets/samples/plan/scripts/list_plans.py index db6c412..689cead 100644 --- a/core/src/skills/assets/samples/plan/scripts/list_plans.py +++ b/core/src/skills/assets/samples/plan/scripts/list_plans.py @@ -10,7 +10,7 @@ from plan_utils import get_plans_dir, parse_frontmatter def main() -> int: - parser = argparse.ArgumentParser(description="List plan summaries from $CODEX_HOME/plans.") + parser = argparse.ArgumentParser(description="List plan summaries from $DEV_HOME/plans.") parser.add_argument("--query", help="Case-insensitive substring to filter name/description.") parser.add_argument("--json", action="store_true", help="Emit JSON output.") args = parser.parse_args() diff --git a/core/src/skills/assets/samples/plan/scripts/plan_utils.py b/core/src/skills/assets/samples/plan/scripts/plan_utils.py index 1a36a4b..4921be6 100644 --- a/core/src/skills/assets/samples/plan/scripts/plan_utils.py +++ b/core/src/skills/assets/samples/plan/scripts/plan_utils.py @@ -11,8 +11,8 @@ _NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") def get_codex_home() -> Path: - """Return CODEX_HOME if set, else ~/.codex.""" - return Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser() + """Return DEV_HOME if set, else ~/.codex.""" + return Path(os.environ.get("DEV_HOME", "~/.codex")).expanduser() def get_plans_dir() -> Path: diff --git a/core/src/skills/assets/samples/skill-installer/SKILL.md b/core/src/skills/assets/samples/skill-installer/SKILL.md index 857c32d..13a7f37 100644 --- a/core/src/skills/assets/samples/skill-installer/SKILL.md +++ b/core/src/skills/assets/samples/skill-installer/SKILL.md @@ -1,6 +1,6 @@ --- name: skill-installer -description: Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). +description: Install Codex skills into $DEV_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). metadata: short-description: Install curated skills from openai/skills or other repos --- @@ -27,7 +27,7 @@ Skills from {repo}: Which ones would you like installed? """ -After installing a skill, tell the user: "Restart Codex to pick up new skills." +After installing a skill, tell the user: "Restart Hanzo Dev to pick up new skills." ## Scripts @@ -43,7 +43,7 @@ All of these scripts use network, so when running in the sandbox, request escala - Defaults to direct download for public GitHub repos. - If download fails with auth/permission errors, falls back to git sparse checkout. - Aborts if the destination skill directory already exists. -- Installs into `$CODEX_HOME/skills/` (defaults to `~/.codex/skills`). +- Installs into `$DEV_HOME/skills/` (defaults to `~/.codex/skills`). - Multiple `--path` values install multiple skills in one run, each named from the path basename unless `--name` is supplied. - Options: `--ref ` (default `main`), `--dest `, `--method auto|download|git`. @@ -53,4 +53,4 @@ All of these scripts use network, so when running in the sandbox, request escala - Private GitHub repos can be accessed via existing git credentials or optional `GITHUB_TOKEN`/`GH_TOKEN` for download. - Git fallback tries HTTPS first, then SSH. - The skills at https://github.com/openai/skills/tree/main/skills/.system are preinstalled, so no need to help users install those. If they ask, just explain this. If they insist, you can download and overwrite. -- Installed annotations come from `$CODEX_HOME/skills`. +- Installed annotations come from `$DEV_HOME/skills`. diff --git a/core/src/skills/assets/samples/skill-installer/scripts/install-skill-from-github.py b/core/src/skills/assets/samples/skill-installer/scripts/install-skill-from-github.py index 5c0d8e6..b217005 100644 --- a/core/src/skills/assets/samples/skill-installer/scripts/install-skill-from-github.py +++ b/core/src/skills/assets/samples/skill-installer/scripts/install-skill-from-github.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Install a skill from a GitHub repo path into $CODEX_HOME/skills.""" +"""Install a skill from a GitHub repo path into $DEV_HOME/skills.""" from __future__ import annotations @@ -44,7 +44,7 @@ class InstallError(Exception): def _codex_home() -> str: - return os.environ.get("CODEX_HOME", os.path.expanduser("~/.codex")) + return os.environ.get("DEV_HOME", os.path.expanduser("~/.codex")) def _tmp_root() -> str: diff --git a/core/src/skills/assets/samples/skill-installer/scripts/list-curated-skills.py b/core/src/skills/assets/samples/skill-installer/scripts/list-curated-skills.py index 0977c29..f8cf463 100644 --- a/core/src/skills/assets/samples/skill-installer/scripts/list-curated-skills.py +++ b/core/src/skills/assets/samples/skill-installer/scripts/list-curated-skills.py @@ -32,7 +32,7 @@ def _request(url: str) -> bytes: def _codex_home() -> str: - return os.environ.get("CODEX_HOME", os.path.expanduser("~/.codex")) + return os.environ.get("DEV_HOME", os.path.expanduser("~/.codex")) def _installed_skills() -> set[str]: diff --git a/core/src/skills/system.rs b/core/src/skills/system.rs index d086603..5fa9de6 100644 --- a/core/src/skills/system.rs +++ b/core/src/skills/system.rs @@ -80,12 +80,12 @@ const EMBEDDED_SYSTEM_SKILLS: &[(&str, &[u8])] = &[ /// Returns the on-disk cache location for embedded system skills. /// -/// This is typically located at `CODEX_HOME/skills/.system`. +/// This is typically located at `DEV_HOME/skills/.system`. pub(crate) fn system_cache_root_dir(code_home: &Path) -> PathBuf { code_home.join(SKILLS_DIR_NAME).join(SYSTEM_SKILLS_DIR_NAME) } -/// Installs embedded system skills into `CODEX_HOME/skills/.system`. +/// Installs embedded system skills into `DEV_HOME/skills/.system`. /// /// Clears any existing system skills directory first and then writes the embedded /// skills directory into place. diff --git a/core/src/spawn.rs b/core/src/spawn.rs index f2a744c..4c02b94 100644 --- a/core/src/spawn.rs +++ b/core/src/spawn.rs @@ -18,12 +18,12 @@ use crate::protocol::SandboxPolicy; /// /// We may try to have just one environment variable for all sandboxing /// attributes, so this may change in the future. -pub const CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR: &str = "CODEX_SANDBOX_NETWORK_DISABLED"; +pub const DEV_SANDBOX_NETWORK_DISABLED_ENV_VAR: &str = "DEV_SANDBOX_NETWORK_DISABLED"; /// Should be set when the process is spawned under a sandbox. Currently, the /// value is "seatbelt" for macOS, but it may change in the future to /// accommodate sandboxing configuration and other sandboxing mechanisms. -pub const CODEX_SANDBOX_ENV_VAR: &str = "CODEX_SANDBOX"; +pub const DEV_SANDBOX_ENV_VAR: &str = "DEV_SANDBOX"; const SPAWN_RETRY_DELAYS_MS: [u64; 3] = [0, 10, 50]; @@ -96,7 +96,7 @@ pub enum StdioPolicy { /// /// For now, we take `SandboxPolicy` as a parameter to spawn_child() because /// we need to determine whether to set the -/// `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` environment variable. +/// `DEV_SANDBOX_NETWORK_DISABLED_ENV_VAR` environment variable. pub(crate) async fn spawn_child_async( program: PathBuf, args: Vec, @@ -119,7 +119,7 @@ pub(crate) async fn spawn_child_async( cmd.envs(env); if !sandbox_policy.has_full_network_access() { - cmd.env(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR, "1"); + cmd.env(DEV_SANDBOX_NETWORK_DISABLED_ENV_VAR, "1"); } // If this Codex process dies (including being killed via SIGKILL), we want diff --git a/core/src/unified_exec/mod.rs b/core/src/unified_exec/mod.rs index e3ff134..c074a86 100644 --- a/core/src/unified_exec/mod.rs +++ b/core/src/unified_exec/mod.rs @@ -442,7 +442,7 @@ mod tests { session_id: Some(session_id), input_chunks: &[ "export".to_string(), - "CODEX_INTERACTIVE_SHELL_VAR=codex\n".to_string(), + "DEV_INTERACTIVE_SHELL_VAR=codex\n".to_string(), ], timeout_ms: Some(2_500), }) @@ -451,7 +451,7 @@ mod tests { let out_2 = manager .handle_request(UnifiedExecRequest { session_id: Some(session_id), - input_chunks: &["echo $CODEX_INTERACTIVE_SHELL_VAR\n".to_string()], + input_chunks: &["echo $DEV_INTERACTIVE_SHELL_VAR\n".to_string()], timeout_ms: Some(2_500), }) .await?; @@ -477,7 +477,7 @@ mod tests { manager .handle_request(UnifiedExecRequest { session_id: Some(session_a), - input_chunks: &["export CODEX_INTERACTIVE_SHELL_VAR=codex\n".to_string()], + input_chunks: &["export DEV_INTERACTIVE_SHELL_VAR=codex\n".to_string()], timeout_ms: Some(2_500), }) .await?; @@ -487,7 +487,7 @@ mod tests { session_id: None, input_chunks: &[ "echo".to_string(), - "$CODEX_INTERACTIVE_SHELL_VAR\n".to_string(), + "$DEV_INTERACTIVE_SHELL_VAR\n".to_string(), ], timeout_ms: Some(2_500), }) @@ -497,7 +497,7 @@ mod tests { let out_3 = manager .handle_request(UnifiedExecRequest { session_id: Some(session_a), - input_chunks: &["echo $CODEX_INTERACTIVE_SHELL_VAR\n".to_string()], + input_chunks: &["echo $DEV_INTERACTIVE_SHELL_VAR\n".to_string()], timeout_ms: Some(2_500), }) .await?; @@ -525,7 +525,7 @@ mod tests { session_id: Some(session_id), input_chunks: &[ "export".to_string(), - "CODEX_INTERACTIVE_SHELL_VAR=codex\n".to_string(), + "DEV_INTERACTIVE_SHELL_VAR=codex\n".to_string(), ], timeout_ms: Some(2_500), }) @@ -534,7 +534,7 @@ mod tests { let out_2 = manager .handle_request(UnifiedExecRequest { session_id: Some(session_id), - input_chunks: &["sleep 5 && echo $CODEX_INTERACTIVE_SHELL_VAR\n".to_string()], + input_chunks: &["sleep 5 && echo $DEV_INTERACTIVE_SHELL_VAR\n".to_string()], timeout_ms: Some(10), }) .await?; diff --git a/core/tests/common/mod.rs b/core/tests/common/mod.rs index 7866127..078ec18 100644 --- a/core/tests/common/mod.rs +++ b/core/tests/common/mod.rs @@ -5,7 +5,7 @@ use std::sync::{Arc, Mutex}; use code_core::config::{Config, ConfigOverrides, ConfigToml}; use code_core::protocol::EventMsg; -use code_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use code_core::spawn::DEV_SANDBOX_NETWORK_DISABLED_ENV_VAR; use code_core::CodexConversation; use serde_json::Value; use tempfile::TempDir; @@ -108,7 +108,7 @@ where /// Returns true when network-dependent tests should be skipped. pub fn skip_if_no_network() -> bool { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { + if std::env::var(DEV_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { println!( "Skipping test because network access is disabled inside the sandbox." ); diff --git a/core/tests/custom_prompts_discovery.rs b/core/tests/custom_prompts_discovery.rs index 33d5d42..9949fd7 100644 --- a/core/tests/custom_prompts_discovery.rs +++ b/core/tests/custom_prompts_discovery.rs @@ -65,7 +65,7 @@ fn normalize_path_for_assertion(path: &Path) -> PathBuf { #[tokio::test] async fn discovers_prompts_from_code_home() -> Result<()> { let _env_lock = ENV_MUTEX.lock().unwrap(); - let env = EnvBackup::new(&["HOME", "CODE_HOME", "CODEX_HOME"]); + let env = EnvBackup::new(&["HOME", "CODE_HOME", "DEV_HOME"]); let code_home = TempDir::new()?; let prompts_dir = code_home.path().join("prompts"); @@ -74,7 +74,7 @@ async fn discovers_prompts_from_code_home() -> Result<()> { fs::write(prompts_dir.join("beta.MD"), "# beta")?; env.set_path("CODE_HOME", code_home.path()); - env.remove("CODEX_HOME"); + env.remove("DEV_HOME"); let default_dir = default_prompts_dir().expect("expected prompts dir"); assert_eq!( @@ -92,7 +92,7 @@ async fn discovers_prompts_from_code_home() -> Result<()> { #[tokio::test] async fn discovers_prompts_from_legacy_codex_home() -> Result<()> { let _env_lock = ENV_MUTEX.lock().unwrap(); - let env = EnvBackup::new(&["HOME", "CODE_HOME", "CODEX_HOME"]); + let env = EnvBackup::new(&["HOME", "CODE_HOME", "DEV_HOME"]); let fake_home = TempDir::new()?; let codex_home = fake_home.path().join(".codex"); @@ -102,7 +102,7 @@ async fn discovers_prompts_from_legacy_codex_home() -> Result<()> { env.set_path("HOME", fake_home.path()); env.remove("CODE_HOME"); - env.remove("CODEX_HOME"); + env.remove("DEV_HOME"); let default_dir = default_prompts_dir().expect("expected prompts dir"); assert_eq!( @@ -120,7 +120,7 @@ async fn discovers_prompts_from_legacy_codex_home() -> Result<()> { #[tokio::test] async fn prefers_code_home_when_both_locations_exist() -> Result<()> { let _env_lock = ENV_MUTEX.lock().unwrap(); - let env = EnvBackup::new(&["HOME", "CODE_HOME", "CODEX_HOME"]); + let env = EnvBackup::new(&["HOME", "CODE_HOME", "DEV_HOME"]); let fake_home = TempDir::new()?; let code_home = fake_home.path().join(".code"); @@ -134,7 +134,7 @@ async fn prefers_code_home_when_both_locations_exist() -> Result<()> { env.set_path("HOME", fake_home.path()); env.remove("CODE_HOME"); - env.remove("CODEX_HOME"); + env.remove("DEV_HOME"); let default_dir = default_prompts_dir().expect("expected prompts dir"); assert_eq!( @@ -152,7 +152,7 @@ async fn prefers_code_home_when_both_locations_exist() -> Result<()> { #[tokio::test] async fn ignores_non_markdown_files() -> Result<()> { let _env_lock = ENV_MUTEX.lock().unwrap(); - let env = EnvBackup::new(&["HOME", "CODE_HOME", "CODEX_HOME"]); + let env = EnvBackup::new(&["HOME", "CODE_HOME", "DEV_HOME"]); let code_home = TempDir::new()?; let prompts_dir = code_home.path().join("prompts"); @@ -161,7 +161,7 @@ async fn ignores_non_markdown_files() -> Result<()> { fs::write(prompts_dir.join("ignore.txt"), "# ignore")?; env.set_path("CODE_HOME", code_home.path()); - env.remove("CODEX_HOME"); + env.remove("DEV_HOME"); let prompts = discover_prompts_in(&prompts_dir).await; let names = prompt_names(&prompts); diff --git a/core/tests/git_mutation_guard.rs b/core/tests/git_mutation_guard.rs index cc1aef4..fd33029 100644 --- a/core/tests/git_mutation_guard.rs +++ b/core/tests/git_mutation_guard.rs @@ -8,24 +8,24 @@ use std::fs; const TARGET_DIRS: &[&str] = &[ - "code-rs/tui/src", - "code-rs/code-auto-drive-core/src", + "hanzo-dev/tui/src", + "hanzo-dev/code-auto-drive-core/src", ]; // Files allowed to host direct git invocations. They must also demonstrate an // epoch bump reference (either direct `bump_snapshot_epoch` or a documented helper). const ALLOW_LIST: &[(&str, &[&str])] = &[ // chatwidget uses run_git_command (bumps epoch) and ghost snapshots with post_commit_hook. - ("code-rs/tui/src/chatwidget.rs", &["bump_snapshot_epoch", "run_git_command"]), + ("hanzo-dev/tui/src/chatwidget.rs", &["bump_snapshot_epoch", "run_git_command"]), // get_git_diff is read-only; no epoch bump required, still allowed. - ("code-rs/tui/src/get_git_diff.rs", &[]), + ("hanzo-dev/tui/src/get_git_diff.rs", &[]), // auto_coordinator bumps epoch on mutating verbs inside run_git_command. ( - "code-rs/code-auto-drive-core/src/auto_coordinator.rs", + "hanzo-dev/code-auto-drive-core/src/auto_coordinator.rs", &["bump_snapshot_epoch", "run_git_command"], ), // controller currently no direct git calls; kept to avoid churn, require no markers. - ("code-rs/code-auto-drive-core/src/controller.rs", &[]), + ("hanzo-dev/code-auto-drive-core/src/controller.rs", &[]), ]; #[test] diff --git a/core/tests/hanzo_identity.rs b/core/tests/hanzo_identity.rs new file mode 100644 index 0000000..9d587a3 --- /dev/null +++ b/core/tests/hanzo_identity.rs @@ -0,0 +1,255 @@ +//! The product identity of `dev`, expressed as assertions. +//! +//! This repo tracks an upstream (`openai/codex`) and `upstream-merge.yml` merges +//! from it every 30 minutes. A one-time edit to a default is therefore not a +//! decision — it is a suggestion that survives until the next merge. These tests +//! are how the decision is made durable: a merge that reverts any of them turns +//! the build red instead of quietly shipping someone else's defaults. +//! +//! This file has no counterpart upstream, so a merge cannot conflict with it or +//! carry away the guard along with the thing it guards. +//! +//! If you are here because this test failed after an upstream merge: the merge +//! reverted our identity. Re-apply it — do not delete the test. + +use code_core::HANZO_PROVIDER_ID; +use code_core::WireApi; +use code_core::built_in_model_providers; +use code_core::config::HANZO_DEFAULT_MODEL; +use code_core::config::HANZO_DEFAULT_REVIEW_MODEL; + +/// Every model `dev` may pick by default belongs to the Zen family. Naming an +/// upstream model in our own copy is the thing this guards against. +fn is_zen_model(slug: &str) -> bool { + slug.starts_with("zen") || slug.starts_with("enso") +} + +#[test] +fn the_hanzo_cloud_is_a_built_in_provider() { + let providers = built_in_model_providers(None); + let hanzo = providers + .get(HANZO_PROVIDER_ID) + .expect("the Hanzo Cloud must be a built-in provider"); + + assert_eq!(hanzo.name, "Hanzo"); + assert_eq!( + hanzo.base_url.as_deref(), + Some("https://api.hanzo.ai/v1"), + "the Hanzo Cloud answers at api.hanzo.ai/v1" + ); + assert_eq!( + hanzo.env_key.as_deref(), + Some("HANZO_USER_KEY"), + "`hanzo code` sets HANZO_USER_KEY on the child; the provider must read it" + ); + assert!( + matches!(hanzo.wire_api, WireApi::Responses), + "verified against the live gateway: /v1/responses serves our models" + ); + assert!( + !hanzo.requires_openai_auth, + "this account is ours; the ChatGPT login flow never applies to it" + ); +} + +#[test] +fn the_default_model_is_ours() { + assert!( + is_zen_model(HANZO_DEFAULT_MODEL), + "default model must be a Zen model, got `{HANZO_DEFAULT_MODEL}`" + ); + assert!( + is_zen_model(HANZO_DEFAULT_REVIEW_MODEL), + "default review model must be a Zen model, got `{HANZO_DEFAULT_REVIEW_MODEL}`" + ); +} + +/// The installer contract, `hanzo code`, and the Homebrew formula all resolve a +/// binary named `dev` off PATH. A workspace that builds no such binary means a +/// release ships nothing anyone can run. +#[test] +fn the_cli_builds_a_binary_named_dev() { + let manifest = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../cli/Cargo.toml" + )) + .expect("read hanzo-dev/cli/Cargo.toml"); + + let bins: Vec<&str> = manifest + .split("[[bin]]") + .skip(1) + .filter_map(|section| { + section + .lines() + .find_map(|l| l.trim().strip_prefix("name = ")) + .map(|n| n.trim().trim_matches('"')) + }) + .collect(); + + assert!( + bins.contains(&"dev"), + "hanzo-dev/cli must build a binary named `dev`; it builds {bins:?}" + ); + assert_eq!( + bins.first(), + Some(&"dev"), + "`dev` is the product name and must be declared first; the rest are aliases" + ); +} + +/// We publish our releases to namespaces we own. Publishing into a third party's +/// namespace is not a style question — it hands them our distribution. +#[test] +fn the_release_pipeline_publishes_to_our_namespaces() { + let release_yml = repo_file(".hanzo/workflows/release.yml"); + + for (needle, why) in [ + ("@just-every/", "npm packages must publish under @hanzo/"), + ( + "just-every/homebrew-tap", + "the Homebrew formula belongs in hanzoai/homebrew-tap", + ), + ( + "just-every/code/releases", + "release assets must be served from hanzoai/dev", + ), + ] { + assert!( + !release_yml.contains(needle), + "release.yml still references `{needle}` — {why}" + ); + } + + assert!( + release_yml.contains("--bin dev"), + "the release must build the `dev` binary" + ); + for pkg in [ + "@hanzo/dev-darwin-arm64", + "@hanzo/dev-darwin-x64", + "@hanzo/dev-linux-x64-musl", + "@hanzo/dev-linux-arm64-musl", + "@hanzo/dev-win32-x64", + ] { + assert!( + release_yml.contains(pkg), + "the release must publish `{pkg}` — postinstall.js resolves it first" + ); + } + assert!( + release_yml.contains("hanzoai/homebrew-tap"), + "the release must push the formula to hanzoai/homebrew-tap" + ); +} + +/// A SERVICE HAS NO HUMAN SESSION, and reading only `HANZO_USER_KEY` made that +/// case impossible: `dev` inside a sandbox pod reported "Authentication expired. +/// Run `code login` to continue" for a caller that was perfectly authenticated, +/// because the one variable it would look at is the one no service ever has. +#[test] +fn the_hanzo_provider_accepts_a_machine_credential() { + let p = code_core::create_hanzo_provider(); + for want in ["HANZO_API_KEY", "HANZO_MACHINE_TOKEN"] { + assert!( + p.alt_env_keys.iter().any(|k| k == want), + "a service run carries {want}; the provider must read it" + ); + } + assert_eq!( + p.env_key.as_deref(), + Some("HANZO_USER_KEY"), + "the human session stays the declared key" + ); +} + +/// MACHINE FIRST, and deliberately. Where both are present the process was +/// started BY something, and the credential it was handed is the one that should +/// be billed and audited — a stale human key left in an environment must not +/// quietly outrank the identity the caller actually presented. +#[test] +fn a_machine_credential_outranks_a_stale_human_one() { + let p = code_core::create_hanzo_provider(); + let idx = |name: &str| { + p.alt_env_keys + .iter() + .chain(p.env_key.iter()) + .position(|k| k == name) + .expect("declared") + }; + assert!( + idx("HANZO_API_KEY") < idx("HANZO_USER_KEY"), + "the machine credential must be tried before the human session" + ); +} + +/// A repo-relative path, read from the test's own manifest location. +fn repo_file(rel: &str) -> String { + let path = format!("{}/../../{rel}", env!("CARGO_MANIFEST_DIR")); + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {rel}: {e}")) +} + +/// Auto Drive drives the same gateway everything else does. Its defaults were +/// `gpt-5.1` for the model and `gpt-5.5`/`gpt-5.4-mini` for routing — ids the +/// Hanzo Cloud answers with `400 model is not available`, so the feature was +/// broken out of the box for the default provider rather than merely misnamed. +#[test] +fn auto_drive_defaults_to_models_our_gateway_serves() { + let settings = code_core::config_types::AutoDriveSettings::default(); + + assert!( + is_zen_model(&settings.model), + "Auto Drive defaults to `{}`, which the Hanzo Cloud does not serve", + settings.model + ); + + for entry in code_core::config_types::default_auto_drive_model_routing_entries() { + assert!( + is_zen_model(&entry.model), + "Auto Drive routes to `{}`, which the Hanzo Cloud does not serve", + entry.model + ); + } +} + +/// `brew upgrade code` names a formula that does not exist; ours is +/// `Formula/hanzo-dev.rb`. This string is not merely displayed — the TUI runs it. +#[test] +fn the_homebrew_upgrade_command_names_our_formula() { + let updates = repo_file("hanzo-dev/tui/src/updates.rs"); + + assert!( + updates.contains("brew upgrade hanzo-dev"), + "the guided Homebrew upgrade must name the `hanzo-dev` formula" + ); + assert!( + !updates.contains(r#""upgrade".to_string(), + "code".to_string(),"#), + "the Homebrew upgrade must not target a `code` formula" + ); +} + +/// The first screen a new user sees. It greeted them as OpenAI's product for +/// months after the rest of the identity had been fixed, because nothing here +/// looked at rendered text — only at structural defaults. +#[test] +fn the_onboarding_screen_introduces_our_product() { + let welcome = repo_file("hanzo-dev/tui/src/onboarding/welcome.rs"); + assert!( + welcome.contains("Welcome to Hanzo Dev"), + "the welcome screen must greet the user as Hanzo Dev" + ); + + // The screen serves both providers, so the OpenAI copy — including a link to + // ChatGPT's settings — legitimately survives on that branch. What must hold + // is that the Hanzo path exists and is the one a default install is offered. + let auth = repo_file("hanzo-dev/tui/src/onboarding/auth.rs"); + for needle in [ + "Sign in with Hanzo", + "Sign in with Hanzo to use your Hanzo Cloud account", + ] { + assert!( + auth.contains(needle), + "the sign-in screen must offer the Hanzo path (`{needle}` is missing)" + ); + } +} diff --git a/core/tests/remote_models_manager.rs b/core/tests/remote_models_manager.rs index 0974bdc..cb3ba43 100644 --- a/core/tests/remote_models_manager.rs +++ b/core/tests/remote_models_manager.rs @@ -12,7 +12,7 @@ use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; fn skip_if_no_network() -> bool { - std::env::var(code_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() + std::env::var(code_core::spawn::DEV_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() } fn remote_model(slug: &str, display: &str, priority: i32) -> ModelInfo { @@ -45,6 +45,7 @@ fn remote_model(slug: &str, display: &str, priority: i32) -> ModelInfo { fn provider_for(base_url: String) -> ModelProviderInfo { ModelProviderInfo { + alt_env_keys: Vec::new(), name: "mock".into(), base_url: Some(base_url), env_key: None, diff --git a/core/tests/repeated_tool_loop.rs b/core/tests/repeated_tool_loop.rs index 9deed29..70895f5 100644 --- a/core/tests/repeated_tool_loop.rs +++ b/core/tests/repeated_tool_loop.rs @@ -120,6 +120,7 @@ async fn repeated_identical_tool_cycle_stops_with_visible_error() { config.approval_policy = AskForApproval::Never; config.sandbox_policy = SandboxPolicy::DangerFullAccess; config.model_provider = ModelProviderInfo { + alt_env_keys: Vec::new(), base_url: Some(format!("{}/v1", server.uri())), ..built_in_model_providers(None)["openai"].clone() }; diff --git a/core/tests/tool_hooks.rs b/core/tests/tool_hooks.rs index 1fb2cf6..b203d6d 100644 --- a/core/tests/tool_hooks.rs +++ b/core/tests/tool_hooks.rs @@ -78,6 +78,7 @@ fn configure_test( config.sandbox_policy = SandboxPolicy::DangerFullAccess; config.project_hooks = ProjectHooks::from_configs(&hook_configs, &config.cwd); config.model_provider = ModelProviderInfo { + alt_env_keys: Vec::new(), base_url: Some(format!("{}/v1", server.uri())), ..built_in_model_providers(None)["openai"].clone() }; diff --git a/core/tests/tool_selection.rs b/core/tests/tool_selection.rs index 54f9727..229e05c 100644 --- a/core/tests/tool_selection.rs +++ b/core/tests/tool_selection.rs @@ -48,6 +48,7 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec { let resp_mock = mount_sse_once(&server, sse).await; let model_provider = ModelProviderInfo { + alt_env_keys: Vec::new(), base_url: Some(format!("{}/v1", server.uri())), ..built_in_model_providers(None)["openai"].clone() }; diff --git a/default.nix b/default.nix index 90d1570..45a2b01 100644 --- a/default.nix +++ b/default.nix @@ -7,7 +7,7 @@ in rec { package = pkgs.rustPlatform.buildRustPackage { inherit env; - pname = "code-rs"; + pname = "hanzo-dev"; version = "0.1.0"; cargoLock = { lockFile = ./Cargo.lock; @@ -27,20 +27,20 @@ rec { openssl ]; meta = with pkgs.lib; { - description = "OpenAI Codex command‑line interface rust implementation"; + description = "Hanzo Dev — the Hanzo coding agent (Rust implementation)"; license = licenses.asl20; - homepage = "https://github.com/openai/codex"; + homepage = "https://github.com/hanzoai/dev"; }; }; devShell = pkgs.mkShell { inherit env; - name = "code-rs-dev"; + name = "hanzo-dev-dev"; packages = monorep-deps ++ [ pkgs.cargo package ]; shellHook = '' - echo "Entering development shell for code-rs" + echo "Entering development shell for hanzo-dev" alias codex="cd ${package.src}/tui; cargo run; cd -" ${pkgs.rustPlatform.cargoSetupHook} ''; diff --git a/docs/architecture/phase0-baseline.md b/docs/architecture/phase0-baseline.md index 2fc77d9..7aa30d3 100644 --- a/docs/architecture/phase0-baseline.md +++ b/docs/architecture/phase0-baseline.md @@ -1,6 +1,6 @@ # Phase 0 Baseline – Architecture Survey (September 20, 2025) -This note captures the current layout of the `code-rs` workspace before any +This note captures the current layout of the `hanzo-dev` workspace before any phase-one refactors. It should help us reason about the execution pipeline and identify the modules that will be split up in later phases. diff --git a/exec/Cargo.toml b/exec/Cargo.toml index 57c6baf..71963d1 100644 --- a/exec/Cargo.toml +++ b/exec/Cargo.toml @@ -26,6 +26,7 @@ code-common = { workspace = true, features = [ code-core = { workspace = true } code-ollama = { workspace = true } code-protocol = { workspace = true } +code-version = { path = "../code-version" } code-app-server-protocol = { workspace = true } code-auto-drive-core = { workspace = true } code-git-tooling = { workspace = true } @@ -46,6 +47,11 @@ tokio = { workspace = true, features = [ "sync", ] } tracing = { workspace = true, features = ["log"] } +ts-rs = { workspace = true, features = [ + "uuid-impl", + "serde-json-impl", + "no-serde-warnings", +] } tracing-subscriber = { workspace = true, features = ["env-filter"] } once_cell = { workspace = true } diff --git a/exec/src/cli.rs b/exec/src/cli.rs index f1e38ce..e54b983 100644 --- a/exec/src/cli.rs +++ b/exec/src/cli.rs @@ -65,7 +65,7 @@ pub struct Cli { #[clap(long = "debug", short = 'd', default_value_t = false)] pub debug: bool, - /// Allow running Codex outside a Git repository. + /// Allow running Hanzo Dev outside a Git repository. #[arg(long = "skip-git-repo-check", default_value_t = false)] pub skip_git_repo_check: bool, @@ -86,7 +86,7 @@ pub struct Cli { pub color: Color, /// Print events to stdout as JSONL. - #[arg(long = "json", default_value_t = false)] + #[arg(long = "json", alias = "experimental-json", default_value_t = false)] pub json: bool, /// Maximum wall-clock time budget (seconds) before aborting the run. diff --git a/exec/src/event_processor.rs b/exec/src/event_processor.rs index 3bc1158..f35f37f 100644 --- a/exec/src/event_processor.rs +++ b/exec/src/event_processor.rs @@ -2,6 +2,7 @@ use std::path::Path; use code_core::config::Config; use code_core::protocol::Event; +use code_core::protocol::SessionConfiguredEvent; pub(crate) enum CodexStatus { Running, @@ -13,6 +14,11 @@ pub(crate) trait EventProcessor { /// Print summary of effective configuration and user prompt. fn print_config_summary(&mut self, config: &Config, prompt: &str); + /// The session handshake. `exec` consumes `SessionConfigured` before the + /// event loop starts, so a processor that needs it — JSON mode, which + /// derives `thread.started` from it — has to be handed it here. + fn on_session_configured(&mut self, _ev: &SessionConfiguredEvent) {} + /// Handle a single event emitted by the agent. fn process_event(&mut self, event: Event) -> CodexStatus; diff --git a/exec/src/event_processor_with_human_output.rs b/exec/src/event_processor_with_human_output.rs index 4c324ea..8005528 100644 --- a/exec/src/event_processor_with_human_output.rs +++ b/exec/src/event_processor_with_human_output.rs @@ -160,12 +160,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { /// for the session. This mirrors the information shown in the TUI welcome /// screen. fn print_config_summary(&mut self, config: &Config, prompt: &str) { - const VERSION: &str = env!("CARGO_PKG_VERSION"); - ts_println!( - self, - "OpenAI Codex v{} (research preview)", - VERSION - ); + ts_println!(self, "Hanzo Dev v{}\n--------", code_version::version()); // Show which binary is being used to execute sub-agents so users can // confirm path mismatches when testing new builds. @@ -262,7 +257,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { EventMsg::AutoContextCheck(_) => {} EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { if !self.answer_started { - ts_println!(self, "{}\n", "codex".style(self.italic).style(self.magenta)); + ts_println!(self, "{}\n", "dev".style(self.italic).style(self.magenta)); self.answer_started = true; } print!("{delta}"); @@ -327,7 +322,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { ts_println!( self, "{}\n{}", - "codex".style(self.italic).style(self.magenta), + "dev".style(self.italic).style(self.magenta), message, ); } else { @@ -588,7 +583,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { ts_println!( self, "{}\n{}", - "codex".style(self.italic).style(self.magenta), + "dev".style(self.italic).style(self.magenta), agent_reasoning_event.text, ); } else { @@ -596,7 +591,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { ts_println!( self, "{}\n{}", - "codex".style(self.italic).style(self.magenta), + "dev".style(self.italic).style(self.magenta), agent_reasoning_event.text, ); } @@ -608,7 +603,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { ts_println!( self, "{} {}", - "codex session".style(self.magenta).style(self.bold), + "dev session".style(self.magenta).style(self.bold), conversation_id.to_string().style(self.dimmed) ); diff --git a/exec/src/event_processor_with_json_output.rs b/exec/src/event_processor_with_json_output.rs deleted file mode 100644 index ac27eea..0000000 --- a/exec/src/event_processor_with_json_output.rs +++ /dev/null @@ -1,67 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; - -use code_core::config::Config; -use code_core::protocol::Event; -use code_core::protocol::EventMsg; -use code_core::protocol::TaskCompleteEvent; -use serde_json::json; - -use crate::event_processor::CodexStatus; -use crate::event_processor::EventProcessor; -use crate::event_processor::handle_last_message; -use code_common::create_config_summary_entries; - -pub(crate) struct EventProcessorWithJsonOutput { - last_message_path: Option, - had_error: bool, -} - -impl EventProcessorWithJsonOutput { - pub fn new(last_message_path: Option) -> Self { - Self { last_message_path, had_error: false } - } -} - -impl EventProcessor for EventProcessorWithJsonOutput { - fn print_config_summary(&mut self, config: &Config, prompt: &str) { - let entries = create_config_summary_entries(config) - .into_iter() - .map(|(key, value)| (key.to_string(), value)) - .collect::>(); - #[expect(clippy::expect_used)] - let config_json = - serde_json::to_string(&entries).expect("Failed to serialize config summary to JSON"); - println!("{config_json}"); - - let prompt_json = json!({ - "prompt": prompt, - }); - println!("{prompt_json}"); - } - - fn process_event(&mut self, event: Event) -> CodexStatus { - match event.msg { - EventMsg::Error(_) => { self.had_error = true; CodexStatus::Running } - EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) => { - // Suppress streaming events in JSON mode. - CodexStatus::Running - } - EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => { - if let Some(output_file) = self.last_message_path.as_deref() { - handle_last_message(last_agent_message.as_deref(), output_file); - } - CodexStatus::InitiateShutdown - } - EventMsg::ShutdownComplete => CodexStatus::Shutdown, - _ => { - if let Ok(line) = serde_json::to_string(&event) { - println!("{line}"); - } - CodexStatus::Running - } - } - } - - // exit_code handled by CLI; suppress unused warnings by omitting method. -} diff --git a/exec/src/exec_events.rs b/exec/src/exec_events.rs index 606cf91..c184f3c 100644 --- a/exec/src/exec_events.rs +++ b/exec/src/exec_events.rs @@ -60,8 +60,14 @@ pub struct Usage { pub input_tokens: u64, /// The number of cached input tokens used during the turn. pub cached_input_tokens: u64, + /// The number of input tokens written to the prompt cache during the turn. + #[serde(default)] + pub cache_write_input_tokens: u64, /// The number of output tokens used during the turn. pub output_tokens: u64, + /// The number of reasoning output tokens used during the turn. + #[serde(default)] + pub reasoning_output_tokens: u64, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] diff --git a/exec/src/experimental_event_processor_with_json_output.rs b/exec/src/experimental_event_processor_with_json_output.rs index 215af25..2b45054 100644 --- a/exec/src/experimental_event_processor_with_json_output.rs +++ b/exec/src/experimental_event_processor_with_json_output.rs @@ -5,7 +5,7 @@ use std::sync::atomic::AtomicU64; use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; use crate::event_processor::handle_last_message; -use crate::exec_events::AssistantMessageItem; +use crate::exec_events::AgentMessageItem; use crate::exec_events::CommandExecutionItem; use crate::exec_events::CommandExecutionStatus; use crate::exec_events::FileChangeItem; @@ -46,8 +46,7 @@ use code_core::protocol::PatchApplyBeginEvent; use code_core::protocol::PatchApplyEndEvent; use code_core::protocol::SessionConfiguredEvent; use code_core::protocol::TaskCompleteEvent; -use code_core::protocol::TaskStartedEvent; -use code_core::protocol::WebSearchEndEvent; +use code_core::protocol::WebSearchCompleteEvent; use tracing::error; use tracing::warn; @@ -109,14 +108,14 @@ impl ExperimentalEventProcessorWithJsonOutput { EventMsg::PatchApplyBegin(ev) => self.handle_patch_apply_begin(ev), EventMsg::PatchApplyEnd(ev) => self.handle_patch_apply_end(ev), EventMsg::WebSearchBegin(_) => Vec::new(), - EventMsg::WebSearchEnd(ev) => self.handle_web_search_end(ev), + EventMsg::WebSearchComplete(ev) => self.handle_web_search_complete(ev), EventMsg::TokenCount(ev) => { if let Some(info) = &ev.info { self.last_total_token_usage = Some(info.total_token_usage.clone()); } Vec::new() } - EventMsg::TaskStarted(ev) => self.handle_task_started(ev), + EventMsg::TaskStarted => self.handle_task_started(), EventMsg::TaskComplete(_) => self.handle_task_complete(), EventMsg::Error(ev) => { let error = ThreadErrorEvent { @@ -125,9 +124,6 @@ impl ExperimentalEventProcessorWithJsonOutput { self.last_critical_error = Some(error.clone()); vec![ThreadEvent::Error(error)] } - EventMsg::StreamError(ev) => vec![ThreadEvent::Error(ThreadErrorEvent { - message: ev.message.clone(), - })], EventMsg::PlanUpdate(ev) => self.handle_plan_update(ev), _ => Vec::new(), } @@ -147,11 +143,11 @@ impl ExperimentalEventProcessorWithJsonOutput { })] } - fn handle_web_search_end(&self, ev: &WebSearchEndEvent) -> Vec { + fn handle_web_search_complete(&self, ev: &WebSearchCompleteEvent) -> Vec { let item = ThreadItem { id: self.get_next_item_id(), details: ThreadItemDetails::WebSearch(WebSearchItem { - query: ev.query.clone(), + query: ev.query.clone().unwrap_or_default(), }), }; @@ -162,7 +158,7 @@ impl ExperimentalEventProcessorWithJsonOutput { let item = ThreadItem { id: self.get_next_item_id(), - details: ThreadItemDetails::AssistantMessage(AssistantMessageItem { + details: ThreadItemDetails::AgentMessage(AgentMessageItem { text: payload.message.clone(), }), }; @@ -339,7 +335,7 @@ impl ExperimentalEventProcessorWithJsonOutput { details: ThreadItemDetails::CommandExecution(CommandExecutionItem { command, - aggregated_output: ev.aggregated_output.clone(), + aggregated_output: format!("{}{}", ev.stdout, ev.stderr), exit_code: Some(ev.exit_code), status, }), @@ -382,7 +378,7 @@ impl ExperimentalEventProcessorWithJsonOutput { vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] } - fn handle_task_started(&mut self, _: &TaskStartedEvent) -> Vec { + fn handle_task_started(&mut self) -> Vec { self.last_critical_error = None; vec![ThreadEvent::TurnStarted(TurnStartedEvent {})] } @@ -392,7 +388,9 @@ impl ExperimentalEventProcessorWithJsonOutput { Usage { input_tokens: u.input_tokens, cached_input_tokens: u.cached_input_tokens, + cache_write_input_tokens: u.cache_write_input_tokens, output_tokens: u.output_tokens, + reasoning_output_tokens: u.reasoning_output_tokens, } } else { Usage::default() @@ -420,26 +418,29 @@ impl ExperimentalEventProcessorWithJsonOutput { } } +impl ExperimentalEventProcessorWithJsonOutput { + fn emit(&self, events: Vec) { + for event in events { + match serde_json::to_string(&event) { + Ok(line) => println!("{line}"), + Err(e) => error!("Failed to serialize event: {e:?}"), + } + } + } +} + impl EventProcessor for ExperimentalEventProcessorWithJsonOutput { - fn print_config_summary(&mut self, _: &Config, _: &str, ev: &SessionConfiguredEvent) { - self.process_event(Event { - id: "".to_string(), - msg: EventMsg::SessionConfigured(ev.clone()), - }); + /// JSON mode emits no human-readable preamble. + fn print_config_summary(&mut self, _: &Config, _: &str) {} + + fn on_session_configured(&mut self, ev: &SessionConfiguredEvent) { + let started = self.handle_session_configured(ev); + self.emit(started); } fn process_event(&mut self, event: Event) -> CodexStatus { let aggregated = self.collect_thread_events(&event); - for conv_event in aggregated { - match serde_json::to_string(&conv_event) { - Ok(line) => { - println!("{line}"); - } - Err(e) => { - error!("Failed to serialize event: {e:?}"); - } - } - } + self.emit(aggregated); let Event { msg, .. } = event; diff --git a/exec/src/lib.rs b/exec/src/lib.rs index 5fc3572..80a4875 100644 --- a/exec/src/lib.rs +++ b/exec/src/lib.rs @@ -1,7 +1,8 @@ mod cli; mod event_processor; mod event_processor_with_human_output; -mod event_processor_with_json_output; +pub mod exec_events; +mod experimental_event_processor_with_json_output; mod slash; pub use cli::Cli; @@ -48,7 +49,7 @@ use code_protocol::protocol::SessionSource; use code_ollama::DEFAULT_OSS_MODEL; use code_protocol::config_types::SandboxMode; use event_processor_with_human_output::EventProcessorWithHumanOutput; -use event_processor_with_json_output::EventProcessorWithJsonOutput; +use experimental_event_processor_with_json_output::ExperimentalEventProcessorWithJsonOutput; use event_processor::handle_last_message; use code_git_tooling::GhostCommit; use code_git_tooling::CreateGhostCommitOptions; @@ -101,7 +102,7 @@ use code_core::timeboxed_exec_guidance::{ const AUTO_REVIEW_SHUTDOWN_GRACE_MS: u64 = 1_500; const REVIEW_SCOPE_MAX_LISTED_PATHS: usize = 120; const REVIEW_SCOPE_EXCLUDED_PREFIXES: [&str; 5] = [ - "codex-rs/", + "vendor/", "node_modules/", "target/", ".git/", @@ -477,7 +478,9 @@ pub async fn run_main(cli: Cli, code_linux_sandbox_exe: Option) -> anyh } let stop_on_task_complete = auto_drive_goal.is_none() && auto_resolve_state.is_none(); let mut event_processor: Box = if json_mode { - Box::new(EventProcessorWithJsonOutput::new(last_message_file.clone())) + Box::new(ExperimentalEventProcessorWithJsonOutput::new( + last_message_file.clone(), + )) } else { Box::new(EventProcessorWithHumanOutput::create_with_ansi( stdout_with_ansi, @@ -493,6 +496,11 @@ pub async fn run_main(cli: Cli, code_linux_sandbox_exe: Option) -> anyh .map_err(|e| anyhow::anyhow!("OSS setup failed: {e}"))?; } + // Say what a free run costs and shares before it starts, not after. + if code_core::free::runs(&config) { + eprintln!("{}", code_core::free::NOTICE); + } + // Print the effective configuration and prompt so users can see what Codex // is using. let default_cwd = config.cwd.to_path_buf(); @@ -542,7 +550,8 @@ pub async fn run_main(cli: Cli, code_linux_sandbox_exe: Option) -> anyh } else { event_processor.print_config_summary(&config, &summary_prompt); } - info!("Codex initialized with event: {session_configured:?}"); + event_processor.on_session_configured(&session_configured); + info!("Hanzo Dev initialized with event: {session_configured:?}"); if let Some(goal) = auto_drive_goal { return run_auto_drive_session( @@ -2822,7 +2831,7 @@ mod tests { fn apply_commit_scope_filters_and_truncates_paths() { let mut paths = vec![ "./src/main.rs".to_string(), - "codex-rs/Cargo.lock".to_string(), + "vendor/codex/Cargo.lock".to_string(), "./src/main.rs".to_string(), ]; for idx in 0..130 { @@ -2840,19 +2849,19 @@ mod tests { let scoped = apply_commit_scope_to_review_request(request, "abc1234", "def5678", Some(&paths)); assert!(scoped.prompt.contains("Files changed in this snapshot:")); assert!(scoped.prompt.contains("- src/main.rs")); - assert!(!scoped.prompt.contains("codex-rs/Cargo.lock")); + assert!(!scoped.prompt.contains("vendor/codex/Cargo.lock")); assert!( scoped .prompt .contains("Omitted 12 changed path(s) from this list (1 excluded by path policy; 11 truncated for prompt size).") ); - assert!(scoped.prompt.contains("Excluded prefixes: codex-rs/")); + assert!(scoped.prompt.contains("Excluded prefixes: vendor/")); } #[test] fn apply_commit_scope_reports_when_all_paths_excluded() { let paths = vec![ - "codex-rs/Cargo.lock".to_string(), + "vendor/codex/Cargo.lock".to_string(), "target/debug/code".to_string(), "node_modules/pkg/index.js".to_string(), ]; @@ -2872,7 +2881,7 @@ mod tests { .prompt .contains("Omitted 3 changed path(s) from this list (3 excluded by path policy; 0 truncated for prompt size).") ); - assert!(scoped.prompt.contains("Excluded prefixes: codex-rs/")); + assert!(scoped.prompt.contains("Excluded prefixes: vendor/")); } #[test] diff --git a/git-apply/src/lib.rs b/git-apply/src/lib.rs index 5d14631..418919c 100644 --- a/git-apply/src/lib.rs +++ b/git-apply/src/lib.rs @@ -45,7 +45,7 @@ pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result { // Optional: additional git config via env knob (defaults OFF) let mut cfg_parts: Vec = Vec::new(); - if let Ok(cfg) = std::env::var("CODEX_APPLY_GIT_CFG") { + if let Ok(cfg) = std::env::var("DEV_APPLY_GIT_CFG") { for pair in cfg.split(',') { let p = pair.trim(); if p.is_empty() || !p.contains('=') { diff --git a/git-tooling/src/ghost_commits.rs b/git-tooling/src/ghost_commits.rs index c56ada6..d7c80ac 100644 --- a/git-tooling/src/ghost_commits.rs +++ b/git-tooling/src/ghost_commits.rs @@ -482,7 +482,7 @@ mod tests { assert_eq!(root_after, "root after\n"); let nested_after = std::fs::read_to_string(workspace.join("nested.txt"))?; assert_eq!(nested_after, "nested modified\n"); - assert!(!workspace.join("code-rs").exists()); + assert!(!workspace.join("hanzo-dev").exists()); Ok(()) } @@ -494,7 +494,7 @@ mod tests { let repo = temp.path(); init_test_repo(repo); - let workspace = repo.join("code-rs"); + let workspace = repo.join("hanzo-dev"); std::fs::create_dir_all(&workspace)?; std::fs::write(repo.join(".gitignore"), ".vscode/\n")?; std::fs::write(workspace.join("tracked.txt"), "snapshot version\n")?; diff --git a/login/src/assets/success.html b/login/src/assets/success.html index 37c4efa..255a3e9 100644 --- a/login/src/assets/success.html +++ b/login/src/assets/success.html @@ -4,7 +4,7 @@ - Sign into Codex + Sign in to Hanzo Dev