trusty-mpm 1.7.4

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
Documentation
//! Session launch and connect methods for [`DaemonClient`].
//!
//! Why: `launch_session` and `connect_session` are the two largest methods on
//! [`DaemonClient`] (~170 SLOC combined) and are cohesive enough to live
//! together — both POST a session registration to the daemon then drive tmux to
//! create or attach the actual shell. Isolating them here keeps `mod.rs` under
//! the 500-SLOC production cap while preserving the logical grouping.
//! What: a second `impl DaemonClient` block containing only the two session-
//! lifecycle entry-point methods.
//! Test: `launch_session_errors_when_daemon_unreachable`,
//! `connect_session_errors_when_daemon_unreachable` in `tests.rs`.

use serde::Deserialize;

use super::DaemonClient;

impl DaemonClient {
    /// Launch a fresh Claude Code session in `workdir`.
    ///
    /// Why: the TUI's `/connect <dir>` command is the single entry point for
    /// "connect to or launch a session for a project" — when no session exists
    /// for a directory it must start one, mirroring `tm session start`. A
    /// trusty-mpm session is always the `claude` (Claude Code) CLI, never
    /// `claude-mpm`; the trusty-mpm behaviour comes from the custom instructions
    /// (deployed agents + project `CLAUDE.md`) prepared before launch.
    /// What: runs [`crate::core::session_launch::prepare_session`] (deploy
    /// agents + merge `CLAUDE.md`), POSTs `{project, project_path}` to
    /// `/sessions`, then creates a detached tmux session via `tmux new-session`
    /// and starts `claude` in it via `tmux send-keys`. Returns the
    /// daemon-assigned tmux session name. The daemon only registers session
    /// state; the prep and launch (tmux + process) are owned by the client,
    /// exactly as the CLI does it.
    /// Test: `launch_session_errors_when_daemon_unreachable`.
    pub async fn launch_session(&self, workdir: &str) -> anyhow::Result<String> {
        // #8405: the operator's config decides the renderer (see `client_claude_cmd`).
        let config_root = crate::core::alt_screen::operator_config_root();
        // Prepare the custom instructions Claude Code reads at startup: deploy
        // composed agents to `~/.claude/agents/` and merge the project
        // `CLAUDE.md`. Most prep failures are logged but not fatal (#2149) —
        // the session can still launch with whatever instructions already exist
        // on disk. The exception is #4752's compiled-prompt write.
        let fw = crate::core::paths::FrameworkPaths::default();
        match crate::core::session_launch::prepare_session(&fw, std::path::Path::new(workdir)) {
            Ok(report) => {
                // Issue #2149: a roster-deploy failure no longer aborts
                // preparation — surface it loudly rather than let it hide.
                // #6649 folded the asset-hygiene lines in beside them, through
                // the one shared reporter.
                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),
                    },
                );
            }
            // #4752: fatal — refuse the launch rather than start a session
            // whose compiled instructions could not be written.
            Err(err) if err.is_fatal() => {
                anyhow::bail!("{err}");
            }
            Err(err) => {
                tracing::warn!(%err, "session pre-launch preparation failed");
            }
        }

        #[derive(Deserialize)]
        struct Body {
            #[serde(default)]
            name: String,
        }
        let url = format!("{}/sessions", self.base);
        let body: Body = self
            .http
            .post(&url)
            .json(&serde_json::json!({
                "project": workdir,
                "project_path": workdir,
            }))
            .send()
            .await?
            .error_for_status()?
            .json()
            .await?;

        // Build the combined `--append-system-prompt` text (claude-mpm PM
        // instructions + trusty tool-priority block), resolved *for this project
        // directory* so override files under `<workdir>/.trusty-mpm/` take effect
        // (issue #381). Write it to a temp file and pass it via
        // `--append-system-prompt-file` so every launched `claude` is a properly
        // configured PM instance while preserving Claude Code's built-in tool use
        // instructions. The temp file persists because `claude` reads it at
        // startup; it lives in `/tmp` and is superseded by the next launch — no
        // explicit cleanup is performed.
        let prompt =
            crate::core::session_launch::build_system_prompt_for(std::path::Path::new(workdir));
        let claude_cmd = {
            let path = std::env::temp_dir().join(format!(
                "trusty-mpm-system-prompt-{}.txt",
                uuid::Uuid::new_v4()
            ));
            // #4467: the line is built in `core::model_inject` so it carries the
            // shared inherited-marker scrub and the `transcript_saving` doctor
            // check can read it. Hand-building it here is what left this launch
            // path silently saving no transcript.
            match std::fs::write(&path, &prompt) {
                Ok(()) => client_claude_cmd(config_root.as_deref(), Some(&path)),
                Err(err) => {
                    tracing::warn!(%err, "failed to write system prompt file; launching bare claude");
                    client_claude_cmd(config_root.as_deref(), None)
                }
            }
        };

        // #2398: routes through `core::tmux::create_managed_session`, the
        // crate's single session-creation choke point, so the configured
        // scrollback/mouse ergonomics are applied before the pane exists (a
        // bare `tmux new-session` here would silently bypass them — the
        // exact QA-caught regression this consolidation closes).
        let new_session =
            crate::core::tmux::create_managed_session(None, &body.name, Some(workdir));
        match new_session {
            Ok(outcome) if outcome.output.status.success() => {
                // #3386 review: operator-visible (not just grep-able) notice
                // — this client is shared by the TUI/bot surfaces, so the
                // `tracing::error!` `warn_if_options_unverified` emits is the
                // channel every one of them can observe.
                crate::core::tmux::warn_if_options_unverified(&outcome, &body.name);
                let send = crate::core::tmux::send_line(
                    None,
                    &crate::core::tmux::TmuxTarget::session(&body.name),
                    &claude_cmd,
                )
                .map(|output| output.status);
                if !matches!(send, Ok(s) if s.success()) {
                    return Err(anyhow::anyhow!(
                        "tmux session {} created but failed to start claude",
                        body.name
                    ));
                }
            }
            Ok(_) | Err(_) => {
                return Err(anyhow::anyhow!(
                    "failed to create tmux session {} in {}",
                    body.name,
                    workdir
                ));
            }
        }
        Ok(body.name)
    }

    /// Connect to — or start — a Claude Code session in `workdir` *without*
    /// running the framework-deployment sequence.
    ///
    /// Why: `tm connect` is the lightweight sibling of `launch_session`. Where
    /// `launch_session` first runs
    /// [`crate::core::session_launch::prepare_session`] to deploy
    /// instructions, agents, and skills into the project, `connect` deliberately
    /// skips all of that — it assumes the framework is already deployed (or that
    /// the operator does not want it touched) and only wants the daemon to know
    /// about the session and the tmux host to be running.
    /// What: POSTs `{project, project_path}` to `/api/v1/sessions/connect`, then
    /// runs `tmux new-session -A` (idempotent — creates the session when absent,
    /// no-ops when it already exists). When the session is freshly created it
    /// starts `claude` in it via `tmux send-keys`; an already-running session is
    /// left untouched. The system-prompt file is still built and passed so a
    /// freshly-started `claude` is a configured PM — that is prompt composition,
    /// not artifact deployment. Returns the daemon-assigned tmux session name.
    /// Test: `connect_session_errors_when_daemon_unreachable`.
    pub async fn connect_session(&self, workdir: &str) -> anyhow::Result<String> {
        // #8405: the operator's config decides the renderer (see `client_claude_cmd`).
        let config_root = crate::core::alt_screen::operator_config_root();
        #[derive(Deserialize)]
        struct Body {
            #[serde(default)]
            name: String,
        }
        let url = format!("{}/api/v1/sessions/connect", self.base);
        let body: Body = self
            .http
            .post(&url)
            .json(&serde_json::json!({
                "project": workdir,
                "project_path": workdir,
            }))
            .send()
            .await?
            .error_for_status()?
            .json()
            .await?;

        // Build the `--append-system-prompt` text so a freshly-started `claude`
        // is a configured PM, resolved *for this project directory* so override
        // files under `<workdir>/.trusty-mpm/` take effect (issue #381). This is
        // prompt composition from bundled assets + project overrides, not
        // deployment of agents/skills/hooks into the project — `connect` only
        // skips the latter (`prepare_session`).
        let prompt =
            crate::core::session_launch::build_system_prompt_for(std::path::Path::new(workdir));
        let claude_cmd = {
            let path = std::env::temp_dir().join(format!(
                "trusty-mpm-system-prompt-{}.txt",
                uuid::Uuid::new_v4()
            ));
            // #4467: the line is built in `core::model_inject` so it carries the
            // shared inherited-marker scrub and the `transcript_saving` doctor
            // check can read it. Hand-building it here is what left this launch
            // path silently saving no transcript.
            match std::fs::write(&path, &prompt) {
                Ok(()) => client_claude_cmd(config_root.as_deref(), Some(&path)),
                Err(err) => {
                    tracing::warn!(%err, "failed to write system prompt file; launching bare claude");
                    client_claude_cmd(config_root.as_deref(), None)
                }
            }
        };

        // `tmux new-session -A` is idempotent: it attaches to the session when
        // it already exists and creates it (detached, `-d`) otherwise. The
        // `has-session` probe distinguishes the two so `claude` is started only
        // for a freshly-created session — an already-running one is left alone.
        // #2398: routes through the crate's single tmux entry point.
        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);

        // #2398: routes through `core::tmux::create_managed_session`, the
        // crate's single session-creation choke point, so the configured
        // scrollback/mouse ergonomics are applied before the pane exists.
        let new_session =
            crate::core::tmux::create_managed_session(None, &body.name, Some(workdir));
        match new_session {
            Ok(outcome) if outcome.output.status.success() => {
                // #3386 review: see `launch_session`'s identical notice above.
                crate::core::tmux::warn_if_options_unverified(&outcome, &body.name);
                if !already_running {
                    let send = crate::core::tmux::send_line(
                        None,
                        &crate::core::tmux::TmuxTarget::session(&body.name),
                        &claude_cmd,
                    )
                    .map(|output| output.status);
                    if !matches!(send, Ok(s) if s.success()) {
                        return Err(anyhow::anyhow!(
                            "tmux session {} created but failed to start claude",
                            body.name
                        ));
                    }
                }
            }
            Ok(_) | Err(_) => {
                return Err(anyhow::anyhow!(
                    "failed to create tmux session {} in {}",
                    body.name,
                    workdir
                ));
            }
        }
        Ok(body.name)
    }
}

