use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::path::Path;
use cryochamber::config;
use cryochamber::message;
use cryochamber::protocol;
use cryochamber::state::{self, CryoState};
#[derive(Parser)]
#[command(name = "cryo", about = "Long-term AI agent task scheduler")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Init {
#[arg(long, default_value = "opencode")]
agent: String,
},
Start {
#[arg(long)]
agent: Option<String>,
#[arg(long)]
max_retries: Option<u32>,
#[arg(long)]
max_session_duration: Option<u64>,
},
Status,
Ps {
#[arg(long)]
kill_all: bool,
},
Restart,
Cancel,
Clean {
#[arg(long)]
force: bool,
},
Log,
Watch {
#[arg(long)]
all: bool,
#[arg(long, default_value = "cryo")]
viewpoint: String,
},
Send {
body: String,
#[arg(long, default_value = "human")]
from: String,
#[arg(long)]
subject: Option<String>,
#[arg(long)]
wake: bool,
},
Receive,
Wake {
message: Option<String>,
},
#[command(hide = true)]
FallbackExec {
action: String,
target: String,
message: String,
},
Web {
#[arg(long)]
host: Option<String>,
#[arg(long)]
port: Option<u16>,
#[arg(long, conflicts_with = "stop")]
foreground: bool,
#[arg(long)]
stop: bool,
},
#[command(hide = true)]
Daemon,
#[command(hide = true)]
WebDaemon {
#[arg(long)]
host: String,
#[arg(long)]
port: u16,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Init { agent } => cmd_init(&agent),
Commands::Start {
agent,
max_retries,
max_session_duration,
} => cmd_start(agent, max_retries, max_session_duration),
Commands::Status => cmd_status(),
Commands::Ps { kill_all } => cmd_ps(kill_all),
Commands::Restart => cmd_restart(),
Commands::Cancel => cmd_cancel(),
Commands::Clean { force } => cmd_clean(force),
Commands::Log => cmd_log(),
Commands::Watch { all, viewpoint } => cmd_watch(all, &viewpoint),
Commands::Send {
body,
from,
subject,
wake,
} => cmd_send(&body, &from, subject.as_deref(), wake),
Commands::Wake { message } => cmd_wake(message.as_deref()),
Commands::Web {
host,
port,
foreground,
stop,
} => cmd_web(host, port, foreground, stop),
Commands::Daemon => cmd_daemon(),
Commands::WebDaemon { host, port } => cmd_web_daemon(host, port),
Commands::Receive => cmd_receive(),
Commands::FallbackExec {
action,
target,
message,
} => {
let dir = cryochamber::work_dir()?;
let fb = cryochamber::fallback::FallbackAction {
action,
target,
message,
};
let config = cryochamber::config::load_config(&cryochamber::config::config_path(&dir))?
.unwrap_or_default();
fb.execute(&dir, &config.fallback_alert)
}
}
}
fn require_valid_project(dir: &Path) -> Result<()> {
if !config::config_path(dir).exists() {
anyhow::bail!("No cryochamber project in this directory. Run `cryo init` first.");
}
Ok(())
}
fn require_live_daemon(dir: &Path) -> Result<CryoState> {
require_valid_project(dir)?;
let cryo_state = state::load_state(&state::state_path(dir))?
.context("No daemon state found. Run `cryo start` first.")?;
if !state::is_locked(&cryo_state) {
anyhow::bail!(
"No live daemon in this directory (stale state from a previous run). \
Run `cryo start` to start a new one, or `cryo cancel` to clean up stale state."
);
}
Ok(cryo_state)
}
fn cmd_init(agent_cmd: &str) -> Result<()> {
let dir = cryochamber::work_dir()?;
if protocol::write_config_file(&dir, agent_cmd)? {
println!(" cryo.toml (created)");
} else {
println!(" cryo.toml (exists, kept)");
}
let filename = protocol::protocol_filename(agent_cmd);
if protocol::write_protocol_file(&dir, filename)? {
println!(" {filename} (created)");
} else {
println!(" {filename} (exists, kept)");
}
if protocol::write_template_plan(&dir)? {
println!(" plan.md (created)");
} else {
println!(" plan.md (exists, kept)");
}
if protocol::write_readme(&dir)? {
println!(" README.md (created)");
} else {
println!(" README.md (exists, kept)");
}
message::ensure_dirs(&dir)?;
println!("\nCryochamber initialized. Next steps:");
println!(" 1. Edit plan.md with your task plan");
println!(" 2. Run: cryo start");
Ok(())
}
fn validate_agent_command(agent_cmd: &str) -> Result<()> {
let program = cryochamber::agent::agent_program(agent_cmd)?;
let status = std::process::Command::new("which")
.arg(&program)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match status {
Ok(s) if s.success() => Ok(()),
_ => anyhow::bail!(
"Agent command '{}' not found. Verify it is installed and on your PATH.",
program
),
}
}
fn cmd_start(
agent_override: Option<String>,
max_retries_override: Option<u32>,
max_session_duration_override: Option<u64>,
) -> Result<()> {
let dir = cryochamber::work_dir()?;
require_valid_project(&dir)?;
if !dir.join("plan.md").exists() {
anyhow::bail!("No plan.md found in the working directory. Create one or run `cryo init`.");
}
if let Some(existing) = state::load_state(&state::state_path(&dir))? {
if state::is_locked(&existing) {
anyhow::bail!(
"A cryochamber session is already running (PID: {:?}). Use `cryo cancel` to stop it first.",
existing.pid
);
}
}
let cfg = config::load_config(&config::config_path(&dir))?.unwrap_or_default();
let effective_agent = agent_override.as_deref().unwrap_or(&cfg.agent);
validate_agent_command(effective_agent)?;
message::ensure_dirs(&dir)?;
let cryo_state = CryoState {
session_number: 0, pid: None, retry_count: 0,
agent_override,
max_retries_override,
max_session_duration_override,
next_wake: None,
last_report_time: None,
provider_index: None,
};
state::save_state(&state::state_path(&dir), &cryo_state)?;
if std::env::var("CRYO_NO_SERVICE").is_ok() {
cryochamber::process::spawn_daemon(&dir)?;
println!("Cryochamber started (background process).");
} else {
let exe = std::env::current_exe().context("Failed to resolve cryo executable path")?;
let log_path = cryochamber::log::log_path(&dir);
cryochamber::service::install("daemon", &dir, &exe, &["daemon"], &log_path, false)?;
println!("Cryochamber started (service installed, survives reboot).");
}
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
if let Some(st) = state::load_state(&state::state_path(&dir))? {
if state::is_locked(&st) {
break;
}
}
if std::time::Instant::now() > deadline {
anyhow::bail!("Daemon did not start within 10 seconds. Check cryo.log for errors.");
}
}
println!("Use `cryo watch` or `cryo web` to follow progress.");
println!("Use `cryo status` to check state.");
Ok(())
}
fn cmd_daemon() -> Result<()> {
let dir = cryochamber::work_dir()?;
let daemon = cryochamber::daemon::Daemon::new(dir);
daemon.run()
}
fn cmd_web(host: Option<String>, port: Option<u16>, foreground: bool, stop: bool) -> Result<()> {
let dir = cryochamber::work_dir()?;
require_valid_project(&dir)?;
let cfg = config::load_config(&config::config_path(&dir))?.unwrap_or_default();
let host = host.unwrap_or(cfg.web_host);
let port = port.unwrap_or(cfg.web_port);
if stop {
if cryochamber::service::uninstall("web", &dir)? {
println!("Web service stopped and removed.");
} else {
println!("No web service installed for this directory.");
}
return Ok(());
}
if foreground {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(cryochamber::web::serve(dir, &host, port))
} else {
let exe = std::env::current_exe().context("Failed to resolve cryo executable path")?;
let port_str = port.to_string();
let log_path = dir.join("cryo-web.log");
cryochamber::service::install(
"web",
&dir,
&exe,
&["web-daemon", "--host", &host, "--port", &port_str],
&log_path,
true,
)?;
println!("Web UI service installed: http://{}:{}", host, port);
println!("Log: cryo-web.log");
println!("Survives reboot. Stop with: cryo web --stop");
Ok(())
}
}
fn cmd_web_daemon(host: String, port: u16) -> Result<()> {
let dir = cryochamber::work_dir()?;
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(cryochamber::web::serve(dir, &host, port))
}
fn cmd_status() -> Result<()> {
let dir = cryochamber::work_dir()?;
require_valid_project(&dir)?;
let cfg = config::load_config(&config::config_path(&dir))?.unwrap_or_default();
match state::load_state(&state::state_path(&dir))? {
None => {
println!("No daemon has been started yet. Run `cryo start` to begin.");
println!("\nConfig (cryo.toml):");
println!(" Agent: {}", cfg.agent);
}
Some(st) => {
println!(
"Daemon: {}",
if state::is_locked(&st) {
"running"
} else {
"stopped"
}
);
println!("Session: {}", st.session_number);
if let Some(pid) = st.pid {
println!("PID: {pid}");
}
let effective_agent = st.agent_override.as_deref().unwrap_or(&cfg.agent);
println!("Agent: {effective_agent}");
if st.agent_override.is_some() {
println!(" (override; cryo.toml has \"{}\")", cfg.agent);
}
if !cfg.providers.is_empty() {
let idx = st.provider_index.unwrap_or(0);
if let Some(provider) = cfg.providers.get(idx) {
println!(
"Provider: {} ({}/{})",
provider.name,
idx + 1,
cfg.providers.len()
);
}
}
let effective_timeout = st
.max_session_duration_override
.unwrap_or(cfg.max_session_duration);
if effective_timeout > 0 {
println!("Session timeout: {effective_timeout}s");
}
let log = cryochamber::log::log_path(&dir);
if let Some(latest) = cryochamber::log::read_latest_session(&log)? {
println!("\n--- Latest session ---");
let lines: Vec<&str> = latest.lines().collect();
let start = lines.len().saturating_sub(10);
for line in &lines[start..] {
println!("{line}");
}
}
}
}
Ok(())
}
fn cmd_restart() -> Result<()> {
let dir = cryochamber::work_dir()?;
let cryo_state = require_live_daemon(&dir)?;
let _ = cryochamber::service::uninstall("daemon", &dir);
if state::is_locked(&cryo_state) {
if let Some(pid) = cryo_state.pid {
cryochamber::process::terminate_pid(pid)?;
}
}
let updated = CryoState {
pid: None,
..cryo_state
};
state::save_state(&state::state_path(&dir), &updated)?;
let exe = std::env::current_exe().context("Failed to resolve cryo executable path")?;
let log_path = cryochamber::log::log_path(&dir);
cryochamber::service::install("daemon", &dir, &exe, &["daemon"], &log_path, false)?;
println!("Restarted (service reinstalled).");
println!("Use `cryo watch` or `cryo web` to follow progress.");
Ok(())
}
fn cmd_ps(kill_all: bool) -> Result<()> {
let entries = cryochamber::registry::list()?;
if entries.is_empty() {
println!("No cryo daemons running.");
return Ok(());
}
for entry in &entries {
if kill_all {
cryochamber::process::terminate_pid(entry.pid)?;
println!("Killed PID {:>6} {}", entry.pid, entry.dir);
} else {
println!("PID {:>6} {}", entry.pid, entry.dir);
}
}
Ok(())
}
fn cmd_cancel() -> Result<()> {
let dir = cryochamber::work_dir()?;
require_valid_project(&dir)?;
let service_removed = cryochamber::service::uninstall("daemon", &dir)?;
if service_removed {
println!("Service removed.");
}
let sp = state::state_path(&dir);
match state::load_state(&sp)? {
None => {
if !service_removed {
anyhow::bail!("Nothing to cancel. No daemon state or service found.");
}
}
Some(cryo_state) => {
if state::is_locked(&cryo_state) {
if let Some(pid) = cryo_state.pid {
cryochamber::process::terminate_pid(pid)?;
println!("Killed daemon (PID {pid}).");
}
}
std::fs::remove_file(sp)?;
println!("Removed timer.json.");
}
}
println!("Cryochamber cancelled.");
Ok(())
}
fn confirm(prompt: &str) -> bool {
eprint!("{prompt} [y/N] ");
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() {
return false;
}
matches!(input.trim(), "y" | "Y" | "yes" | "Yes")
}
fn cmd_clean(force: bool) -> Result<()> {
let dir = cryochamber::work_dir()?;
require_valid_project(&dir)?;
if !force && !confirm("Stop daemon and remove all runtime files?") {
println!("Aborted.");
return Ok(());
}
if cryochamber::service::uninstall("daemon", &dir)? {
println!("Removed daemon service.");
}
if cryochamber::service::uninstall("gh-sync", &dir)? {
println!("Removed gh-sync service.");
}
if cryochamber::service::uninstall("zulip-sync", &dir)? {
println!("Removed zulip-sync service.");
}
if cryochamber::service::uninstall("web", &dir)? {
println!("Removed web service.");
}
let sp = state::state_path(&dir);
if let Some(cryo_state) = state::load_state(&sp)? {
if state::is_locked(&cryo_state) {
if let Some(pid) = cryo_state.pid {
cryochamber::process::terminate_pid(pid)?;
println!("Killed daemon (PID {pid}).");
}
}
}
let runtime_files = [
"timer.json",
"cryo.log",
"cryo-agent.log",
"cryo-gh-sync.log",
"gh-sync.json",
"cryo-zulip-sync.log",
"zulip-sync.json",
"cryo-web.log",
];
for name in &runtime_files {
let path = dir.join(name);
if path.exists() {
std::fs::remove_file(&path)?;
println!("Removed {name}");
}
}
let runtime_dirs = ["messages", ".cryo"];
for name in &runtime_dirs {
let path = dir.join(name);
if path.exists() {
std::fs::remove_dir_all(&path)?;
println!("Removed {name}/");
}
}
println!("Clean.");
Ok(())
}
fn cmd_log() -> Result<()> {
let dir = cryochamber::work_dir()?;
let log = cryochamber::log::log_path(&dir);
if log.exists() {
let contents = std::fs::read_to_string(log)?;
println!("{contents}");
} else {
println!("No log file found.");
}
Ok(())
}
fn build_inbox_message(from: &str, subject: &str, body: &str) -> message::Message {
message::Message {
from: from.to_string(),
subject: subject.to_string(),
body: body.to_string(),
timestamp: chrono::Local::now().naive_local(),
metadata: std::collections::BTreeMap::new(),
}
}
fn is_daemon_running(dir: &std::path::Path) -> bool {
if let Ok(Some(st)) = state::load_state(&state::state_path(dir)) {
return state::is_locked(&st);
}
false
}
fn signal_daemon_wake(dir: &std::path::Path) -> bool {
cryochamber::process::signal_daemon_wake(dir)
}
fn notify_daemon_wake(dir: &std::path::Path) -> Result<()> {
let watch_inbox = config::load_config(&config::config_path(dir))?
.map(|c| c.watch_inbox)
.unwrap_or(true);
if !is_daemon_running(dir) {
eprintln!("Warning: no daemon is running. Message queued for the next `cryo start`.");
} else if watch_inbox {
println!("Daemon will pick it up shortly.");
} else if signal_daemon_wake(dir) {
println!("Wake signal sent. Daemon waking now.");
} else {
eprintln!("Warning: failed to signal daemon. Message queued for the next session.");
}
Ok(())
}
fn cmd_wake(wake_message: Option<&str>) -> Result<()> {
let dir = cryochamber::work_dir()?;
require_valid_project(&dir)?;
message::ensure_dirs(&dir)?;
let body = wake_message.unwrap_or("Manual wake requested by operator.");
let msg = build_inbox_message("operator", "Wake", body);
message::write_message(&dir, "inbox", &msg)?;
notify_daemon_wake(&dir)
}
fn cmd_send(body: &str, from: &str, subject: Option<&str>, wake: bool) -> Result<()> {
let dir = cryochamber::work_dir()?;
require_valid_project(&dir)?;
message::ensure_dirs(&dir)?;
let subject = subject.unwrap_or_else(|| {
let mut end = body.len().min(50);
while end > 0 && !body.is_char_boundary(end) {
end -= 1;
}
&body[..end]
});
let msg = build_inbox_message(from, subject, body);
let path = message::write_message(&dir, "inbox", &msg)?;
println!(
"Message sent to {}",
path.strip_prefix(&dir).unwrap_or(&path).display()
);
if wake {
notify_daemon_wake(&dir)?;
}
Ok(())
}
fn cmd_receive() -> Result<()> {
let dir = cryochamber::work_dir()?;
let messages = message::read_outbox(&dir)?;
if messages.is_empty() {
println!("No messages in outbox.");
return Ok(());
}
for (filename, msg) in &messages {
println!("--- {} ---", filename);
println!("From: {}", msg.from);
println!("Subject: {}", msg.subject);
println!("Time: {}", msg.timestamp.format("%Y-%m-%dT%H:%M:%S"));
println!();
println!("{}", msg.body);
println!();
}
Ok(())
}
fn cmd_watch(show_all: bool, viewpoint: &str) -> Result<()> {
use std::io::Read;
let dir = cryochamber::work_dir()?;
require_valid_project(&dir)?;
let log = match viewpoint {
"agent" => cryochamber::log::agent_log_path(&dir),
"cryo" => cryochamber::log::log_path(&dir),
other => anyhow::bail!("Unknown viewpoint '{other}'. Use 'cryo' or 'agent'."),
};
let state_file = state::state_path(&dir);
if !log.exists() {
println!("Waiting for first session output...");
}
let mut pos = if show_all {
0
} else {
log.metadata().map(|m| m.len()).unwrap_or(0)
};
let mut no_state_ticks: u32 = 0;
loop {
if log.exists() {
let file_len = log.metadata().map(|m| m.len()).unwrap_or(0);
if file_len > pos {
let mut f = std::fs::File::open(&log)?;
std::io::Seek::seek(&mut f, std::io::SeekFrom::Start(pos))?;
let mut buf = String::new();
f.read_to_string(&mut buf)?;
print!("{buf}");
pos = file_len;
no_state_ticks = 0; }
}
if let Some(st) = state::load_state(&state_file)? {
no_state_ticks = 0;
if state::is_locked(&st) {
} else {
if log.exists() {
let file_len = log.metadata().map(|m| m.len()).unwrap_or(0);
if file_len > pos {
let mut f = std::fs::File::open(&log)?;
std::io::Seek::seek(&mut f, std::io::SeekFrom::Start(pos))?;
let mut buf = String::new();
f.read_to_string(&mut buf)?;
print!("{buf}");
}
}
println!("\n(No active session or pending timer. Exiting watch.)");
break;
}
} else {
no_state_ticks += 1;
if no_state_ticks >= 20 {
println!("\n(No cryochamber instance found. Exiting watch.)");
break;
}
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
Ok(())
}