aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--docs/next/website/src/content/docs/integrations.mdx2
-rw-r--r--docs/next/website/src/content/docs/session-state.mdx2
-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
13 files changed, 216 insertions, 24 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index 267a033e..d42f3d76 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -29,6 +29,7 @@
### Fixed
- Config startup and reload now warn about unknown top-level table sections, including a `[toast]` hint that points to `[ui.toast]`, instead of silently ignoring them.
+- Claude Code session restore now accepts real `/clear`, `/resume`, and compacted session identity changes while still ignoring nested `claude -p` startup sessions that inherit the pane environment. (#620)
## [0.6.10] - 2026-06-11
diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx
index 1506479f..0407bb4a 100644
--- a/docs/next/website/src/content/docs/integrations.mdx
+++ b/docs/next/website/src/content/docs/integrations.mdx
@@ -56,7 +56,7 @@ Custom socket integrations can also report state when they define state that is
Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Droid, Kimi Code CLI, Qoder CLI, Cursor Agent CLI, GitHub Copilot CLI, Pi, Hermes Agent, OpenCode, and Kilo Code CLI 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 `5`, Codex version `5`, GitHub Copilot CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Cursor Agent CLI version `1`, OpenCode version `5`, Kilo Code CLI version `1`, 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 `6`, Codex version `5`, GitHub Copilot CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Cursor Agent CLI version `1`, OpenCode version `5`, Kilo Code CLI version `1`, or Hermes Agent version `2`. OMP integration version `2` reports agent state only. Check installed versions with `herdr integration status`.
## Pi
diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx
index 6e627d67..8edb82d5 100644
--- a/docs/next/website/src/content/docs/session-state.mdx
+++ b/docs/next/website/src/content/docs/session-state.mdx
@@ -67,7 +67,7 @@ Native session restore requires these Herdr integration versions or newer:
| Agent | Minimum Herdr integration version | Resume command |
| --- | --- | --- |
| Pi | `2` | `pi --session <path-or-id>` |
-| Claude Code | `5` | `claude --resume <id>` |
+| Claude Code | `6` | `claude --resume <id>` |
| Codex | `5` | `codex resume <id>` |
| Cursor Agent CLI | `1` | `cursor-agent --resume <id>` |
| GitHub Copilot CLI | `2` | `copilot --resume=<id>` |
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