Merge pull request 'feat/clawdie-agent' (#13) from feat/clawdie-agent into main

Reviewed-on: #13
This commit is contained in:
clawdie 2026-06-02 10:43:17 +02:00
commit 35174b2f32
11 changed files with 786 additions and 13 deletions

14
Cargo.lock generated
View file

@ -187,6 +187,20 @@ dependencies = [
"windows-link",
]
[[package]]
name = "clawdie"
version = "0.0.1"
dependencies = [
"colibri-daemon",
"colibri-glasspane",
"reqwest",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "colibri"
version = "0.0.1"

View file

@ -1,5 +1,5 @@
[workspace]
members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store", "crates/colibri-skills"]
members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store", "crates/colibri-skills", "crates/clawdie"]
[package]
name = "colibri"
@ -26,4 +26,9 @@ colibri-deepseek = { path = "crates/colibri-deepseek" }
dotenvy = "0.15"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_json = "1"
# Lean release artifacts (notably the `clawdie` operator binary): strip symbols
# so the staged ISO binaries stay small without a manual strip step.
[profile.release]
strip = true

View file

@ -11,18 +11,20 @@ Next ISO integration plan: `docs/ISO-INTEGRATION-PLAN.md`.
ISO acceptance runbook: `docs/ISO-ACCEPTANCE-RUNBOOK.md`.
Clawdie Studio/Zed proposal: `docs/CLAWDIE-STUDIO-PROPOSAL.md`.
## Workspace — 8 crates
## Workspace — 10 crates
| Crate | Role |
|-------|------|
| `colibri-contracts` | JSON schema contracts (golden tests) |
| `colibri-deepseek` | DeepSeek cache-hit probe, prefix metering |
| `colibri-runtime` | Host status ingestion, runtime inventory |
| `colibri-glasspane` | Agent 5-state machine (Pi events → state) |
| `colibri-daemon` | Always-on Unix socket server, session lifecycle |
| `colibri-client` | Typed Unix-socket client + operator CLI |
| `colibri-glasspane-tui` | ratatui live dashboard (FreeBSD-native) |
| `colibri-store` | Embedded SQLite coordination (task board, agents, skills) |
| Crate | Role |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `clawdie` | Simplified operator agent: glasspane + herdr + DeepSeek/Telegram in one small binary (build-flag configured). See `docs/CLAWDIE-AGENT-WIKI.md`. |
| `colibri-contracts` | JSON schema contracts (golden tests) |
| `colibri-deepseek` | DeepSeek cache-hit probe, prefix metering |
| `colibri-runtime` | Host status ingestion, runtime inventory |
| `colibri-glasspane` | Agent 5-state machine (Pi events → state) |
| `colibri-daemon` | Always-on Unix socket server, session lifecycle |
| `colibri-client` | Typed Unix-socket client + operator CLI |
| `colibri-glasspane-tui` | ratatui live dashboard (FreeBSD-native) |
| `colibri-store` | Embedded SQLite coordination (task board, agents, skills) |
| `colibri-skills` | Skills catalog crate |
## Build

22
crates/clawdie/Cargo.toml Normal file
View file

@ -0,0 +1,22 @@
[package]
name = "clawdie"
version = "0.0.1"
edition = "2021"
license = "AGPL-3.0-only"
description = "Clawdie — the simplified, operator-friendly Colibri agent (glasspane + herdr + DeepSeek/Telegram) as one small binary"
[[bin]]
name = "clawdie"
path = "src/main.rs"
[dependencies]
# Reuse the proven control-plane core: glasspane (supervision radar),
# the Herdr Unix-socket API, the coordination loop, and session lifecycle.
colibri-daemon = { path = "../colibri-daemon" }
colibri-glasspane = { path = "../colibri-glasspane" }
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

29
crates/clawdie/build.rs Normal file
View file

@ -0,0 +1,29 @@
//! Build-time configuration for the `clawdie` binary.
//!
//! The whole product surface is two credentials, and we want them bakeable at
//! compile time so an operator ISO ships ready-to-run with no config files:
//!
//! CLAWDIE_TG_TOKEN — Telegram bot token (the chat front door)
//! CLAWDIE_DEEPSEEK_KEY — DeepSeek API key (the one key for everything)
//!
//! Pass them as build flags, e.g.:
//!
//! CLAWDIE_TG_TOKEN=123:abc CLAWDIE_DEEPSEEK_KEY=sk-... cargo build --release
//!
//! Whatever is present at build time is re-exported into the compiled binary
//! via `cargo:rustc-env`, where `option_env!` can read it. Runtime environment
//! variables always win over baked-in values (see `src/main.rs`), so the same
//! binary works for both "baked" ISO builds and "bring your own key" operators.
fn main() {
// Re-run if the build-flag inputs change.
println!("cargo:rerun-if-env-changed=CLAWDIE_TG_TOKEN");
println!("cargo:rerun-if-env-changed=CLAWDIE_DEEPSEEK_KEY");
// Bake whatever was provided (empty string when unset — main.rs treats
// empty as absent). We never print the secret values to the build log.
let tg = std::env::var("CLAWDIE_TG_TOKEN").unwrap_or_default();
let ds = std::env::var("CLAWDIE_DEEPSEEK_KEY").unwrap_or_default();
println!("cargo:rustc-env=CLAWDIE_BUILTIN_TG_TOKEN={tg}");
println!("cargo:rustc-env=CLAWDIE_BUILTIN_DEEPSEEK_KEY={ds}");
}

View file

@ -0,0 +1,114 @@
//! Minimal DeepSeek chat lane for the Clawdie agent.
//!
//! Deliberately tiny: one model, one key, one request shape, no cost modes, no
//! quota accounting, no provider fallback. That heavier discipline lives in
//! `colibri-deepseek` / `colibri-daemon::cost` for the full control plane; the
//! simplified Clawdie agent intentionally does **not** carry it.
use serde::{Deserialize, Serialize};
/// DeepSeek's OpenAI-compatible chat-completions endpoint.
pub const DEFAULT_ENDPOINT: &str = "https://api.deepseek.com/chat/completions";
/// Default chat model. Override at runtime with `DEEPSEEK_MODEL`.
pub const DEFAULT_MODEL: &str = "deepseek-chat";
/// One DeepSeek chat lane, configured once at startup.
#[derive(Clone)]
pub struct DeepSeek {
client: reqwest::Client,
endpoint: String,
model: String,
api_key: String,
system_prompt: String,
}
#[derive(Serialize)]
struct ChatMessage<'a> {
role: &'a str,
content: &'a str,
}
#[derive(Serialize)]
struct ChatRequest<'a> {
model: &'a str,
messages: Vec<ChatMessage<'a>>,
stream: bool,
}
#[derive(Deserialize)]
struct Choice {
message: ResponseMessage,
}
#[derive(Deserialize)]
struct ResponseMessage {
#[serde(default)]
content: String,
}
#[derive(Deserialize)]
struct ChatResponse {
#[serde(default)]
choices: Vec<Choice>,
}
impl DeepSeek {
/// Build a lane. `api_key` must be non-empty (callers gate on this).
pub fn new(api_key: String) -> Result<Self, reqwest::Error> {
let endpoint =
std::env::var("DEEPSEEK_ENDPOINT").unwrap_or_else(|_| DEFAULT_ENDPOINT.to_string());
let model = std::env::var("DEEPSEEK_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string());
let system_prompt = std::env::var("CLAWDIE_SYSTEM_PROMPT").unwrap_or_else(|_| {
"You are Clawdie, a friendly, concise operator assistant running on a \
FreeBSD live system. Answer directly and helpfully."
.to_string()
});
let client = reqwest::Client::builder()
.user_agent(concat!("clawdie/", env!("CARGO_PKG_VERSION")))
.build()?;
Ok(Self {
client,
endpoint,
model,
api_key,
system_prompt,
})
}
/// Send one user message, return the assistant's reply text.
pub async fn reply(
&self,
user_text: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let body = ChatRequest {
model: &self.model,
messages: vec![
ChatMessage {
role: "system",
content: &self.system_prompt,
},
ChatMessage {
role: "user",
content: user_text,
},
],
stream: false,
};
let resp = self
.client
.post(&self.endpoint)
.bearer_auth(&self.api_key)
.json(&body)
.send()
.await?
.error_for_status()?;
let parsed: ChatResponse = resp.json().await?;
let text = parsed
.choices
.into_iter()
.next()
.map(|c| c.message.content)
.unwrap_or_default();
Ok(text)
}
}

170
crates/clawdie/src/main.rs Normal file
View file

@ -0,0 +1,170 @@
//! clawdie — the simplified, operator-friendly Colibri agent.
//!
//! One small binary, two credentials, zero ceremony. It bundles the proven
//! control-plane core (glasspane supervision radar + the Herdr Unix-socket API +
//! the coordination loop — the "split brain") and puts a DeepSeek-backed
//! Telegram bot in front of it. That is the entire out-of-the-box surface.
//!
//! What was deliberately **lifted** vs. the full Colibri control plane:
//! - no cost modes / quota accounting / context-budget enforcement
//! - no multi-provider fallback (OpenRouter / Anthropic) — one DeepSeek key
//! - no per-user limits, no admin gating
//!
//! Credentials come from build flags (see `build.rs`) and are overridable at
//! runtime by environment variables, so the same binary serves both a baked
//! ISO and a bring-your-own-key operator.
mod deepseek;
mod telegram;
use std::sync::Arc;
use colibri_daemon::{daemon, session, socket, DaemonConfig, DaemonState, SharedState};
use tracing::{info, warn};
/// Resolve a credential: runtime env wins, then the build-flag baked value,
/// then nothing. Empty strings count as "not set".
fn resolve(env_names: &[&str], baked: Option<&str>) -> Option<String> {
for name in env_names {
if let Ok(v) = std::env::var(name) {
if !v.trim().is_empty() {
return Some(v);
}
}
}
baked
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
info!("clawdie {} starting", env!("CARGO_PKG_VERSION"));
// ── Resolve the two and only credentials ────────────────────────────────
let tg_token = resolve(
&["CLAWDIE_TG_TOKEN", "TELEGRAM_BOT_TOKEN"],
option_env!("CLAWDIE_BUILTIN_TG_TOKEN"),
);
let deepseek_key = resolve(
&["CLAWDIE_DEEPSEEK_KEY", "DEEPSEEK_API_KEY"],
option_env!("CLAWDIE_BUILTIN_DEEPSEEK_KEY"),
);
// One key for everything: make the daemon's own provider routing see it too.
if let Some(key) = &deepseek_key {
std::env::set_var("DEEPSEEK_API_KEY", key);
}
info!(
telegram = tg_token.is_some(),
deepseek = deepseek_key.is_some(),
"credentials resolved (build flags + runtime env)"
);
// ── Bring up the control-plane core (glasspane + Herdr socket + loop) ────
let config = DaemonConfig::from_env();
info!(
host = %config.host,
data_dir = %config.data_dir.display(),
socket_path = %config.socket_path.display(),
"control plane configured"
);
let state: SharedState = Arc::new(DaemonState::new(config.clone()));
tokio::fs::create_dir_all(&config.data_dir).await?;
tokio::fs::create_dir_all(config.data_dir.join("sessions")).await?;
// Reload any persisted sessions so supervision picks up where it left off.
if let Ok(mut entries) = tokio::fs::read_dir(config.data_dir.join("sessions")).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let session_id = stem.to_string();
let cfg = Arc::new(config.clone());
match session::Session::load(session_id.clone(), cfg) {
Ok(sess) => {
state.sessions.insert(session_id, sess);
}
Err(e) => warn!(error = %e, "failed to load session file"),
}
}
}
}
}
// Herdr Unix-socket API.
let socket_state = state.clone();
let socket_shutdown = state.shutdown_rx.resubscribe();
let socket_handle = tokio::spawn(async move {
socket::serve(socket_state, socket_shutdown).await;
});
// Coordination / heartbeat / glasspane loop.
let loop_state = state.clone();
let loop_shutdown = state.shutdown_rx.resubscribe();
let loop_handle = tokio::spawn(async move {
daemon::run_loop(
loop_state,
daemon::DaemonLoopConfig::default(),
loop_shutdown,
)
.await;
});
// ── Telegram front door (only if we have both a token and a key) ─────────
let telegram_handle = match (tg_token, &deepseek_key) {
(Some(token), Some(key)) => match deepseek::DeepSeek::new(key.clone()) {
Ok(ds) => {
let tg_shutdown = state.shutdown_rx.resubscribe();
Some(tokio::spawn(async move {
telegram::run(token, ds, tg_shutdown).await;
}))
}
Err(e) => {
warn!(error = %e, "could not start DeepSeek lane; Telegram bridge disabled");
None
}
},
(Some(_), None) => {
warn!("Telegram token present but no DeepSeek key — bridge disabled");
None
}
(None, _) => {
info!("no Telegram token — running headless (control plane only)");
None
}
};
// Graceful shutdown on Ctrl-C / SIGTERM.
let shutdown_state = state.clone();
tokio::spawn(async move {
tokio::signal::ctrl_c().await.ok();
info!("interrupt received, shutting down");
let _ = shutdown_state.shutdown_tx.send(());
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
for entry in shutdown_state.agents.iter() {
let _ = entry.value().kill().await;
}
});
let (socket_result, loop_result) = tokio::join!(socket_handle, loop_handle);
socket_result?;
loop_result?;
if let Some(h) = telegram_handle {
let _ = h.await;
}
info!("clawdie shut down cleanly");
Ok(())
}

