diff --git a/HERDR_DETECTION_ANALYSIS.md b/HERDR_DETECTION_ANALYSIS.md new file mode 100644 index 0000000000..3690be2772 --- /dev/null +++ b/HERDR_DETECTION_ANALYSIS.md @@ -0,0 +1,113 @@ +# Improving jcode Status Detection in herdr + +## Problem + +Herdr's `jcode-support` branch detects jcode's agent state using **only screen-scraping** (TOML manifest regex/contains rules). This is fragile because: + +1. **Spinner frames change** — `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏` are matched per-frame, but any visual change breaks detection +2. **Status text is version-dependent** — `"sending…"`, `"thinking…"`, `"running tool"` labels change between jcode releases +3. **False positives** — generic words like `"permission"`, `"allow"`, `"deny"` appear in conversation text +4. **No transcript viewer rule** — scrollback text can confuse state detection (other agents have `skip_state_update` rules) +5. **Stale text handling** — requires complex `bottom_non_empty_lines(N)` + `not` gate tuning + +## How reliable agents do it + +Every well-detected agent uses **OSC sequences** as the primary detection path: + +| Agent | OSC title rules | OSC progress | Screen-scrape fallback | +|-------|----------------|--------------|----------------------| +| codex | priority 1100 (spinner + "Action Required") | no | yes (lower priority) | +| claude | priority 1100 (braille prefix) | yes (`4;0`) | yes (lower priority) | +| opencode | JS integration plugin | JS plugin | yes (lower priority) | +| kilo | JS integration plugin | JS plugin | yes (lower priority) | +| **jcode** | **none** | **none** | **only path (fragile)** | + +Herdr captures two OSC channels from the terminal stream (`src/pane/osc.rs`): +- **OSC 0/2** (terminal title) → `osc_title` region in manifests +- **OSC 9** (progress) → `osc_progress` region in manifests + +jcode already emits OSC 0 via `crossterm::terminal::SetTitle` for the window title, but it only contains the session name — **not the processing status**. + +## Proposed solution: OSC 9 progress emission + +### Phase 1: jcode emits structured OSC 9 sequences + +When the TUI's `ProcessingStatus` changes, emit an OSC 9 progress sequence with a stable, version-independent payload: + +``` +Working: ESC ] 9 ;jcode:working BEL +Idle: ESC ] 9 ;jcode:idle BEL +Blocked: ESC ] 9 ;jcode:blocked BEL +``` + +Hook point: `update_terminal_title()` in `tui_lifecycle_runtime.rs` already runs on status changes. Add a sibling `emit_agent_status_osc()` call alongside it. + +The emission should happen: +- When `app.status` transitions (Sending/Connecting/Thinking/Streaming/RunningTool → working) +- When turn finishes (→ idle) +- When permission prompt opens (→ blocked) +- On every full redraw as a heartbeat (cheap, keeps herdr in sync) + +### Phase 2: Update herdr jcode.toml with OSC rules at high priority + +```toml +[[rules]] +id = "osc_progress_working" +state = "working" +priority = 1100 +region = "osc_progress" +visible_working = true +contains = ["jcode:working"] + +[[rules]] +id = "osc_progress_blocked" +state = "blocked" +priority = 1100 +region = "osc_progress" +visible_blocker = true +contains = ["jcode:blocked"] + +[[rules]] +id = "osc_progress_idle" +state = "idle" +priority = 1100 +region = "osc_progress" +visible_idle = true +contains = ["jcode:idle"] +``` + +These OSC rules win over all screen-scrape rules (current max priority is 300). Screen-scrape rules remain as fallback for environments where OSC is stripped (some terminal multiplexers). + +### Phase 3: Add transcript viewer skip rule + +Add a `skip_state_update` rule to detect when the user is scrolling through transcript history (matching claude/codex patterns): + +```toml +[[rules]] +id = "transcript_viewer" +state = "unknown" +priority = 1000 +region = "bottom_non_empty_lines(5)" +skip_state_update = true +# Match jcode scrollback indicators if any exist +``` + +## Benefits + +1. **Version-stable** — `jcode:working` string never changes even if UI text changes +2. **No false positives** — structured payload can't appear in conversation text +3. **No stale text issues** — OSC is a live signal, not screen-scraped +4. **Cheap** — one escape sequence per status change, negligible overhead +5. **Backward compatible** — screen-scrape rules remain as fallback +6. **Consistent with ecosystem** — matches codex/claude OSC-first approach + +## Implementation files + +### jcode side: +- `crates/jcode-tui/src/tui/app/tui_lifecycle_runtime.rs` — add `emit_agent_status_osc()` method +- `crates/jcode-tui/src/tui/app/run_shell.rs` — call it in the redraw loop +- `crates/jcode-tui/src/tui/app.rs` — call it on status transitions + +### herdr side: +- `src/detect/manifests/jcode.toml` — add OSC progress rules at priority 1100 +- `website/agent-detection/jcode.toml` — mirror the bundled manifest \ No newline at end of file diff --git a/crates/jcode-tui-permissions/src/lib.rs b/crates/jcode-tui-permissions/src/lib.rs index 520ebe9566..03df7452ee 100644 --- a/crates/jcode-tui-permissions/src/lib.rs +++ b/crates/jcode-tui-permissions/src/lib.rs @@ -511,6 +511,13 @@ impl PermissionsApp { anyhow::bail!("permissions viewer requires an interactive terminal"); } + // Emit OSC 9 progress with "jcode:blocked" so terminal multiplexers + // (herdr) detect that the user needs to act on a permission request. + // The main jcode TUI is paused while this viewer runs, so it cannot + // emit its own status. Emit on entry and on every redraw (deduped + // by the terminal: OSC 9 with the same payload is idempotent). + emit_osc9_status("blocked"); + let mut terminal = std::panic::catch_unwind(std::panic::AssertUnwindSafe(ratatui::init)) .map_err(|payload| { let msg = if let Some(s) = payload.downcast_ref::<&str>() { @@ -584,10 +591,24 @@ impl PermissionsApp { }; ratatui::restore(); + // Restore idle status when leaving the permissions viewer so herdr + // does not stay stuck in "blocked" after the user has acted. + emit_osc9_status("idle"); result } } +/// Emit an OSC 9 progress sequence with a `jcode:` payload. +/// +/// This is the same format the main jcode TUI emits via +/// `emit_agent_status_osc`, so terminal multiplexers (herdr) can detect +/// the permissions viewer as a "blocked" state requiring user action. +fn emit_osc9_status(state: &str) { + use std::io::Write as _; + let osc = format!("\x1b]9;jcode:{state}\x07"); + let _ = std::io::stdout().write_all(osc.as_bytes()); +} + fn detail_height(total: u16) -> u16 { let min_list = 5; let help = 1; diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index d5a0cff2f5..470af1b9a8 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -849,6 +849,8 @@ pub struct App { pending_reload_reconnect_status: Option, // Current status status: ProcessingStatus, + // Last OSC 9 status emitted to stdout, so we only re-emit on transitions. + last_osc_state: Option<&'static str>, // Subagent status (shown during Task tool execution) subagent_status: Option, // Batch progress (shown during batch tool execution) diff --git a/crates/jcode-tui/src/tui/app/local.rs b/crates/jcode-tui/src/tui/app/local.rs index b1b48152fb..037832f54f 100644 --- a/crates/jcode-tui/src/tui/app/local.rs +++ b/crates/jcode-tui/src/tui/app/local.rs @@ -502,6 +502,7 @@ pub(super) fn finish_turn(app: &mut App) { app.update_cost_impl(); app.is_processing = false; app.status = ProcessingStatus::Idle; + app.emit_agent_status_osc(); app.stream_message_ended = false; app.processing_started = None; app.interleave_message = None; diff --git a/crates/jcode-tui/src/tui/app/run_shell.rs b/crates/jcode-tui/src/tui/app/run_shell.rs index f2a118f1eb..6e9c2c4c0b 100644 --- a/crates/jcode-tui/src/tui/app/run_shell.rs +++ b/crates/jcode-tui/src/tui/app/run_shell.rs @@ -249,6 +249,11 @@ impl StatusSpinnerRenderer { input: crate::tui::ui::frame_input_attribution_snapshot(), }); self.last_frame = Some(completed_buffer); + + // Emit OSC 9 progress with current agent status so terminal + // multiplexers (herdr) can detect working/idle without screen-scraping. + app.emit_agent_status_osc(); + Ok(()) } diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index 71da4719e4..4cff47aead 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -402,6 +402,7 @@ impl App { remote_resume_activity: None, pending_reload_reconnect_status: None, status: ProcessingStatus::default(), + last_osc_state: None, subagent_status: None, batch_progress: None, processing_started: None, @@ -826,6 +827,7 @@ impl App { remote_resume_activity: None, pending_reload_reconnect_status: None, status: ProcessingStatus::default(), + last_osc_state: None, subagent_status: None, batch_progress: None, processing_started: None, diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle_runtime.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle_runtime.rs index 21506e4f76..6a8a2ca087 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle_runtime.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle_runtime.rs @@ -120,6 +120,50 @@ impl App { ); } + /// Compute the current agent status tag for OSC emission. + /// + /// Returns `Some("working"|"idle")` when the state has changed since the + /// last call, or `None` when suppressed (replay/title-off) or unchanged + /// (dedup). The OSC byte sequence is built from the returned value. + fn agent_osc_state(&mut self) -> Option<&'static str> { + if self.suppress_terminal_title_updates || self.is_replay { + return None; + } + let state: &'static str = if self.is_processing() { + "working" + } else { + "idle" + }; + if self.last_osc_state == Some(state) { + return None; + } + self.last_osc_state = Some(state); + Some(state) + } + + /// Emit an OSC 9 progress sequence carrying a stable agent-status tag. + /// + /// Terminal multiplexers and status-bar integrations (e.g. herdr) capture + /// OSC 9 payloads as a structured side-channel that does not depend on + /// screen-scraping. The payload format is `jcode:` so detection + /// manifests can match on a version-independent string. + /// + /// Only emits when the state has actually changed since the last call, + /// so it is safe to call on every redraw without flooding the terminal. + pub(super) fn emit_agent_status_osc(&mut self) { + let Some(state) = self.agent_osc_state() else { + return; + }; + let payload = format!("jcode:{state}"); + // OSC 9 ; BEL + // crossterm does not have a built-in OSC 9 command, so write the + // escape sequence directly. Writing to stdout is best-effort: if the + // terminal does not understand OSC 9 the bytes are silently ignored. + let osc = format!("\x1b]9;{payload}\x07"); + use std::io::Write as _; + let _ = std::io::stdout().write_all(osc.as_bytes()); + } + pub(super) fn reconnect_target_session_id(&self) -> Option { self.remote_session_id .clone() @@ -733,3 +777,100 @@ pub(super) fn handle_dev_command(app: &mut App, trimmed: &str) -> bool { false } + +#[cfg(test)] +mod tests { + use super::*; + use crate::provider::Provider; + use std::sync::Arc; + + struct MockProvider; + + #[async_trait::async_trait] + impl Provider for MockProvider { + async fn complete( + &self, + _messages: &[crate::message::Message], + _tools: &[crate::message::ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> anyhow::Result { + Err(anyhow::anyhow!("mock")) + } + fn name(&self) -> &str { + "mock" + } + fn fork(&self) -> Arc { + Arc::new(Self) + } + } + + fn create_test_app() -> App { + let provider: Arc = Arc::new(MockProvider); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let registry = rt.block_on(crate::tool::Registry::new(provider.clone())); + App::new_for_test_harness(provider, registry) + } + + #[test] + fn osc_state_idle_when_not_processing() { + let mut app = create_test_app(); + app.is_processing = false; + app.status = ProcessingStatus::Idle; + let state = app.agent_osc_state(); + assert_eq!(state, Some("idle")); + assert_eq!(app.last_osc_state, Some("idle")); + } + + #[test] + fn osc_state_working_when_processing() { + let mut app = create_test_app(); + app.is_processing = true; + app.status = ProcessingStatus::Streaming; + let state = app.agent_osc_state(); + assert_eq!(state, Some("working")); + assert_eq!(app.last_osc_state, Some("working")); + } + + #[test] + fn osc_state_dedup_skips_repeated_same_state() { + let mut app = create_test_app(); + app.is_processing = true; + app.status = ProcessingStatus::Streaming; + let first = app.agent_osc_state(); + let second = app.agent_osc_state(); + assert_eq!(first, Some("working")); + assert_eq!(second, None, "second call with same state should be deduped"); + } + + #[test] + fn osc_state_emits_on_transition() { + let mut app = create_test_app(); + app.is_processing = true; + app.status = ProcessingStatus::Streaming; + assert_eq!(app.agent_osc_state(), Some("working")); + app.is_processing = false; + app.status = ProcessingStatus::Idle; + assert_eq!(app.agent_osc_state(), Some("idle")); + app.is_processing = true; + app.status = ProcessingStatus::Thinking(Instant::now()); + assert_eq!(app.agent_osc_state(), Some("working")); + } + + #[test] + fn osc_state_suppressed_in_replay() { + let mut app = create_test_app(); + app.is_replay = true; + app.is_processing = true; + assert_eq!(app.agent_osc_state(), None, "replay mode should suppress OSC"); + assert_eq!(app.last_osc_state, None, "replay should not set last_osc_state"); + } + + #[test] + fn osc_state_suppressed_when_title_updates_off() { + let mut app = create_test_app(); + app.suppress_terminal_title_updates = true; + app.is_processing = true; + assert_eq!(app.agent_osc_state(), None, "suppressed title mode should not emit OSC"); + } +}