synaps 0.7.0

Terminal-native AI agent runtime — parallel orchestration, reactive subagents, MCP, autonomous supervision
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
#![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;
mod watcher;

// ── Allocator ────────────────────────────────────────────────────────────────
// Long-lived sessions serialize the ENTIRE conversation to a JSON body every
// turn (prompt caching requires sending the full prefix — see
// runtime/api.rs `serde_json::to_vec(&body)`). That transient buffer scales
// with history length; on glibc the freed arena pages are never returned to the
// OS, so RSS ratchets up to ~history size and never shrinks (the classic
// transient-heavy-long-lived-process hysteresis).
//
// jemalloc's background thread purges cold dirty pages back to the OS, which
// reclaims exactly that dead-but-retained arena — with zero impact on caching
// or retained history. Gated to non-musl: musl's allocator already returns
// memory readily, and the Pria agentic-VM runtime is a musl build we don't want
// to perturb.
#[cfg(not(target_env = "musl"))]
#[global_allocator]
static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;

// Configure jemalloc at load time (no env var needed). Three settings:
//   - background_thread:true  → background thread purges cold dirty pages to OS
//   - narenas:4               → cap arenas (default is 4×ncpu = 48 on a 12-core
//                               box). 26 runtime threads sprawling across 48
//                               arenas reserve ~70 MB of 4 MB chunks at baseline
//                               for no benefit; 4 arenas is ample for our
//                               allocation concurrency and slashes idle RSS.
//   - dirty_decay_ms:1000     → return idle dirty pages ~1s after they go cold
//   - muzzy_decay_ms:0        → purge muzzy pages immediately
//
// CRITICAL: tikv-jemalloc-sys is built with the `_rjem_` symbol prefix, so
// jemalloc reads the config from `_rjem_malloc_conf` — NOT `malloc_conf`. An
// earlier revision exported the unmangled name, which jemalloc silently ignored
// (verified: opt.dirty_decay_ms sat at the 10000ms default). Keep this mangled.
#[cfg(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 {
    /// Configuration profile (loads ~/.synaps-cli/<PROFILE>/config)
    #[arg(long, global = true)]
    profile: Option<String>,

    /// Continue a previous session (TUI only). Optionally provide a session ID.
    #[arg(long = "continue", value_name = "NAME_OR_ID")]
    continue_session: Option<Option<String>>,

    /// System prompt: a string or path to a file (TUI only).
    #[arg(long = "system", short = 's', value_name = "PROMPT_OR_FILE")]
    system: Option<String>,

    /// Typed modular prompt manifest (validated offline before session start).
    #[arg(long = "prompt-manifest", value_name = "PATH")]
    prompt_manifest: Option<std::path::PathBuf>,

    /// Disable all extensions for this session.
    #[arg(long)]
    no_extensions: bool,

    #[command(subcommand)]
    command: Option<Command>,
}