View file

@ -0,0 +1,133 @@
//! Minimal Telegram bridge: long-poll `getUpdates`, hand each text message to
//! the DeepSeek lane, and `sendMessage` the reply back.
//!
//! No webhooks, no command framework, no per-user quota — the simplest thing
//! that gives an operator a working chat bot out of the box. Shuts down cleanly
//! when the daemon broadcasts shutdown.
use serde::Deserialize;
use tokio::sync::broadcast;
use tracing::{info, warn};
use crate::deepseek::DeepSeek;
const API_BASE: &str = "https://api.telegram.org";
#[derive(Deserialize)]
struct UpdatesResponse {
#[serde(default)]
ok: bool,
#[serde(default)]
result: Vec<Update>,
}
#[derive(Deserialize)]
struct Update {
update_id: i64,
#[serde(default)]
message: Option<Message>,
}
#[derive(Deserialize)]
struct Message {
#[serde(default)]
text: Option<String>,
chat: Chat,
}
#[derive(Deserialize)]
struct Chat {
id: i64,
}
/// Run the bridge until `shutdown` fires. Network errors are logged and retried
/// rather than fatal — the bot should survive flaky links on a live USB.
pub async fn run(token: String, deepseek: DeepSeek, mut shutdown: broadcast::Receiver<()>) {
let client = match reqwest::Client::builder()
.user_agent(concat!("clawdie/", env!("CARGO_PKG_VERSION")))
.build()
{
Ok(c) => c,
Err(e) => {
warn!(error = %e, "telegram: failed to build HTTP client; bridge disabled");
return;
}
};
let base = format!("{API_BASE}/bot{token}");
let mut offset: i64 = 0;
info!("telegram bridge online (long-poll)");
loop {
tokio::select! {
_ = shutdown.recv() => {
info!("telegram bridge shutting down");
return;
}
updates = poll_once(&client, &base, offset) => {
match updates {
Ok(list) => {
for upd in list {
offset = offset.max(upd.update_id + 1);
if let Some(msg) = upd.message {
if let Some(text) = msg.text {
handle_message(&client, &base, &deepseek, msg.chat.id, &text)
.await;
}
}
}
}
Err(e) => {
warn!(error = %e, "telegram: getUpdates failed; retrying");
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
}
}
}
}
}
}
async fn poll_once(
client: &reqwest::Client,
base: &str,
offset: i64,
) -> Result<Vec<Update>, Box<dyn std::error::Error + Send + Sync>> {
// 25s long-poll; keep below the client's default timeout.
let resp: UpdatesResponse = client
.get(format!("{base}/getUpdates"))
.query(&[("timeout", "25"), ("offset", &offset.to_string())])
.timeout(std::time::Duration::from_secs(35))
.send()
.await?
.error_for_status()?
.json()
.await?;
if !resp.ok {
return Err("telegram getUpdates returned ok=false".into());
}
Ok(resp.result)
}
async fn handle_message(
client: &reqwest::Client,
base: &str,
deepseek: &DeepSeek,
chat_id: i64,
text: &str,
) {
let reply = match deepseek.reply(text).await {
Ok(r) if !r.trim().is_empty() => r,
Ok(_) => "(no reply)".to_string(),
Err(e) => {
warn!(error = %e, "telegram: deepseek reply failed");
"Sorry — I couldn't reach the model just now.".to_string()
}
};
if let Err(e) = client
.post(format!("{base}/sendMessage"))
.json(&serde_json::json!({ "chat_id": chat_id, "text": reply }))
.send()
.await
{
warn!(error = %e, "telegram: sendMessage failed");
}
}

