Normalize markdown formatting after the latest main updates.\n\nChecks: python3 scripts/layered_soul.py validate .; npx --yes prettier@3 --check '**/*.md'; git diff --check.
20 KiB
| name | description |
|---|---|
| colibri-development | Develop in the Colibri Rust workspace — add crates, run tests, write integration tests against glasspane/daemon/client APIs, build the TUI. |
Colibri Development
Trigger: user asks to work on /home/samob/ai/colibri — add a crate, write a test, build the TUI, or interact with the glasspane/daemon/client stack.
Workspace Layout (9 crates)
crates/
colibri-contracts — shared types, schemas, wire contracts
colibri-deepseek — DeepSeek API probe + cache key logic
colibri-runtime — runtime harness (pi, session, heartbeat)
colibri-glasspane — agent state machine, PiJsonlIngestor, GlasspaneSnapshot
colibri-daemon — always-on daemon: socket server, spawner, session manager,
scheduler, cost discipline
colibri-client — typed Unix-socket client for daemon (DaemonClient)
colibri-glasspane-tui — ratatui terminal dashboard (colibri-tui binary)
colibri-store — SQLite coordination store (task board, agents, skills)
colibri-skills — read-only skill consumer (scaffold, Phase 1 only)
(root) — colibri-probe + colibri-runtime-inventory binaries
Adding a New Crate
- Create
crates/<name>/Cargo.tomlwithedition = "2021". For binaries use[[bin]], for libraries use[lib]. - Add to workspace
Cargo.tomlmembers array (comma-separated, keep alphabetical). cargo build --package <name>to verify.cargo test --workspaceto confirm nothing broke.- Commit with
-c user.name="Sam & Hermes".
Pitfall: the lint tool runs syntax checks without reading Cargo.toml, so it may flag async fn as "not permitted in Rust 2015". Ignore these — the build with edition = "2021" works correctly.
Build & Test
cargo fmt --all # auto-format before committing
cargo build --workspace --release # full release build
cargo test --workspace # all tests (baseline: 151, 2026-06-04)
cargo clippy --workspace --all-targets -- -D warnings # must be clean before commit
cargo test --package colibri-store # specific crate
cargo build --package colibri-glasspane-tui --release # just the TUI binary
Pre-FreeBSD Linux check: Before handing off to the FreeBSD/OSA build host, run cargo build --workspace --release && cargo test --workspace on the Linux dev host (Debian, rustc 1.95+). This catches Rust-level compilation errors (missing enum variants, type mismatches, edition errors) that would also block the FreeBSD build — without needing FreeBSD tools. See references/linux-precheck.md.
Integration Test Patterns
Adding a coordination crate (store, scheduler, etc.)
See references/store-integration.md for the step-by-step integration pattern used for colibri-store: config extension, DaemonState wiring, Mutex for !Sync types, socket command dispatch, and updating all config constructors.
See references/scheduler-integration.md for the scheduler module pattern: cron/interval/one-shot jobs, leader/delegate capability matching, intake queue, and the 4th daemon loop tick.
See references/freebsd-daemon-smoke.md for the FreeBSD /tmp daemon smoke test pattern, rc.d service file conventions, and session report format.
Against PiJsonlIngestor (glasspane state machine)
The ingestor uses Default, not new(). It ingests line-by-line via ingest_line_at(line, system_time), not by whole JSONL string.
use colibri_glasspane::{PiJsonlIngestor, AgentState, GlasspaneSnapshot, Pane};
use std::time::SystemTime;
let mut ingestor = PiJsonlIngestor::default();
let t = SystemTime::now();
ingestor.ingest_line_at(r#"{"type":"session","id":"s1"}"#, t);
// state is now idle (session alone doesn't transition)
ingestor.ingest_line_at(r#"{"type":"turn_start"}"#, t);
assert_eq!(ingestor.state(), AgentState::Working);
Key types:
Pane { id, agent, state, pi_session_id, last_event_at, cwd, stalled }— noteidnotpane_id,pi_session_idnotsession_idGlasspaneSnapshot { schema, host, observed_at, panes }— useGlasspaneSnapshot::new(host, observed_at, panes)GlasspaneSnapshot::count(state)— count panes in a given stateGlasspaneSnapshot::stalled_count()— count stalled panes
Against DaemonClient (socket client)
use colibri_client::DaemonClient;
let client = DaemonClient::new("/tmp/colibri-daemon.sock");
let snapshot: GlasspaneSnapshot = client.glasspane_snapshot().await?;
Requires a running daemon or a mock UnixListener in tests.
TUI Binary
cargo run --bin colibri-tui -- /tmp/colibri-daemon.sock
Hotkeys: q/Esc quit, r manual refresh, j/k scroll pane list. Auto-refresh every 2s.
Common Pitfalls
-
edition = "2021"is mandatory in every crate'sCargo.toml— omitting it gives cryptic "async fn not permitted in Rust 2015" errors from rust-analyzer and the lint tool. These are false positives from the linter not readingCargo.toml; the actual build withedition = "2021"works correctly. -
PiJsonlIngestorhas nonew()— useDefault::default(). -
Panefield isid, notpane_id.GlasspaneSnapshotrequiresschema,host,observed_atin addition topanes. -
Serialized state values are lowercase (
"working", not"Working"). -
row_highlight_style(nothighlight_style) in ratatui 0.30 Table. -
Not all Pi event types map to state transitions.
tool_error, for example, keeps the current state. Unknown event types are silently ignored (forward-compatible). Seereferences/pi-event-state-mapping.mdfor the full event→state table. -
Integration tests go in
crates/<crate>/tests/<name>.rs— these compile as separate test binaries and are NOT run withcargo test --package <crate>unless explicitly targeted. -
rusqlite
!Sync:rusqlite::Connectioncontains aRefCelland is notSync. When adding aStore(or any type wrappingrusqlite::Connection) toDaemonState(which is behindArc), wrap it instd::sync::Mutex<Store>. All access requires.lock().unwrap(). -
daemon::run_loop must be started from main.rs: The scheduler tick, heartbeat, session rotation, and memory handoff all live inside
daemon::run_loop. Ifmain.rsonly spawns the socket server but never spawnsdaemon::run_loop, intake-task commands queue into the scheduler's in-memory buffer but never get processed into SQLite tasks. Always verify thatmain.rscalls bothsocket::serveANDdaemon::run_loopas separate tokio tasks. -
Scheduler store deadlock:
Scheduler::tick()must NOT holdstate.store.lock()across a branch that also tries to lock the store. The intake drain loop and job-firing loop both access the store — structure the code so the store lock is released before the next lock attempt. Iflist-taskstimes out afterintake-task, this is the root cause. -
tokio::sync::Mutex for async state: When a Mutex guard is held across
.awaitpoints (e.g. scheduler tick), usetokio::sync::Mutex— NOTstd::sync::Mutex. The latter blocks the async runtime thread. The store usesstd::sync::Mutexbecause its locks are always short-lived (no.awaitinside). -
Bash heredoc corruption: When writing shell scripts containing function definitions that collide with common command names (e.g.
sudo(),git()), do NOT useterminalwithcat > file <<'EOF'— the parent bash shell may have completions/aliases that rewrite the content before the heredoc is written. Usewrite_fileinstead to write literal bytes directly. -
SSH agent for git push: SSH agent does not survive tmux restarts. Run
ssh-add ~/.ssh/codeberg-clawdiebefore git push/pull. If agent is dead:eval $(ssh-agent -s) && ssh-add ~/.ssh/codeberg-clawdie. Codeberg SSH sometimes drops connections — retry. -
Accurate test counting:
cargo test --workspace -- --listovercounts — it includes test helper modules alongside actual#[test]functions. Always count fromcargo test --workspaceoutput: sum theN passednumbers from each crate'stest result:line. Do NOT use--listfor status reports or handoffs. -
DaemonConfig field additions: When adding a new field to
DaemonConfig, everytest_config()function in the codebase must be updated. Checksocket.rs,scheduler.rs,live_socket_smoke.rs, and any other test that constructsDaemonConfig { ... }. Missing fields causeerror[E0063]: missing fieldat build time. This bit us twice (db_path, cost_mode) — grep forDaemonConfig {before committing. -
Cost discipline (
cost.rs): Phase 5 module withCostModeenum (Fast/Smart/Max), escalation path, tool result compaction. Socket command:set-cost-mode. Config field:cost_mode: String(env:COLIBRI_COST_MODE, default"smart"). 9 unit tests in the module. Session thresholds change per mode:session_max_bytes()andmax_uncompacted_turns()vary by 4-16x between Fast and Max. -
UTF-8-safe string truncation:
compact_tool_resultoriginally sliced at a raw byte boundary (&raw[..max_bytes]), which panics when the cut lands inside a multi-byte UTF-8 character (a, o, s, c, z, CJK). FreeBSD tool output and agent logs regularly contain non-ASCII. Fix: usestr::floor_char_boundary(max_bytes)to round down to the nearest valid character boundary before slicing. Always add a multibyte test string — e.g."Cene ze se cesnje je".repeat(2000)for Slavic 2-byte chars (s/c/z),"aou".repeat(2000)for Germanic umlauts + CJK 3-byte chars. These catch both 2-byte and 3-byte boundary panics. -
colibri CLI
Commandenum sync: The binary atcrates/colibri-client/src/bin/colibri.rsdefinesCommandin four places that MUST stay in sync: (1) theenum Commanddefinition (variants + fields), (2) theparse_args()match arms that map CLI strings to enum variants, (3) therun()handler match that dispatches toDaemonClientmethods, and (4) theusage()function text. When adding a subcommand (e.g.list-skills,register-skill), all four locations must be updated. Forgetting the enum definition causes 6+ compilation errors because bothparse_args()andrun()reference variants that don't exist. Always build immediately after adding a subcommand:cargo build --package colibri-client --release.This also fires after
git pull— an upstream PR may add handlers inparse_args()andrun()without the enum variants. The FreeBSD ISO build may not catch this if the crate wasn't rebuilt (binaries staged from a prior build), so the first Linuxcargo buildafter a pull is the detection point. Example: PR #19 (fix/colibri-cli-list-skills) merged withListSkills/RegisterSkillhandlers wired in arg parser and run dispatcher, but the enum definition was missing — 151 other tests passed, build failed only oncolibri-client. Runcargo build --workspace --releaseafter every pull.
Post-Build Cleanup (mandatory)
After finishing a Colibri coding session AND BEFORE handing off to another agent:
cd ~/ai/colibri && cargo clean
rm -rf /tmp/colibri-daemon-socket-test-* /tmp/colibri-scheduler-test-* /tmp/colibri-live-smoke-* /tmp/colibri-osa-smoke-*
Why mandatory: cargo clean frees 6-8 GB of build artifacts. Stale /tmp test dirs accumulate ~34+ directories per session (socket tests create UUID-named temp dirs that tests don't always clean up on panic). Disk was at 91% before cleanup, 90% after. Leaving this for the next agent is disrespectful.
When to skip: If you're continuing work in the same tmux session within minutes — the build cache speeds up iteration. But clean before handoff, end-of-day, or if you won't rebuild within the hour.
This applies to ALL agents (Hermes, Claude, Codex) — the skill is shared.
Git Convention
git -c user.name="Sam & Hermes" -c user.email="hello@clawdie.si" commit -m "message"
Pre-commit hooks require Node.js (installed via nvm). When the hook fails:
export PATH="$HOME/.nvm/versions/node/v24.16.0/bin:$PATH"
git -c user.name="Sam & Hermes" -c user.email="hello@clawdie.si" commit -m "message"
If hooks still fail, --no-verify is acceptable — but the author and email MUST be set.
Cross-Repo Coordination
The Colibri repo has a sibling AGENTS.md at the repo root. When changing crate structure or adding integration contracts, update the design docs under docs/. The migration inventory (docs/MIGRATION-INVENTORY.md) tracks phase progress.
ISO integration workflow: see references/iso-integration.md for the ISO smoke checklist, rc.d conventions, cross-repo commit flow, and FreeBSD service layout.
Multi-agent handoff protocol: see references/multiagent-handoff.md for the handoff entry format, verification rules, relay pattern, platform split, and ISO build handoff specifics.
Pi spawn path testing
The spawn→JSONL→glasspane pipeline is proven via scripts/fake-pi-agent.py
and crates/colibri-daemon/tests/pi_spawn_live.rs. The fake agent emits the
colibri-pi-events JSONL taxonomy (session, agent_start, turn_start, turn_end,
agent_end). For real Pi binary testing (when installed): set
COLIBRI_PI_BINARY=pi and follow the same spawn pattern.
When writing spawn path tests:
- Script path:
CARGO_MANIFEST_DIR/../../scripts/fake-pi-agent.py(two.parent()calls from daemon crate) - Use
tokio::process::Commandfor spawning - Stream stdout via
BufReader::new(stdout).lines() - Verify
AgentState::Doneandpi_session_idin the snapshot - The test is at
tests/pi_spawn_live.rs— integration test, not unit
T1.4 — Cache-first prompt discipline
The prompt assembly pipeline lives in:
| Component | File | Purpose |
|---|---|---|
PromptAssembly |
session.rs |
3-region wrapper (immutable prefix, appendable log, volatile scratch) |
CacheMetrics |
session.rs |
Per-session cache hit/miss tracking |
CostMode |
cost.rs |
Fast/Smart/Max with per-mode budgets |
EscalationTrigger |
cost.rs |
BudgetExceeded + CompactionInsufficient |
trim_to_budget() |
session.rs |
Trim volatile scratch first, then oldest appendable entries |
auto_escalate() |
cost.rs |
Fast→Smart→Max escalation, ceiling at Max |
PR 1 (structs, no behavior change) and PR 2 (trimming + escalation) are merged. PR 3a (scheduler injection) and PR 3b (cache warming) are merged. All four T1.4 phases are complete on main.
Testing trim_to_budget: Use make_assembly(prefix_content, log_count, scratch_count)
helper that creates messages with ~10KB content each (via x.repeat(10000)). Each
message includes format!("msg {i} #N") to make them unique. Budget thresholds:
Fast=500K, Smart=2M, Max=8M. To exceed Fast budget, use at least 80 log entries
with 10KB content + large scratch. Verify with assert!(orig_bytes > CostMode::Fast.session_max_bytes())
before testing trim behavior.
Pitfall: The make_assembly helper in tests originally used format!("message {i}")
for content (9-11 chars) — this makes assemblies too small to trigger budget limits.
Always use a large repeat string like "x".repeat(10000) for the content.
T1.4 — Cache warming (PR3b, merged)
Fire-and-forget on daemon startup. Config-gated, disabled by default:
COLIBRI_CACHE_WARMING=0|1— enable (default: 0)COLIBRI_CACHE_WARMING_INTERVAL_HOURS=N— re-warm interval (0 = once)
warm_cache() is a sync fire-and-forget function — it spawns a tokio::spawn
task and returns immediately. Status exposed in cmd_status under cache_warming.
Pitfall: Box<dyn Error> is not Send. Change to Box<dyn Error + Send + Sync>
in colibri-deepseek/src/lib.rs one_call() return type.
Spawn-argv (merged)
HerdrCommand::SpawnAgent now accepts local_args: Option<Vec<String>>.
Enables Pi spawn without wrapper: local_args: ["--mode","json","--no-tools","-p","task"].
Backward-compatible. Update DaemonClient::spawn_agent in client lib.rs when
adding new fields.
ISO service hardening
The FreeBSD service is in packaging/freebsd/colibri_daemon.in. Key additions:
start_postcmd: waits up to 10s for socket via[ -S $socket ]loopstop_postcmd: removes stale socket from tmpfshealthextra command: probes socket withprintf '{"cmd":"status"}\n' | nc -U- Log rotation:
packaging/freebsd/newsyslog-colibri.conf(1MB, 7 archives) - Staging:
scripts/stage-colibri-iso.shcopies binaries + service + newsyslog config - Service layout documented in
docs/ISO-SERVICE-LAYOUT.md
Validate shell syntax with sh -n before committing rc.d or staging script changes.
daemon(8) rc.d pitfall — procname collision + pidfile mismatch
When an rc.d service wraps its foreground binary in daemon(8), three bugs commonly co-occur and cascade:
- procname collision:
procname="/usr/sbin/daemon"matches ANY daemon(8) instance on the system (tailscaled, etc.).service statusalways reports "not running" because_find_processesin rc.subr finds the wrong process. - Pidfile flag mismatch:
-Pwrites the supervisor PID (daemon itself), not the child PID. rc.subr can't match the child against the procname even if procname were correct. Use-p(child pidfile) instead. - Cascading bypass: When procname matches a generic daemon(8) path,
rc.subr may skip the daemon(8) wrapper entirely. The process tree becomes
rc.d → su → colibri-daemonwith no supervision, crash restart, or privilege dropping.
Fix (when keeping daemon(8)):
procname= the child binary's name (e.g."colibri-daemon"), never"/usr/sbin/daemon"-p ${pidfile}(lowercase, child PID) instead of-P(supervisor PID)- Keep
-r(restart),-u(user drop),-o(log redirection),-t(process title)
This preserves daemon(8)'s crash-restart safety while making rc.subr's
_find_processes target the correct process. Verified pattern from
COLIBRI-XFCE-HANDOFF-04.JUN.2026 live USB diagnostics.
Forgejo API merge workflow
Colibri repos on Forgejo have branch protection (no direct push to main). Instead of web UI, use API token for PR creation + merge:
source ~/.hermes/.env # loads FORGEJO_API_TOKEN
API="https://code.smilepowered.org/api/v1/repos/clawdie/colibri"
# Create PR
PR=$(curl -s -X POST -H "Authorization: token $FORGEJO_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"...","head":"branch-name","base":"main","body":"..."}' \
"$API/pulls")
NUM=$(echo "$PR" | python3 -c "import sys,json; print(json.load(sys.stdin)['number'])")
# Merge
curl -s -X POST -H "Authorization: token $FORGEJO_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"do":"merge"}' "$API/pulls/$NUM/merge"
Rule: No auto-merge until both Linux AND FreeBSD validations are posted.
The API token enables fast merges, but FreeBSD/OSA cargo test --workspace
must pass before merging any Colibri PR.
cmd_status now returns: paths (data_dir, db_path, socket_path), cost (mode, thresholds), tasks (counts by status), panes (count), scheduler (interval). When adding new daemon state fields, update cmd_status in socket.rs to expose them.
Cost thresholds in session rotation
session_rotation in daemon.rs reads CostMode thresholds (session_max_bytes(), max_uncompacted_turns()) instead of static DaemonConfig fields. Changing COLIBRI_COST_MODE (Fast/Smart/Max) affects compaction behavior immediately on next rotation tick.