Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,18 @@ 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

# 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á.
Expand Down Expand Up @@ -68,6 +70,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

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

56 changes: 39 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
26 changes: 26 additions & 0 deletions app/scripts/sidecar.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Builds botloftd for release and puts it where `tauri build` looks for the
// sidecar: src-tauri/binaries/botloftd-<target triple>.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}`);
6 changes: 5 additions & 1 deletion app/src-tauri/build.rs
Original file line number Diff line number Diff line change
@@ -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");
}
120 changes: 76 additions & 44 deletions app/src-tauri/src/daemon.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
//! 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};

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)]
Expand All @@ -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")]
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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<DaemonStatus, String> {
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::<u64>().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<DaemonStatus, String> {
run_service(endpoint, "install")?;
Ok(status(endpoint))
}

/// Stops the daemon and starts it again from its scheduled task.
pub fn restart(endpoint: &Endpoint) -> Result<DaemonStatus, String> {
run_service(endpoint, "restart")?;
Ok(status(endpoint))
}

/// Runs `botloftd --home <home> service <command>` 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<PathBuf, String> {
let app = std::env::current_exe().map_err(|err| err.to_string())?;
let binary = app.with_file_name(if cfg!(windows) {
Expand All @@ -164,31 +210,6 @@ fn daemon_binary() -> Result<PathBuf, String> {
}
}

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<String, String> {
let path = endpoint.home.join("secrets").join("owner.token");
Expand Down Expand Up @@ -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.
Expand Down
17 changes: 12 additions & 5 deletions app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,17 @@ async fn daemon_status() -> Result<DaemonStatus, String> {
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<DaemonStatus, String> {
blocking(|| daemon::start(&daemon::endpoint()?)).await
async fn daemon_install() -> Result<DaemonStatus, String> {
blocking(|| daemon::install(&daemon::endpoint()?)).await
}

/// Stops the daemon and starts it again from its scheduled task.
#[tauri::command]
async fn daemon_restart() -> Result<DaemonStatus, String> {
blocking(|| daemon::restart(&daemon::endpoint()?)).await
}

#[tauri::command]
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading