503 lines
15 KiB
Rust
503 lines
15 KiB
Rust
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 is_reserved_native_state_source(source: &str, agent: &str) -> bool {
|
|
matches!(
|
|
(source, agent),
|
|
("herdr:claude", "claude")
|
|
| ("herdr:codex", "codex")
|
|
| ("herdr:copilot", "copilot")
|
|
| ("herdr:droid", "droid")
|
|
| ("herdr:qodercli", "qodercli")
|
|
| ("herdr:cursor", "cursor")
|
|
)
|
|
}
|
|
|
|
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:copilot", "copilot", AgentSessionRefKind::Id) => {
|
|
vec!["copilot".into(), format!("--resume={}", session_ref.value)]
|
|
}
|
|
("herdr:droid", "droid", AgentSessionRefKind::Id) => {
|
|
vec!["droid".into(), "--resume".into(), session_ref.value.clone()]
|
|
}
|
|
("herdr:kimi", "kimi", AgentSessionRefKind::Id) => {
|
|
vec!["kimi".into(), "--session".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(),
|
|
]
|
|
}
|
|
("herdr:qodercli", "qodercli", AgentSessionRefKind::Id) => {
|
|
vec![
|
|
"qodercli".into(),
|
|
"--resume".into(),
|
|
session_ref.value.clone(),
|
|
]
|
|
}
|
|
("herdr:kilo", "kilo", AgentSessionRefKind::Id) => {
|
|
vec!["kilo".into(), "--session".into(), session_ref.value.clone()]
|
|
}
|
|
("herdr:cursor", "cursor", AgentSessionRefKind::Id) => {
|
|
vec![
|
|
"cursor-agent".into(),
|
|
"--resume".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:copilot", "copilot")
|
|
| ("herdr:droid", "droid")
|
|
| ("herdr:kimi", "kimi")
|
|
| ("herdr:pi", "pi")
|
|
| ("herdr:hermes", "hermes")
|
|
| ("herdr:opencode", "opencode")
|
|
| ("herdr:qodercli", "qodercli")
|
|
| ("herdr:kilo", "kilo")
|
|
| ("herdr:cursor", "cursor")
|
|
)
|
|
}
|
|
|
|
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::*;
|
|
|
|
fn absolute_test_path(name: &str) -> String {
|
|
std::env::current_dir()
|
|
.unwrap()
|
|
.join(name)
|
|
.display()
|
|
.to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn native_state_reservation_excludes_full_lifecycle_sources() {
|
|
assert!(is_reserved_native_state_source("herdr:claude", "claude"));
|
|
assert!(is_reserved_native_state_source("herdr:codex", "codex"));
|
|
assert!(!is_reserved_native_state_source("herdr:kimi", "kimi"));
|
|
assert!(!is_reserved_native_state_source(
|
|
"herdr:opencode",
|
|
"opencode"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn planner_allows_supported_agents() {
|
|
let pi_session = absolute_test_path("pi-session.jsonl");
|
|
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:copilot",
|
|
"copilot",
|
|
&AgentSessionRef::id("copilot-session").unwrap()
|
|
)
|
|
.unwrap()
|
|
.argv,
|
|
vec!["copilot", "--resume=copilot-session"]
|
|
);
|
|
assert_eq!(
|
|
plan(
|
|
"herdr:droid",
|
|
"droid",
|
|
&AgentSessionRef::id("droid-session").unwrap()
|
|
)
|
|
.unwrap()
|
|
.argv,
|
|
vec!["droid", "--resume", "droid-session"]
|
|
);
|
|
assert_eq!(
|
|
plan(
|
|
"herdr:kimi",
|
|
"kimi",
|
|
&AgentSessionRef::id("kimi-session").unwrap()
|
|
)
|
|
.unwrap()
|
|
.argv,
|
|
vec!["kimi", "--session", "kimi-session"]
|
|
);
|
|
assert_eq!(
|
|
plan(
|
|
"herdr:pi",
|
|
"pi",
|
|
&AgentSessionRef::path(&pi_session).unwrap()
|
|
)
|
|
.unwrap()
|
|
.argv,
|
|
vec!["pi", "--session", pi_session.as_str()]
|
|
);
|
|
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"]
|
|
);
|
|
assert_eq!(
|
|
plan(
|
|
"herdr:qodercli",
|
|
"qodercli",
|
|
&AgentSessionRef::id("qoder-session").unwrap()
|
|
)
|
|
.unwrap()
|
|
.argv,
|
|
vec!["qodercli", "--resume", "qoder-session"]
|
|
);
|
|
assert_eq!(
|
|
plan(
|
|
"herdr:kilo",
|
|
"kilo",
|
|
&AgentSessionRef::id("kilo-session").unwrap()
|
|
)
|
|
.unwrap()
|
|
.argv,
|
|
vec!["kilo", "--session", "kilo-session"]
|
|
);
|
|
assert_eq!(
|
|
plan(
|
|
"herdr:cursor",
|
|
"cursor",
|
|
&AgentSessionRef::id("cursor-session").unwrap()
|
|
)
|
|
.unwrap()
|
|
.argv,
|
|
vec!["cursor-agent", "--resume", "cursor-session"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn planner_rejects_custom_and_unsupported_path_refs() {
|
|
let claude_session = absolute_test_path("claude-session");
|
|
assert!(plan(
|
|
"custom:claude",
|
|
"claude",
|
|
&AgentSessionRef::id("session").unwrap()
|
|
)
|
|
.is_none());
|
|
assert!(plan(
|
|
"herdr:claude",
|
|
"claude",
|
|
&AgentSessionRef::path(&claude_session).unwrap()
|
|
)
|
|
.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn report_ref_prefers_pi_path_and_validates_values() {
|
|
let pi_session = absolute_test_path("pi-session.jsonl");
|
|
let claude_session = absolute_test_path("claude-session");
|
|
let copilot_session = absolute_test_path("copilot-session");
|
|
let session_ref = session_ref_from_report(
|
|
"herdr:pi",
|
|
"pi",
|
|
Some("pi-id".into()),
|
|
Some(pi_session.clone()),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(session_ref.kind, AgentSessionRefKind::Path);
|
|
assert_eq!(session_ref.value, pi_session);
|
|
|
|
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(claude_session)).is_none()
|
|
);
|
|
|
|
let session_ref =
|
|
session_ref_from_report("herdr:copilot", "copilot", Some("copilot-id".into()), None)
|
|
.unwrap();
|
|
assert_eq!(session_ref.kind, AgentSessionRefKind::Id);
|
|
assert_eq!(session_ref.value, "copilot-id");
|
|
assert!(
|
|
session_ref_from_report("herdr:copilot", "copilot", None, Some(copilot_session))
|
|
.is_none()
|
|
);
|
|
|
|
let session_ref =
|
|
session_ref_from_report("herdr:droid", "droid", Some("droid-id".into()), None).unwrap();
|
|
assert_eq!(session_ref.kind, AgentSessionRefKind::Id);
|
|
assert_eq!(session_ref.value, "droid-id");
|
|
assert!(session_ref_from_report(
|
|
"herdr:droid",
|
|
"droid",
|
|
None,
|
|
Some("/tmp/droid-session".into())
|
|
)
|
|
.is_none());
|
|
|
|
let session_ref =
|
|
session_ref_from_report("herdr:kimi", "kimi", Some("kimi-id".into()), None).unwrap();
|
|
assert_eq!(session_ref.kind, AgentSessionRefKind::Id);
|
|
assert_eq!(session_ref.value, "kimi-id");
|
|
|
|
let session_ref =
|
|
session_ref_from_report("herdr:kilo", "kilo", Some("kilo-id".into()), None).unwrap();
|
|
assert_eq!(session_ref.kind, AgentSessionRefKind::Id);
|
|
assert_eq!(session_ref.value, "kilo-id");
|
|
|
|
let session_ref =
|
|
session_ref_from_report("herdr:qodercli", "qodercli", Some("qoder-id".into()), None)
|
|
.unwrap();
|
|
assert_eq!(session_ref.kind, AgentSessionRefKind::Id);
|
|
assert_eq!(session_ref.value, "qoder-id");
|
|
}
|
|
|
|
#[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();
|
|
assert_eq!(codex_plan.argv, vec!["codex", "resume", id]);
|
|
|
|
let copilot_plan = plan(
|
|
"herdr:copilot",
|
|
"copilot",
|
|
&AgentSessionRef::id(id).unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(copilot_plan.argv, vec!["copilot", "--resume=abc; rm -rf /"]);
|
|
}
|
|
|
|
#[test]
|
|
fn planner_rejects_path_refs_for_id_only_agents() {
|
|
let hermes_session = absolute_test_path("hermes-session");
|
|
let opencode_session = absolute_test_path("opencode-session");
|
|
let kilo_session = absolute_test_path("kilo-session");
|
|
let copilot_session = absolute_test_path("copilot-session");
|
|
assert!(plan(
|
|
"herdr:hermes",
|
|
"hermes",
|
|
&AgentSessionRef::path(&hermes_session).unwrap()
|
|
)
|
|
.is_none());
|
|
assert!(plan(
|
|
"herdr:opencode",
|
|
"opencode",
|
|
&AgentSessionRef::path(&opencode_session).unwrap()
|
|
)
|
|
.is_none());
|
|
assert!(plan(
|
|
"herdr:kilo",
|
|
"kilo",
|
|
&AgentSessionRef::path(&kilo_session).unwrap()
|
|
)
|
|
.is_none());
|
|
assert!(plan(
|
|
"herdr:copilot",
|
|
"copilot",
|
|
&AgentSessionRef::path(&copilot_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());
|
|
assert!(session_ref_from_snapshot(
|
|
"herdr:kilo",
|
|
"kilo",
|
|
AgentSessionRefKind::Id,
|
|
"kilo-session"
|
|
)
|
|
.is_some());
|
|
assert!(session_ref_from_snapshot(
|
|
"herdr:copilot",
|
|
"copilot",
|
|
AgentSessionRefKind::Id,
|
|
"copilot-session"
|
|
)
|
|
.is_some());
|
|
}
|
|
}
|