use std::path::PathBuf;
use getset::Getters;
use nutype::nutype;
use serde::{Deserialize, Serialize};
use typed_builder::TypedBuilder;
use crate::domain::{
process::{AgentIdentitySource, AgentProtocol, AgentTool},
value::{CommandLine, ProcessName},
};
#[nutype(
sanitize(trim),
validate(not_empty),
derive(
Debug,
Clone,
PartialEq,
Eq,
Hash,
AsRef,
Display,
Serialize,
Deserialize
)
)]
pub struct AgentSessionId(String);
impl AgentSessionId {
pub fn generate() -> Result<Self, AgentSessionIdError> {
Self::try_new(uuid::Uuid::new_v4().to_string())
}
}
#[nutype(
sanitize(trim),
validate(not_empty),
derive(
Debug,
Clone,
PartialEq,
Eq,
Hash,
AsRef,
Display,
Serialize,
Deserialize
)
)]
pub struct NativeSessionId(String);
#[nutype(
validate(greater = 0),
derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Display,
Serialize,
Deserialize
)
)]
pub struct AgentProcessId(u32);
#[nutype(
validate(greater = 0),
derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Display,
Serialize,
Deserialize
)
)]
pub struct AgentProcessStartToken(u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AgentSessionState {
Pending,
Open,
Closed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ShellContext {
Unquoted,
Single,
Double,
Backtick,
TrailingEscape,
}
const SESSION_ID_PLACEHOLDER: &str = "{session_id}";
#[derive(Debug, Clone, Serialize, Deserialize, Getters, TypedBuilder)]
#[getset(get = "pub")]
pub struct AgentSession {
id: AgentSessionId,
name: ProcessName,
tool: AgentTool,
project: PathBuf,
launch_command: CommandLine,
#[builder(default)]
working_dir: Option<PathBuf>,
#[builder(default)]
resume_command: Option<CommandLine>,
#[builder(default)]
native_id: Option<NativeSessionId>,
#[builder(default)]
owner_process_id: Option<AgentProcessId>,
#[builder(default)]
owner_process_start_token: Option<AgentProcessStartToken>,
#[builder(default)]
wrapper_process_id: Option<AgentProcessId>,
state: AgentSessionState,
}
impl AgentSession {
pub fn with_native_id(mut self, native_id: NativeSessionId) -> Self {
self.native_id = Some(native_id);
self
}
pub fn with_owner_process_id(mut self, process_id: AgentProcessId) -> Self {
self.owner_process_id = Some(process_id);
self
}
pub fn with_launch_processes(
mut self,
owner_process_id: AgentProcessId,
owner_process_start_token: Option<AgentProcessStartToken>,
wrapper_process_id: Option<AgentProcessId>,
) -> Self {
self.owner_process_id = Some(owner_process_id);
self.owner_process_start_token = owner_process_start_token;
self.wrapper_process_id = wrapper_process_id;
self
}
pub fn with_state(mut self, state: AgentSessionState) -> Self {
self.state = state;
self
}
pub fn resume(&self) -> Option<CommandLine> {
let native_id = self.native_id.as_ref()?;
if let Some(template) = &self.resume_command {
return Self::expand_resume_template(template, native_id);
}
self.tool.resume_command(&self.launch_command, native_id)
}
pub fn restore_command(&self) -> Option<CommandLine> {
if self.native_id.is_some() {
return self.resume();
}
(self.state == AgentSessionState::Pending
|| self.state == AgentSessionState::Open
&& self.tool.identity_source() == AgentIdentitySource::Assigned)
.then(|| {
self.tool
.new_session_command(&self.launch_command, &self.id)
})
.flatten()
}
pub fn resume_template_is_valid(template: &CommandLine) -> bool {
let template = template.as_ref();
Self::shell_context(template) == ShellContext::Unquoted
&& !Self::contains_here_document(template)
&& (!template.contains(SESSION_ID_PLACEHOLDER)
&& Self::command_text_accepts_provider_arguments(template)
|| template.contains(SESSION_ID_PLACEHOLDER)
&& template
.match_indices(SESSION_ID_PLACEHOLDER)
.all(|(index, _)| {
Self::placeholder_is_unquoted_shell_word(
template,
index,
SESSION_ID_PLACEHOLDER,
)
}))
}
pub fn launch_command_accepts_provider_arguments(command: &CommandLine) -> bool {
Self::command_text_accepts_provider_arguments(command.as_ref())
}
fn command_text_accepts_provider_arguments(command: &str) -> bool {
let mut context = ShellContext::Unquoted;
let mut chars = command.chars();
while let Some(character) = chars.next() {
if context == ShellContext::Unquoted
&& matches!(
character,
'#' | '|' | ';' | '&' | '(' | ')' | '`' | '\n' | '\r'
)
{
return false;
}
context = match (context, character) {
(ShellContext::Unquoted, '\\')
| (ShellContext::Double, '\\')
| (ShellContext::Backtick, '\\') => {
if chars.next().is_some() {
context
} else {
ShellContext::TrailingEscape
}
},
(ShellContext::Unquoted, '\'') => ShellContext::Single,
(ShellContext::Single, '\'') => ShellContext::Unquoted,
(ShellContext::Unquoted, '"') => ShellContext::Double,
(ShellContext::Double, '"') => ShellContext::Unquoted,
(ShellContext::Unquoted, '`') => ShellContext::Backtick,
(ShellContext::Backtick, '`') => ShellContext::Unquoted,
_ => context,
};
}
context == ShellContext::Unquoted
}
fn contains_here_document(command: &str) -> bool {
let mut context = ShellContext::Unquoted;
let mut chars = command.chars().peekable();
while let Some(character) = chars.next() {
if context == ShellContext::Unquoted && character == '<' && chars.peek() == Some(&'<') {
return true;
}
context = match (context, character) {
(ShellContext::Unquoted, '\\')
| (ShellContext::Double, '\\')
| (ShellContext::Backtick, '\\') => {
if chars.next().is_some() {
context
} else {
ShellContext::TrailingEscape
}
},
(ShellContext::Unquoted, '\'') => ShellContext::Single,
(ShellContext::Single, '\'') => ShellContext::Unquoted,
(ShellContext::Unquoted, '"') => ShellContext::Double,
(ShellContext::Double, '"') => ShellContext::Unquoted,
(ShellContext::Unquoted, '`') => ShellContext::Backtick,
(ShellContext::Backtick, '`') => ShellContext::Unquoted,
_ => context,
};
}
false
}
fn expand_resume_template(
template: &CommandLine,
native_id: &NativeSessionId,
) -> Option<CommandLine> {
let quoted = Self::quote_for_command_shell(native_id.as_ref())?;
Self::resume_template_is_valid(template).then_some(())?;
let template = template.as_ref();
let command = if template.contains(SESSION_ID_PLACEHOLDER) {
template.replace(SESSION_ID_PLACEHOLDER, "ed)
} else {
format!("{template} {quoted}")
};
CommandLine::try_new(command).ok()
}
pub(crate) fn quote_for_command_shell(value: &str) -> Option<String> {
#[cfg(windows)]
{
Some(format!(
"\"{}\"",
value.replace('%', "%%").replace('"', "^\"")
))
}
#[cfg(not(windows))]
{
shlex::try_quote(value).ok().map(Into::into)
}
}
fn placeholder_is_unquoted_shell_word(template: &str, index: usize, placeholder: &str) -> bool {
let before = &template[..index];
let after = &template[index + placeholder.len()..];
Self::shell_context(before) == ShellContext::Unquoted
&& Self::prefix_ends_at_shell_boundary(before)
&& after.chars().next().is_none_or(char::is_whitespace)
}
fn prefix_ends_at_shell_boundary(prefix: &str) -> bool {
let Some(last) = prefix.chars().next_back() else {
return true;
};
if !last.is_whitespace() {
return false;
}
prefix
.chars()
.rev()
.skip(1)
.take_while(|character| *character == '\\')
.count()
% 2
== 0
}
fn shell_context(prefix: &str) -> ShellContext {
let mut context = ShellContext::Unquoted;
let mut chars = prefix.chars();
while let Some(character) = chars.next() {
context = match (context, character) {
(ShellContext::Unquoted, '\\')
| (ShellContext::Double, '\\')
| (ShellContext::Backtick, '\\') => {
if chars.next().is_some() {
context
} else {
ShellContext::TrailingEscape
}
},
(ShellContext::Unquoted, '\'') => ShellContext::Single,
(ShellContext::Single, '\'') => ShellContext::Unquoted,
(ShellContext::Unquoted, '"') => ShellContext::Double,
(ShellContext::Double, '"') => ShellContext::Unquoted,
(ShellContext::Unquoted, '`') => ShellContext::Backtick,
(ShellContext::Backtick, '`') => ShellContext::Unquoted,
_ => context,
};
}
context
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expands_custom_resume_templates() {
let session = AgentSession::builder()
.id(AgentSessionId::generate().unwrap())
.name(ProcessName::try_new("Ada").unwrap())
.tool(AgentTool::Custom)
.project(PathBuf::from("/repo/muster.yml"))
.launch_command(CommandLine::try_new("agent").unwrap())
.resume_command(Some(
CommandLine::try_new("agent --resume {session_id}").unwrap(),
))
.native_id(Some(NativeSessionId::try_new("thread one").unwrap()))
.state(AgentSessionState::Closed)
.build();
assert_eq!(
session.resume().unwrap().as_ref(),
"agent --resume 'thread one'"
);
}
#[test]
fn appends_ids_to_complete_placeholder_free_templates() {
let session = AgentSession::builder()
.id(AgentSessionId::generate().unwrap())
.name(ProcessName::try_new("Ada").unwrap())
.tool(AgentTool::Custom)
.project(PathBuf::from("/repo/muster.yml"))
.launch_command(CommandLine::try_new("agent").unwrap())
.resume_command(Some(CommandLine::try_new("agent --resume").unwrap()))
.native_id(Some(NativeSessionId::try_new("thread one").unwrap()))
.state(AgentSessionState::Closed)
.build();
assert_eq!(
session.resume().unwrap().as_ref(),
"agent --resume 'thread one'"
);
}
#[test]
fn rejects_incomplete_placeholder_free_templates() {
for template in ["agent --resume \"", "agent --resume \\"] {
let template = CommandLine::try_new(template).unwrap();
assert!(!AgentSession::resume_template_is_valid(&template));
}
let session = AgentSession::builder()
.id(AgentSessionId::generate().unwrap())
.name(ProcessName::try_new("Ada").unwrap())
.tool(AgentTool::Custom)
.project(PathBuf::from("/repo/muster.yml"))
.launch_command(CommandLine::try_new("agent").unwrap())
.resume_command(Some(CommandLine::try_new("agent --resume \"").unwrap()))
.native_id(Some(
NativeSessionId::try_new("\"; some-command; #").unwrap(),
))
.state(AgentSessionState::Closed)
.build();
assert!(session.resume().is_none());
}
#[test]
fn rejects_shell_compositions_for_provider_arguments() {
let pipeline = CommandLine::try_new("codex | tee agent.log").unwrap();
let sequence = CommandLine::try_new("codex; echo done").unwrap();
let newline = CommandLine::try_new("codex\ntee agent.log").unwrap();
let simple = CommandLine::try_new("FOO=bar codex").unwrap();
assert!(!AgentSession::launch_command_accepts_provider_arguments(
&pipeline
));
assert!(!AgentSession::launch_command_accepts_provider_arguments(
&sequence
));
assert!(!AgentSession::launch_command_accepts_provider_arguments(
&newline
));
assert!(AgentSession::launch_command_accepts_provider_arguments(
&simple
));
}
#[test]
fn rejects_placeholder_free_composed_resume_templates() {
for template in [
"agent --resume | tee agent.log",
"agent --resume # local",
"agent --resume\ntee agent.log",
] {
let template = CommandLine::try_new(template).unwrap();
assert!(!AgentSession::resume_template_is_valid(&template));
}
let explicit = CommandLine::try_new("agent --resume {session_id} | tee agent.log").unwrap();
assert!(AgentSession::resume_template_is_valid(&explicit));
}
#[test]
fn rejects_resume_placeholders_inside_here_documents() {
let template = CommandLine::try_new("cat <<EOF\n{session_id}\nEOF").unwrap();
assert!(!AgentSession::resume_template_is_valid(&template));
}
#[test]
fn rejects_a_quoted_resume_placeholder() {
let session = AgentSession::builder()
.id(AgentSessionId::generate().unwrap())
.name(ProcessName::try_new("Ada").unwrap())
.tool(AgentTool::Custom)
.project(PathBuf::from("/repo/muster.yml"))
.launch_command(CommandLine::try_new("agent").unwrap())
.resume_command(Some(
CommandLine::try_new("agent --resume \"{session_id}\"").unwrap(),
))
.native_id(Some(NativeSessionId::try_new("thread one").unwrap()))
.state(AgentSessionState::Closed)
.build();
assert!(session.resume().is_none());
}
#[test]
fn rejects_a_spaced_placeholder_inside_shell_quotes() {
let session = AgentSession::builder()
.id(AgentSessionId::generate().unwrap())
.name(ProcessName::try_new("Ada").unwrap())
.tool(AgentTool::Custom)
.project(PathBuf::from("/repo/muster.yml"))
.launch_command(CommandLine::try_new("agent").unwrap())
.resume_command(Some(
CommandLine::try_new("agent --resume \"prefix {session_id} suffix\"").unwrap(),
))
.native_id(Some(
NativeSessionId::try_new("$(touch /tmp/muster-owned)").unwrap(),
))
.state(AgentSessionState::Closed)
.build();
assert!(session.resume().is_none());
}
#[test]
fn quotes_shell_metacharacters_in_resume_ids() {
let session = AgentSession::builder()
.id(AgentSessionId::generate().unwrap())
.name(ProcessName::try_new("Ada").unwrap())
.tool(AgentTool::Custom)
.project(PathBuf::from("/repo/muster.yml"))
.launch_command(CommandLine::try_new("agent").unwrap())
.resume_command(Some(
CommandLine::try_new("agent --resume {session_id}").unwrap(),
))
.native_id(Some(
NativeSessionId::try_new("$(touch /tmp/muster-owned)").unwrap(),
))
.state(AgentSessionState::Closed)
.build();
assert_eq!(
session.resume().unwrap().as_ref(),
"agent --resume '$(touch /tmp/muster-owned)'"
);
}
#[cfg(windows)]
#[test]
fn quotes_resume_ids_for_the_windows_command_shell() {
assert_eq!(
AgentSession::quote_for_command_shell("thread & command"),
Some("\"thread & command\"".to_string())
);
}
#[test]
fn restores_an_unconfirmed_assigned_identity_with_a_new_session_command() {
let session = AgentSession::builder()
.id(AgentSessionId::try_new("assigned-session").unwrap())
.name(ProcessName::try_new("Ada").unwrap())
.tool(AgentTool::Claude)
.project(PathBuf::from("/repo/muster.yml"))
.launch_command(CommandLine::try_new("claude").unwrap())
.state(AgentSessionState::Open)
.build();
assert!(session.resume().is_none());
assert_eq!(
session.restore_command().unwrap().as_ref(),
"claude --session-id assigned-session"
);
}
#[test]
fn does_not_reopen_a_closed_unconfirmed_assigned_identity() {
let session = AgentSession::builder()
.id(AgentSessionId::try_new("closed-session").unwrap())
.name(ProcessName::try_new("Ada").unwrap())
.tool(AgentTool::Claude)
.project(PathBuf::from("/repo/muster.yml"))
.launch_command(CommandLine::try_new("claude").unwrap())
.state(AgentSessionState::Closed)
.build();
assert!(session.restore_command().is_none());
}
#[test]
fn restores_a_pending_reported_identity_with_a_new_session_command() {
let session = AgentSession::builder()
.id(AgentSessionId::try_new("pending-session").unwrap())
.name(ProcessName::try_new("Ada").unwrap())
.tool(AgentTool::Codex)
.project(PathBuf::from("/repo/muster.yml"))
.launch_command(CommandLine::try_new("codex").unwrap())
.state(AgentSessionState::Pending)
.build();
assert_eq!(session.restore_command().unwrap().as_ref(), "codex");
}
#[test]
fn restores_a_confirmed_assigned_identity_with_resume() {
let session = AgentSession::builder()
.id(AgentSessionId::try_new("assigned-session").unwrap())
.name(ProcessName::try_new("Ada").unwrap())
.tool(AgentTool::Claude)
.project(PathBuf::from("/repo/muster.yml"))
.launch_command(CommandLine::try_new("claude").unwrap())
.native_id(Some(NativeSessionId::try_new("confirmed-session").unwrap()))
.state(AgentSessionState::Open)
.build();
assert_eq!(
session.restore_command().unwrap().as_ref(),
"claude --resume confirmed-session"
);
}
#[test]
fn does_not_replace_a_confirmed_conversation_when_resume_is_invalid() {
let session = AgentSession::builder()
.id(AgentSessionId::try_new("assigned-session").unwrap())
.name(ProcessName::try_new("Ada").unwrap())
.tool(AgentTool::Claude)
.project(PathBuf::from("/repo/muster.yml"))
.launch_command(CommandLine::try_new("claude").unwrap())
.resume_command(Some(CommandLine::try_new("claude --resume \"").unwrap()))
.native_id(Some(NativeSessionId::try_new("confirmed-session").unwrap()))
.state(AgentSessionState::Open)
.build();
assert!(session.restore_command().is_none());
}
}