swimmers 0.1.0

Axum server plus TUI for orchestrating Claude Code and Codex agents across tmux panes
Documentation
#![cfg_attr(not(feature = "personal-workflows"), allow(dead_code))]

use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command as ProcessCommand;
use std::time::{SystemTime, UNIX_EPOCH};

use crate::types::SessionSummary;

const COMMIT_TMUX_PREFIX: &str = "commit";
const COMMIT_TMUX_RUNTIME_DIR: &str = "swimmers-commit-tmux";
const COMMIT_CODEX_MODEL: &str = "gpt-5.4";
const COMMIT_CODEX_REASONING: &str = "low";

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CommitCodexLaunch {
    pub session_name: String,
    pub watch_command: String,
}

pub trait CommitLauncher: Send + Sync {
    fn launch(&self, session: &SessionSummary) -> io::Result<CommitCodexLaunch>;
}

#[derive(Default)]
pub struct SystemCommitLauncher;

impl CommitLauncher for SystemCommitLauncher {
    fn launch(&self, session: &SessionSummary) -> io::Result<CommitCodexLaunch> {
        let git_state = collect_git_state(&session.cwd)?;
        launch_commit_codex_tmux(session, &git_state)
    }
}

pub trait ArtifactOpener: Send + Sync {
    fn open(&self, path: &str) -> io::Result<()>;
}

#[derive(Default)]
pub struct SystemArtifactOpener;

impl ArtifactOpener for SystemArtifactOpener {
    fn open(&self, path: &str) -> io::Result<()> {
        if cfg!(target_os = "macos") {
            ProcessCommand::new("open").arg(path).spawn().map(|_| ())
        } else if cfg!(target_os = "windows") {
            ProcessCommand::new("cmd")
                .args(["/C", "start", "", path])
                .spawn()
                .map(|_| ())
        } else {
            ProcessCommand::new("xdg-open")
                .arg(path)
                .spawn()
                .map(|_| ())
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
struct GitStateSnapshot {
    repo_root: PathBuf,
    status_short: String,
    unstaged_diff_stat: String,
    staged_diff_stat: String,
    unstaged_diff: String,
    staged_diff: String,
}

fn collect_git_state(cwd: &str) -> io::Result<GitStateSnapshot> {
    let repo_root = resolve_repo_root(cwd)?;
    Ok(GitStateSnapshot {
        status_short: run_git_capture(&repo_root, &["status", "--short"])?,
        unstaged_diff_stat: run_git_capture(&repo_root, &["diff", "--stat"])?,
        staged_diff_stat: run_git_capture(&repo_root, &["diff", "--cached", "--stat"])?,
        unstaged_diff: run_git_capture(&repo_root, &["diff"])?,
        staged_diff: run_git_capture(&repo_root, &["diff", "--cached"])?,
        repo_root,
    })
}

fn resolve_repo_root(cwd: &str) -> io::Result<PathBuf> {
    let output = ProcessCommand::new("git")
        .args(["-C", cwd, "rev-parse", "--show-toplevel"])
        .output()?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
        let detail = if stderr.is_empty() {
            format!("git exited with {}", output.status)
        } else {
            stderr
        };
        return Err(io::Error::other(format!(
            "git repo root lookup failed for {cwd}: {detail}"
        )));
    }

    let repo_root = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if repo_root.is_empty() {
        return Err(io::Error::other(format!(
            "git repo root lookup returned an empty path for {cwd}"
        )));
    }

    Ok(PathBuf::from(repo_root))
}

fn run_git_capture(repo_root: &Path, args: &[&str]) -> io::Result<String> {
    let output = ProcessCommand::new("git")
        .arg("-C")
        .arg(repo_root)
        .args(args)
        .output()?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
        let detail = if stderr.is_empty() {
            format!("git exited with {}", output.status)
        } else {
            stderr
        };
        return Err(io::Error::other(format!(
            "git {} failed in {}: {}",
            args.join(" "),
            repo_root.display(),
            detail
        )));
    }

    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}

fn launch_commit_codex_tmux(
    session: &SessionSummary,
    git_state: &GitStateSnapshot,
) -> io::Result<CommitCodexLaunch> {
    let session_name = commit_tmux_session_name(&session.tmux_name);
    let runtime_dir = std::env::temp_dir().join(COMMIT_TMUX_RUNTIME_DIR);
    fs::create_dir_all(&runtime_dir)?;

    let prompt_path = runtime_dir.join(format!("{session_name}.prompt.md"));
    let wrapper_path = runtime_dir.join(format!("{session_name}.sh"));
    fs::write(&prompt_path, build_commit_codex_prompt(session, git_state))?;
    fs::write(
        &wrapper_path,
        build_commit_tmux_wrapper(&session_name, &git_state.repo_root, &prompt_path),
    )?;

    let repo_root = git_state.repo_root.to_string_lossy().into_owned();
    let wrapper_command = format!(
        "bash {}",
        shell_single_quote(&wrapper_path.to_string_lossy())
    );
    let output = ProcessCommand::new("tmux")
        .args(["new-session", "-d", "-s", &session_name, "-c", &repo_root])
        .arg(wrapper_command)
        .output()?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
        let detail = if stderr.is_empty() {
            format!("tmux exited with {}", output.status)
        } else {
            stderr
        };
        return Err(io::Error::other(format!(
            "tmux launch failed for {}: {}",
            repo_root, detail
        )));
    }

