pub(crate) mod catchup;
mod start;
use serde::Deserialize;
use crate::cli::SessionAction;
use crate::commands::project::resolve_dir;
use crate::formatters::session::{event_summary, print_compression_stats, short_id};
use crate::types::{EventRow, SessionRow};
pub(crate) async fn session(
client: &reqwest::Client,
url: &str,
action: SessionAction,
) -> anyhow::Result<()> {
match action {
SessionAction::Start { dir } => start::start_session(client, url, dir).await?,
SessionAction::Stop { id_or_name } => {
if let Some(managed_id) =
crate::commands::managed_route::resolve_managed_match(client, url, &id_or_name)
.await
{
crate::commands::managed::session_stop(client, url, managed_id).await?;
} else {
let resp = client
.delete(format!("{url}/sessions/{id_or_name}"))
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
anyhow::bail!("session '{id_or_name}' not found");
}
resp.error_for_status()?;
println!("stopped {id_or_name}");
}
}
SessionAction::List { dir } => {
let path = resolve_dir(dir)?;
#[derive(Deserialize)]
struct Body {
sessions: Vec<SessionRow>,
}
let body: Body = client
.get(format!("{url}/sessions"))
.query(&[("project", path.to_string_lossy().as_ref())])
.send()
.await?
.error_for_status()?
.json()
.await?;
if body.sessions.is_empty() {
println!("no sessions for {}", path.display());
println!(
"(looking for a session created via `tm sessions new`? try `tm sessions ls`)"
);
}
for s in &body.sessions {
let status = s.status.as_str().unwrap_or("unknown");
println!("{} {} {}", short_id(&s.id), status, s.workdir);
}
}
SessionAction::Tui {
url: tui_url,
interval_ms,
} => {
let resolved = trusty_mpm::core::resolve_daemon_url(tui_url.as_deref());
trusty_mpm::tui::coordinator::run(resolved, interval_ms).await?;
}
SessionAction::Clean { dir } => {
let _ = resolve_dir(dir)?;
let body: serde_json::Value = client
.delete(format!("{url}/sessions/dead"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let removed = body.get("removed").and_then(|v| v.as_u64()).unwrap_or(0);
println!("reaped {removed} dead session(s)");
}
SessionAction::Info { id_or_name } => {
#[derive(Deserialize)]
struct Body {
sessions: Vec<serde_json::Value>,
}
let body: Body = client
.get(format!("{url}/sessions"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let found = body.sessions.iter().find(|s| {
let id_match = s
.get("id")
.and_then(|v| v.get("0"))
.and_then(|v| v.as_str())
== Some(id_or_name.as_str());
let name_match =
s.get("tmux_name").and_then(|v| v.as_str()) == Some(id_or_name.as_str());
id_match || name_match
});
match found {
Some(s) => println!("{}", serde_json::to_string_pretty(s)?),
None => {
let managed = info_from_managed_store(client, url, &id_or_name).await;
match managed {
Some(val) => println!("{}", serde_json::to_string_pretty(&val)?),
None => anyhow::bail!("session '{id_or_name}' not found"),
}
}
}
}
SessionAction::Instructions { dir } => {
let path = resolve_dir(dir)?;
let (resolved_prompt, _output, _stash) = compose_session_instructions(&path)?;
print!("{resolved_prompt}");
}
SessionAction::Events { id_or_name } => {
let id = match crate::commands::managed_route::resolve_project_session_id(
client,
url,
&id_or_name,
)
.await?
{
Some(id) => id,
None => {
anyhow::bail!("session '{id_or_name}' not found");
}
};
#[derive(Deserialize)]
struct Body {
events: Vec<EventRow>,
}
let body: Body = client
.get(format!("{url}/sessions/{id}/events/poll"))
.send()
.await?
.error_for_status()?
.json()
.await?;
if body.events.is_empty() {
println!("no events for session {id_or_name}");
}
for e in &body.events {
println!("{} {} {}", e.at, e.event, event_summary(&e.payload));
}
}
SessionAction::Breakers => {
#[derive(Deserialize)]
struct Row {
agent: String,
breaker: serde_json::Value,
}
#[derive(Deserialize)]
struct Body {
breakers: Vec<Row>,
}
let body: Body = client
.get(format!("{url}/breakers"))
.send()
.await?
.error_for_status()?
.json()
.await?;
if body.breakers.is_empty() {
println!("no circuit breakers");
} else {
println!("{:<24} {:<12} FAILURES", "AGENT", "STATE");
for r in &body.breakers {
let state = r
.breaker
.get("state")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let failures = r
.breaker
.get("consecutive_failures")
.and_then(|v| v.as_u64())
.unwrap_or(0);
println!("{:<24} {:<12} {}", r.agent, state, failures);
}
}
}
SessionAction::Catchup { all_projects, full } => {
catchup::handle_catchup(all_projects, full).await?;
}
SessionAction::Pause { id_or_name, note } => {
let resp = client
.post(format!("{url}/sessions/{id_or_name}/pause"))
.json(&serde_json::json!({ "summary": note }))
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
anyhow::bail!("session '{id_or_name}' not found");
}
let body: serde_json::Value = resp.error_for_status()?.json().await?;
let summary = body.get("summary").and_then(|v| v.as_str()).unwrap_or("");
println!("paused {id_or_name}: {summary}");
}
SessionAction::Resume { id_or_name } => {
if let Some(managed_id) =
crate::commands::managed_route::resolve_managed_match(client, url, &id_or_name)
.await
{
crate::commands::managed::session_resume(client, url, managed_id).await?;
} else {
let resp = client
.post(format!("{url}/sessions/{id_or_name}/resume"))
.send()
.await?;
match resp.status() {
reqwest::StatusCode::NOT_FOUND => {
anyhow::bail!("session '{id_or_name}' not found");
}
reqwest::StatusCode::CONFLICT => {
println!("session '{id_or_name}' is not paused");
}
_ => {
resp.error_for_status()?;
println!("resumed {id_or_name}");
}
}
}
}
SessionAction::Run {
id_or_name,
command,
summarize,
} => {
let mut req = client.post(format!("{url}/sessions/{id_or_name}/command"));
if summarize {
req = req.query(&[("compress", "summarise")]);
}
let resp = req
.json(&serde_json::json!({ "command": command }))
.send()
.await?;
match resp.status() {
reqwest::StatusCode::NOT_FOUND => {
anyhow::bail!("session '{id_or_name}' not found");
}
reqwest::StatusCode::CONFLICT => {
println!("session '{id_or_name}' is stopped");
}
_ => {
let body: serde_json::Value = resp.error_for_status()?.json().await?;
let output = body.get("output").and_then(|v| v.as_str()).unwrap_or("");
print!("{output}");
print_compression_stats(&body);
}
}
}
SessionAction::Output {
id_or_name,
lines,
summarize,
} => {
let mut query: Vec<(&str, String)> = vec![("lines", lines.to_string())];
if summarize {
query.push(("compress", "summarise".to_string()));
}
let resp = client
.get(format!("{url}/sessions/{id_or_name}/output"))
.query(&query)
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
anyhow::bail!("session '{id_or_name}' not found");
}
let body: serde_json::Value = resp.error_for_status()?.json().await?;
let output = body.get("output").and_then(|v| v.as_str()).unwrap_or("");
print!("{output}");
print_compression_stats(&body);
}
SessionAction::Ls {
terms,
json,
source_id,
current,
all,
} => {
let sid: Option<String> = if current {
derive_source_id_from_cwd()
} else {
source_id
};
let (sort, term) = crate::commands::session_picker::parse_ls_terms(&terms);
let filter = term.map(crate::commands::session_picker::SessionFilter::visible);
crate::commands::managed::session_ls(
client,
url,
json,
sid.as_deref(),
all,
sort,
filter,
)
.await?
}
SessionAction::Activity { id } => {
crate::commands::managed::session_activity(client, url, id).await?
}
SessionAction::PruneIdle { dry_run, json } => {
crate::commands::prune::prune_idle(client, url, dry_run, json).await?
}
SessionAction::DecommissionEphemeral => {
crate::commands::managed::session_decommission_ephemeral(client, url).await?
}
SessionAction::Prune {
state,
dry_run,
include_active,
} => {
crate::commands::managed::session_prune(client, url, state, dry_run, include_active)
.await?
}
SessionAction::PruneWorktrees {
force,
discard_dirty,
merged_prs,
} => {
crate::commands::managed_merged_prs::session_prune_worktrees(
client,
url,
!force,
discard_dirty,
merged_prs,
)
.await?
}
SessionAction::ReconcileWorktrees { json } => {
crate::commands::reconcile_worktrees::session_reconcile_worktrees(client, url, json)
.await?
}
SessionAction::SyncAssets { id: Some(id), .. } => {
crate::commands::sync_assets::session_sync_assets(client, url, id).await?
}
SessionAction::SyncAssets { id: None, .. } => {
crate::commands::sync_assets::session_sync_assets_all(client, url).await?
}
SessionAction::Delete { id, force } => {
crate::commands::delete::session_delete(client, url, id, force).await?
}
SessionAction::Rename { arg1, arg2 } => {
crate::commands::rename::session_rename(client, url, arg1, arg2).await?
}
action @ (SessionAction::ManagedStop { .. }
| SessionAction::RuntimeStop { .. }
| SessionAction::ManagedResume { .. }) => {
emit_managed_alias_notice(&action);
crate::commands::managed_route::run(client, url, &action).await?;
}
action => {
debug_assert!(
crate::commands::managed_route::to_command(&action).is_some(),
"unrouted session action reached the managed fallthrough: {action:?}"
);
crate::commands::managed_route::run(client, url, &action).await?;
}
}
Ok(())
}
fn emit_managed_alias_notice(action: &SessionAction) {
let pair = match action {
SessionAction::ManagedStop { .. } => Some(("managed-stop", "stop")),
SessionAction::RuntimeStop { .. } => Some(("runtime-stop", "stop")),
SessionAction::ManagedResume { .. } => Some(("managed-resume", "resume")),
_ => None,
};
if let Some((old, new)) = pair {
crate::commands::managed::deprecation_notice(old, new);
}
}
pub(crate) fn emit_top_level_alias_notice() {
crate::commands::managed::deprecation_notice("session", "sessions");
}
fn derive_source_id_from_path(dir: &std::path::Path) -> Option<String> {
let url = trusty_mpm::daemon::managed_routes::inproject::get_origin_url(dir)?;
let gh = trusty_common::github_path::parse_github_path(&url)?;
Some(format!("{}/{}", gh.owner, gh.repo))
}
pub(crate) fn derive_source_id_from_cwd() -> Option<String> {
derive_source_id_from_path(&std::env::current_dir().ok()?)
}
fn matches_session(session: &serde_json::Value, id_or_name: &str) -> bool {
let id_match = session.get("id").and_then(|v| v.as_str()) == Some(id_or_name);
let name_match = session.get("name").and_then(|v| v.as_str()) == Some(id_or_name);
id_match || name_match
}
async fn info_from_managed_store(
client: &reqwest::Client,
url: &str,
id_or_name: &str,
) -> Option<serde_json::Value> {
let resp = client
.get(format!("{url}/api/v1/sessions/managed"))
.send()
.await
.ok()?;
let status = resp.status();
if !status.is_success() {
if status != reqwest::StatusCode::NOT_FOUND {
tracing::warn!(
"managed store lookup failed with HTTP {status} — \
'session not found' may mask a daemon error"
);
}
return None;
}
let raw = resp.text().await.ok()?;
let resp: serde_json::Value = serde_json::from_str(&raw).ok()?;
let sessions = resp.get("sessions")?.as_array()?;
sessions
.iter()
.find(|s| matches_session(s, id_or_name))
.cloned()
}
pub(crate) fn compose_session_instructions(
project_dir: &std::path::Path,
) -> anyhow::Result<(
String,
trusty_mpm::core::instruction_pipeline::PipelineOutput,
std::path::PathBuf,
)> {
use trusty_mpm::core::instruction_pipeline::{PipelineInput, build_instructions};
let input = PipelineInput {
project_dir: project_dir.to_path_buf(),
claude_md_path: project_dir.join("CLAUDE.md"),
};
let output = build_instructions(&input)?;
let resolved_prompt = trusty_mpm::core::session_launch::build_system_prompt_for(project_dir);
let stash_dir = trusty_mpm::core::harness_root::harness_dir(project_dir);
std::fs::create_dir_all(&stash_dir)?;
let stash = stash_dir.join("last-instructions.md");
std::fs::write(&stash, &resolved_prompt)?;
Ok((resolved_prompt, output, stash))
}
#[cfg(test)]
#[path = "session_tests.rs"]
mod tests;