#![allow(
clippy::too_many_arguments,
clippy::collapsible_if,
clippy::collapsible_match,
clippy::single_match,
clippy::field_reassign_with_default,
clippy::manual_clamp,
clippy::needless_borrow,
clippy::explicit_auto_deref,
clippy::manual_strip,
clippy::unwrap_or_default,
clippy::await_holding_lock,
clippy::useless_format,
clippy::cmp_owned,
clippy::items_after_test_module
)]
use clap::{Parser, Subcommand};
use synaps_cli::tui;
mod cmd;
#[cfg(unix)]
mod watcher;
#[cfg(all(unix, not(target_env = "musl")))]
#[global_allocator]
static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
#[cfg(all(unix, not(target_env = "musl")))]
#[allow(non_upper_case_globals)]
#[export_name = "_rjem_malloc_conf"]
pub static MALLOC_CONF: &[u8] =
b"background_thread:true,narenas:4,dirty_decay_ms:1000,muzzy_decay_ms:0\0";
#[derive(Parser)]
#[command(
name = "synaps",
about = "SynapsCLI — terminal-native AI agent runtime (TUI, headless, server, RPC)",
long_about = "SynapsCLI — terminal-native AI agent runtime.\n\nRun with no arguments for the interactive TUI. Subcommands provide headless chat,\na WebSocket server, autonomous agents, and event injection into running sessions.",
version
)]
struct Cli {
#[arg(long, global = true)]
profile: Option<String>,
#[arg(long = "continue", value_name = "NAME_OR_ID")]
continue_session: Option<Option<String>>,
#[arg(long = "system", short = 's', value_name = "PROMPT_OR_FILE")]
system: Option<String>,
#[arg(long = "prompt-manifest", value_name = "PATH")]
prompt_manifest: Option<std::path::PathBuf>,
#[arg(long)]
no_extensions: bool,
#[arg(long = "attach", value_name = "ID", global = true, num_args = 0..=1)]
attach: Option<Option<String>>,
#[arg(long, global = true, conflicts_with = "takeover")]
observe: bool,
#[arg(long, global = true)]
takeover: bool,
#[arg(long = "keep-warm", global = true)]
keep_warm: bool,
#[arg(long = "new", alias = "create", requires = "attach")]
new_session: bool,
#[arg(long = "name", value_name = "NAME", requires = "attach")]
session_name: Option<String>,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
Chat {
#[arg(long, short = 'c')]
continue_session: Option<String>,
#[arg(long, short = 's')]
system: Option<String>,
#[arg(long, short = 'a')]
agent: Option<String>,
#[arg(long)]
profile: Option<String>,
#[arg(long)]
no_extensions: bool,
},
Server {
#[arg(long, short, default_value = "3145")]
port: u16,
#[arg(long, default_value = "127.0.0.1")]
host: String,
#[arg(long = "system", short = 's')]
system: Option<String>,
#[arg(long = "continue", value_name = "NAME_OR_ID")]
continue_session: Option<Option<String>>,
#[arg(long)]
token: Option<String>,
#[arg(long)]
auto_approve_confirms: bool,
#[arg(long)]
allowed_origins: Option<String>,
},
Agent {
#[arg(long)]
config: String,
#[arg(long, default_value = "manual start")]
trigger_context: String,
},
Watcher {
#[arg(default_value = "help")]
subcommand: String,
args: Vec<String>,
},
Login {
#[arg(long)]
provider: Option<String>,
},
Auth {
#[command(subcommand)]
action: AuthAction,
},
Status {
#[arg(long)]
memory: bool,
#[arg(long, requires = "memory")]
json: bool,
#[arg(long, requires = "memory")]
pid: Option<u32>,
},
AuthBroker {
#[arg(long, default_value = "127.0.0.1:8181")]
bind: String,
#[arg(long)]
machine_token: Option<String>,
#[arg(long)]
machine_token_file: Option<std::path::PathBuf>,
#[arg(long)]
insecure_no_auth: bool,
#[arg(long, value_name = "PATH")]
tls_cert: Option<std::path::PathBuf>,
#[arg(long, value_name = "PATH")]
tls_key: Option<std::path::PathBuf>,
#[arg(long)]
insecure_http: bool,
},
Daemon(cmd::daemon::DaemonArgs),
Attach(cmd::attach::AttachArgs),
Rpc {
#[arg(long = "continue", value_name = "SESSION_ID")]
continue_id: Option<String>,
#[arg(long = "system", short = 's', value_name = "PROMPT_OR_FILE")]
system: Option<String>,
#[arg(long = "model", short = 'm', value_name = "MODEL_ID")]
model: Option<String>,
#[arg(long = "profile", value_name = "PROFILE")]
profile: Option<String>,
},
Send {
message: String,
#[arg(long, default_value = "cli")]
source: String,
#[arg(long, default_value = "medium")]
severity: String,
#[arg(long)]
channel: Option<String>,
#[arg(long = "content-type", default_value = "message")]
content_type: String,
#[arg(long, value_name = "SESSION")]
session: Option<String>,
#[arg(long)]
broadcast: bool,
},
Completions {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
Prompt {
#[command(subcommand)]
action: cmd::prompt::PromptAction,
},
Tools {
#[command(subcommand)]
action: cmd::tools::ToolsAction,
},
Trace {
#[command(subcommand)]
action: cmd::trace::TraceAction,
},
Retention {
#[command(subcommand)]
action: cmd::retention::RetentionAction,
},
}
#[derive(Subcommand)]
enum AuthAction {
Login {
#[arg(long)]
provider: Option<String>,
},
}
fn worker_threads() -> Option<usize> {
let ncpu = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4);
worker_threads_from(std::env::var("SYNAPS_WORKER_THREADS").ok().as_deref(), ncpu)
}
fn worker_threads_from(raw: Option<&str>, ncpu: usize) -> Option<usize> {
match raw.and_then(|v| v.trim().parse::<usize>().ok()) {
Some(0) => None,
Some(n) => Some(n),
None => Some(ncpu.clamp(1, 4)),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ThinClient {
Tui { profile: Option<String> },
Line { profile: Option<String> },
Adopt { profile: Option<String> },
}
fn adopt_enabled() -> bool {
!matches!(
std::env::var("SYNAPS_DAEMON_ADOPT").ok().map(|s| s.trim().to_ascii_lowercase()).as_deref(),
Some("0" | "false" | "off" | "no")
)
}
fn thin_client() -> Option<ThinClient> {
thin_client_from(std::env::args().skip(1), agent_engine::daemon::enabled(), adopt_enabled())
}
const VALUE_OPTS: &[&str] = &["--system", "-s", "--name", "--prompt-manifest"];
const OPT_VALUE_OPTS: &[&str] = &["--attach", "--continue"];
fn thin_client_from<I: IntoIterator<Item = String>>(
args: I,
daemon_enabled: bool,
adopt: bool,
) -> Option<ThinClient> {
if !daemon_enabled {
return None;
}
let mut want_profile = false;
let mut want_value = false;
let mut maybe_value = false;
let mut profile: Option<String> = None;
let mut first_positional: Option<String> = None;
let mut attach_flag = false;
let mut other_flag = false;
for a in args {
if want_profile {
want_profile = false;
profile = Some(a);
continue;
}
if want_value {
want_value = false;
continue;
}
if maybe_value {
maybe_value = false;
if !a.starts_with('-') {
continue;
}
}
if a == "--attach" || a.starts_with("--attach=") {
attach_flag = true;
maybe_value = a == "--attach";
} else if a == "--profile" {
want_profile = true;
} else if let Some(p) = a.strip_prefix("--profile=") {
profile = Some(p.to_string());
} else if VALUE_OPTS.contains(&a.as_str()) {
want_value = true;
other_flag |= a == "--name";
} else if OPT_VALUE_OPTS.contains(&a.as_str()) {
maybe_value = true;
} else if !a.starts_with('-') {
if first_positional.is_none() {
first_positional = Some(a);
}
} else {
other_flag = true;
}
}
if first_positional.as_deref() == Some("attach") {
Some(ThinClient::Line { profile })
} else if attach_flag && first_positional.is_none() {
Some(ThinClient::Tui { profile })
} else if adopt && first_positional.is_none() && !other_flag {
Some(ThinClient::Adopt { profile })
} else {
None
}
}
static ADOPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn ensure_daemon(kind: &ThinClient) -> Option<String> {
use agent_engine::daemon::{self, EnsureError, Ensured, EXIT_REFUSED};
let (profile, line) = match kind {
ThinClient::Tui { profile } | ThinClient::Adopt { profile } => (profile.clone(), false),
ThinClient::Line { profile } => (profile.clone(), true),
};
let opts = daemon::DaemonOpts { profile, ..Default::default() };
match daemon::ensure_running(&opts) {
Ok(Ensured::Running) => None,
Ok(Ensured::Spawned(pid)) => {
eprintln!("starting daemon (pid {pid}) — synaps daemon stop to end it");
None
}
Err(EnsureError::AutospawnDisabled) => {
if matches!(kind, ThinClient::Adopt { .. }) {
return Some("SYNAPS_DAEMON_AUTOSPAWN=0".into());
}
let msg = cmd::attach::no_daemon_message(opts.profile.as_deref());
eprintln!("{msg}");
std::process::exit(EXIT_REFUSED);
}
Err(EnsureError::Spawn(reason)) => {
if line {
eprintln!("synaps attach: daemon unavailable: {reason}");
std::process::exit(EXIT_REFUSED);
}
Some(reason)
}
}
}
const THIN_MALLOC_CONF: &str = "narenas:1,background_thread:false,dirty_decay_ms:0,muzzy_decay_ms:0";
const ATTACH_FALLBACK: &str = "SYNAPS_ATTACH_FALLBACK";
const REEXECED: &str = "SYNAPS_CLIENT_REEXECED";
const USER_MALLOC_CONF: &str = "SYNAPS_CLIENT_USER_MALLOC_CONF";
#[cfg(target_os = "linux")]
fn thin_client_reexec() {
use agent_core::core::memstat;
if std::env::var("SYNAPS_CLIENT_REEXEC").is_ok_and(|v| v == "0")
|| std::env::var_os(REEXECED).is_some()
|| std::env::var("SYNAPS_CLIENT_THP").is_ok_and(|v| v == "1")
|| tui::client_diet::allocator_tuning_disabled()
|| memstat::thp_disabled() == Some(true)
{
return;
}
if memstat::thp_sysfs_always() != Some(true) {
memstat::ladder("reexec", &"skipped=thp-not-always");
return;
}
let Ok(exe) = std::env::current_exe() else { return };
if memstat::disable_thp().is_err() {
return;
}
memstat::ladder("reexec", &"");
use std::os::unix::process::CommandExt;
let ours = std::env::var("SYNAPS_CLIENT_MALLOC_CONF").unwrap_or_else(|_| THIN_MALLOC_CONF.into());
let user = std::env::var("_RJEM_MALLOC_CONF").ok().filter(|v| !v.is_empty());
let conf = match &user {
Some(u) => format!("{u},{ours}"),
None => ours,
};
let mut cmd = std::process::Command::new(exe);
cmd.args(std::env::args_os().skip(1)).env(REEXECED, "1").env("_RJEM_MALLOC_CONF", conf);
if let Some(u) = user {
cmd.env(USER_MALLOC_CONF, u);
}
let err = cmd.exec();
let _ = err;
std::env::remove_var(REEXECED);
}
#[cfg(not(target_os = "linux"))]
fn thin_client_reexec() {}
fn scrub_reexec_env() {
if std::env::var_os(REEXECED).is_none() {
return;
}
std::env::remove_var(REEXECED);
match std::env::var(USER_MALLOC_CONF) {
Ok(user) => std::env::set_var("_RJEM_MALLOC_CONF", user),
Err(_) => std::env::remove_var("_RJEM_MALLOC_CONF"),
}
std::env::remove_var(USER_MALLOC_CONF);
}
fn main() -> anyhow::Result<()> {
let mut thin = false;
if let Some(kind) = thin_client() {
match ensure_daemon(&kind) {
None => {
if matches!(kind, ThinClient::Adopt { .. }) {
ADOPTED.store(true, std::sync::atomic::Ordering::SeqCst);
}
thin = true;
}
Some(reason) => {
eprintln!("daemon unavailable: {reason} — running in-process");
tui::push_boot_notice(format!("daemon unavailable: {reason} — running in-process"));
std::env::set_var(ATTACH_FALLBACK, "1");
}
}
}
if thin {
thin_client_reexec();
scrub_reexec_env();
agent_core::core::memstat::ladder("main", &"");
tui::client_diet::tune_allocator();
}
let rt = if thin {
tokio::runtime::Builder::new_current_thread().enable_all().thread_name("synaps-rt").build()?
} else {
let mut builder = tokio::runtime::Builder::new_multi_thread();
if let Some(n) = worker_threads() {
builder.worker_threads(n);
}
builder.enable_all().thread_name("synaps-rt").build()?
};
if thin {
agent_core::core::memstat::ladder("runtime", &"");
}
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
default_hook(info);
synaps_cli::EngineHost::flush_installed_logs();
}));
let result = rt.block_on(async_main());
synaps_cli::EngineHost::flush_installed_logs();
result
}
async fn async_main() -> anyhow::Result<()> {
let mut cli = Cli::parse();
if ADOPTED.load(std::sync::atomic::Ordering::SeqCst) && cli.command.is_none() && cli.attach.is_none() {
cli.attach = Some(None);
cli.new_session = true;
}
if matches!(cli.command, Some(Command::Prompt { .. })) {
if let Some(Command::Prompt { action }) = cli.command {
return cmd::prompt::run(action);
}
unreachable!();
}
if let Some(ref prof) = cli.profile {
synaps_cli::config::set_profile(Some(prof.clone()));
}
match cli.command {
None if cli.attach.is_some() => {
let fallback = std::env::var_os(ATTACH_FALLBACK).is_some();
std::env::remove_var(ATTACH_FALLBACK);
if !agent_engine::daemon::enabled() || fallback {
if !fallback {
eprintln!("--attach ignored: {}", agent_engine::daemon::DISABLED_NOTICE);
}
tui::run(
cli.continue_session,
cli.system,
cli.prompt_manifest,
cli.profile,
cli.no_extensions,
)
.await?;
} else {
let id = cli.attach.flatten();
tui::attach::run_attached(tui::attach::AttachOpts {
profile: cli.profile,
id,
continue_session: cli.continue_session,
system: cli.system,
prompt_manifest: cli.prompt_manifest,
mode: tui::attach::attach_mode(cli.observe, cli.takeover),
keep_warm: cli.keep_warm,
new_session: cli.new_session,
name: cli.session_name,
adopted: ADOPTED.load(std::sync::atomic::Ordering::SeqCst),
})
.await?;
}
}
None => {
tui::run(
cli.continue_session,
cli.system,
cli.prompt_manifest,
cli.profile,
cli.no_extensions,
)
.await?;
}
Some(Command::Chat {
continue_session,
system,
agent,
profile,
no_extensions,
}) => {
cmd::chat::run(continue_session, system, agent, profile, no_extensions).await?;
}
Some(Command::Server {
port,
host,
system,
continue_session,
token,
auto_approve_confirms,
allowed_origins,
}) => {
cmd::server::run(
port,
host,
system,
continue_session,
cli.profile,
token,
auto_approve_confirms,
allowed_origins,
)
.await?;
}
Some(Command::Agent {
config,
trigger_context,
}) => {
cmd::agent::run(config, trigger_context).await;
}
Some(Command::Watcher { subcommand, args }) => {
cmd::watcher::run(subcommand, args).await;
}
Some(Command::Login { provider }) => {
cmd::login::run(cli.profile, provider)
.await
.map_err(anyhow::Error::msg)?;
}
Some(Command::Auth { action }) => match action {
AuthAction::Login { provider } => cmd::login::run(cli.profile, provider)
.await
.map_err(anyhow::Error::msg)?,
},
Some(Command::Status { memory, json, pid }) => {
if memory {
cmd::status::run_memory(json, pid).map_err(|e| anyhow::anyhow!(e.to_string()))?;
} else {
cmd::status::run()
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
}
}
Some(Command::AuthBroker {
bind,
machine_token,
machine_token_file,
insecure_no_auth,
tls_cert,
tls_key,
insecure_http,
}) => {
cmd::auth_broker::run(
bind,
machine_token,
machine_token_file,
insecure_no_auth,
tls_cert,
tls_key,
insecure_http,
)
.await?;
}
Some(Command::Completions { shell }) => {
use clap::CommandFactory;
let mut cmd = Cli::command();
let name = cmd.get_name().to_string();
clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout());
}
Some(Command::Prompt { .. }) => {
unreachable!("prompt dispatched before profile initialization")
}
Some(Command::Tools { action }) => {
cmd::tools::run(action).await?;
}
Some(Command::Trace { action }) => {
cmd::trace::run(action)?;
}
Some(Command::Retention { action }) => {
cmd::retention::run(action)?;
}
Some(Command::Daemon(args)) => {
cmd::daemon::run(cli.profile, args).await?;
}
Some(Command::Attach(args)) => {
cmd::attach::run(cli.profile, args).await?;
}
Some(Command::Rpc {
continue_id,
system,
model,
profile,
}) => {
cmd::rpc::run(continue_id, system, model, profile).await?;
}
Some(Command::Send {
message,
source,
severity,
channel,
content_type,
session,
broadcast,
}) => {
cmd::send::run(
message,
source,
severity,
channel,
content_type,
session,
broadcast,
)
.await?;
}
}
Ok(())
}
#[cfg(test)]
mod worker_threads_tests {
use super::{thin_client_from, worker_threads_from};
fn v(a: &[&str]) -> Vec<String> {
a.iter().map(|s| s.to_string()).collect()
}
#[test]
fn thin_client_requires_daemon_flag() {
use super::ThinClient::{Line, Tui};
assert_eq!(thin_client_from(v(&["--attach"]), false, false), None);
assert_eq!(thin_client_from(v(&["attach"]), false, false), None);
assert_eq!(thin_client_from(v(&["--attach"]), true, false), Some(Tui { profile: None }));
assert_eq!(thin_client_from(v(&["--attach=abc"]), true, false), Some(Tui { profile: None }));
assert_eq!(thin_client_from(v(&["attach"]), true, false), Some(Line { profile: None }));
assert_eq!(thin_client_from(v(&["--profile", "x", "attach"]), true, false), Some(Line { profile: Some("x".into()) }));
assert_eq!(thin_client_from(v(&["--profile=x", "--attach", "--new"]), true, false), Some(Tui { profile: Some("x".into()) }));
assert_eq!(thin_client_from(v(&["send", "attach"]), true, false), None);
assert_eq!(thin_client_from(v(&["--profile", "attach"]), true, false), None);
assert_eq!(thin_client_from(v(&["daemon", "status", "--attach"]), true, false), None);
assert_eq!(thin_client_from(v(&[]), true, false), None);
}
#[test]
fn thin_client_sniff_skips_option_values() {
use super::ThinClient::{Line, Tui};
let t = Some(Tui { profile: None });
assert_eq!(thin_client_from(v(&["--attach", "--new", "--system", "/tmp/id.md"]), true, false), t);
assert_eq!(thin_client_from(v(&["--attach", "--new", "-s", "you are x"]), true, false), t);
assert_eq!(thin_client_from(v(&["--attach", "--new", "--name", "ambient"]), true, false), t);
assert_eq!(thin_client_from(v(&["--attach", "--prompt-manifest", "m.toml"]), true, false), t);
assert_eq!(thin_client_from(v(&["--system", "/tmp/id.md", "--attach", "--new"]), true, false), t);
assert_eq!(thin_client_from(v(&["--attach", "abc"]), true, false), t);
assert_eq!(thin_client_from(v(&["--attach", "--continue", "foo"]), true, false), t);
assert_eq!(thin_client_from(v(&["--continue", "--attach"]), true, false), t);
assert_eq!(thin_client_from(v(&["--attach", "abc", "status"]), true, false), None);
assert_eq!(thin_client_from(v(&["--system", "x", "daemon", "status"]), true, false), None);
assert_eq!(thin_client_from(v(&["--system", "x", "attach"]), true, false), Some(Line { profile: None }));
assert_eq!(thin_client_from(v(&["--attach", "--name", "attach"]), true, false), t);
assert_eq!(thin_client_from(v(&["--system", "attach"]), true, false), None);
}
#[test]
fn thin_client_adopt_only_for_plain_tui_argv() {
use super::ThinClient::{Adopt, Line, Tui};
let a = Some(Adopt { profile: None });
assert_eq!(thin_client_from(v(&[]), true, true), a);
assert_eq!(thin_client_from(v(&["--system", "/tmp/id.md"]), true, true), a);
assert_eq!(thin_client_from(v(&["-s", "you are x"]), true, true), a);
assert_eq!(thin_client_from(v(&["--continue"]), true, true), a);
assert_eq!(thin_client_from(v(&["--continue", "foo", "--system", "x"]), true, true), a);
assert_eq!(thin_client_from(v(&["--prompt-manifest", "m.toml"]), true, true), a);
assert_eq!(
thin_client_from(v(&["--profile", "x", "--system", "y"]), true, true),
Some(Adopt { profile: Some("x".into()) })
);
assert_eq!(thin_client_from(v(&[]), true, false), None);
assert_eq!(thin_client_from(v(&[]), false, true), None);
assert_eq!(thin_client_from(v(&["--attach", "--new"]), true, true), Some(Tui { profile: None }));
assert_eq!(thin_client_from(v(&["attach"]), true, true), Some(Line { profile: None }));
for argv in [
&["daemon", "status"][..],
&["chat", "-s", "x"],
&["run"],
&["--version"],
&["-V"],
&["--help"],
&["--no-extensions"],
&["--system", "x", "--no-extensions"],
&["--observe"],
&["--keep-warm"],
&["--new"],
&["--name", "n"],
&["--bogus"],
] {
assert_eq!(thin_client_from(v(argv), true, true), None, "{argv:?}");
}
}
#[test]
fn attach_new_flag_combos() {
use clap::Parser;
let ok = super::Cli::try_parse_from(["synaps", "--attach", "--new"]).unwrap();
assert!(ok.new_session && ok.attach.is_some());
let alias = super::Cli::try_parse_from(["synaps", "--attach", "--create"]).unwrap();
assert!(alias.new_session);
let both = super::Cli::try_parse_from(["synaps", "--attach", "abc", "--new"]).unwrap();
assert_eq!(both.attach.flatten().as_deref(), Some("abc"));
assert!(both.new_session);
let line = super::Cli::try_parse_from(["synaps", "attach", "--create"]).unwrap();
assert!(matches!(line.command, Some(super::Command::Attach(ref a)) if a.create));
let line = super::Cli::try_parse_from(["synaps", "attach", "--new"]).unwrap();
assert!(matches!(line.command, Some(super::Command::Attach(ref a)) if a.create));
assert!(super::Cli::try_parse_from(["synaps", "--new"]).is_err());
let named = super::Cli::try_parse_from(["synaps", "--attach", "--new", "--name", "ambient"]).unwrap();
assert_eq!(named.session_name.as_deref(), Some("ambient"));
assert!(super::Cli::try_parse_from(["synaps", "--name", "ambient"]).is_err());
}
#[test]
fn worker_threads_env_matrix() {
assert_eq!(worker_threads_from(None, 24), Some(4));
assert_eq!(worker_threads_from(None, 2), Some(2));
assert_eq!(worker_threads_from(None, 0), Some(1));
assert_eq!(worker_threads_from(Some("0"), 24), None);
assert_eq!(worker_threads_from(Some("8"), 24), Some(8));
assert_eq!(worker_threads_from(Some(" 3 "), 24), Some(3));
assert_eq!(worker_threads_from(Some("bogus"), 24), Some(4));
}
}