From 2f4d9820ff0cddd4e8525cfb504c8117269139be Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:55:10 +0800 Subject: [PATCH] fix(core): show no effort level for a Claude model that takes none A new Claude session names its model but not its effort level, so the hook fell back to the settings' level - and a session started on Haiku showed "High" until its first turn ended. The hook now checks Claude Code's own model catalog (its cache, read only) first: a model it lists without effort options is reported with an empty level. A model it does not list, or no catalog at all, falls back to the settings as before. --- crates/tty7-core/src/core/agent_hooks.rs | 87 +++++++++++++++++++++--- 1 file changed, 79 insertions(+), 8 deletions(-) diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 45fe2576..1958d55f 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -242,12 +242,13 @@ fn claude_readout(event: &str, payload: &serde_json::Value, body: &mut serde_jso // Claude says the level it is running at, when it says it — and at the // end of a turn it says it whenever the model takes one, so a finished // turn without it ran on a model that does not: that is reported as an - // empty level. Otherwise the environment wins over the settings file, as - // it does for Claude — read only for a model this event names, since the - // file may set it per model. + // empty level. So is one for a model Claude's own model catalog lists + // without effort levels. Otherwise the environment wins over the settings + // file, as it does for Claude — read only for a model this event names, + // since the file may set it per model. let effort = str_of(payload.get("effort").and_then(|e| e.get("level"))).or_else(|| { let model = model.as_deref()?; - if event == "stop" { + if event == "stop" || catalog_says_no_effort(&claude_model_catalogs(), model) { return Some(String::new()); } std::env::var("CLAUDE_CODE_EFFORT_LEVEL") @@ -282,16 +283,57 @@ fn configured_effort(settings: &serde_json::Value, model: Option<&str>) -> Optio .or_else(|| level(Some(settings))) } -fn claude_settings() -> serde_json::Value { - let dir = std::env::var_os("CLAUDE_CONFIG_DIR") +fn claude_config_dir() -> Option { + std::env::var_os("CLAUDE_CONFIG_DIR") .filter(|d| !d.is_empty()) .map(PathBuf::from) - .or_else(|| home_dir().map(|h| h.join(".claude"))); - dir.and_then(|d| std::fs::read(d.join("settings.json")).ok()) + .or_else(|| home_dir().map(|h| h.join(".claude"))) +} + +fn claude_settings() -> serde_json::Value { + claude_config_dir() + .and_then(|d| std::fs::read(d.join("settings.json")).ok()) .and_then(|b| serde_json::from_slice(&b).ok()) .unwrap_or(serde_json::json!({})) } +/// The model catalogs Claude Code keeps in its cache, one per account it +/// has signed in with, as parsed JSON. None, if it has not fetched any. +fn claude_model_catalogs() -> Vec { + let Some(dir) = claude_config_dir().map(|d| d.join("cache").join("model-catalog")) else { + return Vec::new(); + }; + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + entries + .flatten() + .filter(|e| e.path().extension().is_some_and(|x| x == "json")) + .filter(|e| e.metadata().is_ok_and(|m| m.len() <= MAX_CONFIG_BYTES)) + .filter_map(|e| std::fs::read(e.path()).ok()) + .filter_map(|b| serde_json::from_slice(&b).ok()) + .collect() +} + +/// Whether Claude's model catalogs list `model` as taking no effort level — +/// Haiku, say. Only a model they list counts, and only when every catalog +/// that lists it agrees: a model they do not know may well take one. +fn catalog_says_no_effort(catalogs: &[serde_json::Value], model: &str) -> bool { + let model = model.trim_end_matches("[1m]"); + let mut listed = catalogs + .iter() + .filter_map(|c| c.pointer("/catalog/config/models")?.as_array()) + .flatten() + .filter(|m| m.get("id").and_then(|id| id.as_str()) == Some(model)) + .peekable(); + listed.peek().is_some() + && listed.all(|m| { + m.pointer("/thinking/effort_options") + .and_then(|o| o.as_array()) + .is_none_or(|o| o.is_empty()) + }) +} + /// The model that wrote `reply` to the prompt `prompt_id`, once the /// transcript has it as its latest words. Claude Code runs `Stop` before /// that write is always on disk, so this waits a little for it. @@ -2291,6 +2333,35 @@ mod tests { ); } + #[test] + fn a_model_the_catalog_lists_without_effort_levels_takes_none() { + let catalog = serde_json::json!({ + "version": 2, + "catalog": { "config": { "models": [ + { + "id": "claude-opus-5-5", + "thinking": { + "type": "effort", + "effort_options": [{ "id": "low" }, { "id": "high" }], + }, + }, + { "id": "claude-haiku-4-5-20251001", "thinking": { "type": "none" } }, + ] } }, + }); + let catalogs = [catalog]; + assert!(catalog_says_no_effort( + &catalogs, + "claude-haiku-4-5-20251001" + )); + assert!(!catalog_says_no_effort(&catalogs, "claude-opus-5-5")); + assert!(!catalog_says_no_effort(&catalogs, "claude-opus-5-5[1m]")); + assert!( + !catalog_says_no_effort(&catalogs, "claude-new-model"), + "a model the catalog does not know may take a level" + ); + assert!(!catalog_says_no_effort(&[], "claude-haiku-4-5-20251001")); + } + #[test] fn an_effort_set_for_the_model_wins_over_the_general_one() { let settings = serde_json::json!({