    Ok(CommitCodexLaunch {
        watch_command: format!("tmux a -t {session_name}"),
        session_name,
    })
}

fn build_commit_tmux_wrapper(session_name: &str, repo_root: &Path, prompt_path: &Path) -> String {
    let repo_root = shell_single_quote(&repo_root.to_string_lossy());
    let prompt_path = shell_single_quote(&prompt_path.to_string_lossy());

    format!(
        "#!/bin/bash\n\
SESSION={session_name:?}\n\
REPO_DIR={repo_root}\n\
PROMPT_FILE={prompt_path}\n\
\n\
echo \"=== swimmers commit codex: $SESSION ===\"\n\
echo \"Repo: $REPO_DIR\"\n\
echo \"Started: $(date)\"\n\
echo \"\"\n\
\n\
EXIT_CODE=0\n\
codex exec \\\n\
  -m {COMMIT_CODEX_MODEL} \\\n\
  -c 'model_reasoning_effort=\"{COMMIT_CODEX_REASONING}\"' \\\n\
  --dangerously-bypass-approvals-and-sandbox \\\n\
  --cd \"$REPO_DIR\" \\\n\
  - < \"$PROMPT_FILE\" || EXIT_CODE=$?\n\
\n\
echo \"\"\n\
echo \"Codex exited with code: $EXIT_CODE\"\n\
\n\
echo \"\"\n\
if [ \"$EXIT_CODE\" -eq 0 ]; then\n\
  echo \"Finished. Session stays alive for inspection.\"\n\
else\n\
  echo \"Failed. Session stays alive for inspection.\"\n\
fi\n\
echo \"Attach: tmux a -t $SESSION\"\n\
echo \"\"\n\
echo \"Press enter to close, or Ctrl-C to keep session.\"\n\
read -r\n"
    )
}

fn build_commit_codex_prompt(session: &SessionSummary, git_state: &GitStateSnapshot) -> String {
    let repo_root = git_state.repo_root.to_string_lossy();
    format!(
        "$commit\n\n\
You were launched from swimmers by clicking a [commit] opportunity in the clawgs rail.\n\
\n\
Source session:\n\
- tmux: {tmux_name}\n\
- session_id: {session_id}\n\
- cwd: {cwd}\n\
- repo_root: {repo_root}\n\
\n\
Run as a fresh detached Codex commit helper. Use model `{COMMIT_CODEX_MODEL}` with `{COMMIT_CODEX_REASONING}` reasoning.\n\
\n\
Task:\n\
- Use the commit skill workflow.\n\
- Work only in `{repo_root}`.\n\
- Treat the git state below as preloaded context so you do not need an extra rediscovery pass.\n\
- Commit only intentional changes in this repo.\n\
- Do not push.\n\
- If there is nothing intentional to commit, explain why and stop without creating an empty commit.\n\
\n\
## git status --short\n\
```text\n\
{status_short}\n\
```\n\
\n\
## git diff --stat\n\
```text\n\
{unstaged_diff_stat}\n\
```\n\
\n\
## git diff --cached --stat\n\
```text\n\
{staged_diff_stat}\n\
```\n\
\n\
## git diff --cached\n\
```diff\n\
{staged_diff}\n\
```\n\
\n\
## git diff\n\
```diff\n\
{unstaged_diff}\n\
```\n",
        tmux_name = session.tmux_name,
        session_id = session.session_id,
        cwd = session.cwd,
        repo_root = repo_root,
        status_short = display_git_output(&git_state.status_short),
        unstaged_diff_stat = display_git_output(&git_state.unstaged_diff_stat),
        staged_diff_stat = display_git_output(&git_state.staged_diff_stat),
        staged_diff = display_git_output(&git_state.staged_diff),
        unstaged_diff = display_git_output(&git_state.unstaged_diff),
    )
}

