diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-10 19:23:13 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-10 19:23:13 +0300 |
| commit | 82468a0e03360219898ebf3735bdf559182009f8 (patch) | |
| tree | 0ee5213fe041a539f23e846f9148493528249512 /src | |
| parent | 36a1b7f86444871f0c38ff36510a6e3c2eec48c6 (diff) | |
feat: add manifest-based detection engine
Diffstat (limited to 'src')
| -rw-r--r-- | src/agent_resume.rs | 12 | ||||
| -rw-r--r-- | src/app/api/agents.rs | 12 | ||||
| -rw-r--r-- | src/cli/agent.rs | 121 | ||||
| -rw-r--r-- | src/detect/manifest.rs | 68 | ||||
| -rw-r--r-- | src/detect/manifest/tests.rs | 204 | ||||
| -rw-r--r-- | src/detect/manifest_update.rs | 24 | ||||
| -rw-r--r-- | src/detect/manifests/claude.toml | 152 | ||||
| -rw-r--r-- | src/detect/manifests/codex.toml | 85 | ||||
| -rw-r--r-- | src/detect/mod.rs | 21 | ||||
| -rw-r--r-- | src/integration/assets/kimi/herdr-agent-state.ps1 | 21 | ||||
| -rw-r--r-- | src/integration/assets/kimi/herdr-agent-state.sh | 56 | ||||
| -rw-r--r-- | src/integration/mod.rs | 21 | ||||
| -rw-r--r-- | src/pane.rs | 48 | ||||
| -rw-r--r-- | src/pane/agent_detection.rs | 13 | ||||
| -rw-r--r-- | src/pane/osc.rs | 495 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 62 | ||||
| -rw-r--r-- | src/terminal/runtime.rs | 8 |
17 files changed, 1157 insertions, 266 deletions
diff --git a/src/agent_resume.rs b/src/agent_resume.rs index b6a8c818..93bc4c2e 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -76,7 +76,6 @@ pub fn is_reserved_native_state_source(source: &str, agent: &str) -> bool { | ("herdr:codex", "codex") | ("herdr:copilot", "copilot") | ("herdr:droid", "droid") - | ("herdr:kimi", "kimi") | ("herdr:qodercli", "qodercli") | ("herdr:cursor", "cursor") ) @@ -220,6 +219,17 @@ mod tests { } #[test] + fn native_state_reservation_excludes_full_lifecycle_sources() { + assert!(is_reserved_native_state_source("herdr:claude", "claude")); + assert!(is_reserved_native_state_source("herdr:codex", "codex")); + assert!(!is_reserved_native_state_source("herdr:kimi", "kimi")); + assert!(!is_reserved_native_state_source( + "herdr:opencode", + "opencode" + )); + } + + #[test] fn planner_allows_supported_agents() { let pi_session = absolute_test_path("pi-session.jsonl"); assert_eq!( diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs index f89be0ce..471b3052 100644 --- a/src/app/api/agents.rs +++ b/src/app/api/agents.rs @@ -159,7 +159,17 @@ impl App { ); }; - let explain = crate::detect::manifest::explain(agent, &pane.detection_text()); + let screen = pane.detection_text(); + let osc_title = pane.agent_osc_title(); + let osc_progress = pane.agent_osc_progress(); + let explain = crate::detect::manifest::explain_with_input( + agent, + crate::detect::manifest::DetectionInput { + screen: &screen, + osc_title: &osc_title, + osc_progress: &osc_progress, + }, + ); let value = crate::detect::manifest::explain_to_json_value(&explain); encode_success(id, ResponseResult::AgentExplain { explain: value }) diff --git a/src/cli/agent.rs b/src/cli/agent.rs index 24f1880d..8c3c907b 100644 --- a/src/cli/agent.rs +++ b/src/cli/agent.rs @@ -35,6 +35,7 @@ fn agent_explain(args: &[String]) -> std::io::Result<i32> { let mut file = None; let mut agent = None; let mut json = false; + let mut verbose = false; let mut target = None; let mut index = 0; @@ -75,9 +76,15 @@ fn agent_explain(args: &[String]) -> std::io::Result<i32> { } index += 2; } + "--verbose" | "-v" => { + verbose = true; + index += 1; + } "help" | "--help" | "-h" => { - eprintln!("usage: herdr agent explain <target> [--json]"); - eprintln!("usage: herdr agent explain --file PATH --agent LABEL [--json]"); + eprintln!("usage: herdr agent explain <target> [--json|--verbose]"); + eprintln!( + "usage: herdr agent explain --file PATH --agent LABEL [--json|--verbose]" + ); return Ok(0); } value if value.starts_with('-') => { @@ -136,30 +143,62 @@ fn agent_explain(args: &[String]) -> std::io::Result<i32> { if json { println!("{explain}"); } else { - print_agent_explain_text(&explain); + print_agent_explain_text(&explain, verbose); } Ok(0) } -fn print_agent_explain_text(explain: &serde_json::Value) { +fn print_agent_explain_text(explain: &serde_json::Value, verbose: bool) { println!("agent: {}", explain["agent"].as_str().unwrap_or("unknown")); println!("state: {}", explain["state"].as_str().unwrap_or("unknown")); println!( - "screen_detection_skipped: {}", - explain["screen_detection_skipped"] - .as_bool() - .unwrap_or(false) + "manifest: {} {}", + explain["manifest_source"].as_str().unwrap_or("none"), + explain["manifest_version"].as_str().unwrap_or("unknown") ); + if let Some(rule) = explain["matched_rule"].as_object() { + let rule_id = rule + .get("id") + .and_then(|value| value.as_str()) + .unwrap_or("-"); + println!( + "rule: {} (region={} priority={})", + rule_id, + rule.get("region") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + rule.get("priority") + .and_then(|value| value.as_i64()) + .unwrap_or(0), + ); + if let Some(preview) = matched_rule_region_preview(explain, rule_id) { + println!("evidence: {preview:?}"); + } + } else { + println!("rule: none"); + } + if let Some(reason) = explain["fallback_reason"].as_str() { + println!("fallback_reason: {reason}"); + } if let Some(reason) = explain["screen_detection_skip_reason"].as_str() { println!("screen_detection_skip_reason: {reason}"); } + if let Some(reason) = explain["skipped_update_reason"].as_str() { + println!("skipped_update_reason: {reason}"); + } + if let Some(warning) = explain["warning"].as_str() { + println!("warning: {warning}"); + } + + if !verbose { + return; + } + println!( - "manifest: {}", - explain["manifest_source"].as_str().unwrap_or("none") - ); - println!( - "manifest_version: {}", - explain["manifest_version"].as_str().unwrap_or("unknown") + "visible: idle={} blocker={} working={}", + explain["visible_idle"].as_bool().unwrap_or(false), + explain["visible_blocker"].as_bool().unwrap_or(false), + explain["visible_working"].as_bool().unwrap_or(false) ); println!( "cached_remote_version: {}", @@ -177,40 +216,6 @@ fn print_agent_explain_text(explain: &serde_json::Value) { if let Some(error) = explain["remote_update_error"].as_str() { println!("remote_update_error: {error}"); } - if let Some(rule) = explain["matched_rule"].as_object() { - println!( - "matched_rule: {} priority={} region={} state={}", - rule.get("id") - .and_then(|value| value.as_str()) - .unwrap_or("-"), - rule.get("priority") - .and_then(|value| value.as_i64()) - .unwrap_or(0), - rule.get("region") - .and_then(|value| value.as_str()) - .unwrap_or("-"), - rule.get("state") - .and_then(|value| value.as_str()) - .unwrap_or("unknown") - ); - } else { - println!("matched_rule: none"); - } - println!( - "visible: idle={} blocker={} working={}", - explain["visible_idle"].as_bool().unwrap_or(false), - explain["visible_blocker"].as_bool().unwrap_or(false), - explain["visible_working"].as_bool().unwrap_or(false) - ); - if let Some(reason) = explain["fallback_reason"].as_str() { - println!("fallback_reason: {reason}"); - } - if let Some(reason) = explain["skipped_update_reason"].as_str() { - println!("skipped_update_reason: {reason}"); - } - if let Some(warning) = explain["warning"].as_str() { - println!("warning: {warning}"); - } if let Some(evaluated_rules) = explain["evaluated_rules"] .as_array() .filter(|rules| !rules.is_empty()) @@ -218,9 +223,13 @@ fn print_agent_explain_text(explain: &serde_json::Value) { println!("evaluated_rules:"); for rule in evaluated_rules { println!( - " {} matched={} priority={} region={} state={}", + " {} {} priority={} region={} state={}", + if rule["matched"].as_bool().unwrap_or(false) { + "✓" + } else { + "✗" + }, rule["id"].as_str().unwrap_or("-"), - rule["matched"].as_bool().unwrap_or(false), rule["priority"].as_i64().unwrap_or(0), rule["region"].as_str().unwrap_or("-"), rule["state"].as_str().unwrap_or("unknown") @@ -244,6 +253,18 @@ fn print_agent_explain_text(explain: &serde_json::Value) { } } +fn matched_rule_region_preview<'a>( + explain: &'a serde_json::Value, + rule_id: &str, +) -> Option<&'a str> { + explain["evaluated_rules"] + .as_array()? + .iter() + .find(|rule| rule["id"].as_str() == Some(rule_id))?["evidence"]["region_preview"] + .as_str() + .filter(|preview| !preview.is_empty()) +} + fn agent_start(args: &[String]) -> std::io::Result<i32> { let Some(name) = args.first() else { eprintln!("usage: herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>"); diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs index 6f660367..06ed4df6 100644 --- a/src/detect/manifest.rs +++ b/src/detect/manifest.rs @@ -13,6 +13,17 @@ use super::{ pub const DEFAULT_KNOWN_AGENT_IDLE_FALLBACK: &str = "default_known_agent_idle_fallback"; +/// Input to the detection engine, carrying the screen snapshot plus any +/// OSC-derived strings captured from the terminal title / progress sequences. +/// Pass empty strings for `osc_title` and `osc_progress` when the data is not +/// available — behavior is identical to the pre-OSC engine in that case. +#[derive(Debug, Clone, Copy)] +pub struct DetectionInput<'a> { + pub screen: &'a str, + pub osc_title: &'a str, + pub osc_progress: &'a str, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct DetectionExplain { pub agent: Option<String>, @@ -306,18 +317,41 @@ fn manifest_summary_from_loaded(agent: Agent, loaded: LoadedManifest) -> AgentMa } } +#[allow(dead_code)] // shim for existing callers; detect_with_osc is the real path pub fn detect(agent: Agent, screen_content: &str) -> AgentDetection { + detect_with_osc( + agent, + DetectionInput { + screen: screen_content, + osc_title: "", + osc_progress: "", + }, + ) +} + +pub fn detect_with_osc(agent: Agent, input: DetectionInput<'_>) -> AgentDetection { let Some(loaded) = load_manifest(agent) else { return fallback_explain(Some(agent), None, false).into_detection(); }; - evaluate_loaded_manifest(agent, screen_content, loaded, false).into_detection() + evaluate_loaded_manifest(agent, input, loaded, false).into_detection() } pub fn explain(agent: Agent, screen_content: &str) -> DetectionExplain { + explain_with_input( + agent, + DetectionInput { + screen: screen_content, + osc_title: "", + osc_progress: "", + }, + ) +} + +pub fn explain_with_input(agent: Agent, input: DetectionInput<'_>) -> DetectionExplain { let Some(loaded) = load_manifest(agent) else { return fallback_explain(Some(agent), None, true); }; - evaluate_loaded_manifest(agent, screen_content, loaded, true) + evaluate_loaded_manifest(agent, input, loaded, true) } pub fn explain_for_label(agent_label: &str, screen_content: &str) -> DetectionExplain { @@ -350,7 +384,17 @@ pub fn should_skip_state_update(agent: Agent, screen_content: &str) -> bool { let Some(loaded) = load_manifest(agent) else { return false; }; - evaluate_loaded_manifest(agent, screen_content, loaded, false).skip_state_update + evaluate_loaded_manifest( + agent, + DetectionInput { + screen: screen_content, + osc_title: "", + osc_progress: "", + }, + loaded, + false, + ) + .skip_state_update } impl DetectionExplain { @@ -367,7 +411,7 @@ impl DetectionExplain { fn evaluate_loaded_manifest( agent: Agent, - screen_content: &str, + input: DetectionInput<'_>, loaded: LoadedManifest, include_update_status: bool, ) -> DetectionExplain { @@ -375,7 +419,7 @@ fn evaluate_loaded_manifest( let mut evaluated_rules = Vec::new(); for (rule, compiled_rule) in loaded.manifest.rules.iter().zip(&loaded.compiled_rules) { - let region_text = region(screen_content, &rule.region); + let region_text = region(input, &rule.region); let matched_rule = compiled_rule_matches(compiled_rule, region_text); evaluated_rules.push(EvaluatedRule { id: rule.id.clone(), @@ -1025,7 +1069,9 @@ fn validate_region_name(spec: &str) -> Result<(), String> { | "prompt_box_body" | "above_prompt_box" | "last_non_empty_above_prompt_box" - | "after_last_horizontal_rule" => Ok(()), + | "after_last_horizontal_rule" + | "osc_title" + | "osc_progress" => Ok(()), _ if region_count(trimmed, "bottom_lines").is_some() || region_count(trimmed, "bottom_non_empty_lines").is_some() => { @@ -1192,8 +1238,16 @@ fn compiled_gate_matches(gate: &CompiledGate, text: &str, lower_text: &str) -> b true } -fn region<'a>(content: &'a str, spec: &str) -> &'a str { +fn region<'a>(input: DetectionInput<'a>, spec: &str) -> &'a str { let trimmed = spec.trim(); + // OSC regions source from their dedicated fields, not the screen. + match trimmed { + "osc_title" => return input.osc_title, + "osc_progress" => return input.osc_progress, + _ => {} + } + // All other regions operate on the screen content as before. + let content = input.screen; match trimmed { "whole_recent" => content, "after_last_prompt_marker" => after_last_prompt_marker(content), diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs index e5b03c0b..0f425384 100644 --- a/src/detect/manifest/tests.rs +++ b/src/detect/manifest/tests.rs @@ -156,7 +156,7 @@ line_regex = ["^exact line$"] #[test] fn remote_manifest_loads_between_local_override_and_bundled() { with_manifest_dirs("remote-source", || { - write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready")); + write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "remote-ready")); let explain = explain(Agent::Codex, "remote-ready"); @@ -165,10 +165,10 @@ fn remote_manifest_loads_between_local_override_and_bundled() { explain.source, Some(ManifestSource::Remote { .. }) )); - assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.1")); + assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.3")); assert_eq!( explain.cached_remote_version.as_deref(), - Some("2026.06.10.1") + Some("2026.06.10.3") ); }); } @@ -176,7 +176,7 @@ fn remote_manifest_loads_between_local_override_and_bundled() { #[test] fn fallback_explain_preserves_active_manifest_version() { with_manifest_dirs("fallback-version", || { - write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready")); + write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "remote-ready")); let explain = explain(Agent::Codex, "ordinary prompt text"); @@ -185,7 +185,7 @@ fn fallback_explain_preserves_active_manifest_version() { explain.fallback_reason.as_deref(), Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) ); - assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.1")); + assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.3")); assert!(matches!( explain.source, Some(ManifestSource::Remote { .. }) @@ -216,7 +216,7 @@ fn older_cached_remote_manifest_does_not_shadow_newer_bundled_manifest() { #[test] fn local_override_shadows_cached_remote_manifest() { with_manifest_dirs("local-shadows-remote", || { - write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready")); + write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "remote-ready")); write_local_codex(&local_manifest("idle", "local-ready")); let explain = explain(Agent::Codex, "local-ready"); @@ -226,7 +226,7 @@ fn local_override_shadows_cached_remote_manifest() { assert!(explain.local_override_shadowing_remote); assert_eq!( explain.cached_remote_version.as_deref(), - Some("2026.06.10.1") + Some("2026.06.10.3") ); }); } @@ -234,7 +234,7 @@ fn local_override_shadows_cached_remote_manifest() { #[test] fn invalid_local_override_falls_back_to_cached_remote_manifest() { with_manifest_dirs("invalid-local-remote-fallback", || { - write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready")); + write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "remote-ready")); write_local_codex("id = "); let explain = explain(Agent::Codex, "remote-ready"); @@ -251,7 +251,7 @@ fn invalid_local_override_falls_back_to_cached_remote_manifest() { #[test] fn detection_uses_cached_manifest_until_explicit_reload() { with_manifest_dirs("cache-boundary", || { - write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "cached-ready")); + write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "cached-ready")); let cached = explain(Agent::Codex, "cached-ready"); assert_eq!(cached.state, AgentState::Blocked); @@ -261,7 +261,7 @@ fn detection_uses_cached_manifest_until_explicit_reload() { Some("test") ); - write_remote_codex_without_reload(&remote_manifest("2026.06.10.2", "working", "new-ready")); + write_remote_codex_without_reload(&remote_manifest("2026.06.10.4", "working", "new-ready")); let unchanged = explain(Agent::Codex, "new-ready"); assert_eq!(unchanged.state, AgentState::Idle); @@ -271,7 +271,7 @@ fn detection_uses_cached_manifest_until_explicit_reload() { ); assert_eq!( unchanged.cached_remote_version.as_deref(), - Some("2026.06.10.1") + Some("2026.06.10.3") ); reload_manifests(); @@ -280,7 +280,7 @@ fn detection_uses_cached_manifest_until_explicit_reload() { assert_eq!(reloaded.state, AgentState::Working); assert_eq!( reloaded.cached_remote_version.as_deref(), - Some("2026.06.10.2") + Some("2026.06.10.4") ); assert_eq!( reloaded.matched_rule.as_ref().map(|rule| rule.id.as_str()), @@ -492,7 +492,185 @@ fn bottom_non_empty_lines_uses_bottom_occurrence_for_repeated_text() { let content = "marker\nold\n\nmiddle\nmarker\nnew\n"; assert_eq!( - region(content, "bottom_non_empty_lines(2)"), + region( + DetectionInput { + screen: content, + osc_title: "", + osc_progress: "", + }, + "bottom_non_empty_lines(2)" + ), "marker\nnew\n" ); } + +// --------------------------------------------------------------------------- +// OSC rule tests — exercise the new osc_title / osc_progress regions against +// the bundled Claude and Codex manifests. +// --------------------------------------------------------------------------- + +fn osc_explain( + agent: Agent, + screen: &str, + osc_title: &str, + osc_progress: &str, +) -> DetectionExplain { + explain_with_input( + agent, + DetectionInput { + screen, + osc_title, + osc_progress, + }, + ) +} + +// --- Claude OSC rules --- + +#[test] +fn claude_osc_title_braille_prefix_is_working() { + // "⠂" is U+2802, in the braille block U+2800-U+28FF + let result = osc_explain(Agent::Claude, "", "⠂ project", ""); + assert_eq!(result.state, AgentState::Working); + assert_eq!( + result.matched_rule.as_ref().map(|r| r.id.as_str()), + Some("osc_title_working") + ); + assert!(result.visible_working); +} + +#[test] +fn claude_osc_title_static_prefix_is_idle() { + // "✳" is U+2733, static prefix when Claude is not working + let result = osc_explain(Agent::Claude, "", "✳ Claude Code", ""); + assert_eq!(result.state, AgentState::Idle); + assert_eq!( + result.matched_rule.as_ref().map(|r| r.id.as_str()), + Some("osc_title_idle") + ); + assert!(result.visible_idle); +} + +#[test] +fn claude_osc_progress_4_3_alone_does_not_force_working() { + // Claude leaves progress stuck at 4;3 while waiting for permission, so + // 4;3 must not be a working signal on its own. With no other evidence it + // falls back to idle; blocked screen rules can win when present. + let result = osc_explain(Agent::Claude, "", "", "4;3;"); + assert_eq!(result.state, AgentState::Idle); + assert_eq!( + result.fallback_reason.as_deref(), + Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) + ); + assert!(!result.visible_working); +} + +#[test] +fn claude_blocker_screen_outranks_stale_osc_progress() { + // Regression: progress 4;3 persists during permission prompts. The + // blocked form on screen must win because no rule treats 4;3 as working. + let blocker_screen = + "──────────\n 1. Yes\n 2. No\n\nEnter to select · ↑/↓ to navigate · Esc to cancel\n"; + let result = osc_explain(Agent::Claude, blocker_screen, "✳ Task title", "4;3;"); + assert_eq!(result.state, AgentState::Blocked); + assert!(result.visible_blocker); +} + +#[test] +fn claude_osc_progress_4_0_is_idle() { + let result = osc_explain(Agent::Claude, "", "", "4;0;"); + assert_eq!(result.state, AgentState::Idle); + assert_eq!( + result.matched_rule.as_ref().map(|r| r.id.as_str()), + Some("osc_progress_idle") + ); +} + +#[test] +fn claude_blocker_screen_outranks_osc_idle_title() { + // When the OSC title shows ✳ (idle) but the screen has a bash permission + // prompt, the blocked rule at priority 850 beats osc_title_idle at 250. + let blocker_screen = "do you want to proceed?\n\ + bash command: rm -rf /tmp/test\n\ + ❯ 1. Yes\n 2. No\n\n\ + Esc to cancel · Tab to amend · ctrl+e to explain\n"; + let result = osc_explain(Agent::Claude, blocker_screen, "✳ Claude Code", ""); + assert_eq!(result.state, AgentState::Blocked); + assert!(result.visible_blocker); +} + +#[test] +fn claude_empty_osc_empty_screen_is_idle_fallback() { + // No OSC data, no matching screen rule → fallback idle (unchanged V3 behavior) + let result = osc_explain(Agent::Claude, "", "", ""); + assert_eq!(result.state, AgentState::Idle); + assert_eq!( + result.fallback_reason.as_deref(), + Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) + ); + assert!(!result.visible_idle); +} + +// --- Codex OSC rules --- + +#[test] +fn codex_osc_title_braille_spinner_is_working() { + // "⠋" is U+280B, in the braille block + let result = osc_explain(Agent::Codex, "", "⠋ llm-proxy", ""); + assert_eq!(result.state, AgentState::Working); + assert_eq!( + result.matched_rule.as_ref().map(|r| r.id.as_str()), + Some("osc_title_working") + ); + assert!(result.visible_working); +} + +#[test] +fn codex_osc_title_action_required_is_blocked() { + let result = osc_explain(Agent::Codex, "", "[ . ] Action Required | llm-proxy", ""); + assert_eq!(result.state, AgentState::Blocked); + assert_eq!( + result.matched_rule.as_ref().map(|r| r.id.as_str()), + Some("osc_title_blocked") + ); + assert!(result.visible_blocker); +} + +#[test] +fn codex_osc_title_plain_is_idle() { + let result = osc_explain(Agent::Codex, "", "llm-proxy", ""); + assert_eq!(result.state, AgentState::Idle); + assert_eq!( + result.matched_rule.as_ref().map(|r| r.id.as_str()), + Some("osc_title_idle") + ); + assert!(result.visible_idle); +} + +#[test] +fn codex_background_terminal_screen_beats_osc_idle() { + // Codex doesn't set is_task_running() for background terminals, so title is + // plain (idle). The screen rule background_terminal_working at priority 800 + // beats osc_title_idle at priority 100. + let screen = "background terminal running · /ps to view · /stop to close\n"; + let result = osc_explain(Agent::Codex, screen, "llm-proxy", ""); + assert_eq!(result.state, AgentState::Working); + assert_eq!( + result.matched_rule.as_ref().map(|r| r.id.as_str()), + Some("background_terminal_working") + ); + assert!(result.visible_working); +} + +#[test] +fn codex_osc_working_beats_weak_blocker_screen() { + // A stale [y/n] on screen triggers weak_blocker at priority 600, but an + // active braille spinner in the OSC title is priority 1050 — OSC wins. + let screen = "do you want to continue? [y/n]\n"; + let result = osc_explain(Agent::Codex, screen, "⠋ llm-proxy", ""); + assert_eq!(result.state, AgentState::Working); + assert_eq!( + result.matched_rule.as_ref().map(|r| r.id.as_str()), + Some("osc_title_working") + ); +} diff --git a/src/detect/manifest_update.rs b/src/detect/manifest_update.rs index 6534b885..f82b4864 100644 --- a/src/detect/manifest_update.rs +++ b/src/detect/manifest_update.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use super::{agent_label, parse_agent_label, Agent}; -pub(crate) const MANIFEST_ENGINE_VERSION: u32 = 1; +pub(crate) const MANIFEST_ENGINE_VERSION: u32 = 2; const DEFAULT_CATALOG_URL: &str = "https://herdr.dev/agent-detection/index.toml"; const CATALOG_URL_ENV: &str = "HERDR_AGENT_DETECTION_MANIFEST_CATALOG_URL"; const MAX_FETCH_BYTES: usize = 256 * 1024; @@ -446,9 +446,13 @@ fn sync_parent_dir(parent: &Path) -> Result<(), String> { } fn directory_sync_unsupported(err: &std::io::Error) -> bool { + // PermissionDenied: Windows cannot open directories via `File::open` + // (os error 5), so directory sync is effectively unsupported there. matches!( err.kind(), - std::io::ErrorKind::Unsupported | std::io::ErrorKind::InvalidInput + std::io::ErrorKind::Unsupported + | std::io::ErrorKind::InvalidInput + | std::io::ErrorKind::PermissionDenied ) } @@ -610,7 +614,7 @@ contains = ["{contains}"] #[test] fn process_agent_manifest_commits_newer_manifest_atomically() { with_state_dir("commit-newer", || { - let content = remote_manifest("2026.06.10.1", "ready"); + let content = remote_manifest("2026.06.10.3", "ready"); let commit = process_agent_manifest(Agent::Codex, &content, 1) .unwrap() .unwrap(); @@ -618,7 +622,7 @@ contains = ["{contains}"] assert_eq!(commit.agent, Agent::Codex); assert_eq!( commit.version, - ManifestVersion::parse("2026.06.10.1").unwrap() + ManifestVersion::parse("2026.06.10.3").unwrap() ); assert_eq!( fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(), @@ -648,7 +652,7 @@ path = "codex.toml" .unwrap(); fs::write( web_dir.join("codex.toml"), - remote_manifest("2026.06.10.1", "auto-update-ready"), + remote_manifest("2026.06.10.3", "auto-update-ready"), ) .unwrap(); std::env::set_var( @@ -689,10 +693,10 @@ path = "codex.toml" #[test] fn process_agent_manifest_rejects_downgrade_and_keeps_cached_manifest() { with_state_dir("reject-downgrade", || { - let current = remote_manifest("2026.06.10.2", "current"); + let current = remote_manifest("2026.06.10.4", "current"); process_agent_manifest(Agent::Codex, ¤t, 1).unwrap(); - let older = remote_manifest("2026.06.10.1", "older"); + let older = remote_manifest("2026.06.10.3", "older"); assert!(process_agent_manifest(Agent::Codex, &older, 2).is_err()); assert_eq!( fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(), @@ -704,10 +708,10 @@ path = "codex.toml" #[test] fn process_agent_manifest_rejects_equal_version_content_change() { with_state_dir("reject-equal-change", || { - let current = remote_manifest("2026.06.10.1", "current"); + let current = remote_manifest("2026.06.10.3", "current"); process_agent_manifest(Agent::Codex, ¤t, 1).unwrap(); - let changed = remote_manifest("2026.06.10.1", "changed"); + let changed = remote_manifest("2026.06.10.3", "changed"); assert!(process_agent_manifest(Agent::Codex, &changed, 2).is_err()); assert_eq!( fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(), @@ -719,7 +723,7 @@ path = "codex.toml" #[test] fn process_agent_manifest_skips_same_version_same_content() { with_state_dir("skip-same", || { - let current = remote_manifest("2026.06.10.1", "current"); + let current = remote_manifest("2026.06.10.3", "current"); process_agent_manifest(Agent::Codex, ¤t, 1).unwrap(); let result = process_agent_manifest(Agent::Codex, ¤t, 2).unwrap(); diff --git a/src/detect/manifests/claude.toml b/src/detect/manifests/claude.toml index 84177af6..7bcecf6f 100644 --- a/src/detect/manifests/claude.toml +++ b/src/detect/manifests/claude.toml @@ -1,10 +1,18 @@ id = "claude" -version = "2026.06.10.1" -min_engine_version = 1 +version = "2026.06.10.3" +min_engine_version = 2 updated_at = "2026-06-10T00:00:00Z" aliases = ["claude-code"] [[rules]] +id = "osc_title_working" +state = "working" +priority = 1100 +region = "osc_title" +visible_working = true +regex = ['^[\x{2800}-\x{28FF}] '] + +[[rules]] id = "transcript_viewer" state = "unknown" priority = 1000 @@ -20,6 +28,29 @@ any = [ ] [[rules]] +id = "live_blocked_form" +state = "blocked" +priority = 980 +region = "after_last_horizontal_rule" +visible_blocker = true +contains = ["enter to select", "esc to cancel"] +any = [ + { contains = ["tab/arrow keys to navigate"] }, + { contains = ["arrow keys to navigate"] }, + { contains = ["arrows to navigate"] }, + { contains = ["↑/↓ to navigate"] }, + { contains = ["↑↓ to navigate"] }, +] + +[[rules]] +id = "dynamic_workflow_prompt" +state = "blocked" +priority = 980 +region = "whole_recent" +visible_blocker = true +contains = ["run a dynamic workflow?", "esc to cancel"] + +[[rules]] id = "live_prompt_box" state = "idle" priority = 950 @@ -47,27 +78,22 @@ not = [ ] [[rules]] -id = "live_blocked_form" +id = "bash_permission_prompt" state = "blocked" -priority = 980 -region = "after_last_horizontal_rule" +priority = 850 +region = "whole_recent" visible_blocker = true -contains = ["enter to select", "esc to cancel"] +contains = ["do you want to proceed?"] any = [ - { contains = ["tab/arrow keys to navigate"] }, - { contains = ["arrow keys to navigate"] }, - { contains = ["arrows to navigate"] }, - { contains = ["↑/↓ to navigate"] }, - { contains = ["↑↓ to navigate"] }, + { contains = ["bash command"] }, + { contains = ["bash("] }, + { contains = ["contains expansion"] }, + { contains = ["tab to amend"] }, + { contains = ["ctrl+e to explain"] }, +] +all = [ + { any = [{ line_regex = ['(?i)^\s*❯?\s*yes\b'] }, { line_regex = ['(?i)^\s*1\.\s*yes\b'] }, { line_regex = ['(?i)^\s*2\.\s*no\b'] }] }, ] - -[[rules]] -id = "dynamic_workflow_prompt" -state = "blocked" -priority = 980 -region = "whole_recent" -visible_blocker = true -contains = ["run a dynamic workflow?", "esc to cancel"] [[rules]] id = "generic_permission_prompt" @@ -86,79 +112,6 @@ all = [ ] [[rules]] -id = "bash_permission_prompt" -state = "blocked" -priority = 850 -region = "whole_recent" -visible_blocker = true -contains = ["do you want to proceed?"] -any = [ - { contains = ["bash command"] }, - { contains = ["bash("] }, - { contains = ["contains expansion"] }, - { contains = ["tab to amend"] }, - { contains = ["ctrl+e to explain"] }, -] -all = [ - { any = [{ line_regex = ['(?i)^\s*❯?\s*yes\b'] }, { line_regex = ['(?i)^\s*1\.\s*yes\b'] }, { line_regex = ['(?i)^\s*2\.\s*no\b'] }] }, -] - -[[rules]] -id = "interrupt_chrome_working" -state = "working" -priority = 970 -region = "above_prompt_box" -visible_working = true -any = [ - { contains = ["esc to interrupt"] }, - { contains = ["ctrl+c to interrupt"] }, -] - -[[rules]] -id = "spinner_activity_working" -state = "working" -priority = 960 -region = "above_prompt_box" -visible_working = true -line_regex = ['^\s*[·✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆]\s+.*\p{Alphabetic}.*…'] - -[[rules]] -id = "ascii_spinner_activity_working" -state = "working" -priority = 960 -region = "above_prompt_box" -visible_working = true -line_regex = ['^\s*\*\s+\p{Alphabetic}.*…\s*\([^)]*\b[1-9][0-9]*s\b[^)]*\)'] - -[[rules]] -id = "latest_background_status_working" -state = "working" -priority = 960 -region = "last_non_empty_above_prompt_box" -visible_working = true -any = [ - { regex = ['(?i)^\W*waiting for [1-9][0-9]* background agents? to finish$'] }, - { regex = ['(?i).*\b[1-9][0-9]* shells? still running\b'] }, - { regex = ['(?i).*\b[1-9][0-9]* local agents? still running\b'] }, -] - -[[rules]] -id = "bottom_agent_status_working" -state = "working" -priority = 955 -region = "bottom_non_empty_lines(10)" -visible_working = true -line_regex = ['^\s*◯\s+(?:Explore|Agent)\b.*\b[1-9][0-9]*s\b(?:\s*·.*)?$'] - -[[rules]] -id = "dynamic_workflow_progress_working" -state = "working" -priority = 965 -region = "bottom_non_empty_lines(10)" -visible_working = true -line_regex = ['^\s*[◯●]\s+.*\b(?:[0-9]+/[1-9][0-9]*) agents done\b.*\b[1-9][0-9]*s\b'] - -[[rules]] id = "legacy_no_prompt_blocker" state = "blocked" priority = 300 @@ -177,3 +130,18 @@ any = [ not = [ { regex = ['(?m)^\s*❯\s*$'] }, ] + +[[rules]] +id = "osc_title_idle" +state = "idle" +priority = 250 +region = "osc_title" +visible_idle = true +regex = ['^\x{2733} '] + +[[rules]] +id = "osc_progress_idle" +state = "idle" +priority = 250 +region = "osc_progress" +regex = ['^4;0'] diff --git a/src/detect/manifests/codex.toml b/src/detect/manifests/codex.toml index cd09e4b0..8cd42fa3 100644 --- a/src/detect/manifests/codex.toml +++ b/src/detect/manifests/codex.toml @@ -1,9 +1,25 @@ id = "codex" -version = "2026.06.10.1" -min_engine_version = 1 +version = "2026.06.10.2" +min_engine_version = 2 updated_at = "2026-06-10T00:00:00Z" [[rules]] +id = "osc_title_blocked" +state = "blocked" +priority = 1100 +region = "osc_title" +visible_blocker = true +contains = ["Action Required"] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 1050 +region = "osc_title" +visible_working = true +regex = ['^[\x{2800}-\x{28FF}] '] + +[[rules]] id = "transcript_viewer" state = "unknown" priority = 1000 @@ -29,45 +45,16 @@ any = [ ] [[rules]] -id = "current_live_working" +id = "background_terminal_working" state = "working" -priority = 710 -region = "current_prompt_block_marker" +priority = 800 +region = "bottom_non_empty_lines(6)" visible_working = true any = [ - { line_regex = ['(?i)^\s*• queued follow-up inputs'] }, - { line_regex = ['(?i)^\s*• messages to be submitted after next tool call'] }, - { contains = ["Waiting for background terminal"] }, { contains = ["background terminal running"] }, + { contains = ["Waiting for background terminal"] }, { contains = ["/ps to view"] }, { contains = ["/stop to close"] }, - { regex = ['(?i)^\s*•.*\([0-9]+[hms](?: [0-9]+[hms]){0,2}.*esc\s*(…|to interrupt|to .+…)'] }, -] - -[[rules]] -id = "current_working_status" -state = "working" -priority = 700 -region = "current_prompt_block_marker" -any = [ - { line_regex = ['(?i)^\s*• queued follow-up inputs'] }, - { line_regex = ['(?i)^\s*• messages to be submitted after next tool call'] }, - { contains = ["Working ("] }, - { contains = ["Waiting for background terminal ("] }, - { contains = ["Booting MCP server:"] }, - { contains = ["reviewing approval request ("] }, - { regex = ['(?i)reviewing .* approval requests \('] }, - { regex = ['(?i)^\s*•.*\([0-9]+[hms](?: [0-9]+[hms]){0,2}\).* • esc(…| to interrupt| to .+…)'] }, -] - -[[rules]] -id = "current_working_status_with_detail" -state = "working" -priority = 690 -region = "after_current_prompt_block_marker" -any = [ - { contains = ["Queued follow-up inputs"] }, - { contains = ["Messages to be submitted after next tool call"] }, ] [[rules]] @@ -83,23 +70,13 @@ any = [ ] [[rules]] -id = "fallback_working" -state = "working" -priority = 500 -region = "whole_recent_without_current_prompt_marker" -visible_working = true -any = [ - { line_regex = ['(?i)^\s*•.*\([0-9]+[hms](?: [0-9]+[hms]){0,2}.*esc\s*(…|to interrupt|to .+…)'] }, - { contains = ["esc to interrupt"] }, - { all = [{ contains = ["ctrl+c to interrupt"] }, { line_regex = ['(?i)^\s*•'] }] }, - { all = [{ contains = ["press esc to interrupt"] }, { line_regex = ['(?i)^\s*•'] }] }, -] - -[[rules]] -id = "fallback_status_working" -state = "working" -priority = 490 -region = "whole_recent_without_current_prompt_marker" -any = [ - { line_regex = ['(?i)^\s*•.*(Working \(|Waiting for background terminal \(|Booting MCP server:|reviewing approval request \(|reviewing .* approval requests \()'] }, +id = "osc_title_idle" +state = "idle" +priority = 100 +region = "osc_title" +visible_idle = true +regex = ['\S'] +not = [ + { regex = ['^[\x{2800}-\x{28FF}]'] }, + { contains = ["Action Required"] }, ] diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 62dc3d05..6a8a25c5 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -193,7 +193,18 @@ pub fn detect_state(agent: Option<Agent>, screen_content: &str) -> AgentState { } /// Detect state and whether a visible blocker is present on the current screen. +#[allow(dead_code)] // shim for existing callers; detect_agent_with_osc is the real path pub fn detect_agent(agent: Option<Agent>, screen_content: &str) -> AgentDetection { + detect_agent_with_osc(agent, screen_content, "", "") +} + +/// Detect state using screen content plus OSC title/progress strings. +pub fn detect_agent_with_osc( + agent: Option<Agent>, + screen_content: &str, + osc_title: &str, + osc_progress: &str, +) -> AgentDetection { let Some(agent) = agent else { return AgentDetection { state: AgentState::Unknown, @@ -203,7 +214,14 @@ pub fn detect_agent(agent: Option<Agent>, screen_content: &str) -> AgentDetectio visible_working: false, }; }; - manifest::detect(agent, screen_content) + manifest::detect_with_osc( + agent, + manifest::DetectionInput { + screen: screen_content, + osc_title, + osc_progress, + }, + ) } pub fn should_skip_state_update(agent: Option<Agent>, screen_content: &str) -> bool { @@ -218,6 +236,7 @@ pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> | ("herdr:hermes", "hermes") | ("herdr:opencode", "opencode") | ("herdr:kilo", "kilo") + | ("herdr:kimi", "kimi") ) } diff --git a/src/integration/assets/kimi/herdr-agent-state.ps1 b/src/integration/assets/kimi/herdr-agent-state.ps1 index 6684d2a9..d9924acc 100644 --- a/src/integration/assets/kimi/herdr-agent-state.ps1 +++ b/src/integration/assets/kimi/herdr-agent-state.ps1 @@ -2,11 +2,11 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=kimi -# HERDR_INTEGRATION_VERSION=2 +# HERDR_INTEGRATION_VERSION=3 param([string]$Action = "") -if ($Action -ne "session") { exit 0 } +if (@("session", "working", "blocked", "idle", "release") -notcontains $Action) { exit 0 } if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } @@ -17,10 +17,21 @@ try { $payload = $null } -if ($null -eq $payload -or [string]::IsNullOrWhiteSpace($payload.session_id)) { exit 0 } - $seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() +$sessionId = if ($null -ne $payload -and -not [string]::IsNullOrWhiteSpace($payload.session_id)) { $payload.session_id } else { $null } + try { - & herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --agent-session-id $payload.session_id --seq $seq 2>$null | Out-Null + if ($Action -eq "release") { + & herdr pane release-agent $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --seq $seq 2>$null | Out-Null + } elseif ($Action -eq "session") { + if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 } + & herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --agent-session-id $sessionId --seq $seq 2>$null | Out-Null + } else { + if ([string]::IsNullOrWhiteSpace($sessionId)) { + & herdr pane report-agent $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --state $Action --seq $seq 2>$null | Out-Null + } else { + & herdr pane report-agent $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --state $Action --agent-session-id $sessionId --seq $seq 2>$null | Out-Null + } + } } catch { } diff --git a/src/integration/assets/kimi/herdr-agent-state.sh b/src/integration/assets/kimi/herdr-agent-state.sh index 6332ca2a..2c8d8567 100644 --- a/src/integration/assets/kimi/herdr-agent-state.sh +++ b/src/integration/assets/kimi/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=kimi -# HERDR_INTEGRATION_VERSION=2 +# HERDR_INTEGRATION_VERSION=3 set -eu @@ -13,7 +13,7 @@ trap 'rm -f "$hook_input_file"' EXIT HUP INT TERM cat >"$hook_input_file" 2>/dev/null || true case "$action" in - session) ;; + session|working|blocked|idle|release) ;; *) exit 0 ;; esac @@ -30,6 +30,8 @@ import socket import time source = "herdr:kimi" +agent = "kimi" +action = os.environ.get("HERDR_ACTION", "") pane_id = os.environ.get("HERDR_PANE_ID") socket_path = os.environ.get("HERDR_SOCKET_PATH") hook_input_file = os.environ.get("HERDR_HOOK_INPUT_FILE") @@ -48,22 +50,50 @@ if hook_input_file: hook_input = {} session_id = hook_input.get("session_id") -if not isinstance(session_id, str) or not session_id: - raise SystemExit(0) - +agent_session_id = session_id if isinstance(session_id, str) and session_id else None request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" report_seq = time.time_ns() -request = { - "id": request_id, - "method": "pane.report_agent_session", - "params": { + +if action == "release": + request = { + "id": request_id, + "method": "pane.release_agent", + "params": { + "pane_id": pane_id, + "source": source, + "agent": agent, + "seq": report_seq, + }, + } +elif action == "session": + if not agent_session_id: + raise SystemExit(0) + request = { + "id": request_id, + "method": "pane.report_agent_session", + "params": { + "pane_id": pane_id, + "source": source, + "agent": agent, + "agent_session_id": agent_session_id, + "seq": report_seq, + }, + } +else: + params = { "pane_id": pane_id, "source": source, - "agent": "kimi", - "agent_session_id": session_id, + "agent": agent, + "state": action, "seq": report_seq, - }, -} + } + if agent_session_id: + params["agent_session_id"] = agent_session_id + request = { + "id": request_id, + "method": "pane.report_agent", + "params": params, + } try: client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 36178b8d..b063f23b 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -51,12 +51,23 @@ const KIMI_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/kimi/herdr-agent-state.sh") }; -const KIMI_INTEGRATION_VERSION: u32 = 2; +const KIMI_INTEGRATION_VERSION: u32 = 3; const KIMI_CODE_HOME_ENV_VAR: &str = "KIMI_CODE_HOME"; const KIMI_CONFIG_BLOCK_BEGIN: &str = "# >>> herdr kimi integration"; const KIMI_CONFIG_BLOCK_END: &str = "# <<< herdr kimi integration"; -const KIMI_MIN_VERSION: &str = "0.8.0"; -const KIMI_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; +const KIMI_MIN_VERSION: &str = "0.14.0"; +const KIMI_HOOK_EVENTS: [(&str, &str); 10] = [ + ("SessionStart", "session"), + ("UserPromptSubmit", "working"), + ("PreToolUse", "working"), + ("SubagentStart", "working"), + ("PreCompact", "working"), + ("PermissionRequest", "blocked"), + ("PermissionResult", "working"), + ("Stop", "idle"), + ("Interrupt", "idle"), + ("SessionEnd", "release"), +]; const COPILOT_HOOK_INSTALL_NAME: &str = if cfg!(windows) { "herdr-agent-state.ps1" } else { @@ -5365,8 +5376,8 @@ mod tests { assert!(KIMI_HOOK_ASSET.contains("source = \"herdr:kimi\"")); assert!(KIMI_HOOK_ASSET.contains("agent_session_id")); assert!(KIMI_HOOK_ASSET.contains("pane.report_agent_session")); - assert!(!KIMI_HOOK_ASSET.contains("\"state\": action")); - assert!(!KIMI_HOOK_ASSET.contains("pane.release_agent")); + assert!(KIMI_HOOK_ASSET.contains("\"state\": action")); + assert!(KIMI_HOOK_ASSET.contains("pane.release_agent")); assert!(COPILOT_HOOK_ASSET.contains("agent_session_id")); assert!(COPILOT_HOOK_ASSET.contains("pane.report_agent_session")); assert!(!COPILOT_HOOK_ASSET.contains("\"state\":")); diff --git a/src/pane.rs b/src/pane.rs index b880ff32..b0f3bbfa 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -33,11 +33,11 @@ mod xtgettcap; use self::agent_detection::{ agent_caused_pty_activity_active, baseline_pty_causality, decide_detection_screen_read, - decide_screen_detection_publish, detection_update_for_publish, handle_skipped_detection_update, - observe_pty_output_activity, DetectionPublishDecision, DetectionScreenReadDecision, - DetectionScreenReadInput, PendingIdleConfirmation, PendingWorkingConfirmation, - PostTaintWorkingLease, PtyCausalityTracker, ScreenDetectionPublishInput, - AGENT_PENDING_IDLE_RECHECK, AGENT_STARTUP_GRACE_WINDOW, + decide_screen_detection_publish, detection_update_for_publish_with_osc, + handle_skipped_detection_update, observe_pty_output_activity, DetectionPublishDecision, + DetectionScreenReadDecision, DetectionScreenReadInput, PendingIdleConfirmation, + PendingWorkingConfirmation, PostTaintWorkingLease, PtyCausalityTracker, + ScreenDetectionPublishInput, AGENT_PENDING_IDLE_RECHECK, AGENT_STARTUP_GRACE_WINDOW, }; use self::terminal::{GhosttyPaneTerminal, PaneTerminal}; pub(crate) use self::terminal::{TerminalDirtyPatch, TerminalDirtyPatchOutcome}; @@ -544,6 +544,9 @@ fn spawn_basic_detection_task( pending_working.clear(); post_taint_working.clear(); last_screen_scan_pty_output_seq = None; + // A new foreground agent must not inherit OSC + // title/progress evidence from the previous process. + terminal.clear_agent_osc_state(); if agent.is_some() { agent_startup_grace_until = Some(now + AGENT_STARTUP_GRACE_WINDOW); baseline_pty_causality( @@ -665,9 +668,15 @@ fn spawn_basic_detection_task( &mut last_content_change_at, ); - let Some(screen_detection) = - detection_update_for_publish(agent, &content, process_exited) - else { + let osc_title = terminal.agent_osc_title(); + let osc_progress = terminal.agent_osc_progress(); + let Some(screen_detection) = detection_update_for_publish_with_osc( + agent, + &content, + &osc_title, + &osc_progress, + process_exited, + ) else { handle_skipped_detection_update( state, pty_activity, @@ -1945,6 +1954,9 @@ impl PaneRuntime { pending_working.clear(); post_taint_working.clear(); last_screen_scan_pty_output_seq = None; + // A new foreground agent must not inherit OSC + // title/progress evidence from the previous process. + terminal.clear_agent_osc_state(); if agent.is_some() { agent_startup_grace_until = Some(now + AGENT_STARTUP_GRACE_WINDOW); @@ -2098,9 +2110,15 @@ impl PaneRuntime { &mut last_content_change_at, ); - let Some(screen_detection) = - detection_update_for_publish(agent, &content, process_exited) - else { + let osc_title = terminal.agent_osc_title(); + let osc_progress = terminal.agent_osc_progress(); + let Some(screen_detection) = detection_update_for_publish_with_osc( + agent, + &content, + &osc_title, + &osc_progress, + process_exited, + ) else { handle_skipped_detection_update( state, pty_activity, @@ -2306,6 +2324,14 @@ impl PaneRuntime { self.terminal.detection_text() } + pub fn agent_osc_title(&self) -> String { + self.terminal.agent_osc_title() + } + + pub fn agent_osc_progress(&self) -> String { + self.terminal.agent_osc_progress() + } + pub fn recent_text(&self, lines: usize) -> String { self.terminal.recent_text(lines) } diff --git a/src/pane/agent_detection.rs b/src/pane/agent_detection.rs index e6afd368..d891c284 100644 --- a/src/pane/agent_detection.rs +++ b/src/pane/agent_detection.rs @@ -512,11 +512,22 @@ pub(super) fn decide_screen_detection_publish( } } +#[allow(dead_code)] // shim for tests; detection_update_for_publish_with_osc is the real path pub(super) fn detection_update_for_publish( agent: Option<Agent>, content: &str, process_exited: bool, ) -> Option<crate::detect::AgentDetection> { + detection_update_for_publish_with_osc(agent, content, "", "", process_exited) +} + +pub(super) fn detection_update_for_publish_with_osc( + agent: Option<Agent>, + content: &str, + osc_title: &str, + osc_progress: &str, + process_exited: bool, +) -> Option<crate::detect::AgentDetection> { if process_exited { return Some(crate::detect::AgentDetection { state: AgentState::Idle, @@ -527,7 +538,7 @@ pub(super) fn detection_update_for_publish( }); } - let detection = crate::detect::detect_agent(agent, content); + let detection = crate::detect::detect_agent_with_osc(agent, content, osc_title, osc_progress); (!detection.skip_state_update).then_some(detection) } diff --git a/src/pane/osc.rs b/src/pane/osc.rs index 23ab433a..e7b97a79 100644 --- a/src/pane/osc.rs +++ b/src/pane/osc.rs @@ -440,6 +440,263 @@ fn parse_cwd_osc(body: &[u8]) -> Option<PathBuf> { None } +/// Maximum retained string length for agent OSC title and progress payloads. +/// Title text is untrusted model output; cap it to bound memory and log size. +const AGENT_OSC_MAX_CHARS: usize = 256; + +/// Always-on tracker that retains the latest OSC 0/2 title and OSC 9 progress +/// payload emitted by the child process. Nothing here affects rendering; this +/// is pure passive capture for the detection engine (Stage C / Stage D). +/// +/// - `latest_title` — last OSC 0 or OSC 2 payload, sanitized. An empty +/// payload (e.g. `\x1b]0;\x07`) clears the stored value. +/// - `latest_progress` — last OSC 9 payload (the part after `9;`), stored +/// as-is after sanitization. E.g. `"4;3;"` or `"4;0;"`. +#[derive(Debug, Default)] +pub(super) struct AgentOscStateTracker { + state: Osc52ForwarderState, + body: Vec<u8>, + latest_title: Option<String>, + latest_progress: Option<String>, +} + +impl AgentOscStateTracker { + pub(super) fn observe(&mut self, bytes: &[u8]) { + for &byte in bytes { + match self.state { + Osc52ForwarderState::Ground => { + if byte == 0x1b { + self.state = Osc52ForwarderState::Escape; + } + } + Osc52ForwarderState::Escape => { + if byte == b']' { + self.body.clear(); + self.state = Osc52ForwarderState::OscBody; + } else if byte == 0x1b { + self.state = Osc52ForwarderState::Escape; + } else { + self.state = Osc52ForwarderState::Ground; + } + } + Osc52ForwarderState::OscBody => match byte { + 0x07 => { + self.finalize(); + self.state = Osc52ForwarderState::Ground; + } + 0x1b => self.state = Osc52ForwarderState::OscEscape, + _ => self.body.push(byte), + }, + Osc52ForwarderState::OscEscape => { + if byte == b'\\' { + self.finalize(); + self.state = Osc52ForwarderState::Ground; + } else { + self.body.push(0x1b); + self.body.push(byte); + self.state = Osc52ForwarderState::OscBody; + } + } + } + + if self.body.len() > 4096 { + self.body.clear(); + self.state = Osc52ForwarderState::Ground; + } + } + } + + fn finalize(&mut self) { + if let Some((command, payload)) = parse_agent_osc_body(&self.body) { + match command { + b"0" | b"2" => { + if payload.is_empty() { + self.latest_title = None; + } else { + self.latest_title = + Some(sanitize_agent_osc_string(payload, AGENT_OSC_MAX_CHARS)); + } + } + b"9" => { + self.latest_progress = + Some(sanitize_agent_osc_string(payload, AGENT_OSC_MAX_CHARS)); + } + _ => {} + } + } + self.body.clear(); + } + + /// Returns the latest retained OSC title, or `""` if none has been seen or + /// the last title was an empty clear. + #[allow(dead_code)] // used by terminal.rs; full call chain wired in Stage C + pub(super) fn latest_title(&self) -> &str { + self.latest_title.as_deref().unwrap_or("") + } + + /// Returns the latest retained OSC 9 progress payload, or `""` if none. + #[allow(dead_code)] // used by terminal.rs; full call chain wired in Stage C + pub(super) fn latest_progress(&self) -> &str { + self.latest_progress.as_deref().unwrap_or("") + } + + /// Drops the retained title and progress so a new foreground agent cannot + /// inherit OSC evidence emitted by a previous process. The in-flight parse + /// state is kept: a sequence spanning the agent change finalizes normally + /// and is attributed to the new agent. + pub(super) fn clear_retained(&mut self) { + self.latest_title = None; + self.latest_progress = None; + } +} + +/// Splits an OSC body at the first `;`, returning `(command, payload)`. +/// Returns `None` if there is no `;`. +fn parse_agent_osc_body(body: &[u8]) -> Option<(&[u8], &[u8])> { + let sep = body.iter().position(|&b| b == b';')?; + Some((&body[..sep], &body[sep + 1..])) +} + +fn sanitize_agent_osc_string(payload: &[u8], max_chars: usize) -> String { + let text = String::from_utf8_lossy(payload); + let mut out = String::new(); + for ch in text.chars().filter(|ch| !ch.is_control()).take(max_chars) { + out.push(ch); + } + out +} + +/// Reconstructs selected OSC sequences for local evidence capture while +/// debugging agent title/status behavior. This is intentionally passive: +/// nothing here affects terminal rendering or detection state. +#[derive(Debug)] +pub(super) struct OscDebugTracker { + enabled: bool, + state: Osc52ForwarderState, + body: Vec<u8>, + pending: Vec<OscDebugEvent>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct OscDebugEvent { + pub(super) command: String, + pub(super) payload: String, +} + +impl OscDebugTracker { + pub(super) fn from_env() -> Self { + Self { + enabled: osc_debug_enabled_from_env(), + state: Osc52ForwarderState::Ground, + body: Vec::new(), + pending: Vec::new(), + } + } + + pub(super) fn observe(&mut self, bytes: &[u8]) { + if !self.enabled { + return; + } + + for &byte in bytes { + match self.state { + Osc52ForwarderState::Ground => { + if byte == 0x1b { + self.state = Osc52ForwarderState::Escape; + } + } + Osc52ForwarderState::Escape => { + if byte == b']' { + self.body.clear(); + self.state = Osc52ForwarderState::OscBody; + } else if byte == 0x1b { + self.state = Osc52ForwarderState::Escape; + } else { + self.state = Osc52ForwarderState::Ground; + } + } + Osc52ForwarderState::OscBody => match byte { + 0x07 => { + self.finalize(); + self.state = Osc52ForwarderState::Ground; + } + 0x1b => self.state = Osc52ForwarderState::OscEscape, + _ => self.body.push(byte), + }, + Osc52ForwarderState::OscEscape => { + if byte == b'\\' { + self.finalize(); + self.state = Osc52ForwarderState::Ground; + } else { + self.body.push(0x1b); + self.body.push(byte); + self.state = Osc52ForwarderState::OscBody; + } + } + } + + if self.body.len() > 4096 { + self.body.clear(); + self.state = Osc52ForwarderState::Ground; + } + } + } + + fn finalize(&mut self) { + if let Some(event) = parse_osc_debug_event(&self.body) { + self.pending.push(event); + } + self.body.clear(); + } + + pub(super) fn drain_pending(&mut self) -> Vec<OscDebugEvent> { + std::mem::take(&mut self.pending) + } +} + +impl Default for OscDebugTracker { + fn default() -> Self { + Self::from_env() + } +} + +fn osc_debug_enabled_from_env() -> bool { + std::env::var("HERDR_DEBUG_OSC_EVIDENCE") + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(false) +} + +fn parse_osc_debug_event(body: &[u8]) -> Option<OscDebugEvent> { + let separator = body.iter().position(|byte| *byte == b';')?; + let command = &body[..separator]; + let payload = &body[separator + 1..]; + if !matches!(command, b"0" | b"2" | b"9" | b"21337") { + return None; + } + Some(OscDebugEvent { + command: std::str::from_utf8(command).ok()?.to_string(), + payload: sanitized_osc_debug_payload(payload), + }) +} + +fn sanitized_osc_debug_payload(payload: &[u8]) -> String { + const MAX_CHARS: usize = 512; + let text = String::from_utf8_lossy(payload); + let mut sanitized = String::new(); + for ch in text.chars().filter(|ch| !ch.is_control()).take(MAX_CHARS) { + sanitized.push(ch); + } + if text.chars().count() > MAX_CHARS { + sanitized.push_str("..."); + } + sanitized +} + fn parse_file_uri_cwd(uri: &str) -> Option<PathBuf> { let rest = uri.strip_prefix("file://")?; let path = if rest.starts_with('/') { @@ -724,6 +981,15 @@ mod tests { events.into_iter().map(|event| event.event).collect() } + fn enabled_osc_debug_tracker() -> OscDebugTracker { + OscDebugTracker { + enabled: true, + state: Osc52ForwarderState::Ground, + body: Vec::new(), + pending: Vec::new(), + } + } + #[test] fn default_color_tracker_detects_split_osc_11_sequences() { let mut tracker = DefaultColorOscTracker::default(); @@ -766,6 +1032,235 @@ mod tests { ); } + // ----------------------------------------------------------------------- + // AgentOscStateTracker tests + // ----------------------------------------------------------------------- + + #[test] + fn agent_osc_osc0_title_with_bel() { + let mut t = AgentOscStateTracker::default(); + t.observe("hello\x1b]0;braille title\x07world".as_bytes()); + assert_eq!(t.latest_title(), "braille title"); + assert_eq!(t.latest_progress(), ""); + } + + #[test] + fn agent_osc_osc2_title_with_st() { + let mut t = AgentOscStateTracker::default(); + t.observe("hello\x1b]2;static title\x1b\\world".as_bytes()); + assert_eq!(t.latest_title(), "static title"); + assert_eq!(t.latest_progress(), ""); + } + + #[test] + fn agent_osc_empty_osc0_clears_title() { + let mut t = AgentOscStateTracker::default(); + // First set a title. + t.observe(b"\x1b]0;some title\x07"); + assert_eq!(t.latest_title(), "some title"); + // Then clear it with an empty payload (Codex pattern). + t.observe(b"\x1b]0;\x07"); + assert_eq!(t.latest_title(), ""); + } + + #[test] + fn agent_osc_osc9_sets_progress_with_bel() { + let mut t = AgentOscStateTracker::default(); + t.observe(b"\x1b]9;4;3;\x07"); + assert_eq!(t.latest_progress(), "4;3;"); + assert_eq!(t.latest_title(), ""); + } + + #[test] + fn agent_osc_osc9_clear_progress_with_st() { + let mut t = AgentOscStateTracker::default(); + t.observe(b"\x1b]9;4;3;\x07"); + assert_eq!(t.latest_progress(), "4;3;"); + t.observe(b"\x1b]9;4;0;\x1b\\"); + assert_eq!(t.latest_progress(), "4;0;"); + } + + #[test] + fn agent_osc_split_sequence_across_chunks() { + let mut t = AgentOscStateTracker::default(); + t.observe(b"\x1b]9;4;3"); + assert_eq!(t.latest_progress(), ""); + t.observe(b";\x07"); + assert_eq!(t.latest_progress(), "4;3;"); + } + + #[test] + fn agent_osc_bel_and_st_terminators_both_work() { + let mut t = AgentOscStateTracker::default(); + t.observe(b"\x1b]0;title-bel\x07"); + assert_eq!(t.latest_title(), "title-bel"); + t.observe(b"\x1b]0;title-st\x1b\\"); + assert_eq!(t.latest_title(), "title-st"); + } + + #[test] + fn agent_osc_oversized_payload_is_discarded_and_recovers() { + let mut t = AgentOscStateTracker::default(); + // Set a title first. + t.observe(b"\x1b]0;before\x07"); + assert_eq!(t.latest_title(), "before"); + + // Feed an oversized OSC body (> 4096 bytes). + let mut oversized = Vec::from(b"\x1b]0;".as_slice()); + oversized.extend(std::iter::repeat_n(b'x', 4097)); + oversized.push(0x07); + t.observe(&oversized); + // The oversized body is dropped; the previously stored title is kept. + assert_eq!(t.latest_title(), "before"); + + // After recovery, subsequent valid sequences are captured normally. + t.observe(b"\x1b]0;after\x07"); + assert_eq!(t.latest_title(), "after"); + } + + #[test] + fn agent_osc_cap_length_is_respected() { + let mut t = AgentOscStateTracker::default(); + // Build a title of AGENT_OSC_MAX_CHARS + 50 ASCII chars. + let long_title: String = "a".repeat(AGENT_OSC_MAX_CHARS + 50); + let seq = format!("\x1b]0;{long_title}\x07"); + t.observe(seq.as_bytes()); + assert_eq!(t.latest_title().len(), AGENT_OSC_MAX_CHARS); + } + + #[test] + fn agent_osc_control_chars_stripped() { + let mut t = AgentOscStateTracker::default(); + t.observe(b"\x1b]0;before\x01after\x07"); + assert_eq!(t.latest_title(), "beforeafter"); + } + + #[test] + fn agent_osc_unrelated_osc_does_not_overwrite_title() { + let mut t = AgentOscStateTracker::default(); + t.observe(b"\x1b]0;my title\x07"); + // OSC 4 (palette color), OSC 52 (clipboard) — should not touch title/progress. + t.observe(b"\x1b]4;1;rgb:aa/bb/cc\x07"); + t.observe(b"\x1b]52;c;aGVsbG8=\x07"); + assert_eq!(t.latest_title(), "my title"); + assert_eq!(t.latest_progress(), ""); + } + + #[test] + fn agent_osc_interleaved_sequences() { + let mut t = AgentOscStateTracker::default(); + // OSC 0 title, then OSC 9 progress, then OSC 2 title update. + t.observe(b"\x1b]0;first\x07\x1b]9;4;3;\x07\x1b]2;second\x07"); + assert_eq!(t.latest_title(), "second"); + assert_eq!(t.latest_progress(), "4;3;"); + } + + #[test] + fn agent_osc_default_state_is_empty() { + let t = AgentOscStateTracker::default(); + assert_eq!(t.latest_title(), ""); + assert_eq!(t.latest_progress(), ""); + } + + // ----------------------------------------------------------------------- + // OscDebugTracker tests (existing) + // ----------------------------------------------------------------------- + + #[test] + fn osc_debug_tracker_detects_title_with_bel() { + let mut tracker = enabled_osc_debug_tracker(); + + tracker.observe("hello\x1b]0;✻ working title\x07world".as_bytes()); + + assert_eq!( + tracker.drain_pending(), + vec![OscDebugEvent { + command: "0".to_string(), + payload: "✻ working title".to_string(), + }] + ); + } + + #[test] + fn osc_debug_tracker_detects_title_with_st() { + let mut tracker = enabled_osc_debug_tracker(); + + tracker.observe("hello\x1b]2;static title\x1b\\world".as_bytes()); + + assert_eq!( + tracker.drain_pending(), + vec![OscDebugEvent { + command: "2".to_string(), + payload: "static title".to_string(), + }] + ); + } + + #[test] + fn osc_debug_tracker_detects_split_status_sequences() { + let mut tracker = enabled_osc_debug_tracker(); + + tracker.observe(b"\x1b]9;4;3"); + assert!(tracker.drain_pending().is_empty()); + tracker.observe(b"\x07\x1b]21337;status=working\x1b\\"); + + assert_eq!( + tracker.drain_pending(), + vec![ + OscDebugEvent { + command: "9".to_string(), + payload: "4;3".to_string(), + }, + OscDebugEvent { + command: "21337".to_string(), + payload: "status=working".to_string(), + }, + ] + ); + } + + #[test] + fn osc_debug_tracker_ignores_untracked_osc_commands() { + let mut tracker = enabled_osc_debug_tracker(); + + tracker.observe(b"\x1b]52;c;SGVsbG8=\x07\x1b]7;file:///tmp\x07"); + + assert!(tracker.drain_pending().is_empty()); + } + + #[test] + fn osc_debug_tracker_sanitizes_control_characters() { + let mut tracker = enabled_osc_debug_tracker(); + + tracker.observe(b"\x1b]0;before\x01after\x07"); + + assert_eq!( + tracker.drain_pending(), + vec![OscDebugEvent { + command: "0".to_string(), + payload: "beforeafter".to_string(), + }] + ); + } + + #[test] + fn osc_debug_tracker_recovers_after_oversized_payload() { + let mut tracker = enabled_osc_debug_tracker(); + let oversized = vec![b'a'; 4097]; + + tracker.observe(b"\x1b]0;"); + tracker.observe(&oversized); + tracker.observe(b"\x07\x1b]0;ok\x07"); + + assert_eq!( + tracker.drain_pending(), + vec![OscDebugEvent { + command: "0".to_string(), + payload: "ok".to_string(), + }] + ); + } + #[test] fn default_color_event_tracker_detects_queries_sets_and_resets() { let mut tracker = DefaultColorEventTracker::default(); diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index d697f76d..9caa3dba 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -23,8 +23,9 @@ use super::{ osc::{ contains_scrollback_clear_sequence, current_transient_default_color_owner, maybe_filter_primary_screen_scrollback_clear, restore_host_terminal_theme_if_needed, - write_host_terminal_theme, CwdOscTracker, DefaultColorEvent, DefaultColorEventTracker, - DefaultColorOscTracker, DefaultColorQuery, DefaultColorTrackedEvent, Osc52Forwarder, + write_host_terminal_theme, AgentOscStateTracker, CwdOscTracker, DefaultColorEvent, + DefaultColorEventTracker, DefaultColorOscTracker, DefaultColorQuery, + DefaultColorTrackedEvent, Osc52Forwarder, OscDebugTracker, }, xtgettcap::{XtgettcapQueryTracker, XtgettcapResponse}, }; @@ -125,6 +126,8 @@ pub(crate) struct GhosttyPaneCore { pub child_default_background_changed: bool, pub osc52_forwarder: Osc52Forwarder, pub cwd_osc_tracker: CwdOscTracker, + pub osc_debug_tracker: OscDebugTracker, + pub agent_osc_state: AgentOscStateTracker, pub xtgettcap_query_tracker: XtgettcapQueryTracker, } @@ -267,6 +270,21 @@ impl PaneTerminal { .maybe_restore_host_terminal_theme(pane_id, shell_pid) } + #[allow(dead_code)] // exposed for Stage C (detection loop wiring) + pub fn agent_osc_title(&self) -> String { + self.ghostty.agent_osc_title() + } + + #[allow(dead_code)] // exposed for Stage C (detection loop wiring) + pub fn agent_osc_progress(&self) -> String { + self.ghostty.agent_osc_progress() + } + + /// Clears retained OSC title/progress evidence on foreground agent change. + pub fn clear_agent_osc_state(&self) { + self.ghostty.clear_agent_osc_state() + } + pub fn keyboard_protocol( &self, fallback: crate::input::KeyboardProtocol, @@ -364,6 +382,8 @@ impl GhosttyPaneTerminal { child_default_background_changed: false, osc52_forwarder: Osc52Forwarder::default(), cwd_osc_tracker: CwdOscTracker::default(), + osc_debug_tracker: OscDebugTracker::default(), + agent_osc_state: AgentOscStateTracker::default(), xtgettcap_query_tracker: XtgettcapQueryTracker::default(), }), key_encoder: Mutex::new(key_encoder), @@ -417,6 +437,34 @@ impl GhosttyPaneTerminal { ) } + /// Returns the latest OSC 0/2 title retained for agent detection, or `""` + /// if no title has been seen or the last update was an empty clear. + #[allow(dead_code)] // exposed for Stage C (detection loop wiring) + pub fn agent_osc_title(&self) -> String { + self.core + .lock() + .map(|core| core.agent_osc_state.latest_title().to_owned()) + .unwrap_or_default() + } + + /// Returns the latest OSC 9 progress payload retained for agent detection, + /// or `""` if none has been seen. + #[allow(dead_code)] // exposed for Stage C (detection loop wiring) + pub fn agent_osc_progress(&self) -> String { + self.core + .lock() + .map(|core| core.agent_osc_state.latest_progress().to_owned()) + .unwrap_or_default() + } + + /// Clears retained OSC title/progress evidence when the pane's foreground + /// agent changes, so a new agent process starts from a blank OSC slate. + pub fn clear_agent_osc_state(&self) { + if let Ok(mut core) = self.core.lock() { + core.agent_osc_state.clear_retained(); + } + } + pub fn process_pty_bytes( &self, pane_id: PaneId, @@ -451,6 +499,16 @@ impl GhosttyPaneTerminal { let clipboard_writes = core.osc52_forwarder.drain_pending(); core.cwd_osc_tracker.observe(bytes); let reported_cwd = core.cwd_osc_tracker.drain_latest(); + core.osc_debug_tracker.observe(bytes); + for event in core.osc_debug_tracker.drain_pending() { + debug!( + pane = pane_id.raw(), + osc_command = %event.command, + osc_payload = ?event.payload, + "agent OSC evidence observed" + ); + } + core.agent_osc_state.observe(bytes); let alternate_screen = core .terminal diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 727e9929..ec830833 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -269,6 +269,14 @@ impl TerminalRuntime { self.0.detection_text() } + pub fn agent_osc_title(&self) -> String { + self.0.agent_osc_title() + } + + pub fn agent_osc_progress(&self) -> String { + self.0.agent_osc_progress() + } + pub fn recent_text(&self, lines: usize) -> String { self.0.recent_text(lines) } |
