moadim 1.7.4

Loop engine for AI agents — routines over REST, MCP, and a built-in web UI
//! The agent registry: resolving `~/.config/moadim/agents/<name>.toml`, seeded and kept current on
//! startup.
//!
//! Each supported agent contributes its registry key and default config from its own
//! `<agent>/setup.rs` module; [`DEFAULT_AGENT_CONFIGS`] assembles them for [`ensure_default_agents`].
//!
//! Mirrors [`super::defaults::ensure_default_routines`]: the daemon owns the content of a built-in
//! agent config and refreshes it from [`DEFAULT_AGENT_CONFIGS`] on every start, so a shipped fix or
//! improvement reaches existing installs, not just new ones. Unlike a routine's structured fields,
//! an agent config is an opaque TOML blob, so drift can't be detected field-by-field; instead each
//! written file carries a [`MANAGED_HEADER_PREFIX`] line fingerprinting the exact built-in content it
//! was written from. On startup, a file whose current body still matches its recorded fingerprint is
//! provably untouched since the daemon wrote it (pristine, even if stale) and is safely refreshed to
//! the current built-in; a file whose body has drifted from its fingerprint — or one with no
//! fingerprint at all, e.g. seeded before this mechanism existed — is left strictly alone, preserving
//! the existing "user edits are never overwritten" guarantee.

use serde::Deserialize;
use std::io::ErrorKind;
use std::path::Path;

use crate::paths::{agent_toml_path, agents_dir};

#[path = "claude_code/setup.rs"]
mod claude_code;
#[path = "codex/setup.rs"]
mod codex;
#[path = "hermes/setup.rs"]
mod hermes;
#[path = "pi/setup.rs"]
mod pi;

/// The conventions filename a [`AgentCommand`] reads project instructions from when none is
/// configured. Claude Code's convention; the historical (and still default) target for the
/// moadim-managed system prompt.
pub(crate) const DEFAULT_INSTRUCTIONS_FILE: &str = "CLAUDE.md";

/// Default value for [`AgentCommand::instructions_file`] when omitted from the agent's TOML.
fn default_instructions_file() -> String {
    DEFAULT_INSTRUCTIONS_FILE.to_string()
}

/// A resolved agent invocation read from `~/.config/moadim/agents/<name>.toml`.
#[derive(Debug, Clone, Deserialize)]
pub struct AgentCommand {
    /// Executable to run (e.g. `"claude"`).
    pub command: String,
    /// Arguments passed to the executable. Supports `{workbench}`, `{prompt_file}`, and `{prompt}`
    /// placeholders; `{prompt}` inlines the composed prompt as a single shell-quoted argument.
    #[serde(default)]
    pub args: Vec<String>,
    /// Filename, relative to the workbench, that this agent reads its project instructions from.
    /// The moadim-managed system prompt is written here so the agent that actually runs sees it.
    /// Defaults to `CLAUDE.md` (Claude Code's convention); Codex reads `AGENTS.md` instead.
    #[serde(default = "default_instructions_file")]
    pub instructions_file: String,
    /// Optional shell command run in the workbench *before* the agent launches, inserted verbatim
    /// into the cron line. Runs with the shell vars `$WB` (absolute workbench path) and `$SESS`
    /// (tmux session name) in scope — e.g. to pre-seed per-directory editor trust state.
    #[serde(default)]
    pub setup: Option<String>,
}

/// Why [`load_agent_command`] could not produce an [`AgentCommand`].
///
/// Distinguishes a genuinely missing config (the routine simply has no `<name>.toml`) from a config
/// that is present on disk but cannot be read or parsed, so callers can report the real cause instead
/// of collapsing every failure into a misleading "config not found".
#[derive(Debug)]
pub enum AgentLoadError {
    /// No `~/.config/moadim/agents/<name>.toml` exists.
    Missing,
    /// The file exists but could not be read (e.g. a permissions error, or the path is a directory);
    /// carries the underlying I/O error. Distinct from [`Missing`](Self::Missing) so an unreadable
    /// config is never mislabeled "not found" and silently dropped.
    Unreadable(String),
    /// The file exists but its TOML could not be parsed; carries the underlying parse error.
    Parse(String),
}

impl std::fmt::Display for AgentLoadError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Missing => write!(f, "agent config not found"),
            Self::Unreadable(err) => write!(f, "unreadable agent config: {err}"),
            Self::Parse(err) => write!(f, "malformed agent TOML: {err}"),
        }
    }
}

/// Load the agent command for `name`.
///
/// Returns [`AgentLoadError::Missing`] only when no config file exists, [`AgentLoadError::Unreadable`]
/// when the file is present but cannot be read (e.g. a permissions error), and
/// [`AgentLoadError::Parse`] (carrying the `toml` error) when the file is present but unparseable, so
/// the three failures are never conflated.
pub fn load_agent_command(name: &str) -> Result<AgentCommand, AgentLoadError> {
    let text = std::fs::read_to_string(agent_toml_path(name)).map_err(|err| {
        if err.kind() == ErrorKind::NotFound {
            AgentLoadError::Missing
        } else {
            AgentLoadError::Unreadable(err.to_string())
        }
    })?;
    toml::from_str(&text).map_err(|err| AgentLoadError::Parse(err.to_string()))
}

/// Built-in default agent configs `(name, toml)`, seeded on startup if absent and reconciled onto a
/// still-pristine copy of an older default. See [`ensure_default_agents_in`].
const DEFAULT_AGENT_CONFIGS: &[(&str, &str)] = &[
    (claude_code::NAME, claude_code::CONFIG),
    (codex::NAME, codex::CONFIG),
    (hermes::NAME, hermes::CONFIG),
    (pi::NAME, pi::CONFIG),
];

