aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-15 02:13:30 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-15 02:13:30 +0300
commitdcfc45b45d198434db5057c991e72c089b42c705 (patch)
tree4cbdbc253bb5dc12c734a7ed1cbf82fd444d3cd2 /src
parentc91348e68b9e438159ffecc3e26bc6f971e0fa2a (diff)
fix: preserve claude session rotations
refs #620
Diffstat (limited to 'src')
-rw-r--r--src/agent_resume.rs36
-rw-r--r--src/api/schema/panes.rs2
-rw-r--r--src/app/actions.rs9
-rw-r--r--src/app/api/panes.rs3
-rw-r--r--src/cli/pane.rs12
-rw-r--r--src/events.rs1
-rw-r--r--src/integration/assets/claude/herdr-agent-state.ps123
-rw-r--r--src/integration/assets/claude/herdr-agent-state.sh26
-rw-r--r--src/integration/mod.rs8
-rw-r--r--src/terminal/state.rs115
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