#[derive(Subcommand)]
enum Command {
    /// Headless chat with full engine (MCP, extensions, skills, sessions)
    Chat {
        /// Continue a previous session (optional session ID, name, or chain)
        #[arg(long, short = 'c')]
        continue_session: Option<String>,
        /// System prompt (path or inline)
        #[arg(long, short = 's')]
        system: Option<String>,
        /// Agent name (loads agent prompt from ~/.synaps-cli/agents/)
        #[arg(long, short = 'a')]
        agent: Option<String>,
        /// Profile name
        #[arg(long)]
        profile: Option<String>,
        /// Disable extensions
        #[arg(long)]
        no_extensions: bool,
    },
    /// WebSocket API server
    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>>,
        /// Auth token (overrides config). Empty string disables auth.
        #[arg(long)]
        token: Option<String>,
        /// Auto-approve extension confirm hooks without prompting.
        #[arg(long)]
        auto_approve_confirms: bool,
        /// Comma-separated allowed origins (overrides config).
        #[arg(long)]
        allowed_origins: Option<String>,
    },
    /// Headless autonomous agent
    Agent {
        #[arg(long)]
        config: String,
        #[arg(long, default_value = "manual start")]
        trigger_context: String,
    },
    /// Agent supervisor and watcher
    Watcher {
        #[arg(default_value = "help")]
        subcommand: String,
        /// Additional arguments
        args: Vec<String>,
    },
    /// OAuth login
    Login {
        /// Non-interactive: log in directly with a named provider (skips the picker). e.g. openai-codex, claude
        #[arg(long)]
        provider: Option<String>,
    },
    /// Authentication management
    Auth {
        #[command(subcommand)]
        action: AuthAction,
    },
    /// Show account usage and reset times
    Status,
    /// Credential broker — serve short-lived access tokens to client machines
    /// over HTTP/HTTPS so they can share one OAuth credential without storing it.
    ///
    /// For non-loopback binds, pass --tls-cert + --tls-key to enable HTTPS (recommended),
    /// or --insecure-http to acknowledge you are running behind WireGuard / a private network.
    AuthBroker {
        /// Address to bind, e.g. `0.0.0.0:8181` or `127.0.0.1:8181`.
        #[arg(long, default_value = "127.0.0.1:8181")]
        bind: String,
        /// Token clients must present (`Authorization: Bearer <token>`). Falls
        /// back to `--machine-token-file`, then `SYNAPS_BROKER_TOKEN`.
        #[arg(long)]
        machine_token: Option<String>,
        /// Read the machine token from a file (avoids exposing it in argv/`ps`).
        #[arg(long)]
        machine_token_file: Option<std::path::PathBuf>,
        /// Allow starting with auth OFF on a non-loopback bind (serves
        /// credentials unauthenticated to the network — NOT recommended).
        #[arg(long)]
        insecure_no_auth: bool,
        /// PEM file containing the TLS certificate chain. Must be paired with --tls-key.
        /// When both are set, the broker listens on HTTPS.
        #[arg(long, value_name = "PATH")]
        tls_cert: Option<std::path::PathBuf>,
        /// PEM file containing the TLS private key. Must be paired with --tls-cert.
        #[arg(long, value_name = "PATH")]
        tls_key: Option<std::path::PathBuf>,
        /// Allow plain HTTP on a non-loopback bind without TLS.
        /// Only use this when the broker is behind WireGuard or another encrypted
        /// private network — the traffic will be unencrypted in-process.
        #[arg(long)]
        insecure_http: bool,
    },
    /// Headless line-JSON RPC server on stdin/stdout (synaps-bridge IPC)
    Rpc {
        /// Resume an existing session by ID, name, or prefix.
        #[arg(long = "continue", value_name = "SESSION_ID")]
        continue_id: Option<String>,
        /// System prompt: a string or path to a file.
        #[arg(long = "system", short = 's', value_name = "PROMPT_OR_FILE")]
        system: Option<String>,
        /// Override the active model for this session.
        #[arg(long = "model", short = 'm', value_name = "MODEL_ID")]
        model: Option<String>,
        /// Configuration profile to load.
        #[arg(long = "profile", value_name = "PROFILE")]
        profile: Option<String>,
    },
    /// Send an event to the inbox (picked up by running session)
    Send {
        /// Message text
        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,
        /// Target a specific session by ID, name, or prefix
        #[arg(long, value_name = "SESSION")]
        session: Option<String>,
        /// Send to all active sessions
        #[arg(long)]
        broadcast: bool,
    },
    /// Generate shell completions (bash, zsh, fish, elvish, powershell)
    Completions {
        /// Target shell
        #[arg(value_enum)]
        shell: clap_complete::Shell,
    },
    /// Offline modular prompt validation and inspection.
    Prompt {
        #[command(subcommand)]
        action: cmd::prompt::PromptAction,
    },
    /// Tool-surface utilities (schema export, etc.)
    Tools {
        #[command(subcommand)]
        action: cmd::tools::ToolsAction,
    },
    /// Request-trace export utilities (metadata-only by default).
    Trace {
        #[command(subcommand)]
        action: cmd::trace::TraceAction,
    },
    /// Unified retention: inspect/sweep/export/forget across sessions,
    /// memory, indexes, traces, and logs (chain-integrity-safe).
    Retention {
        #[command(subcommand)]
        action: cmd::retention::RetentionAction,
    },
}

#[derive(Subcommand)]
enum AuthAction {
    /// OAuth / API-key login (non-interactive with --provider, interactive without)
    Login {
        /// Non-interactive: log in directly with a named provider (skips the picker). e.g. openai-codex, claude
        #[arg(long)]
        provider: Option<String>,
    },
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();
    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 => {
            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) => {
            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::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(())
}