From 295b09ca25edbccac65401ee84fa420dd3888aad Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Tue, 2 Jun 2026 21:20:28 +0300 Subject: [PATCH] feat: make claude codex opencode hooks session-only --- docs/next/CHANGELOG.md | 1 + docs/next/README.md | 6 +- docs/next/website/src/content/docs/agents.mdx | 4 +- .../website/src/content/docs/integrations.mdx | 21 +- .../src/content/docs/session-state.mdx | 7 +- .../website/src/content/docs/socket-api.mdx | 22 +- 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 + .../assets/claude/herdr-agent-state.sh | 27 +- .../assets/codex/herdr-agent-state.sh | 23 +- .../assets/opencode/herdr-agent-state.js | 74 +---- src/integration/mod.rs | 259 +++++++----------- src/logging.rs | 1 + src/terminal/state.rs | 28 ++ tests/cli_wrapper.rs | 39 +-- 24 files changed, 568 insertions(+), 346 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 234e7503..9db3ec9d 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -9,6 +9,7 @@ ### Changed - Native agent session restore is now enabled by default for supported panes with current official integrations. Set `[session] resume_agents_on_restore = false` to disable it. +- Claude Code, Codex, and OpenCode integrations now report session identity only. Native state for those agents comes from Herdr's screen detection, while Pi, OMP, GitHub Copilot CLI, Hermes Agent, Qoder CLI, and custom socket integrations can still report state. ### Fixed - Kiro sub-agent tool approval prompts are now detected as blocked instead of working. (#388) diff --git a/docs/next/README.md b/docs/next/README.md index 39b3cef8..93906b5b 100644 --- a/docs/next/README.md +++ b/docs/next/README.md @@ -57,7 +57,7 @@ Press `ctrl+b q` to detach the client. The server and pane processes keep runnin **Keybindings.** Herdr uses explicit keybinding strings. `prefix+n` means press the configured prefix, then `n`. `ctrl+alt+n`, `cmd+k`, `alt+1`, and function-key chords are direct terminal-mode shortcuts and do not need the prefix. Plain direct printable keys such as `n` steal normal typing, so use `prefix+n` unless you intentionally want a modifier-gated direct binding. -**Agent awareness.** The sidebar shows blocked, working, done, and idle states. Detection works with process names and terminal output by default. Official integrations make state reporting and native agent session restore more reliable, but Herdr still works as a terminal multiplexer without them. +**Agent awareness.** The sidebar shows blocked, working, done, and idle states. Detection works with process names and terminal output by default. Official Claude Code, Codex, and OpenCode integrations add session restore identity, while Pi, OMP, GitHub Copilot CLI, Hermes Agent, Qoder CLI, and custom socket integrations can still report their own state. ## update @@ -121,7 +121,7 @@ states: - πŸ”΅ **done** β€” work finished, you have not looked at it yet - 🟒 **idle** β€” done and seen -detection works by reading foreground process and terminal output. zero config, no hooks required. for agents that expose hooks, the socket api integration gives more robust state reporting. +detection works by reading foreground process and terminal output. zero config, no hooks required. official claude code, codex, and opencode integrations provide session restore identity; pi, omp, github copilot cli, hermes, qodercli, and custom socket integrations can report their own state. ## lives in your terminal @@ -168,7 +168,7 @@ for agents outside the built-in list, herdr still works as a terminal multiplexe ### direct integrations -the built-in pi, omp, claude code, codex, github copilot cli, opencode, hermes, and qodercli integrations forward semantic state to herdr over the socket api. install with: +the built-in pi, omp, github copilot cli, hermes, and qodercli integrations forward semantic state to herdr over the socket api. claude code, codex, and opencode integrations provide session-start identity while native state comes from screen detection. install with: ```bash herdr integration install pi diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx index 57d18b0e..bc3a8b00 100644 --- a/docs/next/website/src/content/docs/agents.mdx +++ b/docs/next/website/src/content/docs/agents.mdx @@ -37,9 +37,9 @@ Herdr combines three signals: 1. foreground process detection 2. terminal output heuristics -3. integration state reports +3. integration session identity or state reports -Process detection tells Herdr which pane owns an agent. Heuristics infer state when no hook is available. Integrations provide the most precise semantic state. +Process detection tells Herdr which pane owns an agent. Heuristics infer native agent state from the visible terminal. Claude Code, Codex, and OpenCode integrations provide session identity for restore; Pi, OMP, GitHub Copilot CLI, Hermes Agent, Qoder CLI, and custom socket integrations can report state when they define state outside the native terminal UI. ## State rollups diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx index 2cf87e12..cb2041af 100644 --- a/docs/next/website/src/content/docs/integrations.mdx +++ b/docs/next/website/src/content/docs/integrations.mdx @@ -3,9 +3,9 @@ title: Integrations description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, OpenCode, Hermes Agent, and Qoder CLI. --- -Herdr detects supported agents automatically. Integrations make that detection more precise by reporting semantic state directly to Herdr. +Herdr detects supported agents automatically. Native Claude Code, Codex, and OpenCode state comes from screen detection. Their official integrations add session identity for restore; Pi, OMP, GitHub Copilot CLI, Hermes Agent, Qoder CLI, and custom integrations can report their own state. -Use integrations when you want reliable `working`, `blocked`, `idle`, and `done` state from agent hooks or plugins. +Use integrations when you want native agent session restore or custom extension state from Pi/OMP/Copilot/Hermes/Qoder-style hooks or plugins. ## Install integrations @@ -42,14 +42,14 @@ Herdr combines three signals: | Signal | Purpose | | --- | --- | | Process detection | Identifies which pane owns which running process. | -| Agent integration events | Reports semantic state such as `working` or `blocked`. | -| Screen heuristics | Fills gaps when hooks are unavailable or incomplete. | +| Screen heuristics | Infers visible native agent state such as `working` or `blocked`. | +| Integration events | Reports native session identity, or state for Pi/OMP/Copilot/Hermes/Qoder-style extensions. | -Integrations enrich state reporting. They do not replace process detection. +Claude Code, Codex, and OpenCode state detection is screen-read first. Their Herdr-owned integrations do not author `idle`, `working`, or `blocked` state. Pi, OMP, GitHub Copilot CLI, Hermes Agent, Qoder CLI, and custom socket integrations can still report state when they define state that is not visible in the native terminal UI. -Some integrations also report native agent session references. Herdr uses official session references to resume Claude Code, Codex, GitHub Copilot CLI, Pi, Hermes Agent, and OpenCode panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it. +Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, GitHub Copilot CLI, Pi, Hermes Agent, and OpenCode panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it. -Native session restore requires current Herdr integrations: Pi integration version `2`, Claude Code version `4`, Codex version `4`, GitHub Copilot CLI version `1`, OpenCode version `2`, or Hermes Agent version `2`. OMP integration version `2` reports agent state only. Check installed versions with `herdr integration status`. +Native session restore requires current Herdr integrations: Pi integration version `2`, Claude Code version `5`, Codex version `5`, GitHub Copilot CLI version `1`, OpenCode version `4`, or Hermes Agent version `2`. OMP integration version `2` and Qoder CLI integration version `1` report agent state only. Check installed versions with `herdr integration status`. ## Pi @@ -93,7 +93,7 @@ Install the Claude Code hook: herdr integration install claude ``` -The hook reports agent state to the local Herdr socket when Claude Code exposes hook events. +The hook reports Claude Code session identity to the local Herdr socket on session start. Claude Code state comes from Herdr's screen detection. Herdr uses `~/.claude` by default, or `CLAUDE_CONFIG_DIR` when set. The Claude config directory must already exist. Install writes `hooks/herdr-agent-state.sh` and updates `settings.json` with Herdr hook entries. Uninstall removes the matching hook entries and deletes the hook script. @@ -105,7 +105,7 @@ Install the Codex hook: herdr integration install codex ``` -Codex state is reported through the same local socket API used by other integrations. +The Codex hook reports session identity through the same local socket API used by other integrations. Codex state comes from Herdr's screen detection. Herdr uses `~/.codex` by default, or `CODEX_HOME` when set. The Codex config directory must already exist. Install writes `herdr-agent-state.sh`, updates `hooks.json`, and ensures `[features] hooks = true` in `config.toml`. It also removes the deprecated top-level `codex_hooks` flag when present. Uninstall removes Herdr entries from `hooks.json` and deletes the hook script, but leaves `config.toml` unchanged. @@ -133,7 +133,7 @@ herdr integration install opencode Herdr writes the plugin to `~/.config/opencode/plugins/herdr-agent-state.js`. The OpenCode config directory must already exist. Uninstall removes only that plugin file. -The plugin reports semantic state while OpenCode runs inside a Herdr pane. After OpenCode emits a session-bearing event, Herdr can use the reported session id to resume the pane with `opencode --session `. +The plugin reports session identity while OpenCode runs inside a Herdr pane. After OpenCode emits a session-bearing event, Herdr can use the reported session id to resume the pane with `opencode --session `. OpenCode state comes from Herdr's screen detection. ## Hermes Agent @@ -181,7 +181,6 @@ User hooks that run next to a Herdr-managed integration should use metadata inst herdr pane report-metadata "$HERDR_PANE_ID" \ --source user:claude-title \ --agent claude \ - --applies-to-source herdr:claude \ --title "Refactor auth middleware" \ --display-agent "Claude: auth" \ --custom-status "refactor auth" \ diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx index 6ec10af4..d1568675 100644 --- a/docs/next/website/src/content/docs/session-state.mdx +++ b/docs/next/website/src/content/docs/session-state.mdx @@ -56,13 +56,14 @@ Native session restore requires these Herdr integration versions or newer: | Agent | Minimum Herdr integration version | Resume command | | --- | --- | --- | | Pi | `2` | `pi --session ` | -| Claude Code | `4` | `claude --resume ` | -| Codex | `4` | `codex resume ` | +| Claude Code | `5` | `claude --resume ` | +| Codex | `5` | `codex resume ` | | GitHub Copilot CLI | `1` | `copilot --resume=` | -| OpenCode | `2` | `opencode --session ` | +| OpenCode | `4` | `opencode --session ` | | Hermes Agent | `2` | `hermes --resume ` | OMP integration version `2` reports agent state, but does not report native session references for restore. +Qoder CLI integration version `1` reports agent state, but does not report native session references for restore. Run `herdr integration status` to check installed integration versions. Reinstall outdated integrations with `herdr integration install `. diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index 8525c33b..9d9791e7 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -73,7 +73,7 @@ Raw socket method names use dot notation: | Workspace | `workspace.create`, `workspace.list`, `workspace.get`, `workspace.focus`, `workspace.rename`, `workspace.close` | | Worktree | `worktree.list`, `worktree.create`, `worktree.open`, `worktree.remove` | | Tab | `tab.create`, `tab.list`, `tab.get`, `tab.focus`, `tab.rename`, `tab.close` | -| Pane | `pane.split`, `pane.list`, `pane.get`, `pane.rename`, `pane.send_text`, `pane.send_keys`, `pane.send_input`, `pane.read`, `pane.report_agent`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` | +| Pane | `pane.split`, `pane.list`, `pane.get`, `pane.rename`, `pane.send_text`, `pane.send_keys`, `pane.send_input`, `pane.read`, `pane.report_agent`, `pane.report_agent_session`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` | | Agent | `agent.list`, `agent.get`, `agent.read`, `agent.send`, `agent.rename`, `agent.focus`, `agent.start` | | Events | `events.subscribe`, `events.wait` | | Integrations | `integration.install`, `integration.uninstall` | @@ -163,7 +163,22 @@ Integrations report agent state with `pane.report_agent`. `custom_status` is visual. It can show a short label like `indexing` without changing semantic behavior. -Official integrations can also report a native session reference. `pane.get`, `pane.list`, `agent.get`, and `agent.list` expose a read-only `agent_session` object when Herdr has a stored native session reference: +Session-only official integrations report native session references with `pane.report_agent_session`. State-reporting integrations can still include native session references in `pane.report_agent`. State-independent session reports do not affect waits, notifications, or rollups. + +```json +{ + "id": "req_2", + "method": "pane.report_agent_session", + "params": { + "pane_id": "1-1", + "source": "herdr:codex", + "agent": "codex", + "agent_session_id": "..." + } +} +``` + +`pane.get`, `pane.list`, `agent.get`, and `agent.list` expose a read-only `agent_session` object when Herdr has a stored native session reference: ```json { @@ -190,7 +205,6 @@ Use `pane.report_metadata` when a user hook wants to customize presentation with "pane_id": "1-1", "source": "user:claude-title", "agent": "claude", - "applies_to_source": "herdr:claude", "title": "Refactor auth middleware", "display_agent": "Claude: auth", "custom_status": "refactor auth", @@ -204,7 +218,7 @@ Use `pane.report_metadata` when a user hook wants to customize presentation with } ``` -Metadata reports are display-only. Valid metadata can override the pane title, displayed agent name, compact custom status, and visible state labels. `working`, `blocked`, `idle`, waits, notifications, session restore, and rollups still come from the active agent state report. `agent` is an optional guard for the authoritative agent label; `applies_to_source` is an optional guard for the active lifecycle authority source. Use `display_agent` to change the visible name. `state_labels` keys must be `idle`, `working`, `blocked`, `done`, or `unknown`. Use clear fields such as `clear_custom_status: true` with the same `source` to remove one presentation override. +Metadata reports are display-only. Valid metadata can override the pane title, displayed agent name, compact custom status, and visible state labels. `working`, `blocked`, `idle`, waits, notifications, and rollups still come from semantic state. Native session restore comes from stored official session references. `agent` is an optional guard for the authoritative agent label; `applies_to_source` is an optional guard for the active lifecycle authority source. Use `display_agent` to change the visible name. `state_labels` keys must be `idle`, `working`, `blocked`, `done`, or `unknown`. Use clear fields such as `clear_custom_status: true` with the same `source` to remove one presentation override. ## Event subscriptions 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"), )?; + remove_command_hook( + hooks, + "PermissionRequest", + &format!("bash {quoted_hook_path} blocked"), + )?; + remove_command_hook( + hooks, + "SessionStart", + &format!("bash {quoted_hook_path} idle"), + )?; + remove_command_hook( + hooks, + "UserPromptSubmit", + &format!("bash {quoted_hook_path} working"), + )?; + remove_command_hook( + hooks, + "PreToolUse", + &format!("bash {quoted_hook_path} working"), + )?; + remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?; + remove_command_hook( + hooks, + "SessionEnd", + &format!("bash {quoted_hook_path} release"), + )?; ensure_command_hook( hooks, "SessionStart", - format!("bash {quoted_hook_path} idle"), - 10, - Some("*"), - )?; - ensure_command_hook( - hooks, - "UserPromptSubmit", - format!("bash {quoted_hook_path} working"), - 10, - Some("*"), - )?; - ensure_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("*"), - )?; - ensure_command_hook( - hooks, - "Stop", - format!("bash {quoted_hook_path} idle"), - 10, - Some("*"), - )?; - ensure_command_hook( - hooks, - "SessionEnd", - format!("bash {quoted_hook_path} release"), + 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()); + remove_command_hook( + hooks, + "PermissionRequest", + &format!("bash {quoted_hook_path} blocked"), + )?; + remove_command_hook( + hooks, + "SessionStart", + &format!("bash {quoted_hook_path} idle"), + )?; + remove_command_hook( + hooks, + "UserPromptSubmit", + &format!("bash {quoted_hook_path} working"), + )?; + remove_command_hook( + hooks, + "PreToolUse", + &format!("bash {quoted_hook_path} working"), + )?; + remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?; ensure_command_hook( hooks, "SessionStart", - format!("bash {quoted_hook_path} idle"), - 10, - None, - )?; - ensure_command_hook( - hooks, - "UserPromptSubmit", - format!("bash {quoted_hook_path} working"), - 10, - None, - )?; - ensure_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, - )?; - ensure_command_hook( - hooks, - "Stop", - format!("bash {quoted_hook_path} idle"), + 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; diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index 511674ab..871cba64 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -568,19 +568,11 @@ fn run_shell_hook(asset_path: &str, args: &[&str], hook_input: &str) -> Option