upstream: the fork stands alone; Dev fills its two hooks

hanzoai/codex names nothing outside its tree now (d1d710452): Dev's names,
defaults, model catalog, `/model <name>` match, controller interface and
version live in its `hanzo` crate, and Dev's crates depend on that, never
the reverse. hanzo-loop moves there whole; hanzo-tui keeps only the Hanzo
account; hanzo-config reads API_BASE, DEFAULT_MODEL and is_hanzo from it.

hanzo-dev's main registers what Dev adds before anything starts:
`codex_app_server::register(hanzo_kai::install)` puts Kai on every thread,
and `codex_tui::register(Account { login, logout, show })` puts the Hanzo
CLI's sign-in behind /login, /logout, /auth and /status. launch.rs holds
that /login is offered, which it is only when an account is registered.
make test-upstream runs locked against the fork's own lock.
This commit is contained in:
2026-10-15 10:03:29 -07:00
parent bcb26d732d
commit ad468d97af
31 changed files with 149 additions and 542 deletions
Generated
+36 -47
View File
@@ -1802,7 +1802,7 @@ dependencies = [
"codex-protocol",
"crypto_box",
"ed25519-dalek",
"hanzo-version",
"hanzo",
"http 1.4.0",
"jsonwebtoken",
"rand 0.9.3",
@@ -1878,7 +1878,7 @@ dependencies = [
"codex-protocol",
"codex-state",
"codex-utils-path-uri",
"hanzo-version",
"hanzo",
"os_info",
"serde",
"serde_json",
@@ -2000,8 +2000,7 @@ dependencies = [
"codex-websocket-auth",
"codex-windows-sandbox",
"futures",
"hanzo-kai",
"hanzo-version",
"hanzo",
"libc",
"serde",
"serde_json",
@@ -2059,7 +2058,7 @@ dependencies = [
"codex-uds",
"codex-utils-home-dir",
"futures",
"hanzo-version",
"hanzo",
"libc",
"semver",
"serde",
@@ -2114,7 +2113,7 @@ dependencies = [
"codex-otel",
"codex-protocol",
"codex-utils-cli",
"hanzo-version",
"hanzo",
"serde",
"serde_json",
"tokio",
@@ -2146,7 +2145,7 @@ dependencies = [
"codex-websocket-client",
"futures",
"gethostname",
"hanzo-version",
"hanzo",
"httpdate",
"libc",
"owo-colors",
@@ -2353,7 +2352,7 @@ dependencies = [
"codex-windows-sandbox",
"crossterm",
"futures",
"hanzo-version",
"hanzo",
"http 1.4.0",
"libc",
"os_info",
@@ -2430,7 +2429,7 @@ dependencies = [
"codex-tui",
"codex-utils-cli",
"crossterm",
"hanzo-version",
"hanzo",
"http 1.4.0",
"owo-colors",
"ratatui",
@@ -2686,8 +2685,7 @@ dependencies = [
"dunce",
"eventsource-stream",
"futures",
"hanzo-loop",
"hanzo-version",
"hanzo",
"http 1.4.0",
"hyper-util",
"iana-time-zone",
@@ -2815,7 +2813,7 @@ dependencies = [
"codex-utils-path-uri",
"codex-utils-sandbox-summary",
"codex-worktree",
"hanzo-version",
"hanzo",
"owo-colors",
"serde",
"serde_json",
@@ -2864,7 +2862,7 @@ dependencies = [
"codex-websocket-client",
"dirs",
"futures",
"hanzo-version",
"hanzo",
"http 1.4.0",
"libc",
"opentelemetry",
@@ -3012,7 +3010,7 @@ dependencies = [
"codex-rollout",
"codex-state",
"flate2",
"hanzo-version",
"hanzo",
"http 1.4.0",
"httpdate",
"mime_guess",
@@ -3032,7 +3030,7 @@ dependencies = [
"anyhow",
"clap",
"crossbeam-channel",
"hanzo-version",
"hanzo",
"ignore 0.4.25 (registry+https://github.com/rust-lang/crates.io-index)",
"nucleo",
"serde",
@@ -3143,7 +3141,7 @@ dependencies = [
"codex-otel",
"codex-prompts",
"codex-protocol",
"hanzo-version",
"hanzo",
"rand 0.9.3",
"serde",
"serde_json",
@@ -3377,7 +3375,7 @@ dependencies = [
"codex-utils-process",
"codex-utils-template",
"codex-workload-identity",
"hanzo-version",
"hanzo",
"http 1.4.0",
"once_cell",
"os_info",
@@ -3417,7 +3415,7 @@ dependencies = [
"codex-utils-plugins",
"codex-utils-process",
"futures",
"hanzo-version",
"hanzo",
"lru",
"regex-lite",
"rmcp",
@@ -3553,7 +3551,7 @@ dependencies = [
"codex-response-debug-context",
"codex-secrets",
"codex-utils-process",
"hanzo-config",
"hanzo",
"http 1.4.0",
"serde",
"serde_json",
@@ -3570,7 +3568,7 @@ dependencies = [
"codex-client",
"codex-protocol",
"codex-utils-redacted-string",
"hanzo-version",
"hanzo",
"http 1.4.0",
"schemars 0.8.22",
"serde",
@@ -3588,8 +3586,7 @@ dependencies = [
"codex-prompts",
"codex-protocol",
"codex-utils-output-truncation",
"hanzo-config",
"hanzo-version",
"hanzo",
"serde",
"serde_json",
"tokio",
@@ -3694,7 +3691,7 @@ dependencies = [
"codex-utils-string",
"eventsource-stream",
"gethostname",
"hanzo-version",
"hanzo",
"http 1.4.0",
"opentelemetry",
"opentelemetry-appender-tracing",
@@ -3872,7 +3869,7 @@ dependencies = [
"codex-utils-path-uri",
"codex-utils-pty",
"futures",
"hanzo-version",
"hanzo",
"http 1.4.0",
"keyring",
"libc",
@@ -3914,7 +3911,7 @@ dependencies = [
"codex-utils-path",
"codex-utils-process",
"codex-utils-string",
"hanzo-version",
"hanzo",
"memchr",
"regex",
"serde",
@@ -4040,7 +4037,7 @@ dependencies = [
"codex-utils-string",
"dirs",
"futures",
"hanzo-version",
"hanzo",
"schemars 0.8.22",
"serde",
"serde_json",
@@ -4125,7 +4122,7 @@ dependencies = [
"codex-utils-path",
"codex-utils-path-uri",
"futures",
"hanzo-version",
"hanzo",
"pulldown-cmark",
"serde",
"serde_json",
@@ -4227,8 +4224,7 @@ dependencies = [
"dirs",
"dunce",
"futures",
"hanzo-tui",
"hanzo-version",
"hanzo",
"image",
"itertools",
"lazy_static",
@@ -4595,7 +4591,7 @@ dependencies = [
"dirs-next",
"dunce",
"glob",
"hanzo-version",
"hanzo",
"rand 0.8.6",
"serde",
"serde_json",
@@ -7339,10 +7335,18 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "hanzo"
version = "0.0.0"
dependencies = [
"serde_json",
]
[[package]]
name = "hanzo-config"
version = "0.0.0"
dependencies = [
"hanzo",
"serde",
"tempfile",
"toml 0.9.11+spec-1.1.0",
@@ -7409,10 +7413,10 @@ dependencies = [
"codex-windows-sandbox",
"crossterm",
"dialoguer",
"hanzo",
"hanzo-config",
"hanzo-kai",
"hanzo-tui",
"hanzo-version",
"http 1.4.0",
"insta",
"libc",
@@ -7448,8 +7452,8 @@ dependencies = [
"codex-extension-api",
"codex-protocol",
"futures",
"hanzo",
"hanzo-config",
"hanzo-loop",
"hanzo-trigram",
"indexmap 2.14.0",
"pretty_assertions",
@@ -7464,14 +7468,6 @@ dependencies = [
"wiremock",
]
[[package]]
name = "hanzo-loop"
version = "0.0.0"
dependencies = [
"serde_json",
"tokio",
]
[[package]]
name = "hanzo-trigram"
version = "0.1.0"
@@ -7488,13 +7484,6 @@ dependencies = [
[[package]]
name = "hanzo-tui"
version = "0.0.0"
dependencies = [
"hanzo-config",
]
[[package]]
name = "hanzo-version"
version = "0.0.0"
[[package]]
name = "hash32"
+5 -7
View File
@@ -4,16 +4,15 @@ members = [
"crates/hanzo-config",
"crates/hanzo-tui",
"crates/hanzo-kai",
"crates/hanzo-loop",
]
exclude = ["upstream"]
resolver = "2"
[workspace.package]
# The release's version, set by hand with npm/package.json's and the tag. Only
# hanzo-dev takes it: the libraries stay 0.0.0, because codex-tui, codex-core,
# app-server and models-manager depend on them, and a version that moved there
# would recompile the whole graph above them for every release.
# hanzo-dev takes it, and every crate beneath reads it at run time (the fork's
# hanzo::version), so a release recompiles hanzo-dev alone; Dev's libraries stay
# 0.0.0 for the same reason.
version = "0.7.0"
# Track the edition for all workspace crates in one place. Individual
# crates can still override this value, but keeping it here means new
@@ -173,12 +172,11 @@ codex-workload-identity = { path = "upstream/codex/codex-rs/workload-identity" }
codex-windows-sandbox = { path = "upstream/codex/codex-rs/windows-sandbox-rs" }
codex-windows-sandbox-test-support = { path = "upstream/codex/codex-rs/windows-sandbox-rs/tests/support" }
core_test_support = { path = "upstream/codex/codex-rs/core/tests/common" }
hanzo-version = { path = "upstream/codex/codex-rs/hanzo-version" }
hanzo = { path = "upstream/codex/codex-rs/hanzo" }
# Hanzo Dev's own crates, which the fork's workspace names too.
# Hanzo Dev's own crates. They depend on the fork, never the reverse.
hanzo-config = { path = "crates/hanzo-config" }
hanzo-kai = { path = "crates/hanzo-kai" }
hanzo-loop = { path = "crates/hanzo-loop" }
hanzo-tui = { path = "crates/hanzo-tui" }
# External
+26 -16
View File
@@ -9,8 +9,8 @@ Hanzo owns the front door; upstream owns the engines.
- `crates/hanzo-dev` is the binary. Its `main.rs` is the entire command
surface, and every subcommand hands off to a library. Nothing is generated
and no upstream source is compiled through an `include!`.
- `crates/hanzo-config` picks the provider and the product home.
`crates/hanzo-tui` holds the strings and colors that make the interface ours.
- `crates/hanzo-config` picks the provider and the product home. `crates/hanzo-tui`
is the Hanzo account behind /login, /logout, /auth and /status. `crates/hanzo-kai` is Kai.
- `upstream/codex` is hanzoai/codex, the engines with Hanzo's commits on them.
## The loop the cloud drives
@@ -39,22 +39,32 @@ A bump is a merge, and its conflicts are git's:
`make bump` prints those steps. The root `Cargo.toml`'s `[workspace.dependencies]` and
`[patch]` tables mirror the fork's `codex-rs/Cargo.toml` (paths rewritten from the root),
because patches apply only from the root workspace; refresh them on a bump. Measured on
because patches apply only from the root workspace; refresh them on a bump. The fork
builds and tests on its own (`cargo build` in `codex-rs`, `make test-upstream` here) and
keeps its own `codex-rs/Cargo.lock`, as upstream does: it names no path outside its tree. Measured on
the move from 11da6b9e to 3ee47c105 (288 upstream commits, 1,608 files), with the whole
delta committed on the old base: the merge stopped on 5 files (one doc comment the
delta rewords, two test fixtures and two strings upstream rewrote), and with upstream's
side taken there, 16 files differed from what the old build-time rules made of the new
tree: the strings and version reads upstream added, which step 2 finds.
The delta, as first committed on 3ee47c105:
The delta on 3ee47c105 (`git log 3ee47c105..main` in `upstream/codex`):
- `hanzo:` the fork's workspace names Dev's crates (hanzo-config, hanzo-kai, hanzo-loop,
hanzo-tui) by the checkout's relative path, `../../../crates/`. The fork builds as
this repository's submodule and not alone.
- `codex-rs/hanzo`, one crate: Dev's names and the TUI's defaults, `API_BASE`,
`DEFAULT_MODEL`, `is_hanzo`, the model catalog, `model::pick` (the `/model <name>`
match), the controller interface the loop consults (`controller::{Controller, Handle,
Verdict, Card, Request, Shortlist}`) and `version`. Upstream crates read these; Dev's
crates depend on this one, never the reverse.
- Two hooks, each a no-op until a binary fills it, so with nothing registered the engines
behave as upstream's: `codex_app_server::register(Extension)` adds an extension to every
thread (process-global: the TUI and exec start the app server in process), and
`codex_tui::register(Account { login, logout, show })` puts an account behind /login,
/auth, /logout and /status (without one, /login and /auth are not offered). hanzo-dev's
`main` registers `hanzo_kai::install` and `hanzo_tui::account` before anything starts.
- `tui:`, `cli:`, `config:`, `core:`, `words:` the edits that were anchored strings:
Dev's words and colors, `/login` `/logout` `/model <name>`, the published `mcp_cmd`,
`plugin_cmd` and `doctor` (they belong upstream as a pull request), `.hanzo` homes,
Kai and the controller in the loop, Hanzo's catalog and reviewers.
Dev's words and colors, `/model <name>`, the published `mcp_cmd`, `plugin_cmd` and
`doctor` (they belong upstream as a pull request), `.hanzo` homes, the controller's
calls in the loop, Hanzo's catalog and reviewers.
- `brand:` Dev's name in every user-facing string literal of non-test code.
- `version:` the version, below.
@@ -62,15 +72,15 @@ Dev reads only Hanzo's directories. Its home is `~/.hanzo/dev` (or `DEV_HOME`),
settings live in `<repo>/.hanzo/dev/`, and system ones in `/etc/hanzo/dev/`; a `.codex` beside
them belongs to another product and is not read. Its server runs inside the process: upstream's
shared background server is installed from a package directory Dev does not ship
(`hanzo_tui::BACKGROUND_SERVER`). A profile that names no provider is set to Hanzo at startup.
(`hanzo::BACKGROUND_SERVER`). A profile that names no provider is set to Hanzo at startup.
`crates/hanzo-dev/tests/launch.rs` launches a bare `dev` in a terminal and holds all three.
The version is the release's, set by hand in the root `Cargo.toml`'s `[workspace.package]`,
in `npm/package.json` and in the tag, and `release.yml` refuses a tag the two manifests do
not both say. hanzo-dev alone takes it: its `main` hands its own `CARGO_PKG_VERSION` to
`hanzo_version::set`, and every upstream crate asks `hanzo_version::get()` at run time
(`codex-rs/hanzo-version` in the fork). Dev's libraries stay `0.0.0`, since upstream crates
depend on them. So a release recompiles hanzo-dev and the link, nothing beneath.
`hanzo::version::set`, and every upstream crate asks `hanzo::version::get()` at run time.
Dev's libraries stay `0.0.0`. So a release recompiles hanzo-dev and the link, nothing
beneath.
`crates/hanzo-dev/tests/cli.rs` and `launch.rs` hold `--version`, the doctor and the TUI
header to the version the binary was built as, and fail on any upstream read of its own.
@@ -129,7 +139,7 @@ and save steps and is never exported, so no build script sees it.
Automatic approval review and memory extraction and consolidation name a model
of their own. Upstream's ids are OpenAI's; api.hanzo.ai serves none of them. On
the Hanzo provider (`hanzo_config::is_hanzo`, base URL `API_BASE`) all three are
the Hanzo provider (`hanzo::is_hanzo`, base URL `hanzo::API_BASE`) all three are
`DEFAULT_MODEL`. The reviewer takes its preferred id only when the catalog serves
it, else the parent's model, and off OpenAI only a listed entry is served: the
bundled OpenAI entries ride along unlisted in every catalog, for lookups. So a LAN
@@ -221,7 +231,7 @@ and the tools they run. Search, saved actions and the shell classifier are hanzo
## Runtime & Model Fixes (2026-10-07)
- **Model metadata**: a model hanzo-config's catalog knows (`enso-auto` among them) gets its
- **Model metadata**: a model the fork's catalog (`hanzo::catalog`) knows (`enso-auto` among them) gets its
window and modalities from the catalog, so it raises no "Unknown model ... fallback
metadata" warning (hanzoai/codex `core:` commit, models-manager).
- **Sandbox permissions**: a justification with no escalation asked for runs at the default
+4 -4
View File
@@ -27,10 +27,10 @@ test:
## test-upstream: run hanzoai/codex's own suite against the pinned checkout
# voice-host wants GStreamer >= 1.28, which no current distribution ships, and
# nothing else in the workspace depends on it. The fork's workspace names Dev's
# crates, so cargo resolves them into codex-rs/Cargo.lock as it runs.
# nothing else in the workspace depends on it. The fork resolves on its own, so
# its lock holds.
test-upstream: v8
@. target/v8-env.sh && $(CARGO) nextest run --no-fail-fast \
@. target/v8-env.sh && $(CARGO) nextest run --locked --no-fail-fast \
--manifest-path upstream/codex/codex-rs/Cargo.toml \
--workspace --exclude codex-voice-host $(ARGS)
@@ -48,7 +48,7 @@ v8:
## fmt: format the crates Hanzo owns
fmt:
@$(CARGO) fmt -p hanzo-dev -p hanzo-config -p hanzo-tui -p hanzo-kai -p hanzo-loop
@$(CARGO) fmt -p hanzo-dev -p hanzo-config -p hanzo-tui -p hanzo-kai
## clean: discard build output
clean:
+3 -2
View File
@@ -1,7 +1,7 @@
[package]
name = "hanzo-config"
# Never the release's version: upstream crates depend on this one, and a version
# that moved would recompile everything above them. Not published.
# Never the release's version: only hanzo-dev carries it, so a release recompiles
# that crate alone. Not published.
version = "0.0.0"
publish = false
edition = "2021"
@@ -10,6 +10,7 @@ description = "Hanzo Dev config integration"
repository = "https://github.com/hanzoai/dev"
[dependencies]
hanzo = { workspace = true }
serde = { workspace = true, features = ["derive"] }
tempfile = { workspace = true }
toml = { workspace = true }
-83
View File
@@ -1,83 +0,0 @@
//! The chat models api.hanzo.ai serves, as the agent needs to know them.
//!
//! The agent sizes its context, compaction and image input from a model's
//! metadata, and a slug it cannot find runs on a 272K fallback with a warning.
//! These are the chat slugs `GET https://api.hanzo.ai/v1/models` lists; the
//! embedding, rerank, guard and voice models are not chat models and are absent.
/// What the agent needs to know about one served chat model.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Model {
pub slug: &'static str,
pub name: &'static str,
/// Context window in tokens.
pub window: i64,
/// Accepts images as well as text.
pub vision: bool,
}
const MILLION: i64 = 1_000_000;
const fn model(slug: &'static str, name: &'static str, vision: bool) -> Model {
Model {
slug,
name,
window: MILLION,
vision,
}
}
pub const MODELS: &[Model] = &[
model("enso", "Enso", true),
model("enso-auto", "Enso Auto", true),
model("enso-flash", "Enso Flash", true),
model("enso-free", "Enso Free", true),
model("enso-pro", "Enso Pro", false),
model("enso-ultra", "Enso Ultra", true),
model("hanzo/enso", "Enso", true),
model("hanzo/zen", "Zen", true),
model("zen-free", "Zen Free", true),
model("zen-vl", "Zen VL", true),
model("zen5", "Zen 5", true),
model("zen5-coder", "Zen 5 Coder", false),
model("zen5-evo", "Zen 5 Evo", true),
model("zen5-flash", "Zen 5 Flash", true),
model("zen5-mini", "Zen 5 Mini", false),
model("zen5-pro", "Zen 5 Pro", false),
model("zen5-spark", "Zen 5 Spark", true),
model("zen6", "Zen 6", true),
model("zen6-flash", "Zen 6 Flash", true),
];
/// The served chat model named `slug`, if there is one.
pub fn find(slug: &str) -> Option<&'static Model> {
MODELS.iter().find(|model| model.slug == slug)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_default_model_is_known() {
let model = find(crate::DEFAULT_MODEL).expect("the default model is in the catalog");
assert_eq!(model.window, 1_000_000);
}
#[test]
fn every_slug_is_listed_once() {
for (index, model) in MODELS.iter().enumerate() {
assert!(
!MODELS[..index].iter().any(|other| other.slug == model.slug),
"{} is listed twice",
model.slug
);
}
}
#[test]
fn an_unserved_slug_is_unknown() {
assert_eq!(find("zen-embedding"), None);
assert_eq!(find("not-a-model"), None);
}
}
+4 -9
View File
@@ -7,7 +7,7 @@ fn new_home_loads_hanzo_provider_and_native_mcp() {
let config: toml::Value = toml::from_str(&std::fs::read_to_string(directory.path().join("config.toml"))
.unwrap()).unwrap();
assert_eq!(config["model_provider"].as_str(), Some("hanzo"));
assert_eq!(config["model_providers"]["hanzo"]["base_url"].as_str(), Some(API_BASE));
assert_eq!(config["model_providers"]["hanzo"]["base_url"].as_str(), Some(hanzo::API_BASE));
assert_eq!(config["mcp_servers"]["hanzo"]["command"].as_str(), Some("hanzo-mcp"));
assert!(config["mcp_servers"]["hanzo"]["args"].as_array().unwrap().is_empty());
}
@@ -122,14 +122,9 @@ fn a_sign_in_writes_the_shared_credential() {
assert!(std::fs::symlink_metadata(home.join("auth.json")).unwrap().is_symlink());
}
/// The default profile's provider is the one that serves the Hanzo catalog; a LAN
/// engine or any other host is not, however it is named.
/// The default profile's provider is the one that serves the Hanzo catalog.
#[test]
fn only_the_hanzo_api_is_hanzo() {
fn the_default_provider_is_hanzo() {
let config: toml::Value = toml::from_str(DEFAULT_CONFIG).unwrap();
assert!(is_hanzo(config["model_providers"]["hanzo"]["base_url"].as_str()));
assert!(is_hanzo(Some("https://api.hanzo.ai/v1/")));
assert!(!is_hanzo(Some("http://dgx.local:1235/v1")));
assert!(!is_hanzo(Some("https://api.hanzo.ai.example/v1")));
assert!(!is_hanzo(None));
assert!(hanzo::is_hanzo(config["model_providers"]["hanzo"]["base_url"].as_str()));
}
+3 -3
View File
@@ -234,13 +234,13 @@ impl Kai {
let generate_url = file
.generate
.url
.unwrap_or_else(|| crate::API_BASE.to_string())
.unwrap_or_else(|| hanzo::API_BASE.to_string())
.trim_end_matches('/')
.to_string();
let escalate = match file.generate.escalate {
Some(name) if name.trim().is_empty() => None,
Some(name) => Some(name),
None => crate::is_hanzo(Some(&generate_url)).then(|| ESCALATE.to_string()),
None => hanzo::is_hanzo(Some(&generate_url)).then(|| ESCALATE.to_string()),
};
if let Some(name) = &escalate {
let lower = name.to_lowercase();
@@ -253,7 +253,7 @@ impl Kai {
Ok(Kai {
url: file
.url
.unwrap_or_else(|| crate::API_BASE.to_string())
.unwrap_or_else(|| hanzo::API_BASE.to_string())
.trim_end_matches('/')
.to_string(),
model: file.model.unwrap_or_else(|| "laya-agent".to_string()),
-9
View File
@@ -4,7 +4,6 @@ use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
pub mod catalog;
pub mod kai;
mod provider;
pub use provider::activate_provider;
@@ -21,14 +20,6 @@ pub fn home() -> PathBuf {
.expect("initialize runs before any command")
}
pub const API_BASE: &str = "https://api.hanzo.ai/v1";
pub const DEFAULT_MODEL: &str = "enso-auto";
/// Whether a provider's base URL is [`API_BASE`], the API that serves [`catalog::MODELS`].
pub fn is_hanzo(base_url: Option<&str>) -> bool {
base_url.is_some_and(|url| url.trim_end_matches('/') == API_BASE)
}
/// Called once by the synchronous entry point, before the async runtime starts.
pub fn initialize() -> std::io::Result<()> {
let home = product_home(
+3 -3
View File
@@ -26,12 +26,12 @@ pub fn activate_provider(home: &Path, provider: Provider) -> io::Result<()> {
config["model_providers"]["hanzo"] = defaults["model_providers"]["hanzo"].clone();
}
config["model_provider"] = toml_edit::value("hanzo");
config["model"] = toml_edit::value(super::DEFAULT_MODEL);
config["model"] = toml_edit::value(hanzo::DEFAULT_MODEL);
}
Provider::OpenAi => {
config["model_provider"] = toml_edit::value("openai");
// Allow the authenticated model catalog to choose its current default.
if config.get("model").and_then(toml_edit::Item::as_str) == Some(super::DEFAULT_MODEL) {
if config.get("model").and_then(toml_edit::Item::as_str) == Some(hanzo::DEFAULT_MODEL) {
config.remove("model");
}
}
@@ -228,7 +228,7 @@ pub(super) fn upgrade_auth(home: &Path) {
provider.get("env_key").and_then(toml_edit::Item::as_str),
Some("HANZO_USER_KEY" | "HANZO_API_KEY")
)
|| provider.get("base_url").and_then(toml_edit::Item::as_str) != Some(super::API_BASE)
|| provider.get("base_url").and_then(toml_edit::Item::as_str) != Some(hanzo::API_BASE)
{
return;
}
+1 -1
View File
@@ -41,7 +41,7 @@ fn a_profile_naming_no_provider_runs_on_hanzo_and_keeps_its_own_providers() {
let config: toml::Value = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let defaults: toml::Value = toml::from_str(crate::DEFAULT_CONFIG).unwrap();
assert_eq!(config["model_provider"].as_str(), Some("hanzo"));
assert_eq!(config["model"].as_str(), Some(crate::DEFAULT_MODEL));
assert_eq!(config["model"].as_str(), Some(hanzo::DEFAULT_MODEL));
assert_eq!(config["model_providers"]["hanzo"], defaults["model_providers"]["hanzo"]);
assert_eq!(config["model_providers"]["pool"]["base_url"].as_str(), Some("http://pool/v1"));
}
+1 -1
View File
@@ -18,7 +18,7 @@ workspace = true
hanzo-config = { workspace = true }
hanzo-kai = { workspace = true }
hanzo-tui = { workspace = true }
hanzo-version = { workspace = true }
hanzo = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true, features = ["derive", "env"] }
+10 -2
View File
@@ -209,8 +209,16 @@ struct Pick {
fn main() -> Result<()> {
// The release's version is this crate's alone; every crate beneath reads it
// from hanzo-version at run time, so a new release recompiles only this one.
hanzo_version::set(env!("CARGO_PKG_VERSION"));
// from the hanzo crate at run time, so a new release recompiles only this one.
hanzo::version::set(env!("CARGO_PKG_VERSION"));
// What Dev adds to upstream's engines, before any of them starts: Kai on every
// thread, and the Hanzo account behind /login, /logout, /auth and /status.
codex_app_server::register(hanzo_kai::install);
codex_tui::register(codex_tui::Account {
login: hanzo_tui::account::login,
logout: hanzo_tui::account::logout,
show: hanzo_tui::account::show,
});
codex_build_info::initialize!();
// Resolve the Hanzo home and provider before any thread or runtime starts.
hanzo_config::initialize()?;
+2 -2
View File
@@ -110,7 +110,7 @@ fn the_product_states_one_version() {
assert!(doctor.contains(&number), "doctor reports a different version: {doctor:.120}");
}
/// Upstream code asks hanzo-version for the version this binary set. A merge
/// Upstream code asks the hanzo crate for the version this binary set. A merge
/// from upstream that brings its own compile-time read back would state the
/// fork's 0.0.0 wherever that read lands; a build script keeps its own.
#[test]
@@ -123,7 +123,7 @@ fn upstream_reads_the_version_the_binary_sets() {
let path = entry.expect("a directory entry").path();
let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
if path.is_dir() {
if name != "target" && name != "hanzo-version" {
if name != "target" && path != source.join("hanzo") {
pending.push(path);
}
} else if name.ends_with(".rs")
+20 -2
View File
@@ -4,7 +4,8 @@
//! that Dev does not ship, so a launch has to run its server inside the process or the
//! first command ends in an error. And a project keeps Dev's settings in `.hanzo/dev`, the
//! layout of `~/.hanzo/dev`: another product's `.codex` beside it is that product's, and
//! its model must not become ours.
//! its model must not become ours. And the binary registers the Hanzo account with the
//! TUI, which offers /login only then.
#![cfg(unix)]
@@ -12,6 +13,7 @@ use portable_pty::CommandBuilder;
use portable_pty::PtySize;
use portable_pty::native_pty_system;
use std::io::Read;
use std::io::Write;
use std::sync::mpsc;
use std::time::Duration;
use std::time::Instant;
@@ -76,17 +78,33 @@ fn a_bare_launch_starts_in_process_and_reads_only_the_projects_hanzo_config() {
}
}
}
let mut offered = String::new();
if shown.contains(COMPOSER) {
let mut writer = pair.master.take_writer().expect("a writer");
writer.write_all(b"/log").expect("typed");
let deadline = Instant::now() + Duration::from_secs(20);
while Instant::now() < deadline && !offered.contains("sign in to Hanzo") {
if let Ok(bytes) = received.recv_timeout(Duration::from_millis(200)) {
screen.process(&bytes);
offered = screen.screen().contents();
}
}
}
let _ = child.kill();
assert!(!shown.contains(PACKAGE_ERROR), "a bare launch asked for a package:\n{shown}");
assert!(shown.contains(COMPOSER), "a bare launch never reached the composer:\n{shown}");
assert!(shown.contains("project-model"), "the project's own config was not read:\n{shown}");
// The header names the version this binary was built as, which upstream's
// TUI reads from hanzo-version at run time.
// TUI reads from the hanzo crate at run time.
let built = env!("CARGO_PKG_VERSION");
assert!(shown.contains(&format!("Hanzo Dev (v{built})")), "the header does not say v{built}:\n{shown}");
assert!(
!shown.to_ascii_lowercase().contains("gpt-6"),
"another product's project config set the model:\n{shown}"
);
assert!(
offered.contains("/login") && offered.contains("sign in to Hanzo"),
"no account was registered with the TUI, so /login is not offered:\n{offered}"
);
}
+4 -4
View File
@@ -44,7 +44,7 @@ fn hanzo_names_a_model_it_serves_for_review_and_memory() {
hanzo.memory_extraction_preferred_model(),
hanzo.memory_consolidation_preferred_model(),
] {
assert_eq!(model, hanzo_config::DEFAULT_MODEL);
assert_eq!(model, hanzo::DEFAULT_MODEL);
let listed = codex_models_manager::bundled_models_response()
.expect("the bundled catalog parses")
.models
@@ -69,15 +69,15 @@ fn only_hanzo_takes_the_hanzo_choice() {
for other in [openai, lan] {
assert_ne!(
other.approval_review_preferred_model(),
hanzo_config::DEFAULT_MODEL
hanzo::DEFAULT_MODEL
);
assert_ne!(
other.memory_extraction_preferred_model(),
hanzo_config::DEFAULT_MODEL
hanzo::DEFAULT_MODEL
);
assert_ne!(
other.memory_consolidation_preferred_model(),
hanzo_config::DEFAULT_MODEL
hanzo::DEFAULT_MODEL
);
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
[package]
name = "hanzo-kai"
# Never the release's version: upstream crates depend on this one, and a version
# that moved would recompile everything above them. Not published.
# Never the release's version: only hanzo-dev carries it, so a release recompiles
# that crate alone. Not published.
version = "0.0.0"
publish = false
edition.workspace = true
@@ -13,7 +13,7 @@ workspace = true
[dependencies]
hanzo-config = { workspace = true }
hanzo-loop = { workspace = true }
hanzo = { workspace = true }
# Hanzo's one search engine (tgrep), the library hanzo-mcp's search actions run on.
hanzo-trigram = { git = "https://github.com/hanzoai/mcp", rev = "e9e6096ce46c3d3bf61e6830de6487a5b5b5b849" }
chrono = { workspace = true }
+6 -6
View File
@@ -27,11 +27,11 @@ use crate::trace::Step;
use hanzo_config::kai::Mode;
use hanzo_config::kai::Op;
use hanzo_config::kai::Operation;
use hanzo_loop::Answer;
use hanzo_loop::Card;
use hanzo_loop::Controller;
use hanzo_loop::Request;
use hanzo_loop::Verdict;
use hanzo::controller::Answer;
use hanzo::controller::Card;
use hanzo::controller::Controller;
use hanzo::controller::Request;
use hanzo::controller::Verdict;
use serde_json::Value;
use serde_json::json;
use std::collections::BTreeMap;
@@ -1095,7 +1095,7 @@ pub mod extension {
let thread = Arc::new(Thread::new(kai, sku, act));
input
.thread_store
.insert(hanzo_loop::Handle(Arc::new(Shared(Arc::clone(&thread)))));
.insert(hanzo::controller::Handle(Arc::new(Shared(Arc::clone(&thread)))));
input.thread_store.insert(Shared(thread));
})
}
+1 -1
View File
@@ -13,7 +13,7 @@ use crate::program::Program;
use crate::program::Signal;
use crate::program::digest;
use hanzo_config::kai::Mode;
use hanzo_loop::Verdict;
use hanzo::controller::Verdict;
use indexmap::IndexMap;
use serde_json::Value;
use serde_json::json;
+1 -1
View File
@@ -379,7 +379,7 @@ impl Setup {
/// Zen asks for none. Call from within the async runtime.
pub fn new(home: &Path, key: impl FnOnce() -> Option<String>) -> Result<Setup, String> {
let settings = hanzo_config::kai::Kai::load_or_default(home).map_err(|e| e.to_string())?;
let key = hanzo_config::is_hanzo(Some(&settings.generate.url)).then(key).flatten();
let key = hanzo::is_hanzo(Some(&settings.generate.url)).then(key).flatten();
let mut turn = Program::load("fix.turn@1")?;
turn.configure([])?;
Ok(Setup {
+1 -1
View File
@@ -9,7 +9,7 @@
//! an accepted choice labelled allow/ask/deny and raises it by every escalation whose noul
//! holds. The programs Dev asks ship in `programs/`; a path names any other.
use hanzo_loop::Verdict;
use hanzo::controller::Verdict;
use indexmap::IndexMap;
use serde::Deserialize;
use serde::Serialize;
+1 -1
View File
@@ -6,7 +6,7 @@ use crate::agent::Act;
use crate::program::Kind;
use crate::program::Question;
use crate::program::argmax;
use hanzo_loop::Answer;
use hanzo::controller::Answer;
use indexmap::IndexMap;
use serde_json::Map;
use serde_json::Value;
+1 -1
View File
@@ -152,7 +152,7 @@ impl Zen {
/// Zen as `generate` names it, signed with `key` when its URL is Hanzo's. Call from within
/// the async runtime; generation then blocks a thread off it.
pub fn new(generate: &Generate, key: Option<String>) -> Zen {
let cloud = hanzo_config::is_hanzo(Some(&generate.url));
let cloud = hanzo::is_hanzo(Some(&generate.url));
Zen {
url: generate.url.trim_end_matches('/').to_string(),
model: generate.model.clone(),
-20
View File
@@ -1,20 +0,0 @@
[package]
name = "hanzo-loop"
# Never the release's version: upstream crates depend on this one, and a version
# that moved would recompile everything above them. Not published.
version = "0.0.0"
publish = false
edition.workspace = true
license.workspace = true
description = "What a controller decides inside the agent loop, as data the loop applies."
[lints]
workspace = true
# The agent loop depends on this crate, so it stays small and changes rarely: the loop is
# rebuilt whenever it does.
[dependencies]
serde_json = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt"] }
-192
View File
@@ -1,192 +0,0 @@
//! What a controller decides inside the agent loop, as data the loop applies.
//!
//! The loop asks at three points and applies the answer: which tools the model sees directly
//! this step, whether a command may run, and which routing hints ride the turn's requests. A
//! controller installed on the thread answers. Nothing it says can loosen what the loop already
//! decided: a verdict joins the policy's on `allow < ask < deny`, a shortlist only moves tools
//! out of direct view, and a hint is metadata the gateway may read.
//!
//! The loop finds the controller as a [`Handle`] in the thread's extension data. Its only
//! readers are the calls hanzoai/codex's `core:` commit adds to codex-core.
use std::collections::BTreeMap;
use std::collections::HashSet;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
/// A controller's answer, computed asynchronously.
pub type Answer<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
/// Whether an action may proceed. The order is the lattice: `Allow < Ask < Deny`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Verdict {
Allow,
Ask,
Deny,
}
impl Verdict {
/// The stricter of the two.
pub fn join(self, other: Verdict) -> Verdict {
self.max(other)
}
pub fn name(self) -> &'static str {
match self {
Verdict::Allow => "allow",
Verdict::Ask => "ask",
Verdict::Deny => "deny",
}
}
}
/// A tool on offer: its model-visible name and what it says it does.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Card {
pub name: String,
pub description: String,
}
/// A turn's request as the loop received it.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Request {
/// The user's text.
pub text: String,
/// Whether it carries images.
pub images: bool,
}
pub trait Controller: Send + Sync {
/// Of `cards`, the tools to keep in direct view for a step of `turn`; `None` keeps all.
fn tools<'a>(&'a self, turn: &'a str, cards: Vec<Card>) -> Answer<'a, Option<HashSet<String>>>;
/// The verdict for the command `call` in `turn` would run, which the policy rates
/// `policy`. `action` is the command as the approval path describes it.
fn command<'a>(
&'a self,
turn: &'a str,
call: &'a str,
policy: Verdict,
action: serde_json::Value,
) -> Answer<'a, Verdict>;
/// Routing hints for `turn`'s model requests.
fn route<'a>(&'a self, turn: &'a str, request: Request)
-> Answer<'a, BTreeMap<String, String>>;
/// Compact transcript history, pruning stale tool outputs while preserving error diagnostics verbatim.
fn compact<'a>(
&'a self,
_turn: &'a str,
_messages: Vec<serde_json::Value>,
) -> Answer<'a, Option<Vec<serde_json::Value>>> {
Box::pin(async { None })
}
/// Decides or verifies a typed Action for `turn`, returning Some(action) to execute directly,
/// or None to fall back to unconstrained generative model planning.
fn action<'a>(
&'a self,
_turn: &'a str,
_state: serde_json::Value,
) -> Answer<'a, Option<serde_json::Value>> {
Box::pin(async { None })
}
}
/// The controller installed on a thread.
#[derive(Clone)]
pub struct Handle(pub Arc<dyn Controller>);
impl Handle {
/// Decides a typed Action: fast-path or Kai selected action.
pub async fn action(&self, turn: &str, state: serde_json::Value) -> Option<serde_json::Value> {
self.0.action(turn, state).await
}
/// The command's verdict: the policy's joined with the controller's, never looser.
pub async fn command(
&self,
turn: &str,
call: &str,
policy: Verdict,
action: serde_json::Value,
) -> Verdict {
self.0
.command(turn, call, policy, action)
.await
.join(policy)
}
pub async fn tools(&self, turn: &str, cards: Vec<Card>) -> Option<Shortlist> {
self.0.tools(turn, cards).await.map(Shortlist)
}
pub async fn route(&self, turn: &str, request: Request) -> BTreeMap<String, String> {
self.0.route(turn, request).await
}
pub async fn compact(&self, turn: &str, messages: Vec<serde_json::Value>) -> Option<Vec<serde_json::Value>> {
self.0.compact(turn, messages).await
}
}
/// A step's shortlist: the tools that stay in direct view.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Shortlist(pub HashSet<String>);
impl Shortlist {
pub fn keeps(&self, tool: &str) -> bool {
self.0.contains(tool)
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Loose;
impl Controller for Loose {
fn tools<'a>(&'a self, _: &'a str, _: Vec<Card>) -> Answer<'a, Option<HashSet<String>>> {
Box::pin(async { None })
}
fn command<'a>(
&'a self,
_: &'a str,
_: &'a str,
_: Verdict,
_: serde_json::Value,
) -> Answer<'a, Verdict> {
Box::pin(async { Verdict::Allow })
}
fn route<'a>(&'a self, _: &'a str, _: Request) -> Answer<'a, BTreeMap<String, String>> {
Box::pin(async { BTreeMap::new() })
}
}
#[test]
fn the_lattice_orders_allow_ask_deny() {
assert_eq!(Verdict::Allow.join(Verdict::Ask), Verdict::Ask);
assert_eq!(Verdict::Deny.join(Verdict::Ask), Verdict::Deny);
assert_eq!(Verdict::Allow.join(Verdict::Allow), Verdict::Allow);
}
#[tokio::test]
async fn a_controller_that_allows_cannot_loosen_the_policy() {
let handle = Handle(Arc::new(Loose));
for policy in [Verdict::Allow, Verdict::Ask, Verdict::Deny] {
let verdict = handle
.command("t", "c", policy, serde_json::Value::Null)
.await;
assert_eq!(verdict, policy);
}
}
#[tokio::test]
async fn a_controller_default_compact_returns_none() {
let handle = Handle(Arc::new(Loose));
let res = handle.compact("t", vec![]).await;
assert_eq!(res, None);
}
}
+3 -6
View File
@@ -1,13 +1,10 @@
[package]
name = "hanzo-tui"
# Never the release's version: upstream crates depend on this one, and a version
# that moved would recompile everything above them. Not published.
# Never the release's version: only hanzo-dev carries it, so a release recompiles
# that crate alone. Not published.
version = "0.0.0"
publish = false
edition = "2021"
license = "Apache-2.0"
description = "Hanzo Dev tui integration"
description = "The Hanzo account behind the interface's /login, /logout, /auth and /status"
repository = "https://github.com/hanzoai/dev"
[dependencies]
hanzo-config = { workspace = true }
+6 -6
View File
@@ -17,28 +17,28 @@ const MISSING: &str =
const PATIENCE: Duration = Duration::from_secs(15);
/// Who is signed in, as `hanzo auth show` reports it.
pub fn show(mut emit: impl FnMut(String)) {
pub fn show(emit: &mut dyn FnMut(String)) {
let text = capture(PROGRAM, &["auth", "show"]);
if text.to_ascii_lowercase().contains("not signed in") {
emit("Not signed in. Use /login to sign in.".to_string());
return;
}
each_line(&text, &mut emit);
each_line(&text, emit);
}
/// Sign out of the active Hanzo identity.
pub fn logout(mut emit: impl FnMut(String)) {
each_line(&capture(PROGRAM, &["auth", "logout"]), &mut emit);
pub fn logout(emit: &mut dyn FnMut(String)) {
each_line(&capture(PROGRAM, &["auth", "logout"]), emit);
}
/// Sign in through Hanzo IAM without leaving the interface. The sign-in runs
/// beside it, and every line it prints, such as the address to open, reaches
/// `emit` as it arrives.
pub fn login(emit: impl Fn(String) + Send + Sync + 'static) {
pub fn login(emit: Box<dyn Fn(String) + Send + Sync>) {
std::thread::spawn(move || stream(PROGRAM, &["auth", "login", "--provider", "hanzo"], emit));
}
fn each_line(text: &str, emit: &mut impl FnMut(String)) {
fn each_line(text: &str, emit: &mut dyn FnMut(String)) {
text.lines()
.map(str::trim_end)
.filter(|line| !line.trim().is_empty())
+2 -25
View File
@@ -1,27 +1,4 @@
//! Hanzo-owned presentation; the upstream TUI owns interaction and animation.
//! The Hanzo account as the interface runs it: hanzo-dev registers it with codex-tui
//! (`codex_tui::register`) as the account behind /login, /logout, /auth and /status.
pub mod account;
pub mod model;
pub const PRODUCT_NAME: &str = "Hanzo Dev";
pub const INPUT_PLACEHOLDER: &str = "Ask Hanzo Dev to do anything";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Dev checks no one else's releases. The check upstream makes compares this build
/// with another product's latest release and offers that product's installer.
pub const UPDATE_CHECK: bool = false;
/// Dev runs its server inside the process. Upstream's shared background server
/// starts from an installer's package directory, which Dev does not ship.
pub const BACKGROUND_SERVER: bool = false;
/// Dev draws in the terminal's own foreground and background: no hue and no grey,
/// with weight and reverse video for emphasis.
pub const MONOCHROME: bool = true;
/// One line under the input: the status line takes the row the shortcut hint would.
pub const SEPARATE_STATUS_LINE: bool = false;
pub const STARTUP_TIP: &str = "Use **/mcp** to inspect connected tools. Hanzo MCP provides cloud controls and local development tools.";
/// Keep light terminals readable and dark input bars much subtler than the upstream default.
pub fn input_background(background: (u8, u8, u8), light: bool) -> (u8, u8, u8) {
let (target, alpha) = if light { (0.0, 0.04) } else { (255.0, 0.055) };
let mix = |channel: u8| (channel as f32 * (1.0 - alpha) + target * alpha).round() as u8;
(mix(background.0), mix(background.1), mix(background.2))
}
-82
View File
@@ -1,82 +0,0 @@
//! Finding the model a user means by `/model <name>`.
/// What a typed name came to.
#[derive(Debug, PartialEq, Eq)]
pub enum Pick {
One(String),
/// More than one model fits; the user names one of these.
Many(Vec<String>),
None,
}
/// Match `wanted` against the models the session offers and the ones Hanzo
/// serves: the exact name, then `<name>-auto`, then the only model whose name
/// contains it. Case does not matter.
pub fn pick(wanted: &str, offered: &[String]) -> Pick {
let wanted = wanted.trim().to_ascii_lowercase();
if wanted.is_empty() {
return Pick::None;
}
let mut names: Vec<String> = offered.to_vec();
for served in hanzo_config::catalog::MODELS {
if !names.iter().any(|name| name == served.slug) {
names.push(served.slug.to_string());
}
}
let lower = |name: &String| name.to_ascii_lowercase();
let exact = |target: &str| names.iter().find(|name| lower(name) == target).cloned();
if let Some(name) = exact(&wanted).or_else(|| exact(&format!("{wanted}-auto"))) {
return Pick::One(name);
}
let mut near: Vec<String> = names
.iter()
.filter(|name| lower(name).contains(&wanted))
.cloned()
.collect();
match near.len() {
0 => Pick::None,
1 => Pick::One(near.remove(0)),
_ => Pick::Many(near),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn offered() -> Vec<String> {
["gpt-6-astra", "gpt-6-sol"].map(String::from).to_vec()
}
#[test]
fn a_served_name_is_taken_as_it_is() {
assert_eq!(pick("enso", &offered()), Pick::One("enso".into()));
assert_eq!(pick("ENSO-PRO", &offered()), Pick::One("enso-pro".into()));
}
#[test]
fn a_family_name_finds_its_auto_model() {
assert_eq!(pick("zen6", &[]), Pick::One("zen6".into()));
assert_eq!(pick("gpt-6-astra", &offered()), Pick::One("gpt-6-astra".into()));
}
#[test]
fn the_only_model_containing_the_name_is_the_one() {
assert_eq!(pick("astra", &offered()), Pick::One("gpt-6-astra".into()));
}
#[test]
fn a_name_that_fits_several_asks_which() {
let Pick::Many(names) = pick("flash", &offered()) else {
panic!("flash fits several");
};
assert!(names.contains(&"enso-flash".to_string()));
assert!(names.contains(&"zen5-flash".to_string()));
}
#[test]
fn a_name_that_fits_nothing_is_not_guessed() {
assert_eq!(pick("nonesuch", &offered()), Pick::None);
assert_eq!(pick(" ", &offered()), Pick::None);
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ been ported or tested.
| --- | --- | --- |
| Union command surface | `crates/hanzo-dev/src/main.rs` covers the interactive session, `exec`, `login`/`logout`, `serve`, `cloud`, `sandbox`, `completion` | The rest of the upstream surface — `agents`, `review`, `mcp`, `plugin`, `doctor`, `resume`, `fork`, `queue`, `archive`, `features`, `worktree`. Their logic lives in upstream's binary rather than its library, so each needs either a library entry point upstream or an owned implementation. |
| Hanzo provider and Enso default | `hanzo-config` | Provider request and override integration tests |
| Branding and input surface | `hanzo-tui`, through hanzoai/codex's `tui:` commit | Render snapshots and an interactive pass |
| Branding and input surface | hanzoai/codex's `hanzo` crate and `tui:` commit | Render snapshots and an interactive pass |
| Branding outside the island | `-p/--profile` help still reads `$CODEX_HOME` | The string lives in `utils/cli`, which most of the workspace depends on, so it cannot join the island without dragging everything in. Either upstream a product-name indirection, or own the flag. |
| Native Hanzo tools | `hanzo-mcp` executable configuration | Unified session integration |
| Hanzo account login | `dev login --hanzo` selects the provider and reads what `hanzo login` stored | Device flow, and tests that do not depend on a signed-in machine |