hotl 0.2.0

Human-on-the-loop terminal AI agent: steering TUI + headless mode, gated tools under a kernel sandbox floor, session resume + undo, MCP/ACP, any Anthropic or OpenAI-compatible model — plus `hotl watch`, a tmux dashboard for the agents you already run.
//! hotl — one binary, three capabilities (watch · orchestrate · execute).
//!
//! Subcommands:
//!   (none)        the console TUI (execute); also `hotl <id-prefix>` / `hotl --resume`
//!   -p "prompt"   headless one-shot; asks default-deny; --json for events
//!   watch         the tmux dashboard (the pre-merge `hotl`)
//!   fleet         reserved (orchestrate, M4+)
//!   doctor        environment/setup checks (MD)
//!   resume        continue an earlier session from its log (M3b)
//!   undo          restore files to the last pre-batch snapshot (M3b)
//!   acp           serve the ACP JSON-RPC protocol over stdio (M4)
//!   bg            background a session as a detached socket server (attach later)
//!   attach        connect to a backgrounded session (bare: list them)
//!   serve         (internal) host a session on a unix socket — used by `bg`
//!   setup         write default config (safe defaults; never silent) (MD)
//!   gc            prune old sessions/shadows/blobs per [retention] (retention)
//!   update        show version + how to update (MD)

mod acp;
mod agent;
mod attach;
mod bg;
mod config;
mod doctor;
mod gc;
mod keysource;
mod session_server;
mod setup;
mod shell_hooks;
mod spawn;
mod structured;
mod tui;
mod watch;

/// The zsh `:` prefix: a line starting with `: ` becomes
/// an agent prompt; everything else runs as normal shell.
const ZSH_PLUGIN: &str = r#"# hotl zsh plugin — generated by `hotl init zsh`
# A command line starting with `: ` becomes an agent prompt (hotl -p).
# `@[path]` tokens in that prompt are expanded to the file's contents by hotl.
hotl-accept-line() {
  if [[ $BUFFER == ':'\ * ]]; then
    local prompt_text=${BUFFER#: }
    BUFFER="hotl -p ${(qq)prompt_text}"
  fi
  zle .accept-line
}
zle -N accept-line hotl-accept-line

# OSC-133 shell integration: mark prompt/command/output regions so a terminal
# (and `hotl watch`) can find prompts and command boundaries in the scrollback.
hotl-osc133-precmd()  { print -Pn "\e]133;D;$?\a\e]133;A\a" }
hotl-osc133-preexec() { print -Pn "\e]133;C\a" }
autoload -Uz add-zsh-hook
add-zsh-hook precmd  hotl-osc133-precmd
add-zsh-hook preexec hotl-osc133-preexec
"#;

fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();
    match args.first().map(String::as_str) {
        Some("watch") => {
            if let Err(e) = watch::watch_main() {
                eprintln!("hotl watch: {e}");
                std::process::exit(1);
            }
        }
        Some("fleet") => {
            eprintln!("hotl fleet (orchestrate) is reserved and not built yet — it arrives after the harness's M4 seams.");
            std::process::exit(2);
        }
        Some("doctor") => std::process::exit(doctor::doctor_main()),
        Some("undo") => std::process::exit(agent::undo_main(args[1..].to_vec())),
        Some("resume") => {
            let mut rest = vec!["--resume".to_string()];
            rest.extend(args[1..].iter().cloned());
            std::process::exit(block_on(tui::tui_main(rest)));
        }
        Some("acp") => std::process::exit(block_on(agent::acp_main())),
        Some("bg") => std::process::exit(bg::bg_main(bg_prompt(&args))),
        Some("attach") => std::process::exit(block_on(attach::attach_main(
            args.get(1).map(String::as_str),
        ))),
        Some("serve") => {
            let (id, prompt) = parse_serve_args(&args);
            std::process::exit(block_on(agent::serve_main(id, prompt)));
        }
        Some("setup") => {
            let force = args.iter().any(|a| a == "--force" || a == "-f");
            std::process::exit(setup::setup_main(&agent::config_dir(), force));
        }
        Some("gc") => std::process::exit(gc::gc_main(&args)),
        Some("update") => std::process::exit(update_main(args.get(1).map(String::as_str))),
        Some("init") => {
            // Binary-generated shell integration (the `:` prefix).
            match args.get(1).map(String::as_str) {
                Some("zsh") => print!("{}", ZSH_PLUGIN),
                _ => {
                    eprintln!(
                        "usage: hotl init zsh   # then: eval \"$(hotl init zsh)\" in ~/.zshrc"
                    );
                    std::process::exit(2);
                }
            }
        }
        Some("--help") | Some("-h") | Some("help") => print_help(),
        _ => {
            if is_headless(&args) {
                std::process::exit(block_on(agent::agent_main(args)));
            }
            std::process::exit(block_on(tui::tui_main(args)));
        }
    }
}

