aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-10 19:23:13 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-10 19:23:13 +0300
commit82468a0e03360219898ebf3735bdf559182009f8 (patch)
tree0ee5213fe041a539f23e846f9148493528249512 /src
parent36a1b7f86444871f0c38ff36510a6e3c2eec48c6 (diff)
feat: add manifest-based detection engine
Diffstat (limited to 'src')
-rw-r--r--src/agent_resume.rs12
-rw-r--r--src/app/api/agents.rs12
-rw-r--r--src/cli/agent.rs121
-rw-r--r--src/detect/manifest.rs68
-rw-r--r--src/detect/manifest/tests.rs204
-rw-r--r--src/detect/manifest_update.rs24
-rw-r--r--src/detect/manifests/claude.toml152
-rw-r--r--src/detect/manifests/codex.toml85
-rw-r--r--src/detect/mod.rs21
-rw-r--r--src/integration/assets/kimi/herdr-agent-state.ps121
-rw-r--r--src/integration/assets/kimi/herdr-agent-state.sh56
-rw-r--r--src/integration/mod.rs21
-rw-r--r--src/pane.rs48
-rw-r--r--src/pane/agent_detection.rs13
-rw-r--r--src/pane/osc.rs495
-rw-r--r--src/pane/terminal.rs62
-rw-r--r--src/terminal/runtime.rs8
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, &current, 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, &current, 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, &current, 1).unwrap();
let result = process_agent_manifest(Agent::Codex, &current, 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)
}