use serde::Deserialize;
use crate::commands::project::resolve_dir;
use crate::formatters::banner::{
fallback_session_name, normalize_workdir, print_launch_banner,
print_launch_banner_reconnecting, tmux_has_session,
};
struct LaunchSessionGuard {
name: String,
armed: bool,
}
impl LaunchSessionGuard {
fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
armed: true,
}
}
fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for LaunchSessionGuard {
fn drop(&mut self) {
if !self.armed {
return;
}
let _ =
trusty_mpm::core::tmux::run_tmux(&trusty_mpm::core::tmux::TmuxCommand::KillSession {
name: self.name.clone(),
});
}
}
pub(crate) async fn launch(
client: &reqwest::Client,
url: &str,
dir: Option<String>,
style: Option<String>,
worktree: bool,
) -> anyhow::Result<()> {
let live_path = resolve_dir(dir)?;
let live_path = live_path.canonicalize().unwrap_or(live_path);
super::managed_root::try_register_alias(&live_path);
let live_workdir = live_path.to_string_lossy().to_string();
let origin_url = trusty_mpm::daemon::managed_routes::inproject::get_origin_url(&live_path)
.ok_or_else(|| {
anyhow::anyhow!(
"no git origin remote found in '{live_workdir}'\n\
Managed sessions require a GitHub remote. \
Run `tm connect` to start a session in the live checkout instead."
)
})?;
let gh = trusty_common::github_path::parse_github_path(&origin_url).ok_or_else(|| {
anyhow::anyhow!(
"could not parse a GitHub owner/repo from origin remote: {origin_url:?}\n\
Run `tm connect` to start a session in the live checkout instead."
)
})?;
let source_id = format!("{}/{}", gh.owner, gh.repo);
let project_dir =
trusty_mpm::daemon::managed_routes::inproject::base_clone_path(&gh.owner, &gh.repo);
let project_dir_str = project_dir.to_string_lossy().to_string();
if let Some(existing) =
find_existing_session(client, url, &live_workdir, Some(&project_dir_str)).await
&& !existing.is_empty()
{
print_launch_banner_reconnecting(&live_workdir, &existing);
crate::commands::tmux_attach::tmux_attach(&existing)?;
return Ok(());
}
if style.is_some() {
tracing::warn!("--style not yet supported for managed launches; ignoring style flag");
}
let session_uuid = trusty_mpm::session_manager::ManagedSessionId::new();
let workspace = super::managed_workspace::provision_for_launch(
&origin_url,
&project_dir,
&live_path,
worktree,
&session_uuid,
)
.await?;
let managed_path = workspace.path().to_path_buf();
{
match trusty_mpm::core::session_launch::prepare_isolated_session(
&managed_path,
Some(&origin_url),
) {
Ok(report) => {
for err in &report.roster_errors {
tracing::error!(
"roster provisioning gap for worktree {}: {err}",
managed_path.display()
);
}
}
Err(e) if e.is_fatal() => {
anyhow::bail!("{e}");
}
Err(e) => {
tracing::warn!(
"session prep failed for worktree {} (non-fatal): {e}",
managed_path.display()
);
}
}
if let Err(e) = trusty_mpm::core::home_trust_seed::preseed_home_trust(&managed_path) {
tracing::warn!(
"home trust pre-seed failed for {} (non-fatal): {e}",
managed_path.display()
);
}
}
let managed_workdir = managed_path.to_string_lossy().to_string();
if let Err(e) = crate::commands::install::remove_global_trusty_mpm_hooks() {
eprintln!("warning: could not remove global MPM hooks: {e:#}");
}
if let Err(e) = crate::commands::install::write_project_hooks_for_dir(&managed_path) {
eprintln!("warning: could not write project-scoped MPM hooks: {e:#}");
}
#[derive(Deserialize)]
struct Body {
#[serde(default)]
name: String,
id: Option<trusty_mpm::core::session::SessionId>,
}
let folder_name = fallback_session_name(&live_path);
let (tmux_name, session_id) = match client
.post(format!("{url}/sessions"))
.json(&serde_json::json!({
"project": managed_workdir,
"project_path": managed_workdir,
"name": folder_name,
"source_id": source_id,
}))
.send()
.await
{
Ok(resp) => match resp.error_for_status() {
Ok(resp) => match resp.json::<Body>().await {
Ok(body) if !body.name.is_empty() => (body.name, body.id),
Ok(body) => (folder_name.clone(), body.id),
_ => (folder_name.clone(), None),
},
Err(err) => {
eprintln!("warning: daemon rejected session registration: {err}");
(folder_name.clone(), None)
}
},
Err(err) => {
eprintln!("warning: daemon unreachable ({err}); launching without registration");
(folder_name.clone(), None)
}
};
let mpm_cfg = trusty_mpm::core::config::MpmConfig::load_effective_default(Some(&managed_path));
let pm_model = trusty_mpm::core::model_inject::resolve_pm_model(&mpm_cfg, None);
let prompt =
trusty_mpm::core::session_launch::build_system_prompt_for_with_style(&managed_path, None);
let prompt_path = trusty_mpm::core::model_inject::write_prompt_file(&prompt);
if prompt_path.is_none() {
eprintln!("warning: failed to write system prompt file; launching without prompt");
}
let claude_cmd = trusty_mpm::core::spawn_disclaim::disclaim_pane_command(
&trusty_mpm::core::model_inject::build_claude_command(
Some(&pm_model),
prompt_path.as_deref(),
),
);
print_launch_banner(
&live_workdir,
&tmux_name,
prompt_path.as_deref(),
Some(&managed_path),
);
let new_session =
trusty_mpm::core::tmux::create_managed_session(None, &tmux_name, Some(&managed_workdir));
let outcome = match new_session {
Ok(o) if o.output.status.success() => o,
_ => anyhow::bail!("failed to create tmux session {tmux_name} in {managed_workdir}"),
};
if !outcome.options_verified {
eprintln!(
"warning: tmux scrollback limit could not be verified for session {tmux_name} — \
the pane may be capped at tmux's factory 2000-line history-limit instead of the \
configured value; see issue #3386"
);
}
let mut session_guard = LaunchSessionGuard::new(&tmux_name);
let send = trusty_mpm::core::tmux::send_line(
None,
&trusty_mpm::core::tmux::TmuxTarget::session(&tmux_name),
&claude_cmd,
)
.map(|output| output.status);
if !matches!(send, Ok(s) if s.success()) {
anyhow::bail!("tmux session {tmux_name} created but failed to start claude");
}
if let Some(session_id) = session_id {
let claude_pid = trusty_mpm::core::process::find_claude_pid_in_tmux(
&tmux_name,
10,
std::time::Duration::from_millis(500),
);
if let Some(pid) = claude_pid {
let _ = client
.patch(format!("{url}/sessions/{}/pid", session_id.0))
.json(&serde_json::json!({ "pid": pid }))
.send()
.await;
tracing::info!(
"claude process PID {pid} registered for session {}",
session_id.0
);
} else {
tracing::warn!("could not find claude PID for session {tmux_name} after retries");
}
}
crate::commands::tmux_attach::tmux_attach(&tmux_name)?;
session_guard.disarm();
Ok(())
}
pub(crate) async fn connect(
client: &reqwest::Client,
url: &str,
dir: Option<String>,
) -> anyhow::Result<()> {
let path = resolve_dir(dir)?;
let path = path.canonicalize().unwrap_or(path);
let workdir = path.to_string_lossy().to_string();
super::managed_root::try_register_alias(&path);
if let Some(existing) = find_existing_session(client, url, &workdir, None).await
&& !existing.is_empty()
{
print_launch_banner_reconnecting(&workdir, &existing);
crate::commands::tmux_attach::tmux_attach(&existing)?;
return Ok(());
}
match trusty_mpm::core::session_launch::prepare_isolated_session(&path, None) {
Ok(report) => {
for err in &report.roster_errors {
tracing::error!("roster provisioning gap for {}: {err}", path.display());
}
}
Err(err) if err.is_fatal() => {
anyhow::bail!("{err}");
}
Err(err) => {
tracing::warn!(
"session prep failed for {} (non-fatal): {err}",
path.display()
);
}
}
let native = trusty_mpm::core::output_style::claude_supports_native_output_style();
let prompt = trusty_mpm::core::session_launch::build_system_prompt_for_with_style_and_native(
&path, None, native,
);
let prompt_path = trusty_mpm::core::model_inject::write_prompt_file(&prompt);
if prompt_path.is_none() {
eprintln!("warning: failed to write system prompt file; connecting without prompt");
}
#[derive(Deserialize)]
struct Body {
#[serde(default)]
name: String,
}
let folder_name = fallback_session_name(&path);
let tmux_name = match client
.post(format!("{url}/api/v1/sessions/connect"))
.json(&serde_json::json!({
"project": path,
"project_path": path,
"name": folder_name,
}))
.send()
.await
{
Ok(resp) => match resp.error_for_status() {
Ok(resp) => match resp.json::<Body>().await {
Ok(body) if !body.name.is_empty() => body.name,
_ => folder_name.clone(),
},
Err(err) => {
eprintln!("warning: daemon rejected session registration: {err}");
folder_name.clone()
}
},
Err(err) => {
eprintln!("warning: daemon unreachable ({err}); connecting without registration");
folder_name.clone()
}
};
print_launch_banner(&workdir, &tmux_name, prompt_path.as_deref(), None);
let already_running = tmux_has_session(&tmux_name);
let new_session =
trusty_mpm::core::tmux::create_managed_session(None, &tmux_name, Some(&workdir));
let outcome = match new_session {
Ok(o) if o.output.status.success() => o,
_ => anyhow::bail!("failed to create tmux session {tmux_name} in {workdir}"),
};
if !outcome.options_verified {
eprintln!(
"warning: tmux scrollback limit could not be verified for session {tmux_name} — \
the pane may be capped at tmux's factory 2000-line history-limit instead of the \
configured value; see issue #3386"
);
}
let mut session_guard = if already_running {
None
} else {
Some(LaunchSessionGuard::new(&tmux_name))
};
if !already_running {
let claude_cmd = trusty_mpm::core::spawn_disclaim::disclaim_pane_command(
&connect_claude_cmd(prompt_path.as_deref()),
);
let send = trusty_mpm::core::tmux::send_line(
None,
&trusty_mpm::core::tmux::TmuxTarget::session(&tmux_name),
&claude_cmd,
)
.map(|output| output.status);
if !matches!(send, Ok(s) if s.success()) {
anyhow::bail!("tmux session {tmux_name} created but failed to start claude");
}
}
crate::commands::tmux_attach::tmux_attach(&tmux_name)?;
if let Some(ref mut g) = session_guard {
g.disarm();
}
Ok(())
}
pub(crate) fn connect_claude_cmd(prompt_file: Option<&std::path::Path>) -> String {
trusty_mpm::core::model_inject::build_claude_command(None, prompt_file)
}
async fn find_existing_session(
client: &reqwest::Client,
url: &str,
workdir: &str,
project_dir: Option<&str>,
) -> Option<String> {
#[derive(Deserialize)]
struct Row {
#[serde(default)]
workdir: String,
#[serde(default)]
tmux_name: String,
}
let resp = client.get(format!("{url}/sessions")).send().await.ok()?;
let rows: Vec<Row> = resp.error_for_status().ok()?.json().await.ok()?;
rows.into_iter()
.find(|r| {
!r.tmux_name.is_empty()
&& session_matches_workdir(&r.workdir, workdir, project_dir)
&& tmux_has_session(&r.tmux_name)
})
.map(|r| r.tmux_name)
}
fn session_matches_workdir(session_workdir: &str, target: &str, project_dir: Option<&str>) -> bool {
let w = normalize_workdir(session_workdir);
let t = normalize_workdir(target);
if w == t {
return true;
}
let Some(proj) = project_dir else {
return false;
};
let mut prefix = normalize_workdir(proj);
if !prefix.ends_with('/') {
prefix.push('/');
}
w.starts_with(prefix.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_matches_workdir_exact() {
assert!(session_matches_workdir(
"/home/bob/projects/trusty-tools",
"/home/bob/projects/trusty-tools",
None
));
assert!(session_matches_workdir(
"/home/bob/projects/trusty-tools/",
"/home/bob/projects/trusty-tools",
None
));
}
#[test]
fn session_matches_workdir_prefix_and_exact() {
let project_dir = "/home/bob/trusty-mpm-projects/owner/repo";
let session_wd = "/home/bob/trusty-mpm-projects/owner/repo/.worktrees/abc-123-uuid";
assert!(session_matches_workdir(
session_wd,
"/other/live/dir",
Some(project_dir)
));
assert!(session_matches_workdir(session_wd, session_wd, None));
assert!(!session_matches_workdir(
"/home/bob/trusty-mpm-projects/owner/repo-extra/abc-123-uuid",
"/other/live/dir",
Some(project_dir)
));
assert!(!session_matches_workdir(
session_wd,
"/other/live/dir",
None
));
}
#[test]
fn session_matches_workdir_strips_trailing_slash() {
assert!(session_matches_workdir(
"/home/bob/project/",
"/home/bob/project",
None
));
}
#[test]
fn session_matches_workdir_no_false_prefix_match() {
let project_dir = "/projects/owner/repo";
assert!(!session_matches_workdir(
"/projects/owner/repo-extra/some-uuid",
"/other/live",
Some(project_dir)
));
assert!(session_matches_workdir(
"/projects/owner/repo/.worktrees/some-uuid",
"/other/live",
Some(project_dir)
));
}
}