use crate::cli::{CliCompressionLevel, OptimizerAction, OverseerAction};
use crate::commands::hook_rewrite::{
build_pretooluse_rewrite_response, hook_cwd, rewrite_bash_command_unless_isolated,
};
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) const IDLE_PARK_DETECT_TIMEOUT: std::time::Duration =
std::time::Duration::from_millis(300);
pub(crate) const SUBAGENT_STOP_EVENT: &str = "SubagentStop";
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 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.before.is_complete() {
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: {err}");
}
if !outcome.repaired {
eprintln!("auto-repair did NOT restore the workspace");
}
}
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(daemon: &trusty_mpm::client::DaemonClient) -> anyhow::Result<()> {
use trusty_mpm::client::{CommandExecutor, CommandResult, TrustyCommand};
daemon
.health_snapshot()
.await
.map_err(|e| anyhow::anyhow!("daemon: unreachable — {e:#}"))?;
let executor = CommandExecutor::from_daemon_client(daemon.clone());
match executor.execute(TrustyCommand::Health).await {
CommandResult::Health(report) if !report.reachable => {
anyhow::bail!("daemon: unreachable ({})", daemon.transport_label());
}
CommandResult::Health(report) => {
let catalog = if report.catalog_unknown {
CATALOG_UNKNOWN_MSG
} else if report.catalog_stale {
CATALOG_STALE_MSG
} else {
"up to date"
};
println!("daemon: {} ({})", report.status, report.url);
println!("resolved: {}", daemon.transport_label());
println!("catalog: {catalog}");
println!(
"fleet: {} session(s), {} awaiting a decision",
report.managed_total, report.managed_pending_decisions
);
}
CommandResult::Error(msg) => anyhow::bail!("health failed: {msg}"),
other => anyhow::bail!("health: unexpected result {other:?}"),
}
Ok(())
}
pub(crate) async fn read_stdin_hook_payload() -> Option<serde_json::Value> {
use crate::commands::hook_stdin::{HOOK_STDIN_TIMEOUT, read_stdin_hook_payload_strict};
read_stdin_hook_payload_strict(HOOK_STDIN_TIMEOUT)
.await
.inspect_err(|e| tracing::debug!("hook stdin payload unavailable: {e}"))
.ok()
}
pub(crate) 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> {
detect_idle_parking_from_payload_in(
payload,
trusty_mpm::core::session_record::claude_config_dir().as_deref(),
)
.await
}
async fn detect_idle_parking_from_payload_in(
payload: Option<&serde_json::Value>,
claude_config_dir: Option<&std::path::Path>,
) -> Option<&'static str> {
const MAX_TRANSCRIPT_TAIL: u64 = 256 * 1024;
let raw = payload?.get("transcript_path")?.as_str()?;
let path =
trusty_mpm::core::session_record::contained_transcript_path(claude_config_dir?, raw)?;
let jsonl = tokio::time::timeout(
IDLE_PARK_DETECT_TIMEOUT,
read_transcript_tail(&path, MAX_TRANSCRIPT_TAIL),
)
.await
.ok()??;
trusty_mpm::core::idle_parking::detect_idle_parking_in_transcript(&jsonl)
}
fn pretooluse_bash_rewrite(
cmd: &str,
tool_input: Option<&serde_json::Value>,
cwd: Option<&std::path::Path>,
) -> Option<String> {
use super::pm_guard_bash::build_lease_rewrite::LeaseRewrite;
let cwd = cwd.unwrap_or(std::path::Path::new(""));
match super::pm_guard_build_lease::decide_rewrite(cmd, tool_input, cwd) {
(LeaseRewrite::Rewrite(new), permission) => Some(
super::pm_guard_build_lease::rewrite_response(tool_input, &new, permission),
),
(LeaseRewrite::Refuse(_), _) => None,
(LeaseRewrite::None, _) => rewrite_bash_command_unless_isolated(cmd, Some(cwd))
.map(|rewritten| build_pretooluse_rewrite_response(&rewritten).to_string()),
}
}
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();
if event == "SessionStart" && !session_id.is_empty() {
trusty_mpm::core::savings_sidecar::emit_staged_row_for_session(&session_id);
}
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(response) =
pretooluse_bash_rewrite(cmd, tool_input, hook_cwd(stdin_payload.as_ref()).as_deref())
{
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();
super::hook_notify::forward_notification(&event, stdin_payload.as_ref(), &cwd);
let payload =
super::hook_payload::build_hook_payload(&cwd, stdin_payload.as_ref(), idle_parking);
let body = serde_json::json!({
"session_id": session_id,
"event": event,
"payload": payload
});
if event == SUBAGENT_STOP_EVENT {
let outcome = super::hook_post::post_hook_with_retry(url, &body).await;
super::hook_post::emit_hook_post_log(&outcome);
if !outcome.delivered {
super::hook_post::spool_undelivered_stop(&body);
}
return Ok(());
}
if event == "SessionStart" || event == "SessionEnd" {
super::hook_notify::post_lifecycle_via_socket(client, url, &body).await;
return Ok(());
}
super::hook_notify::post_best_effort(client, url, &body).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,
single_pane: bool,
explicit: Option<&str>,
) -> 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));
let daemon = trusty_mpm::client::DaemonClient::new(resolved_url)
.with_pinned_base_url(explicit.is_some_and(|u| !u.is_empty()));
trusty_mpm::tui::run_initial_view_with(daemon, 1000, Some(id), single_pane).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);
}
}
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
const PARKING_TEXT: &str =
"I've started a background polling monitor for the checks and will report back once green.";
fn parking_transcript(dir: &Path) -> PathBuf {
let path = dir.join("session.jsonl");
let line = serde_json::json!({
"type": "assistant",
"message": { "content": [{ "type": "text", "text": PARKING_TEXT }] }
});
std::fs::write(&path, format!("{line}\n")).expect("write transcript");
path
}
fn payload(transcript_path: &str) -> serde_json::Value {
serde_json::json!({
"hook_event_name": "Stop",
"session_id": "sess-7278",
"transcript_path": transcript_path,
})
}
#[tokio::test]
async fn detects_parking_in_a_transcript_under_the_config_dir() {
let config = crate::test_support::hermetic_temp_dir();
let transcript = parking_transcript(config.path());
let payload = payload(&transcript.to_string_lossy());
assert!(
super::detect_idle_parking_from_payload_in(Some(&payload), Some(config.path()))
.await
.is_some(),
"an in-boundary transcript must still be scanned"
);
}
#[tokio::test]
async fn refuses_a_transcript_path_outside_the_config_dir() {
let config = crate::test_support::hermetic_temp_dir();
let elsewhere = crate::test_support::hermetic_temp_dir();
let transcript = parking_transcript(elsewhere.path());
let payload = payload(&transcript.to_string_lossy());
assert_eq!(
super::detect_idle_parking_from_payload_in(Some(&payload), Some(config.path())).await,
None,
"a transcript outside the config directory must never be read"
);
}
#[tokio::test]
async fn refuses_a_relative_transcript_path() {
let config = crate::test_support::hermetic_temp_dir();
let _ = parking_transcript(config.path());
let payload = payload("session.jsonl");
assert_eq!(
super::detect_idle_parking_from_payload_in(Some(&payload), Some(config.path())).await,
None,
"a relative transcript_path must be refused"
);
}
#[tokio::test]
async fn refuses_a_traversing_transcript_path() {
let config = crate::test_support::hermetic_temp_dir();
let elsewhere = crate::test_support::hermetic_temp_dir();
let transcript = parking_transcript(elsewhere.path());
let traversing = config
.path()
.join("..")
.join(elsewhere.path().file_name().expect("temp dir name"))
.join(transcript.file_name().expect("file name"));
let payload = payload(&traversing.to_string_lossy());
assert_eq!(
super::detect_idle_parking_from_payload_in(Some(&payload), Some(config.path())).await,
None,
"a `..` component must be refused before the path is resolved"
);
}
#[tokio::test]
async fn refuses_every_transcript_path_without_a_config_dir() {
let config = crate::test_support::hermetic_temp_dir();
let transcript = parking_transcript(config.path());
let payload = payload(&transcript.to_string_lossy());
assert_eq!(
super::detect_idle_parking_from_payload_in(Some(&payload), None).await,
None,
"no boundary means no read, even for a path that would have passed"
);
}
}