fn display_git_output(output: &str) -> &str {
    let trimmed = output.trim_end();
    if trimmed.is_empty() {
        "(no output)"
    } else {
        trimmed
    }
}

fn commit_tmux_session_name(tmux_name: &str) -> String {
    let suffix = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis();
    format!(
        "{COMMIT_TMUX_PREFIX}-{}-{suffix}",
        sanitize_tmux_name(tmux_name)
    )
}

fn sanitize_tmux_name(tmux_name: &str) -> String {
    let sanitized = tmux_name
        .chars()
        .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
        .collect::<String>();
    if sanitized.is_empty() {
        "session".to_string()
    } else {
        sanitized
    }
}

fn shell_single_quote(value: &str) -> String {
    format!("'{}'", value.replace('\'', "'\\''"))
}

#[cfg(test)]
mod tests {
    use chrono::Utc;

    use super::*;
    use crate::types::{RestState, SessionState, ThoughtSource, ThoughtState, TransportHealth};

    fn sample_session() -> SessionSummary {
        SessionSummary {
            session_id: "sess-1".to_string(),
            tmux_name: "7".to_string(),
            state: SessionState::Busy,
            current_command: None,
            cwd: "/tmp/repos/swimmers/crate".to_string(),
            tool: Some("Codex".to_string()),
            token_count: 0,
            context_limit: 0,
            thought: Some("commit this".to_string()),
            thought_state: ThoughtState::Holding,
            thought_source: ThoughtSource::CarryForward,
            thought_updated_at: None,
            rest_state: RestState::Active,
            commit_candidate: true,
            objective_changed_at: None,
            last_skill: None,
            is_stale: false,
            attached_clients: 0,
            transport_health: TransportHealth::Healthy,
            last_activity_at: Utc::now(),
            repo_theme_id: None,
        }
    }

    #[test]
    fn build_commit_codex_prompt_includes_preloaded_git_state() {
        let prompt = build_commit_codex_prompt(
            &sample_session(),
            &GitStateSnapshot {
                repo_root: PathBuf::from("/tmp/repos/swimmers"),
                status_short: " M src/main.rs\n?? src/new.rs\n".to_string(),
                unstaged_diff_stat: " src/main.rs | 2 +-\n".to_string(),
                staged_diff_stat: " src/lib.rs | 1 +\n".to_string(),
                unstaged_diff: "diff --git a/src/main.rs b/src/main.rs\n".to_string(),
                staged_diff: "diff --git a/src/lib.rs b/src/lib.rs\n".to_string(),
            },
        );

        assert!(prompt.starts_with("$commit"));
        assert!(prompt.contains("gpt-5.4"));
        assert!(prompt.contains("`low` reasoning"));
        assert!(prompt.contains("git status --short"));
        assert!(prompt.contains("M src/main.rs"));
        assert!(prompt.contains("git diff --cached"));
        assert!(prompt.contains("diff --git a/src/lib.rs b/src/lib.rs"));
    }

    #[test]
    fn sanitize_tmux_name_falls_back_for_empty_tokens() {
        assert_eq!(sanitize_tmux_name(""), "session");
        assert_eq!(sanitize_tmux_name("$$$"), "session");
        assert_eq!(sanitize_tmux_name("dev-7"), "dev-7");
    }

    #[test]
    fn build_commit_tmux_wrapper_keeps_successful_sessions_open_for_inspection() {
        let wrapper = build_commit_tmux_wrapper(
            "commit-7-123",
            Path::new("/tmp/repos/swimmers"),
            Path::new("/tmp/prompt.md"),
        );

        assert!(wrapper.contains("Codex exited with code: $EXIT_CODE"));
        assert!(wrapper.contains("Finished. Session stays alive for inspection."));
        assert!(wrapper.contains("Press enter to close, or Ctrl-C to keep session."));
    }
}