use serde::Deserialize;
use super::DaemonClient;
impl DaemonClient {
pub async fn launch_session(&self, workdir: &str) -> anyhow::Result<String> {
let config_root = crate::core::alt_screen::operator_config_root();
self.get("/health").send().await?.error_for_status()?;
let home = self.home.clone().or_else(dirs::home_dir);
let fw = home.as_deref().map_or_else(
crate::core::paths::FrameworkPaths::default,
crate::core::paths::FrameworkPaths::under,
);
let native = crate::core::output_style::claude_supports_native_output_style();
let dir = std::path::Path::new(workdir);
let prep = crate::core::session_launch::prepare_session_with_home(
&fw,
dir,
None,
native,
home.as_deref(),
);
match prep {
Ok(report) => {
crate::core::session_launch::log_prep_findings(
&report.roster_errors,
&report.asset_notices,
crate::core::session_launch::PrepScope {
kind: "connect",
session: None,
dir: std::path::Path::new(workdir),
},
);
}
Err(err) if err.is_fatal() => {
anyhow::bail!("{err}");
}
Err(err) => {
tracing::warn!(%err, "session pre-launch preparation failed");
}
}
let claude_spec = client_prompt_spec(
&std::env::temp_dir(),
workdir,
config_root.as_deref(),
"launch",
)?;
#[derive(Deserialize)]
struct Body {
#[serde(default)]
name: String,
}
let body: Body = self
.post("/sessions")
.json(&serde_json::json!({
"project": workdir,
"project_path": workdir,
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
let new_session =
crate::core::tmux::create_managed_session(None, &body.name, Some(workdir));
match new_session {
Ok(outcome) if outcome.output.status.success() => {
crate::core::tmux::warn_if_options_unverified(&outcome, &body.name);
self.send_client_spec(&body.name, &claude_spec)?;
}
Ok(_) | Err(_) => {
return Err(anyhow::anyhow!(
"failed to create tmux session {} in {}",
body.name,
workdir
));
}
}
Ok(body.name)
}
pub async fn connect_session(&self, workdir: &str) -> anyhow::Result<String> {
let config_root = crate::core::alt_screen::operator_config_root();
let claude_spec = client_prompt_spec(
&std::env::temp_dir(),
workdir,
config_root.as_deref(),
"connect",
)?;
#[derive(Deserialize)]
struct Body {
#[serde(default)]
name: String,
}
let url = "/api/v1/sessions/connect".to_string();
let body: Body = self
.post(&url)
.json(&serde_json::json!({
"project": workdir,
"project_path": workdir,
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
let already_running =
crate::core::tmux::run_tmux(&crate::core::tmux::TmuxCommand::HasSession {
name: body.name.clone(),
})
.map(|output| output.status.success())
.unwrap_or(false);
let new_session =
crate::core::tmux::create_managed_session(None, &body.name, Some(workdir));
match new_session {
Ok(outcome) if outcome.output.status.success() => {
crate::core::tmux::warn_if_options_unverified(&outcome, &body.name);
if !already_running {
self.send_client_spec(&body.name, &claude_spec)?;
}
}
Ok(_) | Err(_) => {
return Err(anyhow::anyhow!(
"failed to create tmux session {} in {}",
body.name,
workdir
));
}
}
Ok(body.name)
}
fn send_client_spec(
&self,
session: &str,
spec: &crate::runtime::launch_spec::LaunchSpec,
) -> anyhow::Result<()> {
let home = self.home.clone().or_else(dirs::home_dir);
let spec_dir = crate::runtime::cli_launch::spec_dir(home.as_deref()).ok_or_else(|| {
anyhow::anyhow!("tmux session {session} created but no home holds its launch spec")
})?;
crate::runtime::cli_launch::send_spec_launch(session, spec, &spec_dir).map_err(|e| {
anyhow::anyhow!("tmux session {session} created but failed to start claude: {e}")
})
}
}
fn client_prompt_spec(
dir: &std::path::Path,
workdir: &str,
config_root: Option<&std::path::Path>,
action: &str,
) -> anyhow::Result<crate::runtime::launch_spec::LaunchSpec> {
let project = std::path::Path::new(workdir);
let content = crate::core::content_source::framework_content_for(project)
.map_err(|err| anyhow::anyhow!("cannot compose the PM instructions: {err}"))?;
let prompt = crate::core::session_launch::build_system_prompt_for(&content, project);
let file = crate::core::model_inject::write_pm_prompt_file_in(dir, &prompt, project, action)?;
Ok(client_claude_spec(project, config_root, Some(&file)))
}
fn client_claude_spec(
cwd: &std::path::Path,
config_root: Option<&std::path::Path>,
prompt_file: Option<&std::path::Path>,
) -> crate::runtime::launch_spec::LaunchSpec {
crate::runtime::cli_launch::client_spec(
cwd,
prompt_file,
crate::core::alt_screen::configured_alternate_screen_in(config_root),
)
}
#[cfg(test)]
mod prompt_refusal_tests {
fn refusal(action: &str) -> (String, String) {
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 workdir = tmp.path().to_string_lossy().into_owned();
let err = super::client_prompt_spec(¬_a_dir, &workdir, None, action)
.expect_err("a PM launch without its prompt must be refused");
(err.to_string(), not_a_dir.to_string_lossy().into_owned())
}
#[test]
fn launch_prompt_spec_refuses_when_the_prompt_file_cannot_be_written() {
let (err, file) = refusal("launch");
assert!(err.contains(&file) && err.contains("os error"), "{err}");
assert!(err.contains("refusing to launch"), "{err}");
}
#[test]
fn connect_prompt_spec_refuses_when_the_prompt_file_cannot_be_written() {
let (err, file) = refusal("connect");
assert!(err.contains(&file) && err.contains("os error"), "{err}");
assert!(err.contains("refusing to connect"), "{err}");
}
}
#[cfg(test)]
mod renderer_tests {
#[test]
fn client_claude_spec_follows_the_configured_renderer() {
let cwd = std::path::Path::new("/w");
for (alternate_screen, value) in [(true, "0"), (false, "1")] {
let want = (
"CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN".to_owned(),
value.to_owned(),
);
let root = tempfile::tempdir().expect("tempdir");
std::fs::write(
root.path().join("config.yaml"),
format!("tmux:\n alternate_screen: {alternate_screen}\n"),
)
.expect("write config");
for prompt in [None, Some(std::path::Path::new("/tmp/p.txt"))] {
let spec = super::client_claude_spec(cwd, Some(root.path()), prompt);
assert!(spec.env_set.contains(&want), "want {want:?} in: {spec:?}");
}
}
}
#[test]
fn client_claude_spec_never_passes_on_the_profile_stamp() {
for prompt in [None, Some(std::path::Path::new("/tmp/p.txt"))] {
let spec = super::client_claude_spec(std::path::Path::new("/w"), None, prompt);
assert!(
spec.env_unset
.iter()
.any(|n| n == "TRUSTY_MPM_SESSION_PROFILE"),
"the daemon-client launch must unset the profile stamp: {spec:?}"
);
}
}
}