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(),
});
}
}
#[allow(clippy::too_many_arguments)] pub(crate) async fn launch(
client: &reqwest::Client,
url: &str,
dir: Option<String>,
style: Option<String>,
worktree: bool,
launch_dir: super::managed_workspace::LaunchDir,
home: Option<&std::path::Path>, twin: bool,
) -> anyhow::Result<()> {
super::launch_twin::preflight(twin, worktree)?; let live_path = resolve_dir(dir)?;
let live_path = live_path.canonicalize().unwrap_or(live_path);
super::auto_git_init::ensure_git_repo(&live_path)?;
super::managed_root::try_register_alias(&live_path);
let live_workdir = live_path.to_string_lossy().to_string();
let raw_origin = trusty_mpm::daemon::managed_routes::inproject::get_origin_url(&live_path)
.map_err(|e| anyhow::anyhow!(e))?;
let origin_url = match super::origin_plan::plan_for_origin(raw_origin.as_deref()) {
super::origin_plan::OriginPlan::LiveCheckout => {
super::launch_twin::refuse_live_checkout(twin)?; eprintln!("{}", super::origin_plan::live_checkout_notice(&live_path));
return connect(client, url, Some(live_workdir), home).await;
}
super::origin_plan::OriginPlan::ManagedClone(u)
| super::origin_plan::OriginPlan::RefuseNonGitHub(u) => u.to_string(),
};
let gh = trusty_common::github_path::parse_github_path(&origin_url)
.ok_or_else(|| unparseable_origin_refusal(&origin_url))?;
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()
{
super::launch_twin::refuse_reattach(twin, &existing)?; print_launch_banner_reconnecting(&live_workdir, &existing);
crate::commands::tmux_attach::tmux_attach(&existing)?;
return Ok(());
}
super::run_target::accounts_preflight(None, &origin_url)?;
if style.is_some() {
tracing::warn!("--style not yet supported for managed launches; ignoring style flag");
}
let twin_root = twin
.then(|| super::launch_twin::precheck(&project_dir))
.transpose()?;
let session_uuid = trusty_mpm::session_manager::ManagedSessionId::new();
let workspace = super::managed_workspace::provision_for_launch(
&origin_url,
&project_dir,
&live_path,
worktree,
launch_dir,
&session_uuid,
home, )
.await?;
let managed_path = workspace.path().to_path_buf();
if twin_root.is_some() {
super::launch_twin::confirm_placement(&project_dir, &managed_path)?; }
let config_dir = {
match trusty_mpm::core::session_launch::prepare_isolated_session_under(
&managed_path,
Some(&origin_url),
home,
) {
Ok(report) => {
for err in &report.roster_errors {
eprintln!(
"error: roster provisioning gap for worktree {}: {err}",
managed_path.display()
);
}
for notice in &report.asset_notices {
println!(" assets: {notice}");
}
}
Err(e) if e.is_fatal() => {
anyhow::bail!("{e}");
}
Err(e) => {
tracing::warn!(
"session prep failed for worktree {} (non-fatal): {e}",
managed_path.display()
);
}
};
super::launch_home::relocate_config_dir(&managed_path, home)
};
let managed_workdir = managed_path.to_string_lossy().to_string();
super::launch_home::strip_global_hooks(home);
if let Err(e) = crate::commands::install::write_project_hooks_for_dir(&managed_path, None) {
eprintln!("warning: could not write project-scoped MPM hooks: {e:#}");
}
let mpm_cfg = trusty_mpm::core::config::MpmConfig::load_effective_default(Some(&managed_path));
let content = trusty_mpm::core::content_source::framework_content_for(&managed_path)
.map_err(|err| anyhow::anyhow!("cannot compose the PM instructions: {err}"))?;
let cli =
trusty_mpm::core::session_launch::cli_launch(&content, &managed_path, Some(&origin_url));
let pm_model = trusty_mpm::core::session_profile::launch_model(cli.profile, &mpm_cfg);
let prompt_path = launch_prompt_file(&std::env::temp_dir(), &cli.prompt, &managed_path)?;
#[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 scoped_mcp =
super::launch_home::provision_session_mcp(&managed_path, config_dir.as_deref(), home)?;
let claude_spec = launch_claude_spec(
&managed_path,
trusty_mpm::core::alt_screen::operator_config_root().as_deref(),
&pm_model,
Some(prompt_path.as_path()),
config_dir.as_deref(),
&cli.env,
scoped_mcp.as_deref(),
);
let spec_dir = launch_spec_dir(home)?;
print_launch_banner(
&live_workdir,
&tmux_name,
Some(prompt_path.as_path()),
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 sent =
trusty_mpm::runtime::cli_launch::send_spec_launch(&tmux_name, &claude_spec, &spec_dir);
if let Err(e) = sent {
anyhow::bail!(
"tmux session {tmux_name} created but failed to start claude: {e} (report this with `tm doctor` output)"
);
}
let claude_pid = (session_id.is_some() || twin_root.is_some())
.then(|| {
trusty_mpm::core::process::find_claude_pid_in_tmux(
&tmux_name,
10,
std::time::Duration::from_millis(500),
)
})
.flatten();
if let Some(root) = &twin_root {
super::launch_twin::arm(root, claude_pid, &managed_path);
}
if let Some(session_id) = session_id {
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>,
home: Option<&std::path::Path>, ) -> 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_under(&path, None, home) {
Ok(report) => {
for err in &report.roster_errors {
eprintln!(
"error: roster provisioning gap for {}: {err}",
path.display()
);
}
for notice in &report.asset_notices {
println!(" assets: {notice}");
}
}
Err(err) if err.is_fatal() => {
anyhow::bail!("{err}");
}
Err(err) => {
tracing::warn!(
"session prep failed for {} (non-fatal): {err}",
path.display()
);
}
}
let config_dir = super::launch_home::relocate_config_dir(&path, home);
let content = trusty_mpm::core::content_source::framework_content_for(&path)
.map_err(|err| anyhow::anyhow!("cannot compose the PM instructions: {err}"))?;
let cli = trusty_mpm::core::session_launch::cli_launch(&content, &path, None);
let prompt_path = connect_prompt_file(&std::env::temp_dir(), &cli.prompt, &path)?;
#[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, Some(prompt_path.as_path()), 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 scoped_mcp =
super::launch_home::provision_session_mcp(&path, config_dir.as_deref(), home)?;
let claude_spec = connect_claude_spec(
&path,
Some(prompt_path.as_path()),
config_dir.as_deref(),
&cli.env,
scoped_mcp.as_deref(),
trusty_mpm::core::alt_screen::operator_config_root().as_deref(),
);
let spec_dir = launch_spec_dir(home)?;
let sent =
trusty_mpm::runtime::cli_launch::send_spec_launch(&tmux_name, &claude_spec, &spec_dir);
if let Err(e) = sent {
anyhow::bail!(
"tmux session {tmux_name} created but failed to start claude: {e} (report this with `tm doctor` output)"
);
}
}
crate::commands::tmux_attach::tmux_attach(&tmux_name)?;
if let Some(ref mut g) = session_guard {
g.disarm();
}
Ok(())
}
fn unparseable_origin_refusal(origin_url: &str) -> anyhow::Error {
anyhow::anyhow!(
"could not parse a GitHub owner/repo from origin remote: {:?}\n\
Run `tm connect` to start a session in the live checkout instead.",
trusty_mpm::core::remote_url_redact::redact_stored_url(origin_url)
)
}
fn connect_prompt_file(
dir: &std::path::Path,
prompt: &str,
project: &std::path::Path,
) -> anyhow::Result<std::path::PathBuf> {
trusty_mpm::core::model_inject::write_pm_prompt_file_in(dir, prompt, project, "connect")
}
fn launch_prompt_file(
dir: &std::path::Path,
prompt: &str,
project: &std::path::Path,
) -> anyhow::Result<std::path::PathBuf> {
trusty_mpm::core::model_inject::write_pm_prompt_file_in(dir, prompt, project, "launch")
}
pub(crate) fn connect_claude_spec(
cwd: &std::path::Path,
prompt_file: Option<&std::path::Path>,
config_dir: Option<&std::path::Path>,
mcp_env: &[(String, String)],
scoped_mcp: Option<&std::path::Path>,
config_root: Option<&std::path::Path>,
) -> trusty_mpm::runtime::launch_spec::LaunchSpec {
isolated_claude_spec(
cwd,
None,
prompt_file,
config_dir,
mcp_env,
scoped_mcp,
config_root,
)
}
pub(crate) fn launch_claude_spec(
cwd: &std::path::Path,
config_root: Option<&std::path::Path>,
pm_model: &str,
prompt_file: Option<&std::path::Path>,
config_dir: Option<&std::path::Path>,
mcp_env: &[(String, String)],
scoped_mcp: Option<&std::path::Path>,
) -> trusty_mpm::runtime::launch_spec::LaunchSpec {
isolated_claude_spec(
cwd,
Some(pm_model),
prompt_file,
config_dir,
mcp_env,
scoped_mcp,
config_root,
)
}
fn isolated_claude_spec(
cwd: &std::path::Path,
model: Option<&str>,
prompt_file: Option<&std::path::Path>,
config_dir: Option<&std::path::Path>,
mcp_env: &[(String, String)],
scoped_mcp: Option<&std::path::Path>,
config_root: Option<&std::path::Path>,
) -> trusty_mpm::runtime::launch_spec::LaunchSpec {
let token = config_dir.and_then(|_| trusty_mpm::core::oauth_token::resolve_oauth_token());
trusty_mpm::runtime::cli_launch::isolated_spec(
cwd,
&trusty_mpm::runtime::cli_launch::CliLaunch {
model,
prompt_file,
config_dir,
oauth_token: token.as_deref(),
mcp_env,
scoped_mcp,
alternate_screen: trusty_mpm::core::alt_screen::configured_alternate_screen_in(
config_root,
),
},
)
}
pub(crate) fn launch_spec_dir(
home: Option<&std::path::Path>,
) -> anyhow::Result<std::path::PathBuf> {
trusty_mpm::runtime::cli_launch::spec_dir(home).ok_or_else(|| {
anyhow::anyhow!("cannot start claude: no home directory to hold its launch spec (#8308)")
})
}
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 launch_refusal_never_quotes_the_origin_token() {
const TOKEN: &str = "ghp_9124LaunchSyntheticToken00000";
let origin = format!("https://octo:{TOKEN}@gitlab.example/acme/widget.git");
let msg = unparseable_origin_refusal(&origin).to_string();
assert!(!msg.contains(TOKEN), "the token reached the refusal");
assert!(
msg.contains("https://***@gitlab.example/acme/widget.git"),
"{msg}"
);
}
#[test]
fn launch_prompt_file_refuses_when_the_prompt_file_cannot_be_written() {
let tmp = tempfile::tempdir().expect("tempdir");
let not_a_dir = tmp.path().join("not-a-dir");
std::fs::write(¬_a_dir, "").expect("plant a file where the prompt dir goes");
let err = launch_prompt_file(¬_a_dir, "prompt", tmp.path())
.expect_err("a launch without its PM prompt must be refused")
.to_string();
assert!(
err.contains(&*not_a_dir.to_string_lossy())
&& err.contains("os error")
&& err.contains("refusing to launch"),
"the refusal must name the prompt file, the I/O cause and the launch: {err}"
);
}
#[test]
fn connect_prompt_file_refuses_when_the_prompt_file_cannot_be_written() {
let tmp = tempfile::tempdir().expect("tempdir");
let not_a_dir = tmp.path().join("not-a-dir");
std::fs::write(¬_a_dir, "").expect("plant a file where the prompt dir goes");
let err = connect_prompt_file(¬_a_dir, "prompt", tmp.path())
.expect_err("a connect without its PM prompt must be refused")
.to_string();
assert!(
err.contains(&*not_a_dir.to_string_lossy()) && err.contains("os error"),
"the refusal must name the prompt file and the I/O cause: {err}"
);
}
#[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)
));
}
}