/// Registry keys of the built-in agents, in declaration order.
fn builtin_agent_names() -> Vec<String> {
    DEFAULT_AGENT_CONFIGS
        .iter()
        .map(|(n, _)| n.to_string())
        .collect()
}

/// Names of all agents the daemon can launch: the `<name>.toml` stems under
/// `~/.config/moadim/agents/`, sorted alphabetically.
///
/// Falls back to the built-in defaults when the directory is unreadable (e.g. before startup
/// seeding), so the list is never empty.
pub fn available_agents() -> Vec<String> {
    available_agents_in(&agents_dir())
}

/// List agent names from `dir`. See [`available_agents`].
pub(crate) fn available_agents_in(dir: &Path) -> Vec<String> {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return builtin_agent_names();
    };
    let mut names: Vec<String> = entries
        .filter_map(Result::ok)
        .filter_map(|entry| {
            let path = entry.path();
            // Propagate None for paths without an extension (e.g. a bare "readme" file).
            if path.extension()? != "toml" {
                return None;
            }
            // For real directory entries file_stem() is always Some; to_str() may be None for
            // non-UTF-8 names (silently skipped).
            path.file_stem()
                .and_then(|stem| stem.to_str())
                .map(str::to_string)
        })
        .collect();
    if names.is_empty() {
        return builtin_agent_names();
    }
    names.sort();
    names
}

/// First line of a daemon-written agent config, recording an FNV-1a [`fingerprint`] of the built-in
/// body it was written from. See the module docs for why this is how a pristine-but-stale default is
/// told apart from a user's edit.
const MANAGED_HEADER_PREFIX: &str = "# moadim:managed ";

/// A non-cryptographic 64-bit fingerprint of `data`, rendered as lowercase hex.
///
/// ponytail: FNV-1a, not a cryptographic hash — fine here since this only tells "pristine" content
/// apart from "edited" content, not a security boundary. Deterministic across Rust versions (unlike
/// `std::collections::hash_map::DefaultHasher`, whose algorithm is not guaranteed stable), which
/// matters because a fingerprint written by one daemon build is compared against one computed by a
/// later build.
fn fingerprint(data: &str) -> String {
    const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
    const FNV_PRIME: u64 = 0x0100_0000_01b3;
    let mut hash = FNV_OFFSET;
    for byte in data.as_bytes() {
        hash ^= u64::from(*byte);
        hash = hash.wrapping_mul(FNV_PRIME);
    }
    format!("{hash:016x}")
}

/// Render `contents` as a daemon-managed file: [`MANAGED_HEADER_PREFIX`] plus its fingerprint, then
/// `contents` verbatim.
fn render_managed(contents: &str) -> String {
    format!(
        "{MANAGED_HEADER_PREFIX}{}\n{contents}",
        fingerprint(contents)
    )
}

/// Split a daemon-written file's `text` into its recorded fingerprint and body.
///
/// Returns `None` for text with no (well-formed) [`MANAGED_HEADER_PREFIX`] line — a config seeded
/// before this mechanism existed, or never written by the daemon at all — which
/// [`ensure_default_agents_in`] treats as foreign and never touches.
fn parse_managed(text: &str) -> Option<(&str, &str)> {
    let rest = text.strip_prefix(MANAGED_HEADER_PREFIX)?;
    rest.split_once('\n')
}

/// Write any missing built-in agent configs into `~/.config/moadim/agents/`, and refresh any
/// existing one that is still pristine (unedited since the daemon wrote it) but stale.
///
/// A user-modified config is never overwritten — only a file whose current content still matches the
/// fingerprint recorded when the daemon wrote it is refreshed. Best-effort: directory, read, or write
/// failures are logged and ignored rather than aborting startup.
pub fn ensure_default_agents() {
    ensure_default_agents_in(&agents_dir());
}

/// Write missing / refresh stale-pristine built-in agent configs into `dir`. See
/// [`ensure_default_agents`].
pub(crate) fn ensure_default_agents_in(dir: &Path) {
    if let Err(err) = crate::utils::fs_perms::create_private_dir_all(dir) {
        log::warn!(
            "ensure_default_agents: failed to create {}: {err}",
            dir.display()
        );
        return;
    }
    for (name, contents) in DEFAULT_AGENT_CONFIGS {
        let path = dir.join(format!("{name}.toml"));
        match std::fs::read_to_string(&path) {
            Err(err) if err.kind() == ErrorKind::NotFound => {
                if let Err(err) = std::fs::write(&path, render_managed(contents)) {
                    log::warn!(
                        "ensure_default_agents: failed to write {}: {err}",
                        path.display()
                    );
                }
            }
            Err(err) => {
                log::warn!(
                    "ensure_default_agents: failed to read {}: {err}",
                    path.display()
                );
            }
            Ok(existing) => {
                let Some((recorded_fingerprint, body)) = parse_managed(&existing) else {
                    // No managed header: a foreign file, or one seeded before this mechanism
                    // existed. Its provenance is unknown, so it's left strictly alone.
                    continue;
                };
                if body == *contents {
                    continue; // Already current; nothing to do.
                }
                if fingerprint(body) != recorded_fingerprint {
                    // Edited since the daemon last wrote it: never overwrite a user's edit.
                    continue;
                }
                // A pristine copy of a stale default: safe to refresh to the current built-in.
                if let Err(err) = std::fs::write(&path, render_managed(contents)) {
                    log::warn!(
                        "ensure_default_agents: failed to rewrite stale default {}: {err}",
                        path.display()
                    );
                } else {
                    log::info!(
                        "ensure_default_agents: upgraded stale default {} to the current built-in",
                        path.display()
                    );
                }
            }
        }
    }
}

#[cfg(test)]
#[path = "agents_tests.rs"]
mod agents_tests;