use crate::cli::{CliCompressionLevel, OptimizerAction, OverseerAction};
use crate::commands::daemon::{daemon_healthy, print_status};
use crate::commands::hook_rewrite::{
build_pretooluse_rewrite_response, rewrite_bash_command_for_compression,
};
use crate::formatters::session::short_id;
use crate::types::EventRow;
pub(crate) const SUB_AGENT_ENV: &str = trusty_common::claude_config::CLAUDE_MPM_SUB_AGENT_ENV_VAR;
pub(crate) const DISABLE_HOOKS_ENV: &str = "TRUSTY_MPM_DISABLE_HOOKS";
pub(crate) const CATALOG_UNKNOWN_MSG: &str = "never synced (run 'tm catalog sync' to initialize)";
pub(crate) const CATALOG_STALE_MSG: &str = "updates available (run 'tm catalog sync' to update)";
pub(crate) const NON_GIT_FALLBACK_HINT: &str =
"tm: not in a git project — run 'tm --help' for available commands";
pub(crate) async fn status(client: &reqwest::Client, url: &str) -> anyhow::Result<()> {
if !daemon_healthy(client, url).await {
println!("daemon: unreachable");
return Ok(());
}
print_status(client, url).await
}
pub(crate) async fn events(client: &reqwest::Client, url: &str) -> anyhow::Result<()> {
use serde::Deserialize;
#[derive(Deserialize)]
struct Body {
events: Vec<EventRow>,
}
let body: Body = client
.get(format!("{url}/events/poll"))
.send()
.await?
.error_for_status()?
.json()
.await?;
for e in &body.events {
println!("{} {} {}", e.at, short_id(&e.session), e.event);
}
Ok(())
}
pub(crate) async fn doctor(url: &str, prune_stale_skills: bool) -> anyhow::Result<()> {
use trusty_mpm::client::{CommandExecutor, CommandResult, DaemonClient, TrustyCommand};
use trusty_mpm::core::doctor::CheckStatus;
let executor = CommandExecutor::new(url.to_string());
match executor.execute(TrustyCommand::Doctor).await {
CommandResult::Doctor(report) => {
println!("trusty-mpm doctor");
for check in &report.checks {
println!(
" {} {:<13} {}",
status_icon(check.status),
check.name,
check.message,
);
}
let daemon = DaemonClient::new(url.to_string());
let stale_check = super::doctor_stale::stale_daemon_check(&daemon).await;
println!(
" {} {:<13} {}",
status_icon(stale_check.status),
stale_check.name,
stale_check.message,
);
let overall = report.overall.worst(stale_check.status);
println!(
"\noverall: {} {}",
status_icon(overall),
match overall {
CheckStatus::Ok => "all checks passed",
CheckStatus::Warn => "passed with warnings",
CheckStatus::Fail => "one or more checks failed",
},
);
}
CommandResult::Error(msg) => eprintln!("doctor failed: {msg}"),
other => eprintln!("doctor: unexpected result {other:?}"),
}
if prune_stale_skills {
prune_stale_skills_locally();
}
Ok(())
}
fn prune_stale_skills_locally() {
use trusty_mpm::core::paths::FrameworkPaths;
use trusty_mpm::core::stale_skills::{find_stale_mpm_skills, remove_stale_mpm_skills};
let paths = FrameworkPaths::default();
let dir = paths.claude_skills_dir();
let stale = find_stale_mpm_skills(&dir);
if stale.is_empty() {
println!(
"\nno stale pre-rename mpm-* skills found in {}",
dir.display()
);
return;
}
println!("\nremoving {} stale pre-rename skill(s):", stale.len());
for skill in &stale {
println!(" - {}", skill.name);
}
match remove_stale_mpm_skills(&stale) {
Ok(n) => println!("removed {n} stale skill directory(ies)"),
Err(e) => eprintln!("failed to remove stale skills: {e}"),
}
}
pub(crate) async fn validate(path: Option<std::path::PathBuf>, repair: bool) -> anyhow::Result<()> {
use trusty_mpm::core::deploy_validate::{validate_and_repair, validate_workspace};
use trusty_mpm::core::paths::FrameworkPaths;
let workspace = match path {
Some(p) => p,
None => std::env::current_dir()?,
};
let fw = FrameworkPaths::for_managed_workspace(&workspace);
println!("tm validate: {}", workspace.display());
let complete = if repair {
let outcome = validate_and_repair(&fw, &workspace, None);
if outcome.repaired {
println!(
"auto-repair ran ({} gap(s) found before repair)",
outcome.before.gaps.len()
);
if let Some(err) = &outcome.repair_error {
eprintln!("repair pipeline reported an error (non-fatal): {err}");
}
}
print_gaps(&outcome.after.gaps);
outcome.is_complete()
} else {
let report = validate_workspace(&fw);
print_gaps(&report.gaps);
report.is_complete()
};
if complete {
println!("deployment is complete");
} else {
eprintln!(
"deployment is INCOMPLETE — run `tm validate --path {} --repair` to auto-repair",
workspace.display()
);
std::process::exit(1);
}
Ok(())
}
fn print_gaps(gaps: &[trusty_mpm::core::deploy_validate::DeploymentGap]) {
if gaps.is_empty() {
println!(" no gaps found");
return;
}
for gap in gaps {
println!(" - {}", gap.describe());
}
}
pub(crate) async fn health(url: &str) -> anyhow::Result<()> {
use trusty_mpm::client::{CommandExecutor, CommandResult, TrustyCommand};
use trusty_mpm::core::GATEWAY_PATH;
let executor = CommandExecutor::new(url.to_string());
match executor.execute(TrustyCommand::Health).await {
CommandResult::Health(report) if !report.reachable => {
println!("daemon: unreachable ({})", report.url);
}
CommandResult::Health(report) => {
let catalog = if report.catalog_unknown {
CATALOG_UNKNOWN_MSG
} else if report.catalog_stale {
CATALOG_STALE_MSG
} else {
"up to date"
};
let resolved_via = if url.contains(GATEWAY_PATH) {
format!("via gateway {url}")
} else {
format!("direct {url}")
};
println!("daemon: {} ({})", report.status, report.url);
println!("resolved: {resolved_via}");
println!("catalog: {catalog}");
println!(
"fleet: {} session(s), {} awaiting a decision",
report.managed_total, report.managed_pending_decisions
);
}
CommandResult::Error(msg) => eprintln!("health failed: {msg}"),
other => eprintln!("health: unexpected result {other:?}"),
}
Ok(())
}
fn status_icon(status: trusty_mpm::core::doctor::CheckStatus) -> &'static str {
use trusty_mpm::core::doctor::CheckStatus;
match status {
CheckStatus::Ok => "\u{2705}",
CheckStatus::Warn => "\u{26a0}\u{fe0f}",
CheckStatus::Fail => "\u{274c}",
}
}
pub(crate) async fn read_stdin_hook_payload() -> Option<serde_json::Value> {
use tokio::io::AsyncReadExt;
const HOOK_STDIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
let mut buf = String::new();
let read = tokio::time::timeout(
HOOK_STDIN_TIMEOUT,
tokio::io::stdin().read_to_string(&mut buf),
)
.await;
match read {
Ok(Ok(_)) if !buf.trim().is_empty() => serde_json::from_str(&buf)
.inspect_err(|e| tracing::debug!("hook stdin payload was not valid JSON: {e}"))
.ok(),
Ok(Ok(_)) => None, Ok(Err(e)) => {
tracing::debug!("hook stdin read error: {e}");
None
}
Err(_) => {
tracing::debug!("hook stdin read timed out after {HOOK_STDIN_TIMEOUT:?}");
None
}
}
}
async fn read_transcript_tail(path: &std::path::Path, max_bytes: u64) -> Option<String> {
use tokio::io::{AsyncReadExt, AsyncSeekExt};
let meta = tokio::fs::metadata(path).await.ok()?;
if !meta.file_type().is_file() {
return None;
}
let mut file = tokio::fs::File::open(path).await.ok()?;
let len = file.metadata().await.ok()?.len();
let start = len.saturating_sub(max_bytes);
if start > 0 {
file.seek(std::io::SeekFrom::Start(start)).await.ok()?;
}
let mut bytes = Vec::new();
file.take(max_bytes).read_to_end(&mut bytes).await.ok()?;
Some(String::from_utf8_lossy(&bytes).into_owned())
}
async fn detect_idle_parking_from_payload(
payload: Option<&serde_json::Value>,
) -> Option<&'static str> {
const MAX_TRANSCRIPT_TAIL: u64 = 256 * 1024;
const DETECT_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(300);
let path = payload?.get("transcript_path")?.as_str()?;
let path = std::path::PathBuf::from(path);
let jsonl = tokio::time::timeout(
DETECT_TIMEOUT,
read_transcript_tail(&path, MAX_TRANSCRIPT_TAIL),
)
.await
.ok()??;
trusty_mpm::core::idle_parking::detect_idle_parking_in_transcript(&jsonl)
}
pub(crate) async fn hook(client: &reqwest::Client, url: &str) -> anyhow::Result<()> {
if std::env::var_os(SUB_AGENT_ENV).is_some() {
return Ok(());
}
if std::env::var_os(DISABLE_HOOKS_ENV).is_some() {
return Ok(());
}
let stdin_payload = read_stdin_hook_payload().await;
let event = stdin_payload
.as_ref()
.and_then(|v| v.get("hook_event_name"))
.and_then(|v| v.as_str())
.map(str::to_string)
.or_else(|| std::env::var("CLAUDE_HOOK_EVENT").ok())
.unwrap_or_else(|| "Unknown".to_string());
let session_id = stdin_payload
.as_ref()
.and_then(|v| v.get("session_id"))
.and_then(|v| v.as_str())
.map(str::to_string)
.or_else(|| std::env::var("CLAUDE_SESSION_ID").ok())
.unwrap_or_default();
let tool_name = stdin_payload
.as_ref()
.and_then(|v| v.get("tool_name"))
.and_then(|v| v.as_str());
let tool_input = stdin_payload.as_ref().and_then(|v| v.get("tool_input"));
let bash_command = tool_input
.and_then(|v| v.get("command"))
.and_then(|v| v.as_str());
if event == "PreToolUse"
&& tool_name == Some("Bash")
&& let Some(cmd) = bash_command
&& let Some(rewritten) = rewrite_bash_command_for_compression(cmd)
{
let response = build_pretooluse_rewrite_response(&rewritten);
println!("{response}");
return Ok(());
}
let idle_parking = if matches!(event.as_str(), "Stop" | "SubagentStop") {
detect_idle_parking_from_payload(stdin_payload.as_ref()).await
} else {
None
};
if let Some(phrase) = idle_parking {
eprintln!(
"trusty-mpm: IDLE-PARKING WARNING (#2610) event={event} session={session_id} \
phrase={phrase:?} — the agent ended its turn with parking language; a stopped \
agent cannot be re-woken by a self-spawned monitor. It needs a foreground nudge."
);
}
let cwd = std::env::current_dir()
.ok()
.and_then(|p| p.to_str().map(str::to_owned))
.unwrap_or_default();
let mut payload = serde_json::json!({ "cwd": cwd });
if let Some(phrase) = idle_parking {
payload["idle_parking"] = serde_json::Value::Bool(true);
payload["idle_parking_phrase"] = serde_json::Value::String(phrase.to_string());
}
if let Some(name) = tool_name {
payload["tool"] = serde_json::Value::String(name.to_string());
}
if let Some(input) = tool_input {
payload["input"] = input.clone();
}
let body = serde_json::json!({
"session_id": session_id,
"event": event,
"payload": payload
});
let hook_client = match reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_millis(500))
.timeout(std::time::Duration::from_secs(2))
.build()
{
Ok(c) => c,
Err(_) => {
let req = client
.post(format!("{url}/hooks"))
.timeout(std::time::Duration::from_secs(2))
.json(&body)
.send();
let _ = req.await;
return Ok(());
}
};
let _ = hook_client
.post(format!("{url}/hooks"))
.json(&body)
.send()
.await;
Ok(())
}
pub(crate) async fn coordinator(url: &str, message: String) -> anyhow::Result<()> {
use trusty_mpm::client::{CommandExecutor, CommandResult, TrustyCommand};
let executor = CommandExecutor::new(url.to_string());
match executor
.execute(TrustyCommand::CoordinatorChat { message })
.await
{
CommandResult::ChatReply { reply } => println!("{reply}"),
CommandResult::Error(msg) => {
eprintln!("coordinator: {msg}");
std::process::exit(1);
}
other => eprintln!("unexpected coordinator result: {other:?}"),
}
Ok(())
}
pub(crate) async fn overseer(
client: &reqwest::Client,
url: &str,
action: OverseerAction,
) -> anyhow::Result<()> {
match action {
OverseerAction::Status => {
let body: serde_json::Value = client
.get(format!("{url}/overseer"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let overseer = &body["overseer"];
let enabled = overseer
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let handler = overseer
.get("handler")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
println!(
"overseer: {} (handler: {handler})",
if enabled { "enabled" } else { "disabled" }
);
}
}
Ok(())
}
pub(crate) async fn optimizer(
client: &reqwest::Client,
url: &str,
action: OptimizerAction,
) -> anyhow::Result<()> {
match action {
OptimizerAction::Status => {
let body: serde_json::Value = client
.get(format!("{url}/optimizer"))
.send()
.await?
.json()
.await?;
println!("{}", serde_json::to_string_pretty(&body["optimizer"])?);
if let Some(scope) = body["scope"].as_str() {
println!("\nscope: {scope}");
}
}
OptimizerAction::Set { level } => {
let level_name = match level {
CliCompressionLevel::Off => "Off",
CliCompressionLevel::Trim => "Trim",
CliCompressionLevel::Summarise => "Summarise",
CliCompressionLevel::Caveman => "Caveman",
};
let paths = trusty_mpm::core::paths::FrameworkPaths::default();
let path = paths.optimizer_config();
std::fs::create_dir_all(&paths.hooks)?;
let contents = format!(
"# trusty-mpm token optimizer — framework hook configuration\n\
# Edited by: trusty-mpm optimizer set\n\n\
[default]\nlevel = \"{level_name}\"\n\n\
[tools]\n"
);
std::fs::write(&path, contents)?;
println!("optimizer level set to {level_name} ({})", path.display());
}
}
Ok(())
}
pub(crate) async fn attach_cmd(
client: &reqwest::Client,
url: &str,
target: &str,
json: bool,
) -> anyhow::Result<()> {
use trusty_mpm::core::{ResolveResult, SessionSummary, resolve_target};
let resp: serde_json::Value = client
.get(format!("{url}/sessions"))
.send()
.await?
.json()
.await?;
let empty = vec![];
let raw = resp
.get("sessions")
.and_then(|v| v.as_array())
.unwrap_or(&empty);
let sessions: Vec<SessionSummary> = raw
.iter()
.filter_map(|v| {
Some(SessionSummary {
id: v["id"].as_str()?.to_string(),
name: v["tmux_name"].as_str().map(str::to_string),
workdir: v["workdir"].as_str().unwrap_or("").to_string(),
last_active: v["last_seen"]["secs_since_epoch"].as_u64().unwrap_or(0),
})
})
.collect();
match resolve_target(target, &sessions) {
ResolveResult::Found(id) => {
if json {
if let Some(s) = raw.iter().find(|v| v["id"].as_str() == Some(&id)) {
println!("{}", serde_json::to_string_pretty(s)?);
}
return Ok(());
}
let resolved_url = trusty_mpm::core::resolve_daemon_url(Some(url));
trusty_mpm::tui::run_focused(resolved_url, 1000, Some(id)).await
}
ResolveResult::Ambiguous(ids) => {
eprintln!(
"Ambiguous target '{target}' — matched {} sessions:",
ids.len()
);
for id in &ids {
eprintln!(" {id}");
}
std::process::exit(1);
}
ResolveResult::NotFound => {
eprintln!("No session matched '{target}'.");
if !sessions.is_empty() {
eprintln!("Available sessions:");
for s in &sessions {
let name = s.name.as_deref().unwrap_or("-");
eprintln!(" {} ({}) {}", s.id, name, s.workdir);
}
}
std::process::exit(1);
}
}
}