use std::{
collections::BTreeMap,
ffi::OsString,
fs,
io::Write,
net::SocketAddr,
path::{Path, PathBuf},
};
use anyhow::{anyhow, Context, Result};
use clap::{Args, Parser, Subcommand, ValueEnum};
use holon::{
client::{normalize_control_base_url, LocalClient},
config::{
built_in_provider_default_config, config_schema, credential_store_path, default_holon_home,
get_config_key, list_credential_profiles_at, load_credential_store_at,
load_persisted_config_at, persisted_config_path, provider_config_view,
provider_config_views, remove_credential_profile_at, save_persisted_config_at,
set_config_key, set_credential_profile_at, unset_config_key, validate_provider_config,
AppConfig, ControlAuthMode, CredentialKind, CredentialSource, ProviderAuthConfig,
ProviderConfigFile, ProviderId, ProviderTransportKind,
},
daemon::{
daemon_logs, daemon_restart, daemon_start, daemon_status, daemon_stop,
ensure_serve_preflight, RuntimeServiceHandle,
},
fd_limit::{apply_nofile_limit_policy, DEFAULT_NOFILE_TARGET},
host::RuntimeHost,
http::{self, AppState, ControlRequest, CreateCommandTaskRequest, CreateTimerRequest},
provider::{provider_doctor, resolved_model_availability},
run_once::{run_once, RunOnceRequest},
solve::{run_solve, SolveRequest},
storage::AppStorage,
tui::run_tui,
types::{AuditEvent, ControlAction, TimerStatus, TrustLevel, WaitingIntentStatus},
};
use tokio::net::TcpListener;
use tracing_subscriber::EnvFilter;
#[derive(Debug, Parser)]
#[command(name = "holon")]
#[command(about = "A headless, event-driven runtime for long-lived agents")]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)]
enum ControlCommandAction {
Start,
Stop,
Abort,
}
impl ControlCommandAction {
fn as_control_action(&self) -> Option<ControlAction> {
match self {
Self::Start => Some(ControlAction::Start),
Self::Stop => Some(ControlAction::Stop),
Self::Abort => None,
}
}
}
#[derive(Debug, Subcommand)]
enum Commands {
Serve {
#[command(flatten)]
options: ServeOptions,
},
Daemon {
#[command(subcommand)]
command: DaemonCommands,
},
Config {
#[command(subcommand)]
command: ConfigCommands,
},
Prompt {
text: String,
#[arg(long)]
agent: Option<String>,
},
Status {
#[arg(long)]
agent: Option<String>,
},
Tail {
#[arg(long, default_value_t = 20)]
limit: usize,
#[arg(long)]
agent: Option<String>,
},
Transcript {
#[arg(long, default_value_t = 50)]
limit: usize,
#[arg(long)]
agent: Option<String>,
},
Task {
summary: String,
#[arg(long)]
cmd: String,
#[arg(long)]
workdir: Option<String>,
#[arg(long)]
shell: Option<String>,
#[arg(long)]
login: Option<bool>,
#[arg(long)]
tty: bool,
#[arg(long)]
yield_time_ms: Option<u64>,
#[arg(long)]
max_output_tokens: Option<u64>,
#[arg(long)]
agent: Option<String>,
},
Timer {
#[arg(long)]
after_ms: u64,
#[arg(long)]
every_ms: Option<u64>,
#[arg(long)]
summary: Option<String>,
#[arg(long)]
agent: Option<String>,
},
#[command(about = "Deprecated: use `holon agent start|stop|abort [agent-id]`")]
Control {
action: ControlCommandAction,
#[arg(long)]
agent: Option<String>,
},
#[command(alias = "agents")]
Agent {
#[command(subcommand)]
command: Option<AgentCommands>,
},
Skills {
#[command(subcommand)]
command: SkillsCommands,
},
Run {
text: String,
#[arg(long, default_value = "trusted-operator")]
trust: TrustLevel,
#[arg(long)]
json: bool,
#[arg(long)]
agent: Option<String>,
#[arg(long, requires = "agent")]
create_agent: bool,
#[arg(long, requires = "create_agent")]
template: Option<String>,
#[arg(long, value_parser = clap::value_parser!(u64).range(1..))]
max_turns: Option<u64>,
#[arg(long)]
no_wait_for_tasks: bool,
#[arg(long)]
home: Option<PathBuf>,
#[arg(long)]
workspace_root: Option<PathBuf>,
#[arg(long)]
cwd: Option<PathBuf>,
},
Solve {
#[arg(value_name = "REF")]
target_ref: String,
#[arg(long)]
repo: Option<String>,
#[arg(long)]
base: Option<String>,
#[arg(long)]
goal: Option<String>,
#[arg(long)]
role: Option<String>,
#[arg(long)]
agent: Option<String>,
#[arg(long)]
template: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(long, value_parser = clap::value_parser!(u64).range(1..))]
max_turns: Option<u64>,
#[arg(long, default_value = "trusted-operator")]
trust: TrustLevel,
#[arg(long)]
json: bool,
#[arg(long)]
home: Option<PathBuf>,
#[arg(long)]
workspace: Option<PathBuf>,
#[arg(long)]
workspace_root: Option<PathBuf>,
#[arg(long)]
cwd: Option<PathBuf>,
#[arg(long)]
input: Option<PathBuf>,
#[arg(long)]
output: Option<PathBuf>,
},
Workspace {
#[command(subcommand)]
command: WorkspaceCommands,
},
Tui {
#[arg(long)]
no_alt_screen: bool,
#[arg(long)]
connect: Option<String>,
#[arg(long)]
token: Option<String>,
#[arg(long)]
token_file: Option<PathBuf>,
#[arg(long)]
token_profile: Option<String>,
},
Debug {
#[command(subcommand)]
command: DebugCommands,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum ServeAccess {
Local,
Tunnel,
Lan,
Tailnet,
}
#[derive(Debug, Clone, Args)]
struct ServeOptions {
#[arg(long, value_enum, default_value_t = ServeAccess::Local)]
access: ServeAccess,
#[arg(long)]
host: Option<String>,
#[arg(long)]
listen: Option<String>,
#[arg(long, value_parser = clap::value_parser!(u16).range(1..))]
port: Option<u16>,
#[arg(long)]
advertise: Option<String>,
#[arg(long)]
token: Option<String>,
#[arg(long)]
token_file: Option<PathBuf>,
}
#[derive(Debug, Subcommand)]
enum ConfigCommands {
Get {
key: String,
},
Set {
key: String,
value: String,
},
Unset {
key: String,
},
Providers {
#[command(subcommand)]
command: ConfigProviderCommands,
},
Credentials {
#[command(subcommand)]
command: ConfigCredentialCommands,
},
Models {
#[command(subcommand)]
command: ConfigModelCommands,
},
List,
Schema,
Doctor,
}
#[derive(Debug, Subcommand)]
enum ConfigModelCommands {
List,
}
#[derive(Debug, Subcommand)]
enum ConfigProviderCommands {
Set {
provider: String,
#[arg(long)]
transport: Option<String>,
#[arg(long)]
base_url: Option<String>,
#[arg(long, default_value = "none")]
credential_source: String,
#[arg(long, default_value = "none")]
credential_kind: String,
#[arg(long)]
credential_env: Option<String>,
#[arg(long)]
credential_profile: Option<String>,
#[arg(long)]
credential_external: Option<String>,
},
Get {
provider: String,
},
List,
Remove {
provider: String,
},
Doctor {
provider: String,
},
}
#[derive(Debug, Subcommand)]
enum ConfigCredentialCommands {
Set {
profile: String,
#[arg(long)]
kind: String,
#[arg(long)]
stdin: bool,
#[arg(
long,
help = "Raw credential material (not recommended for secrets; prefer --stdin to avoid shell history/process args leakage)."
)]
material: Option<String>,
},
List,
Remove {
profile: String,
},
}
#[derive(Debug, Subcommand)]
enum DaemonCommands {
Start {
#[command(flatten)]
options: ServeOptions,
},
Stop,
Status,
Restart {
#[command(flatten)]
options: ServeOptions,
},
Logs {
#[arg(long, default_value_t = 80)]
tail: usize,
},
}
#[derive(Debug, Subcommand)]
enum WorkspaceCommands {
Attach {
#[arg(long)]
agent: Option<String>,
path: PathBuf,
},
Exit {
#[arg(long)]
agent: Option<String>,
},
Detach {
#[arg(long)]
agent: Option<String>,
workspace_id: String,
},
}
#[derive(Debug, Subcommand)]
enum DebugCommands {
Prompt {
text: String,
#[arg(long)]
agent: Option<String>,
#[arg(long, default_value = "trusted-operator")]
trust: TrustLevel,
},
Latency {
#[arg(long)]
agent: Option<String>,
#[arg(long, default_value_t = 10)]
limit: usize,
#[arg(long, default_value_t = 5000)]
events_limit: usize,
},
SchedulerFixture {
#[arg(long)]
agent: Option<String>,
#[arg(long)]
output: PathBuf,
},
}
#[derive(Debug, Subcommand)]
enum AgentCommands {
List,
Status {
agent_id: Option<String>,
},
Create {
agent_id: String,
#[arg(long)]
template: Option<String>,
},
Start {
agent_id: Option<String>,
},
Stop {
agent_id: Option<String>,
},
Abort {
agent_id: Option<String>,
},
Model {
#[command(subcommand)]
command: AgentModelCommands,
},
}
#[derive(Debug, Subcommand)]
enum AgentModelCommands {
Get {
agent_id: Option<String>,
},
Set {
model: String,
agent_id: Option<String>,
},
Clear {
agent_id: Option<String>,
},
}
#[derive(Debug, Subcommand)]
enum SkillsCommands {
List {
#[arg(long)]
agent: Option<String>,
},
Install {
name_or_path: String,
#[arg(long)]
builtin: bool,
#[arg(long)]
remote: bool,
#[arg(long)]
skill: Option<String>,
#[arg(long)]
copy: bool,
#[arg(long)]
agent: Option<String>,
},
Uninstall {
name: String,
#[arg(long)]
agent: Option<String>,
},
}
#[tokio::main]
async fn main() -> Result<()> {
init_tracing();
let cli = Cli::parse();
match cli.command {
Commands::Config { command } => handle_config_command(command).await,
Commands::Run {
text,
trust,
json,
agent,
create_agent,
template,
max_turns,
no_wait_for_tasks,
home,
workspace_root,
cwd,
} => {
run_one_shot(
text,
trust,
json,
agent,
create_agent,
template,
max_turns,
no_wait_for_tasks,
home,
workspace_root,
cwd,
)
.await
}
Commands::Solve {
target_ref,
repo,
base,
goal,
role,
agent,
template,
model,
max_turns,
trust,
json,
home,
workspace,
workspace_root,
cwd,
input,
output,
} => {
run_solve_command(
target_ref,
repo,
base,
goal,
role,
agent,
template,
model,
max_turns,
trust,
json,
home,
workspace.or(workspace_root),
cwd,
input,
output,
)
.await
}
command => run_runtime_command(command).await,
}
}
async fn run_runtime_command(command: Commands) -> Result<()> {
if let Commands::Tui {
no_alt_screen,
connect: Some(connect),
token,
token_file,
token_profile,
} = command
{
let config = AppConfig::load_for_config_inspection()?;
let token = resolve_remote_token(&config, token, token_file, token_profile)?;
let client = LocalClient::remote(config.clone(), connect, token)?;
client.handshake().await?;
return run_tui(config, no_alt_screen, Some(client)).await;
}
let config = AppConfig::load()?;
match command {
Commands::Serve { options } => serve(config, options).await,
Commands::Daemon { command } => handle_daemon_command(config, command).await,
Commands::Prompt { text, agent } => {
let agent = agent.unwrap_or_else(|| config.default_agent_id.clone());
let client = LocalClient::new(config)?;
let response = client.control_prompt(&agent, text).await?;
print_json(&serde_json::to_value(response)?)
}
Commands::Status { agent } => {
let agent = agent.unwrap_or_else(|| config.default_agent_id.clone());
let client = LocalClient::new(config)?;
print_json(&serde_json::to_value(client.agent_status(&agent).await?)?)
}
Commands::Tail { limit, agent } => {
let agent = agent.unwrap_or_else(|| config.default_agent_id.clone());
let client = LocalClient::new(config)?;
print_json(&serde_json::to_value(
client.agent_briefs(&agent, limit).await?,
)?)
}
Commands::Transcript { limit, agent } => {
let agent = agent.unwrap_or_else(|| config.default_agent_id.clone());
let client = LocalClient::new(config)?;
print_json(&serde_json::to_value(
client.agent_transcript(&agent, limit).await?,
)?)
}
Commands::Task {
summary,
cmd,
workdir,
shell,
login,
tty,
yield_time_ms,
max_output_tokens,
agent,
} => {
let agent = agent.unwrap_or_else(|| config.default_agent_id.clone());
post_control_json(
&config,
&format!("/control/agents/{agent}/tasks"),
&CreateCommandTaskRequest {
summary,
cmd,
workdir,
shell,
login,
tty: Some(tty),
yield_time_ms,
max_output_tokens,
accepts_input: Some(false),
trust: Some(TrustLevel::TrustedOperator),
},
)
.await
}
Commands::Timer {
after_ms,
every_ms,
summary,
agent,
} => {
let agent = agent.unwrap_or_else(|| config.default_agent_id.clone());
post_control_json(
&config,
&format!("/control/agents/{agent}/timers"),
&CreateTimerRequest {
duration_ms: after_ms,
interval_ms: every_ms,
summary,
trust: Some(TrustLevel::TrustedOperator),
},
)
.await
}
Commands::Control { action, agent } => {
let agent = agent.unwrap_or_else(|| config.default_agent_id.clone());
if action == ControlCommandAction::Abort {
return post_control_json(
&config,
&format!("/control/agents/{agent}/current-run/abort"),
&http::AbortCurrentRunRequest {
run_id: None,
mode: Some("stop_after_abort".into()),
trust: Some(TrustLevel::TrustedOperator),
},
)
.await;
}
post_control_json(
&config,
&format!("/control/agents/{agent}/control"),
&ControlRequest {
action: action
.as_control_action()
.expect("abort action should be handled separately"),
trust: Some(TrustLevel::TrustedOperator),
},
)
.await
}
Commands::Agent { command } => handle_agent_command(&config, command).await,
Commands::Skills { command } => handle_skills_command(&config, command).await,
Commands::Workspace { command } => handle_workspace_command(&config, command).await,
Commands::Tui {
no_alt_screen,
connect: None,
..
} => run_tui(config, no_alt_screen, None).await,
Commands::Tui {
connect: Some(_), ..
} => unreachable!("remote tui is handled before runtime config load"),
Commands::Run { .. } => unreachable!("run command is handled separately"),
Commands::Solve { .. } => unreachable!("solve command is handled separately"),
Commands::Debug { command } => handle_debug_command(config, command).await,
Commands::Config { .. } => unreachable!("config commands are handled separately"),
}
}
async fn run_one_shot(
text: String,
trust: TrustLevel,
json: bool,
agent_id: Option<String>,
create_agent: bool,
template: Option<String>,
max_turns: Option<u64>,
no_wait_for_tasks: bool,
home: Option<PathBuf>,
workspace_root: Option<PathBuf>,
cwd: Option<PathBuf>,
) -> Result<()> {
let config = AppConfig::load_with_home(home)?;
let response = run_once(
config,
RunOnceRequest {
text,
trust,
agent_id,
create_agent,
template,
max_turns,
wait_for_tasks: !no_wait_for_tasks,
workspace_root,
cwd,
},
)
.await?;
if json {
print_json(&serde_json::to_value(&response)?)?;
} else {
println!("{}", response.render_text());
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn run_solve_command(
target_ref: String,
repo: Option<String>,
base: Option<String>,
goal: Option<String>,
role: Option<String>,
agent: Option<String>,
template: Option<String>,
model: Option<String>,
max_turns: Option<u64>,
trust: TrustLevel,
json: bool,
home: Option<PathBuf>,
workspace_root: Option<PathBuf>,
cwd: Option<PathBuf>,
input_dir: Option<PathBuf>,
output_dir: Option<PathBuf>,
) -> Result<()> {
if let Some(model) = model.as_deref().filter(|model| !model.trim().is_empty()) {
std::env::set_var("HOLON_MODEL", model);
}
let config = AppConfig::load_with_home(home)?;
let response = run_solve(
config,
SolveRequest {
target_ref,
repo,
base,
goal,
role,
agent_id: agent,
template,
max_turns,
trust,
json,
workspace_root,
cwd,
input_dir,
output_dir,
},
)
.await?;
if json {
print_json(&serde_json::to_value(&response)?)?;
} else {
println!("{}", response.render_text());
}
Ok(())
}
fn apply_serve_options(config: &mut AppConfig, options: ServeOptions) -> Result<Option<String>> {
let token = resolve_inline_or_file_token(options.token, options.token_file)?;
if let Some(token) = token {
config.control_token = Some(token);
}
if options.listen.is_some() && options.port.is_some() {
return Err(anyhow!(
"use only one of --listen or --port; use --listen ADDRESS:PORT for full control or --port with --host for a port-only override"
));
}
if let Some(listen) = options.listen {
config.http_addr = listen;
} else if options.access == ServeAccess::Tunnel {
config.http_addr = loopback_with_configured_port(&config.http_addr, options.port);
} else if matches!(options.access, ServeAccess::Lan | ServeAccess::Tailnet) {
config.http_addr =
default_remote_listen_addr(options.host.as_deref(), &config.http_addr, options.port);
} else if options.port.is_some() {
config.http_addr = loopback_with_configured_port(&config.http_addr, options.port);
}
let advertise_url = match options.advertise {
Some(url) => Some(validate_client_visible_url("advertise URL", &url)?),
None => match options.access {
ServeAccess::Local | ServeAccess::Tunnel => None,
ServeAccess::Lan | ServeAccess::Tailnet => {
let Some(host) = options
.host
.as_deref()
.filter(|host| !host.trim().is_empty())
else {
return Err(anyhow!(
"--access {:?} requires --host or --advertise",
options.access
));
};
Some(format!(
"http://{}",
client_visible_host_port(host, &config.http_addr)?
))
}
},
};
if let Some(url) = &advertise_url {
config.callback_base_url = url.clone();
}
config.callback_base_url =
validate_client_visible_url("callback base URL", &config.callback_base_url)?;
let non_loopback_tcp = !config.tcp_listener_is_local();
if non_loopback_tcp
&& config
.control_token
.as_deref()
.is_none_or(|token| token.trim().is_empty())
{
return Err(anyhow!(
"non-loopback TCP listen address {} requires --token, --token-file, or HOLON_CONTROL_TOKEN",
config.http_addr
));
}
if matches!(options.access, ServeAccess::Lan | ServeAccess::Tailnet)
&& config
.control_token
.as_deref()
.is_none_or(|token| token.trim().is_empty())
{
return Err(anyhow!(
"--access {:?} requires --token, --token-file, or HOLON_CONTROL_TOKEN",
options.access
));
}
if non_loopback_tcp || matches!(options.access, ServeAccess::Lan | ServeAccess::Tailnet) {
config.control_auth_mode = ControlAuthMode::Required;
}
Ok(advertise_url)
}
fn default_remote_listen_addr(
host: Option<&str>,
current: &str,
explicit_port: Option<u16>,
) -> String {
let port = explicit_port
.or_else(|| {
current
.rsplit_once(':')
.and_then(|(_, port)| port.parse::<u16>().ok())
})
.unwrap_or(7878);
if let Some(host) = host {
if let Ok(addr) = host.parse::<SocketAddr>() {
return addr.to_string();
}
if host.parse::<std::net::IpAddr>().is_ok() {
return format!("{host}:{port}");
}
}
format!("0.0.0.0:{port}")
}
fn loopback_with_configured_port(current: &str, explicit_port: Option<u16>) -> String {
let port = explicit_port
.or_else(|| {
current
.rsplit_once(':')
.and_then(|(_, port)| port.parse::<u16>().ok())
})
.unwrap_or(7878);
format!("127.0.0.1:{port}")
}
fn client_visible_host_port(host: &str, listen: &str) -> Result<String> {
let host = host.trim();
let listen_port = listen
.rsplit_once(':')
.and_then(|(_, port)| port.parse::<u16>().ok())
.unwrap_or(7878);
let host_port = if host
.rsplit_once(':')
.is_some_and(|(_, tail)| tail.parse::<u16>().is_ok())
{
host.to_string()
} else {
format!("{host}:{listen_port}")
};
let url = validate_client_visible_url("host URL", &format!("http://{host_port}"))?;
let Some(host) = reqwest::Url::parse(&url)
.ok()
.and_then(|url| url.host_str().map(ToString::to_string))
else {
return Err(anyhow!("client-visible host must include a host"));
};
if matches!(host.as_str(), "0.0.0.0" | "::") {
return Err(anyhow!(
"0.0.0.0/:: is only valid for --listen; provide a client-reachable --host or --advertise"
));
}
Ok(url.trim_start_matches("http://").to_string())
}
fn validate_client_visible_url(label: &str, value: &str) -> Result<String> {
let normalized = normalize_control_base_url(value.to_string(), label)
.with_context(|| format!("invalid {label}"))?;
Ok(normalized)
}
fn resolve_remote_token(
config: &AppConfig,
token: Option<String>,
token_file: Option<PathBuf>,
token_profile: Option<String>,
) -> Result<String> {
let token = match (token, token_file, token_profile) {
(Some(token), None, None) => token,
(None, Some(path), None) => read_token_file(&path)?,
(None, None, Some(profile)) => read_token_profile(config, &profile)?,
(None, None, None) => {
return Err(anyhow!(
"remote TUI requires explicit --token, --token-file, or --token-profile"
))
}
_ => {
return Err(anyhow!(
"use exactly one of --token, --token-file, or --token-profile for remote TUI"
))
}
};
let token = token.trim().to_string();
if token.is_empty() {
return Err(anyhow!("remote TUI token must not be empty"));
}
Ok(token)
}
fn resolve_inline_or_file_token(
token: Option<String>,
token_file: Option<PathBuf>,
) -> Result<Option<String>> {
match (token, token_file) {
(Some(_), Some(_)) => Err(anyhow!("use only one of --token or --token-file")),
(Some(token), None) => Ok(Some(non_empty_token(token)?)),
(None, Some(path)) => Ok(Some(non_empty_token(read_token_file(&path)?)?)),
(None, None) => Ok(None),
}
}
fn read_token_file(path: &Path) -> Result<String> {
std::fs::read_to_string(path)
.with_context(|| format!("failed to read token file {}", path.display()))
}
fn read_token_profile(config: &AppConfig, profile: &str) -> Result<String> {
let profile = profile.trim();
if profile.is_empty() {
return Err(anyhow!("token profile must not be empty"));
}
let store = load_credential_store_at(&credential_store_path(&config.home_dir))?;
store
.profiles
.get(profile)
.map(|entry| entry.material.clone())
.ok_or_else(|| anyhow!("token profile {profile:?} was not found"))
}
fn non_empty_token(token: String) -> Result<String> {
let token = token.trim().to_string();
if token.is_empty() {
return Err(anyhow!("control token must not be empty"));
}
Ok(token)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DaemonServeLaunchOptions {
args: Vec<OsString>,
control_token_env: Option<String>,
}
fn serve_args_for_options(options: &ServeOptions) -> DaemonServeLaunchOptions {
let mut args = vec![
OsString::from("--access"),
OsString::from(
options
.access
.to_possible_value()
.expect("serve access should have clap value")
.get_name(),
),
];
if let Some(host) = &options.host {
args.extend([OsString::from("--host"), OsString::from(host)]);
}
if let Some(listen) = &options.listen {
args.extend([OsString::from("--listen"), OsString::from(listen)]);
}
if let Some(port) = options.port {
args.extend([OsString::from("--port"), OsString::from(port.to_string())]);
}
if let Some(advertise) = &options.advertise {
args.extend([OsString::from("--advertise"), OsString::from(advertise)]);
}
if let Some(token_file) = &options.token_file {
args.extend([
OsString::from("--token-file"),
token_file.as_os_str().to_os_string(),
]);
}
DaemonServeLaunchOptions {
args,
control_token_env: options.token.clone(),
}
}
async fn serve(mut config: AppConfig, options: ServeOptions) -> Result<()> {
let advertise_url = apply_serve_options(&mut config, options)?;
std::fs::create_dir_all(config.agent_root_dir())
.with_context(|| format!("failed to create {}", config.agent_root_dir().display()))?;
std::fs::create_dir_all(config.run_dir())
.with_context(|| format!("failed to create {}", config.run_dir().display()))?;
println!(
"{}",
apply_nofile_limit_policy(DEFAULT_NOFILE_TARGET).startup_summary()
);
ensure_serve_preflight(&config).await?;
let host = RuntimeHost::new(config.clone())?;
host.default_runtime().await?;
let runtime_service = RuntimeServiceHandle::new(&config)?;
let tcp_router = http::router(
AppState::for_tcp_with_runtime_service(host.clone(), Some(runtime_service.clone()))
.with_advertise_url(advertise_url.clone()),
);
let listener = TcpListener::bind(&config.http_addr)
.await
.with_context(|| format!("failed to bind {}", config.http_addr))?;
println!("Holon listening on {}", listener.local_addr()?);
if let Some(advertise_url) = &advertise_url {
println!("Holon advertised at {advertise_url}");
}
#[cfg(unix)]
{
ensure_socket_parent(&config.socket_path)?;
let unix_listener = tokio::net::UnixListener::bind(&config.socket_path)
.with_context(|| format!("failed to bind {}", config.socket_path.display()))?;
runtime_service.write_state_files(&config)?;
println!("Holon control socket on {}", config.socket_path.display());
let unix_router = http::router(AppState::for_unix_with_runtime_service(
host.clone(),
Some(runtime_service.clone()),
));
let tcp_server = axum::serve(listener, tcp_router)
.with_graceful_shutdown(wait_for_shutdown(runtime_service.shutdown_signal()));
let unix_server = http::serve_unix(
unix_listener,
unix_router,
runtime_service.shutdown_signal(),
);
let result = tokio::try_join!(tcp_server, unix_server)
.map(|_| ())
.context("runtime servers failed");
let _ = runtime_service.cleanup_state_files(&config);
return result;
}
#[cfg(not(unix))]
{
runtime_service.write_state_files(&config)?;
axum::serve(listener, tcp_router)
.with_graceful_shutdown(wait_for_shutdown(runtime_service.shutdown_signal()))
.await
.context("HTTP server failed")?;
let _ = runtime_service.cleanup_state_files(&config);
Ok(())
}
}
fn ensure_socket_parent(socket_path: &Path) -> Result<()> {
if let Some(parent) = socket_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
Ok(())
}
async fn wait_for_shutdown(mut shutdown: tokio::sync::watch::Receiver<bool>) {
while shutdown.changed().await.is_ok() {
if *shutdown.borrow() {
break;
}
}
}
async fn dump_prompt(
config: AppConfig,
text: String,
agent: Option<String>,
trust: TrustLevel,
) -> Result<()> {
let host = RuntimeHost::new(config.clone())?;
let agent = agent.unwrap_or_else(|| config.default_agent_id.clone());
let runtime = host.get_or_create_agent(&agent).await?;
let prompt = runtime.preview_prompt(text, trust).await?;
println!("{}", prompt.render_dump());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use holon::config::{provider_registry_for_tests, AltScreenMode, ModelRef};
fn test_config() -> AppConfig {
let home = tempfile::tempdir().unwrap().keep();
let workspace = tempfile::tempdir().unwrap().keep();
AppConfig {
default_agent_id: "default".into(),
http_addr: "127.0.0.1:7878".into(),
callback_base_url: "http://127.0.0.1:7878".into(),
home_dir: home.clone(),
data_dir: home.clone(),
socket_path: home.join("run").join("holon.sock"),
workspace_dir: workspace,
context_window_messages: 8,
context_window_briefs: 8,
compaction_trigger_messages: 10,
compaction_keep_recent_messages: 4,
prompt_budget_estimated_tokens: 16_384,
compaction_trigger_estimated_tokens: 8_192,
compaction_keep_recent_estimated_tokens: 2_048,
recent_episode_candidates: 12,
max_relevant_episodes: 3,
control_token: Some("secret".into()),
control_auth_mode: ControlAuthMode::Auto,
config_file_path: home.join("config.json"),
stored_config: Default::default(),
default_model: ModelRef::parse("anthropic/claude-sonnet-4-6").unwrap(),
fallback_models: Vec::new(),
runtime_max_output_tokens: 8192,
default_tool_output_tokens: 8_000,
max_tool_output_tokens: 64_000,
disable_provider_fallback: false,
tui_alternate_screen: AltScreenMode::Auto,
validated_model_overrides: Default::default(),
validated_unknown_model_fallback: None,
providers: provider_registry_for_tests(None, Some("dummy"), home.join(".codex")),
web_config: holon::web::WebConfig::default(),
}
}
#[test]
fn tailnet_host_is_client_visible_not_listen_socket() {
let mut config = test_config();
let advertise = apply_serve_options(
&mut config,
ServeOptions {
access: ServeAccess::Tailnet,
host: Some("lab.tailnet.ts.net".into()),
listen: None,
port: None,
advertise: None,
token: None,
token_file: None,
},
)
.unwrap();
assert_eq!(config.http_addr, "0.0.0.0:7878");
assert_eq!(advertise.as_deref(), Some("http://lab.tailnet.ts.net:7878"));
assert_eq!(config.callback_base_url, "http://lab.tailnet.ts.net:7878");
assert_eq!(config.control_auth_mode, ControlAuthMode::Required);
}
#[test]
fn lan_port_updates_listen_and_advertise_urls() {
let mut config = test_config();
let advertise = apply_serve_options(
&mut config,
ServeOptions {
access: ServeAccess::Lan,
host: Some("192.168.1.10".into()),
listen: None,
port: Some(8787),
advertise: None,
token: None,
token_file: None,
},
)
.unwrap();
assert_eq!(config.http_addr, "192.168.1.10:8787");
assert_eq!(advertise.as_deref(), Some("http://192.168.1.10:8787"));
assert_eq!(config.callback_base_url, "http://192.168.1.10:8787");
assert_eq!(config.control_auth_mode, ControlAuthMode::Required);
}
#[test]
fn daemon_start_accepts_and_forwards_serve_options() {
let cli = Cli::parse_from([
"holon",
"daemon",
"start",
"--access",
"lan",
"--host",
"192.168.1.10",
"--port",
"8787",
"--token-file",
"/tmp/holon.token",
]);
let Commands::Daemon {
command: DaemonCommands::Start { options },
} = cli.command
else {
panic!("expected daemon start command");
};
assert_eq!(options.access, ServeAccess::Lan);
let serve_launch = serve_args_for_options(&options);
assert_eq!(
serve_launch.args,
vec![
OsString::from("--access"),
OsString::from("lan"),
OsString::from("--host"),
OsString::from("192.168.1.10"),
OsString::from("--port"),
OsString::from("8787"),
OsString::from("--token-file"),
OsString::from("/tmp/holon.token"),
]
);
assert_eq!(serve_launch.control_token_env, None);
}
#[test]
fn agent_lifecycle_commands_parse_with_optional_agent_id() {
let cli = Cli::parse_from(["holon", "agent", "start"]);
let Commands::Agent {
command: Some(AgentCommands::Start { agent_id }),
} = cli.command
else {
panic!("expected agent start command");
};
assert_eq!(agent_id, None);
let cli = Cli::parse_from(["holon", "agent", "stop", "foo"]);
let Commands::Agent {
command: Some(AgentCommands::Stop { agent_id }),
} = cli.command
else {
panic!("expected agent stop command");
};
assert_eq!(agent_id.as_deref(), Some("foo"));
let cli = Cli::parse_from(["holon", "agent", "abort", "foo"]);
let Commands::Agent {
command: Some(AgentCommands::Abort { agent_id }),
} = cli.command
else {
panic!("expected agent abort command");
};
assert_eq!(agent_id.as_deref(), Some("foo"));
}
#[test]
fn agent_model_commands_use_positional_agent_id() {
let cli = Cli::parse_from(["holon", "agent", "model", "get", "foo"]);
let Commands::Agent {
command:
Some(AgentCommands::Model {
command: AgentModelCommands::Get { agent_id },
}),
} = cli.command
else {
panic!("expected agent model get command");
};
assert_eq!(agent_id.as_deref(), Some("foo"));
let cli = Cli::parse_from(["holon", "agent", "model", "set", "openai/gpt-5.1", "foo"]);
let Commands::Agent {
command:
Some(AgentCommands::Model {
command: AgentModelCommands::Set { model, agent_id },
}),
} = cli.command
else {
panic!("expected agent model set command");
};
assert_eq!(model, "openai/gpt-5.1");
assert_eq!(agent_id.as_deref(), Some("foo"));
let cli = Cli::parse_from(["holon", "agents", "list"]);
assert!(matches!(
cli.command,
Commands::Agent {
command: Some(AgentCommands::List)
}
));
}
#[test]
fn debug_scheduler_fixture_command_parses_agent_and_output() {
let cli = Cli::parse_from([
"holon",
"debug",
"scheduler-fixture",
"--agent",
"pm",
"--output",
"/tmp/scheduler-case",
]);
let Commands::Debug {
command: DebugCommands::SchedulerFixture { agent, output },
} = cli.command
else {
panic!("expected debug scheduler-fixture command");
};
assert_eq!(agent.as_deref(), Some("pm"));
assert_eq!(output, PathBuf::from("/tmp/scheduler-case"));
}
#[test]
fn export_scheduler_fixture_writes_replay_harness_shape() {
let config = test_config();
let agent_home = config.data_dir.join("agents/default");
let storage = AppStorage::new(&agent_home).unwrap();
let mut agent = holon::types::AgentState::new("default");
agent.current_work_item_id = Some("work-1".into());
storage.write_agent(&agent).unwrap();
let mut work_item = holon::types::WorkItemRecord::new(
"default",
"fixture work",
holon::types::WorkItemState::Open,
);
work_item.id = "work-1".into();
work_item.revision = 2;
storage.append_work_item(&work_item).unwrap();
let output = tempfile::tempdir().unwrap();
export_scheduler_fixture(&config, None, output.path()).unwrap();
let agent_json: serde_json::Value =
serde_json::from_str(&fs::read_to_string(output.path().join("agent.json")).unwrap())
.unwrap();
let expected_json: serde_json::Value =
serde_json::from_str(&fs::read_to_string(output.path().join("expected.json")).unwrap())
.unwrap();
assert_eq!(agent_json["current_work_item_id"].as_str(), Some("work-1"));
assert_eq!(
expected_json["current_work_item_revision"].as_u64(),
Some(2)
);
assert!(output.path().join("ledger/messages.jsonl").exists());
assert!(output.path().join("ledger/tools.jsonl").exists());
assert!(output.path().join("ledger/transcript.jsonl").exists());
}
#[test]
fn daemon_start_passes_inline_token_through_env_not_argv() {
let options = ServeOptions {
access: ServeAccess::Lan,
host: Some("192.168.1.10".into()),
listen: None,
port: None,
advertise: None,
token: Some("secret-token".into()),
token_file: None,
};
let serve_launch = serve_args_for_options(&options);
assert_eq!(
serve_launch.control_token_env.as_deref(),
Some("secret-token")
);
let token_flag = OsString::from("--token");
let token_value = OsString::from("secret-token");
assert!(!serve_launch
.args
.iter()
.any(|arg| arg == &token_flag || arg == &token_value));
}
#[test]
fn skills_install_relative_existing_path_is_absolutized_before_control_request() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("skills/demo");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), "# Demo").unwrap();
let kind = build_skill_install_kind_from_cwd("skills/demo", false, dir.path()).unwrap();
assert_eq!(
kind,
holon::types::SkillInstallKind::Local {
path: skill_dir,
mode: holon::types::SkillInstallMode::Linked,
}
);
}
#[test]
fn skills_install_unresolved_name_stays_named_request() {
let dir = tempfile::tempdir().unwrap();
let kind = build_skill_install_kind_from_cwd("ghx", true, dir.path()).unwrap();
assert_eq!(
kind,
holon::types::SkillInstallKind::Named {
name: "ghx".into(),
mode: holon::types::SkillInstallMode::Copied,
}
);
}
#[test]
fn skills_install_relative_existing_file_stays_named_request() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("ghx"), "not a skill directory").unwrap();
let kind = build_skill_install_kind_from_cwd("ghx", false, dir.path()).unwrap();
assert_eq!(
kind,
holon::types::SkillInstallKind::Named {
name: "ghx".into(),
mode: holon::types::SkillInstallMode::Linked,
}
);
}
#[test]
fn skills_install_remote_cli_builds_remote_request() {
let cli = Cli::parse_from([
"holon",
"skills",
"install",
"vercel-labs/agent-skills",
"--remote",
"--skill",
"pr-review",
]);
let Commands::Skills {
command:
SkillsCommands::Install {
name_or_path,
builtin,
remote,
skill,
copy,
agent,
},
} = cli.command
else {
panic!("expected skills install command");
};
assert_eq!(name_or_path, "vercel-labs/agent-skills");
assert!(!builtin);
assert!(remote);
assert_eq!(skill.as_deref(), Some("pr-review"));
assert!(!copy);
assert_eq!(agent, None);
}
#[test]
fn unspecified_listen_accepts_explicit_advertise_url() {
let mut config = test_config();
let advertise = apply_serve_options(
&mut config,
ServeOptions {
access: ServeAccess::Lan,
host: None,
listen: Some("0.0.0.0:7878".into()),
port: None,
advertise: Some("http://lab.example.test:7878".into()),
token: None,
token_file: None,
},
)
.unwrap();
assert_eq!(config.http_addr, "0.0.0.0:7878");
assert_eq!(advertise.as_deref(), Some("http://lab.example.test:7878"));
assert_eq!(config.callback_base_url, "http://lab.example.test:7878");
assert_eq!(config.control_auth_mode, ControlAuthMode::Required);
}
#[test]
fn callback_base_url_is_normalized_when_validated() {
let mut config = test_config();
config.callback_base_url = "http://lab.example.test:7878/#fragment".into();
let advertise = apply_serve_options(
&mut config,
ServeOptions {
access: ServeAccess::Local,
host: None,
listen: None,
port: None,
advertise: None,
token: None,
token_file: None,
},
)
.unwrap();
assert_eq!(advertise, None);
assert_eq!(config.callback_base_url, "http://lab.example.test:7878");
}
#[test]
fn latency_diagnostics_groups_turn_phase_events() {
let at = |secs: i64| {
chrono::DateTime::parse_from_rfc3339(&format!("2026-05-07T00:00:{secs:02}Z"))
.unwrap()
.with_timezone(&chrono::Utc)
};
let event = |kind: &str, created_at, data| AuditEvent {
id: uuid::Uuid::new_v4().to_string(),
event_seq: 0,
created_at,
kind: kind.into(),
data,
};
let events = vec![
event(
"message_admitted",
at(0),
serde_json::json!({ "message_id": "msg-1" }),
),
event(
"message_processing_started",
at(2),
serde_json::json!({ "id": "msg-1" }),
),
event(
"turn_started",
at(2),
serde_json::json!({
"turn_index": 42,
"message_id": "msg-1",
"run_id": "run-1",
}),
),
event(
"turn_context_built",
at(3),
serde_json::json!({ "turn_index": 42, "duration_ms": 100 }),
),
event(
"provider_round_completed",
at(4),
serde_json::json!({
"turn_index": 42,
"round": 1,
"context_build_ms": 20,
"provider_round_ms": 300,
"input_tokens": 10,
"output_tokens": 5,
"provider_attempt_timeline": {
"winning_model_ref": "anthropic/claude-sonnet-4-6",
"attempts": [{
"provider": "anthropic",
"model_ref": "anthropic/claude-sonnet-4-6",
"outcome": "succeeded"
}]
}
}),
),
event(
"tool_executed",
at(5),
serde_json::json!({
"turn_index": 42,
"tool_name": "ExecCommand",
"duration_ms": 400,
"summary": "ok"
}),
),
event(
"turn_terminal",
at(6),
serde_json::json!({
"turn_index": 42,
"kind": "completed",
"duration_ms": 1000
}),
),
];
let diagnostics = build_latency_diagnostics(&events, 10);
assert_eq!(diagnostics.len(), 1);
let turn = &diagnostics[0];
assert_eq!(turn.turn_index, 42);
assert_eq!(turn.queue_wait_ms, Some(2000));
assert_eq!(turn.context_build_ms, 120);
assert_eq!(turn.provider_context_build_ms, 20);
assert_eq!(turn.provider_round_ms, 300);
assert_eq!(turn.tool_execution_ms, 400);
assert_eq!(turn.total_ms, Some(1000));
assert_eq!(turn.runtime_cleanup_ms(), Some(280));
assert_eq!(
turn.provider_rounds[0].provider.as_deref(),
Some("anthropic")
);
assert_eq!(
turn.provider_rounds[0].model_ref.as_deref(),
Some("anthropic/claude-sonnet-4-6")
);
}
}
async fn handle_debug_command(config: AppConfig, command: DebugCommands) -> Result<()> {
match command {
DebugCommands::Prompt { text, agent, trust } => {
dump_prompt(config, text, agent, trust).await
}
DebugCommands::Latency {
agent,
limit,
events_limit,
} => print_latency_diagnostics(&config, agent, limit, events_limit),
DebugCommands::SchedulerFixture { agent, output } => {
export_scheduler_fixture(&config, agent, &output)
}
}
}
fn export_scheduler_fixture(
config: &AppConfig,
agent: Option<String>,
output: &Path,
) -> Result<()> {
let agent_id = agent.unwrap_or_else(|| config.default_agent_id.clone());
let agent_home = config.data_dir.join("agents").join(&agent_id);
let storage = AppStorage::new(&agent_home)?;
let agent = storage.read_agent()?.with_context(|| {
format!(
"agent state not found for {agent_id} in {}",
agent_home.display()
)
})?;
let ledger_dir = output.join("ledger");
fs::create_dir_all(&ledger_dir)
.with_context(|| format!("failed to create {}", ledger_dir.display()))?;
let pending_wake_hint_reason = agent
.pending_wake_hint
.as_ref()
.map(|pending| pending.reason.clone());
let last_turn_terminal_kind = agent
.last_turn_terminal
.as_ref()
.map(|terminal| terminal.kind.clone());
write_json_pretty(
&output.join("agent.json"),
&serde_json::json!({
"current_work_item_id": agent.current_work_item_id.clone(),
"pending_wake_hint_reason": pending_wake_hint_reason,
"turn_index": agent.turn_index,
"last_turn_terminal_kind": last_turn_terminal_kind,
}),
)?;
let work_queue = storage.work_queue_prompt_projection()?;
let active_tasks = storage.latest_active_task_records_for_agent(&agent.id, usize::MAX)?;
let has_blocking_active_tasks = active_tasks.iter().any(|task| task.is_blocking());
let active_waiting_intents = storage
.latest_waiting_intents()?
.into_iter()
.filter(|intent| {
intent.agent_id == agent.id && intent.status == WaitingIntentStatus::Active
})
.collect::<Vec<_>>();
let active_timers = storage
.latest_timer_records()?
.into_iter()
.filter(|timer| timer.agent_id == agent.id && timer.status == TimerStatus::Active)
.collect::<Vec<_>>();
let replay_message_ids = storage
.recovery_snapshot()?
.replay_messages
.into_iter()
.map(|message| message.id)
.collect::<Vec<_>>();
write_json_pretty(
&output.join("expected.json"),
&serde_json::json!({
"current_work_item_id": work_queue.current.as_ref().map(|item| item.id.clone()),
"current_work_item_revision": work_queue.current.as_ref().map(|item| item.revision),
"queued_work_items": work_queue.queued_runnable.len(),
"triggered_blocked_work_items": work_queue.triggered_blocked.len(),
"waiting_for_operator_work_items": work_queue.waiting_for_operator.len(),
"blocked_work_items": work_queue.blocked.len(),
"completed_recent_work_items": work_queue.completed_recent.len(),
"active_tasks": active_tasks.len(),
"has_blocking_active_tasks": has_blocking_active_tasks,
"pending_wake_hint": agent.pending_wake_hint.is_some(),
"active_waiting_intents": active_waiting_intents.len(),
"active_work_item_waiting_intents": active_waiting_intents.iter().filter(|intent| matches!(intent.scope, holon::types::ExternalTriggerScope::WorkItem)).count(),
"active_agent_waiting_intents": active_waiting_intents.iter().filter(|intent| matches!(intent.scope, holon::types::ExternalTriggerScope::Agent)).count(),
"active_timers": active_timers.len(),
"runtime_error": storage.read_recent_events(128)?.iter().any(|event| event.kind == "runtime_error"),
"replay_message_ids": replay_message_ids,
"turn_terminal_kind": last_turn_terminal_kind,
}),
)?;
write_jsonl(
&ledger_dir.join("messages.jsonl"),
&storage.read_recent_messages(usize::MAX)?,
)?;
write_jsonl(
&ledger_dir.join("queue_entries.jsonl"),
&storage.read_recent_queue_entries(usize::MAX)?,
)?;
write_jsonl(
&ledger_dir.join("events.jsonl"),
&storage.read_recent_events(usize::MAX)?,
)?;
write_jsonl(
&ledger_dir.join("tasks.jsonl"),
&storage.read_recent_tasks(usize::MAX)?,
)?;
write_jsonl(
&ledger_dir.join("work_items.jsonl"),
&storage.read_recent_work_items(usize::MAX)?,
)?;
write_jsonl(
&ledger_dir.join("waiting_intents.jsonl"),
&storage.read_recent_waiting_intents(usize::MAX)?,
)?;
write_jsonl(
&ledger_dir.join("timers.jsonl"),
&storage.read_recent_timers(usize::MAX)?,
)?;
write_jsonl(
&ledger_dir.join("tools.jsonl"),
&storage.read_recent_tool_executions(usize::MAX)?,
)?;
write_jsonl(
&ledger_dir.join("briefs.jsonl"),
&storage.read_recent_briefs(usize::MAX)?,
)?;
write_jsonl(
&ledger_dir.join("transcript.jsonl"),
&storage.read_recent_transcript(usize::MAX)?,
)?;
write_jsonl(
&ledger_dir.join("external_triggers.jsonl"),
&storage.read_recent_external_triggers(usize::MAX)?,
)?;
println!(
"Exported scheduler fixture for agent {agent_id} to {}",
output.display()
);
Ok(())
}
fn write_json_pretty(path: &Path, value: &serde_json::Value) -> Result<()> {
let content = serde_json::to_vec_pretty(value)?;
fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))
}
fn write_jsonl<T: serde::Serialize>(path: &Path, values: &[T]) -> Result<()> {
let mut content = Vec::new();
for value in values {
serde_json::to_writer(&mut content, value)?;
content.push(b'\n');
}
fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))
}
#[derive(Debug, Default)]
struct TurnLatencyDiagnostics {
turn_index: u64,
run_id: Option<String>,
message_id: Option<String>,
terminal_kind: Option<String>,
total_ms: Option<u64>,
queue_wait_ms: Option<u64>,
context_build_ms: u64,
provider_context_build_ms: u64,
provider_round_ms: u64,
tool_execution_ms: u64,
provider_rounds: Vec<ProviderRoundLatencyDiagnostics>,
tools: Vec<ToolLatencyDiagnostics>,
}
impl TurnLatencyDiagnostics {
fn runtime_cleanup_ms(&self) -> Option<u64> {
self.total_ms.map(|total| {
total.saturating_sub(
self.provider_context_build_ms
.saturating_add(self.provider_round_ms)
.saturating_add(self.tool_execution_ms),
)
})
}
}
#[derive(Debug)]
struct ProviderRoundLatencyDiagnostics {
round: u64,
duration_ms: u64,
provider: Option<String>,
model_ref: Option<String>,
input_tokens: Option<u64>,
output_tokens: Option<u64>,
}
#[derive(Debug)]
struct ToolLatencyDiagnostics {
tool_name: String,
duration_ms: u64,
summary: Option<String>,
}
fn print_latency_diagnostics(
config: &AppConfig,
agent: Option<String>,
limit: usize,
events_limit: usize,
) -> Result<()> {
let agent = agent.unwrap_or_else(|| config.default_agent_id.clone());
let agent_home = config.data_dir.join("agents").join(&agent);
let storage = AppStorage::new(&agent_home)?;
let events = storage.read_recent_events(events_limit)?;
let diagnostics = build_latency_diagnostics(&events, limit);
if diagnostics.is_empty() {
println!(
"No turn latency data found for agent {agent} in {}",
agent_home.join(".holon/ledger/events.jsonl").display()
);
return Ok(());
}
for turn in diagnostics {
let total = turn
.total_ms
.map(format_duration_ms)
.unwrap_or_else(|| "unknown".into());
let run = turn.run_id.as_deref().unwrap_or("unknown");
let kind = turn.terminal_kind.as_deref().unwrap_or("unknown");
println!(
"turn {} run={} kind={} runtime_total={}",
turn.turn_index, run, kind, total
);
println!(
" queue_wait {}",
turn.queue_wait_ms
.map(format_duration_ms)
.unwrap_or_else(|| "unknown".into())
);
println!(
" context_build {}",
format_duration_ms(turn.context_build_ms)
);
for provider_round in &turn.provider_rounds {
let provider = provider_round.provider.as_deref().unwrap_or("provider");
let model = provider_round.model_ref.as_deref().unwrap_or("model");
let input = provider_round
.input_tokens
.map(|value| value.to_string())
.unwrap_or_else(|| "?".into());
let output = provider_round
.output_tokens
.map(|value| value.to_string())
.unwrap_or_else(|| "?".into());
println!(
" provider round {:<2} {} {}/{} input={} output={}",
provider_round.round,
format_duration_ms(provider_round.duration_ms),
provider,
model,
input,
output
);
}
for tool in &turn.tools {
let summary = tool
.summary
.as_deref()
.filter(|summary| !summary.trim().is_empty())
.map(|summary| format!(" {}", summary.trim()))
.unwrap_or_default();
println!(
" tool {:<14} {}{}",
tool.tool_name,
format_duration_ms(tool.duration_ms),
summary
);
}
println!(
" tool_execution {}",
format_duration_ms(turn.tool_execution_ms)
);
println!(
" turn_cleanup {}",
turn.runtime_cleanup_ms()
.map(format_duration_ms)
.unwrap_or_else(|| "unknown".into())
);
}
Ok(())
}
fn build_latency_diagnostics(events: &[AuditEvent], limit: usize) -> Vec<TurnLatencyDiagnostics> {
let mut turns = BTreeMap::<u64, TurnLatencyDiagnostics>::new();
let mut admitted_at_by_message = BTreeMap::<String, chrono::DateTime<chrono::Utc>>::new();
let mut processing_at_by_message = BTreeMap::<String, chrono::DateTime<chrono::Utc>>::new();
for event in events {
match event.kind.as_str() {
"message_admitted" => {
if let Some(message_id) = event
.data
.get("message_id")
.and_then(|value| value.as_str())
{
admitted_at_by_message.insert(message_id.to_string(), event.created_at);
}
}
"message_processing_started" => {
if let Some(message_id) = event.data.get("id").and_then(|value| value.as_str()) {
processing_at_by_message.insert(message_id.to_string(), event.created_at);
}
}
"turn_started" => {
if let Some(turn_index) = event
.data
.get("turn_index")
.and_then(|value| value.as_u64())
{
let turn = turns
.entry(turn_index)
.or_insert_with(|| TurnLatencyDiagnostics {
turn_index,
..TurnLatencyDiagnostics::default()
});
turn.message_id = event
.data
.get("message_id")
.and_then(|value| value.as_str())
.map(ToString::to_string);
turn.run_id = event
.data
.get("run_id")
.and_then(|value| value.as_str())
.map(ToString::to_string);
}
}
"turn_context_built" => {
if let Some(turn_index) = event
.data
.get("turn_index")
.and_then(|value| value.as_u64())
{
let turn = turns
.entry(turn_index)
.or_insert_with(|| TurnLatencyDiagnostics {
turn_index,
..TurnLatencyDiagnostics::default()
});
turn.context_build_ms = turn.context_build_ms.saturating_add(
event
.data
.get("duration_ms")
.and_then(|value| value.as_u64())
.unwrap_or(0),
);
if turn.run_id.is_none() {
turn.run_id = event
.data
.get("run_id")
.and_then(|value| value.as_str())
.map(ToString::to_string);
}
}
}
"provider_round_completed" => {
if let Some(turn_index) = event
.data
.get("turn_index")
.and_then(|value| value.as_u64())
{
let duration_ms = event
.data
.get("provider_round_ms")
.or_else(|| event.data.get("duration_ms"))
.and_then(|value| value.as_u64())
.unwrap_or(0);
let turn = turns
.entry(turn_index)
.or_insert_with(|| TurnLatencyDiagnostics {
turn_index,
..TurnLatencyDiagnostics::default()
});
turn.provider_round_ms = turn.provider_round_ms.saturating_add(duration_ms);
let context_build_ms = event
.data
.get("context_build_ms")
.and_then(|value| value.as_u64())
.unwrap_or(0);
turn.context_build_ms = turn.context_build_ms.saturating_add(context_build_ms);
turn.provider_context_build_ms = turn
.provider_context_build_ms
.saturating_add(context_build_ms);
if turn.run_id.is_none() {
turn.run_id = event
.data
.get("run_id")
.and_then(|value| value.as_str())
.map(ToString::to_string);
}
let timeline = event.data.get("provider_attempt_timeline");
let winning = timeline
.and_then(|value| value.get("winning_model_ref"))
.and_then(|value| value.as_str());
let attempt = timeline
.and_then(|value| value.get("attempts"))
.and_then(|value| value.as_array())
.and_then(|attempts| {
attempts
.iter()
.rev()
.find(|attempt| {
attempt.get("outcome").and_then(|value| value.as_str())
== Some("succeeded")
})
.or_else(|| attempts.last())
});
turn.provider_rounds.push(ProviderRoundLatencyDiagnostics {
round: event
.data
.get("round")
.and_then(|value| value.as_u64())
.unwrap_or(0),
duration_ms,
provider: attempt
.and_then(|attempt| attempt.get("provider"))
.and_then(|value| value.as_str())
.map(ToString::to_string),
model_ref: winning
.or_else(|| {
attempt
.and_then(|attempt| attempt.get("model_ref"))
.and_then(|value| value.as_str())
})
.map(ToString::to_string),
input_tokens: event
.data
.get("input_tokens")
.and_then(|value| value.as_u64()),
output_tokens: event
.data
.get("output_tokens")
.and_then(|value| value.as_u64()),
});
}
}
"tool_executed" => {
if let Some(turn_index) = event
.data
.get("turn_index")
.and_then(|value| value.as_u64())
{
let duration_ms = event
.data
.get("duration_ms")
.and_then(|value| value.as_u64())
.unwrap_or(0);
let tool_name = event
.data
.get("tool_name")
.and_then(|value| value.as_str())
.unwrap_or("tool")
.to_string();
let turn = turns
.entry(turn_index)
.or_insert_with(|| TurnLatencyDiagnostics {
turn_index,
..TurnLatencyDiagnostics::default()
});
turn.tool_execution_ms = turn.tool_execution_ms.saturating_add(duration_ms);
turn.tools.push(ToolLatencyDiagnostics {
tool_name,
duration_ms,
summary: event
.data
.get("summary")
.and_then(|value| value.as_str())
.map(ToString::to_string),
});
}
}
"turn_terminal" => {
if let Some(turn_index) = event
.data
.get("turn_index")
.and_then(|value| value.as_u64())
{
let turn = turns
.entry(turn_index)
.or_insert_with(|| TurnLatencyDiagnostics {
turn_index,
..TurnLatencyDiagnostics::default()
});
turn.total_ms = event
.data
.get("duration_ms")
.and_then(|value| value.as_u64());
turn.terminal_kind = event
.data
.get("kind")
.and_then(|value| value.as_str())
.map(ToString::to_string);
}
}
_ => {}
}
}
for turn in turns.values_mut() {
if let Some(message_id) = turn.message_id.as_ref() {
if let (Some(admitted_at), Some(processing_at)) = (
admitted_at_by_message.get(message_id),
processing_at_by_message.get(message_id),
) {
turn.queue_wait_ms = processing_at
.signed_duration_since(*admitted_at)
.num_milliseconds()
.try_into()
.ok();
}
}
}
let mut values = turns.into_values().collect::<Vec<_>>();
values.sort_by_key(|turn| turn.turn_index);
values.into_iter().rev().take(limit).collect()
}
fn format_duration_ms(duration_ms: u64) -> String {
if duration_ms >= 1000 {
format!("{:.1}s", duration_ms as f64 / 1000.0)
} else {
format!("{duration_ms}ms")
}
}
async fn handle_daemon_command(config: AppConfig, command: DaemonCommands) -> Result<()> {
let value = match command {
DaemonCommands::Start { options } => {
let mut config = config;
let serve_launch = serve_args_for_options(&options);
apply_serve_options(&mut config, options)?;
serde_json::to_value(
daemon_start(
&config,
&serve_launch.args,
serve_launch.control_token_env.as_deref(),
)
.await?,
)?
}
DaemonCommands::Stop => serde_json::to_value(daemon_stop(&config).await?)?,
DaemonCommands::Status => serde_json::to_value(daemon_status(&config).await?)?,
DaemonCommands::Restart { options } => {
let mut config = config;
let serve_launch = serve_args_for_options(&options);
apply_serve_options(&mut config, options)?;
serde_json::to_value(
daemon_restart(
&config,
&serve_launch.args,
serve_launch.control_token_env.as_deref(),
)
.await?,
)?
}
DaemonCommands::Logs { tail } => serde_json::to_value(daemon_logs(&config, tail)?)?,
};
print_json(&value)
}
async fn handle_workspace_command(config: &AppConfig, command: WorkspaceCommands) -> Result<()> {
let client = LocalClient::new(config.clone())?;
match command {
WorkspaceCommands::Attach { agent, path } => {
let agent = agent.unwrap_or_else(|| config.default_agent_id.clone());
print_json(&serde_json::to_value(
client
.attach_workspace(&agent, path.display().to_string())
.await?,
)?)
}
WorkspaceCommands::Exit { agent } => {
let agent = agent.unwrap_or_else(|| config.default_agent_id.clone());
print_json(&serde_json::to_value(client.exit_workspace(&agent).await?)?)
}
WorkspaceCommands::Detach {
agent,
workspace_id,
} => {
let agent = agent.unwrap_or_else(|| config.default_agent_id.clone());
print_json(&serde_json::to_value(
client.detach_workspace(&agent, workspace_id).await?,
)?)
}
}
}
async fn handle_agent_command(config: &AppConfig, command: Option<AgentCommands>) -> Result<()> {
match command {
None | Some(AgentCommands::List) => {
let client = LocalClient::new(config.clone())?;
print_json(&serde_json::to_value(client.list_agent_entries().await?)?)
}
Some(AgentCommands::Status { agent_id }) => {
let agent = agent_id.unwrap_or_else(|| config.default_agent_id.clone());
let client = LocalClient::new(config.clone())?;
print_json(&serde_json::to_value(client.agent_status(&agent).await?)?)
}
Some(AgentCommands::Create { agent_id, template }) => {
post_control_json(
config,
&format!("/control/agents/{agent_id}/create"),
&http::CreateAgentRequest {
trust: Some(TrustLevel::TrustedOperator),
template,
},
)
.await
}
Some(AgentCommands::Start { agent_id }) => {
control_agent_lifecycle(config, agent_id, ControlAction::Start).await
}
Some(AgentCommands::Stop { agent_id }) => {
control_agent_lifecycle(config, agent_id, ControlAction::Stop).await
}
Some(AgentCommands::Abort { agent_id }) => {
let agent = agent_id.unwrap_or_else(|| config.default_agent_id.clone());
post_control_json(
config,
&format!("/control/agents/{agent}/current-run/abort"),
&http::AbortCurrentRunRequest {
run_id: None,
mode: Some("stop_after_abort".into()),
trust: Some(TrustLevel::TrustedOperator),
},
)
.await
}
Some(AgentCommands::Model { command }) => {
let client = LocalClient::new(config.clone())?;
match command {
AgentModelCommands::Get { agent_id } => {
let agent = agent_id.unwrap_or_else(|| config.default_agent_id.clone());
let summary = client.agent_status(&agent).await?;
print_json(&serde_json::to_value(summary.model)?)
}
AgentModelCommands::Set { model, agent_id } => {
let agent = agent_id.unwrap_or_else(|| config.default_agent_id.clone());
print_json(&client.set_agent_model_override(&agent, model, None).await?)
}
AgentModelCommands::Clear { agent_id } => {
let agent = agent_id.unwrap_or_else(|| config.default_agent_id.clone());
print_json(&client.clear_agent_model_override(&agent).await?)
}
}
}
}
}
async fn control_agent_lifecycle(
config: &AppConfig,
agent_id: Option<String>,
action: ControlAction,
) -> Result<()> {
let agent = agent_id.unwrap_or_else(|| config.default_agent_id.clone());
let client = LocalClient::new(config.clone())?;
print_json(&client.control_agent(&agent, action).await?)
}
async fn handle_skills_command(config: &AppConfig, command: SkillsCommands) -> Result<()> {
match command {
SkillsCommands::List { agent } => {
let agent = agent.unwrap_or_else(|| config.default_agent_id.clone());
let response: serde_json::Value =
get_json(config, &format!("/agents/{agent}/skills")).await?;
print_json(&response)
}
SkillsCommands::Install {
name_or_path,
builtin,
remote,
skill,
copy,
agent,
} => {
let agent = agent.unwrap_or_else(|| config.default_agent_id.clone());
let kind = if builtin {
if remote || skill.is_some() {
anyhow::bail!("--builtin cannot be combined with --remote or --skill");
}
holon::types::SkillInstallKind::Builtin { name: name_or_path }
} else if remote {
let mode = if copy {
holon::types::SkillInstallMode::Copied
} else {
holon::types::SkillInstallMode::Linked
};
holon::types::SkillInstallKind::Remote {
package: name_or_path,
skill,
mode,
}
} else {
if skill.is_some() {
anyhow::bail!("--skill requires --remote");
}
build_skill_install_kind(&name_or_path, copy)?
};
post_control_json(
config,
&format!("/control/agents/{agent}/skills/install"),
&holon::types::InstallSkillRequest { kind },
)
.await
}
SkillsCommands::Uninstall { name, agent } => {
let agent = agent.unwrap_or_else(|| config.default_agent_id.clone());
post_control_json(
config,
&format!("/control/agents/{agent}/skills/uninstall"),
&holon::types::UninstallSkillRequest { name },
)
.await
}
}
}
fn build_skill_install_kind(
name_or_path: &str,
copy: bool,
) -> Result<holon::types::SkillInstallKind> {
let cwd = std::env::current_dir().context("failed to resolve current directory")?;
build_skill_install_kind_from_cwd(name_or_path, copy, &cwd)
}
fn build_skill_install_kind_from_cwd(
name_or_path: &str,
copy: bool,
cwd: &Path,
) -> Result<holon::types::SkillInstallKind> {
let mode = if copy {
holon::types::SkillInstallMode::Copied
} else {
holon::types::SkillInstallMode::Linked
};
let path = PathBuf::from(name_or_path);
if path.is_absolute() {
return Ok(holon::types::SkillInstallKind::Local { path, mode });
}
let resolved = cwd.join(&path);
if resolved.is_dir() {
Ok(holon::types::SkillInstallKind::Local {
path: resolved,
mode,
})
} else {
Ok(holon::types::SkillInstallKind::Named {
name: name_or_path.into(),
mode,
})
}
}
async fn post_control_json<T: serde::Serialize>(
config: &AppConfig,
path: &str,
payload: &T,
) -> Result<()> {
post_json_with_auth(config, path, payload, true).await
}
async fn get_json(config: &AppConfig, path: &str) -> Result<serde_json::Value> {
let request = reqwest::Client::new().get(format!("http://{}{}", config.http_addr, path));
let response = request.send().await.context("HTTP request failed")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("GET {} returned {}: {}", path, status, body);
}
let body = response
.text()
.await
.context("failed to read response body")?;
serde_json::from_str(&body).context("failed to parse JSON response")
}
async fn post_json_with_auth<T: serde::Serialize>(
config: &AppConfig,
path: &str,
payload: &T,
include_control_auth: bool,
) -> Result<()> {
let mut request = reqwest::Client::new()
.post(format!("http://{}{}", config.http_addr, path))
.json(payload);
if include_control_auth {
if let Some(token) = &config.control_token {
request = request.bearer_auth(token);
}
}
let response = request
.send()
.await
.with_context(|| format!("failed to post {}", path))?;
let body = response.text().await?;
println!("{body}");
Ok(())
}
async fn handle_config_command(command: ConfigCommands) -> Result<()> {
match command {
ConfigCommands::Get { key } => {
let path = config_file_path();
let config = load_persisted_config_at(&path)?;
print_json(&get_config_key(&config, &key)?)
}
ConfigCommands::Set { key, value } => {
let path = config_file_path();
let mut config = load_persisted_config_at(&path)?;
set_config_key(&mut config, &key, &value)?;
save_persisted_config_at(&path, &config)?;
print_json(&get_config_key(&config, &key)?)
}
ConfigCommands::Unset { key } => {
let path = config_file_path();
let mut config = load_persisted_config_at(&path)?;
unset_config_key(&mut config, &key)?;
save_persisted_config_at(&path, &config)?;
print_json(&serde_json::json!({
"key": key,
"status": "unset"
}))
}
ConfigCommands::Providers { command } => handle_config_providers_command(command).await,
ConfigCommands::Credentials { command } => handle_config_credentials_command(command).await,
ConfigCommands::Models { command } => handle_config_models_command(command).await,
ConfigCommands::List => {
let path = config_file_path();
let config = load_persisted_config_at(&path)?;
print_json(&serde_json::to_value(config)?)
}
ConfigCommands::Schema => print_json(&serde_json::to_value(config_schema())?),
ConfigCommands::Doctor => {
let config = AppConfig::load_for_config_inspection()?;
print_json(&provider_doctor(&config))
}
}
}
async fn handle_config_models_command(command: ConfigModelCommands) -> Result<()> {
match command {
ConfigModelCommands::List => {
let config = AppConfig::load_for_config_inspection()?;
print_json(&serde_json::to_value(resolved_model_availability(&config))?)
}
}
}
async fn handle_config_providers_command(command: ConfigProviderCommands) -> Result<()> {
match command {
ConfigProviderCommands::Set {
provider,
transport,
base_url,
credential_source,
credential_kind,
credential_env,
credential_profile,
credential_external,
} => {
let id = ProviderId::parse(&provider)?;
let built_in_defaults = built_in_provider_default_config(&id)?;
let transport = match (transport, built_in_defaults.as_ref()) {
(Some(raw), Some(defaults)) => {
let parsed = ProviderTransportKind::parse(&raw)?;
let id_str = id.as_str();
if (id_str.ends_with("-anthropic") || id_str.ends_with("-openai"))
&& parsed != defaults.transport
{
return Err(anyhow!(
"provider {} has fixed transport {}; remove --transport or set it to {}",
id_str,
defaults.transport.as_str(),
defaults.transport.as_str()
));
}
parsed
}
(Some(raw), None) => ProviderTransportKind::parse(&raw)?,
(None, Some(defaults)) => defaults.transport,
(None, None) => {
return Err(anyhow!(
"provider {} requires --transport when no built-in default exists",
id.as_str()
));
}
};
let base_url = match (base_url, built_in_defaults.as_ref()) {
(Some(value), _) => value,
(None, Some(defaults)) => defaults.base_url.clone(),
(None, None) => {
return Err(anyhow!(
"provider {} requires --base-url when no built-in default exists",
id.as_str()
));
}
};
let provider_config = ProviderConfigFile {
transport,
base_url,
auth: ProviderAuthConfig {
source: CredentialSource::parse(&credential_source)?,
kind: CredentialKind::parse(&credential_kind)?,
env: credential_env,
profile: credential_profile.map(|value| value.trim().to_string()),
external: credential_external,
},
reasoning_effort: None,
builtin_web_search: None,
};
validate_provider_config(&id, &provider_config)?;
let path = config_file_path();
let mut config = load_persisted_config_at(&path)?;
config.providers.insert(id.clone(), provider_config);
save_persisted_config_at(&path, &config)?;
let effective = AppConfig::load_for_config_inspection()?;
let provider = effective.providers.get(&id).with_context(|| {
format!("provider {} was saved but did not resolve", id.as_str())
})?;
print_json(&serde_json::json!({
"applied_via": "offline_store",
"provider": provider_config_view(&effective, provider),
}))
}
ConfigProviderCommands::Get { provider } => {
let id = ProviderId::parse(&provider)?;
let config = AppConfig::load_for_config_inspection()?;
let provider = config
.providers
.get(&id)
.with_context(|| format!("unknown provider {}", id.as_str()))?;
print_json(&serde_json::to_value(provider_config_view(
&config, provider,
))?)
}
ConfigProviderCommands::List => {
let config = AppConfig::load_for_config_inspection()?;
print_json(&serde_json::to_value(provider_config_views(&config))?)
}
ConfigProviderCommands::Remove { provider } => {
let id = ProviderId::parse(&provider)?;
let path = config_file_path();
let mut config = load_persisted_config_at(&path)?;
let removed = config.providers.remove(&id).is_some();
save_persisted_config_at(&path, &config)?;
print_json(&serde_json::json!({
"applied_via": "offline_store",
"provider": id.as_str(),
"status": if removed { "removed" } else { "not_configured" },
}))
}
ConfigProviderCommands::Doctor { provider } => {
let id = ProviderId::parse(&provider)?;
let config = AppConfig::load_for_config_inspection()?;
let provider_cfg = config
.providers
.get(&id)
.with_context(|| format!("unknown provider {}", id.as_str()))?;
let doctor = provider_doctor(&config);
let chain_entries = doctor["providers"]
.as_array()
.map(|entries| {
entries
.iter()
.filter(|entry| entry["provider"].as_str() == Some(id.as_str()))
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default();
print_json(&serde_json::json!({
"provider": provider_config_view(&config, provider_cfg),
"model_chain_diagnostics": chain_entries,
}))
}
}
}
async fn handle_config_credentials_command(command: ConfigCredentialCommands) -> Result<()> {
let path = credentials_file_path();
match command {
ConfigCredentialCommands::Set {
profile,
kind,
stdin,
material,
} => {
if stdin && material.is_some() {
anyhow::bail!("--stdin cannot be used together with --material");
}
let mut material = if let Some(value) = material {
value
} else if stdin {
eprint!("Enter credential material for {profile} and press Enter: ");
std::io::stderr()
.flush()
.context("failed to flush credential prompt to stderr")?;
let mut value = String::new();
std::io::stdin()
.read_line(&mut value)
.context("failed to read credential material from stdin")?;
value
} else {
anyhow::bail!("credential set requires either --stdin or --material");
};
trim_trailing_newlines(&mut material);
let status = set_credential_profile_at(
&path,
&profile,
CredentialKind::parse(&kind)?,
material,
)?;
print_json(&serde_json::json!({
"applied_via": "offline_store",
"credential": status,
}))
}
ConfigCredentialCommands::List => {
let profiles = list_credential_profiles_at(&path)?;
print_json(&serde_json::to_value(profiles)?)
}
ConfigCredentialCommands::Remove { profile } => {
let status = remove_credential_profile_at(&path, &profile)?;
print_json(&serde_json::json!({
"applied_via": "offline_store",
"credential": status,
}))
}
}
}
fn config_file_path() -> std::path::PathBuf {
let home_dir = std::env::var("HOLON_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| default_holon_home());
persisted_config_path(&home_dir)
}
fn credentials_file_path() -> std::path::PathBuf {
let home_dir = std::env::var("HOLON_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| default_holon_home());
credential_store_path(&home_dir)
}
fn trim_trailing_newlines(value: &mut String) {
while value.ends_with('\n') || value.ends_with('\r') {
value.pop();
}
}
fn print_json(value: &serde_json::Value) -> Result<()> {
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}
fn init_tracing() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
let _ = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.try_init();
}