Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions HERDR_DETECTION_ANALYSIS.md
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions crates/jcode-tui-permissions/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>() {
Expand Down Expand Up @@ -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:<state>` 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;
Expand Down
2 changes: 2 additions & 0 deletions crates/jcode-tui/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,8 @@ pub struct App {
pending_reload_reconnect_status: Option<PendingReloadReconnectStatus>,
// 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<String>,
// Batch progress (shown during batch tool execution)
Expand Down
1 change: 1 addition & 0 deletions crates/jcode-tui/src/tui/app/local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions crates/jcode-tui/src/tui/app/run_shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand Down
2 changes: 2 additions & 0 deletions crates/jcode-tui/src/tui/app/tui_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
141 changes: 141 additions & 0 deletions crates/jcode-tui/src/tui/app/tui_lifecycle_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:<state>` 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 ;<payload> 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<String> {
self.remote_session_id
.clone()
Expand Down Expand Up @@ -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<crate::provider::EventStream> {
Err(anyhow::anyhow!("mock"))
}
fn name(&self) -> &str {
"mock"
}
fn fork(&self) -> Arc<dyn Provider> {
Arc::new(Self)
}
}

fn create_test_app() -> App {
let provider: Arc<dyn Provider> = 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");
}
}
Loading