use serde::Deserialize;
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));
}
#[derive(Debug, Deserialize)]
pub(crate) struct ManagedSummary {
pub(crate) id: String,
pub(crate) name: String,
state: String,
#[serde(default)]
pending_decision: Option<String>,
}
pub(crate) fn classify_managed_target(
sessions: &[ManagedSummary],
id_or_name: &str,
) -> Option<String> {
sessions
.iter()
.find(|s| s.id == id_or_name || s.name == id_or_name)
.map(|s| s.id.clone())
}
pub(crate) async fn resolve_managed_id(
client: &reqwest::Client,
url: &str,
id_or_name: &str,
) -> Option<String> {
#[derive(Deserialize)]
struct ListResp {
sessions: Vec<ManagedSummary>,
}
let resp = client
.get(format!("{url}/api/v1/sessions/managed"))
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let body: ListResp = resp.json().await.ok()?;
classify_managed_target(&body.sessions, id_or_name)
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn session_new(
client: &reqwest::Client,
url: &str,
repo: String,
git_ref: String,
task: String,
name_hint: Option<String>,
runtime: trusty_mpm::runtime::RuntimeKind,
) -> anyhow::Result<()> {
#[derive(Deserialize)]
struct SpawnResp {
id: String,
name: String,
state: String,
attach_cmd: String,
#[serde(default)]
runtime: String,
}
let resp: SpawnResp = client
.post(format!("{url}/api/v1/sessions/managed"))
.json(&serde_json::json!({
"repo_url": repo,
"ref": git_ref,
"task": task,
"name_hint": name_hint,
"runtime": runtime.as_str(),
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
println!(
"spawned {} ({}) [{}] runtime={}",
resp.name, resp.id, resp.state, resp.runtime
);
println!(" attach: {}", resp.attach_cmd);
Ok(())
}
pub(crate) async fn session_ls(
client: &reqwest::Client,
url: &str,
json: bool,
) -> anyhow::Result<()> {
let raw = client
.get(format!("{url}/api/v1/sessions/managed"))
.send()
.await?
.error_for_status()?
.text()
.await?;
if json {
println!("{raw}");
return Ok(());
}
#[derive(Deserialize)]
struct ListResp {
sessions: Vec<ManagedSummary>,
}
let body: ListResp = serde_json::from_str(&raw)?;
if body.sessions.is_empty() {
println!("no managed sessions");
}
for s in &body.sessions {
let pending = s
.pending_decision
.as_deref()
.map(|d| format!(" pending=\"{d}\""))
.unwrap_or_default();
println!("{} {} {}{}", s.id, s.name, s.state, pending);
}
Ok(())
}
pub(crate) async fn session_activity(
client: &reqwest::Client,
url: &str,
id: String,
) -> anyhow::Result<()> {
#[derive(Deserialize)]
struct ActivityResp {
raw_pane: String,
runtime_active: bool,
state: String,
summary: String,
confidence: f32,
cache_hit: bool,
input_tokens: u32,
output_tokens: u32,
latency_ms: u64,
total_input_tokens: u64,
total_output_tokens: u64,
#[serde(default)]
classification: Option<String>,
#[serde(default)]
pending_decision: Option<String>,
#[serde(default)]
proposed_default: Option<String>,
}
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: ActivityResp = 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() {
println!("--- raw pane (last 60 lines) ---");
println!("{}", a.raw_pane);
}
Ok(())
}
pub(crate) async fn session_send(
client: &reqwest::Client,
url: &str,
id: String,
text: String,
) -> anyhow::Result<()> {
let resp = client
.post(format!("{url}/api/v1/sessions/managed/{id}/send"))
.json(&serde_json::json!({ "text": text }))
.send()
.await?;
handle_simple_ok(resp, "sent").await
}
pub(crate) async fn session_answer(
client: &reqwest::Client,
url: &str,
id: String,
answer: String,
) -> anyhow::Result<()> {
let resp = client
.post(format!("{url}/api/v1/sessions/managed/{id}/answer"))
.json(&serde_json::json!({ "answer": answer }))
.send()
.await?;
handle_simple_ok(resp, "answered").await
}
pub(crate) async fn session_attach(
client: &reqwest::Client,
url: &str,
id: String,
) -> anyhow::Result<()> {
let resp = client
.get(format!("{url}/api/v1/sessions/managed/{id}/attach-cmd"))
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
println!("not found");
return Ok(());
}
#[derive(Deserialize)]
struct AttachResp {
attach_cmd: String,
}
let body: AttachResp = resp.error_for_status()?.json().await?;
println!("{}", body.attach_cmd);
Ok(())
}
pub(crate) async fn session_managed_stop(
client: &reqwest::Client,
url: &str,
id: String,
) -> anyhow::Result<()> {
deprecation_notice("managed-stop", "stop");
session_stop(client, url, id).await
}
pub(crate) async fn session_runtime_stop(
client: &reqwest::Client,
url: &str,
id: String,
) -> anyhow::Result<()> {
deprecation_notice("runtime-stop", "stop");
session_stop(client, url, id).await
}
pub(crate) async fn session_managed_resume(
client: &reqwest::Client,
url: &str,
id: String,
) -> anyhow::Result<()> {
deprecation_notice("managed-resume", "resume");
session_resume(client, url, id).await
}
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(());
}
#[derive(Deserialize)]
struct ResumeResp {
id: String,
name: String,
state: String,
}
let body: ResumeResp = 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(());
}
resp.error_for_status()?;
println!("decommissioned {id} (workspace removed; tombstone record kept)");
Ok(())
}
async fn handle_simple_ok(resp: reqwest::Response, verb: &str) -> anyhow::Result<()> {
if resp.status() == reqwest::StatusCode::NOT_FOUND {
println!("not found");
return Ok(());
}
resp.error_for_status()?;
println!("{verb}");
Ok(())
}
pub(crate) async fn catalog(action: CatalogAction) -> anyhow::Result<()> {
let catalog_dir = dirs::home_dir()
.ok_or_else(|| anyhow::anyhow!("cannot resolve home directory"))?
.join(".trusty-mpm")
.join("catalog");
let sync =
trusty_mpm::content::CatalogSync::new(trusty_mpm::provisioner::RealGitBackend, catalog_dir);
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
);
}
}
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}");
}
}
}
}
Ok(())
}