From 295b09ca25edbccac65401ee84fa420dd3888aad Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Tue, 2 Jun 2026 21:20:28 +0300 Subject: feat: make claude codex opencode hooks session-only --- src/agent_resume.rs | 7 + src/api/mod.rs | 1 + src/api/schema.rs | 34 ++++ src/api/server.rs | 1 + src/app/actions.rs | 119 ++++++++--- src/app/api.rs | 3 + src/app/api/panes.rs | 32 ++- src/detect/agents/claude_code.rs | 64 ++++-- src/detect/agents/opencode.rs | 31 ++- src/detect/mod.rs | 102 +++++++++- src/events.rs | 8 + src/integration/assets/claude/herdr-agent-state.sh | 27 +-- src/integration/assets/codex/herdr-agent-state.sh | 23 +-- .../assets/opencode/herdr-agent-state.js | 74 ++----- src/integration/mod.rs | 223 ++++++++------------- src/logging.rs | 1 + src/terminal/state.rs | 28 +++ 17 files changed, 498 insertions(+), 280 deletions(-) (limited to 'src') 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")] @@ -353,6 +355,19 @@ pub struct PaneReportAgentParams { pub agent_session_path: Option, } +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session_path: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PaneReportMetadataParams { pub pane_id: String, @@ -1072,6 +1087,25 @@ mod tests { assert_eq!(restored, request); } + #[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 { 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,11 +3677,45 @@ 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"]); 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(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(agent_label) = normalize_reported_agent_label(¶ms.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( + ¶ms.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 { 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('❯'); @@ -989,6 +989,26 @@ mod tests { assert_eq!(detect_claude(screen), AgentState::Blocked); } + #[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"; @@ -1027,6 +1047,28 @@ mod tests { assert!(!detection.visible_blocker); } + #[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%"; @@ -1057,6 +1099,17 @@ mod tests { assert!(!detection.visible_idle); } + #[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"; @@ -1572,6 +1625,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!( 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, session_ref: Option, }, + /// Agent session identity was reported without state authority. + AgentSessionReported { + pane_id: PaneId, + source: String, + agent_label: String, + seq: Option, + session_ref: Option, + }, /// 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 { "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 { "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, )?; @@ -1112,6 +1096,11 @@ pub(crate) fn uninstall_claude() -> io::Result { "SessionStart", &format!("bash {quoted_hook_path} idle"), )?; + updated_settings |= remove_command_hook( + hooks, + "SessionStart", + &format!("bash {quoted_hook_path} session"), + )?; updated_settings |= remove_command_hook( hooks, "UserPromptSubmit", @@ -1191,6 +1180,11 @@ pub(crate) fn uninstall_codex() -> io::Result { "SessionStart", &format!("bash {quoted_hook_path} idle"), )?; + updated_hooks |= remove_command_hook( + hooks, + "SessionStart", + &format!("bash {quoted_hook_path} session"), + )?; updated_hooks |= remove_command_hook( hooks, "UserPromptSubmit", @@ -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); @@ -2477,30 +2451,18 @@ mod tests { let settings: Value = 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, + seq: Option, + ) -> Option { + 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; -- cgit v1.2.3