diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-15 02:13:30 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-15 02:13:30 +0300 |
| commit | dcfc45b45d198434db5057c991e72c089b42c705 (patch) | |
| tree | 4cbdbc253bb5dc12c734a7ed1cbf82fd444d3cd2 /src | |
| parent | c91348e68b9e438159ffecc3e26bc6f971e0fa2a (diff) | |
fix: preserve claude session rotations
refs #620
Diffstat (limited to 'src')
| -rw-r--r-- | src/agent_resume.rs | 36 | ||||
| -rw-r--r-- | src/api/schema/panes.rs | 2 | ||||
| -rw-r--r-- | src/app/actions.rs | 9 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 3 | ||||
| -rw-r--r-- | src/cli/pane.rs | 12 | ||||
| -rw-r--r-- | src/events.rs | 1 | ||||
| -rw-r--r-- | src/integration/assets/claude/herdr-agent-state.ps1 | 23 | ||||
| -rw-r--r-- | src/integration/assets/claude/herdr-agent-state.sh | 26 | ||||
| -rw-r--r-- | src/integration/mod.rs | 8 | ||||
| -rw-r--r-- | src/terminal/state.rs | 115 |
10 files changed, 213 insertions, 22 deletions
diff --git a/src/agent_resume.rs b/src/agent_resume.rs index 93bc4c2e..cea4a641 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 normalize_claude_session_start_source(value: Option<String>) -> Option<String> { + match value.as_deref().map(str::trim) { + Some(source @ ("startup" | "resume" | "clear" | "compact")) => Some(source.to_string()), + _ => None, + } +} + pub fn is_reserved_native_state_source(source: &str, agent: &str) -> bool { matches!( (source, agent), @@ -426,6 +433,35 @@ mod tests { } #[test] + fn normalize_claude_session_start_source_allows_known_claude_values() { + assert_eq!( + normalize_claude_session_start_source(Some("startup".into())), + Some("startup".into()) + ); + assert_eq!( + normalize_claude_session_start_source(Some("resume".into())), + Some("resume".into()) + ); + assert_eq!( + normalize_claude_session_start_source(Some("clear".into())), + Some("clear".into()) + ); + assert_eq!( + normalize_claude_session_start_source(Some("compact".into())), + Some("compact".into()) + ); + assert_eq!( + normalize_claude_session_start_source(Some(" resume ".into())), + Some("resume".into()) + ); + assert_eq!( + normalize_claude_session_start_source(Some("other".into())), + None + ); + assert_eq!(normalize_claude_session_start_source(None), None); + } + + #[test] fn ids_are_data_not_shell_text() { let id = "abc; rm -rf /"; let codex_plan = plan("herdr:codex", "codex", &AgentSessionRef::id(id).unwrap()).unwrap(); diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs index 638829b0..9bd8103d 100644 --- a/src/api/schema/panes.rs +++ b/src/api/schema/panes.rs @@ -274,6 +274,8 @@ pub struct PaneReportAgentSessionParams { pub agent_session_id: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_session_path: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_start_source: Option<String>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/app/actions.rs b/src/app/actions.rs index 1538f12b..82aaa8f7 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -2452,9 +2452,16 @@ impl AppState { agent_label, seq, session_ref, + session_start_source, } => self .update_terminal_state(pane_id, |terminal| { - terminal.set_agent_session_ref(source, agent_label, session_ref, seq) + terminal.set_agent_session_ref_for_session_start( + source, + agent_label, + session_ref, + seq, + session_start_source, + ) }) .into_iter() .collect(), diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 2d994dca..4bae2f6f 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -1239,6 +1239,9 @@ impl App { source: params.source, agent_label, seq: params.seq, + session_start_source: crate::agent_resume::normalize_claude_session_start_source( + params.session_start_source, + ), }); encode_success(id, ResponseResult::Ok {}) diff --git a/src/cli/pane.rs b/src/cli/pane.rs index 2e5ce485..c81e5835 100644 --- a/src/cli/pane.rs +++ b/src/cli/pane.rs @@ -1082,7 +1082,7 @@ fn pane_report_agent(args: &[String]) -> std::io::Result<i32> { fn pane_report_agent_session(args: &[String]) -> std::io::Result<i32> { let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr pane report-agent-session <pane_id> --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); + eprintln!("usage: herdr pane report-agent-session <pane_id> --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH] [--session-start-source SOURCE]"); return Ok(2); }; @@ -1092,6 +1092,7 @@ fn pane_report_agent_session(args: &[String]) -> std::io::Result<i32> { let mut seq = None; let mut agent_session_id = None; let mut agent_session_path = None; + let mut session_start_source = None; let mut index = 1; while index < args.len() { @@ -1136,6 +1137,14 @@ fn pane_report_agent_session(args: &[String]) -> std::io::Result<i32> { agent_session_path = Some(value.clone()); index += 2; } + "--session-start-source" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --session-start-source"); + return Ok(2); + }; + session_start_source = Some(value.clone()); + index += 2; + } other => { eprintln!("unknown option: {other}"); return Ok(2); @@ -1163,6 +1172,7 @@ fn pane_report_agent_session(args: &[String]) -> std::io::Result<i32> { seq, agent_session_id, agent_session_path, + session_start_source, }, )) } diff --git a/src/events.rs b/src/events.rs index 6185b8a7..31a16ee6 100644 --- a/src/events.rs +++ b/src/events.rs @@ -55,6 +55,7 @@ pub enum AppEvent { agent_label: String, seq: Option<u64>, session_ref: Option<crate::agent_resume::AgentSessionRef>, + session_start_source: Option<String>, }, /// Display-only agent metadata was reported for a pane. HookMetadataReported { diff --git a/src/integration/assets/claude/herdr-agent-state.ps1 b/src/integration/assets/claude/herdr-agent-state.ps1 index 71417e9c..dc78efd2 100644 --- a/src/integration/assets/claude/herdr-agent-state.ps1 +++ b/src/integration/assets/claude/herdr-agent-state.ps1 @@ -2,7 +2,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=5 +# HERDR_INTEGRATION_VERSION=6 param([string]$Action = "") @@ -24,6 +24,25 @@ if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 } $seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() try { - & herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:claude --agent claude --seq $seq --agent-session-id $sessionId 2>$null | Out-Null + $args = @( + "pane", + "report-agent-session", + $env:HERDR_PANE_ID, + "--source", + "herdr:claude", + "--agent", + "claude", + "--seq", + "$seq", + "--agent-session-id", + "$sessionId" + ) + if ($payload.transcript_path -is [string] -and -not [string]::IsNullOrWhiteSpace($payload.transcript_path)) { + $args += @("--agent-session-path", "$($payload.transcript_path)") + } + if ($payload.hook_event_name -eq "SessionStart" -and $payload.source -is [string] -and -not [string]::IsNullOrWhiteSpace($payload.source)) { + $args += @("--session-start-source", "$($payload.source)") + } + & herdr @args 2>$null | Out-Null } catch { } diff --git a/src/integration/assets/claude/herdr-agent-state.sh b/src/integration/assets/claude/herdr-agent-state.sh index 12068a69..ce072189 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=5 +# HERDR_INTEGRATION_VERSION=6 set -eu @@ -59,17 +59,27 @@ 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 +transcript_path = hook_input.get("transcript_path") +agent_session_path = transcript_path if isinstance(transcript_path, str) and transcript_path else None +session_start_source = hook_input.get("source") if hook_event_name == "SessionStart" else None +if not isinstance(session_start_source, str) or not session_start_source: + session_start_source = None if agent_session_id: + params = { + "pane_id": pane_id, + "source": source, + "agent": "claude", + "seq": report_seq, + "agent_session_id": agent_session_id, + } + if agent_session_path: + params["agent_session_path"] = agent_session_path + if session_start_source: + params["session_start_source"] = session_start_source request = { "id": request_id, "method": "pane.report_agent_session", - "params": { - "pane_id": pane_id, - "source": source, - "agent": "claude", - "seq": report_seq, - "agent_session_id": agent_session_id, - }, + "params": params, } else: raise SystemExit(0) diff --git a/src/integration/mod.rs b/src/integration/mod.rs index d483e503..ab1e9e2d 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -27,7 +27,7 @@ const CLAUDE_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/claude/herdr-agent-state.sh") }; -const CLAUDE_INTEGRATION_VERSION: u32 = 5; +const CLAUDE_INTEGRATION_VERSION: u32 = 6; const CLAUDE_CONFIG_DIR_ENV_VAR: &str = "CLAUDE_CONFIG_DIR"; const CODEX_HOOK_INSTALL_NAME: &str = if cfg!(windows) { "herdr-agent-state.ps1" @@ -4241,7 +4241,7 @@ mod tests { assert_eq!(claude.path, hook_path); assert_eq!(claude.installed_version, Some(1)); - assert_eq!(claude.expected_version, 5); + assert_eq!(claude.expected_version, 6); assert_eq!(claude.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); @@ -4271,7 +4271,7 @@ mod tests { assert_eq!(claude.path, hook_path); assert_eq!(claude.installed_version, Some(2)); - assert_eq!(claude.expected_version, 5); + assert_eq!(claude.expected_version, 6); assert_eq!(claude.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); @@ -5542,6 +5542,8 @@ 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("agent_session_path")); + assert!(CLAUDE_HOOK_ASSET.contains("session_start_source")); assert!(CLAUDE_HOOK_ASSET.contains("pane.report_agent_session")); assert!(!CLAUDE_HOOK_ASSET.contains("\"state\": action")); assert!(!CLAUDE_HOOK_ASSET.contains("pane.release_agent")); diff --git a/src/terminal/state.rs b/src/terminal/state.rs index f2de1342..932e0735 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -356,7 +356,7 @@ impl TerminalState { return None; } let session_ref = session_ref.map(|session_ref| { - self.conflicting_current_session_ref(&source, &agent_label, &session_ref) + self.conflicting_current_session_ref(&source, &agent_label, &session_ref, None) .unwrap_or(session_ref) }); if self.live_full_lifecycle_hook_authority_conflicts_with_session( @@ -613,6 +613,7 @@ impl TerminalState { source: &str, agent_label: &str, session_ref: &crate::agent_resume::AgentSessionRef, + session_start_source: Option<&str>, ) -> Option<crate::agent_resume::AgentSessionRef> { self.current_session_identity_for_persistence().and_then( |(current_source, current_agent, current_kind, current_value)| { @@ -620,15 +621,30 @@ impl TerminalState { && current_agent == agent_label && current_kind == crate::agent_resume::AgentSessionRefKind::Id && session_ref.kind == crate::agent_resume::AgentSessionRefKind::Id - && (current_kind != session_ref.kind || current_value != session_ref.value)) - .then_some(crate::agent_resume::AgentSessionRef { - kind: current_kind, - value: current_value, - }) + && (current_kind != session_ref.kind || current_value != session_ref.value) + && !Self::session_start_source_allows_session_replacement( + source, + agent_label, + session_start_source, + )) + .then_some(crate::agent_resume::AgentSessionRef { + kind: current_kind, + value: current_value, + }) }, ) } + fn session_start_source_allows_session_replacement( + source: &str, + agent_label: &str, + session_start_source: Option<&str>, + ) -> bool { + source == "herdr:claude" + && agent_label == "claude" + && matches!(session_start_source, Some("clear" | "resume" | "compact")) + } + pub fn set_persisted_agent_session( &mut self, session: crate::agent_resume::PersistedAgentSession, @@ -643,6 +659,17 @@ impl TerminalState { session_ref: Option<crate::agent_resume::AgentSessionRef>, seq: Option<u64>, ) -> Option<TerminalStateMutation> { + self.set_agent_session_ref_for_session_start(source, agent_label, session_ref, seq, None) + } + + pub fn set_agent_session_ref_for_session_start( + &mut self, + source: String, + agent_label: String, + session_ref: Option<crate::agent_resume::AgentSessionRef>, + seq: Option<u64>, + session_start_source: Option<String>, + ) -> Option<TerminalStateMutation> { let session_ref = session_ref?; if !self.accept_hook_report(&source, seq) { return None; @@ -651,7 +678,12 @@ impl TerminalState { return None; } if self - .conflicting_current_session_ref(&source, &agent_label, &session_ref) + .conflicting_current_session_ref( + &source, + &agent_label, + &session_ref, + session_start_source.as_deref(), + ) .is_some() { return None; @@ -2361,6 +2393,75 @@ mod tests { } #[test] + fn claude_startup_session_ref_does_not_replace_existing_session_ref() { + let mut terminal = test_terminal(); + terminal + .set_agent_session_ref( + "herdr:claude".into(), + "claude".into(), + crate::agent_resume::AgentSessionRef::id("claude-session"), + Some(20), + ) + .expect("initial session should be accepted"); + + let mutation = terminal.set_agent_session_ref_for_session_start( + "herdr:claude".into(), + "claude".into(), + crate::agent_resume::AgentSessionRef::id("nested-session"), + Some(21), + Some("startup".into()), + ); + + assert!(mutation.is_none()); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| session.session_ref.value.as_str()), + Some("claude-session") + ); + } + + #[test] + fn claude_lifecycle_session_ref_replaces_existing_session_ref() { + for session_start_source in ["clear", "resume", "compact"] { + let mut terminal = test_terminal(); + terminal + .set_agent_session_ref( + "herdr:claude".into(), + "claude".into(), + crate::agent_resume::AgentSessionRef::id("claude-session"), + Some(20), + ) + .expect("initial session should be accepted"); + + let next_session = format!("{session_start_source}-session"); + let mutation = terminal + .set_agent_session_ref_for_session_start( + "herdr:claude".into(), + "claude".into(), + crate::agent_resume::AgentSessionRef::id(&next_session), + Some(21), + Some(session_start_source.into()), + ) + .unwrap_or_else(|| panic!("{session_start_source} should replace the session")); + + assert!( + mutation.session_ref_changed, + "{session_start_source} should mark the session changed" + ); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| session.session_ref.value.as_str()), + Some(next_session.as_str()), + "{session_start_source} should store the replacement session" + ); + } + } + + #[test] fn repeated_same_agent_session_ref_is_accepted_without_session_change() { let mut terminal = test_terminal(); terminal |
