aboutsummaryrefslogtreecommitdiff
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
parent36a1b7f86444871f0c38ff36510a6e3c2eec48c6 (diff)
feat: add manifest-based detection engine
-rw-r--r--docs/next/CHANGELOG.md2
-rw-r--r--docs/next/website/src/content/docs/agents.mdx6
-rw-r--r--docs/next/website/src/content/docs/cli-reference.mdx6
-rw-r--r--docs/next/website/src/content/docs/integrations.mdx8
-rw-r--r--docs/next/website/src/content/docs/session-state.mdx2
-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
22 files changed, 1169 insertions, 278 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index 3060e328..17e0f2e6 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -24,7 +24,7 @@
- Added native Windows beta documentation, `install.ps1`, preview Windows release assets, update-channel wiring, and platform capability tracking for ConPTY panes, semantic client input, Windows agent discovery, known partial cwd behavior, and unsupported Unix-only features such as live handoff, direct terminal attach, and `herdr --remote` from the Windows binary.
### Changed
-- OpenCode installed with the current Herdr plugin now reports lifecycle state directly instead of relying on screen manifest detection. Droid, Kimi Code CLI, and Qoder CLI now report native session identity while leaving lifecycle state to screen manifest detection.
+- OpenCode installed with the current Herdr plugin now reports lifecycle state directly instead of relying on screen manifest detection. Kimi Code CLI `0.14.0` or newer now reports full lifecycle state through hooks, including interrupts. Droid and Qoder CLI now report native session identity while leaving lifecycle state to screen manifest detection.
## [0.6.8] - 2026-06-04
diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx
index 2ccc6154..e6319704 100644
--- a/docs/next/website/src/content/docs/agents.mdx
+++ b/docs/next/website/src/content/docs/agents.mdx
@@ -14,7 +14,7 @@ Automatic detection works out of the box for common coding agents. The important
| Pi | lifecycle hooks when installed; otherwise screen manifest | state and session |
| OMP | lifecycle hooks when installed | state |
| GitHub Copilot CLI | screen manifest | session |
-| Kimi Code CLI | screen manifest | session |
+| Kimi Code CLI | lifecycle hooks when installed; otherwise screen manifest | state and session |
| Hermes Agent | lifecycle hooks when installed; otherwise screen manifest | state and session |
| Qoder CLI | screen manifest | session |
| Droid | screen manifest | session |
@@ -36,11 +36,11 @@ Herdr first detects the foreground process in each pane. After that, each pane h
For agents with complete lifecycle hooks, the integration is authoritative when it is installed and actively reporting for the running pane. Herdr uses those hook reports for `idle`, `working`, `blocked`, and session identity. It does not also run screen manifest fallback for that same lifecycle authority. This avoids two competing sources of truth.
-For agents without complete lifecycle hooks, Herdr identifies the foreground process and reads the live bottom-buffer screen snapshot. It evaluates bundled TOML manifests against that snapshot to classify `idle`, `working`, and `blocked`. PTY bytes are terminal I/O evidence only; they do not publish `working`, veto `blocked`, or decide `idle`.
+For agents without complete lifecycle hooks, Herdr identifies the foreground process and reads the live bottom-buffer screen snapshot. It evaluates TOML manifests against that snapshot to classify `idle`, `working`, and `blocked`. For agents that emit them, manifests can also match terminal title and progress (OSC) sequences as detection evidence; when that evidence is absent, screen rules carry detection on their own.
The screen snapshot comes from the recent bottom of the pane buffer, not the scrolled viewport. If you scroll back in Herdr, detection still follows the live agent UI at the bottom.
-Claude Code, Codex, GitHub Copilot CLI, Droid, Kimi Code CLI, Qoder CLI, and Cursor Agent CLI integrations are intentionally not lifecycle authorities. They provide native session identity for restore, but their hooks do not cover the whole lifecycle. They can miss permission approval results, escape interrupts, or other transitions. For those agents, Herdr still uses screen manifest detection.
+Claude Code, Codex, GitHub Copilot CLI, Droid, Qoder CLI, and Cursor Agent CLI integrations are intentionally not lifecycle authorities. They provide native session identity for restore, but their hooks do not cover the whole lifecycle. They can miss permission approval results, escape interrupts, or other transitions. For those agents, Herdr still uses screen manifest detection.
## Blocked state
diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx
index 5fe28cd6..5ed9ce8f 100644
--- a/docs/next/website/src/content/docs/cli-reference.mdx
+++ b/docs/next/website/src/content/docs/cli-reference.mdx
@@ -189,15 +189,15 @@ herdr agent focus <target>
herdr agent wait <target> --status <idle|working|blocked|unknown> [--timeout MS]
herdr agent attach <target> [--takeover]
herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>
-herdr agent explain <target> [--json]
-herdr agent explain --file PATH --agent LABEL [--json]
+herdr agent explain <target> [--json|--verbose]
+herdr agent explain --file PATH --agent LABEL [--json|--verbose]
```
Agent targets can be terminal IDs, unique agent names, detected or reported agent labels, or legacy pane IDs. Names and labels are agent identities. Terminal IDs and legacy pane IDs are low-level escape hatches.
`agent read` reads the resolved terminal stream. `agent send` writes literal text to that stream. `agent get`, `agent focus`, `agent wait`, and `agent attach` require the resolved terminal to have agent identity. `agent rename` can assign that identity.
-`agent explain` asks the running server to classify the same bottom-buffer detection snapshot used by screen detection, so live output reflects the server's active manifest cache. Because this uses the `agent.explain` socket method, restart or hand off to an updated server after upgrading Herdr before using live explain. Use `--file PATH --agent LABEL` to explain a saved fixture locally instead. The output includes whether screen detection was skipped by a full lifecycle authority, the manifest source and version, cached remote version, local override shadowing, remote update status, matched rule, visible evidence flags, matcher and region evidence for evaluated rules, skipped-update reason, and idle fallback reason. Add `--json` for issue reports or tests.
+`agent explain` asks the running server to classify the same bottom-buffer detection snapshot used by screen detection, so live output reflects the server's active manifest cache. Because this uses the `agent.explain` socket method, restart or hand off to an updated server after upgrading Herdr before using live explain. Use `--file PATH --agent LABEL` to explain a saved fixture locally instead. The default output shows the agent, final state, manifest source and version, matched rule with its region evidence, and any fallback, skip, or warning reasons. Add `--verbose` for visible evidence flags, cached remote version, local override shadowing, remote update status, and the full evaluated-rules list with matcher and region evidence. Add `--json` for issue reports or tests.
Use `pane send-text`, `pane send-keys`, `pane run`, and `terminal attach` for ordinary terminals, servers, tests, shells, or low-level terminal control. Use `pane run` when you want to submit a command with Enter.
diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx
index 565a3c04..d4181e35 100644
--- a/docs/next/website/src/content/docs/integrations.mdx
+++ b/docs/next/website/src/content/docs/integrations.mdx
@@ -49,14 +49,14 @@ Herdr uses integrations in two different ways:
| Integration type | Agents | Effect |
| --- | --- | --- |
-| Lifecycle authority | Pi, OMP, OpenCode, Kilo Code CLI, Hermes Agent | When installed and actively reporting for the pane, hook or plugin events author `idle`, `working`, and `blocked`. Herdr does not also use screen manifest fallback for that same lifecycle authority. |
-| Session identity | Claude Code, Codex, GitHub Copilot CLI, Droid, Kimi Code CLI, Qoder CLI, Cursor Agent CLI | The integration reports native session references for restore. State still comes from Herdr's screen manifest detection. |
+| Lifecycle authority | Pi, OMP, Kimi Code CLI, OpenCode, Kilo Code CLI, Hermes Agent | When installed and actively reporting for the pane, hook or plugin events author `idle`, `working`, and `blocked`. Herdr does not also use screen manifest fallback for that same lifecycle authority. |
+| Session identity | Claude Code, Codex, GitHub Copilot CLI, Droid, Qoder CLI, Cursor Agent CLI | The integration reports native session references for restore. State still comes from Herdr's screen manifest detection. |
Custom socket integrations can also report state when they define state that is not visible in the native terminal UI.
Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Droid, Kimi Code CLI, Qoder CLI, Cursor Agent CLI, GitHub Copilot CLI, Pi, Hermes Agent, OpenCode, and Kilo Code CLI panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it.
-Native session restore requires current Herdr integrations: Pi integration version `2`, Claude Code version `5`, Codex version `5`, GitHub Copilot CLI version `2`, Droid version `2`, Kimi Code CLI version `2`, Qoder CLI version `2`, Cursor Agent CLI version `1`, OpenCode version `5`, Kilo Code CLI version `1`, or Hermes Agent version `2`. OMP integration version `2` reports agent state only. Check installed versions with `herdr integration status`.
+Native session restore requires current Herdr integrations: Pi integration version `2`, Claude Code version `5`, Codex version `5`, GitHub Copilot CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Cursor Agent CLI version `1`, OpenCode version `5`, Kilo Code CLI version `1`, or Hermes Agent version `2`. OMP integration version `2` reports agent state only. Check installed versions with `herdr integration status`.
## Pi
@@ -138,7 +138,7 @@ Install the Kimi Code CLI hook:
herdr integration install kimi
```
-The hook reports Kimi session identity to Herdr for native restore. Lifecycle state still comes from Herdr's screen manifest detection because Kimi hooks do not cover every lifecycle transition. It requires Kimi Code CLI `0.8.0` or newer.
+The hook reports Kimi session identity and lifecycle state to Herdr for native restore and authoritative `idle`, `working`, and `blocked` status. It requires Kimi Code CLI `0.14.0` or newer.
Herdr uses `~/.kimi-code` by default, or `KIMI_CODE_HOME` when set. The Kimi Code config directory must already exist. Install writes `hooks/herdr-agent-state.sh` and appends Herdr-managed `[[hooks]]` entries to `config.toml`. Uninstall removes the Herdr-managed config block and deletes the hook script.
diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx
index e88c59bf..e317caad 100644
--- a/docs/next/website/src/content/docs/session-state.mdx
+++ b/docs/next/website/src/content/docs/session-state.mdx
@@ -61,7 +61,7 @@ Native session restore requires these Herdr integration versions or newer:
| Cursor Agent CLI | `1` | `cursor-agent --resume <id>` |
| GitHub Copilot CLI | `2` | `copilot --resume=<id>` |
| Droid | `2` | `droid --resume <id>` |
-| Kimi Code CLI | `2` | `kimi --session <id>` |
+| Kimi Code CLI | `3` | `kimi --session <id>` |
| Qoder CLI | `2` | `qodercli --resume <id>` |
| OpenCode | `5` | `opencode --session <id>` |
| Kilo Code CLI | `1` | `kilo --session <id>` |
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)
}