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