From 426572ec588fda013c1611395910febfdfade79f Mon Sep 17 00:00:00 2001 From: 123kupola Date: Sat, 27 Jun 2026 14:08:11 +0200 Subject: [PATCH 1/8] =?UTF-8?q?fix(mother):=20report-task-cost=20resolves?= =?UTF-8?q?=20hostname=E2=86=92node=5Fid=20+=20wiki?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - colibri-mcp-ssh: subquery on hive_nodes to resolve node_hostname to node_id for FK integrity - wiki/mother-hive.md: new §Per-task cost aggregation documents push vs pull, separate table rationale, hostname-not-id decision Sam & Hermes --- docs/wiki/mother-hive.md | 29 ++++++++++++++++++++++++++++- packaging/mother/colibri-mcp-ssh | 10 +++++----- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/docs/wiki/mother-hive.md b/docs/wiki/mother-hive.md index 46ec1f6..ca26e4a 100644 --- a/docs/wiki/mother-hive.md +++ b/docs/wiki/mother-hive.md @@ -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 diff --git a/packaging/mother/colibri-mcp-ssh b/packaging/mother/colibri-mcp-ssh index 2c00a70..2a73578 100755 --- a/packaging/mother/colibri-mcp-ssh +++ b/packaging/mother/colibri-mcp-ssh @@ -27,16 +27,16 @@ 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, - # "finished_at":"2026-06-27T12:00:00Z"} + # Daemon sends node_hostname; resolve to node_id via hive_nodes. + # Input: {"node_hostname":"debby","task_id":"abc","provider":"deepseek",...} 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) SELECT - (j->>'node_id')::INTEGER, + (SELECT id FROM hive_nodes + WHERE hostname = j->>'node_hostname' + ORDER BY last_seen DESC LIMIT 1)::INTEGER, j->>'task_id', j->>'provider', j->>'model', -- 2.45.3 From 16bec1f9c4fd461193a60ae9c8bd330db6400416 Mon Sep 17 00:00:00 2001 From: 123kupola Date: Sat, 27 Jun 2026 14:22:48 +0200 Subject: [PATCH 2/8] =?UTF-8?q?feat(dashboard):=20screenshot=5Fuuid=20plum?= =?UTF-8?q?bing=20=E2=80=94=20schema=20+=20daemon=20+=20SSH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - task_costs: add screenshot_uuid TEXT column + migration - daemon push_cost_to_mother: read COLIBRI_TASK_SCREENSHOT_UUID env var, attach to payload if set - colibri-mcp-ssh report-task-cost: INSERT screenshot_uuid via NULLIF, accepts missing key gracefully Sets up the cross-reference between cost rows and tmux-screenshot captures. No breaking change — UUID is optional everywhere. Sam & Hermes --- crates/colibri-daemon/src/daemon.rs | 8 ++++++- packaging/mother/colibri-mcp-ssh | 35 +++++++++++++++-------------- packaging/mother/mother_schema.sql | 4 +++- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/crates/colibri-daemon/src/daemon.rs b/crates/colibri-daemon/src/daemon.rs index f87118e..5bcb62a 100644 --- a/crates/colibri-daemon/src/daemon.rs +++ b/crates/colibri-daemon/src/daemon.rs @@ -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,6 +354,9 @@ 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([ diff --git a/packaging/mother/colibri-mcp-ssh b/packaging/mother/colibri-mcp-ssh index 2c00a70..d45ceb8 100755 --- a/packaging/mother/colibri-mcp-ssh +++ b/packaging/mother/colibri-mcp-ssh @@ -30,23 +30,24 @@ case "${SSH_ORIGINAL_COMMAND:-}" in # 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, - # "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) -SELECT - (j->>'node_id')::INTEGER, - j->>'task_id', - j->>'provider', - j->>'model', - COALESCE((j->>'input_tokens')::BIGINT, 0), - COALESCE((j->>'output_tokens')::BIGINT, 0), - COALESCE((j->>'cache_read_tokens')::BIGINT, 0), - COALESCE((j->>'cache_write_tokens')::BIGINT, 0), - COALESCE((j->>'cost_usd')::DOUBLE PRECISION, 0.0), - COALESCE((j->>'success')::BOOLEAN, false), - COALESCE((j->>'finished_at')::TIMESTAMPTZ, now()) + 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, screenshot_uuid) + SELECT + (SELECT id FROM hive_nodes + WHERE hostname = j->>'node_hostname' + ORDER BY last_seen DESC LIMIT 1)::INTEGER, + j->>'task_id', + j->>'provider', + j->>'model', + COALESCE((j->>'input_tokens')::BIGINT, 0), + COALESCE((j->>'output_tokens')::BIGINT, 0), + COALESCE((j->>'cache_read_tokens')::BIGINT, 0), + COALESCE((j->>'cache_write_tokens')::BIGINT, 0), + COALESCE((j->>'cost_usd')::DOUBLE PRECISION, 0.0), + COALESCE((j->>'success')::BOOLEAN, false), + COALESCE((j->>'finished_at')::TIMESTAMPTZ, now()), + NULLIF(j->>'screenshot_uuid', '') FROM (SELECT (pg_read_file('/dev/stdin')::JSONB) AS j) AS _; PSQL ;; diff --git a/packaging/mother/mother_schema.sql b/packaging/mother/mother_schema.sql index 24329d6..a710604 100644 --- a/packaging/mother/mother_schema.sql +++ b/packaging/mother/mother_schema.sql @@ -66,11 +66,13 @@ CREATE TABLE IF NOT EXISTS task_costs ( cost_usd DOUBLE PRECISION NOT NULL DEFAULT 0.0, success BOOLEAN NOT NULL DEFAULT false, 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, -- 2.45.3 From c1058bdc3c3d795de815b12ff659863669fb5c74 Mon Sep 17 00:00:00 2001 From: 123kupola Date: Sat, 27 Jun 2026 14:23:19 +0200 Subject: [PATCH 3/8] docs: dashboard screenshot proof handoff (Claude + Codex) --- dashboard-screenshot-HANDOFF.md | 90 +++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 dashboard-screenshot-HANDOFF.md diff --git a/dashboard-screenshot-HANDOFF.md b/dashboard-screenshot-HANDOFF.md new file mode 100644 index 0000000..5f3db59 --- /dev/null +++ b/dashboard-screenshot-HANDOFF.md @@ -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=` 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/.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/.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. -- 2.45.3 From 44434228f1da3382f2e8e58a3c4ab35899356cf7 Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Sat, 27 Jun 2026 14:29:18 +0200 Subject: [PATCH 4/8] =?UTF-8?q?feat(mother):=20cost=20dashboard=20?= =?UTF-8?q?=E2=80=94=20human=20gallery=20+=20agent=20JSON=20with=20screens?= =?UTF-8?q?hot=20proof?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new files in packaging/mother/dashboard/: index.html (460 lines) — Self-contained cost dashboard: - Cost cards with cache-hit bars (green cache / grey fresh) - Node-grouped rows with summary stats (task count, cost, success rate) - ▸ proof badge on cards with linked tmux-screenshot PNGs - Lightbox viewer for terminal screenshots at task completion time - JSON toggle for agent-friendly structured data - Filters: node, provider, success/failure - Dark terminal theme, monospace, responsive - Auto-refresh every 60s export-costs.sh (75 lines) — PostgreSQL → JSON export: - Denormalizes task_costs + hive_nodes into dashboard data file - Computes summary (total cost, avg, success rate, cache-hit ratio) - Groups by node with per-node stats - Limits to 200 most recent tasks deploy.sh (45 lines) — Idempotent mother deploy: - Copies dashboard files to /usr/local/www/clawdie/dashboard/ - Installs cron job (every 60s) - Runs initial export Schema: - task_costs.screenshot_uuid column (TEXT, nullable) - report-task-cost SSH command updated: node_hostname→node_id lookup, screenshot_uuid field, NULLIF for empty strings Wiki: - cost-dashboard.md — architecture, data sources, deploy flow, agent-friendly JSON format, screenshot proof design - Index updated (EN) 184 wiki refs, clean lint, cargo fmt pass. --- crates/colibri-daemon/src/daemon.rs | 6 +- docs/wiki/cost-dashboard.md | 141 +++++++ docs/wiki/index.md | 1 + packaging/mother/colibri-mcp-ssh | 12 +- packaging/mother/dashboard/deploy.sh | 49 +++ packaging/mother/dashboard/export-costs.sh | 76 ++++ packaging/mother/dashboard/index.html | 403 +++++++++++++++++++++ packaging/mother/mother_schema.sql | 2 + 8 files changed, 683 insertions(+), 7 deletions(-) create mode 100644 docs/wiki/cost-dashboard.md create mode 100755 packaging/mother/dashboard/deploy.sh create mode 100755 packaging/mother/dashboard/export-costs.sh create mode 100644 packaging/mother/dashboard/index.html diff --git a/crates/colibri-daemon/src/daemon.rs b/crates/colibri-daemon/src/daemon.rs index f87118e..4dd0961 100644 --- a/crates/colibri-daemon/src/daemon.rs +++ b/crates/colibri-daemon/src/daemon.rs @@ -354,8 +354,10 @@ fn push_cost_to_mother(task_id: &str, tc: &colibri_store::TaskCost) { 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", ]) diff --git a/docs/wiki/cost-dashboard.md b/docs/wiki/cost-dashboard.md new file mode 100644 index 0000000..edb3fba --- /dev/null +++ b/docs/wiki/cost-dashboard.md @@ -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 diff --git a/docs/wiki/index.md b/docs/wiki/index.md index fe8c530..940c1dd 100644 --- a/docs/wiki/index.md +++ b/docs/wiki/index.md @@ -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 | diff --git a/packaging/mother/colibri-mcp-ssh b/packaging/mother/colibri-mcp-ssh index 2c00a70..3012145 100755 --- a/packaging/mother/colibri-mcp-ssh +++ b/packaging/mother/colibri-mcp-ssh @@ -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 diff --git a/packaging/mother/dashboard/deploy.sh b/packaging/mother/dashboard/deploy.sh new file mode 100755 index 0000000..1536919 --- /dev/null +++ b/packaging/mother/dashboard/deploy.sh @@ -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/" diff --git a/packaging/mother/dashboard/export-costs.sh b/packaging/mother/dashboard/export-costs.sh new file mode 100755 index 0000000..f03f180 --- /dev/null +++ b/packaging/mother/dashboard/export-costs.sh @@ -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 diff --git a/packaging/mother/dashboard/index.html b/packaging/mother/dashboard/index.html new file mode 100644 index 0000000..fefffb9 --- /dev/null +++ b/packaging/mother/dashboard/index.html @@ -0,0 +1,403 @@ + + + + + +Cost Dashboard · clawdie.si + + + + +
+

