aboutsummaryrefslogtreecommitdiff
path: root/src/agent_resume.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/agent_resume.rs')
-rw-r--r--src/agent_resume.rs302
1 files changed, 302 insertions, 0 deletions
diff --git a/src/agent_resume.rs b/src/agent_resume.rs
new file mode 100644
index 00000000..8f450088
--- /dev/null
+++ b/src/agent_resume.rs
@@ -0,0 +1,302 @@
+use std::path::Path;
+
+use serde::{Deserialize, Serialize};
+
+const MAX_SESSION_ID_LEN: usize = 512;
+const MAX_SESSION_PATH_LEN: usize = 4096;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct AgentSessionRef {
+ pub kind: AgentSessionRefKind,
+ pub value: String,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AgentSessionRefKind {
+ Id,
+ Path,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct AgentResumePlan {
+ pub agent: String,
+ pub argv: Vec<String>,
+ pub dedupe_key: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PersistedAgentSession {
+ pub source: String,
+ pub agent: String,
+ pub session_ref: AgentSessionRef,
+}
+
+impl AgentSessionRef {
+ pub fn id(value: impl Into<String>) -> Option<Self> {
+ let value = value.into();
+ valid_session_id(&value).then_some(Self {
+ kind: AgentSessionRefKind::Id,
+ value,
+ })
+ }
+
+ pub fn path(value: impl Into<String>) -> Option<Self> {
+ let value = value.into();
+ valid_session_path(&value).then_some(Self {
+ kind: AgentSessionRefKind::Path,
+ value,
+ })
+ }
+}
+
+pub fn session_ref_from_report(
+ source: &str,
+ agent: &str,
+ agent_session_id: Option<String>,
+ _agent_session_path: Option<String>,
+) -> Option<AgentSessionRef> {
+ if !is_official_agent_source(source, agent) {
+ return None;
+ }
+
+ if agent == "pi" {
+ return _agent_session_path
+ .and_then(AgentSessionRef::path)
+ .or_else(|| agent_session_id.and_then(AgentSessionRef::id));
+ }
+
+ agent_session_id.and_then(AgentSessionRef::id)
+}
+
+pub fn session_ref_from_snapshot(
+ source: &str,
+ agent: &str,
+ kind: AgentSessionRefKind,
+ value: &str,
+) -> Option<PersistedAgentSession> {
+ if !is_official_agent_source(source, agent) {
+ return None;
+ }
+ let session_ref = match (agent, kind) {
+ ("pi", AgentSessionRefKind::Path) => AgentSessionRef::path(value)?,
+ (_, AgentSessionRefKind::Id) => AgentSessionRef::id(value)?,
+ _ => return None,
+ };
+ Some(PersistedAgentSession {
+ source: source.to_string(),
+ agent: agent.to_string(),
+ session_ref,
+ })
+}
+
+pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option<AgentResumePlan> {
+ if !is_official_agent_source(source, agent) {
+ return None;
+ }
+
+ let argv = match (source, agent, session_ref.kind) {
+ ("herdr:claude", "claude", AgentSessionRefKind::Id) => {
+ vec![
+ "claude".into(),
+ "--resume".into(),
+ session_ref.value.clone(),
+ ]
+ }
+ ("herdr:codex", "codex", AgentSessionRefKind::Id) => {
+ vec!["codex".into(), "resume".into(), session_ref.value.clone()]
+ }
+ ("herdr:pi", "pi", AgentSessionRefKind::Path | AgentSessionRefKind::Id) => {
+ vec!["pi".into(), "--session".into(), session_ref.value.clone()]
+ }
+ ("herdr:hermes", "hermes", AgentSessionRefKind::Id) => {
+ vec![
+ "hermes".into(),
+ "--resume".into(),
+ session_ref.value.clone(),
+ ]
+ }
+ ("herdr:opencode", "opencode", AgentSessionRefKind::Id) => {
+ vec![
+ "opencode".into(),
+ "--session".into(),
+ session_ref.value.clone(),
+ ]
+ }
+ _ => return None,
+ };
+
+ Some(AgentResumePlan {
+ agent: agent.to_string(),
+ argv,
+ dedupe_key: dedupe_key(source, agent, session_ref),
+ })
+}
+
+pub fn dedupe_key(source: &str, agent: &str, session_ref: &AgentSessionRef) -> String {
+ format!(
+ "{source}\u{0}{agent}\u{0}{:?}\u{0}{}",
+ session_ref.kind, session_ref.value
+ )
+}
+
+fn is_official_agent_source(source: &str, agent: &str) -> bool {
+ matches!(
+ (source, agent),
+ ("herdr:claude", "claude")
+ | ("herdr:codex", "codex")
+ | ("herdr:pi", "pi")
+ | ("herdr:hermes", "hermes")
+ | ("herdr:opencode", "opencode")
+ )
+}
+
+fn valid_session_id(value: &str) -> bool {
+ !value.is_empty() && value.len() <= MAX_SESSION_ID_LEN && !value.chars().any(char::is_control)
+}
+
+fn valid_session_path(value: &str) -> bool {
+ !value.is_empty()
+ && value.len() <= MAX_SESSION_PATH_LEN
+ && !value.chars().any(char::is_control)
+ && Path::new(value).is_absolute()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn planner_allows_supported_agents() {
+ assert_eq!(
+ plan(
+ "herdr:claude",
+ "claude",
+ &AgentSessionRef::id("claude-session").unwrap()
+ )
+ .unwrap()
+ .argv,
+ vec!["claude", "--resume", "claude-session"]
+ );
+ assert_eq!(
+ plan(
+ "herdr:codex",
+ "codex",
+ &AgentSessionRef::id("codex-session").unwrap()
+ )
+ .unwrap()
+ .argv,
+ vec!["codex", "resume", "codex-session"]
+ );
+ assert_eq!(
+ plan(
+ "herdr:pi",
+ "pi",
+ &AgentSessionRef::path("/tmp/pi-session.jsonl").unwrap()
+ )
+ .unwrap()
+ .argv,
+ vec!["pi", "--session", "/tmp/pi-session.jsonl"]
+ );
+ assert_eq!(
+ plan(
+ "herdr:hermes",
+ "hermes",
+ &AgentSessionRef::id("hermes-session").unwrap()
+ )
+ .unwrap()
+ .argv,
+ vec!["hermes", "--resume", "hermes-session"]
+ );
+ assert_eq!(
+ plan(
+ "herdr:opencode",
+ "opencode",
+ &AgentSessionRef::id("opencode-session").unwrap()
+ )
+ .unwrap()
+ .argv,
+ vec!["opencode", "--session", "opencode-session"]
+ );
+ }
+
+ #[test]
+ fn planner_rejects_custom_and_unsupported_path_refs() {
+ assert!(plan(
+ "custom:claude",
+ "claude",
+ &AgentSessionRef::id("session").unwrap()
+ )
+ .is_none());
+ assert!(plan(
+ "herdr:claude",
+ "claude",
+ &AgentSessionRef::path("/tmp/claude-session").unwrap()
+ )
+ .is_none());
+ }
+
+ #[test]
+ fn report_ref_prefers_pi_path_and_validates_values() {
+ let session_ref = session_ref_from_report(
+ "herdr:pi",
+ "pi",
+ Some("pi-id".into()),
+ Some("/tmp/pi-session.jsonl".into()),
+ )
+ .unwrap();
+ assert_eq!(session_ref.kind, AgentSessionRefKind::Path);
+ assert_eq!(session_ref.value, "/tmp/pi-session.jsonl");
+
+ assert!(session_ref_from_report("herdr:pi", "pi", Some("bad\nid".into()), None).is_none());
+ assert!(
+ session_ref_from_report("herdr:pi", "pi", None, Some("relative.jsonl".into()))
+ .is_none()
+ );
+ assert!(session_ref_from_report("custom:pi", "pi", Some("pi-id".into()), None).is_none());
+ assert!(session_ref_from_report(
+ "herdr:claude",
+ "claude",
+ None,
+ Some("/tmp/claude-session".into())
+ )
+ .is_none());
+ }
+
+ #[test]
+ fn ids_are_data_not_shell_text() {
+ let id = "abc; rm -rf /";
+ let plan = plan("herdr:codex", "codex", &AgentSessionRef::id(id).unwrap()).unwrap();
+ assert_eq!(plan.argv, vec!["codex", "resume", id]);
+ }
+
+ #[test]
+ fn planner_rejects_path_refs_for_id_only_agents() {
+ assert!(plan(
+ "herdr:hermes",
+ "hermes",
+ &AgentSessionRef::path("/tmp/hermes-session").unwrap()
+ )
+ .is_none());
+ assert!(plan(
+ "herdr:opencode",
+ "opencode",
+ &AgentSessionRef::path("/tmp/opencode-session").unwrap()
+ )
+ .is_none());
+ assert!(session_ref_from_snapshot(
+ "herdr:hermes",
+ "hermes",
+ AgentSessionRefKind::Id,
+ "hermes-session"
+ )
+ .is_some());
+ assert!(session_ref_from_snapshot(
+ "herdr:opencode",
+ "opencode",
+ AgentSessionRefKind::Id,
+ "opencode-session"
+ )
+ .is_some());
+ }
+}