zeph 0.22.2

Lightweight AI agent with hybrid inference, skills-first architecture, and multi-channel I/O
// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
// SPDX-License-Identifier: MIT OR Apache-2.0

// Raised from the default 128 to accommodate deeply nested async state machines
// generated by #[tracing::instrument] on the agent call stack.
#![recursion_limit = "256"]

// Thread-local allocation counter used by AllocLayer for per-span heap tracking
// (global allocator declaration, requiring `unsafe impl GlobalAlloc`). Other `unsafe`
// blocks exist elsewhere in the workspace (e.g. `zeph-llm`'s candle-backed embedders and
// classifiers, `src/runner.rs`), each documented with its own `// SAFETY:` comment.
#[cfg(feature = "profiling-alloc")]
#[allow(unsafe_code)]
mod alloc_counter {
    use std::alloc::{GlobalAlloc, Layout, System};
    use std::cell::RefCell;

    /// Global allocator that records per-thread allocation counts and bytes.
    ///
    /// All allocation and deallocation operations are forwarded unchanged to the
    /// system allocator. The only addition is a thread-local counter update, which
    /// uses `const`-initialised storage to avoid re-entrant allocation on first access.
    pub struct CountingAllocator;

    // SAFETY: All methods delegate to `System` with identical arguments.
    // Thread-local counter updates use `const`-initialised `RefCell` in `.tdata`/`.tbss`,
    // so no dynamic allocation occurs on first thread-local access, eliminating allocator
    // re-entrancy. Borrows are non-overlapping: each function borrow-mutably, completes,
    // and drops the guard before returning.
    unsafe impl GlobalAlloc for CountingAllocator {
        unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
            increment_alloc(layout.size());
            // SAFETY: forwarding to System with the same layout.
            unsafe { System.alloc(layout) }
        }

        unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
            increment_dealloc(layout.size());
            // SAFETY: forwarding to System with the same pointer and layout.
            unsafe { System.dealloc(ptr, layout) }
        }
    }

    #[derive(Clone, Copy)]
    struct RawStats {
        alloc_count: u64,
        alloc_bytes: u64,
        dealloc_count: u64,
        dealloc_bytes: u64,
    }

    impl RawStats {
        const ZERO: Self = Self {
            alloc_count: 0,
            alloc_bytes: 0,
            dealloc_count: 0,
            dealloc_bytes: 0,
        };
    }

    // `const` initialisation places the slot in `.tdata`/`.tbss` — no heap allocation
    // on first access, preventing re-entrant calls back into this allocator.
    thread_local! {
        static STATS: RefCell<RawStats> = const { RefCell::new(RawStats::ZERO) };
    }

    fn increment_alloc(size: usize) {
        STATS.with(|s| {
            let mut s = s.borrow_mut();
            s.alloc_count += 1;
            s.alloc_bytes += size as u64;
        });
    }

    fn increment_dealloc(size: usize) {
        STATS.with(|s| {
            let mut s = s.borrow_mut();
            s.dealloc_count += 1;
            s.dealloc_bytes += size as u64;
        });
    }

    /// Snapshot the current thread's allocation counters.
    ///
    /// Returns `(alloc_count, alloc_bytes, dealloc_count, dealloc_bytes)`.
    /// Counters are monotonically increasing per-thread and are never reset.
    /// `AllocLayer` computes deltas by subtracting the enter snapshot from the exit snapshot.
    pub fn snapshot() -> (u64, u64, u64, u64) {
        STATS.with(|s| {
            let s = s.borrow();
            (
                s.alloc_count,
                s.alloc_bytes,
                s.dealloc_count,
                s.dealloc_bytes,
            )
        })
    }
}

#[cfg(feature = "profiling-alloc")]
#[allow(unsafe_code)]
#[global_allocator]
static GLOBAL: alloc_counter::CountingAllocator = alloc_counter::CountingAllocator;

mod acp;
mod agent_setup;
mod bootstrap;
mod channel;
#[cfg(feature = "otel")]
mod circuit_breaker_exporter;
mod cli;
mod commands;
mod daemon;
mod db_url;
mod execution_mode;
mod fleet_session;
mod gateway_spawn;
mod init;
#[cfg(feature = "prometheus")]
mod metrics_export;
#[cfg(feature = "profiling-pyroscope")]
mod pyroscope_push;
#[cfg(feature = "otel")]
mod redacting_span_processor;
mod runner;
mod scheduler;
#[cfg(feature = "scheduler")]
mod scheduler_executor;
#[cfg(feature = "session")]
mod serve;
mod startup_checks;
mod tracing_init;
mod tui_bridge;
mod tui_remote;
#[cfg(feature = "deep-link")]
mod url_scheme;

use clap::Parser;
use cli::Cli;

// `Config`'s derive-generated `Deserialize` visitor (43 top-level fields,
// `crates/zeph-config/src/root.rs`) produces a very large single stack frame in
// unoptimized debug builds. Stacked on top of the similarly large `runner::run`/
// `run_daemon` async-fn frames, this can exceed the OS main thread's default stack
// size (8 MiB on macOS/Linux, entirely governed by the caller's `ulimit -s`). Driving
// the runtime from a dedicated thread with an explicit, generous stack removes the
// dependency on the invoking shell/service manager's default (see #5394).
// 32 MiB is a 2x margin over the 16 MiB confirmed sufficient during root-cause
// debugging, leaving headroom as `Config` grows new fields over time.
const MAIN_THREAD_STACK_SIZE: usize = 32 * 1024 * 1024;

fn main() -> anyhow::Result<()> {
    std::thread::Builder::new()
        .name("zeph-main".into())
        .stack_size(MAIN_THREAD_STACK_SIZE)
        .spawn(|| {
            tokio::runtime::Builder::new_multi_thread()
                .enable_all()
                .build()?
                .block_on(Box::pin(runner::run(Cli::parse())))
        })?
        .join()
        .expect("zeph-main thread panicked")
}

#[cfg(test)]
mod tests;