View file

@ -0,0 +1,98 @@
# Clawdie Agent — wiki / mindmap
The **Clawdie agent** is the simplified, operator-friendly face of Colibri: one
small Rust binary (`clawdie`) that ships with exactly two things wired up — a
Telegram bot and a DeepSeek lane — sitting on top of the proven control-plane
core. Everything heavier (cost modes, quotas, multi-provider fallback) is
deliberately **lifted**.
This is a mindmap-style wiki page (the format from the Herdr/Colibri capability
graph work — see [`HERDR-VS-COLIBRI-GRAPH.md`](./HERDR-VS-COLIBRI-GRAPH.md) and
[`COLIBRI-GLASSPANE-DESIGN.md`](./COLIBRI-GLASSPANE-DESIGN.md)).
## Mindmap
```mermaid
mindmap
root((clawdie<br/>one binary))
Split brain
Glasspane radar
5-state machine
idle/working/blocked/done/error
derived from Pi JSON events
Coordination
task board
agent registry
session JSONL
Front door
Telegram bot
long-poll getUpdates
sendMessage reply
DeepSeek lane
one model
one key
no fallback
Build flags
CLAWDIE_TG_TOKEN
CLAWDIE_DEEPSEEK_KEY
baked at compile time
runtime env overrides
Lifted (NOT shipped)
cost modes
quota accounting
context budgets
OpenRouter / Anthropic
per-user limits
Runs as a service
rc.d clawdie
daemon(8) supervised
restart on crash
like Clawdie-AI
```
## What "split brain" means here
Two halves of the same daemon, both kept:
| Half | Crate / module | Job |
| ---------------- | ----------------------------------- | ----------------------------------------------------- |
| **Glasspane** | `colibri-glasspane` + daemon socket | The "radar": agent state, panes, supervision snapshot |
| **Coordination** | `colibri-store` + daemon loop | Task board, agent registry, session lifecycle |
The Clawdie agent reuses these as-is over the Herdr Unix-socket API. It adds the
Telegram + DeepSeek front door and **removes** the cost/quota machinery from its
own runtime path.
## Surface area (the whole product)
```mermaid
graph LR
tg["Telegram<br/>(CLAWDIE_TG_TOKEN)"] -->|text| clawdie
clawdie -->|chat| ds["DeepSeek<br/>(CLAWDIE_DEEPSEEK_KEY)"]
ds -->|reply| clawdie
clawdie --> gp["glasspane radar"]
clawdie --> co["coordination board"]
clawdie -. Herdr socket .- ui["operator tools (colibri CLI / TUI)"]
```
## Files
| Path | Role |
| -------------------------------- | ------------------------------------------------------- |
| `crates/clawdie/src/main.rs` | Entrypoint: resolve creds, start core + Telegram bridge |
| `crates/clawdie/src/telegram.rs` | Long-poll bridge |
| `crates/clawdie/src/deepseek.rs` | Minimal one-key chat lane (no cost/quota) |
| `crates/clawdie/build.rs` | Bakes the two credentials from build flags |
| `packaging/freebsd/clawdie.in` | rc.d service (daemon(8)-supervised, like Clawdie-AI) |
## Credential resolution order
1. Runtime env (`CLAWDIE_TG_TOKEN` / `TELEGRAM_BOT_TOKEN`,
`CLAWDIE_DEEPSEEK_KEY` / `DEEPSEEK_API_KEY`)
2. Build-flag baked value (`option_env!` from `build.rs`)
3. Absent → that half stays off (no token = headless control plane only)
One DeepSeek key serves both the Telegram chat lane and the daemon's own
provider routing.
See [`CLAWDIE-BUILD.md`](./CLAWDIE-BUILD.md) for the build + ISO instructions.

