use std::{ ffi::OsString, path::{Path, PathBuf}, process::Command, }; use anyhow::{Context, Result, bail}; use clap::ValueEnum; use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash, ValueEnum)] #[serde(rename_all = "lowercase")] pub enum Agent { Pi, Claude, Codex, Opencode, Omp, } impl Agent { pub const ALL: [Self; 5] = [ Self::Pi, Self::Claude, Self::Codex, Self::Opencode, Self::Omp, ]; pub fn next_filter(current: Option) -> Option { match current { None => Some(Self::ALL[0]), Some(agent) => Self::ALL .iter() .position(|a| *a == agent) .and_then(|i| Self::ALL.get(i + 1)) .copied(), } } pub fn name(self) -> &'static str { match self { Self::Pi => "pi", Self::Claude => "claude", Self::Codex => "codex", Self::Opencode => "opencode", Self::Omp => "omp", } } } #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Session { pub agent: Agent, pub id: String, pub path: PathBuf, pub cwd: PathBuf, pub title: String, pub modified: u64, pub messages: Vec, } impl Session { pub fn args(&self) -> Vec { match self.agent { Agent::Pi => vec!["--session".into(), self.path.as_os_str().into()], Agent::Claude => vec!["--resume".into(), self.id.clone().into()], Agent::Codex => vec!["resume".into(), self.id.clone().into()], Agent::Opencode => vec!["--session".into(), self.id.clone().into()], Agent::Omp => vec!["--resume".into(), self.path.as_os_str().into()], } } pub fn shell_command(&self) -> String { let args = self .args() .iter() .map(|a| quote(&a.to_string_lossy())) .collect::>() .join(" "); format!( "cd -- {} && {}{} {}", quote(&self.cwd.to_string_lossy()), if self.agent == Agent::Opencode { format!("OPENCODE_DB={} ", quote(&self.path.to_string_lossy())) } else { String::new() }, self.agent.name(), args ) } pub fn resume(&self) -> Result<()> { if !self.cwd.is_dir() { bail!("Session directory no longer exists: {}", self.cwd.display()); } if !self.path.is_file() { bail!("Session file no longer exists: {}", self.path.display()); } let mut command = Command::new(self.agent.name()); command.args(self.args()).current_dir(&self.cwd); if self.agent == Agent::Opencode { // Explicitly select the discovered database, including non-default/channel stores. command.env("OPENCODE_DB", &self.path); } // Do not pass any flags that bypass the agent's permission/trust checks. #[cfg(unix)] { use std::os::unix::process::CommandExt; Err(command.exec()) .with_context(|| format!("Could not start {}; is it on PATH?", self.agent.name())) } #[cfg(not(unix))] { let status = command.status().context("Could not start agent")?; std::process::exit(status.code().unwrap_or(1)); } } } fn quote(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) } pub fn canonical(path: &Path) -> PathBuf { path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) } pub fn compact(s: &str, limit: usize) -> String { let text = s.split_whitespace().collect::>().join(" "); let mut chars = text.chars(); let mut result: String = chars .by_ref() .take(limit) .filter(|c| !c.is_control()) .collect(); if chars.next().is_some() { result.push('…'); } result } #[cfg(test)] mod tests { use super::*; pub fn fixture(agent: Agent) -> Session { Session { agent, id: "uuid".into(), path: "/tmp/a b.jsonl".into(), cwd: "/tmp/project's dir".into(), title: "Example".into(), modified: 1, messages: vec![], } } #[test] fn commands_preserve_arguments_and_quote_paths() { let mut s = fixture(Agent::Pi); assert_eq!( s.args(), vec![ OsString::from("--session"), OsString::from("/tmp/a b.jsonl") ] ); assert!(s.shell_command().contains("project'\\''s dir")); s.agent = Agent::Claude; assert_eq!( s.args(), vec![OsString::from("--resume"), OsString::from("uuid")] ); s.agent = Agent::Codex; assert_eq!( s.args(), vec![OsString::from("resume"), OsString::from("uuid")] ); } #[test] fn new_agent_commands_and_filter_cycle() { let s = fixture(Agent::Omp); assert_eq!( s.args(), [OsString::from("--resume"), OsString::from("/tmp/a b.jsonl")] ); let s = fixture(Agent::Opencode); assert_eq!( s.args(), [OsString::from("--session"), OsString::from("uuid")] ); assert!( s.shell_command() .contains("OPENCODE_DB='/tmp/a b.jsonl' opencode '--session' 'uuid'") ); let mut selected = None; for agent in Agent::ALL { selected = Agent::next_filter(selected); assert_eq!(selected, Some(agent)); } assert_eq!(Agent::next_filter(selected), None); } #[test] fn compact_is_unicode_safe() { assert_eq!(compact(" 日本語\n test", 3), "日本語…"); } }