From 33edf6f4535b582067566dad12779edee7f6803f Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:42:19 -0300 Subject: [PATCH 1/3] feat(daemon): start at logon as a scheduled task and keep the computer awake (M5) `botloftd service install|status|restart|uninstall` registers a per-user scheduled task through the Task Scheduler COM API (schtasks output is localized). A logon trigger starts the daemon at logon; a time trigger that repeats every minute with IgnoreNew brings it back after a crash. Tested on Windows 11 25H2: RestartOnFailure does not restart a process that exits with an error, and a logon trigger's repetition only begins at the next logon, so neither can be the watchdog. `service install` copies the running binary to \bin and the task runs that copy, so the app's installer can replace its own files while the daemon runs. A busy exe is renamed aside instead of overwritten. One task per data folder, so a dev BOTLOFT_HOME never replaces the real installation; `--home` overrides BOTLOFT_HOME for every subcommand. The daemon's manifest asks for long paths and no console (consoleAllocationPolicy), embedded as a resource because the linker's manifest tool warns about the new element. Older Windows gets FreeConsole. Fatal start-up errors now reach the log file, since nobody sees the task's stderr. Keep-awake holds a power request while any bot is busy. Unlike SetThreadExecutionState it belongs to a handle, not a thread, which fits Tokio's worker pool. The spec is updated to match. --- CLAUDE.md | 2 + Cargo.lock | 1 + crates/botloftd/Cargo.toml | 5 +- crates/botloftd/botloftd.manifest | 18 ++ crates/botloftd/botloftd.rc | 3 + crates/botloftd/build.rs | 14 ++ crates/botloftd/src/autostart/binary.rs | 175 ++++++++++++++ crates/botloftd/src/autostart/health.rs | 50 ++++ crates/botloftd/src/autostart/mod.rs | 224 ++++++++++++++++++ crates/botloftd/src/keep_awake.rs | 77 ++++++ crates/botloftd/src/lib.rs | 2 + crates/botloftd/src/main.rs | 46 +++- crates/botloftd/src/paths.rs | 19 +- crates/botloftd/src/platform/mod.rs | 37 ++- crates/botloftd/src/platform/unix.rs | 46 ++++ crates/botloftd/src/platform/windows.rs | 25 +- .../botloftd/src/platform/windows/console.rs | 18 ++ crates/botloftd/src/platform/windows/power.rs | 70 ++++++ crates/botloftd/src/platform/windows/task.rs | 159 +++++++++++++ .../botloftd/src/platform/windows/task_xml.rs | 171 +++++++++++++ crates/botloftd/src/supervisor/mod.rs | 26 +- crates/botloftd/src/supervisor/spawn.rs | 1 + crates/botloftd/tests/supervisor_turns.rs | 29 +++ docs/spec.md | 13 +- 24 files changed, 1207 insertions(+), 24 deletions(-) create mode 100644 crates/botloftd/botloftd.manifest create mode 100644 crates/botloftd/botloftd.rc create mode 100644 crates/botloftd/build.rs create mode 100644 crates/botloftd/src/autostart/binary.rs create mode 100644 crates/botloftd/src/autostart/health.rs create mode 100644 crates/botloftd/src/autostart/mod.rs create mode 100644 crates/botloftd/src/keep_awake.rs create mode 100644 crates/botloftd/src/platform/windows/console.rs create mode 100644 crates/botloftd/src/platform/windows/power.rs create mode 100644 crates/botloftd/src/platform/windows/task.rs create mode 100644 crates/botloftd/src/platform/windows/task_xml.rs diff --git a/CLAUDE.md b/CLAUDE.md index 99a3b200..cdf0cfc5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ cargo test --workspace cargo clippy --workspace --all-targets -- -D warnings cargo fmt --all --check cargo run -p botloftd -- serve # com BOTLOFT_HOME de dev (abaixo) +cargo run -p botloftd -- service install # tarefa agendada própria da pasta de dev; `service uninstall` depois # Tipos TS gerados a partir do Rust cargo test -p botloft-core export_bindings @@ -68,6 +69,7 @@ Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon - Permissão de arquivo é ACL (SID do usuário atual), não modo Unix. - Estado em `%LOCALAPPDATA%`, nunca `%APPDATA%` (roaming). - Cuidado com o limite de 260 caracteres em caminhos de workspace. +- Tarefa agendada (spec 14): `RestartOnFailure` não reinicia quando o processo sai com erro, e a repetição de um gatilho de logon só começa no próximo logon. Quem traz o daemon de volta é um gatilho de horário repetido a cada minuto com `IgnoreNew`. Use a API COM do Agendador: a saída do `schtasks.exe` é traduzida. ## Segurança e privacidade diff --git a/Cargo.lock b/Cargo.lock index 411b6a79..aef4ca2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,6 +309,7 @@ dependencies = [ "bytes", "clap", "dirs", + "embed-resource", "futures-util", "getrandom 0.4.3", "hex", diff --git a/crates/botloftd/Cargo.toml b/crates/botloftd/Cargo.toml index 4c48caad..7b4a6488 100644 --- a/crates/botloftd/Cargo.toml +++ b/crates/botloftd/Cargo.toml @@ -33,7 +33,10 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } workspace = true [target."cfg(windows)".dependencies] -windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment"] } +windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment", "Win32_System_Power", "Win32_System_Console", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Variant", "Win32_System_TaskScheduler"] } + +[target."cfg(windows)".build-dependencies] +embed-resource = "3.0.11" [dev-dependencies] base64 = "0.23.1" diff --git a/crates/botloftd/botloftd.manifest b/crates/botloftd/botloftd.manifest new file mode 100644 index 00000000..c137bdc8 --- /dev/null +++ b/crates/botloftd/botloftd.manifest @@ -0,0 +1,18 @@ + + + + + + + + + + + + true + + detached + + + diff --git a/crates/botloftd/botloftd.rc b/crates/botloftd/botloftd.rc new file mode 100644 index 00000000..fac54ca2 --- /dev/null +++ b/crates/botloftd/botloftd.rc @@ -0,0 +1,3 @@ +// The daemon's Windows resources: only its manifest (spec 14). +#define RT_MANIFEST 24 +1 RT_MANIFEST "botloftd.manifest" diff --git a/crates/botloftd/build.rs b/crates/botloftd/build.rs new file mode 100644 index 00000000..9c3a71d0 --- /dev/null +++ b/crates/botloftd/build.rs @@ -0,0 +1,14 @@ +//! Embeds `botloftd.manifest` in the Windows binary (spec 14). It goes in +//! as a resource: the linker's own manifest tool predates +//! `consoleAllocationPolicy` and warns about it on every build. + +fn main() { + println!("cargo:rerun-if-changed=botloftd.rc"); + println!("cargo:rerun-if-changed=botloftd.manifest"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") + && let Err(err) = + embed_resource::compile("botloftd.rc", embed_resource::NONE).manifest_required() + { + panic!("cannot embed the manifest: {err}"); + } +} diff --git a/crates/botloftd/src/autostart/binary.rs b/crates/botloftd/src/autostart/binary.rs new file mode 100644 index 00000000..cb21780e --- /dev/null +++ b/crates/botloftd/src/autostart/binary.rs @@ -0,0 +1,175 @@ +//! The daemon runs from its own copy in `\bin`, never from the app's +//! folder, so the app's installer can replace its files while the daemon +//! runs (spec 14). + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +pub const EXE_NAME: &str = if cfg!(windows) { + "botloftd.exe" +} else { + "botloftd" +}; +const STAGED: &str = "botloftd.new"; +const OLD_SUFFIX: &str = ".old"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Installed { + pub path: PathBuf, + /// Whether a different binary was put in place. + pub changed: bool, +} + +/// Copies `source` to `\botloftd.exe`, unless it is that file or +/// has the same bytes. A running exe cannot be overwritten but can be +/// renamed, so the old one moves aside and is deleted on a later install. +pub fn install(source: &Path, bin_dir: &Path) -> io::Result { + fs::create_dir_all(bin_dir)?; + remove_old(bin_dir); + let target = bin_dir.join(EXE_NAME); + let unchanged = Installed { + path: target.clone(), + changed: false, + }; + if is_same_file(source, &target) { + return Ok(unchanged); + } + let bytes = fs::read(source)?; + match fs::read(&target) { + Ok(current) if current == bytes => return Ok(unchanged), + Ok(_) => {} + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(err), + } + let staged = bin_dir.join(STAGED); + fs::write(&staged, &bytes)?; + if target.exists() { + fs::rename(&target, aside(bin_dir))?; + } + fs::rename(&staged, &target)?; + Ok(Installed { + path: target, + changed: true, + }) +} + +/// Deletes the installed binary and what was moved aside, as far as +/// nothing still runs them. +pub fn remove(bin_dir: &Path) { + let _ = fs::remove_file(bin_dir.join(EXE_NAME)); + let _ = fs::remove_file(bin_dir.join(STAGED)); + remove_old(bin_dir); + let _ = fs::remove_dir(bin_dir); +} + +fn is_same_file(a: &Path, b: &Path) -> bool { + match (fs::canonicalize(a), fs::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } +} + +/// A free `botloftd..old` name. A previous one may still be running. +fn aside(bin_dir: &Path) -> PathBuf { + (1..) + .map(|n| bin_dir.join(format!("botloftd.{n}{OLD_SUFFIX}"))) + .find(|path| !path.exists()) + .unwrap_or_else(|| bin_dir.join(format!("botloftd{OLD_SUFFIX}"))) +} + +fn remove_old(bin_dir: &Path) { + let Ok(entries) = fs::read_dir(bin_dir) else { + return; + }; + for entry in entries.flatten() { + if entry.file_name().to_string_lossy().ends_with(OLD_SUFFIX) { + // Still running: it goes next time. + let _ = fs::remove_file(entry.path()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names(dir: &Path) -> Vec { + let mut names: Vec = fs::read_dir(dir) + .expect("read dir") + .map(|entry| { + entry + .expect("entry") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect(); + names.sort(); + names + } + + #[test] + fn copies_once_and_replaces_only_a_different_binary() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = dir.path().join("sidecar.exe"); + let bin = dir.path().join("bin"); + fs::write(&source, b"v1").expect("write"); + + let first = install(&source, &bin).expect("install"); + assert!(first.changed); + assert_eq!(fs::read(&first.path).expect("read"), b"v1"); + assert!(!install(&source, &bin).expect("again").changed); + assert!( + !install(&first.path, &bin).expect("itself").changed, + "running install from the installed copy changes nothing" + ); + + fs::write(&source, b"v2").expect("write"); + let second = install(&source, &bin).expect("update"); + assert!(second.changed); + assert_eq!(fs::read(&second.path).expect("read"), b"v2"); + assert_eq!(names(&bin), ["botloftd.1.old".to_owned(), EXE_NAME.into()]); + + fs::write(&source, b"v3").expect("write"); + install(&source, &bin).expect("update"); + assert_eq!( + names(&bin), + ["botloftd.1.old".to_owned(), EXE_NAME.into()], + "the v1 copy went away and v2 moved aside" + ); + + remove(&bin); + assert!(!bin.exists()); + } + + /// A running exe is open with share-delete: it can be renamed, not + /// written. + #[cfg(windows)] + #[test] + fn replaces_a_binary_that_is_running() { + use std::os::windows::fs::OpenOptionsExt as _; + const FILE_SHARE_READ: u32 = 1; + const FILE_SHARE_DELETE: u32 = 4; + + let dir = tempfile::tempdir().expect("tempdir"); + let source = dir.path().join("sidecar.exe"); + let bin = dir.path().join("bin"); + fs::write(&source, b"v1").expect("write"); + let installed = install(&source, &bin).expect("install"); + let running = fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE) + .open(&installed.path) + .expect("open"); + assert!( + fs::write(&installed.path, b"x").is_err(), + "cannot be written" + ); + + fs::write(&source, b"v2").expect("write"); + let updated = install(&source, &bin).expect("update"); + assert_eq!(fs::read(&updated.path).expect("read"), b"v2"); + drop(running); + } +} diff --git a/crates/botloftd/src/autostart/health.rs b/crates/botloftd/src/autostart/health.rs new file mode 100644 index 00000000..7f9e795d --- /dev/null +++ b/crates/botloftd/src/autostart/health.rs @@ -0,0 +1,50 @@ +//! Asking a daemon on 127.0.0.1 whether it is up, over plain HTTP. + +use std::io::{Read as _, Write as _}; +use std::net::{Ipv4Addr, SocketAddr, TcpStream}; +use std::time::{Duration, Instant}; + +use serde::Deserialize; + +const TIMEOUT: Duration = Duration::from_secs(2); + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct Health { + pub status: String, + pub version: String, + pub protocol: u32, +} + +/// The daemon's `/health`, or `None` if nothing Botloft answers. +pub fn probe(port: u16) -> Option { + let address = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); + let mut stream = TcpStream::connect_timeout(&address, TIMEOUT).ok()?; + stream.set_read_timeout(Some(TIMEOUT)).ok()?; + stream.set_write_timeout(Some(TIMEOUT)).ok()?; + let request = "GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"; + stream.write_all(request.as_bytes()).ok()?; + let mut response = Vec::new(); + stream.read_to_end(&mut response).ok()?; + let response = String::from_utf8_lossy(&response); + let (head, body) = response.split_once("\r\n\r\n")?; + if !head.starts_with("HTTP/1.1 200") { + return None; + } + serde_json::from_str::(body) + .ok() + .filter(|health| health.status == "ok") +} + +/// Polls until `done` holds for the probe, or `limit` passes. +pub fn wait(port: u16, limit: Duration, done: impl Fn(Option<&Health>) -> bool) -> bool { + let started = Instant::now(); + loop { + if done(probe(port).as_ref()) { + return true; + } + if started.elapsed() > limit { + return false; + } + std::thread::sleep(Duration::from_millis(250)); + } +} diff --git a/crates/botloftd/src/autostart/mod.rs b/crates/botloftd/src/autostart/mod.rs new file mode 100644 index 00000000..3b92c2ad --- /dev/null +++ b/crates/botloftd/src/autostart/mod.rs @@ -0,0 +1,224 @@ +//! `botloftd service ...`: the daemon as a per-user scheduled task that +//! starts at logon and comes back within a minute if it dies (spec 14). +//! Not a Windows service: a service runs in another session, without the +//! owner's Claude Code sign-in. + +pub mod binary; +pub mod health; + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, bail}; +use sha2::{Digest, Sha256}; + +use crate::config::Config; +use crate::paths; +use crate::platform::{self, TaskDefinition, TaskState}; + +/// How long a daemon may take to stop or to answer after starting. +const STOP_TIMEOUT: Duration = Duration::from_secs(10); +const START_TIMEOUT: Duration = Duration::from_secs(20); +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// One task per data folder: `Botloft` for the default one, and a name +/// with a hash of the folder for a dev `BOTLOFT_HOME`, so installing a dev +/// daemon never replaces the real one. +pub fn task_name(home: &Path) -> String { + let key = |path: &Path| path.to_string_lossy().to_lowercase(); + match paths::default_home() { + Some(default) if key(&default) == key(home) => "Botloft".to_owned(), + _ => { + let digest = Sha256::digest(key(home).as_bytes()); + format!("Botloft-{}", hex::encode(&digest[..4])) + } + } +} + +/// Quotes one argument for the Windows command line. Backslashes before +/// the closing quote are doubled, so `C:\` does not escape it. +fn quote(arg: &str) -> String { + let trailing = arg.len() - arg.trim_end_matches('\\').len(); + format!("\"{arg}{}\"", "\\".repeat(trailing)) +} + +fn definition(home: &Path, program: &Path) -> TaskDefinition { + let home_text = home.to_string_lossy(); + TaskDefinition { + description: format!( + "Starts the Botloft daemon at logon and keeps it running. Data folder: {home_text}" + ), + program: program.to_owned(), + arguments: format!("serve --home {}", quote(&home_text)), + working_dir: home.to_owned(), + } +} + +fn port(home: &Path) -> anyhow::Result { + Ok(Config::load(&home.join("config.toml"), false)?.port) +} + +fn bin_dir(home: &Path) -> PathBuf { + home.join("bin") +} + +/// Copies this binary to `\bin`, registers the task and makes sure +/// the daemon of this version runs. A daemon that already runs this very +/// binary is left alone, so its bots are not interrupted. +pub fn install(home: &Path) -> anyhow::Result<()> { + std::fs::create_dir_all(home).with_context(|| format!("cannot create {}", home.display()))?; + let port = port(home)?; + let exe = std::env::current_exe().context("cannot find this program")?; + let installed = binary::install(&exe, &bin_dir(home)) + .with_context(|| format!("cannot copy the daemon to {}", bin_dir(home).display()))?; + let name = task_name(home); + platform::register_task(&name, &definition(home, &installed.path)) + .with_context(|| format!("cannot register the scheduled task {name}"))?; + println!( + "Scheduled task {name} starts {} at logon.", + installed.path.display() + ); + + let running = platform::find_task(&name)?.is_some_and(|task| task.state == TaskState::Running); + let current = health::probe(port).is_some_and(|health| health.version == VERSION); + if running && current && !installed.changed { + println!("The daemon {VERSION} is already running on 127.0.0.1:{port}."); + return Ok(()); + } + restart_task(&name, port) +} + +/// Stops the daemon and starts it again from the task. +pub fn restart(home: &Path) -> anyhow::Result<()> { + let name = task_name(home); + if platform::find_task(&name)?.is_none() { + bail!("the scheduled task {name} is not installed; run `botloftd service install`"); + } + restart_task(&name, port(home)?) +} + +fn restart_task(name: &str, port: u16) -> anyhow::Result<()> { + stop_task(name, port)?; + platform::run_task(name).with_context(|| format!("cannot start the scheduled task {name}"))?; + let up = health::wait(port, START_TIMEOUT, |health| { + health.is_some_and(|health| health.version == VERSION) + }); + if !up { + bail!( + "the daemon did not answer on 127.0.0.1:{port} within {} s. Another program may \ + use the port, or another botloftd may run with this data folder; its log is in \ + the logs folder", + START_TIMEOUT.as_secs() + ); + } + println!("The daemon {VERSION} is running on 127.0.0.1:{port}."); + Ok(()) +} + +/// Ends the task's daemon and waits until it no longer answers. Killing +/// it is safe: every message is stored before it is delivered, and the +/// bots die with it (Job Objects). +fn stop_task(name: &str, port: u16) -> anyhow::Result<()> { + let running = platform::find_task(name)?.is_some_and(|task| task.state == TaskState::Running); + if !running { + return Ok(()); + } + platform::stop_task(name).with_context(|| format!("cannot stop the scheduled task {name}"))?; + let stopped = health::wait(port, STOP_TIMEOUT, |health| health.is_none()) + && wait_until(STOP_TIMEOUT, || { + platform::find_task(name) + .is_ok_and(|task| task.is_none_or(|task| task.state != TaskState::Running)) + }); + if !stopped { + bail!("the daemon of the scheduled task {name} did not stop"); + } + Ok(()) +} + +/// Stops the daemon, deletes the task and the installed binary. The data +/// folder and the bots' workspaces stay. +pub fn uninstall(home: &Path) -> anyhow::Result<()> { + let name = task_name(home); + let port = port(home).unwrap_or(Config::default().port); + stop_task(&name, port)?; + if platform::delete_task(&name)? { + println!("Deleted the scheduled task {name}."); + } else { + println!("The scheduled task {name} was not installed."); + } + binary::remove(&bin_dir(home)); + println!("Your bots and data stay in {}.", home.display()); + Ok(()) +} + +/// Prints whether the task is installed and whether the daemon answers. +pub fn status(home: &Path) -> anyhow::Result<()> { + let name = task_name(home); + match platform::find_task(&name)? { + Some(task) => { + let state = match task.state { + TaskState::Running => "running", + TaskState::Ready => "waiting for the next logon", + TaskState::Disabled => "disabled", + TaskState::Unknown => "in an unknown state", + }; + println!("Scheduled task {name}: installed, {state}."); + if let Some(command) = task.command { + println!(" {command}"); + } + } + None => println!("Scheduled task {name}: not installed."), + } + let port = port(home)?; + match health::probe(port) { + Some(health) => println!( + "Daemon: {} (protocol {}) answers on 127.0.0.1:{port}.", + health.version, health.protocol + ), + None => println!("Daemon: nothing answers on 127.0.0.1:{port}."), + } + println!("Data folder: {}", home.display()); + Ok(()) +} + +fn wait_until(limit: Duration, done: impl Fn() -> bool) -> bool { + let started = std::time::Instant::now(); + while !done() { + if started.elapsed() > limit { + return false; + } + std::thread::sleep(Duration::from_millis(250)); + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arguments_are_quoted_for_the_command_line() { + assert_eq!( + quote(r"C:\Users\Ana Lima\Botloft"), + r#""C:\Users\Ana Lima\Botloft""# + ); + assert_eq!(quote(r"D:\"), r#""D:\\""#); + let task = definition( + Path::new(r"C:\data"), + Path::new(r"C:\data\bin\botloftd.exe"), + ); + assert_eq!(task.arguments, r#"serve --home "C:\data""#); + assert!(task.description.ends_with(r"Data folder: C:\data")); + } + + #[test] + fn a_dev_home_gets_its_own_task() { + let default = paths::default_home().expect("default home"); + assert_eq!(task_name(&default), "Botloft"); + let upper = PathBuf::from(default.to_string_lossy().to_uppercase()); + assert_eq!(task_name(&upper), "Botloft", "Windows paths ignore case"); + let dev = task_name(Path::new(r"C:\src\botloft\.dev\home")); + assert!(dev.starts_with("Botloft-") && dev.len() == "Botloft-".len() + 8); + assert_ne!(dev, task_name(Path::new(r"C:\other\home"))); + } +} diff --git a/crates/botloftd/src/keep_awake.rs b/crates/botloftd/src/keep_awake.rs new file mode 100644 index 00000000..3084a468 --- /dev/null +++ b/crates/botloftd/src/keep_awake.rs @@ -0,0 +1,77 @@ +//! Keeps the computer awake while any bot is `busy`, when `keep_awake` is +//! on (spec 14). A bot waiting for the owner or idle lets it sleep. + +use tokio::sync::watch; +use tracing::{debug, warn}; + +use crate::platform::KeepAwake; + +/// Follows the supervisor's count of busy bots until the daemon stops. +pub async fn run(busy: watch::Receiver) { + let mut request = match KeepAwake::new() { + Ok(request) => request, + Err(err) => { + warn!("cannot keep the computer awake: {err}"); + return; + } + }; + follow(busy, |on| { + debug!(on, "keep awake"); + if let Err(err) = request.set(on) { + warn!("cannot change the keep-awake request: {err}"); + } + }) + .await; +} + +/// Calls `hold` with `true` when the first bot starts working and with +/// `false` when the last one stops. +async fn follow(mut busy: watch::Receiver, mut hold: impl FnMut(bool)) { + let mut held = false; + loop { + let working = *busy.borrow_and_update() > 0; + if working != held { + hold(working); + held = working; + } + if busy.changed().await.is_err() { + break; + } + } + if held { + hold(false); + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::*; + + #[tokio::test] + async fn holds_only_while_some_bot_is_busy() { + let (count, busy) = watch::channel(0usize); + let calls = Arc::new(Mutex::new(Vec::new())); + let seen = Arc::clone(&calls); + let task = tokio::spawn(follow(busy, move |on| { + seen.lock().expect("calls").push(on); + })); + for n in [1, 2, 1, 0, 0, 3] { + count.send_replace(n); + tokio::task::yield_now().await; + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + drop(count); + task.await.expect("follow ends with the daemon"); + assert_eq!(*calls.lock().expect("calls"), [true, false, true, false]); + } + + #[test] + fn a_keep_awake_request_can_be_set_and_cleared() { + let mut request = KeepAwake::new().expect("request"); + request.set(true).expect("set"); + request.set(true).expect("set twice"); + request.set(false).expect("clear"); + } +} diff --git a/crates/botloftd/src/lib.rs b/crates/botloftd/src/lib.rs index 8ab4a722..521f03ec 100644 --- a/crates/botloftd/src/lib.rs +++ b/crates/botloftd/src/lib.rs @@ -3,10 +3,12 @@ //! parses the command line and wires these modules together. pub mod approvals; +pub mod autostart; pub mod chat; pub mod clock; pub mod config; pub mod courier; +pub mod keep_awake; pub mod logging; pub mod paths; pub mod platform; diff --git a/crates/botloftd/src/main.rs b/crates/botloftd/src/main.rs index 2b4aff96..3313214c 100644 --- a/crates/botloftd/src/main.rs +++ b/crates/botloftd/src/main.rs @@ -15,14 +15,17 @@ use botloftd::runtime::PipeRuntime; use botloftd::service::tasks::TaskSettings; use botloftd::state::{BotSettings, Daemon, DaemonOptions}; use botloftd::supervisor::{self, SupervisorSettings}; -use botloftd::{approvals, logging, secrets, server}; +use botloftd::{approvals, autostart, keep_awake, logging, secrets, server}; use clap::{Parser, Subcommand}; use tokio::net::TcpListener; -use tracing::info; +use tracing::{error, info}; #[derive(Parser)] #[command(name = "botloftd", version, about = "Botloft daemon")] struct Cli { + /// Data folder. Defaults to BOTLOFT_HOME, then %LOCALAPPDATA%\Botloft. + #[arg(long, global = true)] + home: Option, #[command(subcommand)] command: Command, } @@ -35,16 +38,38 @@ enum Command { #[arg(long)] config: Option, }, + /// Start the daemon at logon, as a scheduled task of the current user. + #[command(subcommand)] + Service(ServiceCommand), +} + +#[derive(Subcommand)] +enum ServiceCommand { + /// Copy this binary to the data folder, register the task and start it. + Install, + /// Show whether the task is installed and the daemon answers. + Status, + /// Stop the daemon and start it again. + Restart, + /// Stop the daemon and remove the task. Bots and data stay. + Uninstall, } fn main() -> anyhow::Result<()> { - match Cli::parse().command { - Command::Serve { config } => serve(config), + let cli = Cli::parse(); + let home = + paths::resolve_home(cli.home.as_deref()).context("cannot resolve the data folder")?; + match cli.command { + Command::Serve { config } => serve(home, config), + Command::Service(ServiceCommand::Install) => autostart::install(&home), + Command::Service(ServiceCommand::Status) => autostart::status(&home), + Command::Service(ServiceCommand::Restart) => autostart::restart(&home), + Command::Service(ServiceCommand::Uninstall) => autostart::uninstall(&home), } } -fn serve(config_path: Option) -> anyhow::Result<()> { - let home = paths::resolve_home().context("cannot resolve the data folder")?; +fn serve(home: PathBuf, config_path: Option) -> anyhow::Result<()> { + platform::leave_own_console(); std::fs::create_dir_all(&home).with_context(|| format!("cannot create {}", home.display()))?; let config = match &config_path { Some(path) => Config::load(path, true)?, @@ -53,6 +78,12 @@ fn serve(config_path: Option) -> anyhow::Result<()> { let paths = Paths::new(home, paths::resolve_workspaces_root(&config)?); std::fs::create_dir_all(paths.logs())?; let _log_guard = logging::init(&paths.logs(), &config.log_level)?; + // Started by the scheduled task, nobody sees stderr: the log has to + // say why the daemon stopped. + run(paths, config).inspect_err(|err| error!("{err:#}")) +} + +fn run(paths: Paths, config: Config) -> anyhow::Result<()> { let _lock = InstanceLock::acquire(&paths.lock_file())?; let owner_token = secrets::load_or_create_owner_token(&paths.secrets()) @@ -92,6 +123,9 @@ fn serve(config_path: Option) -> anyhow::Result<()> { info!(port = config.port, "listening on 127.0.0.1"); tokio::spawn(supervisor::run(Arc::clone(&daemon))); tokio::spawn(courier::run(Arc::clone(&daemon))); + if config.keep_awake { + tokio::spawn(keep_awake::run(daemon.supervisor.busy_bots())); + } server::serve(Arc::clone(&daemon), listener, platform::shutdown_signal()).await?; daemon.supervisor.shutdown(); info!("stopped"); diff --git a/crates/botloftd/src/paths.rs b/crates/botloftd/src/paths.rs index 3c9d2140..60286db9 100644 --- a/crates/botloftd/src/paths.rs +++ b/crates/botloftd/src/paths.rs @@ -62,17 +62,26 @@ impl Paths { } } +/// `--home` when given (the scheduled task passes it, spec 14), else /// `BOTLOFT_HOME` when set, otherwise `%LOCALAPPDATA%\Botloft` (never the -/// roaming profile). Relative overrides resolve against the current directory. -pub fn resolve_home() -> io::Result { - match std::env::var_os(HOME_ENV).filter(|v| !v.is_empty()) { +/// roaming profile). Relative paths resolve against the current directory. +pub fn resolve_home(flag: Option<&Path>) -> io::Result { + let chosen = flag + .map(|path| path.as_os_str().to_owned()) + .or_else(|| std::env::var_os(HOME_ENV)) + .filter(|value| !value.is_empty()); + match chosen { Some(home) => std::path::absolute(home), - None => dirs::data_local_dir() - .map(|dir| dir.join("Botloft")) + None => default_home() .ok_or_else(|| io::Error::other("cannot find the local application data folder")), } } +/// `%LOCALAPPDATA%\Botloft`, the data folder of an installed daemon. +pub fn default_home() -> Option { + dirs::data_local_dir().map(|dir| dir.join("Botloft")) +} + /// `workspaces_root` from the config, otherwise `%USERPROFILE%\Botloft`. pub fn resolve_workspaces_root(config: &Config) -> io::Result { match config.workspaces_root() { diff --git a/crates/botloftd/src/platform/mod.rs b/crates/botloftd/src/platform/mod.rs index 085e696e..1440b7d4 100644 --- a/crates/botloftd/src/platform/mod.rs +++ b/crates/botloftd/src/platform/mod.rs @@ -8,12 +8,43 @@ mod windows; use std::fs::{File, OpenOptions, TryLockError}; use std::io; -use std::path::Path; +use std::path::{Path, PathBuf}; #[cfg(unix)] -pub use unix::{ProcessJob, restrict_to_current_user, user_environment}; +pub use unix::{ + KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, register_task, + restrict_to_current_user, run_task, stop_task, user_environment, +}; #[cfg(windows)] -pub use windows::{ProcessJob, restrict_to_current_user, user_environment}; +pub use windows::{ + KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, register_task, + restrict_to_current_user, run_task, stop_task, user_environment, +}; + +/// A program the system starts when the owner logs on (spec 14). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskDefinition { + pub description: String, + pub program: PathBuf, + /// Already quoted for the command line. + pub arguments: String, + pub working_dir: PathBuf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TaskState { + Running, + /// Installed and waiting for its trigger. + Ready, + Disabled, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskInfo { + pub state: TaskState, + pub command: Option, +} /// Exclusive lock on `\botloftd.lock`, held for the daemon's lifetime. /// The OS releases it when the process exits, even after a crash. diff --git a/crates/botloftd/src/platform/unix.rs b/crates/botloftd/src/platform/unix.rs index bf65cfca..de9772d8 100644 --- a/crates/botloftd/src/platform/unix.rs +++ b/crates/botloftd/src/platform/unix.rs @@ -5,6 +5,8 @@ use std::io; use std::os::unix::fs::PermissionsExt; use std::path::Path; +use super::{TaskDefinition, TaskInfo}; + /// `0700` for folders, `0600` for files. pub fn restrict_to_current_user(path: &Path) -> io::Result<()> { let mode = if path.is_dir() { 0o700 } else { 0o600 }; @@ -42,6 +44,20 @@ impl ProcessJob { } } +/// Nothing keeps a Unix machine awake yet. +#[derive(Debug)] +pub struct KeepAwake; + +impl KeepAwake { + pub fn new() -> io::Result { + Ok(Self) + } + + pub fn set(&mut self, _on: bool) -> io::Result<()> { + Ok(()) + } +} + /// The daemon's environment without Claude Code session variables. pub fn user_environment() -> io::Result> { Ok(std::env::vars_os() @@ -51,3 +67,33 @@ pub fn user_environment() -> io::Result> { }) .collect()) } + +/// Nothing to leave: Unix never opens a console for the daemon. +pub fn leave_own_console() {} + +fn no_tasks() -> io::Error { + io::Error::new( + io::ErrorKind::Unsupported, + "starting at logon is only supported on Windows", + ) +} + +pub fn register_task(_name: &str, _task: &TaskDefinition) -> io::Result<()> { + Err(no_tasks()) +} + +pub fn find_task(_name: &str) -> io::Result> { + Err(no_tasks()) +} + +pub fn run_task(_name: &str) -> io::Result<()> { + Err(no_tasks()) +} + +pub fn stop_task(_name: &str) -> io::Result<()> { + Err(no_tasks()) +} + +pub fn delete_task(_name: &str) -> io::Result { + Err(no_tasks()) +} diff --git a/crates/botloftd/src/platform/windows.rs b/crates/botloftd/src/platform/windows.rs index ade501df..fce4924d 100644 --- a/crates/botloftd/src/platform/windows.rs +++ b/crates/botloftd/src/platform/windows.rs @@ -1,10 +1,18 @@ -//! Windows ACLs, Job Objects, the user environment and console signals. +//! Windows ACLs, Job Objects, the user environment, power requests and +//! console signals. +mod console; mod env; mod job; +mod power; +mod task; +mod task_xml; +pub use console::leave_own_console; pub use env::user_environment; pub use job::ProcessJob; +pub use power::KeepAwake; +pub use task::{delete_task, find_task, register_task, run_task, stop_task}; use std::io; use std::os::windows::ffi::OsStrExt; @@ -14,8 +22,8 @@ use windows::Win32::Foundation::{ CloseHandle, ERROR_SUCCESS, GENERIC_ALL, HANDLE, HLOCAL, LocalFree, WIN32_ERROR, }; use windows::Win32::Security::Authorization::{ - EXPLICIT_ACCESS_W, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW, - SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W, + ConvertSidToStringSidW, EXPLICIT_ACCESS_W, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, SET_ACCESS, + SetEntriesInAclW, SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W, }; use windows::Win32::Security::{ ACL, DACL_SECURITY_INFORMATION, GetTokenInformation, NO_INHERITANCE, @@ -122,6 +130,17 @@ impl CurrentUser { // SAFETY: `buf` was filled by GetTokenInformation(TokenUser). unsafe { (*self.buf.as_ptr().cast::()).User.Sid } } + + /// The SID as text (`S-1-5-21-...`). + pub(crate) fn sid_string(&self) -> io::Result { + let mut text = PWSTR::null(); + // SAFETY: `sid()` is valid while `self` lives; the string is freed + // by LocalBox. + unsafe { ConvertSidToStringSidW(self.sid(), &mut text) }?; + let _text = LocalBox(text.0.cast()); + // SAFETY: a NUL-terminated string allocated by the call above. + unsafe { text.to_string() }.map_err(io::Error::other) + } } struct OwnedHandle(HANDLE); diff --git a/crates/botloftd/src/platform/windows/console.rs b/crates/botloftd/src/platform/windows/console.rs new file mode 100644 index 00000000..7d5cddca --- /dev/null +++ b/crates/botloftd/src/platform/windows/console.rs @@ -0,0 +1,18 @@ +//! The daemon runs from the scheduled task with no window. Its manifest +//! asks for no console (`consoleAllocationPolicy`, Windows 11 24H2 and +//! later); older Windows still opens one, which the daemon leaves here. + +use windows::Win32::System::Console::{FreeConsole, GetConsoleProcessList}; + +/// Detaches from the console when no other process uses it: Windows made +/// it just for the daemon, so nobody reads it and closing it would kill +/// the daemon. A console shared with a shell (run from a terminal) stays. +pub fn leave_own_console() { + let mut processes = [0u32; 2]; + // SAFETY: the buffer is valid for its length; the call only fills it. + let attached = unsafe { GetConsoleProcessList(&mut processes) }; + if attached == 1 { + // SAFETY: detaching from a console this process alone uses. + let _ = unsafe { FreeConsole() }; + } +} diff --git a/crates/botloftd/src/platform/windows/power.rs b/crates/botloftd/src/platform/windows/power.rs new file mode 100644 index 00000000..c20bbbbc --- /dev/null +++ b/crates/botloftd/src/platform/windows/power.rs @@ -0,0 +1,70 @@ +//! A power request that keeps Windows from sleeping while bots work +//! (spec 14). Unlike `SetThreadExecutionState`, it belongs to a handle and +//! not to a thread, so any Tokio worker can set or clear it, and +//! `powercfg /requests` lists it with the reason below. + +use std::io; + +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::System::Power::{ + PowerClearRequest, PowerCreateRequest, PowerRequestSystemRequired, PowerSetRequest, +}; +use windows::Win32::System::Threading::{ + POWER_REQUEST_CONTEXT_SIMPLE_STRING, REASON_CONTEXT, REASON_CONTEXT_0, +}; +use windows::core::PWSTR; + +/// `POWER_REQUEST_CONTEXT_VERSION`. +const CONTEXT_VERSION: u32 = 0; +const REASON: &str = "Botloft bots are working"; + +#[derive(Debug)] +pub struct KeepAwake { + handle: HANDLE, + on: bool, +} + +// SAFETY: a power request handle can be used and closed from any thread. +unsafe impl Send for KeepAwake {} + +impl KeepAwake { + pub fn new() -> io::Result { + let mut reason: Vec = REASON.encode_utf16().chain(Some(0)).collect(); + let context = REASON_CONTEXT { + Version: CONTEXT_VERSION, + Flags: POWER_REQUEST_CONTEXT_SIMPLE_STRING, + Reason: REASON_CONTEXT_0 { + SimpleReasonString: PWSTR(reason.as_mut_ptr()), + }, + }; + // SAFETY: `context` and the string it points at outlive the call, + // and the request keeps its own copy of the reason. + let handle = unsafe { PowerCreateRequest(&context) }?; + Ok(Self { handle, on: false }) + } + + /// Holds the system awake, or lets it sleep again. + pub fn set(&mut self, on: bool) -> io::Result<()> { + if on == self.on { + return Ok(()); + } + // SAFETY: `handle` is a live power request owned by `self`. + unsafe { + if on { + PowerSetRequest(self.handle, PowerRequestSystemRequired) + } else { + PowerClearRequest(self.handle, PowerRequestSystemRequired) + } + }?; + self.on = on; + Ok(()) + } +} + +impl Drop for KeepAwake { + fn drop(&mut self) { + let _ = self.set(false); + // SAFETY: closed exactly once. + let _ = unsafe { CloseHandle(self.handle) }; + } +} diff --git a/crates/botloftd/src/platform/windows/task.rs b/crates/botloftd/src/platform/windows/task.rs new file mode 100644 index 00000000..dd89a366 --- /dev/null +++ b/crates/botloftd/src/platform/windows/task.rs @@ -0,0 +1,159 @@ +//! The per-user scheduled task that starts the daemon at logon (spec 14), +//! through the Task Scheduler COM API. `schtasks.exe` would do the same, +//! but its messages are localized and cannot be parsed. + +use std::io; + +use windows::Win32::Foundation::{ERROR_FILE_NOT_FOUND, RPC_E_CHANGED_MODE}; +use windows::Win32::System::Com::{ + CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize, +}; +use windows::Win32::System::TaskScheduler::{ + IRegisteredTask, ITaskFolder, ITaskService, TASK_CREATE_OR_UPDATE, + TASK_LOGON_INTERACTIVE_TOKEN, TASK_STATE_DISABLED, TASK_STATE_QUEUED, TASK_STATE_READY, + TASK_STATE_RUNNING, TaskScheduler, +}; +use windows::Win32::System::Variant::VARIANT; +use windows::core::BSTR; + +use super::CurrentUser; +use super::task_xml; +use crate::platform::{TaskDefinition, TaskInfo, TaskState}; + +/// COM for the current thread, released on drop. +struct Com { + uninit: bool, +} + +impl Com { + fn init() -> io::Result { + // SAFETY: plain COM initialization for this thread. + let status = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }; + if status == RPC_E_CHANGED_MODE { + // Already initialized with another model; that works too. + return Ok(Self { uninit: false }); + } + status.ok()?; + Ok(Self { uninit: true }) + } +} + +impl Drop for Com { + fn drop(&mut self) { + if self.uninit { + // SAFETY: pairs with the successful CoInitializeEx above. + unsafe { CoUninitialize() }; + } + } +} + +/// The root folder of the current user's Task Scheduler. +struct Scheduler { + // Declared first so the folder is released before COM goes away. + folder: ITaskFolder, + _com: Com, +} + +impl Scheduler { + fn connect() -> io::Result { + let com = Com::init()?; + // SAFETY: COM is initialized on this thread; empty VARIANTs mean the + // local machine and the current user. + let folder = unsafe { + let service: ITaskService = + CoCreateInstance(&TaskScheduler, None, CLSCTX_INPROC_SERVER)?; + let empty = VARIANT::default(); + service.Connect(&empty, &empty, &empty, &empty)?; + service.GetFolder(&BSTR::from("\\"))? + }; + Ok(Self { folder, _com: com }) + } + + fn task(&self, name: &str) -> io::Result> { + // SAFETY: `folder` is a live COM object. + match unsafe { self.folder.GetTask(&BSTR::from(name)) } { + Ok(task) => Ok(Some(task)), + Err(err) if err.code() == ERROR_FILE_NOT_FOUND.to_hresult() => Ok(None), + Err(err) => Err(err.into()), + } + } + + fn existing(&self, name: &str) -> io::Result { + self.task(name)?.ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("the scheduled task {name} does not exist"), + ) + }) + } +} + +/// Creates the task, or replaces its definition if it exists. It runs as +/// the current user, only while they are logged on. +pub fn register_task(name: &str, task: &TaskDefinition) -> io::Result<()> { + let xml = task_xml::render(task, &CurrentUser::query()?.sid_string()?); + let scheduler = Scheduler::connect()?; + let empty = VARIANT::default(); + // SAFETY: `folder` is a live COM object and every argument outlives the call. + unsafe { + scheduler.folder.RegisterTask( + &BSTR::from(name), + &BSTR::from(xml.as_str()), + TASK_CREATE_OR_UPDATE.0, + &empty, + &empty, + TASK_LOGON_INTERACTIVE_TOKEN, + &empty, + ) + }?; + Ok(()) +} + +/// The task's state and command line, or `None` if it does not exist. +pub fn find_task(name: &str) -> io::Result> { + let scheduler = Scheduler::connect()?; + let Some(task) = scheduler.task(name)? else { + return Ok(None); + }; + // SAFETY: `task` is a live COM object. + let (state, xml) = unsafe { (task.State()?, task.Xml()?) }; + let state = match state { + TASK_STATE_RUNNING => TaskState::Running, + TASK_STATE_READY | TASK_STATE_QUEUED => TaskState::Ready, + TASK_STATE_DISABLED => TaskState::Disabled, + _ => TaskState::Unknown, + }; + Ok(Some(TaskInfo { + state, + command: task_xml::command_line(&xml.to_string()), + })) +} + +/// Starts the task now. A running task ignores it. +pub fn run_task(name: &str) -> io::Result<()> { + let scheduler = Scheduler::connect()?; + let task = scheduler.existing(name)?; + // SAFETY: `task` is a live COM object. + unsafe { task.Run(&VARIANT::default()) }?; + Ok(()) +} + +/// Ends every running instance of the task, which kills its process. +pub fn stop_task(name: &str) -> io::Result<()> { + let scheduler = Scheduler::connect()?; + let task = scheduler.existing(name)?; + // SAFETY: `task` is a live COM object. + unsafe { task.Stop(0) }?; + Ok(()) +} + +/// Deletes the task. Returns whether it existed. +pub fn delete_task(name: &str) -> io::Result { + let scheduler = Scheduler::connect()?; + // SAFETY: `folder` is a live COM object. + match unsafe { scheduler.folder.DeleteTask(&BSTR::from(name), 0) } { + Ok(()) => Ok(true), + Err(err) if err.code() == ERROR_FILE_NOT_FOUND.to_hresult() => Ok(false), + Err(err) => Err(err.into()), + } +} diff --git a/crates/botloftd/src/platform/windows/task_xml.rs b/crates/botloftd/src/platform/windows/task_xml.rs new file mode 100644 index 00000000..1966eb1b --- /dev/null +++ b/crates/botloftd/src/platform/windows/task_xml.rs @@ -0,0 +1,171 @@ +//! The definition of the daemon's scheduled task, in Task Scheduler XML. +//! +//! - A logon trigger for the current user starts the daemon at logon. +//! - A time trigger that began in the past and repeats every minute keeps +//! it running: with `IgnoreNew`, a repetition does nothing while the +//! daemon runs and starts it again within a minute after it dies. The +//! logon trigger cannot repeat for this: its repetition only begins at +//! the next logon, not when the task is installed. `RestartOnFailure` +//! is not used either: it does not fire when the process exits with an +//! error (both tested on Windows 11 25H2, spec 14). +//! - Interactive token, least privilege: only while the user is logged on, +//! in their session, with their Claude Code sign-in. +//! - No time limit, runs on battery, normal priority. The default priority +//! (7) would pass "below normal" on to every bot. + +use crate::platform::TaskDefinition; + +pub(super) fn render(task: &TaskDefinition, user_sid: &str) -> String { + let sid = escape(user_sid); + let description = escape(&task.description); + let command = escape(&task.program.to_string_lossy()); + let arguments = escape(&task.arguments); + let working_dir = escape(&task.working_dir.to_string_lossy()); + format!( + r#" + + + Botloft + {description} + + + + true + {sid} + + + true + 2026-01-01T00:00:00 + + PT1M + false + + + + + + {sid} + InteractiveToken + LeastPrivilege + + + + IgnoreNew + false + false + true + true + false + + false + false + + true + true + false + false + false + PT0S + 5 + + + + {command} + {arguments} + {working_dir} + + + +"# + ) +} + +/// The command line of the task's first action, as the owner would type it. +pub(super) fn command_line(xml: &str) -> Option { + let command = unescape(element(xml, "Command")?); + let quoted = if command.contains(' ') { + format!("\"{command}\"") + } else { + command + }; + Some(match element(xml, "Arguments") { + Some(arguments) => format!("{quoted} {}", unescape(arguments)), + None => quoted, + }) +} + +fn element<'a>(xml: &'a str, name: &str) -> Option<&'a str> { + let open = format!("<{name}>"); + let start = xml.find(&open)? + open.len(); + let end = start + xml[start..].find(&format!(""))?; + Some(&xml[start..end]) +} + +fn escape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for ch in text.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out +} + +fn unescape(text: &str) -> String { + text.replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + fn task() -> TaskDefinition { + TaskDefinition { + description: "Botloft & friends".into(), + program: PathBuf::from(r"C:\Users\Ana Lima\AppData\Local\Botloft\bin\botloftd.exe"), + arguments: r#"serve --home "C:\Users\Ana Lima\AppData\Local\Botloft""#.into(), + working_dir: PathBuf::from(r"C:\Users\Ana Lima\AppData\Local\Botloft"), + } + } + + #[test] + fn the_definition_escapes_text_and_runs_for_the_user_at_logon() { + let xml = render(&task(), "S-1-5-21-1-2-3-1001"); + assert!(xml.contains("Botloft <daemon> & friends")); + assert!(xml.contains( + "serve --home "C:\\Users\\Ana Lima\\AppData\\Local\\Botloft"" + )); + assert_eq!( + xml.matches("S-1-5-21-1-2-3-1001").count(), + 2 + ); + assert!(xml.contains("")); + assert!(xml.contains("PT1M")); + assert!(xml.contains("InteractiveToken")); + assert!(xml.contains("IgnoreNew")); + assert!(xml.contains("PT0S")); + } + + #[test] + fn the_command_line_comes_back_out_of_the_definition() { + let xml = render(&task(), "S-1-5-21-1-2-3-1001"); + assert_eq!( + command_line(&xml).as_deref(), + Some( + r#""C:\Users\Ana Lima\AppData\Local\Botloft\bin\botloftd.exe" serve --home "C:\Users\Ana Lima\AppData\Local\Botloft""# + ) + ); + assert_eq!(command_line(""), None); + } +} diff --git a/crates/botloftd/src/supervisor/mod.rs b/crates/botloftd/src/supervisor/mod.rs index cc3358de..fe0674b6 100644 --- a/crates/botloftd/src/supervisor/mod.rs +++ b/crates/botloftd/src/supervisor/mod.rs @@ -20,7 +20,7 @@ use botloft_core::ids::BotId; use botloft_core::now_ms; use botloft_core::protocol::{BotState, BotStateChanged}; use bytes::Bytes; -use tokio::sync::{Notify, broadcast}; +use tokio::sync::{Notify, broadcast, watch}; use tokio::time::Instant; use tracing::warn; @@ -50,6 +50,8 @@ pub struct Supervisor { next_generation: AtomicU64, reconcile_lock: tokio::sync::Mutex<()>, wake: Notify, + /// How many bots are `busy`, for keeping the computer awake (spec 14). + busy: watch::Sender, } #[derive(Default)] @@ -90,6 +92,7 @@ impl Supervisor { next_generation: AtomicU64::new(u64::try_from(now_ms()).unwrap_or(1)), reconcile_lock: tokio::sync::Mutex::new(()), wake: Notify::new(), + busy: watch::Sender::new(0), } } @@ -113,6 +116,11 @@ impl Supervisor { .map(|slot| (slot.state, slot.generation)) } + /// Follows how many bots are `busy`. + pub fn busy_bots(&self) -> watch::Receiver { + self.busy.subscribe() + } + pub fn claude_version(&self) -> Option { match &self.lock().claude { ClaudeStatus::Ready(claude) => Some(claude.version.clone()), @@ -194,11 +202,27 @@ impl Supervisor { fn set_state(&self, bot: &BotId, slot: &mut Slot, state: BotState) { if slot.state != state { + self.count_busy(slot.state, state); slot.state = state; self.announce(bot, slot); } } + /// Keeps the busy count in step with a state change. Every change of + /// `slot.state` goes through here. + fn count_busy(&self, from: BotState, to: BotState) { + let (was, is) = (from == BotState::Busy, to == BotState::Busy); + if was != is { + self.busy.send_modify(|count| { + *count = if is { + *count + 1 + } else { + count.saturating_sub(1) + }; + }); + } + } + /// Emits `bot.state`. A new process always announces itself, even when /// the state name did not change, because its generation did. fn announce(&self, bot: &BotId, slot: &Slot) { diff --git a/crates/botloftd/src/supervisor/spawn.rs b/crates/botloftd/src/supervisor/spawn.rs index 9b2ff1bd..a6dc92e9 100644 --- a/crates/botloftd/src/supervisor/spawn.rs +++ b/crates/botloftd/src/supervisor/spawn.rs @@ -73,6 +73,7 @@ impl Supervisor { resumed: launch.resumed, token_hash: launch.token_hash, }); + self.count_busy(slot.state, BotState::Launching); slot.state = BotState::Launching; self.announce(&bot.id, slot); debug!(bot = %bot.id, generation, pid = ?process.pid, resumed = launch.resumed, "bot started"); diff --git a/crates/botloftd/tests/supervisor_turns.rs b/crates/botloftd/tests/supervisor_turns.rs index b8f69308..3077a48c 100644 --- a/crates/botloftd/tests/supervisor_turns.rs +++ b/crates/botloftd/tests/supervisor_turns.rs @@ -40,6 +40,35 @@ async fn turns_and_approvals_drive_the_state() { assert_eq!(s.state(), BotState::Idle); } +/// What keeps the computer awake (spec 14): only `busy` counts. +#[tokio::test(start_paused = true)] +async fn the_busy_count_follows_turns_approvals_and_crashes() { + let s = setup().await; + let busy = s.daemon.supervisor.busy_bots(); + let process = s.runtime.process(1).await; + s.until(BotState::Idle).await; + assert_eq!(*busy.borrow(), 0); + let generation = s.message("work"); + assert_eq!(*busy.borrow(), 1); + + s.daemon.supervisor.approval_opened(&s.bot, generation); + assert_eq!(*busy.borrow(), 0, "waiting for the owner is not working"); + s.daemon.supervisor.approval_closed(&s.bot, generation); + assert_eq!(*busy.borrow(), 1); + process.emit(stream::result(false)).await; + s.until(BotState::Idle).await; + assert_eq!(*busy.borrow(), 0); + + s.message("again"); + assert_eq!(*busy.borrow(), 1); + process.exit(1).await; + s.until(BotState::Backoff).await; + assert_eq!(*busy.borrow(), 0, "a dead process works no more"); + s.runtime.process(2).await; + s.until(BotState::Idle).await; + assert_eq!(*busy.borrow(), 0); +} + #[tokio::test(start_paused = true)] async fn sign_in_errors_stop_the_bot_until_the_owner_restarts_it() { let s = setup().await; diff --git a/docs/spec.md b/docs/spec.md index 22da01ca..ff0960e1 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -491,11 +491,14 @@ Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations | Tema | Solução | |---|---| -| Iniciar com o Windows | `botloftd service install` cria **Tarefa Agendada por usuário** ("ao fazer logon", reiniciar em falha a cada 1 min, sem limite de execução, só com o usuário logado). Não usar Windows Service: roda em outra sessão e sem acesso à autenticação do Claude Code do usuário. Subcomandos `service status`, `service restart`, `service uninstall`. | -| Binário instalado | `%LOCALAPPDATA%\Botloft\bin\botloftd.exe` (copiado do sidecar do app) | -| Não suspender | `SetThreadExecutionState(ES_CONTINUOUS \| ES_SYSTEM_REQUIRED)` enquanto houver bot `busy`, se `keep_awake = true` | +| Iniciar com o Windows | `botloftd service install` registra uma **Tarefa Agendada por usuário**, pela API COM do Agendador (as mensagens do `schtasks.exe` são traduzidas e não dá para lê-las). Dois gatilhos: "ao fazer logon" do usuário, que sobe o daemon na hora, e um gatilho de horário com início no passado repetido **a cada 1 min**, que o traz de volta se ele morrer: com `MultipleInstancesPolicy = IgnoreNew`, a repetição não faz nada enquanto o daemon roda. Token interativo e privilégio mínimo (só com o usuário logado, na sessão dele), sem limite de execução, roda na bateria, prioridade 5 (a padrão, 7, passaria "abaixo do normal" para todos os bots). A ação é `\bin\botloftd.exe serve --home `. Não usar Windows Service: roda em outra sessão e sem acesso à autenticação do Claude Code do usuário. Subcomandos `service status`, `service restart`, `service uninstall` (os dados ficam). | +| Reinício da tarefa (testado no Windows 11 25H2) | `RestartOnFailure` **não** reinicia a tarefa quando o processo sai com código de erro, nem numa execução por gatilho de horário nem numa sob demanda. A repetição de um gatilho de logon só começa no próximo logon, não quando a tarefa é registrada. Por isso o gatilho de horário faz o papel de vigia: morto o daemon, ele voltou em 43 s | +| Uma tarefa por pasta de dados | `Botloft` para `%LOCALAPPDATA%\Botloft`; `Botloft-<8 hex do SHA-256 do caminho>` para outra pasta (`--home` ou `BOTLOFT_HOME` de dev), para instalar um daemon de dev sem tocar no real. `--home` vale para todos os subcomandos e vem antes de `BOTLOFT_HOME` | +| Binário instalado | `service install` copia o próprio executável para `\bin\botloftd.exe` e a tarefa roda essa cópia, nunca a do app: assim o instalador do app troca os arquivos dele com o daemon rodando. Um exe em uso não pode ser sobrescrito mas pode ser renomeado, então o antigo vai para `botloftd..old` e é apagado numa instalação seguinte. Se o binário não mudou e o daemon dessa versão já roda pela tarefa, `install` não o reinicia; senão para a tarefa, espera o `/health` sumir, inicia de novo e espera o `/health` com a nova versão (20 s) | +| Janela de console | O manifesto do daemon pede `consoleAllocationPolicy = detached` (Windows 11 24H2 e depois): iniciado pela tarefa, ele não ganha console nem janela; num terminal, continua usando o console do terminal. Em Windows mais antigo, `serve` larga o console se for o único processo nele (`FreeConsole`). O manifesto entra como recurso (`embed-resource`), porque a ferramenta de manifesto do linker não conhece o elemento e avisa a cada build | +| Não suspender | Um power request (`PowerCreateRequest` + `PowerSetRequest(PowerRequestSystemRequired)`, motivo "Botloft bots are working") enquanto houver bot `busy`, se `keep_awake = true`. Bot esperando aprovação não conta. Diferente de `SetThreadExecutionState`, o pedido é de um handle e não de uma thread, então qualquer worker do Tokio o liga e desliga, e `powercfg /requests` o mostra. Não impede a suspensão pedida pelo usuário (tampa, menu Iniciar) | | Processos órfãos | Job Object por bot (seção 7.3) | -| Encerramento | `ctrl_c`, `ctrl_close`, `ctrl_shutdown`, `ctrl_logoff` do Tokio: parar courier, sinalizar bots, flush do banco | +| Encerramento | `ctrl_c`, `ctrl_close`, `ctrl_shutdown`, `ctrl_logoff` do Tokio: parar courier, sinalizar bots, flush do banco. Esses sinais são de console: iniciado pela tarefa, sem console, o daemon não tem garantia de recebê-los, e o Windows encerra o processo no logoff e no desligamento. Isso equivale a um crash, e o sistema já é feito para ele (mensagens gravadas antes de sair, bots mortos pelo Job Object, aprovações abertas expiram no próximo start). Um erro fatal na subida vai para o log, porque ninguém vê o stderr da tarefa | | Caminhos longos | manifesto `longPathAware` no daemon e no app; usar APIs com `\\?\` ao apagar árvores de workspace | | Arquivo em uso | arquivar bot só apaga workspace depois do processo encerrado; retentar exclusão se bloqueado | | Instância única | lock exclusivo em `\botloftd.lock` (`File::try_lock`), solto pelo sistema quando o processo termina, mesmo em crash; se já estiver preso, sair com erro claro. É um lock por pasta de dados, e não um mutex de nome fixo, para o daemon de dev (`BOTLOFT_HOME`) rodar ao lado do instalado | @@ -551,7 +554,7 @@ Layout, como um app de mensagens: |---|---| | `daemon_status` | GET `/health` local; diz se o daemon roda, está parado ou se outro programa ocupa a porta | | `daemon_start` | (M4) inicia o `botloftd.exe` ao lado do executável do app, destacado (sem console, fora do job do app) para seguir vivo quando o app fecha, e espera o `/health` por até 15 s. No M5 o `daemon_install` o substitui | -| `daemon_install` | copia sidecar e roda `botloftd service install` | +| `daemon_install` | roda `botloftd service install` a partir do sidecar (o `botloftd.exe` ao lado do app), que se copia para `\bin` e registra e inicia a tarefa (seção 14) | | `daemon_restart` | `botloftd service restart` | | `read_owner_token` | lê `secrets\owner.token` (só no app local) | | `open_path` | abre uma pasta no Explorer; recusa arquivos, que o Explorer executaria | From 5b635add98363a2ea9486db695e8fd303cf75ea0 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:08:40 -0300 Subject: [PATCH 2/3] feat(app): set Botloft up by itself and speak plainly (M5) The app no longer asks the owner to start anything. When nothing runs, it installs the daemon it ships (`daemon_install`, which runs the sidecar's `botloftd service install`); when the running daemon is older than the app, it updates it the same way. Each check installs or updates at most once, so a failure shows instead of looping. `daemon_start` is gone; `daemon_restart` backs a "Restart Botloft in the background" button while a connection does not come up. The owner never has to know that a daemon, a port or a scheduled task exists: the interface says "Botloft" and "in the background", and keeps technical text (errors, paths, ports) folded under "Details". A missing Claude Code links to its official setup page from the welcome screen and the "Bots can't start" banner. botloftd now prints errors on one line (`what failed: why`), which the app shows as is. --- app/src-tauri/src/daemon.rs | 120 +++++++++----- app/src-tauri/src/lib.rs | 17 +- app/src/App.test.tsx | 33 +++- app/src/dev/preview.ts | 8 +- app/src/features/bots/BotStateBadge.tsx | 10 +- .../features/messages/FailedDeliveries.tsx | 4 +- .../features/onboarding/ClaudeCodeHelp.tsx | 30 ++++ app/src/features/onboarding/Onboarding.tsx | 151 ++++++++++++------ app/src/features/onboarding/Welcome.tsx | 17 +- app/src/features/onboarding/link.test.ts | 75 +++++++-- app/src/features/onboarding/link.ts | 112 +++++++++---- app/src/lib/fakeHost.ts | 41 +++-- app/src/lib/host.ts | 24 ++- app/src/lib/rpc.ts | 4 +- app/src/lib/tauriHost.ts | 3 +- app/src/shell/Workspace.tsx | 5 +- app/src/ui/Details.tsx | 13 ++ crates/botloftd/src/main.rs | 16 +- docs/spec.md | 19 ++- 19 files changed, 512 insertions(+), 190 deletions(-) create mode 100644 app/src/features/onboarding/ClaudeCodeHelp.tsx create mode 100644 app/src/ui/Details.tsx diff --git a/app/src-tauri/src/daemon.rs b/app/src-tauri/src/daemon.rs index faa6ba42..8a8de91d 100644 --- a/app/src-tauri/src/daemon.rs +++ b/app/src-tauri/src/daemon.rs @@ -1,15 +1,19 @@ -//! Finding, checking and starting the local daemon (spec 15.2). +//! Finding, checking and installing the local daemon (spec 15.2). //! //! The app resolves the daemon's data folder the way the daemon does //! (spec 5): `BOTLOFT_HOME`, else `%LOCALAPPDATA%\Botloft`. The port comes //! from `config.toml` in that folder, so a dev daemon with its own //! `BOTLOFT_HOME` is found without extra setup. +//! +//! The app ships `botloftd.exe` next to itself (the sidecar) and installs +//! it with `botloftd service install`, which copies it into the data +//! folder and runs it as a scheduled task (spec 14). use std::io::{Read as _, Write as _}; use std::net::{Ipv4Addr, SocketAddr, TcpStream}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use std::time::{Duration, Instant}; +use std::time::Duration; use serde::{Deserialize, Serialize}; @@ -17,8 +21,8 @@ const HOME_ENV: &str = "BOTLOFT_HOME"; const DEFAULT_PORT: u16 = 45710; const CONNECT_TIMEOUT: Duration = Duration::from_millis(500); const READ_TIMEOUT: Duration = Duration::from_secs(2); -/// How long a freshly started daemon may take to answer `/health`. -const START_TIMEOUT: Duration = Duration::from_secs(15); +/// The daemon this app ships has the app's version (one Cargo workspace). +const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); /// Where the daemon lives. #[derive(Debug, Clone)] @@ -36,6 +40,8 @@ pub enum DaemonStatus { port: u16, version: String, protocol: u32, + /// Older than the daemon this app ships: installing replaces it. + outdated: bool, }, /// Nothing listens on the port. #[serde(rename_all = "camelCase")] @@ -83,6 +89,7 @@ pub fn status(endpoint: &Endpoint) -> DaemonStatus { match health(endpoint.port) { Probe::Healthy(health) => DaemonStatus::Running { port: endpoint.port, + outdated: is_older(&health.version, APP_VERSION), version: health.version, protocol: health.protocol, }, @@ -127,26 +134,65 @@ fn health(port: u16) -> Probe { } } -/// Starts `botloftd.exe serve` from the app's folder, detached so it keeps -/// running after the app closes (spec 1), and waits for it to answer. -pub fn start(endpoint: &Endpoint) -> Result { - if let running @ DaemonStatus::Running { .. } = status(endpoint) { - return Ok(running); +/// Whether version `a` comes before `b` (`major.minor.patch`; a +/// pre-release suffix is ignored). Unreadable versions are never older. +fn is_older(a: &str, b: &str) -> bool { + fn parse(version: &str) -> Option<(u64, u64, u64)> { + let core = version.split(['-', '+']).next()?; + let mut parts = core.split('.').map(|part| part.parse::().ok()); + Some((parts.next()??, parts.next()??, parts.next()??)) } + matches!((parse(a), parse(b)), (Some(a), Some(b)) if a < b) +} + +/// Installs the daemon this app ships and starts it: `botloftd service +/// install` copies it into the data folder, registers the scheduled task +/// and waits for the daemon to answer (spec 14). +pub fn install(endpoint: &Endpoint) -> Result { + run_service(endpoint, "install")?; + Ok(status(endpoint)) +} + +/// Stops the daemon and starts it again from its scheduled task. +pub fn restart(endpoint: &Endpoint) -> Result { + run_service(endpoint, "restart")?; + Ok(status(endpoint)) +} + +/// Runs `botloftd --home service ` without a window and +/// returns its error message if it fails. +fn run_service(endpoint: &Endpoint, command: &str) -> Result<(), String> { let binary = daemon_binary()?; - spawn_detached(&binary).map_err(|err| format!("cannot start {}: {err}", binary.display()))?; - let started = Instant::now(); - loop { - let current = status(endpoint); - if matches!(current, DaemonStatus::Running { .. }) || started.elapsed() > START_TIMEOUT { - return Ok(current); - } - std::thread::sleep(Duration::from_millis(200)); + let mut process = Command::new(&binary); + process + .arg("--home") + .arg(&endpoint.home) + .args(["service", command]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + process.creation_flags(CREATE_NO_WINDOW); + } + let output = process + .output() + .map_err(|err| format!("cannot run {}: {err}", binary.display()))?; + if output.status.success() { + return Ok(()); } + let message = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + Err(if message.is_empty() { + format!("botloftd service {command} failed ({})", output.status) + } else { + message + }) } -/// `botloftd.exe` next to the app: the installer puts it there (M5), and -/// in dev both land in `target\debug`. +/// `botloftd.exe` next to the app: the installer puts it there, and in dev +/// both land in `target\debug`. fn daemon_binary() -> Result { let app = std::env::current_exe().map_err(|err| err.to_string())?; let binary = app.with_file_name(if cfg!(windows) { @@ -164,31 +210,6 @@ fn daemon_binary() -> Result { } } -fn spawn_detached(binary: &Path) -> std::io::Result<()> { - let mut command = Command::new(binary); - command - .arg("serve") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt as _; - const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; - const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - let flags = CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP; - // Leave the app's job, if it runs in one that would kill children - // on close. Jobs that forbid breaking away refuse the flag. - command.creation_flags(flags | CREATE_BREAKAWAY_FROM_JOB); - if command.spawn().is_ok() { - return Ok(()); - } - command.creation_flags(flags); - } - command.spawn().map(drop) -} - /// The owner token the daemon wrote on its first start (spec 13). pub fn owner_token(endpoint: &Endpoint) -> Result { let path = endpoint.home.join("secrets").join("owner.token"); @@ -226,6 +247,17 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn versions_compare_by_number() { + assert!(is_older("0.1.0", "0.2.0")); + assert!(is_older("0.9.3", "0.10.0")); + assert!(is_older("1.2.3-beta.1", "1.2.4")); + assert!(!is_older("0.2.0", "0.2.0")); + assert!(!is_older("0.3.0", "0.2.9")); + assert!(!is_older("dev", "0.2.0")); + assert!(!is_older("0.1", "0.2.0")); + } + #[test] fn a_closed_port_reads_as_stopped() { // Bind and drop to find a port nothing listens on. diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 449c1646..64f0c0cf 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -22,11 +22,17 @@ async fn daemon_status() -> Result { blocking(|| daemon::endpoint().map(|endpoint| daemon::status(&endpoint))).await } -/// Starts the daemon next to the app when it is not running (M4; M5 -/// installs it as a scheduled task instead). +/// Installs the daemon this app ships as a scheduled task and starts it, +/// replacing an older one (spec 14). #[tauri::command] -async fn daemon_start() -> Result { - blocking(|| daemon::start(&daemon::endpoint()?)).await +async fn daemon_install() -> Result { + blocking(|| daemon::install(&daemon::endpoint()?)).await +} + +/// Stops the daemon and starts it again from its scheduled task. +#[tauri::command] +async fn daemon_restart() -> Result { + blocking(|| daemon::restart(&daemon::endpoint()?)).await } #[tauri::command] @@ -85,7 +91,8 @@ pub fn run() { tauri::Builder::default() .invoke_handler(tauri::generate_handler![ daemon_status, - daemon_start, + daemon_install, + daemon_restart, read_owner_token, open_path, open_url diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx index e8aff173..97df36f6 100644 --- a/app/src/App.test.tsx +++ b/app/src/App.test.tsx @@ -1,4 +1,4 @@ -import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, describe, expect, test } from "vitest"; import { App } from "./App"; import type { Client } from "./lib/client"; @@ -69,13 +69,26 @@ describe("app", () => { expect(screen.getByRole("dialog", { name: "New bot" })).toBeDefined(); }); - test("the owner starts the daemon from the app", async () => { + test("the app sets itself up on the first run, without asking", async () => { const host = new FakeHost(); host.status = { state: "stopped", port: 45710, home: "C:\\data\\Botloft" }; renderApp(new FakeBotloft(), host); - fireEvent.click(await screen.findByRole("button", { name: "Start the daemon" })); expect(await screen.findByRole("heading", { name: "Welcome to Botloft" })).toBeDefined(); - expect(host.starts).toBe(1); + expect(host.installs).toEqual(["install"]); + expect(screen.queryByText(/daemon/i)).toBeNull(); + }); + + test("a failed setup explains itself in plain words", async () => { + const host = new FakeHost(); + host.status = { state: "stopped", port: 45710, home: "C:\\data\\Botloft" }; + host.afterInstall = new Error("cannot register the scheduled task Botloft: access denied"); + renderApp(new FakeBotloft(), host); + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("Botloft couldn't start"); + expect(screen.getByText("Details")).toBeDefined(); + host.afterInstall = host.status = { ...host.status }; + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(host.installs).toEqual(["install", "install"]); }); test("archiving a bot asks first", async () => { @@ -105,17 +118,21 @@ describe("app", () => { expect(within(sidebar()).getAllByText("Paused")).toHaveLength(2); }); - test("a missing Claude Code says why bots cannot start", async () => { + test("a missing Claude Code says why bots cannot start and where to get it", async () => { const fake = new FakeBotloft(); + const host = new FakeHost(); fake.addCrew("Ops"); fake.system = { ...fake.system, claudeVersion: null, runtimeError: "claude.exe was not found on PATH.", }; - renderApp(fake); - const banner = await screen.findByText("Bots cannot start"); - expect(banner.closest("[role=alert]")?.textContent).toContain("claude.exe was not found"); + renderApp(fake, host); + const banner = await screen.findByText("Bots can't start"); + const alert = banner.closest("[role=alert]") as HTMLElement; + expect(alert.textContent).toContain("claude.exe was not found"); + fireEvent.click(within(alert).getByRole("button", { name: "How to install Claude Code" })); + await waitFor(() => expect(host.opened).toEqual(["https://code.claude.com/docs/en/setup"])); }); test("a lost connection shows in the title bar", async () => { diff --git a/app/src/dev/preview.ts b/app/src/dev/preview.ts index 99a897e9..dc13ffb4 100644 --- a/app/src/dev/preview.ts +++ b/app/src/dev/preview.ts @@ -35,7 +35,13 @@ export function previewProps(search: string): { host: Host; connect: Connect } { function liveProps(port: number): { host: Host; connect: Connect } { const host = new FakeHost(); - host.status = { state: "running", port, version: "dev", protocol: PROTOCOL_VERSION }; + host.status = { + state: "running", + port, + version: "dev", + protocol: PROTOCOL_VERSION, + outdated: false, + }; host.token = sessionStorage.getItem("botloft.devToken") ?? new Error('Set sessionStorage "botloft.devToken" to the dev daemon\'s owner token.'); diff --git a/app/src/features/bots/BotStateBadge.tsx b/app/src/features/bots/BotStateBadge.tsx index 95b7c066..4d4c2711 100644 --- a/app/src/features/bots/BotStateBadge.tsx +++ b/app/src/features/bots/BotStateBadge.tsx @@ -34,7 +34,7 @@ const views: Record = { hint: "Claude Code is starting.", }, idle: { label: "Idle", tone: "ok", icon: CircleDot, hint: "Ready for work." }, - busy: { label: "Working", tone: "work", icon: Activity, hint: "In the middle of a turn." }, + busy: { label: "Working", tone: "work", icon: Activity, hint: "Working on something." }, needs_approval: { label: "Needs approval", tone: "warn", @@ -42,22 +42,22 @@ const views: Record = { hint: "Waiting for you to allow or deny a tool in its chat.", }, rate_limited: { - label: "Rate limited", + label: "Usage limit", tone: "warn", icon: Hourglass, - hint: "Hit the usage limit; messages wait until it clears.", + hint: "Your Claude plan hit its usage limit; messages wait until it resets.", }, auth_error: { label: "Sign-in needed", tone: "danger", icon: KeyRound, - hint: "Claude Code is not signed in. Sign in, then restart the bot.", + hint: "Claude Code is not signed in. Open Claude Code and sign in, then restart the bot.", }, backoff: { label: "Restarting", tone: "warn", icon: RotateCw, - hint: "The process stopped; it starts again shortly.", + hint: "It stopped unexpectedly; Botloft starts it again shortly.", }, archived: { label: "Archived", tone: "quiet", icon: Archive, hint: "Archived." }, }; diff --git a/app/src/features/messages/FailedDeliveries.tsx b/app/src/features/messages/FailedDeliveries.tsx index 29126f62..e78c9997 100644 --- a/app/src/features/messages/FailedDeliveries.tsx +++ b/app/src/features/messages/FailedDeliveries.tsx @@ -95,8 +95,8 @@ function FailedDialog({ dead, onClose }: { dead: Delivery[]; onClose(): void }) } >

