use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, bail};
use trusty_mpm::core::architect_conversation::{self, ConversationStart};
use trusty_mpm::core::architect_session;
use trusty_mpm::core::process::PaneClaude;
use trusty_mpm::core::session_profile::{self, SESSION_PROFILE_ENV};
use trusty_mpm::core::tmux::{self, TmuxCommand};
use trusty_mpm::core::twin_identity::ArmingRecord;
pub(crate) const ARCHITECT_SESSION: &str = architect_session::DEFAULT_ARCHITECT_SESSION;
pub(crate) const RESUME_SETTLE: Duration = Duration::from_secs(5);
const REPROBE: Duration = Duration::from_millis(500);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PaneState {
Absent,
Live(PathBuf),
Dead(PathBuf),
Unknown(String),
}
impl PaneState {
pub(crate) fn dir(self) -> Option<PathBuf> {
match self {
Self::Live(dir) | Self::Dead(dir) => Some(dir),
Self::Absent | Self::Unknown(_) => None,
}
}
}
pub(crate) fn pane_state(name: &str) -> PaneState {
let argv = [
"display-message",
"-p",
"-t",
&tmux::exact_window_target(name),
"#{pane_dead} #{session_path}",
]
.map(str::to_owned);
let out = match tmux::run_tmux_argv(&argv) {
Ok(out) => out,
Err(err) => return PaneState::Unknown(format!("cannot run tmux: {err}")),
};
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr).trim().to_owned();
if ["no server running", "error connecting to"]
.iter()
.any(|m| err.contains(m))
{
return PaneState::Absent;
}
return PaneState::Unknown(format!("tmux display-message failed: {err}"));
}
parse_pane(&String::from_utf8_lossy(&out.stdout))
}
pub(crate) fn parse_pane(answer: &str) -> PaneState {
let answer = answer.trim_end_matches(['\n', '\r']);
if answer.trim().is_empty() {
return PaneState::Absent;
}
let canonical = |path: &str| {
let path = PathBuf::from(path);
std::fs::canonicalize(&path).unwrap_or(path)
};
match answer.split_once(' ') {
Some(("0", path)) if !path.is_empty() => PaneState::Live(canonical(path)),
Some(("1", path)) if !path.is_empty() => PaneState::Dead(canonical(path)),
_ => PaneState::Unknown(format!("unreadable tmux answer {answer:?}")),
}
}
pub(crate) fn launch_stamp(name: &str) -> Option<String> {
let argv = tmux::show_environment_argv(Some(name), SESSION_PROFILE_ENV);
let out = tmux::run_tmux_argv(&argv).ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
text.trim()
.strip_prefix(SESSION_PROFILE_ENV)
.and_then(|rest| rest.strip_prefix('='))
.map(str::to_owned)
}
pub(crate) fn claude_pid(name: &str) -> Option<u32> {
trusty_mpm::core::process::find_claude_pid_in_tmux(name, 1, std::time::Duration::ZERO)
}
pub(crate) fn start(
dir: &Path,
home: &Path,
session: &str,
) -> anyhow::Result<(Result<ArmingRecord, String>, ConversationStart)> {
require_supervisor(dir, session)?;
match trusty_mpm::core::session_launch::prepare_isolated_session_under(dir, None, Some(home)) {
Ok(report) => {
for err in &report.roster_errors {
eprintln!(
"warning: roster provisioning gap for {}: {err}",
dir.display()
);
}
}
Err(err) if err.is_fatal() => bail!("{err}"),
Err(err) => eprintln!(
"warning: session prep failed for {} (non-fatal): {err}",
dir.display()
),
}
let config_dir = crate::commands::launch_home::relocate_config_dir(dir, Some(home));
let content = trusty_mpm::core::content_source::framework_content_for(dir)
.map_err(|err| anyhow::anyhow!("cannot compose the PM instructions: {err}"))?;
let cli = trusty_mpm::core::session_launch::cli_launch(&content, dir, None);
if !cli.profile.is_supervisor() {
bail!("{}", pm_refusal(dir, session));
}
let config = trusty_mpm::core::config::MpmConfig::load_effective_default(Some(dir));
let model = session_profile::launch_model(cli.profile, &config);
let prompt = trusty_mpm::core::model_inject::write_pm_prompt_file_in(
&std::env::temp_dir(),
&cli.prompt,
dir,
"launch",
)?;
let scoped_mcp = crate::commands::launch_home::provision_session_mcp(
dir,
config_dir.as_deref(),
Some(home),
)?;
let mut claude_spec = crate::commands::launch::launch_claude_spec(
dir,
trusty_mpm::core::alt_screen::operator_config_root().as_deref(),
&model,
Some(prompt.as_path()),
config_dir.as_deref(),
&cli.env,
scoped_mcp.as_deref(),
);
let root = home.join(".trusty-mpm");
let conversation =
architect_conversation::resolve_conversation(&root, dir, config_dir.as_deref());
claude_spec.args.extend(architect_args(&conversation));
let spec_dir = crate::commands::launch::launch_spec_dir(Some(home))?;
let workdir = dir
.to_str()
.context("the Architect directory is not valid UTF-8")?;
let created = tmux::create_managed_session_exclusive(None, session, Some(workdir))
.with_context(|| format!("failed to run tmux to create {session}"))?;
if !created.output.status.success() {
bail!(
"tmux refused to create {session}: {}",
String::from_utf8_lossy(&created.output.stderr).trim()
);
}
if let Err(err) = stamp_and_send(&cli.profile, &claude_spec, &spec_dir, session) {
let _ = tmux::run_tmux(&TmuxCommand::KillSession {
name: session.to_owned(),
});
return Err(err);
}
if let ConversationStart::Fresh { id, .. } = &conversation
&& let Err(err) = architect_conversation::record_conversation(&root, dir, id)
{
eprintln!(
"warning: conversation {id} was NOT recorded, so the next `tm fleet init` \
starts fresh: {err}"
);
}
let binding = record_process(dir, home, session);
if let ConversationStart::Resume(id) = &conversation {
let check = resume_check(
&binding,
|| trusty_mpm::core::process::pane_claude(session),
std::thread::sleep,
);
settle_resume(check, &root, id, session)?;
}
Ok((binding, conversation))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ResumeCheck {
Running,
Failed(String),
Unknown,
}
pub(crate) fn resume_check(
binding: &Result<ArmingRecord, String>,
mut probe: impl FnMut() -> PaneClaude,
mut sleep: impl FnMut(Duration),
) -> ResumeCheck {
let mut read = |sleep: &mut dyn FnMut(Duration)| match probe() {
PaneClaude::Unknown => {
sleep(REPROBE);
probe()
}
seen => seen,
};
if let Err(why) = binding {
match read(&mut sleep) {
PaneClaude::Absent => return ResumeCheck::Failed(why.clone()),
PaneClaude::Unknown => return ResumeCheck::Unknown,
PaneClaude::Present => {}
}
}
sleep(RESUME_SETTLE);
match read(&mut sleep) {
PaneClaude::Present => ResumeCheck::Running,
PaneClaude::Unknown => ResumeCheck::Unknown,
PaneClaude::Absent => ResumeCheck::Failed(format!(
"`claude` exited within {} s of starting",
RESUME_SETTLE.as_secs()
)),
}
}
fn settle_resume(check: ResumeCheck, root: &Path, id: &str, session: &str) -> anyhow::Result<()> {
let path = architect_conversation::conversation_path(root);
let why = match check {
ResumeCheck::Running => return Ok(()),
ResumeCheck::Unknown => {
eprintln!(
"warning: could not tell whether the resumed `claude` (conversation {id}) runs \
in tmux session {session}: tmux or the process table could not be read; kept \
the session and the conversation record {}",
path.display()
);
return Ok(());
}
ResumeCheck::Failed(why) => why,
};
let killed = match kill_session(session) {
Ok(()) => format!("killed tmux session {session}"),
Err(err) => format!(
"could NOT kill tmux session {session}; run `tmux kill-session -t ={session}` \
({err})"
),
};
let record = match architect_conversation::clear_conversation(root) {
Ok(()) => format!(
"cleared {}, so the next `tm fleet init` starts a new conversation",
path.display()
),
Err(err) => format!(
"could NOT clear {} ({err}); remove it, or the next `tm fleet init` resumes {id} \
again",
path.display()
),
};
bail!("resuming the Architect's conversation {id} failed ({why}); {killed}, and {record}")
}
fn kill_session(session: &str) -> Result<(), String> {
let out = tmux::run_tmux(&TmuxCommand::KillSession {
name: session.to_owned(),
})
.map_err(|e| format!("cannot run tmux: {e}"))?;
if out.status.success() {
return Ok(());
}
Err(format!(
"tmux kill-session failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
))
}
pub(crate) fn architect_args(conversation: &ConversationStart) -> Vec<String> {
let mut args = conversation.claude_args().to_vec();
args.push("--remote-control".to_owned());
args
}
fn record_process(dir: &Path, home: &Path, session: &str) -> Result<ArmingRecord, String> {
let pid = trusty_mpm::core::process::find_claude_pid_in_tmux(
session,
20,
std::time::Duration::from_millis(500),
)
.ok_or_else(|| format!("no `claude` process appeared in {session} within 10 s"))?;
let root = home.join(".trusty-mpm");
let record = architect_session::record_launch(&root, pid, dir, session)?;
match architect_session::architect_session_name(&root, pid) {
Ok(name) if name == session => Ok(record),
Ok(name) => Err(format!(
"the launch record reads back session {name}, not {session}"
)),
Err(why) => Err(why.to_string()),
}
}
fn stamp_and_send(
profile: &session_profile::SessionProfile,
claude_spec: &trusty_mpm::runtime::launch_spec::LaunchSpec,
spec_dir: &Path,
session: &str,
) -> anyhow::Result<()> {
let (key, value) = session_profile::launch_env(*profile);
let set = tmux::run_tmux(&TmuxCommand::SetEnvironment {
session: session.to_owned(),
key,
value,
})?;
if !set.status.success() {
bail!("failed to record the launch stamp on {session}");
}
trusty_mpm::runtime::cli_launch::send_spec_launch(session, claude_spec, spec_dir).map_err(|e| {
anyhow::anyhow!("{session} was created but `claude` could not be started in it: {e}")
})
}
fn require_supervisor(dir: &Path, session: &str) -> anyhow::Result<()> {
if session_profile::resolve_ambient(dir).is_supervisor() {
return Ok(());
}
bail!("{}", pm_refusal(dir, session))
}
fn pm_refusal(dir: &Path, session: &str) -> String {
let config = trusty_mpm::core::config::MpmConfig::load_default();
let reason = session_profile::refusal(dir, &config)
.unwrap_or("the project does not request `profile = \"supervisor\"`");
format!(
"not starting {session}: a launch in {} would run the PM profile ({reason})",
dir.display()
)
}