aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/agent_resume.rs7
-rw-r--r--src/api/mod.rs1
-rw-r--r--src/api/schema.rs34
-rw-r--r--src/api/server.rs1
-rw-r--r--src/app/actions.rs119
-rw-r--r--src/app/api.rs3
-rw-r--r--src/app/api/panes.rs32
-rw-r--r--src/detect/agents/claude_code.rs64
-rw-r--r--src/detect/agents/opencode.rs31
-rw-r--r--src/detect/mod.rs102
-rw-r--r--src/events.rs8
-rw-r--r--src/integration/assets/claude/herdr-agent-state.sh27
-rw-r--r--src/integration/assets/codex/herdr-agent-state.sh23
-rw-r--r--src/integration/assets/opencode/herdr-agent-state.js74
-rw-r--r--src/integration/mod.rs223
-rw-r--r--src/logging.rs1
-rw-r--r--src/terminal/state.rs28
17 files changed, 498 insertions, 280 deletions
diff --git a/src/agent_resume.rs b/src/agent_resume.rs
index 269b74de..bfd72d7d 100644
--- a/src/agent_resume.rs
+++ b/src/agent_resume.rs
@@ -69,6 +69,13 @@ pub fn session_ref_from_report(
agent_session_id.and_then(AgentSessionRef::id)
}
+pub fn is_reserved_native_state_source(source: &str, agent: &str) -> bool {
+ matches!(
+ (source, agent),
+ ("herdr:claude", "claude") | ("herdr:codex", "codex") | ("herdr:opencode", "opencode")
+ )
+}
+
pub fn session_ref_from_snapshot(
source: &str,
agent: &str,
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 67964e83..7fd16fda 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -39,6 +39,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool {
| Method::PaneSplit(_)
| Method::PaneRename(_)
| Method::PaneReportAgent(_)
+ | Method::PaneReportAgentSession(_)
| Method::PaneReportMetadata(_)
| Method::PaneClearAgentAuthority(_)
| Method::PaneReleaseAgent(_)
diff --git a/src/api/schema.rs b/src/api/schema.rs
index 794f6db4..483d4842 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -84,6 +84,8 @@ pub enum Method {
PaneRead(PaneReadParams),
#[serde(rename = "pane.report_agent")]
PaneReportAgent(PaneReportAgentParams),
+ #[serde(rename = "pane.report_agent_session")]
+ PaneReportAgentSession(PaneReportAgentSessionParams),
#[serde(rename = "pane.report_metadata")]
PaneReportMetadata(PaneReportMetadataParams),
#[serde(rename = "pane.clear_agent_authority")]
@@ -354,6 +356,19 @@ pub struct PaneReportAgentParams {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneReportAgentSessionParams {
+ pub pane_id: String,
+ pub source: String,
+ pub agent: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub seq: Option<u64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub agent_session_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub agent_session_path: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneReportMetadataParams {
pub pane_id: String,
pub source: String,
@@ -1073,6 +1088,25 @@ mod tests {
}
#[test]
+ fn request_round_trips_for_pane_report_agent_session() {
+ let request = Request {
+ id: "req_session".into(),
+ method: Method::PaneReportAgentSession(PaneReportAgentSessionParams {
+ pane_id: "1-1".into(),
+ source: "herdr:claude".into(),
+ agent: "claude".into(),
+ seq: Some(42),
+ agent_session_id: Some("claude-session".into()),
+ agent_session_path: None,
+ }),
+ };
+
+ let json = serde_json::to_string(&request).unwrap();
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, request);
+ }
+
+ #[test]
fn request_round_trips_for_pane_report_metadata() {
let request = Request {
id: "req_metadata".into(),
diff --git a/src/api/server.rs b/src/api/server.rs
index cd130e47..8b4362fb 100644
--- a/src/api/server.rs
+++ b/src/api/server.rs
@@ -303,6 +303,7 @@ fn api_method_name(method: &Method) -> &'static str {
Method::PaneSendInput(_) => "pane.send_input",
Method::PaneRead(_) => "pane.read",
Method::PaneReportAgent(_) => "pane.report_agent",
+ Method::PaneReportAgentSession(_) => "pane.report_agent_session",
Method::PaneReportMetadata(_) => "pane.report_metadata",
Method::PaneClearAgentAuthority(_) => "pane.clear_agent_authority",
Method::PaneReleaseAgent(_) => "pane.release_agent",
diff --git a/src/app/actions.rs b/src/app/actions.rs
index f6c76a36..95e634be 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -2041,17 +2041,38 @@ impl AppState {
custom_status,
seq,
session_ref,
+ } => {
+ if crate::agent_resume::is_reserved_native_state_source(&source, &agent_label) {
+ self.update_terminal_state(pane_id, |terminal| {
+ terminal.set_agent_session_ref(source, agent_label, session_ref, seq)
+ })
+ .into_iter()
+ .collect()
+ } else {
+ self.update_terminal_state(pane_id, |terminal| {
+ terminal.set_hook_authority_with_session_ref(
+ source,
+ agent_label,
+ state,
+ message,
+ custom_status,
+ session_ref,
+ seq,
+ )
+ })
+ .into_iter()
+ .collect()
+ }
+ }
+ AppEvent::AgentSessionReported {
+ pane_id,
+ source,
+ agent_label,
+ seq,
+ session_ref,
} => self
.update_terminal_state(pane_id, |terminal| {
- terminal.set_hook_authority_with_session_ref(
- source,
- agent_label,
- state,
- message,
- custom_status,
- session_ref,
- seq,
- )
+ terminal.set_agent_session_ref(source, agent_label, session_ref, seq)
})
.into_iter()
.collect(),
@@ -2106,12 +2127,17 @@ impl AppState {
agent_label,
seq,
..
- } => self
- .update_terminal_state(pane_id, |terminal| {
- terminal.release_agent_with_mutation(&source, &agent_label, seq)
- })
- .into_iter()
- .collect(),
+ } => {
+ if crate::agent_resume::is_reserved_native_state_source(&source, &agent_label) {
+ Vec::new()
+ } else {
+ self.update_terminal_state(pane_id, |terminal| {
+ terminal.release_agent_with_mutation(&source, &agent_label, seq)
+ })
+ .into_iter()
+ .collect()
+ }
+ }
// Intercepted in App::handle_internal_event before reaching this
// dispatch; never touches AppState.
AppEvent::ClipboardWrite { .. } => Vec::new(),
@@ -3603,20 +3629,20 @@ mod tests {
}
#[test]
- fn visible_idle_waits_before_overriding_claude_hook_working() {
- let mut state = app_with_workspaces(&["active", "background"]);
+ fn reserved_native_state_report_does_not_override_screen_state() {
+ let mut state = app_with_workspaces(&["active"]);
state.active = Some(0);
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
- let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap();
- let bg_terminal_id = state.workspaces[1]
+ let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
+ let terminal_id = state.workspaces[0]
.panes
- .get(&bg_pane_id)
+ .get(&pane_id)
.unwrap()
.attached_terminal_id
.clone();
state.handle_app_event(AppEvent::StateChanged {
- pane_id: bg_pane_id,
+ pane_id,
agent: Some(Agent::Claude),
state: AgentState::Working,
visible_blocker: false,
@@ -3626,17 +3652,22 @@ mod tests {
observed_at: std::time::Instant::now(),
});
state.handle_app_event(AppEvent::HookStateReported {
- pane_id: bg_pane_id,
+ pane_id,
source: "herdr:claude".into(),
agent_label: "claude".into(),
- state: AgentState::Working,
+ state: AgentState::Blocked,
message: None,
custom_status: None,
seq: Some(1),
- session_ref: None,
+ session_ref: crate::agent_resume::AgentSessionRef::id("claude-session"),
});
+ let terminal = state.terminals.get(&terminal_id).unwrap();
+ assert_eq!(terminal.state, AgentState::Working);
+ assert!(terminal.hook_authority.is_none());
+ assert!(terminal.persisted_agent_session.is_some());
+
state.handle_app_event(AppEvent::StateChanged {
- pane_id: bg_pane_id,
+ pane_id,
agent: Some(Agent::Claude),
state: AgentState::Idle,
visible_blocker: false,
@@ -3646,12 +3677,46 @@ mod tests {
observed_at: std::time::Instant::now(),
});
- let terminal = state.terminals.get(&bg_terminal_id).unwrap();
- assert_eq!(terminal.state, AgentState::Working);
+ let terminal = state.terminals.get(&terminal_id).unwrap();
+ assert_eq!(terminal.state, AgentState::Idle);
assert!(state.toast.is_none());
}
#[test]
+ fn reserved_native_release_report_does_not_clear_screen_state() {
+ let mut state = app_with_workspaces(&["active"]);
+ let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
+ let terminal_id = state.workspaces[0]
+ .panes
+ .get(&pane_id)
+ .unwrap()
+ .attached_terminal_id
+ .clone();
+
+ state.handle_app_event(AppEvent::StateChanged {
+ pane_id,
+ agent: Some(Agent::Claude),
+ state: AgentState::Working,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: true,
+ process_exited: false,
+ observed_at: std::time::Instant::now(),
+ });
+ state.handle_app_event(AppEvent::HookAgentReleased {
+ pane_id,
+ source: "herdr:claude".into(),
+ agent_label: "claude".into(),
+ known_agent: Some(Agent::Claude),
+ seq: Some(1),
+ });
+
+ let terminal = state.terminals.get(&terminal_id).unwrap();
+ assert_eq!(terminal.state, AgentState::Working);
+ assert_eq!(terminal.detected_agent, Some(Agent::Claude));
+ }
+
+ #[test]
fn hidden_session_ref_only_update_marks_session_dirty_without_visible_update() {
let mut state = app_with_workspaces(&["active"]);
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
diff --git a/src/app/api.rs b/src/app/api.rs
index 1442012b..733fb8b8 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -562,6 +562,9 @@ impl App {
Method::PaneReportAgent(params) => {
return self.handle_pane_report_agent(request.id, params);
}
+ Method::PaneReportAgentSession(params) => {
+ return self.handle_pane_report_agent_session(request.id, params);
+ }
Method::PaneReportMetadata(params) => {
return self.handle_pane_report_metadata(request.id, params);
}
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index 0902ac39..d027f732 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -3,8 +3,9 @@ use bytes::Bytes;
use crate::api::schema::{
EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneListParams,
PaneReadParams, PaneReadResult, PaneReleaseAgentParams, PaneRenameParams,
- PaneReportAgentParams, PaneReportMetadataParams, PaneSendInputParams, PaneSendKeysParams,
- PaneSendTextParams, PaneSplitParams, PaneTarget, ReadFormat, ReadSource, ResponseResult,
+ PaneReportAgentParams, PaneReportAgentSessionParams, PaneReportMetadataParams,
+ PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneTarget,
+ ReadFormat, ReadSource, ResponseResult,
};
use crate::app::{App, Mode};
@@ -200,6 +201,33 @@ impl App {
encode_success(id, ResponseResult::Ok {})
}
+ pub(super) fn handle_pane_report_agent_session(
+ &mut self,
+ id: String,
+ params: PaneReportAgentSessionParams,
+ ) -> String {
+ let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let Some(agent_label) = normalize_reported_agent_label(&params.agent) else {
+ return invalid_agent(id);
+ };
+ self.handle_internal_event(crate::events::AppEvent::AgentSessionReported {
+ pane_id,
+ session_ref: crate::agent_resume::session_ref_from_report(
+ &params.source,
+ &agent_label,
+ params.agent_session_id,
+ params.agent_session_path,
+ ),
+ source: params.source,
+ agent_label,
+ seq: params.seq,
+ });
+
+ encode_success(id, ResponseResult::Ok {})
+ }
+
pub(super) fn handle_pane_report_metadata(
&mut self,
id: String,
diff --git a/src/detect/agents/claude_code.rs b/src/detect/agents/claude_code.rs
index e6633eda..819dadf4 100644
--- a/src/detect/agents/claude_code.rs
+++ b/src/detect/agents/claude_code.rs
@@ -23,30 +23,35 @@ pub(super) fn detect(content: &str) -> AgentState {
return AgentState::Idle;
}
- // --- Blocked detection (full content including prompt box) ---
-
- if has_claude_blocked_prompt(content, &lower) {
+ if has_live_blocked_form(content) {
return AgentState::Blocked;
}
- // --- Working detection (content above the prompt box) ---
-
if has_working_chrome(content) {
return AgentState::Working;
}
+ if !has_prompt_box(content) && has_claude_blocked_prompt(content, &lower) {
+ return AgentState::Blocked;
+ }
+
+ if has_prompt_box(content) {
+ return AgentState::Idle;
+ }
+
AgentState::Idle
}
pub(super) fn has_visible_blocker(content: &str) -> bool {
let lower = content.to_lowercase();
- lower.contains("do you want to proceed?")
- && has_claude_yes_no_choice(content)
- && (lower.contains("bash command")
- || lower.contains("bash(")
- || lower.contains("contains expansion")
- || lower.contains("tab to amend")
- || lower.contains("ctrl+e to explain"))
+ has_live_blocked_form(content)
+ || lower.contains("do you want to proceed?")
+ && has_claude_yes_no_choice(content)
+ && (lower.contains("bash command")
+ || lower.contains("bash(")
+ || lower.contains("contains expansion")
+ || lower.contains("tab to amend")
+ || lower.contains("ctrl+e to explain"))
}
pub(super) fn has_working_chrome(content: &str) -> bool {
@@ -54,6 +59,7 @@ pub(super) fn has_working_chrome(content: &str) -> bool {
let above_lower = above.to_lowercase();
above_lower.contains("esc to interrupt")
|| above_lower.contains("ctrl+c to interrupt")
+ || has_background_agent_wait(above)
|| has_spinner_activity(above)
}
@@ -86,6 +92,26 @@ fn has_claude_blocked_prompt(content: &str, lower_content: &str) -> bool {
|| (has_selection_prompt(content) && has_claude_yes_no_choice(content))
}
+fn has_live_blocked_form(content: &str) -> bool {
+ let region = content_after_last_horizontal_rule(content);
+ region.lines().any(|line| {
+ let lower = line.to_lowercase();
+ lower.contains("enter to select")
+ && lower.contains("esc to cancel")
+ && (lower.contains("tab/arrow keys to navigate")
+ || lower.contains("arrow keys to navigate"))
+ })
+}
+
+fn has_background_agent_wait(content_above_prompt: &str) -> bool {
+ content_above_prompt.lines().rev().take(8).any(|line| {
+ let lower = line.to_lowercase();
+ lower.contains("waiting for")
+ && lower.contains("background agent")
+ && lower.contains("to finish")
+ })
+}
+
fn has_claude_yes_no_choice(content: &str) -> bool {
content.lines().any(|line| {
let trimmed = line
@@ -140,6 +166,20 @@ pub(in crate::detect) fn content_above_prompt_box(content: &str) -> &str {
content
}
+fn content_after_last_horizontal_rule(content: &str) -> &str {
+ let mut last_rule_end = 0usize;
+ let mut offset = 0usize;
+ for line in content.lines() {
+ let next_offset = offset + line.len() + 1;
+ if is_horizontal_rule(line) {
+ last_rule_end = next_offset.min(content.len());
+ }
+ offset = next_offset;
+ }
+
+ &content[last_rule_end..]
+}
+
fn claude_prompt_box_top_border_index(lines: &[&str]) -> Option<usize> {
let mut border_count = 0;
diff --git a/src/detect/agents/opencode.rs b/src/detect/agents/opencode.rs
index 5badcad9..219f69ed 100644
--- a/src/detect/agents/opencode.rs
+++ b/src/detect/agents/opencode.rs
@@ -7,7 +7,10 @@ pub(super) fn detect(content: &str) -> AgentState {
}
// Working
- if has_interrupt_pattern(&content.to_lowercase()) {
+ if has_interrupt_pattern(&content.to_lowercase())
+ || has_opencode_interrupt_footer(content)
+ || has_opencode_progress_run(content)
+ {
return AgentState::Working;
}
@@ -23,3 +26,29 @@ fn has_opencode_question_prompt(content: &str) -> bool {
lower.contains("esc dismiss") && has_enter_action && has_question_nav
}
+
+fn has_opencode_interrupt_footer(content: &str) -> bool {
+ content.lines().any(|line| {
+ let lower = line.to_lowercase();
+ if !(lower.contains("esc interrupt") || lower.contains("esc again to interrupt")) {
+ return false;
+ }
+
+ lower.contains("opencode")
+ })
+}
+
+fn has_opencode_progress_run(line: &str) -> bool {
+ let mut run = 0usize;
+ for ch in line.chars() {
+ if matches!(ch, '■' | '⬝') {
+ run += 1;
+ if run >= 4 {
+ return true;
+ }
+ } else {
+ run = 0;
+ }
+ }
+ false
+}
diff --git a/src/detect/mod.rs b/src/detect/mod.rs
index e5f84628..ff27892b 100644
--- a/src/detect/mod.rs
+++ b/src/detect/mod.rs
@@ -268,11 +268,11 @@ fn detect_kilo(content: &str) -> AgentState {
// Shared helpers
// ---------------------------------------------------------------------------
-/// Check for "do you want"/"would you like" followed by "yes" or "❯"
+/// Check for action confirmations followed by "yes" or "❯".
fn has_confirmation_prompt(lower_content: &str) -> bool {
if let Some(pos) = lower_content
- .find("do you want")
- .or_else(|| lower_content.find("would you like"))
+ .find("do you want to")
+ .or_else(|| lower_content.find("would you like to"))
{
let after = &lower_content[pos..];
return after.contains("yes") || after.contains('❯');
@@ -990,6 +990,26 @@ mod tests {
}
#[test]
+ fn claude_question_form_selected_top_is_visible_blocker() {
+ let screen = "❯ ask again\n─────────────────────────────────────────────────────────────────────────────────────────\n← ☐ Subject ☐ Tone ✔ Submit →\n\nWhat should I ask you about?\n\n❯ 1. Today\n Your current plan or priority.\n 2. Project\n A codebase, feature, bug, or PR.\n 3. Preference\n How you want me to work with you.\n 4. Random\n A casual question with no work context.\n 5. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────\n 6. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel";
+ let detection = detect_agent(Some(Agent::Claude), screen);
+
+ assert_eq!(detection.state, AgentState::Blocked);
+ assert!(detection.visible_blocker);
+ assert!(!detection.visible_idle);
+ }
+
+ #[test]
+ fn claude_question_form_selected_bottom_is_visible_blocker() {
+ let screen = "❯ ask again\n─────────────────────────────────────────────────────────────────────────────────────────\n← ☐ Subject ☐ Tone ✔ Submit →\n\nWhat should I ask you about?\n\n 1. Today\n Your current plan or priority.\n 2. Project\n A codebase, feature, bug, or PR.\n 3. Preference\n How you want me to work with you.\n 4. Random\n A casual question with no work context.\n 5. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ 6. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · ctrl+g to edit in Zed · Esc to cancel";
+ let detection = detect_agent(Some(Agent::Claude), screen);
+
+ assert_eq!(detection.state, AgentState::Blocked);
+ assert!(detection.visible_blocker);
+ assert!(!detection.visible_idle);
+ }
+
+ #[test]
fn claude_idle_hooks_menu() {
let screen = "Hooks\n0 hooks configured\nℹ This menu is read-only. To add or modify hooks, edit settings.json directly or ask Claude. Learn more\n\n❯ 1. PreToolUse\n 2. PostToolUse\n 3. PostToolUseFailure\n\nEnter to confirm · Esc to cancel";
assert_eq!(detect_claude(screen), AgentState::Idle);
@@ -1028,6 +1048,28 @@ mod tests {
}
#[test]
+ fn claude_declined_questions_in_scrollback_with_prompt_box_are_idle() {
+ let screen = "● User declined to answer questions\n ⎿ · What do you want help with? (Code task / PR review / Research / Claude setup)\n · How detailed should I be? (Short / Medium / Detailed)\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~ ⊘ no git ▱▱▱▱▱ 0%";
+ let detection = detect_agent(Some(Agent::Claude), screen);
+
+ assert_eq!(detection.state, AgentState::Idle);
+ assert!(detection.visible_idle);
+ assert!(!detection.visible_working);
+ assert!(!detection.visible_blocker);
+ }
+
+ #[test]
+ fn claude_old_permission_prompt_with_live_prompt_box_is_idle() {
+ let screen = "● Bash(rm -rf /tmp/test)\n ⎿ Waiting…\n\nDo you want to proceed?\n❯ 1. Yes\n 2. No\n\nEsc to cancel · Tab to amend · ctrl+e to explain\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%";
+ let detection = detect_agent(Some(Agent::Claude), screen);
+
+ assert_eq!(detection.state, AgentState::Idle);
+ assert!(detection.visible_idle);
+ assert!(!detection.visible_working);
+ assert!(!detection.visible_blocker);
+ }
+
+ #[test]
fn claude_spinner_after_interrupted_permission_is_visible_working() {
let screen = "❯ this is a test, create some dummy files on /tmp and -rm rf them i wanna test\n permissions\n\n Thought for 7s (ctrl+o to expand)\n\n● Bash(tmpdir=$(mktemp -d /tmp/claude-perm-test.XXXXXX) && touch \"$tmpdir/file1.txt\"\n \"$tmpdir/file2.log\" && mkdir \"$tmpdir/subdir\" && touch\n \"$tmpdir/subdir/nested.txt\"…)\n ⎿ Interrupted · What should Claude do instead?\n\n❯ test\n\n✢ Garnishing… (1s · thinking with high effort)\n ⎿ Tip: Run claude --continue or claude --resume to resume a conversation\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%";
let detection = detect_agent(Some(Agent::Claude), screen);
@@ -1058,6 +1100,17 @@ mod tests {
}
#[test]
+ fn claude_waiting_for_background_agent_is_working() {
+ let screen = "● Done. I’ve delegated a read-only repo investigation to a subagent, and it will come back with a detailed report.\n\n✻ Waiting for 1 background agent to finish\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/llm-proxy ⎇ master ▱▱▱▱▱ 0%\n\n ● main ↑/↓ to select · Enter to view\n ◯ Explore Investigate repo and report 33s · ↓ 225 tokens";
+ let detection = detect_agent(Some(Agent::Claude), screen);
+
+ assert_eq!(detection.state, AgentState::Working);
+ assert!(detection.visible_working);
+ assert!(!detection.visible_idle);
+ assert!(!detection.visible_blocker);
+ }
+
+ #[test]
fn claude_idle_search() {
let screen = "⌕ Search…\nsome content";
assert_eq!(detect_claude(screen), AgentState::Idle);
@@ -1573,6 +1626,49 @@ mod tests {
}
#[test]
+ fn opencode_working_on_footer_interrupt() {
+ let screen = "\
+ ▣ Build · MiniMax M3 Free\n\
+\n\
+ ┃\n\
+ ┃ Build · MiniMax M3 Free OpenCode Zen ~/Projects/llm-proxy:master\n\
+ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\n\
+ ⬝⬝⬝■■■■■ esc interrupt 24.4K (12%) ctrl+p commands • OpenCode 1.15.13";
+ assert_eq!(detect_opencode(screen), AgentState::Working);
+ }
+
+ #[test]
+ fn opencode_working_on_progress_footer_without_product_text() {
+ assert_eq!(
+ detect_opencode("■■■■■■⬝⬝ esc interrupt"),
+ AgentState::Working
+ );
+ }
+
+ #[test]
+ fn opencode_working_on_escape_again_footer() {
+ assert_eq!(
+ detect_opencode(
+ "⬝⬝■■■■■■ esc again to interrupt 14.3K (7%) ctrl+p commands • OpenCode 1.15.13"
+ ),
+ AgentState::Working
+ );
+ }
+
+ #[test]
+ fn opencode_progress_row_alone_is_working() {
+ assert_eq!(detect_opencode("■■■■■■⬝⬝"), AgentState::Working);
+ }
+
+ #[test]
+ fn opencode_ctrl_p_commands_alone_is_not_working() {
+ assert_eq!(
+ detect_opencode("esc interrupt 24.4K (12%) ctrl+p commands"),
+ AgentState::Idle
+ );
+ }
+
+ #[test]
fn opencode_waiting_question_prompt() {
assert_eq!(
detect_opencode(
diff --git a/src/events.rs b/src/events.rs
index 84378b65..2b33bebb 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -49,6 +49,14 @@ pub enum AppEvent {
seq: Option<u64>,
session_ref: Option<crate::agent_resume::AgentSessionRef>,
},
+ /// Agent session identity was reported without state authority.
+ AgentSessionReported {
+ pane_id: PaneId,
+ source: String,
+ agent_label: String,
+ seq: Option<u64>,
+ session_ref: Option<crate::agent_resume::AgentSessionRef>,
+ },
/// Display-only agent metadata was reported for a pane.
HookMetadataReported {
pane_id: PaneId,
diff --git a/src/integration/assets/claude/herdr-agent-state.sh b/src/integration/assets/claude/herdr-agent-state.sh
index e2a970d3..12068a69 100644
--- a/src/integration/assets/claude/herdr-agent-state.sh
+++ b/src/integration/assets/claude/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=claude
-# HERDR_INTEGRATION_VERSION=4
+# HERDR_INTEGRATION_VERSION=5
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
- working|idle|blocked|release) ;;
+ session) ;;
*) exit 0 ;;
esac
@@ -55,39 +55,24 @@ if hook_event_name == "SubagentStop":
# to durable working, but Claude recap/away-summary can emit it after the
# main turn has already stopped. Never let it revive an idle pane.
raise SystemExit(0)
-if is_subagent and action in ("idle", "release"):
- # Subagent completion must not make the parent pane look done early.
- raise SystemExit(0)
-
request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}"
report_seq = time.time_ns()
session_id = hook_input.get("session_id")
agent_session_id = session_id if isinstance(session_id, str) and session_id else None
-if action == "release":
+if agent_session_id:
request = {
"id": request_id,
- "method": "pane.release_agent",
+ "method": "pane.report_agent_session",
"params": {
"pane_id": pane_id,
"source": source,
"agent": "claude",
"seq": report_seq,
+ "agent_session_id": agent_session_id,
},
}
else:
- request = {
- "id": request_id,
- "method": "pane.report_agent",
- "params": {
- "pane_id": pane_id,
- "source": source,
- "agent": "claude",
- "state": action,
- "seq": report_seq,
- },
- }
- if agent_session_id:
- request["params"]["agent_session_id"] = agent_session_id
+ raise SystemExit(0)
try:
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
diff --git a/src/integration/assets/codex/herdr-agent-state.sh b/src/integration/assets/codex/herdr-agent-state.sh
index 5341d71c..385c9a98 100644
--- a/src/integration/assets/codex/herdr-agent-state.sh
+++ b/src/integration/assets/codex/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=codex
-# HERDR_INTEGRATION_VERSION=4
+# HERDR_INTEGRATION_VERSION=5
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
- working|idle|blocked|release) ;;
+ session) ;;
*) exit 0 ;;
esac
@@ -52,31 +52,20 @@ request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):0
report_seq = time.time_ns()
session_id = hook_input.get("session_id")
agent_session_id = session_id if isinstance(session_id, str) and session_id else None
-if action == "release":
+if agent_session_id:
request = {
"id": request_id,
- "method": "pane.release_agent",
+ "method": "pane.report_agent_session",
"params": {
"pane_id": pane_id,
"source": source,
"agent": "codex",
"seq": report_seq,
+ "agent_session_id": agent_session_id,
},
}
else:
- request = {
- "id": request_id,
- "method": "pane.report_agent",
- "params": {
- "pane_id": pane_id,
- "source": source,
- "agent": "codex",
- "state": action,
- "seq": report_seq,
- },
- }
- if agent_session_id:
- request["params"]["agent_session_id"] = agent_session_id
+ raise SystemExit(0)
try:
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
diff --git a/src/integration/assets/opencode/herdr-agent-state.js b/src/integration/assets/opencode/herdr-agent-state.js
index ed0958fd..c2fd79a6 100644
--- a/src/integration/assets/opencode/herdr-agent-state.js
+++ b/src/integration/assets/opencode/herdr-agent-state.js
@@ -2,7 +2,7 @@
// managed by herdr; reinstalling or updating the integration overwrites this file.
// add custom hooks/plugins beside this file instead of editing it.
// HERDR_INTEGRATION_ID=opencode
-// HERDR_INTEGRATION_VERSION=3
+// HERDR_INTEGRATION_VERSION=4
import net from "node:net";
@@ -20,7 +20,10 @@ function sessionIDFromProperties(properties) {
: undefined;
}
-function reportState(action, sessionID) {
+function reportSession(sessionID) {
+ if (!sessionID) {
+ return Promise.resolve();
+ }
const paneId = process.env.HERDR_PANE_ID;
const socketPath = process.env.HERDR_SOCKET_PATH;
@@ -31,26 +34,16 @@ function reportState(action, sessionID) {
const requestId = `${SOURCE}:${Date.now()}:${Math.floor(Math.random() * 1_000_000)
.toString()
.padStart(6, "0")}`;
- const params =
- action === "release"
- ? {
- pane_id: paneId,
- source: SOURCE,
- agent: "opencode",
- seq: nextReportSeq(),
- }
- : {
- pane_id: paneId,
- source: SOURCE,
- agent: "opencode",
- state: action,
- seq: nextReportSeq(),
- ...(sessionID ? { agent_session_id: sessionID } : {}),
- };
const request = {
id: requestId,
- method: action === "release" ? "pane.release_agent" : "pane.report_agent",
- params,
+ method: "pane.report_agent_session",
+ params: {
+ pane_id: paneId,
+ source: SOURCE,
+ agent: "opencode",
+ seq: nextReportSeq(),
+ agent_session_id: sessionID,
+ },
};
return new Promise((resolve) => {
@@ -81,53 +74,16 @@ export const HerdrAgentStatePlugin = async () => {
}
return {
- dispose: async () => {
- await reportState("release");
- },
event: async ({ event }) => {
const type = event?.type;
const properties = event?.properties ?? {};
const sessionID = sessionIDFromProperties(properties);
switch (type) {
- case "permission.asked":
- case "question.asked":
- await reportState("blocked", sessionID);
- break;
- case "permission.replied": {
- const reply = properties.reply ?? properties.response;
- if (reply === "reject") {
- await reportState("idle", sessionID);
- } else if (reply === "once" || reply === "always") {
- await reportState("working", sessionID);
- }
- break;
- }
- case "question.replied":
- await reportState("working", sessionID);
- break;
- case "question.rejected":
- await reportState("idle", sessionID);
- break;
case "session.created":
case "session.updated":
- // session.created and session.updated are metadata events; lifecycle
- // state comes from session.status and the deprecated session.idle.
- break;
- case "session.status": {
- const status =
- typeof properties.status === "string"
- ? properties.status
- : properties.status?.type;
- if (status === "busy" || status === "retry") {
- await reportState("working", sessionID);
- } else if (status === "idle") {
- await reportState("idle", sessionID);
- }
- break;
- }
- case "session.idle":
- await reportState("idle", sessionID);
+ case "session.status":
+ await reportSession(sessionID);
break;
default:
break;
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index 82afb195..31197318 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -19,11 +19,11 @@ const OMP_INTEGRATION_VERSION: u32 = 2;
const PI_CODING_AGENT_DIR_ENV_VAR: &str = "PI_CODING_AGENT_DIR";
const CLAUDE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
const CLAUDE_HOOK_ASSET: &str = include_str!("assets/claude/herdr-agent-state.sh");
-const CLAUDE_INTEGRATION_VERSION: u32 = 4;
+const CLAUDE_INTEGRATION_VERSION: u32 = 5;
const CLAUDE_CONFIG_DIR_ENV_VAR: &str = "CLAUDE_CONFIG_DIR";
const CODEX_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
const CODEX_HOOK_ASSET: &str = include_str!("assets/codex/herdr-agent-state.sh");
-const CODEX_INTEGRATION_VERSION: u32 = 4;
+const CODEX_INTEGRATION_VERSION: u32 = 5;
const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME";
const COPILOT_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
const COPILOT_HOOK_ASSET: &str = include_str!("assets/copilot/herdr-agent-state.sh");
@@ -31,7 +31,7 @@ const COPILOT_INTEGRATION_VERSION: u32 = 1;
const COPILOT_HOME_ENV_VAR: &str = "COPILOT_HOME";
const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js";
const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js");
-const OPENCODE_INTEGRATION_VERSION: u32 = 3;
+const OPENCODE_INTEGRATION_VERSION: u32 = 4;
const HERMES_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state";
const HERMES_PLUGIN_MANIFEST_INSTALL_NAME: &str = "plugin.yaml";
const HERMES_PLUGIN_INIT_INSTALL_NAME: &str = "__init__.py";
@@ -813,45 +813,36 @@ pub(crate) fn install_claude() -> io::Result<ClaudeInstallPaths> {
"SubagentStop",
&format!("bash {quoted_hook_path} working"),
)?;
- ensure_command_hook(
+ remove_command_hook(
+ hooks,
+ "PermissionRequest",
+ &format!("bash {quoted_hook_path} blocked"),
+ )?;
+ remove_command_hook(
hooks,
"SessionStart",
- format!("bash {quoted_hook_path} idle"),
- 10,
- Some("*"),
+ &format!("bash {quoted_hook_path} idle"),
)?;
- ensure_command_hook(
+ remove_command_hook(
hooks,
"UserPromptSubmit",
- format!("bash {quoted_hook_path} working"),
- 10,
- Some("*"),
+ &format!("bash {quoted_hook_path} working"),
)?;
- ensure_command_hook(
+ remove_command_hook(
hooks,
"PreToolUse",
- format!("bash {quoted_hook_path} working"),
- 10,
- Some("*"),
- )?;
- ensure_command_hook(
- hooks,
- "PermissionRequest",
- format!("bash {quoted_hook_path} blocked"),
- 10,
- Some("*"),
+ &format!("bash {quoted_hook_path} working"),
)?;
- ensure_command_hook(
+ remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?;
+ remove_command_hook(
hooks,
- "Stop",
- format!("bash {quoted_hook_path} idle"),
- 10,
- Some("*"),
+ "SessionEnd",
+ &format!("bash {quoted_hook_path} release"),
)?;
ensure_command_hook(
hooks,
- "SessionEnd",
- format!("bash {quoted_hook_path} release"),
+ "SessionStart",
+ format!("bash {quoted_hook_path} session"),
10,
Some("*"),
)?;
@@ -893,38 +884,31 @@ pub(crate) fn install_codex() -> io::Result<CodexInstallPaths> {
"codex hooks file hooks",
)?;
let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
- ensure_command_hook(
+ remove_command_hook(
+ hooks,
+ "PermissionRequest",
+ &format!("bash {quoted_hook_path} blocked"),
+ )?;
+ remove_command_hook(
hooks,
"SessionStart",
- format!("bash {quoted_hook_path} idle"),
- 10,
- None,
+ &format!("bash {quoted_hook_path} idle"),
)?;
- ensure_command_hook(
+ remove_command_hook(
hooks,
"UserPromptSubmit",
- format!("bash {quoted_hook_path} working"),
- 10,
- None,
+ &format!("bash {quoted_hook_path} working"),
)?;
- ensure_command_hook(
+ remove_command_hook(
hooks,
"PreToolUse",
- format!("bash {quoted_hook_path} working"),
- 10,
- None,
- )?;
- ensure_command_hook(
- hooks,
- "PermissionRequest",
- format!("bash {quoted_hook_path} blocked"),
- 10,
- None,
+ &format!("bash {quoted_hook_path} working"),
)?;
+ remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?;
ensure_command_hook(
hooks,
- "Stop",
- format!("bash {quoted_hook_path} idle"),
+ "SessionStart",
+ format!("bash {quoted_hook_path} session"),
10,
None,
)?;
@@ -1114,6 +1098,11 @@ pub(crate) fn uninstall_claude() -> io::Result<ClaudeUninstallResult> {
)?;
updated_settings |= remove_command_hook(
hooks,
+ "SessionStart",
+ &format!("bash {quoted_hook_path} session"),
+ )?;
+ updated_settings |= remove_command_hook(
+ hooks,
"UserPromptSubmit",
&format!("bash {quoted_hook_path} working"),
)?;
@@ -1193,6 +1182,11 @@ pub(crate) fn uninstall_codex() -> io::Result<CodexUninstallResult> {
)?;
updated_hooks |= remove_command_hook(
hooks,
+ "SessionStart",
+ &format!("bash {quoted_hook_path} session"),
+ )?;
+ updated_hooks |= remove_command_hook(
+ hooks,
"UserPromptSubmit",
&format!("bash {quoted_hook_path} working"),
)?;
@@ -2408,35 +2402,15 @@ mod tests {
assert!(settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
- .contains(" idle"));
- assert_eq!(settings["hooks"]["UserPromptSubmit"][0]["matcher"], "*");
- assert!(
- settings["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
- .as_str()
- .unwrap()
- .contains(" working")
- );
- assert!(settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
- .as_str()
- .unwrap()
- .contains(" working"));
- assert!(
- settings["hooks"]["PermissionRequest"][0]["hooks"][0]["command"]
- .as_str()
- .unwrap()
- .contains(" blocked")
- );
+ .contains(" session"));
+ assert!(settings["hooks"].get("UserPromptSubmit").is_none());
+ assert!(settings["hooks"].get("PreToolUse").is_none());
+ assert!(settings["hooks"].get("PermissionRequest").is_none());
assert!(settings["hooks"].get("PostToolUse").is_none());
assert!(settings["hooks"].get("PostToolUseFailure").is_none());
assert!(settings["hooks"].get("SubagentStop").is_none());
- assert!(settings["hooks"]["Stop"][0]["hooks"][0]["command"]
- .as_str()
- .unwrap()
- .contains(" idle"));
- assert!(settings["hooks"]["SessionEnd"][0]["hooks"][0]["command"]
- .as_str()
- .unwrap()
- .contains(" release"));
+ assert!(settings["hooks"].get("Stop").is_none());
+ assert!(settings["hooks"].get("SessionEnd").is_none());
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
@@ -2478,29 +2452,17 @@ mod tests {
serde_json::from_str(&fs::read_to_string(claude_dir.join("settings.json")).unwrap())
.unwrap();
assert_eq!(
- settings["hooks"]["UserPromptSubmit"]
- .as_array()
- .unwrap()
- .len(),
- 1
- );
- assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
- assert_eq!(
- settings["hooks"]["PermissionRequest"]
- .as_array()
- .unwrap()
- .len(),
- 1
- );
- assert_eq!(
settings["hooks"]["SessionStart"].as_array().unwrap().len(),
1
);
+ assert!(settings["hooks"].get("UserPromptSubmit").is_none());
+ assert!(settings["hooks"].get("PreToolUse").is_none());
+ assert!(settings["hooks"].get("PermissionRequest").is_none());
assert!(settings["hooks"].get("PostToolUse").is_none());
assert!(settings["hooks"].get("PostToolUseFailure").is_none());
assert!(settings["hooks"].get("SubagentStop").is_none());
- assert_eq!(settings["hooks"]["Stop"].as_array().unwrap().len(), 1);
- assert_eq!(settings["hooks"]["SessionEnd"].as_array().unwrap().len(), 1);
+ assert!(settings["hooks"].get("Stop").is_none());
+ assert!(settings["hooks"].get("SessionEnd").is_none());
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
@@ -2518,7 +2480,8 @@ mod tests {
fs::write(
claude_dir.join("settings.json"),
format!(
- r#"{{"hooks":{{"PostToolUse":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-post","timeout":10}}]}}],"PostToolUseFailure":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-failure","timeout":10}}]}}],"SubagentStop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-subagent","timeout":10}}]}}]}}}}"#,
+ r#"{{"hooks":{{"PostToolUse":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-post","timeout":10}}]}}],"PostToolUseFailure":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-failure","timeout":10}}]}}],"SubagentStop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-subagent","timeout":10}}]}}],"SessionEnd":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' release","timeout":10}},{{"type":"command","command":"echo keep-session-end","timeout":10}}]}}]}}}}"#,
+ hook_path.display(),
hook_path.display(),
hook_path.display(),
hook_path.display(),
@@ -2545,14 +2508,12 @@ mod tests {
"echo keep-subagent"
);
assert_eq!(
- settings["hooks"]["UserPromptSubmit"]
- .as_array()
- .unwrap()
- .len(),
- 1
+ settings["hooks"]["SessionEnd"][0]["hooks"][0]["command"],
+ "echo keep-session-end"
);
- assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
- assert_eq!(settings["hooks"]["Stop"].as_array().unwrap().len(), 1);
+ assert!(settings["hooks"].get("UserPromptSubmit").is_none());
+ assert!(settings["hooks"].get("PreToolUse").is_none());
+ assert!(settings["hooks"].get("Stop").is_none());
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
@@ -2581,7 +2542,7 @@ mod tests {
assert_eq!(claude.path, hook_path);
assert_eq!(claude.installed_version, Some(1));
- assert_eq!(claude.expected_version, 4);
+ assert_eq!(claude.expected_version, 5);
assert_eq!(claude.state, IntegrationStatusKind::Outdated);
std::env::remove_var("HOME");
@@ -2611,7 +2572,7 @@ mod tests {
assert_eq!(claude.path, hook_path);
assert_eq!(claude.installed_version, Some(2));
- assert_eq!(claude.expected_version, 4);
+ assert_eq!(claude.expected_version, 5);
assert_eq!(claude.state, IntegrationStatusKind::Outdated);
std::env::remove_var("HOME");
@@ -2715,7 +2676,7 @@ mod tests {
assert_eq!(codex.path, hook_path);
assert_eq!(codex.installed_version, Some(2));
- assert_eq!(codex.expected_version, 4);
+ assert_eq!(codex.expected_version, 5);
assert_eq!(codex.state, IntegrationStatusKind::Outdated);
std::env::remove_var("HOME");
@@ -2745,25 +2706,11 @@ mod tests {
assert!(hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
- .contains(" idle"));
- assert!(hooks["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
- .as_str()
- .unwrap()
- .contains(" working"));
- assert!(hooks["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
- .as_str()
- .unwrap()
- .contains(" working"));
- assert!(
- hooks["hooks"]["PermissionRequest"][0]["hooks"][0]["command"]
- .as_str()
- .unwrap()
- .contains(" blocked")
- );
- assert!(hooks["hooks"]["Stop"][0]["hooks"][0]["command"]
- .as_str()
- .unwrap()
- .contains(" idle"));
+ .contains(" session"));
+ assert!(hooks["hooks"].get("UserPromptSubmit").is_none());
+ assert!(hooks["hooks"].get("PreToolUse").is_none());
+ assert!(hooks["hooks"].get("PermissionRequest").is_none());
+ assert!(hooks["hooks"].get("Stop").is_none());
assert!(config.contains("model = \"gpt-5.4\""));
assert!(config.contains("[features]"));
assert!(config.contains("hooks = true"));
@@ -2815,19 +2762,10 @@ mod tests {
let config = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
assert_eq!(hooks["hooks"]["SessionStart"].as_array().unwrap().len(), 1);
- assert_eq!(
- hooks["hooks"]["UserPromptSubmit"].as_array().unwrap().len(),
- 1
- );
- assert_eq!(hooks["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
- assert_eq!(
- hooks["hooks"]["PermissionRequest"]
- .as_array()
- .unwrap()
- .len(),
- 1
- );
- assert_eq!(hooks["hooks"]["Stop"].as_array().unwrap().len(), 1);
+ assert!(hooks["hooks"].get("UserPromptSubmit").is_none());
+ assert!(hooks["hooks"].get("PreToolUse").is_none());
+ assert!(hooks["hooks"].get("PermissionRequest").is_none());
+ assert!(hooks["hooks"].get("Stop").is_none());
assert_eq!(config.matches("hooks = true").count(), 1);
assert!(!config.contains("codex_hooks"));
assert!(config.contains("other = true"));
@@ -3297,18 +3235,27 @@ mod tests {
assert!(PI_EXTENSION_ASSET.contains("agent_session_id: currentAgentSessionId"));
assert!(PI_EXTENSION_ASSET.contains("publishState(true)"));
assert!(CLAUDE_HOOK_ASSET.contains("agent_session_id"));
+ assert!(CLAUDE_HOOK_ASSET.contains("pane.report_agent_session"));
+ assert!(!CLAUDE_HOOK_ASSET.contains("\"state\": action"));
+ assert!(!CLAUDE_HOOK_ASSET.contains("pane.release_agent"));
assert!(CODEX_HOOK_ASSET.contains("HERDR_HOOK_INPUT_FILE"));
assert!(CODEX_HOOK_ASSET.contains("agent_session_id"));
+ assert!(CODEX_HOOK_ASSET.contains("pane.report_agent_session"));
+ assert!(!CODEX_HOOK_ASSET.contains("\"state\": action"));
+ assert!(!CODEX_HOOK_ASSET.contains("pane.release_agent"));
assert!(COPILOT_HOOK_ASSET.contains("agent_session_id"));
assert!(COPILOT_HOOK_ASSET.contains("notification_type"));
assert!(COPILOT_HOOK_ASSET.contains("ask_user"));
assert!(COPILOT_HOOK_ASSET.contains("exit_plan_mode"));
assert!(OPENCODE_PLUGIN_ASSET.contains("properties?.sessionID"));
- assert!(OPENCODE_PLUGIN_ASSET.contains("dispose: async"));
assert!(OPENCODE_PLUGIN_ASSET.contains("agent_session_id: sessionID"));
- assert!(!OPENCODE_PLUGIN_ASSET.contains("await reportState(\"idle\", sessionID);\n }\n break;\n case \"session.status\""));
+ assert!(OPENCODE_PLUGIN_ASSET.contains("pane.report_agent_session"));
+ assert!(!OPENCODE_PLUGIN_ASSET.contains("reportState"));
+ assert!(!OPENCODE_PLUGIN_ASSET.contains("pane.release_agent"));
assert!(HERMES_PLUGIN_INIT_ASSET.contains("session_id = _session_id(kwargs)"));
assert!(HERMES_PLUGIN_INIT_ASSET.contains("agent_session_id"));
+ assert!(HERMES_PLUGIN_INIT_ASSET.contains("pane.report_agent\","));
+ assert!(HERMES_PLUGIN_INIT_ASSET.contains("pane.release_agent"));
// Qoder hook reads the event from the stdin JSON payload (per
// https://docs.qoder.com/zh/cli/hooks). Make sure the bundled script
// never reaches for a QODER_HOOK_EVENT environment variable.
diff --git a/src/logging.rs b/src/logging.rs
index 2c64fbce..31dba911 100644
--- a/src/logging.rs
+++ b/src/logging.rs
@@ -111,6 +111,7 @@ fn is_routine_api_method(method: &str) -> bool {
| "workspace.list"
| "tab.list"
| "pane.report_agent"
+ | "pane.report_agent_session"
| "pane.report_metadata"
)
}
diff --git a/src/terminal/state.rs b/src/terminal/state.rs
index 09c5812a..e6ac3c00 100644
--- a/src/terminal/state.rs
+++ b/src/terminal/state.rs
@@ -428,6 +428,34 @@ impl TerminalState {
self.persisted_agent_session = Some(session);
}
+ pub fn set_agent_session_ref(
+ &mut self,
+ source: String,
+ agent_label: String,
+ session_ref: Option<crate::agent_resume::AgentSessionRef>,
+ seq: Option<u64>,
+ ) -> Option<TerminalStateMutation> {
+ let session_ref = session_ref?;
+ if !self.accept_hook_report(&source, seq) {
+ return None;
+ }
+ if self.known_agent_label_conflicts_with_detected_agent(&agent_label) {
+ return None;
+ }
+
+ let previous_session = self.current_session_identity_for_persistence();
+ self.persisted_agent_session = Some(crate::agent_resume::PersistedAgentSession {
+ source,
+ agent: agent_label,
+ session_ref,
+ });
+ let current_session = self.current_session_identity_for_persistence();
+ Some(TerminalStateMutation {
+ effective_state_change: None,
+ session_ref_changed: previous_session != current_session,
+ })
+ }
+
fn known_agent_label_conflicts_with_detected_agent(&self, agent_label: &str) -> bool {
let Some(detected_agent) = self.detected_agent else {
return false;