/// The `claude` line the daemon client types into a fresh pane (#8405).
///
/// Why: the one seam where `DaemonClient::launch_session` / `connect_session`
/// turn the operator's config into the renderer, split out so a test can drive
/// it from a config root.
/// What: [`crate::core::model_inject::build_client_session_command_configured`]
/// with the renderer
/// [`crate::core::alt_screen::configured_alternate_screen_in`] reads from
/// `config_root`.
/// Test: `client_claude_cmd_follows_the_configured_renderer`.
fn client_claude_cmd(
    config_root: Option<&std::path::Path>,
    prompt_file: Option<&std::path::Path>,
) -> String {
    crate::core::model_inject::build_client_session_command_configured(
        prompt_file,
        crate::core::alt_screen::configured_alternate_screen_in(config_root),
    )
}

#[cfg(test)]
mod renderer_tests {
    /// #8405: both `DaemonClient` launch paths build their line here, so this
    /// pins the seam in both directions. Fails if it ignores the config.
    #[test]
    fn client_claude_cmd_follows_the_configured_renderer() {
        for (alternate_screen, want) in [
            (true, "CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=0 "),
            (false, "CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 "),
        ] {
            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 line = super::client_claude_cmd(Some(root.path()), prompt);
                assert!(line.contains(want), "want {want:?} in: {line}");
            }
        }
    }
}