mod agent;
mod agent_handoff;
mod channels;
mod cli_agent_flags;
mod config;
mod conversation;
mod core;
mod cron_utils;
mod daemon;
mod dashboard;
mod db;
#[allow(dead_code)]
mod events;
mod execution_policy;
mod goal_tokens;
#[allow(dead_code)]
mod health;
mod heartbeat;
mod llm_markers;
mod llm_runtime;
mod mcp;
mod memory;
mod oauth;
#[allow(dead_code)]
mod plans;
mod providers;
mod queue_policy;
mod queue_telemetry;
mod router;
mod session;
mod skills;
mod startup;
mod state;
mod tasks;
#[cfg(feature = "terminal-bridge")]
mod terminal_bridge;
mod terminal_lite;
mod token_alerts;
mod tools;
mod traits;
mod triggers;
mod types;
mod updater;
pub mod utils;
mod wizard;
#[cfg(test)]
mod integration_tests;
#[cfg(test)]
mod live_e2e_tests;
#[cfg(test)]
mod testing;
use std::path::{Path, PathBuf};
#[cfg(not(feature = "terminal-bridge"))]
use std::process::Stdio;
use tracing_subscriber::EnvFilter;
const SUPPORTED_TERMINAL_AGENTS: &[&str] = &["codex", "claude", "gemini", "opencode"];
const MAX_AGENT_LAUNCH_ARGS: usize = 24;
const MAX_AGENT_LAUNCH_ARG_CHARS: usize = 256;
pub(crate) const RUNTIME_CONFIG_PATH_ENV_KEY: &str = "AIDAEMON_RUNTIME_CONFIG_PATH";
pub(crate) const RUNTIME_ENV_FILE_ENV_KEY: &str = "AIDAEMON_RUNTIME_ENV_FILE";
pub(crate) const RUNTIME_WORKDIR_ENV_KEY: &str = "AIDAEMON_RUNTIME_WORKDIR";
fn runtime_working_dir() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
fn absolutize_path(path: &Path, base_dir: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
base_dir.join(path)
}
}
fn resolve_runtime_env_file_path(working_dir: &Path) -> PathBuf {
if let Ok(path) = std::env::var("AIDAEMON_ENV_FILE") {
let trimmed = path.trim();
if !trimmed.is_empty() {
return absolutize_path(Path::new(trimmed), working_dir);
}
}
working_dir.join(".env")
}
fn export_runtime_context(config_path: &Path, env_file_path: &Path, working_dir: &Path) {
std::env::set_var(
RUNTIME_CONFIG_PATH_ENV_KEY,
config_path.to_string_lossy().to_string(),
);
std::env::set_var(
RUNTIME_ENV_FILE_ENV_KEY,
env_file_path.to_string_lossy().to_string(),
);
std::env::set_var(
RUNTIME_WORKDIR_ENV_KEY,
working_dir.to_string_lossy().to_string(),
);
}
fn load_startup_env_file(env_file_path: &Path) {
let explicit_override = std::env::var("AIDAEMON_ENV_FILE")
.ok()
.is_some_and(|v| !v.trim().is_empty());
if env_file_path.exists() {
if let Err(e) = dotenvy::from_path(env_file_path) {
eprintln!(
"Warning: failed to load env file '{}': {}",
env_file_path.display(),
e
);
}
} else if explicit_override {
eprintln!(
"Warning: AIDAEMON_ENV_FILE points to missing file '{}'",
env_file_path.display()
);
}
}
pub fn run() -> anyhow::Result<()> {
let working_dir = runtime_working_dir();
let config_path = working_dir.join("config.toml");
let env_file_path = resolve_runtime_env_file_path(&working_dir);
export_runtime_context(&config_path, &env_file_path, &working_dir);
load_startup_env_file(&env_file_path);
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| {
if cfg!(feature = "browser") {
EnvFilter::new("info,chromiumoxide=off")
} else {
EnvFilter::new("info")
}
}))
.init();
let args: Vec<String> = std::env::args().collect();
if args.len() > 1 {
match args[1].as_str() {
"--version" | "-V" => {
println!("aidaemon {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
"--help" | "-h" => {
println!("aidaemon {}", env!("CARGO_PKG_VERSION"));
println!("{}\n", env!("CARGO_PKG_DESCRIPTION"));
println!("Usage: aidaemon [COMMAND]\n");
println!("Commands:");
println!(" install-service Install as a system service (launchd/systemd)");
println!(" check-update Check for available updates");
println!(" migrate [--config] Run DB migrations and exit");
println!(" setup low-latency Configure Telegram webhook low-latency mode");
println!(
" browser login Launch Chrome to log into services for the agent"
);
println!(" attach <code> Attach native terminal to /agent session");
println!(" agent attach <code> Legacy alias for attach");
println!(" share [session_id] Generate a Telegram /agent resume code");
println!(
" agent start <agent> Launch codex/claude/gemini/opencode via aidaemon"
);
println!(" codex [cwd] [-- ...] Shortcut for: aidaemon agent start codex ...");
println!(" claude [cwd] [-- ...] Shortcut for: aidaemon agent start claude ...");
println!(" gemini [cwd] [-- ...] Shortcut for: aidaemon agent start gemini ...");
println!(
" opencode [cwd] [-- ...] Shortcut for: aidaemon agent start opencode ..."
);
println!(" keychain set <key> Store a secret in the OS keychain");
println!(" keychain get <key> Retrieve a secret from the OS keychain");
println!(" keychain delete <key> Remove a secret from the OS keychain");
println!("\nOptions:");
println!(" -h, --help Print help");
println!(" -V, --version Print version");
return Ok(());
}
"install-service" => {
return daemon::install_service();
}
"check-update" => {
println!("Checking for updates...");
match updater::Updater::check_for_update() {
Ok(Some((version, _notes))) => {
println!(
"Update available: v{} -> v{}",
env!("CARGO_PKG_VERSION"),
version
);
}
Ok(None) => {
println!("aidaemon is up to date (v{}).", env!("CARGO_PKG_VERSION"));
}
Err(e) => {
eprintln!("Update check failed: {}", e);
std::process::exit(1);
}
}
return Ok(());
}
"migrate" => {
return handle_migrate_command(&args[2..], config_path.as_path());
}
"setup" => {
return handle_setup_command(&args[2..], config_path.as_path());
}
"browser" => {
return crate::handle_browser_command(&args[2..], config_path.as_path());
}
"agent" => {
return handle_agent_command(&args[2..]);
}
"attach" => {
return handle_agent_command(&args[1..]);
}
"share" => {
let mut forwarded = Vec::with_capacity(args.len().saturating_sub(1));
forwarded.push("share".to_string());
forwarded.extend(args.iter().skip(2).cloned());
return handle_agent_command(&forwarded);
}
"keychain" => {
return handle_keychain_command(&args[2..]);
}
other => {
if let Some(agent) = normalize_terminal_agent_name(other) {
return launch_terminal_agent(agent, &args[2..]);
}
anyhow::bail!(
"Unknown command: {}. Run `aidaemon --help` for available commands.",
other
);
}
}
}
if !config_path.exists() {
let start_now = wizard::run_wizard(&config_path)?;
if !start_now {
return Ok(());
}
}
let config = match config::AppConfig::load(&config_path) {
Ok(c) => c,
Err(e) => {
eprintln!("Config load failed: {}", e);
let candidates = [
config_path.with_extension("toml.lastgood"),
config_path.with_extension("toml.bak"),
config_path.with_extension("toml.bak.1"),
config_path.with_extension("toml.bak.2"),
];
let mut restored = false;
for candidate in &candidates {
if candidate.exists() {
eprintln!("Trying restore from {}...", candidate.display());
if let Ok(()) = std::fs::copy(candidate, &config_path).map(|_| ()) {
if config::AppConfig::load(&config_path).is_ok() {
eprintln!("Restored config from {}", candidate.display());
restored = true;
break;
}
}
}
}
if !restored {
return Err(e);
}
config::AppConfig::load(&config_path)?
}
};
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(crate::core::run(config, config_path))
}
#[allow(unreachable_code)]
fn handle_browser_command(
args: &[String],
#[allow(unused)] config_path: &Path,
) -> anyhow::Result<()> {
let action = args.first().map(|s| s.as_str()).unwrap_or("");
match action {
"login" => {
#[cfg(not(feature = "browser"))]
{
eprintln!("Browser support is not compiled in.");
eprintln!("Rebuild with: cargo build --features browser");
std::process::exit(1);
}
#[cfg(feature = "browser")]
{
let config = config::AppConfig::load(config_path)?;
let profile_dir = config.browser.user_data_dir.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from(".").to_path_buf())
.join(".aidaemon")
.join("chrome-profile")
.to_string_lossy()
.into_owned()
});
let expanded = shellexpand::tilde(&profile_dir).into_owned();
std::fs::create_dir_all(&expanded)?;
println!("Launching Chrome for login...");
println!("Profile: {}", expanded);
println!();
println!("Log into the services you want the agent to access,");
println!("then close Chrome when you're done.");
println!();
let chrome_bin = find_chrome_binary().ok_or_else(|| {
anyhow::anyhow!("Chrome not found. Install Chrome or Chromium and try again.")
})?;
let profile = config.browser.profile.as_deref().unwrap_or("Default");
let status = std::process::Command::new(&chrome_bin)
.arg(format!("--user-data-dir={}", expanded))
.arg(format!("--profile-directory={}", profile))
.arg("--no-first-run")
.arg("--no-default-browser-check")
.status()?;
if status.success() {
println!();
println!("Chrome closed. Your login sessions are saved.");
println!(
"The agent will use them automatically when the browser tool is enabled."
);
} else {
eprintln!("Chrome exited with status: {}", status);
}
}
}
_ => {
eprintln!("Usage: aidaemon browser login");
eprintln!();
eprintln!("Launch Chrome to log into services for the agent.");
eprintln!("Sessions are saved and reused by the browser tool.");
std::process::exit(1);
}
}
Ok(())
}
fn handle_migrate_command(args: &[String], default_config_path: &Path) -> anyhow::Result<()> {
let mut config_path = default_config_path.to_path_buf();
let mut i = 0usize;
while i < args.len() {
match args[i].as_str() {
"-h" | "--help" => {
println!("Usage: aidaemon migrate [--config PATH]");
println!();
println!("Run startup database migrations and exit.");
println!("Defaults to ./config.toml if --config is not provided.");
return Ok(());
}
"-c" | "--config" => {
let Some(path) = args.get(i + 1) else {
anyhow::bail!("Missing value for --config");
};
config_path = PathBuf::from(path);
i += 2;
}
other => {
anyhow::bail!("Unknown migrate option: {other}");
}
}
}
if !config_path.exists() {
anyhow::bail!(
"Config file not found at {}. Run `aidaemon` once to generate config.toml, or pass --config PATH.",
config_path.display()
);
}
let config = config::AppConfig::load(&config_path)?;
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(crate::core::run_migrations_only(
config,
config_path.clone(),
))?;
println!(
"Migrations completed successfully (config: {}).",
config_path.display()
);
Ok(())
}
fn handle_setup_command(args: &[String], default_config_path: &Path) -> anyhow::Result<()> {
let action = args.first().map(|s| s.as_str()).unwrap_or("");
match action {
"low-latency" | "telegram-webhook" => {
if !default_config_path.exists() {
anyhow::bail!(
"Config file not found at {}. Run `aidaemon` once to generate config.toml first.",
default_config_path.display()
);
}
wizard::run_low_latency_setup(default_config_path)
}
"-h" | "--help" | "" => {
println!("Usage:");
println!(" aidaemon setup low-latency");
println!(" aidaemon setup telegram-webhook");
println!();
println!("Configure Telegram webhook low-latency mode (opt-in).");
println!("Defaults remain polling unless you run this setup.");
Ok(())
}
other => anyhow::bail!("Unknown setup command: {other}"),
}
}
fn handle_agent_command(args: &[String]) -> anyhow::Result<()> {
let action = args.first().map(|s| s.as_str()).unwrap_or("");
match action {
"attach" => {
let Some(code) = args.get(1).map(|v| v.trim()).filter(|v| !v.is_empty()) else {
anyhow::bail!("Usage: aidaemon attach <code>");
};
#[cfg(feature = "terminal-bridge")]
{
crate::terminal_bridge::run_local_attach_cli(code)
}
#[cfg(not(feature = "terminal-bridge"))]
{
anyhow::bail!(
"This binary was built without terminal bridge support. Rebuild with --features terminal-bridge."
);
}
}
"start" => {
let Some(raw_agent) = args.get(1).map(|v| v.as_str()) else {
anyhow::bail!(
"Usage: aidaemon agent start <codex|claude|gemini|opencode> [cwd] [-- flags...]"
);
};
let Some(agent) = normalize_terminal_agent_name(raw_agent) else {
anyhow::bail!(
"Unknown agent `{}`. Supported: codex, claude, gemini, opencode.",
raw_agent
);
};
launch_terminal_agent(agent, &args[2..])
}
"share" => {
#[cfg(feature = "terminal-bridge")]
{
let explicit_session_id = args.get(1).map(|v| v.as_str());
crate::terminal_bridge::run_local_share_cli(explicit_session_id)
}
#[cfg(not(feature = "terminal-bridge"))]
{
anyhow::bail!(
"This binary was built without terminal bridge support. Rebuild with --features terminal-bridge."
);
}
}
"-h" | "--help" | "" => {
println!("Usage:");
println!(" aidaemon attach <code>");
println!(" aidaemon agent attach <code>");
println!(" aidaemon agent start <codex|claude|gemini|opencode> [cwd] [-- flags...]");
println!(" aidaemon share [session_id]");
println!();
println!("Attach your native terminal to an active /agent session started from Telegram Mini App.");
println!(
"The one-time code is generated inside Telegram when you tap Continue on Computer."
);
println!();
println!("Shortcuts:");
println!(" aidaemon codex [cwd] [-- flags...]");
println!(" aidaemon claude [cwd] [-- flags...]");
println!(" aidaemon gemini [cwd] [-- flags...]");
println!(" aidaemon opencode [cwd] [-- flags...]");
Ok(())
}
other => anyhow::bail!("Unknown agent command: {other}"),
}
}
pub(crate) fn normalize_terminal_agent_name(raw: &str) -> Option<&'static str> {
let normalized = raw.trim().to_ascii_lowercase();
match normalized.as_str() {
"codex" => Some("codex"),
"claude" => Some("claude"),
"gemini" => Some("gemini"),
"opencode" => Some("opencode"),
_ => None,
}
}
pub(crate) fn normalize_terminal_agent_permission_aliases(
agent: Option<&str>,
args: Vec<String>,
) -> (Vec<String>, bool) {
if !agent
.map(|value| value.trim().eq_ignore_ascii_case("claude"))
.unwrap_or(false)
{
return (args, false);
}
let mut out = Vec::with_capacity(args.len());
let mut has_dangerous_skip = false;
let mut rewrote = false;
for arg in args {
if arg == "--dangerously-skip-permissions" {
has_dangerous_skip = true;
out.push(arg);
} else if arg == "--allow-dangerously-skip-permissions" {
rewrote = true;
if !has_dangerous_skip {
out.push("--dangerously-skip-permissions".to_string());
has_dangerous_skip = true;
}
} else {
out.push(arg);
}
}
(out, rewrote)
}
fn parse_terminal_agent_launch_args(
raw_args: &[String],
) -> anyhow::Result<(Option<PathBuf>, Vec<String>)> {
let mut cwd: Option<PathBuf> = None;
let mut args_start = 0usize;
if let Some(first) = raw_args.first().map(|v| v.trim()) {
if first == "--" {
args_start = 1;
} else if !first.starts_with("--") {
if first.is_empty() || first.contains('\0') {
anyhow::bail!("Invalid working directory.");
}
cwd = Some(PathBuf::from(shellexpand::tilde(first).into_owned()));
args_start = 1;
if raw_args
.get(args_start)
.map(|v| v.as_str() == "--")
.unwrap_or(false)
{
args_start += 1;
}
}
}
let mut launch_args: Vec<String> = Vec::new();
for raw in raw_args.iter().skip(args_start) {
let value = raw.trim();
if value.is_empty() {
continue;
}
if value.contains('\0') {
anyhow::bail!("Agent flag contains an invalid NUL byte.");
}
if value.len() > MAX_AGENT_LAUNCH_ARG_CHARS {
anyhow::bail!(
"Agent flag exceeds {} characters.",
MAX_AGENT_LAUNCH_ARG_CHARS
);
}
launch_args.push(value.to_string());
if launch_args.len() > MAX_AGENT_LAUNCH_ARGS {
anyhow::bail!("Too many agent flags (max {}).", MAX_AGENT_LAUNCH_ARGS);
}
}
Ok((cwd, launch_args))
}
fn launch_terminal_agent(agent: &str, raw_args: &[String]) -> anyhow::Result<()> {
if !SUPPORTED_TERMINAL_AGENTS.contains(&agent) {
anyhow::bail!("Unsupported terminal agent: {}", agent);
}
let (cwd, launch_args) = parse_terminal_agent_launch_args(raw_args)?;
let (launch_args, _) = normalize_terminal_agent_permission_aliases(Some(agent), launch_args);
#[cfg(feature = "terminal-bridge")]
{
crate::terminal_bridge::run_local_start_cli(agent, cwd.as_deref(), &launch_args)
}
#[cfg(not(feature = "terminal-bridge"))]
{
let mut cmd = std::process::Command::new(agent);
cmd.args(&launch_args)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
if let Some(dir) = cwd.as_ref() {
cmd.current_dir(dir);
}
let status = cmd.status().map_err(|err| {
anyhow::anyhow!(
"Failed to launch `{}`: {}. Ensure the CLI is installed and on PATH.",
agent,
err
)
})?;
if status.success() {
return Ok(());
}
if let Some(code) = status.code() {
std::process::exit(code);
}
anyhow::bail!("`{}` terminated unexpectedly.", agent);
}
}
#[cfg(feature = "browser")]
fn find_chrome_binary() -> Option<String> {
#[cfg(target_os = "macos")]
{
let candidates = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
];
for path in &candidates {
if std::path::Path::new(path).exists() {
return Some(path.to_string());
}
}
}
#[cfg(target_os = "linux")]
{
let candidates = [
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
];
for name in &candidates {
if std::process::Command::new("which")
.arg(name)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
return Some(name.to_string());
}
}
}
#[cfg(target_os = "windows")]
{
let candidates = [
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
];
for path in &candidates {
if std::path::Path::new(path).exists() {
return Some(path.to_string());
}
}
}
None
}
fn handle_keychain_command(args: &[String]) -> anyhow::Result<()> {
if args.is_empty() {
eprintln!("Usage: aidaemon keychain <set|get|delete> <key>");
eprintln!("\nExamples:");
eprintln!(" aidaemon keychain set api_key");
eprintln!(" aidaemon keychain set http_auth_twitter_api_key");
eprintln!(" aidaemon keychain get api_key");
eprintln!(" aidaemon keychain delete api_key");
std::process::exit(1);
}
let action = args[0].as_str();
let key = args.get(1).map(|s| s.as_str());
match action {
"set" => {
let key = key.unwrap_or_else(|| {
eprintln!("Usage: aidaemon keychain set <key>");
std::process::exit(1);
});
let value = dialoguer::Password::new()
.with_prompt(format!("Enter value for '{}'", key))
.with_confirmation("Confirm value", "Values don't match, try again")
.interact()?;
config::store_in_keychain(key, &value)?;
println!("Stored '{}' in OS keychain (service: aidaemon)", key);
}
"get" => {
let key = key.unwrap_or_else(|| {
eprintln!("Usage: aidaemon keychain get <key>");
std::process::exit(1);
});
match config::resolve_from_keychain(key) {
Ok(value) => {
if value.len() > 12 {
let masked = format!(
"{}{}{}",
&value[..4],
"*".repeat(value.len() - 8),
&value[value.len() - 4..]
);
println!("{}", masked);
} else {
println!("{}", "*".repeat(value.len()));
}
}
Err(e) => {
eprintln!("Not found: {}", e);
std::process::exit(1);
}
}
}
"delete" => {
let key = key.unwrap_or_else(|| {
eprintln!("Usage: aidaemon keychain delete <key>");
std::process::exit(1);
});
config::delete_from_keychain(key)?;
println!("Deleted '{}' from OS keychain", key);
}
_ => {
eprintln!(
"Unknown keychain action: '{}'. Use set, get, or delete.",
action
);
std::process::exit(1);
}
}
Ok(())
}
#[cfg(test)]
mod cli_alias_tests {
use super::*;
#[test]
fn parse_terminal_agent_launch_args_accepts_cwd_and_flags_with_delimiter() {
let args = vec![
"~/projects/aidaemon".to_string(),
"--".to_string(),
"--dangerously-skip-permissions".to_string(),
"--chrome".to_string(),
];
let (cwd, flags) = parse_terminal_agent_launch_args(&args).expect("parse");
assert_eq!(
cwd,
Some(PathBuf::from(
shellexpand::tilde("~/projects/aidaemon").into_owned()
))
);
assert_eq!(
flags,
vec![
"--dangerously-skip-permissions".to_string(),
"--chrome".to_string()
]
);
}
#[test]
fn parse_terminal_agent_launch_args_accepts_flag_only_invocation() {
let args = vec!["--model".to_string(), "gpt-5".to_string()];
let (cwd, flags) = parse_terminal_agent_launch_args(&args).expect("parse");
assert_eq!(cwd, None);
assert_eq!(flags, vec!["--model".to_string(), "gpt-5".to_string()]);
}
#[test]
fn normalize_terminal_agent_name_is_case_insensitive() {
assert_eq!(normalize_terminal_agent_name("CoDeX"), Some("codex"));
assert_eq!(normalize_terminal_agent_name("claude"), Some("claude"));
assert_eq!(normalize_terminal_agent_name("nope"), None);
}
#[test]
fn normalize_terminal_agent_permission_aliases_rewrites_claude_allow_flag() {
let args = vec![
"--allow-dangerously-skip-permissions".to_string(),
"--model".to_string(),
"sonnet".to_string(),
];
let (normalized, rewrote) =
normalize_terminal_agent_permission_aliases(Some("claude"), args);
assert!(rewrote);
assert_eq!(
normalized,
vec![
"--dangerously-skip-permissions".to_string(),
"--model".to_string(),
"sonnet".to_string()
]
);
}
#[test]
fn normalize_terminal_agent_permission_aliases_leaves_other_agents_unchanged() {
let args = vec!["--allow-dangerously-skip-permissions".to_string()];
let (normalized, rewrote) =
normalize_terminal_agent_permission_aliases(Some("codex"), args.clone());
assert!(!rewrote);
assert_eq!(normalized, args);
}
}