chore/merge-all-uncommitted #235

Merged
clawdie merged 13 commits from chore/merge-all-uncommitted into main 2026-06-27 18:11:51 +02:00
26 changed files with 1474 additions and 120 deletions

20
Cargo.lock generated
View file

@ -386,6 +386,8 @@ dependencies = [
"clap",
"colibri-client",
"colibri-daemon",
"colibri-pf",
"colibri-zfs",
"serde",
"serde_json",
"tokio",
@ -394,6 +396,15 @@ dependencies = [
"uuid",
]
[[package]]
name = "colibri-pf"
version = "0.12.0"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "colibri-runtime"
version = "0.12.0"
@ -437,6 +448,15 @@ dependencies = [
"tracing",
]
[[package]]
name = "colibri-zfs"
version = "0.12.0"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "colorchoice"
version = "1.0.5"

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", "crates/colibri-mcp", "crates/colibri-vault", "crates/clawdie"]
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/colibri-mcp", "crates/colibri-vault", "crates/colibri-zfs", "crates/colibri-pf", "crates/clawdie"]
[workspace.package]
version = "0.12.0"

View file

@ -334,11 +334,14 @@ fn push_cost_to_mother(task_id: &str, tc: &colibri_store::TaskCost) {
let cache_write_tokens = tc.cache_write_tokens;
let cost = tc.cost;
let success = tc.success;
// Optional: tmux-screenshot UUID set by the agent harness on completion.
// The daemon passes it through to mother; mother JOINs with screenshot storage.
let screenshot_uuid = std::env::var("COLIBRI_TASK_SCREENSHOT_UUID").ok();
// Run SSH in a blocking thread — heartbeat is async, SSH is fast (<1s).
std::thread::spawn(move || {
use std::io::Write;
let payload = serde_json::json!({
let mut payload = serde_json::json!({
"node_hostname": node_hostname,
"task_id": task_id,
"provider": provider,
@ -351,11 +354,16 @@ fn push_cost_to_mother(task_id: &str, tc: &colibri_store::TaskCost) {
"success": success,
"finished_at": chrono::Utc::now().to_rfc3339(),
});
if let Some(ref uuid) = screenshot_uuid {
payload["screenshot_uuid"] = serde_json::Value::String(uuid.clone());
}
let payload_line = serde_json::to_string(&payload).unwrap_or_default();
let mut child = match std::process::Command::new("ssh")
.args([
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=5",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=5",
&mother_host,
"report-task-cost",
])

View file

@ -12,6 +12,8 @@ path = "src/main.rs"
[dependencies]
colibri-client = { path = "../colibri-client" }
colibri-daemon = { path = "../colibri-daemon" }
colibri-pf = { path = "../colibri-pf" }
colibri-zfs = { path = "../colibri-zfs" }
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View file

@ -15,6 +15,12 @@
//! | `colibri_create_task` | write-gated| Create a task |
//! | `colibri_intake_task` | write-gated| Submit intake task with capabilities|
//! | `colibri_set_cost_mode` | write-gated | Switch cost mode (fast/smart/max) |
//! | `colibri_zfs_list_snapshots` | read-only | ZFS snapshot listing |
//! | `colibri_zfs_destroy_snapshot` | write-gated | Destroy a ZFS snapshot |
//! | `colibri_pf_list_rules` | read-only | Active PF firewall rules |
//! | `colibri_pf_list_states` | read-only | PF state table entries |
//! | `colibri_wiki_search` | read-only | Search wiki pages |
//! | `colibri_wiki_page` | read-only | Read wiki page content |
//! | `colibri_get_task` | read-only | Task details with cost data |
//! | `colibri_list_task_costs` | read-only | All tasks with cost (dashboard) |
//!
@ -168,6 +174,62 @@ pub fn tool_list() -> Vec<Value> {
}
})),
),
// ── Infrastructure tools ──
json_tool(
"colibri_zfs_list_snapshots",
"List ZFS snapshots for a dataset. Returns structured data: name, used_bytes, refer_bytes, creation, age_hours.",
Some(serde_json::json!({
"type": "object",
"properties": {
"dataset": { "type": "string", "description": "ZFS dataset name, e.g. 'zroot/home/clawdie'" }
},
"required": ["dataset"]
})),
),
json_tool(
"colibri_zfs_destroy_snapshot",
"Destroy a ZFS snapshot by full name. Requires ZFS destroy permission.",
Some(serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string", "description": "Full snapshot name, e.g. 'zroot/home@autosnap_2026-06-27_09:00:00_hourly'" }
},
"required": ["name"]
})),
),
json_tool(
"colibri_pf_list_rules",
"List active PF firewall rules. Returns array of rule strings.",
None,
),
json_tool(
"colibri_pf_list_states",
"List active PF state table entries: protocol, source, destination, state.",
None,
),
// ── Wiki tools ──
json_tool(
"colibri_wiki_search",
"Search Colibri wiki pages for a keyword. Returns matching page names and line excerpts.",
Some(serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Search keyword or phrase" }
},
"required": ["query"]
})),
),
json_tool(
"colibri_wiki_page",
"Read a Colibri wiki page in full. Returns the complete markdown content.",
Some(serde_json::json!({
"type": "object",
"properties": {
"page": { "type": "string", "description": "Wiki page name without .md, e.g. 'cost-model' or 'sl/cost-model'" }
},
"required": ["page"]
})),
),
json_tool(
"colibri_external_mcp_servers",
"List configured external MCP servers from COLIBRI_MCP_EXTERNAL_CONFIG",
@ -324,6 +386,53 @@ pub async fn dispatch_tool(
let all_tasks = client.list_tasks(status).await.map_err(map_client_error)?;
Ok(tool_text(all_tasks))
}
// ── Infrastructure dispatch ──
"colibri_zfs_list_snapshots" => {
let dataset = require_string(arguments, "dataset")?;
let snaps = colibri_zfs::Snapshot::list(&dataset)
.map_err(|e| McpError::internal(format!("zfs: {e}")))?;
Ok(tool_text(serde_json::to_value(&snaps).unwrap_or_default()))
}
"colibri_zfs_destroy_snapshot" => {
let name = require_string(arguments, "name")?;
let snap = colibri_zfs::Snapshot {
name,
dataset: String::new(),
tag: String::new(),
used_bytes: 0,
refer_bytes: 0,
creation: String::new(),
};
snap.destroy()
.map_err(|e| McpError::internal(format!("zfs destroy: {e}")))?;
Ok(tool_text(serde_json::json!({"destroyed": true})))
}
"colibri_pf_list_rules" => {
let rules =
colibri_pf::list_rules().map_err(|e| McpError::internal(format!("pf: {e}")))?;
Ok(tool_text(serde_json::to_value(&rules).unwrap_or_default()))
}
"colibri_pf_list_states" => {
let states =
colibri_pf::list_states().map_err(|e| McpError::internal(format!("pf: {e}")))?;
Ok(tool_text(serde_json::to_value(&states).unwrap_or_default()))
}
// ── Wiki dispatch ──
"colibri_wiki_search" => {
let query = require_string(arguments, "query")?;
let results = wiki_search(&query);
Ok(tool_text(
serde_json::to_value(&results).unwrap_or_default(),
))
}
"colibri_wiki_page" => {
let page = require_string(arguments, "page")?;
let content = wiki_page(&page)
.ok_or_else(|| McpError::not_found(format!("wiki page not found: {page}")))?;
Ok(tool_text(
serde_json::json!({"page": page, "content": content}),
))
}
"colibri_external_mcp_servers" => {
let registry = external::load_registry_if_present(&config.external_config_path).await?;
Ok(tool_text(serde_json::json!({
@ -588,3 +697,70 @@ impl StdioHandler {
Ok(())
}
}
// ── Wiki helpers ──
fn wiki_search(query: &str) -> Vec<serde_json::Value> {
let wiki_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/wiki");
let q = query.to_lowercase();
let mut results = Vec::new();
if let Ok(entries) = std::fs::read_dir(&wiki_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_none_or(|e| e != "md") {
continue;
}
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
let matches: Vec<String> = content
.lines()
.enumerate()
.filter(|(_, l)| l.to_lowercase().contains(&q))
.take(3)
.map(|(i, l)| format!("L{}: {}", i + 1, l.trim()))
.collect();
if !matches.is_empty() {
let name = path.file_stem().unwrap_or_default().to_string_lossy();
results.push(serde_json::json!({
"page": name,
"matches": matches
}));
}
}
}
// Also search sl/ subdirectory
let sl_dir = wiki_dir.join("sl");
if let Ok(entries) = std::fs::read_dir(&sl_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_none_or(|e| e != "md") {
continue;
}
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
let matches: Vec<String> = content
.lines()
.enumerate()
.filter(|(_, l)| l.to_lowercase().contains(&q))
.take(3)
.map(|(i, l)| format!("L{}: {}", i + 1, l.trim()))
.collect();
if !matches.is_empty() {
let name = path.file_stem().unwrap_or_default().to_string_lossy();
results.push(serde_json::json!({
"page": format!("sl/{}", name),
"matches": matches
}));
}
}
}
results
}
fn wiki_page(page: &str) -> Option<String> {
let wiki_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/wiki");
let path = wiki_dir.join(format!("{page}.md"));
std::fs::read_to_string(&path).ok()
}

View file

@ -255,5 +255,5 @@ fn tool_list_has_all_phase1_tools() {
assert!(names.contains(&"colibri_list_task_costs"));
assert!(names.contains(&"colibri_get_task"));
assert_eq!(names.len(), 12);
assert_eq!(names.len(), 18);
}

View file

@ -0,0 +1,10 @@
[package]
name = "colibri-pf"
version.workspace = true
edition = "2021"
license = "MIT"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"

View file

@ -0,0 +1,86 @@
//! colibri-pf — structured PF firewall inspection for Colibri agents.
//!
//! Wraps `pfctl(8)` output into Rust structs. Read-only operations
//! work without root; state/rule manipulation requires privileges.
use serde::{Deserialize, Serialize};
use std::process::Command;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PfRule {
pub line: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PfState {
pub proto: String,
pub src: String,
pub dst: String,
pub state: String,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("pfctl failed: {0}")]
Command(String),
}
/// List active PF rules (equivalent to `pfctl -s rules`).
pub fn list_rules() -> Result<Vec<String>, Error> {
let output = Command::new("pfctl")
.args(["-s", "rules"])
.output()
.map_err(|e| Error::Command(format!("pfctl: {e}")))?;
if !output.status.success() {
return Err(Error::Command(
String::from_utf8_lossy(&output.stderr).into(),
));
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !l.starts_with('@'))
.collect())
}
/// List active PF states (equivalent to `pfctl -s state`).
pub fn list_states() -> Result<Vec<PfState>, Error> {
let output = Command::new("pfctl")
.args(["-s", "state"])
.output()
.map_err(|e| Error::Command(format!("pfctl: {e}")))?;
if !output.status.success() {
return Err(Error::Command(
String::from_utf8_lossy(&output.stderr).into(),
));
}
let mut states = Vec::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
let line = line.trim();
if line.is_empty() || line.starts_with("all") {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 4 {
states.push(PfState {
proto: parts[0].to_string(),
src: format!("{} {}", parts[1], parts.get(2).unwrap_or(&"")),
dst: format!("{} {}", parts[3], parts.get(4).unwrap_or(&"")),
state: parts.last().unwrap_or(&"").to_string(),
});
}
}
Ok(states)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn list_rules_does_not_panic() {
// pfctl may not be present (Linux CI), but the function should not panic.
let _ = list_rules();
let _ = list_states();
}
}

View file

@ -0,0 +1,10 @@
[package]
name = "colibri-zfs"
version.workspace = true
edition = "2021"
license = "MIT"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"

View file

@ -0,0 +1,234 @@
//! colibri-zfs — structured ZFS snapshot lifecycle for Colibri agents.
//!
//! Wraps `zfs(8)` output into Rust structs so MCP tools can reason about
//! snapshots without shell parsing. ZFS operations run on the host — the
//! daemon runs as user `colibri`, which needs `zfs allow` delegations or
//! `sudo` for destructive operations.
//!
//! ```no_run
//! use colibri_zfs::Snapshot;
//! let snaps = Snapshot::list("zroot/home/clawdie").unwrap();
//! for s in &snaps {
//! println!("{} {} {}", s.name, s.used_bytes, s.creation);
//! }
//! // Destroy snapshots older than 24h
//! let old: Vec<_> = snaps.into_iter()
//! .filter(|s| s.age_hours() > 24.0)
//! .collect();
//! Snapshot::destroy_all(&old).unwrap();
//! ```
use serde::{Deserialize, Serialize};
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// A single ZFS snapshot as reported by `zfs list -Hp -t snapshot`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Snapshot {
/// Full snapshot name, e.g. "zroot/home/clawdie@autosnap_2026-06-27_09:00:00_hourly"
pub name: String,
/// Dataset portion, e.g. "zroot/home/clawdie"
pub dataset: String,
/// Snapshot tag, e.g. "autosnap_2026-06-27_09:00:00_hourly"
pub tag: String,
/// Space used by this snapshot (bytes). Note: ZFS `used` includes
/// space uniquely held by this snapshot after accounting for clones
/// and child snapshots.
pub used_bytes: u64,
/// Space referenced (bytes) — total data accessible in this snapshot,
/// shared or not.
pub refer_bytes: u64,
/// Snapshot creation time as reported by ZFS.
pub creation: String,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("zfs command failed: {0}")]
Command(String),
#[error("parse error: {0}")]
Parse(String),
}
impl Snapshot {
/// List all snapshots for a dataset and its children.
/// Uses `zfs list -Hp -t snapshot -o name,used,refer,creation -r <dataset>`.
pub fn list(dataset: &str) -> Result<Vec<Self>, Error> {
let output = Command::new("zfs")
.args([
"list",
"-Hp",
"-t",
"snapshot",
"-o",
"name,used,refer,creation",
"-r",
dataset,
])
.output()
.map_err(|e| Error::Command(format!("zfs list: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Command(format!("zfs list failed: {stderr}")));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut snaps = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.splitn(4, '\t').collect();
if parts.len() < 4 {
return Err(Error::Parse(format!("unexpected zfs output: {line}")));
}
let full_name = parts[0].to_string();
let (ds, tag) = full_name
.split_once('@')
.ok_or_else(|| Error::Parse(format!("no @ in snapshot name: {full_name}")))?;
snaps.push(Snapshot {
dataset: ds.to_string(),
tag: tag.to_string(),
name: full_name,
used_bytes: parts[1].parse().unwrap_or(0),
refer_bytes: parts[2].parse().unwrap_or(0),
creation: parts[3].to_string(),
});
}
Ok(snaps)
}
/// Destroy a single snapshot. Requires ZFS destroy permission.
pub fn destroy(&self) -> Result<(), Error> {
let output = Command::new("zfs")
.args(["destroy", &self.name])
.output()
.map_err(|e| Error::Command(format!("zfs destroy {}: {e}", self.name)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Command(format!("zfs destroy: {stderr}")));
}
Ok(())
}
/// Destroy multiple snapshots. Each failure is collected and returned.
pub fn destroy_all(snaps: &[Snapshot]) -> Result<(), Error> {
let mut errors = Vec::new();
for s in snaps {
if let Err(e) = s.destroy() {
errors.push(e.to_string());
}
}
if errors.is_empty() {
Ok(())
} else {
Err(Error::Command(errors.join("; ")))
}
}
/// Age of the snapshot in hours, parsed from the creation timestamp.
/// Returns 0.0 if the timestamp cannot be parsed.
pub fn age_hours(&self) -> f64 {
parse_zfs_timestamp(&self.creation)
.and_then(|created| SystemTime::now().duration_since(created).ok())
.map(|d| d.as_secs_f64() / 3600.0)
.unwrap_or(0.0)
}
/// Filter to snapshots older than `hours`.
pub fn older_than(snaps: &[Snapshot], hours: f64) -> Vec<Snapshot> {
snaps
.iter()
.filter(|s| s.age_hours() > hours)
.cloned()
.collect()
}
}
/// Parse a ZFS timestamp like "Mon Jun 23 01:15 2026" into SystemTime.
/// ZFS outputs creation time in `date(1)` format when not using `-p`.
fn parse_zfs_timestamp(s: &str) -> Option<SystemTime> {
// With -p flag, ZFS outputs Unix seconds (e.g. "1719270900")
if let Ok(secs) = s.parse::<u64>() {
return UNIX_EPOCH.checked_add(Duration::from_secs(secs));
}
// Fallback: try standard date format
if let Ok(t) = chrono_parse(s) {
return Some(t);
}
None
}
fn chrono_parse(_s: &str) -> Result<SystemTime, ()> {
// Avoid pulling in chrono as a dependency. If the seconds parse fails,
// we return 0.0 hours — the age_hours() caller handles this gracefully.
Err(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snapshot_parse_basic() {
let _line = "zroot/home@test\t1024000\t2048000\t1719270900";
// ZFS list output format (tab-separated).
// Snapshot is tested via struct construction below.
let s = Snapshot {
name: "zroot/home@test".into(),
dataset: "zroot/home".into(),
tag: "test".into(),
used_bytes: 1_024_000,
refer_bytes: 2_048_000,
creation: "1719270900".into(),
};
// This timestamp is in the past, so age should be positive.
assert!(s.age_hours() > 0.0);
}
#[test]
fn older_than_filter() {
let ancient = Snapshot {
name: "pool@old".into(),
dataset: "pool".into(),
tag: "old".into(),
used_bytes: 1000,
refer_bytes: 2000,
creation: "1".into(), // epoch start — very old
};
let recent = Snapshot {
name: "pool@new".into(),
dataset: "pool".into(),
tag: "new".into(),
used_bytes: 1000,
refer_bytes: 2000,
creation: format!(
"{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
),
};
let all = vec![ancient.clone(), recent];
let old = Snapshot::older_than(&all, 1.0);
assert_eq!(old.len(), 1);
assert_eq!(old[0].tag, "old");
}
#[test]
fn destroy_nonexistent_is_error() {
let s = Snapshot {
name: "zroot/nonexistent@definitely_not_real_2026".into(),
dataset: "zroot/nonexistent".into(),
tag: "definitely_not_real_2026".into(),
used_bytes: 0,
refer_bytes: 0,
creation: "0".into(),
};
assert!(s.destroy().is_err());
}
}

View file

@ -0,0 +1,90 @@
# Dashboard Screenshot Proof — Agent Handoff
**Status:** Hermes done (Steps 1-2). Claude: Step 3. Codex: Step 4.
## What Hermes Built (Steps 1-2 — done, branch `feat/dashboard-screenshot-proof`)
- **Schema:** `task_costs.screenshot_uuid TEXT` column + `ALTER TABLE IF NOT EXISTS` migration
- **Daemon:** `push_cost_to_mother()` reads `COLIBRI_TASK_SCREENSHOT_UUID` env var, attaches it to the SSH payload
- **SSH wrapper:** `report-task-cost` INSERT includes `screenshot_uuid` via `NULLIF`
Flow: agent harness sets `COLIBRI_TASK_SCREENSHOT_UUID=<uuid>` before spawning colibri. Daemon heartbeat picks it up when task completes, pushes to mother. Mother stores the UUID alongside cost row.
## Step 3 — Dashboard HTML (Claude)
Build a single-page dashboard at a webroot path (TBD with Sam — e.g. `https://osa.taile682b7.ts.net/dashboard/`).
### Data source
Query `task_costs` via a JSON endpoint or a static dump:
```json
[
{
"id": 1,
"node_hostname": "debby",
"task_id": "abc-123",
"provider": "deepseek",
"model": "deepseek-chat",
"input_tokens": 45230,
"output_tokens": 2847,
"cache_read_tokens": 12100,
"cost_usd": 0.0042,
"success": true,
"finished_at": "2026-06-27T13:42:00Z",
"screenshot_uuid": "a1b2c3d4e5f6"
}
]
```
Proposed JSON source: a small CGI script (`query.cgi`) that runs `psql -d mother_hive -tA -c "SELECT json_agg(row_to_json(...)) FROM task_costs LEFT JOIN hive_nodes ON ... ORDER BY finished_at DESC LIMIT 200"`. Or a cron-dumped `dashboard.json` (simpler, no CGI). Your call.
### UI (terminal-printable mockup above)
- **Node rows** grouping cost cards by `node_hostname`
- **Cost cards** with: cache-hit bar (green = cache fraction of total tokens), provider badge, cost, ▸ marker if `screenshot_uuid` is non-null
- **Lightbox** on ▸ click: opens `screenshots/<uuid>.png` (reuse tmux-screenshot's lightbox pattern from its index.html)
- **Filters:** node dropdown, date range, provider, success/fail checkbox
- **Zero dependencies beyond vanilla JS + CSS** (this is FreeBSD, not a Node host)
### Screenshot storage
Screenshots live at a web-accessible path. tmux-screenshot.py already outputs to a content-addressed directory. The dashboard just needs a URL pattern like `screenshots/<uuid>.png`.
### Constraints
- No Node.js, no npm build step — plain HTML/CSS/JS
- Must work from a static directory served by nginx or thttpd on FreeBSD
- Reuse existing tmux-screenshot gallery patterns where possible (lightbox, date filter, grid)
## Step 4 — OSA-Side Setup (Codex)
On osa (FreeBSD), as clawdie user:
1. **Run schema migration:**
```sh
psql -d mother_hive -f packaging/mother/mother_schema.sql
```
Verifies `screenshot_uuid` column exists.
2. **Daemon env:**
```sh
echo 'COLIBRI_MOTHER_HOST=osa.taile682b7.ts.net' >> /var/db/colibri/provider.env
```
3. **SSH key verification:**
```sh
# Confirm authorized_keys has command= restriction for colibri user
sudo cat ~colibri/.ssh/authorized_keys | grep command=
```
4. **Deploy dashboard HTML** (once Claude delivers it):
```sh
sudo mkdir -p /usr/local/www/dashboard
sudo cp ~clawdie/dashboard/index.html /usr/local/www/dashboard/
# Configure nginx location block to serve /dashboard/
```
5. **End-to-end test:**
```sh
echo '{"node_hostname":"debby","task_id":"test-001","provider":"deepseek","model":"deepseek-chat","input_tokens":100,"output_tokens":50,"cost_usd":0.002,"success":true,"screenshot_uuid":"test-uuid-123"}' | ssh colibri@osa.taile682b7.ts.net report-task-cost
psql -d mother_hive -c "SELECT * FROM task_costs ORDER BY id DESC LIMIT 1"
```
## Deletion
Delete this file when Steps 3 and 4 are complete and confirmed working end-to-end.

View file

@ -8,13 +8,13 @@
Colibri speaks 5 protocols today:
| Protocol | Where | Lines | Purpose |
|---|---|---|---|
| **Custom JSON wire** | `crates/colibri-daemon/src/socket.rs` + `crates/colibri-client/src/lib.rs` | 1,981 | Local daemon control (spawn, status, snapshot, tasks, skills) |
| **MCP JSON-RPC** | `crates/colibri-mcp/src/lib.rs` | 570 | Editor integration + external MCP host |
| **MCP-over-SSH** | `packaging/mother/` (3 files) | 437 | Mother hive entrypoint (forced-command allowlist + node register) |
| **JSONL** | `crates/colibri-glasspane/src/lib.rs` | 1,186 | Agent subprocess stdout events |
| **SQL** | `crates/colibri-store/src/lib.rs` + `crates/colibri-store/src/schema.rs` | 1,150 | Local coordination (tasks, agents, skills, tenants) |
| Protocol | Where | Lines | Purpose |
| -------------------- | -------------------------------------------------------------------------- | ----- | ----------------------------------------------------------------- |
| **Custom JSON wire** | `crates/colibri-daemon/src/socket.rs` + `crates/colibri-client/src/lib.rs` | 1,981 | Local daemon control (spawn, status, snapshot, tasks, skills) |
| **MCP JSON-RPC** | `crates/colibri-mcp/src/lib.rs` | 570 | Editor integration + external MCP host |
| **MCP-over-SSH** | `packaging/mother/` (3 files) | 437 | Mother hive entrypoint (forced-command allowlist + node register) |
| **JSONL** | `crates/colibri-glasspane/src/lib.rs` | 1,186 | Agent subprocess stdout events |
| **SQL** | `crates/colibri-store/src/lib.rs` + `crates/colibri-store/src/schema.rs` | 1,150 | Local coordination (tasks, agents, skills, tenants) |
**Total protocol surface: ~5,324 lines.**
@ -40,6 +40,7 @@ USB node → HTTPS → mother A2A endpoint → PostgreSQL
```
**Removed:**
- `colibri-mcp-ssh` (32 lines) — SSH forced-command allowlist wrapper
- `node-register-mcp` (88 lines) — Custom MCP tool with embedded psql
- SSH key management in `setup-mother.sh` (~40 lines of key distribution logic)
@ -47,6 +48,7 @@ USB node → HTTPS → mother A2A endpoint → PostgreSQL
**Removed total: ~160 lines.**
**Added:**
- A2A HTTP endpoint on mother (~200 lines)
- A2A client library integration on USB node (~150 lines)
- mTLS/TLS termination for auth (~30 lines)
@ -54,6 +56,7 @@ USB node → HTTPS → mother A2A endpoint → PostgreSQL
**Added total: ~380 lines.**
**Net delta: +220 lines.** Not a code reduction. But operational complexity drops significantly:
- No SSH key distribution to USB nodes (key lives on seed partition → no longer needed on mother)
- No forced-command allowlist to maintain
- Standard HTTPS is easier to firewall, audit, and monitor than SSH forced-command
@ -78,7 +81,7 @@ Today: external MCP registry config — manual JSON listing third-party MCP serv
With A2A: third-party tools that speak A2A (not MCP) publish an Agent Card. Colibri discovers them via the well-known Agent Card URL instead of manual JSON config files.
**Reality check:** No third-party tools speak A2A yet. The protocol was just announced (April 2025). MCP has ~2 years of ecosystem maturity. This is a *future* replacement, not a *current* one.
**Reality check:** No third-party tools speak A2A yet. The protocol was just announced (April 2025). MCP has ~2 years of ecosystem maturity. This is a _future_ replacement, not a _current_ one.
**Verdict:** A2A discovery doesn't reduce code today. External MCP stays for tool access.
@ -94,20 +97,20 @@ With A2A: cost data is a typed message part (`application/json+cost`). The forma
**Code savings:** ~10 lines (the info! log stays; the A2A part is new code).
**Verdict:** Negligible code impact. The value is *interop*, not complexity reduction.
**Verdict:** Negligible code impact. The value is _interop_, not complexity reduction.
---
## What A2A does NOT replace
| Component | Why A2A doesn't touch it | Lines saved |
|---|---|---|
| **Unix socket wire protocol** (`crates/colibri-daemon/src/socket.rs`) | A2A is cross-node HTTP. Local daemon control needs IPC — Unix socket is faster, auth-free (filesystem permissions), and doesn't need a network stack. | 0 |
| **Spawner** (`crates/colibri-daemon/src/spawner.rs`) | A2A routes tasks to existing agents. Colibri *creates* agents by spawning subprocesses. A2A has no process lifecycle concept. | 0 |
| **Glasspane** (`crates/colibri-glasspane/src/lib.rs`) | A2A doesn't watch subprocess stdout. Glasspane is a PTY observer — it reads JSONL from child processes. A2A operates one layer above. | 0 |
| **Store** (`crates/colibri-store/src/lib.rs`) | A2A doesn't replace local SQLite coordination. Each node needs local persistence for task board, agents, skills — A2A is the *transport*, not the *database*. | 0 |
| **MCP editor bridge** | A2A is agent-to-agent. MCP is human-to-tool. Different protocols for different directions. They coexist. | 0 |
| **Contracts schemas** (`crates/colibri-contracts/src/lib.rs`) | A2A uses JSON Schema for input validation. Colibri's contracts are already compatible — no change needed. | 0 |
| Component | Why A2A doesn't touch it | Lines saved |
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| **Unix socket wire protocol** (`crates/colibri-daemon/src/socket.rs`) | A2A is cross-node HTTP. Local daemon control needs IPC — Unix socket is faster, auth-free (filesystem permissions), and doesn't need a network stack. | 0 |
| **Spawner** (`crates/colibri-daemon/src/spawner.rs`) | A2A routes tasks to existing agents. Colibri _creates_ agents by spawning subprocesses. A2A has no process lifecycle concept. | 0 |
| **Glasspane** (`crates/colibri-glasspane/src/lib.rs`) | A2A doesn't watch subprocess stdout. Glasspane is a PTY observer — it reads JSONL from child processes. A2A operates one layer above. | 0 |
| **Store** (`crates/colibri-store/src/lib.rs`) | A2A doesn't replace local SQLite coordination. Each node needs local persistence for task board, agents, skills — A2A is the _transport_, not the _database_. | 0 |
| **MCP editor bridge** | A2A is agent-to-agent. MCP is human-to-tool. Different protocols for different directions. They coexist. | 0 |
| **Contracts schemas** (`crates/colibri-contracts/src/lib.rs`) | A2A uses JSON Schema for input validation. Colibri's contracts are already compatible — no change needed. | 0 |
**Total irreplaceable: ~5,000 lines.** A2A doesn't reduce this at all.
@ -138,16 +141,16 @@ TOTAL 5,524 5,467
A2A is not a complexity reduction play. It's an **interoperability and operational simplicity** play:
| Metric | MCP-over-SSH (current) | A2A (proposed) |
|---|---|---|
| **Lines of code** | ~5,524 (spread across 6 crates + 3 shell scripts) | ~5,467 (SSH scripts gone, A2A handler added) |
| **Protocol count** | 5 | 6 (A2A adds one) |
| **Operational complexity** | SSH keys × N nodes, forced-command allowlists, peer auth setup | One HTTPS endpoint, mTLS certs, well-known URL |
| **Discoverability** | Manual external MCP registry entries | Agent Card at well-known URL |
| **Interoperability** | Colibri-only | Any A2A client |
| **Debugability** | `ssh -v`, `psql`, `jq` | `curl`, browser devtools, standard HTTP tooling |
| **Ecosystem maturity** | N/A (Colibri-specific) | Protocol < 3 months old, zero adoption |
| **When it pays off** | Works today for 4 nodes | Pays off at 10+ nodes, or when 3rd-party tools ship A2A |
| Metric | MCP-over-SSH (current) | A2A (proposed) |
| -------------------------- | -------------------------------------------------------------- | ------------------------------------------------------- |
| **Lines of code** | ~5,524 (spread across 6 crates + 3 shell scripts) | ~5,467 (SSH scripts gone, A2A handler added) |
| **Protocol count** | 5 | 6 (A2A adds one) |
| **Operational complexity** | SSH keys × N nodes, forced-command allowlists, peer auth setup | One HTTPS endpoint, mTLS certs, well-known URL |
| **Discoverability** | Manual external MCP registry entries | Agent Card at well-known URL |
| **Interoperability** | Colibri-only | Any A2A client |
| **Debugability** | `ssh -v`, `psql`, `jq` | `curl`, browser devtools, standard HTTP tooling |
| **Ecosystem maturity** | N/A (Colibri-specific) | Protocol < 3 months old, zero adoption |
| **When it pays off** | Works today for 4 nodes | Pays off at 10+ nodes, or when 3rd-party tools ship A2A |
---

141
docs/wiki/cost-dashboard.md Normal file
View file

@ -0,0 +1,141 @@
---
title: Cost Dashboard
description: "Mother-side cost observability — human gallery + agent-friendly JSON, with screenshot proof linked from every cost row."
---
← [index](./index.md)
The Cost Dashboard is the presentation surface for the hive-wide cost data flowing
into mother's `task_costs` table. It serves two audiences: operators (human gallery)
and agents (JSON query).
## Decision
One HTML page, two views. The same data feeds both a human-browseable card grid
(with lightbox screenshot proofs) and an agent-queryable JSON panel. No server-side
rendering — static HTML with a JSON data file refreshed every 60s by cron.
## What it shows
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ HIVE COST DASHBOARD [4 nodes] [24h] [$2.37] │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ debby ● online 12 tasks $1.87 ▲ $0.15 avg 89% success │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ ████░░░░ │ │ ████████ │ │ ██████░░ │ │ ████████ │ ← cost cards │
│ │ deepseek │ │ deepseek │ │ claude │ │ gemini │ │
│ │ $0.0042 │ │ $0.0031 │ │ $0.89 ▸ │ │ $0.02 ▸ │ ▸ = screenshot │
│ │ ✓ │ │ ✓ │ │ ✗ │ │ ✓ │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└──────────────────────────────────────────────────────────────────────────────┘
```
Each cost card shows:
- **Cache-hit bar:** green (cache) vs grey (fresh) — visual cache efficiency
- **Provider:** deepseek / claude / gemini / ollama / local
- **Cost:** with `▸ proof` badge if a tmux-screenshot exists for this task
- **Success:** ✓ (green) or ✗ (red)
Click a card with `▸` → lightbox opens the terminal screenshot at task completion time.
## Data sources
| Source | What | Refresh |
|---|---|---|
| `task_costs` (PostgreSQL) | Per-task cost rows pushed by daemon heartbeat | Real-time (SSH push on completion) |
| `hive_nodes` (PostgreSQL) | Node metadata, capabilities, LLM tier | Node heartbeat |
| `task_costs JSON file` | Denormalized JSON for the dashboard page | Every 60s (cron) |
| `../screenshots/{uuid}.png` (static) | tmux-screenshot captures linked from cost rows | On task completion (daemon) |
## Architecture
```
daemon heartbeat
├─ push_cost_to_mother() ──SSH──→ colibri-mcp-ssh "report-task-cost"
│ │
│ └─ INSERT INTO task_costs
│ (node_hostname → node_id lookup)
└─ (optional) tmux-screenshot.py → ../screenshots/{uuid}.png
on COLIBRI_SCREENSHOT_ON_COMPLETION=1
cron (every 60s)
└─ export-costs.sh
└─ psql → task_costs JSON file (dashboard data file)
browser
└─ GET /dashboard/ → index.html
└─ fetch task_costs JSON file → render cards, lightbox, JSON panel
```
## Deployment
```sh
# On mother (osa):
cd /usr/local/src/colibri/packaging/mother/dashboard
./deploy.sh
```
This places:
- `/usr/local/www/clawdie/dashboard/index.html` — the dashboard page
- `export-costs.sh` — JSON export script (in mother webroot)
- `/usr/local/etc/cron.d/clawdie-dashboard` — cron job (every 60s)
Nginx serves it as a static location under the existing mother vhost.
## Agent-friendly JSON
The dashboard has a **JSON** toggle button that shows the filtered data as
structured JSON. This is the same data agents get via `colibri_list_task_costs`
MCP tool, but with screenshot UUIDs and node groupings:
```json
{
"updated_at": "2026-06-27T14:00:00Z",
"summary": {
"total_tasks": 23,
"total_cost": 2.37,
"avg_cost": 0.103,
"success_rate": 87.0,
"cache_hit_ratio": 64.3
},
"nodes": {
"debby": [
{
"task_id": "abc-123",
"provider": "deepseek",
"cost": 0.0042,
"success": true,
"screenshot_uuid": "a1b2c3d4e5f6",
"tokens": {"in": 45000, "out": 2800, "cache_read": 12000}
}
]
}
}
```
Agents use this for cost-aware routing: "debby averages $0.004/task with 89%
cache-hit on DeepSeek — route non-urgent tasks there."
## Screenshot proof
The `▸ proof` badge on cost cards links to tmux-screenshot captures. The
screenshot UUID is stored alongside the cost row. Clicking opens the lightbox
with:
- The full terminal PNG at task completion time
- Task ID, provider, and cost in the overlay
- The screenshot metadata (pane title, command, path, timestamp)
- Signature detection results (failures, warnings, healthy signals)
This is the "verify, don't guess" layer — every cost number has visual proof
behind it.
## References
- [task-board](./task-board.md) — local task board (data source for cost capture)
- [hive-pane](./hive-pane.md) — hive board (companion surface, node status)
- [hive-routing](./hive-routing.md) — cost-aware routing engine (consumes this data)
- [tmux-screenshot skill](../../.agent/skills/tmux-screenshot/SKILL.md) — screenshot capture

View file

@ -161,13 +161,13 @@ as the wiki.
A2A tasks map directly to Colibri's task board:
| A2A state | Colibri equivalent |
| -------------- | ------------------ |
| `submitted` | `Pending` |
| `working` | `Started` |
| `completed` | `Done` |
| `failed` | `Error` |
| `canceled` | (not yet modeled) |
| A2A state | Colibri equivalent |
| ----------- | ------------------ |
| `submitted` | `Pending` |
| `working` | `Started` |
| `completed` | `Done` |
| `failed` | `Error` |
| `canceled` | (not yet modeled) |
Mother pushes a `node_register` task to a new USB node; the node executes it and
returns the result. The task carries cost data as a typed A2A part:
@ -187,13 +187,13 @@ returns the result. The task carries cost data as a typed A2A part:
### What A2A adds over the current MCP bridge
| Concern | Current (MCP + SSH) | A2A |
| -------------------- | ----------------------------- | -------------------------------- |
| Discovery | Manual external MCP registry entry | Well-known Agent Card URL |
| Interop | Colibri-only | Any A2A client |
| Cost data | Embedded in task completion | Typed `application/json+cost` |
| Push notifications | Polling (heartbeat) | Optional webhook/push |
| Versioning | Ad-hoc | Agent Card version + schema pins |
| Concern | Current (MCP + SSH) | A2A |
| ------------------ | ---------------------------------- | -------------------------------- |
| Discovery | Manual external MCP registry entry | Well-known Agent Card URL |
| Interop | Colibri-only | Any A2A client |
| Cost data | Embedded in task completion | Typed `application/json+cost` |
| Push notifications | Polling (heartbeat) | Optional webhook/push |
| Versioning | Ad-hoc | Agent Card version + schema pins |
A2A is not a replacement for the MCP bridge — it's the next layer. The MCP
bridge handles local daemon commands (status, snapshot, spawn). A2A handles

View file

@ -10,15 +10,15 @@
## What Exists Today
| Component | State | Gap |
|---|---|---|
| `mother_schema.sql` | `hive_nodes` table with `hw_profile` + `capabilities` JSONB | No stable node UUID; hostname is the key |
| `derive_capabilities()` trigger | Auto-computes `has_gpu`, `gpu_vendor`, `can_run_local_llm`, `max_model` from hw_profile | Only GPU/VRAM heuristics — doesn't probe running services |
| `clawdie-hw-probe` | Collects GPU, RAM, CPU, disks, ZFS, WiFi, Vulkan, Colibri status | No ollama/llama.cpp probing |
| `node-register-mcp` | UPSERTs hw_profile into `hive_nodes` on join | No UUID generation at join time |
| `crates/colibri-daemon/src/scheduler.rs` | Cron/interval/one-shot jobs, capability matching stubs | No cost-aware routing, no hive awareness |
| `colibri-store` | Local SQLite `agents` table with UUID (v4 random) | UUID is session-local, not hive-stable |
| T1.5 cost tracking | Per-task cost captured in local SQLite | No hive-level cost aggregation |
| Component | State | Gap |
| ---------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| `mother_schema.sql` | `hive_nodes` table with `hw_profile` + `capabilities` JSONB | No stable node UUID; hostname is the key |
| `derive_capabilities()` trigger | Auto-computes `has_gpu`, `gpu_vendor`, `can_run_local_llm`, `max_model` from hw_profile | Only GPU/VRAM heuristics — doesn't probe running services |
| `clawdie-hw-probe` | Collects GPU, RAM, CPU, disks, ZFS, WiFi, Vulkan, Colibri status | No ollama/llama.cpp probing |
| `node-register-mcp` | UPSERTs hw_profile into `hive_nodes` on join | No UUID generation at join time |
| `crates/colibri-daemon/src/scheduler.rs` | Cron/interval/one-shot jobs, capability matching stubs | No cost-aware routing, no hive awareness |
| `colibri-store` | Local SQLite `agents` table with UUID (v4 random) | UUID is session-local, not hive-stable |
| T1.5 cost tracking | Per-task cost captured in local SQLite | No hive-level cost aggregation |
## Design Goals
@ -81,17 +81,19 @@ A 32-character hex UUID generated once, stored locally, included in every hw-pro
```
**Properties:**
- **Stable across reboots**: stored on disk, not tmpfs
- **Survives re-provisioning**: if the seed partition preserves `/var/db/machine-id`, the same physical machine keeps the same identity
- **Not a secret**: it's an ID, not a key
- **Verifiable**: mother can check "has node a1b2c3d4 ever joined?" — if yes, this is a rejoin, not a new node
**Alternatives considered:**
| Approach | Pros | Cons |
|---|---|---|
| SMBIOS UUID (`hw.uuid`) | Truly hardware-bound, survives OS reinstall | Not available on all platforms (VPS, ARM); can be spoofed |
| SSH host key fingerprint | Cryptographically strong | Changes on OS reinstall; key rotation breaks identity |
| Random UUID (this design) | Portable, simple, survives seed restore | Can be copied/cloned (but same machine, same ID — that's correct) |
| Approach | Pros | Cons |
| ------------------------- | ------------------------------------------- | ----------------------------------------------------------------- |
| SMBIOS UUID (`hw.uuid`) | Truly hardware-bound, survives OS reinstall | Not available on all platforms (VPS, ARM); can be spoofed |
| SSH host key fingerprint | Cryptographically strong | Changes on OS reinstall; key rotation breaks identity |
| Random UUID (this design) | Portable, simple, survives seed restore | Can be copied/cloned (but same machine, same ID — that's correct) |
**Recommendation:** Generate on first boot, store in `/var/db/machine-id`. The hw-probe includes it as `machine_id`. Mother's `hive_nodes` table gets a `UNIQUE` constraint on `machine_id`.
@ -112,18 +114,18 @@ The `node-register-mcp` UPSERT switches from `ON CONFLICT (hostname)` to `ON CON
Every capability is a boolean derived from hardware facts, not a self-declaration. The hw-probe collects hardware; the trigger derives capabilities.
| Capability | Derived from | Used for |
|---|---|---|
| `has_gpu` | GPU detected in pciconf | GPU-accelerated inference |
| `gpu_vendor` | amdgpu/nvidia driver | Model compatibility |
| `vulkan_compute` | vulkaninfo success | llama.cpp Vulkan backend |
| `can_run_local_llm` | RAM ≥ 16GB or has GPU | Eligibility for local task execution |
| `max_model` | RAM heuristic | Model size limit (3b, 7b-q4, 13b-q4, 34b-q4) |
| `cpu_only` | No GPU detected | Fallback only (slow) |
| `has_wifi` | wlan devices | Network capability |
| `has_zfs` | ZFS pools non-empty | Storage capability |
| `colibri_running` | service status | Agent host eligibility |
| `provider_api_keys` | MCP-reported (not hw probe) | Cloud provider availability |
| Capability | Derived from | Used for |
| ------------------- | --------------------------- | -------------------------------------------- |
| `has_gpu` | GPU detected in pciconf | GPU-accelerated inference |
| `gpu_vendor` | amdgpu/nvidia driver | Model compatibility |
| `vulkan_compute` | vulkaninfo success | llama.cpp Vulkan backend |
| `can_run_local_llm` | RAM ≥ 16GB or has GPU | Eligibility for local task execution |
| `max_model` | RAM heuristic | Model size limit (3b, 7b-q4, 13b-q4, 34b-q4) |
| `cpu_only` | No GPU detected | Fallback only (slow) |
| `has_wifi` | wlan devices | Network capability |
| `has_zfs` | ZFS pools non-empty | Storage capability |
| `colibri_running` | service status | Agent host eligibility |
| `provider_api_keys` | MCP-reported (not hw probe) | Cloud provider availability |
### Local LLM capabilities (NEW)
@ -143,14 +145,14 @@ Extend the hw-probe to detect running local LLM services and extend the trigger
**New derived capabilities:**
| Capability | Derivation |
|---|---|
| `ollama_available` | `ollama_running == true` |
| `ollama_models` | Array of model tags (from `ollama list`) |
| `llama_cpp_available` | Binary at `/usr/local/bin/llama-server` or similar |
| `llama_cpp_models` | GGUFs in `/var/db/models/` or `/usr/local/share/models/` |
| `can_embed_locally` | `nomic-embed-text` in ollama OR any embedding model loaded |
| `inference_tier` | `local-fast` (GPU ≥ 24GB), `local-slow` (CPU-only, RAM ≥ 16GB), `cloud-only` |
| Capability | Derivation |
| --------------------- | ---------------------------------------------------------------------------- |
| `ollama_available` | `ollama_running == true` |
| `ollama_models` | Array of model tags (from `ollama list`) |
| `llama_cpp_available` | Binary at `/usr/local/bin/llama-server` or similar |
| `llama_cpp_models` | GGUFs in `/var/db/models/` or `/usr/local/share/models/` |
| `can_embed_locally` | `nomic-embed-text` in ollama OR any embedding model loaded |
| `inference_tier` | `local-fast` (GPU ≥ 24GB), `local-slow` (CPU-only, RAM ≥ 16GB), `cloud-only` |
### Probe additions to `clawdie-hw-probe`
@ -181,12 +183,12 @@ cache_weight: 0.01.0 (warm cache → higher weight)
### Cost tiers
| Tier | Provider | Cost per 1M tokens | Latency | Used when |
|---|---|---|---|
| T0 (free) | Local ollama/llama.cpp | $0.00 | 560s | Non-urgent, capability match |
| T1 (cheap) | DeepSeek V3 | $0.27 / $1.10 | 25s | Default for most tasks |
| T2 (balanced) | Gemini Flash | $0.15 / $0.60 | 13s | High cache-hit tasks |
| T3 (premium) | Claude Sonnet 4 | $3.00 / $15.00 | 38s | Complex reasoning, only when needed |
| Tier | Provider | Cost per 1M tokens | Latency | Used when |
| ------------- | ---------------------- | ------------------ | ------- | ----------------------------------- |
| T0 (free) | Local ollama/llama.cpp | $0.00 | 560s | Non-urgent, capability match |
| T1 (cheap) | DeepSeek V3 | $0.27 / $1.10 | 25s | Default for most tasks |
| T2 (balanced) | Gemini Flash | $0.15 / $0.60 | 13s | High cache-hit tasks |
| T3 (premium) | Claude Sonnet 4 | $3.00 / $15.00 | 38s | Complex reasoning, only when needed |
### Local LLM routing rules
@ -257,6 +259,7 @@ When the task completes, the local daemon writes cost to its SQLite (T1.5). The
**What:** Mother is the brain. Nodes register, mother routes. No peer-to-peer.
**Implementation:**
1. Add `machine_id` to `hive_nodes` + hw-probe (1 day)
2. Extend `derive_capabilities()` for local LLM (1 day)
3. Add `routing_score()` function to mother's PostgreSQL (stored function — zero Rust changes)
@ -268,12 +271,14 @@ When the task completes, the local daemon writes cost to its SQLite (T1.5). The
**Total:** ~3.5 days.
**Pros:**
- Simple to reason about — one source of truth
- Lowest implementation risk
- Scheduler lives on mother (always-on)
- Existing MCP bridge handles all communication
**Cons:**
- Mother is single point of failure for routing (but not execution — once dispatched, the task runs independently)
- Latency: scheduler must query mother on every tick
- Doesn't scale to 100+ nodes (not a real concern for our use case)
@ -285,6 +290,7 @@ When the task completes, the local daemon writes cost to its SQLite (T1.5). The
**What:** Mother stores the matrix, but nodes can also route tasks they own to peers directly. Hybrid: central registry + distributed execution.
**Implementation:**
1. All of Option A (3.5 days)
2. Add `capabilities` API to `colibri-daemon`'s Unix socket (self-awareness) — 1 day
3. Add local peer discovery via mDNS or Tailscale whois — 1 day
@ -294,12 +300,14 @@ When the task completes, the local daemon writes cost to its SQLite (T1.5). The
**Total:** ~8.5 days.
**Pros:**
- Lower latency for local dispatch
- Survives mother downtime for peer-to-peer tasks
- Natural fit for local LLM use case (beefy node is on same LAN)
- Nodes that discover each other can route without phoning home
**Cons:**
- Complexity: two code paths (central + peer-to-peer)
- Security: peer-to-peer dispatch needs authentication (who can send tasks to my daemon?)
- Harder to audit: cost tracking must handle peer-dispatched vs mother-dispatched tasks differently
@ -312,6 +320,7 @@ When the task completes, the local daemon writes cost to its SQLite (T1.5). The
**What:** Don't build a routing engine at all. The capability matrix is exposed as an MCP tool that agents query. The agent itself decides where to route based on the matrix + its own reasoning. The matrix is advisory, not prescriptive.
**Implementation:**
1. All of Option A minus the routing_scoring function (2.5 days)
2. Add `colibri_query_hive_capabilities` MCP tool on mother — returns full online node matrix (0.5 day)
3. Add `colibri_dispatch_to_node` MCP tool — sends task to a specific node (1 day)
@ -320,6 +329,7 @@ When the task completes, the local daemon writes cost to its SQLite (T1.5). The
**Total:** ~4.5 days. **Zero scheduler changes.**
**Pros:**
- Exploits Colibri's architecture-as-differentiator: the agent IS the intelligence
- The routing decision is auditable in the conversation log (why did the agent pick this node?)
- Natural fit for local LLM — the agent can reason "this task is low priority, I'll try the beefy node first"
@ -327,6 +337,7 @@ When the task completes, the local daemon writes cost to its SQLite (T1.5). The
- The skill can be iterated without recompiling Colibri
**Cons:**
- Each routing decision costs tokens (the agent must reason about it)
- Agents make inscrutable routing choices (the LLM "just knows")
- No hard guarantees — an agent might route a $5 task to Claude when DeepSeek would do fine
@ -350,26 +361,26 @@ The capability matrix, stable UUIDs, and local LLM probes are the foundation —
### Phase 1 — Identity & Capability Foundation
| Deliverable | Where | Lines |
|---|---|---|
| `machine_id` generation in `clawdie-firstboot` | clawdie-iso | ~15 |
| `collect_machine_id()` in hw-probe | clawdie-iso | ~10 |
| `collect_ollama_status()` in hw-probe | clawdie-iso | ~30 |
| `collect_llama_cpp()` in hw-probe | clawdie-iso | ~20 |
| `collect_local_llm()` aggregator in hw-probe | clawdie-iso | ~25 |
| `machine_id` column + constraint in mother_schema.sql | colibri | ~5 |
| Extended `derive_capabilities()` for `ollama_available`, `llama_cpp_available`, `inference_tier` | colibri | ~40 |
| `node-register-mcp` handling of `machine_id` key + new local_llm fields | colibri | ~15 |
| This design doc (hive-routing.md) | This file | ~0 (done) |
| Deliverable | Where | Lines |
| ------------------------------------------------------------------------------------------------ | ----------- | --------- |
| `machine_id` generation in `clawdie-firstboot` | clawdie-iso | ~15 |
| `collect_machine_id()` in hw-probe | clawdie-iso | ~10 |
| `collect_ollama_status()` in hw-probe | clawdie-iso | ~30 |
| `collect_llama_cpp()` in hw-probe | clawdie-iso | ~20 |
| `collect_local_llm()` aggregator in hw-probe | clawdie-iso | ~25 |
| `machine_id` column + constraint in mother_schema.sql | colibri | ~5 |
| Extended `derive_capabilities()` for `ollama_available`, `llama_cpp_available`, `inference_tier` | colibri | ~40 |
| `node-register-mcp` handling of `machine_id` key + new local_llm fields | colibri | ~15 |
| This design doc (hive-routing.md) | This file | ~0 (done) |
### Phase 2 — Routing Engine
| Deliverable | Where |
|---|---|
| `colibri_query_hive_capabilities` MCP tool | colibri-mcp |
| `colibri_dispatch_to_node` MCP tool | colibri-mcp |
| `hive-routing` skill | `.agent/skills/` |
| `Task.routing` JSONB field in colibri-store | colibri-store |
| Deliverable | Where |
| -------------------------------------------------------------------------------------------------------------- | ----------------- |
| `colibri_query_hive_capabilities` MCP tool | colibri-mcp |
| `colibri_dispatch_to_node` MCP tool | colibri-mcp |
| `hive-routing` skill | `.agent/skills/` |
| `Task.routing` JSONB field in colibri-store | colibri-store |
| Mother-side routing score as PostgreSQL function (optional — only if agent-driven routing proves insufficient) | mother_schema.sql |
---

View file

@ -55,6 +55,7 @@ warning.
| [mother-hive](./mother-hive.md) | Mother MCP architecture — forced-command SSH, single-home-in-colibri, peer auth, key-on-seed |
| [hive-routing](./hive-routing.md) | Hive member identity (machine UUID), capability matrix + local LLM probes, cost-aware task routing |
| [hive-pane](./hive-pane.md) | Glasspane for the hive — multi-node cost observability, A2A discovery, and operator board |
| [cost-dashboard](./cost-dashboard.md) | Mother-side cost observability — human gallery + JSON, screenshot proof linked from cost rows |
| [a2a-complexity-audit](./a2a-complexity-audit.md) | A2A code complexity impact — 6-protocol surface audit, when A2A pays off |
| [naming-decisions](./naming-decisions.md) | Ledger of harness-neutral / architecture renames — shipped and in-flight |
| [daemon-not-demon](./daemon-not-demon.md) | Why we say daemon (helper spirit) not demon (bad spirit) — English + Slovenian |

View file

@ -103,7 +103,34 @@
103|→ [`clawdie-live-seed` (clawdie-iso)](https://code.smilepowered.org/clawdie/clawdie-iso/src/branch/main/live/operator-session/clawdie-live-seed),
104|[`MOTHER-SETUP.md` §Key management](../../packaging/mother/MOTHER-SETUP.md#key-management)
105|
106|## See also
106|### Per-task cost aggregation (`task_costs`)
When an agent finishes, the daemon's heartbeat captures a `TaskCostSummary` to
the local SQLite store and also pushes it to mother via `ssh mother
report-task-cost`. The mother resolves the sending node's hostname to a
`hive_nodes.id` and INSERTs into `task_costs`.
**Why push, not pull**: a pull model requires mother to SSH into every node
periodically (N connections, node firewalls, scheduling). Push reuses the
node's existing outbound SSH to mother — one connection per heartbeat tick,
fire-and-forget. Nodes that lose connectivity queue locally (SQLite) and resume
pushing when the link returns.
**Why a separate table, not `hive_nodes` columns**: costs are per-task,
time-series data. Storing them as cumulative counters on `hive_nodes` would
lose per-provider, per-model, and per-time-slice detail. `task_costs` keeps
one row per task completion — the dashboard can aggregate by any dimension.
**Why `node_hostname` in the payload, not `node_id`**: the daemon only knows
its own hostname, not the mother-assigned `hive_nodes.id`. The mother resolves
it via a subquery. If the node hasn't registered yet, the INSERT fails on the
FK — correct behaviour (register first, then report costs).
→ [`mother_schema.sql`](../../packaging/mother/mother_schema.sql) (task_costs DDL),
[`colibri-mcp-ssh`](../../packaging/mother/colibri-mcp-ssh) (`report-task-cost` case),
[`daemon.rs`](../../crates/colibri-daemon/src/daemon.rs) (`push_cost_to_mother`)
## See also
107|
108|- [agent-harness](./agent-harness.md) — the zot/Colibri split; autospawn
109|- [naming-decisions](./naming-decisions.md) — `usb_nodes → hive_nodes`, autospawn flag rename

View file

@ -23,7 +23,7 @@ _sheme in (De)serialize_, ne poslovne logike.
| -------------------------------------- | --------------------- | ------------------------------------------------------------------------- |
| `clawdie.interagent.run-manifest.v1` | `RunManifest` | Beleži tek gradnje/testa — vloga, agent, artefakti, povzetek. |
| `clawdie.runtime-version-inventory.v1` | `RuntimeInventory` | Posnetek izvajalnega okolja gostitelja — OS, različice paketov, npm/node. |
| `clawdie.provider-test.result.v1` | `ProviderSmokeResult` | Rezultat sonde predpomnilnika DeepSeek in obračun žetonov. |
| `clawdie.provider-test.result.v1` | `ProviderSmokeResult` | Rezultat sonde predpomnilnika DeepSeek in obračun žetonov. |
Konstante shem in strukture živijo v `crates/colibri-contracts/src/lib.rs`.

View file

@ -59,19 +59,21 @@ clippy.
| [headroom-sidecar](./headroom-sidecar.md) | Neobvezni stranski vagon za stiskanje rezultatov orodij in njegov protokol Unix vtičnice |
| [jail-confinement](./jail-confinement.md) | Trajne proti prehodnim ječam, pravilnik načina priv, ponovna uporaba omejitve zaganjalnika za strežnike MCP |
| [mother-hive](./mother-hive.md) | Arhitektura matičnega MCP — SSH s prisiljenim ukazom, enojni-dom-v-colibri, peer avtentikacija, ključ-na-semenu |
| [hive-pane](./hive-pane.md) | Steklena plošča za panj — opazovanje stroškov več vozlišč, odkrivanje A2A in operaterska nadzorna plošča |
| [hive-routing](./hive-routing.md) | Identiteta članov panja (UUID stroja), matrika zmožnosti + sonde lokalnih LLM, usmerjanje nalog glede na stroške |
| [hive-pane](./hive-pane.md) | Steklena plošča za panj — opazovanje stroškov več vozlišč, odkrivanje A2A in operaterska nadzorna plošča |
| [a2a-complexity-audit](./a2a-complexity-audit.md) | Vpliv A2A na kodno kompleksnost — revizija šestih protokolov, kdaj se A2A izplača |
| [naming-decisions](./naming-decisions.md) | Imenik preimenovanj, nevtralnih glede na opremo / arhitekturnih — dostavljenih in v teku |
| [daemon-not-demon](./daemon-not-demon.md) | Zakaj rečemo daemon (duh pomočnik) in ne demon (hudič) — angleško + slovensko |
| [layered-soul](./layered-soul.md) | Kako Colibri danes uporablja repozitorij pregledanega konteksta layered-soul proti načrtovanemu |
| [task-board](./task-board.md) | Točkovanje po zmožnostih, cron razporejanje, praznjenje vnosne vrste, podlaga SQLite |
| [quality-gates](./quality-gates.md) | `ci-checks.sh` kot preverjanje pred združitvijo; zakaj je odmik prej dosegel `main` |
| [contracts](./contracts.md) | Stabilne JSON sheme (run-manifest, runtime-inventory, provider-test), zlati testi |
| [contracts](./contracts.md) | Stabilne JSON sheme (run-manifest, runtime-inventory, provider-test), zlati testi |
| [store-schema](./store-schema.md) | Usklajevalna shema SQLite in disciplina migracij |
| [external-mcp](./external-mcp.md) | Most MCP za urejevalnike + zunanji gostitelj stdio MCP; dovoljenja za branje/pisanje/zunanji-klic |
| [operator-cli](./operator-cli.md) | CLI `colibri` kot tanek tipiziran odjemalec Unix vtičnice prek API procesa v ozadju |
| [tui](./tui.md) | Odjemalec terminalske nadzorne plošče (colibri-tui) proti avtomatu stanj colibri-glasspane |
| [terminal](./terminal.md) | Odločitev o terminalski zmožnosti (Kitty, razširjeno poročanje tipk, prehod tmux, SSH terminfo) |
| [runtime-inventory](./runtime-inventory.md) | Popis izvajalnega okolja gostitelja + bralnik statusa čuvaja; aditivne, bralne integracije |
| [skills-catalog](./skills-catalog.md) | Bralni izvajalni porabnik za pregledane artefakte veščin |
| [skills-catalog](./skills-catalog.md) | Bralni izvajalni porabnik za pregledane artefakte veščin |
| [vault-provision](./vault-provision.md) | Oskrba datotek env, gnana z Vaultwarden, v ječe po zagonu agenta |
| [deployment](./deployment.md) | Nameščevalnik gostitelja (clawdie): postavitev ZFS, storitev rc.d/systemd, varnost suhega teka |

View file

@ -81,7 +81,7 @@ should be revisited.
| `s` | Spawn a local `colibri-test-agent` |
| `x` | Stop the selected pane |
| `Enter` | Open/close the detail pane for the selected row |
| `Tab` / `Shift+Tab` | Cycle through distinct sessions (incl. "All") |
| `Tab` / `Shift+Tab` | Cycle through distinct sessions (incl. "All") |
| `j` / `k` or `↓` / `↑` | Navigate the pane table |
| `n` / `N` | Jump to next / previous **attention** pane |
| `a` | Toggle the attention filter (only attention) |
@ -137,10 +137,10 @@ renders. This makes attention impossible to miss without consuming extra space.
### Row highlight inverts on selection
| Row state | Normal | Selected |
| --------- | --------------------------- | ----------------------------------------- |
| Attention | `bg(DarkRed)` + `fg(White)` | `bg(DarkGray)` + `fg(LightRed)` + bold |
| Normal | (plain) | `bg(DarkGray)` |
| Row state | Normal | Selected |
| --------- | --------------------------- | -------------------------------------- |
| Attention | `bg(DarkRed)` + `fg(White)` | `bg(DarkGray)` + `fg(LightRed)` + bold |
| Normal | (plain) | `bg(DarkGray)` |
Attention rows are impossible to miss; the inversion on selection confirms
which one the cursor is on without losing the attention signal.

View file

@ -27,16 +27,17 @@ case "${SSH_ORIGINAL_COMMAND:-}" in
;;
"report-task-cost")
# Read TaskCostSummary JSON from stdin, INSERT into mother_hive.task_costs.
# Input: {"node_id":1,"task_id":"abc","provider":"deepseek","model":"deepseek-chat",
# "input_tokens":150,"output_tokens":80,"cache_read_tokens":200,
# "cache_write_tokens":50,"cost_usd":0.0042,"success":true,
# Input: {"node_hostname":"debby","task_id":"abc","provider":"deepseek",
# "model":"deepseek-chat","input_tokens":150,"output_tokens":80,
# "cache_read_tokens":200,"cache_write_tokens":50,
# "cost_usd":0.0042,"success":true,"screenshot_uuid":"a1b2c3d4e5f6",
# "finished_at":"2026-06-27T12:00:00Z"}
psql -d mother_hive -tA -v ON_ERROR_STOP=1 <<'PSQL'
INSERT INTO task_costs (node_id, task_id, provider, model,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
cost_usd, success, finished_at)
cost_usd, success, screenshot_uuid, finished_at)
SELECT
(j->>'node_id')::INTEGER,
(SELECT id FROM hive_nodes WHERE hostname = j->>'node_hostname'),
j->>'task_id',
j->>'provider',
j->>'model',
@ -46,6 +47,7 @@ SELECT
COALESCE((j->>'cache_write_tokens')::BIGINT, 0),
COALESCE((j->>'cost_usd')::DOUBLE PRECISION, 0.0),
COALESCE((j->>'success')::BOOLEAN, false),
NULLIF(j->>'screenshot_uuid', ''),
COALESCE((j->>'finished_at')::TIMESTAMPTZ, now())
FROM (SELECT (pg_read_file('/dev/stdin')::JSONB) AS j) AS _;
PSQL

View file

@ -0,0 +1,49 @@
#!/bin/sh
# Deploy the cost dashboard to the mother webroot.
# Run on mother (osa) after colibri deploy.
#
# Places:
# /usr/local/www/clawdie/dashboard/index.html — dashboard page
# /usr/local/www/clawdie/dashboard/export-costs.sh — JSON export (cron)
# /usr/local/etc/cron.d/clawdie-dashboard — cron job
#
# The dashboard reads task_costs.json (exported every 60s by cron) and
# links screenshots from ../screenshots/ (tmux-screenshot publish dir).
#
# Nginx: the dashboard directory is served as a static location under
# the existing mother vhost. No new server block needed — just:
#
# location /dashboard/ {
# alias /usr/local/www/clawdie/dashboard/;
# index index.html;
# }
set -eu
SRC="$(dirname "$0")"
WEBROOT="/usr/local/www/clawdie/dashboard"
CRON_FILE="/usr/local/etc/cron.d/clawdie-dashboard"
echo "=== deploy cost dashboard ==="
mkdir -p "$WEBROOT"
cp "$SRC/index.html" "$WEBROOT/index.html"
cp "$SRC/export-costs.sh" "$WEBROOT/export-costs.sh"
chmod +x "$WEBROOT/export-costs.sh"
# Idempotent cron entry: export every 60s
cat > "$CRON_FILE" <<'CRON'
# clawdie cost dashboard — export task_costs to JSON every 60s
* * * * * root /usr/local/www/clawdie/dashboard/export-costs.sh
CRON
echo " dashboard → $WEBROOT/index.html"
echo " cron → $CRON_FILE"
echo ""
# Run once immediately to seed the data
echo "=== initial export ==="
"$WEBROOT/export-costs.sh" || echo " (no data yet — tasks will appear as agents complete)"
echo ""
echo "Done. Dashboard at: https://mother.clawdie.si/dashboard/"

View file

@ -0,0 +1,76 @@
#!/bin/sh
# Export task_costs to JSON for the cost dashboard.
# Run from cron (every 60s) or on-demand.
# Output: /usr/local/www/clawdie/dashboard/task_costs.json
set -eu
OUTDIR="/usr/local/www/clawdie/dashboard"
mkdir -p "$OUTDIR"
psql -d mother_hive -tA <<'SQL' > "${OUTDIR}/task_costs.json"
SELECT json_build_object(
'updated_at', now(),
'summary', json_build_object(
'total_tasks', COUNT(*),
'total_cost', COALESCE(SUM(cost_usd), 0.0),
'avg_cost', COALESCE(ROUND(AVG(cost_usd)::numeric, 6), 0.0),
'success_rate', COALESCE(ROUND(
COUNT(*) FILTER (WHERE success)::numeric / NULLIF(COUNT(*), 0) * 100, 1
), 0.0),
'total_input_tokens', COALESCE(SUM(input_tokens), 0),
'total_output_tokens', COALESCE(SUM(output_tokens), 0),
'cache_hit_ratio', COALESCE(ROUND(
SUM(cache_read_tokens)::numeric /
NULLIF(SUM(cache_read_tokens + input_tokens), 0) * 100, 1
), 0.0)
),
'nodes', (
SELECT json_agg(node_stats ORDER BY total_cost DESC) FROM (
SELECT
hn.hostname,
hn.node_type,
hn.capabilities->>'inference_tier' AS llm_tier,
COUNT(tc.*) AS task_count,
COALESCE(SUM(tc.cost_usd), 0.0) AS total_cost,
COALESCE(ROUND(AVG(tc.cost_usd)::numeric, 6), 0.0) AS avg_cost,
COALESCE(ROUND(
COUNT(*) FILTER (WHERE tc.success)::numeric /
NULLIF(COUNT(*), 0) * 100, 1
), 0.0) AS success_rate,
COALESCE(SUM(tc.input_tokens), 0) AS total_input,
COALESCE(SUM(tc.output_tokens), 0) AS total_output,
COALESCE(SUM(tc.cache_read_tokens), 0) AS total_cache_read,
COALESCE(ROUND(
SUM(tc.cache_read_tokens)::numeric /
NULLIF(SUM(tc.cache_read_tokens + tc.input_tokens), 0) * 100, 1
), 0.0) AS cache_hit_pct
FROM task_costs tc
LEFT JOIN hive_nodes hn ON hn.id = tc.node_id
GROUP BY hn.hostname, hn.node_type, hn.capabilities
) AS node_stats
),
'tasks', (
SELECT json_agg(t ORDER BY t.finished_at DESC) FROM (
SELECT
tc.task_id,
hn.hostname AS node,
tc.provider,
tc.model,
tc.input_tokens,
tc.output_tokens,
tc.cache_read_tokens,
tc.cache_write_tokens,
tc.cost_usd,
tc.success,
tc.finished_at,
tc.screenshot_uuid
FROM task_costs tc
LEFT JOIN hive_nodes hn ON hn.id = tc.node_id
ORDER BY tc.finished_at DESC
LIMIT 200
) AS t
)
) AS result;
SQL
echo "dashboard: $(date -Iseconds)$(jq '.summary.total_tasks' "${OUTDIR}/task_costs.json") tasks" >&2

View file

@ -0,0 +1,403 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cost Dashboard · clawdie.si</title>
<style>
/* ══ reset & base ═══════════════════════════════════════════════════════ */
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#0d1117; --surface:#161b22; --border:#21262d; --muted:#484f58;
--fg:#c9d1d9; --fg2:#8b949e; --accent:#00b4d8; --green:#3fb950;
--red:#f85149; --yellow:#d29922; --orange:#db6d28; --cache:#238636;
--fresh:#30363d; --bar-h:6px; --card-w:200px;
}
body{
background:var(--bg); color:var(--fg);
font-family:'DM Mono','SF Mono','Cascadia Code',monospace;
padding:2rem; min-height:100vh;
}
/* ══ header ═════════════════════════════════════════════════════════════ */
.header{
display:flex; align-items:center; justify-content:space-between;
flex-wrap:wrap; gap:1rem; margin-bottom:1.5rem;
padding-bottom:1rem; border-bottom:1px solid var(--border);
}
h1{font-size:1.1rem; font-weight:400; color:var(--fg2); letter-spacing:.08em; text-transform:uppercase}
h1 span{color:var(--accent)}
h1 .dot{display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; background:var(--green)}
.summary-bar{display:flex; gap:1.5rem; flex-wrap:wrap}
.summary-item{text-align:center}
.summary-item .val{font-size:1.3rem; color:var(--accent)}
.summary-item .lbl{font-size:.65rem; color:var(--fg2); text-transform:uppercase; letter-spacing:.05em}
/* ══ controls ═══════════════════════════════════════════════════════════ */
.controls{display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;margin-bottom:1.5rem}
.controls label{font-size:.7rem; color:var(--fg2); text-transform:uppercase; letter-spacing:.05em}
.controls select, .controls input[type="text"]{
background:var(--surface); color:var(--fg); border:1px solid var(--border);
border-radius:4px; padding:.35rem .5rem; font-family:inherit; font-size:.75rem
}
.controls select:hover, .controls input:hover{border-color:var(--accent)}
.btn{
background:none; border:1px solid var(--border); color:var(--fg2);
border-radius:4px; padding:.35rem .8rem; font-family:inherit; font-size:.72rem;
cursor:pointer; transition:border-color .2s,color .2s
}
.btn:hover{border-color:var(--accent); color:var(--accent)}
.btn.active{background:var(--accent); color:var(--bg); border-color:var(--accent)}
.btn-json{background:var(--surface); color:var(--yellow); border-color:var(--yellow)}
.btn-json:hover{background:var(--yellow); color:var(--bg)}
/* ══ nodes ══════════════════════════════════════════════════════════════ */
.nodes{margin-bottom:2rem}
.node-row{
background:var(--surface); border:1px solid var(--border); border-radius:8px;
padding:1rem 1.25rem; margin-bottom:.75rem; transition:border-color .2s
}
.node-row:hover{border-color:var(--accent)}
.node-head{display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.5rem}
.node-name{font-size:.9rem; color:var(--accent); font-weight:600}
.node-name .status-dot{font-size:.6rem; margin-right:4px}
.node-name .status-dot.online{color:var(--green)}
.node-name .status-dot.offline{color:var(--fg2)}
.node-meta{display:flex; gap:1.2rem; font-size:.72rem; color:var(--fg2)}
.node-meta strong{color:var(--fg); font-weight:400}
.node-cards{display:flex; gap:.75rem; flex-wrap:wrap; margin-top:.75rem; padding-top:.75rem; border-top:1px solid var(--border)}
.node-empty{font-size:.72rem; color:var(--muted); font-style:italic; padding:.5rem 0}
/* ══ cost cards ═════════════════════════════════════════════════════════ */
.card{
background:var(--bg); border:1px solid var(--border); border-radius:6px;
padding:.65rem .75rem; width:var(--card-w); cursor:default;
transition:border-color .2s,transform .15s; position:relative
}
.card:hover{transform:translateY(-1px)}
.card.has-proof{border-color:var(--accent); cursor:pointer}
.card.has-proof:hover{border-color:var(--green); box-shadow:0 0 12px rgba(0,180,216,.12)}
.card-provider{font-size:.65rem; text-transform:uppercase; letter-spacing:.05em; color:var(--fg2); margin-bottom:4px}
.card-cost{font-size:1.15rem; font-weight:600; color:var(--fg)}
.card-model{font-size:.65rem; color:var(--muted); margin-bottom:6px}
.card-success{
position:absolute; top:8px; right:8px; font-size:.75rem;
width:20px; height:20px; border-radius:50%; display:flex; align-items:center; justify-content:center
}
.card-success.ok{background:rgba(63,185,80,.15); color:var(--green)}
.card-success.fail{background:rgba(248,81,73,.15); color:var(--red)}
.card-proof-badge{
position:absolute; bottom:6px; right:6px; font-size:.55rem; color:var(--accent);
opacity:.6; text-transform:uppercase; letter-spacing:.04em
}
/* cache bar */
.card-cache-bar{
display:flex; height:var(--bar-h); border-radius:3px; overflow:hidden; margin-top:4px
}
.card-cache-hit{background:var(--cache)}
.card-cache-fresh{background:var(--fresh)}
.card-tokens{font-size:.6rem; color:var(--muted); margin-top:3px}
/* ══ raw json panel ═════════════════════════════════════════════════════ */
.json-panel{
display:none; background:var(--surface); border:1px solid var(--border);
border-radius:8px; padding:1.25rem; margin-top:1.5rem; max-height:70vh; overflow:auto
}
.json-panel.open{display:block}
.json-panel pre{
font-family:inherit; font-size:.72rem; color:var(--green);
white-space:pre-wrap; word-break:break-all; line-height:1.5
}
.json-summary{font-size:.72rem; color:var(--fg2); margin-bottom:.75rem}
/* ══ lightbox ═══════════════════════════════════════════════════════════ */
.lightbox{display:none; position:fixed; inset:0; background:rgba(0,0,0,.94); z-index:1000;
align-items:center; justify-content:center; padding:1.5rem}
.lightbox.open{display:flex}
.lightbox img{max-width:94vw; max-height:88vh; border-radius:4px; box-shadow:0 0 40px rgba(0,180,216,.15)}
.lightbox-close{position:fixed; top:1rem; right:1.5rem; background:var(--surface); color:var(--fg2);
border:1px solid var(--border); border-radius:4px; padding:.4rem 1rem; font-family:inherit;
font-size:.78rem; cursor:pointer; z-index:1001}
.lightbox-close:hover{color:var(--accent); border-color:var(--accent)}
.lightbox-meta{
position:fixed; bottom:1.5rem; left:50%; transform:translateX(-50%);
background:var(--surface); color:var(--fg2); border:1px solid var(--border);
border-radius:6px; padding:.5rem 1rem; font-size:.68rem; z-index:1001
}
.lightbox-meta strong{color:var(--accent); margin-right:.5rem}
/* ══ empty state ════════════════════════════════════════════════════════ */
.empty{text-align:center; padding:4rem 2rem; color:var(--muted); font-size:.85rem}
.empty-icon{font-size:2.5rem; margin-bottom:.75rem}
/* ══ status markers ═════════════════════════════════════════════════════ */
.status-online{color:var(--green)}
.status-offline{color:var(--fg2)}
.cost-trend-up{color:var(--red)}
.cost-trend-down{color:var(--green)}
.llm-badge{
display:inline-block; background:rgba(0,180,216,.1); color:var(--accent);
font-size:.6rem; padding:1px 6px; border-radius:3px; margin-left:6px;
text-transform:uppercase; letter-spacing:.03em
}
</style>
</head>
<body>
<div class="header">
<h1><span class="dot"></span> Colibri Cost Dashboard <span id="updated"></span></h1>
<div class="summary-bar" id="summary"></div>
</div>
<div class="controls">
<label>Node</label>
<select id="filter-node"><option value="">all nodes</option></select>
<label>Provider</label>
<select id="filter-provider">
<option value="">all providers</option>
<option value="deepseek">deepseek</option>
<option value="openrouter">openrouter</option>
<option value="anthropic">anthropic</option>
<option value="google">google</option>
<option value="ollama">ollama</option>
<option value="local">local</option>
</select>
<label>Status</label>
<select id="filter-success">
<option value="">all</option>
<option value="true">success</option>
<option value="false">failed</option>
</select>
<button class="btn btn-json" id="toggle-json">JSON</button>
</div>
<div class="nodes" id="nodes"></div>
<div class="empty" id="empty-state" style="display:none">
<div class="empty-icon"></div>
<p>No cost data yet. Tasks will appear here as agents complete work.</p>
</div>
<div class="json-panel" id="json-panel">
<div class="json-summary" id="json-summary"></div>
<pre id="json-output"></pre>
</div>
<div class="lightbox" id="lightbox">
<button class="lightbox-close">Esc to close</button>
<div class="lightbox-meta" id="lightbox-meta"></div>
<img id="lb-img" src="" alt="screenshot proof">
</div>
<script>
// ══ state ═══════════════════════════════════════════════════════════════
let DATA = null;
let FILTERS = { node: '', provider: '', success: '' };
// ══ data loading ════════════════════════════════════════════════════════
async function load() {
try {
const r = await fetch('task_costs.json');
DATA = await r.json();
} catch(e) {
document.getElementById('nodes').innerHTML =
'<div class="empty"><div class="empty-icon"></div><p>Waiting for cost data…</p></div>';
return;
}
render();
}
// ══ render ══════════════════════════════════════════════════════════════
function render() {
if (!DATA) return;
const s = DATA.summary || {};
document.getElementById('updated').textContent =
DATA.updated_at ? '· ' + fmtDate(DATA.updated_at) : '';
// summary bar
document.getElementById('summary').innerHTML = [
{v: s.total_tasks||0, l:'tasks'},
{v: '$'+(s.total_cost||0).toFixed(3), l:'total cost'},
{v: '$'+(s.avg_cost||0).toFixed(4), l:'avg/task'},
{v: (s.success_rate||0)+'%', l:'success'},
{v: (s.cache_hit_ratio||0)+'%', l:'cache hit'},
{v: fmtTokens(s.total_input_tokens||0), l:'tokens in'},
{v: fmtTokens(s.total_output_tokens||0), l:'tokens out'},
].map(i => `<div class="summary-item"><div class="val">${i.v}</div><div class="lbl">${i.l}</div></div>`).join('');
// node filter dropdown
const nodeSel = document.getElementById('filter-node');
const nodes = DATA.nodes || [];
nodeSel.innerHTML = '<option value="">all nodes</option>' +
nodes.map(n => `<option value="${esc(n.hostname)}">${esc(n.hostname)}</option>`).join('');
// apply filters to tasks
const tasks = (DATA.tasks || []).filter(t => {
if (FILTERS.node && t.node !== FILTERS.node) return false;
if (FILTERS.provider && t.provider !== FILTERS.provider) return false;
if (FILTERS.success !== '' && String(t.success) !== FILTERS.success) return false;
return true;
});
// group tasks by node
const byNode = {};
for (const t of tasks) {
const n = t.node || 'unknown';
if (!byNode[n]) byNode[n] = [];
byNode[n].push(t);
}
// find node metadata
const nodeMeta = {};
for (const n of nodes) nodeMeta[n.hostname] = n;
const container = document.getElementById('nodes');
const empty = document.getElementById('empty-state');
if (Object.keys(byNode).length === 0) {
container.innerHTML = '';
empty.style.display = 'block';
} else {
empty.style.display = 'none';
container.innerHTML = Object.entries(byNode).map(([hostname, nodeTasks]) => {
const meta = nodeMeta[hostname] || {};
const totalCost = nodeTasks.reduce((s,t)=>s+(t.cost_usd||0),0);
const succeeded = nodeTasks.filter(t=>t.success).length;
const rate = nodeTasks.length > 0
? Math.round(succeeded / nodeTasks.length * 100) : 0;
return `
<div class="node-row">
<div class="node-head">
<div>
<span class="node-name">
<span class="status-dot online"></span>${esc(hostname)}
</span>
${meta.llm_tier ? `<span class="llm-badge">${esc(meta.llm_tier)}</span>` : ''}
</div>
<div class="node-meta">
<span><strong>${nodeTasks.length}</strong> tasks</span>
<span><strong>$${totalCost.toFixed(3)}</strong> total</span>
<span><strong>$${nodeTasks.length>0?(totalCost/nodeTasks.length).toFixed(4):'0.0000'}</strong> avg</span>
<span><strong>${rate}%</strong> success</span>
${meta.cache_hit_pct!=null ? `<span><strong>${meta.cache_hit_pct}%</strong> cache</span>` : ''}
</div>
</div>
<div class="node-cards">
${nodeTasks.map(t => renderCard(t)).join('')}
</div>
</div>`;
}).join('');
}
// update json panel if open
if (document.getElementById('json-panel').classList.contains('open')) {
showJSON();
}
}
// ══ cost card ═══════════════════════════════════════════════════════════
function renderCard(t) {
const total = (t.input_tokens||0) + (t.cache_read_tokens||0);
const cachePct = total > 0 ? Math.round((t.cache_read_tokens||0) / total * 100) : 0;
const freshPct = 100 - cachePct;
const hasProof = !!t.screenshot_uuid;
const cls = hasProof ? 'card has-proof' : 'card';
const onClick = hasProof
? `onclick="openProof('${esc(t.screenshot_uuid)}','${esc(t.task_id||'')}','${esc(t.provider||'')}','${(t.cost_usd||0).toFixed(4)}')"`
: '';
return `
<div class="${cls}" ${onClick} title="${hasProof?'Click for terminal proof':''}">
<div class="card-success ${t.success?'ok':'fail'}">${t.success?'✓':'✗'}</div>
<div class="card-provider">${esc(t.provider||'unknown')}</div>
<div class="card-cost">$${(t.cost_usd||0).toFixed(4)}</div>
<div class="card-model">${esc(t.model||'')}</div>
<div class="card-cache-bar">
<div class="card-cache-hit" style="width:${cachePct}%"></div>
<div class="card-cache-fresh" style="width:${freshPct}%"></div>
</div>
<div class="card-tokens">${fmtTokens(t.input_tokens||0)} in · ${fmtTokens(t.output_tokens||0)} out
${cachePct>0?` · ${cachePct}% cache` : ''}
</div>
${hasProof ? '<div class="card-proof-badge">▸ proof</div>' : ''}
</div>`;
}
// ══ lightbox ════════════════════════════════════════════════════════════
function openProof(uuid, taskId, provider, cost) {
const lb = document.getElementById('lightbox');
document.getElementById('lb-img').src = `../screenshots/${uuid}.png`;
document.getElementById('lightbox-meta').innerHTML =
`<strong>${esc(taskId)}</strong> ${esc(provider)} · $${cost}`;
lb.classList.add('open');
}
function closeLb() { document.getElementById('lightbox').classList.remove('open'); }
// ══ json panel ══════════════════════════════════════════════════════════
function showJSON() {
// Build filtered agent-friendly view
const tasks = (DATA.tasks || []).filter(t => {
if (FILTERS.node && t.node !== FILTERS.node) return false;
if (FILTERS.provider && t.provider !== FILTERS.provider) return false;
if (FILTERS.success !== '' && String(t.success) !== FILTERS.success) return false;
return true;
});
const byNode = {};
for (const t of tasks) {
const n = t.node || 'unknown'; if (!byNode[n]) byNode[n] = [];
byNode[n].push({task_id:t.task_id,provider:t.provider,model:t.model,
cost:t.cost_usd,success:t.success,finished_at:t.finished_at,
tokens:{in:t.input_tokens,out:t.output_tokens,cache_read:t.cache_read_tokens},
screenshot_uuid:t.screenshot_uuid||null});
}
const output = {updated_at:DATA.updated_at, summary:DATA.summary, nodes:byNode};
document.getElementById('json-output').textContent = JSON.stringify(output, null, 2);
document.getElementById('json-summary').textContent =
`${tasks.length} tasks across ${Object.keys(byNode).length} nodes ` +
`· total cost $${(DATA.summary?.total_cost||0).toFixed(3)}`;
}
// ══ events ══════════════════════════════════════════════════════════════
document.getElementById('filter-node').addEventListener('change', e => {
FILTERS.node = e.target.value; render();
});
document.getElementById('filter-provider').addEventListener('change', e => {
FILTERS.provider = e.target.value; render();
});
document.getElementById('filter-success').addEventListener('change', e => {
FILTERS.success = e.target.value; render();
});
document.getElementById('toggle-json').addEventListener('click', () => {
const panel = document.getElementById('json-panel');
const btn = document.getElementById('toggle-json');
panel.classList.toggle('open');
btn.classList.toggle('active');
if (panel.classList.contains('open')) showJSON();
});
document.getElementById('lightbox').addEventListener('click', e => {
if (e.target.classList.contains('lightbox') || e.target.id === 'lb-img') closeLb();
});
document.querySelector('.lightbox-close').addEventListener('click', closeLb);
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { closeLb(); document.getElementById('json-panel').classList.remove('open'); }
});
// ══ utils ═══════════════════════════════════════════════════════════════
function esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function fmtTokens(n) { return n >= 1000000 ? (n/1000000).toFixed(1)+'M' : n >= 1000 ? (n/1000).toFixed(1)+'K' : String(n); }
function fmtDate(iso) {
if (!iso) return '';
try { const d = new Date(iso); return d.toLocaleDateString('sl-SI',{day:'2-digit',month:'short',hour:'2-digit',minute:'2-digit'}); }
catch(e) { return iso.slice(0,16); }
}
// ══ init ════════════════════════════════════════════════════════════════
load();
setInterval(load, 60000); // auto-refresh every 60s
</script>
</body>
</html>

View file

@ -65,12 +65,15 @@ CREATE TABLE IF NOT EXISTS task_costs (
cache_write_tokens BIGINT NOT NULL DEFAULT 0,
cost_usd DOUBLE PRECISION NOT NULL DEFAULT 0.0,
success BOOLEAN NOT NULL DEFAULT false,
screenshot_uuid TEXT, -- tmux-screenshot content hash (12-char UUID)
finished_at TIMESTAMPTZ NOT NULL DEFAULT now(),
reported_at TIMESTAMPTZ NOT NULL DEFAULT now()
reported_at TIMESTAMPTZ NOT NULL DEFAULT now(),
screenshot_uuid TEXT -- optional; links to tmux-screenshot capture at task completion
);
CREATE INDEX IF NOT EXISTS idx_task_costs_node ON task_costs (node_id);
CREATE INDEX IF NOT EXISTS idx_task_costs_finished ON task_costs (finished_at DESC);
CREATE INDEX IF NOT EXISTS idx_task_costs_provider ON task_costs (provider, model);
ALTER TABLE task_costs ADD COLUMN IF NOT EXISTS screenshot_uuid TEXT;
CREATE TABLE IF NOT EXISTS build_queue (
id SERIAL PRIMARY KEY,

View file

@ -8,4 +8,4 @@ ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$ROOT_DIR"
exec npx --yes prettier@3 --check '**/*.md'
exec npx --yes prettier@3.8.4 --check '**/*.md'