use trusty_mpm::client::ManagedSessionSummary;
use crate::cli::CatalogAction;
pub(crate) fn deprecation_message(old: &str, new: &str) -> String {
format!("warning: '{old}' is deprecated; use '{new}'")
}
pub(crate) fn deprecation_notice(old: &str, new: &str) {
eprintln!("{}", deprecation_message(old, new));
}
pub(crate) fn is_live_session_state(state: &str) -> bool {
state != "decommissioned"
}
pub(crate) fn filter_live_sessions(
sessions: Vec<ManagedSessionSummary>,
) -> Vec<ManagedSessionSummary> {
sessions
.into_iter()
.filter(|s| is_live_session_state(&s.state))
.collect()
}
pub(crate) async fn session_ls(
client: &reqwest::Client,
url: &str,
json: bool,
source_id: Option<&str>,
all: bool,
) -> anyhow::Result<()> {
let endpoint = format!("{url}/api/v1/sessions/managed");
let mut req = client.get(&endpoint);
if let Some(sid) = source_id {
req = req.query(&[("source_id", sid)]);
}
let raw = req.send().await?.error_for_status()?.text().await?;
if json {
println!("{raw}");
return Ok(());
}
let fetched = serde_json::from_str::<trusty_mpm::client::ManagedListResponse>(&raw)?.sessions;
let sessions: Vec<_> = if all {
let mut s = fetched;
s.sort_by_key(|sess| u8::from(sess.state == "decommissioned"));
s
} else {
filter_live_sessions(fetched)
};
if sessions.is_empty() {
if let Some(sid) = source_id {
println!("no managed sessions for {sid}");
} else {
println!("no managed sessions");
}
return Ok(());
}
println!(
"{:<36} {:<14} {:<24} {:<30} CREATED",
"ID", "STATE", "NAME", "TASK"
);
for s in &sessions {
let task = s
.task
.as_deref()
.map(|t| truncate(t, 30))
.unwrap_or_else(|| "(interactive)".to_string());
let created = s
.created_at
.as_deref()
.map(short_timestamp)
.unwrap_or_default();
let pending = s
.pending_decision
.as_deref()
.map(|d| format!(" [pending: {d}]"))
.unwrap_or_default();
println!(
"{:<36} {:<14} {:<24} {:<30} {}{}",
s.id,
s.state,
truncate(&s.name, 24),
task,
created,
pending
);
}
Ok(())
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let end = s
.char_indices()
.nth(max.saturating_sub(1))
.map(|(i, _)| i)
.unwrap_or(s.len());
format!("{}\u{2026}", &s[..end])
}
}
fn short_timestamp(s: &str) -> String {
if s.len() >= 16 {
s[..16].replace('T', " ")
} else {
s.to_string()
}
}
pub(crate) async fn session_activity(
client: &reqwest::Client,
url: &str,
id: String,
) -> anyhow::Result<()> {
let resp = client
.get(format!("{url}/api/v1/sessions/managed/{id}/activity"))
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
println!("not found");
return Ok(());
}
let a: trusty_mpm::client::ManagedActivityResponse = resp.error_for_status()?.json().await?;
let runtime_str = if a.runtime_active {
"running"
} else {
"stopped"
};
println!("runtime: {runtime_str}");
println!("state: {} (confidence: {:.2})", a.state, a.confidence);
println!("summary: {}", a.summary);
let classification_str = a
.classification
.as_deref()
.unwrap_or("(no classifier — raw pane available for agentic inference)");
println!("classification: {classification_str}");
let cache = if a.cache_hit { "hit" } else { "miss" };
println!(
"cache: {} | tokens: in={} out={} | latency: {}ms",
cache, a.input_tokens, a.output_tokens, a.latency_ms
);
println!(
"total: in={} out={}",
a.total_input_tokens, a.total_output_tokens
);
if let Some(pending) = &a.pending_decision {
println!("pending decision: {pending}");
if let Some(default) = &a.proposed_default {
println!(" proposed default: {default}");
}
}
if !a.raw_pane.is_empty() {
if a.pane_stale {
println!("--- pane content (stop-time scrollback snapshot — not real-time) ---");
} else {
println!("--- raw pane (last 60 lines) ---");
}
println!("{}", a.raw_pane);
}
Ok(())
}
pub(crate) async fn session_stop(
client: &reqwest::Client,
url: &str,
id: String,
) -> anyhow::Result<()> {
let resp = client
.post(format!("{url}/api/v1/sessions/managed/{id}/runtime-stop"))
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
println!("not found");
return Ok(());
}
resp.error_for_status()?;
println!("runtime stopped {id} (workspace intact; use 'resume' to restart)");
Ok(())
}
pub(crate) async fn session_resume(
client: &reqwest::Client,
url: &str,
id: String,
) -> anyhow::Result<()> {
let resp = client
.post(format!("{url}/api/v1/sessions/managed/{id}/resume"))
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
println!("not found");
return Ok(());
}
if resp.status() == reqwest::StatusCode::CONFLICT {
let msg = resp.text().await.unwrap_or_default();
println!("cannot resume: {msg}");
return Ok(());
}
let body: ManagedSessionSummary = resp.error_for_status()?.json().await?;
println!("resumed {} ({}) [{}]", body.name, body.id, body.state);
Ok(())
}
pub(crate) async fn session_decommission(
client: &reqwest::Client,
url: &str,
id: String,
) -> anyhow::Result<()> {
let resp = client
.post(format!("{url}/api/v1/sessions/managed/{id}/decommission"))
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
println!("not found");
return Ok(());
}
let body: serde_json::Value = resp.error_for_status()?.json().await?;
let workspace_removed = body
.get("workspace_removed")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let id_display = body
.get("id")
.and_then(|v| v.as_str())
.unwrap_or(id.as_str());
if workspace_removed {
println!("decommissioned {id_display} — workspace removed; tombstone record kept");
if let Some(ws_was) = body.get("workspace_path_was").and_then(|v| v.as_str()) {
let parent = std::path::Path::new(ws_was)
.parent()
.unwrap_or(std::path::Path::new(ws_was));
let _ = std::process::Command::new("git")
.args(["-C", &parent.to_string_lossy(), "worktree", "prune"])
.output();
}
} else {
println!(
"decommissioned {id_display} — record decommissioned; \
workspace left in place (not owned by tm)"
);
}
Ok(())
}
pub(crate) async fn session_decommission_ephemeral(
client: &reqwest::Client,
url: &str,
) -> anyhow::Result<()> {
let resp = client
.post(format!(
"{url}/api/v1/sessions/managed/decommission-ephemeral"
))
.send()
.await?;
let body: serde_json::Value = resp.error_for_status()?.json().await?;
let count = body
.get("decommissioned")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
println!("decommissioned {count} ephemeral session(s)");
Ok(())
}
pub(crate) async fn session_prune(
client: &reqwest::Client,
url: &str,
state: String,
dry_run: bool,
include_active: bool,
) -> anyhow::Result<()> {
let resp = client
.post(format!("{url}/api/v1/sessions/managed/prune"))
.json(&serde_json::json!({
"state": state,
"dry_run": dry_run,
"include_active": include_active,
}))
.send()
.await?;
if resp.status() == reqwest::StatusCode::BAD_REQUEST {
let msg = resp.text().await.unwrap_or_default();
eprintln!("error: {msg}");
return Err(anyhow::anyhow!("prune rejected: {msg}"));
}
let body: serde_json::Value = resp.error_for_status()?.json().await?;
let sessions = body
.get("sessions")
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
for s in &sessions {
let action = s
.get("action")
.and_then(serde_json::Value::as_str)
.unwrap_or("?");
let id = s
.get("id")
.and_then(serde_json::Value::as_str)
.unwrap_or("?");
let name = s
.get("tmux_name")
.and_then(serde_json::Value::as_str)
.unwrap_or("?");
let prior = s
.get("state")
.and_then(serde_json::Value::as_str)
.unwrap_or("?");
println!("{action} {id} {name} [{prior}]");
}
let verb = if dry_run { "would prune" } else { "pruned" };
println!("{verb} {} session(s) (filter={state})", sessions.len());
Ok(())
}
pub(crate) async fn session_prune_worktrees(
client: &reqwest::Client,
url: &str,
dry_run: bool,
) -> anyhow::Result<()> {
let resp = client
.post(format!("{url}/api/v1/sessions/managed/prune-worktrees"))
.json(&serde_json::json!({ "dry_run": dry_run }))
.send()
.await?;
let body: serde_json::Value = resp.error_for_status()?.json().await?;
let paths = body
.get("paths")
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
let mut printed = 0usize;
for p in &paths {
if let Some(s) = p.as_str() {
println!("{s}");
printed += 1;
} else {
eprintln!("warning: prune-worktrees: unexpected non-string path entry: {p}");
}
}
let verb = if dry_run { "would remove" } else { "removed" };
println!("{verb} {printed} orphaned worktree dir(s)");
Ok(())
}
pub(crate) async fn catalog(action: CatalogAction) -> anyhow::Result<()> {
let framework_root = trusty_mpm::core::paths::FrameworkPaths::default().root;
let config = trusty_mpm::core::config::MpmConfig::load_default();
let sync = trusty_mpm::content::CatalogSync::for_framework(
trusty_mpm::provisioner::RealGitBackend::default(),
&framework_root,
Some(&config.manifest),
);
match action {
CatalogAction::Sync { force } => {
let result = sync.sync(force)?;
if result.fetched {
println!(
"catalog synced: {} agents, {} skills",
result.agent_count, result.skill_count
);
} else {
println!(
"catalog cache fresh ({} agents, {} skills); use --force to refetch",
result.agent_count, result.skill_count
);
}
if result.agents_empty() {
println!(
"warning: the synced catalog contains 0 composable agents \
(upstream layout moved); trusty-mpm deploys its bundled agents \
— the catalog agent source is not currently used."
);
}
}
CatalogAction::Ls { json } => {
let agents = sync.list_agents();
let skills = sync.list_skills();
if json {
println!(
"{}",
serde_json::json!({ "agents": agents, "skills": skills })
);
} else {
println!("agents ({}):", agents.len());
for a in &agents {
println!(" {a}");
}
println!("skills ({}):", skills.len());
for s in &skills {
println!(" {s}");
}
}
}
CatalogAction::Status { json } => {
let report = trusty_mpm::core::update_check::detect_for_framework(
&trusty_mpm::core::paths::FrameworkPaths::default(),
&framework_root,
);
if json {
println!(
"{}",
serde_json::json!({
"stale": report.stale,
"unknown": report.unknown,
"changes": report.summary_lines(),
})
);
} else if report.unknown {
println!("catalog: unknown (never synced — run `tm catalog sync`)");
} else if report.stale {
println!("catalog: stale ({} change(s)):", report.changes.len());
for line in report.summary_lines() {
println!(" {line}");
}
println!("run `tm catalog apply` to redeploy the updated content");
} else {
println!("catalog: up to date");
}
}
CatalogAction::Apply { force, prune } => {
let report = trusty_mpm::core::update_check::apply_catalog(
trusty_mpm::provisioner::RealGitBackend::default(),
&trusty_mpm::core::paths::FrameworkPaths::default(),
&framework_root,
force,
prune,
)?;
let synced = if report.fetched {
"synced catalog"
} else {
"catalog cache fresh"
};
println!(
"{synced}; redeployed {} agent(s), {} skill(s)",
report.agents_deployed.len(),
report.skills_deployed.len()
);
if !report.agents_skipped.is_empty() || !report.skills_skipped.is_empty() {
println!(
" skipped (user-modified/unchanged): {} agent(s), {} skill(s)",
report.agents_skipped.len(),
report.skills_skipped.len()
);
}
if prune && (!report.agents_pruned.is_empty() || !report.skills_pruned.is_empty()) {
println!(
" pruned (deselected): {} agent(s), {} skill(s)",
report.agents_pruned.len(),
report.skills_pruned.len()
);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{short_timestamp, truncate};
#[test]
fn truncate_clips_and_appends_ellipsis() {
assert_eq!(truncate("hello", 10), "hello");
assert_eq!(truncate("hello world", 5), "hell\u{2026}");
assert_eq!(truncate("", 5), "");
assert_eq!(truncate("abcde", 5), "abcde");
}
#[test]
fn short_timestamp_formats_correctly() {
assert_eq!(short_timestamp("2025-06-27T14:32:00Z"), "2025-06-27 14:32");
assert_eq!(short_timestamp("short"), "short");
assert_eq!(short_timestamp("2025-06-27T14:32"), "2025-06-27 14:32");
}
#[test]
fn decommission_message_reflects_workspace_removed() {
let owned_removed = serde_json::json!({
"id": "abc-123",
"workspace_removed": true,
"workspace_path_was": "/some/workspace/path"
});
assert_eq!(
owned_removed
.get("workspace_removed")
.and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
owned_removed
.get("workspace_path_was")
.and_then(|v| v.as_str()),
Some("/some/workspace/path")
);
let adopted_not_removed = serde_json::json!({
"id": "xyz-456",
"workspace_removed": false
});
assert_eq!(
adopted_not_removed
.get("workspace_removed")
.and_then(|v| v.as_bool()),
Some(false)
);
assert!(adopted_not_removed.get("workspace_path_was").is_none());
}
}