use chrono::{DateTime, Utc};
use serde::Serialize;
use trusty_mpm_core::session::SessionStatus;
use crate::services::TmuxService;
use crate::state::DaemonState;
const RECENT_EVENT_LIMIT: usize = 20;
const SESSION_OUTPUT_LINES: u32 = 20;
#[derive(Debug, Clone, Serialize)]
pub struct CoordinatorContext {
pub sessions: Vec<SessionSummary>,
pub recent_events: Vec<EventSummary>,
pub generated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionSummary {
pub id: String,
pub name: String,
pub prefix: String,
pub workdir: String,
pub status: String,
pub active_delegations: u32,
pub recent_output: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct EventSummary {
pub session: String,
pub event: String,
pub at: String,
}
fn derive_prefix(name: &str) -> String {
name.strip_prefix("tmpm-").unwrap_or(name).to_string()
}
fn status_word(status: SessionStatus) -> String {
match status {
SessionStatus::Starting => "Starting",
SessionStatus::Active => "Active",
SessionStatus::AwaitingApproval => "AwaitingApproval",
SessionStatus::Detached => "Detached",
SessionStatus::Paused => "Paused",
SessionStatus::Stopped => "Stopped",
}
.to_string()
}
pub fn build_coordinator_context(state: &DaemonState) -> CoordinatorContext {
let sessions = state
.list_sessions()
.into_iter()
.map(|session| {
let recent_output = if session.status == SessionStatus::Stopped {
Vec::new()
} else {
let raw = TmuxService::capture(&session, SESSION_OUTPUT_LINES);
raw.lines().map(str::to_string).collect()
};
SessionSummary {
id: session.id.0.to_string(),
name: session.tmux_name.clone(),
prefix: derive_prefix(&session.tmux_name),
workdir: session.workdir.clone(),
status: status_word(session.status),
active_delegations: session.active_delegations,
recent_output,
}
})
.collect();
let recent = state.recent_hook_events();
let start = recent.len().saturating_sub(RECENT_EVENT_LIMIT);
let recent_events = recent[start..]
.iter()
.map(|record| EventSummary {
session: record.session.0.to_string(),
event: record.event.wire_name().to_string(),
at: record.at.to_rfc3339(),
})
.collect();
CoordinatorContext {
sessions,
recent_events,
generated_at: Utc::now(),
}
}
pub fn parse_session_prefix(input: &str, sessions: &[SessionSummary]) -> Option<(String, String)> {
let trimmed = input.trim_start();
let (head, rest) = trimmed.split_once(':')?;
let had_at = head.starts_with('@');
let candidate = head.trim_start_matches('@').trim();
if candidate.is_empty() {
return None;
}
let candidate_lc = candidate.to_lowercase();
let matches: Vec<&SessionSummary> = sessions
.iter()
.filter(|s| {
s.name.to_lowercase() == candidate_lc || s.prefix.to_lowercase() == candidate_lc
})
.collect();
let session = match matches.as_slice() {
[one] => *one,
_ if !had_at => return None,
_ => return None,
};
Some((session.name.clone(), rest.trim().to_string()))
}
pub fn coordinator_system_prompt(context: &CoordinatorContext) -> String {
let mut prompt = String::from(
"You are the trusty-mpm coordinator. You have visibility into all active \
Claude Code sessions.\n",
);
if context.sessions.is_empty() {
prompt.push_str("\nThere are no active sessions right now.\n");
} else {
prompt.push_str("\nCurrent sessions:\n");
for s in &context.sessions {
prompt.push_str(&format!(
"- {} (prefix @{}) — workdir {}, status {}, {} active delegation(s)\n",
s.name, s.prefix, s.workdir, s.status, s.active_delegations,
));
if !s.recent_output.is_empty() {
let start = s.recent_output.len().saturating_sub(5);
for line in &s.recent_output[start..] {
prompt.push_str(&format!(" | {line}\n"));
}
}
}
}
if !context.recent_events.is_empty() {
prompt.push_str("\nRecent events:\n");
let start = context.recent_events.len().saturating_sub(10);
for e in &context.recent_events[start..] {
prompt.push_str(&format!("- {} {} ({})\n", e.at, e.event, e.session));
}
}
prompt.push_str(
"\nAnswer questions about session activity, summarize what is happening, and \
help the user manage sessions. To send a command to a specific session, the user \
can prefix their message with @session-name (or the short prefix).\n",
);
prompt
}
#[cfg(test)]
mod tests {
use super::*;
fn summary(name: &str) -> SessionSummary {
SessionSummary {
id: "00000000-0000-0000-0000-000000000000".to_string(),
name: name.to_string(),
prefix: derive_prefix(name),
workdir: "/tmp/proj".to_string(),
status: "Active".to_string(),
active_delegations: 0,
recent_output: Vec::new(),
}
}
#[test]
fn prefix_strips_tmpm() {
assert_eq!(derive_prefix("tmpm-aipowerranking"), "aipowerranking");
assert_eq!(derive_prefix("frontend"), "frontend");
}
#[test]
fn parses_at_prefix() {
let sessions = [summary("tmpm-aipowerranking")];
let parsed = parse_session_prefix("@aipowerranking: run the tests", &sessions);
assert_eq!(
parsed,
Some((
"tmpm-aipowerranking".to_string(),
"run the tests".to_string()
))
);
}
#[test]
fn parses_full_tmux_name() {
let sessions = [summary("tmpm-aipowerranking")];
let parsed = parse_session_prefix("@tmpm-aipowerranking: do X", &sessions);
assert_eq!(
parsed,
Some(("tmpm-aipowerranking".to_string(), "do X".to_string()))
);
}
#[test]
fn bare_prefix_unambiguous() {
let sessions = [summary("tmpm-aipowerranking"), summary("tmpm-other")];
let parsed = parse_session_prefix("aipowerranking: status?", &sessions);
assert_eq!(
parsed,
Some(("tmpm-aipowerranking".to_string(), "status?".to_string()))
);
}
#[test]
fn bare_prefix_ambiguous_is_none() {
let sessions = [summary("tmpm-aipowerranking")];
assert_eq!(
parse_session_prefix("note: this is just prose", &sessions),
None
);
}
#[test]
fn at_prefix_unknown_session_is_none() {
let sessions = [summary("tmpm-aipowerranking")];
assert_eq!(parse_session_prefix("@ghost: hello", &sessions), None);
}
#[test]
fn no_colon_is_none() {
let sessions = [summary("tmpm-aipowerranking")];
assert_eq!(
parse_session_prefix("just a question with no prefix", &sessions),
None
);
}
#[test]
fn empty_prefix_is_none() {
let sessions = [summary("tmpm-aipowerranking")];
assert_eq!(parse_session_prefix("@: hello", &sessions), None);
assert_eq!(parse_session_prefix(": hello", &sessions), None);
}
#[test]
fn system_prompt_lists_sessions() {
let mut s = summary("tmpm-aipowerranking");
s.recent_output = vec!["building…".to_string(), "tests passed".to_string()];
let context = CoordinatorContext {
sessions: vec![s],
recent_events: Vec::new(),
generated_at: Utc::now(),
};
let prompt = coordinator_system_prompt(&context);
assert!(prompt.contains("tmpm-aipowerranking"));
assert!(prompt.contains("prefix @aipowerranking"));
assert!(prompt.contains("tests passed"));
assert!(prompt.contains("@session-name"));
}
#[test]
fn system_prompt_handles_empty() {
let context = CoordinatorContext {
sessions: Vec::new(),
recent_events: Vec::new(),
generated_at: Utc::now(),
};
let prompt = coordinator_system_prompt(&context);
assert!(prompt.contains("no active sessions"));
}
#[test]
fn context_builds_from_state() {
let state = DaemonState::new();
let context = build_coordinator_context(&state);
assert!(context.sessions.is_empty());
assert!(context.recent_events.is_empty());
}
}