100
docs/CLAWDIE-BUILD.md Normal file
View file

@ -0,0 +1,100 @@
# Clawdie agent — build & ISO instructions
The `clawdie` binary is the simplified Colibri agent (see
[`CLAWDIE-AGENT-WIKI.md`](./CLAWDIE-AGENT-WIKI.md)). It is configured almost
entirely by **build flags**, so a baked ISO ships ready to run.
## 1. Build with baked credentials
Pass the two credentials as environment variables at build time. `build.rs`
bakes whatever is present into the binary; runtime env still overrides.
```sh
cd ~/colibri
CLAWDIE_TG_TOKEN="123456:telegram-bot-token" \
CLAWDIE_DEEPSEEK_KEY="sk-deepseek-key" \
cargo build --release -p clawdie
ls -lh target/release/clawdie # release profile strips symbols automatically
target/release/clawdie --version 2>/dev/null || true
```
Build with **no** flags for a "bring your own key" binary — it reads
`CLAWDIE_TG_TOKEN` / `TELEGRAM_BOT_TOKEN` and `CLAWDIE_DEEPSEEK_KEY` /
`DEEPSEEK_API_KEY` from the runtime environment (or the rc.d env file) instead.
> Build credentials are secrets. Pass them inline on the build command (so they
> are not persisted), never commit them, and prefer the runtime env file for
> rotation. They are never printed to the build log.
## 2. What the binary does out of the box
- Starts the control-plane core: glasspane supervision + Herdr Unix socket +
coordination loop.
- If a Telegram token **and** a DeepSeek key are present, runs the Telegram
bridge (long-poll → DeepSeek → reply).
- No token → headless (control plane only). Token but no key → bridge disabled
with a warning.
No cost modes, quotas, context budgets, or provider fallback — those are lifted
from this agent on purpose.
## 3. Run as a service (like Clawdie-AI)
On FreeBSD, install the rc.d script and enable it:
```sh
pw groupadd clawdie
pw useradd clawdie -g clawdie -d /var/db/clawdie -s /usr/sbin/nologin
install -m 0555 packaging/freebsd/clawdie.in /usr/local/etc/rc.d/clawdie
install -m 0555 target/release/clawdie /usr/local/bin/clawdie
sysrc clawdie_enable=YES
service clawdie start
service clawdie status
```
Per-host credential override (optional; binary already has baked defaults):
```sh
install -d -m 0750 /usr/local/etc/clawdie
cat > /usr/local/etc/clawdie/clawdie.env <<'EOF'
CLAWDIE_TG_TOKEN=123456:telegram-bot-token
CLAWDIE_DEEPSEEK_KEY=sk-deepseek-key
EOF
chmod 0600 /usr/local/etc/clawdie/clawdie.env
service clawdie restart
```
## 4. ISO integration
The ISO build stages the prebuilt FreeBSD `clawdie` release binary + rc.d (it
never compiles Rust while the image is mounted), the same model as the Colibri
daemon staging. In `clawdie-iso`:
- `build.cfg`: `FEATURE_CLAWDIE`, `CLAWDIE_ENABLE`, and the build-flag creds.
- `scripts/stage-clawdie-iso.sh`: installs binary + rc.d + rc.conf sample.
Build the binary on the FreeBSD/OSA host (not Debian/Linux), then run the ISO
preflight. **Do not `cargo clean` the colibri checkout** until the ISO build has
consumed `target/release/clawdie`.
## 5. Next ISO build — XFCE operator-USB fixes to carry forward
The next operator-USB image must retain the XFCE/live-GUI fixes already proven
on real hardware (see `clawdie-iso/PLAN-OPERATOR-USB-NEXT.md` and
`doc/AMD-ASUS-XFCE-LIVE-USB-FINDINGS.md`). Load-bearing items:
- **SDDM over LightDM** — LightDM was the silent blocker for live GUI boot on
Intel and AMD. SDDM is part of the operator-USB contract; do **not** revert.
- **`clawdie-live-gpu`** — pre-SDDM rc.d service that does a conservative KMS
pick (Intel iGPU works out of the box; AMD/NVIDIA picked conservatively)
before the display manager starts.
- **Hardened USB-root power policy** (commit `b9290ab`): `powerdxx`, C3 C-state
default, `hw.usb.no_suspend=1`, and a boot-time `power_profile` — keeps
USB-root laptops from stalling/suspending.
- **Base runtime fixes already landed**: resolver bootstrap, internal-audio
preference, touchpad XInput guards, loader branding, hardware-report capture,
public probe-URL reporting, `hw-probe` upload dependency.
Final XFCE/session/input/audio behavior is only proven on **real hardware**
bhyve/nested VMs/static image inspection are build gates, not final proof.