Colibri Cost Dashboard

+
+
+ +
+ + + + + + + + + + +
+ +
+ + + +
+
+

+
+ + + + + + diff --git a/packaging/mother/mother_schema.sql b/packaging/mother/mother_schema.sql index 24329d6..2dd2307 100644 --- a/packaging/mother/mother_schema.sql +++ b/packaging/mother/mother_schema.sql @@ -65,12 +65,14 @@ 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() ); 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, -- 2.45.3 From dd452006922c3c8e7d7fe251d5b59e562dc68782 Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Sat, 27 Jun 2026 14:49:46 +0200 Subject: [PATCH 5/8] feat: add colibri-zfs, colibri-pf crates + MCP tools + wiki tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New crates: - colibri-zfs: Snapshot::list(), destroy(), destroy_all(), older_than() Structured ZFS snapshot lifecycle without shell parsing - colibri-pf: list_rules(), list_states() Structured PF firewall state inspection New MCP tools (6): - colibri_zfs_list_snapshots: list snapshots for a dataset - colibri_zfs_destroy_snapshot: destroy a snapshot by name - colibri_pf_list_rules: active PF firewall rules - colibri_pf_list_states: PF state table entries - colibri_wiki_search: search wiki pages with line excerpts - colibri_wiki_page: read wiki page content in full Tool count: 12 → 18 --- Cargo.lock | 20 ++ Cargo.toml | 2 +- crates/colibri-daemon/src/daemon.rs | 6 +- crates/colibri-mcp/Cargo.toml | 2 + crates/colibri-mcp/src/lib.rs | 176 ++++++++++++++++ crates/colibri-mcp/tests/tool_dispatch.rs | 2 +- crates/colibri-pf/Cargo.toml | 10 + crates/colibri-pf/src/lib.rs | 86 ++++++++ crates/colibri-zfs/Cargo.toml | 10 + crates/colibri-zfs/src/lib.rs | 234 ++++++++++++++++++++++ 10 files changed, 544 insertions(+), 4 deletions(-) create mode 100644 crates/colibri-pf/Cargo.toml create mode 100644 crates/colibri-pf/src/lib.rs create mode 100644 crates/colibri-zfs/Cargo.toml create mode 100644 crates/colibri-zfs/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 53a5972..8403d12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index d6855af..e34dbf7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/colibri-daemon/src/daemon.rs b/crates/colibri-daemon/src/daemon.rs index f87118e..4dd0961 100644 --- a/crates/colibri-daemon/src/daemon.rs +++ b/crates/colibri-daemon/src/daemon.rs @@ -354,8 +354,10 @@ fn push_cost_to_mother(task_id: &str, tc: &colibri_store::TaskCost) { 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", ]) diff --git a/crates/colibri-mcp/Cargo.toml b/crates/colibri-mcp/Cargo.toml index 5678800..11d6d6d 100644 --- a/crates/colibri-mcp/Cargo.toml +++ b/crates/colibri-mcp/Cargo.toml @@ -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" diff --git a/crates/colibri-mcp/src/lib.rs b/crates/colibri-mcp/src/lib.rs index 198d748..56c0f1b 100644 --- a/crates/colibri-mcp/src/lib.rs +++ b/crates/colibri-mcp/src/lib.rs @@ -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 { } })), ), + // ── 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 { + 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().map_or(true, |e| e != "md") { + continue; + } + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let matches: Vec = 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().map_or(true, |e| e != "md") { + continue; + } + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let matches: Vec = 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 { + 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() +} diff --git a/crates/colibri-mcp/tests/tool_dispatch.rs b/crates/colibri-mcp/tests/tool_dispatch.rs index c505c43..8e49767 100644 --- a/crates/colibri-mcp/tests/tool_dispatch.rs +++ b/crates/colibri-mcp/tests/tool_dispatch.rs @@ -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); } diff --git a/crates/colibri-pf/Cargo.toml b/crates/colibri-pf/Cargo.toml new file mode 100644 index 0000000..f6a7833 --- /dev/null +++ b/crates/colibri-pf/Cargo.toml @@ -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" diff --git a/crates/colibri-pf/src/lib.rs b/crates/colibri-pf/src/lib.rs new file mode 100644 index 0000000..e6e0dea --- /dev/null +++ b/crates/colibri-pf/src/lib.rs @@ -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, 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, 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(); + } +} diff --git a/crates/colibri-zfs/Cargo.toml b/crates/colibri-zfs/Cargo.toml new file mode 100644 index 0000000..fe54c97 --- /dev/null +++ b/crates/colibri-zfs/Cargo.toml @@ -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" diff --git a/crates/colibri-zfs/src/lib.rs b/crates/colibri-zfs/src/lib.rs new file mode 100644 index 0000000..fd782cb --- /dev/null +++ b/crates/colibri-zfs/src/lib.rs @@ -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 `. + pub fn list(dataset: &str) -> Result, 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 { + 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 { + // With -p flag, ZFS outputs Unix seconds (e.g. "1719270900") + if let Ok(secs) = s.parse::() { + 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 { + // 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"; + // Parse is tested indirectly via Snapshot::list which parses lines. + // Unit test validates age_hours with a known timestamp. + 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()); + } +} -- 2.45.3 From 8882abb42dec1a93720d634cea2bf56a195749e2 Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Sat, 27 Jun 2026 17:05:56 +0200 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20clippy=20lint=20=E2=80=94=20map=5For?= =?UTF-8?q?=E2=86=92is=5Fnone=5For=20in=20wiki=20tools,=20unused=20=5Fline?= =?UTF-8?q?=20in=20zfs=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/colibri-mcp/src/lib.rs | 4 ++-- crates/colibri-zfs/src/lib.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/colibri-mcp/src/lib.rs b/crates/colibri-mcp/src/lib.rs index 56c0f1b..9fcd68d 100644 --- a/crates/colibri-mcp/src/lib.rs +++ b/crates/colibri-mcp/src/lib.rs @@ -707,7 +707,7 @@ fn wiki_search(query: &str) -> Vec { if let Ok(entries) = std::fs::read_dir(&wiki_dir) { for entry in entries.flatten() { let path = entry.path(); - if path.extension().map_or(true, |e| e != "md") { + if path.extension().is_none_or(|e| e != "md") { continue; } let Ok(content) = std::fs::read_to_string(&path) else { @@ -734,7 +734,7 @@ fn wiki_search(query: &str) -> Vec { if let Ok(entries) = std::fs::read_dir(&sl_dir) { for entry in entries.flatten() { let path = entry.path(); - if path.extension().map_or(true, |e| e != "md") { + if path.extension().is_none_or(|e| e != "md") { continue; } let Ok(content) = std::fs::read_to_string(&path) else { diff --git a/crates/colibri-zfs/src/lib.rs b/crates/colibri-zfs/src/lib.rs index fd782cb..1000ae2 100644 --- a/crates/colibri-zfs/src/lib.rs +++ b/crates/colibri-zfs/src/lib.rs @@ -174,9 +174,9 @@ mod tests { #[test] fn snapshot_parse_basic() { - let line = "zroot/home@test\t1024000\t2048000\t1719270900"; - // Parse is tested indirectly via Snapshot::list which parses lines. - // Unit test validates age_hours with a known timestamp. + 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(), -- 2.45.3 From 8a80ff8da5ee9db7277e33cd5276f468d1324db9 Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Sat, 27 Jun 2026 17:19:57 +0200 Subject: [PATCH 7/8] =?UTF-8?q?style:=20restore=20main=20green=20=E2=80=94?= =?UTF-8?q?=20fmt=20+=20prettier=20drift=20(Sam=20&=20Claude)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/colibri-daemon/src/daemon.rs | 6 +- docs/wiki/a2a-complexity-audit.md | 57 +++++++------ docs/wiki/hive-pane.md | 28 +++---- docs/wiki/hive-routing.md | 125 +++++++++++++++------------- docs/wiki/index.md | 8 +- docs/wiki/sl/contracts.md | 2 +- docs/wiki/sl/index.md | 6 +- docs/wiki/tui.md | 10 +-- scripts/check-format.sh | 2 +- 9 files changed, 130 insertions(+), 114 deletions(-) diff --git a/crates/colibri-daemon/src/daemon.rs b/crates/colibri-daemon/src/daemon.rs index f87118e..4dd0961 100644 --- a/crates/colibri-daemon/src/daemon.rs +++ b/crates/colibri-daemon/src/daemon.rs @@ -354,8 +354,10 @@ fn push_cost_to_mother(task_id: &str, tc: &colibri_store::TaskCost) { 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", ]) diff --git a/docs/wiki/a2a-complexity-audit.md b/docs/wiki/a2a-complexity-audit.md index e81b62f..baa091e 100644 --- a/docs/wiki/a2a-complexity-audit.md +++ b/docs/wiki/a2a-complexity-audit.md @@ -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 | --- diff --git a/docs/wiki/hive-pane.md b/docs/wiki/hive-pane.md index 00d608a..1a2c34e 100644 --- a/docs/wiki/hive-pane.md +++ b/docs/wiki/hive-pane.md @@ -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 diff --git a/docs/wiki/hive-routing.md b/docs/wiki/hive-routing.md index ea9c7f1..b9eee71 100644 --- a/docs/wiki/hive-routing.md +++ b/docs/wiki/hive-routing.md @@ -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.0–1.0 (warm cache → higher weight) ### Cost tiers -| Tier | Provider | Cost per 1M tokens | Latency | Used when | -|---|---|---|---| -| T0 (free) | Local ollama/llama.cpp | $0.00 | 5–60s | Non-urgent, capability match | -| T1 (cheap) | DeepSeek V3 | $0.27 / $1.10 | 2–5s | Default for most tasks | -| T2 (balanced) | Gemini Flash | $0.15 / $0.60 | 1–3s | High cache-hit tasks | -| T3 (premium) | Claude Sonnet 4 | $3.00 / $15.00 | 3–8s | Complex reasoning, only when needed | +| Tier | Provider | Cost per 1M tokens | Latency | Used when | +| ------------- | ---------------------- | ------------------ | ------- | ----------------------------------- | +| T0 (free) | Local ollama/llama.cpp | $0.00 | 5–60s | Non-urgent, capability match | +| T1 (cheap) | DeepSeek V3 | $0.27 / $1.10 | 2–5s | Default for most tasks | +| T2 (balanced) | Gemini Flash | $0.15 / $0.60 | 1–3s | High cache-hit tasks | +| T3 (premium) | Claude Sonnet 4 | $3.00 / $15.00 | 3–8s | 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 | --- diff --git a/docs/wiki/index.md b/docs/wiki/index.md index fe8c530..3b3830d 100644 --- a/docs/wiki/index.md +++ b/docs/wiki/index.md @@ -53,21 +53,21 @@ warning. | [headroom-sidecar](./headroom-sidecar.md) | Optional tool-result compression sidecar and its Unix-socket protocol | | [jail-confinement](./jail-confinement.md) | Persistent vs ephemeral jails, priv-mode policy, reuse of spawner confinement for MCP servers | | [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-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 | -| [a2a-complexity-audit](./a2a-complexity-audit.md) | A2A code complexity impact — 6-protocol surface audit, when A2A pays off | +| [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 | | [layered-soul](./layered-soul.md) | How Colibri consumes the layered-soul reviewed-context repo today vs planned | | [task-board](./task-board.md) | Capability match scoring, cron scheduling, intake drain, SQLite backing | | [quality-gates](./quality-gates.md) | `ci-checks.sh` as the pre-merge gate; why drift reached `main` before | -| [contracts](./contracts.md) | Stable JSON schemas (run-manifest, runtime-inventory, provider-test), golden tests | +| [contracts](./contracts.md) | Stable JSON schemas (run-manifest, runtime-inventory, provider-test), golden tests | | [store-schema](./store-schema.md) | SQLite coordination schema and migration discipline | | [external-mcp](./external-mcp.md) | MCP bridge for editors + external stdio MCP host; read/write/external-call gates | | [operator-cli](./operator-cli.md) | The `colibri` CLI as a thin typed Unix-socket client over the daemon API | | [tui](./tui.md) | Terminal dashboard client (colibri-tui) vs the colibri-glasspane state machine | | [terminal](./terminal.md) | Terminal capability decision (Kitty, extended-key reporting, tmux passthrough, SSH terminfo) | | [runtime-inventory](./runtime-inventory.md) | Host runtime inventory + watchdog status reader; additive, read-only integrations | -| [skills-catalog](./skills-catalog.md) | Read-only runtime consumer for reviewed skill artifacts | +| [skills-catalog](./skills-catalog.md) | Read-only runtime consumer for reviewed skill artifacts | | [vault-provision](./vault-provision.md) | Vaultwarden-driven env-file provisioning into jails after agent spawn | | [deployment](./deployment.md) | Host installer (clawdie): ZFS layout, rc.d/systemd service, dry-run safety | diff --git a/docs/wiki/sl/contracts.md b/docs/wiki/sl/contracts.md index 10748c7..0ccf2b7 100644 --- a/docs/wiki/sl/contracts.md +++ b/docs/wiki/sl/contracts.md @@ -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`. diff --git a/docs/wiki/sl/index.md b/docs/wiki/sl/index.md index 847fa76..2426a1b 100644 --- a/docs/wiki/sl/index.md +++ b/docs/wiki/sl/index.md @@ -59,19 +59,19 @@ 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-pane](./hive-pane.md) | Steklena plošča za panj — opazovanje stroškov več vozlišč, odkrivanje A2A in operaterska nadzorna plošč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 | diff --git a/docs/wiki/tui.md b/docs/wiki/tui.md index 6073b4d..e3c9b96 100644 --- a/docs/wiki/tui.md +++ b/docs/wiki/tui.md @@ -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. diff --git a/scripts/check-format.sh b/scripts/check-format.sh index 9e22314..fff24df 100755 --- a/scripts/check-format.sh +++ b/scripts/check-format.sh @@ -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' -- 2.45.3 From e1fd602f33c15c961840b2d67d70acb1d0df0f20 Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Sat, 27 Jun 2026 17:57:24 +0200 Subject: [PATCH 8/8] docs(wiki): add sl/ cross-links for hive-routing + a2a-complexity-audit (Sam & Claude) --- docs/wiki/sl/index.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/wiki/sl/index.md b/docs/wiki/sl/index.md index 847fa76..9eec309 100644 --- a/docs/wiki/sl/index.md +++ b/docs/wiki/sl/index.md @@ -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 | -- 2.45.3