cont/src/session.rs

207 lines
5.8 KiB
Rust
Raw Normal View History

2026-09-15 13:13:22 +00:00
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<Self>) -> Option<Self> {
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<String>,
}
impl Session {
pub fn args(&self) -> Vec<OsString> {
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::<Vec<_>>()
.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::<Vec<_>>().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), "日本語…");
}
}