View file

@ -0,0 +1,86 @@
#!/bin/sh
#
# clawdie — FreeBSD rc.d service for the simplified Colibri agent.
#
# Operator-friendly by design: enable it and start it. The two credentials
# (Telegram bot token + DeepSeek key) are normally baked into the binary at
# build time (see crates/clawdie/build.rs), so a baked ISO needs no config.
# To override per-host, drop them in the env file (default
# /usr/local/etc/clawdie/clawdie.env) or set them in rc.conf below.
#
# clawdie runs in the FOREGROUND (no self-daemonize, no pidfile), so rc.d runs
# it under daemon(8), which backgrounds it, writes the supervisor pidfile,
# restarts on crash, drops privileges to the clawdie user, and redirects
# stdout/stderr (tracing) to a logfile.
#
# Install:
# pw groupadd clawdie
# pw useradd clawdie -g clawdie -d /var/db/clawdie -s /usr/sbin/nologin
# cp packaging/freebsd/clawdie.in /usr/local/etc/rc.d/clawdie
# chmod 555 /usr/local/etc/rc.d/clawdie
# sysrc clawdie_enable=YES
# service clawdie start
#
# Requires the clawdie binary at /usr/local/bin/clawdie.
# PROVIDE: clawdie
# REQUIRE: LOGIN NETWORKING cleanvar
# KEYWORD: shutdown
. /etc/rc.subr
name="clawdie"
rcvar="clawdie_enable"
load_rc_config $name
: ${clawdie_enable:="NO"}
: ${clawdie_user:="clawdie"}
: ${clawdie_group:="clawdie"}
: ${clawdie_program:="/usr/local/bin/clawdie"}
: ${clawdie_data_dir:="/var/db/clawdie"}
: ${clawdie_run_dir:="/var/run/clawdie"}
: ${clawdie_socket:="${clawdie_run_dir}/clawdie.sock"}
: ${clawdie_logfile:="/var/log/clawdie/clawdie.log"}
: ${clawdie_host:="$(/bin/hostname)"}
: ${clawdie_env_file:="/usr/local/etc/clawdie/clawdie.env"}
pidfile="${clawdie_run_dir}/clawdie.pid"
# Supervise via daemon(8): -P supervisor pidfile, -r restart on exit, -t title,
# -u drop privileges, -o append stdout/stderr to the logfile.
command="/usr/sbin/daemon"
command_args="-P ${pidfile} -r -t ${name} -u ${clawdie_user} \
-o ${clawdie_logfile} ${clawdie_program}"
# rc.subr matches the pidfile process against ${procname}; that is daemon(8),
# the supervised parent — not clawdie itself.
procname="/usr/sbin/daemon"
start_precmd="clawdie_prestart"
clawdie_prestart()
{
# /var/run is tmpfs on FreeBSD (wiped each boot) — recreate every start.
install -d -o "${clawdie_user}" -g "${clawdie_group}" -m 0750 "${clawdie_run_dir}"
install -d -o "${clawdie_user}" -g "${clawdie_group}" -m 0750 "${clawdie_data_dir}"
install -d -o "${clawdie_user}" -g "${clawdie_group}" -m 0750 \
"$(/usr/bin/dirname "${clawdie_logfile}")"
# Control-plane config passed to the child via the environment.
export COLIBRI_DAEMON_DATA_DIR="${clawdie_data_dir}"
export COLIBRI_DAEMON_SOCKET="${clawdie_socket}"
export COLIBRI_HOST="${clawdie_host}"
# Optional per-host credential overrides (binary already has baked defaults).
# File format: simple KEY=VALUE lines, e.g.
# CLAWDIE_TG_TOKEN=123456:abc
# CLAWDIE_DEEPSEEK_KEY=sk-...
if [ -r "${clawdie_env_file}" ]; then
set -a
. "${clawdie_env_file}"
set +a
fi
}
run_rc_command "$1"