1use std::path::PathBuf;
2
3use getset::Getters;
4use nutype::nutype;
5use serde::{Deserialize, Serialize};
6use typed_builder::TypedBuilder;
7
8use crate::domain::{
9 process::{AgentIdentitySource, AgentProtocol, AgentTool},
10 value::{CommandLine, ProcessName},
11};
12
13#[nutype(
15 sanitize(trim),
16 validate(not_empty),
17 derive(
18 Debug,
19 Clone,
20 PartialEq,
21 Eq,
22 Hash,
23 AsRef,
24 Display,
25 Serialize,
26 Deserialize
27 )
28)]
29pub struct AgentSessionId(String);
30
31impl AgentSessionId {
32 pub fn generate() -> Result<Self, AgentSessionIdError> {
38 Self::try_new(uuid::Uuid::new_v4().to_string())
39 }
40}
41
42#[nutype(
44 sanitize(trim),
45 validate(not_empty),
46 derive(
47 Debug,
48 Clone,
49 PartialEq,
50 Eq,
51 Hash,
52 AsRef,
53 Display,
54 Serialize,
55 Deserialize
56 )
57)]
58pub struct NativeSessionId(String);
59
60#[nutype(
62 validate(greater = 0),
63 derive(
64 Debug,
65 Clone,
66 Copy,
67 PartialEq,
68 Eq,
69 Hash,
70 Display,
71 Serialize,
72 Deserialize
73 )
74)]
75pub struct AgentProcessId(u32);
76
77#[nutype(
79 validate(greater = 0),
80 derive(
81 Debug,
82 Clone,
83 Copy,
84 PartialEq,
85 Eq,
86 Hash,
87 Display,
88 Serialize,
89 Deserialize
90 )
91)]
92pub struct AgentProcessStartToken(u64);
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "lowercase")]
97pub enum AgentSessionState {
98 Pending,
100 Open,
102 Closed,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108enum ShellContext {
109 Unquoted,
111 Single,
113 Double,
115 Backtick,
117 TrailingEscape,
119}
120
121const SESSION_ID_PLACEHOLDER: &str = "{session_id}";
123
124#[derive(Debug, Clone, Serialize, Deserialize, Getters, TypedBuilder)]
126#[getset(get = "pub")]
127pub struct AgentSession {
128 id: AgentSessionId,
130 name: ProcessName,
132 tool: AgentTool,
134 project: PathBuf,
136 launch_command: CommandLine,
138 #[builder(default)]
140 working_dir: Option<PathBuf>,
141 #[builder(default)]
143 resume_command: Option<CommandLine>,
144 #[builder(default)]
146 native_id: Option<NativeSessionId>,
147 #[builder(default)]
149 owner_process_id: Option<AgentProcessId>,
150 #[builder(default)]
152 owner_process_start_token: Option<AgentProcessStartToken>,
153 #[builder(default)]
155 wrapper_process_id: Option<AgentProcessId>,
156 state: AgentSessionState,
158}
159
160impl AgentSession {
161 pub fn with_native_id(mut self, native_id: NativeSessionId) -> Self {
163 self.native_id = Some(native_id);
164 self
165 }
166
167 pub fn with_owner_process_id(mut self, process_id: AgentProcessId) -> Self {
169 self.owner_process_id = Some(process_id);
170 self
171 }
172
173 pub fn with_launch_processes(
175 mut self,
176 owner_process_id: AgentProcessId,
177 owner_process_start_token: Option<AgentProcessStartToken>,
178 wrapper_process_id: Option<AgentProcessId>,
179 ) -> Self {
180 self.owner_process_id = Some(owner_process_id);
181 self.owner_process_start_token = owner_process_start_token;
182 self.wrapper_process_id = wrapper_process_id;
183 self
184 }
185
186 pub fn with_state(mut self, state: AgentSessionState) -> Self {
188 self.state = state;
189 self
190 }
191
192 pub fn resume(&self) -> Option<CommandLine> {
194 let native_id = self.native_id.as_ref()?;
195 if let Some(template) = &self.resume_command {
196 return Self::expand_resume_template(template, native_id);
197 }
198 self.tool.resume_command(&self.launch_command, native_id)
199 }
200
201 pub fn restore_command(&self) -> Option<CommandLine> {
205 if self.native_id.is_some() {
206 return self.resume();
207 }
208 let never_launched = self.owner_process_id.is_none();
213 (self.state == AgentSessionState::Pending
214 || never_launched
215 || self.state == AgentSessionState::Open
216 && self.tool.identity_source() == AgentIdentitySource::Assigned)
217 .then(|| {
218 self.tool
219 .new_session_command(&self.launch_command, &self.id)
220 })
221 .flatten()
222 }
223
224 pub fn resume_template_is_valid(template: &CommandLine) -> bool {
228 let template = template.as_ref();
229 Self::shell_context(template) == ShellContext::Unquoted
230 && !Self::contains_here_document(template)
231 && (!template.contains(SESSION_ID_PLACEHOLDER)
232 && Self::command_text_accepts_provider_arguments(template)
233 || template.contains(SESSION_ID_PLACEHOLDER)
234 && template
235 .match_indices(SESSION_ID_PLACEHOLDER)
236 .all(|(index, _)| {
237 Self::placeholder_is_unquoted_shell_word(
238 template,
239 index,
240 SESSION_ID_PLACEHOLDER,
241 )
242 }))
243 }
244
245 pub fn launch_command_accepts_provider_arguments(command: &CommandLine) -> bool {
249 Self::command_text_accepts_provider_arguments(command.as_ref())
250 }
251
252 fn command_text_accepts_provider_arguments(command: &str) -> bool {
254 let mut context = ShellContext::Unquoted;
255 let mut chars = command.chars();
256 while let Some(character) = chars.next() {
257 if context == ShellContext::Unquoted
258 && matches!(
259 character,
260 '#' | '|' | ';' | '&' | '(' | ')' | '`' | '\n' | '\r'
261 )
262 {
263 return false;
264 }
265 context = match (context, character) {
266 (ShellContext::Unquoted, '\\')
267 | (ShellContext::Double, '\\')
268 | (ShellContext::Backtick, '\\') => {
269 if chars.next().is_some() {
270 context
271 } else {
272 ShellContext::TrailingEscape
273 }
274 },
275 (ShellContext::Unquoted, '\'') => ShellContext::Single,
276 (ShellContext::Single, '\'') => ShellContext::Unquoted,
277 (ShellContext::Unquoted, '"') => ShellContext::Double,
278 (ShellContext::Double, '"') => ShellContext::Unquoted,
279 (ShellContext::Unquoted, '`') => ShellContext::Backtick,
280 (ShellContext::Backtick, '`') => ShellContext::Unquoted,
281 _ => context,
282 };
283 }
284 context == ShellContext::Unquoted
285 }
286
287 fn contains_here_document(command: &str) -> bool {
290 let mut context = ShellContext::Unquoted;
291 let mut chars = command.chars().peekable();
292 while let Some(character) = chars.next() {
293 if context == ShellContext::Unquoted && character == '<' && chars.peek() == Some(&'<') {
294 return true;
295 }
296 context = match (context, character) {
297 (ShellContext::Unquoted, '\\')
298 | (ShellContext::Double, '\\')
299 | (ShellContext::Backtick, '\\') => {
300 if chars.next().is_some() {
301 context
302 } else {
303 ShellContext::TrailingEscape
304 }
305 },
306 (ShellContext::Unquoted, '\'') => ShellContext::Single,
307 (ShellContext::Single, '\'') => ShellContext::Unquoted,
308 (ShellContext::Unquoted, '"') => ShellContext::Double,
309 (ShellContext::Double, '"') => ShellContext::Unquoted,
310 (ShellContext::Unquoted, '`') => ShellContext::Backtick,
311 (ShellContext::Backtick, '`') => ShellContext::Unquoted,
312 _ => context,
313 };
314 }
315 false
316 }
317
318 fn expand_resume_template(
321 template: &CommandLine,
322 native_id: &NativeSessionId,
323 ) -> Option<CommandLine> {
324 let quoted = Self::quote_for_command_shell(native_id.as_ref())?;
325 Self::resume_template_is_valid(template).then_some(())?;
326 let template = template.as_ref();
327 let command = if template.contains(SESSION_ID_PLACEHOLDER) {
328 template.replace(SESSION_ID_PLACEHOLDER, "ed)
329 } else {
330 format!("{template} {quoted}")
331 };
332 CommandLine::try_new(command).ok()
333 }
334
335 pub(crate) fn quote_for_command_shell(value: &str) -> Option<String> {
337 #[cfg(windows)]
338 {
339 Some(format!(
343 "\"{}\"",
344 value.replace('%', "%%").replace('"', "^\"")
345 ))
346 }
347 #[cfg(not(windows))]
348 {
349 shlex::try_quote(value).ok().map(Into::into)
350 }
351 }
352
353 fn placeholder_is_unquoted_shell_word(template: &str, index: usize, placeholder: &str) -> bool {
356 let before = &template[..index];
357 let after = &template[index + placeholder.len()..];
358 Self::shell_context(before) == ShellContext::Unquoted
359 && Self::prefix_ends_at_shell_boundary(before)
360 && after.chars().next().is_none_or(char::is_whitespace)
361 }
362
363 fn prefix_ends_at_shell_boundary(prefix: &str) -> bool {
366 let Some(last) = prefix.chars().next_back() else {
367 return true;
368 };
369 if !last.is_whitespace() {
370 return false;
371 }
372 prefix
373 .chars()
374 .rev()
375 .skip(1)
376 .take_while(|character| *character == '\\')
377 .count()
378 % 2
379 == 0
380 }
381
382 fn shell_context(prefix: &str) -> ShellContext {
385 let mut context = ShellContext::Unquoted;
386 let mut chars = prefix.chars();
387 while let Some(character) = chars.next() {
388 context = match (context, character) {
389 (ShellContext::Unquoted, '\\')
390 | (ShellContext::Double, '\\')
391 | (ShellContext::Backtick, '\\') => {
392 if chars.next().is_some() {
393 context
394 } else {
395 ShellContext::TrailingEscape
396 }
397 },
398 (ShellContext::Unquoted, '\'') => ShellContext::Single,
399 (ShellContext::Single, '\'') => ShellContext::Unquoted,
400 (ShellContext::Unquoted, '"') => ShellContext::Double,
401 (ShellContext::Double, '"') => ShellContext::Unquoted,
402 (ShellContext::Unquoted, '`') => ShellContext::Backtick,
403 (ShellContext::Backtick, '`') => ShellContext::Unquoted,
404 _ => context,
405 };
406 }
407 context
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[test]
418 fn a_never_launched_session_restores_as_a_new_conversation() {
419 let session = AgentSession::builder()
420 .id(AgentSessionId::try_new("48e80ed9-1d5b-410a-9c20-1023d1cae5f4").unwrap())
421 .name(ProcessName::try_new("Rahul").unwrap())
422 .tool(AgentTool::Codex)
423 .project(PathBuf::from("/p/muster.yml"))
424 .launch_command(CommandLine::try_new("codex").unwrap())
425 .state(AgentSessionState::Open)
426 .build();
427
428 assert!(
429 session.restore_command().is_some(),
430 "no owner was ever bound, so nothing can be lost"
431 );
432 }
433
434 #[test]
437 fn an_owned_uncaptured_session_stays_unrestorable() {
438 let session = AgentSession::builder()
439 .id(AgentSessionId::try_new("48e80ed9-1d5b-410a-9c20-1023d1cae5f4").unwrap())
440 .name(ProcessName::try_new("Rahul").unwrap())
441 .tool(AgentTool::Codex)
442 .project(PathBuf::from("/p/muster.yml"))
443 .launch_command(CommandLine::try_new("codex").unwrap())
444 .state(AgentSessionState::Open)
445 .build()
446 .with_owner_process_id(AgentProcessId::try_new(4242).unwrap());
447
448 assert!(session.restore_command().is_none());
449 }
450
451 #[test]
453 fn expands_custom_resume_templates() {
454 let session = AgentSession::builder()
455 .id(AgentSessionId::generate().unwrap())
456 .name(ProcessName::try_new("Ada").unwrap())
457 .tool(AgentTool::Custom)
458 .project(PathBuf::from("/repo/muster.yml"))
459 .launch_command(CommandLine::try_new("agent").unwrap())
460 .resume_command(Some(
461 CommandLine::try_new("agent --resume {session_id}").unwrap(),
462 ))
463 .native_id(Some(NativeSessionId::try_new("thread one").unwrap()))
464 .state(AgentSessionState::Closed)
465 .build();
466
467 assert_eq!(
468 session.resume().unwrap().as_ref(),
469 "agent --resume 'thread one'"
470 );
471 }
472
473 #[test]
476 fn appends_ids_to_complete_placeholder_free_templates() {
477 let session = AgentSession::builder()
478 .id(AgentSessionId::generate().unwrap())
479 .name(ProcessName::try_new("Ada").unwrap())
480 .tool(AgentTool::Custom)
481 .project(PathBuf::from("/repo/muster.yml"))
482 .launch_command(CommandLine::try_new("agent").unwrap())
483 .resume_command(Some(CommandLine::try_new("agent --resume").unwrap()))
484 .native_id(Some(NativeSessionId::try_new("thread one").unwrap()))
485 .state(AgentSessionState::Closed)
486 .build();
487
488 assert_eq!(
489 session.resume().unwrap().as_ref(),
490 "agent --resume 'thread one'"
491 );
492 }
493
494 #[test]
497 fn rejects_incomplete_placeholder_free_templates() {
498 for template in ["agent --resume \"", "agent --resume \\"] {
499 let template = CommandLine::try_new(template).unwrap();
500 assert!(!AgentSession::resume_template_is_valid(&template));
501 }
502 let session = AgentSession::builder()
503 .id(AgentSessionId::generate().unwrap())
504 .name(ProcessName::try_new("Ada").unwrap())
505 .tool(AgentTool::Custom)
506 .project(PathBuf::from("/repo/muster.yml"))
507 .launch_command(CommandLine::try_new("agent").unwrap())
508 .resume_command(Some(CommandLine::try_new("agent --resume \"").unwrap()))
509 .native_id(Some(
510 NativeSessionId::try_new("\"; some-command; #").unwrap(),
511 ))
512 .state(AgentSessionState::Closed)
513 .build();
514
515 assert!(session.resume().is_none());
516 }
517
518 #[test]
520 fn rejects_shell_compositions_for_provider_arguments() {
521 let pipeline = CommandLine::try_new("codex | tee agent.log").unwrap();
522 let sequence = CommandLine::try_new("codex; echo done").unwrap();
523 let newline = CommandLine::try_new("codex\ntee agent.log").unwrap();
524 let simple = CommandLine::try_new("FOO=bar codex").unwrap();
525
526 assert!(!AgentSession::launch_command_accepts_provider_arguments(
527 &pipeline
528 ));
529 assert!(!AgentSession::launch_command_accepts_provider_arguments(
530 &sequence
531 ));
532 assert!(!AgentSession::launch_command_accepts_provider_arguments(
533 &newline
534 ));
535 assert!(AgentSession::launch_command_accepts_provider_arguments(
536 &simple
537 ));
538 }
539
540 #[test]
543 fn rejects_placeholder_free_composed_resume_templates() {
544 for template in [
545 "agent --resume | tee agent.log",
546 "agent --resume # local",
547 "agent --resume\ntee agent.log",
548 ] {
549 let template = CommandLine::try_new(template).unwrap();
550 assert!(!AgentSession::resume_template_is_valid(&template));
551 }
552 let explicit = CommandLine::try_new("agent --resume {session_id} | tee agent.log").unwrap();
553 assert!(AgentSession::resume_template_is_valid(&explicit));
554 }
555
556 #[test]
558 fn rejects_resume_placeholders_inside_here_documents() {
559 let template = CommandLine::try_new("cat <<EOF\n{session_id}\nEOF").unwrap();
560
561 assert!(!AgentSession::resume_template_is_valid(&template));
562 }
563
564 #[test]
567 fn rejects_a_quoted_resume_placeholder() {
568 let session = AgentSession::builder()
569 .id(AgentSessionId::generate().unwrap())
570 .name(ProcessName::try_new("Ada").unwrap())
571 .tool(AgentTool::Custom)
572 .project(PathBuf::from("/repo/muster.yml"))
573 .launch_command(CommandLine::try_new("agent").unwrap())
574 .resume_command(Some(
575 CommandLine::try_new("agent --resume \"{session_id}\"").unwrap(),
576 ))
577 .native_id(Some(NativeSessionId::try_new("thread one").unwrap()))
578 .state(AgentSessionState::Closed)
579 .build();
580
581 assert!(session.resume().is_none());
582 }
583
584 #[test]
587 fn rejects_a_spaced_placeholder_inside_shell_quotes() {
588 let session = AgentSession::builder()
589 .id(AgentSessionId::generate().unwrap())
590 .name(ProcessName::try_new("Ada").unwrap())
591 .tool(AgentTool::Custom)
592 .project(PathBuf::from("/repo/muster.yml"))
593 .launch_command(CommandLine::try_new("agent").unwrap())
594 .resume_command(Some(
595 CommandLine::try_new("agent --resume \"prefix {session_id} suffix\"").unwrap(),
596 ))
597 .native_id(Some(
598 NativeSessionId::try_new("$(touch /tmp/muster-owned)").unwrap(),
599 ))
600 .state(AgentSessionState::Closed)
601 .build();
602
603 assert!(session.resume().is_none());
604 }
605
606 #[test]
608 fn quotes_shell_metacharacters_in_resume_ids() {
609 let session = AgentSession::builder()
610 .id(AgentSessionId::generate().unwrap())
611 .name(ProcessName::try_new("Ada").unwrap())
612 .tool(AgentTool::Custom)
613 .project(PathBuf::from("/repo/muster.yml"))
614 .launch_command(CommandLine::try_new("agent").unwrap())
615 .resume_command(Some(
616 CommandLine::try_new("agent --resume {session_id}").unwrap(),
617 ))
618 .native_id(Some(
619 NativeSessionId::try_new("$(touch /tmp/muster-owned)").unwrap(),
620 ))
621 .state(AgentSessionState::Closed)
622 .build();
623
624 assert_eq!(
625 session.resume().unwrap().as_ref(),
626 "agent --resume '$(touch /tmp/muster-owned)'"
627 );
628 }
629
630 #[cfg(windows)]
632 #[test]
633 fn quotes_resume_ids_for_the_windows_command_shell() {
634 assert_eq!(
635 AgentSession::quote_for_command_shell("thread & command"),
636 Some("\"thread & command\"".to_string())
637 );
638 }
639
640 #[test]
643 fn restores_an_unconfirmed_assigned_identity_with_a_new_session_command() {
644 let session = AgentSession::builder()
645 .id(AgentSessionId::try_new("assigned-session").unwrap())
646 .name(ProcessName::try_new("Ada").unwrap())
647 .tool(AgentTool::Claude)
648 .project(PathBuf::from("/repo/muster.yml"))
649 .launch_command(CommandLine::try_new("claude").unwrap())
650 .state(AgentSessionState::Open)
651 .build();
652
653 assert!(session.resume().is_none());
654 assert_eq!(
655 session.restore_command().unwrap().as_ref(),
656 "claude --session-id assigned-session"
657 );
658 }
659
660 #[test]
662 fn does_not_reopen_a_closed_unconfirmed_assigned_identity() {
663 let session = AgentSession::builder()
666 .id(AgentSessionId::try_new("closed-session").unwrap())
667 .name(ProcessName::try_new("Ada").unwrap())
668 .tool(AgentTool::Claude)
669 .project(PathBuf::from("/repo/muster.yml"))
670 .launch_command(CommandLine::try_new("claude").unwrap())
671 .state(AgentSessionState::Closed)
672 .build()
673 .with_owner_process_id(AgentProcessId::try_new(4242).unwrap());
674
675 assert!(session.restore_command().is_none());
676 }
677
678 #[test]
680 fn restores_a_pending_reported_identity_with_a_new_session_command() {
681 let session = AgentSession::builder()
682 .id(AgentSessionId::try_new("pending-session").unwrap())
683 .name(ProcessName::try_new("Ada").unwrap())
684 .tool(AgentTool::Codex)
685 .project(PathBuf::from("/repo/muster.yml"))
686 .launch_command(CommandLine::try_new("codex").unwrap())
687 .state(AgentSessionState::Pending)
688 .build();
689
690 assert_eq!(session.restore_command().unwrap().as_ref(), "codex");
691 }
692
693 #[test]
696 fn restores_a_confirmed_assigned_identity_with_resume() {
697 let session = AgentSession::builder()
698 .id(AgentSessionId::try_new("assigned-session").unwrap())
699 .name(ProcessName::try_new("Ada").unwrap())
700 .tool(AgentTool::Claude)
701 .project(PathBuf::from("/repo/muster.yml"))
702 .launch_command(CommandLine::try_new("claude").unwrap())
703 .native_id(Some(NativeSessionId::try_new("confirmed-session").unwrap()))
704 .state(AgentSessionState::Open)
705 .build();
706
707 assert_eq!(
708 session.restore_command().unwrap().as_ref(),
709 "claude --resume confirmed-session"
710 );
711 }
712
713 #[test]
716 fn does_not_replace_a_confirmed_conversation_when_resume_is_invalid() {
717 let session = AgentSession::builder()
718 .id(AgentSessionId::try_new("assigned-session").unwrap())
719 .name(ProcessName::try_new("Ada").unwrap())
720 .tool(AgentTool::Claude)
721 .project(PathBuf::from("/repo/muster.yml"))
722 .launch_command(CommandLine::try_new("claude").unwrap())
723 .resume_command(Some(CommandLine::try_new("claude --resume \"").unwrap()))
724 .native_id(Some(NativeSessionId::try_new("confirmed-session").unwrap()))
725 .state(AgentSessionState::Open)
726 .build();
727
728 assert!(session.restore_command().is_none());
729 }
730}