- The daemon stopped trying after several attempts. Retrying puts a message back in line; it - goes out when the bot is ready. + Botloft stopped trying after several attempts. Retrying puts a message back in line; it goes + out when the bot is ready.

    {dead.map((delivery) => { diff --git a/app/src/features/onboarding/ClaudeCodeHelp.tsx b/app/src/features/onboarding/ClaudeCodeHelp.tsx new file mode 100644 index 00000000..a0e4f210 --- /dev/null +++ b/app/src/features/onboarding/ClaudeCodeHelp.tsx @@ -0,0 +1,30 @@ +import { ExternalLink } from "lucide-react"; +import { useHost } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Details } from "../../ui/Details"; +import { attempt } from "../../ui/toast"; + +/** Claude Code's official install page. */ +export const CLAUDE_CODE_SETUP = "https://code.claude.com/docs/en/setup"; + +/** What to do when bots cannot start because Claude Code is missing or unusable. */ +export function ClaudeCodeHelp({ error }: { error: string }) { + const host = useHost(); + return ( +
    +

    + Botloft runs your bots with Claude Code. Install it or update it, open it once to sign in, + and Botloft picks it up within 30 seconds. +

    + +
    {error}
    +
    + ); +} diff --git a/app/src/features/onboarding/Onboarding.tsx b/app/src/features/onboarding/Onboarding.tsx index c62b5d53..bd172525 100644 --- a/app/src/features/onboarding/Onboarding.tsx +++ b/app/src/features/onboarding/Onboarding.tsx @@ -1,92 +1,124 @@ -import { LoaderCircle, Play, RefreshCw } from "lucide-react"; +import { LoaderCircle, RefreshCw, RotateCw } from "lucide-react"; import type { ReactNode } from "react"; import { type StoreApi, useStore } from "zustand"; import { PROTOCOL_VERSION } from "../../lib/protocol.gen"; import { Button } from "../../ui/Button"; import { Callout } from "../../ui/Callout"; +import { Details } from "../../ui/Details"; import type { Link } from "./link"; -/** Shown until the app is connected to the daemon. */ +// Plain words on purpose: the owner never has to know that a daemon, a +// port or a scheduled task exists. Technical text goes under "Details". + +/** Shown until the app is connected to the part that runs the bots. */ export function Onboarding({ link }: { link: StoreApi }) { const current = useStore(link, (state) => state.current); - const { check, start } = link.getState(); - const again = ( - ); let body: ReactNode; switch (current.step) { case "checking": - body = ; + body = ; + break; + case "installing": + body = ( +
    + + {current.action === "install" && ( +

    + Botloft keeps your bots running in the background, even after you close this window, + and starts with Windows. +

    + )} +
    + ); break; case "connecting": body = (
    - - {again} + +
    + + +
    ); break; - case "starting": - body = ; - break; case "connected": body = null; break; case "stopped": body = ( -
    -

    - The daemon runs your bots and keeps them running after this window closes. It is not - running yet. -

    - {current.error && ( - - {current.error} - - {current.home} - - - )} -
    - - {again} -
    -
    + + Botloft runs your bots in the background, and that part didn't start. +
    + {current.error ?? "It is not running."} + {"\n"} + {current.home} +
    +
    + ); + break; + case "outdated": + body = ( + + The part that runs your bots in the background is still on the previous version. +
    + Running version {current.daemonVersion}. {current.error} +
    +
    ); break; case "foreign": body = ( - - Another program listens on 127.0.0.1:{current.port}. Close it, or set another{" "} - port in the daemon's config.toml. - + + Botloft needs port {current.port} on this computer, and another program is using it. Close + that program and try again. +
    + 127.0.0.1:{current.port} answers but is not Botloft. To use another port, set{" "} + port in Botloft's config.toml. +
    +
    ); break; case "mismatch": body = ( - - The running daemon ({current.daemonVersion}) speaks protocol {current.daemonProtocol}, and - this app speaks protocol {PROTOCOL_VERSION}. Install matching versions of both. - + + The Botloft running in the background is newer than this app. Install the latest version + of Botloft. +
    + Running version {current.daemonVersion}, protocol {current.daemonProtocol}. This app + speaks protocol {PROTOCOL_VERSION}. +
    +
    ); break; case "refused": body = ( - - {current.reason} - + + Try again. If this keeps happening, reinstall Botloft. +
    {current.reason}
    +
    ); break; case "error": body = ( - - {current.message} - + + Try again. If this keeps happening, reinstall Botloft. +
    {current.message}
    +
    ); break; } @@ -102,6 +134,33 @@ export function Onboarding({ link }: { link: StoreApi }) { ); } +const INSTALLING = { + install: "Getting Botloft ready…", + update: "Updating Botloft…", + restart: "Restarting Botloft…", +} as const; + +function Problem({ + title, + action, + children, +}: { + title: string; + action: ReactNode; + children: ReactNode; +}) { + return ( +
    +
    + + {children} + +
    + {action} +
    + ); +} + function Waiting({ text }: { text: string }) { return (

    diff --git a/app/src/features/onboarding/Welcome.tsx b/app/src/features/onboarding/Welcome.tsx index daf6c862..4094cc4a 100644 --- a/app/src/features/onboarding/Welcome.tsx +++ b/app/src/features/onboarding/Welcome.tsx @@ -3,13 +3,12 @@ import { type ReactNode, useState } from "react"; import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; import { CrewDialog } from "../crews/CrewDialog"; +import { ClaudeCodeHelp } from "./ClaudeCodeHelp"; /** First run: what is ready, what is not, and the first crew. */ export function Welcome() { const system = useApp((state) => state.system); - const connection = useApp((state) => state.connection); const [creating, setCreating] = useState(false); - const daemonVersion = connection.kind === "open" ? connection.daemonVersion : null; let claude: ReactNode; if (!system || (system.claudeVersion === null && system.runtimeError === null)) { @@ -19,7 +18,7 @@ export function Welcome() { } /> ); } else { @@ -35,7 +34,11 @@ export function Welcome() { folder. Start with one crew and a bot or two.

      - + {claude}
    @@ -61,7 +64,7 @@ function Check({ }: { state: "ok" | "bad" | "pending"; title: string; - detail: string; + detail: ReactNode; }) { const Icon = state === "ok" ? CircleCheck : state === "bad" ? CircleX : LoaderCircle; const tone = state === "ok" ? "text-ok" : state === "bad" ? "text-danger" : "text-muted"; @@ -79,9 +82,9 @@ function Check({ : {state === "ok" ? "ready" : state === "bad" ? "not ready" : "checking"}

    -

    +

    {detail} -

    +
    ); diff --git a/app/src/features/onboarding/link.test.ts b/app/src/features/onboarding/link.test.ts index 9a8d6272..21f5a27c 100644 --- a/app/src/features/onboarding/link.test.ts +++ b/app/src/features/onboarding/link.test.ts @@ -1,8 +1,7 @@ import { describe, expect, test } from "vitest"; import type { Client } from "../../lib/client"; import { FakeBotloft } from "../../lib/fake"; -import { FakeHost } from "../../lib/fakeHost"; -import { PROTOCOL_VERSION } from "../../lib/protocol.gen"; +import { FakeHost, running } from "../../lib/fakeHost"; import { type Connect, createLink } from "./link"; function setup() { @@ -20,40 +19,86 @@ function setup() { describe("link to the daemon", () => { test("a running daemon connects with the owner token", async () => { const { host, link, connections } = setup(); - host.status = { state: "running", port: 45799, version: "0.1.0", protocol: PROTOCOL_VERSION }; + host.status = running({ port: 45799 }); await link.getState().check(); expect(link.getState().current.step).toBe("connected"); expect(connections).toEqual([{ port: 45799, token: "a".repeat(64) }]); }); - test("a stopped daemon waits for the owner to start it", async () => { + test("a stopped daemon is installed without asking", async () => { const { host, link } = setup(); host.status = { state: "stopped", port: 45710, home: "C:\\data\\Botloft" }; await link.getState().check(); - expect(link.getState().current).toEqual({ - step: "stopped", - port: 45710, - home: "C:\\data\\Botloft", - error: null, - }); - await link.getState().start(); - expect(host.starts).toBe(1); + expect(host.installs).toEqual(["install"]); expect(link.getState().current.step).toBe("connected"); }); test("a daemon that does not come up says so", async () => { const { host, link } = setup(); host.status = { state: "stopped", port: 45710, home: "C:\\data" }; - host.afterStart = host.status; - await link.getState().start(); + host.afterInstall = host.status; + await link.getState().check(); + expect(host.installs).toEqual(["install"]); const current = link.getState().current; expect(current.step).toBe("stopped"); expect(current.step === "stopped" && current.error).toMatch(/did not answer/); }); + test("a failed install shows its error once, with a way to try again", async () => { + const { host, link } = setup(); + host.status = { state: "stopped", port: 45710, home: "C:\\data" }; + host.afterInstall = new Error("cannot register the scheduled task Botloft: access denied"); + await link.getState().check(); + expect(link.getState().current).toEqual({ + step: "stopped", + port: 45710, + home: "C:\\data", + error: "cannot register the scheduled task Botloft: access denied", + }); + }); + + test("an older daemon is updated before connecting", async () => { + const { host, link, connections } = setup(); + host.status = running({ version: "0.0.9", protocol: 1, outdated: true }); + await link.getState().check(); + expect(host.installs).toEqual(["install"]); + expect(link.getState().current.step).toBe("connected"); + expect(connections).toHaveLength(1); + }); + + test("an update that fails is shown once, without a loop", async () => { + const { host, link, connections } = setup(); + host.status = running({ version: "0.0.9", outdated: true }); + host.afterInstall = new Error("the daemon did not stop"); + await link.getState().check(); + expect(link.getState().current).toEqual({ + step: "outdated", + daemonVersion: "0.0.9", + error: "the daemon did not stop", + }); + host.afterInstall = running({ version: "0.0.9", outdated: true }); + await link.getState().install(); + expect(host.installs).toEqual(["install", "install"]); + expect(link.getState().current).toEqual({ + step: "outdated", + daemonVersion: "0.0.9", + error: "The daemon still reports version 0.0.9.", + }); + expect(connections).toHaveLength(0); + }); + + test("restarting the daemon reconnects", async () => { + const { host, link, connections } = setup(); + await link.getState().check(); + await link.getState().restart(); + expect(host.installs).toEqual(["restart"]); + expect(link.getState().current.step).toBe("connected"); + expect(connections).toHaveLength(2); + }); + test("another protocol version is not spoken to", async () => { const { host, link, connections } = setup(); - host.status = { state: "running", port: 45710, version: "9.0.0", protocol: 9 }; + host.status = running({ version: "9.0.0", protocol: 9 }); await link.getState().check(); expect(link.getState().current).toEqual({ step: "mismatch", diff --git a/app/src/features/onboarding/link.ts b/app/src/features/onboarding/link.ts index a1f4528e..08f1563f 100644 --- a/app/src/features/onboarding/link.ts +++ b/app/src/features/onboarding/link.ts @@ -1,5 +1,8 @@ -// Getting from "app opened" to "connected to the daemon": check that it -// runs, offer to start it, read the owner token and say hello (spec 15.1). +// Getting from "app opened" to "connected to the daemon" without asking +// the owner anything: install it when it does not run, keep it at the +// version this app ships, read the owner token and say hello (spec 15.1). +// Each run installs or updates at most once, so a failure shows instead +// of looping. import { createStore, type StoreApi } from "zustand/vanilla"; import { errorText } from "../../lib/api"; @@ -9,12 +12,16 @@ import { PROTOCOL_VERSION } from "../../lib/protocol.gen"; export type LinkStep = | { step: "checking" } + /** Not running, and installing it did not help. */ | { step: "stopped"; port: number; home: string; error: string | null } - | { step: "starting" } + /** Installing, updating to this app's version, or restarting the daemon. */ + | { step: "installing"; action: "install" | "update" | "restart" } /** Another program holds the daemon's port. */ | { step: "foreign"; port: number } - /** The daemon speaks another protocol version. */ + /** The daemon speaks another protocol version and is not older than the app. */ | { step: "mismatch"; daemonProtocol: number; daemonVersion: string } + /** The daemon is older than this app and updating it failed. */ + | { step: "outdated"; daemonVersion: string; error: string } | { step: "error"; message: string } | { step: "connecting"; api: Client } | { step: "connected"; api: Client } @@ -23,14 +30,18 @@ export type LinkStep = export interface Link { current: LinkStep; - /** Checks the daemon again from the start. */ + /** Checks the daemon again from the start, installing it if needed. */ check(): Promise; - /** Starts the daemon, then connects. */ - start(): Promise; + /** Installs the daemon as a scheduled task and starts it, then connects. */ + install(): Promise; + /** Restarts the daemon, then connects. */ + restart(): Promise; } export type Connect = (port: number, token: string) => Client; +const LATE = "It started but did not answer in time. Its log is in the logs folder."; + export function createLink(host: Host, connect: Connect): StoreApi { let api: Client | null = null; let generation = 0; @@ -73,18 +84,41 @@ export function createLink(host: Host, connect: Connect): StoreApi { follow(); }; - const settle = async (status: DaemonStatus, run: number, error: string | null = null) => { + /** Moves on from a status: installs a stopped daemon and updates an older one. */ + const settle = async ( + status: DaemonStatus, + run: number, + { error = null, mayChange = true }: { error?: string | null; mayChange?: boolean } = {}, + ): Promise => { if (run !== generation) { return; } switch (status.state) { case "stopped": + if (mayChange) { + await change(run, "install", () => host.installDaemon()); + return; + } set({ current: { step: "stopped", port: status.port, home: status.home, error } }); return; case "foreign": set({ current: { step: "foreign", port: status.port } }); return; case "running": + if (status.outdated) { + if (!mayChange) { + set({ + current: { + step: "outdated", + daemonVersion: status.version, + error: error ?? `The daemon still reports version ${status.version}.`, + }, + }); + return; + } + await change(run, "update", () => host.installDaemon()); + return; + } if (status.protocol !== PROTOCOL_VERSION) { set({ current: { @@ -99,12 +133,44 @@ export function createLink(host: Host, connect: Connect): StoreApi { } }; + /** Installs, updates or restarts, then settles on what runs afterwards. */ + const change = async ( + run: number, + action: "install" | "update" | "restart", + work: () => Promise, + ) => { + set({ current: { step: "installing", action } }); + let status: DaemonStatus; + let error: string | null = null; + try { + status = await work(); + if (status.state === "stopped") { + error = LATE; + } + } catch (failure) { + error = errorText(failure); + try { + status = await host.daemonStatus(); + } catch (again) { + if (run === generation) { + set({ current: { step: "error", message: errorText(again) } }); + } + return; + } + } + await settle(status, run, { error, mayChange: false }); + }; + + const begin = () => { + generation += 1; + drop(); + return generation; + }; + return { current: { step: "checking" }, check: async () => { - generation += 1; - const run = generation; - drop(); + const run = begin(); set({ current: { step: "checking" } }); try { await settle(await host.daemonStatus(), run); @@ -114,23 +180,13 @@ export function createLink(host: Host, connect: Connect): StoreApi { } } }, - start: async () => { - generation += 1; - const run = generation; - drop(); - set({ current: { step: "starting" } }); - try { - const status = await host.startDaemon(); - const late = - status.state === "stopped" - ? "The daemon did not answer in time. Its log is in the logs folder of its data folder." - : null; - await settle(status, run, late); - } catch (error) { - if (run === generation) { - set({ current: { step: "error", message: errorText(error) } }); - } - } + install: async () => { + const run = begin(); + await change(run, "install", () => host.installDaemon()); + }, + restart: async () => { + const run = begin(); + await change(run, "restart", () => host.restartDaemon()); }, }; }); diff --git a/app/src/lib/fakeHost.ts b/app/src/lib/fakeHost.ts index bccb494b..e30e068e 100644 --- a/app/src/lib/fakeHost.ts +++ b/app/src/lib/fakeHost.ts @@ -3,23 +3,27 @@ import type { DaemonStatus, Host } from "./host"; import { PROTOCOL_VERSION } from "./protocol.gen"; -export class FakeHost implements Host { - status: DaemonStatus = { - state: "running", - port: 45710, - version: "0.1.0", - protocol: PROTOCOL_VERSION, - }; - /** What `startDaemon` leaves behind. */ - afterStart: DaemonStatus = { +/** A current daemon on the default port. */ +export function running( + changes: Partial> = {}, +): DaemonStatus { + return { state: "running", port: 45710, version: "0.1.0", protocol: PROTOCOL_VERSION, + outdated: false, + ...changes, }; +} + +export class FakeHost implements Host { + status: DaemonStatus = running(); + /** What `installDaemon` and `restartDaemon` leave behind, or their error. */ + afterInstall: DaemonStatus | Error = running(); token: string | Error = "a".repeat(64); readonly opened: string[] = []; - starts = 0; + readonly installs: ("install" | "restart")[] = []; maximized = false; closed = false; attention = false; @@ -28,9 +32,20 @@ export class FakeHost implements Host { return Promise.resolve(this.status); } - startDaemon(): Promise { - this.starts += 1; - this.status = this.afterStart; + installDaemon(): Promise { + return this.settle("install"); + } + + restartDaemon(): Promise { + return this.settle("restart"); + } + + private settle(action: "install" | "restart"): Promise { + this.installs.push(action); + if (this.afterInstall instanceof Error) { + return Promise.reject(this.afterInstall); + } + this.status = this.afterInstall; return Promise.resolve(this.status); } diff --git a/app/src/lib/host.ts b/app/src/lib/host.ts index 9b26d4f5..8ec0a0da 100644 --- a/app/src/lib/host.ts +++ b/app/src/lib/host.ts @@ -1,9 +1,17 @@ // What the app needs from the native shell (spec 15.2): the daemon's -// status, starting it, the owner token, Explorer and the window. The Tauri -// implementation lives in `tauriHost.ts`; tests pass a fake. +// status, installing and restarting it, the owner token, Explorer and the +// window. The Tauri implementation lives in `tauriHost.ts`; tests pass a +// fake. export type DaemonStatus = - | { state: "running"; port: number; version: string; protocol: number } + | { + state: "running"; + port: number; + version: string; + protocol: number; + /** Older than the daemon this app ships; installing replaces it. */ + outdated: boolean; + } /** Nothing listens on the port. `home` is the daemon's data folder. */ | { state: "stopped"; port: number; home: string } /** Something that is not botloftd holds the port. */ @@ -22,8 +30,14 @@ export interface AppWindow { export interface Host { daemonStatus(): Promise; - /** Starts the daemon if needed and waits for it to answer. */ - startDaemon(): Promise; + /** + * Installs the daemon this app ships as a scheduled task that starts at + * logon, starts it and waits for it to answer (spec 14). Replaces an + * older daemon; leaves the same version running. + */ + installDaemon(): Promise; + /** Stops the daemon and starts it again from its scheduled task. */ + restartDaemon(): Promise; readOwnerToken(): Promise; /** Opens a folder in Explorer. */ openPath(path: string): Promise; diff --git a/app/src/lib/rpc.ts b/app/src/lib/rpc.ts index 5f9b7043..9ce2a14c 100644 --- a/app/src/lib/rpc.ts +++ b/app/src/lib/rpc.ts @@ -116,7 +116,7 @@ export class RpcConnection { request(method: M, ...params: ParamsArg): Promise> { return new Promise>((resolve, reject) => { if (this.state.kind === "closed" || this.state.kind === "failed") { - reject(new RpcError(ClientErrorCode.closed, "not connected to the daemon")); + reject(new RpcError(ClientErrorCode.closed, "not connected to Botloft")); return; } const pending: Pending = { @@ -251,7 +251,7 @@ export class RpcConnection { this.inflight.delete(id); clearTimeout(pending.timer); pending.reject( - new RpcError(ClientErrorCode.connectionLost, "lost the connection to the daemon"), + new RpcError(ClientErrorCode.connectionLost, "lost the connection to Botloft"), ); } if (this.state.kind === "failed" || this.state.kind === "closed") { diff --git a/app/src/lib/tauriHost.ts b/app/src/lib/tauriHost.ts index efdab5e3..c3c21b67 100644 --- a/app/src/lib/tauriHost.ts +++ b/app/src/lib/tauriHost.ts @@ -29,7 +29,8 @@ export function tauriHost(): Host { let overlay: Promise | undefined; return { daemonStatus: () => invoke("daemon_status"), - startDaemon: () => invoke("daemon_start"), + installDaemon: () => invoke("daemon_install"), + restartDaemon: () => invoke("daemon_restart"), readOwnerToken: () => invoke("read_owner_token"), openPath: (path) => invoke("open_path", { path }), openUrl: (url) => invoke("open_url", { url }), diff --git a/app/src/shell/Workspace.tsx b/app/src/shell/Workspace.tsx index 4d4e181c..ac2c1668 100644 --- a/app/src/shell/Workspace.tsx +++ b/app/src/shell/Workspace.tsx @@ -8,6 +8,7 @@ import { BotView } from "../features/bots/BotView"; import { CrewView } from "../features/crews/CrewView"; import { Sidebar } from "../features/crews/Sidebar"; import { FailedDeliveries } from "../features/messages/FailedDeliveries"; +import { ClaudeCodeHelp } from "../features/onboarding/ClaudeCodeHelp"; import { Welcome } from "../features/onboarding/Welcome"; import { useApp } from "../store/context"; import { Callout } from "../ui/Callout"; @@ -41,8 +42,8 @@ export function Workspace() {
    {runtimeError && (
    - - {runtimeError} The daemon checks again every 30 seconds. + +
    )} diff --git a/app/src/ui/Details.tsx b/app/src/ui/Details.tsx new file mode 100644 index 00000000..f11dac0e --- /dev/null +++ b/app/src/ui/Details.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; + +/** Technical text for troubleshooting, folded away under the plain message. */ +export function Details({ children }: { children: ReactNode }) { + return ( +
    + Details +
    + {children} +
    +
    + ); +} diff --git a/crates/botloftd/src/main.rs b/crates/botloftd/src/main.rs index 3313214c..f85bfa9d 100644 --- a/crates/botloftd/src/main.rs +++ b/crates/botloftd/src/main.rs @@ -2,6 +2,7 @@ use std::net::Ipv4Addr; use std::path::PathBuf; +use std::process::ExitCode; use std::sync::Arc; use anyhow::Context; @@ -55,8 +56,19 @@ enum ServiceCommand { Uninstall, } -fn main() -> anyhow::Result<()> { - let cli = Cli::parse(); +/// Errors go to stderr on one line (`what failed: why`), which the app +/// shows as is when it runs `service install`. +fn main() -> ExitCode { + match dispatch(Cli::parse()) { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("{err:#}"); + ExitCode::FAILURE + } + } +} + +fn dispatch(cli: Cli) -> anyhow::Result<()> { let home = paths::resolve_home(cli.home.as_deref()).context("cannot resolve the data folder")?; match cli.command { diff --git a/docs/spec.md b/docs/spec.md index ff0960e1..28e76412 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -552,10 +552,9 @@ Layout, como um app de mensagens: | Comando | Função | |---|---| -| `daemon_status` | GET `/health` local; diz se o daemon roda, está parado ou se outro programa ocupa a porta | -| `daemon_start` | (M4) inicia o `botloftd.exe` ao lado do executável do app, destacado (sem console, fora do job do app) para seguir vivo quando o app fecha, e espera o `/health` por até 15 s. No M5 o `daemon_install` o substitui | -| `daemon_install` | roda `botloftd service install` a partir do sidecar (o `botloftd.exe` ao lado do app), que se copia para `\bin` e registra e inicia a tarefa (seção 14) | -| `daemon_restart` | `botloftd service restart` | +| `daemon_status` | GET `/health` local; diz se o daemon roda, está parado ou se outro programa ocupa a porta. Quando o daemon roda, diz também se ele é `outdated`: versão menor que a do app, que é a do sidecar (um workspace Cargo só). Pré-release é ignorado; versão ilegível nunca é antiga | +| `daemon_install` | roda `botloftd --home service install` a partir do sidecar (o `botloftd.exe` ao lado do app), sem janela, e espera: ele se copia para `\bin`, registra e inicia a tarefa e espera o `/health` (seção 14). Um erro volta na mensagem de uma linha que o daemon escreve no stderr. Substitui o `daemon_start` do M4, que iniciava o daemon destacado ao lado do app | +| `daemon_restart` | `botloftd --home service restart`, do mesmo jeito | | `read_owner_token` | lê `secrets\owner.token` (só no app local) | | `open_path` | abre uma pasta no Explorer; recusa arquivos, que o Explorer executaria | | `open_url` | abre no navegador padrão um link de uma resposta do bot; só `http` e `https`, porque qualquer outro esquema pode iniciar um programa. Seguir o link dentro do app trocaria a janela pela página | @@ -563,6 +562,18 @@ Layout, como um app de mensagens: O app acha o daemon como o daemon acha a si mesmo (seção 5): `BOTLOFT_HOME` ou `%LOCALAPPDATA%\Botloft`, com a porta lida do `config.toml` dessa pasta (45710 se ausente). Um daemon de dev com seu próprio `BOTLOFT_HOME` é encontrado sem configuração extra. A CSP libera `ws://127.0.0.1:*` pelo mesmo motivo. +Onboarding (M5): **configuração sem perguntas e sem jargão.** O dono não precisa saber que existe um daemon, uma porta ou uma tarefa agendada; a interface nunca usa essas palavras. Fala de "Botloft" e de "rodar em segundo plano", e o texto técnico (erro do daemon, caminho, porta) fica dobrado sob "Details". + +- Nada rodando: o app chama `daemon_install` sozinho ("Getting Botloft ready…", com uma linha dizendo que o Botloft segue rodando em segundo plano depois que a janela fecha e inicia com o Windows). Se falhar, "Botloft couldn't start" com "Try again". +- Daemon `outdated`: o app o atualiza sozinho ("Updating Botloft…"), porque app e daemon são distribuídos juntos e quem atualizou o app espera o daemon novo. Os bots em turno são interrompidos e retomam a sessão (7.3). Se falhar ou a versão não mudar, "Botloft couldn't finish updating" com "Try again". +- Cada verificação instala ou atualiza no máximo uma vez: uma falha aparece em vez de virar loop. +- Daemon mais novo que o app com outro protocolo: pede para instalar a versão mais recente do Botloft. +- Porta ocupada por outro programa: diz que outro programa está no caminho; porta e `config.toml` ficam em "Details". +- Conectando sem sucesso: "Try again" e "Restart Botloft in the background" (`daemon_restart`). +- Claude Code ausente ou inutilizável (`runtimeError`), na tela de boas-vindas e no aviso "Bots can't start": explica que os bots rodam no Claude Code, oferece "How to install Claude Code" (abre `https://code.claude.com/docs/en/setup`) e diz que o Botloft percebe sozinho em até 30 s; o erro vai em "Details". + +Em dev, `pnpm tauri dev` usa o `target\debug\botloftd.exe` como sidecar e instala uma tarefa própria da pasta de dev (seção 14). Quem prefere um daemon em primeiro plano roda `cargo run -p botloftd -- serve` antes de abrir o app. + ### 15.3 Direção visual Ferramenta de trabalho densa e calma: tipografia forte, grid firme, estados dos bots legíveis de longe (cor + ícone + texto, nunca só cor). Sem gradiente, sem sombra pesada, sem visual de template. Tema escuro e claro. Barra de título própria (`decorations: false`) com controles de janela do Windows. From fb3d46212e726ce4c58660ac4b9f5d1b0bd79cd0 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:11:19 -0300 Subject: [PATCH 3/3] feat(app): NSIS installer with the daemon inside (M5) `pnpm bundle` builds botloftd for release, copies it where Tauri looks for the sidecar and runs `tauri build` with tauri.bundle.conf.json, which turns on `externalBin` and the per-user NSIS installer. It stays out of tauri.conf.json because tauri-build copies `externalBin` in dev and in clippy too, which would demand the release sidecar on every build and overwrite target\debug\botloftd.exe. The installer never touches the running daemon: the daemon runs from its own copy in the data folder, so the sidecar file is never in use. Its uninstall hook runs `botloftd service uninstall` on a real uninstall and does nothing when an update runs the old uninstaller with /UPDATE; the app updates the daemon when it next opens. Bots and data stay. The app's exe is now Botloft.exe and its manifest adds longPathAware to Tauri's default. The README no longer describes the ConPTY runtime. --- .gitignore | 2 + CLAUDE.md | 3 +- README.md | 56 +++++++++++++++++++--------- app/package.json | 1 + app/scripts/sidecar.mjs | 26 +++++++++++++ app/src-tauri/build.rs | 6 ++- app/src-tauri/tauri.bundle.conf.json | 12 ++++++ app/src-tauri/tauri.conf.json | 1 + app/src-tauri/windows/app.manifest | 21 +++++++++++ app/src-tauri/windows/hooks.nsh | 13 +++++++ docs/spec.md | 11 ++++++ 11 files changed, 133 insertions(+), 19 deletions(-) create mode 100644 app/scripts/sidecar.mjs create mode 100644 app/src-tauri/tauri.bundle.conf.json create mode 100644 app/src-tauri/windows/app.manifest create mode 100644 app/src-tauri/windows/hooks.nsh diff --git a/.gitignore b/.gitignore index f0d8e21a..873940ff 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ # App node_modules/ /app/dist/ +# Sidecar copied in by app/scripts/sidecar.mjs before `pnpm bundle` +/app/src-tauri/binaries/ *.local # Local dev state (BOTLOFT_HOME, dev config) diff --git a/CLAUDE.md b/CLAUDE.md index cdf0cfc5..39ae825a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,9 +39,10 @@ cargo test -p botloft-core export_bindings # App cd app pnpm install -pnpm tauri dev # o app acha o daemon pelo BOTLOFT_HOME e o inicia (target\debug\botloftd.exe) +pnpm tauri dev # o app acha o daemon pelo BOTLOFT_HOME; "Start the daemon" instala target\debug\botloftd.exe como tarefa da pasta de dev pnpm dev # só a UI num navegador comum, com o FakeBotloft (src/dev/preview.ts) pnpm check # tsc --noEmit + biome check + vitest run +pnpm bundle # instalador NSIS com o sidecar (spec 15.4), em target\release\bundle\nsis ``` Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon e no app, para não tocar na instalação real em `%LOCALAPPDATA%\Botloft`. O `config.toml` de dev fica dentro dessa pasta: o app lê a porta de lá. diff --git a/README.md b/README.md index 2dcca86f..fae20cfa 100644 --- a/README.md +++ b/README.md @@ -3,20 +3,26 @@ Always-on Claude Code crews for Windows. Botloft keeps groups of persistent Claude Code bots ("crews") running on your machine and lets -them hand work to each other. A Rust daemon, `botloftd`, runs each bot in its own ConPTY, stores -everything in SQLite and delivers messages through Claude Code's native inbox. A Tauri + React -desktop app shows the live terminals and controls the daemon over JSON-RPC. +them hand work to each other. A Rust daemon, `botloftd`, runs each bot as headless Claude Code, +stores everything in SQLite and delivers messages between bots durably. A Tauri + React desktop +app shows each bot as a chat: you talk to it, send it images and files, and approve what it asks +to do. -> **Status: pre-alpha.** The repository is at milestone M0 (foundation): the workspace, an empty -> app and CI. Nothing runs bots yet. The design lives in [`docs/spec.md`](docs/spec.md). +> **Status: pre-alpha.** Milestones M0 to M4.1 are done: the daemon, the chat, messages and +> tasks between bots. M5 (distribution) is in progress. The design lives in +> [`docs/spec.md`](docs/spec.md). ## How it works -- **The daemon is the source of truth.** Closing the app never stops a bot. -- **Claude Code is the runtime.** Each bot is a real `claude` session with its own workspace, - memory (`CLAUDE.md`) and permission prompts. There is no custom agent SDK. -- **Terminal and messages are separate channels.** What you type goes through the PTY; messages - from other bots arrive through Claude Code's inbox and are never injected into the terminal. +- **The daemon is the source of truth.** Closing the app never stops a bot. The daemon runs as a + scheduled task of your Windows user: it starts when you sign in and comes back within a minute + if it dies. +- **Claude Code is the runtime.** Each bot is a real `claude -p` session with its own workspace, + memory (`CLAUDE.md`) and conversation, talking `stream-json` over stdin and stdout. There is no + custom agent SDK. +- **One conversation per bot.** Your messages, messages from other bots and notices from the + daemon all reach the bot in order. What it does comes back as a chat: replies, tool use and + permission requests you allow or deny. - **Durable before delivered.** Every message is written to SQLite before it is sent and is retried until it is delivered or marked dead. - **Local only.** The daemon listens on `127.0.0.1`. No cloud services, no telemetry. @@ -34,7 +40,7 @@ docs/ specification and ADRs ## Development Requirements: Windows 10 or 11, Rust stable (MSVC toolchain), Node.js 24 and pnpm. Running real -bots (milestone M2 onwards) also needs Claude Code installed and signed in. +bots also needs Claude Code (the native `claude.exe`) installed and signed in. ```powershell # Daemon and libraries @@ -48,16 +54,31 @@ cd app pnpm install pnpm tauri dev pnpm check # typecheck + Biome + Vitest +pnpm bundle # NSIS installer with the daemon inside ``` -Set `BOTLOFT_HOME=.dev\home` during development so the daemon never touches a real installation -in `%LOCALAPPDATA%\Botloft`. +Set `BOTLOFT_HOME` to an absolute path such as `$PWD\.dev\home` during development so the daemon +never touches a real installation in `%LOCALAPPDATA%\Botloft`. + +The daemon manages its own scheduled task: + +```powershell +botloftd service install # copy botloftd into the data folder, start it now and at every logon +botloftd service status +botloftd service restart +botloftd service uninstall # stop it and remove the task; bots and data stay +``` + +Each data folder gets its own task, so a dev daemon installed with `BOTLOFT_HOME` (or `--home`) +never replaces the real one. ## Security model - The daemon binds to `127.0.0.1` only. The app authenticates with an owner token stored under `%LOCALAPPDATA%\Botloft\secrets`, readable only by your Windows user. -- Each bot gets its own token per process start; the database keeps only its SHA-256 hash. +- Each bot gets its own token per process start; the daemon keeps only its SHA-256 hash. +- Bots ask in their chat before they run commands or edit files, and wait for you to allow or + deny each request. - **Botloft is not a sandbox.** Every bot runs as your Windows user, so isolation between bots is cooperative: a bot can read other bots' workspaces if it decides to. Give bots only the permissions you would give Claude Code directly. @@ -68,10 +89,11 @@ in `%LOCALAPPDATA%\Botloft`. |---|---| | M0 | Workspace, empty app, CI | | M1 | Daemon base: config, storage, RPC, crews and bots | -| M2 | Runtime: ConPTY, supervisor, hooks, terminal replay | +| M2 | Runtime: supervisor, restarts with backoff, Job Objects | | M3 | Messaging: durable delivery, bot tools, tasks between bots | -| M4 | Desktop app: onboarding, crews, bots, terminals, timeline | -| M5 | Distribution: start with Windows, installer, updater | +| M4 | Desktop app: onboarding, crews, bots, timeline | +| M4.1 | Headless bots with a chat, approvals, attachments | +| M5 | Distribution: start with Windows, keep awake, installer, updater | ## License diff --git a/app/package.json b/app/package.json index 0f1c2141..0fdb9f03 100644 --- a/app/package.json +++ b/app/package.json @@ -10,6 +10,7 @@ "build": "pnpm typecheck && vite build", "preview": "vite preview", "tauri": "tauri", + "bundle": "node scripts/sidecar.mjs && tauri build --config src-tauri/tauri.bundle.conf.json", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json", "lint": "biome check .", "format": "biome check --write .", diff --git a/app/scripts/sidecar.mjs b/app/scripts/sidecar.mjs new file mode 100644 index 00000000..e345f7aa --- /dev/null +++ b/app/scripts/sidecar.mjs @@ -0,0 +1,26 @@ +// Builds botloftd for release and puts it where `tauri build` looks for the +// sidecar: src-tauri/binaries/botloftd-.exe (spec 15.4). +// The installer then ships it next to the app as botloftd.exe. + +import { execFileSync } from "node:child_process"; +import { copyFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const app = join(dirname(fileURLToPath(import.meta.url)), ".."); +const root = join(app, ".."); +const target = process.env.CARGO_TARGET_DIR ?? join(root, "target"); +const exe = process.platform === "win32" ? ".exe" : ""; + +const rustc = execFileSync("rustc", ["-vV"], { encoding: "utf8" }); +const triple = rustc.match(/^host: (\S+)$/m)?.[1]; +if (!triple) { + throw new Error("rustc -vV did not print the host triple"); +} + +execFileSync("cargo", ["build", "--release", "-p", "botloftd"], { cwd: root, stdio: "inherit" }); +const binaries = join(app, "src-tauri", "binaries"); +mkdirSync(binaries, { recursive: true }); +const sidecar = join(binaries, `botloftd-${triple}${exe}`); +copyFileSync(join(target, "release", `botloftd${exe}`), sidecar); +console.log(`sidecar: ${sidecar}`); diff --git a/app/src-tauri/build.rs b/app/src-tauri/build.rs index d860e1e6..134c72a5 100644 --- a/app/src-tauri/build.rs +++ b/app/src-tauri/build.rs @@ -1,3 +1,7 @@ fn main() { - tauri_build::build() + println!("cargo:rerun-if-changed=windows/app.manifest"); + let windows = + tauri_build::WindowsAttributes::new().app_manifest(include_str!("windows/app.manifest")); + tauri_build::try_build(tauri_build::Attributes::new().windows_attributes(windows)) + .expect("failed to run tauri-build"); } diff --git a/app/src-tauri/tauri.bundle.conf.json b/app/src-tauri/tauri.bundle.conf.json new file mode 100644 index 00000000..775c0d51 --- /dev/null +++ b/app/src-tauri/tauri.bundle.conf.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "externalBin": ["binaries/botloftd"], + "windows": { + "nsis": { + "installMode": "currentUser", + "installerHooks": "./windows/hooks.nsh" + } + } + } +} diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index a9a5512c..ea2262f4 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -1,6 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Botloft", + "mainBinaryName": "Botloft", "version": "0.1.0", "identifier": "io.github.httpsphl.botloft", "build": { diff --git a/app/src-tauri/windows/app.manifest b/app/src-tauri/windows/app.manifest new file mode 100644 index 00000000..0f129031 --- /dev/null +++ b/app/src-tauri/windows/app.manifest @@ -0,0 +1,21 @@ + + + + + + + + + + + true + + + diff --git a/app/src-tauri/windows/hooks.nsh b/app/src-tauri/windows/hooks.nsh new file mode 100644 index 00000000..16c1e0d3 --- /dev/null +++ b/app/src-tauri/windows/hooks.nsh @@ -0,0 +1,13 @@ +; Installer hooks (spec 15.4). The daemon runs from its own copy in the +; data folder as a scheduled task, so installing or updating the app never +; touches it; the app updates the daemon when it next opens. + +!macro NSIS_HOOK_PREUNINSTALL + ; A real uninstall stops the daemon and removes its scheduled task and + ; binary. Bots and data stay in %LOCALAPPDATA%\Botloft. An update runs + ; this uninstaller too, with /UPDATE: then the daemon keeps running. + ${If} $UpdateMode <> 1 + nsExec::Exec '"$INSTDIR\botloftd.exe" service uninstall' + Pop $0 + ${EndIf} +!macroend diff --git a/docs/spec.md b/docs/spec.md index 28e76412..26f56774 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -102,6 +102,10 @@ Tipos do protocolo ficam em `botloft-core` e são exportados para TypeScript com | Config | `%LOCALAPPDATA%\Botloft\config.toml` | `--config` | | Workspaces | `%USERPROFILE%\Botloft\\\` | `workspaces_root` na config | | Pasta compartilhada da crew | `%USERPROFILE%\Botloft\\shared\` | | +| Binário do daemon | `%LOCALAPPDATA%\Botloft\bin\botloftd.exe` (seção 14) | `--home` | +| App instalado | `%LOCALAPPDATA%\Botloft\` (`Botloft.exe`, o sidecar `botloftd.exe`, `uninstall.exe`; seção 15.4) | | + +O instalador por usuário do Tauri põe o app em `%LOCALAPPDATA%\`, a mesma pasta dos dados. Os nomes não se cruzam, e o desinstalador só apaga os arquivos que instalou e remove a pasta se ela ficar vazia: os dados ficam. `%LOCALAPPDATA%` e não `%APPDATA%`: o perfil roaming sincroniza em rede e não deve carregar SQLite nem segredos. @@ -588,6 +592,13 @@ No chat: Identidade: o mascote do Botloft é uma chama com olhos, desenhada em vetor em `app/app-icon.svg`. O ícone do app é o mascote branco sobre fundo preto. Cada bot usa o mesmo personagem como avatar, com uma cor própria escolhida na criação. A cor do avatar identifica o bot e não comunica estado: estado continua sendo cor + ícone + texto, como descrito acima. +### 15.4 Instalador e sidecar + +- `pnpm bundle` (em `app/`) roda `scripts/sidecar.mjs`, que compila o `botloftd` em release e o copia para `src-tauri/binaries/botloftd-.exe`, e depois `tauri build --config src-tauri/tauri.bundle.conf.json`. Esse arquivo liga o `externalBin` e o NSIS; ele fica fora do `tauri.conf.json` porque o `tauri-build` copia o `externalBin` também em dev e no `cargo clippy`, o que exigiria o sidecar em todo build e sobrescreveria o `target\debug\botloftd.exe` com a cópia de release. +- NSIS por usuário (`installMode = currentUser`), sem pedir administrador, em `%LOCALAPPDATA%\Botloft` (seção 5). O instalador não mexe no daemon: ele roda da própria cópia em `\bin`, então o arquivo do sidecar nunca está em uso. +- Hook `NSIS_HOOK_PREUNINSTALL` (`src-tauri/windows/hooks.nsh`): numa desinstalação de verdade, `botloftd service uninstall` para o daemon e apaga a tarefa e o binário; bots e dados ficam. Uma atualização também roda o desinstalador antigo, com `/UPDATE`: aí o hook não faz nada e o daemon continua rodando até o app novo abrir e atualizá-lo (15.2). +- O manifesto do app (`src-tauri/windows/app.manifest`) é o padrão do Tauri (controles comuns v6) mais `longPathAware`. + ## 16. Qualidade - Rust: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`.