/// Headless flags route to `agent.rs`; everything else in the catch-all is
/// the TUI's (bare, an id-prefix, or --resume).
fn is_headless(args: &[String]) -> bool {
    args.iter()
        .any(|a| matches!(a.as_str(), "-p" | "--print" | "--json" | "--json-schema"))
}

fn print_help() {
    println!(
        "hotl — human on the loop\n\n\
         USAGE:\n  hotl [id-prefix]     console TUI (execute); --resume picks a session\n  \
         hotl -p \"prompt\"     headless one-shot (--json for events; --json-schema <f> for validated JSON)\n  \
         hotl bg [prompt]     background a session (detached socket server; attach later)\n  \
         hotl attach [id]     connect to a backgrounded session (bare: list them)\n  \
         hotl watch           tmux agent dashboard (watch)\n  \
         hotl init zsh        print the zsh `:` prefix plugin (eval it in ~/.zshrc)\n  \
         hotl doctor          check provider keys, sandbox, config, session store\n  \
         hotl setup           write default config (safe defaults)\n  \
         hotl gc [--dry-run]  prune old sessions/shadows/blobs per [retention]\n  \
         hotl resume [id]     continue an earlier session in the TUI (bare: pick from a list)\n  \
         hotl undo            restore files to before the agent's last change\n  \
         hotl fleet           reserved (orchestrate)\n\n\
         CONFIG: ~/.config/hotl/config.toml (one file: [provider] [context] [behavior]\n  \
         [retention], plus [[allow]] [[mcp]] [[hook]] [diagnostics]). Env vars override\n  \
         (HOTL_MODEL, ANTHROPIC_API_KEY / OPENAI_API_KEY, HOTL_OPENAI_BASE_URL,\n  \
         HOTL_SANDBOX=off). Run `hotl setup` to write a starter.\n\n\
         TUI: type to prompt · type mid-turn to steer · y/n answers asks · ? help · ctrl-c quits"
    );
}

/// `hotl update`: report the current version, compare against `latest` if the
/// caller supplied one, and point at the update path.
fn update_main(latest: Option<&str>) -> i32 {
    let current = env!("CARGO_PKG_VERSION");
    let enforced = if hotl_tools::rules::enforced_build() {
        " (security-enforced)"
    } else {
        ""
    };
    println!("hotl {current}{enforced}");
    if let Some(latest) = latest {
        if setup::is_newer(current, latest) {
            println!("a newer version is available: {latest}");
        } else {
            println!("up to date (latest: {latest}).");
        }
    }
    println!(
        "to update: `cargo install --locked hotl`, or re-run the installer script.\n\
         (automated self-update is not wired yet — it needs a signed release feed; MD.)"
    );
    0
}

/// The optional opening prompt for `hotl bg` (the first non-flag arg).
fn bg_prompt(args: &[String]) -> Option<&str> {
    args.get(1)
        .map(String::as_str)
        .filter(|a| !a.starts_with('-'))
}

/// `--id <id>` and `--prompt <p>` for `hotl serve`; a missing id is generated.
fn parse_serve_args(args: &[String]) -> (String, Option<String>) {
    let mut id = None;
    let mut prompt = None;
    let mut it = args.iter().skip(1);
    while let Some(a) = it.next() {
        match a.as_str() {
            "--id" => id = it.next().cloned(),
            "--prompt" => prompt = it.next().cloned(),
            _ => {}
        }
    }
    (
        id.unwrap_or_else(|| format!("bg-{}", std::process::id())),
        prompt,
    )
}

/// One-shot CLI paths run current_thread per the async policy (no pool
/// spinup on the cold-start path).
fn block_on(f: impl std::future::Future<Output = i32>) -> i32 {
    tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("tokio runtime")
        .block_on(f)
}

#[cfg(test)]
mod tests {
    use super::is_headless;
    fn v(args: &[&str]) -> Vec<String> {
        args.iter().map(|s| s.to_string()).collect()
    }
    #[test]
    fn headless_flags_route_to_agent() {
        assert!(is_headless(&v(&["-p", "hi"])));
        assert!(is_headless(&v(&["--json", "-p", "hi"])));
        assert!(is_headless(&v(&["-p", "hi", "--json-schema", "s.json"])));
    }
    #[test]
    fn tui_args_do_not() {
        assert!(!is_headless(&v(&[])));
        assert!(!is_headless(&v(&["01ABC"])));
        assert!(!is_headless(&v(&["--resume"])));
    }
}