use std::io::Write;
use std::path::PathBuf;
#[cfg(unix)]
use std::time::Duration;
use clap::Parser;
#[cfg(unix)]
use winx_code_agent::daemon::{default_socket_path, DaemonClient};
#[cfg(unix)]
use winx_code_agent::runtime::{configured_daemon_binary, restart_control_daemon_at};
use winx_code_agent::{start_winx_server, Result, WinxError};
#[derive(Parser)]
#[command(name = "winx-code-agent")]
#[command(author = "Gabriel Maia")]
#[command(version = env!("WINX_BUILD_IDENTITY"))]
#[command(
about = "Remote-first MCP runtime with Streamable HTTP, durable PTYs, and guarded shell/file tools",
long_about = None
)]
struct Cli {
#[arg(short, long)]
verbose: bool,
#[arg(long)]
debug: bool,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(clap::Subcommand)]
enum Commands {
Serve {
#[arg(long)]
http: bool,
#[arg(long, default_value = "127.0.0.1:8000")]
bind: String,
#[arg(long, conflicts_with_all = ["token_file", "principal_config"])]
token: Option<String>,
#[arg(long, value_name = "PATH", conflicts_with_all = ["token", "principal_config"])]
token_file: Option<PathBuf>,
#[arg(long, value_name = "PATH", conflicts_with_all = ["token", "token_file"])]
principal_config: Option<PathBuf>,
#[arg(
long,
value_enum,
default_value_t = winx_code_agent::tool_policy::ToolProfile::Full,
conflicts_with = "principal_config"
)]
tool_profile: winx_code_agent::tool_policy::ToolProfile,
#[arg(long = "allow-tool", value_name = "NAME", conflicts_with = "principal_config")]
allowed_tools: Vec<String>,
#[arg(long)]
allow_weak_token: bool,
#[arg(long)]
allow_non_loopback: bool,
#[arg(
long,
value_enum,
default_value_t = winx_code_agent::config::HttpSessionAffinity::Workspace
)]
session_affinity: winx_code_agent::config::HttpSessionAffinity,
#[arg(
long,
default_value_t = winx_code_agent::http_server::DEFAULT_MAX_CONCURRENCY
)]
max_concurrency: usize,
#[arg(
long,
default_value_t = winx_code_agent::http_server::DEFAULT_REQUESTS_PER_MINUTE
)]
requests_per_minute: u32,
#[arg(long = "allowed-host")]
allowed_host: Vec<String>,
#[arg(long)]
allow_query_token: bool,
},
#[cfg(unix)]
List {
#[arg(long)]
socket: Option<PathBuf>,
},
#[cfg(unix)]
Attach {
thread_id: String,
#[arg(long, default_value = "cli")]
consumer: String,
#[arg(long)]
follow: bool,
#[arg(long)]
socket: Option<PathBuf>,
},
#[cfg(unix)]
Kill {
#[arg(required_unless_present = "all")]
thread_id: Option<String>,
#[arg(long, conflicts_with = "thread_id")]
all: bool,
#[arg(long)]
socket: Option<PathBuf>,
},
#[cfg(unix)]
Prune {
#[arg(long)]
idle_seconds: Option<u64>,
#[arg(long)]
socket: Option<PathBuf>,
},
#[cfg(unix)]
RestartDaemon {
#[arg(long)]
socket: Option<PathBuf>,
},
Doctor,
Report {
#[arg(long, value_name = "PATH")]
log: Option<PathBuf>,
#[arg(long, default_value_t = 10_000)]
last: usize,
#[arg(long, default_value_t = 0)]
since_minutes: u64,
#[arg(long)]
build: Option<String>,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
let offline_report = matches!(&cli.command, Some(Commands::Report { .. }));
let sandbox_report = winx_code_agent::sandbox::apply_if_requested();
let _logging_guard = if offline_report {
winx_code_agent::logging::initialize_without_usage(cli.verbose, cli.debug)?
} else {
winx_code_agent::logging::initialize(cli.verbose, cli.debug)?
};
sandbox_report.emit();
tokio::runtime::Runtime::new()
.map_err(|e| {
WinxError::ShellInitializationError(format!("failed to start tokio runtime: {e}"))
})?
.block_on(async_main(cli))
}
async fn async_main(cli: Cli) -> Result<()> {
match cli.command {
Some(Commands::Serve {
http: true,
bind,
token,
token_file,
principal_config,
tool_profile,
allowed_tools,
allow_weak_token,
allow_non_loopback,
session_affinity,
max_concurrency,
requests_per_minute,
allowed_host,
allow_query_token,
}) => {
run_http_server(
bind,
token,
token_file,
principal_config,
tool_profile,
allowed_tools,
allow_weak_token,
allow_non_loopback,
session_affinity,
max_concurrency,
requests_per_minute,
allowed_host,
allow_query_token,
)
.await
}
#[cfg(unix)]
Some(Commands::List { socket }) => run_list(socket).await,
#[cfg(unix)]
Some(Commands::Attach { thread_id, consumer, follow, socket }) => {
run_attach(socket, thread_id, consumer, follow).await
}
#[cfg(unix)]
Some(Commands::Kill { thread_id, all, socket }) => run_kill(socket, thread_id, all).await,
#[cfg(unix)]
Some(Commands::Prune { idle_seconds, socket }) => run_prune(socket, idle_seconds).await,
#[cfg(unix)]
Some(Commands::RestartDaemon { socket }) => run_restart_daemon(socket).await,
Some(Commands::Doctor) => run_doctor().await,
Some(Commands::Report { log, last, since_minutes, build }) => {
run_report(log, last, since_minutes, build).await
}
None | Some(Commands::Serve { .. }) => run_server().await,
}
}
#[cfg(unix)]
fn daemon_client(socket: Option<PathBuf>) -> DaemonClient {
DaemonClient::new(socket.unwrap_or_else(default_socket_path))
}
#[cfg(unix)]
async fn run_list(socket: Option<PathBuf>) -> Result<()> {
let sessions = daemon_client(socket).list_sessions().await?;
serde_json::to_writer_pretty(std::io::stdout().lock(), &sessions)
.map_err(|error| WinxError::SerializationError(error.to_string()))?;
writeln!(std::io::stdout().lock())?;
Ok(())
}
#[cfg(unix)]
async fn run_attach(
socket: Option<PathBuf>,
thread_id: String,
consumer: String,
follow: bool,
) -> Result<()> {
let client = daemon_client(socket);
loop {
let read = client.read_output(&thread_id, &consumer).await?;
if read.gap {
writeln!(std::io::stderr().lock(), "(...daemon scrollback truncated...)")?;
}
if !read.output.is_empty() {
let mut stdout = std::io::stdout().lock();
stdout.write_all(read.output.as_bytes())?;
stdout.flush()?;
}
if !follow {
return Ok(());
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
#[cfg(unix)]
async fn run_kill(socket: Option<PathBuf>, thread_id: Option<String>, all: bool) -> Result<()> {
let client = daemon_client(socket);
if all {
let sessions = client.list_sessions().await?;
let mut killed = 0usize;
for session in sessions {
if client.kill_session(&session.thread_id).await? {
killed += 1;
}
}
writeln!(std::io::stdout().lock(), "killed {killed} session(s)")?;
return Ok(());
}
let thread_id = thread_id
.ok_or_else(|| WinxError::InvalidInput("provide a thread_id or pass --all".to_string()))?;
let killed = client.kill_session(&thread_id).await?;
writeln!(std::io::stdout().lock(), "{}", if killed { "killed" } else { "not found" })?;
Ok(())
}
#[cfg(unix)]
async fn run_prune(socket: Option<PathBuf>, idle_seconds: Option<u64>) -> Result<()> {
let result = daemon_client(socket).prune_sessions(idle_seconds).await?;
serde_json::to_writer_pretty(std::io::stdout().lock(), &result)
.map_err(|error| WinxError::SerializationError(error.to_string()))?;
writeln!(std::io::stdout().lock())?;
Ok(())
}
#[cfg(unix)]
async fn run_restart_daemon(socket: Option<PathBuf>) -> Result<()> {
let socket = socket.unwrap_or_else(default_socket_path);
let binary = configured_daemon_binary()?;
let hello = restart_control_daemon_at(&socket, &binary).await?;
writeln!(
std::io::stdout().lock(),
"restarted winxd pid={} epoch={}",
hello.daemon_pid,
hello.daemon_epoch
)?;
Ok(())
}
async fn run_doctor() -> Result<()> {
let report = winx_code_agent::diagnostics::doctor_report().await;
serde_json::to_writer_pretty(std::io::stdout().lock(), &report)
.map_err(|error| WinxError::SerializationError(error.to_string()))?;
writeln!(std::io::stdout().lock())?;
Ok(())
}
async fn run_report(
log: Option<PathBuf>,
last: usize,
since_minutes: u64,
build: Option<String>,
) -> Result<()> {
let report = tokio::task::spawn_blocking(move || {
winx_code_agent::report::usage_report(log.as_deref(), last, since_minutes, build.as_deref())
})
.await
.map_err(|error| {
WinxError::CommandExecutionError(format!("report worker failed: {error}"))
})??;
serde_json::to_writer_pretty(std::io::stdout().lock(), &report)
.map_err(|error| WinxError::SerializationError(error.to_string()))?;
writeln!(std::io::stdout().lock())?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn run_http_server(
bind: String,
token: Option<String>,
token_file: Option<PathBuf>,
principal_config: Option<PathBuf>,
tool_profile: winx_code_agent::tool_policy::ToolProfile,
allowed_tools: Vec<String>,
allow_weak_token: bool,
allow_non_loopback: bool,
session_affinity: winx_code_agent::config::HttpSessionAffinity,
max_concurrency: usize,
requests_per_minute: u32,
allowed_hosts: Vec<String>,
allow_query_token: bool,
) -> Result<()> {
if token.is_some() {
tracing::warn!("--token exposes the HTTP secret in process arguments; prefer --token-file");
}
let explicit_tools = (!allowed_tools.is_empty()).then_some(allowed_tools.as_slice());
let tool_policy =
winx_code_agent::tool_policy::ToolPolicy::resolve(tool_profile, explicit_tools)?;
let principals = winx_code_agent::config::load_http_principals_with_policy(
principal_config.as_deref(),
token,
token_file.as_deref(),
allow_weak_token,
tool_policy,
)?;
let options = winx_code_agent::http_server::HttpServerOptions {
bind: bind.clone(),
principals,
extra_hosts: allowed_hosts,
allow_query_token,
allow_non_loopback,
session_affinity,
max_concurrency,
requests_per_minute,
};
tracing::info!(
build = %winx_code_agent::build_info::display_version(),
"Starting winx remote MCP (HTTP) on {bind}"
);
winx_code_agent::http_server::start_http_server_with_options(options).await.map_err(|error| {
WinxError::ShellInitializationError(format!("HTTP server failed: {error}"))
})
}
async fn run_server() -> Result<()> {
tracing::info!(
build = %winx_code_agent::build_info::display_version(),
"Starting winx MCP server"
);
match start_winx_server().await {
Ok(()) => {
tracing::info!("Server shutting down normally");
Ok(())
}
Err(e) => {
tracing::error!("Server error: {}", e);
Err(WinxError::ShellInitializationError(format!("Failed to start server: {e}")))
}
}
}